"""Milestone abstraction for the UI-agnostic installer core.""" from __future__ import annotations from abc import ABC, abstractmethod from typing import Callable, Sequence from . import config as inst_config from .state import InstallerState ProgressCallback = Callable[[str, float | None], None] ValidationResult = str | Sequence[str] | None class Milestone(ABC): """Base class for installer milestones. Milestones encapsulate business logic, validation, and flow control. They must not import or depend on UI toolkits. """ id: str title: str def __init__(self, milestone_id: str, title: str): self.id = milestone_id self.title = title def validate(self, state: InstallerState) -> ValidationResult: """Return a validation error or None when valid.""" return None @abstractmethod def execute( self, state: InstallerState, progress: ProgressCallback | None = None ) -> None: """Execute milestone logic. Use the progress callback to emit updates.""" raise NotImplementedError def next(self, state: InstallerState) -> str | None: """Return the next milestone id or None to finish.""" return None def _parse_bool(self, val: any, default: bool | None = False) -> bool | None: return inst_config._parse_bool(val, default) def _run_cmd( self, cmd: str | list[str], cwd: str | None = None, env: dict | None = None, on_stdout: callable | None = None, ) -> int: if isinstance(cmd, str): cmd = ["bash", "-c", cmd] import os import subprocess try: proc_env = os.environ.copy() if env: proc_env.update(env) proc_env = inst_config._augment_env_for_dependency_backend(proc_env) proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, cwd=cwd, env=proc_env, ) for line in iter(proc.stdout.readline, ""): if on_stdout: on_stdout(line) return proc.wait() except Exception: return 1 def _get_script_env(self, state: InstallerState) -> dict: import os from pathlib import Path env = os.environ.copy() project_root = inst_config.PROJECT_ROOT env["PROLE_HOME"] = str(project_root) env["PROLE_SERVICE"] = str(project_root) namespace = (state.inputs.get("init_password.db_namespace", "") or "").strip() env["NAMESPACE"] = namespace or "default" env["PROLE_NAMESPACE"] = env["NAMESPACE"] env["DATABASE_NAMESPACE"] = env["NAMESPACE"] # Paths — prefer the env-specific conf subdir (e.g. conf/service/) so that # init scripts source the correct prole.cfg and don't fall back to the # conf/prole.cfg symlink which may point to a different cluster env. # Priority: (1) inherited PROLE_CONF from parent shell when it points to a # valid env-specific subdir, (2) cluster_env-derived subdir, (3) stored value. _inherited_conf = os.environ.get("PROLE_CONF", "") _cluster_env_for_conf = state.inputs.get("init_cluster.cluster_env", "") _env_conf_dir = ( Path(project_root) / "conf" / _cluster_env_for_conf if _cluster_env_for_conf else None ) if _inherited_conf and (Path(_inherited_conf) / "prole.cfg").exists(): env["PROLE_CONF"] = _inherited_conf elif _env_conf_dir and _env_conf_dir.is_dir(): env["PROLE_CONF"] = str(_env_conf_dir) else: env["PROLE_CONF"] = state.inputs.get( "env_setup.PROLE_CONF", str(project_root / "conf") ) env["PROLE_DATA"] = state.inputs.get( "env_setup.PROLE_DATA", str(project_root / "knoe-db" / "data") ) env["PROLE_LOGS"] = state.inputs.get( "env_setup.PROLE_LOGS", str(project_root / "logs") ) env["PROLE_SERVICE"] = state.inputs.get( "env_setup.PROLE_SERVICE", str(project_root / "etc") ) service_ns = ( (state.config_data.get("Global", {}) or {}) .get("SERVICE_NAMESPACE", "") .strip() ) if not service_ns: service_ns = (os.environ.get("SERVICE_NAMESPACE") or "").strip() if not service_ns: service_ns = env["NAMESPACE"] env["SERVICE_NAMESPACE"] = service_ns from knoe.core.env import _deployment_mode_from_env cluster_env = state.inputs.get("init_cluster.cluster_env", "dev") mode = _deployment_mode_from_env(cluster_env) or "k3d" env["PROLE_MODE"] = mode # Propagate [Global] config keys into env so Python-owned operations # (CNPG, monitoring, etc.) can access values declared in prole.cfg. # This mirrors what prole_cfg.sh does for shell subprocesses. # We only set keys that are not already in env (env vars win over config). # Machine-specific / runtime-detected values are excluded — they must # never be sourced from a potentially stale config written on a different host. _GLOBAL_CFG_PROPAGATION_BLOCK = frozenset({ "KUBECONTEXT", # runtime-detected: k3s="default", k8s=explicit context "HOME", # OS-provided; never a config value "USER", # OS-provided; never a config value }) _global_cfg = (state.config_data.get("Global") or {}) for _cfg_key, _cfg_val in _global_cfg.items(): if _cfg_key in _GLOBAL_CFG_PROPAGATION_BLOCK: continue if _cfg_val is not None and str(_cfg_key) and _cfg_key not in env: env[_cfg_key] = str(_cfg_val) # Kubeconfig if mode == "k3s": _k3s_kc_candidates = [ Path(env["PROLE_SERVICE"]) / "secrets" / "k3s.kubeconfig", project_root / "prole-k3s.kubeconfig", project_root / "etc" / "secrets" / "k3s.kubeconfig", project_root / "secrets" / "k3s.kubeconfig", ] for _kc in _k3s_kc_candidates: if _kc.exists(): env["KUBECONFIG"] = str(_kc) break # k3s kubeconfig's current-context is authoritative (typically "default"). # Clear any stale KUBECONTEXT written from a different machine's config so # kubectl uses KUBECONFIG without a --context override. env.pop("KUBECONTEXT", None) elif mode == "k3d": # Ensure k3d dev clusters have a resolvable KUBECONFIG. # The cluster may already be running from a previous session; # merge the kubeconfig so downstream scripts can reach it. if not (env.get("KUBECONFIG") or "").strip(): import subprocess as _sp cluster_name = "knoe-dev-cluster" try: _sp.run( [ "k3d", "kubeconfig", "merge", cluster_name, "--kubeconfig-switch-context", ], capture_output=True, text=True, timeout=10, ) except Exception: pass default_kube = str(Path.home() / ".kube" / "config") if Path(default_kube).exists(): env["KUBECONFIG"] = default_kube elif mode == "k8s": # GKE / standard k8s — use ~/.kube/config and set explicit cluster contexts. # APP cluster = common services (garage, openbao, registry, monitoring, kong). # DB cluster = CNPG/PostgreSQL (knoe-cnpg-0). default_kube = str(Path.home() / ".kube" / "config") if not (env.get("KUBECONFIG") or "").strip(): if Path(default_kube).exists(): env["KUBECONFIG"] = default_kube app_ctx = ( state.inputs.get("init_cluster.app_cluster_kubecontext", "") or state.inputs.get("env_setup.APP_CLUSTER_KUBECONTEXT", "") or (state.config_data.get("Global", {}) or {}).get("APP_CLUSTER_KUBECONTEXT", "") ).strip() db_ctx = ( state.inputs.get("init_cluster.db_cluster_kubecontext", "") or state.inputs.get("env_setup.DB_CLUSTER_KUBECONTEXT", "") or (state.config_data.get("Global", {}) or {}).get("DB_CLUSTER_KUBECONTEXT", "") ).strip() if app_ctx: env["APP_CLUSTER_KUBECONTEXT"] = app_ctx # Default KUBECONTEXT = app cluster; callers needing the DB cluster # must override with DB_CLUSTER_KUBECONTEXT before invoking kubectl. env["KUBECONTEXT"] = app_ctx if db_ctx: env["DB_CLUSTER_KUBECONTEXT"] = db_ctx db_pw = state.inputs.get("init_password.db_password", "").strip() if db_pw: db_pw = inst_config._resolve_secret_value(db_pw) env["DB_PASSWORD"] = db_pw env["OPENTOFU_ADMIN_PASSWORD"] = db_pw grafana_pw = ( (state.config_data.get("Monitoring", {}) or {}) .get("GRAFANA_ADMIN_PASSWORD", "") .strip() ) resolved_grafana = ( inst_config._resolve_secret_value(grafana_pw) if grafana_pw else "" ) if ( resolved_grafana and not inst_config._is_openbao_ref(resolved_grafana) and not inst_config._is_prole_secret(resolved_grafana) ): env["GRAFANA_ADMIN_PASSWORD"] = resolved_grafana elif db_pw: env["GRAFANA_ADMIN_PASSWORD"] = db_pw db_user = (state.inputs.get("init_password.db_username", "") or "").strip() if db_user: env["KNOE_DB_USER"] = db_user # Kerberos env["KRB5_REALM"] = state.inputs.get("kerberos_config.realm", "") realm = (state.inputs.get("kerberos_config.realm", "") or "").strip() env["KRB5_KDC"] = state.inputs.get("kerberos_config.kdc", "") if realm: env["REALM"] = realm env["DOMAIN"] = realm.lower() env["KRB5_USER"] = state.inputs.get("kerberos_config.user", "") krb_pw = state.inputs.get("kerberos_config.password", "") if krb_pw: env["KRB5_PASSWORD"] = inst_config._resolve_secret_value(krb_pw) if state.inputs.get("kerberos_config.kdc", ""): env["KRB5_ADMIN"] = state.inputs.get("kerberos_config.kdc", "") kerberos_enabled = self._parse_bool( state.inputs.get("kerberos_config.enabled", "False"), default=False ) if kerberos_enabled is None: kerberos_enabled = False env["KERBEROS_ENABLED"] = "True" if kerberos_enabled else "False" env["ENABLED"] = env["KERBEROS_ENABLED"] at_rest = state.inputs.get("init_cluster.at_rest_encryption_enabled", "") if at_rest != "": env["AT_REST_ENCRYPTION_ENABLED"] = str(at_rest) argocd_ns = ( (state.config_data.get("Global", {}) or {}) .get("ARGOCD_NAMESPACE", "") .strip() ) if not argocd_ns: argocd_ns = (os.environ.get("ARGOCD_NAMESPACE") or "").strip() or "argocd" env["ARGOCD_NAMESPACE"] = argocd_ns registry_ns = ( (state.config_data.get("Global", {}) or {}) .get("REGISTRY_NAMESPACE", "") .strip() ) if not registry_ns: registry_ns = ( os.environ.get("REGISTRY_NAMESPACE") or "" ).strip() or "default" env["REGISTRY_NAMESPACE"] = registry_ns if mode == "k3s": server = state.inputs.get("init_cluster.k3s_server_url", "").strip() token = state.inputs.get("init_cluster.k3s_token", "").strip() if token: token = inst_config._resolve_secret_value(token) if server: if not server.startswith("http"): server = f"https://{server}" env["PROLE_K3S_SERVER"] = server if token: env["PROLE_K3S_TOKEN"] = token return env