from __future__ import annotations import subprocess import logging from pathlib import Path from typing import TYPE_CHECKING from installer.milestone import Milestone from installer import config as inst_config if TYPE_CHECKING: from installer.state import InstallerState from installer.milestone import ProgressCallback class DependenciesMilestone(Milestone): def __init__(self): super().__init__("dependencies", "Dependency Verification") self.logger = logging.getLogger("DependenciesMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: if progress: progress("Checking dependencies...", 0.1) dependencies = inst_config.DEPENDENCIES missing = [] for i, dep in enumerate(dependencies): ok, location, version = inst_config.get_dep_info(dep) if ok: self.logger.info(f"[OK] {dep['name']} {version or ''}".strip()) else: missing.append(dep) self.logger.info(f"[MISSING] {dep['name']}") if progress: progress(f"Checking {dep['name']}...", 0.1 + (i / len(dependencies)) * 0.4) if not missing: self._set_status(state, "All installed") if progress: progress("All dependencies installed", 1.0) return # Check if auto-install is enabled auto_install = self._parse_bool(state.inputs.get('dependencies.auto_install_missing', 'True')) if not auto_install: self.logger.error("Dependencies missing and auto-install disabled.") self._set_status(state, "Missing") return for i, dep in enumerate(missing): install_cmd = dep.get('install_cmd') if not install_cmd: self.logger.error(f"No install command for {dep['name']}.") continue should_install = self._parse_bool(state.inputs.get(f"dependencies.{dep['id']}.install", 'True')) if not should_install: self.logger.info(f"[SKIP] {dep['name']} install disabled by config.") continue self.logger.info(f"[INSTALL] {dep['name']} -> {install_cmd}") if progress: progress(f"Installing {dep['name']}...", 0.5 + (i / len(missing)) * 0.4) rc = self._run_cmd(install_cmd) if rc != 0: self.logger.error(f"Install failed for {dep['name']} (code {rc})") # Re-verify still_missing = [] for dep in missing: ok, _, _ = inst_config.get_dep_info(dep) if not ok: still_missing.append(dep['name']) if still_missing: self.logger.error(f"Still missing: {', '.join(still_missing)}") self._set_status(state, "Missing") else: self._set_status(state, "All installed") if progress: progress("All dependencies installed", 1.0) def _set_status(self, state: InstallerState, status: str): if 'Dependencies' not in state.config_data: state.config_data['Dependencies'] = {} state.config_data['Dependencies']['STATUS'] = status class NetworkScanMilestone(Milestone): def __init__(self): super().__init__("network_scan", "Network Configuration") self.logger = logging.getLogger("NetworkScanMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: if not self._parse_bool(state.inputs.get('network_scan.run', 'True')): self.logger.info("Network scan disabled.") return existing_kdc = state.config_data.get('Network', {}).get('KDC_AUTO_DETECTED') if existing_kdc: self.logger.info(f"Network scan already performed. KDC: {existing_kdc}") return scan_binary = inst_config.get_resource_path("prole-net/prole-agent") if not scan_binary.exists(): self.logger.error(f"Scan binary not found at {scan_binary}") return prole_home = Path.home() / ".prole" scan_dir = prole_home / "scan" scan_dir.mkdir(parents=True, exist_ok=True) kdc_found = None def _handle_stdout(line: str): nonlocal kdc_found if "KDC is:" in line: try: ip_part = line.split("KDC is:")[1].strip() ip = ip_part.split()[0].strip('[]():,') if ip: kdc_found = ip except Exception: pass elif "Active Directory" in line or "88" in line: import socket for part in line.split(): try: socket.inet_aton(part.strip('[]():,')) kdc_found = part.strip('[]():,') break except Exception: continue if progress: progress("Scanning network...", 0.5) rc = self._run_cmd([str(scan_binary), "-t", "10"], cwd=str(scan_dir), on_stdout=_handle_stdout) if kdc_found: state.inputs['kerberos_config.kdc'] = kdc_found state.inputs['kerberos_config.enabled'] = "True" if 'Network' not in state.config_data: state.config_data['Network'] = {} state.config_data['Network']['KDC_AUTO_DETECTED'] = kdc_found state.config_data['Network']['KERBEROS_AUTO_ENABLED'] = 'True' self.logger.info(f"KDC detected: {kdc_found}") if progress: progress("Network scan complete", 1.0) class EnvSetupMilestone(Milestone): def __init__(self): super().__init__("env_setup", "System Environment Setup") self.logger = logging.getLogger("EnvSetupMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: if progress: progress("Setting up environment...", 0.5) env_keys = ['PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE', 'NAMESPACE'] if 'System Environment' not in state.config_data: state.config_data['System Environment'] = {} for k in env_keys: val = state.inputs.get(f'env_setup.{k}') if val: state.config_data['System Environment'][k] = val if progress: progress("Environment setup complete", 1.0) class SecretManagementMilestone(Milestone): def __init__(self): super().__init__("secret_management", "Secret Resolution") self.logger = logging.getLogger("SecretManagementMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: if progress: progress("Resolving secrets...", 0.5) # Keys that might contain secrets secret_keys = [ 'init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password', 'init_cluster.k3s_token' ] for key in secret_keys: val = state.inputs.get(key) if val: resolved = inst_config._resolve_secret_value(val) if resolved != val: state.inputs[key] = resolved self.logger.info(f"Resolved secret for {key}") # Synchronize confirm password if state.inputs.get('init_password.db_password') and not state.inputs.get('init_password.db_password_confirm'): state.inputs['init_password.db_password_confirm'] = state.inputs['init_password.db_password'] if progress: progress("Secret resolution complete", 1.0) class DatabaseCreationMilestone(Milestone): def __init__(self): super().__init__("init_password", "Database Creation") self.logger = logging.getLogger("DatabaseCreationMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: if progress: progress("Preparing database configuration...", 0.5) ns = (state.inputs.get('init_password.db_namespace', '') or '').strip() if not ns: ns = (state.inputs.get('env_setup.NAMESPACE', '') or '').strip() or 'default' user = (state.inputs.get('init_password.db_username', '') or '').strip() p1 = state.inputs.get('init_password.db_password', '') # In ncurses/silent mode we might not have all fields yet, but we want to ensure defaults if not user: import getpass user = getpass.getuser() state.inputs['init_password.db_username'] = user state.inputs['init_password.db_namespace'] = ns state.inputs['env_setup.NAMESPACE'] = ns if 'Database Creation' not in state.config_data: state.config_data['Database Creation'] = {} state.config_data['Database Creation']['DB_USER'] = user state.config_data['Database Creation']['DB_NAME'] = ns state.config_data['Database Creation']['NAMESPACE'] = ns # SSH key generation (simplified for now) if self._parse_bool(state.inputs.get('init_password.generate_ssh_key', 'True')): key_path = Path.home() / ".ssh" / "id_prole_ed25519" if not key_path.exists(): self._run_cmd(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", user]) if progress: progress("Database creation preparation complete", 1.0) class DockerBuildMilestone(Milestone): def __init__(self): super().__init__("init_db_build", "Docker Build") self.logger = logging.getLogger("DockerBuildMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: if not self._parse_bool(state.inputs.get('init_db_build.run_build', 'True')): self.logger.info("Docker build disabled.") return if progress: progress("Building Prole database image...", 0.1) tag = state.controller.get_prole_db_version() env_key = state.inputs.get('init_cluster.cluster_env', 'dev') args = ["--tag", f"prole-db:{tag}"] if env_key != 'dev': # Add registry prefix for non-dev envs server = state.inputs.get('init_cluster.k3s_server_url', 'myrddin.prole.org') if '://' in server: server = server.split('://')[1] if ':' in server: server = server.split(':')[0] args = ["--tag", f"{server}:5000/prole-db:{tag}", "--push"] rc = state.controller.run_script("build_db.sh", args=args) if rc != 0: self.logger.error(f"Docker build failed (code {rc})") if progress: progress("Docker build complete", 1.0) class ClusterLifecycleMilestone(Milestone): def __init__(self): super().__init__("cluster_lifecycle", "Cluster Lifecycle Management") self.logger = logging.getLogger("ClusterLifecycleMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: cluster_env = state.inputs.get('init_cluster.cluster_env', 'dev') start_requested = self._parse_bool(state.inputs.get('init_cluster.start_cluster', 'True')) if not start_requested: self.logger.info("Cluster start not requested.") return env = self._get_script_env(state) if cluster_env == 'dev': if progress: progress("Initializing k3d dev cluster...", 0.5) rc = state.controller.run_script("init_k3d.sh", args=["start"], env=env) if rc != 0: self.logger.error(f"k3d initialization failed (code {rc})") elif cluster_env in ('service', 'prole-service-cluster'): if progress: progress("Verifying k3s connection...", 0.5) # Additional logic for remote k3s services could go here self.logger.info("Service cluster mode: verification and deployment triggered via scripts.") # For now, matching the behavior of silent installer's _step_init_cluster # which mostly sets up env and calls init scripts if needed. if progress: progress("Cluster lifecycle management complete", 1.0) class InitializationScriptsMilestone(Milestone): def __init__(self): super().__init__("init_scripts", "Initialization Scripts") self.logger = logging.getLogger("InitializationScriptsMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: env = self._get_script_env(state) scripts = ["init_authority.sh", "init_storage.sh", "init_registry.sh", "init_openbao.sh", "init_opentofu.sh"] # Optional scripts if self._parse_bool(state.inputs.get('kerberos_config.enabled', 'False')): scripts.append("init_kerberos.sh") if self._parse_bool(state.inputs.get('init_cluster.supabase_enabled', 'False')): scripts.append("init_supabase.sh") for i, script in enumerate(scripts): if progress: progress(f"Running {script}...", (i / len(scripts))) # For registry, we might need specific args matching silent installer args = ["update"] if script == "init_registry.sh": argocd_ns = env.get("ARGOCD_NAMESPACE") or "argocd" registry_ns = env.get("REGISTRY_NAMESPACE") or "default" args = ["-n", argocd_ns, "--registry-namespace", registry_ns, "update"] elif script in ("init_openbao.sh", "init_opentofu.sh", "init_kerberos.sh", "init_supabase.sh"): args = ["-n", env.get("NAMESPACE", "default"), "update"] rc = state.controller.run_script(script, args=args, env=env) if rc != 0: self.logger.error(f"Script {script} failed (code {rc})") # Should we stop? Silent installer continues but logs error. if progress: progress("Initialization scripts complete", 1.0) class DeploymentMilestone(Milestone): def __init__(self): super().__init__("deployment", "Prole Deployment") self.logger = logging.getLogger("DeploymentMilestone") def execute(self, state: InstallerState, progress: ProgressCallback | None = None) -> None: if progress: progress("Deploying Prole...", 0.5) env = self._get_script_env(state) # Using deploy_pipeline.sh as the primary deployment mechanism rc = state.controller.run_script("deploy_pipeline.sh", args=["--mode", "local", "apply"], env=env) if rc == 0: self.logger.info("Deployment successful") else: self.logger.error(f"Deployment failed (code {rc})") if progress: progress("Deployment complete", 1.0)