#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" if [[ -f "$PROJECT_ROOT/env.sh" ]]; then # shellcheck disable=SC1090 source "$PROJECT_ROOT/env.sh" fi MODE="k3d" HELM_RELEASE="supabase" USE_DEV_HELPERS="false" FOREGROUND="false" FORCE="true" PROLE_CFG_PATH="" HELM_TEMPLATE_ONLY="false" PREFETCH_IMAGES_ONLY="false" SKIP_PREFETCH="false" usage() { cat <<'USAGE' supabase/deploy.sh Launches the slimmed Supabase control-plane stack (Auth, Realtime, Meta, Studio, Analytics). Postgres is provided by CloudNativePG (knoe-db-rw). Storage/MinIO/ Imgproxy and the local Supabase Kong are not deployed; the shared Kong in kube-system is the API gateway and Garage is the object store. Usage: ./deploy.sh [options] Options: --mode Deployment mode ('local' for Docker Compose, 'k3d' for local k3d, 'k8s' for generic k8s/k3s; default: k3d) --helm-template-only Render Helm manifests to supabase/k8s without applying (for pipelines) --prefetch-images-only Discover/prepare/load/import Supabase images only, then exit (k3d mode) --skip-prefetch Skip image prefetch during deploy (for preloaded flows) --with-dev-helpers Include docker/dev/docker-compose.dev.yml --foreground Run docker compose in the foreground (default: detached, local mode only) -f, --force Reset the Supabase namespace before applying manifests (k3d only; default) --no-force Skip namespace reset (k3d only) -c, --config Path to knoe.cfg (loads environment defaults) -h, --help Show help Notes: - This script syncs the Supabase repo into $DEV_HOME/supabase. - The deployment runs as a single "supabase" namespace via the Compose project name. - For k3d mode, manifests are applied from $SUPABASE_K8S_DIR. - Docker image artifacts are cached in $DOCKER_IMPORT_DIR. - This script preserves the README/DEVELOPERS steps: 1) use docker/docker-compose.yml 2) copy docker/.env.example -> docker/.env (if missing) 3) run docker compose up USAGE } die() { echo "Error: $*" >&2 exit 1 } repair_blocked() { local reason="$1" local fix="${2:-}" echo "" >&2 echo "[REPAIR_BLOCKED] ${reason}" >&2 if [[ -n "$fix" ]]; then echo "Suggested Fix: ${fix}" >&2 fi echo "" >&2 exit 1 } check_supabase_minio_blocked() { local ns="$1" local kube_context="${2:-}" local ctx_args=() if [[ -n "$kube_context" ]]; then ctx_args+=(--context "$kube_context") fi local minio_pod minio_pod=$(kubectl "${ctx_args[@]}" get pods -n "$ns" -l "app.kubernetes.io/instance=supabase" --no-headers -o custom-columns=":metadata.name" | grep "minio" | head -n 1) if [[ -n "$minio_pod" ]]; then local logs logs=$(kubectl "${ctx_args[@]}" logs -n "$ns" "$minio_pod" --tail=50 2>/dev/null || true) if [[ "$logs" == *"file access denied"* ]] || [[ "$logs" == *"/data/.minio.sys/tmp"* ]]; then repair_blocked "Supabase MinIO is failing with file access denied on /data" \ "Ensure the rendered YAML contains the correct podSecurityContext (runAsUser: 65532, fsGroup: 65532). MinIO with Chainguard images must run as non-root with appropriate fsGroup for PVC volume mounts." fi fi } supabase_collect_stale_retained_pv_info() { local ns="$1" local kube_context="${2:-}" local ctx_args=() if [[ -n "$kube_context" ]]; then ctx_args+=(--context "$kube_context") fi python3 - "$ns" "${ctx_args[@]}" <<'PY' import json import re import subprocess import sys ns = sys.argv[1] ctx_args = sys.argv[2:] try: raw = subprocess.check_output(["kubectl", *ctx_args, "get", "pv", "-o", "json"], stderr=subprocess.DEVNULL) data = json.loads((raw or b"{}").decode("utf-8", errors="replace")) except Exception: print("") sys.exit(0) for item in data.get("items", []) or []: spec = item.get("spec") or {} status = item.get("status") or {} claim = spec.get("claimRef") or {} labels = (item.get("metadata") or {}).get("labels") or {} claim_ns = (claim.get("namespace") or "").strip() claim_name = (claim.get("name") or "").strip() sc_name = (spec.get("storageClassName") or "").strip() is_supabase_claim = (claim_ns == ns) is_supabase_sc = (sc_name == "supabase-standard") if not (is_supabase_claim or is_supabase_sc): continue phase = (status.get("phase") or "").strip() if phase != "Released": continue reclaim = (spec.get("persistentVolumeReclaimPolicy") or "").strip() if reclaim != "Retain": continue csi = spec.get("csi") or {} gce = spec.get("gcePersistentDisk") or {} handle = (csi.get("volumeHandle") or gce.get("pdName") or "").strip() zone = ( labels.get("topology.kubernetes.io/zone") or labels.get("failure-domain.beta.kubernetes.io/zone") or (csi.get("volumeAttributes") or {}).get("topology.gke.io/zone") or "" ).strip() if not zone and handle: m = re.search(r"/zones/([^/]+)/disks/[^/]+$", handle) if m: zone = m.group(1) pv_name = ((item.get("metadata") or {}).get("name") or "").strip() print("\t".join([ pv_name, claim_ns, claim_name, phase, sc_name, reclaim, handle, zone, ])) PY } supabase_cfg_first_nonempty_value() { if [[ -z "${PROLE_CFG_PATH:-}" || ! -f "${PROLE_CFG_PATH}" ]]; then return 0 fi python3 - "$PROLE_CFG_PATH" "$@" <<'PY' import configparser import sys cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str try: cfg.read(sys.argv[1]) except Exception: sys.exit(0) for pair in sys.argv[2:]: if ":" not in pair: continue section, key = pair.split(":", 1) if not cfg.has_option(section, key): continue value = (cfg.get(section, key, fallback="") or "").strip() if value: print(value) break PY } check_supabase_retained_pv_blocked() { local ns="$1" local kube_context="${2:-}" local ctx_args=() if [[ -n "$kube_context" ]]; then ctx_args+=(--context "$kube_context") fi local stale_info stale_info="$(supabase_collect_stale_retained_pv_info "$ns" "$kube_context")" || true if [[ -z "$stale_info" ]]; then return 0 fi local auto_clean_raw auto_clean_disks_raw auto_clean_disks auto_clean_raw="${SUPABASE_AUTO_CLEAN_RETAINED_PVS:-}" if [[ -z "$auto_clean_raw" ]]; then auto_clean_raw="$(supabase_cfg_first_nonempty_value \ "Inputs:init_cluster.SUPABASE_AUTO_CLEAN_RETAINED_PVS" \ "Inputs:SUPABASE_AUTO_CLEAN_RETAINED_PVS" \ "Global:SUPABASE_AUTO_CLEAN_RETAINED_PVS" \ "Supabase:SUPABASE_AUTO_CLEAN_RETAINED_PVS")" fi auto_clean_raw="$(printf '%s' "${auto_clean_raw:-false}" | tr '[:upper:]' '[:lower:]')" auto_clean_disks_raw="${SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS:-}" if [[ -z "$auto_clean_disks_raw" ]]; then auto_clean_disks_raw="$(supabase_cfg_first_nonempty_value \ "Inputs:init_cluster.SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS" \ "Inputs:SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS" \ "Global:SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS" \ "Supabase:SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS")" fi auto_clean_disks_raw="$(printf '%s' "${auto_clean_disks_raw:-false}" | tr '[:upper:]' '[:lower:]')" auto_clean_disks="false" if [[ "$auto_clean_disks_raw" =~ ^(1|true|yes|on)$ ]]; then auto_clean_disks="true" fi if [[ "$auto_clean_raw" =~ ^(1|true|yes|on)$ ]]; then export SUPABASE_AUTO_CLEAN_RETAINED_PVS="true" else warn "Detected stale Supabase Retain PV artifacts; auto-enabling SUPABASE_AUTO_CLEAN_RETAINED_PVS=true for this run." export SUPABASE_AUTO_CLEAN_RETAINED_PVS="true" fi local remaining_disk_lines=() local cleanup_dry_run_raw cleanup_dry_run="false" cleanup_dry_run_raw="$(printf '%s' "${SUPABASE_RETAINED_PV_CLEANUP_DRY_RUN:-false}" | tr '[:upper:]' '[:lower:]')" if [[ "$cleanup_dry_run_raw" =~ ^(1|true|yes|on)$ ]]; then cleanup_dry_run="true" fi local skip_reason_flag_disabled=0 local skip_reason_missing_project=0 local skip_reason_missing_gcloud=0 local skip_reason_missing_gcloud_auth=0 local skip_reason_missing_zone=0 local skip_reason_delete_failed=0 local skip_reason_dry_run=0 local pv_name pvc_ns pvc_name phase sc_name reclaim disk_handle zone warn "SUPABASE_AUTO_CLEAN_RETAINED_PVS=true; deleting stale Supabase Retain PV objects before Helm reconcile." local project_id project_id="${GCP_PROJECT_ID:-${PROJECT_ID:-${GOOGLE_CLOUD_PROJECT:-${GCP_PROJECT:-${CLOUDSDK_CORE_PROJECT:-}}}}}" if [[ -z "$project_id" ]]; then project_id="$(supabase_cfg_first_nonempty_value \ "Inputs:init_cluster.project_id" \ "Inputs:env_setup.GCP_PROJECT_ID" \ "Inputs:env_setup.GCP_PROJECT" \ "Global:GCP_PROJECT_ID" \ "Global:PROJECT_ID" \ "Global:GCP_PROJECT" \ "GCP:project_id" \ "GCP:PROJECT_ID")" fi if [[ -z "$project_id" ]]; then local project_ctx="${kube_context:-${KUBECONTEXT:-${APP_CLUSTER_KUBECONTEXT:-}}}" if [[ -z "$project_ctx" ]]; then project_ctx="$(supabase_cfg_first_nonempty_value \ "Inputs:init_cluster.app_cluster_kubecontext" \ "Inputs:env_setup.APP_CLUSTER_KUBECONTEXT" \ "Global:APP_CLUSTER_KUBECONTEXT" \ "Global:KUBECONTEXT")" fi if [[ "$project_ctx" =~ ^gke_([^_]+)_ ]]; then project_id="${BASH_REMATCH[1]}" fi fi if [[ -n "$project_id" ]]; then export GCP_PROJECT_ID="$project_id" export GOOGLE_CLOUD_PROJECT="${GOOGLE_CLOUD_PROJECT:-$project_id}" export CLOUDSDK_CORE_PROJECT="${CLOUDSDK_CORE_PROJECT:-$project_id}" fi local gcloud_available="false" local gcloud_auth_ready="false" local gcloud_auth_account="" if command -v gcloud >/dev/null 2>&1; then gcloud_available="true" if [[ "$auto_clean_disks" == "true" ]]; then gcloud_auth_account="$(gcloud auth list --filter=status:ACTIVE --format='value(account)' 2>/dev/null | head -n 1 || true)" if [[ -n "$gcloud_auth_account" ]]; then gcloud_auth_ready="true" fi fi fi warn "Supabase retained cleanup settings: pv_auto_clean=true, gce_disk_auto_clean=${auto_clean_disks}, dry_run=${cleanup_dry_run}, resolved_project=${project_id:-}" if [[ "$auto_clean_disks" == "true" ]]; then warn "Supabase retained disk cleanup prerequisites: gcloud_available=${gcloud_available}, gcloud_auth_ready=${gcloud_auth_ready}" fi while IFS=$'\t' read -r pv_name pvc_ns pvc_name phase sc_name reclaim disk_handle zone; do [[ -n "$pv_name" ]] || continue if [[ "$cleanup_dry_run" == "true" ]]; then warn "[dry-run] Would delete stale PV '${pv_name}' (claim_ns=${pvc_ns:-}, pvc=${pvc_name:-}, phase=${phase:-?}, reclaim=${reclaim:-?}, disk=${disk_handle:-}, zone=${zone:-})." if [[ -n "$disk_handle" ]]; then skip_reason_dry_run=$((skip_reason_dry_run + 1)) remaining_disk_lines+=("${disk_handle} (zone=${zone:-}, reason=dry_run)") fi continue fi warn "Deleting stale PV '${pv_name}' (claim_ns=${pvc_ns:-}, pvc=${pvc_name:-}, phase=${phase:-?}, reclaim=${reclaim:-?}, disk=${disk_handle:-}, zone=${zone:-})." kubectl "${ctx_args[@]}" delete pv "$pv_name" >/dev/null 2>&1 || true if [[ "$auto_clean_disks" != "true" ]]; then if [[ -n "$disk_handle" ]]; then skip_reason_flag_disabled=$((skip_reason_flag_disabled + 1)) remaining_disk_lines+=("${disk_handle} (zone=${zone:-}, reason=disk_autoclean_disabled)") fi continue fi if [[ -z "$disk_handle" ]]; then continue fi local disk_name="$disk_handle" local disk_zone="$zone" if [[ "$disk_handle" =~ /zones/([^/]+)/disks/([^/]+)$ ]]; then disk_zone="${BASH_REMATCH[1]}" disk_name="${BASH_REMATCH[2]}" fi if [[ "$gcloud_available" != "true" ]]; then warn "Skipping disk cleanup for '${disk_handle}': gcloud is not installed." skip_reason_missing_gcloud=$((skip_reason_missing_gcloud + 1)) remaining_disk_lines+=("${disk_handle} (zone=${disk_zone:-}, reason=missing_gcloud)") continue fi if [[ -z "$project_id" ]]; then warn "Skipping disk cleanup for '${disk_handle}': GCP project is not resolved from env/config/context." skip_reason_missing_project=$((skip_reason_missing_project + 1)) remaining_disk_lines+=("${disk_handle} (zone=${disk_zone:-}, reason=missing_project)") continue fi if [[ "$gcloud_auth_ready" != "true" ]]; then warn "Skipping disk cleanup for '${disk_handle}': gcloud has no active authenticated account." skip_reason_missing_gcloud_auth=$((skip_reason_missing_gcloud_auth + 1)) remaining_disk_lines+=("${disk_handle} (zone=${disk_zone:-}, reason=missing_gcloud_auth)") continue fi if [[ -z "$disk_zone" ]]; then warn "Skipping disk cleanup for '${disk_handle}': could not determine disk zone." skip_reason_missing_zone=$((skip_reason_missing_zone + 1)) remaining_disk_lines+=("${disk_handle} (zone=, reason=missing_zone)") continue fi warn "Deleting retained GCE disk '${disk_name}' (zone=${disk_zone}, project=${project_id})." if ! gcloud compute disks delete "$disk_name" --project "$project_id" --zone "$disk_zone" --quiet >/dev/null 2>&1; then warn "Failed to delete retained GCE disk '${disk_name}' in zone '${disk_zone}'." skip_reason_delete_failed=$((skip_reason_delete_failed + 1)) remaining_disk_lines+=("${disk_handle} (zone=${disk_zone}, reason=delete_failed)") fi done <<< "$stale_info" local verify_stale_info="" local verify_attempt for verify_attempt in 1 2 3 4; do verify_stale_info="$(supabase_collect_stale_retained_pv_info "$ns" "$kube_context")" || true if [[ -z "$verify_stale_info" ]]; then break fi warn "Supabase retained-PV cleanup verification poll ${verify_attempt}/4: stale PV objects still visible; waiting 2s..." sleep 2 done if [[ -n "$verify_stale_info" ]]; then local verify_lines=() while IFS=$'\t' read -r pv_name pvc_ns pvc_name phase sc_name reclaim disk_handle zone; do [[ -n "$pv_name" ]] || continue if [[ -z "$zone" && "$disk_handle" =~ /zones/([^/]+)/disks/([^/]+)$ ]]; then zone="${BASH_REMATCH[1]}" fi verify_lines+=("pv=${pv_name}, claim_ns=${pvc_ns:-}, pvc=${pvc_name:-}, phase=${phase:-?}, storageClass=${sc_name:-}, reclaim=${reclaim:-?}, disk=${disk_handle:-}, zone=${zone:-}") done <<< "$verify_stale_info" repair_blocked "Supabase retained PV cleanup did not converge; stale Supabase Retain PV artifacts are still present." \ "Remaining stale artifacts after cleanup: ${verify_lines[*]}" fi if (( ${#remaining_disk_lines[@]} > 0 )); then local remaining_disks local reason_detail="" remaining_disks=$(IFS='; '; printf '%s' "${remaining_disk_lines[*]}") if (( skip_reason_dry_run > 0 )); then reason_detail="Dry-run enabled: SUPABASE_RETAINED_PV_CLEANUP_DRY_RUN=true prevented deletion." elif (( skip_reason_flag_disabled > 0 )); then reason_detail="Flag disabled: SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS is not enabled." elif (( skip_reason_missing_project > 0 )); then reason_detail="Missing project resolution: no GCP project could be resolved from env/config/context." elif (( skip_reason_missing_gcloud > 0 )); then reason_detail="Missing gcloud binary: install gcloud on the deploy host." elif (( skip_reason_missing_gcloud_auth > 0 )); then reason_detail="Missing gcloud auth: no active authenticated gcloud account/session was found." elif (( skip_reason_delete_failed > 0 )); then reason_detail="Deletion command failure: one or more gcloud disk-delete commands returned non-zero." elif (( skip_reason_missing_zone > 0 )); then reason_detail="Disk metadata incomplete: one or more disk zones could not be resolved from PV volumeHandle." else reason_detail="Unknown retained-disk cleanup failure." fi if [[ "$auto_clean_disks" != "true" ]]; then repair_blocked "Stale Supabase Retain PVs were cleaned automatically, but retained backing GCE disks remain and can still consume quota." \ "${reason_detail} Remaining handles: ${remaining_disks}. To auto-delete in-script, set SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS=true." fi repair_blocked "Automatic retained GCE disk cleanup did not fully succeed for Supabase stale artifacts." \ "${reason_detail} Remaining handles: ${remaining_disks}." fi warn "Supabase retained-PV preflight cleanup completed; stale PV blockers are cleared for this run." } check_supabase_pvc_quota_blocked() { local ns="$1" local kube_context="${2:-}" local ctx_args=() if [[ -n "$kube_context" ]]; then ctx_args+=(--context "$kube_context") fi # Preflight for stale Retain PV/disk artifacts from previous runs before # evaluating current PVC quota events. check_supabase_retained_pv_blocked "$ns" "$kube_context" local blockers_json blockers_json=$(kubectl "${ctx_args[@]}" get events -n "$ns" -o json 2>/dev/null | python3 - "$ns" "$kube_context" <<'PY' import json import sys import subprocess from datetime import datetime, timezone ns = sys.argv[1] kube_context = sys.argv[2] def kubectl_json(*args): cmd = ["kubectl"] if kube_context: cmd.extend(["--context", kube_context]) cmd.extend(args) try: out = subprocess.check_output(cmd, text=True) return json.loads(out) except: return {} def parse_ts(value): if not value: return None try: return datetime.fromisoformat(str(value).replace('Z', '+00:00')) except: return None try: data = json.load(sys.stdin) except: print("[]") sys.exit(0) # Fetch current PVC states to avoid reporting for Bound PVCs pvcs = kubectl_json("get", "pvc", "-n", ns, "-o", "json").get("items", []) pvc_info = {} for pvc in pvcs: meta = pvc.get("metadata", {}) spec = pvc.get("spec", {}) status = pvc.get("status", {}) name = meta.get("name") if not name: continue pvc_info[name] = { "phase": status.get("phase", "Unknown"), "storageClassName": spec.get("storageClassName") or "", } sc_cache = {} def get_sc_info(name): key = name or "" if key in sc_cache: return sc_cache[key] info = { "storageClassName": key, "provisioner": "", "type": "", "volumeBindingMode": "", "reclaimPolicy": "", } if name and name != "": sc = kubectl_json("get", "storageclass", name, "-o", "json") if sc: info["provisioner"] = sc.get("provisioner") or "" info["type"] = (sc.get("parameters") or {}).get("type") or "" info["volumeBindingMode"] = sc.get("volumeBindingMode") or "" info["reclaimPolicy"] = sc.get("reclaimPolicy") or "" sc_cache[key] = info return info blockers = {} for event in data.get("items", []): obj = event.get("involvedObject", {}) if obj.get("kind") == "PersistentVolumeClaim": pvc_name = obj.get("name") if not pvc_name: continue info = pvc_info.get(pvc_name, {}) # SKIP if currently Bound if info.get("phase") == "Bound": continue msg = event.get("message", "") reason = event.get("reason", "") msg_l = msg.lower() reason_l = reason.lower() if not ( "quota_exceeded" in msg_l or "exceeded" in msg_l or "provisioningfailed" in reason_l or "failedbinding" in reason_l ): continue ts = parse_ts(event.get("lastTimestamp") or event.get("eventTime") or event.get("firstTimestamp")) sc_details = get_sc_info(info.get("storageClassName")) entry = { "pvc": pvc_name, "phase": info.get("phase") or "Unknown", "reason": reason or "Unknown", "message": msg or "", "storageClassName": sc_details["storageClassName"], "storageClassProvisioner": sc_details["provisioner"], "storageClassType": sc_details["type"], "storageClassVolumeBindingMode": sc_details["volumeBindingMode"], "storageClassReclaimPolicy": sc_details["reclaimPolicy"], "eventTime": ts.isoformat() if ts else "", } current = blockers.get(pvc_name) if not current: blockers[pvc_name] = entry continue current_ts = parse_ts(current.get("eventTime")) if current_ts is None or (ts is not None and ts >= current_ts): blockers[pvc_name] = entry print(json.dumps(sorted(blockers.values(), key=lambda x: x.get("pvc") or ""))) PY ) local blockers_count blockers_count=$(BLOCKERS_JSON="$blockers_json" python3 - <<'PY' import json import os raw = os.environ.get("BLOCKERS_JSON") or "[]" try: data = json.loads(raw) except Exception: data = [] print(len(data) if isinstance(data, list) else 0) PY ) if [[ "${blockers_count:-0}" != "0" ]]; then local blocker_details blocker_details=$(BLOCKERS_JSON="$blockers_json" python3 - <<'PY' import json import os raw = os.environ.get("BLOCKERS_JSON") or "[]" try: data = json.loads(raw) except Exception: data = [] lines = [] for item in data if isinstance(data, list) else []: lines.append( "PVC='{pvc}' phase={phase} storageClass={sc} (provisioner={prov}, type={sc_type}, binding={binding}, reclaim={reclaim}) reason={reason} msg={msg}".format( pvc=item.get("pvc") or "?", phase=item.get("phase") or "Unknown", sc=item.get("storageClassName") or "", prov=item.get("storageClassProvisioner") or "", sc_type=item.get("storageClassType") or "", binding=item.get("storageClassVolumeBindingMode") or "", reclaim=item.get("storageClassReclaimPolicy") or "", reason=item.get("reason") or "Unknown", msg=(item.get("message") or "").strip() or "", ) ) print(" ; ".join(lines)) PY ) repair_blocked "Supabase PVC provisioning failed (Quota/Storage Error)." \ "${blocker_details}. If this is quota pressure from stale Retain PVs/disks, remove stale Supabase PVs (Released/Failed) and their retained GCE disks before rerun." fi } validate_supabase_rendered_pvc_storage() { local values_file="$1" local expected_storage_class="${2:-}" [[ -n "$values_file" ]] || die "Rendered Supabase values path is empty." [[ -f "$values_file" ]] || die "Rendered Supabase values file not found: $values_file" local preflight_output if ! preflight_output=$(python3 - "$values_file" "$expected_storage_class" <<'PY' import json import sys values_path = sys.argv[1] expected = (sys.argv[2] or "").strip() try: with open(values_path, encoding="utf-8") as fh: data = json.load(fh) except Exception as exc: print(f"Unable to parse rendered Supabase values '{values_path}': {exc}") sys.exit(2) persistence = data.get("persistence") or {} components = ("deno", "functions", "storage", "imgproxy", "minio", "snippets") errors = [] print("Supabase rendered PVC storage preflight:") for name in components: cfg = persistence.get(name) or {} storage_class = str(cfg.get("storageClassName") or "").strip() size = str(cfg.get("size") or "").strip() or "" access_modes = cfg.get("accessModes") or [] annotations = cfg.get("annotations") or {} access_modes_s = ",".join(str(m) for m in access_modes) if access_modes else "" annotation_keys_s = ",".join(sorted(str(k) for k in annotations.keys())) if annotations else "-" print( f" - {name}: storageClassName={storage_class or ''} " f"size={size} accessModes={access_modes_s} annotations={annotation_keys_s}" ) if not storage_class: errors.append(f"{name}: missing storageClassName") elif expected and storage_class != expected: errors.append(f"{name}: storageClassName '{storage_class}' != expected '{expected}'") if errors: print("Supabase rendered PVC storage mismatch: " + " ; ".join(errors)) sys.exit(3) PY ); then repair_blocked "Supabase rendered PVC storageClass preflight failed." "$preflight_output" fi while IFS= read -r line; do [[ -n "$line" ]] || continue log "$line" done <<<"$preflight_output" } warn() { echo "Warning: $*" >&2 } log() { echo "==> $*" >&2 } write_split_supabase_values() { local source_values="$1" local app_values="$2" local db_values="$3" python3 - "$source_values" "$app_values" "$db_values" <<'PY' import json import os import sys from copy import deepcopy source_path, app_path, db_path = sys.argv[1:4] base = json.load(open(source_path, encoding="utf-8")) def ensure_enabled(data, component, enabled): deployment = data.setdefault("deployment", {}) cfg = deployment.setdefault(component, {}) cfg["enabled"] = bool(enabled) # Also attempt to disable persistence if deployment is disabled if not enabled: persistence = data.setdefault("persistence", {}) pvc_cfg = persistence.setdefault(component, {}) pvc_cfg["enabled"] = False # Specific known PVC keys that might differ from deployment keys if component == "functions": persistence.setdefault("deno", {})["enabled"] = False persistence.setdefault("snippets", {})["enabled"] = False def clear_stale_app_scheduling(data): deployment = data.get("deployment", {}) if not isinstance(deployment, dict): return for cfg in deployment.values(): if not isinstance(cfg, dict): continue cfg["nodeSelector"] = {} cfg["affinity"] = {} cfg["topologySpreadConstraints"] = [] app_values = deepcopy(base) ensure_enabled(app_values, "studio", False) ensure_enabled(app_values, "kong", False) app_values.setdefault("studioIngress", {})["enabled"] = False app_values.setdefault("ingress", {})["enabled"] = False app_values.setdefault("scheduling", {})["enforceGeneralNodeRole"] = False clear_stale_app_scheduling(app_values) db_values = deepcopy(base) clear_stale_app_scheduling(db_values) for component in ( "analytics", "auth", "functions", "imgproxy", "meta", "minio", "realtime", "rest", "storage", "vector", ): ensure_enabled(db_values, component, False) ensure_enabled(db_values, "studio", True) ensure_enabled(db_values, "kong", True) db_values.setdefault("studioIngress", {})["enabled"] = True db_values.setdefault("ingress", {})["enabled"] = True db_values.setdefault("scheduling", {})["enforceGeneralNodeRole"] = False force_single_functions_replica = os.environ.get( "SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA", "" ).strip().lower() in {"1", "true", "yes", "on"} if force_single_functions_replica: deployment = app_values.setdefault("deployment", {}) functions_cfg = deployment.setdefault("functions", {}) functions_cfg["replicaCount"] = 1 autoscaling = app_values.setdefault("autoscaling", {}) functions_as = autoscaling.setdefault("functions", {}) functions_as["enabled"] = False json.dump(app_values, open(app_path, "w", encoding="utf-8"), indent=2) json.dump(db_values, open(db_path, "w", encoding="utf-8"), indent=2) PY } load_manifest_summary_path() { local summary="$PROJECT_ROOT/supabase/helm/generated/manifest-summary.json" if [[ ! -f "$summary" ]]; then return 0 fi python3 - "$summary" "$1" <<'PY' import json import sys path = sys.argv[1] key = sys.argv[2] try: data = json.load(open(path, encoding="utf-8")) except Exception: print("") sys.exit(0) value = data.get(key, "") print(value if isinstance(value, str) else "") PY } apply_supabase_public_ingress_tls_resources() { local kube_context="$1" local namespace="$2" local manifest_path="$3" if [[ -z "$manifest_path" || ! -f "$manifest_path" ]]; then return 0 fi local ctx_args=() if [[ -n "$kube_context" ]]; then ctx_args+=(--context "$kube_context") fi log "Applying Supabase public ingress TLS dependencies from '$manifest_path'..." if ! kubectl "${ctx_args[@]}" -n "$namespace" apply -f "$manifest_path"; then die "Failed applying Supabase public ingress TLS dependencies from '$manifest_path'." fi } # Apply the standalone supabase-kong Ingress (api.0.knoe.dev) in k8s/GKE mode. # render_supabase.py emits this manifest outside the helm chart because the # chart-rendered ingress kept getting reaped from the cluster minutes after # helm install. Gated by the chart's `ingress.externallyManaged` flag which # render_supabase.py sets to true in k8s mode, so the chart no-ops # kong/ingress.yaml and this standalone manifest is the single source of # truth for the api.0 ingress. apply_supabase_public_ingress_kong_resource() { local kube_context="$1" local namespace="$2" local manifest_path="$3" if [[ -z "$manifest_path" || ! -f "$manifest_path" ]]; then return 0 fi local ctx_args=() if [[ -n "$kube_context" ]]; then ctx_args+=(--context "$kube_context") fi log "Applying standalone supabase-kong Ingress from '$manifest_path'..." if ! kubectl "${ctx_args[@]}" -n "$namespace" apply -f "$manifest_path"; then die "Failed applying standalone supabase-kong Ingress from '$manifest_path'." fi } apply_defaults() { DEV_HOME="${DEV_HOME:-$HOME/dev}" DEV_HOME="${DEV_HOME/#\~/$HOME}" SUPABASE_DIR="$DEV_HOME/supabase" DOCKER_DIR="$SUPABASE_DIR/docker" COMPOSE_FILE="$DOCKER_DIR/docker-compose.yml" DEV_COMPOSE_FILE="$DOCKER_DIR/dev/docker-compose.dev.yml" ENV_EXAMPLE="$DOCKER_DIR/.env.example" ENV_FILE="$DOCKER_DIR/.env" SUPABASE_K8S_DIR="${SUPABASE_K8S_DIR:-$SCRIPT_DIR/../build/supabase-k8s}" local default_owner default_owner="$(id -un 2>/dev/null || echo knoe)" local data_root="${PROLE_DATA:-/opt/knoe/data/${default_owner}}" data_root="${data_root%/}" DOCKER_IMPORT_DIR="${DOCKER_IMPORT_DIR:-$data_root/docker-import}" SUPABASE_IMAGE_PLATFORM="${SUPABASE_IMAGE_PLATFORM:-}" SUPABASE_IMAGE_PLATFORMS="${SUPABASE_IMAGE_PLATFORMS:-linux/amd64 linux/arm64}" SUPABASE_POSTGRES_PORT="${SUPABASE_POSTGRES_PORT:-15432}" KNOE_DB_SERVICE="${KNOE_DB_SERVICE:-knoe-db-rw}" KNOE_DB_NAMESPACE="${KNOE_DB_NAMESPACE:-}" } load_knoe_cfg() { local cfg_loader="$PROJECT_ROOT/etc/knoe_cfg.sh" local cfg_path="${PROLE_CFG_PATH:-}" local candidate="" if [[ -z "$cfg_path" ]]; then if [[ -n "${KNOE_CONF:-}" ]]; then for candidate in \ "${KNOE_CONF%/}/k3d.cfg" \ "${KNOE_CONF%/}/k3s.cfg" \ "${KNOE_CONF%/}/gke.cfg" \ "${KNOE_CONF%/}/knoe.cfg" \ "${KNOE_CONF%/}/service/prod.cfg"; do if [[ -f "$candidate" ]]; then cfg_path="$candidate" break fi done elif [[ -f "$PROJECT_ROOT/conf/k3d.cfg" ]]; then cfg_path="$PROJECT_ROOT/conf/k3d.cfg" elif [[ -f "$PROJECT_ROOT/conf/k3s.cfg" ]]; then cfg_path="$PROJECT_ROOT/conf/k3s.cfg" elif [[ -f "$PROJECT_ROOT/conf/gke.cfg" ]]; then cfg_path="$PROJECT_ROOT/conf/gke.cfg" elif [[ -f "$PROJECT_ROOT/conf/knoe.cfg" ]]; then cfg_path="$PROJECT_ROOT/conf/knoe.cfg" elif [[ -f "$PROJECT_ROOT/conf/service/prod.cfg" ]]; then cfg_path="$PROJECT_ROOT/conf/service/prod.cfg" fi fi if [[ -z "$cfg_path" ]]; then return 0 fi if [[ -n "$cfg_path" ]]; then if [[ -d "$cfg_path" ]]; then for candidate in \ "$cfg_path/k3d.cfg" \ "$cfg_path/k3s.cfg" \ "$cfg_path/gke.cfg" \ "$cfg_path/knoe.cfg" \ "$cfg_path/prod.cfg" \ "$cfg_path/service/prod.cfg"; do if [[ -f "$candidate" ]]; then cfg_path="$candidate" break fi done fi [[ -f "$cfg_path" ]] || die "Config file not found: $cfg_path" KNOE_CONF="$(cd "$(dirname "$cfg_path")" && pwd)" export KNOE_CONF PROLE_CFG_PATH="$cfg_path" fi if [[ -f "$cfg_loader" ]]; then # shellcheck disable=SC1090 source "$cfg_loader" else warn "knoe_cfg.sh not found; config defaults may be incomplete." fi } register_supabase_ports() { # Register port forwards if helper is available # Kong is no longer deployed locally; API routing is handled by shared Kong in kube-system. if command -v knoe_register_port_forward >/dev/null 2>&1; then knoe_register_port_forward "supabase-studio" "supabase" "svc/studio" "8082" "3000" "0.0.0.0" "TCP" "Supabase Studio" fi } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } duration_to_seconds() { # Accepts: 900, 900s, 15m, 1h local raw="${1:-}" raw="${raw//[[:space:]]/}" if [[ -z "$raw" ]]; then echo 900 return 0 fi if [[ "$raw" =~ ^[0-9]+$ ]]; then echo "$raw" return 0 fi if [[ "$raw" =~ ^([0-9]+)s$ ]]; then echo "${BASH_REMATCH[1]}" return 0 fi if [[ "$raw" =~ ^([0-9]+)m$ ]]; then echo $((BASH_REMATCH[1] * 60)) return 0 fi if [[ "$raw" =~ ^([0-9]+)h$ ]]; then echo $((BASH_REMATCH[1] * 3600)) return 0 fi echo 900 } supabase_rollout_status() { local ns="$1" local kube_context="${2:-}" local selector="${3:-}" python3 - "$ns" "$kube_context" "$selector" <<'PY' import json import os import subprocess import sys ns = sys.argv[1] kube_context = sys.argv[2].strip() selector = sys.argv[3].strip() def kubectl_json(*args, use_selector=True): cmd = ["kubectl"] if kube_context: cmd.extend(["--context", kube_context]) cmd.extend(args) if use_selector and selector: cmd.extend(["-l", selector]) out = subprocess.check_output(cmd, text=True) return json.loads(out) def first_non_empty(*values): for value in values: if value: text = str(value).strip() if text: return text return "" pvc = kubectl_json("get", "pvc", "-n", ns, "-o", "json") pods = kubectl_json("get", "pods", "-n", ns, "-o", "json") sc = kubectl_json("get", "storageclass", "-o", "json", use_selector=False) sc_type_map = {item["metadata"]["name"]: item.get("parameters", {}).get("type", "unknown") for item in sc.get("items", [])} pvc_pending = [] for item in pvc.get("items", []) or []: md = item.get("metadata") or {} status = item.get("status") or {} spec = item.get("spec") or {} name = md.get("name") or "?" phase = status.get("phase") or "" if phase != "Bound": reason = "" for cond in status.get("conditions", []) or []: if cond.get("status") == "True": reason = first_non_empty(cond.get("reason"), cond.get("message")) if reason: break if not reason: reason = phase or "Pending" sc_name = first_non_empty(spec.get("storageClassName"), "") sc_type = sc_type_map.get(sc_name, "unknown") pvc_pending.append({ "name": name, "phase": phase or "Unknown", "storageClass": f"{sc_name} (type: {sc_type})", "reason": reason, }) not_ready = [] terminating = 0 for item in pods.get("items", []) or []: md = item.get("metadata") or {} name = md.get("name") or "?" if md.get("deletionTimestamp"): terminating += 1 continue status = item.get("status") or {} phase = status.get("phase") or "" if phase in ("Succeeded",): continue # Consider Failed / Pending / Running-not-ready as not ready ready = False for cond in status.get("conditions", []) or []: if cond.get("type") == "Ready" and cond.get("status") == "True": ready = True break if not ready: reason = "" for cond in status.get("conditions", []) or []: if cond.get("status") == "False": reason = first_non_empty(cond.get("reason"), cond.get("message")) if reason: break for cs in (status.get("initContainerStatuses") or []) + (status.get("containerStatuses") or []): waiting = ((cs.get("state") or {}).get("waiting") or {}) if waiting: reason = first_non_empty(waiting.get("reason"), waiting.get("message"), reason) if reason: break not_ready.append({ "name": name, "phase": phase or "Unknown", "reason": reason or "NotReady", }) print(json.dumps({ "pvcPending": pvc_pending, "podsNotReady": not_ready, "podsTerminating": terminating, })) PY } wait_for_supabase_ready() { local ns="$1" local timeout_s="$2" local kube_context="${3:-}" local scope_label="${4:-Supabase}" local selector="${5:-}" local start start=$(date +%s) local poll_s poll_s="${SUPABASE_DEPLOY_POLL_SECONDS:-10}" if [[ -z "$poll_s" ]]; then poll_s=10 fi local context_hint="current-context" if [[ -n "$kube_context" ]]; then context_hint="$kube_context" fi local selector_hint="" if [[ -n "$selector" ]]; then selector_hint=" (selector=${selector})" fi log "Waiting for ${scope_label} readiness in namespace '${ns}' (context=${context_hint}${selector_hint}, timeout=${timeout_s}s)..." while true; do local now elapsed now=$(date +%s) elapsed=$((now - start)) local status_json if ! status_json=$(supabase_rollout_status "$ns" "$kube_context" "$selector" 2>/dev/null); then warn "Could not query ${scope_label} rollout status (ns=${ns}, context=${context_hint}); retrying..." if (( elapsed >= timeout_s )); then return 1 fi sleep "$poll_s" continue fi # Targeted blocked-state checks check_supabase_minio_blocked "$ns" "$kube_context" check_supabase_pvc_quota_blocked "$ns" "$kube_context" local pending_pvcs not_ready_pods terminating_pods pvc_blockers pod_blockers status_metrics status_metrics=$(STATUS_JSON="$status_json" python3 - <<'PY' import json import os data = json.loads(os.environ.get("STATUS_JSON") or "{}") pvc_pending = data.get("pvcPending") or [] pods_not_ready = data.get("podsNotReady") or [] pods_terminating = int(data.get("podsTerminating") or 0) def fmt_pvc(item): if isinstance(item, dict): return ( f"{item.get('name', '?')}(phase={item.get('phase') or '?'}," f"sc={item.get('storageClass') or ''}," f"reason={item.get('reason') or 'not-bound'})" ) return str(item) def fmt_pod(item): if isinstance(item, dict): return ( f"{item.get('name', '?')}(phase={item.get('phase') or '?'}," f"reason={item.get('reason') or 'not-ready'})" ) return str(item) print(len(pvc_pending)) print(len(pods_not_ready)) print(pods_terminating) print("; ".join(fmt_pvc(item) for item in pvc_pending) if pvc_pending else "-") print("; ".join(fmt_pod(item) for item in pods_not_ready) if pods_not_ready else "-") PY ) local status_lines=() mapfile -t status_lines <<<"$status_metrics" pending_pvcs="${status_lines[0]:-0}" not_ready_pods="${status_lines[1]:-0}" terminating_pods="${status_lines[2]:-0}" pvc_blockers="${status_lines[3]:--}" pod_blockers="${status_lines[4]:--}" if (( pending_pvcs == 0 && not_ready_pods == 0 && terminating_pods == 0 )); then log "${scope_label} is Ready (all PVCs bound; all pods ready; context=${context_hint})." return 0 fi if (( elapsed >= timeout_s )); then warn "${scope_label} readiness timed out after ${elapsed}s (context=${context_hint}, pendingPVCs=${pending_pvcs}, notReadyPods=${not_ready_pods}, terminatingPods=${terminating_pods})" warn "Blocking PVCs: ${pvc_blockers}" warn "Blocking Pods: ${pod_blockers}" return 1 fi if (( elapsed % 30 == 0 )); then log "Still waiting (${scope_label}): elapsed=${elapsed}s context=${context_hint} pendingPVCs=${pending_pvcs} notReadyPods=${not_ready_pods} terminatingPods=${terminating_pods}" log " pending PVCs: ${pvc_blockers}" log " pending pods: ${pod_blockers}" fi sleep "$poll_s" done } detect_platform() { case "$(uname -m 2>/dev/null || true)" in arm64|aarch64) echo "linux/arm64" ;; x86_64|amd64) echo "linux/amd64" ;; *) echo "linux/amd64" ;; esac } normalize_platform() { case "${1:-}" in linux/*) echo "$1" ;; arm64|aarch64) echo "linux/arm64" ;; x86_64|amd64) echo "linux/amd64" ;; "") detect_platform ;; *) detect_platform ;; esac } compose_cmd() { if docker compose version >/dev/null 2>&1; then echo "docker compose" return 0 fi if command -v docker-compose >/dev/null 2>&1; then echo "docker-compose" return 0 fi die "Docker Compose not found (expected 'docker compose' or 'docker-compose')" } ensure_k3d() { require_cmd k3d } ensure_kompose() { require_cmd kompose } ensure_helm() { require_cmd helm } list_k3d_clusters() { local json="" if json="$(k3d cluster list -o json 2>/dev/null)"; then if [[ -n "$json" ]]; then if printf '%s' "$json" | python - <<'PY' import json import sys raw = sys.stdin.read() if not raw.strip(): sys.exit(1) try: data = json.loads(raw) except Exception: sys.exit(1) items = data.get("items") if isinstance(data, dict) else data if not items: sys.exit(0) for item in items: name = item.get("name") if isinstance(item, dict) else None if name: print(name) PY then return 0 fi fi fi k3d cluster list 2>/dev/null | awk 'NR>1 {print $1}' } cluster_exists() { local name="$1" list_k3d_clusters | awk -v target="$name" '$0 == target {found=1} END {exit found ? 0 : 1}' } ensure_k3d_cluster() { local cluster="${K3D_CLUSTER_NAME:-}" local -a clusters local picked="" mapfile -t clusters < <(list_k3d_clusters || true) if [[ -n "$cluster" ]]; then if ! cluster_exists "$cluster"; then log "Creating k3d cluster '$cluster'..." k3d cluster create "$cluster" >/dev/null fi picked="$cluster" elif [[ ${#clusters[@]} -gt 0 ]]; then for name in "${clusters[@]}"; do if [[ "$name" == "k3s-default" ]]; then picked="$name" break fi done if [[ -z "$picked" ]]; then picked="${clusters[0]}" fi else picked="k3s-default" log "Creating k3d cluster '$picked'..." k3d cluster create "$picked" >/dev/null fi export K3D_CLUSTER_NAME="$picked" log "Using k3d cluster: $K3D_CLUSTER_NAME" k3d cluster start "$K3D_CLUSTER_NAME" >/dev/null 2>&1 || true k3d kubeconfig merge "$K3D_CLUSTER_NAME" --switch-context >/dev/null 2>&1 || true } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --mode|-m) MODE="${2:-}" shift 2 ;; -c|--config) PROLE_CFG_PATH="${2:-}" [[ -n "$PROLE_CFG_PATH" ]] || die "Missing value for $1" shift 2 ;; --config=*) PROLE_CFG_PATH="${1#*=}" [[ -n "$PROLE_CFG_PATH" ]] || die "Missing value for $1" shift ;; --with-dev-helpers) USE_DEV_HELPERS="true" shift ;; --helm-template-only) HELM_TEMPLATE_ONLY="true" shift ;; --prefetch-images-only) PREFETCH_IMAGES_ONLY="true" shift ;; --skip-prefetch) SKIP_PREFETCH="true" shift ;; --foreground) FOREGROUND="true" shift ;; -f|--force) FORCE="true" shift ;; --no-force) FORCE="false" shift ;; -h|--help) usage exit 0 ;; *) die "Unknown option: $1" ;; esac done } ensure_env_file() { if [[ -f "$ENV_FILE" ]]; then return 0 fi [[ -f "$ENV_EXAMPLE" ]] || die "Missing env example file: $ENV_EXAMPLE" log "Creating docker/.env from docker/.env.example" cp "$ENV_EXAMPLE" "$ENV_FILE" warn "docker/.env contains default secrets. Update them before any production use." } compose_files() { local -a files=("-f" "docker-compose.yml") if [[ "$USE_DEV_HELPERS" == "true" ]]; then [[ -f "$DEV_COMPOSE_FILE" ]] || die "Missing dev helpers compose file: $DEV_COMPOSE_FILE" files+=("-f" "dev/docker-compose.dev.yml") fi echo "${files[@]}" } ensure_repo() { require_cmd git mkdir -p "$DEV_HOME" if [[ -d "$SUPABASE_DIR/.git" ]]; then log "Updating Supabase repo in $SUPABASE_DIR" (cd "$SUPABASE_DIR" && git pull) return 0 fi if [[ -e "$SUPABASE_DIR" ]]; then die "Path exists but is not a git repo: $SUPABASE_DIR" fi log "Cloning Supabase repo into $SUPABASE_DIR" git clone https://github.com/supabase/supabase.git "$SUPABASE_DIR" } run_local() { ensure_repo [[ -f "$COMPOSE_FILE" ]] || die "Missing compose file: $COMPOSE_FILE" require_cmd docker docker info >/dev/null 2>&1 || die "Docker daemon is not running" local compose compose="$(compose_cmd)" log "knoe::supabase local deploy (Docker Compose)" log "Repo: $SUPABASE_DIR" log "This follows README/DEVELOPERS guidance for running the full open-source stack." log "Namespace: supabase (Compose project name)" ensure_env_file local compose_args=("-f" "docker-compose.yml") if [[ "$USE_DEV_HELPERS" == "true" ]]; then [[ -f "$DEV_COMPOSE_FILE" ]] || die "Missing dev helpers compose file: $DEV_COMPOSE_FILE" compose_args+=("-f" "dev/docker-compose.dev.yml") fi local up_args=("up") if [[ "$FOREGROUND" != "true" ]]; then up_args+=("-d") fi log "Starting Supabase services..." (cd "$DOCKER_DIR" && COMPOSE_PROJECT_NAME="supabase" $compose "${compose_args[@]}" "${up_args[@]}") log "Deployment complete." log "Access Studio at http://localhost:8082 (see docker/.env for ports)." } image_safe_name() { echo "$1" | sed 's/[\/:@]/_/g' } image_platform_tag() { local img="$1" local platform="$2" local suffix="${platform//\//-}" local name tag if [[ "$img" == *@* ]]; then name="${img%@*}" tag="digest-${suffix}" elif [[ "$img" == *:* ]]; then name="${img%:*}" tag="${img##*:}-${suffix}" else name="$img" tag="latest-${suffix}" fi echo "${name}:${tag}" } ensure_image_artifact() { local img="$1" local platform="$2" local safe_name local tar_path local platform_tag safe_name="$(image_safe_name "$img")" tar_path="${DOCKER_IMPORT_DIR}/${safe_name}.${platform//\//-}.tar" platform_tag="$(image_platform_tag "$img" "$platform")" if [[ -f "$tar_path" ]]; then return 0 fi mkdir -p "$DOCKER_IMPORT_DIR" # Prefer buildx export to reliably produce single-arch tarballs with containerd-backed Docker if docker buildx version >/dev/null 2>&1; then log "Fetching image $img for platform $platform (buildx export)" if ! printf 'FROM %s\n' "$img" \ | docker buildx build --pull --platform "$platform" -t "$platform_tag" \ --output "type=docker,dest=$tar_path" - >/dev/null 2>&1; then die "Failed to export image $img ($platform) via buildx" fi return 0 fi # Fallback: traditional pull + save (may fail on some Docker/containerd combos) log "Fetching image $img for platform $platform" if ! docker pull --platform "$platform" "$img" >/dev/null 2>&1; then if docker image inspect "$img" >/dev/null 2>&1; then warn "Using local image for $img (pull failed for $platform)" else die "Failed to pull image for $img ($platform)" fi fi docker tag "$img" "$platform_tag" >/dev/null 2>&1 || true log "Saving $img ($platform) to $tar_path" if ! docker save -o "$tar_path" "$platform_tag" >/dev/null 2>&1; then die "Failed to save image $img ($platform) to $tar_path" fi } load_image_for_platform() { local img="$1" local platform="$2" local safe_name local tar_path local platform_tag safe_name="$(image_safe_name "$img")" tar_path="${DOCKER_IMPORT_DIR}/${safe_name}.${platform//\//-}.tar" platform_tag="$(image_platform_tag "$img" "$platform")" if [[ ! -f "$tar_path" ]]; then ensure_image_artifact "$img" "$platform" fi log "Loading $img ($platform) from $tar_path" docker load -i "$tar_path" 2>&1 | sed 's/^/ /' docker tag "$platform_tag" "$img" } # Returns 0 if the image is already present in k3d's containerd store, 1 otherwise. image_in_k3d() { local img="$1" local cluster="${K3D_CLUSTER_NAME:-}" local node="k3d-${cluster}-server-0" if ! docker inspect "$node" >/dev/null 2>&1; then return 1 fi docker exec "$node" crictl images --no-trunc -o json 2>/dev/null \ | python3 - "$img" <<'PY' import json, sys target = sys.argv[1] try: data = json.load(sys.stdin) except Exception: sys.exit(1) for entry in data.get('images', []): for tag in entry.get('repoTags', []): if tag == target: sys.exit(0) sys.exit(1) PY } # Build the list of images already present in k3d's containerd store. list_k3d_images() { local cluster="${K3D_CLUSTER_NAME:-}" local node="k3d-${cluster}-server-0" if ! docker inspect "$node" >/dev/null 2>&1; then return 0 fi docker exec "$node" crictl images --no-trunc -o json 2>/dev/null \ | python3 - <<'PY' import json, sys try: data = json.load(sys.stdin) except Exception: sys.exit(0) for entry in data.get('images', []): for tag in entry.get('repoTags', []): print(tag) PY } write_artifact_manifest() { local images="$1" local list_path="${DOCKER_IMPORT_DIR}/supabase-images.txt" mkdir -p "$DOCKER_IMPORT_DIR" printf "%s\n" $images > "$list_path" local platform for platform in $SUPABASE_IMAGE_PLATFORMS; do local platform_list="${DOCKER_IMPORT_DIR}/supabase-images-${platform//\//-}.txt" : > "$platform_list" for img in $images; do local safe_name safe_name="$(image_safe_name "$img")" echo "${DOCKER_IMPORT_DIR}/${safe_name}.${platform//\//-}.tar" >> "$platform_list" done done } prefetch_k3d_images() { require_cmd docker docker info >/dev/null 2>&1 || die "Docker daemon is not running" local compose compose="$(compose_cmd)" ensure_env_file local files files=($(compose_files)) local images images=$(cd "$DOCKER_DIR" && $compose "${files[@]}" --env-file ".env" config --images | awk 'NF' | sort -u) if [[ -z "$images" ]]; then die "No images found in Supabase compose config" fi log "Supabase images discovered:" printf " - %s\n" $images write_artifact_manifest "$images" log "Image artifact list written to $DOCKER_IMPORT_DIR/supabase-images.txt" local platform for platform in $SUPABASE_IMAGE_PLATFORMS; do log "Ensuring artifacts for platform '$platform' in $DOCKER_IMPORT_DIR" for img in $images; do ensure_image_artifact "$img" "$platform" done done local deploy_platform deploy_platform="$(normalize_platform "${SUPABASE_IMAGE_PLATFORM:-}")" log "Loading images for platform '$deploy_platform'" for img in $images; do load_image_for_platform "$img" "$deploy_platform" done # Build a list of images already present in k3d to avoid re-importing them. log "Checking k3d registry for already-imported images..." local k3d_existing k3d_existing="$(list_k3d_images || true)" local -a import_delta=() local -a skipped=() for img in $images; do if printf '%s\n' "$k3d_existing" | grep -qxF "$img"; then skipped+=("$img") else import_delta+=("$img") fi done if [[ ${#skipped[@]} -gt 0 ]]; then log "Already in k3d registry (skipping ${#skipped[@]} image(s)):" printf " [skip] %s\n" "${skipped[@]}" fi if [[ ${#import_delta[@]} -eq 0 ]]; then log "All images already present in k3d registry; nothing to import." return 0 fi log "Importing ${#import_delta[@]} image(s) into k3d cluster '${K3D_CLUSTER_NAME:-}' (delta only)..." local idx=0 for img in "${import_delta[@]}"; do idx=$(( idx + 1 )) log " [${idx}/${#import_delta[@]}] Importing $img into k3d..." if [[ -n "${K3D_CLUSTER_NAME:-}" ]]; then k3d image import "$img" -c "$K3D_CLUSTER_NAME" else k3d image import "$img" fi log " [${idx}/${#import_delta[@]}] Done: $img" done log "k3d image import complete (${#import_delta[@]} imported, ${#skipped[@]} already present)." } resolve_knoe_db_namespace() { if [[ -n "${KNOE_DB_NAMESPACE:-}" ]]; then return 0 fi if [[ -n "${DATABASE_NAMESPACE:-}" ]]; then KNOE_DB_NAMESPACE="$DATABASE_NAMESPACE" return 0 fi if [[ -n "${NAMESPACE:-}" ]]; then KNOE_DB_NAMESPACE="$NAMESPACE" return 0 fi if db_kubectl get namespace knoe-db >/dev/null 2>&1; then KNOE_DB_NAMESPACE="knoe-db" return 0 fi if db_kubectl get namespace knoe >/dev/null 2>&1; then KNOE_DB_NAMESPACE="knoe" else KNOE_DB_NAMESPACE="default" fi } resolve_db_cluster_kubecontext() { local db_ctx="${DB_CLUSTER_KUBECONTEXT:-}" if [[ -n "$db_ctx" ]]; then printf '%s' "$db_ctx" return 0 fi if [[ -n "${PROLE_CFG_PATH:-}" && -f "${PROLE_CFG_PATH}" ]]; then db_ctx=$(python3 - "${PROLE_CFG_PATH}" <<'PY' import configparser import sys cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str cfg.read(sys.argv[1]) for section, key in ( ("Inputs", "init_cluster.db_cluster_kubecontext"), ("Inputs", "env_setup.DB_CLUSTER_KUBECONTEXT"), ("Global", "DB_CLUSTER_KUBECONTEXT"), ): if cfg.has_option(section, key): v = cfg.get(section, key, fallback="").strip() if v: print(v) break PY ) fi printf '%s' "$db_ctx" } db_kubectl() { local db_ctx db_ctx="$(resolve_db_cluster_kubecontext)" if [[ -n "$db_ctx" ]]; then if kubectl config get-contexts "$db_ctx" >/dev/null 2>&1; then kubectl --context "$db_ctx" "$@" return $? fi if [[ "${_DB_KUBECTL_WARNED:-}" != "true" ]]; then warn "DB cluster kubecontext '${db_ctx}' is not available in kubeconfig; using current context for DB operations." _DB_KUBECTL_WARNED="true" fi fi kubectl "$@" } patch_db_deployment_port() { local file="$SUPABASE_K8S_DIR/db-deployment.yaml" if [[ ! -f "$file" ]]; then return 0 fi local port="$SUPABASE_POSTGRES_PORT" python - "$file" "$port" <<'PY' import re import sys path = sys.argv[1] port = sys.argv[2] with open(path, "r", encoding="utf-8") as fh: data = fh.read() data = re.sub(r"(name:\s*PGPORT\s*\n\s*value:\s*)\"[^\"]+\"", rf"\1\"{port}\"", data) data = re.sub(r"(name:\s*POSTGRES_PORT\s*\n\s*value:\s*)\"[^\"]+\"", rf"\1\"{port}\"", data) with open(path, "w", encoding="utf-8") as fh: fh.write(data) PY } write_supabase_postgres_service() { local file="$SUPABASE_K8S_DIR/supabase-postgres-service.yaml" cat > "$file" < "$file" < emptyDir (postgres needs writable data dir) local db_deploy="$SUPABASE_K8S_DIR/db-deployment.yaml" if [[ -f "$db_deploy" ]]; then python3 - "$db_deploy" <<'PYFIX' import sys, re path = sys.argv[1] with open(path, 'r') as f: content = f.read() # Replace the db-cm4 configMap volume definition with emptyDir # Matches: - configMap:\n name: db-cm4\n name: db-cm4 content = re.sub( r'(\s+)-\s+configMap:\s*\n\s+name:\s*db-cm4\s*\n(\s+name:\s*db-cm4)', r'\1- emptyDir: {}\n\2', content ) # Also handle the alternate format kompose might produce content = re.sub( r'(\s+)-\s+configMap:\s*\n\s+defaultMode:\s*\d+\s*\n\s+name:\s*db-cm4\s*\n(\s+name:\s*db-cm4)', r'\1- emptyDir: {}\n\2', content ) with open(path, 'w') as f: f.write(content) print(f" [OK] db data volume -> emptyDir") PYFIX fi # 2. Remove db-cm4 configmap (no longer needed; postgres inits its own data dir) local db_cm4="$SUPABASE_K8S_DIR/db-cm4-configmap.yaml" if [[ -f "$db_cm4" ]]; then rm -f "$db_cm4" log " Removed db-cm4-configmap.yaml (data dir handled by emptyDir)" fi # 3. Scale vector to 0 replicas (docker_logs source needs docker.sock, unavailable in k8s) local vector_deploy="$SUPABASE_K8S_DIR/vector-deployment.yaml" if [[ -f "$vector_deploy" ]]; then python3 - "$vector_deploy" <<'PYFIX' import sys, re path = sys.argv[1] with open(path, 'r') as f: content = f.read() content = re.sub(r'(spec:\s*\n\s+replicas:)\s*\d+', r'\1 0', content, count=1) with open(path, 'w') as f: f.write(content) print(" [OK] vector replicas -> 0 (docker_logs incompatible with k8s)") PYFIX fi # 4. Scale functions to 0 replicas (edge-runtime needs function files not present in k8s) local functions_deploy="$SUPABASE_K8S_DIR/functions-deployment.yaml" if [[ -f "$functions_deploy" ]]; then python3 - "$functions_deploy" <<'PYFIX' import sys, re path = sys.argv[1] with open(path, 'r') as f: content = f.read() content = re.sub(r'(spec:\s*\n\s+replicas:)\s*\d+', r'\1 0', content, count=1) with open(path, 'w') as f: f.write(content) print(" [OK] functions replicas -> 0 (edge-runtime entrypoint not available in k8s)") PYFIX fi # 6. Scale db deployment to 0 replicas (using knoe-db instead of supabase standalone postgres) if [[ -f "$db_deploy" ]]; then python3 - "$db_deploy" <<'PYFIX' import sys, re path = sys.argv[1] with open(path, 'r') as f: content = f.read() content = re.sub(r'(spec:\s*\n\s+replicas:)\s*\d+', r'\1 0', content, count=1) with open(path, 'w') as f: f.write(content) print(" [OK] db replicas -> 0 (using knoe-db as database backend)") PYFIX fi # 7. Fix liveness probes that reference Docker Compose service hostnames # In Docker Compose, service names resolve via internal DNS, but in k8s # there is no matching Service for every container. Rewrite probes to # use localhost (the probe checks the container's own health endpoint). local storage_deploy="$SUPABASE_K8S_DIR/storage-deployment.yaml" if [[ -f "$storage_deploy" ]]; then python3 - "$storage_deploy" <<'PYFIX' import sys path = sys.argv[1] with open(path, 'r') as f: content = f.read() content = content.replace('http://storage:5000/status', 'http://localhost:5000/status') with open(path, 'w') as f: f.write(content) print(" [OK] storage liveness probe -> localhost:5000 (no k8s Service needed)") PYFIX fi # 8. Fix studio liveness probe: studio:3000 -> localhost:3000 local studio_deploy="$SUPABASE_K8S_DIR/studio-deployment.yaml" if [[ -f "$studio_deploy" ]]; then python3 - "$studio_deploy" <<'PYFIX' import sys path = sys.argv[1] with open(path, 'r') as f: content = f.read() content = content.replace('http://studio:3000/', 'http://localhost:3000/') with open(path, 'w') as f: f.write(content) print(" [OK] studio liveness probe -> localhost:3000") PYFIX fi # 9. Fix realtime liveness probe: kompose emits the whole curl command as a # single argv entry, and unquoted `Authorization: ...` fragments can be # parsed as YAML mappings. Sanitize the probe so Kubernetes receives a # proper list of strings. local realtime_deploy="$SUPABASE_K8S_DIR/realtime-deployment.yaml" if [[ -f "$realtime_deploy" ]]; then python3 "$SCRIPT_DIR/patch_realtime_probe.py" "$realtime_deploy" fi } patch_supabase_credentials() { log "Patching placeholder credentials in Supabase manifests..." local pg_password="" local jwt_secret="" # Resolve postgres password from knoe-db-superuser secret resolve_knoe_db_namespace local ns="${KNOE_DB_NAMESPACE:-${NAMESPACE:-default}}" if kubectl get secret knoe-db-superuser -n "$ns" >/dev/null 2>&1; then pg_password=$(kubectl get secret knoe-db-superuser -n "$ns" \ -o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || true) fi if [[ -z "$pg_password" ]]; then pg_password="${POSTGRES_PASSWORD:-}" fi # Resolve JWT secret from supabase-jwt secret or env if kubectl get secret supabase-jwt -n supabase >/dev/null 2>&1; then jwt_secret=$(kubectl get secret supabase-jwt -n supabase \ -o jsonpath='{.data.jwt-secret}' 2>/dev/null | base64 -d 2>/dev/null || true) fi if [[ -z "$jwt_secret" ]]; then local env_file="${ENV_FILE:-}" if [[ -f "$env_file" ]]; then jwt_secret=$(grep -E '^JWT_SECRET=' "$env_file" | head -1 | cut -d= -f2- | tr -d "'\"" || true) fi fi if [[ -z "$jwt_secret" ]]; then jwt_secret="${JWT_SECRET:-}" fi if [[ -z "$pg_password" ]]; then warn "Could not resolve postgres password; manifests will keep placeholder credentials." warn "Set POSTGRES_PASSWORD or ensure knoe-db-superuser secret exists in namespace '$ns'." return 0 fi # Replace placeholder values in all deployment manifests local placeholder_pw="your-super-secret-and-long-postgres-password" local placeholder_jwt="your-super-secret-jwt-token-with-at-least-32-characters-long" local f for f in "$SUPABASE_K8S_DIR"/*-deployment.yaml; do [[ -f "$f" ]] || continue python3 - "$f" "$pg_password" "$jwt_secret" "$placeholder_pw" "$placeholder_jwt" <<'PYFIX' import sys path = sys.argv[1] pg_pw = sys.argv[2] jwt_sec = sys.argv[3] ph_pw = sys.argv[4] ph_jwt = sys.argv[5] with open(path, 'r') as fh: content = fh.read() changed = False if ph_pw in content and pg_pw: content = content.replace(ph_pw, pg_pw) changed = True if ph_jwt in content and jwt_sec: content = content.replace(ph_jwt, jwt_sec) changed = True if changed: with open(path, 'w') as fh: fh.write(content) PYFIX done log " Credentials patched in Supabase manifests." } setup_knoe_db_for_supabase() { log "Ensuring Supabase roles, schemas and databases exist in knoe-db..." resolve_knoe_db_namespace local ns="${KNOE_DB_NAMESPACE:-${NAMESPACE:-default}}" # Find the primary CNPG pod local primary primary=$(db_kubectl -n "$ns" get pods \ -l "cnpg.io/cluster=knoe-db,cnpg.io/instanceRole=primary" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [[ -z "$primary" ]]; then primary=$(db_kubectl -n "$ns" get pods -l "cnpg.io/cluster=knoe-db" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) fi if [[ -z "$primary" ]]; then warn "No knoe-db pod found in namespace '$ns'; skipping Supabase DB setup." return 0 fi local pg_password="" if db_kubectl get secret knoe-db-superuser -n "$ns" >/dev/null 2>&1; then pg_password=$(db_kubectl get secret knoe-db-superuser -n "$ns" \ -o jsonpath='{.data.password}' 2>/dev/null | base64 -d 2>/dev/null || true) fi if [[ -z "$pg_password" ]]; then pg_password="${POSTGRES_PASSWORD:-}" fi if [[ -z "$pg_password" ]]; then warn "Cannot resolve postgres password; skipping Supabase DB role setup." return 0 fi # Create roles required by Supabase services db_kubectl -n "$ns" exec -i "$primary" -c postgres -- psql -U postgres -d postgres -c " -- Drop stale storage migration tracking tables so storage reinitializes cleanly. -- Truncate is insufficient when prior failed starts left inconsistent duplicate rows. DROP TABLE IF EXISTS public.migrations CASCADE; DROP TABLE IF EXISTS storage.migrations CASCADE; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='anon') THEN CREATE ROLE anon NOLOGIN; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='authenticated') THEN CREATE ROLE authenticated NOLOGIN; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='service_role') THEN CREATE ROLE service_role NOLOGIN; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='authenticator') THEN CREATE ROLE authenticator LOGIN PASSWORD '${pg_password}'; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='pgbouncer') THEN CREATE ROLE pgbouncer LOGIN PASSWORD '${pg_password}'; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='knoe') THEN CREATE ROLE knoe LOGIN PASSWORD '${pg_password}' NOSUPERUSER NOCREATEDB NOCREATEROLE; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='knoe_catalog_executor') THEN CREATE ROLE knoe_catalog_executor NOLOGIN; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='supabase_admin') THEN CREATE ROLE supabase_admin LOGIN PASSWORD '${pg_password}' SUPERUSER; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='supabase_auth_admin') THEN CREATE ROLE supabase_auth_admin LOGIN PASSWORD '${pg_password}' NOINHERIT; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='supabase_storage_admin') THEN CREATE ROLE supabase_storage_admin LOGIN PASSWORD '${pg_password}' NOINHERIT; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='supabase_functions_admin') THEN CREATE ROLE supabase_functions_admin LOGIN PASSWORD '${pg_password}' NOINHERIT; END IF; END \$\$; DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='supabase_read_only_user') THEN CREATE ROLE supabase_read_only_user NOLOGIN; END IF; END \$\$; -- Fix search_path for supabase_storage_admin so migration reads and writes use the same schema. -- Without this, the service queries auth.migrations (empty) but inserts into public.migrations, -- causing duplicate-key errors on every restart after the first successful migration run. ALTER ROLE supabase_storage_admin SET search_path = storage, auth, public; ALTER ROLE supabase_storage_admin BYPASSRLS; -- Always sync service role passwords to current postgres password (idempotent) ALTER ROLE authenticator PASSWORD '${pg_password}'; ALTER ROLE pgbouncer PASSWORD '${pg_password}'; ALTER ROLE knoe WITH NOSUPERUSER NOCREATEDB NOCREATEROLE; ALTER ROLE knoe PASSWORD '${pg_password}'; ALTER ROLE knoe SET search_path = knoe, public; ALTER ROLE supabase_admin PASSWORD '${pg_password}'; ALTER ROLE supabase_auth_admin PASSWORD '${pg_password}'; ALTER ROLE supabase_storage_admin PASSWORD '${pg_password}'; ALTER ROLE supabase_functions_admin PASSWORD '${pg_password}'; -- External CNPG installs may lock down database-level CREATE by default. -- Storage startup migrations run CREATE SCHEMA IF NOT EXISTS and fail without this. GRANT CONNECT, TEMPORARY ON DATABASE postgres TO supabase_storage_admin; GRANT CREATE ON DATABASE postgres TO supabase_storage_admin; -- Grant membership GRANT anon TO authenticator; GRANT authenticated TO authenticator; GRANT service_role TO authenticator; GRANT supabase_admin TO authenticator; -- supabase-storage runs `SET LOCAL role = 'service_role'` on every request -- (and 'authenticated' / 'anon' depending on the JWT). Without these grants -- the SET fails with 42501 inside guc.c:call_string_check_hook, which the -- service surfaces as a misleading \"new row violates row-level security -- policy\" error -- regardless of whether RLS is even involved. Mirrors what -- the upstream supabase/postgres image's bootstrap does for `authenticator`. GRANT anon, authenticated, service_role TO supabase_storage_admin; -- Schemas on postgres database CREATE SCHEMA IF NOT EXISTS knoe AUTHORIZATION knoe; CREATE SCHEMA IF NOT EXISTS auth AUTHORIZATION supabase_auth_admin; CREATE SCHEMA IF NOT EXISTS storage AUTHORIZATION supabase_storage_admin; -- Idempotent: if storage schema pre-existed with a different owner (e.g. postgres), -- CREATE SCHEMA IF NOT EXISTS is a no-op and ownership is NOT transferred. -- Explicitly grant so supabase_storage_admin can create its migration tables there. GRANT USAGE, CREATE ON SCHEMA storage TO supabase_storage_admin; CREATE SCHEMA IF NOT EXISTS graphql_public; CREATE SCHEMA IF NOT EXISTS _realtime; ALTER SCHEMA _realtime OWNER TO postgres; GRANT USAGE, CREATE ON SCHEMA public TO supabase_storage_admin; GRANT USAGE, CREATE ON SCHEMA knoe TO knoe; GRANT USAGE ON SCHEMA knoe TO anon, authenticated, service_role, authenticator, supabase_admin, supabase_auth_admin, supabase_storage_admin, supabase_functions_admin; GRANT USAGE ON SCHEMA knoe TO knoe_catalog_executor; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA knoe TO knoe_catalog_executor; ALTER DEFAULT PRIVILEGES FOR ROLE knoe IN SCHEMA knoe GRANT EXECUTE ON FUNCTIONS TO knoe_catalog_executor; GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role; GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role; GRANT USAGE ON SCHEMA storage TO anon, authenticated, service_role; GRANT USAGE ON SCHEMA graphql_public TO anon, authenticated, service_role; -- The `extensions` schema is created by the CNPG postInitTemplateSQL block -- (see deploy/gcp/gke/knoe-db.yaml) so relocatable extensions like -- pg_stat_statements live there instead of `public` (Supabase's Database -- Advisor flags extensions in public as a Security warning). The supabase -- roles need USAGE so PostgREST/Studio queries that walk the schema graph -- (e.g. the advisor itself) don't 42501 on it. GRANT USAGE ON SCHEMA extensions TO anon, authenticated, service_role; -- Keep extension objects out of public schema to satisfy database linter checks. DO \$\$ DECLARE ext_name text; BEGIN FOREACH ext_name IN ARRAY ARRAY['pg_tde','pgcrypto','postgis','vector','tds_fdw'] LOOP IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = ext_name) THEN BEGIN EXECUTE format('ALTER EXTENSION %I SET SCHEMA knoe', ext_name); EXCEPTION WHEN OTHERS THEN RAISE NOTICE 'Could not move extension % to schema knoe: %', ext_name, SQLERRM; END; END IF; END LOOP; END \$\$; GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA knoe TO anon, authenticated, service_role, authenticator, supabase_admin, supabase_auth_admin, supabase_storage_admin, supabase_functions_admin; -- Resolve Supabase lint error: ensure RLS is enabled on PostGIS spatial_ref_sys in its extension schema. DO \$\$ DECLARE postgis_schema text; BEGIN SELECT n.nspname INTO postgis_schema FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = 'postgis'; IF postgis_schema IS NOT NULL AND to_regclass(format('%I.spatial_ref_sys', postgis_schema)) IS NOT NULL THEN EXECUTE format( 'GRANT SELECT ON TABLE %I.spatial_ref_sys TO anon, authenticated, service_role, authenticator, supabase_admin, supabase_auth_admin, supabase_storage_admin, supabase_functions_admin', postgis_schema ); EXECUTE format('ALTER TABLE %I.spatial_ref_sys ENABLE ROW LEVEL SECURITY', postgis_schema); IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = postgis_schema AND tablename = 'spatial_ref_sys' AND policyname = 'spatial_ref_sys_select_all' ) THEN EXECUTE format( 'CREATE POLICY spatial_ref_sys_select_all ON %I.spatial_ref_sys FOR SELECT USING (true)', postgis_schema ); END IF; END IF; END \$\$; " 2>&1 || warn "Could not set up Supabase roles (cluster may not be ready yet)." # Create _supabase database (used by supavisor, analytics) local db_exists db_exists=$(db_kubectl -n "$ns" exec "$primary" -c postgres -- psql -U postgres -d postgres -tAc \ "SELECT 1 FROM pg_database WHERE datname = '_supabase';" 2>/dev/null || true) if [[ "$db_exists" != "1" ]]; then db_kubectl -n "$ns" exec "$primary" -c postgres -- psql -U postgres -d postgres -c \ "CREATE DATABASE _supabase OWNER postgres;" 2>&1 || warn "Could not create _supabase database." fi # Create schemas in _supabase database db_kubectl -n "$ns" exec "$primary" -c postgres -- psql -U postgres -d _supabase -c " CREATE SCHEMA IF NOT EXISTS knoe AUTHORIZATION knoe; GRANT USAGE, CREATE ON SCHEMA knoe TO knoe; CREATE SCHEMA IF NOT EXISTS _supavisor; ALTER SCHEMA _supavisor OWNER TO postgres; CREATE SCHEMA IF NOT EXISTS _analytics; ALTER SCHEMA _analytics OWNER TO postgres; " 2>&1 || warn "Could not create _supabase schemas." log " Supabase database roles and schemas ready." } generate_k8s_manifests() { ensure_kompose ensure_env_file local files files=($(compose_files)) rm -rf "$SUPABASE_K8S_DIR" mkdir -p "$SUPABASE_K8S_DIR" log "Generating Kubernetes manifests from docker-compose.yml" (cd "$DOCKER_DIR" && kompose "${files[@]}" -n supabase -o "$SUPABASE_K8S_DIR" --volumes=configMap --suppress-warnings convert) fix_k8s_manifests resolve_knoe_db_namespace write_db_alias_service patch_db_deployment_port write_supabase_postgres_service patch_supabase_credentials sanitize_container_names } sanitize_container_names() { log "Sanitizing container names in manifests..." local files files=("$SUPABASE_K8S_DIR"/*-deployment.yaml) for file in "${files[@]}"; do [[ -f "$file" ]] || continue # Replace dots with hyphens in container names (spec.template.spec.containers[].name) python - "$file" <<'PY' import sys import re path = sys.argv[1] with open(path, 'r', encoding='utf-8') as f: content = f.read() def replace_dots(match): prefix = match.group(1) name = match.group(2) if '.' in name: return prefix + name.replace('.', '-') return match.group(0) # Match 'name: some.container.name' with at least 4 spaces of indentation # This helps avoid metadata.name which usually has 2 spaces. new_content = re.sub(r'(\s{4,}name:\s+)([^\n]+)', replace_dots, content) if new_content != content: with open(path, 'w', encoding='utf-8') as f: f.write(new_content) PY done } helm_render_values() { local renderer="$PROJECT_ROOT/supabase/helm/render_supabase.py" [[ -f "$renderer" ]] || die "Renderer not found: $renderer" ensure_cross_cluster_db_host local cfg_arg=() if [[ -n "$PROLE_CFG_PATH" ]]; then cfg_arg=("-c" "$PROLE_CFG_PATH") fi # Resolve the live postgres password from the CNPG secret so the renderer # always uses the actual cluster password rather than the (potentially stale) # value stored in knoe.cfg. local _live_pg_pw _live_pg_pw=$(db_kubectl get secret knoe-db-superuser \ -n "${DATABASE_NAMESPACE:-knoe-db}" \ -o jsonpath='{.data.password}' 2>/dev/null | base64 --decode 2>/dev/null || true) if [[ -n "$_live_pg_pw" ]]; then export DB_PASSWORD="$_live_pg_pw" fi # Resolve Garage S3 credentials from the live k8s secret so the renderer # always has concrete values regardless of knoe.cfg state. if [[ -z "${GARAGE_S3_KEY_ID:-}" ]]; then local _live_garage_key_id _live_garage_key_id=$(db_kubectl get secret knoe-db-barman-s3 \ -n "${DATABASE_NAMESPACE:-knoe-db}" \ -o jsonpath='{.data.ACCESS_KEY_ID}' 2>/dev/null | base64 --decode 2>/dev/null || true) if [[ -n "$_live_garage_key_id" ]]; then export GARAGE_S3_KEY_ID="$_live_garage_key_id" fi fi if [[ -z "${GARAGE_S3_ACCESS_KEY:-}" ]]; then local _live_garage_secret _live_garage_secret=$(db_kubectl get secret knoe-db-barman-s3 \ -n "${DATABASE_NAMESPACE:-knoe-db}" \ -o jsonpath='{.data.SECRET_ACCESS_KEY}' 2>/dev/null | base64 --decode 2>/dev/null || true) if [[ -n "$_live_garage_secret" ]]; then export GARAGE_S3_ACCESS_KEY="$_live_garage_secret" fi fi # Always regenerate — never reuse stale cached values from a previous deploy attempt rm -f "$PROJECT_ROOT/supabase/helm/generated/values.generated.json" log "Rendering Supabase Helm values/manifests..." python3 "$renderer" "${cfg_arg[@]}" \ --output-dir "$PROJECT_ROOT/supabase/helm/generated" \ --manifests-dir "$PROJECT_ROOT/supabase/k8s" } ensure_k8s_supabase_static_pvs() { local storage_class="$1" if [[ "${MODE:-}" != "k8s" ]]; then return 0 fi if [[ -z "${storage_class:-}" || "$storage_class" != "synology-iscsi" ]]; then return 0 fi require_cmd kubectl local node base parent base_name node="${SUPABASE_PV_NODE:-gandalf.knoe.dev}" base="${SUPABASE_PV_BASE_DIR:-${SUPABASE_PV_BASE:-/synology/d005/supabase}}" base="${base%/}" parent="$(dirname "$base")" base_name="$(basename "$base")" log "Ensuring static Supabase PV directories exist on node '${node}' (base=${base})..." local job_ns job_name timeout image job_ns="${SUPABASE_DIRPREP_NAMESPACE:-kube-system}" job_name="${SUPABASE_DIRPREP_JOB_NAME:-knoe-supabase-dirprep}" timeout="${SUPABASE_DIRPREP_TIMEOUT:-120s}" image="${SUPABASE_DIRPREP_IMAGE:-alpine:3.20}" kubectl -n "$job_ns" delete job "$job_name" --ignore-not-found >/dev/null 2>&1 || true cat </dev/null apiVersion: batch/v1 kind: Job metadata: name: ${job_name} namespace: ${job_ns} spec: backoffLimit: 0 template: spec: restartPolicy: Never nodeName: ${node} containers: - name: dirprep image: ${image} securityContext: runAsUser: 0 runAsGroup: 0 command: ["/bin/sh", "-ec"] args: - | BASE_DIR="/host-parent/${base_name}" echo "[dirprep] node=${node} base=${base}" mkdir -p "\${BASE_DIR}" mkdir -p "\${BASE_DIR}/db" "\${BASE_DIR}/deno" "\${BASE_DIR}/functions" "\${BASE_DIR}/snippets" "\${BASE_DIR}/imgproxy" "\${BASE_DIR}/minio" "\${BASE_DIR}/storage" chmod 0777 "\${BASE_DIR}" 2>/dev/null || true chmod 0777 "\${BASE_DIR}/db" "\${BASE_DIR}/deno" "\${BASE_DIR}/functions" "\${BASE_DIR}/snippets" "\${BASE_DIR}/imgproxy" "\${BASE_DIR}/minio" "\${BASE_DIR}/storage" 2>/dev/null || true echo "[dirprep][OK] Prepared ${base}/{db,deno,functions,snippets,imgproxy,minio,storage}" volumeMounts: - name: host-parent mountPath: /host-parent volumes: - name: host-parent hostPath: path: ${parent} type: Directory EOF if ! kubectl -n "$job_ns" wait --for=condition=complete "job/${job_name}" --timeout="$timeout" >/dev/null 2>&1; then warn "Supabase directory preparation job did not complete successfully (job=${job_name} ns=${job_ns})." set +e kubectl -n "$job_ns" logs "job/${job_name}" 2>/dev/null || true kubectl -n "$job_ns" describe "job/${job_name}" 2>/dev/null || true kubectl -n "$job_ns" get pod -l job-name="${job_name}" -o wide 2>/dev/null || true set -e return 1 fi set +e kubectl -n "$job_ns" logs "job/${job_name}" 2>/dev/null || true kubectl -n "$job_ns" delete job "$job_name" --ignore-not-found >/dev/null 2>&1 || true set -e # Static local PVs with Retain reclaimPolicy can get stuck in Released after a namespace reset. # Delete Released PV objects so they can be recreated and rebound to the new PVCs. local pvs=( synology-supabase-db synology-supabase-deno synology-supabase-functions synology-supabase-snippets synology-supabase-imgproxy synology-supabase-minio synology-supabase-storage ) local pv for pv in "${pvs[@]}"; do local phase phase=$(kubectl get pv "$pv" -o jsonpath='{.status.phase}' 2>/dev/null || true) local expected_path="" case "$pv" in synology-supabase-db) expected_path="${base}/db" ;; synology-supabase-deno) expected_path="${base}/deno" ;; synology-supabase-functions) expected_path="${base}/functions" ;; synology-supabase-snippets) expected_path="${base}/snippets" ;; synology-supabase-imgproxy) expected_path="${base}/imgproxy" ;; synology-supabase-minio) expected_path="${base}/minio" ;; synology-supabase-storage) expected_path="${base}/storage" ;; esac local current_path current_node current_path=$(kubectl get pv "$pv" -o jsonpath='{.spec.local.path}' 2>/dev/null || true) current_node=$(kubectl get pv "$pv" -o jsonpath='{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}' 2>/dev/null || true) if [[ "${phase:-}" == "Released" ]]; then warn "PV '$pv' is in Released state; deleting PV object to allow rebind" kubectl delete pv "$pv" --ignore-not-found >/dev/null 2>&1 || true elif [[ -n "$current_path" && -n "$expected_path" && "$current_path" != "$expected_path" ]]; then warn "PV '$pv' path mismatch (current=${current_path}, expected=${expected_path}); recreating PV object" kubectl delete pv "$pv" --ignore-not-found >/dev/null 2>&1 || true elif [[ -n "$current_node" && "$current_node" != "$node" ]]; then warn "PV '$pv' node mismatch (current=${current_node}, expected=${node}); recreating PV object" kubectl delete pv "$pv" --ignore-not-found >/dev/null 2>&1 || true fi done log "Applying static Supabase PersistentVolumes (storageClass=${storage_class}, node=${node})..." cat </dev/null 2>&1; then warn "APP cluster kubecontext '${target_ctx}' is not available in kubeconfig; proceeding with current context." return 0 fi local current_ctx current_ctx="$(kubectl config current-context 2>/dev/null || true)" if [[ "$current_ctx" != "$target_ctx" ]]; then log "Switching kubectl context to APP cluster '${target_ctx}' for Supabase deployment..." kubectl config use-context "$target_ctx" >/dev/null fi export KUBECONTEXT="$target_ctx" } enforce_supabase_workload_node() { local ns="$1" local clear_kind clear_resource clear_supabase_node_constraints() { local clear_ns="$1" for clear_kind in deployment statefulset; do while IFS= read -r clear_resource; do [[ -n "$clear_resource" ]] || continue kubectl -n "$clear_ns" patch "$clear_resource" --type merge -p '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/hostname":null,"knoe.dev/node-role":null,"knoe.org/node-role":null},"affinity":{"nodeAffinity":null},"topologySpreadConstraints":null}}}}' >/dev/null 2>&1 || true done < <(kubectl -n "$clear_ns" get "$clear_kind" -o name 2>/dev/null || true) done } if [[ "${MODE:-}" == "k8s" ]]; then if ! kubectl get nodes -l knoe.dev/node-role=general --no-headers 2>/dev/null | grep -q .; then warn "No nodes found with label 'knoe.dev/node-role=general'; clearing knoe-specific node constraints from Supabase workloads." clear_supabase_node_constraints "$ns" fi fi local node node="${SUPABASE_NODE_SELECTOR:-${SUPABASE_PV_NODE:-}}" if [[ -z "${node:-}" ]]; then return 0 fi if ! kubectl get node "$node" >/dev/null 2>&1; then warn "Supabase node selector '${node}' not found in cluster; clearing hostname selector from Supabase workloads." clear_supabase_node_constraints "$ns" return 0 fi log "Forcing Supabase workloads onto node '${node}'..." local kind resource for kind in deployment statefulset; do while IFS= read -r resource; do [[ -n "$resource" ]] || continue kubectl -n "$ns" patch "$resource" --type merge -p "{\"spec\":{\"template\":{\"spec\":{\"nodeSelector\":{\"kubernetes.io/hostname\":\"${node}\"}}}}}" >/dev/null 2>&1 || \ warn "Could not patch ${resource} with nodeSelector ${node}" done < <(kubectl -n "$ns" get "$kind" -o name 2>/dev/null || true) done } ensure_cross_cluster_db_host() { if [[ "${MODE:-}" != "k8s" ]]; then return 0 fi local app_ctx db_ctx app_ctx="${APP_CLUSTER_KUBECONTEXT:-${KUBECONTEXT:-}}" if [[ -z "$app_ctx" ]]; then app_ctx="$(kubectl config current-context 2>/dev/null || true)" fi db_ctx="$(resolve_db_cluster_kubecontext)" if [[ -z "$db_ctx" || -z "$app_ctx" || "$db_ctx" == "$app_ctx" ]]; then return 0 fi resolve_knoe_db_namespace local db_cluster_name db_cluster_name="${CNPG_CLUSTER_NAME:-${CLUSTER_NAME:-knoe-db}}" if [[ -z "$db_cluster_name" || "$db_cluster_name" == *'${'* ]]; then db_cluster_name="knoe-db" fi local db_ns ilb_service db_ns="${KNOE_DB_NAMESPACE:-${DATABASE_NAMESPACE:-knoe-db-0}}" ilb_service="${SUPABASE_DB_ILB_SERVICE:-knoe-db-rw-ilb}" log "Ensuring cross-cluster Postgres ILB service '${ilb_service}' in namespace '${db_ns}'..." db_kubectl -n "$db_ns" apply -f - </dev/null apiVersion: v1 kind: Service metadata: name: ${ilb_service} annotations: networking.gke.io/load-balancer-type: "Internal" spec: type: LoadBalancer selector: cnpg.io/cluster: ${db_cluster_name} cnpg.io/instanceRole: primary ports: - name: postgres port: 5432 targetPort: 5432 protocol: TCP EOF local db_host="" local attempts=0 while (( attempts < 60 )); do attempts=$((attempts + 1)) db_host="$(db_kubectl -n "$db_ns" get svc "$ilb_service" -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || true)" if [[ -z "$db_host" ]]; then db_host="$(db_kubectl -n "$db_ns" get svc "$ilb_service" -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || true)" fi if [[ -n "$db_host" ]]; then break fi sleep 5 done if [[ -z "$db_host" ]]; then warn "Cross-cluster Postgres ILB '${ilb_service}' has no ingress address yet; using default DB host wiring." return 0 fi SUPABASE_DB_HOST="$db_host" export SUPABASE_DB_HOST log "Using cross-cluster Supabase DB host: ${SUPABASE_DB_HOST}" } ensure_gke_supabase_storage_class() { local sc_name="${SUPABASE_GKE_STORAGE_CLASS:-supabase-standard}" local sc_json sc_json=$(kubectl get storageclass "$sc_name" -o json 2>/dev/null || true) local provisioner="" local disk_type="" local reclaim_policy="" local binding_mode="" if [[ -n "$sc_json" ]]; then provisioner=$(echo "$sc_json" | python3 -c "import sys, json; print(json.load(sys.stdin).get('provisioner', ''))") disk_type=$(echo "$sc_json" | python3 -c "import sys, json; print(json.load(sys.stdin).get('parameters', {}).get('type', ''))") reclaim_policy=$(echo "$sc_json" | python3 -c "import sys, json; print(json.load(sys.stdin).get('reclaimPolicy', 'Retain'))") binding_mode=$(echo "$sc_json" | python3 -c "import sys, json; print(json.load(sys.stdin).get('volumeBindingMode', 'WaitForFirstConsumer'))") fi if [[ -z "$sc_json" ]] || [[ "$provisioner" != "pd.csi.storage.gke.io" ]] || [[ "$disk_type" != "pd-standard" ]] || [[ "$binding_mode" != "WaitForFirstConsumer" ]]; then if [[ -n "$sc_json" ]]; then warn "StorageClass '${sc_name}' exists but has incorrect parameters (type=${disk_type}, provisioner=${provisioner}, mode=${binding_mode}). Reconciling..." kubectl delete storageclass "$sc_name" --wait=true >/dev/null 2>&1 || true else log "StorageClass '${sc_name}' does not exist. Creating HDD-backed StorageClass..." fi cat </dev/null || true) if [[ -z "$sc_json" ]]; then die "Failed to create StorageClass '${sc_name}'." fi disk_type=$(echo "$sc_json" | python3 -c "import sys, json; print(json.load(sys.stdin).get('parameters', {}).get('type', ''))") if [[ "$disk_type" != "pd-standard" ]]; then die "Created StorageClass '${sc_name}' but it still reports type '${disk_type}'. This is unexpected." fi fi log "Verified live Supabase StorageClass '${sc_name}' (type: ${disk_type})." printf '%s' "$sc_name" return 0 } reconcile_supabase_app_pvcs() { local ns="$1" local target_storage_class="$2" if [[ -z "$target_storage_class" ]]; then return 0 fi local pvc deployment current_storage_class phase local scaled_deployments="," for pvc in supabase-deno supabase-functions supabase-imgproxy supabase-minio supabase-storage supabase-snippets; do if ! kubectl -n "$ns" get pvc "$pvc" >/dev/null 2>&1; then continue fi current_storage_class="$(kubectl -n "$ns" get pvc "$pvc" -o jsonpath='{.spec.storageClassName}' 2>/dev/null || true)" phase="$(kubectl -n "$ns" get pvc "$pvc" -o jsonpath='{.status.phase}' 2>/dev/null || true)" if [[ "$current_storage_class" == "$target_storage_class" ]]; then continue fi deployment="$pvc" if [[ "$scaled_deployments" != *",${deployment},"* ]] && kubectl -n "$ns" get deployment "$deployment" >/dev/null 2>&1; then warn "Scaling deployment/${deployment} to 0 before APP PVC reconciliation." kubectl -n "$ns" scale deployment "$deployment" --replicas=0 >/dev/null 2>&1 || true kubectl -n "$ns" rollout status "deployment/${deployment}" --timeout=120s >/dev/null 2>&1 || true scaled_deployments+="${deployment}," fi warn "Recreating APP PVC '$pvc' (phase=${phase:-unknown}) to switch storageClass from '${current_storage_class:-}' to '$target_storage_class'." kubectl -n "$ns" delete pvc "$pvc" --wait=false >/dev/null 2>&1 || true kubectl -n "$ns" wait --for=delete "pvc/${pvc}" --timeout=120s >/dev/null 2>&1 || true done } reconcile_db_frontdoor_studio_pvcs() { local ns="$1" local target_storage_class="$2" if [[ -z "$target_storage_class" ]]; then return 0 fi local pvc current_storage_class phase local studio_scaled_down=0 for pvc in supabase-functions supabase-snippets; do if ! db_kubectl -n "$ns" get pvc "$pvc" >/dev/null 2>&1; then continue fi current_storage_class="$(db_kubectl -n "$ns" get pvc "$pvc" -o jsonpath='{.spec.storageClassName}' 2>/dev/null || true)" phase="$(db_kubectl -n "$ns" get pvc "$pvc" -o jsonpath='{.status.phase}' 2>/dev/null || true)" if [[ "$current_storage_class" == "$target_storage_class" ]]; then continue fi if (( studio_scaled_down == 0 )) && db_kubectl -n "$ns" get deployment supabase-studio >/dev/null 2>&1; then warn "Scaling deployment/supabase-studio to 0 before DB frontdoor PVC reconciliation." db_kubectl -n "$ns" scale deployment supabase-studio --replicas=0 >/dev/null 2>&1 || true db_kubectl -n "$ns" rollout status deployment/supabase-studio --timeout=120s >/dev/null 2>&1 || true studio_scaled_down=1 fi warn "Recreating DB frontdoor PVC '$pvc' (phase=${phase:-unknown}) to switch storageClass from '${current_storage_class:-}' to '$target_storage_class'." db_kubectl -n "$ns" delete pvc "$pvc" --wait=false >/dev/null 2>&1 || true db_kubectl -n "$ns" wait --for=delete "pvc/${pvc}" --timeout=120s >/dev/null 2>&1 || true done } run_helm() { if [[ "$MODE" == "local" ]]; then return 1 fi ensure_helm require_cmd kubectl ensure_k8s_app_context kubectl cluster-info >/dev/null 2>&1 || return 1 if [[ "$FORCE" == "true" ]]; then # Helm stores release metadata as Secrets in the namespace; it will fail if # the namespace is stuck in Terminating. force_reset_supabase_namespace else ensure_supabase_namespace fi local db_frontdoor_release="${HELM_RELEASE}-frontdoor-db" export SUPABASE_FRONTDOOR_DB_RELEASE="$db_frontdoor_release" local storage_class storage_class="${SUPABASE_STORAGE_CLASS:-}" local first_node_name="" local is_gke_cluster="false" local force_gke_functions_single_replica="false" # Robust GKE detection: check for gke nodes OR the gke-cluster-id label on nodes first_node_name=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) is_gke_cluster="false" if [[ "$first_node_name" == gke-* ]]; then is_gke_cluster="true" elif kubectl get nodes -o jsonpath='{.items[0].metadata.labels}' 2>/dev/null | grep -q "cloud.google.com/gke-cluster-id"; then is_gke_cluster="true" fi # Resolve storage class name strictly in k8s mode if [[ "$MODE" == "k8s" ]]; then if [[ -z "$storage_class" && -n "${PROLE_CFG_PATH:-}" ]]; then storage_class=$(python3 -c " import configparser, sys c=configparser.ConfigParser(); c.read('${PROLE_CFG_PATH}') # Try [Supabase] then [Global] val = c.get('Supabase', 'STORAGE_CLASS', fallback='') or c.get('Global','SUPABASE_STORAGE_CLASS', fallback='') print(val)" 2>/dev/null || true) fi if [[ "$is_gke_cluster" == "true" ]]; then # On GKE, we MUST use the dedicated HDD class. # If it's empty, ensure_gke_supabase_storage_class will use 'supabase-standard' and validate it. storage_class="$(ensure_gke_supabase_storage_class)" else # Non-GKE k8s: still must be explicit if [[ -z "$storage_class" ]]; then die "SUPABASE_STORAGE_CLASS is required in k8s mode. No fallback to default storage class allowed." fi fi fi if [[ "$is_gke_cluster" == "true" ]]; then force_gke_functions_single_replica="true" fi # Export these so the renderer and other sub-processes see the resolved GKE/storage state export SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA="$force_gke_functions_single_replica" export SUPABASE_STORAGE_CLASS="$storage_class" ensure_cross_cluster_db_host helm_render_values setup_knoe_db_for_supabase local values="$PROJECT_ROOT/supabase/helm/generated/values.generated.json" local db_frontdoor_manifest_path="" local public_ingress_tls_manifest_path="" local public_ingress_kong_manifest_path="" local ns="supabase" ns="$(load_manifest_summary_path supabase_namespace)" ns="${ns:-supabase}" db_frontdoor_manifest_path="$(load_manifest_summary_path manifests_frontdoor_db)" public_ingress_tls_manifest_path="$(load_manifest_summary_path manifests_public_ingress_tls)" public_ingress_kong_manifest_path="$(load_manifest_summary_path manifests_public_ingress_kong)" apply_supabase_public_ingress_tls_resources "" "$ns" "$public_ingress_tls_manifest_path" apply_supabase_public_ingress_kong_resource "" "$ns" "$public_ingress_kong_manifest_path" # Pre-apply storage preflight: rendered PVC-bearing components must all use # the resolved storage class source-of-truth. validate_supabase_rendered_pvc_storage "$values" "$storage_class" if [[ "$HELM_TEMPLATE_ONLY" == "true" ]]; then log "Helm template only; manifests ready in supabase/k8s" return 0 fi if [[ -z "${SUPABASE_PV_NODE:-}" && -n "${PROLE_CFG_PATH:-}" ]]; then SUPABASE_PV_NODE=$(python3 -c " import configparser c=configparser.ConfigParser(); c.read('${PROLE_CFG_PATH}') print(c.get('Global','SUPABASE_PV_NODE',fallback=''))" 2>/dev/null || true) fi if [[ -z "${SUPABASE_PV_BASE_DIR:-}" && -n "${PROLE_CFG_PATH:-}" ]]; then SUPABASE_PV_BASE_DIR=$(python3 -c " import configparser c=configparser.ConfigParser(); c.read('${PROLE_CFG_PATH}') base=c.get('Global','SUPABASE_PV_BASE_DIR',fallback='').strip() or c.get('Global','SUPABASE_PV_BASE',fallback='').strip() print(base)" 2>/dev/null || true) fi if [[ -n "${SUPABASE_PV_BASE_DIR:-}" && -z "${SUPABASE_PV_BASE:-}" ]]; then SUPABASE_PV_BASE="$SUPABASE_PV_BASE_DIR" fi if [[ -n "${SUPABASE_PV_NODE:-}" ]]; then export SUPABASE_PV_NODE fi if [[ -n "${SUPABASE_PV_BASE_DIR:-}" ]]; then export SUPABASE_PV_BASE_DIR export SUPABASE_PV_BASE fi # Guard: refuse non-synology storage classes in non-GKE environments to # prevent accidental deployment onto fragile local media. if [[ -n "$storage_class" ]] && ! [[ "$storage_class" =~ ^(synology|merlin-local-iscsi|myrddin-local-iscsi) ]]; then if [[ "$MODE" == "k8s" && "$is_gke_cluster" == "true" ]]; then log "Using storage class '${storage_class}' on GKE k8s mode." else die "SUPABASE_STORAGE_CLASS '${storage_class}' is not a synology/iSCSI mount. Refusing Supabase deploy to prevent node crash." fi fi ensure_k8s_supabase_static_pvs "$storage_class" # k3s: apply static PV manifest (idempotent) so PVCs can bind on merlin if [[ "${MODE:-}" == "k3s" && -f "$PROJECT_ROOT/k8s/knoe/supabase-pvs.yaml" ]]; then log "Applying Supabase static PVs from k8s/knoe/supabase-pvs.yaml..." kubectl apply -f "$PROJECT_ROOT/k8s/knoe/supabase-pvs.yaml" 2>&1 || true fi local helm_set_args=() if [[ "$force_gke_functions_single_replica" == "true" ]]; then helm_set_args+=( --set "deployment.functions.replicaCount=1" --set "autoscaling.functions.enabled=false" ) fi local reuse_existing_sc_via_values="false" if [[ "$MODE" == "k8s" && "$is_gke_cluster" == "true" && -n "$storage_class" ]]; then # Reuse the pre-existing equivalent GKE StorageClass dependency by reference only. # Do not let Helm claim/own a cluster-scoped StorageClass. reuse_existing_sc_via_values="true" helm_set_args+=( --set "storageClass.enabled=false" --set "storageClass.name=${storage_class}" ) fi if [[ -n "$storage_class" ]]; then # Keep all non-DB Supabase PVC-backed components on the selected storage class. helm_set_args+=( --set "persistence.db.storageClassName=${storage_class}" --set "persistence.deno.storageClassName=${storage_class}" --set "persistence.functions.storageClassName=${storage_class}" --set "persistence.imgproxy.storageClassName=${storage_class}" --set "persistence.minio.storageClassName=${storage_class}" --set "persistence.snippets.storageClassName=${storage_class}" --set "persistence.storage.storageClassName=${storage_class}" ) fi local timeout_raw timeout_s timeout_raw="${SUPABASE_DEPLOY_TIMEOUT:-15m}" timeout_s=$(duration_to_seconds "$timeout_raw") local max_attempts max_attempts="${SUPABASE_DEPLOY_MAX_ATTEMPTS:-2}" if [[ -z "$max_attempts" ]]; then max_attempts=2 fi # Supabase app/frontdoor components (Kong, Studio) are always deployed to the APP cluster. # Postgres remains on the DB cluster. local split_frontdoor_to_db="false" local db_ctx="" db_ctx="$(resolve_db_cluster_kubecontext)" local attempt=1 local app_ready=0 local allow_retry_namespace_reset_raw allow_retry_namespace_reset="false" allow_retry_namespace_reset_raw="$(printf '%s' "${SUPABASE_ALLOW_DESTRUCTIVE_RETRY_RESET:-false}" | tr '[:upper:]' '[:lower:]')" if [[ "$allow_retry_namespace_reset_raw" =~ ^(1|true|yes|on)$ ]]; then allow_retry_namespace_reset="true" fi while (( attempt <= max_attempts )); do if (( attempt > 1 )); then warn "Retrying Supabase Helm deploy after timeout (attempt ${attempt}/${max_attempts})" local release_status release_status="$(helm status "$HELM_RELEASE" -n "$ns" -o json 2>/dev/null | python3 -c 'import json,sys try: data=json.load(sys.stdin) except Exception: print("unknown") raise SystemExit(0) print((data.get("info") or {}).get("status") or "unknown")' || true)" local release_is_usable=0 if [[ "$release_status" == "deployed" ]]; then release_is_usable=1 fi if (( app_ready == 1 || release_is_usable == 1 )); then log "Supabase release is usable (app_ready=${app_ready}, helm_status=${release_status}); skipping destructive namespace reset." elif [[ "$allow_retry_namespace_reset" == "true" ]]; then log "Supabase APP cluster not ready; SUPABASE_ALLOW_DESTRUCTIVE_RETRY_RESET=true so namespace reset is allowed." force_reset_supabase_namespace else warn "Supabase APP cluster is not ready, but destructive retry reset is disabled. Continuing with non-destructive Helm reconcile." fi helm_render_values setup_knoe_db_for_supabase ensure_k8s_supabase_static_pvs "$storage_class" public_ingress_tls_manifest_path="$(load_manifest_summary_path manifests_public_ingress_tls)" apply_supabase_public_ingress_tls_resources "" "$ns" "$public_ingress_tls_manifest_path" public_ingress_kong_manifest_path="$(load_manifest_summary_path manifests_public_ingress_kong)" apply_supabase_public_ingress_kong_resource "" "$ns" "$public_ingress_kong_manifest_path" fi check_supabase_retained_pv_blocked "$ns" reconcile_supabase_app_pvcs "$ns" "$storage_class" log "Installing Supabase via Helm into namespace '$ns' (attempt ${attempt}/${max_attempts})..." local helm_install_output="" if ! helm_install_output=$(helm upgrade --install "$HELM_RELEASE" "$PROJECT_ROOT/supabase/helm/knoe-supabase" \ -n "$ns" --create-namespace -f "$values" "${helm_set_args[@]}" 2>&1); then if [[ "$reuse_existing_sc_via_values" != "true" && -n "$storage_class" && \ "$helm_install_output" == *"kind: StorageClass"* && \ "$helm_install_output" == *"cannot be imported into the current release"* ]]; then warn "Helm reported a StorageClass ownership conflict for '${storage_class}'. Retrying with StorageClass reuse mode." if ! helm_install_output=$(helm upgrade --install "$HELM_RELEASE" "$PROJECT_ROOT/supabase/helm/knoe-supabase" \ -n "$ns" --create-namespace -f "$values" "${helm_set_args[@]}" \ --set "storageClass.enabled=false" --set "storageClass.name=${storage_class}" 2>&1); then warn "Helm install failed after enabling StorageClass reuse mode." printf '%s\n' "$helm_install_output" >&2 return 1 fi else warn "Helm install failed." printf '%s\n' "$helm_install_output" >&2 return 1 fi fi if [[ -n "$helm_install_output" ]]; then printf '%s\n' "$helm_install_output" fi enforce_supabase_workload_node "$ns" local wait_enabled wait_enabled="${SUPABASE_DEPLOY_WAIT:-true}" if [[ "$wait_enabled" == "true" ]]; then app_ready=0 if wait_for_supabase_ready "$ns" "$timeout_s" "" "Supabase (APP cluster)"; then app_ready=1 log "Supabase (APP cluster) is healthy." register_supabase_ports log "Deployment complete via Helm." return 0 else warn "Supabase (APP cluster) is NOT ready." fi else register_supabase_ports log "Deployment complete via Helm (wait disabled)." return 0 fi if (( attempt >= max_attempts )); then warn "Supabase Helm deploy did not become Ready within timeout after ${attempt} attempt(s)." log "Final status: APP_READY=${app_ready}" return 2 fi attempt=$((attempt + 1)) done return 2 } ensure_supabase_namespace() { if kubectl get namespace supabase >/dev/null 2>&1; then local phase phase=$(kubectl get namespace supabase -o jsonpath='{.status.phase}' 2>/dev/null || true) if [[ "${phase:-}" == "Terminating" ]]; then warn "Namespace 'supabase' is Terminating; resetting it" force_reset_supabase_namespace fi return 0 fi log "Creating 'supabase' namespace..." kubectl create namespace supabase } force_reset_supabase_namespace() { if ! kubectl get namespace supabase >/dev/null 2>&1; then ensure_supabase_namespace return fi log "Resetting 'supabase' namespace..." # Delete all workloads first to avoid finalizer stalls kubectl delete all --all -n supabase --timeout=60s >/dev/null 2>&1 || true # PVCs can keep pods around and block namespace deletion; remove them explicitly. kubectl delete pvc --all -n supabase --timeout=60s >/dev/null 2>&1 || true # Request namespace deletion without blocking kubectl delete namespace supabase --ignore-not-found --wait=false >/dev/null 2>&1 || true if kubectl get namespace supabase >/dev/null 2>&1; then if ! kubectl wait --for=delete namespace/supabase --timeout=120s >/dev/null 2>&1; then warn "Namespace deletion stalled; clearing finalizers" python - <<'PY' | kubectl replace --raw "/api/v1/namespaces/supabase/finalize" -f - >/dev/null 2>&1 || true import json import subprocess import sys try: raw = subprocess.check_output(["kubectl", "get", "namespace", "supabase", "-o", "json"]) if not raw: sys.exit(0) data = json.loads(raw) if "spec" in data: data["spec"]["finalizers"] = [] print(json.dumps(data)) except Exception: sys.exit(0) PY kubectl wait --for=delete namespace/supabase --timeout=60s >/dev/null 2>&1 || true fi fi ensure_supabase_namespace } run_k3d() { # Helm-first path (preferred) run_helm local helm_rc=$? if [[ $helm_rc -eq 0 ]]; then return 0 fi if [[ $helm_rc -eq 2 ]]; then die "Supabase Helm deploy timed out waiting for readiness; aborting (no legacy fallback)." fi ensure_repo log "knoe::supabase k3d deploy (Kubernetes)" log "Repo: $SUPABASE_DIR" require_cmd kubectl ensure_k3d ensure_k3d_cluster kubectl cluster-info >/dev/null 2>&1 || die "Kubernetes cluster not reachable" if [[ "$FORCE" == "true" ]]; then force_reset_supabase_namespace else ensure_supabase_namespace fi export SUPABASE_HOME="$SUPABASE_DIR" if [[ "$USE_DEV_HELPERS" == "true" ]]; then export SUPABASE_USE_DEV_COMPOSE=1 fi export SUPABASE_IMAGE_PLATFORM SUPABASE_IMAGE_PLATFORM="$(normalize_platform "${SUPABASE_IMAGE_PLATFORM:-}")" if [[ "$SKIP_PREFETCH" != "true" ]]; then prefetch_k3d_images else log "Skipping Supabase image prefetch (requested)." fi generate_k8s_manifests setup_knoe_db_for_supabase if [[ -f "$SUPABASE_K8S_DIR/supabase-namespace.yaml" ]]; then kubectl apply -f "$SUPABASE_K8S_DIR/supabase-namespace.yaml" fi log "Applying Supabase manifests from $SUPABASE_K8S_DIR" kubectl apply -f "$SUPABASE_K8S_DIR" register_supabase_ports log "Deployment complete." log "Access Studio at the configured ingress host (API routing via shared Kong in kube-system)." } run_prefetch_images_only() { ensure_repo case "$MODE" in k3d) require_cmd kubectl ensure_k3d ensure_k3d_cluster kubectl cluster-info >/dev/null 2>&1 || die "Kubernetes cluster not reachable" export SUPABASE_HOME="$SUPABASE_DIR" if [[ "$USE_DEV_HELPERS" == "true" ]]; then export SUPABASE_USE_DEV_COMPOSE=1 fi export SUPABASE_IMAGE_PLATFORM SUPABASE_IMAGE_PLATFORM="$(normalize_platform "${SUPABASE_IMAGE_PLATFORM:-}")" prefetch_k3d_images log "Supabase image prefetch complete." ;; *) die "--prefetch-images-only currently supports only --mode k3d" ;; esac } run_k8s() { # Helm-first path (preferred) run_helm local helm_rc=$? if [[ $helm_rc -eq 0 ]]; then return 0 fi if [[ $helm_rc -eq 2 ]]; then die "Supabase Helm deploy timed out waiting for readiness; aborting (no legacy fallback)." fi ensure_repo log "knoe::supabase k8s deploy (Kubernetes - generic/containerd)" log "Repo: $SUPABASE_DIR" require_cmd kubectl kubectl cluster-info >/dev/null 2>&1 || die "Kubernetes cluster not reachable" if [[ "$FORCE" == "true" ]]; then force_reset_supabase_namespace else ensure_supabase_namespace fi export SUPABASE_HOME="$SUPABASE_DIR" if [[ "$USE_DEV_HELPERS" == "true" ]]; then export SUPABASE_USE_DEV_COMPOSE=1 fi # For generic k8s/k3s clusters (containerd), let nodes pull appropriate arch images directly # Optionally, a future enhancement could push pre-fetched images to an internal registry. generate_k8s_manifests setup_knoe_db_for_supabase if [[ -f "$SUPABASE_K8S_DIR/supabase-namespace.yaml" ]]; then kubectl apply -f "$SUPABASE_K8S_DIR/supabase-namespace.yaml" fi log "Applying Supabase manifests from $SUPABASE_K8S_DIR" kubectl apply -f "$SUPABASE_K8S_DIR" register_supabase_ports log "Deployment complete." log "Access Studio at the configured ingress host (API routing via shared Kong in kube-system)." } main() { parse_args "$@" load_knoe_cfg apply_defaults if [[ -z "$MODE" ]]; then usage exit 1 fi if [[ "$PREFETCH_IMAGES_ONLY" == "true" ]]; then run_prefetch_images_only return 0 fi case "$MODE" in local) run_local ;; k3d) run_k3d ;; k8s) run_k8s ;; *) die "Unsupported mode: $MODE" ;; esac } main "$@"