#!/usr/bin/env python3 """ Render a personalized Supabase Helm values file and pre-template manifests using the local knoe.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 copy import deepcopy from pathlib import Path from typing import Any, Dict import yaml # 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 knoe_conf as knoe_conf_mgr # type: ignore files = [p for p in knoe_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.knoe.org") -> 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 _extract_k8s_docs(rendered_manifest: str) -> list[dict[str, Any]]: docs: list[dict[str, Any]] = [] for parsed in yaml.safe_load_all(rendered_manifest): if isinstance(parsed, dict) and parsed.get("kind") and parsed.get("apiVersion"): docs.append(parsed) return docs def _doc_key(doc: dict[str, Any]) -> tuple[str, str, str, str]: api_version = str(doc.get("apiVersion") or "") kind = str(doc.get("kind") or "") meta = doc.get("metadata") or {} if not isinstance(meta, dict): meta = {} namespace = str(meta.get("namespace") or "") name = str(meta.get("name") or "") return api_version, kind, namespace, name def _dump_manifest_docs(path: Path, docs: list[dict[str, Any]]) -> None: if not docs: path.write_text("") return payload = "\n---\n".join(json.dumps(doc) for doc in docs) path.write_text(f"{payload}\n") def _is_valid_frontdoor_doc(doc: dict[str, Any]) -> bool: kind = str(doc.get("kind") or "") spec = doc.get("spec") if kind not in {"Deployment", "Service", "Ingress"}: return True if not isinstance(spec, dict): return False if kind == "Service": ports = spec.get("ports") return isinstance(ports, list) and len(ports) > 0 if kind == "Deployment": selector = spec.get("selector") template = spec.get("template") if not isinstance(selector, dict) or not isinstance(template, dict): return False match_labels = selector.get("matchLabels") template_meta = template.get("metadata") template_spec = template.get("spec") if not isinstance(match_labels, dict) or not match_labels: return False if not isinstance(template_meta, dict): return False labels = template_meta.get("labels") if not isinstance(labels, dict) or not labels: return False if not isinstance(template_spec, dict): return False containers = template_spec.get("containers") return isinstance(containers, list) and len(containers) > 0 rules = spec.get("rules") default_backend = spec.get("defaultBackend") has_rules = isinstance(rules, list) and len(rules) > 0 has_default_backend = isinstance(default_backend, dict) and len(default_backend) > 0 return has_rules or has_default_backend def _split_frontdoor_docs( rendered_manifest: str, namespace: str = "", frontdoor_release: str = "supabase-frontdoor-db", skip_split: bool = False, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: docs = _extract_k8s_docs(rendered_manifest) if skip_split: return docs, [] studio_kinds = {"Deployment", "Service", "Ingress"} kong_kinds = {"Deployment", "Service", "Ingress"} publish_kinds = {"Service", "Ingress"} studio_names = {"supabase-studio", "supabase-studio-config"} kong_names = { "supabase-kong", "supabase-kong-declarative-config", "supabase-kong-declarative-config-jwt", } functions_names = {"supabase-functions", "supabase-functions-config"} frontdoor_docs: list[dict[str, Any]] = [] frontdoor_indexes: set[int] = set() for idx, doc in enumerate(docs): kind = str(doc.get("kind") or "") meta = doc.get("metadata") or {} name = "" if isinstance(meta, dict): name = str(meta.get("name") or "") is_studio = name in studio_names and kind in studio_kinds is_kong = name in kong_names and kind in kong_kinds is_functions = name in functions_names and kind in studio_kinds if is_studio or is_kong or is_functions: if is_studio or is_kong: frontdoor_indexes.add(idx) if kind not in publish_kinds: continue if not _is_valid_frontdoor_doc(doc): continue doc_for_frontdoor = deepcopy(doc) meta_fd = doc_for_frontdoor.get("metadata") or {} if frontdoor_release: labels = meta_fd.get("labels") if isinstance(meta_fd, dict) else None if not isinstance(labels, dict): labels = {} if isinstance(meta_fd, dict): meta_fd["labels"] = labels labels["app.kubernetes.io/instance"] = frontdoor_release if kind == "Service": spec = doc_for_frontdoor.get("spec") if isinstance(spec, dict): selector = spec.get("selector") if isinstance(selector, dict): selector["app.kubernetes.io/instance"] = frontdoor_release if namespace: if not isinstance(meta_fd, dict): meta_fd = {} doc_for_frontdoor["metadata"] = meta_fd if not str(meta_fd.get("namespace") or "").strip(): meta_fd["namespace"] = namespace frontdoor_docs.append(doc_for_frontdoor) app_docs = [doc for idx, doc in enumerate(docs) if idx not in frontdoor_indexes] return app_docs, frontdoor_docs def _normalize_hostname(raw_value: str, fallback: str) -> str: token = (raw_value or "").strip() if not token or _is_placeholder(token): return fallback if "://" in token: parsed = urllib.parse.urlparse(token) return (parsed.hostname or "").strip() or fallback return token.split("/", 1)[0].strip() or fallback def _derive_api_hostname_from_studio(studio_hostname_raw: str, auth_hostname: str, mode: str) -> str: if mode != "k8s": return studio_hostname_raw normalized_studio = _normalize_hostname(studio_hostname_raw, fallback="db.knoe.org") if mode == "k8s": if normalized_studio.startswith("db."): return f"api.{normalized_studio[3:]}" return auth_hostname return studio_hostname_raw def _validate_unique_ingress_claims(claims: dict[str, list[str]]) -> None: claimed: dict[tuple[str, str], str] = {} for owner, hosts in claims.items(): for host in hosts: normalized_host = (host or "").strip().lower() if not normalized_host: continue key = (normalized_host, "/") previous_owner = claimed.get(key) if previous_owner and previous_owner != owner: raise SystemExit( f"Duplicate ingress host/path claim detected for '{normalized_host}/': " f"{previous_owner} and {owner}." ) claimed[key] = owner def _validate_k8s_ingress_class( *, mode: str, ingress_class: str, ingress_owner: str, allow_traefik_public_ingress: bool ) -> None: if mode != "k8s": return normalized = (ingress_class or "").strip().lower() if not normalized: raise SystemExit(f"Ingress class for {ingress_owner} cannot be empty in k8s mode.") if normalized in {"traefik", "traefik-external", "traefik-internal"} and not allow_traefik_public_ingress: raise SystemExit( f"Ingress class '{ingress_class}' for {ingress_owner} is incompatible with k8s mode " "unless explicit Traefik public ingress provisioning is enabled." ) 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.knoe.org", ).strip() if _is_placeholder(legacy_supabase_hostname_raw): legacy_supabase_hostname_raw = "" if not legacy_supabase_hostname_raw: legacy_supabase_hostname_raw = "db.knoe.org" mode = _first( os.environ.get("KNOE_MODE", ""), _cfg_get(cfg, "Global", "DEPLOYMENT_MODE"), _cfg_get(cfg, "globals", "knoe.mode"), default="", ).strip().lower() default_auth_hostname = "api.knoe.dev" if mode == "k8s" else "api.knoe.org" auth_hostname = _normalize_hostname( _first( os.environ.get("AUTH_HOSTNAME", ""), os.environ.get("FRONTDOOR_HOST", ""), _cfg_get(cfg, "Global", "AUTH_HOSTNAME"), _cfg_get(cfg, "Global", "FRONTDOOR_HOST"), default=default_auth_hostname, ), fallback=default_auth_hostname, ) studio_hostname_raw = _first( os.environ.get("SUPABASE_STUDIO_URL", ""), os.environ.get("SUPABASE_STUDIO_HOSTNAME", ""), _cfg_get(cfg, "User", "SUPABASE_STUDIO_HOSTNAME"), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_HOSTNAME"), _cfg_get(cfg, "Inputs", "init_cluster.supabase_studio_url"), legacy_supabase_hostname_raw, default="db.knoe.org", ).strip() if _is_placeholder(studio_hostname_raw): studio_hostname_raw = "" if not studio_hostname_raw: studio_hostname_raw = legacy_supabase_hostname_raw api_host_fallback = _derive_api_hostname_from_studio(studio_hostname_raw, auth_hostname, mode) api_hostname_raw = _first( os.environ.get("SUPABASE_API_URL", ""), os.environ.get("SUPABASE_API_HOSTNAME", ""), _cfg_get(cfg, "User", "SUPABASE_API_HOSTNAME"), _cfg_get(cfg, "Global", "SUPABASE_API_HOSTNAME"), _cfg_get(cfg, "Inputs", "init_cluster.supabase_api_url"), api_host_fallback, default="supabase.knoe.org", ).strip() if _is_placeholder(api_hostname_raw): api_hostname_raw = "" if not api_hostname_raw: api_hostname_raw = api_host_fallback 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.knoe.org", ) api_host_entries, api_public_url = _normalize_public_hosts( api_hostname_raw, fallback_host=studio_host_entries[0], ) 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 supabase_api_managed_cert_name = _first( os.environ.get("SUPABASE_API_MANAGED_CERT_NAME", ""), os.environ.get("SUPABASE_MANAGED_CERT_NAME", ""), _cfg_get(cfg, "Global", "SUPABASE_API_MANAGED_CERT_NAME"), _cfg_get(cfg, "Global", "SUPABASE_MANAGED_CERT_NAME"), default="supabase-api-managed-cert", ).strip() if not supabase_api_managed_cert_name or _is_placeholder(supabase_api_managed_cert_name): supabase_api_managed_cert_name = "supabase-api-managed-cert" supabase_studio_managed_cert_name = _first( os.environ.get("SUPABASE_STUDIO_MANAGED_CERT_NAME", ""), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_MANAGED_CERT_NAME"), _cfg_get(cfg, "Global", "SUPABASE_MANAGED_CERT_NAME"), default="supabase-studio-managed-cert", ).strip() if not supabase_studio_managed_cert_name or _is_placeholder(supabase_studio_managed_cert_name): supabase_studio_managed_cert_name = "supabase-studio-managed-cert" supabase_api_frontend_config_name = _first( os.environ.get("SUPABASE_API_FRONTEND_CONFIG_NAME", ""), os.environ.get("SUPABASE_FRONTEND_CONFIG_NAME", ""), _cfg_get(cfg, "Global", "SUPABASE_API_FRONTEND_CONFIG_NAME"), _cfg_get(cfg, "Global", "SUPABASE_FRONTEND_CONFIG_NAME"), default="supabase-api-frontend-config", ).strip() if not supabase_api_frontend_config_name or _is_placeholder(supabase_api_frontend_config_name): supabase_api_frontend_config_name = "supabase-api-frontend-config" supabase_studio_frontend_config_name = _first( os.environ.get("SUPABASE_STUDIO_FRONTEND_CONFIG_NAME", ""), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_FRONTEND_CONFIG_NAME"), _cfg_get(cfg, "Global", "SUPABASE_FRONTEND_CONFIG_NAME"), default="supabase-studio-frontend-config", ).strip() if not supabase_studio_frontend_config_name or _is_placeholder(supabase_studio_frontend_config_name): supabase_studio_frontend_config_name = "supabase-studio-frontend-config" supabase_api_pre_shared_cert = _first( os.environ.get("SUPABASE_API_PRE_SHARED_CERT", ""), os.environ.get("SUPABASE_PRE_SHARED_CERT", ""), _cfg_get(cfg, "Global", "SUPABASE_API_PRE_SHARED_CERT"), _cfg_get(cfg, "Global", "SUPABASE_PRE_SHARED_CERT"), default="", ).strip() if _is_placeholder(supabase_api_pre_shared_cert): supabase_api_pre_shared_cert = "" supabase_studio_pre_shared_cert = _first( os.environ.get("SUPABASE_STUDIO_PRE_SHARED_CERT", ""), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_PRE_SHARED_CERT"), _cfg_get(cfg, "Global", "SUPABASE_PRE_SHARED_CERT"), default="", ).strip() if _is_placeholder(supabase_studio_pre_shared_cert): supabase_studio_pre_shared_cert = "" if mode == "k8s" and (supabase_api_pre_shared_cert or supabase_studio_pre_shared_cert): sys.stderr.write( "WARN: Ignoring SUPABASE_*_PRE_SHARED_CERT in k8s mode; " "ManagedCertificate + FrontendConfig is the single TLS source of truth.\n" ) # Optional: pin each GCE L7 ingress to a pre-reserved GLOBAL external # static IP (gcloud compute addresses create --global). Prevents IP churn # on ingress delete/recreate. Blank = GCE assigns ephemerally. supabase_api_global_static_ip_name = _first( os.environ.get("SUPABASE_API_GLOBAL_STATIC_IP_NAME", ""), _cfg_get(cfg, "Global", "SUPABASE_API_GLOBAL_STATIC_IP_NAME"), default="", ).strip() if _is_placeholder(supabase_api_global_static_ip_name): supabase_api_global_static_ip_name = "" supabase_studio_global_static_ip_name = _first( os.environ.get("SUPABASE_STUDIO_GLOBAL_STATIC_IP_NAME", ""), _cfg_get(cfg, "Global", "SUPABASE_STUDIO_GLOBAL_STATIC_IP_NAME"), default="", ).strip() if _is_placeholder(supabase_studio_global_static_ip_name): supabase_studio_global_static_ip_name = "" allow_traefik_public_ingress = _as_bool( _first( os.environ.get("SUPABASE_ALLOW_TRAEFIK_INGRESS", ""), os.environ.get("ALLOW_TRAEFIK_PUBLIC_INGRESS", ""), _cfg_get(cfg, "Global", "SUPABASE_ALLOW_TRAEFIK_INGRESS"), _cfg_get(cfg, "Global", "ALLOW_TRAEFIK_PUBLIC_INGRESS"), default="false", ), default=False, ) _validate_k8s_ingress_class( mode=mode, ingress_class=api_ingress_class, ingress_owner="supabase-kong", allow_traefik_public_ingress=allow_traefik_public_ingress, ) _validate_k8s_ingress_class( mode=mode, ingress_class=studio_ingress_class, ingress_owner="supabase-studio", allow_traefik_public_ingress=allow_traefik_public_ingress, ) if mode == "k8s": _validate_unique_ingress_claims( { "supabase-kong": api_host_entries, "supabase-studio": studio_host_entries, } ) app_cluster_kubecontext = _first( os.environ.get("APP_CLUSTER_KUBECONTEXT", ""), _cfg_get(cfg, "Inputs", "init_cluster.app_cluster_kubecontext"), _cfg_get(cfg, "Global", "APP_CLUSTER_KUBECONTEXT"), default="", ).strip() db_cluster_kubecontext = _first( os.environ.get("DB_CLUSTER_KUBECONTEXT", ""), _cfg_get(cfg, "Inputs", "init_cluster.db_cluster_kubecontext"), _cfg_get(cfg, "Global", "DB_CLUSTER_KUBECONTEXT"), default="", ).strip() active_kubecontext = _first( os.environ.get("KUBECONTEXT", ""), os.environ.get("KUBE_CONTEXT_NAME", ""), os.environ.get("KUBECTL_CONTEXT", ""), default="", ).strip() public_hosts = sorted(set(api_host_entries + studio_host_entries)) if mode == "k8s" and app_cluster_kubecontext and active_kubecontext: allowed_contexts = {app_cluster_kubecontext} if db_cluster_kubecontext: allowed_contexts.add(db_cluster_kubecontext) if active_kubecontext not in allowed_contexts: raise SystemExit( "Refusing to render Supabase public ingress outside allowed split-cluster contexts " f"{sorted(allowed_contexts)}. Active context: '{active_kubecontext}'." ) frontdoor_auth_enabled = _as_bool( _first( os.environ.get("FRONTDOOR_AUTH_ENABLED", ""), os.environ.get("AUTHORITY_ENABLED", ""), _cfg_get(cfg, "Global", "FRONTDOOR_AUTH_ENABLED"), _cfg_get(cfg, "Global", "AUTHORITY_ENABLED"), default="true", ), default=True, ) auth_verify_path = _first( os.environ.get("AUTH_VERIFY_PATH", ""), _cfg_get(cfg, "Global", "AUTH_VERIFY_PATH"), default="/auth/verify", ).strip() if not auth_verify_path.startswith("/"): auth_verify_path = f"/{auth_verify_path}" auth_login_path = _first( os.environ.get("AUTH_LOGIN_PATH", ""), _cfg_get(cfg, "Global", "AUTH_LOGIN_PATH"), default="/auth/login", ).strip() if not auth_login_path.startswith("/"): auth_login_path = f"/{auth_login_path}" auth_response_headers = _first( os.environ.get("AUTH_RESPONSE_HEADERS", ""), _cfg_get(cfg, "Global", "AUTH_RESPONSE_HEADERS"), default="X-Knoe-User,X-Knoe-Email,X-Knoe-Groups", ).strip() auth_verify_url = _first( os.environ.get("AUTH_VERIFY_URL", ""), _cfg_get(cfg, "Global", "AUTH_VERIFY_URL"), default=f"https://{auth_hostname}{auth_verify_path}", ).strip() auth_signin_url = _first( os.environ.get("AUTH_SIGNIN_URL", ""), _cfg_get(cfg, "Global", "AUTH_SIGNIN_URL"), default=f"https://{auth_hostname}{auth_login_path}?next=$scheme://$host$escaped_request_uri", ).strip() # 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 {} ) if mode == "k8s": default_sc = "supabase-standard" elif mode == "k3d": default_sc = "local-path" else: default_sc = "synology-iscsi" supabase_storage_class = _first( os.environ.get("SUPABASE_STORAGE_CLASS", ""), _cfg_get(cfg, "Global", "SUPABASE_STORAGE_CLASS"), _cfg_get(cfg, "Supabase", "STORAGE_CLASS"), default=default_sc, ).strip() if not supabase_storage_class or _is_placeholder(supabase_storage_class): if mode == "k8s": raise SystemExit("SUPABASE_STORAGE_CLASS is required in k8s mode.") supabase_storage_class = default_sc 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 = "" app_cluster_kubecontext = _first( os.environ.get("APP_CLUSTER_KUBECONTEXT", ""), _cfg_get(cfg, "Global", "APP_CLUSTER_KUBECONTEXT"), _cfg_get(cfg, "Global", "init_cluster.app_cluster_kubecontext"), _cfg_get(cfg, "Global", "init_cluster_app_cluster_kubecontext"), default="", ).strip() db_cluster_kubecontext = _first( os.environ.get("DB_CLUSTER_KUBECONTEXT", ""), _cfg_get(cfg, "Global", "DB_CLUSTER_KUBECONTEXT"), _cfg_get(cfg, "Global", "init_cluster.db_cluster_kubecontext"), _cfg_get(cfg, "Global", "init_cluster_db_cluster_kubecontext"), default="", ).strip() split_cluster_kubecontexts = ( bool(app_cluster_kubecontext) and bool(db_cluster_kubecontext) and app_cluster_kubecontext != db_cluster_kubecontext ) global_s3_endpoint = _first( os.environ.get("SUPABASE_GLOBAL_S3_ENDPOINT", ""), os.environ.get("GLOBAL_S3_ENDPOINT", ""), os.environ.get("GARAGE_S3_ENDPOINT", ""), os.environ.get("GARAGE_PRIVATE_S3_ENDPOINT", ""), _cfg_get(cfg, "Supabase", "GLOBAL_S3_ENDPOINT"), _cfg_get(cfg, "Global", "GLOBAL_S3_ENDPOINT"), _cfg_get(cfg, "Supabase", "GARAGE_S3_ENDPOINT"), _cfg_get(cfg, "Global", "GARAGE_S3_ENDPOINT"), _cfg_get(cfg, "Global", "GARAGE_PRIVATE_S3_ENDPOINT"), default="http://garage.knoe-system.svc.cluster.local:3900", ).strip() if _is_placeholder(global_s3_endpoint): global_s3_endpoint = "" if storage_backend == "s3" and split_cluster_kubecontexts and not global_s3_endpoint: raise SystemExit( "Supabase S3 storage in split APP/DB clusters requires an explicit private Garage endpoint. " "Set SUPABASE_GLOBAL_S3_ENDPOINT/GLOBAL_S3_ENDPOINT (or GARAGE_S3_ENDPOINT)." ) if ( storage_backend == "s3" and split_cluster_kubecontexts and ".svc.cluster.local" in global_s3_endpoint.lower() ): raise SystemExit( "Supabase S3 endpoint must be a private cross-cluster endpoint in split APP/DB clusters; " "cluster-local service DNS is not allowed." ) 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." ) rules_public: list[dict[str, Any]] = [] if mode == "k8s": # Consolidated APP-cluster GCE ingress for host in api_host_entries: rules_public.append({ "host": host, "serviceName": "supabase-kong", "servicePort": 8000, }) for host in studio_host_entries: rules_public.append({ "host": host, "serviceName": "supabase-studio", "servicePort": 3000, }) overlay: dict[str, Any] = { "nameOverride": "supabase", "fullnameOverride": "supabase", "storageClass": { "enabled": mode == "k8s", "name": supabase_storage_class or "supabase-standard", "provisioner": "pd.csi.storage.gke.io", "type": "pd-standard", "reclaimPolicy": "Retain", "volumeBindingMode": "WaitForFirstConsumer", }, "publicIngress": { "enabled": False, "rules": rules_public, "annotations": { "kubernetes.io/ingress.class": api_ingress_class, } if mode == "k8s" else {}, }, "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", "podSecurityContext": { "runAsUser": 65532, "runAsGroup": 65532, "fsGroup": 65532, "fsGroupChangePolicy": "OnRootMismatch" }, "securityContext": { "runAsUser": 65532, "runAsGroup": 65532, "allowPrivilegeEscalation": False, "readOnlyRootFilesystem": True, "runAsNonRoot": True }, "resources": { "requests": {"cpu": "100m", "memory": "256Mi"}, "limits": {"cpu": "500m", "memory": "512Mi"} } }, "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": mode != "k3d", # In k8s/GKE mode the chart-rendered supabase-kong Ingress kept # getting deleted from the cluster minutes after install (helm # thought it owned it, something on the cluster kept reaping it). # We sidestep by emitting a standalone public-ingress-kong.yaml # below and telling the chart NOT to render its own ingress: # kong/ingress.yaml has a top-level `if not externallyManaged` guard. "externallyManaged": mode == "k8s", "className": "" if mode == "k8s" else api_ingress_class, "hosts": [{"host": host, "paths": [{"path": "/", "pathType": "Prefix"}]} for host in api_host_entries], "annotations": { **({"kubernetes.io/ingress.class": api_ingress_class} if mode == "k8s" else {}), **({ "networking.gke.io/managed-certificates": supabase_api_managed_cert_name, "networking.gke.io/v1beta1.FrontendConfig": supabase_api_frontend_config_name, } if mode == "k8s" else {}), **({ "kubernetes.io/ingress.global-static-ip-name": supabase_api_global_static_ip_name, } if mode == "k8s" and supabase_api_global_static_ip_name else {}), } }, "studioIngress": { # In k8s/GKE mode the separate studio ingress (db.0.knoe.dev -> # supabase-studio:3000) kept getting silently reaped from the # cluster after helm install -- same ghosting pattern we hit with # the kong ingress. We already worked around kong by emitting a # standalone manifest. For studio we go further: retire the # separate ingress entirely and merge db.0.knoe.dev onto the # supabase-kong ingress. Kong's declarative config already routes # `/` to supabase-studio (the `dashboard` service block in # templates/kong/config.yaml), so db.0.knoe.dev traffic lands on # Studio exactly the same way it did before, just by going # through Kong first. One ingress, one static IP, one BackendConfig # to maintain -- and no ghosting, because the standalone manifest # is the single source of truth. # # Chart's studio/ingress.yaml is gated on `.Values.studioIngress.enabled`, # so `enabled: false` makes the chart no-op it cleanly. "enabled": mode not in ("k8s", "k3d"), "className": "" if mode == "k8s" else studio_ingress_class, # Hosts are still tracked here so the standalone kong-ingress # generator below can merge them in as additional rules on the # shared supabase-kong ingress. We also still emit # supabase-studio-managed-cert / -frontend-config in # public-ingress-tls.yaml so the merged ingress can reference # the existing Active certificate. "hosts": [{"host": host, "paths": [{"path": "/", "pathType": "Prefix"}]} for host in studio_host_entries], "annotations": { **({"kubernetes.io/ingress.class": studio_ingress_class} if mode == "k8s" else {}), **({ "networking.gke.io/managed-certificates": supabase_studio_managed_cert_name, "networking.gke.io/v1beta1.FrontendConfig": supabase_studio_frontend_config_name, } if mode == "k8s" else {}), **({ "kubernetes.io/ingress.global-static-ip-name": supabase_studio_global_static_ip_name, } if mode == "k8s" and supabase_studio_global_static_ip_name else {}), **({ "nginx.ingress.kubernetes.io/auth-url": auth_verify_url, "nginx.ingress.kubernetes.io/auth-signin": auth_signin_url, "nginx.ingress.kubernetes.io/auth-response-headers": auth_response_headers, } if frontdoor_auth_enabled and mode != "k8s" else {}), } }, # BackendConfig wiring for GCE LB -- Kong's default "/" returns 404 # and Studio returns 301, both fail GCE's default HTTP healthCheck # and mark the backends UNHEALTHY. Our chart templates emit a # BackendConfig with a HTTP /healthz (Kong) or /favicon.ico (Studio) # when backendConfigName is set, and the Service gets a # cloud.google.com/backend-config annotation referencing that name. # # Studio's BackendConfig is only useful when Studio has its own # Ingress. In k8s mode we retire that ingress (see studioIngress # block above) and route db.0.knoe.dev through supabase-kong, so # studio.backendConfigName is cleared -- the chart then skips # studio/backendconfig.yaml and the Service annotation. Studio is # reached only via Kong, and Kong's BackendConfig covers both hosts. "service": { "kong": { "backendConfigName": "supabase-kong-backendconfig" if mode == "k8s" else "", }, "studio": { "backendConfigName": "", }, }, } if supabase_storage_class: # Validate: must be a synology/merlin/gke mount — refuse pi/local SD card classes allowed_prefixes = ("synology", "merlin-local-iscsi", "myrddin-local-iscsi", "supabase-gke", "supabase-standard", "pd-standard", "local-path") if not any(supabase_storage_class.startswith(p) for p in allowed_prefixes): raise SystemExit( f"SUPABASE_STORAGE_CLASS '{supabase_storage_class}' is not a synology or GKE CSI mount. " "Supabase must run on iSCSI/NFS or GKE CSI storage. " "Refusing deploy to prevent node crash." ) # Write to persistence.*.storageClassName — the path the Helm chart actually uses persistence = overlay.setdefault("persistence", {}) # db is handled by CNPG but we explicitly set it if enabled in values.yaml for pvc_key in ("db", "functions", "snippets", "deno", "imgproxy", "minio", "storage"): persistence.setdefault(pvc_key, {})["storageClassName"] = supabase_storage_class if mode == "k3d": # k3d nodes have no knoe.dev/node-role label — disable the enforced affinity overlay.setdefault("scheduling", {})["enforceGeneralNodeRole"] = False 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) if _as_bool(os.environ.get("SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA", "false")): deployment = overlay.setdefault("deployment", {}) functions_cfg = deployment.setdefault("functions", {}) functions_cfg["replicaCount"] = 1 autoscaling = overlay.setdefault("autoscaling", {}) functions_as = autoscaling.setdefault("functions", {}) functions_as["enabled"] = False meta = { "supabase_namespace": supabase_ns, "db_namespace": db_ns, "db_host": db_host, "mode": mode, } return overlay, meta def render(args: argparse.Namespace) -> None: cfg_path = Path(args.config) if cfg_path.is_dir(): cfg_path = cfg_path / "knoe.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)) if meta.get("mode") == "k8s": ingress_render_specs = ( ("supabase-kong", overlay.get("ingress") or {}), ("supabase-studio", overlay.get("studioIngress") or {}), ) for ingress_name, ingress_cfg in ingress_render_specs: if not isinstance(ingress_cfg, dict): continue annotations = ingress_cfg.get("annotations") or {} if not isinstance(annotations, dict): annotations = {} host_entries = ingress_cfg.get("hosts") or [] if not isinstance(host_entries, list): host_entries = [] sys.stderr.write( "Rendered Supabase ingress (pre-apply): " f"ns={meta['supabase_namespace']} ingress={ingress_name} " f"class={annotations.get('kubernetes.io/ingress.class', ingress_cfg.get('className', '-'))} " f"managedCert={annotations.get('networking.gke.io/managed-certificates', '-')} " f"frontendConfig={annotations.get('networking.gke.io/v1beta1.FrontendConfig', '-')} " f"preSharedCert={annotations.get('ingress.gcp.kubernetes.io/pre-shared-cert', '-')} " f"annotations={json.dumps(annotations, sort_keys=True)} " f"hosts={json.dumps(host_entries, sort_keys=True)}\n" ) # 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)) gke_tls_resources: list[dict[str, Any]] = [] if meta.get("mode") == "k8s": for ingress_cfg in (overlay.get("ingress") or {}, overlay.get("studioIngress") or {}): if not isinstance(ingress_cfg, dict): continue annotations = ingress_cfg.get("annotations") or {} if not isinstance(annotations, dict): annotations = {} managed_cert_name = str(annotations.get("networking.gke.io/managed-certificates") or "").strip() frontend_config_name = str(annotations.get("networking.gke.io/v1beta1.FrontendConfig") or "").strip() host_entries = ingress_cfg.get("hosts") or [] hosts = [] for host_entry in host_entries: if not isinstance(host_entry, dict): continue host = str(host_entry.get("host") or "").strip() if host and host not in hosts: hosts.append(host) if managed_cert_name and hosts: gke_tls_resources.append( { "apiVersion": "networking.gke.io/v1", "kind": "ManagedCertificate", "metadata": { "name": managed_cert_name, "namespace": meta["supabase_namespace"], }, "spec": {"domains": hosts}, } ) if frontend_config_name: gke_tls_resources.append( { "apiVersion": "networking.gke.io/v1beta1", "kind": "FrontendConfig", "metadata": { "name": frontend_config_name, "namespace": meta["supabase_namespace"], }, "spec": { "redirectToHttps": { "enabled": True, "responseCodeName": "MOVED_PERMANENTLY_DEFAULT", } }, } ) ingress_tls_manifest_path = k8s_dir / "public-ingress-tls.yaml" if gke_tls_resources: ingress_tls_manifest_path.write_text(yaml.safe_dump_all(gke_tls_resources, sort_keys=False)) print(f"\nGenerated Supabase GKE TLS dependency manifest: {ingress_tls_manifest_path}") print("Rendered Supabase TLS dependency objects:") for resource in gke_tls_resources: rendered_doc = yaml.safe_dump(resource, sort_keys=False).rstrip() print("---") print(rendered_doc) elif ingress_tls_manifest_path.exists(): ingress_tls_manifest_path.unlink() # Standalone supabase-kong Ingress for GCE L7. # # Two histories converge here: # 1. The chart's templates/kong/ingress.yaml used to vanish out from # under us even though helm's manifest still showed it -- something # was reaping Ingresses in the supabase namespace. Diagnosis # deferred. Workaround: ingress.externallyManaged=true makes the # chart no-op its kong/ingress.yaml, and we emit the ingress here # as a standalone manifest deploy.sh applies directly. # 2. The chart's studio/ingress.yaml hit the exact same reaping. Rather # than duplicate the externally-managed workaround, we retired the # studio ingress entirely. Kong's declarative config already routes # `/` to supabase-studio (see templates/kong/config.yaml `dashboard` # service block), so traffic for db.0.knoe.dev can flow through # supabase-kong and Kong forwards to Studio transparently. That # collapses two fragile ingresses into one, simplifies BackendConfig # wiring, and sidesteps ghosting for Studio without us having to # identify the reaper. # # This block emits a single Ingress carrying both hosts (api.0 and db.0), # annotated with both ManagedCertificates (comma-separated -- GCE # supports multiple per ingress, each covering the SAN it was issued for) # and the supabase-api static IP (the supabase-studio static IP becomes # orphaned and can be released once DNS points db.0.knoe.dev at the # kong ingress IP). ingress_kong_manifest_path = k8s_dir / "public-ingress-kong.yaml" if meta.get("mode") == "k8s": api_ingress_cfg = overlay.get("ingress") or {} studio_ingress_cfg = overlay.get("studioIngress") or {} api_annotations = dict(api_ingress_cfg.get("annotations") or {}) api_annotations.pop("externallyManaged", None) # Merge studio's ManagedCertificate into the shared ingress as a # comma-separated list. GCE provisions each cert for its own SANs; # clients for api.0 get the api cert, clients for db.0 get the # studio cert. studio_annotations = studio_ingress_cfg.get("annotations") or {} studio_cert_name = studio_annotations.get("networking.gke.io/managed-certificates") if studio_cert_name: api_cert_name = api_annotations.get("networking.gke.io/managed-certificates", "") merged_certs = ",".join( name for name in (api_cert_name, studio_cert_name) if name ) if merged_certs: api_annotations["networking.gke.io/managed-certificates"] = merged_certs def _rules_for_hosts(host_list, backend_name: str, backend_port: int) -> list[dict[str, Any]]: rules: list[dict[str, Any]] = [] for host_entry in host_list or []: if not isinstance(host_entry, dict): continue host = str(host_entry.get("host") or "").strip() if not host: continue rules.append( { "host": host, "http": { "paths": [ { "path": p.get("path", "/"), "pathType": p.get("pathType", "Prefix"), "backend": { "service": { "name": backend_name, "port": {"number": backend_port}, } }, } for p in (host_entry.get("paths") or [{"path": "/", "pathType": "Prefix"}]) ] }, } ) return rules # Both hosts land on supabase-kong:8000. Kong's dashboard route # forwards `/` to supabase-studio:3000 internally, so db.0.knoe.dev # traffic still reaches Studio -- just via one Kong hop. kong_ingress_rules = _rules_for_hosts(api_ingress_cfg.get("hosts"), "supabase-kong", 8000) kong_ingress_rules += _rules_for_hosts(studio_ingress_cfg.get("hosts"), "supabase-kong", 8000) if kong_ingress_rules and api_annotations: kong_ingress_doc = { "apiVersion": "networking.k8s.io/v1", "kind": "Ingress", "metadata": { "name": "supabase-kong", "namespace": meta["supabase_namespace"], "annotations": api_annotations, }, "spec": {"rules": kong_ingress_rules}, } ingress_kong_manifest_path.write_text( yaml.safe_dump(kong_ingress_doc, sort_keys=False) ) print(f"\nGenerated Supabase kong public Ingress manifest: {ingress_kong_manifest_path}") print(yaml.safe_dump(kong_ingress_doc, sort_keys=False).rstrip()) elif ingress_kong_manifest_path.exists(): ingress_kong_manifest_path.unlink() elif ingress_kong_manifest_path.exists(): ingress_kong_manifest_path.unlink() # 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})") # Hard validation: Every PVC in the rendered manifest MUST carry a storageClassName docs = _extract_k8s_docs(proc.stdout) errors = [] for doc in docs: if doc.get("kind") == "PersistentVolumeClaim": name = doc.get("metadata", {}).get("name", "unknown") spec = doc.get("spec", {}) sc = spec.get("storageClassName") if not sc: errors.append(f"PVC '{name}' is missing explicit storageClassName.") elif meta["mode"] == "k8s" and sc != overlay.get("persistence", {}).get(name.replace("supabase-", ""), {}).get("storageClassName"): # Special case: 'db' might not be in overlay persistence if disabled, but it shouldn't be rendered anyway expected = overlay.get("persistence", {}).get(name.replace("supabase-", ""), {}).get("storageClassName") if expected and sc != expected: errors.append(f"PVC '{name}' has unexpected storageClassName '{sc}' (expected '{expected}').") if errors: for err in errors: print(f"VALIDATION ERROR: {err}", file=sys.stderr) raise SystemExit("Rendered manifest failed StorageClass validation.") rendered_path.write_text(proc.stdout) frontdoor_release = os.environ.get("SUPABASE_FRONTDOOR_DB_RELEASE", "supabase-frontdoor-db").strip() app_docs, frontdoor_docs = _split_frontdoor_docs( proc.stdout, namespace=meta["supabase_namespace"], frontdoor_release=frontdoor_release, skip_split=meta["mode"] == "k8s", ) app_rendered_path = gen_dir / "supabase-helm.app.yaml" frontdoor_rendered_path = gen_dir / "supabase-helm.frontdoor-db.yaml" _dump_manifest_docs(app_rendered_path, app_docs) _dump_manifest_docs(frontdoor_rendered_path, frontdoor_docs) summary = { "values": str(values_path), "manifests": str(rendered_path), "manifests_app": str(app_rendered_path), "manifests_frontdoor_db": str(frontdoor_rendered_path), "manifests_public_ingress_tls": str(ingress_tls_manifest_path), "manifests_public_ingress_kong": str(ingress_kong_manifest_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 knoe.cfg") parser.add_argument( "--config", "-c", default=os.environ.get("KNOE_CONF", str(REPO_ROOT / "conf")), help="Path to knoe.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)