prole/supabase/helm/render_supabase.py
chrisfu b905cbe584 Bootstrap env-based prole.cfg entrypoint (safe legacy migration)
- Add centralized config resolver/activator with per-environment layering and stable symlink entrypoint\n- Bootstrap missing env dirs and preserve non-symlink legacy prole.cfg by seeding into inferred env\n- Wire installer UI/backend + shell helpers to shared resolution path for explicit, safe env switching\n- Harden k3d: registry network/DNS wiring and ArgoCD repo-server hostPath permission init\n- Add docs + regression tests for env switching, namespace stability, k3d registry, and ArgoCD rollout

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-13 12:23:15 -07:00

240 lines
7.6 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 Prole's CNPG cluster
(prole-db-rw Service) and Traefik 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
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 installer.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 installer 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 _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_ns = _first(
_cfg_get(cfg, "Global", "NAMESPACE"),
os.environ.get("NAMESPACE", ""),
"default",
)
supabase_ns = _first(
_cfg_get(cfg, "Supabase", "NAMESPACE"),
os.environ.get("SUPABASE_NAMESPACE", ""),
"supabase",
)
db_service = os.environ.get("PROLE_DB_SERVICE", "prole-db-rw")
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(
_cfg_get(cfg, "Inputs", "init_password.db_password"),
_cfg_get(cfg, "Global", "DB_PASSWORD"),
os.environ.get("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": "prole-supabase", "iat": now, "exp": exp}
service_payload = {"role": "service_role", "iss": "prole-supabase", "iat": now, "exp": exp}
anon_key = _jwt(anon_payload, jwt_secret)
service_key = _jwt(service_payload, jwt_secret)
ingress_host = _first(os.environ.get("SUPABASE_HOST", ""), "supabase.prole.org")
overlay: dict[str, Any] = {
"nameOverride": "supabase",
"fullnameOverride": "supabase",
"deployment": {
"db": {"enabled": False},
"functions": {"enabled": False},
"vector": {"enabled": False},
},
"environment": {
"auth": {"DB_HOST": db_host, "DB_PORT": str(db_port), "DB_SSL": "disable"},
"analytics": {"DB_HOST": db_host, "DB_PORT": str(db_port)},
"meta": {"DB_HOST": db_host, "DB_PORT": str(db_port)},
},
"secret": {
"db": {"password": db_password, "database": db_name},
"jwt": {"secret": jwt_secret, "anonKey": anon_key, "serviceKey": service_key},
},
"ingress": {
"enabled": True,
"className": "traefik",
"hosts": [
{"host": ingress_host, "paths": [{"path": "/", "pathType": "Prefix"}]}
],
},
}
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",
"prole-supabase",
str(REPO_ROOT / "supabase" / "helm" / "prole-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)