mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
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>
345 lines
13 KiB
Python
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
|