From 906392d4629527fa9f658edb7e4b6d382bd38bdc Mon Sep 17 00:00:00 2001 From: chrisfu Date: Thu, 8 Jan 2026 21:51:30 -0800 Subject: [PATCH] feat(installer): improve UI and add test coverage for core features - Refactored installer UI with updated canvas rendering, sidebar navigation, and footer buttons. - Enhanced styling for macOS compatibility and consistent design across controls. - Added Pytest-based unit tests for `screen.py` and `config.py`. - Expanded dependency catalog with new tools like `tshark` and `pyshark`. - Improved error tolerance for background rendering and added placeholders for Kerberos configuration. --- .coveragerc | 11 + .gitignore | 46 + bin/prole-env.sh | 11 + bin/prole-kpf.sh | 76 ++ etc/init_cloudnative_pg.sh | 4 +- etc/init_openbao.sh | 41 +- etc/init_port_forwards.sh | 2 +- install.py | 1382 +++++++++++++++++++++------ installer/config.py | 23 + installer/screen.py | 99 +- k8s/openbao/kerberos-configmap.yaml | 2 +- k8s/prole/prole-db.yaml | 3 +- prole-db/Dockerfile | 4 +- run_tests.sh | 15 + run_with_coverage.sh | 8 + tests/installer/test_build.py | 15 + tests/installer/test_config.py | 33 + tests/installer/test_screen.py | 48 + tests/test_install_logic.py | 62 ++ 19 files changed, 1506 insertions(+), 379 deletions(-) create mode 100644 .coveragerc create mode 100644 .gitignore create mode 100644 bin/prole-env.sh create mode 100644 bin/prole-kpf.sh create mode 100755 run_tests.sh create mode 100755 run_with_coverage.sh create mode 100644 tests/installer/test_build.py create mode 100644 tests/installer/test_config.py create mode 100644 tests/installer/test_screen.py create mode 100644 tests/test_install_logic.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..b37f74c --- /dev/null +++ b/.coveragerc @@ -0,0 +1,11 @@ +[run] +source = + install.py + installer/ + +omit = + tests/* + */__init__.py + +[report] +show_missing = True diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..925ac58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +/.DS_Store +/.gitignore +/__pycache__/ +/bin/* +!/bin/prole-env.sh +!/bin/prole-kpf.sh +/lib/ +/include/ +/.idea/ +/.vagrant/ +/.vscode/ +/.venv/ +/prole-tools-app/dist/ +/prole-tools-app/.build/ +/prole-tools-app/.build-cli/ + +# Secrets and local config +*-password.txt +*secret.yaml +/secrets/ +/conf/ +/data/ +/logs/ +/storage/ +/target/ +/pyvenv.cfg +.output.txt +deploy/gcp/terraform-setup.txt +.terraform/ +.terraform.lock.hcl + +# Large binaries +*.zip +*.tar.gz + +# Coverage and testing +.coverage +htmlcov/ +.pytest_cache/ + +# Unreal Engine artifacts +/workstation/Prole/Binaries/ +/workstation/Prole/Intermediate/ +/workstation/Prole/Saved/ +/workstation/Prole/DerivedDataCache/ +/workstation/Prole/Build/ diff --git a/bin/prole-env.sh b/bin/prole-env.sh new file mode 100644 index 0000000..662f46e --- /dev/null +++ b/bin/prole-env.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Prole environment file. Sourced by other scripts in ~/.prole/bin. +# Customize PATH, KUBECONFIG, contexts, etc. + +# Ensure common kubectl locations are available +export PATH="/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:$PATH" + +# Example: point to a specific kubeconfig if needed +# export KUBECONFIG="$HOME/.kube/config" + +# You can add any environment variables that your port-forward commands require here. diff --git a/bin/prole-kpf.sh b/bin/prole-kpf.sh new file mode 100644 index 0000000..b329e65 --- /dev/null +++ b/bin/prole-kpf.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +LABEL="org.prole.prole-db.kpf-dev" +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +PROLE_HOME="$HOME/.prole" +BIN_DIR="$PROLE_HOME/bin" +RUN_DIR="$PROLE_HOME/run" +PIDFILE="$RUN_DIR/kpf.pids" + +# Source environment if present +if [ -f "$BIN_DIR/prole-env.sh" ]; then + # shellcheck source=/dev/null + . "$BIN_DIR/prole-env.sh" +fi + +ensure_dirs() { + mkdir -p "$BIN_DIR" "$RUN_DIR" +} + +list_cmds() { + if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then + local 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_dirs + : > "$PIDFILE" + # Iterate over commands and start each in background shell + while IFS= read -r cmd; do + [ -z "$cmd" ] && continue + (sh -lc "$cmd") & + echo $! >> "$PIDFILE" + done < <(list_cmds) + wait || true +} + +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 + local 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 diff --git a/etc/init_cloudnative_pg.sh b/etc/init_cloudnative_pg.sh index 8357f6b..6708415 100755 --- a/etc/init_cloudnative_pg.sh +++ b/etc/init_cloudnative_pg.sh @@ -280,8 +280,8 @@ case "$ACTION" in echo "ERROR: CNPG manifest not found at $CNPG_MANIFEST" >&2 exit 1 fi - echo "Starting CloudNative-PG cluster from $CNPG_MANIFEST ..." - kubectl apply -f "$CNPG_MANIFEST" + echo "Starting CloudNative-PG cluster from $CNPG_MANIFEST in namespace $NAMESPACE..." + kubectl apply -n "$NAMESPACE" -f "$CNPG_MANIFEST" ;; stop) ensure_tools diff --git a/etc/init_openbao.sh b/etc/init_openbao.sh index a2502a0..4cf8e73 100755 --- a/etc/init_openbao.sh +++ b/etc/init_openbao.sh @@ -33,9 +33,8 @@ fi ACTION=${1:-} # Defaults -NAMESPACE=${NAMESPACE:-default} +NAMESPACE=${NAMESPACE:-prole} OPENBAO_NAME=${OPENBAO_NAME:-openbao} -OPENBAO_IMAGE=${OPENBAO_IMAGE:-ghcr.io/openbao/openbao:latest} OPENBAO_MANIFEST_DIR="$SCRIPT_DIR/../k8s/openbao" # Kerberos realm defaults @@ -167,14 +166,23 @@ EOF apply_k8s() { echo "Applying OpenBao manifest to namespace '$NAMESPACE' ..." - kubectl apply -f "$OPENBAO_MANIFEST_DIR/deployment.yaml" + if [[ -f "$SCRIPT_DIR/../k8s/prole/openbao-statefulset.yaml" ]]; then + kubectl apply -n "$NAMESPACE" -f "$SCRIPT_DIR/../k8s/prole/openbao-statefulset.yaml" + kubectl apply -n "$NAMESPACE" -f "$SCRIPT_DIR/../k8s/prole/openbao-service.yaml" + else + kubectl apply -n "$NAMESPACE" -f "$OPENBAO_MANIFEST_DIR/deployment.yaml" + fi echo "Applying Kerberos ConfigMap (external realm) to namespace '$NAMESPACE' ..." - kubectl apply -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" + kubectl apply -n "$NAMESPACE" -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" } wait_for_openbao() { echo "Waiting for OpenBao to become ready ..." - kubectl rollout status deploy/$OPENBAO_NAME -n "$NAMESPACE" --timeout=120s + if kubectl get statefulset/$OPENBAO_NAME -n "$NAMESPACE" >/dev/null 2>&1; then + kubectl rollout status statefulset/$OPENBAO_NAME -n "$NAMESPACE" --timeout=120s + else + kubectl rollout status deploy/$OPENBAO_NAME -n "$NAMESPACE" --timeout=120s + fi } init_openbao_kv_and_store_admin_key() { @@ -282,7 +290,19 @@ cmd_status() { fi # K8s resources - if kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" >/dev/null 2>&1; then + if kubectl -n "$NAMESPACE" get statefulset "$OPENBAO_NAME" >/dev/null 2>&1; then + local ready desired + ready=$(kubectl -n "$NAMESPACE" get statefulset "$OPENBAO_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0") + desired=$(kubectl -n "$NAMESPACE" get statefulset "$OPENBAO_NAME" -o jsonpath='{.status.replicas}' 2>/dev/null || echo "0") + ready=${ready:-0} + desired=${desired:-0} + if [[ "$ready" == "$desired" && "$ready" != "0" ]]; then + echo "[OK] OpenBao StatefulSet running ($ready/$desired ready)" + else + echo "[WARN] OpenBao StatefulSet not fully ready ($ready/$desired)" + ok=1 + fi + elif kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" >/dev/null 2>&1; then # Get readiness local ready desired ready=$(kubectl -n "$NAMESPACE" get deploy "$OPENBAO_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo "0") @@ -296,7 +316,7 @@ cmd_status() { ok=1 fi else - echo "[MISSING] OpenBao Deployment '$OPENBAO_NAME' in namespace '$NAMESPACE'" + echo "[MISSING] OpenBao Deployment or StatefulSet '$OPENBAO_NAME' in namespace '$NAMESPACE'" ok=1 fi @@ -416,13 +436,13 @@ case "$ACTION" in kubectl create secret generic prole-db-user -n "$NAMESPACE" \ --from-literal=username=prole \ --from-literal=password="$db_pass" \ - --dry-run=client -o yaml | kubectl apply -f - + --dry-run=client -o yaml | kubectl apply -n "$NAMESPACE" -f - echo "Creating database superuser secret 'prole-db-superuser' ..." kubectl create secret generic prole-db-superuser -n "$NAMESPACE" \ --from-literal=username=postgres \ --from-literal=password="$db_pass" \ - --dry-run=client -o yaml | kubectl apply -f - + --dry-run=client -o yaml | kubectl apply -n "$NAMESPACE" -f - fi wait_for_openbao @@ -442,8 +462,7 @@ case "$ACTION" in update|reload) generate_openbao_manifests generate_kerberos_configmap - kubectl apply -f "$OPENBAO_MANIFEST_DIR/deployment.yaml" - kubectl apply -f "$OPENBAO_MANIFEST_DIR/kerberos-configmap.yaml" + apply_k8s echo "Re-applied manifests." ;; *) diff --git a/etc/init_port_forwards.sh b/etc/init_port_forwards.sh index 083de97..9ebea5c 100755 --- a/etc/init_port_forwards.sh +++ b/etc/init_port_forwards.sh @@ -6,7 +6,7 @@ set -u # Manages kubectl port-forward daemons defined in an XML file. PROG="init_port_forwards" -PROLE_HOME="/Users/chrisfu/dev/prole" +PROLE_HOME="${PROLE_HOME:-/Users/chrisfu/dev/prole}" VERBOSE=0 CONFIG_FILE="$PROLE_HOME/conf/port-mappings.properties" diff --git a/install.py b/install.py index 83c0b75..e5978cd 100755 --- a/install.py +++ b/install.py @@ -52,6 +52,7 @@ class ProleInstaller: self.screens = None self.deploy_environment = None self.root = root + self._installing_dep_id = None self.root.title("Prole Database Installer") # Best-effort: set app identity (menu title / dock icon) early try: @@ -60,90 +61,109 @@ class ProleInstaller: pass # Center window on screen - window_width = 1000 - window_height = 700 + window_width = 1300 + window_height = 910 screen_width = root.winfo_screenwidth() screen_height = root.winfo_screenheight() center_x = int(screen_width / 2 - window_width / 2) center_y = int(screen_height / 2 - window_height / 2) self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}") - - # Light background overall - self.root.configure(bg='#f5f5f7') + self.root.resizable(False, False) + + # Force light mode colors and prevent dark mode shifts + self.root.configure(bg='white') # Style configuration self.style = ttk.Style() - # 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) + # Main container with two columns + self.main_container = tk.Frame(root, bg='white') + self.main_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) + # Left Sidebar (approx 25%) + self.sidebar = tk.Frame(self.main_container, bg='#F5F5DC', width=325) # Sepia background + self.sidebar.pack(side='left', fill='y') + self.sidebar.pack_propagate(False) - # Background canvas paints the image as a full-cover background + # Vertical Divider Line + self.divider = tk.Frame(self.main_container, bg='#CCCCCC', width=1) + self.divider.pack(side='left', fill='y') + + # Right Content Area (approx 66%) + self.content_area = tk.Frame(self.main_container, bg='white') + self.content_area.pack(side='left', fill='both', expand=True) + + # Footer for Next/Prev buttons in the content area + btns = ui.create_nav_footer( + self.content_area, + buttons=[(1, 'Previous'), (2, 'Next'), (3, 'Finish')], + commands={1: self.on_prev, 2: self.on_next, 3: self.on_finish}, + style_name='Nav.TButton', + ) + self.footer = btns.get('_footer') # type: ignore[assignment] + self.prev_button = btns.get(1) + self.next_button = btns.get(2) + self.finish_button = btns.get(3) + + # Background canvas for the content area self._bg_pil = None self._bg_tk = None - self.bg_canvas = tk.Canvas(self.slide_area, highlightthickness=0, bd=0) + self.bg_canvas = tk.Canvas(self.content_area, highlightthickness=0, bd=0, bg='white') self.bg_canvas.pack(fill='both', expand=True) self._bg_item = None + + # Slide area: a container for overlaying widgets on the canvas. + # We use a frame. On macOS, we can't make it truly transparent without lifting/lowering. + self.slide_area = tk.Frame(self.bg_canvas, bg='white') + # We will use place but manage visibility in show_page + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lower() # Start below canvas items + try: - from PIL import Image, ImageTk # 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' + from PIL import Image, ImageTk + bg_path = Path('img/proleLogoSepia.png') if bg_path.exists(): - self._bg_pil = Image.open(str(bg_path)).convert('RGBA') + # Load and prepare image with 50% opacity + original = Image.open(str(bg_path)).convert('RGBA') + # Create a white background of the same size + white_bg = Image.new('RGBA', original.size, (255, 255, 255, 255)) + # Blend with 15% opacity of original (85% white) + self._bg_pil = Image.blend(white_bg, original, 0.15) def _render_bg(event=None): - if not self._bg_pil: - return + 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.delete(self._bg_item) + self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, image=self._bg_tk, anchor='center') + self.bg_canvas.tag_lower(self._bg_item) self.bg_canvas.bind('', _render_bg) - # Initial render after window shows - self.root.after(100, _render_bg) - except Exception: - # If PIL not available or image missing, leave plain background - pass + except Exception as e: + print(f"Error loading background: {e}") + + # Navigation menu items + self.nav_items = [ + ("Welcome", "welcome"), + ("Dependencies", "deps_summary"), + ("Network", "network_scan"), + ("System Environment", "env_setup"), + ("Kerberos Authentication", "kerberos_config"), + ("Initialization", "init_cluster"), + ("Docker Build", "init_db_build"), + ("Prole DB Deploy", "init_cnpg_deploy"), + ("Database Password", "init_password"), + ("Prole.app", "build") + ] + self.nav_widgets = {} + self._create_sidebar_nav() # Initialize validation attributes before creating screens self.validation_running = False @@ -160,27 +180,8 @@ class ProleInstaller: 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 = {} @@ -194,50 +195,65 @@ class ProleInstaller: # Shared dependency catalog from installer.config self.dependencies = list(inst_config.DEPENDENCIES) + # Wizard pages setup + self.pages = [] # list of (page_id, frame) + self.page_index = 0 + # Initialize variables for new Initialize screens self.cluster_env = tk.StringVar(value='dev') self.db_password = tk.StringVar() self.db_password_confirm = tk.StringVar() + # Kerberos variables + self.kerberos_enabled = tk.BooleanVar(value=False) + self.kerberos_realm = tk.StringVar() + self.kerberos_user = tk.StringVar() + self.kerberos_password = tk.StringVar() + self.kerberos_kdc = tk.StringVar() + # Create pages self.page_frames = {} self.verify_mode = tk.BooleanVar(value=False) # Register page ids (renderers will draw directly on canvas) - # 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('network_scan', self._render_network_scan_page) + self._register_canvas_renderer('env_setup', self._render_env_setup_page) self._register_canvas_renderer('deps_summary', self._render_deps_summary_page) for dep in self.dependencies: self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d)) + self._register_canvas_renderer('kerberos_config', self._render_kerberos_config_page) + # New Initialize screens self._register_canvas_renderer('init_cluster', self._render_init_cluster_page) self._register_canvas_renderer('init_db_build', self._render_init_db_build_page) self._register_canvas_renderer('init_password', self._render_init_password_page) + self._register_canvas_renderer('init_cnpg_deploy', self._render_init_cnpg_deploy_page) self._register_canvas_renderer('init_scripts', self._render_init_scripts_page) self._register_canvas_renderer('build', self._render_build_page) self._register_canvas_renderer('build_summary', self._render_build_summary_page) # Mirror old pages list ordering for navigation - # 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) + self._register_page('network_scan', None) + self._register_page('env_setup', None) + self._register_page('kerberos_config', None) # New Initialize screens in sequence self._register_page('init_cluster', None) self._register_page('init_db_build', None) + self._register_page('init_cnpg_deploy', None) self._register_page('init_password', None) - self._register_page('init_scripts', None) self._register_page('build', None) self._register_page('build_summary', None) - # Initialize page and footer - self.show_page(0) + # Initial render after window shows + self.root.after(100, lambda: self.show_page(0)) self.update_footer() # ---------------- App identity (title, Dock icon) ---------------- @@ -332,66 +348,100 @@ class ProleInstaller: 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 + base_bg = 'white' + self.style.configure('TFrame', background='white') + self.style.configure('TLabel', background='white', foreground='black', font=('SF Pro Text', 11)) + self.style.configure('Header.TLabel', font=('SF Pro Text', 16, 'bold')) + self.style.configure('Small.TLabel', font=('SF Pro Text', 10)) + + # Consistent button styling + self.style.configure('Nav.TButton', + font=('SF Pro Text', 11), + padding=(12, 8), + background='#F5F5DC', + bordercolor='#F5F5DC', + lightcolor='#F5F5DC', + darkcolor='#F5F5DC', + relief='flat') + + # Map state colors to avoid black boxes on hover/active + self.style.map('Nav.TButton', + background=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], + bordercolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], + lightcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')], + darkcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')]) + + # Ensure common controls inherit white background try: - self.style.configure('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('TCheckbutton', background=base_bg, foreground='black') self.style.configure('TCombobox', fieldbackground='white', background=base_bg) except Exception: pass - + + def _create_sidebar_nav(self): + """Create the left-hand navigation menu.""" + tk.Label(self.sidebar, text="INSTALLER", bg='#F5F5DC', fg='#8B8B7A', + font=('SF Pro Text', 10, 'bold'), anchor='w').pack(fill='x', padx=20, pady=(20, 10)) + + for text, page_id in self.nav_items: + lbl = tk.Label(self.sidebar, text=text, bg='#F5F5DC', fg='black', + font=('SF Pro Text', 11), anchor='w', padx=20, pady=5, cursor='hand2') + lbl.pack(fill='x') + lbl.bind('', lambda e, p=page_id: self.show_page(p)) + self.nav_widgets[page_id] = lbl + + def _update_nav_highlight(self, active_id): + """Highlight the current active page in the sidebar.""" + # Handle dep_id mapping to 'Dependencies' + if active_id.startswith('dep_'): + active_id = 'deps_summary' + + for page_id, widget in self.nav_widgets.items(): + if page_id == active_id: + widget.configure(bg='#E5E5D5', font=('SF Pro Text', 11, 'bold')) + else: + widget.configure(bg='#F5F5DC', font=('SF Pro Text', 11)) + def create_navigation(self): - # 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 + self.show_page(screen_id) 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 + # Resolve index and page_id if isinstance(index_or_id, int): idx = max(0, min(index_or_id, len(self.pages) - 1)) else: idx = next((i for i, (pid, _) in enumerate(self.pages) if pid == index_or_id), 0) self.page_index = idx pid, frame = self.pages[self.page_index] + + # Clear any previously drawn canvas content + self._clear_canvas_page() + self._update_nav_highlight(pid) + + # If the page has overlay widgets, we need to show the slide_area. + # Otherwise, we hide it so the canvas content is visible. + # Pages that use canvas drawing and need to show the background should have slide_area hidden. + canvas_only_pages = ( + 'welcome', 'network_scan', 'env_setup', 'kerberos_config', + 'deps_summary', 'init_cluster', 'init_password', 'init_db_build', + 'init_cnpg_deploy', 'init_scripts', 'build', 'build_summary' + ) + if pid in canvas_only_pages or pid.startswith('dep_'): + self.slide_area.place_forget() + else: + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lift() + # Render the page directly on the canvas if we have a renderer if pid in self.canvas_renderers: try: self.canvas_renderers[pid]() except Exception as e: # Fallback: show an error message on canvas - ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='#1d1d1f', font=('Helvetica', 12)) + ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='black', font=('SF Pro Text', 11)) elif frame is not None: # Legacy fallback (should not be used) frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9) @@ -401,7 +451,25 @@ class ProleInstaller: def _register_canvas_renderer(self, page_id, func): self.canvas_renderers[page_id] = func + def _register_page(self, page_id, frame): + self.pages.append((page_id, frame)) + self.page_frames[page_id] = frame + def _clear_canvas_page(self): + # Unbind common events to prevent "echo" or persistent behavior from previous pages + try: + self.bg_canvas.unbind('') + self.root.unbind('') + self.root.unbind('') + except Exception: + pass + + # Reset slide_area background to white for safety + try: + self.slide_area.configure(bg='white') + except Exception: + pass + # Remove any small overlay widgets from the previous page try: if getattr(self, '_overlay_widgets', None): @@ -653,15 +721,17 @@ class ProleInstaller: 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) + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + # Task heading for clarity - self._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) + self._render_title('Environment', y=150) + self._render_paragraph('Configure your Prole environment. You can accept defaults or choose custom locations. Changing PROLE_HOME will update the defaults for other paths.', y=200) defaults = self._env_defaults() existing = self._read_existing_env() @@ -679,10 +749,10 @@ class ProleInstaller: ] x_label = 48 - x_field = 260 - x_btn = 820 - y = 160 - row_h = 56 + x_field = 300 + x_btn = 970 # Adjusted for new content width + y = 280 + row_h = 75 def browse(var_name: str): from tkinter import filedialog @@ -697,19 +767,27 @@ class ProleInstaller: # 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) + self._canvas_items.append(ui.canvas_text(self, x_label, y, key, fill='black', font=('SF Pro Text', 12, 'bold'))) + # Use tk.Entry and place on canvas via create_window + entry = tk.Entry(self.bg_canvas, bg='white', fg='black', insertbackground='black', + highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11)) entry.insert(0, values[key]) - entry.place(x=x_field, y=y-12, width=530) + entry_window = self.bg_canvas.create_window(x_field, y-12, window=entry, anchor='nw', width=650, height=32) + self._canvas_items.append(entry_window) self._overlay_widgets.append(entry) self._env_entries[key] = entry - btn = ttk.Button(self.slide_area, text='Browse…', command=lambda k=key: browse(k)) - btn.place(x=x_btn, y=y-16, width=100) + # Use tk.Button and place on canvas via create_window + btn = tk.Button(self.bg_canvas, text='Browse…', command=lambda k=key: browse(k), + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=10, pady=4) + btn_window = self.bg_canvas.create_window(x_btn, y-16, window=btn, anchor='ne', width=120) + self._canvas_items.append(btn_window) self._overlay_widgets.append(btn) # hint text - self._canvas_items.append(ui.canvas_text(self, x_field, y+16, help_text, fill='#6e6e73', font=('Helvetica', 10))) + self._canvas_items.append(ui.canvas_text(self, x_field, y+24, help_text, fill='#6e6e73', font=('SF Pro Text', 10))) y += row_h # When PROLE_HOME changes, update dependent paths that still follow default pattern @@ -766,17 +844,359 @@ class ProleInstaller: def _render_title(self, text, y=40): ui.render_title(self, text, y) - def _render_paragraph(self, text, y, wrap=860): + def _render_paragraph(self, text, y, wrap=600): ui.render_paragraph(self, text, y, wrap) def _render_welcome_page(self): # Opening page should NOT perform any dependency checks. - 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) + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + # Welcome title + self._render_title('Welcome', y=150) + + welcome_text = ( + "This installer will guide you through the process of setting up the Prole Database and its supporting " + "infrastructure. We have designed this process to be as automated as possible, ensuring that your " + "deployment is secure, efficient, and tailored to your specific network environment.\n\n" + "What to expect:\n" + "• Network Environment Discovery: We'll scan for existing services like Active Directory and DNS.\n" + "• System Configuration: Setting up local paths and environment variables.\n" + "• Dependency Management: Ensuring all required tools (Docker, k3d, etc.) are ready.\n" + "• Database Initialization: Configuring passwords, Kerberos authentication, and deploying the database cluster.\n\n" + "We are excited to have you join our community and start building with us. " + "Welcome to the neighborhood! Let's get started by preparing your system for the Prole experience." + ) + + ui.canvas_text(self, 48, 210, welcome_text, fill='black', font=('SF Pro Text', 13), width=750) + # Ensure footer is updated (Next button visible) self.update_footer() + def _render_network_scan_page(self): + # Letterhead at top right (matching welcome screen theme) + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Network Configuration Scan', y=150) + self._render_paragraph("The network scan will detect Kerberos services, Active Directory controllers, and other configuration details required for deployment.", y=210) + + y = 280 + self.scan_status_var = tk.StringVar(value="Ready to scan") + # Ready to scan text (drawn on canvas) + status_item = ui.canvas_text(self, 48, y, "Ready to scan", fill='black', font=('SF Pro Text', 12)) + + # Link variable to canvas text + def update_status_text(*args): + try: + self.bg_canvas.itemconfig(status_item, text=self.scan_status_var.get()) + except Exception: + pass + self.scan_status_var.trace_add('write', update_status_text) + + y += 40 + # Output Box (Larger, placed on canvas) + self.scan_results_text = scrolledtext.ScrolledText(self.bg_canvas, width=115, height=25, + font=('Menlo', 10), bg='white', fg='black', + insertbackground='black', highlightthickness=1, + highlightbackground='#CCCCCC') + results_window = self.bg_canvas.create_window(48, y, window=self.scan_results_text, anchor='nw') + self._overlay_widgets.append(self.scan_results_text) + self._canvas_items.append(results_window) + + # Determine actual height of ScrolledText to place button below it + self.root.update_idletasks() + box_height = self.scan_results_text.winfo_reqheight() + y += box_height + 30 + + # Start Network Scan Button (placed on canvas) + scan_btn = tk.Button(self.bg_canvas, text="Start Network Scan", command=self._run_network_scan, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=20, pady=10) + btn_window = self.bg_canvas.create_window(48, y, window=scan_btn, anchor='nw') + self._overlay_widgets.append(scan_btn) + self._canvas_items.append(btn_window) + + y += 60 + # Hint about auto-fill + autofill_msg = "Scan results will auto-fill Kerberos and Environment settings." + self._canvas_items.append(ui.canvas_text(self, 48, y, autofill_msg, fill='#6e6e73', font=('SF Pro Text', 10, 'italic'))) + + def _run_network_scan(self): + if getattr(self, '_scan_running', False): + return + self._scan_running = True + + self.scan_results_text.delete('1.0', tk.END) + self.scan_results_text.insert(tk.END, "Initializing network scan...\n") + self.scan_status_var.set("Scanning...") + + def worker(): + try: + import pyshark + tshark_path = shutil.which("tshark") + if not tshark_path: + self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "tshark not found. Please install it from the Dependencies screen.\n")) + self.root.after(0, lambda: self.scan_status_var.set("Scan failed")) + self._scan_running = False + return + + # Get local IP and determine network + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + # doesn't even have to be reachable + s.connect(('10.255.255.255', 1)) + local_ip = s.getsockname()[0] + except Exception: + local_ip = '127.0.0.1' + finally: + s.close() + + self.root.after(0, lambda: self.scan_results_text.insert(tk.END, f"Local IP detected: {local_ip}\n")) + + # We will perform a scan using ARP and common ports + # Pyshark is better for passive discovery, but we can try to find devices by sniffing for a bit + # or by looking at common infrastructure ports. + + hosts = {} # ip -> {hostname, ports, type} + + self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "Sniffing for network traffic (15 seconds)...\n")) + + # Infrastructure detection + # port 88: Kerberos (AD) + # port 53: DNS + # port 445: SMB (Windows) + # port 631: CUPS (Apple/Linux) + + capture = pyshark.LiveCapture(interface=None) + capture.sniff(timeout=15) + + for packet in capture: + try: + if 'ip' in packet: + src_ip = packet.ip.src + dst_ip = packet.ip.dst + + for ip in [src_ip, dst_ip]: + if ip not in hosts: + hosts[ip] = {"hostname": "Unknown", "ports": set(), "type": "Unknown Device"} + try: + hostname = socket.gethostbyaddr(ip)[0] + hosts[ip]["hostname"] = hostname + except Exception: + pass + + # Try to identify by packet content + if 'kerberos' in packet: + hosts[src_ip]["type"] = "Kerberos Server / AD" + try: + realm = packet.kerberos.realm + self.kerberos_realm.set(realm) + self.kerberos_kdc.set(src_ip) + self.kerberos_enabled.set(True) + except AttributeError: + pass + + if 'dns' in packet: + hosts[src_ip]["type"] = "DNS Server" + + if 'tcp' in packet: + hosts[src_ip]["ports"].add(packet.tcp.srcport) + hosts[dst_ip]["ports"].add(packet.tcp.dstport) + + # Heuristics for device types + if 'mdns' in packet or 'dns' in packet: + if '.local' in hosts[src_ip]["hostname"] or 'apple' in hosts[src_ip]["hostname"].lower(): + hosts[src_ip]["type"] = "Apple Device" + if 'iphone' in hosts[src_ip]["hostname"].lower(): + hosts[src_ip]["type"] = "Apple iPhone" + + except (AttributeError, KeyError): + continue + + # Refine device types based on ports + for ip, info in hosts.items(): + if 88 in info["ports"] or "88" in info["ports"]: + info["type"] = "Active Directory / Kerberos" + elif 53 in info["ports"] or "53" in info["ports"]: + info["type"] = "DNS Server" + elif 445 in info["ports"] or "445" in info["ports"]: + if info["type"] == "Unknown Device": + info["type"] = "Windows Server" + + # Identify default router (usually .1 or .254) + if ip.endswith(".1") or ip.endswith(".254"): + if info["type"] == "Unknown Device": + info["type"] = "Default Router" + + # Output summary + self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "\n--- Network Summary ---\n")) + infra_found = False + for ip, info in hosts.items(): + # Filter out multicast/broadcast + if ip.startswith("224.") or ip.startswith("239.") or ip.endswith(".255"): + continue + + is_infra = info["type"] in ["Active Directory / Kerberos", "DNS Server", "Default Router", "Kerberos Server / AD"] + prefix = ">>> [INFRA] " if is_infra else " " + if is_infra: infra_found = True + + line = f"{prefix}{info['type']}: {info['hostname']} ({ip})\n" + self.root.after(0, lambda l=line: self.scan_results_text.insert(tk.END, l)) + if is_infra: + self.root.after(0, lambda: self.scan_results_text.tag_add("infra", "end-2c linestart", "end-1c")) + self.root.after(0, lambda: self.scan_results_text.tag_config("infra", foreground="blue", font=('Helvetica', 10, 'bold'))) + + if not infra_found: + self.root.after(0, lambda: self.scan_results_text.insert(tk.END, "No specific infrastructure servers highlighted.\n")) + + self.root.after(0, lambda: self.scan_status_var.set("Scan complete")) + self._scan_running = False + + except Exception as e: + self.root.after(0, lambda: self.scan_results_text.insert(tk.END, f"Scan error: {str(e)}\n")) + self.root.after(0, lambda: self.scan_status_var.set("Scan failed")) + self._scan_running = False + + threading.Thread(target=worker, daemon=True).start() + + def _render_kerberos_config_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Kerberos Authentication', y=150) + self._render_paragraph("Configure Kerberos authentication for Prole and the PostgreSQL database.", y=200) + + y = 280 + # Use tk.Checkbutton on canvas + enable_cb = tk.Checkbutton(self.bg_canvas, text="Enable Kerberos Authentication", + variable=self.kerberos_enabled, bg='white', fg='black', + activebackground='white', selectcolor='white', font=('SF Pro Text', 11)) + cb_window = self.bg_canvas.create_window(48, y, window=enable_cb, anchor='nw') + self._canvas_items.append(cb_window) + self._overlay_widgets.append(enable_cb) + + x_field = 200 + field_w = 400 + + y += 60 + ui.canvas_text(self, 48, y, "Kerberos Realm:", fill='black', font=('SF Pro Text', 12)) + realm_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_realm, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + realm_window = self.bg_canvas.create_window(x_field, y-4, window=realm_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(realm_window) + self._overlay_widgets.append(realm_entry) + + y += 48 + ui.canvas_text(self, 48, y, "KDC Host/IP:", fill='black', font=('SF Pro Text', 12)) + kdc_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_kdc, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + kdc_window = self.bg_canvas.create_window(x_field, y-4, window=kdc_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(kdc_window) + self._overlay_widgets.append(kdc_entry) + + y += 48 + ui.canvas_text(self, 48, y, "Username:", fill='black', font=('SF Pro Text', 12)) + user_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_user, bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + user_window = self.bg_canvas.create_window(x_field, y-4, window=user_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(user_window) + self._overlay_widgets.append(user_entry) + + y += 48 + ui.canvas_text(self, 48, y, "Password:", fill='black', font=('SF Pro Text', 12)) + pass_entry = tk.Entry(self.bg_canvas, textvariable=self.kerberos_password, show="*", bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + pass_window = self.bg_canvas.create_window(x_field, y-4, window=pass_entry, anchor='nw', width=field_w, height=32) + self._canvas_items.append(pass_window) + self._overlay_widgets.append(pass_entry) + + y += 60 + # Use tk.Button on canvas + test_btn = tk.Button(self.bg_canvas, text="Test Connection", command=self.test_kerberos_connection, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, y, window=test_btn, anchor='nw') + self._canvas_items.append(btn_window) + self._overlay_widgets.append(test_btn) + + y += 60 + # Use tk.Text + self.kerberos_status_text = tk.Text(self.bg_canvas, width=100, height=12, font=('Menlo', 10), + bg='white', fg='black', insertbackground='black', + highlightbackground='#CCCCCC', highlightthickness=1, relief='flat') + status_window = self.bg_canvas.create_window(48, y, window=self.kerberos_status_text, anchor='nw', width=800, height=200) + self._canvas_items.append(status_window) + self._overlay_widgets.append(self.kerberos_status_text) + + def test_kerberos_connection(self): + realm = self.kerberos_realm.get().strip() + user = self.kerberos_user.get().strip() + password = self.kerberos_password.get().strip() + + if not realm or not user or not password: + messagebox.showerror("Error", "Please fill in all fields.") + return + + self.kerberos_status_text.delete('1.0', tk.END) + self.kerberos_status_text.insert(tk.END, f"Testing connection for {user}@{realm}...\n") + + def worker(): + try: + # Use kinit to test credentials + # We'll use a pipe to send the password to kinit if possible, + # but kinit usually expects it from terminal. + # A better way is using a keytab or expect, but for testing we can use kinit with stdin + principal = f"{user}@{realm}" + + # Check if kinit is available + if not shutil.which("kinit"): + self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, "Error: kinit not found. Please install kerberos client tools.\n")) + return + + # Attempt kinit + process = subprocess.Popen( + ["kinit", principal], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + stdout, stderr = process.communicate(input=password + "\n") + + if process.returncode == 0: + self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, "Success! Authenticated successfully.\n")) + # Get ticket details + klist_res = subprocess.run(["klist"], capture_output=True, text=True) + self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"\nTicket details:\n{klist_res.stdout}")) + else: + self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"Failed to authenticate.\nExit code: {process.returncode}\nError: {stderr}\n")) + + except Exception as e: + self.root.after(0, lambda: self.kerberos_status_text.insert(tk.END, f"Error: {str(e)}\n")) + + threading.Thread(target=worker, daemon=True).start() + def _start_welcome_dependency_scan(self): self.splash_scan_running = True self.splash_scan_done_at = None @@ -843,30 +1263,31 @@ class ProleInstaller: def _render_deps_summary_page(self): self._render_title('Dependencies', y=40) - y = 90 + y = 100 # Draw each dependency row: status dot (blank initially), name, info - row_gap = 28 + row_gap = 36 left = 56 text_x = left + 28 self.dep_status_items = {} # Store canvas IDs to update later + self.dep_install_buttons = {} # Store button window items for dep in self.dependencies: # blank box (outline only initially) dot = ui.canvas_rectangle(self, left, y, left+18, y+18, outline='#6e6e73', width=2) self._canvas_items.append(dot) # name - self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='#1d1d1f', font=('Helvetica', 12, 'bold'))) + self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='black', font=('SF Pro Text', 12, 'bold'))) # info placeholder - info = ui.canvas_text(self, text_x + 150, y, 'Checking...', fill='#6e6e73', font=('Helvetica', 11)) + info = ui.canvas_text(self, text_x + 180, y, 'Checking...', fill='#6e6e73', font=('SF Pro Text', 11)) self._canvas_items.append(info) self.dep_status_items[dep['id']] = {'dot': dot, 'info': info, 'y': y} y += row_gap # Message line - msg_y = y + 20 - self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('Helvetica', 11)) + msg_y = y + 30 + self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('SF Pro Text', 11)) self._canvas_items.append(self.deps_msg_item) # Start async check @@ -895,6 +1316,15 @@ class ProleInstaller: self.bg_canvas.create_line(left+8, y+12, left+13, y+6, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}") else: items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff3b30', outline='') + # Add "Click to install" button + btn = tk.Button(self.bg_canvas, text='Install', command=lambda d=did: self.show_page(f"dep_{d}"), + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 10), padx=8, pady=2) + btn_window = self.bg_canvas.create_window(left + 350, y, window=btn, anchor='nw') + self._overlay_widgets.append(btn) + self._canvas_items.append(btn_window) + self.dep_install_buttons[did] = btn_window info_text = self.normalize_version(version) if ok and version else ('Not installed' if not ok else '') self.bg_canvas.itemconfig(items['info'], text=info_text) @@ -915,48 +1345,114 @@ class ProleInstaller: def _render_dependency_page(self, dep): self._render_title(dep['name'], y=40) - self._render_paragraph(dep['description'], y=80) + self._render_paragraph(dep['description'], y=88) # Status ok, location, version = self.get_dep_info(dep) - y = 140 + y = 180 left = 56 if ok: self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')) - self._canvas_items.append(ui.canvas_text(self, left+26, y-2, 'Installed', fill='#1d1d1f', font=('Helvetica', 12, 'bold'))) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold'))) if location: - self._canvas_items.append(ui.canvas_text(self, left+26, y+26, f'Location: {location}', fill='#6e6e73', font=('Helvetica', 11))) + self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11))) if version: ver = self.normalize_version(version) - self._canvas_items.append(ui.canvas_text(self, left+26, y+46, f'Version: {ver}', fill='#6e6e73', font=('Helvetica', 11))) + self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 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') + # Check if we should auto-install + install_cmd = dep.get('install_cmd') + if install_cmd and self._installing_dep_id != dep['id']: + # Launch directly into installation + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Trigger console-based install for brew/pip + if 'brew install' in install_cmd or 'pip install' in install_cmd: + self.root.after(500, lambda: self._install_dep_in_console(dep)) + else: + self.root.after(500, lambda: self.open_terminal_with_command(install_cmd)) + elif self._installing_dep_id == dep['id']: + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline='')) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold'))) + else: + self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline='')) + self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Click to install link (if available) + if install_cmd: + link_y = y + 40 + link_text = ui.render_link(self, left+32, link_y, 'Click to install') + self._canvas_items.append(link_text) + self.bg_canvas.config(cursor='hand2') - def _on_click(event): - ex, ey = event.x, event.y - bbox = self.bg_canvas.bbox(link_text) - if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: - self.open_terminal_with_command(dep.get('install_cmd')) - self.bg_canvas.bind('', _on_click) + def _on_click(event): + ex, ey = event.x, event.y + bbox = self.bg_canvas.bbox(link_text) + if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]: + self.open_terminal_with_command(install_cmd) + self.bg_canvas.bind('', _on_click) + + def _install_dep_in_console(self, dep): + """Run dependency installation in the embedded console.""" + if self._installing_dep_id == dep['id']: + return + + install_cmd = dep.get('install_cmd') + if not install_cmd: + return + + self._installing_dep_id = dep['id'] + # Prepare log file + logs_dir = PROJECT_ROOT / 'logs' + try: + logs_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + ts = time.strftime('%Y%m%d-%H%M%S') + log_path = logs_dir / f"install-{dep['id']}-{ts}.log" + + # Show console + self._ensure_console_overlay(radio_bottom_y=160) + self._console_text.configure(state='normal') + self._console_text.delete('1.0', tk.END) + self._console_text.insert('end', f"Starting installation of {dep['name']}...\n") + self._console_text.insert('end', f"Command: {install_cmd}\n\n") + self._console_text.configure(state='disabled') + + def on_done(rc): + self._installing_dep_id = None + if rc == 0: + self._append_console(f"\nSuccessfully installed {dep['name']}.\n") + # Refresh status and re-render page + self.root.after(1500, lambda: self.show_page(f"dep_{dep['id']}")) + else: + self._append_console(f"\nInstallation failed with exit code {rc}.\n") + # Enable next button so user can retry or proceed if they fixed it manually + try: + self.next_button.configure(state='normal', text='Next') + except Exception: + pass + + self._run_in_console(install_cmd, str(log_path), on_complete=on_done) # ---------------- Initialize Screen Handlers ---------------- def _render_init_cluster_page(self): - self._render_title('Initialize Cluster', y=40) - self._render_paragraph('Select a cluster environment and ensure Docker and K3D are running.', y=90) + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Initialize Cluster', y=150) + self._render_paragraph('Select a cluster environment and ensure Docker and K3D are running.', y=200) # Cluster Selection (Radio Buttons) - x_label = 56 - y = 140 - self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Cluster Name:', fill='#1d1d1f', font=('Helvetica', 14, 'bold'))) + x_label = 48 + y = 280 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Select Cluster Name:', fill='black', font=('SF Pro Text', 14, 'bold'))) - y += 30 + y += 40 cluster_options = [ ('dev', 'prole-dev-cluster'), ('service', 'prole-service-cluster'), @@ -964,23 +1460,32 @@ class ProleInstaller: ] 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) + # Use tk.Radiobutton on canvas + rb = tk.Radiobutton(self.bg_canvas, text=name, variable=self.cluster_env, value=val, + bg='white', fg='black', activebackground='white', selectcolor='white', + font=('SF Pro Text', 11)) + rb_window = self.bg_canvas.create_window(x_label+20, y, window=rb, anchor='nw') + self._canvas_items.append(rb_window) self._overlay_widgets.append(rb) - y += 30 + y += 32 # Docker/K3D Status - y += 20 - self.docker_status_label = ui.canvas_text(self, x_label, y, 'Docker: Checking...', fill='#6e6e73', font=('Helvetica', 12)) + y += 30 + self.docker_status_label = ui.canvas_text(self, x_label, y, 'Docker: Checking...', fill='#6e6e73', font=('SF Pro Text', 12)) self._canvas_items.append(self.docker_status_label) - y += 25 - self.k3d_status_label = ui.canvas_text(self, x_label, y, 'K3D Cluster: Checking...', fill='#6e6e73', font=('Helvetica', 12)) + y += 30 + self.k3d_status_label = ui.canvas_text(self, x_label, y, 'K3D Cluster: Checking...', fill='#6e6e73', font=('SF Pro Text', 12)) self._canvas_items.append(self.k3d_status_label) - y += 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) + y += 50 + # Use tk.Button on canvas + btn = tk.Button(self.bg_canvas, text='Start / Verify Cluster', command=self.ensure_cluster_ready, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(x_label, y, window=btn, anchor='nw', width=220) + self._canvas_items.append(btn_window) self._overlay_widgets.append(btn) # Async status check @@ -1050,20 +1555,41 @@ class ProleInstaller: 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) + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Build Database Image', y=150) + self._render_paragraph('Building the prole-db Postgres image. This may take a few minutes.', y=200) # Output Console - self._db_build_console = ui.TerminalConsole(self.slide_area) - self._db_build_console.place(x=48, y=130, width=860, height=360) + self._db_build_console = ui.TerminalConsole(self.bg_canvas) + console_window = self.bg_canvas.create_window(48, 260, window=self._db_build_console, anchor='nw', width=900, height=520) + self._canvas_items.append(console_window) self._overlay_widgets.append(self._db_build_console) - 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) + # Use tk.Button + self._db_build_button = tk.Button(self.bg_canvas, text='Start Build', command=self.run_db_build, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, 800, window=self._db_build_button, anchor='nw', width=180) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(self._db_build_button) + + # Status Label + self._db_build_status_label = ui.canvas_text(self, 240, 812, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._db_build_status_label) def run_db_build(self): def worker(): + self.root.after(0, lambda: self._db_build_button.configure(state='disabled')) + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Building...", fill='blue')) + tag = self.get_prole_db_version() cwd = PROJECT_ROOT / "prole-db" cmd = ['docker', 'build', '-t', f"prole-db:{tag}", '.'] @@ -1081,37 +1607,59 @@ class ProleInstaller: if proc.returncode == 0: self._db_build_console.write("\nBuild successful!\n") + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build successful! Importing...", fill='#34c759')) + # Import to k3d cluster_name = f"prole-{self.cluster_env.get()}-cluster" self._db_build_console.write(f"Importing image to {cluster_name}...\n") subprocess.run(['k3d', 'image', 'import', f"prole-db:{tag}", '-c', cluster_name]) self._db_build_console.write("Import complete.\n") + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build and Import complete.", fill='#34c759')) else: self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n") + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text=f"Build failed (code {proc.returncode})", fill='#ff3b30')) + + self.root.after(0, lambda: self._db_build_button.configure(state='normal')) threading.Thread(target=worker, daemon=True).start() def _render_init_password_page(self): - 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) + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Database Password', y=150) + self._render_paragraph('Enter a password for the Prole database administrator. This will be used to initialize OpenBao and CloudNative-PG.', y=200) x_label = 48 x_field = 200 - y = 160 + y = 280 - 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._canvas_items.append(ui.canvas_text(self, x_label, y, 'Password:', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Use tk.Entry on canvas + p1 = tk.Entry(self.bg_canvas, textvariable=self.db_password, show='*', bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + p1_window = self.bg_canvas.create_window(x_field, y-12, window=p1, anchor='nw', width=400, height=32) + self._canvas_items.append(p1_window) self._overlay_widgets.append(p1) - y += 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) + y += 60 + self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Confirm:', fill='black', font=('SF Pro Text', 12, 'bold'))) + # Use tk.Entry on canvas + p2 = tk.Entry(self.bg_canvas, textvariable=self.db_password_confirm, show='*', bg='white', fg='black', + insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1, + relief='flat', font=('SF Pro Text', 11)) + p2_window = self.bg_canvas.create_window(x_field, y-12, window=p2, anchor='nw', width=400, height=32) + self._canvas_items.append(p2_window) self._overlay_widgets.append(p2) # Indicator for password match (X or ✓) - self.password_indicator = ui.canvas_text(self, x_field + 310, y, '✘', fill='#dc3545', font=('Helvetica', 16, 'bold'), state='hidden') + self.password_indicator = ui.canvas_text(self, x_field + 410, y, '✘', fill='#dc3545', font=('SF Pro Text', 16, 'bold'), state='hidden') self._canvas_items.append(self.password_indicator) def on_password_change(*args): @@ -1130,13 +1678,89 @@ class ProleInstaller: # Trigger once in case they are already set on_password_change() + def _render_init_cnpg_deploy_page(self): + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Deploy CloudNative-PG', y=150) + self._render_paragraph('Deploying the CloudNative-PG operator and cluster manifests to Kubernetes.', y=200) + + # Output Console + self._cnpg_deploy_console = ui.TerminalConsole(self.bg_canvas) + console_window = self.bg_canvas.create_window(48, 260, window=self._cnpg_deploy_console, anchor='nw', width=900, height=520) + self._canvas_items.append(console_window) + self._overlay_widgets.append(self._cnpg_deploy_console) + + # Use tk.Button + self._cnpg_deploy_button = tk.Button(self.bg_canvas, text='Run Deployment', command=self.run_cnpg_deploy, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, 800, window=self._cnpg_deploy_button, anchor='nw', width=180) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(self._cnpg_deploy_button) + + # Status Label + self._cnpg_deploy_status_label = ui.canvas_text(self, 240, 812, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._cnpg_deploy_status_label) + + def run_cnpg_deploy(self): + def worker(): + self.root.after(0, lambda: self._cnpg_deploy_button.configure(state='disabled')) + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deploying...", fill='blue')) + + etc_dir = PROJECT_ROOT / "etc" + # Prepare environment for scripts + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + + self._cnpg_deploy_console.clear() + self._cnpg_deploy_console.write("Starting CloudNative-PG deployment...\n") + self._cnpg_deploy_console.write("> bash etc/init_prole-db.sh deploy latest\n\n") + + proc = subprocess.Popen(['bash', str(etc_dir / 'init_prole-db.sh'), 'deploy', 'latest'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) + + while True: + line = proc.stdout.readline() + if not line and proc.poll() is not None: + break + if line: + self._cnpg_deploy_console.write(line) + + if proc.returncode == 0: + self._cnpg_deploy_console.write("\nDeployment successful!\n") + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text="Deployment successful!", fill='#34c759')) + else: + self._cnpg_deploy_console.write(f"\nDeployment failed with code {proc.returncode}\n") + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._cnpg_deploy_status_label, text=f"Deployment failed (code {proc.returncode})", fill='#ff3b30')) + + self.root.after(0, lambda: self._cnpg_deploy_button.configure(state='normal')) + + threading.Thread(target=worker, daemon=True).start() + def _render_init_scripts_page(self): - self._render_title('Initialization Scripts', y=40) - self._render_paragraph('Running initialization scripts to set up OpenBao, CloudNative-PG, and Port Forwards.', y=80) + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + self._render_title('Initialization Scripts', y=150) + self._render_paragraph('Running initialization scripts to set up OpenBao, CloudNative-PG, and Port Forwards.', y=200) # Tabs for output - tab_container = ttk.Notebook(self.slide_area) - tab_container.place(x=48, y=120, width=860, height=380) + tab_container = ttk.Notebook(self.bg_canvas) + tab_window = self.bg_canvas.create_window(48, 260, window=tab_container, anchor='nw', width=900, height=520) + self._canvas_items.append(tab_window) self._overlay_widgets.append(tab_container) self.script_consoles = {} @@ -1147,21 +1771,38 @@ class ProleInstaller: ] for title, fname in scripts: - frame = ttk.Frame(tab_container) + frame = tk.Frame(tab_container, bg='white') tab_container.add(frame, text=title) console = ui.TerminalConsole(frame) console.pack(fill='both', expand=True) self.script_consoles[fname] = console - 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) + # Use tk.Button + self._init_scripts_button = tk.Button(self.bg_canvas, text='Run Scripts', command=self.run_init_scripts, + bg='#F5F5DC', fg='black', activebackground='#E5E5D5', + highlightbackground='#F5F5DC', highlightthickness=0, + relief='flat', font=('SF Pro Text', 11), padx=16, pady=8) + btn_window = self.bg_canvas.create_window(48, 800, window=self._init_scripts_button, anchor='nw', width=180) + self._canvas_items.append(btn_window) + self._overlay_widgets.append(self._init_scripts_button) + + # Status Label + self._init_scripts_status_label = ui.canvas_text(self, 240, 812, "", fill='black', font=('SF Pro Text', 12)) + self._canvas_items.append(self._init_scripts_status_label) def run_init_scripts(self): def worker(): + self.root.after(0, lambda: self._init_scripts_button.configure(state='disabled')) + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Running scripts...", fill='blue')) + password = self.db_password.get() etc_dir = PROJECT_ROOT / "etc" + # Prepare environment for scripts + env = os.environ.copy() + env["PROLE_HOME"] = str(PROJECT_ROOT) + env["PROLE_SERVICE"] = str(PROJECT_ROOT) + # 1. init_openbao.sh initialize script = "init_openbao.sh" self.script_consoles[script].clear() @@ -1169,7 +1810,8 @@ class ProleInstaller: # 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) + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, env=env) proc.stdin.write(f"{password}\n") proc.stdin.close() @@ -1180,31 +1822,31 @@ class ProleInstaller: if line: self.script_consoles[script].write(line) - # 2. init_cloudnative_pg.sh initialize and start - if proc.returncode == 0: + # 2. init_cloudnative_pg.sh initialize + overall_success = (proc.returncode == 0) + if overall_success: script = "init_cloudnative_pg.sh" self.script_consoles[script].clear() - self.script_consoles[script].write(f"Running {script} start and initialize...\n") + self.script_consoles[script].write(f"Running {script} 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 + # Initialize + self.script_consoles[script].write("> bash etc/init_cloudnative_pg.sh initialize\n") proc2 = subprocess.Popen(['bash', str(etc_dir / script), 'initialize'], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) while True: line = proc2.stdout.readline() if not line and proc2.poll() is not None: break if line: self.script_consoles[script].write(line) + + if proc2.returncode != 0: + self.script_consoles[script].write(f"\nERROR: {script} initialize failed with code {proc2.returncode}\n") + overall_success = False + else: + self.script_consoles[script].write(f"\n{script} completed successfully.\n") + else: + self.script_consoles["init_cloudnative_pg.sh"].write("Skipping CloudNative-PG initialization because OpenBao initialization failed.\n") # 3. init_port_forwards.sh start if True: # Always try to start port forwards if previous steps were okay or even if they failed (best effort) @@ -1212,41 +1854,60 @@ class ProleInstaller: 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) + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env) while True: line = proc3.stdout.readline() if not line and proc3.poll() is not None: break if line: self.script_consoles[script].write(line) + + if proc3.returncode != 0: + overall_success = False + + if overall_success: + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Scripts completed successfully.", fill='#34c759')) + else: + self.root.after(0, lambda: self.bg_canvas.itemconfig(self._init_scripts_status_label, text="Some scripts failed.", fill='#ff3b30')) + + self.root.after(0, lambda: self._init_scripts_button.configure(state='normal')) threading.Thread(target=worker, daemon=True).start() def _render_build_page(self): - self._render_title('Build', y=40) - self._render_paragraph('Build and prepare Prole services for deployment.', y=90) + # Letterhead at top right + content_width = self.bg_canvas.winfo_width() or 975 + right_margin = content_width - 48 + + ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne') + ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73', + font=('SF Pro Text', 18), anchor='ne') + + # Shifted up to accommodate radios and standard console position + self._render_title('Build Prole.app', y=80) + self._render_paragraph('Build and prepare Prole services for deployment.', y=130) # Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes) if not hasattr(self, 'deploy_env_value'): self.deploy_env_value = 'Dev' - radio_y = 130 + radio_y = 180 left = 56 - spacing = 110 + spacing = 150 # Draw three radio options self._build_radio_items = [] options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)] for label, x in options: # outer circle - r = 9 - circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='#1d1d1f', width=2) + r = 10 + circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='black', width=2) self._canvas_items.append(circle) # selected dot if self.deploy_env_value == label: - dot = ui.canvas_oval(self, x+4, radio_y+4, x+2*r-4, radio_y+2*r-4, fill='#1d1d1f', outline='') + dot = ui.canvas_oval(self, x+5, radio_y+5, x+2*r-5, radio_y+2*r-5, fill='black', outline='') self._canvas_items.append(dot) - text = ui.canvas_text(self, x + 2*r + 8, radio_y - 2, label, fill='#1d1d1f', font=('Helvetica', 12)) + text = ui.canvas_text(self, x + 2*r + 10, radio_y - 2, label, fill='black', font=('SF Pro Text', 12)) self._canvas_items.append(text) self._build_radio_items.append((label, circle, text)) @@ -1270,12 +1931,17 @@ class ProleInstaller: break self.bg_canvas.bind('', _on_click) - # Embedded console overlay (semi-transparent black backdrop + scrolled text) - self._ensure_console_overlay(radio_bottom_y=radio_y + 24) + # Output Console (Consistent size and location: y=260, width=900, height=520) + self._build_console = ui.TerminalConsole(self.bg_canvas) + console_window = self.bg_canvas.create_window(48, 260, window=self._build_console, anchor='nw', width=900, height=520) + self._canvas_items.append(console_window) + self._overlay_widgets.append(self._build_console) + self._console_text = self._build_console.text # For compatibility with _append_console and others # Show a command preview with PS1-style prompt and blinking cursor preview = self._compose_build_preview() self._console_set_preview(preview) + # Ensure state holders exist if not hasattr(self, 'last_build_log_path'): self.last_build_log_path = None @@ -1332,23 +1998,82 @@ class ProleInstaller: # 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: + if current_id == 'deps_summary': + self.show_page('welcome') + return + if current_id == 'network_scan': + # Go back to last relevant dep or deps_summary + if self.all_dependencies_installed(): self.show_page('deps_summary') + else: + seq = self._dep_navigation_sequence() + self.show_page(seq[-1] if seq else 'deps_summary') + return + if current_id == 'env_setup': + self.show_page('network_scan') + return + if current_id == 'kerberos_config': + self.show_page('env_setup') + return + if current_id == 'init_cluster': + self.show_page('kerberos_config') + return + if current_id == 'init_db_build': + self.show_page('init_cluster') + return + if current_id == 'init_cnpg_deploy': + self.show_page('init_db_build') + return + if current_id == 'init_password': + self.show_page('init_cnpg_deploy') + return + if current_id == 'build': + self.show_page('init_password') return # Default prev if self.page_index > 0: self.show_page(self.page_index - 1) + return def on_next(self): # Special handling for dynamic labels current_id = self.pages[self.page_index][0] + if current_id == 'welcome': + self.show_page('deps_summary') + return + if current_id == 'deps_summary': + # Determine where to go from summary + # Always go to the next dependency or Network Scan + if self.all_dependencies_installed(): + self.show_page('network_scan') + return + # Go to first missing dependency page + seq = self._dep_navigation_sequence() + target = seq[0] if seq else 'network_scan' + self.show_page(target) + return + if current_id.startswith('dep_'): + # Navigate within dependency sequence + seq = self._dep_navigation_sequence() + try: + i = seq.index(current_id) + except ValueError: + i = -1 + if i >= 0 and i < len(seq) - 1: + self.show_page(seq[i + 1]) + return + else: + # After last relevant dep page, go back to summary if anything is still missing + if not self.all_dependencies_installed(): + self.show_page('deps_summary') + else: + self.show_page('network_scan') + return + if current_id == 'network_scan': + self.show_page('env_setup') + return if current_id == 'env_setup': - # Collect values, validate, write env.sh, then go to deps summary + # Collect values, validate, write env.sh, then go to Kerberos config vals = {} try: for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'): @@ -1371,18 +2096,11 @@ class ProleInstaller: 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) + self.show_page('kerberos_config') + return + if current_id == 'kerberos_config': + self.show_page('init_cluster') return if current_id == 'init_cluster': # Ensure docker is started, then proceed @@ -1395,6 +2113,9 @@ class ProleInstaller: self.show_page('init_db_build') return if current_id == 'init_db_build': + self.show_page('init_cnpg_deploy') + return + if current_id == 'init_cnpg_deploy': self.show_page('init_password') return if current_id == 'init_password': @@ -1407,9 +2128,6 @@ class ProleInstaller: 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': @@ -1425,21 +2143,24 @@ class ProleInstaller: # Start or rerun build; Build Summary opens automatically on completion self.perform_build() return + # Default next + if self.page_index < len(self.pages) - 1: + self.show_page(self.page_index + 1) + return + if current_id == 'build': + # Build page button: Deploy on success (and then show summary), otherwise (re)run build + if getattr(self, '_built_success', False): + try: + self.open_drag_install_window() + except Exception: + pass + # Always navigate to Build Summary per spec + self.show_page('build_summary') + return + # Start or rerun build; Build Summary opens automatically on completion + self.perform_build() + return # No longer using external Deploy page - if 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) @@ -1448,10 +2169,10 @@ class ProleInstaller: 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']) + # Default states for tk.Button + self.prev_button.configure(state='normal') + self.next_button.configure(state='normal') + self.finish_button.configure(state='normal') first = self.page_index == 0 last = self.page_index == len(self.pages) - 1 @@ -1476,33 +2197,25 @@ class ProleInstaller: self.next_button.configure(text='Build') # Visibility rules + self.prev_button.pack_forget() + self.next_button.pack_forget() + self.finish_button.pack_forget() + if first: - self.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) + self.next_button.pack(side='right', padx=(0, 20), pady=12) 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() + # [Prev] [Next] clustered right + self.next_button.pack(side='right', padx=(0, 20), pady=12) + self.prev_button.pack(side='right', padx=(0, 8), pady=12) else: - 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) + # [Prev] [Finish] clustered right + self.finish_button.pack(side='right', padx=(0, 20), pady=12) + self.prev_button.pack(side='right', padx=(0, 8), pady=12) else: - # 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() + # [Prev] [Next] clustered right + self.next_button.pack(side='right', padx=(0, 20), pady=12) + self.prev_button.pack(side='right', padx=(0, 8), pady=12) def _splash_should_hide_nav(self) -> bool: """Deprecated: Welcome page no longer performs dependency checks or gates navigation.""" @@ -1951,7 +2664,7 @@ echo "-------------------------------------------------------------------"; pass # Re-enable Next and go to summary try: - self.next_button.state(['!disabled']) + self.next_button.configure(state='normal') except Exception: pass self.show_page('build_summary') @@ -1961,6 +2674,10 @@ echo "-------------------------------------------------------------------"; """Create semi-transparent black backdrop and a ScrolledText console overlay. The overlay is placed within slide_area between given top and bottom margins. """ + # Ensure slide_area is visible + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lift() + # Compute geometry within slide area geom = self._compute_console_geometry(radio_bottom_y) left, top, width, height = geom @@ -1999,7 +2716,7 @@ echo "-------------------------------------------------------------------"; w = self.slide_area.winfo_width() h = self.slide_area.winfo_height() except Exception: - w, h = 1000, 620 + w, h = 1300 * 0.75, 910 - 64 # Content area size approx margin = 24 top = max(radio_bottom_y + 10, 120) bottom = max(top + 180, h - 16) # ensure some height @@ -2015,6 +2732,8 @@ echo "-------------------------------------------------------------------"; if not txt: return try: + if not txt.winfo_exists(): + return txt.configure(state='normal') txt.insert('end', text) txt.see('end') @@ -2163,8 +2882,7 @@ echo "-------------------------------------------------------------------"; return # Disable Next while running try: - self.next_button.configure(text='Building…') - self.next_button.state(['disabled']) + self.next_button.configure(text='Building…', state='disabled') except Exception: pass # Reader thread @@ -2315,11 +3033,12 @@ echo "-------------------------------------------------------------------"; h = self.root.winfo_height() except Exception: # Reasonable defaults - x0, y0, w, h = 200, 200, 1000, 700 + x0, y0, w, h = 200, 200, 1300, 910 margin = 24 top = y0 + radio_bottom_y + 10 bottom = y0 + h - 90 # leave space for footer - left = x0 + margin + # Offset left by sidebar width (325) + divider (1) + left = x0 + 326 + margin right = x0 + w - margin # Ensure minimum height if bottom - top < 160: @@ -2404,10 +3123,14 @@ echo "-------------------------------------------------------------------"; # ---------------- Build Summary page ---------------- def _render_build_summary_page(self): + # Ensure slide_area is visible for the build log console + self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1) + self.slide_area.lift() + # Build Summary: show combined stdout/stderr from last build log self._render_title('Build Summary', y=40) if getattr(self, '_built_success', False): - self._render_paragraph('✅ Build completed successfully. You can now drag Prole Tools.app into Applications. Output below:', y=90) + self._render_paragraph('✅ Build completed successfully. You can now drag Prole Tools.app into Applications. Output below:', y=88) else: # Troubleshooting header tips = ( @@ -2417,13 +3140,14 @@ echo "-------------------------------------------------------------------"; "• If on Apple Silicon, ensure dependencies target arm64 or install via Homebrew\n" "• Clean derived data / SPM cache if needed: rm -rf ~/Library/Developer/Xcode/DerivedData\n" "• Check Docker status if Docker-related steps are used\n" + "• Check the logs below for specific errors" ) - self._render_paragraph(tips, y=90) + self._render_paragraph(tips, y=88) logp = getattr(self, 'last_build_log_path', None) # Place a scrolled text overlay to show the log try: # Use same geometry helper as console but with DARK theme (match Build console) - l, t, w, h = self._compute_console_geometry(120) + l, t, w, h = self._compute_console_geometry(160) txt = scrolledtext.ScrolledText(self.slide_area, wrap='word', bg='#000000', fg='#ffffff', insertbackground='#ffffff', font=('Menlo', 11), relief='flat', bd=0, highlightthickness=0) @@ -2442,7 +3166,7 @@ echo "-------------------------------------------------------------------"; # Keep overlay positioned on resize def _on_resize(_evt=None): try: - l2, t2, w2, h2 = self._compute_console_geometry(120) + l2, t2, w2, h2 = self._compute_console_geometry(160) txt.place(x=l2, y=t2, width=max(200, w2), height=max(140, h2)) except Exception: pass @@ -2451,7 +3175,7 @@ echo "-------------------------------------------------------------------"; # 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 + y_links = 100 + 40 if logp: link = ui.render_link(self, 56, y_links, 'Open build log file') self._canvas_items.append(link) @@ -2476,7 +3200,7 @@ echo "-------------------------------------------------------------------"; except Exception: dist_dir = None if dist_dir: - y_links += 24 + y_links += 30 # Create DMG link2 = ui.render_link(self, 56, y_links, 'Create Prole Tools.dmg') self._canvas_items.append(link2) @@ -2494,7 +3218,7 @@ echo "-------------------------------------------------------------------"; self.bg_canvas.bind('', _open_dist) # Also link to open dist folder (fallback) - y_links += 24 + y_links += 30 link3 = ui.render_link(self, 56, y_links, 'Open dist folder') self._canvas_items.append(link3) @@ -2691,7 +3415,7 @@ exec "$DIR/ProleTools.bin" "$@" def create_install_screen(self): """Create the dependency installer screen""" - frame = tk.Frame(self.container, bg='#1a1a1a') + frame = tk.Frame(self.content_area, bg='#1a1a1a') self.screens['install'] = frame # Title @@ -2945,7 +3669,7 @@ exec "$DIR/ProleTools.bin" "$@" def create_deploy_screen(self): """Create the Deploy screen""" - frame = tk.Frame(self.container, bg='#1a1a1a') + frame = tk.Frame(self.content_area, bg='#1a1a1a') self.screens['deploy'] = frame # Title @@ -3115,6 +3839,12 @@ svc.5.port=11434 svc.6.name=PostgreSQL svc.6.host={host} svc.6.port=5432 + +# Kerberos configuration +kerberos.enabled={str(self.kerberos_enabled.get()).lower()} +kerberos.realm={self.kerberos_realm.get()} +kerberos.user={self.kerberos_user.get()} +kerberos.kdc={self.kerberos_kdc.get()} """ props_path.write_text(content) print(f"Generated {props_path} for {env} environment") @@ -3421,10 +4151,24 @@ esac version_file = PROJECT_ROOT / 'conf' / 'postgresql' / '.version' if version_file.exists(): return version_file.read_text().strip() - return '17.7-031' # Fallback + return '17.7-033' # Fallback def build_docker_image(self): """Build prole-db Docker image""" + # If Kerberos is enabled, update pg_hba.conf in conf/postgresql before copying + if self.kerberos_enabled.get(): + realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM" + hba_src = PROJECT_ROOT / 'conf' / 'postgresql' / 'pg_hba.conf' + if hba_src.exists(): + content = hba_src.read_text() + # Also ensure the Kerberos line exists in the source if it's not there yet + if "gss" not in content: + content += f"\nhost all all all gss include_realm=1 krb_realm={realm}\n" + else: + content = content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}") + hba_src.write_text(content) + print(f"Updated {hba_src} with realm {realm}") + # Base local image tag (before pushing to registry) version = self.get_prole_db_version() image_tag = f'prole-db:{version}' @@ -3458,7 +4202,7 @@ esac self.update_k8s_manifest(version) def update_k8s_manifest(self, version): - """Update k8s/prole/prole-db.yaml with the new version""" + """Update k8s/prole/prole-db.yaml with the new version and Kerberos config""" manifest_path = PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db.yaml' if not manifest_path.exists(): return @@ -3467,6 +4211,16 @@ esac import re # Update imageName: prole-db:17.7-031 new_content = re.sub(r'imageName: prole-db:.*', f'imageName: prole-db:{version}', content) + + # Update Kerberos realm in manifest if enabled + if self.kerberos_enabled.get(): + realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM" + if "gss" not in new_content: + # Insert before bootstrap if not present + new_content = new_content.replace(" pg_hba:", f" pg_hba:\n - host all all all gss include_realm=1 krb_realm={realm}") + else: + new_content = new_content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}") + if new_content != content: manifest_path.write_text(new_content) @@ -3512,7 +4266,7 @@ esac def create_validate_screen(self): """Create the Validate screen""" - frame = tk.Frame(self.container, bg='#1a1a1a') + frame = tk.Frame(self.content_area, bg='#1a1a1a') self.screens['validate'] = frame # Title diff --git a/installer/config.py b/installer/config.py index 7c0b7a9..942798a 100644 --- a/installer/config.py +++ b/installer/config.py @@ -9,6 +9,7 @@ from __future__ import annotations from pathlib import Path import platform import subprocess +import sys from typing import Tuple, Optional import os @@ -171,6 +172,24 @@ DEPENDENCIES = [ "check_cmd": "ollama --version", "bin": "ollama", }, + { + "id": "tshark", + "name": "tshark (Wireshark)", + "description": "Network protocol analyzer for network scanning", + "url": "https://www.wireshark.org", + "install_cmd": "brew install wireshark" if platform.system() == 'Darwin' else "sudo apt-get update && sudo apt-get install -y tshark", + "check_cmd": "tshark --version", + "bin": "tshark", + }, + { + "id": "pyshark", + "name": "pyshark", + "description": "Python wrapper for tshark", + "url": "https://github.com/KimiNewt/pyshark", + "install_cmd": f"'{sys.executable}' -m pip install pyshark", + "check_cmd": f"'{sys.executable}' -c 'import pyshark'", + "bin": None, + }, ] @@ -190,6 +209,10 @@ def get_dep_info(dep: dict) -> Tuple[bool, Optional[str], Optional[str]]: res2 = subprocess.run(["bash", "-lc", check_cmd], capture_output=True, text=True) if res2.returncode == 0: version = " ".join(res2.stdout.strip().splitlines()[:1]) + # If check_cmd succeeded, consider it installed + installed = True + if not location: + location = "Installed via Python" except Exception: pass return installed, location, version diff --git a/installer/screen.py b/installer/screen.py index eca79c2..e4ae3fb 100644 --- a/installer/screen.py +++ b/installer/screen.py @@ -15,20 +15,20 @@ def render_title(app, text: str, y: int = 40): """Render a section title on the main background canvas.""" if getattr(app, 'bg_canvas', None) is None: return - item = app.bg_canvas.create_text(40, y, anchor='nw', text=text, fill='#111', font=('Helvetica Neue', 22, 'bold')) + item = app.bg_canvas.create_text(48, y, anchor='nw', text=text, fill='black', font=('SF Pro Text', 18, 'bold')) app._canvas_items.append(item) -def render_paragraph(app, text: str, y: int, wrap: int = 860): +def render_paragraph(app, text: str, y: int, wrap: int = 800): """Render a paragraph on the main background canvas.""" if getattr(app, 'bg_canvas', None) is None: return - item = app.bg_canvas.create_text(40, y, anchor='nw', text=text, fill='#1d1d1f', font=('Helvetica', 12), width=wrap) + item = app.bg_canvas.create_text(48, y, anchor='nw', text=text, fill='black', font=('SF Pro Text', 11), width=wrap) app._canvas_items.append(item) -def canvas_text(app, x: int, y: int, text: str, *, fill: str = '#1d1d1f', - font: tuple = ('Helvetica', 12), anchor: str = 'nw', width: int | None = None, +def canvas_text(app, x: int, y: int, text: str, *, fill: str = 'black', + font: tuple = ('SF Pro Text', 11), anchor: str = 'nw', width: int | None = None, justify: str | None = None, state: str | None = None) -> int: """Create a text item on the app's main canvas and track it. @@ -73,7 +73,7 @@ def canvas_rectangle(app, x1: int, y1: int, x2: int, y2: int, *, outline: str = return item -def canvas_line(app, x1: int, y1: int, x2: int, y2: int, *, fill: str = '#1d1d1f', +def canvas_line(app, x1: int, y1: int, x2: int, y2: int, *, fill: str = 'black', width: int = 2, state: str | None = None) -> int: if getattr(app, 'bg_canvas', None) is None: return -1 @@ -145,49 +145,45 @@ def canvas_line_on(cnv: tk.Canvas, x1: int, y1: int, x2: int, y2: int, *, fill: def create_nav_footer(parent, buttons: list[tuple[int, str]], commands: dict[int, callable] | None = None, - style_name: str = 'Nav.TButton') -> dict[int, ttk.Button]: + style_name: str = 'Nav.TButton') -> dict[int, tk.Button]: """Create a right-aligned navigation footer with uniform button styling. - Parameters: - - parent: the container (typically the root installer container) - - buttons: list of (button_id, title) in the order to display - - commands: optional mapping of button_id -> callback function - - style_name: ttk style to apply to all buttons - - Returns: {button_id: ttk.Button} - - Examples: - create_nav_footer(footer, [(1, 'Next')]) - create_nav_footer(footer, [(1, 'Prev'), (2, 'Next')]) - create_nav_footer(footer, [(1, 'Exit')]) + Uses tk.Button instead of ttk.Button for better color control on macOS. """ - # Ensure a consistent style for navigation buttons - try: - style = ttk.Style() - # Keep padding modest to allow Aqua to size buttons naturally. - # Avoid forcing a font so the native Aqua metrics are used. - style.configure(style_name, padding=(10, 6)) - except Exception: - pass - - footer = ttk.Frame(parent) + footer = tk.Frame(parent, bg='#F5F5DC', height=64) footer.pack(fill='x', side='bottom') - # Let the footer naturally size to its contents to avoid clipping text on macOS + footer.pack_propagate(False) + + # Top divider line for the footer + divider = tk.Frame(footer, bg='#CCCCCC', height=1) + divider.pack(side='top', fill='x') # Flexible spacer to push buttons to the right - spacer = ttk.Frame(footer) + spacer = tk.Frame(footer, bg='#F5F5DC') spacer.pack(side='left', expand=True, fill='x') cmds = commands or {} - btn_map: dict[int, ttk.Button] = {} + btn_map: dict[int, tk.Button] = {} for btn_id, title in buttons: cmd = cmds.get(btn_id) - b = ttk.Button(footer, text=title, style=style_name, command=cmd) + # Use tk.Button for full control over background and borders on macOS + b = tk.Button(footer, + text=title, + command=cmd, + bg='#F5F5DC', + fg='black', + activebackground='#E5E5D5', + activeforeground='black', + highlightbackground='#F5F5DC', # Essential for macOS to avoid black boxes + highlightthickness=0, + relief='flat', + font=('SF Pro Text', 11), + padx=16, + pady=8) + # Right-aligned order (pack to the right in the declared order) - # Small extra right padding on the last (right-most) button - pad = (0, 20) if title.lower() in ('finish', 'exit', 'done') else (0, 8) - # Use modest vertical padding; let Aqua compute proper height. - b.pack(side='right', padx=pad, pady=10) + pad = (0, 20) if title.lower() in ('finish', 'exit', 'done', 'next') else (0, 8) + b.pack(side='right', padx=pad, pady=12) btn_map[btn_id] = b # Return both the frame and button map if needed later by callers @@ -212,16 +208,25 @@ class TerminalConsole(ttk.Frame): def write(self, content: str): """Append text to the console and scroll to the bottom.""" - self.text.configure(state='normal') - self.text.insert('end', content) - self.text.see('end') - self.text.configure(state='disabled') - self.update_idletasks() - + try: + if not self.winfo_exists(): + return + self.text.configure(state='normal') + self.text.insert('end', content) + self.text.see('end') + self.text.configure(state='disabled') + self.update_idletasks() + except tk.TclError: + pass def clear(self): """Clear all content from the console.""" - self.text.configure(state='normal') - self.text.delete('1.0', 'end') - self.text.configure(state='disabled') - self.update_idletasks() + try: + if not self.winfo_exists(): + return + self.text.configure(state='normal') + self.text.delete('1.0', 'end') + self.text.configure(state='disabled') + self.update_idletasks() + except tk.TclError: + pass diff --git a/k8s/openbao/kerberos-configmap.yaml b/k8s/openbao/kerberos-configmap.yaml index 967425c..4a4c919 100644 --- a/k8s/openbao/kerberos-configmap.yaml +++ b/k8s/openbao/kerberos-configmap.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: ConfigMap metadata: name: prole-krb5-conf - namespace: default + namespace: prole data: krb5.conf: | [libdefaults] diff --git a/k8s/prole/prole-db.yaml b/k8s/prole/prole-db.yaml index 576383a..665b817 100644 --- a/k8s/prole/prole-db.yaml +++ b/k8s/prole/prole-db.yaml @@ -4,7 +4,7 @@ metadata: name: prole-db spec: instances: 3 - imageName: prole-db:17.7-031 + imageName: prole-db:17.7-033 postgresUID: 100 postgresGID: 101 maxSyncReplicas: 1 @@ -24,6 +24,7 @@ spec: - host prole prole-db all scram-sha-256 - host all all all scram-sha-256 - hostssl prole prole-db all scram-sha-256 + - host all all all gss include_realm=1 krb_realm=EXAMPLE.COM bootstrap: initdb: diff --git a/prole-db/Dockerfile b/prole-db/Dockerfile index e91e8c6..4b786bd 100644 --- a/prole-db/Dockerfile +++ b/prole-db/Dockerfile @@ -88,8 +88,8 @@ RUN set -eux; \ apt-get update; \ apt-cache search percona-postgresql-17 | grep -E "(pgvector|postgis|contrib)"; \ apt-get install -y --no-install-recommends \ - percona-pgvector-percona-postgresql-17 \ - percona-postgis-percona-postgresql-17 \ + percona-postgresql-17-pgvector \ + percona-postgresql-17-postgis-3 \ percona-postgresql-contrib-17 \ freetds-dev \ ; \ diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..d3eb3ae --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Run all unit tests and generate a coverage report + +export PYTHONPATH=$PYTHONPATH:. + +# Check if pytest-cov is installed +if pytest --trace-config | grep -q "pytest_cov"; then + echo "Running tests with coverage..." + pytest --cov=installer --cov=install --cov-report=term-missing --cov-report=html tests/ + echo "Coverage report (HTML) generated in htmlcov/index.html" +else + echo "Warning: pytest-cov not found. Running tests without coverage." + echo "To enable coverage, install it via: pip install pytest-cov" + pytest tests/ +fi diff --git a/run_with_coverage.sh b/run_with_coverage.sh new file mode 100755 index 0000000..61c6164 --- /dev/null +++ b/run_with_coverage.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Run the Prole installer with execution instrumentation (coverage) +# This helps identify unused code paths during manual testing/usage. +export PYTHONPATH=$PYTHONPATH:. +coverage run install.py "$@" +coverage report -m +coverage html +echo "Execution instrumentation report generated in htmlcov/index.html" diff --git a/tests/installer/test_build.py b/tests/installer/test_build.py new file mode 100644 index 0000000..6560b27 --- /dev/null +++ b/tests/installer/test_build.py @@ -0,0 +1,15 @@ +from pathlib import Path +from installer.build import get_build_command + +def test_get_build_command(): + root = Path("/fake/root") + cmd = get_build_command(root, "Dev") + assert 'cd "/fake/root"' in cmd + assert "PROLE_VERBOSE=1" in cmd + assert "# Dev" in cmd + + cmd_prod = get_build_command(root, "Prod") + assert "# Prod" in cmd_prod + + cmd_none = get_build_command(root, None) + assert "# Dev" in cmd_none diff --git a/tests/installer/test_config.py b/tests/installer/test_config.py new file mode 100644 index 0000000..5f77931 --- /dev/null +++ b/tests/installer/test_config.py @@ -0,0 +1,33 @@ +import pytest +from unittest.mock import patch, MagicMock +from installer.config import normalize_version, is_apple_silicon, get_docker_build_platform_args + +def test_normalize_version(): + assert normalize_version("1.2.3") == "01.02.03" + assert normalize_version("v1.2") == "01.02.00" + assert normalize_version("2") == "02.00.00" + assert normalize_version("10.0.1-beta") == "10.00.01" + assert normalize_version("") == "" + assert normalize_version("no numbers") == "no numbers" + +@patch("platform.machine") +@patch("platform.system") +def test_is_apple_silicon(mock_system, mock_machine): + mock_machine.return_value = "arm64" + mock_system.return_value = "Darwin" + assert is_apple_silicon() is True + + mock_machine.return_value = "x86_64" + assert is_apple_silicon() is False + + mock_machine.return_value = "arm64" + mock_system.return_value = "Linux" + assert is_apple_silicon() is False + +@patch("installer.config.is_apple_silicon") +def test_get_docker_build_platform_args(mock_is_apple_silicon): + mock_is_apple_silicon.return_value = True + assert get_docker_build_platform_args() == ["--platform", "linux/amd64"] + + mock_is_apple_silicon.return_value = False + assert get_docker_build_platform_args() == [] diff --git a/tests/installer/test_screen.py b/tests/installer/test_screen.py new file mode 100644 index 0000000..4dea144 --- /dev/null +++ b/tests/installer/test_screen.py @@ -0,0 +1,48 @@ +import pytest +from unittest.mock import MagicMock, patch +import tkinter as tk +from installer.screen import render_title, render_paragraph, TerminalConsole + +@pytest.fixture +def mock_app(): + app = MagicMock() + app.bg_canvas = MagicMock() + app._canvas_items = [] + return app + +def test_render_title(mock_app): + render_title(mock_app, "Test Title", y=50) + mock_app.bg_canvas.create_text.assert_called_once() + args, kwargs = mock_app.bg_canvas.create_text.call_args + assert args == (48, 50) + assert kwargs['text'] == "Test Title" + assert len(mock_app._canvas_items) == 1 + +def test_render_paragraph(mock_app): + render_paragraph(mock_app, "Test Paragraph", y=100) + mock_app.bg_canvas.create_text.assert_called_once() + args, kwargs = mock_app.bg_canvas.create_text.call_args + assert args == (48, 100) + assert kwargs['text'] == "Test Paragraph" + assert len(mock_app._canvas_items) == 1 + +def test_render_title_no_canvas(): + app = MagicMock() + app.bg_canvas = None + render_title(app, "Title") + # Should not raise exception + +@patch('platform.system', return_value='Darwin') +def test_terminal_console(mock_platform): + root = tk.Tk() + try: + console = TerminalConsole(root) + console.write("Hello\n") + # In mock or headless env, we might not be able to check text content easily + # but we can verify it doesn't crash and state is managed + assert console.text.cget('state') == 'disabled' + + console.clear() + assert console.text.cget('state') == 'disabled' + finally: + root.destroy() diff --git a/tests/test_install_logic.py b/tests/test_install_logic.py new file mode 100644 index 0000000..a1e493b --- /dev/null +++ b/tests/test_install_logic.py @@ -0,0 +1,62 @@ +import sys +from unittest.mock import patch, MagicMock + +# Mock tkinter and other GUI/macOS specific imports before they are imported in install.py +sys.modules['tkinter'] = MagicMock() +sys.modules['tkinter.ttk'] = MagicMock() +sys.modules['tkinter.scrolledtext'] = MagicMock() +sys.modules['tkinter.messagebox'] = MagicMock() +sys.modules['Foundation'] = MagicMock() +sys.modules['objc'] = MagicMock() + +import os +import pytest +from pathlib import Path + +# Now import install.py after mocking +import install +from install import ProleInstaller + +@pytest.fixture +def installer(tmp_path): + # Mocking os.environ to avoid messing with real env + with patch.dict(os.environ, {}, clear=True): + # We need to mock ProleInstaller.__init__ because it creates GUI elements + with patch.object(ProleInstaller, '__init__', return_value=None): + ins = ProleInstaller() + return ins + +def test_resolve_prole_home_default(installer): + with patch.dict(os.environ, {}, clear=True): + # Path.home() might vary, so we just check it ends with .prole if no env var + home = installer.resolve_prole_home() + assert home.name == ".prole" + +def test_resolve_prole_home_env(installer): + custom_home = "/tmp/custom_prole" + with patch.dict(os.environ, {'PROLE_HOME': custom_home}): + home = installer.resolve_prole_home() + assert str(home) == custom_home + +def test_env_defaults(installer): + defaults = installer._env_defaults() + assert 'PROLE_HOME' in defaults + assert defaults['PROLE_HOME'].endswith('.prole') + assert defaults['PROLE_CONF'].endswith('.prole/conf') + +def test_read_existing_env_no_file(installer, tmp_path): + # Ensure neither PROLE_HOME nor the default location has an env.sh for this test + with patch.dict(os.environ, {'PROLE_HOME': str(tmp_path)}): + with patch('pathlib.Path.home', return_value=tmp_path / "fake_home"): + env = installer._read_existing_env() + assert env == {} + +def test_read_existing_env_with_file(installer, tmp_path): + env_sh = tmp_path / "env.sh" + env_sh.write_text('export VAR1="val1"\nVAR2=val2\n# comment\n') + + with patch.dict(os.environ, {'PROLE_HOME': str(tmp_path)}): + with patch('pathlib.Path.home', return_value=tmp_path / "fake_home"): + env = installer._read_existing_env() + assert env.get('VAR1') == "val1" + assert env.get('VAR2') == "val2"