prole/installer/core/milestones.py
chrisfu ec8a4e98af k3s: Fix installation hangs, CA mismatches, and arm64 networking
- Implement proactive CA hash verification and automated repair for mismatches

- Ensure agents prioritize discovered server tokens over stale vault values

- Fix K3s service hangs with explicit stop and killall before reinstall

- Add Retropie/Pi networking fixes (WiFi power save, wlan0 priority)

- Pin pre-staged images to stable, architecture-aware versions (arm64)

- Remove obsolete init-port-forwards and prole.cfg sync tasks

- Update k8s manifests and installer core logic with new tests
2026-02-16 23:30:27 -08:00

376 lines
15 KiB
Python

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)