#!/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 import secrets import string from datetime import datetime import urllib.request import urllib.parse import urllib.error import logging 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 from installer.core.controller import ProleController from installer.core.milestones import ( DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone, SecretManagementMilestone, DatabaseCreationMilestone, DockerBuildMilestone, ClusterLifecycleMilestone, InitializationScriptsMilestone, DeploymentMilestone ) # On macOS, set the process name as early as possible so the menu bar shows 'Prole Database Installer' if platform.system() == 'Darwin': try: from Foundation import NSProcessInfo NSProcessInfo.processInfo().setProcessName_("Prole Database Installer") except Exception: pass from installer.core.env import * # noqa: F401,F403 from installer.core.actions import ProleSilentInstaller, _prepare_k3s_pipeline # Initialize global frames list to keep them in memory GLOBAL_SCAN_FRAMES = [] class ProleInstaller: KUBECTL_STATUS_TAB = "__kubectl_status__" def __init__(self, root, config_path: str | None = None, reset_cluster: bool = False, verbose: bool = False): self.controller = ProleController(PROJECT_ROOT, verbose=verbose) self.screens = None self.deploy_environment = None self.root = root self._cfg_path_override = None if config_path: try: self._cfg_path_override = Path(config_path).expanduser() except Exception: self._cfg_path_override = None self._installing_dep_id = None self._managed_kubeconfig = None self._kong_last_repair_at = 0.0 self._kong_repair_cooldown_s = 120 self._repair_last_run_at = 0.0 self._repair_cooldown_s = 600 self._repair_inflight = False self._reset_cluster = bool(reset_cluster) _ensure_ansible_vault_credentials(prompt_ui=True, root=root) self._action_flags = {} self.root.title("Prole Database Installer") # Best-effort: set app identity (menu title / dock icon) early try: self._set_app_identity() except Exception: pass # Center window on screen window_width = 1300 window_height = 910 screen_width = root.winfo_screenwidth() screen_height = root.winfo_screenheight() center_x = int(screen_width / 2 - window_width / 2) center_y = int(screen_height / 2 - window_height / 2) self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}") self.root.resizable(False, False) # Force light mode colors and prevent dark mode shifts self.root.configure(bg='white') # Style configuration self.style = ttk.Style() self.configure_styles() # Main container with two columns self.main_container = tk.Frame(root, bg='white') self.main_container.pack(fill='both', expand=True) # Left Sidebar (approx 25%) self.sidebar = tk.Frame(self.main_container, bg='#F5F5DC', width=325) # Sepia background self.sidebar.pack(side='left', fill='y') self.sidebar.pack_propagate(False) # Vertical Divider Line self.divider = tk.Frame(self.main_container, bg='#CCCCCC', width=1, highlightthickness=0, bd=0) self.divider.pack(side='left', fill='y') # Right Content Area (approx 66%) self.content_area = tk.Frame(self.main_container, bg='white') self.content_area.pack(side='left', fill='both', expand=True) # Footer for Next/Prev buttons in the content area btns = ui.create_nav_footer( self.content_area, buttons=[(1, 'Previous'), (2, 'Next'), (3, 'Deploy'), (4, 'Launch')], commands={1: self.on_prev, 2: self.on_next, 3: self.on_deploy, 4: self.on_launch}, style_name='Nav.TButton', gear_command=self.show_config_dialog ) self.footer = btns.get('_footer') # type: ignore[assignment] self.prev_button = btns.get(1) self.next_button = btns.get(2) self.deploy_button = btns.get(3) self.launch_button = btns.get(4) self.gear_button = btns.get('gear') # Background canvas for the content area self._bg_pil = None self._bg_tk = None self.bg_canvas = tk.Canvas(self.content_area, highlightthickness=0, bd=0, bg='white') self.bg_canvas.pack(fill='both', expand=True) self._bg_item = None # Slide area: a container for overlaying widgets on the canvas. # We use a frame. On macOS, we can't make it truly transparent without lifting/lowering. self.slide_area = tk.Frame(self.bg_canvas, bg='white') # We will use place but manage visibility in show_page self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) self.slide_area.lower() # Start below canvas items try: from PIL import Image, ImageTk bg_path = get_resource_path('img/proleLogoSepia.png') if bg_path.exists(): # Load and prepare image with 50% opacity original = Image.open(str(bg_path)).convert('RGBA') # Create a white background of the same size white_bg = Image.new('RGBA', original.size, (255, 255, 255, 255)) # Blend with 15% opacity of original (85% white) self._bg_pil = Image.blend(white_bg, original, 0.15) def _render_bg(event=None): if not self._bg_pil: return cw = max(1, self.bg_canvas.winfo_width()) ch = max(1, self.bg_canvas.winfo_height()) iw, ih = self._bg_pil.size scale = max(cw / iw, ch / ih) nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale)) img = self._bg_pil.resize((nw, nh), Image.LANCZOS) self._bg_tk = ImageTk.PhotoImage(img) if self._bg_item is not None: self.bg_canvas.delete(self._bg_item) self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, image=self._bg_tk, anchor='center') self.bg_canvas.tag_lower(self._bg_item) self.bg_canvas.bind('', _render_bg) except Exception as e: print(f"Error loading background: {e}") # Navigation menu items self.nav_items = [ ("Welcome", "welcome"), ("Dependencies", "deps_summary"), ("Network", "network_scan"), ("System Environment", "env_setup"), ("Cluster Environment", "init_cluster"), ("Database Creation", "init_password"), ("Docker Build", "init_db_build"), ("Kerberos Authentication", "kerberos_config"), ("Initialization Scripts", "init_scripts"), ("Ollama", "ollama_config"), ("Supabase", "supabase_config"), ("Deployment", "init_cnpg_deploy"), ("Post Install", "create_installer") ] self.nav_widgets = {} self._create_sidebar_nav() # Initialize validation attributes before creating screens self.validation_running = False self.validation_thread = None # Welcome/splash dependency scan state self._welcome_scan_started = False self.splash_scan_running = False self.splash_scan_done_at = None self.splash_scan_started_at = None self._splash_status_item = None # Capture expected host (short hostname) at app start for safety guards try: self.expected_host = (platform.node() or socket.gethostname()).split('.')[0] except Exception: self.expected_host = None # Content will be rendered directly on the background canvas to avoid # any opaque rectangles obscuring the image. self._canvas_page = None self._canvas_items = [] self.canvas_renderers = {} # Keep track of small overlay widgets placed above the canvas so we can # cleanly remove them on page switches (e.g., radiobuttons, consoles) self._overlay_widgets = [] # Cursor blink timer id for command preview self._cursor_blink_after_id = None self._cursor_blink_visible = False self._k3s_status_after_id = None self._k3s_status_active = False self._k3s_status_inflight = False self._k3s_service_status_console = None self._k3s_service_deploy_console = None self._k3s_service_notebook = None self._k3s_service_status_tab = None self._k3s_service_deploy_tab = None self._common_services_success = False self._showing_service_overlay = False self._last_registry_log_path = None self._last_common_services_log_path = None # Shared dependency catalog from installer.config self.dependencies = list(inst_config.DEPENDENCIES) # Docker import directory configuration self.docker_import_dir = tk.StringVar() # Initialize prole.cfg data structure self.prole_cfg_data = { 'Global': {}, 'Welcome': {}, 'Dependencies': {}, 'Network': {}, 'Port Forwards': {}, 'System Environment': {}, 'Kerberos Authentication': {}, 'Ollama': {}, 'Optional Features': {}, 'Database Creation': {}, 'Docker Build': {}, 'Initialize Cluster': {}, 'Initialization Scripts': {}, 'Deployment': {}, 'Dev Cluster (k3d)': {}, 'Service Cluster (k3s)': {}, 'Prod Cluster (k8s)': {}, 'Install': {} } self._cfg_secret_cache = {} self._secrets_finalized = False self.service_namespace = tk.StringVar(value='default') # Try to load existing docker_import_dir from prole.cfg try: conf_dir = self._resolve_prole_conf_dir() cfg_path = conf_dir / 'prole.cfg' if self._cfg_path_override: cfg_path = self._cfg_path_override if cfg_path.exists(): cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str cfg.read(cfg_path) cfg_vars = _collect_cfg_vars(cfg) if cfg.has_section('Global'): if 'DOCKER_IMPORT_DIR' in cfg['Global']: docker_dir = _expand_cfg_value(cfg['Global']['DOCKER_IMPORT_DIR'], cfg_vars) self.docker_import_dir.set(docker_dir) self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = docker_dir # Preload saved cluster env and namespace if available saved_cluster = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', ''), cfg_vars).strip() saved_ns = _expand_cfg_value(cfg['Global'].get('NAMESPACE', ''), cfg_vars).strip() saved_service_ns = _expand_cfg_value(cfg['Global'].get('SERVICE_NAMESPACE', ''), cfg_vars).strip() if saved_cluster: try: self.cluster_env.set(saved_cluster) except Exception: pass if saved_ns: # Persist for later use (StringVar may not be initialized yet) try: self.db_namespace.set(saved_ns) except Exception: # Defer applying to after initialization pass try: self.prole_cfg_data.setdefault('Global', {})['NAMESPACE'] = saved_ns except Exception: pass if saved_service_ns: try: self.service_namespace.set(saved_service_ns) except Exception: pass self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = saved_service_ns except Exception: pass self.prole_cfg_data['Global'].setdefault('SERVICE_NAMESPACE', self.service_namespace.get()) # Disk selection variables self.selected_disk_type = tk.StringVar(value='local') # 'removable' or 'local' self.selected_removable_disk = tk.StringVar() self.selected_local_path = tk.StringVar(value=str(Path.home())) self.removable_disks = [] # List of (name, mount_point) # Wizard pages setup self.pages = [] # list of (page_id, frame) self.page_index = 0 # Initialize variables for new Initialize screens self.cluster_env = tk.StringVar(value='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()) # If prole.cfg provided a namespace, prefer it as the initial selection try: _ns_from_cfg = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip() if _ns_from_cfg: self.db_namespace.set(self._ensure_namespace_prefix(_ns_from_cfg)) except Exception: pass self.db_host_port = tk.StringVar(value='5432') self.db_namespace_suffix = tk.StringVar() self._updating_namespace_fields = False self._sync_namespace_suffix_from_full() self.db_namespace.trace_add('write', lambda *args: self._sync_namespace_suffix_from_full()) self.db_namespace_suffix.trace_add('write', lambda *args: self._sync_namespace_full_from_suffix()) # Kerberos variables self.kerberos_enabled = tk.BooleanVar(value=False) self.kerberos_realm = tk.StringVar() self.kerberos_user = tk.StringVar() self.kerberos_password = tk.StringVar() self.kerberos_kdc = tk.StringVar() self.supabase_enabled = tk.BooleanVar(value=False) self.at_rest_encryption_enabled = tk.BooleanVar(value=True) self.ollama_server_host = tk.StringVar() self.ollama_server_port = tk.StringVar(value=DEFAULT_OLLAMA_PORT) self.ollama_model = tk.StringVar() self._ollama_servers_cache = [] self._ollama_model_options = {} self.ansible_topology = {} self.ansible_topology_summary = '' try: self._apply_ansible_topology_defaults() except Exception: pass # Load any encrypted secrets from existing prole.cfg to allow resume try: self._load_secret_cache_from_cfg() except Exception: pass try: self._apply_cluster_env_default_from_cfg() except Exception: pass try: self._apply_k3s_defaults() except Exception: pass try: self._apply_ollama_defaults() except Exception: pass # Create pages self.page_frames = {} self.verify_mode = tk.BooleanVar(value=False) # Register page ids (renderers will draw directly on canvas) self._register_canvas_renderer('welcome', self._render_welcome_page) self._register_canvas_renderer('network_scan', self._render_network_scan_page) self._register_canvas_renderer('env_setup', self._render_env_setup_page) self._register_canvas_renderer('deps_summary', self._render_deps_summary_page) for dep in self.dependencies: self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d)) self._register_canvas_renderer('kerberos_config', self._render_kerberos_config_page) self._register_canvas_renderer('ollama_config', self._render_ollama_config_page) self._register_canvas_renderer('supabase_config', self._render_supabase_config_page) # New Initialize screens self._register_canvas_renderer('init_cluster', self._render_init_cluster_page) self._register_canvas_renderer('init_db_build', self._render_init_db_build_page) self._register_canvas_renderer('init_password', self._render_init_password_page) self._register_canvas_renderer('init_cnpg_deploy', self._render_init_cnpg_deploy_page) self._register_canvas_renderer('init_scripts', self._render_init_scripts_page) self._register_canvas_renderer('build', self._render_build_page) self._register_canvas_renderer('disk_selection', self._render_disk_selection_page) self._register_canvas_renderer('build_summary', self._render_build_summary_page) self._register_canvas_renderer('create_installer', self._render_create_installer_page) # Mirror old pages list ordering for navigation self._register_page('welcome', None) self._register_page('deps_summary', None) for dep in self.dependencies: self._register_page(f"dep_{dep['id']}", None) self._register_page('network_scan', None) self._register_page('env_setup', None) self._register_page('init_cluster', None) self._register_page('init_password', None) self._register_page('init_db_build', None) self._register_page('kerberos_config', None) self._register_page('init_scripts', None) self._register_page('ollama_config', None) self._register_page('supabase_config', None) # New Initialize screens in sequence self._register_page('init_cnpg_deploy', None) self._register_page('build', None) self._register_page('disk_selection', None) self._register_page('build_summary', None) self._register_page('create_installer', None) # Initial render after window shows self.root.after(100, lambda: self.selected_local_path.set(str(self._get_prole_dist_dir()))) self.update_footer() self.show_page(0) # ---------------- App identity (title, Dock icon) ---------------- def _set_app_identity(self): """Set the installer identity: process/menu name and Dock icon on macOS. Notes: - We set Tk's appname for consistency. - On macOS, attempt to set the process/menu name to 'Prole Database Installer' via NSProcessInfo if PyObjC is available. - Prefer the Prole Tools.app .icns from the built app; fallback to local PNG/GIF. """ # Set Tk application name try: self.root.tk.call('tk', 'appname', 'Prole Database Installer') except Exception: pass # macOS: set process/menu name and Dock icon via AppKit/Foundation if platform.system() == 'Darwin': # Try to set the visible process name for the menu bar try: from Foundation import NSProcessInfo NSProcessInfo.processInfo().setProcessName_("Prole Database Installer") except Exception: pass # Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Database Installer' try: from AppKit import NSApplication app = NSApplication.sharedApplication() main_menu = app.mainMenu() if main_menu is not None and main_menu.numberOfItems() > 0: first_item = main_menu.itemAtIndex_(0) if first_item is not None: first_item.setTitle_("Prole Database Installer") except Exception: pass icns_candidates = [ get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole Tools.icns'), get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole.icns'), ] icns_path = next((p for p in icns_candidates if p.exists()), None) if icns_path is not None: try: # Use PyObjC if available from AppKit import NSApplication, NSImage img = NSImage.alloc().initWithContentsOfFile_(str(icns_path)) if img is not None: NSApplication.sharedApplication().setApplicationIconImage_(img) return except Exception: pass # If no .icns was found, try setting Dock icon from configured PNG try: from AppKit import NSApplication, NSImage cfg_icon = inst_config.get_ui_icon_image_path() if cfg_icon.exists(): png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon)) if png_img is not None: NSApplication.sharedApplication().setApplicationIconImage_(png_img) # do not return; still set Tk icon below for consistency except Exception: pass # Fallback: Tk icon from image assets (PNG/GIF) # Prefer config-defined icon image try: cfg_icon = inst_config.get_ui_icon_image_path() except Exception: cfg_icon = get_resource_path('img/proleIcon.png') img_candidates = [ cfg_icon, get_resource_path('img/prole-type.png'), get_resource_path('img/prole-type.gif'), get_resource_path('img/Prole.png'), get_resource_path('img/proleLogoSepia.png'), ] for p in img_candidates: try: if p.exists(): self._app_iconphoto = tk.PhotoImage(file=str(p)) try: self.root.iconphoto(True, self._app_iconphoto) except Exception: pass break except Exception: continue def show_config_dialog(self): """Show configuration dialog for Docker image import directory.""" dialog = tk.Toplevel(self.root) dialog.title("Configuration") dialog.geometry("500x200") dialog.resizable(False, False) dialog.transient(self.root) dialog.grab_set() # Center on parent x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 250 y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 100 dialog.geometry(f"+{x}+{y}") container = tk.Frame(dialog, padx=20, pady=20) container.pack(fill='both', expand=True) tk.Label(container, text="Docker Image Import Directory:", font=('SF Pro Text', 12, 'bold')).pack(anchor='w') tk.Label(container, text="Images found here will be loaded instead of pulled from Docker Hub.", font=('SF Pro Text', 10), fg='#666666').pack(anchor='w', pady=(0, 10)) row = tk.Frame(container) row.pack(fill='x') entry = tk.Entry(row, textvariable=self.docker_import_dir, font=('SF Pro Text', 11)) entry.pack(side='left', fill='x', expand=True, padx=(0, 5)) def browse(): path = filedialog.askdirectory(initialdir=self.docker_import_dir.get() or Path.home()) if path: self.docker_import_dir.set(path) tk.Button(row, text="Browse...", command=browse).pack(side='right') def save(): path = self.docker_import_dir.get().strip() self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = path self._save_prole_cfg() dialog.destroy() btns = tk.Frame(container) btns.pack(fill='x', pady=(20, 0)) tk.Button(btns, text="Save", command=save, width=10).pack(side='right') tk.Button(btns, text="Cancel", command=dialog.destroy, width=10).pack(side='right', padx=10) def configure_styles(self): """Configure ttk styles""" base_bg = 'white' self.style.configure('TFrame', background='white') self.style.configure('TLabel', background='white', foreground='black', font=('SF Pro Text', 11)) self.style.configure('Header.TLabel', font=('SF Pro Text', 16, 'bold')) self.style.configure('Small.TLabel', font=('SF Pro Text', 10)) # Consistent button styling self.style.configure('Nav.TButton', font=('SF Pro Text', 11), padding=(12, 8), background='#F5F5DC', bordercolor='#F5F5DC', lightcolor='#F5F5DC', darkcolor='#F5F5DC', relief='flat') # Map state colors to avoid black boxes on hover/active self.style.map('Nav.TButton', background=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], bordercolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], lightcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], darkcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')]) # Notebook styling to match light theme and avoid dark mode shifts on macOS self.style.theme_use('default') self.style.configure('TNotebook', background='white', borderwidth=0, highlightthickness=0) self.style.configure('TNotebook.Tab', background='#F5F5DC', foreground='black', lightcolor='#F5F5DC', bordercolor='#CCCCCC', darkcolor='#F5F5DC', borderwidth=1, padding=[10, 5]) self.style.map('TNotebook.Tab', background=[('selected', 'white')], bordercolor=[('selected', '#CCCCCC')], lightcolor=[('selected', 'white')], focuscolor=[('selected', 'white')]) # Ensure common controls inherit white background try: self.style.configure('TCheckbutton', background=base_bg, foreground='black') self.style.configure('TCombobox', fieldbackground='white', background=base_bg) except Exception: pass def _apply_ansible_topology_defaults(self): info = _detect_ansible_topology(PROJECT_ROOT) if not info: return self.ansible_topology = info self.ansible_topology_summary = _format_ansible_topology_summary(info) try: net = self.prole_cfg_data.get('Network', {}) if info.get('topology_json'): net['ANSIBLE_TOPOLOGY'] = info['topology_json'] if info.get('inventory_path'): net['ANSIBLE_INVENTORY'] = info['inventory_path'] if info.get('infrastructure_path'): net['ANSIBLE_INFRASTRUCTURE'] = info['infrastructure_path'] if info.get('domain'): net['ANSIBLE_DOMAIN'] = info['domain'] if info.get('realm'): net['ANSIBLE_REALM'] = info['realm'] if info.get('ad_dc_host'): net['AD_DC_HOST'] = info['ad_dc_host'] if info.get('ad_dc_ip'): net['AD_DC_IP'] = info['ad_dc_ip'] if info.get('kdc_ip'): net['KDC_ANSIBLE_DETECTED'] = info['kdc_ip'] self.prole_cfg_data['Network'] = net except Exception: pass try: if not self.kerberos_kdc.get().strip() and info.get('kdc_ip'): self.kerberos_kdc.set(info['kdc_ip']) self.kerberos_enabled.set(True) if not self.kerberos_realm.get().strip() and info.get('realm'): self.kerberos_realm.set(info['realm']) except Exception: pass def _read_k3s_cfg_values(self) -> tuple[str, str, str]: """Return (cluster_env, server_url, token) from prole.cfg if present.""" cfg_path = None try: if self._cfg_path_override is not None: cfg_path = self._cfg_path_override if cfg_path.is_dir(): cfg_path = cfg_path / 'prole.cfg' else: cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' except Exception: cfg_path = None if not cfg_path or not Path(cfg_path).exists(): return '', '', '' cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str try: cfg.read(cfg_path) except Exception: return '', '', '' cfg_vars = _collect_cfg_vars(cfg) env_val = '' server_val = '' token_val = '' if cfg.has_section('Global'): env_val = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', env_val), cfg_vars).strip() server_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_SERVER', server_val), cfg_vars).strip() if not server_val: server_val = _expand_cfg_value(cfg['Global'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() token_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_TOKEN', token_val), cfg_vars).strip() if not token_val: token_val = _expand_cfg_value(cfg['Global'].get('K3S_TOKEN', token_val), cfg_vars).strip() if cfg.has_section('Initialize Cluster'): server_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() token_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_TOKEN', token_val), cfg_vars).strip() if cfg.has_section('Service Cluster (k3s)'): server_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_SERVER_URL', server_val), cfg_vars).strip() token_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_TOKEN', token_val), cfg_vars).strip() return env_val, server_val, token_val def _k3s_connection_raw(self) -> tuple[str, str]: """Return (server_url, token) without attempting secret resolution.""" server = (self.k3s_server_url.get() or os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() token = (self.k3s_token.get() or os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() if server and not server.startswith('http'): server = f"https://{server}" return server, token def _read_ollama_cfg_values(self) -> tuple[str, str, str]: """Return (host, port, model) from prole.cfg if present.""" cfg_path = None try: if self._cfg_path_override is not None: cfg_path = self._cfg_path_override if cfg_path.is_dir(): cfg_path = cfg_path / 'prole.cfg' else: cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' except Exception: cfg_path = None if not cfg_path or not Path(cfg_path).exists(): return '', '', '' cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str try: cfg.read(cfg_path) except Exception: return '', '', '' cfg_vars = _collect_cfg_vars(cfg) host_val = '' port_val = '' model_val = '' host_url = '' if cfg.has_section('Ollama'): sec = cfg['Ollama'] host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() if cfg.has_section('Global'): sec = cfg['Global'] if not host_val: host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip() if not port_val: port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip() if not model_val: model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip() if not host_url: host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip() if host_url and not host_val: parsed_host, parsed_port = _parse_ollama_host(host_url) if parsed_host: host_val = parsed_host if parsed_port and not port_val: port_val = parsed_port return host_val, port_val, model_val def _apply_cluster_env_default_from_cfg(self): cfg_env, _cfg_server, _cfg_token = self._read_k3s_cfg_values() if not cfg_env: return try: current = (self.cluster_env.get() or '').strip() except Exception: current = '' if not current or current == '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('', lambda e, p=page_id: self.show_page(p)) self.nav_widgets[page_id] = lbl def _update_nav_highlight(self, active_id): """Highlight the current active page in the sidebar.""" # Handle dep_id mapping to 'Dependencies' if active_id.startswith('dep_'): active_id = 'deps_summary' for page_id, widget in self.nav_widgets.items(): if page_id == active_id: widget.configure(bg='#E5E5D5', font=('SF Pro Text', 11, 'bold')) else: widget.configure(bg='#F5F5DC', font=('SF Pro Text', 11)) def create_navigation(self): pass def show_screen(self, screen_id): self.show_page(screen_id) def show_page(self, index_or_id): self._showing_service_overlay = False # Resolve index and page_id old_idx = self.page_index if isinstance(index_or_id, int): idx = max(0, min(index_or_id, len(self.pages) - 1)) else: # Find index by page_id idx = -1 for i, (pid, _) in enumerate(self.pages): if pid == index_or_id: idx = i break if idx == -1: msg = f"Navigation error: page ID '{index_or_id}' not found." print(f"[ERROR] {msg}") try: messagebox.showerror("Navigation Error", msg) except Exception: pass return print(f"[DEBUG] Navigating from {old_idx} to {idx} (requested: {index_or_id})") self.page_index = idx pid, frame = self.pages[self.page_index] # Clear any previously drawn canvas content self._clear_canvas_page() self._update_nav_highlight(pid) # If the page has overlay widgets, we need to show the slide_area. # Otherwise, we hide it so the canvas content is visible. # Pages that use canvas drawing and need to show the background should have slide_area hidden. canvas_only_pages = ( 'welcome', 'network_scan', 'env_setup', 'kerberos_config', 'deps_summary', 'init_cluster', 'init_password', 'init_db_build', 'init_cnpg_deploy', 'init_scripts', 'build', 'disk_selection', 'build_summary', 'supabase_config', 'ollama_config', 'create_installer' ) if pid in canvas_only_pages or pid.startswith('dep_'): self.slide_area.place_forget() else: self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) self.slide_area.lift() # Render the page directly on the canvas if we have a renderer if pid in self.canvas_renderers: try: self.canvas_renderers[pid]() except Exception as e: # Fallback: show an error message on canvas ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='black', font=('SF Pro Text', 11)) elif frame is not None: # Legacy fallback (should not be used) frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9) self.update_footer() # ---------------- Canvas page rendering ---------------- def _register_canvas_renderer(self, page_id, func): self.canvas_renderers[page_id] = func def _register_page(self, page_id, frame): self.pages.append((page_id, frame)) self.page_frames[page_id] = frame def _clear_canvas_page(self): # Unbind common events to prevent "echo" or persistent behavior from previous pages try: self.bg_canvas.unbind('') self.root.unbind('') self.root.unbind('') except Exception: pass try: self._stop_k3s_service_status_updates() except Exception: pass # Reset slide_area background to white for safety try: self.slide_area.configure(bg='white') except Exception: pass # Remove any small overlay widgets from the previous page try: if getattr(self, '_overlay_widgets', None): for w in list(self._overlay_widgets): try: w.destroy() except Exception: try: w.place_forget() except Exception: pass self._overlay_widgets.clear() except Exception: pass # Clear page-specific canvas drawings (keep the background image) if self._canvas_items: for item in self._canvas_items: try: self.bg_canvas.delete(item) except Exception: pass self._canvas_items.clear() # ---------------- Environment setup helpers ---------------- def resolve_prole_home(self) -> Path: """Return PROLE_HOME or default to $HOME/.prole as a Path (expanded).""" val = os.environ.get('PROLE_HOME') if val: try: return Path(val).expanduser() except Exception: pass return Path.home() / '.prole' def ensure_prole_env(self) -> Path: """Ensure $PROLE_HOME and env.sh exist and are readable. Create minimal env if missing. Returns the resolved PROLE_HOME Path. Raises Exception on failure. """ home = self.resolve_prole_home() try: home.mkdir(parents=True, exist_ok=True) except Exception as e: raise Exception(f"Failed to create PROLE_HOME at {home}: {e}") env_file = home / 'env.sh' if not env_file.exists(): # Construct minimal values based on defaults with this home vals = { 'PROLE_HOME': str(home), 'PROLE_CONF': self._resolve_env_value('PROLE_CONF', str(home / 'conf')) or str(home / 'conf'), 'PROLE_DATA': self._resolve_env_value('PROLE_DATA', str(home / 'data')) or str(home / 'data'), 'PROLE_LOGS': self._resolve_env_value('PROLE_LOGS', str(home / 'logs')) or str(home / 'logs'), 'PROLE_SERVICE': self._resolve_env_value('PROLE_SERVICE', str(home / 'etc')) or str(home / 'etc'), } self._save_env_to_file(vals) # Validate readability try: data = env_file.read_text() if not isinstance(data, str) or len(data) == 0: raise Exception("env.sh is empty") except Exception as e: raise Exception(f"env.sh not readable at {env_file}: {e}") return home def reload_env_from_shell(self) -> None: """Reload environment by sourcing $PROLE_HOME/env.sh in a login shell and merging into os.environ.""" home = self.ensure_prole_env() env_file = home / 'env.sh' # Use bash to source and print env in null-delimited form cmd = ( f'export PROLE_HOME={shlex.quote(str(home))}; ' f'source {shlex.quote(str(env_file))}; ' 'env -0' ) try: out = subprocess.check_output(['bash', '-lc', cmd]) except subprocess.CalledProcessError as e: raise Exception(f"Failed to reload environment: {e}") except Exception as e: raise Exception(f"Failed to run bash to reload environment: {e}") # Merge variables try: items = out.split(b'\x00') for raw in items: if not raw: continue try: kv = raw.decode('utf-8', errors='ignore') except Exception: continue if '=' not in kv: continue k, v = kv.split('=', 1) # Avoid clobbering Python internals we rely on; safe list only if k in ('PYTHONPATH', 'PYTHONHOME'): continue os.environ[k] = v except Exception: # Best-effort; ignore merge errors pass def _env_defaults(self) -> dict: default_home = Path.home() / '.prole' resolved_home = self._resolve_env_value('PROLE_HOME', str(default_home)) or str(default_home) home = Path(resolved_home).expanduser() namespace = "" try: namespace = self.db_namespace.get() except Exception: namespace = os.environ.get('NAMESPACE', '') return { 'PROLE_HOME': str(home), 'PROLE_CONF': self._resolve_env_value('PROLE_CONF', str(home / 'conf')) or str(home / 'conf'), 'PROLE_DATA': self._resolve_env_value('PROLE_DATA', str(home / 'data')) or str(home / 'data'), 'PROLE_LOGS': self._resolve_env_value('PROLE_LOGS', str(home / 'logs')) or str(home / 'logs'), 'PROLE_SERVICE': self._resolve_env_value('PROLE_SERVICE', str(home / 'etc')) or str(home / 'etc'), 'NAMESPACE': namespace, } def _read_existing_env(self) -> dict: # Best effort: read $PROLE_HOME/env.sh if present env = {} # Try current env var first prole_home = os.environ.get('PROLE_HOME') candidates = [] if prole_home: candidates.append(Path(prole_home).expanduser() / 'env.sh') # Also check default location candidates.append(Path.home() / '.prole' / 'env.sh') for p in candidates: try: if p.exists(): for line in p.read_text().splitlines(): line = line.strip() if not line or line.startswith('#'): continue # expect lines like: export NAME="value" if line.startswith('export '): line = line[len('export '):] if '=' in line: k, v = line.split('=', 1) env[k.strip()] = v.strip().strip('"') break except Exception: pass return env def _resolve_env_value(self, key: str, fallback: str | None = None) -> str | None: val = os.environ.get(key) if val: return val try: env = self._read_existing_env() val = env.get(key) if val: return val except Exception: pass return fallback def _resolve_env_dir(self, key: str, default_suffix: str) -> Path: val = self._resolve_env_value(key) if val: try: return Path(val).expanduser() except Exception: pass return Path.home() / '.prole' / default_suffix def _resolve_prole_conf_dir(self) -> Path: return self._resolve_env_dir('PROLE_CONF', 'conf') def _resolve_prole_logs_dir(self) -> Path: return self._resolve_env_dir('PROLE_LOGS', 'logs') def _registry_log_path(self) -> Path: logs_dir = self._resolve_prole_logs_dir() try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass path = logs_dir / "local_registry.log" self._last_registry_log_path = path return path def _common_services_log_path(self) -> Path: logs_dir = self._resolve_prole_logs_dir() try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass path = logs_dir / "init_common_services.log" self._last_common_services_log_path = path return path def _read_log_tail(self, path: Path, max_lines: int = 400) -> str: try: from collections import deque with path.open('r', encoding='utf-8', errors='ignore') as handle: tail = deque(handle, maxlen=max_lines) return ''.join(tail) except Exception: return "" def _restore_common_services_log(self, console) -> bool: path = getattr(self, '_last_common_services_log_path', None) if not path: candidate = self._common_services_log_path() if candidate.exists(): path = candidate if not path or not path.exists(): return False content = self._read_log_tail(path) if not content: return False try: console.clear() console.write(f"Log file: {path}\n\n") console.write(content) except Exception: return False return True def _ensure_prole_directories(self) -> None: paths = { 'PROLE_HOME': self._resolve_env_value('PROLE_HOME', str(Path.home() / '.prole')), 'PROLE_CONF': str(self._resolve_prole_conf_dir()), 'PROLE_DATA': str(self._resolve_env_dir('PROLE_DATA', 'data')), 'PROLE_LOGS': str(self._resolve_prole_logs_dir()), 'PROLE_SERVICE': str(self._resolve_env_dir('PROLE_SERVICE', 'etc')), } for key, raw in paths.items(): try: Path(raw).expanduser().mkdir(parents=True, exist_ok=True) except Exception: pass if key not in os.environ and raw: os.environ[key] = raw def _record_install_log(self, path) -> None: if not path: return try: p = Path(path) except Exception: return if not hasattr(self, '_install_run_log_paths'): self._install_run_log_paths = [] sp = str(p) if sp not in self._install_run_log_paths: self._install_run_log_paths.append(sp) def _get_local_owner(self) -> str: try: return getpass.getuser() except Exception: try: return os.getlogin() except Exception: return "prole" def _sanitize_namespace(self, name: str) -> str: cleaned = re.sub(r'[^a-z0-9-]+', '-', (name or '').lower()) cleaned = re.sub(r'-{2,}', '-', cleaned).strip('-') if not cleaned: cleaned = 'prole' if len(cleaned) > 63: cleaned = cleaned[:63].rstrip('-') return cleaned def _generate_namespace_name(self) -> str: owner = self._sanitize_namespace(self._get_local_owner()) suffix = uuid.uuid4().hex[:6] base = f"prole-{owner}-{suffix}" return self._sanitize_namespace(base) def _initial_namespace(self) -> str: try: existing = self._read_existing_env() ns = existing.get('NAMESPACE') or existing.get('PROLE_NAMESPACE') if ns: return self._ensure_namespace_prefix(ns) except Exception: pass ns = os.environ.get('NAMESPACE') or os.environ.get('PROLE_NAMESPACE') if ns: return self._ensure_namespace_prefix(ns) return self._ensure_namespace_prefix(self._generate_namespace_name()) def _namespace_prefix(self) -> str: return NAMESPACE_PREFIX def _max_namespace_suffix_len(self) -> int: return max(0, POSTGRES_DB_NAME_MAX_LEN - len(self._namespace_prefix())) def _strip_namespace_prefix(self, name: str) -> str: prefix = self._namespace_prefix() if name.startswith(prefix): return name[len(prefix):] return name def _ensure_namespace_prefix(self, name: str) -> str: cleaned = (name or '').strip() prefix = self._namespace_prefix() if not cleaned: return prefix if cleaned.startswith(prefix): return cleaned return f"{prefix}{cleaned}" def _sync_namespace_suffix_from_full(self): if self._updating_namespace_fields: return self._updating_namespace_fields = True try: full = (self.db_namespace.get() or '').strip() full = self._ensure_namespace_prefix(full) if full != self.db_namespace.get(): self.db_namespace.set(full) self.db_namespace_suffix.set(self._strip_namespace_prefix(full)) finally: self._updating_namespace_fields = False def _sync_namespace_full_from_suffix(self): if self._updating_namespace_fields: return self._updating_namespace_fields = True try: suffix = (self.db_namespace_suffix.get() or '').strip() self.db_namespace.set(f"{self._namespace_prefix()}{suffix}") finally: self._updating_namespace_fields = False def _validate_namespace_suffix(self, proposed: str) -> bool: return len(proposed) <= self._max_namespace_suffix_len() def _is_valid_namespace(self, name: str) -> bool: if not name or len(name) > 63: return False return re.match(r'^[a-z0-9]([-a-z0-9]*[a-z0-9])?$', name) is not None def _parse_rfc3339(self, ts: str): if not ts: return None try: if ts.endswith('Z'): ts = ts[:-1] + '+00:00' return datetime.fromisoformat(ts) except Exception: return None def _format_pod_time(self, ts: str) -> str: dt = self._parse_rfc3339(ts) if not dt: return ts or '—' try: return dt.astimezone().strftime('%Y-%m-%d %H:%M:%S') except Exception: return dt.strftime('%Y-%m-%d %H:%M:%S') def _collect_namespace_rows(self): owner = self.namespace_owner or self._get_local_owner() ns_names = [] notice = "" kubectl_ok = False try: base_cmd = self._kubectl_base_cmd() result = subprocess.run( base_cmd + ['get', 'ns', '-o', 'json'], capture_output=True, text=True, timeout=5 ) if result.returncode == 0: kubectl_ok = True data = json.loads(result.stdout or "{}") for item in data.get('items', []): name = item.get('metadata', {}).get('name') if name: ns_names.append(name) else: notice = (result.stderr or '').strip() or "kubectl returned a non-zero status." except FileNotFoundError: notice = "kubectl not found; showing local defaults." except Exception as e: notice = f"Unable to query namespaces: {e}" current_ns = (self.db_namespace.get() or '').strip() prefix = self._namespace_prefix() ns_names = [name for name in ns_names if name.startswith(prefix) or name == 'supabase'] if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase') and current_ns not in ns_names: ns_names.append(current_ns) if not ns_names: if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase'): ns_names = [current_ns] else: ns_names = [] pods_by_ns = {} if kubectl_ok: try: base_cmd = self._kubectl_base_cmd() pods_result = subprocess.run( base_cmd + ['get', 'pods', '--all-namespaces', '-o', 'json'], capture_output=True, text=True, timeout=8 ) if pods_result.returncode == 0: pods_data = json.loads(pods_result.stdout or "{}") for pod in pods_data.get('items', []): meta = pod.get('metadata', {}) status = pod.get('status', {}) ns = meta.get('namespace') if not ns: continue entry = pods_by_ns.setdefault(ns, {'starts': [], 'running': False}) start_time = status.get('startTime') if start_time: entry['starts'].append(start_time) if status.get('phase') == 'Running': entry['running'] = True else: notice = notice or (pods_result.stderr or '').strip() or notice except Exception: # Best effort only pass rows = [] for ns in sorted(set(ns_names)): entry = pods_by_ns.get(ns, {'starts': [], 'running': False}) oldest = None if entry['starts']: parsed = [self._parse_rfc3339(ts) for ts in entry['starts']] parsed = [p for p in parsed if p is not None] if parsed: oldest_dt = min(parsed) oldest = oldest_dt.isoformat() else: oldest = min(entry['starts']) creation = self._format_pod_time(oldest) if oldest else '—' status = 'active' if entry['running'] else 'inactive' # Best effort to find port port = '—' if ns == 'supabase': port = '5432' else: # For prole-db namespaces if ns == (self.db_namespace.get() or '').strip(): if self.supabase_enabled.get(): port = '15432' else: port = self.db_host_port.get() or '5432' else: # If it's another prole-db namespace, we might not know its port easily # but if Supabase is enabled globally, we assume standard alternate port if self.supabase_enabled.get(): port = '15432' else: port = '5432' rows.append((ns, owner, port, creation, status)) return rows, notice def _refresh_namespace_table(self): if getattr(self, '_refreshing_ns_table', False): return self._refreshing_ns_table = True def worker(): try: rows, notice = self._collect_namespace_rows() current_ns = (self.db_namespace.get() or '').strip() def update_ui(): tree = getattr(self, '_db_namespace_tree', None) if not tree: return for item in tree.get_children(): tree.delete(item) for row in rows: # Add checkbox-like symbol for selection ns_name = row[0] prefix = ' [✓] ' if ns_name == current_ns else ' [ ] ' display_row = (prefix,) + row tree.insert('', 'end', values=display_row) if current_ns: for item in tree.get_children(): vals = tree.item(item, 'values') if vals and vals[1] == current_ns: tree.selection_set(item) tree.see(item) break note_item = getattr(self, '_db_namespace_note', None) if note_item and self.bg_canvas.winfo_exists(): msg = notice if notice else "" self.bg_canvas.itemconfig(note_item, text=msg) self.safe_after(update_ui) finally: self._refreshing_ns_table = False threading.Thread(target=worker, daemon=True).start() def _db_set_status(self, message: str, color: str = '#6e6e73'): note_item = getattr(self, '_db_namespace_note', None) if note_item and self.bg_canvas.winfo_exists(): self.bg_canvas.itemconfig(note_item, text=message or "", fill=color) def _db_set_buttons_state(self, state: str): buttons = getattr(self, '_db_action_buttons', None) if not buttons: return for btn in buttons: try: if btn.winfo_exists(): btn.configure(state=state) except Exception: pass def _db_selected_namespace(self) -> str: tree = getattr(self, '_db_namespace_tree', None) if not tree: return "" sel = tree.selection() if not sel: return "" vals = tree.item(sel[0], 'values') if not vals: return "" ns = vals[1] if ns == 'supabase': return "" return ns def _db_sync_supabase_ports(self, namespace): if not self.supabase_enabled.get(): return script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh" if not script.exists(): return def worker(): try: env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) # Best effort: use kubectl context if selected if hasattr(self, 'selected_kubectx') and self.selected_kubectx.get(): env["KUBECONFIG"] = env.get("KUBECONFIG", "") # Ensure it's passed if set subprocess.run(["bash", str(script), "-n", namespace], cwd=str(PROJECT_ROOT), env=env, capture_output=True, text=True) except: pass threading.Thread(target=worker, daemon=True).start() def _db_action_log_path(self) -> Path: logs_dir = PROJECT_ROOT / "logs" try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass return logs_dir / "db-actions.log" def _db_log(self, text: str): try: with self._db_action_log_path().open('a', encoding='utf-8') as fp: fp.write(text) if not text.endswith('\n'): fp.write('\n') except Exception: pass def _run_cmd_capture(self, cmd, env=None, stdin_text=None, timeout=None): output_lines = [] try: # Ensure PYTHONUNBUFFERED=1 run_env = (env or os.environ).copy() run_env['PYTHONUNBUFFERED'] = '1' proc = subprocess.Popen( cmd, stdin=subprocess.PIPE if stdin_text is not None else None, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=run_env ) except Exception as e: return 1, f"Failed to start command: {e}" if stdin_text is not None and proc.stdin: try: proc.stdin.write(stdin_text) proc.stdin.close() except Exception: pass try: for line in proc.stdout: output_lines.append(line) except Exception: pass try: rc = proc.wait(timeout=timeout) except Exception: try: proc.kill() except Exception: pass rc = 1 return rc, "".join(output_lines) def _resolve_openbao_ref(self, value: str) -> str: if not _is_openbao_ref(value): return value inner = value[len(OPENBAO_PREFIX):-len(OPENBAO_SUFFIX)] path, key = (inner.split('#', 1) + [""])[:2] if not path or not key: return "" mount = "kv" secret_path = path if "/" in path: maybe_mount, rest = path.split("/", 1) if maybe_mount: mount = maybe_mount secret_path = rest token = os.environ.get("OPENBAO_ROOT_TOKEN", "") if not token: prole_service = os.environ.get("PROLE_SERVICE") if prole_service: token_path = Path(prole_service) / "secrets" / "openbao-root-token" if token_path.exists(): token = token_path.read_text().strip() if not token: return value url = (os.environ.get("PROLE_OPENBAO_URL") or "http://127.0.0.1:18200").rstrip("/") try: req = urllib.request.Request(f"{url}/v1/{mount}/data/{secret_path}") req.add_header("X-Vault-Token", token) with urllib.request.urlopen(req, timeout=4) as resp: payload = json.loads(resp.read().decode("utf-8")) return payload.get("data", {}).get("data", {}).get(key, "") or "" except Exception: return value def _resolve_secret_value(self, value: str) -> str: if _is_prole_secret(value): return _decrypt_prole_secret(value) if _is_openbao_ref(value): return self._resolve_openbao_ref(value) return value def _k3s_connection_info(self) -> tuple[str, str]: server = (self.k3s_server_url.get() or os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip() token = (self.k3s_token.get() or os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip() if token: token = self._resolve_secret_value(token) token = _normalize_k3s_token(token) if server and not server.startswith('http'): server = f"https://{server}" return server, token def _script_env_for_namespace(self, namespace: str) -> dict: env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["PROLE_DB_USER"] = self.db_username.get().strip() if (self.db_password.get() or '').strip(): env["DB_PASSWORD"] = self.db_password.get().strip() env["OPENTOFU_ADMIN_PASSWORD"] = self.db_password.get().strip() env["NAMESPACE"] = namespace env["SERVICE_NAMESPACE"] = self._get_service_namespace() mode = self._deployment_mode() if mode: env["PROLE_MODE"] = mode env["DEPLOYMENT_MODE"] = mode env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) if self.kerberos_realm.get().strip(): env["KRB5_REALM"] = self.kerberos_realm.get().strip() env["REALM"] = self.kerberos_realm.get().strip() env["DOMAIN"] = self.kerberos_realm.get().strip().lower() if self.kerberos_kdc.get().strip(): env["KRB5_KDC"] = self.kerberos_kdc.get().strip() env["KRB5_ADMIN"] = self.kerberos_kdc.get().strip() if self.kerberos_user.get().strip(): env["KRB5_USER"] = self.kerberos_user.get().strip() if self.kerberos_password.get().strip(): env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() if self._cluster_env_key() == 'service': raw_server, raw_token = self._k3s_connection_raw() if raw_server: env["PROLE_K3S_SERVER"] = raw_server if raw_token: env["PROLE_K3S_TOKEN"] = raw_token k3s_server, k3s_token = self._k3s_connection_info() explicit_kubeconfig = (env.get("KUBECONFIG") or "").strip() if explicit_kubeconfig and not os.path.exists(explicit_kubeconfig): explicit_kubeconfig = "" managed_set = False if k3s_server and k3s_token and _looks_like_k8s_bearer_token(k3s_token) and not explicit_kubeconfig: # Always generate a fresh kubeconfig to ensure no environment leakage try: if self._managed_kubeconfig and os.path.exists(self._managed_kubeconfig): try: os.unlink(self._managed_kubeconfig) except Exception: pass self._managed_kubeconfig = str(_write_k3s_kubeconfig(k3s_server, k3s_token)) env["KUBECONFIG"] = self._managed_kubeconfig managed_set = True except Exception as e: print(f"[WARN] Failed to generate managed KUBECONFIG: {e}") if not managed_set: fallback = _find_kubeconfig_file(env) if fallback: env["KUBECONFIG"] = fallback # Registry settings reg_host = self.prole_cfg_data.get('Docker Build', {}).get('LOCAL_REGISTRY') if reg_host: env["LOCAL_REGISTRY"] = reg_host reg_internal = self.prole_cfg_data.get('Docker Build', {}).get('LOCAL_REGISTRY_INTERNAL') if reg_internal: env["LOCAL_REGISTRY_INTERNAL"] = reg_internal return env def _k3s_kubectl_base_cmd(self) -> list[str]: if self._managed_kubeconfig and os.path.exists(self._managed_kubeconfig): return ["kubectl", "--kubeconfig", self._managed_kubeconfig] server, token = self._k3s_connection_info() if server and token: # Token is already resolved by _k3s_connection_info return [ "kubectl", "--server=" + server, "--token=" + token, "--insecure-skip-tls-verify=true" ] kubeconfig = _find_kubeconfig_file() if kubeconfig: return ["kubectl", "--kubeconfig", kubeconfig] return ["kubectl"] def _kubectl_base_cmd(self, env: str | None = None) -> list[str]: env_key = self._cluster_env_key(env) if env_key == 'service': return self._k3s_kubectl_base_cmd() kubeconfig_env = (os.environ.get("KUBECONFIG") or "").strip() if kubeconfig_env: return ["kubectl", "--kubeconfig", kubeconfig_env] if env_key == 'prod': kubeconfig = _find_kubeconfig_file() if kubeconfig: return ["kubectl", "--kubeconfig", kubeconfig] return ["kubectl"] def _db_add_namespace(self): ns = (self.db_namespace.get() or '').strip() if not ns: messagebox.showerror('Database Name', 'Database namespace cannot be empty.') return if not self._is_valid_namespace(ns): messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.') return self.db_namespace.set(ns) def worker(): self.safe_after(lambda: self._db_set_buttons_state('disabled')) self.safe_after(lambda: self._db_set_status(f"Creating namespace {ns}...", '#1d1d1f')) cmd = self._kubectl_base_cmd() + ['create', 'namespace', ns] rc, out = self._run_cmd_capture(cmd) if rc != 0 and 'AlreadyExists' not in out: self._db_log(out) self.safe_after(self._refresh_namespace_table) self.safe_after(lambda: self._db_set_status(f"Failed to create namespace {ns}. See logs/db-actions.log.", '#ff3b30')) else: self._update_env_namespace(ns) self.safe_after(self._refresh_namespace_table) self.safe_after(lambda: self._db_set_status(f"Namespace {ns} is ready.", '#34c759')) self.safe_after(lambda: self._db_set_buttons_state('normal')) threading.Thread(target=worker, daemon=True).start() def _db_delete_namespace(self): ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip() if not ns: messagebox.showerror('Delete Database', 'Select a database namespace to delete.') return if not messagebox.askyesno('Delete Database', f'Delete namespace "{ns}"? This cannot be undone.'): return def worker(): self.safe_after(lambda: self._db_set_buttons_state('disabled')) self.safe_after(lambda: self._db_set_status(f"Deleting namespace {ns}...", '#1d1d1f')) cmd = self._kubectl_base_cmd() + ['delete', 'namespace', ns] rc, out = self._run_cmd_capture(cmd) if rc != 0: self._db_log(out) self.safe_after(self._refresh_namespace_table) self.safe_after(lambda: self._db_set_status(f"Failed to delete namespace {ns}. See logs/db-actions.log.", '#ff3b30')) else: self.safe_after(self._refresh_namespace_table) self.safe_after(lambda: self._db_set_status(f"Namespace {ns} deleted.", '#34c759')) self.safe_after(lambda: self._db_set_buttons_state('normal')) threading.Thread(target=worker, daemon=True).start() def _db_edit_namespace(self): ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip() if not ns: messagebox.showerror('Edit Database', 'Select a database namespace to edit.') return self.db_namespace.set(ns) if not self._is_valid_namespace(ns): messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.') return p1 = self.db_password.get() p2 = self.db_password_confirm.get() if not p1: messagebox.showerror('Password', 'Password cannot be empty.') return if p1 != p2: messagebox.showerror('Password', 'Passwords do not match.') return def worker(): self.safe_after(lambda: self._db_set_buttons_state('disabled')) self.safe_after(lambda: self._db_set_status(f"Recreating SSH key for {ns}...", '#1d1d1f')) # We prefer ed25519, but fallback to rsa if not available key_path = Path.home() / ".ssh" / "id_prole_ed25519" pub_path = Path.home() / ".ssh" / "id_prole_ed25519.pub" try: if key_path.exists(): key_path.unlink() if pub_path.exists(): pub_path.unlink() except Exception: pass cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()] rc, out = self._run_cmd_capture(cmd) if rc != 0: self._db_log(f"ed25519 generation failed, falling back to rsa: {out}") cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()] rc, out = self._run_cmd_capture(cmd) self._db_log(out) if rc != 0: self.safe_after(lambda: self._db_set_status("Failed to recreate SSH key. See logs/db-actions.log.", '#ff3b30')) self.safe_after(lambda: self._db_set_buttons_state('normal')) return self.safe_after(lambda: self._db_set_status("Updating OpenBao...", '#1d1d1f')) env = self._script_env_for_namespace(ns) env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) script_path = str(PROJECT_ROOT / "etc" / "init_openbao.sh") rc2, out2 = self._run_cmd_capture(['bash', script_path, 'initialize'], env=env, stdin_text=f"{p1}\n") self._db_log(out2) if rc2 != 0: self.safe_after(self._refresh_namespace_table) self.safe_after(lambda: self._db_set_status("OpenBao update failed. See logs/db-actions.log.", '#ff3b30')) else: self._update_env_namespace(ns) self.safe_after(self._refresh_namespace_table) self.safe_after(lambda: self._db_set_status(f"Updated SSH key and OpenBao for {ns}.", '#34c759')) self.safe_after(lambda: self._db_set_buttons_state('normal')) threading.Thread(target=worker, daemon=True).start() def _update_env_namespace(self, namespace: str): try: existing = self._read_existing_env() defaults = self._env_defaults() values = {**defaults, **existing} values['NAMESPACE'] = namespace self._save_env_to_file(values) os.environ['NAMESPACE'] = namespace except Exception as e: print(f"[WARN] Failed to update env.sh namespace: {e}") def _save_env_to_file(self, values: dict): home = Path(values['PROLE_HOME']).expanduser() # ensure base dir exists home.mkdir(parents=True, exist_ok=True) # ensure subdirs exist for key in ('PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): try: Path(values[key]).expanduser().mkdir(parents=True, exist_ok=True) except Exception: pass # write env.sh atomically content = [] # Executable wrapper + sourceable config content.append('#!/usr/bin/env bash') content.append('# Prole environment configuration') content.append('# This file is generated by the installer. Source it in new shells, or execute as a wrapper:') content.append('# "$PROLE_HOME/env.sh" [args…]') content.append('# shellcheck shell=bash') # Core PROLE_* directories for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'): content.append(f'export {k}="{values[k]}"') if values.get('NAMESPACE'): content.append(f'export NAMESPACE="{values["NAMESPACE"]}"') content.append('') # Ensure PATH contains common locations and $PROLE_HOME/bin (POSIX sh compatible) content.append('# Ensure PATH works for GUI-launched shells (Docker, etc.)') content.append('_prole_add_path() { case ":${PATH}:" in *":$1:"*) ;; *) PATH="$1:${PATH:-}" ;; esac; }') content.append('_prole_add_path "$PROLE_HOME/bin"') content.append('_prole_add_path "/opt/homebrew/bin"') content.append('_prole_add_path "/usr/local/bin"') content.append('_prole_add_path "/usr/bin"') content.append('_prole_add_path "/bin"') content.append('_prole_add_path "/usr/sbin"') content.append('_prole_add_path "/sbin"') content.append('export PATH') content.append('') content.append('# Add custom paths below if needed (examples):') content.append('') content.append('# If executed with arguments (and not sourced), run them under this environment') content.append('if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [ "$#" -gt 0 ]; then') content.append(' exec "$@"') content.append('fi') new_content = '\n'.join(content) + '\n' out = home / 'env.sh' should_write = True if out.exists(): if out.read_text() == new_content: should_write = False # print(f"[OK] {out} is up to date.") if should_write: tmp = home / 'env.sh.tmp' tmp.write_text(new_content) tmp.replace(out) try: os.chmod(out, 0o755) except Exception: pass # print(f"[OK] Wrote {out}") # After creating env.sh, deploy additional resources as requested: # 1) Deploy init-port-forward.sh into $PROLE_HOME (and ensure it's executable) # 2) Copy contents of src/prole/etc (or fallback to top-level etc) into $PROLE_SERVICE try: prole_home = Path(values['PROLE_HOME']).expanduser() prole_service = Path(values['PROLE_SERVICE']).expanduser() # Determine source for init-port-forward.sh # Preferred location under repo: PROJECT_ROOT/src/prole/etc/init-port-forward.sh init_pf_src_candidates = [ PROJECT_ROOT / 'src' / 'prole' / 'etc' / 'init-port-forward.sh', PROJECT_ROOT / 'etc' / 'init-port-forward.sh', ] init_pf_src = next((p for p in init_pf_src_candidates if p.exists()), None) if init_pf_src is not None: init_pf_dst = prole_home / 'init-port-forward.sh' try: data = init_pf_src.read_bytes() init_pf_dst.write_bytes(data) os.chmod(init_pf_dst, 0o755) except Exception: # best effort, ignore errors pass # Determine etc directory source etc_src_candidates = [ PROJECT_ROOT / 'src' / 'prole' / 'etc', PROJECT_ROOT / 'etc', ] etc_src = next((p for p in etc_src_candidates if p.exists() and p.is_dir()), None) if etc_src is not None: try: prole_service.mkdir(parents=True, exist_ok=True) except Exception: pass # Copy files (non-recursive: contents of etc root). If subdirectories exist, copy them recursively. import shutil def _copy_item(src_path: Path, dst_path: Path): try: if src_path.is_dir(): # copy directory tree if dst_path.exists(): # remove then copy to keep in sync shutil.rmtree(dst_path, ignore_errors=True) shutil.copytree(src_path, dst_path) else: shutil.copy2(src_path, dst_path) except Exception: pass try: for item in etc_src.iterdir(): _copy_item(item, prole_service / item.name) except Exception: pass except Exception: # overall best-effort; do not fail env creation if copies fail pass def _render_env_setup_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Personal Infrastructure.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') # Task heading for clarity self._render_title('Environment', y=150) self._render_paragraph('Configure your Prole environment. You can accept defaults or choose custom locations. Changing PROLE_HOME will update the defaults for other paths.', y=200) defaults = self._env_defaults() existing = self._read_existing_env() values = {**defaults, **existing} # Keep entry widgets to read values on Next self._env_entries = {} labels = [ ('PROLE_HOME', 'Base directory for data, configs, logs, and scripts'), ('PROLE_CONF', 'Configuration directory'), ('PROLE_DATA', 'Data directory'), ('PROLE_LOGS', 'Logs directory'), ('PROLE_SERVICE', 'System scripts directory'), ] x_label = 48 x_field = 300 x_btn = 850 y = 280 row_h = 75 def browse(var_name: str): from tkinter import filedialog initial = self._env_entries[var_name].get() or Path.home() path = filedialog.askdirectory(title=f"Select {var_name}", initialdir=str(Path(initial).expanduser())) if path: self._env_entries[var_name].delete(0, 'end') self._env_entries[var_name].insert(0, path) if var_name == 'PROLE_HOME': # Update dependent defaults if they still match old pattern self._update_dependent_env_paths() # Draw rows for key, help_text in labels: self._canvas_items.append(ui.canvas_text(self, x_label, y, key, fill='black', font=('SF Pro Text', 12, 'bold'), anchor='w')) # Use tk.Entry and place on canvas via create_window entry = tk.Entry(self.bg_canvas, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) entry.insert(0, values[key]) entry_window = self.bg_canvas.create_window(x_field, y, window=entry, anchor='w', width=500, height=32) self._canvas_items.append(entry_window) self._overlay_widgets.append(entry) self._env_entries[key] = entry # Use tk.Button and place on canvas via create_window btn = tk.Button(self.bg_canvas, text='Browse…', command=lambda k=key: browse(k), bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=10, pady=4) btn_window = self.bg_canvas.create_window(x_btn, y, window=btn, anchor='w', width=120) self._canvas_items.append(btn_window) self._overlay_widgets.append(btn) # hint text self._canvas_items.append(ui.canvas_text(self, x_field, y+24, help_text, fill='#6e6e73', font=('SF Pro Text', 10), anchor='nw')) y += row_h # When PROLE_HOME changes, update dependent paths that still follow default pattern def on_home_change(_evt=None): self._update_dependent_env_paths() try: self._env_entries['PROLE_HOME'].bind('', on_home_change) except Exception: pass def _after_env_saved(self): """Reload the installer process environment after saving env.sh.""" try: self.reload_env_from_shell() # Ensure we capture PROLE_HOME in Global section self.prole_cfg_data['Global']['PROLE_HOME'] = os.environ.get('PROLE_HOME', '') self._save_prole_cfg() except Exception as e: try: messagebox.showwarning('Environment', f'Environment saved, but reload failed: {e}') except Exception: pass def _collect_input_snapshot(self) -> dict: """Collect all possible user inputs for prole.cfg replay.""" inputs: dict[str, str] = {} def _set(key: str, val): inputs[key] = '' if val is None else str(val) def _set_bool(key: str, val: bool): inputs[key] = _bool_str(bool(val)) def _get_var(var, default=''): try: return var.get() except Exception: return default def _action(key: str, default: bool): return bool(self._action_flags.get(key, default)) # Dependencies _set_bool('dependencies.verify_all', _get_var(self.verify_mode, False)) _set_bool('dependencies.auto_install_missing', _action('dependencies.auto_install_missing', DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True))) for dep in self.dependencies: dep_key = f"dependencies.{dep['id']}.install" _set_bool(dep_key, _action(dep_key, True)) # Network scan _set_bool('network_scan.run', _action('network_scan.run', DEFAULT_ACTION_FLAGS.get('network_scan.run', True))) # Environment setup values env_vals = {} try: env_vals.update(self._env_defaults()) except Exception: pass try: env_vals.update(self._read_existing_env()) except Exception: pass for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): if os.environ.get(k): env_vals[k] = os.environ.get(k) if hasattr(self, '_env_entries'): for k, ent in self._env_entries.items(): try: v = ent.get().strip() if v: env_vals[k] = v except Exception: pass for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'): _set(f'env_setup.{k}', env_vals.get(k, '')) ns_val = (str(_get_var(self.db_namespace, '')).strip()) if hasattr(self, 'db_namespace') else '' if not ns_val: ns_val = env_vals.get('NAMESPACE', '') _set('env_setup.NAMESPACE', ns_val) # Database creation _set('init_password.db_namespace', ns_val) _set('init_password.db_username', _get_var(self.db_username, '')) db_pw = _get_var(self.db_password, '') db_pw_confirm = _get_var(self.db_password_confirm, '') or db_pw db_pw_cfg = self._secret_cfg_value('Inputs', 'init_password.db_password', db_pw, 'db', 'password') _set('init_password.db_password', db_pw_cfg) _set('init_password.db_password_confirm', db_pw_cfg or db_pw_confirm) _set('init_password.db_host_port', _get_var(self.db_host_port, '5432')) _set_bool('init_password.generate_ssh_key', _action('init_password.generate_ssh_key', DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True))) # Build DB image _set_bool('init_db_build.run_build', _action('init_db_build.run_build', DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True))) # Cluster init + optional features _set('init_cluster.cluster_env', _get_var(self.cluster_env, '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 _pf_extract_field(self, mapping_str: str, field: str) -> str: if not mapping_str or not field: return '' needle = f"{field}=" for part in str(mapping_str).split(';'): part = part.strip() if part.startswith(needle): return part[len(needle):].strip() return '' def _validate_port_forward_overlaps(self) -> bool: try: self._sync_port_forward_mappings() except Exception: pass try: mode = self._deployment_mode() except Exception: mode = '' if not mode: try: mode = _deployment_mode_from_env(self.cluster_env.get()) except Exception: mode = '' if not mode: mode = 'k3d' prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" pf_section = self.prole_cfg_data.get('Port Forwards', {}) or {} port_index = {} for key, mapping in pf_section.items(): if not key.startswith(prefix): continue host_port = self._pf_extract_field(mapping, 'hostPort') if not host_port: continue mapping_id = _pf_extract_id(mapping) target = self._pf_extract_field(mapping, 'target') namespace = self._pf_extract_field(mapping, 'namespace') label = mapping_id or target or key if namespace and target: label = f"{label} ({namespace}/{target})" port_index.setdefault(host_port, []).append(label) collisions = {port: labels for port, labels in port_index.items() if len(labels) > 1} if not collisions: return True lines = [] for port, labels in sorted(collisions.items(), key=lambda item: int(item[0]) if str(item[0]).isdigit() else item[0]): lines.append(f"{port}: {', '.join(sorted(labels))}") try: messagebox.showerror( "Port Collision", "Port forward mappings overlap:\n\n" + "\n".join(lines) + "\n\nPlease choose unique host ports before continuing." ) except Exception: pass return False def _show_port_error(self, port: int): """Show error message when port is in use, with option to see lsof output.""" msg = f"Port {port} is already in use on the local host.\n\nPlease choose a different port or stop the service using it." dialog = tk.Toplevel(self.root) dialog.title("Port In Use") dialog.geometry("450x200") dialog.configure(bg='white') dialog.transient(self.root) dialog.grab_set() tk.Label(dialog, text="Port Conflict", font=('SF Pro Text', 14, 'bold'), bg='white', fg='#ff3b30').pack(pady=(20, 10)) tk.Label(dialog, text=msg, font=('SF Pro Text', 11), bg='white', fg='black', wraplength=400).pack(pady=10) btn_frame = tk.Frame(dialog, bg='white') btn_frame.pack(pady=20) def show_lsof(): log_file = PROJECT_ROOT / "logs" / f"port_{port}_lsof.log" log_file.parent.mkdir(parents=True, exist_ok=True) try: res = subprocess.run(['lsof', '-i', f':{port}'], capture_output=True, text=True) content = res.stdout if res.stdout else f"No lsof output for port {port}. (Maybe permission denied?)" if res.stderr: content += "\n\nError:\n" + res.stderr log_file.write_text(content) # Open the log file if platform.system() == 'Darwin': subprocess.run(['open', str(log_file)]) elif platform.system() == 'Windows': os.startfile(str(log_file)) else: subprocess.run(['xdg-open', str(log_file)]) except Exception as e: messagebox.showerror("Error", f"Failed to run lsof: {e}") tk.Button(btn_frame, text="Show Logs (lsof)", command=show_lsof, bg='#F5F5DC', relief='flat', padx=10).pack(side='left', padx=10) tk.Button(btn_frame, text="OK", command=dialog.destroy, bg='#007aff', fg='white', relief='flat', padx=20).pack(side='left', padx=10) def _secret_namespace(self) -> str: ns = '' try: ns = (self.db_namespace.get() or '').strip() except Exception: ns = '' if not ns: ns = os.environ.get('NAMESPACE', '') or 'default' return ns def _secret_cfg_value(self, section: str, key: str, plaintext: str, leaf: str, bao_key: str) -> str: if self._secrets_finalized: return _openbao_placeholder(self._secret_namespace(), leaf, bao_key) if not plaintext: return '' if _is_openbao_ref(plaintext) or _is_prole_secret(plaintext): return plaintext # Try to reuse existing encrypted value if it decrypts to the same plaintext existing = (self.prole_cfg_data.get(section, {}) or {}).get(key) if existing and _is_prole_secret(existing): try: if _decrypt_prole_secret(existing) == plaintext: return existing except Exception: pass try: return _encrypt_prole_secret(plaintext) except Exception: return plaintext def _load_secret_cache_from_cfg(self): try: if self._cfg_path_override is not None: cfg_path = self._cfg_path_override if cfg_path.is_dir(): cfg_path = cfg_path / 'prole.cfg' else: conf_dir = self._resolve_prole_conf_dir() cfg_path = conf_dir / 'prole.cfg' except Exception: return if not cfg_path.exists(): return cfg = configparser.ConfigParser(interpolation=None) cfg.optionxform = str cfg.read(cfg_path) for (section, key), _spec in SECRET_KEY_SPECS.items(): if cfg.has_option(section, key): val = cfg.get(section, key, fallback="").strip() if val: self._cfg_secret_cache[(section, key)] = val if _is_openbao_ref(val): self._secrets_finalized = True def _resolve_cached(pairs: list[tuple[str, str]]) -> str: for pair in pairs: val = self._cfg_secret_cache.get(pair, '') if val: return val return '' db_val = _resolve_cached([ ("Inputs", "init_password.db_password"), ("Global", "DB_PASSWORD"), ]) if db_val and _is_prole_secret(db_val): db_plain = _decrypt_prole_secret(db_val) elif db_val and not _is_openbao_ref(db_val): db_plain = db_val else: db_plain = '' if db_plain: try: self.db_password.set(db_plain) self.db_password_confirm.set(db_plain) except Exception: pass krb_val = _resolve_cached([ ("Inputs", "kerberos_config.password"), ("Kerberos Authentication", "PASSWORD"), ]) if krb_val and _is_prole_secret(krb_val): krb_plain = _decrypt_prole_secret(krb_val) elif krb_val and not _is_openbao_ref(krb_val): krb_plain = krb_val else: krb_plain = '' if krb_plain: try: self.kerberos_password.set(krb_plain) except Exception: pass def _sanitize_sections_for_cfg(self, sections: dict) -> dict: sanitized = {k: dict(v) for k, v in sections.items()} # Kerberos password if "Kerberos Authentication" in sanitized: val = sanitized["Kerberos Authentication"].get("PASSWORD", "") if val or ("Kerberos Authentication", "PASSWORD") in self._cfg_secret_cache: sanitized["Kerberos Authentication"]["PASSWORD"] = self._secret_cfg_value( "Kerberos Authentication", "PASSWORD", val, "kerberos", "password" ) # Monitoring (Grafana) if "Monitoring" in sanitized: val = sanitized["Monitoring"].get("GRAFANA_ADMIN_PASSWORD", "") if val or ("Monitoring", "GRAFANA_ADMIN_PASSWORD") in self._cfg_secret_cache: sanitized["Monitoring"]["GRAFANA_ADMIN_PASSWORD"] = self._secret_cfg_value( "Monitoring", "GRAFANA_ADMIN_PASSWORD", val, "monitoring", "grafana_admin_password" ) return sanitized def _save_ansible_prole_vault(self, root_password: str): if not root_password: return vault_path = PROJECT_ROOT / 'infrastructure' / 'inventory' / 'group_vars' / 'all' / 'prole_vault.yml' _ensure_ansible_vault_credentials(prompt_ui=False) vault_pass_file = os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE') if not vault_pass_file or not Path(vault_pass_file).exists(): for base in (Path.cwd(), PROJECT_ROOT): candidate = base / '.vault_pass' if candidate.is_file(): vault_pass_file = str(candidate) break if not vault_pass_file or not Path(vault_pass_file).exists(): return if shutil.which('ansible-vault') is None: return try: # We must decrypt if it is a prole secret plain_pass = root_password if _is_prole_secret(root_password): try: plain_pass = _decrypt_prole_secret(root_password) except Exception: pass content = f"prole_root_password: \"{plain_pass}\"\n" with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: f.write(content) tmp_name = f.name try: os.chmod(tmp_name, 0o600) subprocess.run([ 'ansible-vault', 'encrypt', tmp_name, '--vault-password-file', vault_pass_file ], check=True, capture_output=True) shutil.move(tmp_name, str(vault_path)) os.chmod(str(vault_path), 0o600) finally: if os.path.exists(tmp_name): try: os.unlink(tmp_name) except: pass except Exception as e: print(f"[ERROR] Failed to save ansible vault: {e}") def _sync_port_forward_mappings(self): try: mode = self._deployment_mode() except Exception: mode = '' if not mode: try: mode = _deployment_mode_from_env(self.cluster_env.get()) except Exception: mode = '' if not mode: mode = 'k3d' prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" pf_section = self.prole_cfg_data.get('Port Forwards', {}) if pf_section is None: pf_section = {} try: service_ns = (self._get_service_namespace() or '').strip() except Exception: service_ns = '' if not service_ns: service_ns = 'default' argocd_ns = (self.prole_cfg_data.get('Global', {}) or {}).get('ARGOCD_NAMESPACE', '').strip() if not argocd_ns: argocd_ns = (os.environ.get('ARGOCD_NAMESPACE') or '').strip() if not argocd_ns: argocd_ns = 'argocd' try: db_ns = (self.db_namespace.get() or '').strip() except Exception: db_ns = '' if not db_ns: db_ns = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip() if not db_ns: db_ns = (os.environ.get('NAMESPACE') or '').strip() if not db_ns: db_ns = 'default' try: db_host_port = (self.db_host_port.get() or '').strip() except Exception: db_host_port = '' if not db_host_port: db_host_port = (self.prole_cfg_data.get('Global', {}) or {}).get('DB_HOST_PORT', '').strip() if not db_host_port: db_host_port = '5432' supabase_enabled = False try: supabase_enabled = bool(self.supabase_enabled.get()) except Exception: supabase_enabled = False supabase_ns = (os.environ.get('SUPABASE_NAMESPACE') or '').strip() if not supabase_ns: supabase_ns = (self.prole_cfg_data.get('Supabase', {}) or {}).get('NAMESPACE', '').strip() if not supabase_ns: supabase_ns = 'supabase' mappings = _build_required_port_forwards( mode=mode, service_ns=service_ns, argocd_ns=argocd_ns, db_ns=db_ns, db_host_port=db_host_port, supabase_enabled=supabase_enabled, supabase_namespace=supabase_ns, ) for mapping in mappings: _pf_upsert_mapping(pf_section, prefix, mapping) self.prole_cfg_data['Port Forwards'] = pf_section self._update_legacy_port_mapping() def _update_legacy_port_mapping(self): conf_dir = PROJECT_ROOT / 'conf' mapping_path = conf_dir / 'port-mapping.cfg' pf_section = self.prole_cfg_data.get('Port Forwards', {}) if pf_section is None: pf_section = {} try: mode = self._deployment_mode() except Exception: mode = 'k3d' prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" lines = [ "# Port mappings for Prole Tools (generated).", "# Format: key: local=... remote=... ns=... svc=... address=...", "" ] # We want to maintain some order or just dump them for k, v in sorted(pf_section.items()): if k.startswith(prefix): # Parse the mapping string: id=...;namespace=...;target=...;address=...;hostPort=...;servicePort=...;protocol=...;description=... parts = {} for p in v.split(';'): if '=' in p: key_val = p.split('=', 1) if len(key_val) == 2: parts[key_val[0].strip()] = key_val[1].strip() if 'id' in parts: m_id = parts['id'] local = parts.get('hostPort', '') remote = parts.get('servicePort', '') ns = parts.get('namespace', '') target = parts.get('target', '') addr = parts.get('address', '0.0.0.0') svc = target if svc.startswith('svc/'): svc = svc[4:] lines.append(f"{m_id}: local={local} remote={remote} ns={ns} svc={svc} address={addr}") try: mapping_path.write_text('\n'.join(lines) + '\n') print(f"[DEBUG] port-mapping.cfg updated at {mapping_path}") except Exception as e: print(f"[ERROR] Failed to update port-mapping.cfg: {e}") def _save_prole_cfg(self): """Generates prole.cfg; master configuration file containing all values used in install process.""" try: # Check port availability before saving # try: # port_val = int(self.db_host_port.get().strip()) # if not self._is_port_available(port_val): # self._show_port_error(port_val) # return # except ValueError: # messagebox.showerror("Invalid Port", "Please enter a valid numeric port.") # return # Determine path: conf/prole.cfg # If PROLE_CONF is set, use it. Otherwise fallback to PROJECT_ROOT/conf if self._cfg_path_override is not None: cfg_path = self._cfg_path_override if cfg_path.is_dir(): conf_dir = cfg_path cfg_path = conf_dir / 'prole.cfg' else: conf_dir = cfg_path.parent else: prole_conf = os.environ.get('PROLE_CONF') if prole_conf: conf_dir = Path(prole_conf).expanduser() else: conf_dir = PROJECT_ROOT / 'conf' os.environ.setdefault('PROLE_CONF', str(conf_dir)) cfg_path = conf_dir / 'prole.cfg' conf_dir.mkdir(parents=True, exist_ok=True) # Identify global candidates mode = self._deployment_mode() target_label = _deployment_target_label(self.cluster_env.get()) globals_to_save = { 'PROLE_HOME': os.environ.get('PROLE_HOME', ''), 'PROLE_DB_USER': self.db_username.get(), 'DB_PASSWORD': self._secret_cfg_value('Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password'), 'CLUSTER_ENV': self.cluster_env.get(), 'DEPLOYMENT_MODE': mode, 'DEPLOYMENT_TARGET': target_label, 'NAMESPACE': (self.db_namespace.get() or '').strip(), 'DB_HOST_PORT': (self.db_host_port.get() or '5432').strip(), 'PROLE_OPENTOFU_URL': _default_opentofu_pipeline_url(), } # Add any other values already in self.prole_cfg_data['Global'] globals_to_save.update(self.prole_cfg_data.get('Global', {})) globals_to_save['DB_PASSWORD'] = self._secret_cfg_value( 'Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password' ) globals_to_save['DEPLOYMENT_MODE'] = mode globals_to_save['DEPLOYMENT_TARGET'] = target_label globals_to_save['PROLE_K3S_SERVER'] = (self.k3s_server_url.get() or '').strip() globals_to_save['PROLE_K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '') globals_to_save['SERVICE_NAMESPACE'] = self._get_service_namespace() self._sync_port_forward_mappings() # Inputs section (replayable UI inputs) try: inputs = self._collect_input_snapshot() except Exception: inputs = {} sections = {k: self.prole_cfg_data.get(k, {}) for k in [ 'Welcome', 'Dependencies', 'Network', 'System Environment', 'Monitoring', 'Kerberos Authentication', 'Ollama', 'Optional Features', 'Database Creation', 'Initialize Cluster', 'Docker Build', 'Initialization Scripts', 'Deployment', 'Install' ]} deployment_section = dict(sections.get('Deployment', {})) if mode: deployment_section.setdefault('MODE', mode) if target_label: deployment_section.setdefault('TARGET', target_label) sections['Deployment'] = deployment_section sections['Dev Cluster (k3d)'] = { **self.prole_cfg_data.get('Dev Cluster (k3d)', {}), 'MODE': 'k3d', 'CLUSTER_ENV': '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: if _pf_extract_id(mapping) == "registry": return self._add_port_mapping(mapping) def _update_dependent_env_paths(self): try: new_home = Path(self._env_entries['PROLE_HOME'].get()).expanduser() except Exception: return mapping = { 'PROLE_CONF': new_home / 'conf', 'PROLE_DATA': new_home / 'data', 'PROLE_LOGS': new_home / 'logs', 'PROLE_SERVICE': new_home / 'etc', } # Update entries if they are empty or previously matched the old base for key, new_path in mapping.items(): ent = self._env_entries.get(key) if not ent: continue cur = ent.get().strip() if not cur: ent.delete(0, 'end') ent.insert(0, str(new_path)) continue # If cur looked like old_home/, update it try: # detect suffix suffix = new_path.name if cur.endswith('/' + suffix): # replace base path ent.delete(0, 'end') ent.insert(0, str(new_path)) except Exception: pass # done def _render_title(self, text, y=40): ui.render_title(self, text, y) def _render_paragraph(self, text, y, wrap=600): ui.render_paragraph(self, text, y, wrap) def _create_console_output(self, y=280, title="Scan Output", width=880, height=450): """Create a standardized console output area with a label and scrollable text.""" # Section title/label for the console ui.canvas_text(self, 48, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) y_console = y + 30 # Use a background frame to ensure NO borders are visible around the console console_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) console.pack(fill='both', expand=True, padx=1, pady=1) console_window = self.bg_canvas.create_window(48, y_console, window=console_bg, anchor='nw', width=width, height=height) self._canvas_items.append(console_window) self._overlay_widgets.append(console_bg) self._overlay_widgets.append(console) return console def _render_welcome_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') # Welcome title self._render_title('Welcome', y=150) welcome_text = ( "This installer will guide you through the process of setting up the Prole Database and its supporting " "infrastructure. We have designed this process to be as automated as possible, ensuring that your " "deployment is secure, efficient, and tailored to your specific network environment.\n\n" "What to expect:\n" "• Network Environment Discovery: We'll scan for existing services like Active Directory and DNS.\n" "• System Configuration: Setting up local paths and environment variables.\n" "• Dependency Management: Ensuring all required tools (Docker, k3d, etc.) are ready.\n" "• Database Initialization: Configuring passwords, Kerberos authentication, and deploying the database cluster.\n\n" "We are excited to have you join our community and start building with us. " "Welcome to the neighborhood! Let's get started by preparing your system for the Prole experience." ) ui.canvas_text(self, 48, 220, welcome_text, fill='black', font=('SF Pro Text', 13), width=750) # Ensure footer is updated (Next button visible) self.update_footer() def _render_network_scan_page(self): # Letterhead at top right (matching welcome screen theme) content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Network Configuration Scan', y=150) self._render_paragraph("The network prole-agent will detect Kerberos services, Active Directory controllers, and other configuration details required for deployment.", y=210) y = 280 self.scan_status_var = tk.StringVar(value="Ready to prole-agent") # Ready to scan text (drawn on canvas) status_item = ui.canvas_text(self, 48, y, "Ready to prole-agent", fill='black', font=('SF Pro Text', 12)) # Link variable to canvas text def update_status_text(*args): try: self.bg_canvas.itemconfig(status_item, text=self.scan_status_var.get()) except Exception: pass self.scan_status_var.trace_add('write', update_status_text) # Standardized Console Output self.scan_results_console = self._create_console_output(y=320, title="Scan Output", width=880, height=380) self.scan_results_text = self.scan_results_console.text if getattr(self, 'ansible_topology_summary', ''): self.scan_results_console.write(self.ansible_topology_summary + "\n") self.scan_results_console.write("Ansible topology loaded; network scan can refine detection.\n") y = 740 # Start Network Scan Button (placed on canvas) self.scan_btn = tk.Button(self.bg_canvas, text="Start Network Scan", command=self._run_network_scan, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=20, pady=10) self.btn_window = self.bg_canvas.create_window(48, y, window=self.scan_btn, anchor='nw') self._overlay_widgets.append(self.scan_btn) self._canvas_items.append(self.btn_window) # Prepare animation frames if not GLOBAL_SCAN_FRAMES: try: from PIL import Image, ImageTk gif_path = get_resource_path('img/prole-type.gif') if gif_path.exists(): gif = Image.open(str(gif_path)) max_frames = 120 try: while len(GLOBAL_SCAN_FRAMES) < max_frames: # Create a copy and resize frame = gif.copy().convert('RGBA') frame.thumbnail((24, 24), Image.LANCZOS) GLOBAL_SCAN_FRAMES.append(ImageTk.PhotoImage(frame)) gif.seek(len(GLOBAL_SCAN_FRAMES)) except EOFError: pass except Exception as e: print(f"Error loading scan animation: {e}") self._scan_frames = GLOBAL_SCAN_FRAMES y += 60 # Hint about auto-fill autofill_msg = "Scan results will auto-fill Kerberos and Environment settings." self._canvas_items.append(ui.canvas_text(self, 48, y, autofill_msg, fill='#6e6e73', font=('SF Pro Text', 10, 'italic'))) def _animate_scan_button(self, frame_idx=0): if not getattr(self, '_scan_running', False) or not self._scan_frames: if hasattr(self, 'scan_btn'): self.scan_btn.config(image='', compound='none') return self.scan_btn.config(image=self._scan_frames[frame_idx], compound='left') next_idx = (frame_idx + 1) % len(self._scan_frames) self.root.after(100, lambda: self._animate_scan_button(next_idx)) def _run_network_scan(self): if getattr(self, '_scan_running', False): return self._action_flags['network_scan.run'] = True self._scan_running = True self._scan_kdc_value = None ansible_kdc = '' try: ansible_kdc = (self.ansible_topology or {}).get('kdc_ip') or '' if not ansible_kdc: ansible_kdc = (self.prole_cfg_data.get('Network', {}) or {}).get('KDC_ANSIBLE_DETECTED', '') except Exception: ansible_kdc = '' self.scan_results_console.clear() self.scan_results_console.write("Initializing network scan using prole-net/prole-agent ...\n") self.scan_status_var.set("Scanning...") # Start animation if self._scan_frames: self._animate_scan_button() def worker(): try: # Use the new scan binary scan_binary = get_resource_path("prole-net/prole-agent") if not scan_binary.exists(): self.safe_after(lambda: self.scan_results_console.write(f"Scan binary not found at {scan_binary}\n")) self.safe_after(lambda: self.scan_status_var.set("Scan failed")) self._scan_running = False return # Create writable scan directory for output/cache # This avoids issues with PyInstaller's read-only _MEIPASS directory prole_home = Path.home() / ".prole" scan_dir = prole_home / "scan" scan_dir.mkdir(parents=True, exist_ok=True) # Run scan from writable directory with 10s timeout and summary analysis # Set bufsize=0 for truly unbuffered binary stream reading # Ensure PYTHONUNBUFFERED=1 is set in case prole-agent is/uses Python env = os.environ.copy() env['PYTHONUNBUFFERED'] = '1' process = subprocess.Popen([str(scan_binary), "-t", "10"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, env=env, cwd=str(scan_dir)) # Capture output in real-time using larger reads to avoid overhead fd = process.stdout.fileno() line_buffer = [] last_ui_update = 0 pending_output = [] def maybe_set_kdc(ip: str): if not ip: return if ansible_kdc: return if self._scan_kdc_value: return self._scan_kdc_value = ip self.safe_after(lambda i=ip: self.kerberos_kdc.set(i)) self.safe_after(lambda: self.kerberos_enabled.set(True)) while True: try: # Use a smaller chunk size to encourage more frequent reads chunk_bytes = os.read(fd, 1024) except (EOFError, OSError): chunk_bytes = b'' if not chunk_bytes: if process.poll() is not None: break # If we have pending output, flush it before sleeping if pending_output: text_to_flush = "".join(pending_output) pending_output = [] self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) last_ui_update = time.time() time.sleep(0.01) continue text = chunk_bytes.decode('utf-8', errors='replace') pending_output.append(text) # Update more frequently if we have enough data or enough time has passed now = time.time() if now - last_ui_update > 0.05 or sum(len(x) for x in pending_output) > 2048: text_to_flush = "".join(pending_output) pending_output = [] self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) last_ui_update = now # Accumulate for line parsing (for AD/KDC detection) for char in text: line_buffer.append(char) if char == '\n': line = "".join(line_buffer) line_buffer = [] # Parse "KDC is: IP" from the new summary analysis format if "KDC is:" in line: try: ip_part = line.split("KDC is:")[1].strip() ip = ip_part.split()[0].strip('[]():,') if ip: maybe_set_kdc(ip) except (IndexError, ValueError): pass elif "Active Directory" in line or "88" in line: parts = line.split() for part in parts: try: socket.inet_aton(part.strip('[]():,')) ip = part.strip('[]():,') maybe_set_kdc(ip) break except socket.error: continue process.wait() # Final flush of any remaining output if pending_output: text_to_flush = "".join(pending_output) self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t)) if process.returncode == 0: self.safe_after(lambda: self.scan_status_var.set("Scan complete")) else: self.safe_after(lambda: self.scan_status_var.set(f"Scan failed (code {process.returncode})")) self._scan_running = False except Exception as e: self.safe_after(lambda: self.scan_results_console.write(f"Scan error: {str(e)}\n")) self.safe_after(lambda: self.scan_status_var.set("Scan failed")) self._scan_running = False threading.Thread(target=worker, daemon=True).start() def _render_kerberos_config_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Kerberos Authentication', y=150) self._render_paragraph("Configure Kerberos authentication for Prole and the PostgreSQL database. Tests run inside the Kubernetes namespace to validate against your realm.", y=200) y = 280 # Use tk.Checkbutton on canvas enable_cb = tk.Checkbutton(self.bg_canvas, text="Enable Kerberos Authentication", variable=self.kerberos_enabled, bg='white', fg='black', activebackground='white', selectcolor='white', font=('SF Pro Text', 11)) cb_window = self.bg_canvas.create_window(48, y, window=enable_cb, anchor='nw') self._canvas_items.append(cb_window) self._overlay_widgets.append(enable_cb) x_field = 200 field_w = 400 y += 60 ui.canvas_text(self, 48, y, "Kerberos Realm:", fill='black', font=('SF Pro Text', 12)) realm_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_realm, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) realm_window = self.bg_canvas.create_window(x_field, y-4, window=realm_entry, anchor='nw', width=field_w, height=32) self._canvas_items.append(realm_window) self._overlay_widgets.append(realm_entry) y += 48 ui.canvas_text(self, 48, y, "KDC Host/IP:", fill='black', font=('SF Pro Text', 12)) kdc_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_kdc, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) kdc_window = self.bg_canvas.create_window(x_field, y-4, window=kdc_entry, anchor='nw', width=field_w, height=32) self._canvas_items.append(kdc_window) self._overlay_widgets.append(kdc_entry) y += 48 ui.canvas_text(self, 48, y, "Username:", fill='black', font=('SF Pro Text', 12)) user_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_user, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) user_window = self.bg_canvas.create_window(x_field, y-4, window=user_entry, anchor='nw', width=field_w, height=32) self._canvas_items.append(user_window) self._overlay_widgets.append(user_entry) y += 48 ui.canvas_text(self, 48, y, "Password:", fill='black', font=('SF Pro Text', 12)) pass_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_password, show="*", bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) pass_window = self.bg_canvas.create_window(x_field, y-4, window=pass_entry, anchor='nw', width=field_w, height=32) self._canvas_items.append(pass_window) self._overlay_widgets.append(pass_entry) y += 60 # Use tk.Button on canvas test_btn = tk.Button(self.bg_canvas, text="Test Connection", command=self.test_kerberos_connection, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) btn_window = self.bg_canvas.create_window(48, y, window=test_btn, anchor='nw') self._canvas_items.append(btn_window) self._overlay_widgets.append(test_btn) y += 60 # Use tk.Text self.kerberos_status_text = tk.Text(self.bg_canvas, width=100, height=12, font=('Menlo', 10), bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat') status_window = self.bg_canvas.create_window(48, y, window=self.kerberos_status_text, anchor='nw', width=800, height=200) self._canvas_items.append(status_window) self._overlay_widgets.append(self.kerberos_status_text) def _ollama_current_key(self) -> str: host = (self.ollama_server_host.get() or '').strip() port = (self.ollama_server_port.get() or '').strip() or DEFAULT_OLLAMA_PORT if not host: return '' return f"{host}:{port}" def _set_ollama_model_values(self, models: list[str]): combo = getattr(self, '_ollama_model_combo', None) if not combo: return if getattr(self, '_refreshing_ollama_table', False): return values = models or [] try: combo.configure(values=values) except Exception: pass current = (self.ollama_model.get() or '').strip() if values and (not current or current not in values): self.ollama_model.set(values[0]) def _render_ollama_table_from_cache(self): rows = list(getattr(self, '_ollama_servers_cache', []) or []) notice = getattr(self, '_ollama_last_notice', '') self._update_ollama_table(rows, notice) def _update_ollama_table(self, rows: list[dict], notice: str = ''): tree = getattr(self, '_ollama_server_tree', None) if not tree: return self._ollama_servers_cache = rows # Map of key -> list[str] available models self._ollama_model_options = {} # Map of key -> selected default model if not hasattr(self, '_ollama_default_model_by_key'): self._ollama_default_model_by_key = {} self._ollama_last_notice = notice or '' for item in tree.get_children(): tree.delete(item) current_key = self._ollama_current_key() for row in rows: host = row.get('host', '') port = row.get('port', '') or DEFAULT_OLLAMA_PORT models = row.get('models', []) or [] key = f"{host}:{port}" self._ollama_model_options[key] = models # Initialize default selection if missing if key not in self._ollama_default_model_by_key: self._ollama_default_model_by_key[key] = (models[0] if models else '') prefix = ' [✓] ' if key == current_key else ' [ ] ' models_preview = ', '.join(models) if models else '—' default_model = self._ollama_default_model_by_key.get(key, '') or '—' try: tree.insert('', 'end', values=(prefix, host, port, default_model, models_preview)) except Exception: # Fallback if columns not updated yet tree.insert('', 'end', values=(prefix, host, port, models_preview)) if current_key: for item in tree.get_children(): vals = tree.item(item, 'values') if vals and f"{vals[1]}:{vals[2]}" == current_key: tree.selection_set(item) tree.see(item) break note_item = getattr(self, '_ollama_note_item', None) if note_item and self.bg_canvas.winfo_exists(): msg = notice if notice else "" self.bg_canvas.itemconfig(note_item, text=msg) models = self._ollama_model_options.get(current_key, []) if models: self._set_ollama_model_values(models) def _refresh_ollama_table(self): if getattr(self, '_refreshing_ollama_table', False): return self._refreshing_ollama_table = True self._ollama_model_options = {} if getattr(self, '_ollama_status_var', None): self._ollama_status_var.set("Scanning...") def worker(): notice = '' rows: list[dict] = [] try: script_path = PROJECT_ROOT / "etc" / "init_ollama.sh" if not script_path.exists(): notice = f"Missing script: {script_path}" self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) else: env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) rc, out = self._run_cmd_capture(['bash', str(script_path), 'scan'], env=env) if rc == 0: for line in (out or '').splitlines(): line = line.strip() if not line or line.startswith('#'): continue parts = [p.strip() for p in line.split('\t')] if len(parts) < 2: continue host = parts[0] port = parts[1] or DEFAULT_OLLAMA_PORT model_list: list[str] = [] if len(parts) > 2 and parts[2]: model_list = [m.strip() for m in parts[2].split(',') if m.strip() and m.strip() != '-'] rows.append({'host': host, 'port': port, 'models': model_list}) # Deduplicate by canonical host:port while preferring hostname labels if rows: def _is_ip(name: str) -> bool: if not name: return False return bool(re.match(r'^(?:\d{1,3}\.){3}\d{1,3}$', name)) or ':' in name def _canon_host(name: str) -> str: n = (name or '').strip().lower() if n in {'localhost', '127.0.0.1', '::1', 'k3d.local'}: return '127.0.0.1' try: # Prefer IPv4 return socket.gethostbyname(n) except Exception: return n def _label_rank(name: str) -> int: n = (name or '').strip().lower() if n == 'k3d.local': return 0 if not _is_ip(n) and n not in {'localhost'}: return 1 # other hostnames preferred after k3d.local if n == 'localhost': return 2 return 3 # IPs last merged: dict[tuple[str, str], dict] = {} for r in rows: disp_host = r.get('host', '') port = str(r.get('port', '') or DEFAULT_OLLAMA_PORT) canon = _canon_host(disp_host) key = (canon, port) cur = merged.get(key) if not cur: merged[key] = { 'host': disp_host, # display label 'port': port, 'models': list(r.get('models', []) or []) } else: # Merge models existing = set(cur.get('models', []) or []) for m in (r.get('models', []) or []): if m not in existing: cur['models'].append(m) existing.add(m) # Pick better display host old_label = cur.get('host', '') if _label_rank(disp_host) < _label_rank(old_label): cur['host'] = disp_host # Convert back to list and sort by host rows = sorted(merged.values(), key=lambda d: (d.get('host') or '')) if rows: notice = f"Detected {len(rows)} server(s)." self.safe_after(lambda: self._ollama_status_var.set("Scan complete") if getattr(self, '_ollama_status_var', None) else None) else: notice = "No Ollama servers detected." self.safe_after(lambda: self._ollama_status_var.set("No servers found") if getattr(self, '_ollama_status_var', None) else None) else: notice = "Scan failed. See logs for details." self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) except Exception as e: notice = f"Scan error: {e}" self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None) finally: def finish(): self._refreshing_ollama_table = False self._update_ollama_table(rows, notice) self.safe_after(finish) threading.Thread(target=worker, daemon=True).start() def _save_ollama_config(self): raw_host = (self.ollama_server_host.get() or '').strip() raw_port = (self.ollama_server_port.get() or '').strip() raw_model = (self.ollama_model.get() or '').strip() host = raw_host port = raw_port if raw_host: parsed_host, parsed_port = _parse_ollama_host(raw_host) if parsed_host: host = parsed_host if not port and parsed_port: port = parsed_port if host and not port: port = DEFAULT_OLLAMA_PORT if host and host != raw_host: self.ollama_server_host.set(host) if port and port != raw_port: self.ollama_server_port.set(port) data = self.prole_cfg_data.setdefault('Ollama', {}) if host: data['OLLAMA_SERVER_HOST'] = host data['OLLAMA_SERVER_PORT'] = port data['OLLAMA_HOST'] = _format_ollama_host(host, port) else: data.pop('OLLAMA_SERVER_HOST', None) data.pop('OLLAMA_SERVER_PORT', None) data.pop('OLLAMA_HOST', None) if raw_model: data['OLLAMA_MODEL'] = raw_model else: data.pop('OLLAMA_MODEL', None) self._save_prole_cfg() if getattr(self, '_ollama_status_var', None): self._ollama_status_var.set("Saved") def _render_ollama_config_page(self): # Letterhead at top right (matching welcome screen theme) content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Ollama Server', y=150) self._render_paragraph( "Detect and select a network Ollama server. The selected host, port, and model are saved to prole.cfg " "and exported as OLLAMA_* for k3s hosts and services.", y=200 ) x_label = 48 x_field = 300 y = 270 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Host:', fill='black', font=('SF Pro Text', 12, 'bold'))) host_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_host, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) host_window = self.bg_canvas.create_window(x_field, y-12, window=host_entry, anchor='nw', width=400, height=32) self._canvas_items.append(host_window) self._overlay_widgets.append(host_entry) y += 42 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Port:', fill='black', font=('SF Pro Text', 12, 'bold'))) port_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_port, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=120, height=32) self._canvas_items.append(port_window) self._overlay_widgets.append(port_entry) y += 42 # Removed global model selector; model is now selected per-server row save_btn = tk.Button(self.bg_canvas, text='Save', command=self._save_ollama_config, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=16, pady=6) save_window = self.bg_canvas.create_window(x_field + 320, y-14, window=save_btn, anchor='nw') self._canvas_items.append(save_window) self._overlay_widgets.append(save_btn) y += 42 preview_item = ui.canvas_text(self, x_label, y, 'OLLAMA_HOST: ', fill='#6e6e73', font=('SF Pro Text', 10)) self._canvas_items.append(preview_item) def update_preview(*_args): host = (self.ollama_server_host.get() or '').strip() port = (self.ollama_server_port.get() or '').strip() url = _format_ollama_host(host, port) text = f"OLLAMA_HOST: {url}" if url else "OLLAMA_HOST: (not set)" try: self.bg_canvas.itemconfig(preview_item, text=text) except Exception: pass # No global model selector updates to avoid UI spin-lock self.ollama_server_host.trace_add('write', update_preview) self.ollama_server_port.trace_add('write', update_preview) update_preview() y += 40 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))) refresh_btn = tk.Button(self.bg_canvas, text='Scan', command=self._refresh_ollama_table, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) refresh_window = self.bg_canvas.create_window(x_label + 190, y - 8, window=refresh_btn, anchor='nw') self._canvas_items.append(refresh_window) self._overlay_widgets.append(refresh_btn) self._ollama_status_var = tk.StringVar(value="Ready") status_item = ui.canvas_text(self, x_label + 270, y, "Status: Ready", fill='#6e6e73', font=('SF Pro Text', 10)) self._canvas_items.append(status_item) def update_status_text(*args): try: self.bg_canvas.itemconfig(status_item, text=f"Status: {self._ollama_status_var.get()}") except Exception: pass self._ollama_status_var.trace_add('write', update_status_text) table_y = y + 30 table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1) table_window = self.bg_canvas.create_window(x_label, table_y, window=table_frame, anchor='nw', width=900, height=220) self._canvas_items.append(table_window) self._overlay_widgets.append(table_frame) # Add an editable Default Model column; available models shown for reference columns = ('select', 'host', 'port', 'default', 'models') tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=6) tree.heading('select', text='Select') tree.heading('host', text='Host') tree.heading('port', text='Port') tree.heading('default', text='Default Model') tree.heading('models', text='Models') tree.column('select', width=60, anchor='center') tree.column('host', width=200, anchor='w') tree.column('port', width=70, anchor='center') tree.column('default', width=180, anchor='w') tree.column('models', width=380, anchor='w') tree.pack(side='left', fill='both', expand=True) scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview) tree.configure(yscrollcommand=scroll.set) scroll.pack(side='right', fill='y') self._overlay_widgets.append(tree) self._overlay_widgets.append(scroll) self._ollama_server_tree = tree # Inline combobox to edit Default Model for the selected row self._ollama_row_model_combo = None def _place_row_model_combo(item_id): try: # Compute bbox for 'default' column bbox = tree.bbox(item_id, column='default') if not bbox: return x, y, w, h = bbox # Create lazily if not self._ollama_row_model_combo: self._ollama_row_model_combo = ttk.Combobox(tree, state='readonly') self._ollama_row_model_combo.bind('<>', _on_row_model_change) # Identify models by key vals = tree.item(item_id, 'values') if not vals: return host = vals[1] port = vals[2] or DEFAULT_OLLAMA_PORT key = f"{host}:{port}" models = self._ollama_model_options.get(key, []) try: self._ollama_row_model_combo.configure(values=models) except Exception: pass # Set current selection current = (self._ollama_default_model_by_key.get(key) or (models[0] if models else '')) if current: self._ollama_row_model_combo.set(current) # Place within Treeview self._ollama_row_model_combo.place(x=x+1, y=y+1, width=max(w-2, 60), height=h-2) except Exception: pass def _on_row_model_change(_evt=None): try: if not self._ollama_row_model_combo: return sel = tree.selection() if not sel: return item_id = sel[0] vals = tree.item(item_id, 'values') if not vals: return host = vals[1] port = vals[2] or DEFAULT_OLLAMA_PORT key = f"{host}:{port}" choice = self._ollama_row_model_combo.get().strip() self._ollama_default_model_by_key[key] = choice # If this row is the active selection for host/port, reflect into self.ollama_model current_key = self._ollama_current_key() if current_key == key and choice: try: self.ollama_model.set(choice) except Exception: pass # Update the table cell text new_vals = list(vals) # Default column index is 3 if len(new_vals) >= 4: new_vals[3] = choice or '—' tree.item(item_id, values=new_vals) except Exception: pass def on_select(event): if getattr(self, '_refreshing_ollama_table', False): return sel = tree.selection() if not sel: # Hide editor when nothing selected if self._ollama_row_model_combo: self._ollama_row_model_combo.place_forget() return vals = tree.item(sel[0], 'values') if not vals: return host = vals[1] port = vals[2] or DEFAULT_OLLAMA_PORT self.ollama_server_host.set(host) self.ollama_server_port.set(port) key = f"{host}:{port}" # If we have a stored default model for this key, reflect into self.ollama_model for saving chosen = (self._ollama_default_model_by_key.get(key) or '') if chosen: try: self.ollama_model.set(chosen) except Exception: pass # Refresh to update the selection indicator, then place editor self._render_ollama_table_from_cache() # Reselect and place inline model editor over this row try: for item in tree.get_children(): v = tree.item(item, 'values') if v and v[1] == host and (v[2] or DEFAULT_OLLAMA_PORT) == port: tree.selection_set(item) _place_row_model_combo(item) break except Exception: pass tree.bind('<>', on_select) # Also allow double-click on default cell to focus the combobox def on_double_click(event): if getattr(self, '_refreshing_ollama_table', False): return item_id = tree.identify_row(event.y) col = tree.identify_column(event.x) if not item_id: return if col in ('#4', 'default'): _place_row_model_combo(item_id) tree.bind('', on_double_click) self._ollama_note_item = ui.canvas_text(self, x_label, table_y + 230, '', fill='#6e6e73', font=('SF Pro Text', 10)) self._canvas_items.append(self._ollama_note_item) self._render_ollama_table_from_cache() self._refresh_ollama_table() def _render_supabase_config_page(self): # Letterhead at top right (matching welcome screen theme) content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Prole::Supabase', y=150) description = ( "Launches the Supabase open-source stack via supabase/deploy.sh.\n" "Default mode is local (Docker Compose). Set SUPABASE_DEPLOY_MODE=k3d\n" "to deploy into the 'supabase' Kubernetes namespace." ) self._render_paragraph(description, y=210) y = 280 self._supabase_status_var = tk.StringVar(value="Ready") status_item = ui.canvas_text(self, 48, y, "Status: Ready", fill='black', font=('SF Pro Text', 12)) def update_status_text(*args): try: self.bg_canvas.itemconfig(status_item, text=f"Status: {self._supabase_status_var.get()}") except Exception: pass self._supabase_status_var.trace_add('write', update_status_text) # Standardized Console Output self._supabase_console = self._create_console_output(y=320, title="Deployment Output", width=880, height=380) y = 740 # Deploy Supabase Button self._supabase_deploy_button = tk.Button( self.bg_canvas, text="Deploy Supabase", command=self.run_supabase_deploy, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', activeforeground='black', highlightbackground='#F5F5DC', highlightcolor='#F5F5DC', highlightthickness=0, relief='flat', bd=0, cursor='hand2', disabledforeground='#8B8B7A', font=('SF Pro Text', 11), padx=20, pady=10, ) btn_window = self.bg_canvas.create_window(48, y, window=self._supabase_deploy_button, anchor='nw') self._overlay_widgets.append(self._supabase_deploy_button) self._canvas_items.append(btn_window) def run_supabase_deploy(self): if getattr(self, '_supabase_deploying', False): return self._supabase_deploying = True self._supabase_deploy_button.configure(state='disabled') self._supabase_status_var.set("Deploying...") self._supabase_success = False self._supabase_console.clear() self.update_footer() def worker(): namespace = (self.db_namespace.get() or '').strip() or os.environ.get('NAMESPACE') or 'default' env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["NAMESPACE"] = namespace self._supabase_console.write("Starting Supabase deployment...\n") script_path = PROJECT_ROOT / "supabase" / "deploy.sh" if not script_path.exists(): self._supabase_console.write(f"Error: {script_path} not found.\n") self.safe_after(lambda: self._supabase_status_var.set("Failed (Script not found)")) self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) self._supabase_deploying = False return mode = (os.environ.get("SUPABASE_DEPLOY_MODE") or os.environ.get("SUPABASE_MODE") or "").strip() if not mode: # Match cluster environment to supabase deploy mode cluster_env = _normalize_cluster_env(self.cluster_env.get()) if cluster_env == 'dev': mode = "k3d" elif cluster_env in ('service', 'prod'): mode = "k8s" else: mode = "k3d" args = ["--mode", mode] cfg_path = None try: if self._cfg_path_override and self._cfg_path_override.exists(): cfg_path = self._cfg_path_override else: conf_dir = self._resolve_prole_conf_dir() candidate = conf_dir / "prole.cfg" if candidate.exists(): cfg_path = candidate if not cfg_path: candidate = PROJECT_ROOT / "conf" / "prole.cfg" if candidate.exists(): cfg_path = candidate except Exception: cfg_path = None if cfg_path: args.extend(["-c", str(cfg_path)]) if _parse_bool(os.environ.get("SUPABASE_USE_DEV_COMPOSE"), False): args.append("--with-dev-helpers") if _parse_bool(os.environ.get("SUPABASE_FOREGROUND"), False): args.append("--foreground") self._supabase_console.write(f"Mode: {mode}\n") cmd = ["bash", str(script_path)] + args try: proc = subprocess.Popen( cmd, cwd=str(PROJECT_ROOT), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) except Exception as e: self._supabase_console.write(f"Failed to start deploy: {e}\n") self.safe_after(lambda: self._supabase_status_var.set("Failed (Launch error)")) self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) self._supabase_deploying = False return if proc.stdout: for line in iter(proc.stdout.readline, ''): if line: self._supabase_console.write(line) proc.stdout.close() rc = proc.wait() if rc == 0: self._supabase_console.write("\nSupabase deployment completed successfully.\n") ports_script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh" self.safe_after(lambda: self._supabase_status_var.set("Configuring Ports...")) if not ports_script.exists(): self._supabase_success = False self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports script not found)")) self._supabase_console.write(f"Error: {ports_script} not found.\n") else: self._supabase_console.write(f"\nConfiguring Supabase ports for namespace '{namespace}'...\n") self._supabase_console.write(f"> bash etc/init_supabase_ports.sh -n {namespace}\n\n") try: ports_proc = subprocess.Popen( ["bash", str(ports_script), "-n", namespace], cwd=str(PROJECT_ROOT), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) except Exception as e: self._supabase_success = False self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports launch error)")) self._supabase_console.write(f"Failed to start port wiring: {e}\n") else: if ports_proc.stdout: for line in iter(ports_proc.stdout.readline, ''): if line: self._supabase_console.write(line) ports_proc.stdout.close() ports_rc = ports_proc.wait() if ports_rc == 0: self._supabase_success = True self.safe_after(lambda: self._supabase_status_var.set("Deployed Successfully")) self._supabase_console.write("\nSupabase port wiring completed successfully.\n") else: self._supabase_success = False self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Ports code {ports_rc})")) self._supabase_console.write(f"\nSupabase port wiring failed with exit code {ports_rc}.\n") else: self._supabase_success = False self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Code {rc})")) self._supabase_console.write(f"\nSupabase deployment failed with exit code {rc}.\n") self._supabase_deploying = False self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal')) self.safe_after(self.update_footer) threading.Thread(target=worker, daemon=True).start() def test_kerberos_connection(self): self._action_flags['kerberos_config.test_connection'] = True realm = self.kerberos_realm.get().strip() user = self.kerberos_user.get().strip() password = self.kerberos_password.get().strip() kdc = self.kerberos_kdc.get().strip() if not realm or not user or not password or not kdc: messagebox.showerror("Error", "Please fill in realm, KDC host, username, and password.") return try: self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get()) self.prole_cfg_data['Kerberos Authentication']['REALM'] = realm self.prole_cfg_data['Kerberos Authentication']['KDC'] = kdc self.prole_cfg_data['Kerberos Authentication']['SERVER'] = kdc self.prole_cfg_data['Kerberos Authentication']['USER'] = user self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = password self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get()) self.prole_cfg_data['Kerberos Authentication']['AD_PORT_FORWARD'] = os.environ.get('KRB5_AD_PORT_FORWARD', '1') self.prole_cfg_data['Kerberos Authentication']['AD_TCP_PORTS'] = os.environ.get('KRB5_AD_TCP_PORTS', '88 389 445 464 636') self.prole_cfg_data['Kerberos Authentication']['AD_UDP_PORTS'] = os.environ.get('KRB5_AD_UDP_PORTS', '88 464') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_HOST_NETWORK'] = os.environ.get('KRB5_AD_PROXY_HOST_NETWORK', '1') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_IMAGE'] = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_SERVICE'] = os.environ.get('KRB5_AD_SERVICE_NAME', 'prole-kerberos-ad-dc') self._save_prole_cfg() except Exception: pass self.kerberos_status_text.delete('1.0', tk.END) self.kerberos_status_text.insert(tk.END, f"Testing {user}@{realm} inside the cluster...\n") def worker(): try: env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["NAMESPACE"] = (self.db_namespace.get() or '').strip() env["SERVICE_NAMESPACE"] = self._get_service_namespace() env["KRB5_REALM"] = realm env["REALM"] = realm env["DOMAIN"] = realm.lower() env["KRB5_KDC"] = kdc env["KRB5_ADMIN"] = kdc env["KRB5_USER"] = user env["KRB5_PASSWORD"] = password env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get()) env["ENABLED"] = env["KERBEROS_ENABLED"] mode = self._deployment_mode() if mode == "k3s": env.setdefault("KRB5_AD_PORT_FORWARD", "0") if mode: env["PROLE_MODE"] = mode env["DEPLOYMENT_MODE"] = mode env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) mode_args = ["--mode", mode] if mode else [] def write_line(line): def _do_write(l=line): try: if self.kerberos_status_text.winfo_exists(): self.kerberos_status_text.insert(tk.END, l) self.kerberos_status_text.see(tk.END) except (tk.TclError, RuntimeError): pass self.safe_after(_do_write) write_line("\n==> init_kerberos.sh test\n") rc2 = self.controller.run_script( "init_kerberos.sh", args=mode_args + ["test"], env=env, on_line=write_line ) if rc2 == 0: write_line("\nKerberos test completed successfully.\n") else: write_line(f"\nKerberos test failed with code {rc2}\n") except Exception as e: def _do_error(msg=str(e)): try: if self.kerberos_status_text.winfo_exists(): self.kerberos_status_text.insert(tk.END, f"Error: {msg}\n") except (tk.TclError, RuntimeError): pass self.safe_after(_do_error) threading.Thread(target=worker, daemon=True).start() def _start_welcome_dependency_scan(self): self.splash_scan_running = True self.splash_scan_done_at = None self.splash_scan_started_at = time.time() deps = list(self.dependencies) total = len(deps) results = {} def set_status(text: str): try: if self._splash_status_item is not None: # Replace existing text to avoid creating many items self.bg_canvas.itemconfig(self._splash_status_item, text=text) except Exception: pass def worker(): missing_names = [] for idx, dep in enumerate(deps, start=1): try: ok, location, version = self.get_dep_info(dep) except Exception: ok, location, version = False, None, None results[dep['id']] = (ok, location, version) # Update status text progressively if ok: txt = f"[{idx}/{total}] {dep['name']}: Installed" else: txt = f"[{idx}/{total}] {dep['name']}: Not installed" missing_names.append(dep['name']) self.safe_after(lambda s=txt: set_status(s)) # tiny sleep to keep UI responsive without being too fast try: time.sleep(0.02) except Exception: pass # Final message final_msg = 'All dependencies installed.' if not missing_names else ( 'Preparing to install ... ' + ', '.join(missing_names) ) def on_done(): set_status(final_msg) self.splash_scan_running = False self.splash_scan_done_at = time.time() # Re-evaluate footer to potentially show Next self.update_footer() # Important: schedule a follow-up refresh slightly after the debounce window # so the Next button becomes visible without requiring further UI events. self.safe_after(self.update_footer, delay=600) self.safe_after(on_done) t = threading.Thread(target=worker, daemon=True) t.start() # Failsafe refresh slightly after the 8s gating window in case no UI events fire. self.safe_after(self.update_footer, delay=9000) def _render_deps_summary_page(self): self._render_title('Dependencies', y=40) y = 100 # Draw each dependency row: status dot (blank initially), name, info row_gap = 36 self.dep_status_items = {} # Store canvas IDs to update later self.dep_install_buttons = {} # Store button window items for dep in self.dependencies: indent = 0 if dep.get('parent'): indent = 24 left = 56 + indent text_x = left + 28 # blank box (outline only initially) dot = ui.canvas_rectangle(self, left, y, left+18, y+18, outline='#6e6e73', width=2) self._canvas_items.append(dot) # name self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='black', font=('SF Pro Text', 12, 'bold'))) # info placeholder info = ui.canvas_text(self, text_x + 180, y, 'Checking...', fill='#6e6e73', font=('SF Pro Text', 11)) self._canvas_items.append(info) self.dep_status_items[dep['id']] = {'dot': dot, 'info': info, 'y': y, 'left': left} y += row_gap # Message line msg_y = y + 30 self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('SF Pro Text', 11)) self._canvas_items.append(self.deps_msg_item) # Start async check threading.Thread(target=self._run_delayed_deps_check, daemon=True).start() def _run_delayed_deps_check(self): results = {} any_missing = False for dep in self.dependencies: ok, location, version = self.get_dep_info(dep) results[dep['id']] = (ok, location, version) # Update UI for this item def update_item(did=dep['id'], ok=ok, version=version): items = self.dep_status_items.get(did) if not items: return # Replace box with colored dot/check self.bg_canvas.delete(items['dot']) left = items.get('left', 56) y = items['y'] if ok: items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='') # Add a small white checkmark inside the green dot self.bg_canvas.create_line(left+5, y+9, left+8, y+12, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}") self.bg_canvas.create_line(left+8, y+12, left+13, y+6, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}") else: items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff3b30', outline='') # Add "Click to install" button btn = tk.Button(self.bg_canvas, text='Install', command=lambda d=did: self.show_page(f"dep_{d}"), bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=8, pady=2) btn_window = self.bg_canvas.create_window(left + 350, y, window=btn, anchor='nw') self._overlay_widgets.append(btn) self._canvas_items.append(btn_window) self.dep_install_buttons[did] = btn_window info_text = self.normalize_version(version) if ok and version else ('Not installed' if not ok else '') self.bg_canvas.itemconfig(items['info'], text=info_text) self.safe_after(update_item) time.sleep(0.1) # small delay to show it checking one by one def final_update(): missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]] if missing: msg = f"Preparing to install ... {', '.join(missing)}" else: msg = 'All dependencies installed.' try: if self.bg_canvas.winfo_exists() and self.deps_msg_item in self.bg_canvas.find_all(): self.bg_canvas.itemconfig(self.deps_msg_item, text=msg) except (tk.TclError, RuntimeError): pass self.update_footer() self.safe_after(final_update) def _render_dependency_page(self, dep): self._render_title(dep['name'], y=40) self._render_paragraph(dep['description'], y=88) # Status ok, location, version = self.get_dep_info(dep) y = 180 left = 56 if ok: self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')) self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold'))) if location: self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11))) if version: ver = self.normalize_version(version) self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 11))) else: # Check if we should auto-install install_cmd = dep.get('install_cmd') if install_cmd and self._installing_dep_id != dep['id']: # Launch directly into installation self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) # Trigger console-based install for brew/pip if 'brew install' in install_cmd or 'pip install' in install_cmd: self.root.after(500, lambda: self._install_dep_in_console(dep)) else: self.root.after(500, lambda: self.open_terminal_with_command(install_cmd)) elif self._installing_dep_id == dep['id']: self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) else: self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline='')) self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold'))) # Click to install link (if available) if install_cmd: link_y = y + 40 link_text = ui.render_link(self, left+32, link_y, 'Click to install') self._canvas_items.append(link_text) self.bg_canvas.config(cursor='hand2') def _on_click(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link_text) if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: self.open_terminal_with_command(install_cmd) self.bg_canvas.bind('', _on_click) def _install_dep_in_console(self, dep): """Run dependency installation in the embedded console.""" if self._installing_dep_id == dep['id']: return try: self._action_flags[f"dependencies.{dep['id']}.install"] = True except Exception: pass install_cmd = dep.get('install_cmd') if not install_cmd: return self._installing_dep_id = dep['id'] # Prepare log file logs_dir = self._resolve_env_dir('PROLE_LOGS', 'logs') try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass ts = time.strftime('%Y%m%d-%H%M%S') log_path = logs_dir / f"install-{dep['id']}-{ts}.log" # Show console self._ensure_console_overlay(radio_bottom_y=160) self._console_text.configure(state='normal') self._console_text.delete('1.0', tk.END) self._console_text.insert('end', f"Starting installation of {dep['name']}...\n") self._console_text.insert('end', f"Command: {install_cmd}\n\n") self._console_text.configure(state='disabled') def on_done(rc): self._installing_dep_id = None if rc == 0: self._append_console(f"\nSuccessfully installed {dep['name']}.\n") # Refresh status and re-render page self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}")) else: self._append_console(f"\nInstallation failed with exit code {rc}.\n") # Enable next button so user can retry or proceed if they fixed it manually try: self.next_button.configure(state='normal', text='Next') except Exception: pass self._run_in_console(install_cmd, str(log_path), on_complete=on_done) # ---------------- Initialize Screen Handlers ---------------- def _run_service_layer_overlay(self, next_page: str = 'init_password'): """Ensure service-layer resources are ready before database creation.""" self._showing_service_overlay = True self._common_services_success = False self.update_footer() self._clear_canvas_page() # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Preparing Service Layer', y=150) self._render_paragraph('Ensuring service-layer resources are ready before database creation.', y=200) console = self._create_console_output(y=260, title="Service Layer Output", width=900, height=520) status_label = ui.canvas_text(self, 240, 812, "Ready to deploy common services", fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(status_label) log_path = self._common_services_log_path() try: console.write(f"Log file: {log_path}\n\n") except Exception: pass def worker(): try: self.safe_after(lambda: self._deploy_services_button.configure(state='disabled') if self._deploy_services_button.winfo_exists() else None) namespace = (self.db_namespace.get() or '').strip() or 'default' env = self._script_env_for_namespace(namespace) env["SERVICE_NAMESPACE"] = self._get_service_namespace() try: log_fp = log_path.open('a', encoding='utf-8') log_fp.write(f"\n# Service layer deploy @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") log_fp.flush() except Exception: log_fp = None def _line(line: str): console.write(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Deploying service layer...", fill='blue') if self.bg_canvas.winfo_exists() else None) rc = self.controller.run_script( "init_service_layer.sh", args=["migrate"], env=env, on_line=_line ) if log_fp: try: log_fp.write(f"\nExit status: {rc}\n") log_fp.close() except Exception: pass if rc != 0: console.write(f"\nService layer setup failed (code {rc}).\n") self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"Service layer failed (code {rc})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) self.safe_after(lambda: self._deploy_services_button.configure(state='normal') if self._deploy_services_button.winfo_exists() else None) return self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Service layer ready. You may proceed.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) self._common_services_success = True self.safe_after(self.update_footer) except Exception as e: console.write(f"\nService layer error: {e}\n") self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Service layer error", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) self.safe_after(lambda: self._deploy_services_button.configure(state='normal') if self._deploy_services_button.winfo_exists() else None) # Use tk.Button explicitly for control over button appearance and behavior self._deploy_services_button = tk.Button(self.bg_canvas, text='Deploy Services', command=lambda: threading.Thread(target=worker, daemon=True).start(), bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) btn_window = self.bg_canvas.create_window(48, 800, window=self._deploy_services_button, anchor='nw', width=180) self._canvas_items.append(btn_window) self._overlay_widgets.append(self._deploy_services_button) def _run_preparation_overlay(self): """Prepare environment for database creation.""" self._action_flags['init_password.generate_ssh_key'] = True self._clear_canvas_page() # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Preparing', y=150) self._render_paragraph('Preparing your environment for database creation.', y=200) # Output Console - standardized to match Docker Build screen console = self._create_console_output(y=260, title="", width=900, height=520) # Status Label status_label = ui.canvas_text(self, 48, 812, "Initializing...", fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(status_label) def worker(): key_path = Path.home() / ".ssh" / "id_prole_ed25519" key_path.parent.mkdir(parents=True, exist_ok=True) password = self.db_password.get() namespace = (self.db_namespace.get() or '').strip() # Pre-pull dependent images into local registry before initialization try: self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Pre-pulling images...", fill='blue') if self.bg_canvas.winfo_exists() else None) pull_ok = self._prepull_images_to_registry( include_supabase=self.supabase_enabled.get(), include_kerberos_proxy=self.kerberos_enabled.get(), log=console.write ) if not pull_ok: console.write("\n[WARN] Image pre-pull failed or incomplete. Continuing...\n") except Exception as e: console.write(f"\n[WARN] Image pre-pull error: {e}\n") if key_path.exists(): console.write(f"Key already exists at {key_path}. Proceeding...\n") self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Keys present. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None) time.sleep(0.5) else: self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Preparing keys...", fill='blue') if self.bg_canvas.winfo_exists() else None) cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get()] console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) while True: line = proc.stdout.readline() if not line and proc.poll() is not None: break if line: console.write(line) if proc.returncode != 0: console.write(f"\nPreparation failed, trying alternative (code {proc.returncode})\n") cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get()] console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) while True: line = proc.stdout.readline() if not line and proc.poll() is not None: break if line: console.write(line) if proc.returncode == 0: console.write("\nPreparation completed successfully.\n") self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Preparation completed successfully.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) else: console.write(f"\nError during preparation (code {proc.returncode})\n") self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"Error during preparation (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) # In case of error, we might want to let the user see it before continuing or stopping time.sleep(2) self.root.after(500, lambda: self.show_page('init_password')) return # Initialize OpenBao and store keys/passwords self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="Initializing OpenBao...", fill='blue') if self.bg_canvas.winfo_exists() else None) env = self._script_env_for_namespace(namespace) env["PROLE_DB_USER"] = self.db_username.get().strip() env["DB_PASSWORD"] = password env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get()) if self.at_rest_encryption_enabled.get(): console.write("At-rest encryption enabled: generating/storing TDE keys in OpenBao...\n") rc = self.controller.run_script( "init_openbao.sh", args=["initialize"], env=env, stdin_text=f"{password}\n", on_line=lambda l: console.write(l) ) if rc != 0: console.write(f"\nOpenBao initialization failed (code {rc}).\n") self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text=f"OpenBao init failed (code {rc})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) self.root.after(500, lambda: self.show_page('init_password')) return # Move to next page self.safe_after(lambda: self.bg_canvas.itemconfig(status_label, text="OpenBao ready. Proceeding...", fill='#34c759') if self.bg_canvas.winfo_exists() else None) self.root.after(1000, lambda: self.show_page('init_db_build')) threading.Thread(target=worker, daemon=True).start() def _cluster_env_key(self, env_label: str | None = None) -> str: """Return normalized cluster env key ('dev', 'service', 'prod').""" value = env_label if value is None: try: value = self.cluster_env.get() except Exception: value = '' try: if hasattr(value, 'get') and callable(value.get): value = value.get() except Exception: pass if not value: try: value = (self.prole_cfg_data.get('Initialize Cluster', {}) or {}).get('ENVIRONMENT', '').strip() except Exception: value = '' if not value: try: value = (self.prole_cfg_data.get('Global', {}) or {}).get('CLUSTER_ENV', '').strip() except Exception: value = '' key = _normalize_cluster_env(value) if not key: return 'dev' return key def _get_service_namespace(self) -> str: """Resolve service namespace with UI/env/config fallbacks.""" try: ns = (self.service_namespace.get() or '').strip() except Exception: ns = '' if not ns: try: ns = (self.prole_cfg_data.get('Global', {}) or {}).get('SERVICE_NAMESPACE', '').strip() except Exception: ns = '' if not ns: ns = (os.environ.get('SERVICE_NAMESPACE') or '').strip() return ns or 'default' def _on_cluster_env_change(self, *args): self._set_deploy_target_from_cluster_env() # Trigger status check or refresh UI env_key = self._cluster_env_key() if env_key == 'service': try: self._apply_k3s_defaults() except Exception: pass self._verify_k3s_services() self.show_page('init_cluster') def _deployment_mode(self) -> str: try: env_val = self.cluster_env.get() except Exception: env_val = '' return _deployment_mode_from_env(env_val) def _deployment_pipeline_url(self, target_key: str) -> str: env_url = (os.environ.get('PROLE_OPENTOFU_URL') or os.environ.get('OPENTOFU_URL') or '').strip() if env_url: return env_url section = None if target_key == 'service': section = 'Service Cluster (k3s)' elif target_key == 'prod': section = 'Prod Cluster (k8s)' if section: url = (self.prole_cfg_data.get(section, {}) or {}).get('PIPELINE_URL', '').strip() if url: return url return _default_opentofu_pipeline_url() def _set_deploy_target_from_cluster_env(self): if not hasattr(self, 'deploy_target'): return if getattr(self, '_syncing_deploy_target', False): return self._syncing_deploy_target = True try: self.deploy_target.set(_deployment_target_label(self.cluster_env.get())) except Exception: pass finally: self._syncing_deploy_target = False def _on_deploy_target_change(self, *args): if getattr(self, '_syncing_deploy_target', False): return target = '' try: target = self.deploy_target.get() except Exception: target = '' key = _normalize_cluster_env(target) if key == 'dev': desired = '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 is running and common services can be deployed.', y=200) # Cluster Selection (Radio Buttons) x_label = 48 y = 280 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select 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 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 = '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]) restored = self._restore_common_services_log(deploy_console) if restored: try: self._k3s_service_notebook.select(self._k3s_service_deploy_tab) except Exception: pass y += console_height + 12 deploy_btn = tk.Button( self.bg_canvas, text='Deploy Missing Services', command=self._deploy_k3s_services, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=10, pady=5, ) deploy_win = self.bg_canvas.create_window(x_label + 20, y, window=deploy_btn, anchor='nw') self._canvas_items.append(deploy_win) self._overlay_widgets.append(deploy_btn) y += 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 # Cluster Status (only if dev) if selected_env_key == 'dev': y += 10 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.common_services_status_label = ui.canvas_text(self, x_label, y, 'Common Services: Checking...', fill='#6e6e73', font=('SF Pro Text', 12)) self._canvas_items.append(self.common_services_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': False, 'msg': 'Dashboard (Kong): Missing', 'fill': '#ff9f0a', 'reset_pods': [], } rc, out = self._run_cmd_capture(base_cmd + ['get', 'pods', '-n', ns, '-o', 'json'], timeout=8) if rc != 0: return { 'ok': False, 'msg': 'Dashboard (Kong): Unable to query', 'fill': '#ff9f0a', 'reset_pods': [], } try: payload = json.loads(out or "{}") except Exception: return { 'ok': False, 'msg': 'Dashboard (Kong): Unable to parse status', 'fill': '#ff9f0a', 'reset_pods': [], } kong_pods = [] for item in payload.get('items', []): name = (item.get('metadata', {}) or {}).get('name', '') name_lc = name.lower() if 'kong' in name_lc and 'dashboard' in name_lc: kong_pods.append(item) if not kong_pods: return { 'ok': False, 'msg': 'Dashboard (Kong): Missing', 'fill': '#ff9f0a', 'reset_pods': [], } unhealthy = [] for item in kong_pods: name = (item.get('metadata', {}) or {}).get('name', '') status = item.get('status') or {} if not _pod_healthy(status): unhealthy.append(name) if unhealthy: return { 'ok': False, 'msg': 'Dashboard (Kong): Unhealthy (safe to reset)', 'fill': '#ff9f0a', 'reset_pods': unhealthy, } return { 'ok': True, 'msg': 'Dashboard (Kong): Running', 'fill': '#34c759', 'reset_pods': [], } def _set_cluster_env_message(self, text: str, color: str = '#34c759', clear_after_ms: int | None = None): """Set a transient status message on the Cluster Environment screen.""" def _apply(): if not hasattr(self, '_cluster_env_message_label'): return if not self.bg_canvas.winfo_exists(): return try: self.bg_canvas.itemconfig(self._cluster_env_message_label, text=text, fill=color) except Exception: pass self.safe_after(_apply) if clear_after_ms is not None and clear_after_ms >= 0: def _clear(): if not hasattr(self, '_cluster_env_message_label'): return if not self.bg_canvas.winfo_exists(): return try: self.bg_canvas.itemconfig(self._cluster_env_message_label, text="") except Exception: pass self.safe_after(_clear, delay=clear_after_ms) def _authority_context_missing(self) -> bool: """Detect missing authority Docker context when Kerberos is enabled.""" try: if not self.kerberos_enabled.get(): return False except Exception: return False candidates = [] env_home = (os.environ.get('PROLE_HOME') or '').strip() if env_home: candidates.append(Path(env_home)) candidates.append(PROJECT_ROOT) for base in candidates: try: if (base / 'authority').is_dir(): return False if (base / 'prole' / 'authority').is_dir(): return False except Exception: continue return True def _maybe_run_repair_pipeline(self, status: dict, kong_status: dict | None): """Run repair pipeline when cluster is ready and anomalies are detected.""" if not status.get('cluster_ok'): return anomalies = [] if kong_status and not kong_status.get('ok', True): anomalies.append('dashboard') if self._authority_context_missing(): anomalies.append('authority') if not anomalies: return now = time.time() if self._repair_inflight: return if now - self._repair_last_run_at < self._repair_cooldown_s: return self._repair_last_run_at = now self._repair_inflight = True def worker(): try: env_key = status.get('env') or self._cluster_env_key() if env_key == 'service': ns = self._get_service_namespace() else: ns = (self.db_namespace.get() or '').strip() or 'default' env = self._script_env_for_namespace(ns) if env_key == 'service': env["PROLE_MODE"] = "k3s" elif env_key == 'dev': env["PROLE_MODE"] = "k3d" self._set_cluster_env_message("Repair pipeline started", '#34c759', clear_after_ms=3000) self.controller.run_script( "repair_pipeline.sh", args=["-n", ns], env=env, ) self._set_cluster_env_message("Repair pipeline complete", '#34c759', clear_after_ms=3000) except Exception as e: self._set_cluster_env_message(f"Repair pipeline failed: {e}", '#ff3b30', clear_after_ms=6000) finally: self._repair_inflight = False threading.Thread(target=worker, daemon=True).start() def _check_opentofu_health(self) -> bool: """Check OpenTofu readiness in the selected namespace.""" try: env_key = self._cluster_env_key() except Exception: env_key = 'dev' try: if env_key == 'service': ns = self._get_service_namespace() else: ns = (self.db_namespace.get() or '').strip() or 'default' except Exception: ns = 'default' try: base_cmd = self._kubectl_base_cmd(env_key) res_dep = subprocess.run( base_cmd + ['-n', ns, 'get', 'deploy', 'opentofu', '-o', 'jsonpath={.status.readyReplicas}'], capture_output=True, text=True, timeout=3 ) ready_dep = (res_dep.returncode == 0 and (res_dep.stdout or '0').strip() not in ('', '0')) if ready_dep: return True res_sts = subprocess.run( base_cmd + ['-n', ns, 'get', 'statefulset', 'opentofu', '-o', 'jsonpath={.status.readyReplicas}'], capture_output=True, text=True, timeout=3 ) ready_sts = (res_sts.returncode == 0 and (res_sts.stdout or '0').strip() not in ('', '0')) return ready_sts except Exception: return False def _check_k8s_cluster(self, env_label: str) -> tuple[bool, str]: env_key = self._cluster_env_key(env_label) label = "K3s Cluster" if env_key == 'service' else "Prod Cluster" if env_key == 'prod' else "Kubernetes Cluster" try: kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) if kubectl.returncode != 0: return False, f"{label}: kubectl not found" if env_key == 'service': kubeconfig = _find_kubeconfig_file() if not kubeconfig: server, token = self._k3s_connection_info() if not server or not token: return False, f"{label}: missing server URL/token or kubeconfig" cmd = self._kubectl_base_cmd(env_key) + ['cluster-info'] res = subprocess.run(cmd, capture_output=True, text=True, timeout=8) ok = res.returncode == 0 except Exception: ok = False msg = f"{label}: Connected" if ok else f"{label}: Not reachable" return ok, msg def _get_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 (ArgoCD/OpenBao/OpenTofu/Garage) to remote k3s cluster.""" def _deploy(): console = self._k3s_service_deploy_console if console: console.clear() console.write(f"Deploying common services @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") try: if self._k3s_service_notebook and self._k3s_service_deploy_tab: self._k3s_service_notebook.select(self._k3s_service_deploy_tab) except Exception: pass service_ns = self._get_service_namespace() env = self._script_env_for_namespace(service_ns) env["PROLE_MODE"] = "k3s" log_path = self._common_services_log_path() env["COMMON_SERVICES_INIT_LOG"] = str(log_path) try: log_fp = log_path.open('a', encoding='utf-8') log_fp.write(f"\n# Common services deploy @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") log_fp.flush() except Exception: log_fp = None if console: console.write(f"Log file: {log_path}\n\n") if not env.get("KUBECONFIG"): if console: console.write("KUBECONFIG not generated. Check K3s credentials in prole.cfg.\n") if log_fp: try: log_fp.write("KUBECONFIG not generated. Check K3s credentials in prole.cfg.\n") log_fp.close() except Exception: pass self._verify_k3s_services() return try: common_args = ["-n", env["NAMESPACE"]] if self.kerberos_enabled.get(): common_args.append("-k") common_args.append("update") def _log_line(line: str): if console: console.write(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc = self.controller.run_script( "init_common_services.sh", args=common_args, env=env, on_line=_log_line ) if console: console.write(f"\nExit status: {rc}\n") if log_fp: try: log_fp.write(f"\nExit status: {rc}\n") except Exception: pass finally: if log_fp: try: log_fp.close() except Exception: pass self._verify_k3s_services() threading.Thread(target=_deploy, daemon=True).start() def _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() 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' common_ok = cluster_ok common_msg = 'Common Services: Ready to deploy' if common_ok else 'Common Services: Not ready' common_fill = '#34c759' if common_ok else '#ff9f0a' return { 'env': env, 'cluster_ok': cluster_ok, 'cluster_msg': cluster_msg, 'cluster_fill': cluster_fill, 'common_ok': common_ok, 'common_msg': common_msg, 'common_fill': common_fill, } def _cluster_ready_for_navigation(self) -> bool: status = self._cluster_status_snapshot() env = status['env'] 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 # 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) # Run the repair pipeline if anomalies are detected on a ready cluster try: self._maybe_run_repair_pipeline(status, kong_status) except Exception: pass def update_ui(): if hasattr(self, 'k3d_status_label'): self.bg_canvas.itemconfig(self.k3d_status_label, text=status['cluster_msg'], fill=status['cluster_fill']) if hasattr(self, 'common_services_status_label'): self.bg_canvas.itemconfig(self.common_services_status_label, text=status['common_msg'], fill=status['common_fill']) if hasattr(self, '_init_cluster_button'): try: self._init_cluster_button.configure(text='Save') except Exception: pass self.root.after(0, update_ui) # Clear message after a short delay if no notice is active if not notice_active: self._set_cluster_env_message("", '#34c759', clear_after_ms=2000) threading.Thread(target=worker, daemon=True).start() def _services_status_snapshot(self) -> dict: openbao_ok = self._check_openbao_health() opentofu_ok = self._check_opentofu_health() openbao_msg = 'OpenBao: Running' if openbao_ok else 'OpenBao: Not reachable' opentofu_msg = 'OpenTofu: Running' if opentofu_ok else 'OpenTofu: Not reachable' openbao_fill = '#34c759' if openbao_ok else '#ff9f0a' opentofu_fill = '#34c759' if opentofu_ok else '#ff9f0a' return { 'openbao_msg': openbao_msg, 'openbao_fill': openbao_fill, 'opentofu_msg': opentofu_msg, 'opentofu_fill': opentofu_fill, } def check_services_status_async(self): def worker(): status = self._services_status_snapshot() def update_ui(): if hasattr(self, '_db_openbao_status_label'): self.bg_canvas.itemconfig(self._db_openbao_status_label, text=status['openbao_msg'], fill=status['openbao_fill']) if hasattr(self, '_db_opentofu_status_label'): self.bg_canvas.itemconfig(self._db_opentofu_status_label, text=status['opentofu_msg'], fill=status['opentofu_fill']) self.root.after(0, update_ui) threading.Thread(target=worker, daemon=True).start() def ensure_cluster_ready(self): self._action_flags['init_cluster.start_cluster'] = True # Implementation of cluster creation/startup def worker(): cluster_env = self._cluster_env_key() if cluster_env == 'dev': # 1. Start Docker if not running if not self.controller.check_docker_running(): # Attempt to start Docker on macOS subprocess.run(['open', '-a', 'Docker'], capture_output=True) # Wait for it to start for _ in range(30): time.sleep(2) if self.controller.check_docker_running(): break if not self.controller.check_docker_running(): self.root.after(0, lambda: messagebox.showerror('Docker', 'Could not start Docker. Please start it manually.')) return # 2. Manage or verify cluster cluster_name = 'prole-dev-cluster' res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True) res_stdout = res.stdout or '' if self._reset_cluster: subprocess.run(['k3d', 'cluster', 'delete', cluster_name], capture_output=True) res_stdout = '' self._reset_cluster = False if cluster_name not in res_stdout: # Create it # Default args based on README.md prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data')) volume_args = _k3d_prole_data_volume_args(prole_data) cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2'] + volume_args reg_args = [] try: if self.ensure_local_registry_available(): reg_args = ['--registry-use', 'k3d-prole-registry:5000'] except Exception: reg_args = [] cmd += reg_args + ['--api-port', '0.0.0.0:6443'] # Run in terminal or capture output? Let's use a console window later. # For now, run it and update status. subprocess.run(cmd, capture_output=True) else: # Start it if it's stopped subprocess.run(['k3d', 'cluster', 'start', cluster_name], capture_output=True) self.check_cluster_status_async() self.root.after(0, lambda: messagebox.showinfo('Cluster', f'Cluster {cluster_name} is ready.')) else: if self._reset_cluster and cluster_env in ('service', 'k3s'): ns = (self._get_service_namespace() or '').strip() or 'default' server, token = self._k3s_connection_info() _reset_k3s_namespace(self.controller.project_root, ns, server, token) self._reset_cluster = False ok, msg = self._check_k8s_cluster(cluster_env) if not ok: self.root.after(0, lambda: messagebox.showerror('Cluster', msg)) return self.check_cluster_status_async() self.root.after(0, lambda: messagebox.showinfo('Cluster', msg)) threading.Thread(target=worker, daemon=True).start() def _render_init_db_build_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Build Database Image', y=150) self._render_paragraph('Building the prole-db Postgres image. This may take a few minutes.', y=200) # Local registry status (checked async) self._db_registry_status_label = ui.canvas_text(self, 48, 230, "Local Registry: Checking...", fill='#6e6e73', font=('SF Pro Text', 11)) self._canvas_items.append(self._db_registry_status_label) # Output Console self._db_build_console = self._create_console_output(y=260, title="Build Output", width=900, height=520) # Use tk.Button self._db_build_button = tk.Button(self.bg_canvas, text='Start Build', command=self.run_db_build, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) btn_window = self.bg_canvas.create_window(48, 800, window=self._db_build_button, anchor='nw', width=180) self._canvas_items.append(btn_window) self._overlay_widgets.append(self._db_build_button) # Status Label self._db_build_status_label = ui.canvas_text(self, 240, 812, "", fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(self._db_build_status_label) # Ensure registry check runs when entering the screen self._ensure_db_build_registry_async() def _ensure_db_build_registry_async(self): def worker(): try: if not self.check_docker_running(): self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: Docker not running", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) return info = self.ensure_local_registry_available() if info: host_registry, _cluster_registry = info self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=f"Local Registry: {host_registry}", fill='#34c759') if self.bg_canvas.winfo_exists() else None) else: self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: unavailable", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) log_path = getattr(self, '_last_registry_log_path', None) if log_path and self._db_build_console: self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n")) except Exception as e: msg = f"Local Registry: error ({e})" self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=msg, fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) log_path = getattr(self, '_last_registry_log_path', None) if log_path and self._db_build_console: self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n")) threading.Thread(target=worker, daemon=True).start() def safe_after(self, func, delay=0): """Run a function in the main thread if the root window still exists.""" if not self.root or not self.root.winfo_exists(): return def wrapper(): try: if self.root and self.root.winfo_exists(): func() except (tk.TclError, RuntimeError): pass try: self.root.after(delay, wrapper) except (tk.TclError, RuntimeError): pass def run_db_build(self): self._action_flags['init_db_build.run_build'] = True def worker(): self.safe_after(lambda: self._db_build_button.configure(state='disabled') if self._db_build_button.winfo_exists() else None) self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Building...", fill='blue') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) tag = self.get_prole_db_version() image_name = f"prole-db:{tag}" # Use $HOME/.prole/build for Docker build context # This avoids issues with PyInstaller's temporary _MEIPASS directory prole_home = Path.home() / ".prole" build_dir = prole_home / "build" / "prole-db" build_dir.mkdir(parents=True, exist_ok=True) # Copy prole-db directory to writable location source_dir = get_resource_path("prole-db") if source_dir.exists(): import shutil # Remove old build dir and copy fresh if source_dir.resolve() != build_dir.resolve(): if build_dir.exists(): shutil.rmtree(build_dir) shutil.copytree(source_dir, build_dir) cwd = build_dir # Fetch the generated public key pub_key = "" pub_key_path = Path.home() / ".ssh" / "id_prole_ed25519.pub" if pub_key_path.exists(): pub_key = pub_key_path.read_text().strip() username = self.db_username.get() env_key = self._cluster_env_key() cmd = ['docker', 'build'] cmd.extend(get_docker_build_platform_args(env_key)) cmd += [ '--build-arg', f"PROLE_USER={username}", '--build-arg', f"PROLE_SSH_PUB_KEY={pub_key}", '-t', image_name, '.' ] self._db_build_console.clear() self._db_build_console.write(f"Building {image_name} in {cwd}...\n\n") proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) while True: line = proc.stdout.readline() if not line and proc.poll() is not None: break if line: self._db_build_console.write(line) if proc.returncode == 0: self._db_build_console.write("\nBuild successful!\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build successful! Tagging...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) self._db_built_success = True if env_key == 'dev': # Tag + push to local registry for k3d pulls registry_info = self.ensure_local_registry_available() import_tag = image_name if registry_info: host_registry, cluster_registry = registry_info remote_tag = f"{host_registry}/prole-db:{tag}" self._db_build_console.write(f"Tagging image for registry: {remote_tag}\n") tag_res = subprocess.run(['docker', 'tag', image_name, remote_tag], capture_output=True, text=True) if tag_res.returncode != 0: self._db_build_console.write(tag_res.stdout or '') self._db_build_console.write(tag_res.stderr or '') self._db_build_console.write("Tag failed; skipping push/import.\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, tag failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) return self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Pushing to registry...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) if not _push_docker_image(remote_tag, log_fn=self._db_build_console.write): self._db_build_console.write("Push failed; skipping import.\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, push failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) return self._db_build_console.write(f"Push complete: {remote_tag}\n") if cluster_registry: import_tag = f"{cluster_registry}/prole-db:{tag}" # k3d registry is often reachable internally without the .localhost suffix if cluster_registry.endswith('.localhost:5000'): alt_registry = cluster_registry.replace('.localhost', '') alt_tag = f"{alt_registry}/prole-db:{tag}" if alt_tag != import_tag: self._db_build_console.write(f"Tagging image for k3d import: {alt_tag}\n") subprocess.run(['docker', 'tag', image_name, alt_tag], capture_output=True, text=True) import_tag = alt_tag else: self._db_build_console.write("Local registry unavailable; skipping tag/push.\n") # Import to k3d cluster_name = "prole-dev-cluster" self._db_build_console.write(f"Importing image to {cluster_name} ({import_tag})...\n") subprocess.run(['k3d', 'image', 'import', import_tag, '-c', cluster_name]) self._db_build_console.write("Import complete.\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build, tag, push, and import complete.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) else: self._db_build_console.write("Import skipped for non-dev clusters.\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build complete. Import skipped.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) else: self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text=f"Build failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None) self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None) threading.Thread(target=worker, daemon=True).start() def _render_init_password_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Database Creation', y=150) self._render_paragraph('Create a database namespace for this deployment. The namespace is treated as a single database with a name and one root password. An ed25519 SSH key will be generated and stored in OpenBao for root access.', y=200) x_label = 48 x_field = 300 y = 270 # Database (namespace) name self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Database Name (Namespace):', fill='black', font=('SF Pro Text', 12, 'bold'))) ns_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#CCCCCC', highlightthickness=1, bd=0) ns_frame.pack_propagate(False) prefix_label = tk.Label(ns_frame, text=self._namespace_prefix(), fg='#9a9aa0', bg='white', font=('SF Pro Text', 11)) vcmd = (self.root.register(self._validate_namespace_suffix), '%P') suffix_entry = tk.Entry( ns_frame, textvariable=self.db_namespace_suffix, bg='white', fg='black', insertbackground='black', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), validate='key', validatecommand=vcmd ) prefix_label.pack(side='left', padx=(8, 2)) suffix_entry.pack(side='left', fill='both', expand=True, padx=(0, 8)) ns_window = self.bg_canvas.create_window(x_field, y-12, window=ns_frame, anchor='nw', width=400, height=32) self._canvas_items.append(ns_window) self._overlay_widgets.append(ns_frame) self._overlay_widgets.append(prefix_label) self._overlay_widgets.append(suffix_entry) y += 42 # Owner (local user) self.db_username.set(self.namespace_owner) self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Owner:', fill='black', font=('SF Pro Text', 12, 'bold'))) owner_entry = tk.Entry(self.bg_canvas, textvariable=self.db_username, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) owner_window = self.bg_canvas.create_window(x_field, y-12, window=owner_entry, anchor='nw', width=400, height=32) self._canvas_items.append(owner_window) self._overlay_widgets.append(owner_entry) y += 42 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Master Password:', fill='black', font=('SF Pro Text', 12, 'bold'))) # Use tk.Entry on canvas p1 = tk.Entry(self.bg_canvas, textvariable=self.db_password, show='*', bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) p1_window = self.bg_canvas.create_window(x_field, y-12, window=p1, anchor='nw', width=400, height=32) self._canvas_items.append(p1_window) self._overlay_widgets.append(p1) y += 42 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Confirm:', fill='black', font=('SF Pro Text', 12, 'bold'))) # Use tk.Entry on canvas p2 = tk.Entry(self.bg_canvas, textvariable=self.db_password_confirm, show='*', bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) p2_window = self.bg_canvas.create_window(x_field, y-12, window=p2, anchor='nw', width=400, height=32) self._canvas_items.append(p2_window) self._overlay_widgets.append(p2) y += 42 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Host Port Forward:', fill='black', font=('SF Pro Text', 12, 'bold'))) port_entry = tk.Entry(self.bg_canvas, textvariable=self.db_host_port, bg='white', fg='black', insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=100, height=32) self._canvas_items.append(port_window) self._overlay_widgets.append(port_entry) # Indicator for password match (X or ✓) self.password_indicator = ui.canvas_text(self, x_field + 410, y, '✘', fill='#dc3545', font=('SF Pro Text', 16, 'bold'), state='hidden') self._canvas_items.append(self.password_indicator) # Optional Features moved above browser y += 50 self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Optional Features:', fill='black', font=('SF Pro Text', 13, 'bold'))) feature_options = [ ('Enable Supabase', self.supabase_enabled), ('Enable Kerberos Authentication', self.kerberos_enabled), ('Enable At-Rest Encryption', self.at_rest_encryption_enabled), ] fx = x_label + 20 fy = y + 36 for label, var in feature_options: cb = tk.Checkbutton(self.bg_canvas, text=label, variable=var, bg='white', fg='black', activebackground='white', selectcolor='white', font=('SF Pro Text', 11), command=self._save_prole_cfg) cb_window = self.bg_canvas.create_window(fx, fy, window=cb, anchor='nw') self._canvas_items.append(cb_window) self._overlay_widgets.append(cb) fx += 280 # Database browser table_y = fy + 50 self._canvas_items.append(ui.canvas_text(self, x_label, table_y, 'Database Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))) refresh_btn = tk.Button(self.bg_canvas, text='Refresh', command=self._refresh_namespace_table, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) refresh_window = self.bg_canvas.create_window(x_label + 160, table_y - 8, window=refresh_btn, anchor='nw') self._canvas_items.append(refresh_window) self._overlay_widgets.append(refresh_btn) add_btn = tk.Button(self.bg_canvas, text='Add', command=self._db_add_namespace, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) add_window = self.bg_canvas.create_window(x_label + 240, table_y - 8, window=add_btn, anchor='nw') self._canvas_items.append(add_window) self._overlay_widgets.append(add_btn) edit_btn = tk.Button(self.bg_canvas, text='Edit', command=self._db_edit_namespace, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) edit_window = self.bg_canvas.create_window(x_label + 300, table_y - 8, window=edit_btn, anchor='nw') self._canvas_items.append(edit_window) self._overlay_widgets.append(edit_btn) delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._db_delete_namespace, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 10), padx=10, pady=4) delete_window = self.bg_canvas.create_window(x_label + 360, table_y - 8, window=delete_btn, anchor='nw') self._canvas_items.append(delete_window) self._overlay_widgets.append(delete_btn) self._db_action_buttons = [refresh_btn, add_btn, edit_btn, delete_btn] table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1) table_window = self.bg_canvas.create_window(x_label, table_y + 30, window=table_frame, anchor='nw', width=900, height=180) self._canvas_items.append(table_window) self._overlay_widgets.append(table_frame) columns = ('select', 'name', 'owner', 'port', 'created', 'status') tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=5) tree.heading('select', text='Select') tree.heading('name', text='Name') tree.heading('owner', text='Owner') tree.heading('port', text='Host Port') tree.heading('created', text='Creation Date') tree.heading('status', text='Status') tree.column('select', width=60, anchor='center') tree.column('name', width=180, anchor='w') tree.column('owner', width=140, anchor='w') tree.column('port', width=80, anchor='center') tree.column('created', width=200, anchor='w') tree.column('status', width=120, anchor='center') tree.pack(side='left', fill='both', expand=True) scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview) tree.configure(yscrollcommand=scroll.set) scroll.pack(side='right', fill='y') self._overlay_widgets.append(tree) self._overlay_widgets.append(scroll) self._db_namespace_tree = tree def on_select(event): if getattr(self, '_refreshing_ns_table', False): return sel = tree.selection() if sel: vals = tree.item(sel[0], 'values') if vals: ns = vals[1] if ns == 'supabase': # Do not allow selecting 'supabase' as the primary prole-db namespace return if ns == self.db_namespace.get(): # Already selected, don't trigger re-refresh/save return self.db_namespace.set(ns) self._sync_namespace_suffix_from_full() # Mark as selected in tree visually if needed, but we use the checkbox column self._refresh_namespace_table() self._save_prole_cfg() if self.supabase_enabled.get(): self._db_sync_supabase_ports(ns) tree.bind('<>', on_select) self._db_namespace_note = ui.canvas_text(self, x_label, table_y + 220, '', fill='#6e6e73', font=('SF Pro Text', 10)) self._canvas_items.append(self._db_namespace_note) def on_password_change(*args): p = self.db_password.get() c = self.db_password_confirm.get() if not p: self.bg_canvas.itemconfigure(self.password_indicator, state='hidden') elif p == c: self.bg_canvas.itemconfigure(self.password_indicator, text='✓', fill='#28a745', state='normal') else: self.bg_canvas.itemconfigure(self.password_indicator, text='✘', fill='#dc3545', state='normal') self.db_password.trace_add('write', on_password_change) self.db_password_confirm.trace_add('write', on_password_change) # Add trace to host port to update mappings immediately def on_port_change(*args): try: self._save_prole_cfg() except: pass self.db_host_port.trace_add('write', on_port_change) # Trigger once in case they are already set on_password_change() self._refresh_namespace_table() def _render_init_cnpg_deploy_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Deployment', y=150) self._render_paragraph('Deploy the CloudNative-PG operator and cluster manifests to Kubernetes.', y=200) # Output Console self._cnpg_deploy_console = self._create_console_output(y=260, title="Deployment Output", width=900, height=520) # Use tk.Button self._cnpg_deploy_button = tk.Button(self.bg_canvas, text='Run Deployment', command=self.run_cnpg_deploy, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) self._cnpg_deploy_button.update_idletasks() deploy_btn_height = self._cnpg_deploy_button.winfo_reqheight() or 32 # Deployment mode selector mode_label = ui.canvas_text(self, 48, 782, "Mode", fill='#6e6e73', font=('SF Pro Text', 10, 'bold')) self._canvas_items.append(mode_label) mode_values = ['prole-dev-cluster', 'prole-service-cluster', 'prole-prod-cluster'] mode_frame = tk.Frame(self.bg_canvas, bg='#F5F5DC', width=180, height=deploy_btn_height) mode_frame.pack_propagate(False) mode_combo = ttk.Combobox(mode_frame, textvariable=self.deploy_target, values=mode_values, state='readonly', width=24) mode_combo.pack(fill='both', expand=True) mode_combo_window = self.bg_canvas.create_window(48, 800, window=mode_frame, anchor='nw', width=180, height=deploy_btn_height) self._canvas_items.append(mode_combo_window) self._overlay_widgets.append(mode_frame) btn_window = self.bg_canvas.create_window(240, 800, window=self._cnpg_deploy_button, anchor='nw', width=180) self._canvas_items.append(btn_window) self._overlay_widgets.append(self._cnpg_deploy_button) # Force Rollout Button self._cnpg_rollout_button = tk.Button(self.bg_canvas, text='Force Rollout', command=self.run_cnpg_rollout, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) rollout_btn_window = self.bg_canvas.create_window(420, 800, window=self._cnpg_rollout_button, anchor='nw', width=160) self._canvas_items.append(rollout_btn_window) self._overlay_widgets.append(self._cnpg_rollout_button) # Save Deployment Button (moved from Post Install) self._save_deployment_button = tk.Button(self.bg_canvas, text='Save Deployment', command=self.run_final_deployment, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) save_btn_window = self.bg_canvas.create_window(600, 800, window=self._save_deployment_button, anchor='nw', width=180) self._canvas_items.append(save_btn_window) self._overlay_widgets.append(self._save_deployment_button) # Status Labels (below the buttons row to avoid overlap) self._cnpg_deploy_status_label = ui.canvas_text(self, 48, 840, "", fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(self._cnpg_deploy_status_label) self._save_deployment_status_label = ui.canvas_text(self, 600, 840, "", fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(self._save_deployment_status_label) def run_cnpg_deploy(self): self._action_flags['init_cnpg_deploy.run_deploy'] = True def worker(): self.safe_after(lambda: self._cnpg_deploy_button.configure(state='disabled') if self._cnpg_deploy_button.winfo_exists() else None) self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deploying...", fill='blue') if self.bg_canvas.winfo_exists() else None) target_value = '' try: target_value = (self.deploy_target.get() or '').strip() except Exception: target_value = '' if not target_value: try: target_value = (self.cluster_env.get() or '').strip() except Exception: target_value = '' target_key = _normalize_cluster_env(target_value) mode = _deployment_mode_from_env(target_value or self.cluster_env.get()) if target_key in ('service', 'prod'): url = self._deployment_pipeline_url(target_key) self._cnpg_deploy_console.clear() self._cnpg_deploy_console.write(f"Opening deployment pipeline for {target_value or target_key}...\n") self._cnpg_deploy_console.write(f"{url}\n") try: webbrowser.open(url) except Exception: pass self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment pipeline opened.", fill='#34c759') if self.bg_canvas.winfo_exists() else None) self._cnpg_success = False self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) return etc_dir = PROJECT_ROOT / "etc" # Prepare environment for scripts env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["NAMESPACE"] = (self.db_namespace.get() or '').strip() env["NAMESPACE"] = (self.db_namespace.get() or '').strip() if mode: env["PROLE_MODE"] = mode env["DEPLOYMENT_MODE"] = mode env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) self._cnpg_deploy_console.clear() self._cnpg_deploy_console.write("Starting CloudNative-PG deployment...\n") self._cnpg_deploy_console.write(f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} deploy latest\n\n") cmd = ['bash', str(etc_dir / 'init_cloudnative_pg.sh')] if mode: cmd += ['--mode', mode] cmd += ['deploy', 'latest'] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) while True: line = proc.stdout.readline() if not line and proc.poll() is not None: break if line: self._cnpg_deploy_console.write(line) if proc.returncode == 0: self._cnpg_deploy_console.write("\nDeployment successful!\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment successful!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) self._cnpg_success = True else: self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Deployment failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) self._cnpg_success = False self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) threading.Thread(target=worker, daemon=True).start() def run_cnpg_rollout(self): self._action_flags['init_cnpg_deploy.force_rollout'] = True def worker(): self.safe_after(lambda: self._cnpg_deploy_button.configure(state='disabled') if self._cnpg_deploy_button.winfo_exists() else None) self.safe_after(lambda: self._cnpg_rollout_button.configure(state='disabled') if self._cnpg_rollout_button.winfo_exists() else None) self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Rolling out...", fill='blue') if self.bg_canvas.winfo_exists() else None) etc_dir = PROJECT_ROOT / "etc" # Prepare environment for scripts env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) mode = self._deployment_mode() if mode: env["PROLE_MODE"] = mode env["DEPLOYMENT_MODE"] = mode env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) self._cnpg_deploy_console.clear() self._cnpg_deploy_console.write("Starting manual recreate rollout for prole-db cluster...\n") self._cnpg_deploy_console.write(f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} rollout\n\n") cmd = ['bash', str(etc_dir / 'init_cloudnative_pg.sh')] if mode: cmd += ['--mode', mode] cmd += ['rollout'] proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) while True: line = proc.stdout.readline() if not line and proc.poll() is not None: break if line: self._cnpg_deploy_console.write(line) if proc.returncode == 0: self._cnpg_deploy_console.write("\nRollout successful!\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Rollout successful!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) else: self._cnpg_deploy_console.write(f"\nRollout failed with code {proc.returncode}\n") self.safe_after(lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Rollout failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) self.safe_after(lambda: self._cnpg_deploy_button.configure(state='normal') if self._cnpg_deploy_button.winfo_exists() else None) self.safe_after(lambda: self._cnpg_rollout_button.configure(state='normal') if self._cnpg_rollout_button.winfo_exists() else None) def _init_scripts_list(self): scripts = [ ('Kubectl Status', self.KUBECTL_STATUS_TAB), ('Common Services', 'init_common_services.sh'), ('Common Services Log', 'common_services_log'), ('CloudNative-PG', 'init_cloudnative_pg.sh'), ] if self.kerberos_enabled.get(): scripts.append(('Kerberos Realm', 'init_kerberos.sh')) scripts.extend([ ('Prole DB Backup', 'init_prole-db-backup.sh'), ('Monitoring', 'init_monitoring.sh'), ('Nginx Ingress', 'init_nginx_ingress.sh'), ('Port Forwards', 'init_port_forwards.sh'), ]) return scripts def _render_init_scripts_section(self, x: int, y: int, width: int, height: int, title: str | None = "Execution Output") -> int: if title: title_item = ui.canvas_text(self, x, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) self._canvas_items.append(title_item) y += 30 # Use a background frame for the notebook to hide potential system borders notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) self.script_tabs = ttk.Notebook(notebook_bg, style='TNotebook') self.script_tabs.pack(fill='both', expand=True, padx=1, pady=1) tab_window = self.bg_canvas.create_window(x, y, window=notebook_bg, anchor='nw', width=width, height=height) self._canvas_items.append(tab_window) self._overlay_widgets.append(notebook_bg) self._overlay_widgets.append(self.script_tabs) self.script_consoles = {} scripts = self._init_scripts_list() self._script_tab_index = {} for idx, (title, fname) in enumerate(scripts): # Use a background frame to ensure NO borders are visible around the console console_bg = tk.Frame(self.script_tabs, bg='white', highlightthickness=0, bd=0) self.script_tabs.add(console_bg, text=title) # Use TerminalConsole for consistent styling if fname == self.KUBECTL_STATUS_TAB: mono = ('Menlo', 9) if platform.system() == 'Darwin' else ('Consolas', 9) console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0, font=mono, wrap='none', show_horizontal=True) else: console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) console.pack(fill='both', expand=True, padx=1, pady=1) self.script_consoles[fname] = console self._script_tab_index[fname] = idx self._overlay_widgets.append(console_bg) self._overlay_widgets.append(console) self.script_tabs.bind("<>", self._on_init_scripts_tab_changed) self.safe_after(self._refresh_kubectl_status_tab, delay=50) return y + height def _render_init_scripts_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Initialization Scripts', y=150) self._render_paragraph('Running initialization scripts to set up Garage, CloudNative-PG, and backups.', y=200) y = 260 y = self._render_init_scripts_section(48, y, 900, 450, title="Execution Output") # Use tk.Button self._init_scripts_button = tk.Button(self.bg_canvas, text='Run Scripts', command=self.run_init_scripts, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) btn_window = self.bg_canvas.create_window(48, y + 20, window=self._init_scripts_button, anchor='nw', width=180) self._canvas_items.append(btn_window) self._overlay_widgets.append(self._init_scripts_button) # Status Label self._init_scripts_status_label = ui.canvas_text(self, 240, y + 32, "", fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(self._init_scripts_status_label) def _on_init_scripts_tab_changed(self, _event=None): try: current_idx = self.script_tabs.index("current") except Exception: return if current_idx == self._script_tab_index.get(self.KUBECTL_STATUS_TAB): self._refresh_kubectl_status_tab() def _refresh_kubectl_status_tab(self): console = self.script_consoles.get(self.KUBECTL_STATUS_TAB) if not console: return namespace = (self.db_namespace.get() or '').strip() or os.environ.get('NAMESPACE') or 'default' base_cmd = self._kubectl_base_cmd() def _display_cmd(args: list[str]) -> str: redacted = [] for item in base_cmd + args: if item.startswith('--token='): redacted.append('--token=***') else: redacted.append(item) return " ".join(shlex.quote(x) for x in redacted) def _run_kubectl(args: list[str]): env = os.environ.copy() env.setdefault("PROLE_HOME", str(PROJECT_ROOT)) try: res = subprocess.run(base_cmd + args, capture_output=True, text=True, env=env) out = res.stdout or "" err = res.stderr or "" return res.returncode, out, err except Exception as exc: return 1, "", f"{exc}" def worker(): cmd_args = ['get', 'pods', '-A', '-o', 'wide'] code, out, err = _run_kubectl(cmd_args) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") header = f"# {_display_cmd(cmd_args)} ({now}) [highlight: namespace={namespace}]" lines = [ln.rstrip('\n') for ln in out.splitlines() if ln.strip() != ""] if code != 0 and not lines: lines = ["(no output)"] pod_rows = [] unhappy = [] if lines: for idx, line in enumerate(lines): if idx == 0: pod_rows.append({'raw': line, 'ns': '', 'unhappy': False}) continue parts = line.split() if len(parts) < 4: pod_rows.append({'raw': line, 'ns': '', 'unhappy': False}) continue ns, name, ready, status = parts[0], parts[1], parts[2], parts[3] status_lc = status.lower() happy_status = status_lc in ('running', 'completed', 'succeeded') ready_ok = True if status_lc == 'running' and '/' in ready: try: a, b = ready.split('/', 1) ready_ok = (a == b) except Exception: ready_ok = False is_happy = happy_status and (ready_ok or status_lc in ('completed', 'succeeded')) if not is_happy: unhappy.append({'ns': ns, 'name': name, 'status': status, 'ready': ready}) pod_rows.append({'raw': line, 'ns': ns, 'unhappy': not is_happy}) details = "" if unhappy: blocks = ["", "# Unhealthy pods detected; collecting events + logs"] for pod in unhappy: ns = pod['ns'] name = pod['name'] blocks.append(f"\n## Events for {ns}/{name}") ec, eout, eerr = _run_kubectl([ 'get', 'events', '-n', ns, '--field-selector', f"involvedObject.kind=Pod,involvedObject.name={name}", '--sort-by=.lastTimestamp' ]) blocks.append(eout.strip() or eerr.strip() or "(no events output)") blocks.append(f"\n## Logs for {ns}/{name}") lc, lout, lerr = _run_kubectl([ 'logs', '-n', ns, name, '--all-containers', '--tail=200' ]) blocks.append(lout.strip() or lerr.strip() or "(no logs output)") details = "\n".join(blocks).rstrip() + "\n" elif err.strip(): details = "\n# kubectl stderr\n" + err.strip() + "\n" def render(): if not console.text.winfo_exists(): return text = console.text text.configure(state='normal') text.delete('1.0', 'end') text.tag_configure('header', foreground='#6e6e73') text.tag_configure('ns', background='#DDF4FF') text.tag_configure('bad', foreground='#B00020') text.insert('end', header + "\n\n", ('header',)) for row in pod_rows: tags = [] if row['ns'] == namespace: tags.append('ns') if row['unhappy']: tags.append('bad') if row['raw'].strip(): text.insert('end', row['raw'] + "\n", tuple(tags)) if details: text.insert('end', "\n" + details) text.see('1.0') text.configure(state='disabled') self.safe_after(render) threading.Thread(target=worker, daemon=True).start() def run_init_scripts(self): self._action_flags['init_scripts.run_scripts'] = True def worker(): def _select_tab(script_name): idx = getattr(self, '_script_tab_index', {}).get(script_name) if idx is None: return self.safe_after(lambda: self.script_tabs.select(idx) if self.script_tabs.winfo_exists() else None) self.safe_after(lambda: self._init_scripts_button.configure(state='disabled') if self._init_scripts_button.winfo_exists() else None) self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Running scripts...", fill='blue') if self.bg_canvas.winfo_exists() else None) password = self.db_password.get() # Prepare environment for scripts env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["PROLE_DB_USER"] = self.db_username.get() env["DB_PASSWORD"] = password env["GRAFANA_ADMIN_PASSWORD"] = password env["NAMESPACE"] = (self.db_namespace.get() or '').strip() env["SERVICE_NAMESPACE"] = self._get_service_namespace() if self.kerberos_realm.get().strip(): env["KRB5_REALM"] = self.kerberos_realm.get().strip() env["REALM"] = self.kerberos_realm.get().strip() env["DOMAIN"] = self.kerberos_realm.get().strip().lower() if self.kerberos_kdc.get().strip(): env["KRB5_KDC"] = self.kerberos_kdc.get().strip() env["KRB5_ADMIN"] = self.kerberos_kdc.get().strip() if self.kerberos_user.get().strip(): env["KRB5_USER"] = self.kerberos_user.get().strip() if self.kerberos_password.get().strip(): env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get()) env["ENABLED"] = env["KERBEROS_ENABLED"] mode = self._deployment_mode() if mode: env["PROLE_MODE"] = mode env["DEPLOYMENT_MODE"] = mode env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get()) mode_args = ["--mode", mode] if mode else [] logs_dir = self._resolve_prole_logs_dir() try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass env.setdefault("PROLE_LOGS", str(logs_dir)) def _log_path_for(script_name: str) -> Path: base = Path(script_name).stem if base in ('common_services_log', 'init_common_services'): return logs_dir / 'init_common_services.log' return logs_dir / f'{base}.log' # 1. init_common_services.sh update overall_success = True if overall_success: script = "init_common_services.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} update...\n") svc_log_path = _log_path_for(script) self._last_common_services_log_path = svc_log_path env["COMMON_SERVICES_INIT_LOG"] = str(svc_log_path) self._record_install_log(svc_log_path) self.script_consoles["common_services_log"].clear() self.script_consoles["common_services_log"].write(f"Log file: {svc_log_path}\n\n") self.script_consoles[script].write(f"Log file: {svc_log_path}\n\n") try: svc_fp = svc_log_path.open('a', encoding='utf-8') except Exception: svc_fp = None def _svc_line(line): self.script_consoles[script].write(line) self.script_consoles["common_services_log"].write(line) self._process_script_output_line(line) if svc_fp: try: svc_fp.write(line) svc_fp.flush() except Exception: pass svc_args = list(mode_args) if self.kerberos_enabled.get(): svc_args.append("-k") svc_args.append("update") rc_svc = self.controller.run_script( script, args=svc_args, env=env, on_line=_svc_line ) if svc_fp: try: svc_fp.close() except Exception: pass if rc_svc != 0: self.script_consoles[script].write(f"\nERROR: {script} update failed with code {rc_svc}\n") overall_success = False else: self.script_consoles[script].write(f"\n{script} completed successfully.\n") else: self.script_consoles["init_common_services.sh"].write("Skipping common services initialization because previous steps failed.\n") # 2. init_cloudnative_pg.sh initialize if overall_success: script = "init_cloudnative_pg.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} initialize...\n") self.script_consoles[script].write("> bash etc/init_cloudnative_pg.sh initialize\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open('a', encoding='utf-8') except Exception: log_fp = None def _cnpg_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc2 = self.controller.run_script( script, args=mode_args + ['initialize'], env=env, on_line=_cnpg_line ) if log_fp: try: log_fp.close() except Exception: pass if rc2 != 0: self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc2}\n") overall_success = False else: self.script_consoles[script].write(f"\n{script} completed successfully.\n") else: if "init_cloudnative_pg.sh" in self.script_consoles: self.script_consoles["init_cloudnative_pg.sh"].write("Skipping CloudNative-PG initialization because previous steps failed.\n") # 3. init_kerberos.sh initialize (optional) if overall_success and self.kerberos_enabled.get(): script = "init_kerberos.sh" if script in self.script_consoles: _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} initialize...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open('a', encoding='utf-8') except Exception: log_fp = None def _krb_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc_krb = self.controller.run_script( script, args=mode_args + ['initialize'], env=env, on_line=_krb_line ) if log_fp: try: log_fp.close() except Exception: pass if rc_krb != 0: self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_krb}\n") overall_success = False else: self.script_consoles[script].write(f"\n{script} completed successfully.\n") else: fallback_console = self.script_consoles.get("init_cloudnative_pg.sh") if fallback_console: fallback_console.write("Kerberos init console missing; skipping.\n") elif not self.kerberos_enabled.get(): if "init_kerberos.sh" in self.script_consoles: self.script_consoles["init_kerberos.sh"].write("Kerberos auth disabled; skipping init_kerberos.sh.\n") # 6. init_prole-db-backup.sh start if overall_success: script = "init_prole-db-backup.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} start...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open('a', encoding='utf-8') except Exception: log_fp = None def _backup_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc_backup = self.controller.run_script( script, args=mode_args + ['start'], env=env, on_line=_backup_line ) if log_fp: try: log_fp.close() except Exception: pass if rc_backup != 0: self.script_consoles[script].write(f"\nERROR: {script} start failed with code {rc_backup}\n") overall_success = False else: self.script_consoles[script].write(f"\n{script} completed successfully.\n") else: self.script_consoles["init_prole-db-backup.sh"].write("Skipping Prole DB backup because previous steps failed.\n") # 8. init_monitoring.sh initialize if overall_success: script = "init_monitoring.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} initialize...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open('a', encoding='utf-8') except Exception: log_fp = None def _monitoring_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass # Extract Grafana password if present if "GRAFANA_ADMIN_PASSWORD=" in line: pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip() if pwd: self.prole_cfg_data['Monitoring'] = {'GRAFANA_ADMIN_PASSWORD': pwd} self._save_prole_cfg() rc_mon = self.controller.run_script( script, args=mode_args + ['initialize'], env=env, on_line=_monitoring_line ) if log_fp: try: log_fp.close() except Exception: pass if rc_mon != 0: self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_mon}\n") overall_success = False else: self.script_consoles[script].write(f"\n{script} completed successfully.\n") else: if "init_monitoring.sh" in self.script_consoles: self.script_consoles["init_monitoring.sh"].write("Skipping Monitoring initialization because previous steps failed.\n") # 9. init_nginx_ingress.sh initialize if overall_success: script = "init_nginx_ingress.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} initialize...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open('a', encoding='utf-8') except Exception: log_fp = None def _ingress_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc_ing = self.controller.run_script( script, args=mode_args + ['initialize'], env=env, on_line=_ingress_line ) if log_fp: try: log_fp.close() except Exception: pass if rc_ing != 0: self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {rc_ing}\n") overall_success = False else: self.script_consoles[script].write(f"\n{script} completed successfully.\n") else: self.script_consoles["init_nginx_ingress.sh"].write("Skipping Nginx Ingress because previous steps failed.\n") # 10. init_port_forwards.sh start (k3d only) if overall_success and mode == "k3d": script = "init_port_forwards.sh" if script in self.script_consoles: _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} start...\n") def _pf_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) env_pf = dict(env) env_pf.setdefault("PORT_FORWARD_SKIP_VALIDATE", "1") env_pf.setdefault("PORT_FORWARD_SKIP_WAIT", "1") env_pf.setdefault("PORT_FORWARD_WAIT_TIMEOUT", "20") env_pf.setdefault("PORT_FORWARD_WAIT_INTERVAL", "2") rc_pf = self.controller.run_script( script, args=["--mode", mode, "start"], env=env_pf, on_line=_pf_line ) if rc_pf != 0: self.script_consoles[script].write(f"\nERROR: {script} start failed with code {rc_pf}\n") overall_success = False else: running = False for _ in range(10): status_lines = [] def _status_line(line): status_lines.append(line) self.script_consoles[script].write(line) rc_status = self.controller.run_script( script, args=["--mode", mode, "status", "grafana"], env=env_pf, on_line=_status_line ) running = any(l.lstrip().startswith("RUNNING") and "grafana" in l for l in status_lines) if rc_status == 0 and running: break time.sleep(1) if not running: self.script_consoles[script].write("\nERROR: Grafana port-forward not running after start.\n") overall_success = False else: self.script_consoles[script].write("\nPort-forwards running (grafana ok).\n") if overall_success: self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization complete!", fill='#34c759') if self.bg_canvas.winfo_exists() else None) self._scripts_success = True else: self.safe_after(lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Initialization failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None) self._scripts_success = False try: self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if self._scripts_success else 'Attempted' self.safe_after(self._save_prole_cfg) except Exception: pass self.safe_after(lambda: self.update_footer()) self.safe_after(lambda: self._init_scripts_button.configure(state='normal') if self._init_scripts_button.winfo_exists() else None) self.safe_after(self.check_services_status_async) threading.Thread(target=worker, daemon=True).start() def get_removable_disks(self): """Detect removable media on macOS using diskutil.""" self.removable_disks = [] if platform.system() != 'Darwin': return self.removable_disks try: # Get list of all disks res = subprocess.run(['diskutil', 'list', '-plist'], capture_output=True, text=True) if res.returncode != 0: return [] import plistlib data = plistlib.loads(res.stdout.encode()) all_disks = data.get('AllDisks', []) for disk in all_disks: # Filter for whole disks to check if they are removable if not disk.startswith('disk') or 's' in disk: continue info_res = subprocess.run(['diskutil', 'info', '-plist', disk], capture_output=True, text=True) if info_res.returncode == 0: info = plistlib.loads(info_res.stdout.encode()) # Check for RemovableMedia or RemovableMediaOrExternalDevice # Also check BusProtocol to catch most USB sticks if they don't report as removable is_removable = ( info.get('RemovableMedia', False) or info.get('RemovableMediaOrExternalDevice', False) or info.get('BusProtocol') in ['USB', 'FireWire', 'Thunderbolt'] ) # Ensure it's not the internal system drive if we are using protocol as a hint if info.get('Internal', False) and info.get('BusProtocol') not in ['USB']: is_removable = False if is_removable: # Found a removable disk, now find its mounted volumes # We look for partitions of this disk that are mounted for d2 in all_disks: if d2.startswith(disk + 's'): v_res = subprocess.run(['diskutil', 'info', '-plist', d2], capture_output=True, text=True) if v_res.returncode == 0: v_info = plistlib.loads(v_res.stdout.encode()) mount_point = v_info.get('MountPoint') volume_name = v_info.get('VolumeName') or v_info.get('DeviceIdentifier') if mount_point: self.removable_disks.append((volume_name, mount_point)) except Exception as e: print(f"Error detecting disks: {e}") return self.removable_disks def _render_disk_selection_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Deployment Destination.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Select Destination', y=150) self._render_paragraph("Choose where you would like to deploy the built Prole application and its supporting artifacts.", y=210) # Container for the two main options y_options = 300 x_center = content_width // 2 # We need large friendly images. I'll use placeholders if I can't find specific ones. # But I'll try to use symbols or colors for now if images are missing. try: from PIL import Image, ImageTk # Using proleIcon.png as a placeholder for both for now, but I'll add distinct styling icon_path = PROJECT_ROOT / 'img' / 'proleIcon.png' icon_img = Image.open(str(icon_path)).resize((128, 128), Image.LANCZOS) self._disk_icon_tk = ImageTk.PhotoImage(icon_img) except Exception: self._disk_icon_tk = None # Option 1: Removable Disk frame_usb = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20) usb_window = self.bg_canvas.create_window(x_center - 250, y_options, window=frame_usb, anchor='n', width=350) self._overlay_widgets.append(frame_usb) self._canvas_items.append(usb_window) if self._disk_icon_tk: lbl_img_usb = tk.Label(frame_usb, image=self._disk_icon_tk, bg='white', cursor='hand2') lbl_img_usb.pack() lbl_img_usb.bind("", lambda e: self.selected_disk_type.set('removable')) tk.Radiobutton(frame_usb, text="USB / Flash Drive", variable=self.selected_disk_type, value='removable', bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10) # Dropdown for removable disks disk_names = [d[0] for d in self.removable_disks] or ["No removable disks detected"] if not self.selected_removable_disk.get() and self.removable_disks: self.selected_removable_disk.set(self.removable_disks[0][1]) self.disk_dropdown = ttk.Combobox(frame_usb, values=disk_names, state="readonly", width=30) self.disk_dropdown.pack(pady=5) if disk_names: self.disk_dropdown.current(0) def on_disk_select(event): idx = self.disk_dropdown.current() if idx >= 0 and idx < len(self.removable_disks): self.selected_removable_disk.set(self.removable_disks[idx][1]) self.selected_disk_type.set('removable') self.disk_dropdown.bind("<>", on_disk_select) # Option 2: Local Folder frame_local = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20) local_window = self.bg_canvas.create_window(x_center + 250, y_options, window=frame_local, anchor='n', width=350) self._overlay_widgets.append(frame_local) self._canvas_items.append(local_window) if self._disk_icon_tk: lbl_img_local = tk.Label(frame_local, image=self._disk_icon_tk, bg='white', cursor='hand2') lbl_img_local.pack() lbl_img_local.bind("", lambda e: self.selected_disk_type.set('local')) tk.Radiobutton(frame_local, text="Local Filesystem", variable=self.selected_disk_type, value='local', bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10) # Path input and browse path_frame = tk.Frame(frame_local, bg='white') path_frame.pack(fill='x', pady=5) ent_path = tk.Entry(path_frame, textvariable=self.selected_local_path, font=('SF Pro Text', 10), width=30) ent_path.pack(side='left', padx=(0, 5)) def browse_local(): from tkinter import filedialog d = filedialog.askdirectory(initialdir=self.selected_local_path.get()) if d: self.selected_local_path.set(d) self.selected_disk_type.set('local') btn_browse = tk.Button(path_frame, text="Browse...", command=browse_local, bg='#F5F5DC') btn_browse.pack(side='left') def _render_build_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') # Shifted up to accommodate radios and standard console position self._render_title('Build Prole Tools.app', y=80) self._render_paragraph('Build and prepare Prole Tools.app for deployment.', y=130) # Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes) if not hasattr(self, 'deploy_env_value'): self.deploy_env_value = 'Dev' radio_y = 180 left = 56 spacing = 150 # Draw three radio options self._build_radio_items = [] options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)] for label, x in options: # outer circle r = 10 circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='black', width=2) self._canvas_items.append(circle) # selected dot if self.deploy_env_value == label: dot = ui.canvas_oval(self, x+5, radio_y+5, x+2*r-5, radio_y+2*r-5, fill='black', outline='') self._canvas_items.append(dot) text = ui.canvas_text(self, x + 2*r + 10, radio_y - 2, label, fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(text) self._build_radio_items.append((label, circle, text)) # Click handling for radio selection def _on_click(event): ex, ey = event.x, event.y for label, circle, text in self._build_radio_items: bbox_c = self.bg_canvas.bbox(circle) bbox_t = self.bg_canvas.bbox(text) hit = False if bbox_c and bbox_c[0] <= ex <= bbox_c[2] and bbox_c[1] <= ey <= bbox_c[3]: hit = True if bbox_t and bbox_t[0] <= ex <= bbox_t[2] and bbox_t[1] <= ey <= bbox_t[3]: hit = True if hit: self.deploy_env_value = label # Re-render only radios by re-drawing the page self._clear_canvas_page() self._render_build_page() self.update_footer() break self.bg_canvas.bind('', _on_click) # Output Console (Consistent size and location: y=260, width=900, height=520) self._build_console = self._create_console_output(y=230, title="Build Output", width=900, height=550) self._console_text = self._build_console.text # For compatibility with _append_console and others # Show a command preview with PS1-style prompt and blinking cursor preview = self._compose_build_preview() self._console_set_preview(preview) # Ensure state holders exist if not hasattr(self, 'last_build_log_path'): self.last_build_log_path = None # Update Next button label/state self.update_footer() # Bind Enter to trigger Build on this page def _enter_build(_evt=None): self.perform_build() try: self.root.bind('', _enter_build) self.root.bind('', _enter_build) except Exception: pass def _render_deploy_page(self): self._render_title('Deploy', y=40) self._render_paragraph('Preparing to deploy. We will verify steps and perform actions as needed.', y=90) # Render simple list of steps (static view). Runtime updates can redraw as needed. y = 140 left = 56 if not hasattr(self, 'deploy_steps'): self.deploy_steps = [ {'name': 'Build Prole macOS app', 'status': 'pending'}, {'name': 'Check Docker is running', 'status': 'pending'}, {'name': 'Install Prole Tools.app', 'status': 'pending'}, ] for step in self.deploy_steps: # status circle (pending empty) self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, outline='#b0b0b0')) self._canvas_items.append(ui.canvas_text(self, left+26, y-2, step['name'], fill='#1d1d1f', font=('Helvetica', 12))) y += 26 def on_deploy(self): if getattr(self, '_deploy_running', False): return self._deploy_running = True try: if self.deploy_button: self.deploy_button.configure(state='disabled') except Exception: pass def worker(): err = None try: self.ensure_prole_env() self.reload_env_from_shell() except Exception as e: err = f"Failed to load environment: {e}" try: self._ensure_prole_directories() if 'Install' in self.prole_cfg_data: self.prole_cfg_data['Install']['STATUS'] = 'Deployed' self._save_prole_cfg() except Exception as e: err = err or f"Failed to prepare Prole directories: {e}" if err: self.safe_after(lambda: messagebox.showerror("Deploy", err)) else: self.safe_after(self.open_drag_install_window) def _finish(): self._deploy_running = False try: if self.deploy_button: self.deploy_button.configure(state='normal') except Exception: pass self.safe_after(_finish) threading.Thread(target=worker, daemon=True).start() def on_launch(self): app_path = self._get_prole_dist_dir() / 'Prole Tools.app' if app_path.exists(): try: subprocess.Popen(['open', str(app_path)]) except Exception: pass else: try: messagebox.showerror("Launch", f"App not found at {app_path}. Please build it first.") except Exception: pass def on_prev(self): # Custom prev navigation for dependency pages when filtering current_id = self.pages[self.page_index][0] # Close Terminal if leaving build summary via Prev if current_id == 'build_summary': try: self.close_build_terminal() except Exception: pass if current_id.startswith('dep_'): seq = self._dep_navigation_sequence() try: i = seq.index(current_id) except ValueError: i = -1 if i > 0: self.show_page(seq[i - 1]) return else: # Go back to summary if there is no previous in sequence self.show_page('deps_summary') return if current_id == 'deps_summary': self.show_page('welcome') return if current_id == 'network_scan': # Go back to last relevant dep or deps_summary if self.all_dependencies_installed(): self.show_page('deps_summary') else: seq = self._dep_navigation_sequence() self.show_page(seq[-1] if seq else 'deps_summary') return if current_id == 'env_setup': self.show_page('network_scan') return if current_id == 'init_cluster': if getattr(self, '_showing_service_overlay', False): self._showing_service_overlay = False self.show_page('init_cluster') return self.show_page('env_setup') return if current_id == 'init_password': self.show_page('init_cluster') return if current_id == 'init_db_build': self.show_page('init_password') return if current_id == 'init_scripts': if self.kerberos_enabled.get(): self.show_page('kerberos_config') else: self.show_page('init_db_build') return if current_id == 'kerberos_config': self.show_page('init_db_build') return if current_id == 'ollama_config': self.show_page('init_scripts') return if current_id == 'supabase_config': self.show_page('ollama_config') return if current_id == 'init_cnpg_deploy': if self.supabase_enabled.get(): self.show_page('supabase_config') return self.show_page('ollama_config') return if current_id == 'create_installer': self.show_page('init_cnpg_deploy') return # Default prev if self.page_index > 0: self.show_page(self.page_index - 1) return def on_next(self): # Special handling for dynamic labels current_id = self.pages[self.page_index][0] print(f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}") if current_id == 'welcome': self.show_page('deps_summary') return if current_id == 'deps_summary': # Determine where to go from summary # Always go to the next dependency or Network Scan if self.all_dependencies_installed(): print("[DEBUG] on_next: all deps installed, going to network_scan") self.show_page('network_scan') return # Go to first missing dependency page seq = self._dep_navigation_sequence() print(f"[DEBUG] on_next: missing deps sequence: {seq}") if seq: self.show_page(seq[0]) else: print("[DEBUG] on_next: all deps seem OK in sequence, going to network_scan") self.show_page('network_scan') return if current_id.startswith('dep_'): # Navigate within dependency sequence seq = self._dep_navigation_sequence() print(f"[DEBUG] on_next: dep sequence: {seq}") try: i = seq.index(current_id) except ValueError: i = -1 if i >= 0 and i < len(seq) - 1: self.show_page(seq[i + 1]) return else: # After last relevant dep page, go back to summary if anything is still missing if not self.all_dependencies_installed(): print("[DEBUG] on_next: some deps still missing, returning to deps_summary") self.show_page('deps_summary') else: print("[DEBUG] on_next: all deps now installed, going to network_scan") self.show_page('network_scan') return if current_id == 'network_scan': # Capture network scan info try: self.prole_cfg_data['Network']['KDC_AUTO_DETECTED'] = self.kerberos_kdc.get() self.prole_cfg_data['Network']['KERBEROS_AUTO_ENABLED'] = str(self.kerberos_enabled.get()) self._save_prole_cfg() except Exception: pass self.show_page('env_setup') return if current_id == 'env_setup': # Collect values, validate, write env.sh, then go to Database Creation vals = {} try: for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'): vals[k] = self._env_entries[k].get().strip() self.prole_cfg_data['System Environment'][k] = vals[k] vals['NAMESPACE'] = (self.db_namespace.get() or '').strip() except Exception: vals = self._env_defaults() # Basic validation: require non-empty PROLE_HOME if not vals.get('PROLE_HOME'): try: messagebox.showerror('Environment', 'Please specify PROLE_HOME') except Exception: pass return try: self._save_env_to_file(vals) # Reload our environment so subsequent steps (Build) inherit it self._after_env_saved() except Exception as e: try: messagebox.showwarning('Environment', f'Could not save env.sh: {e}') except Exception: pass return self.show_page('init_cluster') return if current_id == 'init_password': p1 = self.db_password.get() p2 = self.db_password_confirm.get() if not p1: try: messagebox.showerror('Password', 'Password cannot be empty.') except Exception: pass return if p1 != p2: try: messagebox.showerror('Password', 'Passwords do not match.') except Exception: pass return if not self._validate_port_forward_overlaps(): return self._run_preparation_overlay() return if current_id == 'init_db_build': if self.kerberos_enabled.get(): self.show_page('kerberos_config') else: self.show_page('init_scripts') return if current_id == 'build': if getattr(self, '_built_success', False): self.show_page('create_installer') else: self.perform_build() return if current_id == 'init_cluster': # 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 if not getattr(self, '_common_services_success', False): self._run_service_layer_overlay(next_page='init_password') return self.show_page('init_password') return if current_id == 'init_scripts': self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if getattr(self, '_scripts_success', False) else 'Attempted' self._save_prole_cfg() self.show_page('ollama_config') return if current_id == 'kerberos_config': # Capture kerberos config self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get()) self.prole_cfg_data['Kerberos Authentication']['REALM'] = self.kerberos_realm.get() self.prole_cfg_data['Kerberos Authentication']['KDC'] = self.kerberos_kdc.get() self.prole_cfg_data['Kerberos Authentication']['SERVER'] = self.kerberos_kdc.get() self.prole_cfg_data['Kerberos Authentication']['USER'] = self.kerberos_user.get() self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = self.kerberos_password.get() self.prole_cfg_data['Kerberos Authentication']['AD_PORT_FORWARD'] = os.environ.get('KRB5_AD_PORT_FORWARD', '1') self.prole_cfg_data['Kerberos Authentication']['AD_TCP_PORTS'] = os.environ.get('KRB5_AD_TCP_PORTS', '88 389 445 464 636') self.prole_cfg_data['Kerberos Authentication']['AD_UDP_PORTS'] = os.environ.get('KRB5_AD_UDP_PORTS', '88 464') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_HOST_NETWORK'] = os.environ.get('KRB5_AD_PROXY_HOST_NETWORK', '1') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_IMAGE'] = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat') self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_SERVICE'] = os.environ.get('KRB5_AD_SERVICE_NAME', 'prole-kerberos-ad-dc') self._save_prole_cfg() self.show_page('init_scripts') return if current_id == 'ollama_config': self._save_ollama_config() if self.supabase_enabled.get(): self.show_page('supabase_config') else: self.show_page('init_cnpg_deploy') return if current_id == 'supabase_config': self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get()) self.prole_cfg_data['Supabase'] = {'STATUS': 'Deployed' if getattr(self, '_supabase_success', False) else 'Attempted'} self._save_prole_cfg() self.show_page('init_cnpg_deploy') return if current_id == 'init_cnpg_deploy': self.prole_cfg_data['Deployment']['STATUS'] = 'Deployed' if getattr(self, '_cnpg_success', False) else 'Attempted' self._save_prole_cfg() self.show_page('create_installer') return if current_id == 'create_installer': print("[DEBUG] on_next: at create_installer, Finish clicked. Closing.") if 'Install' in self.prole_cfg_data: self.prole_cfg_data['Install']['STATUS'] = 'Finished' self._save_prole_cfg() self.root.destroy() return # Default next if self.page_index < len(self.pages) - 1: print(f"[DEBUG] on_next: default next to index {self.page_index + 1}") self.show_page(self.page_index + 1) else: print("[DEBUG] on_next: already at last page") return def update_footer(self): # Default states for tk.Button self.prev_button.configure(state='normal') self.next_button.configure(state='normal') first = self.page_index == 0 # Base label self.next_button.configure(text='Next') if getattr(self, '_showing_service_overlay', False): if not getattr(self, '_common_services_success', False): self.next_button.configure(state='disabled') else: self.next_button.configure(state='normal') # Page-specific adjustments pid = self.pages[self.page_index][0] if pid == 'build': # Build page: show Build or Next depending on state if getattr(self, '_built_success', False): self.next_button.configure(text='Next') else: self.next_button.configure(text='Build') elif pid == 'supabase_config': # Supabase screen: Next is disabled until success if getattr(self, '_supabase_success', False): self.next_button.configure(state='normal') else: self.next_button.configure(state='disabled') elif pid == 'init_scripts': # Initialization Scripts: Next is disabled until success if getattr(self, '_scripts_success', False): self.next_button.configure(state='normal') else: self.next_button.configure(state='disabled') # Visibility rules self.prev_button.pack_forget() self.next_button.pack_forget() if self.deploy_button: self.deploy_button.pack_forget() if self.launch_button: self.launch_button.pack_forget() if self.gear_button: self.gear_button.pack_forget() if pid == 'init_password' and self.gear_button: self.gear_button.pack(side='left', padx=(20, 0), pady=12) if pid == 'create_installer': self.next_button.configure(text='Finish') self.next_button.pack(side='right', padx=(0, 20), pady=12) return if first: self.next_button.pack(side='right', padx=(0, 20), pady=12) else: # [Prev] [Next] clustered right self.next_button.pack(side='right', padx=(0, 20), pady=12) self.prev_button.pack(side='right', padx=(0, 8), pady=12) def _splash_should_hide_nav(self) -> bool: """Deprecated: Welcome page no longer performs dependency checks or gates navigation.""" return False def _create_page_welcome(self): f = self._page_container() ttk.Label(f, text='Welcome to Prole', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) msg = ( 'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.' ) ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) # self._register_page('welcome', f) def _create_page_dependencies_summary(self): f = self._page_container() ttk.Label(f, text='Dependencies', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) self.deps_container = ttk.Frame(f) self.deps_container.pack(fill='both', expand=True, padx=16, pady=8) self.dep_status = {} for dep in self.dependencies: row = ttk.Frame(self.deps_container) row.pack(fill='x', pady=6) indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0) indicator.pack(side='left', padx=8) name = ttk.Label(row, text=dep['name'], style='Body.TLabel') name.pack(side='left') info = ttk.Label(row, text='', style='Dim.TLabel') info.pack(side='left', padx=10) self.dep_status[dep['id']] = {'canvas': indicator, 'info': info, 'dep': dep} # Verify all checkbox (controls navigation behavior) chk = ttk.Checkbutton(f, text='Verify all dependencies', variable=self.verify_mode, command=self.update_footer) chk.pack(anchor='w', padx=24, pady=(4, 0)) # Message label must be created before refresh to avoid AttributeError self.deps_msg = ttk.Label(f, text='', style='Dim.TLabel') self.deps_msg.pack(anchor='w', padx=24, pady=(8, 8)) # Now we can safely populate the UI. Start async scan to avoid startup delay self.deps_msg.configure(text='Checking dependencies...') self.start_dependency_scan() # self._register_page('deps_summary', f) def start_dependency_scan(self): """Scan dependencies in a background thread to avoid blocking UI startup.""" if self.validation_running: return self.validation_running = True # Hold incremental results so we can update the status line and icons progressively self._dep_scan_results = {} def worker(): for dep in self.dependencies: try: ok, location, version = self.get_dep_info(dep) except Exception: ok, location, version = False, None, None did = dep['id'] # Save and apply incrementally self._dep_scan_results[did] = (ok, location, version) self.safe_after(lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(d, o, l, v)) try: time.sleep(0.02) except Exception: pass # After all are processed, finalize pass to unify any remaining labels self.safe_after(lambda: self._apply_dependency_scan(dict(self._dep_scan_results))) t = threading.Thread(target=worker, daemon=True) self.validation_thread = t t.start() def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version): """Update the dependencies page row and message as each check completes.""" if not hasattr(self, 'dep_status'): return slot = self.dep_status.get(dep_id) if not slot: return # Update dot icon and info text for this row self._draw_status(slot['canvas'], 'success' if ok else 'error') if ok: norm_ver = self.normalize_version(version) if version else '' slot['info'].configure(text=(norm_ver or '')) else: slot['info'].configure(text='Not installed') # Update the message line with current missing list if hasattr(self, '_dep_scan_results'): missing = [self.dep_status[d]['dep']['name'] for d, res in self._dep_scan_results.items() if not res[0] and d in self.dep_status] if hasattr(self, 'deps_msg') and self.deps_msg is not None: if missing: self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") else: self.deps_msg.configure(text='All dependencies installed.') # Footer may need to react if verify mode is enabled self.update_footer() # Update prole.cfg data try: status_str = f"installed (version {version})" if ok else "not installed" self.prole_cfg_data['Dependencies'][dep_id] = status_str self._save_prole_cfg() except Exception: pass def _apply_dependency_scan(self, results: dict): """Apply scan results to the summary UI and footer labels.""" self.validation_running = False any_missing = False for did, slot in self.dep_status.items(): ok, location, version = results.get(did, (False, None, None)) self._draw_status(slot['canvas'], 'success' if ok else 'error') if ok: norm_ver = self.normalize_version(version) if version else '' slot['info'].configure(text=(norm_ver or '')) else: any_missing = True slot['info'].configure(text='Not installed') has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None if any_missing: missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]] if missing and has_msg: self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}") else: if has_msg: self.deps_msg.configure(text='All dependencies installed.') self.update_footer() def _create_page_dependency(self, dep): f = self._page_container() ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24) status_var = tk.StringVar(value='Checking...') loc_var = tk.StringVar(value='') ver_var = tk.StringVar(value='') info_frame = ttk.Frame(f) info_frame.pack(fill='x', padx=24, pady=12) # Pretty status row with an icon status_row = ttk.Frame(info_frame) status_row.pack(anchor='w', fill='x') status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0) status_icon.pack(side='left', padx=(0, 8), pady=(2, 0)) ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left') # Detail rows (hidden when not installed) loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel') ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel') loc_label.pack(anchor='w') ver_label.pack(anchor='w') link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel') link.pack(anchor='w', padx=24, pady=(12, 0)) def _draw_status_icon(canvas, status): ui.canvas_clear(canvas) if status == 'installed': ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#34c759', outline='') ui.canvas_line_on(canvas, 4, 9, 8, 13, fill='white', width=2) ui.canvas_line_on(canvas, 8, 13, 15, 5, fill='white', width=2) elif status == 'missing': # Friendly warning dot with exclamation ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#ff9f0a', outline='') ui.canvas_line_on(canvas, 9, 5, 9, 11, fill='white', width=2) ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill='white', outline='white') def check_then_update(): ok, location, version = self.get_dep_info(dep) if ok: status_var.set('Installed') _draw_status_icon(status_icon, 'installed') loc_var.set(f'Location: {location or ""}') norm_ver = self.normalize_version(version) if version else None ver_var.set(f'Version: {norm_ver or ""}') # Ensure details visible try: loc_label.pack_configure() ver_label.pack_configure() link.pack_forget() except Exception: pass else: status_var.set('Not installed') _draw_status_icon(status_icon, 'missing') # Hide undefined details instead of showing dashes try: loc_label.pack_forget() ver_label.pack_forget() except Exception: pass try: # Keep install link visible if we have a command if dep.get('install_cmd') and not link.winfo_ismapped(): link.pack(anchor='w', padx=24, pady=(12, 0)) except Exception: pass self.root.after(50, check_then_update) def do_install(event=None): self.open_terminal_with_command(dep.get('install_cmd')) if dep.get('install_cmd'): link.bind('', do_install) else: try: link.pack_forget() except Exception: pass # self._register_page(f'dep_{dep["id"]}', f) def _dep_navigation_sequence(self, force_all: bool = False): """Return a list of dependency page ids to traverse next. - If force_all is True or verify_mode is True: include all dep pages in defined order. - Else: include only missing dependency pages based on current checks. """ if force_all or self.verify_mode.get(): res = [f'dep_{d["id"]}' for d in self.dependencies] print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}") return res seq = [] for d in self.dependencies: ok, _, _ = self.get_dep_info(d) if not ok: seq.append(f'dep_{d["id"]}') print(f"[DEBUG] _dep_navigation_sequence: {seq}") return seq def _create_page_build(self): f = self._page_container() ttk.Label(f, text='Build', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) ttk.Label(f, text='Choose a target and build the artifacts.', style='Body.TLabel').pack(anchor='w', padx=24) wrap = ttk.Frame(f) wrap.pack(anchor='w', padx=24, pady=12) ttk.Label(wrap, text='Target Environment:', style='Body.TLabel').pack(side='left') self.deploy_environment = tk.StringVar(value='Dev') ttk.Combobox(wrap, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10) # Build output console # Use a background frame to ensure NO borders are visible around the console console_bg = tk.Frame(f, bg='white', highlightthickness=0, bd=0) console_bg.pack(fill='both', expand=True, padx=24, pady=12) self.build_output_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) self.build_output_console.pack(fill='both', expand=True, padx=1, pady=1) self.build_output = self.build_output_console.text # self._register_page('build', f) def _create_page_deploy(self): f = self._page_container() ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6)) ttk.Label(f, text='Preparing to deploy. We will verify steps and perform actions as needed.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24) # Reuse existing deploy steps UI but on light theme self.deploy_steps = [ {'name': 'Build Prole macOS app', 'status': 'pending'}, {'name': 'Check Docker is running', 'status': 'pending'}, {'name': 'Check or configure container registry', 'status': 'pending'}, {'name': 'Ensure target cluster', 'status': 'pending'}, {'name': 'Build prole-db Docker image', 'status': 'pending'}, {'name': 'Tag Docker image for registry', 'status': 'pending'}, {'name': 'Push image to registry', 'status': 'pending'}, {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, {'name': 'Install LaunchAgent for port-forwards (Dev)', 'status': 'pending'}, ] self.deploy_widgets = {} container = ttk.Frame(f) container.pack(fill='both', expand=True, padx=16, pady=8) for step in self.deploy_steps: self._create_deploy_row(container, step) # self._register_page('deploy', f) def _create_deploy_row(self, parent, step): row = ttk.Frame(parent) row.pack(fill='x', pady=6) canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0) canvas.pack(side='left', padx=8) lbl = ttk.Label(row, text=step['name'], style='Body.TLabel') lbl.pack(side='left') status = ttk.Label(row, text='Pending', style='Dim.TLabel') status.pack(side='right', padx=8) self.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step} self._draw_status(canvas, 'pending') def _draw_status(self, canvas, status): ui.canvas_clear(canvas) if status == 'success': ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#34c759', outline='') ui.canvas_line_on(canvas, 5, 10, 9, 14, fill='white', width=2) ui.canvas_line_on(canvas, 9, 14, 16, 6, fill='white', width=2) elif status == 'running': ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ffd60a', outline='') elif status == 'error': ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ff3b30', outline='') else: ui.canvas_oval_on(canvas, 2, 2, 18, 18, outline='#b0b0b0') # --------------- Dependency helpers --------------- def refresh_dependencies_ui(self): """Deprecated synchronous refresh retained for compatibility. Prefer start_dependency_scan -> _apply_dependency_scan. """ self.start_dependency_scan() def all_dependencies_installed(self): for dep in self.dependencies: ok, _, _ = self.get_dep_info(dep) if not ok: print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING") return False print("[DEBUG] all_dependencies_installed: YES (all OK)") return True def get_dep_info(self, dep): """Delegate dependency probing to installer.config.get_dep_info.""" return inst_config.get_dep_info(dep) def normalize_version(self, text: str) -> str: """Normalize versions via installer.config.normalize_version.""" return inst_config.normalize_version(text) def open_terminal_with_command(self, command: str | None): if not command: return try: # Always create a brand-new Terminal window and paste via clipboard if platform.system() == 'Darwin': win_id = self._terminal_create_new_window() if win_id: # Position reasonably l, t, r, b = self._compute_terminal_bounds(radio_bottom_y=120) self._terminal_set_bounds_by_id(win_id, l, t, r, b) self._terminal_paste_by_id(win_id, command, press_enter=False) return # Fallback: copy to clipboard and open Terminal subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(command)} | pbcopy && open -a Terminal']) except Exception: webbrowser.open_new_tab('https://brew.sh') # ---------------- Build integration ---------------- def perform_build(self): """Run Prole build in an embedded console (Scoped bash subprocess).""" self._action_flags['build.run_build'] = True # Ensure environment exists and is readable before attempting build try: self.ensure_prole_env() except Exception as e: # Print a readable error to the console area and abort try: self._console_press_enter() except Exception: pass err_msg = str(e).replace("'", "'\\''") err = f"echo 'ERROR: {err_msg}' && exit 1" self._run_in_console(err, None, on_complete=lambda rc: None) return # Prepare logs dir and file try: self._build_attempted = True except Exception: pass # reset success flag until proven otherwise try: self._built_success = False except Exception: pass logs_dir = self._resolve_prole_logs_dir() try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass ts = time.strftime('%Y%m%d-%H%M%S') log_path = logs_dir / f'build-{ts}.log' self.last_build_log_path = str(log_path) self._record_install_log(log_path) # Compose build command env = getattr(self, 'deploy_env_value', 'Dev') base_cmd = self.get_build_command(env) # Hostname safety guard: ensure commands run only on the machine that launched the installer guard = '' if getattr(self, 'expected_host', None): eh = self.expected_host guard = f'host=$(hostname -s); if [ "$host" != "{eh}" ]; then echo "ERROR: wrong host $host (expected {eh})"; exit 1; fi; ' # Simulate pressing Enter on the previewed command; do not echo a duplicate command self._console_press_enter() # Compose a verbose wrapped build command with environment diagnostics and tracing full_cmd = guard + self._compose_verbose_build_command(base_cmd) # Run in embedded console self._run_in_console(full_cmd, self.last_build_log_path, on_complete=lambda rc: self._on_build_complete(rc)) def _compose_verbose_build_command(self, base_cmd: str) -> str: """Wrap the provided build command with a verbose, diagnostic-rich shell script. Adds: - Timestamps and section headers - Platform/OS/tooling info (uname, macOS version, Xcode/Swift, Java, Maven, Docker, Git) - set -euxo pipefail for tracing and early failure - Echo of the actual build command """ # Use portable bash; guard external tool probes to avoid hard failures prologue = r''' echo "====[PROLE] Build started $(date '+%Y-%m-%d %H:%M:%S %Z')"; echo "---- System -------------------------------------------------------"; uname -a || true; printf "ARCH=%s\n" "$(uname -m)" || true; if command -v sw_vers >/dev/null 2>&1; then sw_vers || true; fi; echo "---- Tooling ------------------------------------------------------"; if command -v xcodebuild >/dev/null 2>&1; then xcodebuild -version || true; else echo "xcodebuild: not found"; fi; if command -v swift >/dev/null 2>&1; then swift --version || true; else echo "swift: not found"; fi; if command -v java >/dev/null 2>&1; then java -version 2>&1 | sed 's/^/java: /'; else echo "java: not found"; fi; if command -v mvn >/dev/null 2>&1; then mvn -v || true; else echo "mvn: not found"; fi; if command -v docker >/dev/null 2>&1; then docker version || true; else echo "docker: not found"; fi; echo "---- Git ----------------------------------------------------------"; if command -v git >/dev/null 2>&1; then \ (git -C "$(pwd)" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \ echo "repo: $(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")" && \ echo "branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null)" && \ echo "commit: $(git rev-parse --short HEAD 2>/dev/null)" && \ git status --porcelain=v1 | sed 's/^/ /' || true) || echo "not a git repo"; \ else echo "git: not found"; fi; echo "-------------------------------------------------------------------"; ''' # The actual build with tracing and timing wrapped = f""" ( set -euxo pipefail {prologue} echo "====[PROLE] Executing build command:"; printf '%s\n' {shlex.quote(base_cmd)}; echo "-------------------------------------------------------------------"; start_ts=$(date +%s || echo 0); {base_cmd} rc=$? end_ts=$(date +%s || echo 0); dur=$((end_ts - start_ts)); echo "-------------------------------------------------------------------"; if [ $rc -eq 0 ]; then echo "====[PROLE] Build finished OK in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; else echo "====[PROLE] Build FAILED (rc=$rc) in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')"; fi exit $rc ) """ return wrapped def _on_build_complete(self, returncode: int): # After build completes, update state and check for removable disks try: self._built_success = (returncode == 0) except Exception: self._built_success = False # Re-enable Next button try: self.next_button.configure(state='normal') except Exception: pass # Navigate to disk_selection if removable disks exist, otherwise build_summary if getattr(self, '_built_success', False): disks = self.get_removable_disks() if disks: self.show_page('disk_selection') else: self.show_page('build_summary') else: # Build failed, stay on build page pass # ---------------- Embedded console helpers ---------------- def _ensure_console_overlay(self, radio_bottom_y: int = 160): """Create semi-transparent backdrop and a ScrolledText console overlay. The overlay is placed within slide_area between given top and bottom margins. """ # Ensure slide_area is visible self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) self.slide_area.lift() # Compute geometry within slide area geom = self._compute_console_geometry(radio_bottom_y) left, top, width, height = geom # Use a background frame to ensure NO borders are visible around the console console_bg = tk.Frame(self.slide_area, bg='white', highlightthickness=0, bd=0) console_bg.place(x=left, y=top, width=width, height=height) # Create TerminalConsole overlay console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) console.pack(fill='both', expand=True, padx=1, pady=1) self._overlay_widgets.append(console_bg) self._overlay_widgets.append(console) self._console_text = console.text # Keep overlay positioned on resize def _on_resize(_evt=None): l, t, w, h = self._compute_console_geometry(radio_bottom_y) try: console_bg.place(x=l, y=t, width=w, height=h) except Exception: pass # Bind to slide area; store bind id to unbind later self._overlay_bind_id = self.slide_area.bind('', _on_resize) def _compute_console_geometry(self, radio_bottom_y: int) -> tuple[int, int, int, int]: """Return (left, top, width, height) for the console overlay area.""" try: w = self.slide_area.winfo_width() h = self.slide_area.winfo_height() except Exception: w, h = 1300 * 0.75, 910 - 64 # Content area size approx margin = 24 top = max(radio_bottom_y + 10, 120) bottom = max(top + 180, h - 16) # ensure some height height = max(160, bottom - top - 80) if bottom - top > 260 else max(140, h - top - 24) # Recompute bottom based on height bottom = min(h - 24, top + height) left = margin width = max(300, w - 2 * margin) return (left, top, width, bottom - top) def _append_console(self, text: str): txt = getattr(self, '_console_text', None) if not txt: return try: if not txt.winfo_exists(): return txt.configure(state='normal') txt.insert('end', text) txt.see('end') txt.configure(state='disabled') except Exception: pass def _console_press_enter(self): """Simulate pressing Enter on the console preview line: remove blinking cursor if present and add a newline.""" txt = getattr(self, '_console_text', None) if not txt: return # Stop cursor blinking if getattr(self, '_cursor_blink_after_id', None): try: self.root.after_cancel(self._cursor_blink_after_id) except Exception: pass self._cursor_blink_after_id = None self._cursor_blink_visible = False try: txt.configure(state='normal') # If last char is our fake cursor, remove it try: last_char = txt.get('end-2c', 'end-1c') if last_char in ('_', '|'): txt.delete('end-2c', 'end-1c') except Exception: pass txt.insert('end', '\n') txt.see('end') txt.configure(state='disabled') except Exception: pass # ----- Command preview & blinking cursor helpers ----- def _get_user_host(self) -> tuple[str, str]: try: user = os.environ.get('USER') or os.getlogin() except Exception: user = 'user' host = getattr(self, 'expected_host', None) or (platform.node() or 'host').split('.')[0] return user, host def _compose_build_preview(self) -> str: env = getattr(self, 'deploy_env_value', 'Dev') cmd = self.get_build_command(env) user, host = self._get_user_host() return f"[{user}@{host}]# {cmd}" def _console_set_preview(self, line: str): """Clear console and show a single-line preview with blinking cursor.""" txt = getattr(self, '_console_text', None) if not txt: return # Stop any previous blinking first if getattr(self, '_cursor_blink_after_id', None): try: self.root.after_cancel(self._cursor_blink_after_id) except Exception: pass self._cursor_blink_after_id = None self._cursor_blink_visible = False try: txt.configure(state='normal') txt.delete('1.0', 'end') txt.insert('end', line) txt.see('end') txt.configure(state='disabled') except Exception: return # Start blinking cursor at end of line def blink(): t = getattr(self, '_console_text', None) if t is None: self._cursor_blink_after_id = None return try: t.configure(state='normal') # Remove existing cursor if self._cursor_blink_visible: # Delete last character if it's our cursor end_index = t.index('end-1c') if end_index and end_index != '1.0': last_char = t.get('end-2c', 'end-1c') if last_char in ('_', '|'): t.delete('end-2c', 'end-1c') self._cursor_blink_visible = False else: # Append cursor t.insert('end', '_') self._cursor_blink_visible = True t.see('end') t.configure(state='disabled') except Exception: self._cursor_blink_after_id = None return # schedule next toggle self._cursor_blink_after_id = self.root.after(600, blink) self._cursor_blink_after_id = self.root.after(600, blink) def _run_in_console(self, command: str, log_path: str, on_complete=None): """Run a bash -lc command in a background subprocess and stream output to the console and a log file. This function does not echo the command into the console so that the UI behaves like pressing Enter on the previously previewed command line. """ # Ensure console exists if not getattr(self, '_console_text', None): self._ensure_console_overlay(160) # Stop cursor blinking before starting execution if getattr(self, '_cursor_blink_after_id', None): try: self.root.after_cancel(self._cursor_blink_after_id) except Exception: pass self._cursor_blink_after_id = None self._cursor_blink_visible = False # Terminate any previous process if getattr(self, '_running_process', None): self._terminate_running_process() # Open log file try: self._console_log_fp = open(log_path, 'a', buffering=1, encoding='utf-8') except Exception: self._console_log_fp = None # Start process group for safe termination def preexec(): try: os.setsid() except Exception: pass try: proc = subprocess.Popen(['bash', '-lc', command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, preexec_fn=preexec) self._running_process = proc except Exception as e: self._append_console(f"Failed to start process: {e}\n") if self._console_log_fp: try: self._console_log_fp.write(f"Failed to start process: {e}\n") except Exception: pass self._running_process = None return # Disable Next while running try: self.next_button.configure(text='Building…', state='disabled') except Exception: pass # Reader thread def reader(): rc = None try: for line in proc.stdout: if line is None: break self.safe_after(lambda s=line: self._append_console(s)) if self._console_log_fp: try: self._console_log_fp.write(line) except Exception: pass rc = proc.wait() except Exception: pass finally: if self._console_log_fp: try: self._console_log_fp.flush() self._console_log_fp.close() except Exception: pass self._console_log_fp = None self._running_process = None if on_complete: self.safe_after(lambda: on_complete(rc if rc is not None else -1)) t = threading.Thread(target=reader, daemon=True) t.start() def _terminate_running_process(self): proc = getattr(self, '_running_process', None) if not proc: return try: pgid = os.getpgid(proc.pid) os.killpg(pgid, signal.SIGTERM) except Exception: try: proc.terminate() except Exception: pass # best-effort kill after short delay try: for _ in range(10): if proc.poll() is not None: break time.sleep(0.05) if proc.poll() is None: try: pgid = os.getpgid(proc.pid) os.killpg(pgid, signal.SIGKILL) except Exception: proc.kill() except Exception: pass self._running_process = None # ---------------- Build helpers (Terminal window management) ---------------- def _terminal_create_new_window(self) -> str | None: """Create a brand-new Terminal window (never reuse existing) and return its id.""" if platform.system() != 'Darwin': return None osa = ''' tell application "Terminal" to activate delay 0.05 tell application "System Events" if exists process "Terminal" then tell process "Terminal" set frontmost to true try click menu item "New Window" of menu "Shell" of menu bar 1 on error keystroke "n" using {command down} end try end tell end if end tell delay 0.1 tell application "Terminal" try set _w to front window set _id to id of _w do script "" in _w return _id on error return "" end try end tell ''' try: result = subprocess.run(['osascript', '-e', osa], capture_output=True, text=True) if result.returncode == 0: sid = result.stdout.strip() return sid or None except Exception: pass return None def _terminal_set_bounds_by_id(self, win_id: str, l: int, t: int, r: int, b: int): if platform.system() != 'Darwin' or not win_id: return osa = f'''tell application "Terminal" to try set the bounds of every window whose id is {win_id} to {{{l}, {t}, {r}, {b}}} end try''' try: subprocess.run(['osascript', '-e', osa]) except Exception: pass def _terminal_paste_by_id(self, win_id: str, text: str, press_enter: bool = False): if platform.system() != 'Darwin' or not win_id: return # Put text on clipboard and paste into our specific window try: subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(text)} | pbcopy']) except Exception: pass osa = ''' tell application "Terminal" try set _wins to every window whose id is {win_id} if (count of _wins) > 0 then set front window to item 1 of _wins end try activate end tell delay 0.05 tell application "System Events" keystroke "v" using {command down} end tell ''' if press_enter: osa += '\n' + 'tell application "System Events" to key code 36' try: subprocess.run(['osascript', '-e', osa]) except Exception: pass def _compute_terminal_bounds(self, radio_bottom_y: int = 160) -> tuple[int, int, int, int]: """Compute terminal window bounds (left, top, right, bottom) to fit inside the installer window between the radio buttons and the footer.""" try: # Window absolute position x0 = self.root.winfo_rootx() y0 = self.root.winfo_rooty() w = self.root.winfo_width() h = self.root.winfo_height() except Exception: # Reasonable defaults x0, y0, w, h = 200, 200, 1300, 910 margin = 24 top = y0 + radio_bottom_y + 10 bottom = y0 + h - 90 # leave space for footer # Offset left by sidebar width (325) + divider (1) left = x0 + 326 + margin right = x0 + w - margin # Ensure minimum height if bottom - top < 160: bottom = top + 160 return (left, top, right, bottom) def open_build_terminal_for_canvas_area(self, radio_bottom_y: int = 160): """Open a brand-new Terminal.app window and size it to nestle inside the installer.""" if platform.system() != 'Darwin': return l, t, r, b = self._compute_terminal_bounds(radio_bottom_y) win_id = self._terminal_create_new_window() if win_id: self._terminal_set_bounds_by_id(win_id, l, t, r, b) self.build_terminal_window_id = win_id def _start_terminal_follow(self, radio_bottom_y: int = 160): """Bind window Configure to keep Terminal bounds anchored to installer area.""" if platform.system() != 'Darwin': return self._terminal_follow_rby = radio_bottom_y if getattr(self, '_terminal_follow_bound', False): return def _follow(_evt=None): # Debounce slightly if getattr(self, '_terminal_follow_after', None): try: self.root.after_cancel(self._terminal_follow_after) except Exception: pass def _do(): if not getattr(self, 'build_terminal_window_id', None): return l, t, r, b = self._compute_terminal_bounds(self._terminal_follow_rby) self._terminal_set_bounds_by_id(self.build_terminal_window_id, l, t, r, b) self._terminal_follow_after = self.root.after(60, _do) self.root.bind('', _follow) self._terminal_follow_bound = True def _stop_terminal_follow(self): if getattr(self, '_terminal_follow_bound', False): try: self.root.unbind('') except Exception: pass self._terminal_follow_bound = False def paste_into_terminal(self, text: str, press_enter: bool = False): """Paste text into Terminal by targeting the build window id if available.""" if platform.system() != 'Darwin': return win_id = getattr(self, 'build_terminal_window_id', None) if win_id: self._terminal_paste_by_id(win_id, text, press_enter=press_enter) return # Fallback to generic new window win_id = self._terminal_create_new_window() if win_id: self._terminal_paste_by_id(win_id, text, press_enter=press_enter) def close_build_terminal(self): if platform.system() != 'Darwin': return # stop following window self._stop_terminal_follow() win_id = getattr(self, 'build_terminal_window_id', None) if not win_id: # attempt to close front window politely osa = 'tell application "Terminal" to if (count of windows) > 0 then close front window' else: osa = f'tell application "Terminal" to try\nclose (every window whose id is {win_id})\nend try' try: subprocess.run(['osascript', '-e', osa]) except Exception: pass def get_build_command(self, env: str) -> str: """Delegate to installer.build.get_build_command.""" return inst_get_build_command(PROJECT_ROOT, env) # ---------------- Build Summary page ---------------- def run_final_deployment(self): initial_dir = str(self._resolve_env_dir('PROLE_DATA', 'data')) try: if not Path(initial_dir).exists(): initial_dir = str(Path.home()) except Exception: initial_dir = str(Path.home()) export_base = filedialog.askdirectory( initialdir=initial_dir, title="Select deployment export directory" ) if not export_base: self.safe_after(lambda: self.bg_canvas.itemconfig( self._save_deployment_status_label, text="Save canceled.", fill='#6e6e73' ) if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) return export_base = str(Path(export_base).expanduser()) docker_export_dir = str(Path(export_base) / "docker-import") helm_export_dir = str(Path(export_base) / "helm-chart") kustomize_export_dir = str(Path(export_base) / "kustomize") def worker(): self.safe_after(lambda: self._save_deployment_button.configure(state='disabled') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None) self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Saving deployment...", fill='blue') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) script_name = "final_deployment.sh" if hasattr(self, 'install_consoles') and script_name in self.install_consoles: self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None) env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["DOCKER_IMPORT_DIR"] = docker_export_dir env["HELM_CHART_DIR"] = helm_export_dir env["KUSTOMIZE_DIR"] = kustomize_export_dir console = getattr(self, 'install_consoles', {}).get(script_name) if console: console.clear() console.write("Saving deployment artifacts...\n") console.write(f"Export base: {export_base}\n") console.write(f"Docker images: {docker_export_dir}\n") console.write(f"Helm chart: {helm_export_dir}\n") console.write(f"Kustomize: {kustomize_export_dir}\n\n") def _on_line(line): if console: console.write(line) rc = self.controller.run_script( script_name, args=['--docker-export', '--helm-chart', '--kustomize', '--output-dir', export_base], env=env, on_line=_on_line ) if rc == 0: self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Deployment saved successfully.", fill='#34c759') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) else: self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None) self.safe_after(lambda: self._save_deployment_button.configure(state='normal') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None) threading.Thread(target=worker, daemon=True).start() def run_build_a_bao(self): def worker(): self.safe_after(lambda: self._build_a_bao_button.configure(state='disabled') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None) self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Saving secrets to OpenBao...", fill='blue') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) script_name = "build-a-bao.sh" if hasattr(self, 'install_consoles') and script_name in self.install_consoles: self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None) env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["NAMESPACE"] = (self.db_namespace.get() or '').strip() console = getattr(self, 'install_consoles', {}).get(script_name) if console: console.clear() console.write(f"Running {script_name} ...\n") def _on_line(line): if console: console.write(line) rc = self.controller.run_script( script_name, env=env, on_line=_on_line ) if rc == 0: self._secrets_finalized = True self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Secrets saved to OpenBao.", fill='#34c759') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) else: self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None) self.safe_after(lambda: self._build_a_bao_button.configure(state='normal') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None) threading.Thread(target=worker, daemon=True).start() def _collect_install_logs(self) -> list[Path]: logs_dir = self._resolve_prole_logs_dir() build_logs = [] init_logs = [] seen = set() def _add(p: Path): sp = str(p) if sp in seen: return if not p.exists(): return seen.add(sp) if p.name.startswith('build-'): build_logs.append(p) elif p.name.startswith('init_') or p.name.startswith('init-') or p.name == 'init_common_services.log': init_logs.append(p) for raw in getattr(self, '_install_run_log_paths', []): try: _add(Path(raw)) except Exception: pass try: lbp = getattr(self, 'last_build_log_path', None) if lbp: _add(Path(lbp)) except Exception: pass if not build_logs: try: candidates = sorted(logs_dir.glob('build-*.log'), key=lambda p: p.stat().st_mtime) if candidates: _add(candidates[-1]) except Exception: pass if not init_logs: try: candidates = sorted(logs_dir.glob('init_*.log')) for p in candidates: _add(p) except Exception: pass try: build_logs.sort(key=lambda p: p.stat().st_mtime) except Exception: pass try: init_logs.sort(key=lambda p: p.name) except Exception: pass return build_logs + init_logs def _render_create_installer_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', font=('SF Pro Text', 18), anchor='ne') self._render_title('Post Install', y=150) self._render_paragraph('Review installation logs and save the deployment artifacts.', y=200) # Tabs for output - using standardized appearance ui.canvas_text(self, 48, 260, "Installation Logs", fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')) # Use a background frame for the notebook to hide potential system borders notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0) self.install_tabs = ttk.Notebook(notebook_bg, style='TNotebook') self.install_tabs.pack(fill='both', expand=True, padx=1, pady=1) tab_window = self.bg_canvas.create_window(48, 290, window=notebook_bg, anchor='nw', width=900, height=450) self._canvas_items.append(tab_window) self._overlay_widgets.append(notebook_bg) self._overlay_widgets.append(self.install_tabs) self.install_consoles = {} def _add_tab(title: str, content: str = "", script_name: str | None = None): # Use a background frame to ensure NO borders are visible around the console console_bg = tk.Frame(self.install_tabs, bg='white', highlightthickness=0, bd=0) self.install_tabs.add(console_bg, text=title) # Use TerminalConsole for consistent styling console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) console.pack(fill='both', expand=True, padx=1, pady=1) if content: console.write(content) if script_name: self.install_consoles[script_name] = console return console # Add existing logs logs = self._collect_install_logs() for p in logs: try: text = p.read_text(encoding='utf-8', errors='ignore') content = f"{p}\n\n{text}" except Exception as e: content = f"{p}\n\nFailed to read log: {e}\n" _add_tab(p.name, content) # Add prole.cfg tab cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg' try: cfg_text = cfg_path.read_text(encoding='utf-8', errors='ignore') cfg_content = f"{cfg_path}\n\n{cfg_text}" except Exception as e: cfg_content = f"{cfg_path}\n\nprole.cfg not found or unreadable: {e}\n" _add_tab('prole.cfg', cfg_content) # Add final_deployment.sh tab _add_tab('final_deployment.sh', script_name='final_deployment.sh') # Add build-a-bao.sh tab _add_tab('build-a-bao.sh', script_name='build-a-bao.sh') # Build-A-Bao Button self._build_a_bao_button = tk.Button(self.bg_canvas, text='Build-A-Bao', command=self.run_build_a_bao, bg='#F5F5DC', fg='black', activebackground='#E5E5D5', highlightbackground='#F5F5DC', highlightthickness=0, relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) bao_btn_window = self.bg_canvas.create_window(48, 760, window=self._build_a_bao_button, anchor='nw', width=180) self._canvas_items.append(bao_btn_window) self._overlay_widgets.append(self._build_a_bao_button) # Status Label self._build_a_bao_status_label = ui.canvas_text(self, 48, 802, "", fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(self._build_a_bao_status_label) def _render_build_summary_page(self): # Ensure slide_area is visible for the build log console self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) self.slide_area.lift() # Build Summary: show combined stdout/stderr from last build log self._render_title('Build Summary', y=40) if getattr(self, '_built_success', False): self._render_paragraph('✅ Build completed successfully. You can now drag Prole Tools.app into Applications. Output below:', y=88) else: # Troubleshooting header tips = ( "❌ Build failed. Troubleshooting tips:\n" "• Ensure Xcode Command Line Tools are installed: xcode-select --install\n" "• Verify Swift toolchain and SPM networking (try again; network hiccups can happen)\n" "• If on Apple Silicon, ensure dependencies target arm64 or install via Homebrew\n" "• Clean derived data / SPM cache if needed: rm -rf ~/Library/Developer/Xcode/DerivedData\n" "• Check Docker status if Docker-related steps are used\n" "• Check the logs below for specific errors" ) self._render_paragraph(tips, y=88) logp = getattr(self, 'last_build_log_path', None) # Place a scrollable console to show the log try: # Standardized Console Output area for build summary self.build_summary_console = self._create_console_output(y=160, title="Build Log Output", width=900, height=520) txt = self.build_summary_console.text if logp and os.path.exists(logp): try: with open(logp, 'r', encoding='utf-8', errors='ignore') as fp: content = fp.read() self.build_summary_console.write(content) except Exception as e: self.build_summary_console.write(f"Failed to read log: {e}\n") else: self.build_summary_console.write('No build log available.') # Keep standard y for links y_links = 760 except Exception: # Fallback: just show path self._render_paragraph('No build log could be displayed.', y=120) y_links = 140 # Add link to open the log file in Finder/TextEdit if logp: link = ui.render_link(self, 56, y_links, 'Open build log file') self._canvas_items.append(link) def _open_log(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link) if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: try: if platform.system() == 'Darwin': subprocess.run(['open', logp]) else: webbrowser.open(f'file://{logp}') except Exception: pass self.bg_canvas.bind('', _open_log) # On success, also offer DMG packaging and opening options if getattr(self, '_built_success', False): try: dist_dir = str(self._get_prole_dist_dir()) except Exception: dist_dir = None if dist_dir: y_links += 30 # Create DMG link2 = ui.render_link(self, 56, y_links, 'Create Prole Tools.dmg') self._canvas_items.append(link2) def _open_dist(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link2) if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: try: # Build the DMG, then offer to open it self.create_dmg() self.open_dmg() except Exception: pass self.bg_canvas.bind('', _open_dist) # Also link to open dist folder (fallback) y_links += 30 link3 = ui.render_link(self, 56, y_links, 'Open dist folder') self._canvas_items.append(link3) def _open_dist_folder(event): ex, ey = event.x, event.y bbox = self.bg_canvas.bbox(link3) if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: try: if platform.system() == 'Darwin': subprocess.run(['open', dist_dir]) else: webbrowser.open(f'file://{dist_dir}') except Exception: pass self.bg_canvas.bind('', _open_dist_folder) # ---------------- Drag-and-drop install (macOS Finder) ---------------- def _get_prole_dist_dir(self) -> Path: return PROJECT_ROOT / 'prole-tools-app' / 'dist' def ensure_applications_symlink(self): """Deprecated: no longer create /Applications symlink inside the repo. We now place the symlink only inside the DMG staging directory to avoid confusing IDE indexers and to keep the workspace clean. """ return def open_drag_install_window(self): """Open the Prole DMG in Finder (macOS) for drag-and-drop install.""" if platform.system() != 'Darwin': return p = self._get_dmg_paths() dmg = p['final_dmg'] if not dmg.exists(): try: self.create_dmg() except Exception: pass try: subprocess.run(['open', str(dmg)]) except Exception: try: subprocess.run(['open', str(p['dist'])]) except Exception: pass # ---------------- DMG Packaging ---------------- def _get_dmg_paths(self): # Ensure we have a valid path for DMG output. # Default to ~/Downloads if selected path is home or invalid. raw_path = self.selected_local_path.get() user_dist = Path(raw_path).expanduser() if str(user_dist) == str(Path.home()): user_dist = Path.home() / 'Downloads' self.selected_local_path.set(str(user_dist)) try: user_dist.mkdir(parents=True, exist_ok=True) except Exception: user_dist = self._get_prole_dist_dir() dmg_name = 'Prole Tools.dmg' tmp_dmg = user_dist / 'Prole Tools.tmp.dmg' final_dmg = user_dist / dmg_name # Use a temporary staging area in the app's dist dir to keep user folder clean app_dist = self._get_prole_dist_dir() staging = app_dist / 'dmg_stage' bg_dir = staging / '.background' bg_img = get_resource_path('img/proleLogoBlueprint.png') return { 'dist': user_dist, 'tmp_dmg': tmp_dmg, 'final_dmg': final_dmg, 'staging': staging, 'bg_dir': bg_dir, 'bg_img': bg_img, } def create_dmg(self): """Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink.""" if platform.system() != 'Darwin': return p = self._get_dmg_paths() app_src = get_resource_path('prole-app/dist/Prole Tools.app') if not app_src.exists(): print(f"Error: {app_src} not found. Run build first.") return staging = p['staging'] try: if staging.exists(): print(f"Cleaning staging area: {staging}") shutil.rmtree(staging, ignore_errors=True) staging.mkdir(parents=True, exist_ok=True) # 1. Build static installer binary using PyInstaller print("Building static installer binary...") installer_name = "Install Prole Infrastructure" icon_path = PROJECT_ROOT / 'img' / 'proleIconblueprint.png' try: # Use --onefile for a single executable cmd = [ sys.executable, '-m', 'PyInstaller', '--onefile', '--name', installer_name, '--clean', '--noconsole', # It's a GUI app (tkinter) ] if icon_path.exists(): cmd.extend(['--icon', str(icon_path)]) cmd.append('install.py') subprocess.check_call(cmd) setup_bin = PROJECT_ROOT / 'dist' / installer_name if setup_bin.exists(): dst_setup = staging / installer_name if dst_setup.exists(): if dst_setup.is_dir(): shutil.rmtree(dst_setup) else: dst_setup.unlink() shutil.copy2(setup_bin, dst_setup) else: print(f"Error: PyInstaller failed to create '{installer_name}' binary.") except Exception as e: messagebox.showerror("Error", f"Failed to create DMG: {e}") print(f"Warning: Failed to build setup binary with PyInstaller: {e}") # 2. Copy Prole Tools.app to staging (at root for drag-and-drop) print("Copying Prole Tools.app to staging...") dst_app = staging / 'Prole Tools.app' if dst_app.exists(): shutil.rmtree(dst_app) if sys.version_info >= (3, 8): shutil.copytree(app_src, dst_app, dirs_exist_ok=True) else: subprocess.check_call(['cp', '-R', str(app_src), str(dst_app)]) # Inject launcher wrapper into Prole Tools.app macos_dir = dst_app / 'Contents' / 'MacOS' launcher_path = macos_dir / 'Prole Tools' real_bin_path = macos_dir / 'ProleTools.bin' if launcher_path.exists() and launcher_path.is_file(): if real_bin_path.exists(): if real_bin_path.is_dir(): shutil.rmtree(real_bin_path) else: real_bin_path.unlink() os.rename(launcher_path, real_bin_path) script = """#!/bin/bash set -euo pipefail export PROLE_HOME="${PROLE_HOME:-$HOME/.prole}" if [ -f "$PROLE_HOME/env.sh" ]; then . "$PROLE_HOME/env.sh" fi DIR="$(cd "$(dirname "$0")" && pwd)" exec "$DIR/ProleTools.bin" "$@" """ with open(launcher_path, 'w') as fp: fp.write(script) os.chmod(launcher_path, 0o755) # 3. Create Applications symlink try: os.symlink('/Applications', str(staging / 'Applications')) except FileExistsError: pass # 4. Background image if p['bg_dir'].exists(): shutil.rmtree(p['bg_dir']) p['bg_dir'].mkdir(parents=True, exist_ok=True) if p['bg_img'].exists(): shutil.copy2(p['bg_img'], p['bg_dir'] / 'background.png') # 5. Create the DMG tmp_dmg = p['tmp_dmg'] final_dmg = p['final_dmg'] if final_dmg.exists(): os.remove(final_dmg) # Create the DMG using hdiutil messagebox.showinfo("Creating DMG", "Building DMG image. This may take a minute...") subprocess.check_call([ 'hdiutil', 'create', '-volname', 'Prole', '-srcfolder', str(staging), '-ov', '-format', 'UDRW', # Create as Read/Write initially to modify view options str(tmp_dmg) ]) # Positions in DMG: # [Install Prole Infrastructure] (left) # [Prole Tools.app] (center/right) # [Applications] (below Prole Tools.app) # Note: We use the installer name in the AppleScript. # Finder items need to match the actual file names on disk. # \n in filename might be literal or interpreted. # 6. Set DMG view options (large icons) using AppleScript print("Configuring DMG view options...") mount_point = Path('/Volumes/Prole') try: # Detach if already mounted subprocess.run(['hdiutil', 'detach', str(mount_point)], capture_output=True) # Mount the temporary DMG subprocess.check_call(['hdiutil', 'attach', str(tmp_dmg), '-nobrowse']) # Give it a moment to mount time.sleep(2) if mount_point.exists(): # Escape the installer name for AppleScript # Use the name that actually exists on disk. # PyInstaller might have replaced \n with something else in the filename if it was problematic, # but usually it's literal in the FS if allowed. applescript = f''' tell application "Finder" tell disk "Prole" open set current view of container window to icon view set toolbar visible of container window to false set statusbar visible of container window to false set the_container to container window set bounds of the_container to {{400, 100, 1000, 600}} set icon_view_options to icon view options of the_container set icon size of icon_view_options to 192 set arrangement of icon_view_options to not arranged set background picture of icon_view_options to file ".background:background.png" -- Position icons set position of item "{installer_name}" of container window to {{150, 200}} set position of item "Prole Tools.app" of container window to {{450, 200}} set position of item "Applications" of container window to {{450, 400}} update without registering applications delay 2 close end tell end tell ''' subprocess.run(['osascript', '-e', applescript]) # Detach subprocess.check_call(['hdiutil', 'detach', str(mount_point)]) # Convert to final compressed format if final_dmg.exists(): os.remove(final_dmg) subprocess.check_call([ 'hdiutil', 'convert', str(tmp_dmg), '-format', 'UDZO', '-o', str(final_dmg) ]) if tmp_dmg.exists(): os.remove(tmp_dmg) except Exception as e: print(f"Warning: Failed to set DMG view options: {e}") # Fallback: just rename tmp_dmg if conversion/AppleScript failed if not final_dmg.exists(): os.rename(tmp_dmg, final_dmg) messagebox.showinfo("Success", f"Successfully created {final_dmg}") print(f"Successfully created {final_dmg}") finally: # Clean up staging directory try: if staging.exists(): shutil.rmtree(staging, ignore_errors=True) # Also clean up PyInstaller artifacts for d in ['build', 'dist']: p_path = PROJECT_ROOT / d if p_path.exists(): # Be careful not to delete 'dist' if it contains our final DMG # However, create_dmg is usually run to build the DMG # and PyInstaller artifacts are usually temporary in this context. # We only remove them if they were created during this run. pass spec_file = PROJECT_ROOT / f"{installer_name}.spec" if spec_file.exists(): os.remove(spec_file) except Exception: pass def open_dmg(self): """Reveal the created DMG in Finder without mounting it inline. This avoids blocking the installer process and lets macOS handle mounting/ejecting normally when the user opens the DMG. """ if platform.system() != 'Darwin': return p = self._get_dmg_paths() dmg = p['final_dmg'] if not dmg.exists(): # Try to create it first self.create_dmg() # Reveal the DMG in Finder (non-blocking); do NOT attach/mount inline try: subprocess.run(['open', '-R', str(dmg)]) except Exception: try: # Fallback: open the dist folder subprocess.run(['open', str(p['dist'])]) except Exception: pass def create_install_screen(self): """Create the dependency installer screen""" frame = tk.Frame(self.content_area, bg='#1a1a1a') self.screens['install'] = frame # Title title = ttk.Label(frame, text="Install Dependencies", style='Title.TLabel') title.pack(pady=(0, 30)) # Instructions instructions = tk.Label(frame, text="Install the following dependencies to proceed with Prole deployment:", bg='#1a1a1a', fg='#aaaaaa', font=('Helvetica', 11)) instructions.pack(pady=(0, 20)) # Dependencies list deps_frame = tk.Frame(frame, bg='#1a1a1a') deps_frame.pack(fill='both', expand=True) dependencies = [ { 'name': 'Docker', 'description': 'Container platform for running Prole services', 'url': 'https://www.docker.com/products/docker-desktop', 'install_cmd': None, 'check_cmd': 'docker --version' }, { 'name': 'Homebrew', 'description': 'Package manager for macOS', 'url': 'https://brew.sh', 'install_cmd': '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"', 'check_cmd': 'brew --version' }, { 'name': 'k3d', 'description': 'Lightweight wrapper to run k3s in Docker', 'url': 'https://k3d.io', 'install_cmd': 'brew install k3d', 'check_cmd': 'k3d --version' }, { 'name': 'kubectl', 'description': 'Kubernetes command-line tool', 'url': 'https://kubernetes.io/docs/tasks/tools/', 'install_cmd': 'brew install kubectl', 'check_cmd': 'kubectl version --client' }, { 'name': 'Helm', 'description': 'Kubernetes package manager', 'url': 'https://helm.sh', 'install_cmd': 'brew install helm', 'check_cmd': 'helm version' }, { 'name': 'krew', 'description': 'Kubectl plugin manager', 'url': 'https://krew.sigs.k8s.io', 'install_cmd': 'brew install krew', 'check_cmd': 'kubectl krew version' }, { 'name': 'cmctl', 'description': 'cert-manager CLI tool', 'url': 'https://cert-manager.io', 'install_cmd': 'brew install cmctl', 'check_cmd': 'cmctl version' } ] self.dep_status = {} for dep in dependencies: self.create_dependency_card(deps_frame, dep) # Generate installer script button script_btn = tk.Button(frame, text="Generate Installer Script", command=self.generate_installer_script, bg='#4a9eff', fg='white', activebackground='#3a8eef', font=('Helvetica', 12, 'bold'), padx=30, pady=15, cursor='hand2', relief='flat') script_btn.pack(pady=20) def create_dependency_card(self, parent, dep): """Create a dependency card with status and download link""" card = tk.Frame(parent, bg='#2a2a2a', relief='flat', bd=1) card.pack(fill='x', pady=5, padx=10) # Left side - info info_frame = tk.Frame(card, bg='#2a2a2a') info_frame.pack(side='left', fill='both', expand=True, padx=15, pady=15) name_label = tk.Label(info_frame, text=dep['name'], bg='#2a2a2a', fg='#ffffff', font=('Helvetica', 13, 'bold'), anchor='w') name_label.pack(fill='x') desc_label = tk.Label(info_frame, text=dep['description'], bg='#2a2a2a', fg='#aaaaaa', font=('Helvetica', 10), anchor='w') desc_label.pack(fill='x', pady=(5, 0)) # Right side - status and actions action_frame = tk.Frame(card, bg='#2a2a2a') action_frame.pack(side='right', padx=15, pady=15) # Status indicator status_label = tk.Label(action_frame, text="Checking...", bg='#2a2a2a', fg='#ffaa00', font=('Helvetica', 10)) status_label.pack(side='left', padx=10) self.dep_status[dep['name']] = {'label': status_label, 'dep': dep} # Download button download_btn = tk.Button(action_frame, text="Download", command=lambda url=dep['url']: webbrowser.open(url), bg='#28a745', fg='white', activebackground='#218838', font=('Helvetica', 10), padx=15, pady=5, cursor='hand2', relief='flat') download_btn.pack(side='left', padx=5) # Check status self.check_dependency(dep['name']) def check_dependency(self, name): """Check if a dependency is installed""" dep_info = self.dep_status[name] dep = dep_info['dep'] label = dep_info['label'] def check(): try: result = subprocess.run(dep['check_cmd'].split(), capture_output=True, text=True, timeout=5) if result.returncode == 0: label.configure(text="✓ Installed", fg='#28a745') else: label.configure(text="✗ Not Installed", fg='#dc3545') except Exception: label.configure(text="✗ Not Installed", fg='#dc3545') threading.Thread(target=check, daemon=True).start() def generate_installer_script(self): """Generate installer script by composing per-dependency templates from installer/scripts.""" script_path = PROJECT_ROOT / 'install_dependencies.sh' scripts_dir = PROJECT_ROOT / 'installer' / 'scripts' # Expected order of templates expected = [ 'install_prole_homebrew.sh', 'install_prole_k3d.sh', 'install_prole_kubectl.sh', 'install_prole_helm.sh', 'install_prole_krew.sh', 'install_prole_cmctl.sh', 'install_prole_kubectl_plugins.sh', ] def _validate_and_strip(path: Path) -> str: """Validate template format and return content stripped of shebang and initial set -e* line.""" try: text = path.read_text(encoding='utf-8') except Exception as e: raise RuntimeError(f"Failed to read {path.name}: {e}") lines = text.splitlines() if not lines: raise RuntimeError(f"Template {path.name} is empty") # Validate shebang if not lines[0].startswith('#!/bin/bash'): raise RuntimeError(f"Template {path.name} must start with #!/bin/bash") # Ensure there is a set -e or set -euo pipefail somewhere within first 10 lines has_set = any('set -e' in l for l in lines[:10]) if not has_set: raise RuntimeError(f"Template {path.name} must set '-e' or 'set -euo pipefail'") # Strip shebang i = 1 # Optionally strip empty/comment lines immediately after shebang while i < len(lines) and lines[i].strip() == '': i += 1 # If next non-empty is a "set -e*" line, drop it to avoid duplicates in composed script if i < len(lines) and lines[i].lstrip().startswith('set -e'): i += 1 body = "\n".join(lines[i:]).strip() + "\n" return body # Compose the final script errors: list[str] = [] parts: list[str] = [] header = ( "#!/bin/bash\n" "# Prole Dependencies Installer Script\n" "# Generated by Prole Installer\n\n" "set -euo pipefail\n\n" "echo \"Installing Prole dependencies...\"\n\n" ) parts.append(header) for fname in expected: p = scripts_dir / fname if not p.exists(): errors.append(f"Missing template: {fname}") continue try: body = _validate_and_strip(p) parts.append(f"# ---- {fname} ----\n") parts.append(body) parts.append("\n") except Exception as e: errors.append(str(e)) parts.append('echo "All dependencies installed successfully!"\n') if errors: messagebox.showerror("Template error", "\n".join(errors)) return try: with open(script_path, 'w', encoding='utf-8', newline='\n') as f: f.write("".join(parts)) os.chmod(script_path, 0o755) messagebox.showinfo("Success", f"Installer script generated at:\n{script_path}\n\n" "You can run it with: ./install_dependencies.sh") except Exception as e: messagebox.showerror("Error", f"Failed to generate script: {str(e)}") def create_deploy_screen(self): """Create the Deploy screen""" frame = tk.Frame(self.content_area, bg='#1a1a1a') self.screens['deploy'] = frame # Title title = ttk.Label(frame, text="Build and Deploy", style='Title.TLabel') title.pack(pady=(0, 30)) # Instructions instructions = tk.Label(frame, text="Build and deploy Prole services to k3d cluster", bg='#1a1a1a', fg='#aaaaaa', font=('Helvetica', 11)) instructions.pack(pady=(0, 20)) # Environment selector env_frame = tk.Frame(frame, bg='#1a1a1a') env_frame.pack(pady=(0, 10), fill='x') env_label = tk.Label(env_frame, text="Target Environment:", bg='#1a1a1a', fg='#dddddd', font=('Helvetica', 11)) env_label.pack(side='left', padx=(0, 10)) self.deploy_environment = tk.StringVar(value='Dev') env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18) env_combo.pack(side='left') env_help = tk.Label(env_frame, text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at pi.prole.org:6443, Prod = stretch of prole-service-cluster", bg='#1a1a1a', fg='#888888', font=('Helvetica', 9)) env_help.pack(side='left', padx=(12, 0)) # Deploy button deploy_btn = tk.Button(frame, text="Build and Deploy", command=self.start_deployment, bg='#4a9eff', fg='white', activebackground='#3a8eef', font=('Helvetica', 16, 'bold'), padx=40, pady=20, cursor='hand2', relief='flat') deploy_btn.pack(pady=20) # Progress frame progress_frame = tk.Frame(frame, bg='#1a1a1a') progress_frame.pack(fill='both', expand=True, pady=20) # Progress list # Initialize deploy steps; some names are updated dynamically when deployment starts self.deploy_steps = [ {'name': 'Build ProleStatus macOS app', 'status': 'pending'}, {'name': 'Check Docker is running', 'status': 'pending'}, {'name': 'Check or configure container registry', 'status': 'pending'}, {'name': 'Ensure target cluster', 'status': 'pending'}, {'name': 'Build prole-db Docker image', 'status': 'pending'}, {'name': 'Tag Docker image for registry', 'status': 'pending'}, {'name': 'Push image to registry', 'status': 'pending'}, {'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'}, ] self.deploy_widgets = {} for step in self.deploy_steps: self.create_deploy_step_widget(progress_frame, step) def create_deploy_step_widget(self, parent, step): """Create a widget for a deployment step""" step_frame = tk.Frame(parent, bg='#2a2a2a', relief='flat') step_frame.pack(fill='x', pady=5, padx=10) # Status indicator status_canvas = tk.Canvas(step_frame, width=30, height=30, bg='#2a2a2a', highlightthickness=0) status_canvas.pack(side='left', padx=15, pady=15) # Step name name_label = tk.Label(step_frame, text=step['name'], bg='#2a2a2a', fg='#ffffff', font=('Helvetica', 11), anchor='w') name_label.pack(side='left', fill='x', expand=True, padx=10) # Status text status_label = tk.Label(step_frame, text="Pending", bg='#2a2a2a', fg='#aaaaaa', font=('Helvetica', 10)) status_label.pack(side='right', padx=15) self.deploy_widgets[step['name']] = { 'canvas': status_canvas, 'label': status_label, 'step': step } # Draw initial pending state self.update_deploy_step_status(step['name'], 'pending') def update_deploy_step_status(self, step_name, status): """Update the status of a deployment step""" widget = self.deploy_widgets[step_name] canvas = widget['canvas'] label = widget['label'] step = widget['step'] step['status'] = status canvas.delete('all') if status == 'pending': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2) label.configure(text="Pending", fg='#aaaaaa') elif status == 'running': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#ffaa00', width=2, fill='#ffaa00') label.configure(text="Running...", fg='#ffaa00') elif status == 'completed': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#28a745', width=2, fill='#28a745') ui.canvas_text_on(canvas, 15, 15, '✓', fill='white', font=('Helvetica', 16, 'bold')) label.configure(text="Completed", fg='#28a745') elif status == 'error': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#dc3545', width=2, fill='#dc3545') ui.canvas_text_on(canvas, 15, 15, '✗', fill='white', font=('Helvetica', 16, 'bold')) label.configure(text="Error", fg='#dc3545') elif status == 'skipped': ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2, fill='#444444') label.configure(text="Skipped", fg='#888888') def start_deployment(self): """Start the deployment process""" threading.Thread(target=self.run_deployment, daemon=True).start() def generate_prole_properties(self, env: str): """Dynamically generate prole-tools-app/prole.properties based on environment""" props_path = Path("prole-tools-app/prole.properties") # Determine host based on environment # For now use localhost as a placeholder for Service/Prod host = "localhost" content = f"""# Prole default endpoints (dynamically generated by install.py) # UI assets icon=img/proleIcon.png background=img/proleLogoSepia.png # Dev port-forward supervision pf.enabled=true # Service endpoints (5 traffic lights) svc.1.name=K3D svc.1.host={host} svc.1.port=6443 svc.2.name=Prometheus svc.2.host={host} svc.2.port=9090 svc.3.name=Grafana svc.3.host={host} svc.3.port=3000 svc.4.name=OpenBAO svc.4.host={host} svc.4.port=8200 svc.5.name=PostgreSQL svc.5.host={host} svc.5.port=5432 # Kerberos configuration kerberos.enabled={str(self.kerberos_enabled.get()).lower()} kerberos.realm={self.kerberos_realm.get()} kerberos.user={self.kerberos_user.get()} kerberos.kdc={self.kerberos_kdc.get()} """ props_path.write_text(content) print(f"Generated {props_path} for {env} environment") def run_deployment(self): """Run the deployment steps""" try: # Capture environment selection and prepare dynamic labels env = self.deploy_environment.get().strip() if env not in ('Dev', 'Service', 'Prod'): env = 'Dev' # Update step labels to reflect environment self.deploy_widgets['Ensure target cluster']['step']['name'] = f"Ensure target cluster ({env})" # Use the correct way to update the label text in the UI self.deploy_widgets['Ensure target cluster']['label'].master.winfo_children()[1].configure(text=f"Ensure target cluster ({env})") # Step 0: Generate prole.properties self.generate_prole_properties(env) # Step 1: Build Prole macOS app self.update_deploy_step_status('Build Prole macOS app', 'running') self.build_prole_app() self.update_deploy_step_status('Build Prole macOS app', 'completed') # Step 1: Check Docker self.update_deploy_step_status('Check Docker is running', 'running') if not self.check_docker_running(): self.update_deploy_step_status('Check Docker is running', 'error') messagebox.showerror("Error", "Docker is not running. Please start Docker Desktop.") return self.update_deploy_step_status('Check Docker is running', 'completed') # Step 2: Registry discovery/config self.update_deploy_step_status('Check or configure container registry', 'running') self.registry_url = self.ensure_registry_available(env) self.update_deploy_step_status('Check or configure container registry', 'completed') # Step 3: Ensure target cluster as per environment self.update_deploy_step_status('Ensure target cluster', 'running') self.create_or_select_cluster(env) self.update_deploy_step_status('Ensure target cluster', 'completed') # Step 3: Build Docker image self.update_deploy_step_status('Build prole-db Docker image', 'running') self.build_docker_image() self.update_deploy_step_status('Build prole-db Docker image', 'completed') # Step 4: Tag image self.update_deploy_step_status('Tag Docker image for registry', 'running') self.tag_docker_image() self.update_deploy_step_status('Tag Docker image for registry', 'completed') # Step 5: Push to registry self.update_deploy_step_status('Push image to registry', 'running') self.push_docker_image() self.update_deploy_step_status('Push image to registry', 'completed') # Step 6: Import to k3d (Dev only) if env == 'Dev': self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'running') self.import_k3d_image(cluster_name='prole-dev-cluster') self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'completed') else: self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'skipped') # Step 7: Install LaunchAgent for port-forwards (Dev) if env == 'Dev': self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'running') ok = self.install_launchagent_port_forwards() if ok: self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'completed') else: self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'error') return else: self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'skipped') messagebox.showinfo("Success", "Deployment completed successfully!") except Exception as e: messagebox.showerror("Error", f"Deployment failed: {str(e)}") def install_launchagent_port_forwards(self) -> bool: """Create/update the user LaunchAgent and helper script to manage kubectl port-forwards. - Helper script: ~/Library/Application Support/Prole/bin/prole-kpf.sh - LaunchAgent: ~/Library/LaunchAgents/org.prole.prole-db.kpf-dev.plist """ try: home = Path.home() bin_dir = home / 'Library' / 'Application Support' / 'Prole' / 'bin' run_dir = home / 'Library' / 'Application Support' / 'Prole' / 'run' plist_path = home / 'Library' / 'LaunchAgents' / 'org.prole.prole-db.kpf-dev.plist' script_path = bin_dir / 'prole-kpf.sh' bin_dir.mkdir(parents=True, exist_ok=True) run_dir.mkdir(parents=True, exist_ok=True) plist_path.parent.mkdir(parents=True, exist_ok=True) helper_script = """#!/bin/sh set -eu LABEL="org.prole.prole-db.kpf-dev" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" RUNDIR="$HOME/Library/Application Support/Prole/run" PIDFILE="$RUNDIR/kpf.pids" ensure_rundir() { mkdir -p "$RUNDIR" } list_cmds() { # Enumerate ProleCommands array using PlistBuddy if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then i=0 while true; do if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then break fi echo "$val" i=$((i+1)) done fi } start() { ensure_rundir : > "$PIDFILE" IFS='\n' for cmd in $(list_cmds); do [ -z "$cmd" ] && continue (sh -lc "$cmd") & echo $! >> "$PIDFILE" done wait } stop() { if [ -f "$PIDFILE" ]; then while read -r pid; do [ -z "$pid" ] && continue kill "$pid" 2>/dev/null || true done < "$PIDFILE" rm -f "$PIDFILE" fi } status() { if [ ! -f "$PIDFILE" ]; then echo "not running" exit 3 fi alive=0 total=0 while read -r pid; do [ -z "$pid" ] && continue total=$((total+1)) if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi done < "$PIDFILE" echo "$alive/$total running" } case "${1:-}" in start) start ;; stop) stop ;; restart) stop; start ;; status) status ;; *) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;; esac """ script_path.write_text(helper_script) os.chmod(script_path, 0o755) # Default commands for Dev environment (k3d) default_cmds = [ "kubectl port-forward -n monitoring svc/kps-kube-prometheus-stack-prometheus 9090", "kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443", "kubectl port-forward svc/prole-db-rw 5432:5432 --address 0.0.0.0", "kubectl port-forward -n monitoring svc/kps-grafana 3000:80 --address 0.0.0.0", ] # Create plist dict from plistlib import dumps as plist_dumps plist_dict = { 'Label': 'org.prole.prole-db.kpf-dev', 'RunAtLoad': True, 'KeepAlive': True, # Ensure PATH has common locations for kubectl 'EnvironmentVariables': { 'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin') }, 'StandardOutPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.out.log'), 'StandardErrorPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.err.log'), 'ProgramArguments': [str(script_path), 'start'], 'ProleCommands': default_cmds, } plist_bytes = plist_dumps(plist_dict) plist_path.write_bytes(plist_bytes) # Reload the agent uid = os.getuid() # Try to kickstart first subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) # Bootout and bootstrap to ensure it's loaded, then kickstart subprocess.run(['launchctl', 'bootout', f'gui/{uid}', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True) subprocess.run(['launchctl', 'bootstrap', f'gui/{uid}', str(plist_path)], check=True) subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], check=True) return True except Exception as e: print(f"LaunchAgent setup failed: {e}") return False def check_xcode_tools(self): """Check if Xcode Command Line Tools are installed via deploy helper.""" return inst_deploy.check_xcode_tools() def build_prole_app(self): """Delegate building the native ProleStatus app to deploy helper.""" return inst_deploy.build_prole_app(PROJECT_ROOT) def check_docker_running(self): """Check if Docker is running""" try: result = subprocess.run(['docker', 'ps'], capture_output=True, timeout=10) return result.returncode == 0 except Exception: return False def ensure_registry_available(self, env: str) -> str: """Detect if k8s.prole.org:5000 is reachable; if so use it. Otherwise ensure a local registry is available. Returns the registry URL (host:port) to be used for tagging/pushing. For Dev, may create a local k3d-managed registry; for other envs, still prefer the external if reachable. """ # Prefer the shared registry if reachable if _http_ping_registry('k8s.prole.org', 5000): return 'k8s.prole.org:5000' if not _local_registry_enabled(env): return '' # Otherwise, ensure a local registry (localhost:5000) exists/started if _http_ping_registry('localhost', 5000): return 'localhost:5000' # Use k3d registry helper for Dev; for non-Dev, we still create/use local for pushing reg_name = 'prole-registry' # Check if k3d is installed k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0 if k3d_exists: # List registries lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True) if reg_name not in (lst.stdout or ''): # Create registry exposed on 0.0.0.0:5000 subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) return 'localhost:5000' def ensure_local_registry_available(self): """Ensure a local registry is running and return (host_registry, cluster_registry).""" mode_hint = None try: mode_hint = self._deployment_mode() except Exception: mode_hint = None if not _local_registry_enabled(mode_hint): try: log_path = self._registry_log_path() with log_path.open('a', encoding='utf-8') as fp: fp.write(f"\n# Registry disabled (mode={mode_hint or 'unknown'}) @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n") except Exception: pass return None log_path = self._registry_log_path() try: log_fp = log_path.open('a', encoding='utf-8') except Exception: log_fp = None def _log(msg: str): if not log_fp: return try: log_fp.write(msg) log_fp.flush() except Exception: pass def _log_cmd(cmd: list[str], res: subprocess.CompletedProcess): _log(f"$ {' '.join(cmd)}\n") if res.stdout: _log(res.stdout) if res.stderr: _log(res.stderr) _log(f"[exit {res.returncode}]\n") def _run(cmd: list[str], check: bool = False): res = subprocess.run(cmd, capture_output=True, text=True) if res.returncode != 0: _log_cmd(cmd, res) if check: raise RuntimeError(f"Command failed: {' '.join(cmd)}") return res _log(f"\n# Registry check @ {time.strftime('%Y-%m-%d %H:%M:%S')} (mode={mode_hint or 'unknown'})\n") reg_name = 'prole-registry' registry_container = f'k3d-{reg_name}' host_registry = 'localhost:5000' mode = _deployment_mode_from_env(self.prole_cfg_data.get('Global', {}).get('DEPLOYMENT_MODE')) if mode == 'k3d': cluster_registry = f'{registry_container}.localhost:5000' else: cluster_registry = host_registry if not self.check_docker_running(): _log("Docker not running; cannot ensure local registry.\n") if log_fp: log_fp.close() return None # 1. If a registry is already responsive on localhost:5000, we use it. if _http_ping_registry('localhost', 5000): # Check if it's a k3d registry to use the correct cluster internal name try: lst = _run(['k3d', 'registry', 'list']) if registry_container in (lst.stdout or '') or reg_name in (lst.stdout or ''): cluster_registry = f'{registry_container}.localhost:5000' else: cluster_registry = host_registry except Exception: cluster_registry = host_registry self.local_registry_url = host_registry self.local_registry_internal = cluster_registry try: if 'Docker Build' not in self.prole_cfg_data: self.prole_cfg_data['Docker Build'] = {} self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY'] = host_registry self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY_INTERNAL'] = cluster_registry self.safe_after(self._save_prole_cfg) except Exception: pass if log_fp: log_fp.close() return host_registry, cluster_registry # 2. Try k3d first if available k3d_exists = _run(['which', 'k3d']).returncode == 0 if k3d_exists: try: # Ensure registry exists lst = _run(['k3d', 'registry', 'list']) if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''): _run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True) # Ensure registry container is running running = _run( ['docker', 'ps', '--filter', f'name={registry_container}', '--format', '{{.Names}}'], ) if not (running.stdout or '').strip(): _run(['docker', 'start', registry_container], check=True) self.local_registry_url = host_registry self.local_registry_internal = f'{registry_container}.localhost:5000' if log_fp: log_fp.close() return self.local_registry_url, self.local_registry_internal except Exception: _log("k3d registry setup failed.\n") # 3. Fallback to plain Docker registry try: docker_exists = _run(['which', 'docker']).returncode == 0 if docker_exists: # Check if prole-registry container exists exists = _run( ['docker', 'ps', '-a', '--filter', 'name=^/prole-registry$', '--format', '{{.Names}}'], ) if (exists.stdout or '').strip(): _run(['docker', 'start', 'prole-registry'], check=True) else: _run([ 'docker', 'run', '-d', '-p', '5000:5000', '--restart', 'always', '--name', 'prole-registry', 'registry:2' ], check=True) # Wait a moment for it to start time.sleep(1) if _http_ping_registry('localhost', 5000): self.local_registry_url = host_registry self.local_registry_internal = host_registry if log_fp: log_fp.close() return host_registry, host_registry except Exception: _log("Docker registry fallback failed.\n") if log_fp: log_fp.close() return None def _collect_dependent_images(self, include_supabase: bool, include_kerberos_proxy: bool) -> list[str]: images = set() for rel_dir in ('k8s/prole', 'k8s/openbao'): base_dir = PROJECT_ROOT / rel_dir if not base_dir.exists(): continue images.update(_collect_images_from_files(list(base_dir.glob('*.yaml')))) if include_supabase: supa_home = _resolve_supabase_home(PROJECT_ROOT) if supa_home: docker_dir = supa_home / 'docker' compose_files = [docker_dir / 'docker-compose.yml'] if os.environ.get('SUPABASE_USE_DEV_COMPOSE') == '1': compose_files.append(docker_dir / 'dev' / 'docker-compose.dev.yml') images.update(_collect_images_from_files(compose_files)) if not include_supabase: images = {img for img in images if 'supabase' not in img} if include_kerberos_proxy: krb_img = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat') if krb_img: images.add(krb_img) return sorted(images) def _prepull_images_to_registry(self, include_supabase: bool, include_kerberos_proxy: bool, log=None) -> bool: def _log(msg: str): if log: try: log(msg) except Exception: pass info = self.ensure_local_registry_available() if not info: _log("Local registry unavailable; skipping image pre-pull.\n") return False registry, _cluster_registry = info images = self._collect_dependent_images(include_supabase, include_kerberos_proxy) if not images: _log("No dependent images found to pre-pull.\n") return True overall_ok = True import_dir = self.docker_import_dir.get().strip() for image in images: local_tag = image if not image.startswith(f"{registry}/"): local_tag = f"{registry}/{image}" # 1. Check if already in local registry _log(f"Checking if {image} exists in local registry...\n") check_reg = subprocess.run(['docker', 'pull', local_tag], capture_output=True, text=True) if check_reg.returncode == 0: _log(f"[OK] {image} already exists in local registry as {local_tag}\n") continue # 2. Check if we already have it in local docker daemon check_local = subprocess.run(['docker', 'image', 'inspect', image], capture_output=True, text=True) found = (check_local.returncode == 0) if not found and import_dir and os.path.isdir(import_dir): # 3. Check import directory safe_name = image.replace("/", "_").replace(":", "_") tar_path = Path(import_dir) / f"{safe_name}.tar" if tar_path.exists(): _log(f"Found {tar_path} in import directory, loading...\n") load = subprocess.run(['docker', 'load', '-i', str(tar_path)], capture_output=True, text=True) if load.returncode == 0: found = True else: _log(f"[WARN] Failed to load {tar_path}: {load.stderr}\n") if not found: # 4. Pull from Docker Hub _log(f"Pulling {image} from Docker Hub...\n") pull = subprocess.run(['docker', 'pull', image], capture_output=True, text=True) if pull.returncode != 0: overall_ok = False _log(pull.stdout or '') _log(pull.stderr or '') _log(f"[ERROR] docker pull failed for {image}\n") continue found = True # If we have the image locally, tag and push to local registry if found: if local_tag != image: tag = subprocess.run(['docker', 'tag', image, local_tag], capture_output=True, text=True) if tag.returncode != 0: overall_ok = False _log(tag.stdout or '') _log(tag.stderr or '') _log(f"[ERROR] docker tag failed for {image} to {local_tag}\n") continue _log(f"Pushing {local_tag} to local registry...\n") if not _push_docker_image(local_tag, log_fn=log): overall_ok = False continue _log(f"[OK] Stored {image} in local registry as {local_tag}\n") return overall_ok def create_or_select_cluster(self, env: str): """Ensure target cluster depending on environment selection.""" if env == 'Dev': self.create_or_recreate_k3d_dev_cluster() elif env == 'Service': kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) if kubectl.returncode != 0: raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.") server, token = self._k3s_connection_info() if server and token: res = subprocess.run(self._k3s_kubectl_base_cmd() + ['cluster-info'], capture_output=True, text=True) if res.returncode != 0: raise Exception("K3s cluster not reachable with provided server/token.") else: # Fallback to local kubeconfig if no token provided subprocess.run(['kubectl', 'version', '--client'], check=True) elif env == 'Prod': kubectl = subprocess.run(['which', 'kubectl'], capture_output=True) if kubectl.returncode != 0: raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.") subprocess.run(['kubectl', 'version', '--client'], check=True) else: raise Exception(f"Unknown environment: {env}") def create_or_recreate_k3d_dev_cluster(self): """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""" if not getattr(self, 'registry_url', ''): print("Registry disabled; skipping tag.") return version = self.get_prole_db_version() registry = getattr(self, 'registry_url', 'localhost:5000') image_name = f'prole-db:{version}' image = getattr(self, 'local_image_tag', image_name) self.remote_image_tag = f"{registry}/prole-db:{version}" subprocess.run(['docker', 'tag', image, self.remote_image_tag], check=True, capture_output=True) def push_docker_image(self): """Push Docker image to registry""" if not getattr(self, 'registry_url', ''): print("Registry disabled; skipping push.") return remote_tag = getattr(self, 'remote_image_tag', None) if not remote_tag: raise Exception('Remote image tag not set') if not _push_docker_image(remote_tag): raise Exception('Failed to push image to registry.') def import_k3d_image(self, cluster_name='prole-dev-cluster'): """Import image to k3d cluster (only for Dev).""" version = self.get_prole_db_version() image_name = f'prole-db:{version}' subprocess.run(['k3d', 'image', 'import', image_name, '-c', cluster_name], check=True, capture_output=True) def create_validate_screen(self): """Create the Validate screen""" frame = self._page_container() self.screens['validate'] = frame # Title title = ttk.Label(frame, text="Validate Deployment", style='Title.TLabel') title.pack(pady=(0, 20)) # Prometheus link prometheus_frame = tk.Frame(frame, bg='white') prometheus_frame.pack(pady=(0, 20)) prometheus_label = tk.Label(prometheus_frame, text="Prometheus: ", bg='white', fg='#6e6e73', font=('Helvetica', 11)) prometheus_label.pack(side='left') prometheus_link = tk.Label(prometheus_frame, text="http://localhost:9090", bg='white', fg='#4a9eff', font=('Helvetica', 11, 'underline'), cursor='hand2') prometheus_link.pack(side='left') prometheus_link.bind('', lambda e: webbrowser.open('http://localhost:9090')) # Status display status_frame = tk.Frame(frame, bg='white') status_frame.pack(fill='both', expand=True, pady=10) status_label = ttk.Label(status_frame, text="Cluster Status", style='Heading.TLabel') status_label.pack(anchor='w', pady=(0, 10)) # Status text area - using TerminalConsole for consistency # Use a background frame to ensure NO borders are visible around the console console_bg = tk.Frame(status_frame, bg='white', highlightthickness=0, bd=0) console_bg.pack(fill='both', expand=True, padx=1, pady=1) self._validation_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) self._validation_console.pack(fill='both', expand=True) self.status_text = self._validation_console.text # Auto-refresh checkbox refresh_frame = tk.Frame(frame, bg='white') refresh_frame.pack(pady=10) self.auto_refresh_var = tk.BooleanVar(value=True) refresh_check = tk.Checkbutton(refresh_frame, text="Auto-refresh every 10 seconds", variable=self.auto_refresh_var, bg='white', fg='#1d1d1f', selectcolor='#f0f0f0', activebackground='white', activeforeground='#1d1d1f', font=('Helvetica', 10), command=self.toggle_auto_refresh) refresh_check.pack(side='left', padx=10) # Manual refresh button refresh_btn = tk.Button(refresh_frame, text="Refresh Now", command=self.refresh_status, bg='#4a9eff', fg='white', activebackground='#3a8eef', highlightbackground='white', font=('Helvetica', 10), padx=15, pady=5, cursor='hand2', relief='flat') refresh_btn.pack(side='left', padx=10) # Start auto-refresh self.refresh_status() self.toggle_auto_refresh() def toggle_auto_refresh(self): """Toggle auto-refresh""" if self.auto_refresh_var.get(): if not self.validation_running: self.validation_running = True self.validation_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True) self.validation_thread.start() else: self.validation_running = False def auto_refresh_loop(self): """Auto-refresh loop""" while self.validation_running: time.sleep(10) if self.validation_running: self.root.after(0, self.refresh_status) def refresh_status(self): """Refresh the cluster status""" def update(): try: full_status = "" # Get k3d cluster list try: cluster_result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True, timeout=5) full_status += f"=== k3d Cluster Status ===\n{cluster_result.stdout}\n\n" except Exception as e: full_status += f"=== k3d Cluster Status ===\nError: {str(e)}\n\n" # Get kubectl cnpg status try: namespace = (self.db_namespace.get() or 'default').strip() cmd = self._kubectl_base_cmd() + ['cnpg', 'status', 'prole-db', '-n', namespace] result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if result.returncode == 0: status_output = result.stdout else: # Try default namespace if prole fails, or just show error status_output = f"Error: {result.stderr}\n\nNote: Make sure kubectl cnpg plugin is installed and cluster exists in '{namespace}' namespace." full_status += f"=== CloudNativePG Status (namespace: {namespace}) ===\n{status_output}\n" except FileNotFoundError: full_status += "=== CloudNativePG Status ===\nError: kubectl not found. Please install dependencies first.\n" except subprocess.TimeoutExpired: full_status += "=== CloudNativePG Status ===\nError: Command timed out\n" except Exception as e: full_status += f"=== CloudNativePG Status ===\nError: {str(e)}\n" full_status += f"\nLast updated: {time.strftime('%Y-%m-%d %H:%M:%S')}" self.status_text.delete('1.0', tk.END) self.status_text.insert('1.0', full_status) except Exception as e: self.status_text.delete('1.0', tk.END) self.status_text.insert('1.0', f"Error: {str(e)}") threading.Thread(target=update, daemon=True).start() def has_display(): """Check if a display is available for GUI.""" # Check DISPLAY environment variable (X11) if os.environ.get('DISPLAY'): return True # On macOS, check if running in graphical session if platform.system() == 'Darwin': try: # Try to create a Tk root to see if GUI is available test_root = tk.Tk() test_root.withdraw() test_root.destroy() return True except Exception: return False # On Linux/Unix, no DISPLAY means no GUI return False def main(): import argparse # Parse command-line arguments parser = argparse.ArgumentParser(description='Prole Database Installer') parser.add_argument('-c', '--config', default=None, help='Path to prole.cfg for saving or silent replay') parser.add_argument('-S', '--silent', action='store_true', help='Run unattended install in console mode using prole.cfg') parser.add_argument('--prepare-k3s-pipeline', action='store_true', help='Prepare OpenTofu k3s pipeline and run silent install test') parser.add_argument('--reset', action='store_true', help='Reset k3d dev cluster or k3s service cluster (myrddin) when running in k3d/k3s mode') parser.add_argument('--no-gui', action='store_true', help='Run installer with ncurses terminal interface instead of GUI') parser.add_argument('--gui', action='store_true', help='Force GUI mode (will fail if no display available)') parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output') parser.add_argument('-d', '--debug', action='store_true', help='Enable debug logging to prole/logs') args = parser.parse_args() # Set up logging early from installer.config import setup_logging setup_logging(verbose=args.verbose, debug=args.debug) # Create controller (shared business logic) controller = ProleController(PROJECT_ROOT, verbose=args.verbose) if args.prepare_k3s_pipeline: rc = _prepare_k3s_pipeline(controller) sys.exit(rc) # Silent console mode (unattended) if args.silent: _ensure_ansible_vault_credentials(prompt_ui=False) installer = ProleSilentInstaller(controller, args.config, reset_cluster=args.reset) sys.exit(installer.run()) # Determine which interface to use use_gui = False if args.gui: # Force GUI mode use_gui = True elif args.no_gui: # Force ncurses mode use_gui = False else: # Auto-detect: use GUI if display is available, otherwise ncurses use_gui = has_display() if use_gui: # Run Tk GUI interface try: root = tk.Tk() root.title("Prole Database Installer") # Center window similarly to legacy UI window_width, window_height = 1000, 700 try: sw, sh = root.winfo_screenwidth(), root.winfo_screenheight() cx, cy = int(sw / 2 - window_width / 2), int(sh / 2 - window_height / 2) root.geometry(f"{window_width}x{window_height}+{cx}+{cy}") except Exception: root.geometry(f"{window_width}x{window_height}") _ensure_ansible_vault_credentials(prompt_ui=True, root=root) ProleInstaller(root, config_path=args.config, reset_cluster=args.reset, verbose=args.verbose) # Launch the main installer window immediately with background and welcome page. # The lightweight dependency verification runs on the welcome page and gates the footer there. root.mainloop() except Exception as e: print(f"Failed to start GUI: {e}", file=sys.stderr) print("Falling back to ncurses interface...", file=sys.stderr) time.sleep(1) from installer.ncurses_installer import run_ncurses_installer run_ncurses_installer(controller) else: _ensure_ansible_vault_credentials(prompt_ui=False) # Run ncurses interface from installer.ncurses_installer import run_ncurses_installer run_ncurses_installer(controller) if __name__ == '__main__': main()