"""KnoeDeployment — orchestrates the service-mode (k3s) deploy pipeline.""" from __future__ import annotations import configparser import os import subprocess from pathlib import Path from typing import Any # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- KUBECONFIG_FILENAME = "knoe-k3s.kubeconfig" FETCH_PLAYBOOK = "infrastructure/playbooks/fetch_kubeconfig.yml" _CFG_KEY_MAP: dict[str, tuple[str, str]] = { "init_password.db_namespace": ("Global", "NAMESPACE"), "init_password.db_password": ("Database Creation", "DB_PASSWORD"), "init_cluster.environment": ("Initialize Cluster", "ENVIRONMENT"), "system_environment.knoe_conf": ("System Environment", "KNOE_CONF"), "optional_features.supabase_enabled": ("Optional Features", "SUPABASE_ENABLED"), "kerberos.enabled": ("Kerberos Authentication", "ENABLED"), } _DEFAULT_NAMESPACE = "knoe-db" # Default upper bound for milestone-style subprocess calls (Ansible playbooks, # OpenTofu apply, init_*.sh scripts, supabase/deploy.sh). Long enough that a # legit install step rarely hits it; short enough that an indefinitely-hung # command (network unreachable, prompt waiting on stdin, etc.) doesn't lock up # the installer forever. Override via KNOE_MILESTONE_TIMEOUT_SECONDS env var. _MILESTONE_TIMEOUT = int(os.environ.get("KNOE_MILESTONE_TIMEOUT_SECONDS", "1800")) # --------------------------------------------------------------------------- # Module-level helpers (patchable) # --------------------------------------------------------------------------- def _detect_ansible_topology(knoe_home: Path) -> dict: """Return topology info from Ansible inventory (best-effort).""" try: result = subprocess.run( ["ansible-inventory", "--list"], cwd=str(knoe_home), capture_output=True, text=True, timeout=30, ) if result.returncode != 0: return {} import json data = json.loads(result.stdout) # Extract k3s server URL and token from hostvars hostvars = data.get("_meta", {}).get("hostvars", {}) for host, vars_ in hostvars.items(): url = vars_.get("k3s_server_url") or vars_.get("ansible_host") token = vars_.get("k3s_token", "") if url: return {"k3s_server_url": url, "k3s_token": token} except Exception: pass return {} def _sync_opentofu_pipeline(src: Path, dst: Path) -> Path: """Sync the k3d opentofu pipeline to a k3s destination directory.""" import shutil if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst) return dst def cnpg_initialize(env: dict) -> None: """Initialise CNPG cluster (no-op stub; real impl in knoe.core.ops).""" pass # --------------------------------------------------------------------------- # KnoeDeployment # --------------------------------------------------------------------------- class KnoeDeployment: """Drives the service-mode (k3s) deployment pipeline.""" def __init__(self, controller: Any, knoe_home: Path) -> None: self.controller = controller self.knoe_home = Path(knoe_home) self.cfg_path: Path | None = None self.knoe_cfg_data: configparser.ConfigParser = configparser.ConfigParser( interpolation=None ) self.knoe_cfg_data.optionxform = str # Ensure Global section with default namespace self.knoe_cfg_data.add_section("Global") self.knoe_cfg_data.set("Global", "NAMESPACE", _DEFAULT_NAMESPACE) self._load_knoe_cfg() # ------------------------------------------------------------------ # Config loading # ------------------------------------------------------------------ def _load_knoe_cfg(self) -> None: knoe_conf = os.environ.get("KNOE_CONF", "").strip() candidate: Path | None = None if knoe_conf: p = Path(knoe_conf) if p.is_dir(): candidate = p / "knoe.cfg" elif p.is_file(): candidate = p if candidate is None or not candidate.exists(): # Fallback: look next to knoe_home candidate = self.knoe_home / "conf" / "knoe.cfg" if candidate and candidate.exists(): self.cfg_path = candidate cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str cfg.read(candidate) # Merge into self.knoe_cfg_data for section in cfg.sections(): if not self.knoe_cfg_data.has_section(section): self.knoe_cfg_data.add_section(section) for key, val in cfg.items(section): self.knoe_cfg_data.set(section, key, val) # Ensure namespace default if not self.knoe_cfg_data.get("Global", "NAMESPACE", fallback=""): self.knoe_cfg_data.set("Global", "NAMESPACE", _DEFAULT_NAMESPACE) # ------------------------------------------------------------------ # Input helpers # ------------------------------------------------------------------ def _get_input(self, key: str, default: str = "") -> str: mapping = _CFG_KEY_MAP.get(key) if mapping is None: return default section, cfg_key = mapping return self.knoe_cfg_data.get(section, cfg_key, fallback=default) # ------------------------------------------------------------------ # Environment building # ------------------------------------------------------------------ def _build_deploy_env(self) -> dict[str, str]: env = os.environ.copy() env["KNOE_HOME"] = str(self.knoe_home) env["KNOE_SERVICE"] = str(self.knoe_home) env["NAMESPACE"] = self._get_input("init_password.db_namespace", _DEFAULT_NAMESPACE) env["KNOE_MODE"] = "k3s" knoe_conf = os.environ.get("KNOE_CONF", "").strip() if not knoe_conf and self.cfg_path: knoe_conf = str(self.cfg_path.parent) if knoe_conf: env["KNOE_CONF"] = knoe_conf db_password = self._get_input("init_password.db_password", "") if db_password: env["DB_PASSWORD"] = db_password env["OPENTOFU_ADMIN_PASSWORD"] = db_password # Kubeconfig deployment = self.knoe_cfg_data kc_path = "" if deployment.has_section("Deployment"): kc_path = deployment.get("Deployment", "KUBECONFIG_PATH", fallback="") if kc_path and Path(kc_path).exists(): env["KUBECONFIG"] = kc_path return env # ------------------------------------------------------------------ # Kubeconfig fetch # ------------------------------------------------------------------ def _fetch_kubeconfig(self) -> Path | None: playbook = self.knoe_home / FETCH_PLAYBOOK if not playbook.exists(): return None cmd = ["ansible-playbook", str(playbook)] vault_pass = self.knoe_home / ".vault_pass" if vault_pass.exists(): cmd += ["--vault-password-file", str(vault_pass)] try: # Fetching the kubeconfig from a remote k3s host is a tightly # scoped Ansible play; bound it generously but not unboundedly. result = subprocess.run( cmd, cwd=str(self.knoe_home), capture_output=True, timeout=120, ) if result.returncode != 0: return None except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): return None kc = self.knoe_home / KUBECONFIG_FILENAME if not kc.exists(): return None return kc # ------------------------------------------------------------------ # Script runners # ------------------------------------------------------------------ def _run_script(self, script: str, *args: str, env: dict | None = None) -> int: script_path = self.knoe_home / script cmd = [str(script_path)] + list(args) run_env = env or self._build_deploy_env() try: # Bounded by _MILESTONE_TIMEOUT (default 30 min, override via # KNOE_MILESTONE_TIMEOUT_SECONDS env). Catches indefinite hangs # — e.g. a child process waiting on a stdin prompt or a stalled # network call — while still allowing legit long milestone # scripts (CNPG bring-up, supabase deploy, etc.). result = subprocess.run( cmd, env=run_env, cwd=str(self.knoe_home), timeout=_MILESTONE_TIMEOUT, ) return result.returncode except subprocess.TimeoutExpired: return 124 # conventional timeout exit code except Exception: return 1 def _run_cmd(self, cmd: list[str], env: dict | None = None) -> int: run_env = env or self._build_deploy_env() try: result = subprocess.run( cmd, env=run_env, cwd=str(self.knoe_home), timeout=_MILESTONE_TIMEOUT, ) return result.returncode except subprocess.TimeoutExpired: return 124 except Exception: return 1 def _run_supabase_deploy(self, env: dict) -> bool: enabled = self.knoe_cfg_data.get( "Optional Features", "SUPABASE_ENABLED", fallback="false" ) if enabled.lower() not in ("true", "1", "yes"): return True script = self.knoe_home / "supabase" / "deploy.sh" if not script.exists(): return False cmd = [str(script), "--mode", "k8s"] rc = self._run_cmd(cmd, env) return rc == 0 def _run_post_apply_scripts(self) -> bool: env = self._build_deploy_env() kerberos_enabled = self.knoe_cfg_data.get( "Kerberos Authentication", "ENABLED", fallback="false" ) kerberos_args = ["-k"] if kerberos_enabled.lower() in ("true", "1", "yes") else [] scripts = [ ("etc/init_common_services.sh", kerberos_args), ("etc/init_registry.sh", []), ("etc/init_openbao.sh", []), ("etc/init_kong.sh", []), ] for script, extra_args in scripts: rc = self._run_script(script, *extra_args, env=env) if rc != 0: return False cnpg_initialize(env) self._run_supabase_deploy(env) return True # ------------------------------------------------------------------ # Apply (opentofu) # ------------------------------------------------------------------ def apply(self) -> bool: if not self.knoe_cfg_data.has_section("Deployment"): return False pipeline_dir = self.knoe_cfg_data.get("Deployment", "OPENTOFU_PIPELINE_DIR", fallback="") if not pipeline_dir or not Path(pipeline_dir).exists(): return False env = self._build_deploy_env() try: # tofu init: usually <30s; bound generously. r1 = subprocess.run( ["tofu", "init"], cwd=pipeline_dir, env=env, capture_output=True, timeout=300, ) if r1.returncode != 0: return False # tofu apply: can be slow for big stacks; use the milestone budget. r2 = subprocess.run( ["tofu", "apply", "-auto-approve"], cwd=pipeline_dir, env=env, capture_output=True, timeout=_MILESTONE_TIMEOUT, ) if r2.returncode != 0: return False except (subprocess.CalledProcessError, FileNotFoundError): return False return self._run_post_apply_scripts() # ------------------------------------------------------------------ # duplicate_k3d_to_k3s # ------------------------------------------------------------------ def duplicate_k3d_to_k3s(self) -> bool: topology = _detect_ansible_topology(self.knoe_home) server_url = topology.get("k3s_server_url", "") if not server_url: return False kc = self._fetch_kubeconfig() if kc is None: return False src = self.knoe_home / "deploy" / "opentofu" / "k3d" dst = self.knoe_home / "deploy" / "opentofu" / "k3s" try: pipeline_dir = _sync_opentofu_pipeline(src, dst) except Exception: return False if not self.knoe_cfg_data.has_section("Deployment"): self.knoe_cfg_data.add_section("Deployment") self.knoe_cfg_data.set("Deployment", "OPENTOFU_PIPELINE_DIR", str(pipeline_dir)) self.knoe_cfg_data.set("Deployment", "KUBECONFIG_PATH", str(kc)) return True