mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
Filed in response to the 2026-04-28 14:00 UTC backup outage. An
`install.sh --mode k3d` run with the shell pointed at GKE silently
overwrote the GKE cluster's GCS-backed ObjectStore + ScheduledBackup
with k3d-mode defaults; Garage filled up and CNPG backups failed for
hours before the next manual check. The class of bug is "config says
target cluster A, shell context says target cluster B, installer
proceeds against B without warning."
New shared bash helper at etc/preflight_kubecontext.sh with two
functions:
- verify_kubecontext_matches_config <cfg-path>
Strict gate. Reads [Global] APP_CLUSTER_KUBECONTEXT from the
config and exits 1 if `kubectl config current-context` differs.
Skipped silently when the config has no baked APP_CLUSTER_KUBECONTEXT
(e.g. fresh k3d.cfg) or when there's no live current-context.
- print_kubecontext_notice
Informational. Prints what's about to be inherited so the user
can abort before the TUI launches if it looks wrong. Never fails.
Wiring:
- deploy.sh sources the helper and calls the strict gate against
${PROLE_DEPLOY_CFG:-conf/gke.cfg} before invoking Python.
Unattended path -> hard refusal on mismatch.
- install.sh sources the helper and calls the informational notice
(gated on not-`--min`) right after entering the local-checkout
branch. The TUI is interactive, so the strict mode-aware gate is
a follow-up once the welcome screen records a mode in
state.inputs.
Bypass for deliberate cross-cluster maintenance:
KNOE_SKIP_KUBECONTEXT_GUARD=true ./deploy.sh
End-to-end verified:
- deploy.sh with current=cnpg-0, gke.cfg=app-0 -> exit 1, clear msg
- deploy.sh with KNOE_SKIP_...=true -> bypasses, prints
"skipping check"
- install.sh --min -> notice skipped
- install.sh (no flag) and install.sh --silent -> notice printed
Doc updates:
- CLAUDE.md §"Env-contamination warning" rewritten to describe the
live guard (was a forward-looking TODO).
- CLAUDE.md drift table row R4 removed; "Closed 2026-05-01" line added.
- docs/TODO.md queue item #1 archived to Done; R4 dropped from the
reality-vs-intent table. Queue numbering retained (no #1 placeholder)
so the docs/plans/junie/<NN>-...md filenames still match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1177 lines
48 KiB
Bash
Executable File
1177 lines
48 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
CONFIG_PATH="${PROLE_DEPLOY_CFG:-${ROOT_DIR}/conf/gke.cfg}"
|
|
|
|
# Env-contamination guard. Refuses to proceed if `kubectl config
|
|
# current-context` doesn't match the [Global] APP_CLUSTER_KUBECONTEXT in
|
|
# the config we're about to deploy with. Filed in response to the
|
|
# 2026-04-28 14:00 UTC outage; see docs/TODO.md queue item #1 + drift R4.
|
|
# Bypass with KNOE_SKIP_KUBECONTEXT_GUARD=true if you're doing deliberate
|
|
# cross-cluster maintenance.
|
|
# shellcheck source=etc/preflight_kubecontext.sh
|
|
source "${ROOT_DIR}/etc/preflight_kubecontext.sh"
|
|
verify_kubecontext_matches_config "${CONFIG_PATH}"
|
|
|
|
if [[ -x "${ROOT_DIR}/.venv/bin/python3" ]]; then
|
|
PYTHON_BIN="${ROOT_DIR}/.venv/bin/python3"
|
|
elif [[ -x "${ROOT_DIR}/bin/python3" ]]; then
|
|
PYTHON_BIN="${ROOT_DIR}/bin/python3"
|
|
else
|
|
PYTHON_BIN="${PYTHON:-python3}"
|
|
fi
|
|
|
|
# Validate StorageClass on GKE before deployment
|
|
if [[ "$CONFIG_PATH" == *"gke.cfg"* ]]; then
|
|
echo "Validating Supabase StorageClass configuration..."
|
|
"${PYTHON_BIN}" - "${CONFIG_PATH}" <<'PY'
|
|
import sys
|
|
import subprocess
|
|
import json
|
|
import configparser
|
|
|
|
def die(msg):
|
|
print(f"ERROR: {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
def warn(msg):
|
|
print(f"WARN: {msg}", file=sys.stderr)
|
|
|
|
c = configparser.ConfigParser()
|
|
c.read(sys.argv[1])
|
|
g = c["Global"] if "Global" in c else {}
|
|
e = c["env_setup"] if "env_setup" in c else {}
|
|
i = c["init_cluster"] if "init_cluster" in c else {}
|
|
|
|
ctx = g.get("APP_CLUSTER_KUBECONTEXT") or e.get("APP_CLUSTER_KUBECONTEXT") or i.get("app_cluster_kubecontext", "")
|
|
if not ctx:
|
|
sys.exit(0)
|
|
|
|
# Resolve StorageClass name
|
|
sc_name = g.get("SUPABASE_STORAGE_CLASS") or c.get("Supabase", "STORAGE_CLASS", fallback="supabase-standard")
|
|
|
|
try:
|
|
out = subprocess.check_output(["kubectl", "--context", ctx, "get", "storageclass", sc_name, "-o", "json"], stderr=subprocess.DEVNULL, text=True)
|
|
sc = json.loads(out)
|
|
provisioner = sc.get("provisioner", "")
|
|
params = sc.get("parameters", {})
|
|
disk_type = params.get("type", "")
|
|
|
|
if provisioner == "pd.csi.storage.gke.io" and disk_type == "pd-standard":
|
|
print(f"Verified live Supabase StorageClass '{sc_name}' (type: {disk_type}).", file=sys.stderr)
|
|
else:
|
|
warn(
|
|
f"storage-class preflight advisory: StorageClass '{sc_name}' is present but not the expected GKE pd-standard class "
|
|
f"(provisioner='{provisioner}', type='{disk_type}'). Supabase deploy will reconcile it."
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
warn(
|
|
f"storage-class preflight advisory: StorageClass '{sc_name}' does not exist on cluster '{ctx}'. "
|
|
"Supabase deploy will create/reconcile it in this run."
|
|
)
|
|
PY
|
|
fi
|
|
|
|
"${PYTHON_BIN}" -m knoe.deploy_pipeline --config "${CONFIG_PATH}" "$@"
|
|
|
|
# Post-deploy summary
|
|
echo ""
|
|
echo "Public Endpoints:"
|
|
ROOT_DIR="${ROOT_DIR}" "${PYTHON_BIN}" - "${CONFIG_PATH}" <<'PY'
|
|
import sys
|
|
import os
|
|
import subprocess
|
|
import json
|
|
import configparser
|
|
import time
|
|
import re
|
|
|
|
def _cfg_get(section, *keys):
|
|
if not section:
|
|
return ""
|
|
for key in keys:
|
|
if not key:
|
|
continue
|
|
for candidate in (key, key.lower(), key.upper()):
|
|
try:
|
|
value = section.get(candidate, "")
|
|
except Exception:
|
|
value = ""
|
|
if value is None:
|
|
continue
|
|
value = str(value).strip()
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def _cfg_first(g, e, i, *keys):
|
|
return _cfg_get(g, *keys) or _cfg_get(e, *keys) or _cfg_get(i, *keys)
|
|
|
|
|
|
def _as_bool(value, default=False):
|
|
if value is None:
|
|
return default
|
|
token = str(value).strip().lower()
|
|
if not token:
|
|
return default
|
|
if token in {"1", "true", "yes", "on", "y"}:
|
|
return True
|
|
if token in {"0", "false", "no", "off", "n"}:
|
|
return False
|
|
return default
|
|
|
|
|
|
def get_runtime_config(path):
|
|
c = configparser.ConfigParser()
|
|
c.read(path)
|
|
g = c["Global"] if "Global" in c else {}
|
|
e = c["env_setup"] if "env_setup" in c else {}
|
|
i = c["init_cluster"] if "init_cluster" in c else {}
|
|
|
|
mode = (_cfg_first(g, e, i, "DEPLOYMENT_MODE", "deployment_mode") or "").strip().lower()
|
|
if not mode:
|
|
mode = "k8s" if "gke" in str(path).lower() else "k3s"
|
|
|
|
app_ctx = _cfg_first(g, e, i, "APP_CLUSTER_KUBECONTEXT", "app_cluster_kubecontext")
|
|
db_ctx = _cfg_first(g, e, i, "DB_CLUSTER_KUBECONTEXT", "db_cluster_kubecontext")
|
|
gitlab_ns = _cfg_first(g, e, i, "GITLAB_NAMESPACE", "gitlab_namespace") or "gitlab"
|
|
gitlab_release = _cfg_first(g, e, i, "GITLAB_RELEASE", "gitlab_release") or "gitlab"
|
|
|
|
gitlab_public_hosts_raw = _cfg_first(g, e, i, "GITLAB_PUBLIC_HOSTS", "gitlab_public_hosts")
|
|
gitlab_domain = _cfg_first(g, e, i, "GITLAB_DOMAIN", "GITLAB_HOSTNAME", "gitlab_domain", "gitlab_hostname")
|
|
if not gitlab_domain:
|
|
gitlab_domain = "git.knoe.dev" if mode == "k8s" else "git.prole.org"
|
|
gitlab_public_hosts = [h.strip() for h in str(gitlab_public_hosts_raw or "").split(",") if h.strip()]
|
|
if not gitlab_public_hosts:
|
|
gitlab_public_hosts = [gitlab_domain]
|
|
gitlab_host = gitlab_public_hosts[0]
|
|
|
|
gitlab_frontdoor_owner = (_cfg_first(g, e, i, "GITLAB_FRONTDOOR_OWNER", "gitlab_frontdoor_owner") or "").strip().lower()
|
|
if not gitlab_frontdoor_owner:
|
|
gitlab_frontdoor_owner = "fallback" if mode == "k8s" else "operator"
|
|
gitlab_fallback_ingress_name = _cfg_first(g, e, i, "GITLAB_FALLBACK_INGRESS_NAME", "gitlab_fallback_ingress_name") or "gitlab-frontdoor-ingress"
|
|
|
|
auth_host = _cfg_first(g, e, i, "AUTH_HOSTNAME", "auth_hostname")
|
|
if not auth_host:
|
|
auth_host = "api.knoe.dev" if mode == "k8s" else "api.prole.org"
|
|
|
|
service_host = _cfg_first(g, e, i, "SERVICE_HOSTNAME", "service_hostname", "GRAFANA_HOSTNAME", "grafana_hostname")
|
|
if not service_host:
|
|
service_host = "svc.knoe.dev" if mode == "k8s" else "svc.prole.org"
|
|
|
|
service_ns = _cfg_first(g, e, i, "SERVICE_NAMESPACE", "service_namespace") or "knoe-system"
|
|
|
|
supabase_enabled = _as_bool(_cfg_first(g, e, i, "SUPABASE_ENABLED", "supabase_enabled"), default=True)
|
|
supabase_api_host = _cfg_first(g, e, i, "SUPABASE_API_HOSTNAME", "supabase_api_hostname") or "api.0.knoe.dev"
|
|
supabase_studio_host = _cfg_first(g, e, i, "SUPABASE_STUDIO_HOSTNAME", "SUPABASE_HOSTNAME", "supabase_studio_hostname", "supabase_hostname") or "db.0.knoe.dev"
|
|
|
|
return {
|
|
"mode": mode,
|
|
"app_ctx": app_ctx,
|
|
"db_ctx": db_ctx,
|
|
"gitlab_ns": gitlab_ns,
|
|
"gitlab_release": gitlab_release,
|
|
"gitlab_host": gitlab_host,
|
|
"gitlab_frontdoor_owner": gitlab_frontdoor_owner,
|
|
"gitlab_fallback_ingress_name": gitlab_fallback_ingress_name,
|
|
"gitlab_operator_ingress_name": f"{gitlab_release}-webservice-default",
|
|
"auth_host": auth_host,
|
|
"service_host": service_host,
|
|
"service_ns": service_ns,
|
|
"supabase_enabled": supabase_enabled,
|
|
"supabase_api_host": supabase_api_host,
|
|
"supabase_studio_host": supabase_studio_host,
|
|
}
|
|
|
|
|
|
def load_ingress_inventory(ctx):
|
|
if not ctx:
|
|
return []
|
|
try:
|
|
out = subprocess.check_output([
|
|
"kubectl", "--context", ctx, "get", "ingress", "-A", "-o", "json"
|
|
], stderr=subprocess.DEVNULL, text=True)
|
|
return json.loads(out).get("items", [])
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _ingress_class(item):
|
|
spec = item.get("spec", {}) or {}
|
|
metadata = item.get("metadata", {}) or {}
|
|
annotations = metadata.get("annotations", {}) or {}
|
|
spec_class = str(spec.get("ingressClassName") or "").strip()
|
|
ann_class = str(annotations.get("kubernetes.io/ingress.class") or "").strip()
|
|
effective = spec_class or ann_class or "<unset>"
|
|
return effective, spec_class, ann_class
|
|
|
|
|
|
def _ingress_address(item):
|
|
ing = (item.get("status", {}) or {}).get("loadBalancer", {}).get("ingress", [])
|
|
if not ing:
|
|
return "pending"
|
|
first = ing[0] or {}
|
|
return first.get("ip") or first.get("hostname") or "pending"
|
|
|
|
|
|
def _ingress_backend_summary(item, host):
|
|
services = []
|
|
spec = item.get("spec", {}) or {}
|
|
for rule in spec.get("rules", []) or []:
|
|
if (rule or {}).get("host") != host:
|
|
continue
|
|
http = (rule or {}).get("http", {}) or {}
|
|
for path in http.get("paths", []) or []:
|
|
backend = (path or {}).get("backend", {}) or {}
|
|
service = backend.get("service", {}) or {}
|
|
name = service.get("name")
|
|
port = (service.get("port") or {}).get("number") or (service.get("port") or {}).get("name")
|
|
if name:
|
|
services.append(f"{name}:{port}" if port else str(name))
|
|
return ",".join(services) if services else "-"
|
|
|
|
|
|
def _ingress_tls_diagnostics(item, host):
|
|
metadata = item.get("metadata", {}) or {}
|
|
annotations = metadata.get("annotations", {}) or {}
|
|
spec = item.get("spec", {}) or {}
|
|
|
|
tls_entries = spec.get("tls", []) or []
|
|
tls_hosts = []
|
|
tls_secret_names = []
|
|
tls_host_match = False
|
|
for entry in tls_entries:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
hosts = [str(h).strip() for h in (entry.get("hosts") or []) if str(h).strip()]
|
|
if hosts:
|
|
tls_hosts.extend(hosts)
|
|
else:
|
|
tls_host_match = True
|
|
if host in hosts:
|
|
tls_host_match = True
|
|
secret_name = str(entry.get("secretName") or "").strip()
|
|
if secret_name:
|
|
tls_secret_names.append(secret_name)
|
|
|
|
managed_cert = str(annotations.get("networking.gke.io/managed-certificates") or "").strip()
|
|
pre_shared_cert = str(annotations.get("ingress.gcp.kubernetes.io/pre-shared-cert") or "").strip()
|
|
|
|
has_tls_path = bool(tls_host_match or managed_cert or pre_shared_cert)
|
|
tls_path_state = "TLS_PATH_ATTACHED" if has_tls_path else "MISSING_TLS_PATH"
|
|
|
|
return {
|
|
"has_tls_path": has_tls_path,
|
|
"tls_path_state": tls_path_state,
|
|
"tls_hosts": ",".join(sorted(set(tls_hosts))) if tls_hosts else "-",
|
|
"tls_secrets": ",".join(sorted(set(tls_secret_names))) if tls_secret_names else "-",
|
|
"managed_cert": managed_cert or "-",
|
|
"pre_shared_cert": pre_shared_cert or "-",
|
|
}
|
|
|
|
|
|
def get_ingresses_for_host(inventory, host):
|
|
result = []
|
|
for item in inventory:
|
|
spec = item.get("spec", {}) or {}
|
|
matched = False
|
|
for rule in spec.get("rules", []) or []:
|
|
if (rule or {}).get("host") == host:
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
continue
|
|
|
|
metadata = item.get("metadata", {}) or {}
|
|
namespace = metadata.get("namespace") or "default"
|
|
name = metadata.get("name") or "<unknown>"
|
|
ingress_class, class_from_spec, class_from_annotation = _ingress_class(item)
|
|
tls_diag = _ingress_tls_diagnostics(item, host)
|
|
result.append({
|
|
"namespace": namespace,
|
|
"name": name,
|
|
"ingressClass": ingress_class,
|
|
"classFromSpec": class_from_spec or "-",
|
|
"classFromAnnotation": class_from_annotation or "-",
|
|
"address": _ingress_address(item),
|
|
"backend": _ingress_backend_summary(item, host),
|
|
**tls_diag,
|
|
})
|
|
return result
|
|
|
|
|
|
def _pick_authoritative_owner(owners, expected):
|
|
if not owners:
|
|
return None
|
|
if expected:
|
|
for owner in owners:
|
|
if owner.get("namespace") == expected.get("namespace") and owner.get("name") == expected.get("name"):
|
|
return owner
|
|
return owners[0]
|
|
|
|
|
|
def _managed_cert_status(ctx, namespace, cert_name):
|
|
if not ctx or not namespace or not cert_name:
|
|
return "Unknown"
|
|
try:
|
|
out = subprocess.check_output([
|
|
"kubectl", "--context", ctx, "get", "managedcertificate", cert_name,
|
|
"-n", namespace, "-o", "json"
|
|
], stderr=subprocess.DEVNULL, text=True)
|
|
item = json.loads(out)
|
|
status = (item.get("status") or {}) if isinstance(item, dict) else {}
|
|
cert_status = str(status.get("certificateStatus") or status.get("status") or "").strip()
|
|
return cert_status or "Unknown"
|
|
except Exception:
|
|
return "Unknown"
|
|
|
|
|
|
def _managed_cert_statuses(ctx, namespace, managed_cert_annotation):
|
|
cert_names = [token.strip() for token in str(managed_cert_annotation or "").split(",") if token.strip()]
|
|
statuses = {}
|
|
for cert_name in cert_names:
|
|
statuses[cert_name] = _managed_cert_status(ctx, namespace, cert_name)
|
|
return statuses
|
|
|
|
|
|
def _emit_gce_ingress_diagnostics(ctx, namespace, ingress_names):
|
|
"""Emit kubectl diagnostics for GCE ingress resources."""
|
|
print("\n--- GCE ingress diagnostics ---")
|
|
try:
|
|
subprocess.run(["kubectl", "--context", ctx, "get", "ingress", "-A"], check=False)
|
|
except Exception:
|
|
pass
|
|
for name in ingress_names:
|
|
try:
|
|
subprocess.run(["kubectl", "--context", ctx, "describe", "ingress", name, "-n", namespace], check=False)
|
|
except Exception:
|
|
pass
|
|
print("\nManagedCertificates:")
|
|
try:
|
|
subprocess.run(["kubectl", "--context", ctx, "get", "managedcertificate", "-A"], check=False)
|
|
except Exception:
|
|
pass
|
|
print("\nFrontendConfigs:")
|
|
try:
|
|
subprocess.run(["kubectl", "--context", ctx, "get", "frontendconfig", "-A"], check=False)
|
|
except Exception:
|
|
pass
|
|
print("--- end diagnostics ---\n")
|
|
|
|
|
|
def _wait_for_gclb_convergence(ctx, host, ingress_name, namespace, managed_cert_annotation, timeout_seconds=300):
|
|
"""Wait for GCLB convergence: ingress address assignment and ManagedCertificate Active or Provisioning.
|
|
|
|
Returns (address_ok, cert_state) where cert_state is one of:
|
|
'Active', 'Provisioning', 'unknown', or an error string.
|
|
Does not fail — the caller decides how to handle non-Active certs.
|
|
"""
|
|
deadline = time.time() + timeout_seconds
|
|
address_ok = False
|
|
cert_state = "unknown"
|
|
|
|
cert_names = [t.strip() for t in str(managed_cert_annotation or "").split(",") if t.strip()]
|
|
|
|
while time.time() < deadline:
|
|
# Check ingress address.
|
|
try:
|
|
out = subprocess.check_output(
|
|
["kubectl", "--context", ctx, "get", "ingress", ingress_name, "-n", namespace, "-o", "json"],
|
|
stderr=subprocess.DEVNULL, text=True,
|
|
)
|
|
item = json.loads(out)
|
|
ing = (item.get("status") or {}).get("loadBalancer", {}).get("ingress", [])
|
|
if ing:
|
|
addr = ing[0].get("ip") or ing[0].get("hostname")
|
|
if addr:
|
|
address_ok = True
|
|
except Exception:
|
|
pass
|
|
|
|
# Check cert statuses if we have managed certs.
|
|
if cert_names:
|
|
statuses = _managed_cert_statuses(ctx, namespace, managed_cert_annotation)
|
|
non_active = [s for s in statuses.values() if s != "Active"]
|
|
if not non_active:
|
|
cert_state = "Active"
|
|
elif all(s in ("Provisioning", "RenewingManaged", "", "unknown") for s in non_active):
|
|
cert_state = "Provisioning"
|
|
else:
|
|
cert_state = ",".join(f"{n}:{s}" for n, s in statuses.items())
|
|
else:
|
|
# No managed cert annotation; convergence is purely address-based.
|
|
cert_state = "Active"
|
|
|
|
if address_ok and cert_state == "Active":
|
|
break
|
|
|
|
if address_ok and cert_state == "Provisioning":
|
|
# Address assigned and cert is provisioning — safe to stop waiting and
|
|
# surface state to caller; probing HTTPS now would likely fail (expected).
|
|
break
|
|
|
|
remaining = int(deadline - time.time())
|
|
print(
|
|
f" GCLB convergence: host={host} ingress={namespace}/{ingress_name} "
|
|
f"address={'assigned' if address_ok else 'pending'} cert={cert_state} "
|
|
f"(waiting, {remaining}s remaining)"
|
|
)
|
|
time.sleep(15)
|
|
|
|
return address_ok, cert_state
|
|
|
|
|
|
def _probe_https_endpoint(host, timeout_seconds=15):
|
|
url = f"https://{host}/"
|
|
try:
|
|
completed = subprocess.run([
|
|
"curl",
|
|
"--silent",
|
|
"--show-error",
|
|
"--location",
|
|
"--output", "/dev/null",
|
|
"--write-out", "%{http_code}",
|
|
"--connect-timeout", "5",
|
|
"--max-time", str(timeout_seconds),
|
|
url,
|
|
], check=False, capture_output=True, text=True)
|
|
http_code = str(completed.stdout or "").strip()
|
|
if completed.returncode == 0 and re.fullmatch(r"\d{3}", http_code):
|
|
if http_code in {"500", "502", "503", "504"}:
|
|
return False, f"http={http_code}"
|
|
return True, f"http={http_code}"
|
|
|
|
detail = str(completed.stderr or "").strip()
|
|
if not detail:
|
|
detail = str(completed.stdout or "").strip()
|
|
if detail:
|
|
detail = detail.splitlines()[-1]
|
|
if not detail:
|
|
detail = f"curl_exit={completed.returncode}"
|
|
return False, detail[:220]
|
|
except Exception as exc:
|
|
return False, str(exc)
|
|
|
|
|
|
def _classify_tls_failure(*, has_tls_path, managed_cert_statuses, probe_result):
|
|
if not has_tls_path:
|
|
return "NO_TLS_PATH_CONFIGURED"
|
|
|
|
if managed_cert_statuses:
|
|
non_active = [status for status in managed_cert_statuses.values() if status != "Active"]
|
|
if non_active:
|
|
return "CERT_NOT_READY"
|
|
|
|
detail = str(probe_result or "").strip().lower()
|
|
if detail.startswith("http="):
|
|
code = detail.split("=", 1)[1][:3]
|
|
if code in {"500", "502", "503", "504"}:
|
|
return "BACKEND_UNHEALTHY"
|
|
|
|
if any(token in detail for token in ("ssl", "tls", "handshake", "eof", "certificate")):
|
|
return "TLS_HANDSHAKE_BROKEN"
|
|
|
|
if any(token in detail for token in (
|
|
"timed out",
|
|
"timeout",
|
|
"connection refused",
|
|
"connection reset",
|
|
"no route to host",
|
|
"empty reply",
|
|
)):
|
|
return "BACKEND_UNHEALTHY"
|
|
|
|
return "TLS_PATH_ATTACHED_BUT_NOT_SERVING"
|
|
|
|
def get_ingress_status(ctx, namespace, ingress_name):
|
|
status = {
|
|
"name": ingress_name,
|
|
"exists": False,
|
|
"address": "pending",
|
|
"hosts": [],
|
|
"managed_certificates": [],
|
|
"frontend_configs": [],
|
|
}
|
|
if not ctx:
|
|
return status
|
|
try:
|
|
out = subprocess.check_output([
|
|
"kubectl", "--context", ctx, "get", "ingress", ingress_name,
|
|
"-n", namespace, "-o", "json"
|
|
], stderr=subprocess.DEVNULL, text=True)
|
|
item = json.loads(out)
|
|
status["exists"] = True
|
|
status["hosts"] = [
|
|
rule.get("host")
|
|
for rule in item.get("spec", {}).get("rules", [])
|
|
if rule.get("host")
|
|
]
|
|
annotations = (item.get("metadata") or {}).get("annotations") or {}
|
|
managed_raw = str(annotations.get("networking.gke.io/managed-certificates") or "")
|
|
frontend_raw = str(annotations.get("networking.gke.io/v1beta1.FrontendConfig") or "")
|
|
status["managed_certificates"] = [token.strip() for token in managed_raw.split(",") if token.strip()]
|
|
status["frontend_configs"] = [token.strip() for token in frontend_raw.split(",") if token.strip()]
|
|
ing = item.get("status", {}).get("loadBalancer", {}).get("ingress", [])
|
|
if ing:
|
|
addr = ing[0].get("ip") or ing[0].get("hostname")
|
|
if addr:
|
|
status["address"] = addr
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
return status
|
|
|
|
|
|
def _k8s_object_exists(ctx, namespace, kind, name):
|
|
if not ctx or not namespace or not kind or not name:
|
|
return False
|
|
try:
|
|
subprocess.check_output(
|
|
[
|
|
"kubectl", "--context", ctx,
|
|
"get", kind, name,
|
|
"-n", namespace,
|
|
"-o", "name",
|
|
],
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _collect_supabase_ingress_dependency_errors(ctx, namespace, ingress_state):
|
|
errors = []
|
|
for ingress_name in ("supabase-kong", "supabase-studio"):
|
|
ing = ingress_state.get(ingress_name) or {}
|
|
if not ing.get("exists"):
|
|
errors.append(f"Ingress {namespace}/{ingress_name} is missing.")
|
|
continue
|
|
if ing.get("address") == "pending":
|
|
errors.append(f"Ingress {namespace}/{ingress_name} has no assigned address yet.")
|
|
|
|
for cert_name in ing.get("managed_certificates") or []:
|
|
if not _k8s_object_exists(ctx, namespace, "managedcertificate", cert_name):
|
|
errors.append(
|
|
f"Ingress {namespace}/{ingress_name} references missing ManagedCertificate {namespace}/{cert_name}."
|
|
)
|
|
|
|
for frontend_name in ing.get("frontend_configs") or []:
|
|
if not _k8s_object_exists(ctx, namespace, "frontendconfig", frontend_name):
|
|
errors.append(
|
|
f"Ingress {namespace}/{ingress_name} references missing FrontendConfig {namespace}/{frontend_name}."
|
|
)
|
|
|
|
return errors
|
|
|
|
def check_supabase_pvcs(ctx):
|
|
if not ctx: return None
|
|
try:
|
|
from datetime import datetime, timezone
|
|
out = subprocess.check_output(["kubectl", "--context", ctx, "get", "events", "-n", "supabase", "-o", "json"], stderr=subprocess.DEVNULL, text=True)
|
|
events = json.loads(out).get("items", [])
|
|
|
|
# Fetch current PVC states to ignore Bound ones
|
|
out_pvc = subprocess.check_output(["kubectl", "--context", ctx, "get", "pvc", "-n", "supabase", "-o", "json"], stderr=subprocess.DEVNULL, text=True)
|
|
pvcs = json.loads(out_pvc).get("items", [])
|
|
pvc_status = {}
|
|
pvc_sc = {}
|
|
for p in pvcs:
|
|
name = (p.get("metadata") or {}).get("name")
|
|
if not name:
|
|
continue
|
|
pvc_status[name] = (p.get("status") or {}).get("phase")
|
|
pvc_sc[name] = (p.get("spec") or {}).get("storageClassName") or "<unset>"
|
|
|
|
sc_cache = {}
|
|
def sc_info(sc_name):
|
|
key = sc_name or "<unset>"
|
|
if key in sc_cache:
|
|
return sc_cache[key]
|
|
info = {
|
|
"storageClass": key,
|
|
"provisioner": "<unknown>",
|
|
"type": "<unknown>",
|
|
"volumeBindingMode": "<unknown>",
|
|
"reclaimPolicy": "<unknown>",
|
|
}
|
|
if sc_name and sc_name != "<unset>":
|
|
try:
|
|
out_sc = subprocess.check_output([
|
|
"kubectl", "--context", ctx, "get", "storageclass", sc_name, "-o", "json"
|
|
], stderr=subprocess.DEVNULL, text=True)
|
|
sc_obj = json.loads(out_sc)
|
|
info["provisioner"] = sc_obj.get("provisioner") or "<unknown>"
|
|
info["type"] = (sc_obj.get("parameters") or {}).get("type") or "<unknown>"
|
|
info["volumeBindingMode"] = sc_obj.get("volumeBindingMode") or "<unknown>"
|
|
info["reclaimPolicy"] = sc_obj.get("reclaimPolicy") or "<unknown>"
|
|
except Exception:
|
|
pass
|
|
sc_cache[key] = info
|
|
return info
|
|
|
|
def parse_ts(value):
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
except Exception:
|
|
return None
|
|
|
|
blocking_pvcs = {}
|
|
for event in events:
|
|
obj = event.get("involvedObject", {})
|
|
if obj.get("kind") == "PersistentVolumeClaim":
|
|
pvc_name = obj.get("name")
|
|
if not pvc_name:
|
|
continue
|
|
|
|
# Skip Bound PVCs
|
|
if pvc_status.get(pvc_name) == "Bound":
|
|
continue
|
|
|
|
msg = event.get("message", "")
|
|
reason = event.get("reason", "")
|
|
msg_l = msg.lower()
|
|
reason_l = reason.lower()
|
|
if not (
|
|
"quota_exceeded" in msg_l
|
|
or "exceeded" in msg_l
|
|
or "quota" in msg_l
|
|
or "provisioningfailed" in reason_l
|
|
or "failedbinding" in reason_l
|
|
):
|
|
continue
|
|
|
|
ts = parse_ts(event.get("lastTimestamp") or event.get("eventTime") or event.get("firstTimestamp"))
|
|
sc = pvc_sc.get(pvc_name) or "<unset>"
|
|
sc_details = sc_info(sc)
|
|
candidate = {
|
|
"pvc": pvc_name,
|
|
"phase": pvc_status.get(pvc_name) or "Unknown",
|
|
"message": msg,
|
|
"reason": reason or "Unknown",
|
|
"eventTime": ts.isoformat() if ts else "",
|
|
"storageClass": sc_details["storageClass"],
|
|
"storageProvisioner": sc_details["provisioner"],
|
|
"storageType": sc_details["type"],
|
|
"volumeBindingMode": sc_details["volumeBindingMode"],
|
|
"reclaimPolicy": sc_details["reclaimPolicy"],
|
|
}
|
|
|
|
previous = blocking_pvcs.get(pvc_name)
|
|
if not previous:
|
|
blocking_pvcs[pvc_name] = candidate
|
|
continue
|
|
|
|
prev_ts = parse_ts(previous.get("eventTime"))
|
|
if prev_ts is None or (ts is not None and ts >= prev_ts):
|
|
blocking_pvcs[pvc_name] = candidate
|
|
|
|
stale_retained = []
|
|
out_pv = subprocess.check_output([
|
|
"kubectl", "--context", ctx, "get", "pv", "-o", "json"
|
|
], stderr=subprocess.DEVNULL, text=True)
|
|
pvs = json.loads(out_pv).get("items", [])
|
|
for pv in pvs:
|
|
spec = pv.get("spec", {})
|
|
status = pv.get("status", {})
|
|
claim = spec.get("claimRef") or {}
|
|
claim_ns = claim.get("namespace")
|
|
claim_name = claim.get("name")
|
|
if claim_ns != "supabase":
|
|
continue
|
|
if not str(claim_name or "").startswith("supabase-"):
|
|
continue
|
|
|
|
phase = status.get("phase", "")
|
|
reclaim = spec.get("persistentVolumeReclaimPolicy", "")
|
|
if reclaim != "Retain" or phase not in ("Released", "Failed"):
|
|
continue
|
|
|
|
csi = spec.get("csi") or {}
|
|
gce = spec.get("gcePersistentDisk") or {}
|
|
labels = (pv.get("metadata") or {}).get("labels") or {}
|
|
disk_handle = csi.get("volumeHandle") or gce.get("pdName") or "<unknown>"
|
|
zone = (
|
|
labels.get("topology.kubernetes.io/zone")
|
|
or labels.get("failure-domain.beta.kubernetes.io/zone")
|
|
or (csi.get("volumeAttributes") or {}).get("topology.gke.io/zone")
|
|
or ""
|
|
)
|
|
if not zone and isinstance(disk_handle, str):
|
|
m = re.search(r"/zones/([^/]+)/disks/[^/]+$", disk_handle)
|
|
if m:
|
|
zone = m.group(1)
|
|
stale_retained.append({
|
|
"pv": (pv.get("metadata") or {}).get("name", "?"),
|
|
"pvc": claim_name or "?",
|
|
"phase": phase or "Unknown",
|
|
"storageClass": spec.get("storageClassName") or "<unset>",
|
|
"diskHandle": disk_handle,
|
|
"zone": zone or "<unknown>",
|
|
})
|
|
|
|
if blocking_pvcs or stale_retained:
|
|
return {
|
|
"blockingPVCs": sorted(blocking_pvcs.values(), key=lambda x: x.get("pvc") or ""),
|
|
"staleRetainedPVs": stale_retained,
|
|
}
|
|
except: pass
|
|
return None
|
|
|
|
def get_deployment_status(ctx, namespace, name):
|
|
status = {
|
|
"exists": False,
|
|
"specReplicas": 0,
|
|
"readyReplicas": 0,
|
|
"availableReplicas": 0,
|
|
}
|
|
if not ctx:
|
|
return status
|
|
try:
|
|
out = subprocess.check_output([
|
|
"kubectl", "--context", ctx, "get", "deployment", name, "-n", namespace, "-o", "json"
|
|
], stderr=subprocess.DEVNULL, text=True)
|
|
dep = json.loads(out)
|
|
spec = dep.get("spec") or {}
|
|
st = dep.get("status") or {}
|
|
status["exists"] = True
|
|
status["specReplicas"] = int(spec.get("replicas") or 0)
|
|
status["readyReplicas"] = int(st.get("readyReplicas") or 0)
|
|
status["availableReplicas"] = int(st.get("availableReplicas") or 0)
|
|
except Exception:
|
|
pass
|
|
return status
|
|
|
|
def reconcile_gitlab(app_ctx, db_ctx, ns, config_path):
|
|
if not app_ctx: return
|
|
print(f"\nReconciling GitLab workloads in namespace '{ns}'...")
|
|
print(f" Using config: {config_path}")
|
|
print(f" Using APP context: {app_ctx}")
|
|
if db_ctx:
|
|
print(f" Using DB context: {db_ctx}")
|
|
|
|
try:
|
|
# Get all deployments in one go
|
|
out = subprocess.check_output(["kubectl", "--context", app_ctx, "-n", ns, "get", "deployments", "-o", "json"], text=True)
|
|
deploys = json.loads(out).get("items", [])
|
|
|
|
# Get all replicasets in one go
|
|
out = subprocess.check_output(["kubectl", "--context", app_ctx, "-n", ns, "get", "replicasets", "-o", "json"], text=True)
|
|
all_rss = json.loads(out).get("items", [])
|
|
except Exception as e:
|
|
print(f" Warning: Could not fetch GitLab workloads for reconciliation: {e}")
|
|
return
|
|
|
|
# Targets specifically mentioned in requirements or operator managed
|
|
gitlab_workloads = [
|
|
"gitlab-sidekiq-all-in-1-v2",
|
|
"gitlab-gitlab-shell",
|
|
"gitlab-kas",
|
|
"gitlab-registry",
|
|
"gitlab-webservice-default"
|
|
]
|
|
|
|
summary = []
|
|
over_deployed_detected = False
|
|
|
|
for dep in deploys:
|
|
name = dep['metadata']['name']
|
|
# Filter for operator-managed GitLab workloads only
|
|
is_gitlab = False
|
|
if name in gitlab_workloads:
|
|
is_gitlab = True
|
|
elif name.startswith("gitlab-") and not any(x in name for x in ["-migration", "-secrets", "-minio-create-buckets", "-db-config"]):
|
|
is_gitlab = True
|
|
|
|
if not is_gitlab:
|
|
continue
|
|
|
|
spec = dep.get('spec', {})
|
|
status = dep.get('status', {})
|
|
|
|
# Target should be 1 for these specific GitLab workloads (Requirement 11)
|
|
if name in gitlab_workloads:
|
|
desired = 1
|
|
else:
|
|
desired = spec.get('replicas', 0)
|
|
|
|
live = status.get('replicas', 0)
|
|
updated = status.get('updatedReplicas', 0)
|
|
|
|
# Enforce desired replica count (Requirement 3 & 4)
|
|
# Reconciliation compares:
|
|
# - desired replicas (from Helm values)
|
|
# - actual replicas (from Deployment.spec.replicas)
|
|
actual_spec_replicas = spec.get('replicas', 0)
|
|
if name in gitlab_workloads and actual_spec_replicas != desired:
|
|
print(f" [FIX] Deployment '{name}' spec.replicas ({actual_spec_replicas}) differs from desired ({desired}).")
|
|
over_deployed_detected = True
|
|
|
|
summary.append({
|
|
"name": name,
|
|
"desired": desired,
|
|
"live": live,
|
|
"updated": updated
|
|
})
|
|
|
|
# Check for Gitaly storage class drift on GKE (Requirement 3)
|
|
is_gke = "gke" in sys.argv[1].lower()
|
|
if is_gke:
|
|
try:
|
|
out = subprocess.check_output(["kubectl", "--context", app_ctx, "-n", ns, "get", "gitlab", "-o", "json"], text=True)
|
|
crs = json.loads(out).get("items", [])
|
|
for cr in crs:
|
|
spec = cr.get("spec", {})
|
|
live_sc = spec.get("chart", {}).get("values", {}).get("global", {}).get("persistence", {}).get("storageClass")
|
|
if not live_sc:
|
|
live_sc = spec.get("chart", {}).get("values", {}).get("gitlab", {}).get("gitaly", {}).get("persistence", {}).get("storageClass")
|
|
|
|
# Desired is 'standard' in this environment by default
|
|
desired_sc = "standard"
|
|
|
|
if live_sc:
|
|
if live_sc != desired_sc:
|
|
print(f" [FIX] GitLab CR '{cr['metadata']['name']}' storageClass '{live_sc}' differs from desired '{desired_sc}'.")
|
|
over_deployed_detected = True
|
|
except:
|
|
pass
|
|
|
|
# Print summary (Requirement 8)
|
|
print(f"\n{'GitLab Deployment':<35} {'Desired':<8} {'Live':<8} {'Updated':<8}")
|
|
print("-" * 70)
|
|
for s in summary:
|
|
print(f"{s['name']:<35} {s['desired']:<8} {s['live']:<8} {s['updated']:<8}")
|
|
|
|
if over_deployed_detected:
|
|
print("\nEnforcing desired configuration by re-applying GitLab Helm release/CR...")
|
|
# Re-apply the Helm release (Requirement 2 & 4)
|
|
try:
|
|
root_dir = os.environ.get("ROOT_DIR", ".")
|
|
env = os.environ.copy()
|
|
# Explicitly pass contexts and config path to avoid fallbacks
|
|
env["APP_CLUSTER_KUBECONTEXT"] = app_ctx
|
|
if db_ctx:
|
|
env["DB_CLUSTER_KUBECONTEXT"] = db_ctx
|
|
env["KUBECTL_CONTEXT"] = app_ctx
|
|
env["PROLE_DEPLOY_CFG"] = config_path
|
|
|
|
subprocess.run(["/usr/bin/env", "bash", f"{root_dir}/etc/init_gitlab.sh", "--config", config_path, "deploy"], env=env, check=True)
|
|
except Exception as e:
|
|
print(f" ERROR: Failed to re-apply GitLab configuration: {e}")
|
|
sys.exit(1)
|
|
|
|
# Final Convergence Check (Requirement 6)
|
|
still_over_deployed = False
|
|
for s in summary:
|
|
# If we detected drift, we re-applied the configuration.
|
|
# Now we check if it converged.
|
|
if over_deployed_detected:
|
|
# Re-fetch live status for the final check
|
|
try:
|
|
out = subprocess.check_output(["kubectl", "--context", app_ctx, "-n", ns, "get", "deployment", s['name'], "-o", "json"], text=True)
|
|
d = json.loads(out)
|
|
st = d.get('status', {})
|
|
# Compare against the intended 'desired' count (1), not necessarily the (potentially stale) spec.replicas
|
|
if st.get('replicas', 0) > s['desired'] or st.get('updatedReplicas', 0) != s['desired']:
|
|
print(f"ERROR: GitLab deployment '{s['name']}' failed to converge (live={st.get('replicas')}, desired={s['desired']}).")
|
|
still_over_deployed = True
|
|
except:
|
|
pass
|
|
elif s['live'] > s['desired'] or s['updated'] != s['desired']:
|
|
print(f"ERROR: GitLab deployment '{s['name']}' is not converged.")
|
|
still_over_deployed = True
|
|
|
|
if still_over_deployed:
|
|
sys.exit(1)
|
|
|
|
runtime = get_runtime_config(sys.argv[1])
|
|
app_ctx = runtime["app_ctx"]
|
|
db_ctx = runtime["db_ctx"]
|
|
gitlab_ns = runtime["gitlab_ns"]
|
|
config_path = sys.argv[1]
|
|
|
|
# Run GitLab reconciliation first
|
|
reconcile_gitlab(app_ctx, db_ctx, gitlab_ns, config_path)
|
|
|
|
# Supabase frontdoor ingresses that must converge together
|
|
required_supabase_ingresses = [
|
|
{"name": "supabase-kong", "endpoint": runtime["supabase_api_host"]},
|
|
{"name": "supabase-studio", "endpoint": runtime["supabase_studio_host"]},
|
|
]
|
|
timeout = 300 # 5 minutes
|
|
start = time.time()
|
|
supabase_ingress_state = {}
|
|
studio_deploy_status = get_deployment_status(app_ctx, "supabase", "supabase-studio")
|
|
|
|
if runtime["supabase_enabled"] and runtime["supabase_studio_host"]:
|
|
if not studio_deploy_status.get("exists"):
|
|
print("\nERROR: Supabase Studio ingress cannot reconcile because deployment/supabase-studio is absent.")
|
|
print("Reason: Studio ingress rendering depends on Studio workload enablement in rendered Supabase values.")
|
|
sys.exit(1)
|
|
if int(studio_deploy_status.get("specReplicas") or 0) < 1:
|
|
print("\nERROR: Supabase Studio ingress cannot reconcile because deployment/supabase-studio is scaled to 0.")
|
|
print("Reason: Studio ingress is enabled but Studio workload replicas are zero in the current rendered/apply state.")
|
|
sys.exit(1)
|
|
|
|
while time.time() - start < timeout:
|
|
pvc_state = check_supabase_pvcs(app_ctx)
|
|
if pvc_state:
|
|
stale = pvc_state.get("staleRetainedPVs") or []
|
|
if stale:
|
|
print("\nERROR: Supabase PVC provisioning is blocked by stale retained PV/GCE disk artifacts from previous runs.")
|
|
for item in stale:
|
|
print(f" - PV: {item['pv']}")
|
|
print(f" PVC: {item['pvc']}")
|
|
print(f" Phase/Reclaim: {item['phase']}/{item.get('storageClass','<unset>')} Retain")
|
|
print(f" Disk: {item['diskHandle']} (zone: {item['zone']})")
|
|
print("\nAction: stale Supabase Retain PVs should be auto-cleaned during deploy; delete the remaining backing GCE disks listed above, or enable SUPABASE_AUTO_CLEAN_RETAINED_GCE_DISKS=true with gcloud/project env configured.")
|
|
sys.exit(1)
|
|
|
|
blockers = pvc_state.get("blockingPVCs") or []
|
|
if blockers:
|
|
print("\nERROR: Supabase PVC provisioning failed (Storage Quota Exceeded).")
|
|
for item in blockers:
|
|
print(f" - PVC: {item.get('pvc')}")
|
|
print(f" Phase/Reason: {item.get('phase')}/{item.get('reason')}")
|
|
print(
|
|
f" StorageClass: {item.get('storageClass')} "
|
|
f"(provisioner={item.get('storageProvisioner')}, type={item.get('storageType')}, "
|
|
f"binding={item.get('volumeBindingMode')}, reclaim={item.get('reclaimPolicy')})"
|
|
)
|
|
print(f" GKE Error: {item.get('message')}")
|
|
sys.exit(1)
|
|
|
|
pending = False
|
|
supabase_ingress_state = {}
|
|
for req in required_supabase_ingresses:
|
|
ing = get_ingress_status(app_ctx, "supabase", req["name"])
|
|
supabase_ingress_state[req["name"]] = ing
|
|
if not ing.get("exists") or ing.get("address") == "pending":
|
|
pending = True
|
|
|
|
dependency_errors = _collect_supabase_ingress_dependency_errors(app_ctx, "supabase", supabase_ingress_state)
|
|
if dependency_errors:
|
|
# Requirement 6: TLS dependency objects are applied before Helm; if they are still
|
|
# missing here, surface as diagnostics and keep waiting (they may still be propagating).
|
|
print("\nWARN: Supabase ingress TLS dependencies not yet fully present (will retry):")
|
|
for error in dependency_errors:
|
|
print(f" - {error}")
|
|
pending = True
|
|
|
|
if not pending:
|
|
break
|
|
time.sleep(10)
|
|
|
|
if time.time() - start >= timeout:
|
|
print("\nERROR: Timeout waiting for Supabase ingress reconciliation after dependency checks passed.")
|
|
for req in required_supabase_ingresses:
|
|
ing = supabase_ingress_state.get(req["name"]) or get_ingress_status(app_ctx, "supabase", req["name"])
|
|
managed = ",".join(ing.get("managed_certificates") or []) or "-"
|
|
frontend = ",".join(ing.get("frontend_configs") or []) or "-"
|
|
print(
|
|
f" - {req['name']} ({req['endpoint']}): exists={ing.get('exists')} address={ing.get('address')} "
|
|
f"hosts={','.join(ing.get('hosts') or []) or '-'} managedCertRefs={managed} frontendConfigRefs={frontend}"
|
|
)
|
|
print("Ingress Controller Diagnosis (kubectl get events -n supabase):")
|
|
try:
|
|
subprocess.run(["kubectl", "--context", app_ctx, "get", "events", "-n", "supabase", "--sort-by=.lastTimestamp"], check=False)
|
|
print("\nIngress Resource Status:")
|
|
subprocess.run(["kubectl", "--context", app_ctx, "describe", "ingress", "supabase-kong", "-n", "supabase"], check=False)
|
|
subprocess.run(["kubectl", "--context", app_ctx, "describe", "ingress", "supabase-studio", "-n", "supabase"], check=False)
|
|
except:
|
|
pass
|
|
sys.exit(1)
|
|
|
|
kong_state = supabase_ingress_state.get("supabase-kong") or get_ingress_status(app_ctx, "supabase", "supabase-kong")
|
|
studio_state = supabase_ingress_state.get("supabase-studio") or get_ingress_status(app_ctx, "supabase", "supabase-studio")
|
|
if (not kong_state.get("exists") or kong_state.get("address") == "pending" or
|
|
not studio_state.get("exists") or studio_state.get("address") == "pending"):
|
|
print("\nERROR: Supabase frontdoor is not converged: both supabase-kong and supabase-studio ingresses must exist and have addresses.")
|
|
print(f" - supabase-kong ({runtime['supabase_api_host']}): exists={kong_state.get('exists')} address={kong_state.get('address')}")
|
|
print(f" - supabase-studio ({runtime['supabase_studio_host']}): exists={studio_state.get('exists')} address={studio_state.get('address')}")
|
|
sys.exit(1)
|
|
|
|
public_hosts = []
|
|
for candidate in (
|
|
runtime["gitlab_host"],
|
|
runtime["supabase_api_host"] if runtime["supabase_enabled"] else "",
|
|
runtime["supabase_studio_host"] if runtime["supabase_enabled"] else "",
|
|
runtime["auth_host"],
|
|
runtime["service_host"],
|
|
):
|
|
h = str(candidate or "").strip()
|
|
if not h or h in public_hosts:
|
|
continue
|
|
public_hosts.append(h)
|
|
|
|
expected_owner = {
|
|
runtime["gitlab_host"]: {
|
|
"namespace": gitlab_ns,
|
|
"name": runtime["gitlab_fallback_ingress_name"] if runtime["gitlab_frontdoor_owner"] == "fallback" else runtime["gitlab_operator_ingress_name"],
|
|
},
|
|
runtime["auth_host"]: {"namespace": runtime["service_ns"], "name": "svc-knoe-ingress"},
|
|
runtime["service_host"]: {"namespace": runtime["service_ns"], "name": "svc-knoe-ingress"},
|
|
}
|
|
if runtime["supabase_enabled"]:
|
|
expected_owner[runtime["supabase_api_host"]] = {"namespace": "supabase", "name": "supabase-kong"}
|
|
expected_owner[runtime["supabase_studio_host"]] = {"namespace": "supabase", "name": "supabase-studio"}
|
|
|
|
inventory = load_ingress_inventory(app_ctx)
|
|
broken_hosts = []
|
|
host_diagnostics = {}
|
|
|
|
# Emit diagnostics before HTTPS probing for all GCE ingresses.
|
|
_gce_ingress_names = [
|
|
name for host, info in expected_owner.items()
|
|
for name in [info["name"]]
|
|
if host in public_hosts
|
|
]
|
|
_gce_namespaces = list({info["namespace"] for info in expected_owner.values()})
|
|
if app_ctx:
|
|
for _gce_ns in _gce_namespaces:
|
|
_emit_gce_ingress_diagnostics(
|
|
app_ctx,
|
|
_gce_ns,
|
|
[info["name"] for info in expected_owner.values() if info["namespace"] == _gce_ns],
|
|
)
|
|
|
|
print("\nIngress/TLS diagnostics by public hostname:")
|
|
for host in public_hosts:
|
|
owners = get_ingresses_for_host(inventory, host)
|
|
host_diagnostics[host] = owners
|
|
if not owners:
|
|
broken_hosts.append(f"{host}: no ingress claims this host")
|
|
print(
|
|
f" - host={host} ingress=(none) class=- address=pending tlsState=MISSING_TLS_PATH failureReason=NO_TLS_PATH_CONFIGURED tlsHosts=- tlsSecrets=- managedCert=- managedCertStatus=- preSharedCert=- httpsProbe=not-run backend=-"
|
|
)
|
|
continue
|
|
|
|
expected = expected_owner.get(host)
|
|
owner = _pick_authoritative_owner(owners, expected)
|
|
if not owner:
|
|
broken_hosts.append(f"{host}: unable to determine authoritative ingress owner")
|
|
continue
|
|
|
|
if len(owners) > 1:
|
|
owner_desc = ", ".join(f"{o['namespace']}/{o['name']}[class={o['ingressClass']}]" for o in owners)
|
|
broken_hosts.append(f"{host}: multiple ingress owners found ({owner_desc})")
|
|
|
|
# Requirement 5: GCLB convergence phase — wait for address assignment and cert state
|
|
# before probing HTTPS. This prevents false failures immediately after ingress creation.
|
|
_gclb_address_ok = True
|
|
_gclb_cert_state = "Active"
|
|
if owner["has_tls_path"] and owner.get("managed_cert") and owner.get("ingressClass") == "gce":
|
|
print(f" Waiting for GCLB convergence: host={host} ingress={owner['namespace']}/{owner['name']}...")
|
|
_gclb_address_ok, _gclb_cert_state = _wait_for_gclb_convergence(
|
|
app_ctx,
|
|
host,
|
|
owner["name"],
|
|
owner["namespace"],
|
|
owner["managed_cert"],
|
|
timeout_seconds=300,
|
|
)
|
|
print(
|
|
f" GCLB convergence result: host={host} address={'assigned' if _gclb_address_ok else 'pending'} certState={_gclb_cert_state}"
|
|
)
|
|
|
|
managed_cert_statuses = _managed_cert_statuses(app_ctx, owner["namespace"], owner["managed_cert"])
|
|
managed_cert_status_summary = (
|
|
",".join(f"{name}:{status}" for name, status in managed_cert_statuses.items()) if managed_cert_statuses else "-"
|
|
)
|
|
owner["managed_cert_status"] = managed_cert_status_summary
|
|
|
|
probe_ok = False
|
|
probe_result = "not-run"
|
|
if owner["has_tls_path"]:
|
|
probe_ok, probe_result = _probe_https_endpoint(host)
|
|
owner["tls_path_state"] = "TLS_PATH_SERVING" if probe_ok else "TLS_PATH_ATTACHED_BUT_NOT_SERVING"
|
|
else:
|
|
owner["tls_path_state"] = "MISSING_TLS_PATH"
|
|
owner["https_probe"] = probe_result
|
|
owner["tls_failure_reason"] = "-" if probe_ok else _classify_tls_failure(
|
|
has_tls_path=owner["has_tls_path"],
|
|
managed_cert_statuses=managed_cert_statuses,
|
|
probe_result=probe_result,
|
|
)
|
|
# Store GCLB convergence state for failure classification.
|
|
owner["_gclb_cert_state"] = _gclb_cert_state
|
|
owner["_gclb_address_ok"] = _gclb_address_ok
|
|
|
|
print(
|
|
" - host={host} ingress={ns}/{name} class={cls} address={addr} tlsState={tls_state} failureReason={reason} tlsHosts={tls_hosts} tlsSecrets={tls_secrets} managedCert={managed} managedCertStatus={managed_status} preSharedCert={pre_shared} httpsProbe={https_probe} backend={backend}".format(
|
|
host=host,
|
|
ns=owner["namespace"],
|
|
name=owner["name"],
|
|
cls=owner["ingressClass"],
|
|
addr=owner["address"],
|
|
tls_state=owner["tls_path_state"],
|
|
reason=owner.get("tls_failure_reason", "-"),
|
|
tls_hosts=owner["tls_hosts"],
|
|
tls_secrets=owner["tls_secrets"],
|
|
managed=owner["managed_cert"],
|
|
managed_status=owner.get("managed_cert_status", "-"),
|
|
pre_shared=owner["pre_shared_cert"],
|
|
https_probe=owner.get("https_probe", "not-run"),
|
|
backend=owner["backend"],
|
|
)
|
|
)
|
|
|
|
if expected and (owner["namespace"] != expected["namespace"] or owner["name"] != expected["name"]):
|
|
broken_hosts.append(
|
|
f"{host}: unexpected ingress owner {owner['namespace']}/{owner['name']} (expected {expected['namespace']}/{expected['name']})"
|
|
)
|
|
|
|
if owner["address"] == "pending":
|
|
broken_hosts.append(f"{host}: ingress {owner['namespace']}/{owner['name']} has no load-balancer address yet")
|
|
|
|
if owner["tls_path_state"] == "MISSING_TLS_PATH":
|
|
broken_hosts.append(
|
|
f"{host}: NO_TLS_PATH_CONFIGURED on ingress {owner['namespace']}/{owner['name']} (tlsHosts={owner['tls_hosts']}, managedCert={owner['managed_cert']}, preSharedCert={owner['pre_shared_cert']})"
|
|
)
|
|
|
|
if owner["tls_path_state"] == "TLS_PATH_ATTACHED_BUT_NOT_SERVING":
|
|
reason = owner.get("tls_failure_reason", "TLS_PATH_ATTACHED_BUT_NOT_SERVING")
|
|
cert_status_detail = f", managedCertStatus={owner.get('managed_cert_status', '-')}" if reason == "CERT_NOT_READY" else ""
|
|
# Requirement 5: if cert is still Provisioning (expected right after ingress creation/replacement),
|
|
# surface as a warning instead of a hard failure. GCLB needs time to provision.
|
|
_gclb_cert_state = owner.get("_gclb_cert_state", "Active")
|
|
if reason in ("CERT_NOT_READY", "TLS_PATH_ATTACHED_BUT_NOT_SERVING") and _gclb_cert_state == "Provisioning":
|
|
print(
|
|
f" WARN: {host}: cert is still Provisioning on ingress {owner['namespace']}/{owner['name']} "
|
|
f"(httpsProbe={owner.get('https_probe', '-')}{cert_status_detail}) — "
|
|
"GCLB is converging; HTTPS will become available once the cert is Active."
|
|
)
|
|
else:
|
|
broken_hosts.append(
|
|
f"{host}: {reason} on ingress {owner['namespace']}/{owner['name']} (httpsProbe={owner.get('https_probe', '-')}{cert_status_detail})"
|
|
)
|
|
|
|
if broken_hosts:
|
|
print("\nERROR: Public HTTPS front-door validation failed.")
|
|
for issue in broken_hosts:
|
|
print(f" - {issue}")
|
|
sys.exit(1)
|
|
|
|
endpoint_labels = {
|
|
runtime["supabase_api_host"]: "Supabase Kong",
|
|
runtime["supabase_studio_host"]: "Supabase Studio",
|
|
runtime["gitlab_host"]: "GitLab",
|
|
runtime["auth_host"]: "Auth API",
|
|
runtime["service_host"]: "Service/Grafana",
|
|
}
|
|
|
|
print(f"\n{'hostname':<24} {'owner ingress':<34} {'class':<10} {'external IP':<22}")
|
|
print("-" * 100)
|
|
for host in public_hosts:
|
|
owners = host_diagnostics.get(host) or []
|
|
owner = _pick_authoritative_owner(owners, expected_owner.get(host)) or (owners[0] if owners else {})
|
|
owner_name = f"{owner.get('namespace','-')}/{owner.get('name','-')}"
|
|
print(f"{host:<24} {owner_name:<34} {owner.get('ingressClass','-'):<10} {owner.get('address','pending'):<22}")
|
|
PY
|