""" Shared configuration and utility functions for the Prole installer (root-level). This mirrors `prole.knoe.config` but is located under the root `installer/` package per the refactor request. UI code should import from `knoe.config`. """ from __future__ import annotations import argparse import base64 import curses import getpass import json import os import platform import re import shlex import shutil import subprocess import sys import urllib.request from pathlib import Path from typing import Optional, Tuple from cryptography.hazmat.primitives.ciphers.aead import AESGCM def resolve_prole_home(env: dict[str, str] | None = None) -> Path: """Resolve `PROLE_HOME` from `env`/process environment. Falls back to `$HOME/.prole` when `PROLE_HOME` is not set. NOTE: This is duplicated here (and in `knoe.core.env`) to avoid circular imports between the two modules. """ env_map = env or os.environ raw = (env_map.get("PROLE_HOME") or "").strip() if raw: expanded = os.path.expanduser(os.path.expandvars(raw)) return Path(expanded) return Path.home() / ".prole" # 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" # 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 _get_secret_key_file() -> Path: return resolve_prole_home() / "secrets" / "knoe.key" 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) plaintext = aesgcm.decrypt(nonce, ciphertext, None) return plaintext.decode("utf-8") except Exception: return value 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 _resolve_openbao_ref(value: str) -> str: if not _is_openbao_ref(value): return value inner = value[len(OPENBAO_PREFIX) : -len(OPENBAO_SUFFIX)] if ":" not in inner: return value path, key = inner.split(":", 1) 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: 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(value: str) -> str: if _is_prole_secret(value): return _decrypt_prole_secret(value) if _is_openbao_ref(value): return _resolve_openbao_ref(value) return value # .properties loader (simple key=value, # comments) def _load_properties(path: Path) -> dict: props: dict[str, str] = {} try: text = path.read_text(encoding="utf-8") except Exception: return props for line in text.splitlines(): s = line.strip() if not s or s.startswith("#"): continue if "=" in s: k, v = s.split("=", 1) k = k.strip() v = v.strip() if k: props[k] = v return props # Repository root: this file lives at /installer/config.py → parent is repo PROJECT_ROOT = Path(__file__).resolve().parents[1] PROLE_APP_DIR = PROJECT_ROOT / "prole-app" PROLE_PROPS_PATH = PROLE_APP_DIR / "prole.properties" import logging def setup_logging(verbose=False, debug=False): """Set up logging for the installer. Levels: default – WARNING on console (quiet) verbose – INFO on console debug – DEBUG to a datestamped file *and* INFO on console """ if debug: console_level = logging.INFO file_level = logging.DEBUG elif verbose: console_level = logging.INFO file_level = None else: console_level = logging.WARNING file_level = None console_format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" debug_format = "%(asctime)s.%(msecs)03d [%(levelname)-5s] %(name)s (%(filename)s:%(lineno)d) %(funcName)s: %(message)s" console_handler = logging.StreamHandler() console_handler.setLevel(console_level) console_handler.setFormatter(logging.Formatter(console_format, datefmt="%H:%M:%S")) handlers: list[logging.Handler] = [console_handler] if debug and file_level is not None: log_dir = PROJECT_ROOT / "logs" log_dir.mkdir(parents=True, exist_ok=True) from datetime import datetime ts = datetime.now().strftime("%Y%m%d-%H%M%S") debug_log = log_dir / f"install-debug-{ts}.log" file_handler = logging.FileHandler(debug_log) file_handler.setLevel(file_level) file_handler.setFormatter( logging.Formatter(debug_format, datefmt="%Y-%m-%d %H:%M:%S") ) handlers.append(file_handler) print(f"Debug logging enabled to {debug_log}") logging.basicConfig( level=logging.DEBUG if debug else console_level, handlers=handlers, force=True ) _PROLE_PROPS_CACHE: Optional[dict] = None def get_properties() -> dict: """Load and cache prole.properties from the repo (installer context). Order: - Repo default at prole-app/prole.properties - Optional env override: PROLE_PROPERTIES points to a file """ global _PROLE_PROPS_CACHE if _PROLE_PROPS_CACHE is None: props: dict[str, str] = {} # repo default if PROLE_PROPS_PATH.exists(): props.update(_load_properties(PROLE_PROPS_PATH)) # env override (absolute path) env_path = os.environ.get("PROLE_PROPERTIES") if env_path: p = Path(env_path) if p.exists(): props.update(_load_properties(p)) _PROLE_PROPS_CACHE = props return dict(_PROLE_PROPS_CACHE) def get_config_value(key: str, default: Optional[str] = None) -> Optional[str]: return get_properties().get(key, default) def get_ui_icon_image_path() -> Path: """Return absolute path to the UI icon image for the installer. Priority (new → legacy): - `icon` key in prole.properties (requested) - legacy `ui.icon` Defaults to img/proleIcon.png under repo root if not set or missing. """ # Prefer new key `icon`, fall back to old `ui.icon` rel = get_config_value("icon") or get_config_value("ui.icon") or "img/proleIcon.png" p = (PROJECT_ROOT / rel).resolve() if p.exists(): return p # fallback return (PROJECT_ROOT / "img/proleIcon.png").resolve() def get_ui_background_image_path() -> Path: """Return absolute path to the UI background image. Priority (new → legacy): - `background` key in prole.properties (requested) - legacy `ui.background` Defaults to img/proleLogoSepia.png under repo root if not set or missing. """ rel = ( get_config_value("background") or get_config_value("ui.background") or "img/proleLogoSepia.png" ) p = (PROJECT_ROOT / rel).resolve() if p.exists(): return p # fallback return (PROJECT_ROOT / "img/proleLogoSepia.png").resolve() def is_apple_silicon() -> bool: return platform.machine() == "arm64" and platform.system() == "Darwin" def get_docker_build_platform_args(target_env: Optional[str] = None) -> list[str]: override = os.environ.get("PROLE_DOCKER_PLATFORM", "").strip() if override: return ["--platform", override] env_key = (target_env or "").strip().lower() if env_key == "dev" and is_apple_silicon(): return ["--platform", "linux/arm64"] if env_key in ("service", "k3s", "prole-service-cluster"): return ["--platform", "linux/arm64"] if is_apple_silicon(): return ["--platform", "linux/amd64"] return [] def normalize_version(text: str) -> str: if not text: return "" m = re.search(r"(\d+(?:[._-]\d+){0,5})", text) if not m: nums = re.findall(r"\d+", text) else: nums = re.findall(r"\d+", m.group(1)) if not nums: return text.strip() parts = (nums + ["0", "0"])[:3] try: parts = [f"{int(p):02d}" for p in parts] except Exception: parts = [p.zfill(2) for p in parts] return ".".join(parts) def get_resource_path(relative_path: str | Path) -> 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 = sys._MEIPASS except Exception: base_path = PROJECT_ROOT return Path(base_path) / relative_path def _expand_path(val: str | None) -> str: if not val: return "" # Expand ~ and shell-style variables like $HOME. Use a small fixed-point loop # so nested refs (e.g. PROLE_HOME=$HOME/...) expand fully. out = os.path.expanduser(str(val)) for _ in range(10): new = os.path.expandvars(out) if new == out: break out = new return out def _normalize_persistence_mode(mode: str | None) -> str: raw = (mode or "").strip().lower() if raw in {"dev", "k3d", "local"}: return "dev" if raw in {"service", "k3s", "k3s-hosts", "prole-service-cluster"}: return "k3s-hosts" if raw in {"prod", "k8s", "prole-prod-cluster"}: return "prod" return raw def _is_simple_template_placeholder(value: str | None) -> bool: v = (value or "").strip() return bool(re.match(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$", v)) def _looks_like_localhost_value(value: str | None) -> bool: v = (value or "").strip().lower() if not v: return False return "localhost" in v or "127.0.0.1" in v def _looks_like_generated_service_endpoint_key(key: str | None) -> bool: k = (key or "").strip().upper() if not k: return False return ( k == "SERVICE_HOSTNAME" or k.endswith("_SERVICE_ENDPOINT") or k.endswith("_SERVICE_HOSTNAME") or (k.startswith("SERVICE_") and k.endswith("_HOSTNAME")) ) def _normalize_cfg_value_for_persistence( section: str, key: str, value: str | None, *, mode: str | None = None, explicit: bool = False, ) -> str | None: """Return a persistable value, or `None` if it must not be persisted. Policy: - Dev/k3d: retain existing behavior. - k3s-hosts/Service and Prod: keep explicit inputs/handoff values, but drop derived placeholders, generated endpoint hostnames, localhost-based derived values, and k3d/dev endpoint assumptions. """ raw_value = "" if value is None else str(value) trimmed = raw_value.strip() if not trimmed: return None normalized_mode = _normalize_persistence_mode(mode) if normalized_mode not in {"k3s-hosts", "prod"}: return trimmed normalized_key = (key or "").strip().upper() if _is_simple_template_placeholder(trimmed): return None if normalized_key == "SERVICE_NAMESPACE" and not explicit: return None if _looks_like_generated_service_endpoint_key(normalized_key) and not explicit: return None if _looks_like_localhost_value(trimmed) and not explicit: return None if "k3d.localhost" in trimmed.lower() and not explicit: return None return trimmed def _filter_cfg_values_for_persistence( section: str, values: dict | None, *, mode: str | None = None, explicit_keys: set[str] | None = None, ) -> dict[str, str]: """Filter section values using mode-aware persistence rules.""" if not values or not isinstance(values, dict): return {} explicit_keys_upper = {str(k).strip().upper() for k in (explicit_keys or set())} filtered: dict[str, str] = {} for key, value in values.items(): key_str = str(key) normalized = _normalize_cfg_value_for_persistence( section, key_str, "" if value is None else str(value), mode=mode, explicit=key_str.strip().upper() in explicit_keys_upper, ) if normalized is None: continue filtered[key_str] = normalized return filtered def _collect_cfg_vars( cfg: any, *, mode: str | None = None, explicit_keys: set[tuple[str, str]] | None = None, ) -> dict: variables: dict[str, str] = {} explicit_pairs = { (str(sec).strip().upper(), str(key).strip().upper()) for sec, key in (explicit_keys or set()) } def _is_placeholder(v: str) -> bool: vv = (v or "").strip() return vv.startswith("${") and vv.endswith("}") def _merge_section(section: str) -> None: if not cfg.has_section(section): return for k, v in cfg.items(section): s = str(v or "").strip() # Avoid polluting the variable map with empty/self-referencing placeholders. if not s or _is_placeholder(s): continue if mode: norm = _normalize_cfg_value_for_persistence( section, str(k), s, mode=mode, explicit=(section.upper(), str(k).strip().upper()) in explicit_pairs, ) if norm is None: continue s = norm variables[k] = s # Global is the baseline. System and User can override, but only with concrete values. _merge_section("Global") _merge_section("System Environment") _merge_section("User") return variables def _collect_cfg_vars_from_data( cfg_data: dict | None, *, mode: str | None = None, explicit_keys: set[tuple[str, str]] | None = None, ) -> dict[str, str]: """Collect variable names from a `prole_cfg_data`-style dict. This mirrors `_collect_cfg_vars()` but operates on the UI/controller's in-memory dict instead of a `configparser.ConfigParser`. """ variables: dict[str, str] = {} if not cfg_data or not isinstance(cfg_data, dict): return variables explicit_pairs = { (str(sec).strip().upper(), str(key).strip().upper()) for sec, key in (explicit_keys or set()) } def _is_placeholder(v: str) -> bool: vv = (v or "").strip() return vv.startswith("${") and vv.endswith("}") for section in ("Global", "System Environment", "User"): sec = cfg_data.get(section, {}) or {} if not isinstance(sec, dict): continue for k, v in sec.items(): s = str(v or "").strip() if not s or _is_placeholder(s): continue if mode: norm = _normalize_cfg_value_for_persistence( section, str(k), s, mode=mode, explicit=(section.upper(), str(k).strip().upper()) in explicit_pairs, ) if norm is None: continue s = norm variables[str(k)] = s return variables def _expand_cfg_vars_shellstyle(val: str, variables: dict[str, str]) -> str: """Expand `$VAR` and `${VAR}` using `variables`. Notes: - Only expands simple shell-style identifiers (letters/digits/underscore). This intentionally avoids treating `${PROLE_SECRET:...}` or similar colon-delimited references as variables. - Unknown variables are left intact. """ if not val or not isinstance(val, str) or not variables: return val # ${VAR} def repl_braced(m: re.Match) -> str: name = m.group(1) return variables.get(name, m.group(0)) out = re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl_braced, val) # $VAR (avoid $ followed by { ... } which is already handled above) def repl_plain(m: re.Match) -> str: name = m.group(1) return variables.get(name, m.group(0)) out = re.sub(r"\$([A-Za-z_][A-Za-z0-9_]*)", repl_plain, out) return out def _expand_path_expr(val: str | None, variables: dict[str, str] | None = None) -> str: """Expand a path-like expression with support for config variables. Expansion order (fixed-point loop): 1) `~` expansion 2) config variables (`$VAR` and `${VAR}`) 3) OS environment variables (via `os.path.expandvars`) """ if not val: return "" out = os.path.expanduser(str(val)) for _ in range(10): new = out if variables: new = _expand_cfg_vars_shellstyle(new, variables) new = os.path.expandvars(new) if new == out: break out = new return out def _expand_cfg_value(val: str, variables: dict) -> str: if not val or not isinstance(val, str): return val # Basic ${VAR} expansion import re result = val for m in re.finditer(r"\$\{([^}]+)\}", val): full, var = m.group(0), m.group(1) if var in variables: result = result.replace(full, variables[var]) return result def _preserve_cfg_expr_for_persistence( raw_value: str | None, expanded_value: str | None = None, ) -> str: """Prefer raw tokenized value (e.g. `${HOME}`) when persisting config.""" raw = "" if raw_value is None else str(raw_value).strip() if "$" in raw: return raw if expanded_value is None: return raw return str(expanded_value).strip() def _parse_bool(val: any, default: bool | None = False) -> bool | None: if val is None: return default if isinstance(val, bool): return val s = str(val).lower().strip() if s in ("true", "1", "yes", "on"): return True if s in ("false", "0", "no", "off"): return False return default def _extract_yaml_scalar_from_text(text: str, key: str) -> str: # Look for key: "value" or key: value m = re.search(rf'^{key}:\s*["\']?(.*?)["\']?\s*$', text, re.MULTILINE) if m: return m.group(1) return "" def _extract_inline_vault_block(text: str, key: str) -> str: lines = text.splitlines() for i, line in enumerate(lines): if line.strip().startswith(f"{key}:"): base_indent = len(line) - len(line.lstrip()) block = [] i += 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 "" # Simple search in plain text first try: raw_text = vault_path.read_text() plain = _extract_yaml_scalar_from_text(raw_text, key) if ( plain and not plain.startswith("$ANSIBLE_VAULT") and not plain.startswith("!vault") ): return plain except Exception: pass password_file = (os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") or "").strip() if not password_file: candidate = PROJECT_ROOT / ".vault_pass" if candidate.is_file(): password_file = str(candidate) if not password_file or shutil.which("ansible-vault") is None: return "" def _vault_view(path: str) -> str: try: res = subprocess.run( ["ansible-vault", "view", path, "--vault-password-file", password_file], capture_output=True, text=True, timeout=5, env=os.environ.copy(), stdin=subprocess.DEVNULL, ) if res.returncode == 0: return res.stdout or "" except Exception: pass return "" try: output = _vault_view(str(vault_path)) if output: return _extract_yaml_scalar_from_text(output, key) # Inline vault inline_block = _extract_inline_vault_block(vault_path.read_text(), key) if not inline_block: return "" import tempfile with tempfile.NamedTemporaryFile(mode="w", delete=False) as tmp: tmp.write(inline_block) tmp_name = tmp.name try: output = _vault_view(tmp_name) return output.strip() finally: if os.path.exists(tmp_name): os.unlink(tmp_name) except Exception: pass return "" def _update_prole_cfg_value( section: str, key: str, value: str, *, mode: str | None = None, explicit: bool = False, ): # Prefer `$PROLE_CONF/prole.cfg` (single entrypoint) and follow symlink so we # update the active environment base file without mutating other environments. try: from knoe import prole_conf conf_dir = prole_conf.resolve_prole_conf_dir(PROJECT_ROOT) cfg_path = prole_conf.entrypoint_path(conf_dir) except Exception: cfg_path = PROJECT_ROOT / "conf" / "prole.cfg" if not cfg_path.exists(): return try: write_path = cfg_path.resolve() if cfg_path.is_symlink() else cfg_path except Exception: write_path = cfg_path if not write_path.exists(): return import configparser cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str cfg.read(write_path) if not cfg.has_section(section): cfg.add_section(section) effective_mode = mode if effective_mode is None: effective_mode = ( os.environ.get("DEPLOYMENT_MODE") or os.environ.get("CLUSTER_ENV") or os.environ.get("MODE") or "" ) normalized_value = _normalize_cfg_value_for_persistence( section, key, value, mode=effective_mode, explicit=explicit, ) if normalized_value is None: if cfg.has_option(section, key): cfg.remove_option(section, key) with open(write_path, "w") as f: cfg.write(f) return cfg.set(section, key, normalized_value) with open(write_path, "w") as f: cfg.write(f) def _write_k3s_kubeconfig(server_url: str, token: str) -> Path: 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" ) prole_service = os.environ.get("PROLE_SERVICE", "").strip() if prole_service: path = Path(prole_service).expanduser() / "secrets" / "k3s.kubeconfig" else: prole_home = os.environ.get("PROLE_HOME", "").strip() if prole_home: path = Path(prole_home).expanduser() / "prole-k3s.kubeconfig" else: path = PROJECT_ROOT / "prole-k3s.kubeconfig" # Never overwrite a kubeconfig that uses client-certificate auth (e.g. # one fetched by Ansible) with a token-based fallback. if path.exists(): try: existing = path.read_text(encoding="utf-8") if "client-certificate-data" in existing: # Keep the certificate-based kubeconfig intact, but still generate a # token-based kubeconfig alongside it so callers/tests get a deterministic # token config for the requested server. path = path.with_name(f"{path.stem}.token{path.suffix}") except Exception: pass path.parent.mkdir(parents=True, exist_ok=True) path.write_text(cfg, encoding="utf-8") os.chmod(path, 0o600) return path def _merge_kubeconfig(kubeconfig_path: str) -> bool: """Merge a standalone kubeconfig into ~/.kube/config so kubectx can discover it.""" default_kube = Path.home() / ".kube" / "config" default_kube.parent.mkdir(parents=True, exist_ok=True) merged_env = os.environ.copy() paths = [] if default_kube.exists(): paths.append(str(default_kube)) paths.append(str(kubeconfig_path)) merged_env["KUBECONFIG"] = os.pathsep.join(paths) try: res = subprocess.run( ["kubectl", "config", "view", "--flatten"], capture_output=True, text=True, env=merged_env, timeout=10, ) if res.returncode == 0 and res.stdout.strip(): import tempfile fd, tmp = tempfile.mkstemp(dir=str(default_kube.parent), suffix=".tmp") try: os.write(fd, res.stdout.encode("utf-8")) os.close(fd) fd = -1 os.replace(tmp, str(default_kube)) os.chmod(str(default_kube), 0o600) return True except Exception: if fd >= 0: os.close(fd) try: os.unlink(tmp) except Exception: pass except Exception: pass return False DEPENDENCIES = [ { "id": "brew", "name": "Homebrew", "description": "Package manager for macOS", "url": "https://brew.sh", "install_cmd": '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', "check_cmd": "brew --version", "bin": "brew", }, { "id": "python", "name": "python", "parent": "brew", "description": "Python programming language", "url": "https://www.python.org", "install_cmd": "brew install python", "check_cmd": "python3 --version", "bin": "python3", }, { "id": "ansible", "name": "ansible", "parent": "brew", "description": "Infrastructure automation tool", "url": "https://www.ansible.com", "install_cmd": "brew install ansible", "check_cmd": "ansible --version", "bin": "ansible", }, { "id": "kubectl", "name": "kubectl", "parent": "brew", "description": "Kubernetes command-line tool", "url": "https://kubernetes.io/docs/reference/kubectl/", "install_cmd": "brew install kubectl", "check_cmd": "kubectl version --client", "bin": "kubectl", }, { "id": "kubectx", "name": "kubectx", "parent": "brew", "description": "Tool to switch between Kubernetes contexts", "url": "https://github.com/ahmetb/kubectx", "install_cmd": "brew install kubectx", "check_cmd": "kubectx -h", "version_cmd": "brew list --versions kubectx 2>/dev/null || brew info kubectx 2>/dev/null | head -n1", "bin": "kubectx", }, { "id": "docker", "name": "Docker", "description": "Container platform for running Prole services", "url": "https://www.docker.com/products/docker-desktop", "install_cmd": None, "check_cmd": "docker --version", "bin": "docker", }, { "id": "k3d", "name": "k3d", "description": "Lightweight wrapper to run k3s in Docker", "url": "https://k3d.io", "install_cmd": "brew install k3d", "check_cmd": "k3d --version", "bin": "k3d", }, { "id": "opentofu", "name": "OpenTofu", "parent": "brew", "description": "Open-source infrastructure as code tool", "url": "https://opentofu.org", "install_cmd": "brew install opentofu", "check_cmd": "tofu --version", "bin": "tofu", }, ] def _resolve_brew_bin() -> str | None: """Return an absolute path to a `brew` binary if one is found in common locations.""" candidates: list[str] = [ "/home/linuxbrew/.linuxbrew/bin/brew", # Linuxbrew (recommended) os.path.expanduser("~/.linuxbrew/bin/brew"), # Linuxbrew (legacy single-user) "/opt/homebrew/bin/brew", # macOS (Apple Silicon) "/usr/local/bin/brew", # macOS (Intel) ] for p in candidates: try: if os.path.isfile(p) and os.access(p, os.X_OK): return p except Exception: continue return None def _augment_env_for_brew(env: dict | None = None) -> dict: """Prepend Homebrew `bin`/`sbin` to PATH when brew exists but isn't in PATH. This keeps dependency checks and `brew install ...` invocations working in non-interactive environments where shell init files aren't sourced. """ if env is None: env = os.environ.copy() brew_bin = _resolve_brew_bin() if not brew_bin: return env try: brew_bin_path = Path(brew_bin) prefix = brew_bin_path.parent.parent bin_dir = str(prefix / "bin") sbin_dir = str(prefix / "sbin") path_val = env.get("PATH", "") or "" parts = [p for p in path_val.split(os.pathsep) if p] prepend: list[str] = [] if bin_dir and bin_dir not in parts: prepend.append(bin_dir) try: if os.path.isdir(sbin_dir) and sbin_dir not in parts: prepend.append(sbin_dir) except Exception: # sbin is optional pass if prepend: env["PATH"] = os.pathsep.join(prepend + parts) except Exception: return env return env def get_dep_info(dep: dict) -> Tuple[bool, Optional[str], Optional[str]]: bin_name = dep.get("bin") or dep["name"] location = None version = None installed = False try: env = _augment_env_for_brew(os.environ.copy()) if bin_name: # Avoid `bash -l` here: login shell init scripts on some distros # can overwrite PATH, defeating our augmented PATH for Linuxbrew. try: location = shutil.which(bin_name, path=env.get("PATH")) except Exception: location = None # Extra safety: if checking for brew specifically, fall back to # well-known absolute locations even if PATH lookup fails. if not location and bin_name == "brew": location = _resolve_brew_bin() if location: installed = True check_cmd = dep.get("check_cmd") if check_cmd: res2 = subprocess.run( ["bash", "-c", check_cmd], capture_output=True, text=True, env=env, ) if res2.returncode == 0: installed = True # Use first line as version if not already set if not version: version = " ".join(res2.stdout.strip().splitlines()[:1]) if not location: location = "Installed via Python" version_cmd = dep.get("version_cmd") if version_cmd: res3 = subprocess.run( ["bash", "-c", version_cmd], capture_output=True, text=True, env=env, ) if res3.returncode == 0: v_out = res3.stdout.strip() if v_out: version = " ".join(v_out.splitlines()[:1]) except Exception: pass return installed, location, version def load_dependencies(refresh: bool = True) -> list[dict]: """Return a list of dependency descriptors. Each item mirrors entries from ``DEPENDENCIES`` and is enriched with: - installed: bool - location: Optional[str] - version: Optional[str] """ deps: list[dict] = [dict(d) for d in DEPENDENCIES] if refresh: for dep in deps: try: installed, location, version = get_dep_info(dep) dep["installed"] = bool(installed) if location is not None: dep["location"] = location if version is not None: dep["version"] = version except Exception: dep["installed"] = False return deps def _parse_gcp_cfg(path) -> dict: """Parse flat tfvars-style file written by etc/config.py --provider gcp. Handles: key = "value" and key = value Skips lines starting with # or ; Returns empty dict if file missing or unreadable. """ result = {} try: for raw in path.read_text().splitlines(): line = raw.strip() if not line or line.startswith("#") or line.startswith(";"): continue if "=" not in line: continue k, _, v = line.partition("=") key = k.strip() val = v.strip().strip('"').strip("'") if key: result[key] = val except Exception: pass return result # --------------------------------------------------------------------------- # GCP ncurses TUI — cluster configuration generator # # Usage (direct): # python3 -m knoe.config --mode k8s --provider gcp # # Usage (via launcher): # ./config.sh --mode k8s --provider gcp # # Flow: # 1. Check / perform gcloud auth (device-code, no browser required) # 2. Select GCP organization # 3. Select project (filtered to org) # 4. Select billing account # 5. Confirm selection and write flat tfvars-compatible output file # # Output format: # org_id = "" # billing_account = "" # billing_project = "" # project_id = "" # --------------------------------------------------------------------------- DEFAULT_GCP_OUTPUT = PROJECT_ROOT / "conf" / "prod" / "gcp.cfg" SUPPORTED_MODES = ("k8s", "prod") SUPPORTED_PROVIDERS = ("gcp",) # -- gcloud helpers ---------------------------------------------------------- def _gcloud(*args) -> tuple[int, list | dict]: cmd = ["gcloud", *args, "--format=json", "--quiet"] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) except FileNotFoundError: return 127, [] except subprocess.TimeoutExpired: return 1, [] if result.returncode != 0: return result.returncode, [] try: return 0, json.loads(result.stdout.strip() or "[]") except Exception: return 1, [] def _gcloud_plain(*args) -> tuple[int, str]: cmd = ["gcloud", *args, "--quiet"] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) return result.returncode, result.stdout.strip() except FileNotFoundError: return 127, "" except subprocess.TimeoutExpired: return 1, "" def gcloud_available() -> bool: rc, _ = _gcloud_plain("version") return rc != 127 def active_gcp_account() -> str | None: rc, data = _gcloud("auth", "list", "--filter=status=ACTIVE") if rc == 0 and data: return data[0].get("account", "") return None def fetch_gcp_orgs() -> list[dict]: rc, data = _gcloud("organizations", "list") if rc != 0 or not data: return [] rows = [] for d in data: org_id = d.get("name", "").replace("organizations/", "") name = d.get("displayName", "") rows.append({ "display": f"{name:<40} {org_id}", "org_id": org_id, "org_name": name, }) return rows def fetch_gcp_projects(org_id: str | None) -> list[dict]: args = ["projects", "list"] if org_id: args += [f"--filter=parent.id={org_id} AND parent.type=organization"] rc, data = _gcloud(*args) if rc != 0 or not data: return [] rows = [] for d in data: pid = d.get("projectId", "") name = d.get("name", "") number = d.get("projectNumber", "") rows.append({ "display": f"{name:<35} {pid:<30} #{number}", "project_id": pid, "project_name": name, "project_number": number, }) rows.sort(key=lambda r: r["project_name"].lower()) return rows def fetch_gcp_billing_accounts() -> list[dict]: rc, data = _gcloud("beta", "billing", "accounts", "list", "--filter=open=true") if rc != 0 or not data: rc, data = _gcloud("billing", "accounts", "list", "--filter=open=true") if rc != 0 or not data: return [] rows = [] for d in data: acct_id = d.get("name", "").replace("billingAccounts/", "") name = d.get("displayName", "") rows.append({ "display": f"{name:<45} {acct_id}", "billing_account_id": acct_id, "billing_name": name, }) return rows # -- ncurses UI primitives --------------------------------------------------- def _tui_draw_header(win, title: str, subtitle: str = ""): h, w = win.getmaxyx() win.attron(curses.A_BOLD) win.addstr(0, 0, title[:w - 1]) win.attroff(curses.A_BOLD) win.addstr(1, 0, ("─" * (w - 1))[:w - 1]) if subtitle: win.addstr(2, 2, subtitle[:w - 3], curses.A_DIM) def _tui_draw_footer(win, text: str): h, w = win.getmaxyx() win.addstr(h - 1, 0, text[:w - 1], curses.A_DIM) def tui_message(win, lines: list[str], wait: bool = True): win.erase() h, w = win.getmaxyx() for i, line in enumerate(lines): if i >= h - 2: break win.addstr(i, 0, line[:w - 1]) if wait: _tui_draw_footer(win, "Press any key to continue…") win.refresh() win.getch() else: win.refresh() def tui_list(win, title: str, items: list[dict], subtitle: str = "") -> int | None: """Scrollable list. Returns selected index or None on quit/cancel.""" curses.curs_set(0) idx = 0 offset = 0 header_rows = 3 if subtitle else 2 while True: win.erase() h, w = win.getmaxyx() _tui_draw_header(win, title, subtitle) list_h = h - header_rows - 1 if not items: win.addstr(header_rows + 1, 2, "(no items)") _tui_draw_footer(win, "q quit") win.refresh() k = win.getch() if k in (ord("q"), ord("Q"), 27): return None continue if idx < offset: offset = idx elif idx >= offset + list_h: offset = idx - list_h + 1 for i, item in enumerate(items[offset: offset + list_h]): row = header_rows + i abs_i = i + offset label = item["display"][:w - 4] if abs_i == idx: win.attron(curses.A_REVERSE) win.addstr(row, 2, f" {label} ") win.attroff(curses.A_REVERSE) else: win.addstr(row, 2, label) scroll_info = f" {idx + 1}/{len(items)}" _tui_draw_footer(win, f"↑↓/jk navigate Enter select q quit{scroll_info}") win.refresh() k = win.getch() if k in (curses.KEY_UP, ord("k")) and idx > 0: idx -= 1 elif k in (curses.KEY_DOWN, ord("j")) and idx < len(items) - 1: idx += 1 elif k == curses.KEY_PPAGE: idx = max(0, idx - list_h) elif k == curses.KEY_NPAGE: idx = min(len(items) - 1, idx + list_h) elif k in (curses.KEY_ENTER, 10, 13): return idx elif k in (ord("q"), ord("Q"), 27): return None def tui_confirm(win, summary: dict, output_path: str, mode: str, provider: str) -> str | None: """Show selection summary, allow editing of the output path, then confirm. Returns the final path string or None to cancel. """ curses.curs_set(1) path_buf = list(output_path) cursor = len(path_buf) while True: win.erase() h, w = win.getmaxyx() win.attron(curses.A_BOLD) win.addstr(0, 0, f"Config: --mode {mode} --provider {provider} — Confirm & Save"[:w - 1]) win.attroff(curses.A_BOLD) win.addstr(1, 0, ("─" * (w - 1))[:w - 1]) rows = [ ("Org ID", summary.get("org_id", "(none)")), ("Org Name", summary.get("org_name", "")), ("Project ID", summary.get("project_id", "(none)")), ("Project Name", summary.get("project_name", "")), ("Project Number", summary.get("project_number", "")), ("Billing Account", summary.get("billing_account_id", "(none)")), ("Billing Name", summary.get("billing_name", "")), ] for i, (label, value) in enumerate(rows): row = i + 2 if row >= h - 4: break win.addstr(row, 2, f"{label + ':':<18} {value}"[:w - 3]) path_row = 2 + len(rows) + 1 if path_row < h - 2: win.addstr(path_row, 2, "Output file: "[:w - 3], curses.A_BOLD) path_str = "".join(path_buf) win.addstr(path_row, 20, path_str[:w - 22]) win.move(path_row, 20 + min(cursor, w - 22)) _tui_draw_footer(win, "Edit path above Enter to write Esc to cancel") win.refresh() k = win.getch() if k in (curses.KEY_ENTER, 10, 13): curses.curs_set(0) return "".join(path_buf) elif k == 27: curses.curs_set(0) return None elif k in (curses.KEY_BACKSPACE, 127, 8) and cursor > 0: path_buf.pop(cursor - 1) cursor -= 1 elif k == curses.KEY_DC and cursor < len(path_buf): path_buf.pop(cursor) elif k == curses.KEY_LEFT and cursor > 0: cursor -= 1 elif k == curses.KEY_RIGHT and cursor < len(path_buf): cursor += 1 elif k == curses.KEY_HOME: cursor = 0 elif k == curses.KEY_END: cursor = len(path_buf) elif 32 <= k < 127: path_buf.insert(cursor, chr(k)) cursor += 1 # -- output writer ----------------------------------------------------------- def write_gcp_config(path: str, summary: dict, mode: str, provider: str): org_id = summary.get("org_id", "") billing_account = summary.get("billing_account_id", "") project_id = summary.get("project_id", "") org_name = summary.get("org_name", "") project_name = summary.get("project_name", "") billing_name = summary.get("billing_name", "") lines = [ f"# Generated by knoe/config.py --mode {mode} --provider {provider}", "# Copy org_id, billing_account, billing_project into:", "# deploy/gcp/terraform/cloud-setup.auto.tfvars", "# Loaded automatically by the installer into conf/prod/prole.cfg [GCP]", "", f"# Org: {org_name}", f'org_id = "{org_id}"', "", f"# Billing account: {billing_name}", f'billing_account = "{billing_account}"', "", "# Billing project (used for API quota / billing attribution)", f'billing_project = "{project_id}"', "", f"# Selected project: {project_name}", f'project_id = "{project_id}"', ] out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) out.write_text("\n".join(lines) + "\n") # -- TUI orchestrator -------------------------------------------------------- def _run_gcp_tui(stdscr, output_path: str, mode: str, provider: str): curses.start_color() curses.use_default_colors() stdscr.keypad(True) if not gcloud_available(): tui_message(stdscr, [ "ERROR: gcloud CLI not found in PATH.", "", "Install the Google Cloud SDK and re-run:", " ./config.sh --mode k8s --provider gcp", ]) return tui_message(stdscr, ["Checking gcloud authentication…"], wait=False) account = active_gcp_account() if not account: tui_message(stdscr, [ "No active gcloud account found.", "", "This will run: gcloud auth login --no-browser", "", "A device-code URL will be printed to the terminal.", "Open it in any browser (on any machine) to authenticate.", "", "Press any key to begin, or q to quit.", ]) k = stdscr.getch() if k in (ord("q"), ord("Q"), 27): return curses.endwin() print("\nRunning: gcloud auth login --no-browser\n") rc = subprocess.run(["gcloud", "auth", "login", "--no-browser"]).returncode stdscr = curses.initscr() curses.start_color() curses.use_default_colors() stdscr.keypad(True) if rc != 0: tui_message(stdscr, ["Login failed or was cancelled. Exiting."]) return account = active_gcp_account() if not account: tui_message(stdscr, ["Auth succeeded but no active account detected. Exiting."]) return tui_message(stdscr, [ f"Authenticated as: {account}", "", "Fetching GCP organizations…", ], wait=False) summary: dict = {} orgs = fetch_gcp_orgs() if not orgs: tui_message(stdscr, [ f"No organizations found for {account}.", "", "You may lack resourcemanager.organizations.list permission,", "or this account belongs to no GCP org.", "", "Continuing to project selection without an org filter.", ]) else: sel = tui_list(stdscr, "Select GCP Organization", orgs, subtitle=f"Authenticated as: {account}") if sel is None: return summary["org_id"] = orgs[sel]["org_id"] summary["org_name"] = orgs[sel]["org_name"] tui_message(stdscr, ["Fetching projects…"], wait=False) projects = fetch_gcp_projects(summary.get("org_id")) if not projects: tui_message(stdscr, [ "No projects found.", "Ensure you have resourcemanager.projects.list permission.", ]) return sel = tui_list( stdscr, "Select GCP Project", projects, subtitle=( f"Org: {summary.get('org_name', summary.get('org_id', 'none'))}" f" ({len(projects)} projects)" ), ) if sel is None: return summary["project_id"] = projects[sel]["project_id"] summary["project_name"] = projects[sel]["project_name"] summary["project_number"] = projects[sel]["project_number"] tui_message(stdscr, ["Fetching billing accounts…"], wait=False) billing = fetch_gcp_billing_accounts() if not billing: tui_message(stdscr, [ "No open billing accounts found (or insufficient permissions).", "", "billing_account will be left blank in the output file.", "Edit the file manually to fill it in.", ]) else: sel = tui_list(stdscr, "Select Billing Account", billing, subtitle=f"Project: {summary['project_id']}") if sel is None: return summary["billing_account_id"] = billing[sel]["billing_account_id"] summary["billing_name"] = billing[sel]["billing_name"] final_path = tui_confirm(stdscr, summary, output_path, mode, provider) if final_path is None: tui_message(stdscr, ["Cancelled. No file written."]) return try: write_gcp_config(final_path, summary, mode, provider) except Exception as e: tui_message(stdscr, [f"ERROR writing file: {e}"]) return tui_message(stdscr, [ "Configuration written.", "", f" {final_path}", "", "Next steps:", " 1. Copy org_id / billing_account / billing_project into:", " deploy/gcp/terraform/cloud-setup.auto.tfvars", " 2. Open the installer — Prod Cluster → Cloud tab will be pre-filled", " (or click 'Load from gcp.cfg' to refresh on demand)", ]) # -- CLI entry point --------------------------------------------------------- def _gcp_config_main(): parser = argparse.ArgumentParser( prog="config.sh", description=( "ncurses TUI: select GCP credentials → write flat config file.\n" "Output is used by the installer Prod Cluster screen and deploy pipelines." ), formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " ./config.sh --mode k8s --provider gcp\n" " ./config.sh --mode prod --provider gcp --output /tmp/gcp.cfg\n" ), ) parser.add_argument( "--mode", choices=SUPPORTED_MODES, default="k8s", help="Deployment mode: k8s or prod (synonymous). Default: k8s", ) parser.add_argument( "--provider", choices=SUPPORTED_PROVIDERS, default="gcp", help="Cloud provider. Default: gcp", ) parser.add_argument( "--output", default=str(DEFAULT_GCP_OUTPUT), metavar="PATH", help=f"Output file path. Default: {DEFAULT_GCP_OUTPUT}", ) args = parser.parse_args() mode = "k8s" if args.mode == "prod" else args.mode if args.provider == "gcp": try: curses.wrapper(_run_gcp_tui, args.output, mode, args.provider) except KeyboardInterrupt: pass except Exception as exc: print(f"Fatal error: {exc}", file=sys.stderr) sys.exit(1) else: print(f"Provider '{args.provider}' is not yet implemented.", file=sys.stderr) sys.exit(1) if __name__ == "__main__": _gcp_config_main()