chore: update GKE storage class and reconcile PVCs for Supabase deployment

- Changed default GKE non-DB PVCs to use CSI `pd-standard` with WFFC mode, deprecating legacy `standard`.
- Added reconciliation logic for APP PVCs to align with updated storage class.
- Enforced single replica for Supabase functions on GKE, disabling autoscaling.
- Updated tests to validate storage class changes, PVC reconciliation, and single-replica logic.
This commit is contained in:
chrisfu 2026-04-14 11:13:14 -07:00
parent fddaf80823
commit 174877201e
3 changed files with 89 additions and 41 deletions

View File

@ -107,10 +107,10 @@ app_values.setdefault("scheduling", {})["enforceGeneralNodeRole"] = False
clear_stale_app_scheduling(app_values) clear_stale_app_scheduling(app_values)
db_values = deepcopy(base) db_values = deepcopy(base)
clear_stale_app_scheduling(db_values)
for component in ( for component in (
"analytics", "analytics",
"auth", "auth",
"functions",
"imgproxy", "imgproxy",
"meta", "meta",
"minio", "minio",
@ -130,7 +130,13 @@ force_single_functions_replica = os.environ.get(
"SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA", "" "SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA", ""
).strip().lower() in {"1", "true", "yes", "on"} ).strip().lower() in {"1", "true", "yes", "on"}
if force_single_functions_replica: if force_single_functions_replica:
app_values.setdefault("replicaCount", {})["functions"] = 1 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(app_values, open(app_path, "w", encoding="utf-8"), indent=2)
json.dump(db_values, open(db_path, "w", encoding="utf-8"), indent=2) json.dump(db_values, open(db_path, "w", encoding="utf-8"), indent=2)
@ -2213,22 +2219,6 @@ run_helm() {
local db_frontdoor_release="${HELM_RELEASE}-frontdoor-db" local db_frontdoor_release="${HELM_RELEASE}-frontdoor-db"
export SUPABASE_FRONTDOOR_DB_RELEASE="$db_frontdoor_release" export SUPABASE_FRONTDOOR_DB_RELEASE="$db_frontdoor_release"
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
local storage_class local storage_class
storage_class="${SUPABASE_STORAGE_CLASS:-}" storage_class="${SUPABASE_STORAGE_CLASS:-}"
local first_node_name="" local first_node_name=""
@ -2267,6 +2257,26 @@ print(c.get('Global','SUPABASE_STORAGE_CLASS',fallback=''))" 2>/dev/null || true
force_gke_functions_single_replica="true" force_gke_functions_single_replica="true"
fi 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 if [[ -z "${SUPABASE_PV_NODE:-}" && -n "${PROLE_CFG_PATH:-}" ]]; then
SUPABASE_PV_NODE=$(python3 -c " SUPABASE_PV_NODE=$(python3 -c "
import configparser import configparser
@ -2308,6 +2318,13 @@ print(base)" 2>/dev/null || true)
fi fi
local helm_set_args=() 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 if [[ -n "$storage_class" ]]; then
# Keep all non-DB Supabase PVC-backed components on the selected storage class. # Keep all non-DB Supabase PVC-backed components on the selected storage class.
helm_set_args+=( helm_set_args+=(

View File

@ -18,6 +18,7 @@ import subprocess
import sys import sys
import time import time
import urllib.parse import urllib.parse
from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any, Dict
@ -215,6 +216,7 @@ def _split_frontdoor_docs(
"supabase-kong-declarative-config", "supabase-kong-declarative-config",
"supabase-kong-declarative-config-jwt", "supabase-kong-declarative-config-jwt",
} }
functions_names = {"supabase-functions", "supabase-functions-config"}
frontdoor_docs: list[dict[str, Any]] = [] frontdoor_docs: list[dict[str, Any]] = []
frontdoor_indexes: set[int] = set() frontdoor_indexes: set[int] = set()
@ -228,6 +230,8 @@ def _split_frontdoor_docs(
is_studio = name in studio_names and kind in studio_kinds is_studio = name in studio_names and kind in studio_kinds
is_kong = name in kong_names and kind in kong_kinds is_kong = name in kong_names and kind in kong_kinds
is_functions = name in functions_names and kind in studio_kinds
if is_studio or is_kong or is_functions:
if is_studio or is_kong: if is_studio or is_kong:
frontdoor_indexes.add(idx) frontdoor_indexes.add(idx)
if kind not in publish_kinds: if kind not in publish_kinds:
@ -235,28 +239,31 @@ def _split_frontdoor_docs(
if not _is_valid_frontdoor_doc(doc): if not _is_valid_frontdoor_doc(doc):
continue continue
doc_for_frontdoor = deepcopy(doc)
meta_fd = doc_for_frontdoor.get("metadata") or {}
if frontdoor_release: if frontdoor_release:
labels = meta.get("labels") if isinstance(meta, dict) else None labels = meta_fd.get("labels") if isinstance(meta_fd, dict) else None
if not isinstance(labels, dict): if not isinstance(labels, dict):
labels = {} labels = {}
if isinstance(meta, dict): if isinstance(meta_fd, dict):
meta["labels"] = labels meta_fd["labels"] = labels
labels["app.kubernetes.io/instance"] = frontdoor_release labels["app.kubernetes.io/instance"] = frontdoor_release
if kind == "Service": if kind == "Service":
spec = doc.get("spec") spec = doc_for_frontdoor.get("spec")
if isinstance(spec, dict): if isinstance(spec, dict):
selector = spec.get("selector") selector = spec.get("selector")
if isinstance(selector, dict): if isinstance(selector, dict):
selector["app.kubernetes.io/instance"] = frontdoor_release selector["app.kubernetes.io/instance"] = frontdoor_release
if namespace: if namespace:
if not isinstance(meta, dict): if not isinstance(meta_fd, dict):
meta = {} meta_fd = {}
doc["metadata"] = meta doc_for_frontdoor["metadata"] = meta_fd
if not str(meta.get("namespace") or "").strip(): if not str(meta_fd.get("namespace") or "").strip():
meta["namespace"] = namespace meta_fd["namespace"] = namespace
frontdoor_docs.append(doc) frontdoor_docs.append(doc_for_frontdoor)
app_docs = [doc for idx, doc in enumerate(docs) if idx not in frontdoor_indexes] app_docs = [doc for idx, doc in enumerate(docs) if idx not in frontdoor_indexes]
return app_docs, frontdoor_docs return app_docs, frontdoor_docs
@ -859,13 +866,13 @@ def _build_overlay(cfg: configparser.ConfigParser, args: argparse.Namespace) ->
} }
if supabase_storage_class: if supabase_storage_class:
# Validate: must be a synology/merlin mount — refuse pi/local SD card classes # Validate: must be a synology/merlin/gke mount — refuse pi/local SD card classes
if not any(supabase_storage_class.startswith(p) for p in if not any(supabase_storage_class.startswith(p) for p in
("synology", "merlin-local-iscsi", "myrddin-local-iscsi")): ("synology", "merlin-local-iscsi", "myrddin-local-iscsi", "supabase-gke")):
raise SystemExit( raise SystemExit(
f"SUPABASE_STORAGE_CLASS '{supabase_storage_class}' is not a synology mount. " f"SUPABASE_STORAGE_CLASS '{supabase_storage_class}' is not a synology or GKE CSI mount. "
"Supabase must run on iSCSI/NFS storage (merlin-local-iscsi-d002 or synology-iscsi). " "Supabase must run on iSCSI/NFS or GKE CSI storage. "
"Refusing deploy to prevent SD-card crash." "Refusing deploy to prevent node crash."
) )
# Write to persistence.*.storageClassName — the path the Helm chart actually uses # Write to persistence.*.storageClassName — the path the Helm chart actually uses
persistence = overlay.setdefault("persistence", {}) persistence = overlay.setdefault("persistence", {})
@ -892,6 +899,15 @@ def _build_overlay(cfg: configparser.ConfigParser, args: argparse.Namespace) ->
component_cfg.setdefault("nodeSelector", {}) component_cfg.setdefault("nodeSelector", {})
component_cfg["nodeSelector"].update(supabase_node_selector) component_cfg["nodeSelector"].update(supabase_node_selector)
if _as_bool(os.environ.get("SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA", "false")):
deployment = overlay.setdefault("deployment", {})
functions_cfg = deployment.setdefault("functions", {})
functions_cfg["replicaCount"] = 1
autoscaling = overlay.setdefault("autoscaling", {})
functions_as = autoscaling.setdefault("functions", {})
functions_as["enabled"] = False
meta = { meta = {
"supabase_namespace": supabase_ns, "supabase_namespace": supabase_ns,
"db_namespace": db_ns, "db_namespace": db_ns,

View File

@ -564,15 +564,30 @@ def test_supabase_deploy_db_frontdoor_readiness_and_storageclass_guards_present(
assert "Blocking Pods:" in script assert "Blocking Pods:" in script
def test_supabase_deploy_defaults_non_db_storage_class_to_standard_on_gke(): def test_supabase_deploy_defaults_non_db_storage_class_to_csi_pd_standard_on_gke():
"""GKE Supabase non-DB PVC defaults must use pd-standard (`standard`) to avoid SSD/balanced quota pressure.""" """GKE Supabase non-DB PVC defaults must use a CSI pd-standard/WFFC class, not legacy `standard`."""
script = (REPO_ROOT / "supabase" / "deploy.sh").read_text(encoding="utf-8") script = (REPO_ROOT / "supabase" / "deploy.sh").read_text(encoding="utf-8")
assert 'local sc_name="${SUPABASE_GKE_STORAGE_CLASS:-standard}"' in script assert 'local sc_name="${SUPABASE_GKE_STORAGE_CLASS:-supabase-gke-standard-rwo}"' in script
assert 'local preferred="${SUPABASE_DB_FRONTDOOR_STORAGE_CLASS:-standard}"' in script assert "provisioner: pd.csi.storage.gke.io" in script
assert "volumeBindingMode: WaitForFirstConsumer" in script
assert 'local preferred="${SUPABASE_DB_FRONTDOOR_STORAGE_CLASS:-$default_storage_class}"' in script
assert "Ignoring legacy 'standard' storage class for Supabase GKE non-DB PVCs" in script
assert 'preferred="standard"' in script assert 'preferred="standard"' in script
assert "SUPABASE_GKE_STORAGE_CLASS:-standard-rwo" not in script assert 'local sc_name="${SUPABASE_GKE_STORAGE_CLASS:-standard}"' not in script
assert "SUPABASE_DB_FRONTDOOR_STORAGE_CLASS:-standard-rwo" not in script assert 'local preferred="${SUPABASE_DB_FRONTDOOR_STORAGE_CLASS:-standard}"' not in script
def test_supabase_deploy_reconciles_gke_non_db_pvcs_and_forces_single_functions_replica():
"""GKE split deploy must reconcile mismatched APP PVC classes and force single-replica functions semantics."""
script = (REPO_ROOT / "supabase" / "deploy.sh").read_text(encoding="utf-8")
assert "reconcile_supabase_app_pvcs()" in script
assert "for pvc in supabase-deno supabase-functions supabase-imgproxy supabase-storage; do" in script
assert "Recreating APP PVC" in script
assert 'reconcile_supabase_app_pvcs "$ns" "$storage_class"' in script
assert 'SUPABASE_GKE_FORCE_FUNCTIONS_SINGLE_REPLICA="$force_gke_functions_single_replica"' in script
assert 'app_values.setdefault("replicaCount", {})["functions"] = 1' in script
def test_supabase_deploy_split_app_and_db_values_disable_general_node_role_enforcement(): def test_supabase_deploy_split_app_and_db_values_disable_general_node_role_enforcement():