diff --git a/conf/gke.cfg b/conf/gke.cfg index 9614e32..f7a8bc6 100644 --- a/conf/gke.cfg +++ b/conf/gke.cfg @@ -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. diff --git a/etc/init_gitlab.sh b/etc/init_gitlab.sh index 4d92180..4d5be20 100755 --- a/etc/init_gitlab.sh +++ b/etc/init_gitlab.sh @@ -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) diff --git a/etc/init_kong.sh b/etc/init_kong.sh index d794b06..e653b0c 100755 --- a/etc/init_kong.sh +++ b/etc/init_kong.sh @@ -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) diff --git a/knoe/core/actions.py b/knoe/core/actions.py index 6807b4b..6480ae9 100644 --- a/knoe/core/actions.py +++ b/knoe/core/actions.py @@ -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 ''}") + 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 ''}" + ) + 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}") diff --git a/knoe/core/env.py b/knoe/core/env.py index b9d07b9..4f7b5ed 100644 --- a/knoe/core/env.py +++ b/knoe/core/env.py @@ -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 diff --git a/knoe/core/ops/gke_clusters.py b/knoe/core/ops/gke_clusters.py index a2d479d..ebaa4c0 100644 --- a/knoe/core/ops/gke_clusters.py +++ b/knoe/core/ops/gke_clusters.py @@ -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 diff --git a/supabase/helm/render_supabase.py b/supabase/helm/render_supabase.py index 0bc4623..c095dd9 100755 --- a/supabase/helm/render_supabase.py +++ b/supabase/helm/render_supabase.py @@ -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", ""), diff --git a/tests/etc/test_init_monitoring_k8s_storage_class_validation.sh b/tests/etc/test_init_monitoring_k8s_storage_class_validation.sh new file mode 100644 index 0000000..7acd168 --- /dev/null +++ b/tests/etc/test_init_monitoring_k8s_storage_class_validation.sh @@ -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" <> "$MOCK_CALLS_LOG" +exit 0 +EOF + chmod +x "$TMP_DIR/$tool" +done + +mkdir -p "$TMP_DIR/conf" +cat < "$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 \ No newline at end of file diff --git a/tests/etc/test_prole_cfg_hyphenated_keys_normalized.sh b/tests/etc/test_prole_cfg_hyphenated_keys_normalized.sh new file mode 100644 index 0000000..7fd966c --- /dev/null +++ b/tests/etc/test_prole_cfg_hyphenated_keys_normalized.sh @@ -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" <}'" >&2 + exit 1 +fi + +echo "SUCCESS" \ No newline at end of file diff --git a/tests/installer/test_gke_clusters.py b/tests/installer/test_gke_clusters.py index e44c45b..0fd242e 100644 --- a/tests/installer/test_gke_clusters.py +++ b/tests/installer/test_gke_clusters.py @@ -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 = {} diff --git a/tests/test_render_supabase_hostname.py b/tests/test_render_supabase_hostname.py index 36e7785..16cc3bf 100644 --- a/tests/test_render_supabase_hostname.py +++ b/tests/test_render_supabase_hostname.py @@ -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 diff --git a/tests/test_repair_update_and_supabase_flags.py b/tests/test_repair_update_and_supabase_flags.py index f70f5dc..9b52d77 100644 --- a/tests/test_repair_update_and_supabase_flags.py +++ b/tests/test_repair_update_and_supabase_flags.py @@ -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):