mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 17:24:30 +00:00
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:
parent
be85d2e4b2
commit
bfa712273e
10
conf/gke.cfg
10
conf/gke.cfg
@ -117,19 +117,19 @@ PROLE_HOME = $HOME/dev/prole
|
|||||||
REGISTRY_NAMESPACE = knoe-system
|
REGISTRY_NAMESPACE = knoe-system
|
||||||
SERVICE_NAMESPACE = knoe-system
|
SERVICE_NAMESPACE = knoe-system
|
||||||
SUPABASE_HOSTNAME = db.0.knoe.dev
|
SUPABASE_HOSTNAME = db.0.knoe.dev
|
||||||
SUPABASE_API_HOSTNAME = api.knoe.dev
|
SUPABASE_API_HOSTNAME = api.0.knoe.dev
|
||||||
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev,db.prole.org
|
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
||||||
SUPABASE_INGRESS_CLASS = gce
|
SUPABASE_INGRESS_CLASS = gce
|
||||||
GITLAB_DOMAIN = git.knoe.dev
|
GITLAB_DOMAIN = git.knoe.dev
|
||||||
GITLAB_PUBLIC_HOSTS = git.knoe.dev,git.prole.org
|
GITLAB_PUBLIC_HOSTS = git.knoe.dev
|
||||||
GITLAB_INGRESS_CLASS = gce
|
GITLAB_INGRESS_CLASS = gce
|
||||||
AUTHORITY_ENABLED = true
|
AUTHORITY_ENABLED = true
|
||||||
AUTH_HOSTNAME = api.knoe.dev
|
AUTH_HOSTNAME = api.knoe.dev
|
||||||
AUTH_VERIFY_PATH = /auth/verify
|
AUTH_VERIFY_PATH = /auth/verify
|
||||||
AUTH_LOGIN_PATH = /auth/login
|
AUTH_LOGIN_PATH = /auth/login
|
||||||
AUTH_RESPONSE_HEADERS = X-Prole-User,X-Prole-Email,X-Prole-Groups
|
AUTH_RESPONSE_HEADERS = X-Prole-User,X-Prole-Email,X-Prole-Groups
|
||||||
PROTECTED_GIT_HOSTS = git.knoe.dev,git.prole.org
|
PROTECTED_GIT_HOSTS = git.knoe.dev
|
||||||
PROTECTED_DB_HOSTS = db.0.knoe.dev,db.prole.org
|
PROTECTED_DB_HOSTS = db.0.knoe.dev,api.0.knoe.dev
|
||||||
|
|
||||||
[Welcome]
|
[Welcome]
|
||||||
; No configuration values captured yet for this section.
|
; No configuration values captured yet for this section.
|
||||||
|
|||||||
@ -45,6 +45,65 @@ die() { echo "[ERROR] $*" >&2; exit 2; }
|
|||||||
log() { echo "[INFO] $*"; }
|
log() { echo "[INFO] $*"; }
|
||||||
warn() { echo "[WARN] $*" >&2; }
|
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() {
|
is_truthy() {
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
1|true|TRUE|True|yes|YES|on|ON|y|Y)
|
1|true|TRUE|True|yes|YES|on|ON|y|Y)
|
||||||
@ -65,11 +124,12 @@ assert_public_ingress_targeting() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
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 db_ctx="${DB_CLUSTER_KUBECONTEXT:-}"
|
||||||
local active_ctx="${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}"
|
local active_ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
|
||||||
if [[ -z "$active_ctx" ]]; then
|
|
||||||
active_ctx="$(kubectl config current-context 2>/dev/null || true)"
|
if [[ -z "$app_ctx" || -z "$active_ctx" ]]; then
|
||||||
|
die "Explicit APP cluster context is required for GitLab ingress operations in k8s mode."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local host_count=0
|
local host_count=0
|
||||||
@ -98,7 +158,12 @@ assert_unique_ingress_host_claims() {
|
|||||||
local host_csv="${3:-}"
|
local host_csv="${3:-}"
|
||||||
[[ -n "$host_csv" ]] || return 0
|
[[ -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 json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@ -106,9 +171,15 @@ import sys
|
|||||||
requested_hosts = {h.strip().lower() for h in (sys.argv[1] or "").split(",") if h.strip()}
|
requested_hosts = {h.strip().lower() for h in (sys.argv[1] or "").split(",") if h.strip()}
|
||||||
target_ns = sys.argv[2]
|
target_ns = sys.argv[2]
|
||||||
target_name = sys.argv[3]
|
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:
|
try:
|
||||||
raw = subprocess.check_output(["kubectl", "get", "ingress", "-A", "-o", "json"], text=True)
|
raw = subprocess.check_output(cmd, text=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,73 @@ if [[ -z "${PROLE_MODE:-}" ]]; then
|
|||||||
export PROLE_MODE="k3s"
|
export PROLE_MODE="k3s"
|
||||||
fi
|
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
|
if [[ "${COMMON_CORE_HELP:-0}" == 1 ]]; then
|
||||||
common_core_usage "$0"
|
common_core_usage "$0"
|
||||||
exit 0
|
exit 0
|
||||||
@ -143,11 +210,13 @@ assert_public_ingress_targeting() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
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 db_ctx="${DB_CLUSTER_KUBECONTEXT:-}"
|
||||||
local active_ctx="${KUBECONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECTL_CONTEXT:-}}}"
|
local active_ctx="${KUBECTL_CONTEXT:-${KUBE_CONTEXT_NAME:-${KUBECONTEXT:-}}}"
|
||||||
if [[ -z "$active_ctx" ]]; then
|
|
||||||
active_ctx="$(kubectl config current-context 2>/dev/null || true)"
|
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
|
fi
|
||||||
|
|
||||||
local host_count=0
|
local host_count=0
|
||||||
@ -181,7 +250,13 @@ assert_unique_ingress_host_claims() {
|
|||||||
local host_csv="${3:-}"
|
local host_csv="${3:-}"
|
||||||
[[ -n "$host_csv" ]] || return 0
|
[[ -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 json
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@ -189,9 +264,15 @@ import sys
|
|||||||
requested_hosts = {h.strip().lower() for h in (sys.argv[1] or "").split(",") if h.strip()}
|
requested_hosts = {h.strip().lower() for h in (sys.argv[1] or "").split(",") if h.strip()}
|
||||||
target_ns = sys.argv[2]
|
target_ns = sys.argv[2]
|
||||||
target_name = sys.argv[3]
|
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:
|
try:
|
||||||
raw = subprocess.check_output(["kubectl", "get", "ingress", "-A", "-o", "json"], text=True)
|
raw = subprocess.check_output(cmd, text=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
|||||||
@ -392,13 +392,13 @@ class KnoeInstaller:
|
|||||||
else:
|
else:
|
||||||
cmd = ["kubectl"]
|
cmd = ["kubectl"]
|
||||||
|
|
||||||
# 2. Add context if explicitly selected (and not already in the command)
|
# 2. Add context explicitly (and never rely on ambient current-context)
|
||||||
ctx = self._get_input("init_cluster.selected_kubectx", "").strip()
|
if mode == "k8s":
|
||||||
# For k8s/prod mode, fall back to the GKE context saved in prole_cfg_data
|
# In split-cluster GKE mode default kubectl access must target APP cluster
|
||||||
if not ctx and mode == "k8s":
|
# unless a step uses an explicit role-specific env override.
|
||||||
ctx = (
|
ctx = self._cluster_kubecontext("app").strip()
|
||||||
(self.prole_cfg_data.get("Global", {}) or {}).get("KUBECONTEXT", "") or ""
|
else:
|
||||||
).strip()
|
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:
|
if ctx and cmd[0] == "kubectl" and "--server" not in cmd and "--context" not in cmd:
|
||||||
cmd.extend(["--context", ctx])
|
cmd.extend(["--context", ctx])
|
||||||
return cmd
|
return cmd
|
||||||
@ -1656,18 +1656,15 @@ class KnoeInstaller:
|
|||||||
if Path(default_kube).exists():
|
if Path(default_kube).exists():
|
||||||
env["KUBECONFIG"] = default_kube
|
env["KUBECONFIG"] = default_kube
|
||||||
elif mode == "k8s":
|
elif mode == "k8s":
|
||||||
# For GKE/prod clusters, gcloud get-credentials writes to ~/.kube/config.
|
# For GKE/prod split-cluster deploys we require explicit role context.
|
||||||
# Read the context that was saved after cluster selection.
|
gke_context = self._cluster_kubecontext(cluster_role).strip()
|
||||||
gke_context = (
|
|
||||||
self._cluster_kubecontext(cluster_role)
|
|
||||||
or (self.prole_cfg_data.get("Global", {}) or {}).get("KUBECONTEXT", "")
|
|
||||||
or ""
|
|
||||||
).strip()
|
|
||||||
default_kube = str(Path.home() / ".kube" / "config")
|
default_kube = str(Path.home() / ".kube" / "config")
|
||||||
if Path(default_kube).exists():
|
if Path(default_kube).exists():
|
||||||
env["KUBECONFIG"] = default_kube
|
env["KUBECONFIG"] = default_kube
|
||||||
if gke_context:
|
if gke_context:
|
||||||
env["KUBECONTEXT"] = gke_context
|
env["KUBECONTEXT"] = gke_context
|
||||||
|
env["KUBE_CONTEXT_NAME"] = gke_context
|
||||||
|
env["KUBECTL_CONTEXT"] = gke_context
|
||||||
else:
|
else:
|
||||||
env.pop("KUBECONFIG", None)
|
env.pop("KUBECONFIG", None)
|
||||||
env["SERVICE_NAMESPACE"] = service_ns
|
env["SERVICE_NAMESPACE"] = service_ns
|
||||||
@ -1680,9 +1677,10 @@ class KnoeInstaller:
|
|||||||
env["SUPABASE_STUDIO_ENABLED"] = _bool_str(
|
env["SUPABASE_STUDIO_ENABLED"] = _bool_str(
|
||||||
self._get_input_bool("init_cluster.supabase_studio_enabled", False)
|
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"] = (
|
env["SUPABASE_STUDIO_URL"] = (
|
||||||
self._get_input("init_cluster.supabase_studio_url", "db.prole.org").strip()
|
self._get_input("init_cluster.supabase_studio_url", default_supabase_studio_url).strip()
|
||||||
or "db.prole.org"
|
or default_supabase_studio_url
|
||||||
)
|
)
|
||||||
env["SUPABASE_AUTH_ENABLED"] = _bool_str(
|
env["SUPABASE_AUTH_ENABLED"] = _bool_str(
|
||||||
self._get_input_bool("init_cluster.supabase_auth_enabled", True)
|
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
|
return str(mode).strip() or DEFAULT_DB_CLUSTER_MODE
|
||||||
|
|
||||||
def _cluster_kubecontext(self, role: str) -> str:
|
def _cluster_kubecontext(self, role: str) -> str:
|
||||||
|
role = "app" if str(role).strip().lower() == "app" else "db"
|
||||||
key = (
|
key = (
|
||||||
"init_cluster.app_cluster_kubecontext"
|
"init_cluster.app_cluster_kubecontext"
|
||||||
if role == "app"
|
if role == "app"
|
||||||
@ -1872,12 +1871,40 @@ class KnoeInstaller:
|
|||||||
else "env_setup.DB_CLUSTER_KUBECONTEXT"
|
else "env_setup.DB_CLUSTER_KUBECONTEXT"
|
||||||
)
|
)
|
||||||
context = _safe_str(self._get_input(key, "") or self._get_input(fallback_key, ""))
|
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 {}
|
glob = self.prole_cfg_data.get("Global", {}) or {}
|
||||||
global_key = "APP_CLUSTER_KUBECONTEXT" if role == "app" else "DB_CLUSTER_KUBECONTEXT"
|
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:
|
if context:
|
||||||
return context
|
return context
|
||||||
|
|
||||||
@ -3984,6 +4011,21 @@ class KnoeConsoleInstaller(KnoeInstaller):
|
|||||||
def _run_cmd(
|
def _run_cmd(
|
||||||
self, cmd, cwd=None, env=None, stdin_text=None, on_stdout=None, on_stderr=None
|
self, cmd, cwd=None, env=None, stdin_text=None, on_stdout=None, on_stderr=None
|
||||||
) -> int:
|
) -> 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
|
stdin_handle = subprocess.PIPE if stdin_text else None
|
||||||
if isinstance(cmd, str):
|
if isinstance(cmd, str):
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
@ -4054,6 +4096,21 @@ class KnoeConsoleInstaller(KnoeInstaller):
|
|||||||
if mode:
|
if mode:
|
||||||
mode_args = ["--mode", 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(
|
return self.controller.run_script(
|
||||||
script_name,
|
script_name,
|
||||||
args=mode_args + (args or []),
|
args=mode_args + (args or []),
|
||||||
@ -7055,9 +7112,12 @@ class KnoeConsoleInstaller(KnoeInstaller):
|
|||||||
Uses ``supabase_studio_url`` as the source of truth for ingress host.
|
Uses ``supabase_studio_url`` as the source of truth for ingress host.
|
||||||
Operates only on Studio resources — does not restart other Supabase components.
|
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 = (
|
studio_url = (
|
||||||
self._get_input("init_cluster.supabase_studio_url", "db.prole.org").strip()
|
self._get_input("init_cluster.supabase_studio_url", default_supabase_studio_url).strip()
|
||||||
or "db.prole.org"
|
or default_supabase_studio_url
|
||||||
)
|
)
|
||||||
supabase_ns = (os.environ.get("SUPABASE_NAMESPACE") or "").strip() or "supabase"
|
supabase_ns = (os.environ.get("SUPABASE_NAMESPACE") or "").strip() or "supabase"
|
||||||
self.log(f"[REPAIR] Reconciling Supabase Studio → ingress host: {studio_url}")
|
self.log(f"[REPAIR] Reconciling Supabase Studio → ingress host: {studio_url}")
|
||||||
|
|||||||
@ -2097,8 +2097,9 @@ def _render_prole_cfg(
|
|||||||
get_section("Global", "supabase_hostname"),
|
get_section("Global", "supabase_hostname"),
|
||||||
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:
|
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:
|
def derive_value(current: str, derived: str, placeholder: str) -> str:
|
||||||
"""Return a placeholder when `current` is unset or matches the derived value."""
|
"""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
|
user_section["SERVICE_HOSTNAME"] = base_service_hostname
|
||||||
|
|
||||||
# Canonical public hostname for Supabase (front-door)
|
# 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
|
user_section["supabase_hostname"] = base_supabase_hostname
|
||||||
|
|
||||||
# Derived values for repeated touch-points
|
# Derived values for repeated touch-points
|
||||||
|
|||||||
@ -53,15 +53,44 @@ def build_kubectl_env_for_cluster(
|
|||||||
cluster_role: str,
|
cluster_role: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
env = dict(base_env or os.environ)
|
env = dict(base_env or os.environ)
|
||||||
if kubecontext:
|
role = (cluster_role or "").strip().lower()
|
||||||
env["KUBECONTEXT"] = kubecontext
|
if role not in {"app", "db"}:
|
||||||
env["KUBE_CONTEXT_NAME"] = kubecontext
|
raise ValueError(f"Unsupported cluster role '{cluster_role}'. Expected 'app' or 'db'.")
|
||||||
env["KUBECTL_CONTEXT"] = kubecontext
|
|
||||||
|
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["CLUSTER_NAME"] = cluster_name
|
||||||
env["KNOE_CLUSTER_ROLE"] = cluster_role
|
env["KNOE_CLUSTER_ROLE"] = role
|
||||||
if cluster_role == "app":
|
if role == "app":
|
||||||
env["KNOE_APP_CLUSTER_NAME"] = cluster_name
|
env["KNOE_APP_CLUSTER_NAME"] = cluster_name
|
||||||
elif cluster_role == "db":
|
elif role == "db":
|
||||||
env["KNOE_DB_CLUSTER_NAME"] = cluster_name
|
env["KNOE_DB_CLUSTER_NAME"] = cluster_name
|
||||||
return env
|
return env
|
||||||
|
|
||||||
|
|||||||
@ -141,6 +141,18 @@ def _normalize_hostname(raw_value: str, fallback: str) -> str:
|
|||||||
return token.split("/", 1)[0].strip() or fallback
|
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:
|
def _validate_unique_ingress_claims(claims: dict[str, list[str]]) -> None:
|
||||||
claimed: dict[tuple[str, str], str] = {}
|
claimed: dict[tuple[str, str], str] = {}
|
||||||
for owner, hosts in claims.items():
|
for owner, hosts in claims.items():
|
||||||
@ -323,7 +335,7 @@ def _build_overlay(cfg: configparser.ConfigParser, args: argparse.Namespace) ->
|
|||||||
if not studio_hostname_raw:
|
if not studio_hostname_raw:
|
||||||
studio_hostname_raw = legacy_supabase_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(
|
api_hostname_raw = _first(
|
||||||
os.environ.get("SUPABASE_API_URL", ""),
|
os.environ.get("SUPABASE_API_URL", ""),
|
||||||
|
|||||||
297
tests/etc/test_init_monitoring_k8s_storage_class_validation.sh
Normal file
297
tests/etc/test_init_monitoring_k8s_storage_class_validation.sh
Normal 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
|
||||||
65
tests/etc/test_prole_cfg_hyphenated_keys_normalized.sh
Normal file
65
tests/etc/test_prole_cfg_hyphenated_keys_normalized.sh
Normal 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"
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
import pytest
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
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"
|
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):
|
def test_get_cluster_credentials_builds_expected_gcloud_command(monkeypatch):
|
||||||
called = {}
|
called = {}
|
||||||
|
|
||||||
|
|||||||
@ -108,7 +108,7 @@ def test_render_supabase_splits_api_and_studio_hosts_for_k8s(tmp_path: Path, mon
|
|||||||
[Global]
|
[Global]
|
||||||
NAMESPACE = test-ns
|
NAMESPACE = test-ns
|
||||||
DEPLOYMENT_MODE = k8s
|
DEPLOYMENT_MODE = k8s
|
||||||
SUPABASE_API_HOSTNAME = api.knoe.dev
|
SUPABASE_API_HOSTNAME = api.0.knoe.dev
|
||||||
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
||||||
STORAGE_BACKEND = local
|
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"))
|
args = Namespace(output_dir=str(tmp_path / "gen"), manifests_dir=str(tmp_path / "k8s"))
|
||||||
overlay, _meta = _build_overlay(cfg, args)
|
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["studioIngress"]["hosts"][0]["host"] == "db.0.knoe.dev"
|
||||||
assert overlay["ingress"]["className"] == "gce"
|
assert overlay["ingress"]["className"] == "gce"
|
||||||
assert overlay["studioIngress"]["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"]["auth"]["GOTRUE_SITE_URL"] == "https://db.0.knoe.dev"
|
||||||
assert overlay["environment"]["studio"]["SUPABASE_PUBLIC_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"] == (
|
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]
|
[Global]
|
||||||
NAMESPACE = test-ns
|
NAMESPACE = test-ns
|
||||||
DEPLOYMENT_MODE = k8s
|
DEPLOYMENT_MODE = k8s
|
||||||
SUPABASE_API_HOSTNAME = api.knoe.dev
|
SUPABASE_API_HOSTNAME = api.0.knoe.dev
|
||||||
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
||||||
STORAGE_BACKEND = local
|
STORAGE_BACKEND = local
|
||||||
""",
|
""",
|
||||||
@ -218,7 +255,7 @@ def test_render_supabase_rejects_traefik_class_in_k8s_without_opt_in(tmp_path: P
|
|||||||
[Global]
|
[Global]
|
||||||
NAMESPACE = test-ns
|
NAMESPACE = test-ns
|
||||||
DEPLOYMENT_MODE = k8s
|
DEPLOYMENT_MODE = k8s
|
||||||
SUPABASE_API_HOSTNAME = api.knoe.dev
|
SUPABASE_API_HOSTNAME = api.0.knoe.dev
|
||||||
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev
|
||||||
SUPABASE_INGRESS_CLASS = traefik
|
SUPABASE_INGRESS_CLASS = traefik
|
||||||
STORAGE_BACKEND = local
|
STORAGE_BACKEND = local
|
||||||
|
|||||||
@ -61,6 +61,7 @@ init_password.db_host_port = 5432
|
|||||||
{extra}
|
{extra}
|
||||||
[Global]
|
[Global]
|
||||||
NAMESPACE = test-ns
|
NAMESPACE = test-ns
|
||||||
|
STORAGE_BACKEND = local
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
return _read_cfg(cfg_path)
|
return _read_cfg(cfg_path)
|
||||||
@ -356,7 +357,7 @@ def test_default_inputs_contains_supabase_component_flags(tmp_path):
|
|||||||
for key in expected_keys:
|
for key in expected_keys:
|
||||||
assert key in defaults, f"Missing default for {key!r}"
|
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)
|
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 CREATE ON DATABASE postgres TO supabase_storage_admin;" in script
|
||||||
assert "GRANT USAGE, CREATE ON SCHEMA public 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 EXTENSION %I SET SCHEMA knoe" in script
|
||||||
assert "ALTER TABLE public.spatial_ref_sys ENABLE ROW LEVEL SECURITY;" in script
|
assert "ALTER TABLE %I.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 "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):
|
def test_reset_k3s_namespace_delete_db_deletes_pvcs_and_bound_pvs(tmp_path, monkeypatch):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user