""" Core environment/config helpers shared by installer UI and actions. """ from __future__ import annotations import base64 import configparser import getpass import json import os import platform import re import shutil import subprocess import sys import tempfile import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from cryptography.hazmat.primitives.ciphers.aead import AESGCM from installer import config as inst_config # Get the project root directory (installer/ui -> installer -> repo root) PROJECT_ROOT = Path(__file__).resolve().parents[2] def get_resource_path(relative_path): """Get absolute path to resource, works for dev and for PyInstaller.""" try: # PyInstaller creates a temp folder and stores path in _MEIPASS base_path = Path(sys._MEIPASS) except AttributeError: # Running from source base_path = PROJECT_ROOT return base_path / relative_path # Standard Console Theme (Light background, Dark text) CONSOLE_BG = '#F5F5DC' # Cream/Light background similar to our images CONSOLE_FG = '#1d1d1f' # Dark text CONSOLE_INSERT = '#1d1d1f' CONSOLE_FONT = ('Menlo', 10) NAMESPACE_PREFIX = 'prole-' POSTGRES_DB_NAME_MAX_LEN = 63 DEFAULT_OLLAMA_PORT = '11434' # Default action behavior for unattended replays DEFAULT_ACTION_FLAGS = { 'dependencies.auto_install_missing': True, 'network_scan.run': True, 'init_password.generate_ssh_key': True, 'init_db_build.run_build': True, 'init_cluster.start_cluster': True, 'init_scripts.run_scripts': True, 'init_cnpg_deploy.run_deploy': True, 'init_cnpg_deploy.force_rollout': False, 'kerberos_config.test_connection': False, 'build.run_build': False, } # Secret handling (temporary encrypted values in prole.cfg) PROLE_SECRET_PREFIX = "${PROLE_SECRET:" PROLE_SECRET_SUFFIX = "}" OPENBAO_PREFIX = "${OPENBAO:" OPENBAO_SUFFIX = "}" PROLE_SECRET_VERSION = "v1" PROLE_SECRET_SERVICE = "prole-installer" PROLE_SECRET_KEY_FILE = Path.home() / ".prole" / "secrets" / "installer.key" # Map config keys to OpenBao paths (namespace injected at runtime) SECRET_KEY_SPECS = { ("Inputs", "init_password.db_password"): ("db", "password"), ("Inputs", "init_password.db_password_confirm"): ("db", "password"), ("Inputs", "kerberos_config.password"): ("kerberos", "password"), ("Global", "DB_PASSWORD"): ("db", "password"), ("Kerberos Authentication", "PASSWORD"): ("kerberos", "password"), ("Monitoring", "GRAFANA_ADMIN_PASSWORD"): ("monitoring", "grafana_admin_password"), } def _is_prole_secret(value: str | None) -> bool: return bool(value) and value.startswith(PROLE_SECRET_PREFIX) and value.endswith(PROLE_SECRET_SUFFIX) def _is_openbao_ref(value: str | None) -> bool: return bool(value) and value.startswith(OPENBAO_PREFIX) and value.endswith(OPENBAO_SUFFIX) def _encrypt_cfg_secret(plaintext: str | None) -> str: if not plaintext: return '' if _is_prole_secret(plaintext) or _is_openbao_ref(plaintext): return plaintext try: return _encrypt_prole_secret(plaintext) except Exception: return str(plaintext) def _get_secret_key_file() -> Path: return PROLE_SECRET_KEY_FILE def _get_keychain_key(service: str, account: str) -> bytes: try: res = subprocess.run( ["security", "find-generic-password", "-a", account, "-s", service, "-w"], capture_output=True, text=True ) if res.returncode == 0 and res.stdout.strip(): return base64.urlsafe_b64decode(res.stdout.strip().encode("utf-8")) except Exception: pass key_b64 = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8") try: subprocess.run( ["security", "add-generic-password", "-a", account, "-s", service, "-w", key_b64, "-U"], capture_output=True, text=True ) except Exception: pass return base64.urlsafe_b64decode(key_b64.encode("utf-8")) def _get_file_key(path: Path) -> bytes: path.parent.mkdir(parents=True, exist_ok=True) if path.exists(): raw = path.read_text().strip() try: return base64.urlsafe_b64decode(raw.encode("utf-8")) except Exception: pass key_b64 = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8") path.write_text(key_b64) try: os.chmod(path, 0o600) except Exception: pass return base64.urlsafe_b64decode(key_b64.encode("utf-8")) def _get_secret_key() -> bytes: system = platform.system() account = getpass.getuser() or "prole" if system == "Darwin": return _get_keychain_key(PROLE_SECRET_SERVICE, account) return _get_file_key(_get_secret_key_file()) def _encrypt_prole_secret(plaintext: str) -> str: if plaintext is None: return "" if _is_prole_secret(plaintext): return plaintext key = _get_secret_key() aesgcm = AESGCM(key) nonce = os.urandom(12) ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) nonce_b64 = base64.urlsafe_b64encode(nonce).decode("utf-8") ct_b64 = base64.urlsafe_b64encode(ciphertext).decode("utf-8") return f"{PROLE_SECRET_PREFIX}{PROLE_SECRET_VERSION}:{nonce_b64}:{ct_b64}{PROLE_SECRET_SUFFIX}" def _decrypt_prole_secret(value: str) -> str: if not _is_prole_secret(value): return value inner = value[len(PROLE_SECRET_PREFIX):-len(PROLE_SECRET_SUFFIX)] parts = inner.split(":") if len(parts) != 3 or parts[0] != PROLE_SECRET_VERSION: return value try: nonce = base64.urlsafe_b64decode(parts[1].encode("utf-8")) ciphertext = base64.urlsafe_b64decode(parts[2].encode("utf-8")) key = _get_secret_key() aesgcm = AESGCM(key) return aesgcm.decrypt(nonce, ciphertext, None).decode("utf-8") except Exception: return value def _normalize_k3s_token(value: str | None) -> str: token = (value or '').strip() if not token: return '' if _is_openbao_ref(token): return '' if _is_prole_secret(token): try: token = _decrypt_prole_secret(token) except Exception: return '' if _is_prole_secret(token): return '' return token def _looks_like_k8s_bearer_token(token: str | None) -> bool: tok = (token or '').strip() if not tok: return False # K3s node tokens include '::' and are not valid API bearer tokens. if '::' in tok: return False # JWT-style tokens contain two dots. if tok.count('.') >= 2: return True # Bootstrap tokens are usually id.secret with a dot separator. if tok.count('.') == 1: token_id, token_secret = tok.split('.', 1) if len(token_id) >= 6 and len(token_secret) >= 16: return True return False def _parse_ollama_host(value: str, default_port: str = DEFAULT_OLLAMA_PORT) -> tuple[str, str]: raw = (value or '').strip() if not raw: return '', '' if '://' not in raw: raw = f"http://{raw}" try: parsed = urllib.parse.urlparse(raw) except Exception: return '', '' host = parsed.hostname or '' port = str(parsed.port) if parsed.port else '' if not port and default_port: port = default_port return host, port def _format_ollama_host(host: str, port: str) -> str: raw_host = (host or '').strip() if not raw_host: return '' scheme = 'https' if raw_host.startswith('https://') else 'http' parsed_host, parsed_port = _parse_ollama_host(raw_host, default_port='') host_val = parsed_host or raw_host port_val = parsed_port or (port or '').strip() if port_val: return f"{scheme}://{host_val}:{port_val}" return f"{scheme}://{host_val}" def _find_kubeconfig_file(env: dict | None = None) -> str: env = env or os.environ candidates = [] kubeconfig_env = (env.get("KUBECONFIG") or "").strip() if kubeconfig_env: candidates.append(kubeconfig_env) prole_service = (env.get("PROLE_SERVICE") or "").strip() if prole_service: candidates.append(str(Path(prole_service) / "secrets" / "k3s.kubeconfig")) for key in ("PROLE_K3S_KUBECONFIG", "PROLE_KUBECONFIG"): val = (env.get(key) or "").strip() if val: candidates.append(val) prole_home = (env.get("PROLE_HOME") or "").strip() if prole_home: candidates.append(str(Path(prole_home) / "prole-k3s.kubeconfig")) candidates.append(str(Path(prole_home) / "secrets" / "k3s.kubeconfig")) candidates.append(str(Path(prole_home) / ".kube" / "config")) candidates.append(str(Path.home() / ".prole" / "secrets" / "k3s.kubeconfig")) candidates.append(str(PROJECT_ROOT / "etc" / "secrets" / "k3s.kubeconfig")) candidates.append(str(PROJECT_ROOT / "prole-k3s.kubeconfig")) candidates.append("/etc/rancher/k3s/prole-kubeconfig.yaml") candidates.append("/etc/rancher/k3s/k3s.yaml") for path in candidates: try: if path and Path(path).expanduser().exists(): return path except Exception: continue return "" def _k3d_prole_data_volume_args(prole_data: str | None) -> list[str]: path = (prole_data or '').strip() if not path: return [] try: p = Path(path).expanduser() except Exception: return [] try: p.mkdir(parents=True, exist_ok=True) except Exception: pass return ["--volume", f"{p}:/var/lib/rancher/k3s/storage@all"] def _ensure_ansible_vault_credentials(prompt_ui: bool = False, root=None) -> None: password_file = (os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') or '').strip() password = (os.environ.get('ANSIBLE_VAULT_PASSWORD') or '').strip() if password_file: try: if not Path(password_file).expanduser().is_file(): password_file = '' os.environ.pop('ANSIBLE_VAULT_PASSWORD_FILE', None) except Exception: password_file = '' os.environ.pop('ANSIBLE_VAULT_PASSWORD_FILE', None) if password or password_file: return for base in (Path.cwd(), PROJECT_ROOT): try: candidate = base / '.vault_pass' if candidate.is_file(): os.environ['ANSIBLE_VAULT_PASSWORD_FILE'] = str(candidate) print(f"[INFO] Using Ansible Vault password file: {candidate}") return except Exception: continue vault_password = '' if prompt_ui and root is not None: try: from tkinter import simpledialog vault_password = simpledialog.askstring( "Ansible Vault", "Enter Ansible Vault password:", show='*', parent=root ) or '' except Exception: vault_password = '' if not vault_password: try: vault_password = getpass.getpass("Ansible Vault password: ") except Exception: vault_password = '' if vault_password: os.environ['ANSIBLE_VAULT_PASSWORD'] = vault_password def _openbao_placeholder(namespace: str, leaf: str, key: str | None = None) -> str: ns = (namespace or "").strip() or "default" path = f"kv/prole/{ns}/{leaf}" if key: return f"{OPENBAO_PREFIX}{path}#{key}{OPENBAO_SUFFIX}" return f"{OPENBAO_PREFIX}{path}{OPENBAO_SUFFIX}" def _bool_str(val: bool) -> str: return 'true' if bool(val) else 'false' def _parse_bool(val, default=False) -> bool: if val is None: return default s = str(val).strip().lower() if s in ('1', 'true', 'yes', 'y', 'on'): return True if s in ('0', 'false', 'no', 'n', 'off'): return False return default def _local_registry_enabled(mode_hint: str | None = None) -> bool: raw = (os.environ.get("PROLE_ENABLE_LOCAL_REGISTRY") or os.environ.get("ENABLE_LOCAL_REGISTRY") or "").strip() if raw: return _parse_bool(raw, False) if mode_hint: mode = _deployment_mode_from_env(mode_hint) else: mode_hint = ( os.environ.get("PROLE_MODE") or os.environ.get("DEPLOYMENT_MODE") or os.environ.get("CLUSTER_ENV") or "" ) mode = _deployment_mode_from_env(mode_hint) return mode in ("k3d", "k3s") def _expand_path(val: str | None) -> str: if val is None: return '' return os.path.expandvars(os.path.expanduser(str(val))) _CFG_VAR_PATTERN = re.compile(r"\$(\w+)|\$\{(\w+)\}") def _expand_cfg_value(val: str | None, cfg_vars: dict[str, str] | None = None, env: dict[str, str] | None = None, max_depth: int = 5) -> str: if val is None: return '' raw = str(val) if _is_openbao_ref(raw) or _is_prole_secret(raw): return raw cfg_vars = cfg_vars or {} env = env or os.environ def repl(match): var = match.group(1) or match.group(2) if var in cfg_vars and cfg_vars[var] is not None: return str(cfg_vars[var]) if var in env and env[var] is not None: return str(env[var]) return match.group(0) out = raw for _ in range(max_depth): new = _CFG_VAR_PATTERN.sub(repl, out) if new == out: break out = new return out def _collect_cfg_vars(cfg: configparser.ConfigParser) -> dict[str, str]: cfg_vars: dict[str, str] = {} for section in cfg.sections(): for k, v in cfg.items(section): if k in cfg_vars: continue cfg_vars[k] = _expand_cfg_value(v, cfg_vars) return cfg_vars def _resolve_supabase_home(project_root: Path) -> Path | None: env_home = (os.environ.get('SUPABASE_HOME') or '').strip() if env_home: try: candidate = Path(_expand_path(env_home)) if candidate.is_dir(): return candidate except Exception: pass candidates = [ Path.home() / 'prole' / 'supabase', Path.home() / 'dev' / 'supabase', project_root / 'supabase', ] prole_home = (os.environ.get('PROLE_HOME') or '').strip() if prole_home: try: candidates.append(Path(_expand_path(prole_home)).parent / 'supabase') except Exception: pass for candidate in candidates: try: if candidate.is_dir(): return candidate except Exception: continue return None def _collect_images_from_files(paths: list[Path]) -> set[str]: images = set() for path in paths: if not path or not path.exists(): continue try: for line in path.read_text().splitlines(): m = re.match(r'^\s*image:\s*([^\s#]+)', line) if not m: continue img = m.group(1).strip().strip('"').strip("'") if not img: continue img = os.path.expandvars(img) if '${' in img or '}' in img or '$' in img: continue images.add(img) except Exception: continue return images def _clean_yaml_value(raw: str) -> str: if raw is None: return '' val = raw.strip() if '#' in val: val = val.split('#', 1)[0].strip() if len(val) >= 2 and ((val[0] == val[-1]) and val.startswith(("'", '"'))): val = val[1:-1] return val.strip() def _parse_yaml_scalar_values(path: Path, keys: set[str]) -> dict: data = {} if not path.exists(): return data try: for raw in path.read_text().splitlines(): line = raw.strip() if not line or line.startswith('#') or line == '---': continue if line.startswith('- '): continue if ':' not in line: continue key, val = line.split(':', 1) key = key.strip() if key not in keys: continue clean_val = _clean_yaml_value(val) if clean_val: data[key] = clean_val except Exception: pass return data def _parse_internal_a_records(path: Path) -> tuple[str, dict, dict]: domain = '' ip_map: dict[str, str] = {} fqdn_map: dict[str, str] = {} if not path.exists(): return domain, ip_map, fqdn_map try: lines = path.read_text().splitlines() except Exception: return domain, ip_map, fqdn_map in_block = False current: dict[str, str] = {} records = [] for raw in lines: if not in_block: s = raw.strip() if s.startswith('prole_domain:'): domain = _clean_yaml_value(s.split(':', 1)[1]) if s.startswith('prole_internal_a_records:'): in_block = True continue if raw and not raw.startswith((' ', '\t')): break s = raw.strip() if not s: continue if s.startswith('- '): if current: records.append(current) current = {} s = s[2:].strip() if ':' in s: key, val = s.split(':', 1) key = key.strip() if key in ('fqdn', 'ipv4'): current[key] = _clean_yaml_value(val) if current: records.append(current) for rec in records: fqdn = rec.get('fqdn') ip = rec.get('ipv4') if not fqdn or not ip: continue fqdn_map[fqdn] = ip ip_map[fqdn] = ip short = fqdn.split('.')[0] if short and short not in ip_map: ip_map[short] = ip return domain, ip_map, fqdn_map def _parse_ansible_inventory_hosts(path: Path) -> dict: groups: dict[str, list[str]] = {} if not path.exists(): return groups try: current_group = None for raw in path.read_text().splitlines(): line = raw.strip() if not line or line.startswith(('#', ';')): continue if line.startswith('[') and line.endswith(']'): current_group = line[1:-1].strip() groups.setdefault(current_group, []) continue if current_group is None: continue host = line.split()[0] if host and host not in groups[current_group]: groups[current_group].append(host) except Exception: pass return groups def _resolve_host_ip(host: str, domain: str, ip_map: dict) -> str: if not host: return '' if host in ip_map: return ip_map[host] if domain: if '.' not in host: fqdn = f"{host}.{domain}" if fqdn in ip_map: return ip_map[fqdn] else: short = host.split('.')[0] if short in ip_map: return ip_map[short] return '' def _normalize_cluster_env(env: str | None) -> str: if not env: return '' s = str(env).strip().lower() if s in ('dev', 'k3d', 'k3d-dev', 'k3d-prole-dev-cluster', 'prole-dev-cluster') or s.startswith('k3d-') or s.startswith('prole-dev-'): return 'dev' if s in ('service', 'k3s', 'k3s-service', 'prole-service-cluster') or s.startswith('prole-service-'): return 'service' if s in ('prod', 'production', 'k8s', 'prole-prod-cluster') or s.startswith('prole-prod-'): return 'prod' return s def _cluster_env_radio_value(env: str | None) -> str: key = _normalize_cluster_env(env) if key == 'dev': return 'dev' if key == 'service': return 'service' if key == 'prod': return 'prod' return (env or '') def _deployment_target_label(env: str | None) -> str: key = _normalize_cluster_env(env) if key == 'dev': return 'prole-dev-cluster' if key == 'service': return 'prole-service-cluster' if key == 'prod': return 'prole-prod-cluster' return (env or '') def _deployment_mode_from_env(env: str | None) -> str: key = _normalize_cluster_env(env) if key == 'dev': return 'k3d' if key == 'service': return 'k3s' if key == 'prod': return 'k8s' return '' def _extract_yaml_scalar_from_text(text: str, key: str) -> str: for raw in text.splitlines(): line = raw.strip() if not line or line.startswith('#'): continue if line.startswith(f"{key}:"): return _clean_yaml_value(line.split(':', 1)[1]) return '' def _extract_inline_vault_block(text: str, key: str) -> str: lines = text.splitlines() for idx, raw in enumerate(lines): stripped = raw.strip() if not stripped or stripped.startswith('#'): continue if stripped.startswith(f"{key}:") and "!vault" in stripped: base_indent = len(raw) - len(raw.lstrip()) block = [] i = idx + 1 while i < len(lines): line = lines[i] if not line.strip(): i += 1 continue indent = len(line) - len(line.lstrip()) if indent <= base_indent: break block.append(line.strip()) i += 1 if block and block[0].startswith("$ANSIBLE_VAULT"): return "\n".join(block) + "\n" return "" return "" def _try_read_ansible_vault_value(vault_path: Path, key: str) -> str: if not vault_path.exists(): return '' # First, try plain YAML parsing (in case the file is not encrypted). try: plain = _parse_yaml_scalar_values(vault_path, {key}).get(key, '') except Exception: plain = '' if plain and not plain.lower().startswith('!vault') and not plain.startswith('$ANSIBLE_VAULT'): return plain password_file = (os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') or '').strip() password = (os.environ.get('ANSIBLE_VAULT_PASSWORD') or '').strip() if password_file: try: if not Path(password_file).expanduser().exists(): password_file = '' except Exception: password_file = '' if not password_file and not password: for base in (Path.cwd(), PROJECT_ROOT): try: candidate = base / '.vault_pass' if candidate.is_file(): password_file = str(candidate) os.environ['ANSIBLE_VAULT_PASSWORD_FILE'] = password_file break except Exception: continue if not password_file and not password: return '' if shutil.which('ansible-vault') is None: return '' tmp_path = None if password_file: password_file = password_file elif password: try: tmp = tempfile.NamedTemporaryFile(delete=False) tmp.write(password.encode('utf-8')) tmp.flush() tmp.close() tmp_path = tmp.name password_file = tmp_path except Exception: tmp_path = None return '' def _vault_view(path: str) -> str: res = subprocess.run( ['ansible-vault', 'view', path] + (['--vault-password-file', password_file] if password_file else []), capture_output=True, text=True, timeout=5, env=os.environ.copy(), stdin=subprocess.DEVNULL ) if res.returncode != 0: return '' return res.stdout or '' try: output = _vault_view(str(vault_path)) if output: return _extract_yaml_scalar_from_text(output, key) # Inline vault: extract the block and decrypt separately. try: raw_text = vault_path.read_text() except Exception: raw_text = '' inline_block = _extract_inline_vault_block(raw_text, key) if not inline_block: return '' tmp_inline = tempfile.NamedTemporaryFile(delete=False) tmp_inline.write(inline_block.encode('utf-8')) tmp_inline.flush() tmp_inline.close() output = _vault_view(tmp_inline.name) try: os.unlink(tmp_inline.name) except Exception: pass return output.strip() except Exception: return '' finally: if tmp_path: try: os.unlink(tmp_path) except Exception: pass def _detect_ansible_k3s_settings(inventory_path: Path, groups: dict, domain: str, ip_map: dict) -> dict: k3s_hosts = groups.get('k3s_hosts') or [] server_url = '' server_host = '' for host in k3s_hosts: host_vars_path = inventory_path / 'host_vars' / f'{host}.yml' vals = _parse_yaml_scalar_values( host_vars_path, {'k3s_server_url', 'k3s_cluster_init', 'k3s_role'} ) if not server_url and vals.get('k3s_server_url'): server_url = vals['k3s_server_url'] if not server_host: if _parse_bool(vals.get('k3s_cluster_init'), False) or vals.get('k3s_role') == 'server': server_host = host if not server_url and server_host: host_for_url = server_host if domain and '.' not in host_for_url: host_for_url = f"{host_for_url}.{domain}" server_url = f"https://{host_for_url}:6443" vault_path = inventory_path / 'group_vars' / 'all' / 'vault_k3s.yml' token = _try_read_ansible_vault_value(vault_path, 'vault_k3s_token') return { 'server_url': server_url, 'server_host': server_host, 'token': token, 'vault_path': str(vault_path) if vault_path.exists() else '', } def _detect_ansible_topology(project_root: Path) -> dict: prole_home = (os.environ.get('PROLE_HOME') or '').strip() base_candidates = [] if prole_home: base_candidates.append(Path(_expand_path(prole_home))) base_candidates.append(project_root) infra_path = None for base in base_candidates: try: candidate = base / 'infrastructure' if candidate.is_dir(): infra_path = candidate break except Exception: continue if infra_path is None: return {} inventory_path = infra_path / 'inventory' if not inventory_path.is_dir(): return {} groups = _parse_ansible_inventory_hosts(inventory_path / 'hosts.ini') vars_vals = _parse_yaml_scalar_values( inventory_path / 'group_vars' / 'all' / 'vars.yml', {'ad_dc_ip', 'prole_domain', 'kerberos_realm', 'krb5_realm', 'kerberos_kdc', 'krb5_kdc', 'kerberos_kdc_ip'} ) domain = vars_vals.get('prole_domain', '') ad_dc_ip = vars_vals.get('ad_dc_ip', '') explicit_realm = vars_vals.get('kerberos_realm') or vars_vals.get('krb5_realm') or '' explicit_kdc = vars_vals.get('kerberos_kdc') or vars_vals.get('krb5_kdc') or vars_vals.get('kerberos_kdc_ip') or '' if explicit_kdc and not ad_dc_ip: ad_dc_ip = explicit_kdc dns_domain, ip_map, fqdn_records = _parse_internal_a_records(inventory_path / 'group_vars' / 'all' / 'dns.yml') if not domain and dns_domain: domain = dns_domain ad_vars = _parse_yaml_scalar_values( inventory_path / 'group_vars' / 'ad_dc' / 'vars.yml', {'samba_dns_server'} ) samba_dns_server = ad_vars.get('samba_dns_server', '') ad_dc_host = '' ad_dc_hosts = groups.get('ad_dc') or [] if ad_dc_hosts: ad_dc_host = ad_dc_hosts[0] if samba_dns_server: ad_dc_host = ad_dc_host or samba_dns_server if not ad_dc_ip: ad_dc_ip = _resolve_host_ip(samba_dns_server, domain, ip_map) if not ad_dc_ip and ad_dc_host: ad_dc_ip = _resolve_host_ip(ad_dc_host, domain, ip_map) kdc_ip = explicit_kdc or ad_dc_ip or '' realm = explicit_realm or (domain.upper() if domain else '') all_hosts = set() for hosts in groups.values(): all_hosts.update(hosts) if ad_dc_host: all_hosts.add(ad_dc_host) host_ip_map = {} unmapped_hosts = [] for host in sorted(all_hosts): ip = _resolve_host_ip(host, domain, ip_map) if ip: host_ip_map[host] = ip else: unmapped_hosts.append(host) k3s_info = _detect_ansible_k3s_settings(inventory_path, groups, domain, ip_map) k3s_server_url = k3s_info.get('server_url') or '' k3s_server_host = k3s_info.get('server_host') or '' k3s_token = k3s_info.get('token') or '' k3s_vault_path = k3s_info.get('vault_path') or '' topology = { 'domain': domain, 'realm': realm, 'internal_records': fqdn_records, 'ad_dc': { 'host': ad_dc_host, 'ip': ad_dc_ip, }, 'k3s': { 'server_url': k3s_server_url, 'server_host': k3s_server_host, 'token_present': bool(k3s_token), }, 'groups': groups, 'hosts': host_ip_map, 'unmapped_hosts': unmapped_hosts, } try: topology_json = json.dumps(topology, separators=(',', ':')) except Exception: topology_json = '' return { 'infrastructure_path': str(infra_path), 'inventory_path': str(inventory_path), 'domain': domain, 'realm': realm, 'ad_dc_ip': ad_dc_ip, 'ad_dc_host': ad_dc_host, 'kdc_ip': kdc_ip, 'k3s_server_url': k3s_server_url, 'k3s_server_host': k3s_server_host, 'k3s_token': k3s_token, 'k3s_vault_path': k3s_vault_path, 'groups': groups, 'hosts': host_ip_map, 'unmapped_hosts': unmapped_hosts, 'topology': topology, 'topology_json': topology_json, } def _format_ansible_topology_summary(info: dict) -> str: if not info: return '' lines = [] inv = info.get('inventory_path') or '' if inv: lines.append(f"Ansible inventory detected at {inv}.") domain = info.get('domain') or '' realm = info.get('realm') or '' if domain or realm: if domain and realm: lines.append(f"Domain: {domain} (Realm: {realm})") elif domain: lines.append(f"Domain: {domain}") else: lines.append(f"Realm: {realm}") ad_dc_host = info.get('ad_dc_host') or '' ad_dc_ip = info.get('ad_dc_ip') or '' if ad_dc_host or ad_dc_ip: if ad_dc_host and ad_dc_ip: lines.append(f"AD DC: {ad_dc_host} -> {ad_dc_ip}") elif ad_dc_ip: lines.append(f"AD DC IP: {ad_dc_ip}") else: lines.append(f"AD DC Host: {ad_dc_host}") groups = info.get('groups') or {} if groups: group_bits = [] for name in sorted(groups.keys()): group_bits.append(f"{name}({len(groups[name])})") lines.append("Groups: " + ", ".join(group_bits)) hosts = info.get('hosts') or {} if hosts: host_items = sorted(hosts.items()) preview = host_items[:8] host_str = ", ".join([f"{h}={ip}" for h, ip in preview]) if len(host_items) > len(preview): host_str += f", +{len(host_items) - len(preview)} more" lines.append("Hosts: " + host_str) unmapped = info.get('unmapped_hosts') or [] if unmapped: lines.append(f"Hosts without IPs: {len(unmapped)}") return "\n".join(lines) def _default_opentofu_pipeline_url() -> str: url = (os.environ.get('PROLE_OPENTOFU_URL') or os.environ.get('OPENTOFU_URL') or '').strip() return url or 'http://127.0.0.1:8080' def _push_docker_image(image_tag: str, log_fn=None) -> bool: """Push Docker image with skopeo fallback for insecure registries.""" def _log(msg): if log_fn: try: log_fn(msg) except: print(msg, end='') else: print(msg, end='', flush=True) _log(f"Pushing image {image_tag} ...\n") # 1. Try standard docker push res = subprocess.run(['docker', 'push', image_tag], capture_output=True, text=True) if res.returncode == 0: _log(f"[OK] Pushed {image_tag}\n") return True _log(f"[WARN] Docker push failed: {res.stderr.strip() if res.stderr else 'unknown error'}\n") # 2. Try skopeo fallback for insecure registry skopeo = shutil.which('skopeo') if skopeo: _log("Retrying with skopeo (insecure registry) ...\n") # skopeo copy --dest-tls-verify=false docker-daemon:TAG docker://TAG cmd = [skopeo, 'copy', '--dest-tls-verify=false', f"docker-daemon:{image_tag}", f"docker://{image_tag}"] res2 = subprocess.run(cmd, capture_output=True, text=True) if res2.returncode == 0: _log(f"[OK] Pushed {image_tag} using skopeo\n") return True _log(f"[ERROR] Skopeo push failed: {res2.stderr.strip() if res2.stderr else 'unknown error'}\n") else: _log("[ERROR] skopeo not found; cannot retry push.\n") return False def _http_ping_registry(host: str, port: int) -> bool: try: import http.client conn = http.client.HTTPConnection(host, port, timeout=3) conn.request('GET', '/v2/') resp = conn.getresponse() # Docker registry typically returns 200 or 401 for /v2/ return resp.status in (200, 401) except Exception: return False def _default_k3s_kubeconfig_path() -> Path: prole_service = (os.environ.get("PROLE_SERVICE") or "").strip() if prole_service: return Path(prole_service).expanduser() / "secrets" / "k3s.kubeconfig" prole_home = (os.environ.get("PROLE_HOME") or "").strip() if prole_home: return Path(prole_home).expanduser() / "prole-k3s.kubeconfig" return PROJECT_ROOT / "prole-k3s.kubeconfig" def _write_k3s_kubeconfig(server_url: str, token: str) -> Path: if not server_url: raise ValueError("K3s server URL is required.") if not token: raise ValueError("K3s token is required.") if not server_url.startswith('http'): server_url = f"https://{server_url}" cfg = ( "apiVersion: v1\n" "kind: Config\n" "clusters:\n" "- cluster:\n" f" server: {server_url}\n" " insecure-skip-tls-verify: true\n" " name: prole-k3s\n" "contexts:\n" "- context:\n" " cluster: prole-k3s\n" " user: prole-k3s\n" " name: prole-k3s\n" "current-context: prole-k3s\n" "users:\n" "- name: prole-k3s\n" " user:\n" f" token: {token}\n" ) path = _default_k3s_kubeconfig_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(cfg, encoding="utf-8") os.chmod(path, 0o600) return path def _sync_opentofu_pipeline(project_root: Path, namespace: str, k3s_server_url: str, k3s_token: str) -> Path: pipeline_dir = project_root / 'deploy' / 'opentofu' / 'k3s' manifest_root = pipeline_dir / 'manifests' manifest_root.mkdir(parents=True, exist_ok=True) sources = ( project_root / 'k8s' / 'prole', project_root / 'k8s' / 'openbao', project_root / 'k8s' / 'opentofu', ) for src in sources: if not src.exists(): continue dst = manifest_root / src.name dst.mkdir(parents=True, exist_ok=True) for path in src.glob('*.yaml'): shutil.copy2(path, dst / path.name) tfvars = [ f'k3s_server_url = "{k3s_server_url}"', f'k3s_token = "{k3s_token}"', f'namespace = "{namespace}"', '' ] (pipeline_dir / 'opentofu.auto.tfvars').write_text("\n".join(tfvars)) return pipeline_dir def _render_prole_cfg(inputs: dict, globals_to_save: dict, sections: dict, generated_at: str | None = None) -> str: inputs = dict(inputs or {}) globals_to_save = dict(globals_to_save or {}) sections = {k: dict(v or {}) for k, v in (sections or {}).items()} def first_non_empty(*vals: str) -> str: for val in vals: if val: return val return '' def get_input(key: str) -> str: return str(inputs.get(key, '') or '').strip() def get_section(section: str, key: str) -> str: return str((sections.get(section, {}) or {}).get(key, '') or '').strip() base_home = first_non_empty( str(globals_to_save.get('PROLE_HOME', '')).strip(), get_section('System Environment', 'PROLE_HOME'), get_input('env_setup.PROLE_HOME'), ) base_data = first_non_empty( str(globals_to_save.get('PROLE_DATA', '')).strip(), get_section('System Environment', 'PROLE_DATA'), get_input('env_setup.PROLE_DATA'), ) base_logs = first_non_empty( str(globals_to_save.get('PROLE_LOGS', '')).strip(), get_section('System Environment', 'PROLE_LOGS'), get_input('env_setup.PROLE_LOGS'), ) base_conf = first_non_empty( str(globals_to_save.get('PROLE_CONF', '')).strip(), get_section('System Environment', 'PROLE_CONF'), get_input('env_setup.PROLE_CONF'), ) if not base_conf and base_home: base_conf = f"{base_home}/conf" base_service = first_non_empty( str(globals_to_save.get('PROLE_SERVICE', '')).strip(), get_section('System Environment', 'PROLE_SERVICE'), get_input('env_setup.PROLE_SERVICE'), ) if not base_service and base_home: base_service = f"{base_home}/etc" base_namespace = first_non_empty( str(globals_to_save.get('NAMESPACE', '')).strip(), get_input('env_setup.NAMESPACE'), get_section('Database Creation', 'NAMESPACE'), ) base_service_namespace = first_non_empty( str(globals_to_save.get('SERVICE_NAMESPACE', '')).strip(), get_section('Global', 'SERVICE_NAMESPACE'), ) or base_namespace if base_service_namespace == '${SERVICE_NAMESPACE}': base_service_namespace = 'default' def derive_value(current: str, derived: str, placeholder: str) -> str: cur = str(current or '').strip() if not derived: return cur if not cur or cur == derived: return placeholder return cur # User-editable values surfaced at the top user_section = {} if base_home: user_section['PROLE_HOME'] = base_home if base_conf: user_section['PROLE_CONF'] = derive_value(base_conf, base_home + '/conf' if base_home else '', '${PROLE_HOME}/conf') if base_service: user_section['PROLE_SERVICE'] = derive_value(base_service, base_home + '/etc' if base_home else '', '${PROLE_HOME}/etc') if base_data: user_section['PROLE_DATA'] = base_data if base_logs: user_section['PROLE_LOGS'] = base_logs if base_namespace: user_section['NAMESPACE'] = base_namespace if base_service_namespace: user_section['SERVICE_NAMESPACE'] = base_service_namespace # Derived values for repeated touch-points if base_home: inputs['disk_selection.local_path'] = derive_value( inputs.get('disk_selection.local_path', ''), f"{base_home}/prole-tools-app/dist", '${PROLE_HOME}/prole-tools-app/dist' ) if base_namespace: inputs['env_setup.NAMESPACE'] = derive_value( inputs.get('env_setup.NAMESPACE', ''), base_namespace, '${NAMESPACE}' ) inputs['init_password.db_namespace'] = derive_value( inputs.get('init_password.db_namespace', ''), base_namespace, '${NAMESPACE}' ) if base_home: inputs['env_setup.PROLE_HOME'] = derive_value( inputs.get('env_setup.PROLE_HOME', ''), base_home, '${PROLE_HOME}' ) if base_conf: inputs['env_setup.PROLE_CONF'] = derive_value( inputs.get('env_setup.PROLE_CONF', ''), base_conf, '${PROLE_CONF}' ) if base_data: inputs['env_setup.PROLE_DATA'] = derive_value( inputs.get('env_setup.PROLE_DATA', ''), base_data, '${PROLE_DATA}' ) if base_logs: inputs['env_setup.PROLE_LOGS'] = derive_value( inputs.get('env_setup.PROLE_LOGS', ''), base_logs, '${PROLE_LOGS}' ) if base_service: inputs['env_setup.PROLE_SERVICE'] = derive_value( inputs.get('env_setup.PROLE_SERVICE', ''), base_service, '${PROLE_SERVICE}' ) globals_to_save['PROLE_HOME'] = derive_value( globals_to_save.get('PROLE_HOME', ''), base_home, '${PROLE_HOME}' ) if base_namespace: globals_to_save['NAMESPACE'] = derive_value( globals_to_save.get('NAMESPACE', ''), base_namespace, '${NAMESPACE}' ) if base_service_namespace: globals_to_save['SERVICE_NAMESPACE'] = base_service_namespace sys_env = sections.get('System Environment', {}) if base_home: sys_env['PROLE_HOME'] = derive_value(sys_env.get('PROLE_HOME', ''), base_home, '${PROLE_HOME}') if base_conf: sys_env['PROLE_CONF'] = derive_value(sys_env.get('PROLE_CONF', ''), base_conf, '${PROLE_CONF}') if base_data: sys_env['PROLE_DATA'] = derive_value(sys_env.get('PROLE_DATA', ''), base_data, '${PROLE_DATA}') if base_logs: sys_env['PROLE_LOGS'] = derive_value(sys_env.get('PROLE_LOGS', ''), base_logs, '${PROLE_LOGS}') if base_service: sys_env['PROLE_SERVICE'] = derive_value(sys_env.get('PROLE_SERVICE', ''), base_service, '${PROLE_SERVICE}') if sys_env: sections['System Environment'] = sys_env net = sections.get('Network', {}) if base_home: net['ANSIBLE_INFRASTRUCTURE'] = derive_value( net.get('ANSIBLE_INFRASTRUCTURE', ''), f"{base_home}/infrastructure", '${PROLE_HOME}/infrastructure' ) net['ANSIBLE_INVENTORY'] = derive_value( net.get('ANSIBLE_INVENTORY', ''), f"{base_home}/infrastructure/inventory", '${PROLE_HOME}/infrastructure/inventory' ) if net: sections['Network'] = net db_create = sections.get('Database Creation', {}) if base_namespace: db_create['NAMESPACE'] = derive_value( db_create.get('NAMESPACE', ''), base_namespace, '${NAMESPACE}' ) if db_create: sections['Database Creation'] = db_create prod = sections.get('Prod Cluster (k8s)', {}) if base_data: prod['ARTIFACTS_DIR'] = derive_value( prod.get('ARTIFACTS_DIR', ''), f"{base_data}/staging", '${PROLE_DATA}/staging' ) if prod: sections['Prod Cluster (k8s)'] = prod # Normalize port-forward namespaces back to placeholders if they match base namespace if base_namespace: pf = sections.get('Port Forwards', {}) if pf: ns_pat = re.compile(r"(namespace=)" + re.escape(base_namespace) + r"(?=;|$)") for k, v in pf.items(): if not isinstance(v, str): continue if 'namespace=' in v: pf[k] = ns_pat.sub(r"\1${NAMESPACE}", v) sections['Port Forwards'] = pf content = [] content.append('; Prole Master Configuration File') content.append('; Generated by install.py on ' + (generated_at or time.strftime('%Y-%m-%d %H:%M:%S'))) content.append('; This file is used as input for Ansible deployment and k8s cluster creation.') content.append('') allowed_namespace_keys = {'NAMESPACE', 'SERVICE_NAMESPACE'} def is_namespace_key(key: str) -> bool: return 'namespace' in key.lower() def emit_kv(data: dict, allow_namespace: bool = False) -> None: for k in sorted(data.keys()): if is_namespace_key(k) and (not allow_namespace or k not in allowed_namespace_keys): continue content.append(f'{k} = {data[k]}') # User overrides section content.append('[User]') content.append('; User-editable values; derived values below reference these by default.') if not user_section: content.append('; No user values captured yet for this section.') else: emit_kv(user_section, allow_namespace=True) content.append('') # Inputs section (replayable UI inputs) content.append('[Inputs]') content.append('; Screen-scoped inputs used for unattended replays (-S)') if not inputs: content.append('; No input values captured yet for this section.') else: emit_kv(inputs, allow_namespace=False) content.append('') # Global Section content.append('[Global]') content.append('; Variables used by name in more than one place or assumed global scope') emit_kv(globals_to_save, allow_namespace=True) content.append('') sections_order = [ 'Welcome', 'Dependencies', 'Network', 'Port Forwards', 'System Environment', 'Monitoring', 'Kerberos Authentication', 'Ollama', 'Optional Features', 'Database Creation', 'Initialize Cluster', 'Dev Cluster (k3d)', 'Service Cluster (k3s)', 'Prod Cluster (k8s)', 'Docker Build', 'Initialization Scripts', 'Deployment', 'Install' ] for section in sections_order: data = sections.get(section, {}) content.append(f'[{section}]') if not data: content.append('; No configuration values captured yet for this section.') else: emit_kv(data, allow_namespace=False) content.append('') return '\n'.join(content) def _pf_extract_id(mapping_str: str) -> str: if not mapping_str: return '' for part in str(mapping_str).split(';'): part = part.strip() if part.startswith('id='): return part[3:].strip() return '' def _pf_mapping_str( mapping_id: str, namespace: str, target: str, host_port: str | int, service_port: str | int, address: str = "0.0.0.0", protocol: str = "TCP", description: str = "", ) -> str: return ( f"id={mapping_id};" f"namespace={namespace};" f"target={target};" f"address={address};" f"hostPort={host_port};" f"servicePort={service_port};" f"protocol={protocol};" f"description={description}" ) def _pf_upsert_mapping(pf_section: dict, prefix: str, mapping_str: str) -> bool: if pf_section is None: return False mapping_id = _pf_extract_id(mapping_str) if not mapping_id: return False updated = False for k, v in list(pf_section.items()): if not k.startswith(prefix): continue if v == mapping_str: return False if f"id={mapping_id};" in v or v.strip() == f"id={mapping_id}": pf_section[k] = mapping_str updated = True if updated: return True existing_indices = [] for k in pf_section.keys(): if k.startswith(prefix): try: existing_indices.append(int(k[len(prefix):])) except ValueError: pass next_idx = max(existing_indices, default=0) + 1 pf_section[f"{prefix}{next_idx}"] = mapping_str return True def _build_required_port_forwards( mode: str, service_ns: str, argocd_ns: str, db_ns: str, db_host_port: str, supabase_enabled: bool, supabase_namespace: str, ) -> list[str]: service_ns = (service_ns or '').strip() or 'default' argocd_ns = (argocd_ns or '').strip() or 'argocd' db_ns = (db_ns or '').strip() or 'default' supabase_namespace = (supabase_namespace or '').strip() or 'supabase' db_host_port = (db_host_port or '5432').strip() addr_all = "0.0.0.0" addr_local = "127.0.0.1" openbao_addr = addr_local if mode == "k3d" else addr_all mappings = [ _pf_mapping_str("argocd", argocd_ns, "svc/argocd-server", "8081", "80", addr_all, "TCP", "ArgoCD"), _pf_mapping_str("garage", service_ns, "svc/garage", "3900", "3900", addr_all, "TCP", "Garage S3"), _pf_mapping_str("openbao", service_ns, "svc/openbao", "8200", "8200", openbao_addr, "TCP", "OpenBao"), _pf_mapping_str("opentofu", service_ns, "svc/opentofu", "8080", "8080", addr_all, "TCP", "OpenTofu"), _pf_mapping_str("dashboard", "kubernetes-dashboard", "svc/kubernetes-dashboard-kong-proxy", "8443", "443", addr_local, "TCP", "Kubernetes Dashboard"), _pf_mapping_str("postgres", db_ns, "svc/prole-db-rw", db_host_port, "5432", addr_all, "TCP", "PostgreSQL (primary)"), _pf_mapping_str("prometheus", "monitoring", "svc/kps-kube-prometheus-stack-prometheus", "9090", "9090", addr_local, "TCP", "Prometheus UI"), _pf_mapping_str("grafana", "monitoring", "svc/kps-grafana", "3000", "80", addr_all, "TCP", "Grafana UI"), ] if supabase_enabled: used_ports = {part.split('hostPort=', 1)[1].split(';', 1)[0] for part in mappings if 'hostPort=' in part} supabase_port = "8080" if supabase_port in used_ports: supabase_port = "8088" # Core Supabase services (minimal exposure for client + studio) mappings.append( _pf_mapping_str("supabase-kong", supabase_namespace, "svc/kong", "8000", "8000", addr_all, "TCP", "Supabase API (Kong)") ) mappings.append( _pf_mapping_str("supabase-studio", supabase_namespace, "svc/studio", supabase_port, "3000", addr_all, "TCP", "Supabase Studio") ) mappings.append( _pf_mapping_str("supabase-auth", supabase_namespace, "svc/auth", "9999", "9999", addr_all, "TCP", "Supabase Auth (GoTrue)") ) mappings.append( _pf_mapping_str("supabase-rest", supabase_namespace, "svc/rest", "3001", "3000", addr_all, "TCP", "Supabase REST (PostgREST)") ) mappings.append( _pf_mapping_str("supabase-realtime", supabase_namespace, "svc/realtime", "4000", "4000", addr_all, "TCP", "Supabase Realtime") ) return mappings def is_apple_silicon(): """Check if running on Apple Silicon (ARM64).""" return inst_config.is_apple_silicon() def get_docker_build_platform_args(target_env: str | None = None): """Get Docker build platform arguments for the target environment (from config).""" return inst_config.get_docker_build_platform_args(target_env) __all__ = [name for name in globals() if not name.startswith('__')]