prole/install.py
chrisfu f2c9012cce Refactor installation and initialization logic, and expand test coverage
- install.py: Major update including configuration variable expansion, improved k3s/k3d handling, and enhanced installation logic.

- etc/ scripts: Significant refactoring of initialization scripts (Kerberos, Port Forwards, Garage Store, etc.).

- Port Forwards: Transitioned from XML to port-mappings.conf for managing kubectl port-forwards.

- Status Reporting: Improved status checking for common services.

- Infrastructure: Updated Ansible inventory and rsyslog role configurations.

- Tests: Added a comprehensive suite of tests for 'etc' initialization scripts in prole/tests/etc/.

- Documentation: Added prole-db-documentation-mcp-architecture.md.

- General: Updated Dockerfiles and various helper scripts.
2026-02-13 21:36:39 -08:00

13125 lines
573 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Prole Service Dependencies Installer
Desktop application for installing, deploying, and validating Prole services
"""
import tkinter as tk
from tkinter import ttk, scrolledtext, messagebox, filedialog
import subprocess
import threading
import os
import sys
import webbrowser
import time
import platform
from pathlib import Path
import shlex
import socket
import signal
import shutil
import json
import configparser
import tempfile
import getpass
import uuid
import re
import base64
from datetime import datetime
import urllib.request
import urllib.parse
import urllib.error
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Refactor: import shared helpers from the root-level installer package
from installer import config as inst_config
from installer.build import get_build_command as inst_get_build_command
from installer import deploy as inst_deploy
from installer import screen as ui
# 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
# Get the project root directory (kept local for clarity in this legacy entry)
PROJECT_ROOT = Path(__file__).parent.absolute()
# Initialize global frames list to keep them in memory
GLOBAL_SCAN_FRAMES = []
def get_resource_path(relative_path):
"""Get absolute path to resource, works for dev and for PyInstaller."""
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = Path(sys._MEIPASS)
except AttributeError:
# Running from source
base_path = PROJECT_ROOT
return base_path / relative_path
# Standard Console Theme (Light background, Dark text)
CONSOLE_BG = '#F5F5DC' # Cream/Light background similar to our images
CONSOLE_FG = '#1d1d1f' # Dark text
CONSOLE_INSERT = '#1d1d1f'
CONSOLE_FONT = ('Menlo', 10)
NAMESPACE_PREFIX = 'prole-'
POSTGRES_DB_NAME_MAX_LEN = 63
DEFAULT_OLLAMA_PORT = '11434'
# Default action behavior for unattended replays
DEFAULT_ACTION_FLAGS = {
'dependencies.auto_install_missing': True,
'network_scan.run': True,
'init_password.generate_ssh_key': True,
'init_db_build.run_build': True,
'init_cluster.start_cluster': True,
'init_scripts.run_scripts': True,
'init_cnpg_deploy.run_deploy': True,
'init_cnpg_deploy.force_rollout': False,
'kerberos_config.test_connection': False,
'build.run_build': False,
}
# Secret handling (temporary encrypted values in prole.cfg)
PROLE_SECRET_PREFIX = "${PROLE_SECRET:"
PROLE_SECRET_SUFFIX = "}"
OPENBAO_PREFIX = "${OPENBAO:"
OPENBAO_SUFFIX = "}"
PROLE_SECRET_VERSION = "v1"
PROLE_SECRET_SERVICE = "prole-installer"
PROLE_SECRET_KEY_FILE = Path.home() / ".prole" / "secrets" / "installer.key"
# Map config keys to OpenBao paths (namespace injected at runtime)
SECRET_KEY_SPECS = {
("Inputs", "init_password.db_password"): ("db", "password"),
("Inputs", "init_password.db_password_confirm"): ("db", "password"),
("Inputs", "kerberos_config.password"): ("kerberos", "password"),
("Global", "DB_PASSWORD"): ("db", "password"),
("Kerberos Authentication", "PASSWORD"): ("kerberos", "password"),
("Monitoring", "GRAFANA_ADMIN_PASSWORD"): ("monitoring", "grafana_admin_password"),
}
def _is_prole_secret(value: str | None) -> bool:
return bool(value) and value.startswith(PROLE_SECRET_PREFIX) and value.endswith(PROLE_SECRET_SUFFIX)
def _is_openbao_ref(value: str | None) -> bool:
return bool(value) and value.startswith(OPENBAO_PREFIX) and value.endswith(OPENBAO_SUFFIX)
def _encrypt_cfg_secret(plaintext: str | None) -> str:
if not plaintext:
return ''
if _is_prole_secret(plaintext) or _is_openbao_ref(plaintext):
return plaintext
try:
return _encrypt_prole_secret(plaintext)
except Exception:
return str(plaintext)
def _get_secret_key_file() -> Path:
return PROLE_SECRET_KEY_FILE
def _get_keychain_key(service: str, account: str) -> bytes:
try:
res = subprocess.run(
["security", "find-generic-password", "-a", account, "-s", service, "-w"],
capture_output=True,
text=True
)
if res.returncode == 0 and res.stdout.strip():
return base64.urlsafe_b64decode(res.stdout.strip().encode("utf-8"))
except Exception:
pass
key_b64 = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8")
try:
subprocess.run(
["security", "add-generic-password", "-a", account, "-s", service, "-w", key_b64, "-U"],
capture_output=True,
text=True
)
except Exception:
pass
return base64.urlsafe_b64decode(key_b64.encode("utf-8"))
def _get_file_key(path: Path) -> bytes:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
raw = path.read_text().strip()
try:
return base64.urlsafe_b64decode(raw.encode("utf-8"))
except Exception:
pass
key_b64 = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8")
path.write_text(key_b64)
try:
os.chmod(path, 0o600)
except Exception:
pass
return base64.urlsafe_b64decode(key_b64.encode("utf-8"))
def _get_secret_key() -> bytes:
system = platform.system()
account = getpass.getuser() or "prole"
if system == "Darwin":
return _get_keychain_key(PROLE_SECRET_SERVICE, account)
return _get_file_key(_get_secret_key_file())
def _encrypt_prole_secret(plaintext: str) -> str:
if plaintext is None:
return ""
if _is_prole_secret(plaintext):
return plaintext
key = _get_secret_key()
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
nonce_b64 = base64.urlsafe_b64encode(nonce).decode("utf-8")
ct_b64 = base64.urlsafe_b64encode(ciphertext).decode("utf-8")
return f"{PROLE_SECRET_PREFIX}{PROLE_SECRET_VERSION}:{nonce_b64}:{ct_b64}{PROLE_SECRET_SUFFIX}"
def _decrypt_prole_secret(value: str) -> str:
if not _is_prole_secret(value):
return value
inner = value[len(PROLE_SECRET_PREFIX):-len(PROLE_SECRET_SUFFIX)]
parts = inner.split(":")
if len(parts) != 3 or parts[0] != PROLE_SECRET_VERSION:
return value
try:
nonce = base64.urlsafe_b64decode(parts[1].encode("utf-8"))
ciphertext = base64.urlsafe_b64decode(parts[2].encode("utf-8"))
key = _get_secret_key()
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, None).decode("utf-8")
except Exception:
return value
def _normalize_k3s_token(value: str | None) -> str:
token = (value or '').strip()
if not token:
return ''
if _is_openbao_ref(token):
return ''
if _is_prole_secret(token):
try:
token = _decrypt_prole_secret(token)
except Exception:
return ''
if _is_prole_secret(token):
return ''
return token
def _looks_like_k8s_bearer_token(token: str | None) -> bool:
tok = (token or '').strip()
if not tok:
return False
# K3s node tokens include '::' and are not valid API bearer tokens.
if '::' in tok:
return False
# JWT-style tokens contain two dots.
if tok.count('.') >= 2:
return True
# Bootstrap tokens are usually id.secret with a dot separator.
if tok.count('.') == 1:
token_id, token_secret = tok.split('.', 1)
if len(token_id) >= 6 and len(token_secret) >= 16:
return True
return False
def _parse_ollama_host(value: str, default_port: str = DEFAULT_OLLAMA_PORT) -> tuple[str, str]:
raw = (value or '').strip()
if not raw:
return '', ''
if '://' not in raw:
raw = f"http://{raw}"
try:
parsed = urllib.parse.urlparse(raw)
except Exception:
return '', ''
host = parsed.hostname or ''
port = str(parsed.port) if parsed.port else ''
if not port and default_port:
port = default_port
return host, port
def _format_ollama_host(host: str, port: str) -> str:
raw_host = (host or '').strip()
if not raw_host:
return ''
scheme = 'https' if raw_host.startswith('https://') else 'http'
parsed_host, parsed_port = _parse_ollama_host(raw_host, default_port='')
host_val = parsed_host or raw_host
port_val = parsed_port or (port or '').strip()
if port_val:
return f"{scheme}://{host_val}:{port_val}"
return f"{scheme}://{host_val}"
def _find_kubeconfig_file(env: dict | None = None) -> str:
env = env or os.environ
candidates = []
kubeconfig_env = (env.get("KUBECONFIG") or "").strip()
if kubeconfig_env:
candidates.append(kubeconfig_env)
prole_service = (env.get("PROLE_SERVICE") or "").strip()
if prole_service:
candidates.append(str(Path(prole_service) / "secrets" / "k3s.kubeconfig"))
for key in ("PROLE_K3S_KUBECONFIG", "PROLE_KUBECONFIG"):
val = (env.get(key) or "").strip()
if val:
candidates.append(val)
prole_home = (env.get("PROLE_HOME") or "").strip()
if prole_home:
candidates.append(str(Path(prole_home) / "prole-k3s.kubeconfig"))
candidates.append(str(Path(prole_home) / ".kube" / "config"))
candidates.append(str(PROJECT_ROOT / "prole-k3s.kubeconfig"))
candidates.append("/etc/rancher/k3s/prole-kubeconfig.yaml")
candidates.append("/etc/rancher/k3s/k3s.yaml")
for path in candidates:
try:
if path and Path(path).expanduser().exists():
return path
except Exception:
continue
return ""
def _k3d_prole_data_volume_args(prole_data: str | None) -> list[str]:
path = (prole_data or '').strip()
if not path:
return []
try:
p = Path(path).expanduser()
except Exception:
return []
try:
p.mkdir(parents=True, exist_ok=True)
except Exception:
pass
return ["--volume", f"{p}:/var/lib/rancher/k3s/storage@all"]
def _ensure_ansible_vault_credentials(prompt_ui: bool = False, root=None) -> None:
password_file = (os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') or '').strip()
password = (os.environ.get('ANSIBLE_VAULT_PASSWORD') or '').strip()
if password_file:
try:
if not Path(password_file).expanduser().is_file():
password_file = ''
os.environ.pop('ANSIBLE_VAULT_PASSWORD_FILE', None)
except Exception:
password_file = ''
os.environ.pop('ANSIBLE_VAULT_PASSWORD_FILE', None)
if password or password_file:
return
for base in (Path.cwd(), PROJECT_ROOT):
try:
candidate = base / '.vault_pass'
if candidate.is_file():
os.environ['ANSIBLE_VAULT_PASSWORD_FILE'] = str(candidate)
print(f"[INFO] Using Ansible Vault password file: {candidate}")
return
except Exception:
continue
vault_password = ''
if prompt_ui and root is not None:
try:
from tkinter import simpledialog
vault_password = simpledialog.askstring(
"Ansible Vault",
"Enter Ansible Vault password:",
show='*',
parent=root
) or ''
except Exception:
vault_password = ''
if not vault_password:
try:
vault_password = getpass.getpass("Ansible Vault password: ")
except Exception:
vault_password = ''
if vault_password:
os.environ['ANSIBLE_VAULT_PASSWORD'] = vault_password
def _openbao_placeholder(namespace: str, leaf: str, key: str | None = None) -> str:
ns = (namespace or "").strip() or "default"
path = f"kv/prole/{ns}/{leaf}"
if key:
return f"{OPENBAO_PREFIX}{path}#{key}{OPENBAO_SUFFIX}"
return f"{OPENBAO_PREFIX}{path}{OPENBAO_SUFFIX}"
def _bool_str(val: bool) -> str:
return 'true' if bool(val) else 'false'
def _parse_bool(val, default=False) -> bool:
if val is None:
return default
s = str(val).strip().lower()
if s in ('1', 'true', 'yes', 'y', 'on'):
return True
if s in ('0', 'false', 'no', 'n', 'off'):
return False
return default
def _expand_path(val: str | None) -> str:
if val is None:
return ''
return os.path.expandvars(os.path.expanduser(str(val)))
_CFG_VAR_PATTERN = re.compile(r"\$(\w+)|\$\{(\w+)\}")
def _expand_cfg_value(val: str | None, cfg_vars: dict[str, str] | None = None,
env: dict[str, str] | None = None, max_depth: int = 5) -> str:
if val is None:
return ''
raw = str(val)
if _is_openbao_ref(raw) or _is_prole_secret(raw):
return raw
cfg_vars = cfg_vars or {}
env = env or os.environ
def repl(match):
var = match.group(1) or match.group(2)
if var in cfg_vars and cfg_vars[var] is not None:
return str(cfg_vars[var])
if var in env and env[var] is not None:
return str(env[var])
return match.group(0)
out = raw
for _ in range(max_depth):
new = _CFG_VAR_PATTERN.sub(repl, out)
if new == out:
break
out = new
return out
def _collect_cfg_vars(cfg: configparser.ConfigParser) -> dict[str, str]:
cfg_vars: dict[str, str] = {}
for section in cfg.sections():
for k, v in cfg.items(section):
if k in cfg_vars:
continue
cfg_vars[k] = _expand_cfg_value(v, cfg_vars)
return cfg_vars
def _resolve_supabase_home(project_root: Path) -> Path | None:
env_home = (os.environ.get('SUPABASE_HOME') or '').strip()
if env_home:
try:
candidate = Path(_expand_path(env_home))
if candidate.is_dir():
return candidate
except Exception:
pass
candidates = [
Path.home() / 'prole' / 'supabase',
Path.home() / 'dev' / 'supabase',
project_root / 'supabase',
]
prole_home = (os.environ.get('PROLE_HOME') or '').strip()
if prole_home:
try:
candidates.append(Path(_expand_path(prole_home)).parent / 'supabase')
except Exception:
pass
for candidate in candidates:
try:
if candidate.is_dir():
return candidate
except Exception:
continue
return None
def _collect_images_from_files(paths: list[Path]) -> set[str]:
images = set()
for path in paths:
if not path or not path.exists():
continue
try:
for line in path.read_text().splitlines():
m = re.match(r'^\s*image:\s*([^\s#]+)', line)
if not m:
continue
img = m.group(1).strip().strip('"').strip("'")
if not img:
continue
img = os.path.expandvars(img)
if '${' in img or '}' in img or '$' in img:
continue
images.add(img)
except Exception:
continue
return images
def _clean_yaml_value(raw: str) -> str:
if raw is None:
return ''
val = raw.strip()
if '#' in val:
val = val.split('#', 1)[0].strip()
if len(val) >= 2 and ((val[0] == val[-1]) and val.startswith(("'", '"'))):
val = val[1:-1]
return val.strip()
def _parse_yaml_scalar_values(path: Path, keys: set[str]) -> dict:
data = {}
if not path.exists():
return data
try:
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith('#') or line == '---':
continue
if line.startswith('- '):
continue
if ':' not in line:
continue
key, val = line.split(':', 1)
key = key.strip()
if key not in keys:
continue
clean_val = _clean_yaml_value(val)
if clean_val:
data[key] = clean_val
except Exception:
pass
return data
def _parse_internal_a_records(path: Path) -> tuple[str, dict, dict]:
domain = ''
ip_map: dict[str, str] = {}
fqdn_map: dict[str, str] = {}
if not path.exists():
return domain, ip_map, fqdn_map
try:
lines = path.read_text().splitlines()
except Exception:
return domain, ip_map, fqdn_map
in_block = False
current: dict[str, str] = {}
records = []
for raw in lines:
if not in_block:
s = raw.strip()
if s.startswith('prole_domain:'):
domain = _clean_yaml_value(s.split(':', 1)[1])
if s.startswith('prole_internal_a_records:'):
in_block = True
continue
if raw and not raw.startswith((' ', '\t')):
break
s = raw.strip()
if not s:
continue
if s.startswith('- '):
if current:
records.append(current)
current = {}
s = s[2:].strip()
if ':' in s:
key, val = s.split(':', 1)
key = key.strip()
if key in ('fqdn', 'ipv4'):
current[key] = _clean_yaml_value(val)
if current:
records.append(current)
for rec in records:
fqdn = rec.get('fqdn')
ip = rec.get('ipv4')
if not fqdn or not ip:
continue
fqdn_map[fqdn] = ip
ip_map[fqdn] = ip
short = fqdn.split('.')[0]
if short and short not in ip_map:
ip_map[short] = ip
return domain, ip_map, fqdn_map
def _parse_ansible_inventory_hosts(path: Path) -> dict:
groups: dict[str, list[str]] = {}
if not path.exists():
return groups
try:
current_group = None
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith(('#', ';')):
continue
if line.startswith('[') and line.endswith(']'):
current_group = line[1:-1].strip()
groups.setdefault(current_group, [])
continue
if current_group is None:
continue
host = line.split()[0]
if host and host not in groups[current_group]:
groups[current_group].append(host)
except Exception:
pass
return groups
def _resolve_host_ip(host: str, domain: str, ip_map: dict) -> str:
if not host:
return ''
if host in ip_map:
return ip_map[host]
if domain:
if '.' not in host:
fqdn = f"{host}.{domain}"
if fqdn in ip_map:
return ip_map[fqdn]
else:
short = host.split('.')[0]
if short in ip_map:
return ip_map[short]
return ''
def _normalize_cluster_env(env: str | None) -> str:
if not env:
return ''
s = str(env).strip().lower()
if s in ('dev', 'k3d', 'k3d-dev', 'k3d-prole-dev-cluster', 'prole-dev-cluster'):
return 'dev'
if s in ('service', 'k3s', 'k3s-service', 'prole-service-cluster'):
return 'service'
if s in ('prod', 'production', 'k8s', 'prole-prod-cluster'):
return 'prod'
return s
def _cluster_env_radio_value(env: str | None) -> str:
key = _normalize_cluster_env(env)
if key == 'dev':
return 'k3d-prole-dev-cluster'
if key == 'service':
return 'prole-service-cluster'
if key == 'prod':
return 'prole-prod-cluster'
return (env or '')
def _deployment_target_label(env: str | None) -> str:
key = _normalize_cluster_env(env)
if key == 'dev':
return 'prole-dev-cluster'
if key == 'service':
return 'prole-service-cluster'
if key == 'prod':
return 'prole-prod-cluster'
return (env or '')
def _deployment_mode_from_env(env: str | None) -> str:
key = _normalize_cluster_env(env)
if key == 'dev':
return 'k3d'
if key == 'service':
return 'k3s'
if key == 'prod':
return 'k8s'
return ''
def _extract_yaml_scalar_from_text(text: str, key: str) -> str:
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith('#'):
continue
if line.startswith(f"{key}:"):
return _clean_yaml_value(line.split(':', 1)[1])
return ''
def _extract_inline_vault_block(text: str, key: str) -> str:
lines = text.splitlines()
for idx, raw in enumerate(lines):
stripped = raw.strip()
if not stripped or stripped.startswith('#'):
continue
if stripped.startswith(f"{key}:") and "!vault" in stripped:
base_indent = len(raw) - len(raw.lstrip())
block = []
i = idx + 1
while i < len(lines):
line = lines[i]
if not line.strip():
i += 1
continue
indent = len(line) - len(line.lstrip())
if indent <= base_indent:
break
block.append(line.strip())
i += 1
if block and block[0].startswith("$ANSIBLE_VAULT"):
return "\n".join(block) + "\n"
return ""
return ""
def _try_read_ansible_vault_value(vault_path: Path, key: str) -> str:
if not vault_path.exists():
return ''
# First, try plain YAML parsing (in case the file is not encrypted).
try:
plain = _parse_yaml_scalar_values(vault_path, {key}).get(key, '')
except Exception:
plain = ''
if plain and not plain.lower().startswith('!vault') and not plain.startswith('$ANSIBLE_VAULT'):
return plain
password_file = (os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') or '').strip()
password = (os.environ.get('ANSIBLE_VAULT_PASSWORD') or '').strip()
if password_file:
try:
if not Path(password_file).expanduser().exists():
password_file = ''
except Exception:
password_file = ''
if not password_file and not password:
for base in (Path.cwd(), PROJECT_ROOT):
try:
candidate = base / '.vault_pass'
if candidate.is_file():
password_file = str(candidate)
os.environ['ANSIBLE_VAULT_PASSWORD_FILE'] = password_file
break
except Exception:
continue
if not password_file and not password:
return ''
if shutil.which('ansible-vault') is None:
return ''
tmp_path = None
if password_file:
password_file = password_file
elif password:
try:
tmp = tempfile.NamedTemporaryFile(delete=False)
tmp.write(password.encode('utf-8'))
tmp.flush()
tmp.close()
tmp_path = tmp.name
password_file = tmp_path
except Exception:
tmp_path = None
return ''
def _vault_view(path: str) -> str:
res = subprocess.run(
['ansible-vault', 'view', path] + (['--vault-password-file', password_file] if password_file else []),
capture_output=True,
text=True,
timeout=5,
env=os.environ.copy(),
stdin=subprocess.DEVNULL
)
if res.returncode != 0:
return ''
return res.stdout or ''
try:
output = _vault_view(str(vault_path))
if output:
return _extract_yaml_scalar_from_text(output, key)
# Inline vault: extract the block and decrypt separately.
try:
raw_text = vault_path.read_text()
except Exception:
raw_text = ''
inline_block = _extract_inline_vault_block(raw_text, key)
if not inline_block:
return ''
tmp_inline = tempfile.NamedTemporaryFile(delete=False)
tmp_inline.write(inline_block.encode('utf-8'))
tmp_inline.flush()
tmp_inline.close()
output = _vault_view(tmp_inline.name)
try:
os.unlink(tmp_inline.name)
except Exception:
pass
return output.strip()
except Exception:
return ''
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except Exception:
pass
def _detect_ansible_k3s_settings(inventory_path: Path, groups: dict, domain: str, ip_map: dict) -> dict:
k3s_hosts = groups.get('k3s_hosts') or []
server_url = ''
server_host = ''
for host in k3s_hosts:
host_vars_path = inventory_path / 'host_vars' / f'{host}.yml'
vals = _parse_yaml_scalar_values(
host_vars_path,
{'k3s_server_url', 'k3s_cluster_init', 'k3s_role'}
)
if not server_url and vals.get('k3s_server_url'):
server_url = vals['k3s_server_url']
if not server_host:
if _parse_bool(vals.get('k3s_cluster_init'), False) or vals.get('k3s_role') == 'server':
server_host = host
if not server_url and server_host:
host_for_url = server_host
if domain and '.' not in host_for_url:
host_for_url = f"{host_for_url}.{domain}"
server_url = f"https://{host_for_url}:6443"
vault_path = inventory_path / 'group_vars' / 'all' / 'vault_k3s.yml'
token = _try_read_ansible_vault_value(vault_path, 'vault_k3s_token')
return {
'server_url': server_url,
'server_host': server_host,
'token': token,
'vault_path': str(vault_path) if vault_path.exists() else '',
}
def _detect_ansible_topology(project_root: Path) -> dict:
prole_home = (os.environ.get('PROLE_HOME') or '').strip()
base_candidates = []
if prole_home:
base_candidates.append(Path(_expand_path(prole_home)))
base_candidates.append(project_root)
infra_path = None
for base in base_candidates:
try:
candidate = base / 'infrastructure'
if candidate.is_dir():
infra_path = candidate
break
except Exception:
continue
if infra_path is None:
return {}
inventory_path = infra_path / 'inventory'
if not inventory_path.is_dir():
return {}
groups = _parse_ansible_inventory_hosts(inventory_path / 'hosts.ini')
vars_vals = _parse_yaml_scalar_values(
inventory_path / 'group_vars' / 'all' / 'vars.yml',
{'ad_dc_ip', 'prole_domain', 'kerberos_realm', 'krb5_realm', 'kerberos_kdc', 'krb5_kdc', 'kerberos_kdc_ip'}
)
domain = vars_vals.get('prole_domain', '')
ad_dc_ip = vars_vals.get('ad_dc_ip', '')
explicit_realm = vars_vals.get('kerberos_realm') or vars_vals.get('krb5_realm') or ''
explicit_kdc = vars_vals.get('kerberos_kdc') or vars_vals.get('krb5_kdc') or vars_vals.get('kerberos_kdc_ip') or ''
if explicit_kdc and not ad_dc_ip:
ad_dc_ip = explicit_kdc
dns_domain, ip_map, fqdn_records = _parse_internal_a_records(inventory_path / 'group_vars' / 'all' / 'dns.yml')
if not domain and dns_domain:
domain = dns_domain
ad_vars = _parse_yaml_scalar_values(
inventory_path / 'group_vars' / 'ad_dc' / 'vars.yml',
{'samba_dns_server'}
)
samba_dns_server = ad_vars.get('samba_dns_server', '')
ad_dc_host = ''
ad_dc_hosts = groups.get('ad_dc') or []
if ad_dc_hosts:
ad_dc_host = ad_dc_hosts[0]
if samba_dns_server:
ad_dc_host = ad_dc_host or samba_dns_server
if not ad_dc_ip:
ad_dc_ip = _resolve_host_ip(samba_dns_server, domain, ip_map)
if not ad_dc_ip and ad_dc_host:
ad_dc_ip = _resolve_host_ip(ad_dc_host, domain, ip_map)
kdc_ip = explicit_kdc or ad_dc_ip or ''
realm = explicit_realm or (domain.upper() if domain else '')
all_hosts = set()
for hosts in groups.values():
all_hosts.update(hosts)
if ad_dc_host:
all_hosts.add(ad_dc_host)
host_ip_map = {}
unmapped_hosts = []
for host in sorted(all_hosts):
ip = _resolve_host_ip(host, domain, ip_map)
if ip:
host_ip_map[host] = ip
else:
unmapped_hosts.append(host)
k3s_info = _detect_ansible_k3s_settings(inventory_path, groups, domain, ip_map)
k3s_server_url = k3s_info.get('server_url') or ''
k3s_server_host = k3s_info.get('server_host') or ''
k3s_token = k3s_info.get('token') or ''
k3s_vault_path = k3s_info.get('vault_path') or ''
topology = {
'domain': domain,
'realm': realm,
'internal_records': fqdn_records,
'ad_dc': {
'host': ad_dc_host,
'ip': ad_dc_ip,
},
'k3s': {
'server_url': k3s_server_url,
'server_host': k3s_server_host,
'token_present': bool(k3s_token),
},
'groups': groups,
'hosts': host_ip_map,
'unmapped_hosts': unmapped_hosts,
}
try:
topology_json = json.dumps(topology, separators=(',', ':'))
except Exception:
topology_json = ''
return {
'infrastructure_path': str(infra_path),
'inventory_path': str(inventory_path),
'domain': domain,
'realm': realm,
'ad_dc_ip': ad_dc_ip,
'ad_dc_host': ad_dc_host,
'kdc_ip': kdc_ip,
'k3s_server_url': k3s_server_url,
'k3s_server_host': k3s_server_host,
'k3s_token': k3s_token,
'k3s_vault_path': k3s_vault_path,
'groups': groups,
'hosts': host_ip_map,
'unmapped_hosts': unmapped_hosts,
'topology': topology,
'topology_json': topology_json,
}
def _format_ansible_topology_summary(info: dict) -> str:
if not info:
return ''
lines = []
inv = info.get('inventory_path') or ''
if inv:
lines.append(f"Ansible inventory detected at {inv}.")
domain = info.get('domain') or ''
realm = info.get('realm') or ''
if domain or realm:
if domain and realm:
lines.append(f"Domain: {domain} (Realm: {realm})")
elif domain:
lines.append(f"Domain: {domain}")
else:
lines.append(f"Realm: {realm}")
ad_dc_host = info.get('ad_dc_host') or ''
ad_dc_ip = info.get('ad_dc_ip') or ''
if ad_dc_host or ad_dc_ip:
if ad_dc_host and ad_dc_ip:
lines.append(f"AD DC: {ad_dc_host} -> {ad_dc_ip}")
elif ad_dc_ip:
lines.append(f"AD DC IP: {ad_dc_ip}")
else:
lines.append(f"AD DC Host: {ad_dc_host}")
groups = info.get('groups') or {}
if groups:
group_bits = []
for name in sorted(groups.keys()):
group_bits.append(f"{name}({len(groups[name])})")
lines.append("Groups: " + ", ".join(group_bits))
hosts = info.get('hosts') or {}
if hosts:
host_items = sorted(hosts.items())
preview = host_items[:8]
host_str = ", ".join([f"{h}={ip}" for h, ip in preview])
if len(host_items) > len(preview):
host_str += f", +{len(host_items) - len(preview)} more"
lines.append("Hosts: " + host_str)
unmapped = info.get('unmapped_hosts') or []
if unmapped:
lines.append(f"Hosts without IPs: {len(unmapped)}")
return "\n".join(lines)
def _default_opentofu_pipeline_url() -> str:
url = (os.environ.get('PROLE_OPENTOFU_URL') or os.environ.get('OPENTOFU_URL') or '').strip()
return url or 'http://127.0.0.1:8080'
def _push_docker_image(image_tag: str, log_fn=None) -> bool:
"""Push Docker image with skopeo fallback for insecure registries."""
def _log(msg):
if log_fn:
try: log_fn(msg)
except: print(msg, end='')
else:
print(msg, end='', flush=True)
_log(f"Pushing image {image_tag} ...\n")
# 1. Try standard docker push
res = subprocess.run(['docker', 'push', image_tag], capture_output=True, text=True)
if res.returncode == 0:
_log(f"[OK] Pushed {image_tag}\n")
return True
_log(f"[WARN] Docker push failed: {res.stderr.strip() if res.stderr else 'unknown error'}\n")
# 2. Try skopeo fallback for insecure registry
skopeo = shutil.which('skopeo')
if skopeo:
_log("Retrying with skopeo (insecure registry) ...\n")
# skopeo copy --dest-tls-verify=false docker-daemon:TAG docker://TAG
cmd = [skopeo, 'copy', '--dest-tls-verify=false', f"docker-daemon:{image_tag}", f"docker://{image_tag}"]
res2 = subprocess.run(cmd, capture_output=True, text=True)
if res2.returncode == 0:
_log(f"[OK] Pushed {image_tag} using skopeo\n")
return True
_log(f"[ERROR] Skopeo push failed: {res2.stderr.strip() if res2.stderr else 'unknown error'}\n")
else:
_log("[ERROR] skopeo not found; cannot retry push.\n")
return False
def _default_k3s_kubeconfig_path() -> Path:
prole_service = (os.environ.get("PROLE_SERVICE") or "").strip()
if prole_service:
return Path(prole_service).expanduser() / "secrets" / "k3s.kubeconfig"
prole_home = (os.environ.get("PROLE_HOME") or "").strip()
if prole_home:
return Path(prole_home).expanduser() / "prole-k3s.kubeconfig"
return PROJECT_ROOT / "prole-k3s.kubeconfig"
def _write_k3s_kubeconfig(server_url: str, token: str) -> Path:
if not server_url:
raise ValueError("K3s server URL is required.")
if not token:
raise ValueError("K3s token is required.")
if not server_url.startswith('http'):
server_url = f"https://{server_url}"
cfg = (
"apiVersion: v1\n"
"kind: Config\n"
"clusters:\n"
"- cluster:\n"
f" server: {server_url}\n"
" insecure-skip-tls-verify: true\n"
" name: prole-k3s\n"
"contexts:\n"
"- context:\n"
" cluster: prole-k3s\n"
" user: prole-k3s\n"
" name: prole-k3s\n"
"current-context: prole-k3s\n"
"users:\n"
"- name: prole-k3s\n"
" user:\n"
f" token: {token}\n"
)
path = _default_k3s_kubeconfig_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(cfg, encoding="utf-8")
os.chmod(path, 0o600)
return path
def _sync_opentofu_pipeline(project_root: Path, namespace: str, k3s_server_url: str, k3s_token: str) -> Path:
pipeline_dir = project_root / 'deploy' / 'opentofu' / 'k3s'
manifest_root = pipeline_dir / 'manifests'
manifest_root.mkdir(parents=True, exist_ok=True)
sources = (
project_root / 'k8s' / 'prole',
project_root / 'k8s' / 'openbao',
project_root / 'k8s' / 'opentofu',
)
for src in sources:
if not src.exists():
continue
dst = manifest_root / src.name
dst.mkdir(parents=True, exist_ok=True)
for path in src.glob('*.yaml'):
shutil.copy2(path, dst / path.name)
tfvars = [
f'k3s_server_url = "{k3s_server_url}"',
f'k3s_token = "{k3s_token}"',
f'namespace = "{namespace}"',
''
]
(pipeline_dir / 'opentofu.auto.tfvars').write_text("\n".join(tfvars))
return pipeline_dir
def _render_prole_cfg(inputs: dict, globals_to_save: dict, sections: dict, generated_at: str | None = None) -> str:
inputs = dict(inputs or {})
globals_to_save = dict(globals_to_save or {})
sections = {k: dict(v or {}) for k, v in (sections or {}).items()}
def first_non_empty(*vals: str) -> str:
for val in vals:
if val:
return val
return ''
def get_input(key: str) -> str:
return str(inputs.get(key, '') or '').strip()
def get_section(section: str, key: str) -> str:
return str((sections.get(section, {}) or {}).get(key, '') or '').strip()
base_home = first_non_empty(
str(globals_to_save.get('PROLE_HOME', '')).strip(),
get_section('System Environment', 'PROLE_HOME'),
get_input('env_setup.PROLE_HOME'),
)
base_data = first_non_empty(
str(globals_to_save.get('PROLE_DATA', '')).strip(),
get_section('System Environment', 'PROLE_DATA'),
get_input('env_setup.PROLE_DATA'),
)
base_logs = first_non_empty(
str(globals_to_save.get('PROLE_LOGS', '')).strip(),
get_section('System Environment', 'PROLE_LOGS'),
get_input('env_setup.PROLE_LOGS'),
)
base_conf = first_non_empty(
str(globals_to_save.get('PROLE_CONF', '')).strip(),
get_section('System Environment', 'PROLE_CONF'),
get_input('env_setup.PROLE_CONF'),
)
if not base_conf and base_home:
base_conf = f"{base_home}/conf"
base_service = first_non_empty(
str(globals_to_save.get('PROLE_SERVICE', '')).strip(),
get_section('System Environment', 'PROLE_SERVICE'),
get_input('env_setup.PROLE_SERVICE'),
)
if not base_service and base_home:
base_service = f"{base_home}/etc"
base_namespace = first_non_empty(
str(globals_to_save.get('NAMESPACE', '')).strip(),
get_input('env_setup.NAMESPACE'),
get_section('Database Creation', 'NAMESPACE'),
)
base_service_namespace = first_non_empty(
str(globals_to_save.get('SERVICE_NAMESPACE', '')).strip(),
get_section('Global', 'SERVICE_NAMESPACE'),
) or base_namespace
def derive_value(current: str, derived: str, placeholder: str) -> str:
cur = str(current or '').strip()
if not derived:
return cur
if not cur or cur == derived:
return placeholder
return cur
# User-editable values surfaced at the top
user_section = {}
if base_home:
user_section['PROLE_HOME'] = base_home
if base_conf:
user_section['PROLE_CONF'] = derive_value(base_conf, base_home + '/conf' if base_home else '', '${PROLE_HOME}/conf')
if base_service:
user_section['PROLE_SERVICE'] = derive_value(base_service, base_home + '/etc' if base_home else '', '${PROLE_HOME}/etc')
if base_data:
user_section['PROLE_DATA'] = base_data
if base_logs:
user_section['PROLE_LOGS'] = base_logs
if base_namespace:
user_section['NAMESPACE'] = base_namespace
if base_service_namespace:
user_section['SERVICE_NAMESPACE'] = base_service_namespace
# Derived values for repeated touch-points
if base_home:
inputs['disk_selection.local_path'] = derive_value(
inputs.get('disk_selection.local_path', ''),
f"{base_home}/prole-tools-app/dist",
'${PROLE_HOME}/prole-tools-app/dist'
)
if base_namespace:
inputs['env_setup.NAMESPACE'] = derive_value(
inputs.get('env_setup.NAMESPACE', ''),
base_namespace,
'${NAMESPACE}'
)
inputs['init_password.db_namespace'] = derive_value(
inputs.get('init_password.db_namespace', ''),
base_namespace,
'${NAMESPACE}'
)
if base_home:
inputs['env_setup.PROLE_HOME'] = derive_value(
inputs.get('env_setup.PROLE_HOME', ''),
base_home,
'${PROLE_HOME}'
)
if base_conf:
inputs['env_setup.PROLE_CONF'] = derive_value(
inputs.get('env_setup.PROLE_CONF', ''),
base_conf,
'${PROLE_CONF}'
)
if base_data:
inputs['env_setup.PROLE_DATA'] = derive_value(
inputs.get('env_setup.PROLE_DATA', ''),
base_data,
'${PROLE_DATA}'
)
if base_logs:
inputs['env_setup.PROLE_LOGS'] = derive_value(
inputs.get('env_setup.PROLE_LOGS', ''),
base_logs,
'${PROLE_LOGS}'
)
if base_service:
inputs['env_setup.PROLE_SERVICE'] = derive_value(
inputs.get('env_setup.PROLE_SERVICE', ''),
base_service,
'${PROLE_SERVICE}'
)
globals_to_save['PROLE_HOME'] = derive_value(
globals_to_save.get('PROLE_HOME', ''),
base_home,
'${PROLE_HOME}'
)
if base_namespace:
globals_to_save['NAMESPACE'] = derive_value(
globals_to_save.get('NAMESPACE', ''),
base_namespace,
'${NAMESPACE}'
)
if base_service_namespace:
globals_to_save['SERVICE_NAMESPACE'] = derive_value(
globals_to_save.get('SERVICE_NAMESPACE', ''),
base_service_namespace,
'${SERVICE_NAMESPACE}'
)
sys_env = sections.get('System Environment', {})
if base_home:
sys_env['PROLE_HOME'] = derive_value(sys_env.get('PROLE_HOME', ''), base_home, '${PROLE_HOME}')
if base_conf:
sys_env['PROLE_CONF'] = derive_value(sys_env.get('PROLE_CONF', ''), base_conf, '${PROLE_CONF}')
if base_data:
sys_env['PROLE_DATA'] = derive_value(sys_env.get('PROLE_DATA', ''), base_data, '${PROLE_DATA}')
if base_logs:
sys_env['PROLE_LOGS'] = derive_value(sys_env.get('PROLE_LOGS', ''), base_logs, '${PROLE_LOGS}')
if base_service:
sys_env['PROLE_SERVICE'] = derive_value(sys_env.get('PROLE_SERVICE', ''), base_service, '${PROLE_SERVICE}')
if sys_env:
sections['System Environment'] = sys_env
net = sections.get('Network', {})
if base_home:
net['ANSIBLE_INFRASTRUCTURE'] = derive_value(
net.get('ANSIBLE_INFRASTRUCTURE', ''),
f"{base_home}/infrastructure",
'${PROLE_HOME}/infrastructure'
)
net['ANSIBLE_INVENTORY'] = derive_value(
net.get('ANSIBLE_INVENTORY', ''),
f"{base_home}/infrastructure/inventory",
'${PROLE_HOME}/infrastructure/inventory'
)
if net:
sections['Network'] = net
db_create = sections.get('Database Creation', {})
if base_namespace:
db_create['NAMESPACE'] = derive_value(
db_create.get('NAMESPACE', ''),
base_namespace,
'${NAMESPACE}'
)
if db_create:
sections['Database Creation'] = db_create
prod = sections.get('Prod Cluster (k8s)', {})
if base_data:
prod['ARTIFACTS_DIR'] = derive_value(
prod.get('ARTIFACTS_DIR', ''),
f"{base_data}/staging",
'${PROLE_DATA}/staging'
)
if prod:
sections['Prod Cluster (k8s)'] = prod
# Normalize port-forward namespaces back to placeholders if they match base namespace
if base_namespace:
pf = sections.get('Port Forwards', {})
if pf:
ns_pat = re.compile(r"(namespace=)" + re.escape(base_namespace) + r"(?=;|$)")
for k, v in pf.items():
if not isinstance(v, str):
continue
if 'namespace=' in v:
pf[k] = ns_pat.sub(r"\1${NAMESPACE}", v)
sections['Port Forwards'] = pf
content = []
content.append('; Prole Master Configuration File')
content.append('; Generated by install.py on ' + (generated_at or time.strftime('%Y-%m-%d %H:%M:%S')))
content.append('; This file is used as input for Ansible deployment and k8s cluster creation.')
content.append('')
allowed_namespace_keys = {'NAMESPACE', 'SERVICE_NAMESPACE'}
def is_namespace_key(key: str) -> bool:
return 'namespace' in key.lower()
def emit_kv(data: dict, allow_namespace: bool = False) -> None:
for k in sorted(data.keys()):
if is_namespace_key(k) and (not allow_namespace or k not in allowed_namespace_keys):
continue
content.append(f'{k} = {data[k]}')
# User overrides section
content.append('[User]')
content.append('; User-editable values; derived values below reference these by default.')
if not user_section:
content.append('; No user values captured yet for this section.')
else:
emit_kv(user_section, allow_namespace=True)
content.append('')
# Inputs section (replayable UI inputs)
content.append('[Inputs]')
content.append('; Screen-scoped inputs used for unattended replays (-S)')
if not inputs:
content.append('; No input values captured yet for this section.')
else:
emit_kv(inputs, allow_namespace=False)
content.append('')
# Global Section
content.append('[Global]')
content.append('; Variables used by name in more than one place or assumed global scope')
emit_kv(globals_to_save, allow_namespace=True)
content.append('')
sections_order = [
'Welcome', 'Dependencies', 'Network', 'Port Forwards', 'System Environment',
'Monitoring', 'Kerberos Authentication', 'Ollama', 'Optional Features', 'Database Creation',
'Initialize Cluster', 'Dev Cluster (k3d)', 'Service Cluster (k3s)', 'Prod Cluster (k8s)',
'Docker Build', 'Initialization Scripts', 'Deployment', 'Install'
]
for section in sections_order:
data = sections.get(section, {})
content.append(f'[{section}]')
if not data:
content.append('; No configuration values captured yet for this section.')
else:
emit_kv(data, allow_namespace=False)
content.append('')
return '\n'.join(content)
def _pf_extract_id(mapping_str: str) -> str:
if not mapping_str:
return ''
for part in str(mapping_str).split(';'):
part = part.strip()
if part.startswith('id='):
return part[3:].strip()
return ''
def _pf_mapping_str(
mapping_id: str,
namespace: str,
target: str,
host_port: str | int,
service_port: str | int,
address: str = "0.0.0.0",
protocol: str = "TCP",
description: str = "",
) -> str:
return (
f"id={mapping_id};"
f"namespace={namespace};"
f"target={target};"
f"address={address};"
f"hostPort={host_port};"
f"servicePort={service_port};"
f"protocol={protocol};"
f"description={description}"
)
def _pf_upsert_mapping(pf_section: dict, prefix: str, mapping_str: str) -> bool:
if pf_section is None:
return False
mapping_id = _pf_extract_id(mapping_str)
if not mapping_id:
return False
updated = False
for k, v in list(pf_section.items()):
if not k.startswith(prefix):
continue
if v == mapping_str:
return False
if f"id={mapping_id};" in v or v.strip() == f"id={mapping_id}":
pf_section[k] = mapping_str
updated = True
if updated:
return True
existing_indices = []
for k in pf_section.keys():
if k.startswith(prefix):
try:
existing_indices.append(int(k[len(prefix):]))
except ValueError:
pass
next_idx = max(existing_indices, default=0) + 1
pf_section[f"{prefix}{next_idx}"] = mapping_str
return True
def _build_required_port_forwards(
mode: str,
service_ns: str,
db_ns: str,
db_host_port: str,
supabase_enabled: bool,
supabase_namespace: str,
) -> list[str]:
service_ns = (service_ns or '').strip() or 'default'
db_ns = (db_ns or '').strip() or 'default'
supabase_namespace = (supabase_namespace or '').strip() or 'supabase'
db_host_port = (db_host_port or '5432').strip()
addr_all = "0.0.0.0"
addr_local = "127.0.0.1"
openbao_addr = addr_local if mode == "k3d" else addr_all
mappings = [
_pf_mapping_str("registry", service_ns, "svc/registry", "5000", "5000", addr_all, "TCP", "Docker registry"),
_pf_mapping_str("garage", service_ns, "svc/garage", "3900", "3900", addr_all, "TCP", "Garage S3"),
_pf_mapping_str("openbao", service_ns, "svc/openbao", "8200", "8200", openbao_addr, "TCP", "OpenBao"),
_pf_mapping_str("opentofu", service_ns, "svc/opentofu", "8080", "8080", addr_all, "TCP", "OpenTofu"),
_pf_mapping_str("dashboard", "kubernetes-dashboard", "svc/kubernetes-dashboard-kong-proxy",
"8443", "443", addr_local, "TCP", "Kubernetes Dashboard"),
_pf_mapping_str("postgres", db_ns, "svc/prole-db-rw", db_host_port, "5432", addr_all, "TCP",
"PostgreSQL (primary)"),
_pf_mapping_str("grafana", db_ns, "svc/grafana", "3000", "3000", addr_all, "TCP", "Grafana UI"),
]
if supabase_enabled:
used_ports = {part.split('hostPort=', 1)[1].split(';', 1)[0] for part in mappings if 'hostPort=' in part}
supabase_port = "8080"
if supabase_port in used_ports:
supabase_port = "8088"
mappings.append(
_pf_mapping_str("supabase-kong", supabase_namespace, "svc/kong", "8000", "8000", addr_all, "TCP",
"Supabase API (Kong)")
)
mappings.append(
_pf_mapping_str("supabase-studio", supabase_namespace, "svc/studio", supabase_port, "3000", addr_all, "TCP",
"Supabase Studio")
)
return mappings
class ProleController:
"""Business logic for Prole installer, separated from UI."""
def __init__(self, project_root):
self.project_root = project_root
def check_docker_running(self):
"""Check if Docker daemon is responsive."""
try:
subprocess.run(['docker', 'info'], capture_output=True, check=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def get_prole_db_version(self):
pg_version_file = self.project_root / "conf" / "postgresql" / ".version"
release_file = self.project_root / "prole-db" / ".version"
pg_version = pg_version_file.read_text().strip() if pg_version_file.exists() else "17.7"
release = release_file.read_text().strip() if release_file.exists() else "43"
if release.isdigit():
release = release.zfill(3)
return f"{pg_version}-{release}"
def run_script(self, script_name, args=None, env=None, stdin_text=None, on_line=None, on_stderr_line=None, stderr_to_stdout=True):
"""Generic runner for etc/ scripts.
Copies the script to $PROLE_HOME/etc before running it.
"""
if args is None: args = []
# Get paths
source_script = self.project_root / "etc" / script_name
# Determine target etc directory
prole_home_val = (env or os.environ).get("PROLE_HOME")
if not prole_home_val:
prole_home = Path.home() / ".prole"
else:
prole_home = Path(prole_home_val).expanduser()
target_etc = prole_home / "etc"
target_etc.mkdir(parents=True, exist_ok=True)
target_script = target_etc / script_name
# Also ensure k8s resources are copied to $PROLE_HOME/k8s for reference
target_k8s = prole_home / "k8s"
source_k8s = self.project_root / "k8s"
if source_k8s.exists():
if source_k8s.resolve() != target_k8s.resolve():
if not target_k8s.exists() or (source_k8s.stat().st_mtime > target_k8s.stat().st_mtime):
if target_k8s.exists():
shutil.rmtree(target_k8s)
shutil.copytree(source_k8s, target_k8s)
# Ensure conf/postgresql is copied for version detection
target_conf = prole_home / "conf"
source_conf = self.project_root / "conf"
if source_conf.exists():
if source_conf.resolve() != target_conf.resolve():
if not target_conf.exists() or (source_conf.stat().st_mtime > target_conf.stat().st_mtime):
# Only copy what we need or the whole thing? etc/ already exists in PROLE_HOME.
# Let's copy the whole conf dir if it doesn't exist or is older.
if target_conf.exists():
shutil.rmtree(target_conf)
shutil.copytree(source_conf, target_conf)
# Ensure prole-db/.version is available in PROLE_HOME for image tagging
source_db_version = self.project_root / "prole-db" / ".version"
if source_db_version.exists():
target_db_dir = prole_home / "prole-db"
target_db_dir.mkdir(parents=True, exist_ok=True)
target_db_version = target_db_dir / ".version"
try:
shutil.copy2(source_db_version, target_db_version)
except Exception:
pass
# Copy script and set permissions
if source_script.exists():
if source_script.resolve() != target_script.resolve():
shutil.copy2(source_script, target_script)
os.chmod(target_script, 0o755)
# Keep shared config helpers in sync (prole_cfg.sh)
try:
source_cfg = self.project_root / "etc" / "prole_cfg.sh"
target_cfg = target_etc / "prole_cfg.sh"
if source_cfg.exists():
if not target_cfg.exists() or (source_cfg.stat().st_mtime > target_cfg.stat().st_mtime):
shutil.copy2(source_cfg, target_cfg)
os.chmod(target_cfg, 0o755)
except Exception:
pass
# Run from the installed location
cmd = ['bash', str(target_script)] + args
# Ensure PYTHONUNBUFFERED=1 for any python scripts called within the bash script
run_env = (env or os.environ).copy()
run_env['PYTHONUNBUFFERED'] = '1'
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE if stdin_text else None,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if stderr_to_stdout else subprocess.PIPE,
text=True,
bufsize=1,
env=run_env
)
if stdin_text and proc.stdin:
proc.stdin.write(stdin_text)
proc.stdin.close()
def _read_stream(stream, handler):
if not stream:
return
for line in iter(stream.readline, ''):
if handler:
handler(line)
stderr_thread = None
if not stderr_to_stdout:
stderr_thread = threading.Thread(target=_read_stream, args=(proc.stderr, on_stderr_line), daemon=True)
stderr_thread.start()
while True:
line = proc.stdout.readline() if proc.stdout else None
if not line and proc.poll() is not None:
break
if line and on_line:
on_line(line)
if stderr_thread:
stderr_thread.join(timeout=2)
return proc.returncode
def is_apple_silicon():
"""Check if running on Apple Silicon (ARM64)."""
return inst_config.is_apple_silicon()
def get_docker_build_platform_args(target_env: str | None = None):
"""Get Docker build platform arguments for the target environment (from config)."""
return inst_config.get_docker_build_platform_args(target_env)
class ProleInstaller:
KUBECTL_STATUS_TAB = "__kubectl_status__"
def __init__(self, root, config_path: str | None = None):
self.controller = ProleController(PROJECT_ROOT)
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
_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('<Configure>', _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
# 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:
try:
self.db_namespace.set(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='k3d-prole-dev-cluster')
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())
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 == 'k3d-prole-dev-cluster':
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('<Button-1>', 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):
# 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('<Button-1>')
self.root.unbind('<Return>')
self.root.unbind('<KP_Enter>')
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 _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_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() == '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" <command> [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('<FocusOut>', 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, 'k3d-prole-dev-cluster'))
_set('init_cluster.mode', _deployment_mode_from_env(_get_var(self.cluster_env, 'k3d-prole-dev-cluster')))
_set('init_cluster.deployment_target', _deployment_target_label(_get_var(self.cluster_env, 'k3d-prole-dev-cluster')))
_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 _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'
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,
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 _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: prole/conf/prole.cfg
# If PROLE_CONF is set, use it. Otherwise fallback to PROJECT_ROOT/prole/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 / 'prole' / 'conf'
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': 'k3d-prole-dev-cluster',
'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:
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/<suffix>, 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
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
self._ollama_model_options = {}
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
prefix = ' [✓] ' if key == current_key else ' [ ] '
models_preview = ', '.join(models) if models else ''
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
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})
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:
self.safe_after(lambda: self._update_ollama_table(rows, notice))
self._refreshing_ollama_table = False
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
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Model:', fill='black', font=('SF Pro Text', 12, 'bold')))
self._ollama_model_combo = ttk.Combobox(self.bg_canvas, textvariable=self.ollama_model, values=[], state='normal', width=36)
model_window = self.bg_canvas.create_window(x_field, y-12, window=self._ollama_model_combo, anchor='nw', width=300, height=32)
self._canvas_items.append(model_window)
self._overlay_widgets.append(self._ollama_model_combo)
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
current_key = self._ollama_current_key()
models = self._ollama_model_options.get(current_key, [])
if models:
self._set_ollama_model_values(models)
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)
columns = ('select', 'host', 'port', '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('models', text='Models')
tree.column('select', width=60, anchor='center')
tree.column('host', width=220, anchor='w')
tree.column('port', width=80, anchor='center')
tree.column('models', width=520, 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
def on_select(event):
if getattr(self, '_refreshing_ollama_table', False):
return
sel = tree.selection()
if not sel:
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}"
models = self._ollama_model_options.get(key, [])
if models:
self._set_ollama_model_values(models)
self._render_ollama_table_from_cache()
tree.bind('<<TreeviewSelect>>', on_select)
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['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
mode = self._deployment_mode()
if mode == "k3d":
env.setdefault("KRB5_AD_PORT_FORWARD", "1")
elif 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('<Button-1>', _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._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, 48, 812, "Initializing...", fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(status_label)
def worker():
try:
namespace = (self.db_namespace.get() or '').strip() or 'default'
env = self._script_env_for_namespace(namespace)
env["SERVICE_NAMESPACE"] = self._get_service_namespace()
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=lambda l: console.write(l)
)
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.root.after(500, lambda: self.show_page('init_cluster'))
return
self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Service layer ready. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None)
self.root.after(1000, lambda: self.show_page(next_page))
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.root.after(500, lambda: self.show_page('init_cluster'))
threading.Thread(target=worker, daemon=True).start()
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 = 'k3d-prole-dev-cluster'
elif key == 'service':
desired = 'prole-service-cluster'
elif key == 'prod':
desired = 'prole-prod-cluster'
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 (k3d/k3s/prod), OpenBao, and required services are running.', y=200)
# Cluster Selection (Radio Buttons)
x_label = 48
y = 280
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Cluster Name:', fill='black', font=('SF Pro Text', 14, 'bold')))
y += 28
cluster_options = [
('k3d-prole-dev-cluster', 'prole-dev-cluster'),
('prole-service-cluster', 'prole-service-cluster'),
('prole-prod-cluster', 'prole-prod-cluster')
]
# 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 = 240
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)
y += 30
save_x = content_width - 140
save_y = y - 4
btn = tk.Button(self.bg_canvas, text='Save', command=self._on_save_cluster_config,
bg='#F5F5DC', fg='black', activebackground='#E5E5D5', activeforeground='black',
highlightbackground='#F5F5DC', highlightthickness=0,
relief='flat', font=('SF Pro Text', 11), padx=10, pady=2)
self._init_cluster_button = btn
btn_window = self.bg_canvas.create_window(save_x, save_y, window=btn, anchor='nw')
self._canvas_items.append(btn_window)
self._overlay_widgets.append(btn)
selected_env = self.cluster_env.get()
selected_env_key = self._cluster_env_key(selected_env)
y += 16
if selected_env_key == 'dev':
# kubectx dropdown
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Available kubectx contexts:', fill='black', font=('SF Pro Text', 12, 'bold')))
y += 24
values = self._get_kubectx_list()
combo = ttk.Combobox(self.bg_canvas, textvariable=self.selected_kubectx, values=values, state='readonly', width=40)
# Preselect from prole.cfg (cluster env) if available and present in contexts
try:
desired_ctx = (self.cluster_env.get() or '').strip()
if desired_ctx and desired_ctx in values:
self.selected_kubectx.set(desired_ctx)
elif not self.selected_kubectx.get() and values:
self.selected_kubectx.set(values[0])
except Exception:
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 += 34
elif 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 += 24
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 (vault_k3s_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 += 28
ui.canvas_text(self, x_label + 20, y, 'Service Namespace:', fill='black', font=('SF Pro Text', 11))
service_ns_entry = tk.Entry(self.bg_canvas, textvariable=self.service_namespace, width=40)
service_ns_win = self.bg_canvas.create_window(x_label + 200, y - 8, window=service_ns_entry, anchor='nw')
self._canvas_items.append(service_ns_win)
self._overlay_widgets.append(service_ns_entry)
y += 28
service_label = ui.canvas_text(self, x_label, y, '', fill='black', font=('SF Pro Text', 12, 'bold'))
self._canvas_items.append(service_label)
def _update_service_label(*_args):
ns_val = (self.service_namespace.get() or '').strip() or 'default'
if self.bg_canvas.winfo_exists():
self.bg_canvas.itemconfig(service_label, text=f"Common Services (namespace: {ns_val})")
if hasattr(self, '_service_ns_trace'):
try:
self.service_namespace.trace_remove('write', self._service_ns_trace)
except Exception:
pass
self._service_ns_trace = self.service_namespace.trace_add('write', lambda *_a: self.safe_after(_update_service_label))
_update_service_label()
y += 24
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])
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 += 24
self._verify_k3s_services()
elif 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 + 20 + 400, y - 5, window=browse_btn, anchor='nw')
self._canvas_items.append(browse_win)
self._overlay_widgets.append(browse_btn)
y += 40
ui.canvas_text(self, x_label + 20, y, '(Used by etc/deploy_pipeline.sh --mode gcp)', fill='#6e6e73', font=('SF Pro Text', 10))
y += 30
# Docker/K3D Status (only if dev)
if selected_env_key == 'dev':
y += 10
self.docker_status_label = ui.canvas_text(self, x_label, y, 'Docker: Checking...', fill='#6e6e73', font=('SF Pro Text', 12))
self._canvas_items.append(self.docker_status_label)
y += 30
self.k3d_status_label = ui.canvas_text(self, x_label, y, 'Cluster: Checking...', fill='#6e6e73', font=('SF Pro Text', 12))
self._canvas_items.append(self.k3d_status_label)
y += 30
self.registry_status_label = ui.canvas_text(self, x_label, y, 'Local Registry: Checking...', fill='#6e6e73', font=('SF Pro Text', 12))
self._canvas_items.append(self.registry_status_label)
y += 30
self.openbao_status_label = ui.canvas_text(self, x_label, y, 'OpenBao: Checking...', fill='#6e6e73', font=('SF Pro Text', 12))
self._canvas_items.append(self.openbao_status_label)
y += 30
self._cluster_env_message_label = ui.canvas_text(self, x_label, y, '', fill='#34c759', font=('SF Pro Text', 11))
self._canvas_items.append(self._cluster_env_message_label)
# Async status check
self.check_cluster_status_async()
y += 40
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': True,
'msg': 'Dashboard (Kong): Not installed',
'fill': '#6e6e73',
'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': True,
'msg': 'Dashboard (Kong): Not installed',
'fill': '#6e6e73',
'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 _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_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 self._cluster_env_key() != 'service' or 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"] = "k3s"
if not env.get("KUBECONFIG"):
console.write("KUBECONFIG not generated. Check K3s 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 (registry/OpenBao/OpenTofu) 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"] = "k3s"
if not env.get("KUBECONFIG"):
if console:
console.write("KUBECONFIG not generated. Check K3s credentials in prole.cfg.\n")
self._verify_k3s_services()
return
try:
common_args = ["-n", env["NAMESPACE"]]
if self.kerberos_enabled.get():
common_args.append("-k")
common_args.append("update")
rc = self.controller.run_script(
"init_common_services.sh",
args=common_args,
env=env,
on_line=lambda l: console.write(l) if console else None
)
if console:
console.write(f"\nExit status: {rc}\n")
finally:
pass
self._verify_k3s_services()
threading.Thread(target=_deploy, daemon=True).start()
def _on_save_cluster_config(self):
"""Verify the config then write the values to prole.cfg."""
# Verification logic
env_key = self._cluster_env_key()
if env_key == 'dev':
ctx = self.selected_kubectx.get()
if not ctx:
messagebox.showwarning("Validation", "Please select a kubectx context.")
return
# Switch context
try:
subprocess.run(['kubectx', ctx], check=True)
except Exception:
try:
subprocess.run(['kubectl', 'config', 'use-context', ctx], check=True)
except Exception as e:
messagebox.showerror("Error", f"Failed to switch to context {ctx}: {e}")
return
elif env_key == 'service':
if not (self.k3s_server_url.get() or '').strip():
messagebox.showwarning("Validation", "Please specify the K3s server URL.")
return
if not (self.k3s_token.get() or '').strip():
messagebox.showwarning("Validation", "Please specify the K3s token.")
return
elif env_key == 'prod':
path = self.prod_artifacts_path.get().strip()
if not path:
messagebox.showwarning("Validation", "Please specify an artifact staging directory.")
return
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
# Capture cluster info for config
self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = self.cluster_env.get()
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()
# Save config
self._save_prole_cfg()
# Apply common services configuration for service namespace, then refresh statuses
ns = self._get_service_namespace()
def worker():
try:
env_vars = self._script_env_for_namespace(ns)
# Use 'update' to (re)apply manifests and config in the selected namespace
svc_args = ["-n", ns]
if self.kerberos_enabled.get():
svc_args.append("-k")
svc_args.append("update")
self.controller.run_script("init_common_services.sh", args=svc_args, env=env_vars)
except Exception as e:
print(f"Cluster service update failed: {e}")
finally:
# Refresh status labels
self.safe_after(self.check_cluster_status_async)
threading.Thread(target=worker, daemon=True).start()
messagebox.showinfo("Success", "Cluster configuration saved and common services applied.")
def _cluster_status_snapshot(self) -> dict:
env = self._cluster_env_key()
docker_ok = self.controller.check_docker_running()
docker_msg = 'Docker: Running' if docker_ok else 'Docker: Not running'
docker_fill = '#34c759' if docker_ok else '#ff3b30'
openbao_ok = self._check_openbao_health()
openbao_msg = 'OpenBao: Running' if openbao_ok else 'OpenBao: Not reachable'
openbao_fill = '#34c759' if openbao_ok else '#ff9f0a'
registry_ok = False
registry_msg = ''
registry_fill = '#6e6e73'
if env == 'dev':
if docker_ok:
try:
ps = subprocess.run(['docker', 'ps', '--format', '{{.Names}} {{.Image}}'], capture_output=True, text=True)
for line in (ps.stdout or '').splitlines():
parts = line.split()
if not parts:
continue
name = parts[0]
image = parts[1] if len(parts) > 1 else ''
if name == 'k3d-prole-registry' or image.startswith('registry:2'):
registry_ok = True
break
except Exception:
registry_ok = False
registry_msg = 'Local Registry: Running' if registry_ok else 'Local Registry: Not running'
registry_fill = '#34c759' if registry_ok else '#ff9f0a'
else:
registry_ok = True
registry_msg = 'Local Registry: Not required'
registry_fill = '#6e6e73'
cluster_ok = False
if env == 'dev':
cluster_name = "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/stopped"
else:
cluster_ok, cluster_msg = self._check_k8s_cluster(env)
cluster_fill = '#34c759' if cluster_ok else '#ff9f0a'
return {
'env': env,
'docker_ok': docker_ok,
'docker_msg': docker_msg,
'docker_fill': docker_fill,
'cluster_ok': cluster_ok,
'cluster_msg': cluster_msg,
'cluster_fill': cluster_fill,
'registry_ok': registry_ok,
'registry_msg': registry_msg,
'registry_fill': registry_fill,
'openbao_ok': openbao_ok,
'openbao_msg': openbao_msg,
'openbao_fill': openbao_fill,
}
def _cluster_ready_for_navigation(self) -> bool:
status = self._cluster_status_snapshot()
env = status['env']
if env == 'dev' and not status['docker_ok']:
try:
messagebox.showerror('Docker', 'Docker is not running. Please start Docker and try again.')
except Exception:
pass
return False
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
if env == 'dev' and not status['registry_ok']:
try:
messagebox.showerror('Registry', 'Local registry is not running. Start the registry and try again.')
except Exception:
pass
return False
if not status['openbao_ok']:
if env == 'dev':
# Attempt to start OpenBao locally
if status['docker_ok']:
env_vars = self._script_env_for_namespace((self.db_namespace.get() or '').strip())
env_vars["PROLE_DB_USER"] = self.db_username.get().strip()
env_vars["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get())
rc_openbao = self.controller.run_script("init_openbao.sh", args=["start"], env=env_vars)
if rc_openbao != 0:
try:
messagebox.showerror('OpenBao', f'Failed to start OpenBao (code {rc_openbao}).')
except Exception:
pass
return False
# Re-check after start
status = self._cluster_status_snapshot()
if not status['openbao_ok']:
try:
messagebox.showerror('OpenBao', 'OpenBao is not reachable after startup.')
except Exception:
pass
return False
else:
try:
messagebox.showerror('OpenBao', 'OpenBao is not reachable and Docker is not running.')
except Exception:
pass
return False
else:
try:
messagebox.showerror('OpenBao', 'OpenBao is not reachable for the selected cluster.')
except Exception:
pass
return False
# Refresh status display for any changes
self.check_cluster_status_async()
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)
def update_ui():
if hasattr(self, 'docker_status_label'):
self.bg_canvas.itemconfig(self.docker_status_label, text=status['docker_msg'], fill=status['docker_fill'])
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, 'registry_status_label'):
self.bg_canvas.itemconfig(self.registry_status_label, text=status['registry_msg'], fill=status['registry_fill'])
if hasattr(self, 'openbao_status_label'):
self.bg_canvas.itemconfig(self.openbao_status_label, text=status['openbao_msg'], fill=status['openbao_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. Ensure OpenBao container is running (local-only)
env = self._script_env_for_namespace((self.db_namespace.get() or '').strip())
env["PROLE_DB_USER"] = self.db_username.get().strip()
env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get())
rc_openbao = self.controller.run_script("init_openbao.sh", args=["start"], env=env)
if rc_openbao != 0:
self.root.after(0, lambda: messagebox.showerror('OpenBao', f'Failed to start OpenBao (code {rc_openbao}).'))
return
# 3. Ensure local registry is available before cluster creation (dev only)
try:
reg_info = self.ensure_local_registry_available()
except Exception:
reg_info = None
if not reg_info:
self.root.after(0, lambda: messagebox.showerror('Registry', 'Local registry is not running. Please start the registry and try again.'))
return
# 4. Manage or verify cluster
cluster_name = 'prole-dev-cluster'
res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True)
if cluster_name not in (res.stdout or ''):
# 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:
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)
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)
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! Importing...", 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':
# Import to k3d
cluster_name = "prole-dev-cluster"
self._db_build_console.write(f"Importing image to {cluster_name}...\n")
subprocess.run(['k3d', 'image', 'import', image_name, '-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 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, 'Root 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()
tree.bind('<<TreeviewSelect>>', 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)
# 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)
# 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_combo = ttk.Combobox(self.bg_canvas, textvariable=self.deploy_target, values=mode_values, state='readonly', width=24)
mode_combo_window = self.bg_canvas.create_window(48, 800, window=mode_combo, anchor='nw', width=180)
self._canvas_items.append(mode_combo_window)
self._overlay_widgets.append(mode_combo)
# 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)
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)
# Status Label
self._cnpg_deploy_status_label = ui.canvas_text(self, 600, 812, "", fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(self._cnpg_deploy_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'),
('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("<<NotebookTabChanged>>", 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["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.setdefault("KRB5_AD_PORT_FORWARD", "1")
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'
# 0. Pre-emptive stop for port-forwards to avoid conflicts on re-install
script_pf = "init_port_forwards.sh"
if script_pf in self.script_consoles:
self.script_consoles[script_pf].write("Stopping existing port-forwards to avoid conflicts...\n")
self.controller.run_script(
script_pf,
args=mode_args + ['stop'],
env=env
)
# 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)
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_port_forwards.sh start
if overall_success:
script = "init_port_forwards.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 _pf_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_pf = self.controller.run_script(
script,
args=mode_args + ['start'],
env=env,
on_line=_pf_line
)
if log_fp:
try:
log_fp.close()
except Exception:
pass
if rc_pf != 0:
self.script_consoles[script].write(f"\nERROR: {script} start failed with code {rc_pf}\n")
overall_success = False
else:
self.script_consoles[script].write(f"\n{script} completed successfully.\n")
else:
self.script_consoles["init_port_forwards.sh"].write("Skipping Port Forwards because previous steps failed.\n")
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("<Button-1>", 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("<<ComboboxSelected>>", 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("<Button-1>", 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('<Button-1>', _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('<Return>', _enter_build)
self.root.bind('<KP_Enter>', _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':
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
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':
# Capture cluster info
self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = self.cluster_env.get()
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())
self._save_prole_cfg()
if not self._cluster_ready_for_navigation():
return
self._run_service_layer_overlay(next_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')
# 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('<Button-1>', 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('<Configure>', _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('<Configure>', _follow)
self._terminal_follow_bound = True
def _stop_terminal_follow(self):
if getattr(self, '_terminal_follow_bound', False):
try:
self.root.unbind('<Configure>')
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):
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)
import_dir = self.docker_import_dir.get().strip()
if import_dir:
env["DOCKER_IMPORT_DIR"] = import_dir
console = getattr(self, 'install_consoles', {}).get(script_name)
if console:
console.clear()
console.write(f"Running {script_name} --docker-export...\n")
def _on_line(line):
if console:
console.write(line)
rc = self.controller.run_script(
script_name,
args=['--docker-export'],
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)
# Save Deployment Button
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)
btn_window = self.bg_canvas.create_window(240, 760, window=self._save_deployment_button, anchor='nw', width=180)
self._canvas_items.append(btn_window)
self._overlay_widgets.append(self._save_deployment_button)
# Status Labels
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)
self._save_deployment_status_label = ui.canvas_text(self, 240, 802, "", fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(self._save_deployment_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('<Button-1>', _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('<Button-1>', _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('<Button-1>', _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."""
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 svc/prometheus-community-kube-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 svc/prometheus-community-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.
"""
def http_ping_registry(host: str, port: int) -> bool:
try:
import http.client
conn = http.client.HTTPConnection(host, port, timeout=3)
conn.request('GET', '/v2/')
resp = conn.getresponse()
# Docker registry typically returns 200 or 401 for /v2/
return resp.status in (200, 401)
except Exception:
return False
# Prefer the shared registry if reachable
if http_ping_registry('k8s.prole.org', 5000):
return 'k8s.prole.org:5000'
# Otherwise, ensure a local registry (localhost:5000) exists/started
# 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 k3d registry is running and return (host_registry, cluster_registry)."""
reg_name = 'prole-registry'
registry_container = f'k3d-{reg_name}'
host_registry = 'localhost:5000'
cluster_registry = f'{registry_container}.localhost:5000'
if not self.check_docker_running():
return None
# Require k3d to manage the local registry
k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0
if not k3d_exists:
return None
# Ensure registry exists
lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True)
if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''):
subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True)
# Ensure registry container is running
running = subprocess.run(
['docker', 'ps', '--filter', f'name={registry_container}', '--format', '{{.Names}}'],
capture_output=True, text=True
)
if not (running.stdout or '').strip():
exists = subprocess.run(
['docker', 'ps', '-a', '--filter', f'name={registry_container}', '--format', '{{.Names}}'],
capture_output=True, text=True
)
if (exists.stdout or '').strip():
subprocess.run(['docker', 'start', registry_container], check=True)
# Persist for later steps
self.local_registry_url = host_registry
self.local_registry_internal = cluster_registry
try:
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
return host_registry, cluster_registry
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):
"""Create or restart local k3d cluster named prole-dev-cluster and wire it to the chosen registry."""
cluster_name = 'prole-dev-cluster'
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"""
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"""
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('<Button-1>', 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 _configure_unbuffered_io():
os.environ.setdefault('PYTHONUNBUFFERED', '1')
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(line_buffering=True, write_through=True)
except Exception:
try:
stream.flush()
except Exception:
pass
class ProleSilentInstaller:
"""Console-based unattended installer driven by prole.cfg inputs."""
def __init__(self, controller: ProleController, cfg_path: str | None = None):
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.inputs = {}
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._db_built_success = False
self._scripts_success = False
self._cnpg_success = False
self._managed_kubeconfig = None
self.docker_import_dir = None
# ---------------- Logging helpers ----------------
def log(self, msg: str):
print(msg, flush=True)
def err(self, msg: str):
print(msg, file=sys.stderr, flush=True)
# ---------------- Config helpers ----------------
def _normalize_cfg_path(self, raw: str | None) -> Path:
if raw:
p = Path(_expand_path(raw))
if p.is_dir():
return p / 'prole.cfg'
return p
prole_conf = os.environ.get('PROLE_CONF')
if prole_conf:
return Path(_expand_path(prole_conf)) / 'prole.cfg'
return self.project_root / 'prole' / 'conf' / 'prole.cfg'
def _load_inputs_from_cfg(self) -> dict:
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
if not self.cfg_path.exists():
raise FileNotFoundError(f"prole.cfg not found at {self.cfg_path}")
cfg.read(self.cfg_path)
cfg_vars = _collect_cfg_vars(cfg)
# Load all sections into prole_cfg_data to maintain idempotency
for section in cfg.sections():
if section not in self.prole_cfg_data:
self.prole_cfg_data[section] = {}
for k, v in cfg.items(section):
self.prole_cfg_data[section][k] = _expand_cfg_value(v, cfg_vars)
if cfg.has_section('Global'):
service_ns = _expand_cfg_value(cfg.get('Global', 'SERVICE_NAMESPACE', fallback=''), cfg_vars).strip()
if service_ns:
self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = service_ns
if cfg.has_section('Port Forwards'):
self.prole_cfg_data['Port Forwards'] = {
k: _expand_cfg_value(v, cfg_vars) for k, v in cfg.items('Port Forwards')
}
for (section, key), _spec in SECRET_KEY_SPECS.items():
if cfg.has_option(section, key):
val = _expand_cfg_value(cfg.get(section, key, fallback="").strip(), cfg_vars)
if val:
self._cfg_secret_cache[(section, key)] = val
if _is_openbao_ref(val):
self._secrets_finalized = True
self._auto_input_keys = set()
auto_kdc = None
auto_enabled = None
if cfg.has_section('Network'):
sec = cfg['Network']
if 'KDC_AUTO_DETECTED' in sec:
auto_kdc = sec.get('KDC_AUTO_DETECTED', '')
if 'KERBEROS_AUTO_ENABLED' in sec:
auto_enabled = sec.get('KERBEROS_AUTO_ENABLED', '')
def mark_auto(inputs: dict):
if auto_kdc:
if inputs.get('kerberos_config.kdc', '').strip() == str(auto_kdc).strip():
self._auto_input_keys.add('kerberos_config.kdc')
if auto_enabled is not None:
input_enabled = inputs.get('kerberos_config.enabled')
input_bool = _parse_bool(input_enabled, None)
auto_bool = _parse_bool(auto_enabled, None)
if input_bool is not None and auto_bool is not None and input_bool == auto_bool:
self._auto_input_keys.add('kerberos_config.enabled')
inputs: dict[str, str] = {}
if cfg.has_section('Inputs'):
inputs.update({k: _expand_cfg_value(v, cfg_vars) for k, v in cfg.items('Inputs')})
mark_auto(inputs)
# Resolve encrypted/anchored secrets for runtime use
for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password', 'init_cluster.k3s_token'):
if key in inputs and inputs[key]:
inputs[key] = self._resolve_secret_value(inputs[key])
if inputs.get('init_password.db_password') and not inputs.get('init_password.db_password_confirm'):
inputs['init_password.db_password_confirm'] = inputs['init_password.db_password']
# Fill Ollama inputs from config sections if missing
if cfg.has_section('Ollama'):
sec = cfg['Ollama']
if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_SERVER_HOST', '').strip():
inputs['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars)
if 'ollama_config.server_port' not in inputs and sec.get('OLLAMA_SERVER_PORT', '').strip():
inputs['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars)
if 'ollama_config.model' not in inputs and sec.get('OLLAMA_MODEL', '').strip():
inputs['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars)
if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_HOST', '').strip():
host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars))
if host:
inputs['ollama_config.server_host'] = host
if port and 'ollama_config.server_port' not in inputs:
inputs['ollama_config.server_port'] = port
if cfg.has_section('Global'):
sec = cfg['Global']
if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_SERVER_HOST', '').strip():
inputs['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars)
if 'ollama_config.server_port' not in inputs and sec.get('OLLAMA_SERVER_PORT', '').strip():
inputs['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars)
if 'ollama_config.model' not in inputs and sec.get('OLLAMA_MODEL', '').strip():
inputs['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars)
if 'ollama_config.server_host' not in inputs and sec.get('OLLAMA_HOST', '').strip():
host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars))
if host:
inputs['ollama_config.server_host'] = host
if port and 'ollama_config.server_port' not in inputs:
inputs['ollama_config.server_port'] = port
return inputs
# Legacy fallback mapping
legacy = {}
if cfg.has_section('System Environment'):
sec = cfg['System Environment']
for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
if k in sec:
legacy[f'env_setup.{k}'] = _expand_cfg_value(sec.get(k, ''), cfg_vars)
if cfg.has_section('Global'):
sec = cfg['Global']
if 'PROLE_HOME' in sec:
legacy['env_setup.PROLE_HOME'] = _expand_cfg_value(sec.get('PROLE_HOME', ''), cfg_vars)
if 'CLUSTER_ENV' in sec:
legacy['init_cluster.cluster_env'] = _expand_cfg_value(sec.get('CLUSTER_ENV', ''), cfg_vars)
if 'PROLE_K3S_SERVER' in sec:
legacy['init_cluster.k3s_server_url'] = _expand_cfg_value(sec.get('PROLE_K3S_SERVER', ''), cfg_vars)
if 'K3S_SERVER_URL' in sec and 'init_cluster.k3s_server_url' not in legacy:
legacy['init_cluster.k3s_server_url'] = _expand_cfg_value(sec.get('K3S_SERVER_URL', ''), cfg_vars)
if 'PROLE_K3S_TOKEN' in sec:
legacy['init_cluster.k3s_token'] = _expand_cfg_value(sec.get('PROLE_K3S_TOKEN', ''), cfg_vars)
if 'K3S_TOKEN' in sec and 'init_cluster.k3s_token' not in legacy:
legacy['init_cluster.k3s_token'] = _expand_cfg_value(sec.get('K3S_TOKEN', ''), cfg_vars)
if 'NAMESPACE' in sec:
legacy['init_password.db_namespace'] = _expand_cfg_value(sec.get('NAMESPACE', ''), cfg_vars)
legacy['env_setup.NAMESPACE'] = _expand_cfg_value(sec.get('NAMESPACE', ''), cfg_vars)
if 'PROLE_DB_USER' in sec:
legacy['init_password.db_username'] = _expand_cfg_value(sec.get('PROLE_DB_USER', ''), cfg_vars)
if 'DB_PASSWORD' in sec:
legacy['init_password.db_password'] = _expand_cfg_value(sec.get('DB_PASSWORD', ''), cfg_vars)
legacy['init_password.db_password_confirm'] = _expand_cfg_value(sec.get('DB_PASSWORD', ''), cfg_vars)
if 'DB_HOST_PORT' in sec:
legacy['init_password.db_host_port'] = _expand_cfg_value(sec.get('DB_HOST_PORT', '5432'), cfg_vars)
if 'DOCKER_IMPORT_DIR' in sec:
self.docker_import_dir = _expand_cfg_value(sec.get('DOCKER_IMPORT_DIR', ''), cfg_vars)
self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = self.docker_import_dir
if 'OLLAMA_SERVER_HOST' in sec:
legacy['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars)
if 'OLLAMA_SERVER_PORT' in sec:
legacy['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars)
if 'OLLAMA_MODEL' in sec:
legacy['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars)
if 'OLLAMA_HOST' in sec and 'ollama_config.server_host' not in legacy:
host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars))
if host:
legacy['ollama_config.server_host'] = host
if port:
legacy.setdefault('ollama_config.server_port', port)
if cfg.has_section('Network'):
sec = cfg['Network']
if 'KDC_AUTO_DETECTED' in sec:
legacy['kerberos_config.kdc'] = _expand_cfg_value(sec.get('KDC_AUTO_DETECTED', ''), cfg_vars)
if 'KERBEROS_AUTO_ENABLED' in sec:
legacy['kerberos_config.enabled'] = _expand_cfg_value(sec.get('KERBEROS_AUTO_ENABLED', ''), cfg_vars)
if cfg.has_section('Kerberos Authentication'):
sec = cfg['Kerberos Authentication']
for k, tgt in (
('ENABLED', 'kerberos_config.enabled'),
('REALM', 'kerberos_config.realm'),
('KDC', 'kerberos_config.kdc'),
('USER', 'kerberos_config.user'),
('PASSWORD', 'kerberos_config.password'),
):
if k in sec:
legacy[tgt] = _expand_cfg_value(sec.get(k, ''), cfg_vars)
if cfg.has_section('Ollama'):
sec = cfg['Ollama']
if 'OLLAMA_SERVER_HOST' in sec:
legacy['ollama_config.server_host'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', ''), cfg_vars)
if 'OLLAMA_SERVER_PORT' in sec:
legacy['ollama_config.server_port'] = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', ''), cfg_vars)
if 'OLLAMA_MODEL' in sec:
legacy['ollama_config.model'] = _expand_cfg_value(sec.get('OLLAMA_MODEL', ''), cfg_vars)
if 'OLLAMA_HOST' in sec and 'ollama_config.server_host' not in legacy:
host, port = _parse_ollama_host(_expand_cfg_value(sec.get('OLLAMA_HOST', ''), cfg_vars))
if host:
legacy['ollama_config.server_host'] = host
if port:
legacy.setdefault('ollama_config.server_port', port)
if cfg.has_section('Optional Features'):
sec = cfg['Optional Features']
if 'SUPABASE_ENABLED' in sec:
legacy['init_cluster.supabase_enabled'] = sec.get('SUPABASE_ENABLED', '')
if 'KERBEROS_ENABLED' in sec:
legacy['init_cluster.kerberos_enabled'] = sec.get('KERBEROS_ENABLED', '')
legacy['kerberos_config.enabled'] = sec.get('KERBEROS_ENABLED', '')
if 'AT_REST_ENCRYPTION_ENABLED' in sec:
legacy['init_cluster.at_rest_encryption_enabled'] = sec.get('AT_REST_ENCRYPTION_ENABLED', '')
if cfg.has_section('Initialize Cluster'):
sec = cfg['Initialize Cluster']
if 'ENVIRONMENT' in sec:
legacy['init_cluster.cluster_env'] = sec.get('ENVIRONMENT', '')
if 'K3S_SERVER_URL' in sec and 'init_cluster.k3s_server_url' not in legacy:
legacy['init_cluster.k3s_server_url'] = sec.get('K3S_SERVER_URL', '')
if 'K3S_TOKEN' in sec and 'init_cluster.k3s_token' not in legacy:
legacy['init_cluster.k3s_token'] = sec.get('K3S_TOKEN', '')
if cfg.has_section('Database Creation'):
sec = cfg['Database Creation']
if 'DB_NAME' in sec:
legacy['init_password.db_namespace'] = sec.get('DB_NAME', '')
legacy['env_setup.NAMESPACE'] = sec.get('DB_NAME', '')
if 'NAMESPACE' in sec:
legacy['init_password.db_namespace'] = sec.get('NAMESPACE', '')
legacy['env_setup.NAMESPACE'] = sec.get('NAMESPACE', '')
if 'DB_USER' in sec:
legacy['init_password.db_username'] = sec.get('DB_USER', '')
mark_auto(legacy)
for key in ('init_password.db_password', 'init_password.db_password_confirm', 'kerberos_config.password', 'init_cluster.k3s_token'):
if key in legacy and legacy[key]:
legacy[key] = self._resolve_secret_value(legacy[key])
if legacy.get('init_password.db_password') and not legacy.get('init_password.db_password_confirm'):
legacy['init_password.db_password_confirm'] = legacy['init_password.db_password']
return legacy
def _default_inputs(self) -> dict:
namespace = self._initial_namespace()
owner = self._get_local_owner()
env_vals = self._env_defaults(namespace)
inputs: dict[str, str] = {}
# Dependencies
inputs['dependencies.verify_all'] = _bool_str(False)
inputs['dependencies.auto_install_missing'] = _bool_str(DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True))
for dep in self.dependencies:
inputs[f"dependencies.{dep['id']}.install"] = _bool_str(True)
# Network scan
inputs['network_scan.run'] = _bool_str(DEFAULT_ACTION_FLAGS.get('network_scan.run', True))
# Environment setup
for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
inputs[f'env_setup.{k}'] = env_vals.get(k, '')
inputs['env_setup.NAMESPACE'] = namespace
# Database creation
inputs['init_password.db_namespace'] = namespace
inputs['init_password.db_username'] = owner
inputs['init_password.db_password'] = ''
inputs['init_password.db_password_confirm'] = ''
inputs['init_password.generate_ssh_key'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True))
# Build DB image
inputs['init_db_build.run_build'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True))
# Cluster init + optional features
inputs['init_cluster.cluster_env'] = 'dev'
inputs['init_cluster.k3s_server_url'] = ''
inputs['init_cluster.k3s_token'] = ''
inputs['init_cluster.supabase_enabled'] = _bool_str(False)
inputs['init_cluster.kerberos_enabled'] = _bool_str(False)
inputs['init_cluster.at_rest_encryption_enabled'] = _bool_str(True)
inputs['init_cluster.start_cluster'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_cluster.start_cluster', True))
# Kerberos config
inputs['kerberos_config.enabled'] = _bool_str(False)
inputs['kerberos_config.realm'] = ''
inputs['kerberos_config.kdc'] = ''
inputs['kerberos_config.user'] = ''
inputs['kerberos_config.password'] = ''
inputs['kerberos_config.test_connection'] = _bool_str(DEFAULT_ACTION_FLAGS.get('kerberos_config.test_connection', False))
# Ollama config
inputs['ollama_config.server_host'] = ''
inputs['ollama_config.server_port'] = DEFAULT_OLLAMA_PORT
inputs['ollama_config.model'] = ''
# Init scripts + deploy
inputs['init_scripts.run_scripts'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_scripts.run_scripts', True))
inputs['init_cnpg_deploy.run_deploy'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True))
inputs['init_cnpg_deploy.force_rollout'] = _bool_str(DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.force_rollout', False))
# Disk selection (installer packaging)
inputs['disk_selection.disk_type'] = 'local'
inputs['disk_selection.removable_mount'] = ''
inputs['disk_selection.local_path'] = str(Path.home())
# Build tools app
inputs['build.deploy_env'] = 'Dev'
inputs['build.run_build'] = _bool_str(DEFAULT_ACTION_FLAGS.get('build.run_build', False))
return inputs
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") 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 _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()
env["SERVICE_NAMESPACE"] = service_ns
mode = self._deployment_mode()
if mode:
env["PROLE_MODE"] = mode
db_pw = self._get_input('init_password.db_password', '').strip()
if db_pw:
env["DB_PASSWORD"] = db_pw
env["OPENTOFU_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', '')
env["KRB5_PASSWORD"] = self._get_input('kerberos_config.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
# ---------------- 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
# ---------------- 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 _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):
home = Path(values['PROLE_HOME']).expanduser()
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" <command> [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'
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 = home / '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
if isinstance(cmd, str):
proc = subprocess.Popen(['bash', '-lc', cmd], cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=stdin_handle, text=True, bufsize=1)
else:
proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=stdin_handle, text=True, bufsize=1)
if stdin_text and proc.stdin:
try:
proc.stdin.write(stdin_text)
proc.stdin.close()
except Exception:
pass
def _read_stream(stream, handler, is_err=False):
if not stream:
return
for line in iter(stream.readline, ''):
if handler:
handler(line)
else:
if is_err:
self.err(line.rstrip('\n'))
else:
self.log(line.rstrip('\n'))
err_thread = threading.Thread(target=_read_stream, args=(proc.stderr, on_stderr, True), daemon=True)
err_thread.start()
_read_stream(proc.stdout, on_stdout, False)
rc = proc.wait()
err_thread.join(timeout=2)
return rc
def _run_script(self, script_name: str, args=None, env=None, stdin_text=None, on_line=None) -> int:
def _stdout(line):
self.log(line.rstrip('\n'))
if on_line:
on_line(line)
def _stderr(line):
self.err(line.rstrip('\n'))
mode_args = []
if script_name.startswith("init_"):
mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', ''))
if mode:
mode_args = ["--mode", mode]
return self.controller.run_script(
script_name,
args=mode_args + (args or []),
env=env,
stdin_text=stdin_text,
on_line=_stdout,
on_stderr_line=_stderr,
stderr_to_stdout=False,
)
def _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 = {}
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'
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,
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)."""
reg_name = 'prole-registry'
registry_container = f'k3d-{reg_name}'
host_registry = 'localhost:5000'
cluster_registry = f'{registry_container}.localhost:5000'
if not self.controller.check_docker_running():
self.err("[WARN] Docker not running; cannot ensure local registry.")
return None
k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0
if not k3d_exists:
self.err("[WARN] k3d not found; cannot ensure local registry.")
return None
lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True)
if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''):
self.log("[INFO] Creating local k3d registry...")
subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True)
running = subprocess.run(
['docker', 'ps', '--filter', f'name={registry_container}', '--format', '{{.Names}}'],
capture_output=True, text=True
)
if not (running.stdout or '').strip():
exists = subprocess.run(
['docker', 'ps', '-a', '--filter', f'name={registry_container}', '--format', '{{.Names}}'],
capture_output=True, text=True
)
if (exists.stdout or '').strip():
subprocess.run(['docker', 'start', registry_container], check=True)
self.local_registry_url = host_registry
self.local_registry_internal = cluster_registry
self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY'] = host_registry
self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY_INTERNAL'] = cluster_registry
return host_registry, cluster_registry
def _collect_dependent_images(self, include_supabase: bool, include_kerberos_proxy: bool) -> list[str]:
images = set()
for rel_dir in ('k8s/prole', 'k8s/openbao'):
base_dir = self.project_root / rel_dir
if not base_dir.exists():
continue
images.update(_collect_images_from_files(list(base_dir.glob('*.yaml'))))
if include_supabase:
supa_home = _resolve_supabase_home(self.project_root)
if supa_home:
docker_dir = supa_home / 'docker'
compose_files = [docker_dir / 'docker-compose.yml']
if os.environ.get('SUPABASE_USE_DEV_COMPOSE') == '1':
compose_files.append(docker_dir / 'dev' / 'docker-compose.dev.yml')
images.update(_collect_images_from_files(compose_files))
if not include_supabase:
images = {img for img in images if 'supabase' not in img}
if include_kerberos_proxy:
krb_img = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat')
if krb_img:
images.add(krb_img)
return sorted(images)
def _prepull_images_to_registry(self, include_supabase: bool, include_kerberos_proxy: bool) -> bool:
info = self._ensure_local_registry_available()
if not info:
self.err("[WARN] Local registry unavailable; skipping image pre-pull.")
return False
registry, _cluster_registry = info
images = self._collect_dependent_images(include_supabase, include_kerberos_proxy)
if not images:
self.log("[INFO] No dependent images found to pre-pull.")
return True
overall_ok = True
import_dir = self.docker_import_dir
for image in images:
local_tag = image
if not image.startswith(f"{registry}/"):
local_tag = f"{registry}/{image}"
# 1. Check if already in local registry
self.log(f"[INFO] Checking if {image} exists in local registry...")
check_reg = subprocess.run(['docker', 'pull', local_tag], capture_output=True, text=True)
if check_reg.returncode == 0:
self.log(f"[OK] {image} already exists in local registry as {local_tag}")
continue
# 2. Check if we already have it in local docker daemon
check_local = subprocess.run(['docker', 'image', 'inspect', image], capture_output=True, text=True)
found = (check_local.returncode == 0)
if not found and import_dir and os.path.isdir(import_dir):
# 3. Check import directory
safe_name = image.replace("/", "_").replace(":", "_")
tar_path = Path(import_dir) / f"{safe_name}.tar"
if tar_path.exists():
self.log(f"[INFO] Found {tar_path} in import directory, loading...")
load = subprocess.run(['docker', 'load', '-i', str(tar_path)], capture_output=True, text=True)
if load.returncode == 0:
found = True
else:
self.err(f"[WARN] Failed to load {tar_path}: {load.stderr}")
if not found:
# 4. Pull from Docker Hub
self.log(f"[INFO] Pulling {image} from Docker Hub...")
pull = subprocess.run(['docker', 'pull', image], capture_output=True, text=True)
if pull.returncode != 0:
overall_ok = False
self.err(pull.stdout or '')
self.err(pull.stderr or '')
self.err(f"[ERROR] docker pull failed for {image}")
continue
found = True
# If we have the image locally, tag and push to local registry
if found:
if local_tag != image:
tag = subprocess.run(['docker', 'tag', image, local_tag], capture_output=True, text=True)
if tag.returncode != 0:
overall_ok = False
self.err(tag.stdout or '')
self.err(tag.stderr or '')
self.err(f"[ERROR] docker tag failed for {image} to {local_tag}")
continue
self.log(f"[INFO] Pushing {local_tag} to local registry...")
if not _push_docker_image(local_tag, log_fn=self.log):
overall_ok = False
continue
self.log(f"[OK] Stored {image} in local registry as {local_tag}")
return overall_ok
# ---------------- Steps ----------------
def _apply_inputs(self):
defaults = self._default_inputs()
loaded = self._load_inputs_from_cfg()
self.inputs = {**defaults, **loaded}
try:
self._apply_ansible_defaults(set(loaded.keys()))
except Exception:
pass
for k in ('env_setup.PROLE_HOME', 'env_setup.PROLE_CONF', 'env_setup.PROLE_DATA', 'env_setup.PROLE_LOGS', 'env_setup.PROLE_SERVICE'):
if k in self.inputs:
self.inputs[k] = _expand_path(self.inputs[k])
def _apply_ansible_defaults(self, loaded_keys: set[str] | None = None):
info = _detect_ansible_topology(self.project_root)
if not info:
return
loaded_keys = loaded_keys or set()
auto_keys = getattr(self, '_auto_input_keys', set())
ansible_kdc = info.get('kdc_ip') or ''
try:
net = self.prole_cfg_data.get('Network', {})
if info.get('topology_json'):
net['ANSIBLE_TOPOLOGY'] = info['topology_json']
if info.get('inventory_path'):
net['ANSIBLE_INVENTORY'] = info['inventory_path']
if info.get('infrastructure_path'):
net['ANSIBLE_INFRASTRUCTURE'] = info['infrastructure_path']
if info.get('domain'):
net['ANSIBLE_DOMAIN'] = info['domain']
if info.get('realm'):
net['ANSIBLE_REALM'] = info['realm']
if info.get('ad_dc_host'):
net['AD_DC_HOST'] = info['ad_dc_host']
if info.get('ad_dc_ip'):
net['AD_DC_IP'] = info['ad_dc_ip']
if info.get('kdc_ip'):
net['KDC_ANSIBLE_DETECTED'] = info['kdc_ip']
self.prole_cfg_data['Network'] = net
except Exception:
pass
if ansible_kdc and 'kerberos_config.kdc' in auto_keys:
self.inputs['kerberos_config.kdc'] = ansible_kdc
if ansible_kdc and 'kerberos_config.enabled' in auto_keys:
self.inputs['kerberos_config.enabled'] = _bool_str(True)
if 'kerberos_config.kdc' not in loaded_keys:
if not self._get_input('kerberos_config.kdc', '').strip() and info.get('kdc_ip'):
self.inputs['kerberos_config.kdc'] = info['kdc_ip']
if 'kerberos_config.realm' not in loaded_keys:
if not self._get_input('kerberos_config.realm', '').strip() and info.get('realm'):
self.inputs['kerberos_config.realm'] = info['realm']
if 'kerberos_config.enabled' not in loaded_keys:
if info.get('kdc_ip') and not self._get_input_bool('kerberos_config.enabled', False):
self.inputs['kerberos_config.enabled'] = _bool_str(True)
if 'init_cluster.k3s_server_url' not in loaded_keys:
if not self._get_input('init_cluster.k3s_server_url', '').strip() and info.get('k3s_server_url'):
self.inputs['init_cluster.k3s_server_url'] = info['k3s_server_url']
if 'init_cluster.k3s_token' not in loaded_keys:
if not self._get_input('init_cluster.k3s_token', '').strip() and info.get('k3s_token'):
self.inputs['init_cluster.k3s_token'] = info['k3s_token']
def _write_cfg(self):
inputs = dict(self.inputs)
db_pw = self._get_input('init_password.db_password', '')
db_pw_cfg = self._secret_cfg_value('Inputs', 'init_password.db_password', db_pw, 'db', 'password')
if db_pw_cfg:
inputs['init_password.db_password'] = db_pw_cfg
inputs['init_password.db_password_confirm'] = db_pw_cfg
elif db_pw:
inputs['init_password.db_password'] = db_pw
inputs['init_password.db_password_confirm'] = self._get_input('init_password.db_password_confirm', '') or db_pw
krb_pw = self._get_input('kerberos_config.password', '')
if krb_pw:
inputs['kerberos_config.password'] = self._secret_cfg_value('Inputs', 'kerberos_config.password', krb_pw, 'kerberos', 'password')
k3s_token = self._get_input('init_cluster.k3s_token', '')
if k3s_token:
inputs['init_cluster.k3s_token'] = self._secret_cfg_value('Inputs', 'init_cluster.k3s_token', k3s_token, 'k3s', 'token')
mode = _deployment_mode_from_env(self._get_input('init_cluster.cluster_env', ''))
target_label = _deployment_target_label(self._get_input('init_cluster.cluster_env', ''))
globals_to_save = {
'PROLE_HOME': self._get_input('env_setup.PROLE_HOME', ''),
'PROLE_DB_USER': self._get_input('init_password.db_username', ''),
'DB_PASSWORD': self._secret_cfg_value('Global', 'DB_PASSWORD', db_pw, 'db', 'password'),
'CLUSTER_ENV': self._get_input('init_cluster.cluster_env', ''),
'DEPLOYMENT_MODE': mode,
'DEPLOYMENT_TARGET': target_label,
'NAMESPACE': (self._get_input('init_password.db_namespace', '') or '').strip(),
'DB_HOST_PORT': (self._get_input('init_password.db_host_port', '5432') or '5432').strip(),
'DOCKER_IMPORT_DIR': self.docker_import_dir or '',
'PROLE_K3S_SERVER': (self._get_input('init_cluster.k3s_server_url', '') or '').strip(),
'PROLE_K3S_TOKEN': self._secret_cfg_value('Global', 'PROLE_K3S_TOKEN', self._get_input('init_cluster.k3s_token', ''), 'k3s', 'token'),
'PROLE_OPENTOFU_URL': _default_opentofu_pipeline_url(),
'SERVICE_NAMESPACE': self._service_namespace(),
}
globals_to_save.update(self.prole_cfg_data.get('Global', {}))
globals_to_save['DB_PASSWORD'] = self._secret_cfg_value('Global', 'DB_PASSWORD', db_pw, 'db', 'password')
globals_to_save['DEPLOYMENT_MODE'] = mode
globals_to_save['DEPLOYMENT_TARGET'] = target_label
ollama_host = (self._get_input('ollama_config.server_host', '') or '').strip()
ollama_port = (self._get_input('ollama_config.server_port', DEFAULT_OLLAMA_PORT) or '').strip()
ollama_model = (self._get_input('ollama_config.model', '') or '').strip()
ollama_section = dict(self.prole_cfg_data.get('Ollama', {}))
if ollama_host:
if not ollama_port:
ollama_port = DEFAULT_OLLAMA_PORT
ollama_section['OLLAMA_SERVER_HOST'] = ollama_host
ollama_section['OLLAMA_SERVER_PORT'] = ollama_port
ollama_section['OLLAMA_HOST'] = _format_ollama_host(ollama_host, ollama_port)
else:
for key in ('OLLAMA_SERVER_HOST', 'OLLAMA_SERVER_PORT', 'OLLAMA_HOST'):
ollama_section.pop(key, None)
if ollama_model:
ollama_section['OLLAMA_MODEL'] = ollama_model
else:
ollama_section.pop('OLLAMA_MODEL', None)
if ollama_section:
self.prole_cfg_data['Ollama'] = ollama_section
self._sync_port_forward_mappings()
sections = {k: self.prole_cfg_data.get(k, {}) for k in [
'Welcome', 'Dependencies', 'Network', 'Port Forwards', 'System Environment',
'Monitoring', 'Kerberos Authentication', 'Ollama', 'Optional Features', 'Database Creation',
'Initialize Cluster', 'Docker Build', 'Initialization Scripts',
'Deployment', 'Install'
]}
deployment_section = dict(sections.get('Deployment', {}))
if mode:
deployment_section.setdefault('MODE', mode)
if target_label:
deployment_section.setdefault('TARGET', target_label)
sections['Deployment'] = deployment_section
sections['Dev Cluster (k3d)'] = {
**self.prole_cfg_data.get('Dev Cluster (k3d)', {}),
'MODE': 'k3d',
'CLUSTER_ENV': 'k3d-prole-dev-cluster',
'DISPLAY_NAME': 'prole-dev-cluster',
'KUBECTL_CONTEXT': self._get_input('init_cluster.cluster_env', '')
}
sections['Service Cluster (k3s)'] = {
**self.prole_cfg_data.get('Service Cluster (k3s)', {}),
'MODE': 'k3s',
'CLUSTER_ENV': 'prole-service-cluster',
'DISPLAY_NAME': 'prole-service-cluster',
'K3S_SERVER_URL': (self._get_input('init_cluster.k3s_server_url', '') or '').strip(),
'K3S_TOKEN': self._secret_cfg_value('Service Cluster (k3s)', 'K3S_TOKEN', self._get_input('init_cluster.k3s_token', ''), 'k3s', 'token'),
'PIPELINE_URL': _default_opentofu_pipeline_url()
}
sections['Prod Cluster (k8s)'] = {
**self.prole_cfg_data.get('Prod Cluster (k8s)', {}),
'MODE': 'k8s',
'CLUSTER_ENV': 'prole-prod-cluster',
'DISPLAY_NAME': 'prole-prod-cluster',
'ARTIFACTS_DIR': (self._get_input('init_cluster.prod_artifacts_path', '') or '').strip(),
'PIPELINE_URL': _default_opentofu_pipeline_url()
}
sections = self._sanitize_sections_for_cfg(sections)
# Check for existing config and timestamp to maintain idempotency
existing_text = None
existing_timestamp = None
if self.cfg_path.exists():
existing_text = self.cfg_path.read_text()
for line in existing_text.splitlines():
if line.startswith('; Generated by install.py on '):
existing_timestamp = line[len('; Generated by install.py on '):].strip()
break
cfg_text = _render_prole_cfg(inputs, globals_to_save, sections, generated_at=existing_timestamp)
should_write = True
if existing_text:
if existing_text == cfg_text:
should_write = False
if should_write:
# If content changed but we reused existing_timestamp, it might be misleading.
# But usually we want to know when it LAST changed.
# If it's different, let's generate a new one.
if existing_text and existing_text != cfg_text:
cfg_text = _render_prole_cfg(inputs, globals_to_save, sections)
self.cfg_path.parent.mkdir(parents=True, exist_ok=True)
self.cfg_path.write_text(cfg_text)
self.log(f"[CONFIG] Wrote {self.cfg_path}")
def _step_dependencies(self) -> bool:
self.log("==> Dependencies")
missing = []
for dep in self.dependencies:
ok, location, version = inst_config.get_dep_info(dep)
if ok:
self.log(f"[OK] {dep['name']} {version or ''}".strip())
continue
missing.append(dep)
self.log(f"[MISSING] {dep['name']}")
if not missing:
self.prole_cfg_data['Dependencies']['STATUS'] = 'All installed'
return True
auto_install = self._get_input_bool('dependencies.auto_install_missing', DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True))
if not auto_install:
self.err("[ERROR] Dependencies missing and auto-install disabled.")
self.prole_cfg_data['Dependencies']['STATUS'] = 'Missing'
return False
for dep in missing:
install_cmd = dep.get('install_cmd')
if not install_cmd:
self.err(f"[ERROR] No install command for {dep['name']}.")
continue
should_install = self._get_input_bool(f"dependencies.{dep['id']}.install", True)
if not should_install:
self.err(f"[SKIP] {dep['name']} install disabled by config.")
continue
self.log(f"[INSTALL] {dep['name']} -> {install_cmd}")
rc = self._run_cmd(install_cmd)
if rc != 0:
self.err(f"[ERROR] Install failed for {dep['name']} (code {rc})")
still_missing = []
for dep in missing:
ok, _, _ = inst_config.get_dep_info(dep)
if not ok:
still_missing.append(dep['name'])
if still_missing:
self.err(f"[ERROR] Still missing: {', '.join(still_missing)}")
self.prole_cfg_data['Dependencies']['STATUS'] = 'Missing'
return False
self.prole_cfg_data['Dependencies']['STATUS'] = 'All installed'
return True
def _step_network_scan(self) -> None:
if not self._get_input_bool('network_scan.run', DEFAULT_ACTION_FLAGS.get('network_scan.run', True)):
self.log("[SKIP] Network scan disabled.")
return
existing_kdc = self.prole_cfg_data.get('Network', {}).get('KDC_AUTO_DETECTED')
if existing_kdc:
self.log(f"[OK] Network scan already performed. KDC detected: {existing_kdc}")
return
self.log("==> Network scan (prole-agent)")
ansible_kdc = ''
try:
ansible_kdc = (self.prole_cfg_data.get('Network', {}) or {}).get('KDC_ANSIBLE_DETECTED', '')
except Exception:
ansible_kdc = ''
scan_binary = get_resource_path("prole-net/prole-agent")
if not scan_binary.exists():
self.err(f"[ERROR] Scan binary not found at {scan_binary}")
return
prole_home = Path.home() / ".prole"
scan_dir = prole_home / "scan"
scan_dir.mkdir(parents=True, exist_ok=True)
kdc_found = None
def _handle_stdout(line: str):
nonlocal kdc_found
self.log(line.rstrip('\n'))
if "KDC is:" in line:
try:
ip_part = line.split("KDC is:")[1].strip()
ip = ip_part.split()[0].strip('[]():,')
if ip:
kdc_found = ip
except Exception:
pass
elif "Active Directory" in line or "88" in line:
for part in line.split():
try:
socket.inet_aton(part.strip('[]():,'))
kdc_found = part.strip('[]():,')
break
except Exception:
continue
rc = self._run_cmd([str(scan_binary), "-t", "10"], cwd=str(scan_dir), on_stdout=_handle_stdout)
if rc == 0:
self.log("[OK] Scan complete.")
else:
self.err(f"[ERROR] Scan failed (code {rc})")
if kdc_found and not ansible_kdc:
self.inputs['kerberos_config.kdc'] = kdc_found
self.inputs['kerberos_config.enabled'] = _bool_str(True)
self.prole_cfg_data['Network']['KDC_AUTO_DETECTED'] = kdc_found
self.prole_cfg_data['Network']['KERBEROS_AUTO_ENABLED'] = 'True'
elif kdc_found and ansible_kdc:
self.log(f"[INFO] Ansible KDC detected ({ansible_kdc}); ignoring scan-detected KDC {kdc_found}.")
def _step_env_setup(self) -> None:
self.log("==> Environment setup")
vals = {
'PROLE_HOME': self._get_input('env_setup.PROLE_HOME', str(Path.home() / '.prole')),
'PROLE_CONF': self._get_input('env_setup.PROLE_CONF', ''),
'PROLE_DATA': self._get_input('env_setup.PROLE_DATA', ''),
'PROLE_LOGS': self._get_input('env_setup.PROLE_LOGS', ''),
'PROLE_SERVICE': self._get_input('env_setup.PROLE_SERVICE', ''),
'PROLE_OPENTOFU_URL': (os.environ.get('PROLE_OPENTOFU_URL') or '').strip(),
}
defaults = self._env_defaults(self._get_input('env_setup.NAMESPACE', ''))
for k in vals:
if not vals[k]:
vals[k] = defaults.get(k, '')
if not vals.get('PROLE_OPENTOFU_URL'):
vals['PROLE_OPENTOFU_URL'] = _default_opentofu_pipeline_url()
vals['NAMESPACE'] = self._get_input('env_setup.NAMESPACE', '')
if not vals.get('PROLE_HOME'):
raise Exception("PROLE_HOME is required for env setup.")
# Persist normalized inputs
for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
self.inputs[f'env_setup.{k}'] = vals[k]
self.inputs['env_setup.NAMESPACE'] = vals.get('NAMESPACE', '')
self._save_env_to_file(vals)
self.reload_env_from_shell()
for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE', 'PROLE_OPENTOFU_URL'):
self.prole_cfg_data['System Environment'][k] = vals[k]
def _step_init_password(self) -> None:
self.log("==> Database creation")
ns = (self._get_input('init_password.db_namespace', '') or '').strip()
if not ns:
ns = self._get_input('env_setup.NAMESPACE', '')
if not ns:
raise Exception("Database namespace cannot be empty.")
if not self._is_valid_namespace(ns):
raise Exception("Invalid database namespace.")
user = (self._get_input('init_password.db_username', '') or '').strip()
if not user:
raise Exception("Database owner cannot be empty.")
p1 = self._get_input('init_password.db_password', '')
p2 = self._get_input('init_password.db_password_confirm', '') or p1
if not p1:
raise Exception("Database password cannot be empty.")
if p1 != p2:
raise Exception("Database passwords do not match.")
# Check if password is already an anchor, which implies OpenBao is already initialized
if p1.startswith('${'):
self.log(f"[OK] OpenBao secrets already anchored for namespace {ns}. Skipping initialization.")
return
self.inputs['init_password.db_namespace'] = ns
self.inputs['env_setup.NAMESPACE'] = ns
self.inputs['init_password.db_password'] = p1
self.inputs['init_password.db_password_confirm'] = p2
self._update_env_namespace(ns)
self.prole_cfg_data['Database Creation']['DB_USER'] = user
self.prole_cfg_data['Database Creation']['DB_PASSWORD_SET'] = 'true'
self.prole_cfg_data['Database Creation']['DB_NAME'] = ns
self.prole_cfg_data['Database Creation']['NAMESPACE'] = ns
include_supabase = self._get_input_bool('init_cluster.supabase_enabled', False)
include_kerberos = self._get_input_bool('init_cluster.kerberos_enabled', False) or self._get_input_bool('kerberos_config.enabled', False)
try:
self._prepull_images_to_registry(include_supabase, include_kerberos)
except Exception as e:
self.err(f"[WARN] Image pre-pull error: {e}")
if self._get_input_bool('init_password.generate_ssh_key', DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True)):
self._generate_ssh_key(user)
# Initialize OpenBao and store keys/passwords for this namespace
if not self.controller.check_docker_running():
if platform.system() == 'Darwin':
self.log("Starting Docker...")
self._run_cmd(['open', '-a', 'Docker'])
for _ in range(30):
time.sleep(2)
if self.controller.check_docker_running():
break
if not self.controller.check_docker_running():
raise Exception("Docker is not running; OpenBao requires Docker.")
env = os.environ.copy()
env["PROLE_HOME"] = str(self.project_root)
env["PROLE_SERVICE"] = str(self.project_root)
env["PROLE_DB_USER"] = user
env["DB_PASSWORD"] = p1
env["GRAFANA_ADMIN_PASSWORD"] = p1
env["NAMESPACE"] = ns
env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False))
realm = self._get_input('kerberos_config.realm', '').strip()
kdc = self._get_input('kerberos_config.kdc', '').strip()
krb_user = self._get_input('kerberos_config.user', '').strip()
krb_pw = self._get_input('kerberos_config.password', '').strip()
if realm:
env["KRB5_REALM"] = realm
env["REALM"] = realm
env["DOMAIN"] = realm.lower()
if kdc:
env["KRB5_KDC"] = kdc
env["KRB5_ADMIN"] = kdc
if krb_user:
env["KRB5_USER"] = krb_user
if krb_pw:
env["KRB5_PASSWORD"] = krb_pw
self.log("==> OpenBao initialize")
rc_bao = self._run_script("init_openbao.sh", args=["initialize"], env=env, stdin_text=f"{p1}\n")
if rc_bao != 0:
raise Exception(f"OpenBao initialization failed (code {rc_bao})")
def _generate_ssh_key(self, user: str):
self.log("==> Environment Preparation (Secure Access)")
key_path = Path.home() / ".ssh" / "id_prole_ed25519"
key_path.parent.mkdir(parents=True, exist_ok=True)
if key_path.exists():
self.log(f"[SKIP] Secure access keys already exist at {key_path}")
return
cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", user]
rc = self._run_cmd(cmd)
if rc != 0:
self.err(f"[WARN] Preparation failed (code {rc}), trying alternative.")
cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", user]
rc = self._run_cmd(cmd)
if rc != 0:
self.err(f"[ERROR] Preparation failed (code {rc})")
def _step_db_build(self) -> None:
if not self._get_input_bool('init_db_build.run_build', DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True)):
self.log("[SKIP] DB build disabled.")
self.prole_cfg_data['Docker Build']['STATUS'] = 'Skipped'
return
tag = self.controller.get_prole_db_version()
image_name = f"prole-db:{tag}"
# Check if image already exists
rc_inspect = subprocess.run(['docker', 'inspect', image_name], capture_output=True).returncode
if rc_inspect == 0:
self.log(f"[OK] {image_name} already exists. Skipping build.")
self._db_built_success = True
self.prole_cfg_data['Docker Build']['STATUS'] = 'Built'
return
self.log("==> Build prole-db image")
prole_home = Path.home() / ".prole"
build_dir = prole_home / "build" / "prole-db"
build_dir.mkdir(parents=True, exist_ok=True)
source_dir = get_resource_path("prole-db")
if source_dir.exists():
if source_dir.resolve() != build_dir.resolve():
if build_dir.exists():
shutil.rmtree(build_dir)
shutil.copytree(source_dir, build_dir)
pub_key_path = Path.home() / ".ssh" / "id_prole_ed25519.pub"
pub_key = pub_key_path.read_text().strip() if pub_key_path.exists() else ""
username = self._get_input('init_password.db_username', '')
env_key = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', 'dev'))
cmd = ['docker', 'build']
cmd.extend(get_docker_build_platform_args(env_key))
cmd += [
'--build-arg', f"PROLE_USER={username}",
'--build-arg', f"PROLE_SSH_PUB_KEY={pub_key}",
'-t', image_name, '.'
]
rc = self._run_cmd(cmd, cwd=str(build_dir))
if rc == 0:
self._db_built_success = True
self.log("[OK] Build successful.")
if env_key == 'dev':
cluster_name = "prole-dev-cluster"
self.log(f"Importing image to {cluster_name}...")
self._run_cmd(['k3d', 'image', 'import', image_name, '-c', cluster_name])
else:
# For non-dev, tag and push to registry
registry = (self.prole_cfg_data.get('Docker Build', {}).get('LOCAL_REGISTRY') or 'localhost:5000').strip()
remote_tag = f"{registry}/{image_name}"
self.log(f"Tagging {image_name} as {remote_tag}...")
subprocess.run(['docker', 'tag', image_name, remote_tag], check=True)
self.log(f"Pushing {remote_tag} to registry...")
if not _push_docker_image(remote_tag, log_fn=self.log):
self.err(f"[ERROR] Failed to push image {remote_tag}")
self._db_built_success = False
self.prole_cfg_data['Docker Build']['STATUS'] = 'Built' if self._db_built_success else 'Failed'
def _step_cluster(self) -> None:
self.log("==> Cluster setup")
cluster_env = self._get_input('init_cluster.cluster_env', 'dev')
env_key = _normalize_cluster_env(cluster_env)
self.prole_cfg_data['Initialize Cluster']['ENVIRONMENT'] = cluster_env
self.prole_cfg_data['Initialize Cluster']['K3S_SERVER_URL'] = self._get_input('init_cluster.k3s_server_url', '')
self.prole_cfg_data['Initialize Cluster']['K3S_TOKEN'] = _encrypt_cfg_secret(self._get_input('init_cluster.k3s_token', '') or '')
self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.supabase_enabled', False))
self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.kerberos_enabled', False))
self.prole_cfg_data['Optional Features']['AT_REST_ENCRYPTION_ENABLED'] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False))
if not self._get_input_bool('init_cluster.start_cluster', DEFAULT_ACTION_FLAGS.get('init_cluster.start_cluster', True)):
self.log("[SKIP] Cluster start disabled.")
return
if env_key == 'dev':
if not self.controller.check_docker_running():
if platform.system() == 'Darwin':
self.log("Starting Docker...")
self._run_cmd(['open', '-a', 'Docker'])
for _ in range(30):
time.sleep(2)
if self.controller.check_docker_running():
break
if not self.controller.check_docker_running():
raise Exception("Docker is not running.")
# Ensure OpenBao container is running (local-only)
ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
env = self._script_env_for_namespace(ns)
env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self._get_input_bool('init_cluster.at_rest_encryption_enabled', False))
rc_bao = self._run_script("init_openbao.sh", args=["start"], env=env)
if rc_bao != 0:
raise Exception(f"OpenBao start failed (code {rc_bao})")
# Ensure local registry is available before cluster creation (dev only)
if not self._ensure_local_registry_available():
raise Exception("Local registry is not running.")
cluster_name = "prole-dev-cluster"
res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True)
if cluster_name not in res.stdout:
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']
self._run_cmd(cmd)
else:
# Check if it's already running
if 'running' not in res.stdout.lower():
self._run_cmd(['k3d', 'cluster', 'start', cluster_name])
else:
self.log(f"[OK] Cluster {cluster_name} is already running.")
self.log(f"[OK] Cluster ready: {cluster_name}")
else:
kubectl = subprocess.run(['which', 'kubectl'], capture_output=True)
if kubectl.returncode != 0:
raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.")
if env_key == 'service':
server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip()
token = (self._get_input('init_cluster.k3s_token', '') or '').strip()
if token:
token = self._resolve_secret_value(token)
if server and token:
if not server.startswith('http'):
server = f"https://{server}"
cmd = [
'kubectl',
'--server=' + server,
'--token=' + token,
'--insecure-skip-tls-verify=true',
'cluster-info'
]
else:
kubeconfig_env = (os.environ.get('KUBECONFIG') or '').strip()
if kubeconfig_env:
cmd = ['kubectl', '--kubeconfig', kubeconfig_env, 'cluster-info']
else:
raise Exception("K3s server URL/token missing for service cluster.")
else:
cmd = ['kubectl', 'cluster-info']
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
raise Exception("Cluster is not reachable.")
self.log(f"[OK] Cluster ready: {env_key}")
if env_key == 'service':
self._deploy_common_services(env_key)
else:
self._deploy_opentofu(env_key)
def _deploy_common_services(self, env_key: str) -> None:
self.log("==> Common services deploy")
service_ns = self._service_namespace()
env = self._script_env_for_namespace(service_ns)
kerberos_enabled = self._get_input_bool('kerberos_config.enabled', False) or \
self._get_input_bool('init_cluster.kerberos_enabled', False)
try:
# Check status before update
status_args = ["-n", service_ns]
if kerberos_enabled:
status_args.append("-k")
rc_status = self._run_script("status_common_services.sh", args=status_args, env=env)
if rc_status == 0:
self.log(f"[OK] Common services in {service_ns} are healthy. Skipping update.")
return
self.log(f"[INFO] Common services unhealthy or missing in {service_ns}. Attempting repair/deploy...")
svc_args = ["-n", service_ns]
if kerberos_enabled:
svc_args.append("-k")
svc_args.append("update")
rc = self._run_script("init_common_services.sh", args=svc_args, env=env)
if rc != 0:
self.err(f"[ERROR] Common services deploy failed (code {rc})")
else:
# Re-validate after fix
rc_status = self._run_script("status_common_services.sh", args=status_args, env=env)
if rc_status != 0:
self.err(f"[ERROR] Common services still unhealthy after repair attempt.")
else:
self.log(f"[OK] Common services in {service_ns} are now healthy.")
finally:
pass
def _deploy_opentofu(self, env_key: str) -> None:
self.log("==> OpenTofu deploy")
ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
env = self._script_env_for_namespace(ns)
try:
rc = self._run_script("init_opentofu.sh", args=["start"], env=env)
if rc != 0:
self.err(f"[ERROR] OpenTofu deploy failed (code {rc})")
finally:
pass
def _step_kerberos(self) -> None:
enabled = self._get_input_bool('kerberos_config.enabled', False)
if not enabled:
self.log("[SKIP] Kerberos disabled.")
return
realm = self._get_input('kerberos_config.realm', '')
kdc = self._get_input('kerberos_config.kdc', '')
user = self._get_input('kerberos_config.user', '')
password = self._get_input('kerberos_config.password', '')
self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = _bool_str(enabled)
self.prole_cfg_data['Kerberos Authentication']['REALM'] = realm
self.prole_cfg_data['Kerberos Authentication']['KDC'] = kdc
self.prole_cfg_data['Kerberos Authentication']['SERVER'] = kdc
self.prole_cfg_data['Kerberos Authentication']['USER'] = user
self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = password
self.prole_cfg_data['Kerberos Authentication']['AD_PORT_FORWARD'] = os.environ.get('KRB5_AD_PORT_FORWARD', '1')
self.prole_cfg_data['Kerberos Authentication']['AD_TCP_PORTS'] = os.environ.get('KRB5_AD_TCP_PORTS', '88 389 445 464 636')
self.prole_cfg_data['Kerberos Authentication']['AD_UDP_PORTS'] = os.environ.get('KRB5_AD_UDP_PORTS', '88 464')
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_HOST_NETWORK'] = os.environ.get('KRB5_AD_PROXY_HOST_NETWORK', '1')
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_IMAGE'] = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat')
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_SERVICE'] = os.environ.get('KRB5_AD_SERVICE_NAME', 'prole-kerberos-ad-dc')
ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
env = self._script_env_for_namespace(ns)
env["KRB5_REALM"] = realm
if env.get("PROLE_MODE") == "k3d":
env.setdefault("KRB5_AD_PORT_FORWARD", "1")
elif env.get("PROLE_MODE") == "k3s":
env.setdefault("KRB5_AD_PORT_FORWARD", "0")
if not self._get_input_bool('kerberos_config.test_connection', DEFAULT_ACTION_FLAGS.get('kerberos_config.test_connection', False)):
self.log("[SKIP] Kerberos test disabled.")
return
# Check if already tested successfully
if self.prole_cfg_data.get('Kerberos Authentication', {}).get('TEST_STATUS') == 'Success':
self.log("[OK] Kerberos already tested successfully. Skipping.")
return
if not (realm and user and password and kdc):
self.err("[WARN] Kerberos test skipped: missing realm/user/password/kdc.")
return
self.log("==> Kerberos test: init_kerberos.sh test")
rc2 = self._run_script("init_kerberos.sh", args=["test"], env=env)
if rc2 != 0:
self.err(f"[ERROR] Kerberos test failed (code {rc2})")
self.prole_cfg_data.setdefault('Kerberos Authentication', {})['TEST_STATUS'] = 'Failed'
else:
self.log("[OK] Kerberos test completed successfully.")
self.prole_cfg_data.setdefault('Kerberos Authentication', {})['TEST_STATUS'] = 'Success'
def _step_init_scripts(self) -> None:
if not self._get_input_bool('init_scripts.run_scripts', DEFAULT_ACTION_FLAGS.get('init_scripts.run_scripts', True)):
self.log("[SKIP] Init scripts disabled.")
self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Skipped'
return
if self.prole_cfg_data.get('Initialization Scripts', {}).get('STATUS') == 'Finished':
self.log("[OK] Initialization scripts already finished.")
return
self.log("==> Initialization scripts")
ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
env = self._script_env_for_namespace(ns)
env.setdefault("KRB5_AD_PORT_FORWARD", "1")
self.log("Stopping existing port-forwards to avoid conflicts...")
self._run_script("init_port_forwards.sh", args=["stop"], env=env)
password = self._get_input('init_password.db_password', '')
kerberos_enabled = self._get_input_bool('kerberos_config.enabled', False)
svc_args = ["update"]
if kerberos_enabled:
svc_args = ["-k", "update"]
steps = [
("init_common_services.sh", svc_args, False),
("init_cloudnative_pg.sh", ["initialize"], False),
]
if kerberos_enabled:
steps.append(("init_kerberos.sh", ["initialize"], False))
steps.extend([
("init_prole-db-backup.sh", ["start"], False),
("init_monitoring.sh", ["initialize"], False),
("init_port_forwards.sh", ["start"], False),
])
overall_success = True
for script, args, needs_password in steps:
self.log(f"--> {script} {' '.join(args)}")
stdin_text = f"{password}\n" if needs_password else None
# Use custom line handler for monitoring to capture Grafana password and port mappings
rc = self._run_script(script, args=args, env=env, stdin_text=stdin_text, on_line=self._process_script_output_line)
if rc != 0:
self.err(f"[ERROR] {script} failed (code {rc})")
overall_success = False
self._scripts_success = overall_success
self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if overall_success else 'Attempted'
def _step_cnpg_deploy(self) -> None:
if not self._get_input_bool('init_cnpg_deploy.run_deploy', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True)):
self.log("[SKIP] CnPG deploy disabled.")
self.prole_cfg_data['Deployment']['STATUS'] = 'Skipped'
return
self.log("==> Deploy CloudNative-PG")
etc_dir = self.project_root / "etc"
ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
env = self._script_env_for_namespace(ns)
rc = self._run_cmd(['bash', str(etc_dir / 'init_cloudnative_pg.sh'), 'deploy', 'latest'], env=env)
if rc == 0:
self._cnpg_success = True
self.prole_cfg_data['Deployment']['STATUS'] = 'Deployed'
else:
self._cnpg_success = False
self.prole_cfg_data['Deployment']['STATUS'] = 'Attempted'
self.err(f"[ERROR] CnPG deploy failed (code {rc})")
if self._get_input_bool('init_cnpg_deploy.force_rollout', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.force_rollout', False)):
self.log("==> Force rollout")
rc2 = self._run_cmd(['bash', str(etc_dir / 'init_cloudnative_pg.sh'), 'rollout'], env=env)
if rc2 != 0:
self.err(f"[ERROR] Rollout failed (code {rc2})")
def _step_supabase(self) -> None:
if not self._get_input_bool('supabase_config.run_deploy', False):
self.log("[SKIP] Supabase deploy disabled.")
self.prole_cfg_data['Supabase'] = {'STATUS': 'Skipped'}
return
self.log("==> Deploy Supabase")
ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
env = self._script_env_for_namespace(ns)
script_path = self.project_root / "supabase" / "deploy.sh"
if not script_path.exists():
self.err(f"[ERROR] Supabase deploy script not found: {script_path}")
self._supabase_success = False
self.prole_cfg_data['Supabase'] = {'STATUS': 'Attempted'}
return
mode = (os.environ.get("SUPABASE_DEPLOY_MODE") or os.environ.get("SUPABASE_MODE") or "").strip()
if not mode:
# Match cluster environment to supabase deploy mode
cluster_env = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', 'dev'))
if cluster_env == 'dev':
mode = "k3d"
elif cluster_env in ('service', 'prod'):
mode = "k8s"
else:
mode = "k3d"
args = ["--mode", mode]
cfg_path = None
if self.cfg_path and self.cfg_path.exists():
cfg_path = self.cfg_path
else:
candidate = self.project_root / "conf" / "prole.cfg"
if candidate.exists():
cfg_path = candidate
if cfg_path:
args.extend(["-c", str(cfg_path)])
if _parse_bool(os.environ.get("SUPABASE_USE_DEV_COMPOSE"), False):
args.append("--with-dev-helpers")
if _parse_bool(os.environ.get("SUPABASE_FOREGROUND"), False):
args.append("--foreground")
self.log(f"--> supabase/deploy.sh {' '.join(args)}")
rc = self._run_cmd(['bash', str(script_path)] + args, env=env)
if rc == 0:
self._supabase_success = True
self.prole_cfg_data['Supabase'] = {'STATUS': 'Deployed'}
else:
self._supabase_success = False
self.prole_cfg_data['Supabase'] = {'STATUS': 'Attempted'}
self.err(f"[ERROR] Supabase deploy failed (code {rc})")
def _finalize_secrets(self) -> None:
ns = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
if not ns or ns == 'default':
ns = self._get_input('env_setup.NAMESPACE', '') or 'default'
p1 = self._get_input('init_password.db_password', '')
if p1.startswith('${'):
self.log(f"[OK] Secrets already finalized for namespace {ns}. Skipping build-a-bao.")
return
self.log("==> OpenBao: finalize secrets")
env = self._script_env_for_namespace(ns)
rc = self._run_script("build-a-bao.sh", env=env)
if rc == 0:
self._secrets_finalized = True
self.log("[OK] Secrets saved to OpenBao and prole.cfg updated.")
else:
self.err(f"[ERROR] build-a-bao.sh failed (code {rc})")
def _prepare_opentofu_pipeline(self) -> None:
env_key = _normalize_cluster_env(self._get_input('init_cluster.cluster_env', ''))
if env_key != 'service':
return
namespace = (self._get_input('init_password.db_namespace', '') or '').strip() or 'default'
k3s_server = (self._get_input('init_cluster.k3s_server_url', '') or '').strip()
k3s_token = (self._get_input('init_cluster.k3s_token', '') or '').strip()
if k3s_token:
k3s_token = self._resolve_secret_value(k3s_token)
if k3s_server and not k3s_server.startswith('http'):
k3s_server = f"https://{k3s_server}"
if not k3s_server:
self.err("[WARN] OpenTofu pipeline skipped: missing k3s server URL.")
return
if not k3s_token:
self.err("[WARN] OpenTofu pipeline missing k3s token; writing empty token.")
pipeline_dir = _sync_opentofu_pipeline(self.project_root, namespace, k3s_server, k3s_token)
self.prole_cfg_data['Deployment']['OPENTOFU_PIPELINE_DIR'] = str(pipeline_dir)
self.log(f"[OK] OpenTofu pipeline prepared at {pipeline_dir}")
def run(self) -> int:
_configure_unbuffered_io()
self.log(f"[CONFIG] Using {self.cfg_path}")
try:
self._apply_inputs()
except Exception as e:
self.err(f"[FATAL] {e}")
return 2
self._write_cfg()
try:
if not self._step_dependencies():
self._write_cfg()
return 1
self._write_cfg()
self._step_network_scan()
self._write_cfg()
self._step_env_setup()
self._write_cfg()
self._step_init_password()
self._write_cfg()
self._step_cluster()
self._write_cfg()
self._step_db_build()
self._write_cfg()
self._step_kerberos()
self._write_cfg()
self._step_init_scripts()
self._write_cfg()
self._step_supabase()
self._write_cfg()
self._step_cnpg_deploy()
self._write_cfg()
self._finalize_secrets()
self._write_cfg()
self._prepare_opentofu_pipeline()
self._write_cfg()
self.prole_cfg_data['Install']['STATUS'] = 'Finished'
self._write_cfg()
self.log("[DONE] Silent install completed.")
return 0
except Exception as e:
self.err(f"[FATAL] {e}")
try:
self.prole_cfg_data['Install']['STATUS'] = 'Failed'
self._write_cfg()
except Exception:
pass
return 2
def _run_silent_install_test(project_root: Path, cfg_path: Path) -> int:
script = project_root / "tests" / "silent_install_test.sh"
if not script.exists():
print(f"[ERROR] Silent install test script not found: {script}", file=sys.stderr)
return 1
env = os.environ.copy()
env.setdefault("SILENT_INSTALL_LOG", "true")
res = subprocess.run(["bash", str(script), str(cfg_path)], env=env)
return res.returncode
def _attempt_k3s_repair(controller: ProleController, namespace: str, server: str, token: str, db_password: str) -> None:
env = os.environ.copy()
env["PROLE_HOME"] = str(controller.project_root)
env["PROLE_SERVICE"] = str(controller.project_root)
env["NAMESPACE"] = namespace
env["PROLE_MODE"] = "k3s"
if db_password:
env["DB_PASSWORD"] = db_password
env["OPENTOFU_ADMIN_PASSWORD"] = db_password
if server and token and not env.get("KUBECONFIG"):
kubeconfig_path = _write_k3s_kubeconfig(server, token)
env["KUBECONFIG"] = str(kubeconfig_path)
controller.run_script("init_openbao.sh", args=["-n", namespace, "update"], env=env)
controller.run_script("init_opentofu.sh", args=["-n", namespace, "update"], env=env)
controller.run_script("init_port_forwards.sh", args=["stop"], env=env)
def _reset_k3s_namespace(project_root: Path, namespace: str, server: str, token: str) -> None:
script = project_root / "scripts" / "reset-ns.sh"
if not script.exists():
print(f"[WARN] Namespace reset script not found: {script}", file=sys.stderr)
return
env = os.environ.copy()
if server and token and not env.get("KUBECONFIG"):
kubeconfig_path = _write_k3s_kubeconfig(server, token)
env["KUBECONFIG"] = str(kubeconfig_path)
subprocess.run(["bash", str(script), "-n", namespace], env=env)
def _prepare_k3s_pipeline(controller: ProleController) -> int:
project_root = controller.project_root
cfg_path = project_root / "conf" / "prole.cfg"
info = _detect_ansible_topology(project_root)
k3s_server = (info.get('k3s_server_url') or '').strip() if info else ''
k3s_token = (info.get('k3s_token') or '').strip() if info else ''
if k3s_server and not k3s_server.startswith('http'):
k3s_server = f"https://{k3s_server}"
installer = ProleSilentInstaller(controller, str(cfg_path))
try:
existing_inputs = installer._load_inputs_from_cfg()
except Exception:
existing_inputs = {}
installer.inputs = {**installer._default_inputs(), **existing_inputs}
# Override for k3s pipeline defaults
installer.inputs['init_cluster.cluster_env'] = 'prole-service-cluster'
installer.inputs['init_cluster.supabase_enabled'] = _bool_str(False)
installer.inputs['init_cluster.kerberos_enabled'] = _bool_str(False)
installer.inputs['init_cluster.at_rest_encryption_enabled'] = _bool_str(True)
installer.inputs['kerberos_config.enabled'] = _bool_str(False)
installer.inputs['kerberos_config.test_connection'] = _bool_str(False)
if k3s_server and not installer.inputs.get('init_cluster.k3s_server_url'):
installer.inputs['init_cluster.k3s_server_url'] = k3s_server
if k3s_token and not installer.inputs.get('init_cluster.k3s_token'):
installer.inputs['init_cluster.k3s_token'] = k3s_token
installer.inputs['env_setup.PROLE_HOME'] = str(project_root)
installer.inputs['env_setup.PROLE_CONF'] = str(project_root / "conf")
installer.inputs['env_setup.PROLE_DATA'] = (
installer._resolve_env_value('PROLE_DATA', str(Path.home() / '.prole' / 'data'))
or str(Path.home() / '.prole' / 'data')
)
installer.inputs['env_setup.PROLE_LOGS'] = (
installer._resolve_env_value('PROLE_LOGS', str(Path.home() / '.prole' / 'logs'))
or str(Path.home() / '.prole' / 'logs')
)
installer.inputs['env_setup.PROLE_SERVICE'] = str(project_root / "etc")
installer._write_cfg()
namespace = (installer._get_input('init_password.db_namespace', '') or '').strip()
if not namespace:
namespace = (installer._get_input('env_setup.NAMESPACE', '') or '').strip() or 'default'
db_password = installer._get_input('init_password.db_password', '').strip()
attempt = 1
total_attempts = 0
max_attempts_env = os.environ.get("PROLE_SILENT_TEST_MAX_ATTEMPTS", "").strip()
max_attempts = int(max_attempts_env) if max_attempts_env.isdigit() else 0
while True:
total_attempts += 1
rc = _run_silent_install_test(project_root, cfg_path)
if rc == 0:
break
cur_server = installer.inputs.get('init_cluster.k3s_server_url', k3s_server)
cur_token = installer.inputs.get('init_cluster.k3s_token', k3s_token)
if attempt == 1:
_attempt_k3s_repair(controller, namespace, cur_server, cur_token, db_password)
elif attempt == 2:
_reset_k3s_namespace(project_root, namespace, cur_server, cur_token)
else:
attempt = 0
attempt += 1
if max_attempts and total_attempts >= max_attempts:
return rc
cur_server = installer.inputs.get('init_cluster.k3s_server_url', k3s_server)
cur_token = installer.inputs.get('init_cluster.k3s_token', k3s_token)
_sync_opentofu_pipeline(project_root, namespace, cur_server, cur_token)
return 0
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('--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)')
args = parser.parse_args()
# Create controller (shared business logic)
controller = ProleController(PROJECT_ROOT)
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)
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)
# 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()