prole/knoe/knoe_conf.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

492 lines
16 KiB
Python

"""Knoe configuration environment management.
This module centralizes how Knoe resolves configuration paths and how it
loads configuration with per-environment layering.
Target on-disk layout (under `$KNOE_CONF`):
- `k3d.cfg` -> local development configuration
- `k3s.cfg` -> k3s/service configuration
- `gke.cfg` -> production/GKE configuration
Legacy `knoe.cfg` and `conf/<env>/knoe.cfg` paths are still recognized for
backward compatibility.
"""
from __future__ import annotations
import configparser
import os
import time
from pathlib import Path
ENVIRONMENTS: tuple[str, ...] = ("dev", "service", "prod", "test")
ENV_TO_CFG_FILE: dict[str, str] = {
"dev": "k3d.cfg",
"service": "k3s.cfg",
"prod": "gke.cfg",
"test": "test.cfg",
}
CFG_FILE_TO_ENV: dict[str, str] = {cfg: env for env, cfg in ENV_TO_CFG_FILE.items()}
PREFERRED_CFG_ORDER: tuple[str, ...] = ("k3d.cfg", "k3s.cfg", "gke.cfg", "test.cfg")
def cfg_file_for_env(env: str | None) -> str:
env_key = normalize_environment(env)
return ENV_TO_CFG_FILE.get(env_key, "")
def env_for_cfg_file(name: str | None) -> str:
if not name:
return ""
return CFG_FILE_TO_ENV.get(str(name).strip().lower(), "")
def _env_from_mode_or_env_hint(raw: str | None) -> str:
"""Map a mode/env hint to one of the supported environments."""
s = normalize_environment(raw or "")
if not s:
return ""
# normalize_environment already maps k3d/k3s/k8s-ish values to env names.
if s in ENVIRONMENTS:
return s
return ""
def _infer_env_from_cfg_file(cfg_path: Path) -> str:
"""Best-effort inference of active env from a legacy standalone knoe.cfg."""
cfg_path = Path(cfg_path)
if not cfg_path.exists() or not cfg_path.is_file() or cfg_path.is_symlink():
return ""
try:
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
cfg.read([str(cfg_path)])
except Exception:
return ""
# Priority order: explicit deployment mode -> explicit env -> UI/inputs hints.
for section, key in (
("Global", "DEPLOYMENT_MODE"),
("Global", "MODE"),
("Global", "CLUSTER_ENV"),
("Initialize Cluster", "ENVIRONMENT"),
("Inputs", "init_cluster.mode"),
("Inputs", "init_cluster.cluster_env"),
):
try:
if cfg.has_section(section):
env = _env_from_mode_or_env_hint(cfg.get(section, key, fallback=""))
if env:
return env
except Exception:
continue
return ""
def normalize_environment(env: str | None) -> str:
if not env:
return ""
s = str(env).strip().lower()
if s in ("dev", "k3d") or s.startswith(("k3d-", "knoe-dev-")) or s == "knoe-dev-cluster":
return "dev"
if s in ("service", "services", "k3s", "knoe-service-cluster") or s.startswith("knoe-service-"):
return "service"
if s in ("prod", "production", "k8s", "knoe-prod-cluster") or s.startswith("knoe-prod-"):
return "prod"
if s in ("test", "testing"):
return "test"
return s
def resolve_knoe_conf_dir(project_root: Path) -> Path:
raw = os.environ.get("KNOE_CONF", "").strip()
if raw:
return Path(raw).expanduser()
return project_root / "conf"
def entrypoint_path(conf_dir: Path) -> Path:
"""Return the active configuration file path.
Prefers named root config files (`k3d.cfg`, `k3s.cfg`, `gke.cfg`) resolved
from CLUSTER_ENV/KNOE_MODE/DEPLOYMENT_MODE. Falls back to legacy env-dir
`knoe.cfg` files and finally `conf/knoe.cfg` for compatibility.
"""
conf_dir = Path(conf_dir)
# Determine active env from environment variables (set by the UI on save).
for var in ("CLUSTER_ENV", "KNOE_MODE", "DEPLOYMENT_MODE"):
hint = os.environ.get(var, "").strip()
if hint:
env = _env_from_mode_or_env_hint(hint)
if env in ENVIRONMENTS:
named = cfg_file_for_env(env)
if named:
named_path = conf_dir / named
if named_path.exists():
return named_path
env_path = conf_dir / env / "knoe.cfg"
if env_path.exists():
return env_path
for cfg_name in PREFERRED_CFG_ORDER:
path = conf_dir / cfg_name
if path.exists():
return path
for env in ENVIRONMENTS:
env_path = conf_dir / env / "knoe.cfg"
if env_path.exists():
return env_path
legacy = conf_dir / "knoe.cfg"
if legacy.exists():
return legacy
# Default target when creating a fresh config.
return conf_dir / "k3d.cfg"
def _is_known_env_dir(conf_dir: Path, env_dir: Path) -> bool:
try:
return env_dir.is_dir() and env_dir.parent.resolve() == conf_dir.resolve() and env_dir.name in ENVIRONMENTS
except Exception:
return False
def resolve_env_dir_from_cfg_path(cfg_path: Path) -> tuple[Path, str, Path]:
"""Return (conf_dir, env_name, env_dir).
Supports:
- `$KNOE_CONF/{k3d.cfg|k3s.cfg|gke.cfg|test.cfg}` base files
- legacy `$KNOE_CONF/knoe.cfg` entrypoint (symlink/plain)
- legacy `$KNOE_CONF/<env>/knoe.cfg` base files
"""
cfg_path = Path(cfg_path)
# New layout: named config directly in conf dir.
env_from_name = env_for_cfg_file(cfg_path.name)
if env_from_name:
return cfg_path.parent, env_from_name, cfg_path.parent / env_from_name
# If cfg_path is an entrypoint symlink, resolve its target.
try:
if cfg_path.is_symlink():
target = cfg_path.resolve()
target_env_from_name = env_for_cfg_file(target.name)
if target_env_from_name:
return target.parent, target_env_from_name, target.parent / target_env_from_name
env_dir = target.parent
conf_dir = env_dir.parent
env = normalize_environment(env_dir.name)
expected = cfg_file_for_env(env)
if env and _is_known_env_dir(conf_dir, env_dir) and target.name in {"knoe.cfg", expected}:
return conf_dir, env, env_dir
except Exception:
pass
# If cfg_path is already a base file inside an env dir.
try:
env_dir = cfg_path.parent
conf_dir = env_dir.parent
env = normalize_environment(env_dir.name)
expected = cfg_file_for_env(env)
if env and env in ENVIRONMENTS and cfg_path.name in {"knoe.cfg", expected} and _is_known_env_dir(conf_dir, env_dir):
return conf_dir, env, env_dir
except Exception:
pass
# Fallback: treat as a standalone file.
return cfg_path.parent, "", cfg_path.parent
def layered_cfg_files(cfg_path: Path) -> list[Path]:
"""Return config files to load in order (base then overrides)."""
cfg_path = Path(cfg_path)
conf_dir, env, env_dir = resolve_env_dir_from_cfg_path(cfg_path)
files: list[Path] = []
# New layout uses single named config files in conf dir (no overlays).
if env_for_cfg_file(cfg_path.name):
files.append(cfg_path)
return files
if env and _is_known_env_dir(conf_dir, env_dir):
base_name = cfg_file_for_env(env)
base = env_dir / base_name if base_name else env_dir / "knoe.cfg"
if not base.exists():
base = env_dir / "knoe.cfg"
if base.exists():
files.append(base)
try:
skip_names = {
"prod.cfg", # standalone deploy config, not an overlay for knoe.cfg
"gcp.cfg", # generated metadata file (key=value, no INI sections)
}
overrides = [
p
for p in env_dir.iterdir()
if p.is_file()
and p.name != base.name
and p.name not in skip_names
and p.suffix == ".cfg"
and not p.name.startswith(".")
]
overrides.sort(key=lambda p: p.name)
files.extend(overrides)
except Exception:
pass
if files:
return files
# Legacy/standalone: load only the given path.
files.append(cfg_path)
return files
def load_layered_config(cfg_path: Path, require_exists: bool = False) -> configparser.ConfigParser:
"""Load config from `cfg_path` applying env directory overrides if applicable."""
cfg_path = Path(cfg_path)
if require_exists and not cfg_path.exists():
raise FileNotFoundError(str(cfg_path))
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
files = [p for p in layered_cfg_files(cfg_path) if p.exists()]
if files:
cfg.read([str(p) for p in files])
return cfg
def ensure_environment_dirs(conf_dir: Path) -> None:
conf_dir = Path(conf_dir)
conf_dir.mkdir(parents=True, exist_ok=True)
for env in ENVIRONMENTS:
try:
(conf_dir / env).mkdir(parents=True, exist_ok=True)
except Exception:
pass
def _default_base_cfg_text(env: str) -> str:
env = normalize_environment(env)
if env not in ENVIRONMENTS:
env = "dev"
# Keep defaults conservative and compatible with prior single-file defaults:
# - non-test uses a stable DB namespace default
# - test is isolated
if env == "test":
database_ns = "knoe-test"
service_ns = "knoe-test"
cluster_name = "knoe-db"
elif env == "dev":
database_ns = "knoe-db"
service_ns = "default"
cluster_name = "knoe-db"
else:
database_ns = "knoe-db"
service_ns = "knoe-system"
cluster_name = "knoe-db"
return (
"; Knoe Master Configuration File\n"
"; Generated by knoe_conf\n\n"
"[Global]\n"
f"CLUSTER_ENV = {env}\n"
f"NAMESPACE = {database_ns}\n"
f"DATABASE_NAMESPACE = {database_ns}\n"
f"SERVICE_NAMESPACE = {service_ns}\n"
f"CLUSTER_NAME = {cluster_name}\n"
)
def _looks_like_generated_base_cfg(path: Path, env: str) -> bool:
"""True if `path` looks like a default knoe_conf-generated base."""
path = Path(path)
if not path.exists() or not path.is_file():
return False
try:
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
cfg.read([str(path)])
if cfg.sections() != ["Global"]:
return False
return _env_from_mode_or_env_hint(cfg.get("Global", "CLUSTER_ENV", fallback="")) == env
except Exception:
return False
def ensure_base_cfg(conf_dir: Path, env: str, *, create_if_missing: bool = True) -> Path:
conf_dir = Path(conf_dir)
env_key = normalize_environment(env)
if env_key not in ENVIRONMENTS:
raise ValueError(f"Unsupported environment: {env!r}")
# Primary location: conf/{env}/knoe.cfg (env-dir layout)
env_dir = conf_dir / env_key
env_dir.mkdir(parents=True, exist_ok=True)
base = env_dir / "knoe.cfg"
if not base.exists() and create_if_missing:
base.write_text(_default_base_cfg_text(env_key), encoding="utf-8")
return base
def activate_environment(
conf_dir: Path,
env: str,
*,
create_base_if_missing: bool = True,
migrate_legacy_entrypoint: bool = True,
) -> Path:
"""Activate `env` and return its named root config path.
Sets CLUSTER_ENV in the process environment so that subsequent
entrypoint_path() calls resolve the correct config without
needing a conf/knoe.cfg symlink.
This never overwrites another environment's curated config.
"""
conf_dir = Path(conf_dir)
env_key = normalize_environment(env)
if env_key not in ENVIRONMENTS:
raise ValueError(f"Unsupported environment: {env!r}")
ensure_environment_dirs(conf_dir)
base = ensure_base_cfg(conf_dir, env_key, create_if_missing=create_base_if_missing)
# Migrate any legacy plain-file entrypoint into the target base.
if migrate_legacy_entrypoint:
legacy_entry = conf_dir / "knoe.cfg"
if legacy_entry.exists() and not legacy_entry.is_symlink():
try:
legacy_text = legacy_entry.read_text(encoding="utf-8")
if not base.exists() or _looks_like_generated_base_cfg(base, env_key) or not base.read_text(
encoding="utf-8", errors="ignore"
).strip():
base.write_text(legacy_text, encoding="utf-8")
legacy_name = f"knoe.cfg.legacy.{int(time.time())}"
legacy_entry.rename(conf_dir / legacy_name)
except Exception:
pass
# Migrate legacy env-dir base into new named root config when helpful.
legacy_env_base = conf_dir / env_key / "knoe.cfg"
if legacy_env_base.exists() and legacy_env_base.is_file():
try:
legacy_text = legacy_env_base.read_text(encoding="utf-8")
if not base.exists() or _looks_like_generated_base_cfg(base, env_key) or not base.read_text(
encoding="utf-8", errors="ignore"
).strip():
base.write_text(legacy_text, encoding="utf-8")
except Exception:
pass
if not base.exists() and create_base_if_missing:
base.write_text(_default_base_cfg_text(env_key), encoding="utf-8")
if not base.exists():
raise FileNotFoundError(str(base))
# Create / update conf/knoe.cfg as a symlink pointing to the active env base.
entry = conf_dir / "knoe.cfg"
try:
if entry.exists() or entry.is_symlink():
if entry.is_symlink():
entry.unlink()
elif entry.is_file():
# Back up any remaining plain file before replacing with symlink.
legacy_name = f"knoe.cfg.legacy.{int(time.time())}"
entry.rename(conf_dir / legacy_name)
entry.symlink_to(base)
except Exception:
pass
# Persist the active environment in the process so entrypoint_path() resolves
# conf/{env}/knoe.cfg without a conf/knoe.cfg symlink.
os.environ["CLUSTER_ENV"] = env_key
return base
def ensure_entrypoint(
conf_dir: Path,
*,
preferred_env: str | None = None,
create_base_if_missing: bool = True,
) -> Path:
"""Ensure the active named config exists and return its path.
- Creates missing env directories.
- Infers the environment from preferred_env, env vars, or existing config.
- If the env-specific base is missing, creates a default placeholder.
Returns the resolved config path (e.g. conf/k3d.cfg).
"""
conf_dir = Path(conf_dir)
ensure_environment_dirs(conf_dir)
env = _env_from_mode_or_env_hint(preferred_env)
if not env:
# Try to infer from the legacy plain-file entrypoint if it still exists.
legacy = conf_dir / "knoe.cfg"
if legacy.exists() and not legacy.is_symlink():
env = _infer_env_from_cfg_file(legacy)
if not env:
for cfg_name in PREFERRED_CFG_ORDER:
if (conf_dir / cfg_name).exists():
env = env_for_cfg_file(cfg_name)
if env:
break
if not env:
env = "dev"
return activate_environment(
conf_dir,
env,
create_base_if_missing=create_base_if_missing,
migrate_legacy_entrypoint=True,
)
def active_environment(conf_dir: Path) -> str:
"""Return the currently active environment name.
Resolves from CLUSTER_ENV/KNOE_MODE env vars first (set by activate_environment),
then falls back to inferring from the resolved config file path.
"""
conf_dir = Path(conf_dir)
for var in ("CLUSTER_ENV", "KNOE_MODE", "DEPLOYMENT_MODE"):
hint = os.environ.get(var, "").strip()
if hint:
env = _env_from_mode_or_env_hint(hint)
if env in ENVIRONMENTS:
return env
entry = entrypoint_path(conf_dir)
if not entry.exists():
return ""
env = env_for_cfg_file(entry.name)
if env in ENVIRONMENTS:
return env
try:
target = entry.resolve()
env = env_for_cfg_file(target.name)
if env in ENVIRONMENTS:
return env
env = normalize_environment(target.parent.name)
expected = cfg_file_for_env(env)
if env in ENVIRONMENTS and _is_known_env_dir(conf_dir, target.parent) and target.name in {"knoe.cfg", expected}:
return env
except Exception:
pass
return ""