mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
- Added Ollama endpoint to `Config.swift` and `install.py`. - Updated `ServiceChecker` to handle Ollama status checks and errors. - Enhanced `StatusView` with a sixth traffic light for Ollama.
3680 lines
156 KiB
Python
Executable File
3680 lines
156 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Prole Service Dependencies Installer
|
||
Desktop application for installing, deploying, and validating Prole services
|
||
"""
|
||
|
||
import tkinter as tk
|
||
from tkinter import ttk, scrolledtext, messagebox
|
||
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.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 = 1000
|
||
window_height = 700
|
||
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}")
|
||
|
||
# Light background overall
|
||
self.root.configure(bg='#f5f5f7')
|
||
|
||
# Style configuration
|
||
self.style = ttk.Style()
|
||
# Prefer native Aqua widgets on macOS; otherwise use a stable light theme
|
||
try:
|
||
if platform.system() == 'Darwin' and 'aqua' in self.style.theme_names():
|
||
self.style.theme_use('aqua')
|
||
else:
|
||
self.style.theme_use('clam')
|
||
except Exception:
|
||
# Fallback to whatever default exists
|
||
pass
|
||
self.configure_styles()
|
||
|
||
# Create main container
|
||
self.container = ttk.Frame(root)
|
||
self.container.pack(fill='both', expand=True)
|
||
|
||
# "Slide" area with a full-frame background on the lowest layer
|
||
self.slide_area = ttk.Frame(self.container)
|
||
self.slide_area.pack(fill='both', expand=True)
|
||
|
||
# Background canvas paints the image as a full-cover background
|
||
self._bg_pil = None
|
||
self._bg_tk = None
|
||
self.bg_canvas = tk.Canvas(self.slide_area, highlightthickness=0, bd=0)
|
||
self.bg_canvas.pack(fill='both', expand=True)
|
||
self._bg_item = None
|
||
try:
|
||
from PIL import Image, ImageTk # optional
|
||
# Use the full-frame background image from config
|
||
try:
|
||
bg_path = inst_config.get_ui_background_image_path()
|
||
except Exception:
|
||
bg_path = PROJECT_ROOT / 'img' / 'proleLogoSepia.png'
|
||
if bg_path.exists():
|
||
self._bg_pil = Image.open(str(bg_path)).convert('RGBA')
|
||
|
||
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
|
||
# Cover algorithm: scale so that image covers the canvas fully
|
||
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)
|
||
# center crop (no need to crop since canvas can clip)
|
||
self._bg_tk = ImageTk.PhotoImage(img)
|
||
# Only replace the previous background image; do NOT clear all canvas content
|
||
if self._bg_item is not None:
|
||
try:
|
||
self.bg_canvas.delete(self._bg_item)
|
||
except Exception:
|
||
pass
|
||
self._bg_item = ui.canvas_image(self, cw // 2, ch // 2, self._bg_tk, anchor='center')
|
||
# Ensure the background sits behind all other items
|
||
try:
|
||
self.bg_canvas.tag_lower(self._bg_item)
|
||
except Exception:
|
||
pass
|
||
# Defensive: refresh footer once background is in place
|
||
try:
|
||
self.update_footer()
|
||
except Exception:
|
||
pass
|
||
|
||
self.bg_canvas.bind('<Configure>', _render_bg)
|
||
# Initial render after window shows
|
||
self.root.after(100, _render_bg)
|
||
except Exception:
|
||
# If PIL not available or image missing, leave plain background
|
||
pass
|
||
|
||
# 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
|
||
|
||
# Footer navigation (Prev / Next / Finish) via centralized helper
|
||
btns = ui.create_nav_footer(
|
||
parent=self.container,
|
||
buttons=[(1, 'Prev'), (2, 'Next'), (3, 'Finish')],
|
||
commands={1: self.on_prev, 2: self.on_next, 3: self.on_finish},
|
||
style_name='Nav.TButton',
|
||
)
|
||
# Extract references for existing logic
|
||
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)
|
||
|
||
# Wizard pages setup
|
||
self.pages = [] # list of (page_id, frame)
|
||
self.page_index = 0
|
||
|
||
# Content will be rendered directly on the background canvas to avoid
|
||
# any opaque rectangles obscuring the image.
|
||
# Maintain a tiny overlay layer only for small interactive widgets (if any).
|
||
# removed deprecated wizard_frame overlay (legacy)
|
||
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)
|
||
|
||
# Initialize variables for new Initialize screens
|
||
self.cluster_env = tk.StringVar(value='dev')
|
||
self.db_password = tk.StringVar()
|
||
self.db_password_confirm = tk.StringVar()
|
||
|
||
# Create pages
|
||
self.page_frames = {}
|
||
self.verify_mode = tk.BooleanVar(value=False)
|
||
# Register page ids (renderers will draw directly on canvas)
|
||
# New: Environment setup page first
|
||
self._register_canvas_renderer('env_setup', self._render_env_setup_page)
|
||
self._register_canvas_renderer('welcome', self._render_welcome_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))
|
||
|
||
# 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_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
|
||
# Show env setup first as the open screen
|
||
self._register_page('env_setup', None)
|
||
self._register_page('welcome', None)
|
||
self._register_page('deps_summary', None)
|
||
for dep in self.dependencies:
|
||
self._register_page(f"dep_{dep['id']}", None)
|
||
|
||
# New Initialize screens in sequence
|
||
self._register_page('init_cluster', None)
|
||
self._register_page('init_db_build', None)
|
||
self._register_page('init_password', None)
|
||
self._register_page('init_scripts', None)
|
||
|
||
self._register_page('build', None)
|
||
self._register_page('build_summary', None)
|
||
|
||
# Initialize page and footer
|
||
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 = '#f5f5f7'
|
||
self.style.configure('Title.TLabel', background=base_bg, foreground='#1d1d1f', font=('Helvetica', 22, 'bold'))
|
||
self.style.configure('Body.TLabel', background=base_bg, foreground='#1d1d1f', font=('Helvetica', 12))
|
||
self.style.configure('Dim.TLabel', background=base_bg, foreground='#6e6e73', font=('Helvetica', 11))
|
||
self.style.configure('Card.TFrame', background=base_bg, relief='flat')
|
||
# Buttons: keep native look; ensure readable foreground
|
||
try:
|
||
self.style.configure('TButton', foreground='#1d1d1f')
|
||
except Exception:
|
||
pass
|
||
# Ensure checkbuttons and other common controls inherit light background
|
||
try:
|
||
self.style.configure('TCheckbutton', background=base_bg, foreground='#1d1d1f')
|
||
self.style.configure('TCombobox', fieldbackground='white', background=base_bg)
|
||
except Exception:
|
||
pass
|
||
|
||
def create_navigation(self):
|
||
# Deprecated top navigation retained for compatibility; not used in wizard redesign
|
||
pass
|
||
|
||
def show_screen(self, screen_id):
|
||
# No-op in wizard redesign
|
||
return
|
||
|
||
# ---------------- Wizard pages ----------------
|
||
def _page_container(self):
|
||
# Deprecated: pages are drawn directly on the canvas to avoid opaque overlays.
|
||
return None
|
||
|
||
def _register_page(self, page_id, frame):
|
||
self.pages.append((page_id, frame))
|
||
self.page_frames[page_id] = frame
|
||
|
||
def show_page(self, index_or_id):
|
||
# Clear any previously drawn canvas content
|
||
self._clear_canvas_page()
|
||
# Hide any legacy frames if they exist
|
||
for _, f in self.pages:
|
||
try:
|
||
|
||
if f is not None:
|
||
f.place_forget()
|
||
f.pack_forget()
|
||
except Exception:
|
||
pass
|
||
# Resolve index
|
||
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]
|
||
# 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='#1d1d1f', font=('Helvetica', 12))
|
||
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 _clear_canvas_page(self):
|
||
# 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):
|
||
# Opening screen: large welcome header (left‑justified), then Task 1: Environment
|
||
try:
|
||
self._canvas_items.append(ui.canvas_text(self, 48, 34, 'Welcome to Prole', fill='#1d1d1f', font=('Helvetica', 34, 'bold')))
|
||
except Exception:
|
||
# Fallback to standard title renderer if custom font fails
|
||
self._render_title('Welcome to Prole', y=34)
|
||
# Task heading for clarity
|
||
self._canvas_items.append(ui.canvas_text(self, 48, 88, 'Environment', fill='#1d1d1f', font=('Helvetica', 18, 'bold')))
|
||
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=118)
|
||
|
||
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 = 260
|
||
x_btn = 820
|
||
y = 160
|
||
row_h = 56
|
||
|
||
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='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
||
entry = ttk.Entry(self.slide_area, width=64)
|
||
entry.insert(0, values[key])
|
||
entry.place(x=x_field, y=y-12, width=530)
|
||
self._overlay_widgets.append(entry)
|
||
self._env_entries[key] = entry
|
||
|
||
btn = ttk.Button(self.slide_area, text='Browse…', command=lambda k=key: browse(k))
|
||
btn.place(x=x_btn, y=y-16, width=100)
|
||
self._overlay_widgets.append(btn)
|
||
|
||
# hint text
|
||
self._canvas_items.append(ui.canvas_text(self, x_field, y+16, help_text, fill='#6e6e73', font=('Helvetica', 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=860):
|
||
ui.render_paragraph(self, text, y, wrap)
|
||
|
||
def _render_welcome_page(self):
|
||
# Opening page should NOT perform any dependency checks.
|
||
self._render_title('Welcome to Prole', y=40)
|
||
msg = 'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.'
|
||
self._render_paragraph(msg, y=90)
|
||
# Ensure footer is updated (Next button visible)
|
||
self.update_footer()
|
||
|
||
def _start_welcome_dependency_scan(self):
|
||
self.splash_scan_running = True
|
||
self.splash_scan_done_at = None
|
||
self.splash_scan_started_at = time.time()
|
||
|
||
deps = list(self.dependencies)
|
||
total = len(deps)
|
||
results = {}
|
||
|
||
def set_status(text: str):
|
||
try:
|
||
if self._splash_status_item is not None:
|
||
# Replace existing text to avoid creating many items
|
||
self.bg_canvas.itemconfig(self._splash_status_item, text=text)
|
||
except Exception:
|
||
pass
|
||
|
||
def worker():
|
||
missing_names = []
|
||
for idx, dep in enumerate(deps, start=1):
|
||
try:
|
||
ok, location, version = self.get_dep_info(dep)
|
||
except Exception:
|
||
ok, location, version = False, None, None
|
||
results[dep['id']] = (ok, location, version)
|
||
# Update status text progressively
|
||
if ok:
|
||
txt = f"[{idx}/{total}] {dep['name']}: Installed"
|
||
else:
|
||
txt = f"[{idx}/{total}] {dep['name']}: Not installed"
|
||
missing_names.append(dep['name'])
|
||
self.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 = 90
|
||
# Draw each dependency row: status dot (blank initially), name, info
|
||
row_gap = 28
|
||
left = 56
|
||
text_x = left + 28
|
||
|
||
self.dep_status_items = {} # Store canvas IDs to update later
|
||
|
||
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='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
||
# info placeholder
|
||
info = ui.canvas_text(self, text_x + 150, y, 'Checking...', fill='#6e6e73', font=('Helvetica', 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 + 20
|
||
self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('Helvetica', 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='')
|
||
|
||
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=80)
|
||
# Status
|
||
ok, location, version = self.get_dep_info(dep)
|
||
y = 140
|
||
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+26, y-2, 'Installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
||
if location:
|
||
self._canvas_items.append(ui.canvas_text(self, left+26, y+26, f'Location: {location}', fill='#6e6e73', font=('Helvetica', 11)))
|
||
if version:
|
||
ver = self.normalize_version(version)
|
||
self._canvas_items.append(ui.canvas_text(self, left+26, y+46, f'Version: {ver}', fill='#6e6e73', font=('Helvetica', 11)))
|
||
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+26, y-2, 'Not installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
||
# Click to install link (if available)
|
||
if dep.get('install_cmd'):
|
||
link_y = y + 30
|
||
link_text = ui.render_link(self, left+26, 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(dep.get('install_cmd'))
|
||
self.bg_canvas.bind('<Button-1>', _on_click)
|
||
|
||
# ---------------- Initialize Screen Handlers ----------------
|
||
|
||
def _render_init_cluster_page(self):
|
||
self._render_title('Initialize Cluster', y=40)
|
||
self._render_paragraph('Select a cluster environment and ensure Docker and K3D are running.', y=90)
|
||
|
||
# Cluster Selection (Radio Buttons)
|
||
x_label = 56
|
||
y = 140
|
||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Cluster Name:', fill='#1d1d1f', font=('Helvetica', 14, 'bold')))
|
||
|
||
y += 30
|
||
cluster_options = [
|
||
('dev', 'prole-dev-cluster'),
|
||
('service', 'prole-service-cluster'),
|
||
('prod', 'prole-prod-cluster')
|
||
]
|
||
|
||
for val, name in cluster_options:
|
||
rb = ttk.Radiobutton(self.slide_area, text=name, variable=self.cluster_env, value=val)
|
||
rb.place(x=x_label+20, y=y)
|
||
self._overlay_widgets.append(rb)
|
||
y += 30
|
||
|
||
# Docker/K3D Status
|
||
y += 20
|
||
self.docker_status_label = ui.canvas_text(self, x_label, y, 'Docker: Checking...', fill='#6e6e73', font=('Helvetica', 12))
|
||
self._canvas_items.append(self.docker_status_label)
|
||
|
||
y += 25
|
||
self.k3d_status_label = ui.canvas_text(self, x_label, y, 'K3D Cluster: Checking...', fill='#6e6e73', font=('Helvetica', 12))
|
||
self._canvas_items.append(self.k3d_status_label)
|
||
|
||
y += 40
|
||
btn = ttk.Button(self.slide_area, text='Start / Verify Cluster', command=self.ensure_cluster_ready)
|
||
btn.place(x=x_label, y=y, width=200)
|
||
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):
|
||
self._render_title('Build Database Image', y=40)
|
||
self._render_paragraph('Building the prole-db Postgres image. This may take a few minutes.', y=90)
|
||
|
||
# Output Console
|
||
self._db_build_console = ui.TerminalConsole(self.slide_area)
|
||
self._db_build_console.place(x=48, y=130, width=860, height=360)
|
||
self._overlay_widgets.append(self._db_build_console)
|
||
|
||
btn = ttk.Button(self.slide_area, text='Start Build', command=self.run_db_build)
|
||
btn.place(x=48, y=500, width=150)
|
||
self._overlay_widgets.append(btn)
|
||
|
||
def run_db_build(self):
|
||
def worker():
|
||
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")
|
||
# 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")
|
||
else:
|
||
self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n")
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _render_init_password_page(self):
|
||
self._render_title('Database Password', y=40)
|
||
self._render_paragraph('Enter a password for the Prole database administrator. This will be used to initialize OpenBao and CloudNative-PG.', y=90)
|
||
|
||
x_label = 48
|
||
x_field = 200
|
||
y = 160
|
||
|
||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Password:', fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
||
p1 = ttk.Entry(self.slide_area, textvariable=self.db_password, show='*')
|
||
p1.place(x=x_field, y=y-12, width=300)
|
||
self._overlay_widgets.append(p1)
|
||
|
||
y += 50
|
||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Confirm:', fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
|
||
p2 = ttk.Entry(self.slide_area, textvariable=self.db_password_confirm, show='*')
|
||
p2.place(x=x_field, y=y-12, width=300)
|
||
self._overlay_widgets.append(p2)
|
||
|
||
# Indicator for password match (X or ✓)
|
||
self.password_indicator = ui.canvas_text(self, x_field + 310, y, '✘', fill='#dc3545', font=('Helvetica', 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_scripts_page(self):
|
||
self._render_title('Initialization Scripts', y=40)
|
||
self._render_paragraph('Running initialization scripts to set up OpenBao, CloudNative-PG, and Port Forwards.', y=80)
|
||
|
||
# Tabs for output
|
||
tab_container = ttk.Notebook(self.slide_area)
|
||
tab_container.place(x=48, y=120, width=860, height=380)
|
||
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 = ttk.Frame(tab_container)
|
||
tab_container.add(frame, text=title)
|
||
console = ui.TerminalConsole(frame)
|
||
console.pack(fill='both', expand=True)
|
||
self.script_consoles[fname] = console
|
||
|
||
btn = ttk.Button(self.slide_area, text='Run Scripts', command=self.run_init_scripts)
|
||
btn.place(x=48, y=510, width=150)
|
||
self._overlay_widgets.append(btn)
|
||
|
||
def run_init_scripts(self):
|
||
def worker():
|
||
password = self.db_password.get()
|
||
etc_dir = PROJECT_ROOT / "etc"
|
||
|
||
# 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)
|
||
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 and start
|
||
if proc.returncode == 0:
|
||
script = "init_cloudnative_pg.sh"
|
||
self.script_consoles[script].clear()
|
||
self.script_consoles[script].write(f"Running {script} start and initialize...\n")
|
||
|
||
# Start first
|
||
proc2 = subprocess.Popen(['bash', str(etc_dir / script), 'start'],
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||
while True:
|
||
line = proc2.stdout.readline()
|
||
if not line and proc2.poll() is not None:
|
||
break
|
||
if line:
|
||
self.script_consoles[script].write(line)
|
||
|
||
# Initialize second
|
||
proc2 = subprocess.Popen(['bash', str(etc_dir / script), 'initialize'],
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||
while True:
|
||
line = proc2.stdout.readline()
|
||
if not line and proc2.poll() is not None:
|
||
break
|
||
if line:
|
||
self.script_consoles[script].write(line)
|
||
|
||
# 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)
|
||
while True:
|
||
line = proc3.stdout.readline()
|
||
if not line and proc3.poll() is not None:
|
||
break
|
||
if line:
|
||
self.script_consoles[script].write(line)
|
||
|
||
threading.Thread(target=worker, daemon=True).start()
|
||
|
||
def _render_build_page(self):
|
||
self._render_title('Build', y=40)
|
||
self._render_paragraph('Build and prepare Prole services for deployment.', y=90)
|
||
|
||
# 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 = 130
|
||
left = 56
|
||
spacing = 110
|
||
|
||
# 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 = 9
|
||
circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='#1d1d1f', width=2)
|
||
self._canvas_items.append(circle)
|
||
# selected dot
|
||
if self.deploy_env_value == label:
|
||
dot = ui.canvas_oval(self, x+4, radio_y+4, x+2*r-4, radio_y+2*r-4, fill='#1d1d1f', outline='')
|
||
self._canvas_items.append(dot)
|
||
text = ui.canvas_text(self, x + 2*r + 8, radio_y - 2, label, fill='#1d1d1f', font=('Helvetica', 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)
|
||
|
||
# Embedded console overlay (semi-transparent black backdrop + scrolled text)
|
||
self._ensure_console_overlay(radio_bottom_y=radio_y + 24)
|
||
|
||
# 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 == 'init_cluster':
|
||
# Go back to the last missing dependency or deps_summary
|
||
seq = self._dep_navigation_sequence()
|
||
if seq:
|
||
self.show_page(seq[-1])
|
||
else:
|
||
self.show_page('deps_summary')
|
||
return
|
||
# Default prev
|
||
if self.page_index > 0:
|
||
self.show_page(self.page_index - 1)
|
||
|
||
def on_next(self):
|
||
# Special handling for dynamic labels
|
||
current_id = self.pages[self.page_index][0]
|
||
if current_id == 'env_setup':
|
||
# Collect values, validate, write env.sh, then go to deps summary
|
||
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
|
||
self.show_page('deps_summary')
|
||
return
|
||
if current_id == 'deps_summary':
|
||
# Determine where to go from summary
|
||
# Always go to the next dependency or Initialize cluster
|
||
if self.all_dependencies_installed():
|
||
self.show_page('init_cluster')
|
||
return
|
||
# Go to first missing dependency page
|
||
seq = self._dep_navigation_sequence()
|
||
target = seq[0] if seq else 'init_cluster'
|
||
self.show_page(target)
|
||
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_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('init_scripts')
|
||
return
|
||
if current_id == 'init_scripts':
|
||
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
|
||
# No longer using external Deploy page
|
||
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 to Initialize
|
||
self.show_page('init_cluster')
|
||
return
|
||
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 hidden states
|
||
self.prev_button.state(['!disabled'])
|
||
self.next_button.state(['!disabled'])
|
||
self.finish_button.state(['!disabled'])
|
||
|
||
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
|
||
if first:
|
||
self.prev_button.pack_forget()
|
||
self.finish_button.pack_forget()
|
||
if not self.next_button.winfo_ismapped():
|
||
self.next_button.pack(side='right', padx=(0, 8), pady=10)
|
||
elif last:
|
||
# Special-case: Build is not the true final if we have build_summary.
|
||
# On Build page (last before summary), show Prev + Build/Deploy/Next; hide Finish.
|
||
if pid == 'build':
|
||
if not self.prev_button.winfo_ismapped():
|
||
self.prev_button.pack(side='left', padx=(16, 8), pady=10)
|
||
if not self.next_button.winfo_ismapped():
|
||
self.next_button.pack(side='right', padx=(0, 8), pady=10)
|
||
self.finish_button.pack_forget()
|
||
else:
|
||
if not self.prev_button.winfo_ismapped():
|
||
self.prev_button.pack(side='left', padx=(16, 8), pady=10)
|
||
self.next_button.pack_forget()
|
||
if not self.finish_button.winfo_ismapped():
|
||
self.finish_button.pack(side='right', padx=(0, 20), pady=10)
|
||
else:
|
||
# Middle pages: Prev + Next
|
||
if not self.prev_button.winfo_ismapped():
|
||
self.prev_button.pack(side='left', padx=(16, 8), pady=10)
|
||
if not self.next_button.winfo_ismapped():
|
||
self.next_button.pack(side='right', padx=(0, 8), pady=10)
|
||
self.finish_button.pack_forget()
|
||
|
||
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.state(['!disabled'])
|
||
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.
|
||
"""
|
||
# 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 = 1000, 620
|
||
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:
|
||
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…')
|
||
self.next_button.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, 1000, 700
|
||
margin = 24
|
||
top = y0 + radio_bottom_y + 10
|
||
bottom = y0 + h - 90 # leave space for footer
|
||
left = x0 + 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):
|
||
# 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=90)
|
||
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"
|
||
)
|
||
self._render_paragraph(tips, y=90)
|
||
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(120)
|
||
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(120)
|
||
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 = 90 + 24
|
||
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 += 24
|
||
# 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 += 24
|
||
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 wasn’t 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.container, 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.container, 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
|
||
"""
|
||
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-031' # Fallback
|
||
|
||
def build_docker_image(self):
|
||
"""Build prole-db Docker image"""
|
||
# 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"""
|
||
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)
|
||
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.container, 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()
|
||
|