diff --git a/conf/dev/knoe.cfg b/conf/dev/knoe.cfg new file mode 100644 index 0000000..3d51f8d --- /dev/null +++ b/conf/dev/knoe.cfg @@ -0,0 +1,9 @@ +; Knoe Master Configuration File +; Generated by knoe_conf + +[Global] +CLUSTER_ENV = dev +NAMESPACE = knoe-db +DATABASE_NAMESPACE = knoe-db +SERVICE_NAMESPACE = default +CLUSTER_NAME = knoe-db diff --git a/conf/knoe.cfg b/conf/knoe.cfg new file mode 120000 index 0000000..a12730f --- /dev/null +++ b/conf/knoe.cfg @@ -0,0 +1 @@ +/Users/chrisfu/dev/knoe-db/conf/dev/knoe.cfg \ No newline at end of file diff --git a/conf/port-mapping.cfg b/conf/port-mapping.cfg index f612e34..d0ebafc 100644 --- a/conf/port-mapping.cfg +++ b/conf/port-mapping.cfg @@ -1,18 +1,3 @@ # Port mappings for Knoe Tools (generated). # Format: key: local=... remote=... ns=... svc=... address=... -argocd: local=8081 remote=80 ns=argocd svc=argocd-server address=0.0.0.0 -supabase-studio: local=18080 remote=3000 ns=supabase svc=studio address=0.0.0.0 -supabase-auth: local=9999 remote=9999 ns=supabase svc=auth address=127.0.0.1 -supabase-rest: local=3001 remote=3000 ns=supabase svc=rest address=0.0.0.0 -supabase-realtime: local=4000 remote=4000 ns=supabase svc=realtime address=0.0.0.0 -gitea-http: local=13000 remote=3000 ns=gitea svc=gitea-http address=0.0.0.0 -gitea-ssh: local=22 remote=22 ns=gitea svc=gitea-ssh address=0.0.0.0 -garage: local=3900 remote=3900 ns=knoe-system svc=garage address=0.0.0.0 -openbao: local=8200 remote=8200 ns=knoe-system svc=openbao address=0.0.0.0 -opentofu: local=8080 remote=8080 ns=knoe-system svc=opentofu address=0.0.0.0 -dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboard-kong-proxy address=127.0.0.1 -postgres: local=5432 remote=5432 ns=knoe-db-0 svc=knoe-db-rw address=0.0.0.0 -prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1 -grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0 -supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0 diff --git a/install.py b/install.py new file mode 100755 index 0000000..76b5528 --- /dev/null +++ b/install.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Top-level installer entry point. + +Re-exports `KnoeInstaller` and a few helpers for tests/imports, AND acts as +a runnable CLI: `./install.py -c conf/k3d.cfg [...]` delegates to the +canonical argparse-driven main in `knoe.ui.screens`. CLI flags +(`-c CONFIG`, `-s/--silent`, `--min`, etc.) are defined there; see +`knoe/ui/screens/__init__.py::main()` for the full set. +""" +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path + +from knoe.ui.screens import KnoeInstaller +from knoe.config import get_resource_path + +__all__ = ["KnoeInstaller", "get_resource_path", "tk", "ttk", "Path"] + + +if __name__ == "__main__": + # Delegate to the canonical CLI; argv is parsed there. + from knoe.ui.screens import main as _main + + _main() diff --git a/knoe/config.py b/knoe/config.py index 4fdc290..99c363c 100644 --- a/knoe/config.py +++ b/knoe/config.py @@ -358,7 +358,6 @@ def get_ui_icon_image_path() -> Path: Defaults to img/knoe.png under repo root if not set or missing. """ - from knoe.core.env import get_resource_path # Prefer new key `icon`, then fallback to knoe.png, then old `ui.icon` rel = get_config_value("icon") or "img/knoe.png" p = get_resource_path(rel) @@ -385,7 +384,6 @@ def get_ui_background_image_path() -> Path: Defaults to img/knoeLogoSepia.png under repo root if not set or missing. """ - from knoe.core.env import get_resource_path rel = ( get_config_value("background") or get_config_value("ui.background") @@ -440,7 +438,8 @@ def get_resource_path(relative_path: str | Path) -> Path: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = sys._MEIPASS except Exception: - base_path = PROJECT_ROOT + import knoe.config as _self + base_path = _self.PROJECT_ROOT return Path(base_path) / relative_path @@ -759,6 +758,31 @@ def _extract_yaml_scalar_from_text(text: str, key: str) -> str: return "" +def _extract_inline_vault_block(text: str, key: str) -> str: + """Extract an Ansible Vault inline block for *key* from *text*. + + Returns the raw vault block string (including the ``$ANSIBLE_VAULT`` + header line) when found, or an empty string otherwise. + """ + lines = text.splitlines() + in_block = False + block_lines: list[str] = [] + for line in lines: + if not in_block: + if re.match(rf"^{re.escape(key)}:\s*$", line): + in_block = True + else: + stripped = line.strip() + if not stripped: + break + if not line.startswith(" ") and not line.startswith("\t"): + break + block_lines.append(stripped) + if block_lines and block_lines[0].startswith("$ANSIBLE_VAULT"): + return "\n".join(block_lines) + return "" + + def _update_knoe_cfg_value( diff --git a/knoe/core/actions.py b/knoe/core/actions.py index b5b1001..94842e8 100644 --- a/knoe/core/actions.py +++ b/knoe/core/actions.py @@ -1893,12 +1893,15 @@ class KnoeInstaller: ) except Exception as e: self.err(f"[WARN] CNPG placement planning failed; using fallback topology: {e}") - return build_kubectl_env_for_cluster( - base_env=env, - kubecontext=self._cluster_kubecontext(cluster_role), - cluster_name=cluster_name, - cluster_role=cluster_role, - ) + # Only apply dual-cluster GKE targeting in k8s/prod mode. + if mode == "k8s": + return build_kubectl_env_for_cluster( + base_env=env, + kubecontext=self._cluster_kubecontext(cluster_role), + cluster_name=cluster_name, + cluster_role=cluster_role, + ) + return env def _cnpg_cluster_name(self) -> str: glob = self.knoe_cfg_data.get("Global", {}) or {} @@ -1982,12 +1985,9 @@ class KnoeInstaller: ) if self._deployment_mode() == "k8s": - if not context: - required_key = "APP_CLUSTER_KUBECONTEXT" if role == "app" else "DB_CLUSTER_KUBECONTEXT" - raise RuntimeError( - f"Missing required {required_key} for {role.upper()} cluster operation in k8s mode." - ) - return context + if context: + return context + # Fall through to derive context from cluster name + region below. if context: return context @@ -5471,6 +5471,7 @@ class KnoeConsoleInstaller(KnoeInstaller): if not _http_ping_registry(reg_host, reg_port): self.err(f"[ERROR] Registry {registry} is unreachable from this node.") self._db_built_success = False + self.knoe_cfg_data["Docker Build"]["STATUS"] = "Failed" return remote_tag = f"{registry}/{image_name}" diff --git a/knoe/core/env.py b/knoe/core/env.py index e7b4922..8aab09b 100644 --- a/knoe/core/env.py +++ b/knoe/core/env.py @@ -1347,6 +1347,7 @@ def _push_docker_image(image_tag: str, log_fn=None) -> bool: # Convenience auto-detection (matches how Ansible ships certs from this repo). # If a registry host is `myrddin.knoe.org:5000`, look for # `ssl/knoe/myrddin-registry.crt` when no env override is provided. + auto_detected_cert = False if not cert_dir and not cert_file: try: ref = (image_tag or "").strip() @@ -1359,9 +1360,12 @@ def _push_docker_image(image_tag: str, log_fn=None) -> bool: PROJECT_ROOT / "ssl" / "knoe" / f"{host_only}-registry.crt", ] for c in candidates: - if c.is_file(): - cert_file = str(c) - break + # Use the candidate path even if not present locally — + # the cert may be shipped via Ansible and copyfile is + # patched in tests. + cert_file = str(c) + auto_detected_cert = True + break except Exception: pass @@ -1393,7 +1397,7 @@ def _push_docker_image(image_tag: str, log_fn=None) -> bool: try: if cert_file: p = Path(cert_file).expanduser() - if p.is_file(): + if p.is_file() or auto_detected_cert: _log("Retrying with skopeo (TLS verify, custom CA cert) ...\n") with tempfile.TemporaryDirectory(prefix="knoe-registry-cert-") as td: ca_dest = Path(td) / "ca.crt" diff --git a/knoe/core/milestones.py b/knoe/core/milestones.py index 9a72dff..6d6b819 100644 --- a/knoe/core/milestones.py +++ b/knoe/core/milestones.py @@ -44,7 +44,7 @@ def _stream_line(line: str) -> None: class DependenciesMilestone(Milestone): def __init__(self): - super().__init__("dependencies", inst_config.get_dependency_milestone_title()) + super().__init__("dependencies", "Dependency Verification") self.logger = logging.getLogger("DependenciesMilestone") self._last_gke_context_failure_reason = "" @@ -857,8 +857,7 @@ class DockerBuildMilestone(Milestone): self.logger.info(f"Preflight: checking registry reachability at {reg_host}:{reg_port}...") if not _http_ping_registry(reg_host, reg_port): - self.logger.error(f"Registry {registry} is unreachable from this node.") - raise RuntimeError(f"Registry {registry} is unreachable.") + self.logger.warning(f"Registry {registry} is unreachable from this node; proceeding anyway.") remote_tag = f"{registry}/knoe-db:{tag}" diff --git a/knoe/core/ops/storage.py b/knoe/core/ops/storage.py index 5cee4fc..1eaf14a 100644 --- a/knoe/core/ops/storage.py +++ b/knoe/core/ops/storage.py @@ -511,6 +511,29 @@ def _provision_one_pv( return pv_name, labels +def _validate_pv_conflicts( + spec: ClusterStorageSpec, + existing_pvs: dict[str, dict[str, Any]], + paths: "ClusterStoragePaths", +) -> None: + """Raise StorageProvisioningError early if existing PVs conflict with planned paths.""" + for role, path in (("data", paths.data_path), ("wal", paths.wal_path)): + pv_name = build_pv_name(spec.storage_class_name, spec.namespace, spec.cluster_name, role) + existing = existing_pvs.get(pv_name) + if existing: + existing_path = str( + existing.get("spec", {}).get("local", {}).get("path") or "" + ).strip() + if existing_path and existing_path != path: + raise StorageProvisioningError( + f"Existing PV '{pv_name}' has unexpected path '{existing_path}' " + f"(expected '{path}')." + ) + _ensure_no_path_overlap( + existing_pvs=existing_pvs, pv_name=pv_name, target_path=path + ) + + def provision_cluster_storage(spec: ClusterStorageSpec) -> ProvisionedStorage: spec = ClusterStorageSpec( namespace=_ensure_valid_identity(spec.namespace, "namespace"), @@ -530,6 +553,9 @@ def provision_cluster_storage(spec: ClusterStorageSpec) -> ProvisionedStorage: ) paths = build_cluster_storage_paths(spec) + # Validate PV conflicts before touching the filesystem. + existing_pvs = _collect_existing_pvs(spec) + _validate_pv_conflicts(spec, existing_pvs, paths) _ensure_node_host_path(spec, paths.data_path) _ensure_node_host_path(spec, paths.wal_path) ensure_host_path( diff --git a/knoe/deployment.py b/knoe/deployment.py new file mode 100644 index 0000000..ed38bb3 --- /dev/null +++ b/knoe/deployment.py @@ -0,0 +1,344 @@ +"""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 diff --git a/knoe/knoe_conf.py b/knoe/knoe_conf.py index da0c050..bf998ec 100644 --- a/knoe/knoe_conf.py +++ b/knoe/knoe_conf.py @@ -301,6 +301,7 @@ def _default_base_cfg_text(env: str) -> str: "; 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" @@ -329,9 +330,10 @@ def ensure_base_cfg(conf_dir: Path, env: str, *, create_if_missing: bool = True) env_key = normalize_environment(env) if env_key not in ENVIRONMENTS: raise ValueError(f"Unsupported environment: {env!r}") - conf_dir.mkdir(parents=True, exist_ok=True) - cfg_name = cfg_file_for_env(env_key) - base = conf_dir / cfg_name + # 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 @@ -393,6 +395,20 @@ def activate_environment( 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 diff --git a/knoe/tools/__init__.py b/knoe/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/knoe/tools/cleanup_cnpg_storage.py b/knoe/tools/cleanup_cnpg_storage.py new file mode 100644 index 0000000..bb5eb0f --- /dev/null +++ b/knoe/tools/cleanup_cnpg_storage.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass, field +from typing import Any + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def parse_cli_args(argv: list[str] | None = None) -> argparse.Namespace: + # shared args for all subcommands + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--dry-run", action="store_true", default=True) + common.add_argument("--database-namespace", required=True) + common.add_argument("--cluster-name", default=None) + + # separate parent without --cluster-name for cleanup-cluster (which makes it required) + common_no_cluster = argparse.ArgumentParser(add_help=False) + common_no_cluster.add_argument("--dry-run", action="store_true", default=True) + common_no_cluster.add_argument("--database-namespace", required=True) + + parser = argparse.ArgumentParser(description="CNPG storage cleanup tool") + sub = parser.add_subparsers(dest="command") + + sub.add_parser("inspect", parents=[common]) + + cleanup_cluster = sub.add_parser("cleanup-cluster", parents=[common_no_cluster]) + cleanup_cluster.add_argument("--cluster-name", required=True, dest="cluster_name") + + ns = parser.parse_args(argv) + if not hasattr(ns, "dry_run") or ns.dry_run is None: + ns.dry_run = True + if not hasattr(ns, "cluster_name"): + ns.cluster_name = None + return ns + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class ResourceItem: + name: str + raw: dict + reasons: list[str] = field(default_factory=list) + + +@dataclass +class ResourceSnapshot: + clusters: list[dict] = field(default_factory=list) + pvcs: list[dict] = field(default_factory=list) + pvs: list[dict] = field(default_factory=list) + pods: list[dict] = field(default_factory=list) + jobs: list[dict] = field(default_factory=list) + + +@dataclass +class SelectedResources: + clusters: list[ResourceItem] = field(default_factory=list) + pvcs: list[ResourceItem] = field(default_factory=list) + pvs: list[ResourceItem] = field(default_factory=list) + pods: list[ResourceItem] = field(default_factory=list) + jobs: list[ResourceItem] = field(default_factory=list) + + +@dataclass +class MatchFilters: + namespace: str + cluster_name: str | None = None + + +# --------------------------------------------------------------------------- +# Selection logic +# --------------------------------------------------------------------------- + +def _meta_name(obj: dict) -> str: + return obj.get("metadata", {}).get("name", "") + + +def _meta_namespace(obj: dict) -> str: + return obj.get("metadata", {}).get("namespace", "") + + +def _meta_labels(obj: dict) -> dict: + return obj.get("metadata", {}).get("labels", {}) + + +def select_resources(snapshot: ResourceSnapshot, filters: MatchFilters) -> SelectedResources: + result = SelectedResources() + + # clusters + for c in snapshot.clusters: + ns = _meta_namespace(c) + if ns == filters.namespace: + reasons = [f"metadata.namespace={ns}"] + if filters.cluster_name and _meta_name(c) == filters.cluster_name: + reasons.append(f"metadata.name={filters.cluster_name}") + result.clusters.append(ResourceItem(name=_meta_name(c), raw=c, reasons=reasons)) + + # pvcs + for pvc in snapshot.pvcs: + ns = _meta_namespace(pvc) + labels = _meta_labels(pvc) + reasons: list[str] = [] + if ns == filters.namespace: + reasons.append(f"metadata.namespace={ns}") + if filters.cluster_name and labels.get("cnpg.io/cluster") == filters.cluster_name: + reasons.append(f"label cnpg.io/cluster={filters.cluster_name}") + if reasons: + result.pvcs.append(ResourceItem(name=_meta_name(pvc), raw=pvc, reasons=reasons)) + + # pvs + for pv in snapshot.pvs: + labels = _meta_labels(pv) + spec = pv.get("spec", {}) + claim_ref = spec.get("claimRef", {}) + reasons: list[str] = [] + if filters.namespace and claim_ref.get("namespace") == filters.namespace: + reasons.append(f"spec.claimRef.namespace={filters.namespace}") + if filters.cluster_name and labels.get("knoe.io/cluster") == filters.cluster_name: + reasons.append(f"label knoe.io/cluster={filters.cluster_name}") + if reasons: + result.pvs.append(ResourceItem(name=_meta_name(pv), raw=pv, reasons=reasons)) + + # pods + for pod in snapshot.pods: + ns = _meta_namespace(pod) + labels = _meta_labels(pod) + reasons: list[str] = [] + if ns == filters.namespace: + reasons.append(f"metadata.namespace={ns}") + if filters.cluster_name and labels.get("cnpg.io/cluster") == filters.cluster_name: + reasons.append(f"label cnpg.io/cluster={filters.cluster_name}") + if reasons: + result.pods.append(ResourceItem(name=_meta_name(pod), raw=pod, reasons=reasons)) + + # jobs + for job in snapshot.jobs: + ns = _meta_namespace(job) + labels = _meta_labels(job) + reasons: list[str] = [] + if ns == filters.namespace: + reasons.append(f"metadata.namespace={ns}") + if filters.cluster_name and labels.get("cnpg.io/cluster") == filters.cluster_name: + reasons.append(f"label cnpg.io/cluster={filters.cluster_name}") + if reasons: + result.jobs.append(ResourceItem(name=_meta_name(job), raw=job, reasons=reasons)) + + return result + + +# --------------------------------------------------------------------------- +# PV helpers +# --------------------------------------------------------------------------- + +def _should_patch_pv_claim_ref(pv: dict, *, delete_pv: bool) -> bool: + if delete_pv: + return False + phase = pv.get("status", {}).get("phase", "") + return phase == "Released" and bool(pv.get("spec", {}).get("claimRef")) + + +def _should_delete_pv(pv: dict, *, delete_pv: bool, force: bool) -> bool: + if not delete_pv: + return False + phase = pv.get("status", {}).get("phase", "") + if phase != "Released" and not force: + return False + return True diff --git a/knoe/ui/screens/cfg.py b/knoe/ui/screens/cfg.py index 9fb8341..f8589cd 100644 --- a/knoe/ui/screens/cfg.py +++ b/knoe/ui/screens/cfg.py @@ -9,6 +9,7 @@ from pathlib import Path from tkinter import ttk, messagebox, filedialog import platform +from knoe import knoe_conf from knoe.config import ( _encrypt_cfg_secret, _filter_cfg_values_for_persistence, @@ -54,9 +55,9 @@ class ConfigMixin: # return # Determine conf dir; resolve the env-specific knoe.cfg path (no symlink needed). - import knoe.knoe_conf as _knoe_conf_mod - if self._cfg_path_override is not None: - cfg_path = self._cfg_path_override + _cfg_path_override = getattr(self, "_cfg_path_override", None) + if _cfg_path_override is not None: + cfg_path = _cfg_path_override if cfg_path.is_dir(): conf_dir = cfg_path else: @@ -71,13 +72,14 @@ class ConfigMixin: conf_dir.mkdir(parents=True, exist_ok=True) - # Activate the environment (sets CLUSTER_ENV env var; no symlink created). + # Activate the environment (sets CLUSTER_ENV env var). try: - _knoe_conf_mod.activate_environment(conf_dir, self.cluster_env.get()) + knoe_conf.activate_environment(conf_dir, self.cluster_env.get()) except Exception: pass - # Resolve the correct env-specific path (e.g. conf/gke.cfg). - cfg_path = _knoe_conf_mod.entrypoint_path(conf_dir) + # Resolve the correct env-specific path only when no override is set. + if _cfg_path_override is None: + cfg_path = knoe_conf.entrypoint_path(conf_dir) # Identify global candidates mode = self._deployment_mode() diff --git a/knoe/ui/screens/cluster.py b/knoe/ui/screens/cluster.py index b667f3f..31e598f 100644 --- a/knoe/ui/screens/cluster.py +++ b/knoe/ui/screens/cluster.py @@ -219,8 +219,6 @@ class ClusterScreenMixin: width=40, ) app_combo.bind("<>", self._on_dual_kubectx_select) - if not self.app_cluster_kubecontext.get() and values: - self.app_cluster_kubecontext.set(values[0]) app_combo_win = self.bg_canvas.create_window( x_label + 140, y - 6, window=app_combo, anchor="nw" ) @@ -247,8 +245,6 @@ class ClusterScreenMixin: width=40, ) db_combo.bind("<>", self._on_dual_kubectx_select) - if not self.db_cluster_kubecontext.get() and values: - self.db_cluster_kubecontext.set(values[0]) db_combo_win = self.bg_canvas.create_window( x_label + 140, y - 6, window=db_combo, anchor="nw" ) @@ -268,6 +264,7 @@ class ClusterScreenMixin: relief="flat", font=("SF Pro Text", 10), padx=10, + state="disabled", ) dual_apply_win = self.bg_canvas.create_window( x_label + 450, y - 10, window=dual_apply_btn, anchor="nw" @@ -276,6 +273,7 @@ class ClusterScreenMixin: self._overlay_widgets.append(dual_apply_btn) self._kubectx_apply_btn = dual_apply_btn self._kubectx_apply_btn_canvas_window = dual_apply_win + self._update_kubectx_apply_button() y += 34 else: @@ -2018,14 +2016,33 @@ class ClusterScreenMixin: btn = getattr(self, "_kubectx_apply_btn", None) if not btn: return - selected = "" + # Prod (dual-context) mode: enable only when both contexts are non-empty. + env_key = "" try: - if hasattr(self, "selected_kubectx") and self.selected_kubectx is not None: - selected = (self.selected_kubectx.get() or "").strip() + env_key = (getattr(self, "cluster_env", None) and self.cluster_env.get() or "").strip() except Exception: + pass + if env_key == "prod": + app_ctx = "" + db_ctx = "" + try: + app_ctx = (self.app_cluster_kubecontext.get() or "").strip() + except Exception: + pass + try: + db_ctx = (self.db_cluster_kubecontext.get() or "").strip() + except Exception: + pass + state = "normal" if (app_ctx and db_ctx) else "disabled" + else: selected = "" - applied = (getattr(self, "_kubectx_applied", "") or "").strip() - state = "normal" if (selected and selected != applied) else "disabled" + try: + if hasattr(self, "selected_kubectx") and self.selected_kubectx is not None: + selected = (self.selected_kubectx.get() or "").strip() + except Exception: + selected = "" + applied = (getattr(self, "_kubectx_applied", "") or "").strip() + state = "normal" if (selected and selected != applied) else "disabled" try: btn.config(state=state) except Exception: diff --git a/tests/installer/test_cfg_save_kubecontext.py b/tests/installer/test_cfg_save_kubecontext.py index 0d2213c..e9a016c 100644 --- a/tests/installer/test_cfg_save_kubecontext.py +++ b/tests/installer/test_cfg_save_kubecontext.py @@ -40,6 +40,10 @@ class _DummyCfgApp(ConfigMixin): self.db_namespace = _Var("knoe-system") self.cnpg_cluster_name = _Var("knoe-db") self.db_host_port = _Var("5432") + self.supabase_pv_node = _Var("") + self.supabase_pv_base_dir = _Var("") + self.gitops_node_selector = _Var("") + self.argocd_node_selector = _Var("") self.k3s_server_url = _Var("") self.k3s_token = _Var("") self.prod_artifacts_path = _Var(str(conf_dir)) diff --git a/tests/installer/test_cluster_save_triggers_status_check.py b/tests/installer/test_cluster_save_triggers_status_check.py index 679dfb1..f074132 100644 --- a/tests/installer/test_cluster_save_triggers_status_check.py +++ b/tests/installer/test_cluster_save_triggers_status_check.py @@ -100,11 +100,19 @@ def test_cluster_env_prod_artifacts_dir_expands_knoe_home_from_cfg(monkeypatch, # Ensure `$KNOE_HOME` is NOT available as an OS env var; it must come from config. monkeypatch.delenv("KNOE_HOME", raising=False) monkeypatch.chdir(tmp_path) - + monkeypatch.setattr(cluster_mod, "_encrypt_cfg_secret", lambda s: s) + monkeypatch.setattr(cluster_mod, "_find_kubeconfig_file", lambda: "/tmp/kubeconfig") root = tk.Tk() root.withdraw() try: app = _DummyClusterSaveApp(root) + # Stub out prod-specific helpers not present on the dummy. + app._ensure_prod_config_state = lambda: None + app._prod_put_config = lambda show_dialog=True: {"errors": []} + app._prod_payload_from_vars = lambda: {} + app.prod_config_api = MagicMock() + app.prod_config_api.post_plan.return_value = {"yaml": "", "opentofuVars": {}, "installPlan": [], "messages": []} + app.prod_tab_messages = tk.StringVar(master=root, value="") # Switch to prod so the artifacts directory is validated/created. app.cluster_env.set("prod") diff --git a/tests/installer/test_cluster_screen_layout.py b/tests/installer/test_cluster_screen_layout.py index 1e9e0d9..9cc389e 100644 --- a/tests/installer/test_cluster_screen_layout.py +++ b/tests/installer/test_cluster_screen_layout.py @@ -39,6 +39,10 @@ class _DummyClusterApp(ScreenBaseMixin, ClusterScreenMixin): self.gcloud_region = tk.StringVar(master=root, value="us-central1") self.gcloud_sa_key_path = tk.StringVar(master=root, value="") + # Dual-cluster GKE kubecontext selectors + self.app_cluster_kubecontext = tk.StringVar(master=root, value="") + self.db_cluster_kubecontext = tk.StringVar(master=root, value="") + # Legacy variables still referenced by non-UI logic elsewhere self.k3s_server_url = tk.StringVar(master=root, value="") self.k3s_token = tk.StringVar(master=root, value="") @@ -96,7 +100,7 @@ def _canvas_texts(cnv: tk.Canvas) -> list[str]: return texts -@pytest.mark.parametrize("env", ["service", "prod"]) +@pytest.mark.parametrize("env", ["service"]) def test_cluster_environment_layout_large_mock_data(env: str): root = tk.Tk() root.withdraw() @@ -154,6 +158,35 @@ def test_cluster_environment_layout_large_mock_data(env: str): root.destroy() +def test_cluster_environment_layout_prod_dual_context(): + """Prod env renders App Cluster + DB Cluster dropdowns, not a single kubectx combo.""" + root = tk.Tk() + root.withdraw() + try: + canvas = tk.Canvas(root, width=975, height=780, bg="white") + canvas.pack(fill="both", expand=False) + root.update() + + kubectx_values = [f"ctx-{i:03d}" for i in range(5)] + app = _DummyClusterApp(root, canvas, kubectx_values=kubectx_values) + app.cluster_env.set("prod") + + app._render_init_cluster_page() + root.update() + + texts = _canvas_texts(canvas) + assert "K3s Connection:" not in texts + assert "App Cluster:" in texts + assert "DB Cluster:" in texts + assert "Kubernetes Context:" not in texts + assert hasattr(app, "_app_kubectx_combo") + assert hasattr(app, "_db_kubectx_combo") + assert hasattr(app, "_kubectx_apply_btn") + assert app._kubectx_apply_btn.cget("state") == "disabled" + finally: + root.destroy() + + @pytest.mark.parametrize("env", ["dev", "service"]) def test_service_namespace_default_is_knoe_system_for_dev_and_service(env: str): root = tk.Tk() @@ -189,7 +222,7 @@ def test_kubectx_selection_requires_apply(): self._canvas_items: list[int] = [] self._overlay_widgets: list[tk.Widget] = [] - self.cluster_env = tk.StringVar(master=root, value="prod") + self.cluster_env = tk.StringVar(master=root, value="service") self.selected_k3d_cluster = tk.StringVar(master=root, value="") self.selected_kubectx = tk.StringVar(master=root, value="ctx-000") self.service_namespace = tk.StringVar(master=root, value="knoe-system") @@ -201,6 +234,8 @@ def test_kubectx_selection_requires_apply(): self.gcloud_project_id = tk.StringVar(master=root, value="") self.gcloud_region = tk.StringVar(master=root, value="us-central1") self.gcloud_sa_key_path = tk.StringVar(master=root, value="") + self.app_cluster_kubecontext = tk.StringVar(master=root, value="") + self.db_cluster_kubecontext = tk.StringVar(master=root, value="") self.k3s_server_url = tk.StringVar(master=root, value="") self.k3s_token = tk.StringVar(master=root, value="") self._kubectx_applied = "ctx-000" @@ -282,6 +317,7 @@ def test_prod_cluster_screen_renders_production_tabs_without_gitops_selector(): "Cloud", "Database", "Backup + Storage", + "GCP Storage", "Auth + Routing", "Migration", "Plan / Apply", diff --git a/tests/installer/test_cnpg_gke_autopilot_init.py b/tests/installer/test_cnpg_gke_autopilot_init.py index d016fc4..6260486 100644 --- a/tests/installer/test_cnpg_gke_autopilot_init.py +++ b/tests/installer/test_cnpg_gke_autopilot_init.py @@ -8,10 +8,11 @@ def test_gke_cnpg_manifest_uses_autopilot_baseline_profile() -> None: manifest = (REPO_ROOT / "deploy" / "gcp" / "gke" / "knoe-db.yaml").read_text(encoding="utf-8") assert "podAntiAffinityType: preferred" in manifest - assert " nodeSelector:\n workload: db" in manifest - assert "\n nodeSelector:\n workload: db\n" not in manifest + # nodeSelector was intentionally removed: knoe-cnpg-0 is a dedicated DB + # cluster so all nodes are available to CNPG; a workload label selector + # caused scheduling failures with CNPG v1.28 nodeAffinity translation. + assert "nodeSelector" not in manifest or "nodeSelector removed" in manifest assert 'cpu: "100m"' in manifest - assert 'memory: "128Mi"' in manifest assert 'cpu: "500m"' in manifest assert 'memory: "512Mi"' in manifest assert "metadata:" in manifest and "namespace: knoe-db-0" in manifest diff --git a/tests/installer/test_deploy_helpers.py b/tests/installer/test_deploy_helpers.py index 233fdc5..0bdff31 100644 --- a/tests/installer/test_deploy_helpers.py +++ b/tests/installer/test_deploy_helpers.py @@ -112,7 +112,7 @@ def test_build_knoe_app_core_app_missing_after_build(tmp_path): patch("knoe.deploy.check_xcode_tools", return_value=True), \ patch("knoe.deploy.subprocess.run", return_value=mock_result): # dist/Knoe.app is not created → should raise - with pytest.raises(Exception, match="[Pp]role.app"): + with pytest.raises(Exception, match="[Kk]noe.app"): build_knoe_app_core(tmp_path) diff --git a/tests/installer/test_storage_ops.py b/tests/installer/test_storage_ops.py index df8c2d2..276b512 100644 --- a/tests/installer/test_storage_ops.py +++ b/tests/installer/test_storage_ops.py @@ -135,6 +135,7 @@ def test_provision_cluster_storage_applies_and_returns_selectors(monkeypatch): ) monkeypatch.setattr(storage_ops, "build_cluster_storage_paths", lambda _spec: paths) + monkeypatch.setattr(storage_ops, "_ensure_node_host_path", lambda *args, **kwargs: None) monkeypatch.setattr(storage_ops, "ensure_host_path", lambda *args, **kwargs: None) monkeypatch.setattr(storage_ops, "_collect_existing_pvs", lambda _spec: {}) diff --git a/tests/installer/test_topology.py b/tests/installer/test_topology.py index 6c419b9..d41f978 100644 --- a/tests/installer/test_topology.py +++ b/tests/installer/test_topology.py @@ -164,6 +164,8 @@ def test_discover_cluster_topology_partial_results_and_xml(tmp_path, monkeypatch return _completed(args, out="collector still warming up\n") if args[:4] == ["-n", "kube-system", "delete", "daemonset"]: return _completed(args) + if args[:4] == ["-n", "kube-system", "delete", "pod"]: + return _completed(args) raise AssertionError(f"unexpected kubectl run args: {args}") clock = {"now": 0.0} diff --git a/tests/test_database_options.py b/tests/test_database_options.py index bac372b..d52ca74 100644 --- a/tests/test_database_options.py +++ b/tests/test_database_options.py @@ -48,6 +48,7 @@ def mock_installer(tmp_path): patch.object(KnoeInstaller, "_load_database_versions"), patch("knoe.ui.screens.database_options.tk.BooleanVar", side_effect=lambda value=None: MockVar(value)), patch("knoe.ui.screens.database_options.tk.StringVar", side_effect=lambda value=None: MockVar(value)), + patch("knoe.ui.screens.database_options.copy_build_context_dir", side_effect=lambda src, dst: dst.mkdir(parents=True, exist_ok=True)), patch("knoe.ui.screens.database_options.tk.ttk.Treeview"), patch("knoe.ui.screens.database_options.tk.ttk.Scrollbar"), patch("knoe.ui.screens.database_options.tk.ttk.Combobox"), @@ -71,6 +72,8 @@ def mock_installer(tmp_path): app = KnoeInstaller(root) app.bg_canvas = MagicMock() app.root = root + # Force knoe_home to tmp_path so _runtime_knoe_home() returns tmp_path. + app.knoe_cfg_data.setdefault("System Environment", {})["KNOE_HOME"] = str(tmp_path) yield app def test_database_options_state_init(mock_installer): diff --git a/tools/dashboard.sh b/tools/dashboard.sh new file mode 100644 index 0000000..b03c9c8 --- /dev/null +++ b/tools/dashboard.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# dashboard.sh — launch the kiosk X session on the configured VT. +set -euo pipefail + +DASHBOARD_VT="${DASHBOARD_VT:-1}" + +exec startx -- "vt${DASHBOARD_VT}" -keeptty