""" Installer actions and unattended workflow helpers. """ from __future__ import annotations import logging import secrets import shlex import socket import string import threading import uuid from installer.config import _expand_path, _collect_cfg_vars, _expand_cfg_value, _parse_bool, _is_openbao_ref, \ _is_prole_secret, _decrypt_prole_secret, _encrypt_prole_secret, _write_k3s_kubeconfig, _encrypt_cfg_secret from installer.core.controller import ProleController from installer.core.env import * # noqa: F401,F403 from installer.core.env import _parse_ollama_host, _bool_str, _deployment_mode_from_env, _openbao_placeholder, \ _looks_like_k8s_bearer_token, _find_kubeconfig_file, _normalize_cluster_env, _pf_upsert_mapping, \ _build_required_port_forwards, _local_registry_enabled, _collect_images_from_files, _http_ping_registry, \ _resolve_supabase_home, _push_docker_image, _detect_ansible_topology, _deployment_target_label, \ _default_opentofu_pipeline_url, _format_ollama_host, _render_prole_cfg, _k3d_prole_data_volume_args, \ _sync_opentofu_pipeline from installer.core.milestones import ( DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone, SecretManagementMilestone, DatabaseCreationMilestone, DockerBuildMilestone, ClusterLifecycleMilestone, InitializationScriptsMilestone, DeploymentMilestone ) def _configure_unbuffered_io(): os.environ.setdefault('PYTHONUNBUFFERED', '1') for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(line_buffering=True, write_through=True) except Exception: try: stream.flush() except Exception: pass class ProleInstallerBase: """Shared business logic for both silent and interactive installer modes. Subclasses must provide a :meth:`_get_input` implementation that bridges their respective data-access layers (``dict`` for silent, tk ``StringVar`` for the GUI). All common helpers – namespace, env, secrets, port-forwards, script-env, logging – live here so they are defined exactly once. """ # Subclasses should set this to ``True`` for headless operation. silent: bool = False # ------------------------------------------------------------------ init def _init_shared_state(self): """Initialise state common to every installer flavour.""" self.prole_cfg_data = { 'Global': {}, 'Welcome': {}, 'Dependencies': {}, 'Network': {}, 'Port Forwards': {}, 'System Environment': {}, 'Kerberos Authentication': {}, 'Ollama': {}, 'Optional Features': {}, 'Database Creation': {}, 'Docker Build': {}, 'Initialize Cluster': {}, 'Initialization Scripts': {}, 'Deployment': {}, 'Dev Cluster (k3d)': {}, 'Service Cluster (k3s)': {}, 'Prod Cluster (k8s)': {}, 'Install': {} } self._cfg_secret_cache: dict = {} self._secrets_finalized: bool = False self._managed_kubeconfig: str | None = None self._repair_ran: bool = False # --------------------------------------------------------- data access def _get_input(self, key: str, default: str | None = None) -> str: """Return user input for *key*. Subclasses **must** override.""" raise NotImplementedError def _get_input_bool(self, key: str, default: bool = False) -> bool: return _parse_bool(self._get_input(key, None), default=default) # --------------------------------------------------------- logging def log(self, msg: str): print(msg, flush=True) logging.info(msg) def err(self, msg: str): print(msg, file=sys.stderr, flush=True) logging.error(msg) # ------------------------------------------------ deployment helpers def _deployment_mode(self) -> str: return _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) def _secret_namespace(self) -> str: ns = (self._get_input('init_password.db_namespace', '') or '').strip() if not ns: ns = (self._get_input('env_setup.NAMESPACE', '') or '').strip() if not ns: ns = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip() return ns or 'default' def _service_namespace(self) -> str: ns = (self.prole_cfg_data.get('Global', {}) or {}).get('SERVICE_NAMESPACE', '').strip() if not ns: ns = (os.environ.get('SERVICE_NAMESPACE') or '').strip() return ns or 'default' # ------------------------------------------------- secret helpers def _secret_cfg_value(self, section: str, key: str, plaintext: str, leaf: str, bao_key: str) -> str: if self._secrets_finalized: return _openbao_placeholder(self._secret_namespace(), leaf, bao_key) if not plaintext: return '' if _is_openbao_ref(plaintext) or _is_prole_secret(plaintext): return plaintext existing = (self.prole_cfg_data.get(section, {}) or {}).get(key) if existing and _is_prole_secret(existing): try: if _decrypt_prole_secret(existing) == plaintext: return existing except Exception: pass try: return _encrypt_prole_secret(plaintext) except Exception: return plaintext def _resolve_openbao_ref(self, value: str) -> str: if not _is_openbao_ref(value): return value inner = value[len(OPENBAO_PREFIX):-len(OPENBAO_SUFFIX)] path, key = (inner.split('#', 1) + [""])[:2] if not path or not key: return "" mount = "kv" secret_path = path if "/" in path: maybe_mount, rest = path.split("/", 1) if maybe_mount: mount = maybe_mount secret_path = rest token = os.environ.get("OPENBAO_ROOT_TOKEN", "") if not token: prole_service = os.environ.get("PROLE_SERVICE") if prole_service: token_path = Path(prole_service) / "secrets" / "openbao-root-token" if token_path.exists(): token = token_path.read_text().strip() if not token: return value url = os.environ.get("PROLE_OPENBAO_URL") if not url: for p in ["8200", "18200"]: try: import urllib.request with urllib.request.urlopen(f"http://127.0.0.1:{p}/v1/sys/health", timeout=0.5) as r: if r.getcode() == 200: url = f"http://127.0.0.1:{p}" break except Exception: pass if not url: url = "http://127.0.0.1:8200" url = url.rstrip("/") try: req = urllib.request.Request(f"{url}/v1/{mount}/data/{secret_path}") req.add_header("X-Vault-Token", token) with urllib.request.urlopen(req, timeout=4) as resp: payload = json.loads(resp.read().decode("utf-8")) return payload.get("data", {}).get("data", {}).get(key, "") or "" except Exception: return value def _resolve_secret_value(self, value: str) -> str: if _is_prole_secret(value): return _decrypt_prole_secret(value) if _is_openbao_ref(value): return self._resolve_openbao_ref(value) return value # --------------------------------------------- cfg sanitisation def _sanitize_sections_for_cfg(self, sections: dict) -> dict: sanitized = {k: dict(v) for k, v in sections.items()} if "Kerberos Authentication" in sanitized: val = sanitized["Kerberos Authentication"].get("PASSWORD", "") if val or ("Kerberos Authentication", "PASSWORD") in self._cfg_secret_cache: sanitized["Kerberos Authentication"]["PASSWORD"] = self._secret_cfg_value( "Kerberos Authentication", "PASSWORD", val, "kerberos", "password" ) if "Monitoring" in sanitized: val = sanitized["Monitoring"].get("GRAFANA_ADMIN_PASSWORD", "") if val or ("Monitoring", "GRAFANA_ADMIN_PASSWORD") in self._cfg_secret_cache: sanitized["Monitoring"]["GRAFANA_ADMIN_PASSWORD"] = self._secret_cfg_value( "Monitoring", "GRAFANA_ADMIN_PASSWORD", val, "monitoring", "grafana_admin_password" ) return sanitized # ------------------------------------------- namespace helpers def _get_local_owner(self) -> str: try: return getpass.getuser() except Exception: try: return os.getlogin() except Exception: return "prole" def _sanitize_namespace(self, name: str) -> str: cleaned = re.sub(r'[^a-z0-9-]+', '-', (name or '').lower()) cleaned = re.sub(r'-{2,}', '-', cleaned).strip('-') if not cleaned: cleaned = 'prole' if len(cleaned) > 63: cleaned = cleaned[:63].rstrip('-') return cleaned def _generate_namespace_name(self) -> str: owner = self._sanitize_namespace(self._get_local_owner()) suffix = uuid.uuid4().hex[:6] base = f"prole-{owner}-{suffix}" return self._sanitize_namespace(base) def _ensure_namespace_prefix(self, name: str) -> str: cleaned = (name or '').strip() if not cleaned: return NAMESPACE_PREFIX if cleaned.startswith(NAMESPACE_PREFIX): return cleaned return f"{NAMESPACE_PREFIX}{cleaned}" def _initial_namespace(self) -> str: try: existing = self._read_existing_env() ns = existing.get('NAMESPACE') or existing.get('PROLE_NAMESPACE') if ns: return self._ensure_namespace_prefix(ns) except Exception: pass ns = os.environ.get('NAMESPACE') or os.environ.get('PROLE_NAMESPACE') if ns: return self._ensure_namespace_prefix(ns) return self._ensure_namespace_prefix(self._generate_namespace_name()) def _is_valid_namespace(self, name: str) -> bool: if not name or len(name) > 63: return False return re.match(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', name) is not None def _generate_db_password(self, length: int = 24) -> str: alphabet = string.ascii_letters + string.digits return ''.join(secrets.choice(alphabet) for _ in range(length)) # --------------------------------------------------- env helpers def _env_defaults(self, namespace: str | None = None) -> dict: default_home = Path.home() / '.prole' resolved_home = self._resolve_env_value('PROLE_HOME', str(default_home)) or str(default_home) home = Path(resolved_home).expanduser() if namespace is None: namespace = (self._get_input('init_password.db_namespace', '') or '').strip() if not namespace: namespace = os.environ.get('NAMESPACE', '') return { 'PROLE_HOME': str(home), 'PROLE_CONF': self._resolve_env_value('PROLE_CONF', str(home / 'conf')) or str(home / 'conf'), 'PROLE_DATA': self._resolve_env_value('PROLE_DATA', str(home / 'data')) or str(home / 'data'), 'PROLE_LOGS': self._resolve_env_value('PROLE_LOGS', str(home / 'logs')) or str(home / 'logs'), 'PROLE_SERVICE': self._resolve_env_value('PROLE_SERVICE', str(home / 'etc')) or str(home / 'etc'), 'NAMESPACE': namespace or '', } def _resolve_env_value(self, key: str, fallback: str | None = None) -> str | None: val = os.environ.get(key) if val: return val try: env = self._read_existing_env() val = env.get(key) if val: return val except Exception: pass return fallback def _resolve_env_dir(self, key: str, default_suffix: str) -> Path: input_key = f'env_setup.{key}' val = (self._get_input(input_key, '') or '').strip() if val: try: return Path(val).expanduser() except Exception: pass val = self._resolve_env_value(key) if val: try: return Path(val).expanduser() except Exception: pass return Path.home() / '.prole' / default_suffix def _read_existing_env(self) -> dict: env = {} prole_home = os.environ.get('PROLE_HOME') candidates = [] if prole_home: candidates.append(Path(prole_home).expanduser() / 'env.sh') candidates.append(Path.home() / '.prole' / 'env.sh') for p in candidates: try: if p.exists(): for line in p.read_text().splitlines(): line = line.strip() if not line or line.startswith('#'): continue if line.startswith('export '): line = line[len('export '):] if '=' in line: k, v = line.split('=', 1) env[k.strip()] = v.strip().strip('"') break except Exception: pass return env def _save_env_to_file(self, values: dict): cluster_env = self._get_input('init_cluster.cluster_env', 'dev') env_key = _normalize_cluster_env(cluster_env) home = Path(values['PROLE_HOME']).expanduser() if env_key == 'dev': home.mkdir(parents=True, exist_ok=True) for key in ('PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): try: Path(values[key]).expanduser().mkdir(parents=True, exist_ok=True) except Exception: pass content = [] content.append('#!/usr/bin/env bash') content.append('# Prole environment configuration') content.append('# This file is generated by the installer. Source it in new shells, or execute as a wrapper:') content.append('# "$PROLE_HOME/env.sh" [args…]') content.append('# shellcheck shell=bash') for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): content.append(f'export {k}="{values[k]}"') if values.get('NAMESPACE'): content.append(f'export NAMESPACE="{values["NAMESPACE"]}"') content.append('') content.append('# Ensure PATH works for GUI-launched shells (Docker, etc.)') content.append('_prole_add_path() { case ":${PATH}:" in *":$1:"*) ;; *) PATH="$1:${PATH:-}" ;; esac; }') content.append('_prole_add_path "$PROLE_HOME/bin"') content.append('_prole_add_path "/opt/homebrew/bin"') content.append('_prole_add_path "/usr/local/bin"') content.append('_prole_add_path "/usr/bin"') content.append('_prole_add_path "/bin"') content.append('_prole_add_path "/usr/sbin"') content.append('_prole_add_path "/sbin"') content.append('export PATH') content.append('') content.append('# Add custom paths below if needed (examples):') content.append('') content.append('# If executed with arguments (and not sourced), run them under this environment') content.append('if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [ "$#" -gt 0 ]; then') content.append(' exec "$@"') content.append('fi') new_content = '\n'.join(content) + '\n' out = home / 'env.sh' if env_key != 'dev': out = Path.home() / '.prole' / 'env.sh' out.parent.mkdir(parents=True, exist_ok=True) should_write = True if out.exists(): if out.read_text() == new_content: should_write = False self.log(f"[OK] {out} is up to date.") if should_write: tmp = out.parent / 'env.sh.tmp' tmp.write_text(new_content) tmp.replace(out) try: os.chmod(out, 0o755) except Exception: pass self.log(f"[OK] Wrote {out}") self._deploy_env_resources(values) def _deploy_env_resources(self, values: dict): """Copy init scripts and etc directory into PROLE_HOME / PROLE_SERVICE.""" try: prole_home = Path(values['PROLE_HOME']).expanduser() prole_service = Path(values['PROLE_SERVICE']).expanduser() init_pf_src_candidates = [ self.project_root / 'src' / 'prole' / 'etc' / 'init-port-forward.sh', self.project_root / 'etc' / 'init-port-forward.sh', ] init_pf_src = next((p for p in init_pf_src_candidates if p.exists()), None) if init_pf_src is not None: init_pf_dst = prole_home / 'init-port-forward.sh' try: data = init_pf_src.read_bytes() init_pf_dst.write_bytes(data) os.chmod(init_pf_dst, 0o755) except Exception: pass etc_src_candidates = [ self.project_root / 'src' / 'prole' / 'etc', self.project_root / 'etc', ] etc_src = next((p for p in etc_src_candidates if p.exists()), None) if etc_src is not None: try: prole_service.mkdir(parents=True, exist_ok=True) if etc_src.resolve() != prole_service.resolve(): if prole_service.exists(): shutil.rmtree(prole_service, ignore_errors=True) shutil.copytree(etc_src, prole_service) except Exception: pass except Exception: pass def reload_env_from_shell(self) -> None: home = Path(self._get_input('env_setup.PROLE_HOME', str(Path.home() / '.prole'))) env_file = home / 'env.sh' cmd = ( f'export PROLE_HOME={shlex.quote(str(home))}; ' f'source {shlex.quote(str(env_file))}; ' 'env -0' ) try: out = subprocess.check_output(['bash', '-lc', cmd]) except Exception as e: raise Exception(f"Failed to reload environment: {e}") try: for raw in out.split(b'\x00'): if not raw: continue kv = raw.decode('utf-8', errors='ignore') if '=' not in kv: continue k, v = kv.split('=', 1) if k in ('PYTHONPATH', 'PYTHONHOME'): continue os.environ[k] = v except Exception: pass def _update_env_namespace(self, namespace: str): try: existing = self._read_existing_env() defaults = self._env_defaults(namespace) values = {**defaults, **existing} values['NAMESPACE'] = namespace self._save_env_to_file(values) os.environ['NAMESPACE'] = namespace except Exception as e: self.err(f"[WARN] Failed to update env.sh namespace: {e}") # --------------------------------------------- port-forward helpers def _add_port_mapping(self, mapping_str): mode = self._deployment_mode() prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" pf_section = self.prole_cfg_data.get('Port Forwards', {}) if pf_section is None: pf_section = {} if not _pf_upsert_mapping(pf_section, prefix, mapping_str): return self.prole_cfg_data['Port Forwards'] = pf_section self._write_cfg() def _process_script_output_line(self, line): if "GRAFANA_ADMIN_PASSWORD=" in line: pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip() if pwd: mon = self.prole_cfg_data.get('Monitoring', {}) if mon is None: mon = {} mon['GRAFANA_ADMIN_PASSWORD'] = pwd self.prole_cfg_data['Monitoring'] = mon self._write_cfg() if "PORT_FORWARD_MAPPING:" in line: mapping = line.split("PORT_FORWARD_MAPPING:")[1].strip() if mapping: self._add_port_mapping(mapping) def _sync_port_forward_mappings(self): mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) if not mode: mode = 'k3d' prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" pf_section = self.prole_cfg_data.get('Port Forwards', {}) if pf_section is None: pf_section = {} for k, v in list(pf_section.items()): if isinstance(v, str) and ("id=registry;" in v or v.strip() == "id=registry"): del pf_section[k] service_ns = (self._service_namespace() or '').strip() or 'default' db_ns = (self._get_input('init_password.db_namespace', '') or '').strip() if not db_ns: db_ns = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip() if not db_ns: db_ns = (os.environ.get('NAMESPACE') or '').strip() if not db_ns: db_ns = 'default' argocd_ns = (self.prole_cfg_data.get('Global', {}) or {}).get('ARGOCD_NAMESPACE', '').strip() if not argocd_ns: argocd_ns = (os.environ.get('ARGOCD_NAMESPACE') or '').strip() if not argocd_ns: argocd_ns = 'argocd' db_host_port = (self._get_input('init_password.db_host_port', '') or '').strip() if not db_host_port: db_host_port = (self.prole_cfg_data.get('Global', {}) or {}).get('DB_HOST_PORT', '').strip() if not db_host_port: db_host_port = '5432' supabase_enabled = self._get_input_bool('init_cluster.supabase_enabled', False) supabase_ns = (os.environ.get('SUPABASE_NAMESPACE') or '').strip() if not supabase_ns: supabase_ns = (self.prole_cfg_data.get('Supabase', {}) or {}).get('NAMESPACE', '').strip() if not supabase_ns: supabase_ns = 'supabase' mappings = _build_required_port_forwards( mode=mode, service_ns=service_ns, argocd_ns=argocd_ns, db_ns=db_ns, db_host_port=db_host_port, supabase_enabled=supabase_enabled, supabase_namespace=supabase_ns, ) for mapping in mappings: _pf_upsert_mapping(pf_section, prefix, mapping) self.prole_cfg_data['Port Forwards'] = pf_section # --------------------------------------------- script env def _script_env_for_namespace(self, namespace: str) -> dict: env = os.environ.copy() env["PROLE_HOME"] = str(self.project_root) env["PROLE_SERVICE"] = str(self.project_root) env["NAMESPACE"] = namespace service_ns = self._service_namespace() env["PROLE_HOME"] = str(self.project_root) env["PROLE_CONF"] = self._get_input('env_setup.PROLE_CONF', str(self.project_root / "conf")) env["PROLE_DATA"] = self._get_input('env_setup.PROLE_DATA', str(self.project_root / "prole-db" / "data")) env["PROLE_LOGS"] = self._get_input('env_setup.PROLE_LOGS', str(self.project_root / "logs")) env["PROLE_SERVICE"] = self._get_input('env_setup.PROLE_SERVICE', str(self.project_root / "etc")) mode = self._deployment_mode() if mode: env["PROLE_MODE"] = mode if mode == "k3s": kc = Path(env["PROLE_SERVICE"]) / "secrets" / "k3s.kubeconfig" if not kc.exists(): kc = self.project_root / "etc" / "secrets" / "k3s.kubeconfig" if kc.exists(): env["KUBECONFIG"] = str(kc) else: env.pop("KUBECONFIG", None) env["SERVICE_NAMESPACE"] = service_ns db_pw = self._get_input('init_password.db_password', '').strip() if db_pw: db_pw = self._resolve_secret_value(db_pw) env["DB_PASSWORD"] = db_pw env["OPENTOFU_ADMIN_PASSWORD"] = db_pw grafana_pw = self.prole_cfg_data.get('Monitoring', {}).get('GRAFANA_ADMIN_PASSWORD', '').strip() resolved_grafana = self._resolve_secret_value(grafana_pw) if grafana_pw else "" if resolved_grafana and not (_is_openbao_ref(resolved_grafana) or _is_prole_secret(resolved_grafana)): env["GRAFANA_ADMIN_PASSWORD"] = resolved_grafana elif db_pw: env["GRAFANA_ADMIN_PASSWORD"] = db_pw env["KRB5_REALM"] = self._get_input('kerberos_config.realm', '') env["KRB5_KDC"] = self._get_input('kerberos_config.kdc', '') env["KRB5_USER"] = self._get_input('kerberos_config.user', '') krb_pw = self._get_input('kerberos_config.password', '') if krb_pw: env["KRB5_PASSWORD"] = self._resolve_secret_value(krb_pw) else: env["KRB5_PASSWORD"] = "" if mode == 'k3s': server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip() token = (self._get_input('init_cluster.k3s_token', '') or '').strip() if token: token = self._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 explicit_kubeconfig = (env.get("KUBECONFIG") or "").strip() if explicit_kubeconfig and not os.path.exists(explicit_kubeconfig): explicit_kubeconfig = "" managed_set = False if server and token and _looks_like_k8s_bearer_token(token) and not explicit_kubeconfig: try: if self._managed_kubeconfig and os.path.exists(self._managed_kubeconfig): try: os.unlink(self._managed_kubeconfig) except Exception: pass self._managed_kubeconfig = str(_write_k3s_kubeconfig(server, token)) env["KUBECONFIG"] = self._managed_kubeconfig managed_set = True except Exception as e: self.err(f"[WARN] Failed to generate managed KUBECONFIG: {e}") if not managed_set: fallback = _find_kubeconfig_file(env) if fallback: env["KUBECONFIG"] = fallback reg_host = self.prole_cfg_data.get('Docker Build', {}).get('LOCAL_REGISTRY') if reg_host: env["LOCAL_REGISTRY"] = reg_host reg_internal = self.prole_cfg_data.get('Docker Build', {}).get('LOCAL_REGISTRY_INTERNAL') if reg_internal: env["LOCAL_REGISTRY_INTERNAL"] = reg_internal return env # --------------------------------------------- authority / repair def _authority_context_missing(self) -> bool: enabled = self._get_input_bool('kerberos_config.enabled', False) or \ self._get_input_bool('init_cluster.kerberos_enabled', False) if not enabled: return False candidates = [] env_home = (os.environ.get('PROLE_HOME') or '').strip() if env_home: candidates.append(Path(env_home)) candidates.append(self.project_root) for base in candidates: try: if (base / 'authority').is_dir(): return False if (base / 'prole' / 'authority').is_dir(): return False except Exception: continue return True def _dashboard_kong_missing(self, env: dict) -> bool: ns = "kubernetes-dashboard" try: res_ns = subprocess.run( ['kubectl', 'get', 'ns', ns, '-o', 'name'], env=env, capture_output=True, text=True, timeout=6 ) if res_ns.returncode != 0 or ns not in (res_ns.stdout or ''): return True res = subprocess.run( ['kubectl', '-n', ns, 'get', 'pods', '--no-headers'], env=env, capture_output=True, text=True, timeout=6 ) if res.returncode != 0: return True kong_lines = [l for l in (res.stdout or '').splitlines() if 'kong' in l.lower()] if not kong_lines: return True for line in kong_lines: parts = line.split() if len(parts) < 3: return True if parts[2] != 'Running': return True return False except Exception: return True def _maybe_run_repair_pipeline(self, env: dict) -> None: if self._repair_ran: return anomalies = [] if self._dashboard_kong_missing(env): anomalies.append('dashboard') if self._authority_context_missing(): anomalies.append('authority') if not anomalies: return self._repair_ran = True service_ns = self._service_namespace() db_ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' self.log(f"[INFO] Detected anomalies ({', '.join(anomalies)}); running repair pipeline...") self._run_script( "repair_pipeline.sh", args=["-n", service_ns, "--db-namespace", db_ns], env=env ) # ------------------------------------------ image helpers def _collect_dependent_images(self, include_supabase: bool, include_kerberos_proxy: bool) -> list[str]: images = _collect_images_from_files(self.project_root, include_supabase=include_supabase) if include_kerberos_proxy: images.append("ghcr.io/bsharp-tech/prole-kerberos-proxy:latest") return images def _prepull_images_to_registry(self, include_supabase: bool, include_kerberos_proxy: bool) -> None: images = self._collect_dependent_images(include_supabase, include_kerberos_proxy) if not images: self.log("[SKIP] No dependent images to prepull.") return reg = getattr(self, 'local_registry_url', None) if not reg: reg = self.prole_cfg_data.get('Docker Build', {}).get('LOCAL_REGISTRY', '') if not reg: self.log("[SKIP] No local registry configured; skipping prepull.") return host, _, port_str = reg.partition(':') try: port = int(port_str) except (ValueError, TypeError): port = 5000 if not _http_ping_registry(host, port): self.log(f"[SKIP] Registry {reg} not reachable; skipping prepull.") return for image in images: self.log(f"[INFO] Pulling {image} ...") try: rc = subprocess.run(['docker', 'pull', image], capture_output=True, text=True, timeout=300) if rc.returncode != 0: self.err(f"[WARN] docker pull failed for {image}: {(rc.stderr or '').strip()}") continue except Exception as e: self.err(f"[WARN] docker pull exception for {image}: {e}") continue ok = _push_docker_image(image, reg) if ok: self.log(f"[OK] Pushed {image} → {reg}") else: self.err(f"[WARN] Push failed for {image} → {reg}") class ProleSilentInstaller(ProleInstallerBase): """Console-based unattended installer driven by prole.cfg inputs.""" silent = True def __init__(self, controller: ProleController, cfg_path: str | None = None, reset_cluster: bool = False): self.controller = controller self.project_root = controller.project_root self.dependencies = list(inst_config.DEPENDENCIES) self.cfg_path = self._normalize_cfg_path(cfg_path) self.reset_cluster = bool(reset_cluster) self.reset_requested = bool(reset_cluster) self.inputs: dict = {} self._init_shared_state() self._db_built_success = False self._scripts_success = False self._cnpg_success = False self.docker_import_dir = None self._openbao_init_env = None self._openbao_init_password = None # ---- Data access (from inputs dict) ---- def _get_input(self, key: str, default: str | None = None) -> str: if key in self.inputs: return self.inputs[key] return default if default is not None else '' # ---------------- Config helpers ---------------- def _normalize_cfg_path(self, raw: str | None) -> Path: if raw: p = Path(_expand_path(raw)) if p.is_dir(): return p / 'prole.cfg' return p prole_conf = os.environ.get('PROLE_CONF') if prole_conf: return Path(_expand_path(prole_conf)) / 'prole.cfg' return self.project_root / 'conf' / 'prole.cfg' def _load_inputs_from_cfg(self) -> dict: cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str if not self.cfg_path.exists(): raise FileNotFoundError(f"prole.cfg not found at {self.cfg_path}") cfg.read(self.cfg_path) cfg_vars = _collect_cfg_vars(cfg) # Load all sections into prole_cfg_data to maintain idempotency for section in cfg.sections(): if section not in self.prole_cfg_data: self.prole_cfg_data[section] = {} for k, v in cfg.items(section): self.prole_cfg_data[section][k] = _expand_cfg_value(v, cfg_vars) if cfg.has_section('Global'): service_ns = _expand_cfg_value(cfg.get('Global', 'SERVICE_NAMESPACE', fallback=''), cfg_vars).strip() if service_ns: self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = service_ns if cfg.has_section('Port Forwards'): self.prole_cfg_data['Port Forwards'] = { k: _expand_cfg_value(v, cfg_vars) for k, v in cfg.items('Port Forwards') } for (section, key), _spec in SECRET_KEY_SPECS.items(): if cfg.has_option(section, key): val = _expand_cfg_value(cfg.get(section, key, fallback="").strip(), cfg_vars) if val: self._cfg_secret_cache[(section, key)] = val if _is_openbao_ref(val): self._secrets_finalized = True self._auto_input_keys = set() auto_kdc = None auto_enabled = None if cfg.has_section('Network'): sec = cfg['Network'] if 'KDC_AUTO_DETECTED' in sec: auto_kdc = sec.get('KDC_AUTO_DETECTED', '') if 'KERBEROS_AUTO_ENABLED' in sec: auto_enabled = sec.get('KERBEROS_AUTO_ENABLED', '') def mark_auto(inputs: dict): if auto_kdc: if inputs.get('kerberos_config.kdc', '').strip() == str(auto_kdc).strip(): self._auto_input_keys.add('kerberos_config.kdc') if auto_enabled is not None: input_enabled = inputs.get('kerberos_config.enabled') input_bool = _parse_bool(input_enabled, None) auto_bool = _parse_bool(auto_enabled, None) if input_bool is not None and auto_bool is not None and input_bool == auto_bool: self._auto_input_keys.add('kerberos_config.enabled') inputs: dict[str, str] = {} if cfg.has_section('Inputs'): inputs.update({k: _expand_cfg_value(v, cfg_vars) for k, v in cfg.items('Inputs')}) mark_auto(inputs) # Resolve encrypted/anchored secrets for runtime use for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password', 'init_cluster.k3s_token'): if key in inputs and inputs[key]: inputs[key] = self._resolve_secret_value(inputs[key]) if inputs.get('init_password.db_password') and not inputs.get('init_password.db_password_confirm'): inputs['init_password.db_password_confirm'] = inputs['init_password.db_password'] # Allow explicit runtime override of DB password in silent mode. env_db_pw = os.environ.get("PROLE_DB_PASSWORD") or os.environ.get("DB_PASSWORD") if env_db_pw: raw_db_pw = inputs.get('init_password.db_password', '') if not raw_db_pw or _is_openbao_ref(raw_db_pw) or _is_prole_secret(raw_db_pw): inputs['init_password.db_password'] = env_db_pw inputs['init_password.db_password_confirm'] = env_db_pw # Fill Ollama inputs from config sections if missing if cfg.has_section('Ollama'): sec = cfg['Ollama'] if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_SERVER_HOST', '').strip(): inputs['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars) if 'ollama_config.server_port' not in inputs and sec.get('OLLAMA_SERVER_PORT', '').strip(): inputs['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars) if 'ollama_config.model' not in inputs and sec.get('OLLAMA_MODEL', '').strip(): inputs['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars) if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_HOST', '').strip(): host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars)) if host: inputs['ollama_config.server_host'] = host if port and 'ollama_config.server_port' not in inputs: inputs['ollama_config.server_port'] = port if cfg.has_section('Global'): sec = cfg['Global'] if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_SERVER_HOST', '').strip(): inputs['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars) if 'ollama_config.server_port' not in inputs and sec.get('OLLAMA_SERVER_PORT', '').strip(): inputs['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars) if 'ollama_config.model' not in inputs and sec.get('OLLAMA_MODEL', '').strip(): inputs['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars) if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_HOST', '').strip(): host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars)) if host: inputs['ollama_config.server_host'] = host if port and 'ollama_config.server_port' not in inputs: inputs['ollama_config.server_port'] = port return inputs # Legacy fallback mapping legacy = {} if cfg.has_section('System Environment'): sec = cfg['System Environment'] for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): if k in sec: legacy[f'env_setup.{k}'] = _expand_cfg_value(sec.get(k, ''), cfg_vars) if cfg.has_section('Global'): sec = cfg['Global'] if 'PROLE_HOME' in sec: legacy['env_setup.PROLE_HOME'] = _expand_cfg_value(sec.get('PROLE_HOME', ''), cfg_vars) if 'CLUSTER_ENV' in sec: legacy['init_cluster.cluster_env'] = _expand_cfg_value(sec.get('CLUSTER_ENV', ''), cfg_vars) if 'PROLE_K3S_SERVER' in sec: legacy['init_cluster.k3s_server_url'] = _expand_cfg_value(sec.get('PROLE_K3S_SERVER', ''), cfg_vars) if 'K3S_SERVER_URL' in sec and 'init_cluster.k3s_server_url' not in legacy: legacy['init_cluster.k3s_server_url'] = _expand_cfg_value(sec.get('K3S_SERVER_URL', ''), cfg_vars) if 'PROLE_K3S_TOKEN' in sec: legacy['init_cluster.k3s_token'] = _expand_cfg_value(sec.get('PROLE_K3S_TOKEN', ''), cfg_vars) if 'K3S_TOKEN' in sec and 'init_cluster.k3s_token' not in legacy: legacy['init_cluster.k3s_token'] = _expand_cfg_value(sec.get('K3S_TOKEN', ''), cfg_vars) if 'NAMESPACE' in sec: legacy['init_password.db_namespace'] = _expand_cfg_value(sec.get('NAMESPACE', ''), cfg_vars) legacy['env_setup.NAMESPACE'] = _expand_cfg_value(sec.get('NAMESPACE', ''), cfg_vars) if 'PROLE_DB_USER' in sec: legacy['init_password.db_username'] = _expand_cfg_value(sec.get('PROLE_DB_USER', ''), cfg_vars) if 'DB_PASSWORD' in sec: legacy['init_password.db_password'] = _expand_cfg_value(sec.get('DB_PASSWORD', ''), cfg_vars) legacy['init_password.db_password_confirm'] = _expand_cfg_value(sec.get('DB_PASSWORD', ''), cfg_vars) if 'DB_HOST_PORT' in sec: legacy['init_password.db_host_port'] = _expand_cfg_value(sec.get('DB_HOST_PORT', '5432'), cfg_vars) if 'DOCKER_IMPORT_DIR' in sec: self.docker_import_dir = _expand_cfg_value(sec.get('DOCKER_IMPORT_DIR', ''), cfg_vars) self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = self.docker_import_dir if 'OLLAMA_SERVER_HOST' in sec: legacy['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars) if 'OLLAMA_SERVER_PORT' in sec: legacy['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars) if 'OLLAMA_MODEL' in sec: legacy['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars) if 'OLLAMA_HOST' in sec and 'ollama_config.server_host' not in legacy: host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars)) if host: legacy['ollama_config.server_host'] = host if port: legacy.setdefault('ollama_config.server_port', port) if cfg.has_section('Network'): sec = cfg['Network'] if 'KDC_AUTO_DETECTED' in sec: legacy['kerberos_config.kdc'] = _expand_cfg_value(sec.get('KDC_AUTO_DETECTED', ''), cfg_vars) if 'KERBEROS_AUTO_ENABLED' in sec: legacy['kerberos_config.enabled'] = _expand_cfg_value(sec.get('KERBEROS_AUTO_ENABLED', ''), cfg_vars) if cfg.has_section('Kerberos Authentication'): sec = cfg['Kerberos Authentication'] for k, tgt in ( ('ENABLED', 'kerberos_config.enabled'), ('REALM', 'kerberos_config.realm'), ('KDC', 'kerberos_config.kdc'), ('USER', 'kerberos_config.user'), ('PASSWORD', 'kerberos_config.password'), ): if k in sec: legacy[tgt] = _expand_cfg_value(sec.get(k, ''), cfg_vars) if cfg.has_section('Ollama'): sec = cfg['Ollama'] if 'OLLAMA_SERVER_HOST' in sec: legacy['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars) if 'OLLAMA_SERVER_PORT' in sec: legacy['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars) if 'OLLAMA_MODEL' in sec: legacy['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars) if 'OLLAMA_HOST' in sec and 'ollama_config.server_host' not in legacy: host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars)) if host: legacy['ollama_config.server_host'] = host if port: legacy.setdefault('ollama_config.server_port', port) if cfg.has_section('Optional Features'): sec = cfg['Optional Features'] if 'SUPABASE_ENABLED' in sec: legacy['init_cluster.supabase_enabled'] = sec.get('SUPABASE_ENABLED', '') if 'KERBEROS_ENABLED' in sec: legacy['init_cluster.kerberos_enabled'] = sec.get('KERBEROS_ENABLED', '') legacy['kerberos_config.enabled'] = sec.get('KERBEROS_ENABLED', '') if 'AT_REST_ENCRYPTION_ENABLED' in sec: legacy['init_cluster.at_rest_encryption_enabled'] = sec.get('AT_REST_ENCRYPTION_ENABLED', '') if cfg.has_section('Initialize Cluster'): sec = cfg['Initialize Cluster'] if 'ENVIRONMENT' in sec: legacy['init_cluster.cluster_env'] = sec.get('ENVIRONMENT', '') if 'K3S_SERVER_URL' in sec and 'init_cluster.k3s_server_url' not in legacy: legacy['init_cluster.k3s_server_url'] = sec.get('K3S_SERVER_URL', '') if 'K3S_TOKEN' in sec and 'init_cluster.k3s_token' not in legacy: legacy['init_cluster.k3s_token'] = sec.get('K3S_TOKEN', '') if cfg.has_section('Database Creation'): sec = cfg['Database Creation'] if 'DB_NAME' in sec: legacy['init_password.db_namespace'] = sec.get('DB_NAME', '') legacy['env_setup.NAMESPACE'] = sec.get('DB_NAME', '') if 'NAMESPACE' in sec: legacy['init_password.db_namespace'] = sec.get('NAMESPACE', '') legacy['env_setup.NAMESPACE'] = sec.get('NAMESPACE', '') if 'DB_USER' in sec: legacy['init_password.db_username'] = sec.get('DB_USER', '') mark_auto(legacy) for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password', 'init_cluster.k3s_token'): if key in legacy and legacy[key]: legacy[key] = self._resolve_secret_value(legacy[key]) if legacy.get('init_password.db_password') and not legacy.get('init_password.db_password_confirm'): legacy['init_password.db_password_confirm'] = legacy['init_password.db_password'] return legacy def _default_inputs(self) -> dict: namespace = self._initial_namespace() owner = self._get_local_owner() env_vals = self._env_defaults(namespace) inputs: dict[str, str] = {} # Dependencies inputs['dependencies.verify_all'] = _bool_str(False) inputs['dependencies.auto_install_missing'] = _bool_str(DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True)) for dep in self.dependencies: inputs[f"dependencies.{dep['id']}.install"] = _bool_str(True) # Network scan inputs['network_scan.run'] = _bool_str(DEFAULT_ACTION_FLAGS.get('network_scan.run', True)) # Environment setup for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): inputs[f'env_setup.{k}'] = env_vals.get(k, '') inputs['env_setup.NAMESPACE'] = namespace # Database creation inputs['init_password.db_namespace'] = namespace inputs['init_password.db_username'] = owner inputs['init_password.db_password'] = '' inputs['init_password.db_password_confirm'] = '' inputs['init_password.generate_ssh_key'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True)) # Build DB image inputs['init_db_build.run_build'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True)) # Cluster init + optional features inputs['init_cluster.cluster_env'] = 'dev' inputs['init_cluster.k3s_server_url'] = '' inputs['init_cluster.k3s_token'] = '' inputs['init_cluster.supabase_enabled'] = _bool_str(False) inputs['init_cluster.kerberos_enabled'] = _bool_str(False) inputs['init_cluster.at_rest_encryption_enabled'] = _bool_str(True) inputs['init_cluster.start_cluster'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_cluster.start_cluster', True)) # Kerberos config inputs['kerberos_config.enabled'] = _bool_str(False) inputs['kerberos_config.realm'] = '' inputs['kerberos_config.kdc'] = '' inputs['kerberos_config.user'] = '' inputs['kerberos_config.password'] = '' inputs['kerberos_config.test_connection'] = _bool_str(DEFAULT_ACTION_FLAGS.get('kerberos_config.test_connection', False)) # Ollama config inputs['ollama_config.server_host'] = '' inputs['ollama_config.server_port'] = DEFAULT_OLLAMA_PORT inputs['ollama_config.model'] = '' # Init scripts + deploy inputs['init_scripts.run_scripts'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_scripts.run_scripts', True)) inputs['init_cnpg_deploy.run_deploy'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True)) inputs['init_cnpg_deploy.force_rollout'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.force_rollout', False)) # Disk selection (installer packaging) inputs['disk_selection.disk_type'] = 'local' inputs['disk_selection.removable_mount'] = '' inputs['disk_selection.local_path'] = str(Path.home()) # Build tools app inputs['build.deploy_env'] = 'Dev' inputs['build.run_build'] = _bool_str(DEFAULT_ACTION_FLAGS.get('build.run_build', False)) return inputs # ---------------- Process helpers ---------------- def _run_cmd(self, cmd, cwd=None, env=None, stdin_text=None, on_stdout=None, on_stderr=None) -> int: stdin_handle = subprocess.PIPE if stdin_text else None if isinstance(cmd, str): proc = subprocess.Popen(['bash', '-lc', cmd], cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=stdin_handle, text=True, bufsize=1) else: proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=stdin_handle, text=True, bufsize=1) if stdin_text and proc.stdin: try: proc.stdin.write(stdin_text) proc.stdin.close() except Exception: pass def _read_stream(stream, handler, is_err=False): if not stream: return for line in iter(stream.readline, ''): if handler: handler(line) else: if is_err: self.err(line.rstrip('\n')) else: self.log(line.rstrip('\n')) err_thread = threading.Thread(target=_read_stream, args=(proc.stderr, on_stderr, True), daemon=True) err_thread.start() _read_stream(proc.stdout, on_stdout, False) rc = proc.wait() err_thread.join(timeout=2) return rc def _run_script(self, script_name: str, args=None, env=None, stdin_text=None, on_line=None) -> int: def _stdout(line): self.log(line.rstrip('\n')) if on_line: on_line(line) def _stderr(line): self.err(line.rstrip('\n')) mode_args = [] if script_name.startswith("init_"): mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) if mode: mode_args = ["--mode", mode] return self.controller.run_script( script_name, args=mode_args + (args or []), env=env, stdin_text=stdin_text, on_line=_stdout, on_stderr_line=_stderr, stderr_to_stdout=False, ) def _ensure_local_registry_available(self): """Ensure a local k3d registry is running and return (host_registry, cluster_registry).""" if not _local_registry_enabled(self._get_input('init_cluster.cluster_env', '')): self.log("[SKIP] Local registry disabled; skipping registry setup.") return None reg_name = 'prole-registry' registry_container = f'k3d-{reg_name}' host_registry = 'localhost:5000' cluster_registry = f'{registry_container}.localhost:5000' if not self.controller.check_docker_running(): self.err("[WARN] Docker not running; cannot ensure local registry.") return None # 1. If already responsive, just use it if _http_ping_registry('localhost', 5000): self.log("[INFO] Local registry already responsive on localhost:5000.") lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True) if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''): cluster_registry = 'localhost:5000' self.local_registry_url = host_registry self.local_registry_internal = cluster_registry self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY'] = host_registry self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY_INTERNAL'] = cluster_registry return host_registry, cluster_registry # 2. Require k3d to manage/create the local registry k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0 if not k3d_exists: self.err("[WARN] k3d not found; cannot ensure local registry.") return None lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True) if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''): self.log("[INFO] Creating local k3d registry...") subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) running = subprocess.run( ['docker', 'ps', '--filter', f'name={registry_container}', '--format', '{{.Names}}'], capture_output=True, text=True ) if not (running.stdout or '').strip(): exists = subprocess.run( ['docker', 'ps', '-a', '--filter', f'name={registry_container}', '--format', '{{.Names}}'], capture_output=True, text=True ) if (exists.stdout or '').strip(): subprocess.run(['docker', 'start', registry_container], check=True) self.local_registry_url = host_registry self.local_registry_internal = cluster_registry self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY'] = host_registry self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY_INTERNAL'] = cluster_registry return host_registry, cluster_registry def _argocd_namespace(self) -> str: ns = (self.prole_cfg_data.get('Global', {}) or {}).get('ARGOCD_NAMESPACE', '').strip() if not ns: ns = (os.environ.get('ARGOCD_NAMESPACE') or '').strip() return ns or 'argocd' def _registry_namespace(self) -> str: ns = (self.prole_cfg_data.get('Global', {}) or {}).get('REGISTRY_NAMESPACE', '').strip() if not ns: ns = (os.environ.get('REGISTRY_NAMESPACE') or '').strip() return ns or 'default' def _ensure_registry_defaults(self, mode: str) -> None: db = self.prole_cfg_data.get('Docker Build', {}) or {} if mode == 'k3d' and _local_registry_enabled(mode): info = self._ensure_local_registry_available() if info: host_registry, cluster_registry = info db.setdefault('LOCAL_REGISTRY', host_registry) db.setdefault('LOCAL_REGISTRY_INTERNAL', cluster_registry) if mode == 'k3s' and _local_registry_enabled(mode): server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip() if not server: server = (self.prole_cfg_data.get('Global', {}) or {}).get('PROLE_K3S_SERVER', '').strip() host = '' if server: host = server.replace('https://', '').replace('http://', '') host = host.split('/')[0].split(':')[0] if host: cur = (db.get('LOCAL_REGISTRY') or '').strip() if not cur or cur == 'localhost:5000': db['LOCAL_REGISTRY'] = f"{host}:5000" cur_internal = (db.get('LOCAL_REGISTRY_INTERNAL') or '').strip() if not cur_internal: registry_ns = self._registry_namespace() db['LOCAL_REGISTRY_INTERNAL'] = f"registry.{registry_ns}.svc.cluster.local:5000" self.prole_cfg_data['Docker Build'] = db def _collect_dependent_images(self, include_supabase: bool, include_kerberos_proxy: bool) -> list[str]: images = set() for rel_dir in ('k8s/prole', 'k8s/openbao'): base_dir = self.project_root / rel_dir if not base_dir.exists(): continue images.update(_collect_images_from_files(list(base_dir.glob('*.yaml')))) if include_supabase: supa_home = _resolve_supabase_home(self.project_root) if supa_home: docker_dir = supa_home / 'docker' compose_files = [docker_dir / 'docker-compose.yml'] if os.environ.get('SUPABASE_USE_DEV_COMPOSE') == '1': compose_files.append(docker_dir / 'dev' / 'docker-compose.dev.yml') images.update(_collect_images_from_files(compose_files)) if not include_supabase: images = {img for img in images if 'supabase' not in img} if include_kerberos_proxy: krb_img = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat') if krb_img: images.add(krb_img) return sorted(images) def _prepull_images_to_registry(self, include_supabase: bool, include_kerberos_proxy: bool) -> bool: if not _local_registry_enabled(self._get_input('init_cluster.cluster_env', '')): self.log("[SKIP] Local registry disabled; skipping image pre-pull.") return False mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) if mode != 'k3d': self.log("[SKIP] Local registry pre-pull only supported for k3d.") return False info = self._ensure_local_registry_available() if not info: self.err("[WARN] Local registry unavailable; skipping image pre-pull.") return False registry, _cluster_registry = info images = self._collect_dependent_images(include_supabase, include_kerberos_proxy) if not images: self.log("[INFO] No dependent images found to pre-pull.") return True overall_ok = True import_dir = self.docker_import_dir for image in images: local_tag = image if not image.startswith(f"{registry}/"): local_tag = f"{registry}/{image}" # 1. Check if already in local registry self.log(f"[INFO] Checking if {image} exists in local registry...") check_reg = subprocess.run(['docker', 'pull', local_tag], capture_output=True, text=True) if check_reg.returncode == 0: self.log(f"[OK] {image} already exists in local registry as {local_tag}") continue # 2. Check if we already have it in local docker daemon check_local = subprocess.run(['docker', 'image', 'inspect', image], capture_output=True, text=True) found = (check_local.returncode == 0) if not found and import_dir and os.path.isdir(import_dir): # 3. Check import directory safe_name = image.replace("/", "_").replace(":", "_") tar_path = Path(import_dir) / f"{safe_name}.tar" if tar_path.exists(): self.log(f"[INFO] Found {tar_path} in import directory, loading...") load = subprocess.run(['docker', 'load', '-i', str(tar_path)], capture_output=True, text=True) if load.returncode == 0: found = True else: self.err(f"[WARN] Failed to load {tar_path}: {load.stderr}") if not found: # 4. Pull from Docker Hub self.log(f"[INFO] Pulling {image} from Docker Hub...") pull = subprocess.run(['docker', 'pull', image], capture_output=True, text=True) if pull.returncode != 0: overall_ok = False self.err(pull.stdout or '') self.err(pull.stderr or '') self.err(f"[ERROR] docker pull failed for {image}") continue found = True # If we have the image locally, tag and push to local registry if found: if local_tag != image: tag = subprocess.run(['docker', 'tag', image, local_tag], capture_output=True, text=True) if tag.returncode != 0: overall_ok = False self.err(tag.stdout or '') self.err(tag.stderr or '') self.err(f"[ERROR] docker tag failed for {image} to {local_tag}") continue self.log(f"[INFO] Pushing {local_tag} to local registry...") if not _push_docker_image(local_tag, log_fn=self.log): overall_ok = False continue self.log(f"[OK] Stored {image} in local registry as {local_tag}") return overall_ok def _export_docker_image(self, image_name: str, export_dir: Path) -> Path: export_dir.mkdir(parents=True, exist_ok=True) safe_name = image_name.replace("/", "_").replace(":", "_") tar_path = export_dir / f"{safe_name}.tar" if tar_path.exists(): self.log(f"[INFO] Reusing image tarball {tar_path}") return tar_path self.log(f"[INFO] Exporting {image_name} to {tar_path}") rc = self._run_cmd(['docker', 'save', '-o', str(tar_path), image_name]) if rc != 0: raise Exception(f"Failed to export image {image_name} (code {rc})") return tar_path def _import_k3s_image(self, image_name: str, force: bool = False) -> bool: env_key = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', 'dev')) if env_key != 'service': return True if _local_registry_enabled(env_key) and not force: self.log("[INFO] Local registry enabled; skipping k3s image import.") return True inspect = subprocess.run(['docker', 'image', 'inspect', image_name], capture_output=True) if inspect.returncode != 0: self.err(f"[WARN] {image_name} not found locally; cannot import to k3s.") return False export_dir = None if self.docker_import_dir: export_dir = Path(self.docker_import_dir).expanduser() elif Path("/private/tmp").exists(): export_dir = Path("/private/tmp") else: export_dir = Path("/tmp") tar_path = self._export_docker_image(image_name, export_dir) ansible_env = os.environ.copy() ansible_cfg = self.project_root / "ansible.cfg" if ansible_cfg.exists(): ansible_env["ANSIBLE_CONFIG"] = str(ansible_cfg) vault_file = os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") if not vault_file: candidate = self.project_root / ".vault_pass" if candidate.exists(): vault_file = str(candidate) host_group = "k3s_hosts" ansible_base = ["ansible", host_group] if vault_file: ansible_base += ["--vault-password-file", vault_file] dest_path = f"/tmp/{tar_path.name}" self.log(f"[INFO] Importing {image_name} into k3s nodes (group: {host_group})") rc = self._run_cmd( ansible_base + ["-m", "copy", "-a", f"src={tar_path} dest={dest_path} mode=0644"], cwd=str(self.project_root), env=ansible_env, ) if rc != 0: self.err(f"[WARN] Failed to copy {tar_path} to k3s nodes (code {rc})") return False rc = self._run_cmd( ansible_base + ["-m", "shell", "-a", f"ctr -n k8s.io -a /run/k3s/containerd/containerd.sock images import --no-unpack {dest_path}"], cwd=str(self.project_root), env=ansible_env, ) if rc != 0: self.err(f"[WARN] Failed to import {image_name} into k3s nodes (code {rc})") return False return True # ---------------- Steps ---------------- def _apply_inputs(self): defaults = self._default_inputs() loaded = self._load_inputs_from_cfg() self.inputs = {**defaults, **loaded} try: self._apply_ansible_defaults(set(loaded.keys())) except Exception: pass mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) if not mode: mode = _deployment_mode_from_env(self._get_input('init_cluster.mode', '')) if mode: os.environ['PROLE_MODE'] = mode for k in ('env_setup.PROLE_HOME', 'env_setup.PROLE_CONF', 'env_setup.PROLE_DATA', 'env_setup.PROLE_LOGS', 'env_setup.PROLE_SERVICE'): if k in self.inputs: self.inputs[k] = _expand_path(self.inputs[k]) def _apply_ansible_defaults(self, loaded_keys: set[str] | None = None): info = _detect_ansible_topology(self.project_root) if not info: return loaded_keys = loaded_keys or set() auto_keys = getattr(self, '_auto_input_keys', set()) ansible_kdc = info.get('kdc_ip') or '' try: net = self.prole_cfg_data.get('Network', {}) if info.get('topology_json'): net['ANSIBLE_TOPOLOGY'] = info['topology_json'] if info.get('inventory_path'): net['ANSIBLE_INVENTORY'] = info['inventory_path'] if info.get('infrastructure_path'): net['ANSIBLE_INFRASTRUCTURE'] = info['infrastructure_path'] if info.get('domain'): net['ANSIBLE_DOMAIN'] = info['domain'] if info.get('realm'): net['ANSIBLE_REALM'] = info['realm'] if info.get('ad_dc_host'): net['AD_DC_HOST'] = info['ad_dc_host'] if info.get('ad_dc_ip'): net['AD_DC_IP'] = info['ad_dc_ip'] if info.get('kdc_ip'): net['KDC_ANSIBLE_DETECTED'] = info['kdc_ip'] self.prole_cfg_data['Network'] = net except Exception: pass if ansible_kdc and 'kerberos_config.kdc' in auto_keys: self.inputs['kerberos_config.kdc'] = ansible_kdc if ansible_kdc and 'kerberos_config.enabled' in auto_keys: self.inputs['kerberos_config.enabled'] = _bool_str(True) if 'kerberos_config.kdc' not in loaded_keys: if not self._get_input('kerberos_config.kdc', '').strip() and info.get('kdc_ip'): self.inputs['kerberos_config.kdc'] = info['kdc_ip'] if 'kerberos_config.realm' not in loaded_keys: if not self._get_input('kerberos_config.realm', '').strip() and info.get('realm'): self.inputs['kerberos_config.realm'] = info['realm'] if 'kerberos_config.enabled' not in loaded_keys: if info.get('kdc_ip') and not self._get_input_bool('kerberos_config.enabled', False): self.inputs['kerberos_config.enabled'] = _bool_str(True) if 'init_cluster.k3s_server_url' not in loaded_keys: if not self._get_input('init_cluster.k3s_server_url', '').strip() and info.get('k3s_server_url'): self.inputs['init_cluster.k3s_server_url'] = info['k3s_server_url'] if 'init_cluster.k3s_token' not in loaded_keys: if not self._get_input('init_cluster.k3s_token', '').strip() and info.get('k3s_token'): self.inputs['init_cluster.k3s_token'] = info['k3s_token'] def _write_cfg(self): inputs = dict(self.inputs) db_pw = self._get_input('init_password.db_password', '') db_pw_cfg = self._secret_cfg_value('Inputs', 'init_password.db_password', db_pw, 'db', 'password') if db_pw_cfg: inputs['init_password.db_password'] = db_pw_cfg inputs['init_password.db_password_confirm'] = db_pw_cfg elif db_pw: inputs['init_password.db_password'] = db_pw inputs['init_password.db_password_confirm'] = self._get_input('init_password.db_password_confirm', '') or db_pw krb_pw = self._get_input('kerberos_config.password', '') if krb_pw: inputs['kerberos_config.password'] = self._secret_cfg_value('Inputs', 'kerberos_config.password', krb_pw, 'kerberos', 'password') k3s_token = self._get_input('init_cluster.k3s_token', '') if k3s_token: inputs['init_cluster.k3s_token'] = self._secret_cfg_value('Inputs', 'init_cluster.k3s_token', k3s_token, 'k3s', 'token') mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) target_label = _deployment_target_label(self._get_input('init_cluster.cluster_env', '')) globals_to_save = { 'PROLE_HOME': self._get_input('env_setup.PROLE_HOME', ''), 'PROLE_DB_USER': self._get_input('init_password.db_username', ''), 'DB_PASSWORD': self._secret_cfg_value('Global', 'DB_PASSWORD', db_pw, 'db', 'password'), 'CLUSTER_ENV': self._get_input('init_cluster.cluster_env', ''), 'DEPLOYMENT_MODE': mode, 'DEPLOYMENT_TARGET': target_label, 'NAMESPACE': (self._get_input('init_password.db_namespace', '') or '').strip(), 'DB_HOST_PORT': (self._get_input('init_password.db_host_port', '5432') or '5432').strip(), 'DOCKER_IMPORT_DIR': self.docker_import_dir or '', 'PROLE_K3S_SERVER': (self._get_input('init_cluster.k3s_server_url', '') or '').strip(), 'PROLE_K3S_TOKEN': self._secret_cfg_value('Global', 'PROLE_K3S_TOKEN', self._get_input('init_cluster.k3s_token', ''), 'k3s', 'token'), 'PROLE_OPENTOFU_URL': _default_opentofu_pipeline_url(), 'SERVICE_NAMESPACE': self._service_namespace(), } globals_to_save.update(self.prole_cfg_data.get('Global', {})) globals_to_save['DB_PASSWORD'] = self._secret_cfg_value('Global', 'DB_PASSWORD', db_pw, 'db', 'password') globals_to_save['DEPLOYMENT_MODE'] = mode globals_to_save['DEPLOYMENT_TARGET'] = target_label globals_to_save.setdefault('ARGOCD_NAMESPACE', self._argocd_namespace()) globals_to_save.setdefault('REGISTRY_NAMESPACE', self._registry_namespace()) ollama_host = (self._get_input('ollama_config.server_host', '') or '').strip() ollama_port = (self._get_input('ollama_config.server_port', DEFAULT_OLLAMA_PORT) or '').strip() ollama_model = (self._get_input('ollama_config.model', '') or '').strip() ollama_section = dict(self.prole_cfg_data.get('Ollama', {})) if ollama_host: if not ollama_port: ollama_port = DEFAULT_OLLAMA_PORT ollama_section['OLLAMA_SERVER_HOST'] = ollama_host ollama_section['OLLAMA_SERVER_PORT'] = ollama_port ollama_section['OLLAMA_HOST'] = _format_ollama_host(ollama_host, ollama_port) else: for key in ('OLLAMA_SERVER_HOST', 'OLLAMA_SERVER_PORT', 'OLLAMA_HOST'): ollama_section.pop(key, None) if ollama_model: ollama_section['OLLAMA_MODEL'] = ollama_model else: ollama_section.pop('OLLAMA_MODEL', None) if ollama_section: self.prole_cfg_data['Ollama'] = ollama_section self._sync_port_forward_mappings() sections = {k: self.prole_cfg_data.get(k, {}) for k in [ 'Welcome', 'Dependencies', 'Network', 'Port Forwards', 'System Environment', 'Monitoring', 'Kerberos Authentication', 'Ollama', 'Optional Features', 'Database Creation', 'Initialize Cluster', 'Docker Build', 'Initialization Scripts', 'Deployment', 'Install' ]} deployment_section = dict(sections.get('Deployment', {})) if mode: deployment_section.setdefault('MODE', mode) if target_label: deployment_section.setdefault('TARGET', target_label) sections['Deployment'] = deployment_section sections['Dev Cluster (k3d)'] = { **self.prole_cfg_data.get('Dev Cluster (k3d)', {}), 'MODE': 'k3d', 'CLUSTER_ENV': 'k3d-prole-dev-cluster', 'DISPLAY_NAME': 'prole-dev-cluster', 'KUBECTL_CONTEXT': self._get_input('init_cluster.cluster_env', '') } sections['Service Cluster (k3s)'] = { **self.prole_cfg_data.get('Service Cluster (k3s)', {}), 'MODE': 'k3s', 'CLUSTER_ENV': 'prole-service-cluster', 'DISPLAY_NAME': 'prole-service-cluster', 'K3S_SERVER_URL': (self._get_input('init_cluster.k3s_server_url', '') or '').strip(), 'K3S_TOKEN': self._secret_cfg_value('Service Cluster (k3s)', 'K3S_TOKEN', self._get_input('init_cluster.k3s_token', ''), 'k3s', 'token'), 'PIPELINE_URL': _default_opentofu_pipeline_url() } sections['Prod Cluster (k8s)'] = { **self.prole_cfg_data.get('Prod Cluster (k8s)', {}), 'MODE': 'k8s', 'CLUSTER_ENV': 'prole-prod-cluster', 'DISPLAY_NAME': 'prole-prod-cluster', 'ARTIFACTS_DIR': (self._get_input('init_cluster.prod_artifacts_path', '') or '').strip(), 'PIPELINE_URL': _default_opentofu_pipeline_url() } sections = self._sanitize_sections_for_cfg(sections) # Check for existing config and timestamp to maintain idempotency existing_text = None existing_timestamp = None if self.cfg_path.exists(): existing_text = self.cfg_path.read_text() for line in existing_text.splitlines(): if line.startswith('; Generated by install.py on '): existing_timestamp = line[len('; Generated by install.py on '):].strip() break cfg_text = _render_prole_cfg(inputs, globals_to_save, sections, generated_at=existing_timestamp) should_write = True if existing_text: if existing_text == cfg_text: should_write = False if should_write: # If content changed but we reused existing_timestamp, it might be misleading. # But usually we want to know when it LAST changed. # If it's different, let's generate a new one. if existing_text and existing_text != cfg_text: cfg_text = _render_prole_cfg(inputs, globals_to_save, sections) self.cfg_path.parent.mkdir(parents=True, exist_ok=True) self.cfg_path.write_text(cfg_text) self.log(f"[CONFIG] Wrote {self.cfg_path}") def _step_dependencies(self) -> bool: self.log("==> Dependencies") missing = [] for dep in self.dependencies: ok, location, version = inst_config.get_dep_info(dep) if ok: self.log(f"[OK] {dep['name']} {version or ''}".strip()) continue missing.append(dep) self.log(f"[MISSING] {dep['name']}") if not missing: self.prole_cfg_data['Dependencies']['STATUS'] = 'All installed' return True auto_install = self._get_input_bool('dependencies.auto_install_missing', DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True)) if not auto_install: self.err("[ERROR] Dependencies missing and auto-install disabled.") self.prole_cfg_data['Dependencies']['STATUS'] = 'Missing' return False for dep in missing: install_cmd = dep.get('install_cmd') if not install_cmd: self.err(f"[ERROR] No install command for {dep['name']}.") continue should_install = self._get_input_bool(f"dependencies.{dep['id']}.install", True) if not should_install: self.err(f"[SKIP] {dep['name']} install disabled by config.") continue self.log(f"[INSTALL] {dep['name']} -> {install_cmd}") rc = self._run_cmd(install_cmd) if rc != 0: self.err(f"[ERROR] Install failed for {dep['name']} (code {rc})") 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.err(f"[ERROR] Still missing: {', '.join(still_missing)}") self.prole_cfg_data['Dependencies']['STATUS'] = 'Missing' return False self.prole_cfg_data['Dependencies']['STATUS'] = 'All installed' return True def _step_network_scan(self) -> None: if not self._get_input_bool('network_scan.run', DEFAULT_ACTION_FLAGS.get('network_scan.run', True)): self.log("[SKIP] Network scan disabled.") return existing_kdc = self.prole_cfg_data.get('Network', {}).get('KDC_AUTO_DETECTED') if existing_kdc: self.log(f"[OK] Network scan already performed. KDC detected: {existing_kdc}") return self.log("==> Network scan (prole-agent)") ansible_kdc = '' try: ansible_kdc = (self.prole_cfg_data.get('Network', {}) or {}).get('KDC_ANSIBLE_DETECTED', '') except Exception: ansible_kdc = '' scan_binary = get_resource_path("prole-net/prole-agent") if not scan_binary.exists(): self.err(f"[ERROR] 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 self.log(line.rstrip('\n')) 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: for part in line.split(): try: socket.inet_aton(part.strip('[]():,')) kdc_found = part.strip('[]():,') break except Exception: continue rc = self._run_cmd([str(scan_binary), "-t", "10"], cwd=str(scan_dir), on_stdout=_handle_stdout) if rc == 0: self.log("[OK] Scan complete.") else: self.err(f"[ERROR] Scan failed (code {rc})") if kdc_found and not ansible_kdc: self.inputs['kerberos_config.kdc'] = kdc_found self.inputs['kerberos_config.enabled'] = _bool_str(True) self.prole_cfg_data['Network']['KDC_AUTO_DETECTED'] = kdc_found self.prole_cfg_data['Network']['KERBEROS_AUTO_ENABLED'] = 'True' elif kdc_found and ansible_kdc: self.log(f"[INFO] Ansible KDC detected ({ansible_kdc}); ignoring scan-detected KDC {kdc_found}.") def _step_env_setup(self) -> None: self.log("==> Environment setup") vals = { 'PROLE_HOME': self._get_input('env_setup.PROLE_HOME', str(Path.home() / '.prole')), 'PROLE_CONF': self._get_input('env_setup.PROLE_CONF', ''), 'PROLE_DATA': self._get_input('env_setup.PROLE_DATA', ''), 'PROLE_LOGS': self._get_input('env_setup.PROLE_LOGS', ''), 'PROLE_SERVICE': self._get_input('env_setup.PROLE_SERVICE', ''), 'PROLE_OPENTOFU_URL': (os.environ.get('PROLE_OPENTOFU_URL') or '').strip(), } defaults = self._env_defaults(self._get_input('env_setup.NAMESPACE', '')) for k in vals: if not vals[k]: vals[k] = defaults.get(k, '') if not vals.get('PROLE_OPENTOFU_URL'): vals['PROLE_OPENTOFU_URL'] = _default_opentofu_pipeline_url() vals['NAMESPACE'] = self._get_input('env_setup.NAMESPACE', '') if not vals.get('PROLE_HOME'): raise Exception("PROLE_HOME is required for env setup.") # Persist normalized inputs for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): self.inputs[f'env_setup.{k}'] = vals[k] self.inputs['env_setup.NAMESPACE'] = vals.get('NAMESPACE', '') self._save_env_to_file(vals) self.reload_env_from_shell() for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE', 'PROLE_OPENTOFU_URL'): self.prole_cfg_data['System Environment'][k] = vals[k] def _step_init_password(self) -> None: self.log("==> Database creation") ns = (self._get_input('init_password.db_namespace', '') or '').strip() if not ns: ns = self._get_input('env_setup.NAMESPACE', '') if not ns: raise Exception("Database namespace cannot be empty.") if not self._is_valid_namespace(ns): raise Exception("Invalid database namespace.") user = (self._get_input('init_password.db_username', '') or '').strip() if not user: import getpass try: user = getpass.getuser() except Exception: user = "prole" self.log(f"[INFO] No database owner provided; using default: {user}") p1 = self._get_input('init_password.db_password', '') p2 = self._get_input('init_password.db_password_confirm', '') or p1 if not p1: p1 = self._generate_db_password() p2 = p1 self.log("[INFO] No database password provided; generated default password.") if p1 != p2: raise Exception("Database passwords do not match.") # If password is an OpenBao anchor, only skip when OpenBao is already initialized. if p1.startswith('${'): resolved = self._resolve_secret_value(p1) if resolved and not resolved.startswith('${'): p1 = resolved p2 = resolved else: service_dir = self._get_input('env_setup.PROLE_SERVICE', '').strip() if not service_dir: service_dir = self._env_defaults(ns).get('PROLE_SERVICE', '') token_path = Path(service_dir) / 'secrets' / 'openbao-root-token' local_token_path = Path.home() / '.prole' / 'etc' / 'secrets' / 'openbao-root-token' has_token = bool(os.environ.get("OPENBAO_ROOT_TOKEN")) or token_path.exists() or local_token_path.exists() if self.reset_requested or not has_token: p1 = self._generate_db_password() p2 = p1 self.log("[RESET] OpenBao anchor detected without token; generated new DB password for initialization.") else: self.log(f"[OK] OpenBao secrets already anchored for namespace {ns}. Skipping initialization.") return self.inputs['init_password.db_namespace'] = ns self.inputs['env_setup.NAMESPACE'] = ns self.inputs['init_password.db_password'] = p1 self.inputs['init_password.db_password_confirm'] = p2 self._update_env_namespace(ns) self.prole_cfg_data['Database Creation']['DB_USER'] = user self.prole_cfg_data['Database Creation']['DB_PASSWORD_SET'] = 'true' self.prole_cfg_data['Database Creation']['DB_NAME'] = ns self.prole_cfg_data['Database Creation']['NAMESPACE'] = ns include_supabase = self._get_input_bool('init_cluster.supabase_enabled', False) include_kerberos = self._get_input_bool('init_cluster.kerberos_enabled', False) or self._get_input_bool('kerberos_config.enabled', False) try: self._prepull_images_to_registry(include_supabase, include_kerberos) except Exception as e: self.err(f"[WARN] Image pre-pull error: {e}") if self._get_input_bool('init_password.generate_ssh_key', DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True)): self._generate_ssh_key(user) # Initialize OpenBao and store keys/passwords for this namespace. # OpenBao is expected to run inside the cluster; defer initialization until cluster is ready. env = os.environ.copy() env["PROLE_HOME"] = str(self.project_root) env["PROLE_SERVICE"] = str(self.project_root) env["PROLE_DB_USER"] = user env["DB_PASSWORD"] = p1 env["GRAFANA_ADMIN_PASSWORD"] = p1 env["NAMESPACE"] = ns env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False)) realm = self._get_input('kerberos_config.realm', '').strip() kdc = self._get_input('kerberos_config.kdc', '').strip() krb_user = self._get_input('kerberos_config.user', '').strip() krb_pw = self._get_input('kerberos_config.password', '').strip() if realm: env["KRB5_REALM"] = realm env["REALM"] = realm env["DOMAIN"] = realm.lower() if kdc: env["KRB5_KDC"] = kdc env["KRB5_ADMIN"] = kdc if krb_user: env["KRB5_USER"] = krb_user if krb_pw: env["KRB5_PASSWORD"] = krb_pw self._openbao_init_env = env self._openbao_init_password = p1 self.log("[INFO] Deferring OpenBao initialization until cluster is ready.") def _generate_ssh_key(self, user: str): self.log("==> Environment Preparation (Secure Access)") key_path = Path.home() / ".ssh" / "id_prole_ed25519" key_path.parent.mkdir(parents=True, exist_ok=True) if key_path.exists(): self.log(f"[SKIP] Secure access keys already exist at {key_path}") return cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", user] rc = self._run_cmd(cmd) if rc != 0: self.err(f"[WARN] Preparation failed (code {rc}), trying alternative.") cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", user] rc = self._run_cmd(cmd) if rc != 0: self.err(f"[ERROR] Preparation failed (code {rc})") def _step_db_build(self) -> None: if not self._get_input_bool('init_db_build.run_build', DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True)): self.log("[SKIP] DB build disabled.") self.prole_cfg_data['Docker Build']['STATUS'] = 'Skipped' return tag = self.controller.get_prole_db_version() image_name = f"prole-db:{tag}" env_key = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', 'dev')) cluster_name = "prole-dev-cluster" # Check if image already exists rc_inspect = subprocess.run(['docker', 'inspect', image_name], capture_output=True).returncode if rc_inspect == 0: self.log(f"[OK] {image_name} already exists. Skipping build.") if env_key == 'dev': self.log(f"Importing image to {cluster_name}...") self._run_cmd(['k3d', 'image', 'import', image_name, '-c', cluster_name]) elif env_key == 'service': try: self._import_k3s_image(image_name) except Exception as e: self.err(f"[WARN] k3s image import failed: {e}") self._db_built_success = True self.prole_cfg_data['Docker Build']['STATUS'] = 'Built' return self.log("==> Build prole-db image") prole_home = Path.home() / ".prole" build_dir = prole_home / "build" / "prole-db" build_dir.mkdir(parents=True, exist_ok=True) source_dir = get_resource_path("prole-db") if source_dir.exists(): if source_dir.resolve() != build_dir.resolve(): if build_dir.exists(): shutil.rmtree(build_dir) shutil.copytree(source_dir, build_dir) pub_key_path = Path.home() / ".ssh" / "id_prole_ed25519.pub" pub_key = pub_key_path.read_text().strip() if pub_key_path.exists() else "" username = self._get_input('init_password.db_username', '') cmd = ['docker', 'build'] cmd.extend(get_docker_build_platform_args(env_key)) cmd += [ '--build-arg', f"PROLE_USER={username}", '--build-arg', f"PROLE_SSH_PUB_KEY={pub_key}", '-t', image_name, '.' ] rc = self._run_cmd(cmd, cwd=str(build_dir)) if rc == 0: self._db_built_success = True self.log("[OK] Build successful.") push_ok = True if env_key == 'dev': cluster_name = "prole-dev-cluster" self.log(f"Importing image to {cluster_name}...") self._run_cmd(['k3d', 'image', 'import', image_name, '-c', cluster_name]) else: # For non-dev, tag and push to registry db_cfg = self.prole_cfg_data.get('Docker Build', {}) or {} registry = (db_cfg.get('LOCAL_REGISTRY') or '').strip() if (not registry or registry == 'localhost:5000') and env_key == 'service': server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip() if not server: server = (self.prole_cfg_data.get('Global', {}) or {}).get('PROLE_K3S_SERVER', '').strip() if server: host = server.replace('https://', '').replace('http://', '') host = host.split('/')[0].split(':')[0] if host: registry = f"{host}:5000" db_cfg['LOCAL_REGISTRY'] = registry self.prole_cfg_data['Docker Build'] = db_cfg if not registry: registry = 'localhost:5000' remote_tag = f"{registry}/{image_name}" self.log(f"Tagging {image_name} as {remote_tag}...") subprocess.run(['docker', 'tag', image_name, remote_tag], check=True) self.log(f"Pushing {remote_tag} to registry...") if not _push_docker_image(remote_tag, log_fn=self.log): push_ok = False self.err(f"[ERROR] Failed to push image {remote_tag}") self._db_built_success = False if env_key == 'service': try: self._import_k3s_image(image_name, force=not push_ok) except Exception as e: self.err(f"[WARN] k3s image import failed: {e}") self.prole_cfg_data['Docker Build']['STATUS'] = 'Built' if self._db_built_success else 'Failed' def _step_cluster(self) -> None: self.log("==> Cluster setup") cluster_env = self._get_input('init_cluster.cluster_env', 'dev') env_key = _normalize_cluster_env(cluster_env) self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = cluster_env self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = self._get_input('init_cluster.k3s_server_url', '') self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self._get_input('init_cluster.k3s_token', '') or '') self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.supabase_enabled', False)) self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.kerberos_enabled', False)) self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False)) mode = _deployment_mode_from_env(cluster_env) if mode: self._ensure_registry_defaults(mode) if not self._get_input_bool('init_cluster.start_cluster', DEFAULT_ACTION_FLAGS.get('init_cluster.start_cluster', True)): self.log("[SKIP] Cluster start disabled.") return if env_key == 'dev': if not self.controller.check_docker_running(): if platform.system() == 'Darwin': self.log("Starting Docker...") self._run_cmd(['open', '-a', 'Docker']) for _ in range(30): time.sleep(2) if self.controller.check_docker_running(): break if not self.controller.check_docker_running(): raise Exception("Docker is not running.") cluster_name = "prole-dev-cluster" res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) res_stdout = res.stdout or '' if self.reset_cluster: self.log(f"[RESET] Deleting k3d cluster {cluster_name}...") self._run_cmd(['k3d', 'cluster', 'delete', cluster_name]) res_stdout = '' self.reset_cluster = False if cluster_name not in res_stdout: prole_data = self._resolve_env_dir('PROLE_DATA', 'data') self._create_k3d_cluster(cluster_name, prole_data) else: # Check if it's already running if 'running' not in res_stdout.lower(): self._run_cmd(['k3d', 'cluster', 'start', cluster_name]) else: self.log(f"[OK] Cluster {cluster_name} is already running.") self.log(f"[OK] Cluster ready: {cluster_name}") try: os.environ.pop("KUBECONFIG", None) subprocess.run(['kubectl', 'config', 'use-context', f'k3d-{cluster_name}'], capture_output=True) except Exception: pass else: # Set KUBECONFIG to local prole secrets if it exists service_dir = self._get_input('env_setup.PROLE_SERVICE', '').strip() candidates = [] if service_dir: candidates.append(Path(service_dir) / 'secrets' / 'k3s.kubeconfig') candidates.append(Path.home() / '.prole' / 'secrets' / 'k3s.kubeconfig') candidates.append(self.project_root / 'etc' / 'secrets' / 'k3s.kubeconfig') for kc in candidates: if kc.exists(): os.environ['KUBECONFIG'] = str(kc) self.log(f"Using local kubeconfig: {kc}") break kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) if kubectl.returncode != 0: raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.") if env_key == 'service': if self.reset_cluster: self.log("[RESET] Resetting k3s cluster (mode k3s)...") self._cleanup_local_k3s_artifacts() vault_args = [] v_file = os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') if v_file: vault_args = ["-v", v_file] delete_cmd = ["./ansible.sh", "-p", "infrastructure/playbooks/k3s_delete.yml"] + vault_args self.log(f"Running: {' '.join(delete_cmd)}") rc = self._run_cmd(delete_cmd) if rc != 0: raise Exception(f"k3s delete failed with code {rc}") # 1) --reset should run the ansible k3s/tasks/cleanup on myrddin and then reinstall. # This is handled by k3s_reset.yml reset_cmd = ["./ansible.sh", "-p", "infrastructure/playbooks/k3s_reset.yml", "-l", "myrddin.prole.org"] + vault_args self.log(f"Running: {' '.join(reset_cmd)}") rc = self._run_cmd(reset_cmd) if rc != 0: raise Exception(f"k3s reset failed with code {rc}") # 2) Capture new k3s token + TLS bundle from init server and update vault sync_token_cmd = ["./ansible.sh", "-p", "infrastructure/playbooks/k3s_sync.yml", "-l", "myrddin.prole.org"] + vault_args self.log(f"Running: {' '.join(sync_token_cmd)}") rc = self._run_cmd(sync_token_cmd) if rc != 0: raise Exception(f"k3s sync (token) failed with code {rc}") # 3) Reinstall agent nodes with the refreshed token reset_agents_cmd = ["./ansible.sh", "-p", "infrastructure/playbooks/k3s_reset.yml", "-l", "pi.prole.org,retropie.prole.org"] + vault_args self.log(f"Running: {' '.join(reset_agents_cmd)}") rc = self._run_cmd(reset_agents_cmd) if rc != 0: raise Exception(f"k3s reset (agents) failed with code {rc}") self.reset_cluster = False if not (os.environ.get("KUBECONFIG") or "").strip(): kubeconfig_path = _find_kubeconfig_file() if kubeconfig_path: os.environ["KUBECONFIG"] = kubeconfig_path self.log(f"Using local kubeconfig: {kubeconfig_path}") server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip() token = (self._get_input('init_cluster.k3s_token', '') or '').strip() if token: token = self._resolve_secret_value(token) if server and token: if not server.startswith('http'): server = f"https://{server}" cmd = [ 'kubectl', '--server=' + server, '--token=' + token, '--insecure-skip-tls-verify=true', 'cluster-info' ] else: kubeconfig_env = (os.environ.get('KUBECONFIG') or '').strip() if kubeconfig_env: cmd = ['kubectl', '--kubeconfig', kubeconfig_env, 'cluster-info'] else: raise Exception("K3s server URL/token missing for service cluster.") else: cmd = ['kubectl', 'cluster-info'] res = subprocess.run(cmd, capture_output=True, text=True) if res.returncode != 0: raise Exception("Cluster is not reachable.") self.log(f"[OK] Cluster ready: {env_key}") if env_key == 'service': try: argocd_ns = self._argocd_namespace() registry_ns = self._registry_namespace() env = self._script_env_for_namespace(self._service_namespace()) env["ARGOCD_NAMESPACE"] = argocd_ns env["REGISTRY_NAMESPACE"] = registry_ns self.log("==> Registry/ArgoCD preflight") self._run_script( "init_registry.sh", args=["-n", argocd_ns, "--registry-namespace", registry_ns, "update"], env=env, ) except Exception as e: self.err(f"[WARN] Registry/ArgoCD preflight failed: {e}") if self._openbao_init_env: env = dict(self._openbao_init_env) mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) if not mode: if env_key == 'dev': mode = 'k3d' elif env_key == 'service': mode = 'k3s' elif env_key == 'prod': mode = 'k8s' env.setdefault("PROLE_MODE", mode or env_key) self.log("==> OpenBao initialize (cluster)") rc_bao = self._run_script("init_openbao.sh", args=["initialize"], env=env, stdin_text=f"{self._openbao_init_password}\n") if rc_bao != 0: raise Exception(f"OpenBao initialization failed (code {rc_bao})") self._openbao_init_env = None self._openbao_init_password = None # Run repair pipeline if anomalies are detected on a ready cluster try: service_ns = self._service_namespace() env = self._script_env_for_namespace(service_ns) self._maybe_run_repair_pipeline(env) except Exception: pass if env_key == 'service': self._deploy_common_services(env_key) else: self._deploy_opentofu(env_key) def _deploy_common_services(self, env_key: str) -> None: self.log("==> Common services deploy") service_ns = self._service_namespace() env = self._script_env_for_namespace(service_ns) kerberos_enabled = self._get_input_bool('kerberos_config.enabled', False) or \ self._get_input_bool('init_cluster.kerberos_enabled', False) try: # Check status before update status_args = ["-n", service_ns] if kerberos_enabled: status_args.append("-k") rc_status = self._run_script("status_common_services.sh", args=status_args, env=env) if rc_status == 0: self.log(f"[OK] Common services in {service_ns} are healthy. Skipping update.") return self.log(f"[INFO] Common services unhealthy or missing in {service_ns}. Attempting repair/deploy...") svc_args = ["-n", service_ns] if kerberos_enabled: svc_args.append("-k") svc_args.append("update") rc = self._run_script("init_common_services.sh", args=svc_args, env=env) if rc != 0: self.err(f"[ERROR] Common services deploy failed (code {rc})") else: # Re-validate after fix rc_status = self._run_script("status_common_services.sh", args=status_args, env=env) if rc_status != 0: self.err(f"[ERROR] Common services still unhealthy after repair attempt.") else: self.log(f"[OK] Common services in {service_ns} are now healthy.") finally: pass def _deploy_opentofu(self, env_key: str) -> None: self.log("==> OpenTofu deploy") ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' env = self._script_env_for_namespace(ns) try: rc = self._run_script("init_opentofu.sh", args=["start"], env=env) if rc != 0: self.err(f"[ERROR] OpenTofu deploy failed (code {rc})") finally: pass def _step_kerberos(self) -> None: enabled = self._get_input_bool('kerberos_config.enabled', False) if not enabled: self.log("[SKIP] Kerberos disabled.") return realm = self._get_input('kerberos_config.realm', '') kdc = self._get_input('kerberos_config.kdc', '') user = self._get_input('kerberos_config.user', '') password = self._get_input('kerberos_config.password', '') self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = _bool_str(enabled) self.prole_cfg_data['Kerberos Authentication']['REALM'] = realm self.prole_cfg_data['Kerberos Authentication']['KDC'] = kdc self.prole_cfg_data['Kerberos Authentication']['SERVER'] = kdc self.prole_cfg_data['Kerberos Authentication']['USER'] = user self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = password self.prole_cfg_data['Kerberos Authentication']['AD_PORT_FORWARD'] = os.environ.get('KRB5_AD_PORT_FORWARD', '1') self.prole_cfg_data['Kerberos Authentication']['AD_TCP_PORTS'] = os.environ.get('KRB5_AD_TCP_PORTS', '88 389 445 464 636') self.prole_cfg_data['Kerberos Authentication']['AD_UDP_PORTS'] = os.environ.get('KRB5_AD_UDP_PORTS', '88 464') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_HOST_NETWORK'] = os.environ.get('KRB5_AD_PROXY_HOST_NETWORK', '1') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_IMAGE'] = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_SERVICE'] = os.environ.get('KRB5_AD_SERVICE_NAME', 'prole-kerberos-ad-dc') ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' env = self._script_env_for_namespace(ns) env["KERBEROS_ENABLED"] = _bool_str(enabled) env["ENABLED"] = env["KERBEROS_ENABLED"] env["KRB5_REALM"] = realm if env.get("PROLE_MODE") == "k3s": env.setdefault("KRB5_AD_PORT_FORWARD", "0") if not self._get_input_bool('kerberos_config.test_connection', DEFAULT_ACTION_FLAGS.get('kerberos_config.test_connection', False)): self.log("[SKIP] Kerberos test disabled.") return # Check if already tested successfully if self.prole_cfg_data.get('Kerberos Authentication', {}).get('TEST_STATUS') == 'Success': self.log("[OK] Kerberos already tested successfully. Skipping.") return if not (realm and user and password and kdc): self.err("[WARN] Kerberos test skipped: missing realm/user/password/kdc.") return self.log("==> Kerberos test: init_kerberos.sh test") rc2 = self._run_script("init_kerberos.sh", args=["test"], env=env) if rc2 != 0: self.err(f"[ERROR] Kerberos test failed (code {rc2})") self.prole_cfg_data.setdefault('Kerberos Authentication', {})['TEST_STATUS'] = 'Failed' else: self.log("[OK] Kerberos test completed successfully.") self.prole_cfg_data.setdefault('Kerberos Authentication', {})['TEST_STATUS'] = 'Success' def _step_init_scripts(self) -> None: if not self._get_input_bool('init_scripts.run_scripts', DEFAULT_ACTION_FLAGS.get('init_scripts.run_scripts', True)): self.log("[SKIP] Init scripts disabled.") self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Skipped' return if self.prole_cfg_data.get('Initialization Scripts', {}).get('STATUS') == 'Finished': self.log("[OK] Initialization scripts already finished.") return self.log("==> Initialization scripts") ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' env = self._script_env_for_namespace(ns) password = self._get_input('init_password.db_password', '') kerberos_enabled = self._get_input_bool('kerberos_config.enabled', False) svc_args = ["update"] if kerberos_enabled: svc_args = ["-k", "update"] steps = [ ("init_common_services.sh", svc_args, False), ("init_cloudnative_pg.sh", ["initialize"], False), ] if kerberos_enabled: steps.append(("init_kerberos.sh", ["initialize"], False)) steps.extend([ ("init_prole-db-backup.sh", ["start"], False), ("init_monitoring.sh", ["initialize"], False), ("init_nginx_ingress.sh", ["initialize"], False), ]) overall_success = True for script, args, needs_password in steps: self.log(f"--> {script} {' '.join(args)}") stdin_text = f"{password}\n" if needs_password else None # Use custom line handler for monitoring to capture Grafana password and port mappings rc = self._run_script(script, args=args, env=env, stdin_text=stdin_text, on_line=self._process_script_output_line) if rc != 0: self.err(f"[ERROR] {script} failed (code {rc})") overall_success = False self._scripts_success = overall_success # Verify critical secrets self.log(f"==> Verifying critical secrets in namespace {ns}") critical_secrets = ["prole-db-user", "prole-db-superuser", "cnpg-admin-key"] missing_secrets = [] for secret in critical_secrets: rc_s = self._run_cmd(["kubectl", "get", "secret", secret, "-n", ns]) if rc_s != 0: missing_secrets.append(secret) if missing_secrets: self.err(f"[CRITICAL] Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}") self.err("Database initialization will fail without these secrets.") self._scripts_success = False self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if self._scripts_success else 'Attempted' def _step_cnpg_deploy(self) -> None: if not self._get_input_bool('init_cnpg_deploy.run_deploy', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True)): self.log("[SKIP] CnPG deploy disabled.") self.prole_cfg_data['Deployment']['STATUS'] = 'Skipped' return self.log("==> Deploy CloudNative-PG") etc_dir = self.project_root / "etc" ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' env = self._script_env_for_namespace(ns) rc = self._run_cmd(['bash', str(etc_dir / 'init_cloudnative_pg.sh'), 'deploy', 'latest'], env=env) if rc == 0: self._cnpg_success = True self.prole_cfg_data['Deployment']['STATUS'] = 'Deployed' else: self._cnpg_success = False self.prole_cfg_data['Deployment']['STATUS'] = 'Attempted' self.err(f"[ERROR] CnPG deploy failed (code {rc})") if self._get_input_bool('init_cnpg_deploy.force_rollout', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.force_rollout', False)): self.log("==> Force rollout") rc2 = self._run_cmd(['bash', str(etc_dir / 'init_cloudnative_pg.sh'), 'rollout'], env=env) if rc2 != 0: self.err(f"[ERROR] Rollout failed (code {rc2})") def _step_supabase(self) -> None: if not self._get_input_bool('supabase_config.run_deploy', False): self.log("[SKIP] Supabase deploy disabled.") self.prole_cfg_data['Supabase'] = {'STATUS': 'Skipped'} return self.log("==> Deploy Supabase") ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' env = self._script_env_for_namespace(ns) script_path = self.project_root / "supabase" / "deploy.sh" if not script_path.exists(): self.err(f"[ERROR] Supabase deploy script not found: {script_path}") self._supabase_success = False self.prole_cfg_data['Supabase'] = {'STATUS': 'Attempted'} return mode = (os.environ.get("SUPABASE_DEPLOY_MODE") or os.environ.get("SUPABASE_MODE") or "").strip() if not mode: # Match cluster environment to supabase deploy mode cluster_env = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', 'dev')) if cluster_env == 'dev': mode = "k3d" elif cluster_env in ('service', 'prod'): mode = "k8s" else: mode = "k3d" args = ["--mode", mode] cfg_path = None if self.cfg_path and self.cfg_path.exists(): cfg_path = self.cfg_path else: candidate = self.project_root / "conf" / "prole.cfg" if candidate.exists(): cfg_path = candidate if cfg_path: args.extend(["-c", str(cfg_path)]) if _parse_bool(os.environ.get("SUPABASE_USE_DEV_COMPOSE"), False): args.append("--with-dev-helpers") if _parse_bool(os.environ.get("SUPABASE_FOREGROUND"), False): args.append("--foreground") self.log(f"--> supabase/deploy.sh {' '.join(args)}") rc = self._run_cmd(['bash', str(script_path)] + args, env=env) if rc == 0: self._supabase_success = True self.prole_cfg_data['Supabase'] = {'STATUS': 'Deployed'} else: self._supabase_success = False self.prole_cfg_data['Supabase'] = {'STATUS': 'Attempted'} self.err(f"[ERROR] Supabase deploy failed (code {rc})") def _finalize_secrets(self) -> None: ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' if not ns or ns == 'default': ns = self._get_input('env_setup.NAMESPACE', '') or 'default' p1 = self._get_input('init_password.db_password', '') if p1.startswith('${'): self.log(f"[OK] Secrets already finalized for namespace {ns}. Skipping build-a-bao.") return self.log("==> OpenBao: finalize secrets") env = self._script_env_for_namespace(ns) rc = self._run_script("build-a-bao.sh", env=env) if rc == 0: self._secrets_finalized = True self.log("[OK] Secrets saved to OpenBao and prole.cfg updated.") else: self.err(f"[ERROR] build-a-bao.sh failed (code {rc})") def _prepare_opentofu_pipeline(self) -> None: env_key = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', '')) if env_key != 'service': return namespace = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default' k3s_server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip() k3s_token = (self._get_input('init_cluster.k3s_token', '') or '').strip() if k3s_token: k3s_token = self._resolve_secret_value(k3s_token) if k3s_server and not k3s_server.startswith('http'): k3s_server = f"https://{k3s_server}" if not k3s_server: self.err("[WARN] OpenTofu pipeline skipped: missing k3s server URL.") return if not k3s_token: self.err("[WARN] OpenTofu pipeline missing k3s token; writing empty token.") pipeline_dir = _sync_opentofu_pipeline(self.project_root, namespace, k3s_server, k3s_token) self.prole_cfg_data['Deployment']['OPENTOFU_PIPELINE_DIR'] = str(pipeline_dir) self.log(f"[OK] OpenTofu pipeline prepared at {pipeline_dir}") def _create_k3d_cluster(self, cluster_name: str, prole_data: Path) -> None: volume_args = _k3d_prole_data_volume_args(str(prole_data)) reg_args = [] if _local_registry_enabled('k3d'): info = self._ensure_local_registry_available() if info: _host_registry, cluster_registry = info reg_use = cluster_registry or '' if reg_use.endswith(".localhost:5000"): reg_use = reg_use.replace(".localhost:5000", ":5000") elif reg_use.endswith(".localhost"): reg_use = reg_use[:-len(".localhost")] if reg_use: reg_args = ['--registry-use', reg_use] cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2'] + volume_args + reg_args + ['--api-port', '0.0.0.0:6443'] rc = self._run_cmd(cmd) if rc == 0: return if platform.system() == 'Darwin': fallback = Path.home() / '.prole' / 'data' try: if prole_data.resolve() == fallback.resolve(): raise Exception("k3d cluster create failed.") except Exception: # If path resolution fails, still try the fallback pass self.err(f"[WARN] k3d create failed with {prole_data}; retrying with {fallback}...") rc = self._run_cmd(['k3d', 'cluster', 'create', cluster_name, '-a', '2'] + _k3d_prole_data_volume_args(str(fallback)) + reg_args + ['--api-port', '0.0.0.0:6443']) if rc == 0: # Persist fallback for the rest of the run self.inputs['env_setup.PROLE_DATA'] = str(fallback) return raise Exception(f"Failed to create k3d cluster {cluster_name} (code {rc})") def _perform_cluster_reset(self) -> None: if not self.reset_requested: return cluster_env = self._get_input('init_cluster.cluster_env', 'dev') env_key = _normalize_cluster_env(cluster_env) if env_key == 'dev': self.log("==> Cluster reset (k3d)") if not self.controller.check_docker_running(): if platform.system() == 'Darwin': self.log("Starting Docker...") self._run_cmd(['open', '-a', 'Docker']) for _ in range(30): time.sleep(2) if self.controller.check_docker_running(): break if not self.controller.check_docker_running(): raise Exception("Docker is not running.") cluster_name = "prole-dev-cluster" rc = self._run_cmd(['k3d', 'cluster', 'delete', cluster_name]) if rc != 0: self.err(f"[WARN] Failed to delete k3d cluster {cluster_name} (code {rc}); continuing.") prole_data = self._resolve_env_dir('PROLE_DATA', 'data') self._create_k3d_cluster(cluster_name, prole_data) elif env_key in ('service', 'prole-service-cluster', 'k3s'): self.log("==> Cluster reset (k3s namespace cleanup)") ns = self._get_input('env_setup.NAMESPACE', 'default') server = self._get_input('init_cluster.k3s_server_url', '') token = self._get_input('init_cluster.k3s_token', '') _reset_k3s_namespace(self.project_root, ns, server, token) self._cleanup_local_k3s_artifacts() self.reset_requested = False def _cleanup_local_k3s_artifacts(self) -> None: paths = [ self.project_root / "etc" / "secrets" / "k3s.kubeconfig", Path.home() / ".prole" / "secrets" / "k3s.kubeconfig", self.project_root / "prole-k3s.kubeconfig", ] for path in paths: try: if path.exists(): path.unlink() self.log(f"[RESET] Removed local kubeconfig {path}") except Exception as exc: self.err(f"[WARN] Failed to remove {path}: {exc}") def run(self) -> int: _configure_unbuffered_io() self.log(f"[CONFIG] Using {self.cfg_path}") try: self.inputs = {**self._default_inputs(), **self._load_inputs_from_cfg()} # Synchronize controller state self.controller.state.inputs = self.inputs self.controller.state.config_data = self.prole_cfg_data except Exception as e: self.err(f"[FATAL] {e}") return 2 self._write_cfg() self._perform_cluster_reset() milestones = [ DependenciesMilestone(), NetworkScanMilestone(), EnvSetupMilestone(), SecretManagementMilestone(), DatabaseCreationMilestone(), DockerBuildMilestone(), ClusterLifecycleMilestone(), InitializationScriptsMilestone(), DeploymentMilestone() ] try: self.controller.run_milestones(milestones, progress_callback=lambda msg, p: self.log(f"[{p*100:0.0f}%] {msg}")) # Sync back results self.prole_cfg_data = self.controller.state.config_data self.prole_cfg_data['Install']['STATUS'] = 'Finished' self._write_cfg() self.log("[DONE] Silent install completed.") return 0 except Exception as e: self.err(f"[FATAL] {e}") try: self.prole_cfg_data['Install']['STATUS'] = 'Failed' self._write_cfg() except Exception: pass return 2 def _run_silent_install_test(project_root: Path, cfg_path: Path) -> int: script = project_root / "tests" / "silent_install_test.sh" if not script.exists(): print(f"[ERROR] Silent install test script not found: {script}", file=sys.stderr) return 1 env = os.environ.copy() env.setdefault("SILENT_INSTALL_LOG", "true") res = subprocess.run(["bash", str(script), str(cfg_path)], env=env) return res.returncode def _attempt_k3s_repair(controller: ProleController, namespace: str, server: str, token: str, db_password: str) -> None: env = os.environ.copy() env["PROLE_HOME"] = str(controller.project_root) env["PROLE_SERVICE"] = str(controller.project_root) env["NAMESPACE"] = namespace env["PROLE_MODE"] = "k3s" if db_password: env["DB_PASSWORD"] = db_password env["OPENTOFU_ADMIN_PASSWORD"] = db_password if server and token and not env.get("KUBECONFIG"): kubeconfig_path = _write_k3s_kubeconfig(server, token) env["KUBECONFIG"] = str(kubeconfig_path) controller.run_script("init_openbao.sh", args=["-n", namespace, "update"], env=env) controller.run_script("init_opentofu.sh", args=["-n", namespace, "update"], env=env) def _reset_k3s_namespace(project_root: Path, namespace: str, server: str, token: str) -> None: script = project_root / "scripts" / "reset-ns.sh" if not script.exists(): print(f"[WARN] Namespace reset script not found: {script}", file=sys.stderr) return env = os.environ.copy() if server and token and not env.get("KUBECONFIG"): kubeconfig_path = _write_k3s_kubeconfig(server, token) env["KUBECONFIG"] = str(kubeconfig_path) subprocess.run(["bash", str(script), "-n", namespace], env=env) def _prepare_k3s_pipeline(controller: ProleController) -> int: project_root = controller.project_root cfg_path = project_root / "conf" / "prole.cfg" info = _detect_ansible_topology(project_root) k3s_server = (info.get('k3s_server_url') or '').strip() if info else '' k3s_token = (info.get('k3s_token') or '').strip() if info else '' if k3s_server and not k3s_server.startswith('http'): k3s_server = f"https://{k3s_server}" installer = ProleSilentInstaller(controller, str(cfg_path)) try: existing_inputs = installer._load_inputs_from_cfg() except Exception: existing_inputs = {} installer.inputs = {**installer._default_inputs(), **existing_inputs} # Override for k3s pipeline defaults installer.inputs['init_cluster.cluster_env'] = 'prole-service-cluster' installer.inputs['init_cluster.supabase_enabled'] = _bool_str(False) installer.inputs['init_cluster.kerberos_enabled'] = _bool_str(False) installer.inputs['init_cluster.at_rest_encryption_enabled'] = _bool_str(True) installer.inputs['kerberos_config.enabled'] = _bool_str(False) installer.inputs['kerberos_config.test_connection'] = _bool_str(False) if k3s_server and not installer.inputs.get('init_cluster.k3s_server_url'): installer.inputs['init_cluster.k3s_server_url'] = k3s_server if k3s_token and not installer.inputs.get('init_cluster.k3s_token'): installer.inputs['init_cluster.k3s_token'] = k3s_token installer.inputs['env_setup.PROLE_HOME'] = str(project_root) installer.inputs['env_setup.PROLE_CONF'] = str(project_root / "conf") installer.inputs['env_setup.PROLE_DATA'] = ( installer._resolve_env_value('PROLE_DATA', str(Path.home() / '.prole' / 'data')) or str(Path.home() / '.prole' / 'data') ) installer.inputs['env_setup.PROLE_LOGS'] = ( installer._resolve_env_value('PROLE_LOGS', str(Path.home() / '.prole' / 'logs')) or str(Path.home() / '.prole' / 'logs') ) installer.inputs['env_setup.PROLE_SERVICE'] = str(project_root / "etc") installer._write_cfg() namespace = (installer._get_input('init_password.db_namespace', '') or '').strip() if not namespace: namespace = (installer._get_input('env_setup.NAMESPACE', '') or '').strip() or 'default' db_password = installer._get_input('init_password.db_password', '').strip() attempt = 1 total_attempts = 0 max_attempts_env = os.environ.get("PROLE_SILENT_TEST_MAX_ATTEMPTS", "").strip() max_attempts = int(max_attempts_env) if max_attempts_env.isdigit() else 0 while True: total_attempts += 1 rc = _run_silent_install_test(project_root, cfg_path) if rc == 0: break cur_server = installer.inputs.get('init_cluster.k3s_server_url', k3s_server) cur_token = installer.inputs.get('init_cluster.k3s_token', k3s_token) if attempt == 1: _attempt_k3s_repair(controller, namespace, cur_server, cur_token, db_password) elif attempt == 2: _reset_k3s_namespace(project_root, namespace, cur_server, cur_token) else: attempt = 0 attempt += 1 if max_attempts and total_attempts >= max_attempts: return rc cur_server = installer.inputs.get('init_cluster.k3s_server_url', k3s_server) cur_token = installer.inputs.get('init_cluster.k3s_token', k3s_token) _sync_opentofu_pipeline(project_root, namespace, cur_server, cur_token) return 0