mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 12:03:59 +00:00
- 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.
13125 lines
573 KiB
Python
Executable File
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()
|