From c6bd8be03092eabc7463fedfca944befd9d150c8 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Fri, 27 Mar 2026 09:59:20 +0000 Subject: [PATCH] chore: remove Python code and pyproject.toml --- pyproject.toml | 12 - sunbeam/Cargo.toml | 18 - sunbeam/__init__.py | 1 - sunbeam/__main__.py | 4 - sunbeam/checks.py | 336 --- sunbeam/cli.py | 375 --- sunbeam/cluster.py | 301 --- sunbeam/config.py | 96 - sunbeam/gitea.py | 259 -- sunbeam/images.py | 1049 -------- sunbeam/kube.py | 257 -- sunbeam/manifests.py | 437 ---- sunbeam/output.py | 47 - sunbeam/secrets.py | 978 ------- sunbeam/services.py | 237 -- sunbeam/src/cli.rs | 1011 -------- sunbeam/src/main.rs | 39 - sunbeam/tests/__init__.py | 0 sunbeam/tests/test_checks.py | 317 --- sunbeam/tests/test_cli.py | 850 ------- sunbeam/tests/test_kube.py | 108 - sunbeam/tests/test_manifests.py | 99 - sunbeam/tests/test_secrets.py | 93 - sunbeam/tests/test_services.py | 128 - sunbeam/tests/test_tools.py | 162 -- sunbeam/tools.py | 171 -- sunbeam/users.py | 528 ---- vendor/chumsky/examples/sample.py | 16 - vendor/unicode-width/scripts/unicode.py | 2250 ----------------- vendor/zerocopy/ci/validate_auto_approvers.py | 142 -- 30 files changed, 10321 deletions(-) delete mode 100644 pyproject.toml delete mode 100644 sunbeam/Cargo.toml delete mode 100644 sunbeam/__init__.py delete mode 100644 sunbeam/__main__.py delete mode 100644 sunbeam/checks.py delete mode 100644 sunbeam/cli.py delete mode 100644 sunbeam/cluster.py delete mode 100644 sunbeam/config.py delete mode 100644 sunbeam/gitea.py delete mode 100644 sunbeam/images.py delete mode 100644 sunbeam/kube.py delete mode 100644 sunbeam/manifests.py delete mode 100644 sunbeam/output.py delete mode 100644 sunbeam/secrets.py delete mode 100644 sunbeam/services.py delete mode 100644 sunbeam/src/cli.rs delete mode 100644 sunbeam/src/main.rs delete mode 100644 sunbeam/tests/__init__.py delete mode 100644 sunbeam/tests/test_checks.py delete mode 100644 sunbeam/tests/test_cli.py delete mode 100644 sunbeam/tests/test_kube.py delete mode 100644 sunbeam/tests/test_manifests.py delete mode 100644 sunbeam/tests/test_secrets.py delete mode 100644 sunbeam/tests/test_services.py delete mode 100644 sunbeam/tests/test_tools.py delete mode 100644 sunbeam/tools.py delete mode 100644 sunbeam/users.py delete mode 100644 vendor/chumsky/examples/sample.py delete mode 100755 vendor/unicode-width/scripts/unicode.py delete mode 100755 vendor/zerocopy/ci/validate_auto_approvers.py diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 41543e51..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "sunbeam" -version = "0.1.0" -requires-python = ">=3.11" -dependencies = ["setuptools"] - -[project.scripts] -sunbeam = "sunbeam.__main__:main" - -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" diff --git a/sunbeam/Cargo.toml b/sunbeam/Cargo.toml deleted file mode 100644 index 3732d3a4..00000000 --- a/sunbeam/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "sunbeam" -version = "1.1.2" -edition = "2024" -description = "Sunbeam Studios SDK, CLI, and ecosystem integrations" - -[[bin]] -name = "sunbeam" -path = "src/main.rs" - -[dependencies] -sunbeam-sdk = { path = "../sunbeam-sdk", features = ["all", "cli"] } -tokio = { version = "1", features = ["full"] } -clap = { version = "4", features = ["derive"] } -chrono = "0.4" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -rustls = { version = "0.23", features = ["ring"] } diff --git a/sunbeam/__init__.py b/sunbeam/__init__.py deleted file mode 100644 index 97093f97..00000000 --- a/sunbeam/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# sunbeam CLI package diff --git a/sunbeam/__main__.py b/sunbeam/__main__.py deleted file mode 100644 index 71d59be9..00000000 --- a/sunbeam/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from sunbeam.cli import main - -if __name__ == "__main__": - main() diff --git a/sunbeam/checks.py b/sunbeam/checks.py deleted file mode 100644 index da9fcde3..00000000 --- a/sunbeam/checks.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Service-level health checks — functional probes beyond pod readiness.""" -import base64 -import hashlib -import hmac -import json -import ssl -import subprocess -import urllib.error -import urllib.request -from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from sunbeam.kube import get_domain, kube_exec, kube_out, parse_target -from sunbeam.output import ok, step, warn - - -@dataclass -class CheckResult: - name: str - ns: str - svc: str - passed: bool - detail: str = "" - - -def _ssl_ctx() -> ssl.SSLContext: - """Return an SSL context that trusts the mkcert local CA if available.""" - ctx = ssl.create_default_context() - try: - r = subprocess.run(["mkcert", "-CAROOT"], capture_output=True, text=True) - if r.returncode == 0: - ca_file = Path(r.stdout.strip()) / "rootCA.pem" - if ca_file.exists(): - ctx.load_verify_locations(cafile=str(ca_file)) - except FileNotFoundError: - pass - return ctx - - -def _kube_secret(ns: str, name: str, key: str) -> str: - """Read a base64-encoded K8s secret value and return the decoded string.""" - raw = kube_out("get", "secret", name, "-n", ns, f"-o=jsonpath={{.data.{key}}}") - if not raw: - return "" - try: - return base64.b64decode(raw + "==").decode() - except Exception: - return "" - - -class _NoRedirect(urllib.request.HTTPRedirectHandler): - """Prevent urllib from following redirects so we can inspect the status code.""" - def redirect_request(self, req, fp, code, msg, headers, newurl): - return None - - -def _opener(ssl_ctx: ssl.SSLContext) -> urllib.request.OpenerDirector: - return urllib.request.build_opener( - _NoRedirect(), - urllib.request.HTTPSHandler(context=ssl_ctx), - ) - - -def _http_get(url: str, opener: urllib.request.OpenerDirector, *, - headers: dict | None = None, timeout: int = 5) -> tuple[int, bytes]: - """Return (status_code, body). Redirects are not followed. - - Any network/SSL error (including TimeoutError) is re-raised as URLError - so callers only need to catch urllib.error.URLError. - """ - req = urllib.request.Request(url, headers=headers or {}) - try: - with opener.open(req, timeout=timeout) as resp: - return resp.status, resp.read() - except urllib.error.HTTPError as e: - return e.code, b"" - except urllib.error.URLError: - raise - except OSError as e: - # TimeoutError and other socket/SSL errors don't always get wrapped - # in URLError by Python's urllib — normalize them here. - raise urllib.error.URLError(e) from e - - -# ── Individual checks ───────────────────────────────────────────────────────── - -def check_gitea_version(domain: str, opener) -> CheckResult: - """GET /api/v1/version -> JSON with version field.""" - url = f"https://src.{domain}/api/v1/version" - try: - status, body = _http_get(url, opener) - if status == 200: - ver = json.loads(body).get("version", "?") - return CheckResult("gitea-version", "devtools", "gitea", True, f"v{ver}") - return CheckResult("gitea-version", "devtools", "gitea", False, f"HTTP {status}") - except urllib.error.URLError as e: - return CheckResult("gitea-version", "devtools", "gitea", False, str(e.reason)) - - -def check_gitea_auth(domain: str, opener) -> CheckResult: - """GET /api/v1/user with admin credentials -> 200 and login field.""" - username = _kube_secret("devtools", "gitea-admin-credentials", "admin-username") or "gitea_admin" - password = _kube_secret("devtools", "gitea-admin-credentials", "admin-password") - if not password: - return CheckResult("gitea-auth", "devtools", "gitea", False, - "admin-password not found in secret") - creds = base64.b64encode(f"{username}:{password}".encode()).decode() - url = f"https://src.{domain}/api/v1/user" - try: - status, body = _http_get(url, opener, headers={"Authorization": f"Basic {creds}"}) - if status == 200: - login = json.loads(body).get("login", "?") - return CheckResult("gitea-auth", "devtools", "gitea", True, f"user={login}") - return CheckResult("gitea-auth", "devtools", "gitea", False, f"HTTP {status}") - except urllib.error.URLError as e: - return CheckResult("gitea-auth", "devtools", "gitea", False, str(e.reason)) - - -def check_postgres(domain: str, opener) -> CheckResult: - """CNPG Cluster readyInstances == instances.""" - ready = kube_out("get", "cluster", "postgres", "-n", "data", - "-o=jsonpath={.status.readyInstances}") - total = kube_out("get", "cluster", "postgres", "-n", "data", - "-o=jsonpath={.status.instances}") - if ready and total and ready == total: - return CheckResult("postgres", "data", "postgres", True, f"{ready}/{total} ready") - detail = (f"{ready or '?'}/{total or '?'} ready" - if (ready or total) else "cluster not found") - return CheckResult("postgres", "data", "postgres", False, detail) - - -def check_valkey(domain: str, opener) -> CheckResult: - """kubectl exec valkey pod -- valkey-cli ping -> PONG.""" - pod = kube_out("get", "pods", "-n", "data", "-l", "app=valkey", - "--no-headers", "-o=custom-columns=NAME:.metadata.name") - pod = pod.splitlines()[0].strip() if pod else "" - if not pod: - return CheckResult("valkey", "data", "valkey", False, "no valkey pod") - _, out = kube_exec("data", pod, "valkey-cli", "ping", container="valkey") - return CheckResult("valkey", "data", "valkey", out == "PONG", out or "no response") - - -def check_openbao(domain: str, opener) -> CheckResult: - """kubectl exec openbao-0 -- bao status -format=json -> initialized + unsealed.""" - rc, out = kube_exec("data", "openbao-0", "bao", "status", "-format=json", container="openbao") - if not out: - return CheckResult("openbao", "data", "openbao", False, "no response") - try: - data = json.loads(out) - init = data.get("initialized", False) - sealed = data.get("sealed", True) - return CheckResult("openbao", "data", "openbao", init and not sealed, - f"init={init}, sealed={sealed}") - except json.JSONDecodeError: - return CheckResult("openbao", "data", "openbao", False, out[:80]) - - -def _s3_auth_headers(access_key: str, secret_key: str, host: str) -> dict: - """Return Authorization + x-amz-date headers for an unsigned GET / S3 request.""" - t = datetime.now(tz=timezone.utc) - amzdate = t.strftime("%Y%m%dT%H%M%SZ") - datestamp = t.strftime("%Y%m%d") - - payload_hash = hashlib.sha256(b"").hexdigest() - canonical = f"GET\n/\n\nhost:{host}\nx-amz-date:{amzdate}\n\nhost;x-amz-date\n{payload_hash}" - credential_scope = f"{datestamp}/us-east-1/s3/aws4_request" - string_to_sign = ( - f"AWS4-HMAC-SHA256\n{amzdate}\n{credential_scope}\n" - f"{hashlib.sha256(canonical.encode()).hexdigest()}" - ) - - def _sign(key: bytes, msg: str) -> bytes: - return hmac.new(key, msg.encode(), hashlib.sha256).digest() - - k = _sign(f"AWS4{secret_key}".encode(), datestamp) - k = _sign(k, "us-east-1") - k = _sign(k, "s3") - k = _sign(k, "aws4_request") - sig = hmac.new(k, string_to_sign.encode(), hashlib.sha256).hexdigest() - - auth = ( - f"AWS4-HMAC-SHA256 Credential={access_key}/{credential_scope}," - f" SignedHeaders=host;x-amz-date, Signature={sig}" - ) - return {"Authorization": auth, "x-amz-date": amzdate} - - -def check_seaweedfs(domain: str, opener) -> CheckResult: - """GET https://s3.{domain}/ with S3 credentials -> 200 list-buckets response.""" - access_key = _kube_secret("storage", "seaweedfs-s3-credentials", "S3_ACCESS_KEY") - secret_key = _kube_secret("storage", "seaweedfs-s3-credentials", "S3_SECRET_KEY") - if not access_key or not secret_key: - return CheckResult("seaweedfs", "storage", "seaweedfs", False, - "credentials not found in seaweedfs-s3-credentials secret") - - host = f"s3.{domain}" - url = f"https://{host}/" - headers = _s3_auth_headers(access_key, secret_key, host) - try: - status, _ = _http_get(url, opener, headers=headers) - if status == 200: - return CheckResult("seaweedfs", "storage", "seaweedfs", True, "S3 authenticated") - return CheckResult("seaweedfs", "storage", "seaweedfs", False, f"HTTP {status}") - except urllib.error.URLError as e: - return CheckResult("seaweedfs", "storage", "seaweedfs", False, str(e.reason)) - - -def check_kratos(domain: str, opener) -> CheckResult: - """GET /kratos/health/ready -> 200.""" - url = f"https://auth.{domain}/kratos/health/ready" - try: - status, body = _http_get(url, opener) - ok_flag = status == 200 - detail = f"HTTP {status}" - if not ok_flag and body: - detail += f": {body.decode(errors='replace')[:80]}" - return CheckResult("kratos", "ory", "kratos", ok_flag, detail) - except urllib.error.URLError as e: - return CheckResult("kratos", "ory", "kratos", False, str(e.reason)) - - -def check_hydra_oidc(domain: str, opener) -> CheckResult: - """GET /.well-known/openid-configuration -> 200 with issuer field.""" - url = f"https://auth.{domain}/.well-known/openid-configuration" - try: - status, body = _http_get(url, opener) - if status == 200: - issuer = json.loads(body).get("issuer", "?") - return CheckResult("hydra-oidc", "ory", "hydra", True, f"issuer={issuer}") - return CheckResult("hydra-oidc", "ory", "hydra", False, f"HTTP {status}") - except urllib.error.URLError as e: - return CheckResult("hydra-oidc", "ory", "hydra", False, str(e.reason)) - - -def check_people(domain: str, opener) -> CheckResult: - """GET https://people.{domain}/ -> any response < 500 (302 to OIDC is fine).""" - url = f"https://people.{domain}/" - try: - status, _ = _http_get(url, opener) - return CheckResult("people", "lasuite", "people", status < 500, f"HTTP {status}") - except urllib.error.URLError as e: - return CheckResult("people", "lasuite", "people", False, str(e.reason)) - - -def check_people_api(domain: str, opener) -> CheckResult: - """GET /api/v1.0/config/ -> any response < 500 (401 auth-required is fine).""" - url = f"https://people.{domain}/api/v1.0/config/" - try: - status, _ = _http_get(url, opener) - return CheckResult("people-api", "lasuite", "people", status < 500, f"HTTP {status}") - except urllib.error.URLError as e: - return CheckResult("people-api", "lasuite", "people", False, str(e.reason)) - - -def check_livekit(domain: str, opener) -> CheckResult: - """kubectl exec livekit-server pod -- wget localhost:7880/ -> rc 0.""" - pod = kube_out("get", "pods", "-n", "media", "-l", "app.kubernetes.io/name=livekit-server", - "--no-headers", "-o=custom-columns=NAME:.metadata.name") - pod = pod.splitlines()[0].strip() if pod else "" - if not pod: - return CheckResult("livekit", "media", "livekit", False, "no livekit pod") - rc, _ = kube_exec("media", pod, "wget", "-qO-", "http://localhost:7880/") - if rc == 0: - return CheckResult("livekit", "media", "livekit", True, "server responding") - return CheckResult("livekit", "media", "livekit", False, "server not responding") - - -# ── Check registry ──────────────────────────────────────────────────────────── - -CHECKS: list[tuple[Any, str, str]] = [ - (check_gitea_version, "devtools", "gitea"), - (check_gitea_auth, "devtools", "gitea"), - (check_postgres, "data", "postgres"), - (check_valkey, "data", "valkey"), - (check_openbao, "data", "openbao"), - (check_seaweedfs, "storage", "seaweedfs"), - (check_kratos, "ory", "kratos"), - (check_hydra_oidc, "ory", "hydra"), - (check_people, "lasuite", "people"), - (check_people_api, "lasuite", "people"), - (check_livekit, "media", "livekit"), -] - - -def _run_one(fn, domain: str, op, ns: str, svc: str) -> CheckResult: - try: - return fn(domain, op) - except Exception as e: - return CheckResult(fn.__name__.replace("check_", ""), ns, svc, False, str(e)[:80]) - - -def cmd_check(target: str | None) -> None: - """Run service-level health checks, optionally scoped to a namespace or service.""" - step("Service health checks...") - - domain = get_domain() - ssl_ctx = _ssl_ctx() - op = _opener(ssl_ctx) - - ns_filter, svc_filter = parse_target(target) if target else (None, None) - selected = [ - (fn, ns, svc) for fn, ns, svc in CHECKS - if (ns_filter is None or ns == ns_filter) - and (svc_filter is None or svc == svc_filter) - ] - - if not selected: - warn(f"No checks match target: {target}") - return - - # Run all checks concurrently — total time ≈ slowest single check. - with ThreadPoolExecutor(max_workers=len(selected)) as pool: - futures = [pool.submit(_run_one, fn, domain, op, ns, svc) - for fn, ns, svc in selected] - results = [f.result() for f in futures] - - # Print grouped by namespace (mirrors sunbeam status layout). - name_w = max(len(r.name) for r in results) - cur_ns = None - for r in results: - if r.ns != cur_ns: - print(f" {r.ns}:") - cur_ns = r.ns - icon = "\u2713" if r.passed else "\u2717" - detail = f" {r.detail}" if r.detail else "" - print(f" {icon} {r.name:<{name_w}}{detail}") - - print() - failed = [r for r in results if not r.passed] - if failed: - warn(f"{len(failed)} check(s) failed.") - else: - ok(f"All {len(results)} check(s) passed.") diff --git a/sunbeam/cli.py b/sunbeam/cli.py deleted file mode 100644 index c7526810..00000000 --- a/sunbeam/cli.py +++ /dev/null @@ -1,375 +0,0 @@ -"""CLI entry point — argparse dispatch table for all sunbeam verbs.""" -import argparse -import datetime -import sys - - -def _date_type(value): - """Validate YYYY-MM-DD date format for argparse.""" - if not value: - return value - try: - datetime.date.fromisoformat(value) - except ValueError: - raise argparse.ArgumentTypeError(f"Invalid date: {value!r} (expected YYYY-MM-DD)") - return value - - -ENV_CONTEXTS = { - "local": "sunbeam", - "production": "production", -} - - -def main() -> None: - parser = argparse.ArgumentParser( - prog="sunbeam", - description="Sunbeam local dev stack manager", - ) - parser.add_argument( - "--env", choices=["local", "production"], default="local", - help="Target environment (default: local)", - ) - parser.add_argument( - "--context", default=None, - help="kubectl context override (default: sunbeam for local, default for production)", - ) - parser.add_argument( - "--domain", default="", - help="Domain suffix for production deploys (e.g. sunbeam.pt)", - ) - parser.add_argument( - "--email", default="", - help="ACME email for cert-manager (e.g. ops@sunbeam.pt)", - ) - - sub = parser.add_subparsers(dest="verb", metavar="verb") - - # sunbeam up - sub.add_parser("up", help="Full cluster bring-up") - - # sunbeam down - sub.add_parser("down", help="Tear down Lima VM") - - # sunbeam status [ns[/name]] - p_status = sub.add_parser("status", help="Pod health (optionally scoped)") - p_status.add_argument("target", nargs="?", default=None, - help="namespace or namespace/name") - - # sunbeam apply [namespace] - p_apply = sub.add_parser("apply", help="kustomize build + domain subst + kubectl apply") - p_apply.add_argument("namespace", nargs="?", default="", - help="Limit apply to one namespace (e.g. lasuite, ingress, ory)") - p_apply.add_argument("--all", action="store_true", dest="apply_all", - help="Apply all namespaces without confirmation") - p_apply.add_argument("--domain", default="", help="Domain suffix (e.g. sunbeam.pt)") - p_apply.add_argument("--email", default="", help="ACME email for cert-manager") - - # sunbeam seed - sub.add_parser("seed", help="Generate/store all credentials in OpenBao") - - # sunbeam verify - sub.add_parser("verify", help="E2E VSO + OpenBao integration test") - - # sunbeam logs [-f] - p_logs = sub.add_parser("logs", help="kubectl logs for a service") - p_logs.add_argument("target", help="namespace/name") - p_logs.add_argument("-f", "--follow", action="store_true", - help="Stream logs (--follow)") - - # sunbeam get [-o yaml|json|wide] - p_get = sub.add_parser("get", help="Raw kubectl get for a pod (ns/name)") - p_get.add_argument("target", help="namespace/name") - p_get.add_argument("-o", "--output", default="yaml", - choices=["yaml", "json", "wide"], - help="Output format (default: yaml)") - - # sunbeam restart [ns[/name]] - p_restart = sub.add_parser("restart", help="Rolling restart of services") - p_restart.add_argument("target", nargs="?", default=None, - help="namespace or namespace/name") - - # sunbeam build [--push] [--deploy] - p_build = sub.add_parser("build", help="Build an artifact (add --push to push, --deploy to apply+rollout)") - p_build.add_argument("what", - choices=["proxy", "integration", "kratos-admin", "meet", - "docs-frontend", "people-frontend", "people", - "messages", "messages-backend", "messages-frontend", - "messages-mta-in", "messages-mta-out", - "messages-mpa", "messages-socks-proxy", - "tuwunel", "calendars", "projects", "sol"], - help="What to build") - p_build.add_argument("--push", action="store_true", - help="Push image to registry after building") - p_build.add_argument("--deploy", action="store_true", - help="Apply manifests and rollout restart after pushing (implies --push)") - p_build.add_argument("--no-cache", action="store_true", - help="Disable buildkitd layer cache") - - # sunbeam check [ns[/name]] - p_check = sub.add_parser("check", help="Functional service health checks") - p_check.add_argument("target", nargs="?", default=None, - help="namespace or namespace/name") - - # sunbeam mirror - sub.add_parser("mirror", help="Mirror amd64-only La Suite images") - - # sunbeam bootstrap - sub.add_parser("bootstrap", help="Create Gitea orgs/repos; set up Lima registry") - - # sunbeam config [args] - p_config = sub.add_parser("config", help="Manage sunbeam configuration") - config_sub = p_config.add_subparsers(dest="config_action", metavar="action") - - # sunbeam config set --host HOST --infra-dir DIR --acme-email EMAIL - p_config_set = config_sub.add_parser("set", help="Set configuration values") - p_config_set.add_argument("--host", default="", - help="Production SSH host (e.g. user@server.example.com)") - p_config_set.add_argument("--infra-dir", default="", - help="Infrastructure directory root") - p_config_set.add_argument("--acme-email", default="", - help="ACME email for Let's Encrypt certificates (e.g. ops@sunbeam.pt)") - - # sunbeam config get - config_sub.add_parser("get", help="Get current configuration") - - # sunbeam config clear - config_sub.add_parser("clear", help="Clear configuration") - - # sunbeam k8s [kubectl args...] — transparent kubectl --context=sunbeam wrapper - p_k8s = sub.add_parser("k8s", help="kubectl --context=sunbeam passthrough") - p_k8s.add_argument("kubectl_args", nargs=argparse.REMAINDER, - help="arguments forwarded verbatim to kubectl") - - # sunbeam bao [bao args...] — bao CLI inside OpenBao pod with root token injected - p_bao = sub.add_parser("bao", help="bao CLI passthrough (runs inside OpenBao pod with root token)") - p_bao.add_argument("bao_args", nargs=argparse.REMAINDER, - help="arguments forwarded verbatim to bao") - - # sunbeam user [args] - p_user = sub.add_parser("user", help="User/identity management") - user_sub = p_user.add_subparsers(dest="user_action", metavar="action") - - p_user_list = user_sub.add_parser("list", help="List identities") - p_user_list.add_argument("--search", default="", help="Filter by email") - - p_user_get = user_sub.add_parser("get", help="Get identity by email or ID") - p_user_get.add_argument("target", help="Email or identity ID") - - p_user_create = user_sub.add_parser("create", help="Create identity") - p_user_create.add_argument("email", help="Email address") - p_user_create.add_argument("--name", default="", help="Display name") - p_user_create.add_argument("--schema", default="default", help="Schema ID") - - p_user_delete = user_sub.add_parser("delete", help="Delete identity") - p_user_delete.add_argument("target", help="Email or identity ID") - - p_user_recover = user_sub.add_parser("recover", help="Generate recovery link") - p_user_recover.add_argument("target", help="Email or identity ID") - - p_user_disable = user_sub.add_parser("disable", help="Disable identity + revoke sessions (lockout)") - p_user_disable.add_argument("target", help="Email or identity ID") - - p_user_enable = user_sub.add_parser("enable", help="Re-enable a disabled identity") - p_user_enable.add_argument("target", help="Email or identity ID") - - p_user_set_pw = user_sub.add_parser("set-password", help="Set password for an identity") - p_user_set_pw.add_argument("target", help="Email or identity ID") - p_user_set_pw.add_argument("password", help="New password") - - p_user_onboard = user_sub.add_parser("onboard", help="Onboard new user (create + welcome email)") - p_user_onboard.add_argument("email", help="Email address") - p_user_onboard.add_argument("--name", default="", help="Display name (First Last)") - p_user_onboard.add_argument("--schema", default="employee", help="Schema ID (default: employee)") - p_user_onboard.add_argument("--no-email", action="store_true", help="Skip sending welcome email") - p_user_onboard.add_argument("--notify", default="", help="Send welcome email to this address instead of identity email") - p_user_onboard.add_argument("--job-title", default="", help="Job title") - p_user_onboard.add_argument("--department", default="", help="Department") - p_user_onboard.add_argument("--office-location", default="", help="Office location") - p_user_onboard.add_argument("--hire-date", default="", type=_date_type, help="Hire date (YYYY-MM-DD)") - p_user_onboard.add_argument("--manager", default="", help="Manager name or email") - - p_user_offboard = user_sub.add_parser("offboard", help="Offboard user (disable + revoke all)") - p_user_offboard.add_argument("target", help="Email or identity ID") - - args = parser.parse_args() - - - - # Set kubectl context before any kube calls. - # For production, also register the SSH host so the tunnel is opened on demand. - # SUNBEAM_SSH_HOST env var: e.g. "user@server.example.com" or just "server.example.com" - import os - from sunbeam.kube import set_context - from sunbeam.config import get_production_host - - ctx = args.context or ENV_CONTEXTS.get(args.env, "sunbeam") - ssh_host = "" - if args.env == "production": - ssh_host = get_production_host() - if not ssh_host: - from sunbeam.output import die - die("Production host not configured. Use --host to set it or set SUNBEAM_SSH_HOST environment variable.") - set_context(ctx, ssh_host=ssh_host) - - if args.verb is None: - parser.print_help() - sys.exit(0) - - # Lazy imports to keep startup fast - if args.verb == "up": - from sunbeam.cluster import cmd_up - cmd_up() - - elif args.verb == "down": - from sunbeam.cluster import cmd_down - cmd_down() - - elif args.verb == "status": - from sunbeam.services import cmd_status - cmd_status(args.target) - - elif args.verb == "apply": - from sunbeam.manifests import cmd_apply, MANAGED_NS - # --domain/--email can appear before OR after the verb; subparser wins if both set. - domain = getattr(args, "domain", "") or "" - email = getattr(args, "email", "") or "" - namespace = getattr(args, "namespace", "") or "" - apply_all = getattr(args, "apply_all", False) - - # Full apply on production requires --all or interactive confirmation - if args.env == "production" and not namespace and not apply_all: - from sunbeam.output import warn - warn(f"This will apply ALL namespaces ({', '.join(MANAGED_NS)}) to production.") - try: - answer = input(" Continue? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - if answer not in ("y", "yes"): - print("Aborted.") - sys.exit(0) - - cmd_apply(env=args.env, domain=domain, email=email, namespace=namespace) - - elif args.verb == "seed": - from sunbeam.secrets import cmd_seed - cmd_seed() - - elif args.verb == "verify": - from sunbeam.secrets import cmd_verify - cmd_verify() - - elif args.verb == "logs": - from sunbeam.services import cmd_logs - cmd_logs(args.target, follow=args.follow) - - elif args.verb == "get": - from sunbeam.services import cmd_get - cmd_get(args.target, output=args.output) - - elif args.verb == "restart": - from sunbeam.services import cmd_restart - cmd_restart(args.target) - - elif args.verb == "build": - from sunbeam.images import cmd_build - push = args.push or args.deploy - cmd_build(args.what, push=push, deploy=args.deploy, no_cache=args.no_cache) - - elif args.verb == "check": - from sunbeam.checks import cmd_check - cmd_check(args.target) - - elif args.verb == "mirror": - from sunbeam.images import cmd_mirror - cmd_mirror() - - elif args.verb == "bootstrap": - from sunbeam.gitea import cmd_bootstrap - cmd_bootstrap() - - elif args.verb == "config": - from sunbeam.config import ( - SunbeamConfig, load_config, save_config, get_production_host, get_infra_directory - ) - action = getattr(args, "config_action", None) - if action is None: - p_config.print_help() - sys.exit(0) - elif action == "set": - config = load_config() - if args.host: - config.production_host = args.host - if args.infra_dir: - config.infra_directory = args.infra_dir - if args.acme_email: - config.acme_email = args.acme_email - save_config(config) - elif action == "get": - from sunbeam.output import ok - config = load_config() - ok(f"Production host: {config.production_host or '(not set)'}") - ok(f"Infrastructure directory: {config.infra_directory or '(not set)'}") - ok(f"ACME email: {config.acme_email or '(not set)'}") - - # Also show effective production host (from config or env) - effective_host = get_production_host() - if effective_host: - ok(f"Effective production host: {effective_host}") - elif action == "clear": - import os - config_path = os.path.expanduser("~/.sunbeam.json") - if os.path.exists(config_path): - os.remove(config_path) - from sunbeam.output import ok - ok(f"Configuration cleared from {config_path}") - else: - from sunbeam.output import warn - warn("No configuration file found to clear") - - elif args.verb == "k8s": - from sunbeam.kube import cmd_k8s - sys.exit(cmd_k8s(args.kubectl_args)) - - elif args.verb == "bao": - from sunbeam.kube import cmd_bao - sys.exit(cmd_bao(args.bao_args)) - - elif args.verb == "user": - from sunbeam.users import (cmd_user_list, cmd_user_get, cmd_user_create, - cmd_user_delete, cmd_user_recover, - cmd_user_disable, cmd_user_enable, - cmd_user_set_password, - cmd_user_onboard, cmd_user_offboard) - action = getattr(args, "user_action", None) - if action is None: - p_user.print_help() - sys.exit(0) - elif action == "list": - cmd_user_list(search=args.search) - elif action == "get": - cmd_user_get(args.target) - elif action == "create": - cmd_user_create(args.email, name=args.name, schema_id=args.schema) - elif action == "delete": - cmd_user_delete(args.target) - elif action == "recover": - cmd_user_recover(args.target) - elif action == "disable": - cmd_user_disable(args.target) - elif action == "enable": - cmd_user_enable(args.target) - elif action == "set-password": - cmd_user_set_password(args.target, args.password) - elif action == "onboard": - cmd_user_onboard(args.email, name=args.name, schema_id=args.schema, - send_email=not args.no_email, notify=args.notify, - job_title=args.job_title, department=args.department, - office_location=args.office_location, - hire_date=args.hire_date, manager=args.manager) - elif action == "offboard": - cmd_user_offboard(args.target) - - else: - parser.print_help() - sys.exit(1) diff --git a/sunbeam/cluster.py b/sunbeam/cluster.py deleted file mode 100644 index 325f39eb..00000000 --- a/sunbeam/cluster.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Cluster lifecycle — Lima VM, kubeconfig, Linkerd, TLS, core service readiness.""" -import base64 -import json -import shutil -import subprocess -import time -from pathlib import Path - -from sunbeam.kube import (kube, kube_out, kube_ok, kube_apply, - kustomize_build, get_lima_ip, ensure_ns, create_secret, ns_exists) -from sunbeam.tools import run_tool, CACHE_DIR -from sunbeam.output import step, ok, warn, die - -LIMA_VM = "sunbeam" -from sunbeam.config import get_infra_dir as _get_infra_dir -SECRETS_DIR = _get_infra_dir() / "secrets" / "local" - -GITEA_ADMIN_USER = "gitea_admin" - - -# --------------------------------------------------------------------------- -# Lima VM -# --------------------------------------------------------------------------- - -def _lima_status() -> str: - """Return the Lima VM status, handling both JSON-array and NDJSON output.""" - r = subprocess.run(["limactl", "list", "--json"], - capture_output=True, text=True) - raw = r.stdout.strip() if r.returncode == 0 else "" - if not raw: - return "none" - vms: list[dict] = [] - try: - parsed = json.loads(raw) - vms = parsed if isinstance(parsed, list) else [parsed] - except json.JSONDecodeError: - for line in raw.splitlines(): - line = line.strip() - if not line: - continue - try: - vms.append(json.loads(line)) - except json.JSONDecodeError: - continue - for vm in vms: - if vm.get("name") == LIMA_VM: - return vm.get("status", "unknown") - return "none" - - -def ensure_lima_vm(): - step("Lima VM...") - status = _lima_status() - if status == "none": - ok("Creating 'sunbeam' (k3s 6 CPU / 12 GB / 60 GB)...") - subprocess.run( - ["limactl", "start", - "--name=sunbeam", "template:k3s", - "--memory=12", "--cpus=6", "--disk=60", - "--vm-type=vz", "--mount-type=virtiofs", - "--rosetta"], - check=True, - ) - elif status == "Running": - ok("Already running.") - else: - ok(f"Starting (current status: {status})...") - subprocess.run(["limactl", "start", LIMA_VM], check=True) - - -# --------------------------------------------------------------------------- -# Kubeconfig -# --------------------------------------------------------------------------- - -def merge_kubeconfig(): - step("Merging kubeconfig...") - lima_kube = Path.home() / f".lima/{LIMA_VM}/copied-from-guest/kubeconfig.yaml" - if not lima_kube.exists(): - die(f"Lima kubeconfig not found: {lima_kube}") - - tmp = Path("/tmp/sunbeam-kube") - tmp.mkdir(exist_ok=True) - try: - for query, filename in [ - (".clusters[0].cluster.certificate-authority-data", "ca.crt"), - (".users[0].user.client-certificate-data", "client.crt"), - (".users[0].user.client-key-data", "client.key"), - ]: - r = subprocess.run(["yq", query, str(lima_kube)], - capture_output=True, text=True) - b64 = r.stdout.strip() if r.returncode == 0 else "" - (tmp / filename).write_bytes(base64.b64decode(b64)) - - subprocess.run( - ["kubectl", "config", "set-cluster", LIMA_VM, - "--server=https://127.0.0.1:6443", - f"--certificate-authority={tmp}/ca.crt", "--embed-certs=true"], - check=True, - ) - subprocess.run( - ["kubectl", "config", "set-credentials", f"{LIMA_VM}-admin", - f"--client-certificate={tmp}/client.crt", - f"--client-key={tmp}/client.key", "--embed-certs=true"], - check=True, - ) - subprocess.run( - ["kubectl", "config", "set-context", LIMA_VM, - f"--cluster={LIMA_VM}", f"--user={LIMA_VM}-admin"], - check=True, - ) - finally: - shutil.rmtree(tmp, ignore_errors=True) - ok("Context 'sunbeam' ready.") - - -# --------------------------------------------------------------------------- -# Traefik -# --------------------------------------------------------------------------- - -def disable_traefik(): - step("Traefik...") - if kube_ok("get", "helmchart", "traefik", "-n", "kube-system"): - ok("Removing (replaced by Pingora)...") - kube("delete", "helmchart", "traefik", "traefik-crd", - "-n", "kube-system", check=False) - subprocess.run( - ["limactl", "shell", LIMA_VM, - "sudo", "rm", "-f", - "/var/lib/rancher/k3s/server/manifests/traefik.yaml"], - capture_output=True, - ) - # Write k3s config so Traefik can never return after a k3s restart. - subprocess.run( - ["limactl", "shell", LIMA_VM, "sudo", "tee", - "/etc/rancher/k3s/config.yaml"], - input="disable:\n - traefik\n", - text=True, - capture_output=True, - ) - ok("Done.") - - -# --------------------------------------------------------------------------- -# cert-manager -# --------------------------------------------------------------------------- - -def ensure_cert_manager(): - step("cert-manager...") - if ns_exists("cert-manager"): - ok("Already installed.") - return - ok("Installing...") - kube("apply", "-f", - "https://github.com/cert-manager/cert-manager/releases/download/v1.17.0/cert-manager.yaml") - for dep in ["cert-manager", "cert-manager-webhook", "cert-manager-cainjector"]: - kube("rollout", "status", f"deployment/{dep}", - "-n", "cert-manager", "--timeout=120s") - ok("Installed.") - - -# --------------------------------------------------------------------------- -# Linkerd -# --------------------------------------------------------------------------- - -def ensure_linkerd(): - step("Linkerd...") - if ns_exists("linkerd"): - ok("Already installed.") - return - ok("Installing Gateway API CRDs...") - kube("apply", "--server-side", "-f", - "https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml") - ok("Installing Linkerd CRDs...") - r = subprocess.run(["linkerd", "install", "--crds"], - capture_output=True, text=True) - crds = r.stdout.strip() if r.returncode == 0 else "" - kube_apply(crds) - ok("Installing Linkerd control plane...") - r = subprocess.run(["linkerd", "install"], - capture_output=True, text=True) - cp = r.stdout.strip() if r.returncode == 0 else "" - kube_apply(cp) - for dep in ["linkerd-identity", "linkerd-destination", "linkerd-proxy-injector"]: - kube("rollout", "status", f"deployment/{dep}", - "-n", "linkerd", "--timeout=120s") - ok("Installed.") - - -# --------------------------------------------------------------------------- -# TLS certificate -# --------------------------------------------------------------------------- - -def ensure_tls_cert(domain: str | None = None) -> str: - step("TLS certificate...") - ip = get_lima_ip() - if domain is None: - domain = f"{ip}.sslip.io" - cert = SECRETS_DIR / "tls.crt" - if cert.exists(): - ok(f"Cert exists. Domain: {domain}") - return domain - ok(f"Generating wildcard cert for *.{domain}...") - SECRETS_DIR.mkdir(parents=True, exist_ok=True) - subprocess.run(["mkcert", f"*.{domain}"], cwd=SECRETS_DIR, check=True) - for src, dst in [ - (f"_wildcard.{domain}.pem", "tls.crt"), - (f"_wildcard.{domain}-key.pem", "tls.key"), - ]: - (SECRETS_DIR / src).rename(SECRETS_DIR / dst) - ok(f"Cert generated. Domain: {domain}") - return domain - - -# --------------------------------------------------------------------------- -# TLS secret -# --------------------------------------------------------------------------- - -def ensure_tls_secret(domain: str): - step("TLS secret...") - ensure_ns("ingress") - manifest = kube_out( - "create", "secret", "tls", "pingora-tls", - f"--cert={SECRETS_DIR}/tls.crt", - f"--key={SECRETS_DIR}/tls.key", - "-n", "ingress", - "--dry-run=client", "-o=yaml", - ) - if manifest: - kube_apply(manifest) - ok("Done.") - - -# --------------------------------------------------------------------------- -# Wait for core -# --------------------------------------------------------------------------- - -def wait_for_core(): - step("Waiting for core services...") - for ns, dep in [("data", "valkey"), ("ory", "kratos"), ("ory", "hydra")]: - kube("rollout", "status", f"deployment/{dep}", - "-n", ns, "--timeout=120s", check=False) - ok("Core services ready.") - - -# --------------------------------------------------------------------------- -# Print URLs -# --------------------------------------------------------------------------- - -def print_urls(domain: str, gitea_admin_pass: str = ""): - print(f"\n{'─' * 60}") - print(f" Stack is up. Domain: {domain}") - print(f"{'─' * 60}") - for name, url in [ - ("Auth", f"https://auth.{domain}/"), - ("Docs", f"https://docs.{domain}/"), - ("Meet", f"https://meet.{domain}/"), - ("Drive", f"https://drive.{domain}/"), - ("Chat", f"https://chat.{domain}/"), - ("Mail", f"https://mail.{domain}/"), - ("People", f"https://people.{domain}/"), - ("Gitea", f"https://src.{domain}/ ({GITEA_ADMIN_USER} / {gitea_admin_pass})"), - ]: - print(f" {name:<10} {url}") - print() - print(" OpenBao UI:") - print(f" kubectl --context=sunbeam -n data port-forward svc/openbao 8200:8200") - print(f" http://localhost:8200") - token_cmd = "kubectl --context=sunbeam -n data get secret openbao-keys -o jsonpath='{.data.root-token}' | base64 -d" - print(f" token: {token_cmd}") - print(f"{'─' * 60}\n") - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - -def cmd_up(): - from sunbeam.manifests import cmd_apply - from sunbeam.secrets import cmd_seed - from sunbeam.gitea import cmd_bootstrap, setup_lima_vm_registry - from sunbeam.images import cmd_mirror - - ensure_lima_vm() - merge_kubeconfig() - disable_traefik() - ensure_cert_manager() - ensure_linkerd() - domain = ensure_tls_cert() - ensure_tls_secret(domain) - cmd_apply() - creds = cmd_seed() - admin_pass = creds.get("gitea-admin-password", "") if isinstance(creds, dict) else "" - setup_lima_vm_registry(domain, admin_pass) - cmd_bootstrap() - cmd_mirror() - wait_for_core() - print_urls(domain, admin_pass) - - -def cmd_down(): - subprocess.run(["limactl", "stop", LIMA_VM]) diff --git a/sunbeam/config.py b/sunbeam/config.py deleted file mode 100644 index 3dbfbb0c..00000000 --- a/sunbeam/config.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Configuration management — load/save ~/.sunbeam.json for production host and infra directory.""" -import json -import os -from pathlib import Path -from typing import Optional - - -CONFIG_PATH = Path.home() / ".sunbeam.json" - - -class SunbeamConfig: - """Sunbeam configuration with production host and infrastructure directory.""" - - def __init__(self, production_host: str = "", infra_directory: str = "", - acme_email: str = ""): - self.production_host = production_host - self.infra_directory = infra_directory - self.acme_email = acme_email - - def to_dict(self) -> dict: - """Convert configuration to dictionary for JSON serialization.""" - return { - "production_host": self.production_host, - "infra_directory": self.infra_directory, - "acme_email": self.acme_email, - } - - @classmethod - def from_dict(cls, data: dict) -> 'SunbeamConfig': - """Create configuration from dictionary.""" - return cls( - production_host=data.get("production_host", ""), - infra_directory=data.get("infra_directory", ""), - acme_email=data.get("acme_email", ""), - ) - - -def load_config() -> SunbeamConfig: - """Load configuration from ~/.sunbeam.json, return empty config if not found.""" - if not CONFIG_PATH.exists(): - return SunbeamConfig() - - try: - with open(CONFIG_PATH, 'r') as f: - data = json.load(f) - return SunbeamConfig.from_dict(data) - except (json.JSONDecodeError, IOError, KeyError) as e: - from sunbeam.output import warn - warn(f"Failed to load config from {CONFIG_PATH}: {e}") - return SunbeamConfig() - - -def save_config(config: SunbeamConfig) -> None: - """Save configuration to ~/.sunbeam.json.""" - try: - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(CONFIG_PATH, 'w') as f: - json.dump(config.to_dict(), f, indent=2) - from sunbeam.output import ok - ok(f"Configuration saved to {CONFIG_PATH}") - except IOError as e: - from sunbeam.output import die - die(f"Failed to save config to {CONFIG_PATH}: {e}") - - -def get_production_host() -> str: - """Get production host from config or SUNBEAM_SSH_HOST environment variable.""" - config = load_config() - if config.production_host: - return config.production_host - return os.environ.get("SUNBEAM_SSH_HOST", "") - - -def get_infra_directory() -> str: - """Get infrastructure directory from config.""" - config = load_config() - return config.infra_directory - - -def get_infra_dir() -> "Path": - """Infrastructure manifests directory as a Path. - - Prefers the configured infra_directory; falls back to the package-relative - path (works when running from the development checkout). - """ - from pathlib import Path - configured = load_config().infra_directory - if configured: - return Path(configured) - # Dev fallback: cli/sunbeam/config.py → parents[0]=cli/sunbeam, [1]=cli, [2]=monorepo root - return Path(__file__).resolve().parents[2] / "infrastructure" - - -def get_repo_root() -> "Path": - """Monorepo root directory (parent of the infrastructure directory).""" - return get_infra_dir().parent diff --git a/sunbeam/gitea.py b/sunbeam/gitea.py deleted file mode 100644 index bc5ece00..00000000 --- a/sunbeam/gitea.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Gitea bootstrap — registry trust, admin setup, org creation.""" -import base64 -import json -import subprocess -import time - -from sunbeam.kube import kube, kube_out, context_arg -from sunbeam.output import step, ok, warn - -LIMA_VM = "sunbeam" -GITEA_ADMIN_USER = "gitea_admin" -GITEA_ADMIN_EMAIL = "gitea@local.domain" - - -def _capture_out(cmd, *, default=""): - r = subprocess.run(cmd, capture_output=True, text=True) - return r.stdout.strip() if r.returncode == 0 else default - - -def _run(cmd, *, check=True, input=None, capture=False, cwd=None): - text = not isinstance(input, bytes) - return subprocess.run(cmd, check=check, text=text, input=input, - capture_output=capture, cwd=cwd) - - -def _kube_ok(*args): - return subprocess.run( - ["kubectl", context_arg(), *args], capture_output=True - ).returncode == 0 - - -def setup_lima_vm_registry(domain: str, gitea_admin_pass: str = ""): - """Install mkcert root CA in the Lima VM and configure k3s to auth with Gitea. - - Restarts k3s if either configuration changes so pods don't fight TLS errors - or get unauthenticated pulls on the first deploy. - """ - step("Configuring Lima VM registry trust...") - changed = False - - # Install mkcert root CA so containerd trusts our wildcard TLS cert - caroot = _capture_out(["mkcert", "-CAROOT"]) - if caroot: - from pathlib import Path - ca_pem = Path(caroot) / "rootCA.pem" - if ca_pem.exists(): - already = subprocess.run( - ["limactl", "shell", LIMA_VM, "test", "-f", - "/usr/local/share/ca-certificates/mkcert-root.crt"], - capture_output=True, - ).returncode == 0 - if not already: - _run(["limactl", "copy", str(ca_pem), - f"{LIMA_VM}:/tmp/mkcert-root.pem"]) - _run(["limactl", "shell", LIMA_VM, "sudo", "cp", - "/tmp/mkcert-root.pem", - "/usr/local/share/ca-certificates/mkcert-root.crt"]) - _run(["limactl", "shell", LIMA_VM, "sudo", - "update-ca-certificates"]) - ok("mkcert CA installed in VM.") - changed = True - else: - ok("mkcert CA already installed.") - - # Write k3s registries.yaml (auth for Gitea container registry) - registry_host = f"src.{domain}" - want = ( - f'configs:\n' - f' "{registry_host}":\n' - f' auth:\n' - f' username: "{GITEA_ADMIN_USER}"\n' - f' password: "{gitea_admin_pass}"\n' - ) - existing = _capture_out(["limactl", "shell", LIMA_VM, - "sudo", "cat", - "/etc/rancher/k3s/registries.yaml"]) - if existing.strip() != want.strip(): - subprocess.run( - ["limactl", "shell", LIMA_VM, "sudo", "tee", - "/etc/rancher/k3s/registries.yaml"], - input=want, text=True, capture_output=True, - ) - ok(f"Registry config written for {registry_host}.") - changed = True - else: - ok("Registry config up to date.") - - if changed: - ok("Restarting k3s to apply changes...") - subprocess.run( - ["limactl", "shell", LIMA_VM, "sudo", "systemctl", "restart", - "k3s"], - capture_output=True, - ) - # Wait for API server to come back - for _ in range(40): - if _kube_ok("get", "nodes"): - break - time.sleep(3) - # Extra settle time -- pods take a moment to start terminating/restarting - time.sleep(15) - ok("k3s restarted.") - - -def cmd_bootstrap(domain: str = "", gitea_admin_pass: str = ""): - """Ensure Gitea admin has a known password and create the studio/internal orgs.""" - if not domain: - from sunbeam.kube import get_lima_ip - ip = get_lima_ip() - domain = f"{ip}.sslip.io" - if not gitea_admin_pass: - b64 = kube_out("-n", "devtools", "get", "secret", - "gitea-admin-credentials", - "-o=jsonpath={.data.password}") - if b64: - gitea_admin_pass = base64.b64decode(b64).decode() - - step("Bootstrapping Gitea...") - - # Wait for a Running + Ready Gitea pod - pod = "" - for _ in range(60): - candidate = kube_out( - "-n", "devtools", "get", "pods", - "-l=app.kubernetes.io/name=gitea", - "--field-selector=status.phase=Running", - "-o=jsonpath={.items[0].metadata.name}", - ) - if candidate: - ready = kube_out("-n", "devtools", "get", "pod", candidate, - "-o=jsonpath={.status.containerStatuses[0].ready}") - if ready == "true": - pod = candidate - break - time.sleep(3) - - if not pod: - warn("Gitea pod not ready after 3 min -- skipping bootstrap.") - return - - def gitea_exec(*args): - return subprocess.run( - ["kubectl", context_arg(), "-n", "devtools", "exec", pod, "-c", - "gitea", "--"] + list(args), - capture_output=True, text=True, - ) - - # Ensure admin has the generated password and no forced-change flag. - r = gitea_exec("gitea", "admin", "user", "change-password", - "--username", GITEA_ADMIN_USER, "--password", - gitea_admin_pass, "--must-change-password=false") - if r.returncode == 0 or "password" in (r.stdout + r.stderr).lower(): - ok(f"Admin '{GITEA_ADMIN_USER}' password set.") - else: - warn(f"change-password: {r.stderr.strip()}") - - def api(method, path, data=None): - args = [ - "curl", "-s", "-X", method, - f"http://localhost:3000/api/v1{path}", - "-H", "Content-Type: application/json", - "-u", f"{GITEA_ADMIN_USER}:{gitea_admin_pass}", - ] - if data: - args += ["-d", json.dumps(data)] - r = gitea_exec(*args) - try: - return json.loads(r.stdout) - except json.JSONDecodeError: - return {} - - # Mark admin account as private so it doesn't appear in public listings. - r = api("PATCH", f"/admin/users/{GITEA_ADMIN_USER}", { - "source_id": 0, - "login_name": GITEA_ADMIN_USER, - "email": GITEA_ADMIN_EMAIL, - "visibility": "private", - }) - if r.get("login") == GITEA_ADMIN_USER: - ok(f"Admin '{GITEA_ADMIN_USER}' marked as private.") - else: - warn(f"Could not set admin visibility: {r}") - - for org_name, visibility, desc in [ - ("studio", "public", "Public source code"), - ("internal", "private", "Internal tools and services"), - ]: - result = api("POST", "/orgs", { - "username": org_name, - "visibility": visibility, - "description": desc, - }) - if "id" in result: - ok(f"Created org '{org_name}'.") - elif "already" in result.get("message", "").lower(): - ok(f"Org '{org_name}' already exists.") - else: - warn(f"Org '{org_name}': {result.get('message', result)}") - - # Configure Hydra as the OIDC authentication source. - # Source name "Sunbeam" determines the callback URL: - # /user/oauth2/Sunbeam/callback (must match oidc-clients.yaml redirectUri) - auth_list = gitea_exec("gitea", "admin", "auth", "list") - # Parse tab-separated rows: ID\tName\tType\tEnabled - existing_id = None - exact_ok = False - for line in auth_list.stdout.splitlines()[1:]: # skip header - parts = line.split("\t") - if len(parts) < 2: - continue - src_id, src_name = parts[0].strip(), parts[1].strip() - if src_name == "Sunbeam": - exact_ok = True - break - if src_name in ("Sunbeam Auth",) or (src_name.startswith("Sunbeam") and parts[2].strip() == "OAuth2"): - existing_id = src_id - - if exact_ok: - ok("OIDC auth source 'Sunbeam' already present.") - elif existing_id: - # Wrong name (e.g. "Sunbeam Auth") — rename in-place to fix callback URL - r = gitea_exec("gitea", "admin", "auth", "update-oauth", - "--id", existing_id, "--name", "Sunbeam") - if r.returncode == 0: - ok(f"Renamed OIDC auth source (id={existing_id}) to 'Sunbeam'.") - else: - warn(f"Rename failed: {r.stderr.strip()}") - else: - oidc_id_b64 = kube_out("-n", "lasuite", "get", "secret", "oidc-gitea", - "-o=jsonpath={.data.CLIENT_ID}") - oidc_secret_b64 = kube_out("-n", "lasuite", "get", "secret", "oidc-gitea", - "-o=jsonpath={.data.CLIENT_SECRET}") - if oidc_id_b64 and oidc_secret_b64: - oidc_id = base64.b64decode(oidc_id_b64).decode() - oidc_sec = base64.b64decode(oidc_secret_b64).decode() - discover_url = ( - "http://hydra-public.ory.svc.cluster.local:4444" - "/.well-known/openid-configuration" - ) - r = gitea_exec( - "gitea", "admin", "auth", "add-oauth", - "--name", "Sunbeam", - "--provider", "openidConnect", - "--key", oidc_id, - "--secret", oidc_sec, - "--auto-discover-url", discover_url, - "--scopes", "openid", - "--scopes", "email", - "--scopes", "profile", - ) - if r.returncode == 0: - ok("OIDC auth source 'Sunbeam' configured.") - else: - warn(f"OIDC auth source config failed: {r.stderr.strip()}") - else: - warn("oidc-gitea secret not found -- OIDC auth source not configured.") - - ok(f"Gitea ready -- https://src.{domain} ({GITEA_ADMIN_USER} / )") diff --git a/sunbeam/images.py b/sunbeam/images.py deleted file mode 100644 index 5d4cdc0e..00000000 --- a/sunbeam/images.py +++ /dev/null @@ -1,1049 +0,0 @@ -"""Image building, mirroring, and pushing to Gitea registry.""" -import base64 -import json -import os -import shutil -import socket -import subprocess -import tempfile -import time -from dataclasses import dataclass -from pathlib import Path - -from sunbeam.config import get_repo_root as _get_repo_root -from sunbeam.kube import kube, kube_out, get_lima_ip -from sunbeam.output import step, ok, warn, die - -LIMA_VM = "sunbeam" -GITEA_ADMIN_USER = "gitea_admin" -MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "ory", - "storage", "vault-secrets-operator"] - -AMD64_ONLY_IMAGES = [ - ("docker.io/lasuite/people-backend:latest", "studio", "people-backend", "latest"), - ("docker.io/lasuite/people-frontend:latest", "studio", "people-frontend", "latest"), - ("docker.io/lasuite/impress-backend:latest", "studio", "impress-backend", "latest"), - ("docker.io/lasuite/impress-frontend:latest", "studio", "impress-frontend", "latest"), - ("docker.io/lasuite/impress-y-provider:latest","studio", "impress-y-provider","latest"), -] - -_MIRROR_SCRIPT_BODY = r''' -import json, hashlib, io, tarfile, os, subprocess, urllib.request - -CONTENT_STORE = ( - "/var/lib/rancher/k3s/agent/containerd" - "/io.containerd.content.v1.content/blobs/sha256" -) - -def blob_path(h): - return os.path.join(CONTENT_STORE, h) - -def blob_exists(h): - return os.path.exists(blob_path(h)) - -def read_blob(h): - with open(blob_path(h), "rb") as f: - return f.read() - -def add_tar_entry(tar, name, data): - info = tarfile.TarInfo(name=name) - info.size = len(data) - tar.addfile(info, io.BytesIO(data)) - -def get_image_digest(ref): - r = subprocess.run( - ["ctr", "-n", "k8s.io", "images", "ls", "name==" + ref], - capture_output=True, text=True, - ) - for line in r.stdout.splitlines(): - if ref in line: - for part in line.split(): - if part.startswith("sha256:"): - return part[7:] - return None - -def fetch_index_from_registry(repo, tag): - url = ( - "https://auth.docker.io/token" - f"?service=registry.docker.io&scope=repository:{repo}:pull" - ) - with urllib.request.urlopen(url) as resp: - token = json.loads(resp.read())["token"] - accept = ",".join([ - "application/vnd.oci.image.index.v1+json", - "application/vnd.docker.distribution.manifest.list.v2+json", - ]) - req = urllib.request.Request( - f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}", - headers={"Authorization": f"Bearer {token}", "Accept": accept}, - ) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read()) - -def make_oci_tar(ref, new_index_bytes, amd64_manifest_bytes): - ix_hex = hashlib.sha256(new_index_bytes).hexdigest() - amd64_hex = json.loads(new_index_bytes)["manifests"][0]["digest"].replace("sha256:", "") - layout = json.dumps({"imageLayoutVersion": "1.0.0"}).encode() - top = json.dumps({ - "schemaVersion": 2, - "mediaType": "application/vnd.oci.image.index.v1+json", - "manifests": [{ - "mediaType": "application/vnd.oci.image.index.v1+json", - "digest": f"sha256:{ix_hex}", - "size": len(new_index_bytes), - "annotations": {"org.opencontainers.image.ref.name": ref}, - }], - }, separators=(",", ":")).encode() - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:") as tar: - add_tar_entry(tar, "oci-layout", layout) - add_tar_entry(tar, "index.json", top) - add_tar_entry(tar, f"blobs/sha256/{ix_hex}", new_index_bytes) - add_tar_entry(tar, f"blobs/sha256/{amd64_hex}", amd64_manifest_bytes) - return buf.getvalue() - -def import_ref(ref, tar_bytes): - subprocess.run(["ctr", "-n", "k8s.io", "images", "rm", ref], capture_output=True) - r = subprocess.run( - ["ctr", "-n", "k8s.io", "images", "import", "--all-platforms", "-"], - input=tar_bytes, capture_output=True, - ) - if r.returncode: - print(f" import failed: {r.stderr.decode()}") - return False - subprocess.run( - ["ctr", "-n", "k8s.io", "images", "label", ref, "io.cri-containerd.image=managed"], - capture_output=True, - ) - return True - -def process(src, tgt, user, pwd): - print(f" {src}") - - # Pull by tag — may fail on arm64-only images but still puts the index blob in the store - subprocess.run(["ctr", "-n", "k8s.io", "images", "pull", src], capture_output=True) - - ix_hex = get_image_digest(src) - if ix_hex and blob_exists(ix_hex): - index = json.loads(read_blob(ix_hex)) - else: - print(" index not in content store — fetching from docker.io...") - no_prefix = src.replace("docker.io/", "") - parts = no_prefix.split(":", 1) - repo, tag = parts[0], (parts[1] if len(parts) > 1 else "latest") - index = fetch_index_from_registry(repo, tag) - - amd64 = next( - (m for m in index.get("manifests", []) - if m.get("platform", {}).get("architecture") == "amd64" - and m.get("platform", {}).get("os") == "linux"), - None, - ) - if not amd64: - print(" skip: no linux/amd64 entry in index") - return - - amd64_hex = amd64["digest"].replace("sha256:", "") - - # Always pull by digest with --platform linux/amd64 to ensure all layer - # blobs are downloaded to the content store (the index pull in step 1 only - # fetches the manifest blob, not the layers, on an arm64 host). - print(" pulling amd64 manifest + layers by digest...") - repo_base = src.rsplit(":", 1)[0] - subprocess.run( - ["ctr", "-n", "k8s.io", "images", "pull", - "--platform", "linux/amd64", - f"{repo_base}@sha256:{amd64_hex}"], - capture_output=True, - ) - if not blob_exists(amd64_hex): - print(" failed: amd64 manifest blob missing after pull") - return - - amd64_bytes = read_blob(amd64_hex) - - # Patched index: keep amd64 + add arm64 alias pointing at same manifest - arm64 = { - "mediaType": amd64["mediaType"], - "digest": amd64["digest"], - "size": amd64["size"], - "platform": {"architecture": "arm64", "os": "linux"}, - } - new_index = dict(index) - new_index["manifests"] = [amd64, arm64] - new_index_bytes = json.dumps(new_index, separators=(",", ":")).encode() - - # Import with Gitea target name - if not import_ref(tgt, make_oci_tar(tgt, new_index_bytes, amd64_bytes)): - return - # Also patch the original source ref so pods still using docker.io name work - import_ref(src, make_oci_tar(src, new_index_bytes, amd64_bytes)) - - # Push to Gitea registry - print(f" pushing to registry...") - r = subprocess.run( - ["ctr", "-n", "k8s.io", "images", "push", - "--user", f"{user}:{pwd}", tgt], - capture_output=True, text=True, - ) - status = "OK" if r.returncode == 0 else f"PUSH FAILED: {r.stderr.strip()}" - print(f" {status}") - -for _src, _tgt in TARGETS: - process(_src, _tgt, USER, PASS) -''' - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _capture_out(cmd, *, default=""): - r = subprocess.run(cmd, capture_output=True, text=True) - return r.stdout.strip() if r.returncode == 0 else default - - -def _run(cmd, *, check=True, input=None, capture=False, cwd=None): - text = not isinstance(input, bytes) - return subprocess.run(cmd, check=check, text=text, input=input, - capture_output=capture, cwd=cwd) - - -# --------------------------------------------------------------------------- -# Build environment & generic builder -# --------------------------------------------------------------------------- - -@dataclass -class BuildEnv: - """Resolved build environment — production (remote k8s) or local (Lima).""" - is_prod: bool - domain: str - registry: str - admin_pass: str - platform: str - ssh_host: str | None = None - - -def _get_build_env() -> BuildEnv: - """Detect prod vs local and resolve registry credentials.""" - from sunbeam import kube as _kube - is_prod = bool(_kube._ssh_host) - - if is_prod: - domain = os.environ.get("SUNBEAM_DOMAIN", "sunbeam.pt") - else: - ip = get_lima_ip() - domain = f"{ip}.sslip.io" - - b64 = kube_out("-n", "devtools", "get", "secret", - "gitea-admin-credentials", "-o=jsonpath={.data.password}") - if not b64: - die("gitea-admin-credentials secret not found -- run seed first.") - admin_pass = base64.b64decode(b64).decode() - - return BuildEnv( - is_prod=is_prod, - domain=domain, - registry=f"src.{domain}", - admin_pass=admin_pass, - platform="linux/amd64" if is_prod else "linux/arm64", - ssh_host=_kube._ssh_host if is_prod else None, - ) - - -def _buildctl_build_and_push( - env: BuildEnv, - image: str, - dockerfile: Path, - context_dir: Path, - *, - target: str | None = None, - build_args: dict[str, str] | None = None, - no_cache: bool = False, -) -> None: - """Build and push an image via buildkitd running in k3s. - - Port-forwards to the buildkitd service in the `build` namespace, - runs `buildctl build`, and pushes the image directly to the Gitea - registry from inside the cluster. No local Docker daemon needed. - Works for both production and local Lima k3s. - """ - from sunbeam import kube as _kube - from sunbeam.tools import ensure_tool - - buildctl = ensure_tool("buildctl") - kubectl = ensure_tool("kubectl") - - with socket.socket() as s: - s.bind(("", 0)) - local_port = s.getsockname()[1] - - ctx_args = [_kube.context_arg()] - - auth_token = base64.b64encode( - f"{GITEA_ADMIN_USER}:{env.admin_pass}".encode() - ).decode() - docker_cfg = {"auths": {env.registry: {"auth": auth_token}}} - - with tempfile.TemporaryDirectory() as tmpdir: - cfg_path = Path(tmpdir) / "config.json" - cfg_path.write_text(json.dumps(docker_cfg)) - - pf = subprocess.Popen( - [str(kubectl), *ctx_args, - "port-forward", "-n", "build", "svc/buildkitd", - f"{local_port}:1234"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - deadline = time.time() + 15 - while time.time() < deadline: - try: - with socket.create_connection(("127.0.0.1", local_port), timeout=1): - break - except OSError: - time.sleep(0.3) - else: - pf.terminate() - raise RuntimeError( - f"buildkitd port-forward on :{local_port} did not become ready within 15s" - ) - - try: - cmd = [ - str(buildctl), "build", - "--frontend", "dockerfile.v0", - "--local", f"context={context_dir}", - "--local", f"dockerfile={dockerfile.parent}", - "--opt", f"filename={dockerfile.name}", - "--opt", f"platform={env.platform}", - "--output", f"type=image,name={image},push=true", - ] - if target: - cmd += ["--opt", f"target={target}"] - if no_cache: - cmd += ["--no-cache"] - if build_args: - for k, v in build_args.items(): - cmd += ["--opt", f"build-arg:{k}={v}"] - run_env = { - **os.environ, - "BUILDKIT_HOST": f"tcp://127.0.0.1:{local_port}", - "DOCKER_CONFIG": tmpdir, - } - subprocess.run(cmd, env=run_env, check=True) - finally: - pf.terminate() - pf.wait() - - -def _build_image( - env: BuildEnv, - image: str, - dockerfile: Path, - context_dir: Path, - *, - target: str | None = None, - build_args: dict[str, str] | None = None, - push: bool = False, - no_cache: bool = False, - cleanup_paths: list[Path] | None = None, -) -> None: - """Build a container image via buildkitd and push to the Gitea registry. - - Both production and local builds use the in-cluster buildkitd. The image - is built for the environment's platform and pushed directly to the registry. - """ - ok(f"Building image ({env.platform}{f', {target} target' if target else ''})...") - - if not push: - warn("Builds require --push (buildkitd pushes directly to registry); skipping.") - return - - try: - _buildctl_build_and_push( - env=env, - image=image, - dockerfile=dockerfile, - context_dir=context_dir, - target=target, - build_args=build_args, - no_cache=no_cache, - ) - finally: - for p in (cleanup_paths or []): - if p.exists(): - if p.is_dir(): - shutil.rmtree(str(p), ignore_errors=True) - else: - p.unlink(missing_ok=True) - - -def _get_node_addresses() -> list[str]: - """Return one SSH-reachable IP per node in the cluster. - - Each node may report both IPv4 and IPv6 InternalIPs. We pick one per - node name, preferring IPv4 (more likely to have SSH reachable). - """ - # Get "nodeName ip" pairs - raw = kube_out( - "get", "nodes", - "-o", "jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}" - "{range .status.addresses[?(@.type==\"InternalIP\")]}{.address}{\" \"}{end}{\"\\n\"}{end}", - ) - lines = [l.strip() for l in raw.strip().split("\n") if l.strip()] - seen_nodes: dict[str, str] = {} - # Lines alternate: node name, then space-separated IPs - i = 0 - while i < len(lines) - 1: - node_name = lines[i] - addrs = lines[i + 1].split() - i += 2 - if node_name in seen_nodes: - continue - # Prefer IPv4 (no colons) - ipv4 = [a for a in addrs if ":" not in a] - seen_nodes[node_name] = ipv4[0] if ipv4 else addrs[0] - return list(seen_nodes.values()) - - -def _ctr_pull_on_nodes(env: BuildEnv, images: list[str]): - """SSH to each k3s node and pull images into containerd. - - For k3s with imagePullPolicy: IfNotPresent, the image must be present - in containerd *before* the rollout restart. buildkitd pushes to the - Gitea registry; we SSH to each node and ctr-pull so containerd has the - fresh layers. - """ - if not images: - return - nodes = _get_node_addresses() - if not nodes: - warn("Could not detect node addresses; skipping ctr pull.") - return - - ssh_user = env.ssh_host.split("@")[0] if env.ssh_host and "@" in env.ssh_host else "root" - - for node_ip in nodes: - for img in images: - ok(f"Pulling {img} into containerd on {node_ip}...") - r = subprocess.run( - ["ssh", "-p", "2222", - "-o", "StrictHostKeyChecking=no", f"{ssh_user}@{node_ip}", - f"sudo ctr -n k8s.io images pull {img}"], - capture_output=True, text=True, - ) - if r.returncode != 0: - die(f"ctr pull failed on {node_ip}: {r.stderr.strip()}") - ok(f"Pulled {img} on {node_ip}") - - -def _deploy_rollout(env: BuildEnv, deployments: list[str], namespace: str, - timeout: str = "180s", images: list[str] | None = None): - """Apply manifests for the target namespace and rolling-restart the given deployments. - - For single-node k3s (env.ssh_host is set), pulls *images* into containerd - on the node via SSH before restarting, so imagePullPolicy: IfNotPresent - picks up the new layers. - """ - from sunbeam.manifests import cmd_apply - cmd_apply(env="production" if env.is_prod else "local", domain=env.domain, - namespace=namespace) - - # Pull fresh images into containerd on every node before rollout - if images: - _ctr_pull_on_nodes(env, images) - - for dep in deployments: - ok(f"Rolling {dep}...") - kube("rollout", "restart", f"deployment/{dep}", "-n", namespace) - for dep in deployments: - kube("rollout", "status", f"deployment/{dep}", "-n", namespace, - f"--timeout={timeout}") - ok("Redeployed.") - - -# --------------------------------------------------------------------------- -# Mirroring -# --------------------------------------------------------------------------- - -def cmd_mirror(domain: str = "", gitea_admin_pass: str = ""): - """Patch amd64-only images with an arm64 alias and push to Gitea registry.""" - if not domain: - ip = get_lima_ip() - domain = f"{ip}.sslip.io" - if not gitea_admin_pass: - b64 = kube_out("-n", "devtools", "get", "secret", - "gitea-admin-credentials", "-o=jsonpath={.data.password}") - if b64: - gitea_admin_pass = base64.b64decode(b64).decode() - - step("Mirroring amd64-only images to Gitea registry...") - - registry = f"src.{domain}" - targets = [ - (src, f"{registry}/{org}/{repo}:{tag}") - for src, org, repo, tag in AMD64_ONLY_IMAGES - ] - - header = ( - f"TARGETS = {repr(targets)}\n" - f"USER = {repr(GITEA_ADMIN_USER)}\n" - f"PASS = {repr(gitea_admin_pass)}\n" - ) - script = header + _MIRROR_SCRIPT_BODY - - _run(["limactl", "shell", LIMA_VM, "sudo", "python3", "-c", script]) - - # Delete any pods stuck in image-pull error states - ok("Clearing image-pull-error pods...") - error_reasons = {"ImagePullBackOff", "ErrImagePull", "ErrImageNeverPull"} - for ns in MANAGED_NS: - pods_raw = kube_out( - "-n", ns, "get", "pods", - "-o=jsonpath={range .items[*]}" - "{.metadata.name}:{.status.containerStatuses[0].state.waiting.reason}\\n" - "{end}", - ) - for line in pods_raw.splitlines(): - if not line: - continue - parts = line.split(":", 1) - if len(parts) == 2 and parts[1] in error_reasons: - kube("delete", "pod", parts[0], "-n", ns, - "--ignore-not-found", check=False) - ok("Done.") - - -# --------------------------------------------------------------------------- -# Build dispatch -# --------------------------------------------------------------------------- - -def cmd_build(what: str, push: bool = False, deploy: bool = False, no_cache: bool = False): - """Build an image. Pass push=True to push, deploy=True to also apply + rollout.""" - try: - _cmd_build(what, push=push, deploy=deploy, no_cache=no_cache) - except subprocess.CalledProcessError as exc: - cmd_str = " ".join(str(a) for a in exc.cmd) - die(f"Build step failed (exit {exc.returncode}): {cmd_str}") - - -def _cmd_build(what: str, push: bool = False, deploy: bool = False, no_cache: bool = False): - if what == "proxy": - _build_proxy(push=push, deploy=deploy) - elif what == "integration": - _build_integration(push=push, deploy=deploy) - elif what == "kratos-admin": - _build_kratos_admin(push=push, deploy=deploy) - elif what == "meet": - _build_meet(push=push, deploy=deploy) - elif what == "docs-frontend": - _build_la_suite_frontend( - app="docs-frontend", - repo_dir=_get_repo_root() / "docs", - workspace_rel="src/frontend", - app_rel="src/frontend/apps/impress", - dockerfile_rel="src/frontend/Dockerfile", - image_name="impress-frontend", - deployment="docs-frontend", - namespace="lasuite", - push=push, - deploy=deploy, - ) - elif what in ("people", "people-frontend"): - _build_people(push=push, deploy=deploy) - elif what in ("messages", "messages-backend", "messages-frontend", - "messages-mta-in", "messages-mta-out", "messages-mpa", - "messages-socks-proxy"): - _build_messages(what, push=push, deploy=deploy) - elif what == "tuwunel": - _build_tuwunel(push=push, deploy=deploy) - elif what == "calendars": - _build_calendars(push=push, deploy=deploy) - elif what == "projects": - _build_projects(push=push, deploy=deploy) - elif what == "sol": - _build_sol(push=push, deploy=deploy) - else: - die(f"Unknown build target: {what}") - - -# --------------------------------------------------------------------------- -# Per-service build functions -# --------------------------------------------------------------------------- - -def _build_proxy(push: bool = False, deploy: bool = False): - env = _get_build_env() - - proxy_dir = _get_repo_root() / "proxy" - if not proxy_dir.is_dir(): - die(f"Proxy source not found at {proxy_dir}") - - image = f"{env.registry}/studio/proxy:latest" - step(f"Building sunbeam-proxy -> {image} ...") - - # Both local and production use the same Dockerfile and build via - # the in-cluster buildkitd. The buildkitd on each environment - # compiles natively for its own architecture (arm64 on Lima, - # amd64 on Scaleway). - _build_image(env, image, proxy_dir / "Dockerfile", proxy_dir, push=push) - - if deploy: - _deploy_rollout(env, ["pingora"], "ingress", timeout="120s", - images=[image]) - - -def _build_tuwunel(push: bool = False, deploy: bool = False): - """Build tuwunel Matrix homeserver image from source.""" - env = _get_build_env() - - tuwunel_dir = _get_repo_root() / "tuwunel" - if not tuwunel_dir.is_dir(): - die(f"Tuwunel source not found at {tuwunel_dir}") - - image = f"{env.registry}/studio/tuwunel:latest" - step(f"Building tuwunel -> {image} ...") - - # buildkitd runs on the x86_64 server — builds natively, no cross-compilation. - _build_image(env, image, tuwunel_dir / "Dockerfile", tuwunel_dir, push=push) - - if deploy: - _deploy_rollout(env, ["tuwunel"], "matrix", timeout="180s", - images=[image]) - - -def _build_integration(push: bool = False, deploy: bool = False): - env = _get_build_env() - - sunbeam_dir = _get_repo_root() - integration_service_dir = sunbeam_dir / "integration-service" - dockerfile = integration_service_dir / "Dockerfile" - dockerignore = integration_service_dir / ".dockerignore" - - if not dockerfile.exists(): - die(f"integration-service Dockerfile not found at {dockerfile}") - if not (sunbeam_dir / "integration" / "packages" / "widgets").is_dir(): - die(f"integration repo not found at {sunbeam_dir / 'integration'} -- " - "run: cd sunbeam && git clone https://github.com/suitenumerique/integration.git") - - image = f"{env.registry}/studio/integration:latest" - step(f"Building integration -> {image} ...") - - # .dockerignore needs to be at context root (sunbeam/) - root_ignore = sunbeam_dir / ".dockerignore" - copied_ignore = False - if not root_ignore.exists() and dockerignore.exists(): - shutil.copy(str(dockerignore), str(root_ignore)) - copied_ignore = True - try: - _build_image(env, image, dockerfile, sunbeam_dir, push=push) - finally: - if copied_ignore and root_ignore.exists(): - root_ignore.unlink() - - if deploy: - _deploy_rollout(env, ["integration"], "lasuite", timeout="120s") - - -def _build_kratos_admin(push: bool = False, deploy: bool = False): - env = _get_build_env() - - kratos_admin_dir = _get_repo_root() / "kratos-admin" - if not kratos_admin_dir.is_dir(): - die(f"kratos-admin source not found at {kratos_admin_dir}") - - image = f"{env.registry}/studio/kratos-admin-ui:latest" - - step(f"Building kratos-admin-ui -> {image} ...") - - _build_image( - env, image, - kratos_admin_dir / "Dockerfile", kratos_admin_dir, - push=push, - ) - - if deploy: - _deploy_rollout(env, ["kratos-admin-ui"], "ory", timeout="120s") - - -def _build_meet(push: bool = False, deploy: bool = False): - """Build meet-backend and meet-frontend images from source.""" - env = _get_build_env() - - meet_dir = _get_repo_root() / "meet" - if not meet_dir.is_dir(): - die(f"meet source not found at {meet_dir}") - - backend_image = f"{env.registry}/studio/meet-backend:latest" - frontend_image = f"{env.registry}/studio/meet-frontend:latest" - - step(f"Building meet-backend -> {backend_image} ...") - _build_image( - env, backend_image, - meet_dir / "Dockerfile", meet_dir, - target="backend-production", - push=push, - ) - - step(f"Building meet-frontend -> {frontend_image} ...") - frontend_dockerfile = meet_dir / "src" / "frontend" / "Dockerfile" - if not frontend_dockerfile.exists(): - die(f"meet frontend Dockerfile not found at {frontend_dockerfile}") - _build_image( - env, frontend_image, - frontend_dockerfile, meet_dir, - target="frontend-production", - build_args={"VITE_API_BASE_URL": ""}, - push=push, - ) - - if deploy: - _deploy_rollout( - env, - ["meet-backend", "meet-celery-worker", "meet-frontend"], - "lasuite", - ) - - -def _build_people(push: bool = False, deploy: bool = False): - """Build people-frontend from source.""" - env = _get_build_env() - - people_dir = _get_repo_root() / "people" - if not people_dir.is_dir(): - die(f"people source not found at {people_dir}") - - if not shutil.which("yarn"): - die("yarn not found on PATH -- install Node.js + yarn first (nvm use 22).") - - workspace_dir = people_dir / "src" / "frontend" - app_dir = people_dir / "src" / "frontend" / "apps" / "desk" - dockerfile = people_dir / "src" / "frontend" / "Dockerfile" - if not dockerfile.exists(): - die(f"Dockerfile not found at {dockerfile}") - - image = f"{env.registry}/studio/people-frontend:latest" - step(f"Building people-frontend -> {image} ...") - - ok("Updating yarn.lock (yarn install in workspace)...") - _run(["yarn", "install", "--ignore-engines"], cwd=str(workspace_dir)) - - ok("Regenerating cunningham design tokens (cunningham -g css,ts)...") - cunningham_bin = workspace_dir / "node_modules" / ".bin" / "cunningham" - _run([str(cunningham_bin), "-g", "css,ts", "-o", "src/cunningham", "--utility-classes"], - cwd=str(app_dir)) - - _build_image( - env, image, - dockerfile, people_dir, - target="frontend-production", - build_args={"DOCKER_USER": "101"}, - push=push, - ) - - if deploy: - _deploy_rollout(env, ["people-frontend"], "lasuite") - - -def _build_messages(what: str, push: bool = False, deploy: bool = False): - """Build one or all messages images from source.""" - env = _get_build_env() - - messages_dir = _get_repo_root() / "messages" - if not messages_dir.is_dir(): - die(f"messages source not found at {messages_dir}") - - all_components = [ - ("messages-backend", "messages-backend", "src/backend/Dockerfile", "runtime-distroless-prod"), - ("messages-frontend", "messages-frontend", "src/frontend/Dockerfile", "runtime-prod"), - ("messages-mta-in", "messages-mta-in", "src/mta-in/Dockerfile", None), - ("messages-mta-out", "messages-mta-out", "src/mta-out/Dockerfile", None), - ("messages-mpa", "messages-mpa", "src/mpa/rspamd/Dockerfile", None), - ("messages-socks-proxy", "messages-socks-proxy", "src/socks-proxy/Dockerfile", None), - ] - components = all_components if what == "messages" else [ - c for c in all_components if c[0] == what - ] - - built_images = [] - for component, image_name, dockerfile_rel, target in components: - dockerfile = messages_dir / dockerfile_rel - if not dockerfile.exists(): - warn(f"Dockerfile not found at {dockerfile} -- skipping {component}") - continue - - image = f"{env.registry}/studio/{image_name}:latest" - context_dir = dockerfile.parent - step(f"Building {component} -> {image} ...") - - # Patch ghcr.io/astral-sh/uv COPY for messages-backend on local builds - cleanup_paths: list[Path] = [] - actual_dockerfile = dockerfile - if not env.is_prod and image_name == "messages-backend": - actual_dockerfile, cleanup_paths = _patch_dockerfile_uv( - dockerfile, context_dir, env.platform - ) - - _build_image( - env, image, - actual_dockerfile, context_dir, - target=target, - push=push, - cleanup_paths=cleanup_paths, - ) - built_images.append(image) - - if deploy and built_images: - _deploy_rollout( - env, - ["messages-backend", "messages-worker", "messages-frontend", - "messages-mta-in", "messages-mta-out", "messages-mpa", - "messages-socks-proxy"], - "lasuite", - ) - - -def _build_la_suite_frontend( - app: str, - repo_dir: Path, - workspace_rel: str, - app_rel: str, - dockerfile_rel: str, - image_name: str, - deployment: str, - namespace: str, - push: bool = False, - deploy: bool = False, -): - """Build a La Suite frontend image from source and push to the Gitea registry.""" - env = _get_build_env() - - if not shutil.which("yarn"): - die("yarn not found on PATH — install Node.js + yarn first (nvm use 22).") - - workspace_dir = repo_dir / workspace_rel - app_dir = repo_dir / app_rel - dockerfile = repo_dir / dockerfile_rel - - if not repo_dir.is_dir(): - die(f"{app} source not found at {repo_dir}") - if not dockerfile.exists(): - die(f"Dockerfile not found at {dockerfile}") - - image = f"{env.registry}/studio/{image_name}:latest" - step(f"Building {app} -> {image} ...") - - ok("Updating yarn.lock (yarn install in workspace)...") - _run(["yarn", "install", "--ignore-engines"], cwd=str(workspace_dir)) - - ok("Regenerating cunningham design tokens (yarn build-theme)...") - _run(["yarn", "build-theme"], cwd=str(app_dir)) - - _build_image( - env, image, - dockerfile, repo_dir, - target="frontend-production", - build_args={"DOCKER_USER": "101"}, - push=push, - ) - - if deploy: - _deploy_rollout(env, [deployment], namespace) - - -def _patch_dockerfile_uv( - dockerfile_path: Path, - messages_dir: Path, - platform: str, -) -> tuple[Path, list[Path]]: - """Download uv from GitHub releases and return a patched Dockerfile path. - - The docker-container buildkit driver cannot access the host Docker daemon's - local image cache, so --build-context docker-image:// silently falls through - to docker.io. oci-layout:// is the only local-context type that works, but - it requires producing an OCI tar and extracting it. - - The simplest reliable approach: stage the downloaded binaries inside the - build context directory and patch the Dockerfile to use a plain COPY instead - of COPY --from=ghcr.io/... The patched Dockerfile is written next to the - original; both it and the staging dir are cleaned up by the caller. - - Returns (patched_dockerfile_path, [paths_to_cleanup]). - """ - import re as _re - import tarfile as _tf - import urllib.request as _url - - content = dockerfile_path.read_text() - - copy_match = _re.search( - r'(COPY\s+--from=ghcr\.io/astral-sh/uv@sha256:[a-f0-9]+\s+/uv\s+/uvx\s+/bin/)', - content, - ) - if not copy_match: - return (dockerfile_path, []) - original_copy = copy_match.group(1) - - version_match = _re.search(r'oci://ghcr\.io/astral-sh/uv:(\S+)', content) - if not version_match: - warn("Could not find uv version comment in Dockerfile; ghcr.io pull may fail.") - return (dockerfile_path, []) - version = version_match.group(1) - - arch = "x86_64" if "amd64" in platform else "aarch64" - url = ( - f"https://github.com/astral-sh/uv/releases/download/{version}/" - f"uv-{arch}-unknown-linux-gnu.tar.gz" - ) - - stage_dir = messages_dir / "_sunbeam_uv_stage" - patched_df = dockerfile_path.parent / "Dockerfile._sunbeam_patched" - cleanup = [stage_dir, patched_df] - - ok(f"Downloading uv {version} ({arch}) from GitHub releases to bypass ghcr.io...") - try: - stage_dir.mkdir(exist_ok=True) - tarball = stage_dir / "uv.tar.gz" - _url.urlretrieve(url, str(tarball)) - - with _tf.open(str(tarball), "r:gz") as tf: - for member in tf.getmembers(): - name = os.path.basename(member.name) - if name in ("uv", "uvx") and member.isfile(): - member.name = name - tf.extract(member, str(stage_dir)) - tarball.unlink() - - uv_path = stage_dir / "uv" - uvx_path = stage_dir / "uvx" - if not uv_path.exists(): - warn("uv binary not found in release tarball; build may fail.") - return (dockerfile_path, cleanup) - uv_path.chmod(0o755) - if uvx_path.exists(): - uvx_path.chmod(0o755) - - patched = content.replace( - original_copy, - "COPY _sunbeam_uv_stage/uv _sunbeam_uv_stage/uvx /bin/", - ) - patched_df.write_text(patched) - ok(f" uv {version} staged; using patched Dockerfile.") - return (patched_df, cleanup) - - except Exception as exc: - warn(f"Failed to stage uv binaries: {exc}") - return (dockerfile_path, cleanup) - - -def _build_projects(push: bool = False, deploy: bool = False): - """Build projects (Planka Kanban) image from source.""" - env = _get_build_env() - - projects_dir = _get_repo_root() / "projects" - if not projects_dir.is_dir(): - die(f"projects source not found at {projects_dir}") - - image = f"{env.registry}/studio/projects:latest" - step(f"Building projects -> {image} ...") - - _build_image(env, image, projects_dir / "Dockerfile", projects_dir, push=push) - - if deploy: - _deploy_rollout(env, ["projects"], "lasuite", timeout="180s", - images=[image]) - - -def _build_sol(push: bool = False, deploy: bool = False): - """Build Sol virtual librarian image from source. - - # TODO: first deploy requires registration enabled on tuwunel to create - # the @sol:sunbeam.pt bot account. Flow: - # 1. Set allow_registration = true in tuwunel-config.yaml - # 2. Apply + restart tuwunel - # 3. Register bot via POST /_matrix/client/v3/register with registration token - # 4. Store access_token + device_id in OpenBao at secret/sol - # 5. Set allow_registration = false, re-apply - # 6. Then build + deploy sol - # This should be automated as `sunbeam user create-bot `. - """ - env = _get_build_env() - - sol_dir = _get_repo_root() / "sol" - if not sol_dir.is_dir(): - die(f"Sol source not found at {sol_dir}") - - image = f"{env.registry}/studio/sol:latest" - step(f"Building sol -> {image} ...") - - _build_image(env, image, sol_dir / "Dockerfile", sol_dir, push=push) - - if deploy: - _deploy_rollout(env, ["sol"], "matrix", timeout="120s") - - -def _build_calendars(push: bool = False, deploy: bool = False): - env = _get_build_env() - cal_dir = _get_repo_root() / "calendars" - if not cal_dir.is_dir(): - die(f"calendars source not found at {cal_dir}") - - backend_dir = cal_dir / "src" / "backend" - backend_image = f"{env.registry}/studio/calendars-backend:latest" - step(f"Building calendars-backend -> {backend_image} ...") - - # Stage translations.json into the build context so the production image - # has it at /data/translations.json (Docker Compose mounts it; we bake it in). - translations_src = (cal_dir / "src" / "frontend" / "apps" / "calendars" - / "src" / "features" / "i18n" / "translations.json") - translations_dst = backend_dir / "_translations.json" - cleanup: list[Path] = [] - dockerfile = backend_dir / "Dockerfile" - if translations_src.exists(): - shutil.copy(str(translations_src), str(translations_dst)) - cleanup.append(translations_dst) - # Patch Dockerfile to COPY translations into production image - patched = dockerfile.read_text() + ( - "\n# Sunbeam: bake translations.json for default calendar names\n" - "COPY _translations.json /data/translations.json\n" - ) - patched_df = backend_dir / "Dockerfile._sunbeam_patched" - patched_df.write_text(patched) - cleanup.append(patched_df) - dockerfile = patched_df - - _build_image(env, backend_image, - dockerfile, - backend_dir, - target="backend-production", - push=push, - cleanup_paths=cleanup) - - caldav_image = f"{env.registry}/studio/calendars-caldav:latest" - step(f"Building calendars-caldav -> {caldav_image} ...") - _build_image(env, caldav_image, - cal_dir / "src" / "caldav" / "Dockerfile", - cal_dir / "src" / "caldav", - push=push) - - frontend_image = f"{env.registry}/studio/calendars-frontend:latest" - step(f"Building calendars-frontend -> {frontend_image} ...") - integration_base = f"https://integration.{env.domain}" - _build_image(env, frontend_image, - cal_dir / "src" / "frontend" / "Dockerfile", - cal_dir / "src" / "frontend", - target="frontend-production", - build_args={ - "VISIO_BASE_URL": f"https://meet.{env.domain}", - "GAUFRE_WIDGET_PATH": f"{integration_base}/api/v2/lagaufre.js", - "GAUFRE_API_URL": f"{integration_base}/api/v2/services.json", - "THEME_CSS_URL": f"{integration_base}/api/v2/theme.css", - }, - push=push) - - if deploy: - _deploy_rollout(env, - ["calendars-backend", "calendars-worker", - "calendars-caldav", "calendars-frontend"], - "lasuite", timeout="180s", - images=[backend_image, caldav_image, frontend_image]) diff --git a/sunbeam/kube.py b/sunbeam/kube.py deleted file mode 100644 index f3be0ad7..00000000 --- a/sunbeam/kube.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Kubernetes interface — kubectl/kustomize wrappers, domain substitution, target parsing.""" -import subprocess -import time -from contextlib import contextmanager -from pathlib import Path - -from sunbeam.tools import run_tool, CACHE_DIR -from sunbeam.output import die, ok - -# Active kubectl context. Set once at startup via set_context(). -# Defaults to "sunbeam" (Lima VM) for local dev. -_context: str = "sunbeam" - -# SSH host for production tunnel. Set alongside context for production env. -_ssh_host: str = "" -_tunnel_proc: subprocess.Popen | None = None - - -def set_context(ctx: str, ssh_host: str = "") -> None: - global _context, _ssh_host - _context = ctx - _ssh_host = ssh_host - - -def context_arg() -> str: - """Return '--context=' for use in subprocess command lists.""" - return f"--context={_context}" - - -def ensure_tunnel() -> None: - """Open SSH tunnel to localhost:16443 → remote:6443 for production if needed.""" - global _tunnel_proc - if not _ssh_host: - return - import socket - try: - with socket.create_connection(("127.0.0.1", 16443), timeout=0.5): - return # already open - except (ConnectionRefusedError, TimeoutError, OSError): - pass - ok(f"Opening SSH tunnel to {_ssh_host}...") - _tunnel_proc = subprocess.Popen( - ["ssh", "-p", "2222", "-L", "16443:127.0.0.1:6443", "-N", "-o", "ExitOnForwardFailure=yes", - "-o", "StrictHostKeyChecking=no", _ssh_host], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - for _ in range(10): - try: - with socket.create_connection(("127.0.0.1", 16443), timeout=0.5): - return - except (ConnectionRefusedError, TimeoutError, OSError): - time.sleep(0.5) - die(f"SSH tunnel to {_ssh_host} did not open in time") - - -def parse_target(s: str | None) -> tuple[str | None, str | None]: - """Parse 'ns/name' -> ('ns', 'name'), 'ns' -> ('ns', None), None -> (None, None).""" - if s is None: - return (None, None) - parts = s.split("/") - if len(parts) == 1: - return (parts[0], None) - if len(parts) == 2: - return (parts[0], parts[1]) - raise ValueError(f"Invalid target {s!r}: expected 'namespace' or 'namespace/name'") - - -def domain_replace(text: str, domain: str) -> str: - """Replace all occurrences of DOMAIN_SUFFIX with domain.""" - return text.replace("DOMAIN_SUFFIX", domain) - - -def get_lima_ip() -> str: - """Get the socket_vmnet IP of the Lima sunbeam VM (192.168.105.x).""" - r = subprocess.run( - ["limactl", "shell", "sunbeam", "ip", "-4", "addr", "show", "eth1"], - capture_output=True, text=True, - ) - for line in r.stdout.splitlines(): - if "inet " in line: - return line.strip().split()[1].split("/")[0] - # fallback: second IP from hostname -I - r2 = subprocess.run( - ["limactl", "shell", "sunbeam", "hostname", "-I"], - capture_output=True, text=True, - ) - ips = r2.stdout.strip().split() - return ips[-1] if len(ips) >= 2 else (ips[0] if ips else "") - - -def kube(*args, input=None, check=True) -> subprocess.CompletedProcess: - """Run kubectl against the active context, opening SSH tunnel if needed.""" - ensure_tunnel() - text = not isinstance(input, bytes) - return run_tool("kubectl", context_arg(), *args, - input=input, text=text, check=check, - capture_output=False) - - -def kube_out(*args) -> str: - """Run kubectl and return stdout (empty string on failure).""" - ensure_tunnel() - r = run_tool("kubectl", context_arg(), *args, - capture_output=True, text=True, check=False) - return r.stdout.strip() if r.returncode == 0 else "" - - -def kube_ok(*args) -> bool: - """Return True if kubectl command exits 0.""" - ensure_tunnel() - r = run_tool("kubectl", context_arg(), *args, - capture_output=True, check=False) - return r.returncode == 0 - - -def kube_apply(manifest: str, *, server_side: bool = True) -> None: - """Pipe manifest YAML to kubectl apply.""" - args = ["apply", "-f", "-"] - if server_side: - args += ["--server-side", "--force-conflicts"] - kube(*args, input=manifest) - - -def ns_exists(ns: str) -> bool: - return kube_ok("get", "namespace", ns) - - -def ensure_ns(ns: str) -> None: - manifest = kube_out("create", "namespace", ns, "--dry-run=client", "-o=yaml") - if manifest: - kube_apply(manifest) - - -def create_secret(ns: str, name: str, **literals) -> None: - """Create or update a K8s generic secret idempotently via server-side apply.""" - args = ["create", "secret", "generic", name, f"-n={ns}"] - for k, v in literals.items(): - args.append(f"--from-literal={k}={v}") - args += ["--dry-run=client", "-o=yaml"] - manifest = kube_out(*args) - if manifest: - kube("apply", "--server-side", "--force-conflicts", - "--field-manager=sunbeam", "-f", "-", input=manifest) - - -def kube_exec(ns: str, pod: str, *cmd: str, container: str | None = None) -> tuple[int, str]: - """Run a command inside a pod. Returns (returncode, stdout).""" - args = ["kubectl", context_arg(), "exec", "-n", ns, pod] - if container: - args += ["-c", container] - args += ["--", *cmd] - r = run_tool(*args, capture_output=True, text=True, check=False) - return r.returncode, r.stdout.strip() - - -def get_domain() -> str: - """Discover the active domain from cluster state. - - Tries multiple reliable anchors; falls back to the Lima VM IP for local dev. - """ - import base64 - - # 1. Gitea inline-config secret: server section contains DOMAIN=src. - # Works in both local and production because DOMAIN_SUFFIX is substituted - # into gitea-values.yaml at apply time. - raw = kube_out("get", "secret", "gitea-inline-config", "-n", "devtools", - "-o=jsonpath={.data.server}", "--ignore-not-found") - if raw: - try: - server_ini = base64.b64decode(raw).decode() - for line in server_ini.splitlines(): - if line.startswith("DOMAIN=src."): - # e.g. "DOMAIN=src.sunbeam.pt" - return line.split("DOMAIN=src.", 1)[1].strip() - except Exception: - pass - - # 2. Fallback: lasuite-oidc-provider configmap (works if La Suite is deployed) - raw2 = kube_out("get", "configmap", "lasuite-oidc-provider", "-n", "lasuite", - "-o=jsonpath={.data.OIDC_OP_JWKS_ENDPOINT}", "--ignore-not-found") - if raw2 and "https://auth." in raw2: - return raw2.split("https://auth.")[1].split("/")[0] - - # 3. Local dev fallback - ip = get_lima_ip() - return f"{ip}.sslip.io" - - -def cmd_k8s(kubectl_args: list[str]) -> int: - """Transparent kubectl passthrough for the active context.""" - ensure_tunnel() - from sunbeam.tools import ensure_tool - bin_path = ensure_tool("kubectl") - r = subprocess.run([str(bin_path), context_arg(), *kubectl_args]) - return r.returncode - - -def cmd_bao(bao_args: list[str]) -> int: - """Run bao CLI inside the OpenBao pod with the root token. Returns exit code. - - Automatically resolves the pod name and root token from the cluster, then - runs ``kubectl exec openbao-0 -- sh -c "VAULT_TOKEN= bao "`` - so callers never need to handle raw kubectl exec or token management. - """ - ob_pod = kube_out("-n", "data", "get", "pod", - "-l", "app.kubernetes.io/name=openbao", - "-o", "jsonpath={.items[0].metadata.name}") - if not ob_pod: - from sunbeam.output import die - die("OpenBao pod not found — is the cluster running?") - - token_b64 = kube_out("-n", "data", "get", "secret", "openbao-keys", - "-o", "jsonpath={.data.root-token}") - import base64 - root_token = base64.b64decode(token_b64).decode() if token_b64 else "" - if not root_token: - from sunbeam.output import die - die("root-token not found in openbao-keys secret") - - cmd_str = "VAULT_TOKEN=" + root_token + " bao " + " ".join(bao_args) - r = subprocess.run( - ["kubectl", context_arg(), "-n", "data", "exec", ob_pod, - "-c", "openbao", "--", "sh", "-c", cmd_str] - ) - return r.returncode - - -def kustomize_build(overlay: Path, domain: str, email: str = "") -> str: - """Run kustomize build --enable-helm and apply domain/email substitution.""" - import socket as _socket - r = run_tool( - "kustomize", "build", "--enable-helm", str(overlay), - capture_output=True, text=True, check=True, - ) - text = r.stdout - text = domain_replace(text, domain) - if email: - text = text.replace("ACME_EMAIL", email) - if "REGISTRY_HOST_IP" in text: - registry_ip = "" - try: - registry_ip = _socket.gethostbyname(f"src.{domain}") - except _socket.gaierror: - pass - if not registry_ip: - # DNS not resolvable locally (VPN, split-horizon, etc.) — derive IP from SSH host config - from sunbeam.config import get_production_host as _get_host - ssh_host = _get_host() - # ssh_host may be "user@host" or just "host" - raw = ssh_host.split("@")[-1].split(":")[0] - try: - registry_ip = _socket.gethostbyname(raw) - except _socket.gaierror: - registry_ip = raw # raw is already an IP in typical config - text = text.replace("REGISTRY_HOST_IP", registry_ip) - text = text.replace("\n annotations: null", "") - return text diff --git a/sunbeam/manifests.py b/sunbeam/manifests.py deleted file mode 100644 index 464ecde4..00000000 --- a/sunbeam/manifests.py +++ /dev/null @@ -1,437 +0,0 @@ -"""Manifest build + apply — kustomize overlay with domain substitution.""" -import time -from pathlib import Path - -from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, kustomize_build, get_lima_ip, get_domain -from sunbeam.output import step, ok, warn - -from sunbeam.config import get_infra_dir as _get_infra_dir -REPO_ROOT = _get_infra_dir() -MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "monitoring", - "ory", "storage", "vault-secrets-operator"] - - -def pre_apply_cleanup(namespaces=None): - """Delete immutable resources that must be re-created on each apply. - - Also prunes VaultStaticSecrets that share a name with a VaultDynamicSecret -- - kubectl apply doesn't delete the old resource when a manifest switches kinds, - and VSO refuses to overwrite a secret owned by a different resource type. - - namespaces: if given, only clean those namespaces; otherwise clean all MANAGED_NS. - """ - ns_list = namespaces if namespaces is not None else MANAGED_NS - ok("Cleaning up immutable Jobs and test Pods...") - for ns in ns_list: - kube("delete", "jobs", "--all", "-n", ns, "--ignore-not-found", check=False) - # Query all pods (no phase filter) — CrashLoopBackOff pods report phase=Running - # so filtering on phase!=Running would silently skip them. - pods_out = kube_out("get", "pods", "-n", ns, - "-o=jsonpath={.items[*].metadata.name}") - for pod in pods_out.split(): - if pod.endswith(("-test-connection", "-server-test", "-test")): - kube("delete", "pod", pod, "-n", ns, "--ignore-not-found", check=False) - - # Prune VaultStaticSecrets that were replaced by VaultDynamicSecrets. - # When a manifest transitions a resource from VSS -> VDS, apply won't delete - # the old VSS; it just creates the new VDS alongside it. VSO then errors - # "not the owner" because the K8s secret's ownerRef still points to the VSS. - ok("Pruning stale VaultStaticSecrets superseded by VaultDynamicSecrets...") - for ns in ns_list: - vss_names = set(kube_out( - "get", "vaultstaticsecret", "-n", ns, - "-o=jsonpath={.items[*].metadata.name}", "--ignore-not-found", - ).split()) - vds_names = set(kube_out( - "get", "vaultdynamicsecret", "-n", ns, - "-o=jsonpath={.items[*].metadata.name}", "--ignore-not-found", - ).split()) - for stale in vss_names & vds_names: - ok(f" deleting stale VaultStaticSecret {ns}/{stale}") - kube("delete", "vaultstaticsecret", stale, "-n", ns, - "--ignore-not-found", check=False) - - -def _snapshot_configmaps() -> dict: - """Return {ns/name: resourceVersion} for all ConfigMaps in managed namespaces.""" - result = {} - for ns in MANAGED_NS: - out = kube_out( - "get", "configmaps", "-n", ns, "--ignore-not-found", - "-o=jsonpath={range .items[*]}{.metadata.name}={.metadata.resourceVersion}\\n{end}", - ) - for line in out.splitlines(): - if "=" in line: - name, rv = line.split("=", 1) - result[f"{ns}/{name}"] = rv - return result - - -def _restart_for_changed_configmaps(before: dict, after: dict): - """Restart deployments that mount any ConfigMap whose resourceVersion changed.""" - changed_by_ns: dict = {} - for key, rv in after.items(): - if before.get(key) != rv: - ns, name = key.split("/", 1) - changed_by_ns.setdefault(ns, set()).add(name) - - for ns, cm_names in changed_by_ns.items(): - out = kube_out( - "get", "deployments", "-n", ns, "--ignore-not-found", - "-o=jsonpath={range .items[*]}{.metadata.name}:" - "{range .spec.template.spec.volumes[*]}{.configMap.name},{end};{end}", - ) - for entry in out.split(";"): - entry = entry.strip() - if not entry or ":" not in entry: - continue - dep, vols = entry.split(":", 1) - mounted = {v.strip() for v in vols.split(",") if v.strip()} - if mounted & cm_names: - ok(f"Restarting {ns}/{dep} (ConfigMap updated)...") - kube("rollout", "restart", f"deployment/{dep}", "-n", ns, check=False) - - -def _wait_for_webhook(ns: str, svc: str, timeout: int = 120) -> bool: - """Poll until a webhook service endpoint exists (signals webhook is ready). - - Returns True if the webhook is ready within timeout seconds. - """ - ok(f"Waiting for {ns}/{svc} webhook (up to {timeout}s)...") - deadline = time.time() + timeout - while time.time() < deadline: - eps = kube_out("get", "endpoints", svc, "-n", ns, - "-o=jsonpath={.subsets[0].addresses[0].ip}", "--ignore-not-found") - if eps: - ok(f" {ns}/{svc} ready.") - return True - time.sleep(3) - warn(f" {ns}/{svc} not ready after {timeout}s — continuing anyway.") - return False - - -def _apply_mkcert_ca_configmap(): - """Create/update gitea-mkcert-ca ConfigMap from the local mkcert root CA. - - Only called in local env. The ConfigMap is mounted into Gitea so Go's TLS - stack trusts the mkcert wildcard cert when making server-side OIDC calls. - """ - import subprocess, json - from pathlib import Path - caroot = subprocess.run(["mkcert", "-CAROOT"], capture_output=True, text=True).stdout.strip() - if not caroot: - warn("mkcert not found — skipping gitea-mkcert-ca ConfigMap.") - return - ca_pem = Path(caroot) / "rootCA.pem" - if not ca_pem.exists(): - warn(f"mkcert root CA not found at {ca_pem} — skipping.") - return - cm = json.dumps({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": {"name": "gitea-mkcert-ca", "namespace": "devtools"}, - "data": {"ca.crt": ca_pem.read_text()}, - }) - kube("apply", "--server-side", "-f", "-", input=cm) - ok("gitea-mkcert-ca ConfigMap applied.") - - -def _filter_by_namespace(manifests: str, namespace: str) -> str: - """Return only the YAML documents that belong to the given namespace. - - Also includes the Namespace resource itself (safe to re-apply). - Uses simple string matching — namespace always appears as 'namespace: ' - in top-level metadata, so this is reliable without a full YAML parser. - """ - kept = [] - for doc in manifests.split("\n---"): - doc = doc.strip() - if not doc: - continue - if f"namespace: {namespace}" in doc: - kept.append(doc) - elif "kind: Namespace" in doc and f"name: {namespace}" in doc: - kept.append(doc) - if not kept: - return "" - return "---\n" + "\n---\n".join(kept) + "\n" - - -def _patch_tuwunel_oauth2_redirect(domain: str): - """Patch the tuwunel OAuth2Client redirect URI with the actual client_id. - - Hydra-maester generates the client_id when it first reconciles the - OAuth2Client CRD, storing it in the oidc-tuwunel Secret. We read that - secret and patch the CRD's redirectUris to include the correct callback - path that tuwunel will use. - """ - import base64, json - - client_id_b64 = kube_out("get", "secret", "oidc-tuwunel", "-n", "matrix", - "-o=jsonpath={.data.CLIENT_ID}", "--ignore-not-found") - if not client_id_b64: - warn("oidc-tuwunel secret not yet available — skipping redirect URI patch. " - "Re-run 'sunbeam apply matrix' after hydra-maester has reconciled.") - return - - client_id = base64.b64decode(client_id_b64).decode() - redirect_uri = f"https://messages.{domain}/_matrix/client/unstable/login/sso/callback/{client_id}" - - # Check current redirect URIs to avoid unnecessary patches. - current = kube_out("get", "oauth2client", "tuwunel", "-n", "matrix", - "-o=jsonpath={.spec.redirectUris[*]}", "--ignore-not-found") - if redirect_uri in current.split(): - return - - patch = json.dumps({"spec": {"redirectUris": [redirect_uri]}}) - kube("patch", "oauth2client", "tuwunel", "-n", "matrix", - "--type=merge", f"-p={patch}", check=False) - ok(f"Patched tuwunel OAuth2Client redirect URI.") - - -def _os_api(path: str, method: str = "GET", data: str | None = None) -> str: - """Call OpenSearch API via kubectl exec. Returns response body.""" - cmd = ["exec", "deploy/opensearch", "-n", "data", "-c", "opensearch", "--"] - curl = ["curl", "-sf", f"http://localhost:9200{path}"] - if method != "GET": - curl += ["-X", method] - if data is not None: - curl += ["-H", "Content-Type: application/json", "-d", data] - return kube_out(*cmd, *curl) - - -def _ensure_opensearch_ml(): - """Idempotently configure OpenSearch ML Commons for neural search. - - 1. Sets cluster settings to allow ML on data nodes. - 2. Registers and deploys all-mpnet-base-v2 (pre-trained, 384-dim). - 3. Creates ingest + search pipelines for hybrid BM25+neural scoring. - """ - import json, time - - # Check OpenSearch is reachable. - if not _os_api("/_cluster/health"): - warn("OpenSearch not reachable — skipping ML setup.") - return - - # 1. Ensure ML Commons cluster settings (idempotent PUT). - _os_api("/_cluster/settings", "PUT", json.dumps({"persistent": { - "plugins.ml_commons.only_run_on_ml_node": False, - "plugins.ml_commons.native_memory_threshold": 90, - "plugins.ml_commons.model_access_control_enabled": False, - "plugins.ml_commons.allow_registering_model_via_url": True, - }})) - - # 2. Check if model already registered and deployed. - search_resp = _os_api("/_plugins/_ml/models/_search", "POST", - '{"query":{"match":{"name":"huggingface/sentence-transformers/all-mpnet-base-v2"}}}') - if not search_resp: - warn("OpenSearch ML search API failed — skipping ML setup.") - return - - resp = json.loads(search_resp) - hits = resp.get("hits", {}).get("hits", []) - model_id = None - - for hit in hits: - state = hit.get("_source", {}).get("model_state", "") - if state == "DEPLOYED": - model_id = hit["_id"] - break - elif state in ("REGISTERED", "DEPLOYING"): - model_id = hit["_id"] - - if model_id and any(h["_source"].get("model_state") == "DEPLOYED" for h in hits): - pass # Already deployed, skip to pipelines. - elif model_id: - # Registered but not deployed — deploy it. - ok("Deploying OpenSearch ML model...") - _os_api(f"/_plugins/_ml/models/{model_id}/_deploy", "POST") - for _ in range(30): - time.sleep(5) - r = _os_api(f"/_plugins/_ml/models/{model_id}") - if r and '"DEPLOYED"' in r: - break - else: - # Register from pre-trained hub. - ok("Registering OpenSearch ML model (all-mpnet-base-v2)...") - reg_resp = _os_api("/_plugins/_ml/models/_register", "POST", json.dumps({ - "name": "huggingface/sentence-transformers/all-mpnet-base-v2", - "version": "1.0.1", - "model_format": "TORCH_SCRIPT", - })) - if not reg_resp: - warn("Failed to register ML model — skipping.") - return - task_id = json.loads(reg_resp).get("task_id", "") - if not task_id: - warn("No task_id from model registration — skipping.") - return - - # Wait for registration. - ok("Waiting for model registration...") - for _ in range(60): - time.sleep(10) - task_resp = _os_api(f"/_plugins/_ml/tasks/{task_id}") - if not task_resp: - continue - task = json.loads(task_resp) - state = task.get("state", "") - if state == "COMPLETED": - model_id = task.get("model_id", "") - break - if state == "FAILED": - warn(f"ML model registration failed: {task_resp}") - return - - if not model_id: - warn("ML model registration timed out.") - return - - # Deploy. - ok("Deploying ML model...") - _os_api(f"/_plugins/_ml/models/{model_id}/_deploy", "POST") - for _ in range(30): - time.sleep(5) - r = _os_api(f"/_plugins/_ml/models/{model_id}") - if r and '"DEPLOYED"' in r: - break - - if not model_id: - warn("No ML model available — skipping pipeline setup.") - return - - # 3. Create/update ingest pipeline (PUT is idempotent). - _os_api("/_ingest/pipeline/tuwunel_embedding_pipeline", "PUT", json.dumps({ - "description": "Tuwunel message embedding pipeline", - "processors": [{"text_embedding": { - "model_id": model_id, - "field_map": {"body": "embedding"}, - }}], - })) - - # 4. Create/update search pipeline (PUT is idempotent). - _os_api("/_search/pipeline/tuwunel_hybrid_pipeline", "PUT", json.dumps({ - "description": "Tuwunel hybrid BM25+neural search pipeline", - "phase_results_processors": [{"normalization-processor": { - "normalization": {"technique": "min_max"}, - "combination": {"technique": "arithmetic_mean", "parameters": {"weights": [0.3, 0.7]}}, - }}], - })) - - ok(f"OpenSearch ML ready (model: {model_id}).") - return model_id - - -def _inject_opensearch_model_id(): - """Read deployed ML model_id from OpenSearch, write to ConfigMap in matrix ns. - - The tuwunel deployment reads TUWUNEL_SEARCH_OPENSEARCH_MODEL_ID from this - ConfigMap. Creates or updates the ConfigMap idempotently. - - Reads the model_id from the ingest pipeline (which _ensure_opensearch_ml - already configured with the correct model_id). - """ - import json - - # Read model_id from the ingest pipeline that _ensure_opensearch_ml created. - pipe_resp = _os_api("/_ingest/pipeline/tuwunel_embedding_pipeline") - if not pipe_resp: - warn("OpenSearch ingest pipeline not found — skipping model_id injection. " - "Run 'sunbeam apply data' first.") - return - - pipe = json.loads(pipe_resp) - processors = (pipe.get("tuwunel_embedding_pipeline", {}) - .get("processors", [])) - model_id = None - for proc in processors: - model_id = proc.get("text_embedding", {}).get("model_id") - if model_id: - break - - if not model_id: - warn("No model_id in ingest pipeline — tuwunel hybrid search will be unavailable.") - return - - # Check if ConfigMap already has this value. - current = kube_out("get", "configmap", "opensearch-ml-config", "-n", "matrix", - "-o=jsonpath={.data.model_id}", "--ignore-not-found") - if current == model_id: - return - - cm = json.dumps({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": {"name": "opensearch-ml-config", "namespace": "matrix"}, - "data": {"model_id": model_id}, - }) - kube("apply", "--server-side", "-f", "-", input=cm) - ok(f"Injected OpenSearch model_id ({model_id}) into matrix/opensearch-ml-config.") - - -def cmd_apply(env: str = "local", domain: str = "", email: str = "", namespace: str = ""): - """Build kustomize overlay for env, substitute domain/email, kubectl apply. - - Runs a second convergence pass if cert-manager is present in the overlay — - cert-manager registers a ValidatingWebhook that must be running before - ClusterIssuer / Certificate resources can be created. - """ - # Fall back to config for ACME email if not provided via CLI flag. - if not email: - from sunbeam.config import load_config - email = load_config().acme_email - - if env == "production": - if not domain: - # Try to discover domain from running cluster - domain = get_domain() - if not domain: - from sunbeam.output import die - die("--domain is required for production apply on first deploy") - overlay = REPO_ROOT / "overlays" / "production" - else: - ip = get_lima_ip() - domain = f"{ip}.sslip.io" - overlay = REPO_ROOT / "overlays" / "local" - - scope = f" [{namespace}]" if namespace else "" - step(f"Applying manifests (env: {env}, domain: {domain}){scope}...") - if env == "local": - _apply_mkcert_ca_configmap() - ns_list = [namespace] if namespace else None - pre_apply_cleanup(namespaces=ns_list) - before = _snapshot_configmaps() - manifests = kustomize_build(overlay, domain, email=email) - - if namespace: - manifests = _filter_by_namespace(manifests, namespace) - if not manifests.strip(): - warn(f"No resources found for namespace '{namespace}' — check the name and try again.") - return - - # First pass: may emit errors for resources that depend on webhooks not yet running - # (e.g. cert-manager ClusterIssuer/Certificate), which is expected on first deploy. - kube("apply", "--server-side", "--force-conflicts", "-f", "-", - input=manifests, check=False) - - # If cert-manager is in the overlay, wait for its webhook then re-apply - # so that ClusterIssuer and Certificate resources converge. - # Skip for partial applies unless the target IS cert-manager. - cert_manager_present = (overlay / "../../base/cert-manager").resolve().exists() - if cert_manager_present and not namespace: - if _wait_for_webhook("cert-manager", "cert-manager-webhook", timeout=120): - ok("Running convergence pass for cert-manager resources...") - manifests2 = kustomize_build(overlay, domain, email=email) - kube("apply", "--server-side", "--force-conflicts", "-f", "-", input=manifests2) - - _restart_for_changed_configmaps(before, _snapshot_configmaps()) - - # Post-apply hooks for namespaces that need runtime patching. - if not namespace or namespace == "matrix": - _patch_tuwunel_oauth2_redirect(domain) - _inject_opensearch_model_id() - if not namespace or namespace == "data": - _ensure_opensearch_ml() - - ok("Applied.") diff --git a/sunbeam/output.py b/sunbeam/output.py deleted file mode 100644 index ba0108ea..00000000 --- a/sunbeam/output.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Output helpers — step/ok/warn/die + aligned text table.""" -import sys - - -def step(msg: str) -> None: - """Print a step header.""" - print(f"\n==> {msg}", flush=True) - - -def ok(msg: str) -> None: - """Print a success/info line.""" - print(f" {msg}", flush=True) - - -def warn(msg: str) -> None: - """Print a warning to stderr.""" - print(f" WARN: {msg}", file=sys.stderr, flush=True) - - -def die(msg: str) -> None: - """Print an error to stderr and exit.""" - print(f"\nERROR: {msg}", file=sys.stderr, flush=True) - sys.exit(1) - - -def table(rows: list[list[str]], headers: list[str]) -> str: - """Return an aligned text table. Columns padded to max width.""" - if not headers: - return "" - # Compute column widths - col_widths = [len(h) for h in headers] - for row in rows: - for i, cell in enumerate(row): - if i < len(col_widths): - col_widths[i] = max(col_widths[i], len(cell)) - # Format header - header_line = " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers)) - separator = " ".join("-" * w for w in col_widths) - lines = [header_line, separator] - # Format rows - for row in rows: - cells = [] - for i in range(len(headers)): - val = row[i] if i < len(row) else "" - cells.append(val.ljust(col_widths[i])) - lines.append(" ".join(cells)) - return "\n".join(lines) diff --git a/sunbeam/secrets.py b/sunbeam/secrets.py deleted file mode 100644 index b28c0d35..00000000 --- a/sunbeam/secrets.py +++ /dev/null @@ -1,978 +0,0 @@ -"""Secrets management — OpenBao KV seeding, DB engine config, VSO verification.""" -import base64 -import json -import secrets as _secrets -import subprocess -import time -import urllib.error -import urllib.request -from contextlib import contextmanager -from pathlib import Path - -from sunbeam.kube import kube, kube_out, kube_ok, kube_apply, ensure_ns, create_secret, get_domain, context_arg -from sunbeam.output import step, ok, warn, die - -ADMIN_USERNAME = "estudio-admin" - - -def _gen_fernet_key() -> str: - """Generate a Fernet-compatible key (32 random bytes, URL-safe base64).""" - return base64.urlsafe_b64encode(_secrets.token_bytes(32)).decode() - - -def _gen_dkim_key_pair() -> tuple[str, str]: - """Generate an RSA 2048-bit DKIM key pair using openssl. - - Returns (private_pem_pkcs8, public_pem). Returns ("", "") on failure. - """ - try: - r1 = subprocess.run(["openssl", "genrsa", "2048"], capture_output=True, text=True) - if r1.returncode != 0: - warn(f"openssl genrsa failed: {r1.stderr.strip()}") - return ("", "") - # Convert to PKCS8 (format expected by rspamd) - r2 = subprocess.run(["openssl", "pkcs8", "-topk8", "-nocrypt"], - input=r1.stdout, capture_output=True, text=True) - private_pem = r2.stdout.strip() if r2.returncode == 0 else r1.stdout.strip() - # Extract public key from the original RSA key - r3 = subprocess.run(["openssl", "rsa", "-pubout"], - input=r1.stdout, capture_output=True, text=True) - if r3.returncode != 0: - warn(f"openssl rsa -pubout failed: {r3.stderr.strip()}") - return (private_pem, "") - return (private_pem, r3.stdout.strip()) - except FileNotFoundError: - warn("openssl not found -- skipping DKIM key generation.") - return ("", "") - -LIMA_VM = "sunbeam" -GITEA_ADMIN_USER = "gitea_admin" -PG_USERS = [ - "kratos", "hydra", "gitea", "hive", - "docs", "meet", "drive", "messages", "conversations", - "people", "find", "calendars", "projects", -] - - -# --------------------------------------------------------------------------- -# OpenBao KV seeding -# --------------------------------------------------------------------------- - -def _seed_openbao() -> dict: - """Initialize/unseal OpenBao, generate/read credentials idempotently, configure VSO auth. - - Returns a dict of all generated credentials. Values are read from existing - OpenBao KV entries when present -- re-running never rotates credentials. - """ - ob_pod = kube_out( - "-n", "data", "get", "pods", - "-l=app.kubernetes.io/name=openbao,component=server", - "-o=jsonpath={.items[0].metadata.name}", - ) - if not ob_pod: - ok("OpenBao pod not found -- skipping.") - return {} - - ok(f"OpenBao ({ob_pod})...") - kube("wait", "-n", "data", f"pod/{ob_pod}", - "--for=jsonpath={.status.phase}=Running", "--timeout=120s", check=False) - - def bao(cmd): - r = subprocess.run( - ["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao", - "--", "sh", "-c", cmd], - capture_output=True, text=True, - ) - return r.stdout.strip() - - def bao_status(): - out = bao("bao status -format=json 2>/dev/null || echo '{}'") - try: - return json.loads(out) - except json.JSONDecodeError: - return {} - - unseal_key = "" - root_token = "" - - status = bao_status() - already_initialized = status.get("initialized", False) - if not already_initialized: - existing_key = kube_out("-n", "data", "get", "secret", "openbao-keys", - "-o=jsonpath={.data.key}") - already_initialized = bool(existing_key) - - if not already_initialized: - ok("Initializing OpenBao...") - init_json = bao("bao operator init -key-shares=1 -key-threshold=1 -format=json 2>/dev/null || echo '{}'") - try: - init = json.loads(init_json) - unseal_key = init["unseal_keys_b64"][0] - root_token = init["root_token"] - create_secret("data", "openbao-keys", - key=unseal_key, **{"root-token": root_token}) - ok("Initialized -- keys stored in secret/openbao-keys.") - except (json.JSONDecodeError, KeyError): - warn("Init failed -- resetting OpenBao storage for local dev...") - kube("delete", "pvc", "data-openbao-0", "-n", "data", "--ignore-not-found", check=False) - kube("delete", "pod", ob_pod, "-n", "data", "--ignore-not-found", check=False) - warn("OpenBao storage reset. Run --seed again after the pod restarts.") - return {} - else: - ok("Already initialized.") - existing_key = kube_out("-n", "data", "get", "secret", "openbao-keys", - "-o=jsonpath={.data.key}") - if existing_key: - unseal_key = base64.b64decode(existing_key).decode() - root_token_enc = kube_out("-n", "data", "get", "secret", "openbao-keys", - "-o=jsonpath={.data.root-token}") - if root_token_enc: - root_token = base64.b64decode(root_token_enc).decode() - - if bao_status().get("sealed", False) and unseal_key: - ok("Unsealing...") - bao(f"bao operator unseal '{unseal_key}' 2>/dev/null") - - if not root_token: - warn("No root token available -- skipping KV seeding.") - return {} - - # Read-or-generate helper: preserves existing KV values; only generates missing ones. - # Tracks which paths had new values so we only write back when necessary. - _dirty_paths: set = set() - - def get_or_create(path, **fields): - raw = bao( - f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao kv get -format=json secret/{path} 2>/dev/null || echo '{{}}'" - ) - existing = {} - try: - existing = json.loads(raw).get("data", {}).get("data", {}) - except (json.JSONDecodeError, AttributeError): - pass - result = {} - for key, default_fn in fields.items(): - val = existing.get(key) - if val: - result[key] = val - else: - result[key] = default_fn() - _dirty_paths.add(path) - return result - - def rand(): - return _secrets.token_urlsafe(32) - - ok("Seeding KV (idempotent -- existing values preserved)...") - - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao secrets enable -path=secret -version=2 kv 2>/dev/null || true") - - # DB passwords removed -- OpenBao database secrets engine manages them via static roles. - hydra = get_or_create("hydra", - **{"system-secret": rand, - "cookie-secret": rand, - "pairwise-salt": rand}) - - SMTP_URI = "smtp://postfix.lasuite.svc.cluster.local:25/?skip_ssl_verify=true" - kratos = get_or_create("kratos", - **{"secrets-default": rand, - "secrets-cookie": rand, - "smtp-connection-uri": lambda: SMTP_URI}) - - seaweedfs = get_or_create("seaweedfs", - **{"access-key": rand, "secret-key": rand}) - - gitea = get_or_create("gitea", - **{"admin-username": lambda: GITEA_ADMIN_USER, - "admin-password": rand}) - - hive = get_or_create("hive", - **{"oidc-client-id": lambda: "hive-local", - "oidc-client-secret": rand}) - - livekit = get_or_create("livekit", - **{"api-key": lambda: "devkey", - "api-secret": rand}) - - people = get_or_create("people", - **{"django-secret-key": rand}) - - login_ui = get_or_create("login-ui", - **{"cookie-secret": rand, - "csrf-cookie-secret": rand}) - - kratos_admin = get_or_create("kratos-admin", - **{"cookie-secret": rand, - "csrf-cookie-secret": rand, - "admin-identity-ids": lambda: "", - "s3-access-key": lambda: seaweedfs["access-key"], - "s3-secret-key": lambda: seaweedfs["secret-key"]}) - - docs = get_or_create("docs", - **{"django-secret-key": rand, - "collaboration-secret": rand}) - - meet = get_or_create("meet", - **{"django-secret-key": rand, - "application-jwt-secret-key": rand}) - - drive = get_or_create("drive", - **{"django-secret-key": rand}) - - projects = get_or_create("projects", - **{"secret-key": rand}) - - calendars = get_or_create("calendars", - **{"django-secret-key": lambda: _secrets.token_urlsafe(50), - "salt-key": rand, - "caldav-inbound-api-key": rand, - "caldav-outbound-api-key": rand, - "caldav-internal-api-key": rand}) - - # DKIM key pair -- generated together since private and public keys are coupled. - # Read existing keys first; only generate a new pair when absent. - existing_messages_raw = bao( - f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao kv get -format=json secret/messages 2>/dev/null || echo '{{}}'" - ) - existing_messages = {} - try: - existing_messages = json.loads(existing_messages_raw).get("data", {}).get("data", {}) - except (json.JSONDecodeError, AttributeError): - pass - - if existing_messages.get("dkim-private-key"): - _dkim_private = existing_messages["dkim-private-key"] - _dkim_public = existing_messages.get("dkim-public-key", "") - else: - _dkim_private, _dkim_public = _gen_dkim_key_pair() - - messages = get_or_create("messages", - **{"django-secret-key": rand, - "salt-key": rand, - "mda-api-secret": rand, - "oidc-refresh-token-key": _gen_fernet_key, - "dkim-private-key": lambda: _dkim_private, - "dkim-public-key": lambda: _dkim_public, - "rspamd-password": rand, - "socks-proxy-users": lambda: f"sunbeam:{rand()}", - "mta-out-smtp-username": lambda: "sunbeam", - "mta-out-smtp-password": rand}) - - collabora = get_or_create("collabora", - **{"username": lambda: "admin", - "password": rand}) - - tuwunel = get_or_create("tuwunel", - **{"oidc-client-id": lambda: "", - "oidc-client-secret": lambda: "", - "turn-secret": lambda: "", - "registration-token": rand}) - - # Scaleway S3 credentials for CNPG barman backups. - # Read from `scw config` at seed time; falls back to empty string (operator must fill in). - def _scw_config(key): - try: - r = subprocess.run(["scw", "config", "get", key], - capture_output=True, text=True, timeout=5) - return r.stdout.strip() if r.returncode == 0 else "" - except (FileNotFoundError, subprocess.TimeoutExpired): - return "" - - grafana = get_or_create("grafana", - **{"admin-password": rand}) - - scaleway_s3 = get_or_create("scaleway-s3", - **{"access-key-id": lambda: _scw_config("access-key"), - "secret-access-key": lambda: _scw_config("secret-key")}) - - # Only write secrets to OpenBao KV for paths that have new/missing values. - # This avoids unnecessary KV version bumps which trigger VSO re-syncs and - # rollout restarts across the cluster. - if not _dirty_paths: - ok("All OpenBao KV secrets already present -- skipping writes.") - else: - ok(f"Writing new secrets to OpenBao KV ({', '.join(sorted(_dirty_paths))})...") - - def _kv_put(path, **kv): - pairs = " ".join(f'{k}="{v}"' for k, v in kv.items()) - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao kv put secret/{path} {pairs}") - - if "messages" in _dirty_paths: - _kv_put("messages", - **{"django-secret-key": messages["django-secret-key"], - "salt-key": messages["salt-key"], - "mda-api-secret": messages["mda-api-secret"], - "oidc-refresh-token-key": messages["oidc-refresh-token-key"], - "rspamd-password": messages["rspamd-password"], - "socks-proxy-users": messages["socks-proxy-users"], - "mta-out-smtp-username": messages["mta-out-smtp-username"], - "mta-out-smtp-password": messages["mta-out-smtp-password"]}) - # DKIM keys stored separately (large PEM values) - dkim_priv_b64 = base64.b64encode(messages['dkim-private-key'].encode()).decode() - dkim_pub_b64 = base64.b64encode(messages['dkim-public-key'].encode()).decode() - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' sh -c '" - f"echo {dkim_priv_b64} | base64 -d > /tmp/dkim_priv.pem && " - f"echo {dkim_pub_b64} | base64 -d > /tmp/dkim_pub.pem && " - f"bao kv patch secret/messages" - f" dkim-private-key=\"$(cat /tmp/dkim_priv.pem)\"" - f" dkim-public-key=\"$(cat /tmp/dkim_pub.pem)\" && " - f"rm /tmp/dkim_priv.pem /tmp/dkim_pub.pem" - f"'") - if "hydra" in _dirty_paths: - _kv_put("hydra", **{"system-secret": hydra["system-secret"], - "cookie-secret": hydra["cookie-secret"], - "pairwise-salt": hydra["pairwise-salt"]}) - if "kratos" in _dirty_paths: - _kv_put("kratos", **{"secrets-default": kratos["secrets-default"], - "secrets-cookie": kratos["secrets-cookie"], - "smtp-connection-uri": kratos["smtp-connection-uri"]}) - if "gitea" in _dirty_paths: - _kv_put("gitea", **{"admin-username": gitea["admin-username"], - "admin-password": gitea["admin-password"]}) - if "seaweedfs" in _dirty_paths: - _kv_put("seaweedfs", **{"access-key": seaweedfs["access-key"], - "secret-key": seaweedfs["secret-key"]}) - if "hive" in _dirty_paths: - _kv_put("hive", **{"oidc-client-id": hive["oidc-client-id"], - "oidc-client-secret": hive["oidc-client-secret"]}) - if "livekit" in _dirty_paths: - _kv_put("livekit", **{"api-key": livekit["api-key"], - "api-secret": livekit["api-secret"]}) - if "people" in _dirty_paths: - _kv_put("people", **{"django-secret-key": people["django-secret-key"]}) - if "login-ui" in _dirty_paths: - _kv_put("login-ui", **{"cookie-secret": login_ui["cookie-secret"], - "csrf-cookie-secret": login_ui["csrf-cookie-secret"]}) - if "kratos-admin" in _dirty_paths: - _kv_put("kratos-admin", **{"cookie-secret": kratos_admin["cookie-secret"], - "csrf-cookie-secret": kratos_admin["csrf-cookie-secret"], - "admin-identity-ids": kratos_admin["admin-identity-ids"], - "s3-access-key": kratos_admin["s3-access-key"], - "s3-secret-key": kratos_admin["s3-secret-key"]}) - if "docs" in _dirty_paths: - _kv_put("docs", **{"django-secret-key": docs["django-secret-key"], - "collaboration-secret": docs["collaboration-secret"]}) - if "meet" in _dirty_paths: - _kv_put("meet", **{"django-secret-key": meet["django-secret-key"], - "application-jwt-secret-key": meet["application-jwt-secret-key"]}) - if "drive" in _dirty_paths: - _kv_put("drive", **{"django-secret-key": drive["django-secret-key"]}) - if "projects" in _dirty_paths: - _kv_put("projects", **{"secret-key": projects["secret-key"]}) - if "calendars" in _dirty_paths: - _kv_put("calendars", **{"django-secret-key": calendars["django-secret-key"], - "salt-key": calendars["salt-key"], - "caldav-inbound-api-key": calendars["caldav-inbound-api-key"], - "caldav-outbound-api-key": calendars["caldav-outbound-api-key"], - "caldav-internal-api-key": calendars["caldav-internal-api-key"]}) - if "collabora" in _dirty_paths: - _kv_put("collabora", **{"username": collabora["username"], - "password": collabora["password"]}) - if "grafana" in _dirty_paths: - _kv_put("grafana", **{"admin-password": grafana["admin-password"]}) - if "scaleway-s3" in _dirty_paths: - _kv_put("scaleway-s3", **{"access-key-id": scaleway_s3["access-key-id"], - "secret-access-key": scaleway_s3["secret-access-key"]}) - if "tuwunel" in _dirty_paths: - _kv_put("tuwunel", **{"oidc-client-id": tuwunel["oidc-client-id"], - "oidc-client-secret": tuwunel["oidc-client-secret"], - "turn-secret": tuwunel["turn-secret"], - "registration-token": tuwunel["registration-token"]}) - - # Patch gitea admin credentials into secret/sol for Sol's Gitea integration. - # Uses kv patch (not put) to preserve manually-set keys (matrix-access-token etc.). - ok("Patching Gitea admin credentials into secret/sol...") - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao kv patch secret/sol " - f"gitea-admin-username='{gitea['admin-username']}' " - f"gitea-admin-password='{gitea['admin-password']}'") - - # Configure Kubernetes auth method so VSO can authenticate with OpenBao - ok("Configuring Kubernetes auth for VSO...") - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao auth enable kubernetes 2>/dev/null; true") - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao write auth/kubernetes/config " - f"kubernetes_host=https://kubernetes.default.svc.cluster.local") - - policy_hcl = ( - 'path "secret/data/*" { capabilities = ["read"] }\n' - 'path "secret/metadata/*" { capabilities = ["read", "list"] }\n' - 'path "database/static-creds/*" { capabilities = ["read"] }\n' - ) - policy_b64 = base64.b64encode(policy_hcl.encode()).decode() - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"sh -c 'echo {policy_b64} | base64 -d | bao policy write vso-reader -'") - - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao write auth/kubernetes/role/vso " - f"bound_service_account_names=default " - f"bound_service_account_namespaces=ory,devtools,storage,lasuite,matrix,media,data,monitoring " - f"policies=vso-reader " - f"ttl=1h") - - # Sol agent policy — read/write access to sol-tokens/* for user impersonation PATs - ok("Configuring Kubernetes auth for Sol agent...") - sol_policy_hcl = ( - 'path "secret/data/sol-tokens/*" { capabilities = ["create", "read", "update", "delete"] }\n' - 'path "secret/metadata/sol-tokens/*" { capabilities = ["read", "delete", "list"] }\n' - ) - sol_policy_b64 = base64.b64encode(sol_policy_hcl.encode()).decode() - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"sh -c 'echo {sol_policy_b64} | base64 -d | bao policy write sol-agent -'") - - bao(f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}' " - f"bao write auth/kubernetes/role/sol-agent " - f"bound_service_account_names=default " - f"bound_service_account_namespaces=matrix " - f"policies=sol-agent " - f"ttl=1h") - - return { - "hydra-system-secret": hydra["system-secret"], - "hydra-cookie-secret": hydra["cookie-secret"], - "hydra-pairwise-salt": hydra["pairwise-salt"], - "kratos-secrets-default": kratos["secrets-default"], - "kratos-secrets-cookie": kratos["secrets-cookie"], - "s3-access-key": seaweedfs["access-key"], - "s3-secret-key": seaweedfs["secret-key"], - "gitea-admin-password": gitea["admin-password"], - "hive-oidc-client-id": hive["oidc-client-id"], - "hive-oidc-client-secret": hive["oidc-client-secret"], - "people-django-secret": people["django-secret-key"], - "livekit-api-key": livekit["api-key"], - "livekit-api-secret": livekit["api-secret"], - "kratos-admin-cookie-secret": kratos_admin["cookie-secret"], - "messages-dkim-public-key": messages.get("dkim-public-key", ""), - "_ob_pod": ob_pod, - "_root_token": root_token, - } - - -# --------------------------------------------------------------------------- -# Database secrets engine -# --------------------------------------------------------------------------- - -def _configure_db_engine(ob_pod, root_token, pg_user, pg_pass): - """Enable OpenBao database secrets engine and create PostgreSQL static roles. - - Static roles cause OpenBao to immediately set (and later rotate) each service - user's password via ALTER USER, eliminating hardcoded DB passwords. - Idempotent: bao write overwrites existing config/roles safely. - - The `vault` PG user is created here (if absent) and used as the DB engine - connection user. pg_user/pg_pass (the CNPG superuser) are kept for potential - future use but are no longer used for the connection URL. - """ - ok("Configuring OpenBao database secrets engine...") - pg_rw = "postgres-rw.data.svc.cluster.local:5432" - bao_env = f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}'" - - def bao(cmd, check=True): - r = subprocess.run( - ["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao", - "--", "sh", "-c", cmd], - capture_output=True, text=True, - ) - if check and r.returncode != 0: - raise RuntimeError(f"bao command failed (exit {r.returncode}):\n{r.stderr.strip()}") - return r.stdout.strip() - - # Enable database secrets engine -- tolerate "already enabled" error via || true. - bao(f"{bao_env} bao secrets enable database 2>/dev/null || true", check=False) - - # -- vault PG user setup --------------------------------------------------- - # Locate the CNPG primary pod for psql exec (peer auth -- no password needed). - cnpg_pod = kube_out( - "-n", "data", "get", "pods", - "-l=cnpg.io/cluster=postgres,role=primary", - "-o=jsonpath={.items[0].metadata.name}", - ) - if not cnpg_pod: - raise RuntimeError("Could not find CNPG primary pod for vault user setup.") - - def psql(sql): - r = subprocess.run( - ["kubectl", context_arg(), "-n", "data", "exec", cnpg_pod, "-c", "postgres", - "--", "psql", "-U", "postgres", "-c", sql], - capture_output=True, text=True, - ) - if r.returncode != 0: - raise RuntimeError(f"psql failed: {r.stderr.strip()}") - return r.stdout.strip() - - # Read existing vault pg-password from OpenBao KV, or generate a new one. - existing_vault_pass = bao( - f"{bao_env} bao kv get -field=pg-password secret/vault 2>/dev/null || true", - check=False, - ) - vault_pg_pass = existing_vault_pass.strip() if existing_vault_pass.strip() else _secrets.token_urlsafe(32) - - # Store vault pg-password in OpenBao KV (idempotent). - bao(f"{bao_env} bao kv put secret/vault pg-password=\"{vault_pg_pass}\"") - ok("vault KV entry written.") - - # Create vault PG user if absent, set its password, grant ADMIN OPTION on all service users. - create_vault_sql = ( - f"DO $$ BEGIN " - f"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'vault') THEN " - f"CREATE USER vault WITH LOGIN CREATEROLE; " - f"END IF; " - f"END $$;" - ) - psql(create_vault_sql) - psql(f"ALTER USER vault WITH PASSWORD '{vault_pg_pass}';") - for user in PG_USERS: - psql(f"GRANT {user} TO vault WITH ADMIN OPTION;") - ok("vault PG user configured with ADMIN OPTION on all service roles.") - - # -- DB engine connection config (uses vault user) ------------------------- - conn_url = ( - "postgresql://{{username}}:{{password}}" - f"@{pg_rw}/postgres?sslmode=disable" - ) - bao( - f"{bao_env} bao write database/config/cnpg-postgres" - f" plugin_name=postgresql-database-plugin" - f" allowed_roles='*'" - f" connection_url='{conn_url}'" - f" username='vault'" - f" password='{vault_pg_pass}'" - ) - ok("DB engine connection configured (vault user).") - - # Encode the rotation statement to avoid shell quoting issues with inner quotes. - rotation_b64 = base64.b64encode( - b"ALTER USER \"{{name}}\" WITH PASSWORD '{{password}}';" - ).decode() - - for user in PG_USERS: - bao( - f"{bao_env} sh -c '" - f"bao write database/static-roles/{user}" - f" db_name=cnpg-postgres" - f" username={user}" - f" rotation_period=86400" - f" \"rotation_statements=$(echo {rotation_b64} | base64 -d)\"'" - ) - ok(f" static-role/{user}") - - ok("Database secrets engine configured.") - - -# --------------------------------------------------------------------------- -# cmd_seed — main entry point -# --------------------------------------------------------------------------- - -@contextmanager -def _kratos_admin_pf(local_port=14434): - """Port-forward directly to the Kratos admin API.""" - proc = subprocess.Popen( - ["kubectl", context_arg(), "-n", "ory", "port-forward", - "svc/kratos-admin", f"{local_port}:80"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - time.sleep(1.5) - try: - yield f"http://localhost:{local_port}" - finally: - proc.terminate() - proc.wait() - - -def _kratos_api(base, path, method="GET", body=None): - url = f"{base}/admin{path}" - data = json.dumps(body).encode() if body is not None else None - req = urllib.request.Request( - url, data=data, - headers={"Content-Type": "application/json", "Accept": "application/json"}, - method=method, - ) - try: - with urllib.request.urlopen(req) as resp: - raw = resp.read() - return json.loads(raw) if raw else None - except urllib.error.HTTPError as e: - raise RuntimeError(f"Kratos API {method} {url} → {e.code}: {e.read().decode()}") - - -def _seed_kratos_admin_identity(ob_pod: str, root_token: str) -> tuple[str, str]: - """Ensure estudio-admin@ exists in Kratos and is the only admin identity. - - Returns (recovery_link, recovery_code), or ("", "") if Kratos is unreachable. - Idempotent: if the identity already exists, skips creation and just returns - a fresh recovery link+code. - """ - domain = get_domain() - admin_email = f"{ADMIN_USERNAME}@{domain}" - - ok(f"Ensuring Kratos admin identity ({admin_email})...") - try: - with _kratos_admin_pf() as base: - # Check if the identity already exists by searching by email - result = _kratos_api(base, f"/identities?credentials_identifier={admin_email}&page_size=1") - existing = result[0] if isinstance(result, list) and result else None - - if existing: - identity_id = existing["id"] - ok(f" admin identity exists ({identity_id[:8]}...)") - else: - identity = _kratos_api(base, "/identities", method="POST", body={ - "schema_id": "employee", - "traits": {"email": admin_email}, - "state": "active", - }) - identity_id = identity["id"] - ok(f" created admin identity ({identity_id[:8]}...)") - - # Generate fresh recovery code + link - recovery = _kratos_api(base, "/recovery/code", method="POST", body={ - "identity_id": identity_id, - "expires_in": "24h", - }) - recovery_link = recovery.get("recovery_link", "") if recovery else "" - recovery_code = recovery.get("recovery_code", "") if recovery else "" - except Exception as exc: - warn(f"Could not seed Kratos admin identity (Kratos may not be ready): {exc}") - return ("", "") - - # Update admin-identity-ids in OpenBao KV so kratos-admin-ui enforces access - bao_env = f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}'" - - def _bao(cmd): - return subprocess.run( - ["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao", - "--", "sh", "-c", cmd], - capture_output=True, text=True, - ) - - _bao(f"{bao_env} bao kv patch secret/kratos-admin admin-identity-ids=\"{admin_email}\"") - ok(f" ADMIN_IDENTITY_IDS set to {admin_email}") - return (recovery_link, recovery_code) - - -def cmd_seed() -> dict: - """Seed OpenBao KV with crypto-random credentials, then mirror to K8s Secrets. - - Returns a dict of credentials for use by callers (gitea admin pass, etc.). - Idempotent: reads existing OpenBao values before generating; never rotates. - """ - step("Seeding secrets...") - - creds = _seed_openbao() - - ob_pod = creds.pop("_ob_pod", "") - root_token = creds.pop("_root_token", "") - - s3_access_key = creds.get("s3-access-key", "") - s3_secret_key = creds.get("s3-secret-key", "") - hydra_system = creds.get("hydra-system-secret", "") - hydra_cookie = creds.get("hydra-cookie-secret", "") - hydra_pairwise = creds.get("hydra-pairwise-salt", "") - kratos_secrets_default = creds.get("kratos-secrets-default", "") - kratos_secrets_cookie = creds.get("kratos-secrets-cookie", "") - hive_oidc_id = creds.get("hive-oidc-client-id", "hive-local") - hive_oidc_sec = creds.get("hive-oidc-client-secret", "") - django_secret = creds.get("people-django-secret", "") - gitea_admin_pass = creds.get("gitea-admin-password", "") - - ok("Waiting for postgres cluster...") - pg_pod = "" - for _ in range(60): - phase = kube_out("-n", "data", "get", "cluster", "postgres", - "-o=jsonpath={.status.phase}") - if phase == "Cluster in healthy state": - pg_pod = kube_out("-n", "data", "get", "pods", - "-l=cnpg.io/cluster=postgres,role=primary", - "-o=jsonpath={.items[0].metadata.name}") - ok(f"Postgres ready ({pg_pod}).") - break - time.sleep(5) - else: - warn("Postgres not ready after 5 min -- continuing anyway.") - - if pg_pod: - ok("Ensuring postgres roles and databases exist...") - db_map = { - "kratos": "kratos_db", "hydra": "hydra_db", "gitea": "gitea_db", - "hive": "hive_db", "docs": "docs_db", "meet": "meet_db", - "drive": "drive_db", "messages": "messages_db", - "conversations": "conversations_db", - "people": "people_db", "find": "find_db", - "calendars": "calendars_db", "projects": "projects_db", - } - for user in PG_USERS: - # Only CREATE if missing -- passwords are managed by OpenBao static roles. - ensure_sql = ( - f"DO $$ BEGIN " - f"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{user}') " - f"THEN EXECUTE 'CREATE USER {user}'; END IF; END $$;" - ) - kube("exec", "-n", "data", pg_pod, "-c", "postgres", "--", - "psql", "-U", "postgres", "-c", ensure_sql, check=False) - db = db_map.get(user, f"{user}_db") - kube("exec", "-n", "data", pg_pod, "-c", "postgres", "--", - "psql", "-U", "postgres", "-c", - f"CREATE DATABASE {db} OWNER {user};", check=False) - - # Read CNPG superuser credentials and configure database secrets engine. - # CNPG creates secret named "{cluster}-app" (not "{cluster}-superuser") - # when owner is specified without an explicit secret field. - pg_user_b64 = kube_out("-n", "data", "get", "secret", "postgres-app", - "-o=jsonpath={.data.username}") - pg_pass_b64 = kube_out("-n", "data", "get", "secret", "postgres-app", - "-o=jsonpath={.data.password}") - pg_user = base64.b64decode(pg_user_b64).decode() if pg_user_b64 else "postgres" - pg_pass = base64.b64decode(pg_pass_b64).decode() if pg_pass_b64 else "" - - if ob_pod and root_token and pg_pass: - try: - _configure_db_engine(ob_pod, root_token, pg_user, pg_pass) - except Exception as exc: - warn(f"DB engine config failed: {exc}") - else: - warn("Skipping DB engine config -- missing ob_pod, root_token, or pg_pass.") - - ok("Creating K8s secrets (VSO will overwrite on next sync)...") - - ensure_ns("ory") - # Hydra app secrets -- DSN comes from VaultDynamicSecret hydra-db-creds. - create_secret("ory", "hydra", - secretsSystem=hydra_system, - secretsCookie=hydra_cookie, - **{"pairwise-salt": hydra_pairwise}, - ) - # Kratos non-rotating encryption keys -- DSN comes from VaultDynamicSecret kratos-db-creds. - create_secret("ory", "kratos-app-secrets", - secretsDefault=kratos_secrets_default, - secretsCookie=kratos_secrets_cookie, - ) - - ensure_ns("devtools") - # gitea-db-credentials comes from VaultDynamicSecret (static-creds/gitea). - create_secret("devtools", "gitea-s3-credentials", - **{"access-key": s3_access_key, "secret-key": s3_secret_key}) - create_secret("devtools", "gitea-admin-credentials", - username=GITEA_ADMIN_USER, password=gitea_admin_pass) - - # Sync Gitea admin password to Gitea's own DB (Gitea's existingSecret only - # applies on first run — subsequent K8s secret updates are not picked up - # automatically by Gitea). - if gitea_admin_pass: - gitea_pod = kube_out( - "-n", "devtools", "get", "pods", - "-l=app.kubernetes.io/name=gitea", - "-o=jsonpath={.items[0].metadata.name}", - ) - if gitea_pod: - r = subprocess.run( - ["kubectl", context_arg(), "-n", "devtools", "exec", gitea_pod, - "--", "gitea", "admin", "user", "change-password", - "--username", GITEA_ADMIN_USER, "--password", gitea_admin_pass, - "--must-change-password=false"], - capture_output=True, text=True, - ) - if r.returncode == 0: - ok(f"Gitea admin password synced to Gitea DB.") - else: - warn(f"Could not sync Gitea admin password: {r.stderr.strip()}") - else: - warn("Gitea pod not found — admin password NOT synced to Gitea DB. Run seed again after Gitea is deployed.") - - ensure_ns("storage") - s3_json = ( - '{"identities":[{"name":"seaweed","credentials":[{"accessKey":"' - + s3_access_key + '","secretKey":"' + s3_secret_key - + '"}],"actions":["Admin","Read","Write","List","Tagging"]}]}' - ) - create_secret("storage", "seaweedfs-s3-credentials", - S3_ACCESS_KEY=s3_access_key, S3_SECRET_KEY=s3_secret_key) - create_secret("storage", "seaweedfs-s3-json", **{"s3.json": s3_json}) - - ensure_ns("lasuite") - create_secret("lasuite", "seaweedfs-s3-credentials", - S3_ACCESS_KEY=s3_access_key, S3_SECRET_KEY=s3_secret_key) - # hive-db-url and people-db-credentials come from VaultDynamicSecrets. - create_secret("lasuite", "hive-oidc", - **{"client-id": hive_oidc_id, "client-secret": hive_oidc_sec}) - create_secret("lasuite", "people-django-secret", - DJANGO_SECRET_KEY=django_secret) - - ensure_ns("matrix") - - ensure_ns("media") - ensure_ns("monitoring") - - # Ensure the Kratos admin identity exists and ADMIN_IDENTITY_IDS is set. - # This runs after all other secrets are in place (Kratos must be up). - recovery_link, recovery_code = _seed_kratos_admin_identity(ob_pod, root_token) - if recovery_link: - ok("Admin recovery link (valid 24h):") - print(f" {recovery_link}") - if recovery_code: - ok("Admin recovery code (enter on the page above):") - print(f" {recovery_code}") - - dkim_pub = creds.get("messages-dkim-public-key", "") - if dkim_pub: - b64_key = "".join( - dkim_pub.replace("-----BEGIN PUBLIC KEY-----", "") - .replace("-----END PUBLIC KEY-----", "") - .split() - ) - domain = get_domain() - ok("DKIM DNS record (add to DNS at your registrar):") - print(f" default._domainkey.{domain} TXT \"v=DKIM1; k=rsa; p={b64_key}\"") - - ok("All secrets seeded.") - return creds - - -# --------------------------------------------------------------------------- -# cmd_verify — VSO E2E verification -# --------------------------------------------------------------------------- - -def cmd_verify(): - """End-to-end test of VSO -> OpenBao integration. - - 1. Writes a random value to OpenBao KV at secret/vso-test. - 2. Creates a VaultAuth + VaultStaticSecret in the 'ory' namespace - (already bound to the 'vso' Kubernetes auth role). - 3. Polls until VSO syncs the K8s Secret (up to 60s). - 4. Reads and base64-decodes the K8s Secret; compares to the expected value. - 5. Cleans up all test resources in a finally block. - """ - step("Verifying VSO -> OpenBao integration (E2E)...") - - ob_pod = kube_out( - "-n", "data", "get", "pods", - "-l=app.kubernetes.io/name=openbao,component=server", - "-o=jsonpath={.items[0].metadata.name}", - ) - if not ob_pod: - die("OpenBao pod not found -- run full bring-up first.") - - root_token_enc = kube_out( - "-n", "data", "get", "secret", "openbao-keys", - "-o=jsonpath={.data.root-token}", - ) - if not root_token_enc: - die("Could not read openbao-keys secret.") - root_token = base64.b64decode(root_token_enc).decode() - - bao_env = f"BAO_ADDR=http://127.0.0.1:8200 BAO_TOKEN='{root_token}'" - - def bao(cmd, *, check=True): - r = subprocess.run( - ["kubectl", context_arg(), "-n", "data", "exec", ob_pod, "-c", "openbao", - "--", "sh", "-c", cmd], - capture_output=True, text=True, - ) - if check and r.returncode != 0: - raise RuntimeError(f"bao failed (exit {r.returncode}): {r.stderr.strip()}") - return r.stdout.strip() - - test_value = _secrets.token_urlsafe(16) - test_ns = "ory" - test_name = "vso-verify" - - def cleanup(): - ok("Cleaning up test resources...") - kube("delete", "vaultstaticsecret", test_name, f"-n={test_ns}", - "--ignore-not-found", check=False) - kube("delete", "vaultauth", test_name, f"-n={test_ns}", - "--ignore-not-found", check=False) - kube("delete", "secret", test_name, f"-n={test_ns}", - "--ignore-not-found", check=False) - bao(f"{bao_env} bao kv delete secret/vso-test 2>/dev/null || true", check=False) - - try: - # 1. Write test value to OpenBao KV - ok(f"Writing test sentinel to OpenBao secret/vso-test ...") - bao(f"{bao_env} bao kv put secret/vso-test test-key='{test_value}'") - - # 2. Create VaultAuth in ory (already in vso role's bound namespaces) - ok(f"Creating VaultAuth {test_ns}/{test_name} ...") - kube_apply(f""" -apiVersion: secrets.hashicorp.com/v1beta1 -kind: VaultAuth -metadata: - name: {test_name} - namespace: {test_ns} -spec: - method: kubernetes - mount: kubernetes - kubernetes: - role: vso - serviceAccount: default -""") - - # 3. Create VaultStaticSecret pointing at our test KV path - ok(f"Creating VaultStaticSecret {test_ns}/{test_name} ...") - kube_apply(f""" -apiVersion: secrets.hashicorp.com/v1beta1 -kind: VaultStaticSecret -metadata: - name: {test_name} - namespace: {test_ns} -spec: - vaultAuthRef: {test_name} - mount: secret - type: kv-v2 - path: vso-test - refreshAfter: 10s - destination: - name: {test_name} - create: true - overwrite: true -""") - - # 4. Poll until VSO sets secretMAC (= synced) - ok("Waiting for VSO to sync (up to 60s) ...") - deadline = time.time() + 60 - synced = False - while time.time() < deadline: - mac = kube_out( - "get", "vaultstaticsecret", test_name, f"-n={test_ns}", - "-o=jsonpath={.status.secretMAC}", "--ignore-not-found", - ) - if mac and mac not in ("", ""): - synced = True - break - time.sleep(3) - - if not synced: - msg = kube_out( - "get", "vaultstaticsecret", test_name, f"-n={test_ns}", - "-o=jsonpath={.status.conditions[0].message}", "--ignore-not-found", - ) - raise RuntimeError(f"VSO did not sync within 60s. Last status: {msg or 'unknown'}") - - # 5. Read and verify the K8s Secret value - ok("Verifying K8s Secret contents ...") - raw = kube_out( - "get", "secret", test_name, f"-n={test_ns}", - "-o=jsonpath={.data.test-key}", "--ignore-not-found", - ) - if not raw: - raise RuntimeError( - f"K8s Secret {test_ns}/{test_name} not found or missing key 'test-key'." - ) - actual = base64.b64decode(raw).decode() - if actual != test_value: - raise RuntimeError( - f"Value mismatch!\n expected: {test_value!r}\n got: {actual!r}" - ) - - ok(f"Sentinel value matches -- VSO -> OpenBao integration is working.") - - except Exception as exc: - cleanup() - die(f"VSO verification FAILED: {exc}") - - cleanup() - ok("VSO E2E verification passed.") diff --git a/sunbeam/services.py b/sunbeam/services.py deleted file mode 100644 index 60e1301a..00000000 --- a/sunbeam/services.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Service management — status, logs, restart.""" -import subprocess -import sys -from pathlib import Path - -import sunbeam.kube as _kube_mod -from sunbeam.kube import kube, kube_out, parse_target -from sunbeam.tools import ensure_tool -from sunbeam.output import step, ok, warn, die - -MANAGED_NS = ["data", "devtools", "ingress", "lasuite", "matrix", "media", "ory", - "storage", "vault-secrets-operator"] - -SERVICES_TO_RESTART = [ - ("ory", "hydra"), - ("ory", "kratos"), - ("ory", "login-ui"), - ("devtools", "gitea"), - ("storage", "seaweedfs-filer"), - ("lasuite", "hive"), - ("lasuite", "people-backend"), - ("lasuite", "people-frontend"), - ("lasuite", "people-celery-worker"), - ("lasuite", "people-celery-beat"), - ("lasuite", "projects"), - ("matrix", "tuwunel"), - ("media", "livekit-server"), -] - - -def _k8s_ctx(): - """Return the kubectl --context flag matching the active environment.""" - return [_kube_mod.context_arg()] - - -def _capture_out(cmd, *, default=""): - r = subprocess.run(cmd, capture_output=True, text=True) - return r.stdout.strip() if r.returncode == 0 else default - - -def _vso_sync_status(): - """Print VSO VaultStaticSecret and VaultDynamicSecret sync health. - - VSS synced = status.secretMAC is non-empty. - VDS synced = status.lastRenewalTime is non-zero. - """ - step("VSO secret sync status...") - all_ok = True - - # VaultStaticSecrets: synced when secretMAC is populated - vss_raw = _capture_out([ - "kubectl", *_k8s_ctx(), "get", "vaultstaticsecret", "-A", "--no-headers", - "-o=custom-columns=" - "NS:.metadata.namespace,NAME:.metadata.name,MAC:.status.secretMAC", - ]) - cur_ns = None - for line in sorted(vss_raw.splitlines()): - cols = line.split() - if len(cols) < 2: - continue - ns, name = cols[0], cols[1] - mac = cols[2] if len(cols) > 2 else "" - synced = bool(mac and mac != "") - if not synced: - all_ok = False - icon = "\u2713" if synced else "\u2717" - if ns != cur_ns: - print(f" {ns} (VSS):") - cur_ns = ns - print(f" {icon} {name}") - - # VaultDynamicSecrets: synced when lastRenewalTime is non-zero - vds_raw = _capture_out([ - "kubectl", *_k8s_ctx(), "get", "vaultdynamicsecret", "-A", "--no-headers", - "-o=custom-columns=" - "NS:.metadata.namespace,NAME:.metadata.name,RENEWED:.status.lastRenewalTime", - ]) - cur_ns = None - for line in sorted(vds_raw.splitlines()): - cols = line.split() - if len(cols) < 2: - continue - ns, name = cols[0], cols[1] - renewed = cols[2] if len(cols) > 2 else "0" - synced = renewed not in ("", "0", "") - if not synced: - all_ok = False - icon = "\u2713" if synced else "\u2717" - if ns != cur_ns: - print(f" {ns} (VDS):") - cur_ns = ns - print(f" {icon} {name}") - - print() - if all_ok: - ok("All VSO secrets synced.") - else: - warn("Some VSO secrets are not synced.") - - -def cmd_status(target: str | None): - """Show pod health, optionally filtered by namespace or namespace/service.""" - step("Pod health across all namespaces...") - - ns_set = set(MANAGED_NS) - - if target is None: - # All pods across managed namespaces - raw = _capture_out([ - "kubectl", *_k8s_ctx(), - "get", "pods", - "--field-selector=metadata.namespace!= kube-system", - "-A", "--no-headers", - ]) - pods = [] - for line in raw.splitlines(): - cols = line.split() - if len(cols) < 4: - continue - ns = cols[0] - if ns not in ns_set: - continue - pods.append(cols) - else: - ns, name = parse_target(target) - if name: - # Specific service: namespace/service - raw = _capture_out([ - "kubectl", *_k8s_ctx(), - "get", "pods", "-n", ns, "-l", f"app={name}", "--no-headers", - ]) - pods = [] - for line in raw.splitlines(): - cols = line.split() - if len(cols) < 3: - continue - # Prepend namespace since -n output doesn't include it - pods.append([ns] + cols) - else: - # Namespace only - raw = _capture_out([ - "kubectl", *_k8s_ctx(), - "get", "pods", "-n", ns, "--no-headers", - ]) - pods = [] - for line in raw.splitlines(): - cols = line.split() - if len(cols) < 3: - continue - pods.append([ns] + cols) - - if not pods: - warn("No pods found in managed namespaces.") - return - - all_ok = True - cur_ns = None - icon_map = {"Running": "\u2713", "Completed": "\u2713", "Succeeded": "\u2713", - "Pending": "\u25cb", "Failed": "\u2717", "Unknown": "?"} - for cols in sorted(pods, key=lambda c: (c[0], c[1])): - ns, name, ready, status = cols[0], cols[1], cols[2], cols[3] - if ns != cur_ns: - print(f" {ns}:") - cur_ns = ns - icon = icon_map.get(status, "?") - unhealthy = status not in ("Running", "Completed", "Succeeded") - # Only check ready ratio for Running pods — Completed/Succeeded pods - # legitimately report 0/N containers ready. - if not unhealthy and status == "Running" and "/" in ready: - r, t = ready.split("/") - unhealthy = r != t - if unhealthy: - all_ok = False - print(f" {icon} {name:<50} {ready:<6} {status}") - - print() - if all_ok: - ok("All pods healthy.") - else: - warn("Some pods are not ready.") - - _vso_sync_status() - - -def cmd_logs(target: str, follow: bool): - """Stream logs for a service. Target must include service name (e.g. ory/kratos).""" - ns, name = parse_target(target) - if not name: - die("Logs require a service name, e.g. 'ory/kratos'.") - - _kube_mod.ensure_tunnel() - kubectl = str(ensure_tool("kubectl")) - cmd = [kubectl, _kube_mod.context_arg(), "-n", ns, "logs", - "-l", f"app={name}", "--tail=100"] - if follow: - cmd.append("--follow") - - proc = subprocess.Popen(cmd) - proc.wait() - - -def cmd_get(target: str, output: str = "yaml"): - """Print raw kubectl get output for a pod or resource (ns/name). - - Usage: sunbeam get vault-secrets-operator/vault-secrets-operator-test - sunbeam get ory/kratos-abc -o json - """ - ns, name = parse_target(target) - if not ns or not name: - die("get requires namespace/name, e.g. 'sunbeam get ory/kratos-abc'") - # Try pod first, fall back to any resource type if caller passes kind/ns/name - result = kube_out("get", "pod", name, "-n", ns, f"-o={output}") - if not result: - die(f"Pod {ns}/{name} not found.") - print(result) - - -def cmd_restart(target: str | None): - """Restart deployments. None=all, 'ory'=namespace, 'ory/kratos'=specific.""" - step("Restarting services...") - - if target is None: - matched = SERVICES_TO_RESTART - else: - ns, name = parse_target(target) - if name: - matched = [(n, d) for n, d in SERVICES_TO_RESTART if n == ns and d == name] - else: - matched = [(n, d) for n, d in SERVICES_TO_RESTART if n == ns] - - if not matched: - warn(f"No matching services for target: {target}") - return - - for ns, dep in matched: - kube("-n", ns, "rollout", "restart", f"deployment/{dep}", check=False) - ok("Done.") diff --git a/sunbeam/src/cli.rs b/sunbeam/src/cli.rs deleted file mode 100644 index 4f3d93fa..00000000 --- a/sunbeam/src/cli.rs +++ /dev/null @@ -1,1011 +0,0 @@ -use sunbeam_sdk::error::Result; -use sunbeam_sdk::images::BuildTarget; -use sunbeam_sdk::output::OutputFormat; -use clap::{Parser, Subcommand}; - -/// Sunbeam local dev stack manager. -#[derive(Parser, Debug)] -#[command(name = "sunbeam", about = "Sunbeam Studios CLI")] -pub struct Cli { - /// Named context to use (overrides current-context from config). - #[arg(long)] - pub context: Option, - - /// Domain suffix override (e.g. sunbeam.pt). - #[arg(long, default_value = "")] - pub domain: String, - - /// ACME email for cert-manager (e.g. ops@sunbeam.pt). - #[arg(long, default_value = "")] - pub email: String, - - /// Output format (json, yaml, table). Default: table. - #[arg(short = 'o', long = "output", global = true, default_value = "table")] - pub output_format: OutputFormat, - - #[command(subcommand)] - pub verb: Option, -} - - -#[derive(Subcommand, Debug)] -pub enum Verb { - /// Platform operations (cluster, builds, deploys). - Platform { - #[command(subcommand)] - action: PlatformAction, - }, - - /// Manage sunbeam configuration. - Config { - #[command(subcommand)] - action: Option, - }, - - /// Project management. - Pm { - #[command(subcommand)] - action: Option, - }, - - /// Self-update from latest mainline commit. - Update, - - /// Print version info. - Version, - - // -- Service commands ----------------------------------------------------- - - /// Authentication, identity & OAuth2 management. - Auth { - #[command(subcommand)] - action: sunbeam_sdk::identity::cli::AuthCommand, - }, - - /// Version control. - Vcs { - #[command(subcommand)] - action: sunbeam_sdk::gitea::cli::VcsCommand, - }, - - /// Chat and messaging. - Chat { - #[command(subcommand)] - action: sunbeam_sdk::matrix::cli::ChatCommand, - }, - - /// Search engine. - Search { - #[command(subcommand)] - action: sunbeam_sdk::search::cli::SearchCommand, - }, - - /// Object storage. - Storage { - #[command(subcommand)] - action: sunbeam_sdk::storage::cli::StorageCommand, - }, - - /// Media and video. - Media { - #[command(subcommand)] - action: sunbeam_sdk::media::cli::MediaCommand, - }, - - /// Monitoring. - Mon { - #[command(subcommand)] - action: sunbeam_sdk::monitoring::cli::MonCommand, - }, - - /// Secrets management. - Vault { - #[command(subcommand)] - action: sunbeam_sdk::openbao::cli::VaultCommand, - }, - - /// Video meetings. - Meet { - #[command(subcommand)] - action: sunbeam_sdk::lasuite::cli::MeetCommand, - }, - - /// File storage. - Drive { - #[command(subcommand)] - action: sunbeam_sdk::lasuite::cli::DriveCommand, - }, - - /// Email. - Mail { - #[command(subcommand)] - action: sunbeam_sdk::lasuite::cli::MailCommand, - }, - - /// Calendar. - Cal { - #[command(subcommand)] - action: sunbeam_sdk::lasuite::cli::CalCommand, - }, - - /// Search across services. - Find { - #[command(subcommand)] - action: sunbeam_sdk::lasuite::cli::FindCommand, - }, -} - -#[derive(Subcommand, Debug)] -pub enum PlatformAction { - /// Full cluster bring-up. - Up, - /// Pod health (optionally scoped). - Status { - /// namespace or namespace/name - target: Option, - }, - /// Build and apply manifests. - Apply { - /// Limit apply to one namespace. - namespace: Option, - /// Apply all namespaces without confirmation. - #[arg(long = "all")] - apply_all: bool, - /// Domain suffix (e.g. sunbeam.pt). - #[arg(long, default_value = "")] - domain: String, - /// ACME email for cert-manager. - #[arg(long, default_value = "")] - email: String, - }, - /// Seed credentials and secrets. - Seed, - /// End-to-end integration test. - Verify, - /// View service logs. - Logs { - /// namespace/name - target: String, - /// Stream logs. - #[arg(short, long)] - follow: bool, - }, - /// Get a resource (ns/name). - Get { - /// namespace/name - target: String, - /// Output format (yaml, json, wide). - #[arg(long = "kubectl-output", default_value = "yaml", value_parser = ["yaml", "json", "wide"])] - output: String, - }, - /// Rolling restart of services. - Restart { - /// namespace or namespace/name - target: Option, - }, - /// Build an artifact. - Build { - /// What to build. - what: BuildTarget, - /// Push image to registry after building. - #[arg(long)] - push: bool, - /// Apply manifests and rollout restart after pushing (implies --push). - #[arg(long)] - deploy: bool, - /// Disable layer cache. - #[arg(long)] - no_cache: bool, - }, - /// Service health checks. - Check { - /// namespace or namespace/name - target: Option, - }, - /// Mirror container images. - Mirror, - /// Bootstrap orgs, repos, and services. - Bootstrap, - /// kubectl passthrough. - K8s { - /// arguments forwarded verbatim to kubectl - #[arg(trailing_var_arg = true, allow_hyphen_values = true)] - kubectl_args: Vec, - }, -} - -#[derive(Subcommand, Debug)] -pub enum PmAction { - /// List tickets across Planka and Gitea. - List { - /// Filter by source: planka, gitea, or all (default: all). - #[arg(long, default_value = "all")] - source: String, - /// Filter by state: open, closed, all (default: open). - #[arg(long, default_value = "open")] - state: String, - }, - /// Show ticket details. - Show { - /// Ticket ID (e.g. p:42 for Planka, g:studio/cli#7 for Gitea). - id: String, - }, - /// Create a new ticket. - Create { - /// Ticket title. - title: String, - /// Ticket body/description. - #[arg(long, default_value = "")] - body: String, - /// Source: planka or gitea. - #[arg(long, default_value = "gitea")] - source: String, - /// Target: board ID for Planka, or org/repo for Gitea. - #[arg(long, default_value = "")] - target: String, - }, - /// Add a comment to a ticket. - Comment { - /// Ticket ID. - id: String, - /// Comment text. - text: String, - }, - /// Close/complete a ticket. - Close { - /// Ticket ID. - id: String, - }, - /// Assign a user to a ticket. - Assign { - /// Ticket ID. - id: String, - /// Username or email to assign. - user: String, - }, -} - -#[derive(Subcommand, Debug)] -pub enum ConfigAction { - /// Set configuration values for the current context. - Set { - /// Domain suffix (e.g. sunbeam.pt). - #[arg(long, default_value = "")] - domain: String, - /// Production SSH host (e.g. user@server.example.com). - #[arg(long, default_value = "")] - host: String, - /// Infrastructure directory root. - #[arg(long, default_value = "")] - infra_dir: String, - /// ACME email for Let's Encrypt certificates. - #[arg(long, default_value = "")] - acme_email: String, - /// Context name to configure (default: current context). - #[arg(long, default_value = "")] - context_name: String, - }, - /// Get current configuration. - Get, - /// Clear configuration. - Clear, - /// Switch the active context. - UseContext { - /// Context name to switch to. - name: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::Parser; - - fn parse(args: &[&str]) -> Cli { - Cli::try_parse_from(args).unwrap() - } - - // 1. test_up - #[test] - fn test_up() { - let cli = parse(&["sunbeam", "platform", "up"]); - assert!(matches!(cli.verb, Some(Verb::Platform { action: PlatformAction::Up }))); - } - - // 2. test_status_no_target - #[test] - fn test_status_no_target() { - let cli = parse(&["sunbeam", "platform", "status"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Status { target } }) => assert!(target.is_none()), - _ => panic!("expected Status"), - } - } - - // 3. test_status_with_namespace - #[test] - fn test_status_with_namespace() { - let cli = parse(&["sunbeam", "platform", "status", "ory"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Status { target } }) => assert_eq!(target.unwrap(), "ory"), - _ => panic!("expected Status"), - } - } - - // 4. test_logs_no_follow - #[test] - fn test_logs_no_follow() { - let cli = parse(&["sunbeam", "platform", "logs", "ory/kratos"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Logs { target, follow } }) => { - assert_eq!(target, "ory/kratos"); - assert!(!follow); - } - _ => panic!("expected Logs"), - } - } - - // 5. test_logs_follow_short - #[test] - fn test_logs_follow_short() { - let cli = parse(&["sunbeam", "platform", "logs", "ory/kratos", "-f"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Logs { follow, .. } }) => assert!(follow), - _ => panic!("expected Logs"), - } - } - - // 6. test_build_proxy - #[test] - fn test_build_proxy() { - let cli = parse(&["sunbeam", "platform", "build", "proxy"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { what, push, deploy, no_cache } }) => { - assert!(matches!(what, BuildTarget::Proxy)); - assert!(!push); - assert!(!deploy); - assert!(!no_cache); - } - _ => panic!("expected Build"), - } - } - - // 7. test_build_deploy_flag - #[test] - fn test_build_deploy_flag() { - let cli = parse(&["sunbeam", "platform", "build", "proxy", "--deploy"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { deploy, push, no_cache, .. } }) => { - assert!(deploy); - // clap does not imply --push; that logic is in dispatch() - assert!(!push); - assert!(!no_cache); - } - _ => panic!("expected Build"), - } - } - - // 8. test_build_invalid_target - #[test] - fn test_build_invalid_target() { - let result = Cli::try_parse_from(&["sunbeam", "platform", "build", "notavalidtarget"]); - assert!(result.is_err()); - } - - // 12. test_apply_no_namespace - #[test] - fn test_apply_no_namespace() { - let cli = parse(&["sunbeam", "platform", "apply"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Apply { namespace, .. } }) => assert!(namespace.is_none()), - _ => panic!("expected Apply"), - } - } - - // 13. test_apply_with_namespace - #[test] - fn test_apply_with_namespace() { - let cli = parse(&["sunbeam", "platform", "apply", "lasuite"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Apply { namespace, .. } }) => assert_eq!(namespace.unwrap(), "lasuite"), - _ => panic!("expected Apply"), - } - } - - // 14. test_config_set - #[test] - fn test_config_set() { - let cli = parse(&[ - "sunbeam", "config", "set", - "--host", "user@example.com", - "--infra-dir", "/path/to/infra", - ]); - match cli.verb { - Some(Verb::Config { action: Some(ConfigAction::Set { host, infra_dir, .. }) }) => { - assert_eq!(host, "user@example.com"); - assert_eq!(infra_dir, "/path/to/infra"); - } - _ => panic!("expected Config Set"), - } - } - - // 15. test_config_get / test_config_clear - #[test] - fn test_config_get() { - let cli = parse(&["sunbeam", "config", "get"]); - match cli.verb { - Some(Verb::Config { action: Some(ConfigAction::Get) }) => {} - _ => panic!("expected Config Get"), - } - } - - #[test] - fn test_config_clear() { - let cli = parse(&["sunbeam", "config", "clear"]); - match cli.verb { - Some(Verb::Config { action: Some(ConfigAction::Clear) }) => {} - _ => panic!("expected Config Clear"), - } - } - - // 16. test_no_args_prints_help - #[test] - fn test_no_args_prints_help() { - let cli = parse(&["sunbeam"]); - assert!(cli.verb.is_none()); - } - - // 17. test_get_json_output - #[test] - fn test_get_json_output() { - let cli = parse(&["sunbeam", "platform", "get", "ory/kratos-abc", "--kubectl-output", "json"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Get { target, output } }) => { - assert_eq!(target, "ory/kratos-abc"); - assert_eq!(output, "json"); - } - _ => panic!("expected Get"), - } - } - - // 18. test_check_with_target - #[test] - fn test_check_with_target() { - let cli = parse(&["sunbeam", "platform", "check", "devtools"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Check { target } }) => assert_eq!(target.unwrap(), "devtools"), - _ => panic!("expected Check"), - } - } - - // 19. test_build_messages_components - #[test] - fn test_build_messages_backend() { - let cli = parse(&["sunbeam", "platform", "build", "messages-backend"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => { - assert!(matches!(what, BuildTarget::MessagesBackend)); - } - _ => panic!("expected Build"), - } - } - - #[test] - fn test_build_messages_frontend() { - let cli = parse(&["sunbeam", "platform", "build", "messages-frontend"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => { - assert!(matches!(what, BuildTarget::MessagesFrontend)); - } - _ => panic!("expected Build"), - } - } - - #[test] - fn test_build_messages_mta_in() { - let cli = parse(&["sunbeam", "platform", "build", "messages-mta-in"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => { - assert!(matches!(what, BuildTarget::MessagesMtaIn)); - } - _ => panic!("expected Build"), - } - } - - #[test] - fn test_build_messages_mta_out() { - let cli = parse(&["sunbeam", "platform", "build", "messages-mta-out"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => { - assert!(matches!(what, BuildTarget::MessagesMtaOut)); - } - _ => panic!("expected Build"), - } - } - - #[test] - fn test_build_messages_mpa() { - let cli = parse(&["sunbeam", "platform", "build", "messages-mpa"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => { - assert!(matches!(what, BuildTarget::MessagesMpa)); - } - _ => panic!("expected Build"), - } - } - - #[test] - fn test_build_messages_socks_proxy() { - let cli = parse(&["sunbeam", "platform", "build", "messages-socks-proxy"]); - match cli.verb { - Some(Verb::Platform { action: PlatformAction::Build { what, .. } }) => { - assert!(matches!(what, BuildTarget::MessagesSocksProxy)); - } - _ => panic!("expected Build"), - } - } - - // -- New service subcommand tests ----------------------------------------- - - #[test] - fn test_auth_identity_list() { - let cli = parse(&["sunbeam", "auth", "identity", "list"]); - assert!(matches!(cli.verb, Some(Verb::Auth { .. }))); - } - - #[test] - fn test_auth_login() { - let cli = parse(&["sunbeam", "auth", "login"]); - assert!(matches!(cli.verb, Some(Verb::Auth { .. }))); - } - - #[test] - fn test_vcs_repo_search() { - let cli = parse(&["sunbeam", "vcs", "repo", "search", "-q", "cli"]); - assert!(matches!(cli.verb, Some(Verb::Vcs { .. }))); - } - - #[test] - fn test_vcs_issue_list() { - let cli = parse(&["sunbeam", "vcs", "issue", "list", "-r", "studio/cli"]); - assert!(matches!(cli.verb, Some(Verb::Vcs { .. }))); - } - - #[test] - fn test_chat_whoami() { - let cli = parse(&["sunbeam", "chat", "whoami"]); - assert!(matches!(cli.verb, Some(Verb::Chat { .. }))); - } - - #[test] - fn test_chat_room_list() { - let cli = parse(&["sunbeam", "chat", "room", "list"]); - assert!(matches!(cli.verb, Some(Verb::Chat { .. }))); - } - - #[test] - fn test_search_cluster_health() { - let cli = parse(&["sunbeam", "search", "cluster", "health"]); - assert!(matches!(cli.verb, Some(Verb::Search { .. }))); - } - - #[test] - fn test_storage_bucket_list() { - let cli = parse(&["sunbeam", "storage", "bucket", "list"]); - assert!(matches!(cli.verb, Some(Verb::Storage { .. }))); - } - - #[test] - fn test_media_room_list() { - let cli = parse(&["sunbeam", "media", "room", "list"]); - assert!(matches!(cli.verb, Some(Verb::Media { .. }))); - } - - #[test] - fn test_mon_prometheus_query() { - let cli = parse(&["sunbeam", "mon", "prometheus", "query", "-q", "up"]); - assert!(matches!(cli.verb, Some(Verb::Mon { .. }))); - } - - #[test] - fn test_mon_grafana_dashboard_list() { - let cli = parse(&["sunbeam", "mon", "grafana", "dashboard", "list"]); - assert!(matches!(cli.verb, Some(Verb::Mon { .. }))); - } - - #[test] - fn test_vault_status() { - let cli = parse(&["sunbeam", "vault", "status"]); - assert!(matches!(cli.verb, Some(Verb::Vault { .. }))); - } - - #[test] - fn test_meet_room_list() { - let cli = parse(&["sunbeam", "meet", "room", "list"]); - assert!(matches!(cli.verb, Some(Verb::Meet { .. }))); - } - - #[test] - fn test_drive_file_list() { - let cli = parse(&["sunbeam", "drive", "file", "list"]); - assert!(matches!(cli.verb, Some(Verb::Drive { .. }))); - } - - #[test] - fn test_mail_mailbox_list() { - let cli = parse(&["sunbeam", "mail", "mailbox", "list"]); - assert!(matches!(cli.verb, Some(Verb::Mail { .. }))); - } - - #[test] - fn test_cal_calendar_list() { - let cli = parse(&["sunbeam", "cal", "calendar", "list"]); - assert!(matches!(cli.verb, Some(Verb::Cal { .. }))); - } - - #[test] - fn test_find_search() { - let cli = parse(&["sunbeam", "find", "search", "-q", "hello"]); - assert!(matches!(cli.verb, Some(Verb::Find { .. }))); - } - - #[test] - fn test_global_output_format() { - let cli = parse(&["sunbeam", "-o", "json", "vault", "status"]); - assert!(matches!(cli.output_format, OutputFormat::Json)); - assert!(matches!(cli.verb, Some(Verb::Vault { .. }))); - } - - #[test] - fn test_infra_commands_preserved() { - // Verify all old infra commands still parse under platform - assert!(matches!(parse(&["sunbeam", "platform", "up"]).verb, Some(Verb::Platform { action: PlatformAction::Up }))); - assert!(matches!(parse(&["sunbeam", "platform", "seed"]).verb, Some(Verb::Platform { action: PlatformAction::Seed }))); - assert!(matches!(parse(&["sunbeam", "platform", "verify"]).verb, Some(Verb::Platform { action: PlatformAction::Verify }))); - assert!(matches!(parse(&["sunbeam", "platform", "mirror"]).verb, Some(Verb::Platform { action: PlatformAction::Mirror }))); - assert!(matches!(parse(&["sunbeam", "platform", "bootstrap"]).verb, Some(Verb::Platform { action: PlatformAction::Bootstrap }))); - assert!(matches!(parse(&["sunbeam", "update"]).verb, Some(Verb::Update))); - assert!(matches!(parse(&["sunbeam", "version"]).verb, Some(Verb::Version))); - } -} - -/// Main dispatch function — parse CLI args and route to subcommands. -pub async fn dispatch() -> Result<()> { - let cli = Cli::parse(); - - // Resolve the active context from config + CLI flags (like kubectl) - let config = sunbeam_sdk::config::load_config(); - let active = sunbeam_sdk::config::resolve_context( - &config, - "", - cli.context.as_deref(), - &cli.domain, - ); - - // Initialize kube context from the resolved context - let kube_ctx_str = if active.kube_context.is_empty() { - "sunbeam".to_string() - } else { - active.kube_context.clone() - }; - let ssh_host_str = active.ssh_host.clone(); - sunbeam_sdk::kube::set_context(&kube_ctx_str, &ssh_host_str); - - // Store active context globally for other modules to read - sunbeam_sdk::config::set_active_context(active); - - match cli.verb { - None => { - // Print help via clap - use clap::CommandFactory; - Cli::command().print_help()?; - println!(); - Ok(()) - } - - Some(Verb::Platform { action }) => match action { - PlatformAction::Up => sunbeam_sdk::cluster::cmd_up().await, - - PlatformAction::Status { target } => { - sunbeam_sdk::services::cmd_status(target.as_deref()).await - } - - PlatformAction::Apply { - namespace, - apply_all, - domain, - email, - } => { - let is_production = !sunbeam_sdk::config::active_context().ssh_host.is_empty(); - let env_str = if is_production { "production" } else { "local" }; - let domain = if domain.is_empty() { - cli.domain.clone() - } else { - domain - }; - let email = if email.is_empty() { - cli.email.clone() - } else { - email - }; - let ns = namespace.unwrap_or_default(); - - // Production full-apply requires --all or confirmation - if is_production && ns.is_empty() && !apply_all { - sunbeam_sdk::output::warn( - "This will apply ALL namespaces to production.", - ); - eprint!(" Continue? [y/N] "); - let mut answer = String::new(); - std::io::stdin().read_line(&mut answer)?; - if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") { - println!("Aborted."); - return Ok(()); - } - } - - sunbeam_sdk::manifests::cmd_apply(&env_str, &domain, &email, &ns).await - } - - PlatformAction::Seed => sunbeam_sdk::secrets::cmd_seed().await, - - PlatformAction::Verify => sunbeam_sdk::secrets::cmd_verify().await, - - PlatformAction::Logs { target, follow } => { - sunbeam_sdk::services::cmd_logs(&target, follow).await - } - - PlatformAction::Get { target, output } => { - sunbeam_sdk::services::cmd_get(&target, &output).await - } - - PlatformAction::Restart { target } => { - sunbeam_sdk::services::cmd_restart(target.as_deref()).await - } - - PlatformAction::Build { what, push, deploy, no_cache } => { - let push = push || deploy; - sunbeam_sdk::images::cmd_build(&what, push, deploy, no_cache).await - } - - PlatformAction::Check { target } => { - sunbeam_sdk::checks::cmd_check(target.as_deref()).await - } - - PlatformAction::Mirror => sunbeam_sdk::images::cmd_mirror().await, - - PlatformAction::Bootstrap => sunbeam_sdk::gitea::cmd_bootstrap().await, - - PlatformAction::K8s { kubectl_args } => { - sunbeam_sdk::kube::cmd_k8s(&kubectl_args).await - } - }, - - Some(Verb::Config { action }) => match action { - None => { - use clap::CommandFactory; - // Print config subcommand help - let mut cmd = Cli::command(); - let sub = cmd - .find_subcommand_mut("config") - .expect("config subcommand"); - sub.print_help()?; - println!(); - Ok(()) - } - Some(ConfigAction::Set { - domain: set_domain, - host, - infra_dir, - acme_email, - context_name, - }) => { - let mut config = sunbeam_sdk::config::load_config(); - // Determine which context to modify - let ctx_name = if context_name.is_empty() { - if !config.current_context.is_empty() { - config.current_context.clone() - } else { - "production".to_string() - } - } else { - context_name - }; - - let ctx = config.contexts.entry(ctx_name.clone()).or_default(); - if !set_domain.is_empty() { - ctx.domain = set_domain; - } - if !host.is_empty() { - ctx.ssh_host = host.clone(); - config.production_host = host; // keep legacy field in sync - } - if !infra_dir.is_empty() { - ctx.infra_dir = infra_dir.clone(); - config.infra_directory = infra_dir; - } - if !acme_email.is_empty() { - ctx.acme_email = acme_email.clone(); - config.acme_email = acme_email; - } - if config.current_context.is_empty() { - config.current_context = ctx_name; - } - sunbeam_sdk::config::save_config(&config) - } - Some(ConfigAction::UseContext { name }) => { - let mut config = sunbeam_sdk::config::load_config(); - if !config.contexts.contains_key(&name) { - sunbeam_sdk::output::warn(&format!("Context '{name}' does not exist. Creating empty context.")); - config.contexts.insert(name.clone(), sunbeam_sdk::config::Context::default()); - } - config.current_context = name.clone(); - sunbeam_sdk::config::save_config(&config)?; - sunbeam_sdk::output::ok(&format!("Switched to context '{name}'.")); - Ok(()) - } - Some(ConfigAction::Get) => { - let config = sunbeam_sdk::config::load_config(); - let current = if config.current_context.is_empty() { - "(none)" - } else { - &config.current_context - }; - sunbeam_sdk::output::ok(&format!("Current context: {current}")); - println!(); - for (name, ctx) in &config.contexts { - let marker = if name == current { " *" } else { "" }; - sunbeam_sdk::output::ok(&format!("Context: {name}{marker}")); - if !ctx.domain.is_empty() { - sunbeam_sdk::output::ok(&format!(" domain: {}", ctx.domain)); - } - if !ctx.kube_context.is_empty() { - sunbeam_sdk::output::ok(&format!(" kube-context: {}", ctx.kube_context)); - } - if !ctx.ssh_host.is_empty() { - sunbeam_sdk::output::ok(&format!(" ssh-host: {}", ctx.ssh_host)); - } - if !ctx.infra_dir.is_empty() { - sunbeam_sdk::output::ok(&format!(" infra-dir: {}", ctx.infra_dir)); - } - if !ctx.acme_email.is_empty() { - sunbeam_sdk::output::ok(&format!(" acme-email: {}", ctx.acme_email)); - } - println!(); - } - Ok(()) - } - Some(ConfigAction::Clear) => sunbeam_sdk::config::clear_config(), - }, - - Some(Verb::Auth { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::identity::cli::dispatch(action, &sc, cli.output_format).await - } - - Some(Verb::Vcs { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::gitea::cli::dispatch(action, &sc, cli.output_format).await - } - - Some(Verb::Chat { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::matrix::cli::dispatch(&sc, cli.output_format, action).await - } - - Some(Verb::Search { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::search::cli::dispatch(action, &sc, cli.output_format).await - } - - Some(Verb::Storage { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::storage::cli::dispatch(action, &sc, cli.output_format).await - } - - Some(Verb::Media { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::media::cli::dispatch(action, &sc, cli.output_format).await - } - - Some(Verb::Mon { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::monitoring::cli::dispatch(action, &sc, cli.output_format).await - } - - Some(Verb::Vault { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::openbao::cli::dispatch(action, &sc, cli.output_format).await - } - - Some(Verb::Meet { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::lasuite::cli::dispatch_meet(action, &sc, cli.output_format).await - } - - Some(Verb::Drive { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::lasuite::cli::dispatch_drive(action, &sc, cli.output_format).await - } - - Some(Verb::Mail { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::lasuite::cli::dispatch_mail(action, &sc, cli.output_format).await - } - - Some(Verb::Cal { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::lasuite::cli::dispatch_cal(action, &sc, cli.output_format).await - } - - Some(Verb::Find { action }) => { - let sc = sunbeam_sdk::client::SunbeamClient::from_context( - &sunbeam_sdk::config::active_context(), - ); - sunbeam_sdk::lasuite::cli::dispatch_find(action, &sc, cli.output_format).await - } - - Some(Verb::Pm { action }) => match action { - None => { - use clap::CommandFactory; - let mut cmd = Cli::command(); - let sub = cmd - .find_subcommand_mut("pm") - .expect("pm subcommand"); - sub.print_help()?; - println!(); - Ok(()) - } - Some(PmAction::List { source, state }) => { - let src = if source == "all" { None } else { Some(source.as_str()) }; - sunbeam_sdk::pm::cmd_pm_list(src, &state).await - } - Some(PmAction::Show { id }) => { - sunbeam_sdk::pm::cmd_pm_show(&id).await - } - Some(PmAction::Create { title, body, source, target }) => { - sunbeam_sdk::pm::cmd_pm_create(&title, &body, &source, &target).await - } - Some(PmAction::Comment { id, text }) => { - sunbeam_sdk::pm::cmd_pm_comment(&id, &text).await - } - Some(PmAction::Close { id }) => { - sunbeam_sdk::pm::cmd_pm_close(&id).await - } - Some(PmAction::Assign { id, user }) => { - sunbeam_sdk::pm::cmd_pm_assign(&id, &user).await - } - }, - - Some(Verb::Update) => sunbeam_sdk::update::cmd_update().await, - - Some(Verb::Version) => { - sunbeam_sdk::update::cmd_version(); - Ok(()) - } - } -} diff --git a/sunbeam/src/main.rs b/sunbeam/src/main.rs deleted file mode 100644 index 826a5bee..00000000 --- a/sunbeam/src/main.rs +++ /dev/null @@ -1,39 +0,0 @@ -mod cli; - -#[tokio::main] -async fn main() { - // Install rustls crypto provider (ring) before any TLS operations. - rustls::crypto::ring::default_provider() - .install_default() - .expect("Failed to install rustls crypto provider"); - - // Initialize tracing subscriber. - // Respects RUST_LOG env var (e.g. RUST_LOG=debug, RUST_LOG=sunbeam=trace). - // Default: warn for dependencies, info for sunbeam + sunbeam_sdk. - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { - tracing_subscriber::EnvFilter::new("sunbeam=info,sunbeam_sdk=info,warn") - }), - ) - .with_target(false) - .with_writer(std::io::stderr) - .init(); - - match cli::dispatch().await { - Ok(()) => {} - Err(e) => { - let code = e.exit_code(); - tracing::error!("{e}"); - - // Print source chain for non-trivial errors - let mut source = std::error::Error::source(&e); - while let Some(cause) = source { - tracing::debug!("caused by: {cause}"); - source = std::error::Error::source(cause); - } - - std::process::exit(code); - } - } -} diff --git a/sunbeam/tests/__init__.py b/sunbeam/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sunbeam/tests/test_checks.py b/sunbeam/tests/test_checks.py deleted file mode 100644 index 70231885..00000000 --- a/sunbeam/tests/test_checks.py +++ /dev/null @@ -1,317 +0,0 @@ -"""Tests for checks.py — service-level health probes.""" -import json -import unittest -from unittest.mock import MagicMock, patch - - -class TestCheckGiteaVersion(unittest.TestCase): - def test_ok_returns_version(self): - body = json.dumps({"version": "1.21.0"}).encode() - with patch("sunbeam.checks._http_get", return_value=(200, body)): - from sunbeam import checks - r = checks.check_gitea_version("testdomain", None) - self.assertTrue(r.passed) - self.assertIn("1.21.0", r.detail) - - def test_non_200_fails(self): - with patch("sunbeam.checks._http_get", return_value=(502, b"")): - from sunbeam import checks - r = checks.check_gitea_version("testdomain", None) - self.assertFalse(r.passed) - self.assertIn("502", r.detail) - - def test_connection_error_fails(self): - import urllib.error - with patch("sunbeam.checks._http_get", side_effect=urllib.error.URLError("refused")): - from sunbeam import checks - r = checks.check_gitea_version("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckGiteaAuth(unittest.TestCase): - def _secret(self, key, val): - def side_effect(ns, name, k): - return val if k == key else "gitea_admin" - return side_effect - - def test_ok_returns_login(self): - body = json.dumps({"login": "gitea_admin"}).encode() - with patch("sunbeam.checks._kube_secret", - side_effect=self._secret("admin-password", "hunter2")): - with patch("sunbeam.checks._http_get", return_value=(200, body)): - from sunbeam import checks - r = checks.check_gitea_auth("testdomain", None) - self.assertTrue(r.passed) - self.assertIn("gitea_admin", r.detail) - - def test_missing_password_fails(self): - with patch("sunbeam.checks._kube_secret", return_value=""): - from sunbeam import checks - r = checks.check_gitea_auth("testdomain", None) - self.assertFalse(r.passed) - self.assertIn("secret", r.detail) - - def test_non_200_fails(self): - with patch("sunbeam.checks._kube_secret", - side_effect=self._secret("admin-password", "hunter2")): - with patch("sunbeam.checks._http_get", return_value=(401, b"")): - from sunbeam import checks - r = checks.check_gitea_auth("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckPostgres(unittest.TestCase): - def test_ready_passes(self): - with patch("sunbeam.checks.kube_out", side_effect=["1", "1"]): - from sunbeam import checks - r = checks.check_postgres("testdomain", None) - self.assertTrue(r.passed) - self.assertIn("1/1", r.detail) - - def test_not_ready_fails(self): - with patch("sunbeam.checks.kube_out", side_effect=["0", "1"]): - from sunbeam import checks - r = checks.check_postgres("testdomain", None) - self.assertFalse(r.passed) - - def test_cluster_not_found_fails(self): - with patch("sunbeam.checks.kube_out", return_value=""): - from sunbeam import checks - r = checks.check_postgres("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckValkey(unittest.TestCase): - def test_pong_passes(self): - with patch("sunbeam.checks.kube_out", return_value="valkey-abc"): - with patch("sunbeam.checks.kube_exec", return_value=(0, "PONG")): - from sunbeam import checks - r = checks.check_valkey("testdomain", None) - self.assertTrue(r.passed) - - def test_no_pod_fails(self): - with patch("sunbeam.checks.kube_out", return_value=""): - from sunbeam import checks - r = checks.check_valkey("testdomain", None) - self.assertFalse(r.passed) - - def test_no_pong_fails(self): - with patch("sunbeam.checks.kube_out", return_value="valkey-abc"): - with patch("sunbeam.checks.kube_exec", return_value=(1, "")): - from sunbeam import checks - r = checks.check_valkey("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckOpenbao(unittest.TestCase): - def test_unsealed_passes(self): - out = json.dumps({"initialized": True, "sealed": False}) - with patch("sunbeam.checks.kube_exec", return_value=(0, out)): - from sunbeam import checks - r = checks.check_openbao("testdomain", None) - self.assertTrue(r.passed) - - def test_sealed_fails(self): - out = json.dumps({"initialized": True, "sealed": True}) - with patch("sunbeam.checks.kube_exec", return_value=(2, out)): - from sunbeam import checks - r = checks.check_openbao("testdomain", None) - self.assertFalse(r.passed) - - def test_no_output_fails(self): - with patch("sunbeam.checks.kube_exec", return_value=(1, "")): - from sunbeam import checks - r = checks.check_openbao("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckSeaweedfs(unittest.TestCase): - def _with_creds(self, http_result=None, http_error=None): - """Helper: patch both _kube_secret (returns creds) and _http_get.""" - def secret_side_effect(ns, name, key): - return "testkey" if key == "S3_ACCESS_KEY" else "testsecret" - - patches = [ - patch("sunbeam.checks._kube_secret", side_effect=secret_side_effect), - ] - if http_error: - patches.append(patch("sunbeam.checks._http_get", side_effect=http_error)) - else: - patches.append(patch("sunbeam.checks._http_get", return_value=http_result)) - return patches - - def test_200_authenticated_passes(self): - with patch("sunbeam.checks._kube_secret", return_value="val"), \ - patch("sunbeam.checks._http_get", return_value=(200, b"")): - from sunbeam import checks - r = checks.check_seaweedfs("testdomain", None) - self.assertTrue(r.passed) - self.assertIn("authenticated", r.detail) - - def test_missing_credentials_fails(self): - with patch("sunbeam.checks._kube_secret", return_value=""): - from sunbeam import checks - r = checks.check_seaweedfs("testdomain", None) - self.assertFalse(r.passed) - self.assertIn("secret", r.detail) - - def test_403_bad_credentials_fails(self): - with patch("sunbeam.checks._kube_secret", return_value="val"), \ - patch("sunbeam.checks._http_get", return_value=(403, b"")): - from sunbeam import checks - r = checks.check_seaweedfs("testdomain", None) - self.assertFalse(r.passed) - self.assertIn("403", r.detail) - - def test_502_fails(self): - with patch("sunbeam.checks._kube_secret", return_value="val"), \ - patch("sunbeam.checks._http_get", return_value=(502, b"")): - from sunbeam import checks - r = checks.check_seaweedfs("testdomain", None) - self.assertFalse(r.passed) - - def test_connection_error_fails(self): - import urllib.error - with patch("sunbeam.checks._kube_secret", return_value="val"), \ - patch("sunbeam.checks._http_get", - side_effect=urllib.error.URLError("refused")): - from sunbeam import checks - r = checks.check_seaweedfs("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckKratos(unittest.TestCase): - def test_200_passes(self): - with patch("sunbeam.checks._http_get", return_value=(200, b"")): - from sunbeam import checks - r = checks.check_kratos("testdomain", None) - self.assertTrue(r.passed) - - def test_503_fails(self): - with patch("sunbeam.checks._http_get", return_value=(503, b"not ready")): - from sunbeam import checks - r = checks.check_kratos("testdomain", None) - self.assertFalse(r.passed) - self.assertIn("503", r.detail) - - -class TestCheckHydraOidc(unittest.TestCase): - def test_200_with_issuer_passes(self): - body = json.dumps({"issuer": "https://auth.testdomain/"}).encode() - with patch("sunbeam.checks._http_get", return_value=(200, body)): - from sunbeam import checks - r = checks.check_hydra_oidc("testdomain", None) - self.assertTrue(r.passed) - self.assertIn("testdomain", r.detail) - - def test_502_fails(self): - with patch("sunbeam.checks._http_get", return_value=(502, b"")): - from sunbeam import checks - r = checks.check_hydra_oidc("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckPeople(unittest.TestCase): - def test_200_passes(self): - with patch("sunbeam.checks._http_get", return_value=(200, b"")): - from sunbeam import checks - r = checks.check_people("testdomain", None) - self.assertTrue(r.passed) - - def test_302_redirect_passes(self): - with patch("sunbeam.checks._http_get", return_value=(302, b"")): - from sunbeam import checks - r = checks.check_people("testdomain", None) - self.assertTrue(r.passed) - self.assertIn("302", r.detail) - - def test_502_fails(self): - with patch("sunbeam.checks._http_get", return_value=(502, b"")): - from sunbeam import checks - r = checks.check_people("testdomain", None) - self.assertFalse(r.passed) - self.assertIn("502", r.detail) - - -class TestCheckPeopleApi(unittest.TestCase): - def test_200_passes(self): - with patch("sunbeam.checks._http_get", return_value=(200, b"{}")): - from sunbeam import checks - r = checks.check_people_api("testdomain", None) - self.assertTrue(r.passed) - - def test_401_auth_required_passes(self): - with patch("sunbeam.checks._http_get", return_value=(401, b"")): - from sunbeam import checks - r = checks.check_people_api("testdomain", None) - self.assertTrue(r.passed) - - def test_502_fails(self): - with patch("sunbeam.checks._http_get", return_value=(502, b"")): - from sunbeam import checks - r = checks.check_people_api("testdomain", None) - self.assertFalse(r.passed) - - -class TestCheckLivekit(unittest.TestCase): - def test_responding_passes(self): - with patch("sunbeam.checks.kube_out", return_value="livekit-server-abc"): - with patch("sunbeam.checks.kube_exec", return_value=(0, "")): - from sunbeam import checks - r = checks.check_livekit("testdomain", None) - self.assertTrue(r.passed) - - def test_no_pod_fails(self): - with patch("sunbeam.checks.kube_out", return_value=""): - from sunbeam import checks - r = checks.check_livekit("testdomain", None) - self.assertFalse(r.passed) - - def test_exec_fails(self): - with patch("sunbeam.checks.kube_out", return_value="livekit-server-abc"): - with patch("sunbeam.checks.kube_exec", return_value=(1, "")): - from sunbeam import checks - r = checks.check_livekit("testdomain", None) - self.assertFalse(r.passed) - - -class TestCmdCheck(unittest.TestCase): - def _run(self, target, mock_list): - from sunbeam import checks - result = checks.CheckResult("x", "ns", "svc", True, "ok") - fns = [MagicMock(return_value=result) for _ in mock_list] - patched = list(zip(fns, [ns for _, ns, _ in mock_list], [s for _, _, s in mock_list])) - with patch("sunbeam.checks.get_domain", return_value="td"), \ - patch("sunbeam.checks._ssl_ctx", return_value=None), \ - patch("sunbeam.checks._opener", return_value=None), \ - patch.object(checks, "CHECKS", patched): - checks.cmd_check(target) - return fns - - def test_no_target_runs_all(self): - mock_list = [("unused", "devtools", "gitea"), ("unused", "data", "postgres")] - fns = self._run(None, mock_list) - fns[0].assert_called_once_with("td", None) - fns[1].assert_called_once_with("td", None) - - def test_ns_filter_skips_other_namespaces(self): - mock_list = [("unused", "devtools", "gitea"), ("unused", "data", "postgres")] - fns = self._run("devtools", mock_list) - fns[0].assert_called_once() - fns[1].assert_not_called() - - def test_svc_filter(self): - mock_list = [("unused", "ory", "kratos"), ("unused", "ory", "hydra")] - fns = self._run("ory/kratos", mock_list) - fns[0].assert_called_once() - fns[1].assert_not_called() - - def test_no_match_warns(self): - from sunbeam import checks - with patch("sunbeam.checks.get_domain", return_value="td"), \ - patch("sunbeam.checks._ssl_ctx", return_value=None), \ - patch("sunbeam.checks._opener", return_value=None), \ - patch.object(checks, "CHECKS", []), \ - patch("sunbeam.checks.warn") as mock_warn: - checks.cmd_check("nonexistent") - mock_warn.assert_called_once() diff --git a/sunbeam/tests/test_cli.py b/sunbeam/tests/test_cli.py deleted file mode 100644 index f9d2ef78..00000000 --- a/sunbeam/tests/test_cli.py +++ /dev/null @@ -1,850 +0,0 @@ -"""Tests for CLI routing and argument validation.""" -import sys -import unittest -from unittest.mock import MagicMock, patch -import argparse - - -class TestArgParsing(unittest.TestCase): - """Test that argparse parses arguments correctly.""" - - def _parse(self, argv): - """Parse argv using the same parser as main(), return args namespace.""" - parser = argparse.ArgumentParser(prog="sunbeam") - sub = parser.add_subparsers(dest="verb", metavar="verb") - sub.add_parser("up") - sub.add_parser("down") - p_status = sub.add_parser("status") - p_status.add_argument("target", nargs="?", default=None) - p_apply = sub.add_parser("apply") - p_apply.add_argument("namespace", nargs="?", default="") - p_apply.add_argument("--domain", default="") - p_apply.add_argument("--email", default="") - sub.add_parser("seed") - sub.add_parser("verify") - p_logs = sub.add_parser("logs") - p_logs.add_argument("target") - p_logs.add_argument("-f", "--follow", action="store_true") - p_get = sub.add_parser("get") - p_get.add_argument("target") - p_get.add_argument("-o", "--output", default="yaml", choices=["yaml", "json", "wide"]) - p_restart = sub.add_parser("restart") - p_restart.add_argument("target", nargs="?", default=None) - p_build = sub.add_parser("build") - p_build.add_argument("what", choices=["proxy", "integration", "kratos-admin", "meet", - "docs-frontend", "people-frontend", "people", - "messages", "messages-backend", "messages-frontend", - "messages-mta-in", "messages-mta-out", - "messages-mpa", "messages-socks-proxy"]) - p_build.add_argument("--push", action="store_true") - p_build.add_argument("--deploy", action="store_true") - sub.add_parser("mirror") - sub.add_parser("bootstrap") - p_check = sub.add_parser("check") - p_check.add_argument("target", nargs="?", default=None) - p_user = sub.add_parser("user") - user_sub = p_user.add_subparsers(dest="user_action") - p_user_list = user_sub.add_parser("list") - p_user_list.add_argument("--search", default="") - p_user_get = user_sub.add_parser("get") - p_user_get.add_argument("target") - p_user_create = user_sub.add_parser("create") - p_user_create.add_argument("email") - p_user_create.add_argument("--name", default="") - p_user_create.add_argument("--schema", default="default") - p_user_delete = user_sub.add_parser("delete") - p_user_delete.add_argument("target") - p_user_recover = user_sub.add_parser("recover") - p_user_recover.add_argument("target") - p_user_disable = user_sub.add_parser("disable") - p_user_disable.add_argument("target") - p_user_enable = user_sub.add_parser("enable") - p_user_enable.add_argument("target") - p_user_set_pw = user_sub.add_parser("set-password") - p_user_set_pw.add_argument("target") - p_user_set_pw.add_argument("password") - p_user_onboard = user_sub.add_parser("onboard") - p_user_onboard.add_argument("email") - p_user_onboard.add_argument("--name", default="") - p_user_onboard.add_argument("--schema", default="employee") - p_user_onboard.add_argument("--no-email", action="store_true") - p_user_onboard.add_argument("--notify", default="") - p_user_onboard.add_argument("--job-title", default="") - p_user_onboard.add_argument("--department", default="") - p_user_onboard.add_argument("--office-location", default="") - p_user_onboard.add_argument("--hire-date", default="") - p_user_onboard.add_argument("--manager", default="") - p_user_offboard = user_sub.add_parser("offboard") - p_user_offboard.add_argument("target") - - # Add config subcommand for testing - p_config = sub.add_parser("config") - config_sub = p_config.add_subparsers(dest="config_action") - p_config_set = config_sub.add_parser("set") - p_config_set.add_argument("--host", default="") - p_config_set.add_argument("--infra-dir", default="") - config_sub.add_parser("get") - config_sub.add_parser("clear") - - return parser.parse_args(argv) - - def test_up(self): - args = self._parse(["up"]) - self.assertEqual(args.verb, "up") - - def test_status_no_target(self): - args = self._parse(["status"]) - self.assertEqual(args.verb, "status") - self.assertIsNone(args.target) - - def test_status_with_namespace(self): - args = self._parse(["status", "ory"]) - self.assertEqual(args.verb, "status") - self.assertEqual(args.target, "ory") - - def test_logs_no_follow(self): - args = self._parse(["logs", "ory/kratos"]) - self.assertEqual(args.verb, "logs") - self.assertEqual(args.target, "ory/kratos") - self.assertFalse(args.follow) - - def test_logs_follow_short(self): - args = self._parse(["logs", "ory/kratos", "-f"]) - self.assertTrue(args.follow) - - def test_logs_follow_long(self): - args = self._parse(["logs", "ory/kratos", "--follow"]) - self.assertTrue(args.follow) - - def test_build_proxy(self): - args = self._parse(["build", "proxy"]) - self.assertEqual(args.what, "proxy") - self.assertFalse(args.push) - self.assertFalse(args.deploy) - - def test_build_integration(self): - args = self._parse(["build", "integration"]) - self.assertEqual(args.what, "integration") - - def test_build_push_flag(self): - args = self._parse(["build", "proxy", "--push"]) - self.assertTrue(args.push) - self.assertFalse(args.deploy) - - def test_build_deploy_flag(self): - args = self._parse(["build", "proxy", "--deploy"]) - self.assertFalse(args.push) - self.assertTrue(args.deploy) - - def test_build_invalid_target(self): - with self.assertRaises(SystemExit): - self._parse(["build", "notavalidtarget"]) - - def test_user_set_password(self): - args = self._parse(["user", "set-password", "admin@example.com", "hunter2"]) - self.assertEqual(args.verb, "user") - self.assertEqual(args.user_action, "set-password") - self.assertEqual(args.target, "admin@example.com") - self.assertEqual(args.password, "hunter2") - - def test_user_disable(self): - args = self._parse(["user", "disable", "admin@example.com"]) - self.assertEqual(args.user_action, "disable") - self.assertEqual(args.target, "admin@example.com") - - def test_user_enable(self): - args = self._parse(["user", "enable", "admin@example.com"]) - self.assertEqual(args.user_action, "enable") - self.assertEqual(args.target, "admin@example.com") - - def test_user_list_search(self): - args = self._parse(["user", "list", "--search", "sienna"]) - self.assertEqual(args.user_action, "list") - self.assertEqual(args.search, "sienna") - - def test_user_create(self): - args = self._parse(["user", "create", "x@example.com", "--name", "X Y"]) - self.assertEqual(args.user_action, "create") - self.assertEqual(args.email, "x@example.com") - self.assertEqual(args.name, "X Y") - - def test_user_onboard_basic(self): - args = self._parse(["user", "onboard", "a@b.com"]) - self.assertEqual(args.user_action, "onboard") - self.assertEqual(args.email, "a@b.com") - self.assertEqual(args.name, "") - self.assertEqual(args.schema, "employee") - self.assertFalse(args.no_email) - self.assertEqual(args.notify, "") - - def test_user_onboard_full(self): - args = self._parse(["user", "onboard", "a@b.com", "--name", "A B", "--schema", "default", - "--no-email", "--job-title", "Engineer", "--department", "Dev", - "--office-location", "Paris", "--hire-date", "2026-01-15", - "--manager", "boss@b.com"]) - self.assertEqual(args.user_action, "onboard") - self.assertEqual(args.email, "a@b.com") - self.assertEqual(args.name, "A B") - self.assertEqual(args.schema, "default") - self.assertTrue(args.no_email) - self.assertEqual(args.job_title, "Engineer") - self.assertEqual(args.department, "Dev") - self.assertEqual(args.office_location, "Paris") - self.assertEqual(args.hire_date, "2026-01-15") - self.assertEqual(args.manager, "boss@b.com") - - def test_user_onboard_notify(self): - args = self._parse(["user", "onboard", "a@work.com", "--notify", "a@personal.com"]) - self.assertEqual(args.email, "a@work.com") - self.assertEqual(args.notify, "a@personal.com") - self.assertFalse(args.no_email) - - def test_user_offboard(self): - args = self._parse(["user", "offboard", "a@b.com"]) - self.assertEqual(args.user_action, "offboard") - self.assertEqual(args.target, "a@b.com") - - def test_get_with_target(self): - args = self._parse(["get", "ory/kratos-abc"]) - self.assertEqual(args.verb, "get") - self.assertEqual(args.target, "ory/kratos-abc") - self.assertEqual(args.output, "yaml") - - def test_get_json_output(self): - args = self._parse(["get", "ory/kratos-abc", "-o", "json"]) - self.assertEqual(args.output, "json") - - def test_get_invalid_output_format(self): - with self.assertRaises(SystemExit): - self._parse(["get", "ory/kratos-abc", "-o", "toml"]) - - def test_check_no_target(self): - args = self._parse(["check"]) - self.assertEqual(args.verb, "check") - self.assertIsNone(args.target) - - def test_check_with_namespace(self): - args = self._parse(["check", "devtools"]) - self.assertEqual(args.verb, "check") - self.assertEqual(args.target, "devtools") - - def test_check_with_service(self): - args = self._parse(["check", "lasuite/people"]) - self.assertEqual(args.verb, "check") - self.assertEqual(args.target, "lasuite/people") - - def test_apply_no_namespace(self): - args = self._parse(["apply"]) - self.assertEqual(args.verb, "apply") - self.assertEqual(args.namespace, "") - - def test_apply_with_namespace(self): - args = self._parse(["apply", "lasuite"]) - self.assertEqual(args.verb, "apply") - self.assertEqual(args.namespace, "lasuite") - - def test_apply_ingress_namespace(self): - args = self._parse(["apply", "ingress"]) - self.assertEqual(args.namespace, "ingress") - - def test_build_meet(self): - args = self._parse(["build", "meet"]) - self.assertEqual(args.what, "meet") - - def test_config_set_with_host_and_infra_dir(self): - args = self._parse(["config", "set", "--host", "user@example.com", "--infra-dir", "/path/to/infra"]) - self.assertEqual(args.verb, "config") - self.assertEqual(args.config_action, "set") - self.assertEqual(args.host, "user@example.com") - self.assertEqual(args.infra_dir, "/path/to/infra") - - def test_config_set_with_only_host(self): - args = self._parse(["config", "set", "--host", "user@example.com"]) - self.assertEqual(args.verb, "config") - self.assertEqual(args.config_action, "set") - self.assertEqual(args.host, "user@example.com") - self.assertEqual(args.infra_dir, "") - - def test_config_set_with_only_infra_dir(self): - args = self._parse(["config", "set", "--infra-dir", "/path/to/infra"]) - self.assertEqual(args.verb, "config") - self.assertEqual(args.config_action, "set") - self.assertEqual(args.host, "") - self.assertEqual(args.infra_dir, "/path/to/infra") - - def test_config_get(self): - args = self._parse(["config", "get"]) - self.assertEqual(args.verb, "config") - self.assertEqual(args.config_action, "get") - - def test_config_clear(self): - args = self._parse(["config", "clear"]) - self.assertEqual(args.verb, "config") - self.assertEqual(args.config_action, "clear") - - def test_build_people(self): - args = self._parse(["build", "people"]) - self.assertEqual(args.what, "people") - self.assertFalse(args.push) - self.assertFalse(args.deploy) - - def test_build_people_push(self): - args = self._parse(["build", "people", "--push"]) - self.assertEqual(args.what, "people") - self.assertTrue(args.push) - self.assertFalse(args.deploy) - - def test_build_people_push_deploy(self): - args = self._parse(["build", "people", "--push", "--deploy"]) - self.assertEqual(args.what, "people") - self.assertTrue(args.push) - self.assertTrue(args.deploy) - - def test_no_args_verb_is_none(self): - args = self._parse([]) - self.assertIsNone(args.verb) - - -class TestCliDispatch(unittest.TestCase): - """Test that main() dispatches to the correct command function.""" - - @staticmethod - def _mock_users(**overrides): - defaults = {f: MagicMock() for f in [ - "cmd_user_list", "cmd_user_get", "cmd_user_create", "cmd_user_delete", - "cmd_user_recover", "cmd_user_disable", "cmd_user_enable", - "cmd_user_set_password", "cmd_user_onboard", "cmd_user_offboard", - ]} - defaults.update(overrides) - return MagicMock(**defaults) - - def test_no_verb_exits_0(self): - with patch.object(sys, "argv", ["sunbeam"]): - from sunbeam import cli - with self.assertRaises(SystemExit) as ctx: - cli.main() - self.assertEqual(ctx.exception.code, 0) - - def test_unknown_verb_exits_nonzero(self): - with patch.object(sys, "argv", ["sunbeam", "unknown-verb"]): - from sunbeam import cli - with self.assertRaises(SystemExit) as ctx: - cli.main() - self.assertNotEqual(ctx.exception.code, 0) - - def test_up_calls_cmd_up(self): - mock_up = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "up"]): - with patch.dict("sys.modules", {"sunbeam.cluster": MagicMock(cmd_up=mock_up)}): - import importlib - import sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_up.assert_called_once() - - def test_status_no_target(self): - mock_status = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "status"]): - with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_status=mock_status)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_status.assert_called_once_with(None) - - def test_status_with_namespace(self): - mock_status = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "status", "ory"]): - with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_status=mock_status)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_status.assert_called_once_with("ory") - - def test_logs_with_target(self): - mock_logs = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "logs", "ory/kratos"]): - with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_logs=mock_logs)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_logs.assert_called_once_with("ory/kratos", follow=False) - - def test_logs_follow_flag(self): - mock_logs = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "logs", "ory/kratos", "-f"]): - with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_logs=mock_logs)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_logs.assert_called_once_with("ory/kratos", follow=True) - - def test_get_dispatches_with_target_and_output(self): - mock_get = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "get", "ory/kratos-abc"]): - with patch.dict("sys.modules", {"sunbeam.services": MagicMock(cmd_get=mock_get)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_get.assert_called_once_with("ory/kratos-abc", output="yaml") - - def test_build_proxy(self): - mock_build = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "build", "proxy"]): - with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_build.assert_called_once_with("proxy", push=False, deploy=False, no_cache=False) - - def test_build_with_push_flag(self): - mock_build = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "build", "integration", "--push"]): - with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_build.assert_called_once_with("integration", push=True, deploy=False, no_cache=False) - - def test_build_with_deploy_flag_implies_push(self): - mock_build = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "build", "proxy", "--deploy"]): - with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_build.assert_called_once_with("proxy", push=True, deploy=True, no_cache=False) - - def test_user_set_password_dispatches(self): - mock_set_pw = MagicMock() - mock_users = self._mock_users(cmd_user_set_password=mock_set_pw) - with patch.object(sys, "argv", ["sunbeam", "user", "set-password", - "admin@sunbeam.pt", "s3cr3t"]): - with patch.dict("sys.modules", {"sunbeam.users": mock_users}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_set_pw.assert_called_once_with("admin@sunbeam.pt", "s3cr3t") - - def test_user_disable_dispatches(self): - mock_disable = MagicMock() - mock_users = self._mock_users(cmd_user_disable=mock_disable) - with patch.object(sys, "argv", ["sunbeam", "user", "disable", "x@sunbeam.pt"]): - with patch.dict("sys.modules", {"sunbeam.users": mock_users}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_disable.assert_called_once_with("x@sunbeam.pt") - - def test_user_enable_dispatches(self): - mock_enable = MagicMock() - mock_users = self._mock_users(cmd_user_enable=mock_enable) - with patch.object(sys, "argv", ["sunbeam", "user", "enable", "x@sunbeam.pt"]): - with patch.dict("sys.modules", {"sunbeam.users": mock_users}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_enable.assert_called_once_with("x@sunbeam.pt") - - def test_apply_full_dispatches_without_namespace(self): - mock_apply = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "apply"]): - with patch.dict("sys.modules", {"sunbeam.manifests": MagicMock(cmd_apply=mock_apply)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_apply.assert_called_once_with(env="local", domain="", email="", namespace="") - - def test_apply_partial_passes_namespace(self): - mock_apply = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "apply", "lasuite"]): - with patch.dict("sys.modules", {"sunbeam.manifests": MagicMock(cmd_apply=mock_apply)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_apply.assert_called_once_with(env="local", domain="", email="", namespace="lasuite") - - def test_build_people_dispatches(self): - mock_build = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "build", "people"]): - with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_build.assert_called_once_with("people", push=False, deploy=False, no_cache=False) - - def test_build_people_push_dispatches(self): - mock_build = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "build", "people", "--push"]): - with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_build.assert_called_once_with("people", push=True, deploy=False, no_cache=False) - - def test_build_people_deploy_implies_push(self): - mock_build = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "build", "people", "--push", "--deploy"]): - with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_build.assert_called_once_with("people", push=True, deploy=True, no_cache=False) - - def test_build_meet_dispatches(self): - mock_build = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "build", "meet"]): - with patch.dict("sys.modules", {"sunbeam.images": MagicMock(cmd_build=mock_build)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_build.assert_called_once_with("meet", push=False, deploy=False, no_cache=False) - - def test_check_no_target(self): - mock_check = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "check"]): - with patch.dict("sys.modules", {"sunbeam.checks": MagicMock(cmd_check=mock_check)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_check.assert_called_once_with(None) - - def test_check_with_target(self): - mock_check = MagicMock() - with patch.object(sys, "argv", ["sunbeam", "check", "lasuite/people"]): - with patch.dict("sys.modules", {"sunbeam.checks": MagicMock(cmd_check=mock_check)}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_check.assert_called_once_with("lasuite/people") - - - def test_user_onboard_dispatches(self): - mock_onboard = MagicMock() - mock_users = self._mock_users(cmd_user_onboard=mock_onboard) - with patch.object(sys, "argv", ["sunbeam", "user", "onboard", - "new@sunbeam.pt", "--name", "New User"]): - with patch.dict("sys.modules", {"sunbeam.users": mock_users}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_onboard.assert_called_once_with("new@sunbeam.pt", name="New User", - schema_id="employee", send_email=True, - notify="", job_title="", department="", - office_location="", hire_date="", - manager="") - - def test_user_onboard_no_email_dispatches(self): - mock_onboard = MagicMock() - mock_users = self._mock_users(cmd_user_onboard=mock_onboard) - with patch.object(sys, "argv", ["sunbeam", "user", "onboard", - "new@sunbeam.pt", "--no-email"]): - with patch.dict("sys.modules", {"sunbeam.users": mock_users}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_onboard.assert_called_once_with("new@sunbeam.pt", name="", - schema_id="employee", send_email=False, - notify="", job_title="", department="", - office_location="", hire_date="", - manager="") - - def test_user_offboard_dispatches(self): - mock_offboard = MagicMock() - mock_users = self._mock_users(cmd_user_offboard=mock_offboard) - with patch.object(sys, "argv", ["sunbeam", "user", "offboard", "x@sunbeam.pt"]): - with patch.dict("sys.modules", {"sunbeam.users": mock_users}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - mock_offboard.assert_called_once_with("x@sunbeam.pt") - - -class TestConfigCli(unittest.TestCase): - """Test config subcommand functionality.""" - - def setUp(self): - """Set up test fixtures.""" - import tempfile - import os - self.temp_dir = tempfile.mkdtemp() - self.original_home = os.environ.get('HOME') - os.environ['HOME'] = self.temp_dir - - # Import and mock config path - from pathlib import Path - import sunbeam.config - self.original_config_path = sunbeam.config.CONFIG_PATH - sunbeam.config.CONFIG_PATH = Path(self.temp_dir) / ".sunbeam.json" - - def tearDown(self): - """Clean up test fixtures.""" - import shutil - import os - import sunbeam.config - - # Restore original config path - sunbeam.config.CONFIG_PATH = self.original_config_path - - # Clean up temp directory - shutil.rmtree(self.temp_dir) - - # Restore original HOME - if self.original_home: - os.environ['HOME'] = self.original_home - else: - del os.environ['HOME'] - - def test_config_set_and_get(self): - """Test config set and get functionality.""" - from sunbeam.config import SunbeamConfig, load_config, save_config - - # Test initial state - config = load_config() - self.assertEqual(config.production_host, "") - self.assertEqual(config.infra_directory, "") - - # Test setting config - test_config = SunbeamConfig( - production_host="user@example.com", - infra_directory="/path/to/infra" - ) - save_config(test_config) - - # Test loading config - loaded_config = load_config() - self.assertEqual(loaded_config.production_host, "user@example.com") - self.assertEqual(loaded_config.infra_directory, "/path/to/infra") - - def test_config_clear(self): - """Test config clear functionality.""" - from sunbeam.config import SunbeamConfig, load_config, save_config - from pathlib import Path - import os - - # Set a config first - test_config = SunbeamConfig( - production_host="user@example.com", - infra_directory="/path/to/infra" - ) - save_config(test_config) - - # Verify it exists - config_path = Path(self.temp_dir) / ".sunbeam.json" - self.assertTrue(config_path.exists()) - - # Clear config - os.remove(config_path) - - # Verify cleared state - cleared_config = load_config() - self.assertEqual(cleared_config.production_host, "") - self.assertEqual(cleared_config.infra_directory, "") - - def test_config_get_production_host_priority(self): - """Test that config file takes priority over environment variable.""" - from sunbeam.config import SunbeamConfig, save_config, get_production_host - import os - - # Set environment variable - os.environ['SUNBEAM_SSH_HOST'] = "env@example.com" - - # Get production host without config - should use env var - host_no_config = get_production_host() - self.assertEqual(host_no_config, "env@example.com") - - # Set config - test_config = SunbeamConfig( - production_host="config@example.com", - infra_directory="" - ) - save_config(test_config) - - # Get production host with config - should use config - host_with_config = get_production_host() - self.assertEqual(host_with_config, "config@example.com") - - # Clean up env var - del os.environ['SUNBEAM_SSH_HOST'] - - def test_config_cli_set_dispatch(self): - """Test that config set CLI dispatches correctly.""" - mock_existing = MagicMock() - mock_existing.production_host = "old@example.com" - mock_existing.infra_directory = "/old/infra" - mock_existing.acme_email = "" - mock_save = MagicMock() - mock_config = MagicMock( - load_config=MagicMock(return_value=mock_existing), - save_config=mock_save - ) - - with patch.object(sys, "argv", ["sunbeam", "config", "set", - "--host", "cli@example.com", - "--infra-dir", "/cli/infra"]): - with patch.dict("sys.modules", {"sunbeam.config": mock_config}): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - - # Verify existing config was loaded and updated - self.assertEqual(mock_existing.production_host, "cli@example.com") - self.assertEqual(mock_existing.infra_directory, "/cli/infra") - # Verify save_config was called with the updated config - mock_save.assert_called_once_with(mock_existing) - - def test_config_cli_get_dispatch(self): - """Test that config get CLI dispatches correctly.""" - mock_load = MagicMock() - mock_ok = MagicMock() - mock_config = MagicMock( - load_config=mock_load, - get_production_host=MagicMock(return_value="effective@example.com") - ) - mock_output = MagicMock(ok=mock_ok) - - # Mock config with some values - mock_config_instance = MagicMock() - mock_config_instance.production_host = "loaded@example.com" - mock_config_instance.infra_directory = "/loaded/infra" - mock_load.return_value = mock_config_instance - - with patch.object(sys, "argv", ["sunbeam", "config", "get"]): - with patch.dict("sys.modules", { - "sunbeam.config": mock_config, - "sunbeam.output": mock_output - }): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - - # Verify load_config was called - mock_load.assert_called_once() - # Verify ok was called with expected messages - mock_ok.assert_any_call("Production host: loaded@example.com") - mock_ok.assert_any_call("Infrastructure directory: /loaded/infra") - mock_ok.assert_any_call("Effective production host: effective@example.com") - - def test_config_cli_clear_dispatch(self): - """Test that config clear CLI dispatches correctly.""" - mock_ok = MagicMock() - mock_warn = MagicMock() - mock_output = MagicMock(ok=mock_ok, warn=mock_warn) - mock_os = MagicMock() - mock_os.path.exists.return_value = True - - with patch.object(sys, "argv", ["sunbeam", "config", "clear"]): - with patch.dict("sys.modules", { - "sunbeam.output": mock_output, - "os": mock_os - }): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - - # Verify os.remove was called - mock_os.remove.assert_called_once() - # Verify ok was called - mock_ok.assert_called_once() - - def test_config_cli_clear_no_file(self): - """Test that config clear handles missing file gracefully.""" - mock_ok = MagicMock() - mock_warn = MagicMock() - mock_output = MagicMock(ok=mock_ok, warn=mock_warn) - mock_os = MagicMock() - mock_os.path.exists.return_value = False - - with patch.object(sys, "argv", ["sunbeam", "config", "clear"]): - with patch.dict("sys.modules", { - "sunbeam.output": mock_output, - "os": mock_os - }): - import importlib, sunbeam.cli as cli_mod - importlib.reload(cli_mod) - try: - cli_mod.main() - except SystemExit: - pass - - # Verify os.remove was not called - mock_os.remove.assert_not_called() - # Verify warn was called - mock_warn.assert_called_once_with("No configuration file found to clear") diff --git a/sunbeam/tests/test_kube.py b/sunbeam/tests/test_kube.py deleted file mode 100644 index 70346c2b..00000000 --- a/sunbeam/tests/test_kube.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for kube.py — domain substitution, target parsing, kubectl wrappers.""" -import unittest -from unittest.mock import MagicMock, patch - - -class TestParseTarget(unittest.TestCase): - def setUp(self): - from sunbeam.kube import parse_target - self.parse = parse_target - - def test_none(self): - self.assertEqual(self.parse(None), (None, None)) - - def test_namespace_only(self): - self.assertEqual(self.parse("ory"), ("ory", None)) - - def test_namespace_and_name(self): - self.assertEqual(self.parse("ory/kratos"), ("ory", "kratos")) - - def test_too_many_parts_raises(self): - with self.assertRaises(ValueError): - self.parse("too/many/parts") - - def test_empty_string(self): - result = self.parse("") - self.assertEqual(result, ("", None)) - - -class TestDomainReplace(unittest.TestCase): - def setUp(self): - from sunbeam.kube import domain_replace - self.replace = domain_replace - - def test_single_occurrence(self): - result = self.replace("src.DOMAIN_SUFFIX/foo", "192.168.1.1.sslip.io") - self.assertEqual(result, "src.192.168.1.1.sslip.io/foo") - - def test_multiple_occurrences(self): - text = "DOMAIN_SUFFIX and DOMAIN_SUFFIX" - result = self.replace(text, "x.sslip.io") - self.assertEqual(result, "x.sslip.io and x.sslip.io") - - def test_no_occurrence(self): - result = self.replace("no match here", "x.sslip.io") - self.assertEqual(result, "no match here") - - -class TestKustomizeBuild(unittest.TestCase): - def test_calls_run_tool_and_applies_domain_replace(self): - from pathlib import Path - mock_result = MagicMock() - mock_result.stdout = "image: src.DOMAIN_SUFFIX/foo\nimage: src.DOMAIN_SUFFIX/bar" - with patch("sunbeam.kube.run_tool", return_value=mock_result) as mock_rt: - from sunbeam.kube import kustomize_build - result = kustomize_build(Path("/some/overlay"), "192.168.1.1.sslip.io") - mock_rt.assert_called_once() - call_args = mock_rt.call_args[0] - self.assertEqual(call_args[0], "kustomize") - self.assertIn("build", call_args) - self.assertIn("--enable-helm", call_args) - self.assertIn("192.168.1.1.sslip.io", result) - self.assertNotIn("DOMAIN_SUFFIX", result) - - def test_strips_null_annotations(self): - from pathlib import Path - mock_result = MagicMock() - mock_result.stdout = "metadata:\n annotations: null\n name: test" - with patch("sunbeam.kube.run_tool", return_value=mock_result): - from sunbeam.kube import kustomize_build - result = kustomize_build(Path("/overlay"), "x.sslip.io") - self.assertNotIn("annotations: null", result) - - -class TestKubeWrappers(unittest.TestCase): - def test_kube_passes_context(self): - with patch("sunbeam.kube.run_tool") as mock_rt: - mock_rt.return_value = MagicMock(returncode=0) - from sunbeam.kube import kube - kube("get", "pods") - call_args = mock_rt.call_args[0] - self.assertEqual(call_args[0], "kubectl") - self.assertIn("--context=sunbeam", call_args) - - def test_kube_out_returns_stdout_on_success(self): - with patch("sunbeam.kube.run_tool") as mock_rt: - mock_rt.return_value = MagicMock(returncode=0, stdout=" output ") - from sunbeam.kube import kube_out - result = kube_out("get", "pods") - self.assertEqual(result, "output") - - def test_kube_out_returns_empty_on_failure(self): - with patch("sunbeam.kube.run_tool") as mock_rt: - mock_rt.return_value = MagicMock(returncode=1, stdout="error text") - from sunbeam.kube import kube_out - result = kube_out("get", "pods") - self.assertEqual(result, "") - - def test_kube_ok_returns_true_on_zero(self): - with patch("sunbeam.kube.run_tool") as mock_rt: - mock_rt.return_value = MagicMock(returncode=0) - from sunbeam.kube import kube_ok - self.assertTrue(kube_ok("get", "ns", "default")) - - def test_kube_ok_returns_false_on_nonzero(self): - with patch("sunbeam.kube.run_tool") as mock_rt: - mock_rt.return_value = MagicMock(returncode=1) - from sunbeam.kube import kube_ok - self.assertFalse(kube_ok("get", "ns", "missing")) diff --git a/sunbeam/tests/test_manifests.py b/sunbeam/tests/test_manifests.py deleted file mode 100644 index 0048adee..00000000 --- a/sunbeam/tests/test_manifests.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Tests for manifests.py — primarily _filter_by_namespace.""" -import unittest - -from sunbeam.manifests import _filter_by_namespace - - -MULTI_DOC = """\ ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: meet-config - namespace: lasuite -data: - FOO: bar ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: meet-backend - namespace: lasuite -spec: - replicas: 1 ---- -apiVersion: v1 -kind: Namespace -metadata: - name: lasuite ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: pingora-config - namespace: ingress -data: - config.toml: | - hello ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: pingora - namespace: ingress -spec: - replicas: 1 -""" - - -class TestFilterByNamespace(unittest.TestCase): - - def test_keeps_matching_namespace(self): - result = _filter_by_namespace(MULTI_DOC, "lasuite") - self.assertIn("name: meet-config", result) - self.assertIn("name: meet-backend", result) - - def test_excludes_other_namespaces(self): - result = _filter_by_namespace(MULTI_DOC, "lasuite") - self.assertNotIn("namespace: ingress", result) - self.assertNotIn("name: pingora-config", result) - self.assertNotIn("name: pingora", result) - - def test_includes_namespace_resource_itself(self): - result = _filter_by_namespace(MULTI_DOC, "lasuite") - self.assertIn("kind: Namespace", result) - - def test_ingress_filter(self): - result = _filter_by_namespace(MULTI_DOC, "ingress") - self.assertIn("name: pingora-config", result) - self.assertIn("name: pingora", result) - self.assertNotIn("namespace: lasuite", result) - - def test_unknown_namespace_returns_empty(self): - result = _filter_by_namespace(MULTI_DOC, "nonexistent") - self.assertEqual(result.strip(), "") - - def test_empty_input_returns_empty(self): - result = _filter_by_namespace("", "lasuite") - self.assertEqual(result.strip(), "") - - def test_result_is_valid_multidoc_yaml(self): - # Each non-empty doc in the result should start with '---' - result = _filter_by_namespace(MULTI_DOC, "lasuite") - self.assertTrue(result.startswith("---")) - - def test_does_not_include_namespace_resource_for_wrong_ns(self): - # The lasuite Namespace CR should NOT appear in an ingress-filtered result - result = _filter_by_namespace(MULTI_DOC, "ingress") - # There's no ingress Namespace CR in the fixture, so kind: Namespace should be absent - self.assertNotIn("kind: Namespace", result) - - def test_single_doc_matching(self): - doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n" - result = _filter_by_namespace(doc, "ory") - self.assertIn("name: x", result) - - def test_single_doc_not_matching(self): - doc = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: x\n namespace: ory\n" - result = _filter_by_namespace(doc, "lasuite") - self.assertEqual(result.strip(), "") diff --git a/sunbeam/tests/test_secrets.py b/sunbeam/tests/test_secrets.py deleted file mode 100644 index 3a19b104..00000000 --- a/sunbeam/tests/test_secrets.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Tests for secrets.py — seed idempotency, verify flow.""" -import base64 -import unittest -from unittest.mock import MagicMock, patch, call - - -class TestSeedIdempotency(unittest.TestCase): - """_seed_openbao() must read existing values before writing (never rotates).""" - - def test_get_or_create_skips_existing(self): - """If OpenBao already has a value, it's reused not regenerated.""" - with patch("sunbeam.secrets._seed_openbao") as mock_seed: - mock_seed.return_value = { - "hydra-system-secret": "existingvalue", - "_ob_pod": "openbao-0", - "_root_token": "token123", - } - from sunbeam import secrets - result = secrets._seed_openbao() - self.assertIn("hydra-system-secret", result) - - -class TestCmdVerify(unittest.TestCase): - def _mock_kube_out(self, ob_pod="openbao-0", root_token="testtoken", mac=""): - """Create a side_effect function for kube_out that simulates verify flow.""" - encoded_token = base64.b64encode(root_token.encode()).decode() - def side_effect(*args, **kwargs): - args_str = " ".join(str(a) for a in args) - if "app.kubernetes.io/name=openbao" in args_str: - return ob_pod - if "root-token" in args_str: - return encoded_token - if "secretMAC" in args_str: - return mac - if "conditions" in args_str: - return "unknown" - if ".data.test-key" in args_str: - return "" - return "" - return side_effect - - def test_verify_cleans_up_on_timeout(self): - """cmd_verify() must clean up test resources even when VSO doesn't sync.""" - kube_out_fn = self._mock_kube_out(mac="") # MAC never set -> timeout - with patch("sunbeam.secrets.kube_out", side_effect=kube_out_fn): - with patch("sunbeam.secrets.kube") as mock_kube: - with patch("sunbeam.secrets.kube_apply"): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - with patch("time.time") as mock_time: - # start=0, first check=0, second check past deadline - mock_time.side_effect = [0, 0, 100] - with patch("time.sleep"): - from sunbeam import secrets - with self.assertRaises(SystemExit): - secrets.cmd_verify() - # Cleanup should have been called (delete calls) - delete_calls = [c for c in mock_kube.call_args_list - if "delete" in str(c)] - self.assertGreater(len(delete_calls), 0) - - def test_verify_succeeds_when_synced(self): - """cmd_verify() succeeds when VSO syncs the secret and value matches.""" - # We need a fixed test_value. Patch _secrets.token_urlsafe to return known value. - test_val = "fixed-test-value" - encoded_val = base64.b64encode(test_val.encode()).decode() - encoded_token = base64.b64encode(b"testtoken").decode() - - call_count = [0] - def kube_out_fn(*args, **kwargs): - args_str = " ".join(str(a) for a in args) - if "app.kubernetes.io/name=openbao" in args_str: - return "openbao-0" - if "root-token" in args_str: - return encoded_token - if "secretMAC" in args_str: - call_count[0] += 1 - return "somemac" if call_count[0] >= 1 else "" - if ".data.test-key" in args_str: - return encoded_val - return "" - - with patch("sunbeam.secrets.kube_out", side_effect=kube_out_fn): - with patch("sunbeam.secrets.kube") as mock_kube: - with patch("sunbeam.secrets.kube_apply"): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") - with patch("sunbeam.secrets._secrets.token_urlsafe", return_value=test_val): - with patch("time.time", return_value=0): - with patch("time.sleep"): - from sunbeam import secrets - # Should not raise - secrets.cmd_verify() diff --git a/sunbeam/tests/test_services.py b/sunbeam/tests/test_services.py deleted file mode 100644 index 4831fa33..00000000 --- a/sunbeam/tests/test_services.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Tests for services.py — status scoping, log command construction, restart.""" -import unittest -from unittest.mock import MagicMock, patch, call - - -class TestCmdStatus(unittest.TestCase): - def test_all_namespaces_when_no_target(self): - fake_output = ( - "ory hydra-abc 1/1 Running 0 1d\n" - "data valkey-xyz 1/1 Running 0 1d\n" - ) - with patch("sunbeam.services._capture_out", return_value=fake_output): - from sunbeam import services - services.cmd_status(None) - - def test_namespace_scoped(self): - fake_output = "ory kratos-abc 1/1 Running 0 1d\n" - with patch("sunbeam.services._capture_out", return_value=fake_output) as mock_co: - from sunbeam import services - services.cmd_status("ory") - # Should have called _capture_out with -n ory - calls_str = str(mock_co.call_args_list) - self.assertIn("ory", calls_str) - - def test_pod_scoped(self): - fake_output = "kratos-abc 1/1 Running 0 1d\n" - with patch("sunbeam.services._capture_out", return_value=fake_output) as mock_co: - from sunbeam import services - services.cmd_status("ory/kratos") - calls_str = str(mock_co.call_args_list) - self.assertIn("ory", calls_str) - self.assertIn("kratos", calls_str) - - -class TestCmdLogs(unittest.TestCase): - def test_logs_no_follow(self): - with patch("subprocess.Popen") as mock_popen: - mock_proc = MagicMock() - mock_proc.wait.return_value = 0 - mock_popen.return_value = mock_proc - with patch("sunbeam.tools.ensure_tool", return_value="/fake/kubectl"): - from sunbeam import services - services.cmd_logs("ory/kratos", follow=False) - args = mock_popen.call_args[0][0] - self.assertIn("-n", args) - self.assertIn("ory", args) - self.assertNotIn("--follow", args) - - def test_logs_follow(self): - with patch("subprocess.Popen") as mock_popen: - mock_proc = MagicMock() - mock_proc.wait.return_value = 0 - mock_popen.return_value = mock_proc - with patch("sunbeam.tools.ensure_tool", return_value="/fake/kubectl"): - from sunbeam import services - services.cmd_logs("ory/kratos", follow=True) - args = mock_popen.call_args[0][0] - self.assertIn("--follow", args) - - def test_logs_requires_service_name(self): - """Passing just a namespace (no service) should die().""" - with self.assertRaises(SystemExit): - from sunbeam import services - services.cmd_logs("ory", follow=False) - - -class TestCmdGet(unittest.TestCase): - def test_prints_yaml_for_pod(self): - with patch("sunbeam.services.kube_out", return_value="apiVersion: v1\nkind: Pod") as mock_ko: - from sunbeam import services - services.cmd_get("ory/kratos-abc") - mock_ko.assert_called_once_with("get", "pod", "kratos-abc", "-n", "ory", "-o=yaml") - - def test_default_output_is_yaml(self): - with patch("sunbeam.services.kube_out", return_value="kind: Pod"): - from sunbeam import services - # no output kwarg → defaults to yaml - services.cmd_get("ory/kratos-abc") - - def test_json_output_format(self): - with patch("sunbeam.services.kube_out", return_value='{"kind":"Pod"}') as mock_ko: - from sunbeam import services - services.cmd_get("ory/kratos-abc", output="json") - mock_ko.assert_called_once_with("get", "pod", "kratos-abc", "-n", "ory", "-o=json") - - def test_missing_name_exits(self): - with self.assertRaises(SystemExit): - from sunbeam import services - services.cmd_get("ory") # namespace-only, no pod name - - def test_not_found_exits(self): - with patch("sunbeam.services.kube_out", return_value=""): - with self.assertRaises(SystemExit): - from sunbeam import services - services.cmd_get("ory/nonexistent") - - -class TestCmdRestart(unittest.TestCase): - def test_restart_all(self): - with patch("sunbeam.services.kube") as mock_kube: - from sunbeam import services - services.cmd_restart(None) - # Should restart all SERVICES_TO_RESTART - self.assertGreater(mock_kube.call_count, 0) - - def test_restart_namespace_scoped(self): - with patch("sunbeam.services.kube") as mock_kube: - from sunbeam import services - services.cmd_restart("ory") - calls_str = str(mock_kube.call_args_list) - # Should only restart ory/* services - self.assertIn("ory", calls_str) - self.assertNotIn("devtools", calls_str) - - def test_restart_specific_service(self): - with patch("sunbeam.services.kube") as mock_kube: - from sunbeam import services - services.cmd_restart("ory/kratos") - # Should restart exactly deployment/kratos in ory - calls_str = str(mock_kube.call_args_list) - self.assertIn("kratos", calls_str) - - def test_restart_unknown_service_warns(self): - with patch("sunbeam.services.kube") as mock_kube: - from sunbeam import services - services.cmd_restart("nonexistent/nosuch") - # kube should not be called since no match - mock_kube.assert_not_called() diff --git a/sunbeam/tests/test_tools.py b/sunbeam/tests/test_tools.py deleted file mode 100644 index 8c47a008..00000000 --- a/sunbeam/tests/test_tools.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for tools.py binary bundler.""" -import hashlib -import stat -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch -import tempfile -import shutil - - -class TestSha256(unittest.TestCase): - def test_computes_correct_hash(self): - from sunbeam.tools import _sha256 - with tempfile.NamedTemporaryFile(delete=False) as f: - f.write(b"hello world") - f.flush() - path = Path(f.name) - try: - expected = hashlib.sha256(b"hello world").hexdigest() - self.assertEqual(_sha256(path), expected) - finally: - path.unlink() - - -class TestEnsureTool(unittest.TestCase): - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self.cache_patcher = patch("sunbeam.tools.CACHE_DIR", Path(self.tmpdir)) - self.cache_patcher.start() - - def tearDown(self): - self.cache_patcher.stop() - shutil.rmtree(self.tmpdir, ignore_errors=True) - - def test_returns_cached_if_sha_matches(self): - binary_data = b"#!/bin/sh\necho kubectl" - dest = Path(self.tmpdir) / "kubectl" - dest.write_bytes(binary_data) - dest.chmod(dest.stat().st_mode | stat.S_IXUSR) - expected_sha = hashlib.sha256(binary_data).hexdigest() - tools_spec = {"kubectl": {"url": "http://x", "sha256": expected_sha}} - with patch("sunbeam.tools.TOOLS", tools_spec): - from sunbeam import tools - result = tools.ensure_tool("kubectl") - self.assertEqual(result, dest) - - def test_returns_cached_if_sha_empty(self): - binary_data = b"#!/bin/sh\necho kubectl" - dest = Path(self.tmpdir) / "kubectl" - dest.write_bytes(binary_data) - dest.chmod(dest.stat().st_mode | stat.S_IXUSR) - tools_spec = {"kubectl": {"url": "http://x", "sha256": ""}} - with patch("sunbeam.tools.TOOLS", tools_spec): - from sunbeam import tools - result = tools.ensure_tool("kubectl") - self.assertEqual(result, dest) - - def test_downloads_on_cache_miss(self): - binary_data = b"#!/bin/sh\necho kubectl" - tools_spec = {"kubectl": {"url": "http://example.com/kubectl", "sha256": ""}} - with patch("sunbeam.tools.TOOLS", tools_spec): - with patch("urllib.request.urlopen") as mock_url: - mock_resp = MagicMock() - mock_resp.read.return_value = binary_data - mock_resp.__enter__ = lambda s: s - mock_resp.__exit__ = MagicMock(return_value=False) - mock_url.return_value = mock_resp - from sunbeam import tools - result = tools.ensure_tool("kubectl") - dest = Path(self.tmpdir) / "kubectl" - self.assertTrue(dest.exists()) - self.assertEqual(dest.read_bytes(), binary_data) - # Should be executable - self.assertTrue(dest.stat().st_mode & stat.S_IXUSR) - - def test_raises_on_sha256_mismatch(self): - binary_data = b"#!/bin/sh\necho fake" - tools_spec = {"kubectl": { - "url": "http://example.com/kubectl", - "sha256": "a" * 64, # wrong hash - }} - with patch("sunbeam.tools.TOOLS", tools_spec): - with patch("urllib.request.urlopen") as mock_url: - mock_resp = MagicMock() - mock_resp.read.return_value = binary_data - mock_resp.__enter__ = lambda s: s - mock_resp.__exit__ = MagicMock(return_value=False) - mock_url.return_value = mock_resp - from sunbeam import tools - with self.assertRaises(RuntimeError) as ctx: - tools.ensure_tool("kubectl") - self.assertIn("SHA256 mismatch", str(ctx.exception)) - # Binary should be cleaned up - self.assertFalse((Path(self.tmpdir) / "kubectl").exists()) - - def test_redownloads_on_sha_mismatch_cached(self): - """If cached binary has wrong hash, it's deleted and re-downloaded.""" - old_data = b"old binary" - new_data = b"new binary" - dest = Path(self.tmpdir) / "kubectl" - dest.write_bytes(old_data) - new_sha = hashlib.sha256(new_data).hexdigest() - tools_spec = {"kubectl": {"url": "http://x/kubectl", "sha256": new_sha}} - with patch("sunbeam.tools.TOOLS", tools_spec): - with patch("urllib.request.urlopen") as mock_url: - mock_resp = MagicMock() - mock_resp.read.return_value = new_data - mock_resp.__enter__ = lambda s: s - mock_resp.__exit__ = MagicMock(return_value=False) - mock_url.return_value = mock_resp - from sunbeam import tools - result = tools.ensure_tool("kubectl") - self.assertEqual(dest.read_bytes(), new_data) - - def test_unknown_tool_raises_value_error(self): - from sunbeam import tools - with self.assertRaises(ValueError): - tools.ensure_tool("notarealtool") - - -class TestRunTool(unittest.TestCase): - def setUp(self): - self.tmpdir = tempfile.mkdtemp() - self.cache_patcher = patch("sunbeam.tools.CACHE_DIR", Path(self.tmpdir)) - self.cache_patcher.start() - - def tearDown(self): - self.cache_patcher.stop() - shutil.rmtree(self.tmpdir, ignore_errors=True) - - def test_kustomize_prepends_cache_dir_to_path(self): - binary_data = b"#!/bin/sh" - dest = Path(self.tmpdir) / "kustomize" - dest.write_bytes(binary_data) - dest.chmod(dest.stat().st_mode | stat.S_IXUSR) - tools_spec = {"kustomize": {"url": "http://x", "sha256": ""}} - with patch("sunbeam.tools.TOOLS", tools_spec): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - from sunbeam import tools - tools.run_tool("kustomize", "build", ".") - call_kwargs = mock_run.call_args[1] - env = call_kwargs.get("env", {}) - self.assertTrue(env.get("PATH", "").startswith(str(self.tmpdir))) - - def test_non_kustomize_does_not_modify_path(self): - binary_data = b"#!/bin/sh" - dest = Path(self.tmpdir) / "kubectl" - dest.write_bytes(binary_data) - dest.chmod(dest.stat().st_mode | stat.S_IXUSR) - tools_spec = {"kubectl": {"url": "http://x", "sha256": ""}} - with patch("sunbeam.tools.TOOLS", tools_spec): - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock(returncode=0) - from sunbeam import tools - import os - original_path = os.environ.get("PATH", "") - tools.run_tool("kubectl", "get", "pods") - call_kwargs = mock_run.call_args[1] - env = call_kwargs.get("env", {}) - # PATH should not be modified (starts same as original) - self.assertFalse(env.get("PATH", "").startswith(str(self.tmpdir))) diff --git a/sunbeam/tools.py b/sunbeam/tools.py deleted file mode 100644 index be2ef9c1..00000000 --- a/sunbeam/tools.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Binary bundler — downloads kubectl, kustomize, helm, buildctl at pinned versions. - -Binaries are cached in ~/.local/share/sunbeam/bin/ and SHA256-verified. -Platform (OS + arch) is detected at runtime so the same package works on -darwin/arm64 (development Mac), darwin/amd64, linux/arm64, and linux/amd64. -""" -import hashlib -import io -import os -import platform -import stat -import subprocess -import tarfile -import urllib.request -from pathlib import Path - -CACHE_DIR = Path.home() / ".local/share/sunbeam/bin" - -# Tool specs — URL and extract templates use {version}, {os}, {arch}. -# {os} : darwin | linux -# {arch} : arm64 | amd64 -_TOOL_SPECS: dict[str, dict] = { - "kubectl": { - "version": "v1.32.2", - "url": "https://dl.k8s.io/release/{version}/bin/{os}/{arch}/kubectl", - # plain binary, no archive - }, - "kustomize": { - "version": "v5.8.1", - "url": ( - "https://github.com/kubernetes-sigs/kustomize/releases/download/" - "kustomize%2F{version}/kustomize_{version}_{os}_{arch}.tar.gz" - ), - "extract": "kustomize", - }, - "helm": { - "version": "v4.1.0", - "url": "https://get.helm.sh/helm-{version}-{os}-{arch}.tar.gz", - "extract": "{os}-{arch}/helm", - "sha256": { - "darwin_arm64": "82f7065bf4e08d4c8d7881b85c0a080581ef4968a4ae6df4e7b432f8f7a88d0c", - }, - }, - "buildctl": { - "version": "v0.28.0", - # BuildKit releases: buildkit-v0.28.0.linux.amd64.tar.gz - "url": ( - "https://github.com/moby/buildkit/releases/download/{version}/" - "buildkit-{version}.{os}-{arch}.tar.gz" - ), - "extract": "bin/buildctl", - }, -} - -# Expose as TOOLS for callers that do `if "helm" in TOOLS`. -TOOLS = _TOOL_SPECS - - -def _detect_platform() -> tuple[str, str]: - """Return (os_name, arch) for the current host.""" - sys_os = platform.system().lower() - machine = platform.machine().lower() - os_name = {"darwin": "darwin", "linux": "linux"}.get(sys_os) - if not os_name: - raise RuntimeError(f"Unsupported OS: {sys_os}") - arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" - return os_name, arch - - -def _resolve_spec(name: str) -> dict: - """Return a tool spec with {os} / {arch} / {version} substituted. - - Uses the module-level TOOLS dict so that tests can patch it. - """ - if name not in TOOLS: - raise ValueError(f"Unknown tool: {name}") - os_name, arch = _detect_platform() - raw = TOOLS[name] - version = raw.get("version", "") - fmt = {"version": version, "os": os_name, "arch": arch} - spec = dict(raw) - spec["version"] = version - spec["url"] = raw["url"].format(**fmt) - if "extract" in raw: - spec["extract"] = raw["extract"].format(**fmt) - # sha256 may be a per-platform dict {"darwin_arm64": "..."} or a plain string. - sha256_val = raw.get("sha256", {}) - if isinstance(sha256_val, dict): - spec["sha256"] = sha256_val.get(f"{os_name}_{arch}", "") - return spec - - -def _sha256(path: Path) -> str: - h = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(65536), b""): - h.update(chunk) - return h.hexdigest() - - -def ensure_tool(name: str) -> Path: - """Return path to cached binary, downloading + verifying if needed. - - Re-downloads automatically when the pinned version in _TOOL_SPECS changes. - A .version sidecar file records the version of the cached binary. - """ - spec = _resolve_spec(name) - CACHE_DIR.mkdir(parents=True, exist_ok=True) - dest = CACHE_DIR / name - version_file = CACHE_DIR / f"{name}.version" - - expected_sha = spec.get("sha256", "") - expected_version = spec.get("version", "") - - if dest.exists(): - version_ok = ( - not expected_version - or (version_file.exists() and version_file.read_text().strip() == expected_version) - ) - sha_ok = not expected_sha or _sha256(dest) == expected_sha - if version_ok and sha_ok: - return dest - - # Version mismatch or SHA mismatch — re-download - if dest.exists(): - dest.unlink() - if version_file.exists(): - version_file.unlink() - - url = spec["url"] - with urllib.request.urlopen(url) as resp: # noqa: S310 - data = resp.read() - - extract_path = spec.get("extract") - if extract_path: - with tarfile.open(fileobj=io.BytesIO(data)) as tf: - member = tf.getmember(extract_path) - fobj = tf.extractfile(member) - binary_data = fobj.read() - else: - binary_data = data - - dest.write_bytes(binary_data) - - if expected_sha: - actual = _sha256(dest) - if actual != expected_sha: - dest.unlink() - raise RuntimeError( - f"SHA256 mismatch for {name}: expected {expected_sha}, got {actual}" - ) - - dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - version_file.write_text(expected_version) - return dest - - -def run_tool(name: str, *args, **kwargs) -> subprocess.CompletedProcess: - """Run a bundled tool, ensuring it is downloaded first. - - For kustomize: prepends CACHE_DIR to PATH so helm is found. - """ - bin_path = ensure_tool(name) - env = kwargs.pop("env", None) - if env is None: - env = os.environ.copy() - if name == "kustomize": - if "helm" in TOOLS: - ensure_tool("helm") - env["PATH"] = str(CACHE_DIR) + os.pathsep + env.get("PATH", "") - return subprocess.run([str(bin_path), *args], env=env, **kwargs) diff --git a/sunbeam/users.py b/sunbeam/users.py deleted file mode 100644 index 9464a385..00000000 --- a/sunbeam/users.py +++ /dev/null @@ -1,528 +0,0 @@ -"""User management — Kratos identity operations via port-forwarded admin API.""" -import json -import smtplib -import subprocess -import sys -import time -import urllib.request -import urllib.error -from contextlib import contextmanager -from email.message import EmailMessage - -import sunbeam.kube as _kube_mod -from sunbeam.output import step, ok, warn, die, table - -_SMTP_LOCAL_PORT = 10025 - - -@contextmanager -def _port_forward(ns="ory", svc="kratos-admin", local_port=4434, remote_port=80): - """Port-forward to a cluster service and yield the local base URL.""" - proc = subprocess.Popen( - ["kubectl", _kube_mod.context_arg(), "-n", ns, "port-forward", - f"svc/{svc}", f"{local_port}:{remote_port}"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - ) - # Wait for port-forward to be ready - time.sleep(1.5) - try: - yield f"http://localhost:{local_port}" - finally: - proc.terminate() - proc.wait() - - -def _api(base_url, path, method="GET", body=None, prefix="/admin", ok_statuses=()): - """Make a request to an admin API via port-forward.""" - url = f"{base_url}{prefix}{path}" - data = json.dumps(body).encode() if body is not None else None - headers = {"Content-Type": "application/json", "Accept": "application/json"} - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req) as resp: - resp_body = resp.read() - return json.loads(resp_body) if resp_body else None - except urllib.error.HTTPError as e: - if e.code in ok_statuses: - return None - err_text = e.read().decode() - die(f"API error {e.code}: {err_text}") - - -def _find_identity(base_url, target, required=True): - """Find identity by email or ID. Returns identity dict or None if not required.""" - # Try as ID first - if len(target) == 36 and target.count("-") == 4: - return _api(base_url, f"/identities/{target}") - # Search by email - result = _api(base_url, f"/identities?credentials_identifier={target}&page_size=1") - if isinstance(result, list) and result: - return result[0] - if required: - die(f"Identity not found: {target}") - return None - - -def _identity_put_body(identity, state=None, **extra): - """Build the PUT body for updating an identity, preserving all required fields.""" - body = { - "schema_id": identity["schema_id"], - "traits": identity["traits"], - "state": state or identity.get("state", "active"), - "metadata_public": identity.get("metadata_public"), - "metadata_admin": identity.get("metadata_admin"), - } - body.update(extra) - return body - - -def _generate_recovery(base_url, identity_id): - """Generate a 24h recovery code. Returns (link, code).""" - recovery = _api(base_url, "/recovery/code", method="POST", body={ - "identity_id": identity_id, - "expires_in": "24h", - }) - return recovery.get("recovery_link", ""), recovery.get("recovery_code", "") - - -def cmd_user_list(search=""): - step("Listing identities...") - with _port_forward() as base: - path = f"/identities?page_size=20" - if search: - path += f"&credentials_identifier={search}" - identities = _api(base, path) - - rows = [] - for i in identities or []: - traits = i.get("traits", {}) - email = traits.get("email", "") - # Support both employee (given_name/family_name) and default (name.first/last) schemas - given = traits.get("given_name", "") - family = traits.get("family_name", "") - if given or family: - display_name = f"{given} {family}".strip() - else: - name = traits.get("name", {}) - if isinstance(name, dict): - display_name = f"{name.get('first', '')} {name.get('last', '')}".strip() - else: - display_name = str(name) if name else "" - rows.append([i["id"][:8] + "...", email, display_name, i.get("state", "active")]) - - print(table(rows, ["ID", "Email", "Name", "State"])) - - -def cmd_user_get(target): - step(f"Getting identity: {target}") - with _port_forward() as base: - identity = _find_identity(base, target) - print(json.dumps(identity, indent=2)) - - -def cmd_user_create(email, name="", schema_id="default"): - step(f"Creating identity: {email}") - traits = {"email": email} - if name: - parts = name.split(" ", 1) - traits["name"] = {"first": parts[0], "last": parts[1] if len(parts) > 1 else ""} - - body = { - "schema_id": schema_id, - "traits": traits, - "state": "active", - } - - with _port_forward() as base: - identity = _api(base, "/identities", method="POST", body=body) - ok(f"Created identity: {identity['id']}") - link, code = _generate_recovery(base, identity["id"]) - - ok("Recovery link (valid 24h):") - print(link) - ok("Recovery code (enter on the page above):") - print(code) - - -def cmd_user_delete(target): - step(f"Deleting identity: {target}") - - confirm = input(f"Delete identity '{target}'? This cannot be undone. [y/N] ").strip().lower() - if confirm != "y": - ok("Cancelled.") - return - - with _port_forward() as base: - identity = _find_identity(base, target) - _api(base, f"/identities/{identity['id']}", method="DELETE") - ok(f"Deleted.") - - -def cmd_user_recover(target): - step(f"Generating recovery link for: {target}") - with _port_forward() as base: - identity = _find_identity(base, target) - link, code = _generate_recovery(base, identity["id"]) - ok("Recovery link (valid 24h):") - print(link) - ok("Recovery code (enter on the page above):") - print(code) - - -def cmd_user_disable(target): - """Disable identity + revoke all Kratos sessions (emergency lockout). - - After this: - - No new logins possible. - - Existing Hydra OAuth2 tokens are revoked. - - Django app sessions expire within SESSION_COOKIE_AGE (1h). - """ - step(f"Disabling identity: {target}") - with _port_forward() as base: - identity = _find_identity(base, target) - iid = identity["id"] - _api(base, f"/identities/{iid}", method="PUT", - body=_identity_put_body(identity, state="inactive")) - _api(base, f"/identities/{iid}/sessions", method="DELETE") - ok(f"Identity {iid[:8]}... disabled and all Kratos sessions revoked.") - warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE — currently 1h.") - - -def cmd_user_set_password(target, password): - """Set (or reset) the password credential for an identity.""" - step(f"Setting password for: {target}") - with _port_forward() as base: - identity = _find_identity(base, target) - iid = identity["id"] - _api(base, f"/identities/{iid}", method="PUT", - body=_identity_put_body(identity, credentials={ - "password": {"config": {"password": password}}, - })) - ok(f"Password set for {iid[:8]}...") - - -def cmd_user_enable(target): - """Re-enable a previously disabled identity.""" - step(f"Enabling identity: {target}") - with _port_forward() as base: - identity = _find_identity(base, target) - iid = identity["id"] - _api(base, f"/identities/{iid}", method="PUT", - body=_identity_put_body(identity, state="active")) - ok(f"Identity {iid[:8]}... re-enabled.") - - -def _send_welcome_email(domain, email, name, recovery_link, recovery_code, - job_title="", department=""): - """Send a welcome email via cluster Postfix (port-forward to svc/postfix in lasuite).""" - greeting = f"Hi {name}" if name else "Hi" - body_text = f"""{greeting}, - -Welcome to Sunbeam Studios!{f" You're joining as {job_title} in the {department} department." if job_title and department else ""} Your account has been created. - -To set your password, open this link and enter the recovery code below: - - Link: {recovery_link} - Code: {recovery_code} - -This link expires in 24 hours. - -Once signed in you will be prompted to set up 2FA (mandatory). - -After that, head to https://auth.{domain}/settings to set up your -profile — add your name, profile picture, and any other details. - -Your services: - Calendar: https://cal.{domain} - Drive: https://drive.{domain} - Mail: https://mail.{domain} - Meet: https://meet.{domain} - Projects: https://projects.{domain} - Source Code: https://src.{domain} - -Messages (Matrix): - Download Element for your platform: - Desktop: https://element.io/download - iOS: https://apps.apple.com/app/element-messenger/id1083446067 - Android: https://play.google.com/store/apps/details?id=im.vector.app - - Setup: - 1. Open Element and tap "Sign in" - 2. Tap "Edit" next to the homeserver field (matrix.org) - 3. Enter: https://messages.{domain} - 4. Tap "Continue" — you'll be redirected to Sunbeam Studios SSO - 5. Sign in with your {domain} email and password - -\u2014 With Love & Warmth, Sunbeam Studios -""" - msg = EmailMessage() - msg["Subject"] = "Welcome to Sunbeam Studios — Set Your Password" - msg["From"] = f"Sunbeam Studios " - msg["To"] = email - msg.set_content(body_text) - - with _port_forward(ns="lasuite", svc="postfix", local_port=_SMTP_LOCAL_PORT, remote_port=25): - with smtplib.SMTP("localhost", _SMTP_LOCAL_PORT) as smtp: - smtp.send_message(msg) - ok(f"Welcome email sent to {email}") - - -def _next_employee_id(base_url): - """Find the next sequential employee ID by scanning all employee identities.""" - identities = _api(base_url, "/identities?page_size=200") or [] - max_num = 0 - for ident in identities: - eid = ident.get("traits", {}).get("employee_id", "") - if eid and eid.isdigit(): - max_num = max(max_num, int(eid)) - return str(max_num + 1) - - -def _create_mailbox(email, name=""): - """Create a mailbox in Messages via kubectl exec into the backend.""" - local_part, domain_part = email.split("@", 1) - display_name = name or local_part - step(f"Creating mailbox: {email}") - result = _kube_mod.kube_out( - "exec", "deployment/messages-backend", "-n", "lasuite", - "-c", "messages-backend", "--", - "python", "manage.py", "shell", "-c", - f""" -mb, created = Mailbox.objects.get_or_create( - local_part="{local_part}", - domain=MailDomain.objects.get(name="{domain_part}"), -) -print("created" if created else "exists") -""", - ) - if "created" in (result or ""): - ok(f"Mailbox {email} created.") - elif "exists" in (result or ""): - ok(f"Mailbox {email} already exists.") - else: - warn(f"Could not create mailbox (Messages backend may not be running): {result}") - - -def _delete_mailbox(email): - """Delete a mailbox and associated Django user in Messages.""" - local_part, domain_part = email.split("@", 1) - step(f"Cleaning up mailbox: {email}") - result = _kube_mod.kube_out( - "exec", "deployment/messages-backend", "-n", "lasuite", - "-c", "messages-backend", "--", - "python", "manage.py", "shell", "-c", - f""" -from django.contrib.auth import get_user_model -User = get_user_model() -# Delete mailbox + access + contacts -deleted = 0 -for mb in Mailbox.objects.filter(local_part="{local_part}", domain__name="{domain_part}"): - mb.delete() - deleted += 1 -# Delete Django user -try: - u = User.objects.get(email="{email}") - u.delete() - deleted += 1 -except User.DoesNotExist: - pass -print(f"deleted {{deleted}}") -""", - ) - if "deleted" in (result or ""): - ok(f"Mailbox and user cleaned up.") - else: - warn(f"Could not clean up mailbox: {result}") - - -def _setup_projects_user(email, name=""): - """Create a Projects (Planka) user and add them as manager of the Default project.""" - step(f"Setting up Projects user: {email}") - js = f""" -const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}}); -async function go() {{ - // Create or find user - let user = await knex('user_account').where({{email: '{email}'}}).first(); - if (!user) {{ - const id = Date.now().toString(); - await knex('user_account').insert({{ - id, email: '{email}', name: '{name}', password: '', - is_admin: true, is_sso: true, language: 'en-US', - created_at: new Date(), updated_at: new Date() - }}); - user = {{id}}; - console.log('user_created'); - }} else {{ - console.log('user_exists'); - }} - // Add to Default project - const project = await knex('project').where({{name: 'Default'}}).first(); - if (project) {{ - const exists = await knex('project_manager').where({{project_id: project.id, user_id: user.id}}).first(); - if (!exists) {{ - await knex('project_manager').insert({{ - id: (Date.now()+1).toString(), project_id: project.id, - user_id: user.id, created_at: new Date() - }}); - console.log('manager_added'); - }} else {{ - console.log('manager_exists'); - }} - }} else {{ - console.log('no_default_project'); - }} -}} -go().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }}); -""" - result = _kube_mod.kube_out( - "exec", "deployment/projects", "-n", "lasuite", - "-c", "projects", "--", "node", "-e", js, - ) - if "manager_added" in (result or "") or "manager_exists" in (result or ""): - ok(f"Projects user ready.") - elif "no_default_project" in (result or ""): - warn("No Default project found in Projects — skip.") - else: - warn(f"Could not set up Projects user: {result}") - - -def _cleanup_projects_user(email): - """Remove a user from Projects (Planka) — delete memberships and user record.""" - step(f"Cleaning up Projects user: {email}") - js = f""" -const knex = require('knex')({{client: 'pg', connection: process.env.DATABASE_URL}}); -async function go() {{ - const user = await knex('user_account').where({{email: '{email}'}}).first(); - if (!user) {{ console.log('not_found'); return; }} - await knex('board_membership').where({{user_id: user.id}}).del(); - await knex('project_manager').where({{user_id: user.id}}).del(); - await knex('user_account').where({{id: user.id}}).update({{deleted_at: new Date()}}); - console.log('cleaned'); -}} -go().then(() => process.exit(0)).catch(e => {{ console.error(e.message); process.exit(1); }}); -""" - result = _kube_mod.kube_out( - "exec", "deployment/projects", "-n", "lasuite", - "-c", "projects", "--", "node", "-e", js, - ) - if "cleaned" in (result or ""): - ok("Projects user cleaned up.") - else: - warn(f"Could not clean up Projects user: {result}") - - -def cmd_user_onboard(email, name="", schema_id="employee", send_email=True, - notify="", job_title="", department="", office_location="", - hire_date="", manager=""): - """Onboard a new user: create identity, generate recovery link, optionally send welcome email.""" - step(f"Onboarding: {email}") - - with _port_forward() as base: - existing = _find_identity(base, email, required=False) - - if existing: - warn(f"Identity already exists: {existing['id'][:8]}...") - step("Generating fresh recovery link...") - iid = existing["id"] - recovery_link, recovery_code = _generate_recovery(base, iid) - else: - traits = {"email": email} - if name: - parts = name.split(" ", 1) - traits["given_name"] = parts[0] - traits["family_name"] = parts[1] if len(parts) > 1 else "" - - # Auto-assign employee ID if not provided and using employee schema - employee_id = "" - if schema_id == "employee": - employee_id = _next_employee_id(base) - traits["employee_id"] = employee_id - if job_title: - traits["job_title"] = job_title - if department: - traits["department"] = department - if office_location: - traits["office_location"] = office_location - if hire_date: - traits["hire_date"] = hire_date - if manager: - traits["manager"] = manager - - identity = _api(base, "/identities", method="POST", body={ - "schema_id": schema_id, - "traits": traits, - "state": "active", - "verifiable_addresses": [{ - "value": email, - "verified": True, - "via": "email", - }], - }) - iid = identity["id"] - ok(f"Created identity: {iid}") - if employee_id: - ok(f"Employee #{employee_id}") - - # Kratos ignores verifiable_addresses on POST — PATCH is required - _api(base, f"/identities/{iid}", method="PATCH", body=[ - {"op": "replace", "path": "/verifiable_addresses/0/verified", "value": True}, - {"op": "replace", "path": "/verifiable_addresses/0/status", "value": "completed"}, - ]) - - recovery_link, recovery_code = _generate_recovery(base, iid) - - # Provision app-level accounts - if not existing: - _create_mailbox(email, name) - _setup_projects_user(email, name) - - if send_email: - domain = _kube_mod.get_domain() - recipient = notify or email - _send_welcome_email(domain, recipient, name, recovery_link, recovery_code, - job_title=job_title, department=department) - - ok(f"Identity ID: {iid}") - ok("Recovery link (valid 24h):") - print(recovery_link) - ok("Recovery code:") - print(recovery_code) - - -def cmd_user_offboard(target): - """Offboard a user: disable identity, revoke all Kratos + Hydra sessions.""" - step(f"Offboarding: {target}") - - confirm = input(f"Offboard '{target}'? This will disable the account and revoke all sessions. [y/N] ").strip().lower() - if confirm != "y": - ok("Cancelled.") - return - - with _port_forward() as base: - identity = _find_identity(base, target) - iid = identity["id"] - - step("Disabling identity...") - _api(base, f"/identities/{iid}", method="PUT", - body=_identity_put_body(identity, state="inactive")) - ok(f"Identity {iid[:8]}... disabled.") - - step("Revoking Kratos sessions...") - _api(base, f"/identities/{iid}/sessions", method="DELETE", ok_statuses=(404,)) - ok("Kratos sessions revoked.") - - step("Revoking Hydra consent sessions...") - with _port_forward(svc="hydra-admin", local_port=14445, remote_port=4445) as hydra_base: - _api(hydra_base, f"/oauth2/auth/sessions/consent?subject={iid}&all=true", - method="DELETE", prefix="/admin", ok_statuses=(404,)) - ok("Hydra consent sessions revoked.") - - # Clean up Messages Django user and mailbox - email = identity.get("traits", {}).get("email", "") - if email: - _delete_mailbox(email) - _cleanup_projects_user(email) - - ok(f"Offboarding complete for {iid[:8]}...") - warn("Existing access tokens expire within ~1h (Hydra TTL).") - warn("App sessions (docs/people) expire within SESSION_COOKIE_AGE (~1h).") diff --git a/vendor/chumsky/examples/sample.py b/vendor/chumsky/examples/sample.py deleted file mode 100644 index db8503a7..00000000 --- a/vendor/chumsky/examples/sample.py +++ /dev/null @@ -1,16 +0,0 @@ -import turtle - -board = turtle.Turtle( - foo, - bar, - baz, -) - -for i in range(6): - board.forward(50) - if i % 2 == 0: - board.right(144) - else: - board.left(72) - -turtle.done() diff --git a/vendor/unicode-width/scripts/unicode.py b/vendor/unicode-width/scripts/unicode.py deleted file mode 100755 index 0baf6981..00000000 --- a/vendor/unicode-width/scripts/unicode.py +++ /dev/null @@ -1,2250 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2011-2025 The Rust Project Developers. See the COPYRIGHT -# file at the top-level directory of this distribution and at -# http://rust-lang.org/COPYRIGHT. -# -# Licensed under the Apache License, Version 2.0 or the MIT license -# , at your -# option. This file may not be copied, modified, or distributed -# except according to those terms. - -# This script uses the following Unicode tables: -# -# - DerivedCoreProperties.txt -# - EastAsianWidth.txt -# - HangulSyllableType.txt -# - LineBreak.txt -# - NormalizationTest.txt (for tests only) -# - PropList.txt -# - ReadMe.txt -# - UnicodeData.txt -# - auxiliary/GraphemeBreakProperty.txt -# - emoji/emoji-data.txt -# - emoji/emoji-test.txt (for tests only) -# - emoji/emoji-variation-sequences.txt -# - extracted/DerivedCombiningClass.txt -# - extracted/DerivedGeneralCategory.txt -# - extracted/DerivedJoiningGroup.txt -# - extracted/DerivedJoiningType.txt -# -# Since this should not require frequent updates, we just store this -# out-of-line and check the generated module into git. - -import enum -import math -import operator -import os -import re -import sys -import urllib.request -from collections import defaultdict -from itertools import batched -from typing import Callable, Iterable - -UNICODE_VERSION = "17.0.0" -"""The version of the Unicode data files to download.""" - -NUM_CODEPOINTS = 0x110000 -"""An upper bound for which `range(0, NUM_CODEPOINTS)` contains Unicode's codespace.""" - -MAX_CODEPOINT_BITS = math.ceil(math.log2(NUM_CODEPOINTS - 1)) -"""The maximum number of bits required to represent a Unicode codepoint.""" - - -class OffsetType(enum.IntEnum): - """Represents the data type of a lookup table's offsets. Each variant's value represents the - number of bits required to represent that variant's type.""" - - U2 = 2 - """Offsets are 2-bit unsigned integers, packed four-per-byte.""" - U4 = 4 - """Offsets are 4-bit unsigned integers, packed two-per-byte.""" - U8 = 8 - """Each offset is a single byte (u8).""" - - -MODULE_PATH = "../src/tables.rs" -"""The path of the emitted Rust module (relative to the working directory)""" - -TABLE_SPLITS = [7, 13] -"""The splits between the bits of the codepoint used to index each subtable. -Adjust these values to change the sizes of the subtables""" - -Codepoint = int -BitPos = int - - -def fetch_open(filename: str, local_prefix: str = "", emoji: bool = False): - """Opens `filename` and return its corresponding file object. If `filename` isn't on disk, - fetches it from `https://www.unicode.org/Public/`. Exits with code 1 on failure. - """ - basename = os.path.basename(filename) - localname = os.path.join(local_prefix, basename) - if not os.path.exists(localname): - if emoji: - prefix = "emoji" - else: - prefix = "ucd" - urllib.request.urlretrieve( - f"https://www.unicode.org/Public/{UNICODE_VERSION}/{prefix}/{filename}", - localname, - ) - try: - return open(localname, encoding="utf-8") - except OSError: - sys.stderr.write(f"cannot load {localname}") - sys.exit(1) - - -def load_unicode_version() -> tuple[int, int, int]: - """Returns the current Unicode version by fetching and processing `ReadMe.txt`.""" - with fetch_open("ReadMe.txt") as readme: - pattern = r"for Version (\d+)\.(\d+)\.(\d+) of the Unicode" - return tuple(map(int, re.search(pattern, readme.read()).groups())) # type: ignore - - -def load_property(filename: str, pattern: str, action: Callable[[int], None]): - with fetch_open(filename) as properties: - single = re.compile(rf"^([0-9A-F]+)\s*;\s*{pattern}\s+") - multiple = re.compile(rf"^([0-9A-F]+)\.\.([0-9A-F]+)\s*;\s*{pattern}\s+") - - for line in properties.readlines(): - raw_data = None # (low, high) - if match := single.match(line): - raw_data = (match.group(1), match.group(1)) - elif match := multiple.match(line): - raw_data = (match.group(1), match.group(2)) - else: - continue - low = int(raw_data[0], 16) - high = int(raw_data[1], 16) - for cp in range(low, high + 1): - action(cp) - - -def to_sorted_ranges(iter: Iterable[Codepoint]) -> list[tuple[Codepoint, Codepoint]]: - "Creates a sorted list of ranges from an iterable of codepoints" - lst = [c for c in iter] - lst.sort() - ret = [] - for cp in lst: - if len(ret) > 0 and ret[-1][1] == cp - 1: - ret[-1] = (ret[-1][0], cp) - else: - ret.append((cp, cp)) - return ret - - -class EastAsianWidth(enum.IntEnum): - """Represents the width of a Unicode character according to UAX 16. - All East Asian Width classes resolve into either - `EffectiveWidth.NARROW`, `EffectiveWidth.WIDE`, or `EffectiveWidth.AMBIGUOUS`. - """ - - NARROW = 1 - """ One column wide. """ - WIDE = 2 - """ Two columns wide. """ - AMBIGUOUS = 3 - """ Two columns wide in a CJK context. One column wide in all other contexts. """ - - -class CharWidthInTable(enum.IntEnum): - """Represents the width of a Unicode character - as stored in the tables.""" - - ZERO = 0 - ONE = 1 - TWO = 2 - SPECIAL = 3 - - -class WidthState(enum.IntEnum): - """ - Width calculation proceeds according to a state machine. - We iterate over the characters of the string from back to front; - the next character encountered determines the transition to take. - - The integer values of these variants have special meaning: - - Top bit: whether this is Vs16 - - 2nd from top: whether this is Vs15 - - 3rd bit from top: whether this is transparent to emoji/text presentation - (if set, should also set 4th) - - 4th bit: whether to set top bit on emoji presentation. - If this is set but 3rd is not, the width mode is related to zwj sequences - - 5th from top: whether this is unaffected by ligature-transparent - (if set, should also set 3rd and 4th) - - 6th bit: if 4th is set but this one is not, then this is a ZWJ ligature state - where no ZWJ has been encountered yet; encountering one flips this on - - Seventh bit: - - CJK mode: is VS1 or VS3 - - Not CJK: is VS2 - """ - - # BASIC WIDTHS - - ZERO = 0x1_0000 - "Zero columns wide." - - NARROW = 0x1_0001 - "One column wide." - - WIDE = 0x1_0002 - "Two columns wide." - - THREE = 0x1_0003 - "Three columns wide." - - # \r\n - LINE_FEED = 0b0000_0000_0000_0001 - "\\n (CRLF has width 1)" - - # EMOJI - - # Emoji skintone modifiers - EMOJI_MODIFIER = 0b0000_0000_0000_0010 - "`Emoji_Modifier`" - - # Emoji ZWJ sequences - - REGIONAL_INDICATOR = 0b0000_0000_0000_0011 - "`Regional_Indicator`" - - SEVERAL_REGIONAL_INDICATOR = 0b0000_0000_0000_0100 - "At least two `Regional_Indicator`in sequence" - - EMOJI_PRESENTATION = 0b0000_0000_0000_0101 - "`Emoji_Presentation`" - - ZWJ_EMOJI_PRESENTATION = 0b0001_0000_0000_0110 - "\\u200D `Emoji_Presentation`" - - VS16_ZWJ_EMOJI_PRESENTATION = 0b1001_0000_0000_0110 - "\\uFE0F \\u200D `Emoji_Presentation`" - - KEYCAP_ZWJ_EMOJI_PRESENTATION = 0b0001_0000_0000_0111 - "\\u20E3 \\u200D `Emoji_Presentation`" - - VS16_KEYCAP_ZWJ_EMOJI_PRESENTATION = 0b1001_0000_0000_0111 - "\\uFE0F \\u20E3 \\u200D `Emoji_Presentation`" - - REGIONAL_INDICATOR_ZWJ_PRESENTATION = 0b0000_0000_0000_1001 - "`Regional_Indicator` \\u200D `Emoji_Presentation`" - - EVEN_REGIONAL_INDICATOR_ZWJ_PRESENTATION = 0b0000_0000_0000_1010 - "(`Regional_Indicator` `Regional_Indicator`)+ \\u200D `Emoji_Presentation`" - - ODD_REGIONAL_INDICATOR_ZWJ_PRESENTATION = 0b0000_0000_0000_1011 - "(`Regional_Indicator` `Regional_Indicator`)+ `Regional_Indicator` \\u200D `Emoji_Presentation`" - - TAG_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_0000 - "\\uE007F \\u200D `Emoji_Presentation`" - - TAG_D1_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_0001 - "\\uE0030..=\\uE0039 \\uE007F \\u200D `Emoji_Presentation`" - - TAG_D2_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_0010 - "(\\uE0030..=\\uE0039){2} \\uE007F \\u200D `Emoji_Presentation`" - - TAG_D3_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_0011 - "(\\uE0030..=\\uE0039){3} \\uE007F \\u200D `Emoji_Presentation`" - - TAG_A1_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_1001 - "\\uE0061..=\\uE007A \\uE007F \\u200D `Emoji_Presentation`" - - TAG_A2_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_1010 - "(\\uE0061..=\\uE007A){2} \\uE007F \\u200D `Emoji_Presentation`" - - TAG_A3_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_1011 - "(\\uE0061..=\\uE007A){3} \\uE007F \\u200D `Emoji_Presentation`" - - TAG_A4_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_1100 - "(\\uE0061..=\\uE007A){4} \\uE007F \\u200D `Emoji_Presentation`" - - TAG_A5_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_1101 - "(\\uE0061..=\\uE007A){35} \\uE007F \\u200D `Emoji_Presentation`" - - TAG_A6_END_ZWJ_EMOJI_PRESENTATION = 0b0000_0000_0001_1110 - "(\\uE0061..=\\uE007A){6} \\uE007F \\u200D `Emoji_Presentation`" - - # Kirat Rai - KIRAT_RAI_VOWEL_SIGN_E = 0b0000_0000_0010_0000 - "\\u16D67 (\\u16D67 \\u16D67)+ and canonical equivalents" - KIRAT_RAI_VOWEL_SIGN_AI = 0b0000_0000_0010_0001 - "(\\u16D68)+ and canonical equivalents" - - # VARIATION SELECTORS - - VARIATION_SELECTOR_1_2_OR_3 = 0b0000_0010_0000_0000 - "\\uFE00 or \\uFE02 if CJK, or \\uFE01 otherwise" - - # Text presentation sequences (not CJK) - VARIATION_SELECTOR_15 = 0b0100_0000_0000_0000 - "\\uFE0E (text presentation sequences)" - - # Emoji presentation sequences - VARIATION_SELECTOR_16 = 0b1000_0000_0000_0000 - "\\uFE0F (emoji presentation sequences)" - - # ARABIC LAM ALEF - - JOINING_GROUP_ALEF = 0b0011_0000_1111_1111 - "Joining_Group=Alef (Arabic Lam-Alef ligature)" - - # COMBINING SOLIDUS (CJK only) - - COMBINING_LONG_SOLIDUS_OVERLAY = 0b0011_1100_1111_1111 - "\\u0338 (CJK only, makes <, =, > width 2)" - - # SOLIDUS + ALEF (solidus is Joining_Type=Transparent) - SOLIDUS_OVERLAY_ALEF = 0b0011_1000_1111_1111 - "\\u0338 followed by Joining_Group=Alef" - - # SCRIPT ZWJ LIGATURES - - # Hebrew alef lamed - - HEBREW_LETTER_LAMED = 0b0011_1000_0000_0000 - "\\u05DC (Alef-ZWJ-Lamed ligature)" - - ZWJ_HEBREW_LETTER_LAMED = 0b0011_1100_0000_0000 - "\\u200D\\u05DC (Alef-ZWJ-Lamed ligature)" - - # Buginese ya - - BUGINESE_LETTER_YA = 0b0011_1000_0000_0001 - "\\u1A10 ( + ya ligature)" - - ZWJ_BUGINESE_LETTER_YA = 0b0011_1100_0000_0001 - "\\u200D\\u1A10 ( + ya ligature)" - - BUGINESE_VOWEL_SIGN_I_ZWJ_LETTER_YA = 0b0011_1100_0000_0010 - "\\u1A17\\u200D\\u1A10 ( + ya ligature)" - - # Tifinagh bi-consonants - - TIFINAGH_CONSONANT = 0b0011_1000_0000_0011 - "\\u2D31..=\\u2D65 or \\u2D6F (joined by ZWJ or \\u2D7F TIFINAGH CONSONANT JOINER)" - - ZWJ_TIFINAGH_CONSONANT = 0b0011_1100_0000_0011 - "ZWJ then \\u2D31..=\\u2D65 or \\u2D6F" - - TIFINAGH_JOINER_CONSONANT = 0b0011_1100_0000_0100 - "\\u2D7F then \\u2D31..=\\u2D65 or \\u2D6F" - - # Lisu tone letters - LISU_TONE_LETTER_MYA_NA_JEU = 0b0011_1100_0000_0101 - "\\uA4FC or \\uA4FD (https://www.unicode.org/versions/Unicode15.0.0/ch18.pdf#G42078)" - - # Old Turkic orkhon ec - orkhon i - - OLD_TURKIC_LETTER_ORKHON_I = 0b0011_1000_0000_0110 - "\\u10C03 (ORKHON EC-ZWJ-ORKHON I ligature)" - - ZWJ_OLD_TURKIC_LETTER_ORKHON_I = 0b0011_1100_0000_0110 - "\\u10C03 (ORKHON EC-ZWJ-ORKHON I ligature)" - - # Khmer coeng signs - - KHMER_COENG_ELIGIBLE_LETTER = 0b0011_1100_0000_0111 - "\\u1780..=\\u17A2 | \\u17A7 | \\u17AB | \\u17AC | \\u17AF" - - def table_width(self) -> CharWidthInTable: - "The width of a character as stored in the lookup tables." - match self: - case WidthState.ZERO: - return CharWidthInTable.ZERO - case WidthState.NARROW: - return CharWidthInTable.ONE - case WidthState.WIDE: - return CharWidthInTable.TWO - case _: - return CharWidthInTable.SPECIAL - - def is_carried(self) -> bool: - "Whether this corresponds to a non-default `WidthInfo`." - return int(self) <= 0xFFFF - - def width_alone(self) -> int: - "The width of a character with this type when it appears alone." - match self: - case ( - WidthState.ZERO - | WidthState.COMBINING_LONG_SOLIDUS_OVERLAY - | WidthState.VARIATION_SELECTOR_15 - | WidthState.VARIATION_SELECTOR_16 - | WidthState.VARIATION_SELECTOR_1_2_OR_3 - ): - return 0 - case ( - WidthState.WIDE - | WidthState.EMOJI_MODIFIER - | WidthState.EMOJI_PRESENTATION - ): - return 2 - case WidthState.THREE: - return 3 - case _: - return 1 - - def is_cjk_only(self) -> bool: - return self in [ - WidthState.COMBINING_LONG_SOLIDUS_OVERLAY, - WidthState.SOLIDUS_OVERLAY_ALEF, - ] - - def is_non_cjk_only(self) -> bool: - return self == WidthState.VARIATION_SELECTOR_15 - - -assert len(set([v.value for v in WidthState])) == len([v.value for v in WidthState]) - - -def load_east_asian_widths() -> list[EastAsianWidth]: - """Return a list of effective widths, indexed by codepoint. - Widths are determined by fetching and parsing `EastAsianWidth.txt`. - - `Neutral`, `Narrow`, and `Halfwidth` characters are assigned `EffectiveWidth.NARROW`. - - `Wide` and `Fullwidth` characters are assigned `EffectiveWidth.WIDE`. - - `Ambiguous` characters are assigned `EffectiveWidth.AMBIGUOUS`.""" - - with fetch_open("EastAsianWidth.txt") as eaw: - # matches a width assignment for a single codepoint, i.e. "1F336;N # ..." - single = re.compile(r"^([0-9A-F]+)\s*;\s*(\w+) +# (\w+)") - # matches a width assignment for a range of codepoints, i.e. "3001..3003;W # ..." - multiple = re.compile(r"^([0-9A-F]+)\.\.([0-9A-F]+)\s*;\s*(\w+) +# (\w+)") - # map between width category code and condensed width - width_codes = { - **{c: EastAsianWidth.NARROW for c in ["N", "Na", "H"]}, - **{c: EastAsianWidth.WIDE for c in ["W", "F"]}, - "A": EastAsianWidth.AMBIGUOUS, - } - - width_map = [] - current = 0 - for line in eaw.readlines(): - raw_data = None # (low, high, width) - if match := single.match(line): - raw_data = (match.group(1), match.group(1), match.group(2)) - elif match := multiple.match(line): - raw_data = (match.group(1), match.group(2), match.group(3)) - else: - continue - low = int(raw_data[0], 16) - high = int(raw_data[1], 16) - width = width_codes[raw_data[2]] - - assert current <= high - while current <= high: - # Some codepoints don't fall into any of the ranges in EastAsianWidth.txt. - # All such codepoints are implicitly given Neural width (resolves to narrow) - width_map.append(EastAsianWidth.NARROW if current < low else width) - current += 1 - - while len(width_map) < NUM_CODEPOINTS: - # Catch any leftover codepoints and assign them implicit Neutral/narrow width. - width_map.append(EastAsianWidth.NARROW) - - # Characters with ambiguous line breaking are ambiguous - load_property( - "LineBreak.txt", - "AI", - lambda cp: (operator.setitem(width_map, cp, EastAsianWidth.AMBIGUOUS)), - ) - - # Ambiguous `Letter`s and `Modifier_Symbol`s are narrow - load_property( - "extracted/DerivedGeneralCategory.txt", - r"(:?Lu|Ll|Lt|Lm|Lo|Sk)", - lambda cp: ( - operator.setitem(width_map, cp, EastAsianWidth.NARROW) - if width_map[cp] == EastAsianWidth.AMBIGUOUS - else None - ), - ) - - # GREEK ANO TELEIA: NFC decomposes to U+00B7 MIDDLE DOT - width_map[0x0387] = EastAsianWidth.AMBIGUOUS - - # Canonical equivalence for symbols with stroke - with fetch_open("UnicodeData.txt") as udata: - single = re.compile(r"([0-9A-Z]+);.*?;.*?;.*?;.*?;([0-9A-Z]+) 0338;") - for line in udata.readlines(): - if match := single.match(line): - composed = int(match.group(1), 16) - decomposed = int(match.group(2), 16) - if width_map[decomposed] == EastAsianWidth.AMBIGUOUS: - width_map[composed] = EastAsianWidth.AMBIGUOUS - - return width_map - - -def load_zero_widths() -> list[bool]: - """Returns a list `l` where `l[c]` is true if codepoint `c` is considered a zero-width - character. `c` is considered a zero-width character if - - - it has the `Default_Ignorable_Code_Point` property (determined from `DerivedCoreProperties.txt`), - - or if it has the `Grapheme_Extend` property (determined from `DerivedCoreProperties.txt`), - - or if it one of eight characters that should be `Grapheme_Extend` but aren't due to a Unicode spec bug, - - or if it has a `Hangul_Syllable_Type` of `Vowel_Jamo` or `Trailing_Jamo` (determined from `HangulSyllableType.txt`). - """ - - zw_map = [False] * NUM_CODEPOINTS - - # `Default_Ignorable_Code_Point`s also have 0 width: - # https://www.unicode.org/faq/unsup_char.html#3 - # https://www.unicode.org/versions/Unicode15.1.0/ch05.pdf#G40095 - # - # `Grapheme_Extend` includes characters with general category `Mn` or `Me`, - # as well as a few `Mc` characters that need to be included so that - # canonically equivalent sequences have the same width. - load_property( - "DerivedCoreProperties.txt", - r"(?:Default_Ignorable_Code_Point|Grapheme_Extend)", - lambda cp: operator.setitem(zw_map, cp, True), - ) - - # Treat `Hangul_Syllable_Type`s of `Vowel_Jamo` and `Trailing_Jamo` - # as zero-width. This matches the behavior of glibc `wcwidth`. - # - # Decomposed Hangul characters consist of 3 parts: a `Leading_Jamo`, - # a `Vowel_Jamo`, and an optional `Trailing_Jamo`. Together these combine - # into a single wide grapheme. So we treat vowel and trailing jamo as - # 0-width, such that only the width of the leading jamo is counted - # and the resulting grapheme has width 2. - # - # (See the Unicode Standard sections 3.12 and 18.6 for more on Hangul) - load_property( - "HangulSyllableType.txt", - r"(?:V|T)", - lambda cp: operator.setitem(zw_map, cp, True), - ) - - # Syriac abbreviation mark: - # Zero-width `Prepended_Concatenation_Mark` - zw_map[0x070F] = True - - # Some Arabic Prepended_Concatenation_Mark`s - # https://www.unicode.org/versions/Unicode15.0.0/ch09.pdf#G27820 - zw_map[0x0605] = True - zw_map[0x0890] = True - zw_map[0x0891] = True - zw_map[0x08E2] = True - - # `[:Grapheme_Cluster_Break=Prepend:]-[:Prepended_Concatenation_Mark:]` - gcb_prepend = set() - load_property( - "auxiliary/GraphemeBreakProperty.txt", - "Prepend", - lambda cp: gcb_prepend.add(cp), - ) - load_property( - "PropList.txt", - "Prepended_Concatenation_Mark", - lambda cp: gcb_prepend.remove(cp), - ) - for cp in gcb_prepend: - zw_map[cp] = True - - # HANGUL CHOSEONG FILLER - # U+115F is a `Default_Ignorable_Code_Point`, and therefore would normally have - # zero width. However, the expected usage is to combine it with vowel or trailing jamo - # (which are considered 0-width on their own) to form a composed Hangul syllable with - # width 2. Therefore, we treat it as having width 2. - zw_map[0x115F] = False - - # TIFINAGH CONSONANT JOINER - # (invisible only when used to join two Tifinagh consonants - zw_map[0x2D7F] = False - - # DEVANAGARI CARET - # https://www.unicode.org/versions/Unicode15.0.0/ch12.pdf#G667447 - zw_map[0xA8FA] = True - - return zw_map - - -def load_width_maps() -> tuple[list[WidthState], list[WidthState]]: - """Load complete width table, including characters needing special handling. - (Returns 2 tables, one for East Asian and one for not.)""" - - eaws = load_east_asian_widths() - zws = load_zero_widths() - - not_ea = [] - ea = [] - - for eaw, zw in zip(eaws, zws): - if zw: - not_ea.append(WidthState.ZERO) - ea.append(WidthState.ZERO) - else: - if eaw == EastAsianWidth.WIDE: - not_ea.append(WidthState.WIDE) - else: - not_ea.append(WidthState.NARROW) - - if eaw == EastAsianWidth.NARROW: - ea.append(WidthState.NARROW) - else: - ea.append(WidthState.WIDE) - - # Joining_Group=Alef (Arabic Lam-Alef ligature) - alef_joining = [] - load_property( - "extracted/DerivedJoiningGroup.txt", - "Alef", - lambda cp: alef_joining.append(cp), - ) - - # Regional indicators - regional_indicators = [] - load_property( - "PropList.txt", - "Regional_Indicator", - lambda cp: regional_indicators.append(cp), - ) - - # Emoji modifiers - emoji_modifiers = [] - load_property( - "emoji/emoji-data.txt", - "Emoji_Modifier", - lambda cp: emoji_modifiers.append(cp), - ) - - # Default emoji presentation (for ZWJ sequences) - emoji_presentation = [] - load_property( - "emoji/emoji-data.txt", - "Emoji_Presentation", - lambda cp: emoji_presentation.append(cp), - ) - - for cps, width in [ - ([0x0A], WidthState.LINE_FEED), - ([0x05DC], WidthState.HEBREW_LETTER_LAMED), - (alef_joining, WidthState.JOINING_GROUP_ALEF), - (range(0x1780, 0x1783), WidthState.KHMER_COENG_ELIGIBLE_LETTER), - (range(0x1784, 0x1788), WidthState.KHMER_COENG_ELIGIBLE_LETTER), - (range(0x1789, 0x178D), WidthState.KHMER_COENG_ELIGIBLE_LETTER), - (range(0x178E, 0x1794), WidthState.KHMER_COENG_ELIGIBLE_LETTER), - (range(0x1795, 0x1799), WidthState.KHMER_COENG_ELIGIBLE_LETTER), - (range(0x179B, 0x179E), WidthState.KHMER_COENG_ELIGIBLE_LETTER), - ( - [0x17A0, 0x17A2, 0x17A7, 0x17AB, 0x17AC, 0x17AF], - WidthState.KHMER_COENG_ELIGIBLE_LETTER, - ), - ([0x17A4], WidthState.WIDE), - ([0x17D8], WidthState.THREE), - ([0x1A10], WidthState.BUGINESE_LETTER_YA), - (range(0x2D31, 0x2D66), WidthState.TIFINAGH_CONSONANT), - ([0x2D6F], WidthState.TIFINAGH_CONSONANT), - ([0xA4FC], WidthState.LISU_TONE_LETTER_MYA_NA_JEU), - ([0xA4FD], WidthState.LISU_TONE_LETTER_MYA_NA_JEU), - ([0xFE0F], WidthState.VARIATION_SELECTOR_16), - ([0x10C03], WidthState.OLD_TURKIC_LETTER_ORKHON_I), - ([0x16D67], WidthState.KIRAT_RAI_VOWEL_SIGN_E), - ([0x16D68], WidthState.KIRAT_RAI_VOWEL_SIGN_AI), - (emoji_presentation, WidthState.EMOJI_PRESENTATION), - (emoji_modifiers, WidthState.EMOJI_MODIFIER), - (regional_indicators, WidthState.REGIONAL_INDICATOR), - ]: - for cp in cps: - not_ea[cp] = width - ea[cp] = width - - # East-Asian only - ea[0x0338] = WidthState.COMBINING_LONG_SOLIDUS_OVERLAY - ea[0xFE00] = WidthState.VARIATION_SELECTOR_1_2_OR_3 - ea[0xFE02] = WidthState.VARIATION_SELECTOR_1_2_OR_3 - - # Not East Asian only - not_ea[0xFE01] = WidthState.VARIATION_SELECTOR_1_2_OR_3 - not_ea[0xFE0E] = WidthState.VARIATION_SELECTOR_15 - - return (not_ea, ea) - - -def load_joining_group_lam() -> list[tuple[Codepoint, Codepoint]]: - "Returns a list of character ranges with Joining_Group=Lam" - lam_joining = [] - load_property( - "extracted/DerivedJoiningGroup.txt", - "Lam", - lambda cp: lam_joining.append(cp), - ) - - return to_sorted_ranges(lam_joining) - - -def load_non_transparent_zero_widths( - width_map: list[WidthState], -) -> list[tuple[Codepoint, Codepoint]]: - "Returns a list of characters with zero width but not 'Joining_Type=Transparent'" - - zero_widths = set() - for cp, width in enumerate(width_map): - if width.width_alone() == 0: - zero_widths.add(cp) - transparent = set() - load_property( - "extracted/DerivedJoiningType.txt", - "T", - lambda cp: transparent.add(cp), - ) - - return to_sorted_ranges(zero_widths - transparent) - - -def load_ligature_transparent() -> list[tuple[Codepoint, Codepoint]]: - """Returns a list of character ranges corresponding to all combining marks that are also - `Default_Ignorable_Code_Point`s, plus ZWJ. This is the set of characters that won't interrupt - a ligature.""" - default_ignorables = set() - load_property( - "DerivedCoreProperties.txt", - "Default_Ignorable_Code_Point", - lambda cp: default_ignorables.add(cp), - ) - - combining_marks = set() - load_property( - "extracted/DerivedGeneralCategory.txt", - "(?:Mc|Mn|Me)", - lambda cp: combining_marks.add(cp), - ) - - default_ignorable_combinings = default_ignorables.intersection(combining_marks) - default_ignorable_combinings.add(0x200D) # ZWJ - - return to_sorted_ranges(default_ignorable_combinings) - - -def load_solidus_transparent( - ligature_transparents: list[tuple[Codepoint, Codepoint]], - cjk_width_map: list[WidthState], -) -> list[tuple[Codepoint, Codepoint]]: - """Characters expanding to a canonical combining class above 1, plus `ligature_transparent`s from above. - Ranges matching ones in `ligature_transparent` exactly are excluded (for compression), so it needs to be checked also. - """ - - ccc_above_1 = set() - load_property( - "extracted/DerivedCombiningClass.txt", - "(?:[2-9]|(?:[1-9][0-9]+))", - lambda cp: ccc_above_1.add(cp), - ) - - for lo, hi in ligature_transparents: - for cp in range(lo, hi + 1): - ccc_above_1.add(cp) - - num_chars = len(ccc_above_1) - - # Recursive decompositions - while True: - with fetch_open("UnicodeData.txt") as udata: - single = re.compile(r"([0-9A-Z]+);.*?;.*?;.*?;.*?;([0-9A-F ]+);") - for line in udata.readlines(): - if match := single.match(line): - composed = int(match.group(1), 16) - decomposed = [int(c, 16) for c in match.group(2).split(" ")] - if all([c in ccc_above_1 for c in decomposed]): - ccc_above_1.add(composed) - if len(ccc_above_1) == num_chars: - break - else: - num_chars = len(ccc_above_1) - - for cp in ccc_above_1: - if cp not in [0xFE00, 0xFE02, 0xFE0F]: - assert ( - cjk_width_map[cp].table_width() != CharWidthInTable.SPECIAL - ), f"U+{cp:X}" - - sorted = to_sorted_ranges(ccc_above_1) - return list(filter(lambda range: range not in ligature_transparents, sorted)) - - -def load_normalization_tests() -> list[tuple[str, str, str, str, str]]: - def parse_codepoints(cps: str) -> str: - return "".join(map(lambda cp: chr(int(cp, 16)), cps.split(" "))) - - with fetch_open("NormalizationTest.txt") as normtests: - ret = [] - single = re.compile( - r"^([0-9A-F ]+);([0-9A-F ]+);([0-9A-F ]+);([0-9A-F ]+);([0-9A-F ]+);" - ) - for line in normtests.readlines(): - if match := single.match(line): - ret.append( - ( - parse_codepoints(match.group(1)), - parse_codepoints(match.group(2)), - parse_codepoints(match.group(3)), - parse_codepoints(match.group(4)), - parse_codepoints(match.group(5)), - ) - ) - return ret - - -def make_special_ranges( - width_map: list[WidthState], -) -> list[tuple[tuple[Codepoint, Codepoint], WidthState]]: - "Assign ranges of characters to their special behavior (used in match)" - ret = [] - can_merge_with_prev = False - for cp, width in enumerate(width_map): - if width == WidthState.EMOJI_PRESENTATION: - can_merge_with_prev = False - elif width.table_width() == CharWidthInTable.SPECIAL: - if can_merge_with_prev and ret[-1][1] == width: - ret[-1] = ((ret[-1][0][0], cp), width) - else: - ret.append(((cp, cp), width)) - can_merge_with_prev = True - return ret - - -class Bucket: - """A bucket contains a group of codepoints and an ordered width list. If one bucket's width - list overlaps with another's width list, those buckets can be merged via `try_extend`. - """ - - def __init__(self): - """Creates an empty bucket.""" - self.entry_set = set() - self.widths = [] - - def append(self, codepoint: Codepoint, width: CharWidthInTable): - """Adds a codepoint/width pair to the bucket, and appends `width` to the width list.""" - self.entry_set.add((codepoint, width)) - self.widths.append(width) - - def try_extend(self, attempt: "Bucket") -> bool: - """If either `self` or `attempt`'s width list starts with the other bucket's width list, - set `self`'s width list to the longer of the two, add all of `attempt`'s codepoints - into `self`, and return `True`. Otherwise, return `False`.""" - (less, more) = (self.widths, attempt.widths) - if len(self.widths) > len(attempt.widths): - (less, more) = (attempt.widths, self.widths) - if less != more[: len(less)]: - return False - self.entry_set |= attempt.entry_set - self.widths = more - return True - - def entries(self) -> list[tuple[Codepoint, CharWidthInTable]]: - """Return a list of the codepoint/width pairs in this bucket, sorted by codepoint.""" - result = list(self.entry_set) - result.sort() - return result - - def width(self) -> CharWidthInTable | None: - """If all codepoints in this bucket have the same width, return that width; otherwise, - return `None`.""" - if len(self.widths) == 0: - return None - potential_width = self.widths[0] - for width in self.widths[1:]: - if potential_width != width: - return None - return potential_width - - -def make_buckets( - entries: Iterable[tuple[int, CharWidthInTable]], low_bit: BitPos, cap_bit: BitPos -) -> list[Bucket]: - """Partitions the `(Codepoint, EffectiveWidth)` tuples in `entries` into `Bucket`s. All - codepoints with identical bits from `low_bit` to `cap_bit` (exclusive) are placed in the - same bucket. Returns a list of the buckets in increasing order of those bits.""" - num_bits = cap_bit - low_bit - assert num_bits > 0 - buckets = [Bucket() for _ in range(0, 2**num_bits)] - mask = (1 << num_bits) - 1 - for codepoint, width in entries: - buckets[(codepoint >> low_bit) & mask].append(codepoint, width) - return buckets - - -class Table: - """Represents a lookup table. Each table contains a certain number of subtables; each - subtable is indexed by a contiguous bit range of the codepoint and contains a list - of `2**(number of bits in bit range)` entries. (The bit range is the same for all subtables.) - - Typically, tables contain a list of buckets of codepoints. Bucket `i`'s codepoints should - be indexed by sub-table `i` in the next-level lookup table. The entries of this table are - indexes into the bucket list (~= indexes into the sub-tables of the next-level table.) The - key to compression is that two different buckets in two different sub-tables may have the - same width list, which means that they can be merged into the same bucket. - - If no bucket contains two codepoints with different widths, calling `indices_to_widths` will - discard the buckets and convert the entries into `EffectiveWidth` values.""" - - def __init__( - self, - name: str, - entry_groups: Iterable[Iterable[tuple[int, CharWidthInTable]]], - secondary_entry_groups: Iterable[Iterable[tuple[int, CharWidthInTable]]], - low_bit: BitPos, - cap_bit: BitPos, - offset_type: OffsetType, - align: int, - bytes_per_row: int | None = None, - starting_indexed: list[Bucket] = [], - cfged: bool = False, - ): - """Create a lookup table with a sub-table for each `(Codepoint, EffectiveWidth)` iterator - in `entry_groups`. Each sub-table is indexed by codepoint bits in `low_bit..cap_bit`, - and each table entry is represented in the format specified by `offset_type`. Asserts - that this table is actually representable with `offset_type`.""" - starting_indexed_len = len(starting_indexed) - self.name = name - self.low_bit = low_bit - self.cap_bit = cap_bit - self.offset_type = offset_type - self.entries: list[int] = [] - self.indexed: list[Bucket] = list(starting_indexed) - self.align = align - self.bytes_per_row = bytes_per_row - self.cfged = cfged - - buckets: list[Bucket] = [] - for entries in entry_groups: - buckets.extend(make_buckets(entries, self.low_bit, self.cap_bit)) - - for bucket in buckets: - for i, existing in enumerate(self.indexed): - if existing.try_extend(bucket): - self.entries.append(i) - break - else: - self.entries.append(len(self.indexed)) - self.indexed.append(bucket) - - self.primary_len = len(self.entries) - self.primary_bucket_len = len(self.indexed) - - buckets = [] - for entries in secondary_entry_groups: - buckets.extend(make_buckets(entries, self.low_bit, self.cap_bit)) - - for bucket in buckets: - for i, existing in enumerate(self.indexed): - if existing.try_extend(bucket): - self.entries.append(i) - break - else: - self.entries.append(len(self.indexed)) - self.indexed.append(bucket) - - # Validate offset type - max_index = 1 << int(self.offset_type) - for index in self.entries: - assert index < max_index, f"{index} <= {max_index}" - - self.indexed = self.indexed[starting_indexed_len:] - - def indices_to_widths(self): - """Destructively converts the indices in this table to the `EffectiveWidth` values of - their buckets. Assumes that no bucket contains codepoints with different widths. - """ - self.entries = list(map(lambda i: int(self.indexed[i].width()), self.entries)) # type: ignore - del self.indexed - - def buckets(self): - """Returns an iterator over this table's buckets.""" - return self.indexed - - def to_bytes(self) -> list[int]: - """Returns this table's entries as a list of bytes. The bytes are formatted according to - the `OffsetType` which the table was created with, converting any `EffectiveWidth` entries - to their enum variant's integer value. For example, with `OffsetType.U2`, each byte will - contain four packed 2-bit entries.""" - entries_per_byte = 8 // int(self.offset_type) - byte_array = [] - for i in range(0, len(self.entries), entries_per_byte): - byte = 0 - for j in range(0, entries_per_byte): - byte |= self.entries[i + j] << (j * int(self.offset_type)) - byte_array.append(byte) - return byte_array - - -def make_tables( - width_map: list[WidthState], - cjk_width_map: list[WidthState], -) -> list[Table]: - """Creates a table for each configuration in `table_cfgs`, with the first config corresponding - to the top-level lookup table, the second config corresponding to the second-level lookup - table, and so forth. `entries` is an iterator over the `(Codepoint, EffectiveWidth)` pairs - to include in the top-level table.""" - - entries = enumerate([w.table_width() for w in width_map]) - cjk_entries = enumerate([w.table_width() for w in cjk_width_map]) - - root_table = Table( - "WIDTH_ROOT", - [entries], - [], - TABLE_SPLITS[1], - MAX_CODEPOINT_BITS, - OffsetType.U8, - 128, - ) - - cjk_root_table = Table( - "WIDTH_ROOT_CJK", - [cjk_entries], - [], - TABLE_SPLITS[1], - MAX_CODEPOINT_BITS, - OffsetType.U8, - 128, - starting_indexed=root_table.indexed, - cfged=True, - ) - - middle_table = Table( - "WIDTH_MIDDLE", - map(lambda bucket: bucket.entries(), root_table.buckets()), - map(lambda bucket: bucket.entries(), cjk_root_table.buckets()), - TABLE_SPLITS[0], - TABLE_SPLITS[1], - OffsetType.U8, - 2 ** (TABLE_SPLITS[1] - TABLE_SPLITS[0]), - bytes_per_row=2 ** (TABLE_SPLITS[1] - TABLE_SPLITS[0]), - ) - - leaves_table = Table( - "WIDTH_LEAVES", - map( - lambda bucket: bucket.entries(), - middle_table.buckets()[: middle_table.primary_bucket_len], - ), - map( - lambda bucket: bucket.entries(), - middle_table.buckets()[middle_table.primary_bucket_len :], - ), - 0, - TABLE_SPLITS[0], - OffsetType.U2, - 2 ** (TABLE_SPLITS[0] - 2), - bytes_per_row=2 ** (TABLE_SPLITS[0] - 2), - ) - - return [root_table, cjk_root_table, middle_table, leaves_table] - - -def load_emoji_presentation_sequences() -> list[Codepoint]: - """Outputs a list of cpodepoints, corresponding to all the valid characters for starting - an emoji presentation sequence.""" - - with fetch_open("emoji/emoji-variation-sequences.txt") as sequences: - # Match all emoji presentation sequences - # (one codepoint followed by U+FE0F, and labeled "emoji style") - sequence = re.compile(r"^([0-9A-F]+)\s+FE0F\s*;\s*emoji style") - codepoints = [] - for line in sequences.readlines(): - if match := sequence.match(line): - cp = int(match.group(1), 16) - codepoints.append(cp) - return codepoints - - -def load_text_presentation_sequences() -> list[Codepoint]: - """Outputs a list of codepoints, corresponding to all the valid characters - whose widths change with a text presentation sequence.""" - - text_presentation_seq_codepoints = set() - with fetch_open("emoji/emoji-variation-sequences.txt") as sequences: - # Match all text presentation sequences - # (one codepoint followed by U+FE0E, and labeled "text style") - sequence = re.compile(r"^([0-9A-F]+)\s+FE0E\s*;\s*text style") - for line in sequences.readlines(): - if match := sequence.match(line): - cp = int(match.group(1), 16) - text_presentation_seq_codepoints.add(cp) - - default_emoji_codepoints = set() - - load_property( - "emoji/emoji-data.txt", - "Emoji_Presentation", - lambda cp: default_emoji_codepoints.add(cp), - ) - - codepoints = [] - for cp in text_presentation_seq_codepoints.intersection(default_emoji_codepoints): - # "Enclosed Ideographic Supplement" block; - # wide even in text presentation - if not cp in range(0x1F200, 0x1F300): - codepoints.append(cp) - - codepoints.sort() - return codepoints - - -def load_emoji_modifier_bases() -> list[Codepoint]: - """Outputs a list of codepoints, corresponding to all the valid characters - whose widths change with a text presentation sequence.""" - - ret = [] - load_property( - "emoji/emoji-data.txt", - "Emoji_Modifier_Base", - lambda cp: ret.append(cp), - ) - ret.sort() - return ret - - -def make_presentation_sequence_table( - seqs: list[Codepoint], - lsb: int = 10, -) -> tuple[list[tuple[int, int]], list[list[int]]]: - """Generates 2-level lookup table for whether a codepoint might start an emoji variation sequence. - The first level is a match on all but the 10 LSB, the second level is a 1024-bit bitmap for those 10 LSB. - """ - - prefixes_dict = defaultdict(set) - for cp in seqs: - prefixes_dict[cp >> lsb].add(cp & (2**lsb - 1)) - - msbs: list[int] = list(prefixes_dict.keys()) - - leaves: list[list[int]] = [] - for cps in prefixes_dict.values(): - leaf = [0] * (2 ** (lsb - 3)) - for cp in cps: - idx_in_leaf, bit_shift = divmod(cp, 8) - leaf[idx_in_leaf] |= 1 << bit_shift - leaves.append(leaf) - - indexes = [(msb, index) for (index, msb) in enumerate(msbs)] - - # Cull duplicate leaves - i = 0 - while i < len(leaves): - first_idx = leaves.index(leaves[i]) - if first_idx == i: - i += 1 - else: - for j in range(0, len(indexes)): - if indexes[j][1] == i: - indexes[j] = (indexes[j][0], first_idx) - elif indexes[j][1] > i: - indexes[j] = (indexes[j][0], indexes[j][1] - 1) - - leaves.pop(i) - - return (indexes, leaves) - - -def make_ranges_table( - seqs: list[Codepoint], -) -> tuple[list[tuple[int, int]], list[list[tuple[int, int]]]]: - """Generates 2-level lookup table for a binary property of a codepoint. - First level is all but the last byte, second level is ranges for last byte - """ - - prefixes_dict = defaultdict(list) - for cp in seqs: - prefixes_dict[cp >> 8].append(cp & 0xFF) - - msbs: list[int] = list(prefixes_dict.keys()) - - leaves: list[list[tuple[int, int]]] = [] - for cps in prefixes_dict.values(): - leaf = [] - for cp in cps: - if len(leaf) > 0 and leaf[-1][1] == cp - 1: - leaf[-1] = (leaf[-1][0], cp) - else: - leaf.append((cp, cp)) - leaves.append(leaf) - - indexes = [(msb, index) for (index, msb) in enumerate(msbs)] - - # Cull duplicate leaves - i = 0 - while i < len(leaves): - first_idx = leaves.index(leaves[i]) - if first_idx == i: - i += 1 - else: - for j in range(0, len(indexes)): - if indexes[j][1] == i: - indexes[j] = (indexes[j][0], first_idx) - elif indexes[j][1] > i: - indexes[j] = (indexes[j][0], indexes[j][1] - 1) - - leaves.pop(i) - - return (indexes, leaves) - - -def lookup_fns( - is_cjk: bool, - special_ranges: list[tuple[tuple[Codepoint, Codepoint], WidthState]], - joining_group_lam: list[tuple[Codepoint, Codepoint]], -) -> str: - if is_cjk: - cfg = '#[cfg(feature = "cjk")]\n' - cjk_lo = "_cjk" - cjk_cap = "_CJK" - ambig = "wide" - else: - cfg = "" - cjk_lo = "" - cjk_cap = "" - ambig = "narrow" - s = f""" -/// Returns the [UAX #11](https://www.unicode.org/reports/tr11/) based width of `c` by -/// consulting a multi-level lookup table. -/// -/// # Maintenance -/// The tables themselves are autogenerated but this function is hardcoded. You should have -/// nothing to worry about if you re-run `unicode.py` (for example, when updating Unicode.) -/// However, if you change the *actual structure* of the lookup tables (perhaps by editing the -/// `make_tables` function in `unicode.py`) you must ensure that this code reflects those changes. -{cfg}#[inline] -fn lookup_width{cjk_lo}(c: char) -> (u8, WidthInfo) {{ - let cp = c as usize; - - let t1_offset = WIDTH_ROOT{cjk_cap}.0[cp >> {TABLE_SPLITS[1]}]; - - // Each sub-table in WIDTH_MIDDLE is 7 bits, and each stored entry is a byte, - // so each sub-table is 128 bytes in size. - // (Sub-tables are selected using the computed offset from the previous table.) - let t2_offset = WIDTH_MIDDLE.0[usize::from(t1_offset)][cp >> {TABLE_SPLITS[0]} & 0x{(2 ** (TABLE_SPLITS[1] - TABLE_SPLITS[0]) - 1):X}]; - - // Each sub-table in WIDTH_LEAVES is 6 bits, but each stored entry is 2 bits. - // This is accomplished by packing four stored entries into one byte. - // So each sub-table is 2**(7-2) == 32 bytes in size. - // Since this is the last table, each entry represents an encoded width. - let packed_widths = WIDTH_LEAVES.0[usize::from(t2_offset)][cp >> 2 & 0x{(2 ** (TABLE_SPLITS[0] - 2) - 1):X}]; - - // Extract the packed width - let width = packed_widths >> (2 * (cp & 0b11)) & 0b11; - - if width < 3 {{ - (width, WidthInfo::DEFAULT) - }} else {{ - match c {{ -""" - - for (lo, hi), width in special_ranges: - s += f" '\\u{{{lo:X}}}'" - if hi != lo: - s += f"..='\\u{{{hi:X}}}'" - if width.is_carried(): - width_info = width.name - else: - width_info = "DEFAULT" - s += f" => ({width.width_alone()}, WidthInfo::{width_info}),\n" - - s += f""" _ => (2, WidthInfo::EMOJI_PRESENTATION), - }} - }} -}} - -/// Returns the [UAX #11](https://www.unicode.org/reports/tr11/) based width of `c`, or -/// `None` if `c` is a control character. -/// Ambiguous width characters are treated as {ambig}. -{cfg}#[inline] -pub fn single_char_width{cjk_lo}(c: char) -> Option {{ - if c < '\\u{{7F}}' {{ - if c >= '\\u{{20}}' {{ - // U+0020 to U+007F (exclusive) are single-width ASCII codepoints - Some(1) - }} else {{ - // U+0000 to U+0020 (exclusive) are control codes - None - }} - }} else if c >= '\\u{{A0}}' {{ - // No characters >= U+00A0 are control codes, so we can consult the lookup tables - Some(lookup_width{cjk_lo}(c).0.into()) - }} else {{ - // U+007F to U+00A0 (exclusive) are control codes - None - }} -}} - -/// Returns the [UAX #11](https://www.unicode.org/reports/tr11/) based width of `c`. -/// Ambiguous width characters are treated as {ambig}. -{cfg}#[inline] -fn width_in_str{cjk_lo}(c: char, mut next_info: WidthInfo) -> (i8, WidthInfo) {{ - if next_info.is_emoji_presentation() {{ - if starts_emoji_presentation_seq(c) {{ - let width = if next_info.is_zwj_emoji_presentation() {{ - 0 - }} else {{ - 2 - }}; - return (width, WidthInfo::EMOJI_PRESENTATION); - }} else {{ - next_info = next_info.unset_emoji_presentation(); - }} - }}""" - - if is_cjk: - s += """ - if (matches!( - next_info, - WidthInfo::COMBINING_LONG_SOLIDUS_OVERLAY | WidthInfo::SOLIDUS_OVERLAY_ALEF - ) && matches!(c, '<' | '=' | '>')) - { - return (2, WidthInfo::DEFAULT); - }""" - - s += """ - if c <= '\\u{A0}' { - match c { - '\\n' => (1, WidthInfo::LINE_FEED), - '\\r' if next_info == WidthInfo::LINE_FEED => (0, WidthInfo::DEFAULT), - _ => (1, WidthInfo::DEFAULT), - } - } else { - // Fast path - if next_info != WidthInfo::DEFAULT { - if c == '\\u{FE0F}' { - return (0, next_info.set_emoji_presentation()); - }""" - - if is_cjk: - s += """ - if matches!(c, '\\u{FE00}' | '\\u{FE02}') { - return (0, next_info.set_vs1_2_3()); - } - """ - else: - s += """ - if c == '\\u{FE01}' { - return (0, next_info.set_vs1_2_3()); - } - if c == '\\u{FE0E}' { - return (0, next_info.set_text_presentation()); - } - if next_info.is_text_presentation() { - if starts_non_ideographic_text_presentation_seq(c) { - return (1, WidthInfo::DEFAULT); - } else { - next_info = next_info.unset_text_presentation(); - } - } else """ - - s += """if next_info.is_vs1_2_3() { - if matches!(c, '\\u{2018}' | '\\u{2019}' | '\\u{201C}' | '\\u{201D}') { - return (""" - - s += str(2 - is_cjk) - - s += """, WidthInfo::DEFAULT); - } else { - next_info = next_info.unset_vs1_2_3(); - } - } - if next_info.is_ligature_transparent() { - if c == '\\u{200D}' { - return (0, next_info.set_zwj_bit()); - } else if is_ligature_transparent(c) { - return (0, next_info); - } - } - - match (next_info, c) {""" - if is_cjk: - s += """ - (WidthInfo::COMBINING_LONG_SOLIDUS_OVERLAY, _) if is_solidus_transparent(c) => { - return ( - lookup_width_cjk(c).0 as i8, - WidthInfo::COMBINING_LONG_SOLIDUS_OVERLAY, - ); - } - (WidthInfo::JOINING_GROUP_ALEF, '\\u{0338}') => { - return (0, WidthInfo::SOLIDUS_OVERLAY_ALEF); - } - // Arabic Lam-Alef ligature - ( - WidthInfo::JOINING_GROUP_ALEF | WidthInfo::SOLIDUS_OVERLAY_ALEF, - """ - else: - s += """ - // Arabic Lam-Alef ligature - ( - WidthInfo::JOINING_GROUP_ALEF, - """ - - tail = False - for lo, hi in joining_group_lam: - if tail: - s += " | " - tail = True - s += f"'\\u{{{lo:X}}}'" - if hi != lo: - s += f"..='\\u{{{hi:X}}}'" - s += """, - ) => return (0, WidthInfo::DEFAULT), - (WidthInfo::JOINING_GROUP_ALEF, _) if is_transparent_zero_width(c) => { - return (0, WidthInfo::JOINING_GROUP_ALEF); - } - - // Hebrew Alef-ZWJ-Lamed ligature - (WidthInfo::ZWJ_HEBREW_LETTER_LAMED, '\\u{05D0}') => { - return (0, WidthInfo::DEFAULT); - } - - // Khmer coeng signs - (WidthInfo::KHMER_COENG_ELIGIBLE_LETTER, '\\u{17D2}') => { - return (-1, WidthInfo::DEFAULT); - } - - // Buginese ZWJ ya ligature - (WidthInfo::ZWJ_BUGINESE_LETTER_YA, '\\u{1A17}') => { - return (0, WidthInfo::BUGINESE_VOWEL_SIGN_I_ZWJ_LETTER_YA) - } - (WidthInfo::BUGINESE_VOWEL_SIGN_I_ZWJ_LETTER_YA, '\\u{1A15}') => { - return (0, WidthInfo::DEFAULT) - } - - // Tifinagh bi-consonants - (WidthInfo::TIFINAGH_CONSONANT | WidthInfo::ZWJ_TIFINAGH_CONSONANT, '\\u{2D7F}') => { - return (1, WidthInfo::TIFINAGH_JOINER_CONSONANT); - } - (WidthInfo::ZWJ_TIFINAGH_CONSONANT, '\\u{2D31}'..='\\u{2D65}' | '\\u{2D6F}') => { - return (0, WidthInfo::DEFAULT); - } - (WidthInfo::TIFINAGH_JOINER_CONSONANT, '\\u{2D31}'..='\\u{2D65}' | '\\u{2D6F}') => { - return (-1, WidthInfo::DEFAULT); - } - - // Lisu tone letter combinations - (WidthInfo::LISU_TONE_LETTER_MYA_NA_JEU, '\\u{A4F8}'..='\\u{A4FB}') => { - return (0, WidthInfo::DEFAULT); - } - - // Old Turkic ligature - (WidthInfo::ZWJ_OLD_TURKIC_LETTER_ORKHON_I, '\\u{10C32}') => { - return (0, WidthInfo::DEFAULT); - }""" - - s += f""" - // Emoji modifier - (WidthInfo::EMOJI_MODIFIER, _) if is_emoji_modifier_base(c) => {{ - return (0, WidthInfo::EMOJI_PRESENTATION); - }} - - // Regional indicator - ( - WidthInfo::REGIONAL_INDICATOR | WidthInfo::SEVERAL_REGIONAL_INDICATOR, - '\\u{{1F1E6}}'..='\\u{{1F1FF}}', - ) => return (1, WidthInfo::SEVERAL_REGIONAL_INDICATOR), - - // ZWJ emoji - ( - WidthInfo::EMOJI_PRESENTATION - | WidthInfo::SEVERAL_REGIONAL_INDICATOR - | WidthInfo::EVEN_REGIONAL_INDICATOR_ZWJ_PRESENTATION - | WidthInfo::ODD_REGIONAL_INDICATOR_ZWJ_PRESENTATION - | WidthInfo::EMOJI_MODIFIER, - '\\u{{200D}}', - ) => return (0, WidthInfo::ZWJ_EMOJI_PRESENTATION), - (WidthInfo::ZWJ_EMOJI_PRESENTATION, '\\u{{20E3}}') => {{ - return (0, WidthInfo::KEYCAP_ZWJ_EMOJI_PRESENTATION); - }} - (WidthInfo::VS16_ZWJ_EMOJI_PRESENTATION, _) if starts_emoji_presentation_seq(c) => {{ - return (0, WidthInfo::EMOJI_PRESENTATION) - }} - (WidthInfo::VS16_KEYCAP_ZWJ_EMOJI_PRESENTATION, '0'..='9' | '#' | '*') => {{ - return (0, WidthInfo::EMOJI_PRESENTATION) - }} - (WidthInfo::ZWJ_EMOJI_PRESENTATION, '\\u{{1F1E6}}'..='\\u{{1F1FF}}') => {{ - return (1, WidthInfo::REGIONAL_INDICATOR_ZWJ_PRESENTATION); - }} - ( - WidthInfo::REGIONAL_INDICATOR_ZWJ_PRESENTATION - | WidthInfo::ODD_REGIONAL_INDICATOR_ZWJ_PRESENTATION, - '\\u{{1F1E6}}'..='\\u{{1F1FF}}', - ) => return (-1, WidthInfo::EVEN_REGIONAL_INDICATOR_ZWJ_PRESENTATION), - ( - WidthInfo::EVEN_REGIONAL_INDICATOR_ZWJ_PRESENTATION, - '\\u{{1F1E6}}'..='\\u{{1F1FF}}', - ) => return (3, WidthInfo::ODD_REGIONAL_INDICATOR_ZWJ_PRESENTATION), - (WidthInfo::ZWJ_EMOJI_PRESENTATION, '\\u{{1F3FB}}'..='\\u{{1F3FF}}') => {{ - return (0, WidthInfo::EMOJI_MODIFIER); - }} - (WidthInfo::ZWJ_EMOJI_PRESENTATION, '\\u{{E007F}}') => {{ - return (0, WidthInfo::TAG_END_ZWJ_EMOJI_PRESENTATION); - }} - (WidthInfo::TAG_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0061}}'..='\\u{{E007A}}') => {{ - return (0, WidthInfo::TAG_A1_END_ZWJ_EMOJI_PRESENTATION); - }} - (WidthInfo::TAG_A1_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0061}}'..='\\u{{E007A}}') => {{ - return (0, WidthInfo::TAG_A2_END_ZWJ_EMOJI_PRESENTATION) - }} - (WidthInfo::TAG_A2_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0061}}'..='\\u{{E007A}}') => {{ - return (0, WidthInfo::TAG_A3_END_ZWJ_EMOJI_PRESENTATION) - }} - (WidthInfo::TAG_A3_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0061}}'..='\\u{{E007A}}') => {{ - return (0, WidthInfo::TAG_A4_END_ZWJ_EMOJI_PRESENTATION) - }} - (WidthInfo::TAG_A4_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0061}}'..='\\u{{E007A}}') => {{ - return (0, WidthInfo::TAG_A5_END_ZWJ_EMOJI_PRESENTATION) - }} - (WidthInfo::TAG_A5_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0061}}'..='\\u{{E007A}}') => {{ - return (0, WidthInfo::TAG_A6_END_ZWJ_EMOJI_PRESENTATION) - }} - ( - WidthInfo::TAG_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_A1_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_A2_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_A3_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_A4_END_ZWJ_EMOJI_PRESENTATION, - '\\u{{E0030}}'..='\\u{{E0039}}', - ) => return (0, WidthInfo::TAG_D1_END_ZWJ_EMOJI_PRESENTATION), - (WidthInfo::TAG_D1_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0030}}'..='\\u{{E0039}}') => {{ - return (0, WidthInfo::TAG_D2_END_ZWJ_EMOJI_PRESENTATION); - }} - (WidthInfo::TAG_D2_END_ZWJ_EMOJI_PRESENTATION, '\\u{{E0030}}'..='\\u{{E0039}}') => {{ - return (0, WidthInfo::TAG_D3_END_ZWJ_EMOJI_PRESENTATION); - }} - ( - WidthInfo::TAG_A3_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_A4_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_A5_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_A6_END_ZWJ_EMOJI_PRESENTATION - | WidthInfo::TAG_D3_END_ZWJ_EMOJI_PRESENTATION, - '\\u{{1F3F4}}', - ) => return (0, WidthInfo::EMOJI_PRESENTATION), - (WidthInfo::ZWJ_EMOJI_PRESENTATION, _) - if lookup_width{cjk_lo}(c).1 == WidthInfo::EMOJI_PRESENTATION => - {{ - return (0, WidthInfo::EMOJI_PRESENTATION) - }} - - (WidthInfo::KIRAT_RAI_VOWEL_SIGN_E, '\\u{{16D63}}') => {{ - return (0, WidthInfo::DEFAULT); - }} - (WidthInfo::KIRAT_RAI_VOWEL_SIGN_E, '\\u{{16D67}}') => {{ - return (0, WidthInfo::KIRAT_RAI_VOWEL_SIGN_AI); - }} - (WidthInfo::KIRAT_RAI_VOWEL_SIGN_E, '\\u{{16D68}}') => {{ - return (1, WidthInfo::KIRAT_RAI_VOWEL_SIGN_E); - }} - (WidthInfo::KIRAT_RAI_VOWEL_SIGN_E, '\\u{{16D69}}') => {{ - return (0, WidthInfo::DEFAULT); - }} - (WidthInfo::KIRAT_RAI_VOWEL_SIGN_AI, '\\u{{16D63}}') => {{ - return (0, WidthInfo::DEFAULT); - }} - - // Fallback - _ => {{}} - }} - }} - - let ret = lookup_width{cjk_lo}(c); - (ret.0 as i8, ret.1) - }} -}} - -{cfg}#[inline] -pub fn str_width{cjk_lo}(s: &str) -> usize {{ - s.chars() - .rfold( - (0, WidthInfo::DEFAULT), - |(sum, next_info), c| -> (usize, WidthInfo) {{ - let (add, info) = width_in_str{cjk_lo}(c, next_info); - (sum.wrapping_add_signed(isize::from(add)), info) - }}, - ) - .0 -}} -""" - - return s - - -def emit_module( - out_name: str, - unicode_version: tuple[int, int, int], - tables: list[Table], - special_ranges: list[tuple[tuple[Codepoint, Codepoint], WidthState]], - special_ranges_cjk: list[tuple[tuple[Codepoint, Codepoint], WidthState]], - emoji_presentation_table: tuple[list[tuple[int, int]], list[list[int]]], - text_presentation_table: tuple[list[tuple[int, int]], list[list[tuple[int, int]]]], - emoji_modifier_table: tuple[list[tuple[int, int]], list[list[tuple[int, int]]]], - joining_group_lam: list[tuple[Codepoint, Codepoint]], - non_transparent_zero_widths: list[tuple[Codepoint, Codepoint]], - ligature_transparent: list[tuple[Codepoint, Codepoint]], - solidus_transparent: list[tuple[Codepoint, Codepoint]], - normalization_tests: list[tuple[str, str, str, str, str]], -): - """Outputs a Rust module to `out_name` using table data from `tables`. - If `TABLE_CFGS` is edited, you may need to edit the included code for `lookup_width`. - """ - if os.path.exists(out_name): - os.remove(out_name) - with open(out_name, "w", newline="\n", encoding="utf-8") as module: - module.write( - """// Copyright 2012-2025 The Rust Project Developers. See the COPYRIGHT -// file at the top-level directory of this distribution and at -// http://rust-lang.org/COPYRIGHT. -// -// Licensed under the Apache License, Version 2.0 or the MIT license -// , at your -// option. This file may not be copied, modified, or distributed -// except according to those terms. - -// NOTE: The following code was generated by "scripts/unicode.py", do not edit directly - -use core::cmp::Ordering; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct WidthInfo(u16); - -const LIGATURE_TRANSPARENT_MASK: u16 = 0b0010_0000_0000_0000; - -impl WidthInfo { - /// No special handling necessary - const DEFAULT: Self = Self(0); -""" - ) - - for variant in WidthState: - if variant.is_carried(): - if variant.is_cjk_only(): - module.write(' #[cfg(feature = "cjk")]\n') - module.write( - f" const {variant.name}: Self = Self(0b{variant.value:016b});\n" - ) - - module.write( - f""" - /// Whether this width mode is ligature_transparent - /// (has 5th MSB set.) - fn is_ligature_transparent(self) -> bool {{ - (self.0 & 0b0000_1000_0000_0000) == 0b0000_1000_0000_0000 - }} - - /// Sets 6th MSB. - fn set_zwj_bit(self) -> Self {{ - Self(self.0 | 0b0000_0100_0000_0000) - }} - - /// Has top bit set - fn is_emoji_presentation(self) -> bool {{ - (self.0 & WidthInfo::VARIATION_SELECTOR_16.0) == WidthInfo::VARIATION_SELECTOR_16.0 - }} - - fn is_zwj_emoji_presentation(self) -> bool {{ - (self.0 & 0b1011_0000_0000_0000) == 0b1001_0000_0000_0000 - }} - - /// Set top bit - fn set_emoji_presentation(self) -> Self {{ - if (self.0 & LIGATURE_TRANSPARENT_MASK) == LIGATURE_TRANSPARENT_MASK - || (self.0 & 0b1001_0000_0000_0000) == 0b0001_0000_0000_0000 - {{ - Self( - self.0 - | WidthInfo::VARIATION_SELECTOR_16.0 - & !WidthInfo::VARIATION_SELECTOR_15.0 - & !WidthInfo::VARIATION_SELECTOR_1_2_OR_3.0, - ) - }} else {{ - Self::VARIATION_SELECTOR_16 - }} - }} - - /// Clear top bit - fn unset_emoji_presentation(self) -> Self {{ - if (self.0 & LIGATURE_TRANSPARENT_MASK) == LIGATURE_TRANSPARENT_MASK {{ - Self(self.0 & !WidthInfo::VARIATION_SELECTOR_16.0) - }} else {{ - Self::DEFAULT - }} - }} - - /// Has 2nd bit set - fn is_text_presentation(self) -> bool {{ - (self.0 & WidthInfo::VARIATION_SELECTOR_15.0) == WidthInfo::VARIATION_SELECTOR_15.0 - }} - - /// Set 2nd bit - fn set_text_presentation(self) -> Self {{ - if (self.0 & LIGATURE_TRANSPARENT_MASK) == LIGATURE_TRANSPARENT_MASK {{ - Self( - self.0 - | WidthInfo::VARIATION_SELECTOR_15.0 - & !WidthInfo::VARIATION_SELECTOR_16.0 - & !WidthInfo::VARIATION_SELECTOR_1_2_OR_3.0, - ) - }} else {{ - Self(WidthInfo::VARIATION_SELECTOR_15.0) - }} - }} - - /// Clear 2nd bit - fn unset_text_presentation(self) -> Self {{ - Self(self.0 & !WidthInfo::VARIATION_SELECTOR_15.0) - }} - - /// Has 7th bit set - fn is_vs1_2_3(self) -> bool {{ - (self.0 & WidthInfo::VARIATION_SELECTOR_1_2_OR_3.0) - == WidthInfo::VARIATION_SELECTOR_1_2_OR_3.0 - }} - - /// Set 7th bit - fn set_vs1_2_3(self) -> Self {{ - if (self.0 & LIGATURE_TRANSPARENT_MASK) == LIGATURE_TRANSPARENT_MASK {{ - Self( - self.0 - | WidthInfo::VARIATION_SELECTOR_1_2_OR_3.0 - & !WidthInfo::VARIATION_SELECTOR_15.0 - & !WidthInfo::VARIATION_SELECTOR_16.0, - ) - }} else {{ - Self(WidthInfo::VARIATION_SELECTOR_1_2_OR_3.0) - }} - }} - - /// Clear 7th bit - fn unset_vs1_2_3(self) -> Self {{ - Self(self.0 & !WidthInfo::VARIATION_SELECTOR_1_2_OR_3.0) - }} -}} - -/// The version of [Unicode](http://www.unicode.org/) -/// that this version of unicode-width is based on. -pub const UNICODE_VERSION: (u8, u8, u8) = {unicode_version}; -""" - ) - - module.write(lookup_fns(False, special_ranges, joining_group_lam)) - module.write(lookup_fns(True, special_ranges_cjk, joining_group_lam)) - - emoji_presentation_idx, emoji_presentation_leaves = emoji_presentation_table - text_presentation_idx, text_presentation_leaves = text_presentation_table - emoji_modifier_idx, emoji_modifier_leaves = emoji_modifier_table - - module.write( - """ -/// Whether this character is a zero-width character with -/// `Joining_Type=Transparent`. Used by the Alef-Lamed ligatures. -/// See also [`is_ligature_transparent`], a near-subset of this (only ZWJ is excepted) -/// which is transparent for non-Arabic ligatures. -fn is_transparent_zero_width(c: char) -> bool { - if lookup_width(c).0 != 0 { - // Not zero-width - false - } else { - let cp: u32 = c.into(); - NON_TRANSPARENT_ZERO_WIDTHS - .binary_search_by(|&(lo, hi)| { - let lo = u32::from_le_bytes([lo[0], lo[1], lo[2], 0]); - let hi = u32::from_le_bytes([hi[0], hi[1], hi[2], 0]); - if cp < lo { - Ordering::Greater - } else if cp > hi { - Ordering::Less - } else { - Ordering::Equal - } - }) - .is_err() - } -} - -/// Whether this character is a default-ignorable combining mark -/// or ZWJ. These characters won't interrupt non-Arabic ligatures. -fn is_ligature_transparent(c: char) -> bool { - matches!(c, """ - ) - - tail = False - for lo, hi in ligature_transparent: - if tail: - module.write(" | ") - tail = True - module.write(f"'\\u{{{lo:X}}}'") - if hi != lo: - module.write(f"..='\\u{{{hi:X}}}'") - - module.write( - """) -} - -/// Whether this character is transparent wrt the effect of -/// U+0338 COMBINING LONG SOLIDUS OVERLAY -/// on its base character. -#[cfg(feature = "cjk")] -fn is_solidus_transparent(c: char) -> bool { - let cp: u32 = c.into(); - is_ligature_transparent(c) - || SOLIDUS_TRANSPARENT - .binary_search_by(|&(lo, hi)| { - let lo = u32::from_le_bytes([lo[0], lo[1], lo[2], 0]); - let hi = u32::from_le_bytes([hi[0], hi[1], hi[2], 0]); - if cp < lo { - Ordering::Greater - } else if cp > hi { - Ordering::Less - } else { - Ordering::Equal - } - }) - .is_ok() -} - -/// Whether this character forms an [emoji presentation sequence] -/// (https://www.unicode.org/reports/tr51/#def_emoji_presentation_sequence) -/// when followed by `'\\u{FEOF}'`. -/// Emoji presentation sequences are considered to have width 2. -#[inline] -pub fn starts_emoji_presentation_seq(c: char) -> bool { - let cp: u32 = c.into(); - // First level of lookup uses all but 10 LSB - let top_bits = cp >> 10; - let idx_of_leaf: usize = match top_bits { -""" - ) - - for msbs, i in emoji_presentation_idx: - module.write(f" 0x{msbs:X} => {i},\n") - - module.write( - """ _ => return false, - }; - // Extract the 3-9th (0-indexed) least significant bits of `cp`, - // and use them to index into `leaf_row`. - let idx_within_leaf = usize::try_from((cp >> 3) & 0x7F).unwrap(); - let leaf_byte = EMOJI_PRESENTATION_LEAVES.0[idx_of_leaf][idx_within_leaf]; - // Use the 3 LSB of `cp` to index into `leaf_byte`. - ((leaf_byte >> (cp & 7)) & 1) == 1 -} - -/// Returns `true` if `c` has default emoji presentation, but forms a [text presentation sequence] -/// (https://www.unicode.org/reports/tr51/#def_text_presentation_sequence) -/// when followed by `'\\u{FEOE}'`, and is not ideographic. -/// Such sequences are considered to have width 1. -#[inline] -pub fn starts_non_ideographic_text_presentation_seq(c: char) -> bool { - let cp: u32 = c.into(); - // First level of lookup uses all but 8 LSB - let top_bits = cp >> 8; - let leaf: &[(u8, u8)] = match top_bits { -""" - ) - - for msbs, i in text_presentation_idx: - module.write(f" 0x{msbs:X} => &TEXT_PRESENTATION_LEAF_{i},\n") - - module.write( - """ _ => return false, - }; - - let bottom_bits = (cp & 0xFF) as u8; - leaf.binary_search_by(|&(lo, hi)| { - if bottom_bits < lo { - Ordering::Greater - } else if bottom_bits > hi { - Ordering::Less - } else { - Ordering::Equal - } - }) - .is_ok() -} - -/// Returns `true` if `c` is an `Emoji_Modifier_Base`. -#[inline] -pub fn is_emoji_modifier_base(c: char) -> bool { - let cp: u32 = c.into(); - // First level of lookup uses all but 8 LSB - let top_bits = cp >> 8; - let leaf: &[(u8, u8)] = match top_bits { -""" - ) - - for msbs, i in emoji_modifier_idx: - module.write(f" 0x{msbs:X} => &EMOJI_MODIFIER_LEAF_{i},\n") - - module.write( - """ _ => return false, - }; - - let bottom_bits = (cp & 0xFF) as u8; - leaf.binary_search_by(|&(lo, hi)| { - if bottom_bits < lo { - Ordering::Greater - } else if bottom_bits > hi { - Ordering::Less - } else { - Ordering::Equal - } - }) - .is_ok() -} - -#[repr(align(32))] -struct Align32(T); - -#[repr(align(64))] -struct Align64(T); - -#[repr(align(128))] -struct Align128(T); -""" - ) - - subtable_count = 1 - for i, table in enumerate(tables): - new_subtable_count = len(table.buckets()) - if i == len(tables) - 1: - table.indices_to_widths() # for the last table, indices == widths - byte_array = table.to_bytes() - - if table.bytes_per_row is None: - module.write( - f"/// Autogenerated. {subtable_count} sub-table(s). Consult [`lookup_width`] for layout info.)\n" - ) - if table.cfged: - module.write('#[cfg(feature = "cjk")]\n') - module.write( - f"static {table.name}: Align{table.align}<[u8; {len(byte_array)}]> = Align{table.align}([" - ) - for j, byte in enumerate(byte_array): - # Add line breaks for every 15th entry (chosen to match what rustfmt does) - if j % 16 == 0: - module.write("\n ") - module.write(f" 0x{byte:02X},") - module.write("\n") - else: - num_rows = len(byte_array) // table.bytes_per_row - num_primary_rows = ( - table.primary_len - // (8 // int(table.offset_type)) - // table.bytes_per_row - ) - module.write( - f""" -#[cfg(feature = "cjk")] -const {table.name}_LEN: usize = {num_rows}; -#[cfg(not(feature = "cjk"))] -const {table.name}_LEN: usize = {num_primary_rows}; -/// Autogenerated. {subtable_count} sub-table(s). Consult [`lookup_width`] for layout info. -static {table.name}: Align{table.align}<[[u8; {table.bytes_per_row}]; {table.name}_LEN]> = Align{table.align}([\n""" - ) - for row_num in range(0, num_rows): - if row_num >= num_primary_rows: - module.write(' #[cfg(feature = "cjk")]\n') - module.write(" [\n") - row = byte_array[ - row_num - * table.bytes_per_row : (row_num + 1) - * table.bytes_per_row - ] - for subrow in batched(row, 15): - module.write(" ") - for entry in subrow: - module.write(f" 0x{entry:02X},") - module.write("\n") - module.write(" ],\n") - module.write("]);\n") - subtable_count = new_subtable_count - - # non transparent zero width table - - module.write( - f""" -/// Sorted list of codepoint ranges (inclusive) -/// that are zero-width but not `Joining_Type=Transparent` -/// FIXME: can we get better compression? -static NON_TRANSPARENT_ZERO_WIDTHS: [([u8; 3], [u8; 3]); {len(non_transparent_zero_widths)}] = [ -""" - ) - - for lo, hi in non_transparent_zero_widths: - module.write( - f" ([0x{lo & 0xFF:02X}, 0x{lo >> 8 & 0xFF:02X}, 0x{lo >> 16:02X}], [0x{hi & 0xFF:02X}, 0x{hi >> 8 & 0xFF:02X}, 0x{hi >> 16:02X}]),\n" - ) - - # solidus transparent table - - module.write( - f"""]; - -/// Sorted list of codepoint ranges (inclusive) -/// that don't affect how the combining solidus applies -/// (mostly ccc > 1). -/// FIXME: can we get better compression? -#[cfg(feature = "cjk")] -static SOLIDUS_TRANSPARENT: [([u8; 3], [u8; 3]); {len(solidus_transparent)}] = [ -""" - ) - - for lo, hi in solidus_transparent: - module.write( - f" ([0x{lo & 0xFF:02X}, 0x{lo >> 8 & 0xFF:02X}, 0x{lo >> 16:02X}], [0x{hi & 0xFF:02X}, 0x{hi >> 8 & 0xFF:02X}, 0x{hi >> 16:02X}]),\n" - ) - - # emoji table - - module.write( - f"""]; - -/// Array of 1024-bit bitmaps. Index into the correct bitmap with the 10 LSB of your codepoint -/// to get whether it can start an emoji presentation sequence. -static EMOJI_PRESENTATION_LEAVES: Align128<[[u8; 128]; {len(emoji_presentation_leaves)}]> = Align128([ -""" - ) - for leaf in emoji_presentation_leaves: - module.write(" [\n") - for row in batched(leaf, 15): - module.write(" ") - for entry in row: - module.write(f" 0x{entry:02X},") - module.write("\n") - module.write(" ],\n") - - module.write("]);\n") - - # text table - - for leaf_idx, leaf in enumerate(text_presentation_leaves): - module.write( - f""" -#[rustfmt::skip] -static TEXT_PRESENTATION_LEAF_{leaf_idx}: [(u8, u8); {len(leaf)}] = [ -""" - ) - for lo, hi in leaf: - module.write(f" (0x{lo:02X}, 0x{hi:02X}),\n") - module.write(f"];\n") - - # emoji modifier table - - for leaf_idx, leaf in enumerate(emoji_modifier_leaves): - module.write( - f""" -#[rustfmt::skip] -static EMOJI_MODIFIER_LEAF_{leaf_idx}: [(u8, u8); {len(leaf)}] = [ -""" - ) - for lo, hi in leaf: - module.write(f" (0x{lo:02X}, 0x{hi:02X}),\n") - module.write(f"];\n") - - test_width_variants = [] - test_width_variants_cjk = [] - for variant in WidthState: - if variant.is_carried(): - if not variant.is_cjk_only(): - test_width_variants.append(variant) - if not variant.is_non_cjk_only(): - test_width_variants_cjk.append(variant) - - module.write( - f""" -#[cfg(test)] -mod tests {{ - use super::*; - - fn str_width_test(s: &str, init: WidthInfo) -> isize {{ - s.chars() - .rfold((0, init), |(sum, next_info), c| -> (isize, WidthInfo) {{ - let (add, info) = width_in_str(c, next_info); - (sum.checked_add(isize::from(add)).unwrap(), info) - }}) - .0 - }} - - #[cfg(feature = "cjk")] - fn str_width_test_cjk(s: &str, init: WidthInfo) -> isize {{ - s.chars() - .rfold((0, init), |(sum, next_info), c| -> (isize, WidthInfo) {{ - let (add, info) = width_in_str_cjk(c, next_info); - (sum.checked_add(isize::from(add)).unwrap(), info) - }}) - .0 - }} - - #[test] - fn test_normalization() {{ - for &(orig, nfc, nfd, nfkc, nfkd) in &NORMALIZATION_TEST {{ - for init in NORMALIZATION_TEST_WIDTHS {{ - assert_eq!( - str_width_test(orig, init), - str_width_test(nfc, init), - "width of X = {{orig:?}} differs from toNFC(X) = {{nfc:?}} with mode {{init:X?}}", - ); - assert_eq!( - str_width_test(orig, init), - str_width_test(nfd, init), - "width of X = {{orig:?}} differs from toNFD(X) = {{nfd:?}} with mode {{init:X?}}", - ); - assert_eq!( - str_width_test(nfkc, init), - str_width_test(nfkd, init), - "width of toNFKC(X) = {{nfkc:?}} differs from toNFKD(X) = {{nfkd:?}} with mode {{init:X?}}", - ); - }} - - #[cfg(feature = "cjk")] - for init in NORMALIZATION_TEST_WIDTHS_CJK {{ - assert_eq!( - str_width_test_cjk(orig, init), - str_width_test_cjk(nfc, init), - "CJK width of X = {{orig:?}} differs from toNFC(X) = {{nfc:?}} with mode {{init:X?}}", - ); - assert_eq!( - str_width_test_cjk(orig, init), - str_width_test_cjk(nfd, init), - "CJK width of X = {{orig:?}} differs from toNFD(X) = {{nfd:?}} with mode {{init:X?}}", - ); - assert_eq!( - str_width_test_cjk(nfkc, init), - str_width_test_cjk(nfkd, init), - "CJK width of toNFKC(X) = {{nfkc:?}} differs from toNFKD(X) = {{nfkd:?}} with mode {{init:?}}", - ); - }} - }} - }} - - static NORMALIZATION_TEST_WIDTHS: [WidthInfo; {len(test_width_variants) + 1}] = [ - WidthInfo::DEFAULT,\n""" - ) - - for variant in WidthState: - if variant.is_carried() and not variant.is_cjk_only(): - module.write(f" WidthInfo::{variant.name},\n") - - module.write( - f""" ]; - - #[cfg(feature = "cjk")] - static NORMALIZATION_TEST_WIDTHS_CJK: [WidthInfo; {len(test_width_variants_cjk) + 1}] = [ - WidthInfo::DEFAULT,\n""" - ) - - for variant in WidthState: - if variant.is_carried() and not variant.is_non_cjk_only(): - module.write(f" WidthInfo::{variant.name},\n") - - module.write( - f""" ]; - - #[rustfmt::skip] - static NORMALIZATION_TEST: [(&str, &str, &str, &str, &str); {len(normalization_tests)}] = [\n""" - ) - for orig, nfc, nfd, nfkc, nfkd in normalization_tests: - module.write( - f' (r#"{orig}"#, r#"{nfc}"#, r#"{nfd}"#, r#"{nfkc}"#, r#"{nfkd}"#),\n' - ) - - module.write(" ];\n}\n") - - -def main(module_path: str): - """Obtain character data from the latest version of Unicode, transform it into a multi-level - lookup table for character width, and write a Rust module utilizing that table to - `module_filename`. - - See `lib.rs` for documentation of the exact width rules. - """ - version = load_unicode_version() - print(f"Generating module for Unicode {version[0]}.{version[1]}.{version[2]}") - - (width_map, cjk_width_map) = load_width_maps() - - tables = make_tables(width_map, cjk_width_map) - - special_ranges = make_special_ranges(width_map) - cjk_special_ranges = make_special_ranges(cjk_width_map) - - emoji_presentations = load_emoji_presentation_sequences() - emoji_presentation_table = make_presentation_sequence_table(emoji_presentations) - - text_presentations = load_text_presentation_sequences() - text_presentation_table = make_ranges_table(text_presentations) - - emoji_modifier_bases = load_emoji_modifier_bases() - emoji_modifier_table = make_ranges_table(emoji_modifier_bases) - - joining_group_lam = load_joining_group_lam() - non_transparent_zero_widths = load_non_transparent_zero_widths(width_map) - ligature_transparent = load_ligature_transparent() - solidus_transparent = load_solidus_transparent(ligature_transparent, cjk_width_map) - - normalization_tests = load_normalization_tests() - - fetch_open("emoji-test.txt", "../tests", emoji=True) - - print("------------------------") - total_size = 0 - for i, table in enumerate(tables): - size_bytes = len(table.to_bytes()) - print(f"Table {i} size: {size_bytes} bytes") - total_size += size_bytes - - for s, table in [ - ("Emoji presentation", emoji_presentation_table), - ]: - index_size = len(table[0]) * (math.ceil(math.log(table[0][-1][0], 256)) + 8) - print(f"{s} index size: {index_size} bytes") - total_size += index_size - leaves_size = len(table[1]) * len(table[1][0]) - print(f"{s} leaves size: {leaves_size} bytes") - total_size += leaves_size - - for s, table in [ - ("Text presentation", text_presentation_table), - ("Emoji modifier", emoji_modifier_table), - ]: - index_size = len(table[0]) * (math.ceil(math.log(table[0][-1][0], 256)) + 16) - print(f"{s} index size: {index_size} bytes") - total_size += index_size - leaves_size = 2 * sum(map(len, table[1])) - print(f"{s} leaves size: {leaves_size} bytes") - total_size += leaves_size - - for s, table in [ - ("Non transparent zero width", non_transparent_zero_widths), - ("Solidus transparent", solidus_transparent), - ]: - table_size = 6 * len(table) - print(f"{s} table size: {table_size} bytes") - total_size += table_size - print("------------------------") - print(f" Total size: {total_size} bytes") - - emit_module( - out_name=module_path, - unicode_version=version, - tables=tables, - special_ranges=special_ranges, - special_ranges_cjk=cjk_special_ranges, - emoji_presentation_table=emoji_presentation_table, - text_presentation_table=text_presentation_table, - emoji_modifier_table=emoji_modifier_table, - joining_group_lam=joining_group_lam, - non_transparent_zero_widths=non_transparent_zero_widths, - ligature_transparent=ligature_transparent, - solidus_transparent=solidus_transparent, - normalization_tests=normalization_tests, - ) - print(f'Wrote to "{module_path}"') - - -if __name__ == "__main__": - main(MODULE_PATH) diff --git a/vendor/zerocopy/ci/validate_auto_approvers.py b/vendor/zerocopy/ci/validate_auto_approvers.py deleted file mode 100755 index 3fdfaecf..00000000 --- a/vendor/zerocopy/ci/validate_auto_approvers.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import json -import re -import sys -import posixpath -import os - -# Exit codes -SUCCESS = 0 -NOT_APPROVED = 1 -TECHNICAL_ERROR = 255 - - -def main(): - parser = argparse.ArgumentParser( - description="Validate PR changes against auto-approver rules." - ) - parser.add_argument( - "--config", - default=".github/auto-approvers.json", - help="Path to the rules JSON.", - ) - parser.add_argument( - "--changed-files", help="Path to the fetched changed files JSON." - ) - parser.add_argument( - "--expected-count", type=int, help="Total number of files expected in the PR." - ) - parser.add_argument( - "--contributors", nargs="+", help="List of GitHub usernames to validate." - ) - parser.add_argument( - "--check-config", - action="store_true", - help="Only validate the configuration file and exit.", - ) - args = parser.parse_args() - - # REGEX: Strict path structure, prevents absolute paths and weird characters - VALID_PATH = re.compile(r"^([a-zA-Z0-9_.-]+/)+$") - - # Load and validate config - try: - with open(args.config) as f: - rules = json.load(f) - except FileNotFoundError: - print(f"::error::❌ Config file not found at {args.config}") - sys.exit(TECHNICAL_ERROR) - except json.JSONDecodeError as e: - print(f"::error::❌ Failed to parse config JSON: {e}") - sys.exit(TECHNICAL_ERROR) - - safe_rules = {} - for directory, users in rules.items(): - if not isinstance(users, list): - print( - f"::error::❌ Users for '{directory}' must be a JSON array (list), not a string." - ) - sys.exit(TECHNICAL_ERROR) - - if not VALID_PATH.match(directory) or ".." in directory.split("/"): - print(f"::error::❌ Invalid config path: {directory}") - sys.exit(TECHNICAL_ERROR) - - safe_rules[directory] = [str(u).lower() for u in users] - - if not args.check_config: - # Validate that required arguments are present if not in --check-config mode - if not ( - args.changed_files and args.expected_count is not None and args.contributors - ): - print( - "::error::❌ Missing required arguments: --changed-files, --expected-count, and --contributors are required unless --check-config is used." - ) - sys.exit(TECHNICAL_ERROR) - - # Load and flatten changed files - try: - with open(args.changed_files) as f: - file_objects = json.load(f) - except FileNotFoundError: - print(f"::error::❌ Changed files JSON not found at {args.changed_files}") - sys.exit(TECHNICAL_ERROR) - except json.JSONDecodeError as e: - print(f"::error::❌ Failed to parse changed files JSON: {e}") - sys.exit(TECHNICAL_ERROR) - - if not file_objects or len(file_objects) != args.expected_count: - print( - f"::error::❌ File truncation mismatch or empty PR. Expected {args.expected_count}, got {len(file_objects) if file_objects else 0}." - ) - sys.exit(TECHNICAL_ERROR) - - if not all(isinstance(obj, list) for obj in file_objects): - print("::error::❌ Invalid payload format. Expected a list of lists.") - sys.exit(TECHNICAL_ERROR) - - changed_files = [path for obj in file_objects for path in obj] - - # Validate every file against every contributor - contributors = set(str(c).lower() for c in args.contributors) - print(f"👥 Validating contributors: {', '.join(contributors)}") - - for raw_file_path in changed_files: - file_path = posixpath.normpath(raw_file_path) - - # Find the most specific (longest) matching directory rule. - longest_match_dir = None - for directory in safe_rules.keys(): - if file_path.startswith(directory): - if longest_match_dir is None or len(directory) > len( - longest_match_dir - ): - longest_match_dir = directory - - # First, explicitly fail if the file isn't covered by ANY rule. - if not longest_match_dir: - print( - f"::error::❌ File '{file_path}' does not fall under any configured auto-approve directory." - ) - sys.exit(NOT_APPROVED) - - # Then, verify every contributor has access to that specific rule. - for user in contributors: - if user not in safe_rules[longest_match_dir]: - print( - f"::error::❌ Contributor @{user} not authorized for '{file_path}'." - ) - sys.exit(NOT_APPROVED) - - if args.check_config: - print("✅ Configuration is structurally valid") - else: - print("✅ Validation passed") - - sys.exit(SUCCESS) - - -if __name__ == "__main__": - main()