Implement split-cluster host ownership and context-safe deploy wiring

- move Supabase k8s ingress defaults to env-indexed api/db hostnames and remove legacy host bleed-through

- enforce explicit APP/DB kubecontext role validation across cluster ops and init scripts

- align env/default derivation and extend tests for hostname rendering and context checks

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
chrisfu 2026-04-12 21:00:20 -07:00
parent be85d2e4b2
commit bfa712273e
12 changed files with 746 additions and 58 deletions

View File

@ -117,19 +117,19 @@ PROLE_HOME = $HOME/dev/prole
REGISTRY_NAMESPACE = knoe-system
SERVICE_NAMESPACE = knoe-system
SUPABASE_HOSTNAME = db.0.knoe.dev
SUPABASE_API_HOSTNAME = api.knoe.dev
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev,db.prole.org
SUPABASE_API_HOSTNAME = api.0.knoe.dev
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
SUPABASE_INGRESS_CLASS = gce
GITLAB_DOMAIN = git.knoe.dev
GITLAB_PUBLIC_HOSTS = git.knoe.dev,git.prole.org
GITLAB_PUBLIC_HOSTS = git.knoe.dev
GITLAB_INGRESS_CLASS = gce
AUTHORITY_ENABLED = true
AUTH_HOSTNAME = api.knoe.dev
AUTH_VERIFY_PATH = /auth/verify
AUTH_LOGIN_PATH = /auth/login
AUTH_RESPONSE_HEADERS = X-Prole-User,X-Prole-Email,X-Prole-Groups
PROTECTED_GIT_HOSTS = git.knoe.dev,git.prole.org
PROTECTED_DB_HOSTS = db.0.knoe.dev,db.prole.org
PROTECTED_GIT_HOSTS = git.knoe.dev
PROTECTED_DB_HOSTS = db.0.knoe.dev,api.0.knoe.dev
[Welcome]
; No configuration values captured yet for this section.

View File

@ -45,6 +45,65 @@ die() { echo "[ERROR] $*" >&2; exit 2; }
log() { echo "[INFO] $*"; }
warn() { echo "[WARN] $*" >&2; }
resolve_explicit_kube_context() {
local ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
if [[ -n "$ctx" ]]; then
printf '%s' "$ctx"
return 0
fi
return 1
}
enforce_app_cluster_targeting() {
if [[ "$MODE" != "k8s" ]]; then
return 0
fi
local app_ctx="${APP_CLUSTER_KUBECONTEXT:-}"
local db_ctx="${DB_CLUSTER_KUBECONTEXT:-}"
local target_ctx
target_ctx="$(resolve_explicit_kube_context || true)"
[[ -n "$app_ctx" ]] || die "APP_CLUSTER_KUBECONTEXT is required for k8s GitLab deployment."
[[ -n "$target_ctx" ]] || die "Explicit kubectl context is required for k8s GitLab deployment."
if [[ -n "$db_ctx" && "$target_ctx" == "$db_ctx" ]]; then
die "Refusing GitLab APP step against DB context '$target_ctx'."
fi
if [[ "$target_ctx" != "$app_ctx" ]]; then
die "GitLab APP step must target APP_CLUSTER_KUBECONTEXT='${app_ctx}' (got '${target_ctx}')."
fi
export KUBECTL_CONTEXT="$app_ctx"
export KUBE_CONTEXT_NAME="$app_ctx"
export KUBECONTEXT="$app_ctx"
}
kubectl() {
local target_ctx
target_ctx="$(resolve_explicit_kube_context || true)"
if [[ "$MODE" == "k8s" && -z "$target_ctx" ]]; then
die "Explicit kubectl context is required in k8s mode."
fi
local arg has_context=0
for arg in "$@"; do
case "$arg" in
--context|--context=*|--server|--server=*)
has_context=1
break
;;
esac
done
if [[ -n "$target_ctx" && $has_context -eq 0 ]]; then
command kubectl --context "$target_ctx" "$@"
else
command kubectl "$@"
fi
}
enforce_app_cluster_targeting
is_truthy() {
case "${1:-}" in
1|true|TRUE|True|yes|YES|on|ON|y|Y)
@ -65,11 +124,12 @@ assert_public_ingress_targeting() {
return 0
fi
local app_ctx="${APP_CLUSTER_KUBECONTEXT:-${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}}"
local app_ctx="${APP_CLUSTER_KUBECONTEXT:-}"
local db_ctx="${DB_CLUSTER_KUBECONTEXT:-}"
local active_ctx="${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}"
if [[ -z "$active_ctx" ]]; then
active_ctx="$(kubectl config current-context 2>/dev/null || true)"
local active_ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
if [[ -z "$app_ctx" || -z "$active_ctx" ]]; then
die "Explicit APP cluster context is required for GitLab ingress operations in k8s mode."
fi
local host_count=0
@ -98,7 +158,12 @@ assert_unique_ingress_host_claims() {
local host_csv="${3:-}"
[[ -n "$host_csv" ]] || return 0
if ! python3 - "$host_csv" "$ingress_namespace" "$ingress_name" <<'PY'
local target_ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
if [[ "$MODE" == "k8s" && -z "$target_ctx" ]]; then
die "Explicit kubectl context is required for ingress ownership checks in k8s mode."
fi
if ! python3 - "$host_csv" "$ingress_namespace" "$ingress_name" "$target_ctx" <<'PY'
import json
import subprocess
import sys
@ -106,9 +171,15 @@ import sys
requested_hosts = {h.strip().lower() for h in (sys.argv[1] or "").split(",") if h.strip()}
target_ns = sys.argv[2]
target_name = sys.argv[3]
target_ctx = (sys.argv[4] or "").strip()
cmd = ["kubectl"]
if target_ctx:
cmd.extend(["--context", target_ctx])
cmd.extend(["get", "ingress", "-A", "-o", "json"])
try:
raw = subprocess.check_output(["kubectl", "get", "ingress", "-A", "-o", "json"], text=True)
raw = subprocess.check_output(cmd, text=True)
except Exception:
raise SystemExit(0)

View File

@ -42,6 +42,73 @@ if [[ -z "${PROLE_MODE:-}" ]]; then
export PROLE_MODE="k3s"
fi
resolve_explicit_kube_context() {
local ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
if [[ -n "$ctx" ]]; then
printf '%s' "$ctx"
return 0
fi
return 1
}
enforce_app_cluster_targeting() {
if [[ "${PROLE_MODE:-}" != "k8s" ]]; then
return 0
fi
local app_ctx="${APP_CLUSTER_KUBECONTEXT:-}"
local db_ctx="${DB_CLUSTER_KUBECONTEXT:-}"
local target_ctx
target_ctx="$(resolve_explicit_kube_context || true)"
if [[ -z "$app_ctx" ]]; then
echo "ERROR: APP_CLUSTER_KUBECONTEXT is required for k8s Kong deployment." >&2
exit 2
fi
if [[ -z "$target_ctx" ]]; then
echo "ERROR: explicit kubectl context is required for k8s Kong deployment." >&2
exit 2
fi
if [[ -n "$db_ctx" && "$target_ctx" == "$db_ctx" ]]; then
echo "ERROR: refusing Kong APP step against DB context '$target_ctx'." >&2
exit 2
fi
if [[ "$target_ctx" != "$app_ctx" ]]; then
echo "ERROR: Kong APP step must target APP_CLUSTER_KUBECONTEXT='$app_ctx' (got '$target_ctx')." >&2
exit 2
fi
export KUBECTL_CONTEXT="$app_ctx"
export KUBE_CONTEXT_NAME="$app_ctx"
export KUBECONTEXT="$app_ctx"
}
kubectl() {
local target_ctx
target_ctx="$(resolve_explicit_kube_context || true)"
if [[ "${PROLE_MODE:-}" == "k8s" && -z "$target_ctx" ]]; then
echo "ERROR: explicit kubectl context is required in k8s mode." >&2
return 2
fi
local arg has_context=0
for arg in "$@"; do
case "$arg" in
--context|--context=*|--server|--server=*)
has_context=1
break
;;
esac
done
if [[ -n "$target_ctx" && $has_context -eq 0 ]]; then
command kubectl --context "$target_ctx" "$@"
else
command kubectl "$@"
fi
}
enforce_app_cluster_targeting
if [[ "${COMMON_CORE_HELP:-0}" == 1 ]]; then
common_core_usage "$0"
exit 0
@ -143,11 +210,13 @@ assert_public_ingress_targeting() {
return 0
fi
local app_ctx="${APP_CLUSTER_KUBECONTEXT:-${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}}"
local app_ctx="${APP_CLUSTER_KUBECONTEXT:-}"
local db_ctx="${DB_CLUSTER_KUBECONTEXT:-}"
local active_ctx="${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}"
if [[ -z "$active_ctx" ]]; then
active_ctx="$(kubectl config current-context 2>/dev/null || true)"
local active_ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
if [[ -z "$app_ctx" || -z "$active_ctx" ]]; then
echo "ERROR: explicit APP cluster context is required for public Kong ingress in k8s mode." >&2
exit 1
fi
local host_count=0
@ -181,7 +250,13 @@ assert_unique_ingress_host_claims() {
local host_csv="${3:-}"
[[ -n "$host_csv" ]] || return 0
if ! python3 - "$host_csv" "$ingress_namespace" "$ingress_name" <<'PY'
local target_ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
if [[ "${PROLE_MODE:-}" == "k8s" && -z "$target_ctx" ]]; then
echo "ERROR: explicit kubectl context is required for ingress ownership checks in k8s mode." >&2
return 1
fi
if ! python3 - "$host_csv" "$ingress_namespace" "$ingress_name" "$target_ctx" <<'PY'
import json
import subprocess
import sys
@ -189,9 +264,15 @@ import sys
requested_hosts = {h.strip().lower() for h in (sys.argv[1] or "").split(",") if h.strip()}
target_ns = sys.argv[2]
target_name = sys.argv[3]
target_ctx = (sys.argv[4] or "").strip()
cmd = ["kubectl"]
if target_ctx:
cmd.extend(["--context", target_ctx])
cmd.extend(["get", "ingress", "-A", "-o", "json"])
try:
raw = subprocess.check_output(["kubectl", "get", "ingress", "-A", "-o", "json"], text=True)
raw = subprocess.check_output(cmd, text=True)
except Exception:
raise SystemExit(0)

View File

@ -392,13 +392,13 @@ class KnoeInstaller:
else:
cmd = ["kubectl"]
# 2. Add context if explicitly selected (and not already in the command)
ctx = self._get_input("init_cluster.selected_kubectx", "").strip()
# For k8s/prod mode, fall back to the GKE context saved in prole_cfg_data
if not ctx and mode == "k8s":
ctx = (
(self.prole_cfg_data.get("Global", {}) or {}).get("KUBECONTEXT", "") or ""
).strip()
# 2. Add context explicitly (and never rely on ambient current-context)
if mode == "k8s":
# In split-cluster GKE mode default kubectl access must target APP cluster
# unless a step uses an explicit role-specific env override.
ctx = self._cluster_kubecontext("app").strip()
else:
ctx = self._get_input("init_cluster.selected_kubectx", "").strip()
if ctx and cmd[0] == "kubectl" and "--server" not in cmd and "--context" not in cmd:
cmd.extend(["--context", ctx])
return cmd
@ -1656,18 +1656,15 @@ class KnoeInstaller:
if Path(default_kube).exists():
env["KUBECONFIG"] = default_kube
elif mode == "k8s":
# For GKE/prod clusters, gcloud get-credentials writes to ~/.kube/config.
# Read the context that was saved after cluster selection.
gke_context = (
self._cluster_kubecontext(cluster_role)
or (self.prole_cfg_data.get("Global", {}) or {}).get("KUBECONTEXT", "")
or ""
).strip()
# For GKE/prod split-cluster deploys we require explicit role context.
gke_context = self._cluster_kubecontext(cluster_role).strip()
default_kube = str(Path.home() / ".kube" / "config")
if Path(default_kube).exists():
env["KUBECONFIG"] = default_kube
if gke_context:
env["KUBECONTEXT"] = gke_context
env["KUBE_CONTEXT_NAME"] = gke_context
env["KUBECTL_CONTEXT"] = gke_context
else:
env.pop("KUBECONFIG", None)
env["SERVICE_NAMESPACE"] = service_ns
@ -1680,9 +1677,10 @@ class KnoeInstaller:
env["SUPABASE_STUDIO_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_studio_enabled", False)
)
default_supabase_studio_url = "db.0.knoe.dev" if mode == "k8s" else "db.prole.org"
env["SUPABASE_STUDIO_URL"] = (
self._get_input("init_cluster.supabase_studio_url", "db.prole.org").strip()
or "db.prole.org"
self._get_input("init_cluster.supabase_studio_url", default_supabase_studio_url).strip()
or default_supabase_studio_url
)
env["SUPABASE_AUTH_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_auth_enabled", True)
@ -1861,6 +1859,7 @@ class KnoeInstaller:
return str(mode).strip() or DEFAULT_DB_CLUSTER_MODE
def _cluster_kubecontext(self, role: str) -> str:
role = "app" if str(role).strip().lower() == "app" else "db"
key = (
"init_cluster.app_cluster_kubecontext"
if role == "app"
@ -1872,12 +1871,40 @@ class KnoeInstaller:
else "env_setup.DB_CLUSTER_KUBECONTEXT"
)
context = _safe_str(self._get_input(key, "") or self._get_input(fallback_key, ""))
if context:
return context
glob = self.prole_cfg_data.get("Global", {}) or {}
global_key = "APP_CLUSTER_KUBECONTEXT" if role == "app" else "DB_CLUSTER_KUBECONTEXT"
context = _safe_str(glob.get(global_key, ""))
if not context:
context = _safe_str(glob.get(global_key, ""))
opposite_key = (
"init_cluster.db_cluster_kubecontext"
if role == "app"
else "init_cluster.app_cluster_kubecontext"
)
opposite_fallback_key = (
"env_setup.DB_CLUSTER_KUBECONTEXT"
if role == "app"
else "env_setup.APP_CLUSTER_KUBECONTEXT"
)
opposite_global_key = "DB_CLUSTER_KUBECONTEXT" if role == "app" else "APP_CLUSTER_KUBECONTEXT"
opposite_context = _safe_str(
self._get_input(opposite_key, "")
or self._get_input(opposite_fallback_key, "")
or glob.get(opposite_global_key, "")
)
if context and opposite_context and context == opposite_context:
raise RuntimeError(
f"Refusing {role.upper()} targeting: APP/DB contexts must differ, both resolve to '{context}'."
)
if self._deployment_mode() == "k8s":
if not context:
required_key = "APP_CLUSTER_KUBECONTEXT" if role == "app" else "DB_CLUSTER_KUBECONTEXT"
raise RuntimeError(
f"Missing required {required_key} for {role.upper()} cluster operation in k8s mode."
)
return context
if context:
return context
@ -3984,6 +4011,21 @@ class KnoeConsoleInstaller(KnoeInstaller):
def _run_cmd(
self, cmd, cwd=None, env=None, stdin_text=None, on_stdout=None, on_stderr=None
) -> int:
if isinstance(cmd, (list, tuple)) and cmd:
binary = Path(str(cmd[0])).name
if binary in {"kubectl", "helm"}:
target_role = "UNKNOWN"
target_ctx = ""
if isinstance(env, dict):
target_role = str(env.get("KNOE_CLUSTER_ROLE") or "").strip().upper() or "UNKNOWN"
target_ctx = str(
env.get("KUBECTL_CONTEXT")
or env.get("KUBE_CONTEXT_NAME")
or env.get("KUBECONTEXT")
or ""
).strip()
self.log(f"[TARGET {target_role}] {binary} context={target_ctx or '<unset>'}")
stdin_handle = subprocess.PIPE if stdin_text else None
if isinstance(cmd, str):
proc = subprocess.Popen(
@ -4054,6 +4096,21 @@ class KnoeConsoleInstaller(KnoeInstaller):
if mode:
mode_args = ["--mode", mode]
target_role = "UNKNOWN"
target_ctx = ""
if isinstance(env, dict):
target_role = str(env.get("KNOE_CLUSTER_ROLE") or "").strip().upper() or "UNKNOWN"
target_ctx = str(
env.get("KUBECTL_CONTEXT")
or env.get("KUBE_CONTEXT_NAME")
or env.get("KUBECONTEXT")
or ""
).strip()
if target_ctx or target_role != "UNKNOWN":
self.log(
f"[TARGET {target_role}] script={script_name} context={target_ctx or '<unset>'}"
)
return self.controller.run_script(
script_name,
args=mode_args + (args or []),
@ -7055,9 +7112,12 @@ class KnoeConsoleInstaller(KnoeInstaller):
Uses ``supabase_studio_url`` as the source of truth for ingress host.
Operates only on Studio resources does not restart other Supabase components.
"""
default_supabase_studio_url = (
"db.0.knoe.dev" if self._deployment_mode() == "k8s" else "db.prole.org"
)
studio_url = (
self._get_input("init_cluster.supabase_studio_url", "db.prole.org").strip()
or "db.prole.org"
self._get_input("init_cluster.supabase_studio_url", default_supabase_studio_url).strip()
or default_supabase_studio_url
)
supabase_ns = (os.environ.get("SUPABASE_NAMESPACE") or "").strip() or "supabase"
self.log(f"[REPAIR] Reconciling Supabase Studio → ingress host: {studio_url}")

View File

@ -2097,8 +2097,9 @@ def _render_prole_cfg(
get_section("Global", "supabase_hostname"),
get_section("Global", "SUPABASE_HOSTNAME"),
)
default_supabase_hostname = "db.0.knoe.dev" if runtime_mode == "prod" else "db.prole.org"
if not base_supabase_hostname:
base_supabase_hostname = "db.prole.org"
base_supabase_hostname = default_supabase_hostname
def derive_value(current: str, derived: str, placeholder: str) -> str:
"""Return a placeholder when `current` is unset or matches the derived value."""
@ -2162,7 +2163,7 @@ def _render_prole_cfg(
user_section["SERVICE_HOSTNAME"] = base_service_hostname
# Canonical public hostname for Supabase (front-door)
if base_supabase_hostname and base_supabase_hostname != "db.prole.org":
if base_supabase_hostname and base_supabase_hostname != default_supabase_hostname:
user_section["supabase_hostname"] = base_supabase_hostname
# Derived values for repeated touch-points

View File

@ -53,15 +53,44 @@ def build_kubectl_env_for_cluster(
cluster_role: str,
) -> dict:
env = dict(base_env or os.environ)
if kubecontext:
env["KUBECONTEXT"] = kubecontext
env["KUBE_CONTEXT_NAME"] = kubecontext
env["KUBECTL_CONTEXT"] = kubecontext
role = (cluster_role or "").strip().lower()
if role not in {"app", "db"}:
raise ValueError(f"Unsupported cluster role '{cluster_role}'. Expected 'app' or 'db'.")
required_key = "APP_CLUSTER_KUBECONTEXT" if role == "app" else "DB_CLUSTER_KUBECONTEXT"
opposite_key = "DB_CLUSTER_KUBECONTEXT" if role == "app" else "APP_CLUSTER_KUBECONTEXT"
required_ctx = str(env.get(required_key) or "").strip()
opposite_ctx = str(env.get(opposite_key) or "").strip()
explicit_ctx = str(kubecontext or "").strip() or required_ctx
if not explicit_ctx:
raise ValueError(
f"Missing required kube context for {role.upper()} cluster targeting. "
f"Set {required_key} and pass an explicit context."
)
if opposite_ctx and explicit_ctx == opposite_ctx:
raise ValueError(
f"Refusing {role.upper()} operation: kube context '{explicit_ctx}' matches "
f"{opposite_key}."
)
if required_ctx and explicit_ctx != required_ctx:
raise ValueError(
f"Refusing {role.upper()} operation with kube context '{explicit_ctx}': "
f"expected {required_key}='{required_ctx}'."
)
env["KUBECTEXT"] = explicit_ctx
env["KUBE_CONTEXT_NAME"] = explicit_ctx
env["KUBECTL_CONTEXT"] = explicit_ctx
if role == "app":
env.setdefault("APP_CLUSTER_KUBECONTEXT", explicit_ctx)
else:
env.setdefault("DB_CLUSTER_KUBECONTEXT", explicit_ctx)
env["CLUSTER_NAME"] = cluster_name
env["KNOE_CLUSTER_ROLE"] = cluster_role
if cluster_role == "app":
env["KNOE_CLUSTER_ROLE"] = role
if role == "app":
env["KNOE_APP_CLUSTER_NAME"] = cluster_name
elif cluster_role == "db":
elif role == "db":
env["KNOE_DB_CLUSTER_NAME"] = cluster_name
return env

View File

@ -141,6 +141,18 @@ def _normalize_hostname(raw_value: str, fallback: str) -> str:
return token.split("/", 1)[0].strip() or fallback
def _derive_api_hostname_from_studio(studio_hostname_raw: str, auth_hostname: str, mode: str) -> str:
if mode != "k8s":
return studio_hostname_raw
normalized_studio = _normalize_hostname(studio_hostname_raw, fallback="db.0.knoe.dev")
if mode == "k8s":
if normalized_studio.startswith("db."):
return f"api.{normalized_studio[3:]}"
return auth_hostname
return studio_hostname_raw
def _validate_unique_ingress_claims(claims: dict[str, list[str]]) -> None:
claimed: dict[tuple[str, str], str] = {}
for owner, hosts in claims.items():
@ -323,7 +335,7 @@ def _build_overlay(cfg: configparser.ConfigParser, args: argparse.Namespace) ->
if not studio_hostname_raw:
studio_hostname_raw = legacy_supabase_hostname_raw
api_host_fallback = auth_hostname if mode == "k8s" else studio_hostname_raw
api_host_fallback = _derive_api_hostname_from_studio(studio_hostname_raw, auth_hostname, mode)
api_hostname_raw = _first(
os.environ.get("SUPABASE_API_URL", ""),

View File

@ -0,0 +1,297 @@
#!/usr/bin/env bash
# Unit test for etc/init_monitoring.sh (k8s mode): storage class selection must be
# cluster-aware and fail early when configured classes do not exist.
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd)
ETC_DIR="$PROLE_HOME/etc"
SCRIPT_UNDER_TEST="$ETC_DIR/init_monitoring.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
MOCK_CALLS_LOG="$TMP_DIR/mock_calls.log"
HELM_STATUS_DIR="$TMP_DIR/helm_status"
NS_EXISTS_FILE="$TMP_DIR/ns_exists"
mkdir -p "$HELM_STATUS_DIR"
echo "pending-install" > "$HELM_STATUS_DIR/kps"
echo "1" > "$NS_EXISTS_FILE"
export MOCK_CALLS_LOG
export HELM_STATUS_DIR
export NS_EXISTS_FILE
export TMP_DIR
write_mock() {
local name="$1"
cat > "$TMP_DIR/$name"
chmod +x "$TMP_DIR/$name"
}
write_mock kubectl <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "Mocked kubectl called with $*" >> "$MOCK_CALLS_LOG"
if [[ "${1:-}" == "-n" ]]; then
shift 2
fi
if [[ "${1:-}" == "get" && "${2:-}" == "namespace" && "${3:-}" == "monitoring" ]]; then
if [[ "$(cat "$NS_EXISTS_FILE" 2>/dev/null || echo 0)" == "1" ]]; then
exit 0
fi
exit 1
fi
if [[ "${1:-}" == "create" && "${2:-}" == "namespace" && "${3:-}" == "monitoring" ]]; then
echo 1 > "$NS_EXISTS_FILE"
exit 0
fi
if [[ "${1:-}" == "delete" && "${2:-}" == "namespace" && "${3:-}" == "monitoring" ]]; then
echo 0 > "$NS_EXISTS_FILE"
exit 0
fi
if [[ "${1:-}" == "get" && "${2:-}" == "pvc" && "${3:-}" == "-o" && "${4:-}" == "json" ]]; then
echo '{"items":[]}'
exit 0
fi
if [[ "${1:-}" == "get" && "${2:-}" == "nodes" ]]; then
echo "node/prole-gke-node-0"
exit 0
fi
if [[ "${1:-}" == "get" && "${2:-}" == "storageclass" && "${3:-}" == "-o" ]]; then
printf '%s\n' "${DEFAULT_STORAGE_CLASS:-}"
exit 0
fi
if [[ "${1:-}" == "get" && "${2:-}" == "storageclass" ]]; then
target="${3:-}"
IFS=',' read -r -a scs <<< "${AVAILABLE_STORAGE_CLASSES:-}"
for sc in "${scs[@]}"; do
if [[ "$sc" == "$target" ]]; then
exit 0
fi
done
exit 1
fi
if [[ "${1:-}" == "apply" ]]; then
exit 0
fi
exit 0
EOF
write_mock helm <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "Mocked helm called with $*" >> "$MOCK_CALLS_LOG"
cmd="${1:-}"
shift || true
status_file_for_release() {
local release="$1"
echo "$HELM_STATUS_DIR/$release"
}
case "$cmd" in
status)
release="${1:-}"
st="$(cat "$(status_file_for_release "$release")" 2>/dev/null || true)"
if [[ -z "$st" ]]; then
echo '{}'
exit 0
fi
echo '{"info":{"status":"'"$st"'"}}'
;;
history)
echo "REVISION UPDATED STATUS CHART"
echo "1 2026-01-01 00:00:00 unknown kube-prometheus-stack"
;;
repo)
exit 0
;;
upgrade)
release=""
if [[ "${1:-}" == "--install" ]]; then
release="${2:-}"
else
release="${1:-}"
fi
for ((i=1; i<=$#; i++)); do
if [[ "${!i}" == "-f" ]]; then
j=$((i+1))
vf="${!j:-}"
if [[ -n "${vf}" && -f "${vf}" && -n "${release}" ]]; then
cp "${vf}" "${TMP_DIR}/helm_values_${release}.yaml" 2>/dev/null || true
fi
fi
done
if [[ -n "$release" ]]; then
echo "deployed" > "$(status_file_for_release "$release")"
fi
echo "Release upgraded"
exit 0
;;
uninstall)
release="${1:-}"
if [[ -n "$release" ]]; then
: > "$(status_file_for_release "$release")"
fi
echo "Release uninstalled"
exit 0
;;
*)
exit 0
;;
esac
EOF
write_mock jq <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "Mocked jq called with $*" >> "$MOCK_CALLS_LOG"
query=""
for arg in "$@"; do
query="$arg"
done
input="$(cat)"
if [[ "$query" == *".info.status"* ]]; then
echo "$input" | sed -n 's/.*"status"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
exit 0
fi
exit 0
EOF
for tool in curl docker k3d ansible-playbook tofu terraform ollama; do
cat > "$TMP_DIR/$tool" <<EOF
#!/usr/bin/env bash
set -euo pipefail
echo "Mocked $tool called with \$*" >> "$MOCK_CALLS_LOG"
exit 0
EOF
chmod +x "$TMP_DIR/$tool"
done
mkdir -p "$TMP_DIR/conf"
cat <<C_EOF > "$TMP_DIR/conf/prole.cfg"
[globals]
prole.home = $PROLE_HOME
prole.mode = k8s
[k3s]
server = https://localhost:6443
token = test-token
C_EOF
export PATH="$TMP_DIR:$PATH"
export PROLE_HOME="$PROLE_HOME"
export PROLE_CONF="$TMP_DIR/conf"
export PROLE_SERVICE="$PROLE_HOME"
export NAMESPACE="test-ns"
export PROLE_PASSWD="test-password"
export HELM_WAIT_TIMEOUT=1
export HELM_WAIT_INTERVAL=0.2
export MONITORING_RESET_TIMEOUT=2
export MONITORING_RESET_INTERVAL=0.2
export MONITORING_STABILIZE_TIMEOUT=1
export MONITORING_READY_TIMEOUT=1s
export MONITORING_RESET_RETRIES=1
export HELM_UPGRADE_RETRIES=2
export HELM_RETRY_DELAY=0
export MONITORING_HELM_TIMEOUT=1s
run_initialize() {
local label="$1"
set +e
bash "$SCRIPT_UNDER_TEST" initialize > "$TMP_DIR/${label}.stdout" 2> "$TMP_DIR/${label}.stderr"
local rc=$?
set -e
echo "$rc"
}
# Scenario 1: k8s mode should use an existing default StorageClass and avoid k3s-local fallbacks.
export DEFAULT_STORAGE_CLASS="garage-hdd"
export AVAILABLE_STORAGE_CLASSES="garage-hdd"
unset MONITORING_STORAGE_CLASS || true
RC=$(run_initialize "default_sc")
if [[ "$RC" -ne 0 ]]; then
echo "FAILURE: expected success for default storage class scenario"
echo "--- stderr ---"
cat "$TMP_DIR/default_sc.stderr"
echo "--- stdout ---"
cat "$TMP_DIR/default_sc.stdout"
exit 1
fi
if [[ ! -f "$TMP_DIR/helm_values_kps.yaml" ]]; then
echo "FAILURE: expected helm values file for kps release"
ls -la "$TMP_DIR" || true
exit 1
fi
if ! grep -q "storageClassName: garage-hdd" "$TMP_DIR/helm_values_kps.yaml"; then
echo "FAILURE: expected rendered monitoring storageClassName to use default class garage-hdd"
sed -n '1,260p' "$TMP_DIR/helm_values_kps.yaml" || true
exit 1
fi
if grep -q "merlin-local-iscsi" "$TMP_DIR/helm_values_kps.yaml"; then
echo "FAILURE: did not expect k3s-local storage class names in k8s mode values"
sed -n '1,260p' "$TMP_DIR/helm_values_kps.yaml" || true
exit 1
fi
# Scenario 2: configured stale class must fail early with a clear error.
echo "pending-install" > "$HELM_STATUS_DIR/kps"
echo "" > "$HELM_STATUS_DIR/grafana"
rm -f "$TMP_DIR/helm_values_kps.yaml" "$TMP_DIR/helm_values_grafana.yaml"
export MONITORING_STORAGE_CLASS="merlin-local-iscsi-d004-grafana"
export AVAILABLE_STORAGE_CLASSES="garage-hdd"
RC=$(run_initialize "missing_sc")
if [[ "$RC" -eq 0 ]]; then
echo "FAILURE: expected failure for missing configured monitoring storage class"
echo "--- stderr ---"
cat "$TMP_DIR/missing_sc.stderr"
echo "--- stdout ---"
cat "$TMP_DIR/missing_sc.stdout"
exit 1
fi
if ! grep -q "Monitoring storageClass validation failed" "$TMP_DIR/missing_sc.stderr"; then
echo "FAILURE: expected clear validation error message for missing storage class"
echo "--- stderr ---"
cat "$TMP_DIR/missing_sc.stderr"
exit 1
fi
if ! grep -q "merlin-local-iscsi-d004-grafana" "$TMP_DIR/missing_sc.stderr"; then
echo "FAILURE: expected missing stale storage class name in error output"
echo "--- stderr ---"
cat "$TMP_DIR/missing_sc.stderr"
exit 1
fi
echo "SUCCESS"
exit 0

View File

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# Regression test: config keys containing hyphens must be normalized to
# shell-safe variable names instead of crashing with "invalid variable name".
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PROLE_HOME=$(cd "$SCRIPT_DIR/../.." && pwd)
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
BIN_DIR="$TMP_DIR/bin"
mkdir -p "$BIN_DIR"
cat >"$BIN_DIR/kubectl" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$BIN_DIR/kubectl"
cat >"$BIN_DIR/kubectx" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$BIN_DIR/kubectx"
cat >"$BIN_DIR/kubens" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$BIN_DIR/kubens"
export PATH="$BIN_DIR:$PATH"
CONF_DIR="$TMP_DIR/conf"
mkdir -p "$CONF_DIR/service"
cat >"$CONF_DIR/service/prole.cfg" <<EOF
[Global]
DEPLOYMENT_MODE = local
NAMESPACE = test-ns
PROLE_HOME = $PROLE_HOME
PROLE_SERVICE = $TMP_DIR/service
dependencies_gke-gcloud-auth-plugin_install = 1
EOF
ln -snf "service/prole.cfg" "$CONF_DIR/prole.cfg"
export PROLE_CONF="$CONF_DIR"
export PROLE_HOME
export PROLE_SERVICE="$TMP_DIR/service"
# shellcheck disable=SC1090
source "$PROLE_HOME/etc/prole_cfg.sh"
expected="1"
actual="${dependencies_gke_gcloud_auth_plugin_install:-}"
if [[ "$actual" != "$expected" ]]; then
echo "FAILURE: expected normalized key dependencies_gke_gcloud_auth_plugin_install=$expected, got '${actual:-<unset>}'" >&2
exit 1
fi
echo "SUCCESS"

View File

@ -1,5 +1,6 @@
from pathlib import Path
import sys
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
@ -25,6 +26,39 @@ def test_build_kubectl_env_for_cluster_sets_explicit_role_and_context():
assert env["KNOE_APP_CLUSTER_NAME"] == "knoe-dev-0"
def test_build_kubectl_env_for_cluster_requires_explicit_context_when_missing():
with pytest.raises(ValueError, match="Missing required kube context"):
gke_clusters.build_kubectl_env_for_cluster(
base_env={},
kubecontext="",
cluster_name="knoe-dev-0",
cluster_role="app",
)
def test_build_kubectl_env_for_cluster_rejects_mismatched_required_role_context():
with pytest.raises(ValueError, match="expected APP_CLUSTER_KUBECONTEXT"):
gke_clusters.build_kubectl_env_for_cluster(
base_env={"APP_CLUSTER_KUBECONTEXT": "ctx-app"},
kubecontext="ctx-other",
cluster_name="knoe-dev-0",
cluster_role="app",
)
def test_build_kubectl_env_for_cluster_rejects_cross_role_context_usage():
with pytest.raises(ValueError, match="matches DB_CLUSTER_KUBECONTEXT"):
gke_clusters.build_kubectl_env_for_cluster(
base_env={
"APP_CLUSTER_KUBECONTEXT": "ctx-app",
"DB_CLUSTER_KUBECONTEXT": "ctx-db",
},
kubecontext="ctx-db",
cluster_name="knoe-dev-0",
cluster_role="app",
)
def test_get_cluster_credentials_builds_expected_gcloud_command(monkeypatch):
called = {}

View File

@ -108,7 +108,7 @@ def test_render_supabase_splits_api_and_studio_hosts_for_k8s(tmp_path: Path, mon
[Global]
NAMESPACE = test-ns
DEPLOYMENT_MODE = k8s
SUPABASE_API_HOSTNAME = api.knoe.dev
SUPABASE_API_HOSTNAME = api.0.knoe.dev
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
STORAGE_BACKEND = local
""",
@ -118,15 +118,52 @@ def test_render_supabase_splits_api_and_studio_hosts_for_k8s(tmp_path: Path, mon
args = Namespace(output_dir=str(tmp_path / "gen"), manifests_dir=str(tmp_path / "k8s"))
overlay, _meta = _build_overlay(cfg, args)
assert overlay["ingress"]["hosts"][0]["host"] == "api.knoe.dev"
assert overlay["ingress"]["hosts"][0]["host"] == "api.0.knoe.dev"
assert overlay["studioIngress"]["hosts"][0]["host"] == "db.0.knoe.dev"
assert overlay["ingress"]["className"] == "gce"
assert overlay["studioIngress"]["className"] == "gce"
assert overlay["environment"]["auth"]["API_EXTERNAL_URL"] == "https://api.knoe.dev"
assert overlay["environment"]["auth"]["API_EXTERNAL_URL"] == "https://api.0.knoe.dev"
assert overlay["environment"]["auth"]["GOTRUE_SITE_URL"] == "https://db.0.knoe.dev"
assert overlay["environment"]["studio"]["SUPABASE_PUBLIC_URL"] == "https://db.0.knoe.dev"
assert overlay["environment"]["auth"]["GOTRUE_URI_ALLOW_LIST"] == (
"https://db.0.knoe.dev/**,https://api.knoe.dev/**"
"https://db.0.knoe.dev/**,https://api.0.knoe.dev/**"
)
def test_render_supabase_derives_env_indexed_api_host_for_k8s(tmp_path: Path, monkeypatch):
monkeypatch.delenv("SUPABASE_HOST", raising=False)
monkeypatch.delenv("SUPABASE_HOSTNAME", raising=False)
monkeypatch.delenv("SUPABASE_API_HOSTNAME", raising=False)
monkeypatch.delenv("KNOE_DB_NAMESPACE", raising=False)
monkeypatch.delenv("KNOE_DB_SERVICE", raising=False)
cfg_path = tmp_path / "prole.cfg"
_write_cfg(
cfg_path,
"""
[Inputs]
init_password.db_password = test-password
init_password.db_host_port = 5432
[Global]
NAMESPACE = test-ns
DEPLOYMENT_MODE = k8s
AUTH_HOSTNAME = api.knoe.dev
SUPABASE_STUDIO_HOSTNAME = db.33.knoe.dev
STORAGE_BACKEND = local
""",
)
cfg = _read_cfg(cfg_path)
args = Namespace(output_dir=str(tmp_path / "gen"), manifests_dir=str(tmp_path / "k8s"))
overlay, _meta = _build_overlay(cfg, args)
assert overlay["ingress"]["hosts"][0]["host"] == "api.33.knoe.dev"
assert overlay["studioIngress"]["hosts"][0]["host"] == "db.33.knoe.dev"
assert overlay["environment"]["auth"]["API_EXTERNAL_URL"] == "https://api.33.knoe.dev"
assert overlay["environment"]["auth"]["GOTRUE_SITE_URL"] == "https://db.33.knoe.dev"
assert overlay["environment"]["auth"]["GOTRUE_URI_ALLOW_LIST"] == (
"https://db.33.knoe.dev/**,https://api.33.knoe.dev/**"
)
@ -184,7 +221,7 @@ def test_render_supabase_rejects_db_cluster_context_for_public_ingress(tmp_path:
[Global]
NAMESPACE = test-ns
DEPLOYMENT_MODE = k8s
SUPABASE_API_HOSTNAME = api.knoe.dev
SUPABASE_API_HOSTNAME = api.0.knoe.dev
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
STORAGE_BACKEND = local
""",
@ -218,7 +255,7 @@ def test_render_supabase_rejects_traefik_class_in_k8s_without_opt_in(tmp_path: P
[Global]
NAMESPACE = test-ns
DEPLOYMENT_MODE = k8s
SUPABASE_API_HOSTNAME = api.knoe.dev
SUPABASE_API_HOSTNAME = api.0.knoe.dev
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
SUPABASE_INGRESS_CLASS = traefik
STORAGE_BACKEND = local

View File

@ -61,6 +61,7 @@ init_password.db_host_port = 5432
{extra}
[Global]
NAMESPACE = test-ns
STORAGE_BACKEND = local
""",
)
return _read_cfg(cfg_path)
@ -356,7 +357,7 @@ def test_default_inputs_contains_supabase_component_flags(tmp_path):
for key in expected_keys:
assert key in defaults, f"Missing default for {key!r}"
assert defaults["init_cluster.supabase_studio_url"] == "db.prole.org"
assert defaults["init_cluster.supabase_studio_url"] == "db.0.knoe.dev"
assert defaults["init_cluster.supabase_studio_enabled"] in ("false", "False", False)
@ -548,8 +549,8 @@ def test_supabase_deploy_includes_knoe_schema_and_lint_remediation_sql():
assert "GRANT CREATE ON DATABASE postgres TO supabase_storage_admin;" in script
assert "GRANT USAGE, CREATE ON SCHEMA public TO supabase_storage_admin;" in script
assert "ALTER EXTENSION %I SET SCHEMA knoe" in script
assert "ALTER TABLE public.spatial_ref_sys ENABLE ROW LEVEL SECURITY;" in script
assert "CREATE POLICY spatial_ref_sys_select_all ON public.spatial_ref_sys" in script
assert "ALTER TABLE %I.spatial_ref_sys ENABLE ROW LEVEL SECURITY" in script
assert "CREATE POLICY spatial_ref_sys_select_all ON %I.spatial_ref_sys" in script
def test_reset_k3s_namespace_delete_db_deletes_pvcs_and_bound_pvs(tmp_path, monkeypatch):