prole/install.py
chrisfu 906392d462 feat(installer): improve UI and add test coverage for core features
- Refactored installer UI with updated canvas rendering, sidebar navigation, and footer buttons.
- Enhanced styling for macOS compatibility and consistent design across controls.
- Added Pytest-based unit tests for `screen.py` and `config.py`.
- Expanded dependency catalog with new tools like `tshark` and `pyshark`.
- Improved error tolerance for background rendering and added placeholders for Kerberos configuration.
2026-01-08 21:51:30 -08:00

4434 lines
196 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
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
# Refactor: import shared helpers from the root-level installer package
from installer import config as inst_config
from installer.build import get_build_command as inst_get_build_command
from installer import deploy as inst_deploy
from installer import screen as ui
# On macOS, set the process name as early as possible so the menu bar shows 'Prole Database Installer'
if platform.system() == 'Darwin':
try:
from Foundation import NSProcessInfo
NSProcessInfo.processInfo().setProcessName_("Prole Database Installer")
except Exception:
pass
# Get the project root directory (kept local for clarity in this legacy entry)
PROJECT_ROOT = Path(__file__).parent.absolute()
def is_apple_silicon():
"""Check if running on Apple Silicon (ARM64)."""
return inst_config.is_apple_silicon()
def get_docker_build_platform_args():
"""Get Docker build platform arguments for Apple Silicon (from config)."""
return inst_config.get_docker_build_platform_args()
class ProleInstaller:
def __init__(self, root):
self.screens = None
self.deploy_environment = None
self.root = root
self._installing_dep_id = None
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)
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, 'Finish')],
commands={1: self.on_prev, 2: self.on_next, 3: self.on_finish},
style_name='Nav.TButton',
)
self.footer = btns.get('_footer') # type: ignore[assignment]
self.prev_button = btns.get(1)
self.next_button = btns.get(2)
self.finish_button = btns.get(3)
# 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 = Path('img/proleLogoSepia.png')
if bg_path.exists():
# Load and prepare image with 50% opacity
original = Image.open(str(bg_path)).convert('RGBA')
# Create a white background of the same size
white_bg = Image.new('RGBA', original.size, (255, 255, 255, 255))
# Blend with 15% opacity of original (85% white)
self._bg_pil = Image.blend(white_bg, original, 0.15)
def _render_bg(event=None):
if not self._bg_pil: return
cw = max(1, self.bg_canvas.winfo_width())
ch = max(1, self.bg_canvas.winfo_height())
iw, ih = self._bg_pil.size
scale = max(cw / iw, ch / ih)
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
img = self._bg_pil.resize((nw, nh), Image.LANCZOS)
self._bg_tk = ImageTk.PhotoImage(img)
if self._bg_item is not None:
self.bg_canvas.delete(self._bg_item)
self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, image=self._bg_tk, anchor='center')
self.bg_canvas.tag_lower(self._bg_item)
self.bg_canvas.bind('<Configure>', _render_bg)
except Exception as e:
print(f"Error loading background: {e}")
# Navigation menu items
self.nav_items = [
("Welcome", "welcome"),
("Dependencies", "deps_summary"),
("Network", "network_scan"),
("System Environment", "env_setup"),
("Kerberos Authentication", "kerberos_config"),
("Initialization", "init_cluster"),
("Docker Build", "init_db_build"),
("Prole DB Deploy", "init_cnpg_deploy"),
("Database Password", "init_password"),
("Prole.app", "build")
]
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
# Shared dependency catalog from installer.config
self.dependencies = list(inst_config.DEPENDENCIES)
# Wizard pages setup
self.pages = [] # list of (page_id, frame)
self.page_index = 0
# Initialize variables for new Initialize screens
self.cluster_env = tk.StringVar(value='dev')
self.db_password = tk.StringVar()
self.db_password_confirm = tk.StringVar()
# 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()
# 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)
# 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('build_summary', self._render_build_summary_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('kerberos_config', None)
# New Initialize screens in sequence
self._register_page('init_cluster', None)
self._register_page('init_db_build', None)
self._register_page('init_cnpg_deploy', None)
self._register_page('init_password', None)
self._register_page('build', None)
self._register_page('build_summary', None)
# Initial render after window shows
self.root.after(100, lambda: self.show_page(0))
self.update_footer()
# ---------------- 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 = [
PROJECT_ROOT / 'prole-tools-app' / 'dist' / 'Prole Tools.app' / 'Contents' / 'Resources' / 'AppIcon.icns',
PROJECT_ROOT / 'prole-tools-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 = PROJECT_ROOT / 'img' / 'proleIcon.png'
img_candidates = [
cfg_icon,
PROJECT_ROOT / 'img' / 'prole-type.png',
PROJECT_ROOT / 'img' / 'prole-type.gif',
PROJECT_ROOT / 'img' / 'Prole.png',
PROJECT_ROOT / '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 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')])
# Ensure common controls inherit white background
try:
self.style.configure('TCheckbutton', background=base_bg, foreground='black')
self.style.configure('TCombobox', fieldbackground='white', background=base_bg)
except Exception:
pass
def _create_sidebar_nav(self):
"""Create the left-hand navigation menu."""
tk.Label(self.sidebar, text="INSTALLER", bg='#F5F5DC', fg='#8B8B7A',
font=('SF Pro Text', 10, 'bold'), anchor='w').pack(fill='x', padx=20, pady=(20, 10))
for text, page_id in self.nav_items:
lbl = tk.Label(self.sidebar, text=text, bg='#F5F5DC', fg='black',
font=('SF Pro Text', 11), anchor='w', padx=20, pady=5, cursor='hand2')
lbl.pack(fill='x')
lbl.bind('<Button-1>', lambda e, p=page_id: self.show_page(p))
self.nav_widgets[page_id] = lbl
def _update_nav_highlight(self, active_id):
"""Highlight the current active page in the sidebar."""
# Handle dep_id mapping to 'Dependencies'
if active_id.startswith('dep_'):
active_id = 'deps_summary'
for page_id, widget in self.nav_widgets.items():
if page_id == active_id:
widget.configure(bg='#E5E5D5', font=('SF Pro Text', 11, 'bold'))
else:
widget.configure(bg='#F5F5DC', font=('SF Pro Text', 11))
def create_navigation(self):
pass
def show_screen(self, screen_id):
self.show_page(screen_id)
def show_page(self, index_or_id):
# Resolve index and page_id
if isinstance(index_or_id, int):
idx = max(0, min(index_or_id, len(self.pages) - 1))
else:
idx = next((i for i, (pid, _) in enumerate(self.pages) if pid == index_or_id), 0)
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', 'build_summary'
)
if pid in canvas_only_pages or pid.startswith('dep_'):
self.slide_area.place_forget()
else:
self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1)
self.slide_area.lift()
# Render the page directly on the canvas if we have a renderer
if pid in self.canvas_renderers:
try:
self.canvas_renderers[pid]()
except Exception as e:
# Fallback: show an error message on canvas
ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='black', font=('SF Pro Text', 11))
elif frame is not None:
# Legacy fallback (should not be used)
frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9)
self.update_footer()
# ---------------- Canvas page rendering ----------------
def _register_canvas_renderer(self, page_id, func):
self.canvas_renderers[page_id] = func
def _register_page(self, page_id, frame):
self.pages.append((page_id, frame))
self.page_frames[page_id] = frame
def _clear_canvas_page(self):
# Unbind common events to prevent "echo" or persistent behavior from previous pages
try:
self.bg_canvas.unbind('<Button-1>')
self.root.unbind('<Return>')
self.root.unbind('<KP_Enter>')
except Exception:
pass
# 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': str(home / 'conf'),
'PROLE_DATA': str(home / 'data'),
'PROLE_LOGS': str(home / 'logs'),
'PROLE_SERVICE': 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:
home = Path.home() / '.prole'
return {
'PROLE_HOME': str(home),
'PROLE_CONF': str(home / 'conf'),
'PROLE_DATA': str(home / 'data'),
'PROLE_LOGS': str(home / 'logs'),
'PROLE_SERVICE': str(home / 'etc'),
}
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 _save_env_to_file(self, values: dict):
home = Path(values['PROLE_HOME']).expanduser()
# ensure base dir exists
home.mkdir(parents=True, exist_ok=True)
# ensure subdirs exist
for key in ('PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
try:
Path(values[key]).expanduser().mkdir(parents=True, exist_ok=True)
except Exception:
pass
# write env.sh atomically
content = []
# Executable wrapper + sourceable config
content.append('#!/usr/bin/env bash')
content.append('# Prole environment configuration')
content.append('# This file is generated by the installer. Source it in new shells, or execute as a wrapper:')
content.append('# "$PROLE_HOME/env.sh" <command> [args…]')
content.append('# shellcheck shell=bash')
# Core PROLE_* directories
for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'):
content.append(f'export {k}="{values[k]}"')
content.append('')
# Ensure PATH contains common locations and $PROLE_HOME/bin (POSIX sh compatible)
content.append('# Ensure PATH works for GUI-launched shells (Docker, Ollama, 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('# _prole_add_path "/Applications/Ollama.app/Contents/MacOS"')
content.append('')
content.append('# If executed with arguments, run them under this environment')
content.append('if [ "$#" -gt 0 ]; then')
content.append(' exec "$@"')
content.append('fi')
tmp = home / 'env.sh.tmp'
out = home / 'env.sh'
tmp.write_text('\n'.join(content) + '\n')
tmp.replace(out)
try:
os.chmod(out, 0o755)
except Exception:
pass
# 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, "Infrastructure Automated.", 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 = 970 # Adjusted for new content width
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')))
# 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-12, window=entry, anchor='nw', width=650, 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-16, window=btn, anchor='ne', 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)))
y += row_h
# When PROLE_HOME changes, update dependent paths that still follow default pattern
def on_home_change(_evt=None):
self._update_dependent_env_paths()
try:
self._env_entries['PROLE_HOME'].bind('<FocusOut>', on_home_change)
except Exception:
pass
def _after_env_saved(self):
"""Reload the installer process environment after saving env.sh."""
try:
self.reload_env_from_shell()
except Exception as e:
try:
messagebox.showwarning('Environment', f'Environment saved, but reload failed: {e}')
except Exception:
pass
def _update_dependent_env_paths(self):
try:
new_home = Path(self._env_entries['PROLE_HOME'].get()).expanduser()
except Exception:
return
mapping = {
'PROLE_CONF': new_home / 'conf',
'PROLE_DATA': new_home / 'data',
'PROLE_LOGS': new_home / 'logs',
'PROLE_SERVICE': new_home / 'etc',
}
# Update entries if they are empty or previously matched the old base
for key, new_path in mapping.items():
ent = self._env_entries.get(key)
if not ent:
continue
cur = ent.get().strip()
if not cur:
ent.delete(0, 'end')
ent.insert(0, str(new_path))
continue
# If cur looked like old_home/<suffix>, update it
try:
# detect suffix
suffix = new_path.name
if cur.endswith('/' + suffix):
# replace base path
ent.delete(0, 'end')
ent.insert(0, str(new_path))
except Exception:
pass
# done
def _render_title(self, text, y=40):
ui.render_title(self, text, y)
def _render_paragraph(self, text, y, wrap=600):
ui.render_paragraph(self, text, y, wrap)
def _render_welcome_page(self):
# Opening page should NOT perform any dependency checks.
# 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, 210, 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 scan 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 scan")
# Ready to scan text (drawn on canvas)
status_item = ui.canvas_text(self, 48, y, "Ready to scan", 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)
y += 40
# Output Box (Larger, placed on canvas)
self.scan_results_text = scrolledtext.ScrolledText(self.bg_canvas, width=115, height=25,
font=('Menlo', 10), bg='white', fg='black',
insertbackground='black', highlightthickness=1,
highlightbackground='#CCCCCC')
results_window = self.bg_canvas.create_window(48, y, window=self.scan_results_text, anchor='nw')
self._overlay_widgets.append(self.scan_results_text)
self._canvas_items.append(results_window)
# Determine actual height of ScrolledText to place button below it
self.root.update_idletasks()
box_height = self.scan_results_text.winfo_reqheight()
y += box_height + 30
# Start Network Scan Button (placed on canvas)
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)
btn_window = self.bg_canvas.create_window(48, y, window=scan_btn, anchor='nw')
self._overlay_widgets.append(scan_btn)
self._canvas_items.append(btn_window)
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 _run_network_scan(self):
if getattr(self, '_scan_running', False):
return
self._scan_running = True
self.scan_results_text.delete('1.0', tk.END)
self.scan_results_text.insert(tk.END, "Initializing network scan...\n")
self.scan_status_var.set("Scanning...")
def worker():
try:
import pyshark
tshark_path = shutil.which("tshark")
if not tshark_path:
self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "tshark not found. Please install it from the Dependencies screen.\n"))
self.root.after(0, lambda: self.scan_status_var.set("Scan failed"))
self._scan_running = False
return
# Get local IP and determine network
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# doesn't even have to be reachable
s.connect(('10.255.255.255', 1))
local_ip = s.getsockname()[0]
except Exception:
local_ip = '127.0.0.1'
finally:
s.close()
self.root.after(0, lambda: self.scan_results_text.insert(tk.END, f"Local IP detected: {local_ip}\n"))
# We will perform a scan using ARP and common ports
# Pyshark is better for passive discovery, but we can try to find devices by sniffing for a bit
# or by looking at common infrastructure ports.
hosts = {} # ip -> {hostname, ports, type}
self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "Sniffing for network traffic (15 seconds)...\n"))
# Infrastructure detection
# port 88: Kerberos (AD)
# port 53: DNS
# port 445: SMB (Windows)
# port 631: CUPS (Apple/Linux)
capture = pyshark.LiveCapture(interface=None)
capture.sniff(timeout=15)
for packet in capture:
try:
if 'ip' in packet:
src_ip = packet.ip.src
dst_ip = packet.ip.dst
for ip in [src_ip, dst_ip]:
if ip not in hosts:
hosts[ip] = {"hostname": "Unknown", "ports": set(), "type": "Unknown Device"}
try:
hostname = socket.gethostbyaddr(ip)[0]
hosts[ip]["hostname"] = hostname
except Exception:
pass
# Try to identify by packet content
if 'kerberos' in packet:
hosts[src_ip]["type"] = "Kerberos Server / AD"
try:
realm = packet.kerberos.realm
self.kerberos_realm.set(realm)
self.kerberos_kdc.set(src_ip)
self.kerberos_enabled.set(True)
except AttributeError:
pass
if 'dns' in packet:
hosts[src_ip]["type"] = "DNS Server"
if 'tcp' in packet:
hosts[src_ip]["ports"].add(packet.tcp.srcport)
hosts[dst_ip]["ports"].add(packet.tcp.dstport)
# Heuristics for device types
if 'mdns' in packet or 'dns' in packet:
if '.local' in hosts[src_ip]["hostname"] or 'apple' in hosts[src_ip]["hostname"].lower():
hosts[src_ip]["type"] = "Apple Device"
if 'iphone' in hosts[src_ip]["hostname"].lower():
hosts[src_ip]["type"] = "Apple iPhone"
except (AttributeError, KeyError):
continue
# Refine device types based on ports
for ip, info in hosts.items():
if 88 in info["ports"] or "88" in info["ports"]:
info["type"] = "Active Directory / Kerberos"
elif 53 in info["ports"] or "53" in info["ports"]:
info["type"] = "DNS Server"
elif 445 in info["ports"] or "445" in info["ports"]:
if info["type"] == "Unknown Device":
info["type"] = "Windows Server"
# Identify default router (usually .1 or .254)
if ip.endswith(".1") or ip.endswith(".254"):
if info["type"] == "Unknown Device":
info["type"] = "Default Router"
# Output summary
self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "\n--- Network Summary ---\n"))
infra_found = False
for ip, info in hosts.items():
# Filter out multicast/broadcast
if ip.startswith("224.") or ip.startswith("239.") or ip.endswith(".255"):
continue
is_infra = info["type"] in ["Active Directory / Kerberos", "DNS Server", "Default Router", "Kerberos Server / AD"]
prefix = ">>> [INFRA] " if is_infra else " "
if is_infra: infra_found = True
line = f"{prefix}{info['type']}: {info['hostname']} ({ip})\n"
self.root.after(0, lambda l=line: self.scan_results_text.insert(tk.END, l))
if is_infra:
self.root.after(0, lambda: self.scan_results_text.tag_add("infra", "end-2c linestart", "end-1c"))
self.root.after(0, lambda: self.scan_results_text.tag_config("infra", foreground="blue", font=('Helvetica', 10, 'bold')))
if not infra_found:
self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "No specific infrastructure servers highlighted.\n"))
self.root.after(0, lambda: self.scan_status_var.set("Scan complete"))
self._scan_running = False
except Exception as e:
self.root.after(0, lambda: self.scan_results_text.insert(tk.END, f"Scan error: {str(e)}\n"))
self.root.after(0, 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.", y=200)
y = 280
# Use tk.Checkbutton on canvas
enable_cb = tk.Checkbutton(self.bg_canvas, text="Enable Kerberos Authentication",
variable=self.kerberos_enabled, bg='white', fg='black',
activebackground='white', selectcolor='white', font=('SF Pro Text', 11))
cb_window = self.bg_canvas.create_window(48, y, window=enable_cb, anchor='nw')
self._canvas_items.append(cb_window)
self._overlay_widgets.append(enable_cb)
x_field = 200
field_w = 400
y += 60
ui.canvas_text(self, 48, y, "Kerberos Realm:", fill='black', font=('SF Pro Text', 12))
realm_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_realm, bg='white', fg='black',
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
relief='flat', font=('SF Pro Text', 11))
realm_window = self.bg_canvas.create_window(x_field, y-4, window=realm_entry, anchor='nw', width=field_w, height=32)
self._canvas_items.append(realm_window)
self._overlay_widgets.append(realm_entry)
y += 48
ui.canvas_text(self, 48, y, "KDC Host/IP:", fill='black', font=('SF Pro Text', 12))
kdc_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_kdc, bg='white', fg='black',
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
relief='flat', font=('SF Pro Text', 11))
kdc_window = self.bg_canvas.create_window(x_field, y-4, window=kdc_entry, anchor='nw', width=field_w, height=32)
self._canvas_items.append(kdc_window)
self._overlay_widgets.append(kdc_entry)
y += 48
ui.canvas_text(self, 48, y, "Username:", fill='black', font=('SF Pro Text', 12))
user_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_user, bg='white', fg='black',
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
relief='flat', font=('SF Pro Text', 11))
user_window = self.bg_canvas.create_window(x_field, y-4, window=user_entry, anchor='nw', width=field_w, height=32)
self._canvas_items.append(user_window)
self._overlay_widgets.append(user_entry)
y += 48
ui.canvas_text(self, 48, y, "Password:", fill='black', font=('SF Pro Text', 12))
pass_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_password, show="*", bg='white', fg='black',
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
relief='flat', font=('SF Pro Text', 11))
pass_window = self.bg_canvas.create_window(x_field, y-4, window=pass_entry, anchor='nw', width=field_w, height=32)
self._canvas_items.append(pass_window)
self._overlay_widgets.append(pass_entry)
y += 60
# Use tk.Button on canvas
test_btn = tk.Button(self.bg_canvas, text="Test Connection", command=self.test_kerberos_connection,
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
highlightbackground='#F5F5DC', highlightthickness=0,
relief='flat', font=('SF Pro Text', 11), padx=16, pady=8)
btn_window = self.bg_canvas.create_window(48, y, window=test_btn, anchor='nw')
self._canvas_items.append(btn_window)
self._overlay_widgets.append(test_btn)
y += 60
# Use tk.Text
self.kerberos_status_text = tk.Text(self.bg_canvas, width=100, height=12, font=('Menlo', 10),
bg='white', fg='black', insertbackground='black',
highlightbackground='#CCCCCC', highlightthickness=1, relief='flat')
status_window = self.bg_canvas.create_window(48, y, window=self.kerberos_status_text, anchor='nw', width=800, height=200)
self._canvas_items.append(status_window)
self._overlay_widgets.append(self.kerberos_status_text)
def test_kerberos_connection(self):
realm = self.kerberos_realm.get().strip()
user = self.kerberos_user.get().strip()
password = self.kerberos_password.get().strip()
if not realm or not user or not password:
messagebox.showerror("Error", "Please fill in all fields.")
return
self.kerberos_status_text.delete('1.0', tk.END)
self.kerberos_status_text.insert(tk.END, f"Testing connection for {user}@{realm}...\n")
def worker():
try:
# Use kinit to test credentials
# We'll use a pipe to send the password to kinit if possible,
# but kinit usually expects it from terminal.
# A better way is using a keytab or expect, but for testing we can use kinit with stdin
principal = f"{user}@{realm}"
# Check if kinit is available
if not shutil.which("kinit"):
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, "Error: kinit not found. Please install kerberos client tools.\n"))
return
# Attempt kinit
process = subprocess.Popen(
["kinit", principal],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
stdout, stderr = process.communicate(input=password + "\n")
if process.returncode == 0:
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, "Success! Authenticated successfully.\n"))
# Get ticket details
klist_res = subprocess.run(["klist"], capture_output=True, text=True)
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"\nTicket details:\n{klist_res.stdout}"))
else:
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"Failed to authenticate.\nExit code: {process.returncode}\nError: {stderr}\n"))
except Exception as e:
self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"Error: {str(e)}\n"))
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.root.after(0, 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.
try:
self.root.after(600, self.update_footer)
except Exception:
pass
self.root.after(0, 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.
try:
self.root.after(9000, self.update_footer)
except Exception:
pass
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
left = 56
text_x = left + 28
self.dep_status_items = {} # Store canvas IDs to update later
self.dep_install_buttons = {} # Store button window items
for dep in self.dependencies:
# 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}
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 = 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.root.after(0, 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.'
self.bg_canvas.itemconfig(self.deps_msg_item, text=msg)
self.update_footer()
self.root.after(0, final_update)
def _render_dependency_page(self, dep):
self._render_title(dep['name'], y=40)
self._render_paragraph(dep['description'], y=88)
# Status
ok, location, version = self.get_dep_info(dep)
y = 180
left = 56
if ok:
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline=''))
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold')))
if location:
self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11)))
if version:
ver = self.normalize_version(version)
self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 11)))
else:
# Check if we should auto-install
install_cmd = dep.get('install_cmd')
if install_cmd and self._installing_dep_id != dep['id']:
# Launch directly into installation
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
# Trigger console-based install for brew/pip
if 'brew install' in install_cmd or 'pip install' in install_cmd:
self.root.after(500, lambda: self._install_dep_in_console(dep))
else:
self.root.after(500, lambda: self.open_terminal_with_command(install_cmd))
elif self._installing_dep_id == dep['id']:
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
else:
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline=''))
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold')))
# Click to install link (if available)
if install_cmd:
link_y = y + 40
link_text = ui.render_link(self, left+32, link_y, 'Click to install')
self._canvas_items.append(link_text)
self.bg_canvas.config(cursor='hand2')
def _on_click(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link_text)
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
self.open_terminal_with_command(install_cmd)
self.bg_canvas.bind('<Button-1>', _on_click)
def _install_dep_in_console(self, dep):
"""Run dependency installation in the embedded console."""
if self._installing_dep_id == dep['id']:
return
install_cmd = dep.get('install_cmd')
if not install_cmd:
return
self._installing_dep_id = dep['id']
# Prepare log file
logs_dir = PROJECT_ROOT / '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 _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('Initialize Cluster', y=150)
self._render_paragraph('Select a cluster environment and ensure Docker and K3D are running.', y=200)
# Cluster Selection (Radio Buttons)
x_label = 48
y = 280
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Cluster Name:', fill='black', font=('SF Pro Text', 14, 'bold')))
y += 40
cluster_options = [
('dev', 'prole-dev-cluster'),
('service', 'prole-service-cluster'),
('prod', 'prole-prod-cluster')
]
for val, name in cluster_options:
# Use tk.Radiobutton on canvas
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(x_label+20, y, window=rb, anchor='nw')
self._canvas_items.append(rb_window)
self._overlay_widgets.append(rb)
y += 32
# Docker/K3D Status
y += 30
self.docker_status_label = ui.canvas_text(self, x_label, y, 'Docker: Checking...', fill='#6e6e73', font=('SF Pro Text', 12))
self._canvas_items.append(self.docker_status_label)
y += 30
self.k3d_status_label = ui.canvas_text(self, x_label, y, 'K3D Cluster: Checking...', fill='#6e6e73', font=('SF Pro Text', 12))
self._canvas_items.append(self.k3d_status_label)
y += 50
# Use tk.Button on canvas
btn = tk.Button(self.bg_canvas, text='Start / Verify Cluster', command=self.ensure_cluster_ready,
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(x_label, y, window=btn, anchor='nw', width=220)
self._canvas_items.append(btn_window)
self._overlay_widgets.append(btn)
# Async status check
self.check_cluster_status_async()
def check_cluster_status_async(self):
def worker():
docker_ok = self.check_docker_running()
docker_msg = 'Docker: Running' if docker_ok else 'Docker: Not running'
docker_fill = '#34c759' if docker_ok else '#ff3b30'
cluster_name = f"prole-{self.cluster_env.get()}-cluster"
res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True)
k3d_ok = cluster_name in res.stdout
k3d_msg = f"K3D Cluster ({cluster_name}): Running" if k3d_ok else f"K3D Cluster ({cluster_name}): Not found/stopped"
k3d_fill = '#34c759' if k3d_ok else '#ff9f0a'
def update_ui():
if hasattr(self, 'docker_status_label'):
self.bg_canvas.itemconfig(self.docker_status_label, text=docker_msg, fill=docker_fill)
if hasattr(self, 'k3d_status_label'):
self.bg_canvas.itemconfig(self.k3d_status_label, text=k3d_msg, fill=k3d_fill)
self.root.after(0, update_ui)
threading.Thread(target=worker, daemon=True).start()
def ensure_cluster_ready(self):
# Implementation of cluster creation/startup
def worker():
# 1. Start Docker if not running
if not self.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.check_docker_running():
break
if not self.check_docker_running():
self.root.after(0, lambda: messagebox.showerror('Docker', 'Could not start Docker. Please start it manually.'))
return
# 2. Manage K3D Cluster
cluster_name = f"prole-{self.cluster_env.get()}-cluster"
res = subprocess.run(['k3d', 'cluster', 'list', '--no-headers'], capture_output=True, text=True)
if cluster_name not in res.stdout:
# Create it
# Default args based on README.md
cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2']
if self.cluster_env.get() == 'service':
cmd += ['--registry-create', 'k8s-prole-org-registry:k8s.prole.org:5000', '--api-port', '10.0.0.205:6443']
elif self.cluster_env.get() == 'dev':
cmd += ['--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.'))
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)
# Output Console
self._db_build_console = ui.TerminalConsole(self.bg_canvas)
console_window = self.bg_canvas.create_window(48, 260, window=self._db_build_console, anchor='nw', width=900, height=520)
self._canvas_items.append(console_window)
self._overlay_widgets.append(self._db_build_console)
# 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)
def run_db_build(self):
def worker():
self.root.after(0, lambda: self._db_build_button.configure(state='disabled'))
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Building...", fill='blue'))
tag = self.get_prole_db_version()
cwd = PROJECT_ROOT / "prole-db"
cmd = ['docker', 'build', '-t', f"prole-db:{tag}", '.']
self._db_build_console.clear()
self._db_build_console.write(f"Building prole-db:{tag} 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.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build successful! Importing...", fill='#34c759'))
# Import to k3d
cluster_name = f"prole-{self.cluster_env.get()}-cluster"
self._db_build_console.write(f"Importing image to {cluster_name}...\n")
subprocess.run(['k3d', 'image', 'import', f"prole-db:{tag}", '-c', cluster_name])
self._db_build_console.write("Import complete.\n")
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build and Import complete.", fill='#34c759'))
else:
self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n")
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text=f"Build failed (code {proc.returncode})", fill='#ff3b30'))
self.root.after(0, lambda: self._db_build_button.configure(state='normal'))
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 Password', y=150)
self._render_paragraph('Enter a password for the Prole database administrator. This will be used to initialize OpenBao and CloudNative-PG.', y=200)
x_label = 48
x_field = 200
y = 280
self._canvas_items.append(ui.canvas_text(self, x_label, y, '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 += 60
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)
# 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)
def on_password_change(*args):
p = self.db_password.get()
c = self.db_password_confirm.get()
if not p:
self.bg_canvas.itemconfigure(self.password_indicator, state='hidden')
elif p == c:
self.bg_canvas.itemconfigure(self.password_indicator, text='', fill='#28a745', state='normal')
else:
self.bg_canvas.itemconfigure(self.password_indicator, text='', fill='#dc3545', state='normal')
self.db_password.trace_add('write', on_password_change)
self.db_password_confirm.trace_add('write', on_password_change)
# Trigger once in case they are already set
on_password_change()
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('Deploy CloudNative-PG', y=150)
self._render_paragraph('Deploying the CloudNative-PG operator and cluster manifests to Kubernetes.', y=200)
# Output Console
self._cnpg_deploy_console = ui.TerminalConsole(self.bg_canvas)
console_window = self.bg_canvas.create_window(48, 260, window=self._cnpg_deploy_console, anchor='nw', width=900, height=520)
self._canvas_items.append(console_window)
self._overlay_widgets.append(self._cnpg_deploy_console)
# Use tk.Button
self._cnpg_deploy_button = tk.Button(self.bg_canvas, text='Run Deployment', command=self.run_cnpg_deploy,
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
highlightbackground='#F5F5DC', highlightthickness=0,
relief='flat', font=('SF Pro Text', 11), padx=16, pady=8)
btn_window = self.bg_canvas.create_window(48, 800, window=self._cnpg_deploy_button, anchor='nw', width=180)
self._canvas_items.append(btn_window)
self._overlay_widgets.append(self._cnpg_deploy_button)
# Status Label
self._cnpg_deploy_status_label = ui.canvas_text(self, 240, 812, "", fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(self._cnpg_deploy_status_label)
def run_cnpg_deploy(self):
def worker():
self.root.after(0, lambda: self._cnpg_deploy_button.configure(state='disabled'))
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deploying...", fill='blue'))
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)
self._cnpg_deploy_console.clear()
self._cnpg_deploy_console.write("Starting CloudNative-PG deployment...\n")
self._cnpg_deploy_console.write("> bash etc/init_prole-db.sh deploy latest\n\n")
proc = subprocess.Popen(['bash', str(etc_dir / 'init_prole-db.sh'), 'deploy', 'latest'],
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.root.after(0, lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment successful!", fill='#34c759'))
else:
self._cnpg_deploy_console.write(f"\nDeployment failed with code {proc.returncode}\n")
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Deployment failed (code {proc.returncode})", fill='#ff3b30'))
self.root.after(0, lambda: self._cnpg_deploy_button.configure(state='normal'))
threading.Thread(target=worker, daemon=True).start()
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 OpenBao, CloudNative-PG, and Port Forwards.', y=200)
# Tabs for output
tab_container = ttk.Notebook(self.bg_canvas)
tab_window = self.bg_canvas.create_window(48, 260, window=tab_container, anchor='nw', width=900, height=520)
self._canvas_items.append(tab_window)
self._overlay_widgets.append(tab_container)
self.script_consoles = {}
scripts = [
('OpenBao', 'init_openbao.sh'),
('CloudNative-PG', 'init_cloudnative_pg.sh'),
('Port Forwards', 'init_port_forwards.sh')
]
for title, fname in scripts:
frame = tk.Frame(tab_container, bg='white')
tab_container.add(frame, text=title)
console = ui.TerminalConsole(frame)
console.pack(fill='both', expand=True)
self.script_consoles[fname] = console
# 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, 800, 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, 812, "", fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(self._init_scripts_status_label)
def run_init_scripts(self):
def worker():
self.root.after(0, lambda: self._init_scripts_button.configure(state='disabled'))
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Running scripts...", fill='blue'))
password = self.db_password.get()
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)
# 1. init_openbao.sh initialize
script = "init_openbao.sh"
self.script_consoles[script].clear()
self.script_consoles[script].write(f"Running {script} initialize...\n")
# Using stdin for password as requested
proc = subprocess.Popen(['bash', str(etc_dir / script), 'initialize'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, env=env)
proc.stdin.write(f"{password}\n")
proc.stdin.close()
while True:
line = proc.stdout.readline()
if not line and proc.poll() is not None:
break
if line:
self.script_consoles[script].write(line)
# 2. init_cloudnative_pg.sh initialize
overall_success = (proc.returncode == 0)
if overall_success:
script = "init_cloudnative_pg.sh"
self.script_consoles[script].clear()
self.script_consoles[script].write(f"Running {script} initialize...\n")
# Initialize
self.script_consoles[script].write("> bash etc/init_cloudnative_pg.sh initialize\n")
proc2 = subprocess.Popen(['bash', str(etc_dir / script), 'initialize'],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env)
while True:
line = proc2.stdout.readline()
if not line and proc2.poll() is not None:
break
if line:
self.script_consoles[script].write(line)
if proc2.returncode != 0:
self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {proc2.returncode}\n")
overall_success = False
else:
self.script_consoles[script].write(f"\n{script} completed successfully.\n")
else:
self.script_consoles["init_cloudnative_pg.sh"].write("Skipping CloudNative-PG initialization because OpenBao initialization failed.\n")
# 3. init_port_forwards.sh start
if True: # Always try to start port forwards if previous steps were okay or even if they failed (best effort)
script = "init_port_forwards.sh"
self.script_consoles[script].clear()
self.script_consoles[script].write(f"Running {script} start...\n")
proc3 = subprocess.Popen(['bash', str(etc_dir / script), 'start'],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env)
while True:
line = proc3.stdout.readline()
if not line and proc3.poll() is not None:
break
if line:
self.script_consoles[script].write(line)
if proc3.returncode != 0:
overall_success = False
if overall_success:
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Scripts completed successfully.", fill='#34c759'))
else:
self.root.after(0, lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Some scripts failed.", fill='#ff3b30'))
self.root.after(0, lambda: self._init_scripts_button.configure(state='normal'))
threading.Thread(target=worker, daemon=True).start()
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.app', y=80)
self._render_paragraph('Build and prepare Prole services for deployment.', y=130)
# Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes)
if not hasattr(self, 'deploy_env_value'):
self.deploy_env_value = 'Dev'
radio_y = 180
left = 56
spacing = 150
# Draw three radio options
self._build_radio_items = []
options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)]
for label, x in options:
# outer circle
r = 10
circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='black', width=2)
self._canvas_items.append(circle)
# selected dot
if self.deploy_env_value == label:
dot = ui.canvas_oval(self, x+5, radio_y+5, x+2*r-5, radio_y+2*r-5, fill='black', outline='')
self._canvas_items.append(dot)
text = ui.canvas_text(self, x + 2*r + 10, radio_y - 2, label, fill='black', font=('SF Pro Text', 12))
self._canvas_items.append(text)
self._build_radio_items.append((label, circle, text))
# Click handling for radio selection
def _on_click(event):
ex, ey = event.x, event.y
for label, circle, text in self._build_radio_items:
bbox_c = self.bg_canvas.bbox(circle)
bbox_t = self.bg_canvas.bbox(text)
hit = False
if bbox_c and bbox_c[0] <= ex <= bbox_c[2] and bbox_c[1] <= ey <= bbox_c[3]:
hit = True
if bbox_t and bbox_t[0] <= ex <= bbox_t[2] and bbox_t[1] <= ey <= bbox_t[3]:
hit = True
if hit:
self.deploy_env_value = label
# Re-render only radios by re-drawing the page
self._clear_canvas_page()
self._render_build_page()
self.update_footer()
break
self.bg_canvas.bind('<Button-1>', _on_click)
# Output Console (Consistent size and location: y=260, width=900, height=520)
self._build_console = ui.TerminalConsole(self.bg_canvas)
console_window = self.bg_canvas.create_window(48, 260, window=self._build_console, anchor='nw', width=900, height=520)
self._canvas_items.append(console_window)
self._overlay_widgets.append(self._build_console)
self._console_text = self._build_console.text # For compatibility with _append_console and others
# Show a command preview with PS1-style prompt and blinking cursor
preview = self._compose_build_preview()
self._console_set_preview(preview)
# Ensure state holders exist
if not hasattr(self, 'last_build_log_path'):
self.last_build_log_path = None
# Update Next button label/state
self.update_footer()
# Bind Enter to trigger Build on this page
def _enter_build(_evt=None):
self.perform_build()
try:
self.root.bind('<Return>', _enter_build)
self.root.bind('<KP_Enter>', _enter_build)
except Exception:
pass
def _render_deploy_page(self):
self._render_title('Deploy', y=40)
self._render_paragraph('Preparing to deploy. We will verify steps and perform actions as needed.', y=90)
# Render simple list of steps (static view). Runtime updates can redraw as needed.
y = 140
left = 56
if not hasattr(self, 'deploy_steps'):
self.deploy_steps = [
{'name': 'Build Prole macOS app', 'status': 'pending'},
{'name': 'Build workstation Docker image', '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_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 == 'kerberos_config':
self.show_page('env_setup')
return
if current_id == 'init_cluster':
self.show_page('kerberos_config')
return
if current_id == 'init_db_build':
self.show_page('init_cluster')
return
if current_id == 'init_cnpg_deploy':
self.show_page('init_db_build')
return
if current_id == 'init_password':
self.show_page('init_cnpg_deploy')
return
if current_id == 'build':
self.show_page('init_password')
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]
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():
self.show_page('network_scan')
return
# Go to first missing dependency page
seq = self._dep_navigation_sequence()
target = seq[0] if seq else 'network_scan'
self.show_page(target)
return
if current_id.startswith('dep_'):
# Navigate within dependency sequence
seq = self._dep_navigation_sequence()
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():
self.show_page('deps_summary')
else:
self.show_page('network_scan')
return
if current_id == 'network_scan':
self.show_page('env_setup')
return
if current_id == 'env_setup':
# Collect values, validate, write env.sh, then go to Kerberos config
vals = {}
try:
for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'):
vals[k] = self._env_entries[k].get().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('kerberos_config')
return
if current_id == 'kerberos_config':
self.show_page('init_cluster')
return
if current_id == 'init_cluster':
# Ensure docker is started, then proceed
if not self.check_docker_running():
try:
messagebox.showerror('Docker', 'Docker is not running. Please start Docker and try again.')
except Exception:
pass
return
self.show_page('init_db_build')
return
if current_id == 'init_db_build':
self.show_page('init_cnpg_deploy')
return
if current_id == 'init_cnpg_deploy':
self.show_page('init_password')
return
if current_id == 'init_password':
# Validate passwords match and are not empty
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
self.show_page('build')
return
if current_id == 'build':
# Build page button: Deploy on success (and then show summary), otherwise (re)run build
if getattr(self, '_built_success', False):
try:
self.open_drag_install_window()
except Exception:
pass
# Always navigate to Build Summary per spec
self.show_page('build_summary')
return
# Start or rerun build; Build Summary opens automatically on completion
self.perform_build()
return
# Default next
if self.page_index < len(self.pages) - 1:
self.show_page(self.page_index + 1)
return
if current_id == 'build':
# Build page button: Deploy on success (and then show summary), otherwise (re)run build
if getattr(self, '_built_success', False):
try:
self.open_drag_install_window()
except Exception:
pass
# Always navigate to Build Summary per spec
self.show_page('build_summary')
return
# Start or rerun build; Build Summary opens automatically on completion
self.perform_build()
return
# No longer using external Deploy page
if self.page_index < len(self.pages) - 1:
self.show_page(self.page_index + 1)
def on_finish(self):
# Close app on Finish
self.root.quit()
def update_footer(self):
# Default states for tk.Button
self.prev_button.configure(state='normal')
self.next_button.configure(state='normal')
self.finish_button.configure(state='normal')
first = self.page_index == 0
last = self.page_index == len(self.pages) - 1
# Base labels
self.next_button.configure(text='Next')
self.finish_button.configure(text='Finish')
# Page-specific adjustments
pid = self.pages[self.page_index][0]
# Welcome page no longer gates navigation; treat as normal first page
if pid == 'deps_summary':
# Dependencies screens use Prev/Next wording
self.next_button.configure(text='Next')
if pid == 'build':
# Build page: show Build/Deploy/Next depending on state
if getattr(self, '_built_success', False):
self.next_button.configure(text='Deploy')
elif getattr(self, '_build_attempted', False):
# Attempted and failed → keep as Build (no Next on Build page)
self.next_button.configure(text='Build')
else:
self.next_button.configure(text='Build')
# Visibility rules
self.prev_button.pack_forget()
self.next_button.pack_forget()
self.finish_button.pack_forget()
if first:
self.next_button.pack(side='right', padx=(0, 20), pady=12)
elif last:
if pid == 'build':
# [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)
else:
# [Prev] [Finish] clustered right
self.finish_button.pack(side='right', padx=(0, 20), pady=12)
self.prev_button.pack(side='right', padx=(0, 8), 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.root.after(0, 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.root.after(0, 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()
def _apply_dependency_scan(self, results: dict):
"""Apply scan results to the summary UI and footer labels."""
self.validation_running = False
any_missing = False
for did, slot in self.dep_status.items():
ok, location, version = results.get(did, (False, None, None))
self._draw_status(slot['canvas'], 'success' if ok else 'error')
if ok:
norm_ver = self.normalize_version(version) if version else ''
slot['info'].configure(text=(norm_ver or ''))
else:
any_missing = True
slot['info'].configure(text='Not installed')
has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None
if any_missing:
missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]]
if missing and has_msg:
self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}")
else:
if has_msg:
self.deps_msg.configure(text='All dependencies installed.')
self.update_footer()
def _create_page_dependency(self, dep):
f = self._page_container()
ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
status_var = tk.StringVar(value='Checking...')
loc_var = tk.StringVar(value='')
ver_var = tk.StringVar(value='')
info_frame = ttk.Frame(f)
info_frame.pack(fill='x', padx=24, pady=12)
# Pretty status row with an icon
status_row = ttk.Frame(info_frame)
status_row.pack(anchor='w', fill='x')
status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0)
status_icon.pack(side='left', padx=(0, 8), pady=(2, 0))
ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left')
# Detail rows (hidden when not installed)
loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel')
ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel')
loc_label.pack(anchor='w')
ver_label.pack(anchor='w')
link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel')
link.pack(anchor='w', padx=24, pady=(12, 0))
def _draw_status_icon(canvas, status):
ui.canvas_clear(canvas)
if status == 'installed':
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#34c759', outline='')
ui.canvas_line_on(canvas, 4, 9, 8, 13, fill='white', width=2)
ui.canvas_line_on(canvas, 8, 13, 15, 5, fill='white', width=2)
elif status == 'missing':
# Friendly warning dot with exclamation
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#ff9f0a', outline='')
ui.canvas_line_on(canvas, 9, 5, 9, 11, fill='white', width=2)
ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill='white', outline='white')
def check_then_update():
ok, location, version = self.get_dep_info(dep)
if ok:
status_var.set('Installed')
_draw_status_icon(status_icon, 'installed')
loc_var.set(f'Location: {location or ""}')
norm_ver = self.normalize_version(version) if version else None
ver_var.set(f'Version: {norm_ver or ""}')
# Ensure details visible
try:
loc_label.pack_configure()
ver_label.pack_configure()
link.pack_forget()
except Exception:
pass
else:
status_var.set('Not installed')
_draw_status_icon(status_icon, 'missing')
# Hide undefined details instead of showing dashes
try:
loc_label.pack_forget()
ver_label.pack_forget()
except Exception:
pass
try:
# Keep install link visible if we have a command
if dep.get('install_cmd') and not link.winfo_ismapped():
link.pack(anchor='w', padx=24, pady=(12, 0))
except Exception:
pass
self.root.after(50, check_then_update)
def do_install(event=None):
self.open_terminal_with_command(dep.get('install_cmd'))
if dep.get('install_cmd'):
link.bind('<Button-1>', do_install)
else:
try:
link.pack_forget()
except Exception:
pass
self._register_page(f'dep_{dep["id"]}', f)
def _dep_navigation_sequence(self, force_all: bool = False):
"""Return a list of dependency page ids to traverse next.
- If force_all is True or verify_mode is True: include all dep pages in defined order.
- Else: include only missing dependency pages based on current checks.
"""
if force_all or self.verify_mode.get():
return [f'dep_{d["id"]}' for d in self.dependencies]
seq = []
for d in self.dependencies:
ok, _, _ = self.get_dep_info(d)
if not ok:
seq.append(f'dep_{d["id"]}')
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
self.build_output = scrolledtext.ScrolledText(f, height=12, bg='#fafafa', fg='#1d1d1f')
self.build_output.pack(fill='both', expand=True, padx=24, pady=12)
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:
return False
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)."""
# 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 = f"echo 'ERROR: {str(e).replace("'", "'\''")}' && 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 = PROJECT_ROOT / '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'build-{ts}.log'
self.last_build_log_path = str(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 navigate to Build Summary
try:
self._built_success = (returncode == 0)
except Exception:
self._built_success = False
# If success on macOS, open the drag & drop install Finder window now
if getattr(self, '_built_success', False):
try:
self.open_drag_install_window()
except Exception:
pass
# Re-enable Next and go to summary
try:
self.next_button.configure(state='normal')
except Exception:
pass
self.show_page('build_summary')
# ---------------- Embedded console helpers ----------------
def _ensure_console_overlay(self, radio_bottom_y: int = 160):
"""Create semi-transparent black 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
# Draw a stippled rectangle on the canvas to simulate ~60% opacity
rect = ui.canvas_rectangle(self, left, top, left + width, top + height, fill='#000000', outline='')
try:
# Apply stipple directly on the canvas item if supported
self.bg_canvas.itemconfig(rect, stipple='gray50')
except Exception:
pass
self._canvas_items.append(rect)
# Create ScrolledText overlay
txt = scrolledtext.ScrolledText(self.slide_area, wrap='word',
bg='#000000', fg='#ffffff', insertbackground='#ffffff',
font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0)
txt.place(x=left + 8, y=top + 8, width=max(50, width - 16), height=max(50, height - 16))
self._overlay_widgets.append(txt)
self._console_text = txt
# Keep overlay positioned on resize
def _on_resize(_evt=None):
l, t, w, h = self._compute_console_geometry(radio_bottom_y)
try:
self.bg_canvas.coords(rect, l, t, l + w, t + h)
except Exception:
pass
try:
txt.place(x=l + 8, y=t + 8, width=max(50, w - 16), height=max(50, h - 16))
except Exception:
pass
# Bind to slide area; store bind id to unbind later
self._overlay_bind_id = self.slide_area.bind('<Configure>', _on_resize)
def _compute_console_geometry(self, radio_bottom_y: int) -> tuple[int, int, int, int]:
"""Return (left, top, width, height) for the console overlay area."""
try:
w = self.slide_area.winfo_width()
h = self.slide_area.winfo_height()
except Exception:
w, h = 1300 * 0.75, 910 - 64 # Content area size approx
margin = 24
top = max(radio_bottom_y + 10, 120)
bottom = max(top + 180, h - 16) # ensure some height
height = max(160, bottom - top - 80) if bottom - top > 260 else max(140, h - top - 24)
# Recompute bottom based on height
bottom = min(h - 24, top + height)
left = margin
width = max(300, w - 2 * margin)
return (left, top, width, bottom - top)
def _append_console(self, text: str):
txt = getattr(self, '_console_text', None)
if not txt:
return
try:
if not txt.winfo_exists():
return
txt.configure(state='normal')
txt.insert('end', text)
txt.see('end')
txt.configure(state='disabled')
except Exception:
pass
def _console_press_enter(self):
"""Simulate pressing Enter on the console preview line: remove blinking cursor if present and add a newline."""
txt = getattr(self, '_console_text', None)
if not txt:
return
# Stop cursor blinking
if getattr(self, '_cursor_blink_after_id', None):
try:
self.root.after_cancel(self._cursor_blink_after_id)
except Exception:
pass
self._cursor_blink_after_id = None
self._cursor_blink_visible = False
try:
txt.configure(state='normal')
# If last char is our fake cursor, remove it
try:
last_char = txt.get('end-2c', 'end-1c')
if last_char in ('_', '|'):
txt.delete('end-2c', 'end-1c')
except Exception:
pass
txt.insert('end', '\n')
txt.see('end')
txt.configure(state='disabled')
except Exception:
pass
# ----- Command preview & blinking cursor helpers -----
def _get_user_host(self) -> tuple[str, str]:
try:
user = os.environ.get('USER') or os.getlogin()
except Exception:
user = 'user'
host = getattr(self, 'expected_host', None) or (platform.node() or 'host').split('.')[0]
return user, host
def _compose_build_preview(self) -> str:
env = getattr(self, 'deploy_env_value', 'Dev')
cmd = self.get_build_command(env)
user, host = self._get_user_host()
return f"[{user}@{host}]# {cmd}"
def _console_set_preview(self, line: str):
"""Clear console and show a single-line preview with blinking cursor."""
txt = getattr(self, '_console_text', None)
if not txt:
return
# Stop any previous blinking first
if getattr(self, '_cursor_blink_after_id', None):
try:
self.root.after_cancel(self._cursor_blink_after_id)
except Exception:
pass
self._cursor_blink_after_id = None
self._cursor_blink_visible = False
try:
txt.configure(state='normal')
txt.delete('1.0', 'end')
txt.insert('end', line)
txt.see('end')
txt.configure(state='disabled')
except Exception:
return
# Start blinking cursor at end of line
def blink():
t = getattr(self, '_console_text', None)
if t is None:
self._cursor_blink_after_id = None
return
try:
t.configure(state='normal')
# Remove existing cursor
if self._cursor_blink_visible:
# Delete last character if it's our cursor
end_index = t.index('end-1c')
if end_index and end_index != '1.0':
last_char = t.get('end-2c', 'end-1c')
if last_char in ('_', '|'):
t.delete('end-2c', 'end-1c')
self._cursor_blink_visible = False
else:
# Append cursor
t.insert('end', '_')
self._cursor_blink_visible = True
t.see('end')
t.configure(state='disabled')
except Exception:
self._cursor_blink_after_id = None
return
# schedule next toggle
self._cursor_blink_after_id = self.root.after(600, blink)
self._cursor_blink_after_id = self.root.after(600, blink)
def _run_in_console(self, command: str, log_path: str, on_complete=None):
"""Run a bash -lc command in a background subprocess and stream output to the console and a log file.
This function does not echo the command into the console so that the UI behaves like pressing Enter
on the previously previewed command line.
"""
# Ensure console exists
if not getattr(self, '_console_text', None):
self._ensure_console_overlay(160)
# Stop cursor blinking before starting execution
if getattr(self, '_cursor_blink_after_id', None):
try:
self.root.after_cancel(self._cursor_blink_after_id)
except Exception:
pass
self._cursor_blink_after_id = None
self._cursor_blink_visible = False
# Terminate any previous process
if getattr(self, '_running_process', None):
self._terminate_running_process()
# Open log file
try:
self._console_log_fp = open(log_path, 'a', buffering=1, encoding='utf-8')
except Exception:
self._console_log_fp = None
# Start process group for safe termination
def preexec():
try:
os.setsid()
except Exception:
pass
try:
proc = subprocess.Popen(['bash', '-lc', command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, preexec_fn=preexec)
self._running_process = proc
except Exception as e:
self._append_console(f"Failed to start process: {e}\n")
if self._console_log_fp:
try:
self._console_log_fp.write(f"Failed to start process: {e}\n")
except Exception:
pass
self._running_process = None
return
# Disable Next while running
try:
self.next_button.configure(text='Building…', state='disabled')
except Exception:
pass
# Reader thread
def reader():
rc = None
try:
for line in proc.stdout:
if line is None:
break
self.root.after(0, 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.root.after(0, lambda: on_complete(rc if rc is not None else -1))
t = threading.Thread(target=reader, daemon=True)
t.start()
def _terminate_running_process(self):
proc = getattr(self, '_running_process', None)
if not proc:
return
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
except Exception:
try:
proc.terminate()
except Exception:
pass
# best-effort kill after short delay
try:
for _ in range(10):
if proc.poll() is not None:
break
time.sleep(0.05)
if proc.poll() is None:
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGKILL)
except Exception:
proc.kill()
except Exception:
pass
self._running_process = None
# ---------------- Build helpers (Terminal window management) ----------------
def _terminal_create_new_window(self) -> str | None:
"""Create a brand-new Terminal window (never reuse existing) and return its id."""
if platform.system() != 'Darwin':
return None
osa = '''
tell application "Terminal" to activate
delay 0.05
tell application "System Events"
if exists process "Terminal" then
tell process "Terminal"
set frontmost to true
try
click menu item "New Window" of menu "Shell" of menu bar 1
on error
keystroke "n" using {command down}
end try
end tell
end if
end tell
delay 0.1
tell application "Terminal"
try
set _w to front window
set _id to id of _w
do script "" in _w
return _id
on error
return ""
end try
end tell
'''
try:
result = subprocess.run(['osascript', '-e', osa], capture_output=True, text=True)
if result.returncode == 0:
sid = result.stdout.strip()
return sid or None
except Exception:
pass
return None
def _terminal_set_bounds_by_id(self, win_id: str, l: int, t: int, r: int, b: int):
if platform.system() != 'Darwin' or not win_id:
return
osa = f'''tell application "Terminal" to try
set the bounds of every window whose id is {win_id} to {{{l}, {t}, {r}, {b}}}
end try'''
try:
subprocess.run(['osascript', '-e', osa])
except Exception:
pass
def _terminal_paste_by_id(self, win_id: str, text: str, press_enter: bool = False):
if platform.system() != 'Darwin' or not win_id:
return
# Put text on clipboard and paste into our specific window
try:
subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(text)} | pbcopy'])
except Exception:
pass
osa = '''
tell application "Terminal"
try
set _wins to every window whose id is {win_id}
if (count of _wins) > 0 then set front window to item 1 of _wins
end try
activate
end tell
delay 0.05
tell application "System Events"
keystroke "v" using {command down}
end tell
'''
if press_enter:
osa += '\n' + 'tell application "System Events" to key code 36'
try:
subprocess.run(['osascript', '-e', osa])
except Exception:
pass
def _compute_terminal_bounds(self, radio_bottom_y: int = 160) -> tuple[int, int, int, int]:
"""Compute terminal window bounds (left, top, right, bottom) to fit inside
the installer window between the radio buttons and the footer."""
try:
# Window absolute position
x0 = self.root.winfo_rootx()
y0 = self.root.winfo_rooty()
w = self.root.winfo_width()
h = self.root.winfo_height()
except Exception:
# Reasonable defaults
x0, y0, w, h = 200, 200, 1300, 910
margin = 24
top = y0 + radio_bottom_y + 10
bottom = y0 + h - 90 # leave space for footer
# Offset left by sidebar width (325) + divider (1)
left = x0 + 326 + margin
right = x0 + w - margin
# Ensure minimum height
if bottom - top < 160:
bottom = top + 160
return (left, top, right, bottom)
def open_build_terminal_for_canvas_area(self, radio_bottom_y: int = 160):
"""Open a brand-new Terminal.app window and size it to nestle inside the installer."""
if platform.system() != 'Darwin':
return
l, t, r, b = self._compute_terminal_bounds(radio_bottom_y)
win_id = self._terminal_create_new_window()
if win_id:
self._terminal_set_bounds_by_id(win_id, l, t, r, b)
self.build_terminal_window_id = win_id
def _start_terminal_follow(self, radio_bottom_y: int = 160):
"""Bind window Configure to keep Terminal bounds anchored to installer area."""
if platform.system() != 'Darwin':
return
self._terminal_follow_rby = radio_bottom_y
if getattr(self, '_terminal_follow_bound', False):
return
def _follow(_evt=None):
# Debounce slightly
if getattr(self, '_terminal_follow_after', None):
try:
self.root.after_cancel(self._terminal_follow_after)
except Exception:
pass
def _do():
if not getattr(self, 'build_terminal_window_id', None):
return
l, t, r, b = self._compute_terminal_bounds(self._terminal_follow_rby)
self._terminal_set_bounds_by_id(self.build_terminal_window_id, l, t, r, b)
self._terminal_follow_after = self.root.after(60, _do)
self.root.bind('<Configure>', _follow)
self._terminal_follow_bound = True
def _stop_terminal_follow(self):
if getattr(self, '_terminal_follow_bound', False):
try:
self.root.unbind('<Configure>')
except Exception:
pass
self._terminal_follow_bound = False
def paste_into_terminal(self, text: str, press_enter: bool = False):
"""Paste text into Terminal by targeting the build window id if available."""
if platform.system() != 'Darwin':
return
win_id = getattr(self, 'build_terminal_window_id', None)
if win_id:
self._terminal_paste_by_id(win_id, text, press_enter=press_enter)
return
# Fallback to generic new window
win_id = self._terminal_create_new_window()
if win_id:
self._terminal_paste_by_id(win_id, text, press_enter=press_enter)
def close_build_terminal(self):
if platform.system() != 'Darwin':
return
# stop following window
self._stop_terminal_follow()
win_id = getattr(self, 'build_terminal_window_id', None)
if not win_id:
# attempt to close front window politely
osa = 'tell application "Terminal" to if (count of windows) > 0 then close front window'
else:
osa = f'tell application "Terminal" to try\nclose (every window whose id is {win_id})\nend try'
try:
subprocess.run(['osascript', '-e', osa])
except Exception:
pass
def get_build_command(self, env: str) -> str:
"""Delegate to installer.build.get_build_command."""
return inst_get_build_command(PROJECT_ROOT, env)
# ---------------- Build Summary page ----------------
def _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 scrolled text overlay to show the log
try:
# Use same geometry helper as console but with DARK theme (match Build console)
l, t, w, h = self._compute_console_geometry(160)
txt = scrolledtext.ScrolledText(self.slide_area, wrap='word',
bg='#000000', fg='#ffffff', insertbackground='#ffffff',
font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0)
txt.place(x=l, y=t, width=max(200, w), height=max(140, h))
self._overlay_widgets.append(txt)
if logp and os.path.exists(logp):
try:
with open(logp, 'r', encoding='utf-8', errors='ignore') as fp:
content = fp.read()
txt.insert('1.0', content)
except Exception as e:
txt.insert('1.0', f"Failed to read log: {e}\n")
else:
txt.insert('1.0', 'No build log available.')
txt.configure(state='disabled')
# Keep overlay positioned on resize
def _on_resize(_evt=None):
try:
l2, t2, w2, h2 = self._compute_console_geometry(160)
txt.place(x=l2, y=t2, width=max(200, w2), height=max(140, h2))
except Exception:
pass
self._overlay_bind_id = self.slide_area.bind('<Configure>', _on_resize)
except Exception:
# Fallback: just show path
self._render_paragraph('No build log could be displayed.', y=120)
# Add link to open the log file in Finder/TextEdit
y_links = 100 + 40
if logp:
link = ui.render_link(self, 56, y_links, 'Open build log file')
self._canvas_items.append(link)
def _open_log(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link)
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
try:
if platform.system() == 'Darwin':
subprocess.run(['open', logp])
else:
webbrowser.open(f'file://{logp}')
except Exception:
pass
self.bg_canvas.bind('<Button-1>', _open_log)
# On success, also offer DMG packaging and opening options
if getattr(self, '_built_success', False):
try:
dist_dir = str(self._get_prole_dist_dir())
except Exception:
dist_dir = None
if dist_dir:
y_links += 30
# Create DMG
link2 = ui.render_link(self, 56, y_links, 'Create Prole Tools.dmg')
self._canvas_items.append(link2)
def _open_dist(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link2)
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
try:
# Build the DMG, then offer to open it
self.create_dmg()
self.open_dmg()
except Exception:
pass
self.bg_canvas.bind('<Button-1>', _open_dist)
# Also link to open dist folder (fallback)
y_links += 30
link3 = ui.render_link(self, 56, y_links, 'Open dist folder')
self._canvas_items.append(link3)
def _open_dist_folder(event):
ex, ey = event.x, event.y
bbox = self.bg_canvas.bbox(link3)
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
try:
if platform.system() == 'Darwin':
subprocess.run(['open', dist_dir])
else:
webbrowser.open(f'file://{dist_dir}')
except Exception:
pass
self.bg_canvas.bind('<Button-1>', _open_dist_folder)
# ---------------- Drag-and-drop install (macOS Finder) ----------------
def _get_prole_dist_dir(self) -> Path:
return PROJECT_ROOT / 'prole-tools-app' / 'dist'
def ensure_applications_symlink(self):
"""Deprecated: no longer create /Applications symlink inside the repo.
We now place the symlink only inside the DMG staging directory to avoid
confusing IDE indexers and to keep the workspace clean.
"""
return
def open_drag_install_window(self):
"""Open the Prole DMG in Finder (macOS) with medium-sized icons for drag-and-drop install."""
if platform.system() != 'Darwin':
return
try:
self.open_dmg()
except Exception:
pass
# ---------------- DMG Packaging ----------------
def _get_dmg_paths(self):
dist = self._get_prole_dist_dir()
dmg_name = 'Prole Tools.dmg'
tmp_dmg = dist / 'Prole Tools.tmp.dmg'
final_dmg = dist / dmg_name
staging = dist / 'dmg_stage'
bg_dir = staging / '.background'
bg_img = PROJECT_ROOT / 'img' / 'proleLogoSepia.png'
return {
'dist': 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 and an /Applications symlink.
The DMG includes a background image and is generated as compressed (UDZO).
"""
if platform.system() != 'Darwin':
return
p = self._get_dmg_paths()
dist = p['dist']
app_src = dist / 'Prole Tools.app'
if not app_src.exists():
raise FileNotFoundError(f"App not found: {app_src}")
# Prepare staging
staging = p['staging']
try:
if staging.exists():
# Clean staging safely
for root, dirs, files in os.walk(staging, topdown=False):
for name in files:
try:
os.remove(os.path.join(root, name))
except Exception:
pass
for name in dirs:
try:
os.rmdir(os.path.join(root, name))
except Exception:
pass
try:
os.rmdir(staging)
except Exception:
pass
staging.mkdir(parents=True, exist_ok=True)
# Copy app bundle
subprocess.check_call(['cp', '-R', str(app_src), str(staging / 'Prole Tools.app')])
# Inject launcher wrapper that sources $PROLE_HOME/env.sh
macos_dir = staging / 'Prole Tools.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():
# Move original binary to ProleTools.bin if not already moved
try:
if not real_bin_path.exists():
os.rename(launcher_path, real_bin_path)
except Exception:
pass
# Write launcher script (always overwrite to ensure correctness)
try:
script = """#!/bin/bash
set -euo pipefail
# Ensure PROLE_HOME exists and source env if available
export PROLE_HOME="${PROLE_HOME:-$HOME/.prole}"
if [ -f "$PROLE_HOME/env.sh" ]; then
# shellcheck disable=SC1090
. "$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)
# If original binary wasnt moved yet (e.g., app used different name), try to locate and adjust
if not real_bin_path.exists():
# Try common binary name without space
alt_bin = macos_dir / 'ProleTools'
if alt_bin.exists():
os.rename(alt_bin, real_bin_path)
except Exception as e:
raise RuntimeError(f"Failed to inject launcher: {e}")
# Applications symlink
try:
os.symlink('/Applications', str(staging / 'Applications'))
except FileExistsError:
pass
# Background image
p['bg_dir'].mkdir(parents=True, exist_ok=True)
if p['bg_img'].exists():
subprocess.check_call(['cp', str(p['bg_img']), str(p['bg_dir'] / 'background.png')])
except Exception as e:
raise RuntimeError(f"Failed to prepare DMG staging: {e}")
# Create temp RW DMG from staging
tmp_dmg = p['tmp_dmg']
final_dmg = p['final_dmg']
try:
if tmp_dmg.exists():
os.remove(tmp_dmg)
if final_dmg.exists():
os.remove(final_dmg)
subprocess.check_call([
'hdiutil', 'create', '-ov', '-fs', 'HFS+', '-srcfolder', str(staging),
'-volname', 'Prole Tools', str(tmp_dmg)
])
except Exception as e:
raise RuntimeError(f"Failed to create temp DMG: {e}")
# Convert to compressed read-only
try:
subprocess.check_call(['hdiutil', 'convert', str(tmp_dmg), '-format', 'UDZO', '-imagekey', 'zlib-level=9', '-o', str(final_dmg)])
except Exception as e:
raise RuntimeError(f"Failed to convert DMG: {e}")
finally:
try:
if tmp_dmg.exists():
os.remove(tmp_dmg)
except Exception:
pass
# Clean up staging directory to avoid leaving symlinks/files in the repo
try:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
except Exception:
# Best-effort cleanup; ignore failures
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_ollama.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 retropie.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=Ollama
svc.5.host={host}
svc.5.port=11434
svc.6.name=PostgreSQL
svc.6.host={host}
svc.6.port=5432
# Kerberos configuration
kerberos.enabled={str(self.kerberos_enabled.get()).lower()}
kerberos.realm={self.kerberos_realm.get()}
kerberos.user={self.kerberos_user.get()}
kerberos.kdc={self.kerberos_kdc.get()}
"""
props_path.write_text(content)
print(f"Generated {props_path} for {env} environment")
def run_deployment(self):
"""Run the deployment steps"""
try:
# Capture environment selection and prepare dynamic labels
env = self.deploy_environment.get().strip()
if env not in ('Dev', 'Service', 'Prod'):
env = 'Dev'
# Update step labels to reflect environment
self.deploy_widgets['Ensure target cluster']['step']['name'] = f"Ensure target cluster ({env})"
# Use the correct way to update the label text in the UI
self.deploy_widgets['Ensure target cluster']['label'].master.winfo_children()[1].configure(text=f"Ensure target cluster ({env})")
# Step 0: Generate prole.properties
self.generate_prole_properties(env)
# Step 1: Build Prole macOS app
self.update_deploy_step_status('Build Prole macOS app', 'running')
self.build_prole_app()
self.update_deploy_step_status('Build Prole macOS app', 'completed')
# Step 1: Check Docker
self.update_deploy_step_status('Check Docker is running', 'running')
if not self.check_docker_running():
self.update_deploy_step_status('Check Docker is running', 'error')
messagebox.showerror("Error", "Docker is not running. Please start Docker Desktop.")
return
self.update_deploy_step_status('Check Docker is running', 'completed')
# Step 2: Registry discovery/config
self.update_deploy_step_status('Check or configure container registry', 'running')
self.registry_url = self.ensure_registry_available(env)
self.update_deploy_step_status('Check or configure container registry', 'completed')
# Step 3: Ensure target cluster as per environment
self.update_deploy_step_status('Ensure target cluster', 'running')
self.create_or_select_cluster(env)
self.update_deploy_step_status('Ensure target cluster', 'completed')
# Step 3: Build Docker image
self.update_deploy_step_status('Build prole-db Docker image', 'running')
self.build_docker_image()
self.update_deploy_step_status('Build prole-db Docker image', 'completed')
# Step 4: Tag image
self.update_deploy_step_status('Tag Docker image for registry', 'running')
self.tag_docker_image()
self.update_deploy_step_status('Tag Docker image for registry', 'completed')
# Step 5: Push to registry
self.update_deploy_step_status('Push image to registry', 'running')
self.push_docker_image()
self.update_deploy_step_status('Push image to registry', 'completed')
# Step 6: Import to k3d (Dev only)
if env == 'Dev':
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'running')
self.import_k3d_image(cluster_name='prole-dev-cluster')
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'completed')
else:
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'skipped')
# Step 7: Install LaunchAgent for port-forwards (Dev)
if env == 'Dev':
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'running')
ok = self.install_launchagent_port_forwards()
if ok:
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'completed')
else:
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'error')
return
else:
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'skipped')
messagebox.showinfo("Success", "Deployment completed successfully!")
except Exception as e:
messagebox.showerror("Error", f"Deployment failed: {str(e)}")
def install_launchagent_port_forwards(self) -> bool:
"""Create/update the user LaunchAgent and helper script to manage kubectl port-forwards.
- Helper script: ~/Library/Application Support/Prole/bin/prole-kpf.sh
- LaunchAgent: ~/Library/LaunchAgents/org.prole.prole-db.kpf-dev.plist
"""
try:
home = Path.home()
bin_dir = home / 'Library' / 'Application Support' / 'Prole' / 'bin'
run_dir = home / 'Library' / 'Application Support' / 'Prole' / 'run'
plist_path = home / 'Library' / 'LaunchAgents' / 'org.prole.prole-db.kpf-dev.plist'
script_path = bin_dir / 'prole-kpf.sh'
bin_dir.mkdir(parents=True, exist_ok=True)
run_dir.mkdir(parents=True, exist_ok=True)
plist_path.parent.mkdir(parents=True, exist_ok=True)
helper_script = """#!/bin/sh
set -eu
LABEL="org.prole.prole-db.kpf-dev"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
RUNDIR="$HOME/Library/Application Support/Prole/run"
PIDFILE="$RUNDIR/kpf.pids"
ensure_rundir() {
mkdir -p "$RUNDIR"
}
list_cmds() {
# Enumerate ProleCommands array using PlistBuddy
if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then
i=0
while true; do
if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then
break
fi
echo "$val"
i=$((i+1))
done
fi
}
start() {
ensure_rundir
: > "$PIDFILE"
IFS='\n'
for cmd in $(list_cmds); do
[ -z "$cmd" ] && continue
(sh -lc "$cmd") &
echo $! >> "$PIDFILE"
done
wait
}
stop() {
if [ -f "$PIDFILE" ]; then
while read -r pid; do
[ -z "$pid" ] && continue
kill "$pid" 2>/dev/null || true
done < "$PIDFILE"
rm -f "$PIDFILE"
fi
}
status() {
if [ ! -f "$PIDFILE" ]; then
echo "not running"
exit 3
fi
alive=0
total=0
while read -r pid; do
[ -z "$pid" ] && continue
total=$((total+1))
if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi
done < "$PIDFILE"
echo "$alive/$total running"
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;;
esac
"""
script_path.write_text(helper_script)
os.chmod(script_path, 0o755)
# Default commands for Dev environment (k3d)
default_cmds = [
"kubectl port-forward svc/prometheus-community-kube-prometheus 9090",
"kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443",
"kubectl port-forward svc/prole-db-rw 5432:5432 --address 0.0.0.0",
"kubectl port-forward svc/prometheus-community-grafana 3000:80 --address 0.0.0.0",
]
# Create plist dict
from plistlib import dumps as plist_dumps
plist_dict = {
'Label': 'org.prole.prole-db.kpf-dev',
'RunAtLoad': True,
'KeepAlive': True,
# Ensure PATH has common locations for kubectl
'EnvironmentVariables': {
'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin')
},
'StandardOutPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.out.log'),
'StandardErrorPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.err.log'),
'ProgramArguments': [str(script_path), 'start'],
'ProleCommands': default_cmds,
}
plist_bytes = plist_dumps(plist_dict)
plist_path.write_bytes(plist_bytes)
# Reload the agent
uid = os.getuid()
# Try to kickstart first
subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True)
# Bootout and bootstrap to ensure it's loaded, then kickstart
subprocess.run(['launchctl', 'bootout', f'gui/{uid}', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True)
subprocess.run(['launchctl', 'bootstrap', f'gui/{uid}', str(plist_path)], check=True)
subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], check=True)
return True
except Exception as e:
print(f"LaunchAgent setup failed: {e}")
return False
def check_xcode_tools(self):
"""Check if Xcode Command Line Tools are installed via deploy helper."""
return inst_deploy.check_xcode_tools()
def build_prole_app(self):
"""Delegate building the native ProleStatus app to deploy helper."""
return inst_deploy.build_prole_app(PROJECT_ROOT)
def check_docker_running(self):
"""Check if Docker is running"""
try:
result = subprocess.run(['docker', 'ps'],
capture_output=True,
timeout=10)
return result.returncode == 0
except Exception:
return False
def ensure_registry_available(self, env: str) -> str:
"""Detect if k8s.prole.org:5000 is reachable; if so use it. Otherwise ensure a local registry is available.
Returns the registry URL (host:port) to be used for tagging/pushing.
For Dev, may create a local k3d-managed registry; for other envs, still prefer the external if reachable.
"""
def http_ping_registry(host: str, port: int) -> bool:
try:
import http.client
conn = http.client.HTTPConnection(host, port, timeout=3)
conn.request('GET', '/v2/')
resp = conn.getresponse()
# Docker registry typically returns 200 or 401 for /v2/
return resp.status in (200, 401)
except Exception:
return False
# Prefer the shared registry if reachable
if http_ping_registry('k8s.prole.org', 5000):
return 'k8s.prole.org:5000'
# Otherwise, ensure a local registry (localhost:5000) exists/started
# Use k3d registry helper for Dev; for non-Dev, we still create/use local for pushing
reg_name = 'prole-registry'
# Check if k3d is installed
k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0
if k3d_exists:
# List registries
lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True)
if reg_name not in (lst.stdout or ''):
# Create registry exposed on 0.0.0.0:5000
subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True)
return 'localhost:5000'
def 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 in ('Service', 'Prod'):
# For now, just check kubectl availability and inform user. Real connectivity requires kubeconfig.
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.")
# Optionally, try a quick cluster-info; don't fail hard on auth errors.
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]
cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2', '--wait'] + reg_args + ['--timestamps']
subprocess.run(cmd, check=True, cwd=PROJECT_ROOT)
def get_prole_db_version(self):
"""Read the version from the conf directory"""
version_file = PROJECT_ROOT / 'conf' / 'postgresql' / '.version'
if version_file.exists():
return version_file.read_text().strip()
return '17.7-033' # Fallback
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)
shutil.copytree(conf_src, conf_dst)
build_cmd = ['docker', 'build', '-t', image_tag]
# Add platform flag for Apple Silicon (ARM64 needs amd64 for compatibility)
build_cmd.extend(get_docker_build_platform_args())
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(version)
def update_k8s_manifest(self, version):
"""Update k8s/prole/prole-db.yaml with the new version and Kerberos config"""
manifest_path = PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db.yaml'
if not manifest_path.exists():
return
content = manifest_path.read_text()
import re
# Update imageName: prole-db:17.7-031
new_content = re.sub(r'imageName: prole-db:.*', f'imageName: prole-db:{version}', 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'):
"""Build mssql Docker image (with platform detection for Apple Silicon)"""
build_cmd = ['docker', 'build', '-t', image_tag]
# Add platform flag for Apple Silicon (mssql requires amd64)
build_cmd.extend(get_docker_build_platform_args())
build_cmd.append('.')
result = subprocess.run(build_cmd,
check=True,
cwd=PROJECT_ROOT / 'mssql',
capture_output=True,
text=True)
if result.returncode != 0:
raise Exception(f"Failed to build mssql image: {result.stderr}")
return result
def tag_docker_image(self):
"""Tag Docker image for registry"""
version = self.get_prole_db_version()
registry = getattr(self, 'registry_url', 'localhost:5000')
image = getattr(self, 'local_image_tag', f'prole-db:{version}')
self.remote_image_tag = f"{registry}/prole-db:{version}"
subprocess.run(['docker', 'tag', image, self.remote_image_tag], check=True, capture_output=True)
def push_docker_image(self):
"""Push Docker image to registry"""
remote_tag = getattr(self, 'remote_image_tag', None)
if not remote_tag:
raise Exception('Remote image tag not set')
result = subprocess.run(['docker', 'push', remote_tag], check=True, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"Failed to push image: {result.stderr}")
def import_k3d_image(self, cluster_name='prole-dev-cluster'):
"""Import image to k3d cluster (only for Dev)."""
version = self.get_prole_db_version()
subprocess.run(['k3d', 'image', 'import', f'prole-db:{version}', '-c', cluster_name], check=True, capture_output=True)
def create_validate_screen(self):
"""Create the Validate screen"""
frame = tk.Frame(self.content_area, bg='#1a1a1a')
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='#1a1a1a')
prometheus_frame.pack(pady=(0, 20))
prometheus_label = tk.Label(prometheus_frame,
text="Prometheus: ",
bg='#1a1a1a',
fg='#aaaaaa',
font=('Helvetica', 11))
prometheus_label.pack(side='left')
prometheus_link = tk.Label(prometheus_frame,
text="http://localhost:9090",
bg='#1a1a1a',
fg='#4a9eff',
font=('Helvetica', 11, 'underline'),
cursor='hand2')
prometheus_link.pack(side='left')
prometheus_link.bind('<Button-1>', lambda e: webbrowser.open('http://localhost:9090'))
# Status display
status_frame = tk.Frame(frame, bg='#1a1a1a')
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
self.status_text = scrolledtext.ScrolledText(status_frame,
bg='#0a0a0a',
fg='#4a9eff',
font=('Courier', 10),
wrap='word',
relief='flat',
bd=1)
self.status_text.pack(fill='both', expand=True)
# Auto-refresh checkbox
refresh_frame = tk.Frame(frame, bg='#1a1a1a')
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='#1a1a1a',
fg='#aaaaaa',
selectcolor='#2a2a2a',
activebackground='#1a1a1a',
activeforeground='#aaaaaa',
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',
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:
result = subprocess.run(['kubectl', 'cnpg', 'status', 'prole-db'],
capture_output=True,
text=True,
timeout=10)
if result.returncode == 0:
status_output = result.stdout
else:
status_output = f"Error: {result.stderr}\n\nNote: Make sure kubectl cnpg plugin is installed:\n kubectl krew install cnpg"
full_status += f"=== CloudNativePG Status ===\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 main():
# Root window
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}")
# 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.
ProleInstaller(root)
root.mainloop()
if __name__ == '__main__':
main()