prole/install.py
2025-12-01 14:52:18 -08:00

2346 lines
100 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
# Refactor: import shared helpers from the new 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.workstation import (
get_vagrant_up_command as inst_get_vagrant_up_command,
select_vagrantfile as inst_select_vagrantfile,
)
from installer import deploy as inst_deploy
# 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.root = root
self.root.title("Prole 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()
# Force a light, stable theme to avoid dark/black background artifacts.
# macOS Aqua can flip to dark backgrounds depending on system appearance.
try:
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 per latest spec
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)
self.bg_canvas.delete('all')
self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, anchor='center', image=self._bg_tk)
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
# 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)
self.footer = ttk.Frame(self.container)
self.footer.pack(fill='x', side='bottom')
self.prev_button = ttk.Button(self.footer, text='Prev', command=self.on_prev)
self.next_button = ttk.Button(self.footer, text='Next', command=self.on_next)
self.finish_button = ttk.Button(self.footer, text='Finish', command=self.on_finish)
# Layout: right-aligned
spacer = ttk.Frame(self.footer)
spacer.pack(side='left', expand=True, fill='x')
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
self.next_button.pack(side='right', padx=(0, 8), pady=12)
self.finish_button.pack(side='right', padx=(0, 20), pady=12)
# 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).
self.wizard_frame = None # deprecated overlay frame
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)
# Create pages
self.page_frames = {}
self.verify_mode = tk.BooleanVar(value=False)
# Register page ids (renderers will draw directly on canvas)
self._register_canvas_renderer('welcome', self._render_welcome_page)
self._register_canvas_renderer('deps_summary', self._render_deps_summary_page)
for dep in self.dependencies:
self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d))
self._register_canvas_renderer('build', self._render_build_page)
# New second build page for the Vagrant workstation
self._register_canvas_renderer('build_vm', self._render_build_vm_page)
self._register_canvas_renderer('build_summary', self._render_build_summary_page)
self._register_canvas_renderer('deploy', self._render_deploy_page)
# Mirror old pages list ordering for navigation
self._register_page('welcome', None)
self._register_page('deps_summary', None)
for dep in self.dependencies:
self._register_page(f"dep_{dep['id']}", None)
self._register_page('build', None)
self._register_page('build_vm', None)
self._register_page('build_summary', None)
self._register_page('deploy', None)
# Initialize page and footer
self.show_page(0)
self.update_footer()
# ---------------- App identity (title, Dock icon) ----------------
def _set_app_identity(self):
"""Set the app name shown by Tk and attempt to set the macOS Dock icon.
Notes:
- tk appname affects Tk's internal application name and may improve
display in some OS integrations. On macOS, fully changing the menu bar
app name from "Python" typically requires running as a bundled app
with CFBundleName set, but we still set the Tk appname here.
- For the Dock icon on macOS, we try to use the ProleStatus.app icns
(capital P icon). If PyObjC (AppKit) isn't available, we fall back to
setting a Tk window icon from a PNG/GIF in img/.
"""
# Set Tk application name
try:
self.root.tk.call('tk', 'appname', 'Prole Installer')
except Exception:
pass
# macOS Dock icon via AppKit (preferred)
if platform.system() == 'Darwin':
icns_candidates = [
PROJECT_ROOT / 'proleStatus' / 'dist' / 'ProleStatus.app' / 'Contents' / 'Resources' / 'AppIcon.icns',
PROJECT_ROOT / 'proleStatus' / 'dist' / 'ProleStatus.app' / 'Contents' / 'Resources' / 'ProleStatus.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
# Fallback: Tk icon from image assets (PNG/GIF)
img_candidates = [
PROJECT_ROOT / 'img' / 'prole-type.gif',
PROJECT_ROOT / 'img' / 'prole-type.png',
PROJECT_ROOT / 'img' / 'ProleStatus.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
self._canvas_items.append(
self.bg_canvas.create_text(
32, 32, anchor='nw', text=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):
if self._canvas_items:
for item in self._canvas_items:
try:
self.bg_canvas.delete(item)
except Exception:
pass
self._canvas_items = []
# Also unbind any page-specific bindings
self.bg_canvas.unbind('<Button-1>')
self.bg_canvas.config(cursor='')
# Remove any overlay widgets we placed for the previous page
if getattr(self, '_overlay_widgets', None):
for w in self._overlay_widgets:
try:
w.place_forget()
except Exception:
pass
try:
w.destroy()
except Exception:
pass
self._overlay_widgets = []
# Stop any blinking cursor if active
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
# Stop any page-specific resize binding for overlays
if getattr(self, '_overlay_bind_id', None):
try:
self.slide_area.unbind('<Configure>', self._overlay_bind_id)
except Exception:
pass
self._overlay_bind_id = None
# Unbind Enter/Return shortcuts that may have been set by a page
try:
self.root.unbind('<Return>')
self.root.unbind('<KP_Enter>')
except Exception:
pass
# If a build process is running and we leave the page, terminate it safely
if getattr(self, '_running_process', None):
try:
self._terminate_running_process()
except Exception:
pass
def _render_title(self, text, y=40):
self._canvas_items.append(
self.bg_canvas.create_text(48, y, anchor='nw', text=text,
fill='#1d1d1f', font=('Helvetica', 22, 'bold'))
)
def _render_paragraph(self, text, y, wrap=860):
self._canvas_items.append(
self.bg_canvas.create_text(48, y, anchor='nw', text=text, width=wrap,
fill='#1d1d1f', font=('Helvetica', 12), justify='left')
)
def _render_welcome_page(self):
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)
def _render_deps_summary_page(self):
self._render_title('Dependencies', y=40)
y = 90
# Draw each dependency row: status dot, name, info
row_gap = 28
left = 56
text_x = left + 28
# Try to get recent results by checking synchronously (cheap) or show pending
results = {}
any_missing = False
for dep in self.dependencies:
ok, location, version = self.get_dep_info(dep)
results[dep['id']] = (ok, location, version)
for dep in self.dependencies:
did = dep['id']
ok, _, version = results.get(did, (False, None, None))
# status dot
if ok:
fill = '#34c759'
else:
fill = '#ff3b30'
any_missing = True
self._canvas_items.append(self.bg_canvas.create_oval(left, y, left+18, y+18, fill=fill, outline=''))
# name
self._canvas_items.append(self.bg_canvas.create_text(text_x, y-2, anchor='nw', text=dep['name'],
fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
# info
info_text = ''
if ok:
info_text = self.normalize_version(version) if version else ''
else:
info_text = 'Not installed'
if info_text:
self._canvas_items.append(self.bg_canvas.create_text(text_x + 150, y, anchor='nw', text=info_text,
fill='#6e6e73', font=('Helvetica', 11)))
y += row_gap
# Verify all checkbox (canvas-drawn toggle)
toggle_y = y + 10
box_x = 52
box = self.bg_canvas.create_rectangle(box_x, toggle_y, box_x+16, toggle_y+16, outline='#6e6e73', width=2)
self._canvas_items.append(box)
label = self.bg_canvas.create_text(box_x+24, toggle_y-2, anchor='nw', text='Verify all dependencies',
fill='#1d1d1f', font=('Helvetica', 12))
self._canvas_items.append(label)
# check mark if enabled
if self.verify_mode.get():
self._canvas_items.append(self.bg_canvas.create_line(box_x+3, toggle_y+9, box_x+7, toggle_y+13, fill='#1d1d1f', width=2))
self._canvas_items.append(self.bg_canvas.create_line(box_x+7, toggle_y+13, box_x+14, toggle_y+5, fill='#1d1d1f', width=2))
# Message line
msg_y = toggle_y + 28
msg_text = ''
if any_missing:
missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]]
if missing:
msg_text = f"Preparing to install ... {', '.join(missing)}"
else:
msg_text = 'All dependencies installed.'
if msg_text:
self._canvas_items.append(self.bg_canvas.create_text(48, msg_y, anchor='nw', text=msg_text,
fill='#6e6e73', font=('Helvetica', 11)))
# Bind toggle click
def _on_click(event):
ex, ey = event.x, event.y
if box_x <= ex <= box_x+16 and toggle_y <= ey <= toggle_y+16:
self.verify_mode.set(not self.verify_mode.get())
self._clear_canvas_page()
self._render_deps_summary_page()
self.update_footer()
self.bg_canvas.bind('<Button-1>', _on_click)
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(self.bg_canvas.create_oval(left, y, left+18, y+18, fill='#34c759', outline=''))
self._canvas_items.append(self.bg_canvas.create_text(left+26, y-2, anchor='nw', text='Installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold')))
if location:
self._canvas_items.append(self.bg_canvas.create_text(left+26, y+26, anchor='nw', text=f'Location: {location}', fill='#6e6e73', font=('Helvetica', 11)))
if version:
ver = self.normalize_version(version)
self._canvas_items.append(self.bg_canvas.create_text(left+26, y+46, anchor='nw', text=f'Version: {ver}', fill='#6e6e73', font=('Helvetica', 11)))
else:
self._canvas_items.append(self.bg_canvas.create_oval(left, y, left+18, y+18, fill='#ff9f0a', outline=''))
self._canvas_items.append(self.bg_canvas.create_text(left+26, y-2, anchor='nw', text='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 = self.bg_canvas.create_text(left+26, link_y, anchor='nw', text='Click to install', fill='#0a84ff', font=('Helvetica', 12, 'underline'))
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)
def _render_build_page(self):
# Title and instructions on canvas (fully transparent background)
self._render_title('Build', y=40)
self._render_paragraph('Choose a target and build the artifacts.', 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 = self.bg_canvas.create_oval(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 = self.bg_canvas.create_oval(x+4, radio_y+4, x+2*r-4, radio_y+2*r-4, fill='#1d1d1f', outline='')
self._canvas_items.append(dot)
text = self.bg_canvas.create_text(x + 2*r + 8, radio_y - 2, anchor='nw', text=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_build_vm_page(self):
# Title and instructions
self._render_title('Build Workstation (Vagrant)', y=40)
self._render_paragraph('We will bring up the workstation VM using Vagrant. If a Vagrantfile specific to your target (dev/service/prod) exists, it will be used; otherwise we will use workstation/Vagrantfile.', y=90)
# Reuse the same environment selection chosen on Build page
if not hasattr(self, 'deploy_env_value'):
self.deploy_env_value = 'Dev'
# Less verbose per request
left = 56
y = 130
self._canvas_items.append(self.bg_canvas.create_text(left, y, anchor='nw', text="Building Prole Workstation", fill='#1d1d1f', font=('Helvetica', 13, 'bold')))
# Embedded console overlay under this text
self._ensure_console_overlay(radio_bottom_y=y + 24)
# Command preview for Vagrant up with PS1 prompt and blinking cursor
v_preview = self._compose_vagrant_preview()
self._console_set_preview(v_preview)
self.update_footer()
# Bind Enter to trigger the Vagrant build on this page
def _enter_vagrant(_evt=None):
self.perform_vagrant_build()
try:
self.root.bind('<Return>', _enter_vagrant)
self.root.bind('<KP_Enter>', _enter_vagrant)
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 ProleStatus macOS app', 'status': 'pending'},
{'name': 'Build workstation Vagrant VM', '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'},
]
for step in self.deploy_steps:
# status circle (pending empty)
self._canvas_items.append(self.bg_canvas.create_oval(left, y, left+18, y+18, outline='#b0b0b0'))
self._canvas_items.append(self.bg_canvas.create_text(left+26, y-2, anchor='nw', text=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
# 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 == 'deps_summary':
# Determine where to go from summary
if self.verify_mode.get():
# Start with first dependency page regardless of status
seq = self._dep_navigation_sequence(force_all=True)
target = seq[0] if seq else 'build'
self.show_page(target)
return
else:
if self.all_dependencies_installed():
# Jump directly to build
self.show_page('build')
return
# Go to first missing dependency page
seq = self._dep_navigation_sequence()
target = seq[0] if seq else 'build'
self.show_page(target)
return
if current_id == 'build':
# Treat Next as Build for ProleStatus
self.perform_build()
return
if current_id == 'build_vm':
# Build the workstation VM
self.perform_vagrant_build()
return
# No longer using external Terminal; nothing special to close on summary
if current_id == 'deploy':
# Start deployment steps when moving next
self.start_deployment()
return
if current_id.startswith('dep_'):
# Navigate within dependency sequence
seq = self._dep_navigation_sequence()
try:
i = seq.index(current_id)
except ValueError:
i = -1
if i >= 0 and i < len(seq) - 1:
self.show_page(seq[i + 1])
return
else:
# After last relevant dep page, go to build
self.show_page('build')
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]
if pid == 'deps_summary' and self.all_dependencies_installed():
# If verify mode is enabled, invite user to Verify instead of Build
self.next_button.configure(text='Verify' if self.verify_mode.get() else 'Build')
if pid == 'build':
# Build page has Prev + Build
self.next_button.configure(text='Build')
if pid == 'build_vm':
self.next_button.configure(text='Build')
if pid == 'deploy':
self.next_button.configure(text='Deploy')
if pid == 'build_summary':
self.next_button.configure(text='Deploy')
# 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=12)
elif last:
if not self.prev_button.winfo_ismapped():
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
self.next_button.pack_forget()
if not self.finish_button.winfo_ismapped():
self.finish_button.pack(side='right', padx=(0, 20), pady=12)
else:
# Middle pages: Prev + Next
if not self.prev_button.winfo_ismapped():
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
if not self.next_button.winfo_ismapped():
self.next_button.pack(side='right', padx=(0, 8), pady=12)
self.finish_button.pack_forget()
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
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
results[dep['id']] = (ok, location, version)
# marshal back to UI thread
self.root.after(0, lambda: self._apply_dependency_scan(results))
t = threading.Thread(target=worker, daemon=True)
self.validation_thread = t
t.start()
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):
canvas.delete('all')
if status == 'installed':
canvas.create_oval(1, 1, 17, 17, fill='#34c759', outline='')
canvas.create_line(4, 9, 8, 13, fill='white', width=2)
canvas.create_line(8, 13, 15, 5, fill='white', width=2)
elif status == 'missing':
# Friendly warning dot with exclamation
canvas.create_oval(1, 1, 17, 17, fill='#ff9f0a', outline='')
canvas.create_line(9, 5, 9, 11, fill='white', width=2)
canvas.create_oval(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_env_var = tk.StringVar(value='Dev')
ttk.Combobox(wrap, textvariable=self.deploy_env_var, 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 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 = {}
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):
canvas.delete('all')
if status == 'success':
canvas.create_oval(2, 2, 18, 18, fill='#34c759', outline='')
canvas.create_line(5, 10, 9, 14, fill='white', width=2)
canvas.create_line(9, 14, 16, 6, fill='white', width=2)
elif status == 'running':
canvas.create_oval(2, 2, 18, 18, fill='#ffd60a', outline='')
elif status == 'error':
canvas.create_oval(2, 2, 18, 18, fill='#ff3b30', outline='')
else:
canvas.create_oval(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 ProleStatus build in an embedded console (Scopped bash subprocess)."""
# Prepare logs dir and file
logs_dir = PROJECT_ROOT / 'logs'
try:
logs_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
ts = time.strftime('%Y%m%d-%H%M%S')
log_path = logs_dir / f'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()
full_cmd = guard + 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 _on_build_complete(self, returncode: int):
# After ProleStatus build completes, enable Next to proceed to the VM build page
try:
self.next_button.configure(text='Next')
self.next_button.state(['!disabled'])
except Exception:
pass
# Automatically navigate to the VM build page
self.show_page('build_vm')
def perform_vagrant_build(self):
# Prepare logs
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')
v_log_path = logs_dir / f'vagrant-{ts}.log'
self.last_vagrant_log_path = str(v_log_path)
env = getattr(self, 'deploy_env_value', 'Dev')
cmd = self.get_vagrant_up_command(env)
# Hostname guard
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()
full_cmd = guard + cmd
self._run_in_console(full_cmd, self.last_vagrant_log_path, on_complete=lambda rc: self._on_vagrant_build_complete(rc))
def _on_vagrant_build_complete(self, returncode: int):
# After VM build, go to summary
try:
self.next_button.configure(text='Summary')
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 = self.bg_canvas.create_rectangle(left, top, left + width, top + height,
fill='#000000', outline='', stipple='gray50')
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 _compose_vagrant_preview(self) -> str:
env = getattr(self, 'deploy_env_value', 'Dev')
cmd = self.get_vagrant_up_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)
def get_workstation_build_command(self) -> str:
"""Deprecated: kept for compatibility. Not used in embedded console flow."""
return f"cd \"{PROJECT_ROOT / 'workstation'}\" && vagrant up"
def _select_vagrantfile(self, env: str) -> str:
"""Select Vagrantfile via installer.workstation.select_vagrantfile."""
return str(inst_select_vagrantfile(PROJECT_ROOT, env))
def get_vagrant_up_command(self, env: str) -> str:
"""Delegate to installer.workstation.get_vagrant_up_command."""
return inst_get_vagrant_up_command(PROJECT_ROOT, env)
# ---------------- Build Summary page ----------------
def _render_build_summary_page(self):
self._render_title('Build summary', y=40)
logp = getattr(self, 'last_build_log_path', None)
vlog = getattr(self, 'last_vagrant_log_path', None)
if logp or vlog:
self._render_paragraph('Build output was captured from the embedded console.', y=90)
if logp:
self._render_paragraph('ProleStatus log:', y=120)
self._render_paragraph(logp, y=140)
y_next = 180 if logp else 120
if vlog:
self._render_paragraph('Workstation (Vagrant) log:', y=y_next)
self._render_paragraph(vlog, y=y_next+20)
# clickable link
link_y = (y_next + 56) if vlog else 200
if logp:
link1 = self.bg_canvas.create_text(56, link_y, anchor='nw', text='Open ProleStatus log', fill='#0a84ff', font=('Helvetica', 12, 'underline'))
self._canvas_items.append(link1)
if vlog:
link2 = self.bg_canvas.create_text(56, link_y + 28, anchor='nw', text='Open Vagrant log', fill='#0a84ff', font=('Helvetica', 12, 'underline'))
self._canvas_items.append(link2)
def _open_log(event):
ex, ey = event.x, event.y
if logp:
bbox = self.bg_canvas.bbox(link1)
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
return
if vlog:
bbox2 = self.bg_canvas.bbox(link2)
if bbox2 and bbox2[0] <= ex <= bbox2[2] and bbox2[1] <= ey <= bbox2[3]:
try:
if platform.system() == 'Darwin':
subprocess.run(['open', vlog])
else:
webbrowser.open(f'file://{vlog}')
except Exception:
pass
return
self.bg_canvas.bind('<Button-1>', _open_log)
else:
self._render_paragraph('No build log path available.', y=100)
def create_install_screen(self):
"""Create the Install 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 with all commands"""
script_path = PROJECT_ROOT / 'install_dependencies.sh'
script_content = """#!/bin/bash
# Prole Dependencies Installer Script
# Generated by Prole Installer
set -e
echo "Installing Prole dependencies..."
# Check and install Homebrew
if ! command -v brew &> /dev/null; then
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
else
echo "Homebrew already installed. Updating..."
brew update
brew upgrade
fi
# Install k3d
if ! command -v k3d &> /dev/null; then
echo "Installing k3d..."
brew install k3d
fi
# Install kubectl
if ! command -v kubectl &> /dev/null; then
echo "Installing kubectl..."
brew install kubectl
fi
# Install Helm
if ! command -v helm &> /dev/null; then
echo "Installing Helm..."
brew install helm
fi
# Install krew
if ! command -v kubectl krew &> /dev/null; then
echo "Installing krew..."
brew install krew
kubectl krew update
fi
# Install cmctl
if ! command -v cmctl &> /dev/null; then
echo "Installing cmctl..."
brew install cmctl
fi
# Install kubectl plugins
echo "Installing kubectl plugins..."
kubectl krew install view-secret
echo "All dependencies installed successfully!"
"""
try:
with open(script_path, 'w') as f:
f.write(script_content)
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_env_var = tk.StringVar(value='Dev')
env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_env_var, 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':
canvas.create_oval(5, 5, 25, 25, outline='#666', width=2)
label.configure(text="Pending", fg='#aaaaaa')
elif status == 'running':
canvas.create_oval(5, 5, 25, 25, outline='#ffaa00', width=2, fill='#ffaa00')
label.configure(text="Running...", fg='#ffaa00')
elif status == 'completed':
canvas.create_oval(5, 5, 25, 25, outline='#28a745', width=2, fill='#28a745')
canvas.create_text(15, 15, text='', fill='white', font=('Helvetica', 16, 'bold'))
label.configure(text="Completed", fg='#28a745')
elif status == 'error':
canvas.create_oval(5, 5, 25, 25, outline='#dc3545', width=2, fill='#dc3545')
canvas.create_text(15, 15, text='', fill='white', font=('Helvetica', 16, 'bold'))
label.configure(text="Error", fg='#dc3545')
elif status == 'skipped':
canvas.create_oval(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 run_deployment(self):
"""Run the deployment steps"""
try:
# Capture environment selection and prepare dynamic labels
env = self.deploy_env_var.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})"
self.deploy_widgets['Ensure target cluster']['label'].master.master.children['!label'].configure(text=f"Ensure target cluster ({env})")
# Step 0: Build ProleStatus macOS app
self.update_deploy_step_status('Build ProleStatus macOS app', 'running')
self.build_prole_status_app()
self.update_deploy_step_status('Build ProleStatus 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')
messagebox.showinfo("Success", "Deployment completed successfully!")
except Exception as e:
messagebox.showerror("Error", f"Deployment failed: {str(e)}")
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_status_app(self):
"""Delegate building the native ProleStatus app to deploy helper."""
return inst_deploy.build_prole_status_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 build_docker_image(self):
"""Build prole-db Docker image"""
# Base local image tag (before pushing to registry)
image_tag = 'prole-db:17.5-027'
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
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"""
registry = getattr(self, 'registry_url', 'localhost:5000')
image = getattr(self, 'local_image_tag', 'prole-db:17.5-027')
self.remote_image_tag = f"{registry}/prole-db:17.5-027"
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)."""
subprocess.run(['k3d', 'image', 'import', 'prole-db:17.5-027', '-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 = tk.Tk()
app = ProleInstaller(root)
root.mainloop()
if __name__ == '__main__':
main()