"""Monitor subcommand — read port-forwards.cfg and manage kubectl port-forward processes.""" from __future__ import annotations import os import signal import subprocess import sys import time from pathlib import Path from knoe import knoe_conf from knoe.config import _expand_path, _collect_cfg_vars, _expand_cfg_value from knoe.core.env import ( _build_required_port_forwards, ) # --------------------------------------------------------------------------- # Port-forwards.cfg I/O # --------------------------------------------------------------------------- def _parse_pf_cfg(path: Path) -> list[dict]: """Parse a port-forwards.cfg file and return a list of mapping dicts.""" mappings: list[dict] = [] if not path.exists(): return mappings for raw_line in path.read_text().splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue # Format: id: local=... remote=... ns=... svc=... address=... if ":" not in line: continue m_id, _, rest = line.partition(":") m_id = m_id.strip() parts: dict[str, str] = {"id": m_id} for token in rest.split(): if "=" in token: k, v = token.split("=", 1) parts[k.strip()] = v.strip() mappings.append(parts) return mappings def write_port_forwards_cfg(path: Path, mappings: list[str]) -> None: """Write a port-forwards.cfg from internal mapping strings.""" lines = [ "# Port forward configuration for Knoe services (generated).", "# Format: id: local= remote= ns= svc= address=", "", ] for m in mappings: parts: dict[str, str] = {} for token in m.split(";"): if "=" in token: k, v = token.split("=", 1) parts[k.strip()] = v.strip() m_id = parts.get("id", "") if not m_id: continue local = parts.get("hostPort", "") remote = parts.get("servicePort", "") ns = parts.get("namespace", "") target = parts.get("target", "") addr = parts.get("address", "0.0.0.0") svc = target[4:] if target.startswith("svc/") else target lines.append( f"{m_id}: local={local} remote={remote} ns={ns} svc={svc} address={addr}" ) path.write_text("\n".join(lines) + "\n") # --------------------------------------------------------------------------- # Conflict detection # --------------------------------------------------------------------------- def check_port_conflicts(mappings: list[dict]) -> list[str]: """Return list of conflict descriptions (empty == no conflicts).""" seen: dict[str, str] = {} conflicts: list[str] = [] for m in mappings: port = m.get("local", "") m_id = m.get("id", "?") if port in seen: conflicts.append(f"Port {port} conflict: {seen[port]} and {m_id}") else: seen[port] = m_id return conflicts # --------------------------------------------------------------------------- # kubectl port-forward launcher # --------------------------------------------------------------------------- def _build_kubectl_cmd(m: dict) -> list[str]: """Build a kubectl port-forward command from a mapping dict.""" ns = m.get("ns", "default") svc = m.get("svc", "") local = m.get("local", "") remote = m.get("remote", "") addr = m.get("address", "0.0.0.0") if not svc.startswith("svc/"): svc = f"svc/{svc}" cmd = ["kubectl", "port-forward", "-n", ns, svc, f"{local}:{remote}"] if addr and addr != "127.0.0.1": cmd.extend(["--address", addr]) return cmd def _start_port_forwards( mappings: list[dict], verbose: bool = False ) -> list[tuple[dict, subprocess.Popen | None]]: """Start kubectl port-forward for each mapping; return (mapping, proc) pairs.""" procs: list[tuple[dict, subprocess.Popen | None]] = [] for m in mappings: cmd = _build_kubectl_cmd(m) m_id = m.get("id", "?") if verbose: print(f"[PORT-FWD] Starting: {' '.join(cmd)}", flush=True) try: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) procs.append((m, proc)) print( f" ✓ {m_id}: localhost:{m.get('local','')} → {m.get('ns','')}/{m.get('svc','')}:{m.get('remote','')}", flush=True, ) except Exception as e: print(f" ✗ {m_id}: failed to start — {e}", flush=True) procs.append((m, None)) return procs # --------------------------------------------------------------------------- # Monitor loop # --------------------------------------------------------------------------- def run_monitor( controller, cfg_path: str | None, pf_cfg_path: str | None, verbose: bool = False ) -> int: """Main entry point for the 'monitor' subcommand.""" project_root = controller.project_root # Resolve knoe.cfg for context if cfg_path: knoe_cfg = Path(_expand_path(cfg_path)) else: conf_dir = knoe_conf.resolve_knoe_conf_dir(project_root) try: knoe_cfg = knoe_conf.ensure_entrypoint(conf_dir) except Exception: knoe_cfg = knoe_conf.entrypoint_path(conf_dir) # Read knoe.cfg (layered) to extract deployment parameters cfg = knoe_conf.load_layered_config(knoe_cfg) # Build vars: start with User section (concrete values), then overlay Global/System cfg_vars: dict[str, str] = {} if cfg.has_section("User"): cfg_vars.update(dict(cfg.items("User"))) overlay = _collect_cfg_vars(cfg) # Only overlay non-self-referencing values from Global/System for k, v in overlay.items(): if "${" not in v: cfg_vars[k] = v # Resolve remaining references iteratively for _ in range(5): changed = False for k, v in list(cfg_vars.items()): expanded = _expand_cfg_value(v, cfg_vars) if expanded != v: cfg_vars[k] = expanded changed = True if not changed: break mode = ( _expand_cfg_value( cfg.get("Global", "DEPLOYMENT_MODE", fallback="k3d"), cfg_vars ).strip() or "k3d" ) service_ns = ( _expand_cfg_value( cfg.get("Global", "SERVICE_NAMESPACE", fallback="default"), cfg_vars ).strip() or "default" ) db_ns = ( _expand_cfg_value( cfg.get("Global", "NAMESPACE", fallback="default"), cfg_vars ).strip() or "default" ) db_host_port = ( _expand_cfg_value( cfg.get("Global", "DB_HOST_PORT", fallback="5432"), cfg_vars ).strip() or "5432" ) supabase_enabled = _expand_cfg_value( cfg.get("Inputs", "init_cluster.supabase_enabled", fallback="false"), cfg_vars ).strip().lower() in ("true", "1", "yes") supabase_ns = "supabase" gitops_enabled = _expand_cfg_value( cfg.get("Inputs", "init_cluster.gitops_enabled", fallback="false"), cfg_vars ).strip().lower() in ("true", "1", "yes") gitops_ns = _expand_cfg_value( cfg.get("GitOps", "GITOPS_NAMESPACE", fallback="gitea"), cfg_vars ).strip() or "gitea" # Resolve port-forwards.cfg path if pf_cfg_path: pf_path = Path(pf_cfg_path) if not pf_path.is_absolute(): pf_path = project_root / pf_path else: pf_path = project_root / "conf" / "port-forwards.cfg" # Generate canonical mappings raw_mappings = _build_required_port_forwards( mode=mode, service_ns=service_ns, argocd_ns="argocd", 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, ) # Write / refresh the port-forwards.cfg write_port_forwards_cfg(pf_path, raw_mappings) print(f"[CONFIG] Port-forwards config written to {pf_path}", flush=True) # Parse the written file back for monitoring mappings = _parse_pf_cfg(pf_path) # Check for conflicts conflicts = check_port_conflicts(mappings) if conflicts: print("\n[CONFLICT] Port conflicts detected:", flush=True) for c in conflicts: print(f" ⚠ {c}", flush=True) return 1 print(f"\n[OK] {len(mappings)} port-forward mappings — no conflicts.\n", flush=True) # Print summary table print( f"{'Service':<25} {'Local':>6} → {'Remote':>6} {'Namespace':<25} {'Target':<40} {'Address'}", flush=True, ) print("-" * 130, flush=True) for m in mappings: print( f"{m.get('id',''):<25} {m.get('local',''):>6} → {m.get('remote',''):>6} {m.get('ns',''):<25} svc/{m.get('svc',''):<36} {m.get('address','')}", flush=True, ) print(flush=True) # Start port-forwards procs = _start_port_forwards(mappings, verbose=verbose) failed = [m_id for (m, p) in procs if p is None for m_id in [m.get("id", "?")]] started = [(m, p) for (m, p) in procs if p is not None] if failed: print(f"\n[WARN] Failed to start: {', '.join(failed)}", flush=True) if not started: print("[ERROR] No port-forwards could be started.", flush=True) return 1 # Brief monitoring: wait a few seconds and check which processes are still alive print(f"\n[MONITOR] Checking port-forward health (3s)...", flush=True) time.sleep(3) alive = 0 dead_services: list[str] = [] for m, p in started: m_id = m.get("id", "?") if p.poll() is None: alive += 1 else: stderr_out = "" try: stderr_out = p.stderr.read().decode(errors="replace").strip() except Exception: pass dead_services.append(m_id) print( f" ✗ {m_id}: exited (rc={p.returncode}) {stderr_out[:200]}", flush=True ) print(f"\n[RESULT] {alive}/{len(started)} port-forwards running.", flush=True) if dead_services: print( f"[WARN] Services that did not come up: {', '.join(dead_services)}", flush=True, ) # Cleanup for m, p in started: if p.poll() is None: try: p.terminate() except Exception: pass return 0 if alive > 0 else 1