#!/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 prole.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 } warn() { echo "Warning: $*" >&2 } log() { echo "==> $*" } 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) 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", "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_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 prole)" local data_root="${PROLE_DATA:-/opt/prole/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_prole_cfg() { local cfg_loader="$PROJECT_ROOT/etc/prole_cfg.sh" local cfg_path="${PROLE_CFG_PATH:-}" local candidate="" if [[ -z "$cfg_path" ]]; then if [[ -n "${PROLE_CONF:-}" ]]; then for candidate in \ "${PROLE_CONF%/}/k3d.cfg" \ "${PROLE_CONF%/}/k3s.cfg" \ "${PROLE_CONF%/}/gke.cfg" \ "${PROLE_CONF%/}/prole.cfg" \ "${PROLE_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/prole.cfg" ]]; then cfg_path="$PROJECT_ROOT/conf/prole.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/prole.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" PROLE_CONF="$(cd "$(dirname "$cfg_path")" && pwd)" export PROLE_CONF PROLE_CFG_PATH="$cfg_path" fi if [[ -f "$cfg_loader" ]]; then # shellcheck disable=SC1090 source "$cfg_loader" else warn "prole_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 prole_register_port_forward >/dev/null 2>&1; then prole_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:-}" python3 - "$ns" "$kube_context" <<'PY' import json import os import subprocess import sys ns = sys.argv[1] kube_context = sys.argv[2].strip() def kubectl_json(*args): cmd = ["kubectl"] if kube_context: cmd.extend(["--context", kube_context]) cmd.extend(args) 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") 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" pvc_pending.append({ "name": name, "phase": phase or "Unknown", "storageClass": first_non_empty(spec.get("storageClassName"), ""), "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 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 log "Waiting for ${scope_label} readiness in namespace '${ns}' (context=${context_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" 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 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 prole >/dev/null 2>&1; then KNOE_DB_NAMESPACE="prole" 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; -- 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; -- 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" 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 prole.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 prole.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,"prole.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-gke-standard-rwo}" if kubectl get storageclass "$sc_name" >/dev/null 2>&1; then printf '%s' "$sc_name" return 0 fi printf '==> Creating GKE Supabase storage class %q (pd-standard, WaitForFirstConsumer)...\n' "$sc_name" >&2 kubectl apply -f - </dev/null apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: ${sc_name} provisioner: pd.csi.storage.gke.io reclaimPolicy: Delete allowVolumeExpansion: true volumeBindingMode: WaitForFirstConsumer parameters: type: pd-standard EOF printf '%s' "$sc_name" } resolve_db_frontdoor_storage_class() { local default_storage_class="${1:-}" local preferred="${SUPABASE_DB_FRONTDOOR_STORAGE_CLASS:-$default_storage_class}" if [[ -z "$preferred" ]]; then preferred="$default_storage_class" fi if [[ -z "$preferred" ]]; then preferred="standard" fi if db_kubectl get storageclass "$preferred" >/dev/null 2>&1; then printf '%s' "$preferred" return 0 fi local fallback fallback=$(db_kubectl get storageclass -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.metadata.name}{"\n"}{end}' 2>/dev/null | head -n1) if [[ -n "$fallback" ]]; then warn "DB frontdoor storageClass '${preferred}' not found; using DB cluster default storageClass '${fallback}'." printf '%s' "$fallback" return 0 fi warn "DB frontdoor storageClass '${preferred}' not found and no DB default storageClass detected; proceeding with '${preferred}'." printf '%s' "$preferred" } 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-storage; 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" first_node_name=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [[ "$first_node_name" == gke-* ]]; then is_gke_cluster="true" fi # Fall back to cfg file if env var not exported into this shell if [[ -z "$storage_class" && -n "${PROLE_CFG_PATH:-}" ]]; then storage_class=$(python3 -c " import configparser, sys c=configparser.ConfigParser(); c.read('${PROLE_CFG_PATH}') print(c.get('Global','SUPABASE_STORAGE_CLASS',fallback=''))" 2>/dev/null || true) fi if [[ -z "$storage_class" && "$MODE" == "k8s" ]]; then if [[ "$is_gke_cluster" == "true" ]]; then storage_class="$(ensure_gke_supabase_storage_class)" else storage_class=$(kubectl get storageclass -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.metadata.name}{"\n"}{end}' 2>/dev/null | head -n1) storage_class="${storage_class:-synology-iscsi}" fi fi if [[ "$is_gke_cluster" == "true" ]]; then if [[ "$storage_class" == "standard" ]]; then warn "Ignoring legacy 'standard' storage class for Supabase GKE non-DB PVCs; using dedicated CSI pd-standard class." storage_class="" fi if [[ -z "$storage_class" ]]; then storage_class="$(ensure_gke_supabase_storage_class)" fi 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 ns="supabase" ns="$(load_manifest_summary_path supabase_namespace)" ns="${ns:-supabase}" db_frontdoor_manifest_path="$(load_manifest_summary_path manifests_frontdoor_db)" 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/prole/supabase-pvs.yaml" ]]; then log "Applying Supabase static PVs from k8s/prole/supabase-pvs.yaml..." kubectl apply -f "$PROJECT_ROOT/k8s/prole/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 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 local split_frontdoor_to_db="false" local db_ctx="" local db_frontdoor_storage_class="$storage_class" if [[ "${MODE:-}" == "k8s" ]]; then db_ctx="$(resolve_db_cluster_kubecontext)" if [[ -n "$db_ctx" ]]; then split_frontdoor_to_db="true" db_frontdoor_storage_class="$(resolve_db_frontdoor_storage_class "$storage_class")" if [[ "$is_gke_cluster" == "true" && "$db_frontdoor_storage_class" == "standard" ]]; then warn "Ignoring legacy DB frontdoor storage class 'standard' on GKE; reusing '${storage_class}' for Supabase frontdoor PVCs." db_frontdoor_storage_class="$storage_class" fi log "DB frontdoor PVC storageClass: ${db_frontdoor_storage_class}" fi fi local db_frontdoor_helm_set_args=("${helm_set_args[@]}") if [[ "$split_frontdoor_to_db" == "true" && -n "$db_frontdoor_storage_class" ]]; then db_frontdoor_helm_set_args+=( --set "persistence.functions.storageClassName=${db_frontdoor_storage_class}" --set "persistence.snippets.storageClassName=${db_frontdoor_storage_class}" ) fi local split_app_values_file="" local split_db_values_file="" if [[ "$split_frontdoor_to_db" == "true" ]]; then split_app_values_file="$PROJECT_ROOT/supabase/helm/generated/values.app.generated.json" split_db_values_file="$PROJECT_ROOT/supabase/helm/generated/values.frontdoor-db.generated.json" SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA="$force_gke_functions_single_replica" \ write_split_supabase_values "$values" "$split_app_values_file" "$split_db_values_file" values="$split_app_values_file" fi local attempt attempt=1 while (( attempt <= max_attempts )); do if (( attempt > 1 )); then warn "Retrying Supabase Helm deploy after timeout (attempt ${attempt}/${max_attempts})" force_reset_supabase_namespace helm_render_values setup_knoe_db_for_supabase ensure_k8s_supabase_static_pvs "$storage_class" if [[ "$split_frontdoor_to_db" == "true" ]]; then SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA="$force_gke_functions_single_replica" \ write_split_supabase_values "$PROJECT_ROOT/supabase/helm/generated/values.generated.json" "$split_app_values_file" "$split_db_values_file" values="$split_app_values_file" fi fi reconcile_supabase_app_pvcs "$ns" "$storage_class" log "Installing Supabase via Helm into namespace '$ns' (attempt ${attempt}/${max_attempts})..." if ! helm upgrade --install "$HELM_RELEASE" "$PROJECT_ROOT/supabase/helm/knoe-supabase" \ -n "$ns" --create-namespace -f "$values" "${helm_set_args[@]}"; then warn "Helm install failed; falling back to legacy kompose flow." return 1 fi if [[ "$split_frontdoor_to_db" == "true" ]]; then if [[ -z "$db_ctx" ]]; then die "DB cluster kubecontext is required for split Supabase Studio/Kong relocation." fi if ! kubectl config get-contexts "$db_ctx" >/dev/null 2>&1; then die "DB cluster kubecontext '${db_ctx}' is not available in kubeconfig." fi reconcile_db_frontdoor_studio_pvcs "$ns" "$db_frontdoor_storage_class" log "Installing Supabase Studio/Kong frontdoor release into DB cluster context '${db_ctx}'..." if ! helm --kube-context "$db_ctx" upgrade --install "$db_frontdoor_release" "$PROJECT_ROOT/supabase/helm/knoe-supabase" \ -n "$ns" --create-namespace -f "$split_db_values_file" "${db_frontdoor_helm_set_args[@]}"; then warn "DB-cluster Studio/Kong Helm install failed; falling back to legacy kompose flow." return 1 fi if [[ -n "$db_frontdoor_manifest_path" && -s "$db_frontdoor_manifest_path" ]]; then log "Applying rendered Studio/Kong frontdoor manifests to DB cluster for explicit ingress ownership..." db_kubectl apply -f "$db_frontdoor_manifest_path" fi fi enforce_supabase_workload_node "$ns" local wait_enabled wait_enabled="${SUPABASE_DEPLOY_WAIT:-true}" if [[ "$wait_enabled" == "true" ]]; then local app_ready=0 local db_frontdoor_ready=0 if wait_for_supabase_ready "$ns" "$timeout_s" "" "Supabase (APP cluster)"; then app_ready=1 fi if [[ "$split_frontdoor_to_db" == "true" ]]; then if wait_for_supabase_ready "$ns" "$timeout_s" "$db_ctx" "Supabase frontdoor (DB cluster)"; then db_frontdoor_ready=1 fi else db_frontdoor_ready=1 fi if (( app_ready == 1 && db_frontdoor_ready == 1 )); then register_supabase_ports log "Deployment complete via Helm." return 0 fi if (( attempt >= max_attempts )); then warn "Supabase Helm deploy did not become Ready within timeout after ${attempt} attempt(s)." # Return a distinct code so k8s mode doesn't fall back to legacy manifests. return 2 fi else register_supabase_ports log "Deployment complete via Helm (wait disabled)." return 0 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_prole_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 "$@"