From ec50f82cc8ea2fbd86cb57f8cbb46bb2e2786ea0 Mon Sep 17 00:00:00 2001 From: chrisfu Date: Fri, 20 Feb 2026 14:43:20 -0800 Subject: [PATCH] refactor: deduplicate installer business logic & split screens.py into package Separation of concerns: merge silent/UI actions & modularize screens. ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers. screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility. All 37 tests pass with no regressions. --- conf/port-mapping.cfg | 8 +- conf/prole.cfg | 36 +- installer/core/actions.py | 1363 +-- installer/ui/screens.py | 10368 ----------------------- installer/ui/screens/__init__.py | 638 ++ installer/ui/screens/base.py | 225 + installer/ui/screens/build.py | 726 ++ installer/ui/screens/cfg.py | 655 ++ installer/ui/screens/cluster.py | 1357 +++ installer/ui/screens/database.py | 809 ++ installer/ui/screens/dependencies.py | 441 + installer/ui/screens/deploy.py | 637 ++ installer/ui/screens/docker.py | 496 ++ installer/ui/screens/environment.py | 432 + installer/ui/screens/navigation.py | 561 ++ installer/ui/screens/network.py | 241 + installer/ui/screens/ollama.py | 567 ++ installer/ui/screens/packaging.py | 959 +++ installer/ui/screens/security.py | 319 + installer/ui/screens/services.py | 1053 +++ installer/ui/screens/supabase.py | 212 + installer/ui/screens/validate.py | 160 + installer/ui/screens/welcome.py | 108 + tests/installer/test_milestones.py | 55 +- tests/test_install_logic.py | 10 +- tests/test_navigation.py | 3 + tests/test_service_layer_navigation.py | 3 + 27 files changed, 11391 insertions(+), 11051 deletions(-) delete mode 100755 installer/ui/screens.py create mode 100644 installer/ui/screens/__init__.py create mode 100644 installer/ui/screens/base.py create mode 100644 installer/ui/screens/build.py create mode 100644 installer/ui/screens/cfg.py create mode 100644 installer/ui/screens/cluster.py create mode 100644 installer/ui/screens/database.py create mode 100644 installer/ui/screens/dependencies.py create mode 100644 installer/ui/screens/deploy.py create mode 100644 installer/ui/screens/docker.py create mode 100644 installer/ui/screens/environment.py create mode 100644 installer/ui/screens/navigation.py create mode 100644 installer/ui/screens/network.py create mode 100644 installer/ui/screens/ollama.py create mode 100644 installer/ui/screens/packaging.py create mode 100644 installer/ui/screens/security.py create mode 100644 installer/ui/screens/services.py create mode 100644 installer/ui/screens/supabase.py create mode 100644 installer/ui/screens/validate.py create mode 100644 installer/ui/screens/welcome.py diff --git a/conf/port-mapping.cfg b/conf/port-mapping.cfg index 5b5c013..2f0153f 100644 --- a/conf/port-mapping.cfg +++ b/conf/port-mapping.cfg @@ -6,11 +6,11 @@ supabase-studio: local=8088 remote=3000 ns=supabase svc=studio address=0.0.0.0 supabase-auth: local=9999 remote=9999 ns=supabase svc=auth address=0.0.0.0 supabase-rest: local=3001 remote=3000 ns=supabase svc=rest address=0.0.0.0 supabase-realtime: local=4000 remote=4000 ns=supabase svc=realtime address=0.0.0.0 -garage: local=3900 remote=3900 ns= svc=garage address=0.0.0.0 -openbao: local=8200 remote=8200 ns= svc=openbao address=127.0.0.1 -opentofu: local=8080 remote=8080 ns= svc=opentofu address=0.0.0.0 +garage: local=3900 remote=3900 ns= svc=garage address=0.0.0.0 +openbao: local=8200 remote=8200 ns= svc=openbao address=127.0.0.1 +opentofu: local=8080 remote=8080 ns= svc=opentofu address=0.0.0.0 dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboard-kong-proxy address=127.0.0.1 -postgres: local= remote=5432 ns= svc=prole-db-rw address=0.0.0.0 +postgres: local= remote=5432 ns= svc=prole-db-rw address=0.0.0.0 prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1 grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0 supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0 diff --git a/conf/prole.cfg b/conf/prole.cfg index 181c324..22fbe02 100644 --- a/conf/prole.cfg +++ b/conf/prole.cfg @@ -1,4 +1,9 @@ +; Prole Master Configuration File +; Generated by install.py on 2026-02-20 13:20:27 +; This file is used as input for Ansible deployment and k8s cluster creation. + [User] +; User-editable values; derived values below reference these by default. NAMESPACE = prole-db-a0001 PROLE_CONF = ${PROLE_HOME}/conf PROLE_DATA = /Users/chrisfu/.prole/data @@ -8,6 +13,7 @@ PROLE_SERVICE = ${PROLE_HOME}/etc SERVICE_NAMESPACE = default [Inputs] +; Screen-scoped inputs used for unattended replays (-S) build.deploy_env = Dev build.run_build = false dependencies.ansible.install = true @@ -30,18 +36,18 @@ init_cluster.at_rest_encryption_enabled = true init_cluster.cluster_env = dev init_cluster.deployment_target = prole-dev-cluster init_cluster.k3s_server_url = https://myrddin.prole.org:6443 -init_cluster.k3s_token = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b +init_cluster.k3s_token = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==} init_cluster.kerberos_enabled = true init_cluster.mode = k3d init_cluster.start_cluster = true -init_cluster.supabase_enabled = false +init_cluster.supabase_enabled = true init_cnpg_deploy.force_rollout = false init_cnpg_deploy.run_deploy = true init_db_build.run_build = true init_password.db_host_port = 5432 init_password.db_password = ${OPENBAO:kv/prole/prole-db-a0001/db#password} init_password.db_password_confirm = ${OPENBAO:kv/prole/prole-db-a0001/db#password} -init_password.db_username = root +init_password.db_username = chrisfu init_password.generate_ssh_key = true init_scripts.run_scripts = true kerberos_config.enabled = true @@ -56,8 +62,7 @@ ollama_config.server_host = ollama_config.server_port = 11434 [Global] -CNPG_VERSION = 1.28.1 -CNPG_RELEASE_SERIES = 1.28 +; Variables used by name in more than one place or assumed global scope CLUSTER_ENV = dev DB_HOST_PORT = 5432 DB_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/db#password} @@ -65,16 +70,18 @@ DEPLOYMENT_MODE = k3d DEPLOYMENT_TARGET = prole-dev-cluster DOCKER_IMPORT_DIR = NAMESPACE = ${NAMESPACE} -PROLE_DB_USER = root +PROLE_DB_USER = chrisfu PROLE_HOME = ${PROLE_HOME} PROLE_K3S_SERVER = https://myrddin.prole.org:6443 -PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b +PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==} PROLE_OPENTOFU_URL = http://127.0.0.1:8080 SERVICE_NAMESPACE = default [Welcome] +; No configuration values captured yet for this section. [Dependencies] +; No configuration values captured yet for this section. [Network] AD_DC_HOST = myrddin.prole.org @@ -89,6 +96,7 @@ KDC_AUTO_DETECTED = 10.0.0.3 KERBEROS_AUTO_ENABLED = True [Port Forwards] +; No configuration values captured yet for this section. [System Environment] PROLE_CONF = ${PROLE_CONF} @@ -104,12 +112,19 @@ GRAFANA_ADMIN_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/monitoring#grafana_ad PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/kerberos#password} [Ollama] +; No configuration values captured yet for this section. [Optional Features] +AT_REST_ENCRYPTION_ENABLED = True +KERBEROS_ENABLED = True +SUPABASE_ENABLED = False [Database Creation] [Initialize Cluster] +ENVIRONMENT = prole-dev-cluster +K3S_SERVER_URL = https://myrddin.prole.org:6443 +K3S_TOKEN = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==} [Dev Cluster (k3d)] CLUSTER_ENV = dev @@ -121,7 +136,7 @@ MODE = k3d CLUSTER_ENV = prole-service-cluster DISPLAY_NAME = prole-service-cluster K3S_SERVER_URL = https://myrddin.prole.org:6443 -K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b +K3S_TOKEN = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==} MODE = k3s PIPELINE_URL = http://127.0.0.1:8080 @@ -133,12 +148,15 @@ MODE = k8s PIPELINE_URL = http://127.0.0.1:8080 [Docker Build] +LOCAL_REGISTRY = localhost:5000 +LOCAL_REGISTRY_INTERNAL = k3d-prole-registry.localhost:5000 [Initialization Scripts] +; No configuration values captured yet for this section. [Deployment] MODE = k3d TARGET = prole-dev-cluster [Install] - +; No configuration values captured yet for this section. diff --git a/installer/core/actions.py b/installer/core/actions.py index 11e6863..448ee25 100644 --- a/installer/core/actions.py +++ b/installer/core/actions.py @@ -41,17 +41,21 @@ def _configure_unbuffered_io(): pass -class ProleSilentInstaller: - """Console-based unattended installer driven by prole.cfg inputs.""" +class ProleInstallerBase: + """Shared business logic for both silent and interactive installer modes. - 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 = {} + 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': {}, @@ -72,18 +76,20 @@ class ProleSilentInstaller: 'Prod Cluster (k8s)': {}, 'Install': {} } - self._cfg_secret_cache = {} - self._secrets_finalized = False - self._db_built_success = False - self._scripts_success = False - self._cnpg_success = False - self._managed_kubeconfig = None - self.docker_import_dir = None - self._repair_ran = False - self._openbao_init_env = None - self._openbao_init_password = None + self._cfg_secret_cache: dict = {} + self._secrets_finalized: bool = False + self._managed_kubeconfig: str | None = None + self._repair_ran: bool = False - # ---------------- Logging helpers ---------------- + # --------------------------------------------------------- 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) @@ -92,6 +98,688 @@ class ProleSilentInstaller: 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: @@ -381,553 +1069,6 @@ class ProleSilentInstaller: return inputs - 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 '' - - def _get_input_bool(self, key: str, default: bool = False) -> bool: - return _parse_bool(self._get_input(key, None), default=default) - - 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' - - 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 - - # Try to reuse existing encrypted value if it decrypts to the same 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 - - 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 - - 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() - # Paths - 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 - - # Kubeconfig (k3s only) - 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 - - # Add Grafana password - 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 - - # Kerberos - 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"] = "" - - # K3s Auth - 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 - - # Registry settings - 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 - - 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 - ) - - # ---------------- 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() - 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}") - - 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}") - # ---------------- 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 @@ -986,92 +1127,6 @@ class ProleSilentInstaller: stderr_to_stdout=False, ) - def _add_port_mapping(self, mapping_str): - mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', '')) - 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 = {} - - # Remove deprecated registry port-forward mapping if present. - 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 - 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', '')): diff --git a/installer/ui/screens.py b/installer/ui/screens.py deleted file mode 100755 index 1de6915..0000000 --- a/installer/ui/screens.py +++ /dev/null @@ -1,10368 +0,0 @@ -#!/usr/bin/env python3 -""" -Prole Service Dependencies Installer -Desktop application for installing, deploying, and validating Prole services -""" - -import platform -import shlex -import signal -import socket -import threading -import tkinter as tk -import urllib.error -import urllib.parse -import urllib.request -import uuid -import webbrowser -from datetime import datetime -from tkinter import ttk, messagebox, filedialog - -from installer import deploy as inst_deploy -from installer import screen as ui -# Refactor: import shared helpers from the root-level installer package -from installer.build import get_build_command as inst_get_build_command -from installer.config import _expand_cfg_value, _collect_cfg_vars, _is_prole_secret, _decrypt_prole_secret, \ - _is_openbao_ref, _write_k3s_kubeconfig, _encrypt_cfg_secret, _encrypt_prole_secret -from installer.core.controller import ProleController -from installer.core.env import _detect_ansible_topology, _format_ansible_topology_summary, _parse_ollama_host, \ - _normalize_k3s_token, _deployment_target_label, _cluster_env_radio_value, _find_kubeconfig_file, _bool_str, \ - _looks_like_k8s_bearer_token, _deployment_mode_from_env, _pf_extract_id, _ensure_ansible_vault_credentials, \ - _openbao_placeholder, _pf_upsert_mapping, _format_ollama_host, _default_opentofu_pipeline_url, \ - _build_required_port_forwards, _render_prole_cfg, _normalize_cluster_env, _k3d_prole_data_volume_args, \ - _push_docker_image, _http_ping_registry, _local_registry_enabled, _collect_images_from_files - -# On macOS, set the process name as early as possible so the menu bar shows 'Prole Database Installer' -if platform.system() == 'Darwin': - try: - from Foundation import NSProcessInfo - NSProcessInfo.processInfo().setProcessName_("Prole Database Installer") - except Exception: - pass - -from installer.core.env import * # noqa: F401,F403 -from installer.core.actions import ProleSilentInstaller, _prepare_k3s_pipeline, _reset_k3s_namespace - -# Initialize global frames list to keep them in memory -GLOBAL_SCAN_FRAMES = [] - -class ProleInstaller: - KUBECTL_STATUS_TAB = "__kubectl_status__" - def __init__(self, root, config_path: str | None = None, reset_cluster: bool = False, verbose: bool = False): - self.controller = ProleController(PROJECT_ROOT, verbose=verbose) - self.screens = None - self.deploy_environment = None - self.root = root - self._cfg_path_override = None - if config_path: - try: - self._cfg_path_override = Path(config_path).expanduser() - except Exception: - self._cfg_path_override = None - self._installing_dep_id = None - self._managed_kubeconfig = None - self._kong_last_repair_at = 0.0 - self._kong_repair_cooldown_s = 120 - self._repair_last_run_at = 0.0 - self._repair_cooldown_s = 600 - self._repair_inflight = False - self._reset_cluster = bool(reset_cluster) - _ensure_ansible_vault_credentials(prompt_ui=True, root=root) - self._action_flags = {} - self.root.title("Prole Database Installer") - # Best-effort: set app identity (menu title / dock icon) early - try: - self._set_app_identity() - except Exception: - pass - - # Center window on screen - window_width = 1300 - window_height = 910 - screen_width = root.winfo_screenwidth() - screen_height = root.winfo_screenheight() - center_x = int(screen_width / 2 - window_width / 2) - center_y = int(screen_height / 2 - window_height / 2) - self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}") - self.root.resizable(False, False) - - # Force light mode colors and prevent dark mode shifts - self.root.configure(bg='white') - - # Style configuration - self.style = ttk.Style() - self.configure_styles() - - # Main container with two columns - self.main_container = tk.Frame(root, bg='white') - self.main_container.pack(fill='both', expand=True) - - # Left Sidebar (approx 25%) - self.sidebar = tk.Frame(self.main_container, bg='#F5F5DC', width=325) # Sepia background - self.sidebar.pack(side='left', fill='y') - self.sidebar.pack_propagate(False) - - # Vertical Divider Line - self.divider = tk.Frame(self.main_container, bg='#CCCCCC', width=1, highlightthickness=0, bd=0) - self.divider.pack(side='left', fill='y') - - # Right Content Area (approx 66%) - self.content_area = tk.Frame(self.main_container, bg='white') - self.content_area.pack(side='left', fill='both', expand=True) - - # Footer for Next/Prev buttons in the content area - btns = ui.create_nav_footer( - self.content_area, - buttons=[(1, 'Previous'), (2, 'Next'), (3, 'Deploy'), (4, 'Launch')], - commands={1: self.on_prev, 2: self.on_next, 3: self.on_deploy, 4: self.on_launch}, - style_name='Nav.TButton', - gear_command=self.show_config_dialog - ) - self.footer = btns.get('_footer') # type: ignore[assignment] - self.prev_button = btns.get(1) - self.next_button = btns.get(2) - self.deploy_button = btns.get(3) - self.launch_button = btns.get(4) - self.gear_button = btns.get('gear') - - # Background canvas for the content area - self._bg_pil = None - self._bg_tk = None - self.bg_canvas = tk.Canvas(self.content_area, highlightthickness=0, bd=0, bg='white') - self.bg_canvas.pack(fill='both', expand=True) - self._bg_item = None - - # Slide area: a container for overlaying widgets on the canvas. - # We use a frame. On macOS, we can't make it truly transparent without lifting/lowering. - self.slide_area = tk.Frame(self.bg_canvas, bg='white') - # We will use place but manage visibility in show_page - self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) - self.slide_area.lower() # Start below canvas items - - try: - from PIL import Image, ImageTk - bg_path = get_resource_path('img/proleLogoSepia.png') - if bg_path.exists(): - # Load and prepare image with 50% opacity - original = Image.open(str(bg_path)).convert('RGBA') - # Create a white background of the same size - white_bg = Image.new('RGBA', original.size, (255, 255, 255, 255)) - # Blend with 15% opacity of original (85% white) - self._bg_pil = Image.blend(white_bg, original, 0.15) - - def _render_bg(event=None): - if not self._bg_pil: return - cw = max(1, self.bg_canvas.winfo_width()) - ch = max(1, self.bg_canvas.winfo_height()) - iw, ih = self._bg_pil.size - scale = max(cw / iw, ch / ih) - nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale)) - img = self._bg_pil.resize((nw, nh), Image.LANCZOS) - self._bg_tk = ImageTk.PhotoImage(img) - if self._bg_item is not None: - self.bg_canvas.delete(self._bg_item) - self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, image=self._bg_tk, anchor='center') - self.bg_canvas.tag_lower(self._bg_item) - - self.bg_canvas.bind('', _render_bg) - except Exception as e: - print(f"Error loading background: {e}") - - # Navigation menu items - self.nav_items = [ - ("Welcome", "welcome"), - ("Dependencies", "deps_summary"), - ("Network", "network_scan"), - ("System Environment", "env_setup"), - ("Cluster Environment", "init_cluster"), - ("Database Creation", "init_password"), - ("Docker Build", "init_db_build"), - ("Kerberos Authentication", "kerberos_config"), - ("Initialization Scripts", "init_scripts"), - ("Ollama", "ollama_config"), - ("Supabase", "supabase_config"), - ("Deployment", "init_cnpg_deploy"), - ("Post Install", "create_installer") - ] - self.nav_widgets = {} - self._create_sidebar_nav() - - # Initialize validation attributes before creating screens - self.validation_running = False - self.validation_thread = None - # Welcome/splash dependency scan state - self._welcome_scan_started = False - self.splash_scan_running = False - self.splash_scan_done_at = None - self.splash_scan_started_at = None - self._splash_status_item = None - # Capture expected host (short hostname) at app start for safety guards - try: - self.expected_host = (platform.node() or socket.gethostname()).split('.')[0] - except Exception: - self.expected_host = None - - # Content will be rendered directly on the background canvas to avoid - # any opaque rectangles obscuring the image. - self._canvas_page = None - self._canvas_items = [] - self.canvas_renderers = {} - # Keep track of small overlay widgets placed above the canvas so we can - # cleanly remove them on page switches (e.g., radiobuttons, consoles) - self._overlay_widgets = [] - # Cursor blink timer id for command preview - self._cursor_blink_after_id = None - self._cursor_blink_visible = False - self._k3s_status_after_id = None - self._k3s_status_active = False - self._k3s_status_inflight = False - self._k3s_service_status_console = None - self._k3s_service_deploy_console = None - self._k3s_service_notebook = None - self._k3s_service_status_tab = None - self._k3s_service_deploy_tab = None - self._common_services_success = False - self._showing_service_overlay = False - self._last_registry_log_path = None - self._last_common_services_log_path = None - - # Shared dependency catalog from installer.config - self.dependencies = list(inst_config.DEPENDENCIES) - - # Docker import directory configuration - self.docker_import_dir = tk.StringVar() - - # Initialize prole.cfg data structure - 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 = {} - self._secrets_finalized = False - self.service_namespace = tk.StringVar(value='default') - - # Try to load existing docker_import_dir from prole.cfg - try: - conf_dir = self._resolve_prole_conf_dir() - cfg_path = conf_dir / 'prole.cfg' - if self._cfg_path_override: - cfg_path = self._cfg_path_override - - if cfg_path.exists(): - cfg = configparser.ConfigParser(interpolation=None) - cfg.optionxform = str - cfg.read(cfg_path) - cfg_vars = _collect_cfg_vars(cfg) - if cfg.has_section('Global'): - if 'DOCKER_IMPORT_DIR' in cfg['Global']: - docker_dir = _expand_cfg_value(cfg['Global']['DOCKER_IMPORT_DIR'], cfg_vars) - self.docker_import_dir.set(docker_dir) - self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = docker_dir - # Preload saved cluster env and namespace if available - saved_cluster = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', ''), cfg_vars).strip() - saved_ns = _expand_cfg_value(cfg['Global'].get('NAMESPACE', ''), cfg_vars).strip() - saved_service_ns = _expand_cfg_value(cfg['Global'].get('SERVICE_NAMESPACE', ''), cfg_vars).strip() - if saved_cluster: - try: - self.cluster_env.set(saved_cluster) - except Exception: - pass - if saved_ns: - # Persist for later use (StringVar may not be initialized yet) - try: - self.db_namespace.set(saved_ns) - except Exception: - # Defer applying to after initialization - pass - try: - self.prole_cfg_data.setdefault('Global', {})['NAMESPACE'] = saved_ns - except Exception: - pass - if saved_service_ns: - try: - self.service_namespace.set(saved_service_ns) - except Exception: - pass - self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = saved_service_ns - except Exception: - pass - self.prole_cfg_data['Global'].setdefault('SERVICE_NAMESPACE', self.service_namespace.get()) - - # Disk selection variables - self.selected_disk_type = tk.StringVar(value='local') # 'removable' or 'local' - self.selected_removable_disk = tk.StringVar() - self.selected_local_path = tk.StringVar(value=str(Path.home())) - self.removable_disks = [] # List of (name, mount_point) - - # Wizard pages setup - self.pages = [] # list of (page_id, frame) - self.page_index = 0 - - # Initialize variables for new Initialize screens - self.cluster_env = tk.StringVar(value='dev') - self.k3d_cluster_name = tk.StringVar(value='prole-dev-cluster') - self.k3d_cluster_list = tk.Variable(value=[]) - self.selected_k3d_cluster = tk.StringVar() - - # Pre-populate selected_k3d_cluster from prole.cfg if applicable - try: - cfg_env = (self.prole_cfg_data.get('Initialize Cluster', {}) or {}).get('ENVIRONMENT', '') - if _normalize_cluster_env(cfg_env) == 'dev': - self.selected_k3d_cluster.set(cfg_env) - except Exception: - pass - - self.deploy_target = tk.StringVar(value=_deployment_target_label(self.cluster_env.get())) - self._syncing_deploy_target = False - self._deploy_target_trace = self.deploy_target.trace_add('write', self._on_deploy_target_change) - self.kubectx_list = tk.Variable(value=self._get_kubectx_list()) - self.selected_kubectx = tk.StringVar() - self.prod_artifacts_path = tk.StringVar(value=str(PROJECT_ROOT / "data" / "staging")) - self.k3s_server_url = tk.StringVar() - self.k3s_token = tk.StringVar() - self.k3s_services_status = { - "registry": tk.StringVar(value="Unknown"), - "openbao": tk.StringVar(value="Unknown"), - "opentofu": tk.StringVar(value="Unknown"), - } - try: - self.db_username = tk.StringVar(value=os.getlogin()) - except Exception: - self.db_username = tk.StringVar(value="prole") - self.db_password = tk.StringVar() - self.db_password_confirm = tk.StringVar() - self.namespace_owner = self._get_local_owner() - self.db_namespace = tk.StringVar(value=self._initial_namespace()) - # If prole.cfg provided a namespace, prefer it as the initial selection - try: - _ns_from_cfg = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip() - if _ns_from_cfg: - self.db_namespace.set(self._ensure_namespace_prefix(_ns_from_cfg)) - except Exception: - pass - self.db_host_port = tk.StringVar(value='5432') - self.db_namespace_suffix = tk.StringVar() - self._updating_namespace_fields = False - self._sync_namespace_suffix_from_full() - self.db_namespace.trace_add('write', lambda *args: self._sync_namespace_suffix_from_full()) - self.db_namespace_suffix.trace_add('write', lambda *args: self._sync_namespace_full_from_suffix()) - - # Kerberos variables - self.kerberos_enabled = tk.BooleanVar(value=False) - self.kerberos_realm = tk.StringVar() - self.kerberos_user = tk.StringVar() - self.kerberos_password = tk.StringVar() - self.kerberos_kdc = tk.StringVar() - self.supabase_enabled = tk.BooleanVar(value=False) - self.at_rest_encryption_enabled = tk.BooleanVar(value=True) - self.ollama_server_host = tk.StringVar() - self.ollama_server_port = tk.StringVar(value=DEFAULT_OLLAMA_PORT) - self.ollama_model = tk.StringVar() - self._ollama_servers_cache = [] - self._ollama_model_options = {} - self.ansible_topology = {} - self.ansible_topology_summary = '' - try: - self._apply_ansible_topology_defaults() - except Exception: - pass - - # Load any encrypted secrets from existing prole.cfg to allow resume - try: - self._load_secret_cache_from_cfg() - except Exception: - pass - try: - self._apply_cluster_env_default_from_cfg() - except Exception: - pass - try: - self._apply_k3s_defaults() - except Exception: - pass - try: - self._apply_ollama_defaults() - except Exception: - pass - - # Create pages - self.page_frames = {} - self.verify_mode = tk.BooleanVar(value=False) - # Register page ids (renderers will draw directly on canvas) - self._register_canvas_renderer('welcome', self._render_welcome_page) - self._register_canvas_renderer('network_scan', self._render_network_scan_page) - self._register_canvas_renderer('env_setup', self._render_env_setup_page) - self._register_canvas_renderer('deps_summary', self._render_deps_summary_page) - for dep in self.dependencies: - self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d)) - - self._register_canvas_renderer('kerberos_config', self._render_kerberos_config_page) - self._register_canvas_renderer('ollama_config', self._render_ollama_config_page) - self._register_canvas_renderer('supabase_config', self._render_supabase_config_page) - - # New Initialize screens - self._register_canvas_renderer('init_cluster', self._render_init_cluster_page) - self._register_canvas_renderer('init_db_build', self._render_init_db_build_page) - self._register_canvas_renderer('init_password', self._render_init_password_page) - self._register_canvas_renderer('init_cnpg_deploy', self._render_init_cnpg_deploy_page) - self._register_canvas_renderer('init_scripts', self._render_init_scripts_page) - - self._register_canvas_renderer('build', self._render_build_page) - self._register_canvas_renderer('disk_selection', self._render_disk_selection_page) - self._register_canvas_renderer('build_summary', self._render_build_summary_page) - self._register_canvas_renderer('create_installer', self._render_create_installer_page) - - # Mirror old pages list ordering for navigation - self._register_page('welcome', None) - self._register_page('deps_summary', None) - for dep in self.dependencies: - self._register_page(f"dep_{dep['id']}", None) - self._register_page('network_scan', None) - self._register_page('env_setup', None) - self._register_page('init_cluster', None) - self._register_page('init_password', None) - self._register_page('init_db_build', None) - self._register_page('kerberos_config', None) - self._register_page('init_scripts', None) - self._register_page('ollama_config', None) - self._register_page('supabase_config', None) - - # New Initialize screens in sequence - self._register_page('init_cnpg_deploy', None) - self._register_page('build', None) - self._register_page('disk_selection', None) - self._register_page('build_summary', None) - self._register_page('create_installer', None) - - # Initial render after window shows - self.root.after(100, lambda: self.selected_local_path.set(str(self._get_prole_dist_dir()))) - self.update_footer() - self.show_page(0) - - # ---------------- App identity (title, Dock icon) ---------------- - def _set_app_identity(self): - """Set the installer identity: process/menu name and Dock icon on macOS. - - Notes: - - We set Tk's appname for consistency. - - On macOS, attempt to set the process/menu name to 'Prole Database Installer' - via NSProcessInfo if PyObjC is available. - - Prefer the Prole Tools.app .icns from the built app; fallback to local PNG/GIF. - """ - # Set Tk application name - try: - self.root.tk.call('tk', 'appname', 'Prole Database Installer') - except Exception: - pass - - # macOS: set process/menu name and Dock icon via AppKit/Foundation - if platform.system() == 'Darwin': - # Try to set the visible process name for the menu bar - try: - from Foundation import NSProcessInfo - NSProcessInfo.processInfo().setProcessName_("Prole Database Installer") - except Exception: - pass - - # Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Database Installer' - try: - from AppKit import NSApplication - app = NSApplication.sharedApplication() - main_menu = app.mainMenu() - if main_menu is not None and main_menu.numberOfItems() > 0: - first_item = main_menu.itemAtIndex_(0) - if first_item is not None: - first_item.setTitle_("Prole Database Installer") - except Exception: - pass - - icns_candidates = [ - get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole Tools.icns'), - get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole.icns'), - ] - icns_path = next((p for p in icns_candidates if p.exists()), None) - if icns_path is not None: - try: - # Use PyObjC if available - from AppKit import NSApplication, NSImage - img = NSImage.alloc().initWithContentsOfFile_(str(icns_path)) - if img is not None: - NSApplication.sharedApplication().setApplicationIconImage_(img) - return - except Exception: - pass - - # If no .icns was found, try setting Dock icon from configured PNG - try: - from AppKit import NSApplication, NSImage - cfg_icon = inst_config.get_ui_icon_image_path() - if cfg_icon.exists(): - png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon)) - if png_img is not None: - NSApplication.sharedApplication().setApplicationIconImage_(png_img) - # do not return; still set Tk icon below for consistency - except Exception: - pass - - # Fallback: Tk icon from image assets (PNG/GIF) - # Prefer config-defined icon image - try: - cfg_icon = inst_config.get_ui_icon_image_path() - except Exception: - cfg_icon = get_resource_path('img/proleIcon.png') - img_candidates = [ - cfg_icon, - get_resource_path('img/prole-type.png'), - get_resource_path('img/prole-type.gif'), - get_resource_path('img/Prole.png'), - get_resource_path('img/proleLogoSepia.png'), - ] - for p in img_candidates: - try: - if p.exists(): - self._app_iconphoto = tk.PhotoImage(file=str(p)) - try: - self.root.iconphoto(True, self._app_iconphoto) - except Exception: - pass - break - except Exception: - continue - - def show_config_dialog(self): - """Show configuration dialog for Docker image import directory.""" - dialog = tk.Toplevel(self.root) - dialog.title("Configuration") - dialog.geometry("500x200") - dialog.resizable(False, False) - dialog.transient(self.root) - dialog.grab_set() - - # Center on parent - x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 250 - y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 100 - dialog.geometry(f"+{x}+{y}") - - container = tk.Frame(dialog, padx=20, pady=20) - container.pack(fill='both', expand=True) - - tk.Label(container, text="Docker Image Import Directory:", font=('SF Pro Text', 12, 'bold')).pack(anchor='w') - tk.Label(container, text="Images found here will be loaded instead of pulled from Docker Hub.", - font=('SF Pro Text', 10), fg='#666666').pack(anchor='w', pady=(0, 10)) - - row = tk.Frame(container) - row.pack(fill='x') - - entry = tk.Entry(row, textvariable=self.docker_import_dir, font=('SF Pro Text', 11)) - entry.pack(side='left', fill='x', expand=True, padx=(0, 5)) - - def browse(): - path = filedialog.askdirectory(initialdir=self.docker_import_dir.get() or Path.home()) - if path: - self.docker_import_dir.set(path) - - tk.Button(row, text="Browse...", command=browse).pack(side='right') - - def save(): - path = self.docker_import_dir.get().strip() - self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = path - self._save_prole_cfg() - dialog.destroy() - - btns = tk.Frame(container) - btns.pack(fill='x', pady=(20, 0)) - - tk.Button(btns, text="Save", command=save, width=10).pack(side='right') - tk.Button(btns, text="Cancel", command=dialog.destroy, width=10).pack(side='right', padx=10) - - def configure_styles(self): - """Configure ttk styles""" - base_bg = 'white' - self.style.configure('TFrame', background='white') - self.style.configure('TLabel', background='white', foreground='black', font=('SF Pro Text', 11)) - self.style.configure('Header.TLabel', font=('SF Pro Text', 16, 'bold')) - self.style.configure('Small.TLabel', font=('SF Pro Text', 10)) - - # Consistent button styling - self.style.configure('Nav.TButton', - font=('SF Pro Text', 11), - padding=(12, 8), - background='#F5F5DC', - bordercolor='#F5F5DC', - lightcolor='#F5F5DC', - darkcolor='#F5F5DC', - relief='flat') - - # Map state colors to avoid black boxes on hover/active - self.style.map('Nav.TButton', - background=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], - bordercolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], - lightcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], - darkcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')]) - - # Notebook styling to match light theme and avoid dark mode shifts on macOS - self.style.theme_use('default') - self.style.configure('TNotebook', background='white', borderwidth=0, highlightthickness=0) - self.style.configure('TNotebook.Tab', - background='#F5F5DC', - foreground='black', - lightcolor='#F5F5DC', - bordercolor='#CCCCCC', - darkcolor='#F5F5DC', - borderwidth=1, - padding=[10, 5]) - self.style.map('TNotebook.Tab', - background=[('selected', 'white')], - bordercolor=[('selected', '#CCCCCC')], - lightcolor=[('selected', 'white')], - focuscolor=[('selected', 'white')]) - - # Ensure common controls inherit white background - try: - self.style.configure('TCheckbutton', background=base_bg, foreground='black') - self.style.configure('TCombobox', fieldbackground='white', background=base_bg) - except Exception: - pass - - def _apply_ansible_topology_defaults(self): - info = _detect_ansible_topology(PROJECT_ROOT) - if not info: - return - self.ansible_topology = info - self.ansible_topology_summary = _format_ansible_topology_summary(info) - 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 - - try: - if not self.kerberos_kdc.get().strip() and info.get('kdc_ip'): - self.kerberos_kdc.set(info['kdc_ip']) - self.kerberos_enabled.set(True) - if not self.kerberos_realm.get().strip() and info.get('realm'): - self.kerberos_realm.set(info['realm']) - except Exception: - pass - - def _read_k3s_cfg_values(self) -> tuple[str, str, str]: - """Return (cluster_env, server_url, token) from prole.cfg if present.""" - cfg_path = None - try: - if self._cfg_path_override is not None: - cfg_path = self._cfg_path_override - if cfg_path.is_dir(): - cfg_path = cfg_path / 'prole.cfg' - else: - cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' - except Exception: - cfg_path = None - if not cfg_path or not Path(cfg_path).exists(): - return '', '', '' - - cfg = configparser.ConfigParser(interpolation=None) - cfg.optionxform = str - try: - cfg.read(cfg_path) - except Exception: - return '', '', '' - cfg_vars = _collect_cfg_vars(cfg) - - env_val = '' - server_val = '' - token_val = '' - if cfg.has_section('Global'): - env_val = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', env_val), cfg_vars).strip() - server_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_SERVER', server_val), cfg_vars).strip() - if not server_val: - server_val = _expand_cfg_value(cfg['Global'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() - token_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_TOKEN', token_val), cfg_vars).strip() - if not token_val: - token_val = _expand_cfg_value(cfg['Global'].get('K3S_TOKEN', token_val), cfg_vars).strip() - if cfg.has_section('Initialize Cluster'): - server_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() - token_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_TOKEN', token_val), cfg_vars).strip() - if cfg.has_section('Service Cluster (k3s)'): - server_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() - token_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_TOKEN', token_val), cfg_vars).strip() - - return env_val, server_val, token_val - - def _k3s_connection_raw(self) -> tuple[str, str]: - """Return (server_url, token) without attempting secret resolution.""" - server = (self.k3s_server_url.get() or os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() - token = (self.k3s_token.get() or os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() - if server and not server.startswith('http'): - server = f"https://{server}" - return server, token - - def _read_ollama_cfg_values(self) -> tuple[str, str, str]: - """Return (host, port, model) from prole.cfg if present.""" - cfg_path = None - try: - if self._cfg_path_override is not None: - cfg_path = self._cfg_path_override - if cfg_path.is_dir(): - cfg_path = cfg_path / 'prole.cfg' - else: - cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' - except Exception: - cfg_path = None - if not cfg_path or not Path(cfg_path).exists(): - return '', '', '' - - cfg = configparser.ConfigParser(interpolation=None) - cfg.optionxform = str - try: - cfg.read(cfg_path) - except Exception: - return '', '', '' - cfg_vars = _collect_cfg_vars(cfg) - - host_val = '' - port_val = '' - model_val = '' - host_url = '' - if cfg.has_section('Ollama'): - sec = cfg['Ollama'] - host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() - port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() - model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() - host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() - if cfg.has_section('Global'): - sec = cfg['Global'] - if not host_val: - host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() - if not port_val: - port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() - if not model_val: - model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() - if not host_url: - host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() - - if host_url and not host_val: - parsed_host, parsed_port = _parse_ollama_host(host_url) - if parsed_host: - host_val = parsed_host - if parsed_port and not port_val: - port_val = parsed_port - - return host_val, port_val, model_val - - def _apply_cluster_env_default_from_cfg(self): - cfg_env, _cfg_server, _cfg_token = self._read_k3s_cfg_values() - if not cfg_env: - return - try: - current = (self.cluster_env.get() or '').strip() - except Exception: - current = '' - if not current or current == 'dev': - try: - self.cluster_env.set(_cluster_env_radio_value(cfg_env)) - except Exception: - pass - self._set_deploy_target_from_cluster_env() - - def _apply_k3s_defaults(self): - # Environment overrides - env_server = (os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() - env_token = (os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() - env_token = _normalize_k3s_token(env_token) - if env_server and not self.k3s_server_url.get().strip(): - self.k3s_server_url.set(env_server) - if env_token and not self.k3s_token.get().strip(): - self.k3s_token.set(env_token) - - # Config defaults - _cfg_env, cfg_server, cfg_token = self._read_k3s_cfg_values() - if cfg_server and not self.k3s_server_url.get().strip(): - self.k3s_server_url.set(cfg_server) - if cfg_token and not self.k3s_token.get().strip(): - self.k3s_token.set(cfg_token) - - # Ansible defaults - info = getattr(self, 'ansible_topology', None) or _detect_ansible_topology(PROJECT_ROOT) - if info: - if info.get('k3s_server_url') and not self.k3s_server_url.get().strip(): - self.k3s_server_url.set(info['k3s_server_url']) - token_val = _normalize_k3s_token(info.get('k3s_token')) - if token_val and not self.k3s_token.get().strip(): - self.k3s_token.set(token_val) - - def _apply_ollama_defaults(self): - env_host = (os.environ.get('OLLAMA_SERVER_HOST') or '').strip() - env_port = (os.environ.get('OLLAMA_SERVER_PORT') or '').strip() - env_model = (os.environ.get('OLLAMA_MODEL') or '').strip() - env_url = (os.environ.get('OLLAMA_HOST') or '').strip() - if env_url and not env_host: - parsed_host, parsed_port = _parse_ollama_host(env_url) - if parsed_host: - env_host = parsed_host - if parsed_port and not env_port: - env_port = parsed_port - - if env_host and not self.ollama_server_host.get().strip(): - self.ollama_server_host.set(env_host) - if env_port and not self.ollama_server_port.get().strip(): - self.ollama_server_port.set(env_port) - if env_model and not self.ollama_model.get().strip(): - self.ollama_model.set(env_model) - - cfg_host, cfg_port, cfg_model = self._read_ollama_cfg_values() - if cfg_host and not self.ollama_server_host.get().strip(): - self.ollama_server_host.set(cfg_host) - if cfg_port and not self.ollama_server_port.get().strip(): - self.ollama_server_port.set(cfg_port) - if cfg_model and not self.ollama_model.get().strip(): - self.ollama_model.set(cfg_model) - - def _create_sidebar_nav(self): - """Create the left-hand navigation menu.""" - tk.Label(self.sidebar, text="INSTALLER", bg='#F5F5DC', fg='#8B8B7A', - font=('SF Pro Text', 10, 'bold'), anchor='w').pack(fill='x', padx=20, pady=(20, 10)) - - for text, page_id in self.nav_items: - lbl = tk.Label(self.sidebar, text=text, bg='#F5F5DC', fg='black', - font=('SF Pro Text', 11), anchor='w', padx=20, pady=5, cursor='hand2') - lbl.pack(fill='x') - lbl.bind('', lambda e, p=page_id: self.show_page(p)) - self.nav_widgets[page_id] = lbl - - def _update_nav_highlight(self, active_id): - """Highlight the current active page in the sidebar.""" - # Handle dep_id mapping to 'Dependencies' - if active_id.startswith('dep_'): - active_id = 'deps_summary' - - for page_id, widget in self.nav_widgets.items(): - if page_id == active_id: - widget.configure(bg='#E5E5D5', font=('SF Pro Text', 11, 'bold')) - else: - widget.configure(bg='#F5F5DC', font=('SF Pro Text', 11)) - - def create_navigation(self): - pass - - def show_screen(self, screen_id): - self.show_page(screen_id) - - def show_page(self, index_or_id): - self._showing_service_overlay = False - # Resolve index and page_id - old_idx = self.page_index - if isinstance(index_or_id, int): - idx = max(0, min(index_or_id, len(self.pages) - 1)) - else: - # Find index by page_id - idx = -1 - for i, (pid, _) in enumerate(self.pages): - if pid == index_or_id: - idx = i - break - - if idx == -1: - msg = f"Navigation error: page ID '{index_or_id}' not found." - print(f"[ERROR] {msg}") - try: - messagebox.showerror("Navigation Error", msg) - except Exception: - pass - return - - print(f"[DEBUG] Navigating from {old_idx} to {idx} (requested: {index_or_id})") - self.page_index = idx - pid, frame = self.pages[self.page_index] - - # Clear any previously drawn canvas content - self._clear_canvas_page() - self._update_nav_highlight(pid) - - # If the page has overlay widgets, we need to show the slide_area. - # Otherwise, we hide it so the canvas content is visible. - # Pages that use canvas drawing and need to show the background should have slide_area hidden. - canvas_only_pages = ( - 'welcome', 'network_scan', 'env_setup', 'kerberos_config', - 'deps_summary', 'init_cluster', 'init_password', 'init_db_build', - 'init_cnpg_deploy', 'init_scripts', 'build', 'disk_selection', 'build_summary', - 'supabase_config', 'ollama_config', 'create_installer' - ) - if pid in canvas_only_pages or pid.startswith('dep_'): - self.slide_area.place_forget() - else: - self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) - self.slide_area.lift() - - # Render the page directly on the canvas if we have a renderer - if pid in self.canvas_renderers: - try: - self.canvas_renderers[pid]() - except Exception as e: - # Fallback: show an error message on canvas - ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='black', font=('SF Pro Text', 11)) - elif frame is not None: - # Legacy fallback (should not be used) - frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9) - self.update_footer() - - # ---------------- Canvas page rendering ---------------- - def _register_canvas_renderer(self, page_id, func): - self.canvas_renderers[page_id] = func - - def _register_page(self, page_id, frame): - self.pages.append((page_id, frame)) - self.page_frames[page_id] = frame - - def _clear_canvas_page(self): - # Unbind common events to prevent "echo" or persistent behavior from previous pages - try: - self.bg_canvas.unbind('') - self.root.unbind('') - self.root.unbind('') - except Exception: - pass - - try: - self._stop_k3s_service_status_updates() - except Exception: - pass - - # Reset slide_area background to white for safety - try: - self.slide_area.configure(bg='white') - except Exception: - pass - - # Remove any small overlay widgets from the previous page - try: - if getattr(self, '_overlay_widgets', None): - for w in list(self._overlay_widgets): - try: - w.destroy() - except Exception: - try: - w.place_forget() - except Exception: - pass - self._overlay_widgets.clear() - except Exception: - pass - - # Clear page-specific canvas drawings (keep the background image) - if self._canvas_items: - for item in self._canvas_items: - try: - self.bg_canvas.delete(item) - except Exception: - pass - self._canvas_items.clear() - - # ---------------- Environment setup helpers ---------------- - def resolve_prole_home(self) -> Path: - """Return PROLE_HOME or default to $HOME/.prole as a Path (expanded).""" - val = os.environ.get('PROLE_HOME') - if val: - try: - return Path(val).expanduser() - except Exception: - pass - return Path.home() / '.prole' - - def ensure_prole_env(self) -> Path: - """Ensure $PROLE_HOME and env.sh exist and are readable. Create minimal env if missing. - - Returns the resolved PROLE_HOME Path. Raises Exception on failure. - """ - home = self.resolve_prole_home() - try: - home.mkdir(parents=True, exist_ok=True) - except Exception as e: - raise Exception(f"Failed to create PROLE_HOME at {home}: {e}") - env_file = home / 'env.sh' - if not env_file.exists(): - # Construct minimal values based on defaults with this home - vals = { - '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'), - } - self._save_env_to_file(vals) - # Validate readability - try: - data = env_file.read_text() - if not isinstance(data, str) or len(data) == 0: - raise Exception("env.sh is empty") - except Exception as e: - raise Exception(f"env.sh not readable at {env_file}: {e}") - return home - - def reload_env_from_shell(self) -> None: - """Reload environment by sourcing $PROLE_HOME/env.sh in a login shell and merging into os.environ.""" - home = self.ensure_prole_env() - env_file = home / 'env.sh' - # Use bash to source and print env in null-delimited form - 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 subprocess.CalledProcessError as e: - raise Exception(f"Failed to reload environment: {e}") - except Exception as e: - raise Exception(f"Failed to run bash to reload environment: {e}") - # Merge variables - try: - items = out.split(b'\x00') - for raw in items: - if not raw: - continue - try: - kv = raw.decode('utf-8', errors='ignore') - except Exception: - continue - if '=' not in kv: - continue - k, v = kv.split('=', 1) - # Avoid clobbering Python internals we rely on; safe list only - if k in ('PYTHONPATH', 'PYTHONHOME'): - continue - os.environ[k] = v - except Exception: - # Best-effort; ignore merge errors - pass - def _env_defaults(self) -> 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() - namespace = "" - try: - namespace = self.db_namespace.get() - except Exception: - 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, - } - - def _read_existing_env(self) -> dict: - # Best effort: read $PROLE_HOME/env.sh if present - env = {} - # Try current env var first - prole_home = os.environ.get('PROLE_HOME') - candidates = [] - if prole_home: - candidates.append(Path(prole_home).expanduser() / 'env.sh') - # Also check default location - 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 - # expect lines like: export NAME="value" - 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 _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: - val = self._resolve_env_value(key) - if val: - try: - return Path(val).expanduser() - except Exception: - pass - return Path.home() / '.prole' / default_suffix - - def _resolve_prole_conf_dir(self) -> Path: - return self._resolve_env_dir('PROLE_CONF', 'conf') - - def _resolve_prole_logs_dir(self) -> Path: - return self._resolve_env_dir('PROLE_LOGS', 'logs') - - def _registry_log_path(self) -> Path: - logs_dir = self._resolve_prole_logs_dir() - try: - logs_dir.mkdir(parents=True, exist_ok=True) - except Exception: - pass - path = logs_dir / "local_registry.log" - self._last_registry_log_path = path - return path - - def _common_services_log_path(self) -> Path: - logs_dir = self._resolve_prole_logs_dir() - try: - logs_dir.mkdir(parents=True, exist_ok=True) - except Exception: - pass - path = logs_dir / "init_common_services.log" - self._last_common_services_log_path = path - return path - - def _read_log_tail(self, path: Path, max_lines: int = 400) -> str: - try: - from collections import deque - with path.open('r', encoding='utf-8', errors='ignore') as handle: - tail = deque(handle, maxlen=max_lines) - return ''.join(tail) - except Exception: - return "" - - def _restore_common_services_log(self, console) -> bool: - path = getattr(self, '_last_common_services_log_path', None) - if not path: - candidate = self._common_services_log_path() - if candidate.exists(): - path = candidate - if not path or not path.exists(): - return False - content = self._read_log_tail(path) - if not content: - return False - try: - console.clear() - console.write(f"Log file: {path}\n\n") - console.write(content) - except Exception: - return False - return True - - def _ensure_prole_directories(self) -> None: - paths = { - 'PROLE_HOME': self._resolve_env_value('PROLE_HOME', str(Path.home() / '.prole')), - 'PROLE_CONF': str(self._resolve_prole_conf_dir()), - 'PROLE_DATA': str(self._resolve_env_dir('PROLE_DATA', 'data')), - 'PROLE_LOGS': str(self._resolve_prole_logs_dir()), - 'PROLE_SERVICE': str(self._resolve_env_dir('PROLE_SERVICE', 'etc')), - } - for key, raw in paths.items(): - try: - Path(raw).expanduser().mkdir(parents=True, exist_ok=True) - except Exception: - pass - if key not in os.environ and raw: - os.environ[key] = raw - - def _record_install_log(self, path) -> None: - if not path: - return - try: - p = Path(path) - except Exception: - return - if not hasattr(self, '_install_run_log_paths'): - self._install_run_log_paths = [] - sp = str(p) - if sp not in self._install_run_log_paths: - self._install_run_log_paths.append(sp) - - 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 _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 _namespace_prefix(self) -> str: - return NAMESPACE_PREFIX - - def _max_namespace_suffix_len(self) -> int: - return max(0, POSTGRES_DB_NAME_MAX_LEN - len(self._namespace_prefix())) - - def _strip_namespace_prefix(self, name: str) -> str: - prefix = self._namespace_prefix() - if name.startswith(prefix): - return name[len(prefix):] - return name - - def _ensure_namespace_prefix(self, name: str) -> str: - cleaned = (name or '').strip() - prefix = self._namespace_prefix() - if not cleaned: - return prefix - if cleaned.startswith(prefix): - return cleaned - return f"{prefix}{cleaned}" - - def _sync_namespace_suffix_from_full(self): - if self._updating_namespace_fields: - return - self._updating_namespace_fields = True - try: - full = (self.db_namespace.get() or '').strip() - full = self._ensure_namespace_prefix(full) - if full != self.db_namespace.get(): - self.db_namespace.set(full) - self.db_namespace_suffix.set(self._strip_namespace_prefix(full)) - finally: - self._updating_namespace_fields = False - - def _sync_namespace_full_from_suffix(self): - if self._updating_namespace_fields: - return - self._updating_namespace_fields = True - try: - suffix = (self.db_namespace_suffix.get() or '').strip() - self.db_namespace.set(f"{self._namespace_prefix()}{suffix}") - finally: - self._updating_namespace_fields = False - - def _validate_namespace_suffix(self, proposed: str) -> bool: - return len(proposed) <= self._max_namespace_suffix_len() - - 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 _parse_rfc3339(self, ts: str): - if not ts: - return None - try: - if ts.endswith('Z'): - ts = ts[:-1] + '+00:00' - return datetime.fromisoformat(ts) - except Exception: - return None - - def _format_pod_time(self, ts: str) -> str: - dt = self._parse_rfc3339(ts) - if not dt: - return ts or '—' - try: - return dt.astimezone().strftime('%Y-%m-%d %H:%M:%S') - except Exception: - return dt.strftime('%Y-%m-%d %H:%M:%S') - - def _collect_namespace_rows(self): - owner = self.namespace_owner or self._get_local_owner() - ns_names = [] - notice = "" - kubectl_ok = False - - try: - base_cmd = self._kubectl_base_cmd() - result = subprocess.run( - base_cmd + ['get', 'ns', '-o', 'json'], - capture_output=True, - text=True, - timeout=5 - ) - if result.returncode == 0: - kubectl_ok = True - data = json.loads(result.stdout or "{}") - for item in data.get('items', []): - name = item.get('metadata', {}).get('name') - if name: - ns_names.append(name) - else: - notice = (result.stderr or '').strip() or "kubectl returned a non-zero status." - except FileNotFoundError: - notice = "kubectl not found; showing local defaults." - except Exception as e: - notice = f"Unable to query namespaces: {e}" - - current_ns = (self.db_namespace.get() or '').strip() - prefix = self._namespace_prefix() - ns_names = [name for name in ns_names if name.startswith(prefix) or name == 'supabase'] - if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase') and current_ns not in ns_names: - ns_names.append(current_ns) - if not ns_names: - if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase'): - ns_names = [current_ns] - else: - ns_names = [] - - pods_by_ns = {} - if kubectl_ok: - try: - base_cmd = self._kubectl_base_cmd() - pods_result = subprocess.run( - base_cmd + ['get', 'pods', '--all-namespaces', '-o', 'json'], - capture_output=True, - text=True, - timeout=8 - ) - if pods_result.returncode == 0: - pods_data = json.loads(pods_result.stdout or "{}") - for pod in pods_data.get('items', []): - meta = pod.get('metadata', {}) - status = pod.get('status', {}) - ns = meta.get('namespace') - if not ns: - continue - entry = pods_by_ns.setdefault(ns, {'starts': [], 'running': False}) - start_time = status.get('startTime') - if start_time: - entry['starts'].append(start_time) - if status.get('phase') == 'Running': - entry['running'] = True - else: - notice = notice or (pods_result.stderr or '').strip() or notice - except Exception: - # Best effort only - pass - - rows = [] - for ns in sorted(set(ns_names)): - entry = pods_by_ns.get(ns, {'starts': [], 'running': False}) - oldest = None - if entry['starts']: - parsed = [self._parse_rfc3339(ts) for ts in entry['starts']] - parsed = [p for p in parsed if p is not None] - if parsed: - oldest_dt = min(parsed) - oldest = oldest_dt.isoformat() - else: - oldest = min(entry['starts']) - creation = self._format_pod_time(oldest) if oldest else '—' - status = 'active' if entry['running'] else 'inactive' - - # Best effort to find port - port = '—' - if ns == 'supabase': - port = '5432' - else: - # For prole-db namespaces - if ns == (self.db_namespace.get() or '').strip(): - if self.supabase_enabled.get(): - port = '15432' - else: - port = self.db_host_port.get() or '5432' - else: - # If it's another prole-db namespace, we might not know its port easily - # but if Supabase is enabled globally, we assume standard alternate port - if self.supabase_enabled.get(): - port = '15432' - else: - port = '5432' - - rows.append((ns, owner, port, creation, status)) - - return rows, notice - - def _refresh_namespace_table(self): - if getattr(self, '_refreshing_ns_table', False): - return - self._refreshing_ns_table = True - - def worker(): - try: - rows, notice = self._collect_namespace_rows() - current_ns = (self.db_namespace.get() or '').strip() - - def update_ui(): - tree = getattr(self, '_db_namespace_tree', None) - if not tree: - return - for item in tree.get_children(): - tree.delete(item) - - for row in rows: - # Add checkbox-like symbol for selection - ns_name = row[0] - prefix = ' [✓] ' if ns_name == current_ns else ' [ ] ' - display_row = (prefix,) + row - tree.insert('', 'end', values=display_row) - - if current_ns: - for item in tree.get_children(): - vals = tree.item(item, 'values') - if vals and vals[1] == current_ns: - tree.selection_set(item) - tree.see(item) - break - - note_item = getattr(self, '_db_namespace_note', None) - if note_item and self.bg_canvas.winfo_exists(): - msg = notice if notice else "" - self.bg_canvas.itemconfig(note_item, text=msg) - - self.safe_after(update_ui) - finally: - self._refreshing_ns_table = False - - threading.Thread(target=worker, daemon=True).start() - - def _db_set_status(self, message: str, color: str = '#6e6e73'): - note_item = getattr(self, '_db_namespace_note', None) - if note_item and self.bg_canvas.winfo_exists(): - self.bg_canvas.itemconfig(note_item, text=message or "", fill=color) - - def _db_set_buttons_state(self, state: str): - buttons = getattr(self, '_db_action_buttons', None) - if not buttons: - return - for btn in buttons: - try: - if btn.winfo_exists(): - btn.configure(state=state) - except Exception: - pass - - def _db_selected_namespace(self) -> str: - tree = getattr(self, '_db_namespace_tree', None) - if not tree: - return "" - sel = tree.selection() - if not sel: - return "" - vals = tree.item(sel[0], 'values') - if not vals: - return "" - ns = vals[1] - if ns == 'supabase': - return "" - return ns - - def _db_sync_supabase_ports(self, namespace): - if not self.supabase_enabled.get(): - return - - script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh" - if not script.exists(): - return - - def worker(): - try: - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - # Best effort: use kubectl context if selected - if hasattr(self, 'selected_kubectx') and self.selected_kubectx.get(): - env["KUBECONFIG"] = env.get("KUBECONFIG", "") # Ensure it's passed if set - - subprocess.run(["bash", str(script), "-n", namespace], - cwd=str(PROJECT_ROOT), env=env, - capture_output=True, text=True) - except: - pass - - threading.Thread(target=worker, daemon=True).start() - - def _db_action_log_path(self) -> Path: - logs_dir = PROJECT_ROOT / "logs" - try: - logs_dir.mkdir(parents=True, exist_ok=True) - except Exception: - pass - return logs_dir / "db-actions.log" - - def _db_log(self, text: str): - try: - with self._db_action_log_path().open('a', encoding='utf-8') as fp: - fp.write(text) - if not text.endswith('\n'): - fp.write('\n') - except Exception: - pass - - def _run_cmd_capture(self, cmd, env=None, stdin_text=None, timeout=None): - output_lines = [] - try: - # Ensure PYTHONUNBUFFERED=1 - run_env = (env or os.environ).copy() - run_env['PYTHONUNBUFFERED'] = '1' - proc = subprocess.Popen( - cmd, - stdin=subprocess.PIPE if stdin_text is not None else None, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=run_env - ) - except Exception as e: - return 1, f"Failed to start command: {e}" - - if stdin_text is not None and proc.stdin: - try: - proc.stdin.write(stdin_text) - proc.stdin.close() - except Exception: - pass - - try: - for line in proc.stdout: - output_lines.append(line) - except Exception: - pass - try: - rc = proc.wait(timeout=timeout) - except Exception: - try: - proc.kill() - except Exception: - pass - rc = 1 - return rc, "".join(output_lines) - - 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") or "http://127.0.0.1:18200").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 - - def _k3s_connection_info(self) -> tuple[str, str]: - server = (self.k3s_server_url.get() or os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() - token = (self.k3s_token.get() or os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() - if token: - token = self._resolve_secret_value(token) - token = _normalize_k3s_token(token) - if server and not server.startswith('http'): - server = f"https://{server}" - return server, token - - def _script_env_for_namespace(self, namespace: str) -> dict: - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - env["PROLE_DB_USER"] = self.db_username.get().strip() - if (self.db_password.get() or '').strip(): - env["DB_PASSWORD"] = self.db_password.get().strip() - env["OPENTOFU_ADMIN_PASSWORD"] = self.db_password.get().strip() - env["NAMESPACE"] = namespace - env["SERVICE_NAMESPACE"] = self._get_service_namespace() - mode = self._deployment_mode() - if mode: - env["PROLE_MODE"] = mode - env["DEPLOYMENT_MODE"] = mode - env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) - if self.kerberos_realm.get().strip(): - env["KRB5_REALM"] = self.kerberos_realm.get().strip() - env["REALM"] = self.kerberos_realm.get().strip() - env["DOMAIN"] = self.kerberos_realm.get().strip().lower() - if self.kerberos_kdc.get().strip(): - env["KRB5_KDC"] = self.kerberos_kdc.get().strip() - env["KRB5_ADMIN"] = self.kerberos_kdc.get().strip() - if self.kerberos_user.get().strip(): - env["KRB5_USER"] = self.kerberos_user.get().strip() - if self.kerberos_password.get().strip(): - env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() - if self._cluster_env_key() == 'dev': - # For k3d dev clusters, use the default kubeconfig (~/.kube/config) - if not (env.get("KUBECONFIG") or "").strip(): - cluster_name = (self.selected_k3d_cluster.get() or '').strip() - if cluster_name: - # Ensure k3d kubeconfig is merged and context is switched - try: - subprocess.run( - ['k3d', 'kubeconfig', 'merge', cluster_name, '--kubeconfig-switch-context'], - capture_output=True, text=True, timeout=10 - ) - except Exception: - pass - default_kube = str(Path.home() / '.kube' / 'config') - if Path(default_kube).exists(): - env["KUBECONFIG"] = default_kube - elif self._cluster_env_key() == 'service': - raw_server, raw_token = self._k3s_connection_raw() - if raw_server: - env["PROLE_K3S_SERVER"] = raw_server - if raw_token: - env["PROLE_K3S_TOKEN"] = raw_token - - k3s_server, k3s_token = self._k3s_connection_info() - explicit_kubeconfig = (env.get("KUBECONFIG") or "").strip() - if explicit_kubeconfig and not os.path.exists(explicit_kubeconfig): - explicit_kubeconfig = "" - managed_set = False - - if k3s_server and k3s_token and _looks_like_k8s_bearer_token(k3s_token) and not explicit_kubeconfig: - # Always generate a fresh kubeconfig to ensure no environment leakage - 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(k3s_server, k3s_token)) - env["KUBECONFIG"] = self._managed_kubeconfig - managed_set = True - except Exception as e: - print(f"[WARN] Failed to generate managed KUBECONFIG: {e}") - - if not managed_set: - fallback = _find_kubeconfig_file(env) - if fallback: - env["KUBECONFIG"] = fallback - - # Registry settings - 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 - - def _k3s_kubectl_base_cmd(self) -> list[str]: - if self._managed_kubeconfig and os.path.exists(self._managed_kubeconfig): - return ["kubectl", "--kubeconfig", self._managed_kubeconfig] - - server, token = self._k3s_connection_info() - if server and token: - # Token is already resolved by _k3s_connection_info - return [ - "kubectl", - "--server=" + server, - "--token=" + token, - "--insecure-skip-tls-verify=true" - ] - - kubeconfig = _find_kubeconfig_file() - if kubeconfig: - return ["kubectl", "--kubeconfig", kubeconfig] - return ["kubectl"] - - def _kubectl_base_cmd(self, env: str | None = None) -> list[str]: - env_key = self._cluster_env_key(env) - if env_key == 'service': - return self._k3s_kubectl_base_cmd() - - kubeconfig_env = (os.environ.get("KUBECONFIG") or "").strip() - if kubeconfig_env: - return ["kubectl", "--kubeconfig", kubeconfig_env] - - if env_key == 'prod': - kubeconfig = _find_kubeconfig_file() - if kubeconfig: - return ["kubectl", "--kubeconfig", kubeconfig] - - return ["kubectl"] - - def _db_add_namespace(self): - ns = (self.db_namespace.get() or '').strip() - if not ns: - messagebox.showerror('Database Name', 'Database namespace cannot be empty.') - return - if not self._is_valid_namespace(ns): - messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.') - return - self.db_namespace.set(ns) - - def worker(): - self.safe_after(lambda: self._db_set_buttons_state('disabled')) - self.safe_after(lambda: self._db_set_status(f"Creating namespace {ns}...", '#1d1d1f')) - cmd = self._kubectl_base_cmd() + ['create', 'namespace', ns] - rc, out = self._run_cmd_capture(cmd) - if rc != 0 and 'AlreadyExists' not in out: - self._db_log(out) - self.safe_after(self._refresh_namespace_table) - self.safe_after(lambda: self._db_set_status(f"Failed to create namespace {ns}. See logs/db-actions.log.", '#ff3b30')) - else: - self._update_env_namespace(ns) - self.safe_after(self._refresh_namespace_table) - self.safe_after(lambda: self._db_set_status(f"Namespace {ns} is ready.", '#34c759')) - self.safe_after(lambda: self._db_set_buttons_state('normal')) - - threading.Thread(target=worker, daemon=True).start() - - def _db_delete_namespace(self): - ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip() - if not ns: - messagebox.showerror('Delete Database', 'Select a database namespace to delete.') - return - if not messagebox.askyesno('Delete Database', f'Delete namespace "{ns}"? This cannot be undone.'): - return - - def worker(): - self.safe_after(lambda: self._db_set_buttons_state('disabled')) - self.safe_after(lambda: self._db_set_status(f"Deleting namespace {ns}...", '#1d1d1f')) - cmd = self._kubectl_base_cmd() + ['delete', 'namespace', ns] - rc, out = self._run_cmd_capture(cmd) - if rc != 0: - self._db_log(out) - self.safe_after(self._refresh_namespace_table) - self.safe_after(lambda: self._db_set_status(f"Failed to delete namespace {ns}. See logs/db-actions.log.", '#ff3b30')) - else: - self.safe_after(self._refresh_namespace_table) - self.safe_after(lambda: self._db_set_status(f"Namespace {ns} deleted.", '#34c759')) - self.safe_after(lambda: self._db_set_buttons_state('normal')) - - threading.Thread(target=worker, daemon=True).start() - - def _db_edit_namespace(self): - ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip() - if not ns: - messagebox.showerror('Edit Database', 'Select a database namespace to edit.') - return - self.db_namespace.set(ns) - if not self._is_valid_namespace(ns): - messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.') - return - p1 = self.db_password.get() - p2 = self.db_password_confirm.get() - if not p1: - messagebox.showerror('Password', 'Password cannot be empty.') - return - if p1 != p2: - messagebox.showerror('Password', 'Passwords do not match.') - return - - def worker(): - self.safe_after(lambda: self._db_set_buttons_state('disabled')) - self.safe_after(lambda: self._db_set_status(f"Recreating SSH key for {ns}...", '#1d1d1f')) - # We prefer ed25519, but fallback to rsa if not available - key_path = Path.home() / ".ssh" / "id_prole_ed25519" - pub_path = Path.home() / ".ssh" / "id_prole_ed25519.pub" - try: - if key_path.exists(): - key_path.unlink() - if pub_path.exists(): - pub_path.unlink() - except Exception: - pass - - cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()] - rc, out = self._run_cmd_capture(cmd) - if rc != 0: - self._db_log(f"ed25519 generation failed, falling back to rsa: {out}") - cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()] - rc, out = self._run_cmd_capture(cmd) - - self._db_log(out) - if rc != 0: - self.safe_after(lambda: self._db_set_status("Failed to recreate SSH key. See logs/db-actions.log.", '#ff3b30')) - self.safe_after(lambda: self._db_set_buttons_state('normal')) - return - - self.safe_after(lambda: self._db_set_status("Updating OpenBao...", '#1d1d1f')) - env = self._script_env_for_namespace(ns) - env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) - script_path = str(PROJECT_ROOT / "etc" / "init_openbao.sh") - rc2, out2 = self._run_cmd_capture(['bash', script_path, 'initialize'], env=env, stdin_text=f"{p1}\n") - self._db_log(out2) - if rc2 != 0: - self.safe_after(self._refresh_namespace_table) - self.safe_after(lambda: self._db_set_status("OpenBao update failed. See logs/db-actions.log.", '#ff3b30')) - else: - self._update_env_namespace(ns) - self.safe_after(self._refresh_namespace_table) - self.safe_after(lambda: self._db_set_status(f"Updated SSH key and OpenBao for {ns}.", '#34c759')) - self.safe_after(lambda: self._db_set_buttons_state('normal')) - - threading.Thread(target=worker, daemon=True).start() - - def _update_env_namespace(self, namespace: str): - try: - existing = self._read_existing_env() - defaults = self._env_defaults() - values = {**defaults, **existing} - values['NAMESPACE'] = namespace - self._save_env_to_file(values) - os.environ['NAMESPACE'] = namespace - except Exception as e: - print(f"[WARN] Failed to update env.sh namespace: {e}") - - def _save_env_to_file(self, values: dict): - home = Path(values['PROLE_HOME']).expanduser() - # ensure base dir exists - home.mkdir(parents=True, exist_ok=True) - # ensure subdirs exist - 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 - # write env.sh atomically - content = [] - # Executable wrapper + sourceable config - 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') - # Core PROLE_* directories - 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('') - # Ensure PATH contains common locations and $PROLE_HOME/bin (POSIX sh compatible) - 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' - - should_write = True - if out.exists(): - if out.read_text() == new_content: - should_write = False - # print(f"[OK] {out} is up to date.") - - if should_write: - tmp = home / 'env.sh.tmp' - tmp.write_text(new_content) - tmp.replace(out) - try: - os.chmod(out, 0o755) - except Exception: - pass - # print(f"[OK] Wrote {out}") - - # After creating env.sh, deploy additional resources as requested: - # 1) Deploy init-port-forward.sh into $PROLE_HOME (and ensure it's executable) - # 2) Copy contents of src/prole/etc (or fallback to top-level etc) into $PROLE_SERVICE - try: - prole_home = Path(values['PROLE_HOME']).expanduser() - prole_service = Path(values['PROLE_SERVICE']).expanduser() - - # Determine source for init-port-forward.sh - # Preferred location under repo: PROJECT_ROOT/src/prole/etc/init-port-forward.sh - init_pf_src_candidates = [ - PROJECT_ROOT / 'src' / 'prole' / 'etc' / 'init-port-forward.sh', - 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: - # best effort, ignore errors - pass - - # Determine etc directory source - etc_src_candidates = [ - PROJECT_ROOT / 'src' / 'prole' / 'etc', - PROJECT_ROOT / 'etc', - ] - etc_src = next((p for p in etc_src_candidates if p.exists() and p.is_dir()), None) - if etc_src is not None: - try: - prole_service.mkdir(parents=True, exist_ok=True) - except Exception: - pass - - # Copy files (non-recursive: contents of etc root). If subdirectories exist, copy them recursively. - import shutil - - def _copy_item(src_path: Path, dst_path: Path): - try: - if src_path.is_dir(): - # copy directory tree - if dst_path.exists(): - # remove then copy to keep in sync - shutil.rmtree(dst_path, ignore_errors=True) - shutil.copytree(src_path, dst_path) - else: - shutil.copy2(src_path, dst_path) - except Exception: - pass - - try: - for item in etc_src.iterdir(): - _copy_item(item, prole_service / item.name) - except Exception: - pass - except Exception: - # overall best-effort; do not fail env creation if copies fail - pass - - def _render_env_setup_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Personal Infrastructure.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - # Task heading for clarity - self._render_title('Environment', y=150) - self._render_paragraph('Configure your Prole environment. You can accept defaults or choose custom locations. Changing PROLE_HOME will update the defaults for other paths.', y=200) - - defaults = self._env_defaults() - existing = self._read_existing_env() - values = {**defaults, **existing} - - # Keep entry widgets to read values on Next - self._env_entries = {} - - labels = [ - ('PROLE_HOME', 'Base directory for data, configs, logs, and scripts'), - ('PROLE_CONF', 'Configuration directory'), - ('PROLE_DATA', 'Data directory'), - ('PROLE_LOGS', 'Logs directory'), - ('PROLE_SERVICE', 'System scripts directory'), - ] - - x_label = 48 - x_field = 300 - x_btn = 850 - y = 280 - row_h = 75 - - def browse(var_name: str): - from tkinter import filedialog - initial = self._env_entries[var_name].get() or Path.home() - path = filedialog.askdirectory(title=f"Select {var_name}", initialdir=str(Path(initial).expanduser())) - if path: - self._env_entries[var_name].delete(0, 'end') - self._env_entries[var_name].insert(0, path) - if var_name == 'PROLE_HOME': - # Update dependent defaults if they still match old pattern - self._update_dependent_env_paths() - - # Draw rows - for key, help_text in labels: - self._canvas_items.append(ui.canvas_text(self, x_label, y, key, fill='black', font=('SF Pro Text', 12, 'bold'), anchor='w')) - # Use tk.Entry and place on canvas via create_window - entry = tk.Entry(self.bg_canvas, bg='white', fg='black', insertbackground='black', - highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) - entry.insert(0, values[key]) - entry_window = self.bg_canvas.create_window(x_field, y, window=entry, anchor='w', width=500, height=32) - self._canvas_items.append(entry_window) - self._overlay_widgets.append(entry) - self._env_entries[key] = entry - - # Use tk.Button and place on canvas via create_window - btn = tk.Button(self.bg_canvas, text='Browse…', command=lambda k=key: browse(k), - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=10, pady=4) - btn_window = self.bg_canvas.create_window(x_btn, y, window=btn, anchor='w', width=120) - self._canvas_items.append(btn_window) - self._overlay_widgets.append(btn) - - # hint text - self._canvas_items.append(ui.canvas_text(self, x_field, y+24, help_text, fill='#6e6e73', font=('SF Pro Text', 10), anchor='nw')) - y += row_h - - # When PROLE_HOME changes, update dependent paths that still follow default pattern - def on_home_change(_evt=None): - self._update_dependent_env_paths() - try: - self._env_entries['PROLE_HOME'].bind('', on_home_change) - except Exception: - pass - - def _after_env_saved(self): - """Reload the installer process environment after saving env.sh.""" - try: - self.reload_env_from_shell() - # Ensure we capture PROLE_HOME in Global section - self.prole_cfg_data['Global']['PROLE_HOME'] = os.environ.get('PROLE_HOME', '') - self._save_prole_cfg() - except Exception as e: - try: - messagebox.showwarning('Environment', f'Environment saved, but reload failed: {e}') - except Exception: - pass - - def _collect_input_snapshot(self) -> dict: - """Collect all possible user inputs for prole.cfg replay.""" - inputs: dict[str, str] = {} - - def _set(key: str, val): - inputs[key] = '' if val is None else str(val) - - def _set_bool(key: str, val: bool): - inputs[key] = _bool_str(bool(val)) - - def _get_var(var, default=''): - try: - return var.get() - except Exception: - return default - - def _action(key: str, default: bool): - return bool(self._action_flags.get(key, default)) - - # Dependencies - _set_bool('dependencies.verify_all', _get_var(self.verify_mode, False)) - _set_bool('dependencies.auto_install_missing', _action('dependencies.auto_install_missing', DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True))) - for dep in self.dependencies: - dep_key = f"dependencies.{dep['id']}.install" - _set_bool(dep_key, _action(dep_key, True)) - - # Network scan - _set_bool('network_scan.run', _action('network_scan.run', DEFAULT_ACTION_FLAGS.get('network_scan.run', True))) - - # Environment setup values - env_vals = {} - try: - env_vals.update(self._env_defaults()) - except Exception: - pass - try: - env_vals.update(self._read_existing_env()) - except Exception: - pass - for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): - if os.environ.get(k): - env_vals[k] = os.environ.get(k) - if hasattr(self, '_env_entries'): - for k, ent in self._env_entries.items(): - try: - v = ent.get().strip() - if v: - env_vals[k] = v - except Exception: - pass - for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): - _set(f'env_setup.{k}', env_vals.get(k, '')) - ns_val = (str(_get_var(self.db_namespace, '')).strip()) if hasattr(self, 'db_namespace') else '' - if not ns_val: - ns_val = env_vals.get('NAMESPACE', '') - _set('env_setup.NAMESPACE', ns_val) - - # Database creation - _set('init_password.db_namespace', ns_val) - _set('init_password.db_username', _get_var(self.db_username, '')) - db_pw = _get_var(self.db_password, '') - db_pw_confirm = _get_var(self.db_password_confirm, '') or db_pw - db_pw_cfg = self._secret_cfg_value('Inputs', 'init_password.db_password', db_pw, 'db', 'password') - _set('init_password.db_password', db_pw_cfg) - _set('init_password.db_password_confirm', db_pw_cfg or db_pw_confirm) - _set('init_password.db_host_port', _get_var(self.db_host_port, '5432')) - _set_bool('init_password.generate_ssh_key', _action('init_password.generate_ssh_key', DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True))) - - # Build DB image - _set_bool('init_db_build.run_build', _action('init_db_build.run_build', DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True))) - - # Cluster init + optional features - _set('init_cluster.cluster_env', _get_var(self.cluster_env, 'dev')) - _set('init_cluster.mode', _deployment_mode_from_env(_get_var(self.cluster_env, 'dev'))) - _set('init_cluster.deployment_target', _deployment_target_label(_get_var(self.cluster_env, 'dev'))) - _set('init_cluster.k3s_server_url', _get_var(self.k3s_server_url, '')) - _set('init_cluster.k3s_token', _encrypt_cfg_secret(_get_var(self.k3s_token, ''))) - _set_bool('init_cluster.supabase_enabled', _get_var(self.supabase_enabled, False)) - _set_bool('init_cluster.kerberos_enabled', _get_var(self.kerberos_enabled, False)) - _set_bool('init_cluster.at_rest_encryption_enabled', _get_var(self.at_rest_encryption_enabled, False)) - _set_bool('init_cluster.start_cluster', _action('init_cluster.start_cluster', DEFAULT_ACTION_FLAGS.get('init_cluster.start_cluster', True))) - - # Kerberos config - _set_bool('kerberos_config.enabled', _get_var(self.kerberos_enabled, False)) - _set('kerberos_config.realm', _get_var(self.kerberos_realm, '')) - _set('kerberos_config.kdc', _get_var(self.kerberos_kdc, '')) - _set('kerberos_config.user', _get_var(self.kerberos_user, '')) - krb_pw = _get_var(self.kerberos_password, '') - _set('kerberos_config.password', self._secret_cfg_value('Inputs', 'kerberos_config.password', krb_pw, 'kerberos', 'password')) - _set_bool('kerberos_config.test_connection', _action('kerberos_config.test_connection', DEFAULT_ACTION_FLAGS.get('kerberos_config.test_connection', False))) - - # Ollama config - _set('ollama_config.server_host', _get_var(self.ollama_server_host, '')) - _set('ollama_config.server_port', _get_var(self.ollama_server_port, DEFAULT_OLLAMA_PORT)) - _set('ollama_config.model', _get_var(self.ollama_model, '')) - - # Init scripts + deploy - _set_bool('init_scripts.run_scripts', _action('init_scripts.run_scripts', DEFAULT_ACTION_FLAGS.get('init_scripts.run_scripts', True))) - _set_bool('init_cnpg_deploy.run_deploy', _action('init_cnpg_deploy.run_deploy', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True))) - _set_bool('init_cnpg_deploy.force_rollout', _action('init_cnpg_deploy.force_rollout', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.force_rollout', False))) - - # Disk selection (installer packaging) - _set('disk_selection.disk_type', _get_var(self.selected_disk_type, 'local')) - _set('disk_selection.removable_mount', _get_var(self.selected_removable_disk, '')) - _set('disk_selection.local_path', _get_var(self.selected_local_path, str(Path.home()))) - - # Build tools app - _set('build.deploy_env', getattr(self, 'deploy_env_value', 'Dev')) - _set_bool('build.run_build', _action('build.run_build', DEFAULT_ACTION_FLAGS.get('build.run_build', False))) - - return inputs - - def _is_port_available(self, port: int) -> bool: - """Check if a port is available on localhost.""" - import socket - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - try: - s.bind(('127.0.0.1', port)) - return True - except socket.error: - return False - - def _pf_extract_field(self, mapping_str: str, field: str) -> str: - if not mapping_str or not field: - return '' - needle = f"{field}=" - for part in str(mapping_str).split(';'): - part = part.strip() - if part.startswith(needle): - return part[len(needle):].strip() - return '' - - def _validate_port_forward_overlaps(self) -> bool: - try: - self._sync_port_forward_mappings() - except Exception: - pass - - try: - mode = self._deployment_mode() - except Exception: - mode = '' - if not mode: - try: - mode = _deployment_mode_from_env(self.cluster_env.get()) - except Exception: - mode = '' - 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', {}) or {} - - port_index = {} - for key, mapping in pf_section.items(): - if not key.startswith(prefix): - continue - host_port = self._pf_extract_field(mapping, 'hostPort') - if not host_port: - continue - mapping_id = _pf_extract_id(mapping) - target = self._pf_extract_field(mapping, 'target') - namespace = self._pf_extract_field(mapping, 'namespace') - label = mapping_id or target or key - if namespace and target: - label = f"{label} ({namespace}/{target})" - port_index.setdefault(host_port, []).append(label) - - collisions = {port: labels for port, labels in port_index.items() if len(labels) > 1} - if not collisions: - return True - - lines = [] - for port, labels in sorted(collisions.items(), key=lambda item: int(item[0]) if str(item[0]).isdigit() else item[0]): - lines.append(f"{port}: {', '.join(sorted(labels))}") - - try: - messagebox.showerror( - "Port Collision", - "Port forward mappings overlap:\n\n" - + "\n".join(lines) - + "\n\nPlease choose unique host ports before continuing." - ) - except Exception: - pass - return False - - def _show_port_error(self, port: int): - """Show error message when port is in use, with option to see lsof output.""" - msg = f"Port {port} is already in use on the local host.\n\nPlease choose a different port or stop the service using it." - - dialog = tk.Toplevel(self.root) - dialog.title("Port In Use") - dialog.geometry("450x200") - dialog.configure(bg='white') - dialog.transient(self.root) - dialog.grab_set() - - tk.Label(dialog, text="Port Conflict", font=('SF Pro Text', 14, 'bold'), bg='white', fg='#ff3b30').pack(pady=(20, 10)) - tk.Label(dialog, text=msg, font=('SF Pro Text', 11), bg='white', fg='black', wraplength=400).pack(pady=10) - - btn_frame = tk.Frame(dialog, bg='white') - btn_frame.pack(pady=20) - - def show_lsof(): - log_file = PROJECT_ROOT / "logs" / f"port_{port}_lsof.log" - log_file.parent.mkdir(parents=True, exist_ok=True) - try: - res = subprocess.run(['lsof', '-i', f':{port}'], capture_output=True, text=True) - content = res.stdout if res.stdout else f"No lsof output for port {port}. (Maybe permission denied?)" - if res.stderr: - content += "\n\nError:\n" + res.stderr - log_file.write_text(content) - # Open the log file - if platform.system() == 'Darwin': - subprocess.run(['open', str(log_file)]) - elif platform.system() == 'Windows': - os.startfile(str(log_file)) - else: - subprocess.run(['xdg-open', str(log_file)]) - except Exception as e: - messagebox.showerror("Error", f"Failed to run lsof: {e}") - - tk.Button(btn_frame, text="Show Logs (lsof)", command=show_lsof, bg='#F5F5DC', relief='flat', padx=10).pack(side='left', padx=10) - tk.Button(btn_frame, text="OK", command=dialog.destroy, bg='#007aff', fg='white', relief='flat', padx=20).pack(side='left', padx=10) - - def _secret_namespace(self) -> str: - ns = '' - try: - ns = (self.db_namespace.get() or '').strip() - except Exception: - ns = '' - if not ns: - ns = os.environ.get('NAMESPACE', '') or 'default' - return ns - - 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 - - # Try to reuse existing encrypted value if it decrypts to the same 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 _load_secret_cache_from_cfg(self): - try: - if self._cfg_path_override is not None: - cfg_path = self._cfg_path_override - if cfg_path.is_dir(): - cfg_path = cfg_path / 'prole.cfg' - else: - conf_dir = self._resolve_prole_conf_dir() - cfg_path = conf_dir / 'prole.cfg' - except Exception: - return - if not cfg_path.exists(): - return - cfg = configparser.ConfigParser(interpolation=None) - cfg.optionxform = str - cfg.read(cfg_path) - for (section, key), _spec in SECRET_KEY_SPECS.items(): - if cfg.has_option(section, key): - val = cfg.get(section, key, fallback="").strip() - if val: - self._cfg_secret_cache[(section, key)] = val - if _is_openbao_ref(val): - self._secrets_finalized = True - - def _resolve_cached(pairs: list[tuple[str, str]]) -> str: - for pair in pairs: - val = self._cfg_secret_cache.get(pair, '') - if val: - return val - return '' - - db_val = _resolve_cached([ - ("Inputs", "init_password.db_password"), - ("Global", "DB_PASSWORD"), - ]) - if db_val and _is_prole_secret(db_val): - db_plain = _decrypt_prole_secret(db_val) - elif db_val and not _is_openbao_ref(db_val): - db_plain = db_val - else: - db_plain = '' - if db_plain: - try: - self.db_password.set(db_plain) - self.db_password_confirm.set(db_plain) - except Exception: - pass - - krb_val = _resolve_cached([ - ("Inputs", "kerberos_config.password"), - ("Kerberos Authentication", "PASSWORD"), - ]) - if krb_val and _is_prole_secret(krb_val): - krb_plain = _decrypt_prole_secret(krb_val) - elif krb_val and not _is_openbao_ref(krb_val): - krb_plain = krb_val - else: - krb_plain = '' - if krb_plain: - try: - self.kerberos_password.set(krb_plain) - except Exception: - pass - - def _sanitize_sections_for_cfg(self, sections: dict) -> dict: - sanitized = {k: dict(v) for k, v in sections.items()} - # Kerberos password - 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" - ) - # Monitoring (Grafana) - 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 - - def _save_ansible_prole_vault(self, root_password: str): - if not root_password: - return - - vault_path = PROJECT_ROOT / 'infrastructure' / 'inventory' / 'group_vars' / 'all' / 'prole_vault.yml' - - _ensure_ansible_vault_credentials(prompt_ui=False) - vault_pass_file = os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') - - if not vault_pass_file or not Path(vault_pass_file).exists(): - for base in (Path.cwd(), PROJECT_ROOT): - candidate = base / '.vault_pass' - if candidate.is_file(): - vault_pass_file = str(candidate) - break - - if not vault_pass_file or not Path(vault_pass_file).exists(): - return - - if shutil.which('ansible-vault') is None: - return - - try: - # We must decrypt if it is a prole secret - plain_pass = root_password - if _is_prole_secret(root_password): - try: - plain_pass = _decrypt_prole_secret(root_password) - except Exception: - pass - - content = f"prole_root_password: \"{plain_pass}\"\n" - - with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: - f.write(content) - tmp_name = f.name - - try: - os.chmod(tmp_name, 0o600) - subprocess.run([ - 'ansible-vault', 'encrypt', tmp_name, - '--vault-password-file', vault_pass_file - ], check=True, capture_output=True) - - shutil.move(tmp_name, str(vault_path)) - os.chmod(str(vault_path), 0o600) - finally: - if os.path.exists(tmp_name): - try: os.unlink(tmp_name) - except: pass - except Exception as e: - print(f"[ERROR] Failed to save ansible vault: {e}") - - def _sync_port_forward_mappings(self): - try: - mode = self._deployment_mode() - except Exception: - mode = '' - if not mode: - try: - mode = _deployment_mode_from_env(self.cluster_env.get()) - except Exception: - mode = '' - 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 = {} - - try: - service_ns = (self._get_service_namespace() or '').strip() - except Exception: - service_ns = '' - if not service_ns: - service_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' - - try: - db_ns = (self.db_namespace.get() or '').strip() - except Exception: - db_ns = '' - 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' - - try: - db_host_port = (self.db_host_port.get() or '').strip() - except Exception: - db_host_port = '' - 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 = False - try: - supabase_enabled = bool(self.supabase_enabled.get()) - except Exception: - 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 - self._update_legacy_port_mapping() - - def _update_legacy_port_mapping(self): - conf_dir = PROJECT_ROOT / 'conf' - mapping_path = conf_dir / 'port-mapping.cfg' - - pf_section = self.prole_cfg_data.get('Port Forwards', {}) - if pf_section is None: - pf_section = {} - - try: - mode = self._deployment_mode() - except Exception: - mode = 'k3d' - prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" - - lines = [ - "# Port mappings for Prole Tools (generated).", - "# Format: key: local=... remote=... ns=... svc=... address=...", - "" - ] - - # We want to maintain some order or just dump them - for k, v in sorted(pf_section.items()): - if k.startswith(prefix): - # Parse the mapping string: id=...;namespace=...;target=...;address=...;hostPort=...;servicePort=...;protocol=...;description=... - parts = {} - for p in v.split(';'): - if '=' in p: - key_val = p.split('=', 1) - if len(key_val) == 2: - parts[key_val[0].strip()] = key_val[1].strip() - - if 'id' in parts: - m_id = parts['id'] - local = parts.get('hostPort', '') - remote = parts.get('servicePort', '') - ns = parts.get('namespace', '') - target = parts.get('target', '') - addr = parts.get('address', '0.0.0.0') - - svc = target - if svc.startswith('svc/'): - svc = svc[4:] - - lines.append(f"{m_id}: local={local} remote={remote} ns={ns} svc={svc} address={addr}") - - try: - mapping_path.write_text('\n'.join(lines) + '\n') - print(f"[DEBUG] port-mapping.cfg updated at {mapping_path}") - except Exception as e: - print(f"[ERROR] Failed to update port-mapping.cfg: {e}") - - def _save_prole_cfg(self): - """Generates prole.cfg; master configuration file containing all values used in install process.""" - try: - # Check port availability before saving - # try: - # port_val = int(self.db_host_port.get().strip()) - # if not self._is_port_available(port_val): - # self._show_port_error(port_val) - # return - # except ValueError: - # messagebox.showerror("Invalid Port", "Please enter a valid numeric port.") - # return - - # Determine path: conf/prole.cfg - # If PROLE_CONF is set, use it. Otherwise fallback to PROJECT_ROOT/conf - if self._cfg_path_override is not None: - cfg_path = self._cfg_path_override - if cfg_path.is_dir(): - conf_dir = cfg_path - cfg_path = conf_dir / 'prole.cfg' - else: - conf_dir = cfg_path.parent - else: - prole_conf = os.environ.get('PROLE_CONF') - if prole_conf: - conf_dir = Path(prole_conf).expanduser() - else: - conf_dir = PROJECT_ROOT / 'conf' - os.environ.setdefault('PROLE_CONF', str(conf_dir)) - cfg_path = conf_dir / 'prole.cfg' - - conf_dir.mkdir(parents=True, exist_ok=True) - - # Identify global candidates - mode = self._deployment_mode() - target_label = _deployment_target_label(self.cluster_env.get()) - globals_to_save = { - 'PROLE_HOME': os.environ.get('PROLE_HOME', ''), - 'PROLE_DB_USER': self.db_username.get(), - 'DB_PASSWORD': self._secret_cfg_value('Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password'), - 'CLUSTER_ENV': self.cluster_env.get(), - 'DEPLOYMENT_MODE': mode, - 'DEPLOYMENT_TARGET': target_label, - 'NAMESPACE': (self.db_namespace.get() or '').strip(), - 'DB_HOST_PORT': (self.db_host_port.get() or '5432').strip(), - 'PROLE_OPENTOFU_URL': _default_opentofu_pipeline_url(), - } - # Add any other values already in self.prole_cfg_data['Global'] - globals_to_save.update(self.prole_cfg_data.get('Global', {})) - globals_to_save['DB_PASSWORD'] = self._secret_cfg_value( - 'Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password' - ) - globals_to_save['DEPLOYMENT_MODE'] = mode - globals_to_save['DEPLOYMENT_TARGET'] = target_label - globals_to_save['PROLE_K3S_SERVER'] = (self.k3s_server_url.get() or '').strip() - globals_to_save['PROLE_K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') - globals_to_save['SERVICE_NAMESPACE'] = self._get_service_namespace() - - self._sync_port_forward_mappings() - - # Inputs section (replayable UI inputs) - try: - inputs = self._collect_input_snapshot() - except Exception: - inputs = {} - - sections = {k: self.prole_cfg_data.get(k, {}) for k in [ - 'Welcome', 'Dependencies', 'Network', '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': 'dev', - 'DISPLAY_NAME': 'prole-dev-cluster', - 'KUBECTL_CONTEXT': (self.selected_kubectx.get() or '').strip() - } - 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.k3s_server_url.get() or '').strip(), - 'K3S_TOKEN': _encrypt_cfg_secret(self.k3s_token.get() or ''), - '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.prod_artifacts_path.get() or '').strip(), - 'PIPELINE_URL': _default_opentofu_pipeline_url() - } - sections = self._sanitize_sections_for_cfg(sections) - - # Sync to Ansible Prole Vault - self._save_ansible_prole_vault(self.db_password.get()) - - cfg_text = _render_prole_cfg(inputs, globals_to_save, sections) - cfg_path.write_text(cfg_text) - print(f"[DEBUG] prole.cfg saved to {cfg_path}") - - except Exception as e: - print(f"[ERROR] Failed to save prole.cfg: {e}") - - 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._save_prole_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._save_prole_cfg() - - if "PORT_FORWARD_MAPPING:" in line: - mapping = line.split("PORT_FORWARD_MAPPING:")[1].strip() - if mapping: - if _pf_extract_id(mapping) == "registry": - return - self._add_port_mapping(mapping) - - def _update_dependent_env_paths(self): - try: - new_home = Path(self._env_entries['PROLE_HOME'].get()).expanduser() - except Exception: - return - mapping = { - 'PROLE_CONF': new_home / 'conf', - 'PROLE_DATA': new_home / 'data', - 'PROLE_LOGS': new_home / 'logs', - 'PROLE_SERVICE': new_home / 'etc', - } - # Update entries if they are empty or previously matched the old base - for key, new_path in mapping.items(): - ent = self._env_entries.get(key) - if not ent: - continue - cur = ent.get().strip() - if not cur: - ent.delete(0, 'end') - ent.insert(0, str(new_path)) - continue - # If cur looked like old_home/, update it - try: - # detect suffix - suffix = new_path.name - if cur.endswith('/' + suffix): - # replace base path - ent.delete(0, 'end') - ent.insert(0, str(new_path)) - except Exception: - pass - # done - - def _render_title(self, text, y=40): - ui.render_title(self, text, y) - - def _render_paragraph(self, text, y, wrap=600): - ui.render_paragraph(self, text, y, wrap) - - def _create_console_output(self, y=280, title="Scan Output", width=880, height=450): - """Create a standardized console output area with a label and scrollable text.""" - # Section title/label for the console - ui.canvas_text(self, 48, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) - - y_console = y + 30 - # Use a background frame to ensure NO borders are visible around the console - console_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) - console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) - console.pack(fill='both', expand=True, padx=1, pady=1) - - console_window = self.bg_canvas.create_window(48, y_console, window=console_bg, - anchor='nw', width=width, height=height) - self._canvas_items.append(console_window) - self._overlay_widgets.append(console_bg) - self._overlay_widgets.append(console) - return console - - def _render_welcome_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - # Welcome title - self._render_title('Welcome', y=150) - - welcome_text = ( - "This installer will guide you through the process of setting up the Prole Database and its supporting " - "infrastructure. We have designed this process to be as automated as possible, ensuring that your " - "deployment is secure, efficient, and tailored to your specific network environment.\n\n" - "What to expect:\n" - "• Network Environment Discovery: We'll scan for existing services like Active Directory and DNS.\n" - "• System Configuration: Setting up local paths and environment variables.\n" - "• Dependency Management: Ensuring all required tools (Docker, k3d, etc.) are ready.\n" - "• Database Initialization: Configuring passwords, Kerberos authentication, and deploying the database cluster.\n\n" - "We are excited to have you join our community and start building with us. " - "Welcome to the neighborhood! Let's get started by preparing your system for the Prole experience." - ) - - ui.canvas_text(self, 48, 220, welcome_text, fill='black', font=('SF Pro Text', 13), width=750) - - # Ensure footer is updated (Next button visible) - self.update_footer() - - def _render_network_scan_page(self): - # Letterhead at top right (matching welcome screen theme) - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Network Configuration Scan', y=150) - self._render_paragraph("The network prole-agent will detect Kerberos services, Active Directory controllers, and other configuration details required for deployment.", y=210) - - y = 280 - self.scan_status_var = tk.StringVar(value="Ready to prole-agent") - # Ready to scan text (drawn on canvas) - status_item = ui.canvas_text(self, 48, y, "Ready to prole-agent", fill='black', font=('SF Pro Text', 12)) - - # Link variable to canvas text - def update_status_text(*args): - try: - self.bg_canvas.itemconfig(status_item, text=self.scan_status_var.get()) - except Exception: - pass - self.scan_status_var.trace_add('write', update_status_text) - - # Standardized Console Output - self.scan_results_console = self._create_console_output(y=320, title="Scan Output", width=880, height=380) - self.scan_results_text = self.scan_results_console.text - - if getattr(self, 'ansible_topology_summary', ''): - self.scan_results_console.write(self.ansible_topology_summary + "\n") - self.scan_results_console.write("Ansible topology loaded; network scan can refine detection.\n") - - y = 740 - - # Start Network Scan Button (placed on canvas) - self.scan_btn = tk.Button(self.bg_canvas, text="Start Network Scan", command=self._run_network_scan, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=20, pady=10) - self.btn_window = self.bg_canvas.create_window(48, y, window=self.scan_btn, anchor='nw') - self._overlay_widgets.append(self.scan_btn) - self._canvas_items.append(self.btn_window) - - # Prepare animation frames - if not GLOBAL_SCAN_FRAMES: - try: - from PIL import Image, ImageTk - gif_path = get_resource_path('img/prole-type.gif') - if gif_path.exists(): - gif = Image.open(str(gif_path)) - max_frames = 120 - try: - while len(GLOBAL_SCAN_FRAMES) < max_frames: - # Create a copy and resize - frame = gif.copy().convert('RGBA') - frame.thumbnail((24, 24), Image.LANCZOS) - GLOBAL_SCAN_FRAMES.append(ImageTk.PhotoImage(frame)) - gif.seek(len(GLOBAL_SCAN_FRAMES)) - except EOFError: - pass - except Exception as e: - print(f"Error loading scan animation: {e}") - self._scan_frames = GLOBAL_SCAN_FRAMES - - y += 60 - # Hint about auto-fill - autofill_msg = "Scan results will auto-fill Kerberos and Environment settings." - self._canvas_items.append(ui.canvas_text(self, 48, y, autofill_msg, fill='#6e6e73', font=('SF Pro Text', 10, 'italic'))) - - def _animate_scan_button(self, frame_idx=0): - if not getattr(self, '_scan_running', False) or not self._scan_frames: - if hasattr(self, 'scan_btn'): - self.scan_btn.config(image='', compound='none') - return - - self.scan_btn.config(image=self._scan_frames[frame_idx], compound='left') - next_idx = (frame_idx + 1) % len(self._scan_frames) - self.root.after(100, lambda: self._animate_scan_button(next_idx)) - - def _run_network_scan(self): - if getattr(self, '_scan_running', False): - return - self._action_flags['network_scan.run'] = True - self._scan_running = True - self._scan_kdc_value = None - ansible_kdc = '' - try: - ansible_kdc = (self.ansible_topology or {}).get('kdc_ip') or '' - if not ansible_kdc: - ansible_kdc = (self.prole_cfg_data.get('Network', {}) or {}).get('KDC_ANSIBLE_DETECTED', '') - except Exception: - ansible_kdc = '' - - self.scan_results_console.clear() - self.scan_results_console.write("Initializing network scan using prole-net/prole-agent ...\n") - self.scan_status_var.set("Scanning...") - - # Start animation - if self._scan_frames: - self._animate_scan_button() - - def worker(): - try: - # Use the new scan binary - scan_binary = get_resource_path("prole-net/prole-agent") - if not scan_binary.exists(): - self.safe_after(lambda: self.scan_results_console.write(f"Scan binary not found at {scan_binary}\n")) - self.safe_after(lambda: self.scan_status_var.set("Scan failed")) - self._scan_running = False - return - - # Create writable scan directory for output/cache - # This avoids issues with PyInstaller's read-only _MEIPASS directory - prole_home = Path.home() / ".prole" - scan_dir = prole_home / "scan" - scan_dir.mkdir(parents=True, exist_ok=True) - - # Run scan from writable directory with 10s timeout and summary analysis - # Set bufsize=0 for truly unbuffered binary stream reading - # Ensure PYTHONUNBUFFERED=1 is set in case prole-agent is/uses Python - env = os.environ.copy() - env['PYTHONUNBUFFERED'] = '1' - process = subprocess.Popen([str(scan_binary), "-t", "10"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - bufsize=0, - env=env, - cwd=str(scan_dir)) - - # Capture output in real-time using larger reads to avoid overhead - fd = process.stdout.fileno() - line_buffer = [] - last_ui_update = 0 - pending_output = [] - - def maybe_set_kdc(ip: str): - if not ip: - return - if ansible_kdc: - return - if self._scan_kdc_value: - return - self._scan_kdc_value = ip - self.safe_after(lambda i=ip: self.kerberos_kdc.set(i)) - self.safe_after(lambda: self.kerberos_enabled.set(True)) - - while True: - try: - # Use a smaller chunk size to encourage more frequent reads - chunk_bytes = os.read(fd, 1024) - except (EOFError, OSError): - chunk_bytes = b'' - - if not chunk_bytes: - if process.poll() is not None: - break - # If we have pending output, flush it before sleeping - if pending_output: - text_to_flush = "".join(pending_output) - pending_output = [] - self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) - last_ui_update = time.time() - time.sleep(0.01) - continue - - text = chunk_bytes.decode('utf-8', errors='replace') - pending_output.append(text) - - # Update more frequently if we have enough data or enough time has passed - now = time.time() - if now - last_ui_update > 0.05 or sum(len(x) for x in pending_output) > 2048: - text_to_flush = "".join(pending_output) - pending_output = [] - self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) - last_ui_update = now - - # Accumulate for line parsing (for AD/KDC detection) - for char in text: - line_buffer.append(char) - if char == '\n': - line = "".join(line_buffer) - line_buffer = [] - - # Parse "KDC is: IP" from the new summary analysis format - if "KDC is:" in line: - try: - ip_part = line.split("KDC is:")[1].strip() - ip = ip_part.split()[0].strip('[]():,') - if ip: - maybe_set_kdc(ip) - except (IndexError, ValueError): - pass - - elif "Active Directory" in line or "88" in line: - parts = line.split() - for part in parts: - try: - socket.inet_aton(part.strip('[]():,')) - ip = part.strip('[]():,') - maybe_set_kdc(ip) - break - except socket.error: - continue - - process.wait() - - # Final flush of any remaining output - if pending_output: - text_to_flush = "".join(pending_output) - self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) - - if process.returncode == 0: - self.safe_after(lambda: self.scan_status_var.set("Scan complete")) - else: - self.safe_after(lambda: self.scan_status_var.set(f"Scan failed (code {process.returncode})")) - - self._scan_running = False - - except Exception as e: - self.safe_after(lambda: self.scan_results_console.write(f"Scan error: {str(e)}\n")) - self.safe_after(lambda: self.scan_status_var.set("Scan failed")) - self._scan_running = False - - threading.Thread(target=worker, daemon=True).start() - - - def _render_kerberos_config_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Kerberos Authentication', y=150) - self._render_paragraph("Configure Kerberos authentication for Prole and the PostgreSQL database. Tests run inside the Kubernetes namespace to validate against your realm.", y=200) - - y = 280 - # Use tk.Checkbutton on canvas - enable_cb = tk.Checkbutton(self.bg_canvas, text="Enable Kerberos Authentication", - variable=self.kerberos_enabled, bg='white', fg='black', - activebackground='white', selectcolor='white', font=('SF Pro Text', 11)) - cb_window = self.bg_canvas.create_window(48, y, window=enable_cb, anchor='nw') - self._canvas_items.append(cb_window) - self._overlay_widgets.append(enable_cb) - - x_field = 200 - field_w = 400 - - y += 60 - ui.canvas_text(self, 48, y, "Kerberos Realm:", fill='black', font=('SF Pro Text', 12)) - realm_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_realm, bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - realm_window = self.bg_canvas.create_window(x_field, y-4, window=realm_entry, anchor='nw', width=field_w, height=32) - self._canvas_items.append(realm_window) - self._overlay_widgets.append(realm_entry) - - y += 48 - ui.canvas_text(self, 48, y, "KDC Host/IP:", fill='black', font=('SF Pro Text', 12)) - kdc_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_kdc, bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - kdc_window = self.bg_canvas.create_window(x_field, y-4, window=kdc_entry, anchor='nw', width=field_w, height=32) - self._canvas_items.append(kdc_window) - self._overlay_widgets.append(kdc_entry) - - y += 48 - ui.canvas_text(self, 48, y, "Username:", fill='black', font=('SF Pro Text', 12)) - user_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_user, bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - user_window = self.bg_canvas.create_window(x_field, y-4, window=user_entry, anchor='nw', width=field_w, height=32) - self._canvas_items.append(user_window) - self._overlay_widgets.append(user_entry) - - y += 48 - ui.canvas_text(self, 48, y, "Password:", fill='black', font=('SF Pro Text', 12)) - pass_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_password, show="*", bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - pass_window = self.bg_canvas.create_window(x_field, y-4, window=pass_entry, anchor='nw', width=field_w, height=32) - self._canvas_items.append(pass_window) - self._overlay_widgets.append(pass_entry) - - y += 60 - # Use tk.Button on canvas - test_btn = tk.Button(self.bg_canvas, text="Test Connection", command=self.test_kerberos_connection, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - btn_window = self.bg_canvas.create_window(48, y, window=test_btn, anchor='nw') - self._canvas_items.append(btn_window) - self._overlay_widgets.append(test_btn) - - y += 60 - # Use tk.Text - self.kerberos_status_text = tk.Text(self.bg_canvas, width=100, height=12, font=('Menlo', 10), - bg='white', fg='black', insertbackground='black', - highlightbackground='#CCCCCC', highlightthickness=1, relief='flat') - status_window = self.bg_canvas.create_window(48, y, window=self.kerberos_status_text, anchor='nw', width=800, height=200) - self._canvas_items.append(status_window) - self._overlay_widgets.append(self.kerberos_status_text) - - def _ollama_current_key(self) -> str: - host = (self.ollama_server_host.get() or '').strip() - port = (self.ollama_server_port.get() or '').strip() or DEFAULT_OLLAMA_PORT - if not host: - return '' - return f"{host}:{port}" - - def _set_ollama_model_values(self, models: list[str]): - combo = getattr(self, '_ollama_model_combo', None) - if not combo: - return - if getattr(self, '_refreshing_ollama_table', False): - return - values = models or [] - try: - combo.configure(values=values) - except Exception: - pass - current = (self.ollama_model.get() or '').strip() - if values and (not current or current not in values): - self.ollama_model.set(values[0]) - - def _render_ollama_table_from_cache(self): - rows = list(getattr(self, '_ollama_servers_cache', []) or []) - notice = getattr(self, '_ollama_last_notice', '') - self._update_ollama_table(rows, notice) - - def _update_ollama_table(self, rows: list[dict], notice: str = ''): - tree = getattr(self, '_ollama_server_tree', None) - if not tree: - return - self._ollama_servers_cache = rows - # Map of key -> list[str] available models - self._ollama_model_options = {} - # Map of key -> selected default model - if not hasattr(self, '_ollama_default_model_by_key'): - self._ollama_default_model_by_key = {} - self._ollama_last_notice = notice or '' - - for item in tree.get_children(): - tree.delete(item) - - current_key = self._ollama_current_key() - for row in rows: - host = row.get('host', '') - port = row.get('port', '') or DEFAULT_OLLAMA_PORT - models = row.get('models', []) or [] - key = f"{host}:{port}" - self._ollama_model_options[key] = models - # Initialize default selection if missing - if key not in self._ollama_default_model_by_key: - self._ollama_default_model_by_key[key] = (models[0] if models else '') - prefix = ' [✓] ' if key == current_key else ' [ ] ' - models_preview = ', '.join(models) if models else '—' - default_model = self._ollama_default_model_by_key.get(key, '') or '—' - try: - tree.insert('', 'end', values=(prefix, host, port, default_model, models_preview)) - except Exception: - # Fallback if columns not updated yet - tree.insert('', 'end', values=(prefix, host, port, models_preview)) - - if current_key: - for item in tree.get_children(): - vals = tree.item(item, 'values') - if vals and f"{vals[1]}:{vals[2]}" == current_key: - tree.selection_set(item) - tree.see(item) - break - - note_item = getattr(self, '_ollama_note_item', None) - if note_item and self.bg_canvas.winfo_exists(): - msg = notice if notice else "" - self.bg_canvas.itemconfig(note_item, text=msg) - - models = self._ollama_model_options.get(current_key, []) - if models: - self._set_ollama_model_values(models) - - def _refresh_ollama_table(self): - if getattr(self, '_refreshing_ollama_table', False): - return - self._refreshing_ollama_table = True - self._ollama_model_options = {} - if getattr(self, '_ollama_status_var', None): - self._ollama_status_var.set("Scanning...") - - def worker(): - notice = '' - rows: list[dict] = [] - try: - script_path = PROJECT_ROOT / "etc" / "init_ollama.sh" - if not script_path.exists(): - notice = f"Missing script: {script_path}" - self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) - else: - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - rc, out = self._run_cmd_capture(['bash', str(script_path), 'scan'], env=env) - if rc == 0: - for line in (out or '').splitlines(): - line = line.strip() - if not line or line.startswith('#'): - continue - parts = [p.strip() for p in line.split('\t')] - if len(parts) < 2: - continue - host = parts[0] - port = parts[1] or DEFAULT_OLLAMA_PORT - model_list: list[str] = [] - if len(parts) > 2 and parts[2]: - model_list = [m.strip() for m in parts[2].split(',') if m.strip() and m.strip() != '-'] - rows.append({'host': host, 'port': port, 'models': model_list}) - # Deduplicate by canonical host:port while preferring hostname labels - if rows: - def _is_ip(name: str) -> bool: - if not name: - return False - return bool(re.match(r'^(?:\d{1,3}\.){3}\d{1,3}$', name)) or ':' in name - - def _canon_host(name: str) -> str: - n = (name or '').strip().lower() - if n in {'localhost', '127.0.0.1', '::1', 'k3d.local'}: - return '127.0.0.1' - try: - # Prefer IPv4 - return socket.gethostbyname(n) - except Exception: - return n - - def _label_rank(name: str) -> int: - n = (name or '').strip().lower() - if n == 'k3d.local': - return 0 - if not _is_ip(n) and n not in {'localhost'}: - return 1 # other hostnames preferred after k3d.local - if n == 'localhost': - return 2 - return 3 # IPs last - - merged: dict[tuple[str, str], dict] = {} - for r in rows: - disp_host = r.get('host', '') - port = str(r.get('port', '') or DEFAULT_OLLAMA_PORT) - canon = _canon_host(disp_host) - key = (canon, port) - cur = merged.get(key) - if not cur: - merged[key] = { - 'host': disp_host, # display label - 'port': port, - 'models': list(r.get('models', []) or []) - } - else: - # Merge models - existing = set(cur.get('models', []) or []) - for m in (r.get('models', []) or []): - if m not in existing: - cur['models'].append(m) - existing.add(m) - # Pick better display host - old_label = cur.get('host', '') - if _label_rank(disp_host) < _label_rank(old_label): - cur['host'] = disp_host - # Convert back to list and sort by host - rows = sorted(merged.values(), key=lambda d: (d.get('host') or '')) - if rows: - notice = f"Detected {len(rows)} server(s)." - self.safe_after(lambda: self._ollama_status_var.set("Scan complete") if getattr(self, '_ollama_status_var', None) else None) - else: - notice = "No Ollama servers detected." - self.safe_after(lambda: self._ollama_status_var.set("No servers found") if getattr(self, '_ollama_status_var', None) else None) - else: - notice = "Scan failed. See logs for details." - self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) - except Exception as e: - notice = f"Scan error: {e}" - self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) - finally: - def finish(): - self._refreshing_ollama_table = False - self._update_ollama_table(rows, notice) - self.safe_after(finish) - - threading.Thread(target=worker, daemon=True).start() - - def _save_ollama_config(self): - raw_host = (self.ollama_server_host.get() or '').strip() - raw_port = (self.ollama_server_port.get() or '').strip() - raw_model = (self.ollama_model.get() or '').strip() - - host = raw_host - port = raw_port - if raw_host: - parsed_host, parsed_port = _parse_ollama_host(raw_host) - if parsed_host: - host = parsed_host - if not port and parsed_port: - port = parsed_port - if host and not port: - port = DEFAULT_OLLAMA_PORT - - if host and host != raw_host: - self.ollama_server_host.set(host) - if port and port != raw_port: - self.ollama_server_port.set(port) - - data = self.prole_cfg_data.setdefault('Ollama', {}) - if host: - data['OLLAMA_SERVER_HOST'] = host - data['OLLAMA_SERVER_PORT'] = port - data['OLLAMA_HOST'] = _format_ollama_host(host, port) - else: - data.pop('OLLAMA_SERVER_HOST', None) - data.pop('OLLAMA_SERVER_PORT', None) - data.pop('OLLAMA_HOST', None) - if raw_model: - data['OLLAMA_MODEL'] = raw_model - else: - data.pop('OLLAMA_MODEL', None) - - self._save_prole_cfg() - if getattr(self, '_ollama_status_var', None): - self._ollama_status_var.set("Saved") - - def _render_ollama_config_page(self): - # Letterhead at top right (matching welcome screen theme) - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Ollama Server', y=150) - self._render_paragraph( - "Detect and select a network Ollama server. The selected host, port, and model are saved to prole.cfg " - "and exported as OLLAMA_* for k3s hosts and services.", - y=200 - ) - - x_label = 48 - x_field = 300 - y = 270 - - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Host:', fill='black', font=('SF Pro Text', 12, 'bold'))) - host_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_host, bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - host_window = self.bg_canvas.create_window(x_field, y-12, window=host_entry, anchor='nw', width=400, height=32) - self._canvas_items.append(host_window) - self._overlay_widgets.append(host_entry) - - y += 42 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Port:', fill='black', font=('SF Pro Text', 12, 'bold'))) - port_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_port, bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=120, height=32) - self._canvas_items.append(port_window) - self._overlay_widgets.append(port_entry) - - y += 42 - # Removed global model selector; model is now selected per-server row - save_btn = tk.Button(self.bg_canvas, text='Save', command=self._save_ollama_config, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=16, pady=6) - save_window = self.bg_canvas.create_window(x_field + 320, y-14, window=save_btn, anchor='nw') - self._canvas_items.append(save_window) - self._overlay_widgets.append(save_btn) - - y += 42 - preview_item = ui.canvas_text(self, x_label, y, 'OLLAMA_HOST: ', fill='#6e6e73', font=('SF Pro Text', 10)) - self._canvas_items.append(preview_item) - - def update_preview(*_args): - host = (self.ollama_server_host.get() or '').strip() - port = (self.ollama_server_port.get() or '').strip() - url = _format_ollama_host(host, port) - text = f"OLLAMA_HOST: {url}" if url else "OLLAMA_HOST: (not set)" - try: - self.bg_canvas.itemconfig(preview_item, text=text) - except Exception: - pass - # No global model selector updates to avoid UI spin-lock - - self.ollama_server_host.trace_add('write', update_preview) - self.ollama_server_port.trace_add('write', update_preview) - update_preview() - - y += 40 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))) - - refresh_btn = tk.Button(self.bg_canvas, text='Scan', command=self._refresh_ollama_table, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) - refresh_window = self.bg_canvas.create_window(x_label + 190, y - 8, window=refresh_btn, anchor='nw') - self._canvas_items.append(refresh_window) - self._overlay_widgets.append(refresh_btn) - - self._ollama_status_var = tk.StringVar(value="Ready") - status_item = ui.canvas_text(self, x_label + 270, y, "Status: Ready", fill='#6e6e73', font=('SF Pro Text', 10)) - self._canvas_items.append(status_item) - - def update_status_text(*args): - try: - self.bg_canvas.itemconfig(status_item, text=f"Status: {self._ollama_status_var.get()}") - except Exception: - pass - self._ollama_status_var.trace_add('write', update_status_text) - - table_y = y + 30 - table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1) - table_window = self.bg_canvas.create_window(x_label, table_y, window=table_frame, anchor='nw', width=900, height=220) - self._canvas_items.append(table_window) - self._overlay_widgets.append(table_frame) - - # Add an editable Default Model column; available models shown for reference - columns = ('select', 'host', 'port', 'default', 'models') - tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=6) - tree.heading('select', text='Select') - tree.heading('host', text='Host') - tree.heading('port', text='Port') - tree.heading('default', text='Default Model') - tree.heading('models', text='Models') - tree.column('select', width=60, anchor='center') - tree.column('host', width=200, anchor='w') - tree.column('port', width=70, anchor='center') - tree.column('default', width=180, anchor='w') - tree.column('models', width=380, anchor='w') - tree.pack(side='left', fill='both', expand=True) - - scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview) - tree.configure(yscrollcommand=scroll.set) - scroll.pack(side='right', fill='y') - - self._overlay_widgets.append(tree) - self._overlay_widgets.append(scroll) - self._ollama_server_tree = tree - - # Inline combobox to edit Default Model for the selected row - self._ollama_row_model_combo = None - - def _place_row_model_combo(item_id): - try: - # Compute bbox for 'default' column - bbox = tree.bbox(item_id, column='default') - if not bbox: - return - x, y, w, h = bbox - # Create lazily - if not self._ollama_row_model_combo: - self._ollama_row_model_combo = ttk.Combobox(tree, state='readonly') - self._ollama_row_model_combo.bind('<>', _on_row_model_change) - # Identify models by key - vals = tree.item(item_id, 'values') - if not vals: - return - host = vals[1] - port = vals[2] or DEFAULT_OLLAMA_PORT - key = f"{host}:{port}" - models = self._ollama_model_options.get(key, []) - try: - self._ollama_row_model_combo.configure(values=models) - except Exception: - pass - # Set current selection - current = (self._ollama_default_model_by_key.get(key) or (models[0] if models else '')) - if current: - self._ollama_row_model_combo.set(current) - # Place within Treeview - self._ollama_row_model_combo.place(x=x+1, y=y+1, width=max(w-2, 60), height=h-2) - except Exception: - pass - - def _on_row_model_change(_evt=None): - try: - if not self._ollama_row_model_combo: - return - sel = tree.selection() - if not sel: - return - item_id = sel[0] - vals = tree.item(item_id, 'values') - if not vals: - return - host = vals[1] - port = vals[2] or DEFAULT_OLLAMA_PORT - key = f"{host}:{port}" - choice = self._ollama_row_model_combo.get().strip() - self._ollama_default_model_by_key[key] = choice - # If this row is the active selection for host/port, reflect into self.ollama_model - current_key = self._ollama_current_key() - if current_key == key and choice: - try: - self.ollama_model.set(choice) - except Exception: - pass - # Update the table cell text - new_vals = list(vals) - # Default column index is 3 - if len(new_vals) >= 4: - new_vals[3] = choice or '—' - tree.item(item_id, values=new_vals) - except Exception: - pass - - def on_select(event): - if getattr(self, '_refreshing_ollama_table', False): - return - sel = tree.selection() - if not sel: - # Hide editor when nothing selected - if self._ollama_row_model_combo: - self._ollama_row_model_combo.place_forget() - return - vals = tree.item(sel[0], 'values') - if not vals: - return - host = vals[1] - port = vals[2] or DEFAULT_OLLAMA_PORT - self.ollama_server_host.set(host) - self.ollama_server_port.set(port) - key = f"{host}:{port}" - # If we have a stored default model for this key, reflect into self.ollama_model for saving - chosen = (self._ollama_default_model_by_key.get(key) or '') - if chosen: - try: - self.ollama_model.set(chosen) - except Exception: - pass - # Refresh to update the selection indicator, then place editor - self._render_ollama_table_from_cache() - # Reselect and place inline model editor over this row - try: - for item in tree.get_children(): - v = tree.item(item, 'values') - if v and v[1] == host and (v[2] or DEFAULT_OLLAMA_PORT) == port: - tree.selection_set(item) - _place_row_model_combo(item) - break - except Exception: - pass - - tree.bind('<>', on_select) - # Also allow double-click on default cell to focus the combobox - def on_double_click(event): - if getattr(self, '_refreshing_ollama_table', False): - return - item_id = tree.identify_row(event.y) - col = tree.identify_column(event.x) - if not item_id: - return - if col in ('#4', 'default'): - _place_row_model_combo(item_id) - tree.bind('', on_double_click) - - self._ollama_note_item = ui.canvas_text(self, x_label, table_y + 230, '', fill='#6e6e73', font=('SF Pro Text', 10)) - self._canvas_items.append(self._ollama_note_item) - - self._render_ollama_table_from_cache() - self._refresh_ollama_table() - - def _render_supabase_config_page(self): - # Letterhead at top right (matching welcome screen theme) - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Prole::Supabase', y=150) - - description = ( - "Launches the Supabase open-source stack via supabase/deploy.sh.\n" - "Default mode is local (Docker Compose). Set SUPABASE_DEPLOY_MODE=k3d\n" - "to deploy into the 'supabase' Kubernetes namespace." - ) - self._render_paragraph(description, y=210) - - y = 280 - self._supabase_status_var = tk.StringVar(value="Ready") - status_item = ui.canvas_text(self, 48, y, "Status: Ready", fill='black', font=('SF Pro Text', 12)) - - def update_status_text(*args): - try: - self.bg_canvas.itemconfig(status_item, text=f"Status: {self._supabase_status_var.get()}") - except Exception: - pass - self._supabase_status_var.trace_add('write', update_status_text) - - # Standardized Console Output - self._supabase_console = self._create_console_output(y=320, title="Deployment Output", width=880, height=380) - - y = 740 - - # Deploy Supabase Button - self._supabase_deploy_button = tk.Button( - self.bg_canvas, - text="Deploy Supabase", - command=self.run_supabase_deploy, - bg='#F5F5DC', - fg='black', - activebackground='#E5E5D5', - activeforeground='black', - highlightbackground='#F5F5DC', - highlightcolor='#F5F5DC', - highlightthickness=0, - relief='flat', - bd=0, - cursor='hand2', - disabledforeground='#8B8B7A', - font=('SF Pro Text', 11), - padx=20, - pady=10, - ) - btn_window = self.bg_canvas.create_window(48, y, window=self._supabase_deploy_button, anchor='nw') - self._overlay_widgets.append(self._supabase_deploy_button) - self._canvas_items.append(btn_window) - - def run_supabase_deploy(self): - if getattr(self, '_supabase_deploying', False): - return - - self._supabase_deploying = True - self._supabase_deploy_button.configure(state='disabled') - self._supabase_status_var.set("Deploying...") - self._supabase_success = False - self._supabase_console.clear() - self.update_footer() - - def worker(): - namespace = (self.db_namespace.get() or '').strip() or os.environ.get('NAMESPACE') or 'default' - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - env["NAMESPACE"] = namespace - - self._supabase_console.write("Starting Supabase deployment...\n") - - script_path = PROJECT_ROOT / "supabase" / "deploy.sh" - if not script_path.exists(): - self._supabase_console.write(f"Error: {script_path} not found.\n") - self.safe_after(lambda: self._supabase_status_var.set("Failed (Script not found)")) - self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) - self._supabase_deploying = False - 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.cluster_env.get()) - if cluster_env == 'dev': - mode = "k3d" - elif cluster_env in ('service', 'prod'): - mode = "k8s" - else: - mode = "k3d" - args = ["--mode", mode] - cfg_path = None - try: - if self._cfg_path_override and self._cfg_path_override.exists(): - cfg_path = self._cfg_path_override - else: - conf_dir = self._resolve_prole_conf_dir() - candidate = conf_dir / "prole.cfg" - if candidate.exists(): - cfg_path = candidate - if not cfg_path: - candidate = PROJECT_ROOT / "conf" / "prole.cfg" - if candidate.exists(): - cfg_path = candidate - except Exception: - cfg_path = None - 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._supabase_console.write(f"Mode: {mode}\n") - cmd = ["bash", str(script_path)] + args - - try: - proc = subprocess.Popen( - cmd, - cwd=str(PROJECT_ROOT), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - except Exception as e: - self._supabase_console.write(f"Failed to start deploy: {e}\n") - self.safe_after(lambda: self._supabase_status_var.set("Failed (Launch error)")) - self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) - self._supabase_deploying = False - return - - if proc.stdout: - for line in iter(proc.stdout.readline, ''): - if line: - self._supabase_console.write(line) - proc.stdout.close() - rc = proc.wait() - - if rc == 0: - self._supabase_console.write("\nSupabase deployment completed successfully.\n") - ports_script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh" - self.safe_after(lambda: self._supabase_status_var.set("Configuring Ports...")) - - if not ports_script.exists(): - self._supabase_success = False - self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports script not found)")) - self._supabase_console.write(f"Error: {ports_script} not found.\n") - else: - self._supabase_console.write(f"\nConfiguring Supabase ports for namespace '{namespace}'...\n") - self._supabase_console.write(f"> bash etc/init_supabase_ports.sh -n {namespace}\n\n") - - try: - ports_proc = subprocess.Popen( - ["bash", str(ports_script), "-n", namespace], - cwd=str(PROJECT_ROOT), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - except Exception as e: - self._supabase_success = False - self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports launch error)")) - self._supabase_console.write(f"Failed to start port wiring: {e}\n") - else: - if ports_proc.stdout: - for line in iter(ports_proc.stdout.readline, ''): - if line: - self._supabase_console.write(line) - ports_proc.stdout.close() - ports_rc = ports_proc.wait() - - if ports_rc == 0: - self._supabase_success = True - self.safe_after(lambda: self._supabase_status_var.set("Deployed Successfully")) - self._supabase_console.write("\nSupabase port wiring completed successfully.\n") - else: - self._supabase_success = False - self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Ports code {ports_rc})")) - self._supabase_console.write(f"\nSupabase port wiring failed with exit code {ports_rc}.\n") - else: - self._supabase_success = False - self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Code {rc})")) - self._supabase_console.write(f"\nSupabase deployment failed with exit code {rc}.\n") - - self._supabase_deploying = False - self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) - self.safe_after(self.update_footer) - - threading.Thread(target=worker, daemon=True).start() - - def test_kerberos_connection(self): - self._action_flags['kerberos_config.test_connection'] = True - realm = self.kerberos_realm.get().strip() - user = self.kerberos_user.get().strip() - password = self.kerberos_password.get().strip() - kdc = self.kerberos_kdc.get().strip() - - if not realm or not user or not password or not kdc: - messagebox.showerror("Error", "Please fill in realm, KDC host, username, and password.") - return - - try: - self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get()) - 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['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) - 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') - self._save_prole_cfg() - except Exception: - pass - - self.kerberos_status_text.delete('1.0', tk.END) - self.kerberos_status_text.insert(tk.END, f"Testing {user}@{realm} inside the cluster...\n") - - def worker(): - try: - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - env["NAMESPACE"] = (self.db_namespace.get() or '').strip() - env["SERVICE_NAMESPACE"] = self._get_service_namespace() - env["KRB5_REALM"] = realm - env["REALM"] = realm - env["DOMAIN"] = realm.lower() - env["KRB5_KDC"] = kdc - env["KRB5_ADMIN"] = kdc - env["KRB5_USER"] = user - env["KRB5_PASSWORD"] = password - env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get()) - env["ENABLED"] = env["KERBEROS_ENABLED"] - mode = self._deployment_mode() - if mode == "k3s": - env.setdefault("KRB5_AD_PORT_FORWARD", "0") - if mode: - env["PROLE_MODE"] = mode - env["DEPLOYMENT_MODE"] = mode - env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) - mode_args = ["--mode", mode] if mode else [] - - def write_line(line): - def _do_write(l=line): - try: - if self.kerberos_status_text.winfo_exists(): - self.kerberos_status_text.insert(tk.END, l) - self.kerberos_status_text.see(tk.END) - except (tk.TclError, RuntimeError): - pass - self.safe_after(_do_write) - - write_line("\n==> init_kerberos.sh test\n") - rc2 = self.controller.run_script( - "init_kerberos.sh", - args=mode_args + ["test"], - env=env, - on_line=write_line - ) - if rc2 == 0: - write_line("\nKerberos test completed successfully.\n") - else: - write_line(f"\nKerberos test failed with code {rc2}\n") - - except Exception as e: - def _do_error(msg=str(e)): - try: - if self.kerberos_status_text.winfo_exists(): - self.kerberos_status_text.insert(tk.END, f"Error: {msg}\n") - except (tk.TclError, RuntimeError): - pass - self.safe_after(_do_error) - - threading.Thread(target=worker, daemon=True).start() - - - def _start_welcome_dependency_scan(self): - self.splash_scan_running = True - self.splash_scan_done_at = None - self.splash_scan_started_at = time.time() - - deps = list(self.dependencies) - total = len(deps) - results = {} - - def set_status(text: str): - try: - if self._splash_status_item is not None: - # Replace existing text to avoid creating many items - self.bg_canvas.itemconfig(self._splash_status_item, text=text) - except Exception: - pass - - def worker(): - missing_names = [] - for idx, dep in enumerate(deps, start=1): - try: - ok, location, version = self.get_dep_info(dep) - except Exception: - ok, location, version = False, None, None - results[dep['id']] = (ok, location, version) - # Update status text progressively - if ok: - txt = f"[{idx}/{total}] {dep['name']}: Installed" - else: - txt = f"[{idx}/{total}] {dep['name']}: Not installed" - missing_names.append(dep['name']) - self.safe_after(lambda s=txt: set_status(s)) - # tiny sleep to keep UI responsive without being too fast - try: - time.sleep(0.02) - except Exception: - pass - - # Final message - final_msg = 'All dependencies installed.' if not missing_names else ( - 'Preparing to install ... ' + ', '.join(missing_names) - ) - def on_done(): - set_status(final_msg) - self.splash_scan_running = False - self.splash_scan_done_at = time.time() - # Re-evaluate footer to potentially show Next - self.update_footer() - # Important: schedule a follow-up refresh slightly after the debounce window - # so the Next button becomes visible without requiring further UI events. - self.safe_after(self.update_footer, delay=600) - self.safe_after(on_done) - - t = threading.Thread(target=worker, daemon=True) - t.start() - # Failsafe refresh slightly after the 8s gating window in case no UI events fire. - self.safe_after(self.update_footer, delay=9000) - - def _render_deps_summary_page(self): - self._render_title('Dependencies', y=40) - y = 100 - # Draw each dependency row: status dot (blank initially), name, info - row_gap = 36 - - self.dep_status_items = {} # Store canvas IDs to update later - self.dep_install_buttons = {} # Store button window items - - for dep in self.dependencies: - indent = 0 - if dep.get('parent'): - indent = 24 - - left = 56 + indent - text_x = left + 28 - - # blank box (outline only initially) - dot = ui.canvas_rectangle(self, left, y, left+18, y+18, outline='#6e6e73', width=2) - self._canvas_items.append(dot) - # name - self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='black', font=('SF Pro Text', 12, 'bold'))) - # info placeholder - info = ui.canvas_text(self, text_x + 180, y, 'Checking...', fill='#6e6e73', font=('SF Pro Text', 11)) - self._canvas_items.append(info) - - self.dep_status_items[dep['id']] = {'dot': dot, 'info': info, 'y': y, 'left': left} - y += row_gap - - # Message line - msg_y = y + 30 - self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('SF Pro Text', 11)) - self._canvas_items.append(self.deps_msg_item) - - # Start async check - threading.Thread(target=self._run_delayed_deps_check, daemon=True).start() - - def _run_delayed_deps_check(self): - results = {} - any_missing = False - for dep in self.dependencies: - ok, location, version = self.get_dep_info(dep) - results[dep['id']] = (ok, location, version) - - # Update UI for this item - def update_item(did=dep['id'], ok=ok, version=version): - items = self.dep_status_items.get(did) - if not items: return - - # Replace box with colored dot/check - self.bg_canvas.delete(items['dot']) - left = items.get('left', 56) - y = items['y'] - if ok: - items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='') - # Add a small white checkmark inside the green dot - self.bg_canvas.create_line(left+5, y+9, left+8, y+12, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}") - self.bg_canvas.create_line(left+8, y+12, left+13, y+6, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}") - else: - items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff3b30', outline='') - # Add "Click to install" button - btn = tk.Button(self.bg_canvas, text='Install', command=lambda d=did: self.show_page(f"dep_{d}"), - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=8, pady=2) - btn_window = self.bg_canvas.create_window(left + 350, y, window=btn, anchor='nw') - self._overlay_widgets.append(btn) - self._canvas_items.append(btn_window) - self.dep_install_buttons[did] = btn_window - - info_text = self.normalize_version(version) if ok and version else ('Not installed' if not ok else '') - self.bg_canvas.itemconfig(items['info'], text=info_text) - - self.safe_after(update_item) - time.sleep(0.1) # small delay to show it checking one by one - - def final_update(): - missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]] - if missing: - msg = f"Preparing to install ... {', '.join(missing)}" - else: - msg = 'All dependencies installed.' - try: - if self.bg_canvas.winfo_exists() and self.deps_msg_item in self.bg_canvas.find_all(): - self.bg_canvas.itemconfig(self.deps_msg_item, text=msg) - except (tk.TclError, RuntimeError): - pass - self.update_footer() - - self.safe_after(final_update) - - def _render_dependency_page(self, dep): - self._render_title(dep['name'], y=40) - self._render_paragraph(dep['description'], y=88) - # Status - ok, location, version = self.get_dep_info(dep) - y = 180 - left = 56 - if ok: - self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')) - self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold'))) - if location: - self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11))) - if version: - ver = self.normalize_version(version) - self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 11))) - else: - # Check if we should auto-install - install_cmd = dep.get('install_cmd') - if install_cmd and self._installing_dep_id != dep['id']: - # Launch directly into installation - self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) - self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) - # Trigger console-based install for brew/pip - if 'brew install' in install_cmd or 'pip install' in install_cmd: - self.root.after(500, lambda: self._install_dep_in_console(dep)) - else: - self.root.after(500, lambda: self.open_terminal_with_command(install_cmd)) - elif self._installing_dep_id == dep['id']: - self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) - self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) - else: - self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline='')) - self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold'))) - # Click to install link (if available) - if install_cmd: - link_y = y + 40 - link_text = ui.render_link(self, left+32, link_y, 'Click to install') - self._canvas_items.append(link_text) - self.bg_canvas.config(cursor='hand2') - - def _on_click(event): - ex, ey = event.x, event.y - bbox = self.bg_canvas.bbox(link_text) - if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: - self.open_terminal_with_command(install_cmd) - self.bg_canvas.bind('', _on_click) - - def _install_dep_in_console(self, dep): - """Run dependency installation in the embedded console.""" - if self._installing_dep_id == dep['id']: - return - try: - self._action_flags[f"dependencies.{dep['id']}.install"] = True - except Exception: - pass - - install_cmd = dep.get('install_cmd') - if not install_cmd: - return - - self._installing_dep_id = dep['id'] - # Prepare log file - logs_dir = self._resolve_env_dir('PROLE_LOGS', 'logs') - try: - logs_dir.mkdir(parents=True, exist_ok=True) - except Exception: - pass - ts = time.strftime('%Y%m%d-%H%M%S') - log_path = logs_dir / f"install-{dep['id']}-{ts}.log" - - # Show console - self._ensure_console_overlay(radio_bottom_y=160) - self._console_text.configure(state='normal') - self._console_text.delete('1.0', tk.END) - self._console_text.insert('end', f"Starting installation of {dep['name']}...\n") - self._console_text.insert('end', f"Command: {install_cmd}\n\n") - self._console_text.configure(state='disabled') - - def on_done(rc): - self._installing_dep_id = None - if rc == 0: - self._append_console(f"\nSuccessfully installed {dep['name']}.\n") - # Refresh status and re-render page - self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}")) - else: - self._append_console(f"\nInstallation failed with exit code {rc}.\n") - # Enable next button so user can retry or proceed if they fixed it manually - try: - self.next_button.configure(state='normal', text='Next') - except Exception: - pass - - self._run_in_console(install_cmd, str(log_path), on_complete=on_done) - - # ---------------- Initialize Screen Handlers ---------------- - - def _run_service_layer_overlay(self, next_page: str = 'init_password'): - """Ensure service-layer resources are ready before database creation.""" - self._showing_service_overlay = True - self._common_services_success = False - self.update_footer() - self._clear_canvas_page() - - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Preparing Service Layer', y=150) - self._render_paragraph('Ensuring service-layer resources are ready before database creation.', y=200) - - console = self._create_console_output(y=260, title="Service Layer Output", width=900, height=520) - status_label = ui.canvas_text(self, 240, 812, "Ready to deploy common services", fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(status_label) - log_path = self._common_services_log_path() - try: - console.write(f"Log file: {log_path}\n\n") - except Exception: - pass - - def worker(): - try: - self.safe_after(lambda: self._deploy_services_button.configure(state='disabled') if self._deploy_services_button.winfo_exists() else None) - namespace = (self.db_namespace.get() or '').strip() or 'default' - env = self._script_env_for_namespace(namespace) - env["SERVICE_NAMESPACE"] = self._get_service_namespace() - try: - log_fp = log_path.open('a', encoding='utf-8') - log_fp.write(f"\n# Service layer deploy @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") - log_fp.flush() - except Exception: - log_fp = None - def _line(line: str): - console.write(line) - if log_fp: - try: - log_fp.write(line) - log_fp.flush() - except Exception: - pass - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Deploying service layer...", fill='blue') if self.bg_canvas.winfo_exists() else None) - rc = self.controller.run_script( - "init_service_layer.sh", - args=["migrate"], - env=env, - on_line=_line - ) - if log_fp: - try: - log_fp.write(f"\nExit status: {rc}\n") - log_fp.close() - except Exception: - pass - if rc != 0: - console.write(f"\nService layer setup failed (code {rc}).\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"Service layer failed (code {rc})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - self.safe_after(lambda: self._deploy_services_button.configure(state='normal') if self._deploy_services_button.winfo_exists() else None) - return - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Service layer ready. You may proceed.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - self._common_services_success = True - self.safe_after(self.update_footer) - except Exception as e: - console.write(f"\nService layer error: {e}\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Service layer error", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - self.safe_after(lambda: self._deploy_services_button.configure(state='normal') if self._deploy_services_button.winfo_exists() else None) - - # Use tk.Button explicitly for control over button appearance and behavior - self._deploy_services_button = tk.Button(self.bg_canvas, text='Deploy Services', - command=lambda: threading.Thread(target=worker, daemon=True).start(), - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - btn_window = self.bg_canvas.create_window(48, 800, window=self._deploy_services_button, anchor='nw', width=180) - self._canvas_items.append(btn_window) - self._overlay_widgets.append(self._deploy_services_button) - - def _run_preparation_overlay(self): - """Prepare environment for database creation.""" - self._action_flags['init_password.generate_ssh_key'] = True - self._clear_canvas_page() - - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Preparing', y=150) - self._render_paragraph('Preparing your environment for database creation.', y=200) - - # Output Console - standardized to match Docker Build screen - console = self._create_console_output(y=260, title="", width=900, height=520) - - # Status Label - status_label = ui.canvas_text(self, 48, 812, "Initializing...", fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(status_label) - - def worker(): - key_path = Path.home() / ".ssh" / "id_prole_ed25519" - key_path.parent.mkdir(parents=True, exist_ok=True) - password = self.db_password.get() - namespace = (self.db_namespace.get() or '').strip() - - # Pre-pull dependent images into local registry before initialization - try: - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Pre-pulling images...", fill='blue') if self.bg_canvas.winfo_exists() else None) - pull_ok = self._prepull_images_to_registry( - include_supabase=self.supabase_enabled.get(), - include_kerberos_proxy=self.kerberos_enabled.get(), - log=console.write - ) - if not pull_ok: - console.write("\n[WARN] Image pre-pull failed or incomplete. Continuing...\n") - except Exception as e: - console.write(f"\n[WARN] Image pre-pull error: {e}\n") - - if key_path.exists(): - console.write(f"Key already exists at {key_path}. Proceeding...\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Keys present. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - time.sleep(0.5) - else: - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Preparing keys...", fill='blue') if self.bg_canvas.winfo_exists() else None) - cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get()] - console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") - - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - while True: - line = proc.stdout.readline() - if not line and proc.poll() is not None: - break - if line: - console.write(line) - - if proc.returncode != 0: - console.write(f"\nPreparation failed, trying alternative (code {proc.returncode})\n") - cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get()] - console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - while True: - line = proc.stdout.readline() - if not line and proc.poll() is not None: - break - if line: - console.write(line) - - if proc.returncode == 0: - console.write("\nPreparation completed successfully.\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Preparation completed successfully.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - else: - console.write(f"\nError during preparation (code {proc.returncode})\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"Error during preparation (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - # In case of error, we might want to let the user see it before continuing or stopping - time.sleep(2) - self.root.after(500, lambda: self.show_page('init_password')) - return - - # Initialize OpenBao and store keys/passwords - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Initializing OpenBao...", fill='blue') if self.bg_canvas.winfo_exists() else None) - env = self._script_env_for_namespace(namespace) - env["PROLE_DB_USER"] = self.db_username.get().strip() - env["DB_PASSWORD"] = password - env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) - if self.at_rest_encryption_enabled.get(): - console.write("At-rest encryption enabled: generating/storing TDE keys in OpenBao...\n") - rc = self.controller.run_script( - "init_openbao.sh", - args=["initialize"], - env=env, - stdin_text=f"{password}\n", - on_line=lambda l: console.write(l) - ) - if rc != 0: - console.write(f"\nOpenBao initialization failed (code {rc}).\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"OpenBao init failed (code {rc})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - self.root.after(500, lambda: self.show_page('init_password')) - return - - # Move to next page - self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="OpenBao ready. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - self.root.after(1000, lambda: self.show_page('init_db_build')) - - threading.Thread(target=worker, daemon=True).start() - - def _cluster_env_key(self, env_label: str | None = None) -> str: - """Return normalized cluster env key ('dev', 'service', 'prod').""" - value = env_label - if value is None: - try: - value = self.cluster_env.get() - except Exception: - value = '' - - try: - if hasattr(value, 'get') and callable(value.get): - value = value.get() - except Exception: - pass - - if not value: - try: - value = (self.prole_cfg_data.get('Initialize Cluster', {}) or {}).get('ENVIRONMENT', '').strip() - except Exception: - value = '' - if not value: - try: - value = (self.prole_cfg_data.get('Global', {}) or {}).get('CLUSTER_ENV', '').strip() - except Exception: - value = '' - - key = _normalize_cluster_env(value) - if not key: - return 'dev' - return key - - def _get_service_namespace(self) -> str: - """Resolve service namespace with UI/env/config fallbacks.""" - try: - ns = (self.service_namespace.get() or '').strip() - except Exception: - ns = '' - if not ns: - try: - ns = (self.prole_cfg_data.get('Global', {}) or {}).get('SERVICE_NAMESPACE', '').strip() - except Exception: - ns = '' - if not ns: - ns = (os.environ.get('SERVICE_NAMESPACE') or '').strip() - return ns or 'default' - - def _on_cluster_env_change(self, *args): - self._set_deploy_target_from_cluster_env() - # Trigger status check or refresh UI - env_key = self._cluster_env_key() - if env_key == 'service': - try: - self._apply_k3s_defaults() - except Exception: - pass - self._verify_k3s_services() - self.show_page('init_cluster') - - def _deployment_mode(self) -> str: - try: - env_val = self.cluster_env.get() - except Exception: - env_val = '' - return _deployment_mode_from_env(env_val) - - def _deployment_pipeline_url(self, target_key: str) -> str: - env_url = (os.environ.get('PROLE_OPENTOFU_URL') or os.environ.get('OPENTOFU_URL') or '').strip() - if env_url: - return env_url - section = None - if target_key == 'service': - section = 'Service Cluster (k3s)' - elif target_key == 'prod': - section = 'Prod Cluster (k8s)' - if section: - url = (self.prole_cfg_data.get(section, {}) or {}).get('PIPELINE_URL', '').strip() - if url: - return url - return _default_opentofu_pipeline_url() - - def _set_deploy_target_from_cluster_env(self): - if not hasattr(self, 'deploy_target'): - return - if getattr(self, '_syncing_deploy_target', False): - return - self._syncing_deploy_target = True - try: - self.deploy_target.set(_deployment_target_label(self.cluster_env.get())) - except Exception: - pass - finally: - self._syncing_deploy_target = False - - def _on_deploy_target_change(self, *args): - if getattr(self, '_syncing_deploy_target', False): - return - target = '' - try: - target = self.deploy_target.get() - except Exception: - target = '' - key = _normalize_cluster_env(target) - if key == 'dev': - desired = 'dev' - elif key == 'service': - desired = 'service' - elif key == 'prod': - desired = 'prod' - else: - return - self._syncing_deploy_target = True - try: - if self.cluster_env.get() != desired: - self.cluster_env.set(desired) - finally: - self._syncing_deploy_target = False - - def _render_init_cluster_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Cluster Environment', y=150) - self._render_paragraph('Select a cluster environment and ensure the cluster is running and common services can be deployed.', y=200) - - # Cluster Selection (Radio Buttons) - x_label = 48 - y = 280 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Environment:', fill='black', font=('SF Pro Text', 14, 'bold'))) - - y += 28 - cluster_options = [ - ('dev', 'Dev'), - ('service', 'Service'), - ('prod', 'Prod') - ] - - # We need to trace cluster_env if not already traced - if not hasattr(self, '_cluster_env_trace'): - self._cluster_env_trace = self.cluster_env.trace_add('write', self._on_cluster_env_change) - - rb_x = x_label + 20 - rb_gap = 150 - for idx, (val, name) in enumerate(cluster_options): - rb = tk.Radiobutton(self.bg_canvas, text=name, variable=self.cluster_env, value=val, - bg='white', fg='black', activebackground='white', selectcolor='white', - font=('SF Pro Text', 11)) - rb_window = self.bg_canvas.create_window(rb_x + idx * rb_gap, y, window=rb, anchor='nw') - self._canvas_items.append(rb_window) - self._overlay_widgets.append(rb) - - selected_env_key = self._cluster_env_key() - y += 44 - - if selected_env_key == 'dev': - # Combined Select / Add Cluster widget - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Cluster:', fill='black', font=('SF Pro Text', 12, 'bold'))) - y += 28 - - clusters = self._get_k3d_cluster_list() - combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_k3d_cluster, values=clusters, width=30) - if not self.selected_k3d_cluster.get() and clusters: - self.selected_k3d_cluster.set(clusters[0]) - - combo.bind('<>', lambda _evt: self._on_k3d_cluster_select()) - - combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw') - self._canvas_items.append(combo_win) - self._overlay_widgets.append(combo) - - add_btn = tk.Button(self.bg_canvas, text='Add', command=self._on_create_k3d_cluster, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', activeforeground='black', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=10) - add_win = self.bg_canvas.create_window(x_label + 290, y - 4, window=add_btn, anchor='nw') - self._canvas_items.append(add_win) - self._overlay_widgets.append(add_btn) - - delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._on_delete_k3d_cluster, - bg='#F5F5DC', fg='#cc0000', activebackground='#E5E5D5', activeforeground='#cc0000', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=10) - delete_win = self.bg_canvas.create_window(x_label + 350, y - 4, window=delete_btn, anchor='nw') - self._canvas_items.append(delete_win) - self._overlay_widgets.append(delete_btn) - y += 40 - - else: - # Service / Prod Section - if selected_env_key == 'service': - # K3s connection settings - try: - self._apply_k3s_defaults() - except Exception: - pass - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'K3s Connection:', fill='black', font=('SF Pro Text', 12, 'bold'))) - y += 28 - - ui.canvas_text(self, x_label + 20, y, 'Server URL:', fill='black', font=('SF Pro Text', 11)) - server_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_server_url, width=60) - server_win = self.bg_canvas.create_window(x_label + 140, y - 8, window=server_entry, anchor='nw') - self._canvas_items.append(server_win) - self._overlay_widgets.append(server_entry) - y += 28 - - ui.canvas_text(self, x_label + 20, y, 'Token:', fill='black', font=('SF Pro Text', 11)) - token_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_token, show='*', width=60) - token_win = self.bg_canvas.create_window(x_label + 200, y - 8, window=token_entry, anchor='nw') - self._canvas_items.append(token_win) - self._overlay_widgets.append(token_entry) - y += 34 - - # Context selection for both Service and Prod - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Kubernetes Context:', fill='black', font=('SF Pro Text', 12, 'bold'))) - y += 28 - values = self._get_kubectx_list() - combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_kubectx, values=values, state='readonly', width=40) - if not self.selected_kubectx.get() and values: - self.selected_kubectx.set(values[0]) - combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw') - self._canvas_items.append(combo_win) - self._overlay_widgets.append(combo) - y += 40 - - if selected_env_key == 'prod': - # Staging directory input - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Local Artifact Staging Directory:', fill='black', font=('SF Pro Text', 12, 'bold'))) - y += 30 - - entry = tk.Entry(self.bg_canvas, textvariable=self.prod_artifacts_path, width=60) - entry_win = self.bg_canvas.create_window(x_label + 20, y, window=entry, anchor='nw') - self._canvas_items.append(entry_win) - self._overlay_widgets.append(entry) - - browse_btn = tk.Button(self.bg_canvas, text="Browse...", command=lambda: self.prod_artifacts_path.set(filedialog.askdirectory() or self.prod_artifacts_path.get())) - browse_win = self.bg_canvas.create_window(x_label + 440, y - 5, window=browse_btn, anchor='nw') - self._canvas_items.append(browse_win) - self._overlay_widgets.append(browse_btn) - y += 30 - - ui.canvas_text(self, x_label + 20, y, '(Used by etc/deploy_pipeline.sh --mode gcp)', fill='#6e6e73', font=('SF Pro Text', 10)) - y += 34 - - # Common Services Status Check (Notebook) - # Show if in Service/Prod or if a cluster is selected in Dev - show_notebook = False - if selected_env_key == 'dev': - if self.selected_k3d_cluster.get(): - show_notebook = True - else: - show_notebook = True - - if show_notebook: - service_label_text = f"Common Services (namespace: {self._get_service_namespace()})" - service_label = ui.canvas_text(self, x_label, y, service_label_text, fill='black', font=('SF Pro Text', 12, 'bold')) - self._canvas_items.append(service_label) - y += 28 - - notebook = ttk.Notebook(self.bg_canvas) - status_frame = tk.Frame(notebook, bg='white') - deploy_frame = tk.Frame(notebook, bg='white') - status_console = ui.TerminalConsole(status_frame) - deploy_console = ui.TerminalConsole(deploy_frame) - status_console.pack(fill='both', expand=True) - deploy_console.pack(fill='both', expand=True) - notebook.add(status_frame, text='Status') - notebook.add(deploy_frame, text='Deploy Output') - - self._k3s_service_notebook = notebook - self._k3s_service_status_console = status_console - self._k3s_service_deploy_console = deploy_console - self._k3s_service_status_tab = status_frame - self._k3s_service_deploy_tab = deploy_frame - - console_width = 900 - console_height = 210 - console_window = self.bg_canvas.create_window( - x_label, y, window=notebook, anchor='nw', - width=console_width, height=console_height - ) - self._canvas_items.append(console_window) - self._overlay_widgets.extend([notebook, status_frame, deploy_frame, status_console, deploy_console]) - - restored = self._restore_common_services_log(deploy_console) - if restored: - try: - self._k3s_service_notebook.select(self._k3s_service_deploy_tab) - except Exception: - pass - y += console_height + 12 - - deploy_btn = tk.Button( - self.bg_canvas, - text='Deploy Missing Services', - command=self._deploy_k3s_services, - bg='#F5F5DC', - fg='black', - activebackground='#E5E5D5', - highlightbackground='#F5F5DC', - highlightthickness=0, - relief='flat', - font=('SF Pro Text', 10), - padx=10, - pady=5, - ) - deploy_win = self.bg_canvas.create_window(x_label + 20, y, window=deploy_btn, anchor='nw') - self._canvas_items.append(deploy_win) - self._overlay_widgets.append(deploy_btn) - y += 40 - self._verify_k3s_services() - - def _openbao_url(self) -> str: - url = (os.environ.get("PROLE_OPENBAO_URL") or "http://127.0.0.1:18200").strip() - return url.rstrip('/') - - def _check_openbao_health(self) -> bool: - """Check OpenBao health in the currently selected namespace/pod. - Preference order: - 1) If in dev k3d or service k3s cluster, check k8s resources in selected namespace (deployment/statefulset ready) - 2) Fallback to HTTP health endpoint if PROLE_OPENBAO_URL (or default) responds - """ - try: - env_key = self._cluster_env_key() - except Exception: - env_key = 'dev' - - # 1) Check k8s readiness in selected namespace when using dev/service clusters - try: - if env_key == 'service': - ns = self._get_service_namespace() - else: - ns = (self.db_namespace.get() or '').strip() or 'default' - except Exception: - ns = 'default' - - try: - base_cmd = self._kubectl_base_cmd(env_key) - # deployment/openbao readiness - res_dep = subprocess.run(base_cmd + ['-n', ns, 'get', 'deploy', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], - capture_output=True, text=True, timeout=3) - ready_dep = (res_dep.returncode == 0 and (res_dep.stdout or '0').strip() not in ('', '0')) - - # statefulset/openbao readiness - res_sts = subprocess.run(base_cmd + ['-n', ns, 'get', 'statefulset', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], - capture_output=True, text=True, timeout=3) - ready_sts = (res_sts.returncode == 0 and (res_sts.stdout or '0').strip() not in ('', '0')) - - if ready_dep or ready_sts: - return True - except Exception: - pass - - # 2) Fallback to HTTP health - url = self._openbao_url() - if not url: - return False - health_url = f"{url}/v1/sys/health" - try: - with urllib.request.urlopen(health_url, timeout=2): - return True - except urllib.error.HTTPError: - # OpenBao responds with non-200 for sealed/standby; still reachable - return True - except Exception: - return False - - def _dashboard_kong_status(self, env: str | None = None) -> dict: - """Check Kubernetes Dashboard (Kong) pod health and return status info.""" - ns = "kubernetes-dashboard" - base_cmd = self._kubectl_base_cmd(env) - - def _pod_healthy(pod_status: dict) -> bool: - phase = (pod_status.get('phase') or '').strip() - if phase not in ('Running', 'Succeeded'): - return False - statuses = pod_status.get('containerStatuses') or [] - if not statuses: - return phase == 'Succeeded' - for cs in statuses: - if cs.get('ready') is True: - continue - state = cs.get('state') or {} - waiting = state.get('waiting') or {} - reason = (waiting.get('reason') or '').lower() - if reason: - return False - return False - return True - - # Ensure namespace exists - rc_ns, out_ns = self._run_cmd_capture(base_cmd + ['get', 'ns', ns, '-o', 'name'], timeout=6) - if rc_ns != 0 or ns not in (out_ns or ''): - return { - 'ok': False, - 'msg': 'Dashboard (Kong): Missing', - 'fill': '#ff9f0a', - 'reset_pods': [], - } - - rc, out = self._run_cmd_capture(base_cmd + ['get', 'pods', '-n', ns, '-o', 'json'], timeout=8) - if rc != 0: - return { - 'ok': False, - 'msg': 'Dashboard (Kong): Unable to query', - 'fill': '#ff9f0a', - 'reset_pods': [], - } - try: - payload = json.loads(out or "{}") - except Exception: - return { - 'ok': False, - 'msg': 'Dashboard (Kong): Unable to parse status', - 'fill': '#ff9f0a', - 'reset_pods': [], - } - - kong_pods = [] - for item in payload.get('items', []): - name = (item.get('metadata', {}) or {}).get('name', '') - name_lc = name.lower() - if 'kong' in name_lc and 'dashboard' in name_lc: - kong_pods.append(item) - - if not kong_pods: - return { - 'ok': False, - 'msg': 'Dashboard (Kong): Missing', - 'fill': '#ff9f0a', - 'reset_pods': [], - } - - unhealthy = [] - for item in kong_pods: - name = (item.get('metadata', {}) or {}).get('name', '') - status = item.get('status') or {} - if not _pod_healthy(status): - unhealthy.append(name) - - if unhealthy: - return { - 'ok': False, - 'msg': 'Dashboard (Kong): Unhealthy (safe to reset)', - 'fill': '#ff9f0a', - 'reset_pods': unhealthy, - } - - return { - 'ok': True, - 'msg': 'Dashboard (Kong): Running', - 'fill': '#34c759', - 'reset_pods': [], - } - - def _set_cluster_env_message(self, text: str, color: str = '#34c759', clear_after_ms: int | None = None): - """Set a transient status message on the Cluster Environment screen.""" - def _apply(): - if not hasattr(self, '_cluster_env_message_label'): - return - if not self.bg_canvas.winfo_exists(): - return - try: - self.bg_canvas.itemconfig(self._cluster_env_message_label, text=text, fill=color) - except Exception: - pass - self.safe_after(_apply) - - if clear_after_ms is not None and clear_after_ms >= 0: - def _clear(): - if not hasattr(self, '_cluster_env_message_label'): - return - if not self.bg_canvas.winfo_exists(): - return - try: - self.bg_canvas.itemconfig(self._cluster_env_message_label, text="") - except Exception: - pass - self.safe_after(_clear, delay=clear_after_ms) - - def _authority_context_missing(self) -> bool: - """Detect missing authority Docker context when Kerberos is enabled.""" - try: - if not self.kerberos_enabled.get(): - return False - except Exception: - return False - - candidates = [] - env_home = (os.environ.get('PROLE_HOME') or '').strip() - if env_home: - candidates.append(Path(env_home)) - candidates.append(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 _maybe_run_repair_pipeline(self, status: dict, kong_status: dict | None): - """Run repair pipeline when cluster is ready and anomalies are detected.""" - if not status.get('cluster_ok'): - return - - anomalies = [] - if kong_status and not kong_status.get('ok', True): - anomalies.append('dashboard') - if self._authority_context_missing(): - anomalies.append('authority') - - if not anomalies: - return - - now = time.time() - if self._repair_inflight: - return - if now - self._repair_last_run_at < self._repair_cooldown_s: - return - - self._repair_last_run_at = now - self._repair_inflight = True - - def worker(): - try: - env_key = status.get('env') or self._cluster_env_key() - if env_key == 'service': - ns = self._get_service_namespace() - else: - ns = (self.db_namespace.get() or '').strip() or 'default' - env = self._script_env_for_namespace(ns) - if env_key == 'service': - env["PROLE_MODE"] = "k3s" - elif env_key == 'dev': - env["PROLE_MODE"] = "k3d" - self._set_cluster_env_message("Repair pipeline started", '#34c759', clear_after_ms=3000) - self.controller.run_script( - "repair_pipeline.sh", - args=["-n", ns], - env=env, - ) - self._set_cluster_env_message("Repair pipeline complete", '#34c759', clear_after_ms=3000) - except Exception as e: - self._set_cluster_env_message(f"Repair pipeline failed: {e}", '#ff3b30', clear_after_ms=6000) - finally: - self._repair_inflight = False - - threading.Thread(target=worker, daemon=True).start() - - def _check_opentofu_health(self) -> bool: - """Check OpenTofu readiness in the selected namespace.""" - try: - env_key = self._cluster_env_key() - except Exception: - env_key = 'dev' - - try: - if env_key == 'service': - ns = self._get_service_namespace() - else: - ns = (self.db_namespace.get() or '').strip() or 'default' - except Exception: - ns = 'default' - - try: - base_cmd = self._kubectl_base_cmd(env_key) - res_dep = subprocess.run( - base_cmd + ['-n', ns, 'get', 'deploy', 'opentofu', '-o', 'jsonpath={.status.readyReplicas}'], - capture_output=True, text=True, timeout=3 - ) - ready_dep = (res_dep.returncode == 0 and (res_dep.stdout or '0').strip() not in ('', '0')) - if ready_dep: - return True - - res_sts = subprocess.run( - base_cmd + ['-n', ns, 'get', 'statefulset', 'opentofu', '-o', 'jsonpath={.status.readyReplicas}'], - capture_output=True, text=True, timeout=3 - ) - ready_sts = (res_sts.returncode == 0 and (res_sts.stdout or '0').strip() not in ('', '0')) - return ready_sts - except Exception: - return False - - def _check_k8s_cluster(self, env_label: str) -> tuple[bool, str]: - env_key = self._cluster_env_key(env_label) - label = "K3s Cluster" if env_key == 'service' else "Prod Cluster" if env_key == 'prod' else "Kubernetes Cluster" - try: - kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) - if kubectl.returncode != 0: - return False, f"{label}: kubectl not found" - if env_key == 'service': - kubeconfig = _find_kubeconfig_file() - if not kubeconfig: - server, token = self._k3s_connection_info() - if not server or not token: - return False, f"{label}: missing server URL/token or kubeconfig" - cmd = self._kubectl_base_cmd(env_key) + ['cluster-info'] - res = subprocess.run(cmd, capture_output=True, text=True, timeout=8) - ok = res.returncode == 0 - except Exception: - ok = False - msg = f"{label}: Connected" if ok else f"{label}: Not reachable" - return ok, msg - - def _get_k3d_cluster_list(self) -> list[str]: - """Get list of k3d clusters.""" - try: - res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True, timeout=5) - if res.returncode == 0: - # Extract first column (cluster names) - return [line.split()[0] for line in res.stdout.splitlines() if line.strip()] - except Exception: - pass - return [] - - def _on_create_k3d_cluster(self): - name = self.selected_k3d_cluster.get().strip() - if not name: - messagebox.showerror("Error", "Cluster name cannot be empty") - return - # Persist the typed name so the refreshed page keeps it selected - self.k3d_cluster_name.set(name) - - # If the cluster already exists just select it (triggers notebook display) - existing = self._get_k3d_cluster_list() - if name in existing: - self.selected_k3d_cluster.set(name) - self.show_page('init_cluster') - return - - # Refresh the page so the notebook/console is visible before streaming output - self.selected_k3d_cluster.set(name) - self.show_page('init_cluster') - - def do_create(): - console = getattr(self, '_k3s_service_deploy_console', None) - if console: - console.clear() - console.write(f"Creating k3d cluster '{name}' @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") - try: - if self._k3s_service_notebook and self._k3s_service_deploy_tab: - self._k3s_service_notebook.select(self._k3s_service_deploy_tab) - except Exception: - pass - try: - # Build the k3d create command - prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) - volume_args = _k3d_prole_data_volume_args(prole_data) - reg_args = [] - if getattr(self, 'registry_url', None): - if self.registry_url.startswith('localhost:5000'): - reg_args = ['--registry-create', 'prole-registry:0.0.0.0:5000'] - else: - reg_args = ['--registry-use', self.registry_url] - cmd = ['k3d', 'cluster', 'create', name, '-a', '2', '--wait'] + volume_args + reg_args + ['--timestamps'] - if console: - console.write(f"$ {' '.join(cmd)}\n\n") - - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, cwd=PROJECT_ROOT) - for line in proc.stdout: - if console: - console.write(line) - proc.wait() - if proc.returncode != 0: - if console: - console.write(f"\nk3d cluster create failed (exit {proc.returncode})\n") - return - if console: - console.write(f"\nCluster '{name}' created successfully.\n\n") - - # Merge kubeconfig so kubectl can reach the new cluster - try: - subprocess.run( - ['k3d', 'kubeconfig', 'merge', name, '--kubeconfig-switch-context'], - capture_output=True, text=True, timeout=10 - ) - if console: - console.write(f"Kubeconfig merged for cluster '{name}'.\n\n") - except Exception as kce: - if console: - console.write(f"Warning: failed to merge kubeconfig: {kce}\n") - - # Refresh UI and then deploy common services check - self.safe_after(self._refresh_k3d_ui) - # Trigger common services check in the deploy console - self.safe_after(self._deploy_k3s_services) - except Exception as e: - if console: - console.write(f"\nError: {e}\n") - self.safe_after(lambda: messagebox.showerror("Error", f"Failed to create k3d cluster: {e}")) - - threading.Thread(target=do_create, daemon=True).start() - - def _refresh_k3d_ui(self): - clusters = self._get_k3d_cluster_list() - self.k3d_cluster_list.set(clusters) - name = self.k3d_cluster_name.get().strip() - if name in clusters: - self.selected_k3d_cluster.set(name) - self.show_page('init_cluster') - - def _on_k3d_cluster_select(self, *args): - """Called when a k3d cluster is selected from the dropdown.""" - cluster_name = self.selected_k3d_cluster.get() - if cluster_name: - try: - # Merge kubeconfig and switch context to the selected cluster - subprocess.run(['k3d', 'kubeconfig', 'merge', cluster_name, '--switch-context'], capture_output=True, text=True) - except Exception: - pass - self.show_page('init_cluster') - - def _on_delete_k3d_cluster(self): - """Delete the selected k3d cluster after user confirmation.""" - name = self.selected_k3d_cluster.get().strip() - if not name: - messagebox.showerror("Error", "No cluster selected to delete.") - return - - confirmed = messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete cluster '{name}'?") - if not confirmed: - return - - # Show the notebook/console before streaming output - self.show_page('init_cluster') - - def do_delete(): - console = getattr(self, '_k3s_service_deploy_console', None) - if console: - console.clear() - console.write(f"Deleting k3d cluster '{name}' @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") - try: - if self._k3s_service_notebook and self._k3s_service_deploy_tab: - self._k3s_service_notebook.select(self._k3s_service_deploy_tab) - except Exception: - pass - try: - cmd = ['k3d', 'cluster', 'delete', name] - if console: - console.write(f"$ {' '.join(cmd)}\n\n") - - proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, cwd=PROJECT_ROOT) - for line in proc.stdout: - if console: - console.write(line) - proc.wait() - if proc.returncode != 0: - if console: - console.write(f"\nk3d cluster delete failed (exit {proc.returncode})\n") - return - if console: - console.write(f"\nCluster '{name}' deleted successfully.\n") - - # Clear selection and refresh UI - self.selected_k3d_cluster.set('') - self.k3d_cluster_name.set('') - self.safe_after(self._refresh_k3d_ui) - except Exception as e: - if console: - console.write(f"\nError: {e}\n") - self.safe_after(lambda: messagebox.showerror("Error", f"Failed to delete k3d cluster: {e}")) - - threading.Thread(target=do_delete, daemon=True).start() - - def _get_kubectx_list(self) -> list[str]: - """Get list of kubernetes contexts.""" - try: - res = subprocess.run(["kubectx"], capture_output=True, text=True) - if res.returncode == 0: - return res.stdout.strip().split('\n') - - # Fallback to kubectl - res = subprocess.run(["kubectl", "config", "get-contexts", "-o", "name"], capture_output=True, text=True) - if res.returncode == 0: - return res.stdout.strip().split('\n') - except Exception: - pass - return ["default"] - - def _verify_k3s_services(self): - if not self._k3s_service_status_console: - self._stop_k3s_service_status_updates() - return - self._start_k3s_service_status_updates() - - def _start_k3s_service_status_updates(self): - if not self._k3s_status_active: - self._k3s_status_active = True - if self._k3s_status_after_id: - try: - self.root.after_cancel(self._k3s_status_after_id) - except Exception: - pass - self._k3s_status_after_id = None - self._schedule_k3s_service_status_update(0) - - def _stop_k3s_service_status_updates(self): - self._k3s_status_active = False - self._k3s_status_inflight = False - if self._k3s_status_after_id: - try: - self.root.after_cancel(self._k3s_status_after_id) - except Exception: - pass - self._k3s_status_after_id = None - - def _schedule_k3s_service_status_update(self, delay_ms: int): - if not self._k3s_status_active: - return - if not self.root or not self.root.winfo_exists(): - return - try: - self._k3s_status_after_id = self.root.after(delay_ms, self._run_k3s_service_status_once) - except Exception: - self._k3s_status_after_id = None - - def _run_k3s_service_status_once(self): - if self._k3s_status_inflight: - return - if not self._k3s_service_status_console: - self._k3s_status_inflight = False - self.safe_after(lambda: self._schedule_k3s_service_status_update(30000)) - return - self._k3s_status_inflight = True - console = self._k3s_service_status_console - console.clear() - console.write(f"Service status refresh @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") - - def worker(): - try: - ns = self._get_service_namespace() - env = self._script_env_for_namespace(ns) - env["PROLE_MODE"] = self._deployment_mode() - - if not env.get("KUBECONFIG"): - console.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") - return - - status_args = ["-n", ns] - if self.kerberos_enabled.get(): - status_args.append("-k") - rc = self.controller.run_script( - "status_common_services.sh", - args=status_args, - env=env, - on_line=lambda l: console.write(l) - ) - console.write(f"\nExit status: {rc}\n") - finally: - self._k3s_status_inflight = False - self.safe_after(lambda: self._schedule_k3s_service_status_update(30000)) - - threading.Thread(target=worker, daemon=True).start() - - def _deploy_k3s_services(self): - """Deploy common services (ArgoCD/OpenBao/OpenTofu/Garage) to remote k3s cluster.""" - def _deploy(): - console = self._k3s_service_deploy_console - if console: - console.clear() - console.write(f"Deploying common services @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") - try: - if self._k3s_service_notebook and self._k3s_service_deploy_tab: - self._k3s_service_notebook.select(self._k3s_service_deploy_tab) - except Exception: - pass - - service_ns = self._get_service_namespace() - env = self._script_env_for_namespace(service_ns) - env["PROLE_MODE"] = self._deployment_mode() - log_path = self._common_services_log_path() - env["COMMON_SERVICES_INIT_LOG"] = str(log_path) - try: - log_fp = log_path.open('a', encoding='utf-8') - log_fp.write(f"\n# Common services deploy @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") - log_fp.flush() - except Exception: - log_fp = None - if console: - console.write(f"Log file: {log_path}\n\n") - - if not env.get("KUBECONFIG"): - if console: - console.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") - if log_fp: - try: - log_fp.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") - log_fp.close() - except Exception: - pass - self._verify_k3s_services() - return - - try: - common_args = ["-n", env["NAMESPACE"]] - if self.kerberos_enabled.get(): - common_args.append("-k") - common_args.append("update") - def _log_line(line: str): - if console: - console.write(line) - if log_fp: - try: - log_fp.write(line) - log_fp.flush() - except Exception: - pass - rc = self.controller.run_script( - "init_common_services.sh", - args=common_args, - env=env, - on_line=_log_line - ) - if console: - console.write(f"\nExit status: {rc}\n") - if log_fp: - try: - log_fp.write(f"\nExit status: {rc}\n") - except Exception: - pass - finally: - if log_fp: - try: - log_fp.close() - except Exception: - pass - - self._verify_k3s_services() - - threading.Thread(target=_deploy, daemon=True).start() - - def _validate_and_save_cluster_config(self) -> bool: - """Verify the config then write the values to prole.cfg.""" - env_key = self._cluster_env_key() - if env_key == 'dev': - cluster_val = self.selected_k3d_cluster.get() or 'prole-dev-cluster' - if not cluster_val: - messagebox.showwarning("Validation", "Please select or create a k3d cluster.") - return False - - # Context switch (if context matches cluster name) - try: - subprocess.run(['kubectl', 'config', 'use-context', f"k3d-{cluster_val}"], capture_output=True, text=True) - except Exception: - pass - elif env_key == 'service': - if not (self.k3s_server_url.get() or '').strip(): - messagebox.showwarning("Validation", "Please specify the K3s server URL.") - return False - if not (self.k3s_token.get() or '').strip(): - messagebox.showwarning("Validation", "Please specify the K3s token.") - return False - cluster_val = self.cluster_env.get() - elif env_key == 'prod': - path = self.prod_artifacts_path.get().strip() - if not path: - messagebox.showwarning("Validation", "Please specify an artifact staging directory.") - return False - p = Path(path).expanduser() - if not p.exists(): - try: - p.mkdir(parents=True, exist_ok=True) - except Exception as e: - messagebox.showerror("Error", f"Failed to create directory {path}: {e}") - return False - cluster_val = self.cluster_env.get() - else: - cluster_val = self.cluster_env.get() - - # Capture cluster info for config - self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = cluster_val - self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = (self.k3s_server_url.get() or '').strip() - self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') - self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = self._get_service_namespace() - self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) - self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) - self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = str(self.at_rest_encryption_enabled.get()) - - if env_key == 'prod': - self.prole_cfg_data['Prod Cluster (k8s)']['STAGING_DIRECTORY'] = self.prod_artifacts_path.get() - - # Save config - self._save_prole_cfg() - return True - - def _cluster_status_snapshot(self) -> dict: - env = self._cluster_env_key() - - cluster_ok = False - if env == 'dev': - cluster_name = self.selected_k3d_cluster.get() or "prole-dev-cluster" - try: - res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) - cluster_ok = cluster_name in (res.stdout or '') - except Exception: - cluster_ok = False - cluster_msg = f"K3D Cluster ({cluster_name}): Running" if cluster_ok else f"K3D Cluster ({cluster_name}): Not found" - else: - cluster_ok, cluster_msg = self._check_k8s_cluster(env) - cluster_fill = '#34c759' if cluster_ok else '#ff9f0a' - - common_ok = cluster_ok - common_msg = 'Common Services: Ready to deploy' if common_ok else 'Common Services: Not ready' - common_fill = '#34c759' if common_ok else '#ff9f0a' - - return { - 'env': env, - 'cluster_ok': cluster_ok, - 'cluster_msg': cluster_msg, - 'cluster_fill': cluster_fill, - 'common_ok': common_ok, - 'common_msg': common_msg, - 'common_fill': common_fill, - } - - def _cluster_ready_for_navigation(self) -> bool: - status = self._cluster_status_snapshot() - if not status['cluster_ok']: - try: - messagebox.showerror('Cluster', 'Cluster is not reachable. Please verify your cluster and try again.') - except Exception: - pass - return False - return True - - def check_cluster_status_async(self): - def worker(): - notice_active = False - self._set_cluster_env_message("Collect Status", '#34c759') - status = self._cluster_status_snapshot() - self._set_cluster_env_message("Verify State", '#34c759') - kong_status = None - if status.get('cluster_ok'): - kong_status = self._dashboard_kong_status(status.get('env')) - self._set_cluster_env_message("Assess Actions", '#34c759') - - # Repair step: reset Dashboard (Kong) pods if unhealthy, with cooldown - if kong_status and kong_status.get('reset_pods'): - now = time.time() - if now - self._kong_last_repair_at >= self._kong_repair_cooldown_s: - self._set_cluster_env_message("Execute Actions (REPAIR): reset Dashboard (Kong) pod(s)", '#34c759') - pods = kong_status.get('reset_pods') or [] - cmd = self._kubectl_base_cmd(status.get('env')) + ['delete', 'pod', '-n', 'kubernetes-dashboard'] + pods - rc, _out = self._run_cmd_capture(cmd, timeout=10) - if rc != 0: - self._set_cluster_env_message("Notice: Dashboard (Kong) repair failed", '#ff3b30', clear_after_ms=6000) - notice_active = True - else: - self._kong_last_repair_at = now - # Give the cluster a moment to recreate pods before re-check - time.sleep(2) - self._set_cluster_env_message("Collect Status", '#34c759') - status = self._cluster_status_snapshot() - else: - self._set_cluster_env_message("Execute Actions (REPAIR): skipped (recently attempted)", '#34c759', clear_after_ms=1500) - else: - self._set_cluster_env_message("Execute Actions (REPAIR): none needed", '#34c759', clear_after_ms=1500) - - # Run the repair pipeline if anomalies are detected on a ready cluster - try: - self._maybe_run_repair_pipeline(status, kong_status) - except Exception: - pass - - def update_ui(): - if hasattr(self, 'k3d_status_label'): - self.bg_canvas.itemconfig(self.k3d_status_label, text=status['cluster_msg'], fill=status['cluster_fill']) - if hasattr(self, 'common_services_status_label'): - self.bg_canvas.itemconfig(self.common_services_status_label, text=status['common_msg'], fill=status['common_fill']) - if hasattr(self, '_init_cluster_button'): - try: - self._init_cluster_button.configure(text='Save') - except Exception: - pass - - self.root.after(0, update_ui) - # Clear message after a short delay if no notice is active - if not notice_active: - self._set_cluster_env_message("", '#34c759', clear_after_ms=2000) - - threading.Thread(target=worker, daemon=True).start() - - def _services_status_snapshot(self) -> dict: - openbao_ok = self._check_openbao_health() - opentofu_ok = self._check_opentofu_health() - - openbao_msg = 'OpenBao: Running' if openbao_ok else 'OpenBao: Not reachable' - opentofu_msg = 'OpenTofu: Running' if opentofu_ok else 'OpenTofu: Not reachable' - - openbao_fill = '#34c759' if openbao_ok else '#ff9f0a' - opentofu_fill = '#34c759' if opentofu_ok else '#ff9f0a' - - return { - 'openbao_msg': openbao_msg, - 'openbao_fill': openbao_fill, - 'opentofu_msg': opentofu_msg, - 'opentofu_fill': opentofu_fill, - } - - def check_services_status_async(self): - def worker(): - status = self._services_status_snapshot() - - def update_ui(): - if hasattr(self, '_db_openbao_status_label'): - self.bg_canvas.itemconfig(self._db_openbao_status_label, text=status['openbao_msg'], fill=status['openbao_fill']) - if hasattr(self, '_db_opentofu_status_label'): - self.bg_canvas.itemconfig(self._db_opentofu_status_label, text=status['opentofu_msg'], fill=status['opentofu_fill']) - - self.root.after(0, update_ui) - - threading.Thread(target=worker, daemon=True).start() - - def ensure_cluster_ready(self): - self._action_flags['init_cluster.start_cluster'] = True - # Implementation of cluster creation/startup - def worker(): - cluster_env = self._cluster_env_key() - - if cluster_env == 'dev': - # 1. Start Docker if not running - if not self.controller.check_docker_running(): - # Attempt to start Docker on macOS - subprocess.run(['open', '-a', 'Docker'], capture_output=True) - # Wait for it to start - for _ in range(30): - time.sleep(2) - if self.controller.check_docker_running(): - break - - if not self.controller.check_docker_running(): - self.root.after(0, lambda: messagebox.showerror('Docker', 'Could not start Docker. Please start it manually.')) - return - - # 2. Manage or verify cluster - 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: - subprocess.run(['k3d', 'cluster', 'delete', cluster_name], capture_output=True) - res_stdout = '' - self._reset_cluster = False - if cluster_name not in res_stdout: - # Create it - # Default args based on README.md - prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) - volume_args = _k3d_prole_data_volume_args(prole_data) - cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2'] + volume_args - reg_args = [] - try: - if self.ensure_local_registry_available(): - reg_args = ['--registry-use', 'k3d-prole-registry:5000'] - except Exception: - reg_args = [] - cmd += reg_args + ['--api-port', '0.0.0.0:6443'] - - # Run in terminal or capture output? Let's use a console window later. - # For now, run it and update status. - subprocess.run(cmd, capture_output=True) - else: - # Start it if it's stopped - subprocess.run(['k3d', 'cluster', 'start', cluster_name], capture_output=True) - - self.check_cluster_status_async() - self.root.after(0, lambda: messagebox.showinfo('Cluster', f'Cluster {cluster_name} is ready.')) - else: - if self._reset_cluster and cluster_env in ('service', 'k3s'): - ns = (self._get_service_namespace() or '').strip() or 'default' - server, token = self._k3s_connection_info() - _reset_k3s_namespace(self.controller.project_root, ns, server, token) - self._reset_cluster = False - ok, msg = self._check_k8s_cluster(cluster_env) - if not ok: - self.root.after(0, lambda: messagebox.showerror('Cluster', msg)) - return - self.check_cluster_status_async() - self.root.after(0, lambda: messagebox.showinfo('Cluster', msg)) - - threading.Thread(target=worker, daemon=True).start() - - def _render_init_db_build_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Build Database Image', y=150) - self._render_paragraph('Building the prole-db Postgres image. This may take a few minutes.', y=200) - - # Local registry status (checked async) - self._db_registry_status_label = ui.canvas_text(self, 48, 230, "Local Registry: Checking...", fill='#6e6e73', font=('SF Pro Text', 11)) - self._canvas_items.append(self._db_registry_status_label) - - # Output Console - self._db_build_console = self._create_console_output(y=260, title="Build Output", width=900, height=520) - - # Use tk.Button - self._db_build_button = tk.Button(self.bg_canvas, text='Start Build', command=self.run_db_build, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - btn_window = self.bg_canvas.create_window(48, 800, window=self._db_build_button, anchor='nw', width=180) - self._canvas_items.append(btn_window) - self._overlay_widgets.append(self._db_build_button) - - # Status Label - self._db_build_status_label = ui.canvas_text(self, 240, 812, "", fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(self._db_build_status_label) - - # Ensure registry check runs when entering the screen - self._ensure_db_build_registry_async() - - def _ensure_db_build_registry_async(self): - def worker(): - try: - if not self.check_docker_running(): - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: Docker not running", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - return - info = self.ensure_local_registry_available() - if info: - host_registry, _cluster_registry = info - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=f"Local Registry: {host_registry}", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - else: - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: unavailable", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - log_path = getattr(self, '_last_registry_log_path', None) - if log_path and self._db_build_console: - self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n")) - except Exception as e: - msg = f"Local Registry: error ({e})" - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=msg, fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - log_path = getattr(self, '_last_registry_log_path', None) - if log_path and self._db_build_console: - self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n")) - - threading.Thread(target=worker, daemon=True).start() - - def safe_after(self, func, delay=0): - """Run a function in the main thread if the root window still exists.""" - if not self.root or not self.root.winfo_exists(): - return - - def wrapper(): - try: - if self.root and self.root.winfo_exists(): - func() - except (tk.TclError, RuntimeError): - pass - try: - self.root.after(delay, wrapper) - except (tk.TclError, RuntimeError): - pass - - def run_db_build(self): - self._action_flags['init_db_build.run_build'] = True - def worker(): - self.safe_after(lambda: self._db_build_button.configure(state='disabled') if self._db_build_button.winfo_exists() else None) - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Building...", fill='blue') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - - tag = self.get_prole_db_version() - image_name = f"prole-db:{tag}" - - # Use $HOME/.prole/build for Docker build context - # This avoids issues with PyInstaller's temporary _MEIPASS directory - prole_home = Path.home() / ".prole" - build_dir = prole_home / "build" / "prole-db" - build_dir.mkdir(parents=True, exist_ok=True) - - # Copy prole-db directory to writable location - source_dir = get_resource_path("prole-db") - if source_dir.exists(): - import shutil - # Remove old build dir and copy fresh - if source_dir.resolve() != build_dir.resolve(): - if build_dir.exists(): - shutil.rmtree(build_dir) - shutil.copytree(source_dir, build_dir) - - cwd = build_dir - - # Fetch the generated public key - pub_key = "" - pub_key_path = Path.home() / ".ssh" / "id_prole_ed25519.pub" - if pub_key_path.exists(): - pub_key = pub_key_path.read_text().strip() - - username = self.db_username.get() - env_key = self._cluster_env_key() - 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, '.' - ] - - self._db_build_console.clear() - self._db_build_console.write(f"Building {image_name} in {cwd}...\n\n") - - proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - while True: - line = proc.stdout.readline() - if not line and proc.poll() is not None: - break - if line: - self._db_build_console.write(line) - - if proc.returncode == 0: - self._db_build_console.write("\nBuild successful!\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build successful! Tagging...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - self._db_built_success = True - - if env_key == 'dev': - # Tag + push to local registry for k3d pulls - registry_info = self.ensure_local_registry_available() - import_tag = image_name - if registry_info: - host_registry, cluster_registry = registry_info - remote_tag = f"{host_registry}/prole-db:{tag}" - self._db_build_console.write(f"Tagging image for registry: {remote_tag}\n") - tag_res = subprocess.run(['docker', 'tag', image_name, remote_tag], capture_output=True, text=True) - if tag_res.returncode != 0: - self._db_build_console.write(tag_res.stdout or '') - self._db_build_console.write(tag_res.stderr or '') - self._db_build_console.write("Tag failed; skipping push/import.\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, tag failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) - return - - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Pushing to registry...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - if not _push_docker_image(remote_tag, log_fn=self._db_build_console.write): - self._db_build_console.write("Push failed; skipping import.\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, push failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) - return - self._db_build_console.write(f"Push complete: {remote_tag}\n") - - if cluster_registry: - import_tag = f"{cluster_registry}/prole-db:{tag}" - # k3d registry is often reachable internally without the .localhost suffix - if cluster_registry.endswith('.localhost:5000'): - alt_registry = cluster_registry.replace('.localhost', '') - alt_tag = f"{alt_registry}/prole-db:{tag}" - if alt_tag != import_tag: - self._db_build_console.write(f"Tagging image for k3d import: {alt_tag}\n") - subprocess.run(['docker', 'tag', image_name, alt_tag], capture_output=True, text=True) - import_tag = alt_tag - else: - self._db_build_console.write("Local registry unavailable; skipping tag/push.\n") - - # Import to k3d - cluster_name = "prole-dev-cluster" - self._db_build_console.write(f"Importing image to {cluster_name} ({import_tag})...\n") - subprocess.run(['k3d', 'image', 'import', import_tag, '-c', cluster_name]) - self._db_build_console.write("Import complete.\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build, tag, push, and import complete.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - else: - self._db_build_console.write("Import skipped for non-dev clusters.\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build complete. Import skipped.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - else: - self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text=f"Build failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) - - self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) - - threading.Thread(target=worker, daemon=True).start() - - def _render_init_password_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Database Creation', y=150) - self._render_paragraph('Create a database namespace for this deployment. The namespace is treated as a single database with a name and one root password. An ed25519 SSH key will be generated and stored in OpenBao for root access.', y=200) - - x_label = 48 - x_field = 300 - y = 270 - - # Database (namespace) name - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Database Name (Namespace):', fill='black', font=('SF Pro Text', 12, 'bold'))) - - ns_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#CCCCCC', highlightthickness=1, bd=0) - ns_frame.pack_propagate(False) - prefix_label = tk.Label(ns_frame, text=self._namespace_prefix(), fg='#9a9aa0', bg='white', font=('SF Pro Text', 11)) - vcmd = (self.root.register(self._validate_namespace_suffix), '%P') - suffix_entry = tk.Entry( - ns_frame, - textvariable=self.db_namespace_suffix, - bg='white', - fg='black', - insertbackground='black', - highlightthickness=0, - relief='flat', - font=('SF Pro Text', 11), - validate='key', - validatecommand=vcmd - ) - prefix_label.pack(side='left', padx=(8, 2)) - suffix_entry.pack(side='left', fill='both', expand=True, padx=(0, 8)) - ns_window = self.bg_canvas.create_window(x_field, y-12, window=ns_frame, anchor='nw', width=400, height=32) - self._canvas_items.append(ns_window) - self._overlay_widgets.append(ns_frame) - self._overlay_widgets.append(prefix_label) - self._overlay_widgets.append(suffix_entry) - - y += 42 - # Owner (local user) - self.db_username.set(self.namespace_owner) - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Owner:', fill='black', font=('SF Pro Text', 12, 'bold'))) - owner_entry = tk.Entry(self.bg_canvas, textvariable=self.db_username, bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - owner_window = self.bg_canvas.create_window(x_field, y-12, window=owner_entry, anchor='nw', width=400, height=32) - self._canvas_items.append(owner_window) - self._overlay_widgets.append(owner_entry) - - y += 42 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Master Password:', fill='black', font=('SF Pro Text', 12, 'bold'))) - # Use tk.Entry on canvas - p1 = tk.Entry(self.bg_canvas, textvariable=self.db_password, show='*', bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - p1_window = self.bg_canvas.create_window(x_field, y-12, window=p1, anchor='nw', width=400, height=32) - self._canvas_items.append(p1_window) - self._overlay_widgets.append(p1) - - y += 42 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Confirm:', fill='black', font=('SF Pro Text', 12, 'bold'))) - # Use tk.Entry on canvas - p2 = tk.Entry(self.bg_canvas, textvariable=self.db_password_confirm, show='*', bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - p2_window = self.bg_canvas.create_window(x_field, y-12, window=p2, anchor='nw', width=400, height=32) - self._canvas_items.append(p2_window) - self._overlay_widgets.append(p2) - - y += 42 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Host Port Forward:', fill='black', font=('SF Pro Text', 12, 'bold'))) - port_entry = tk.Entry(self.bg_canvas, textvariable=self.db_host_port, bg='white', fg='black', - insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, - relief='flat', font=('SF Pro Text', 11)) - port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=100, height=32) - self._canvas_items.append(port_window) - self._overlay_widgets.append(port_entry) - - # Indicator for password match (X or ✓) - self.password_indicator = ui.canvas_text(self, x_field + 410, y, '✘', fill='#dc3545', font=('SF Pro Text', 16, 'bold'), state='hidden') - self._canvas_items.append(self.password_indicator) - - # Optional Features moved above browser - y += 50 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Optional Features:', fill='black', font=('SF Pro Text', 13, 'bold'))) - - feature_options = [ - ('Enable Supabase', self.supabase_enabled), - ('Enable Kerberos Authentication', self.kerberos_enabled), - ('Enable At-Rest Encryption', self.at_rest_encryption_enabled), - ] - - fx = x_label + 20 - fy = y + 36 - for label, var in feature_options: - cb = tk.Checkbutton(self.bg_canvas, text=label, variable=var, - bg='white', fg='black', activebackground='white', - selectcolor='white', font=('SF Pro Text', 11), - command=self._save_prole_cfg) - cb_window = self.bg_canvas.create_window(fx, fy, window=cb, anchor='nw') - self._canvas_items.append(cb_window) - self._overlay_widgets.append(cb) - fx += 280 - - # Database browser - table_y = fy + 50 - self._canvas_items.append(ui.canvas_text(self, x_label, table_y, 'Database Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))) - - refresh_btn = tk.Button(self.bg_canvas, text='Refresh', command=self._refresh_namespace_table, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) - refresh_window = self.bg_canvas.create_window(x_label + 160, table_y - 8, window=refresh_btn, anchor='nw') - self._canvas_items.append(refresh_window) - self._overlay_widgets.append(refresh_btn) - - add_btn = tk.Button(self.bg_canvas, text='Add', command=self._db_add_namespace, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) - add_window = self.bg_canvas.create_window(x_label + 240, table_y - 8, window=add_btn, anchor='nw') - self._canvas_items.append(add_window) - self._overlay_widgets.append(add_btn) - - edit_btn = tk.Button(self.bg_canvas, text='Edit', command=self._db_edit_namespace, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) - edit_window = self.bg_canvas.create_window(x_label + 300, table_y - 8, window=edit_btn, anchor='nw') - self._canvas_items.append(edit_window) - self._overlay_widgets.append(edit_btn) - - delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._db_delete_namespace, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) - delete_window = self.bg_canvas.create_window(x_label + 360, table_y - 8, window=delete_btn, anchor='nw') - self._canvas_items.append(delete_window) - self._overlay_widgets.append(delete_btn) - - self._db_action_buttons = [refresh_btn, add_btn, edit_btn, delete_btn] - - table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1) - table_window = self.bg_canvas.create_window(x_label, table_y + 30, window=table_frame, anchor='nw', width=900, height=180) - self._canvas_items.append(table_window) - self._overlay_widgets.append(table_frame) - - columns = ('select', 'name', 'owner', 'port', 'created', 'status') - tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=5) - tree.heading('select', text='Select') - tree.heading('name', text='Name') - tree.heading('owner', text='Owner') - tree.heading('port', text='Host Port') - tree.heading('created', text='Creation Date') - tree.heading('status', text='Status') - tree.column('select', width=60, anchor='center') - tree.column('name', width=180, anchor='w') - tree.column('owner', width=140, anchor='w') - tree.column('port', width=80, anchor='center') - tree.column('created', width=200, anchor='w') - tree.column('status', width=120, anchor='center') - tree.pack(side='left', fill='both', expand=True) - - scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview) - tree.configure(yscrollcommand=scroll.set) - scroll.pack(side='right', fill='y') - - self._overlay_widgets.append(tree) - self._overlay_widgets.append(scroll) - self._db_namespace_tree = tree - - def on_select(event): - if getattr(self, '_refreshing_ns_table', False): - return - sel = tree.selection() - if sel: - vals = tree.item(sel[0], 'values') - if vals: - ns = vals[1] - if ns == 'supabase': - # Do not allow selecting 'supabase' as the primary prole-db namespace - return - if ns == self.db_namespace.get(): - # Already selected, don't trigger re-refresh/save - return - self.db_namespace.set(ns) - self._sync_namespace_suffix_from_full() - # Mark as selected in tree visually if needed, but we use the checkbox column - self._refresh_namespace_table() - self._save_prole_cfg() - if self.supabase_enabled.get(): - self._db_sync_supabase_ports(ns) - - tree.bind('<>', on_select) - - self._db_namespace_note = ui.canvas_text(self, x_label, table_y + 220, '', fill='#6e6e73', font=('SF Pro Text', 10)) - self._canvas_items.append(self._db_namespace_note) - - def on_password_change(*args): - p = self.db_password.get() - c = self.db_password_confirm.get() - - if not p: - self.bg_canvas.itemconfigure(self.password_indicator, state='hidden') - elif p == c: - self.bg_canvas.itemconfigure(self.password_indicator, text='✓', fill='#28a745', state='normal') - else: - self.bg_canvas.itemconfigure(self.password_indicator, text='✘', fill='#dc3545', state='normal') - - self.db_password.trace_add('write', on_password_change) - self.db_password_confirm.trace_add('write', on_password_change) - - # Add trace to host port to update mappings immediately - def on_port_change(*args): - try: - self._save_prole_cfg() - except: - pass - self.db_host_port.trace_add('write', on_port_change) - - # Trigger once in case they are already set - on_password_change() - - self._refresh_namespace_table() - - def _render_init_cnpg_deploy_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Deployment', y=150) - self._render_paragraph('Deploy the CloudNative-PG operator and cluster manifests to Kubernetes.', y=200) - - # Output Console - self._cnpg_deploy_console = self._create_console_output(y=260, title="Deployment Output", width=900, height=520) - - # Use tk.Button - self._cnpg_deploy_button = tk.Button(self.bg_canvas, text='Run Deployment', command=self.run_cnpg_deploy, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - self._cnpg_deploy_button.update_idletasks() - deploy_btn_height = self._cnpg_deploy_button.winfo_reqheight() or 32 - - # Deployment mode selector - mode_label = ui.canvas_text(self, 48, 782, "Mode", fill='#6e6e73', font=('SF Pro Text', 10, 'bold')) - self._canvas_items.append(mode_label) - mode_values = ['prole-dev-cluster', 'prole-service-cluster', 'prole-prod-cluster'] - mode_frame = tk.Frame(self.bg_canvas, bg='#F5F5DC', width=180, height=deploy_btn_height) - mode_frame.pack_propagate(False) - mode_combo = ttk.Combobox(mode_frame, textvariable=self.deploy_target, values=mode_values, state='readonly', width=24) - mode_combo.pack(fill='both', expand=True) - mode_combo_window = self.bg_canvas.create_window(48, 800, window=mode_frame, anchor='nw', width=180, height=deploy_btn_height) - self._canvas_items.append(mode_combo_window) - self._overlay_widgets.append(mode_frame) - - btn_window = self.bg_canvas.create_window(240, 800, window=self._cnpg_deploy_button, anchor='nw', width=180) - self._canvas_items.append(btn_window) - self._overlay_widgets.append(self._cnpg_deploy_button) - - # Force Rollout Button - self._cnpg_rollout_button = tk.Button(self.bg_canvas, text='Force Rollout', command=self.run_cnpg_rollout, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - rollout_btn_window = self.bg_canvas.create_window(420, 800, window=self._cnpg_rollout_button, anchor='nw', width=160) - self._canvas_items.append(rollout_btn_window) - self._overlay_widgets.append(self._cnpg_rollout_button) - - # Save Deployment Button (moved from Post Install) - self._save_deployment_button = tk.Button(self.bg_canvas, text='Save Deployment', command=self.run_final_deployment, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - save_btn_window = self.bg_canvas.create_window(600, 800, window=self._save_deployment_button, anchor='nw', width=180) - self._canvas_items.append(save_btn_window) - self._overlay_widgets.append(self._save_deployment_button) - - # Status Labels (below the buttons row to avoid overlap) - self._cnpg_deploy_status_label = ui.canvas_text(self, 48, 840, "", fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(self._cnpg_deploy_status_label) - self._save_deployment_status_label = ui.canvas_text(self, 600, 840, "", fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(self._save_deployment_status_label) - - def run_cnpg_deploy(self): - self._action_flags['init_cnpg_deploy.run_deploy'] = True - def worker(): - self.safe_after(lambda: self._cnpg_deploy_button.configure(state='disabled') if self._cnpg_deploy_button.winfo_exists() else None) - self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deploying...", fill='blue') if self.bg_canvas.winfo_exists() else None) - - target_value = '' - try: - target_value = (self.deploy_target.get() or '').strip() - except Exception: - target_value = '' - if not target_value: - try: - target_value = (self.cluster_env.get() or '').strip() - except Exception: - target_value = '' - target_key = _normalize_cluster_env(target_value) - mode = _deployment_mode_from_env(target_value or self.cluster_env.get()) - - if target_key in ('service', 'prod'): - url = self._deployment_pipeline_url(target_key) - self._cnpg_deploy_console.clear() - self._cnpg_deploy_console.write(f"Opening deployment pipeline for {target_value or target_key}...\n") - self._cnpg_deploy_console.write(f"{url}\n") - try: - webbrowser.open(url) - except Exception: - pass - self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment pipeline opened.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - self._cnpg_success = False - self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) - return - - etc_dir = PROJECT_ROOT / "etc" - # Prepare environment for scripts - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - env["NAMESPACE"] = (self.db_namespace.get() or '').strip() - env["NAMESPACE"] = (self.db_namespace.get() or '').strip() - if mode: - env["PROLE_MODE"] = mode - env["DEPLOYMENT_MODE"] = mode - env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) - - self._cnpg_deploy_console.clear() - self._cnpg_deploy_console.write("Starting CloudNative-PG deployment...\n") - self._cnpg_deploy_console.write(f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} deploy latest\n\n") - - cmd = ['bash', str(etc_dir / 'init_cloudnative_pg.sh')] - if mode: - cmd += ['--mode', mode] - cmd += ['deploy', 'latest'] - proc = subprocess.Popen(cmd, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) - - while True: - line = proc.stdout.readline() - if not line and proc.poll() is not None: - break - if line: - self._cnpg_deploy_console.write(line) - - if proc.returncode == 0: - self._cnpg_deploy_console.write("\nDeployment successful!\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment successful!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - self._cnpg_success = True - else: - self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Deployment failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - self._cnpg_success = False - - self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) - - threading.Thread(target=worker, daemon=True).start() - - def run_cnpg_rollout(self): - self._action_flags['init_cnpg_deploy.force_rollout'] = True - def worker(): - self.safe_after(lambda: self._cnpg_deploy_button.configure(state='disabled') if self._cnpg_deploy_button.winfo_exists() else None) - self.safe_after(lambda: self._cnpg_rollout_button.configure(state='disabled') if self._cnpg_rollout_button.winfo_exists() else None) - self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Rolling out...", fill='blue') if self.bg_canvas.winfo_exists() else None) - - etc_dir = PROJECT_ROOT / "etc" - # Prepare environment for scripts - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - mode = self._deployment_mode() - if mode: - env["PROLE_MODE"] = mode - env["DEPLOYMENT_MODE"] = mode - env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) - - self._cnpg_deploy_console.clear() - self._cnpg_deploy_console.write("Starting manual recreate rollout for prole-db cluster...\n") - self._cnpg_deploy_console.write(f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} rollout\n\n") - - cmd = ['bash', str(etc_dir / 'init_cloudnative_pg.sh')] - if mode: - cmd += ['--mode', mode] - cmd += ['rollout'] - proc = subprocess.Popen(cmd, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) - - while True: - line = proc.stdout.readline() - if not line and proc.poll() is not None: - break - if line: - self._cnpg_deploy_console.write(line) - - if proc.returncode == 0: - self._cnpg_deploy_console.write("\nRollout successful!\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Rollout successful!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - else: - self._cnpg_deploy_console.write(f"\nRollout failed with code {proc.returncode}\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Rollout failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - - self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) - self.safe_after(lambda: self._cnpg_rollout_button.configure(state='normal') if self._cnpg_rollout_button.winfo_exists() else None) - - def _init_scripts_list(self): - scripts = [ - ('Kubectl Status', self.KUBECTL_STATUS_TAB), - ('Common Services', 'init_common_services.sh'), - ('Common Services Log', 'common_services_log'), - ('CloudNative-PG', 'init_cloudnative_pg.sh'), - ] - if self.kerberos_enabled.get(): - scripts.append(('Kerberos Realm', 'init_kerberos.sh')) - scripts.extend([ - ('Prole DB Backup', 'init_prole-db-backup.sh'), - ('Monitoring', 'init_monitoring.sh'), - ('Nginx Ingress', 'init_nginx_ingress.sh'), - ('Port Forwards', 'init_port_forwards.sh'), - ]) - return scripts - - def _render_init_scripts_section(self, x: int, y: int, width: int, height: int, title: str | None = "Execution Output") -> int: - if title: - title_item = ui.canvas_text(self, x, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) - self._canvas_items.append(title_item) - y += 30 - - # Use a background frame for the notebook to hide potential system borders - notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) - self.script_tabs = ttk.Notebook(notebook_bg, style='TNotebook') - self.script_tabs.pack(fill='both', expand=True, padx=1, pady=1) - - tab_window = self.bg_canvas.create_window(x, y, window=notebook_bg, anchor='nw', width=width, height=height) - self._canvas_items.append(tab_window) - self._overlay_widgets.append(notebook_bg) - self._overlay_widgets.append(self.script_tabs) - - self.script_consoles = {} - scripts = self._init_scripts_list() - self._script_tab_index = {} - - for idx, (title, fname) in enumerate(scripts): - # Use a background frame to ensure NO borders are visible around the console - console_bg = tk.Frame(self.script_tabs, bg='white', highlightthickness=0, bd=0) - self.script_tabs.add(console_bg, text=title) - # Use TerminalConsole for consistent styling - if fname == self.KUBECTL_STATUS_TAB: - mono = ('Menlo', 9) if platform.system() == 'Darwin' else ('Consolas', 9) - console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0, font=mono, wrap='none', show_horizontal=True) - else: - console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) - console.pack(fill='both', expand=True, padx=1, pady=1) - self.script_consoles[fname] = console - self._script_tab_index[fname] = idx - self._overlay_widgets.append(console_bg) - self._overlay_widgets.append(console) - - self.script_tabs.bind("<>", self._on_init_scripts_tab_changed) - self.safe_after(self._refresh_kubectl_status_tab, delay=50) - - return y + height - - def _render_init_scripts_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Initialization Scripts', y=150) - self._render_paragraph('Running initialization scripts to set up Garage, CloudNative-PG, and backups.', y=200) - - y = 260 - y = self._render_init_scripts_section(48, y, 900, 450, title="Execution Output") - - # Use tk.Button - self._init_scripts_button = tk.Button(self.bg_canvas, text='Run Scripts', command=self.run_init_scripts, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - btn_window = self.bg_canvas.create_window(48, y + 20, window=self._init_scripts_button, anchor='nw', width=180) - self._canvas_items.append(btn_window) - self._overlay_widgets.append(self._init_scripts_button) - - # Status Label - self._init_scripts_status_label = ui.canvas_text(self, 240, y + 32, "", fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(self._init_scripts_status_label) - - def _on_init_scripts_tab_changed(self, _event=None): - try: - current_idx = self.script_tabs.index("current") - except Exception: - return - if current_idx == self._script_tab_index.get(self.KUBECTL_STATUS_TAB): - self._refresh_kubectl_status_tab() - - def _refresh_kubectl_status_tab(self): - console = self.script_consoles.get(self.KUBECTL_STATUS_TAB) - if not console: - return - - namespace = (self.db_namespace.get() or '').strip() or os.environ.get('NAMESPACE') or 'default' - base_cmd = self._kubectl_base_cmd() - - def _display_cmd(args: list[str]) -> str: - redacted = [] - for item in base_cmd + args: - if item.startswith('--token='): - redacted.append('--token=***') - else: - redacted.append(item) - return " ".join(shlex.quote(x) for x in redacted) - - def _run_kubectl(args: list[str]): - env = os.environ.copy() - env.setdefault("PROLE_HOME", str(PROJECT_ROOT)) - try: - res = subprocess.run(base_cmd + args, capture_output=True, text=True, env=env) - out = res.stdout or "" - err = res.stderr or "" - return res.returncode, out, err - except Exception as exc: - return 1, "", f"{exc}" - - def worker(): - cmd_args = ['get', 'pods', '-A', '-o', 'wide'] - code, out, err = _run_kubectl(cmd_args) - now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - header = f"# {_display_cmd(cmd_args)} ({now}) [highlight: namespace={namespace}]" - - lines = [ln.rstrip('\n') for ln in out.splitlines() if ln.strip() != ""] - if code != 0 and not lines: - lines = ["(no output)"] - - pod_rows = [] - unhappy = [] - if lines: - for idx, line in enumerate(lines): - if idx == 0: - pod_rows.append({'raw': line, 'ns': '', 'unhappy': False}) - continue - parts = line.split() - if len(parts) < 4: - pod_rows.append({'raw': line, 'ns': '', 'unhappy': False}) - continue - ns, name, ready, status = parts[0], parts[1], parts[2], parts[3] - - status_lc = status.lower() - happy_status = status_lc in ('running', 'completed', 'succeeded') - ready_ok = True - if status_lc == 'running' and '/' in ready: - try: - a, b = ready.split('/', 1) - ready_ok = (a == b) - except Exception: - ready_ok = False - - is_happy = happy_status and (ready_ok or status_lc in ('completed', 'succeeded')) - if not is_happy: - unhappy.append({'ns': ns, 'name': name, 'status': status, 'ready': ready}) - - pod_rows.append({'raw': line, 'ns': ns, 'unhappy': not is_happy}) - - details = "" - if unhappy: - blocks = ["", "# Unhealthy pods detected; collecting events + logs"] - for pod in unhappy: - ns = pod['ns'] - name = pod['name'] - blocks.append(f"\n## Events for {ns}/{name}") - ec, eout, eerr = _run_kubectl([ - 'get', 'events', - '-n', ns, - '--field-selector', f"involvedObject.kind=Pod,involvedObject.name={name}", - '--sort-by=.lastTimestamp' - ]) - blocks.append(eout.strip() or eerr.strip() or "(no events output)") - - blocks.append(f"\n## Logs for {ns}/{name}") - lc, lout, lerr = _run_kubectl([ - 'logs', - '-n', ns, - name, - '--all-containers', - '--tail=200' - ]) - blocks.append(lout.strip() or lerr.strip() or "(no logs output)") - - details = "\n".join(blocks).rstrip() + "\n" - elif err.strip(): - details = "\n# kubectl stderr\n" + err.strip() + "\n" - - def render(): - if not console.text.winfo_exists(): - return - text = console.text - text.configure(state='normal') - text.delete('1.0', 'end') - text.tag_configure('header', foreground='#6e6e73') - text.tag_configure('ns', background='#DDF4FF') - text.tag_configure('bad', foreground='#B00020') - - text.insert('end', header + "\n\n", ('header',)) - for row in pod_rows: - tags = [] - if row['ns'] == namespace: - tags.append('ns') - if row['unhappy']: - tags.append('bad') - if row['raw'].strip(): - text.insert('end', row['raw'] + "\n", tuple(tags)) - if details: - text.insert('end', "\n" + details) - text.see('1.0') - text.configure(state='disabled') - - self.safe_after(render) - - threading.Thread(target=worker, daemon=True).start() - - def run_init_scripts(self): - self._action_flags['init_scripts.run_scripts'] = True - def worker(): - def _select_tab(script_name): - idx = getattr(self, '_script_tab_index', {}).get(script_name) - if idx is None: - return - self.safe_after(lambda: self.script_tabs.select(idx) if self.script_tabs.winfo_exists() else None) - - self.safe_after(lambda: self._init_scripts_button.configure(state='disabled') if self._init_scripts_button.winfo_exists() else None) - self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Running scripts...", fill='blue') if self.bg_canvas.winfo_exists() else None) - - password = self.db_password.get() - - # Prepare environment for scripts - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - env["PROLE_DB_USER"] = self.db_username.get() - env["DB_PASSWORD"] = password - env["GRAFANA_ADMIN_PASSWORD"] = password - env["NAMESPACE"] = (self.db_namespace.get() or '').strip() - env["SERVICE_NAMESPACE"] = self._get_service_namespace() - if self.kerberos_realm.get().strip(): - env["KRB5_REALM"] = self.kerberos_realm.get().strip() - env["REALM"] = self.kerberos_realm.get().strip() - env["DOMAIN"] = self.kerberos_realm.get().strip().lower() - if self.kerberos_kdc.get().strip(): - env["KRB5_KDC"] = self.kerberos_kdc.get().strip() - env["KRB5_ADMIN"] = self.kerberos_kdc.get().strip() - if self.kerberos_user.get().strip(): - env["KRB5_USER"] = self.kerberos_user.get().strip() - if self.kerberos_password.get().strip(): - env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() - env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get()) - env["ENABLED"] = env["KERBEROS_ENABLED"] - mode = self._deployment_mode() - if mode: - env["PROLE_MODE"] = mode - env["DEPLOYMENT_MODE"] = mode - env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) - mode_args = ["--mode", mode] if mode else [] - - logs_dir = self._resolve_prole_logs_dir() - try: - logs_dir.mkdir(parents=True, exist_ok=True) - except Exception: - pass - env.setdefault("PROLE_LOGS", str(logs_dir)) - - def _log_path_for(script_name: str) -> Path: - base = Path(script_name).stem - if base in ('common_services_log', 'init_common_services'): - return logs_dir / 'init_common_services.log' - return logs_dir / f'{base}.log' - - # 1. init_common_services.sh update - overall_success = True - if overall_success: - script = "init_common_services.sh" - _select_tab(script) - self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} update...\n") - - svc_log_path = _log_path_for(script) - self._last_common_services_log_path = svc_log_path - env["COMMON_SERVICES_INIT_LOG"] = str(svc_log_path) - self._record_install_log(svc_log_path) - self.script_consoles["common_services_log"].clear() - self.script_consoles["common_services_log"].write(f"Log file: {svc_log_path}\n\n") - self.script_consoles[script].write(f"Log file: {svc_log_path}\n\n") - - try: - svc_fp = svc_log_path.open('a', encoding='utf-8') - except Exception: - svc_fp = None - - def _svc_line(line): - self.script_consoles[script].write(line) - self.script_consoles["common_services_log"].write(line) - self._process_script_output_line(line) - if svc_fp: - try: - svc_fp.write(line) - svc_fp.flush() - except Exception: - pass - - svc_args = list(mode_args) - if self.kerberos_enabled.get(): - svc_args.append("-k") - svc_args.append("update") - rc_svc = self.controller.run_script( - script, - args=svc_args, - env=env, - on_line=_svc_line - ) - if svc_fp: - try: - svc_fp.close() - except Exception: - pass - - if rc_svc != 0: - self.script_consoles[script].write(f"\nERROR: {script} update failed with code {rc_svc}\n") - overall_success = False - else: - self.script_consoles[script].write(f"\n{script} completed successfully.\n") - else: - self.script_consoles["init_common_services.sh"].write("Skipping common services initialization because previous steps failed.\n") - - # 2. init_cloudnative_pg.sh initialize - if overall_success: - script = "init_cloudnative_pg.sh" - _select_tab(script) - self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} initialize...\n") - self.script_consoles[script].write("> bash etc/init_cloudnative_pg.sh initialize\n") - - log_path = _log_path_for(script) - self._record_install_log(log_path) - try: - log_fp = log_path.open('a', encoding='utf-8') - except Exception: - log_fp = None - - def _cnpg_line(line): - self.script_consoles[script].write(line) - self._process_script_output_line(line) - if log_fp: - try: - log_fp.write(line) - log_fp.flush() - except Exception: - pass - - rc2 = self.controller.run_script( - script, - args=mode_args + ['initialize'], - env=env, - on_line=_cnpg_line - ) - if log_fp: - try: - log_fp.close() - except Exception: - pass - - if rc2 != 0: - self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc2}\n") - overall_success = False - else: - self.script_consoles[script].write(f"\n{script} completed successfully.\n") - else: - if "init_cloudnative_pg.sh" in self.script_consoles: - self.script_consoles["init_cloudnative_pg.sh"].write("Skipping CloudNative-PG initialization because previous steps failed.\n") - - # 3. init_kerberos.sh initialize (optional) - if overall_success and self.kerberos_enabled.get(): - script = "init_kerberos.sh" - if script in self.script_consoles: - _select_tab(script) - self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} initialize...\n") - - log_path = _log_path_for(script) - self._record_install_log(log_path) - try: - log_fp = log_path.open('a', encoding='utf-8') - except Exception: - log_fp = None - - def _krb_line(line): - self.script_consoles[script].write(line) - self._process_script_output_line(line) - if log_fp: - try: - log_fp.write(line) - log_fp.flush() - except Exception: - pass - - rc_krb = self.controller.run_script( - script, - args=mode_args + ['initialize'], - env=env, - on_line=_krb_line - ) - if log_fp: - try: - log_fp.close() - except Exception: - pass - - if rc_krb != 0: - self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_krb}\n") - overall_success = False - else: - self.script_consoles[script].write(f"\n{script} completed successfully.\n") - else: - fallback_console = self.script_consoles.get("init_cloudnative_pg.sh") - if fallback_console: - fallback_console.write("Kerberos init console missing; skipping.\n") - elif not self.kerberos_enabled.get(): - if "init_kerberos.sh" in self.script_consoles: - self.script_consoles["init_kerberos.sh"].write("Kerberos auth disabled; skipping init_kerberos.sh.\n") - # 6. init_prole-db-backup.sh start - if overall_success: - script = "init_prole-db-backup.sh" - _select_tab(script) - self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} start...\n") - - log_path = _log_path_for(script) - self._record_install_log(log_path) - try: - log_fp = log_path.open('a', encoding='utf-8') - except Exception: - log_fp = None - - def _backup_line(line): - self.script_consoles[script].write(line) - self._process_script_output_line(line) - if log_fp: - try: - log_fp.write(line) - log_fp.flush() - except Exception: - pass - - rc_backup = self.controller.run_script( - script, - args=mode_args + ['start'], - env=env, - on_line=_backup_line - ) - if log_fp: - try: - log_fp.close() - except Exception: - pass - - if rc_backup != 0: - self.script_consoles[script].write(f"\nERROR: {script} start failed with code {rc_backup}\n") - overall_success = False - else: - self.script_consoles[script].write(f"\n{script} completed successfully.\n") - else: - self.script_consoles["init_prole-db-backup.sh"].write("Skipping Prole DB backup because previous steps failed.\n") - - # 8. init_monitoring.sh initialize - if overall_success: - script = "init_monitoring.sh" - _select_tab(script) - self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} initialize...\n") - - log_path = _log_path_for(script) - self._record_install_log(log_path) - try: - log_fp = log_path.open('a', encoding='utf-8') - except Exception: - log_fp = None - - def _monitoring_line(line): - self.script_consoles[script].write(line) - self._process_script_output_line(line) - if log_fp: - try: - log_fp.write(line) - log_fp.flush() - except Exception: - pass - # Extract Grafana password if present - if "GRAFANA_ADMIN_PASSWORD=" in line: - pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip() - if pwd: - self.prole_cfg_data['Monitoring'] = {'GRAFANA_ADMIN_PASSWORD': pwd} - self._save_prole_cfg() - - rc_mon = self.controller.run_script( - script, - args=mode_args + ['initialize'], - env=env, - on_line=_monitoring_line - ) - if log_fp: - try: - log_fp.close() - except Exception: - pass - - if rc_mon != 0: - self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_mon}\n") - overall_success = False - else: - self.script_consoles[script].write(f"\n{script} completed successfully.\n") - else: - if "init_monitoring.sh" in self.script_consoles: - self.script_consoles["init_monitoring.sh"].write("Skipping Monitoring initialization because previous steps failed.\n") - - # 9. init_nginx_ingress.sh initialize - if overall_success: - script = "init_nginx_ingress.sh" - _select_tab(script) - self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} initialize...\n") - log_path = _log_path_for(script) - self._record_install_log(log_path) - try: - log_fp = log_path.open('a', encoding='utf-8') - except Exception: - log_fp = None - - def _ingress_line(line): - self.script_consoles[script].write(line) - self._process_script_output_line(line) - if log_fp: - try: - log_fp.write(line) - log_fp.flush() - except Exception: - pass - - rc_ing = self.controller.run_script( - script, - args=mode_args + ['initialize'], - env=env, - on_line=_ingress_line - ) - if log_fp: - try: - log_fp.close() - except Exception: - pass - if rc_ing != 0: - self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_ing}\n") - overall_success = False - else: - self.script_consoles[script].write(f"\n{script} completed successfully.\n") - else: - self.script_consoles["init_nginx_ingress.sh"].write("Skipping Nginx Ingress because previous steps failed.\n") - - # 10. init_port_forwards.sh start (k3d only) - if overall_success and mode == "k3d": - script = "init_port_forwards.sh" - if script in self.script_consoles: - _select_tab(script) - self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} start...\n") - - def _pf_line(line): - self.script_consoles[script].write(line) - self._process_script_output_line(line) - - env_pf = dict(env) - env_pf.setdefault("PORT_FORWARD_SKIP_VALIDATE", "1") - env_pf.setdefault("PORT_FORWARD_SKIP_WAIT", "1") - env_pf.setdefault("PORT_FORWARD_WAIT_TIMEOUT", "20") - env_pf.setdefault("PORT_FORWARD_WAIT_INTERVAL", "2") - - rc_pf = self.controller.run_script( - script, - args=["--mode", mode, "start"], - env=env_pf, - on_line=_pf_line - ) - if rc_pf != 0: - self.script_consoles[script].write(f"\nERROR: {script} start failed with code {rc_pf}\n") - overall_success = False - else: - running = False - for _ in range(10): - status_lines = [] - - def _status_line(line): - status_lines.append(line) - self.script_consoles[script].write(line) - - rc_status = self.controller.run_script( - script, - args=["--mode", mode, "status", "grafana"], - env=env_pf, - on_line=_status_line - ) - running = any(l.lstrip().startswith("RUNNING") and "grafana" in l for l in status_lines) - if rc_status == 0 and running: - break - time.sleep(1) - - if not running: - self.script_consoles[script].write("\nERROR: Grafana port-forward not running after start.\n") - overall_success = False - else: - self.script_consoles[script].write("\nPort-forwards running (grafana ok).\n") - - # Verify critical secrets - ns = env.get("NAMESPACE", "default") - self.script_consoles["init_cloudnative_pg.sh"].write(f"\nVerifying critical secrets in namespace {ns}...\n") - critical_secrets = ["prole-db-user", "prole-db-superuser", "cnpg-admin-key"] - missing_secrets = [] - for secret in critical_secrets: - rc_s, _ = self._run_cmd_capture(["kubectl", "get", "secret", secret, "-n", ns]) - if rc_s != 0: - missing_secrets.append(secret) - - if missing_secrets: - self.script_consoles["init_cloudnative_pg.sh"].write(f"CRITICAL: Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}.\n") - self.script_consoles["init_cloudnative_pg.sh"].write("Database initialization will fail without these secrets.\n") - self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Critical secret missing.", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - overall_success = False - - if overall_success: - self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization complete!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) - self._scripts_success = True - else: - self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) - self._scripts_success = False - - try: - self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if self._scripts_success else 'Attempted' - self.safe_after(self._save_prole_cfg) - except Exception: - pass - - self.safe_after(lambda: self.update_footer()) - self.safe_after(lambda: self._init_scripts_button.configure(state='normal') if self._init_scripts_button.winfo_exists() else None) - self.safe_after(self.check_services_status_async) - - threading.Thread(target=worker, daemon=True).start() - - def get_removable_disks(self): - """Detect removable media on macOS using diskutil.""" - self.removable_disks = [] - if platform.system() != 'Darwin': - return self.removable_disks - - try: - # Get list of all disks - res = subprocess.run(['diskutil', 'list', '-plist'], capture_output=True, text=True) - if res.returncode != 0: - return [] - - import plistlib - data = plistlib.loads(res.stdout.encode()) - all_disks = data.get('AllDisks', []) - - for disk in all_disks: - # Filter for whole disks to check if they are removable - if not disk.startswith('disk') or 's' in disk: - continue - - info_res = subprocess.run(['diskutil', 'info', '-plist', disk], capture_output=True, text=True) - if info_res.returncode == 0: - info = plistlib.loads(info_res.stdout.encode()) - # Check for RemovableMedia or RemovableMediaOrExternalDevice - # Also check BusProtocol to catch most USB sticks if they don't report as removable - is_removable = ( - info.get('RemovableMedia', False) or - info.get('RemovableMediaOrExternalDevice', False) or - info.get('BusProtocol') in ['USB', 'FireWire', 'Thunderbolt'] - ) - - # Ensure it's not the internal system drive if we are using protocol as a hint - if info.get('Internal', False) and info.get('BusProtocol') not in ['USB']: - is_removable = False - - if is_removable: - # Found a removable disk, now find its mounted volumes - # We look for partitions of this disk that are mounted - for d2 in all_disks: - if d2.startswith(disk + 's'): - v_res = subprocess.run(['diskutil', 'info', '-plist', d2], capture_output=True, text=True) - if v_res.returncode == 0: - v_info = plistlib.loads(v_res.stdout.encode()) - mount_point = v_info.get('MountPoint') - volume_name = v_info.get('VolumeName') or v_info.get('DeviceIdentifier') - if mount_point: - self.removable_disks.append((volume_name, mount_point)) - except Exception as e: - print(f"Error detecting disks: {e}") - - return self.removable_disks - - def _render_disk_selection_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Deployment Destination.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Select Destination', y=150) - self._render_paragraph("Choose where you would like to deploy the built Prole application and its supporting artifacts.", y=210) - - # Container for the two main options - y_options = 300 - x_center = content_width // 2 - - # We need large friendly images. I'll use placeholders if I can't find specific ones. - # But I'll try to use symbols or colors for now if images are missing. - - try: - from PIL import Image, ImageTk - # Using proleIcon.png as a placeholder for both for now, but I'll add distinct styling - icon_path = PROJECT_ROOT / 'img' / 'proleIcon.png' - icon_img = Image.open(str(icon_path)).resize((128, 128), Image.LANCZOS) - self._disk_icon_tk = ImageTk.PhotoImage(icon_img) - except Exception: - self._disk_icon_tk = None - - # Option 1: Removable Disk - frame_usb = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20) - usb_window = self.bg_canvas.create_window(x_center - 250, y_options, window=frame_usb, anchor='n', width=350) - self._overlay_widgets.append(frame_usb) - self._canvas_items.append(usb_window) - - if self._disk_icon_tk: - lbl_img_usb = tk.Label(frame_usb, image=self._disk_icon_tk, bg='white', cursor='hand2') - lbl_img_usb.pack() - lbl_img_usb.bind("", lambda e: self.selected_disk_type.set('removable')) - - tk.Radiobutton(frame_usb, text="USB / Flash Drive", variable=self.selected_disk_type, value='removable', - bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10) - - # Dropdown for removable disks - disk_names = [d[0] for d in self.removable_disks] or ["No removable disks detected"] - if not self.selected_removable_disk.get() and self.removable_disks: - self.selected_removable_disk.set(self.removable_disks[0][1]) - - self.disk_dropdown = ttk.Combobox(frame_usb, values=disk_names, state="readonly", width=30) - self.disk_dropdown.pack(pady=5) - if disk_names: self.disk_dropdown.current(0) - - def on_disk_select(event): - idx = self.disk_dropdown.current() - if idx >= 0 and idx < len(self.removable_disks): - self.selected_removable_disk.set(self.removable_disks[idx][1]) - self.selected_disk_type.set('removable') - - self.disk_dropdown.bind("<>", on_disk_select) - - # Option 2: Local Folder - frame_local = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20) - local_window = self.bg_canvas.create_window(x_center + 250, y_options, window=frame_local, anchor='n', width=350) - self._overlay_widgets.append(frame_local) - self._canvas_items.append(local_window) - - if self._disk_icon_tk: - lbl_img_local = tk.Label(frame_local, image=self._disk_icon_tk, bg='white', cursor='hand2') - lbl_img_local.pack() - lbl_img_local.bind("", lambda e: self.selected_disk_type.set('local')) - - tk.Radiobutton(frame_local, text="Local Filesystem", variable=self.selected_disk_type, value='local', - bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10) - - # Path input and browse - path_frame = tk.Frame(frame_local, bg='white') - path_frame.pack(fill='x', pady=5) - - ent_path = tk.Entry(path_frame, textvariable=self.selected_local_path, font=('SF Pro Text', 10), width=30) - ent_path.pack(side='left', padx=(0, 5)) - - def browse_local(): - from tkinter import filedialog - d = filedialog.askdirectory(initialdir=self.selected_local_path.get()) - if d: - self.selected_local_path.set(d) - self.selected_disk_type.set('local') - - btn_browse = tk.Button(path_frame, text="Browse...", command=browse_local, bg='#F5F5DC') - btn_browse.pack(side='left') - - def _render_build_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - # Shifted up to accommodate radios and standard console position - self._render_title('Build Prole Tools.app', y=80) - self._render_paragraph('Build and prepare Prole Tools.app for deployment.', y=130) - - # Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes) - if not hasattr(self, 'deploy_env_value'): - self.deploy_env_value = 'Dev' - - radio_y = 180 - left = 56 - spacing = 150 - - # Draw three radio options - self._build_radio_items = [] - options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)] - for label, x in options: - # outer circle - r = 10 - circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='black', width=2) - self._canvas_items.append(circle) - # selected dot - if self.deploy_env_value == label: - dot = ui.canvas_oval(self, x+5, radio_y+5, x+2*r-5, radio_y+2*r-5, fill='black', outline='') - self._canvas_items.append(dot) - text = ui.canvas_text(self, x + 2*r + 10, radio_y - 2, label, fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(text) - self._build_radio_items.append((label, circle, text)) - - # Click handling for radio selection - def _on_click(event): - ex, ey = event.x, event.y - for label, circle, text in self._build_radio_items: - bbox_c = self.bg_canvas.bbox(circle) - bbox_t = self.bg_canvas.bbox(text) - hit = False - if bbox_c and bbox_c[0] <= ex <= bbox_c[2] and bbox_c[1] <= ey <= bbox_c[3]: - hit = True - if bbox_t and bbox_t[0] <= ex <= bbox_t[2] and bbox_t[1] <= ey <= bbox_t[3]: - hit = True - if hit: - self.deploy_env_value = label - # Re-render only radios by re-drawing the page - self._clear_canvas_page() - self._render_build_page() - self.update_footer() - break - self.bg_canvas.bind('', _on_click) - - # Output Console (Consistent size and location: y=260, width=900, height=520) - self._build_console = self._create_console_output(y=230, title="Build Output", width=900, height=550) - self._console_text = self._build_console.text # For compatibility with _append_console and others - - # Show a command preview with PS1-style prompt and blinking cursor - preview = self._compose_build_preview() - self._console_set_preview(preview) - - # Ensure state holders exist - if not hasattr(self, 'last_build_log_path'): - self.last_build_log_path = None - # Update Next button label/state - self.update_footer() - # Bind Enter to trigger Build on this page - def _enter_build(_evt=None): - self.perform_build() - try: - self.root.bind('', _enter_build) - self.root.bind('', _enter_build) - except Exception: - pass - - - def _render_deploy_page(self): - self._render_title('Deploy', y=40) - self._render_paragraph('Preparing to deploy. We will verify steps and perform actions as needed.', y=90) - # Render simple list of steps (static view). Runtime updates can redraw as needed. - y = 140 - left = 56 - if not hasattr(self, 'deploy_steps'): - self.deploy_steps = [ - {'name': 'Build Prole macOS app', 'status': 'pending'}, - {'name': 'Check Docker is running', 'status': 'pending'}, - {'name': 'Install Prole Tools.app', 'status': 'pending'}, - ] - for step in self.deploy_steps: - # status circle (pending empty) - self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, outline='#b0b0b0')) - self._canvas_items.append(ui.canvas_text(self, left+26, y-2, step['name'], fill='#1d1d1f', font=('Helvetica', 12))) - y += 26 - - def on_deploy(self): - if getattr(self, '_deploy_running', False): - return - self._deploy_running = True - try: - if self.deploy_button: - self.deploy_button.configure(state='disabled') - except Exception: - pass - - def worker(): - err = None - try: - self.ensure_prole_env() - self.reload_env_from_shell() - except Exception as e: - err = f"Failed to load environment: {e}" - try: - self._ensure_prole_directories() - if 'Install' in self.prole_cfg_data: - self.prole_cfg_data['Install']['STATUS'] = 'Deployed' - self._save_prole_cfg() - except Exception as e: - err = err or f"Failed to prepare Prole directories: {e}" - - if err: - self.safe_after(lambda: messagebox.showerror("Deploy", err)) - else: - self.safe_after(self.open_drag_install_window) - - def _finish(): - self._deploy_running = False - try: - if self.deploy_button: - self.deploy_button.configure(state='normal') - except Exception: - pass - self.safe_after(_finish) - - threading.Thread(target=worker, daemon=True).start() - - def on_launch(self): - app_path = self._get_prole_dist_dir() / 'Prole Tools.app' - if app_path.exists(): - try: - subprocess.Popen(['open', str(app_path)]) - except Exception: - pass - else: - try: - messagebox.showerror("Launch", f"App not found at {app_path}. Please build it first.") - except Exception: - pass - - def on_prev(self): - # Custom prev navigation for dependency pages when filtering - current_id = self.pages[self.page_index][0] - # Close Terminal if leaving build summary via Prev - if current_id == 'build_summary': - try: - self.close_build_terminal() - except Exception: - pass - if current_id.startswith('dep_'): - seq = self._dep_navigation_sequence() - try: - i = seq.index(current_id) - except ValueError: - i = -1 - if i > 0: - self.show_page(seq[i - 1]) - return - else: - # Go back to summary if there is no previous in sequence - self.show_page('deps_summary') - return - if current_id == 'deps_summary': - self.show_page('welcome') - return - if current_id == 'network_scan': - # Go back to last relevant dep or deps_summary - if self.all_dependencies_installed(): - self.show_page('deps_summary') - else: - seq = self._dep_navigation_sequence() - self.show_page(seq[-1] if seq else 'deps_summary') - return - if current_id == 'env_setup': - self.show_page('network_scan') - return - if current_id == 'init_cluster': - if getattr(self, '_showing_service_overlay', False): - self._showing_service_overlay = False - self.show_page('init_cluster') - return - self.show_page('env_setup') - return - if current_id == 'init_password': - self.show_page('init_cluster') - return - if current_id == 'init_db_build': - self.show_page('init_password') - return - if current_id == 'init_scripts': - if self.kerberos_enabled.get(): - self.show_page('kerberos_config') - else: - self.show_page('init_db_build') - return - if current_id == 'kerberos_config': - self.show_page('init_db_build') - return - if current_id == 'ollama_config': - self.show_page('init_scripts') - return - if current_id == 'supabase_config': - self.show_page('ollama_config') - return - if current_id == 'init_cnpg_deploy': - if self.supabase_enabled.get(): - self.show_page('supabase_config') - return - self.show_page('ollama_config') - return - if current_id == 'create_installer': - self.show_page('init_cnpg_deploy') - return - # Default prev - if self.page_index > 0: - self.show_page(self.page_index - 1) - return - - def on_next(self): - # Special handling for dynamic labels - current_id = self.pages[self.page_index][0] - print(f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}") - - if current_id == 'welcome': - self.show_page('deps_summary') - return - - if current_id == 'deps_summary': - # Determine where to go from summary - # Always go to the next dependency or Network Scan - if self.all_dependencies_installed(): - print("[DEBUG] on_next: all deps installed, going to network_scan") - self.show_page('network_scan') - return - - # Go to first missing dependency page - seq = self._dep_navigation_sequence() - print(f"[DEBUG] on_next: missing deps sequence: {seq}") - if seq: - self.show_page(seq[0]) - else: - print("[DEBUG] on_next: all deps seem OK in sequence, going to network_scan") - self.show_page('network_scan') - return - - if current_id.startswith('dep_'): - # Navigate within dependency sequence - seq = self._dep_navigation_sequence() - print(f"[DEBUG] on_next: dep sequence: {seq}") - try: - i = seq.index(current_id) - except ValueError: - i = -1 - - if i >= 0 and i < len(seq) - 1: - self.show_page(seq[i + 1]) - return - else: - # After last relevant dep page, go back to summary if anything is still missing - if not self.all_dependencies_installed(): - print("[DEBUG] on_next: some deps still missing, returning to deps_summary") - self.show_page('deps_summary') - else: - print("[DEBUG] on_next: all deps now installed, going to network_scan") - self.show_page('network_scan') - return - - if current_id == 'network_scan': - # Capture network scan info - try: - self.prole_cfg_data['Network']['KDC_AUTO_DETECTED'] = self.kerberos_kdc.get() - self.prole_cfg_data['Network']['KERBEROS_AUTO_ENABLED'] = str(self.kerberos_enabled.get()) - self._save_prole_cfg() - except Exception: - pass - self.show_page('env_setup') - return - - if current_id == 'env_setup': - # Collect values, validate, write env.sh, then go to Database Creation - vals = {} - try: - for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'): - vals[k] = self._env_entries[k].get().strip() - self.prole_cfg_data['System Environment'][k] = vals[k] - vals['NAMESPACE'] = (self.db_namespace.get() or '').strip() - except Exception: - vals = self._env_defaults() - # Basic validation: require non-empty PROLE_HOME - if not vals.get('PROLE_HOME'): - try: - messagebox.showerror('Environment', 'Please specify PROLE_HOME') - except Exception: - pass - return - try: - self._save_env_to_file(vals) - # Reload our environment so subsequent steps (Build) inherit it - self._after_env_saved() - except Exception as e: - try: - messagebox.showwarning('Environment', f'Could not save env.sh: {e}') - except Exception: - pass - return - self.show_page('init_cluster') - return - - if current_id == 'init_password': - p1 = self.db_password.get() - p2 = self.db_password_confirm.get() - if not p1: - try: - messagebox.showerror('Password', 'Password cannot be empty.') - except Exception: - pass - return - if p1 != p2: - try: - messagebox.showerror('Password', 'Passwords do not match.') - except Exception: - pass - return - if not self._validate_port_forward_overlaps(): - return - self._run_preparation_overlay() - return - - if current_id == 'init_db_build': - if self.kerberos_enabled.get(): - self.show_page('kerberos_config') - else: - self.show_page('init_scripts') - return - - if current_id == 'build': - if getattr(self, '_built_success', False): - self.show_page('create_installer') - else: - self.perform_build() - return - - if current_id == 'init_cluster': - if not self._validate_and_save_cluster_config(): - return - if not self._cluster_ready_for_navigation(): - return - if not getattr(self, '_common_services_success', False): - self._run_service_layer_overlay(next_page='init_password') - return - self.show_page('init_password') - return - - if current_id == 'init_scripts': - self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if getattr(self, '_scripts_success', False) else 'Attempted' - self._save_prole_cfg() - self.show_page('ollama_config') - return - - if current_id == 'kerberos_config': - # Capture kerberos config - self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get()) - self.prole_cfg_data['Kerberos Authentication']['REALM'] = self.kerberos_realm.get() - self.prole_cfg_data['Kerberos Authentication']['KDC'] = self.kerberos_kdc.get() - self.prole_cfg_data['Kerberos Authentication']['SERVER'] = self.kerberos_kdc.get() - self.prole_cfg_data['Kerberos Authentication']['USER'] = self.kerberos_user.get() - self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = self.kerberos_password.get() - 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') - self._save_prole_cfg() - self.show_page('init_scripts') - return - - if current_id == 'ollama_config': - self._save_ollama_config() - if self.supabase_enabled.get(): - self.show_page('supabase_config') - else: - self.show_page('init_cnpg_deploy') - return - - if current_id == 'supabase_config': - self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) - self.prole_cfg_data['Supabase'] = {'STATUS': 'Deployed' if getattr(self, '_supabase_success', False) else 'Attempted'} - self._save_prole_cfg() - self.show_page('init_cnpg_deploy') - return - - if current_id == 'init_cnpg_deploy': - self.prole_cfg_data['Deployment']['STATUS'] = 'Deployed' if getattr(self, '_cnpg_success', False) else 'Attempted' - self._save_prole_cfg() - self.show_page('create_installer') - return - - if current_id == 'create_installer': - print("[DEBUG] on_next: at create_installer, Finish clicked. Closing.") - if 'Install' in self.prole_cfg_data: - self.prole_cfg_data['Install']['STATUS'] = 'Finished' - self._save_prole_cfg() - self.root.destroy() - return - - # Default next - if self.page_index < len(self.pages) - 1: - print(f"[DEBUG] on_next: default next to index {self.page_index + 1}") - self.show_page(self.page_index + 1) - else: - print("[DEBUG] on_next: already at last page") - return - - def update_footer(self): - # Default states for tk.Button - self.prev_button.configure(state='normal') - self.next_button.configure(state='normal') - first = self.page_index == 0 - # Base label - self.next_button.configure(text='Next') - - if getattr(self, '_showing_service_overlay', False): - if not getattr(self, '_common_services_success', False): - self.next_button.configure(state='disabled') - else: - self.next_button.configure(state='normal') - - # Page-specific adjustments - pid = self.pages[self.page_index][0] - if pid == 'build': - # Build page: show Build or Next depending on state - if getattr(self, '_built_success', False): - self.next_button.configure(text='Next') - else: - self.next_button.configure(text='Build') - elif pid == 'supabase_config': - # Supabase screen: Next is disabled until success - if getattr(self, '_supabase_success', False): - self.next_button.configure(state='normal') - else: - self.next_button.configure(state='disabled') - elif pid == 'init_scripts': - # Initialization Scripts: Next is disabled until success - if getattr(self, '_scripts_success', False): - self.next_button.configure(state='normal') - else: - self.next_button.configure(state='disabled') - # Visibility rules - self.prev_button.pack_forget() - self.next_button.pack_forget() - if self.deploy_button: - self.deploy_button.pack_forget() - if self.launch_button: - self.launch_button.pack_forget() - if self.gear_button: - self.gear_button.pack_forget() - - if pid == 'init_password' and self.gear_button: - self.gear_button.pack(side='left', padx=(20, 0), pady=12) - - if pid == 'create_installer': - self.next_button.configure(text='Finish') - self.next_button.pack(side='right', padx=(0, 20), pady=12) - return - - if first: - self.next_button.pack(side='right', padx=(0, 20), pady=12) - else: - # [Prev] [Next] clustered right - self.next_button.pack(side='right', padx=(0, 20), pady=12) - self.prev_button.pack(side='right', padx=(0, 8), pady=12) - - def _splash_should_hide_nav(self) -> bool: - """Deprecated: Welcome page no longer performs dependency checks or gates navigation.""" - return False - - def _create_page_welcome(self): - f = self._page_container() - ttk.Label(f, text='Welcome to Prole', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) - msg = ( - 'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.' - ) - ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) - # self._register_page('welcome', f) - - def _create_page_dependencies_summary(self): - f = self._page_container() - ttk.Label(f, text='Dependencies', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) - self.deps_container = ttk.Frame(f) - self.deps_container.pack(fill='both', expand=True, padx=16, pady=8) - - self.dep_status = {} - for dep in self.dependencies: - row = ttk.Frame(self.deps_container) - row.pack(fill='x', pady=6) - indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0) - indicator.pack(side='left', padx=8) - name = ttk.Label(row, text=dep['name'], style='Body.TLabel') - name.pack(side='left') - info = ttk.Label(row, text='', style='Dim.TLabel') - info.pack(side='left', padx=10) - self.dep_status[dep['id']] = {'canvas': indicator, 'info': info, 'dep': dep} - # Verify all checkbox (controls navigation behavior) - chk = ttk.Checkbutton(f, text='Verify all dependencies', variable=self.verify_mode, command=self.update_footer) - chk.pack(anchor='w', padx=24, pady=(4, 0)) - - # Message label must be created before refresh to avoid AttributeError - self.deps_msg = ttk.Label(f, text='', style='Dim.TLabel') - self.deps_msg.pack(anchor='w', padx=24, pady=(8, 8)) - # Now we can safely populate the UI. Start async scan to avoid startup delay - self.deps_msg.configure(text='Checking dependencies...') - self.start_dependency_scan() - # self._register_page('deps_summary', f) - - def start_dependency_scan(self): - """Scan dependencies in a background thread to avoid blocking UI startup.""" - if self.validation_running: - return - self.validation_running = True - - # Hold incremental results so we can update the status line and icons progressively - self._dep_scan_results = {} - - def worker(): - for dep in self.dependencies: - try: - ok, location, version = self.get_dep_info(dep) - except Exception: - ok, location, version = False, None, None - did = dep['id'] - # Save and apply incrementally - self._dep_scan_results[did] = (ok, location, version) - self.safe_after(lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(d, o, l, v)) - try: - time.sleep(0.02) - except Exception: - pass - # After all are processed, finalize pass to unify any remaining labels - self.safe_after(lambda: self._apply_dependency_scan(dict(self._dep_scan_results))) - - t = threading.Thread(target=worker, daemon=True) - self.validation_thread = t - t.start() - - def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version): - """Update the dependencies page row and message as each check completes.""" - if not hasattr(self, 'dep_status'): - return - slot = self.dep_status.get(dep_id) - if not slot: - return - # Update dot icon and info text for this row - self._draw_status(slot['canvas'], 'success' if ok else 'error') - if ok: - norm_ver = self.normalize_version(version) if version else '' - slot['info'].configure(text=(norm_ver or '')) - else: - slot['info'].configure(text='Not installed') - - # Update the message line with current missing list - if hasattr(self, '_dep_scan_results'): - missing = [self.dep_status[d]['dep']['name'] for d, res in self._dep_scan_results.items() if not res[0] and d in self.dep_status] - if hasattr(self, 'deps_msg') and self.deps_msg is not None: - if missing: - self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") - else: - self.deps_msg.configure(text='All dependencies installed.') - # Footer may need to react if verify mode is enabled - self.update_footer() - - # Update prole.cfg data - try: - status_str = f"installed (version {version})" if ok else "not installed" - self.prole_cfg_data['Dependencies'][dep_id] = status_str - self._save_prole_cfg() - except Exception: - pass - - def _apply_dependency_scan(self, results: dict): - """Apply scan results to the summary UI and footer labels.""" - self.validation_running = False - any_missing = False - for did, slot in self.dep_status.items(): - ok, location, version = results.get(did, (False, None, None)) - self._draw_status(slot['canvas'], 'success' if ok else 'error') - if ok: - norm_ver = self.normalize_version(version) if version else '' - slot['info'].configure(text=(norm_ver or '')) - else: - any_missing = True - slot['info'].configure(text='Not installed') - - has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None - if any_missing: - missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]] - if missing and has_msg: - self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") - else: - if has_msg: - self.deps_msg.configure(text='All dependencies installed.') - self.update_footer() - - def _create_page_dependency(self, dep): - f = self._page_container() - ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) - ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) - status_var = tk.StringVar(value='Checking...') - loc_var = tk.StringVar(value='') - ver_var = tk.StringVar(value='') - - info_frame = ttk.Frame(f) - info_frame.pack(fill='x', padx=24, pady=12) - # Pretty status row with an icon - status_row = ttk.Frame(info_frame) - status_row.pack(anchor='w', fill='x') - status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0) - status_icon.pack(side='left', padx=(0, 8), pady=(2, 0)) - ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left') - - # Detail rows (hidden when not installed) - loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel') - ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel') - loc_label.pack(anchor='w') - ver_label.pack(anchor='w') - - link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel') - link.pack(anchor='w', padx=24, pady=(12, 0)) - - def _draw_status_icon(canvas, status): - ui.canvas_clear(canvas) - if status == 'installed': - ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#34c759', outline='') - ui.canvas_line_on(canvas, 4, 9, 8, 13, fill='white', width=2) - ui.canvas_line_on(canvas, 8, 13, 15, 5, fill='white', width=2) - elif status == 'missing': - # Friendly warning dot with exclamation - ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#ff9f0a', outline='') - ui.canvas_line_on(canvas, 9, 5, 9, 11, fill='white', width=2) - ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill='white', outline='white') - - def check_then_update(): - ok, location, version = self.get_dep_info(dep) - if ok: - status_var.set('Installed') - _draw_status_icon(status_icon, 'installed') - loc_var.set(f'Location: {location or ""}') - norm_ver = self.normalize_version(version) if version else None - ver_var.set(f'Version: {norm_ver or ""}') - # Ensure details visible - try: - loc_label.pack_configure() - ver_label.pack_configure() - link.pack_forget() - except Exception: - pass - else: - status_var.set('Not installed') - _draw_status_icon(status_icon, 'missing') - # Hide undefined details instead of showing dashes - try: - loc_label.pack_forget() - ver_label.pack_forget() - except Exception: - pass - try: - # Keep install link visible if we have a command - if dep.get('install_cmd') and not link.winfo_ismapped(): - link.pack(anchor='w', padx=24, pady=(12, 0)) - except Exception: - pass - - self.root.after(50, check_then_update) - - def do_install(event=None): - self.open_terminal_with_command(dep.get('install_cmd')) - - if dep.get('install_cmd'): - link.bind('', do_install) - else: - try: - link.pack_forget() - except Exception: - pass - - # self._register_page(f'dep_{dep["id"]}', f) - - def _dep_navigation_sequence(self, force_all: bool = False): - """Return a list of dependency page ids to traverse next. - - If force_all is True or verify_mode is True: include all dep pages in defined order. - - Else: include only missing dependency pages based on current checks. - """ - if force_all or self.verify_mode.get(): - res = [f'dep_{d["id"]}' for d in self.dependencies] - print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}") - return res - seq = [] - for d in self.dependencies: - ok, _, _ = self.get_dep_info(d) - if not ok: - seq.append(f'dep_{d["id"]}') - print(f"[DEBUG] _dep_navigation_sequence: {seq}") - return seq - - def _create_page_build(self): - f = self._page_container() - ttk.Label(f, text='Build', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) - ttk.Label(f, text='Choose a target and build the artifacts.', style='Body.TLabel').pack(anchor='w', padx=24) - wrap = ttk.Frame(f) - wrap.pack(anchor='w', padx=24, pady=12) - ttk.Label(wrap, text='Target Environment:', style='Body.TLabel').pack(side='left') - self.deploy_environment = tk.StringVar(value='Dev') - ttk.Combobox(wrap, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10) - - # Build output console - # Use a background frame to ensure NO borders are visible around the console - console_bg = tk.Frame(f, bg='white', highlightthickness=0, bd=0) - console_bg.pack(fill='both', expand=True, padx=24, pady=12) - self.build_output_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) - self.build_output_console.pack(fill='both', expand=True, padx=1, pady=1) - self.build_output = self.build_output_console.text - - # self._register_page('build', f) - - def _create_page_deploy(self): - f = self._page_container() - ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) - ttk.Label(f, text='Preparing to deploy. We will verify steps and perform actions as needed.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24) - - # Reuse existing deploy steps UI but on light theme - self.deploy_steps = [ - {'name': 'Build Prole macOS app', 'status': 'pending'}, - {'name': 'Check Docker is running', 'status': 'pending'}, - {'name': 'Check or configure container registry', 'status': 'pending'}, - {'name': 'Ensure target cluster', 'status': 'pending'}, - {'name': 'Build prole-db Docker image', 'status': 'pending'}, - {'name': 'Tag Docker image for registry', 'status': 'pending'}, - {'name': 'Push image to registry', 'status': 'pending'}, - {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, - {'name': 'Install LaunchAgent for port-forwards (Dev)', 'status': 'pending'}, - ] - self.deploy_widgets = {} - container = ttk.Frame(f) - container.pack(fill='both', expand=True, padx=16, pady=8) - for step in self.deploy_steps: - self._create_deploy_row(container, step) - - # self._register_page('deploy', f) - - def _create_deploy_row(self, parent, step): - row = ttk.Frame(parent) - row.pack(fill='x', pady=6) - canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0) - canvas.pack(side='left', padx=8) - lbl = ttk.Label(row, text=step['name'], style='Body.TLabel') - lbl.pack(side='left') - status = ttk.Label(row, text='Pending', style='Dim.TLabel') - status.pack(side='right', padx=8) - self.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step} - self._draw_status(canvas, 'pending') - - def _draw_status(self, canvas, status): - ui.canvas_clear(canvas) - if status == 'success': - ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#34c759', outline='') - ui.canvas_line_on(canvas, 5, 10, 9, 14, fill='white', width=2) - ui.canvas_line_on(canvas, 9, 14, 16, 6, fill='white', width=2) - elif status == 'running': - ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ffd60a', outline='') - elif status == 'error': - ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ff3b30', outline='') - else: - ui.canvas_oval_on(canvas, 2, 2, 18, 18, outline='#b0b0b0') - - # --------------- Dependency helpers --------------- - def refresh_dependencies_ui(self): - """Deprecated synchronous refresh retained for compatibility. - Prefer start_dependency_scan -> _apply_dependency_scan. - """ - self.start_dependency_scan() - - def all_dependencies_installed(self): - for dep in self.dependencies: - ok, _, _ = self.get_dep_info(dep) - if not ok: - print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING") - return False - print("[DEBUG] all_dependencies_installed: YES (all OK)") - return True - - def get_dep_info(self, dep): - """Delegate dependency probing to installer.config.get_dep_info.""" - return inst_config.get_dep_info(dep) - - def normalize_version(self, text: str) -> str: - """Normalize versions via installer.config.normalize_version.""" - return inst_config.normalize_version(text) - - def open_terminal_with_command(self, command: str | None): - if not command: - return - try: - # Always create a brand-new Terminal window and paste via clipboard - if platform.system() == 'Darwin': - win_id = self._terminal_create_new_window() - if win_id: - # Position reasonably - l, t, r, b = self._compute_terminal_bounds(radio_bottom_y=120) - self._terminal_set_bounds_by_id(win_id, l, t, r, b) - self._terminal_paste_by_id(win_id, command, press_enter=False) - return - # Fallback: copy to clipboard and open Terminal - subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(command)} | pbcopy && open -a Terminal']) - except Exception: - webbrowser.open_new_tab('https://brew.sh') - - # ---------------- Build integration ---------------- - def perform_build(self): - """Run Prole build in an embedded console (Scoped bash subprocess).""" - self._action_flags['build.run_build'] = True - # Ensure environment exists and is readable before attempting build - try: - self.ensure_prole_env() - except Exception as e: - # Print a readable error to the console area and abort - try: - self._console_press_enter() - except Exception: - pass - err_msg = str(e).replace("'", "'\\''") - err = f"echo 'ERROR: {err_msg}' && exit 1" - self._run_in_console(err, None, on_complete=lambda rc: None) - return - # Prepare logs dir and file - try: - self._build_attempted = True - except Exception: - pass - # reset success flag until proven otherwise - try: - self._built_success = False - except Exception: - pass - logs_dir = self._resolve_prole_logs_dir() - try: - logs_dir.mkdir(parents=True, exist_ok=True) - except Exception: - pass - ts = time.strftime('%Y%m%d-%H%M%S') - log_path = logs_dir / f'build-{ts}.log' - self.last_build_log_path = str(log_path) - self._record_install_log(log_path) - - # Compose build command - env = getattr(self, 'deploy_env_value', 'Dev') - base_cmd = self.get_build_command(env) - # Hostname safety guard: ensure commands run only on the machine that launched the installer - guard = '' - if getattr(self, 'expected_host', None): - eh = self.expected_host - guard = f'host=$(hostname -s); if [ "$host" != "{eh}" ]; then echo "ERROR: wrong host $host (expected {eh})"; exit 1; fi; ' - # Simulate pressing Enter on the previewed command; do not echo a duplicate command - self._console_press_enter() - # Compose a verbose wrapped build command with environment diagnostics and tracing - full_cmd = guard + self._compose_verbose_build_command(base_cmd) - - # Run in embedded console - self._run_in_console(full_cmd, self.last_build_log_path, on_complete=lambda rc: self._on_build_complete(rc)) - - def _compose_verbose_build_command(self, base_cmd: str) -> str: - """Wrap the provided build command with a verbose, diagnostic-rich shell script. - - Adds: - - Timestamps and section headers - - Platform/OS/tooling info (uname, macOS version, Xcode/Swift, Java, Maven, Docker, Git) - - set -euxo pipefail for tracing and early failure - - Echo of the actual build command - """ - # Use portable bash; guard external tool probes to avoid hard failures - prologue = r''' -echo "====[PROLE] Build started $(date '+%Y-%m-%d %H:%M:%S %Z')"; -echo "---- System -------------------------------------------------------"; -uname -a || true; -printf "ARCH=%s\n" "$(uname -m)" || true; -if command -v sw_vers >/dev/null 2>&1; then sw_vers || true; fi; -echo "---- Tooling ------------------------------------------------------"; -if command -v xcodebuild >/dev/null 2>&1; then xcodebuild -version || true; else echo "xcodebuild: not found"; fi; -if command -v swift >/dev/null 2>&1; then swift --version || true; else echo "swift: not found"; fi; -if command -v java >/dev/null 2>&1; then java -version 2>&1 | sed 's/^/java: /'; else echo "java: not found"; fi; -if command -v mvn >/dev/null 2>&1; then mvn -v || true; else echo "mvn: not found"; fi; -if command -v docker >/dev/null 2>&1; then docker version || true; else echo "docker: not found"; fi; -echo "---- Git ----------------------------------------------------------"; -if command -v git >/dev/null 2>&1; then \ - (git -C "$(pwd)" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \ - echo "repo: $(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")" && \ - echo "branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null)" && \ - echo "commit: $(git rev-parse --short HEAD 2>/dev/null)" && \ - git status --porcelain=v1 | sed 's/^/ /' || true) || echo "not a git repo"; \ -else echo "git: not found"; fi; -echo "-------------------------------------------------------------------"; -''' - - # The actual build with tracing and timing - wrapped = f""" -( - set -euxo pipefail - {prologue} - echo "====[PROLE] Executing build command:"; - printf '%s\n' {shlex.quote(base_cmd)}; - echo "-------------------------------------------------------------------"; - start_ts=$(date +%s || echo 0); - {base_cmd} - rc=$? - end_ts=$(date +%s || echo 0); - dur=$((end_ts - start_ts)); - echo "-------------------------------------------------------------------"; - if [ $rc -eq 0 ]; then - echo "====[PROLE] Build finished OK in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; - else - echo "====[PROLE] Build FAILED (rc=$rc) in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; - fi - exit $rc -) -""" - return wrapped - - def _on_build_complete(self, returncode: int): - # After build completes, update state and check for removable disks - try: - self._built_success = (returncode == 0) - except Exception: - self._built_success = False - # Re-enable Next button - try: - self.next_button.configure(state='normal') - except Exception: - pass - # Navigate to disk_selection if removable disks exist, otherwise build_summary - if getattr(self, '_built_success', False): - disks = self.get_removable_disks() - if disks: - self.show_page('disk_selection') - else: - self.show_page('build_summary') - else: - # Build failed, stay on build page - pass - - # ---------------- Embedded console helpers ---------------- - def _ensure_console_overlay(self, radio_bottom_y: int = 160): - """Create semi-transparent backdrop and a ScrolledText console overlay. - The overlay is placed within slide_area between given top and bottom margins. - """ - # Ensure slide_area is visible - self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) - self.slide_area.lift() - - # Compute geometry within slide area - geom = self._compute_console_geometry(radio_bottom_y) - left, top, width, height = geom - - # Use a background frame to ensure NO borders are visible around the console - console_bg = tk.Frame(self.slide_area, bg='white', highlightthickness=0, bd=0) - console_bg.place(x=left, y=top, width=width, height=height) - - # Create TerminalConsole overlay - console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) - console.pack(fill='both', expand=True, padx=1, pady=1) - self._overlay_widgets.append(console_bg) - self._overlay_widgets.append(console) - self._console_text = console.text - - # Keep overlay positioned on resize - def _on_resize(_evt=None): - l, t, w, h = self._compute_console_geometry(radio_bottom_y) - try: - console_bg.place(x=l, y=t, width=w, height=h) - except Exception: - pass - # Bind to slide area; store bind id to unbind later - self._overlay_bind_id = self.slide_area.bind('', _on_resize) - - def _compute_console_geometry(self, radio_bottom_y: int) -> tuple[int, int, int, int]: - """Return (left, top, width, height) for the console overlay area.""" - try: - w = self.slide_area.winfo_width() - h = self.slide_area.winfo_height() - except Exception: - w, h = 1300 * 0.75, 910 - 64 # Content area size approx - margin = 24 - top = max(radio_bottom_y + 10, 120) - bottom = max(top + 180, h - 16) # ensure some height - height = max(160, bottom - top - 80) if bottom - top > 260 else max(140, h - top - 24) - # Recompute bottom based on height - bottom = min(h - 24, top + height) - left = margin - width = max(300, w - 2 * margin) - return (left, top, width, bottom - top) - - def _append_console(self, text: str): - txt = getattr(self, '_console_text', None) - if not txt: - return - try: - if not txt.winfo_exists(): - return - txt.configure(state='normal') - txt.insert('end', text) - txt.see('end') - txt.configure(state='disabled') - except Exception: - pass - - def _console_press_enter(self): - """Simulate pressing Enter on the console preview line: remove blinking cursor if present and add a newline.""" - txt = getattr(self, '_console_text', None) - if not txt: - return - # Stop cursor blinking - if getattr(self, '_cursor_blink_after_id', None): - try: - self.root.after_cancel(self._cursor_blink_after_id) - except Exception: - pass - self._cursor_blink_after_id = None - self._cursor_blink_visible = False - try: - txt.configure(state='normal') - # If last char is our fake cursor, remove it - try: - last_char = txt.get('end-2c', 'end-1c') - if last_char in ('_', '|'): - txt.delete('end-2c', 'end-1c') - except Exception: - pass - txt.insert('end', '\n') - txt.see('end') - txt.configure(state='disabled') - except Exception: - pass - - # ----- Command preview & blinking cursor helpers ----- - def _get_user_host(self) -> tuple[str, str]: - try: - user = os.environ.get('USER') or os.getlogin() - except Exception: - user = 'user' - host = getattr(self, 'expected_host', None) or (platform.node() or 'host').split('.')[0] - return user, host - - def _compose_build_preview(self) -> str: - env = getattr(self, 'deploy_env_value', 'Dev') - cmd = self.get_build_command(env) - user, host = self._get_user_host() - return f"[{user}@{host}]# {cmd}" - - def _console_set_preview(self, line: str): - """Clear console and show a single-line preview with blinking cursor.""" - txt = getattr(self, '_console_text', None) - if not txt: - return - # Stop any previous blinking first - if getattr(self, '_cursor_blink_after_id', None): - try: - self.root.after_cancel(self._cursor_blink_after_id) - except Exception: - pass - self._cursor_blink_after_id = None - self._cursor_blink_visible = False - try: - txt.configure(state='normal') - txt.delete('1.0', 'end') - txt.insert('end', line) - txt.see('end') - txt.configure(state='disabled') - except Exception: - return - - # Start blinking cursor at end of line - def blink(): - t = getattr(self, '_console_text', None) - if t is None: - self._cursor_blink_after_id = None - return - try: - t.configure(state='normal') - # Remove existing cursor - if self._cursor_blink_visible: - # Delete last character if it's our cursor - end_index = t.index('end-1c') - if end_index and end_index != '1.0': - last_char = t.get('end-2c', 'end-1c') - if last_char in ('_', '|'): - t.delete('end-2c', 'end-1c') - self._cursor_blink_visible = False - else: - # Append cursor - t.insert('end', '_') - self._cursor_blink_visible = True - t.see('end') - t.configure(state='disabled') - except Exception: - self._cursor_blink_after_id = None - return - # schedule next toggle - self._cursor_blink_after_id = self.root.after(600, blink) - - self._cursor_blink_after_id = self.root.after(600, blink) - - def _run_in_console(self, command: str, log_path: str, on_complete=None): - """Run a bash -lc command in a background subprocess and stream output to the console and a log file. - This function does not echo the command into the console so that the UI behaves like pressing Enter - on the previously previewed command line. - """ - # Ensure console exists - if not getattr(self, '_console_text', None): - self._ensure_console_overlay(160) - # Stop cursor blinking before starting execution - if getattr(self, '_cursor_blink_after_id', None): - try: - self.root.after_cancel(self._cursor_blink_after_id) - except Exception: - pass - self._cursor_blink_after_id = None - self._cursor_blink_visible = False - # Terminate any previous process - if getattr(self, '_running_process', None): - self._terminate_running_process() - # Open log file - try: - self._console_log_fp = open(log_path, 'a', buffering=1, encoding='utf-8') - except Exception: - self._console_log_fp = None - # Start process group for safe termination - def preexec(): - try: - os.setsid() - except Exception: - pass - try: - proc = subprocess.Popen(['bash', '-lc', command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1, preexec_fn=preexec) - self._running_process = proc - except Exception as e: - self._append_console(f"Failed to start process: {e}\n") - if self._console_log_fp: - try: - self._console_log_fp.write(f"Failed to start process: {e}\n") - except Exception: - pass - self._running_process = None - return - # Disable Next while running - try: - self.next_button.configure(text='Building…', state='disabled') - except Exception: - pass - # Reader thread - def reader(): - rc = None - try: - for line in proc.stdout: - if line is None: - break - self.safe_after(lambda s=line: self._append_console(s)) - if self._console_log_fp: - try: - self._console_log_fp.write(line) - except Exception: - pass - rc = proc.wait() - except Exception: - pass - finally: - if self._console_log_fp: - try: - self._console_log_fp.flush() - self._console_log_fp.close() - except Exception: - pass - self._console_log_fp = None - self._running_process = None - if on_complete: - self.safe_after(lambda: on_complete(rc if rc is not None else -1)) - t = threading.Thread(target=reader, daemon=True) - t.start() - - def _terminate_running_process(self): - proc = getattr(self, '_running_process', None) - if not proc: - return - try: - pgid = os.getpgid(proc.pid) - os.killpg(pgid, signal.SIGTERM) - except Exception: - try: - proc.terminate() - except Exception: - pass - # best-effort kill after short delay - try: - for _ in range(10): - if proc.poll() is not None: - break - time.sleep(0.05) - if proc.poll() is None: - try: - pgid = os.getpgid(proc.pid) - os.killpg(pgid, signal.SIGKILL) - except Exception: - proc.kill() - except Exception: - pass - self._running_process = None - - # ---------------- Build helpers (Terminal window management) ---------------- - def _terminal_create_new_window(self) -> str | None: - """Create a brand-new Terminal window (never reuse existing) and return its id.""" - if platform.system() != 'Darwin': - return None - osa = ''' - tell application "Terminal" to activate - delay 0.05 - tell application "System Events" - if exists process "Terminal" then - tell process "Terminal" - set frontmost to true - try - click menu item "New Window" of menu "Shell" of menu bar 1 - on error - keystroke "n" using {command down} - end try - end tell - end if - end tell - delay 0.1 - tell application "Terminal" - try - set _w to front window - set _id to id of _w - do script "" in _w - return _id - on error - return "" - end try - end tell - ''' - try: - result = subprocess.run(['osascript', '-e', osa], capture_output=True, text=True) - if result.returncode == 0: - sid = result.stdout.strip() - return sid or None - except Exception: - pass - return None - - def _terminal_set_bounds_by_id(self, win_id: str, l: int, t: int, r: int, b: int): - if platform.system() != 'Darwin' or not win_id: - return - osa = f'''tell application "Terminal" to try - set the bounds of every window whose id is {win_id} to {{{l}, {t}, {r}, {b}}} - end try''' - try: - subprocess.run(['osascript', '-e', osa]) - except Exception: - pass - - def _terminal_paste_by_id(self, win_id: str, text: str, press_enter: bool = False): - if platform.system() != 'Darwin' or not win_id: - return - # Put text on clipboard and paste into our specific window - try: - subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(text)} | pbcopy']) - except Exception: - pass - osa = ''' - tell application "Terminal" - try - set _wins to every window whose id is {win_id} - if (count of _wins) > 0 then set front window to item 1 of _wins - end try - activate - end tell - delay 0.05 - tell application "System Events" - keystroke "v" using {command down} - end tell - ''' - if press_enter: - osa += '\n' + 'tell application "System Events" to key code 36' - try: - subprocess.run(['osascript', '-e', osa]) - except Exception: - pass - def _compute_terminal_bounds(self, radio_bottom_y: int = 160) -> tuple[int, int, int, int]: - """Compute terminal window bounds (left, top, right, bottom) to fit inside - the installer window between the radio buttons and the footer.""" - try: - # Window absolute position - x0 = self.root.winfo_rootx() - y0 = self.root.winfo_rooty() - w = self.root.winfo_width() - h = self.root.winfo_height() - except Exception: - # Reasonable defaults - x0, y0, w, h = 200, 200, 1300, 910 - margin = 24 - top = y0 + radio_bottom_y + 10 - bottom = y0 + h - 90 # leave space for footer - # Offset left by sidebar width (325) + divider (1) - left = x0 + 326 + margin - right = x0 + w - margin - # Ensure minimum height - if bottom - top < 160: - bottom = top + 160 - return (left, top, right, bottom) - - def open_build_terminal_for_canvas_area(self, radio_bottom_y: int = 160): - """Open a brand-new Terminal.app window and size it to nestle inside the installer.""" - if platform.system() != 'Darwin': - return - l, t, r, b = self._compute_terminal_bounds(radio_bottom_y) - win_id = self._terminal_create_new_window() - if win_id: - self._terminal_set_bounds_by_id(win_id, l, t, r, b) - self.build_terminal_window_id = win_id - - def _start_terminal_follow(self, radio_bottom_y: int = 160): - """Bind window Configure to keep Terminal bounds anchored to installer area.""" - if platform.system() != 'Darwin': - return - self._terminal_follow_rby = radio_bottom_y - if getattr(self, '_terminal_follow_bound', False): - return - - def _follow(_evt=None): - # Debounce slightly - if getattr(self, '_terminal_follow_after', None): - try: - self.root.after_cancel(self._terminal_follow_after) - except Exception: - pass - def _do(): - if not getattr(self, 'build_terminal_window_id', None): - return - l, t, r, b = self._compute_terminal_bounds(self._terminal_follow_rby) - self._terminal_set_bounds_by_id(self.build_terminal_window_id, l, t, r, b) - self._terminal_follow_after = self.root.after(60, _do) - - self.root.bind('', _follow) - self._terminal_follow_bound = True - - def _stop_terminal_follow(self): - if getattr(self, '_terminal_follow_bound', False): - try: - self.root.unbind('') - except Exception: - pass - self._terminal_follow_bound = False - - def paste_into_terminal(self, text: str, press_enter: bool = False): - """Paste text into Terminal by targeting the build window id if available.""" - if platform.system() != 'Darwin': - return - win_id = getattr(self, 'build_terminal_window_id', None) - if win_id: - self._terminal_paste_by_id(win_id, text, press_enter=press_enter) - return - # Fallback to generic new window - win_id = self._terminal_create_new_window() - if win_id: - self._terminal_paste_by_id(win_id, text, press_enter=press_enter) - - def close_build_terminal(self): - if platform.system() != 'Darwin': - return - # stop following window - self._stop_terminal_follow() - win_id = getattr(self, 'build_terminal_window_id', None) - if not win_id: - # attempt to close front window politely - osa = 'tell application "Terminal" to if (count of windows) > 0 then close front window' - else: - osa = f'tell application "Terminal" to try\nclose (every window whose id is {win_id})\nend try' - try: - subprocess.run(['osascript', '-e', osa]) - except Exception: - pass - - def get_build_command(self, env: str) -> str: - """Delegate to installer.build.get_build_command.""" - return inst_get_build_command(PROJECT_ROOT, env) - - # ---------------- Build Summary page ---------------- - def run_final_deployment(self): - initial_dir = str(self._resolve_env_dir('PROLE_DATA', 'data')) - try: - if not Path(initial_dir).exists(): - initial_dir = str(Path.home()) - except Exception: - initial_dir = str(Path.home()) - - export_base = filedialog.askdirectory( - initialdir=initial_dir, - title="Select deployment export directory" - ) - if not export_base: - self.safe_after(lambda: self.bg_canvas.itemconfig( - self._save_deployment_status_label, - text="Save canceled.", - fill='#6e6e73' - ) if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) - return - - export_base = str(Path(export_base).expanduser()) - docker_export_dir = str(Path(export_base) / "docker-import") - helm_export_dir = str(Path(export_base) / "helm-chart") - kustomize_export_dir = str(Path(export_base) / "kustomize") - - def worker(): - self.safe_after(lambda: self._save_deployment_button.configure(state='disabled') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None) - self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Saving deployment...", fill='blue') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) - - script_name = "final_deployment.sh" - if hasattr(self, 'install_consoles') and script_name in self.install_consoles: - self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None) - - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["DOCKER_IMPORT_DIR"] = docker_export_dir - env["HELM_CHART_DIR"] = helm_export_dir - env["KUSTOMIZE_DIR"] = kustomize_export_dir - - console = getattr(self, 'install_consoles', {}).get(script_name) - if console: - console.clear() - console.write("Saving deployment artifacts...\n") - console.write(f"Export base: {export_base}\n") - console.write(f"Docker images: {docker_export_dir}\n") - console.write(f"Helm chart: {helm_export_dir}\n") - console.write(f"Kustomize: {kustomize_export_dir}\n\n") - - def _on_line(line): - if console: - console.write(line) - - rc = self.controller.run_script( - script_name, - args=['--docker-export', '--helm-chart', '--kustomize', '--output-dir', export_base], - env=env, - on_line=_on_line - ) - - if rc == 0: - self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Deployment saved successfully.", fill='#34c759') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) - else: - self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) - - self.safe_after(lambda: self._save_deployment_button.configure(state='normal') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None) - - threading.Thread(target=worker, daemon=True).start() - - def run_build_a_bao(self): - def worker(): - self.safe_after(lambda: self._build_a_bao_button.configure(state='disabled') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None) - self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Saving secrets to OpenBao...", fill='blue') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) - - script_name = "build-a-bao.sh" - if hasattr(self, 'install_consoles') and script_name in self.install_consoles: - self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None) - - env = os.environ.copy() - env["PROLE_HOME"] = str(PROJECT_ROOT) - env["PROLE_SERVICE"] = str(PROJECT_ROOT) - env["NAMESPACE"] = (self.db_namespace.get() or '').strip() - - console = getattr(self, 'install_consoles', {}).get(script_name) - if console: - console.clear() - console.write(f"Running {script_name} ...\n") - - def _on_line(line): - if console: - console.write(line) - - rc = self.controller.run_script( - script_name, - env=env, - on_line=_on_line - ) - - if rc == 0: - self._secrets_finalized = True - self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Secrets saved to OpenBao.", fill='#34c759') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) - else: - self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) - - self.safe_after(lambda: self._build_a_bao_button.configure(state='normal') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None) - - threading.Thread(target=worker, daemon=True).start() - - def _collect_install_logs(self) -> list[Path]: - logs_dir = self._resolve_prole_logs_dir() - build_logs = [] - init_logs = [] - seen = set() - - def _add(p: Path): - sp = str(p) - if sp in seen: - return - if not p.exists(): - return - seen.add(sp) - if p.name.startswith('build-'): - build_logs.append(p) - elif p.name.startswith('init_') or p.name.startswith('init-') or p.name == 'init_common_services.log': - init_logs.append(p) - - for raw in getattr(self, '_install_run_log_paths', []): - try: - _add(Path(raw)) - except Exception: - pass - - try: - lbp = getattr(self, 'last_build_log_path', None) - if lbp: - _add(Path(lbp)) - except Exception: - pass - - if not build_logs: - try: - candidates = sorted(logs_dir.glob('build-*.log'), key=lambda p: p.stat().st_mtime) - if candidates: - _add(candidates[-1]) - except Exception: - pass - - if not init_logs: - try: - candidates = sorted(logs_dir.glob('init_*.log')) - for p in candidates: - _add(p) - except Exception: - pass - - try: - build_logs.sort(key=lambda p: p.stat().st_mtime) - except Exception: - pass - try: - init_logs.sort(key=lambda p: p.name) - except Exception: - pass - return build_logs + init_logs - - def _render_create_installer_page(self): - # Letterhead at top right - content_width = self.bg_canvas.winfo_width() or 975 - right_margin = content_width - 48 - - ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') - ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', - font=('SF Pro Text', 18), anchor='ne') - - self._render_title('Post Install', y=150) - self._render_paragraph('Review installation logs and save the deployment artifacts.', y=200) - - # Tabs for output - using standardized appearance - ui.canvas_text(self, 48, 260, "Installation Logs", fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) - - # Use a background frame for the notebook to hide potential system borders - notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) - self.install_tabs = ttk.Notebook(notebook_bg, style='TNotebook') - self.install_tabs.pack(fill='both', expand=True, padx=1, pady=1) - - tab_window = self.bg_canvas.create_window(48, 290, window=notebook_bg, anchor='nw', width=900, height=450) - self._canvas_items.append(tab_window) - self._overlay_widgets.append(notebook_bg) - self._overlay_widgets.append(self.install_tabs) - - self.install_consoles = {} - - def _add_tab(title: str, content: str = "", script_name: str | None = None): - # Use a background frame to ensure NO borders are visible around the console - console_bg = tk.Frame(self.install_tabs, bg='white', highlightthickness=0, bd=0) - self.install_tabs.add(console_bg, text=title) - # Use TerminalConsole for consistent styling - console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) - console.pack(fill='both', expand=True, padx=1, pady=1) - if content: - console.write(content) - if script_name: - self.install_consoles[script_name] = console - return console - - # Add existing logs - logs = self._collect_install_logs() - for p in logs: - try: - text = p.read_text(encoding='utf-8', errors='ignore') - content = f"{p}\n\n{text}" - except Exception as e: - content = f"{p}\n\nFailed to read log: {e}\n" - _add_tab(p.name, content) - - # Add prole.cfg tab - cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' - try: - cfg_text = cfg_path.read_text(encoding='utf-8', errors='ignore') - cfg_content = f"{cfg_path}\n\n{cfg_text}" - except Exception as e: - cfg_content = f"{cfg_path}\n\nprole.cfg not found or unreadable: {e}\n" - _add_tab('prole.cfg', cfg_content) - - # Add final_deployment.sh tab - _add_tab('final_deployment.sh', script_name='final_deployment.sh') - # Add build-a-bao.sh tab - _add_tab('build-a-bao.sh', script_name='build-a-bao.sh') - - # Build-A-Bao Button - self._build_a_bao_button = tk.Button(self.bg_canvas, text='Build-A-Bao', command=self.run_build_a_bao, - bg='#F5F5DC', fg='black', activebackground='#E5E5D5', - highlightbackground='#F5F5DC', highlightthickness=0, - relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) - bao_btn_window = self.bg_canvas.create_window(48, 760, window=self._build_a_bao_button, anchor='nw', width=180) - self._canvas_items.append(bao_btn_window) - self._overlay_widgets.append(self._build_a_bao_button) - - # Status Label - self._build_a_bao_status_label = ui.canvas_text(self, 48, 802, "", fill='black', font=('SF Pro Text', 12)) - self._canvas_items.append(self._build_a_bao_status_label) - - def _render_build_summary_page(self): - # Ensure slide_area is visible for the build log console - self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) - self.slide_area.lift() - - # Build Summary: show combined stdout/stderr from last build log - self._render_title('Build Summary', y=40) - if getattr(self, '_built_success', False): - self._render_paragraph('✅ Build completed successfully. You can now drag Prole Tools.app into Applications. Output below:', y=88) - else: - # Troubleshooting header - tips = ( - "❌ Build failed. Troubleshooting tips:\n" - "• Ensure Xcode Command Line Tools are installed: xcode-select --install\n" - "• Verify Swift toolchain and SPM networking (try again; network hiccups can happen)\n" - "• If on Apple Silicon, ensure dependencies target arm64 or install via Homebrew\n" - "• Clean derived data / SPM cache if needed: rm -rf ~/Library/Developer/Xcode/DerivedData\n" - "• Check Docker status if Docker-related steps are used\n" - "• Check the logs below for specific errors" - ) - self._render_paragraph(tips, y=88) - logp = getattr(self, 'last_build_log_path', None) - # Place a scrollable console to show the log - try: - # Standardized Console Output area for build summary - self.build_summary_console = self._create_console_output(y=160, title="Build Log Output", width=900, height=520) - txt = self.build_summary_console.text - - if logp and os.path.exists(logp): - try: - with open(logp, 'r', encoding='utf-8', errors='ignore') as fp: - content = fp.read() - self.build_summary_console.write(content) - except Exception as e: - self.build_summary_console.write(f"Failed to read log: {e}\n") - else: - self.build_summary_console.write('No build log available.') - - # Keep standard y for links - y_links = 760 - except Exception: - # Fallback: just show path - self._render_paragraph('No build log could be displayed.', y=120) - y_links = 140 - - # Add link to open the log file in Finder/TextEdit - if logp: - link = ui.render_link(self, 56, y_links, 'Open build log file') - self._canvas_items.append(link) - - def _open_log(event): - ex, ey = event.x, event.y - bbox = self.bg_canvas.bbox(link) - if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: - try: - if platform.system() == 'Darwin': - subprocess.run(['open', logp]) - else: - webbrowser.open(f'file://{logp}') - except Exception: - pass - self.bg_canvas.bind('', _open_log) - - # On success, also offer DMG packaging and opening options - if getattr(self, '_built_success', False): - try: - dist_dir = str(self._get_prole_dist_dir()) - except Exception: - dist_dir = None - if dist_dir: - y_links += 30 - # Create DMG - link2 = ui.render_link(self, 56, y_links, 'Create Prole Tools.dmg') - self._canvas_items.append(link2) - - def _open_dist(event): - ex, ey = event.x, event.y - bbox = self.bg_canvas.bbox(link2) - if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: - try: - # Build the DMG, then offer to open it - self.create_dmg() - self.open_dmg() - except Exception: - pass - self.bg_canvas.bind('', _open_dist) - - # Also link to open dist folder (fallback) - y_links += 30 - link3 = ui.render_link(self, 56, y_links, 'Open dist folder') - self._canvas_items.append(link3) - - def _open_dist_folder(event): - ex, ey = event.x, event.y - bbox = self.bg_canvas.bbox(link3) - if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: - try: - if platform.system() == 'Darwin': - subprocess.run(['open', dist_dir]) - else: - webbrowser.open(f'file://{dist_dir}') - except Exception: - pass - self.bg_canvas.bind('', _open_dist_folder) - - # ---------------- Drag-and-drop install (macOS Finder) ---------------- - def _get_prole_dist_dir(self) -> Path: - return PROJECT_ROOT / 'prole-tools-app' / 'dist' - - def ensure_applications_symlink(self): - """Deprecated: no longer create /Applications symlink inside the repo. - - We now place the symlink only inside the DMG staging directory to avoid - confusing IDE indexers and to keep the workspace clean. - """ - return - - def open_drag_install_window(self): - """Open the Prole DMG in Finder (macOS) for drag-and-drop install.""" - if platform.system() != 'Darwin': - return - p = self._get_dmg_paths() - dmg = p['final_dmg'] - if not dmg.exists(): - try: - self.create_dmg() - except Exception: - pass - try: - subprocess.run(['open', str(dmg)]) - except Exception: - try: - subprocess.run(['open', str(p['dist'])]) - except Exception: - pass - - # ---------------- DMG Packaging ---------------- - def _get_dmg_paths(self): - # Ensure we have a valid path for DMG output. - # Default to ~/Downloads if selected path is home or invalid. - raw_path = self.selected_local_path.get() - user_dist = Path(raw_path).expanduser() - - if str(user_dist) == str(Path.home()): - user_dist = Path.home() / 'Downloads' - self.selected_local_path.set(str(user_dist)) - - try: - user_dist.mkdir(parents=True, exist_ok=True) - except Exception: - user_dist = self._get_prole_dist_dir() - - dmg_name = 'Prole Tools.dmg' - tmp_dmg = user_dist / 'Prole Tools.tmp.dmg' - final_dmg = user_dist / dmg_name - - # Use a temporary staging area in the app's dist dir to keep user folder clean - app_dist = self._get_prole_dist_dir() - staging = app_dist / 'dmg_stage' - bg_dir = staging / '.background' - bg_img = get_resource_path('img/proleLogoBlueprint.png') - return { - 'dist': user_dist, - 'tmp_dmg': tmp_dmg, - 'final_dmg': final_dmg, - 'staging': staging, - 'bg_dir': bg_dir, - 'bg_img': bg_img, - } - - def create_dmg(self): - """Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink.""" - global installer_name - if platform.system() != 'Darwin': - return - - p = self._get_dmg_paths() - app_src = get_resource_path('prole-app/dist/Prole Tools.app') - if not app_src.exists(): - print(f"Error: {app_src} not found. Run build first.") - return - - staging = p['staging'] - try: - if staging.exists(): - print(f"Cleaning staging area: {staging}") - shutil.rmtree(staging, ignore_errors=True) - staging.mkdir(parents=True, exist_ok=True) - - # 1. Build static installer binary using PyInstaller - print("Building static installer binary...") - installer_name = "Install Prole Infrastructure" - icon_path = PROJECT_ROOT / 'img' / 'proleIconblueprint.png' - try: - # Use --onefile for a single executable - cmd = [ - sys.executable, '-m', 'PyInstaller', - '--onefile', - '--name', installer_name, - '--clean', - '--noconsole', # It's a GUI app (tkinter) - ] - if icon_path.exists(): - cmd.extend(['--icon', str(icon_path)]) - - cmd.append('install.py') - - subprocess.check_call(cmd) - setup_bin = PROJECT_ROOT / 'dist' / installer_name - if setup_bin.exists(): - dst_setup = staging / installer_name - if dst_setup.exists(): - if dst_setup.is_dir(): - shutil.rmtree(dst_setup) - else: - dst_setup.unlink() - shutil.copy2(setup_bin, dst_setup) - else: - print(f"Error: PyInstaller failed to create '{installer_name}' binary.") - except Exception as e: - messagebox.showerror("Error", f"Failed to create DMG: {e}") - print(f"Warning: Failed to build setup binary with PyInstaller: {e}") - - # 2. Copy Prole Tools.app to staging (at root for drag-and-drop) - print("Copying Prole Tools.app to staging...") - dst_app = staging / 'Prole Tools.app' - if dst_app.exists(): - shutil.rmtree(dst_app) - - if sys.version_info >= (3, 8): - shutil.copytree(app_src, dst_app, dirs_exist_ok=True) - else: - subprocess.check_call(['cp', '-R', str(app_src), str(dst_app)]) - - # Inject launcher wrapper into Prole Tools.app - macos_dir = dst_app / 'Contents' / 'MacOS' - launcher_path = macos_dir / 'Prole Tools' - real_bin_path = macos_dir / 'ProleTools.bin' - if launcher_path.exists() and launcher_path.is_file(): - if real_bin_path.exists(): - if real_bin_path.is_dir(): - shutil.rmtree(real_bin_path) - else: - real_bin_path.unlink() - os.rename(launcher_path, real_bin_path) - - script = """#!/bin/bash -set -euo pipefail -export PROLE_HOME="${PROLE_HOME:-$HOME/.prole}" -if [ -f "$PROLE_HOME/env.sh" ]; then - . "$PROLE_HOME/env.sh" -fi -DIR="$(cd "$(dirname "$0")" && pwd)" -exec "$DIR/ProleTools.bin" "$@" -""" - with open(launcher_path, 'w') as fp: - fp.write(script) - os.chmod(launcher_path, 0o755) - - # 3. Create Applications symlink - try: - os.symlink('/Applications', str(staging / 'Applications')) - except FileExistsError: - pass - - # 4. Background image - if p['bg_dir'].exists(): - shutil.rmtree(p['bg_dir']) - p['bg_dir'].mkdir(parents=True, exist_ok=True) - if p['bg_img'].exists(): - shutil.copy2(p['bg_img'], p['bg_dir'] / 'background.png') - - # 5. Create the DMG - tmp_dmg = p['tmp_dmg'] - final_dmg = p['final_dmg'] - if final_dmg.exists(): - os.remove(final_dmg) - - # Create the DMG using hdiutil - messagebox.showinfo("Creating DMG", "Building DMG image. This may take a minute...") - subprocess.check_call([ - 'hdiutil', 'create', - '-volname', 'Prole', - '-srcfolder', str(staging), - '-ov', '-format', 'UDRW', # Create as Read/Write initially to modify view options - str(tmp_dmg) - ]) - - # Positions in DMG: - # [Install Prole Infrastructure] (left) - # [Prole Tools.app] (center/right) - # [Applications] (below Prole Tools.app) - - # Note: We use the installer name in the AppleScript. - # Finder items need to match the actual file names on disk. - # \n in filename might be literal or interpreted. - - # 6. Set DMG view options (large icons) using AppleScript - print("Configuring DMG view options...") - mount_point = Path('/Volumes/Prole') - try: - # Detach if already mounted - subprocess.run(['hdiutil', 'detach', str(mount_point)], capture_output=True) - - # Mount the temporary DMG - subprocess.check_call(['hdiutil', 'attach', str(tmp_dmg), '-nobrowse']) - - # Give it a moment to mount - time.sleep(2) - - if mount_point.exists(): - # Escape the installer name for AppleScript - # Use the name that actually exists on disk. - # PyInstaller might have replaced \n with something else in the filename if it was problematic, - # but usually it's literal in the FS if allowed. - - applescript = f''' - tell application "Finder" - tell disk "Prole" - open - set current view of container window to icon view - set toolbar visible of container window to false - set statusbar visible of container window to false - set the_container to container window - set bounds of the_container to {{400, 100, 1000, 600}} - set icon_view_options to icon view options of the_container - set icon size of icon_view_options to 192 - set arrangement of icon_view_options to not arranged - set background picture of icon_view_options to file ".background:background.png" - - -- Position icons - set position of item "{installer_name}" of container window to {{150, 200}} - set position of item "Prole Tools.app" of container window to {{450, 200}} - set position of item "Applications" of container window to {{450, 400}} - - update without registering applications - delay 2 - close - end tell - end tell - ''' - subprocess.run(['osascript', '-e', applescript]) - - # Detach - subprocess.check_call(['hdiutil', 'detach', str(mount_point)]) - - # Convert to final compressed format - if final_dmg.exists(): - os.remove(final_dmg) - subprocess.check_call([ - 'hdiutil', 'convert', str(tmp_dmg), - '-format', 'UDZO', - '-o', str(final_dmg) - ]) - if tmp_dmg.exists(): - os.remove(tmp_dmg) - except Exception as e: - print(f"Warning: Failed to set DMG view options: {e}") - # Fallback: just rename tmp_dmg if conversion/AppleScript failed - if not final_dmg.exists(): - os.rename(tmp_dmg, final_dmg) - - messagebox.showinfo("Success", f"Successfully created {final_dmg}") - print(f"Successfully created {final_dmg}") - - finally: - # Clean up staging directory - try: - if staging.exists(): - shutil.rmtree(staging, ignore_errors=True) - # Also clean up PyInstaller artifacts - for d in ['build', 'dist']: - p_path = PROJECT_ROOT / d - if p_path.exists(): - # Be careful not to delete 'dist' if it contains our final DMG - # However, create_dmg is usually run to build the DMG - # and PyInstaller artifacts are usually temporary in this context. - # We only remove them if they were created during this run. - pass - spec_file = PROJECT_ROOT / f"{installer_name}.spec" - if spec_file.exists(): - os.remove(spec_file) - except Exception: - pass - - def open_dmg(self): - """Reveal the created DMG in Finder without mounting it inline. - - This avoids blocking the installer process and lets macOS handle - mounting/ejecting normally when the user opens the DMG. - """ - if platform.system() != 'Darwin': - return - p = self._get_dmg_paths() - dmg = p['final_dmg'] - if not dmg.exists(): - # Try to create it first - self.create_dmg() - # Reveal the DMG in Finder (non-blocking); do NOT attach/mount inline - try: - subprocess.run(['open', '-R', str(dmg)]) - except Exception: - try: - # Fallback: open the dist folder - subprocess.run(['open', str(p['dist'])]) - except Exception: - pass - - def create_install_screen(self): - """Create the dependency installer screen""" - frame = tk.Frame(self.content_area, bg='#1a1a1a') - self.screens['install'] = frame - - # Title - title = ttk.Label(frame, text="Install Dependencies", style='Title.TLabel') - title.pack(pady=(0, 30)) - - # Instructions - instructions = tk.Label(frame, - text="Install the following dependencies to proceed with Prole deployment:", - bg='#1a1a1a', - fg='#aaaaaa', - font=('Helvetica', 11)) - instructions.pack(pady=(0, 20)) - - # Dependencies list - deps_frame = tk.Frame(frame, bg='#1a1a1a') - deps_frame.pack(fill='both', expand=True) - - dependencies = [ - { - 'name': 'Docker', - 'description': 'Container platform for running Prole services', - 'url': 'https://www.docker.com/products/docker-desktop', - 'install_cmd': None, - 'check_cmd': 'docker --version' - }, - { - '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' - }, - { - 'name': 'k3d', - 'description': 'Lightweight wrapper to run k3s in Docker', - 'url': 'https://k3d.io', - 'install_cmd': 'brew install k3d', - 'check_cmd': 'k3d --version' - }, - { - 'name': 'kubectl', - 'description': 'Kubernetes command-line tool', - 'url': 'https://kubernetes.io/docs/tasks/tools/', - 'install_cmd': 'brew install kubectl', - 'check_cmd': 'kubectl version --client' - }, - { - 'name': 'Helm', - 'description': 'Kubernetes package manager', - 'url': 'https://helm.sh', - 'install_cmd': 'brew install helm', - 'check_cmd': 'helm version' - }, - { - 'name': 'krew', - 'description': 'Kubectl plugin manager', - 'url': 'https://krew.sigs.k8s.io', - 'install_cmd': 'brew install krew', - 'check_cmd': 'kubectl krew version' - }, - { - 'name': 'cmctl', - 'description': 'cert-manager CLI tool', - 'url': 'https://cert-manager.io', - 'install_cmd': 'brew install cmctl', - 'check_cmd': 'cmctl version' - } - ] - - self.dep_status = {} - for dep in dependencies: - self.create_dependency_card(deps_frame, dep) - - # Generate installer script button - script_btn = tk.Button(frame, - text="Generate Installer Script", - command=self.generate_installer_script, - bg='#4a9eff', - fg='white', - activebackground='#3a8eef', - font=('Helvetica', 12, 'bold'), - padx=30, - pady=15, - cursor='hand2', - relief='flat') - script_btn.pack(pady=20) - - def create_dependency_card(self, parent, dep): - """Create a dependency card with status and download link""" - card = tk.Frame(parent, bg='#2a2a2a', relief='flat', bd=1) - card.pack(fill='x', pady=5, padx=10) - - # Left side - info - info_frame = tk.Frame(card, bg='#2a2a2a') - info_frame.pack(side='left', fill='both', expand=True, padx=15, pady=15) - - name_label = tk.Label(info_frame, - text=dep['name'], - bg='#2a2a2a', - fg='#ffffff', - font=('Helvetica', 13, 'bold'), - anchor='w') - name_label.pack(fill='x') - - desc_label = tk.Label(info_frame, - text=dep['description'], - bg='#2a2a2a', - fg='#aaaaaa', - font=('Helvetica', 10), - anchor='w') - desc_label.pack(fill='x', pady=(5, 0)) - - # Right side - status and actions - action_frame = tk.Frame(card, bg='#2a2a2a') - action_frame.pack(side='right', padx=15, pady=15) - - # Status indicator - status_label = tk.Label(action_frame, - text="Checking...", - bg='#2a2a2a', - fg='#ffaa00', - font=('Helvetica', 10)) - status_label.pack(side='left', padx=10) - self.dep_status[dep['name']] = {'label': status_label, 'dep': dep} - - # Download button - download_btn = tk.Button(action_frame, - text="Download", - command=lambda url=dep['url']: webbrowser.open(url), - bg='#28a745', - fg='white', - activebackground='#218838', - font=('Helvetica', 10), - padx=15, - pady=5, - cursor='hand2', - relief='flat') - download_btn.pack(side='left', padx=5) - - # Check status - self.check_dependency(dep['name']) - - def check_dependency(self, name): - """Check if a dependency is installed""" - dep_info = self.dep_status[name] - dep = dep_info['dep'] - label = dep_info['label'] - - def check(): - try: - result = subprocess.run(dep['check_cmd'].split(), - capture_output=True, - text=True, - timeout=5) - if result.returncode == 0: - label.configure(text="✓ Installed", fg='#28a745') - else: - label.configure(text="✗ Not Installed", fg='#dc3545') - except Exception: - label.configure(text="✗ Not Installed", fg='#dc3545') - - threading.Thread(target=check, daemon=True).start() - - def generate_installer_script(self): - """Generate installer script by composing per-dependency templates from installer/scripts.""" - script_path = PROJECT_ROOT / 'install_dependencies.sh' - scripts_dir = PROJECT_ROOT / 'installer' / 'scripts' - - # Expected order of templates - expected = [ - 'install_prole_homebrew.sh', - 'install_prole_k3d.sh', - 'install_prole_kubectl.sh', - 'install_prole_helm.sh', - 'install_prole_krew.sh', - 'install_prole_cmctl.sh', - 'install_prole_kubectl_plugins.sh', - ] - - def _validate_and_strip(path: Path) -> str: - """Validate template format and return content stripped of shebang and initial set -e* line.""" - try: - text = path.read_text(encoding='utf-8') - except Exception as e: - raise RuntimeError(f"Failed to read {path.name}: {e}") - - lines = text.splitlines() - if not lines: - raise RuntimeError(f"Template {path.name} is empty") - # Validate shebang - if not lines[0].startswith('#!/bin/bash'): - raise RuntimeError(f"Template {path.name} must start with #!/bin/bash") - # Ensure there is a set -e or set -euo pipefail somewhere within first 10 lines - has_set = any('set -e' in l for l in lines[:10]) - if not has_set: - raise RuntimeError(f"Template {path.name} must set '-e' or 'set -euo pipefail'") - # Strip shebang - i = 1 - # Optionally strip empty/comment lines immediately after shebang - while i < len(lines) and lines[i].strip() == '': - i += 1 - # If next non-empty is a "set -e*" line, drop it to avoid duplicates in composed script - if i < len(lines) and lines[i].lstrip().startswith('set -e'): - i += 1 - body = "\n".join(lines[i:]).strip() + "\n" - return body - - # Compose the final script - errors: list[str] = [] - parts: list[str] = [] - - header = ( - "#!/bin/bash\n" - "# Prole Dependencies Installer Script\n" - "# Generated by Prole Installer\n\n" - "set -euo pipefail\n\n" - "echo \"Installing Prole dependencies...\"\n\n" - ) - parts.append(header) - - for fname in expected: - p = scripts_dir / fname - if not p.exists(): - errors.append(f"Missing template: {fname}") - continue - try: - body = _validate_and_strip(p) - parts.append(f"# ---- {fname} ----\n") - parts.append(body) - parts.append("\n") - except Exception as e: - errors.append(str(e)) - - parts.append('echo "All dependencies installed successfully!"\n') - - if errors: - messagebox.showerror("Template error", "\n".join(errors)) - return - - try: - with open(script_path, 'w', encoding='utf-8', newline='\n') as f: - f.write("".join(parts)) - os.chmod(script_path, 0o755) - messagebox.showinfo("Success", - f"Installer script generated at:\n{script_path}\n\n" - "You can run it with: ./install_dependencies.sh") - except Exception as e: - messagebox.showerror("Error", f"Failed to generate script: {str(e)}") - - def create_deploy_screen(self): - """Create the Deploy screen""" - frame = tk.Frame(self.content_area, bg='#1a1a1a') - self.screens['deploy'] = frame - - # Title - title = ttk.Label(frame, text="Build and Deploy", style='Title.TLabel') - title.pack(pady=(0, 30)) - - # Instructions - instructions = tk.Label(frame, - text="Build and deploy Prole services to k3d cluster", - bg='#1a1a1a', - fg='#aaaaaa', - font=('Helvetica', 11)) - instructions.pack(pady=(0, 20)) - - # Environment selector - env_frame = tk.Frame(frame, bg='#1a1a1a') - env_frame.pack(pady=(0, 10), fill='x') - env_label = tk.Label(env_frame, text="Target Environment:", bg='#1a1a1a', fg='#dddddd', font=('Helvetica', 11)) - env_label.pack(side='left', padx=(0, 10)) - self.deploy_environment = tk.StringVar(value='Dev') - env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18) - env_combo.pack(side='left') - env_help = tk.Label(env_frame, - text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at pi.prole.org:6443, Prod = stretch of prole-service-cluster", - bg='#1a1a1a', fg='#888888', font=('Helvetica', 9)) - env_help.pack(side='left', padx=(12, 0)) - - # Deploy button - deploy_btn = tk.Button(frame, - text="Build and Deploy", - command=self.start_deployment, - bg='#4a9eff', - fg='white', - activebackground='#3a8eef', - font=('Helvetica', 16, 'bold'), - padx=40, - pady=20, - cursor='hand2', - relief='flat') - deploy_btn.pack(pady=20) - - # Progress frame - progress_frame = tk.Frame(frame, bg='#1a1a1a') - progress_frame.pack(fill='both', expand=True, pady=20) - - # Progress list - # Initialize deploy steps; some names are updated dynamically when deployment starts - self.deploy_steps = [ - {'name': 'Build ProleStatus macOS app', 'status': 'pending'}, - {'name': 'Check Docker is running', 'status': 'pending'}, - {'name': 'Check or configure container registry', 'status': 'pending'}, - {'name': 'Ensure target cluster', 'status': 'pending'}, - {'name': 'Build prole-db Docker image', 'status': 'pending'}, - {'name': 'Tag Docker image for registry', 'status': 'pending'}, - {'name': 'Push image to registry', 'status': 'pending'}, - {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, - ] - - self.deploy_widgets = {} - for step in self.deploy_steps: - self.create_deploy_step_widget(progress_frame, step) - - def create_deploy_step_widget(self, parent, step): - """Create a widget for a deployment step""" - step_frame = tk.Frame(parent, bg='#2a2a2a', relief='flat') - step_frame.pack(fill='x', pady=5, padx=10) - - # Status indicator - status_canvas = tk.Canvas(step_frame, width=30, height=30, bg='#2a2a2a', highlightthickness=0) - status_canvas.pack(side='left', padx=15, pady=15) - - # Step name - name_label = tk.Label(step_frame, - text=step['name'], - bg='#2a2a2a', - fg='#ffffff', - font=('Helvetica', 11), - anchor='w') - name_label.pack(side='left', fill='x', expand=True, padx=10) - - # Status text - status_label = tk.Label(step_frame, - text="Pending", - bg='#2a2a2a', - fg='#aaaaaa', - font=('Helvetica', 10)) - status_label.pack(side='right', padx=15) - - self.deploy_widgets[step['name']] = { - 'canvas': status_canvas, - 'label': status_label, - 'step': step - } - - # Draw initial pending state - self.update_deploy_step_status(step['name'], 'pending') - - def update_deploy_step_status(self, step_name, status): - """Update the status of a deployment step""" - widget = self.deploy_widgets[step_name] - canvas = widget['canvas'] - label = widget['label'] - step = widget['step'] - - step['status'] = status - canvas.delete('all') - - if status == 'pending': - ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2) - label.configure(text="Pending", fg='#aaaaaa') - elif status == 'running': - ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#ffaa00', width=2, fill='#ffaa00') - label.configure(text="Running...", fg='#ffaa00') - elif status == 'completed': - ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#28a745', width=2, fill='#28a745') - ui.canvas_text_on(canvas, 15, 15, '✓', fill='white', font=('Helvetica', 16, 'bold')) - label.configure(text="Completed", fg='#28a745') - elif status == 'error': - ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#dc3545', width=2, fill='#dc3545') - ui.canvas_text_on(canvas, 15, 15, '✗', fill='white', font=('Helvetica', 16, 'bold')) - label.configure(text="Error", fg='#dc3545') - elif status == 'skipped': - ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2, fill='#444444') - label.configure(text="Skipped", fg='#888888') - - def start_deployment(self): - """Start the deployment process""" - threading.Thread(target=self.run_deployment, daemon=True).start() - - def generate_prole_properties(self, env: str): - """Dynamically generate prole-tools-app/prole.properties based on environment""" - props_path = Path("prole-tools-app/prole.properties") - - # Determine host based on environment - # For now use localhost as a placeholder for Service/Prod - host = "localhost" - - content = f"""# Prole default endpoints (dynamically generated by install.py) -# UI assets -icon=img/proleIcon.png -background=img/proleLogoSepia.png - -# Dev port-forward supervision -pf.enabled=true - -# Service endpoints (5 traffic lights) -svc.1.name=K3D -svc.1.host={host} -svc.1.port=6443 - -svc.2.name=Prometheus -svc.2.host={host} -svc.2.port=9090 - -svc.3.name=Grafana -svc.3.host={host} -svc.3.port=3000 - -svc.4.name=OpenBAO -svc.4.host={host} -svc.4.port=8200 - -svc.5.name=PostgreSQL -svc.5.host={host} -svc.5.port=5432 - -# Kerberos configuration -kerberos.enabled={str(self.kerberos_enabled.get()).lower()} -kerberos.realm={self.kerberos_realm.get()} -kerberos.user={self.kerberos_user.get()} -kerberos.kdc={self.kerberos_kdc.get()} -""" - props_path.write_text(content) - print(f"Generated {props_path} for {env} environment") - - def run_deployment(self): - """Run the deployment steps""" - try: - # Capture environment selection and prepare dynamic labels - env = self.deploy_environment.get().strip() - if env not in ('Dev', 'Service', 'Prod'): - env = 'Dev' - - # Update step labels to reflect environment - self.deploy_widgets['Ensure target cluster']['step']['name'] = f"Ensure target cluster ({env})" - # Use the correct way to update the label text in the UI - self.deploy_widgets['Ensure target cluster']['label'].master.winfo_children()[1].configure(text=f"Ensure target cluster ({env})") - - # Step 0: Generate prole.properties - self.generate_prole_properties(env) - - # Step 1: Build Prole macOS app - self.update_deploy_step_status('Build Prole macOS app', 'running') - self.build_prole_app() - self.update_deploy_step_status('Build Prole macOS app', 'completed') - - # Step 1: Check Docker - self.update_deploy_step_status('Check Docker is running', 'running') - if not self.check_docker_running(): - self.update_deploy_step_status('Check Docker is running', 'error') - messagebox.showerror("Error", "Docker is not running. Please start Docker Desktop.") - return - self.update_deploy_step_status('Check Docker is running', 'completed') - - # Step 2: Registry discovery/config - self.update_deploy_step_status('Check or configure container registry', 'running') - self.registry_url = self.ensure_registry_available(env) - self.update_deploy_step_status('Check or configure container registry', 'completed') - - # Step 3: Ensure target cluster as per environment - self.update_deploy_step_status('Ensure target cluster', 'running') - self.create_or_select_cluster(env) - self.update_deploy_step_status('Ensure target cluster', 'completed') - - # Step 3: Build Docker image - self.update_deploy_step_status('Build prole-db Docker image', 'running') - self.build_docker_image() - self.update_deploy_step_status('Build prole-db Docker image', 'completed') - - # Step 4: Tag image - self.update_deploy_step_status('Tag Docker image for registry', 'running') - self.tag_docker_image() - self.update_deploy_step_status('Tag Docker image for registry', 'completed') - - # Step 5: Push to registry - self.update_deploy_step_status('Push image to registry', 'running') - self.push_docker_image() - self.update_deploy_step_status('Push image to registry', 'completed') - - # Step 6: Import to k3d (Dev only) - if env == 'Dev': - self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'running') - self.import_k3d_image(cluster_name='prole-dev-cluster') - self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'completed') - else: - self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'skipped') - - # Step 7: Install LaunchAgent for port-forwards (Dev) - if env == 'Dev': - self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'running') - ok = self.install_launchagent_port_forwards() - if ok: - self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'completed') - else: - self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'error') - return - else: - self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'skipped') - - messagebox.showinfo("Success", "Deployment completed successfully!") - - except Exception as e: - messagebox.showerror("Error", f"Deployment failed: {str(e)}") - - def install_launchagent_port_forwards(self) -> bool: - """Create/update the user LaunchAgent and helper script to manage kubectl port-forwards. - - Helper script: ~/Library/Application Support/Prole/bin/prole-kpf.sh - - LaunchAgent: ~/Library/LaunchAgents/org.prole.prole-db.kpf-dev.plist - """ - try: - home = Path.home() - bin_dir = home / 'Library' / 'Application Support' / 'Prole' / 'bin' - run_dir = home / 'Library' / 'Application Support' / 'Prole' / 'run' - plist_path = home / 'Library' / 'LaunchAgents' / 'org.prole.prole-db.kpf-dev.plist' - script_path = bin_dir / 'prole-kpf.sh' - - bin_dir.mkdir(parents=True, exist_ok=True) - run_dir.mkdir(parents=True, exist_ok=True) - plist_path.parent.mkdir(parents=True, exist_ok=True) - - helper_script = """#!/bin/sh -set -eu - -LABEL="org.prole.prole-db.kpf-dev" -PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" -RUNDIR="$HOME/Library/Application Support/Prole/run" -PIDFILE="$RUNDIR/kpf.pids" - -ensure_rundir() { - mkdir -p "$RUNDIR" -} - -list_cmds() { - # Enumerate ProleCommands array using PlistBuddy - if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then - i=0 - while true; do - if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then - break - fi - echo "$val" - i=$((i+1)) - done - fi -} - -start() { - ensure_rundir - : > "$PIDFILE" - IFS='\n' - for cmd in $(list_cmds); do - [ -z "$cmd" ] && continue - (sh -lc "$cmd") & - echo $! >> "$PIDFILE" - done - wait -} - -stop() { - if [ -f "$PIDFILE" ]; then - while read -r pid; do - [ -z "$pid" ] && continue - kill "$pid" 2>/dev/null || true - done < "$PIDFILE" - rm -f "$PIDFILE" - fi -} - -status() { - if [ ! -f "$PIDFILE" ]; then - echo "not running" - exit 3 - fi - alive=0 - total=0 - while read -r pid; do - [ -z "$pid" ] && continue - total=$((total+1)) - if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi - done < "$PIDFILE" - echo "$alive/$total running" -} - -case "${1:-}" in - start) start ;; - stop) stop ;; - restart) stop; start ;; - status) status ;; - *) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;; -esac -""" - - script_path.write_text(helper_script) - os.chmod(script_path, 0o755) - - # Default commands for Dev environment (k3d) - default_cmds = [ - "kubectl port-forward -n monitoring svc/kps-kube-prometheus-stack-prometheus 9090", - "kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443", - "kubectl port-forward svc/prole-db-rw 5432:5432 --address 0.0.0.0", - "kubectl port-forward -n monitoring svc/kps-grafana 3000:80 --address 0.0.0.0", - ] - - # Create plist dict - from plistlib import dumps as plist_dumps - plist_dict = { - 'Label': 'org.prole.prole-db.kpf-dev', - 'RunAtLoad': True, - 'KeepAlive': True, - # Ensure PATH has common locations for kubectl - 'EnvironmentVariables': { - 'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin') - }, - 'StandardOutPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.out.log'), - 'StandardErrorPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.err.log'), - 'ProgramArguments': [str(script_path), 'start'], - 'ProleCommands': default_cmds, - } - - plist_bytes = plist_dumps(plist_dict) - plist_path.write_bytes(plist_bytes) - - # Reload the agent - uid = os.getuid() - # Try to kickstart first - subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) - # Bootout and bootstrap to ensure it's loaded, then kickstart - subprocess.run(['launchctl', 'bootout', f'gui/{uid}', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) - subprocess.run(['launchctl', 'bootstrap', f'gui/{uid}', str(plist_path)], check=True) - subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], check=True) - - return True - except Exception as e: - print(f"LaunchAgent setup failed: {e}") - return False - - - def check_xcode_tools(self): - """Check if Xcode Command Line Tools are installed via deploy helper.""" - return inst_deploy.check_xcode_tools() - - def build_prole_app(self): - """Delegate building the native ProleStatus app to deploy helper.""" - return inst_deploy.build_prole_app(PROJECT_ROOT) - - def check_docker_running(self): - """Check if Docker is running""" - try: - result = subprocess.run(['docker', 'ps'], - capture_output=True, - timeout=10) - return result.returncode == 0 - except Exception: - return False - - def ensure_registry_available(self, env: str) -> str: - """Detect if k8s.prole.org:5000 is reachable; if so use it. Otherwise ensure a local registry is available. - Returns the registry URL (host:port) to be used for tagging/pushing. - For Dev, may create a local k3d-managed registry; for other envs, still prefer the external if reachable. - """ - # Prefer the shared registry if reachable - if _http_ping_registry('k8s.prole.org', 5000): - return 'k8s.prole.org:5000' - - if not _local_registry_enabled(env): - return '' - - # Otherwise, ensure a local registry (localhost:5000) exists/started - if _http_ping_registry('localhost', 5000): - return 'localhost:5000' - - # Use k3d registry helper for Dev; for non-Dev, we still create/use local for pushing - reg_name = 'prole-registry' - # Check if k3d is installed - k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0 - if k3d_exists: - # List registries - lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True) - if reg_name not in (lst.stdout or ''): - # Create registry exposed on 0.0.0.0:5000 - subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) - return 'localhost:5000' - - def ensure_local_registry_available(self): - """Ensure a local registry is running and return (host_registry, cluster_registry).""" - mode_hint = None - try: - mode_hint = self._deployment_mode() - except Exception: - mode_hint = None - - if not _local_registry_enabled(mode_hint): - try: - log_path = self._registry_log_path() - with log_path.open('a', encoding='utf-8') as fp: - fp.write(f"\n# Registry disabled (mode={mode_hint or 'unknown'}) @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") - except Exception: - pass - return None - - log_path = self._registry_log_path() - try: - log_fp = log_path.open('a', encoding='utf-8') - except Exception: - log_fp = None - - def _log(msg: str): - if not log_fp: - return - try: - log_fp.write(msg) - log_fp.flush() - except Exception: - pass - - def _log_cmd(cmd: list[str], res: subprocess.CompletedProcess): - _log(f"$ {' '.join(cmd)}\n") - if res.stdout: - _log(res.stdout) - if res.stderr: - _log(res.stderr) - _log(f"[exit {res.returncode}]\n") - - def _run(cmd: list[str], check: bool = False): - res = subprocess.run(cmd, capture_output=True, text=True) - if res.returncode != 0: - _log_cmd(cmd, res) - if check: - raise RuntimeError(f"Command failed: {' '.join(cmd)}") - return res - - _log(f"\n# Registry check @ {time.strftime('%Y-%m-%d %H:%M:%S')} (mode={mode_hint or 'unknown'})\n") - reg_name = 'prole-registry' - registry_container = f'k3d-{reg_name}' - host_registry = 'localhost:5000' - - mode = _deployment_mode_from_env(self.prole_cfg_data.get('Global', {}).get('DEPLOYMENT_MODE')) - if mode == 'k3d': - cluster_registry = f'{registry_container}.localhost:5000' - else: - cluster_registry = host_registry - - if not self.check_docker_running(): - _log("Docker not running; cannot ensure local registry.\n") - if log_fp: - log_fp.close() - return None - - # 1. If a registry is already responsive on localhost:5000, we use it. - if _http_ping_registry('localhost', 5000): - # Check if it's a k3d registry to use the correct cluster internal name - try: - lst = _run(['k3d', 'registry', 'list']) - if registry_container in (lst.stdout or '') or reg_name in (lst.stdout or ''): - cluster_registry = f'{registry_container}.localhost:5000' - else: - cluster_registry = host_registry - except Exception: - cluster_registry = host_registry - - self.local_registry_url = host_registry - self.local_registry_internal = cluster_registry - try: - if 'Docker Build' not in self.prole_cfg_data: - self.prole_cfg_data['Docker Build'] = {} - self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY'] = host_registry - self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY_INTERNAL'] = cluster_registry - self.safe_after(self._save_prole_cfg) - except Exception: - pass - if log_fp: - log_fp.close() - return host_registry, cluster_registry - - # 2. Try k3d first if available - k3d_exists = _run(['which', 'k3d']).returncode == 0 - if k3d_exists: - try: - # Ensure registry exists - lst = _run(['k3d', 'registry', 'list']) - if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''): - _run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) - - # Ensure registry container is running - running = _run( - ['docker', 'ps', '--filter', f'name={registry_container}', '--format', '{{.Names}}'], - ) - if not (running.stdout or '').strip(): - _run(['docker', 'start', registry_container], check=True) - - self.local_registry_url = host_registry - self.local_registry_internal = f'{registry_container}.localhost:5000' - if log_fp: - log_fp.close() - return self.local_registry_url, self.local_registry_internal - except Exception: - _log("k3d registry setup failed.\n") - - # 3. Fallback to plain Docker registry - try: - docker_exists = _run(['which', 'docker']).returncode == 0 - if docker_exists: - # Check if prole-registry container exists - exists = _run( - ['docker', 'ps', '-a', '--filter', 'name=^/prole-registry$', '--format', '{{.Names}}'], - ) - if (exists.stdout or '').strip(): - _run(['docker', 'start', 'prole-registry'], check=True) - else: - _run([ - 'docker', 'run', '-d', '-p', '5000:5000', - '--restart', 'always', '--name', 'prole-registry', 'registry:2' - ], check=True) - - # Wait a moment for it to start - time.sleep(1) - if _http_ping_registry('localhost', 5000): - self.local_registry_url = host_registry - self.local_registry_internal = host_registry - if log_fp: - log_fp.close() - return host_registry, host_registry - except Exception: - _log("Docker registry fallback failed.\n") - - if log_fp: - log_fp.close() - return None - - 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 = 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(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, log=None) -> bool: - def _log(msg: str): - if log: - try: - log(msg) - except Exception: - pass - - info = self.ensure_local_registry_available() - if not info: - _log("Local registry unavailable; skipping image pre-pull.\n") - return False - - registry, _cluster_registry = info - images = self._collect_dependent_images(include_supabase, include_kerberos_proxy) - if not images: - _log("No dependent images found to pre-pull.\n") - return True - - overall_ok = True - import_dir = self.docker_import_dir.get().strip() - - 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 - _log(f"Checking if {image} exists in local registry...\n") - check_reg = subprocess.run(['docker', 'pull', local_tag], capture_output=True, text=True) - if check_reg.returncode == 0: - _log(f"[OK] {image} already exists in local registry as {local_tag}\n") - 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(): - _log(f"Found {tar_path} in import directory, loading...\n") - load = subprocess.run(['docker', 'load', '-i', str(tar_path)], capture_output=True, text=True) - if load.returncode == 0: - found = True - else: - _log(f"[WARN] Failed to load {tar_path}: {load.stderr}\n") - - if not found: - # 4. Pull from Docker Hub - _log(f"Pulling {image} from Docker Hub...\n") - pull = subprocess.run(['docker', 'pull', image], capture_output=True, text=True) - if pull.returncode != 0: - overall_ok = False - _log(pull.stdout or '') - _log(pull.stderr or '') - _log(f"[ERROR] docker pull failed for {image}\n") - 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 - _log(tag.stdout or '') - _log(tag.stderr or '') - _log(f"[ERROR] docker tag failed for {image} to {local_tag}\n") - continue - - _log(f"Pushing {local_tag} to local registry...\n") - if not _push_docker_image(local_tag, log_fn=log): - overall_ok = False - continue - _log(f"[OK] Stored {image} in local registry as {local_tag}\n") - - return overall_ok - - def create_or_select_cluster(self, env: str): - """Ensure target cluster depending on environment selection.""" - if env == 'Dev': - self.create_or_recreate_k3d_dev_cluster() - elif env == 'Service': - 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.") - server, token = self._k3s_connection_info() - if server and token: - res = subprocess.run(self._k3s_kubectl_base_cmd() + ['cluster-info'], capture_output=True, text=True) - if res.returncode != 0: - raise Exception("K3s cluster not reachable with provided server/token.") - else: - # Fallback to local kubeconfig if no token provided - subprocess.run(['kubectl', 'version', '--client'], check=True) - elif env == 'Prod': - 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.") - subprocess.run(['kubectl', 'version', '--client'], check=True) - else: - raise Exception(f"Unknown environment: {env}") - - def create_or_recreate_k3d_dev_cluster(self, cluster_name='prole-dev-cluster'): - """Create or restart local k3d cluster and wire it to the chosen registry.""" - result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True) - if cluster_name in (result.stdout or ''): - subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True) - - # Determine registry integration args - reg_args = [] - if getattr(self, 'registry_url', None): - # If using the local k3d registry, we want to create or use it - if self.registry_url.startswith('localhost:5000'): - # Creating with --registry-create ensures it's available and integrated - reg_args = ['--registry-create', f'prole-registry:0.0.0.0:5000'] - else: - reg_args = ['--registry-use', self.registry_url] - - prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) - volume_args = _k3d_prole_data_volume_args(prole_data) - cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2', '--wait'] + volume_args + reg_args + ['--timestamps'] - subprocess.run(cmd, check=True, cwd=PROJECT_ROOT) - - def get_prole_db_version(self): - return self.controller.get_prole_db_version() - - def build_docker_image(self): - """Build prole-db Docker image""" - # If Kerberos is enabled, update pg_hba.conf in conf/postgresql before copying - if self.kerberos_enabled.get(): - realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM" - hba_src = PROJECT_ROOT / 'conf' / 'postgresql' / 'pg_hba.conf' - if hba_src.exists(): - content = hba_src.read_text() - # Also ensure the Kerberos line exists in the source if it's not there yet - if "gss" not in content: - content += f"\nhost all all all gss include_realm=1 krb_realm={realm}\n" - else: - content = content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}") - hba_src.write_text(content) - print(f"Updated {hba_src} with realm {realm}") - - # Base local image tag (before pushing to registry) - version = self.get_prole_db_version() - image_tag = f'prole-db:{version}' - - # Prepare build context: copy conf/postgresql to prole-db/postgresql - conf_src = PROJECT_ROOT / 'conf' / 'postgresql' - conf_dst = PROJECT_ROOT / 'prole-db' / 'postgresql' - - if conf_dst.exists(): - shutil.rmtree(conf_dst, ignore_errors=True) - if sys.version_info >= (3, 8): - shutil.copytree(conf_src, conf_dst, dirs_exist_ok=True) - else: - shutil.copytree(conf_src, conf_dst) - - build_cmd = ['docker', 'build', '-t', image_tag] - - target_env = self._cluster_env_key() - if getattr(self, 'deploy_environment', None): - try: - target_env = _normalize_cluster_env(self.deploy_environment.get()) - except Exception: - pass - # Add platform flag for target environment (k3s on Pi uses arm64) - build_cmd.extend(get_docker_build_platform_args(target_env)) - - build_cmd.append('.') - - result = subprocess.run(build_cmd, - check=True, - cwd=PROJECT_ROOT / 'prole-db', - capture_output=True, - text=True) - if result.returncode != 0: - raise Exception(f"Failed to build image: {result.stderr}") - # Keep a reference for later steps - self.local_image_tag = image_tag - - # Update K8s manifest - self.update_k8s_manifest(image_tag) - - def update_k8s_manifest(self, image_tag): - """Update k8s/prole/prole-db.yaml with the new version and Kerberos config""" - manifest_paths = [ - PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db.yaml', - PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db-recovery.yaml.tpl', - ] - import re - for manifest_path in manifest_paths: - if not manifest_path.exists(): - continue - content = manifest_path.read_text() - # Update imageName: prole-db:17.7-043 - new_content = re.sub(r'imageName:\s*.*', f'imageName: {image_tag}', content) - - # Update Kerberos realm in manifest if enabled - if self.kerberos_enabled.get(): - realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM" - if "gss" not in new_content: - # Insert before bootstrap if not present - new_content = new_content.replace(" pg_hba:", f" pg_hba:\n - host all all all gss include_realm=1 krb_realm={realm}") - else: - new_content = new_content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}") - - if new_content != content: - manifest_path.write_text(new_content) - - def build_mssql_docker_image(self, image_tag='prole-mssql-db:latest', target_env: str | None = None): - """Build mssql Docker image (with platform detection for target env).""" - build_cmd = ['docker', 'build', '-t', image_tag] - - env_key = target_env or self._cluster_env_key() - # Add platform flag for Apple Silicon / k3s target - build_cmd.extend(get_docker_build_platform_args(env_key)) - - build_cmd.append('.') - - result = subprocess.run(build_cmd, - check=True, - cwd=PROJECT_ROOT / 'mssql', - capture_output=True, - text=True) - if result.returncode != 0: - raise Exception(f"Failed to build mssql image: {result.stderr}") - return result - - def tag_docker_image(self): - """Tag Docker image for registry""" - if not getattr(self, 'registry_url', ''): - print("Registry disabled; skipping tag.") - return - version = self.get_prole_db_version() - registry = getattr(self, 'registry_url', 'localhost:5000') - image_name = f'prole-db:{version}' - image = getattr(self, 'local_image_tag', image_name) - self.remote_image_tag = f"{registry}/prole-db:{version}" - subprocess.run(['docker', 'tag', image, self.remote_image_tag], check=True, capture_output=True) - - def push_docker_image(self): - """Push Docker image to registry""" - if not getattr(self, 'registry_url', ''): - print("Registry disabled; skipping push.") - return - remote_tag = getattr(self, 'remote_image_tag', None) - if not remote_tag: - raise Exception('Remote image tag not set') - if not _push_docker_image(remote_tag): - raise Exception('Failed to push image to registry.') - - def import_k3d_image(self, cluster_name='prole-dev-cluster'): - """Import image to k3d cluster (only for Dev).""" - version = self.get_prole_db_version() - image_name = f'prole-db:{version}' - subprocess.run(['k3d', 'image', 'import', image_name, '-c', cluster_name], check=True, capture_output=True) - - def create_validate_screen(self): - """Create the Validate screen""" - frame = self._page_container() - self.screens['validate'] = frame - - # Title - title = ttk.Label(frame, text="Validate Deployment", style='Title.TLabel') - title.pack(pady=(0, 20)) - - # Prometheus link - prometheus_frame = tk.Frame(frame, bg='white') - prometheus_frame.pack(pady=(0, 20)) - - prometheus_label = tk.Label(prometheus_frame, - text="Prometheus: ", - bg='white', - fg='#6e6e73', - font=('Helvetica', 11)) - prometheus_label.pack(side='left') - - prometheus_link = tk.Label(prometheus_frame, - text="http://localhost:9090", - bg='white', - fg='#4a9eff', - font=('Helvetica', 11, 'underline'), - cursor='hand2') - prometheus_link.pack(side='left') - prometheus_link.bind('', lambda e: webbrowser.open('http://localhost:9090')) - - # Status display - status_frame = tk.Frame(frame, bg='white') - status_frame.pack(fill='both', expand=True, pady=10) - - status_label = ttk.Label(status_frame, text="Cluster Status", style='Heading.TLabel') - status_label.pack(anchor='w', pady=(0, 10)) - - # Status text area - using TerminalConsole for consistency - # Use a background frame to ensure NO borders are visible around the console - console_bg = tk.Frame(status_frame, bg='white', highlightthickness=0, bd=0) - console_bg.pack(fill='both', expand=True, padx=1, pady=1) - self._validation_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) - self._validation_console.pack(fill='both', expand=True) - self.status_text = self._validation_console.text - - # Auto-refresh checkbox - refresh_frame = tk.Frame(frame, bg='white') - refresh_frame.pack(pady=10) - - self.auto_refresh_var = tk.BooleanVar(value=True) - refresh_check = tk.Checkbutton(refresh_frame, - text="Auto-refresh every 10 seconds", - variable=self.auto_refresh_var, - bg='white', - fg='#1d1d1f', - selectcolor='#f0f0f0', - activebackground='white', - activeforeground='#1d1d1f', - font=('Helvetica', 10), - command=self.toggle_auto_refresh) - refresh_check.pack(side='left', padx=10) - - # Manual refresh button - refresh_btn = tk.Button(refresh_frame, - text="Refresh Now", - command=self.refresh_status, - bg='#4a9eff', - fg='white', - activebackground='#3a8eef', - highlightbackground='white', - font=('Helvetica', 10), - padx=15, - pady=5, - cursor='hand2', - relief='flat') - refresh_btn.pack(side='left', padx=10) - - # Start auto-refresh - self.refresh_status() - self.toggle_auto_refresh() - - def toggle_auto_refresh(self): - """Toggle auto-refresh""" - if self.auto_refresh_var.get(): - if not self.validation_running: - self.validation_running = True - self.validation_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) - self.validation_thread.start() - else: - self.validation_running = False - - def auto_refresh_loop(self): - """Auto-refresh loop""" - while self.validation_running: - time.sleep(10) - if self.validation_running: - self.root.after(0, self.refresh_status) - - def refresh_status(self): - """Refresh the cluster status""" - def update(): - try: - full_status = "" - - # Get k3d cluster list - try: - cluster_result = subprocess.run(['k3d', 'cluster', 'list'], - capture_output=True, - text=True, - timeout=5) - full_status += f"=== k3d Cluster Status ===\n{cluster_result.stdout}\n\n" - except Exception as e: - full_status += f"=== k3d Cluster Status ===\nError: {str(e)}\n\n" - - # Get kubectl cnpg status - try: - namespace = (self.db_namespace.get() or 'default').strip() - cmd = self._kubectl_base_cmd() + ['cnpg', 'status', 'prole-db', '-n', namespace] - result = subprocess.run(cmd, - capture_output=True, - text=True, - timeout=10) - if result.returncode == 0: - status_output = result.stdout - else: - # Try default namespace if prole fails, or just show error - status_output = f"Error: {result.stderr}\n\nNote: Make sure kubectl cnpg plugin is installed and cluster exists in '{namespace}' namespace." - - full_status += f"=== CloudNativePG Status (namespace: {namespace}) ===\n{status_output}\n" - except FileNotFoundError: - full_status += "=== CloudNativePG Status ===\nError: kubectl not found. Please install dependencies first.\n" - except subprocess.TimeoutExpired: - full_status += "=== CloudNativePG Status ===\nError: Command timed out\n" - except Exception as e: - full_status += f"=== CloudNativePG Status ===\nError: {str(e)}\n" - - full_status += f"\nLast updated: {time.strftime('%Y-%m-%d %H:%M:%S')}" - - self.status_text.delete('1.0', tk.END) - self.status_text.insert('1.0', full_status) - - except Exception as e: - self.status_text.delete('1.0', tk.END) - self.status_text.insert('1.0', f"Error: {str(e)}") - - threading.Thread(target=update, daemon=True).start() - - -def has_display(): - """Check if a display is available for GUI.""" - # Check DISPLAY environment variable (X11) - if os.environ.get('DISPLAY'): - return True - - # On macOS, check if running in graphical session - if platform.system() == 'Darwin': - try: - # Try to create a Tk root to see if GUI is available - test_root = tk.Tk() - test_root.withdraw() - test_root.destroy() - return True - except Exception: - return False - - # On Linux/Unix, no DISPLAY means no GUI - return False - - -def main(): - import argparse - - # Parse command-line arguments - parser = argparse.ArgumentParser(description='Prole Database Installer') - parser.add_argument('-c', '--config', default=None, - help='Path to prole.cfg for saving or silent replay') - parser.add_argument('-S', '--silent', action='store_true', - help='Run unattended install in console mode using prole.cfg') - parser.add_argument('--prepare-k3s-pipeline', action='store_true', - help='Prepare OpenTofu k3s pipeline and run silent install test') - parser.add_argument('--reset', action='store_true', - help='Reset k3d dev cluster or k3s service cluster (myrddin) when running in k3d/k3s mode') - parser.add_argument('--no-gui', action='store_true', - help='Run installer with ncurses terminal interface instead of GUI') - parser.add_argument('--gui', action='store_true', - help='Force GUI mode (will fail if no display available)') - parser.add_argument('-v', '--verbose', action='store_true', - help='Enable verbose output') - parser.add_argument('-d', '--debug', action='store_true', - help='Enable debug logging to prole/logs') - args = parser.parse_args() - - # Set up logging early - from installer.config import setup_logging - setup_logging(verbose=args.verbose, debug=args.debug) - - # Create controller (shared business logic) - controller = ProleController(PROJECT_ROOT, verbose=args.verbose) - - if args.prepare_k3s_pipeline: - rc = _prepare_k3s_pipeline(controller) - sys.exit(rc) - - # Silent console mode (unattended) - if args.silent: - _ensure_ansible_vault_credentials(prompt_ui=False) - installer = ProleSilentInstaller(controller, args.config, reset_cluster=args.reset) - sys.exit(installer.run()) - - # Determine which interface to use - use_gui = False - if args.gui: - # Force GUI mode - use_gui = True - elif args.no_gui: - # Force ncurses mode - use_gui = False - else: - # Auto-detect: use GUI if display is available, otherwise ncurses - use_gui = has_display() - - if use_gui: - # Run Tk GUI interface - try: - root = tk.Tk() - root.title("Prole Database Installer") - # Center window similarly to legacy UI - window_width, window_height = 1000, 700 - try: - sw, sh = root.winfo_screenwidth(), root.winfo_screenheight() - cx, cy = int(sw / 2 - window_width / 2), int(sh / 2 - window_height / 2) - root.geometry(f"{window_width}x{window_height}+{cx}+{cy}") - except Exception: - root.geometry(f"{window_width}x{window_height}") - - _ensure_ansible_vault_credentials(prompt_ui=True, root=root) - ProleInstaller(root, config_path=args.config, reset_cluster=args.reset, verbose=args.verbose) - - # Launch the main installer window immediately with background and welcome page. - # The lightweight dependency verification runs on the welcome page and gates the footer there. - root.mainloop() - except Exception as e: - print(f"Failed to start GUI: {e}", file=sys.stderr) - print("Falling back to ncurses interface...", file=sys.stderr) - time.sleep(1) - from installer.ncurses_installer import run_ncurses_installer - run_ncurses_installer(controller) - else: - _ensure_ansible_vault_credentials(prompt_ui=False) - # Run ncurses interface - from installer.ncurses_installer import run_ncurses_installer - run_ncurses_installer(controller) - - -if __name__ == '__main__': - main() diff --git a/installer/ui/screens/__init__.py b/installer/ui/screens/__init__.py new file mode 100644 index 0000000..b7786af --- /dev/null +++ b/installer/ui/screens/__init__.py @@ -0,0 +1,638 @@ +""" +Prole Service Dependencies Installer — screen package. + +This package splits the monolithic ``ProleInstaller`` class into cohesive +mixin modules, one per logical screen / concern. The composed class is +assembled here and re-exported so that external callers (``install.py``, +tests) see the same public surface as before. +""" +from __future__ import annotations + +import configparser +import platform +import signal +import socket +import threading +import tkinter as tk +import uuid +from tkinter import ttk, messagebox, filedialog + +from installer import deploy as inst_deploy +from installer import screen as ui +from installer.build import get_build_command as inst_get_build_command +from installer.config import ( + _expand_cfg_value, _collect_cfg_vars, _is_prole_secret, + _decrypt_prole_secret, _is_openbao_ref, _write_k3s_kubeconfig, + _encrypt_cfg_secret, _encrypt_prole_secret, +) +from installer.core.controller import ProleController +from installer.core.env import ( + _detect_ansible_topology, _format_ansible_topology_summary, + _parse_ollama_host, _normalize_k3s_token, _deployment_target_label, + _cluster_env_radio_value, _find_kubeconfig_file, _bool_str, + _looks_like_k8s_bearer_token, _deployment_mode_from_env, + _pf_extract_id, _ensure_ansible_vault_credentials, + _openbao_placeholder, _pf_upsert_mapping, _format_ollama_host, + _default_opentofu_pipeline_url, _build_required_port_forwards, + _render_prole_cfg, _normalize_cluster_env, + _k3d_prole_data_volume_args, _push_docker_image, + _http_ping_registry, _local_registry_enabled, + _collect_images_from_files, +) + +# On macOS, set process name early so the menu bar reads correctly. +if platform.system() == 'Darwin': + try: + from Foundation import NSProcessInfo + NSProcessInfo.processInfo().setProcessName_("Prole Database Installer") + except Exception: + pass + +from installer.core.env import * # noqa: F401,F403 +from installer.core.actions import ( + ProleInstallerBase, ProleSilentInstaller, + _prepare_k3s_pipeline, _reset_k3s_namespace, +) + +# Keep global frames list in memory (legacy requirement). +GLOBAL_SCAN_FRAMES: list = [] + +# --------------------------------------------------------------------------- +# Mixin imports — one per screen / concern +# --------------------------------------------------------------------------- +from installer.ui.screens.base import ScreenBaseMixin +from installer.ui.screens.navigation import NavigationMixin +from installer.ui.screens.welcome import WelcomeScreenMixin +from installer.ui.screens.dependencies import DependenciesScreenMixin +from installer.ui.screens.network import NetworkScreenMixin +from installer.ui.screens.environment import EnvironmentScreenMixin +from installer.ui.screens.database import DatabaseScreenMixin +from installer.ui.screens.cluster import ClusterScreenMixin +from installer.ui.screens.services import ServicesScreenMixin +from installer.ui.screens.security import SecurityScreenMixin +from installer.ui.screens.ollama import OllamaScreenMixin +from installer.ui.screens.supabase import SupabaseScreenMixin +from installer.ui.screens.docker import DockerScreenMixin +from installer.ui.screens.build import BuildScreenMixin +from installer.ui.screens.packaging import PackagingScreenMixin +from installer.ui.screens.deploy import DeployScreenMixin +from installer.ui.screens.validate import ValidateScreenMixin +from installer.ui.screens.cfg import ConfigMixin + + +# --------------------------------------------------------------------------- +# Composed installer class +# --------------------------------------------------------------------------- +class ProleInstaller( + ProleInstallerBase, + ScreenBaseMixin, + NavigationMixin, + WelcomeScreenMixin, + DependenciesScreenMixin, + NetworkScreenMixin, + EnvironmentScreenMixin, + DatabaseScreenMixin, + ClusterScreenMixin, + ServicesScreenMixin, + SecurityScreenMixin, + OllamaScreenMixin, + SupabaseScreenMixin, + DockerScreenMixin, + BuildScreenMixin, + PackagingScreenMixin, + DeployScreenMixin, + ValidateScreenMixin, + ConfigMixin, +): + """Prole GUI installer — assembled from per-screen mixins.""" + + KUBECTL_STATUS_TAB = "__kubectl_status__" + + # ---- Data access bridge (satisfies ProleInstallerBase contract) ---- + _INPUT_VAR_MAP: dict[str, str] = { + 'init_cluster.cluster_env': 'cluster_env', + 'init_password.db_namespace': 'db_namespace', + 'init_password.db_username': 'db_username', + 'init_password.db_password': 'db_password', + 'init_password.db_host_port': 'db_host_port', + 'env_setup.NAMESPACE': 'db_namespace', + 'kerberos_config.enabled': 'kerberos_enabled', + 'kerberos_config.realm': 'kerberos_realm', + 'kerberos_config.kdc': 'kerberos_kdc', + 'kerberos_config.user': 'kerberos_user', + 'kerberos_config.password': 'kerberos_password', + 'init_cluster.kerberos_enabled': 'kerberos_enabled', + 'init_cluster.supabase_enabled': 'supabase_enabled', + 'init_cluster.at_rest_encryption_enabled': 'at_rest_encryption_enabled', + 'init_cluster.k3s_server_url': 'k3s_server_url', + 'init_cluster.k3s_token': 'k3s_token', + 'ollama_config.server_host': 'ollama_server_host', + 'ollama_config.server_port': 'ollama_server_port', + 'ollama_config.model': 'ollama_model', + } + + def _get_input(self, key: str, default: str | None = None) -> str: + """Bridge for :class:`ProleInstallerBase` — reads tk vars by key.""" + if key.startswith('env_setup.') and hasattr(self, '_env_entries'): + env_key = key[len('env_setup.'):] + ent = self._env_entries.get(env_key) + if ent is not None: + try: + val = ent.get().strip() + if val: + return val + except Exception: + pass + attr = self._INPUT_VAR_MAP.get(key) + if attr: + var = getattr(self, attr, None) + if var is not None: + try: + val = var.get() + if isinstance(val, bool): + return 'true' if val else 'false' + if val not in (None, ''): + return str(val) + except Exception: + pass + return default if default is not None else '' + + # ------------------------------------------------------------------ init + def __init__(self, root, config_path: str | None = None, reset_cluster: bool = False, verbose: bool = False): + self.controller = ProleController(PROJECT_ROOT, verbose=verbose) + self.project_root = PROJECT_ROOT + self._init_shared_state() + self.screens = None + self.deploy_environment = None + self.root = root + self._cfg_path_override = None + if config_path: + try: + self._cfg_path_override = Path(config_path).expanduser() + except Exception: + self._cfg_path_override = None + self._installing_dep_id = None + self._managed_kubeconfig = None + self._kong_last_repair_at = 0.0 + self._kong_repair_cooldown_s = 120 + self._repair_last_run_at = 0.0 + self._repair_cooldown_s = 600 + self._repair_inflight = False + self._reset_cluster = bool(reset_cluster) + _ensure_ansible_vault_credentials(prompt_ui=True, root=root) + self._action_flags = {} + self.root.title("Prole Database Installer") + try: + self._set_app_identity() + except Exception: + pass + + # Center window on screen + window_width = 1300 + window_height = 910 + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() + center_x = int(screen_width / 2 - window_width / 2) + center_y = int(screen_height / 2 - window_height / 2) + self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}") + self.root.resizable(False, False) + + # Force light mode colors + self.root.configure(bg='white') + + # Style configuration + self.style = ttk.Style() + self.configure_styles() + + # Main container with two columns + self.main_container = tk.Frame(root, bg='white') + self.main_container.pack(fill='both', expand=True) + + # Left Sidebar + self.sidebar = tk.Frame(self.main_container, bg='#F5F5DC', width=325) + self.sidebar.pack(side='left', fill='y') + self.sidebar.pack_propagate(False) + + # Vertical Divider Line + self.divider = tk.Frame(self.main_container, bg='#CCCCCC', width=1, highlightthickness=0, bd=0) + self.divider.pack(side='left', fill='y') + + # Right Content Area + self.content_area = tk.Frame(self.main_container, bg='white') + self.content_area.pack(side='left', fill='both', expand=True) + + # Footer for Next/Prev buttons + btns = ui.create_nav_footer( + self.content_area, + buttons=[(1, 'Previous'), (2, 'Next'), (3, 'Deploy'), (4, 'Launch')], + commands={1: self.on_prev, 2: self.on_next, 3: self.on_deploy, 4: self.on_launch}, + style_name='Nav.TButton', + gear_command=self.show_config_dialog + ) + self.footer = btns.get('_footer') + self.prev_button = btns.get(1) + self.next_button = btns.get(2) + self.deploy_button = btns.get(3) + self.launch_button = btns.get(4) + self.gear_button = btns.get('gear') + + # Background canvas + self._bg_pil = None + self._bg_tk = None + self.bg_canvas = tk.Canvas(self.content_area, highlightthickness=0, bd=0, bg='white') + self.bg_canvas.pack(fill='both', expand=True) + self._bg_item = None + + # Slide area overlay + self.slide_area = tk.Frame(self.bg_canvas, bg='white') + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lower() + + try: + from PIL import Image, ImageTk + bg_path = get_resource_path('img/proleLogoSepia.png') + if bg_path.exists(): + original = Image.open(str(bg_path)).convert('RGBA') + white_bg = Image.new('RGBA', original.size, (255, 255, 255, 255)) + self._bg_pil = Image.blend(white_bg, original, 0.15) + + def _render_bg(event=None): + if not self._bg_pil: + return + cw = max(1, self.bg_canvas.winfo_width()) + ch = max(1, self.bg_canvas.winfo_height()) + iw, ih = self._bg_pil.size + scale = max(cw / iw, ch / ih) + nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale)) + img = self._bg_pil.resize((nw, nh), Image.LANCZOS) + self._bg_tk = ImageTk.PhotoImage(img) + if self._bg_item is not None: + self.bg_canvas.delete(self._bg_item) + self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, image=self._bg_tk, anchor='center') + self.bg_canvas.tag_lower(self._bg_item) + + self.bg_canvas.bind('', _render_bg) + except Exception as e: + print(f"Error loading background: {e}") + + # Navigation menu items + self.nav_items = [ + ("Welcome", "welcome"), + ("Dependencies", "deps_summary"), + ("Network", "network_scan"), + ("System Environment", "env_setup"), + ("Cluster Environment", "init_cluster"), + ("Database Creation", "init_password"), + ("Docker Build", "init_db_build"), + ("Kerberos Authentication", "kerberos_config"), + ("Initialization Scripts", "init_scripts"), + ("Ollama", "ollama_config"), + ("Supabase", "supabase_config"), + ("Deployment", "init_cnpg_deploy"), + ("Post Install", "create_installer") + ] + self.nav_widgets = {} + self._create_sidebar_nav() + + # Validation attributes + self.validation_running = False + self.validation_thread = None + self._welcome_scan_started = False + self.splash_scan_running = False + self.splash_scan_done_at = None + self.splash_scan_started_at = None + self._splash_status_item = None + try: + self.expected_host = (platform.node() or socket.gethostname()).split('.')[0] + except Exception: + self.expected_host = None + + # Canvas page state + self._canvas_page = None + self._canvas_items = [] + self.canvas_renderers = {} + self._overlay_widgets = [] + self._cursor_blink_after_id = None + self._cursor_blink_visible = False + self._k3s_status_after_id = None + self._k3s_status_active = False + self._k3s_status_inflight = False + self._k3s_service_status_console = None + self._k3s_service_deploy_console = None + self._k3s_service_notebook = None + self._k3s_service_status_tab = None + self._k3s_service_deploy_tab = None + self._common_services_success = False + self._showing_service_overlay = False + self._last_registry_log_path = None + self._last_common_services_log_path = None + + # Shared dependency catalog + self.dependencies = list(inst_config.DEPENDENCIES) + + # Docker import directory + self.docker_import_dir = tk.StringVar() + + # Initialize prole.cfg data (override base with tk-aware version) + 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 = {} + self._secrets_finalized = False + self.service_namespace = tk.StringVar(value='default') + + # Load existing docker_import_dir from prole.cfg + try: + conf_dir = self._resolve_prole_conf_dir() + cfg_path = conf_dir / 'prole.cfg' + if self._cfg_path_override: + cfg_path = self._cfg_path_override + if cfg_path.exists(): + cfg = configparser.ConfigParser(interpolation=None) + cfg.optionxform = str + cfg.read(cfg_path) + cfg_vars = _collect_cfg_vars(cfg) + if cfg.has_section('Global'): + if 'DOCKER_IMPORT_DIR' in cfg['Global']: + docker_dir = _expand_cfg_value(cfg['Global']['DOCKER_IMPORT_DIR'], cfg_vars) + self.docker_import_dir.set(docker_dir) + self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = docker_dir + saved_cluster = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', ''), cfg_vars).strip() + saved_ns = _expand_cfg_value(cfg['Global'].get('NAMESPACE', ''), cfg_vars).strip() + saved_service_ns = _expand_cfg_value(cfg['Global'].get('SERVICE_NAMESPACE', ''), cfg_vars).strip() + if saved_cluster: + try: + self.cluster_env.set(saved_cluster) + except Exception: + pass + if saved_ns: + try: + self.db_namespace.set(saved_ns) + except Exception: + pass + try: + self.prole_cfg_data.setdefault('Global', {})['NAMESPACE'] = saved_ns + except Exception: + pass + if saved_service_ns: + try: + self.service_namespace.set(saved_service_ns) + except Exception: + pass + self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = saved_service_ns + except Exception: + pass + self.prole_cfg_data['Global'].setdefault('SERVICE_NAMESPACE', self.service_namespace.get()) + + # Disk selection variables + self.selected_disk_type = tk.StringVar(value='local') + self.selected_removable_disk = tk.StringVar() + self.selected_local_path = tk.StringVar(value=str(Path.home())) + self.removable_disks = [] + + # Wizard pages setup + self.pages = [] + self.page_index = 0 + + # Cluster / k3d variables + self.cluster_env = tk.StringVar(value='dev') + self.k3d_cluster_name = tk.StringVar(value='prole-dev-cluster') + self.k3d_cluster_list = tk.Variable(value=[]) + self.selected_k3d_cluster = tk.StringVar() + try: + cfg_env = (self.prole_cfg_data.get('Initialize Cluster', {}) or {}).get('ENVIRONMENT', '') + if _normalize_cluster_env(cfg_env) == 'dev': + self.selected_k3d_cluster.set(cfg_env) + except Exception: + pass + + self.deploy_target = tk.StringVar(value=_deployment_target_label(self.cluster_env.get())) + self._syncing_deploy_target = False + self._deploy_target_trace = self.deploy_target.trace_add('write', self._on_deploy_target_change) + self.kubectx_list = tk.Variable(value=self._get_kubectx_list()) + self.selected_kubectx = tk.StringVar() + self.prod_artifacts_path = tk.StringVar(value=str(PROJECT_ROOT / "data" / "staging")) + self.k3s_server_url = tk.StringVar() + self.k3s_token = tk.StringVar() + self.k3s_services_status = { + "registry": tk.StringVar(value="Unknown"), + "openbao": tk.StringVar(value="Unknown"), + "opentofu": tk.StringVar(value="Unknown"), + } + try: + self.db_username = tk.StringVar(value=os.getlogin()) + except Exception: + self.db_username = tk.StringVar(value="prole") + self.db_password = tk.StringVar() + self.db_password_confirm = tk.StringVar() + self.namespace_owner = self._get_local_owner() + self.db_namespace = tk.StringVar(value=self._initial_namespace()) + try: + _ns_from_cfg = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip() + if _ns_from_cfg: + self.db_namespace.set(self._ensure_namespace_prefix(_ns_from_cfg)) + except Exception: + pass + self.db_host_port = tk.StringVar(value='5432') + self.db_namespace_suffix = tk.StringVar() + self._updating_namespace_fields = False + self._sync_namespace_suffix_from_full() + self.db_namespace.trace_add('write', lambda *args: self._sync_namespace_suffix_from_full()) + self.db_namespace_suffix.trace_add('write', lambda *args: self._sync_namespace_full_from_suffix()) + + # Kerberos / optional features + self.kerberos_enabled = tk.BooleanVar(value=False) + self.kerberos_realm = tk.StringVar() + self.kerberos_user = tk.StringVar() + self.kerberos_password = tk.StringVar() + self.kerberos_kdc = tk.StringVar() + self.supabase_enabled = tk.BooleanVar(value=False) + self.at_rest_encryption_enabled = tk.BooleanVar(value=True) + self.ollama_server_host = tk.StringVar() + self.ollama_server_port = tk.StringVar(value=DEFAULT_OLLAMA_PORT) + self.ollama_model = tk.StringVar() + self._ollama_servers_cache = [] + self._ollama_model_options = {} + self.ansible_topology = {} + self.ansible_topology_summary = '' + try: + self._apply_ansible_topology_defaults() + except Exception: + pass + + # Load encrypted secrets, apply cfg defaults + try: + self._load_secret_cache_from_cfg() + except Exception: + pass + try: + self._apply_cluster_env_default_from_cfg() + except Exception: + pass + try: + self._apply_k3s_defaults() + except Exception: + pass + try: + self._apply_ollama_defaults() + except Exception: + pass + + # Create pages + self.page_frames = {} + self.verify_mode = tk.BooleanVar(value=False) + + # Register canvas renderers + self._register_canvas_renderer('welcome', self._render_welcome_page) + self._register_canvas_renderer('network_scan', self._render_network_scan_page) + self._register_canvas_renderer('env_setup', self._render_env_setup_page) + self._register_canvas_renderer('deps_summary', self._render_deps_summary_page) + for dep in self.dependencies: + self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d)) + self._register_canvas_renderer('kerberos_config', self._render_kerberos_config_page) + self._register_canvas_renderer('ollama_config', self._render_ollama_config_page) + self._register_canvas_renderer('supabase_config', self._render_supabase_config_page) + self._register_canvas_renderer('init_cluster', self._render_init_cluster_page) + self._register_canvas_renderer('init_db_build', self._render_init_db_build_page) + self._register_canvas_renderer('init_password', self._render_init_password_page) + self._register_canvas_renderer('init_cnpg_deploy', self._render_init_cnpg_deploy_page) + self._register_canvas_renderer('init_scripts', self._render_init_scripts_page) + self._register_canvas_renderer('build', self._render_build_page) + self._register_canvas_renderer('disk_selection', self._render_disk_selection_page) + self._register_canvas_renderer('build_summary', self._render_build_summary_page) + self._register_canvas_renderer('create_installer', self._render_create_installer_page) + + # Page ordering for navigation + self._register_page('welcome', None) + self._register_page('deps_summary', None) + for dep in self.dependencies: + self._register_page(f"dep_{dep['id']}", None) + self._register_page('network_scan', None) + self._register_page('env_setup', None) + self._register_page('init_cluster', None) + self._register_page('init_password', None) + self._register_page('init_db_build', None) + self._register_page('kerberos_config', None) + self._register_page('init_scripts', None) + self._register_page('ollama_config', None) + self._register_page('supabase_config', None) + self._register_page('init_cnpg_deploy', None) + self._register_page('build', None) + self._register_page('disk_selection', None) + self._register_page('build_summary', None) + self._register_page('create_installer', None) + + # Initial render + self.root.after(100, lambda: self.selected_local_path.set(str(self._get_prole_dist_dir()))) + self.update_footer() + self.show_page(0) + + +# --------------------------------------------------------------------------- +# Module-level helpers (kept for backward compatibility) +# --------------------------------------------------------------------------- + +def has_display(): + """Check if a display is available for GUI.""" + if os.environ.get('DISPLAY'): + return True + if platform.system() == 'Darwin': + try: + test_root = tk.Tk() + test_root.withdraw() + test_root.destroy() + return True + except Exception: + return False + return False + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description='Prole Database Installer') + parser.add_argument('-c', '--config', default=None, + help='Path to prole.cfg for saving or silent replay') + parser.add_argument('-S', '--silent', action='store_true', + help='Run unattended install in console mode using prole.cfg') + parser.add_argument('--prepare-k3s-pipeline', action='store_true', + help='Prepare OpenTofu k3s pipeline and run silent install test') + parser.add_argument('--reset', action='store_true', + help='Reset k3d dev cluster or k3s service cluster') + parser.add_argument('--no-gui', action='store_true', + help='Run installer with ncurses terminal interface') + parser.add_argument('--gui', action='store_true', + help='Force GUI mode') + parser.add_argument('-v', '--verbose', action='store_true', + help='Enable verbose output') + parser.add_argument('-d', '--debug', action='store_true', + help='Enable debug logging') + args = parser.parse_args() + + from installer.config import setup_logging + setup_logging(verbose=args.verbose, debug=args.debug) + + controller = ProleController(PROJECT_ROOT, verbose=args.verbose) + + if args.prepare_k3s_pipeline: + rc = _prepare_k3s_pipeline(controller) + sys.exit(rc) + + if args.silent: + _ensure_ansible_vault_credentials(prompt_ui=False) + installer = ProleSilentInstaller(controller, args.config, reset_cluster=args.reset) + sys.exit(installer.run()) + + use_gui = False + if args.gui: + use_gui = True + elif args.no_gui: + use_gui = False + else: + use_gui = has_display() + + if use_gui: + try: + root = tk.Tk() + root.title("Prole Database Installer") + window_width, window_height = 1000, 700 + try: + sw, sh = root.winfo_screenwidth(), root.winfo_screenheight() + cx, cy = int(sw / 2 - window_width / 2), int(sh / 2 - window_height / 2) + root.geometry(f"{window_width}x{window_height}+{cx}+{cy}") + except Exception: + root.geometry(f"{window_width}x{window_height}") + _ensure_ansible_vault_credentials(prompt_ui=True, root=root) + ProleInstaller(root, config_path=args.config, reset_cluster=args.reset, verbose=args.verbose) + root.mainloop() + except Exception as e: + print(f"Failed to start GUI: {e}", file=sys.stderr) + print("Falling back to ncurses interface...", file=sys.stderr) + time.sleep(1) + from installer.ncurses_installer import run_ncurses_installer + run_ncurses_installer(controller) + else: + _ensure_ansible_vault_credentials(prompt_ui=False) + from installer.ncurses_installer import run_ncurses_installer + run_ncurses_installer(controller) + + +if __name__ == '__main__': + main() diff --git a/installer/ui/screens/base.py b/installer/ui/screens/base.py new file mode 100644 index 0000000..1685d4d --- /dev/null +++ b/installer/ui/screens/base.py @@ -0,0 +1,225 @@ +"""Common UI rendering helpers shared across all screens.""" + +import os +import subprocess +from datetime import datetime +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer.screen import TerminalConsole +from installer import screen as ui + + +class ScreenBaseMixin: + """Common UI rendering helpers shared across all screens.""" + + def _render_title(self, text, y=40): + ui.render_title(self, text, y) + + def _render_paragraph(self, text, y, wrap=600): + ui.render_paragraph(self, text, y, wrap) + + def _create_console_output(self, y=280, title="Scan Output", width=880, height=450): + """Create a standardized console output area with a label and scrollable text.""" + # Section title/label for the console + ui.canvas_text(self, 48, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) + + y_console = y + 30 + # Use a background frame to ensure NO borders are visible around the console + console_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) + console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) + console.pack(fill='both', expand=True, padx=1, pady=1) + + console_window = self.bg_canvas.create_window(48, y_console, window=console_bg, + anchor='nw', width=width, height=height) + self._canvas_items.append(console_window) + self._overlay_widgets.append(console_bg) + self._overlay_widgets.append(console) + return console + + def safe_after(self, func, delay=0): + """Run a function in the main thread if the root window still exists.""" + if not self.root or not self.root.winfo_exists(): + return + + def wrapper(): + try: + if self.root and self.root.winfo_exists(): + func() + except (tk.TclError, RuntimeError): + pass + try: + self.root.after(delay, wrapper) + except (tk.TclError, RuntimeError): + pass + + def _clear_canvas_page(self): + # Unbind common events to prevent "echo" or persistent behavior from previous pages + try: + self.bg_canvas.unbind('') + self.root.unbind('') + self.root.unbind('') + except Exception: + pass + + try: + self._stop_k3s_service_status_updates() + except Exception: + pass + + # Reset slide_area background to white for safety + try: + self.slide_area.configure(bg='white') + except Exception: + pass + + # Remove any small overlay widgets from the previous page + try: + if getattr(self, '_overlay_widgets', None): + for w in list(self._overlay_widgets): + try: + w.destroy() + except Exception: + try: + w.place_forget() + except Exception: + pass + self._overlay_widgets.clear() + except Exception: + pass + + # Clear page-specific canvas drawings (keep the background image) + if self._canvas_items: + for item in self._canvas_items: + try: + self.bg_canvas.delete(item) + except Exception: + pass + self._canvas_items.clear() + + # ---------------- Environment setup helpers ---------------- + def _register_canvas_renderer(self, page_id, func): + self.canvas_renderers[page_id] = func + + def _register_page(self, page_id, frame): + self.pages.append((page_id, frame)) + self.page_frames[page_id] = frame + + def show_page(self, index_or_id): + self._showing_service_overlay = False + # Resolve index and page_id + old_idx = self.page_index + if isinstance(index_or_id, int): + idx = max(0, min(index_or_id, len(self.pages) - 1)) + else: + # Find index by page_id + idx = -1 + for i, (pid, _) in enumerate(self.pages): + if pid == index_or_id: + idx = i + break + + if idx == -1: + msg = f"Navigation error: page ID '{index_or_id}' not found." + print(f"[ERROR] {msg}") + try: + messagebox.showerror("Navigation Error", msg) + except Exception: + pass + return + + print(f"[DEBUG] Navigating from {old_idx} to {idx} (requested: {index_or_id})") + self.page_index = idx + pid, frame = self.pages[self.page_index] + + # Clear any previously drawn canvas content + self._clear_canvas_page() + self._update_nav_highlight(pid) + + # If the page has overlay widgets, we need to show the slide_area. + # Otherwise, we hide it so the canvas content is visible. + # Pages that use canvas drawing and need to show the background should have slide_area hidden. + canvas_only_pages = ( + 'welcome', 'network_scan', 'env_setup', 'kerberos_config', + 'deps_summary', 'init_cluster', 'init_password', 'init_db_build', + 'init_cnpg_deploy', 'init_scripts', 'build', 'disk_selection', 'build_summary', + 'supabase_config', 'ollama_config', 'create_installer' + ) + if pid in canvas_only_pages or pid.startswith('dep_'): + self.slide_area.place_forget() + else: + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lift() + + # Render the page directly on the canvas if we have a renderer + if pid in self.canvas_renderers: + try: + self.canvas_renderers[pid]() + except Exception as e: + # Fallback: show an error message on canvas + ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='black', font=('SF Pro Text', 11)) + elif frame is not None: + # Legacy fallback (should not be used) + frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9) + self.update_footer() + + # ---------------- Canvas page rendering ---------------- + def show_screen(self, screen_id): + self.show_page(screen_id) + + def _run_cmd_capture(self, cmd, env=None, stdin_text=None, timeout=None): + output_lines = [] + try: + # Ensure PYTHONUNBUFFERED=1 + run_env = (env or os.environ).copy() + run_env['PYTHONUNBUFFERED'] = '1' + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE if stdin_text is not None else None, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=run_env + ) + except Exception as e: + return 1, f"Failed to start command: {e}" + + if stdin_text is not None and proc.stdin: + try: + proc.stdin.write(stdin_text) + proc.stdin.close() + except Exception: + pass + + try: + for line in proc.stdout: + output_lines.append(line) + except Exception: + pass + try: + rc = proc.wait(timeout=timeout) + except Exception: + try: + proc.kill() + except Exception: + pass + rc = 1 + return rc, "".join(output_lines) + + def _parse_rfc3339(self, ts: str): + if not ts: + return None + try: + if ts.endswith('Z'): + ts = ts[:-1] + '+00:00' + return datetime.fromisoformat(ts) + except Exception: + return None + + def _format_pod_time(self, ts: str) -> str: + dt = self._parse_rfc3339(ts) + if not dt: + return ts or '—' + try: + return dt.astimezone().strftime('%Y-%m-%d %H:%M:%S') + except Exception: + return dt.strftime('%Y-%m-%d %H:%M:%S') diff --git a/installer/ui/screens/build.py b/installer/ui/screens/build.py new file mode 100644 index 0000000..5ca0eb9 --- /dev/null +++ b/installer/ui/screens/build.py @@ -0,0 +1,726 @@ +"""Build screen, terminal console overlay and process management.""" + +import os +import platform +import re +import shlex +import signal +import subprocess +import threading +import time +import webbrowser +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer.screen import TerminalConsole +from installer import deploy as inst_deploy +from installer.build import get_build_command as inst_get_build_command +from installer import screen as ui +from installer.core.env import PROJECT_ROOT + + +class BuildScreenMixin: + """Build screen, terminal console overlay and process management.""" + + def _render_build_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + # Shifted up to accommodate radios and standard console position + self._render_title('Build Prole Tools.app', y=80) + self._render_paragraph('Build and prepare Prole Tools.app for deployment.', y=130) + + # Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes) + if not hasattr(self, 'deploy_env_value'): + self.deploy_env_value = 'Dev' + + radio_y = 180 + left = 56 + spacing = 150 + + # Draw three radio options + self._build_radio_items = [] + options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)] + for label, x in options: + # outer circle + r = 10 + circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='black', width=2) + self._canvas_items.append(circle) + # selected dot + if self.deploy_env_value == label: + dot = ui.canvas_oval(self, x+5, radio_y+5, x+2*r-5, radio_y+2*r-5, fill='black', outline='') + self._canvas_items.append(dot) + text = ui.canvas_text(self, x + 2*r + 10, radio_y - 2, label, fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(text) + self._build_radio_items.append((label, circle, text)) + + # Click handling for radio selection + def _on_click(event): + ex, ey = event.x, event.y + for label, circle, text in self._build_radio_items: + bbox_c = self.bg_canvas.bbox(circle) + bbox_t = self.bg_canvas.bbox(text) + hit = False + if bbox_c and bbox_c[0] <= ex <= bbox_c[2] and bbox_c[1] <= ey <= bbox_c[3]: + hit = True + if bbox_t and bbox_t[0] <= ex <= bbox_t[2] and bbox_t[1] <= ey <= bbox_t[3]: + hit = True + if hit: + self.deploy_env_value = label + # Re-render only radios by re-drawing the page + self._clear_canvas_page() + self._render_build_page() + self.update_footer() + break + self.bg_canvas.bind('', _on_click) + + # Output Console (Consistent size and location: y=260, width=900, height=520) + self._build_console = self._create_console_output(y=230, title="Build Output", width=900, height=550) + self._console_text = self._build_console.text # For compatibility with _append_console and others + + # Show a command preview with PS1-style prompt and blinking cursor + preview = self._compose_build_preview() + self._console_set_preview(preview) + + # Ensure state holders exist + if not hasattr(self, 'last_build_log_path'): + self.last_build_log_path = None + # Update Next button label/state + self.update_footer() + # Bind Enter to trigger Build on this page + def _enter_build(_evt=None): + self.perform_build() + try: + self.root.bind('', _enter_build) + self.root.bind('', _enter_build) + except Exception: + pass + + + def _create_page_build(self): + f = self._page_container() + ttk.Label(f, text='Build', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) + ttk.Label(f, text='Choose a target and build the artifacts.', style='Body.TLabel').pack(anchor='w', padx=24) + wrap = ttk.Frame(f) + wrap.pack(anchor='w', padx=24, pady=12) + ttk.Label(wrap, text='Target Environment:', style='Body.TLabel').pack(side='left') + self.deploy_environment = tk.StringVar(value='Dev') + ttk.Combobox(wrap, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10) + + # Build output console + # Use a background frame to ensure NO borders are visible around the console + console_bg = tk.Frame(f, bg='white', highlightthickness=0, bd=0) + console_bg.pack(fill='both', expand=True, padx=24, pady=12) + self.build_output_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) + self.build_output_console.pack(fill='both', expand=True, padx=1, pady=1) + self.build_output = self.build_output_console.text + + # self._register_page('build', f) + + def perform_build(self): + """Run Prole build in an embedded console (Scoped bash subprocess).""" + self._action_flags['build.run_build'] = True + # Ensure environment exists and is readable before attempting build + try: + self.ensure_prole_env() + except Exception as e: + # Print a readable error to the console area and abort + try: + self._console_press_enter() + except Exception: + pass + err_msg = str(e).replace("'", "'\\''") + err = f"echo 'ERROR: {err_msg}' && exit 1" + self._run_in_console(err, None, on_complete=lambda rc: None) + return + # Prepare logs dir and file + try: + self._build_attempted = True + except Exception: + pass + # reset success flag until proven otherwise + try: + self._built_success = False + except Exception: + pass + logs_dir = self._resolve_prole_logs_dir() + try: + logs_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + ts = time.strftime('%Y%m%d-%H%M%S') + log_path = logs_dir / f'build-{ts}.log' + self.last_build_log_path = str(log_path) + self._record_install_log(log_path) + + # Compose build command + env = getattr(self, 'deploy_env_value', 'Dev') + base_cmd = self.get_build_command(env) + # Hostname safety guard: ensure commands run only on the machine that launched the installer + guard = '' + if getattr(self, 'expected_host', None): + eh = self.expected_host + guard = f'host=$(hostname -s); if [ "$host" != "{eh}" ]; then echo "ERROR: wrong host $host (expected {eh})"; exit 1; fi; ' + # Simulate pressing Enter on the previewed command; do not echo a duplicate command + self._console_press_enter() + # Compose a verbose wrapped build command with environment diagnostics and tracing + full_cmd = guard + self._compose_verbose_build_command(base_cmd) + + # Run in embedded console + self._run_in_console(full_cmd, self.last_build_log_path, on_complete=lambda rc: self._on_build_complete(rc)) + + def _compose_verbose_build_command(self, base_cmd: str) -> str: + """Wrap the provided build command with a verbose, diagnostic-rich shell script. + + Adds: + - Timestamps and section headers + - Platform/OS/tooling info (uname, macOS version, Xcode/Swift, Java, Maven, Docker, Git) + - set -euxo pipefail for tracing and early failure + - Echo of the actual build command + """ + # Use portable bash; guard external tool probes to avoid hard failures + prologue = r''' +echo "====[PROLE] Build started $(date '+%Y-%m-%d %H:%M:%S %Z')"; +echo "---- System -------------------------------------------------------"; +uname -a || true; +printf "ARCH=%s\n" "$(uname -m)" || true; +if command -v sw_vers >/dev/null 2>&1; then sw_vers || true; fi; +echo "---- Tooling ------------------------------------------------------"; +if command -v xcodebuild >/dev/null 2>&1; then xcodebuild -version || true; else echo "xcodebuild: not found"; fi; +if command -v swift >/dev/null 2>&1; then swift --version || true; else echo "swift: not found"; fi; +if command -v java >/dev/null 2>&1; then java -version 2>&1 | sed 's/^/java: /'; else echo "java: not found"; fi; +if command -v mvn >/dev/null 2>&1; then mvn -v || true; else echo "mvn: not found"; fi; +if command -v docker >/dev/null 2>&1; then docker version || true; else echo "docker: not found"; fi; +echo "---- Git ----------------------------------------------------------"; +if command -v git >/dev/null 2>&1; then \ + (git -C "$(pwd)" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \ + echo "repo: $(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")" && \ + echo "branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null)" && \ + echo "commit: $(git rev-parse --short HEAD 2>/dev/null)" && \ + git status --porcelain=v1 | sed 's/^/ /' || true) || echo "not a git repo"; \ +else echo "git: not found"; fi; +echo "-------------------------------------------------------------------"; +''' + + # The actual build with tracing and timing + wrapped = f""" +( + set -euxo pipefail + {prologue} + echo "====[PROLE] Executing build command:"; + printf '%s\n' {shlex.quote(base_cmd)}; + echo "-------------------------------------------------------------------"; + start_ts=$(date +%s || echo 0); + {base_cmd} + rc=$? + end_ts=$(date +%s || echo 0); + dur=$((end_ts - start_ts)); + echo "-------------------------------------------------------------------"; + if [ $rc -eq 0 ]; then + echo "====[PROLE] Build finished OK in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; + else + echo "====[PROLE] Build FAILED (rc=$rc) in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; + fi + exit $rc +) +""" + return wrapped + + def _on_build_complete(self, returncode: int): + # After build completes, update state and check for removable disks + try: + self._built_success = (returncode == 0) + except Exception: + self._built_success = False + # Re-enable Next button + try: + self.next_button.configure(state='normal') + except Exception: + pass + # Navigate to disk_selection if removable disks exist, otherwise build_summary + if getattr(self, '_built_success', False): + disks = self.get_removable_disks() + if disks: + self.show_page('disk_selection') + else: + self.show_page('build_summary') + else: + # Build failed, stay on build page + pass + + # ---------------- Embedded console helpers ---------------- + def _ensure_console_overlay(self, radio_bottom_y: int = 160): + """Create semi-transparent backdrop and a ScrolledText console overlay. + The overlay is placed within slide_area between given top and bottom margins. + """ + # Ensure slide_area is visible + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lift() + + # Compute geometry within slide area + geom = self._compute_console_geometry(radio_bottom_y) + left, top, width, height = geom + + # Use a background frame to ensure NO borders are visible around the console + console_bg = tk.Frame(self.slide_area, bg='white', highlightthickness=0, bd=0) + console_bg.place(x=left, y=top, width=width, height=height) + + # Create TerminalConsole overlay + console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) + console.pack(fill='both', expand=True, padx=1, pady=1) + self._overlay_widgets.append(console_bg) + self._overlay_widgets.append(console) + self._console_text = console.text + + # Keep overlay positioned on resize + def _on_resize(_evt=None): + l, t, w, h = self._compute_console_geometry(radio_bottom_y) + try: + console_bg.place(x=l, y=t, width=w, height=h) + except Exception: + pass + # Bind to slide area; store bind id to unbind later + self._overlay_bind_id = self.slide_area.bind('', _on_resize) + + def _compute_console_geometry(self, radio_bottom_y: int) -> tuple[int, int, int, int]: + """Return (left, top, width, height) for the console overlay area.""" + try: + w = self.slide_area.winfo_width() + h = self.slide_area.winfo_height() + except Exception: + w, h = 1300 * 0.75, 910 - 64 # Content area size approx + margin = 24 + top = max(radio_bottom_y + 10, 120) + bottom = max(top + 180, h - 16) # ensure some height + height = max(160, bottom - top - 80) if bottom - top > 260 else max(140, h - top - 24) + # Recompute bottom based on height + bottom = min(h - 24, top + height) + left = margin + width = max(300, w - 2 * margin) + return (left, top, width, bottom - top) + + def _append_console(self, text: str): + txt = getattr(self, '_console_text', None) + if not txt: + return + try: + if not txt.winfo_exists(): + return + txt.configure(state='normal') + txt.insert('end', text) + txt.see('end') + txt.configure(state='disabled') + except Exception: + pass + + def _console_press_enter(self): + """Simulate pressing Enter on the console preview line: remove blinking cursor if present and add a newline.""" + txt = getattr(self, '_console_text', None) + if not txt: + return + # Stop cursor blinking + if getattr(self, '_cursor_blink_after_id', None): + try: + self.root.after_cancel(self._cursor_blink_after_id) + except Exception: + pass + self._cursor_blink_after_id = None + self._cursor_blink_visible = False + try: + txt.configure(state='normal') + # If last char is our fake cursor, remove it + try: + last_char = txt.get('end-2c', 'end-1c') + if last_char in ('_', '|'): + txt.delete('end-2c', 'end-1c') + except Exception: + pass + txt.insert('end', '\n') + txt.see('end') + txt.configure(state='disabled') + except Exception: + pass + + # ----- Command preview & blinking cursor helpers ----- + def _get_user_host(self) -> tuple[str, str]: + try: + user = os.environ.get('USER') or os.getlogin() + except Exception: + user = 'user' + host = getattr(self, 'expected_host', None) or (platform.node() or 'host').split('.')[0] + return user, host + + def _compose_build_preview(self) -> str: + env = getattr(self, 'deploy_env_value', 'Dev') + cmd = self.get_build_command(env) + user, host = self._get_user_host() + return f"[{user}@{host}]# {cmd}" + + def _console_set_preview(self, line: str): + """Clear console and show a single-line preview with blinking cursor.""" + txt = getattr(self, '_console_text', None) + if not txt: + return + # Stop any previous blinking first + if getattr(self, '_cursor_blink_after_id', None): + try: + self.root.after_cancel(self._cursor_blink_after_id) + except Exception: + pass + self._cursor_blink_after_id = None + self._cursor_blink_visible = False + try: + txt.configure(state='normal') + txt.delete('1.0', 'end') + txt.insert('end', line) + txt.see('end') + txt.configure(state='disabled') + except Exception: + return + + # Start blinking cursor at end of line + def blink(): + t = getattr(self, '_console_text', None) + if t is None: + self._cursor_blink_after_id = None + return + try: + t.configure(state='normal') + # Remove existing cursor + if self._cursor_blink_visible: + # Delete last character if it's our cursor + end_index = t.index('end-1c') + if end_index and end_index != '1.0': + last_char = t.get('end-2c', 'end-1c') + if last_char in ('_', '|'): + t.delete('end-2c', 'end-1c') + self._cursor_blink_visible = False + else: + # Append cursor + t.insert('end', '_') + self._cursor_blink_visible = True + t.see('end') + t.configure(state='disabled') + except Exception: + self._cursor_blink_after_id = None + return + # schedule next toggle + self._cursor_blink_after_id = self.root.after(600, blink) + + self._cursor_blink_after_id = self.root.after(600, blink) + + def _run_in_console(self, command: str, log_path: str, on_complete=None): + """Run a bash -lc command in a background subprocess and stream output to the console and a log file. + This function does not echo the command into the console so that the UI behaves like pressing Enter + on the previously previewed command line. + """ + # Ensure console exists + if not getattr(self, '_console_text', None): + self._ensure_console_overlay(160) + # Stop cursor blinking before starting execution + if getattr(self, '_cursor_blink_after_id', None): + try: + self.root.after_cancel(self._cursor_blink_after_id) + except Exception: + pass + self._cursor_blink_after_id = None + self._cursor_blink_visible = False + # Terminate any previous process + if getattr(self, '_running_process', None): + self._terminate_running_process() + # Open log file + try: + self._console_log_fp = open(log_path, 'a', buffering=1, encoding='utf-8') + except Exception: + self._console_log_fp = None + # Start process group for safe termination + def preexec(): + try: + os.setsid() + except Exception: + pass + try: + proc = subprocess.Popen(['bash', '-lc', command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, preexec_fn=preexec) + self._running_process = proc + except Exception as e: + self._append_console(f"Failed to start process: {e}\n") + if self._console_log_fp: + try: + self._console_log_fp.write(f"Failed to start process: {e}\n") + except Exception: + pass + self._running_process = None + return + # Disable Next while running + try: + self.next_button.configure(text='Building…', state='disabled') + except Exception: + pass + # Reader thread + def reader(): + rc = None + try: + for line in proc.stdout: + if line is None: + break + self.safe_after(lambda s=line: self._append_console(s)) + if self._console_log_fp: + try: + self._console_log_fp.write(line) + except Exception: + pass + rc = proc.wait() + except Exception: + pass + finally: + if self._console_log_fp: + try: + self._console_log_fp.flush() + self._console_log_fp.close() + except Exception: + pass + self._console_log_fp = None + self._running_process = None + if on_complete: + self.safe_after(lambda: on_complete(rc if rc is not None else -1)) + t = threading.Thread(target=reader, daemon=True) + t.start() + + def _terminate_running_process(self): + proc = getattr(self, '_running_process', None) + if not proc: + return + try: + pgid = os.getpgid(proc.pid) + os.killpg(pgid, signal.SIGTERM) + except Exception: + try: + proc.terminate() + except Exception: + pass + # best-effort kill after short delay + try: + for _ in range(10): + if proc.poll() is not None: + break + time.sleep(0.05) + if proc.poll() is None: + try: + pgid = os.getpgid(proc.pid) + os.killpg(pgid, signal.SIGKILL) + except Exception: + proc.kill() + except Exception: + pass + self._running_process = None + + # ---------------- Build helpers (Terminal window management) ---------------- + def _terminal_create_new_window(self) -> str | None: + """Create a brand-new Terminal window (never reuse existing) and return its id.""" + if platform.system() != 'Darwin': + return None + osa = ''' + tell application "Terminal" to activate + delay 0.05 + tell application "System Events" + if exists process "Terminal" then + tell process "Terminal" + set frontmost to true + try + click menu item "New Window" of menu "Shell" of menu bar 1 + on error + keystroke "n" using {command down} + end try + end tell + end if + end tell + delay 0.1 + tell application "Terminal" + try + set _w to front window + set _id to id of _w + do script "" in _w + return _id + on error + return "" + end try + end tell + ''' + try: + result = subprocess.run(['osascript', '-e', osa], capture_output=True, text=True) + if result.returncode == 0: + sid = result.stdout.strip() + return sid or None + except Exception: + pass + return None + + def _terminal_set_bounds_by_id(self, win_id: str, l: int, t: int, r: int, b: int): + if platform.system() != 'Darwin' or not win_id: + return + osa = f'''tell application "Terminal" to try + set the bounds of every window whose id is {win_id} to {{{l}, {t}, {r}, {b}}} + end try''' + try: + subprocess.run(['osascript', '-e', osa]) + except Exception: + pass + + def _terminal_paste_by_id(self, win_id: str, text: str, press_enter: bool = False): + if platform.system() != 'Darwin' or not win_id: + return + # Put text on clipboard and paste into our specific window + try: + subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(text)} | pbcopy']) + except Exception: + pass + osa = ''' + tell application "Terminal" + try + set _wins to every window whose id is {win_id} + if (count of _wins) > 0 then set front window to item 1 of _wins + end try + activate + end tell + delay 0.05 + tell application "System Events" + keystroke "v" using {command down} + end tell + ''' + if press_enter: + osa += '\n' + 'tell application "System Events" to key code 36' + try: + subprocess.run(['osascript', '-e', osa]) + except Exception: + pass + def _compute_terminal_bounds(self, radio_bottom_y: int = 160) -> tuple[int, int, int, int]: + """Compute terminal window bounds (left, top, right, bottom) to fit inside + the installer window between the radio buttons and the footer.""" + try: + # Window absolute position + x0 = self.root.winfo_rootx() + y0 = self.root.winfo_rooty() + w = self.root.winfo_width() + h = self.root.winfo_height() + except Exception: + # Reasonable defaults + x0, y0, w, h = 200, 200, 1300, 910 + margin = 24 + top = y0 + radio_bottom_y + 10 + bottom = y0 + h - 90 # leave space for footer + # Offset left by sidebar width (325) + divider (1) + left = x0 + 326 + margin + right = x0 + w - margin + # Ensure minimum height + if bottom - top < 160: + bottom = top + 160 + return (left, top, right, bottom) + + def open_build_terminal_for_canvas_area(self, radio_bottom_y: int = 160): + """Open a brand-new Terminal.app window and size it to nestle inside the installer.""" + if platform.system() != 'Darwin': + return + l, t, r, b = self._compute_terminal_bounds(radio_bottom_y) + win_id = self._terminal_create_new_window() + if win_id: + self._terminal_set_bounds_by_id(win_id, l, t, r, b) + self.build_terminal_window_id = win_id + + def _start_terminal_follow(self, radio_bottom_y: int = 160): + """Bind window Configure to keep Terminal bounds anchored to installer area.""" + if platform.system() != 'Darwin': + return + self._terminal_follow_rby = radio_bottom_y + if getattr(self, '_terminal_follow_bound', False): + return + + def _follow(_evt=None): + # Debounce slightly + if getattr(self, '_terminal_follow_after', None): + try: + self.root.after_cancel(self._terminal_follow_after) + except Exception: + pass + def _do(): + if not getattr(self, 'build_terminal_window_id', None): + return + l, t, r, b = self._compute_terminal_bounds(self._terminal_follow_rby) + self._terminal_set_bounds_by_id(self.build_terminal_window_id, l, t, r, b) + self._terminal_follow_after = self.root.after(60, _do) + + self.root.bind('', _follow) + self._terminal_follow_bound = True + + def _stop_terminal_follow(self): + if getattr(self, '_terminal_follow_bound', False): + try: + self.root.unbind('') + except Exception: + pass + self._terminal_follow_bound = False + + def paste_into_terminal(self, text: str, press_enter: bool = False): + """Paste text into Terminal by targeting the build window id if available.""" + if platform.system() != 'Darwin': + return + win_id = getattr(self, 'build_terminal_window_id', None) + if win_id: + self._terminal_paste_by_id(win_id, text, press_enter=press_enter) + return + # Fallback to generic new window + win_id = self._terminal_create_new_window() + if win_id: + self._terminal_paste_by_id(win_id, text, press_enter=press_enter) + + def close_build_terminal(self): + if platform.system() != 'Darwin': + return + # stop following window + self._stop_terminal_follow() + win_id = getattr(self, 'build_terminal_window_id', None) + if not win_id: + # attempt to close front window politely + osa = 'tell application "Terminal" to if (count of windows) > 0 then close front window' + else: + osa = f'tell application "Terminal" to try\nclose (every window whose id is {win_id})\nend try' + try: + subprocess.run(['osascript', '-e', osa]) + except Exception: + pass + + def get_build_command(self, env: str) -> str: + """Delegate to installer.build.get_build_command.""" + return inst_get_build_command(PROJECT_ROOT, env) + + # ---------------- Build Summary page ---------------- + def open_terminal_with_command(self, command: str | None): + if not command: + return + try: + # Always create a brand-new Terminal window and paste via clipboard + if platform.system() == 'Darwin': + win_id = self._terminal_create_new_window() + if win_id: + # Position reasonably + l, t, r, b = self._compute_terminal_bounds(radio_bottom_y=120) + self._terminal_set_bounds_by_id(win_id, l, t, r, b) + self._terminal_paste_by_id(win_id, command, press_enter=False) + return + # Fallback: copy to clipboard and open Terminal + subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(command)} | pbcopy && open -a Terminal']) + except Exception: + webbrowser.open_new_tab('https://brew.sh') + + # ---------------- Build integration ---------------- + def check_xcode_tools(self): + """Check if Xcode Command Line Tools are installed via deploy helper.""" + return inst_deploy.check_xcode_tools() + + def build_prole_app(self): + """Delegate building the native ProleStatus app to deploy helper.""" + return inst_deploy.build_prole_app(PROJECT_ROOT) diff --git a/installer/ui/screens/cfg.py b/installer/ui/screens/cfg.py new file mode 100644 index 0000000..3859925 --- /dev/null +++ b/installer/ui/screens/cfg.py @@ -0,0 +1,655 @@ +"""prole.cfg persistence, input snapshot collection and port-forward management.""" + +import configparser +import os +import platform +import socket +import string +import subprocess +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer.core.env import ( + DEFAULT_ACTION_FLAGS, + DEFAULT_OLLAMA_PORT, + PROJECT_ROOT, + _bool_str, + _build_required_port_forwards, + _cluster_env_radio_value, + _default_opentofu_pipeline_url, + _deployment_mode_from_env, + _deployment_target_label, + _detect_ansible_topology, + _format_ansible_topology_summary, + _normalize_k3s_token, + _pf_extract_id, + _pf_upsert_mapping, + _render_prole_cfg, +) +from installer.config import _collect_cfg_vars, _encrypt_cfg_secret, _expand_cfg_value + + +class ConfigMixin: + """prole.cfg persistence, input snapshot collection and port-forward management.""" + + def _save_prole_cfg(self): + """Generates prole.cfg; master configuration file containing all values used in install process.""" + try: + # Check port availability before saving + # try: + # port_val = int(self.db_host_port.get().strip()) + # if not self._is_port_available(port_val): + # self._show_port_error(port_val) + # return + # except ValueError: + # messagebox.showerror("Invalid Port", "Please enter a valid numeric port.") + # return + + # Determine path: conf/prole.cfg + # If PROLE_CONF is set, use it. Otherwise fallback to PROJECT_ROOT/conf + if self._cfg_path_override is not None: + cfg_path = self._cfg_path_override + if cfg_path.is_dir(): + conf_dir = cfg_path + cfg_path = conf_dir / 'prole.cfg' + else: + conf_dir = cfg_path.parent + else: + prole_conf = os.environ.get('PROLE_CONF') + if prole_conf: + conf_dir = Path(prole_conf).expanduser() + else: + conf_dir = PROJECT_ROOT / 'conf' + os.environ.setdefault('PROLE_CONF', str(conf_dir)) + cfg_path = conf_dir / 'prole.cfg' + + conf_dir.mkdir(parents=True, exist_ok=True) + + # Identify global candidates + mode = self._deployment_mode() + target_label = _deployment_target_label(self.cluster_env.get()) + globals_to_save = { + 'PROLE_HOME': os.environ.get('PROLE_HOME', ''), + 'PROLE_DB_USER': self.db_username.get(), + 'DB_PASSWORD': self._secret_cfg_value('Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password'), + 'CLUSTER_ENV': self.cluster_env.get(), + 'DEPLOYMENT_MODE': mode, + 'DEPLOYMENT_TARGET': target_label, + 'NAMESPACE': (self.db_namespace.get() or '').strip(), + 'DB_HOST_PORT': (self.db_host_port.get() or '5432').strip(), + 'PROLE_OPENTOFU_URL': _default_opentofu_pipeline_url(), + } + # Add any other values already in self.prole_cfg_data['Global'] + globals_to_save.update(self.prole_cfg_data.get('Global', {})) + globals_to_save['DB_PASSWORD'] = self._secret_cfg_value( + 'Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password' + ) + globals_to_save['DEPLOYMENT_MODE'] = mode + globals_to_save['DEPLOYMENT_TARGET'] = target_label + globals_to_save['PROLE_K3S_SERVER'] = (self.k3s_server_url.get() or '').strip() + globals_to_save['PROLE_K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') + globals_to_save['SERVICE_NAMESPACE'] = self._get_service_namespace() + + self._sync_port_forward_mappings() + + # Inputs section (replayable UI inputs) + try: + inputs = self._collect_input_snapshot() + except Exception: + inputs = {} + + sections = {k: self.prole_cfg_data.get(k, {}) for k in [ + 'Welcome', 'Dependencies', 'Network', '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': 'dev', + 'DISPLAY_NAME': 'prole-dev-cluster', + 'KUBECTL_CONTEXT': (self.selected_kubectx.get() or '').strip() + } + 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.k3s_server_url.get() or '').strip(), + 'K3S_TOKEN': _encrypt_cfg_secret(self.k3s_token.get() or ''), + '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.prod_artifacts_path.get() or '').strip(), + 'PIPELINE_URL': _default_opentofu_pipeline_url() + } + sections = self._sanitize_sections_for_cfg(sections) + + # Sync to Ansible Prole Vault + self._save_ansible_prole_vault(self.db_password.get()) + + cfg_text = _render_prole_cfg(inputs, globals_to_save, sections) + cfg_path.write_text(cfg_text) + print(f"[DEBUG] prole.cfg saved to {cfg_path}") + + except Exception as e: + print(f"[ERROR] Failed to save prole.cfg: {e}") + + def _collect_input_snapshot(self) -> dict: + """Collect all possible user inputs for prole.cfg replay.""" + inputs: dict[str, str] = {} + + def _set(key: str, val): + inputs[key] = '' if val is None else str(val) + + def _set_bool(key: str, val: bool): + inputs[key] = _bool_str(bool(val)) + + def _get_var(var, default=''): + try: + return var.get() + except Exception: + return default + + def _action(key: str, default: bool): + return bool(self._action_flags.get(key, default)) + + # Dependencies + _set_bool('dependencies.verify_all', _get_var(self.verify_mode, False)) + _set_bool('dependencies.auto_install_missing', _action('dependencies.auto_install_missing', DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True))) + for dep in self.dependencies: + dep_key = f"dependencies.{dep['id']}.install" + _set_bool(dep_key, _action(dep_key, True)) + + # Network scan + _set_bool('network_scan.run', _action('network_scan.run', DEFAULT_ACTION_FLAGS.get('network_scan.run', True))) + + # Environment setup values + env_vals = {} + try: + env_vals.update(self._env_defaults()) + except Exception: + pass + try: + env_vals.update(self._read_existing_env()) + except Exception: + pass + for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): + if os.environ.get(k): + env_vals[k] = os.environ.get(k) + if hasattr(self, '_env_entries'): + for k, ent in self._env_entries.items(): + try: + v = ent.get().strip() + if v: + env_vals[k] = v + except Exception: + pass + for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): + _set(f'env_setup.{k}', env_vals.get(k, '')) + ns_val = (str(_get_var(self.db_namespace, '')).strip()) if hasattr(self, 'db_namespace') else '' + if not ns_val: + ns_val = env_vals.get('NAMESPACE', '') + _set('env_setup.NAMESPACE', ns_val) + + # Database creation + _set('init_password.db_namespace', ns_val) + _set('init_password.db_username', _get_var(self.db_username, '')) + db_pw = _get_var(self.db_password, '') + db_pw_confirm = _get_var(self.db_password_confirm, '') or db_pw + db_pw_cfg = self._secret_cfg_value('Inputs', 'init_password.db_password', db_pw, 'db', 'password') + _set('init_password.db_password', db_pw_cfg) + _set('init_password.db_password_confirm', db_pw_cfg or db_pw_confirm) + _set('init_password.db_host_port', _get_var(self.db_host_port, '5432')) + _set_bool('init_password.generate_ssh_key', _action('init_password.generate_ssh_key', DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True))) + + # Build DB image + _set_bool('init_db_build.run_build', _action('init_db_build.run_build', DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True))) + + # Cluster init + optional features + _set('init_cluster.cluster_env', _get_var(self.cluster_env, 'dev')) + _set('init_cluster.mode', _deployment_mode_from_env(_get_var(self.cluster_env, 'dev'))) + _set('init_cluster.deployment_target', _deployment_target_label(_get_var(self.cluster_env, 'dev'))) + _set('init_cluster.k3s_server_url', _get_var(self.k3s_server_url, '')) + _set('init_cluster.k3s_token', _encrypt_cfg_secret(_get_var(self.k3s_token, ''))) + _set_bool('init_cluster.supabase_enabled', _get_var(self.supabase_enabled, False)) + _set_bool('init_cluster.kerberos_enabled', _get_var(self.kerberos_enabled, False)) + _set_bool('init_cluster.at_rest_encryption_enabled', _get_var(self.at_rest_encryption_enabled, False)) + _set_bool('init_cluster.start_cluster', _action('init_cluster.start_cluster', DEFAULT_ACTION_FLAGS.get('init_cluster.start_cluster', True))) + + # Kerberos config + _set_bool('kerberos_config.enabled', _get_var(self.kerberos_enabled, False)) + _set('kerberos_config.realm', _get_var(self.kerberos_realm, '')) + _set('kerberos_config.kdc', _get_var(self.kerberos_kdc, '')) + _set('kerberos_config.user', _get_var(self.kerberos_user, '')) + krb_pw = _get_var(self.kerberos_password, '') + _set('kerberos_config.password', self._secret_cfg_value('Inputs', 'kerberos_config.password', krb_pw, 'kerberos', 'password')) + _set_bool('kerberos_config.test_connection', _action('kerberos_config.test_connection', DEFAULT_ACTION_FLAGS.get('kerberos_config.test_connection', False))) + + # Ollama config + _set('ollama_config.server_host', _get_var(self.ollama_server_host, '')) + _set('ollama_config.server_port', _get_var(self.ollama_server_port, DEFAULT_OLLAMA_PORT)) + _set('ollama_config.model', _get_var(self.ollama_model, '')) + + # Init scripts + deploy + _set_bool('init_scripts.run_scripts', _action('init_scripts.run_scripts', DEFAULT_ACTION_FLAGS.get('init_scripts.run_scripts', True))) + _set_bool('init_cnpg_deploy.run_deploy', _action('init_cnpg_deploy.run_deploy', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True))) + _set_bool('init_cnpg_deploy.force_rollout', _action('init_cnpg_deploy.force_rollout', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.force_rollout', False))) + + # Disk selection (installer packaging) + _set('disk_selection.disk_type', _get_var(self.selected_disk_type, 'local')) + _set('disk_selection.removable_mount', _get_var(self.selected_removable_disk, '')) + _set('disk_selection.local_path', _get_var(self.selected_local_path, str(Path.home()))) + + # Build tools app + _set('build.deploy_env', getattr(self, 'deploy_env_value', 'Dev')) + _set_bool('build.run_build', _action('build.run_build', DEFAULT_ACTION_FLAGS.get('build.run_build', False))) + + return inputs + + def _sync_port_forward_mappings(self): + try: + mode = self._deployment_mode() + except Exception: + mode = '' + if not mode: + try: + mode = _deployment_mode_from_env(self.cluster_env.get()) + except Exception: + mode = '' + 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 = {} + + try: + service_ns = (self._get_service_namespace() or '').strip() + except Exception: + service_ns = '' + if not service_ns: + service_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' + + try: + db_ns = (self.db_namespace.get() or '').strip() + except Exception: + db_ns = '' + 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' + + try: + db_host_port = (self.db_host_port.get() or '').strip() + except Exception: + db_host_port = '' + 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 = False + try: + supabase_enabled = bool(self.supabase_enabled.get()) + except Exception: + 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 + self._update_legacy_port_mapping() + + def _update_legacy_port_mapping(self): + conf_dir = PROJECT_ROOT / 'conf' + mapping_path = conf_dir / 'port-mapping.cfg' + + pf_section = self.prole_cfg_data.get('Port Forwards', {}) + if pf_section is None: + pf_section = {} + + try: + mode = self._deployment_mode() + except Exception: + mode = 'k3d' + prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" + + lines = [ + "# Port mappings for Prole Tools (generated).", + "# Format: key: local=... remote=... ns=... svc=... address=...", + "" + ] + + # We want to maintain some order or just dump them + for k, v in sorted(pf_section.items()): + if k.startswith(prefix): + # Parse the mapping string: id=...;namespace=...;target=...;address=...;hostPort=...;servicePort=...;protocol=...;description=... + parts = {} + for p in v.split(';'): + if '=' in p: + key_val = p.split('=', 1) + if len(key_val) == 2: + parts[key_val[0].strip()] = key_val[1].strip() + + if 'id' in parts: + m_id = parts['id'] + local = parts.get('hostPort', '') + remote = parts.get('servicePort', '') + ns = parts.get('namespace', '') + target = parts.get('target', '') + addr = parts.get('address', '0.0.0.0') + + svc = target + if svc.startswith('svc/'): + svc = svc[4:] + + lines.append(f"{m_id}: local={local} remote={remote} ns={ns} svc={svc} address={addr}") + + try: + mapping_path.write_text('\n'.join(lines) + '\n') + print(f"[DEBUG] port-mapping.cfg updated at {mapping_path}") + except Exception as e: + print(f"[ERROR] Failed to update port-mapping.cfg: {e}") + + 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._save_prole_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._save_prole_cfg() + + if "PORT_FORWARD_MAPPING:" in line: + mapping = line.split("PORT_FORWARD_MAPPING:")[1].strip() + if mapping: + if _pf_extract_id(mapping) == "registry": + return + self._add_port_mapping(mapping) + + def _is_port_available(self, port: int) -> bool: + """Check if a port is available on localhost.""" + import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(('127.0.0.1', port)) + return True + except socket.error: + return False + + def _pf_extract_field(self, mapping_str: str, field: str) -> str: + if not mapping_str or not field: + return '' + needle = f"{field}=" + for part in str(mapping_str).split(';'): + part = part.strip() + if part.startswith(needle): + return part[len(needle):].strip() + return '' + + def _validate_port_forward_overlaps(self) -> bool: + try: + self._sync_port_forward_mappings() + except Exception: + pass + + try: + mode = self._deployment_mode() + except Exception: + mode = '' + if not mode: + try: + mode = _deployment_mode_from_env(self.cluster_env.get()) + except Exception: + mode = '' + 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', {}) or {} + + port_index = {} + for key, mapping in pf_section.items(): + if not key.startswith(prefix): + continue + host_port = self._pf_extract_field(mapping, 'hostPort') + if not host_port: + continue + mapping_id = _pf_extract_id(mapping) + target = self._pf_extract_field(mapping, 'target') + namespace = self._pf_extract_field(mapping, 'namespace') + label = mapping_id or target or key + if namespace and target: + label = f"{label} ({namespace}/{target})" + port_index.setdefault(host_port, []).append(label) + + collisions = {port: labels for port, labels in port_index.items() if len(labels) > 1} + if not collisions: + return True + + lines = [] + for port, labels in sorted(collisions.items(), key=lambda item: int(item[0]) if str(item[0]).isdigit() else item[0]): + lines.append(f"{port}: {', '.join(sorted(labels))}") + + try: + messagebox.showerror( + "Port Collision", + "Port forward mappings overlap:\n\n" + + "\n".join(lines) + + "\n\nPlease choose unique host ports before continuing." + ) + except Exception: + pass + return False + + def _show_port_error(self, port: int): + """Show error message when port is in use, with option to see lsof output.""" + msg = f"Port {port} is already in use on the local host.\n\nPlease choose a different port or stop the service using it." + + dialog = tk.Toplevel(self.root) + dialog.title("Port In Use") + dialog.geometry("450x200") + dialog.configure(bg='white') + dialog.transient(self.root) + dialog.grab_set() + + tk.Label(dialog, text="Port Conflict", font=('SF Pro Text', 14, 'bold'), bg='white', fg='#ff3b30').pack(pady=(20, 10)) + tk.Label(dialog, text=msg, font=('SF Pro Text', 11), bg='white', fg='black', wraplength=400).pack(pady=10) + + btn_frame = tk.Frame(dialog, bg='white') + btn_frame.pack(pady=20) + + def show_lsof(): + log_file = PROJECT_ROOT / "logs" / f"port_{port}_lsof.log" + log_file.parent.mkdir(parents=True, exist_ok=True) + try: + res = subprocess.run(['lsof', '-i', f':{port}'], capture_output=True, text=True) + content = res.stdout if res.stdout else f"No lsof output for port {port}. (Maybe permission denied?)" + if res.stderr: + content += "\n\nError:\n" + res.stderr + log_file.write_text(content) + # Open the log file + if platform.system() == 'Darwin': + subprocess.run(['open', str(log_file)]) + elif platform.system() == 'Windows': + os.startfile(str(log_file)) + else: + subprocess.run(['xdg-open', str(log_file)]) + except Exception as e: + messagebox.showerror("Error", f"Failed to run lsof: {e}") + + tk.Button(btn_frame, text="Show Logs (lsof)", command=show_lsof, bg='#F5F5DC', relief='flat', padx=10).pack(side='left', padx=10) + tk.Button(btn_frame, text="OK", command=dialog.destroy, bg='#007aff', fg='white', relief='flat', padx=20).pack(side='left', padx=10) + + def _apply_ansible_topology_defaults(self): + info = _detect_ansible_topology(PROJECT_ROOT) + if not info: + return + self.ansible_topology = info + self.ansible_topology_summary = _format_ansible_topology_summary(info) + 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 + + try: + if not self.kerberos_kdc.get().strip() and info.get('kdc_ip'): + self.kerberos_kdc.set(info['kdc_ip']) + self.kerberos_enabled.set(True) + if not self.kerberos_realm.get().strip() and info.get('realm'): + self.kerberos_realm.set(info['realm']) + except Exception: + pass + + def _read_k3s_cfg_values(self) -> tuple[str, str, str]: + """Return (cluster_env, server_url, token) from prole.cfg if present.""" + cfg_path = None + try: + if self._cfg_path_override is not None: + cfg_path = self._cfg_path_override + if cfg_path.is_dir(): + cfg_path = cfg_path / 'prole.cfg' + else: + cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' + except Exception: + cfg_path = None + if not cfg_path or not Path(cfg_path).exists(): + return '', '', '' + + cfg = configparser.ConfigParser(interpolation=None) + cfg.optionxform = str + try: + cfg.read(cfg_path) + except Exception: + return '', '', '' + cfg_vars = _collect_cfg_vars(cfg) + + env_val = '' + server_val = '' + token_val = '' + if cfg.has_section('Global'): + env_val = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', env_val), cfg_vars).strip() + server_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_SERVER', server_val), cfg_vars).strip() + if not server_val: + server_val = _expand_cfg_value(cfg['Global'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() + token_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_TOKEN', token_val), cfg_vars).strip() + if not token_val: + token_val = _expand_cfg_value(cfg['Global'].get('K3S_TOKEN', token_val), cfg_vars).strip() + if cfg.has_section('Initialize Cluster'): + server_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() + token_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_TOKEN', token_val), cfg_vars).strip() + if cfg.has_section('Service Cluster (k3s)'): + server_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() + token_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_TOKEN', token_val), cfg_vars).strip() + + return env_val, server_val, token_val + + def _apply_cluster_env_default_from_cfg(self): + cfg_env, _cfg_server, _cfg_token = self._read_k3s_cfg_values() + if not cfg_env: + return + try: + current = (self.cluster_env.get() or '').strip() + except Exception: + current = '' + if not current or current == 'dev': + try: + self.cluster_env.set(_cluster_env_radio_value(cfg_env)) + except Exception: + pass + self._set_deploy_target_from_cluster_env() + + def _apply_k3s_defaults(self): + # Environment overrides + env_server = (os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() + env_token = (os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() + env_token = _normalize_k3s_token(env_token) + if env_server and not self.k3s_server_url.get().strip(): + self.k3s_server_url.set(env_server) + if env_token and not self.k3s_token.get().strip(): + self.k3s_token.set(env_token) + + # Config defaults + _cfg_env, cfg_server, cfg_token = self._read_k3s_cfg_values() + if cfg_server and not self.k3s_server_url.get().strip(): + self.k3s_server_url.set(cfg_server) + if cfg_token and not self.k3s_token.get().strip(): + self.k3s_token.set(cfg_token) + + # Ansible defaults + info = getattr(self, 'ansible_topology', None) or _detect_ansible_topology(PROJECT_ROOT) + if info: + if info.get('k3s_server_url') and not self.k3s_server_url.get().strip(): + self.k3s_server_url.set(info['k3s_server_url']) + token_val = _normalize_k3s_token(info.get('k3s_token')) + if token_val and not self.k3s_token.get().strip(): + self.k3s_token.set(token_val) diff --git a/installer/ui/screens/cluster.py b/installer/ui/screens/cluster.py new file mode 100644 index 0000000..31e88b9 --- /dev/null +++ b/installer/ui/screens/cluster.py @@ -0,0 +1,1357 @@ +"""Cluster lifecycle management (k3d / k3s / k8s).""" + +import json +import os +import re +import subprocess +import sys +import threading +import time +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer.screen import TerminalConsole +from installer import screen as ui +from installer.core.env import ( + PROJECT_ROOT, + _default_opentofu_pipeline_url, + _deployment_mode_from_env, + _deployment_target_label, + _find_kubeconfig_file, + _k3d_prole_data_volume_args, + _looks_like_k8s_bearer_token, + _normalize_cluster_env, + _normalize_k3s_token, +) +from installer.config import _encrypt_cfg_secret, _write_k3s_kubeconfig +from installer.core.actions import _reset_k3s_namespace + + +class ClusterScreenMixin: + """Cluster lifecycle management (k3d / k3s / k8s).""" + + def _render_init_cluster_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Cluster Environment', y=150) + self._render_paragraph('Select a cluster environment and ensure the cluster is running and common services can be deployed.', y=200) + + # Cluster Selection (Radio Buttons) + x_label = 48 + y = 280 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Environment:', fill='black', font=('SF Pro Text', 14, 'bold'))) + + y += 28 + cluster_options = [ + ('dev', 'Dev'), + ('service', 'Service'), + ('prod', 'Prod') + ] + + # We need to trace cluster_env if not already traced + if not hasattr(self, '_cluster_env_trace'): + self._cluster_env_trace = self.cluster_env.trace_add('write', self._on_cluster_env_change) + + rb_x = x_label + 20 + rb_gap = 150 + for idx, (val, name) in enumerate(cluster_options): + rb = tk.Radiobutton(self.bg_canvas, text=name, variable=self.cluster_env, value=val, + bg='white', fg='black', activebackground='white', selectcolor='white', + font=('SF Pro Text', 11)) + rb_window = self.bg_canvas.create_window(rb_x + idx * rb_gap, y, window=rb, anchor='nw') + self._canvas_items.append(rb_window) + self._overlay_widgets.append(rb) + + selected_env_key = self._cluster_env_key() + y += 44 + + if selected_env_key == 'dev': + # Combined Select / Add Cluster widget + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Cluster:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 28 + + clusters = self._get_k3d_cluster_list() + combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_k3d_cluster, values=clusters, width=30) + if not self.selected_k3d_cluster.get() and clusters: + self.selected_k3d_cluster.set(clusters[0]) + + combo.bind('<>', lambda _evt: self._on_k3d_cluster_select()) + + combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw') + self._canvas_items.append(combo_win) + self._overlay_widgets.append(combo) + + add_btn = tk.Button(self.bg_canvas, text='Add', command=self._on_create_k3d_cluster, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', activeforeground='black', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10) + add_win = self.bg_canvas.create_window(x_label + 290, y - 4, window=add_btn, anchor='nw') + self._canvas_items.append(add_win) + self._overlay_widgets.append(add_btn) + + delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._on_delete_k3d_cluster, + bg='#F5F5DC', fg='#cc0000', activebackground='#E5E5D5', activeforeground='#cc0000', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10) + delete_win = self.bg_canvas.create_window(x_label + 350, y - 4, window=delete_btn, anchor='nw') + self._canvas_items.append(delete_win) + self._overlay_widgets.append(delete_btn) + y += 40 + + else: + # Service / Prod Section + if selected_env_key == 'service': + # K3s connection settings + try: + self._apply_k3s_defaults() + except Exception: + pass + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'K3s Connection:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 28 + + ui.canvas_text(self, x_label + 20, y, 'Server URL:', fill='black', font=('SF Pro Text', 11)) + server_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_server_url, width=60) + server_win = self.bg_canvas.create_window(x_label + 140, y - 8, window=server_entry, anchor='nw') + self._canvas_items.append(server_win) + self._overlay_widgets.append(server_entry) + y += 28 + + ui.canvas_text(self, x_label + 20, y, 'Token:', fill='black', font=('SF Pro Text', 11)) + token_entry = tk.Entry(self.bg_canvas, textvariable=self.k3s_token, show='*', width=60) + token_win = self.bg_canvas.create_window(x_label + 200, y - 8, window=token_entry, anchor='nw') + self._canvas_items.append(token_win) + self._overlay_widgets.append(token_entry) + y += 34 + + # Context selection for both Service and Prod + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Kubernetes Context:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 28 + values = self._get_kubectx_list() + combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_kubectx, values=values, state='readonly', width=40) + if not self.selected_kubectx.get() and values: + self.selected_kubectx.set(values[0]) + combo_win = self.bg_canvas.create_window(x_label + 20, y, window=combo, anchor='nw') + self._canvas_items.append(combo_win) + self._overlay_widgets.append(combo) + y += 40 + + if selected_env_key == 'prod': + # Staging directory input + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Local Artifact Staging Directory:', fill='black', font=('SF Pro Text', 12, 'bold'))) + y += 30 + + entry = tk.Entry(self.bg_canvas, textvariable=self.prod_artifacts_path, width=60) + entry_win = self.bg_canvas.create_window(x_label + 20, y, window=entry, anchor='nw') + self._canvas_items.append(entry_win) + self._overlay_widgets.append(entry) + + browse_btn = tk.Button(self.bg_canvas, text="Browse...", command=lambda: self.prod_artifacts_path.set(filedialog.askdirectory() or self.prod_artifacts_path.get())) + browse_win = self.bg_canvas.create_window(x_label + 440, y - 5, window=browse_btn, anchor='nw') + self._canvas_items.append(browse_win) + self._overlay_widgets.append(browse_btn) + y += 30 + + ui.canvas_text(self, x_label + 20, y, '(Used by etc/deploy_pipeline.sh --mode gcp)', fill='#6e6e73', font=('SF Pro Text', 10)) + y += 34 + + # Common Services Status Check (Notebook) + # Show if in Service/Prod or if a cluster is selected in Dev + show_notebook = False + if selected_env_key == 'dev': + if self.selected_k3d_cluster.get(): + show_notebook = True + else: + show_notebook = True + + if show_notebook: + service_label_text = f"Common Services (namespace: {self._get_service_namespace()})" + service_label = ui.canvas_text(self, x_label, y, service_label_text, fill='black', font=('SF Pro Text', 12, 'bold')) + self._canvas_items.append(service_label) + y += 28 + + notebook = ttk.Notebook(self.bg_canvas) + status_frame = tk.Frame(notebook, bg='white') + deploy_frame = tk.Frame(notebook, bg='white') + status_console = ui.TerminalConsole(status_frame) + deploy_console = ui.TerminalConsole(deploy_frame) + status_console.pack(fill='both', expand=True) + deploy_console.pack(fill='both', expand=True) + notebook.add(status_frame, text='Status') + notebook.add(deploy_frame, text='Deploy Output') + + self._k3s_service_notebook = notebook + self._k3s_service_status_console = status_console + self._k3s_service_deploy_console = deploy_console + self._k3s_service_status_tab = status_frame + self._k3s_service_deploy_tab = deploy_frame + + console_width = 900 + console_height = 210 + console_window = self.bg_canvas.create_window( + x_label, y, window=notebook, anchor='nw', + width=console_width, height=console_height + ) + self._canvas_items.append(console_window) + self._overlay_widgets.extend([notebook, status_frame, deploy_frame, status_console, deploy_console]) + + restored = self._restore_common_services_log(deploy_console) + if restored: + try: + self._k3s_service_notebook.select(self._k3s_service_deploy_tab) + except Exception: + pass + y += console_height + 12 + + deploy_btn = tk.Button( + self.bg_canvas, + text='Deploy Missing Services', + command=self._deploy_k3s_services, + bg='#F5F5DC', + fg='black', + activebackground='#E5E5D5', + highlightbackground='#F5F5DC', + highlightthickness=0, + relief='flat', + font=('SF Pro Text', 10), + padx=10, + pady=5, + ) + deploy_win = self.bg_canvas.create_window(x_label + 20, y, window=deploy_btn, anchor='nw') + self._canvas_items.append(deploy_win) + self._overlay_widgets.append(deploy_btn) + y += 40 + self._verify_k3s_services() + + def _cluster_env_key(self, env_label: str | None = None) -> str: + """Return normalized cluster env key ('dev', 'service', 'prod').""" + value = env_label + if value is None: + try: + value = self.cluster_env.get() + except Exception: + value = '' + + try: + if hasattr(value, 'get') and callable(value.get): + value = value.get() + except Exception: + pass + + if not value: + try: + value = (self.prole_cfg_data.get('Initialize Cluster', {}) or {}).get('ENVIRONMENT', '').strip() + except Exception: + value = '' + if not value: + try: + value = (self.prole_cfg_data.get('Global', {}) or {}).get('CLUSTER_ENV', '').strip() + except Exception: + value = '' + + key = _normalize_cluster_env(value) + if not key: + return 'dev' + return key + + def _get_service_namespace(self) -> str: + """Resolve service namespace with UI/env/config fallbacks.""" + try: + ns = (self.service_namespace.get() or '').strip() + except Exception: + ns = '' + if not ns: + try: + ns = (self.prole_cfg_data.get('Global', {}) or {}).get('SERVICE_NAMESPACE', '').strip() + except Exception: + ns = '' + if not ns: + ns = (os.environ.get('SERVICE_NAMESPACE') or '').strip() + return ns or 'default' + + def _on_cluster_env_change(self, *args): + self._set_deploy_target_from_cluster_env() + # Trigger status check or refresh UI + env_key = self._cluster_env_key() + if env_key == 'service': + try: + self._apply_k3s_defaults() + except Exception: + pass + self._verify_k3s_services() + self.show_page('init_cluster') + + def _deployment_mode(self) -> str: + try: + env_val = self.cluster_env.get() + except Exception: + env_val = '' + return _deployment_mode_from_env(env_val) + + def _deployment_pipeline_url(self, target_key: str) -> str: + env_url = (os.environ.get('PROLE_OPENTOFU_URL') or os.environ.get('OPENTOFU_URL') or '').strip() + if env_url: + return env_url + section = None + if target_key == 'service': + section = 'Service Cluster (k3s)' + elif target_key == 'prod': + section = 'Prod Cluster (k8s)' + if section: + url = (self.prole_cfg_data.get(section, {}) or {}).get('PIPELINE_URL', '').strip() + if url: + return url + return _default_opentofu_pipeline_url() + + def _set_deploy_target_from_cluster_env(self): + if not hasattr(self, 'deploy_target'): + return + if getattr(self, '_syncing_deploy_target', False): + return + self._syncing_deploy_target = True + try: + self.deploy_target.set(_deployment_target_label(self.cluster_env.get())) + except Exception: + pass + finally: + self._syncing_deploy_target = False + + def _on_deploy_target_change(self, *args): + if getattr(self, '_syncing_deploy_target', False): + return + target = '' + try: + target = self.deploy_target.get() + except Exception: + target = '' + key = _normalize_cluster_env(target) + if key == 'dev': + desired = 'dev' + elif key == 'service': + desired = 'service' + elif key == 'prod': + desired = 'prod' + else: + return + self._syncing_deploy_target = True + try: + if self.cluster_env.get() != desired: + self.cluster_env.set(desired) + finally: + self._syncing_deploy_target = False + + def _openbao_url(self) -> str: + url = (os.environ.get("PROLE_OPENBAO_URL") or "http://127.0.0.1:18200").strip() + return url.rstrip('/') + + def _check_openbao_health(self) -> bool: + """Check OpenBao health in the currently selected namespace/pod. + Preference order: + 1) If in dev k3d or service k3s cluster, check k8s resources in selected namespace (deployment/statefulset ready) + 2) Fallback to HTTP health endpoint if PROLE_OPENBAO_URL (or default) responds + """ + try: + env_key = self._cluster_env_key() + except Exception: + env_key = 'dev' + + # 1) Check k8s readiness in selected namespace when using dev/service clusters + try: + if env_key == 'service': + ns = self._get_service_namespace() + else: + ns = (self.db_namespace.get() or '').strip() or 'default' + except Exception: + ns = 'default' + + try: + base_cmd = self._kubectl_base_cmd(env_key) + # deployment/openbao readiness + res_dep = subprocess.run(base_cmd + ['-n', ns, 'get', 'deploy', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], + capture_output=True, text=True, timeout=3) + ready_dep = (res_dep.returncode == 0 and (res_dep.stdout or '0').strip() not in ('', '0')) + + # statefulset/openbao readiness + res_sts = subprocess.run(base_cmd + ['-n', ns, 'get', 'statefulset', 'openbao', '-o', 'jsonpath={.status.readyReplicas}'], + capture_output=True, text=True, timeout=3) + ready_sts = (res_sts.returncode == 0 and (res_sts.stdout or '0').strip() not in ('', '0')) + + if ready_dep or ready_sts: + return True + except Exception: + pass + + # 2) Fallback to HTTP health + url = self._openbao_url() + if not url: + return False + health_url = f"{url}/v1/sys/health" + try: + with urllib.request.urlopen(health_url, timeout=2): + return True + except urllib.error.HTTPError: + # OpenBao responds with non-200 for sealed/standby; still reachable + return True + except Exception: + return False + + def _dashboard_kong_status(self, env: str | None = None) -> dict: + """Check Kubernetes Dashboard (Kong) pod health and return status info.""" + ns = "kubernetes-dashboard" + base_cmd = self._kubectl_base_cmd(env) + + def _pod_healthy(pod_status: dict) -> bool: + phase = (pod_status.get('phase') or '').strip() + if phase not in ('Running', 'Succeeded'): + return False + statuses = pod_status.get('containerStatuses') or [] + if not statuses: + return phase == 'Succeeded' + for cs in statuses: + if cs.get('ready') is True: + continue + state = cs.get('state') or {} + waiting = state.get('waiting') or {} + reason = (waiting.get('reason') or '').lower() + if reason: + return False + return False + return True + + # Ensure namespace exists + rc_ns, out_ns = self._run_cmd_capture(base_cmd + ['get', 'ns', ns, '-o', 'name'], timeout=6) + if rc_ns != 0 or ns not in (out_ns or ''): + return { + 'ok': False, + 'msg': 'Dashboard (Kong): Missing', + 'fill': '#ff9f0a', + 'reset_pods': [], + } + + rc, out = self._run_cmd_capture(base_cmd + ['get', 'pods', '-n', ns, '-o', 'json'], timeout=8) + if rc != 0: + return { + 'ok': False, + 'msg': 'Dashboard (Kong): Unable to query', + 'fill': '#ff9f0a', + 'reset_pods': [], + } + try: + payload = json.loads(out or "{}") + except Exception: + return { + 'ok': False, + 'msg': 'Dashboard (Kong): Unable to parse status', + 'fill': '#ff9f0a', + 'reset_pods': [], + } + + kong_pods = [] + for item in payload.get('items', []): + name = (item.get('metadata', {}) or {}).get('name', '') + name_lc = name.lower() + if 'kong' in name_lc and 'dashboard' in name_lc: + kong_pods.append(item) + + if not kong_pods: + return { + 'ok': False, + 'msg': 'Dashboard (Kong): Missing', + 'fill': '#ff9f0a', + 'reset_pods': [], + } + + unhealthy = [] + for item in kong_pods: + name = (item.get('metadata', {}) or {}).get('name', '') + status = item.get('status') or {} + if not _pod_healthy(status): + unhealthy.append(name) + + if unhealthy: + return { + 'ok': False, + 'msg': 'Dashboard (Kong): Unhealthy (safe to reset)', + 'fill': '#ff9f0a', + 'reset_pods': unhealthy, + } + + return { + 'ok': True, + 'msg': 'Dashboard (Kong): Running', + 'fill': '#34c759', + 'reset_pods': [], + } + + def _set_cluster_env_message(self, text: str, color: str = '#34c759', clear_after_ms: int | None = None): + """Set a transient status message on the Cluster Environment screen.""" + def _apply(): + if not hasattr(self, '_cluster_env_message_label'): + return + if not self.bg_canvas.winfo_exists(): + return + try: + self.bg_canvas.itemconfig(self._cluster_env_message_label, text=text, fill=color) + except Exception: + pass + self.safe_after(_apply) + + if clear_after_ms is not None and clear_after_ms >= 0: + def _clear(): + if not hasattr(self, '_cluster_env_message_label'): + return + if not self.bg_canvas.winfo_exists(): + return + try: + self.bg_canvas.itemconfig(self._cluster_env_message_label, text="") + except Exception: + pass + self.safe_after(_clear, delay=clear_after_ms) + + def _authority_context_missing(self) -> bool: + """Detect missing authority Docker context when Kerberos is enabled.""" + try: + if not self.kerberos_enabled.get(): + return False + except Exception: + return False + + candidates = [] + env_home = (os.environ.get('PROLE_HOME') or '').strip() + if env_home: + candidates.append(Path(env_home)) + candidates.append(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 _maybe_run_repair_pipeline(self, status: dict, kong_status: dict | None): + """Run repair pipeline when cluster is ready and anomalies are detected.""" + if not status.get('cluster_ok'): + return + + anomalies = [] + if kong_status and not kong_status.get('ok', True): + anomalies.append('dashboard') + if self._authority_context_missing(): + anomalies.append('authority') + + if not anomalies: + return + + now = time.time() + if self._repair_inflight: + return + if now - self._repair_last_run_at < self._repair_cooldown_s: + return + + self._repair_last_run_at = now + self._repair_inflight = True + + def worker(): + try: + env_key = status.get('env') or self._cluster_env_key() + if env_key == 'service': + ns = self._get_service_namespace() + else: + ns = (self.db_namespace.get() or '').strip() or 'default' + env = self._script_env_for_namespace(ns) + if env_key == 'service': + env["PROLE_MODE"] = "k3s" + elif env_key == 'dev': + env["PROLE_MODE"] = "k3d" + self._set_cluster_env_message("Repair pipeline started", '#34c759', clear_after_ms=3000) + self.controller.run_script( + "repair_pipeline.sh", + args=["-n", ns], + env=env, + ) + self._set_cluster_env_message("Repair pipeline complete", '#34c759', clear_after_ms=3000) + except Exception as e: + self._set_cluster_env_message(f"Repair pipeline failed: {e}", '#ff3b30', clear_after_ms=6000) + finally: + self._repair_inflight = False + + threading.Thread(target=worker, daemon=True).start() + + def _check_opentofu_health(self) -> bool: + """Check OpenTofu readiness in the selected namespace.""" + try: + env_key = self._cluster_env_key() + except Exception: + env_key = 'dev' + + try: + if env_key == 'service': + ns = self._get_service_namespace() + else: + ns = (self.db_namespace.get() or '').strip() or 'default' + except Exception: + ns = 'default' + + try: + base_cmd = self._kubectl_base_cmd(env_key) + res_dep = subprocess.run( + base_cmd + ['-n', ns, 'get', 'deploy', 'opentofu', '-o', 'jsonpath={.status.readyReplicas}'], + capture_output=True, text=True, timeout=3 + ) + ready_dep = (res_dep.returncode == 0 and (res_dep.stdout or '0').strip() not in ('', '0')) + if ready_dep: + return True + + res_sts = subprocess.run( + base_cmd + ['-n', ns, 'get', 'statefulset', 'opentofu', '-o', 'jsonpath={.status.readyReplicas}'], + capture_output=True, text=True, timeout=3 + ) + ready_sts = (res_sts.returncode == 0 and (res_sts.stdout or '0').strip() not in ('', '0')) + return ready_sts + except Exception: + return False + + def _check_k8s_cluster(self, env_label: str) -> tuple[bool, str]: + env_key = self._cluster_env_key(env_label) + label = "K3s Cluster" if env_key == 'service' else "Prod Cluster" if env_key == 'prod' else "Kubernetes Cluster" + try: + kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) + if kubectl.returncode != 0: + return False, f"{label}: kubectl not found" + if env_key == 'service': + kubeconfig = _find_kubeconfig_file() + if not kubeconfig: + server, token = self._k3s_connection_info() + if not server or not token: + return False, f"{label}: missing server URL/token or kubeconfig" + cmd = self._kubectl_base_cmd(env_key) + ['cluster-info'] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=8) + ok = res.returncode == 0 + except Exception: + ok = False + msg = f"{label}: Connected" if ok else f"{label}: Not reachable" + return ok, msg + + def _get_k3d_cluster_list(self) -> list[str]: + """Get list of k3d clusters.""" + try: + res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True, timeout=5) + if res.returncode == 0: + # Extract first column (cluster names) + return [line.split()[0] for line in res.stdout.splitlines() if line.strip()] + except Exception: + pass + return [] + + def _on_create_k3d_cluster(self): + name = self.selected_k3d_cluster.get().strip() + if not name: + messagebox.showerror("Error", "Cluster name cannot be empty") + return + # Persist the typed name so the refreshed page keeps it selected + self.k3d_cluster_name.set(name) + + # If the cluster already exists just select it (triggers notebook display) + existing = self._get_k3d_cluster_list() + if name in existing: + self.selected_k3d_cluster.set(name) + self.show_page('init_cluster') + return + + # Refresh the page so the notebook/console is visible before streaming output + self.selected_k3d_cluster.set(name) + self.show_page('init_cluster') + + def do_create(): + console = getattr(self, '_k3s_service_deploy_console', None) + if console: + console.clear() + console.write(f"Creating k3d cluster '{name}' @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") + try: + if self._k3s_service_notebook and self._k3s_service_deploy_tab: + self._k3s_service_notebook.select(self._k3s_service_deploy_tab) + except Exception: + pass + try: + # Build the k3d create command + prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) + volume_args = _k3d_prole_data_volume_args(prole_data) + reg_args = [] + if getattr(self, 'registry_url', None): + if self.registry_url.startswith('localhost:5000'): + reg_args = ['--registry-create', 'prole-registry:0.0.0.0:5000'] + else: + reg_args = ['--registry-use', self.registry_url] + cmd = ['k3d', 'cluster', 'create', name, '-a', '2', '--wait'] + volume_args + reg_args + ['--timestamps'] + if console: + console.write(f"$ {' '.join(cmd)}\n\n") + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=PROJECT_ROOT) + for line in proc.stdout: + if console: + console.write(line) + proc.wait() + if proc.returncode != 0: + if console: + console.write(f"\nk3d cluster create failed (exit {proc.returncode})\n") + return + if console: + console.write(f"\nCluster '{name}' created successfully.\n\n") + + # Merge kubeconfig so kubectl can reach the new cluster + try: + subprocess.run( + ['k3d', 'kubeconfig', 'merge', name, '--kubeconfig-switch-context'], + capture_output=True, text=True, timeout=10 + ) + if console: + console.write(f"Kubeconfig merged for cluster '{name}'.\n\n") + except Exception as kce: + if console: + console.write(f"Warning: failed to merge kubeconfig: {kce}\n") + + # Refresh UI and then deploy common services check + self.safe_after(self._refresh_k3d_ui) + # Trigger common services check in the deploy console + self.safe_after(self._deploy_k3s_services) + except Exception as e: + if console: + console.write(f"\nError: {e}\n") + self.safe_after(lambda: messagebox.showerror("Error", f"Failed to create k3d cluster: {e}")) + + threading.Thread(target=do_create, daemon=True).start() + + def _refresh_k3d_ui(self): + clusters = self._get_k3d_cluster_list() + self.k3d_cluster_list.set(clusters) + name = self.k3d_cluster_name.get().strip() + if name in clusters: + self.selected_k3d_cluster.set(name) + self.show_page('init_cluster') + + def _on_k3d_cluster_select(self, *args): + """Called when a k3d cluster is selected from the dropdown.""" + cluster_name = self.selected_k3d_cluster.get() + if cluster_name: + try: + # Merge kubeconfig and switch context to the selected cluster + subprocess.run(['k3d', 'kubeconfig', 'merge', cluster_name, '--switch-context'], capture_output=True, text=True) + except Exception: + pass + self.show_page('init_cluster') + + def _on_delete_k3d_cluster(self): + """Delete the selected k3d cluster after user confirmation.""" + name = self.selected_k3d_cluster.get().strip() + if not name: + messagebox.showerror("Error", "No cluster selected to delete.") + return + + confirmed = messagebox.askyesno("Confirm Delete", f"Are you sure you want to delete cluster '{name}'?") + if not confirmed: + return + + # Show the notebook/console before streaming output + self.show_page('init_cluster') + + def do_delete(): + console = getattr(self, '_k3s_service_deploy_console', None) + if console: + console.clear() + console.write(f"Deleting k3d cluster '{name}' @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") + try: + if self._k3s_service_notebook and self._k3s_service_deploy_tab: + self._k3s_service_notebook.select(self._k3s_service_deploy_tab) + except Exception: + pass + try: + cmd = ['k3d', 'cluster', 'delete', name] + if console: + console.write(f"$ {' '.join(cmd)}\n\n") + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=PROJECT_ROOT) + for line in proc.stdout: + if console: + console.write(line) + proc.wait() + if proc.returncode != 0: + if console: + console.write(f"\nk3d cluster delete failed (exit {proc.returncode})\n") + return + if console: + console.write(f"\nCluster '{name}' deleted successfully.\n") + + # Clear selection and refresh UI + self.selected_k3d_cluster.set('') + self.k3d_cluster_name.set('') + self.safe_after(self._refresh_k3d_ui) + except Exception as e: + if console: + console.write(f"\nError: {e}\n") + self.safe_after(lambda: messagebox.showerror("Error", f"Failed to delete k3d cluster: {e}")) + + threading.Thread(target=do_delete, daemon=True).start() + + def _get_kubectx_list(self) -> list[str]: + """Get list of kubernetes contexts.""" + try: + res = subprocess.run(["kubectx"], capture_output=True, text=True) + if res.returncode == 0: + return res.stdout.strip().split('\n') + + # Fallback to kubectl + res = subprocess.run(["kubectl", "config", "get-contexts", "-o", "name"], capture_output=True, text=True) + if res.returncode == 0: + return res.stdout.strip().split('\n') + except Exception: + pass + return ["default"] + + def _validate_and_save_cluster_config(self) -> bool: + """Verify the config then write the values to prole.cfg.""" + env_key = self._cluster_env_key() + if env_key == 'dev': + cluster_val = self.selected_k3d_cluster.get() or 'prole-dev-cluster' + if not cluster_val: + messagebox.showwarning("Validation", "Please select or create a k3d cluster.") + return False + + # Context switch (if context matches cluster name) + try: + subprocess.run(['kubectl', 'config', 'use-context', f"k3d-{cluster_val}"], capture_output=True, text=True) + except Exception: + pass + elif env_key == 'service': + if not (self.k3s_server_url.get() or '').strip(): + messagebox.showwarning("Validation", "Please specify the K3s server URL.") + return False + if not (self.k3s_token.get() or '').strip(): + messagebox.showwarning("Validation", "Please specify the K3s token.") + return False + cluster_val = self.cluster_env.get() + elif env_key == 'prod': + path = self.prod_artifacts_path.get().strip() + if not path: + messagebox.showwarning("Validation", "Please specify an artifact staging directory.") + return False + p = Path(path).expanduser() + if not p.exists(): + try: + p.mkdir(parents=True, exist_ok=True) + except Exception as e: + messagebox.showerror("Error", f"Failed to create directory {path}: {e}") + return False + cluster_val = self.cluster_env.get() + else: + cluster_val = self.cluster_env.get() + + # Capture cluster info for config + self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = cluster_val + self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = (self.k3s_server_url.get() or '').strip() + self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') + self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = self._get_service_namespace() + self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) + self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) + self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = str(self.at_rest_encryption_enabled.get()) + + if env_key == 'prod': + self.prole_cfg_data['Prod Cluster (k8s)']['STAGING_DIRECTORY'] = self.prod_artifacts_path.get() + + # Save config + self._save_prole_cfg() + return True + + def _cluster_status_snapshot(self) -> dict: + env = self._cluster_env_key() + + cluster_ok = False + if env == 'dev': + cluster_name = self.selected_k3d_cluster.get() or "prole-dev-cluster" + try: + res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) + cluster_ok = cluster_name in (res.stdout or '') + except Exception: + cluster_ok = False + cluster_msg = f"K3D Cluster ({cluster_name}): Running" if cluster_ok else f"K3D Cluster ({cluster_name}): Not found" + else: + cluster_ok, cluster_msg = self._check_k8s_cluster(env) + cluster_fill = '#34c759' if cluster_ok else '#ff9f0a' + + common_ok = cluster_ok + common_msg = 'Common Services: Ready to deploy' if common_ok else 'Common Services: Not ready' + common_fill = '#34c759' if common_ok else '#ff9f0a' + + return { + 'env': env, + 'cluster_ok': cluster_ok, + 'cluster_msg': cluster_msg, + 'cluster_fill': cluster_fill, + 'common_ok': common_ok, + 'common_msg': common_msg, + 'common_fill': common_fill, + } + + def _cluster_ready_for_navigation(self) -> bool: + status = self._cluster_status_snapshot() + if not status['cluster_ok']: + try: + messagebox.showerror('Cluster', 'Cluster is not reachable. Please verify your cluster and try again.') + except Exception: + pass + return False + return True + + def check_cluster_status_async(self): + def worker(): + notice_active = False + self._set_cluster_env_message("Collect Status", '#34c759') + status = self._cluster_status_snapshot() + self._set_cluster_env_message("Verify State", '#34c759') + kong_status = None + if status.get('cluster_ok'): + kong_status = self._dashboard_kong_status(status.get('env')) + self._set_cluster_env_message("Assess Actions", '#34c759') + + # Repair step: reset Dashboard (Kong) pods if unhealthy, with cooldown + if kong_status and kong_status.get('reset_pods'): + now = time.time() + if now - self._kong_last_repair_at >= self._kong_repair_cooldown_s: + self._set_cluster_env_message("Execute Actions (REPAIR): reset Dashboard (Kong) pod(s)", '#34c759') + pods = kong_status.get('reset_pods') or [] + cmd = self._kubectl_base_cmd(status.get('env')) + ['delete', 'pod', '-n', 'kubernetes-dashboard'] + pods + rc, _out = self._run_cmd_capture(cmd, timeout=10) + if rc != 0: + self._set_cluster_env_message("Notice: Dashboard (Kong) repair failed", '#ff3b30', clear_after_ms=6000) + notice_active = True + else: + self._kong_last_repair_at = now + # Give the cluster a moment to recreate pods before re-check + time.sleep(2) + self._set_cluster_env_message("Collect Status", '#34c759') + status = self._cluster_status_snapshot() + else: + self._set_cluster_env_message("Execute Actions (REPAIR): skipped (recently attempted)", '#34c759', clear_after_ms=1500) + else: + self._set_cluster_env_message("Execute Actions (REPAIR): none needed", '#34c759', clear_after_ms=1500) + + # Run the repair pipeline if anomalies are detected on a ready cluster + try: + self._maybe_run_repair_pipeline(status, kong_status) + except Exception: + pass + + def update_ui(): + if hasattr(self, 'k3d_status_label'): + self.bg_canvas.itemconfig(self.k3d_status_label, text=status['cluster_msg'], fill=status['cluster_fill']) + if hasattr(self, 'common_services_status_label'): + self.bg_canvas.itemconfig(self.common_services_status_label, text=status['common_msg'], fill=status['common_fill']) + if hasattr(self, '_init_cluster_button'): + try: + self._init_cluster_button.configure(text='Save') + except Exception: + pass + + self.root.after(0, update_ui) + # Clear message after a short delay if no notice is active + if not notice_active: + self._set_cluster_env_message("", '#34c759', clear_after_ms=2000) + + threading.Thread(target=worker, daemon=True).start() + + def _services_status_snapshot(self) -> dict: + openbao_ok = self._check_openbao_health() + opentofu_ok = self._check_opentofu_health() + + openbao_msg = 'OpenBao: Running' if openbao_ok else 'OpenBao: Not reachable' + opentofu_msg = 'OpenTofu: Running' if opentofu_ok else 'OpenTofu: Not reachable' + + openbao_fill = '#34c759' if openbao_ok else '#ff9f0a' + opentofu_fill = '#34c759' if opentofu_ok else '#ff9f0a' + + return { + 'openbao_msg': openbao_msg, + 'openbao_fill': openbao_fill, + 'opentofu_msg': opentofu_msg, + 'opentofu_fill': opentofu_fill, + } + + def check_services_status_async(self): + def worker(): + status = self._services_status_snapshot() + + def update_ui(): + if hasattr(self, '_db_openbao_status_label'): + self.bg_canvas.itemconfig(self._db_openbao_status_label, text=status['openbao_msg'], fill=status['openbao_fill']) + if hasattr(self, '_db_opentofu_status_label'): + self.bg_canvas.itemconfig(self._db_opentofu_status_label, text=status['opentofu_msg'], fill=status['opentofu_fill']) + + self.root.after(0, update_ui) + + threading.Thread(target=worker, daemon=True).start() + + def ensure_cluster_ready(self): + self._action_flags['init_cluster.start_cluster'] = True + # Implementation of cluster creation/startup + def worker(): + cluster_env = self._cluster_env_key() + + if cluster_env == 'dev': + # 1. Start Docker if not running + if not self.controller.check_docker_running(): + # Attempt to start Docker on macOS + subprocess.run(['open', '-a', 'Docker'], capture_output=True) + # Wait for it to start + for _ in range(30): + time.sleep(2) + if self.controller.check_docker_running(): + break + + if not self.controller.check_docker_running(): + self.root.after(0, lambda: messagebox.showerror('Docker', 'Could not start Docker. Please start it manually.')) + return + + # 2. Manage or verify cluster + 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: + subprocess.run(['k3d', 'cluster', 'delete', cluster_name], capture_output=True) + res_stdout = '' + self._reset_cluster = False + if cluster_name not in res_stdout: + # Create it + # Default args based on README.md + prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) + volume_args = _k3d_prole_data_volume_args(prole_data) + cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2'] + volume_args + reg_args = [] + try: + if self.ensure_local_registry_available(): + reg_args = ['--registry-use', 'k3d-prole-registry:5000'] + except Exception: + reg_args = [] + cmd += reg_args + ['--api-port', '0.0.0.0:6443'] + + # Run in terminal or capture output? Let's use a console window later. + # For now, run it and update status. + subprocess.run(cmd, capture_output=True) + else: + # Start it if it's stopped + subprocess.run(['k3d', 'cluster', 'start', cluster_name], capture_output=True) + + self.check_cluster_status_async() + self.root.after(0, lambda: messagebox.showinfo('Cluster', f'Cluster {cluster_name} is ready.')) + else: + if self._reset_cluster and cluster_env in ('service', 'k3s'): + ns = (self._get_service_namespace() or '').strip() or 'default' + server, token = self._k3s_connection_info() + _reset_k3s_namespace(self.controller.project_root, ns, server, token) + self._reset_cluster = False + ok, msg = self._check_k8s_cluster(cluster_env) + if not ok: + self.root.after(0, lambda: messagebox.showerror('Cluster', msg)) + return + self.check_cluster_status_async() + self.root.after(0, lambda: messagebox.showinfo('Cluster', msg)) + + threading.Thread(target=worker, daemon=True).start() + + def _verify_k3s_services(self): + if not self._k3s_service_status_console: + self._stop_k3s_service_status_updates() + return + self._start_k3s_service_status_updates() + + def _start_k3s_service_status_updates(self): + if not self._k3s_status_active: + self._k3s_status_active = True + if self._k3s_status_after_id: + try: + self.root.after_cancel(self._k3s_status_after_id) + except Exception: + pass + self._k3s_status_after_id = None + self._schedule_k3s_service_status_update(0) + + def _stop_k3s_service_status_updates(self): + self._k3s_status_active = False + self._k3s_status_inflight = False + if self._k3s_status_after_id: + try: + self.root.after_cancel(self._k3s_status_after_id) + except Exception: + pass + self._k3s_status_after_id = None + + def _schedule_k3s_service_status_update(self, delay_ms: int): + if not self._k3s_status_active: + return + if not self.root or not self.root.winfo_exists(): + return + try: + self._k3s_status_after_id = self.root.after(delay_ms, self._run_k3s_service_status_once) + except Exception: + self._k3s_status_after_id = None + + def _run_k3s_service_status_once(self): + if self._k3s_status_inflight: + return + if not self._k3s_service_status_console: + self._k3s_status_inflight = False + self.safe_after(lambda: self._schedule_k3s_service_status_update(30000)) + return + self._k3s_status_inflight = True + console = self._k3s_service_status_console + console.clear() + console.write(f"Service status refresh @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") + + def worker(): + try: + ns = self._get_service_namespace() + env = self._script_env_for_namespace(ns) + env["PROLE_MODE"] = self._deployment_mode() + + if not env.get("KUBECONFIG"): + console.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") + return + + status_args = ["-n", ns] + if self.kerberos_enabled.get(): + status_args.append("-k") + rc = self.controller.run_script( + "status_common_services.sh", + args=status_args, + env=env, + on_line=lambda l: console.write(l) + ) + console.write(f"\nExit status: {rc}\n") + finally: + self._k3s_status_inflight = False + self.safe_after(lambda: self._schedule_k3s_service_status_update(30000)) + + threading.Thread(target=worker, daemon=True).start() + + def _deploy_k3s_services(self): + """Deploy common services (ArgoCD/OpenBao/OpenTofu/Garage) to remote k3s cluster.""" + def _deploy(): + console = self._k3s_service_deploy_console + if console: + console.clear() + console.write(f"Deploying common services @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") + try: + if self._k3s_service_notebook and self._k3s_service_deploy_tab: + self._k3s_service_notebook.select(self._k3s_service_deploy_tab) + except Exception: + pass + + service_ns = self._get_service_namespace() + env = self._script_env_for_namespace(service_ns) + env["PROLE_MODE"] = self._deployment_mode() + log_path = self._common_services_log_path() + env["COMMON_SERVICES_INIT_LOG"] = str(log_path) + try: + log_fp = log_path.open('a', encoding='utf-8') + log_fp.write(f"\n# Common services deploy @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") + log_fp.flush() + except Exception: + log_fp = None + if console: + console.write(f"Log file: {log_path}\n\n") + + if not env.get("KUBECONFIG"): + if console: + console.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") + if log_fp: + try: + log_fp.write("KUBECONFIG not generated. Check cluster credentials in prole.cfg.\n") + log_fp.close() + except Exception: + pass + self._verify_k3s_services() + return + + try: + common_args = ["-n", env["NAMESPACE"]] + if self.kerberos_enabled.get(): + common_args.append("-k") + common_args.append("update") + def _log_line(line: str): + if console: + console.write(line) + if log_fp: + try: + log_fp.write(line) + log_fp.flush() + except Exception: + pass + rc = self.controller.run_script( + "init_common_services.sh", + args=common_args, + env=env, + on_line=_log_line + ) + if console: + console.write(f"\nExit status: {rc}\n") + if log_fp: + try: + log_fp.write(f"\nExit status: {rc}\n") + except Exception: + pass + finally: + if log_fp: + try: + log_fp.close() + except Exception: + pass + + self._verify_k3s_services() + + threading.Thread(target=_deploy, daemon=True).start() + + def _k3s_connection_info(self) -> tuple[str, str]: + server = (self.k3s_server_url.get() or os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() + token = (self.k3s_token.get() or os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() + if token: + token = self._resolve_secret_value(token) + token = _normalize_k3s_token(token) + if server and not server.startswith('http'): + server = f"https://{server}" + return server, token + + def _k3s_connection_raw(self) -> tuple[str, str]: + """Return (server_url, token) without attempting secret resolution.""" + server = (self.k3s_server_url.get() or os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() + token = (self.k3s_token.get() or os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() + if server and not server.startswith('http'): + server = f"https://{server}" + return server, token + + def _script_env_for_namespace(self, namespace: str) -> dict: + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + env["PROLE_DB_USER"] = self.db_username.get().strip() + if (self.db_password.get() or '').strip(): + env["DB_PASSWORD"] = self.db_password.get().strip() + env["OPENTOFU_ADMIN_PASSWORD"] = self.db_password.get().strip() + env["NAMESPACE"] = namespace + env["SERVICE_NAMESPACE"] = self._get_service_namespace() + mode = self._deployment_mode() + if mode: + env["PROLE_MODE"] = mode + env["DEPLOYMENT_MODE"] = mode + env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) + if self.kerberos_realm.get().strip(): + env["KRB5_REALM"] = self.kerberos_realm.get().strip() + env["REALM"] = self.kerberos_realm.get().strip() + env["DOMAIN"] = self.kerberos_realm.get().strip().lower() + if self.kerberos_kdc.get().strip(): + env["KRB5_KDC"] = self.kerberos_kdc.get().strip() + env["KRB5_ADMIN"] = self.kerberos_kdc.get().strip() + if self.kerberos_user.get().strip(): + env["KRB5_USER"] = self.kerberos_user.get().strip() + if self.kerberos_password.get().strip(): + env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() + if self._cluster_env_key() == 'dev': + # For k3d dev clusters, use the default kubeconfig (~/.kube/config) + if not (env.get("KUBECONFIG") or "").strip(): + cluster_name = (self.selected_k3d_cluster.get() or '').strip() + if cluster_name: + # Ensure k3d kubeconfig is merged and context is switched + try: + subprocess.run( + ['k3d', 'kubeconfig', 'merge', cluster_name, '--kubeconfig-switch-context'], + capture_output=True, text=True, timeout=10 + ) + except Exception: + pass + default_kube = str(Path.home() / '.kube' / 'config') + if Path(default_kube).exists(): + env["KUBECONFIG"] = default_kube + elif self._cluster_env_key() == 'service': + raw_server, raw_token = self._k3s_connection_raw() + if raw_server: + env["PROLE_K3S_SERVER"] = raw_server + if raw_token: + env["PROLE_K3S_TOKEN"] = raw_token + + k3s_server, k3s_token = self._k3s_connection_info() + explicit_kubeconfig = (env.get("KUBECONFIG") or "").strip() + if explicit_kubeconfig and not os.path.exists(explicit_kubeconfig): + explicit_kubeconfig = "" + managed_set = False + + if k3s_server and k3s_token and _looks_like_k8s_bearer_token(k3s_token) and not explicit_kubeconfig: + # Always generate a fresh kubeconfig to ensure no environment leakage + 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(k3s_server, k3s_token)) + env["KUBECONFIG"] = self._managed_kubeconfig + managed_set = True + except Exception as e: + print(f"[WARN] Failed to generate managed KUBECONFIG: {e}") + + if not managed_set: + fallback = _find_kubeconfig_file(env) + if fallback: + env["KUBECONFIG"] = fallback + + # Registry settings + 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 + + def _k3s_kubectl_base_cmd(self) -> list[str]: + if self._managed_kubeconfig and os.path.exists(self._managed_kubeconfig): + return ["kubectl", "--kubeconfig", self._managed_kubeconfig] + + server, token = self._k3s_connection_info() + if server and token: + # Token is already resolved by _k3s_connection_info + return [ + "kubectl", + "--server=" + server, + "--token=" + token, + "--insecure-skip-tls-verify=true" + ] + + kubeconfig = _find_kubeconfig_file() + if kubeconfig: + return ["kubectl", "--kubeconfig", kubeconfig] + return ["kubectl"] + + def _kubectl_base_cmd(self, env: str | None = None) -> list[str]: + env_key = self._cluster_env_key(env) + if env_key == 'service': + return self._k3s_kubectl_base_cmd() + + kubeconfig_env = (os.environ.get("KUBECONFIG") or "").strip() + if kubeconfig_env: + return ["kubectl", "--kubeconfig", kubeconfig_env] + + if env_key == 'prod': + kubeconfig = _find_kubeconfig_file() + if kubeconfig: + return ["kubectl", "--kubeconfig", kubeconfig] + + return ["kubectl"] diff --git a/installer/ui/screens/database.py b/installer/ui/screens/database.py new file mode 100644 index 0000000..e2a6b6c --- /dev/null +++ b/installer/ui/screens/database.py @@ -0,0 +1,809 @@ +"""Database creation, namespace management and DB build screens.""" + +import json +import os +import re +import shutil +import subprocess +import threading +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import screen as ui +from installer.core.env import ( + NAMESPACE_PREFIX, + POSTGRES_DB_NAME_MAX_LEN, + PROJECT_ROOT, + _bool_str, + _push_docker_image, + get_resource_path, +) + + +class DatabaseScreenMixin: + """Database creation, namespace management and DB build screens.""" + + def _render_init_password_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Database Creation', y=150) + self._render_paragraph('Create a database namespace for this deployment. The namespace is treated as a single database with a name and one root password. An ed25519 SSH key will be generated and stored in OpenBao for root access.', y=200) + + x_label = 48 + x_field = 300 + y = 270 + + # Database (namespace) name + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Database Name (Namespace):', fill='black', font=('SF Pro Text', 12, 'bold'))) + + ns_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#CCCCCC', highlightthickness=1, bd=0) + ns_frame.pack_propagate(False) + prefix_label = tk.Label(ns_frame, text=self._namespace_prefix(), fg='#9a9aa0', bg='white', font=('SF Pro Text', 11)) + vcmd = (self.root.register(self._validate_namespace_suffix), '%P') + suffix_entry = tk.Entry( + ns_frame, + textvariable=self.db_namespace_suffix, + bg='white', + fg='black', + insertbackground='black', + highlightthickness=0, + relief='flat', + font=('SF Pro Text', 11), + validate='key', + validatecommand=vcmd + ) + prefix_label.pack(side='left', padx=(8, 2)) + suffix_entry.pack(side='left', fill='both', expand=True, padx=(0, 8)) + ns_window = self.bg_canvas.create_window(x_field, y-12, window=ns_frame, anchor='nw', width=400, height=32) + self._canvas_items.append(ns_window) + self._overlay_widgets.append(ns_frame) + self._overlay_widgets.append(prefix_label) + self._overlay_widgets.append(suffix_entry) + + y += 42 + # Owner (local user) + self.db_username.set(self.namespace_owner) + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Owner:', fill='black', font=('SF Pro Text', 12, 'bold'))) + owner_entry = tk.Entry(self.bg_canvas, textvariable=self.db_username, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + owner_window = self.bg_canvas.create_window(x_field, y-12, window=owner_entry, anchor='nw', width=400, height=32) + self._canvas_items.append(owner_window) + self._overlay_widgets.append(owner_entry) + + y += 42 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Master Password:', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Use tk.Entry on canvas + p1 = tk.Entry(self.bg_canvas, textvariable=self.db_password, show='*', bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + p1_window = self.bg_canvas.create_window(x_field, y-12, window=p1, anchor='nw', width=400, height=32) + self._canvas_items.append(p1_window) + self._overlay_widgets.append(p1) + + y += 42 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Confirm:', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Use tk.Entry on canvas + p2 = tk.Entry(self.bg_canvas, textvariable=self.db_password_confirm, show='*', bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + p2_window = self.bg_canvas.create_window(x_field, y-12, window=p2, anchor='nw', width=400, height=32) + self._canvas_items.append(p2_window) + self._overlay_widgets.append(p2) + + y += 42 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Host Port Forward:', fill='black', font=('SF Pro Text', 12, 'bold'))) + port_entry = tk.Entry(self.bg_canvas, textvariable=self.db_host_port, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=100, height=32) + self._canvas_items.append(port_window) + self._overlay_widgets.append(port_entry) + + # Indicator for password match (X or ✓) + self.password_indicator = ui.canvas_text(self, x_field + 410, y, '✘', fill='#dc3545', font=('SF Pro Text', 16, 'bold'), state='hidden') + self._canvas_items.append(self.password_indicator) + + # Optional Features moved above browser + y += 50 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Optional Features:', fill='black', font=('SF Pro Text', 13, 'bold'))) + + feature_options = [ + ('Enable Supabase', self.supabase_enabled), + ('Enable Kerberos Authentication', self.kerberos_enabled), + ('Enable At-Rest Encryption', self.at_rest_encryption_enabled), + ] + + fx = x_label + 20 + fy = y + 36 + for label, var in feature_options: + cb = tk.Checkbutton(self.bg_canvas, text=label, variable=var, + bg='white', fg='black', activebackground='white', + selectcolor='white', font=('SF Pro Text', 11), + command=self._save_prole_cfg) + cb_window = self.bg_canvas.create_window(fx, fy, window=cb, anchor='nw') + self._canvas_items.append(cb_window) + self._overlay_widgets.append(cb) + fx += 280 + + # Database browser + table_y = fy + 50 + self._canvas_items.append(ui.canvas_text(self, x_label, table_y, 'Database Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))) + + refresh_btn = tk.Button(self.bg_canvas, text='Refresh', command=self._refresh_namespace_table, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) + refresh_window = self.bg_canvas.create_window(x_label + 160, table_y - 8, window=refresh_btn, anchor='nw') + self._canvas_items.append(refresh_window) + self._overlay_widgets.append(refresh_btn) + + add_btn = tk.Button(self.bg_canvas, text='Add', command=self._db_add_namespace, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) + add_window = self.bg_canvas.create_window(x_label + 240, table_y - 8, window=add_btn, anchor='nw') + self._canvas_items.append(add_window) + self._overlay_widgets.append(add_btn) + + edit_btn = tk.Button(self.bg_canvas, text='Edit', command=self._db_edit_namespace, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) + edit_window = self.bg_canvas.create_window(x_label + 300, table_y - 8, window=edit_btn, anchor='nw') + self._canvas_items.append(edit_window) + self._overlay_widgets.append(edit_btn) + + delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._db_delete_namespace, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) + delete_window = self.bg_canvas.create_window(x_label + 360, table_y - 8, window=delete_btn, anchor='nw') + self._canvas_items.append(delete_window) + self._overlay_widgets.append(delete_btn) + + self._db_action_buttons = [refresh_btn, add_btn, edit_btn, delete_btn] + + table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1) + table_window = self.bg_canvas.create_window(x_label, table_y + 30, window=table_frame, anchor='nw', width=900, height=180) + self._canvas_items.append(table_window) + self._overlay_widgets.append(table_frame) + + columns = ('select', 'name', 'owner', 'port', 'created', 'status') + tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=5) + tree.heading('select', text='Select') + tree.heading('name', text='Name') + tree.heading('owner', text='Owner') + tree.heading('port', text='Host Port') + tree.heading('created', text='Creation Date') + tree.heading('status', text='Status') + tree.column('select', width=60, anchor='center') + tree.column('name', width=180, anchor='w') + tree.column('owner', width=140, anchor='w') + tree.column('port', width=80, anchor='center') + tree.column('created', width=200, anchor='w') + tree.column('status', width=120, anchor='center') + tree.pack(side='left', fill='both', expand=True) + + scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview) + tree.configure(yscrollcommand=scroll.set) + scroll.pack(side='right', fill='y') + + self._overlay_widgets.append(tree) + self._overlay_widgets.append(scroll) + self._db_namespace_tree = tree + + def on_select(event): + if getattr(self, '_refreshing_ns_table', False): + return + sel = tree.selection() + if sel: + vals = tree.item(sel[0], 'values') + if vals: + ns = vals[1] + if ns == 'supabase': + # Do not allow selecting 'supabase' as the primary prole-db namespace + return + if ns == self.db_namespace.get(): + # Already selected, don't trigger re-refresh/save + return + self.db_namespace.set(ns) + self._sync_namespace_suffix_from_full() + # Mark as selected in tree visually if needed, but we use the checkbox column + self._refresh_namespace_table() + self._save_prole_cfg() + if self.supabase_enabled.get(): + self._db_sync_supabase_ports(ns) + + tree.bind('<>', on_select) + + self._db_namespace_note = ui.canvas_text(self, x_label, table_y + 220, '', fill='#6e6e73', font=('SF Pro Text', 10)) + self._canvas_items.append(self._db_namespace_note) + + def on_password_change(*args): + p = self.db_password.get() + c = self.db_password_confirm.get() + + if not p: + self.bg_canvas.itemconfigure(self.password_indicator, state='hidden') + elif p == c: + self.bg_canvas.itemconfigure(self.password_indicator, text='✓', fill='#28a745', state='normal') + else: + self.bg_canvas.itemconfigure(self.password_indicator, text='✘', fill='#dc3545', state='normal') + + self.db_password.trace_add('write', on_password_change) + self.db_password_confirm.trace_add('write', on_password_change) + + # Add trace to host port to update mappings immediately + def on_port_change(*args): + try: + self._save_prole_cfg() + except: + pass + self.db_host_port.trace_add('write', on_port_change) + + # Trigger once in case they are already set + on_password_change() + + self._refresh_namespace_table() + + def _namespace_prefix(self) -> str: + return NAMESPACE_PREFIX + + def _max_namespace_suffix_len(self) -> int: + return max(0, POSTGRES_DB_NAME_MAX_LEN - len(self._namespace_prefix())) + + def _strip_namespace_prefix(self, name: str) -> str: + prefix = self._namespace_prefix() + if name.startswith(prefix): + return name[len(prefix):] + return name + + def _ensure_namespace_prefix(self, name: str) -> str: + cleaned = (name or '').strip() + prefix = self._namespace_prefix() + if not cleaned: + return prefix + if cleaned.startswith(prefix): + return cleaned + return f"{prefix}{cleaned}" + + def _sync_namespace_suffix_from_full(self): + if self._updating_namespace_fields: + return + self._updating_namespace_fields = True + try: + full = (self.db_namespace.get() or '').strip() + full = self._ensure_namespace_prefix(full) + if full != self.db_namespace.get(): + self.db_namespace.set(full) + self.db_namespace_suffix.set(self._strip_namespace_prefix(full)) + finally: + self._updating_namespace_fields = False + + def _sync_namespace_full_from_suffix(self): + if self._updating_namespace_fields: + return + self._updating_namespace_fields = True + try: + suffix = (self.db_namespace_suffix.get() or '').strip() + self.db_namespace.set(f"{self._namespace_prefix()}{suffix}") + finally: + self._updating_namespace_fields = False + + def _validate_namespace_suffix(self, proposed: str) -> bool: + return len(proposed) <= self._max_namespace_suffix_len() + + def _collect_namespace_rows(self): + owner = self.namespace_owner or self._get_local_owner() + ns_names = [] + notice = "" + kubectl_ok = False + + try: + base_cmd = self._kubectl_base_cmd() + result = subprocess.run( + base_cmd + ['get', 'ns', '-o', 'json'], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + kubectl_ok = True + data = json.loads(result.stdout or "{}") + for item in data.get('items', []): + name = item.get('metadata', {}).get('name') + if name: + ns_names.append(name) + else: + notice = (result.stderr or '').strip() or "kubectl returned a non-zero status." + except FileNotFoundError: + notice = "kubectl not found; showing local defaults." + except Exception as e: + notice = f"Unable to query namespaces: {e}" + + current_ns = (self.db_namespace.get() or '').strip() + prefix = self._namespace_prefix() + ns_names = [name for name in ns_names if name.startswith(prefix) or name == 'supabase'] + if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase') and current_ns not in ns_names: + ns_names.append(current_ns) + if not ns_names: + if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase'): + ns_names = [current_ns] + else: + ns_names = [] + + pods_by_ns = {} + if kubectl_ok: + try: + base_cmd = self._kubectl_base_cmd() + pods_result = subprocess.run( + base_cmd + ['get', 'pods', '--all-namespaces', '-o', 'json'], + capture_output=True, + text=True, + timeout=8 + ) + if pods_result.returncode == 0: + pods_data = json.loads(pods_result.stdout or "{}") + for pod in pods_data.get('items', []): + meta = pod.get('metadata', {}) + status = pod.get('status', {}) + ns = meta.get('namespace') + if not ns: + continue + entry = pods_by_ns.setdefault(ns, {'starts': [], 'running': False}) + start_time = status.get('startTime') + if start_time: + entry['starts'].append(start_time) + if status.get('phase') == 'Running': + entry['running'] = True + else: + notice = notice or (pods_result.stderr or '').strip() or notice + except Exception: + # Best effort only + pass + + rows = [] + for ns in sorted(set(ns_names)): + entry = pods_by_ns.get(ns, {'starts': [], 'running': False}) + oldest = None + if entry['starts']: + parsed = [self._parse_rfc3339(ts) for ts in entry['starts']] + parsed = [p for p in parsed if p is not None] + if parsed: + oldest_dt = min(parsed) + oldest = oldest_dt.isoformat() + else: + oldest = min(entry['starts']) + creation = self._format_pod_time(oldest) if oldest else '—' + status = 'active' if entry['running'] else 'inactive' + + # Best effort to find port + port = '—' + if ns == 'supabase': + port = '5432' + else: + # For prole-db namespaces + if ns == (self.db_namespace.get() or '').strip(): + if self.supabase_enabled.get(): + port = '15432' + else: + port = self.db_host_port.get() or '5432' + else: + # If it's another prole-db namespace, we might not know its port easily + # but if Supabase is enabled globally, we assume standard alternate port + if self.supabase_enabled.get(): + port = '15432' + else: + port = '5432' + + rows.append((ns, owner, port, creation, status)) + + return rows, notice + + def _refresh_namespace_table(self): + if getattr(self, '_refreshing_ns_table', False): + return + self._refreshing_ns_table = True + + def worker(): + try: + rows, notice = self._collect_namespace_rows() + current_ns = (self.db_namespace.get() or '').strip() + + def update_ui(): + tree = getattr(self, '_db_namespace_tree', None) + if not tree: + return + for item in tree.get_children(): + tree.delete(item) + + for row in rows: + # Add checkbox-like symbol for selection + ns_name = row[0] + prefix = ' [✓] ' if ns_name == current_ns else ' [ ] ' + display_row = (prefix,) + row + tree.insert('', 'end', values=display_row) + + if current_ns: + for item in tree.get_children(): + vals = tree.item(item, 'values') + if vals and vals[1] == current_ns: + tree.selection_set(item) + tree.see(item) + break + + note_item = getattr(self, '_db_namespace_note', None) + if note_item and self.bg_canvas.winfo_exists(): + msg = notice if notice else "" + self.bg_canvas.itemconfig(note_item, text=msg) + + self.safe_after(update_ui) + finally: + self._refreshing_ns_table = False + + threading.Thread(target=worker, daemon=True).start() + + def _db_set_status(self, message: str, color: str = '#6e6e73'): + note_item = getattr(self, '_db_namespace_note', None) + if note_item and self.bg_canvas.winfo_exists(): + self.bg_canvas.itemconfig(note_item, text=message or "", fill=color) + + def _db_set_buttons_state(self, state: str): + buttons = getattr(self, '_db_action_buttons', None) + if not buttons: + return + for btn in buttons: + try: + if btn.winfo_exists(): + btn.configure(state=state) + except Exception: + pass + + def _db_selected_namespace(self) -> str: + tree = getattr(self, '_db_namespace_tree', None) + if not tree: + return "" + sel = tree.selection() + if not sel: + return "" + vals = tree.item(sel[0], 'values') + if not vals: + return "" + ns = vals[1] + if ns == 'supabase': + return "" + return ns + + def _db_sync_supabase_ports(self, namespace): + if not self.supabase_enabled.get(): + return + + script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh" + if not script.exists(): + return + + def worker(): + try: + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + # Best effort: use kubectl context if selected + if hasattr(self, 'selected_kubectx') and self.selected_kubectx.get(): + env["KUBECONFIG"] = env.get("KUBECONFIG", "") # Ensure it's passed if set + + subprocess.run(["bash", str(script), "-n", namespace], + cwd=str(PROJECT_ROOT), env=env, + capture_output=True, text=True) + except: + pass + + threading.Thread(target=worker, daemon=True).start() + + def _db_action_log_path(self) -> Path: + logs_dir = PROJECT_ROOT / "logs" + try: + logs_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + return logs_dir / "db-actions.log" + + def _db_log(self, text: str): + try: + with self._db_action_log_path().open('a', encoding='utf-8') as fp: + fp.write(text) + if not text.endswith('\n'): + fp.write('\n') + except Exception: + pass + + def _db_add_namespace(self): + ns = (self.db_namespace.get() or '').strip() + if not ns: + messagebox.showerror('Database Name', 'Database namespace cannot be empty.') + return + if not self._is_valid_namespace(ns): + messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.') + return + self.db_namespace.set(ns) + + def worker(): + self.safe_after(lambda: self._db_set_buttons_state('disabled')) + self.safe_after(lambda: self._db_set_status(f"Creating namespace {ns}...", '#1d1d1f')) + cmd = self._kubectl_base_cmd() + ['create', 'namespace', ns] + rc, out = self._run_cmd_capture(cmd) + if rc != 0 and 'AlreadyExists' not in out: + self._db_log(out) + self.safe_after(self._refresh_namespace_table) + self.safe_after(lambda: self._db_set_status(f"Failed to create namespace {ns}. See logs/db-actions.log.", '#ff3b30')) + else: + self._update_env_namespace(ns) + self.safe_after(self._refresh_namespace_table) + self.safe_after(lambda: self._db_set_status(f"Namespace {ns} is ready.", '#34c759')) + self.safe_after(lambda: self._db_set_buttons_state('normal')) + + threading.Thread(target=worker, daemon=True).start() + + def _db_delete_namespace(self): + ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip() + if not ns: + messagebox.showerror('Delete Database', 'Select a database namespace to delete.') + return + if not messagebox.askyesno('Delete Database', f'Delete namespace "{ns}"? This cannot be undone.'): + return + + def worker(): + self.safe_after(lambda: self._db_set_buttons_state('disabled')) + self.safe_after(lambda: self._db_set_status(f"Deleting namespace {ns}...", '#1d1d1f')) + cmd = self._kubectl_base_cmd() + ['delete', 'namespace', ns] + rc, out = self._run_cmd_capture(cmd) + if rc != 0: + self._db_log(out) + self.safe_after(self._refresh_namespace_table) + self.safe_after(lambda: self._db_set_status(f"Failed to delete namespace {ns}. See logs/db-actions.log.", '#ff3b30')) + else: + self.safe_after(self._refresh_namespace_table) + self.safe_after(lambda: self._db_set_status(f"Namespace {ns} deleted.", '#34c759')) + self.safe_after(lambda: self._db_set_buttons_state('normal')) + + threading.Thread(target=worker, daemon=True).start() + + def _db_edit_namespace(self): + ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip() + if not ns: + messagebox.showerror('Edit Database', 'Select a database namespace to edit.') + return + self.db_namespace.set(ns) + if not self._is_valid_namespace(ns): + messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.') + return + p1 = self.db_password.get() + p2 = self.db_password_confirm.get() + if not p1: + messagebox.showerror('Password', 'Password cannot be empty.') + return + if p1 != p2: + messagebox.showerror('Password', 'Passwords do not match.') + return + + def worker(): + self.safe_after(lambda: self._db_set_buttons_state('disabled')) + self.safe_after(lambda: self._db_set_status(f"Recreating SSH key for {ns}...", '#1d1d1f')) + # We prefer ed25519, but fallback to rsa if not available + key_path = Path.home() / ".ssh" / "id_prole_ed25519" + pub_path = Path.home() / ".ssh" / "id_prole_ed25519.pub" + try: + if key_path.exists(): + key_path.unlink() + if pub_path.exists(): + pub_path.unlink() + except Exception: + pass + + cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()] + rc, out = self._run_cmd_capture(cmd) + if rc != 0: + self._db_log(f"ed25519 generation failed, falling back to rsa: {out}") + cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()] + rc, out = self._run_cmd_capture(cmd) + + self._db_log(out) + if rc != 0: + self.safe_after(lambda: self._db_set_status("Failed to recreate SSH key. See logs/db-actions.log.", '#ff3b30')) + self.safe_after(lambda: self._db_set_buttons_state('normal')) + return + + self.safe_after(lambda: self._db_set_status("Updating OpenBao...", '#1d1d1f')) + env = self._script_env_for_namespace(ns) + env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) + script_path = str(PROJECT_ROOT / "etc" / "init_openbao.sh") + rc2, out2 = self._run_cmd_capture(['bash', script_path, 'initialize'], env=env, stdin_text=f"{p1}\n") + self._db_log(out2) + if rc2 != 0: + self.safe_after(self._refresh_namespace_table) + self.safe_after(lambda: self._db_set_status("OpenBao update failed. See logs/db-actions.log.", '#ff3b30')) + else: + self._update_env_namespace(ns) + self.safe_after(self._refresh_namespace_table) + self.safe_after(lambda: self._db_set_status(f"Updated SSH key and OpenBao for {ns}.", '#34c759')) + self.safe_after(lambda: self._db_set_buttons_state('normal')) + + threading.Thread(target=worker, daemon=True).start() + + def _render_init_db_build_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Build Database Image', y=150) + self._render_paragraph('Building the prole-db Postgres image. This may take a few minutes.', y=200) + + # Local registry status (checked async) + self._db_registry_status_label = ui.canvas_text(self, 48, 230, "Local Registry: Checking...", fill='#6e6e73', font=('SF Pro Text', 11)) + self._canvas_items.append(self._db_registry_status_label) + + # Output Console + self._db_build_console = self._create_console_output(y=260, title="Build Output", width=900, height=520) + + # Use tk.Button + self._db_build_button = tk.Button(self.bg_canvas, text='Start Build', command=self.run_db_build, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, 800, window=self._db_build_button, anchor='nw', width=180) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(self._db_build_button) + + # Status Label + self._db_build_status_label = ui.canvas_text(self, 240, 812, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._db_build_status_label) + + # Ensure registry check runs when entering the screen + self._ensure_db_build_registry_async() + + def _ensure_db_build_registry_async(self): + def worker(): + try: + if not self.check_docker_running(): + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: Docker not running", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + return + info = self.ensure_local_registry_available() + if info: + host_registry, _cluster_registry = info + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=f"Local Registry: {host_registry}", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + else: + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: unavailable", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + log_path = getattr(self, '_last_registry_log_path', None) + if log_path and self._db_build_console: + self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n")) + except Exception as e: + msg = f"Local Registry: error ({e})" + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=msg, fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + log_path = getattr(self, '_last_registry_log_path', None) + if log_path and self._db_build_console: + self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n")) + + threading.Thread(target=worker, daemon=True).start() + + def run_db_build(self): + self._action_flags['init_db_build.run_build'] = True + def worker(): + self.safe_after(lambda: self._db_build_button.configure(state='disabled') if self._db_build_button.winfo_exists() else None) + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Building...", fill='blue') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + + tag = self.get_prole_db_version() + image_name = f"prole-db:{tag}" + + # Use $HOME/.prole/build for Docker build context + # This avoids issues with PyInstaller's temporary _MEIPASS directory + prole_home = Path.home() / ".prole" + build_dir = prole_home / "build" / "prole-db" + build_dir.mkdir(parents=True, exist_ok=True) + + # Copy prole-db directory to writable location + source_dir = get_resource_path("prole-db") + if source_dir.exists(): + import shutil + # Remove old build dir and copy fresh + if source_dir.resolve() != build_dir.resolve(): + if build_dir.exists(): + shutil.rmtree(build_dir) + shutil.copytree(source_dir, build_dir) + + cwd = build_dir + + # Fetch the generated public key + pub_key = "" + pub_key_path = Path.home() / ".ssh" / "id_prole_ed25519.pub" + if pub_key_path.exists(): + pub_key = pub_key_path.read_text().strip() + + username = self.db_username.get() + env_key = self._cluster_env_key() + 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, '.' + ] + + self._db_build_console.clear() + self._db_build_console.write(f"Building {image_name} in {cwd}...\n\n") + + proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + while True: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + if line: + self._db_build_console.write(line) + + if proc.returncode == 0: + self._db_build_console.write("\nBuild successful!\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build successful! Tagging...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + self._db_built_success = True + + if env_key == 'dev': + # Tag + push to local registry for k3d pulls + registry_info = self.ensure_local_registry_available() + import_tag = image_name + if registry_info: + host_registry, cluster_registry = registry_info + remote_tag = f"{host_registry}/prole-db:{tag}" + self._db_build_console.write(f"Tagging image for registry: {remote_tag}\n") + tag_res = subprocess.run(['docker', 'tag', image_name, remote_tag], capture_output=True, text=True) + if tag_res.returncode != 0: + self._db_build_console.write(tag_res.stdout or '') + self._db_build_console.write(tag_res.stderr or '') + self._db_build_console.write("Tag failed; skipping push/import.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, tag failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) + return + + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Pushing to registry...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + if not _push_docker_image(remote_tag, log_fn=self._db_build_console.write): + self._db_build_console.write("Push failed; skipping import.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, push failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) + return + self._db_build_console.write(f"Push complete: {remote_tag}\n") + + if cluster_registry: + import_tag = f"{cluster_registry}/prole-db:{tag}" + # k3d registry is often reachable internally without the .localhost suffix + if cluster_registry.endswith('.localhost:5000'): + alt_registry = cluster_registry.replace('.localhost', '') + alt_tag = f"{alt_registry}/prole-db:{tag}" + if alt_tag != import_tag: + self._db_build_console.write(f"Tagging image for k3d import: {alt_tag}\n") + subprocess.run(['docker', 'tag', image_name, alt_tag], capture_output=True, text=True) + import_tag = alt_tag + else: + self._db_build_console.write("Local registry unavailable; skipping tag/push.\n") + + # Import to k3d + cluster_name = "prole-dev-cluster" + self._db_build_console.write(f"Importing image to {cluster_name} ({import_tag})...\n") + subprocess.run(['k3d', 'image', 'import', import_tag, '-c', cluster_name]) + self._db_build_console.write("Import complete.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build, tag, push, and import complete.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + else: + self._db_build_console.write("Import skipped for non-dev clusters.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build complete. Import skipped.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + else: + self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text=f"Build failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) + + self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) + + threading.Thread(target=worker, daemon=True).start() diff --git a/installer/ui/screens/dependencies.py b/installer/ui/screens/dependencies.py new file mode 100644 index 0000000..59cd656 --- /dev/null +++ b/installer/ui/screens/dependencies.py @@ -0,0 +1,441 @@ +"""Dependency scanning, summary and per-dependency install screens.""" + +import re +import threading +import time +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import config as inst_config +from installer import screen as ui + + +class DependenciesScreenMixin: + """Dependency scanning, summary and per-dependency install screens.""" + + def _render_deps_summary_page(self): + self._render_title('Dependencies', y=40) + y = 100 + # Draw each dependency row: status dot (blank initially), name, info + row_gap = 36 + + self.dep_status_items = {} # Store canvas IDs to update later + self.dep_install_buttons = {} # Store button window items + + for dep in self.dependencies: + indent = 0 + if dep.get('parent'): + indent = 24 + + left = 56 + indent + text_x = left + 28 + + # blank box (outline only initially) + dot = ui.canvas_rectangle(self, left, y, left+18, y+18, outline='#6e6e73', width=2) + self._canvas_items.append(dot) + # name + self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='black', font=('SF Pro Text', 12, 'bold'))) + # info placeholder + info = ui.canvas_text(self, text_x + 180, y, 'Checking...', fill='#6e6e73', font=('SF Pro Text', 11)) + self._canvas_items.append(info) + + self.dep_status_items[dep['id']] = {'dot': dot, 'info': info, 'y': y, 'left': left} + y += row_gap + + # Message line + msg_y = y + 30 + self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('SF Pro Text', 11)) + self._canvas_items.append(self.deps_msg_item) + + # Start async check + threading.Thread(target=self._run_delayed_deps_check, daemon=True).start() + + def _render_dependency_page(self, dep): + self._render_title(dep['name'], y=40) + self._render_paragraph(dep['description'], y=88) + # Status + ok, location, version = self.get_dep_info(dep) + y = 180 + left = 56 + if ok: + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold'))) + if location: + self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11))) + if version: + ver = self.normalize_version(version) + self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 11))) + else: + # Check if we should auto-install + install_cmd = dep.get('install_cmd') + if install_cmd and self._installing_dep_id != dep['id']: + # Launch directly into installation + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Trigger console-based install for brew/pip + if 'brew install' in install_cmd or 'pip install' in install_cmd: + self.root.after(500, lambda: self._install_dep_in_console(dep)) + else: + self.root.after(500, lambda: self.open_terminal_with_command(install_cmd)) + elif self._installing_dep_id == dep['id']: + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) + else: + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline='')) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Click to install link (if available) + if install_cmd: + link_y = y + 40 + link_text = ui.render_link(self, left+32, link_y, 'Click to install') + self._canvas_items.append(link_text) + self.bg_canvas.config(cursor='hand2') + + def _on_click(event): + ex, ey = event.x, event.y + bbox = self.bg_canvas.bbox(link_text) + if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: + self.open_terminal_with_command(install_cmd) + self.bg_canvas.bind('', _on_click) + + def _install_dep_in_console(self, dep): + """Run dependency installation in the embedded console.""" + if self._installing_dep_id == dep['id']: + return + try: + self._action_flags[f"dependencies.{dep['id']}.install"] = True + except Exception: + pass + + install_cmd = dep.get('install_cmd') + if not install_cmd: + return + + self._installing_dep_id = dep['id'] + # Prepare log file + logs_dir = self._resolve_env_dir('PROLE_LOGS', 'logs') + try: + logs_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + ts = time.strftime('%Y%m%d-%H%M%S') + log_path = logs_dir / f"install-{dep['id']}-{ts}.log" + + # Show console + self._ensure_console_overlay(radio_bottom_y=160) + self._console_text.configure(state='normal') + self._console_text.delete('1.0', tk.END) + self._console_text.insert('end', f"Starting installation of {dep['name']}...\n") + self._console_text.insert('end', f"Command: {install_cmd}\n\n") + self._console_text.configure(state='disabled') + + def on_done(rc): + self._installing_dep_id = None + if rc == 0: + self._append_console(f"\nSuccessfully installed {dep['name']}.\n") + # Refresh status and re-render page + self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}")) + else: + self._append_console(f"\nInstallation failed with exit code {rc}.\n") + # Enable next button so user can retry or proceed if they fixed it manually + try: + self.next_button.configure(state='normal', text='Next') + except Exception: + pass + + self._run_in_console(install_cmd, str(log_path), on_complete=on_done) + + # ---------------- Initialize Screen Handlers ---------------- + + def _run_delayed_deps_check(self): + results = {} + any_missing = False + for dep in self.dependencies: + ok, location, version = self.get_dep_info(dep) + results[dep['id']] = (ok, location, version) + + # Update UI for this item + def update_item(did=dep['id'], ok=ok, version=version): + items = self.dep_status_items.get(did) + if not items: return + + # Replace box with colored dot/check + self.bg_canvas.delete(items['dot']) + left = items.get('left', 56) + y = items['y'] + if ok: + items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='') + # Add a small white checkmark inside the green dot + self.bg_canvas.create_line(left+5, y+9, left+8, y+12, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}") + self.bg_canvas.create_line(left+8, y+12, left+13, y+6, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}") + else: + items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff3b30', outline='') + # Add "Click to install" button + btn = tk.Button(self.bg_canvas, text='Install', command=lambda d=did: self.show_page(f"dep_{d}"), + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=8, pady=2) + btn_window = self.bg_canvas.create_window(left + 350, y, window=btn, anchor='nw') + self._overlay_widgets.append(btn) + self._canvas_items.append(btn_window) + self.dep_install_buttons[did] = btn_window + + info_text = self.normalize_version(version) if ok and version else ('Not installed' if not ok else '') + self.bg_canvas.itemconfig(items['info'], text=info_text) + + self.safe_after(update_item) + time.sleep(0.1) # small delay to show it checking one by one + + def final_update(): + missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]] + if missing: + msg = f"Preparing to install ... {', '.join(missing)}" + else: + msg = 'All dependencies installed.' + try: + if self.bg_canvas.winfo_exists() and self.deps_msg_item in self.bg_canvas.find_all(): + self.bg_canvas.itemconfig(self.deps_msg_item, text=msg) + except (tk.TclError, RuntimeError): + pass + self.update_footer() + + self.safe_after(final_update) + + def _create_page_dependencies_summary(self): + f = self._page_container() + ttk.Label(f, text='Dependencies', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) + self.deps_container = ttk.Frame(f) + self.deps_container.pack(fill='both', expand=True, padx=16, pady=8) + + self.dep_status = {} + for dep in self.dependencies: + row = ttk.Frame(self.deps_container) + row.pack(fill='x', pady=6) + indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0) + indicator.pack(side='left', padx=8) + name = ttk.Label(row, text=dep['name'], style='Body.TLabel') + name.pack(side='left') + info = ttk.Label(row, text='', style='Dim.TLabel') + info.pack(side='left', padx=10) + self.dep_status[dep['id']] = {'canvas': indicator, 'info': info, 'dep': dep} + # Verify all checkbox (controls navigation behavior) + chk = ttk.Checkbutton(f, text='Verify all dependencies', variable=self.verify_mode, command=self.update_footer) + chk.pack(anchor='w', padx=24, pady=(4, 0)) + + # Message label must be created before refresh to avoid AttributeError + self.deps_msg = ttk.Label(f, text='', style='Dim.TLabel') + self.deps_msg.pack(anchor='w', padx=24, pady=(8, 8)) + # Now we can safely populate the UI. Start async scan to avoid startup delay + self.deps_msg.configure(text='Checking dependencies...') + self.start_dependency_scan() + # self._register_page('deps_summary', f) + + def _create_page_dependency(self, dep): + f = self._page_container() + ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) + ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) + status_var = tk.StringVar(value='Checking...') + loc_var = tk.StringVar(value='') + ver_var = tk.StringVar(value='') + + info_frame = ttk.Frame(f) + info_frame.pack(fill='x', padx=24, pady=12) + # Pretty status row with an icon + status_row = ttk.Frame(info_frame) + status_row.pack(anchor='w', fill='x') + status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0) + status_icon.pack(side='left', padx=(0, 8), pady=(2, 0)) + ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left') + + # Detail rows (hidden when not installed) + loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel') + ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel') + loc_label.pack(anchor='w') + ver_label.pack(anchor='w') + + link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel') + link.pack(anchor='w', padx=24, pady=(12, 0)) + + def _draw_status_icon(canvas, status): + ui.canvas_clear(canvas) + if status == 'installed': + ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#34c759', outline='') + ui.canvas_line_on(canvas, 4, 9, 8, 13, fill='white', width=2) + ui.canvas_line_on(canvas, 8, 13, 15, 5, fill='white', width=2) + elif status == 'missing': + # Friendly warning dot with exclamation + ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#ff9f0a', outline='') + ui.canvas_line_on(canvas, 9, 5, 9, 11, fill='white', width=2) + ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill='white', outline='white') + + def check_then_update(): + ok, location, version = self.get_dep_info(dep) + if ok: + status_var.set('Installed') + _draw_status_icon(status_icon, 'installed') + loc_var.set(f'Location: {location or ""}') + norm_ver = self.normalize_version(version) if version else None + ver_var.set(f'Version: {norm_ver or ""}') + # Ensure details visible + try: + loc_label.pack_configure() + ver_label.pack_configure() + link.pack_forget() + except Exception: + pass + else: + status_var.set('Not installed') + _draw_status_icon(status_icon, 'missing') + # Hide undefined details instead of showing dashes + try: + loc_label.pack_forget() + ver_label.pack_forget() + except Exception: + pass + try: + # Keep install link visible if we have a command + if dep.get('install_cmd') and not link.winfo_ismapped(): + link.pack(anchor='w', padx=24, pady=(12, 0)) + except Exception: + pass + + self.root.after(50, check_then_update) + + def do_install(event=None): + self.open_terminal_with_command(dep.get('install_cmd')) + + if dep.get('install_cmd'): + link.bind('', do_install) + else: + try: + link.pack_forget() + except Exception: + pass + + # self._register_page(f'dep_{dep["id"]}', f) + + def _dep_navigation_sequence(self, force_all: bool = False): + """Return a list of dependency page ids to traverse next. + - If force_all is True or verify_mode is True: include all dep pages in defined order. + - Else: include only missing dependency pages based on current checks. + """ + if force_all or self.verify_mode.get(): + res = [f'dep_{d["id"]}' for d in self.dependencies] + print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}") + return res + seq = [] + for d in self.dependencies: + ok, _, _ = self.get_dep_info(d) + if not ok: + seq.append(f'dep_{d["id"]}') + print(f"[DEBUG] _dep_navigation_sequence: {seq}") + return seq + + def start_dependency_scan(self): + """Scan dependencies in a background thread to avoid blocking UI startup.""" + if self.validation_running: + return + self.validation_running = True + + # Hold incremental results so we can update the status line and icons progressively + self._dep_scan_results = {} + + def worker(): + for dep in self.dependencies: + try: + ok, location, version = self.get_dep_info(dep) + except Exception: + ok, location, version = False, None, None + did = dep['id'] + # Save and apply incrementally + self._dep_scan_results[did] = (ok, location, version) + self.safe_after(lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(d, o, l, v)) + try: + time.sleep(0.02) + except Exception: + pass + # After all are processed, finalize pass to unify any remaining labels + self.safe_after(lambda: self._apply_dependency_scan(dict(self._dep_scan_results))) + + t = threading.Thread(target=worker, daemon=True) + self.validation_thread = t + t.start() + + def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version): + """Update the dependencies page row and message as each check completes.""" + if not hasattr(self, 'dep_status'): + return + slot = self.dep_status.get(dep_id) + if not slot: + return + # Update dot icon and info text for this row + self._draw_status(slot['canvas'], 'success' if ok else 'error') + if ok: + norm_ver = self.normalize_version(version) if version else '' + slot['info'].configure(text=(norm_ver or '')) + else: + slot['info'].configure(text='Not installed') + + # Update the message line with current missing list + if hasattr(self, '_dep_scan_results'): + missing = [self.dep_status[d]['dep']['name'] for d, res in self._dep_scan_results.items() if not res[0] and d in self.dep_status] + if hasattr(self, 'deps_msg') and self.deps_msg is not None: + if missing: + self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") + else: + self.deps_msg.configure(text='All dependencies installed.') + # Footer may need to react if verify mode is enabled + self.update_footer() + + # Update prole.cfg data + try: + status_str = f"installed (version {version})" if ok else "not installed" + self.prole_cfg_data['Dependencies'][dep_id] = status_str + self._save_prole_cfg() + except Exception: + pass + + def _apply_dependency_scan(self, results: dict): + """Apply scan results to the summary UI and footer labels.""" + self.validation_running = False + any_missing = False + for did, slot in self.dep_status.items(): + ok, location, version = results.get(did, (False, None, None)) + self._draw_status(slot['canvas'], 'success' if ok else 'error') + if ok: + norm_ver = self.normalize_version(version) if version else '' + slot['info'].configure(text=(norm_ver or '')) + else: + any_missing = True + slot['info'].configure(text='Not installed') + + has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None + if any_missing: + missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]] + if missing and has_msg: + self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") + else: + if has_msg: + self.deps_msg.configure(text='All dependencies installed.') + self.update_footer() + + def refresh_dependencies_ui(self): + """Deprecated synchronous refresh retained for compatibility. + Prefer start_dependency_scan -> _apply_dependency_scan. + """ + self.start_dependency_scan() + + def all_dependencies_installed(self): + for dep in self.dependencies: + ok, _, _ = self.get_dep_info(dep) + if not ok: + print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING") + return False + print("[DEBUG] all_dependencies_installed: YES (all OK)") + return True + + def get_dep_info(self, dep): + """Delegate dependency probing to installer.config.get_dep_info.""" + return inst_config.get_dep_info(dep) + + def normalize_version(self, text: str) -> str: + """Normalize versions via installer.config.normalize_version.""" + return inst_config.normalize_version(text) diff --git a/installer/ui/screens/deploy.py b/installer/ui/screens/deploy.py new file mode 100644 index 0000000..e3bc53a --- /dev/null +++ b/installer/ui/screens/deploy.py @@ -0,0 +1,637 @@ +"""Deployment screen, final deployment pipeline and LaunchAgent setup.""" + +import os +import plistlib +import secrets +import subprocess +import threading +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import screen as ui +from installer.core.env import PROJECT_ROOT + + +class DeployScreenMixin: + """Deployment screen, final deployment pipeline and LaunchAgent setup.""" + + def _render_deploy_page(self): + self._render_title('Deploy', y=40) + self._render_paragraph('Preparing to deploy. We will verify steps and perform actions as needed.', y=90) + # Render simple list of steps (static view). Runtime updates can redraw as needed. + y = 140 + left = 56 + if not hasattr(self, 'deploy_steps'): + self.deploy_steps = [ + {'name': 'Build Prole macOS app', 'status': 'pending'}, + {'name': 'Check Docker is running', 'status': 'pending'}, + {'name': 'Install Prole Tools.app', 'status': 'pending'}, + ] + for step in self.deploy_steps: + # status circle (pending empty) + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, outline='#b0b0b0')) + self._canvas_items.append(ui.canvas_text(self, left+26, y-2, step['name'], fill='#1d1d1f', font=('Helvetica', 12))) + y += 26 + + def on_deploy(self): + if getattr(self, '_deploy_running', False): + return + self._deploy_running = True + try: + if self.deploy_button: + self.deploy_button.configure(state='disabled') + except Exception: + pass + + def worker(): + err = None + try: + self.ensure_prole_env() + self.reload_env_from_shell() + except Exception as e: + err = f"Failed to load environment: {e}" + try: + self._ensure_prole_directories() + if 'Install' in self.prole_cfg_data: + self.prole_cfg_data['Install']['STATUS'] = 'Deployed' + self._save_prole_cfg() + except Exception as e: + err = err or f"Failed to prepare Prole directories: {e}" + + if err: + self.safe_after(lambda: messagebox.showerror("Deploy", err)) + else: + self.safe_after(self.open_drag_install_window) + + def _finish(): + self._deploy_running = False + try: + if self.deploy_button: + self.deploy_button.configure(state='normal') + except Exception: + pass + self.safe_after(_finish) + + threading.Thread(target=worker, daemon=True).start() + + def on_launch(self): + app_path = self._get_prole_dist_dir() / 'Prole Tools.app' + if app_path.exists(): + try: + subprocess.Popen(['open', str(app_path)]) + except Exception: + pass + else: + try: + messagebox.showerror("Launch", f"App not found at {app_path}. Please build it first.") + except Exception: + pass + + def run_final_deployment(self): + initial_dir = str(self._resolve_env_dir('PROLE_DATA', 'data')) + try: + if not Path(initial_dir).exists(): + initial_dir = str(Path.home()) + except Exception: + initial_dir = str(Path.home()) + + export_base = filedialog.askdirectory( + initialdir=initial_dir, + title="Select deployment export directory" + ) + if not export_base: + self.safe_after(lambda: self.bg_canvas.itemconfig( + self._save_deployment_status_label, + text="Save canceled.", + fill='#6e6e73' + ) if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) + return + + export_base = str(Path(export_base).expanduser()) + docker_export_dir = str(Path(export_base) / "docker-import") + helm_export_dir = str(Path(export_base) / "helm-chart") + kustomize_export_dir = str(Path(export_base) / "kustomize") + + def worker(): + self.safe_after(lambda: self._save_deployment_button.configure(state='disabled') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None) + self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Saving deployment...", fill='blue') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) + + script_name = "final_deployment.sh" + if hasattr(self, 'install_consoles') and script_name in self.install_consoles: + self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None) + + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["DOCKER_IMPORT_DIR"] = docker_export_dir + env["HELM_CHART_DIR"] = helm_export_dir + env["KUSTOMIZE_DIR"] = kustomize_export_dir + + console = getattr(self, 'install_consoles', {}).get(script_name) + if console: + console.clear() + console.write("Saving deployment artifacts...\n") + console.write(f"Export base: {export_base}\n") + console.write(f"Docker images: {docker_export_dir}\n") + console.write(f"Helm chart: {helm_export_dir}\n") + console.write(f"Kustomize: {kustomize_export_dir}\n\n") + + def _on_line(line): + if console: + console.write(line) + + rc = self.controller.run_script( + script_name, + args=['--docker-export', '--helm-chart', '--kustomize', '--output-dir', export_base], + env=env, + on_line=_on_line + ) + + if rc == 0: + self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Deployment saved successfully.", fill='#34c759') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) + else: + self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) + + self.safe_after(lambda: self._save_deployment_button.configure(state='normal') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None) + + threading.Thread(target=worker, daemon=True).start() + + def run_build_a_bao(self): + def worker(): + self.safe_after(lambda: self._build_a_bao_button.configure(state='disabled') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None) + self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Saving secrets to OpenBao...", fill='blue') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) + + script_name = "build-a-bao.sh" + if hasattr(self, 'install_consoles') and script_name in self.install_consoles: + self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None) + + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + env["NAMESPACE"] = (self.db_namespace.get() or '').strip() + + console = getattr(self, 'install_consoles', {}).get(script_name) + if console: + console.clear() + console.write(f"Running {script_name} ...\n") + + def _on_line(line): + if console: + console.write(line) + + rc = self.controller.run_script( + script_name, + env=env, + on_line=_on_line + ) + + if rc == 0: + self._secrets_finalized = True + self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Secrets saved to OpenBao.", fill='#34c759') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) + else: + self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) + + self.safe_after(lambda: self._build_a_bao_button.configure(state='normal') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None) + + threading.Thread(target=worker, daemon=True).start() + + def create_deploy_screen(self): + """Create the Deploy screen""" + frame = tk.Frame(self.content_area, bg='#1a1a1a') + self.screens['deploy'] = frame + + # Title + title = ttk.Label(frame, text="Build and Deploy", style='Title.TLabel') + title.pack(pady=(0, 30)) + + # Instructions + instructions = tk.Label(frame, + text="Build and deploy Prole services to k3d cluster", + bg='#1a1a1a', + fg='#aaaaaa', + font=('Helvetica', 11)) + instructions.pack(pady=(0, 20)) + + # Environment selector + env_frame = tk.Frame(frame, bg='#1a1a1a') + env_frame.pack(pady=(0, 10), fill='x') + env_label = tk.Label(env_frame, text="Target Environment:", bg='#1a1a1a', fg='#dddddd', font=('Helvetica', 11)) + env_label.pack(side='left', padx=(0, 10)) + self.deploy_environment = tk.StringVar(value='Dev') + env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18) + env_combo.pack(side='left') + env_help = tk.Label(env_frame, + text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at pi.prole.org:6443, Prod = stretch of prole-service-cluster", + bg='#1a1a1a', fg='#888888', font=('Helvetica', 9)) + env_help.pack(side='left', padx=(12, 0)) + + # Deploy button + deploy_btn = tk.Button(frame, + text="Build and Deploy", + command=self.start_deployment, + bg='#4a9eff', + fg='white', + activebackground='#3a8eef', + font=('Helvetica', 16, 'bold'), + padx=40, + pady=20, + cursor='hand2', + relief='flat') + deploy_btn.pack(pady=20) + + # Progress frame + progress_frame = tk.Frame(frame, bg='#1a1a1a') + progress_frame.pack(fill='both', expand=True, pady=20) + + # Progress list + # Initialize deploy steps; some names are updated dynamically when deployment starts + self.deploy_steps = [ + {'name': 'Build ProleStatus macOS app', 'status': 'pending'}, + {'name': 'Check Docker is running', 'status': 'pending'}, + {'name': 'Check or configure container registry', 'status': 'pending'}, + {'name': 'Ensure target cluster', 'status': 'pending'}, + {'name': 'Build prole-db Docker image', 'status': 'pending'}, + {'name': 'Tag Docker image for registry', 'status': 'pending'}, + {'name': 'Push image to registry', 'status': 'pending'}, + {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, + ] + + self.deploy_widgets = {} + for step in self.deploy_steps: + self.create_deploy_step_widget(progress_frame, step) + + def _create_page_deploy(self): + f = self._page_container() + ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) + ttk.Label(f, text='Preparing to deploy. We will verify steps and perform actions as needed.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24) + + # Reuse existing deploy steps UI but on light theme + self.deploy_steps = [ + {'name': 'Build Prole macOS app', 'status': 'pending'}, + {'name': 'Check Docker is running', 'status': 'pending'}, + {'name': 'Check or configure container registry', 'status': 'pending'}, + {'name': 'Ensure target cluster', 'status': 'pending'}, + {'name': 'Build prole-db Docker image', 'status': 'pending'}, + {'name': 'Tag Docker image for registry', 'status': 'pending'}, + {'name': 'Push image to registry', 'status': 'pending'}, + {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, + {'name': 'Install LaunchAgent for port-forwards (Dev)', 'status': 'pending'}, + ] + self.deploy_widgets = {} + container = ttk.Frame(f) + container.pack(fill='both', expand=True, padx=16, pady=8) + for step in self.deploy_steps: + self._create_deploy_row(container, step) + + # self._register_page('deploy', f) + + def create_deploy_step_widget(self, parent, step): + """Create a widget for a deployment step""" + step_frame = tk.Frame(parent, bg='#2a2a2a', relief='flat') + step_frame.pack(fill='x', pady=5, padx=10) + + # Status indicator + status_canvas = tk.Canvas(step_frame, width=30, height=30, bg='#2a2a2a', highlightthickness=0) + status_canvas.pack(side='left', padx=15, pady=15) + + # Step name + name_label = tk.Label(step_frame, + text=step['name'], + bg='#2a2a2a', + fg='#ffffff', + font=('Helvetica', 11), + anchor='w') + name_label.pack(side='left', fill='x', expand=True, padx=10) + + # Status text + status_label = tk.Label(step_frame, + text="Pending", + bg='#2a2a2a', + fg='#aaaaaa', + font=('Helvetica', 10)) + status_label.pack(side='right', padx=15) + + self.deploy_widgets[step['name']] = { + 'canvas': status_canvas, + 'label': status_label, + 'step': step + } + + # Draw initial pending state + self.update_deploy_step_status(step['name'], 'pending') + + def _create_deploy_row(self, parent, step): + row = ttk.Frame(parent) + row.pack(fill='x', pady=6) + canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0) + canvas.pack(side='left', padx=8) + lbl = ttk.Label(row, text=step['name'], style='Body.TLabel') + lbl.pack(side='left') + status = ttk.Label(row, text='Pending', style='Dim.TLabel') + status.pack(side='right', padx=8) + self.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step} + self._draw_status(canvas, 'pending') + + def update_deploy_step_status(self, step_name, status): + """Update the status of a deployment step""" + widget = self.deploy_widgets[step_name] + canvas = widget['canvas'] + label = widget['label'] + step = widget['step'] + + step['status'] = status + canvas.delete('all') + + if status == 'pending': + ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2) + label.configure(text="Pending", fg='#aaaaaa') + elif status == 'running': + ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#ffaa00', width=2, fill='#ffaa00') + label.configure(text="Running...", fg='#ffaa00') + elif status == 'completed': + ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#28a745', width=2, fill='#28a745') + ui.canvas_text_on(canvas, 15, 15, '✓', fill='white', font=('Helvetica', 16, 'bold')) + label.configure(text="Completed", fg='#28a745') + elif status == 'error': + ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#dc3545', width=2, fill='#dc3545') + ui.canvas_text_on(canvas, 15, 15, '✗', fill='white', font=('Helvetica', 16, 'bold')) + label.configure(text="Error", fg='#dc3545') + elif status == 'skipped': + ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2, fill='#444444') + label.configure(text="Skipped", fg='#888888') + + def start_deployment(self): + """Start the deployment process""" + threading.Thread(target=self.run_deployment, daemon=True).start() + + def generate_prole_properties(self, env: str): + """Dynamically generate prole-tools-app/prole.properties based on environment""" + props_path = Path("prole-tools-app/prole.properties") + + # Determine host based on environment + # For now use localhost as a placeholder for Service/Prod + host = "localhost" + + content = f"""# Prole default endpoints (dynamically generated by install.py) +# UI assets +icon=img/proleIcon.png +background=img/proleLogoSepia.png + +# Dev port-forward supervision +pf.enabled=true + +# Service endpoints (5 traffic lights) +svc.1.name=K3D +svc.1.host={host} +svc.1.port=6443 + +svc.2.name=Prometheus +svc.2.host={host} +svc.2.port=9090 + +svc.3.name=Grafana +svc.3.host={host} +svc.3.port=3000 + +svc.4.name=OpenBAO +svc.4.host={host} +svc.4.port=8200 + +svc.5.name=PostgreSQL +svc.5.host={host} +svc.5.port=5432 + +# Kerberos configuration +kerberos.enabled={str(self.kerberos_enabled.get()).lower()} +kerberos.realm={self.kerberos_realm.get()} +kerberos.user={self.kerberos_user.get()} +kerberos.kdc={self.kerberos_kdc.get()} +""" + props_path.write_text(content) + print(f"Generated {props_path} for {env} environment") + + def run_deployment(self): + """Run the deployment steps""" + try: + # Capture environment selection and prepare dynamic labels + env = self.deploy_environment.get().strip() + if env not in ('Dev', 'Service', 'Prod'): + env = 'Dev' + + # Update step labels to reflect environment + self.deploy_widgets['Ensure target cluster']['step']['name'] = f"Ensure target cluster ({env})" + # Use the correct way to update the label text in the UI + self.deploy_widgets['Ensure target cluster']['label'].master.winfo_children()[1].configure(text=f"Ensure target cluster ({env})") + + # Step 0: Generate prole.properties + self.generate_prole_properties(env) + + # Step 1: Build Prole macOS app + self.update_deploy_step_status('Build Prole macOS app', 'running') + self.build_prole_app() + self.update_deploy_step_status('Build Prole macOS app', 'completed') + + # Step 1: Check Docker + self.update_deploy_step_status('Check Docker is running', 'running') + if not self.check_docker_running(): + self.update_deploy_step_status('Check Docker is running', 'error') + messagebox.showerror("Error", "Docker is not running. Please start Docker Desktop.") + return + self.update_deploy_step_status('Check Docker is running', 'completed') + + # Step 2: Registry discovery/config + self.update_deploy_step_status('Check or configure container registry', 'running') + self.registry_url = self.ensure_registry_available(env) + self.update_deploy_step_status('Check or configure container registry', 'completed') + + # Step 3: Ensure target cluster as per environment + self.update_deploy_step_status('Ensure target cluster', 'running') + self.create_or_select_cluster(env) + self.update_deploy_step_status('Ensure target cluster', 'completed') + + # Step 3: Build Docker image + self.update_deploy_step_status('Build prole-db Docker image', 'running') + self.build_docker_image() + self.update_deploy_step_status('Build prole-db Docker image', 'completed') + + # Step 4: Tag image + self.update_deploy_step_status('Tag Docker image for registry', 'running') + self.tag_docker_image() + self.update_deploy_step_status('Tag Docker image for registry', 'completed') + + # Step 5: Push to registry + self.update_deploy_step_status('Push image to registry', 'running') + self.push_docker_image() + self.update_deploy_step_status('Push image to registry', 'completed') + + # Step 6: Import to k3d (Dev only) + if env == 'Dev': + self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'running') + self.import_k3d_image(cluster_name='prole-dev-cluster') + self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'completed') + else: + self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'skipped') + + # Step 7: Install LaunchAgent for port-forwards (Dev) + if env == 'Dev': + self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'running') + ok = self.install_launchagent_port_forwards() + if ok: + self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'completed') + else: + self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'error') + return + else: + self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'skipped') + + messagebox.showinfo("Success", "Deployment completed successfully!") + + except Exception as e: + messagebox.showerror("Error", f"Deployment failed: {str(e)}") + + def install_launchagent_port_forwards(self) -> bool: + """Create/update the user LaunchAgent and helper script to manage kubectl port-forwards. + - Helper script: ~/Library/Application Support/Prole/bin/prole-kpf.sh + - LaunchAgent: ~/Library/LaunchAgents/org.prole.prole-db.kpf-dev.plist + """ + try: + home = Path.home() + bin_dir = home / 'Library' / 'Application Support' / 'Prole' / 'bin' + run_dir = home / 'Library' / 'Application Support' / 'Prole' / 'run' + plist_path = home / 'Library' / 'LaunchAgents' / 'org.prole.prole-db.kpf-dev.plist' + script_path = bin_dir / 'prole-kpf.sh' + + bin_dir.mkdir(parents=True, exist_ok=True) + run_dir.mkdir(parents=True, exist_ok=True) + plist_path.parent.mkdir(parents=True, exist_ok=True) + + helper_script = """#!/bin/sh +set -eu + +LABEL="org.prole.prole-db.kpf-dev" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +RUNDIR="$HOME/Library/Application Support/Prole/run" +PIDFILE="$RUNDIR/kpf.pids" + +ensure_rundir() { + mkdir -p "$RUNDIR" +} + +list_cmds() { + # Enumerate ProleCommands array using PlistBuddy + if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then + i=0 + while true; do + if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then + break + fi + echo "$val" + i=$((i+1)) + done + fi +} + +start() { + ensure_rundir + : > "$PIDFILE" + IFS='\n' + for cmd in $(list_cmds); do + [ -z "$cmd" ] && continue + (sh -lc "$cmd") & + echo $! >> "$PIDFILE" + done + wait +} + +stop() { + if [ -f "$PIDFILE" ]; then + while read -r pid; do + [ -z "$pid" ] && continue + kill "$pid" 2>/dev/null || true + done < "$PIDFILE" + rm -f "$PIDFILE" + fi +} + +status() { + if [ ! -f "$PIDFILE" ]; then + echo "not running" + exit 3 + fi + alive=0 + total=0 + while read -r pid; do + [ -z "$pid" ] && continue + total=$((total+1)) + if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi + done < "$PIDFILE" + echo "$alive/$total running" +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) status ;; + *) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;; +esac +""" + + script_path.write_text(helper_script) + os.chmod(script_path, 0o755) + + # Default commands for Dev environment (k3d) + default_cmds = [ + "kubectl port-forward -n monitoring svc/kps-kube-prometheus-stack-prometheus 9090", + "kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443", + "kubectl port-forward svc/prole-db-rw 5432:5432 --address 0.0.0.0", + "kubectl port-forward -n monitoring svc/kps-grafana 3000:80 --address 0.0.0.0", + ] + + # Create plist dict + from plistlib import dumps as plist_dumps + plist_dict = { + 'Label': 'org.prole.prole-db.kpf-dev', + 'RunAtLoad': True, + 'KeepAlive': True, + # Ensure PATH has common locations for kubectl + 'EnvironmentVariables': { + 'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin') + }, + 'StandardOutPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.out.log'), + 'StandardErrorPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.err.log'), + 'ProgramArguments': [str(script_path), 'start'], + 'ProleCommands': default_cmds, + } + + plist_bytes = plist_dumps(plist_dict) + plist_path.write_bytes(plist_bytes) + + # Reload the agent + uid = os.getuid() + # Try to kickstart first + subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) + # Bootout and bootstrap to ensure it's loaded, then kickstart + subprocess.run(['launchctl', 'bootout', f'gui/{uid}', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) + subprocess.run(['launchctl', 'bootstrap', f'gui/{uid}', str(plist_path)], check=True) + subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], check=True) + + return True + except Exception as e: + print(f"LaunchAgent setup failed: {e}") + return False + + + def _draw_status(self, canvas, status): + ui.canvas_clear(canvas) + if status == 'success': + ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#34c759', outline='') + ui.canvas_line_on(canvas, 5, 10, 9, 14, fill='white', width=2) + ui.canvas_line_on(canvas, 9, 14, 16, 6, fill='white', width=2) + elif status == 'running': + ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ffd60a', outline='') + elif status == 'error': + ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ff3b30', outline='') + else: + ui.canvas_oval_on(canvas, 2, 2, 18, 18, outline='#b0b0b0') + + # --------------- Dependency helpers --------------- diff --git a/installer/ui/screens/docker.py b/installer/ui/screens/docker.py new file mode 100644 index 0000000..09b22d7 --- /dev/null +++ b/installer/ui/screens/docker.py @@ -0,0 +1,496 @@ +"""Docker image build, registry management and image pre-pull.""" + +import os +import platform +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path +from installer.core.env import ( + PROJECT_ROOT, + _collect_images_from_files, + _deployment_mode_from_env, + _http_ping_registry, + _k3d_prole_data_volume_args, + _local_registry_enabled, + _normalize_cluster_env, + _push_docker_image, + _resolve_supabase_home, +) + + +class DockerScreenMixin: + """Docker image build, registry management and image pre-pull.""" + + def check_docker_running(self): + """Check if Docker is running""" + try: + result = subprocess.run(['docker', 'ps'], + capture_output=True, + timeout=10) + return result.returncode == 0 + except Exception: + return False + + def ensure_registry_available(self, env: str) -> str: + """Detect if k8s.prole.org:5000 is reachable; if so use it. Otherwise ensure a local registry is available. + Returns the registry URL (host:port) to be used for tagging/pushing. + For Dev, may create a local k3d-managed registry; for other envs, still prefer the external if reachable. + """ + # Prefer the shared registry if reachable + if _http_ping_registry('k8s.prole.org', 5000): + return 'k8s.prole.org:5000' + + if not _local_registry_enabled(env): + return '' + + # Otherwise, ensure a local registry (localhost:5000) exists/started + if _http_ping_registry('localhost', 5000): + return 'localhost:5000' + + # Use k3d registry helper for Dev; for non-Dev, we still create/use local for pushing + reg_name = 'prole-registry' + # Check if k3d is installed + k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0 + if k3d_exists: + # List registries + lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True) + if reg_name not in (lst.stdout or ''): + # Create registry exposed on 0.0.0.0:5000 + subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) + return 'localhost:5000' + + def ensure_local_registry_available(self): + """Ensure a local registry is running and return (host_registry, cluster_registry).""" + mode_hint = None + try: + mode_hint = self._deployment_mode() + except Exception: + mode_hint = None + + if not _local_registry_enabled(mode_hint): + try: + log_path = self._registry_log_path() + with log_path.open('a', encoding='utf-8') as fp: + fp.write(f"\n# Registry disabled (mode={mode_hint or 'unknown'}) @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") + except Exception: + pass + return None + + log_path = self._registry_log_path() + try: + log_fp = log_path.open('a', encoding='utf-8') + except Exception: + log_fp = None + + def _log(msg: str): + if not log_fp: + return + try: + log_fp.write(msg) + log_fp.flush() + except Exception: + pass + + def _log_cmd(cmd: list[str], res: subprocess.CompletedProcess): + _log(f"$ {' '.join(cmd)}\n") + if res.stdout: + _log(res.stdout) + if res.stderr: + _log(res.stderr) + _log(f"[exit {res.returncode}]\n") + + def _run(cmd: list[str], check: bool = False): + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + _log_cmd(cmd, res) + if check: + raise RuntimeError(f"Command failed: {' '.join(cmd)}") + return res + + _log(f"\n# Registry check @ {time.strftime('%Y-%m-%d %H:%M:%S')} (mode={mode_hint or 'unknown'})\n") + reg_name = 'prole-registry' + registry_container = f'k3d-{reg_name}' + host_registry = 'localhost:5000' + + mode = _deployment_mode_from_env(self.prole_cfg_data.get('Global', {}).get('DEPLOYMENT_MODE')) + if mode == 'k3d': + cluster_registry = f'{registry_container}.localhost:5000' + else: + cluster_registry = host_registry + + if not self.check_docker_running(): + _log("Docker not running; cannot ensure local registry.\n") + if log_fp: + log_fp.close() + return None + + # 1. If a registry is already responsive on localhost:5000, we use it. + if _http_ping_registry('localhost', 5000): + # Check if it's a k3d registry to use the correct cluster internal name + try: + lst = _run(['k3d', 'registry', 'list']) + if registry_container in (lst.stdout or '') or reg_name in (lst.stdout or ''): + cluster_registry = f'{registry_container}.localhost:5000' + else: + cluster_registry = host_registry + except Exception: + cluster_registry = host_registry + + self.local_registry_url = host_registry + self.local_registry_internal = cluster_registry + try: + if 'Docker Build' not in self.prole_cfg_data: + self.prole_cfg_data['Docker Build'] = {} + self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY'] = host_registry + self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY_INTERNAL'] = cluster_registry + self.safe_after(self._save_prole_cfg) + except Exception: + pass + if log_fp: + log_fp.close() + return host_registry, cluster_registry + + # 2. Try k3d first if available + k3d_exists = _run(['which', 'k3d']).returncode == 0 + if k3d_exists: + try: + # Ensure registry exists + lst = _run(['k3d', 'registry', 'list']) + if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''): + _run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) + + # Ensure registry container is running + running = _run( + ['docker', 'ps', '--filter', f'name={registry_container}', '--format', '{{.Names}}'], + ) + if not (running.stdout or '').strip(): + _run(['docker', 'start', registry_container], check=True) + + self.local_registry_url = host_registry + self.local_registry_internal = f'{registry_container}.localhost:5000' + if log_fp: + log_fp.close() + return self.local_registry_url, self.local_registry_internal + except Exception: + _log("k3d registry setup failed.\n") + + # 3. Fallback to plain Docker registry + try: + docker_exists = _run(['which', 'docker']).returncode == 0 + if docker_exists: + # Check if prole-registry container exists + exists = _run( + ['docker', 'ps', '-a', '--filter', 'name=^/prole-registry$', '--format', '{{.Names}}'], + ) + if (exists.stdout or '').strip(): + _run(['docker', 'start', 'prole-registry'], check=True) + else: + _run([ + 'docker', 'run', '-d', '-p', '5000:5000', + '--restart', 'always', '--name', 'prole-registry', 'registry:2' + ], check=True) + + # Wait a moment for it to start + time.sleep(1) + if _http_ping_registry('localhost', 5000): + self.local_registry_url = host_registry + self.local_registry_internal = host_registry + if log_fp: + log_fp.close() + return host_registry, host_registry + except Exception: + _log("Docker registry fallback failed.\n") + + if log_fp: + log_fp.close() + return None + + 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 = 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(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, log=None) -> bool: + def _log(msg: str): + if log: + try: + log(msg) + except Exception: + pass + + info = self.ensure_local_registry_available() + if not info: + _log("Local registry unavailable; skipping image pre-pull.\n") + return False + + registry, _cluster_registry = info + images = self._collect_dependent_images(include_supabase, include_kerberos_proxy) + if not images: + _log("No dependent images found to pre-pull.\n") + return True + + overall_ok = True + import_dir = self.docker_import_dir.get().strip() + + 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 + _log(f"Checking if {image} exists in local registry...\n") + check_reg = subprocess.run(['docker', 'pull', local_tag], capture_output=True, text=True) + if check_reg.returncode == 0: + _log(f"[OK] {image} already exists in local registry as {local_tag}\n") + 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(): + _log(f"Found {tar_path} in import directory, loading...\n") + load = subprocess.run(['docker', 'load', '-i', str(tar_path)], capture_output=True, text=True) + if load.returncode == 0: + found = True + else: + _log(f"[WARN] Failed to load {tar_path}: {load.stderr}\n") + + if not found: + # 4. Pull from Docker Hub + _log(f"Pulling {image} from Docker Hub...\n") + pull = subprocess.run(['docker', 'pull', image], capture_output=True, text=True) + if pull.returncode != 0: + overall_ok = False + _log(pull.stdout or '') + _log(pull.stderr or '') + _log(f"[ERROR] docker pull failed for {image}\n") + 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 + _log(tag.stdout or '') + _log(tag.stderr or '') + _log(f"[ERROR] docker tag failed for {image} to {local_tag}\n") + continue + + _log(f"Pushing {local_tag} to local registry...\n") + if not _push_docker_image(local_tag, log_fn=log): + overall_ok = False + continue + _log(f"[OK] Stored {image} in local registry as {local_tag}\n") + + return overall_ok + + def build_docker_image(self): + """Build prole-db Docker image""" + # If Kerberos is enabled, update pg_hba.conf in conf/postgresql before copying + if self.kerberos_enabled.get(): + realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM" + hba_src = PROJECT_ROOT / 'conf' / 'postgresql' / 'pg_hba.conf' + if hba_src.exists(): + content = hba_src.read_text() + # Also ensure the Kerberos line exists in the source if it's not there yet + if "gss" not in content: + content += f"\nhost all all all gss include_realm=1 krb_realm={realm}\n" + else: + content = content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}") + hba_src.write_text(content) + print(f"Updated {hba_src} with realm {realm}") + + # Base local image tag (before pushing to registry) + version = self.get_prole_db_version() + image_tag = f'prole-db:{version}' + + # Prepare build context: copy conf/postgresql to prole-db/postgresql + conf_src = PROJECT_ROOT / 'conf' / 'postgresql' + conf_dst = PROJECT_ROOT / 'prole-db' / 'postgresql' + + if conf_dst.exists(): + shutil.rmtree(conf_dst, ignore_errors=True) + if sys.version_info >= (3, 8): + shutil.copytree(conf_src, conf_dst, dirs_exist_ok=True) + else: + shutil.copytree(conf_src, conf_dst) + + build_cmd = ['docker', 'build', '-t', image_tag] + + target_env = self._cluster_env_key() + if getattr(self, 'deploy_environment', None): + try: + target_env = _normalize_cluster_env(self.deploy_environment.get()) + except Exception: + pass + # Add platform flag for target environment (k3s on Pi uses arm64) + build_cmd.extend(get_docker_build_platform_args(target_env)) + + build_cmd.append('.') + + result = subprocess.run(build_cmd, + check=True, + cwd=PROJECT_ROOT / 'prole-db', + capture_output=True, + text=True) + if result.returncode != 0: + raise Exception(f"Failed to build image: {result.stderr}") + # Keep a reference for later steps + self.local_image_tag = image_tag + + # Update K8s manifest + self.update_k8s_manifest(image_tag) + + def build_mssql_docker_image(self, image_tag='prole-mssql-db:latest', target_env: str | None = None): + """Build mssql Docker image (with platform detection for target env).""" + build_cmd = ['docker', 'build', '-t', image_tag] + + env_key = target_env or self._cluster_env_key() + # Add platform flag for Apple Silicon / k3s target + build_cmd.extend(get_docker_build_platform_args(env_key)) + + build_cmd.append('.') + + result = subprocess.run(build_cmd, + check=True, + cwd=PROJECT_ROOT / 'mssql', + capture_output=True, + text=True) + if result.returncode != 0: + raise Exception(f"Failed to build mssql image: {result.stderr}") + return result + + def tag_docker_image(self): + """Tag Docker image for registry""" + if not getattr(self, 'registry_url', ''): + print("Registry disabled; skipping tag.") + return + version = self.get_prole_db_version() + registry = getattr(self, 'registry_url', 'localhost:5000') + image_name = f'prole-db:{version}' + image = getattr(self, 'local_image_tag', image_name) + self.remote_image_tag = f"{registry}/prole-db:{version}" + subprocess.run(['docker', 'tag', image, self.remote_image_tag], check=True, capture_output=True) + + def push_docker_image(self): + """Push Docker image to registry""" + if not getattr(self, 'registry_url', ''): + print("Registry disabled; skipping push.") + return + remote_tag = getattr(self, 'remote_image_tag', None) + if not remote_tag: + raise Exception('Remote image tag not set') + if not _push_docker_image(remote_tag): + raise Exception('Failed to push image to registry.') + + def import_k3d_image(self, cluster_name='prole-dev-cluster'): + """Import image to k3d cluster (only for Dev).""" + version = self.get_prole_db_version() + image_name = f'prole-db:{version}' + subprocess.run(['k3d', 'image', 'import', image_name, '-c', cluster_name], check=True, capture_output=True) + + def update_k8s_manifest(self, image_tag): + """Update k8s/prole/prole-db.yaml with the new version and Kerberos config""" + manifest_paths = [ + PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db.yaml', + PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db-recovery.yaml.tpl', + ] + import re + for manifest_path in manifest_paths: + if not manifest_path.exists(): + continue + content = manifest_path.read_text() + # Update imageName: prole-db:17.7-043 + new_content = re.sub(r'imageName:\s*.*', f'imageName: {image_tag}', content) + + # Update Kerberos realm in manifest if enabled + if self.kerberos_enabled.get(): + realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM" + if "gss" not in new_content: + # Insert before bootstrap if not present + new_content = new_content.replace(" pg_hba:", f" pg_hba:\n - host all all all gss include_realm=1 krb_realm={realm}") + else: + new_content = new_content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}") + + if new_content != content: + manifest_path.write_text(new_content) + + def get_prole_db_version(self): + return self.controller.get_prole_db_version() + + def create_or_select_cluster(self, env: str): + """Ensure target cluster depending on environment selection.""" + if env == 'Dev': + self.create_or_recreate_k3d_dev_cluster() + elif env == 'Service': + 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.") + server, token = self._k3s_connection_info() + if server and token: + res = subprocess.run(self._k3s_kubectl_base_cmd() + ['cluster-info'], capture_output=True, text=True) + if res.returncode != 0: + raise Exception("K3s cluster not reachable with provided server/token.") + else: + # Fallback to local kubeconfig if no token provided + subprocess.run(['kubectl', 'version', '--client'], check=True) + elif env == 'Prod': + 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.") + subprocess.run(['kubectl', 'version', '--client'], check=True) + else: + raise Exception(f"Unknown environment: {env}") + + def create_or_recreate_k3d_dev_cluster(self, cluster_name='prole-dev-cluster'): + """Create or restart local k3d cluster and wire it to the chosen registry.""" + result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True) + if cluster_name in (result.stdout or ''): + subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True) + + # Determine registry integration args + reg_args = [] + if getattr(self, 'registry_url', None): + # If using the local k3d registry, we want to create or use it + if self.registry_url.startswith('localhost:5000'): + # Creating with --registry-create ensures it's available and integrated + reg_args = ['--registry-create', f'prole-registry:0.0.0.0:5000'] + else: + reg_args = ['--registry-use', self.registry_url] + + prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) + volume_args = _k3d_prole_data_volume_args(prole_data) + cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2', '--wait'] + volume_args + reg_args + ['--timestamps'] + subprocess.run(cmd, check=True, cwd=PROJECT_ROOT) diff --git a/installer/ui/screens/environment.py b/installer/ui/screens/environment.py new file mode 100644 index 0000000..df47b25 --- /dev/null +++ b/installer/ui/screens/environment.py @@ -0,0 +1,432 @@ +"""System environment setup screen and env.sh management.""" + +import os +import shlex +import shutil +import subprocess +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import screen as ui +from installer.core.env import PROJECT_ROOT + + +class EnvironmentScreenMixin: + """System environment setup screen and env.sh management.""" + + def _render_env_setup_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Personal Infrastructure.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + # Task heading for clarity + self._render_title('Environment', y=150) + self._render_paragraph('Configure your Prole environment. You can accept defaults or choose custom locations. Changing PROLE_HOME will update the defaults for other paths.', y=200) + + defaults = self._env_defaults() + existing = self._read_existing_env() + values = {**defaults, **existing} + + # Keep entry widgets to read values on Next + self._env_entries = {} + + labels = [ + ('PROLE_HOME', 'Base directory for data, configs, logs, and scripts'), + ('PROLE_CONF', 'Configuration directory'), + ('PROLE_DATA', 'Data directory'), + ('PROLE_LOGS', 'Logs directory'), + ('PROLE_SERVICE', 'System scripts directory'), + ] + + x_label = 48 + x_field = 300 + x_btn = 850 + y = 280 + row_h = 75 + + def browse(var_name: str): + from tkinter import filedialog + initial = self._env_entries[var_name].get() or Path.home() + path = filedialog.askdirectory(title=f"Select {var_name}", initialdir=str(Path(initial).expanduser())) + if path: + self._env_entries[var_name].delete(0, 'end') + self._env_entries[var_name].insert(0, path) + if var_name == 'PROLE_HOME': + # Update dependent defaults if they still match old pattern + self._update_dependent_env_paths() + + # Draw rows + for key, help_text in labels: + self._canvas_items.append(ui.canvas_text(self, x_label, y, key, fill='black', font=('SF Pro Text', 12, 'bold'), anchor='w')) + # Use tk.Entry and place on canvas via create_window + entry = tk.Entry(self.bg_canvas, bg='white', fg='black', insertbackground='black', + highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) + entry.insert(0, values[key]) + entry_window = self.bg_canvas.create_window(x_field, y, window=entry, anchor='w', width=500, height=32) + self._canvas_items.append(entry_window) + self._overlay_widgets.append(entry) + self._env_entries[key] = entry + + # Use tk.Button and place on canvas via create_window + btn = tk.Button(self.bg_canvas, text='Browse…', command=lambda k=key: browse(k), + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=10, pady=4) + btn_window = self.bg_canvas.create_window(x_btn, y, window=btn, anchor='w', width=120) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(btn) + + # hint text + self._canvas_items.append(ui.canvas_text(self, x_field, y+24, help_text, fill='#6e6e73', font=('SF Pro Text', 10), anchor='nw')) + y += row_h + + # When PROLE_HOME changes, update dependent paths that still follow default pattern + def on_home_change(_evt=None): + self._update_dependent_env_paths() + try: + self._env_entries['PROLE_HOME'].bind('', on_home_change) + except Exception: + pass + + def _after_env_saved(self): + """Reload the installer process environment after saving env.sh.""" + try: + self.reload_env_from_shell() + # Ensure we capture PROLE_HOME in Global section + self.prole_cfg_data['Global']['PROLE_HOME'] = os.environ.get('PROLE_HOME', '') + self._save_prole_cfg() + except Exception as e: + try: + messagebox.showwarning('Environment', f'Environment saved, but reload failed: {e}') + except Exception: + pass + + def resolve_prole_home(self) -> Path: + """Return PROLE_HOME or default to $HOME/.prole as a Path (expanded).""" + val = os.environ.get('PROLE_HOME') + if val: + try: + return Path(val).expanduser() + except Exception: + pass + return Path.home() / '.prole' + + def ensure_prole_env(self) -> Path: + """Ensure $PROLE_HOME and env.sh exist and are readable. Create minimal env if missing. + + Returns the resolved PROLE_HOME Path. Raises Exception on failure. + """ + home = self.resolve_prole_home() + try: + home.mkdir(parents=True, exist_ok=True) + except Exception as e: + raise Exception(f"Failed to create PROLE_HOME at {home}: {e}") + env_file = home / 'env.sh' + if not env_file.exists(): + # Construct minimal values based on defaults with this home + vals = { + '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'), + } + self._save_env_to_file(vals) + # Validate readability + try: + data = env_file.read_text() + if not isinstance(data, str) or len(data) == 0: + raise Exception("env.sh is empty") + except Exception as e: + raise Exception(f"env.sh not readable at {env_file}: {e}") + return home + + def reload_env_from_shell(self) -> None: + """Reload environment by sourcing $PROLE_HOME/env.sh in a login shell and merging into os.environ.""" + home = self.ensure_prole_env() + env_file = home / 'env.sh' + # Use bash to source and print env in null-delimited form + 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 subprocess.CalledProcessError as e: + raise Exception(f"Failed to reload environment: {e}") + except Exception as e: + raise Exception(f"Failed to run bash to reload environment: {e}") + # Merge variables + try: + items = out.split(b'\x00') + for raw in items: + if not raw: + continue + try: + kv = raw.decode('utf-8', errors='ignore') + except Exception: + continue + if '=' not in kv: + continue + k, v = kv.split('=', 1) + # Avoid clobbering Python internals we rely on; safe list only + if k in ('PYTHONPATH', 'PYTHONHOME'): + continue + os.environ[k] = v + except Exception: + # Best-effort; ignore merge errors + pass + def _resolve_prole_conf_dir(self) -> Path: + return self._resolve_env_dir('PROLE_CONF', 'conf') + + def _resolve_prole_logs_dir(self) -> Path: + return self._resolve_env_dir('PROLE_LOGS', 'logs') + + def _registry_log_path(self) -> Path: + logs_dir = self._resolve_prole_logs_dir() + try: + logs_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + path = logs_dir / "local_registry.log" + self._last_registry_log_path = path + return path + + def _common_services_log_path(self) -> Path: + logs_dir = self._resolve_prole_logs_dir() + try: + logs_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + path = logs_dir / "init_common_services.log" + self._last_common_services_log_path = path + return path + + def _read_log_tail(self, path: Path, max_lines: int = 400) -> str: + try: + from collections import deque + with path.open('r', encoding='utf-8', errors='ignore') as handle: + tail = deque(handle, maxlen=max_lines) + return ''.join(tail) + except Exception: + return "" + + def _restore_common_services_log(self, console) -> bool: + path = getattr(self, '_last_common_services_log_path', None) + if not path: + candidate = self._common_services_log_path() + if candidate.exists(): + path = candidate + if not path or not path.exists(): + return False + content = self._read_log_tail(path) + if not content: + return False + try: + console.clear() + console.write(f"Log file: {path}\n\n") + console.write(content) + except Exception: + return False + return True + + def _ensure_prole_directories(self) -> None: + paths = { + 'PROLE_HOME': self._resolve_env_value('PROLE_HOME', str(Path.home() / '.prole')), + 'PROLE_CONF': str(self._resolve_prole_conf_dir()), + 'PROLE_DATA': str(self._resolve_env_dir('PROLE_DATA', 'data')), + 'PROLE_LOGS': str(self._resolve_prole_logs_dir()), + 'PROLE_SERVICE': str(self._resolve_env_dir('PROLE_SERVICE', 'etc')), + } + for key, raw in paths.items(): + try: + Path(raw).expanduser().mkdir(parents=True, exist_ok=True) + except Exception: + pass + if key not in os.environ and raw: + os.environ[key] = raw + + def _record_install_log(self, path) -> None: + if not path: + return + try: + p = Path(path) + except Exception: + return + if not hasattr(self, '_install_run_log_paths'): + self._install_run_log_paths = [] + sp = str(p) + if sp not in self._install_run_log_paths: + self._install_run_log_paths.append(sp) + + def _update_env_namespace(self, namespace: str): + try: + existing = self._read_existing_env() + defaults = self._env_defaults() + values = {**defaults, **existing} + values['NAMESPACE'] = namespace + self._save_env_to_file(values) + os.environ['NAMESPACE'] = namespace + except Exception as e: + print(f"[WARN] Failed to update env.sh namespace: {e}") + + def _save_env_to_file(self, values: dict): + home = Path(values['PROLE_HOME']).expanduser() + # ensure base dir exists + home.mkdir(parents=True, exist_ok=True) + # ensure subdirs exist + 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 + # write env.sh atomically + content = [] + # Executable wrapper + sourceable config + 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') + # Core PROLE_* directories + 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('') + # Ensure PATH contains common locations and $PROLE_HOME/bin (POSIX sh compatible) + 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' + + should_write = True + if out.exists(): + if out.read_text() == new_content: + should_write = False + # print(f"[OK] {out} is up to date.") + + if should_write: + tmp = home / 'env.sh.tmp' + tmp.write_text(new_content) + tmp.replace(out) + try: + os.chmod(out, 0o755) + except Exception: + pass + # print(f"[OK] Wrote {out}") + + # After creating env.sh, deploy additional resources as requested: + # 1) Deploy init-port-forward.sh into $PROLE_HOME (and ensure it's executable) + # 2) Copy contents of src/prole/etc (or fallback to top-level etc) into $PROLE_SERVICE + try: + prole_home = Path(values['PROLE_HOME']).expanduser() + prole_service = Path(values['PROLE_SERVICE']).expanduser() + + # Determine source for init-port-forward.sh + # Preferred location under repo: PROJECT_ROOT/src/prole/etc/init-port-forward.sh + init_pf_src_candidates = [ + PROJECT_ROOT / 'src' / 'prole' / 'etc' / 'init-port-forward.sh', + 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: + # best effort, ignore errors + pass + + # Determine etc directory source + etc_src_candidates = [ + PROJECT_ROOT / 'src' / 'prole' / 'etc', + PROJECT_ROOT / 'etc', + ] + etc_src = next((p for p in etc_src_candidates if p.exists() and p.is_dir()), None) + if etc_src is not None: + try: + prole_service.mkdir(parents=True, exist_ok=True) + except Exception: + pass + + # Copy files (non-recursive: contents of etc root). If subdirectories exist, copy them recursively. + import shutil + + def _copy_item(src_path: Path, dst_path: Path): + try: + if src_path.is_dir(): + # copy directory tree + if dst_path.exists(): + # remove then copy to keep in sync + shutil.rmtree(dst_path, ignore_errors=True) + shutil.copytree(src_path, dst_path) + else: + shutil.copy2(src_path, dst_path) + except Exception: + pass + + try: + for item in etc_src.iterdir(): + _copy_item(item, prole_service / item.name) + except Exception: + pass + except Exception: + # overall best-effort; do not fail env creation if copies fail + pass + + def _update_dependent_env_paths(self): + try: + new_home = Path(self._env_entries['PROLE_HOME'].get()).expanduser() + except Exception: + return + mapping = { + 'PROLE_CONF': new_home / 'conf', + 'PROLE_DATA': new_home / 'data', + 'PROLE_LOGS': new_home / 'logs', + 'PROLE_SERVICE': new_home / 'etc', + } + # Update entries if they are empty or previously matched the old base + for key, new_path in mapping.items(): + ent = self._env_entries.get(key) + if not ent: + continue + cur = ent.get().strip() + if not cur: + ent.delete(0, 'end') + ent.insert(0, str(new_path)) + continue + # If cur looked like old_home/, update it + try: + # detect suffix + suffix = new_path.name + if cur.endswith('/' + suffix): + # replace base path + ent.delete(0, 'end') + ent.insert(0, str(new_path)) + except Exception: + pass + # done diff --git a/installer/ui/screens/navigation.py b/installer/ui/screens/navigation.py new file mode 100644 index 0000000..f762311 --- /dev/null +++ b/installer/ui/screens/navigation.py @@ -0,0 +1,561 @@ +"""Sidebar navigation, page transitions, footer and app identity.""" + +import os +import platform +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import config as inst_config +from installer.core.env import get_resource_path + + +class NavigationMixin: + """Sidebar navigation, page transitions, footer and app identity.""" + + def _set_app_identity(self): + """Set the installer identity: process/menu name and Dock icon on macOS. + + Notes: + - We set Tk's appname for consistency. + - On macOS, attempt to set the process/menu name to 'Prole Database Installer' + via NSProcessInfo if PyObjC is available. + - Prefer the Prole Tools.app .icns from the built app; fallback to local PNG/GIF. + """ + # Set Tk application name + try: + self.root.tk.call('tk', 'appname', 'Prole Database Installer') + except Exception: + pass + + # macOS: set process/menu name and Dock icon via AppKit/Foundation + if platform.system() == 'Darwin': + # Try to set the visible process name for the menu bar + try: + from Foundation import NSProcessInfo + NSProcessInfo.processInfo().setProcessName_("Prole Database Installer") + except Exception: + pass + + # Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Database Installer' + try: + from AppKit import NSApplication + app = NSApplication.sharedApplication() + main_menu = app.mainMenu() + if main_menu is not None and main_menu.numberOfItems() > 0: + first_item = main_menu.itemAtIndex_(0) + if first_item is not None: + first_item.setTitle_("Prole Database Installer") + except Exception: + pass + + icns_candidates = [ + get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole Tools.icns'), + get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole.icns'), + ] + icns_path = next((p for p in icns_candidates if p.exists()), None) + if icns_path is not None: + try: + # Use PyObjC if available + from AppKit import NSApplication, NSImage + img = NSImage.alloc().initWithContentsOfFile_(str(icns_path)) + if img is not None: + NSApplication.sharedApplication().setApplicationIconImage_(img) + return + except Exception: + pass + + # If no .icns was found, try setting Dock icon from configured PNG + try: + from AppKit import NSApplication, NSImage + cfg_icon = inst_config.get_ui_icon_image_path() + if cfg_icon.exists(): + png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon)) + if png_img is not None: + NSApplication.sharedApplication().setApplicationIconImage_(png_img) + # do not return; still set Tk icon below for consistency + except Exception: + pass + + # Fallback: Tk icon from image assets (PNG/GIF) + # Prefer config-defined icon image + try: + cfg_icon = inst_config.get_ui_icon_image_path() + except Exception: + cfg_icon = get_resource_path('img/proleIcon.png') + img_candidates = [ + cfg_icon, + get_resource_path('img/prole-type.png'), + get_resource_path('img/prole-type.gif'), + get_resource_path('img/Prole.png'), + get_resource_path('img/proleLogoSepia.png'), + ] + for p in img_candidates: + try: + if p.exists(): + self._app_iconphoto = tk.PhotoImage(file=str(p)) + try: + self.root.iconphoto(True, self._app_iconphoto) + except Exception: + pass + break + except Exception: + continue + + def show_config_dialog(self): + """Show configuration dialog for Docker image import directory.""" + dialog = tk.Toplevel(self.root) + dialog.title("Configuration") + dialog.geometry("500x200") + dialog.resizable(False, False) + dialog.transient(self.root) + dialog.grab_set() + + # Center on parent + x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 250 + y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 100 + dialog.geometry(f"+{x}+{y}") + + container = tk.Frame(dialog, padx=20, pady=20) + container.pack(fill='both', expand=True) + + tk.Label(container, text="Docker Image Import Directory:", font=('SF Pro Text', 12, 'bold')).pack(anchor='w') + tk.Label(container, text="Images found here will be loaded instead of pulled from Docker Hub.", + font=('SF Pro Text', 10), fg='#666666').pack(anchor='w', pady=(0, 10)) + + row = tk.Frame(container) + row.pack(fill='x') + + entry = tk.Entry(row, textvariable=self.docker_import_dir, font=('SF Pro Text', 11)) + entry.pack(side='left', fill='x', expand=True, padx=(0, 5)) + + def browse(): + path = filedialog.askdirectory(initialdir=self.docker_import_dir.get() or Path.home()) + if path: + self.docker_import_dir.set(path) + + tk.Button(row, text="Browse...", command=browse).pack(side='right') + + def save(): + path = self.docker_import_dir.get().strip() + self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = path + self._save_prole_cfg() + dialog.destroy() + + btns = tk.Frame(container) + btns.pack(fill='x', pady=(20, 0)) + + tk.Button(btns, text="Save", command=save, width=10).pack(side='right') + tk.Button(btns, text="Cancel", command=dialog.destroy, width=10).pack(side='right', padx=10) + + def configure_styles(self): + """Configure ttk styles""" + base_bg = 'white' + self.style.configure('TFrame', background='white') + self.style.configure('TLabel', background='white', foreground='black', font=('SF Pro Text', 11)) + self.style.configure('Header.TLabel', font=('SF Pro Text', 16, 'bold')) + self.style.configure('Small.TLabel', font=('SF Pro Text', 10)) + + # Consistent button styling + self.style.configure('Nav.TButton', + font=('SF Pro Text', 11), + padding=(12, 8), + background='#F5F5DC', + bordercolor='#F5F5DC', + lightcolor='#F5F5DC', + darkcolor='#F5F5DC', + relief='flat') + + # Map state colors to avoid black boxes on hover/active + self.style.map('Nav.TButton', + background=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], + bordercolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], + lightcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], + darkcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')]) + + # Notebook styling to match light theme and avoid dark mode shifts on macOS + self.style.theme_use('default') + self.style.configure('TNotebook', background='white', borderwidth=0, highlightthickness=0) + self.style.configure('TNotebook.Tab', + background='#F5F5DC', + foreground='black', + lightcolor='#F5F5DC', + bordercolor='#CCCCCC', + darkcolor='#F5F5DC', + borderwidth=1, + padding=[10, 5]) + self.style.map('TNotebook.Tab', + background=[('selected', 'white')], + bordercolor=[('selected', '#CCCCCC')], + lightcolor=[('selected', 'white')], + focuscolor=[('selected', 'white')]) + + # Ensure common controls inherit white background + try: + self.style.configure('TCheckbutton', background=base_bg, foreground='black') + self.style.configure('TCombobox', fieldbackground='white', background=base_bg) + except Exception: + pass + + def _create_sidebar_nav(self): + """Create the left-hand navigation menu.""" + tk.Label(self.sidebar, text="INSTALLER", bg='#F5F5DC', fg='#8B8B7A', + font=('SF Pro Text', 10, 'bold'), anchor='w').pack(fill='x', padx=20, pady=(20, 10)) + + for text, page_id in self.nav_items: + lbl = tk.Label(self.sidebar, text=text, bg='#F5F5DC', fg='black', + font=('SF Pro Text', 11), anchor='w', padx=20, pady=5, cursor='hand2') + lbl.pack(fill='x') + lbl.bind('', lambda e, p=page_id: self.show_page(p)) + self.nav_widgets[page_id] = lbl + + def _update_nav_highlight(self, active_id): + """Highlight the current active page in the sidebar.""" + # Handle dep_id mapping to 'Dependencies' + if active_id.startswith('dep_'): + active_id = 'deps_summary' + + for page_id, widget in self.nav_widgets.items(): + if page_id == active_id: + widget.configure(bg='#E5E5D5', font=('SF Pro Text', 11, 'bold')) + else: + widget.configure(bg='#F5F5DC', font=('SF Pro Text', 11)) + + def create_navigation(self): + pass + + def on_prev(self): + # Custom prev navigation for dependency pages when filtering + current_id = self.pages[self.page_index][0] + # Close Terminal if leaving build summary via Prev + if current_id == 'build_summary': + try: + self.close_build_terminal() + except Exception: + pass + if current_id.startswith('dep_'): + seq = self._dep_navigation_sequence() + try: + i = seq.index(current_id) + except ValueError: + i = -1 + if i > 0: + self.show_page(seq[i - 1]) + return + else: + # Go back to summary if there is no previous in sequence + self.show_page('deps_summary') + return + if current_id == 'deps_summary': + self.show_page('welcome') + return + if current_id == 'network_scan': + # Go back to last relevant dep or deps_summary + if self.all_dependencies_installed(): + self.show_page('deps_summary') + else: + seq = self._dep_navigation_sequence() + self.show_page(seq[-1] if seq else 'deps_summary') + return + if current_id == 'env_setup': + self.show_page('network_scan') + return + if current_id == 'init_cluster': + if getattr(self, '_showing_service_overlay', False): + self._showing_service_overlay = False + self.show_page('init_cluster') + return + self.show_page('env_setup') + return + if current_id == 'init_password': + self.show_page('init_cluster') + return + if current_id == 'init_db_build': + self.show_page('init_password') + return + if current_id == 'init_scripts': + if self.kerberos_enabled.get(): + self.show_page('kerberos_config') + else: + self.show_page('init_db_build') + return + if current_id == 'kerberos_config': + self.show_page('init_db_build') + return + if current_id == 'ollama_config': + self.show_page('init_scripts') + return + if current_id == 'supabase_config': + self.show_page('ollama_config') + return + if current_id == 'init_cnpg_deploy': + if self.supabase_enabled.get(): + self.show_page('supabase_config') + return + self.show_page('ollama_config') + return + if current_id == 'create_installer': + self.show_page('init_cnpg_deploy') + return + # Default prev + if self.page_index > 0: + self.show_page(self.page_index - 1) + return + + def on_next(self): + # Special handling for dynamic labels + current_id = self.pages[self.page_index][0] + print(f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}") + + if current_id == 'welcome': + self.show_page('deps_summary') + return + + if current_id == 'deps_summary': + # Determine where to go from summary + # Always go to the next dependency or Network Scan + if self.all_dependencies_installed(): + print("[DEBUG] on_next: all deps installed, going to network_scan") + self.show_page('network_scan') + return + + # Go to first missing dependency page + seq = self._dep_navigation_sequence() + print(f"[DEBUG] on_next: missing deps sequence: {seq}") + if seq: + self.show_page(seq[0]) + else: + print("[DEBUG] on_next: all deps seem OK in sequence, going to network_scan") + self.show_page('network_scan') + return + + if current_id.startswith('dep_'): + # Navigate within dependency sequence + seq = self._dep_navigation_sequence() + print(f"[DEBUG] on_next: dep sequence: {seq}") + try: + i = seq.index(current_id) + except ValueError: + i = -1 + + if i >= 0 and i < len(seq) - 1: + self.show_page(seq[i + 1]) + return + else: + # After last relevant dep page, go back to summary if anything is still missing + if not self.all_dependencies_installed(): + print("[DEBUG] on_next: some deps still missing, returning to deps_summary") + self.show_page('deps_summary') + else: + print("[DEBUG] on_next: all deps now installed, going to network_scan") + self.show_page('network_scan') + return + + if current_id == 'network_scan': + # Capture network scan info + try: + self.prole_cfg_data['Network']['KDC_AUTO_DETECTED'] = self.kerberos_kdc.get() + self.prole_cfg_data['Network']['KERBEROS_AUTO_ENABLED'] = str(self.kerberos_enabled.get()) + self._save_prole_cfg() + except Exception: + pass + self.show_page('env_setup') + return + + if current_id == 'env_setup': + # Collect values, validate, write env.sh, then go to Database Creation + vals = {} + try: + for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'): + vals[k] = self._env_entries[k].get().strip() + self.prole_cfg_data['System Environment'][k] = vals[k] + vals['NAMESPACE'] = (self.db_namespace.get() or '').strip() + except Exception: + vals = self._env_defaults() + # Basic validation: require non-empty PROLE_HOME + if not vals.get('PROLE_HOME'): + try: + messagebox.showerror('Environment', 'Please specify PROLE_HOME') + except Exception: + pass + return + try: + self._save_env_to_file(vals) + # Reload our environment so subsequent steps (Build) inherit it + self._after_env_saved() + except Exception as e: + try: + messagebox.showwarning('Environment', f'Could not save env.sh: {e}') + except Exception: + pass + return + self.show_page('init_cluster') + return + + if current_id == 'init_password': + p1 = self.db_password.get() + p2 = self.db_password_confirm.get() + if not p1: + try: + messagebox.showerror('Password', 'Password cannot be empty.') + except Exception: + pass + return + if p1 != p2: + try: + messagebox.showerror('Password', 'Passwords do not match.') + except Exception: + pass + return + if not self._validate_port_forward_overlaps(): + return + self._run_preparation_overlay() + return + + if current_id == 'init_db_build': + if self.kerberos_enabled.get(): + self.show_page('kerberos_config') + else: + self.show_page('init_scripts') + return + + if current_id == 'build': + if getattr(self, '_built_success', False): + self.show_page('create_installer') + else: + self.perform_build() + return + + if current_id == 'init_cluster': + if not self._validate_and_save_cluster_config(): + return + if not self._cluster_ready_for_navigation(): + return + if not getattr(self, '_common_services_success', False): + self._run_service_layer_overlay(next_page='init_password') + return + self.show_page('init_password') + return + + if current_id == 'init_scripts': + self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if getattr(self, '_scripts_success', False) else 'Attempted' + self._save_prole_cfg() + self.show_page('ollama_config') + return + + if current_id == 'kerberos_config': + # Capture kerberos config + self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get()) + self.prole_cfg_data['Kerberos Authentication']['REALM'] = self.kerberos_realm.get() + self.prole_cfg_data['Kerberos Authentication']['KDC'] = self.kerberos_kdc.get() + self.prole_cfg_data['Kerberos Authentication']['SERVER'] = self.kerberos_kdc.get() + self.prole_cfg_data['Kerberos Authentication']['USER'] = self.kerberos_user.get() + self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = self.kerberos_password.get() + 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') + self._save_prole_cfg() + self.show_page('init_scripts') + return + + if current_id == 'ollama_config': + self._save_ollama_config() + if self.supabase_enabled.get(): + self.show_page('supabase_config') + else: + self.show_page('init_cnpg_deploy') + return + + if current_id == 'supabase_config': + self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) + self.prole_cfg_data['Supabase'] = {'STATUS': 'Deployed' if getattr(self, '_supabase_success', False) else 'Attempted'} + self._save_prole_cfg() + self.show_page('init_cnpg_deploy') + return + + if current_id == 'init_cnpg_deploy': + self.prole_cfg_data['Deployment']['STATUS'] = 'Deployed' if getattr(self, '_cnpg_success', False) else 'Attempted' + self._save_prole_cfg() + self.show_page('create_installer') + return + + if current_id == 'create_installer': + print("[DEBUG] on_next: at create_installer, Finish clicked. Closing.") + if 'Install' in self.prole_cfg_data: + self.prole_cfg_data['Install']['STATUS'] = 'Finished' + self._save_prole_cfg() + self.root.destroy() + return + + # Default next + if self.page_index < len(self.pages) - 1: + print(f"[DEBUG] on_next: default next to index {self.page_index + 1}") + self.show_page(self.page_index + 1) + else: + print("[DEBUG] on_next: already at last page") + return + + def update_footer(self): + # Default states for tk.Button + self.prev_button.configure(state='normal') + self.next_button.configure(state='normal') + first = self.page_index == 0 + # Base label + self.next_button.configure(text='Next') + + if getattr(self, '_showing_service_overlay', False): + if not getattr(self, '_common_services_success', False): + self.next_button.configure(state='disabled') + else: + self.next_button.configure(state='normal') + + # Page-specific adjustments + pid = self.pages[self.page_index][0] + if pid == 'build': + # Build page: show Build or Next depending on state + if getattr(self, '_built_success', False): + self.next_button.configure(text='Next') + else: + self.next_button.configure(text='Build') + elif pid == 'supabase_config': + # Supabase screen: Next is disabled until success + if getattr(self, '_supabase_success', False): + self.next_button.configure(state='normal') + else: + self.next_button.configure(state='disabled') + elif pid == 'init_scripts': + # Initialization Scripts: Next is disabled until success + if getattr(self, '_scripts_success', False): + self.next_button.configure(state='normal') + else: + self.next_button.configure(state='disabled') + # Visibility rules + self.prev_button.pack_forget() + self.next_button.pack_forget() + if self.deploy_button: + self.deploy_button.pack_forget() + if self.launch_button: + self.launch_button.pack_forget() + if self.gear_button: + self.gear_button.pack_forget() + + if pid == 'init_password' and self.gear_button: + self.gear_button.pack(side='left', padx=(20, 0), pady=12) + + if pid == 'create_installer': + self.next_button.configure(text='Finish') + self.next_button.pack(side='right', padx=(0, 20), pady=12) + return + + if first: + self.next_button.pack(side='right', padx=(0, 20), pady=12) + else: + # [Prev] [Next] clustered right + self.next_button.pack(side='right', padx=(0, 20), pady=12) + self.prev_button.pack(side='right', padx=(0, 8), pady=12) + + def _splash_should_hide_nav(self) -> bool: + """Deprecated: Welcome page no longer performs dependency checks or gates navigation.""" + return False diff --git a/installer/ui/screens/network.py b/installer/ui/screens/network.py new file mode 100644 index 0000000..f83c4c1 --- /dev/null +++ b/installer/ui/screens/network.py @@ -0,0 +1,241 @@ +"""Network scan screen.""" + +import os +import socket +import subprocess +import threading +import time +from pathlib import Path +import tkinter as tk +from installer import screen as ui +from installer.core.env import get_resource_path + + +class NetworkScreenMixin: + """Network scan screen.""" + + def _render_network_scan_page(self): + # Letterhead at top right (matching welcome screen theme) + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Network Configuration Scan', y=150) + self._render_paragraph("The network prole-agent will detect Kerberos services, Active Directory controllers, and other configuration details required for deployment.", y=210) + + y = 280 + self.scan_status_var = tk.StringVar(value="Ready to prole-agent") + # Ready to scan text (drawn on canvas) + status_item = ui.canvas_text(self, 48, y, "Ready to prole-agent", fill='black', font=('SF Pro Text', 12)) + + # Link variable to canvas text + def update_status_text(*args): + try: + self.bg_canvas.itemconfig(status_item, text=self.scan_status_var.get()) + except Exception: + pass + self.scan_status_var.trace_add('write', update_status_text) + + # Standardized Console Output + self.scan_results_console = self._create_console_output(y=320, title="Scan Output", width=880, height=380) + self.scan_results_text = self.scan_results_console.text + + if getattr(self, 'ansible_topology_summary', ''): + self.scan_results_console.write(self.ansible_topology_summary + "\n") + self.scan_results_console.write("Ansible topology loaded; network scan can refine detection.\n") + + y = 740 + + # Start Network Scan Button (placed on canvas) + self.scan_btn = tk.Button(self.bg_canvas, text="Start Network Scan", command=self._run_network_scan, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=20, pady=10) + self.btn_window = self.bg_canvas.create_window(48, y, window=self.scan_btn, anchor='nw') + self._overlay_widgets.append(self.scan_btn) + self._canvas_items.append(self.btn_window) + + # Prepare animation frames + if not GLOBAL_SCAN_FRAMES: + try: + from PIL import Image, ImageTk + gif_path = get_resource_path('img/prole-type.gif') + if gif_path.exists(): + gif = Image.open(str(gif_path)) + max_frames = 120 + try: + while len(GLOBAL_SCAN_FRAMES) < max_frames: + # Create a copy and resize + frame = gif.copy().convert('RGBA') + frame.thumbnail((24, 24), Image.LANCZOS) + GLOBAL_SCAN_FRAMES.append(ImageTk.PhotoImage(frame)) + gif.seek(len(GLOBAL_SCAN_FRAMES)) + except EOFError: + pass + except Exception as e: + print(f"Error loading scan animation: {e}") + self._scan_frames = GLOBAL_SCAN_FRAMES + + y += 60 + # Hint about auto-fill + autofill_msg = "Scan results will auto-fill Kerberos and Environment settings." + self._canvas_items.append(ui.canvas_text(self, 48, y, autofill_msg, fill='#6e6e73', font=('SF Pro Text', 10, 'italic'))) + + def _animate_scan_button(self, frame_idx=0): + if not getattr(self, '_scan_running', False) or not self._scan_frames: + if hasattr(self, 'scan_btn'): + self.scan_btn.config(image='', compound='none') + return + + self.scan_btn.config(image=self._scan_frames[frame_idx], compound='left') + next_idx = (frame_idx + 1) % len(self._scan_frames) + self.root.after(100, lambda: self._animate_scan_button(next_idx)) + + def _run_network_scan(self): + if getattr(self, '_scan_running', False): + return + self._action_flags['network_scan.run'] = True + self._scan_running = True + self._scan_kdc_value = None + ansible_kdc = '' + try: + ansible_kdc = (self.ansible_topology or {}).get('kdc_ip') or '' + if not ansible_kdc: + ansible_kdc = (self.prole_cfg_data.get('Network', {}) or {}).get('KDC_ANSIBLE_DETECTED', '') + except Exception: + ansible_kdc = '' + + self.scan_results_console.clear() + self.scan_results_console.write("Initializing network scan using prole-net/prole-agent ...\n") + self.scan_status_var.set("Scanning...") + + # Start animation + if self._scan_frames: + self._animate_scan_button() + + def worker(): + try: + # Use the new scan binary + scan_binary = get_resource_path("prole-net/prole-agent") + if not scan_binary.exists(): + self.safe_after(lambda: self.scan_results_console.write(f"Scan binary not found at {scan_binary}\n")) + self.safe_after(lambda: self.scan_status_var.set("Scan failed")) + self._scan_running = False + return + + # Create writable scan directory for output/cache + # This avoids issues with PyInstaller's read-only _MEIPASS directory + prole_home = Path.home() / ".prole" + scan_dir = prole_home / "scan" + scan_dir.mkdir(parents=True, exist_ok=True) + + # Run scan from writable directory with 10s timeout and summary analysis + # Set bufsize=0 for truly unbuffered binary stream reading + # Ensure PYTHONUNBUFFERED=1 is set in case prole-agent is/uses Python + env = os.environ.copy() + env['PYTHONUNBUFFERED'] = '1' + process = subprocess.Popen([str(scan_binary), "-t", "10"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, + env=env, + cwd=str(scan_dir)) + + # Capture output in real-time using larger reads to avoid overhead + fd = process.stdout.fileno() + line_buffer = [] + last_ui_update = 0 + pending_output = [] + + def maybe_set_kdc(ip: str): + if not ip: + return + if ansible_kdc: + return + if self._scan_kdc_value: + return + self._scan_kdc_value = ip + self.safe_after(lambda i=ip: self.kerberos_kdc.set(i)) + self.safe_after(lambda: self.kerberos_enabled.set(True)) + + while True: + try: + # Use a smaller chunk size to encourage more frequent reads + chunk_bytes = os.read(fd, 1024) + except (EOFError, OSError): + chunk_bytes = b'' + + if not chunk_bytes: + if process.poll() is not None: + break + # If we have pending output, flush it before sleeping + if pending_output: + text_to_flush = "".join(pending_output) + pending_output = [] + self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) + last_ui_update = time.time() + time.sleep(0.01) + continue + + text = chunk_bytes.decode('utf-8', errors='replace') + pending_output.append(text) + + # Update more frequently if we have enough data or enough time has passed + now = time.time() + if now - last_ui_update > 0.05 or sum(len(x) for x in pending_output) > 2048: + text_to_flush = "".join(pending_output) + pending_output = [] + self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) + last_ui_update = now + + # Accumulate for line parsing (for AD/KDC detection) + for char in text: + line_buffer.append(char) + if char == '\n': + line = "".join(line_buffer) + line_buffer = [] + + # Parse "KDC is: IP" from the new summary analysis format + if "KDC is:" in line: + try: + ip_part = line.split("KDC is:")[1].strip() + ip = ip_part.split()[0].strip('[]():,') + if ip: + maybe_set_kdc(ip) + except (IndexError, ValueError): + pass + + elif "Active Directory" in line or "88" in line: + parts = line.split() + for part in parts: + try: + socket.inet_aton(part.strip('[]():,')) + ip = part.strip('[]():,') + maybe_set_kdc(ip) + break + except socket.error: + continue + + process.wait() + + # Final flush of any remaining output + if pending_output: + text_to_flush = "".join(pending_output) + self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) + + if process.returncode == 0: + self.safe_after(lambda: self.scan_status_var.set("Scan complete")) + else: + self.safe_after(lambda: self.scan_status_var.set(f"Scan failed (code {process.returncode})")) + + self._scan_running = False + + except Exception as e: + self.safe_after(lambda: self.scan_results_console.write(f"Scan error: {str(e)}\n")) + self.safe_after(lambda: self.scan_status_var.set("Scan failed")) + self._scan_running = False + + threading.Thread(target=worker, daemon=True).start() diff --git a/installer/ui/screens/ollama.py b/installer/ui/screens/ollama.py new file mode 100644 index 0000000..261f6a3 --- /dev/null +++ b/installer/ui/screens/ollama.py @@ -0,0 +1,567 @@ +"""Ollama LLM configuration screen.""" + +import configparser +import os +import re +import socket +import threading +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import screen as ui +from installer.core.env import ( + DEFAULT_OLLAMA_PORT, + PROJECT_ROOT, + _format_ollama_host, + _parse_ollama_host, +) +from installer.config import _collect_cfg_vars, _expand_cfg_value + + +class OllamaScreenMixin: + """Ollama LLM configuration screen.""" + + def _render_ollama_config_page(self): + # Letterhead at top right (matching welcome screen theme) + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Ollama Server', y=150) + self._render_paragraph( + "Detect and select a network Ollama server. The selected host, port, and model are saved to prole.cfg " + "and exported as OLLAMA_* for k3s hosts and services.", + y=200 + ) + + x_label = 48 + x_field = 300 + y = 270 + + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Host:', fill='black', font=('SF Pro Text', 12, 'bold'))) + host_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_host, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + host_window = self.bg_canvas.create_window(x_field, y-12, window=host_entry, anchor='nw', width=400, height=32) + self._canvas_items.append(host_window) + self._overlay_widgets.append(host_entry) + + y += 42 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Port:', fill='black', font=('SF Pro Text', 12, 'bold'))) + port_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_port, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=120, height=32) + self._canvas_items.append(port_window) + self._overlay_widgets.append(port_entry) + + y += 42 + # Removed global model selector; model is now selected per-server row + save_btn = tk.Button(self.bg_canvas, text='Save', command=self._save_ollama_config, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=16, pady=6) + save_window = self.bg_canvas.create_window(x_field + 320, y-14, window=save_btn, anchor='nw') + self._canvas_items.append(save_window) + self._overlay_widgets.append(save_btn) + + y += 42 + preview_item = ui.canvas_text(self, x_label, y, 'OLLAMA_HOST: ', fill='#6e6e73', font=('SF Pro Text', 10)) + self._canvas_items.append(preview_item) + + def update_preview(*_args): + host = (self.ollama_server_host.get() or '').strip() + port = (self.ollama_server_port.get() or '').strip() + url = _format_ollama_host(host, port) + text = f"OLLAMA_HOST: {url}" if url else "OLLAMA_HOST: (not set)" + try: + self.bg_canvas.itemconfig(preview_item, text=text) + except Exception: + pass + # No global model selector updates to avoid UI spin-lock + + self.ollama_server_host.trace_add('write', update_preview) + self.ollama_server_port.trace_add('write', update_preview) + update_preview() + + y += 40 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))) + + refresh_btn = tk.Button(self.bg_canvas, text='Scan', command=self._refresh_ollama_table, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) + refresh_window = self.bg_canvas.create_window(x_label + 190, y - 8, window=refresh_btn, anchor='nw') + self._canvas_items.append(refresh_window) + self._overlay_widgets.append(refresh_btn) + + self._ollama_status_var = tk.StringVar(value="Ready") + status_item = ui.canvas_text(self, x_label + 270, y, "Status: Ready", fill='#6e6e73', font=('SF Pro Text', 10)) + self._canvas_items.append(status_item) + + def update_status_text(*args): + try: + self.bg_canvas.itemconfig(status_item, text=f"Status: {self._ollama_status_var.get()}") + except Exception: + pass + self._ollama_status_var.trace_add('write', update_status_text) + + table_y = y + 30 + table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1) + table_window = self.bg_canvas.create_window(x_label, table_y, window=table_frame, anchor='nw', width=900, height=220) + self._canvas_items.append(table_window) + self._overlay_widgets.append(table_frame) + + # Add an editable Default Model column; available models shown for reference + columns = ('select', 'host', 'port', 'default', 'models') + tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=6) + tree.heading('select', text='Select') + tree.heading('host', text='Host') + tree.heading('port', text='Port') + tree.heading('default', text='Default Model') + tree.heading('models', text='Models') + tree.column('select', width=60, anchor='center') + tree.column('host', width=200, anchor='w') + tree.column('port', width=70, anchor='center') + tree.column('default', width=180, anchor='w') + tree.column('models', width=380, anchor='w') + tree.pack(side='left', fill='both', expand=True) + + scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview) + tree.configure(yscrollcommand=scroll.set) + scroll.pack(side='right', fill='y') + + self._overlay_widgets.append(tree) + self._overlay_widgets.append(scroll) + self._ollama_server_tree = tree + + # Inline combobox to edit Default Model for the selected row + self._ollama_row_model_combo = None + + def _place_row_model_combo(item_id): + try: + # Compute bbox for 'default' column + bbox = tree.bbox(item_id, column='default') + if not bbox: + return + x, y, w, h = bbox + # Create lazily + if not self._ollama_row_model_combo: + self._ollama_row_model_combo = ttk.Combobox(tree, state='readonly') + self._ollama_row_model_combo.bind('<>', _on_row_model_change) + # Identify models by key + vals = tree.item(item_id, 'values') + if not vals: + return + host = vals[1] + port = vals[2] or DEFAULT_OLLAMA_PORT + key = f"{host}:{port}" + models = self._ollama_model_options.get(key, []) + try: + self._ollama_row_model_combo.configure(values=models) + except Exception: + pass + # Set current selection + current = (self._ollama_default_model_by_key.get(key) or (models[0] if models else '')) + if current: + self._ollama_row_model_combo.set(current) + # Place within Treeview + self._ollama_row_model_combo.place(x=x+1, y=y+1, width=max(w-2, 60), height=h-2) + except Exception: + pass + + def _on_row_model_change(_evt=None): + try: + if not self._ollama_row_model_combo: + return + sel = tree.selection() + if not sel: + return + item_id = sel[0] + vals = tree.item(item_id, 'values') + if not vals: + return + host = vals[1] + port = vals[2] or DEFAULT_OLLAMA_PORT + key = f"{host}:{port}" + choice = self._ollama_row_model_combo.get().strip() + self._ollama_default_model_by_key[key] = choice + # If this row is the active selection for host/port, reflect into self.ollama_model + current_key = self._ollama_current_key() + if current_key == key and choice: + try: + self.ollama_model.set(choice) + except Exception: + pass + # Update the table cell text + new_vals = list(vals) + # Default column index is 3 + if len(new_vals) >= 4: + new_vals[3] = choice or '—' + tree.item(item_id, values=new_vals) + except Exception: + pass + + def on_select(event): + if getattr(self, '_refreshing_ollama_table', False): + return + sel = tree.selection() + if not sel: + # Hide editor when nothing selected + if self._ollama_row_model_combo: + self._ollama_row_model_combo.place_forget() + return + vals = tree.item(sel[0], 'values') + if not vals: + return + host = vals[1] + port = vals[2] or DEFAULT_OLLAMA_PORT + self.ollama_server_host.set(host) + self.ollama_server_port.set(port) + key = f"{host}:{port}" + # If we have a stored default model for this key, reflect into self.ollama_model for saving + chosen = (self._ollama_default_model_by_key.get(key) or '') + if chosen: + try: + self.ollama_model.set(chosen) + except Exception: + pass + # Refresh to update the selection indicator, then place editor + self._render_ollama_table_from_cache() + # Reselect and place inline model editor over this row + try: + for item in tree.get_children(): + v = tree.item(item, 'values') + if v and v[1] == host and (v[2] or DEFAULT_OLLAMA_PORT) == port: + tree.selection_set(item) + _place_row_model_combo(item) + break + except Exception: + pass + + tree.bind('<>', on_select) + # Also allow double-click on default cell to focus the combobox + def on_double_click(event): + if getattr(self, '_refreshing_ollama_table', False): + return + item_id = tree.identify_row(event.y) + col = tree.identify_column(event.x) + if not item_id: + return + if col in ('#4', 'default'): + _place_row_model_combo(item_id) + tree.bind('', on_double_click) + + self._ollama_note_item = ui.canvas_text(self, x_label, table_y + 230, '', fill='#6e6e73', font=('SF Pro Text', 10)) + self._canvas_items.append(self._ollama_note_item) + + self._render_ollama_table_from_cache() + self._refresh_ollama_table() + + def _ollama_current_key(self) -> str: + host = (self.ollama_server_host.get() or '').strip() + port = (self.ollama_server_port.get() or '').strip() or DEFAULT_OLLAMA_PORT + if not host: + return '' + return f"{host}:{port}" + + def _set_ollama_model_values(self, models: list[str]): + combo = getattr(self, '_ollama_model_combo', None) + if not combo: + return + if getattr(self, '_refreshing_ollama_table', False): + return + values = models or [] + try: + combo.configure(values=values) + except Exception: + pass + current = (self.ollama_model.get() or '').strip() + if values and (not current or current not in values): + self.ollama_model.set(values[0]) + + def _render_ollama_table_from_cache(self): + rows = list(getattr(self, '_ollama_servers_cache', []) or []) + notice = getattr(self, '_ollama_last_notice', '') + self._update_ollama_table(rows, notice) + + def _update_ollama_table(self, rows: list[dict], notice: str = ''): + tree = getattr(self, '_ollama_server_tree', None) + if not tree: + return + self._ollama_servers_cache = rows + # Map of key -> list[str] available models + self._ollama_model_options = {} + # Map of key -> selected default model + if not hasattr(self, '_ollama_default_model_by_key'): + self._ollama_default_model_by_key = {} + self._ollama_last_notice = notice or '' + + for item in tree.get_children(): + tree.delete(item) + + current_key = self._ollama_current_key() + for row in rows: + host = row.get('host', '') + port = row.get('port', '') or DEFAULT_OLLAMA_PORT + models = row.get('models', []) or [] + key = f"{host}:{port}" + self._ollama_model_options[key] = models + # Initialize default selection if missing + if key not in self._ollama_default_model_by_key: + self._ollama_default_model_by_key[key] = (models[0] if models else '') + prefix = ' [✓] ' if key == current_key else ' [ ] ' + models_preview = ', '.join(models) if models else '—' + default_model = self._ollama_default_model_by_key.get(key, '') or '—' + try: + tree.insert('', 'end', values=(prefix, host, port, default_model, models_preview)) + except Exception: + # Fallback if columns not updated yet + tree.insert('', 'end', values=(prefix, host, port, models_preview)) + + if current_key: + for item in tree.get_children(): + vals = tree.item(item, 'values') + if vals and f"{vals[1]}:{vals[2]}" == current_key: + tree.selection_set(item) + tree.see(item) + break + + note_item = getattr(self, '_ollama_note_item', None) + if note_item and self.bg_canvas.winfo_exists(): + msg = notice if notice else "" + self.bg_canvas.itemconfig(note_item, text=msg) + + models = self._ollama_model_options.get(current_key, []) + if models: + self._set_ollama_model_values(models) + + def _refresh_ollama_table(self): + if getattr(self, '_refreshing_ollama_table', False): + return + self._refreshing_ollama_table = True + self._ollama_model_options = {} + if getattr(self, '_ollama_status_var', None): + self._ollama_status_var.set("Scanning...") + + def worker(): + notice = '' + rows: list[dict] = [] + try: + script_path = PROJECT_ROOT / "etc" / "init_ollama.sh" + if not script_path.exists(): + notice = f"Missing script: {script_path}" + self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) + else: + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + rc, out = self._run_cmd_capture(['bash', str(script_path), 'scan'], env=env) + if rc == 0: + for line in (out or '').splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + parts = [p.strip() for p in line.split('\t')] + if len(parts) < 2: + continue + host = parts[0] + port = parts[1] or DEFAULT_OLLAMA_PORT + model_list: list[str] = [] + if len(parts) > 2 and parts[2]: + model_list = [m.strip() for m in parts[2].split(',') if m.strip() and m.strip() != '-'] + rows.append({'host': host, 'port': port, 'models': model_list}) + # Deduplicate by canonical host:port while preferring hostname labels + if rows: + def _is_ip(name: str) -> bool: + if not name: + return False + return bool(re.match(r'^(?:\d{1,3}\.){3}\d{1,3}$', name)) or ':' in name + + def _canon_host(name: str) -> str: + n = (name or '').strip().lower() + if n in {'localhost', '127.0.0.1', '::1', 'k3d.local'}: + return '127.0.0.1' + try: + # Prefer IPv4 + return socket.gethostbyname(n) + except Exception: + return n + + def _label_rank(name: str) -> int: + n = (name or '').strip().lower() + if n == 'k3d.local': + return 0 + if not _is_ip(n) and n not in {'localhost'}: + return 1 # other hostnames preferred after k3d.local + if n == 'localhost': + return 2 + return 3 # IPs last + + merged: dict[tuple[str, str], dict] = {} + for r in rows: + disp_host = r.get('host', '') + port = str(r.get('port', '') or DEFAULT_OLLAMA_PORT) + canon = _canon_host(disp_host) + key = (canon, port) + cur = merged.get(key) + if not cur: + merged[key] = { + 'host': disp_host, # display label + 'port': port, + 'models': list(r.get('models', []) or []) + } + else: + # Merge models + existing = set(cur.get('models', []) or []) + for m in (r.get('models', []) or []): + if m not in existing: + cur['models'].append(m) + existing.add(m) + # Pick better display host + old_label = cur.get('host', '') + if _label_rank(disp_host) < _label_rank(old_label): + cur['host'] = disp_host + # Convert back to list and sort by host + rows = sorted(merged.values(), key=lambda d: (d.get('host') or '')) + if rows: + notice = f"Detected {len(rows)} server(s)." + self.safe_after(lambda: self._ollama_status_var.set("Scan complete") if getattr(self, '_ollama_status_var', None) else None) + else: + notice = "No Ollama servers detected." + self.safe_after(lambda: self._ollama_status_var.set("No servers found") if getattr(self, '_ollama_status_var', None) else None) + else: + notice = "Scan failed. See logs for details." + self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) + except Exception as e: + notice = f"Scan error: {e}" + self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) + finally: + def finish(): + self._refreshing_ollama_table = False + self._update_ollama_table(rows, notice) + self.safe_after(finish) + + threading.Thread(target=worker, daemon=True).start() + + def _save_ollama_config(self): + raw_host = (self.ollama_server_host.get() or '').strip() + raw_port = (self.ollama_server_port.get() or '').strip() + raw_model = (self.ollama_model.get() or '').strip() + + host = raw_host + port = raw_port + if raw_host: + parsed_host, parsed_port = _parse_ollama_host(raw_host) + if parsed_host: + host = parsed_host + if not port and parsed_port: + port = parsed_port + if host and not port: + port = DEFAULT_OLLAMA_PORT + + if host and host != raw_host: + self.ollama_server_host.set(host) + if port and port != raw_port: + self.ollama_server_port.set(port) + + data = self.prole_cfg_data.setdefault('Ollama', {}) + if host: + data['OLLAMA_SERVER_HOST'] = host + data['OLLAMA_SERVER_PORT'] = port + data['OLLAMA_HOST'] = _format_ollama_host(host, port) + else: + data.pop('OLLAMA_SERVER_HOST', None) + data.pop('OLLAMA_SERVER_PORT', None) + data.pop('OLLAMA_HOST', None) + if raw_model: + data['OLLAMA_MODEL'] = raw_model + else: + data.pop('OLLAMA_MODEL', None) + + self._save_prole_cfg() + if getattr(self, '_ollama_status_var', None): + self._ollama_status_var.set("Saved") + + def _apply_ollama_defaults(self): + env_host = (os.environ.get('OLLAMA_SERVER_HOST') or '').strip() + env_port = (os.environ.get('OLLAMA_SERVER_PORT') or '').strip() + env_model = (os.environ.get('OLLAMA_MODEL') or '').strip() + env_url = (os.environ.get('OLLAMA_HOST') or '').strip() + if env_url and not env_host: + parsed_host, parsed_port = _parse_ollama_host(env_url) + if parsed_host: + env_host = parsed_host + if parsed_port and not env_port: + env_port = parsed_port + + if env_host and not self.ollama_server_host.get().strip(): + self.ollama_server_host.set(env_host) + if env_port and not self.ollama_server_port.get().strip(): + self.ollama_server_port.set(env_port) + if env_model and not self.ollama_model.get().strip(): + self.ollama_model.set(env_model) + + cfg_host, cfg_port, cfg_model = self._read_ollama_cfg_values() + if cfg_host and not self.ollama_server_host.get().strip(): + self.ollama_server_host.set(cfg_host) + if cfg_port and not self.ollama_server_port.get().strip(): + self.ollama_server_port.set(cfg_port) + if cfg_model and not self.ollama_model.get().strip(): + self.ollama_model.set(cfg_model) + + def _read_ollama_cfg_values(self) -> tuple[str, str, str]: + """Return (host, port, model) from prole.cfg if present.""" + cfg_path = None + try: + if self._cfg_path_override is not None: + cfg_path = self._cfg_path_override + if cfg_path.is_dir(): + cfg_path = cfg_path / 'prole.cfg' + else: + cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' + except Exception: + cfg_path = None + if not cfg_path or not Path(cfg_path).exists(): + return '', '', '' + + cfg = configparser.ConfigParser(interpolation=None) + cfg.optionxform = str + try: + cfg.read(cfg_path) + except Exception: + return '', '', '' + cfg_vars = _collect_cfg_vars(cfg) + + host_val = '' + port_val = '' + model_val = '' + host_url = '' + if cfg.has_section('Ollama'): + sec = cfg['Ollama'] + host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() + port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() + model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() + host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() + if cfg.has_section('Global'): + sec = cfg['Global'] + if not host_val: + host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() + if not port_val: + port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() + if not model_val: + model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() + if not host_url: + host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() + + if host_url and not host_val: + parsed_host, parsed_port = _parse_ollama_host(host_url) + if parsed_host: + host_val = parsed_host + if parsed_port and not port_val: + port_val = parsed_port + + return host_val, port_val, model_val diff --git a/installer/ui/screens/packaging.py b/installer/ui/screens/packaging.py new file mode 100644 index 0000000..d2ce0d9 --- /dev/null +++ b/installer/ui/screens/packaging.py @@ -0,0 +1,959 @@ +"""Disk selection, installer packaging, DMG creation and build summary.""" + +import os +import platform +import plistlib +import shutil +import subprocess +import sys +import threading +import time +import webbrowser +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer.screen import TerminalConsole +from installer import screen as ui +from installer.core.env import PROJECT_ROOT, get_resource_path + + +class PackagingScreenMixin: + """Disk selection, installer packaging, DMG creation and build summary.""" + + def _render_disk_selection_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Deployment Destination.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Select Destination', y=150) + self._render_paragraph("Choose where you would like to deploy the built Prole application and its supporting artifacts.", y=210) + + # Container for the two main options + y_options = 300 + x_center = content_width // 2 + + # We need large friendly images. I'll use placeholders if I can't find specific ones. + # But I'll try to use symbols or colors for now if images are missing. + + try: + from PIL import Image, ImageTk + # Using proleIcon.png as a placeholder for both for now, but I'll add distinct styling + icon_path = PROJECT_ROOT / 'img' / 'proleIcon.png' + icon_img = Image.open(str(icon_path)).resize((128, 128), Image.LANCZOS) + self._disk_icon_tk = ImageTk.PhotoImage(icon_img) + except Exception: + self._disk_icon_tk = None + + # Option 1: Removable Disk + frame_usb = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20) + usb_window = self.bg_canvas.create_window(x_center - 250, y_options, window=frame_usb, anchor='n', width=350) + self._overlay_widgets.append(frame_usb) + self._canvas_items.append(usb_window) + + if self._disk_icon_tk: + lbl_img_usb = tk.Label(frame_usb, image=self._disk_icon_tk, bg='white', cursor='hand2') + lbl_img_usb.pack() + lbl_img_usb.bind("", lambda e: self.selected_disk_type.set('removable')) + + tk.Radiobutton(frame_usb, text="USB / Flash Drive", variable=self.selected_disk_type, value='removable', + bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10) + + # Dropdown for removable disks + disk_names = [d[0] for d in self.removable_disks] or ["No removable disks detected"] + if not self.selected_removable_disk.get() and self.removable_disks: + self.selected_removable_disk.set(self.removable_disks[0][1]) + + self.disk_dropdown = ttk.Combobox(frame_usb, values=disk_names, state="readonly", width=30) + self.disk_dropdown.pack(pady=5) + if disk_names: self.disk_dropdown.current(0) + + def on_disk_select(event): + idx = self.disk_dropdown.current() + if idx >= 0 and idx < len(self.removable_disks): + self.selected_removable_disk.set(self.removable_disks[idx][1]) + self.selected_disk_type.set('removable') + + self.disk_dropdown.bind("<>", on_disk_select) + + # Option 2: Local Folder + frame_local = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20) + local_window = self.bg_canvas.create_window(x_center + 250, y_options, window=frame_local, anchor='n', width=350) + self._overlay_widgets.append(frame_local) + self._canvas_items.append(local_window) + + if self._disk_icon_tk: + lbl_img_local = tk.Label(frame_local, image=self._disk_icon_tk, bg='white', cursor='hand2') + lbl_img_local.pack() + lbl_img_local.bind("", lambda e: self.selected_disk_type.set('local')) + + tk.Radiobutton(frame_local, text="Local Filesystem", variable=self.selected_disk_type, value='local', + bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10) + + # Path input and browse + path_frame = tk.Frame(frame_local, bg='white') + path_frame.pack(fill='x', pady=5) + + ent_path = tk.Entry(path_frame, textvariable=self.selected_local_path, font=('SF Pro Text', 10), width=30) + ent_path.pack(side='left', padx=(0, 5)) + + def browse_local(): + from tkinter import filedialog + d = filedialog.askdirectory(initialdir=self.selected_local_path.get()) + if d: + self.selected_local_path.set(d) + self.selected_disk_type.set('local') + + btn_browse = tk.Button(path_frame, text="Browse...", command=browse_local, bg='#F5F5DC') + btn_browse.pack(side='left') + + def _render_build_summary_page(self): + # Ensure slide_area is visible for the build log console + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lift() + + # Build Summary: show combined stdout/stderr from last build log + self._render_title('Build Summary', y=40) + if getattr(self, '_built_success', False): + self._render_paragraph('✅ Build completed successfully. You can now drag Prole Tools.app into Applications. Output below:', y=88) + else: + # Troubleshooting header + tips = ( + "❌ Build failed. Troubleshooting tips:\n" + "• Ensure Xcode Command Line Tools are installed: xcode-select --install\n" + "• Verify Swift toolchain and SPM networking (try again; network hiccups can happen)\n" + "• If on Apple Silicon, ensure dependencies target arm64 or install via Homebrew\n" + "• Clean derived data / SPM cache if needed: rm -rf ~/Library/Developer/Xcode/DerivedData\n" + "• Check Docker status if Docker-related steps are used\n" + "• Check the logs below for specific errors" + ) + self._render_paragraph(tips, y=88) + logp = getattr(self, 'last_build_log_path', None) + # Place a scrollable console to show the log + try: + # Standardized Console Output area for build summary + self.build_summary_console = self._create_console_output(y=160, title="Build Log Output", width=900, height=520) + txt = self.build_summary_console.text + + if logp and os.path.exists(logp): + try: + with open(logp, 'r', encoding='utf-8', errors='ignore') as fp: + content = fp.read() + self.build_summary_console.write(content) + except Exception as e: + self.build_summary_console.write(f"Failed to read log: {e}\n") + else: + self.build_summary_console.write('No build log available.') + + # Keep standard y for links + y_links = 760 + except Exception: + # Fallback: just show path + self._render_paragraph('No build log could be displayed.', y=120) + y_links = 140 + + # Add link to open the log file in Finder/TextEdit + if logp: + link = ui.render_link(self, 56, y_links, 'Open build log file') + self._canvas_items.append(link) + + def _open_log(event): + ex, ey = event.x, event.y + bbox = self.bg_canvas.bbox(link) + if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: + try: + if platform.system() == 'Darwin': + subprocess.run(['open', logp]) + else: + webbrowser.open(f'file://{logp}') + except Exception: + pass + self.bg_canvas.bind('', _open_log) + + # On success, also offer DMG packaging and opening options + if getattr(self, '_built_success', False): + try: + dist_dir = str(self._get_prole_dist_dir()) + except Exception: + dist_dir = None + if dist_dir: + y_links += 30 + # Create DMG + link2 = ui.render_link(self, 56, y_links, 'Create Prole Tools.dmg') + self._canvas_items.append(link2) + + def _open_dist(event): + ex, ey = event.x, event.y + bbox = self.bg_canvas.bbox(link2) + if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: + try: + # Build the DMG, then offer to open it + self.create_dmg() + self.open_dmg() + except Exception: + pass + self.bg_canvas.bind('', _open_dist) + + # Also link to open dist folder (fallback) + y_links += 30 + link3 = ui.render_link(self, 56, y_links, 'Open dist folder') + self._canvas_items.append(link3) + + def _open_dist_folder(event): + ex, ey = event.x, event.y + bbox = self.bg_canvas.bbox(link3) + if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: + try: + if platform.system() == 'Darwin': + subprocess.run(['open', dist_dir]) + else: + webbrowser.open(f'file://{dist_dir}') + except Exception: + pass + self.bg_canvas.bind('', _open_dist_folder) + + # ---------------- Drag-and-drop install (macOS Finder) ---------------- + def _render_create_installer_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Post Install', y=150) + self._render_paragraph('Review installation logs and save the deployment artifacts.', y=200) + + # Tabs for output - using standardized appearance + ui.canvas_text(self, 48, 260, "Installation Logs", fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) + + # Use a background frame for the notebook to hide potential system borders + notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) + self.install_tabs = ttk.Notebook(notebook_bg, style='TNotebook') + self.install_tabs.pack(fill='both', expand=True, padx=1, pady=1) + + tab_window = self.bg_canvas.create_window(48, 290, window=notebook_bg, anchor='nw', width=900, height=450) + self._canvas_items.append(tab_window) + self._overlay_widgets.append(notebook_bg) + self._overlay_widgets.append(self.install_tabs) + + self.install_consoles = {} + + def _add_tab(title: str, content: str = "", script_name: str | None = None): + # Use a background frame to ensure NO borders are visible around the console + console_bg = tk.Frame(self.install_tabs, bg='white', highlightthickness=0, bd=0) + self.install_tabs.add(console_bg, text=title) + # Use TerminalConsole for consistent styling + console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) + console.pack(fill='both', expand=True, padx=1, pady=1) + if content: + console.write(content) + if script_name: + self.install_consoles[script_name] = console + return console + + # Add existing logs + logs = self._collect_install_logs() + for p in logs: + try: + text = p.read_text(encoding='utf-8', errors='ignore') + content = f"{p}\n\n{text}" + except Exception as e: + content = f"{p}\n\nFailed to read log: {e}\n" + _add_tab(p.name, content) + + # Add prole.cfg tab + cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' + try: + cfg_text = cfg_path.read_text(encoding='utf-8', errors='ignore') + cfg_content = f"{cfg_path}\n\n{cfg_text}" + except Exception as e: + cfg_content = f"{cfg_path}\n\nprole.cfg not found or unreadable: {e}\n" + _add_tab('prole.cfg', cfg_content) + + # Add final_deployment.sh tab + _add_tab('final_deployment.sh', script_name='final_deployment.sh') + # Add build-a-bao.sh tab + _add_tab('build-a-bao.sh', script_name='build-a-bao.sh') + + # Build-A-Bao Button + self._build_a_bao_button = tk.Button(self.bg_canvas, text='Build-A-Bao', command=self.run_build_a_bao, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + bao_btn_window = self.bg_canvas.create_window(48, 760, window=self._build_a_bao_button, anchor='nw', width=180) + self._canvas_items.append(bao_btn_window) + self._overlay_widgets.append(self._build_a_bao_button) + + # Status Label + self._build_a_bao_status_label = ui.canvas_text(self, 48, 802, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._build_a_bao_status_label) + + def _get_prole_dist_dir(self) -> Path: + return PROJECT_ROOT / 'prole-tools-app' / 'dist' + + def ensure_applications_symlink(self): + """Deprecated: no longer create /Applications symlink inside the repo. + + We now place the symlink only inside the DMG staging directory to avoid + confusing IDE indexers and to keep the workspace clean. + """ + return + + def open_drag_install_window(self): + """Open the Prole DMG in Finder (macOS) for drag-and-drop install.""" + if platform.system() != 'Darwin': + return + p = self._get_dmg_paths() + dmg = p['final_dmg'] + if not dmg.exists(): + try: + self.create_dmg() + except Exception: + pass + try: + subprocess.run(['open', str(dmg)]) + except Exception: + try: + subprocess.run(['open', str(p['dist'])]) + except Exception: + pass + + # ---------------- DMG Packaging ---------------- + def _get_dmg_paths(self): + # Ensure we have a valid path for DMG output. + # Default to ~/Downloads if selected path is home or invalid. + raw_path = self.selected_local_path.get() + user_dist = Path(raw_path).expanduser() + + if str(user_dist) == str(Path.home()): + user_dist = Path.home() / 'Downloads' + self.selected_local_path.set(str(user_dist)) + + try: + user_dist.mkdir(parents=True, exist_ok=True) + except Exception: + user_dist = self._get_prole_dist_dir() + + dmg_name = 'Prole Tools.dmg' + tmp_dmg = user_dist / 'Prole Tools.tmp.dmg' + final_dmg = user_dist / dmg_name + + # Use a temporary staging area in the app's dist dir to keep user folder clean + app_dist = self._get_prole_dist_dir() + staging = app_dist / 'dmg_stage' + bg_dir = staging / '.background' + bg_img = get_resource_path('img/proleLogoBlueprint.png') + return { + 'dist': user_dist, + 'tmp_dmg': tmp_dmg, + 'final_dmg': final_dmg, + 'staging': staging, + 'bg_dir': bg_dir, + 'bg_img': bg_img, + } + + def create_dmg(self): + """Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink.""" + global installer_name + if platform.system() != 'Darwin': + return + + p = self._get_dmg_paths() + app_src = get_resource_path('prole-app/dist/Prole Tools.app') + if not app_src.exists(): + print(f"Error: {app_src} not found. Run build first.") + return + + staging = p['staging'] + try: + if staging.exists(): + print(f"Cleaning staging area: {staging}") + shutil.rmtree(staging, ignore_errors=True) + staging.mkdir(parents=True, exist_ok=True) + + # 1. Build static installer binary using PyInstaller + print("Building static installer binary...") + installer_name = "Install Prole Infrastructure" + icon_path = PROJECT_ROOT / 'img' / 'proleIconblueprint.png' + try: + # Use --onefile for a single executable + cmd = [ + sys.executable, '-m', 'PyInstaller', + '--onefile', + '--name', installer_name, + '--clean', + '--noconsole', # It's a GUI app (tkinter) + ] + if icon_path.exists(): + cmd.extend(['--icon', str(icon_path)]) + + cmd.append('install.py') + + subprocess.check_call(cmd) + setup_bin = PROJECT_ROOT / 'dist' / installer_name + if setup_bin.exists(): + dst_setup = staging / installer_name + if dst_setup.exists(): + if dst_setup.is_dir(): + shutil.rmtree(dst_setup) + else: + dst_setup.unlink() + shutil.copy2(setup_bin, dst_setup) + else: + print(f"Error: PyInstaller failed to create '{installer_name}' binary.") + except Exception as e: + messagebox.showerror("Error", f"Failed to create DMG: {e}") + print(f"Warning: Failed to build setup binary with PyInstaller: {e}") + + # 2. Copy Prole Tools.app to staging (at root for drag-and-drop) + print("Copying Prole Tools.app to staging...") + dst_app = staging / 'Prole Tools.app' + if dst_app.exists(): + shutil.rmtree(dst_app) + + if sys.version_info >= (3, 8): + shutil.copytree(app_src, dst_app, dirs_exist_ok=True) + else: + subprocess.check_call(['cp', '-R', str(app_src), str(dst_app)]) + + # Inject launcher wrapper into Prole Tools.app + macos_dir = dst_app / 'Contents' / 'MacOS' + launcher_path = macos_dir / 'Prole Tools' + real_bin_path = macos_dir / 'ProleTools.bin' + if launcher_path.exists() and launcher_path.is_file(): + if real_bin_path.exists(): + if real_bin_path.is_dir(): + shutil.rmtree(real_bin_path) + else: + real_bin_path.unlink() + os.rename(launcher_path, real_bin_path) + + script = """#!/bin/bash +set -euo pipefail +export PROLE_HOME="${PROLE_HOME:-$HOME/.prole}" +if [ -f "$PROLE_HOME/env.sh" ]; then + . "$PROLE_HOME/env.sh" +fi +DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$DIR/ProleTools.bin" "$@" +""" + with open(launcher_path, 'w') as fp: + fp.write(script) + os.chmod(launcher_path, 0o755) + + # 3. Create Applications symlink + try: + os.symlink('/Applications', str(staging / 'Applications')) + except FileExistsError: + pass + + # 4. Background image + if p['bg_dir'].exists(): + shutil.rmtree(p['bg_dir']) + p['bg_dir'].mkdir(parents=True, exist_ok=True) + if p['bg_img'].exists(): + shutil.copy2(p['bg_img'], p['bg_dir'] / 'background.png') + + # 5. Create the DMG + tmp_dmg = p['tmp_dmg'] + final_dmg = p['final_dmg'] + if final_dmg.exists(): + os.remove(final_dmg) + + # Create the DMG using hdiutil + messagebox.showinfo("Creating DMG", "Building DMG image. This may take a minute...") + subprocess.check_call([ + 'hdiutil', 'create', + '-volname', 'Prole', + '-srcfolder', str(staging), + '-ov', '-format', 'UDRW', # Create as Read/Write initially to modify view options + str(tmp_dmg) + ]) + + # Positions in DMG: + # [Install Prole Infrastructure] (left) + # [Prole Tools.app] (center/right) + # [Applications] (below Prole Tools.app) + + # Note: We use the installer name in the AppleScript. + # Finder items need to match the actual file names on disk. + # \n in filename might be literal or interpreted. + + # 6. Set DMG view options (large icons) using AppleScript + print("Configuring DMG view options...") + mount_point = Path('/Volumes/Prole') + try: + # Detach if already mounted + subprocess.run(['hdiutil', 'detach', str(mount_point)], capture_output=True) + + # Mount the temporary DMG + subprocess.check_call(['hdiutil', 'attach', str(tmp_dmg), '-nobrowse']) + + # Give it a moment to mount + time.sleep(2) + + if mount_point.exists(): + # Escape the installer name for AppleScript + # Use the name that actually exists on disk. + # PyInstaller might have replaced \n with something else in the filename if it was problematic, + # but usually it's literal in the FS if allowed. + + applescript = f''' + tell application "Finder" + tell disk "Prole" + open + set current view of container window to icon view + set toolbar visible of container window to false + set statusbar visible of container window to false + set the_container to container window + set bounds of the_container to {{400, 100, 1000, 600}} + set icon_view_options to icon view options of the_container + set icon size of icon_view_options to 192 + set arrangement of icon_view_options to not arranged + set background picture of icon_view_options to file ".background:background.png" + + -- Position icons + set position of item "{installer_name}" of container window to {{150, 200}} + set position of item "Prole Tools.app" of container window to {{450, 200}} + set position of item "Applications" of container window to {{450, 400}} + + update without registering applications + delay 2 + close + end tell + end tell + ''' + subprocess.run(['osascript', '-e', applescript]) + + # Detach + subprocess.check_call(['hdiutil', 'detach', str(mount_point)]) + + # Convert to final compressed format + if final_dmg.exists(): + os.remove(final_dmg) + subprocess.check_call([ + 'hdiutil', 'convert', str(tmp_dmg), + '-format', 'UDZO', + '-o', str(final_dmg) + ]) + if tmp_dmg.exists(): + os.remove(tmp_dmg) + except Exception as e: + print(f"Warning: Failed to set DMG view options: {e}") + # Fallback: just rename tmp_dmg if conversion/AppleScript failed + if not final_dmg.exists(): + os.rename(tmp_dmg, final_dmg) + + messagebox.showinfo("Success", f"Successfully created {final_dmg}") + print(f"Successfully created {final_dmg}") + + finally: + # Clean up staging directory + try: + if staging.exists(): + shutil.rmtree(staging, ignore_errors=True) + # Also clean up PyInstaller artifacts + for d in ['build', 'dist']: + p_path = PROJECT_ROOT / d + if p_path.exists(): + # Be careful not to delete 'dist' if it contains our final DMG + # However, create_dmg is usually run to build the DMG + # and PyInstaller artifacts are usually temporary in this context. + # We only remove them if they were created during this run. + pass + spec_file = PROJECT_ROOT / f"{installer_name}.spec" + if spec_file.exists(): + os.remove(spec_file) + except Exception: + pass + + def open_dmg(self): + """Reveal the created DMG in Finder without mounting it inline. + + This avoids blocking the installer process and lets macOS handle + mounting/ejecting normally when the user opens the DMG. + """ + if platform.system() != 'Darwin': + return + p = self._get_dmg_paths() + dmg = p['final_dmg'] + if not dmg.exists(): + # Try to create it first + self.create_dmg() + # Reveal the DMG in Finder (non-blocking); do NOT attach/mount inline + try: + subprocess.run(['open', '-R', str(dmg)]) + except Exception: + try: + # Fallback: open the dist folder + subprocess.run(['open', str(p['dist'])]) + except Exception: + pass + + def create_install_screen(self): + """Create the dependency installer screen""" + frame = tk.Frame(self.content_area, bg='#1a1a1a') + self.screens['install'] = frame + + # Title + title = ttk.Label(frame, text="Install Dependencies", style='Title.TLabel') + title.pack(pady=(0, 30)) + + # Instructions + instructions = tk.Label(frame, + text="Install the following dependencies to proceed with Prole deployment:", + bg='#1a1a1a', + fg='#aaaaaa', + font=('Helvetica', 11)) + instructions.pack(pady=(0, 20)) + + # Dependencies list + deps_frame = tk.Frame(frame, bg='#1a1a1a') + deps_frame.pack(fill='both', expand=True) + + dependencies = [ + { + 'name': 'Docker', + 'description': 'Container platform for running Prole services', + 'url': 'https://www.docker.com/products/docker-desktop', + 'install_cmd': None, + 'check_cmd': 'docker --version' + }, + { + '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' + }, + { + 'name': 'k3d', + 'description': 'Lightweight wrapper to run k3s in Docker', + 'url': 'https://k3d.io', + 'install_cmd': 'brew install k3d', + 'check_cmd': 'k3d --version' + }, + { + 'name': 'kubectl', + 'description': 'Kubernetes command-line tool', + 'url': 'https://kubernetes.io/docs/tasks/tools/', + 'install_cmd': 'brew install kubectl', + 'check_cmd': 'kubectl version --client' + }, + { + 'name': 'Helm', + 'description': 'Kubernetes package manager', + 'url': 'https://helm.sh', + 'install_cmd': 'brew install helm', + 'check_cmd': 'helm version' + }, + { + 'name': 'krew', + 'description': 'Kubectl plugin manager', + 'url': 'https://krew.sigs.k8s.io', + 'install_cmd': 'brew install krew', + 'check_cmd': 'kubectl krew version' + }, + { + 'name': 'cmctl', + 'description': 'cert-manager CLI tool', + 'url': 'https://cert-manager.io', + 'install_cmd': 'brew install cmctl', + 'check_cmd': 'cmctl version' + } + ] + + self.dep_status = {} + for dep in dependencies: + self.create_dependency_card(deps_frame, dep) + + # Generate installer script button + script_btn = tk.Button(frame, + text="Generate Installer Script", + command=self.generate_installer_script, + bg='#4a9eff', + fg='white', + activebackground='#3a8eef', + font=('Helvetica', 12, 'bold'), + padx=30, + pady=15, + cursor='hand2', + relief='flat') + script_btn.pack(pady=20) + + def create_dependency_card(self, parent, dep): + """Create a dependency card with status and download link""" + card = tk.Frame(parent, bg='#2a2a2a', relief='flat', bd=1) + card.pack(fill='x', pady=5, padx=10) + + # Left side - info + info_frame = tk.Frame(card, bg='#2a2a2a') + info_frame.pack(side='left', fill='both', expand=True, padx=15, pady=15) + + name_label = tk.Label(info_frame, + text=dep['name'], + bg='#2a2a2a', + fg='#ffffff', + font=('Helvetica', 13, 'bold'), + anchor='w') + name_label.pack(fill='x') + + desc_label = tk.Label(info_frame, + text=dep['description'], + bg='#2a2a2a', + fg='#aaaaaa', + font=('Helvetica', 10), + anchor='w') + desc_label.pack(fill='x', pady=(5, 0)) + + # Right side - status and actions + action_frame = tk.Frame(card, bg='#2a2a2a') + action_frame.pack(side='right', padx=15, pady=15) + + # Status indicator + status_label = tk.Label(action_frame, + text="Checking...", + bg='#2a2a2a', + fg='#ffaa00', + font=('Helvetica', 10)) + status_label.pack(side='left', padx=10) + self.dep_status[dep['name']] = {'label': status_label, 'dep': dep} + + # Download button + download_btn = tk.Button(action_frame, + text="Download", + command=lambda url=dep['url']: webbrowser.open(url), + bg='#28a745', + fg='white', + activebackground='#218838', + font=('Helvetica', 10), + padx=15, + pady=5, + cursor='hand2', + relief='flat') + download_btn.pack(side='left', padx=5) + + # Check status + self.check_dependency(dep['name']) + + def check_dependency(self, name): + """Check if a dependency is installed""" + dep_info = self.dep_status[name] + dep = dep_info['dep'] + label = dep_info['label'] + + def check(): + try: + result = subprocess.run(dep['check_cmd'].split(), + capture_output=True, + text=True, + timeout=5) + if result.returncode == 0: + label.configure(text="✓ Installed", fg='#28a745') + else: + label.configure(text="✗ Not Installed", fg='#dc3545') + except Exception: + label.configure(text="✗ Not Installed", fg='#dc3545') + + threading.Thread(target=check, daemon=True).start() + + def generate_installer_script(self): + """Generate installer script by composing per-dependency templates from installer/scripts.""" + script_path = PROJECT_ROOT / 'install_dependencies.sh' + scripts_dir = PROJECT_ROOT / 'installer' / 'scripts' + + # Expected order of templates + expected = [ + 'install_prole_homebrew.sh', + 'install_prole_k3d.sh', + 'install_prole_kubectl.sh', + 'install_prole_helm.sh', + 'install_prole_krew.sh', + 'install_prole_cmctl.sh', + 'install_prole_kubectl_plugins.sh', + ] + + def _validate_and_strip(path: Path) -> str: + """Validate template format and return content stripped of shebang and initial set -e* line.""" + try: + text = path.read_text(encoding='utf-8') + except Exception as e: + raise RuntimeError(f"Failed to read {path.name}: {e}") + + lines = text.splitlines() + if not lines: + raise RuntimeError(f"Template {path.name} is empty") + # Validate shebang + if not lines[0].startswith('#!/bin/bash'): + raise RuntimeError(f"Template {path.name} must start with #!/bin/bash") + # Ensure there is a set -e or set -euo pipefail somewhere within first 10 lines + has_set = any('set -e' in l for l in lines[:10]) + if not has_set: + raise RuntimeError(f"Template {path.name} must set '-e' or 'set -euo pipefail'") + # Strip shebang + i = 1 + # Optionally strip empty/comment lines immediately after shebang + while i < len(lines) and lines[i].strip() == '': + i += 1 + # If next non-empty is a "set -e*" line, drop it to avoid duplicates in composed script + if i < len(lines) and lines[i].lstrip().startswith('set -e'): + i += 1 + body = "\n".join(lines[i:]).strip() + "\n" + return body + + # Compose the final script + errors: list[str] = [] + parts: list[str] = [] + + header = ( + "#!/bin/bash\n" + "# Prole Dependencies Installer Script\n" + "# Generated by Prole Installer\n\n" + "set -euo pipefail\n\n" + "echo \"Installing Prole dependencies...\"\n\n" + ) + parts.append(header) + + for fname in expected: + p = scripts_dir / fname + if not p.exists(): + errors.append(f"Missing template: {fname}") + continue + try: + body = _validate_and_strip(p) + parts.append(f"# ---- {fname} ----\n") + parts.append(body) + parts.append("\n") + except Exception as e: + errors.append(str(e)) + + parts.append('echo "All dependencies installed successfully!"\n') + + if errors: + messagebox.showerror("Template error", "\n".join(errors)) + return + + try: + with open(script_path, 'w', encoding='utf-8', newline='\n') as f: + f.write("".join(parts)) + os.chmod(script_path, 0o755) + messagebox.showinfo("Success", + f"Installer script generated at:\n{script_path}\n\n" + "You can run it with: ./install_dependencies.sh") + except Exception as e: + messagebox.showerror("Error", f"Failed to generate script: {str(e)}") + + def _collect_install_logs(self) -> list[Path]: + logs_dir = self._resolve_prole_logs_dir() + build_logs = [] + init_logs = [] + seen = set() + + def _add(p: Path): + sp = str(p) + if sp in seen: + return + if not p.exists(): + return + seen.add(sp) + if p.name.startswith('build-'): + build_logs.append(p) + elif p.name.startswith('init_') or p.name.startswith('init-') or p.name == 'init_common_services.log': + init_logs.append(p) + + for raw in getattr(self, '_install_run_log_paths', []): + try: + _add(Path(raw)) + except Exception: + pass + + try: + lbp = getattr(self, 'last_build_log_path', None) + if lbp: + _add(Path(lbp)) + except Exception: + pass + + if not build_logs: + try: + candidates = sorted(logs_dir.glob('build-*.log'), key=lambda p: p.stat().st_mtime) + if candidates: + _add(candidates[-1]) + except Exception: + pass + + if not init_logs: + try: + candidates = sorted(logs_dir.glob('init_*.log')) + for p in candidates: + _add(p) + except Exception: + pass + + try: + build_logs.sort(key=lambda p: p.stat().st_mtime) + except Exception: + pass + try: + init_logs.sort(key=lambda p: p.name) + except Exception: + pass + return build_logs + init_logs + + def get_removable_disks(self): + """Detect removable media on macOS using diskutil.""" + self.removable_disks = [] + if platform.system() != 'Darwin': + return self.removable_disks + + try: + # Get list of all disks + res = subprocess.run(['diskutil', 'list', '-plist'], capture_output=True, text=True) + if res.returncode != 0: + return [] + + import plistlib + data = plistlib.loads(res.stdout.encode()) + all_disks = data.get('AllDisks', []) + + for disk in all_disks: + # Filter for whole disks to check if they are removable + if not disk.startswith('disk') or 's' in disk: + continue + + info_res = subprocess.run(['diskutil', 'info', '-plist', disk], capture_output=True, text=True) + if info_res.returncode == 0: + info = plistlib.loads(info_res.stdout.encode()) + # Check for RemovableMedia or RemovableMediaOrExternalDevice + # Also check BusProtocol to catch most USB sticks if they don't report as removable + is_removable = ( + info.get('RemovableMedia', False) or + info.get('RemovableMediaOrExternalDevice', False) or + info.get('BusProtocol') in ['USB', 'FireWire', 'Thunderbolt'] + ) + + # Ensure it's not the internal system drive if we are using protocol as a hint + if info.get('Internal', False) and info.get('BusProtocol') not in ['USB']: + is_removable = False + + if is_removable: + # Found a removable disk, now find its mounted volumes + # We look for partitions of this disk that are mounted + for d2 in all_disks: + if d2.startswith(disk + 's'): + v_res = subprocess.run(['diskutil', 'info', '-plist', d2], capture_output=True, text=True) + if v_res.returncode == 0: + v_info = plistlib.loads(v_res.stdout.encode()) + mount_point = v_info.get('MountPoint') + volume_name = v_info.get('VolumeName') or v_info.get('DeviceIdentifier') + if mount_point: + self.removable_disks.append((volume_name, mount_point)) + except Exception as e: + print(f"Error detecting disks: {e}") + + return self.removable_disks diff --git a/installer/ui/screens/security.py b/installer/ui/screens/security.py new file mode 100644 index 0000000..af82a85 --- /dev/null +++ b/installer/ui/screens/security.py @@ -0,0 +1,319 @@ +"""Kerberos configuration, secret management and Ansible vault.""" + +import configparser +import os +import shutil +import subprocess +import threading +from pathlib import Path +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import screen as ui +from installer.core.env import ( + PROJECT_ROOT, + _bool_str, + _deployment_target_label, + _ensure_ansible_vault_credentials, +) +from installer.config import _decrypt_prole_secret, _is_openbao_ref, _is_prole_secret + + +class SecurityScreenMixin: + """Kerberos configuration, secret management and Ansible vault.""" + + def _render_kerberos_config_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Kerberos Authentication', y=150) + self._render_paragraph("Configure Kerberos authentication for Prole and the PostgreSQL database. Tests run inside the Kubernetes namespace to validate against your realm.", y=200) + + y = 280 + # Use tk.Checkbutton on canvas + enable_cb = tk.Checkbutton(self.bg_canvas, text="Enable Kerberos Authentication", + variable=self.kerberos_enabled, bg='white', fg='black', + activebackground='white', selectcolor='white', font=('SF Pro Text', 11)) + cb_window = self.bg_canvas.create_window(48, y, window=enable_cb, anchor='nw') + self._canvas_items.append(cb_window) + self._overlay_widgets.append(enable_cb) + + x_field = 200 + field_w = 400 + + y += 60 + ui.canvas_text(self, 48, y, "Kerberos Realm:", fill='black', font=('SF Pro Text', 12)) + realm_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_realm, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + realm_window = self.bg_canvas.create_window(x_field, y-4, window=realm_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(realm_window) + self._overlay_widgets.append(realm_entry) + + y += 48 + ui.canvas_text(self, 48, y, "KDC Host/IP:", fill='black', font=('SF Pro Text', 12)) + kdc_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_kdc, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + kdc_window = self.bg_canvas.create_window(x_field, y-4, window=kdc_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(kdc_window) + self._overlay_widgets.append(kdc_entry) + + y += 48 + ui.canvas_text(self, 48, y, "Username:", fill='black', font=('SF Pro Text', 12)) + user_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_user, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + user_window = self.bg_canvas.create_window(x_field, y-4, window=user_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(user_window) + self._overlay_widgets.append(user_entry) + + y += 48 + ui.canvas_text(self, 48, y, "Password:", fill='black', font=('SF Pro Text', 12)) + pass_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_password, show="*", bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + pass_window = self.bg_canvas.create_window(x_field, y-4, window=pass_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(pass_window) + self._overlay_widgets.append(pass_entry) + + y += 60 + # Use tk.Button on canvas + test_btn = tk.Button(self.bg_canvas, text="Test Connection", command=self.test_kerberos_connection, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, y, window=test_btn, anchor='nw') + self._canvas_items.append(btn_window) + self._overlay_widgets.append(test_btn) + + y += 60 + # Use tk.Text + self.kerberos_status_text = tk.Text(self.bg_canvas, width=100, height=12, font=('Menlo', 10), + bg='white', fg='black', insertbackground='black', + highlightbackground='#CCCCCC', highlightthickness=1, relief='flat') + status_window = self.bg_canvas.create_window(48, y, window=self.kerberos_status_text, anchor='nw', width=800, height=200) + self._canvas_items.append(status_window) + self._overlay_widgets.append(self.kerberos_status_text) + + def test_kerberos_connection(self): + self._action_flags['kerberos_config.test_connection'] = True + realm = self.kerberos_realm.get().strip() + user = self.kerberos_user.get().strip() + password = self.kerberos_password.get().strip() + kdc = self.kerberos_kdc.get().strip() + + if not realm or not user or not password or not kdc: + messagebox.showerror("Error", "Please fill in realm, KDC host, username, and password.") + return + + try: + self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get()) + 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['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) + 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') + self._save_prole_cfg() + except Exception: + pass + + self.kerberos_status_text.delete('1.0', tk.END) + self.kerberos_status_text.insert(tk.END, f"Testing {user}@{realm} inside the cluster...\n") + + def worker(): + try: + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + env["NAMESPACE"] = (self.db_namespace.get() or '').strip() + env["SERVICE_NAMESPACE"] = self._get_service_namespace() + env["KRB5_REALM"] = realm + env["REALM"] = realm + env["DOMAIN"] = realm.lower() + env["KRB5_KDC"] = kdc + env["KRB5_ADMIN"] = kdc + env["KRB5_USER"] = user + env["KRB5_PASSWORD"] = password + env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get()) + env["ENABLED"] = env["KERBEROS_ENABLED"] + mode = self._deployment_mode() + if mode == "k3s": + env.setdefault("KRB5_AD_PORT_FORWARD", "0") + if mode: + env["PROLE_MODE"] = mode + env["DEPLOYMENT_MODE"] = mode + env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) + mode_args = ["--mode", mode] if mode else [] + + def write_line(line): + def _do_write(l=line): + try: + if self.kerberos_status_text.winfo_exists(): + self.kerberos_status_text.insert(tk.END, l) + self.kerberos_status_text.see(tk.END) + except (tk.TclError, RuntimeError): + pass + self.safe_after(_do_write) + + write_line("\n==> init_kerberos.sh test\n") + rc2 = self.controller.run_script( + "init_kerberos.sh", + args=mode_args + ["test"], + env=env, + on_line=write_line + ) + if rc2 == 0: + write_line("\nKerberos test completed successfully.\n") + else: + write_line(f"\nKerberos test failed with code {rc2}\n") + + except Exception as e: + def _do_error(msg=str(e)): + try: + if self.kerberos_status_text.winfo_exists(): + self.kerberos_status_text.insert(tk.END, f"Error: {msg}\n") + except (tk.TclError, RuntimeError): + pass + self.safe_after(_do_error) + + threading.Thread(target=worker, daemon=True).start() + + + def _secret_namespace(self) -> str: + ns = '' + try: + ns = (self.db_namespace.get() or '').strip() + except Exception: + ns = '' + if not ns: + ns = os.environ.get('NAMESPACE', '') or 'default' + return ns + + def _load_secret_cache_from_cfg(self): + try: + if self._cfg_path_override is not None: + cfg_path = self._cfg_path_override + if cfg_path.is_dir(): + cfg_path = cfg_path / 'prole.cfg' + else: + conf_dir = self._resolve_prole_conf_dir() + cfg_path = conf_dir / 'prole.cfg' + except Exception: + return + if not cfg_path.exists(): + return + cfg = configparser.ConfigParser(interpolation=None) + cfg.optionxform = str + cfg.read(cfg_path) + for (section, key), _spec in SECRET_KEY_SPECS.items(): + if cfg.has_option(section, key): + val = cfg.get(section, key, fallback="").strip() + if val: + self._cfg_secret_cache[(section, key)] = val + if _is_openbao_ref(val): + self._secrets_finalized = True + + def _resolve_cached(pairs: list[tuple[str, str]]) -> str: + for pair in pairs: + val = self._cfg_secret_cache.get(pair, '') + if val: + return val + return '' + + db_val = _resolve_cached([ + ("Inputs", "init_password.db_password"), + ("Global", "DB_PASSWORD"), + ]) + if db_val and _is_prole_secret(db_val): + db_plain = _decrypt_prole_secret(db_val) + elif db_val and not _is_openbao_ref(db_val): + db_plain = db_val + else: + db_plain = '' + if db_plain: + try: + self.db_password.set(db_plain) + self.db_password_confirm.set(db_plain) + except Exception: + pass + + krb_val = _resolve_cached([ + ("Inputs", "kerberos_config.password"), + ("Kerberos Authentication", "PASSWORD"), + ]) + if krb_val and _is_prole_secret(krb_val): + krb_plain = _decrypt_prole_secret(krb_val) + elif krb_val and not _is_openbao_ref(krb_val): + krb_plain = krb_val + else: + krb_plain = '' + if krb_plain: + try: + self.kerberos_password.set(krb_plain) + except Exception: + pass + + def _save_ansible_prole_vault(self, root_password: str): + if not root_password: + return + + vault_path = PROJECT_ROOT / 'infrastructure' / 'inventory' / 'group_vars' / 'all' / 'prole_vault.yml' + + _ensure_ansible_vault_credentials(prompt_ui=False) + vault_pass_file = os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') + + if not vault_pass_file or not Path(vault_pass_file).exists(): + for base in (Path.cwd(), PROJECT_ROOT): + candidate = base / '.vault_pass' + if candidate.is_file(): + vault_pass_file = str(candidate) + break + + if not vault_pass_file or not Path(vault_pass_file).exists(): + return + + if shutil.which('ansible-vault') is None: + return + + try: + # We must decrypt if it is a prole secret + plain_pass = root_password + if _is_prole_secret(root_password): + try: + plain_pass = _decrypt_prole_secret(root_password) + except Exception: + pass + + content = f"prole_root_password: \"{plain_pass}\"\n" + + with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: + f.write(content) + tmp_name = f.name + + try: + os.chmod(tmp_name, 0o600) + subprocess.run([ + 'ansible-vault', 'encrypt', tmp_name, + '--vault-password-file', vault_pass_file + ], check=True, capture_output=True) + + shutil.move(tmp_name, str(vault_path)) + os.chmod(str(vault_path), 0o600) + finally: + if os.path.exists(tmp_name): + try: os.unlink(tmp_name) + except: pass + except Exception as e: + print(f"[ERROR] Failed to save ansible vault: {e}") diff --git a/installer/ui/screens/services.py b/installer/ui/screens/services.py new file mode 100644 index 0000000..70442a8 --- /dev/null +++ b/installer/ui/screens/services.py @@ -0,0 +1,1053 @@ +"""Initialization scripts, CNPG deployment and service-layer overlays.""" + +import os +import platform +import secrets +import shlex +import subprocess +import threading +import time +import webbrowser +from pathlib import Path +from datetime import datetime +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer.screen import TerminalConsole +from installer import screen as ui +from installer.core.env import ( + PROJECT_ROOT, + _bool_str, + _deployment_mode_from_env, + _deployment_target_label, + _normalize_cluster_env, +) + + +class ServicesScreenMixin: + """Initialization scripts, CNPG deployment and service-layer overlays.""" + + def _render_init_scripts_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Initialization Scripts', y=150) + self._render_paragraph('Running initialization scripts to set up Garage, CloudNative-PG, and backups.', y=200) + + y = 260 + y = self._render_init_scripts_section(48, y, 900, 450, title="Execution Output") + + # Use tk.Button + self._init_scripts_button = tk.Button(self.bg_canvas, text='Run Scripts', command=self.run_init_scripts, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, y + 20, window=self._init_scripts_button, anchor='nw', width=180) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(self._init_scripts_button) + + # Status Label + self._init_scripts_status_label = ui.canvas_text(self, 240, y + 32, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._init_scripts_status_label) + + def _render_init_scripts_section(self, x: int, y: int, width: int, height: int, title: str | None = "Execution Output") -> int: + if title: + title_item = ui.canvas_text(self, x, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) + self._canvas_items.append(title_item) + y += 30 + + # Use a background frame for the notebook to hide potential system borders + notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) + self.script_tabs = ttk.Notebook(notebook_bg, style='TNotebook') + self.script_tabs.pack(fill='both', expand=True, padx=1, pady=1) + + tab_window = self.bg_canvas.create_window(x, y, window=notebook_bg, anchor='nw', width=width, height=height) + self._canvas_items.append(tab_window) + self._overlay_widgets.append(notebook_bg) + self._overlay_widgets.append(self.script_tabs) + + self.script_consoles = {} + scripts = self._init_scripts_list() + self._script_tab_index = {} + + for idx, (title, fname) in enumerate(scripts): + # Use a background frame to ensure NO borders are visible around the console + console_bg = tk.Frame(self.script_tabs, bg='white', highlightthickness=0, bd=0) + self.script_tabs.add(console_bg, text=title) + # Use TerminalConsole for consistent styling + if fname == self.KUBECTL_STATUS_TAB: + mono = ('Menlo', 9) if platform.system() == 'Darwin' else ('Consolas', 9) + console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0, font=mono, wrap='none', show_horizontal=True) + else: + console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) + console.pack(fill='both', expand=True, padx=1, pady=1) + self.script_consoles[fname] = console + self._script_tab_index[fname] = idx + self._overlay_widgets.append(console_bg) + self._overlay_widgets.append(console) + + self.script_tabs.bind("<>", self._on_init_scripts_tab_changed) + self.safe_after(self._refresh_kubectl_status_tab, delay=50) + + return y + height + + def _init_scripts_list(self): + scripts = [ + ('Kubectl Status', self.KUBECTL_STATUS_TAB), + ('Common Services', 'init_common_services.sh'), + ('Common Services Log', 'common_services_log'), + ('CloudNative-PG', 'init_cloudnative_pg.sh'), + ] + if self.kerberos_enabled.get(): + scripts.append(('Kerberos Realm', 'init_kerberos.sh')) + scripts.extend([ + ('Prole DB Backup', 'init_prole-db-backup.sh'), + ('Monitoring', 'init_monitoring.sh'), + ('Nginx Ingress', 'init_nginx_ingress.sh'), + ('Port Forwards', 'init_port_forwards.sh'), + ]) + return scripts + + def run_init_scripts(self): + self._action_flags['init_scripts.run_scripts'] = True + def worker(): + def _select_tab(script_name): + idx = getattr(self, '_script_tab_index', {}).get(script_name) + if idx is None: + return + self.safe_after(lambda: self.script_tabs.select(idx) if self.script_tabs.winfo_exists() else None) + + self.safe_after(lambda: self._init_scripts_button.configure(state='disabled') if self._init_scripts_button.winfo_exists() else None) + self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Running scripts...", fill='blue') if self.bg_canvas.winfo_exists() else None) + + password = self.db_password.get() + + # Prepare environment for scripts + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + env["PROLE_DB_USER"] = self.db_username.get() + env["DB_PASSWORD"] = password + env["GRAFANA_ADMIN_PASSWORD"] = password + env["NAMESPACE"] = (self.db_namespace.get() or '').strip() + env["SERVICE_NAMESPACE"] = self._get_service_namespace() + if self.kerberos_realm.get().strip(): + env["KRB5_REALM"] = self.kerberos_realm.get().strip() + env["REALM"] = self.kerberos_realm.get().strip() + env["DOMAIN"] = self.kerberos_realm.get().strip().lower() + if self.kerberos_kdc.get().strip(): + env["KRB5_KDC"] = self.kerberos_kdc.get().strip() + env["KRB5_ADMIN"] = self.kerberos_kdc.get().strip() + if self.kerberos_user.get().strip(): + env["KRB5_USER"] = self.kerberos_user.get().strip() + if self.kerberos_password.get().strip(): + env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() + env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get()) + env["ENABLED"] = env["KERBEROS_ENABLED"] + mode = self._deployment_mode() + if mode: + env["PROLE_MODE"] = mode + env["DEPLOYMENT_MODE"] = mode + env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) + mode_args = ["--mode", mode] if mode else [] + + logs_dir = self._resolve_prole_logs_dir() + try: + logs_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + env.setdefault("PROLE_LOGS", str(logs_dir)) + + def _log_path_for(script_name: str) -> Path: + base = Path(script_name).stem + if base in ('common_services_log', 'init_common_services'): + return logs_dir / 'init_common_services.log' + return logs_dir / f'{base}.log' + + # 1. init_common_services.sh update + overall_success = True + if overall_success: + script = "init_common_services.sh" + _select_tab(script) + self.script_consoles[script].clear() + self.script_consoles[script].write(f"Running {script} update...\n") + + svc_log_path = _log_path_for(script) + self._last_common_services_log_path = svc_log_path + env["COMMON_SERVICES_INIT_LOG"] = str(svc_log_path) + self._record_install_log(svc_log_path) + self.script_consoles["common_services_log"].clear() + self.script_consoles["common_services_log"].write(f"Log file: {svc_log_path}\n\n") + self.script_consoles[script].write(f"Log file: {svc_log_path}\n\n") + + try: + svc_fp = svc_log_path.open('a', encoding='utf-8') + except Exception: + svc_fp = None + + def _svc_line(line): + self.script_consoles[script].write(line) + self.script_consoles["common_services_log"].write(line) + self._process_script_output_line(line) + if svc_fp: + try: + svc_fp.write(line) + svc_fp.flush() + except Exception: + pass + + svc_args = list(mode_args) + if self.kerberos_enabled.get(): + svc_args.append("-k") + svc_args.append("update") + rc_svc = self.controller.run_script( + script, + args=svc_args, + env=env, + on_line=_svc_line + ) + if svc_fp: + try: + svc_fp.close() + except Exception: + pass + + if rc_svc != 0: + self.script_consoles[script].write(f"\nERROR: {script} update failed with code {rc_svc}\n") + overall_success = False + else: + self.script_consoles[script].write(f"\n{script} completed successfully.\n") + else: + self.script_consoles["init_common_services.sh"].write("Skipping common services initialization because previous steps failed.\n") + + # 2. init_cloudnative_pg.sh initialize + if overall_success: + script = "init_cloudnative_pg.sh" + _select_tab(script) + self.script_consoles[script].clear() + self.script_consoles[script].write(f"Running {script} initialize...\n") + self.script_consoles[script].write("> bash etc/init_cloudnative_pg.sh initialize\n") + + log_path = _log_path_for(script) + self._record_install_log(log_path) + try: + log_fp = log_path.open('a', encoding='utf-8') + except Exception: + log_fp = None + + def _cnpg_line(line): + self.script_consoles[script].write(line) + self._process_script_output_line(line) + if log_fp: + try: + log_fp.write(line) + log_fp.flush() + except Exception: + pass + + rc2 = self.controller.run_script( + script, + args=mode_args + ['initialize'], + env=env, + on_line=_cnpg_line + ) + if log_fp: + try: + log_fp.close() + except Exception: + pass + + if rc2 != 0: + self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc2}\n") + overall_success = False + else: + self.script_consoles[script].write(f"\n{script} completed successfully.\n") + else: + if "init_cloudnative_pg.sh" in self.script_consoles: + self.script_consoles["init_cloudnative_pg.sh"].write("Skipping CloudNative-PG initialization because previous steps failed.\n") + + # 3. init_kerberos.sh initialize (optional) + if overall_success and self.kerberos_enabled.get(): + script = "init_kerberos.sh" + if script in self.script_consoles: + _select_tab(script) + self.script_consoles[script].clear() + self.script_consoles[script].write(f"Running {script} initialize...\n") + + log_path = _log_path_for(script) + self._record_install_log(log_path) + try: + log_fp = log_path.open('a', encoding='utf-8') + except Exception: + log_fp = None + + def _krb_line(line): + self.script_consoles[script].write(line) + self._process_script_output_line(line) + if log_fp: + try: + log_fp.write(line) + log_fp.flush() + except Exception: + pass + + rc_krb = self.controller.run_script( + script, + args=mode_args + ['initialize'], + env=env, + on_line=_krb_line + ) + if log_fp: + try: + log_fp.close() + except Exception: + pass + + if rc_krb != 0: + self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_krb}\n") + overall_success = False + else: + self.script_consoles[script].write(f"\n{script} completed successfully.\n") + else: + fallback_console = self.script_consoles.get("init_cloudnative_pg.sh") + if fallback_console: + fallback_console.write("Kerberos init console missing; skipping.\n") + elif not self.kerberos_enabled.get(): + if "init_kerberos.sh" in self.script_consoles: + self.script_consoles["init_kerberos.sh"].write("Kerberos auth disabled; skipping init_kerberos.sh.\n") + # 6. init_prole-db-backup.sh start + if overall_success: + script = "init_prole-db-backup.sh" + _select_tab(script) + self.script_consoles[script].clear() + self.script_consoles[script].write(f"Running {script} start...\n") + + log_path = _log_path_for(script) + self._record_install_log(log_path) + try: + log_fp = log_path.open('a', encoding='utf-8') + except Exception: + log_fp = None + + def _backup_line(line): + self.script_consoles[script].write(line) + self._process_script_output_line(line) + if log_fp: + try: + log_fp.write(line) + log_fp.flush() + except Exception: + pass + + rc_backup = self.controller.run_script( + script, + args=mode_args + ['start'], + env=env, + on_line=_backup_line + ) + if log_fp: + try: + log_fp.close() + except Exception: + pass + + if rc_backup != 0: + self.script_consoles[script].write(f"\nERROR: {script} start failed with code {rc_backup}\n") + overall_success = False + else: + self.script_consoles[script].write(f"\n{script} completed successfully.\n") + else: + self.script_consoles["init_prole-db-backup.sh"].write("Skipping Prole DB backup because previous steps failed.\n") + + # 8. init_monitoring.sh initialize + if overall_success: + script = "init_monitoring.sh" + _select_tab(script) + self.script_consoles[script].clear() + self.script_consoles[script].write(f"Running {script} initialize...\n") + + log_path = _log_path_for(script) + self._record_install_log(log_path) + try: + log_fp = log_path.open('a', encoding='utf-8') + except Exception: + log_fp = None + + def _monitoring_line(line): + self.script_consoles[script].write(line) + self._process_script_output_line(line) + if log_fp: + try: + log_fp.write(line) + log_fp.flush() + except Exception: + pass + # Extract Grafana password if present + if "GRAFANA_ADMIN_PASSWORD=" in line: + pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip() + if pwd: + self.prole_cfg_data['Monitoring'] = {'GRAFANA_ADMIN_PASSWORD': pwd} + self._save_prole_cfg() + + rc_mon = self.controller.run_script( + script, + args=mode_args + ['initialize'], + env=env, + on_line=_monitoring_line + ) + if log_fp: + try: + log_fp.close() + except Exception: + pass + + if rc_mon != 0: + self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_mon}\n") + overall_success = False + else: + self.script_consoles[script].write(f"\n{script} completed successfully.\n") + else: + if "init_monitoring.sh" in self.script_consoles: + self.script_consoles["init_monitoring.sh"].write("Skipping Monitoring initialization because previous steps failed.\n") + + # 9. init_nginx_ingress.sh initialize + if overall_success: + script = "init_nginx_ingress.sh" + _select_tab(script) + self.script_consoles[script].clear() + self.script_consoles[script].write(f"Running {script} initialize...\n") + log_path = _log_path_for(script) + self._record_install_log(log_path) + try: + log_fp = log_path.open('a', encoding='utf-8') + except Exception: + log_fp = None + + def _ingress_line(line): + self.script_consoles[script].write(line) + self._process_script_output_line(line) + if log_fp: + try: + log_fp.write(line) + log_fp.flush() + except Exception: + pass + + rc_ing = self.controller.run_script( + script, + args=mode_args + ['initialize'], + env=env, + on_line=_ingress_line + ) + if log_fp: + try: + log_fp.close() + except Exception: + pass + if rc_ing != 0: + self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_ing}\n") + overall_success = False + else: + self.script_consoles[script].write(f"\n{script} completed successfully.\n") + else: + self.script_consoles["init_nginx_ingress.sh"].write("Skipping Nginx Ingress because previous steps failed.\n") + + # 10. init_port_forwards.sh start (k3d only) + if overall_success and mode == "k3d": + script = "init_port_forwards.sh" + if script in self.script_consoles: + _select_tab(script) + self.script_consoles[script].clear() + self.script_consoles[script].write(f"Running {script} start...\n") + + def _pf_line(line): + self.script_consoles[script].write(line) + self._process_script_output_line(line) + + env_pf = dict(env) + env_pf.setdefault("PORT_FORWARD_SKIP_VALIDATE", "1") + env_pf.setdefault("PORT_FORWARD_SKIP_WAIT", "1") + env_pf.setdefault("PORT_FORWARD_WAIT_TIMEOUT", "20") + env_pf.setdefault("PORT_FORWARD_WAIT_INTERVAL", "2") + + rc_pf = self.controller.run_script( + script, + args=["--mode", mode, "start"], + env=env_pf, + on_line=_pf_line + ) + if rc_pf != 0: + self.script_consoles[script].write(f"\nERROR: {script} start failed with code {rc_pf}\n") + overall_success = False + else: + running = False + for _ in range(10): + status_lines = [] + + def _status_line(line): + status_lines.append(line) + self.script_consoles[script].write(line) + + rc_status = self.controller.run_script( + script, + args=["--mode", mode, "status", "grafana"], + env=env_pf, + on_line=_status_line + ) + running = any(l.lstrip().startswith("RUNNING") and "grafana" in l for l in status_lines) + if rc_status == 0 and running: + break + time.sleep(1) + + if not running: + self.script_consoles[script].write("\nERROR: Grafana port-forward not running after start.\n") + overall_success = False + else: + self.script_consoles[script].write("\nPort-forwards running (grafana ok).\n") + + # Verify critical secrets + ns = env.get("NAMESPACE", "default") + self.script_consoles["init_cloudnative_pg.sh"].write(f"\nVerifying critical secrets in namespace {ns}...\n") + critical_secrets = ["prole-db-user", "prole-db-superuser", "cnpg-admin-key"] + missing_secrets = [] + for secret in critical_secrets: + rc_s, _ = self._run_cmd_capture(["kubectl", "get", "secret", secret, "-n", ns]) + if rc_s != 0: + missing_secrets.append(secret) + + if missing_secrets: + self.script_consoles["init_cloudnative_pg.sh"].write(f"CRITICAL: Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}.\n") + self.script_consoles["init_cloudnative_pg.sh"].write("Database initialization will fail without these secrets.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Critical secret missing.", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + overall_success = False + + if overall_success: + self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization complete!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + self._scripts_success = True + else: + self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + self._scripts_success = False + + try: + self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if self._scripts_success else 'Attempted' + self.safe_after(self._save_prole_cfg) + except Exception: + pass + + self.safe_after(lambda: self.update_footer()) + self.safe_after(lambda: self._init_scripts_button.configure(state='normal') if self._init_scripts_button.winfo_exists() else None) + self.safe_after(self.check_services_status_async) + + threading.Thread(target=worker, daemon=True).start() + + def _on_init_scripts_tab_changed(self, _event=None): + try: + current_idx = self.script_tabs.index("current") + except Exception: + return + if current_idx == self._script_tab_index.get(self.KUBECTL_STATUS_TAB): + self._refresh_kubectl_status_tab() + + def _refresh_kubectl_status_tab(self): + console = self.script_consoles.get(self.KUBECTL_STATUS_TAB) + if not console: + return + + namespace = (self.db_namespace.get() or '').strip() or os.environ.get('NAMESPACE') or 'default' + base_cmd = self._kubectl_base_cmd() + + def _display_cmd(args: list[str]) -> str: + redacted = [] + for item in base_cmd + args: + if item.startswith('--token='): + redacted.append('--token=***') + else: + redacted.append(item) + return " ".join(shlex.quote(x) for x in redacted) + + def _run_kubectl(args: list[str]): + env = os.environ.copy() + env.setdefault("PROLE_HOME", str(PROJECT_ROOT)) + try: + res = subprocess.run(base_cmd + args, capture_output=True, text=True, env=env) + out = res.stdout or "" + err = res.stderr or "" + return res.returncode, out, err + except Exception as exc: + return 1, "", f"{exc}" + + def worker(): + cmd_args = ['get', 'pods', '-A', '-o', 'wide'] + code, out, err = _run_kubectl(cmd_args) + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + header = f"# {_display_cmd(cmd_args)} ({now}) [highlight: namespace={namespace}]" + + lines = [ln.rstrip('\n') for ln in out.splitlines() if ln.strip() != ""] + if code != 0 and not lines: + lines = ["(no output)"] + + pod_rows = [] + unhappy = [] + if lines: + for idx, line in enumerate(lines): + if idx == 0: + pod_rows.append({'raw': line, 'ns': '', 'unhappy': False}) + continue + parts = line.split() + if len(parts) < 4: + pod_rows.append({'raw': line, 'ns': '', 'unhappy': False}) + continue + ns, name, ready, status = parts[0], parts[1], parts[2], parts[3] + + status_lc = status.lower() + happy_status = status_lc in ('running', 'completed', 'succeeded') + ready_ok = True + if status_lc == 'running' and '/' in ready: + try: + a, b = ready.split('/', 1) + ready_ok = (a == b) + except Exception: + ready_ok = False + + is_happy = happy_status and (ready_ok or status_lc in ('completed', 'succeeded')) + if not is_happy: + unhappy.append({'ns': ns, 'name': name, 'status': status, 'ready': ready}) + + pod_rows.append({'raw': line, 'ns': ns, 'unhappy': not is_happy}) + + details = "" + if unhappy: + blocks = ["", "# Unhealthy pods detected; collecting events + logs"] + for pod in unhappy: + ns = pod['ns'] + name = pod['name'] + blocks.append(f"\n## Events for {ns}/{name}") + ec, eout, eerr = _run_kubectl([ + 'get', 'events', + '-n', ns, + '--field-selector', f"involvedObject.kind=Pod,involvedObject.name={name}", + '--sort-by=.lastTimestamp' + ]) + blocks.append(eout.strip() or eerr.strip() or "(no events output)") + + blocks.append(f"\n## Logs for {ns}/{name}") + lc, lout, lerr = _run_kubectl([ + 'logs', + '-n', ns, + name, + '--all-containers', + '--tail=200' + ]) + blocks.append(lout.strip() or lerr.strip() or "(no logs output)") + + details = "\n".join(blocks).rstrip() + "\n" + elif err.strip(): + details = "\n# kubectl stderr\n" + err.strip() + "\n" + + def render(): + if not console.text.winfo_exists(): + return + text = console.text + text.configure(state='normal') + text.delete('1.0', 'end') + text.tag_configure('header', foreground='#6e6e73') + text.tag_configure('ns', background='#DDF4FF') + text.tag_configure('bad', foreground='#B00020') + + text.insert('end', header + "\n\n", ('header',)) + for row in pod_rows: + tags = [] + if row['ns'] == namespace: + tags.append('ns') + if row['unhappy']: + tags.append('bad') + if row['raw'].strip(): + text.insert('end', row['raw'] + "\n", tuple(tags)) + if details: + text.insert('end', "\n" + details) + text.see('1.0') + text.configure(state='disabled') + + self.safe_after(render) + + threading.Thread(target=worker, daemon=True).start() + + def _render_init_cnpg_deploy_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Deployment', y=150) + self._render_paragraph('Deploy the CloudNative-PG operator and cluster manifests to Kubernetes.', y=200) + + # Output Console + self._cnpg_deploy_console = self._create_console_output(y=260, title="Deployment Output", width=900, height=520) + + # Use tk.Button + self._cnpg_deploy_button = tk.Button(self.bg_canvas, text='Run Deployment', command=self.run_cnpg_deploy, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + self._cnpg_deploy_button.update_idletasks() + deploy_btn_height = self._cnpg_deploy_button.winfo_reqheight() or 32 + + # Deployment mode selector + mode_label = ui.canvas_text(self, 48, 782, "Mode", fill='#6e6e73', font=('SF Pro Text', 10, 'bold')) + self._canvas_items.append(mode_label) + mode_values = ['prole-dev-cluster', 'prole-service-cluster', 'prole-prod-cluster'] + mode_frame = tk.Frame(self.bg_canvas, bg='#F5F5DC', width=180, height=deploy_btn_height) + mode_frame.pack_propagate(False) + mode_combo = ttk.Combobox(mode_frame, textvariable=self.deploy_target, values=mode_values, state='readonly', width=24) + mode_combo.pack(fill='both', expand=True) + mode_combo_window = self.bg_canvas.create_window(48, 800, window=mode_frame, anchor='nw', width=180, height=deploy_btn_height) + self._canvas_items.append(mode_combo_window) + self._overlay_widgets.append(mode_frame) + + btn_window = self.bg_canvas.create_window(240, 800, window=self._cnpg_deploy_button, anchor='nw', width=180) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(self._cnpg_deploy_button) + + # Force Rollout Button + self._cnpg_rollout_button = tk.Button(self.bg_canvas, text='Force Rollout', command=self.run_cnpg_rollout, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + rollout_btn_window = self.bg_canvas.create_window(420, 800, window=self._cnpg_rollout_button, anchor='nw', width=160) + self._canvas_items.append(rollout_btn_window) + self._overlay_widgets.append(self._cnpg_rollout_button) + + # Save Deployment Button (moved from Post Install) + self._save_deployment_button = tk.Button(self.bg_canvas, text='Save Deployment', command=self.run_final_deployment, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + save_btn_window = self.bg_canvas.create_window(600, 800, window=self._save_deployment_button, anchor='nw', width=180) + self._canvas_items.append(save_btn_window) + self._overlay_widgets.append(self._save_deployment_button) + + # Status Labels (below the buttons row to avoid overlap) + self._cnpg_deploy_status_label = ui.canvas_text(self, 48, 840, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._cnpg_deploy_status_label) + self._save_deployment_status_label = ui.canvas_text(self, 600, 840, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._save_deployment_status_label) + + def run_cnpg_deploy(self): + self._action_flags['init_cnpg_deploy.run_deploy'] = True + def worker(): + self.safe_after(lambda: self._cnpg_deploy_button.configure(state='disabled') if self._cnpg_deploy_button.winfo_exists() else None) + self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deploying...", fill='blue') if self.bg_canvas.winfo_exists() else None) + + target_value = '' + try: + target_value = (self.deploy_target.get() or '').strip() + except Exception: + target_value = '' + if not target_value: + try: + target_value = (self.cluster_env.get() or '').strip() + except Exception: + target_value = '' + target_key = _normalize_cluster_env(target_value) + mode = _deployment_mode_from_env(target_value or self.cluster_env.get()) + + if target_key in ('service', 'prod'): + url = self._deployment_pipeline_url(target_key) + self._cnpg_deploy_console.clear() + self._cnpg_deploy_console.write(f"Opening deployment pipeline for {target_value or target_key}...\n") + self._cnpg_deploy_console.write(f"{url}\n") + try: + webbrowser.open(url) + except Exception: + pass + self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment pipeline opened.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + self._cnpg_success = False + self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) + return + + etc_dir = PROJECT_ROOT / "etc" + # Prepare environment for scripts + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + env["NAMESPACE"] = (self.db_namespace.get() or '').strip() + env["NAMESPACE"] = (self.db_namespace.get() or '').strip() + if mode: + env["PROLE_MODE"] = mode + env["DEPLOYMENT_MODE"] = mode + env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) + + self._cnpg_deploy_console.clear() + self._cnpg_deploy_console.write("Starting CloudNative-PG deployment...\n") + self._cnpg_deploy_console.write(f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} deploy latest\n\n") + + cmd = ['bash', str(etc_dir / 'init_cloudnative_pg.sh')] + if mode: + cmd += ['--mode', mode] + cmd += ['deploy', 'latest'] + proc = subprocess.Popen(cmd, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) + + while True: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + if line: + self._cnpg_deploy_console.write(line) + + if proc.returncode == 0: + self._cnpg_deploy_console.write("\nDeployment successful!\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment successful!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + self._cnpg_success = True + else: + self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Deployment failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + self._cnpg_success = False + + self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) + + threading.Thread(target=worker, daemon=True).start() + + def run_cnpg_rollout(self): + self._action_flags['init_cnpg_deploy.force_rollout'] = True + def worker(): + self.safe_after(lambda: self._cnpg_deploy_button.configure(state='disabled') if self._cnpg_deploy_button.winfo_exists() else None) + self.safe_after(lambda: self._cnpg_rollout_button.configure(state='disabled') if self._cnpg_rollout_button.winfo_exists() else None) + self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Rolling out...", fill='blue') if self.bg_canvas.winfo_exists() else None) + + etc_dir = PROJECT_ROOT / "etc" + # Prepare environment for scripts + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + mode = self._deployment_mode() + if mode: + env["PROLE_MODE"] = mode + env["DEPLOYMENT_MODE"] = mode + env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) + + self._cnpg_deploy_console.clear() + self._cnpg_deploy_console.write("Starting manual recreate rollout for prole-db cluster...\n") + self._cnpg_deploy_console.write(f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} rollout\n\n") + + cmd = ['bash', str(etc_dir / 'init_cloudnative_pg.sh')] + if mode: + cmd += ['--mode', mode] + cmd += ['rollout'] + proc = subprocess.Popen(cmd, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) + + while True: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + if line: + self._cnpg_deploy_console.write(line) + + if proc.returncode == 0: + self._cnpg_deploy_console.write("\nRollout successful!\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Rollout successful!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + else: + self._cnpg_deploy_console.write(f"\nRollout failed with code {proc.returncode}\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Rollout failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + + self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) + self.safe_after(lambda: self._cnpg_rollout_button.configure(state='normal') if self._cnpg_rollout_button.winfo_exists() else None) + + def _run_service_layer_overlay(self, next_page: str = 'init_password'): + """Ensure service-layer resources are ready before database creation.""" + self._showing_service_overlay = True + self._common_services_success = False + self.update_footer() + self._clear_canvas_page() + + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Preparing Service Layer', y=150) + self._render_paragraph('Ensuring service-layer resources are ready before database creation.', y=200) + + console = self._create_console_output(y=260, title="Service Layer Output", width=900, height=520) + status_label = ui.canvas_text(self, 240, 812, "Ready to deploy common services", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(status_label) + log_path = self._common_services_log_path() + try: + console.write(f"Log file: {log_path}\n\n") + except Exception: + pass + + def worker(): + try: + self.safe_after(lambda: self._deploy_services_button.configure(state='disabled') if self._deploy_services_button.winfo_exists() else None) + namespace = (self.db_namespace.get() or '').strip() or 'default' + env = self._script_env_for_namespace(namespace) + env["SERVICE_NAMESPACE"] = self._get_service_namespace() + try: + log_fp = log_path.open('a', encoding='utf-8') + log_fp.write(f"\n# Service layer deploy @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") + log_fp.flush() + except Exception: + log_fp = None + def _line(line: str): + console.write(line) + if log_fp: + try: + log_fp.write(line) + log_fp.flush() + except Exception: + pass + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Deploying service layer...", fill='blue') if self.bg_canvas.winfo_exists() else None) + rc = self.controller.run_script( + "init_service_layer.sh", + args=["migrate"], + env=env, + on_line=_line + ) + if log_fp: + try: + log_fp.write(f"\nExit status: {rc}\n") + log_fp.close() + except Exception: + pass + if rc != 0: + console.write(f"\nService layer setup failed (code {rc}).\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"Service layer failed (code {rc})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + self.safe_after(lambda: self._deploy_services_button.configure(state='normal') if self._deploy_services_button.winfo_exists() else None) + return + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Service layer ready. You may proceed.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + self._common_services_success = True + self.safe_after(self.update_footer) + except Exception as e: + console.write(f"\nService layer error: {e}\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Service layer error", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + self.safe_after(lambda: self._deploy_services_button.configure(state='normal') if self._deploy_services_button.winfo_exists() else None) + + # Use tk.Button explicitly for control over button appearance and behavior + self._deploy_services_button = tk.Button(self.bg_canvas, text='Deploy Services', + command=lambda: threading.Thread(target=worker, daemon=True).start(), + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, 800, window=self._deploy_services_button, anchor='nw', width=180) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(self._deploy_services_button) + + def _run_preparation_overlay(self): + """Prepare environment for database creation.""" + self._action_flags['init_password.generate_ssh_key'] = True + self._clear_canvas_page() + + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Preparing', y=150) + self._render_paragraph('Preparing your environment for database creation.', y=200) + + # Output Console - standardized to match Docker Build screen + console = self._create_console_output(y=260, title="", width=900, height=520) + + # Status Label + status_label = ui.canvas_text(self, 48, 812, "Initializing...", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(status_label) + + def worker(): + key_path = Path.home() / ".ssh" / "id_prole_ed25519" + key_path.parent.mkdir(parents=True, exist_ok=True) + password = self.db_password.get() + namespace = (self.db_namespace.get() or '').strip() + + # Pre-pull dependent images into local registry before initialization + try: + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Pre-pulling images...", fill='blue') if self.bg_canvas.winfo_exists() else None) + pull_ok = self._prepull_images_to_registry( + include_supabase=self.supabase_enabled.get(), + include_kerberos_proxy=self.kerberos_enabled.get(), + log=console.write + ) + if not pull_ok: + console.write("\n[WARN] Image pre-pull failed or incomplete. Continuing...\n") + except Exception as e: + console.write(f"\n[WARN] Image pre-pull error: {e}\n") + + if key_path.exists(): + console.write(f"Key already exists at {key_path}. Proceeding...\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Keys present. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + time.sleep(0.5) + else: + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Preparing keys...", fill='blue') if self.bg_canvas.winfo_exists() else None) + cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get()] + console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") + + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + while True: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + if line: + console.write(line) + + if proc.returncode != 0: + console.write(f"\nPreparation failed, trying alternative (code {proc.returncode})\n") + cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get()] + console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + while True: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + if line: + console.write(line) + + if proc.returncode == 0: + console.write("\nPreparation completed successfully.\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Preparation completed successfully.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + else: + console.write(f"\nError during preparation (code {proc.returncode})\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"Error during preparation (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + # In case of error, we might want to let the user see it before continuing or stopping + time.sleep(2) + self.root.after(500, lambda: self.show_page('init_password')) + return + + # Initialize OpenBao and store keys/passwords + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Initializing OpenBao...", fill='blue') if self.bg_canvas.winfo_exists() else None) + env = self._script_env_for_namespace(namespace) + env["PROLE_DB_USER"] = self.db_username.get().strip() + env["DB_PASSWORD"] = password + env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) + if self.at_rest_encryption_enabled.get(): + console.write("At-rest encryption enabled: generating/storing TDE keys in OpenBao...\n") + rc = self.controller.run_script( + "init_openbao.sh", + args=["initialize"], + env=env, + stdin_text=f"{password}\n", + on_line=lambda l: console.write(l) + ) + if rc != 0: + console.write(f"\nOpenBao initialization failed (code {rc}).\n") + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"OpenBao init failed (code {rc})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) + self.root.after(500, lambda: self.show_page('init_password')) + return + + # Move to next page + self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="OpenBao ready. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None) + self.root.after(1000, lambda: self.show_page('init_db_build')) + + threading.Thread(target=worker, daemon=True).start() diff --git a/installer/ui/screens/supabase.py b/installer/ui/screens/supabase.py new file mode 100644 index 0000000..9e2821f --- /dev/null +++ b/installer/ui/screens/supabase.py @@ -0,0 +1,212 @@ +"""Supabase configuration and deployment screen.""" + +import os +import subprocess +import threading +import tkinter as tk +from installer import screen as ui +from installer.core.env import PROJECT_ROOT, _normalize_cluster_env, _parse_bool + + +class SupabaseScreenMixin: + """Supabase configuration and deployment screen.""" + + def _render_supabase_config_page(self): + # Letterhead at top right (matching welcome screen theme) + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Prole::Supabase', y=150) + + description = ( + "Launches the Supabase open-source stack via supabase/deploy.sh.\n" + "Default mode is local (Docker Compose). Set SUPABASE_DEPLOY_MODE=k3d\n" + "to deploy into the 'supabase' Kubernetes namespace." + ) + self._render_paragraph(description, y=210) + + y = 280 + self._supabase_status_var = tk.StringVar(value="Ready") + status_item = ui.canvas_text(self, 48, y, "Status: Ready", fill='black', font=('SF Pro Text', 12)) + + def update_status_text(*args): + try: + self.bg_canvas.itemconfig(status_item, text=f"Status: {self._supabase_status_var.get()}") + except Exception: + pass + self._supabase_status_var.trace_add('write', update_status_text) + + # Standardized Console Output + self._supabase_console = self._create_console_output(y=320, title="Deployment Output", width=880, height=380) + + y = 740 + + # Deploy Supabase Button + self._supabase_deploy_button = tk.Button( + self.bg_canvas, + text="Deploy Supabase", + command=self.run_supabase_deploy, + bg='#F5F5DC', + fg='black', + activebackground='#E5E5D5', + activeforeground='black', + highlightbackground='#F5F5DC', + highlightcolor='#F5F5DC', + highlightthickness=0, + relief='flat', + bd=0, + cursor='hand2', + disabledforeground='#8B8B7A', + font=('SF Pro Text', 11), + padx=20, + pady=10, + ) + btn_window = self.bg_canvas.create_window(48, y, window=self._supabase_deploy_button, anchor='nw') + self._overlay_widgets.append(self._supabase_deploy_button) + self._canvas_items.append(btn_window) + + def run_supabase_deploy(self): + if getattr(self, '_supabase_deploying', False): + return + + self._supabase_deploying = True + self._supabase_deploy_button.configure(state='disabled') + self._supabase_status_var.set("Deploying...") + self._supabase_success = False + self._supabase_console.clear() + self.update_footer() + + def worker(): + namespace = (self.db_namespace.get() or '').strip() or os.environ.get('NAMESPACE') or 'default' + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + env["NAMESPACE"] = namespace + + self._supabase_console.write("Starting Supabase deployment...\n") + + script_path = PROJECT_ROOT / "supabase" / "deploy.sh" + if not script_path.exists(): + self._supabase_console.write(f"Error: {script_path} not found.\n") + self.safe_after(lambda: self._supabase_status_var.set("Failed (Script not found)")) + self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) + self._supabase_deploying = False + 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.cluster_env.get()) + if cluster_env == 'dev': + mode = "k3d" + elif cluster_env in ('service', 'prod'): + mode = "k8s" + else: + mode = "k3d" + args = ["--mode", mode] + cfg_path = None + try: + if self._cfg_path_override and self._cfg_path_override.exists(): + cfg_path = self._cfg_path_override + else: + conf_dir = self._resolve_prole_conf_dir() + candidate = conf_dir / "prole.cfg" + if candidate.exists(): + cfg_path = candidate + if not cfg_path: + candidate = PROJECT_ROOT / "conf" / "prole.cfg" + if candidate.exists(): + cfg_path = candidate + except Exception: + cfg_path = None + 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._supabase_console.write(f"Mode: {mode}\n") + cmd = ["bash", str(script_path)] + args + + try: + proc = subprocess.Popen( + cmd, + cwd=str(PROJECT_ROOT), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except Exception as e: + self._supabase_console.write(f"Failed to start deploy: {e}\n") + self.safe_after(lambda: self._supabase_status_var.set("Failed (Launch error)")) + self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) + self._supabase_deploying = False + return + + if proc.stdout: + for line in iter(proc.stdout.readline, ''): + if line: + self._supabase_console.write(line) + proc.stdout.close() + rc = proc.wait() + + if rc == 0: + self._supabase_console.write("\nSupabase deployment completed successfully.\n") + ports_script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh" + self.safe_after(lambda: self._supabase_status_var.set("Configuring Ports...")) + + if not ports_script.exists(): + self._supabase_success = False + self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports script not found)")) + self._supabase_console.write(f"Error: {ports_script} not found.\n") + else: + self._supabase_console.write(f"\nConfiguring Supabase ports for namespace '{namespace}'...\n") + self._supabase_console.write(f"> bash etc/init_supabase_ports.sh -n {namespace}\n\n") + + try: + ports_proc = subprocess.Popen( + ["bash", str(ports_script), "-n", namespace], + cwd=str(PROJECT_ROOT), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except Exception as e: + self._supabase_success = False + self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports launch error)")) + self._supabase_console.write(f"Failed to start port wiring: {e}\n") + else: + if ports_proc.stdout: + for line in iter(ports_proc.stdout.readline, ''): + if line: + self._supabase_console.write(line) + ports_proc.stdout.close() + ports_rc = ports_proc.wait() + + if ports_rc == 0: + self._supabase_success = True + self.safe_after(lambda: self._supabase_status_var.set("Deployed Successfully")) + self._supabase_console.write("\nSupabase port wiring completed successfully.\n") + else: + self._supabase_success = False + self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Ports code {ports_rc})")) + self._supabase_console.write(f"\nSupabase port wiring failed with exit code {ports_rc}.\n") + else: + self._supabase_success = False + self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Code {rc})")) + self._supabase_console.write(f"\nSupabase deployment failed with exit code {rc}.\n") + + self._supabase_deploying = False + self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) + self.safe_after(self.update_footer) + + threading.Thread(target=worker, daemon=True).start() diff --git a/installer/ui/screens/validate.py b/installer/ui/screens/validate.py new file mode 100644 index 0000000..5fd4e4e --- /dev/null +++ b/installer/ui/screens/validate.py @@ -0,0 +1,160 @@ +"""Validation screen with auto-refresh status monitoring.""" + +import subprocess +import threading +import time +import webbrowser +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer.screen import TerminalConsole +from installer import screen as ui + + +class ValidateScreenMixin: + """Validation screen with auto-refresh status monitoring.""" + + def create_validate_screen(self): + """Create the Validate screen""" + frame = self._page_container() + self.screens['validate'] = frame + + # Title + title = ttk.Label(frame, text="Validate Deployment", style='Title.TLabel') + title.pack(pady=(0, 20)) + + # Prometheus link + prometheus_frame = tk.Frame(frame, bg='white') + prometheus_frame.pack(pady=(0, 20)) + + prometheus_label = tk.Label(prometheus_frame, + text="Prometheus: ", + bg='white', + fg='#6e6e73', + font=('Helvetica', 11)) + prometheus_label.pack(side='left') + + prometheus_link = tk.Label(prometheus_frame, + text="http://localhost:9090", + bg='white', + fg='#4a9eff', + font=('Helvetica', 11, 'underline'), + cursor='hand2') + prometheus_link.pack(side='left') + prometheus_link.bind('', lambda e: webbrowser.open('http://localhost:9090')) + + # Status display + status_frame = tk.Frame(frame, bg='white') + status_frame.pack(fill='both', expand=True, pady=10) + + status_label = ttk.Label(status_frame, text="Cluster Status", style='Heading.TLabel') + status_label.pack(anchor='w', pady=(0, 10)) + + # Status text area - using TerminalConsole for consistency + # Use a background frame to ensure NO borders are visible around the console + console_bg = tk.Frame(status_frame, bg='white', highlightthickness=0, bd=0) + console_bg.pack(fill='both', expand=True, padx=1, pady=1) + self._validation_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) + self._validation_console.pack(fill='both', expand=True) + self.status_text = self._validation_console.text + + # Auto-refresh checkbox + refresh_frame = tk.Frame(frame, bg='white') + refresh_frame.pack(pady=10) + + self.auto_refresh_var = tk.BooleanVar(value=True) + refresh_check = tk.Checkbutton(refresh_frame, + text="Auto-refresh every 10 seconds", + variable=self.auto_refresh_var, + bg='white', + fg='#1d1d1f', + selectcolor='#f0f0f0', + activebackground='white', + activeforeground='#1d1d1f', + font=('Helvetica', 10), + command=self.toggle_auto_refresh) + refresh_check.pack(side='left', padx=10) + + # Manual refresh button + refresh_btn = tk.Button(refresh_frame, + text="Refresh Now", + command=self.refresh_status, + bg='#4a9eff', + fg='white', + activebackground='#3a8eef', + highlightbackground='white', + font=('Helvetica', 10), + padx=15, + pady=5, + cursor='hand2', + relief='flat') + refresh_btn.pack(side='left', padx=10) + + # Start auto-refresh + self.refresh_status() + self.toggle_auto_refresh() + + def toggle_auto_refresh(self): + """Toggle auto-refresh""" + if self.auto_refresh_var.get(): + if not self.validation_running: + self.validation_running = True + self.validation_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) + self.validation_thread.start() + else: + self.validation_running = False + + def auto_refresh_loop(self): + """Auto-refresh loop""" + while self.validation_running: + time.sleep(10) + if self.validation_running: + self.root.after(0, self.refresh_status) + + def refresh_status(self): + """Refresh the cluster status""" + def update(): + try: + full_status = "" + + # Get k3d cluster list + try: + cluster_result = subprocess.run(['k3d', 'cluster', 'list'], + capture_output=True, + text=True, + timeout=5) + full_status += f"=== k3d Cluster Status ===\n{cluster_result.stdout}\n\n" + except Exception as e: + full_status += f"=== k3d Cluster Status ===\nError: {str(e)}\n\n" + + # Get kubectl cnpg status + try: + namespace = (self.db_namespace.get() or 'default').strip() + cmd = self._kubectl_base_cmd() + ['cnpg', 'status', 'prole-db', '-n', namespace] + result = subprocess.run(cmd, + capture_output=True, + text=True, + timeout=10) + if result.returncode == 0: + status_output = result.stdout + else: + # Try default namespace if prole fails, or just show error + status_output = f"Error: {result.stderr}\n\nNote: Make sure kubectl cnpg plugin is installed and cluster exists in '{namespace}' namespace." + + full_status += f"=== CloudNativePG Status (namespace: {namespace}) ===\n{status_output}\n" + except FileNotFoundError: + full_status += "=== CloudNativePG Status ===\nError: kubectl not found. Please install dependencies first.\n" + except subprocess.TimeoutExpired: + full_status += "=== CloudNativePG Status ===\nError: Command timed out\n" + except Exception as e: + full_status += f"=== CloudNativePG Status ===\nError: {str(e)}\n" + + full_status += f"\nLast updated: {time.strftime('%Y-%m-%d %H:%M:%S')}" + + self.status_text.delete('1.0', tk.END) + self.status_text.insert('1.0', full_status) + + except Exception as e: + self.status_text.delete('1.0', tk.END) + self.status_text.insert('1.0', f"Error: {str(e)}") + + threading.Thread(target=update, daemon=True).start() diff --git a/installer/ui/screens/welcome.py b/installer/ui/screens/welcome.py new file mode 100644 index 0000000..e4054f2 --- /dev/null +++ b/installer/ui/screens/welcome.py @@ -0,0 +1,108 @@ +"""Welcome / splash screen.""" + +import threading +import time +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from installer import screen as ui + + +class WelcomeScreenMixin: + """Welcome / splash screen.""" + + def _render_welcome_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + # Welcome title + self._render_title('Welcome', y=150) + + welcome_text = ( + "This installer will guide you through the process of setting up the Prole Database and its supporting " + "infrastructure. We have designed this process to be as automated as possible, ensuring that your " + "deployment is secure, efficient, and tailored to your specific network environment.\n\n" + "What to expect:\n" + "• Network Environment Discovery: We'll scan for existing services like Active Directory and DNS.\n" + "• System Configuration: Setting up local paths and environment variables.\n" + "• Dependency Management: Ensuring all required tools (Docker, k3d, etc.) are ready.\n" + "• Database Initialization: Configuring passwords, Kerberos authentication, and deploying the database cluster.\n\n" + "We are excited to have you join our community and start building with us. " + "Welcome to the neighborhood! Let's get started by preparing your system for the Prole experience." + ) + + ui.canvas_text(self, 48, 220, welcome_text, fill='black', font=('SF Pro Text', 13), width=750) + + # Ensure footer is updated (Next button visible) + self.update_footer() + + def _start_welcome_dependency_scan(self): + self.splash_scan_running = True + self.splash_scan_done_at = None + self.splash_scan_started_at = time.time() + + deps = list(self.dependencies) + total = len(deps) + results = {} + + def set_status(text: str): + try: + if self._splash_status_item is not None: + # Replace existing text to avoid creating many items + self.bg_canvas.itemconfig(self._splash_status_item, text=text) + except Exception: + pass + + def worker(): + missing_names = [] + for idx, dep in enumerate(deps, start=1): + try: + ok, location, version = self.get_dep_info(dep) + except Exception: + ok, location, version = False, None, None + results[dep['id']] = (ok, location, version) + # Update status text progressively + if ok: + txt = f"[{idx}/{total}] {dep['name']}: Installed" + else: + txt = f"[{idx}/{total}] {dep['name']}: Not installed" + missing_names.append(dep['name']) + self.safe_after(lambda s=txt: set_status(s)) + # tiny sleep to keep UI responsive without being too fast + try: + time.sleep(0.02) + except Exception: + pass + + # Final message + final_msg = 'All dependencies installed.' if not missing_names else ( + 'Preparing to install ... ' + ', '.join(missing_names) + ) + def on_done(): + set_status(final_msg) + self.splash_scan_running = False + self.splash_scan_done_at = time.time() + # Re-evaluate footer to potentially show Next + self.update_footer() + # Important: schedule a follow-up refresh slightly after the debounce window + # so the Next button becomes visible without requiring further UI events. + self.safe_after(self.update_footer, delay=600) + self.safe_after(on_done) + + t = threading.Thread(target=worker, daemon=True) + t.start() + # Failsafe refresh slightly after the 8s gating window in case no UI events fire. + self.safe_after(self.update_footer, delay=9000) + + def _create_page_welcome(self): + f = self._page_container() + ttk.Label(f, text='Welcome to Prole', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) + msg = ( + 'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.' + ) + ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) + # self._register_page('welcome', f) diff --git a/tests/installer/test_milestones.py b/tests/installer/test_milestones.py index 1be33f0..4baabd8 100644 --- a/tests/installer/test_milestones.py +++ b/tests/installer/test_milestones.py @@ -1,5 +1,5 @@ import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, patch, ANY from pathlib import Path from installer.core.milestones import ( DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone, @@ -106,26 +106,56 @@ class TestMilestones(unittest.TestCase): self.assertEqual(self.state.inputs.get('init_password.db_password'), 'resolved-secret-pw') self.assertEqual(self.state.inputs.get('init_password.db_password_confirm'), 'resolved-secret-pw') - def test_cluster_lifecycle_milestone_dev(self): + @patch('subprocess.run') + def test_cluster_lifecycle_milestone_dev(self, mock_subprocess_run): self.state.inputs['init_cluster.cluster_env'] = 'dev' self.state.inputs['init_cluster.start_cluster'] = 'True' self.state.controller.run_script.return_value = 0 - + self.state.controller.check_docker_running.return_value = True + self.state.controller.get_prole_db_version.return_value = "1.0.0" + + # Mock k3d cluster list showing cluster exists and running + mock_list_result = MagicMock() + mock_list_result.stdout = "prole-dev-cluster 1/1 0/2 running" + mock_list_result.returncode = 0 + # Mock docker image inspect (image not found locally) + mock_inspect_result = MagicMock() + mock_inspect_result.returncode = 1 + # Mock kubectl config use-context + mock_ctx_result = MagicMock() + mock_ctx_result.returncode = 0 + + mock_subprocess_run.side_effect = [mock_list_result, mock_ctx_result, mock_inspect_result] + milestone = ClusterLifecycleMilestone() milestone.execute(self.state) - - from unittest.mock import ANY - self.state.controller.run_script.assert_any_call("init_k3d.sh", args=["start"], env=ANY) - def test_init_scripts_milestone(self): + # Verify k3d cluster list was called + mock_subprocess_run.assert_any_call( + ['k3d', 'cluster', 'list', '--no-headers'], + capture_output=True, text=True + ) + + @patch('installer.core.milestones.InitializationScriptsMilestone._run_cmd') + @patch('subprocess.run') + def test_init_scripts_milestone(self, mock_subprocess_run, mock_run_cmd): self.state.controller.run_script.return_value = 0 + # Mock _run_cmd to return 0 (secrets exist) + mock_run_cmd.return_value = 0 milestone = InitializationScriptsMilestone() milestone.execute(self.state) - from unittest.mock import ANY - self.state.controller.run_script.assert_any_call("init_authority.sh", args=["update"], env=ANY) - self.state.controller.run_script.assert_any_call("init_opentofu.sh", args=["-n", "default", "update"], env=ANY) + # Verify current script list is called + self.state.controller.run_script.assert_any_call( + "init_common_services.sh", args=ANY, env=ANY, on_line=ANY + ) + self.state.controller.run_script.assert_any_call( + "init_cloudnative_pg.sh", args=ANY, env=ANY, on_line=ANY + ) + self.state.controller.run_script.assert_any_call( + "init_monitoring.sh", args=ANY, env=ANY, on_line=ANY + ) def test_deployment_milestone(self): self.state.controller.run_script.return_value = 0 @@ -133,8 +163,9 @@ class TestMilestones(unittest.TestCase): milestone = DeploymentMilestone() milestone.execute(self.state) - from unittest.mock import ANY - self.state.controller.run_script.assert_any_call("deploy_pipeline.sh", args=["--mode", "local", "apply"], env=ANY) + self.state.controller.run_script.assert_any_call( + "init_cloudnative_pg.sh", args=ANY, env=ANY, on_line=ANY + ) if __name__ == '__main__': unittest.main() diff --git a/tests/test_install_logic.py b/tests/test_install_logic.py index a1e493b..8246543 100644 --- a/tests/test_install_logic.py +++ b/tests/test_install_logic.py @@ -39,10 +39,12 @@ def test_resolve_prole_home_env(installer): assert str(home) == custom_home def test_env_defaults(installer): - defaults = installer._env_defaults() - assert 'PROLE_HOME' in defaults - assert defaults['PROLE_HOME'].endswith('.prole') - assert defaults['PROLE_CONF'].endswith('.prole/conf') + with patch.dict(os.environ, {}, clear=True), \ + patch.object(installer, '_read_existing_env', return_value={}): + defaults = installer._env_defaults() + assert 'PROLE_HOME' in defaults + assert defaults['PROLE_HOME'].endswith('.prole') + assert defaults['PROLE_CONF'].endswith('.prole/conf') def test_read_existing_env_no_file(installer, tmp_path): # Ensure neither PROLE_HOME nor the default location has an env.sh for this test diff --git a/tests/test_navigation.py b/tests/test_navigation.py index 1007d4c..a6b992c 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -76,6 +76,7 @@ def test_navigation_flow_standard(mock_installer): assert mock_installer.pages[mock_installer.page_index][0] == 'init_cluster' with patch.object(mock_installer, '_cluster_ready_for_navigation', return_value=True): + mock_installer._common_services_success = True mock_installer.on_next() assert mock_installer.pages[mock_installer.page_index][0] == 'init_password' @@ -140,6 +141,8 @@ def test_navigation_to_create_installer(mock_installer): assert mock_installer.pages[mock_installer.page_index][0] == 'create_installer' # Last page Finish label + mock_installer.next_button = MagicMock() + mock_installer.prev_button = MagicMock() mock_installer.update_footer() mock_installer.next_button.configure.assert_any_call(text='Finish') diff --git a/tests/test_service_layer_navigation.py b/tests/test_service_layer_navigation.py index 78148f2..c999d8d 100644 --- a/tests/test_service_layer_navigation.py +++ b/tests/test_service_layer_navigation.py @@ -66,11 +66,14 @@ def test_service_layer_overlay_behavior(mock_installer): assert mock_installer._common_services_success is False # Next button should be disabled via update_footer + mock_installer.next_button = MagicMock() + mock_installer.prev_button = MagicMock() mock_installer.update_footer() mock_installer.next_button.configure.assert_any_call(state='disabled') # Simulate success mock_installer._common_services_success = True + mock_installer.next_button.reset_mock() mock_installer.update_footer() mock_installer.next_button.configure.assert_any_call(state='normal')