prole/supabase/deploy.sh
chrisfu 06862bab5c chore: fix DB-to-APP context mismatches and refine deployment logic
- Resolved context mismatches by updating DB references to APP where appropriate.
- Updated Supabase deployment to ensure proper handling of cross-cluster DB hosts.
- Simplified ingress logic by setting `split_frontdoor_to_db` to `false` for APP clusters lacking GCE Ingress support.
2026-04-15 10:10:46 -07:00

2694 lines
84 KiB
Bash
Executable File

#!/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 <local|k3d|k8s> 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> 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
}
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
}
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)
# 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_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:-}"
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):
cmd = ["kubectl"]
if kube_context:
cmd.extend(["--context", kube_context])
cmd.extend(args)
if 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")
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"), "<unset>"),
"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"
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 '<unset>'},"
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" <<EOF
apiVersion: v1
kind: Service
metadata:
name: supabase-postgres
namespace: supabase
labels:
app: supabase-postgres
spec:
type: ExternalName
externalName: ${KNOE_DB_SERVICE}.${KNOE_DB_NAMESPACE}.svc.cluster.local
ports:
- name: postgres
port: ${SUPABASE_POSTGRES_PORT}
targetPort: 5432
EOF
}
write_db_alias_service() {
local file="$SUPABASE_K8S_DIR/db-service.yaml"
cat > "$file" <<EOF
apiVersion: v1
kind: Service
metadata:
name: db
namespace: supabase
labels:
app: supabase-db
spec:
type: ExternalName
externalName: ${KNOE_DB_SERVICE}.${KNOE_DB_NAMESPACE}.svc.cluster.local
ports:
- name: postgres
port: 5432
targetPort: 5432
EOF
}
fix_k8s_manifests() {
log "Post-processing Kubernetes manifests for k8s compatibility..."
# 1. Fix db data volume: configMap -> 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"
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 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 <<EOF | kubectl apply -f - >/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 <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
name: synology-supabase-db
labels:
synology.storage/pvc: supabase-db
spec:
capacity:
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${storage_class}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/db
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: synology-supabase-deno
labels:
synology.storage/pvc: supabase-deno
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${storage_class}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/deno
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: synology-supabase-functions
labels:
synology.storage/pvc: supabase-functions
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${storage_class}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/functions
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: synology-supabase-snippets
labels:
synology.storage/pvc: supabase-snippets
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${storage_class}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/snippets
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: synology-supabase-imgproxy
labels:
synology.storage/pvc: supabase-imgproxy
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${storage_class}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/imgproxy
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: synology-supabase-minio
labels:
synology.storage/pvc: supabase-minio
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${storage_class}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/minio
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: synology-supabase-storage
labels:
synology.storage/pvc: supabase-storage
spec:
capacity:
storage: 1Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
storageClassName: ${storage_class}
persistentVolumeReclaimPolicy: Retain
local:
path: ${base}/storage
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- ${node}
EOF
}
ensure_k8s_app_context() {
if [[ "${MODE:-}" != "k8s" ]]; then
return 0
fi
local target_ctx="${APP_CLUSTER_KUBECONTEXT:-${KUBECONTEXT:-}}"
if [[ -z "$target_ctx" && -n "${PROLE_CFG_PATH:-}" ]]; then
target_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.app_cluster_kubecontext"),
("Inputs", "env_setup.APP_CLUSTER_KUBECONTEXT"),
("Global", "APP_CLUSTER_KUBECONTEXT"),
("Global", "KUBECONTEXT"),
):
if cfg.has_option(section, key):
value = cfg.get(section, key, fallback="").strip()
if value:
print(value)
break
PY
)
fi
if [[ -z "$target_ctx" ]]; then
warn "APP cluster kubecontext is not set; proceeding with current context."
return 0
fi
if ! kubectl config get-contexts "$target_ctx" >/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 - <<EOF >/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 - <<EOF >/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:-<unset>}' 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:-<unset>}' 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
# 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
while (( attempt <= max_attempts )); do
if (( attempt > 1 )); then
warn "Retrying Supabase Helm deploy after timeout (attempt ${attempt}/${max_attempts})"
if (( app_ready == 1 )); then
log "Supabase APP cluster is already healthy; skipping destructive namespace reset."
else
log "Supabase APP cluster not ready; performing namespace reset."
force_reset_supabase_namespace
fi
helm_render_values
setup_knoe_db_for_supabase
ensure_k8s_supabase_static_pvs "$storage_class"
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
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_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 "$@"