prole/supabase/helm/render_supabase.py
chrisfu bdeb0b62f3 chore: add Supabase PVC quota diagnostics and improve ingress handling
- Added PVC quota error detection with detailed logging in `deploy.sh`.
- Enhanced ingress readiness checks with additional Supabase ingress resources.
- Updated Helm templates to standardize and always enable ingress for API and Studio.
2026-04-15 22:51:57 -07:00

1055 lines
39 KiB
Python
Executable File

#!/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 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 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 _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.0.knoe.dev")
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.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"
mode = _first(
os.environ.get("PROLE_MODE", ""),
_cfg_get(cfg, "Global", "DEPLOYMENT_MODE"),
_cfg_get(cfg, "globals", "prole.mode"),
default="",
).strip().lower()
default_auth_hostname = "api.knoe.dev" if mode == "k8s" else "api.prole.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.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_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="api.knoe.dev",
).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.0.knoe.dev",
)
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
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-Prole-User,X-Prole-Email,X-Prole-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 {}
)
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 = ""
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",
"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-<component>').
"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": "" 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 {}),
}
},
"studioIngress": {
"enabled": True,
"className": "" if mode == "k8s" else studio_ingress_class,
"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 {}),
**({
"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 {}),
}
},
}
if supabase_storage_class:
# Validate: must be a synology/merlin/gke 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", "supabase-gke")):
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", {})
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)
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 / "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)
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),
"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)