prole/knoe/core/actions.py
chrisfu 2df7c91926 fix(kong): SERVICE_NAMESPACE=default in conf/dev overlay caused kong to deploy to default ns
conf/dev/knoe.cfg had a stale SERVICE_NAMESPACE=default written by a prior TUI session.
Also guard _service_namespace() so the value 'default' is never returned — it is never
a valid service namespace and signals a stale generated config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:44:51 -07:00

7791 lines
314 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Installer actions and unattended workflow helpers.
"""
from __future__ import annotations
import shlex
import socket
import sys
import threading
import uuid
import logging
import secrets
import string
from knoe import knoe_conf as knoe_conf_mgr
from knoe.config import (
_expand_path,
_collect_cfg_vars,
_expand_cfg_value,
_parse_bool,
_is_openbao_ref,
_is_knoe_secret,
_decrypt_knoe_secret,
_encrypt_knoe_secret,
_write_k3s_kubeconfig,
_encrypt_cfg_secret,
_merge_kubeconfig,
)
from knoe.core.build_context import copy_build_context_dir
from knoe.core.cnpg_placement import (
load_cnpg_placement_plan,
plan_cnpg_placement,
save_cnpg_placement_plan,
)
from knoe.core.controller import KnoeController
from knoe.core.env import * # noqa: F401,F403
from knoe.core.env import (
_parse_ollama_host,
_bool_str,
_deployment_mode_from_env,
_openbao_placeholder,
_looks_like_k8s_bearer_token,
_find_kubeconfig_file,
_normalize_cluster_env,
_pf_upsert_mapping,
_build_required_port_forwards,
_local_registry_enabled,
_collect_images_from_files,
_http_ping_registry,
_registry_image_ref_exists,
_resolve_supabase_home,
_push_docker_image,
_detect_ansible_topology,
_detect_ansible_storage_mounts,
_detect_ansible_node_labels,
_deployment_target_label,
_default_opentofu_pipeline_url,
_format_ollama_host,
_host_from_url,
_render_knoe_cfg,
_k3d_knoe_data_volume_args,
_sync_opentofu_pipeline,
_read_k3s_cfg,
_safe_str,
_expand_cfg_value as _expand_env_cfg_value,
_resolve_k3s_connection as _resolve_k3s_connection_fn,
_kubectl_base_cmd_for_k3s as _kubectl_base_cmd_for_k3s_fn,
_verify_k3s_services_status,
)
from knoe.core.milestones import (
DependenciesMilestone,
NetworkScanMilestone,
EnvSetupMilestone,
SecretManagementMilestone,
DatabaseCreationMilestone,
DockerBuildMilestone,
ClusterLifecycleMilestone,
InitializationScriptsMilestone,
KerberosMilestone,
DeploymentMilestone,
SecurityMilestone,
GitOpsMilestone,
SupabaseImagePreloadMilestone,
SupabaseMilestone,
)
from knoe.core.ops import garage_store as garage_store_ops
from knoe.core.ops import monitoring as monitoring_ops
from knoe.core.ops import openbao as openbao_ops
from knoe.core.ops import opentofu as opentofu_ops
from knoe.core.ops import registry as registry_ops
from knoe.core.ops.gke_clusters import (
GkeClusterSpec,
build_kubectl_env_for_cluster,
ensure_app_cluster,
ensure_db_cluster,
get_cluster_credentials,
)
from knoe.core.ops.cloudnative_pg import (
initialize as cnpg_initialize,
deploy as cnpg_deploy,
install_barman_plugin as cnpg_install_barman_plugin,
rollout as cnpg_rollout,
)
from knoe.core.ops.storage import (
ClusterStorageSpec,
StorageProvisioningError,
provision_cluster_storage,
)
from knoe.core.policy import (
POLICY_CFG_KEY,
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES,
evaluate_optional_workloads_allowed,
)
from knoe.core.stream_exec import run_streaming_cmd
from typing import Callable, Sequence
DEFAULT_APP_CLUSTER_NAME = "knoe-dev-0"
DEFAULT_APP_CLUSTER_MODE = "standard"
DEFAULT_APP_CLUSTER_MACHINE_TYPE = "e2-standard-2"
DEFAULT_APP_CLUSTER_NODE_COUNT = 3
DEFAULT_DB_CLUSTER_NAME = "knoe-dev-cnpg-0"
DEFAULT_DB_CLUSTER_MODE = "standard"
DEFAULT_DB_CLUSTER_NODE_COUNT = 3
DEFAULT_DB_CLUSTER_MACHINE_TYPE = "e2-standard-2"
DEFAULT_DB_BOOT_DISK_TYPE = "pd-standard"
DEFAULT_DB_BOOT_DISK_SIZE_GB = 50
def _configure_unbuffered_io():
os.environ.setdefault("PYTHONUNBUFFERED", "1")
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(line_buffering=True, write_through=True)
except Exception:
try:
stream.flush()
except Exception:
pass
def _kubeconfig_cluster_info_ok(kubeconfig: Path, timeout_s: int = 8) -> bool:
try:
res = subprocess.run(
["kubectl", "--kubeconfig", str(kubeconfig), "cluster-info"],
capture_output=True,
text=True,
timeout=timeout_s,
)
return res.returncode == 0
except Exception:
return False
def _select_existing_kubeconfig(candidates: Sequence[Path]) -> Path | None:
"""Return the first kubeconfig that exists and can reach/auth the cluster.
If none of the existing candidates pass `kubectl cluster-info` (e.g. cluster
not yet reachable), fall back to the first existing candidate to preserve
previous behavior.
"""
first_existing: Path | None = None
for kc in candidates:
try:
if not kc.exists():
continue
except Exception:
continue
if first_existing is None:
first_existing = kc
if _kubeconfig_cluster_info_ok(kc):
return kc
return first_existing
class KnoeInstaller:
"""Shared business logic for both silent and interactive installer modes.
Subclasses must provide a :meth:`_get_input` implementation that bridges
their respective data-access layers (``dict`` for silent, tk ``StringVar``
for the GUI). All common helpers namespace, env, secrets, port-forwards,
script-env, logging live here so they are defined exactly once.
"""
# Subclasses should set this to ``True`` for headless operation.
silent: bool = False
# ------------------------------------------------------------------ init
def _init_shared_state(self):
"""Initialise state common to every installer flavour."""
self.knoe_cfg_data = {
"Global": {},
"Welcome": {},
"Dependencies": {},
"Network": {},
"Storage": {},
"Port Forwards": {},
"System Environment": {},
"Monitoring": {},
"Kerberos Authentication": {},
"Ollama": {},
"Optional Features": {},
"Database Options": {},
"Database Creation": {},
"Docker Build": {},
"Initialize Cluster": {},
"Initialization Scripts": {},
"GitOps": {},
"Deployment": {},
"Dev Cluster (k3d)": {},
"Service Cluster (k3s)": {},
"Prod Cluster (k8s)": {},
"Install": {},
}
self._cfg_secret_cache: dict = {}
self._secrets_finalized: bool = False
self._managed_kubeconfig: str | None = None
self._repair_ran: bool = False
self._optional_workloads_policy_cache: tuple[bool, int, str] | None = None
def _optional_workloads_policy(self, env: dict) -> tuple[bool, int, str]:
"""Evaluate and cache the optional-workloads policy.
Central rule:
`optional_workloads_allowed = ready_schedulable_nodes >= min_required`
`min_required` is loaded from `knoe.cfg` Global key
`OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES`.
"""
if self._optional_workloads_policy_cache is not None:
return self._optional_workloads_policy_cache
raw = (
(self.knoe_cfg_data.get("Global", {}) or {}).get(POLICY_CFG_KEY, "")
)
try:
min_nodes = int(str(raw).strip() or str(OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES))
except Exception:
min_nodes = OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES
allowed, ready_count, reason = evaluate_optional_workloads_allowed(
env=env, min_nodes=min_nodes
)
self._optional_workloads_policy_cache = (allowed, ready_count, reason)
self.log(f"[POLICY] {reason}")
return self._optional_workloads_policy_cache
# --------------------------------------------------------- data access
def _get_input(self, key: str, default: str | None = None) -> str:
"""Return user input for *key*. Subclasses **must** override."""
raise NotImplementedError
def _get_input_bool(self, key: str, default: bool = False) -> bool:
return _parse_bool(self._get_input(key, None), default=default)
# --------------------------------------------------------- logging
def log(self, msg: str):
print(msg, flush=True)
logging.info(msg)
def err(self, msg: str):
print(msg, file=sys.stderr, flush=True)
logging.error(msg)
# ------------------------------------------------ deployment helpers
def _deployment_mode(self) -> str:
return _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
def _secret_namespace(self) -> str:
ns = (self._get_input("init_password.db_namespace", "") or "").strip()
if not ns:
ns = (self._get_input("env_setup.DATABASE_NAMESPACE", "") or "").strip()
if not ns:
ns = (self._get_input("env_setup.NAMESPACE", "") or "").strip()
if not ns:
ns = (
(self.knoe_cfg_data.get("Global", {}) or {}).get(
"DATABASE_NAMESPACE", ""
)
or ""
).strip()
if not ns:
ns = (
(self.knoe_cfg_data.get("Global", {}) or {}).get("NAMESPACE", "")
or ""
).strip()
if ns.startswith("${") and ns.endswith("}"):
ns = ""
if ns:
return ns
try:
if self._deployment_mode() == "k8s":
return "knoe-db-0"
except Exception:
pass
return "default"
def _service_namespace(self) -> str:
ns = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("SERVICE_NAMESPACE", "")
.strip()
)
if ns.startswith("${") and ns.endswith("}"):
ns = ""
if ns == "default":
ns = ""
if not ns:
ns = (os.environ.get("SERVICE_NAMESPACE") or "").strip()
if ns == "default":
ns = ""
if ns:
return ns
# In k3s service clusters, core services live in knoe-system by default.
try:
if self._deployment_mode() == "k3s":
return "knoe-system"
except Exception:
pass
return "knoe-system"
# ------------------------------------------ k3s connection helpers
def _read_k3s_cfg_values(
self, cfg_path: "Path | None" = None
) -> tuple[str, str, str]:
"""Return (cluster_env, server_url, token) from knoe.cfg.
Delegates to the module-level ``_read_k3s_cfg`` so that every
presentation layer (Tk, ncurses, silent) uses the same logic.
"""
if cfg_path is None:
cfg_path = getattr(self, "cfg_path", None)
return _read_k3s_cfg(cfg_path)
def _resolve_k3s_connection(self) -> tuple[str, str]:
"""Resolve k3s server URL and token from all sources.
Delegates to the module-level ``_resolve_k3s_connection`` so that
every presentation layer uses identical resolution order.
"""
cfg_path = getattr(self, "cfg_path", None)
root = getattr(self, "project_root", None) or PROJECT_ROOT
topology = getattr(self, "ansible_topology", None)
return _resolve_k3s_connection_fn(
project_root=root,
cfg_path=cfg_path,
ansible_topology=topology,
)
def _kubectl_base_cmd_for_k3s(self) -> list[str]:
"""Build a kubectl command prefix suitable for the k3s service cluster.
Delegates to the module-level ``_kubectl_base_cmd_for_k3s``.
"""
managed = getattr(self, "_managed_kubeconfig", None)
root = getattr(self, "project_root", None) or PROJECT_ROOT
return _kubectl_base_cmd_for_k3s_fn(
managed_kubeconfig=managed,
project_root=root,
)
def _kubectl_base_cmd(self, mode: str | None = None, role: str = "app") -> list[str]:
"""Build a consistent kubectl command based on the current cluster environment.
Prefers environment-specific base commands (k3s/k8s) or KUBECONFIG,
and ensures the selected context is used if provided.
"""
if not mode:
mode = self._deployment_mode()
# 1. Start with environment-specific base command
if mode == "k3s":
cmd = self._kubectl_base_cmd_for_k3s()
elif mode == "k8s":
kubeconfig = _find_kubeconfig_file()
if kubeconfig:
cmd = ["kubectl", "--kubeconfig", kubeconfig]
else:
cmd = ["kubectl"]
elif mode == "k3d":
# For k3d, check if we have a managed or merged config
kubeconfig_env = (os.environ.get("KUBECONFIG") or "").strip()
if kubeconfig_env:
cmd = ["kubectl", "--kubeconfig", kubeconfig_env]
else:
cmd = ["kubectl"]
else:
cmd = ["kubectl"]
# 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(role).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
def ensure_db_k8s_secrets(
self,
namespace: str,
db_password: str,
*,
log_fn=None,
) -> None:
"""Ensure DB/CNPG secrets exist in Kubernetes for *namespace*.
`etc/init_cloudnative_pg.sh` expects the following secrets:
- `knoe-db-user` with `username` and `password`
- `knoe-db-superuser` with `username` and `password`
- `cnpg-admin-key` with `admin.key` and `admin.pub`
This uses the provided DB password (user-entered) and a locally generated
ssh keypair (`~/.ssh/id_knoe_ed25519`) to bootstrap the admin key files
in `KNOE_SERVICE/secrets/`.
Notes:
- `knoe-db-user` and `knoe-db-superuser` are always applied/updated.
- `cnpg-admin-key` is created only if missing (no silent rotation).
- Secret manifests are never logged (to avoid leaking credentials).
"""
ns = (namespace or "").strip()
if not ns:
raise Exception("Database namespace cannot be empty.")
pw = (db_password or "").strip()
if pw.startswith("${"):
resolved = self._resolve_secret_value(pw)
if resolved and not resolved.startswith("${"):
pw = resolved
if not pw or pw.startswith("${"):
raise Exception("Database password is required to ensure Kubernetes secrets.")
log = log_fn or getattr(self, "log", None) or (lambda *_: None)
env = self._script_env_for_namespace(ns)
# Determine the base kubectl command for the DB cluster.
base_cmd = self._kubectl_base_cmd(role="db")
# Extract the context name for logging if present.
_db_ctx = ""
if "--context" in base_cmd:
idx = base_cmd.index("--context")
if idx + 1 < len(base_cmd):
_db_ctx = base_cmd[idx+1]
if _db_ctx:
log(f"Using DB cluster context: {_db_ctx}")
def _run(
cmd: list[str],
*,
input_text: str | None = None,
log_output: bool = True,
) -> subprocess.CompletedProcess:
# Replace 'kubectl' with the role-specific base command if applicable.
final_cmd = list(cmd)
if final_cmd and final_cmd[0] == "kubectl":
final_cmd = base_cmd + final_cmd[1:]
res = subprocess.run(
final_cmd,
input=input_text,
text=True,
capture_output=True,
env=env,
)
if log_output:
out = (res.stdout or "").strip()
err = (res.stderr or "").strip()
if out:
log(out)
if err:
log(err)
return res
def _ensure_namespace() -> None:
chk = _run(["kubectl", "get", "namespace", ns], log_output=False)
if chk.returncode == 0:
return
rc = _run(["kubectl", "create", "namespace", ns], log_output=True)
if rc.returncode != 0:
raise Exception(f"Failed to create namespace '{ns}'")
def _apply_secret_from_create(create_cmd: list[str]) -> None:
# 1) render YAML (do not log; contains secret material)
rendered = _run(create_cmd, log_output=False)
if rendered.returncode != 0 or not (rendered.stdout or "").strip():
raise Exception("Failed to render Kubernetes Secret manifest.")
# 2) apply it (safe to log)
applied = _run(
["kubectl", "apply", "-n", ns, "-f", "-"],
input_text=rendered.stdout,
log_output=True,
)
if applied.returncode != 0:
raise Exception("Failed to apply Kubernetes Secret manifest.")
def _apply_literal_secret(name: str, data: dict[str, str]) -> None:
args = ["kubectl", "create", "secret", "generic", name, "-n", ns]
for k, v in data.items():
args.append(f"--from-literal={k}={v}")
args.extend(["--dry-run=client", "-o", "yaml"])
_apply_secret_from_create(args)
def _secret_exists(name: str) -> bool:
res = _run(["kubectl", "-n", ns, "get", "secret", name], log_output=False)
return res.returncode == 0
log(f"==> Ensuring DB secrets in namespace '{ns}'")
_ensure_namespace()
# Ensure expected admin key files exist on disk for CNPG tooling.
service_dir = Path((env.get("KNOE_SERVICE") or "").strip())
secrets_dir = service_dir / "secrets" if service_dir else None
if secrets_dir:
secrets_dir.mkdir(parents=True, exist_ok=True)
ssh_priv = Path.home() / ".ssh" / "id_knoe_ed25519"
ssh_pub = Path.home() / ".ssh" / "id_knoe_ed25519.pub"
if not (ssh_priv.exists() and ssh_pub.exists()):
# Best-effort: generate if missing.
try:
self._generate_ssh_key(self._get_input("init_password.db_username", "knoe").strip() or "knoe")
except Exception:
pass
if secrets_dir and ssh_priv.exists() and ssh_pub.exists():
# Write both generic and legacy filenames expected by scripts.
try:
admin_priv_generic = secrets_dir / "admin.key"
admin_pub_generic = secrets_dir / "admin.pub"
admin_priv_legacy = secrets_dir / "admin_ed25519"
admin_pub_legacy = secrets_dir / "admin_ed25519.pub"
if not admin_priv_generic.exists():
shutil.copyfile(ssh_priv, admin_priv_generic)
os.chmod(admin_priv_generic, 0o600)
if not admin_pub_generic.exists():
shutil.copyfile(ssh_pub, admin_pub_generic)
if not admin_priv_legacy.exists():
shutil.copyfile(ssh_priv, admin_priv_legacy)
os.chmod(admin_priv_legacy, 0o600)
if not admin_pub_legacy.exists():
shutil.copyfile(ssh_pub, admin_pub_legacy)
except Exception:
pass
# DB credentials secrets expected by CNPG init script.
_apply_literal_secret("knoe-db-user", {"username": "knoe", "password": pw})
_apply_literal_secret(
"knoe-db-superuser", {"username": "postgres", "password": pw}
)
# Admin key secret only if missing (avoid rotation).
if not _secret_exists("cnpg-admin-key"):
if not secrets_dir:
raise Exception("KNOE_SERVICE is not set; cannot locate secrets directory for CNPG admin key.")
admin_priv = secrets_dir / "admin.key"
admin_pub = secrets_dir / "admin.pub"
if not (admin_priv.exists() and admin_pub.exists()):
raise Exception(
"CNPG admin key files are missing (admin.key/admin.pub); cannot create cnpg-admin-key secret."
)
create = [
"kubectl",
"create",
"secret",
"generic",
"cnpg-admin-key",
"-n",
ns,
f"--from-file=admin.key={admin_priv}",
f"--from-file=admin.pub={admin_pub}",
"--dry-run=client",
"-o",
"yaml",
]
_apply_secret_from_create(create)
def _verify_k3s_services_core(self) -> dict[str, str]:
"""Check registry, OpenBao and OpenTofu on a remote k3s cluster.
Delegates to the module-level ``_verify_k3s_services_status``.
"""
managed = getattr(self, "_managed_kubeconfig", None)
root = getattr(self, "project_root", None) or PROJECT_ROOT
return _verify_k3s_services_status(
project_root=root,
managed_kubeconfig=managed,
)
def _deploy_k3s_services_core(self, controller=None, log_fn=None):
"""Deploy registry, OpenBao and OpenTofu to a remote k3s cluster.
*controller* must expose ``run_script(name, args, env, on_line)``.
Falls back to ``self.controller`` when not provided.
"""
controller = controller or getattr(self, "controller", None)
if controller is None:
raise RuntimeError("No controller available for script execution")
server, token = self._resolve_k3s_connection()
if not server or not token:
if log_fn:
log_fn("Missing k3s server URL or token.\n")
return
ns = getattr(self, "db_namespace", None) or "default"
if hasattr(ns, "get"):
ns = ns.get()
ns = (ns or "default").strip()
db_pw = getattr(self, "db_password", "") or ""
if hasattr(db_pw, "get"):
db_pw = db_pw.get()
env = os.environ.copy()
root = getattr(self, "project_root", None) or PROJECT_ROOT
env["KNOE_HOME"] = str(root)
env["KNOE_SERVICE"] = str(root)
env["DB_PASSWORD"] = db_pw
env["OPENTOFU_ADMIN_PASSWORD"] = db_pw
env["KNOE_MODE"] = "k3s"
# Resolve KUBECONFIG through the same path the Tk UI and silent
# installer use: prefer existing cert-based file, fall back to
# token-generated kubeconfig.
kubeconfig = _find_kubeconfig_file(env)
if not kubeconfig:
try:
kubeconfig = str(_write_k3s_kubeconfig(server, token))
except Exception:
pass
if kubeconfig:
env["KUBECONFIG"] = kubeconfig
try:
_merge_kubeconfig(kubeconfig)
except Exception:
pass
service_ns = (
env.get("SERVICE_NAMESPACE")
or (self.knoe_cfg_data.get("Global", {}) or {}).get("SERVICE_NAMESPACE", "")
or "knoe-system"
)
service_ns = str(service_ns).strip() or "knoe-system"
argocd_ns = env.get("ARGOCD_NAMESPACE") or "argocd"
registry_ns = env.get("REGISTRY_NAMESPACE") or service_ns
if str(registry_ns).strip() == "default":
registry_ns = service_ns
env["ARGOCD_NAMESPACE"] = argocd_ns
env["SERVICE_NAMESPACE"] = service_ns
env["REGISTRY_NAMESPACE"] = registry_ns
mode = _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
try:
registry_ops.update(
namespace=registry_ns,
env=env,
project_root=root,
mode=mode,
log=log_fn,
)
openbao_ops.update(
namespace=ns,
env=env,
project_root=root,
mode=mode,
log=log_fn,
)
opentofu_ops.update(
namespace=ns,
env=env,
project_root=root,
mode=mode,
log=log_fn,
)
except Exception as e:
if log_fn:
log_fn(f"Deploy error: {e}\n")
# ------------------------------------------------- secret helpers
def _secret_cfg_value(
self, section: str, key: str, plaintext: str, leaf: str, bao_key: str
) -> str:
if self._secrets_finalized:
return _openbao_placeholder(self._secret_namespace(), leaf, bao_key)
if not plaintext:
return ""
if _is_openbao_ref(plaintext) or _is_knoe_secret(plaintext):
return plaintext
existing = (self.knoe_cfg_data.get(section, {}) or {}).get(key)
if existing and _is_knoe_secret(existing):
try:
if _decrypt_knoe_secret(existing) == plaintext:
return existing
except Exception:
pass
try:
return _encrypt_knoe_secret(plaintext)
except Exception:
return plaintext
def _resolve_openbao_ref(self, value: str) -> str:
if not _is_openbao_ref(value):
return value
inner = value[len(OPENBAO_PREFIX) : -len(OPENBAO_SUFFIX)]
path, key = (inner.split("#", 1) + [""])[:2]
if not path or not key:
return ""
mount = "kv"
secret_path = path
if "/" in path:
maybe_mount, rest = path.split("/", 1)
if maybe_mount:
mount = maybe_mount
secret_path = rest
token = os.environ.get("OPENBAO_ROOT_TOKEN", "")
if not token:
knoe_service = os.environ.get("KNOE_SERVICE")
if knoe_service:
token_path = Path(knoe_service) / "secrets" / "openbao-root-token"
if token_path.exists():
token = token_path.read_text().strip()
if not token:
return value
url = os.environ.get("PROLE_OPENBAO_URL")
if not url:
for p in ["8200", "18200"]:
try:
import urllib.request
with urllib.request.urlopen(
f"http://127.0.0.1:{p}/v1/sys/health", timeout=0.5
) as r:
if r.getcode() == 200:
url = f"http://127.0.0.1:{p}"
break
except Exception:
pass
if not url:
url = "http://127.0.0.1:8200"
url = url.rstrip("/")
try:
req = urllib.request.Request(f"{url}/v1/{mount}/data/{secret_path}")
req.add_header("X-Vault-Token", token)
with urllib.request.urlopen(req, timeout=4) as resp:
payload = json.loads(resp.read().decode("utf-8"))
return payload.get("data", {}).get("data", {}).get(key, "") or ""
except Exception:
return value
def _resolve_secretref_value(self, value: str) -> str:
raw = str(value or "").strip()
if not raw.startswith("secretref://"):
return value
ref = raw[len("secretref://") :].strip().strip("/")
if not ref:
return value
env_candidates: list[str] = []
def _add_env(name: str) -> None:
token = str(name or "").strip()
if token and token not in env_candidates:
env_candidates.append(token)
_add_env(ref)
normalized = ref.replace("-", "_").replace(".", "_").replace("/", "_")
_add_env(normalized)
_add_env(normalized.upper())
if ref == "google-oidc-client-id":
for key in (
"GITLAB_OIDC_CLIENT_ID",
"OIDC_CLIENT_ID",
"GOOGLE_OIDC_CLIENT_ID",
"GOOGLE_CLIENT_ID",
):
_add_env(key)
elif ref == "google-oidc-client-secret":
for key in (
"GITLAB_OIDC_CLIENT_SECRET",
"OIDC_CLIENT_SECRET",
"GOOGLE_OIDC_CLIENT_SECRET",
"GOOGLE_CLIENT_SECRET",
):
_add_env(key)
for env_key in env_candidates:
candidate = str(os.environ.get(env_key, "") or "").strip()
if candidate:
return candidate
file_candidates = [
Path(self.project_root) / "etc" / "secrets" / ref,
Path(self.project_root) / "secrets" / ref,
]
service_dir = str(
self._get_input("env_setup.KNOE_SERVICE", "")
or os.environ.get("KNOE_SERVICE", "")
or ""
).strip()
if service_dir:
file_candidates.insert(0, Path(service_dir) / "secrets" / ref)
for file_path in file_candidates:
try:
if file_path.exists() and file_path.is_file():
candidate = file_path.read_text(encoding="utf-8").strip()
if candidate:
return candidate
except Exception:
continue
return value
def _resolve_secret_value(self, value: str) -> str:
if _is_knoe_secret(value):
return _decrypt_knoe_secret(value)
if _is_openbao_ref(value):
return self._resolve_openbao_ref(value)
if str(value or "").strip().startswith("secretref://"):
return self._resolve_secretref_value(value)
return value
# --------------------------------------------- cfg sanitisation
def _sanitize_sections_for_cfg(self, sections: dict) -> dict:
sanitized = {k: dict(v) for k, v in sections.items()}
# Persist fully expanded values in knoe.cfg (no $VAR / ${VAR} expansions).
# Downstream tools treat these values as plain strings.
env_map = dict(os.environ)
try:
env_map.update(
{k: str(v or "") for k, v in (self._read_existing_env() or {}).items()}
)
except Exception:
pass
for k in (
"KNOE_HOME",
"KNOE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"KNOE_SERVICE",
"NAMESPACE",
"SERVICE_NAMESPACE",
):
val = (self._get_input(f"env_setup.{k}", "") or "").strip()
if val:
try:
env_map[k] = self._expand_shell_path(val, env=env_map)
except Exception:
env_map[k] = val
for section_name, section in sanitized.items():
for k, v in list(section.items()):
if not isinstance(v, str) or not v.strip():
continue
if _is_knoe_secret(v) or _is_openbao_ref(v):
continue
try:
section[k] = self._cfgify_home_path(
self._expand_shell_path(v, env=env_map)
)
except Exception:
section[k] = v
if "Kerberos Authentication" in sanitized:
val = sanitized["Kerberos Authentication"].get("PASSWORD", "")
if val or ("Kerberos Authentication", "PASSWORD") in self._cfg_secret_cache:
sanitized["Kerberos Authentication"]["PASSWORD"] = (
self._secret_cfg_value(
"Kerberos Authentication",
"PASSWORD",
val,
"kerberos",
"password",
)
)
if "Monitoring" in sanitized:
val = sanitized["Monitoring"].get("GRAFANA_ADMIN_PASSWORD", "")
if (
val
or ("Monitoring", "GRAFANA_ADMIN_PASSWORD") in self._cfg_secret_cache
):
sanitized["Monitoring"]["GRAFANA_ADMIN_PASSWORD"] = (
self._secret_cfg_value(
"Monitoring",
"GRAFANA_ADMIN_PASSWORD",
val,
"monitoring",
"grafana_admin_password",
)
)
return sanitized
# ------------------------------------------- namespace helpers
def _get_local_owner(self) -> str:
try:
return getpass.getuser()
except Exception:
try:
return os.getlogin()
except Exception:
return "knoe"
def _sanitize_namespace(self, name: str) -> str:
cleaned = re.sub(r"[^a-z0-9-]+", "-", (name or "").lower())
cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
if not cleaned:
cleaned = "knoe-db"
if len(cleaned) > 63:
cleaned = cleaned[:63].rstrip("-")
return cleaned
def _generate_namespace_name(self) -> str:
owner = self._sanitize_namespace(self._get_local_owner())
suffix = uuid.uuid4().hex[:6]
base = f"knoe-db-{owner}-{suffix}"
return self._sanitize_namespace(base)
def _ensure_namespace_prefix(self, name: str) -> str:
cleaned = (name or "").strip()
if not cleaned:
return "knoe-db"
return cleaned
def _read_existing_cfg_namespace(self) -> str | None:
"""Best-effort read of database namespace from the existing knoe.cfg.
Silent retries should reuse the namespace stored in knoe.cfg unless the
user explicitly overrides via env vars.
"""
cfg_path = getattr(self, "cfg_path", None)
if not cfg_path:
return None
try:
p = Path(str(cfg_path)).expanduser()
if p.is_dir():
p = p / "knoe.cfg"
if not p.exists():
return None
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
cfg.read(p)
# Current canonical location
for section, key in (
("User", "DATABASE_NAMESPACE"),
("User", "PROLE_NAMESPACE"),
("Global", "DATABASE_NAMESPACE"),
# Legacy fallbacks
("User", "NAMESPACE"),
("Database Creation", "NAMESPACE"),
("Database Creation", "DATABASE_NAMESPACE"),
("Database Creation", "DB_NAME"),
):
if cfg.has_option(section, key):
ns = (cfg.get(section, key, fallback="") or "").strip()
if ns:
return ns
except Exception:
return None
return None
def _initial_namespace(self) -> str:
# Explicit overrides must win.
ns = (
os.environ.get("DATABASE_NAMESPACE")
or os.environ.get("PROLE_NAMESPACE")
or os.environ.get("NAMESPACE")
)
if ns:
return self._ensure_namespace_prefix(ns)
try:
existing = self._read_existing_env()
ns = (
existing.get("DATABASE_NAMESPACE")
or existing.get("NAMESPACE")
or existing.get("PROLE_NAMESPACE")
)
if ns:
return self._ensure_namespace_prefix(ns)
except Exception:
pass
# Prefer the namespace already stored in knoe.cfg to make retries stable.
ns = self._read_existing_cfg_namespace()
if ns:
return self._ensure_namespace_prefix(ns)
return self._ensure_namespace_prefix(self._generate_namespace_name())
def _initial_db_namespace(self) -> str:
"""Initial namespace suggestion for CNPG/Postgres.
When a [CNPG Clusters] registry exists in knoe.cfg, the identity
cluster's declared namespace is used unconditionally — no version suffix
is appended. Version-aware namespace generation only applies as a
last-resort fallback when no cluster registry is configured.
"""
# Registry-declared identity cluster wins unconditionally.
identity = self._cnpg_identity_cluster()
if identity and identity.get("namespace"):
return self._sanitize_namespace(
self._ensure_namespace_prefix(identity["namespace"])
)
# Explicit environment overrides.
base = (
os.environ.get("DATABASE_NAMESPACE")
or os.environ.get("KNOE_DB_NAMESPACE")
or os.environ.get("DB_NAMESPACE")
or os.environ.get("PROLE_NAMESPACE")
or os.environ.get("NAMESPACE")
or ""
).strip()
if not base:
try:
existing = self._read_existing_env()
base = (
(existing.get("DATABASE_NAMESPACE") or "")
or (existing.get("NAMESPACE") or "")
or (existing.get("PROLE_NAMESPACE") or "")
).strip()
except Exception:
base = ""
if not base:
base = (self._read_existing_cfg_namespace() or "").strip() or "knoe-db"
return self._sanitize_namespace(self._ensure_namespace_prefix(base))
def _cnpg_cluster_registry(self) -> list[dict]:
"""Parse [CNPG Clusters] section from the active knoe.cfg.
Returns a list of cluster dicts with keys: name, namespace, identity,
instances, image. Validates that namespaces are unique and exactly one
cluster is designated as the identity cluster.
"""
section = (getattr(self, "knoe_cfg_data", None) or {}).get("CNPG Clusters", {})
if not section:
return []
clusters: dict[str, dict] = {}
for raw_key, raw_val in section.items():
if "." not in raw_key:
continue
cluster_name, _, prop = raw_key.partition(".")
cluster_name = cluster_name.strip()
clusters.setdefault(cluster_name, {"name": cluster_name})
clusters[cluster_name][prop.strip()] = (raw_val or "").strip()
result = list(clusters.values())
if not result:
return []
# Validate: namespaces must be unique
namespaces = [c.get("namespace", "") for c in result]
if len(namespaces) != len(set(ns for ns in namespaces if ns)):
raise ValueError("CNPG Clusters: duplicate namespace declared")
# Validate: exactly one identity=true
identity_count = sum(
1 for c in result if c.get("identity", "").lower() == "true"
)
if identity_count != 1:
raise ValueError(
f"CNPG Clusters: exactly one cluster must have identity=true, found {identity_count}"
)
return result
def _cnpg_identity_cluster(self) -> dict | None:
"""Return the cluster entry with identity=true, or None if no registry."""
for c in self._cnpg_cluster_registry():
if c.get("identity", "").lower() == "true":
return c
return None
def _is_valid_namespace(self, name: str) -> bool:
if not name or len(name) > 63:
return False
if "db" not in name.lower():
return False
return re.match(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", name) is not None
def _generate_db_password(self, length: int = 24) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
# --------------------------------------------------- env helpers
_FOREIGN_HOME_PREFIX_RE = re.compile(r"^/(Users|home)/([^/]+)(/.*)?$")
def _rewrite_foreign_home_prefix(self, value: str) -> str:
"""Rewrite foreign home prefixes to the current user's home.
Example: running on Linux with HOME=/home/<user>, rewrite
`/Users/<user>/dev/knoe` -> `/home/<user>/dev/knoe`.
This helps prevent stale macOS paths (often stored in `~/.knoe/env.sh`) from
being persisted into `knoe.cfg` on Linux.
"""
raw = (value or "").strip()
if not raw or "$" in raw:
return raw
m = self._FOREIGN_HOME_PREFIX_RE.match(raw)
if not m:
return raw
foreign_root = m.group(1)
foreign_user = m.group(2)
suffix = m.group(3) or ""
try:
current_home = Path.home()
except Exception:
return raw
# Only rewrite when the path refers to the current username.
if foreign_user != current_home.name:
return raw
system = platform.system()
if system != "Darwin" and foreign_root == "Users":
return str(current_home) + suffix
if system == "Darwin" and foreign_root == "home":
return str(current_home) + suffix
return raw
def _shellify_home_path(self, value: str) -> str:
"""Represent paths under the current home directory using `$HOME`.
Keeps values unchanged if they already contain shell variables.
"""
raw = (value or "").strip()
if not raw or "$" in raw:
return raw
try:
p = Path(raw).expanduser()
home = Path.home()
rel = p.relative_to(home)
except Exception:
return raw
rel_str = rel.as_posix()
if not rel_str or rel_str == ".":
return "$HOME"
return f"$HOME/{rel_str}"
def _expand_shell_path(self, value: str, env: dict[str, str] | None = None) -> str:
raw = (value or "").strip()
if not raw:
return ""
env_map = env or os.environ
expanded = _expand_env_cfg_value(raw, env=env_map, max_depth=10)
return os.path.expanduser(expanded)
def _cfgify_home_path(self, value: str) -> str:
"""Represent paths under the current home directory using `$HOME`.
Also rewrites stale foreign-home paths like `/Users/<user>/...` when the
username matches the current user.
"""
raw = (value or "").strip()
if not raw or "$" in raw:
return raw
raw = self._rewrite_foreign_home_prefix(raw)
try:
p = Path(raw).expanduser()
home = Path.home()
rel = p.relative_to(home)
except Exception:
return raw
rel_str = rel.as_posix()
if not rel_str or rel_str == ".":
return "$HOME"
return f"$HOME/{rel_str}"
def _env_defaults(self, namespace: str | None = None) -> dict:
# Default install layout: ~/.knoe
# NOTE: UI may present this as $HOME/.knoe, but internally we keep
# resolved literal paths for filesystem operations.
default_home = resolve_knoe_home(env={})
resolved_home = self._resolve_env_value("KNOE_HOME", str(default_home)) or str(
default_home
)
env_map = dict(os.environ)
env_map["KNOE_HOME"] = resolved_home
expanded_home = self._expand_shell_path(resolved_home, env=env_map) or str(default_home)
home = Path(expanded_home)
env_map["KNOE_HOME"] = str(home)
if namespace is None:
namespace = (
self._get_input("init_password.db_namespace", "") or ""
).strip()
if not namespace:
namespace = os.environ.get("DATABASE_NAMESPACE", "")
if not namespace:
namespace = os.environ.get("NAMESPACE", "")
return {
"KNOE_HOME": str(home),
"KNOE_CONF": self._expand_shell_path(
self._resolve_env_value("KNOE_CONF", str(home / "conf")) or str(home / "conf"),
env=env_map,
),
"PROLE_DATA": self._expand_shell_path(
self._resolve_env_value("PROLE_DATA", str(home / "data")) or str(home / "data"),
env=env_map,
),
"PROLE_LOGS": self._expand_shell_path(
self._resolve_env_value("PROLE_LOGS", str(home / "logs")) or str(home / "logs"),
env=env_map,
),
"KNOE_SERVICE": self._expand_shell_path(
self._resolve_env_value("KNOE_SERVICE", str(home / "etc")) or str(home / "etc"),
env=env_map,
),
"DATABASE_NAMESPACE": namespace or "",
}
def _resolve_env_value(self, key: str, fallback: str | None = None) -> str | None:
val = os.environ.get(key)
if val:
return val
try:
env = self._read_existing_env()
val = env.get(key)
if val:
return val
except Exception:
pass
return fallback
def _resolve_env_dir(self, key: str, default_suffix: str) -> Path:
input_key = f"env_setup.{key}"
val = (self._get_input(input_key, "") or "").strip()
env_map = dict(os.environ)
try:
env_map.update({k: str(v or "") for k, v in (self._read_existing_env() or {}).items()})
except Exception:
pass
if val:
try:
return Path(self._expand_shell_path(val, env=env_map))
except Exception:
pass
val = self._resolve_env_value(key)
if val:
try:
return Path(self._expand_shell_path(val, env=env_map))
except Exception:
pass
return Path.home() / "dev" / "knoe" / default_suffix
def _read_existing_env(self) -> dict:
env = {}
knoe_home = os.environ.get("KNOE_HOME")
candidates = []
if knoe_home:
try:
candidates.append(Path(self._expand_shell_path(knoe_home, env=os.environ)) / "env.sh")
except Exception:
candidates.append(Path(knoe_home) / "env.sh")
candidates.append(resolve_knoe_home(env={}) / "env.sh")
for p in candidates:
try:
if p.exists():
for line in p.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[len("export ") :]
if "=" in line:
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"')
if k in {
"KNOE_HOME",
"KNOE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"KNOE_SERVICE",
}:
v = self._rewrite_foreign_home_prefix(v)
env[k] = v
break
except Exception:
pass
return env
def _save_env_to_file(self, values: dict):
cluster_env = self._get_input("init_cluster.cluster_env", "dev")
env_key = _normalize_cluster_env(cluster_env)
env_map = dict(os.environ)
try:
env_map.update({str(k): str(v) for k, v in (values or {}).items() if v is not None})
except Exception:
pass
expanded_home = self._expand_shell_path(values.get("KNOE_HOME", ""), env=env_map)
expanded_home = os.path.expanduser(expanded_home) if expanded_home else ""
if expanded_home:
env_map["KNOE_HOME"] = expanded_home
home = Path(expanded_home)
else:
home = resolve_knoe_home(env={})
if env_key == "dev":
home.mkdir(parents=True, exist_ok=True)
for key in ("KNOE_CONF", "PROLE_DATA", "PROLE_LOGS", "KNOE_SERVICE"):
try:
raw = values.get(key, "")
expanded = self._expand_shell_path(raw, env=env_map)
expanded = os.path.expanduser(expanded) if expanded else ""
if expanded:
env_map[key] = expanded
Path(expanded).mkdir(parents=True, exist_ok=True)
except Exception:
pass
content = []
content.append("#!/usr/bin/env bash")
content.append("# Knoe environment configuration")
content.append(
"# This file is generated by the installer. Source it in new shells, or execute as a wrapper:"
)
content.append('# "$KNOE_HOME/env.sh" <command> [args…]')
content.append("# shellcheck shell=bash")
for k in (
"KNOE_HOME",
"KNOE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"KNOE_SERVICE",
):
content.append(f'export {k}="{self._shellify_home_path(values[k])}"')
# NAMESPACE/PROLE_NAMESPACE must never be written to env.sh — only knoe.cfg is the source of truth
content.append("")
content.append("# Ensure PATH works for GUI-launched shells (Docker, etc.)")
content.append(
'_knoe_add_path() { case ":${PATH}:" in *":$1:"*) ;; *) PATH="$1:${PATH:-}" ;; esac; }'
)
content.append('_knoe_add_path "$KNOE_HOME/bin"')
content.append('_knoe_add_path "/opt/homebrew/bin"')
content.append('_knoe_add_path "/usr/local/bin"')
content.append('_knoe_add_path "/usr/bin"')
content.append('_knoe_add_path "/bin"')
content.append('_knoe_add_path "/usr/sbin"')
content.append('_knoe_add_path "/sbin"')
content.append("export PATH")
content.append("")
content.append("# Add custom paths below if needed (examples):")
content.append("")
content.append(
"# If executed with arguments (and not sourced), run them under this environment"
)
content.append('if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [ "$#" -gt 0 ]; then')
content.append(' exec "$@"')
content.append("fi")
new_content = "\n".join(content) + "\n"
out = home / "env.sh"
if env_key != "dev":
out = resolve_knoe_home(env={}) / "env.sh"
out.parent.mkdir(parents=True, exist_ok=True)
should_write = True
if out.exists():
if out.read_text() == new_content:
should_write = False
self.log(f"[OK] {out} is up to date.")
if should_write:
tmp = out.parent / "env.sh.tmp"
tmp.write_text(new_content)
tmp.replace(out)
try:
os.chmod(out, 0o755)
except Exception:
pass
self.log(f"[OK] Wrote {out}")
self._deploy_env_resources(values)
def _deploy_env_resources(self, values: dict):
"""Copy init scripts and etc directory into KNOE_HOME / KNOE_SERVICE."""
try:
env_map = dict(os.environ)
try:
env_map.update({str(k): str(v) for k, v in (values or {}).items() if v is not None})
except Exception:
pass
expanded_home = self._expand_shell_path(values.get("KNOE_HOME", ""), env=env_map)
expanded_home = os.path.expanduser(expanded_home) if expanded_home else ""
if expanded_home:
env_map["KNOE_HOME"] = expanded_home
expanded_service = self._expand_shell_path(values.get("KNOE_SERVICE", ""), env=env_map)
expanded_service = os.path.expanduser(expanded_service) if expanded_service else ""
knoe_home = Path(expanded_home) if expanded_home else resolve_knoe_home(env={})
knoe_service = Path(expanded_service or (knoe_home / "etc"))
init_pf_src_candidates = [
self.project_root / "src" / "knoe" / "etc" / "init-port-forward.sh",
self.project_root / "etc" / "init-port-forward.sh",
]
init_pf_src = next((p for p in init_pf_src_candidates if p.exists()), None)
if init_pf_src is not None:
init_pf_dst = knoe_home / "init-port-forward.sh"
try:
data = init_pf_src.read_bytes()
init_pf_dst.write_bytes(data)
os.chmod(init_pf_dst, 0o755)
except Exception:
pass
etc_src_candidates = [
self.project_root / "src" / "knoe" / "etc",
self.project_root / "etc",
]
etc_src = next((p for p in etc_src_candidates if p.exists()), None)
if etc_src is not None:
try:
knoe_service.mkdir(parents=True, exist_ok=True)
if etc_src.resolve() != knoe_service.resolve():
if knoe_service.exists():
shutil.rmtree(knoe_service, ignore_errors=True)
shutil.copytree(etc_src, knoe_service)
except Exception:
pass
except Exception:
pass
def reload_env_from_shell(self) -> None:
raw_home = self._get_input(
"env_setup.KNOE_HOME", str(resolve_knoe_home(env={}))
)
expanded_home = self._expand_shell_path(raw_home, env=os.environ)
expanded_home = os.path.expanduser(expanded_home) if expanded_home else ""
home = Path(expanded_home) if expanded_home else resolve_knoe_home(env={})
env_file = home / "env.sh"
cmd = (
f"export KNOE_HOME={shlex.quote(str(home))}; "
f"source {shlex.quote(str(env_file))}; "
"env -0"
)
try:
out = subprocess.check_output(["bash", "-lc", cmd])
except Exception as e:
raise Exception(f"Failed to reload environment: {e}")
try:
for raw in out.split(b"\x00"):
if not raw:
continue
kv = raw.decode("utf-8", errors="ignore")
if "=" not in kv:
continue
k, v = kv.split("=", 1)
if k in ("PYTHONPATH", "PYTHONHOME"):
continue
os.environ[k] = v
except Exception:
pass
def _update_env_namespace(self, namespace: str):
# Namespace is persisted to knoe.cfg only — never to env.sh or os.environ.
# This method is intentionally a no-op for environment mutation.
pass
# --------------------------------------------- port-forward helpers
def _add_port_mapping(self, mapping_str):
mode = self._deployment_mode()
prefix = (
"PORT_FORWARD_K3S_MAPPING_"
if mode == "k3s"
else "PORT_FORWARD_K3D_MAPPING_"
)
pf_section = self.knoe_cfg_data.get("Port Forwards", {})
if pf_section is None:
pf_section = {}
if not _pf_upsert_mapping(pf_section, prefix, mapping_str):
return
self.knoe_cfg_data["Port Forwards"] = pf_section
if hasattr(self, "_save_knoe_cfg"):
self._save_knoe_cfg()
elif hasattr(self, "_write_cfg"):
self._write_cfg()
def _process_script_output_line(self, line):
if "GRAFANA_ADMIN_PASSWORD=" in line:
pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip()
if pwd:
mon = self.knoe_cfg_data.get("Monitoring", {})
if mon is None:
mon = {}
mon["GRAFANA_ADMIN_PASSWORD"] = pwd
self.knoe_cfg_data["Monitoring"] = mon
if hasattr(self, "_save_knoe_cfg"):
self._save_knoe_cfg()
elif hasattr(self, "_write_cfg"):
self._write_cfg()
if "PORT_FORWARD_MAPPING:" in line:
mapping = line.split("PORT_FORWARD_MAPPING:")[1].strip()
if mapping:
self._add_port_mapping(mapping)
def _sync_port_forward_mappings(self):
mode = _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
if not mode:
mode = "k3d"
prefix = (
"PORT_FORWARD_K3S_MAPPING_"
if mode == "k3s"
else "PORT_FORWARD_K3D_MAPPING_"
)
pf_section = self.knoe_cfg_data.get("Port Forwards", {})
if pf_section is None:
pf_section = {}
_ids_to_remove_if_disabled = {
"registry": True,
"argocd": not self._get_input_bool("init_cluster.argocd_enabled", False),
"dashboard": True,
}
for k, v in list(pf_section.items()):
if not isinstance(v, str):
continue
for _id, _should_remove in _ids_to_remove_if_disabled.items():
if _should_remove and (f"id={_id};" in v or v.strip() == f"id={_id}"):
del pf_section[k]
break
service_ns = (self._service_namespace() or "").strip() or "default"
db_ns = (self._get_input("init_password.db_namespace", "") or "").strip()
if not db_ns:
db_ns = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("DATABASE_NAMESPACE", "")
.strip()
)
if not db_ns:
db_ns = (
(self.knoe_cfg_data.get("Global", {}) or {}).get("NAMESPACE", "").strip()
)
if not db_ns:
db_ns = (
os.environ.get("DATABASE_NAMESPACE")
or os.environ.get("PROLE_NAMESPACE")
or os.environ.get("NAMESPACE")
or ""
).strip()
if not db_ns:
db_ns = "default"
argocd_ns = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("ARGOCD_NAMESPACE", "")
.strip()
)
if not argocd_ns:
argocd_ns = (os.environ.get("ARGOCD_NAMESPACE") or "").strip()
if not argocd_ns:
argocd_ns = "argocd"
db_host_port = (self._get_input("init_password.db_host_port", "") or "").strip()
if not db_host_port:
db_host_port = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("DB_HOST_PORT", "")
.strip()
)
if not db_host_port:
db_host_port = "5432"
supabase_enabled = self._get_input_bool("init_cluster.supabase_enabled", False)
supabase_ns = (os.environ.get("SUPABASE_NAMESPACE") or "").strip()
if not supabase_ns:
supabase_ns = (
(self.knoe_cfg_data.get("Supabase", {}) or {})
.get("NAMESPACE", "")
.strip()
)
if not supabase_ns:
supabase_ns = "supabase"
gitops_enabled = self._get_input_bool("init_cluster.gitops_enabled", False)
gitops_ns = (os.environ.get("GITEA_NAMESPACE") or "").strip()
if not gitops_ns:
gitops_ns = (
(self.knoe_cfg_data.get("GitOps", {}) or {})
.get("GITOPS_NAMESPACE", "")
.strip()
)
if not gitops_ns:
gitops_ns = (self._get_input("gitops.namespace", "") or "").strip()
if not gitops_ns:
gitops_ns = "gitea"
argocd_enabled = self._get_input_bool("init_cluster.argocd_enabled", False)
monitoring_release = (
(self.knoe_cfg_data.get("Monitoring", {}) or {})
.get("MONITORING_RELEASE", "")
.strip()
or os.environ.get("MONITORING_RELEASE", "").strip()
or "prometheus"
)
mappings = _build_required_port_forwards(
mode=mode,
service_ns=service_ns,
argocd_ns=argocd_ns,
db_ns=db_ns,
db_host_port=db_host_port,
supabase_enabled=supabase_enabled,
supabase_namespace=supabase_ns,
gitops_enabled=gitops_enabled,
gitops_namespace=gitops_ns,
argocd_enabled=argocd_enabled,
dashboard_enabled=False,
monitoring_release=monitoring_release,
)
for mapping in mappings:
_pf_upsert_mapping(pf_section, prefix, mapping)
self.knoe_cfg_data["Port Forwards"] = pf_section
# --------------------------------------------- script env
def _script_env_for_namespace(self, namespace: str, cluster_role: str = "db") -> dict:
namespace = _safe_str(namespace)
cluster_role = "app" if str(cluster_role).strip().lower() == "app" else "db"
env = os.environ.copy()
root = getattr(self, "project_root", None) or PROJECT_ROOT
cluster_name = (
self._app_cluster_name() if cluster_role == "app" else self._cnpg_cluster_name()
)
env["KNOE_HOME"] = str(root)
env["KNOE_SERVICE"] = str(root)
env["DATABASE_NAMESPACE"] = namespace
env["CLUSTER_NAME"] = cluster_name
env["APP_CLUSTER_NAME"] = self._app_cluster_name()
env["DB_CLUSTER_NAME"] = self._cnpg_cluster_name()
env["APP_CLUSTER_MODE"] = self._app_cluster_mode()
env["DB_CLUSTER_MODE"] = self._db_cluster_mode()
# Transitional compatibility for scripts still reading NAMESPACE.
env["NAMESPACE"] = namespace
service_ns = self._service_namespace()
env["KNOE_CONF"] = self._get_input(
"env_setup.KNOE_CONF", str(root / "conf")
)
env["PROLE_DATA"] = self._get_input(
"env_setup.PROLE_DATA", str(root / "knoe-db" / "data")
)
env["PROLE_LOGS"] = self._get_input(
"env_setup.PROLE_LOGS", str(root / "logs")
)
env["KNOE_SERVICE"] = self._get_input(
"env_setup.KNOE_SERVICE", str(root / "etc")
)
env["KNOE_DB_USER"] = self._get_input("init_password.db_username", "").strip()
# Make the DB/root master password available to shell scripts.
# OpenTofu bootstrap uses it to create the initial admin secret when missing.
db_pw = (self._get_input("init_password.db_password", "") or "").strip()
if db_pw:
env.setdefault("DB_PASSWORD", db_pw)
env.setdefault("OPENTOFU_ADMIN_PASSWORD", db_pw)
mode = self._deployment_mode()
if mode:
env["KNOE_MODE"] = mode
env["DEPLOYMENT_MODE"] = mode
cluster_env = self._get_input("init_cluster.cluster_env", "dev")
env["DEPLOYMENT_TARGET"] = _deployment_target_label(cluster_env)
if mode == "k3s":
# 1. Prefer an existing kubeconfig that already exists on disk.
# Check knoe-specific paths before ~/.kube/config to avoid
# picking up a stale k3d context from the default kubeconfig.
root = getattr(self, "project_root", None) or PROJECT_ROOT
_knoe_kc_candidates = [
str(Path(root) / "knoe-k3s.kubeconfig"),
str(Path(root) / "etc" / "secrets" / "k3s.kubeconfig"),
str(Path(root) / "secrets" / "k3s.kubeconfig"),
]
knoe_service_dir = (env.get("KNOE_SERVICE") or "").strip()
if knoe_service_dir:
_knoe_kc_candidates.insert(1, str(Path(knoe_service_dir) / "secrets" / "k3s.kubeconfig"))
kc = next(
(p for p in _knoe_kc_candidates if p and Path(p).expanduser().exists()),
"",
) or _find_kubeconfig_file(env)
if kc:
env["KUBECONFIG"] = kc
else:
# 2. Try fetching from k3s via Ansible or generate token-based
fetched = self._fetch_k3s_kubeconfig()
if fetched:
env["KUBECONFIG"] = str(fetched)
else:
server, token = self._resolve_k3s_connection()
if server and token and _looks_like_k8s_bearer_token(token):
try:
managed = _write_k3s_kubeconfig(server, token)
self._managed_kubeconfig = str(managed)
env["KUBECONFIG"] = self._managed_kubeconfig
_merge_kubeconfig(self._managed_kubeconfig)
except Exception:
pass
elif server and token:
self.err(
"[WARN] k3s token does not look like a Kubernetes bearer token; cannot generate kubeconfig. Ensure a cert-based knoe-k3s.kubeconfig exists (Ansible fetch)."
)
raw_server, raw_token = self._resolve_k3s_connection()
if raw_server:
env["K3S_SERVER"] = raw_server
if raw_token:
env["K3S_TOKEN"] = raw_token
elif mode == "k3d":
# Ensure k3d dev clusters have a resolvable KUBECONFIG.
cluster_name = self._get_input("init_cluster.deployment_target", "knoe-dev-cluster")
if not cluster_name or cluster_name == "knoe-dev-cluster":
# Fallback to check if we have a more specific one from UI if available
cluster_name = getattr(self, "selected_k3d_cluster", None)
if hasattr(cluster_name, "get"):
cluster_name = cluster_name.get()
cluster_name = cluster_name or "knoe-dev-cluster"
try:
subprocess.run(
[
"k3d",
"kubeconfig",
"merge",
cluster_name,
"--kubeconfig-switch-context",
],
capture_output=True,
text=True,
timeout=10,
)
except Exception:
pass
default_kube = str(Path.home() / ".kube" / "config")
if Path(default_kube).exists():
env["KUBECONFIG"] = default_kube
elif mode == "k8s":
# 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
env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.at_rest_encryption_enabled", False)
)
env["SUPABASE_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_enabled", False)
)
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.knoe.org"
env["SUPABASE_STUDIO_URL"] = (
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)
)
env["SUPABASE_REALTIME_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_realtime_enabled", True)
)
env["SUPABASE_META_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_meta_enabled", True)
)
env["SUPABASE_ANALYTICS_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_analytics_enabled", True)
)
db_pw = self._get_input("init_password.db_password", "").strip()
if db_pw:
db_pw = self._resolve_secret_value(db_pw)
env["DB_PASSWORD"] = db_pw
env["OPENTOFU_ADMIN_PASSWORD"] = db_pw
grafana_pw = (
self.knoe_cfg_data.get("Monitoring", {})
.get("GRAFANA_ADMIN_PASSWORD", "")
.strip()
)
resolved_grafana = self._resolve_secret_value(grafana_pw) if grafana_pw else ""
if resolved_grafana and not (
_is_openbao_ref(resolved_grafana) or _is_knoe_secret(resolved_grafana)
):
env["GRAFANA_ADMIN_PASSWORD"] = resolved_grafana
elif db_pw:
env["GRAFANA_ADMIN_PASSWORD"] = db_pw
realm = self._get_input("kerberos_config.realm", "").strip()
if realm:
env["KRB5_REALM"] = realm
env["REALM"] = realm
env["DOMAIN"] = realm.lower()
kdc = self._get_input("kerberos_config.kdc", "").strip()
if kdc:
env["KRB5_KDC"] = kdc
env["KRB5_ADMIN"] = kdc
env["KRB5_USER"] = self._get_input("kerberos_config.user", "")
krb_pw = self._get_input("kerberos_config.password", "")
if krb_pw:
env["KRB5_PASSWORD"] = self._resolve_secret_value(krb_pw)
else:
env["KRB5_PASSWORD"] = ""
# Registry settings
reg_host = self.knoe_cfg_data.get("Docker Build", {}).get("LOCAL_REGISTRY")
if reg_host:
env["LOCAL_REGISTRY"] = reg_host
reg_internal = self.knoe_cfg_data.get("Docker Build", {}).get(
"LOCAL_REGISTRY_INTERNAL"
)
if reg_internal:
env["LOCAL_REGISTRY_INTERNAL"] = reg_internal
# Artifact Registry is required by CNPG manifests in k8s mode.
artifact_registry = str(env.get("ARTIFACT_REGISTRY") or "").strip().strip('"')
if not artifact_registry:
artifact_registry = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("ARTIFACT_REGISTRY", "")
.strip()
.strip('"')
)
if not artifact_registry:
gcp_cfg = self.knoe_cfg_data.get("GCP", {}) or {}
project_id = (
(self._get_input("init_cluster.project_id", "") or "").strip()
or str(gcp_cfg.get("project_id") or "").strip().strip('"')
or str(gcp_cfg.get("PROJECT_ID") or "").strip().strip('"')
or (os.environ.get("GCP_PROJECT") or "").strip()
)
region = (
(self._get_input("init_cluster.db_cluster_region", "") or "").strip()
or (self._get_input("init_cluster.app_cluster_region", "") or "").strip()
or str(gcp_cfg.get("region") or "").strip().strip('"')
or str(gcp_cfg.get("REGION") or "").strip().strip('"')
or str(gcp_cfg.get("location") or "").strip().strip('"')
or (os.environ.get("GCP_REGION") or "").strip()
)
repo = (service_ns or "knoe-system").strip() or "knoe-system"
if project_id and region:
artifact_registry = f"{region}-docker.pkg.dev/{project_id}/{repo}"
if artifact_registry:
env["ARTIFACT_REGISTRY"] = artifact_registry.rstrip("/")
try:
placement_plan, placement_path = self._resolve_cnpg_placement_plan(namespace)
eligible_nodes = placement_plan.get("eligible_nodes") or []
assignments = placement_plan.get("assignments") or {}
assignment_pairs = []
for ordinal in sorted(assignments.keys(), key=lambda k: int(str(k))):
assignment_pairs.append(f"{ordinal}:{assignments[ordinal]}")
env["CNPG_PLACEMENT_PLAN_FILE"] = str(placement_path)
env["CNPG_PLACEMENT_PLAN_ID"] = str(placement_plan.get("plan_id") or "")
env["CNPG_PLACEMENT_PLAN_HASH"] = str(
placement_plan.get("plan_hash") or ""
)
env["CNPG_PLACEMENT_ELIGIBLE_NODES"] = ",".join(eligible_nodes)
env["CNPG_PLACEMENT_ASSIGNMENTS"] = ",".join(assignment_pairs)
env["CNPG_PLACEMENT_REUSED"] = _bool_str(
bool((placement_plan.get("metadata") or {}).get("reused", False))
)
env.setdefault(
"CNPG_CLUSTER_NAME",
str(placement_plan.get("cluster_name") or "knoe-db"),
)
env.setdefault(
"CNPG_INSTANCES",
str(placement_plan.get("desired_instances") or "3"),
)
# Compatibility bridge for existing shell topology flow.
if eligible_nodes:
env.setdefault("CNPG_STAGE1_NODE", str(eligible_nodes[0]))
if len(eligible_nodes) == 1:
env.setdefault(
"CNPG_DB_NODE_SELECTOR",
f"kubernetes.io/hostname={eligible_nodes[0]}",
)
except Exception as e:
self.err(f"[WARN] CNPG placement planning failed; using fallback topology: {e}")
# Only apply dual-cluster GKE targeting in k8s/prod mode.
if mode == "k8s":
return build_kubectl_env_for_cluster(
base_env=env,
kubecontext=self._cluster_kubecontext(cluster_role),
cluster_name=cluster_name,
cluster_role=cluster_role,
)
return env
def _cnpg_cluster_name(self) -> str:
glob = self.knoe_cfg_data.get("Global", {}) or {}
cluster_name = (
os.environ.get("CLUSTER_NAME")
or os.environ.get("CNPG_CLUSTER_NAME")
or self._get_input("init_password.db_cluster_name", "")
or self._get_input("env_setup.DB_CLUSTER_NAME", "")
or str(glob.get("CLUSTER_NAME") or "")
or str(glob.get("CNPG_CLUSTER_NAME") or "")
).strip()
return cluster_name or "knoe-db"
def _app_cluster_name(self) -> str:
glob = self.knoe_cfg_data.get("Global", {}) or {}
name = (
self._get_input("init_password.app_cluster_name", "")
or self._get_input("env_setup.APP_CLUSTER_NAME", "")
or str(glob.get("APP_CLUSTER_NAME") or "")
or DEFAULT_APP_CLUSTER_NAME
)
return str(name).strip() or DEFAULT_APP_CLUSTER_NAME
def _app_cluster_mode(self) -> str:
glob = self.knoe_cfg_data.get("Global", {}) or {}
mode = (
self._get_input("init_cluster.app_cluster_mode", "")
or self._get_input("env_setup.APP_CLUSTER_MODE", "")
or str(glob.get("APP_CLUSTER_MODE") or "")
or DEFAULT_APP_CLUSTER_MODE
)
return str(mode).strip() or DEFAULT_APP_CLUSTER_MODE
def _db_cluster_mode(self) -> str:
glob = self.knoe_cfg_data.get("Global", {}) or {}
mode = (
self._get_input("init_cluster.db_cluster_mode", "")
or self._get_input("env_setup.DB_CLUSTER_MODE", "")
or str(glob.get("DB_CLUSTER_MODE") or "")
or DEFAULT_DB_CLUSTER_MODE
)
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"
else "init_cluster.db_cluster_kubecontext"
)
fallback_key = (
"env_setup.APP_CLUSTER_KUBECONTEXT"
if role == "app"
else "env_setup.DB_CLUSTER_KUBECONTEXT"
)
context = _safe_str(self._get_input(key, "") or self._get_input(fallback_key, ""))
glob = self.knoe_cfg_data.get("Global", {}) or {}
global_key = "APP_CLUSTER_KUBECONTEXT" if role == "app" else "DB_CLUSTER_KUBECONTEXT"
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 context:
return context
# Fall through to derive context from cluster name + region below.
if context:
return context
selected_context = _safe_str(
self._get_input("init_cluster.selected_kubectx", "") or glob.get("KUBECONTEXT", "")
)
if selected_context and not selected_context.startswith("gke_"):
return selected_context
if role == "app":
cluster_name = self._app_cluster_name()
region = _safe_str(
self._get_input("init_cluster.app_cluster_region", "")
or self._get_input("env_setup.APP_CLUSTER_REGION", "")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("region")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("REGION")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("location")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("LOCATION")
)
else:
cluster_name = _safe_str(
self._get_input("init_password.db_cluster_name", "")
or self._get_input("env_setup.DB_CLUSTER_NAME", "")
or glob.get("DB_CLUSTER_NAME", "")
or glob.get("CNPG_CLUSTER_NAME", "")
or DEFAULT_DB_CLUSTER_NAME
) or DEFAULT_DB_CLUSTER_NAME
region = _safe_str(
self._get_input("init_cluster.db_cluster_region", "")
or self._get_input("env_setup.DB_CLUSTER_REGION", "")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("region")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("REGION")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("location")
or (self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}).get("LOCATION")
)
gcp_cfg = self.gcp_cfg if isinstance(getattr(self, "gcp_cfg", None), dict) else {}
project_id = _safe_str(
self._get_input("init_cluster.project_id", "")
or gcp_cfg.get("project_id")
or gcp_cfg.get("PROJECT_ID")
)
if not region:
region = _safe_str(
gcp_cfg.get("region")
or gcp_cfg.get("REGION")
or gcp_cfg.get("location")
or gcp_cfg.get("LOCATION")
)
if project_id and region:
return f"gke_{project_id}_{region}_{cluster_name}"
parts = selected_context.split("_", 3)
if len(parts) == 4:
return f"{parts[0]}_{parts[1]}_{parts[2]}_{cluster_name}"
return selected_context
def _cnpg_desired_instances(self) -> int:
glob = self.knoe_cfg_data.get("Global", {}) or {}
raw = (os.environ.get("CNPG_INSTANCES") or str(glob.get("CNPG_INSTANCES") or "")).strip()
try:
val = int(raw) if raw else 3
except Exception:
val = 3
return max(1, val)
def _cnpg_rebalance_requested(self) -> bool:
glob = self.knoe_cfg_data.get("Global", {}) or {}
raw = (
os.environ.get("CNPG_REBALANCE")
or str(glob.get("CNPG_REBALANCE") or "")
)
return _parse_bool(raw, default=False)
def _cnpg_candidate_nodes(self) -> list[str]:
glob = self.knoe_cfg_data.get("Global", {}) or {}
nodes: set[str] = set()
explicit = (
os.environ.get("CNPG_ELIGIBLE_NODES")
or str(glob.get("CNPG_ELIGIBLE_NODES") or "")
)
if explicit:
for part in explicit.split(","):
node = str(part or "").strip()
if node:
nodes.add(node)
mounts_raw = str(
(self.knoe_cfg_data.get("Storage", {}) or {}).get("ANSIBLE_ISCSI_MOUNTS", "")
).strip()
if mounts_raw:
try:
mounts = json.loads(mounts_raw)
except Exception:
mounts = {}
if isinstance(mounts, dict):
for details in mounts.values():
if not isinstance(details, dict):
continue
node = str(details.get("host") or "").strip()
if node:
nodes.add(node)
stage1 = str(glob.get("CNPG_STAGE1_NODE") or "").strip()
if stage1:
nodes.add(stage1)
return sorted(nodes)
def _cnpg_plan_path(self, namespace: str, cluster_name: str) -> Path:
conf_root = self._get_input(
"env_setup.KNOE_CONF",
str((getattr(self, "project_root", None) or PROJECT_ROOT) / "conf"),
)
conf_dir = Path(conf_root)
def _safe_segment(raw: str) -> str:
s = str(raw or "").strip()
if not s:
return "default"
return "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in s)
ns_seg = _safe_segment(namespace)
cluster_seg = _safe_segment(cluster_name)
return conf_dir / "cnpg-placement" / f"{ns_seg}-{cluster_seg}.json"
def _resolve_cnpg_placement_plan(self, namespace: str) -> tuple[dict, Path]:
cluster_name = self._cnpg_cluster_name()
desired_instances = self._cnpg_desired_instances()
candidate_nodes = self._cnpg_candidate_nodes()
rebalance = self._cnpg_rebalance_requested()
plan_path = self._cnpg_plan_path(namespace, cluster_name)
prior_plan = load_cnpg_placement_plan(plan_path)
placement_plan = plan_cnpg_placement(
cluster_name=cluster_name,
desired_instances=desired_instances,
candidate_nodes=candidate_nodes,
prior_plan=prior_plan,
rebalance=rebalance,
)
if not isinstance(prior_plan, dict) or prior_plan != placement_plan:
save_cnpg_placement_plan(plan_path, placement_plan)
glob = self.knoe_cfg_data.setdefault("Global", {})
glob["CNPG_PLACEMENT_PLAN_FILE"] = str(plan_path)
glob["CNPG_PLACEMENT_PLAN_ID"] = str(placement_plan.get("plan_id") or "")
glob["CNPG_PLACEMENT_PLAN_HASH"] = str(placement_plan.get("plan_hash") or "")
return placement_plan, plan_path
def _cnpg_storage_node(self, env: dict[str, str]) -> str:
stage1 = str(env.get("CNPG_STAGE1_NODE") or "").strip()
if stage1:
return stage1
eligible_raw = str(env.get("CNPG_PLACEMENT_ELIGIBLE_NODES") or "").strip()
if eligible_raw:
for part in eligible_raw.split(","):
node = str(part or "").strip()
if node:
return node
candidates = self._cnpg_candidate_nodes()
if candidates:
return candidates[0]
raise StorageProvisioningError(
"Unable to determine CNPG node for local PV provisioning. "
"Expected CNPG placement env (CNPG_STAGE1_NODE/CNPG_PLACEMENT_ELIGIBLE_NODES) "
"or configured CNPG candidate nodes."
)
def _ensure_cnpg_storage_provisioned(self, namespace: str, env: dict[str, str]) -> None:
if self._deployment_mode() != "k3s":
return
cluster_name = str(
env.get("CLUSTER_NAME")
or env.get("CNPG_CLUSTER_NAME")
or self._cnpg_cluster_name()
).strip()
if not cluster_name:
cluster_name = "knoe-db"
node_name = self._cnpg_storage_node(env)
spec = ClusterStorageSpec(
namespace=namespace,
cluster_name=cluster_name,
node_name=node_name,
storage_class_name="synology-iscsi",
command_env=env,
)
provisioned = provision_cluster_storage(spec)
env["CNPG_STORAGE_CLASS"] = spec.storage_class_name
env["CNPG_DATA_SELECTOR_JSON"] = json.dumps(
provisioned.data_selector,
separators=(",", ":"),
sort_keys=True,
)
env["CNPG_WAL_SELECTOR_JSON"] = json.dumps(
provisioned.wal_selector,
separators=(",", ":"),
sort_keys=True,
)
env["CNPG_DATA_PV_NAME"] = provisioned.data_pv_name
env["CNPG_WAL_PV_NAME"] = provisioned.wal_pv_name
env["CNPG_DATA_HOST_PATH"] = provisioned.data_path
env["CNPG_WAL_HOST_PATH"] = provisioned.wal_path
env["SYNOLOGY_ROOTS"] = ",".join(spec.synology_roots)
self.log(
"[CNPG] Provisioned cluster storage "
f"namespace={namespace} cluster={cluster_name} node={node_name} "
f"data_pv={provisioned.data_pv_name} wal_pv={provisioned.wal_pv_name} "
f"data_path={provisioned.data_path} wal_path={provisioned.wal_path}"
)
def _cluster_storage_milestone_enabled(self) -> bool:
raw = str(
os.environ.get("PROLE_ENABLE_CLUSTER_STORAGE_MILESTONE", "")
).strip().lower()
return raw in {"1", "true", "yes", "on"}
# --------------------------------------------- authority / repair
def _authority_context_missing(self) -> bool:
enabled = self._get_input_bool(
"kerberos_config.enabled", False
) or self._get_input_bool("init_cluster.kerberos_enabled", False)
if not enabled:
return False
candidates = []
env_home = (os.environ.get("KNOE_HOME") or "").strip()
if env_home:
candidates.append(Path(env_home))
candidates.append(self.project_root)
for base in candidates:
try:
if (base / "authority").is_dir():
return False
if (base / "knoe" / "authority").is_dir():
return False
except Exception:
continue
return True
def _dashboard_kong_missing(self, env: dict) -> bool:
ns = "kubernetes-dashboard"
try:
res_ns = subprocess.run(
["kubectl", "get", "ns", ns, "-o", "name"],
env=env,
capture_output=True,
text=True,
timeout=6,
)
if res_ns.returncode != 0 or ns not in (res_ns.stdout or ""):
return True
res = subprocess.run(
["kubectl", "-n", ns, "get", "pods", "--no-headers"],
env=env,
capture_output=True,
text=True,
timeout=6,
)
if res.returncode != 0:
return True
kong_lines = [
l for l in (res.stdout or "").splitlines() if "kong" in l.lower()
]
if not kong_lines:
return True
for line in kong_lines:
parts = line.split()
if len(parts) < 3:
return True
if parts[2] != "Running":
return True
return False
except Exception:
return True
def _parse_k8s_quantity_bytes(self, raw: str | None) -> int | None:
"""Parse a Kubernetes resource quantity (e.g. 10Gi, 500Mi) into bytes.
Conservative: returns None when parsing fails.
"""
s = (raw or "").strip()
if not s:
return None
# Common forms: 10Gi, 500Mi, 1Ti, 1000, 1.5Gi
num = ""
suf = ""
for ch in s:
if (ch.isdigit() or ch in (".", "+", "-")) and not suf:
num += ch
else:
suf += ch
try:
value = float(num)
except Exception:
return None
suf = suf.strip()
if not suf:
try:
return int(value)
except Exception:
return None
binary = {
"Ki": 1024,
"Mi": 1024**2,
"Gi": 1024**3,
"Ti": 1024**4,
"Pi": 1024**5,
"Ei": 1024**6,
}
decimal = {
"K": 1000,
"M": 1000**2,
"G": 1000**3,
"T": 1000**4,
"P": 1000**5,
"E": 1000**6,
}
if suf in binary:
return int(value * binary[suf])
if suf in decimal:
return int(value * decimal[suf])
# Be conservative: unknown suffix (e.g. m) => do not parse.
return None
def _kubectl_get_json(self, env: dict, args: list[str]) -> dict | None:
try:
res = subprocess.run(
["kubectl"] + list(args),
env=env,
capture_output=True,
text=True,
timeout=30,
)
if res.returncode != 0:
err = (res.stderr or "").strip() or (res.stdout or "").strip()
if err:
self.err(f"[WARN] kubectl {' '.join(args)} failed: {err}")
return None
out = (res.stdout or "").strip()
if not out:
return None
return json.loads(out)
except Exception as e:
self.err(f"[WARN] kubectl {' '.join(args)} failed: {e}")
return None
def _kubectl_get_namespace_json(self, env: dict, name: str) -> dict | None:
n = (name or "").strip()
if not n:
return None
return self._kubectl_get_json(env, ["get", "namespace", n, "-o", "json"])
def _namespace_is_terminating(self, ns: dict | None) -> bool:
if not ns:
return False
md = ns.get("metadata") or {}
if (md.get("deletionTimestamp") or "").strip():
return True
phase = ((ns.get("status") or {}).get("phase") or "").strip()
return phase == "Terminating"
# ------------------------------------------ blocked cluster reconciliation
def _classify_blocked_pod(self, env: dict, pod: dict) -> dict:
"""Classify common pod blockers into stable, testable categories.
Returns a dict with:
- `type`: primary classification (storage-first)
- `types`: list of all detected classification tags
"""
_ = env # reserved for future use
meta = pod.get("metadata") or {}
status = pod.get("status") or {}
name = (meta.get("name") or "").strip()
ns = (meta.get("namespace") or "").strip()
phase = (status.get("phase") or "").strip()
types: list[str] = []
# ContainerCreating / ImagePullBackOff etc.
for cs in status.get("containerStatuses") or []:
waiting = ((cs.get("state") or {}).get("waiting") or {})
reason = (waiting.get("reason") or "").strip()
if reason == "ContainerCreating":
types.append("container_creating")
elif reason:
types.append(f"container_waiting_{reason.lower()}")
# Unschedulable reasons.
for cond in status.get("conditions") or []:
try:
if (cond.get("type") or "").strip() != "PodScheduled":
continue
if (cond.get("status") or "").strip() != "False":
continue
if (cond.get("reason") or "").strip() != "Unschedulable":
continue
msg = ((cond.get("message") or "").strip() or "").lower()
if "persistent volumes" in msg and "bind" in msg:
types.append("unschedulable_due_to_pv_binding")
if "didn't match pod's node affinity" in msg or "node affinity/selector" in msg:
types.append("unschedulable_due_to_node_affinity")
if "had untolerated taint" in msg:
types.append("unschedulable_due_to_untolerated_taint")
except Exception:
continue
# Primary type: prefer storage/root-cause categories.
primary = "ok"
for preferred in (
"unschedulable_due_to_pv_binding",
"unschedulable_due_to_node_affinity",
"unschedulable_due_to_untolerated_taint",
"container_creating",
):
if preferred in types:
primary = preferred
break
if primary == "ok" and phase in ("Pending", "Unknown") and types:
primary = types[0]
# Dedupe while keeping order.
seen: set[str] = set()
deduped: list[str] = []
for t in types:
if t not in seen:
seen.add(t)
deduped.append(t)
return {
"pod": f"{ns}/{name}" if ns and name else (name or ns or "<unknown>"),
"namespace": ns,
"name": name,
"phase": phase,
"type": primary,
"types": deduped,
}
def _reconcile_blocked_cluster_state(self, env: dict, service_ns: str) -> None:
"""Detect hard cluster blockers and raise before running deployment scripts.
This is intentionally conservative: it attempts safe PV claimRef repair
and then surfaces clear diagnostics for scheduling/storage blockers.
"""
svc_ns = (service_ns or "").strip() or self._service_namespace()
# Always attempt safe stale Released PV claimRef repair first.
try:
self._repair_stale_released_pvs(env, reset=False)
except Exception as e:
self.err(f"[WARN] Stale PV claimRef repair failed: {e}")
pods_data = self._kubectl_get_json(env, ["get", "pod", "-A", "-o", "json"]) or {}
pods = pods_data.get("items") or []
classified = [self._classify_blocked_pod(env, p) for p in pods]
blocked = [c for c in classified if (c.get("type") or "") != "ok"]
# If we can't positively identify blocked pods, do not block deployment.
# This keeps behavior conservative when `kubectl` is unavailable.
if not blocked:
return
# Downstream missingness: surface but treat separately.
missing: list[str] = []
for kind in ("svc", "deploy"):
try:
res = subprocess.run(
["kubectl", "-n", svc_ns, "get", kind, "knoe-svc-kong"],
env=env,
capture_output=True,
text=True,
timeout=10,
)
if res.returncode != 0:
missing.append(f"{kind}/knoe-svc-kong")
except FileNotFoundError:
# If kubectl isn't available, don't guess missing resources.
pass
except Exception:
pass
all_types: set[str] = set()
for b in blocked:
for t in b.get("types") or []:
all_types.add(t)
# Group summary for easier scanning.
storage_types = [t for t in sorted(all_types) if "pv_binding" in t]
sched_types = [
t
for t in sorted(all_types)
if "node_affinity" in t or "untolerated_taint" in t
]
container_types = [t for t in sorted(all_types) if t.startswith("container_")]
lines: list[str] = ["Cluster prerequisites are blocked"]
if storage_types or any("pv_binding" in (b.get("type") or "") for b in blocked):
lines.append("\nStorage blockers:")
for t in storage_types or ["unschedulable_due_to_pv_binding"]:
lines.append(f"- {t}")
if sched_types:
lines.append("\nScheduling blockers:")
for t in sched_types:
lines.append(f"- {t}")
if container_types:
lines.append("\nContainer blockers:")
for t in container_types:
lines.append(f"- {t}")
if blocked:
lines.append("\nBlocked pods:")
for b in blocked:
lines.append(
f"- {b.get('pod')} type={b.get('type')} types={','.join(b.get('types') or [])}"
)
if missing:
lines.append("\nDownstream missing resources:")
for m in sorted(set(missing)):
lines.append(f"- {m}")
raise Exception("\n".join(lines))
def _ensure_namespace_ready(self, env: dict, name: str, timeout_s: int = 90) -> None:
"""Ensure a namespace is usable for deployments.
If the namespace is in `Terminating`, wait for deletion to complete.
If it disappears, recreate it. If it remains terminating after the timeout,
raise with an actionable message.
"""
n = (name or "").strip()
if not n:
return
ns = self._kubectl_get_namespace_json(env, n)
if self._namespace_is_terminating(ns):
self.err(
f"[WARN] Namespace '{n}' is terminating; waiting up to {timeout_s}s for deletion to complete..."
)
deadline = time.time() + max(0, int(timeout_s))
while time.time() < deadline:
time.sleep(3)
ns = self._kubectl_get_namespace_json(env, n)
if not ns:
break
if not self._namespace_is_terminating(ns):
break
ns = self._kubectl_get_namespace_json(env, n)
if self._namespace_is_terminating(ns):
raise Exception(
f"Namespace '{n}' is still terminating. "
f"Wait for it to finish deleting (kubectl get namespace {n}) or force-delete it, "
f"then retry the installer."
)
# Create namespace if missing.
if not self._kubectl_get_namespace_json(env, n):
self.log(f"[INFO] Creating namespace '{n}' ...")
rc = self._run_cmd(["kubectl", "create", "namespace", n], env=env)
if rc != 0:
# Be idempotent: a concurrent creator may have raced us.
self.err(f"[WARN] Failed to create namespace '{n}' (code {rc}); continuing")
def _list_pending_pvcs(
self, env: dict, namespaces: list[str] | None = None
) -> list[dict]:
data = self._kubectl_get_json(env, ["get", "pvc", "-A", "-o", "json"])
items = (data or {}).get("items") or []
ns_filter = {n.strip() for n in (namespaces or []) if (n or "").strip()}
out: list[dict] = []
for pvc in items:
try:
phase = ((pvc.get("status") or {}).get("phase") or "").strip()
if phase != "Pending":
continue
ns = ((pvc.get("metadata") or {}).get("namespace") or "").strip()
if ns_filter and ns not in ns_filter:
continue
out.append(pvc)
except Exception:
continue
return out
def _list_released_pvs(self, env: dict, storage_class: str | None = None) -> list[dict]:
data = self._kubectl_get_json(env, ["get", "pv", "-o", "json"])
items = (data or {}).get("items") or []
sc = (storage_class or "").strip()
out: list[dict] = []
for pv in items:
try:
phase = ((pv.get("status") or {}).get("phase") or "").strip()
if phase != "Released":
continue
pv_sc = ((pv.get("spec") or {}).get("storageClassName") or "").strip()
if sc and pv_sc != sc:
continue
out.append(pv)
except Exception:
continue
return out
def _match_stale_released_pv(self, pvc: dict, pvs: list[dict]) -> dict | None:
pvc_meta = pvc.get("metadata") or {}
pvc_spec = pvc.get("spec") or {}
pvc_name = (pvc_meta.get("name") or "").strip()
pvc_ns = (pvc_meta.get("namespace") or "").strip()
pvc_uid = (pvc_meta.get("uid") or "").strip()
pvc_sc = (pvc_spec.get("storageClassName") or "").strip()
pvc_volume_name = (pvc_spec.get("volumeName") or "").strip()
pvc_modes = pvc_spec.get("accessModes") or []
pvc_mode = (pvc_spec.get("volumeMode") or "").strip()
req_storage = (
(((pvc_spec.get("resources") or {}).get("requests") or {}).get("storage") or "").strip()
)
req_bytes = self._parse_k8s_quantity_bytes(req_storage)
if not pvc_name or not pvc_ns:
return None
candidates: list[tuple[int, dict]] = []
for pv in pvs:
pv_meta = pv.get("metadata") or {}
pv_spec = pv.get("spec") or {}
pv_status = pv.get("status") or {}
pv_name = (pv_meta.get("name") or "").strip()
if not pv_name:
continue
if (pv_status.get("phase") or "").strip() != "Released":
continue
if pvc_volume_name and pvc_volume_name != pv_name:
continue
pv_sc = (pv_spec.get("storageClassName") or "").strip()
if pvc_sc and pv_sc and pvc_sc != pv_sc:
continue
if pvc_sc and not pv_sc:
continue
claim_ref = pv_spec.get("claimRef") or {}
if not claim_ref:
continue
if (claim_ref.get("name") or "").strip() != pvc_name:
continue
if (claim_ref.get("namespace") or "").strip() != pvc_ns:
continue
claim_uid = (claim_ref.get("uid") or "").strip()
if pvc_uid and claim_uid and pvc_uid == claim_uid:
# Too risky: PV believes it is still claimed by this exact PVC.
continue
pv_modes = pv_spec.get("accessModes") or []
if pvc_modes:
if not pv_modes:
continue
if not set(pvc_modes).issubset(set(pv_modes)):
continue
pv_mode = (pv_spec.get("volumeMode") or "").strip()
if pvc_mode and pv_mode and pvc_mode != pv_mode:
continue
cap_storage = (((pv_spec.get("capacity") or {}).get("storage") or "").strip())
cap_bytes = self._parse_k8s_quantity_bytes(cap_storage)
if req_bytes is not None and cap_bytes is not None:
if req_bytes > cap_bytes:
continue
else:
# If we can't compare sizes, be conservative.
continue
candidates.append((cap_bytes or 0, pv))
if not candidates:
return None
candidates.sort(key=lambda t: t[0])
return candidates[0][1]
def _clear_pv_claim_ref(self, env: dict, pv_name: str) -> bool:
pv_name = (pv_name or "").strip()
if not pv_name:
return False
patch = '{"spec":{"claimRef":null}}'
try:
res = subprocess.run(
[
"kubectl",
"patch",
"pv",
pv_name,
"--type",
"merge",
"-p",
patch,
],
env=env,
capture_output=True,
text=True,
timeout=20,
)
if res.returncode == 0:
return True
err = (res.stderr or "").strip() or (res.stdout or "").strip()
if err:
self.err(f"[WARN] Failed to patch pv/{pv_name} claimRef: {err}")
return False
except Exception as e:
self.err(f"[WARN] Failed to patch pv/{pv_name} claimRef: {e}")
return False
def _repair_stale_released_pvs(self, env: dict, reset: bool = False) -> bool:
"""Repair stale `Released` PVs that retain an old `spec.claimRef`.
Policy:
- Normal repair is conservative.
- When `reset=True`, we may proactively reclaim safe, strongly-matched PVs by
clearing `spec.claimRef`.
- Reset must NEVER delete PVs.
"""
service_ns = (env.get("SERVICE_NAMESPACE") or "").strip() or self._service_namespace()
db_ns = (
(env.get("DB_NAMESPACE") or "").strip()
or (self._get_input("init_password.db_namespace", "") or "").strip()
or service_ns
)
namespaces = sorted(
{
n
for n in (
service_ns,
db_ns,
(env.get("NAMESPACE") or "").strip(),
)
if (n or "").strip()
}
)
pending = self._list_pending_pvcs(env, namespaces=namespaces)
if not pending:
return False
# Pull all Released PVs once; matching enforces storageClass/claimRef checks.
released = self._list_released_pvs(env)
if not released:
return False
changed = False
for pvc in pending:
meta = pvc.get("metadata") or {}
spec = pvc.get("spec") or {}
pvc_name = (meta.get("name") or "").strip()
pvc_ns = (meta.get("namespace") or "").strip()
pvc_sc = (spec.get("storageClassName") or "").strip()
req = (((spec.get("resources") or {}).get("requests") or {}).get("storage") or "").strip()
match = self._match_stale_released_pv(pvc, released)
if not match:
continue
pv_name = ((match.get("metadata") or {}).get("name") or "").strip()
pv_cap = (((match.get("spec") or {}).get("capacity") or {}).get("storage") or "").strip()
claim_ref = (match.get("spec") or {}).get("claimRef") or {}
claim_uid = (claim_ref.get("uid") or "").strip()
prefix = "[RESET]" if reset else "[INFO]"
self.log(
f"{prefix} Detected pending PVC with matching Released PV claimRef; clearing claimRef "
f"pvc/{pvc_ns}/{pvc_name} storageClass={pvc_sc or '<unset>'} request={req or '<unset>'} "
f"-> pv/{pv_name} capacity={pv_cap or '<unset>'} claimRef.uid={claim_uid or '<unset>'}"
)
if self._clear_pv_claim_ref(env, pv_name):
self.log(f"[OK] Cleared claimRef on pv/{pv_name}")
changed = True
else:
self.err(f"[WARN] Failed to clear claimRef on pv/{pv_name}")
return changed
def _reset_reclaim_stale_pvs(self, env: dict) -> bool:
"""Reset/reconcile hook to reclaim safe stale PVs.
Reset may reclaim stale `Released` PVs by clearing `spec.claimRef`.
Reset must NEVER delete PVs.
"""
return self._repair_stale_released_pvs(env, reset=True)
def _run_cluster_repair_pipeline(self, env: dict, anomalies: list[str]) -> None:
"""Python-native cluster repair orchestration.
This mirrors the intent of `etc/repair_pipeline.sh` while delegating
actual component deployments to existing `etc/init_*.sh` scripts.
"""
mode = (env.get("KNOE_MODE") or env.get("DEPLOYMENT_MODE") or "").strip()
service_ns = (env.get("SERVICE_NAMESPACE") or "").strip() or self._service_namespace()
db_ns = (
(env.get("DB_NAMESPACE") or "").strip()
or (self._get_input("init_password.db_namespace", "") or "").strip()
or service_ns
)
argocd_ns = (env.get("ARGOCD_NAMESPACE") or "").strip() or self._argocd_namespace()
registry_ns = (env.get("REGISTRY_NAMESPACE") or "").strip() or self._registry_namespace()
def _kubectl(args: list[str], timeout: int = 30) -> subprocess.CompletedProcess:
return subprocess.run(
["kubectl"] + args,
env=env,
capture_output=True,
text=True,
timeout=timeout,
)
self.log(f"[INFO] Cluster repair starting (mode={mode or '<unset>'})")
# Basic reachability check (non-fatal; keeps behavior conservative).
try:
res = _kubectl(["cluster-info"], timeout=15)
if res.returncode != 0:
self.err(
f"[WARN] kubectl cluster-info failed: {((res.stderr or '') or (res.stdout or '')).strip()}"
)
except Exception as e:
self.err(f"[WARN] kubectl cluster-info failed: {e}")
# --- Dashboard repair (Helm-based)
if "dashboard" in (anomalies or []):
try:
self.log("[INFO] Repairing kubernetes-dashboard...")
# Try to create namespace (idempotent)
_kubectl(["create", "ns", "kubernetes-dashboard"], timeout=20)
# Helm install/upgrade as in the legacy pipeline
subprocess.run(
["helm", "repo", "add", "kubernetes-dashboard", "https://kubernetes.github.io/dashboard/"],
env=env,
capture_output=True,
text=True,
timeout=60,
)
subprocess.run(
["helm", "repo", "update"],
env=env,
capture_output=True,
text=True,
timeout=120,
)
subprocess.run(
[
"helm",
"upgrade",
"--install",
"kubernetes-dashboard",
"kubernetes-dashboard/kubernetes-dashboard",
"--namespace",
"kubernetes-dashboard",
"--create-namespace",
"--set",
"kong.enabled=true",
"--set",
"nginx.enabled=false",
],
env=env,
capture_output=True,
text=True,
timeout=300,
)
# Restart pods (idempotent, best-effort)
_kubectl(["-n", "kubernetes-dashboard", "delete", "pods", "-l", "app.kubernetes.io/name=kubernetes-dashboard"], timeout=60)
_kubectl(["-n", "kubernetes-dashboard", "delete", "pods", "-l", "app.kubernetes.io/name=kong"], timeout=60)
except FileNotFoundError:
self.err("[WARN] helm not found; skipping dashboard Helm repair")
except Exception as e:
self.err(f"[WARN] Dashboard repair failed: {e}")
# --- k3s legacy default namespace dedupe
try:
if mode == "k3s" and service_ns != "default":
res_legacy = _kubectl(["get", "ns", "default", "-o", "name"], timeout=10)
if res_legacy.returncode == 0 and "default" in (res_legacy.stdout or ""):
legacy_ns = "default"
def _exists(kind: str, name: str, ns: str) -> bool:
r = _kubectl(["-n", ns, "get", kind, name, "-o", "name"], timeout=10)
return r.returncode == 0
def _delete_kind(kind: str, name: str, ns: str):
_kubectl(["-n", ns, "delete", kind, name, "--ignore-not-found=true"], timeout=60)
legacy_openbao = _exists("deploy", "openbao", legacy_ns) or _exists(
"statefulset", "openbao", legacy_ns
)
legacy_opentofu = _exists("deploy", "opentofu", legacy_ns)
legacy_garage = _exists("statefulset", "garage", legacy_ns)
legacy_registry = _exists("deploy", "registry", legacy_ns)
legacy_kong = _exists("deploy", "knoe-svc-kong", legacy_ns) or _exists(
"svc", "knoe-svc-kong", legacy_ns
)
missing_openbao = not (
_exists("deploy", "openbao", service_ns)
or _exists("statefulset", "openbao", service_ns)
)
missing_opentofu = not _exists("deploy", "opentofu", service_ns)
missing_garage = not _exists("statefulset", "garage", service_ns)
missing_registry = not _exists("deploy", "registry", registry_ns)
if legacy_registry and missing_registry:
self.log(
f"[INFO] Legacy registry in {legacy_ns}; redeploying to {registry_ns}"
)
registry_ops.update(namespace=registry_ns, env=env, mode=mode, log=self.log)
_delete_kind("deploy", "registry", legacy_ns)
if legacy_openbao and missing_openbao:
self.log(
f"[INFO] Legacy OpenBao in {legacy_ns}; redeploying to {service_ns}"
)
openbao_ops.update(namespace=service_ns, env=env, mode=mode, log=self.log)
_delete_kind("deploy", "openbao", legacy_ns)
_delete_kind("statefulset", "openbao", legacy_ns)
_delete_kind("pvc", "data-openbao-0", legacy_ns)
if legacy_opentofu and missing_opentofu:
self.log(
f"[INFO] Legacy OpenTofu in {legacy_ns}; redeploying to {service_ns}"
)
opentofu_ops.update(namespace=service_ns, env=env, mode=mode, log=self.log)
_delete_kind("deploy", "opentofu", legacy_ns)
if legacy_garage and missing_garage:
self.log(
f"[INFO] Legacy Garage in {legacy_ns}; redeploying to {service_ns}"
)
garage_store_ops.update(
namespace=service_ns,
env=env,
mode=mode,
log=self.log,
)
_delete_kind("statefulset", "garage", legacy_ns)
_delete_kind("pvc", "meta-garage-0", legacy_ns)
_delete_kind("pvc", "data-garage-0", legacy_ns)
if legacy_kong:
missing_kong = not (
_exists("deploy", "knoe-svc-kong", service_ns)
or _exists("svc", "knoe-svc-kong", service_ns)
)
if missing_kong:
self.log(
f"[INFO] Legacy Kong (knoe-svc-kong) in {legacy_ns}; redeploying to {service_ns}"
)
self._run_script(
"init_kong.sh",
args=["-n", service_ns, "update"],
env=env,
)
# Always remove legacy Kong to avoid namespace drift.
_kubectl(
[
"-n",
legacy_ns,
"delete",
"pod",
"-l",
"app=knoe-svc-kong",
"--ignore-not-found=true",
],
timeout=60,
)
_delete_kind("deploy", "knoe-svc-kong", legacy_ns)
_delete_kind("svc", "knoe-svc-kong", legacy_ns)
_delete_kind("configmap", "knoe-svc-kong-config", legacy_ns)
# Clean up prole-era Kong (named "prole-svc-kong") from both
# legacy_ns and service_ns — stale after the prole→knoe rebrand.
legacy_prole_kong = (
_exists("deploy", "prole-svc-kong", legacy_ns)
or _exists("svc", "prole-svc-kong", legacy_ns)
or _exists("deploy", "prole-svc-kong", service_ns)
or _exists("svc", "prole-svc-kong", service_ns)
)
if legacy_prole_kong:
self.log("[INFO] Cleaning up legacy prole-svc-kong resources")
for _ns in (legacy_ns, service_ns):
_kubectl(
[
"-n",
_ns,
"delete",
"pod",
"-l",
"app=prole-svc-kong",
"--ignore-not-found=true",
],
timeout=60,
)
_delete_kind("deploy", "prole-svc-kong", _ns)
_delete_kind("svc", "prole-svc-kong", _ns)
_delete_kind("configmap", "prole-svc-kong-config", _ns)
except Exception as e:
self.err(f"[WARN] Legacy namespace dedupe failed: {e}")
# --- ArgoCD repair (registry redeploy)
try:
argocd_apps = [
("deployment", "argocd-repo-server"),
("deployment", "argocd-application-controller"),
("statefulset", "argocd-redis"),
]
unhealthy = False
for kind, name in argocd_apps:
r = _kubectl(
[
"-n",
argocd_ns,
"get",
kind,
name,
"-o",
"jsonpath={.status.readyReplicas}",
],
timeout=10,
)
if r.returncode != 0:
unhealthy = True
break
try:
if int((r.stdout or "0").strip() or "0") < 1:
unhealthy = True
break
except Exception:
unhealthy = True
break
if unhealthy:
self.log("[WARN] ArgoCD is not healthy; redeploying registry...")
env2 = env.copy()
env2["REGISTRY_NAMESPACE"] = registry_ns
registry_ops.stop(namespace=registry_ns, env=env2, mode=mode, log=self.log)
registry_ops.update(namespace=registry_ns, env=env2, mode=mode, log=self.log)
except Exception as e:
self.err(f"[WARN] ArgoCD repair step failed: {e}")
# --- Service restarts in the service namespace
try:
# OpenBao
r = _kubectl(
[
"-n",
service_ns,
"get",
"statefulset",
"openbao",
"-o",
"jsonpath={.status.readyReplicas}",
],
timeout=10,
)
if r.returncode == 0:
if int((r.stdout or "0").strip() or "0") < 1:
self.log("[WARN] OpenBao is not ready; restarting...")
openbao_ops.restart(namespace=service_ns, env=env, log=self.log)
except Exception:
pass
try:
# OpenTofu
r = _kubectl(
[
"-n",
service_ns,
"get",
"deploy",
"opentofu",
"-o",
"jsonpath={.status.readyReplicas}",
],
timeout=10,
)
if r.returncode == 0:
if int((r.stdout or "0").strip() or "0") < 1:
self.log("[WARN] OpenTofu is not ready; restarting...")
opentofu_ops.restart(namespace=service_ns, env=env, log=self.log)
except Exception:
pass
try:
# Garage
r = _kubectl(
[
"-n",
service_ns,
"get",
"statefulset",
"garage",
"-o",
"jsonpath={.status.readyReplicas}",
],
timeout=10,
)
if r.returncode == 0:
if int((r.stdout or "0").strip() or "0") < 1:
self.log("[WARN] Garage is not ready; restarting...")
garage_store_ops.restart(namespace=service_ns, env=env, log=self.log)
except Exception:
pass
# --- Kerberos / KDC repair (gated)
try:
kerberos_enabled = self._get_input_bool(
"kerberos_config.enabled", False
) or self._get_input_bool("init_cluster.kerberos_enabled", False)
if kerberos_enabled:
auth_missing = self._authority_context_missing()
if not auth_missing:
# Only attempt KDC init when authority context exists.
env2 = env.copy()
env2["DB_NAMESPACE"] = db_ns
env2["SERVICE_NAMESPACE"] = service_ns
self._run_script(
"init_kdc.sh",
args=["-n", service_ns],
env=env2,
)
except Exception as e:
self.err(f"[WARN] KDC repair step failed: {e}")
# --- Barman plugin repair (best-effort)
try:
r = _kubectl(["-n", "cnpg-system", "get", "deploy", "barman-cloud"], timeout=10)
env2 = env.copy()
env2["DATABASE_NAMESPACE"] = db_ns
env2["CLUSTER_NAME"] = self._cnpg_cluster_name()
if r.returncode != 0:
cnpg_install_barman_plugin(env=env2, log=self.log)
else:
r2 = _kubectl(
[
"-n",
"cnpg-system",
"get",
"deploy",
"barman-cloud",
"-o",
"jsonpath={.status.readyReplicas}",
],
timeout=10,
)
if r2.returncode == 0:
try:
if int((r2.stdout or "0").strip() or "0") < 1:
cnpg_install_barman_plugin(env=env2, log=self.log)
_kubectl(["-n", "cnpg-system", "rollout", "restart", "deploy/barman-cloud"], timeout=60)
except Exception:
pass
except Exception:
pass
# --- CNPG rollout (best-effort)
try:
# Only attempt if a Cluster exists in the DB namespace.
r = _kubectl(
["-n", db_ns, "get", "clusters.postgresql.cnpg.io", "cluster", "-o", "name"],
timeout=10,
)
if r.returncode == 0 and (r.stdout or "").strip():
env2 = env.copy()
env2["DATABASE_NAMESPACE"] = db_ns
env2["CLUSTER_NAME"] = self._cnpg_cluster_name()
cnpg_rollout(
namespace=db_ns,
cluster_name=self._cnpg_cluster_name(),
env=env2,
log=self.log,
)
except Exception:
pass
# Final health/status report
try:
status_ok = {
"registry": registry_ops.status(
namespace=self._registry_namespace(),
env=env,
mode=env.get("KNOE_MODE") or self._deployment_mode(),
),
"openbao": openbao_ops.status(
namespace=service_ns,
env=env,
mode=env.get("KNOE_MODE") or self._deployment_mode(),
),
"opentofu": opentofu_ops.status(namespace=service_ns, env=env),
"garage": garage_store_ops.status(namespace=service_ns, env=env),
}
for name, ok in status_ok.items():
self.log(f"[STATUS] {name}: {'ready' if ok else 'not-ready'}")
except Exception:
pass
self.log("[OK] Cluster repair pipeline complete")
def _maybe_run_repair_pipeline(self, env: dict) -> None:
# Always attempt stale Released PV claimRef repair first.
try:
self._repair_stale_released_pvs(env)
except Exception as e:
self.err(f"[WARN] Stale PV claimRef repair failed: {e}")
# Best-effort namespace preflight before broader anomaly repairs.
try:
self._ensure_namespace_ready(env, self._service_namespace())
except Exception as e:
self.err(f"[WARN] Namespace preflight failed: {e}")
return
if self._repair_ran:
return
anomalies = []
if self._dashboard_kong_missing(env):
anomalies.append("dashboard")
if self._authority_context_missing():
anomalies.append("authority")
if not anomalies:
return
self._repair_ran = True
service_ns = self._service_namespace()
db_ns = (
self._get_input("init_password.db_namespace", "") or ""
).strip() or "default"
self.log(
f"[INFO] Detected anomalies ({', '.join(anomalies)}); running repair pipeline..."
)
self._run_cluster_repair_pipeline(env=env, anomalies=anomalies)
# ------------------------------------------ image helpers
def _collect_dependent_images(
self, include_supabase: bool, include_kerberos_proxy: bool
) -> list[str]:
images: set[str] = set()
for rel_dir in ("k8s/knoe", "k8s/openbao"):
base_dir = self.project_root / rel_dir
if not base_dir.exists():
continue
images.update(_collect_images_from_files(list(base_dir.glob("*.yaml"))))
if include_supabase:
supa_home = _resolve_supabase_home(self.project_root)
if supa_home:
docker_dir = supa_home / "docker"
compose_files = [docker_dir / "docker-compose.yml"]
if os.environ.get("SUPABASE_USE_DEV_COMPOSE") == "1":
compose_files.append(docker_dir / "dev" / "docker-compose.dev.yml")
images.update(_collect_images_from_files(compose_files))
if not include_supabase:
images = {img for img in images if "supabase" not in img}
if include_kerberos_proxy:
krb_img = os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
if krb_img:
images.add(krb_img)
return sorted(images)
def _get_docker_import_dir(self) -> str:
"""Best-effort accessor for a docker-import directory.
GUI uses a Tk variable with `.get()`, while console flows tend to store a
plain string. This helper normalizes both.
"""
val = getattr(self, "docker_import_dir", None)
if val is None:
return ""
try:
getter = getattr(val, "get", None)
if callable(getter):
val = getter()
except Exception:
pass
return (str(val) if val is not None else "").strip()
def _prepull_images_to_registry(
self,
include_supabase: bool,
include_kerberos_proxy: bool,
log: Callable[[str], None] | None = None,
) -> bool:
def _log(msg: str):
if log:
try:
log(msg)
except Exception:
pass
self.log(msg)
cluster_env = ""
try:
cluster_env = self._get_input("init_cluster.cluster_env", "")
except Exception:
cluster_env = ""
if cluster_env and not _local_registry_enabled(cluster_env):
_log("[SKIP] Local registry disabled; skipping image pre-pull.")
return True
mode = _deployment_mode_from_env(cluster_env)
if mode == "k3s":
# Keep behavior consistent across installers: k3s relies on in-cluster
# image pulls/import paths rather than a local docker registry.
_log("[SKIP] Image pre-pull is not supported in k3s mode.")
return True
ensure_fn = getattr(self, "ensure_local_registry_available", None)
if not callable(ensure_fn):
ensure_fn = getattr(self, "_ensure_local_registry_available", None)
info = ensure_fn() if callable(ensure_fn) else None
if not info:
_log("[SKIP] Local registry unavailable; skipping image pre-pull.")
return False
registry, _cluster_registry = info
images = self._collect_dependent_images(
include_supabase, include_kerberos_proxy
)
if not images:
_log("[SKIP] No dependent images found to pre-pull.")
return True
overall_ok = True
import_dir = self._get_docker_import_dir()
for image in images:
local_tag = image
if not image.startswith(f"{registry}/"):
local_tag = f"{registry}/{image}"
# 1. Check if already in local registry
_log(f"[INFO] Checking if {image} exists in local registry...")
check_reg = subprocess.run(
["docker", "pull", local_tag], capture_output=True, text=True
)
if check_reg.returncode == 0:
_log(f"[OK] {image} already exists in local registry as {local_tag}")
continue
# 2. Check if we already have it in local docker daemon
check_local = subprocess.run(
["docker", "image", "inspect", image],
capture_output=True,
text=True,
)
found = check_local.returncode == 0
if not found and import_dir and os.path.isdir(import_dir):
# 3. Check import directory
safe_name = image.replace("/", "_").replace(":", "_")
tar_path = Path(import_dir) / f"{safe_name}.tar"
if tar_path.exists():
_log(f"[INFO] Found {tar_path} in import directory, loading...")
load = subprocess.run(
["docker", "load", "-i", str(tar_path)],
capture_output=True,
text=True,
)
if load.returncode == 0:
found = True
else:
overall_ok = False
self.err(f"[WARN] Failed to load {tar_path}: {load.stderr}")
if not found:
# 4. Pull from upstream
_log(f"[INFO] Pulling {image} from upstream...")
pull = subprocess.run(
["docker", "pull", image], capture_output=True, text=True
)
if pull.returncode != 0:
overall_ok = False
self.err(pull.stdout or "")
self.err(pull.stderr or "")
self.err(f"[ERROR] docker pull failed for {image}")
continue
found = True
# If we have the image locally, tag and push to local registry
if found:
if local_tag != image:
tag = subprocess.run(
["docker", "tag", image, local_tag],
capture_output=True,
text=True,
)
if tag.returncode != 0:
overall_ok = False
self.err(tag.stdout or "")
self.err(tag.stderr or "")
self.err(
f"[ERROR] docker tag failed for {image} to {local_tag}"
)
continue
_log(f"[INFO] Pushing {local_tag} to local registry...")
if not _push_docker_image(local_tag, log_fn=self.log):
overall_ok = False
continue
_log(f"[OK] Stored {image} in local registry as {local_tag}")
return overall_ok
class _TeeWriter:
"""Write to both the original stream and a log file simultaneously."""
def __init__(self, original, log_file):
self._original = original
self._log_file = log_file
def write(self, data):
self._original.write(data)
self._original.flush()
try:
self._log_file.write(data)
self._log_file.flush()
except Exception:
pass
return len(data)
def flush(self):
self._original.flush()
try:
self._log_file.flush()
except Exception:
pass
def fileno(self):
return self._original.fileno()
def isatty(self):
return self._original.isatty()
def reconfigure(self, **kwargs):
if hasattr(self._original, "reconfigure"):
self._original.reconfigure(**kwargs)
def __getattr__(self, name):
return getattr(self._original, name)
class KnoeConsoleInstaller(KnoeInstaller):
"""Console-based unattended installer driven by knoe.cfg inputs."""
silent = True
def __init__(
self,
controller: KnoeController,
cfg_path: str | None = None,
reset_cluster: bool = False,
delete_db: bool = False,
):
self.controller = controller
self.project_root = controller.project_root
self.dependencies = list(inst_config.DEPENDENCIES)
self.cfg_path = self._normalize_cfg_path(cfg_path)
# Ensure the controller also has the normalized config path
self.controller.cfg_path = self.cfg_path
self.reset_cluster = bool(reset_cluster)
self.reset_requested = bool(reset_cluster)
self.delete_db_requested = bool(delete_db)
# Keep a persistent indicator for reset-oriented reconcile actions (e.g.
# reclaiming safe stale Released PV claimRefs). `_perform_cluster_reset()`
# consumes `reset_requested` and clears it, but we still want to run
# reset reconcile steps later once the cluster is reachable.
self._reset_reclaim_pvs_requested = bool(reset_cluster)
self.inputs: dict = {}
self._init_shared_state()
self._db_built_success = False
self._scripts_success = False
self._cnpg_success = False
self.docker_import_dir = None
self._openbao_init_env = None
self._openbao_init_password = None
self._log_file = None
def enable_log_file(self, path: str):
"""Enable tee-style logging: output goes to both stdout and the log file."""
self._log_file = open(path, "a")
sys.stdout = _TeeWriter(sys.__stdout__, self._log_file)
sys.stderr = _TeeWriter(sys.__stderr__, self._log_file)
self.log(f"[LOG] Logging to {path}")
# ---- Data access (from inputs dict) ----
def _get_input(self, key: str, default: str | None = None) -> str:
if key in self.inputs:
return _safe_str(self.inputs[key])
return default if default is not None else ""
# ---------------- Config helpers ----------------
def _normalize_cfg_path(self, raw: str | None) -> Path:
if raw:
p = Path(_expand_path(raw))
if p.is_dir():
return p / "knoe.cfg"
return p
conf_dir = knoe_conf_mgr.resolve_knoe_conf_dir(self.project_root)
return knoe_conf_mgr.entrypoint_path(conf_dir)
def _load_inputs_from_cfg(self) -> dict:
# Ensure env-dir layout exists and legacy entrypoint config is migrated before loading.
try:
if self.cfg_path.name == "knoe.cfg":
if self.cfg_path.parent.name in knoe_conf_mgr.ENVIRONMENTS:
knoe_conf_mgr.ensure_entrypoint(self.cfg_path.parent.parent)
else:
knoe_conf_mgr.ensure_entrypoint(self.cfg_path.parent)
except Exception:
pass
if not self.cfg_path.exists():
raise FileNotFoundError(f"knoe.cfg not found at {self.cfg_path}")
cfg = knoe_conf_mgr.load_layered_config(self.cfg_path)
cfg_vars = _collect_cfg_vars(cfg)
# Load all sections into knoe_cfg_data to maintain idempotency
for section in cfg.sections():
if section not in self.knoe_cfg_data:
self.knoe_cfg_data[section] = {}
for k, v in cfg.items(section):
self.knoe_cfg_data[section][k] = _expand_cfg_value(v, cfg_vars)
if cfg.has_section("Global"):
service_ns = _expand_cfg_value(
cfg.get("Global", "SERVICE_NAMESPACE", fallback=""), cfg_vars
).strip()
if service_ns:
self.knoe_cfg_data["Global"]["SERVICE_NAMESPACE"] = service_ns
if cfg.has_section("Port Forwards"):
self.knoe_cfg_data["Port Forwards"] = {
k: _expand_cfg_value(v, cfg_vars) for k, v in cfg.items("Port Forwards")
}
for (section, key), _spec in SECRET_KEY_SPECS.items():
if cfg.has_option(section, key):
val = _expand_cfg_value(
cfg.get(section, key, fallback="").strip(), cfg_vars
)
if val:
self._cfg_secret_cache[(section, key)] = val
if _is_openbao_ref(val):
self._secrets_finalized = True
self._auto_input_keys = set()
auto_kdc = None
auto_enabled = None
if cfg.has_section("Network"):
sec = cfg["Network"]
if "KDC_AUTO_DETECTED" in sec:
auto_kdc = sec.get("KDC_AUTO_DETECTED", "")
if "KERBEROS_AUTO_ENABLED" in sec:
auto_enabled = sec.get("KERBEROS_AUTO_ENABLED", "")
def mark_auto(inputs: dict):
if auto_kdc:
if (
inputs.get("kerberos_config.kdc", "").strip()
== str(auto_kdc).strip()
):
self._auto_input_keys.add("kerberos_config.kdc")
if auto_enabled is not None:
input_enabled = inputs.get("kerberos_config.enabled")
input_bool = _parse_bool(input_enabled, None)
auto_bool = _parse_bool(auto_enabled, None)
if (
input_bool is not None
and auto_bool is not None
and input_bool == auto_bool
):
self._auto_input_keys.add("kerberos_config.enabled")
def _first_non_empty(*values: str) -> str:
for value in values:
candidate = str(value or "").strip()
if candidate:
return candidate
return ""
def _cfg_first_non_empty(section_name: str, *keys: str) -> str:
if not cfg.has_section(section_name):
return ""
section = cfg[section_name]
for key in keys:
if key not in section:
continue
candidate = _expand_cfg_value(section.get(key, ""), cfg_vars).strip()
if candidate:
return candidate
return ""
def _hydrate_auth_oidc_inputs(target: dict[str, str]) -> None:
if not str(target.get("auth.clientId", "") or "").strip():
client_id = _first_non_empty(
target.get("oidc.clientId", ""),
_cfg_first_non_empty(
"Auth",
"clientId",
"CLIENT_ID",
"OIDC_CLIENT_ID",
"GOOGLE_OIDC_CLIENT_ID",
"clientIdRef",
"CLIENT_ID_REF",
),
_cfg_first_non_empty(
"Global",
"GITLAB_OIDC_CLIENT_ID",
"OIDC_CLIENT_ID",
"GOOGLE_OIDC_CLIENT_ID",
"OIDC_CLIENT_ID_REF",
"GOOGLE_OIDC_CLIENT_ID_REF",
),
os.environ.get("GITLAB_OIDC_CLIENT_ID", ""),
os.environ.get("OIDC_CLIENT_ID", ""),
os.environ.get("GOOGLE_OIDC_CLIENT_ID", ""),
)
if client_id:
target["auth.clientId"] = client_id
if not str(target.get("auth.clientSecret", "") or "").strip():
client_secret = _first_non_empty(
target.get("oidc.clientSecret", ""),
_cfg_first_non_empty(
"Auth",
"clientSecret",
"CLIENT_SECRET",
"OIDC_CLIENT_SECRET",
"GOOGLE_OIDC_CLIENT_SECRET",
"clientSecretRef",
"CLIENT_SECRET_REF",
),
_cfg_first_non_empty(
"Global",
"GITLAB_OIDC_CLIENT_SECRET",
"OIDC_CLIENT_SECRET",
"GOOGLE_OIDC_CLIENT_SECRET",
"OIDC_CLIENT_SECRET_REF",
"GOOGLE_OIDC_CLIENT_SECRET_REF",
),
os.environ.get("GITLAB_OIDC_CLIENT_SECRET", ""),
os.environ.get("OIDC_CLIENT_SECRET", ""),
os.environ.get("GOOGLE_OIDC_CLIENT_SECRET", ""),
)
if client_secret:
target["auth.clientSecret"] = client_secret
inputs: dict[str, str] = {}
if cfg.has_section("Inputs"):
inputs.update(
{k: _expand_cfg_value(v, cfg_vars) for k, v in cfg.items("Inputs")}
)
mark_auto(inputs)
# Resolve encrypted/anchored secrets for runtime use
for key in (
"init_password.db_password",
"init_password.db_password_confirm",
"kerberos_config.password",
"init_cluster.k3s_token",
"auth.clientId",
"auth.clientSecret",
):
if key in inputs and inputs[key]:
inputs[key] = self._resolve_secret_value(inputs[key])
if inputs.get("init_password.db_password") and not inputs.get(
"init_password.db_password_confirm"
):
inputs["init_password.db_password_confirm"] = inputs[
"init_password.db_password"
]
# Allow explicit runtime override of DB password in silent mode.
env_db_pw = os.environ.get("KNOE_DB_PASSWORD") or os.environ.get(
"DB_PASSWORD"
)
if env_db_pw:
raw_db_pw = inputs.get("init_password.db_password", "")
if (
not raw_db_pw
or _is_openbao_ref(raw_db_pw)
or _is_knoe_secret(raw_db_pw)
):
inputs["init_password.db_password"] = env_db_pw
inputs["init_password.db_password_confirm"] = env_db_pw
# Fill Ollama inputs from config sections if missing
if cfg.has_section("Ollama"):
sec = cfg["Ollama"]
if (
"ollama_config.server_host" not in inputs
and sec.get("OLLAMA_SERVER_HOST", "").strip()
):
inputs["ollama_config.server_host"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_HOST", ""), cfg_vars
)
if (
"ollama_config.server_port" not in inputs
and sec.get("OLLAMA_SERVER_PORT", "").strip()
):
inputs["ollama_config.server_port"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_PORT", ""), cfg_vars
)
if (
"ollama_config.model" not in inputs
and sec.get("OLLAMA_MODEL", "").strip()
):
inputs["ollama_config.model"] = _expand_cfg_value(
sec.get("OLLAMA_MODEL", ""), cfg_vars
)
if (
"ollama_config.server_host" not in inputs
and sec.get("OLLAMA_HOST", "").strip()
):
host, port = _parse_ollama_host(
_expand_cfg_value(sec.get("OLLAMA_HOST", ""), cfg_vars)
)
if host:
inputs["ollama_config.server_host"] = host
if port and "ollama_config.server_port" not in inputs:
inputs["ollama_config.server_port"] = port
if cfg.has_section("Global"):
sec = cfg["Global"]
if (
"ollama_config.server_host" not in inputs
and sec.get("OLLAMA_SERVER_HOST", "").strip()
):
inputs["ollama_config.server_host"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_HOST", ""), cfg_vars
)
if (
"ollama_config.server_port" not in inputs
and sec.get("OLLAMA_SERVER_PORT", "").strip()
):
inputs["ollama_config.server_port"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_PORT", ""), cfg_vars
)
if (
"ollama_config.model" not in inputs
and sec.get("OLLAMA_MODEL", "").strip()
):
inputs["ollama_config.model"] = _expand_cfg_value(
sec.get("OLLAMA_MODEL", ""), cfg_vars
)
if (
"ollama_config.server_host" not in inputs
and sec.get("OLLAMA_HOST", "").strip()
):
host, port = _parse_ollama_host(
_expand_cfg_value(sec.get("OLLAMA_HOST", ""), cfg_vars)
)
if host:
inputs["ollama_config.server_host"] = host
if port and "ollama_config.server_port" not in inputs:
inputs["ollama_config.server_port"] = port
_hydrate_auth_oidc_inputs(inputs)
return inputs
# Legacy fallback mapping
legacy = {}
if cfg.has_section("System Environment"):
sec = cfg["System Environment"]
for k in (
"KNOE_HOME",
"KNOE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"KNOE_SERVICE",
):
if k in sec:
raw = (sec.get(k, "") or "").strip()
# Keep shell-style variables (e.g. $HOME) literal in cfg.
if raw:
legacy[f"env_setup.{k}"] = raw
if cfg.has_section("Global"):
sec = cfg["Global"]
if "KNOE_HOME" in sec:
raw = (sec.get("KNOE_HOME", "") or "").strip()
if raw:
legacy["env_setup.KNOE_HOME"] = raw
if "CLUSTER_ENV" in sec:
legacy["init_cluster.cluster_env"] = _expand_cfg_value(
sec.get("CLUSTER_ENV", ""), cfg_vars
)
if "DATABASE_NAMESPACE" in sec:
db_ns = _expand_cfg_value(sec.get("DATABASE_NAMESPACE", ""), cfg_vars)
legacy["init_password.db_namespace"] = db_ns
legacy["env_setup.DATABASE_NAMESPACE"] = db_ns
if "K3S_SERVER" in sec:
legacy["init_cluster.k3s_server_url"] = _expand_cfg_value(
sec.get("K3S_SERVER", ""), cfg_vars
)
if "K3S_SERVER_URL" in sec and "init_cluster.k3s_server_url" not in legacy:
legacy["init_cluster.k3s_server_url"] = _expand_cfg_value(
sec.get("K3S_SERVER_URL", ""), cfg_vars
)
if "K3S_TOKEN" in sec:
legacy["init_cluster.k3s_token"] = _expand_cfg_value(
sec.get("K3S_TOKEN", ""), cfg_vars
)
if "K3S_TOKEN" in sec and "init_cluster.k3s_token" not in legacy:
legacy["init_cluster.k3s_token"] = _expand_cfg_value(
sec.get("K3S_TOKEN", ""), cfg_vars
)
if "NAMESPACE" in sec and "init_password.db_namespace" not in legacy:
db_ns = _expand_cfg_value(sec.get("NAMESPACE", ""), cfg_vars)
legacy["init_password.db_namespace"] = db_ns
legacy["env_setup.DATABASE_NAMESPACE"] = db_ns
if "CLUSTER_NAME" in sec:
cluster_name = _expand_cfg_value(sec.get("CLUSTER_NAME", ""), cfg_vars)
legacy["init_password.cluster_name"] = cluster_name
legacy["env_setup.CLUSTER_NAME"] = cluster_name
legacy.setdefault("env_setup.DB_CLUSTER_NAME", cluster_name)
legacy.setdefault("init_password.db_cluster_name", cluster_name)
if "CNPG_CLUSTER_NAME" in sec and "init_password.cluster_name" not in legacy:
cluster_name = _expand_cfg_value(
sec.get("CNPG_CLUSTER_NAME", ""), cfg_vars
)
legacy["init_password.cluster_name"] = cluster_name
legacy["env_setup.CLUSTER_NAME"] = cluster_name
legacy.setdefault("env_setup.DB_CLUSTER_NAME", cluster_name)
legacy.setdefault("init_password.db_cluster_name", cluster_name)
if "APP_CLUSTER_NAME" in sec:
app_cluster_name = _expand_cfg_value(
sec.get("APP_CLUSTER_NAME", ""), cfg_vars
)
if app_cluster_name:
legacy["env_setup.APP_CLUSTER_NAME"] = app_cluster_name
legacy["init_password.app_cluster_name"] = app_cluster_name
if "APP_CLUSTER_MODE" in sec:
legacy["env_setup.APP_CLUSTER_MODE"] = _expand_cfg_value(
sec.get("APP_CLUSTER_MODE", ""), cfg_vars
)
legacy["init_cluster.app_cluster_mode"] = legacy[
"env_setup.APP_CLUSTER_MODE"
]
if "DB_CLUSTER_NAME" in sec:
db_cluster_name = _expand_cfg_value(
sec.get("DB_CLUSTER_NAME", ""), cfg_vars
)
if db_cluster_name:
legacy["env_setup.DB_CLUSTER_NAME"] = db_cluster_name
legacy["init_password.db_cluster_name"] = db_cluster_name
if "DB_CLUSTER_MODE" in sec:
legacy["env_setup.DB_CLUSTER_MODE"] = _expand_cfg_value(
sec.get("DB_CLUSTER_MODE", ""), cfg_vars
)
legacy["init_cluster.db_cluster_mode"] = legacy["env_setup.DB_CLUSTER_MODE"]
if "DB_CLUSTER_NODE_COUNT" in sec:
legacy["init_cluster.db_cluster_node_count"] = _expand_cfg_value(
sec.get("DB_CLUSTER_NODE_COUNT", ""), cfg_vars
)
if "DB_CLUSTER_MACHINE_TYPE" in sec:
legacy["init_cluster.db_cluster_machine_type"] = _expand_cfg_value(
sec.get("DB_CLUSTER_MACHINE_TYPE", ""), cfg_vars
)
if "DB_BOOT_DISK_TYPE" in sec:
legacy["init_cluster.db_boot_disk_type"] = _expand_cfg_value(
sec.get("DB_BOOT_DISK_TYPE", ""), cfg_vars
)
if "DB_BOOT_DISK_SIZE_GB" in sec:
legacy["init_cluster.db_boot_disk_size_gb"] = _expand_cfg_value(
sec.get("DB_BOOT_DISK_SIZE_GB", ""), cfg_vars
)
if "DB_CLUSTER_REGION" in sec:
legacy["init_cluster.db_cluster_region"] = _expand_cfg_value(
sec.get("DB_CLUSTER_REGION", ""), cfg_vars
)
if "APP_CLUSTER_KUBECONTEXT" in sec:
kubecontext = _expand_cfg_value(
sec.get("APP_CLUSTER_KUBECONTEXT", ""), cfg_vars
)
legacy["env_setup.APP_CLUSTER_KUBECONTEXT"] = kubecontext
legacy["init_cluster.app_cluster_kubecontext"] = kubecontext
if "DB_CLUSTER_KUBECONTEXT" in sec:
kubecontext = _expand_cfg_value(
sec.get("DB_CLUSTER_KUBECONTEXT", ""), cfg_vars
)
legacy["env_setup.DB_CLUSTER_KUBECONTEXT"] = kubecontext
legacy["init_cluster.db_cluster_kubecontext"] = kubecontext
if "KNOE_DB_USER" in sec:
legacy["init_password.db_username"] = _expand_cfg_value(
sec.get("KNOE_DB_USER", ""), cfg_vars
)
if "DB_PASSWORD" in sec:
legacy["init_password.db_password"] = _expand_cfg_value(
sec.get("DB_PASSWORD", ""), cfg_vars
)
legacy["init_password.db_password_confirm"] = _expand_cfg_value(
sec.get("DB_PASSWORD", ""), cfg_vars
)
if "DB_HOST_PORT" in sec:
legacy["init_password.db_host_port"] = _expand_cfg_value(
sec.get("DB_HOST_PORT", "5432"), cfg_vars
)
if "DOCKER_IMPORT_DIR" in sec:
self.docker_import_dir = _expand_cfg_value(
sec.get("DOCKER_IMPORT_DIR", ""), cfg_vars
)
self.knoe_cfg_data["Global"][
"DOCKER_IMPORT_DIR"
] = self.docker_import_dir
if "OLLAMA_SERVER_HOST" in sec:
legacy["ollama_config.server_host"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_HOST", ""), cfg_vars
)
if "OLLAMA_SERVER_PORT" in sec:
legacy["ollama_config.server_port"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_PORT", ""), cfg_vars
)
if "OLLAMA_MODEL" in sec:
legacy["ollama_config.model"] = _expand_cfg_value(
sec.get("OLLAMA_MODEL", ""), cfg_vars
)
if "OLLAMA_HOST" in sec and "ollama_config.server_host" not in legacy:
host, port = _parse_ollama_host(
_expand_cfg_value(sec.get("OLLAMA_HOST", ""), cfg_vars)
)
if host:
legacy["ollama_config.server_host"] = host
if port:
legacy.setdefault("ollama_config.server_port", port)
if cfg.has_section("Network"):
sec = cfg["Network"]
if "KDC_AUTO_DETECTED" in sec:
legacy["kerberos_config.kdc"] = _expand_cfg_value(
sec.get("KDC_AUTO_DETECTED", ""), cfg_vars
)
if "KERBEROS_AUTO_ENABLED" in sec:
legacy["kerberos_config.enabled"] = _expand_cfg_value(
sec.get("KERBEROS_AUTO_ENABLED", ""), cfg_vars
)
if cfg.has_section("Kerberos Authentication"):
sec = cfg["Kerberos Authentication"]
for k, tgt in (
("ENABLED", "kerberos_config.enabled"),
("REALM", "kerberos_config.realm"),
("KDC", "kerberos_config.kdc"),
("USER", "kerberos_config.user"),
("PASSWORD", "kerberos_config.password"),
):
if k in sec:
legacy[tgt] = _expand_cfg_value(sec.get(k, ""), cfg_vars)
if cfg.has_section("Ollama"):
sec = cfg["Ollama"]
if "OLLAMA_SERVER_HOST" in sec:
legacy["ollama_config.server_host"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_HOST", ""), cfg_vars
)
if "OLLAMA_SERVER_PORT" in sec:
legacy["ollama_config.server_port"] = _expand_cfg_value(
sec.get("OLLAMA_SERVER_PORT", ""), cfg_vars
)
if "OLLAMA_MODEL" in sec:
legacy["ollama_config.model"] = _expand_cfg_value(
sec.get("OLLAMA_MODEL", ""), cfg_vars
)
if "OLLAMA_HOST" in sec and "ollama_config.server_host" not in legacy:
host, port = _parse_ollama_host(
_expand_cfg_value(sec.get("OLLAMA_HOST", ""), cfg_vars)
)
if host:
legacy["ollama_config.server_host"] = host
if port:
legacy.setdefault("ollama_config.server_port", port)
if cfg.has_section("GitOps"):
sec = cfg["GitOps"]
if "NAMESPACE" in sec:
legacy["gitops.namespace"] = _expand_cfg_value(
sec.get("NAMESPACE", ""), cfg_vars
)
if cfg.has_section("Optional Features"):
sec = cfg["Optional Features"]
if "SUPABASE_ENABLED" in sec:
legacy["init_cluster.supabase_enabled"] = sec.get(
"SUPABASE_ENABLED", ""
)
if "SUPABASE_STUDIO_ENABLED" in sec:
legacy["init_cluster.supabase_studio_enabled"] = sec.get(
"SUPABASE_STUDIO_ENABLED", ""
)
if "SUPABASE_STUDIO_URL" in sec:
legacy["init_cluster.supabase_studio_url"] = sec.get(
"SUPABASE_STUDIO_URL", ""
)
if "SUPABASE_AUTH_ENABLED" in sec:
legacy["init_cluster.supabase_auth_enabled"] = sec.get(
"SUPABASE_AUTH_ENABLED", ""
)
if "SUPABASE_REALTIME_ENABLED" in sec:
legacy["init_cluster.supabase_realtime_enabled"] = sec.get(
"SUPABASE_REALTIME_ENABLED", ""
)
if "SUPABASE_META_ENABLED" in sec:
legacy["init_cluster.supabase_meta_enabled"] = sec.get(
"SUPABASE_META_ENABLED", ""
)
if "SUPABASE_ANALYTICS_ENABLED" in sec:
legacy["init_cluster.supabase_analytics_enabled"] = sec.get(
"SUPABASE_ANALYTICS_ENABLED", ""
)
if "GITOPS_ENABLED" in sec:
legacy["init_cluster.gitops_enabled"] = sec.get(
"GITOPS_ENABLED", ""
)
if "KERBEROS_ENABLED" in sec:
legacy["init_cluster.kerberos_enabled"] = sec.get(
"KERBEROS_ENABLED", ""
)
legacy["kerberos_config.enabled"] = sec.get("KERBEROS_ENABLED", "")
if "AT_REST_ENCRYPTION_ENABLED" in sec:
legacy["init_cluster.at_rest_encryption_enabled"] = sec.get(
"AT_REST_ENCRYPTION_ENABLED", ""
)
if cfg.has_section("Initialize Cluster"):
sec = cfg["Initialize Cluster"]
if "ENVIRONMENT" in sec:
legacy["init_cluster.cluster_env"] = sec.get("ENVIRONMENT", "")
if "K3S_SERVER_URL" in sec and "init_cluster.k3s_server_url" not in legacy:
legacy["init_cluster.k3s_server_url"] = sec.get("K3S_SERVER_URL", "")
if "K3S_TOKEN" in sec and "init_cluster.k3s_token" not in legacy:
legacy["init_cluster.k3s_token"] = sec.get("K3S_TOKEN", "")
if cfg.has_section("Database Creation"):
sec = cfg["Database Creation"]
db_ns = ""
for k in ("DB_NAME", "NAMESPACE", "DATABASE_NAMESPACE"):
if k in sec:
db_ns = sec.get(k, "")
if db_ns:
legacy["init_password.db_namespace"] = db_ns
legacy["env_setup.DATABASE_NAMESPACE"] = db_ns
if "CLUSTER_NAME" in sec:
cluster_name = sec.get("CLUSTER_NAME", "")
legacy["init_password.cluster_name"] = cluster_name
legacy["env_setup.CLUSTER_NAME"] = cluster_name
if "DB_USER" in sec:
legacy["init_password.db_username"] = sec.get("DB_USER", "")
_hydrate_auth_oidc_inputs(legacy)
mark_auto(legacy)
for key in (
"init_password.db_password",
"init_password.db_password_confirm",
"kerberos_config.password",
"init_cluster.k3s_token",
"auth.clientId",
"auth.clientSecret",
):
if key in legacy and legacy[key]:
legacy[key] = self._resolve_secret_value(legacy[key])
if legacy.get("init_password.db_password") and not legacy.get(
"init_password.db_password_confirm"
):
legacy["init_password.db_password_confirm"] = legacy[
"init_password.db_password"
]
return legacy
def _default_inputs(self) -> dict:
namespace = self._initial_db_namespace()
cluster_name = self._cnpg_cluster_name()
owner = self._get_local_owner()
env_vals = self._env_defaults(namespace)
inputs: dict[str, str] = {}
# Dependencies
inputs["dependencies.verify_all"] = _bool_str(False)
inputs["dependencies.auto_install_missing"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("dependencies.auto_install_missing", True)
)
for dep in self.dependencies:
inputs[f"dependencies.{dep['id']}.install"] = _bool_str(True)
# Network scan
inputs["network_scan.run"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("network_scan.run", True)
)
# Environment setup
for k in (
"KNOE_HOME",
"KNOE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"KNOE_SERVICE",
):
inputs[f"env_setup.{k}"] = env_vals.get(k, "")
inputs["env_setup.DATABASE_NAMESPACE"] = namespace
inputs["env_setup.CLUSTER_NAME"] = cluster_name
inputs["env_setup.APP_CLUSTER_NAME"] = DEFAULT_APP_CLUSTER_NAME
inputs["env_setup.APP_CLUSTER_MODE"] = DEFAULT_APP_CLUSTER_MODE
inputs["env_setup.DB_CLUSTER_NAME"] = DEFAULT_DB_CLUSTER_NAME
inputs["env_setup.DB_CLUSTER_MODE"] = DEFAULT_DB_CLUSTER_MODE
inputs["env_setup.APP_CLUSTER_KUBECONTEXT"] = ""
inputs["env_setup.DB_CLUSTER_KUBECONTEXT"] = ""
# Database creation
inputs["init_password.db_namespace"] = namespace
inputs["init_password.cluster_name"] = cluster_name
inputs["init_password.app_cluster_name"] = DEFAULT_APP_CLUSTER_NAME
inputs["init_password.db_cluster_name"] = DEFAULT_DB_CLUSTER_NAME
inputs["init_password.db_username"] = owner
inputs["init_password.db_password"] = ""
inputs["init_password.db_password_confirm"] = ""
inputs["init_password.generate_ssh_key"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("init_password.generate_ssh_key", True)
)
inputs["init_password.db_host_port"] = "5432"
# Build DB image
inputs["init_db_build.run_build"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("init_db_build.run_build", True)
)
# Cluster init + optional features
inputs["init_cluster.cluster_env"] = "dev"
inputs["init_cluster.k3s_server_url"] = ""
inputs["init_cluster.k3s_token"] = ""
inputs["init_cluster.supabase_enabled"] = _bool_str(False)
inputs["init_cluster.supabase_studio_enabled"] = _bool_str(False)
inputs["init_cluster.supabase_studio_url"] = "db.0.knoe.dev"
inputs["init_cluster.app_cluster_name"] = DEFAULT_APP_CLUSTER_NAME
inputs["init_cluster.app_cluster_mode"] = DEFAULT_APP_CLUSTER_MODE
inputs["init_cluster.app_cluster_machine_type"] = DEFAULT_APP_CLUSTER_MACHINE_TYPE
inputs["init_cluster.app_cluster_node_count"] = str(DEFAULT_APP_CLUSTER_NODE_COUNT)
inputs["init_cluster.db_cluster_name"] = DEFAULT_DB_CLUSTER_NAME
inputs["init_cluster.db_cluster_mode"] = DEFAULT_DB_CLUSTER_MODE
inputs["init_cluster.db_cluster_node_count"] = str(DEFAULT_DB_CLUSTER_NODE_COUNT)
inputs["init_cluster.db_cluster_machine_type"] = DEFAULT_DB_CLUSTER_MACHINE_TYPE
inputs["init_cluster.db_boot_disk_type"] = DEFAULT_DB_BOOT_DISK_TYPE
inputs["init_cluster.db_boot_disk_size_gb"] = str(DEFAULT_DB_BOOT_DISK_SIZE_GB)
inputs["init_cluster.db_cluster_region"] = ""
inputs["init_cluster.db_cluster_zones"] = ""
inputs["init_cluster.app_cluster_kubecontext"] = ""
inputs["init_cluster.db_cluster_kubecontext"] = ""
inputs["init_cluster.supabase_auth_enabled"] = _bool_str(True)
inputs["init_cluster.supabase_realtime_enabled"] = _bool_str(True)
inputs["init_cluster.supabase_meta_enabled"] = _bool_str(True)
inputs["init_cluster.supabase_analytics_enabled"] = _bool_str(True)
inputs["init_cluster.gitops_enabled"] = _bool_str(False)
inputs["init_cluster.kerberos_enabled"] = _bool_str(False)
inputs["init_cluster.at_rest_encryption_enabled"] = _bool_str(True)
inputs["init_cluster.start_cluster"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("init_cluster.start_cluster", True)
)
# Kerberos config
inputs["kerberos_config.enabled"] = _bool_str(False)
inputs["kerberos_config.realm"] = ""
inputs["kerberos_config.kdc"] = ""
inputs["kerberos_config.user"] = "administrator"
inputs["kerberos_config.password"] = ""
inputs["kerberos_config.test_connection"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("kerberos_config.test_connection", False)
)
inputs["kerberos_config.init_authority"] = _bool_str(False)
# Ollama config
inputs["ollama_config.server_host"] = ""
inputs["ollama_config.server_port"] = DEFAULT_OLLAMA_PORT
inputs["ollama_config.model"] = ""
# Init scripts + deploy
inputs["init_scripts.run_scripts"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("init_scripts.run_scripts", True)
)
inputs["init_cnpg_deploy.run_deploy"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.run_deploy", True)
)
inputs["init_cnpg_deploy.force_rollout"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.force_rollout", False)
)
# GitOps
inputs["gitops.namespace"] = "gitea"
inputs["auth.clientId"] = "secretref://google-oidc-client-id"
inputs["auth.clientSecret"] = "secretref://google-oidc-client-secret"
# Disk selection (installer packaging)
inputs["disk_selection.disk_type"] = "local"
inputs["disk_selection.removable_mount"] = ""
inputs["disk_selection.local_path"] = str(Path.home())
# Build tools app
inputs["build.deploy_env"] = "Dev"
inputs["build.run_build"] = _bool_str(
DEFAULT_ACTION_FLAGS.get("build.run_build", False)
)
return inputs
# ---------------- Process helpers ----------------
def _run_cmd(
self, cmd, cwd=None, env=None, stdin_text=None, on_stdout=None, on_stderr=None
) -> int:
if isinstance(cmd, (list, tuple)) and cmd:
binary = Path(str(cmd[0])).name
if binary in {"kubectl", "helm"}:
target_role = "UNKNOWN"
target_ctx = ""
if isinstance(env, dict):
target_role = str(env.get("KNOE_CLUSTER_ROLE") or "").strip().upper() or "UNKNOWN"
target_ctx = str(
env.get("KUBECTL_CONTEXT")
or env.get("KUBE_CONTEXT_NAME")
or env.get("KUBECONTEXT")
or ""
).strip()
self.log(f"[TARGET {target_role}] {binary} context={target_ctx or '<unset>'}")
stdin_handle = subprocess.PIPE if stdin_text else None
if isinstance(cmd, str):
proc = subprocess.Popen(
["bash", "-lc", cmd],
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=stdin_handle,
text=True,
bufsize=1,
)
else:
proc = subprocess.Popen(
cmd,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=stdin_handle,
text=True,
bufsize=1,
)
if stdin_text and proc.stdin:
try:
proc.stdin.write(stdin_text)
proc.stdin.close()
except Exception:
pass
def _read_stream(stream, handler, is_err=False):
if not stream:
return
for line in iter(stream.readline, ""):
if handler:
handler(line)
else:
if is_err:
self.err(line.rstrip("\n"))
else:
self.log(line.rstrip("\n"))
err_thread = threading.Thread(
target=_read_stream, args=(proc.stderr, on_stderr, True), daemon=True
)
err_thread.start()
_read_stream(proc.stdout, on_stdout, False)
rc = proc.wait()
err_thread.join(timeout=2)
return rc
def _run_script(
self, script_name: str, args=None, env=None, stdin_text=None, on_line=None
) -> int:
def _stdout(line):
self.log(line.rstrip("\n"))
if on_line:
on_line(line)
def _stderr(line):
self.err(line.rstrip("\n"))
mode_args = []
if script_name.startswith("init_"):
mode = _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
if mode:
mode_args = ["--mode", mode]
target_role = "UNKNOWN"
target_ctx = ""
if isinstance(env, dict):
target_role = str(env.get("KNOE_CLUSTER_ROLE") or "").strip().upper() or "UNKNOWN"
target_ctx = str(
env.get("KUBECTL_CONTEXT")
or env.get("KUBE_CONTEXT_NAME")
or env.get("KUBECONTEXT")
or ""
).strip()
if target_ctx or target_role != "UNKNOWN":
self.log(
f"[TARGET {target_role}] script={script_name} context={target_ctx or '<unset>'}"
)
return self.controller.run_script(
script_name,
args=mode_args + (args or []),
env=env,
stdin_text=stdin_text,
on_line=_stdout,
on_stderr_line=_stderr,
stderr_to_stdout=False,
)
def _ensure_local_registry_available(self):
"""Ensure a local k3d registry is running and return (host_registry, cluster_registry)."""
env_hint = self._get_input("init_cluster.cluster_env", "")
mode = _deployment_mode_from_env(env_hint)
if not _local_registry_enabled(env_hint):
self.log("[SKIP] Local registry disabled; skipping registry setup.")
return None
if mode == "k3s":
# In k3s mode we must not use localhost registries or k3d registry containers.
server, _token = _resolve_k3s_connection_fn(
project_root=self.project_root,
cfg_path=self.cfg_path,
)
host = _host_from_url(server)
if not host:
self.err(
"[WARN] k3s mode but K3S_SERVER_URL/K3S_SERVER not set; cannot resolve registry."
)
return None
host_registry = f"{host}:5000"
registry_ns = self._registry_namespace()
cluster_registry = f"registry.{registry_ns}.svc.cluster.local:5000"
self.local_registry_url = host_registry
self.local_registry_internal = cluster_registry
self.knoe_cfg_data.setdefault("Docker Build", {})["LOCAL_REGISTRY"] = host_registry
self.knoe_cfg_data["Docker Build"]["LOCAL_REGISTRY_INTERNAL"] = cluster_registry
return host_registry, cluster_registry
reg_name = "knoe-registry"
registry_container = f"k3d-{reg_name}"
host_registry = "localhost:5000"
cluster_registry = f"{registry_container}.localhost:5000"
if not self.controller.check_docker_running():
self.err("[WARN] Docker not running; cannot ensure local registry.")
return None
# 1. If already responsive, just use it
if _http_ping_registry("localhost", 5000):
self.log("[INFO] Local registry already responsive on localhost:5000.")
lst = subprocess.run(
["k3d", "registry", "list"], capture_output=True, text=True
)
if registry_container not in (lst.stdout or "") and reg_name not in (
lst.stdout or ""
):
cluster_registry = "localhost:5000"
self.local_registry_url = host_registry
self.local_registry_internal = cluster_registry
self.knoe_cfg_data["Docker Build"]["LOCAL_REGISTRY"] = host_registry
self.knoe_cfg_data["Docker Build"][
"LOCAL_REGISTRY_INTERNAL"
] = cluster_registry
return host_registry, cluster_registry
# 2. Require k3d to manage/create the local registry
k3d_exists = (
subprocess.run(["which", "k3d"], capture_output=True).returncode == 0
)
if not k3d_exists:
self.err("[WARN] k3d not found; cannot ensure local registry.")
return None
lst = subprocess.run(
["k3d", "registry", "list"], capture_output=True, text=True
)
if registry_container not in (lst.stdout or "") and reg_name not in (
lst.stdout or ""
):
self.log("[INFO] Creating local k3d registry...")
subprocess.run(
["k3d", "registry", "create", reg_name, "--port", "0.0.0.0:5000"],
check=True,
)
running = subprocess.run(
[
"docker",
"ps",
"--filter",
f"name={registry_container}",
"--format",
"{{.Names}}",
],
capture_output=True,
text=True,
)
if not (running.stdout or "").strip():
exists = subprocess.run(
[
"docker",
"ps",
"-a",
"--filter",
f"name={registry_container}",
"--format",
"{{.Names}}",
],
capture_output=True,
text=True,
)
if (exists.stdout or "").strip():
subprocess.run(["docker", "start", registry_container], check=True)
self.local_registry_url = host_registry
self.local_registry_internal = cluster_registry
self.knoe_cfg_data["Docker Build"]["LOCAL_REGISTRY"] = host_registry
self.knoe_cfg_data["Docker Build"][
"LOCAL_REGISTRY_INTERNAL"
] = cluster_registry
return host_registry, cluster_registry
def _argocd_namespace(self) -> str:
ns = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("ARGOCD_NAMESPACE", "")
.strip()
)
if not ns:
ns = (os.environ.get("ARGOCD_NAMESPACE") or "").strip()
return ns or "argocd"
def _registry_namespace(self) -> str:
ns = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("REGISTRY_NAMESPACE", "")
.strip()
)
if not ns:
ns = (os.environ.get("REGISTRY_NAMESPACE") or "").strip()
# In service-mode k3s, keep registry in the service namespace and
# avoid falling back to default (which causes duplicate deployments).
if ns and not (ns == "default" and self._deployment_mode() == "k3s"):
return ns
# Registry is a common-core service; default to the configured service namespace.
return self._service_namespace()
def _ensure_registry_defaults(self, mode: str) -> None:
mode = _deployment_mode_from_env(mode) or mode
db = self.knoe_cfg_data.get("Docker Build", {}) or {}
if mode == "k3d" and _local_registry_enabled(mode):
info = self._ensure_local_registry_available()
if info:
host_registry, cluster_registry = info
db.setdefault("LOCAL_REGISTRY", host_registry)
db.setdefault("LOCAL_REGISTRY_INTERNAL", cluster_registry)
if mode == "k3s" and _local_registry_enabled(mode):
cur = (db.get("LOCAL_REGISTRY") or "").strip()
if not cur or cur == "localhost:5000":
server, _token = _resolve_k3s_connection_fn(
project_root=self.project_root,
cfg_path=self.cfg_path,
)
host = _host_from_url(server)
if host:
db["LOCAL_REGISTRY"] = f"{host}:5000"
cur_internal = (db.get("LOCAL_REGISTRY_INTERNAL") or "").strip()
if not cur_internal or ".localhost" in cur_internal:
registry_ns = self._registry_namespace()
db["LOCAL_REGISTRY_INTERNAL"] = (
f"registry.{registry_ns}.svc.cluster.local:5000"
)
self.knoe_cfg_data["Docker Build"] = db
def _fetch_k3s_kubeconfig(self) -> Path | None:
"""Fetch a fresh kubeconfig from k3s via the Ansible playbook."""
kubeconfig = self.project_root / "knoe-k3s.kubeconfig"
playbook = (
self.project_root
/ "infrastructure"
/ "playbooks"
/ "k3s_fetch_kubeconfig.yml"
)
if not playbook.exists():
self.err(f"[WARN] Ansible playbook not found: {playbook}")
return None
cmd = ["ansible-playbook", str(playbook)]
self.log("==> Fetching k3s kubeconfig via Ansible")
try:
subprocess.run(
cmd,
cwd=str(self.project_root),
check=True,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.CalledProcessError as e:
self.err(f"[WARN] Failed to fetch kubeconfig: {e}")
return None
except FileNotFoundError:
self.err("[WARN] 'ansible-playbook' command not found.")
return None
except subprocess.TimeoutExpired:
self.err("[WARN] Kubeconfig fetch timed out.")
return None
if not kubeconfig.exists():
self.err("[WARN] Kubeconfig was not written after playbook run.")
return None
self.log(f"[OK] Kubeconfig fetched: {kubeconfig}")
return kubeconfig
# ---------------- Steps ----------------
def _apply_inputs(self):
defaults = self._default_inputs()
loaded = self._load_inputs_from_cfg()
self.inputs = {**defaults, **loaded}
try:
self._apply_ansible_defaults(set(loaded.keys()))
except Exception:
pass
mode = _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
if not mode:
mode = _deployment_mode_from_env(self._get_input("init_cluster.mode", ""))
if mode:
os.environ["KNOE_MODE"] = mode
def _apply_ansible_defaults(self, loaded_keys: set[str] | None = None):
info = _detect_ansible_topology(self.project_root)
if not info:
return
loaded_keys = loaded_keys or set()
auto_keys = getattr(self, "_auto_input_keys", set())
ansible_kdc = info.get("kdc_ip") or ""
try:
net = self.knoe_cfg_data.get("Network", {})
if info.get("topology_json"):
net["ANSIBLE_TOPOLOGY"] = info["topology_json"]
if info.get("inventory_path"):
net["ANSIBLE_INVENTORY"] = info["inventory_path"]
if info.get("infrastructure_path"):
net["ANSIBLE_INFRASTRUCTURE"] = info["infrastructure_path"]
if info.get("domain"):
net["ANSIBLE_DOMAIN"] = info["domain"]
if info.get("realm"):
net["ANSIBLE_REALM"] = info["realm"]
if info.get("ad_dc_host"):
net["AD_DC_HOST"] = info["ad_dc_host"]
if info.get("ad_dc_ip"):
net["AD_DC_IP"] = info["ad_dc_ip"]
if info.get("kdc_ip"):
net["KDC_ANSIBLE_DETECTED"] = info["kdc_ip"]
self.knoe_cfg_data["Network"] = net
except Exception:
pass
# Storage/node placement derived from Ansible host vars.
# This is the authoritative source of truth for where volumes are mounted.
try:
inv_str = (info.get("inventory_path") or "").strip()
inv_path = Path(inv_str) if inv_str else None
except Exception:
inv_path = None
if inv_path is not None and inv_path.exists():
try:
mounts = _detect_ansible_storage_mounts(inv_path)
labels_by_host = _detect_ansible_node_labels(inv_path)
storage_sec = self.knoe_cfg_data.setdefault("Storage", {})
if mounts:
storage_sec["ANSIBLE_ISCSI_MOUNTS"] = json.dumps(
mounts, separators=(",", ":"), sort_keys=True
)
if labels_by_host:
storage_sec["ANSIBLE_NODE_LABELS"] = json.dumps(
labels_by_host, separators=(",", ":"), sort_keys=True
)
glob = self.knoe_cfg_data.setdefault("Global", {})
cnpg_eligible_nodes = sorted(
{
str((details or {}).get("host") or "").strip()
for details in mounts.values()
if isinstance(details, dict)
and str((details or {}).get("host") or "").strip()
}
)
if cnpg_eligible_nodes:
glob["CNPG_ELIGIBLE_NODES"] = ",".join(cnpg_eligible_nodes)
synology_roots = sorted(
{
str((details or {}).get("path") or "").strip()
for details in mounts.values()
if isinstance(details, dict)
and str((details or {}).get("path") or "").strip()
}
)
if synology_roots:
glob["SYNOLOGY_ROOTS"] = ",".join(synology_roots)
# CNPG protected storage defaults (k3s local PV)
d001 = mounts.get("d001") or {}
d001_base = (d001.get("path") or "").strip()
d001_host = (d001.get("host") or "").strip()
if d001_base and d001_host:
glob["CNPG_STAGE1_NODE"] = d001_host
glob["CNPG_DB_NODE_SELECTOR"] = f"kubernetes.io/hostname={d001_host}"
# Monitoring storage defaults (prefer d004; fall back to d002)
mon_choice = None
for vol_id in ("d004", "d002"):
if mounts.get(vol_id):
mon_choice = mounts[vol_id]
break
if mon_choice:
mon_base = (mon_choice.get("path") or "").strip()
mon_host = (mon_choice.get("host") or "").strip()
if mon_base:
glob["MONITORING_DATA_DIR"] = mon_base
if mon_host:
self.knoe_cfg_data.setdefault("Monitoring", {})[
"MONITORING_PRIMARY_NODE"
] = mon_host
except Exception:
pass
if ansible_kdc and "kerberos_config.kdc" in auto_keys:
self.inputs["kerberos_config.kdc"] = ansible_kdc
if ansible_kdc and "kerberos_config.enabled" in auto_keys:
self.inputs["kerberos_config.enabled"] = _bool_str(True)
if "kerberos_config.kdc" not in loaded_keys:
if not self._get_input("kerberos_config.kdc", "").strip() and info.get(
"kdc_ip"
):
self.inputs["kerberos_config.kdc"] = info["kdc_ip"]
if "kerberos_config.realm" not in loaded_keys:
if not self._get_input("kerberos_config.realm", "").strip() and info.get(
"realm"
):
self.inputs["kerberos_config.realm"] = info["realm"]
if "kerberos_config.enabled" not in loaded_keys:
if info.get("kdc_ip") and not self._get_input_bool(
"kerberos_config.enabled", False
):
self.inputs["kerberos_config.enabled"] = _bool_str(True)
if "init_cluster.k3s_server_url" not in loaded_keys:
if not self._get_input(
"init_cluster.k3s_server_url", ""
).strip() and info.get("k3s_server_url"):
self.inputs["init_cluster.k3s_server_url"] = info["k3s_server_url"]
if "init_cluster.k3s_token" not in loaded_keys:
if not self._get_input("init_cluster.k3s_token", "").strip() and info.get(
"k3s_token"
):
self.inputs["init_cluster.k3s_token"] = info["k3s_token"]
def _write_cfg(self):
inputs = dict(self.inputs)
db_pw = self._get_input("init_password.db_password", "")
db_pw_cfg = self._secret_cfg_value(
"Inputs", "init_password.db_password", db_pw, "db", "password"
)
if db_pw_cfg:
inputs["init_password.db_password"] = db_pw_cfg
inputs["init_password.db_password_confirm"] = db_pw_cfg
elif db_pw:
inputs["init_password.db_password"] = db_pw
inputs["init_password.db_password_confirm"] = (
self._get_input("init_password.db_password_confirm", "") or db_pw
)
krb_pw = self._get_input("kerberos_config.password", "")
if krb_pw:
inputs["kerberos_config.password"] = self._secret_cfg_value(
"Inputs", "kerberos_config.password", krb_pw, "kerberos", "password"
)
k3s_token = self._get_input("init_cluster.k3s_token", "")
if k3s_token:
inputs["init_cluster.k3s_token"] = self._secret_cfg_value(
"Inputs", "init_cluster.k3s_token", k3s_token, "k3s", "token"
)
mode = _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
target_label = _deployment_target_label(
self._get_input("init_cluster.cluster_env", "")
)
globals_to_save = {
"KNOE_HOME": self._get_input("env_setup.KNOE_HOME", ""),
"KNOE_DB_USER": self._get_input("init_password.db_username", ""),
"DB_PASSWORD": self._secret_cfg_value(
"Global", "DB_PASSWORD", db_pw, "db", "password"
),
"CLUSTER_ENV": self._get_input("init_cluster.cluster_env", ""),
"DEPLOYMENT_MODE": mode,
"DEPLOYMENT_TARGET": target_label,
"DATABASE_NAMESPACE": (
self._get_input("init_password.db_namespace", "") or ""
).strip(),
"CLUSTER_NAME": (
self._get_input("init_password.cluster_name", "")
or self._get_input("env_setup.CLUSTER_NAME", "")
or self._cnpg_cluster_name()
or ""
).strip(),
"DB_HOST_PORT": (
self._get_input("init_password.db_host_port", "5432") or "5432"
).strip(),
"DOCKER_IMPORT_DIR": self.docker_import_dir or "",
"K3S_SERVER": (
self._get_input("init_cluster.k3s_server_url", "") or ""
).strip(),
"K3S_TOKEN": self._secret_cfg_value(
"Global",
"K3S_TOKEN",
self._get_input("init_cluster.k3s_token", ""),
"k3s",
"token",
),
"OPENTOFU_URL": _default_opentofu_pipeline_url(),
"SERVICE_NAMESPACE": self._service_namespace(),
}
# Merge pre-existing Global values without overriding explicit inputs
existing_global = dict(self.knoe_cfg_data.get("Global", {}))
for k, v in existing_global.items():
if k not in globals_to_save or not str(globals_to_save.get(k, "")).strip():
globals_to_save[k] = v
globals_to_save.setdefault("DOCKER_PRELOAD", "false")
for _gk, _gv in list(globals_to_save.items()):
if isinstance(_gv, str) and _gv.strip():
globals_to_save[_gk] = self._cfgify_home_path(_gv)
# Re-assert critical/dynamic values to avoid accidental overwrite
globals_to_save["DB_PASSWORD"] = self._secret_cfg_value(
"Global", "DB_PASSWORD", db_pw, "db", "password"
)
globals_to_save["DEPLOYMENT_MODE"] = mode
globals_to_save["DEPLOYMENT_TARGET"] = target_label
globals_to_save.pop("NAMESPACE", None)
globals_to_save.pop("CNPG_CLUSTER_NAME", None)
globals_to_save.pop("KUBECONTEXT", None) # machine-specific; always runtime-detected
globals_to_save.setdefault("ARGOCD_NAMESPACE", self._argocd_namespace())
globals_to_save.setdefault("REGISTRY_NAMESPACE", self._registry_namespace())
# Preserve Supabase placement vars from cfg — never let pipeline state overwrite them
for _supa_key in (
"SUPABASE_PRIMARY_NODE", "SUPABASE_PV_NODE",
"SUPABASE_PV_BASE_DIR", "SUPABASE_STORAGE_CLASS",
"SUPABASE_ADDITIONAL_REDIRECT_URLS",
):
_supa_val = existing_global.get(_supa_key, "")
if _supa_val:
globals_to_save[_supa_key] = _supa_val
# Monitoring
# NOTE: Storage base paths for monitoring are currently pinned in the
# monitoring deploy script/Helm values (not persisted in knoe.cfg).
mon_sec = self.knoe_cfg_data.get("Monitoring", {})
if mon_sec is None:
mon_sec = {}
self.knoe_cfg_data["Monitoring"] = mon_sec
# Optional features (sourced from inputs)
opt_sec = self.knoe_cfg_data.setdefault("Optional Features", {})
opt_sec["SUPABASE_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_enabled", False)
)
opt_sec["GITOPS_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.gitops_enabled", False)
)
opt_sec["KERBEROS_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.kerberos_enabled", False)
)
opt_sec["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.at_rest_encryption_enabled", False)
)
gitops_ns_val = (
self._get_input("gitops.namespace", "")
or self.inputs.get("gitops.namespace", "")
)
if gitops_ns_val:
self.knoe_cfg_data.setdefault("GitOps", {})[
"GITOPS_NAMESPACE"
] = gitops_ns_val
ollama_host = (self._get_input("ollama_config.server_host", "") or "").strip()
ollama_port = (
self._get_input("ollama_config.server_port", DEFAULT_OLLAMA_PORT) or ""
).strip()
ollama_model = (self._get_input("ollama_config.model", "") or "").strip()
ollama_section = dict(self.knoe_cfg_data.get("Ollama", {}))
if ollama_host:
if not ollama_port:
ollama_port = DEFAULT_OLLAMA_PORT
ollama_section["OLLAMA_SERVER_HOST"] = ollama_host
ollama_section["OLLAMA_SERVER_PORT"] = ollama_port
ollama_section["OLLAMA_HOST"] = _format_ollama_host(
ollama_host, ollama_port
)
else:
for key in ("OLLAMA_SERVER_HOST", "OLLAMA_SERVER_PORT", "OLLAMA_HOST"):
ollama_section.pop(key, None)
if ollama_model:
ollama_section["OLLAMA_MODEL"] = ollama_model
else:
ollama_section.pop("OLLAMA_MODEL", None)
if ollama_section:
self.knoe_cfg_data["Ollama"] = ollama_section
self._sync_port_forward_mappings()
sections = {
k: self.knoe_cfg_data.get(k, {})
for k in [
"Welcome",
"Dependencies",
"Network",
"Port Forwards",
"System Environment",
"Monitoring",
"Kerberos Authentication",
"Ollama",
"Optional Features",
"GitOps",
"CNPG Clusters",
"Database Creation",
"Initialize Cluster",
"Docker Build",
"Initialization Scripts",
"Deployment",
"Install",
]
}
deployment_section = dict(sections.get("Deployment", {}))
if mode:
deployment_section.setdefault("MODE", mode)
if target_label:
deployment_section.setdefault("TARGET", target_label)
sections["Deployment"] = deployment_section
sections["Dev Cluster (k3d)"] = {
**self.knoe_cfg_data.get("Dev Cluster (k3d)", {}),
"MODE": "k3d",
"CLUSTER_ENV": "k3d-knoe-dev-cluster",
"DISPLAY_NAME": "knoe-dev-cluster",
"KUBECTL_CONTEXT": self._get_input("init_cluster.cluster_env", ""),
}
sections["Service Cluster (k3s)"] = {
**self.knoe_cfg_data.get("Service Cluster (k3s)", {}),
"MODE": "k3s",
"CLUSTER_ENV": "knoe-service-cluster",
"DISPLAY_NAME": "knoe-service-cluster",
"K3S_SERVER_URL": (
self._get_input("init_cluster.k3s_server_url", "") or ""
).strip(),
"K3S_TOKEN": self._secret_cfg_value(
"Service Cluster (k3s)",
"K3S_TOKEN",
self._get_input("init_cluster.k3s_token", ""),
"k3s",
"token",
),
"PIPELINE_URL": _default_opentofu_pipeline_url(),
}
sections["Prod Cluster (k8s)"] = {
**self.knoe_cfg_data.get("Prod Cluster (k8s)", {}),
"MODE": "k8s",
"CLUSTER_ENV": "knoe-prod-cluster",
"DISPLAY_NAME": "knoe-prod-cluster",
"ARTIFACTS_DIR": (
self._get_input("init_cluster.prod_artifacts_path", "") or ""
).strip(),
"PIPELINE_URL": _default_opentofu_pipeline_url(),
}
sections = self._sanitize_sections_for_cfg(sections)
# Check for existing config and timestamp to maintain idempotency
existing_text = None
existing_timestamp = None
if self.cfg_path.exists():
existing_text = self.cfg_path.read_text()
for line in existing_text.splitlines():
if line.startswith("; Generated by install.py on "):
existing_timestamp = line[
len("; Generated by install.py on ") :
].strip()
break
cfg_text = _render_knoe_cfg(
inputs, globals_to_save, sections, generated_at=existing_timestamp
)
should_write = True
if existing_text:
if existing_text == cfg_text:
should_write = False
if should_write:
# If content changed but we reused existing_timestamp, it might be misleading.
# But usually we want to know when it LAST changed.
# If it's different, let's generate a new one.
if existing_text and existing_text != cfg_text:
cfg_text = _render_knoe_cfg(inputs, globals_to_save, sections)
self.cfg_path.parent.mkdir(parents=True, exist_ok=True)
self.cfg_path.write_text(cfg_text)
self.log(f"[CONFIG] Wrote {self.cfg_path}")
def _step_dependencies(self) -> bool:
self.log("==> Dependencies")
missing = []
for dep in self.dependencies:
ok, location, version = inst_config.get_dep_info(dep)
if ok:
self.log(f"[OK] {dep['name']} {version or ''}".strip())
continue
missing.append(dep)
self.log(f"[MISSING] {dep['name']}")
if not missing:
if not self._ensure_gcloud_auth_for_k8s():
self.knoe_cfg_data["Dependencies"]["STATUS"] = "Missing"
return False
self.knoe_cfg_data["Dependencies"]["STATUS"] = "All installed"
return True
auto_install = self._get_input_bool(
"dependencies.auto_install_missing",
DEFAULT_ACTION_FLAGS.get("dependencies.auto_install_missing", True),
)
if not auto_install:
self.err("[ERROR] Dependencies missing and auto-install disabled.")
self.knoe_cfg_data["Dependencies"]["STATUS"] = "Missing"
return False
for dep in missing:
install_cmd = dep.get("install_cmd")
if not install_cmd:
self.err(f"[ERROR] No install command for {dep['name']}.")
continue
should_install = self._get_input_bool(
f"dependencies.{dep['id']}.install", True
)
if not should_install:
self.err(f"[SKIP] {dep['name']} install disabled by config.")
continue
self.log(f"[INSTALL] {dep['name']} -> {install_cmd}")
rc = self._run_cmd(install_cmd)
if rc != 0:
self.err(f"[ERROR] Install failed for {dep['name']} (code {rc})")
still_missing = []
for dep in missing:
ok, _, _ = inst_config.get_dep_info(dep)
if not ok:
still_missing.append(dep["name"])
if still_missing:
self.err(f"[ERROR] Still missing: {', '.join(still_missing)}")
self.knoe_cfg_data["Dependencies"]["STATUS"] = "Missing"
return False
if not self._ensure_gcloud_auth_for_k8s():
self.knoe_cfg_data["Dependencies"]["STATUS"] = "Missing"
return False
self.knoe_cfg_data["Dependencies"]["STATUS"] = "All installed"
return True
def _ensure_gcloud_auth_for_k8s(self) -> bool:
if self._deployment_mode() != "k8s":
return True
gcloud_env = inst_config._augment_env_for_dependency_backend(os.environ.copy())
cmd_base = ["gcloud"]
token_cmd = cmd_base + ["auth", "print-access-token", "--quiet"]
try:
token_res = subprocess.run(
token_cmd,
capture_output=True,
text=True,
env=gcloud_env,
timeout=20,
)
if token_res.returncode == 0 and (token_res.stdout or "").strip():
return True
except Exception:
pass
interactive = bool(getattr(sys.stdin, "isatty", lambda: False)())
if interactive:
login_cmd = cmd_base + ["auth", "login", "--no-launch-browser"]
self.log(
"[ACTION] No active gcloud session found. Starting login flow: "
+ " ".join(shlex.quote(part) for part in login_cmd)
)
try:
login_rc = self._run_cmd(login_cmd)
if login_rc == 0:
token_res = subprocess.run(
token_cmd,
capture_output=True,
text=True,
env=gcloud_env,
timeout=20,
)
if token_res.returncode == 0 and (token_res.stdout or "").strip():
return True
except Exception:
pass
self.err(
"[ERROR] gcloud is installed but no active auth session is available for GKE. "
"Run `gcloud auth login --no-launch-browser`, complete the URL/code flow on a browser-capable machine, then retry deploy."
)
return False
def _step_network_scan(self) -> None:
if not self._get_input_bool(
"network_scan.run", DEFAULT_ACTION_FLAGS.get("network_scan.run", True)
):
self.log("[SKIP] Network scan disabled.")
return
existing_kdc = self.knoe_cfg_data.get("Network", {}).get("KDC_AUTO_DETECTED")
if existing_kdc:
self.log(
f"[OK] Network scan already performed. KDC detected: {existing_kdc}"
)
return
self.log("==> Network scan (network-agent)")
ansible_kdc = ""
try:
ansible_kdc = (self.knoe_cfg_data.get("Network", {}) or {}).get(
"KDC_ANSIBLE_DETECTED", ""
)
except Exception:
ansible_kdc = ""
scan_binary = get_resource_path("scan/network-agent")
if not scan_binary.exists():
self.err(f"[ERROR] Scan binary not found at {scan_binary}")
return
knoe_home = resolve_knoe_home(env={})
scan_dir = knoe_home / "scan"
scan_dir.mkdir(parents=True, exist_ok=True)
kdc_found = None
pending_line = ""
def _handle_line(line: str):
nonlocal kdc_found
self.log(line.rstrip("\n"))
if "KDC is:" in line:
try:
ip_part = line.split("KDC is:")[1].strip()
ip = ip_part.split()[0].strip("[]():,")
if ip:
kdc_found = ip
except Exception:
pass
elif "Active Directory" in line or "88" in line:
for part in line.split():
try:
socket.inet_aton(part.strip("[]():,"))
kdc_found = part.strip("[]():,")
break
except Exception:
continue
def _handle_stdout(text: str):
nonlocal pending_line
pending_line += text
while "\n" in pending_line:
line, pending_line = pending_line.split("\n", 1)
_handle_line(line + "\n")
rc = run_streaming_cmd(
[str(scan_binary)], cwd=str(scan_dir), on_stdout=_handle_stdout
)
if pending_line:
_handle_line(pending_line)
if rc == 0:
self.log("[OK] Scan complete.")
else:
self.err(f"[ERROR] Scan failed (code {rc})")
if kdc_found and not ansible_kdc:
self.inputs["kerberos_config.kdc"] = kdc_found
self.inputs["kerberos_config.enabled"] = _bool_str(True)
self.knoe_cfg_data["Network"]["KDC_AUTO_DETECTED"] = kdc_found
self.knoe_cfg_data["Network"]["KERBEROS_AUTO_ENABLED"] = "True"
elif kdc_found and ansible_kdc:
self.log(
f"[INFO] Ansible KDC detected ({ansible_kdc}); ignoring scan-detected KDC {kdc_found}."
)
def _step_env_setup(self) -> None:
self.log("==> Environment setup")
global_sec = self.knoe_cfg_data.get("Global", {}) or {}
sys_sec = self.knoe_cfg_data.get("System Environment", {}) or {}
def _cfg_val(sec: dict, key: str) -> str:
try:
v = str((sec or {}).get(key, "") or "").strip()
except Exception:
v = ""
if v.startswith("${") and v.endswith("}"):
return ""
return v
ns_hint = (
self._get_input("env_setup.DATABASE_NAMESPACE", "")
or self._get_input("init_password.db_namespace", "")
or _cfg_val(global_sec, "DATABASE_NAMESPACE")
or self._get_input("env_setup.NAMESPACE", "")
or self._get_input("init_password.db_namespace", "")
or _cfg_val(global_sec, "NAMESPACE")
)
cluster_hint = (
self._get_input("env_setup.CLUSTER_NAME", "")
or self._get_input("init_password.cluster_name", "")
or _cfg_val(global_sec, "CLUSTER_NAME")
or _cfg_val(global_sec, "CNPG_CLUSTER_NAME")
or "knoe-db"
)
vals = {
"KNOE_HOME": (
self._get_input("env_setup.KNOE_HOME", "")
or _cfg_val(sys_sec, "KNOE_HOME")
or _cfg_val(global_sec, "KNOE_HOME")
or str(resolve_knoe_home(env={}))
),
"KNOE_CONF": (
self._get_input("env_setup.KNOE_CONF", "")
or _cfg_val(sys_sec, "KNOE_CONF")
or _cfg_val(global_sec, "KNOE_CONF")
),
"PROLE_DATA": (
self._get_input("env_setup.PROLE_DATA", "")
or _cfg_val(sys_sec, "PROLE_DATA")
or _cfg_val(global_sec, "PROLE_DATA")
),
"PROLE_LOGS": (
self._get_input("env_setup.PROLE_LOGS", "")
or _cfg_val(sys_sec, "PROLE_LOGS")
or _cfg_val(global_sec, "PROLE_LOGS")
),
"KNOE_SERVICE": (
self._get_input("env_setup.KNOE_SERVICE", "")
or _cfg_val(sys_sec, "KNOE_SERVICE")
or _cfg_val(global_sec, "KNOE_SERVICE")
),
"OPENTOFU_URL": (os.environ.get("OPENTOFU_URL") or "").strip(),
}
defaults = self._env_defaults(ns_hint)
for k in vals:
if not vals[k]:
vals[k] = defaults.get(k, "")
if not vals.get("OPENTOFU_URL"):
vals["OPENTOFU_URL"] = _default_opentofu_pipeline_url()
vals["DATABASE_NAMESPACE"] = (
self._get_input("env_setup.DATABASE_NAMESPACE", "")
or self._get_input("env_setup.NAMESPACE", "")
or ns_hint
)
vals["CLUSTER_NAME"] = (
self._get_input("env_setup.CLUSTER_NAME", "") or cluster_hint
)
if not vals.get("KNOE_HOME"):
raise Exception("KNOE_HOME is required for env setup.")
# Persist normalized inputs
for k in (
"KNOE_HOME",
"KNOE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"KNOE_SERVICE",
):
self.inputs[f"env_setup.{k}"] = vals[k]
self.inputs["env_setup.DATABASE_NAMESPACE"] = vals.get(
"DATABASE_NAMESPACE", ""
)
self.inputs["env_setup.CLUSTER_NAME"] = vals.get("CLUSTER_NAME", "")
if not (self._get_input("init_password.db_namespace", "") or "").strip():
self.inputs["init_password.db_namespace"] = vals.get(
"DATABASE_NAMESPACE", ""
)
if not (self._get_input("init_password.cluster_name", "") or "").strip():
self.inputs["init_password.cluster_name"] = vals.get("CLUSTER_NAME", "")
self._save_env_to_file(vals)
self.reload_env_from_shell()
for k in (
"KNOE_HOME",
"KNOE_CONF",
"PROLE_DATA",
"PROLE_LOGS",
"KNOE_SERVICE",
"OPENTOFU_URL",
):
self.knoe_cfg_data["System Environment"][k] = vals[k]
def _step_init_password(self) -> None:
self.log("==> Database creation")
ns = _safe_str(self._get_input("init_password.db_namespace", "") or "").strip()
if not ns:
ns = self._get_input("env_setup.DATABASE_NAMESPACE", "")
if not ns:
ns = self._get_input("env_setup.NAMESPACE", "")
if not ns:
ns = (
(self.knoe_cfg_data.get("Global", {}) or {}).get(
"DATABASE_NAMESPACE", ""
)
or ""
).strip()
if not ns:
ns = (
(self.knoe_cfg_data.get("Global", {}) or {}).get("NAMESPACE", "") or ""
).strip()
if ns.startswith("${") and ns.endswith("}"):
ns = ""
if not ns:
raise Exception("Database namespace cannot be empty.")
if not self._is_valid_namespace(ns):
raise Exception("Invalid database namespace.")
cluster_name = (
_safe_str(self._get_input("init_password.cluster_name", "") or "").strip()
)
if not cluster_name:
cluster_name = (
_safe_str(self._get_input("env_setup.CLUSTER_NAME", "") or "").strip()
)
if not cluster_name:
cluster_name = (
(self.knoe_cfg_data.get("Global", {}) or {}).get("CLUSTER_NAME", "")
or ""
).strip()
if not cluster_name:
cluster_name = (
(self.knoe_cfg_data.get("Global", {}) or {}).get(
"CNPG_CLUSTER_NAME", ""
)
or ""
).strip()
if not cluster_name:
cluster_name = "knoe-db"
user = (self._get_input("init_password.db_username", "") or "").strip()
if not user:
user = (
(self.knoe_cfg_data.get("Global", {}) or {}).get("KNOE_DB_USER", "")
or ""
).strip()
if user.startswith("${") and user.endswith("}"):
user = ""
if not user:
import getpass
try:
user = getpass.getuser()
except Exception:
user = "knoe"
self.log(f"[INFO] No database owner provided; using default: {user}")
p1 = self._get_input("init_password.db_password", "")
p2 = self._get_input("init_password.db_password_confirm", "") or p1
if not p1:
p1 = self._generate_db_password()
p2 = p1
self.log(
"[INFO] No database password provided; generated default password."
)
if p1 != p2:
raise Exception("Database passwords do not match.")
# If password is an OpenBao anchor, only skip when OpenBao is already initialized.
if p1.startswith("${"):
resolved = self._resolve_secret_value(p1)
if resolved and not resolved.startswith("${"):
p1 = resolved
p2 = resolved
else:
service_dir = self._get_input("env_setup.KNOE_SERVICE", "").strip()
if not service_dir:
service_dir = self._env_defaults(ns).get("KNOE_SERVICE", "")
token_path = Path(service_dir) / "secrets" / "openbao-root-token"
local_token_path = (
resolve_knoe_home(env={})
/ "etc"
/ "secrets"
/ "openbao-root-token"
)
has_token = (
bool(os.environ.get("OPENBAO_ROOT_TOKEN"))
or token_path.exists()
or local_token_path.exists()
)
if self.reset_requested or not has_token:
p1 = self._generate_db_password()
p2 = p1
self.log(
"[RESET] OpenBao anchor detected without token; generated new DB password for initialization."
)
else:
self.log(
f"[OK] OpenBao secrets already anchored for namespace {ns}. Skipping initialization."
)
return
self.inputs["init_password.db_namespace"] = ns
self.inputs["env_setup.DATABASE_NAMESPACE"] = ns
self.inputs["init_password.cluster_name"] = cluster_name
self.inputs["env_setup.CLUSTER_NAME"] = cluster_name
self.inputs["init_password.db_password"] = p1
self.inputs["init_password.db_password_confirm"] = p2
self._update_env_namespace(ns)
self.knoe_cfg_data["Database Creation"]["DB_USER"] = user
self.knoe_cfg_data["Database Creation"]["DB_PASSWORD_SET"] = "true"
self.knoe_cfg_data["Database Creation"]["DATABASE_NAMESPACE"] = ns
self.knoe_cfg_data["Database Creation"]["CLUSTER_NAME"] = cluster_name
self.knoe_cfg_data["Database Creation"].pop("DB_NAME", None)
self.knoe_cfg_data["Database Creation"].pop("NAMESPACE", None)
include_supabase = self._get_input_bool("init_cluster.supabase_enabled", False)
include_kerberos = self._get_input_bool(
"init_cluster.kerberos_enabled", False
) or self._get_input_bool("kerberos_config.enabled", False)
if include_supabase:
env_policy = self._script_env_for_namespace(ns)
allowed, _count, reason = self._optional_workloads_policy(env_policy)
if not allowed:
self.log(f"[SKIP] Supabase image pre-pull disabled by policy: {reason}")
include_supabase = False
try:
self._prepull_images_to_registry(include_supabase, include_kerberos)
except Exception as e:
self.err(f"[WARN] Image pre-pull error: {e}")
if self._get_input_bool(
"init_password.generate_ssh_key",
DEFAULT_ACTION_FLAGS.get("init_password.generate_ssh_key", True),
):
self._generate_ssh_key(user)
# Initialize OpenBao and store keys/passwords for this namespace.
# OpenBao is expected to run inside the cluster; defer initialization until cluster is ready.
env = os.environ.copy()
env["KNOE_HOME"] = str(self.project_root)
env["KNOE_SERVICE"] = str(self.project_root)
env["KNOE_DB_USER"] = user
env["DB_PASSWORD"] = p1
env["GRAFANA_ADMIN_PASSWORD"] = p1
env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.at_rest_encryption_enabled", False)
)
realm = self._get_input("kerberos_config.realm", "").strip()
kdc = self._get_input("kerberos_config.kdc", "").strip()
krb_user = self._get_input("kerberos_config.user", "").strip()
krb_pw = self._get_input("kerberos_config.password", "").strip()
if realm:
env["KRB5_REALM"] = realm
env["REALM"] = realm
env["DOMAIN"] = realm.lower()
if kdc:
env["KRB5_KDC"] = kdc
env["KRB5_ADMIN"] = kdc
if krb_user:
env["KRB5_USER"] = krb_user
if krb_pw:
env["KRB5_PASSWORD"] = krb_pw
self._openbao_init_env = env
self._openbao_init_password = p1
self.log("[INFO] Deferring OpenBao initialization until cluster is ready.")
def _generate_ssh_key(self, user: str):
self.log("==> Environment Preparation (Secure Access)")
key_path = Path.home() / ".ssh" / "id_knoe_ed25519"
key_path.parent.mkdir(parents=True, exist_ok=True)
if key_path.exists():
self.log(f"[SKIP] Secure access keys already exist at {key_path}")
return
cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", user]
rc = self._run_cmd(cmd)
if rc != 0:
self.err(f"[WARN] Preparation failed (code {rc}), trying alternative.")
cmd = [
"ssh-keygen",
"-t",
"rsa",
"-b",
"4096",
"-N",
"",
"-f",
str(key_path),
"-C",
user,
]
rc = self._run_cmd(cmd)
if rc != 0:
self.err(f"[ERROR] Preparation failed (code {rc})")
def _step_db_build(self) -> None:
if not self._get_input_bool(
"init_db_build.run_build",
DEFAULT_ACTION_FLAGS.get("init_db_build.run_build", True),
):
self.log("[SKIP] DB build disabled.")
self.knoe_cfg_data["Docker Build"]["STATUS"] = "Skipped"
return
tag = self.controller.get_knoe_db_version()
image_name = f"knoe-db:{tag}"
env_key = _normalize_cluster_env(
self._get_input("init_cluster.cluster_env", "dev")
)
cluster_name = "knoe-dev-cluster"
# Check if image already exists
rc_inspect = subprocess.run(
["docker", "inspect", image_name], capture_output=True
).returncode
if rc_inspect == 0:
self.log(f"[OK] {image_name} already exists. Skipping build.")
if env_key == "dev":
self.log(f"Importing image to {cluster_name}...")
self._run_cmd(
["k3d", "image", "import", image_name, "-c", cluster_name]
)
self._db_built_success = True
elif env_key == "service":
# In k3s mode, publish to the in-cluster registry instead of importing
# tarballs into nodes.
push_ok = True
db_cfg = self.knoe_cfg_data.get("Docker Build", {}) or {}
registry = (db_cfg.get("LOCAL_REGISTRY") or "").strip()
# Prioritize KNOE_IMAGE_REGISTRY from [Global] if set
global_registry = (self.knoe_cfg_data.get("Global", {}) or {}).get("KNOE_IMAGE_REGISTRY", "").strip()
if global_registry:
registry = global_registry
if not registry or registry == "localhost:5000":
server, _token = _resolve_k3s_connection_fn(
project_root=self.project_root,
cfg_path=self.cfg_path,
)
host = _host_from_url(server)
if host:
registry = f"{host}:5000"
db_cfg["LOCAL_REGISTRY"] = registry
self.knoe_cfg_data["Docker Build"] = db_cfg
if not registry or registry == "localhost:5000":
push_ok = False
self.err(
"[ERROR] k3s mode but registry host could not be resolved; cannot push knoe-db image."
)
else:
# Preflight check: verify registry is reachable
reg_host = registry
reg_port = 80
if ":" in registry:
parts = registry.split(":")
reg_host = parts[0]
try:
reg_port = int(parts[1])
except:
pass
self.log(f"Preflight: checking registry reachability at {reg_host}:{reg_port}...")
if not _http_ping_registry(reg_host, reg_port):
self.err(f"[ERROR] Registry {registry} is unreachable from this node.")
self._db_built_success = False
self.knoe_cfg_data["Docker Build"]["STATUS"] = "Failed"
return
remote_tag = f"{registry}/{image_name}"
self.log(f"Tagging {image_name} as {remote_tag}...")
subprocess.run(
["docker", "tag", image_name, remote_tag],
capture_output=True,
)
if _registry_image_ref_exists(remote_tag):
self.log(
f"[SKIP] {remote_tag} already exists in registry; skipping push."
)
else:
self.log(f"Pushing {remote_tag} to registry...")
if not _push_docker_image(remote_tag, log_fn=self.log):
push_ok = False
self.err(f"[ERROR] Failed to push image {remote_tag}")
self._db_built_success = push_ok
else:
self._db_built_success = True
self.knoe_cfg_data["Docker Build"]["STATUS"] = (
"Built" if self._db_built_success else "Failed"
)
return
self.log("==> Build knoe-db image")
knoe_home = resolve_knoe_home()
mode_key = _deployment_mode_from_env(env_key) or "default"
build_dir = knoe_home / "build" / mode_key / "knoe-db"
build_dir.mkdir(parents=True, exist_ok=True)
source_dir = get_resource_path("knoe-db")
if not source_dir.exists():
source_dir = get_resource_path("knoe-db")
marker = build_dir / ".knoe_build_context_ready"
if not marker.exists():
copy_build_context_dir(source_dir, build_dir)
pub_key_path = Path.home() / ".ssh" / "id_knoe_ed25519.pub"
pub_key = pub_key_path.read_text().strip() if pub_key_path.exists() else ""
username = self._get_input("init_password.db_username", "")
cmd = ["docker", "build"]
cmd.extend(get_docker_build_platform_args(env_key))
cmd += [
"--build-arg",
f"PROLE_USER={username}",
"--build-arg",
f"PROLE_SSH_PUB_KEY={pub_key}",
"-t",
image_name,
".",
]
rc = self._run_cmd(cmd, cwd=str(build_dir))
if rc == 0:
self._db_built_success = True
self.log("[OK] Build successful.")
push_ok = True
if env_key == "dev":
cluster_name = "knoe-dev-cluster"
self.log(f"Importing image to {cluster_name}...")
self._run_cmd(
["k3d", "image", "import", image_name, "-c", cluster_name]
)
else:
# For non-dev, tag and push to registry
db_cfg = self.knoe_cfg_data.get("Docker Build", {}) or {}
registry = (db_cfg.get("LOCAL_REGISTRY") or "").strip()
# Prioritize KNOE_IMAGE_REGISTRY from [Global] if set
global_registry = (self.knoe_cfg_data.get("Global", {}) or {}).get("KNOE_IMAGE_REGISTRY", "").strip()
if global_registry:
registry = global_registry
if env_key == "service" and (
not registry or registry == "localhost:5000"
):
server, _token = _resolve_k3s_connection_fn(
project_root=self.project_root,
cfg_path=self.cfg_path,
)
host = _host_from_url(server)
if host:
registry = f"{host}:5000"
db_cfg["LOCAL_REGISTRY"] = registry
self.knoe_cfg_data["Docker Build"] = db_cfg
if env_key == "service" and (
not registry or registry == "localhost:5000"
):
push_ok = False
self.err(
"[ERROR] k3s mode but registry host could not be resolved; cannot push knoe-db image."
)
self._db_built_success = False
else:
if not registry:
registry = "localhost:5000"
remote_tag = f"{registry}/{image_name}"
self.log(f"Tagging {image_name} as {remote_tag}...")
subprocess.run(["docker", "tag", image_name, remote_tag], check=True)
if _registry_image_ref_exists(remote_tag):
self.log(
f"[SKIP] {remote_tag} already exists in registry; skipping push."
)
else:
self.log(f"Pushing {remote_tag} to registry...")
if not _push_docker_image(remote_tag, log_fn=self.log):
push_ok = False
self.err(f"[ERROR] Failed to push image {remote_tag}")
self._db_built_success = False
self.knoe_cfg_data["Docker Build"]["STATUS"] = (
"Built" if self._db_built_success else "Failed"
)
def _step_cluster(self) -> None:
self.log("==> Cluster setup")
cluster_env = self._get_input("init_cluster.cluster_env", "dev")
env_key = _normalize_cluster_env(cluster_env)
self.knoe_cfg_data["Initialize Cluster"]["ENVIRONMENT"] = cluster_env
self.knoe_cfg_data["Initialize Cluster"]["K3S_SERVER_URL"] = self._get_input(
"init_cluster.k3s_server_url", ""
)
self.knoe_cfg_data["Initialize Cluster"]["K3S_TOKEN"] = _encrypt_cfg_secret(
self._get_input("init_cluster.k3s_token", "") or ""
)
self.knoe_cfg_data["Optional Features"]["SUPABASE_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.supabase_enabled", False)
)
self.knoe_cfg_data["Optional Features"]["KERBEROS_ENABLED"] = _bool_str(
self._get_input_bool("init_cluster.kerberos_enabled", False)
)
self.knoe_cfg_data["Optional Features"]["AT_REST_ENCRYPTION_ENABLED"] = (
_bool_str(
self._get_input_bool("init_cluster.at_rest_encryption_enabled", False)
)
)
mode = _deployment_mode_from_env(cluster_env)
if mode:
self._ensure_registry_defaults(mode)
if not self._get_input_bool(
"init_cluster.start_cluster",
DEFAULT_ACTION_FLAGS.get("init_cluster.start_cluster", True),
):
self.log("[SKIP] Cluster start disabled.")
return
if env_key == "dev":
if not self.controller.check_docker_running():
if platform.system() == "Darwin":
self.log("Starting Docker...")
self._run_cmd(["open", "-a", "Docker"])
for _ in range(30):
time.sleep(2)
if self.controller.check_docker_running():
break
if not self.controller.check_docker_running():
raise Exception("Docker is not running.")
cluster_name = "knoe-dev-cluster"
res = subprocess.run(
["k3d", "cluster", "list", "--no-headers"],
capture_output=True,
text=True,
)
res_stdout = res.stdout or ""
if self.reset_cluster:
self.log(f"[RESET] Deleting k3d cluster {cluster_name}...")
self._run_cmd(["k3d", "cluster", "delete", cluster_name])
res_stdout = ""
self.reset_cluster = False
if cluster_name not in res_stdout:
knoe_data = self._resolve_env_dir("PROLE_DATA", "data")
self._create_k3d_cluster(cluster_name, knoe_data)
else:
# Check if it's already running
if "running" not in res_stdout.lower():
self._run_cmd(["k3d", "cluster", "start", cluster_name])
else:
self.log(f"[OK] Cluster {cluster_name} is already running.")
self.log(f"[OK] Cluster ready: {cluster_name}")
try:
os.environ.pop("KUBECONFIG", None)
subprocess.run(
["kubectl", "config", "use-context", f"k3d-{cluster_name}"],
capture_output=True,
)
except Exception:
pass
self.inputs["init_cluster.app_cluster_kubecontext"] = f"k3d-{cluster_name}"
self.inputs["init_cluster.db_cluster_kubecontext"] = f"k3d-{cluster_name}"
self.inputs["env_setup.APP_CLUSTER_KUBECONTEXT"] = f"k3d-{cluster_name}"
self.inputs["env_setup.DB_CLUSTER_KUBECONTEXT"] = f"k3d-{cluster_name}"
else:
# Ensure we don't pin KUBECONFIG to a stale local file when another
# kubeconfig (e.g. Ansible-fetched client-cert auth) is available.
service_dir = self._get_input("env_setup.KNOE_SERVICE", "").strip()
candidates: list[Path] = []
kubeconfig_env = (os.environ.get("KUBECONFIG") or "").strip()
if kubeconfig_env:
try:
candidates.append(Path(kubeconfig_env).expanduser())
except Exception:
pass
# Prefer Ansible-fetched kubeconfig in the project root.
candidates.append(self.project_root / "knoe-k3s.kubeconfig")
# Then standard user kubeconfig.
candidates.append(Path.home() / ".kube" / "config")
# Then knoe-specific secret locations.
if service_dir:
candidates.append(Path(service_dir) / "secrets" / "k3s.kubeconfig")
candidates.append(resolve_knoe_home(env={}) / "secrets" / "k3s.kubeconfig")
candidates.append(self.project_root / "etc" / "secrets" / "k3s.kubeconfig")
selected = _select_existing_kubeconfig(candidates)
if selected:
os.environ["KUBECONFIG"] = str(selected)
self.log(f"Using kubeconfig: {selected}")
else:
# No local kubeconfig found try fetching from k3s via Ansible
fetched = self._fetch_k3s_kubeconfig()
if fetched:
os.environ["KUBECONFIG"] = str(fetched)
self.log(f"Using fetched kubeconfig: {fetched}")
if mode == "k8s":
gcp_cfg = self.gcp_cfg if isinstance(self.gcp_cfg, dict) else {}
project_id = str(
gcp_cfg.get("project_id")
or gcp_cfg.get("PROJECT_ID")
or self._get_input("init_cluster.project_id", "")
or ""
).strip()
if not project_id:
raise Exception("GCP project_id is required for dual-cluster GKE setup.")
app_cluster_name = self._app_cluster_name()
app_cluster_mode = self._app_cluster_mode()
app_machine_type = (
self._get_input("init_cluster.app_cluster_machine_type", "")
or DEFAULT_APP_CLUSTER_MACHINE_TYPE
).strip() or DEFAULT_APP_CLUSTER_MACHINE_TYPE
app_node_count = int(
(self._get_input("init_cluster.app_cluster_node_count", "") or str(DEFAULT_APP_CLUSTER_NODE_COUNT)).strip()
or str(DEFAULT_APP_CLUSTER_NODE_COUNT)
)
db_cluster_name = self._cnpg_cluster_name()
db_cluster_mode = self._db_cluster_mode()
db_node_count = int(
(self._get_input("init_cluster.db_cluster_node_count", "") or "3").strip() or "3"
)
db_machine_type = (
self._get_input("init_cluster.db_cluster_machine_type", "")
or DEFAULT_DB_CLUSTER_MACHINE_TYPE
).strip() or DEFAULT_DB_CLUSTER_MACHINE_TYPE
db_boot_disk_type = (
self._get_input("init_cluster.db_boot_disk_type", "")
or DEFAULT_DB_BOOT_DISK_TYPE
).strip() or DEFAULT_DB_BOOT_DISK_TYPE
db_boot_disk_size_gb = int(
(
self._get_input("init_cluster.db_boot_disk_size_gb", "")
or str(DEFAULT_DB_BOOT_DISK_SIZE_GB)
).strip()
or str(DEFAULT_DB_BOOT_DISK_SIZE_GB)
)
db_location = (
self._get_input("init_cluster.db_cluster_region", "")
or gcp_cfg.get("region")
or gcp_cfg.get("REGION")
or gcp_cfg.get("zone")
or gcp_cfg.get("ZONE")
or "us-central1"
)
app_location = (
self._get_input("init_cluster.app_cluster_region", "")
or gcp_cfg.get("region")
or gcp_cfg.get("REGION")
or db_location
)
app_spec = GkeClusterSpec(
name=app_cluster_name,
mode=app_cluster_mode,
location=str(app_location).strip(),
machine_type=app_machine_type,
node_count=max(1, app_node_count),
boot_disk_type=DEFAULT_DB_BOOT_DISK_TYPE,
boot_disk_size_gb=DEFAULT_DB_BOOT_DISK_SIZE_GB,
)
db_spec = GkeClusterSpec(
name=db_cluster_name,
mode=db_cluster_mode,
location=str(db_location).strip(),
machine_type=db_machine_type,
node_count=max(3, db_node_count),
node_pool_name="cnpg-db-pool",
boot_disk_type=db_boot_disk_type,
boot_disk_size_gb=db_boot_disk_size_gb,
)
self.log(f"[app-cluster {app_cluster_name}] validating cluster")
ensure_app_cluster(project_id=project_id, spec=app_spec, log=self.log)
self.log(f"[db-cluster {db_cluster_name}] ensuring Standard GKE cluster")
ensure_db_cluster(project_id=project_id, spec=db_spec, log=self.log)
app_ctx = get_cluster_credentials(
project_id=project_id,
cluster_name=app_cluster_name,
location=app_spec.location,
log=self.log,
)
db_ctx = get_cluster_credentials(
project_id=project_id,
cluster_name=db_cluster_name,
location=db_spec.location,
log=self.log,
)
self.inputs["init_cluster.app_cluster_kubecontext"] = app_ctx
self.inputs["init_cluster.db_cluster_kubecontext"] = db_ctx
self.inputs["env_setup.APP_CLUSTER_KUBECONTEXT"] = app_ctx
self.inputs["env_setup.DB_CLUSTER_KUBECONTEXT"] = db_ctx
self.knoe_cfg_data.setdefault("Global", {})["APP_CLUSTER_NAME"] = app_cluster_name
self.knoe_cfg_data.setdefault("Global", {})["APP_CLUSTER_MODE"] = app_cluster_mode
self.knoe_cfg_data.setdefault("Global", {})["APP_CLUSTER_KUBECONTEXT"] = app_ctx
self.knoe_cfg_data.setdefault("Global", {})["DB_CLUSTER_NAME"] = db_cluster_name
self.knoe_cfg_data.setdefault("Global", {})["DB_CLUSTER_MODE"] = db_cluster_mode
self.knoe_cfg_data.setdefault("Global", {})["DB_CLUSTER_KUBECONTEXT"] = db_ctx
# Back-compat default context points to app cluster for platform services.
self.knoe_cfg_data.setdefault("Global", {})["KUBECONTEXT"] = app_ctx
kubectl = subprocess.run(["which", "kubectl"], capture_output=True)
if kubectl.returncode != 0:
raise Exception(
"kubectl not found. Please install kubectl and configure access to the target cluster."
)
if env_key == "service":
if self.reset_cluster:
self.log("[RESET] Resetting k3s cluster (mode k3s)...")
self._cleanup_local_k3s_artifacts()
delete_cmd = [
"./ansible.sh",
"-p",
"infrastructure/playbooks/k3s_delete.yml",
]
self.log(f"Running: {' '.join(delete_cmd)}")
rc = self._run_cmd(delete_cmd)
if rc != 0:
raise Exception(f"k3s delete failed with code {rc}")
# 1) --reset should run the ansible k3s/tasks/cleanup on myrddin and then reinstall.
# This is handled by k3s_reset.yml
reset_cmd = [
"./ansible.sh",
"-p",
"infrastructure/playbooks/k3s_reset.yml",
"-l",
"myrddin.knoe.org",
] + vault_args
self.log(f"Running: {' '.join(reset_cmd)}")
rc = self._run_cmd(reset_cmd)
if rc != 0:
raise Exception(f"k3s reset failed with code {rc}")
# 2) Capture new k3s token + TLS bundle from init server and update vault
sync_token_cmd = [
"./ansible.sh",
"-p",
"infrastructure/playbooks/k3s_sync.yml",
"-l",
"myrddin.knoe.org",
] + vault_args
self.log(f"Running: {' '.join(sync_token_cmd)}")
rc = self._run_cmd(sync_token_cmd)
if rc != 0:
raise Exception(f"k3s sync (token) failed with code {rc}")
# 3) Reinstall agent nodes with the refreshed token
reset_agents_cmd = [
"./ansible.sh",
"-p",
"infrastructure/playbooks/k3s_reset.yml",
"-l",
"pi.knoe.org,retropie.knoe.org",
] + vault_args
self.log(f"Running: {' '.join(reset_agents_cmd)}")
rc = self._run_cmd(reset_agents_cmd)
if rc != 0:
raise Exception(f"k3s reset (agents) failed with code {rc}")
self.reset_cluster = False
if not (os.environ.get("KUBECONFIG") or "").strip():
kubeconfig_path = _find_kubeconfig_file()
if kubeconfig_path:
os.environ["KUBECONFIG"] = kubeconfig_path
self.log(f"Using local kubeconfig: {kubeconfig_path}")
kubeconfig_env = (os.environ.get("KUBECONFIG") or "").strip()
if kubeconfig_env and os.path.exists(kubeconfig_env):
cmd = ["kubectl", "--kubeconfig", kubeconfig_env, "cluster-info"]
else:
server = (
self._get_input("init_cluster.k3s_server_url", "") or ""
).strip()
token = (
self._get_input("init_cluster.k3s_token", "") or ""
).strip()
if token:
token = self._resolve_secret_value(token)
if server and token:
if not server.startswith("http"):
server = f"https://{server}"
cmd = [
"kubectl",
"--server=" + server,
"--token=" + token,
"--insecure-skip-tls-verify=true",
"cluster-info",
]
else:
raise Exception(
"K3s server URL/token missing and no kubeconfig available for service cluster."
)
else:
cmd = ["kubectl", "cluster-info"]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise Exception("Cluster is not reachable.")
self.log(f"[OK] Cluster ready: {env_key}")
if env_key == "service":
try:
registry_ns = self._registry_namespace()
env = self._script_env_for_namespace(self._service_namespace())
env["REGISTRY_NAMESPACE"] = registry_ns
self.log("==> Registry preflight")
registry_ops.update(
namespace=registry_ns,
env=env,
mode=env.get("KNOE_MODE"),
project_root=self.project_root,
log=self.log,
)
except Exception as e:
self.err(f"[WARN] Registry preflight failed: {e}")
# If this run was launched with a reset request, attempt to reclaim any
# strongly-matched stale Released PVs early, before later deploy/init
# steps that may depend on PVC scheduling.
if getattr(self, "_reset_reclaim_pvs_requested", False):
try:
service_ns = self._service_namespace()
env = self._script_env_for_namespace(service_ns)
self.log("==> Reset: reclaim stale Released PV claimRefs")
self._reset_reclaim_stale_pvs(env)
except Exception as e:
self.err(f"[WARN] Reset stale-PV reclaim failed: {e}")
self._reset_reclaim_pvs_requested = False
if self._openbao_init_env:
env = dict(self._openbao_init_env)
mode = _deployment_mode_from_env(
self._get_input("init_cluster.cluster_env", "")
)
if not mode:
if env_key == "dev":
mode = "k3d"
elif env_key == "service":
mode = "k3s"
elif env_key == "prod":
mode = "k8s"
env.setdefault("KNOE_MODE", mode or env_key)
self.log("==> OpenBao initialize (cluster)")
db_ns = (self._get_input("init_password.db_namespace", "") or "").strip()
if not db_ns:
db_ns = self._service_namespace()
openbao_ops.initialize(
namespace=db_ns,
env=env,
project_root=self.project_root,
mode=mode,
log=self.log,
)
# Materialize DB secrets in Kubernetes now that we have a cluster and a user-provided password.
# This prevents downstream init steps (e.g. CNPG) from failing due to missing secrets.
self.ensure_db_k8s_secrets(
db_ns,
self._openbao_init_password or "",
log_fn=self.log,
)
self._openbao_init_env = None
self._openbao_init_password = None
# Run repair pipeline if anomalies are detected on a ready cluster
try:
service_ns = self._service_namespace()
env = self._script_env_for_namespace(service_ns)
self._maybe_run_repair_pipeline(env)
except Exception:
pass
if env_key == "service":
self._deploy_common_services(env_key)
else:
self._deploy_opentofu(env_key)
def _deploy_common_services(self, env_key: str) -> None:
self.log("==> Common services deploy")
service_ns = self._service_namespace()
env = self._script_env_for_namespace(service_ns)
mode = env.get("KNOE_MODE") or self._deployment_mode()
manage_opentofu = str(mode).lower() != "k8s"
registry_ns = self._registry_namespace()
if not manage_opentofu:
self.log("[INFO] k8s mode: skipping OpenTofu checks/deploy in common services.")
try:
# Avoid deploying into a namespace that is currently being deleted.
self._ensure_namespace_ready(env, service_ns)
# Ensure node labels are present before init scripts that depend on them
# (e.g. node-role selectors and pinned local PV workloads).
try:
self._apply_ansible_node_labels(env)
except Exception:
pass
# Refuse to proceed when the cluster has hard prerequisites blocked
# (e.g. PV binding / scheduling issues). This prevents blind retries
# and surfaces actionable diagnostics.
self._reconcile_blocked_cluster_state(env=env, service_ns=service_ns)
def _status_safe(check_fn) -> bool:
try:
return bool(check_fn())
except Exception:
return False
status_checks = [
_status_safe(
lambda: registry_ops.status(
namespace=registry_ns,
env=env,
mode=mode,
)
),
_status_safe(
lambda: openbao_ops.status(
namespace=service_ns,
env=env,
mode=mode,
)
),
_status_safe(
lambda: garage_store_ops.status(
namespace=service_ns,
env=env,
)
),
]
if manage_opentofu:
status_checks.append(
_status_safe(
lambda: opentofu_ops.status(
namespace=service_ns,
env=env,
)
)
)
is_healthy = all(status_checks)
if is_healthy:
self.log(
f"[OK] Common services in {service_ns} are healthy. Skipping update."
)
return
self.log(
f"[INFO] Common services unhealthy or missing in {service_ns}. Attempting repair/deploy..."
)
try:
registry_ops.update(
namespace=registry_ns,
env=env,
mode=mode,
project_root=self.project_root,
log=self.log,
)
openbao_ops.update(
namespace=service_ns,
env=env,
mode=mode,
project_root=self.project_root,
log=self.log,
)
garage_store_ops.update(
namespace=service_ns,
env=env,
mode=mode,
project_root=self.project_root,
log=self.log,
)
if manage_opentofu:
opentofu_ops.update(
namespace=service_ns,
env=env,
mode=mode,
project_root=self.project_root,
log=self.log,
)
except Exception as e:
self.err(f"[ERROR] Common services deploy failed: {e}")
else:
# Re-validate after fix.
repair_checks = [
_status_safe(
lambda: registry_ops.status(
namespace=registry_ns,
env=env,
mode=mode,
)
),
_status_safe(
lambda: openbao_ops.status(
namespace=service_ns,
env=env,
mode=mode,
)
),
_status_safe(
lambda: garage_store_ops.status(
namespace=service_ns,
env=env,
)
),
]
if manage_opentofu:
repair_checks.append(
_status_safe(
lambda: opentofu_ops.status(
namespace=service_ns,
env=env,
)
)
)
repaired = all(repair_checks)
if not repaired:
self.err(
f"[ERROR] Common services still unhealthy after repair attempt."
)
else:
self.log(f"[OK] Common services in {service_ns} are now healthy.")
finally:
pass
def _apply_ansible_node_labels(self, env: dict) -> None:
"""Apply Ansible-defined node labels to the current cluster.
This makes node selection deterministic even when UI selections are stale
or missing.
"""
inv = (
(self.knoe_cfg_data.get("Network", {}) or {}).get("ANSIBLE_INVENTORY")
or ""
).strip()
if not inv:
return
inv_path = Path(inv)
if not inv_path.exists():
return
labels_by_host = _detect_ansible_node_labels(inv_path)
if not labels_by_host:
return
self.log("==> Applying Ansible node labels")
base_env = dict(os.environ)
base_env.update(env or {})
for host, labels in sorted(labels_by_host.items()):
if not labels:
continue
label_args = [f"{k}={v}" for k, v in sorted(labels.items())]
def _label(node_name: str) -> bool:
res = subprocess.run(
["kubectl", "label", "node", node_name, "--overwrite", *label_args],
capture_output=True,
text=True,
env=base_env,
)
if res.returncode == 0:
return True
return False
# Prefer FQDN node names, but retry with short hostname when needed.
if _label(host):
continue
if "." in host:
short = host.split(".", 1)[0]
if short and _label(short):
continue
self.err(f"[WARN] Failed to apply labels to node '{host}'.")
def _deploy_opentofu(self, env_key: str) -> None:
self.log("==> OpenTofu deploy")
ns = (
self._get_input("init_password.db_namespace", "") or ""
).strip() or "default"
env = self._script_env_for_namespace(ns)
try:
opentofu_ops.start(namespace=ns, env=env, mode=env.get("KNOE_MODE"), log=self.log)
finally:
pass
def _step_kerberos(self) -> None:
enabled = self._get_input_bool("kerberos_config.enabled", False)
if not enabled:
self.log("[SKIP] Kerberos disabled.")
return
realm = self._get_input("kerberos_config.realm", "")
kdc = self._get_input("kerberos_config.kdc", "")
user = self._get_input("kerberos_config.user", "")
password = self._get_input("kerberos_config.password", "")
self.knoe_cfg_data["Kerberos Authentication"]["ENABLED"] = _bool_str(enabled)
self.knoe_cfg_data["Kerberos Authentication"]["REALM"] = realm
self.knoe_cfg_data["Kerberos Authentication"]["KDC"] = kdc
self.knoe_cfg_data["Kerberos Authentication"]["SERVER"] = kdc
self.knoe_cfg_data["Kerberos Authentication"]["USER"] = user
self.knoe_cfg_data["Kerberos Authentication"]["PASSWORD"] = password
self.knoe_cfg_data["Kerberos Authentication"]["AD_PORT_FORWARD"] = (
os.environ.get("KRB5_AD_PORT_FORWARD", "1")
)
self.knoe_cfg_data["Kerberos Authentication"]["AD_TCP_PORTS"] = os.environ.get(
"KRB5_AD_TCP_PORTS", "88 389 445 464 636"
)
self.knoe_cfg_data["Kerberos Authentication"]["AD_UDP_PORTS"] = os.environ.get(
"KRB5_AD_UDP_PORTS", "88 464"
)
self.knoe_cfg_data["Kerberos Authentication"]["AD_PROXY_HOST_NETWORK"] = (
os.environ.get("KRB5_AD_PROXY_HOST_NETWORK", "1")
)
self.knoe_cfg_data["Kerberos Authentication"]["AD_PROXY_IMAGE"] = (
os.environ.get("KRB5_AD_PROXY_IMAGE", "alpine/socat")
)
self.knoe_cfg_data["Kerberos Authentication"]["AD_PROXY_SERVICE"] = (
os.environ.get("KRB5_AD_SERVICE_NAME", "knoe-kerberos-ad-dc")
)
ns = (
self._get_input("init_password.db_namespace", "") or ""
).strip() or "default"
env = self._script_env_for_namespace(ns)
env["KERBEROS_ENABLED"] = _bool_str(enabled)
env["ENABLED"] = env["KERBEROS_ENABLED"]
env["KRB5_REALM"] = realm
if env.get("KNOE_MODE") == "k3s":
env.setdefault("KRB5_AD_PORT_FORWARD", "0")
if not self._get_input_bool(
"kerberos_config.test_connection",
DEFAULT_ACTION_FLAGS.get("kerberos_config.test_connection", False),
):
self.log("[SKIP] Kerberos test disabled.")
return
# Check if already tested successfully
if (
self.knoe_cfg_data.get("Kerberos Authentication", {}).get("TEST_STATUS")
== "Success"
):
self.log("[OK] Kerberos already tested successfully. Skipping.")
return
if not (realm and user and password and kdc):
self.err("[WARN] Kerberos test skipped: missing realm/user/password/kdc.")
return
self.log("==> Kerberos test: init_kerberos.sh test")
rc2 = self._run_script("init_kerberos.sh", args=["test"], env=env)
if rc2 != 0:
self.err(f"[ERROR] Kerberos test failed (code {rc2})")
self.knoe_cfg_data.setdefault("Kerberos Authentication", {})[
"TEST_STATUS"
] = "Failed"
else:
self.log("[OK] Kerberos test completed successfully.")
self.knoe_cfg_data.setdefault("Kerberos Authentication", {})[
"TEST_STATUS"
] = "Success"
def _step_init_scripts(self) -> None:
if not self._get_input_bool(
"init_scripts.run_scripts",
DEFAULT_ACTION_FLAGS.get("init_scripts.run_scripts", True),
):
self.log("[SKIP] Init scripts disabled.")
self.knoe_cfg_data["Initialization Scripts"]["STATUS"] = "Skipped"
return
if (
self.knoe_cfg_data.get("Initialization Scripts", {}).get("STATUS")
== "Finished"
):
self.log("[OK] Initialization scripts already finished.")
return
self.log("==> Initialization scripts")
mode = self._deployment_mode()
db_ns = self._secret_namespace()
if mode == "k8s" and db_ns == "default":
db_ns = "knoe-db-0"
service_ns = self._service_namespace()
db_env = self._script_env_for_namespace(db_ns, cluster_role="db")
app_env = self._script_env_for_namespace(service_ns, cluster_role="app")
opt_allowed, _opt_count, opt_reason = self._optional_workloads_policy(app_env)
password = self._get_input("init_password.db_password", "")
kerberos_enabled = self._get_input_bool("kerberos_config.enabled", False)
pre_cnpg_steps = [
("init_certmgr.sh", ["initialize"], False),
]
if kerberos_enabled:
pre_cnpg_steps.append(("init_kerberos.sh", ["initialize"], False))
post_cnpg_steps: list[tuple[str, list[str], bool, str]] = [
("init_cnpg_backup.sh", ["start"], False, "db"),
("init_kong.sh", ["start"], False, "app"),
]
if not opt_allowed:
self.log(f"[SKIP] Monitoring disabled by policy: {opt_reason}")
self.knoe_cfg_data.setdefault("Monitoring", {})["STATUS"] = "Skipped"
if mode != "k3d":
post_cnpg_steps.append(("init_nginx_ingress.sh", ["initialize"], False, "app"))
overall_success = True
# Common services (Python owners) — replaces init_common_services.sh
self.log(
f"[app-cluster {self._app_cluster_name()} context={app_env.get('KUBECONTEXT','')}] "
f"common services namespace={service_ns}"
)
try:
registry_ns = str(app_env.get("REGISTRY_NAMESPACE") or service_ns).strip() or service_ns
registry_ops.update(namespace=registry_ns, env=app_env, mode=mode, log=self.log)
openbao_ops.update(namespace=service_ns, env=app_env, mode=mode, log=self.log)
garage_store_ops.update(namespace=service_ns, env=app_env, mode=mode, log=self.log)
opentofu_ops.update(namespace=service_ns, env=app_env, mode=mode, log=self.log)
except Exception as e:
self.err(f"[ERROR] common services (python owners) failed: {e}")
overall_success = False
# Run pre-CNPG shell scripts (cert-manager, optional kerberos)
for script, args, needs_password in pre_cnpg_steps:
self.log(f"--> {script} {' '.join(args)}")
stdin_text = f"{password}\n" if needs_password else None
rc = self._run_script(
script,
args=args,
env=db_env,
stdin_text=stdin_text,
on_line=self._process_script_output_line,
)
if rc != 0:
self.err(f"[ERROR] {script} failed (code {rc})")
overall_success = False
# CNPG initialization (Python) — replaces init_cloudnative_pg.sh initialize
if overall_success:
self.log(
f"[db-cluster {self._cnpg_cluster_name()} context={db_env.get('KUBECONTEXT','')}] "
f"cnpg_initialize namespace={db_ns}"
)
try:
self.ensure_db_k8s_secrets(db_ns, password, log_fn=self.log)
except Exception as e:
self.err(f"[ERROR] Failed to ensure DB secrets before CNPG init: {e}")
overall_success = False
if overall_success:
if self._cluster_storage_milestone_enabled():
try:
self._ensure_cnpg_storage_provisioned(db_ns, db_env)
except Exception as e:
self.err(f"[WARN] Failed to provision CNPG storage before init: {e}")
else:
self.log(
"[SKIP] Cluster storage milestone shelved; "
"retaining utility workflows only."
)
try:
cnpg_initialize(
namespace=db_ns,
cluster_name=str(db_env.get("CLUSTER_NAME") or db_env.get("CNPG_CLUSTER_NAME") or "knoe-db").strip(),
env=db_env,
project_root=self.project_root,
log=self.log,
mode=mode,
)
except Exception as e:
self.err(f"[ERROR] CNPG initialization failed: {e}")
overall_success = False
# Run post-CNPG shell scripts (backup, kong, ingress)
if overall_success:
for script, args, needs_password, role in post_cnpg_steps:
self.log(f"--> {script} {' '.join(args)}")
stdin_text = f"{password}\n" if needs_password else None
target_env = db_env if role == "db" else app_env
rc = self._run_script(
script,
args=args,
env=target_env,
stdin_text=stdin_text,
on_line=self._process_script_output_line,
)
if rc != 0:
self.err(f"[ERROR] {script} failed (code {rc})")
overall_success = False
if overall_success and opt_allowed:
self.log(f"[app-cluster {self._app_cluster_name()}] monitoring (python owner) initialize")
try:
monitoring_ns = str(app_env.get("MONITORING_NAMESPACE") or "monitoring").strip() or "monitoring"
monitoring_ops.initialize(
namespace=monitoring_ns,
env=app_env,
mode=mode,
log=self.log,
)
except Exception as e:
self.err(f"[ERROR] monitoring initialization failed: {e}")
overall_success = False
self._scripts_success = overall_success
# Verify critical secrets
self.log(f"==> Verifying critical secrets in namespace {db_ns}")
critical_secrets = ["knoe-db-user", "knoe-db-superuser", "cnpg-admin-key"]
missing_secrets = []
for secret in critical_secrets:
rc_s = self._run_cmd(
["kubectl", "get", "secret", secret, "-n", db_ns],
env=db_env,
)
if rc_s != 0:
missing_secrets.append(secret)
if missing_secrets:
self.err(
f"[CRITICAL] Missing secrets in namespace '{db_ns}': {', '.join(missing_secrets)}"
)
self.err("Database initialization will fail without these secrets.")
self._scripts_success = False
self.knoe_cfg_data["Initialization Scripts"]["STATUS"] = (
"Completed" if self._scripts_success else "Attempted"
)
def _step_cnpg_deploy(self) -> None:
if not self._get_input_bool(
"init_cnpg_deploy.run_deploy",
DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.run_deploy", True),
):
self.log("[SKIP] CnPG deploy disabled.")
self.knoe_cfg_data["Deployment"]["STATUS"] = "Skipped"
return
db_cluster = self._cnpg_cluster_name()
self.log(f"==> [db-cluster {db_cluster}] Deploy CloudNativePG")
ns = self._secret_namespace()
if self._deployment_mode() == "k8s" and ns == "default":
ns = "knoe-db-0"
env = self._script_env_for_namespace(ns, cluster_role="db")
cluster_name = str(
env.get("CLUSTER_NAME")
or env.get("CNPG_CLUSTER_NAME")
or self._cnpg_cluster_name()
).strip()
if not cluster_name:
cluster_name = "knoe-db"
mode = self._deployment_mode()
if self._cluster_storage_milestone_enabled():
try:
self._ensure_cnpg_storage_provisioned(ns, env)
except Exception as exc:
self._cnpg_success = False
self.knoe_cfg_data["Deployment"]["STATUS"] = "Attempted"
self.err(f"[ERROR] CNPG pre-provisioning failed: {exc}")
return
else:
self.log(
"[SKIP] Cluster storage milestone shelved; "
"retaining utility workflows only."
)
try:
self.log(f"[db-cluster {db_cluster}] applying knoe-db cluster")
cnpg_deploy(
namespace=ns,
cluster_name=cluster_name,
env=env,
project_root=self.project_root,
log=self.log,
mode=mode,
)
self._cnpg_success = True
self.knoe_cfg_data["Deployment"]["STATUS"] = "Deployed"
except Exception as exc:
self._cnpg_success = False
self.knoe_cfg_data["Deployment"]["STATUS"] = "Attempted"
self.err(f"[ERROR] CnPG deploy failed: {exc}")
if self._get_input_bool(
"init_cnpg_deploy.force_rollout",
DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.force_rollout", False),
):
# Only attempt rollout if there are existing CNPG pods; otherwise this is a fresh install
rc_check = subprocess.run(
[
"kubectl", "get", "pods",
"-n", ns, "-l", f"cnpg.io/cluster={cluster_name}",
"--no-headers",
],
capture_output=True, text=True, env=env,
)
has_pods = bool((rc_check.stdout or "").strip())
if has_pods:
self.log(f"==> [db-cluster {db_cluster}] Force rollout")
try:
cnpg_rollout(
namespace=ns,
cluster_name=cluster_name,
env=env,
log=self.log,
)
except Exception as exc2:
self.err(f"[ERROR] Rollout failed: {exc2}")
else:
self.log(
"[SKIP] Force rollout skipped — no existing CNPG pods (new installation)."
)
def _step_supabase(self) -> None:
if not self._get_input_bool("init_cluster.supabase_enabled", False):
self.log("[SKIP] Supabase deploy disabled.")
self.knoe_cfg_data["Supabase"] = {"STATUS": "Skipped"}
return
self.log("==> Deploy Supabase")
ns = (
self._get_input("init_password.db_namespace", "") or ""
).strip() or "default"
env = self._script_env_for_namespace(ns)
opt_allowed, _opt_count, opt_reason = self._optional_workloads_policy(env)
if not opt_allowed:
self.log(f"[SKIP] Supabase deploy disabled by policy: {opt_reason}")
self.knoe_cfg_data["Supabase"] = {"STATUS": "Skipped"}
self._supabase_success = False
return
script_path = self.project_root / "supabase" / "deploy.sh"
if not script_path.exists():
self.err(f"[ERROR] Supabase deploy script not found: {script_path}")
self._supabase_success = False
self.knoe_cfg_data["Supabase"] = {"STATUS": "Attempted"}
return
mode = (
os.environ.get("SUPABASE_DEPLOY_MODE")
or os.environ.get("SUPABASE_MODE")
or ""
).strip()
if not mode:
# Match cluster environment to supabase deploy mode
cluster_env = _normalize_cluster_env(
self._get_input("init_cluster.cluster_env", "dev")
)
if cluster_env == "dev":
mode = "k3d"
elif cluster_env in ("service", "prod"):
mode = "k8s"
else:
mode = "k3d"
args = ["--mode", mode]
cfg_path = None
if self.cfg_path and self.cfg_path.exists():
cfg_path = self.cfg_path
else:
conf_dir = knoe_conf_mgr.resolve_knoe_conf_dir(self.project_root)
candidate = knoe_conf_mgr.entrypoint_path(conf_dir)
if candidate.exists():
cfg_path = candidate
if cfg_path:
args.extend(["-c", str(cfg_path)])
if _parse_bool(os.environ.get("SUPABASE_USE_DEV_COMPOSE"), False):
args.append("--with-dev-helpers")
if _parse_bool(os.environ.get("SUPABASE_FOREGROUND"), False):
args.append("--foreground")
self.log(f"--> supabase/deploy.sh {' '.join(args)}")
rc = self._run_cmd(["bash", str(script_path)] + args, env=env)
if rc == 0:
self._supabase_success = True
self.knoe_cfg_data["Supabase"] = {"STATUS": "Deployed"}
else:
self._supabase_success = False
self.knoe_cfg_data["Supabase"] = {"STATUS": "Attempted"}
self.err(f"[ERROR] Supabase deploy failed (code {rc})")
def _finalize_secrets(self) -> None:
ns = (
self._get_input("init_password.db_namespace", "") or ""
).strip() or "default"
if not ns or ns == "default":
ns = self._get_input("env_setup.DATABASE_NAMESPACE", "") or "default"
if not ns or ns == "default":
ns = self._get_input("env_setup.NAMESPACE", "") or "default"
p1 = self._get_input("init_password.db_password", "")
if p1.startswith("${"):
self.log(
f"[OK] Secrets already finalized for namespace {ns}. Skipping build-a-bao."
)
return
self.log("==> OpenBao: finalize secrets")
env = self._script_env_for_namespace(ns)
rc = self._run_script("build-a-bao.sh", env=env)
if rc == 0:
self._secrets_finalized = True
self.log("[OK] Secrets saved to OpenBao and knoe.cfg updated.")
else:
self.err(f"[ERROR] build-a-bao.sh failed (code {rc})")
def _prepare_opentofu_pipeline(self) -> None:
env_key = _normalize_cluster_env(
self._get_input("init_cluster.cluster_env", "")
)
mode = _deployment_mode_from_env(self._get_input("init_cluster.mode", ""))
if not mode:
mode = _deployment_mode_from_env(os.environ.get("KNOE_MODE", ""))
# OpenTofu pipeline is k3s-scoped, but we still want it available for k3d
# installs when the user provides k3s connection settings (e.g., staging
# manifests for a service cluster while running a local dev cluster).
if env_key != "service" and mode not in ("k3d", "k3s"):
return
namespace = (
self._get_input("init_password.db_namespace", "") or ""
).strip()
if not namespace:
namespace = (
self._get_input("env_setup.DATABASE_NAMESPACE", "") or ""
).strip()
if not namespace:
namespace = (
self._get_input("env_setup.NAMESPACE", "") or ""
).strip()
if not namespace:
namespace = "default"
k3s_server = (self._get_input("init_cluster.k3s_server_url", "") or "").strip()
k3s_token = (self._get_input("init_cluster.k3s_token", "") or "").strip()
if k3s_token:
k3s_token = self._resolve_secret_value(k3s_token)
if k3s_server and not k3s_server.startswith("http"):
k3s_server = f"https://{k3s_server}"
if not k3s_server:
self.err("[WARN] OpenTofu pipeline skipped: missing k3s server URL.")
return
if not k3s_token:
self.err("[WARN] OpenTofu pipeline missing k3s token; writing empty token.")
pipeline_dir = _sync_opentofu_pipeline(
self.project_root, namespace, k3s_server, k3s_token
)
self.knoe_cfg_data["Deployment"]["OPENTOFU_PIPELINE_DIR"] = str(pipeline_dir)
self.log(f"[OK] OpenTofu pipeline prepared at {pipeline_dir}")
def _create_k3d_cluster(self, cluster_name: str, knoe_data: Path) -> None:
volume_args = _k3d_knoe_data_volume_args(str(knoe_data))
reg_args = []
if _local_registry_enabled("k3d"):
info = self._ensure_local_registry_available()
if info:
_host_registry, cluster_registry = info
reg_use = cluster_registry or ""
if reg_use.endswith(".localhost:5000"):
reg_use = reg_use.replace(".localhost:5000", ":5000")
elif reg_use.endswith(".localhost"):
reg_use = reg_use[: -len(".localhost")]
if reg_use:
reg_args = ["--registry-use", reg_use]
cmd = (
["k3d", "cluster", "create", cluster_name, "-a", "2"]
+ volume_args
+ reg_args
+ ["--api-port", "0.0.0.0:6443"]
)
rc = self._run_cmd(cmd)
if rc == 0:
return
if platform.system() == "Darwin":
fallback = resolve_knoe_home(env={}) / "data"
try:
if knoe_data.resolve() == fallback.resolve():
raise Exception("k3d cluster create failed.")
except Exception:
# If path resolution fails, still try the fallback
pass
self.err(
f"[WARN] k3d create failed with {knoe_data}; retrying with {fallback}..."
)
rc = self._run_cmd(
["k3d", "cluster", "create", cluster_name, "-a", "2"]
+ _k3d_knoe_data_volume_args(str(fallback))
+ reg_args
+ ["--api-port", "0.0.0.0:6443"]
)
if rc == 0:
# Persist fallback for the rest of the run
self.inputs["env_setup.PROLE_DATA"] = str(fallback)
return
raise Exception(f"Failed to create k3d cluster {cluster_name} (code {rc})")
def _perform_cluster_reset(self) -> None:
if not self.reset_requested:
return
cluster_env = self._get_input("init_cluster.cluster_env", "dev")
env_key = _normalize_cluster_env(cluster_env)
if env_key == "dev":
self.log("==> Cluster reset (k3d)")
if not self.controller.check_docker_running():
if platform.system() == "Darwin":
self.log("Starting Docker...")
self._run_cmd(["open", "-a", "Docker"])
for _ in range(30):
time.sleep(2)
if self.controller.check_docker_running():
break
if not self.controller.check_docker_running():
raise Exception("Docker is not running.")
cluster_name = "knoe-dev-cluster"
rc = self._run_cmd(["k3d", "cluster", "delete", cluster_name])
if rc != 0:
self.err(
f"[WARN] Failed to delete k3d cluster {cluster_name} (code {rc}); continuing."
)
knoe_data = self._resolve_env_dir("PROLE_DATA", "data")
self._create_k3d_cluster(cluster_name, knoe_data)
elif env_key in ("service", "knoe-service-cluster", "k3s"):
self.log("==> Cluster reset (k3s namespace cleanup)")
db_ns = self._get_input("env_setup.DATABASE_NAMESPACE", "")
if not db_ns:
db_ns = self._get_input("env_setup.NAMESPACE", "")
if not db_ns:
db_ns = "default"
db_ns = db_ns.strip() or "default"
service_ns = self._service_namespace().strip() or "knoe-system"
monitoring_ns = (
((self.knoe_cfg_data.get("Global", {}) or {}).get("MONITORING_NAMESPACE", "") or "").strip()
or (os.environ.get("MONITORING_NAMESPACE") or "").strip()
or "monitoring"
)
cnpg_ns = (
((self.knoe_cfg_data.get("Global", {}) or {}).get("CNPG_NAMESPACE", "") or "").strip()
or (os.environ.get("CNPG_NAMESPACE") or "").strip()
or "cnpg-system"
)
argocd_ns = self._argocd_namespace().strip() or "argocd"
registry_ns = service_ns
reset_namespaces: list[str] = []
for candidate in [db_ns, service_ns, monitoring_ns, cnpg_ns, argocd_ns, "default"]:
ns = (candidate or "").strip()
if not ns or (ns.startswith("${") and ns.endswith("}")):
continue
if ns not in reset_namespaces:
reset_namespaces.append(ns)
server = self._get_input("init_cluster.k3s_server_url", "")
token = self._get_input("init_cluster.k3s_token", "")
# For remote k3s clusters the config token is typically a *join token*
# (e.g. `K10...::server:...`) which cannot be used to authenticate kubectl.
# Ensure we have a cert-based kubeconfig (or a valid bearer token config)
# before invoking any kubectl-based reset scripts.
self._ensure_project_k3s_kubeconfig(server, token)
if self.delete_db_requested:
self.log(f"[RESET] Namespaces to clean: {', '.join(reset_namespaces)}")
self.log(
f"[RESET] --delete-db enabled; database PVCs in namespace '{db_ns}' will be deleted."
)
else:
self.log(
f"[RESET] Namespaces to clean (PVCs preserved): {', '.join(reset_namespaces)}"
)
self.log(
f"[RESET] Preserving registry workload in namespace: {registry_ns}"
)
_reset_k3s_namespace(
self.project_root,
reset_namespaces,
server,
token,
registry_namespace=registry_ns,
clear_node_reservations=True,
delete_pvcs_namespaces=[db_ns] if self.delete_db_requested else None,
force_delete_bound_pvs=self.delete_db_requested,
)
self._cleanup_local_k3s_artifacts()
self.reset_requested = False
self.delete_db_requested = False
def _cleanup_local_k3s_artifacts(self) -> None:
paths = [
self.project_root / "etc" / "secrets" / "k3s.kubeconfig",
resolve_knoe_home(env={}) / "secrets" / "k3s.kubeconfig",
]
for path in paths:
try:
if path.exists():
path.unlink()
self.log(f"[RESET] Removed local kubeconfig {path}")
except Exception as exc:
self.err(f"[WARN] Failed to remove {path}: {exc}")
def _ensure_project_k3s_kubeconfig(self, server: str, token: str) -> None:
server = (server or "").strip()
token = (token or "").strip()
target = self.project_root / "knoe-k3s.kubeconfig"
backup = target.with_name(target.name + ".bak")
def _looks_cert_based(path: Path) -> bool:
try:
txt = path.read_text(encoding="utf-8")
except Exception:
return False
return "client-certificate-data" in txt
def _looks_like_join_token_cfg(path: Path) -> bool:
try:
txt = path.read_text(encoding="utf-8")
except Exception:
return False
# k3s join tokens include `::server:`; they are not Kubernetes bearer tokens.
return "token:" in txt and "::server:" in txt and "client-certificate-data" not in txt
# If we already have a kubeconfig, keep it unless it is clearly an invalid join-token config.
if target.exists() and not _looks_like_join_token_cfg(target):
return
# Prefer restoring a known-good cert-based kubeconfig.
if (not target.exists() or _looks_like_join_token_cfg(target)) and backup.exists() and _looks_cert_based(backup):
try:
target.write_text(backup.read_text(encoding="utf-8"), encoding="utf-8")
os.chmod(target, 0o600)
self.log(f"[OK] Restored kubeconfig from backup: {target}")
return
except Exception as exc:
self.err(f"[WARN] Failed to restore kubeconfig from {backup}: {exc}")
# Next best: fetch a fresh cert-based kubeconfig via Ansible.
fetched = self._fetch_k3s_kubeconfig()
if fetched and target.exists() and not _looks_like_join_token_cfg(target):
return
# Final fallback: only generate a token-based kubeconfig if the token actually looks like a
# Kubernetes bearer token. (k3s join tokens are not usable for kubectl auth.)
if server and token and _looks_like_k8s_bearer_token(token):
try:
generated = _write_k3s_kubeconfig(server, token)
if generated.exists() and generated != target:
target.write_text(generated.read_text(encoding="utf-8"), encoding="utf-8")
os.chmod(target, 0o600)
self.log(f"[OK] Wrote token-based kubeconfig: {target}")
except Exception as exc:
self.err(f"[WARN] Failed to generate token-based kubeconfig: {exc}")
else:
self.err(
"[WARN] No usable kubeconfig available for remote k3s; please fetch/create a cert-based knoe-k3s.kubeconfig."
)
def _close_log_file(self):
"""Restore original stdout/stderr and close the log file."""
if self._log_file:
sys.stdout = sys.__stdout__
sys.stderr = sys.__stderr__
try:
self._log_file.close()
except Exception:
pass
self._log_file = None
def _prompt_for_master_password(self) -> str:
"""Prompt user for master password in silent mode with a 10-minute timeout."""
import sys
import time
import select
import termios
import tty
# Use sys.__stdout__ and sys.__stdin__ to ensure we bypass any redirection
out = sys.__stdout__
inp = sys.__stdin__
# ANSI color codes
GREEN = "\033[32m"
RED = "\033[31m"
RESET = "\033[0m"
BOLD = "\033[1m"
CHECK = "\u2713"
out.write("\n" + "=" * 60 + "\n")
out.write(f"{BOLD}DATABASE MASTER PASSWORD REQUIRED{RESET}\n")
out.write("A master password is required to initialize the database cluster.\n")
out.write("You are running in silent mode (-S) and no valid password was found.\n")
out.write(f"Please provide it now. Action timer: {BOLD}10 minutes (600s){RESET}.\n")
out.write("=" * 60 + "\n\n")
out.flush()
timeout = 600
start_time = time.time()
def get_input_with_timeout(prompt, mask=True):
out.write(prompt)
out.flush()
if not inp.isatty():
# Fallback for non-TTY environments if necessary, but requirement says "prompt"
# In non-TTY, select might still work on stdin.
pass
fd = inp.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setcbreak(fd)
res = ""
while True:
elapsed = time.time() - start_time
if elapsed >= timeout:
out.write(
f"\n{RED}[ERROR] Timeout waiting for password input (600s). Cannot proceed.{RESET}\n"
)
out.flush()
sys.exit(1)
rlist, _, _ = select.select([inp], [], [], 1.0)
if rlist:
char = inp.read(1)
if char in ("\n", "\r"):
out.write("\n")
out.flush()
return res
elif char == "\x7f": # Backspace
if res:
res = res[:-1]
if mask:
out.write("\b \b")
out.flush()
elif char == "\x03": # Ctrl+C
out.write(f"\n{RED}Installation aborted by user.{RESET}\n")
out.flush()
sys.exit(1)
else:
res += char
if mask:
out.write("*")
out.flush()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
while True:
p1 = get_input_with_timeout("Enter Master Password: ")
p2 = get_input_with_timeout("Confirm Master Password: ")
if p1 and p1 == p2:
out.write(f"{GREEN}{CHECK} Password accepted.{RESET}\n\n")
out.flush()
return p1
elif not p1:
out.write(f"{RED}Password cannot be empty.{RESET}\n")
out.flush()
else:
out.write(
f"{RED}Passwords do not match. Please try again.{RESET}\n"
)
out.flush()
def _load_db_password_from_1password(self, *, log_found: bool = True) -> str:
try:
from knoe.core.onepassword import get_secret, op_available
if not op_available():
return ""
password = get_secret("administrator", "password").strip()
if password and log_found:
self.log("[CONFIG] Loaded DB master password from 1Password knoey vault.")
return password
except Exception:
return ""
def _persist_db_password_to_1password(self, password: str) -> None:
if not password:
raise RuntimeError("Cannot persist an empty database master password.")
try:
from knoe.core.onepassword import set_secret, op_available
if not op_available():
self.log("[WARN] 1Password CLI not available; skipping password persistence.")
return
set_secret("administrator", "password", password)
self.log("[CONFIG] Persisted DB master password to 1Password knoey vault.")
except Exception as e:
raise RuntimeError(f"Failed to persist database master password to 1Password: {e}") from e
def run(self) -> int:
_configure_unbuffered_io()
self.log(f"[CONFIG] Using {self.cfg_path}")
try:
# Merge caller-provided inputs with defaults and config-file values.
# Caller inputs (self.inputs) take precedence to support programmatic overrides
# in automated pipelines (like knoe.deploy_pipeline).
self.inputs = {
**self._default_inputs(),
**self._load_inputs_from_cfg(),
**self.inputs
}
# Silent mode: ensure we have a master password source before proceeding.
# If the value is an OpenBao reference or an encrypted `${KNOE_SECRET:...}`
# placeholder, allow downstream secret-management steps to resolve it.
# Only prompt/generate when the value is truly missing.
db_pw = (self._get_input("init_password.db_password", "") or "").strip()
env_db_pw = (
os.environ.get("KNOE_DB_PASSWORD")
or os.environ.get("DB_PASSWORD")
or ""
).strip()
# If runtime env provides a password, reconcile with 1Password for
# consistency and bootstrap when missing.
if db_pw and env_db_pw and db_pw == env_db_pw:
vault_pw = self._load_db_password_from_1password(log_found=False)
if vault_pw and vault_pw != db_pw:
self.log(
"[WARN] Runtime DB password differs from 1Password value; "
"using 1Password value for consistency."
)
self.inputs["init_password.db_password"] = vault_pw
self.inputs["init_password.db_password_confirm"] = vault_pw
db_pw = vault_pw
elif not vault_pw:
self._persist_db_password_to_1password(db_pw)
self.log(
"[CONFIG] Bootstrapped 1Password administrator from KNOE_DB_PASSWORD/DB_PASSWORD."
)
if not db_pw:
vault_pw = self._load_db_password_from_1password()
if vault_pw:
self.inputs["init_password.db_password"] = vault_pw
self.inputs["init_password.db_password_confirm"] = vault_pw
db_pw = vault_pw
needs_prompt = not bool(db_pw)
if needs_prompt:
# In silent installs, never block on an interactive prompt in environments
# that cannot provide input (CI/pytest/non-interactive runners).
stdin_is_tty = getattr(sys.__stdin__, "isatty", lambda: False)()
stdout_is_tty = getattr(sys.__stdout__, "isatty", lambda: False)()
running_under_pytest = "PYTEST_CURRENT_TEST" in os.environ
running_in_ci = bool(os.environ.get("CI"))
allow_prompt = (
stdin_is_tty
and stdout_is_tty
and not running_under_pytest
and not running_in_ci
)
if allow_prompt:
new_pw = self._prompt_for_master_password()
self._persist_db_password_to_1password(new_pw)
self.log(
"[CONFIG] Master password captured interactively and saved to 1Password."
)
else:
raise RuntimeError(
"Database master password is missing and prompting is unavailable. "
"Run once in an interactive terminal to bootstrap the 1Password knoey vault, "
"or provide KNOE_DB_PASSWORD/DB_PASSWORD."
)
self.inputs["init_password.db_password"] = new_pw
self.inputs["init_password.db_password_confirm"] = new_pw
# Save immediately to knoe.cfg so subsequent steps/scripts see it.
self._write_cfg()
# Synchronize controller state
self.controller.state.inputs = self.inputs
self.controller.state.config_data = self.knoe_cfg_data
# Delegate config writing to the installer (allows milestones to persist changes)
self.controller.write_config = self._write_cfg
except Exception as e:
self.err(f"[FATAL] {e}")
self._close_log_file()
return 2
self._write_cfg()
self._perform_cluster_reset()
milestones = [
DependenciesMilestone(),
NetworkScanMilestone(),
EnvSetupMilestone(),
SecretManagementMilestone(),
DockerBuildMilestone(),
DatabaseCreationMilestone(),
ClusterLifecycleMilestone(),
InitializationScriptsMilestone(),
KerberosMilestone(),
GitOpsMilestone(),
SupabaseImagePreloadMilestone(),
SupabaseMilestone(),
DeploymentMilestone(),
SecurityMilestone(),
]
try:
self.controller.run_milestones(
milestones,
progress_callback=lambda msg, p: self.log(f"[{p*100:0.0f}%] {msg}"),
)
# Sync back results
self.knoe_cfg_data = self.controller.state.config_data
self.knoe_cfg_data["Install"]["STATUS"] = "Finished"
self._write_cfg()
self.log("[DONE] Silent install completed.")
self._close_log_file()
return 0
except Exception as e:
self.err(f"[FATAL] {e}")
try:
self.knoe_cfg_data["Install"]["STATUS"] = "Failed"
self._write_cfg()
except Exception:
pass
self._close_log_file()
return 2
# ------------------------------------------------------------------
# Repair / update paths
# ------------------------------------------------------------------
def _validate_cluster_state(self, env: dict) -> dict[str, list[str]]:
"""Compare configured intent with live cluster resources.
Returns a dict of anomaly category → list of description strings.
Only kubectl is used so the check is non-destructive.
"""
anomalies: dict[str, list[str]] = {}
try:
supabase_ns = (os.environ.get("SUPABASE_NAMESPACE") or "").strip() or "supabase"
kube_env = {k: v for k, v in env.items()}
def _kubectl(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["kubectl", *args],
capture_output=True, text=True, env=kube_env, timeout=30,
)
# --- Supabase ---
if self._get_input_bool("init_cluster.supabase_enabled", False):
studio_enabled = self._get_input_bool("init_cluster.supabase_studio_enabled", False)
r = _kubectl("get", "deployment", "supabase-studio", "-n", supabase_ns, "--ignore-not-found")
studio_exists = bool(r.stdout.strip())
if studio_enabled and not studio_exists:
anomalies.setdefault("supabase_studio", []).append("deployment missing")
if studio_enabled and studio_exists:
r2 = _kubectl(
"get", "deployment", "supabase-studio", "-n", supabase_ns,
"-o", "jsonpath={.status.readyReplicas}",
)
ready = (r2.stdout.strip() or "0")
if ready == "0":
anomalies.setdefault("supabase_studio", []).append("deployment not ready")
# Per-component checks
for component, flag in (
("supabase-auth", "supabase_auth_enabled"),
("supabase-realtime", "supabase_realtime_enabled"),
("supabase-meta", "supabase_meta_enabled"),
("supabase-analytics", "supabase_analytics_enabled"),
):
if self._get_input_bool(f"init_cluster.{flag}", True):
rc = _kubectl("get", "deployment", component, "-n", supabase_ns, "--ignore-not-found")
if not rc.stdout.strip():
anomalies.setdefault("supabase_components", []).append(f"{component} deployment missing")
# --- Core dashboard (Kong) ---
if self._dashboard_kong_missing(env):
anomalies.setdefault("dashboard", []).append("kong missing")
# --- Authority context ---
if self._authority_context_missing():
anomalies.setdefault("authority", []).append("context missing")
except Exception as exc:
self.err(f"[WARN] Cluster state validation error (non-fatal): {exc}")
return anomalies
def _reconcile_supabase_studio(self, env: dict) -> None:
"""Ensure Studio deployment/service/ingress/TLS exist and match config.
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.knoe.org"
)
studio_url = (
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}")
project_root_str = str(self.controller.project_root)
cfg_path_str = str(self.cfg_path) if (self.cfg_path and self.cfg_path.exists()) else ""
db_ns = (
self._get_input("init_password.db_namespace", "").strip()
or (env.get("KNOE_DB_NAMESPACE") or "").strip()
or "knoe-db"
)
deploy_env = {
**env,
"SUPABASE_STUDIO_URL": studio_url,
"SUPABASE_STUDIO_ENABLED": "true",
"PROJECT_ROOT": project_root_str,
"KNOE_HOME": project_root_str,
"KNOE_DB_NAMESPACE": db_ns,
"DATABASE_NAMESPACE": db_ns,
**({"PROLE_CFG_PATH": cfg_path_str} if cfg_path_str else {}),
}
reconcile_script = self.controller.project_root / "etc" / "supabase_reconcile_studio.sh"
if reconcile_script.exists():
self.controller.run_script(
"supabase_reconcile_studio.sh",
args=["--namespace", supabase_ns, "--studio-url", studio_url],
env=deploy_env,
)
else:
# Dedicated reconcile script not present — fall back to the full
# deploy.sh pipeline (render → helm upgrade --install) with Studio
# env vars forced, mirroring _step_supabase.
self.log("[REPAIR] supabase_reconcile_studio.sh not found; falling back to deploy.sh")
deploy_sh = self.controller.project_root / "supabase" / "deploy.sh"
if not deploy_sh.exists():
self.err(f"[ERROR] supabase/deploy.sh not found: {deploy_sh}")
return
cluster_env = _normalize_cluster_env(
self._get_input("init_cluster.cluster_env", "dev")
)
if cluster_env in ("service", "prod"):
mode = "k8s"
else:
mode = "k3d"
deploy_args = ["--mode", mode]
if self.cfg_path and self.cfg_path.exists():
deploy_args.extend(["-c", str(self.cfg_path)])
self.log(f"[REPAIR] supabase/deploy.sh {' '.join(deploy_args)}")
rc = self._run_cmd(["bash", str(deploy_sh)] + deploy_args, env=deploy_env)
if rc != 0:
self.err(f"[ERROR] Supabase deploy.sh failed (code {rc})")
def _reconcile_supabase(self, env: dict) -> None:
"""Reconcile all enabled Supabase components toward configured intent.
Honours per-component enable flags; avoids namespace-wide restarts.
"""
supabase_enabled = self._get_input_bool("init_cluster.supabase_enabled", False)
if not supabase_enabled:
self.log("[REPAIR] Supabase disabled in config — skipping Supabase reconcile.")
return
studio_enabled = self._get_input_bool("init_cluster.supabase_studio_enabled", False)
if studio_enabled:
self._reconcile_supabase_studio(env)
else:
self.log("[REPAIR] supabase_studio_enabled=false — skipping Studio reconcile.")
component_flags = {
"supabase_auth_enabled": "auth",
"supabase_realtime_enabled": "realtime",
"supabase_meta_enabled": "meta",
"supabase_analytics_enabled": "analytics",
}
for flag, component in component_flags.items():
if self._get_input_bool(f"init_cluster.{flag}", True):
self.log(f"[REPAIR] Verified Supabase component enabled in config: {component}")
else:
self.log(f"[REPAIR] {component} disabled in config — skipping.")
def run_repair(self) -> int:
"""Validate current configuration against live cluster state and fix drift.
Prefers patch/apply over delete/recreate. Does not restart healthy
unrelated components. Safe to run repeatedly (idempotent).
"""
self.log("[REPAIR] Starting repair run…")
try:
self.inputs = {
**self._default_inputs(),
**self._load_inputs_from_cfg(),
**self.inputs
}
self.controller.state.inputs = self.inputs
self.controller.state.config_data = self.knoe_cfg_data
# Delegate config writing to the installer (allows milestones to persist changes)
self.controller.write_config = self._write_cfg
except Exception as exc:
self.err(f"[FATAL] Could not load config: {exc}")
self._close_log_file()
return 2
service_ns = self._service_namespace()
env = self._script_env_for_namespace(service_ns)
env["SERVICE_NAMESPACE"] = service_ns
self.log("[REPAIR] Validating cluster state…")
anomalies = self._validate_cluster_state(env)
if not anomalies:
self.log("[REPAIR] No drift detected — cluster matches configured intent.")
self._close_log_file()
return 0
for category, details in anomalies.items():
for detail in details:
self.log(f"[REPAIR] Anomaly [{category}]: {detail}")
try:
self._repair_stale_released_pvs(env)
except Exception as exc:
self.err(f"[WARN] PV repair error (non-fatal): {exc}")
if "supabase_studio" in anomalies or "supabase_components" in anomalies:
self._reconcile_supabase(env)
core_anomalies = [k for k in anomalies if k not in ("supabase_studio", "supabase_components")]
if core_anomalies:
try:
self._run_cluster_repair_pipeline(env=env, anomalies=core_anomalies)
except Exception as exc:
self.err(f"[WARN] Cluster repair pipeline error (non-fatal): {exc}")
self.log("[REPAIR] Repair run complete.")
self._close_log_file()
return 0
def run_update(self) -> int:
"""Run repair-style reconciliation then apply any newly changed config intent.
Only rolls components whose effective config changed since last write.
Does not perform a full reinstall.
"""
self.log("[UPDATE] Starting update run (repair + config delta apply)…")
rc = self.run_repair()
if rc != 0:
self.err("[UPDATE] Repair phase failed; aborting update.")
return rc
self.log("[UPDATE] Applying config changes…")
try:
self.inputs = {
**self._default_inputs(),
**self._load_inputs_from_cfg(),
**self.inputs
}
self.controller.state.inputs = self.inputs
self.controller.state.config_data = self.knoe_cfg_data
# Delegate config writing to the installer (allows milestones to persist changes)
self.controller.write_config = self._write_cfg
except Exception as exc:
self.err(f"[FATAL] Could not reload config for update: {exc}")
self._close_log_file()
return 2
service_ns = self._service_namespace()
env = self._script_env_for_namespace(service_ns)
if self._get_input_bool("init_db_build.run_build", DEFAULT_ACTION_FLAGS.get("init_db_build.run_build", True)):
self.log("[UPDATE] Running DB image build…")
self._step_db_build()
if self._get_input_bool("init_cnpg_deploy.run_deploy", DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.run_deploy", True)):
configured_force_rollout = self._get_input_bool(
"init_cnpg_deploy.force_rollout",
DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.force_rollout", False),
)
self.log(
"[UPDATE] Running CNPG deploy "
f"(configured_force_rollout={configured_force_rollout}, effective_force_rollout=True)…"
)
original_force_rollout = self.inputs.get("init_cnpg_deploy.force_rollout")
self.inputs["init_cnpg_deploy.force_rollout"] = "true"
try:
self._step_cnpg_deploy()
finally:
if original_force_rollout is None:
self.inputs.pop("init_cnpg_deploy.force_rollout", None)
else:
self.inputs["init_cnpg_deploy.force_rollout"] = original_force_rollout
if self._get_input_bool("init_cluster.supabase_enabled", False):
self._reconcile_supabase(env)
self._write_cfg()
self.log("[UPDATE] Update run complete.")
self._close_log_file()
return 0
def _run_silent_install_test(project_root: Path, cfg_path: Path) -> int:
script = project_root / "tests" / "silent_install_test.sh"
if not script.exists():
print(
f"[ERROR] Silent install test script not found: {script}", file=sys.stderr
)
return 1
env = os.environ.copy()
env.setdefault("SILENT_INSTALL_LOG", "true")
res = subprocess.run(["bash", str(script), str(cfg_path)], env=env)
return res.returncode
def _attempt_k3s_repair(
controller: KnoeController,
namespace: str,
server: str,
token: str,
db_password: str,
) -> None:
env = os.environ.copy()
env["KNOE_HOME"] = str(controller.project_root)
env["KNOE_SERVICE"] = str(controller.project_root)
env["DATABASE_NAMESPACE"] = namespace
env["CLUSTER_NAME"] = (os.environ.get("CLUSTER_NAME") or "knoe-db").strip() or "knoe-db"
# Transitional compatibility for scripts still reading NAMESPACE.
env["NAMESPACE"] = namespace
env["KNOE_MODE"] = "k3s"
if db_password:
env["DB_PASSWORD"] = db_password
env["OPENTOFU_ADMIN_PASSWORD"] = db_password
if server and token and not env.get("KUBECONFIG"):
repo_kubeconfig = controller.project_root / "knoe-k3s.kubeconfig"
if repo_kubeconfig.exists():
env["KUBECONFIG"] = str(repo_kubeconfig)
elif _looks_like_k8s_bearer_token(token):
kubeconfig_path = _write_k3s_kubeconfig(server, token)
env["KUBECONFIG"] = str(kubeconfig_path)
openbao_ops.update(
namespace=namespace,
env=env,
mode="k3s",
project_root=controller.project_root,
)
opentofu_ops.update(
namespace=namespace,
env=env,
mode="k3s",
project_root=controller.project_root,
)
def _reset_k3s_namespace(
project_root: Path,
namespace: str | Sequence[str],
server: str,
token: str,
*,
registry_namespace: str | None = None,
clear_node_reservations: bool = False,
delete_pvcs_namespaces: Sequence[str] | None = None,
force_delete_bound_pvs: bool = False,
) -> None:
script = project_root / "scripts" / "reset-ns.sh"
if not script.exists():
print(f"[WARN] Namespace reset script not found: {script}", file=sys.stderr)
return
if isinstance(namespace, str):
raw_namespaces = [namespace]
else:
raw_namespaces = list(namespace or [])
namespaces: list[str] = []
for ns in raw_namespaces:
item = str(ns or "").strip()
if not item:
continue
if item not in namespaces:
namespaces.append(item)
if not namespaces:
return
env = os.environ.copy()
if not env.get("KUBECONFIG"):
repo_kubeconfig = project_root / "knoe-k3s.kubeconfig"
if repo_kubeconfig.exists():
env["KUBECONFIG"] = str(repo_kubeconfig)
elif server and token and _looks_like_k8s_bearer_token(token):
kubeconfig_path = _write_k3s_kubeconfig(server, token)
env["KUBECONFIG"] = str(kubeconfig_path)
reg_ns = (registry_namespace or "").strip()
delete_pvcs_set = {
str(ns or "").strip()
for ns in (delete_pvcs_namespaces or [])
if str(ns or "").strip()
}
def _collect_bound_pvs(target_ns: str) -> list[str]:
res = subprocess.run(
[
"kubectl",
"-n",
target_ns,
"get",
"pvc",
"-o",
"jsonpath={range .items[*]}{.spec.volumeName}{\"\\n\"}{end}",
],
env=env,
capture_output=True,
text=True,
)
if res.returncode != 0:
print(
f"[WARN] Could not list PVC-bound PVs in {target_ns} (code {res.returncode}).",
file=sys.stderr,
)
return []
pvs: list[str] = []
for line in (res.stdout or "").splitlines():
pv = (line or "").strip()
if pv and pv not in pvs:
pvs.append(pv)
return pvs
def _set_reclaim_policy_delete(pvs: Sequence[str]) -> None:
for pv in pvs:
patch = '{"spec":{"persistentVolumeReclaimPolicy":"Delete"}}'
res = subprocess.run(
["kubectl", "patch", "pv", pv, "--type=merge", "-p", patch],
env=env,
capture_output=True,
text=True,
)
if res.returncode != 0:
print(
f"[WARN] Failed to set reclaimPolicy=Delete for pv/{pv} (code {res.returncode}).",
file=sys.stderr,
)
def _delete_pvs(pvs: Sequence[str]) -> None:
for pv in pvs:
res = subprocess.run(
["kubectl", "delete", "pv", pv, "--ignore-not-found", "--wait=false"],
env=env,
capture_output=True,
text=True,
)
if res.returncode != 0:
print(
f"[WARN] Failed to delete pv/{pv} (code {res.returncode}).",
file=sys.stderr,
)
for idx, ns in enumerate(namespaces):
delete_pvcs = ns in delete_pvcs_set
bound_pvs: list[str] = []
if delete_pvcs and force_delete_bound_pvs:
bound_pvs = _collect_bound_pvs(ns)
if bound_pvs:
_set_reclaim_policy_delete(bound_pvs)
cmd = ["bash", str(script), "-n", ns]
if reg_ns and ns == reg_ns:
cmd.append("--keep-registry")
if delete_pvcs:
cmd.append("--delete-pvcs")
if clear_node_reservations and idx == (len(namespaces) - 1):
cmd.append("--clear-node-reservations")
res = subprocess.run(cmd, env=env)
if res.returncode != 0:
print(
f"[WARN] Namespace reset failed for {ns} (code {res.returncode})",
file=sys.stderr,
)
continue
if delete_pvcs and force_delete_bound_pvs and bound_pvs:
_delete_pvs(bound_pvs)
def _prepare_k3s_pipeline(
controller: KnoeController, log_fn=None, skip_validation: bool = False
) -> int:
"""Prepare OpenTofu k3s pipeline: stage manifests, tfvars, ArgoCD apps.
When *skip_validation* is True the silent-install-test / repair loop is
skipped entirely — the caller (e.g. a successful silent install) already
validated the environment. The main knoe.cfg is **not** overwritten in
this case so it keeps whatever cluster_env the install used.
"""
def _log(msg):
if log_fn:
try:
log_fn(msg)
except:
print(msg, end="", flush=True)
else:
print(msg, end="", flush=True)
project_root = controller.project_root
conf_dir = knoe_conf_mgr.resolve_knoe_conf_dir(project_root)
cfg_path = knoe_conf_mgr.entrypoint_path(conf_dir)
_log("==> Preparing k3s pipeline\n")
_log(f" Project root: {project_root}\n")
_log(f" Config: {cfg_path}\n\n")
_log("==> Detecting Ansible topology...\n")
info = _detect_ansible_topology(project_root)
k3s_server = (info.get("k3s_server_url") or "").strip() if info else ""
k3s_token = (info.get("k3s_token") or "").strip() if info else ""
if k3s_server and not k3s_server.startswith("http"):
k3s_server = f"https://{k3s_server}"
_log(f" k3s_server: {k3s_server or '(not detected)'}\n")
_log(f" k3s_token: {'detected' if k3s_token else '(not detected)'}\n\n")
# Load config to resolve namespace and secrets for pipeline tfvars
_log("==> Loading knoe config...\n")
knoe = KnoeConsoleInstaller(controller, str(cfg_path))
try:
existing_inputs = knoe._load_inputs_from_cfg()
except Exception:
existing_inputs = {}
knoe.inputs = {**knoe._default_inputs(), **existing_inputs}
if k3s_server and not knoe.inputs.get("init_cluster.k3s_server_url"):
knoe.inputs["init_cluster.k3s_server_url"] = k3s_server
if k3s_token and not knoe.inputs.get("init_cluster.k3s_token"):
knoe.inputs["init_cluster.k3s_token"] = k3s_token
namespace = (knoe._get_input("init_password.db_namespace", "") or "").strip()
if not namespace:
namespace = (
knoe._get_input("env_setup.DATABASE_NAMESPACE", "") or ""
).strip()
if not namespace:
namespace = (
knoe._get_input("env_setup.NAMESPACE", "") or ""
).strip() or "default"
_log(f" Target namespace: {namespace}\n")
_log(" [OK] Config loaded\n\n")
if not skip_validation:
# Standalone mode: write a pipeline-specific cfg and validate
knoe.inputs["init_cluster.cluster_env"] = "knoe-service-cluster"
# supabase_enabled is intentionally NOT overridden here — preserve the cfg value
knoe.inputs["init_cluster.kerberos_enabled"] = _bool_str(False)
knoe.inputs["init_cluster.at_rest_encryption_enabled"] = _bool_str(True)
knoe.inputs["kerberos_config.enabled"] = _bool_str(False)
knoe.inputs["kerberos_config.test_connection"] = _bool_str(False)
knoe.inputs["env_setup.KNOE_HOME"] = str(project_root)
knoe.inputs["env_setup.KNOE_CONF"] = str(project_root / "conf")
knoe.inputs["env_setup.PROLE_DATA"] = knoe._resolve_env_value(
"PROLE_DATA", str(resolve_knoe_home(env={}) / "data")
) or str(resolve_knoe_home(env={}) / "data")
knoe.inputs["env_setup.PROLE_LOGS"] = knoe._resolve_env_value(
"PROLE_LOGS", str(resolve_knoe_home(env={}) / "logs")
) or str(resolve_knoe_home(env={}) / "logs")
knoe.inputs["env_setup.KNOE_SERVICE"] = str(project_root / "etc")
# Ensure required keys that the silent install test validates
if not knoe.inputs.get("env_setup.DATABASE_NAMESPACE"):
knoe.inputs["env_setup.DATABASE_NAMESPACE"] = (
knoe.inputs.get("init_password.db_namespace", "") or "default"
)
if not knoe.inputs.get("init_password.db_namespace"):
knoe.inputs["init_password.db_namespace"] = (
knoe.inputs.get("env_setup.DATABASE_NAMESPACE", "")
or knoe.inputs.get("env_setup.NAMESPACE", "")
or "default"
)
if not knoe.inputs.get("kerberos_config.init_authority"):
knoe.inputs["kerberos_config.init_authority"] = _bool_str(False)
if not knoe.inputs.get("init_password.db_host_port"):
knoe.inputs["init_password.db_host_port"] = "5432"
_log(" [OK] Config overrides applied for k3s pipeline\n")
_log("==> Writing knoe.cfg...\n")
knoe._write_cfg()
_log(f" [OK] {cfg_path}\n\n")
db_password = knoe._get_input("init_password.db_password", "").strip()
_log(f"==> Target namespace: {namespace}\n\n")
attempt = 1
total_attempts = 0
max_attempts_env = os.environ.get("SILENT_TEST_MAX_ATTEMPTS", "").strip()
max_attempts = int(max_attempts_env) if max_attempts_env.isdigit() else 0
_log("==> Running silent install validation...\n")
while True:
total_attempts += 1
_log(f" Attempt {total_attempts}...\n")
rc = _run_silent_install_test(project_root, cfg_path)
if rc == 0:
_log(f" [OK] Silent install test passed\n\n")
break
_log(f" [WARN] Silent install test failed (code {rc})\n")
cur_server = knoe.inputs.get("init_cluster.k3s_server_url", k3s_server)
cur_token = knoe.inputs.get("init_cluster.k3s_token", k3s_token)
if attempt == 1:
_log(" Attempting k3s repair...\n")
_attempt_k3s_repair(
controller, namespace, cur_server, cur_token, db_password
)
elif attempt == 2:
_log(" Resetting k3s namespace...\n")
_reset_k3s_namespace(project_root, namespace, cur_server, cur_token)
else:
attempt = 0
attempt += 1
if max_attempts and total_attempts >= max_attempts:
_log(f" [ERROR] Max attempts ({max_attempts}) reached. Aborting.\n")
return rc
else:
_log("==> Skipping validation (silent install already succeeded)\n\n")
cur_server = knoe.inputs.get("init_cluster.k3s_server_url", k3s_server)
cur_token = knoe.inputs.get("init_cluster.k3s_token", k3s_token)
_sync_opentofu_pipeline(project_root, namespace, cur_server, cur_token, log_fn=_log)
_log("\n==> k3s pipeline preparation complete.\n")
return 0