mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
- Simplified public APP endpoint fallback logic in `deploy.sh`. - Enhanced timeout handling with detailed Supabase ingress reconciliation diagnostics. - Removed redundant ingress class override in Helm template for Supabase.
1055 lines
39 KiB
Python
Executable File
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": mode == "k8s",
|
|
"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": mode != "k8s",
|
|
"className": api_ingress_class if mode != "k8s" else "",
|
|
"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": mode != "k8s",
|
|
"className": studio_ingress_class if mode != "k8s" else "",
|
|
"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)
|