"""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, on_stdout: callable | None = None, ) -> int: if isinstance(cmd, str): cmd = ["bash", "-c", cmd] import subprocess try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, cwd=cwd, ) 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", "default") env["NAMESPACE"] = namespace # Paths 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 / "prole-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 = namespace or "default" env["SERVICE_NAMESPACE"] = service_ns from installer.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 # Kubeconfig if mode == "k3s": kc = Path(env["PROLE_SERVICE"]) / "secrets" / "k3s.kubeconfig" if not kc.exists(): kc = project_root / "etc" / "secrets" / "k3s.kubeconfig" if kc.exists(): env["KUBECONFIG"] = str(kc) 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 = "prole-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 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["PROLE_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