#!/usr/bin/env python3 """ Render a personalized Supabase Helm values file and pre-template manifests using the local prole.cfg. Defaults are tailored for knoe's CNPG cluster (knoe-db-rw Service) and unattended k8s ingress. """ from __future__ import annotations import argparse import base64 import configparser import hashlib import hmac import json import os import secrets import subprocess import sys import time import urllib.parse from pathlib import Path from typing import Any, Dict # Allow imports from the repo root REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) try: from knoe.config import _resolve_secret_value # type: ignore except Exception: # pragma: no cover - fallback for minimal environments def _resolve_secret_value(value: str | None) -> str: return value or "" def _read_cfg(path: Path) -> configparser.ConfigParser: parser = configparser.ConfigParser(interpolation=None) try: from knoe import prole_conf as prole_conf_mgr # type: ignore files = [p for p in prole_conf_mgr.layered_cfg_files(path) if p.exists()] if files: parser.read([str(p) for p in files]) else: parser.read(path) except Exception: parser.read(path) return parser def _cfg_get(cfg: configparser.ConfigParser, section: str, key: str, default: str = "") -> str: try: raw = cfg.get(section, key, fallback=default) except Exception: return default return _resolve_secret_value((raw or "").strip()) def _first(*values: str, default: str = "") -> str: for v in values: if v: return v return default def _is_placeholder(value: str) -> bool: v = (value or "").strip() return v.startswith("${") and v.endswith("}") def _as_bool(value: str | bool | None, default: bool = False) -> bool: if isinstance(value, bool): return value if value is None: return default normalized = str(value).strip().lower() if not normalized: return default if normalized in {"1", "true", "yes", "on", "y"}: return True if normalized in {"0", "false", "no", "off", "n"}: return False return default def _parse_hosts(raw_hosts: str, fallback: str = "db.0.knoe.dev") -> list[str]: entries: list[str] = [] seen: set[str] = set() for entry in (raw_hosts or "").split(","): token = (entry or "").strip() if not token or _is_placeholder(token): continue if "://" in token: parsed = urllib.parse.urlparse(token) host = (parsed.hostname or "").strip() else: host = token if not host or host in seen: continue seen.add(host) entries.append(host) if entries: return entries return [fallback] def _normalize_public_hosts(raw_hosts: str, fallback_host: str) -> tuple[list[str], str]: raw_entries: list[str] = [] for entry in (raw_hosts or "").split(","): token = (entry or "").strip() if token and not _is_placeholder(token): raw_entries.append(token) if not raw_entries: raw_entries = [fallback_host] host_entries = _parse_hosts(",".join(raw_entries), fallback=fallback_host) primary_host = host_entries[0] primary_raw = (raw_entries[0] or "").strip() if "://" in primary_raw: parsed = urllib.parse.urlparse(primary_raw) primary_host = (parsed.hostname or "").strip() or primary_host scheme = (parsed.scheme or "https").strip() netloc = (parsed.netloc or primary_host).strip() public_url = f"{scheme}://{netloc}" else: public_url = f"https://{primary_host}" return host_entries, public_url def _discover_k8s_service_namespace(service_name: str) -> str: try: raw = subprocess.check_output(["kubectl", "get", "svc", "-A", "-o", "json"], text=True) data = json.loads(raw) for item in data.get("items", []) or []: md = item.get("metadata", {}) or {} if md.get("name") == service_name and md.get("namespace"): return str(md.get("namespace")) except Exception: return "" return "" def _ensure_dir(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) return path def _load_or_create_secret(path: Path, length: int = 32) -> str: if path.exists(): return path.read_text().strip() val = secrets.token_hex(length) path.write_text(val) return val def _b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") def _jwt(payload: Dict[str, Any], secret: str) -> str: header = {"alg": "HS256", "typ": "JWT"} head = _b64url(json.dumps(header, separators=(",", ":")).encode()) body = _b64url(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{head}.{body}".encode() sig = hmac.new(secret.encode(), signing_input, hashlib.sha256).digest() return f"{head}.{body}.{_b64url(sig)}" def _build_overlay(cfg: configparser.ConfigParser, args: argparse.Namespace) -> tuple[dict, dict]: db_service = os.environ.get("KNOE_DB_SERVICE", "knoe-db-rw") db_ns = _first( os.environ.get("KNOE_DB_NAMESPACE", ""), os.environ.get("DATABASE_NAMESPACE", ""), _cfg_get(cfg, "Global", "DATABASE_NAMESPACE"), _cfg_get(cfg, "Global", "NAMESPACE"), os.environ.get("NAMESPACE", ""), default="", ) if _is_placeholder(db_ns): db_ns = "" if not db_ns: db_ns = _discover_k8s_service_namespace(db_service) or "" if not db_ns: db_ns = "default" supabase_ns = _first( _cfg_get(cfg, "Supabase", "NAMESPACE"), os.environ.get("SUPABASE_NAMESPACE", ""), "supabase", ) db_host = _first( os.environ.get("SUPABASE_DB_HOST", ""), os.environ.get("DB_HOST", ""), _cfg_get(cfg, "Supabase", "DB_HOST"), _cfg_get(cfg, "Global", "SUPABASE_DB_HOST"), default=f"{db_service}.{db_ns}.svc.cluster.local", ).strip() if _is_placeholder(db_host): db_host = f"{db_service}.{db_ns}.svc.cluster.local" db_port = _first( _cfg_get(cfg, "Global", "DB_HOST_PORT"), _cfg_get(cfg, "Inputs", "init_password.db_host_port"), "5432", ) db_name = _first( _cfg_get(cfg, "Database Creation", "DB_NAME"), _cfg_get(cfg, "Global", "DB_NAME"), "postgres", ) db_password = _first( os.environ.get("DB_PASSWORD", ""), # live secret override takes highest priority _cfg_get(cfg, "Inputs", "init_password.db_password"), _cfg_get(cfg, "Global", "DB_PASSWORD"), ) if not db_password: raise SystemExit("Database password is required (init_password.db_password or DB_PASSWORD).") gen_dir = _ensure_dir(Path(args.output_dir)) secrets_dir = _ensure_dir(gen_dir / "secrets") jwt_secret = _first(os.environ.get("SUPABASE_JWT_SECRET", "")) or _load_or_create_secret( secrets_dir / "jwt.secret", length=32 ) now = int(time.time()) exp = now + 10 * 365 * 24 * 3600 anon_payload = {"role": "anon", "iss": "knoe-supabase", "iat": now, "exp": exp} service_payload = {"role": "service_role", "iss": "knoe-supabase", "iat": now, "exp": exp} anon_key = _jwt(anon_payload, jwt_secret) service_key = _jwt(service_payload, jwt_secret) legacy_supabase_hostname_raw = _first( _cfg_get(cfg, "User", "supabase_hostname"), _cfg_get(cfg, "Global", "supabase_hostname"), _cfg_get(cfg, "User", "SUPABASE_HOSTNAME"), _cfg_get(cfg, "Global", "SUPABASE_HOSTNAME"), os.environ.get("SUPABASE_HOSTNAME", ""), os.environ.get("SUPABASE_HOST", ""), default="db.0.knoe.dev", ).strip() if _is_placeholder(legacy_supabase_hostname_raw): legacy_supabase_hostname_raw = "" if not legacy_supabase_hostname_raw: legacy_supabase_hostname_raw = "db.0.knoe.dev" studio_hostname_raw = _first( os.environ.get("SUPABASE_STUDIO_URL", ""), _cfg_get(cfg, "Inputs", "init_cluster.supabase_studio_url"), os.environ.get("SUPABASE_STUDIO_HOSTNAME", ""), _cfg_get(cfg, "User", "SUPABASE_STUDIO_HOSTNAME"), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_HOSTNAME"), legacy_supabase_hostname_raw, default="db.0.knoe.dev", ).strip() if _is_placeholder(studio_hostname_raw): studio_hostname_raw = "" if not studio_hostname_raw: studio_hostname_raw = legacy_supabase_hostname_raw api_hostname_raw = _first( os.environ.get("SUPABASE_API_URL", ""), _cfg_get(cfg, "Inputs", "init_cluster.supabase_api_url"), os.environ.get("SUPABASE_API_HOSTNAME", ""), _cfg_get(cfg, "User", "SUPABASE_API_HOSTNAME"), _cfg_get(cfg, "Global", "SUPABASE_API_HOSTNAME"), studio_hostname_raw, default="api.knoe.dev", ).strip() if _is_placeholder(api_hostname_raw): api_hostname_raw = "" if not api_hostname_raw: api_hostname_raw = studio_hostname_raw studio_enabled = _as_bool( _first( os.environ.get("SUPABASE_STUDIO_ENABLED", ""), _cfg_get(cfg, "Inputs", "init_cluster.supabase_studio_enabled"), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_ENABLED"), default="true", ), default=True, ) auth_enabled = _as_bool( _first( os.environ.get("SUPABASE_AUTH_ENABLED", ""), _cfg_get(cfg, "Inputs", "init_cluster.supabase_auth_enabled"), _cfg_get(cfg, "Global", "SUPABASE_AUTH_ENABLED"), default="true", ), default=True, ) realtime_enabled = _as_bool( _first( os.environ.get("SUPABASE_REALTIME_ENABLED", ""), _cfg_get(cfg, "Inputs", "init_cluster.supabase_realtime_enabled"), _cfg_get(cfg, "Global", "SUPABASE_REALTIME_ENABLED"), default="true", ), default=True, ) meta_enabled = _as_bool( _first( os.environ.get("SUPABASE_META_ENABLED", ""), _cfg_get(cfg, "Inputs", "init_cluster.supabase_meta_enabled"), _cfg_get(cfg, "Global", "SUPABASE_META_ENABLED"), default="true", ), default=True, ) analytics_enabled = _as_bool( _first( os.environ.get("SUPABASE_ANALYTICS_ENABLED", ""), _cfg_get(cfg, "Inputs", "init_cluster.supabase_analytics_enabled"), _cfg_get(cfg, "Global", "SUPABASE_ANALYTICS_ENABLED"), default="true", ), default=True, ) studio_host_entries, studio_public_url = _normalize_public_hosts( studio_hostname_raw, fallback_host="db.0.knoe.dev", ) api_host_entries, api_public_url = _normalize_public_hosts( api_hostname_raw, fallback_host=studio_host_entries[0], ) mode = _first( os.environ.get("PROLE_MODE", ""), _cfg_get(cfg, "Global", "DEPLOYMENT_MODE"), _cfg_get(cfg, "globals", "prole.mode"), default="", ).strip().lower() default_ingress_class = "gce" if mode == "k8s" else "traefik" api_ingress_class = _first( os.environ.get("SUPABASE_API_INGRESS_CLASS", ""), os.environ.get("SUPABASE_INGRESS_CLASS", ""), _cfg_get(cfg, "User", "SUPABASE_API_INGRESS_CLASS"), _cfg_get(cfg, "Global", "SUPABASE_API_INGRESS_CLASS"), _cfg_get(cfg, "User", "SUPABASE_INGRESS_CLASS"), _cfg_get(cfg, "Global", "SUPABASE_INGRESS_CLASS"), default=default_ingress_class, ).strip() if not api_ingress_class or _is_placeholder(api_ingress_class): api_ingress_class = default_ingress_class studio_ingress_class = _first( os.environ.get("SUPABASE_STUDIO_INGRESS_CLASS", ""), _cfg_get(cfg, "User", "SUPABASE_STUDIO_INGRESS_CLASS"), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_INGRESS_CLASS"), api_ingress_class, ).strip() if not studio_ingress_class or _is_placeholder(studio_ingress_class): studio_ingress_class = api_ingress_class # Additional origins/redirect URLs allowed by GoTrue (comma-separated hostnames or URLs). # Needed when a service at a different hostname (e.g. svc.knoe.dev) uses Supabase auth. _extra_redirect_raw = _first( os.environ.get("SUPABASE_ADDITIONAL_REDIRECT_URLS", ""), _cfg_get(cfg, "Global", "SUPABASE_ADDITIONAL_REDIRECT_URLS"), _cfg_get(cfg, "Supabase", "ADDITIONAL_REDIRECT_URLS"), default="", ).strip() _allow_list_entries: list[str] = [] for _base_url in (studio_public_url, api_public_url): if not _base_url: continue _candidate = _base_url.rstrip("/") + "/**" if _candidate not in _allow_list_entries: _allow_list_entries.append(_candidate) for _entry in _extra_redirect_raw.split(","): _entry = _entry.strip() if not _entry or _is_placeholder(_entry): continue if "://" not in _entry: _entry = f"https://{_entry}" if not _entry.endswith("/**"): _entry = _entry.rstrip("/") + "/**" if _entry not in _allow_list_entries: _allow_list_entries.append(_entry) _gotrue_uri_allow_list = ",".join(_allow_list_entries) # Placement: pin Supabase pods to the configured primary node (default: pi.knoe.dev) supabase_primary_node = _first( os.environ.get("SUPABASE_PRIMARY_NODE", ""), os.environ.get("SUPABASE_NODE_SELECTOR", ""), os.environ.get("SUPABASE_PV_NODE", ""), _cfg_get(cfg, "Global", "SUPABASE_PRIMARY_NODE"), _cfg_get(cfg, "Global", "SUPABASE_NODE_SELECTOR"), _cfg_get(cfg, "Global", "SUPABASE_PV_NODE"), _cfg_get(cfg, "Supabase", "PRIMARY_NODE"), default="gandalf.knoe.dev", ).strip() if _is_placeholder(supabase_primary_node): supabase_primary_node = "" supabase_node_selector = ( {"kubernetes.io/hostname": supabase_primary_node} if supabase_primary_node else {} ) supabase_storage_class = _first( os.environ.get("SUPABASE_STORAGE_CLASS", ""), _cfg_get(cfg, "Global", "SUPABASE_STORAGE_CLASS"), _cfg_get(cfg, "Supabase", "STORAGE_CLASS"), default="synology-iscsi", ).strip() if _is_placeholder(supabase_storage_class): supabase_storage_class = "merlin-local-iscsi-d002" storage_backend = _first( os.environ.get("SUPABASE_STORAGE_BACKEND", ""), os.environ.get("STORAGE_BACKEND", ""), _cfg_get(cfg, "Supabase", "STORAGE_BACKEND"), _cfg_get(cfg, "Global", "STORAGE_BACKEND"), default="s3", ).strip().lower() if _is_placeholder(storage_backend): storage_backend = "" global_s3_endpoint = _first( os.environ.get("SUPABASE_GLOBAL_S3_ENDPOINT", ""), os.environ.get("GLOBAL_S3_ENDPOINT", ""), _cfg_get(cfg, "Supabase", "GLOBAL_S3_ENDPOINT"), _cfg_get(cfg, "Global", "GLOBAL_S3_ENDPOINT"), default="http://garage.knoe-system.svc.cluster.local:3900", ).strip() if _is_placeholder(global_s3_endpoint): global_s3_endpoint = "" garage_s3_key_id = _first( os.environ.get("GARAGE_S3_KEY_ID", ""), _cfg_get(cfg, "Supabase", "GARAGE_S3_KEY_ID"), _cfg_get(cfg, "Global", "GARAGE_S3_KEY_ID"), default="", ).strip() garage_s3_access_key = _first( os.environ.get("GARAGE_S3_ACCESS_KEY", ""), _cfg_get(cfg, "Supabase", "GARAGE_S3_ACCESS_KEY"), _cfg_get(cfg, "Global", "GARAGE_S3_ACCESS_KEY"), default="", ).strip() if _is_placeholder(garage_s3_key_id): garage_s3_key_id = "" if _is_placeholder(garage_s3_access_key): garage_s3_access_key = "" use_garage_s3 = storage_backend == "s3" and "garage" in global_s3_endpoint.lower() if use_garage_s3 and (not garage_s3_key_id or not garage_s3_access_key): raise SystemExit( "Garage S3 credentials are required for Supabase storage. " "Set GARAGE_S3_KEY_ID and GARAGE_S3_ACCESS_KEY in [Supabase] or [Global], " "or via environment variables." ) overlay: dict[str, Any] = { "nameOverride": "supabase", "fullnameOverride": "supabase", "deployment": { "db": {"enabled": False}, "functions": {"enabled": True, "fullnameOverride": "supabase-functions"}, "vector": {"enabled": True, "fullnameOverride": "supabase-vector"}, "kong": {"enabled": True, "fullnameOverride": "supabase-kong"}, "storage": {"enabled": True, "fullnameOverride": "supabase-storage"}, "minio": {"enabled": not use_garage_s3, "fullnameOverride": "supabase-minio"}, "imgproxy": {"enabled": True, "fullnameOverride": "supabase-imgproxy"}, # Explicit fullnameOverride per component strips the chart name # from pod names (avoids 'supabase-knoe-supabase-'). "analytics": {"enabled": analytics_enabled, "fullnameOverride": "supabase-analytics"}, "auth": {"enabled": auth_enabled, "fullnameOverride": "supabase-auth"}, "meta": {"enabled": meta_enabled, "fullnameOverride": "supabase-meta"}, "realtime": {"enabled": realtime_enabled, "fullnameOverride": "supabase-realtime"}, "rest": {"enabled": True, "fullnameOverride": "supabase-rest"}, "studio": {"enabled": studio_enabled, "fullnameOverride": "supabase-studio"}, }, "externalDatabase": { "enabled": True, "host": db_host, "port": int(str(db_port)), "database": db_name, "ssl": "disable", "createAliasService": True, "aliasServiceName": "db", }, "environment": { "auth": { "DB_HOST": db_host, "DB_PORT": str(db_port), "DB_SSL": "disable", "API_EXTERNAL_URL": api_public_url, "GOTRUE_SITE_URL": studio_public_url, **( {"GOTRUE_URI_ALLOW_LIST": _gotrue_uri_allow_list} if _gotrue_uri_allow_list else {} ), }, "analytics": {"DB_HOST": db_host, "DB_PORT": str(db_port)}, "meta": {"DB_HOST": db_host, "DB_PORT": str(db_port)}, "studio": {"SUPABASE_PUBLIC_URL": studio_public_url}, }, "secret": { "db": {"password": db_password, "database": db_name}, "jwt": {"secret": jwt_secret, "anonKey": anon_key, "serviceKey": service_key}, "s3": {"keyId": garage_s3_key_id, "accessKey": garage_s3_access_key}, }, "ingress": { "enabled": True, "className": api_ingress_class, "hosts": [{"host": host, "paths": [{"path": "/", "pathType": "Prefix"}]} for host in api_host_entries], }, "studioIngress": { "enabled": True, "className": studio_ingress_class, "hosts": [{"host": host, "paths": [{"path": "/", "pathType": "Prefix"}]} for host in studio_host_entries], }, } if supabase_storage_class: # Validate: must be a synology/merlin mount — refuse pi/local SD card classes if not any(supabase_storage_class.startswith(p) for p in ("synology", "merlin-local-iscsi", "myrddin-local-iscsi")): raise SystemExit( f"SUPABASE_STORAGE_CLASS '{supabase_storage_class}' is not a synology mount. " "Supabase must run on iSCSI/NFS storage (merlin-local-iscsi-d002 or synology-iscsi). " "Refusing deploy to prevent SD-card crash." ) # Write to persistence.*.storageClassName — the path the Helm chart actually uses persistence = overlay.setdefault("persistence", {}) for pvc_key in ("functions", "snippets", "deno", "imgproxy", "minio", "storage"): persistence.setdefault(pvc_key, {})["storageClassName"] = supabase_storage_class if mode == "k3s" and supabase_node_selector: deployment = overlay.setdefault("deployment", {}) for component in ( "analytics", "auth", "functions", "imgproxy", "kong", "meta", "minio", "realtime", "rest", "storage", "studio", "vector", ): component_cfg = deployment.setdefault(component, {}) component_cfg.setdefault("nodeSelector", {}) component_cfg["nodeSelector"].update(supabase_node_selector) meta = { "supabase_namespace": supabase_ns, "db_namespace": db_ns, "db_host": db_host, } return overlay, meta def render(args: argparse.Namespace) -> None: cfg_path = Path(args.config) if cfg_path.is_dir(): cfg_path = cfg_path / "prole.cfg" if not cfg_path.exists(): raise SystemExit(f"Config not found: {cfg_path}") cfg = _read_cfg(cfg_path) overlay, meta = _build_overlay(cfg, args) gen_dir = _ensure_dir(Path(args.output_dir)) values_path = gen_dir / "values.generated.json" values_path.write_text(json.dumps(overlay, indent=2)) # Write Namespace manifest for Supabase k8s_dir = _ensure_dir(Path(args.manifests_dir)) namespace_manifest = { "apiVersion": "v1", "kind": "Namespace", "metadata": {"name": meta["supabase_namespace"]}, } (k8s_dir / "namespace.yaml").write_text(json.dumps(namespace_manifest)) # Render chart to a single manifest file (Helm accepts JSON values) rendered_path = k8s_dir / "supabase-helm.yaml" cmd = [ "helm", "template", "supabase", str(REPO_ROOT / "supabase" / "helm" / "knoe-supabase"), "-n", meta["supabase_namespace"], "-f", str(values_path), ] proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: sys.stderr.write(proc.stderr) raise SystemExit(f"Helm template failed (code {proc.returncode})") rendered_path.write_text(proc.stdout) summary = { "values": str(values_path), "manifests": str(rendered_path), "db_host": meta["db_host"], "supabase_namespace": meta["supabase_namespace"], } (gen_dir / "manifest-summary.json").write_text(json.dumps(summary, indent=2)) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Render Supabase Helm chart from prole.cfg") parser.add_argument( "--config", "-c", default=os.environ.get("PROLE_CONF", str(REPO_ROOT / "conf")), help="Path to prole.cfg or its directory", ) parser.add_argument( "--output-dir", default=str(REPO_ROOT / "supabase" / "helm" / "generated"), help="Directory to write generated values and metadata", ) parser.add_argument( "--manifests-dir", default=str(REPO_ROOT / "supabase" / "k8s"), help="Directory to write rendered Kubernetes manifests", ) args = parser.parse_args() render(args)