prole/installer/ui/screens/__init__.py
chrisfu ec50f82cc8 refactor: deduplicate installer business logic & split screens.py into package
Separation of concerns: merge silent/UI actions & modularize screens.

ProleInstallerBase (actions.py): Created shared base class with 37 deduplicated methods previously duplicated between ProleSilentInstaller and ProleInstaller. Namespace, environment, secret, deployment, port-forward, authority/repair, image, and logging helpers now defined once. Subclasses override _get_input() to bridge their data-access layers.

screens.py -> screens/ package (18 mixin modules): Split 10,234-line monolithic screens.py into focused mixin modules: base, navigation, welcome, dependencies, network, environment, database, cluster, services, security, ollama, supabase, docker, build, packaging, deploy, validate, cfg. __init__.py composes ProleInstaller from all mixins and re-exports has_display(), main() for full backward compatibility.

All 37 tests pass with no regressions.
2026-02-20 14:43:20 -08:00

639 lines
27 KiB
Python

"""
Prole Service Dependencies Installer — screen package.
This package splits the monolithic ``ProleInstaller`` class into cohesive
mixin modules, one per logical screen / concern. The composed class is
assembled here and re-exported so that external callers (``install.py``,
tests) see the same public surface as before.
"""
from __future__ import annotations
import configparser
import platform
import signal
import socket
import threading
import tkinter as tk
import uuid
from tkinter import ttk, messagebox, filedialog
from installer import deploy as inst_deploy
from installer import screen as ui
from installer.build import get_build_command as inst_get_build_command
from installer.config import (
_expand_cfg_value, _collect_cfg_vars, _is_prole_secret,
_decrypt_prole_secret, _is_openbao_ref, _write_k3s_kubeconfig,
_encrypt_cfg_secret, _encrypt_prole_secret,
)
from installer.core.controller import ProleController
from installer.core.env import (
_detect_ansible_topology, _format_ansible_topology_summary,
_parse_ollama_host, _normalize_k3s_token, _deployment_target_label,
_cluster_env_radio_value, _find_kubeconfig_file, _bool_str,
_looks_like_k8s_bearer_token, _deployment_mode_from_env,
_pf_extract_id, _ensure_ansible_vault_credentials,
_openbao_placeholder, _pf_upsert_mapping, _format_ollama_host,
_default_opentofu_pipeline_url, _build_required_port_forwards,
_render_prole_cfg, _normalize_cluster_env,
_k3d_prole_data_volume_args, _push_docker_image,
_http_ping_registry, _local_registry_enabled,
_collect_images_from_files,
)
# On macOS, set process name early so the menu bar reads correctly.
if platform.system() == 'Darwin':
try:
from Foundation import NSProcessInfo
NSProcessInfo.processInfo().setProcessName_("Prole Database Installer")
except Exception:
pass
from installer.core.env import * # noqa: F401,F403
from installer.core.actions import (
ProleInstallerBase, ProleSilentInstaller,
_prepare_k3s_pipeline, _reset_k3s_namespace,
)
# Keep global frames list in memory (legacy requirement).
GLOBAL_SCAN_FRAMES: list = []
# ---------------------------------------------------------------------------
# Mixin imports — one per screen / concern
# ---------------------------------------------------------------------------
from installer.ui.screens.base import ScreenBaseMixin
from installer.ui.screens.navigation import NavigationMixin
from installer.ui.screens.welcome import WelcomeScreenMixin
from installer.ui.screens.dependencies import DependenciesScreenMixin
from installer.ui.screens.network import NetworkScreenMixin
from installer.ui.screens.environment import EnvironmentScreenMixin
from installer.ui.screens.database import DatabaseScreenMixin
from installer.ui.screens.cluster import ClusterScreenMixin
from installer.ui.screens.services import ServicesScreenMixin
from installer.ui.screens.security import SecurityScreenMixin
from installer.ui.screens.ollama import OllamaScreenMixin
from installer.ui.screens.supabase import SupabaseScreenMixin
from installer.ui.screens.docker import DockerScreenMixin
from installer.ui.screens.build import BuildScreenMixin
from installer.ui.screens.packaging import PackagingScreenMixin
from installer.ui.screens.deploy import DeployScreenMixin
from installer.ui.screens.validate import ValidateScreenMixin
from installer.ui.screens.cfg import ConfigMixin
# ---------------------------------------------------------------------------
# Composed installer class
# ---------------------------------------------------------------------------
class ProleInstaller(
ProleInstallerBase,
ScreenBaseMixin,
NavigationMixin,
WelcomeScreenMixin,
DependenciesScreenMixin,
NetworkScreenMixin,
EnvironmentScreenMixin,
DatabaseScreenMixin,
ClusterScreenMixin,
ServicesScreenMixin,
SecurityScreenMixin,
OllamaScreenMixin,
SupabaseScreenMixin,
DockerScreenMixin,
BuildScreenMixin,
PackagingScreenMixin,
DeployScreenMixin,
ValidateScreenMixin,
ConfigMixin,
):
"""Prole GUI installer — assembled from per-screen mixins."""
KUBECTL_STATUS_TAB = "__kubectl_status__"
# ---- Data access bridge (satisfies ProleInstallerBase contract) ----
_INPUT_VAR_MAP: dict[str, str] = {
'init_cluster.cluster_env': 'cluster_env',
'init_password.db_namespace': 'db_namespace',
'init_password.db_username': 'db_username',
'init_password.db_password': 'db_password',
'init_password.db_host_port': 'db_host_port',
'env_setup.NAMESPACE': 'db_namespace',
'kerberos_config.enabled': 'kerberos_enabled',
'kerberos_config.realm': 'kerberos_realm',
'kerberos_config.kdc': 'kerberos_kdc',
'kerberos_config.user': 'kerberos_user',
'kerberos_config.password': 'kerberos_password',
'init_cluster.kerberos_enabled': 'kerberos_enabled',
'init_cluster.supabase_enabled': 'supabase_enabled',
'init_cluster.at_rest_encryption_enabled': 'at_rest_encryption_enabled',
'init_cluster.k3s_server_url': 'k3s_server_url',
'init_cluster.k3s_token': 'k3s_token',
'ollama_config.server_host': 'ollama_server_host',
'ollama_config.server_port': 'ollama_server_port',
'ollama_config.model': 'ollama_model',
}
def _get_input(self, key: str, default: str | None = None) -> str:
"""Bridge for :class:`ProleInstallerBase` — reads tk vars by key."""
if key.startswith('env_setup.') and hasattr(self, '_env_entries'):
env_key = key[len('env_setup.'):]
ent = self._env_entries.get(env_key)
if ent is not None:
try:
val = ent.get().strip()
if val:
return val
except Exception:
pass
attr = self._INPUT_VAR_MAP.get(key)
if attr:
var = getattr(self, attr, None)
if var is not None:
try:
val = var.get()
if isinstance(val, bool):
return 'true' if val else 'false'
if val not in (None, ''):
return str(val)
except Exception:
pass
return default if default is not None else ''
# ------------------------------------------------------------------ init
def __init__(self, root, config_path: str | None = None, reset_cluster: bool = False, verbose: bool = False):
self.controller = ProleController(PROJECT_ROOT, verbose=verbose)
self.project_root = PROJECT_ROOT
self._init_shared_state()
self.screens = None
self.deploy_environment = None
self.root = root
self._cfg_path_override = None
if config_path:
try:
self._cfg_path_override = Path(config_path).expanduser()
except Exception:
self._cfg_path_override = None
self._installing_dep_id = None
self._managed_kubeconfig = None
self._kong_last_repair_at = 0.0
self._kong_repair_cooldown_s = 120
self._repair_last_run_at = 0.0
self._repair_cooldown_s = 600
self._repair_inflight = False
self._reset_cluster = bool(reset_cluster)
_ensure_ansible_vault_credentials(prompt_ui=True, root=root)
self._action_flags = {}
self.root.title("Prole Database Installer")
try:
self._set_app_identity()
except Exception:
pass
# Center window on screen
window_width = 1300
window_height = 910
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
center_x = int(screen_width / 2 - window_width / 2)
center_y = int(screen_height / 2 - window_height / 2)
self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}")
self.root.resizable(False, False)
# Force light mode colors
self.root.configure(bg='white')
# Style configuration
self.style = ttk.Style()
self.configure_styles()
# Main container with two columns
self.main_container = tk.Frame(root, bg='white')
self.main_container.pack(fill='both', expand=True)
# Left Sidebar
self.sidebar = tk.Frame(self.main_container, bg='#F5F5DC', width=325)
self.sidebar.pack(side='left', fill='y')
self.sidebar.pack_propagate(False)
# Vertical Divider Line
self.divider = tk.Frame(self.main_container, bg='#CCCCCC', width=1, highlightthickness=0, bd=0)
self.divider.pack(side='left', fill='y')
# Right Content Area
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
btns = ui.create_nav_footer(
self.content_area,
buttons=[(1, 'Previous'), (2, 'Next'), (3, 'Deploy'), (4, 'Launch')],
commands={1: self.on_prev, 2: self.on_next, 3: self.on_deploy, 4: self.on_launch},
style_name='Nav.TButton',
gear_command=self.show_config_dialog
)
self.footer = btns.get('_footer')
self.prev_button = btns.get(1)
self.next_button = btns.get(2)
self.deploy_button = btns.get(3)
self.launch_button = btns.get(4)
self.gear_button = btns.get('gear')
# Background canvas
self._bg_pil = None
self._bg_tk = None
self.bg_canvas = tk.Canvas(self.content_area, highlightthickness=0, bd=0, bg='white')
self.bg_canvas.pack(fill='both', expand=True)
self._bg_item = None
# Slide area overlay
self.slide_area = tk.Frame(self.bg_canvas, bg='white')
self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1)
self.slide_area.lower()
try:
from PIL import Image, ImageTk
bg_path = get_resource_path('img/proleLogoSepia.png')
if bg_path.exists():
original = Image.open(str(bg_path)).convert('RGBA')
white_bg = Image.new('RGBA', original.size, (255, 255, 255, 255))
self._bg_pil = Image.blend(white_bg, original, 0.15)
def _render_bg(event=None):
if not self._bg_pil:
return
cw = max(1, self.bg_canvas.winfo_width())
ch = max(1, self.bg_canvas.winfo_height())
iw, ih = self._bg_pil.size
scale = max(cw / iw, ch / ih)
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
img = self._bg_pil.resize((nw, nh), Image.LANCZOS)
self._bg_tk = ImageTk.PhotoImage(img)
if self._bg_item is not None:
self.bg_canvas.delete(self._bg_item)
self._bg_item = self.bg_canvas.create_image(cw // 2, ch // 2, image=self._bg_tk, anchor='center')
self.bg_canvas.tag_lower(self._bg_item)
self.bg_canvas.bind('<Configure>', _render_bg)
except Exception as e:
print(f"Error loading background: {e}")
# Navigation menu items
self.nav_items = [
("Welcome", "welcome"),
("Dependencies", "deps_summary"),
("Network", "network_scan"),
("System Environment", "env_setup"),
("Cluster Environment", "init_cluster"),
("Database Creation", "init_password"),
("Docker Build", "init_db_build"),
("Kerberos Authentication", "kerberos_config"),
("Initialization Scripts", "init_scripts"),
("Ollama", "ollama_config"),
("Supabase", "supabase_config"),
("Deployment", "init_cnpg_deploy"),
("Post Install", "create_installer")
]
self.nav_widgets = {}
self._create_sidebar_nav()
# Validation attributes
self.validation_running = False
self.validation_thread = None
self._welcome_scan_started = False
self.splash_scan_running = False
self.splash_scan_done_at = None
self.splash_scan_started_at = None
self._splash_status_item = None
try:
self.expected_host = (platform.node() or socket.gethostname()).split('.')[0]
except Exception:
self.expected_host = None
# Canvas page state
self._canvas_page = None
self._canvas_items = []
self.canvas_renderers = {}
self._overlay_widgets = []
self._cursor_blink_after_id = None
self._cursor_blink_visible = False
self._k3s_status_after_id = None
self._k3s_status_active = False
self._k3s_status_inflight = False
self._k3s_service_status_console = None
self._k3s_service_deploy_console = None
self._k3s_service_notebook = None
self._k3s_service_status_tab = None
self._k3s_service_deploy_tab = None
self._common_services_success = False
self._showing_service_overlay = False
self._last_registry_log_path = None
self._last_common_services_log_path = None
# Shared dependency catalog
self.dependencies = list(inst_config.DEPENDENCIES)
# Docker import directory
self.docker_import_dir = tk.StringVar()
# Initialize prole.cfg data (override base with tk-aware version)
self.prole_cfg_data = {
'Global': {},
'Welcome': {},
'Dependencies': {},
'Network': {},
'Port Forwards': {},
'System Environment': {},
'Kerberos Authentication': {},
'Ollama': {},
'Optional Features': {},
'Database Creation': {},
'Docker Build': {},
'Initialize Cluster': {},
'Initialization Scripts': {},
'Deployment': {},
'Dev Cluster (k3d)': {},
'Service Cluster (k3s)': {},
'Prod Cluster (k8s)': {},
'Install': {}
}
self._cfg_secret_cache = {}
self._secrets_finalized = False
self.service_namespace = tk.StringVar(value='default')
# Load existing docker_import_dir from prole.cfg
try:
conf_dir = self._resolve_prole_conf_dir()
cfg_path = conf_dir / 'prole.cfg'
if self._cfg_path_override:
cfg_path = self._cfg_path_override
if cfg_path.exists():
cfg = configparser.ConfigParser(interpolation=None)
cfg.optionxform = str
cfg.read(cfg_path)
cfg_vars = _collect_cfg_vars(cfg)
if cfg.has_section('Global'):
if 'DOCKER_IMPORT_DIR' in cfg['Global']:
docker_dir = _expand_cfg_value(cfg['Global']['DOCKER_IMPORT_DIR'], cfg_vars)
self.docker_import_dir.set(docker_dir)
self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = docker_dir
saved_cluster = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', ''), cfg_vars).strip()
saved_ns = _expand_cfg_value(cfg['Global'].get('NAMESPACE', ''), cfg_vars).strip()
saved_service_ns = _expand_cfg_value(cfg['Global'].get('SERVICE_NAMESPACE', ''), cfg_vars).strip()
if saved_cluster:
try:
self.cluster_env.set(saved_cluster)
except Exception:
pass
if saved_ns:
try:
self.db_namespace.set(saved_ns)
except Exception:
pass
try:
self.prole_cfg_data.setdefault('Global', {})['NAMESPACE'] = saved_ns
except Exception:
pass
if saved_service_ns:
try:
self.service_namespace.set(saved_service_ns)
except Exception:
pass
self.prole_cfg_data['Global']['SERVICE_NAMESPACE'] = saved_service_ns
except Exception:
pass
self.prole_cfg_data['Global'].setdefault('SERVICE_NAMESPACE', self.service_namespace.get())
# Disk selection variables
self.selected_disk_type = tk.StringVar(value='local')
self.selected_removable_disk = tk.StringVar()
self.selected_local_path = tk.StringVar(value=str(Path.home()))
self.removable_disks = []
# Wizard pages setup
self.pages = []
self.page_index = 0
# Cluster / k3d variables
self.cluster_env = tk.StringVar(value='dev')
self.k3d_cluster_name = tk.StringVar(value='prole-dev-cluster')
self.k3d_cluster_list = tk.Variable(value=[])
self.selected_k3d_cluster = tk.StringVar()
try:
cfg_env = (self.prole_cfg_data.get('Initialize Cluster', {}) or {}).get('ENVIRONMENT', '')
if _normalize_cluster_env(cfg_env) == 'dev':
self.selected_k3d_cluster.set(cfg_env)
except Exception:
pass
self.deploy_target = tk.StringVar(value=_deployment_target_label(self.cluster_env.get()))
self._syncing_deploy_target = False
self._deploy_target_trace = self.deploy_target.trace_add('write', self._on_deploy_target_change)
self.kubectx_list = tk.Variable(value=self._get_kubectx_list())
self.selected_kubectx = tk.StringVar()
self.prod_artifacts_path = tk.StringVar(value=str(PROJECT_ROOT / "data" / "staging"))
self.k3s_server_url = tk.StringVar()
self.k3s_token = tk.StringVar()
self.k3s_services_status = {
"registry": tk.StringVar(value="Unknown"),
"openbao": tk.StringVar(value="Unknown"),
"opentofu": tk.StringVar(value="Unknown"),
}
try:
self.db_username = tk.StringVar(value=os.getlogin())
except Exception:
self.db_username = tk.StringVar(value="prole")
self.db_password = tk.StringVar()
self.db_password_confirm = tk.StringVar()
self.namespace_owner = self._get_local_owner()
self.db_namespace = tk.StringVar(value=self._initial_namespace())
try:
_ns_from_cfg = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip()
if _ns_from_cfg:
self.db_namespace.set(self._ensure_namespace_prefix(_ns_from_cfg))
except Exception:
pass
self.db_host_port = tk.StringVar(value='5432')
self.db_namespace_suffix = tk.StringVar()
self._updating_namespace_fields = False
self._sync_namespace_suffix_from_full()
self.db_namespace.trace_add('write', lambda *args: self._sync_namespace_suffix_from_full())
self.db_namespace_suffix.trace_add('write', lambda *args: self._sync_namespace_full_from_suffix())
# Kerberos / optional features
self.kerberos_enabled = tk.BooleanVar(value=False)
self.kerberos_realm = tk.StringVar()
self.kerberos_user = tk.StringVar()
self.kerberos_password = tk.StringVar()
self.kerberos_kdc = tk.StringVar()
self.supabase_enabled = tk.BooleanVar(value=False)
self.at_rest_encryption_enabled = tk.BooleanVar(value=True)
self.ollama_server_host = tk.StringVar()
self.ollama_server_port = tk.StringVar(value=DEFAULT_OLLAMA_PORT)
self.ollama_model = tk.StringVar()
self._ollama_servers_cache = []
self._ollama_model_options = {}
self.ansible_topology = {}
self.ansible_topology_summary = ''
try:
self._apply_ansible_topology_defaults()
except Exception:
pass
# Load encrypted secrets, apply cfg defaults
try:
self._load_secret_cache_from_cfg()
except Exception:
pass
try:
self._apply_cluster_env_default_from_cfg()
except Exception:
pass
try:
self._apply_k3s_defaults()
except Exception:
pass
try:
self._apply_ollama_defaults()
except Exception:
pass
# Create pages
self.page_frames = {}
self.verify_mode = tk.BooleanVar(value=False)
# Register canvas renderers
self._register_canvas_renderer('welcome', self._render_welcome_page)
self._register_canvas_renderer('network_scan', self._render_network_scan_page)
self._register_canvas_renderer('env_setup', self._render_env_setup_page)
self._register_canvas_renderer('deps_summary', self._render_deps_summary_page)
for dep in self.dependencies:
self._register_canvas_renderer(f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d))
self._register_canvas_renderer('kerberos_config', self._render_kerberos_config_page)
self._register_canvas_renderer('ollama_config', self._render_ollama_config_page)
self._register_canvas_renderer('supabase_config', self._render_supabase_config_page)
self._register_canvas_renderer('init_cluster', self._render_init_cluster_page)
self._register_canvas_renderer('init_db_build', self._render_init_db_build_page)
self._register_canvas_renderer('init_password', self._render_init_password_page)
self._register_canvas_renderer('init_cnpg_deploy', self._render_init_cnpg_deploy_page)
self._register_canvas_renderer('init_scripts', self._render_init_scripts_page)
self._register_canvas_renderer('build', self._render_build_page)
self._register_canvas_renderer('disk_selection', self._render_disk_selection_page)
self._register_canvas_renderer('build_summary', self._render_build_summary_page)
self._register_canvas_renderer('create_installer', self._render_create_installer_page)
# Page ordering for navigation
self._register_page('welcome', None)
self._register_page('deps_summary', None)
for dep in self.dependencies:
self._register_page(f"dep_{dep['id']}", None)
self._register_page('network_scan', None)
self._register_page('env_setup', None)
self._register_page('init_cluster', None)
self._register_page('init_password', None)
self._register_page('init_db_build', None)
self._register_page('kerberos_config', None)
self._register_page('init_scripts', None)
self._register_page('ollama_config', None)
self._register_page('supabase_config', None)
self._register_page('init_cnpg_deploy', None)
self._register_page('build', None)
self._register_page('disk_selection', None)
self._register_page('build_summary', None)
self._register_page('create_installer', None)
# Initial render
self.root.after(100, lambda: self.selected_local_path.set(str(self._get_prole_dist_dir())))
self.update_footer()
self.show_page(0)
# ---------------------------------------------------------------------------
# Module-level helpers (kept for backward compatibility)
# ---------------------------------------------------------------------------
def has_display():
"""Check if a display is available for GUI."""
if os.environ.get('DISPLAY'):
return True
if platform.system() == 'Darwin':
try:
test_root = tk.Tk()
test_root.withdraw()
test_root.destroy()
return True
except Exception:
return False
return False
def main():
import argparse
parser = argparse.ArgumentParser(description='Prole Database Installer')
parser.add_argument('-c', '--config', default=None,
help='Path to prole.cfg for saving or silent replay')
parser.add_argument('-S', '--silent', action='store_true',
help='Run unattended install in console mode using prole.cfg')
parser.add_argument('--prepare-k3s-pipeline', action='store_true',
help='Prepare OpenTofu k3s pipeline and run silent install test')
parser.add_argument('--reset', action='store_true',
help='Reset k3d dev cluster or k3s service cluster')
parser.add_argument('--no-gui', action='store_true',
help='Run installer with ncurses terminal interface')
parser.add_argument('--gui', action='store_true',
help='Force GUI mode')
parser.add_argument('-v', '--verbose', action='store_true',
help='Enable verbose output')
parser.add_argument('-d', '--debug', action='store_true',
help='Enable debug logging')
args = parser.parse_args()
from installer.config import setup_logging
setup_logging(verbose=args.verbose, debug=args.debug)
controller = ProleController(PROJECT_ROOT, verbose=args.verbose)
if args.prepare_k3s_pipeline:
rc = _prepare_k3s_pipeline(controller)
sys.exit(rc)
if args.silent:
_ensure_ansible_vault_credentials(prompt_ui=False)
installer = ProleSilentInstaller(controller, args.config, reset_cluster=args.reset)
sys.exit(installer.run())
use_gui = False
if args.gui:
use_gui = True
elif args.no_gui:
use_gui = False
else:
use_gui = has_display()
if use_gui:
try:
root = tk.Tk()
root.title("Prole Database Installer")
window_width, window_height = 1000, 700
try:
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
cx, cy = int(sw / 2 - window_width / 2), int(sh / 2 - window_height / 2)
root.geometry(f"{window_width}x{window_height}+{cx}+{cy}")
except Exception:
root.geometry(f"{window_width}x{window_height}")
_ensure_ansible_vault_credentials(prompt_ui=True, root=root)
ProleInstaller(root, config_path=args.config, reset_cluster=args.reset, verbose=args.verbose)
root.mainloop()
except Exception as e:
print(f"Failed to start GUI: {e}", file=sys.stderr)
print("Falling back to ncurses interface...", file=sys.stderr)
time.sleep(1)
from installer.ncurses_installer import run_ncurses_installer
run_ncurses_installer(controller)
else:
_ensure_ansible_vault_credentials(prompt_ui=False)
from installer.ncurses_installer import run_ncurses_installer
run_ncurses_installer(controller)
if __name__ == '__main__':
main()