prole/knoe/deployment.py
chrisfu dba8a2d1dc feat(installer): TDD stabilization for k3d install path; dual-cluster GKE TUI
Junie's session targeted the prompt "stabilize ./install.py -c conf/k3d.cfg
using strict TDD" — broad installer-side work, not the k3d-mirror Phase 3
brief I had filed (which she didn't pick up; phase-3 brief stays open). All
750 installer tests pass post-change.

What Junie produced:

  install.py                          (NEW) Top-level CLI entry point. Was
                                            imagined by the prompt but didn't
                                            exist; this commit makes it real.
  knoe/deployment.py                  (NEW) `KnoeDeployment` orchestrator for
                                            the k3s service-mode deploy pipeline.
                                            Wraps Ansible kubeconfig fetch,
                                            opentofu apply, init_*.sh post-apply
                                            scripts, and (optionally) supabase/
                                            deploy.sh.
  knoe/ui/screens/cluster.py          Dual-cluster GKE kubecontext UI: prod env
  knoe/ui/screens/cfg.py              now shows separate "App Cluster:" and
                                       "DB Cluster:" dropdowns instead of a
                                       single "Kubernetes Context:" combo.
                                       New _app_kubectx_combo + _db_kubectx_combo
                                       widgets; new app/db_cluster_kubecontext
                                       tk.StringVars.
  knoe/core/{actions,env,milestones}.py
  knoe/core/ops/storage.py
  knoe/config.py, knoe/knoe_conf.py   Plumbing changes for the dual-cluster
                                       kubecontext flow + storage-class topology
                                       detection cleanup.
  knoe/tools/cleanup_cnpg_storage.py  (NEW) Stand-alone cleanup utility.
  tools/dashboard.sh                  (NEW) Dashboard helper.
  conf/knoe.cfg                       (NEW) Master cfg generated by knoe_conf.
  conf/dev/                           (NEW) Dev-mode cfg directory.
  conf/port-mapping.cfg               Port mapping tweaks for k3d.
  tests/installer/* (8 files)         New + extended tests for the dual-cluster
  tests/test_database_options.py      TUI, kubecontext save flow, storage ops,
                                       topology detection, deploy helpers,
                                       database-options screen.

Issues found in Junie's working state and fixed here:

  1. install.py was a 11-line import shim with no shebang, no `chmod +x`,
     no `if __name__ == '__main__'` block. `./install.py -c conf/k3d.cfg`
     returned `Permission denied` and `python install.py` did nothing.
     Added `#!/usr/bin/env python3`, `chmod +x`, and a __main__ block
     that delegates to `knoe.ui.screens.main()`. `./install.py --help`
     now prints the canonical argparse help.

  2. knoe/deployment.py had FIVE `subprocess.run()` call sites with no
     `timeout=` argument (`_run_script`, `_run_cmd`, the Ansible playbook
     fetch, `tofu init`, `tofu apply`). A hung child process — typical
     failure mode is a script waiting on stdin or a stalled network
     call — would lock up the installer indefinitely. Added timeouts:
       - Ansible kubeconfig fetch: 120s
       - tofu init: 300s
       - tofu apply, _run_script, _run_cmd: bounded by new module
         constant `_MILESTONE_TIMEOUT` (default 1800s = 30 min, override
         via `KNOE_MILESTONE_TIMEOUT_SECONDS` env var).
     `subprocess.TimeoutExpired` is caught explicitly; on timeout the
     run helpers return exit code 124 (conventional timeout code).

  3. `conf/k3d.cfg` was corrupted with MagicMock string-reprs on disk:
        KNOE_CONF = <MagicMock name='Canvas().tk.call().strip()' id='4743999712'>
        argocd.node_selector = <MagicMock name='mock.StringVar().get().strip()' id='...'>
     Likely path: Junie ran `./install.py -c conf/k3d.cfg` interactively
     in a non-Tk environment (or with a partially-mocked widget set) and
     the installer's "save current state" path wrote the mock-objects'
     `__repr__` strings into the cfg file. This commit reverts the cfg
     to its pre-Junie state. **Followup: harden the cfg save path
     against non-string widget values** — track separately.

  4. The corrupted cfg caused the installer to call `os.makedirs()` on
     the mock-string values, producing 10 directories on disk literally
     named `<MagicMock name='Canvas().tk.call().strip()' id='4733210304'>/`
     etc., with 5–86 files of install artifacts inside each. Removed.

The "final step is timing out" the user reported was almost certainly
issue #2 above: install.py walked the milestone pipeline, hit one of
the unbounded subprocess.run calls, and the wrapped command (probably
supabase/deploy.sh, which Junie was reading for context when her
session timed out) hung. With the timeouts in place that path now
exits cleanly with rc=124 instead of locking up.

Verification:
  - pytest tests/installer/ -q                                   750 passed in ~25s
  - python3 -c "import knoe.deployment"                          imports clean
  - ./install.py --help                                          prints argparse help
  - find . -maxdepth 1 -type d -name '<MagicMock*' | wc -l       0
  - head -7 conf/k3d.cfg                                          clean (no MagicMock)

Out of scope for this commit (followups):
  - The cfg save-path that wrote mock-objects-as-strings (issue #3 root cause).
    Reproducer: launch the installer in an env where Tk widget vars are
    `unittest.mock.MagicMock` instances. The cfg save code should refuse to
    serialize non-str values rather than calling `str()` on a MagicMock.
  - The k3d-mirror Phase 3 brief (`docs/plans/junie/k3d-knoe-auth-pod-deploy.md`)
    is still open — Junie picked a different prompt this round.

Co-authored-by: Junie <junie@jetbrains.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:51:14 -07:00

345 lines
13 KiB
Python

"""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