mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:04:31 +00:00
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.
This commit is contained in:
parent
9094d10117
commit
ec50f82cc8
@ -6,11 +6,11 @@ supabase-studio: local=8088 remote=3000 ns=supabase svc=studio address=0.0.0.0
|
||||
supabase-auth: local=9999 remote=9999 ns=supabase svc=auth address=0.0.0.0
|
||||
supabase-rest: local=3001 remote=3000 ns=supabase svc=rest address=0.0.0.0
|
||||
supabase-realtime: local=4000 remote=4000 ns=supabase svc=realtime address=0.0.0.0
|
||||
garage: local=3900 remote=3900 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4767978832'> svc=garage address=0.0.0.0
|
||||
openbao: local=8200 remote=8200 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4767978832'> svc=openbao address=127.0.0.1
|
||||
opentofu: local=8080 remote=8080 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4767978832'> svc=opentofu address=0.0.0.0
|
||||
garage: local=3900 remote=3900 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4560055536'> svc=garage address=0.0.0.0
|
||||
openbao: local=8200 remote=8200 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4560055536'> svc=openbao address=127.0.0.1
|
||||
opentofu: local=8080 remote=8080 ns=<MagicMock name='StringVar().get().strip().strip().strip()' id='4560055536'> svc=opentofu address=0.0.0.0
|
||||
dashboard: local=8443 remote=443 ns=kubernetes-dashboard svc=kubernetes-dashboard-kong-proxy address=127.0.0.1
|
||||
postgres: local=<MagicMock name='StringVar().get().strip().strip()' id='4766698528'> remote=5432 ns=<MagicMock name='StringVar().get().strip().strip()' id='4766698528'> svc=prole-db-rw address=0.0.0.0
|
||||
postgres: local=<MagicMock name='StringVar().get().strip().strip()' id='4559068128'> remote=5432 ns=<MagicMock name='StringVar().get().strip().strip()' id='4559068128'> svc=prole-db-rw address=0.0.0.0
|
||||
prometheus: local=9090 remote=9090 ns=monitoring svc=kps-kube-prometheus-stack-prometheus address=127.0.0.1
|
||||
grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0
|
||||
supabase-kong: local=8000 remote=8000 ns=supabase svc=kong address=0.0.0.0
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
; Prole Master Configuration File
|
||||
; Generated by install.py on 2026-02-20 13:20:27
|
||||
; This file is used as input for Ansible deployment and k8s cluster creation.
|
||||
|
||||
[User]
|
||||
; User-editable values; derived values below reference these by default.
|
||||
NAMESPACE = prole-db-a0001
|
||||
PROLE_CONF = ${PROLE_HOME}/conf
|
||||
PROLE_DATA = /Users/chrisfu/.prole/data
|
||||
@ -8,6 +13,7 @@ PROLE_SERVICE = ${PROLE_HOME}/etc
|
||||
SERVICE_NAMESPACE = default
|
||||
|
||||
[Inputs]
|
||||
; Screen-scoped inputs used for unattended replays (-S)
|
||||
build.deploy_env = Dev
|
||||
build.run_build = false
|
||||
dependencies.ansible.install = true
|
||||
@ -30,18 +36,18 @@ init_cluster.at_rest_encryption_enabled = true
|
||||
init_cluster.cluster_env = dev
|
||||
init_cluster.deployment_target = prole-dev-cluster
|
||||
init_cluster.k3s_server_url = https://myrddin.prole.org:6443
|
||||
init_cluster.k3s_token = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b
|
||||
init_cluster.k3s_token = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==}
|
||||
init_cluster.kerberos_enabled = true
|
||||
init_cluster.mode = k3d
|
||||
init_cluster.start_cluster = true
|
||||
init_cluster.supabase_enabled = false
|
||||
init_cluster.supabase_enabled = true
|
||||
init_cnpg_deploy.force_rollout = false
|
||||
init_cnpg_deploy.run_deploy = true
|
||||
init_db_build.run_build = true
|
||||
init_password.db_host_port = 5432
|
||||
init_password.db_password = ${OPENBAO:kv/prole/prole-db-a0001/db#password}
|
||||
init_password.db_password_confirm = ${OPENBAO:kv/prole/prole-db-a0001/db#password}
|
||||
init_password.db_username = root
|
||||
init_password.db_username = chrisfu
|
||||
init_password.generate_ssh_key = true
|
||||
init_scripts.run_scripts = true
|
||||
kerberos_config.enabled = true
|
||||
@ -56,8 +62,7 @@ ollama_config.server_host =
|
||||
ollama_config.server_port = 11434
|
||||
|
||||
[Global]
|
||||
CNPG_VERSION = 1.28.1
|
||||
CNPG_RELEASE_SERIES = 1.28
|
||||
; Variables used by name in more than one place or assumed global scope
|
||||
CLUSTER_ENV = dev
|
||||
DB_HOST_PORT = 5432
|
||||
DB_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/db#password}
|
||||
@ -65,16 +70,18 @@ DEPLOYMENT_MODE = k3d
|
||||
DEPLOYMENT_TARGET = prole-dev-cluster
|
||||
DOCKER_IMPORT_DIR =
|
||||
NAMESPACE = ${NAMESPACE}
|
||||
PROLE_DB_USER = root
|
||||
PROLE_DB_USER = chrisfu
|
||||
PROLE_HOME = ${PROLE_HOME}
|
||||
PROLE_K3S_SERVER = https://myrddin.prole.org:6443
|
||||
PROLE_K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b
|
||||
PROLE_K3S_TOKEN = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==}
|
||||
PROLE_OPENTOFU_URL = http://127.0.0.1:8080
|
||||
SERVICE_NAMESPACE = default
|
||||
|
||||
[Welcome]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Dependencies]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Network]
|
||||
AD_DC_HOST = myrddin.prole.org
|
||||
@ -89,6 +96,7 @@ KDC_AUTO_DETECTED = 10.0.0.3
|
||||
KERBEROS_AUTO_ENABLED = True
|
||||
|
||||
[Port Forwards]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[System Environment]
|
||||
PROLE_CONF = ${PROLE_CONF}
|
||||
@ -104,12 +112,19 @@ GRAFANA_ADMIN_PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/monitoring#grafana_ad
|
||||
PASSWORD = ${OPENBAO:kv/prole/prole-db-a0001/kerberos#password}
|
||||
|
||||
[Ollama]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Optional Features]
|
||||
AT_REST_ENCRYPTION_ENABLED = True
|
||||
KERBEROS_ENABLED = True
|
||||
SUPABASE_ENABLED = False
|
||||
|
||||
[Database Creation]
|
||||
|
||||
[Initialize Cluster]
|
||||
ENVIRONMENT = prole-dev-cluster
|
||||
K3S_SERVER_URL = https://myrddin.prole.org:6443
|
||||
K3S_TOKEN = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==}
|
||||
|
||||
[Dev Cluster (k3d)]
|
||||
CLUSTER_ENV = dev
|
||||
@ -121,7 +136,7 @@ MODE = k3d
|
||||
CLUSTER_ENV = prole-service-cluster
|
||||
DISPLAY_NAME = prole-service-cluster
|
||||
K3S_SERVER_URL = https://myrddin.prole.org:6443
|
||||
K3S_TOKEN = K107c8c6000488eca4a067d8a73119bbae2f07b4ea1bac7d8d3dc9c500cbb8acb18::server:04572345810eae2f9619a6ed4239702b
|
||||
K3S_TOKEN = ${PROLE_SECRET:v1:BLZSfsTq5mmvcWPO:N2Ac-c5wu4Y3WWrJEKnkxmCLItzg_1v2vTB3vXVcsPPAZtZqAA4GthApseZFo2I34kHfRjGwIFBeQ90FBUUAT9xaqe3JZYhInHrewLoO1HBFUyb0hj0MjLckffhr8-X1sYDt5PO_6_77fg_lQL86eu0OgBoyLYDrq9jc4w==}
|
||||
MODE = k3s
|
||||
PIPELINE_URL = http://127.0.0.1:8080
|
||||
|
||||
@ -133,12 +148,15 @@ MODE = k8s
|
||||
PIPELINE_URL = http://127.0.0.1:8080
|
||||
|
||||
[Docker Build]
|
||||
LOCAL_REGISTRY = localhost:5000
|
||||
LOCAL_REGISTRY_INTERNAL = k3d-prole-registry.localhost:5000
|
||||
|
||||
[Initialization Scripts]
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
[Deployment]
|
||||
MODE = k3d
|
||||
TARGET = prole-dev-cluster
|
||||
|
||||
[Install]
|
||||
|
||||
; No configuration values captured yet for this section.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
10368
installer/ui/screens.py
10368
installer/ui/screens.py
File diff suppressed because it is too large
Load Diff
638
installer/ui/screens/__init__.py
Normal file
638
installer/ui/screens/__init__.py
Normal file
@ -0,0 +1,638 @@
|
||||
"""
|
||||
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()
|
||||
225
installer/ui/screens/base.py
Normal file
225
installer/ui/screens/base.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""Common UI rendering helpers shared across all screens."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer.screen import TerminalConsole
|
||||
from installer import screen as ui
|
||||
|
||||
|
||||
class ScreenBaseMixin:
|
||||
"""Common UI rendering helpers shared across all screens."""
|
||||
|
||||
def _render_title(self, text, y=40):
|
||||
ui.render_title(self, text, y)
|
||||
|
||||
def _render_paragraph(self, text, y, wrap=600):
|
||||
ui.render_paragraph(self, text, y, wrap)
|
||||
|
||||
def _create_console_output(self, y=280, title="Scan Output", width=880, height=450):
|
||||
"""Create a standardized console output area with a label and scrollable text."""
|
||||
# Section title/label for the console
|
||||
ui.canvas_text(self, 48, y, title, fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))
|
||||
|
||||
y_console = y + 30
|
||||
# Use a background frame to ensure NO borders are visible around the console
|
||||
console_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0)
|
||||
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
|
||||
console.pack(fill='both', expand=True, padx=1, pady=1)
|
||||
|
||||
console_window = self.bg_canvas.create_window(48, y_console, window=console_bg,
|
||||
anchor='nw', width=width, height=height)
|
||||
self._canvas_items.append(console_window)
|
||||
self._overlay_widgets.append(console_bg)
|
||||
self._overlay_widgets.append(console)
|
||||
return console
|
||||
|
||||
def safe_after(self, func, delay=0):
|
||||
"""Run a function in the main thread if the root window still exists."""
|
||||
if not self.root or not self.root.winfo_exists():
|
||||
return
|
||||
|
||||
def wrapper():
|
||||
try:
|
||||
if self.root and self.root.winfo_exists():
|
||||
func()
|
||||
except (tk.TclError, RuntimeError):
|
||||
pass
|
||||
try:
|
||||
self.root.after(delay, wrapper)
|
||||
except (tk.TclError, RuntimeError):
|
||||
pass
|
||||
|
||||
def _clear_canvas_page(self):
|
||||
# Unbind common events to prevent "echo" or persistent behavior from previous pages
|
||||
try:
|
||||
self.bg_canvas.unbind('<Button-1>')
|
||||
self.root.unbind('<Return>')
|
||||
self.root.unbind('<KP_Enter>')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._stop_k3s_service_status_updates()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Reset slide_area background to white for safety
|
||||
try:
|
||||
self.slide_area.configure(bg='white')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove any small overlay widgets from the previous page
|
||||
try:
|
||||
if getattr(self, '_overlay_widgets', None):
|
||||
for w in list(self._overlay_widgets):
|
||||
try:
|
||||
w.destroy()
|
||||
except Exception:
|
||||
try:
|
||||
w.place_forget()
|
||||
except Exception:
|
||||
pass
|
||||
self._overlay_widgets.clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Clear page-specific canvas drawings (keep the background image)
|
||||
if self._canvas_items:
|
||||
for item in self._canvas_items:
|
||||
try:
|
||||
self.bg_canvas.delete(item)
|
||||
except Exception:
|
||||
pass
|
||||
self._canvas_items.clear()
|
||||
|
||||
# ---------------- Environment setup helpers ----------------
|
||||
def _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 show_page(self, index_or_id):
|
||||
self._showing_service_overlay = False
|
||||
# Resolve index and page_id
|
||||
old_idx = self.page_index
|
||||
if isinstance(index_or_id, int):
|
||||
idx = max(0, min(index_or_id, len(self.pages) - 1))
|
||||
else:
|
||||
# Find index by page_id
|
||||
idx = -1
|
||||
for i, (pid, _) in enumerate(self.pages):
|
||||
if pid == index_or_id:
|
||||
idx = i
|
||||
break
|
||||
|
||||
if idx == -1:
|
||||
msg = f"Navigation error: page ID '{index_or_id}' not found."
|
||||
print(f"[ERROR] {msg}")
|
||||
try:
|
||||
messagebox.showerror("Navigation Error", msg)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
print(f"[DEBUG] Navigating from {old_idx} to {idx} (requested: {index_or_id})")
|
||||
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', 'disk_selection', 'build_summary',
|
||||
'supabase_config', 'ollama_config', 'create_installer'
|
||||
)
|
||||
if pid in canvas_only_pages or pid.startswith('dep_'):
|
||||
self.slide_area.place_forget()
|
||||
else:
|
||||
self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1)
|
||||
self.slide_area.lift()
|
||||
|
||||
# Render the page directly on the canvas if we have a renderer
|
||||
if pid in self.canvas_renderers:
|
||||
try:
|
||||
self.canvas_renderers[pid]()
|
||||
except Exception as e:
|
||||
# Fallback: show an error message on canvas
|
||||
ui.canvas_text(self, 32, 32, f"Error rendering page '{pid}': {e}", fill='black', font=('SF Pro Text', 11))
|
||||
elif frame is not None:
|
||||
# Legacy fallback (should not be used)
|
||||
frame.place(relx=0.5, rely=0.5, anchor='center', relwidth=0.94, relheight=0.9)
|
||||
self.update_footer()
|
||||
|
||||
# ---------------- Canvas page rendering ----------------
|
||||
def show_screen(self, screen_id):
|
||||
self.show_page(screen_id)
|
||||
|
||||
def _run_cmd_capture(self, cmd, env=None, stdin_text=None, timeout=None):
|
||||
output_lines = []
|
||||
try:
|
||||
# Ensure PYTHONUNBUFFERED=1
|
||||
run_env = (env or os.environ).copy()
|
||||
run_env['PYTHONUNBUFFERED'] = '1'
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE if stdin_text is not None else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=run_env
|
||||
)
|
||||
except Exception as e:
|
||||
return 1, f"Failed to start command: {e}"
|
||||
|
||||
if stdin_text is not None and proc.stdin:
|
||||
try:
|
||||
proc.stdin.write(stdin_text)
|
||||
proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
output_lines.append(line)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
rc = proc.wait(timeout=timeout)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
rc = 1
|
||||
return rc, "".join(output_lines)
|
||||
|
||||
def _parse_rfc3339(self, ts: str):
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
if ts.endswith('Z'):
|
||||
ts = ts[:-1] + '+00:00'
|
||||
return datetime.fromisoformat(ts)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _format_pod_time(self, ts: str) -> str:
|
||||
dt = self._parse_rfc3339(ts)
|
||||
if not dt:
|
||||
return ts or '—'
|
||||
try:
|
||||
return dt.astimezone().strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
726
installer/ui/screens/build.py
Normal file
726
installer/ui/screens/build.py
Normal file
@ -0,0 +1,726 @@
|
||||
"""Build screen, terminal console overlay and process management."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer.screen import TerminalConsole
|
||||
from installer import deploy as inst_deploy
|
||||
from installer.build import get_build_command as inst_get_build_command
|
||||
from installer import screen as ui
|
||||
from installer.core.env import PROJECT_ROOT
|
||||
|
||||
|
||||
class BuildScreenMixin:
|
||||
"""Build screen, terminal console overlay and process management."""
|
||||
|
||||
def _render_build_page(self):
|
||||
# Letterhead at top right
|
||||
content_width = self.bg_canvas.winfo_width() or 975
|
||||
right_margin = content_width - 48
|
||||
|
||||
ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne')
|
||||
ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73',
|
||||
font=('SF Pro Text', 18), anchor='ne')
|
||||
|
||||
# Shifted up to accommodate radios and standard console position
|
||||
self._render_title('Build Prole Tools.app', y=80)
|
||||
self._render_paragraph('Build and prepare Prole Tools.app for deployment.', y=130)
|
||||
|
||||
# Canvas-drawn radio buttons (no ttk widgets to avoid grey/white boxes)
|
||||
if not hasattr(self, 'deploy_env_value'):
|
||||
self.deploy_env_value = 'Dev'
|
||||
|
||||
radio_y = 180
|
||||
left = 56
|
||||
spacing = 150
|
||||
|
||||
# Draw three radio options
|
||||
self._build_radio_items = []
|
||||
options = [('Dev', left), ('Service', left + spacing), ('Prod', left + spacing * 2)]
|
||||
for label, x in options:
|
||||
# outer circle
|
||||
r = 10
|
||||
circle = ui.canvas_oval(self, x, radio_y, x + 2*r, radio_y + 2*r, outline='black', width=2)
|
||||
self._canvas_items.append(circle)
|
||||
# selected dot
|
||||
if self.deploy_env_value == label:
|
||||
dot = ui.canvas_oval(self, x+5, radio_y+5, x+2*r-5, radio_y+2*r-5, fill='black', outline='')
|
||||
self._canvas_items.append(dot)
|
||||
text = ui.canvas_text(self, x + 2*r + 10, radio_y - 2, label, fill='black', font=('SF Pro Text', 12))
|
||||
self._canvas_items.append(text)
|
||||
self._build_radio_items.append((label, circle, text))
|
||||
|
||||
# Click handling for radio selection
|
||||
def _on_click(event):
|
||||
ex, ey = event.x, event.y
|
||||
for label, circle, text in self._build_radio_items:
|
||||
bbox_c = self.bg_canvas.bbox(circle)
|
||||
bbox_t = self.bg_canvas.bbox(text)
|
||||
hit = False
|
||||
if bbox_c and bbox_c[0] <= ex <= bbox_c[2] and bbox_c[1] <= ey <= bbox_c[3]:
|
||||
hit = True
|
||||
if bbox_t and bbox_t[0] <= ex <= bbox_t[2] and bbox_t[1] <= ey <= bbox_t[3]:
|
||||
hit = True
|
||||
if hit:
|
||||
self.deploy_env_value = label
|
||||
# Re-render only radios by re-drawing the page
|
||||
self._clear_canvas_page()
|
||||
self._render_build_page()
|
||||
self.update_footer()
|
||||
break
|
||||
self.bg_canvas.bind('<Button-1>', _on_click)
|
||||
|
||||
# Output Console (Consistent size and location: y=260, width=900, height=520)
|
||||
self._build_console = self._create_console_output(y=230, title="Build Output", width=900, height=550)
|
||||
self._console_text = self._build_console.text # For compatibility with _append_console and others
|
||||
|
||||
# Show a command preview with PS1-style prompt and blinking cursor
|
||||
preview = self._compose_build_preview()
|
||||
self._console_set_preview(preview)
|
||||
|
||||
# Ensure state holders exist
|
||||
if not hasattr(self, 'last_build_log_path'):
|
||||
self.last_build_log_path = None
|
||||
# Update Next button label/state
|
||||
self.update_footer()
|
||||
# Bind Enter to trigger Build on this page
|
||||
def _enter_build(_evt=None):
|
||||
self.perform_build()
|
||||
try:
|
||||
self.root.bind('<Return>', _enter_build)
|
||||
self.root.bind('<KP_Enter>', _enter_build)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _create_page_build(self):
|
||||
f = self._page_container()
|
||||
ttk.Label(f, text='Build', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
||||
ttk.Label(f, text='Choose a target and build the artifacts.', style='Body.TLabel').pack(anchor='w', padx=24)
|
||||
wrap = ttk.Frame(f)
|
||||
wrap.pack(anchor='w', padx=24, pady=12)
|
||||
ttk.Label(wrap, text='Target Environment:', style='Body.TLabel').pack(side='left')
|
||||
self.deploy_environment = tk.StringVar(value='Dev')
|
||||
ttk.Combobox(wrap, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18).pack(side='left', padx=10)
|
||||
|
||||
# Build output console
|
||||
# Use a background frame to ensure NO borders are visible around the console
|
||||
console_bg = tk.Frame(f, bg='white', highlightthickness=0, bd=0)
|
||||
console_bg.pack(fill='both', expand=True, padx=24, pady=12)
|
||||
self.build_output_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
|
||||
self.build_output_console.pack(fill='both', expand=True, padx=1, pady=1)
|
||||
self.build_output = self.build_output_console.text
|
||||
|
||||
# self._register_page('build', f)
|
||||
|
||||
def perform_build(self):
|
||||
"""Run Prole build in an embedded console (Scoped bash subprocess)."""
|
||||
self._action_flags['build.run_build'] = True
|
||||
# Ensure environment exists and is readable before attempting build
|
||||
try:
|
||||
self.ensure_prole_env()
|
||||
except Exception as e:
|
||||
# Print a readable error to the console area and abort
|
||||
try:
|
||||
self._console_press_enter()
|
||||
except Exception:
|
||||
pass
|
||||
err_msg = str(e).replace("'", "'\\''")
|
||||
err = f"echo 'ERROR: {err_msg}' && exit 1"
|
||||
self._run_in_console(err, None, on_complete=lambda rc: None)
|
||||
return
|
||||
# Prepare logs dir and file
|
||||
try:
|
||||
self._build_attempted = True
|
||||
except Exception:
|
||||
pass
|
||||
# reset success flag until proven otherwise
|
||||
try:
|
||||
self._built_success = False
|
||||
except Exception:
|
||||
pass
|
||||
logs_dir = self._resolve_prole_logs_dir()
|
||||
try:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
ts = time.strftime('%Y%m%d-%H%M%S')
|
||||
log_path = logs_dir / f'build-{ts}.log'
|
||||
self.last_build_log_path = str(log_path)
|
||||
self._record_install_log(log_path)
|
||||
|
||||
# Compose build command
|
||||
env = getattr(self, 'deploy_env_value', 'Dev')
|
||||
base_cmd = self.get_build_command(env)
|
||||
# Hostname safety guard: ensure commands run only on the machine that launched the installer
|
||||
guard = ''
|
||||
if getattr(self, 'expected_host', None):
|
||||
eh = self.expected_host
|
||||
guard = f'host=$(hostname -s); if [ "$host" != "{eh}" ]; then echo "ERROR: wrong host $host (expected {eh})"; exit 1; fi; '
|
||||
# Simulate pressing Enter on the previewed command; do not echo a duplicate command
|
||||
self._console_press_enter()
|
||||
# Compose a verbose wrapped build command with environment diagnostics and tracing
|
||||
full_cmd = guard + self._compose_verbose_build_command(base_cmd)
|
||||
|
||||
# Run in embedded console
|
||||
self._run_in_console(full_cmd, self.last_build_log_path, on_complete=lambda rc: self._on_build_complete(rc))
|
||||
|
||||
def _compose_verbose_build_command(self, base_cmd: str) -> str:
|
||||
"""Wrap the provided build command with a verbose, diagnostic-rich shell script.
|
||||
|
||||
Adds:
|
||||
- Timestamps and section headers
|
||||
- Platform/OS/tooling info (uname, macOS version, Xcode/Swift, Java, Maven, Docker, Git)
|
||||
- set -euxo pipefail for tracing and early failure
|
||||
- Echo of the actual build command
|
||||
"""
|
||||
# Use portable bash; guard external tool probes to avoid hard failures
|
||||
prologue = r'''
|
||||
echo "====[PROLE] Build started $(date '+%Y-%m-%d %H:%M:%S %Z')";
|
||||
echo "---- System -------------------------------------------------------";
|
||||
uname -a || true;
|
||||
printf "ARCH=%s\n" "$(uname -m)" || true;
|
||||
if command -v sw_vers >/dev/null 2>&1; then sw_vers || true; fi;
|
||||
echo "---- Tooling ------------------------------------------------------";
|
||||
if command -v xcodebuild >/dev/null 2>&1; then xcodebuild -version || true; else echo "xcodebuild: not found"; fi;
|
||||
if command -v swift >/dev/null 2>&1; then swift --version || true; else echo "swift: not found"; fi;
|
||||
if command -v java >/dev/null 2>&1; then java -version 2>&1 | sed 's/^/java: /'; else echo "java: not found"; fi;
|
||||
if command -v mvn >/dev/null 2>&1; then mvn -v || true; else echo "mvn: not found"; fi;
|
||||
if command -v docker >/dev/null 2>&1; then docker version || true; else echo "docker: not found"; fi;
|
||||
echo "---- Git ----------------------------------------------------------";
|
||||
if command -v git >/dev/null 2>&1; then \
|
||||
(git -C "$(pwd)" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \
|
||||
echo "repo: $(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")" && \
|
||||
echo "branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null)" && \
|
||||
echo "commit: $(git rev-parse --short HEAD 2>/dev/null)" && \
|
||||
git status --porcelain=v1 | sed 's/^/ /' || true) || echo "not a git repo"; \
|
||||
else echo "git: not found"; fi;
|
||||
echo "-------------------------------------------------------------------";
|
||||
'''
|
||||
|
||||
# The actual build with tracing and timing
|
||||
wrapped = f"""
|
||||
(
|
||||
set -euxo pipefail
|
||||
{prologue}
|
||||
echo "====[PROLE] Executing build command:";
|
||||
printf '%s\n' {shlex.quote(base_cmd)};
|
||||
echo "-------------------------------------------------------------------";
|
||||
start_ts=$(date +%s || echo 0);
|
||||
{base_cmd}
|
||||
rc=$?
|
||||
end_ts=$(date +%s || echo 0);
|
||||
dur=$((end_ts - start_ts));
|
||||
echo "-------------------------------------------------------------------";
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "====[PROLE] Build finished OK in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')";
|
||||
else
|
||||
echo "====[PROLE] Build FAILED (rc=$rc) in ${{dur}}s at $(date '+%Y-%m-%d %H:%M:%S %Z')";
|
||||
fi
|
||||
exit $rc
|
||||
)
|
||||
"""
|
||||
return wrapped
|
||||
|
||||
def _on_build_complete(self, returncode: int):
|
||||
# After build completes, update state and check for removable disks
|
||||
try:
|
||||
self._built_success = (returncode == 0)
|
||||
except Exception:
|
||||
self._built_success = False
|
||||
# Re-enable Next button
|
||||
try:
|
||||
self.next_button.configure(state='normal')
|
||||
except Exception:
|
||||
pass
|
||||
# Navigate to disk_selection if removable disks exist, otherwise build_summary
|
||||
if getattr(self, '_built_success', False):
|
||||
disks = self.get_removable_disks()
|
||||
if disks:
|
||||
self.show_page('disk_selection')
|
||||
else:
|
||||
self.show_page('build_summary')
|
||||
else:
|
||||
# Build failed, stay on build page
|
||||
pass
|
||||
|
||||
# ---------------- Embedded console helpers ----------------
|
||||
def _ensure_console_overlay(self, radio_bottom_y: int = 160):
|
||||
"""Create semi-transparent 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
|
||||
|
||||
# Use a background frame to ensure NO borders are visible around the console
|
||||
console_bg = tk.Frame(self.slide_area, bg='white', highlightthickness=0, bd=0)
|
||||
console_bg.place(x=left, y=top, width=width, height=height)
|
||||
|
||||
# Create TerminalConsole overlay
|
||||
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
|
||||
console.pack(fill='both', expand=True, padx=1, pady=1)
|
||||
self._overlay_widgets.append(console_bg)
|
||||
self._overlay_widgets.append(console)
|
||||
self._console_text = console.text
|
||||
|
||||
# Keep overlay positioned on resize
|
||||
def _on_resize(_evt=None):
|
||||
l, t, w, h = self._compute_console_geometry(radio_bottom_y)
|
||||
try:
|
||||
console_bg.place(x=l, y=t, width=w, height=h)
|
||||
except Exception:
|
||||
pass
|
||||
# Bind to slide area; store bind id to unbind later
|
||||
self._overlay_bind_id = self.slide_area.bind('<Configure>', _on_resize)
|
||||
|
||||
def _compute_console_geometry(self, radio_bottom_y: int) -> tuple[int, int, int, int]:
|
||||
"""Return (left, top, width, height) for the console overlay area."""
|
||||
try:
|
||||
w = self.slide_area.winfo_width()
|
||||
h = self.slide_area.winfo_height()
|
||||
except Exception:
|
||||
w, h = 1300 * 0.75, 910 - 64 # Content area size approx
|
||||
margin = 24
|
||||
top = max(radio_bottom_y + 10, 120)
|
||||
bottom = max(top + 180, h - 16) # ensure some height
|
||||
height = max(160, bottom - top - 80) if bottom - top > 260 else max(140, h - top - 24)
|
||||
# Recompute bottom based on height
|
||||
bottom = min(h - 24, top + height)
|
||||
left = margin
|
||||
width = max(300, w - 2 * margin)
|
||||
return (left, top, width, bottom - top)
|
||||
|
||||
def _append_console(self, text: str):
|
||||
txt = getattr(self, '_console_text', None)
|
||||
if not txt:
|
||||
return
|
||||
try:
|
||||
if not txt.winfo_exists():
|
||||
return
|
||||
txt.configure(state='normal')
|
||||
txt.insert('end', text)
|
||||
txt.see('end')
|
||||
txt.configure(state='disabled')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _console_press_enter(self):
|
||||
"""Simulate pressing Enter on the console preview line: remove blinking cursor if present and add a newline."""
|
||||
txt = getattr(self, '_console_text', None)
|
||||
if not txt:
|
||||
return
|
||||
# Stop cursor blinking
|
||||
if getattr(self, '_cursor_blink_after_id', None):
|
||||
try:
|
||||
self.root.after_cancel(self._cursor_blink_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._cursor_blink_after_id = None
|
||||
self._cursor_blink_visible = False
|
||||
try:
|
||||
txt.configure(state='normal')
|
||||
# If last char is our fake cursor, remove it
|
||||
try:
|
||||
last_char = txt.get('end-2c', 'end-1c')
|
||||
if last_char in ('_', '|'):
|
||||
txt.delete('end-2c', 'end-1c')
|
||||
except Exception:
|
||||
pass
|
||||
txt.insert('end', '\n')
|
||||
txt.see('end')
|
||||
txt.configure(state='disabled')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ----- Command preview & blinking cursor helpers -----
|
||||
def _get_user_host(self) -> tuple[str, str]:
|
||||
try:
|
||||
user = os.environ.get('USER') or os.getlogin()
|
||||
except Exception:
|
||||
user = 'user'
|
||||
host = getattr(self, 'expected_host', None) or (platform.node() or 'host').split('.')[0]
|
||||
return user, host
|
||||
|
||||
def _compose_build_preview(self) -> str:
|
||||
env = getattr(self, 'deploy_env_value', 'Dev')
|
||||
cmd = self.get_build_command(env)
|
||||
user, host = self._get_user_host()
|
||||
return f"[{user}@{host}]# {cmd}"
|
||||
|
||||
def _console_set_preview(self, line: str):
|
||||
"""Clear console and show a single-line preview with blinking cursor."""
|
||||
txt = getattr(self, '_console_text', None)
|
||||
if not txt:
|
||||
return
|
||||
# Stop any previous blinking first
|
||||
if getattr(self, '_cursor_blink_after_id', None):
|
||||
try:
|
||||
self.root.after_cancel(self._cursor_blink_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._cursor_blink_after_id = None
|
||||
self._cursor_blink_visible = False
|
||||
try:
|
||||
txt.configure(state='normal')
|
||||
txt.delete('1.0', 'end')
|
||||
txt.insert('end', line)
|
||||
txt.see('end')
|
||||
txt.configure(state='disabled')
|
||||
except Exception:
|
||||
return
|
||||
|
||||
# Start blinking cursor at end of line
|
||||
def blink():
|
||||
t = getattr(self, '_console_text', None)
|
||||
if t is None:
|
||||
self._cursor_blink_after_id = None
|
||||
return
|
||||
try:
|
||||
t.configure(state='normal')
|
||||
# Remove existing cursor
|
||||
if self._cursor_blink_visible:
|
||||
# Delete last character if it's our cursor
|
||||
end_index = t.index('end-1c')
|
||||
if end_index and end_index != '1.0':
|
||||
last_char = t.get('end-2c', 'end-1c')
|
||||
if last_char in ('_', '|'):
|
||||
t.delete('end-2c', 'end-1c')
|
||||
self._cursor_blink_visible = False
|
||||
else:
|
||||
# Append cursor
|
||||
t.insert('end', '_')
|
||||
self._cursor_blink_visible = True
|
||||
t.see('end')
|
||||
t.configure(state='disabled')
|
||||
except Exception:
|
||||
self._cursor_blink_after_id = None
|
||||
return
|
||||
# schedule next toggle
|
||||
self._cursor_blink_after_id = self.root.after(600, blink)
|
||||
|
||||
self._cursor_blink_after_id = self.root.after(600, blink)
|
||||
|
||||
def _run_in_console(self, command: str, log_path: str, on_complete=None):
|
||||
"""Run a bash -lc command in a background subprocess and stream output to the console and a log file.
|
||||
This function does not echo the command into the console so that the UI behaves like pressing Enter
|
||||
on the previously previewed command line.
|
||||
"""
|
||||
# Ensure console exists
|
||||
if not getattr(self, '_console_text', None):
|
||||
self._ensure_console_overlay(160)
|
||||
# Stop cursor blinking before starting execution
|
||||
if getattr(self, '_cursor_blink_after_id', None):
|
||||
try:
|
||||
self.root.after_cancel(self._cursor_blink_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._cursor_blink_after_id = None
|
||||
self._cursor_blink_visible = False
|
||||
# Terminate any previous process
|
||||
if getattr(self, '_running_process', None):
|
||||
self._terminate_running_process()
|
||||
# Open log file
|
||||
try:
|
||||
self._console_log_fp = open(log_path, 'a', buffering=1, encoding='utf-8')
|
||||
except Exception:
|
||||
self._console_log_fp = None
|
||||
# Start process group for safe termination
|
||||
def preexec():
|
||||
try:
|
||||
os.setsid()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc = subprocess.Popen(['bash', '-lc', command], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, preexec_fn=preexec)
|
||||
self._running_process = proc
|
||||
except Exception as e:
|
||||
self._append_console(f"Failed to start process: {e}\n")
|
||||
if self._console_log_fp:
|
||||
try:
|
||||
self._console_log_fp.write(f"Failed to start process: {e}\n")
|
||||
except Exception:
|
||||
pass
|
||||
self._running_process = None
|
||||
return
|
||||
# Disable Next while running
|
||||
try:
|
||||
self.next_button.configure(text='Building…', state='disabled')
|
||||
except Exception:
|
||||
pass
|
||||
# Reader thread
|
||||
def reader():
|
||||
rc = None
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
if line is None:
|
||||
break
|
||||
self.safe_after(lambda s=line: self._append_console(s))
|
||||
if self._console_log_fp:
|
||||
try:
|
||||
self._console_log_fp.write(line)
|
||||
except Exception:
|
||||
pass
|
||||
rc = proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if self._console_log_fp:
|
||||
try:
|
||||
self._console_log_fp.flush()
|
||||
self._console_log_fp.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._console_log_fp = None
|
||||
self._running_process = None
|
||||
if on_complete:
|
||||
self.safe_after(lambda: on_complete(rc if rc is not None else -1))
|
||||
t = threading.Thread(target=reader, daemon=True)
|
||||
t.start()
|
||||
|
||||
def _terminate_running_process(self):
|
||||
proc = getattr(self, '_running_process', None)
|
||||
if not proc:
|
||||
return
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except Exception:
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
# best-effort kill after short delay
|
||||
try:
|
||||
for _ in range(10):
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
pgid = os.getpgid(proc.pid)
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._running_process = None
|
||||
|
||||
# ---------------- Build helpers (Terminal window management) ----------------
|
||||
def _terminal_create_new_window(self) -> str | None:
|
||||
"""Create a brand-new Terminal window (never reuse existing) and return its id."""
|
||||
if platform.system() != 'Darwin':
|
||||
return None
|
||||
osa = '''
|
||||
tell application "Terminal" to activate
|
||||
delay 0.05
|
||||
tell application "System Events"
|
||||
if exists process "Terminal" then
|
||||
tell process "Terminal"
|
||||
set frontmost to true
|
||||
try
|
||||
click menu item "New Window" of menu "Shell" of menu bar 1
|
||||
on error
|
||||
keystroke "n" using {command down}
|
||||
end try
|
||||
end tell
|
||||
end if
|
||||
end tell
|
||||
delay 0.1
|
||||
tell application "Terminal"
|
||||
try
|
||||
set _w to front window
|
||||
set _id to id of _w
|
||||
do script "" in _w
|
||||
return _id
|
||||
on error
|
||||
return ""
|
||||
end try
|
||||
end tell
|
||||
'''
|
||||
try:
|
||||
result = subprocess.run(['osascript', '-e', osa], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
sid = result.stdout.strip()
|
||||
return sid or None
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _terminal_set_bounds_by_id(self, win_id: str, l: int, t: int, r: int, b: int):
|
||||
if platform.system() != 'Darwin' or not win_id:
|
||||
return
|
||||
osa = f'''tell application "Terminal" to try
|
||||
set the bounds of every window whose id is {win_id} to {{{l}, {t}, {r}, {b}}}
|
||||
end try'''
|
||||
try:
|
||||
subprocess.run(['osascript', '-e', osa])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _terminal_paste_by_id(self, win_id: str, text: str, press_enter: bool = False):
|
||||
if platform.system() != 'Darwin' or not win_id:
|
||||
return
|
||||
# Put text on clipboard and paste into our specific window
|
||||
try:
|
||||
subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(text)} | pbcopy'])
|
||||
except Exception:
|
||||
pass
|
||||
osa = '''
|
||||
tell application "Terminal"
|
||||
try
|
||||
set _wins to every window whose id is {win_id}
|
||||
if (count of _wins) > 0 then set front window to item 1 of _wins
|
||||
end try
|
||||
activate
|
||||
end tell
|
||||
delay 0.05
|
||||
tell application "System Events"
|
||||
keystroke "v" using {command down}
|
||||
end tell
|
||||
'''
|
||||
if press_enter:
|
||||
osa += '\n' + 'tell application "System Events" to key code 36'
|
||||
try:
|
||||
subprocess.run(['osascript', '-e', osa])
|
||||
except Exception:
|
||||
pass
|
||||
def _compute_terminal_bounds(self, radio_bottom_y: int = 160) -> tuple[int, int, int, int]:
|
||||
"""Compute terminal window bounds (left, top, right, bottom) to fit inside
|
||||
the installer window between the radio buttons and the footer."""
|
||||
try:
|
||||
# Window absolute position
|
||||
x0 = self.root.winfo_rootx()
|
||||
y0 = self.root.winfo_rooty()
|
||||
w = self.root.winfo_width()
|
||||
h = self.root.winfo_height()
|
||||
except Exception:
|
||||
# Reasonable defaults
|
||||
x0, y0, w, h = 200, 200, 1300, 910
|
||||
margin = 24
|
||||
top = y0 + radio_bottom_y + 10
|
||||
bottom = y0 + h - 90 # leave space for footer
|
||||
# Offset left by sidebar width (325) + divider (1)
|
||||
left = x0 + 326 + margin
|
||||
right = x0 + w - margin
|
||||
# Ensure minimum height
|
||||
if bottom - top < 160:
|
||||
bottom = top + 160
|
||||
return (left, top, right, bottom)
|
||||
|
||||
def open_build_terminal_for_canvas_area(self, radio_bottom_y: int = 160):
|
||||
"""Open a brand-new Terminal.app window and size it to nestle inside the installer."""
|
||||
if platform.system() != 'Darwin':
|
||||
return
|
||||
l, t, r, b = self._compute_terminal_bounds(radio_bottom_y)
|
||||
win_id = self._terminal_create_new_window()
|
||||
if win_id:
|
||||
self._terminal_set_bounds_by_id(win_id, l, t, r, b)
|
||||
self.build_terminal_window_id = win_id
|
||||
|
||||
def _start_terminal_follow(self, radio_bottom_y: int = 160):
|
||||
"""Bind window Configure to keep Terminal bounds anchored to installer area."""
|
||||
if platform.system() != 'Darwin':
|
||||
return
|
||||
self._terminal_follow_rby = radio_bottom_y
|
||||
if getattr(self, '_terminal_follow_bound', False):
|
||||
return
|
||||
|
||||
def _follow(_evt=None):
|
||||
# Debounce slightly
|
||||
if getattr(self, '_terminal_follow_after', None):
|
||||
try:
|
||||
self.root.after_cancel(self._terminal_follow_after)
|
||||
except Exception:
|
||||
pass
|
||||
def _do():
|
||||
if not getattr(self, 'build_terminal_window_id', None):
|
||||
return
|
||||
l, t, r, b = self._compute_terminal_bounds(self._terminal_follow_rby)
|
||||
self._terminal_set_bounds_by_id(self.build_terminal_window_id, l, t, r, b)
|
||||
self._terminal_follow_after = self.root.after(60, _do)
|
||||
|
||||
self.root.bind('<Configure>', _follow)
|
||||
self._terminal_follow_bound = True
|
||||
|
||||
def _stop_terminal_follow(self):
|
||||
if getattr(self, '_terminal_follow_bound', False):
|
||||
try:
|
||||
self.root.unbind('<Configure>')
|
||||
except Exception:
|
||||
pass
|
||||
self._terminal_follow_bound = False
|
||||
|
||||
def paste_into_terminal(self, text: str, press_enter: bool = False):
|
||||
"""Paste text into Terminal by targeting the build window id if available."""
|
||||
if platform.system() != 'Darwin':
|
||||
return
|
||||
win_id = getattr(self, 'build_terminal_window_id', None)
|
||||
if win_id:
|
||||
self._terminal_paste_by_id(win_id, text, press_enter=press_enter)
|
||||
return
|
||||
# Fallback to generic new window
|
||||
win_id = self._terminal_create_new_window()
|
||||
if win_id:
|
||||
self._terminal_paste_by_id(win_id, text, press_enter=press_enter)
|
||||
|
||||
def close_build_terminal(self):
|
||||
if platform.system() != 'Darwin':
|
||||
return
|
||||
# stop following window
|
||||
self._stop_terminal_follow()
|
||||
win_id = getattr(self, 'build_terminal_window_id', None)
|
||||
if not win_id:
|
||||
# attempt to close front window politely
|
||||
osa = 'tell application "Terminal" to if (count of windows) > 0 then close front window'
|
||||
else:
|
||||
osa = f'tell application "Terminal" to try\nclose (every window whose id is {win_id})\nend try'
|
||||
try:
|
||||
subprocess.run(['osascript', '-e', osa])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_build_command(self, env: str) -> str:
|
||||
"""Delegate to installer.build.get_build_command."""
|
||||
return inst_get_build_command(PROJECT_ROOT, env)
|
||||
|
||||
# ---------------- Build Summary page ----------------
|
||||
def open_terminal_with_command(self, command: str | None):
|
||||
if not command:
|
||||
return
|
||||
try:
|
||||
# Always create a brand-new Terminal window and paste via clipboard
|
||||
if platform.system() == 'Darwin':
|
||||
win_id = self._terminal_create_new_window()
|
||||
if win_id:
|
||||
# Position reasonably
|
||||
l, t, r, b = self._compute_terminal_bounds(radio_bottom_y=120)
|
||||
self._terminal_set_bounds_by_id(win_id, l, t, r, b)
|
||||
self._terminal_paste_by_id(win_id, command, press_enter=False)
|
||||
return
|
||||
# Fallback: copy to clipboard and open Terminal
|
||||
subprocess.run(['bash', '-lc', f'printf %s {shlex.quote(command)} | pbcopy && open -a Terminal'])
|
||||
except Exception:
|
||||
webbrowser.open_new_tab('https://brew.sh')
|
||||
|
||||
# ---------------- Build integration ----------------
|
||||
def check_xcode_tools(self):
|
||||
"""Check if Xcode Command Line Tools are installed via deploy helper."""
|
||||
return inst_deploy.check_xcode_tools()
|
||||
|
||||
def build_prole_app(self):
|
||||
"""Delegate building the native ProleStatus app to deploy helper."""
|
||||
return inst_deploy.build_prole_app(PROJECT_ROOT)
|
||||
655
installer/ui/screens/cfg.py
Normal file
655
installer/ui/screens/cfg.py
Normal file
@ -0,0 +1,655 @@
|
||||
"""prole.cfg persistence, input snapshot collection and port-forward management."""
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import string
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer.core.env import (
|
||||
DEFAULT_ACTION_FLAGS,
|
||||
DEFAULT_OLLAMA_PORT,
|
||||
PROJECT_ROOT,
|
||||
_bool_str,
|
||||
_build_required_port_forwards,
|
||||
_cluster_env_radio_value,
|
||||
_default_opentofu_pipeline_url,
|
||||
_deployment_mode_from_env,
|
||||
_deployment_target_label,
|
||||
_detect_ansible_topology,
|
||||
_format_ansible_topology_summary,
|
||||
_normalize_k3s_token,
|
||||
_pf_extract_id,
|
||||
_pf_upsert_mapping,
|
||||
_render_prole_cfg,
|
||||
)
|
||||
from installer.config import _collect_cfg_vars, _encrypt_cfg_secret, _expand_cfg_value
|
||||
|
||||
|
||||
class ConfigMixin:
|
||||
"""prole.cfg persistence, input snapshot collection and port-forward management."""
|
||||
|
||||
def _save_prole_cfg(self):
|
||||
"""Generates prole.cfg; master configuration file containing all values used in install process."""
|
||||
try:
|
||||
# Check port availability before saving
|
||||
# try:
|
||||
# port_val = int(self.db_host_port.get().strip())
|
||||
# if not self._is_port_available(port_val):
|
||||
# self._show_port_error(port_val)
|
||||
# return
|
||||
# except ValueError:
|
||||
# messagebox.showerror("Invalid Port", "Please enter a valid numeric port.")
|
||||
# return
|
||||
|
||||
# Determine path: conf/prole.cfg
|
||||
# If PROLE_CONF is set, use it. Otherwise fallback to PROJECT_ROOT/conf
|
||||
if self._cfg_path_override is not None:
|
||||
cfg_path = self._cfg_path_override
|
||||
if cfg_path.is_dir():
|
||||
conf_dir = cfg_path
|
||||
cfg_path = conf_dir / 'prole.cfg'
|
||||
else:
|
||||
conf_dir = cfg_path.parent
|
||||
else:
|
||||
prole_conf = os.environ.get('PROLE_CONF')
|
||||
if prole_conf:
|
||||
conf_dir = Path(prole_conf).expanduser()
|
||||
else:
|
||||
conf_dir = PROJECT_ROOT / 'conf'
|
||||
os.environ.setdefault('PROLE_CONF', str(conf_dir))
|
||||
cfg_path = conf_dir / 'prole.cfg'
|
||||
|
||||
conf_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Identify global candidates
|
||||
mode = self._deployment_mode()
|
||||
target_label = _deployment_target_label(self.cluster_env.get())
|
||||
globals_to_save = {
|
||||
'PROLE_HOME': os.environ.get('PROLE_HOME', ''),
|
||||
'PROLE_DB_USER': self.db_username.get(),
|
||||
'DB_PASSWORD': self._secret_cfg_value('Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password'),
|
||||
'CLUSTER_ENV': self.cluster_env.get(),
|
||||
'DEPLOYMENT_MODE': mode,
|
||||
'DEPLOYMENT_TARGET': target_label,
|
||||
'NAMESPACE': (self.db_namespace.get() or '').strip(),
|
||||
'DB_HOST_PORT': (self.db_host_port.get() or '5432').strip(),
|
||||
'PROLE_OPENTOFU_URL': _default_opentofu_pipeline_url(),
|
||||
}
|
||||
# Add any other values already in self.prole_cfg_data['Global']
|
||||
globals_to_save.update(self.prole_cfg_data.get('Global', {}))
|
||||
globals_to_save['DB_PASSWORD'] = self._secret_cfg_value(
|
||||
'Global', 'DB_PASSWORD', self.db_password.get(), 'db', 'password'
|
||||
)
|
||||
globals_to_save['DEPLOYMENT_MODE'] = mode
|
||||
globals_to_save['DEPLOYMENT_TARGET'] = target_label
|
||||
globals_to_save['PROLE_K3S_SERVER'] = (self.k3s_server_url.get() or '').strip()
|
||||
globals_to_save['PROLE_K3S_TOKEN'] = _encrypt_cfg_secret(self.k3s_token.get() or '')
|
||||
globals_to_save['SERVICE_NAMESPACE'] = self._get_service_namespace()
|
||||
|
||||
self._sync_port_forward_mappings()
|
||||
|
||||
# Inputs section (replayable UI inputs)
|
||||
try:
|
||||
inputs = self._collect_input_snapshot()
|
||||
except Exception:
|
||||
inputs = {}
|
||||
|
||||
sections = {k: self.prole_cfg_data.get(k, {}) for k in [
|
||||
'Welcome', 'Dependencies', 'Network', 'System Environment',
|
||||
'Monitoring', 'Kerberos Authentication', 'Ollama', 'Optional Features', 'Database Creation',
|
||||
'Initialize Cluster', 'Docker Build', 'Initialization Scripts',
|
||||
'Deployment', 'Install'
|
||||
]}
|
||||
deployment_section = dict(sections.get('Deployment', {}))
|
||||
if mode:
|
||||
deployment_section.setdefault('MODE', mode)
|
||||
if target_label:
|
||||
deployment_section.setdefault('TARGET', target_label)
|
||||
sections['Deployment'] = deployment_section
|
||||
|
||||
sections['Dev Cluster (k3d)'] = {
|
||||
**self.prole_cfg_data.get('Dev Cluster (k3d)', {}),
|
||||
'MODE': 'k3d',
|
||||
'CLUSTER_ENV': 'dev',
|
||||
'DISPLAY_NAME': 'prole-dev-cluster',
|
||||
'KUBECTL_CONTEXT': (self.selected_kubectx.get() or '').strip()
|
||||
}
|
||||
sections['Service Cluster (k3s)'] = {
|
||||
**self.prole_cfg_data.get('Service Cluster (k3s)', {}),
|
||||
'MODE': 'k3s',
|
||||
'CLUSTER_ENV': 'prole-service-cluster',
|
||||
'DISPLAY_NAME': 'prole-service-cluster',
|
||||
'K3S_SERVER_URL': (self.k3s_server_url.get() or '').strip(),
|
||||
'K3S_TOKEN': _encrypt_cfg_secret(self.k3s_token.get() or ''),
|
||||
'PIPELINE_URL': _default_opentofu_pipeline_url()
|
||||
}
|
||||
sections['Prod Cluster (k8s)'] = {
|
||||
**self.prole_cfg_data.get('Prod Cluster (k8s)', {}),
|
||||
'MODE': 'k8s',
|
||||
'CLUSTER_ENV': 'prole-prod-cluster',
|
||||
'DISPLAY_NAME': 'prole-prod-cluster',
|
||||
'ARTIFACTS_DIR': (self.prod_artifacts_path.get() or '').strip(),
|
||||
'PIPELINE_URL': _default_opentofu_pipeline_url()
|
||||
}
|
||||
sections = self._sanitize_sections_for_cfg(sections)
|
||||
|
||||
# Sync to Ansible Prole Vault
|
||||
self._save_ansible_prole_vault(self.db_password.get())
|
||||
|
||||
cfg_text = _render_prole_cfg(inputs, globals_to_save, sections)
|
||||
cfg_path.write_text(cfg_text)
|
||||
print(f"[DEBUG] prole.cfg saved to {cfg_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to save prole.cfg: {e}")
|
||||
|
||||
def _collect_input_snapshot(self) -> dict:
|
||||
"""Collect all possible user inputs for prole.cfg replay."""
|
||||
inputs: dict[str, str] = {}
|
||||
|
||||
def _set(key: str, val):
|
||||
inputs[key] = '' if val is None else str(val)
|
||||
|
||||
def _set_bool(key: str, val: bool):
|
||||
inputs[key] = _bool_str(bool(val))
|
||||
|
||||
def _get_var(var, default=''):
|
||||
try:
|
||||
return var.get()
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _action(key: str, default: bool):
|
||||
return bool(self._action_flags.get(key, default))
|
||||
|
||||
# Dependencies
|
||||
_set_bool('dependencies.verify_all', _get_var(self.verify_mode, False))
|
||||
_set_bool('dependencies.auto_install_missing', _action('dependencies.auto_install_missing', DEFAULT_ACTION_FLAGS.get('dependencies.auto_install_missing', True)))
|
||||
for dep in self.dependencies:
|
||||
dep_key = f"dependencies.{dep['id']}.install"
|
||||
_set_bool(dep_key, _action(dep_key, True))
|
||||
|
||||
# Network scan
|
||||
_set_bool('network_scan.run', _action('network_scan.run', DEFAULT_ACTION_FLAGS.get('network_scan.run', True)))
|
||||
|
||||
# Environment setup values
|
||||
env_vals = {}
|
||||
try:
|
||||
env_vals.update(self._env_defaults())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
env_vals.update(self._read_existing_env())
|
||||
except Exception:
|
||||
pass
|
||||
for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
|
||||
if os.environ.get(k):
|
||||
env_vals[k] = os.environ.get(k)
|
||||
if hasattr(self, '_env_entries'):
|
||||
for k, ent in self._env_entries.items():
|
||||
try:
|
||||
v = ent.get().strip()
|
||||
if v:
|
||||
env_vals[k] = v
|
||||
except Exception:
|
||||
pass
|
||||
for k in ('PROLE_HOME', 'PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
|
||||
_set(f'env_setup.{k}', env_vals.get(k, ''))
|
||||
ns_val = (str(_get_var(self.db_namespace, '')).strip()) if hasattr(self, 'db_namespace') else ''
|
||||
if not ns_val:
|
||||
ns_val = env_vals.get('NAMESPACE', '')
|
||||
_set('env_setup.NAMESPACE', ns_val)
|
||||
|
||||
# Database creation
|
||||
_set('init_password.db_namespace', ns_val)
|
||||
_set('init_password.db_username', _get_var(self.db_username, ''))
|
||||
db_pw = _get_var(self.db_password, '')
|
||||
db_pw_confirm = _get_var(self.db_password_confirm, '') or db_pw
|
||||
db_pw_cfg = self._secret_cfg_value('Inputs', 'init_password.db_password', db_pw, 'db', 'password')
|
||||
_set('init_password.db_password', db_pw_cfg)
|
||||
_set('init_password.db_password_confirm', db_pw_cfg or db_pw_confirm)
|
||||
_set('init_password.db_host_port', _get_var(self.db_host_port, '5432'))
|
||||
_set_bool('init_password.generate_ssh_key', _action('init_password.generate_ssh_key', DEFAULT_ACTION_FLAGS.get('init_password.generate_ssh_key', True)))
|
||||
|
||||
# Build DB image
|
||||
_set_bool('init_db_build.run_build', _action('init_db_build.run_build', DEFAULT_ACTION_FLAGS.get('init_db_build.run_build', True)))
|
||||
|
||||
# Cluster init + optional features
|
||||
_set('init_cluster.cluster_env', _get_var(self.cluster_env, 'dev'))
|
||||
_set('init_cluster.mode', _deployment_mode_from_env(_get_var(self.cluster_env, 'dev')))
|
||||
_set('init_cluster.deployment_target', _deployment_target_label(_get_var(self.cluster_env, 'dev')))
|
||||
_set('init_cluster.k3s_server_url', _get_var(self.k3s_server_url, ''))
|
||||
_set('init_cluster.k3s_token', _encrypt_cfg_secret(_get_var(self.k3s_token, '')))
|
||||
_set_bool('init_cluster.supabase_enabled', _get_var(self.supabase_enabled, False))
|
||||
_set_bool('init_cluster.kerberos_enabled', _get_var(self.kerberos_enabled, False))
|
||||
_set_bool('init_cluster.at_rest_encryption_enabled', _get_var(self.at_rest_encryption_enabled, False))
|
||||
_set_bool('init_cluster.start_cluster', _action('init_cluster.start_cluster', DEFAULT_ACTION_FLAGS.get('init_cluster.start_cluster', True)))
|
||||
|
||||
# Kerberos config
|
||||
_set_bool('kerberos_config.enabled', _get_var(self.kerberos_enabled, False))
|
||||
_set('kerberos_config.realm', _get_var(self.kerberos_realm, ''))
|
||||
_set('kerberos_config.kdc', _get_var(self.kerberos_kdc, ''))
|
||||
_set('kerberos_config.user', _get_var(self.kerberos_user, ''))
|
||||
krb_pw = _get_var(self.kerberos_password, '')
|
||||
_set('kerberos_config.password', self._secret_cfg_value('Inputs', 'kerberos_config.password', krb_pw, 'kerberos', 'password'))
|
||||
_set_bool('kerberos_config.test_connection', _action('kerberos_config.test_connection', DEFAULT_ACTION_FLAGS.get('kerberos_config.test_connection', False)))
|
||||
|
||||
# Ollama config
|
||||
_set('ollama_config.server_host', _get_var(self.ollama_server_host, ''))
|
||||
_set('ollama_config.server_port', _get_var(self.ollama_server_port, DEFAULT_OLLAMA_PORT))
|
||||
_set('ollama_config.model', _get_var(self.ollama_model, ''))
|
||||
|
||||
# Init scripts + deploy
|
||||
_set_bool('init_scripts.run_scripts', _action('init_scripts.run_scripts', DEFAULT_ACTION_FLAGS.get('init_scripts.run_scripts', True)))
|
||||
_set_bool('init_cnpg_deploy.run_deploy', _action('init_cnpg_deploy.run_deploy', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.run_deploy', True)))
|
||||
_set_bool('init_cnpg_deploy.force_rollout', _action('init_cnpg_deploy.force_rollout', DEFAULT_ACTION_FLAGS.get('init_cnpg_deploy.force_rollout', False)))
|
||||
|
||||
# Disk selection (installer packaging)
|
||||
_set('disk_selection.disk_type', _get_var(self.selected_disk_type, 'local'))
|
||||
_set('disk_selection.removable_mount', _get_var(self.selected_removable_disk, ''))
|
||||
_set('disk_selection.local_path', _get_var(self.selected_local_path, str(Path.home())))
|
||||
|
||||
# Build tools app
|
||||
_set('build.deploy_env', getattr(self, 'deploy_env_value', 'Dev'))
|
||||
_set_bool('build.run_build', _action('build.run_build', DEFAULT_ACTION_FLAGS.get('build.run_build', False)))
|
||||
|
||||
return inputs
|
||||
|
||||
def _sync_port_forward_mappings(self):
|
||||
try:
|
||||
mode = self._deployment_mode()
|
||||
except Exception:
|
||||
mode = ''
|
||||
if not mode:
|
||||
try:
|
||||
mode = _deployment_mode_from_env(self.cluster_env.get())
|
||||
except Exception:
|
||||
mode = ''
|
||||
if not mode:
|
||||
mode = 'k3d'
|
||||
prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_"
|
||||
|
||||
pf_section = self.prole_cfg_data.get('Port Forwards', {})
|
||||
if pf_section is None:
|
||||
pf_section = {}
|
||||
|
||||
try:
|
||||
service_ns = (self._get_service_namespace() or '').strip()
|
||||
except Exception:
|
||||
service_ns = ''
|
||||
if not service_ns:
|
||||
service_ns = 'default'
|
||||
|
||||
argocd_ns = (self.prole_cfg_data.get('Global', {}) or {}).get('ARGOCD_NAMESPACE', '').strip()
|
||||
if not argocd_ns:
|
||||
argocd_ns = (os.environ.get('ARGOCD_NAMESPACE') or '').strip()
|
||||
if not argocd_ns:
|
||||
argocd_ns = 'argocd'
|
||||
|
||||
try:
|
||||
db_ns = (self.db_namespace.get() or '').strip()
|
||||
except Exception:
|
||||
db_ns = ''
|
||||
if not db_ns:
|
||||
db_ns = (self.prole_cfg_data.get('Global', {}) or {}).get('NAMESPACE', '').strip()
|
||||
if not db_ns:
|
||||
db_ns = (os.environ.get('NAMESPACE') or '').strip()
|
||||
if not db_ns:
|
||||
db_ns = 'default'
|
||||
|
||||
try:
|
||||
db_host_port = (self.db_host_port.get() or '').strip()
|
||||
except Exception:
|
||||
db_host_port = ''
|
||||
if not db_host_port:
|
||||
db_host_port = (self.prole_cfg_data.get('Global', {}) or {}).get('DB_HOST_PORT', '').strip()
|
||||
if not db_host_port:
|
||||
db_host_port = '5432'
|
||||
|
||||
supabase_enabled = False
|
||||
try:
|
||||
supabase_enabled = bool(self.supabase_enabled.get())
|
||||
except Exception:
|
||||
supabase_enabled = False
|
||||
|
||||
supabase_ns = (os.environ.get('SUPABASE_NAMESPACE') or '').strip()
|
||||
if not supabase_ns:
|
||||
supabase_ns = (self.prole_cfg_data.get('Supabase', {}) or {}).get('NAMESPACE', '').strip()
|
||||
if not supabase_ns:
|
||||
supabase_ns = 'supabase'
|
||||
|
||||
mappings = _build_required_port_forwards(
|
||||
mode=mode,
|
||||
service_ns=service_ns,
|
||||
argocd_ns=argocd_ns,
|
||||
db_ns=db_ns,
|
||||
db_host_port=db_host_port,
|
||||
supabase_enabled=supabase_enabled,
|
||||
supabase_namespace=supabase_ns,
|
||||
)
|
||||
for mapping in mappings:
|
||||
_pf_upsert_mapping(pf_section, prefix, mapping)
|
||||
|
||||
self.prole_cfg_data['Port Forwards'] = pf_section
|
||||
self._update_legacy_port_mapping()
|
||||
|
||||
def _update_legacy_port_mapping(self):
|
||||
conf_dir = PROJECT_ROOT / 'conf'
|
||||
mapping_path = conf_dir / 'port-mapping.cfg'
|
||||
|
||||
pf_section = self.prole_cfg_data.get('Port Forwards', {})
|
||||
if pf_section is None:
|
||||
pf_section = {}
|
||||
|
||||
try:
|
||||
mode = self._deployment_mode()
|
||||
except Exception:
|
||||
mode = 'k3d'
|
||||
prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_"
|
||||
|
||||
lines = [
|
||||
"# Port mappings for Prole Tools (generated).",
|
||||
"# Format: key: local=... remote=... ns=... svc=... address=...",
|
||||
""
|
||||
]
|
||||
|
||||
# We want to maintain some order or just dump them
|
||||
for k, v in sorted(pf_section.items()):
|
||||
if k.startswith(prefix):
|
||||
# Parse the mapping string: id=...;namespace=...;target=...;address=...;hostPort=...;servicePort=...;protocol=...;description=...
|
||||
parts = {}
|
||||
for p in v.split(';'):
|
||||
if '=' in p:
|
||||
key_val = p.split('=', 1)
|
||||
if len(key_val) == 2:
|
||||
parts[key_val[0].strip()] = key_val[1].strip()
|
||||
|
||||
if 'id' in parts:
|
||||
m_id = parts['id']
|
||||
local = parts.get('hostPort', '')
|
||||
remote = parts.get('servicePort', '')
|
||||
ns = parts.get('namespace', '')
|
||||
target = parts.get('target', '')
|
||||
addr = parts.get('address', '0.0.0.0')
|
||||
|
||||
svc = target
|
||||
if svc.startswith('svc/'):
|
||||
svc = svc[4:]
|
||||
|
||||
lines.append(f"{m_id}: local={local} remote={remote} ns={ns} svc={svc} address={addr}")
|
||||
|
||||
try:
|
||||
mapping_path.write_text('\n'.join(lines) + '\n')
|
||||
print(f"[DEBUG] port-mapping.cfg updated at {mapping_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to update port-mapping.cfg: {e}")
|
||||
|
||||
def _add_port_mapping(self, mapping_str):
|
||||
mode = self._deployment_mode()
|
||||
prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_"
|
||||
|
||||
pf_section = self.prole_cfg_data.get('Port Forwards', {})
|
||||
if pf_section is None:
|
||||
pf_section = {}
|
||||
|
||||
if not _pf_upsert_mapping(pf_section, prefix, mapping_str):
|
||||
return
|
||||
|
||||
self.prole_cfg_data['Port Forwards'] = pf_section
|
||||
self._save_prole_cfg()
|
||||
|
||||
def _process_script_output_line(self, line):
|
||||
if "GRAFANA_ADMIN_PASSWORD=" in line:
|
||||
pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip()
|
||||
if pwd:
|
||||
mon = self.prole_cfg_data.get('Monitoring', {})
|
||||
if mon is None: mon = {}
|
||||
mon['GRAFANA_ADMIN_PASSWORD'] = pwd
|
||||
self.prole_cfg_data['Monitoring'] = mon
|
||||
self._save_prole_cfg()
|
||||
|
||||
if "PORT_FORWARD_MAPPING:" in line:
|
||||
mapping = line.split("PORT_FORWARD_MAPPING:")[1].strip()
|
||||
if mapping:
|
||||
if _pf_extract_id(mapping) == "registry":
|
||||
return
|
||||
self._add_port_mapping(mapping)
|
||||
|
||||
def _is_port_available(self, port: int) -> bool:
|
||||
"""Check if a port is available on localhost."""
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
try:
|
||||
s.bind(('127.0.0.1', port))
|
||||
return True
|
||||
except socket.error:
|
||||
return False
|
||||
|
||||
def _pf_extract_field(self, mapping_str: str, field: str) -> str:
|
||||
if not mapping_str or not field:
|
||||
return ''
|
||||
needle = f"{field}="
|
||||
for part in str(mapping_str).split(';'):
|
||||
part = part.strip()
|
||||
if part.startswith(needle):
|
||||
return part[len(needle):].strip()
|
||||
return ''
|
||||
|
||||
def _validate_port_forward_overlaps(self) -> bool:
|
||||
try:
|
||||
self._sync_port_forward_mappings()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
mode = self._deployment_mode()
|
||||
except Exception:
|
||||
mode = ''
|
||||
if not mode:
|
||||
try:
|
||||
mode = _deployment_mode_from_env(self.cluster_env.get())
|
||||
except Exception:
|
||||
mode = ''
|
||||
if not mode:
|
||||
mode = 'k3d'
|
||||
|
||||
prefix = "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_"
|
||||
pf_section = self.prole_cfg_data.get('Port Forwards', {}) or {}
|
||||
|
||||
port_index = {}
|
||||
for key, mapping in pf_section.items():
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
host_port = self._pf_extract_field(mapping, 'hostPort')
|
||||
if not host_port:
|
||||
continue
|
||||
mapping_id = _pf_extract_id(mapping)
|
||||
target = self._pf_extract_field(mapping, 'target')
|
||||
namespace = self._pf_extract_field(mapping, 'namespace')
|
||||
label = mapping_id or target or key
|
||||
if namespace and target:
|
||||
label = f"{label} ({namespace}/{target})"
|
||||
port_index.setdefault(host_port, []).append(label)
|
||||
|
||||
collisions = {port: labels for port, labels in port_index.items() if len(labels) > 1}
|
||||
if not collisions:
|
||||
return True
|
||||
|
||||
lines = []
|
||||
for port, labels in sorted(collisions.items(), key=lambda item: int(item[0]) if str(item[0]).isdigit() else item[0]):
|
||||
lines.append(f"{port}: {', '.join(sorted(labels))}")
|
||||
|
||||
try:
|
||||
messagebox.showerror(
|
||||
"Port Collision",
|
||||
"Port forward mappings overlap:\n\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n\nPlease choose unique host ports before continuing."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _show_port_error(self, port: int):
|
||||
"""Show error message when port is in use, with option to see lsof output."""
|
||||
msg = f"Port {port} is already in use on the local host.\n\nPlease choose a different port or stop the service using it."
|
||||
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title("Port In Use")
|
||||
dialog.geometry("450x200")
|
||||
dialog.configure(bg='white')
|
||||
dialog.transient(self.root)
|
||||
dialog.grab_set()
|
||||
|
||||
tk.Label(dialog, text="Port Conflict", font=('SF Pro Text', 14, 'bold'), bg='white', fg='#ff3b30').pack(pady=(20, 10))
|
||||
tk.Label(dialog, text=msg, font=('SF Pro Text', 11), bg='white', fg='black', wraplength=400).pack(pady=10)
|
||||
|
||||
btn_frame = tk.Frame(dialog, bg='white')
|
||||
btn_frame.pack(pady=20)
|
||||
|
||||
def show_lsof():
|
||||
log_file = PROJECT_ROOT / "logs" / f"port_{port}_lsof.log"
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
res = subprocess.run(['lsof', '-i', f':{port}'], capture_output=True, text=True)
|
||||
content = res.stdout if res.stdout else f"No lsof output for port {port}. (Maybe permission denied?)"
|
||||
if res.stderr:
|
||||
content += "\n\nError:\n" + res.stderr
|
||||
log_file.write_text(content)
|
||||
# Open the log file
|
||||
if platform.system() == 'Darwin':
|
||||
subprocess.run(['open', str(log_file)])
|
||||
elif platform.system() == 'Windows':
|
||||
os.startfile(str(log_file))
|
||||
else:
|
||||
subprocess.run(['xdg-open', str(log_file)])
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to run lsof: {e}")
|
||||
|
||||
tk.Button(btn_frame, text="Show Logs (lsof)", command=show_lsof, bg='#F5F5DC', relief='flat', padx=10).pack(side='left', padx=10)
|
||||
tk.Button(btn_frame, text="OK", command=dialog.destroy, bg='#007aff', fg='white', relief='flat', padx=20).pack(side='left', padx=10)
|
||||
|
||||
def _apply_ansible_topology_defaults(self):
|
||||
info = _detect_ansible_topology(PROJECT_ROOT)
|
||||
if not info:
|
||||
return
|
||||
self.ansible_topology = info
|
||||
self.ansible_topology_summary = _format_ansible_topology_summary(info)
|
||||
try:
|
||||
net = self.prole_cfg_data.get('Network', {})
|
||||
if info.get('topology_json'):
|
||||
net['ANSIBLE_TOPOLOGY'] = info['topology_json']
|
||||
if info.get('inventory_path'):
|
||||
net['ANSIBLE_INVENTORY'] = info['inventory_path']
|
||||
if info.get('infrastructure_path'):
|
||||
net['ANSIBLE_INFRASTRUCTURE'] = info['infrastructure_path']
|
||||
if info.get('domain'):
|
||||
net['ANSIBLE_DOMAIN'] = info['domain']
|
||||
if info.get('realm'):
|
||||
net['ANSIBLE_REALM'] = info['realm']
|
||||
if info.get('ad_dc_host'):
|
||||
net['AD_DC_HOST'] = info['ad_dc_host']
|
||||
if info.get('ad_dc_ip'):
|
||||
net['AD_DC_IP'] = info['ad_dc_ip']
|
||||
if info.get('kdc_ip'):
|
||||
net['KDC_ANSIBLE_DETECTED'] = info['kdc_ip']
|
||||
self.prole_cfg_data['Network'] = net
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if not self.kerberos_kdc.get().strip() and info.get('kdc_ip'):
|
||||
self.kerberos_kdc.set(info['kdc_ip'])
|
||||
self.kerberos_enabled.set(True)
|
||||
if not self.kerberos_realm.get().strip() and info.get('realm'):
|
||||
self.kerberos_realm.set(info['realm'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _read_k3s_cfg_values(self) -> tuple[str, str, str]:
|
||||
"""Return (cluster_env, server_url, token) from prole.cfg if present."""
|
||||
cfg_path = None
|
||||
try:
|
||||
if self._cfg_path_override is not None:
|
||||
cfg_path = self._cfg_path_override
|
||||
if cfg_path.is_dir():
|
||||
cfg_path = cfg_path / 'prole.cfg'
|
||||
else:
|
||||
cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg'
|
||||
except Exception:
|
||||
cfg_path = None
|
||||
if not cfg_path or not Path(cfg_path).exists():
|
||||
return '', '', ''
|
||||
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.optionxform = str
|
||||
try:
|
||||
cfg.read(cfg_path)
|
||||
except Exception:
|
||||
return '', '', ''
|
||||
cfg_vars = _collect_cfg_vars(cfg)
|
||||
|
||||
env_val = ''
|
||||
server_val = ''
|
||||
token_val = ''
|
||||
if cfg.has_section('Global'):
|
||||
env_val = _expand_cfg_value(cfg['Global'].get('CLUSTER_ENV', env_val), cfg_vars).strip()
|
||||
server_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_SERVER', server_val), cfg_vars).strip()
|
||||
if not server_val:
|
||||
server_val = _expand_cfg_value(cfg['Global'].get('K3S_SERVER_URL', server_val), cfg_vars).strip()
|
||||
token_val = _expand_cfg_value(cfg['Global'].get('PROLE_K3S_TOKEN', token_val), cfg_vars).strip()
|
||||
if not token_val:
|
||||
token_val = _expand_cfg_value(cfg['Global'].get('K3S_TOKEN', token_val), cfg_vars).strip()
|
||||
if cfg.has_section('Initialize Cluster'):
|
||||
server_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_SERVER_URL', server_val), cfg_vars).strip()
|
||||
token_val = _expand_cfg_value(cfg['Initialize Cluster'].get('K3S_TOKEN', token_val), cfg_vars).strip()
|
||||
if cfg.has_section('Service Cluster (k3s)'):
|
||||
server_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_SERVER_URL', server_val), cfg_vars).strip()
|
||||
token_val = _expand_cfg_value(cfg['Service Cluster (k3s)'].get('K3S_TOKEN', token_val), cfg_vars).strip()
|
||||
|
||||
return env_val, server_val, token_val
|
||||
|
||||
def _apply_cluster_env_default_from_cfg(self):
|
||||
cfg_env, _cfg_server, _cfg_token = self._read_k3s_cfg_values()
|
||||
if not cfg_env:
|
||||
return
|
||||
try:
|
||||
current = (self.cluster_env.get() or '').strip()
|
||||
except Exception:
|
||||
current = ''
|
||||
if not current or current == 'dev':
|
||||
try:
|
||||
self.cluster_env.set(_cluster_env_radio_value(cfg_env))
|
||||
except Exception:
|
||||
pass
|
||||
self._set_deploy_target_from_cluster_env()
|
||||
|
||||
def _apply_k3s_defaults(self):
|
||||
# Environment overrides
|
||||
env_server = (os.environ.get('PROLE_K3S_SERVER') or os.environ.get('K3S_SERVER_URL') or '').strip()
|
||||
env_token = (os.environ.get('PROLE_K3S_TOKEN') or os.environ.get('K3S_TOKEN') or '').strip()
|
||||
env_token = _normalize_k3s_token(env_token)
|
||||
if env_server and not self.k3s_server_url.get().strip():
|
||||
self.k3s_server_url.set(env_server)
|
||||
if env_token and not self.k3s_token.get().strip():
|
||||
self.k3s_token.set(env_token)
|
||||
|
||||
# Config defaults
|
||||
_cfg_env, cfg_server, cfg_token = self._read_k3s_cfg_values()
|
||||
if cfg_server and not self.k3s_server_url.get().strip():
|
||||
self.k3s_server_url.set(cfg_server)
|
||||
if cfg_token and not self.k3s_token.get().strip():
|
||||
self.k3s_token.set(cfg_token)
|
||||
|
||||
# Ansible defaults
|
||||
info = getattr(self, 'ansible_topology', None) or _detect_ansible_topology(PROJECT_ROOT)
|
||||
if info:
|
||||
if info.get('k3s_server_url') and not self.k3s_server_url.get().strip():
|
||||
self.k3s_server_url.set(info['k3s_server_url'])
|
||||
token_val = _normalize_k3s_token(info.get('k3s_token'))
|
||||
if token_val and not self.k3s_token.get().strip():
|
||||
self.k3s_token.set(token_val)
|
||||
1357
installer/ui/screens/cluster.py
Normal file
1357
installer/ui/screens/cluster.py
Normal file
File diff suppressed because it is too large
Load Diff
809
installer/ui/screens/database.py
Normal file
809
installer/ui/screens/database.py
Normal file
@ -0,0 +1,809 @@
|
||||
"""Database creation, namespace management and DB build screens."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import screen as ui
|
||||
from installer.core.env import (
|
||||
NAMESPACE_PREFIX,
|
||||
POSTGRES_DB_NAME_MAX_LEN,
|
||||
PROJECT_ROOT,
|
||||
_bool_str,
|
||||
_push_docker_image,
|
||||
get_resource_path,
|
||||
)
|
||||
|
||||
|
||||
class DatabaseScreenMixin:
|
||||
"""Database creation, namespace management and DB build screens."""
|
||||
|
||||
def _render_init_password_page(self):
|
||||
# Letterhead at top right
|
||||
content_width = self.bg_canvas.winfo_width() or 975
|
||||
right_margin = content_width - 48
|
||||
|
||||
ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne')
|
||||
ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73',
|
||||
font=('SF Pro Text', 18), anchor='ne')
|
||||
|
||||
self._render_title('Database Creation', y=150)
|
||||
self._render_paragraph('Create a database namespace for this deployment. The namespace is treated as a single database with a name and one root password. An ed25519 SSH key will be generated and stored in OpenBao for root access.', y=200)
|
||||
|
||||
x_label = 48
|
||||
x_field = 300
|
||||
y = 270
|
||||
|
||||
# Database (namespace) name
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Database Name (Namespace):', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
|
||||
ns_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#CCCCCC', highlightthickness=1, bd=0)
|
||||
ns_frame.pack_propagate(False)
|
||||
prefix_label = tk.Label(ns_frame, text=self._namespace_prefix(), fg='#9a9aa0', bg='white', font=('SF Pro Text', 11))
|
||||
vcmd = (self.root.register(self._validate_namespace_suffix), '%P')
|
||||
suffix_entry = tk.Entry(
|
||||
ns_frame,
|
||||
textvariable=self.db_namespace_suffix,
|
||||
bg='white',
|
||||
fg='black',
|
||||
insertbackground='black',
|
||||
highlightthickness=0,
|
||||
relief='flat',
|
||||
font=('SF Pro Text', 11),
|
||||
validate='key',
|
||||
validatecommand=vcmd
|
||||
)
|
||||
prefix_label.pack(side='left', padx=(8, 2))
|
||||
suffix_entry.pack(side='left', fill='both', expand=True, padx=(0, 8))
|
||||
ns_window = self.bg_canvas.create_window(x_field, y-12, window=ns_frame, anchor='nw', width=400, height=32)
|
||||
self._canvas_items.append(ns_window)
|
||||
self._overlay_widgets.append(ns_frame)
|
||||
self._overlay_widgets.append(prefix_label)
|
||||
self._overlay_widgets.append(suffix_entry)
|
||||
|
||||
y += 42
|
||||
# Owner (local user)
|
||||
self.db_username.set(self.namespace_owner)
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Owner:', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
owner_entry = tk.Entry(self.bg_canvas, textvariable=self.db_username, bg='white', fg='black',
|
||||
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
|
||||
relief='flat', font=('SF Pro Text', 11))
|
||||
owner_window = self.bg_canvas.create_window(x_field, y-12, window=owner_entry, anchor='nw', width=400, height=32)
|
||||
self._canvas_items.append(owner_window)
|
||||
self._overlay_widgets.append(owner_entry)
|
||||
|
||||
y += 42
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Master 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 += 42
|
||||
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)
|
||||
|
||||
y += 42
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Host Port Forward:', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
port_entry = tk.Entry(self.bg_canvas, textvariable=self.db_host_port, bg='white', fg='black',
|
||||
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
|
||||
relief='flat', font=('SF Pro Text', 11))
|
||||
port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=100, height=32)
|
||||
self._canvas_items.append(port_window)
|
||||
self._overlay_widgets.append(port_entry)
|
||||
|
||||
# Indicator for password match (X or ✓)
|
||||
self.password_indicator = ui.canvas_text(self, x_field + 410, y, '✘', fill='#dc3545', font=('SF Pro Text', 16, 'bold'), state='hidden')
|
||||
self._canvas_items.append(self.password_indicator)
|
||||
|
||||
# Optional Features moved above browser
|
||||
y += 50
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Optional Features:', fill='black', font=('SF Pro Text', 13, 'bold')))
|
||||
|
||||
feature_options = [
|
||||
('Enable Supabase', self.supabase_enabled),
|
||||
('Enable Kerberos Authentication', self.kerberos_enabled),
|
||||
('Enable At-Rest Encryption', self.at_rest_encryption_enabled),
|
||||
]
|
||||
|
||||
fx = x_label + 20
|
||||
fy = y + 36
|
||||
for label, var in feature_options:
|
||||
cb = tk.Checkbutton(self.bg_canvas, text=label, variable=var,
|
||||
bg='white', fg='black', activebackground='white',
|
||||
selectcolor='white', font=('SF Pro Text', 11),
|
||||
command=self._save_prole_cfg)
|
||||
cb_window = self.bg_canvas.create_window(fx, fy, window=cb, anchor='nw')
|
||||
self._canvas_items.append(cb_window)
|
||||
self._overlay_widgets.append(cb)
|
||||
fx += 280
|
||||
|
||||
# Database browser
|
||||
table_y = fy + 50
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, table_y, 'Database Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')))
|
||||
|
||||
refresh_btn = tk.Button(self.bg_canvas, text='Refresh', command=self._refresh_namespace_table,
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 10), padx=10, pady=4)
|
||||
refresh_window = self.bg_canvas.create_window(x_label + 160, table_y - 8, window=refresh_btn, anchor='nw')
|
||||
self._canvas_items.append(refresh_window)
|
||||
self._overlay_widgets.append(refresh_btn)
|
||||
|
||||
add_btn = tk.Button(self.bg_canvas, text='Add', command=self._db_add_namespace,
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 10), padx=10, pady=4)
|
||||
add_window = self.bg_canvas.create_window(x_label + 240, table_y - 8, window=add_btn, anchor='nw')
|
||||
self._canvas_items.append(add_window)
|
||||
self._overlay_widgets.append(add_btn)
|
||||
|
||||
edit_btn = tk.Button(self.bg_canvas, text='Edit', command=self._db_edit_namespace,
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 10), padx=10, pady=4)
|
||||
edit_window = self.bg_canvas.create_window(x_label + 300, table_y - 8, window=edit_btn, anchor='nw')
|
||||
self._canvas_items.append(edit_window)
|
||||
self._overlay_widgets.append(edit_btn)
|
||||
|
||||
delete_btn = tk.Button(self.bg_canvas, text='Delete', command=self._db_delete_namespace,
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 10), padx=10, pady=4)
|
||||
delete_window = self.bg_canvas.create_window(x_label + 360, table_y - 8, window=delete_btn, anchor='nw')
|
||||
self._canvas_items.append(delete_window)
|
||||
self._overlay_widgets.append(delete_btn)
|
||||
|
||||
self._db_action_buttons = [refresh_btn, add_btn, edit_btn, delete_btn]
|
||||
|
||||
table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1)
|
||||
table_window = self.bg_canvas.create_window(x_label, table_y + 30, window=table_frame, anchor='nw', width=900, height=180)
|
||||
self._canvas_items.append(table_window)
|
||||
self._overlay_widgets.append(table_frame)
|
||||
|
||||
columns = ('select', 'name', 'owner', 'port', 'created', 'status')
|
||||
tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=5)
|
||||
tree.heading('select', text='Select')
|
||||
tree.heading('name', text='Name')
|
||||
tree.heading('owner', text='Owner')
|
||||
tree.heading('port', text='Host Port')
|
||||
tree.heading('created', text='Creation Date')
|
||||
tree.heading('status', text='Status')
|
||||
tree.column('select', width=60, anchor='center')
|
||||
tree.column('name', width=180, anchor='w')
|
||||
tree.column('owner', width=140, anchor='w')
|
||||
tree.column('port', width=80, anchor='center')
|
||||
tree.column('created', width=200, anchor='w')
|
||||
tree.column('status', width=120, anchor='center')
|
||||
tree.pack(side='left', fill='both', expand=True)
|
||||
|
||||
scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview)
|
||||
tree.configure(yscrollcommand=scroll.set)
|
||||
scroll.pack(side='right', fill='y')
|
||||
|
||||
self._overlay_widgets.append(tree)
|
||||
self._overlay_widgets.append(scroll)
|
||||
self._db_namespace_tree = tree
|
||||
|
||||
def on_select(event):
|
||||
if getattr(self, '_refreshing_ns_table', False):
|
||||
return
|
||||
sel = tree.selection()
|
||||
if sel:
|
||||
vals = tree.item(sel[0], 'values')
|
||||
if vals:
|
||||
ns = vals[1]
|
||||
if ns == 'supabase':
|
||||
# Do not allow selecting 'supabase' as the primary prole-db namespace
|
||||
return
|
||||
if ns == self.db_namespace.get():
|
||||
# Already selected, don't trigger re-refresh/save
|
||||
return
|
||||
self.db_namespace.set(ns)
|
||||
self._sync_namespace_suffix_from_full()
|
||||
# Mark as selected in tree visually if needed, but we use the checkbox column
|
||||
self._refresh_namespace_table()
|
||||
self._save_prole_cfg()
|
||||
if self.supabase_enabled.get():
|
||||
self._db_sync_supabase_ports(ns)
|
||||
|
||||
tree.bind('<<TreeviewSelect>>', on_select)
|
||||
|
||||
self._db_namespace_note = ui.canvas_text(self, x_label, table_y + 220, '', fill='#6e6e73', font=('SF Pro Text', 10))
|
||||
self._canvas_items.append(self._db_namespace_note)
|
||||
|
||||
def on_password_change(*args):
|
||||
p = self.db_password.get()
|
||||
c = self.db_password_confirm.get()
|
||||
|
||||
if not p:
|
||||
self.bg_canvas.itemconfigure(self.password_indicator, state='hidden')
|
||||
elif p == c:
|
||||
self.bg_canvas.itemconfigure(self.password_indicator, text='✓', fill='#28a745', state='normal')
|
||||
else:
|
||||
self.bg_canvas.itemconfigure(self.password_indicator, text='✘', fill='#dc3545', state='normal')
|
||||
|
||||
self.db_password.trace_add('write', on_password_change)
|
||||
self.db_password_confirm.trace_add('write', on_password_change)
|
||||
|
||||
# Add trace to host port to update mappings immediately
|
||||
def on_port_change(*args):
|
||||
try:
|
||||
self._save_prole_cfg()
|
||||
except:
|
||||
pass
|
||||
self.db_host_port.trace_add('write', on_port_change)
|
||||
|
||||
# Trigger once in case they are already set
|
||||
on_password_change()
|
||||
|
||||
self._refresh_namespace_table()
|
||||
|
||||
def _namespace_prefix(self) -> str:
|
||||
return NAMESPACE_PREFIX
|
||||
|
||||
def _max_namespace_suffix_len(self) -> int:
|
||||
return max(0, POSTGRES_DB_NAME_MAX_LEN - len(self._namespace_prefix()))
|
||||
|
||||
def _strip_namespace_prefix(self, name: str) -> str:
|
||||
prefix = self._namespace_prefix()
|
||||
if name.startswith(prefix):
|
||||
return name[len(prefix):]
|
||||
return name
|
||||
|
||||
def _ensure_namespace_prefix(self, name: str) -> str:
|
||||
cleaned = (name or '').strip()
|
||||
prefix = self._namespace_prefix()
|
||||
if not cleaned:
|
||||
return prefix
|
||||
if cleaned.startswith(prefix):
|
||||
return cleaned
|
||||
return f"{prefix}{cleaned}"
|
||||
|
||||
def _sync_namespace_suffix_from_full(self):
|
||||
if self._updating_namespace_fields:
|
||||
return
|
||||
self._updating_namespace_fields = True
|
||||
try:
|
||||
full = (self.db_namespace.get() or '').strip()
|
||||
full = self._ensure_namespace_prefix(full)
|
||||
if full != self.db_namespace.get():
|
||||
self.db_namespace.set(full)
|
||||
self.db_namespace_suffix.set(self._strip_namespace_prefix(full))
|
||||
finally:
|
||||
self._updating_namespace_fields = False
|
||||
|
||||
def _sync_namespace_full_from_suffix(self):
|
||||
if self._updating_namespace_fields:
|
||||
return
|
||||
self._updating_namespace_fields = True
|
||||
try:
|
||||
suffix = (self.db_namespace_suffix.get() or '').strip()
|
||||
self.db_namespace.set(f"{self._namespace_prefix()}{suffix}")
|
||||
finally:
|
||||
self._updating_namespace_fields = False
|
||||
|
||||
def _validate_namespace_suffix(self, proposed: str) -> bool:
|
||||
return len(proposed) <= self._max_namespace_suffix_len()
|
||||
|
||||
def _collect_namespace_rows(self):
|
||||
owner = self.namespace_owner or self._get_local_owner()
|
||||
ns_names = []
|
||||
notice = ""
|
||||
kubectl_ok = False
|
||||
|
||||
try:
|
||||
base_cmd = self._kubectl_base_cmd()
|
||||
result = subprocess.run(
|
||||
base_cmd + ['get', 'ns', '-o', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
kubectl_ok = True
|
||||
data = json.loads(result.stdout or "{}")
|
||||
for item in data.get('items', []):
|
||||
name = item.get('metadata', {}).get('name')
|
||||
if name:
|
||||
ns_names.append(name)
|
||||
else:
|
||||
notice = (result.stderr or '').strip() or "kubectl returned a non-zero status."
|
||||
except FileNotFoundError:
|
||||
notice = "kubectl not found; showing local defaults."
|
||||
except Exception as e:
|
||||
notice = f"Unable to query namespaces: {e}"
|
||||
|
||||
current_ns = (self.db_namespace.get() or '').strip()
|
||||
prefix = self._namespace_prefix()
|
||||
ns_names = [name for name in ns_names if name.startswith(prefix) or name == 'supabase']
|
||||
if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase') and current_ns not in ns_names:
|
||||
ns_names.append(current_ns)
|
||||
if not ns_names:
|
||||
if current_ns and (current_ns.startswith(prefix) or current_ns == 'supabase'):
|
||||
ns_names = [current_ns]
|
||||
else:
|
||||
ns_names = []
|
||||
|
||||
pods_by_ns = {}
|
||||
if kubectl_ok:
|
||||
try:
|
||||
base_cmd = self._kubectl_base_cmd()
|
||||
pods_result = subprocess.run(
|
||||
base_cmd + ['get', 'pods', '--all-namespaces', '-o', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8
|
||||
)
|
||||
if pods_result.returncode == 0:
|
||||
pods_data = json.loads(pods_result.stdout or "{}")
|
||||
for pod in pods_data.get('items', []):
|
||||
meta = pod.get('metadata', {})
|
||||
status = pod.get('status', {})
|
||||
ns = meta.get('namespace')
|
||||
if not ns:
|
||||
continue
|
||||
entry = pods_by_ns.setdefault(ns, {'starts': [], 'running': False})
|
||||
start_time = status.get('startTime')
|
||||
if start_time:
|
||||
entry['starts'].append(start_time)
|
||||
if status.get('phase') == 'Running':
|
||||
entry['running'] = True
|
||||
else:
|
||||
notice = notice or (pods_result.stderr or '').strip() or notice
|
||||
except Exception:
|
||||
# Best effort only
|
||||
pass
|
||||
|
||||
rows = []
|
||||
for ns in sorted(set(ns_names)):
|
||||
entry = pods_by_ns.get(ns, {'starts': [], 'running': False})
|
||||
oldest = None
|
||||
if entry['starts']:
|
||||
parsed = [self._parse_rfc3339(ts) for ts in entry['starts']]
|
||||
parsed = [p for p in parsed if p is not None]
|
||||
if parsed:
|
||||
oldest_dt = min(parsed)
|
||||
oldest = oldest_dt.isoformat()
|
||||
else:
|
||||
oldest = min(entry['starts'])
|
||||
creation = self._format_pod_time(oldest) if oldest else '—'
|
||||
status = 'active' if entry['running'] else 'inactive'
|
||||
|
||||
# Best effort to find port
|
||||
port = '—'
|
||||
if ns == 'supabase':
|
||||
port = '5432'
|
||||
else:
|
||||
# For prole-db namespaces
|
||||
if ns == (self.db_namespace.get() or '').strip():
|
||||
if self.supabase_enabled.get():
|
||||
port = '15432'
|
||||
else:
|
||||
port = self.db_host_port.get() or '5432'
|
||||
else:
|
||||
# If it's another prole-db namespace, we might not know its port easily
|
||||
# but if Supabase is enabled globally, we assume standard alternate port
|
||||
if self.supabase_enabled.get():
|
||||
port = '15432'
|
||||
else:
|
||||
port = '5432'
|
||||
|
||||
rows.append((ns, owner, port, creation, status))
|
||||
|
||||
return rows, notice
|
||||
|
||||
def _refresh_namespace_table(self):
|
||||
if getattr(self, '_refreshing_ns_table', False):
|
||||
return
|
||||
self._refreshing_ns_table = True
|
||||
|
||||
def worker():
|
||||
try:
|
||||
rows, notice = self._collect_namespace_rows()
|
||||
current_ns = (self.db_namespace.get() or '').strip()
|
||||
|
||||
def update_ui():
|
||||
tree = getattr(self, '_db_namespace_tree', None)
|
||||
if not tree:
|
||||
return
|
||||
for item in tree.get_children():
|
||||
tree.delete(item)
|
||||
|
||||
for row in rows:
|
||||
# Add checkbox-like symbol for selection
|
||||
ns_name = row[0]
|
||||
prefix = ' [✓] ' if ns_name == current_ns else ' [ ] '
|
||||
display_row = (prefix,) + row
|
||||
tree.insert('', 'end', values=display_row)
|
||||
|
||||
if current_ns:
|
||||
for item in tree.get_children():
|
||||
vals = tree.item(item, 'values')
|
||||
if vals and vals[1] == current_ns:
|
||||
tree.selection_set(item)
|
||||
tree.see(item)
|
||||
break
|
||||
|
||||
note_item = getattr(self, '_db_namespace_note', None)
|
||||
if note_item and self.bg_canvas.winfo_exists():
|
||||
msg = notice if notice else ""
|
||||
self.bg_canvas.itemconfig(note_item, text=msg)
|
||||
|
||||
self.safe_after(update_ui)
|
||||
finally:
|
||||
self._refreshing_ns_table = False
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _db_set_status(self, message: str, color: str = '#6e6e73'):
|
||||
note_item = getattr(self, '_db_namespace_note', None)
|
||||
if note_item and self.bg_canvas.winfo_exists():
|
||||
self.bg_canvas.itemconfig(note_item, text=message or "", fill=color)
|
||||
|
||||
def _db_set_buttons_state(self, state: str):
|
||||
buttons = getattr(self, '_db_action_buttons', None)
|
||||
if not buttons:
|
||||
return
|
||||
for btn in buttons:
|
||||
try:
|
||||
if btn.winfo_exists():
|
||||
btn.configure(state=state)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _db_selected_namespace(self) -> str:
|
||||
tree = getattr(self, '_db_namespace_tree', None)
|
||||
if not tree:
|
||||
return ""
|
||||
sel = tree.selection()
|
||||
if not sel:
|
||||
return ""
|
||||
vals = tree.item(sel[0], 'values')
|
||||
if not vals:
|
||||
return ""
|
||||
ns = vals[1]
|
||||
if ns == 'supabase':
|
||||
return ""
|
||||
return ns
|
||||
|
||||
def _db_sync_supabase_ports(self, namespace):
|
||||
if not self.supabase_enabled.get():
|
||||
return
|
||||
|
||||
script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh"
|
||||
if not script.exists():
|
||||
return
|
||||
|
||||
def worker():
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["PROLE_HOME"] = str(PROJECT_ROOT)
|
||||
# Best effort: use kubectl context if selected
|
||||
if hasattr(self, 'selected_kubectx') and self.selected_kubectx.get():
|
||||
env["KUBECONFIG"] = env.get("KUBECONFIG", "") # Ensure it's passed if set
|
||||
|
||||
subprocess.run(["bash", str(script), "-n", namespace],
|
||||
cwd=str(PROJECT_ROOT), env=env,
|
||||
capture_output=True, text=True)
|
||||
except:
|
||||
pass
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _db_action_log_path(self) -> Path:
|
||||
logs_dir = PROJECT_ROOT / "logs"
|
||||
try:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return logs_dir / "db-actions.log"
|
||||
|
||||
def _db_log(self, text: str):
|
||||
try:
|
||||
with self._db_action_log_path().open('a', encoding='utf-8') as fp:
|
||||
fp.write(text)
|
||||
if not text.endswith('\n'):
|
||||
fp.write('\n')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _db_add_namespace(self):
|
||||
ns = (self.db_namespace.get() or '').strip()
|
||||
if not ns:
|
||||
messagebox.showerror('Database Name', 'Database namespace cannot be empty.')
|
||||
return
|
||||
if not self._is_valid_namespace(ns):
|
||||
messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.')
|
||||
return
|
||||
self.db_namespace.set(ns)
|
||||
|
||||
def worker():
|
||||
self.safe_after(lambda: self._db_set_buttons_state('disabled'))
|
||||
self.safe_after(lambda: self._db_set_status(f"Creating namespace {ns}...", '#1d1d1f'))
|
||||
cmd = self._kubectl_base_cmd() + ['create', 'namespace', ns]
|
||||
rc, out = self._run_cmd_capture(cmd)
|
||||
if rc != 0 and 'AlreadyExists' not in out:
|
||||
self._db_log(out)
|
||||
self.safe_after(self._refresh_namespace_table)
|
||||
self.safe_after(lambda: self._db_set_status(f"Failed to create namespace {ns}. See logs/db-actions.log.", '#ff3b30'))
|
||||
else:
|
||||
self._update_env_namespace(ns)
|
||||
self.safe_after(self._refresh_namespace_table)
|
||||
self.safe_after(lambda: self._db_set_status(f"Namespace {ns} is ready.", '#34c759'))
|
||||
self.safe_after(lambda: self._db_set_buttons_state('normal'))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _db_delete_namespace(self):
|
||||
ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip()
|
||||
if not ns:
|
||||
messagebox.showerror('Delete Database', 'Select a database namespace to delete.')
|
||||
return
|
||||
if not messagebox.askyesno('Delete Database', f'Delete namespace "{ns}"? This cannot be undone.'):
|
||||
return
|
||||
|
||||
def worker():
|
||||
self.safe_after(lambda: self._db_set_buttons_state('disabled'))
|
||||
self.safe_after(lambda: self._db_set_status(f"Deleting namespace {ns}...", '#1d1d1f'))
|
||||
cmd = self._kubectl_base_cmd() + ['delete', 'namespace', ns]
|
||||
rc, out = self._run_cmd_capture(cmd)
|
||||
if rc != 0:
|
||||
self._db_log(out)
|
||||
self.safe_after(self._refresh_namespace_table)
|
||||
self.safe_after(lambda: self._db_set_status(f"Failed to delete namespace {ns}. See logs/db-actions.log.", '#ff3b30'))
|
||||
else:
|
||||
self.safe_after(self._refresh_namespace_table)
|
||||
self.safe_after(lambda: self._db_set_status(f"Namespace {ns} deleted.", '#34c759'))
|
||||
self.safe_after(lambda: self._db_set_buttons_state('normal'))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _db_edit_namespace(self):
|
||||
ns = self._db_selected_namespace() or (self.db_namespace.get() or '').strip()
|
||||
if not ns:
|
||||
messagebox.showerror('Edit Database', 'Select a database namespace to edit.')
|
||||
return
|
||||
self.db_namespace.set(ns)
|
||||
if not self._is_valid_namespace(ns):
|
||||
messagebox.showerror('Database Name', 'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.')
|
||||
return
|
||||
p1 = self.db_password.get()
|
||||
p2 = self.db_password_confirm.get()
|
||||
if not p1:
|
||||
messagebox.showerror('Password', 'Password cannot be empty.')
|
||||
return
|
||||
if p1 != p2:
|
||||
messagebox.showerror('Password', 'Passwords do not match.')
|
||||
return
|
||||
|
||||
def worker():
|
||||
self.safe_after(lambda: self._db_set_buttons_state('disabled'))
|
||||
self.safe_after(lambda: self._db_set_status(f"Recreating SSH key for {ns}...", '#1d1d1f'))
|
||||
# We prefer ed25519, but fallback to rsa if not available
|
||||
key_path = Path.home() / ".ssh" / "id_prole_ed25519"
|
||||
pub_path = Path.home() / ".ssh" / "id_prole_ed25519.pub"
|
||||
try:
|
||||
if key_path.exists():
|
||||
key_path.unlink()
|
||||
if pub_path.exists():
|
||||
pub_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd = ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()]
|
||||
rc, out = self._run_cmd_capture(cmd)
|
||||
if rc != 0:
|
||||
self._db_log(f"ed25519 generation failed, falling back to rsa: {out}")
|
||||
cmd = ["ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get().strip()]
|
||||
rc, out = self._run_cmd_capture(cmd)
|
||||
|
||||
self._db_log(out)
|
||||
if rc != 0:
|
||||
self.safe_after(lambda: self._db_set_status("Failed to recreate SSH key. See logs/db-actions.log.", '#ff3b30'))
|
||||
self.safe_after(lambda: self._db_set_buttons_state('normal'))
|
||||
return
|
||||
|
||||
self.safe_after(lambda: self._db_set_status("Updating OpenBao...", '#1d1d1f'))
|
||||
env = self._script_env_for_namespace(ns)
|
||||
env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(self.at_rest_encryption_enabled.get())
|
||||
script_path = str(PROJECT_ROOT / "etc" / "init_openbao.sh")
|
||||
rc2, out2 = self._run_cmd_capture(['bash', script_path, 'initialize'], env=env, stdin_text=f"{p1}\n")
|
||||
self._db_log(out2)
|
||||
if rc2 != 0:
|
||||
self.safe_after(self._refresh_namespace_table)
|
||||
self.safe_after(lambda: self._db_set_status("OpenBao update failed. See logs/db-actions.log.", '#ff3b30'))
|
||||
else:
|
||||
self._update_env_namespace(ns)
|
||||
self.safe_after(self._refresh_namespace_table)
|
||||
self.safe_after(lambda: self._db_set_status(f"Updated SSH key and OpenBao for {ns}.", '#34c759'))
|
||||
self.safe_after(lambda: self._db_set_buttons_state('normal'))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _render_init_db_build_page(self):
|
||||
# Letterhead at top right
|
||||
content_width = self.bg_canvas.winfo_width() or 975
|
||||
right_margin = content_width - 48
|
||||
|
||||
ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne')
|
||||
ui.canvas_text(self, right_margin, 85, "Infrastructure Automated.", fill='#6e6e73',
|
||||
font=('SF Pro Text', 18), anchor='ne')
|
||||
|
||||
self._render_title('Build Database Image', y=150)
|
||||
self._render_paragraph('Building the prole-db Postgres image. This may take a few minutes.', y=200)
|
||||
|
||||
# Local registry status (checked async)
|
||||
self._db_registry_status_label = ui.canvas_text(self, 48, 230, "Local Registry: Checking...", fill='#6e6e73', font=('SF Pro Text', 11))
|
||||
self._canvas_items.append(self._db_registry_status_label)
|
||||
|
||||
# Output Console
|
||||
self._db_build_console = self._create_console_output(y=260, title="Build Output", width=900, height=520)
|
||||
|
||||
# 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)
|
||||
|
||||
# Ensure registry check runs when entering the screen
|
||||
self._ensure_db_build_registry_async()
|
||||
|
||||
def _ensure_db_build_registry_async(self):
|
||||
def worker():
|
||||
try:
|
||||
if not self.check_docker_running():
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: Docker not running", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None)
|
||||
return
|
||||
info = self.ensure_local_registry_available()
|
||||
if info:
|
||||
host_registry, _cluster_registry = info
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=f"Local Registry: {host_registry}", fill='#34c759') if self.bg_canvas.winfo_exists() else None)
|
||||
else:
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text="Local Registry: unavailable", fill='#ff3b30') if self.bg_canvas.winfo_exists() else None)
|
||||
log_path = getattr(self, '_last_registry_log_path', None)
|
||||
if log_path and self._db_build_console:
|
||||
self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n"))
|
||||
except Exception as e:
|
||||
msg = f"Local Registry: error ({e})"
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_registry_status_label, text=msg, fill='#ff3b30') if self.bg_canvas.winfo_exists() else None)
|
||||
log_path = getattr(self, '_last_registry_log_path', None)
|
||||
if log_path and self._db_build_console:
|
||||
self.safe_after(lambda p=log_path: self._db_build_console.write(f"Registry log: {p}\n"))
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def run_db_build(self):
|
||||
self._action_flags['init_db_build.run_build'] = True
|
||||
def worker():
|
||||
self.safe_after(lambda: self._db_build_button.configure(state='disabled') if self._db_build_button.winfo_exists() else None)
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Building...", fill='blue') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
|
||||
tag = self.get_prole_db_version()
|
||||
image_name = f"prole-db:{tag}"
|
||||
|
||||
# Use $HOME/.prole/build for Docker build context
|
||||
# This avoids issues with PyInstaller's temporary _MEIPASS directory
|
||||
prole_home = Path.home() / ".prole"
|
||||
build_dir = prole_home / "build" / "prole-db"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy prole-db directory to writable location
|
||||
source_dir = get_resource_path("prole-db")
|
||||
if source_dir.exists():
|
||||
import shutil
|
||||
# Remove old build dir and copy fresh
|
||||
if source_dir.resolve() != build_dir.resolve():
|
||||
if build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
shutil.copytree(source_dir, build_dir)
|
||||
|
||||
cwd = build_dir
|
||||
|
||||
# Fetch the generated public key
|
||||
pub_key = ""
|
||||
pub_key_path = Path.home() / ".ssh" / "id_prole_ed25519.pub"
|
||||
if pub_key_path.exists():
|
||||
pub_key = pub_key_path.read_text().strip()
|
||||
|
||||
username = self.db_username.get()
|
||||
env_key = self._cluster_env_key()
|
||||
cmd = ['docker', 'build']
|
||||
cmd.extend(get_docker_build_platform_args(env_key))
|
||||
cmd += [
|
||||
'--build-arg', f"PROLE_USER={username}",
|
||||
'--build-arg', f"PROLE_SSH_PUB_KEY={pub_key}",
|
||||
'-t', image_name, '.'
|
||||
]
|
||||
|
||||
self._db_build_console.clear()
|
||||
self._db_build_console.write(f"Building {image_name} in {cwd}...\n\n")
|
||||
|
||||
proc = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
while True:
|
||||
line = proc.stdout.readline()
|
||||
if not line and proc.poll() is not None:
|
||||
break
|
||||
if line:
|
||||
self._db_build_console.write(line)
|
||||
|
||||
if proc.returncode == 0:
|
||||
self._db_build_console.write("\nBuild successful!\n")
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build successful! Tagging...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
self._db_built_success = True
|
||||
|
||||
if env_key == 'dev':
|
||||
# Tag + push to local registry for k3d pulls
|
||||
registry_info = self.ensure_local_registry_available()
|
||||
import_tag = image_name
|
||||
if registry_info:
|
||||
host_registry, cluster_registry = registry_info
|
||||
remote_tag = f"{host_registry}/prole-db:{tag}"
|
||||
self._db_build_console.write(f"Tagging image for registry: {remote_tag}\n")
|
||||
tag_res = subprocess.run(['docker', 'tag', image_name, remote_tag], capture_output=True, text=True)
|
||||
if tag_res.returncode != 0:
|
||||
self._db_build_console.write(tag_res.stdout or '')
|
||||
self._db_build_console.write(tag_res.stderr or '')
|
||||
self._db_build_console.write("Tag failed; skipping push/import.\n")
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, tag failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None)
|
||||
return
|
||||
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Pushing to registry...", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
if not _push_docker_image(remote_tag, log_fn=self._db_build_console.write):
|
||||
self._db_build_console.write("Push failed; skipping import.\n")
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build ok, push failed.", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None)
|
||||
return
|
||||
self._db_build_console.write(f"Push complete: {remote_tag}\n")
|
||||
|
||||
if cluster_registry:
|
||||
import_tag = f"{cluster_registry}/prole-db:{tag}"
|
||||
# k3d registry is often reachable internally without the .localhost suffix
|
||||
if cluster_registry.endswith('.localhost:5000'):
|
||||
alt_registry = cluster_registry.replace('.localhost', '')
|
||||
alt_tag = f"{alt_registry}/prole-db:{tag}"
|
||||
if alt_tag != import_tag:
|
||||
self._db_build_console.write(f"Tagging image for k3d import: {alt_tag}\n")
|
||||
subprocess.run(['docker', 'tag', image_name, alt_tag], capture_output=True, text=True)
|
||||
import_tag = alt_tag
|
||||
else:
|
||||
self._db_build_console.write("Local registry unavailable; skipping tag/push.\n")
|
||||
|
||||
# Import to k3d
|
||||
cluster_name = "prole-dev-cluster"
|
||||
self._db_build_console.write(f"Importing image to {cluster_name} ({import_tag})...\n")
|
||||
subprocess.run(['k3d', 'image', 'import', import_tag, '-c', cluster_name])
|
||||
self._db_build_console.write("Import complete.\n")
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build, tag, push, and import complete.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
else:
|
||||
self._db_build_console.write("Import skipped for non-dev clusters.\n")
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text="Build complete. Import skipped.", fill='#34c759') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
else:
|
||||
self._db_build_console.write(f"\nBuild failed with code {proc.returncode}\n")
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._db_build_status_label, text=f"Build failed (code {proc.returncode})", fill='#ff3b30') if self.bg_canvas.winfo_exists() and self._db_build_status_label in self.bg_canvas.find_all() else None)
|
||||
|
||||
self.safe_after(lambda: self._db_build_button.configure(state='normal') if self._db_build_button.winfo_exists() else None)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
441
installer/ui/screens/dependencies.py
Normal file
441
installer/ui/screens/dependencies.py
Normal file
@ -0,0 +1,441 @@
|
||||
"""Dependency scanning, summary and per-dependency install screens."""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import config as inst_config
|
||||
from installer import screen as ui
|
||||
|
||||
|
||||
class DependenciesScreenMixin:
|
||||
"""Dependency scanning, summary and per-dependency install screens."""
|
||||
|
||||
def _render_deps_summary_page(self):
|
||||
self._render_title('Dependencies', y=40)
|
||||
y = 100
|
||||
# Draw each dependency row: status dot (blank initially), name, info
|
||||
row_gap = 36
|
||||
|
||||
self.dep_status_items = {} # Store canvas IDs to update later
|
||||
self.dep_install_buttons = {} # Store button window items
|
||||
|
||||
for dep in self.dependencies:
|
||||
indent = 0
|
||||
if dep.get('parent'):
|
||||
indent = 24
|
||||
|
||||
left = 56 + indent
|
||||
text_x = left + 28
|
||||
|
||||
# blank box (outline only initially)
|
||||
dot = ui.canvas_rectangle(self, left, y, left+18, y+18, outline='#6e6e73', width=2)
|
||||
self._canvas_items.append(dot)
|
||||
# name
|
||||
self._canvas_items.append(ui.canvas_text(self, text_x, y-2, dep['name'], fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
# info placeholder
|
||||
info = ui.canvas_text(self, text_x + 180, y, 'Checking...', fill='#6e6e73', font=('SF Pro Text', 11))
|
||||
self._canvas_items.append(info)
|
||||
|
||||
self.dep_status_items[dep['id']] = {'dot': dot, 'info': info, 'y': y, 'left': left}
|
||||
y += row_gap
|
||||
|
||||
# Message line
|
||||
msg_y = y + 30
|
||||
self.deps_msg_item = ui.canvas_text(self, 48, msg_y, 'Scanning system...', fill='#6e6e73', font=('SF Pro Text', 11))
|
||||
self._canvas_items.append(self.deps_msg_item)
|
||||
|
||||
# Start async check
|
||||
threading.Thread(target=self._run_delayed_deps_check, daemon=True).start()
|
||||
|
||||
def _render_dependency_page(self, dep):
|
||||
self._render_title(dep['name'], y=40)
|
||||
self._render_paragraph(dep['description'], y=88)
|
||||
# Status
|
||||
ok, location, version = self.get_dep_info(dep)
|
||||
y = 180
|
||||
left = 56
|
||||
if ok:
|
||||
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline=''))
|
||||
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installed', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
if location:
|
||||
self._canvas_items.append(ui.canvas_text(self, left+32, y+32, f'Location: {location}', fill='#6e6e73', font=('SF Pro Text', 11)))
|
||||
if version:
|
||||
ver = self.normalize_version(version)
|
||||
self._canvas_items.append(ui.canvas_text(self, left+32, y+56, f'Version: {ver}', fill='#6e6e73', font=('SF Pro Text', 11)))
|
||||
else:
|
||||
# Check if we should auto-install
|
||||
install_cmd = dep.get('install_cmd')
|
||||
if install_cmd and self._installing_dep_id != dep['id']:
|
||||
# Launch directly into installation
|
||||
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
|
||||
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
# Trigger console-based install for brew/pip
|
||||
if 'brew install' in install_cmd or 'pip install' in install_cmd:
|
||||
self.root.after(500, lambda: self._install_dep_in_console(dep))
|
||||
else:
|
||||
self.root.after(500, lambda: self.open_terminal_with_command(install_cmd))
|
||||
elif self._installing_dep_id == dep['id']:
|
||||
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ffd60a', outline=''))
|
||||
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Installing...', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
else:
|
||||
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff9f0a', outline=''))
|
||||
self._canvas_items.append(ui.canvas_text(self, left+32, y-2, 'Not installed', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
# Click to install link (if available)
|
||||
if install_cmd:
|
||||
link_y = y + 40
|
||||
link_text = ui.render_link(self, left+32, link_y, 'Click to install')
|
||||
self._canvas_items.append(link_text)
|
||||
self.bg_canvas.config(cursor='hand2')
|
||||
|
||||
def _on_click(event):
|
||||
ex, ey = event.x, event.y
|
||||
bbox = self.bg_canvas.bbox(link_text)
|
||||
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
|
||||
self.open_terminal_with_command(install_cmd)
|
||||
self.bg_canvas.bind('<Button-1>', _on_click)
|
||||
|
||||
def _install_dep_in_console(self, dep):
|
||||
"""Run dependency installation in the embedded console."""
|
||||
if self._installing_dep_id == dep['id']:
|
||||
return
|
||||
try:
|
||||
self._action_flags[f"dependencies.{dep['id']}.install"] = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
install_cmd = dep.get('install_cmd')
|
||||
if not install_cmd:
|
||||
return
|
||||
|
||||
self._installing_dep_id = dep['id']
|
||||
# Prepare log file
|
||||
logs_dir = self._resolve_env_dir('PROLE_LOGS', '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 _run_delayed_deps_check(self):
|
||||
results = {}
|
||||
any_missing = False
|
||||
for dep in self.dependencies:
|
||||
ok, location, version = self.get_dep_info(dep)
|
||||
results[dep['id']] = (ok, location, version)
|
||||
|
||||
# Update UI for this item
|
||||
def update_item(did=dep['id'], ok=ok, version=version):
|
||||
items = self.dep_status_items.get(did)
|
||||
if not items: return
|
||||
|
||||
# Replace box with colored dot/check
|
||||
self.bg_canvas.delete(items['dot'])
|
||||
left = items.get('left', 56)
|
||||
y = items['y']
|
||||
if ok:
|
||||
items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#34c759', outline='')
|
||||
# Add a small white checkmark inside the green dot
|
||||
self.bg_canvas.create_line(left+5, y+9, left+8, y+12, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}")
|
||||
self.bg_canvas.create_line(left+8, y+12, left+13, y+6, fill='white', width=2, tags=f"page_item_{self.pages[self.page_index][0]}")
|
||||
else:
|
||||
items['dot'] = ui.canvas_oval(self, left, y, left+18, y+18, fill='#ff3b30', outline='')
|
||||
# Add "Click to install" button
|
||||
btn = tk.Button(self.bg_canvas, text='Install', command=lambda d=did: self.show_page(f"dep_{d}"),
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 10), padx=8, pady=2)
|
||||
btn_window = self.bg_canvas.create_window(left + 350, y, window=btn, anchor='nw')
|
||||
self._overlay_widgets.append(btn)
|
||||
self._canvas_items.append(btn_window)
|
||||
self.dep_install_buttons[did] = btn_window
|
||||
|
||||
info_text = self.normalize_version(version) if ok and version else ('Not installed' if not ok else '')
|
||||
self.bg_canvas.itemconfig(items['info'], text=info_text)
|
||||
|
||||
self.safe_after(update_item)
|
||||
time.sleep(0.1) # small delay to show it checking one by one
|
||||
|
||||
def final_update():
|
||||
missing = [d['name'] for d in self.dependencies if not results.get(d['id'], (False, None, None))[0]]
|
||||
if missing:
|
||||
msg = f"Preparing to install ... {', '.join(missing)}"
|
||||
else:
|
||||
msg = 'All dependencies installed.'
|
||||
try:
|
||||
if self.bg_canvas.winfo_exists() and self.deps_msg_item in self.bg_canvas.find_all():
|
||||
self.bg_canvas.itemconfig(self.deps_msg_item, text=msg)
|
||||
except (tk.TclError, RuntimeError):
|
||||
pass
|
||||
self.update_footer()
|
||||
|
||||
self.safe_after(final_update)
|
||||
|
||||
def _create_page_dependencies_summary(self):
|
||||
f = self._page_container()
|
||||
ttk.Label(f, text='Dependencies', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
||||
self.deps_container = ttk.Frame(f)
|
||||
self.deps_container.pack(fill='both', expand=True, padx=16, pady=8)
|
||||
|
||||
self.dep_status = {}
|
||||
for dep in self.dependencies:
|
||||
row = ttk.Frame(self.deps_container)
|
||||
row.pack(fill='x', pady=6)
|
||||
indicator = tk.Canvas(row, width=18, height=18, highlightthickness=0)
|
||||
indicator.pack(side='left', padx=8)
|
||||
name = ttk.Label(row, text=dep['name'], style='Body.TLabel')
|
||||
name.pack(side='left')
|
||||
info = ttk.Label(row, text='', style='Dim.TLabel')
|
||||
info.pack(side='left', padx=10)
|
||||
self.dep_status[dep['id']] = {'canvas': indicator, 'info': info, 'dep': dep}
|
||||
# Verify all checkbox (controls navigation behavior)
|
||||
chk = ttk.Checkbutton(f, text='Verify all dependencies', variable=self.verify_mode, command=self.update_footer)
|
||||
chk.pack(anchor='w', padx=24, pady=(4, 0))
|
||||
|
||||
# Message label must be created before refresh to avoid AttributeError
|
||||
self.deps_msg = ttk.Label(f, text='', style='Dim.TLabel')
|
||||
self.deps_msg.pack(anchor='w', padx=24, pady=(8, 8))
|
||||
# Now we can safely populate the UI. Start async scan to avoid startup delay
|
||||
self.deps_msg.configure(text='Checking dependencies...')
|
||||
self.start_dependency_scan()
|
||||
# self._register_page('deps_summary', f)
|
||||
|
||||
def _create_page_dependency(self, dep):
|
||||
f = self._page_container()
|
||||
ttk.Label(f, text=dep['name'], style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
||||
ttk.Label(f, text=dep['description'], style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
|
||||
status_var = tk.StringVar(value='Checking...')
|
||||
loc_var = tk.StringVar(value='')
|
||||
ver_var = tk.StringVar(value='')
|
||||
|
||||
info_frame = ttk.Frame(f)
|
||||
info_frame.pack(fill='x', padx=24, pady=12)
|
||||
# Pretty status row with an icon
|
||||
status_row = ttk.Frame(info_frame)
|
||||
status_row.pack(anchor='w', fill='x')
|
||||
status_icon = tk.Canvas(status_row, width=18, height=18, highlightthickness=0)
|
||||
status_icon.pack(side='left', padx=(0, 8), pady=(2, 0))
|
||||
ttk.Label(status_row, textvariable=status_var, style='Body.TLabel').pack(side='left')
|
||||
|
||||
# Detail rows (hidden when not installed)
|
||||
loc_label = ttk.Label(info_frame, textvariable=loc_var, style='Dim.TLabel')
|
||||
ver_label = ttk.Label(info_frame, textvariable=ver_var, style='Dim.TLabel')
|
||||
loc_label.pack(anchor='w')
|
||||
ver_label.pack(anchor='w')
|
||||
|
||||
link = ttk.Label(f, text='Click to install', foreground='#0a84ff', cursor='hand2', style='Body.TLabel')
|
||||
link.pack(anchor='w', padx=24, pady=(12, 0))
|
||||
|
||||
def _draw_status_icon(canvas, status):
|
||||
ui.canvas_clear(canvas)
|
||||
if status == 'installed':
|
||||
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#34c759', outline='')
|
||||
ui.canvas_line_on(canvas, 4, 9, 8, 13, fill='white', width=2)
|
||||
ui.canvas_line_on(canvas, 8, 13, 15, 5, fill='white', width=2)
|
||||
elif status == 'missing':
|
||||
# Friendly warning dot with exclamation
|
||||
ui.canvas_oval_on(canvas, 1, 1, 17, 17, fill='#ff9f0a', outline='')
|
||||
ui.canvas_line_on(canvas, 9, 5, 9, 11, fill='white', width=2)
|
||||
ui.canvas_oval_on(canvas, 8, 13, 10, 15, fill='white', outline='white')
|
||||
|
||||
def check_then_update():
|
||||
ok, location, version = self.get_dep_info(dep)
|
||||
if ok:
|
||||
status_var.set('Installed')
|
||||
_draw_status_icon(status_icon, 'installed')
|
||||
loc_var.set(f'Location: {location or ""}')
|
||||
norm_ver = self.normalize_version(version) if version else None
|
||||
ver_var.set(f'Version: {norm_ver or ""}')
|
||||
# Ensure details visible
|
||||
try:
|
||||
loc_label.pack_configure()
|
||||
ver_label.pack_configure()
|
||||
link.pack_forget()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
status_var.set('Not installed')
|
||||
_draw_status_icon(status_icon, 'missing')
|
||||
# Hide undefined details instead of showing dashes
|
||||
try:
|
||||
loc_label.pack_forget()
|
||||
ver_label.pack_forget()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Keep install link visible if we have a command
|
||||
if dep.get('install_cmd') and not link.winfo_ismapped():
|
||||
link.pack(anchor='w', padx=24, pady=(12, 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.root.after(50, check_then_update)
|
||||
|
||||
def do_install(event=None):
|
||||
self.open_terminal_with_command(dep.get('install_cmd'))
|
||||
|
||||
if dep.get('install_cmd'):
|
||||
link.bind('<Button-1>', do_install)
|
||||
else:
|
||||
try:
|
||||
link.pack_forget()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# self._register_page(f'dep_{dep["id"]}', f)
|
||||
|
||||
def _dep_navigation_sequence(self, force_all: bool = False):
|
||||
"""Return a list of dependency page ids to traverse next.
|
||||
- If force_all is True or verify_mode is True: include all dep pages in defined order.
|
||||
- Else: include only missing dependency pages based on current checks.
|
||||
"""
|
||||
if force_all or self.verify_mode.get():
|
||||
res = [f'dep_{d["id"]}' for d in self.dependencies]
|
||||
print(f"[DEBUG] _dep_navigation_sequence (force_all={force_all}): {res}")
|
||||
return res
|
||||
seq = []
|
||||
for d in self.dependencies:
|
||||
ok, _, _ = self.get_dep_info(d)
|
||||
if not ok:
|
||||
seq.append(f'dep_{d["id"]}')
|
||||
print(f"[DEBUG] _dep_navigation_sequence: {seq}")
|
||||
return seq
|
||||
|
||||
def start_dependency_scan(self):
|
||||
"""Scan dependencies in a background thread to avoid blocking UI startup."""
|
||||
if self.validation_running:
|
||||
return
|
||||
self.validation_running = True
|
||||
|
||||
# Hold incremental results so we can update the status line and icons progressively
|
||||
self._dep_scan_results = {}
|
||||
|
||||
def worker():
|
||||
for dep in self.dependencies:
|
||||
try:
|
||||
ok, location, version = self.get_dep_info(dep)
|
||||
except Exception:
|
||||
ok, location, version = False, None, None
|
||||
did = dep['id']
|
||||
# Save and apply incrementally
|
||||
self._dep_scan_results[did] = (ok, location, version)
|
||||
self.safe_after(lambda d=did, o=ok, l=location, v=version: self._apply_dependency_incremental(d, o, l, v))
|
||||
try:
|
||||
time.sleep(0.02)
|
||||
except Exception:
|
||||
pass
|
||||
# After all are processed, finalize pass to unify any remaining labels
|
||||
self.safe_after(lambda: self._apply_dependency_scan(dict(self._dep_scan_results)))
|
||||
|
||||
t = threading.Thread(target=worker, daemon=True)
|
||||
self.validation_thread = t
|
||||
t.start()
|
||||
|
||||
def _apply_dependency_incremental(self, dep_id: str, ok: bool, location, version):
|
||||
"""Update the dependencies page row and message as each check completes."""
|
||||
if not hasattr(self, 'dep_status'):
|
||||
return
|
||||
slot = self.dep_status.get(dep_id)
|
||||
if not slot:
|
||||
return
|
||||
# Update dot icon and info text for this row
|
||||
self._draw_status(slot['canvas'], 'success' if ok else 'error')
|
||||
if ok:
|
||||
norm_ver = self.normalize_version(version) if version else ''
|
||||
slot['info'].configure(text=(norm_ver or ''))
|
||||
else:
|
||||
slot['info'].configure(text='Not installed')
|
||||
|
||||
# Update the message line with current missing list
|
||||
if hasattr(self, '_dep_scan_results'):
|
||||
missing = [self.dep_status[d]['dep']['name'] for d, res in self._dep_scan_results.items() if not res[0] and d in self.dep_status]
|
||||
if hasattr(self, 'deps_msg') and self.deps_msg is not None:
|
||||
if missing:
|
||||
self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}")
|
||||
else:
|
||||
self.deps_msg.configure(text='All dependencies installed.')
|
||||
# Footer may need to react if verify mode is enabled
|
||||
self.update_footer()
|
||||
|
||||
# Update prole.cfg data
|
||||
try:
|
||||
status_str = f"installed (version {version})" if ok else "not installed"
|
||||
self.prole_cfg_data['Dependencies'][dep_id] = status_str
|
||||
self._save_prole_cfg()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_dependency_scan(self, results: dict):
|
||||
"""Apply scan results to the summary UI and footer labels."""
|
||||
self.validation_running = False
|
||||
any_missing = False
|
||||
for did, slot in self.dep_status.items():
|
||||
ok, location, version = results.get(did, (False, None, None))
|
||||
self._draw_status(slot['canvas'], 'success' if ok else 'error')
|
||||
if ok:
|
||||
norm_ver = self.normalize_version(version) if version else ''
|
||||
slot['info'].configure(text=(norm_ver or ''))
|
||||
else:
|
||||
any_missing = True
|
||||
slot['info'].configure(text='Not installed')
|
||||
|
||||
has_msg = hasattr(self, 'deps_msg') and self.deps_msg is not None
|
||||
if any_missing:
|
||||
missing = [slot['dep']['name'] for did, slot in self.dep_status.items() if not results.get(did, (False, None, None))[0]]
|
||||
if missing and has_msg:
|
||||
self.deps_msg.configure(text=f"Preparing to install ... {', '.join(missing)}")
|
||||
else:
|
||||
if has_msg:
|
||||
self.deps_msg.configure(text='All dependencies installed.')
|
||||
self.update_footer()
|
||||
|
||||
def refresh_dependencies_ui(self):
|
||||
"""Deprecated synchronous refresh retained for compatibility.
|
||||
Prefer start_dependency_scan -> _apply_dependency_scan.
|
||||
"""
|
||||
self.start_dependency_scan()
|
||||
|
||||
def all_dependencies_installed(self):
|
||||
for dep in self.dependencies:
|
||||
ok, _, _ = self.get_dep_info(dep)
|
||||
if not ok:
|
||||
print(f"[DEBUG] all_dependencies_installed: '{dep['id']}' is MISSING")
|
||||
return False
|
||||
print("[DEBUG] all_dependencies_installed: YES (all OK)")
|
||||
return True
|
||||
|
||||
def get_dep_info(self, dep):
|
||||
"""Delegate dependency probing to installer.config.get_dep_info."""
|
||||
return inst_config.get_dep_info(dep)
|
||||
|
||||
def normalize_version(self, text: str) -> str:
|
||||
"""Normalize versions via installer.config.normalize_version."""
|
||||
return inst_config.normalize_version(text)
|
||||
637
installer/ui/screens/deploy.py
Normal file
637
installer/ui/screens/deploy.py
Normal file
@ -0,0 +1,637 @@
|
||||
"""Deployment screen, final deployment pipeline and LaunchAgent setup."""
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import secrets
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import screen as ui
|
||||
from installer.core.env import PROJECT_ROOT
|
||||
|
||||
|
||||
class DeployScreenMixin:
|
||||
"""Deployment screen, final deployment pipeline and LaunchAgent setup."""
|
||||
|
||||
def _render_deploy_page(self):
|
||||
self._render_title('Deploy', y=40)
|
||||
self._render_paragraph('Preparing to deploy. We will verify steps and perform actions as needed.', y=90)
|
||||
# Render simple list of steps (static view). Runtime updates can redraw as needed.
|
||||
y = 140
|
||||
left = 56
|
||||
if not hasattr(self, 'deploy_steps'):
|
||||
self.deploy_steps = [
|
||||
{'name': 'Build Prole macOS app', 'status': 'pending'},
|
||||
{'name': 'Check Docker is running', 'status': 'pending'},
|
||||
{'name': 'Install Prole Tools.app', 'status': 'pending'},
|
||||
]
|
||||
for step in self.deploy_steps:
|
||||
# status circle (pending empty)
|
||||
self._canvas_items.append(ui.canvas_oval(self, left, y, left+18, y+18, outline='#b0b0b0'))
|
||||
self._canvas_items.append(ui.canvas_text(self, left+26, y-2, step['name'], fill='#1d1d1f', font=('Helvetica', 12)))
|
||||
y += 26
|
||||
|
||||
def on_deploy(self):
|
||||
if getattr(self, '_deploy_running', False):
|
||||
return
|
||||
self._deploy_running = True
|
||||
try:
|
||||
if self.deploy_button:
|
||||
self.deploy_button.configure(state='disabled')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def worker():
|
||||
err = None
|
||||
try:
|
||||
self.ensure_prole_env()
|
||||
self.reload_env_from_shell()
|
||||
except Exception as e:
|
||||
err = f"Failed to load environment: {e}"
|
||||
try:
|
||||
self._ensure_prole_directories()
|
||||
if 'Install' in self.prole_cfg_data:
|
||||
self.prole_cfg_data['Install']['STATUS'] = 'Deployed'
|
||||
self._save_prole_cfg()
|
||||
except Exception as e:
|
||||
err = err or f"Failed to prepare Prole directories: {e}"
|
||||
|
||||
if err:
|
||||
self.safe_after(lambda: messagebox.showerror("Deploy", err))
|
||||
else:
|
||||
self.safe_after(self.open_drag_install_window)
|
||||
|
||||
def _finish():
|
||||
self._deploy_running = False
|
||||
try:
|
||||
if self.deploy_button:
|
||||
self.deploy_button.configure(state='normal')
|
||||
except Exception:
|
||||
pass
|
||||
self.safe_after(_finish)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def on_launch(self):
|
||||
app_path = self._get_prole_dist_dir() / 'Prole Tools.app'
|
||||
if app_path.exists():
|
||||
try:
|
||||
subprocess.Popen(['open', str(app_path)])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
messagebox.showerror("Launch", f"App not found at {app_path}. Please build it first.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run_final_deployment(self):
|
||||
initial_dir = str(self._resolve_env_dir('PROLE_DATA', 'data'))
|
||||
try:
|
||||
if not Path(initial_dir).exists():
|
||||
initial_dir = str(Path.home())
|
||||
except Exception:
|
||||
initial_dir = str(Path.home())
|
||||
|
||||
export_base = filedialog.askdirectory(
|
||||
initialdir=initial_dir,
|
||||
title="Select deployment export directory"
|
||||
)
|
||||
if not export_base:
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(
|
||||
self._save_deployment_status_label,
|
||||
text="Save canceled.",
|
||||
fill='#6e6e73'
|
||||
) if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None)
|
||||
return
|
||||
|
||||
export_base = str(Path(export_base).expanduser())
|
||||
docker_export_dir = str(Path(export_base) / "docker-import")
|
||||
helm_export_dir = str(Path(export_base) / "helm-chart")
|
||||
kustomize_export_dir = str(Path(export_base) / "kustomize")
|
||||
|
||||
def worker():
|
||||
self.safe_after(lambda: self._save_deployment_button.configure(state='disabled') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None)
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Saving deployment...", fill='blue') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None)
|
||||
|
||||
script_name = "final_deployment.sh"
|
||||
if hasattr(self, 'install_consoles') and script_name in self.install_consoles:
|
||||
self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PROLE_HOME"] = str(PROJECT_ROOT)
|
||||
env["DOCKER_IMPORT_DIR"] = docker_export_dir
|
||||
env["HELM_CHART_DIR"] = helm_export_dir
|
||||
env["KUSTOMIZE_DIR"] = kustomize_export_dir
|
||||
|
||||
console = getattr(self, 'install_consoles', {}).get(script_name)
|
||||
if console:
|
||||
console.clear()
|
||||
console.write("Saving deployment artifacts...\n")
|
||||
console.write(f"Export base: {export_base}\n")
|
||||
console.write(f"Docker images: {docker_export_dir}\n")
|
||||
console.write(f"Helm chart: {helm_export_dir}\n")
|
||||
console.write(f"Kustomize: {kustomize_export_dir}\n\n")
|
||||
|
||||
def _on_line(line):
|
||||
if console:
|
||||
console.write(line)
|
||||
|
||||
rc = self.controller.run_script(
|
||||
script_name,
|
||||
args=['--docker-export', '--helm-chart', '--kustomize', '--output-dir', export_base],
|
||||
env=env,
|
||||
on_line=_on_line
|
||||
)
|
||||
|
||||
if rc == 0:
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text="Deployment saved successfully.", fill='#34c759') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None)
|
||||
else:
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._save_deployment_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_save_deployment_status_label') and self.bg_canvas.winfo_exists() else None)
|
||||
|
||||
self.safe_after(lambda: self._save_deployment_button.configure(state='normal') if hasattr(self, '_save_deployment_button') and self._save_deployment_button.winfo_exists() else None)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def run_build_a_bao(self):
|
||||
def worker():
|
||||
self.safe_after(lambda: self._build_a_bao_button.configure(state='disabled') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None)
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Saving secrets to OpenBao...", fill='blue') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None)
|
||||
|
||||
script_name = "build-a-bao.sh"
|
||||
if hasattr(self, 'install_consoles') and script_name in self.install_consoles:
|
||||
self.safe_after(lambda: self.install_tabs.select(self.install_consoles[script_name].master) if hasattr(self, 'install_tabs') and self.install_tabs.winfo_exists() else None)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PROLE_HOME"] = str(PROJECT_ROOT)
|
||||
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
|
||||
env["NAMESPACE"] = (self.db_namespace.get() or '').strip()
|
||||
|
||||
console = getattr(self, 'install_consoles', {}).get(script_name)
|
||||
if console:
|
||||
console.clear()
|
||||
console.write(f"Running {script_name} ...\n")
|
||||
|
||||
def _on_line(line):
|
||||
if console:
|
||||
console.write(line)
|
||||
|
||||
rc = self.controller.run_script(
|
||||
script_name,
|
||||
env=env,
|
||||
on_line=_on_line
|
||||
)
|
||||
|
||||
if rc == 0:
|
||||
self._secrets_finalized = True
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text="Secrets saved to OpenBao.", fill='#34c759') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None)
|
||||
else:
|
||||
self.safe_after(lambda: self.bg_canvas.itemconfig(self._build_a_bao_status_label, text=f"Failed with code {rc}", fill='#ff3b30') if hasattr(self, '_build_a_bao_status_label') and self.bg_canvas.winfo_exists() else None)
|
||||
|
||||
self.safe_after(lambda: self._build_a_bao_button.configure(state='normal') if hasattr(self, '_build_a_bao_button') and self._build_a_bao_button.winfo_exists() else None)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def create_deploy_screen(self):
|
||||
"""Create the Deploy screen"""
|
||||
frame = tk.Frame(self.content_area, bg='#1a1a1a')
|
||||
self.screens['deploy'] = frame
|
||||
|
||||
# Title
|
||||
title = ttk.Label(frame, text="Build and Deploy", style='Title.TLabel')
|
||||
title.pack(pady=(0, 30))
|
||||
|
||||
# Instructions
|
||||
instructions = tk.Label(frame,
|
||||
text="Build and deploy Prole services to k3d cluster",
|
||||
bg='#1a1a1a',
|
||||
fg='#aaaaaa',
|
||||
font=('Helvetica', 11))
|
||||
instructions.pack(pady=(0, 20))
|
||||
|
||||
# Environment selector
|
||||
env_frame = tk.Frame(frame, bg='#1a1a1a')
|
||||
env_frame.pack(pady=(0, 10), fill='x')
|
||||
env_label = tk.Label(env_frame, text="Target Environment:", bg='#1a1a1a', fg='#dddddd', font=('Helvetica', 11))
|
||||
env_label.pack(side='left', padx=(0, 10))
|
||||
self.deploy_environment = tk.StringVar(value='Dev')
|
||||
env_combo = ttk.Combobox(env_frame, textvariable=self.deploy_environment, values=['Dev', 'Service', 'Prod'], state='readonly', width=18)
|
||||
env_combo.pack(side='left')
|
||||
env_help = tk.Label(env_frame,
|
||||
text="Dev = local k3d (prole-dev-cluster), Service = remote k3s at pi.prole.org:6443, Prod = stretch of prole-service-cluster",
|
||||
bg='#1a1a1a', fg='#888888', font=('Helvetica', 9))
|
||||
env_help.pack(side='left', padx=(12, 0))
|
||||
|
||||
# Deploy button
|
||||
deploy_btn = tk.Button(frame,
|
||||
text="Build and Deploy",
|
||||
command=self.start_deployment,
|
||||
bg='#4a9eff',
|
||||
fg='white',
|
||||
activebackground='#3a8eef',
|
||||
font=('Helvetica', 16, 'bold'),
|
||||
padx=40,
|
||||
pady=20,
|
||||
cursor='hand2',
|
||||
relief='flat')
|
||||
deploy_btn.pack(pady=20)
|
||||
|
||||
# Progress frame
|
||||
progress_frame = tk.Frame(frame, bg='#1a1a1a')
|
||||
progress_frame.pack(fill='both', expand=True, pady=20)
|
||||
|
||||
# Progress list
|
||||
# Initialize deploy steps; some names are updated dynamically when deployment starts
|
||||
self.deploy_steps = [
|
||||
{'name': 'Build ProleStatus macOS app', 'status': 'pending'},
|
||||
{'name': 'Check Docker is running', 'status': 'pending'},
|
||||
{'name': 'Check or configure container registry', 'status': 'pending'},
|
||||
{'name': 'Ensure target cluster', 'status': 'pending'},
|
||||
{'name': 'Build prole-db Docker image', 'status': 'pending'},
|
||||
{'name': 'Tag Docker image for registry', 'status': 'pending'},
|
||||
{'name': 'Push image to registry', 'status': 'pending'},
|
||||
{'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'},
|
||||
]
|
||||
|
||||
self.deploy_widgets = {}
|
||||
for step in self.deploy_steps:
|
||||
self.create_deploy_step_widget(progress_frame, step)
|
||||
|
||||
def _create_page_deploy(self):
|
||||
f = self._page_container()
|
||||
ttk.Label(f, text='Deploy', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
||||
ttk.Label(f, text='Preparing to deploy. We will verify steps and perform actions as needed.', style='Body.TLabel', wraplength=800).pack(anchor='w', padx=24)
|
||||
|
||||
# Reuse existing deploy steps UI but on light theme
|
||||
self.deploy_steps = [
|
||||
{'name': 'Build Prole macOS app', 'status': 'pending'},
|
||||
{'name': 'Check Docker is running', 'status': 'pending'},
|
||||
{'name': 'Check or configure container registry', 'status': 'pending'},
|
||||
{'name': 'Ensure target cluster', 'status': 'pending'},
|
||||
{'name': 'Build prole-db Docker image', 'status': 'pending'},
|
||||
{'name': 'Tag Docker image for registry', 'status': 'pending'},
|
||||
{'name': 'Push image to registry', 'status': 'pending'},
|
||||
{'name': 'Import image to k3d cluster (Dev only)', 'status': 'pending'},
|
||||
{'name': 'Install LaunchAgent for port-forwards (Dev)', 'status': 'pending'},
|
||||
]
|
||||
self.deploy_widgets = {}
|
||||
container = ttk.Frame(f)
|
||||
container.pack(fill='both', expand=True, padx=16, pady=8)
|
||||
for step in self.deploy_steps:
|
||||
self._create_deploy_row(container, step)
|
||||
|
||||
# self._register_page('deploy', f)
|
||||
|
||||
def create_deploy_step_widget(self, parent, step):
|
||||
"""Create a widget for a deployment step"""
|
||||
step_frame = tk.Frame(parent, bg='#2a2a2a', relief='flat')
|
||||
step_frame.pack(fill='x', pady=5, padx=10)
|
||||
|
||||
# Status indicator
|
||||
status_canvas = tk.Canvas(step_frame, width=30, height=30, bg='#2a2a2a', highlightthickness=0)
|
||||
status_canvas.pack(side='left', padx=15, pady=15)
|
||||
|
||||
# Step name
|
||||
name_label = tk.Label(step_frame,
|
||||
text=step['name'],
|
||||
bg='#2a2a2a',
|
||||
fg='#ffffff',
|
||||
font=('Helvetica', 11),
|
||||
anchor='w')
|
||||
name_label.pack(side='left', fill='x', expand=True, padx=10)
|
||||
|
||||
# Status text
|
||||
status_label = tk.Label(step_frame,
|
||||
text="Pending",
|
||||
bg='#2a2a2a',
|
||||
fg='#aaaaaa',
|
||||
font=('Helvetica', 10))
|
||||
status_label.pack(side='right', padx=15)
|
||||
|
||||
self.deploy_widgets[step['name']] = {
|
||||
'canvas': status_canvas,
|
||||
'label': status_label,
|
||||
'step': step
|
||||
}
|
||||
|
||||
# Draw initial pending state
|
||||
self.update_deploy_step_status(step['name'], 'pending')
|
||||
|
||||
def _create_deploy_row(self, parent, step):
|
||||
row = ttk.Frame(parent)
|
||||
row.pack(fill='x', pady=6)
|
||||
canvas = tk.Canvas(row, width=20, height=20, highlightthickness=0)
|
||||
canvas.pack(side='left', padx=8)
|
||||
lbl = ttk.Label(row, text=step['name'], style='Body.TLabel')
|
||||
lbl.pack(side='left')
|
||||
status = ttk.Label(row, text='Pending', style='Dim.TLabel')
|
||||
status.pack(side='right', padx=8)
|
||||
self.deploy_widgets[step['name']] = {'canvas': canvas, 'label': status, 'step': step}
|
||||
self._draw_status(canvas, 'pending')
|
||||
|
||||
def update_deploy_step_status(self, step_name, status):
|
||||
"""Update the status of a deployment step"""
|
||||
widget = self.deploy_widgets[step_name]
|
||||
canvas = widget['canvas']
|
||||
label = widget['label']
|
||||
step = widget['step']
|
||||
|
||||
step['status'] = status
|
||||
canvas.delete('all')
|
||||
|
||||
if status == 'pending':
|
||||
ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2)
|
||||
label.configure(text="Pending", fg='#aaaaaa')
|
||||
elif status == 'running':
|
||||
ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#ffaa00', width=2, fill='#ffaa00')
|
||||
label.configure(text="Running...", fg='#ffaa00')
|
||||
elif status == 'completed':
|
||||
ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#28a745', width=2, fill='#28a745')
|
||||
ui.canvas_text_on(canvas, 15, 15, '✓', fill='white', font=('Helvetica', 16, 'bold'))
|
||||
label.configure(text="Completed", fg='#28a745')
|
||||
elif status == 'error':
|
||||
ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#dc3545', width=2, fill='#dc3545')
|
||||
ui.canvas_text_on(canvas, 15, 15, '✗', fill='white', font=('Helvetica', 16, 'bold'))
|
||||
label.configure(text="Error", fg='#dc3545')
|
||||
elif status == 'skipped':
|
||||
ui.canvas_oval_on(canvas, 5, 5, 25, 25, outline='#666', width=2, fill='#444444')
|
||||
label.configure(text="Skipped", fg='#888888')
|
||||
|
||||
def start_deployment(self):
|
||||
"""Start the deployment process"""
|
||||
threading.Thread(target=self.run_deployment, daemon=True).start()
|
||||
|
||||
def generate_prole_properties(self, env: str):
|
||||
"""Dynamically generate prole-tools-app/prole.properties based on environment"""
|
||||
props_path = Path("prole-tools-app/prole.properties")
|
||||
|
||||
# Determine host based on environment
|
||||
# For now use localhost as a placeholder for Service/Prod
|
||||
host = "localhost"
|
||||
|
||||
content = f"""# Prole default endpoints (dynamically generated by install.py)
|
||||
# UI assets
|
||||
icon=img/proleIcon.png
|
||||
background=img/proleLogoSepia.png
|
||||
|
||||
# Dev port-forward supervision
|
||||
pf.enabled=true
|
||||
|
||||
# Service endpoints (5 traffic lights)
|
||||
svc.1.name=K3D
|
||||
svc.1.host={host}
|
||||
svc.1.port=6443
|
||||
|
||||
svc.2.name=Prometheus
|
||||
svc.2.host={host}
|
||||
svc.2.port=9090
|
||||
|
||||
svc.3.name=Grafana
|
||||
svc.3.host={host}
|
||||
svc.3.port=3000
|
||||
|
||||
svc.4.name=OpenBAO
|
||||
svc.4.host={host}
|
||||
svc.4.port=8200
|
||||
|
||||
svc.5.name=PostgreSQL
|
||||
svc.5.host={host}
|
||||
svc.5.port=5432
|
||||
|
||||
# Kerberos configuration
|
||||
kerberos.enabled={str(self.kerberos_enabled.get()).lower()}
|
||||
kerberos.realm={self.kerberos_realm.get()}
|
||||
kerberos.user={self.kerberos_user.get()}
|
||||
kerberos.kdc={self.kerberos_kdc.get()}
|
||||
"""
|
||||
props_path.write_text(content)
|
||||
print(f"Generated {props_path} for {env} environment")
|
||||
|
||||
def run_deployment(self):
|
||||
"""Run the deployment steps"""
|
||||
try:
|
||||
# Capture environment selection and prepare dynamic labels
|
||||
env = self.deploy_environment.get().strip()
|
||||
if env not in ('Dev', 'Service', 'Prod'):
|
||||
env = 'Dev'
|
||||
|
||||
# Update step labels to reflect environment
|
||||
self.deploy_widgets['Ensure target cluster']['step']['name'] = f"Ensure target cluster ({env})"
|
||||
# Use the correct way to update the label text in the UI
|
||||
self.deploy_widgets['Ensure target cluster']['label'].master.winfo_children()[1].configure(text=f"Ensure target cluster ({env})")
|
||||
|
||||
# Step 0: Generate prole.properties
|
||||
self.generate_prole_properties(env)
|
||||
|
||||
# Step 1: Build Prole macOS app
|
||||
self.update_deploy_step_status('Build Prole macOS app', 'running')
|
||||
self.build_prole_app()
|
||||
self.update_deploy_step_status('Build Prole macOS app', 'completed')
|
||||
|
||||
# Step 1: Check Docker
|
||||
self.update_deploy_step_status('Check Docker is running', 'running')
|
||||
if not self.check_docker_running():
|
||||
self.update_deploy_step_status('Check Docker is running', 'error')
|
||||
messagebox.showerror("Error", "Docker is not running. Please start Docker Desktop.")
|
||||
return
|
||||
self.update_deploy_step_status('Check Docker is running', 'completed')
|
||||
|
||||
# Step 2: Registry discovery/config
|
||||
self.update_deploy_step_status('Check or configure container registry', 'running')
|
||||
self.registry_url = self.ensure_registry_available(env)
|
||||
self.update_deploy_step_status('Check or configure container registry', 'completed')
|
||||
|
||||
# Step 3: Ensure target cluster as per environment
|
||||
self.update_deploy_step_status('Ensure target cluster', 'running')
|
||||
self.create_or_select_cluster(env)
|
||||
self.update_deploy_step_status('Ensure target cluster', 'completed')
|
||||
|
||||
# Step 3: Build Docker image
|
||||
self.update_deploy_step_status('Build prole-db Docker image', 'running')
|
||||
self.build_docker_image()
|
||||
self.update_deploy_step_status('Build prole-db Docker image', 'completed')
|
||||
|
||||
# Step 4: Tag image
|
||||
self.update_deploy_step_status('Tag Docker image for registry', 'running')
|
||||
self.tag_docker_image()
|
||||
self.update_deploy_step_status('Tag Docker image for registry', 'completed')
|
||||
|
||||
# Step 5: Push to registry
|
||||
self.update_deploy_step_status('Push image to registry', 'running')
|
||||
self.push_docker_image()
|
||||
self.update_deploy_step_status('Push image to registry', 'completed')
|
||||
|
||||
# Step 6: Import to k3d (Dev only)
|
||||
if env == 'Dev':
|
||||
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'running')
|
||||
self.import_k3d_image(cluster_name='prole-dev-cluster')
|
||||
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'completed')
|
||||
else:
|
||||
self.update_deploy_step_status('Import image to k3d cluster (Dev only)', 'skipped')
|
||||
|
||||
# Step 7: Install LaunchAgent for port-forwards (Dev)
|
||||
if env == 'Dev':
|
||||
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'running')
|
||||
ok = self.install_launchagent_port_forwards()
|
||||
if ok:
|
||||
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'completed')
|
||||
else:
|
||||
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'error')
|
||||
return
|
||||
else:
|
||||
self.update_deploy_step_status('Install LaunchAgent for port-forwards (Dev)', 'skipped')
|
||||
|
||||
messagebox.showinfo("Success", "Deployment completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Deployment failed: {str(e)}")
|
||||
|
||||
def install_launchagent_port_forwards(self) -> bool:
|
||||
"""Create/update the user LaunchAgent and helper script to manage kubectl port-forwards.
|
||||
- Helper script: ~/Library/Application Support/Prole/bin/prole-kpf.sh
|
||||
- LaunchAgent: ~/Library/LaunchAgents/org.prole.prole-db.kpf-dev.plist
|
||||
"""
|
||||
try:
|
||||
home = Path.home()
|
||||
bin_dir = home / 'Library' / 'Application Support' / 'Prole' / 'bin'
|
||||
run_dir = home / 'Library' / 'Application Support' / 'Prole' / 'run'
|
||||
plist_path = home / 'Library' / 'LaunchAgents' / 'org.prole.prole-db.kpf-dev.plist'
|
||||
script_path = bin_dir / 'prole-kpf.sh'
|
||||
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
helper_script = """#!/bin/sh
|
||||
set -eu
|
||||
|
||||
LABEL="org.prole.prole-db.kpf-dev"
|
||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||
RUNDIR="$HOME/Library/Application Support/Prole/run"
|
||||
PIDFILE="$RUNDIR/kpf.pids"
|
||||
|
||||
ensure_rundir() {
|
||||
mkdir -p "$RUNDIR"
|
||||
}
|
||||
|
||||
list_cmds() {
|
||||
# Enumerate ProleCommands array using PlistBuddy
|
||||
if /usr/libexec/PlistBuddy -c "Print :ProleCommands" "$PLIST" >/dev/null 2>&1; then
|
||||
i=0
|
||||
while true; do
|
||||
if ! val=$(/usr/libexec/PlistBuddy -c "Print :ProleCommands:$i" "$PLIST" 2>/dev/null); then
|
||||
break
|
||||
fi
|
||||
echo "$val"
|
||||
i=$((i+1))
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
start() {
|
||||
ensure_rundir
|
||||
: > "$PIDFILE"
|
||||
IFS='\n'
|
||||
for cmd in $(list_cmds); do
|
||||
[ -z "$cmd" ] && continue
|
||||
(sh -lc "$cmd") &
|
||||
echo $! >> "$PIDFILE"
|
||||
done
|
||||
wait
|
||||
}
|
||||
|
||||
stop() {
|
||||
if [ -f "$PIDFILE" ]; then
|
||||
while read -r pid; do
|
||||
[ -z "$pid" ] && continue
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done < "$PIDFILE"
|
||||
rm -f "$PIDFILE"
|
||||
fi
|
||||
}
|
||||
|
||||
status() {
|
||||
if [ ! -f "$PIDFILE" ]; then
|
||||
echo "not running"
|
||||
exit 3
|
||||
fi
|
||||
alive=0
|
||||
total=0
|
||||
while read -r pid; do
|
||||
[ -z "$pid" ] && continue
|
||||
total=$((total+1))
|
||||
if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi
|
||||
done < "$PIDFILE"
|
||||
echo "$alive/$total running"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
restart) stop; start ;;
|
||||
status) status ;;
|
||||
*) echo "Usage: $0 {start|stop|restart|status}" >&2; exit 2 ;;
|
||||
esac
|
||||
"""
|
||||
|
||||
script_path.write_text(helper_script)
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
# Default commands for Dev environment (k3d)
|
||||
default_cmds = [
|
||||
"kubectl port-forward -n monitoring svc/kps-kube-prometheus-stack-prometheus 9090",
|
||||
"kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443",
|
||||
"kubectl port-forward svc/prole-db-rw 5432:5432 --address 0.0.0.0",
|
||||
"kubectl port-forward -n monitoring svc/kps-grafana 3000:80 --address 0.0.0.0",
|
||||
]
|
||||
|
||||
# Create plist dict
|
||||
from plistlib import dumps as plist_dumps
|
||||
plist_dict = {
|
||||
'Label': 'org.prole.prole-db.kpf-dev',
|
||||
'RunAtLoad': True,
|
||||
'KeepAlive': True,
|
||||
# Ensure PATH has common locations for kubectl
|
||||
'EnvironmentVariables': {
|
||||
'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin')
|
||||
},
|
||||
'StandardOutPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.out.log'),
|
||||
'StandardErrorPath': str(home / 'Library' / 'Logs' / 'org.prole.prole-db.kpf-dev.err.log'),
|
||||
'ProgramArguments': [str(script_path), 'start'],
|
||||
'ProleCommands': default_cmds,
|
||||
}
|
||||
|
||||
plist_bytes = plist_dumps(plist_dict)
|
||||
plist_path.write_bytes(plist_bytes)
|
||||
|
||||
# Reload the agent
|
||||
uid = os.getuid()
|
||||
# Try to kickstart first
|
||||
subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True)
|
||||
# Bootout and bootstrap to ensure it's loaded, then kickstart
|
||||
subprocess.run(['launchctl', 'bootout', f'gui/{uid}', f'gui/{uid}/org.prole.prole-db.kpf-dev'], capture_output=True)
|
||||
subprocess.run(['launchctl', 'bootstrap', f'gui/{uid}', str(plist_path)], check=True)
|
||||
subprocess.run(['launchctl', 'kickstart', '-k', f'gui/{uid}/org.prole.prole-db.kpf-dev'], check=True)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"LaunchAgent setup failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _draw_status(self, canvas, status):
|
||||
ui.canvas_clear(canvas)
|
||||
if status == 'success':
|
||||
ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#34c759', outline='')
|
||||
ui.canvas_line_on(canvas, 5, 10, 9, 14, fill='white', width=2)
|
||||
ui.canvas_line_on(canvas, 9, 14, 16, 6, fill='white', width=2)
|
||||
elif status == 'running':
|
||||
ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ffd60a', outline='')
|
||||
elif status == 'error':
|
||||
ui.canvas_oval_on(canvas, 2, 2, 18, 18, fill='#ff3b30', outline='')
|
||||
else:
|
||||
ui.canvas_oval_on(canvas, 2, 2, 18, 18, outline='#b0b0b0')
|
||||
|
||||
# --------------- Dependency helpers ---------------
|
||||
496
installer/ui/screens/docker.py
Normal file
496
installer/ui/screens/docker.py
Normal file
@ -0,0 +1,496 @@
|
||||
"""Docker image build, registry management and image pre-pull."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from installer.core.env import (
|
||||
PROJECT_ROOT,
|
||||
_collect_images_from_files,
|
||||
_deployment_mode_from_env,
|
||||
_http_ping_registry,
|
||||
_k3d_prole_data_volume_args,
|
||||
_local_registry_enabled,
|
||||
_normalize_cluster_env,
|
||||
_push_docker_image,
|
||||
_resolve_supabase_home,
|
||||
)
|
||||
|
||||
|
||||
class DockerScreenMixin:
|
||||
"""Docker image build, registry management and image pre-pull."""
|
||||
|
||||
def check_docker_running(self):
|
||||
"""Check if Docker is running"""
|
||||
try:
|
||||
result = subprocess.run(['docker', 'ps'],
|
||||
capture_output=True,
|
||||
timeout=10)
|
||||
return result.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def ensure_registry_available(self, env: str) -> str:
|
||||
"""Detect if k8s.prole.org:5000 is reachable; if so use it. Otherwise ensure a local registry is available.
|
||||
Returns the registry URL (host:port) to be used for tagging/pushing.
|
||||
For Dev, may create a local k3d-managed registry; for other envs, still prefer the external if reachable.
|
||||
"""
|
||||
# Prefer the shared registry if reachable
|
||||
if _http_ping_registry('k8s.prole.org', 5000):
|
||||
return 'k8s.prole.org:5000'
|
||||
|
||||
if not _local_registry_enabled(env):
|
||||
return ''
|
||||
|
||||
# Otherwise, ensure a local registry (localhost:5000) exists/started
|
||||
if _http_ping_registry('localhost', 5000):
|
||||
return 'localhost:5000'
|
||||
|
||||
# Use k3d registry helper for Dev; for non-Dev, we still create/use local for pushing
|
||||
reg_name = 'prole-registry'
|
||||
# Check if k3d is installed
|
||||
k3d_exists = subprocess.run(['which', 'k3d'], capture_output=True).returncode == 0
|
||||
if k3d_exists:
|
||||
# List registries
|
||||
lst = subprocess.run(['k3d', 'registry', 'list'], capture_output=True, text=True)
|
||||
if reg_name not in (lst.stdout or ''):
|
||||
# Create registry exposed on 0.0.0.0:5000
|
||||
subprocess.run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True)
|
||||
return 'localhost:5000'
|
||||
|
||||
def ensure_local_registry_available(self):
|
||||
"""Ensure a local registry is running and return (host_registry, cluster_registry)."""
|
||||
mode_hint = None
|
||||
try:
|
||||
mode_hint = self._deployment_mode()
|
||||
except Exception:
|
||||
mode_hint = None
|
||||
|
||||
if not _local_registry_enabled(mode_hint):
|
||||
try:
|
||||
log_path = self._registry_log_path()
|
||||
with log_path.open('a', encoding='utf-8') as fp:
|
||||
fp.write(f"\n# Registry disabled (mode={mode_hint or 'unknown'}) @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
log_path = self._registry_log_path()
|
||||
try:
|
||||
log_fp = log_path.open('a', encoding='utf-8')
|
||||
except Exception:
|
||||
log_fp = None
|
||||
|
||||
def _log(msg: str):
|
||||
if not log_fp:
|
||||
return
|
||||
try:
|
||||
log_fp.write(msg)
|
||||
log_fp.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _log_cmd(cmd: list[str], res: subprocess.CompletedProcess):
|
||||
_log(f"$ {' '.join(cmd)}\n")
|
||||
if res.stdout:
|
||||
_log(res.stdout)
|
||||
if res.stderr:
|
||||
_log(res.stderr)
|
||||
_log(f"[exit {res.returncode}]\n")
|
||||
|
||||
def _run(cmd: list[str], check: bool = False):
|
||||
res = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
_log_cmd(cmd, res)
|
||||
if check:
|
||||
raise RuntimeError(f"Command failed: {' '.join(cmd)}")
|
||||
return res
|
||||
|
||||
_log(f"\n# Registry check @ {time.strftime('%Y-%m-%d %H:%M:%S')} (mode={mode_hint or 'unknown'})\n")
|
||||
reg_name = 'prole-registry'
|
||||
registry_container = f'k3d-{reg_name}'
|
||||
host_registry = 'localhost:5000'
|
||||
|
||||
mode = _deployment_mode_from_env(self.prole_cfg_data.get('Global', {}).get('DEPLOYMENT_MODE'))
|
||||
if mode == 'k3d':
|
||||
cluster_registry = f'{registry_container}.localhost:5000'
|
||||
else:
|
||||
cluster_registry = host_registry
|
||||
|
||||
if not self.check_docker_running():
|
||||
_log("Docker not running; cannot ensure local registry.\n")
|
||||
if log_fp:
|
||||
log_fp.close()
|
||||
return None
|
||||
|
||||
# 1. If a registry is already responsive on localhost:5000, we use it.
|
||||
if _http_ping_registry('localhost', 5000):
|
||||
# Check if it's a k3d registry to use the correct cluster internal name
|
||||
try:
|
||||
lst = _run(['k3d', 'registry', 'list'])
|
||||
if registry_container in (lst.stdout or '') or reg_name in (lst.stdout or ''):
|
||||
cluster_registry = f'{registry_container}.localhost:5000'
|
||||
else:
|
||||
cluster_registry = host_registry
|
||||
except Exception:
|
||||
cluster_registry = host_registry
|
||||
|
||||
self.local_registry_url = host_registry
|
||||
self.local_registry_internal = cluster_registry
|
||||
try:
|
||||
if 'Docker Build' not in self.prole_cfg_data:
|
||||
self.prole_cfg_data['Docker Build'] = {}
|
||||
self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY'] = host_registry
|
||||
self.prole_cfg_data['Docker Build']['LOCAL_REGISTRY_INTERNAL'] = cluster_registry
|
||||
self.safe_after(self._save_prole_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
if log_fp:
|
||||
log_fp.close()
|
||||
return host_registry, cluster_registry
|
||||
|
||||
# 2. Try k3d first if available
|
||||
k3d_exists = _run(['which', 'k3d']).returncode == 0
|
||||
if k3d_exists:
|
||||
try:
|
||||
# Ensure registry exists
|
||||
lst = _run(['k3d', 'registry', 'list'])
|
||||
if registry_container not in (lst.stdout or '') and reg_name not in (lst.stdout or ''):
|
||||
_run(['k3d', 'registry', 'create', reg_name, '--port', '0.0.0.0:5000'], check=True)
|
||||
|
||||
# Ensure registry container is running
|
||||
running = _run(
|
||||
['docker', 'ps', '--filter', f'name={registry_container}', '--format', '{{.Names}}'],
|
||||
)
|
||||
if not (running.stdout or '').strip():
|
||||
_run(['docker', 'start', registry_container], check=True)
|
||||
|
||||
self.local_registry_url = host_registry
|
||||
self.local_registry_internal = f'{registry_container}.localhost:5000'
|
||||
if log_fp:
|
||||
log_fp.close()
|
||||
return self.local_registry_url, self.local_registry_internal
|
||||
except Exception:
|
||||
_log("k3d registry setup failed.\n")
|
||||
|
||||
# 3. Fallback to plain Docker registry
|
||||
try:
|
||||
docker_exists = _run(['which', 'docker']).returncode == 0
|
||||
if docker_exists:
|
||||
# Check if prole-registry container exists
|
||||
exists = _run(
|
||||
['docker', 'ps', '-a', '--filter', 'name=^/prole-registry$', '--format', '{{.Names}}'],
|
||||
)
|
||||
if (exists.stdout or '').strip():
|
||||
_run(['docker', 'start', 'prole-registry'], check=True)
|
||||
else:
|
||||
_run([
|
||||
'docker', 'run', '-d', '-p', '5000:5000',
|
||||
'--restart', 'always', '--name', 'prole-registry', 'registry:2'
|
||||
], check=True)
|
||||
|
||||
# Wait a moment for it to start
|
||||
time.sleep(1)
|
||||
if _http_ping_registry('localhost', 5000):
|
||||
self.local_registry_url = host_registry
|
||||
self.local_registry_internal = host_registry
|
||||
if log_fp:
|
||||
log_fp.close()
|
||||
return host_registry, host_registry
|
||||
except Exception:
|
||||
_log("Docker registry fallback failed.\n")
|
||||
|
||||
if log_fp:
|
||||
log_fp.close()
|
||||
return None
|
||||
|
||||
def _collect_dependent_images(self, include_supabase: bool, include_kerberos_proxy: bool) -> list[str]:
|
||||
images = set()
|
||||
for rel_dir in ('k8s/prole', 'k8s/openbao'):
|
||||
base_dir = PROJECT_ROOT / rel_dir
|
||||
if not base_dir.exists():
|
||||
continue
|
||||
images.update(_collect_images_from_files(list(base_dir.glob('*.yaml'))))
|
||||
|
||||
if include_supabase:
|
||||
supa_home = _resolve_supabase_home(PROJECT_ROOT)
|
||||
if supa_home:
|
||||
docker_dir = supa_home / 'docker'
|
||||
compose_files = [docker_dir / 'docker-compose.yml']
|
||||
if os.environ.get('SUPABASE_USE_DEV_COMPOSE') == '1':
|
||||
compose_files.append(docker_dir / 'dev' / 'docker-compose.dev.yml')
|
||||
images.update(_collect_images_from_files(compose_files))
|
||||
|
||||
if not include_supabase:
|
||||
images = {img for img in images if 'supabase' not in img}
|
||||
|
||||
if include_kerberos_proxy:
|
||||
krb_img = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat')
|
||||
if krb_img:
|
||||
images.add(krb_img)
|
||||
|
||||
return sorted(images)
|
||||
|
||||
def _prepull_images_to_registry(self, include_supabase: bool, include_kerberos_proxy: bool, log=None) -> bool:
|
||||
def _log(msg: str):
|
||||
if log:
|
||||
try:
|
||||
log(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
info = self.ensure_local_registry_available()
|
||||
if not info:
|
||||
_log("Local registry unavailable; skipping image pre-pull.\n")
|
||||
return False
|
||||
|
||||
registry, _cluster_registry = info
|
||||
images = self._collect_dependent_images(include_supabase, include_kerberos_proxy)
|
||||
if not images:
|
||||
_log("No dependent images found to pre-pull.\n")
|
||||
return True
|
||||
|
||||
overall_ok = True
|
||||
import_dir = self.docker_import_dir.get().strip()
|
||||
|
||||
for image in images:
|
||||
local_tag = image
|
||||
if not image.startswith(f"{registry}/"):
|
||||
local_tag = f"{registry}/{image}"
|
||||
|
||||
# 1. Check if already in local registry
|
||||
_log(f"Checking if {image} exists in local registry...\n")
|
||||
check_reg = subprocess.run(['docker', 'pull', local_tag], capture_output=True, text=True)
|
||||
if check_reg.returncode == 0:
|
||||
_log(f"[OK] {image} already exists in local registry as {local_tag}\n")
|
||||
continue
|
||||
|
||||
# 2. Check if we already have it in local docker daemon
|
||||
check_local = subprocess.run(['docker', 'image', 'inspect', image], capture_output=True, text=True)
|
||||
found = (check_local.returncode == 0)
|
||||
|
||||
if not found and import_dir and os.path.isdir(import_dir):
|
||||
# 3. Check import directory
|
||||
safe_name = image.replace("/", "_").replace(":", "_")
|
||||
tar_path = Path(import_dir) / f"{safe_name}.tar"
|
||||
if tar_path.exists():
|
||||
_log(f"Found {tar_path} in import directory, loading...\n")
|
||||
load = subprocess.run(['docker', 'load', '-i', str(tar_path)], capture_output=True, text=True)
|
||||
if load.returncode == 0:
|
||||
found = True
|
||||
else:
|
||||
_log(f"[WARN] Failed to load {tar_path}: {load.stderr}\n")
|
||||
|
||||
if not found:
|
||||
# 4. Pull from Docker Hub
|
||||
_log(f"Pulling {image} from Docker Hub...\n")
|
||||
pull = subprocess.run(['docker', 'pull', image], capture_output=True, text=True)
|
||||
if pull.returncode != 0:
|
||||
overall_ok = False
|
||||
_log(pull.stdout or '')
|
||||
_log(pull.stderr or '')
|
||||
_log(f"[ERROR] docker pull failed for {image}\n")
|
||||
continue
|
||||
found = True
|
||||
|
||||
# If we have the image locally, tag and push to local registry
|
||||
if found:
|
||||
if local_tag != image:
|
||||
tag = subprocess.run(['docker', 'tag', image, local_tag], capture_output=True, text=True)
|
||||
if tag.returncode != 0:
|
||||
overall_ok = False
|
||||
_log(tag.stdout or '')
|
||||
_log(tag.stderr or '')
|
||||
_log(f"[ERROR] docker tag failed for {image} to {local_tag}\n")
|
||||
continue
|
||||
|
||||
_log(f"Pushing {local_tag} to local registry...\n")
|
||||
if not _push_docker_image(local_tag, log_fn=log):
|
||||
overall_ok = False
|
||||
continue
|
||||
_log(f"[OK] Stored {image} in local registry as {local_tag}\n")
|
||||
|
||||
return overall_ok
|
||||
|
||||
def build_docker_image(self):
|
||||
"""Build prole-db Docker image"""
|
||||
# If Kerberos is enabled, update pg_hba.conf in conf/postgresql before copying
|
||||
if self.kerberos_enabled.get():
|
||||
realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM"
|
||||
hba_src = PROJECT_ROOT / 'conf' / 'postgresql' / 'pg_hba.conf'
|
||||
if hba_src.exists():
|
||||
content = hba_src.read_text()
|
||||
# Also ensure the Kerberos line exists in the source if it's not there yet
|
||||
if "gss" not in content:
|
||||
content += f"\nhost all all all gss include_realm=1 krb_realm={realm}\n"
|
||||
else:
|
||||
content = content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}")
|
||||
hba_src.write_text(content)
|
||||
print(f"Updated {hba_src} with realm {realm}")
|
||||
|
||||
# Base local image tag (before pushing to registry)
|
||||
version = self.get_prole_db_version()
|
||||
image_tag = f'prole-db:{version}'
|
||||
|
||||
# Prepare build context: copy conf/postgresql to prole-db/postgresql
|
||||
conf_src = PROJECT_ROOT / 'conf' / 'postgresql'
|
||||
conf_dst = PROJECT_ROOT / 'prole-db' / 'postgresql'
|
||||
|
||||
if conf_dst.exists():
|
||||
shutil.rmtree(conf_dst, ignore_errors=True)
|
||||
if sys.version_info >= (3, 8):
|
||||
shutil.copytree(conf_src, conf_dst, dirs_exist_ok=True)
|
||||
else:
|
||||
shutil.copytree(conf_src, conf_dst)
|
||||
|
||||
build_cmd = ['docker', 'build', '-t', image_tag]
|
||||
|
||||
target_env = self._cluster_env_key()
|
||||
if getattr(self, 'deploy_environment', None):
|
||||
try:
|
||||
target_env = _normalize_cluster_env(self.deploy_environment.get())
|
||||
except Exception:
|
||||
pass
|
||||
# Add platform flag for target environment (k3s on Pi uses arm64)
|
||||
build_cmd.extend(get_docker_build_platform_args(target_env))
|
||||
|
||||
build_cmd.append('.')
|
||||
|
||||
result = subprocess.run(build_cmd,
|
||||
check=True,
|
||||
cwd=PROJECT_ROOT / 'prole-db',
|
||||
capture_output=True,
|
||||
text=True)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Failed to build image: {result.stderr}")
|
||||
# Keep a reference for later steps
|
||||
self.local_image_tag = image_tag
|
||||
|
||||
# Update K8s manifest
|
||||
self.update_k8s_manifest(image_tag)
|
||||
|
||||
def build_mssql_docker_image(self, image_tag='prole-mssql-db:latest', target_env: str | None = None):
|
||||
"""Build mssql Docker image (with platform detection for target env)."""
|
||||
build_cmd = ['docker', 'build', '-t', image_tag]
|
||||
|
||||
env_key = target_env or self._cluster_env_key()
|
||||
# Add platform flag for Apple Silicon / k3s target
|
||||
build_cmd.extend(get_docker_build_platform_args(env_key))
|
||||
|
||||
build_cmd.append('.')
|
||||
|
||||
result = subprocess.run(build_cmd,
|
||||
check=True,
|
||||
cwd=PROJECT_ROOT / 'mssql',
|
||||
capture_output=True,
|
||||
text=True)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"Failed to build mssql image: {result.stderr}")
|
||||
return result
|
||||
|
||||
def tag_docker_image(self):
|
||||
"""Tag Docker image for registry"""
|
||||
if not getattr(self, 'registry_url', ''):
|
||||
print("Registry disabled; skipping tag.")
|
||||
return
|
||||
version = self.get_prole_db_version()
|
||||
registry = getattr(self, 'registry_url', 'localhost:5000')
|
||||
image_name = f'prole-db:{version}'
|
||||
image = getattr(self, 'local_image_tag', image_name)
|
||||
self.remote_image_tag = f"{registry}/prole-db:{version}"
|
||||
subprocess.run(['docker', 'tag', image, self.remote_image_tag], check=True, capture_output=True)
|
||||
|
||||
def push_docker_image(self):
|
||||
"""Push Docker image to registry"""
|
||||
if not getattr(self, 'registry_url', ''):
|
||||
print("Registry disabled; skipping push.")
|
||||
return
|
||||
remote_tag = getattr(self, 'remote_image_tag', None)
|
||||
if not remote_tag:
|
||||
raise Exception('Remote image tag not set')
|
||||
if not _push_docker_image(remote_tag):
|
||||
raise Exception('Failed to push image to registry.')
|
||||
|
||||
def import_k3d_image(self, cluster_name='prole-dev-cluster'):
|
||||
"""Import image to k3d cluster (only for Dev)."""
|
||||
version = self.get_prole_db_version()
|
||||
image_name = f'prole-db:{version}'
|
||||
subprocess.run(['k3d', 'image', 'import', image_name, '-c', cluster_name], check=True, capture_output=True)
|
||||
|
||||
def update_k8s_manifest(self, image_tag):
|
||||
"""Update k8s/prole/prole-db.yaml with the new version and Kerberos config"""
|
||||
manifest_paths = [
|
||||
PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db.yaml',
|
||||
PROJECT_ROOT / 'k8s' / 'prole' / 'prole-db-recovery.yaml.tpl',
|
||||
]
|
||||
import re
|
||||
for manifest_path in manifest_paths:
|
||||
if not manifest_path.exists():
|
||||
continue
|
||||
content = manifest_path.read_text()
|
||||
# Update imageName: prole-db:17.7-043
|
||||
new_content = re.sub(r'imageName:\s*.*', f'imageName: {image_tag}', content)
|
||||
|
||||
# Update Kerberos realm in manifest if enabled
|
||||
if self.kerberos_enabled.get():
|
||||
realm = self.kerberos_realm.get().strip() or "EXAMPLE.COM"
|
||||
if "gss" not in new_content:
|
||||
# Insert before bootstrap if not present
|
||||
new_content = new_content.replace(" pg_hba:", f" pg_hba:\n - host all all all gss include_realm=1 krb_realm={realm}")
|
||||
else:
|
||||
new_content = new_content.replace("krb_realm=EXAMPLE.COM", f"krb_realm={realm}")
|
||||
|
||||
if new_content != content:
|
||||
manifest_path.write_text(new_content)
|
||||
|
||||
def get_prole_db_version(self):
|
||||
return self.controller.get_prole_db_version()
|
||||
|
||||
def create_or_select_cluster(self, env: str):
|
||||
"""Ensure target cluster depending on environment selection."""
|
||||
if env == 'Dev':
|
||||
self.create_or_recreate_k3d_dev_cluster()
|
||||
elif env == 'Service':
|
||||
kubectl = subprocess.run(['which', 'kubectl'], capture_output=True)
|
||||
if kubectl.returncode != 0:
|
||||
raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.")
|
||||
server, token = self._k3s_connection_info()
|
||||
if server and token:
|
||||
res = subprocess.run(self._k3s_kubectl_base_cmd() + ['cluster-info'], capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
raise Exception("K3s cluster not reachable with provided server/token.")
|
||||
else:
|
||||
# Fallback to local kubeconfig if no token provided
|
||||
subprocess.run(['kubectl', 'version', '--client'], check=True)
|
||||
elif env == 'Prod':
|
||||
kubectl = subprocess.run(['which', 'kubectl'], capture_output=True)
|
||||
if kubectl.returncode != 0:
|
||||
raise Exception("kubectl not found. Please install kubectl and configure access to the target cluster.")
|
||||
subprocess.run(['kubectl', 'version', '--client'], check=True)
|
||||
else:
|
||||
raise Exception(f"Unknown environment: {env}")
|
||||
|
||||
def create_or_recreate_k3d_dev_cluster(self, cluster_name='prole-dev-cluster'):
|
||||
"""Create or restart local k3d cluster and wire it to the chosen registry."""
|
||||
result = subprocess.run(['k3d', 'cluster', 'list'], capture_output=True, text=True)
|
||||
if cluster_name in (result.stdout or ''):
|
||||
subprocess.run(['k3d', 'cluster', 'delete', cluster_name], check=True)
|
||||
|
||||
# Determine registry integration args
|
||||
reg_args = []
|
||||
if getattr(self, 'registry_url', None):
|
||||
# If using the local k3d registry, we want to create or use it
|
||||
if self.registry_url.startswith('localhost:5000'):
|
||||
# Creating with --registry-create ensures it's available and integrated
|
||||
reg_args = ['--registry-create', f'prole-registry:0.0.0.0:5000']
|
||||
else:
|
||||
reg_args = ['--registry-use', self.registry_url]
|
||||
|
||||
prole_data = str(self._resolve_env_dir('PROLE_DATA', 'data'))
|
||||
volume_args = _k3d_prole_data_volume_args(prole_data)
|
||||
cmd = ['k3d', 'cluster', 'create', cluster_name, '-a', '2', '--wait'] + volume_args + reg_args + ['--timestamps']
|
||||
subprocess.run(cmd, check=True, cwd=PROJECT_ROOT)
|
||||
432
installer/ui/screens/environment.py
Normal file
432
installer/ui/screens/environment.py
Normal file
@ -0,0 +1,432 @@
|
||||
"""System environment setup screen and env.sh management."""
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import screen as ui
|
||||
from installer.core.env import PROJECT_ROOT
|
||||
|
||||
|
||||
class EnvironmentScreenMixin:
|
||||
"""System environment setup screen and env.sh management."""
|
||||
|
||||
def _render_env_setup_page(self):
|
||||
# Letterhead at top right
|
||||
content_width = self.bg_canvas.winfo_width() or 975
|
||||
right_margin = content_width - 48
|
||||
|
||||
ui.canvas_text(self, right_margin, 40, 'Prole', fill='#6e6e73', font=('SF Pro Text', 32, 'bold'), anchor='ne')
|
||||
ui.canvas_text(self, right_margin, 85, "Personal Infrastructure.", fill='#6e6e73',
|
||||
font=('SF Pro Text', 18), anchor='ne')
|
||||
|
||||
# Task heading for clarity
|
||||
self._render_title('Environment', y=150)
|
||||
self._render_paragraph('Configure your Prole environment. You can accept defaults or choose custom locations. Changing PROLE_HOME will update the defaults for other paths.', y=200)
|
||||
|
||||
defaults = self._env_defaults()
|
||||
existing = self._read_existing_env()
|
||||
values = {**defaults, **existing}
|
||||
|
||||
# Keep entry widgets to read values on Next
|
||||
self._env_entries = {}
|
||||
|
||||
labels = [
|
||||
('PROLE_HOME', 'Base directory for data, configs, logs, and scripts'),
|
||||
('PROLE_CONF', 'Configuration directory'),
|
||||
('PROLE_DATA', 'Data directory'),
|
||||
('PROLE_LOGS', 'Logs directory'),
|
||||
('PROLE_SERVICE', 'System scripts directory'),
|
||||
]
|
||||
|
||||
x_label = 48
|
||||
x_field = 300
|
||||
x_btn = 850
|
||||
y = 280
|
||||
row_h = 75
|
||||
|
||||
def browse(var_name: str):
|
||||
from tkinter import filedialog
|
||||
initial = self._env_entries[var_name].get() or Path.home()
|
||||
path = filedialog.askdirectory(title=f"Select {var_name}", initialdir=str(Path(initial).expanduser()))
|
||||
if path:
|
||||
self._env_entries[var_name].delete(0, 'end')
|
||||
self._env_entries[var_name].insert(0, path)
|
||||
if var_name == 'PROLE_HOME':
|
||||
# Update dependent defaults if they still match old pattern
|
||||
self._update_dependent_env_paths()
|
||||
|
||||
# Draw rows
|
||||
for key, help_text in labels:
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, key, fill='black', font=('SF Pro Text', 12, 'bold'), anchor='w'))
|
||||
# Use tk.Entry and place on canvas via create_window
|
||||
entry = tk.Entry(self.bg_canvas, bg='white', fg='black', insertbackground='black',
|
||||
highlightbackground='#CCCCCC', highlightthickness=1, relief='flat', font=('SF Pro Text', 11))
|
||||
entry.insert(0, values[key])
|
||||
entry_window = self.bg_canvas.create_window(x_field, y, window=entry, anchor='w', width=500, height=32)
|
||||
self._canvas_items.append(entry_window)
|
||||
self._overlay_widgets.append(entry)
|
||||
self._env_entries[key] = entry
|
||||
|
||||
# Use tk.Button and place on canvas via create_window
|
||||
btn = tk.Button(self.bg_canvas, text='Browse…', command=lambda k=key: browse(k),
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 11), padx=10, pady=4)
|
||||
btn_window = self.bg_canvas.create_window(x_btn, y, window=btn, anchor='w', width=120)
|
||||
self._canvas_items.append(btn_window)
|
||||
self._overlay_widgets.append(btn)
|
||||
|
||||
# hint text
|
||||
self._canvas_items.append(ui.canvas_text(self, x_field, y+24, help_text, fill='#6e6e73', font=('SF Pro Text', 10), anchor='nw'))
|
||||
y += row_h
|
||||
|
||||
# When PROLE_HOME changes, update dependent paths that still follow default pattern
|
||||
def on_home_change(_evt=None):
|
||||
self._update_dependent_env_paths()
|
||||
try:
|
||||
self._env_entries['PROLE_HOME'].bind('<FocusOut>', on_home_change)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _after_env_saved(self):
|
||||
"""Reload the installer process environment after saving env.sh."""
|
||||
try:
|
||||
self.reload_env_from_shell()
|
||||
# Ensure we capture PROLE_HOME in Global section
|
||||
self.prole_cfg_data['Global']['PROLE_HOME'] = os.environ.get('PROLE_HOME', '')
|
||||
self._save_prole_cfg()
|
||||
except Exception as e:
|
||||
try:
|
||||
messagebox.showwarning('Environment', f'Environment saved, but reload failed: {e}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def resolve_prole_home(self) -> Path:
|
||||
"""Return PROLE_HOME or default to $HOME/.prole as a Path (expanded)."""
|
||||
val = os.environ.get('PROLE_HOME')
|
||||
if val:
|
||||
try:
|
||||
return Path(val).expanduser()
|
||||
except Exception:
|
||||
pass
|
||||
return Path.home() / '.prole'
|
||||
|
||||
def ensure_prole_env(self) -> Path:
|
||||
"""Ensure $PROLE_HOME and env.sh exist and are readable. Create minimal env if missing.
|
||||
|
||||
Returns the resolved PROLE_HOME Path. Raises Exception on failure.
|
||||
"""
|
||||
home = self.resolve_prole_home()
|
||||
try:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create PROLE_HOME at {home}: {e}")
|
||||
env_file = home / 'env.sh'
|
||||
if not env_file.exists():
|
||||
# Construct minimal values based on defaults with this home
|
||||
vals = {
|
||||
'PROLE_HOME': str(home),
|
||||
'PROLE_CONF': self._resolve_env_value('PROLE_CONF', str(home / 'conf')) or str(home / 'conf'),
|
||||
'PROLE_DATA': self._resolve_env_value('PROLE_DATA', str(home / 'data')) or str(home / 'data'),
|
||||
'PROLE_LOGS': self._resolve_env_value('PROLE_LOGS', str(home / 'logs')) or str(home / 'logs'),
|
||||
'PROLE_SERVICE': self._resolve_env_value('PROLE_SERVICE', str(home / 'etc')) or str(home / 'etc'),
|
||||
}
|
||||
self._save_env_to_file(vals)
|
||||
# Validate readability
|
||||
try:
|
||||
data = env_file.read_text()
|
||||
if not isinstance(data, str) or len(data) == 0:
|
||||
raise Exception("env.sh is empty")
|
||||
except Exception as e:
|
||||
raise Exception(f"env.sh not readable at {env_file}: {e}")
|
||||
return home
|
||||
|
||||
def reload_env_from_shell(self) -> None:
|
||||
"""Reload environment by sourcing $PROLE_HOME/env.sh in a login shell and merging into os.environ."""
|
||||
home = self.ensure_prole_env()
|
||||
env_file = home / 'env.sh'
|
||||
# Use bash to source and print env in null-delimited form
|
||||
cmd = (
|
||||
f'export PROLE_HOME={shlex.quote(str(home))}; '
|
||||
f'source {shlex.quote(str(env_file))}; '
|
||||
'env -0'
|
||||
)
|
||||
try:
|
||||
out = subprocess.check_output(['bash', '-lc', cmd])
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise Exception(f"Failed to reload environment: {e}")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to run bash to reload environment: {e}")
|
||||
# Merge variables
|
||||
try:
|
||||
items = out.split(b'\x00')
|
||||
for raw in items:
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
kv = raw.decode('utf-8', errors='ignore')
|
||||
except Exception:
|
||||
continue
|
||||
if '=' not in kv:
|
||||
continue
|
||||
k, v = kv.split('=', 1)
|
||||
# Avoid clobbering Python internals we rely on; safe list only
|
||||
if k in ('PYTHONPATH', 'PYTHONHOME'):
|
||||
continue
|
||||
os.environ[k] = v
|
||||
except Exception:
|
||||
# Best-effort; ignore merge errors
|
||||
pass
|
||||
def _resolve_prole_conf_dir(self) -> Path:
|
||||
return self._resolve_env_dir('PROLE_CONF', 'conf')
|
||||
|
||||
def _resolve_prole_logs_dir(self) -> Path:
|
||||
return self._resolve_env_dir('PROLE_LOGS', 'logs')
|
||||
|
||||
def _registry_log_path(self) -> Path:
|
||||
logs_dir = self._resolve_prole_logs_dir()
|
||||
try:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
path = logs_dir / "local_registry.log"
|
||||
self._last_registry_log_path = path
|
||||
return path
|
||||
|
||||
def _common_services_log_path(self) -> Path:
|
||||
logs_dir = self._resolve_prole_logs_dir()
|
||||
try:
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
path = logs_dir / "init_common_services.log"
|
||||
self._last_common_services_log_path = path
|
||||
return path
|
||||
|
||||
def _read_log_tail(self, path: Path, max_lines: int = 400) -> str:
|
||||
try:
|
||||
from collections import deque
|
||||
with path.open('r', encoding='utf-8', errors='ignore') as handle:
|
||||
tail = deque(handle, maxlen=max_lines)
|
||||
return ''.join(tail)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _restore_common_services_log(self, console) -> bool:
|
||||
path = getattr(self, '_last_common_services_log_path', None)
|
||||
if not path:
|
||||
candidate = self._common_services_log_path()
|
||||
if candidate.exists():
|
||||
path = candidate
|
||||
if not path or not path.exists():
|
||||
return False
|
||||
content = self._read_log_tail(path)
|
||||
if not content:
|
||||
return False
|
||||
try:
|
||||
console.clear()
|
||||
console.write(f"Log file: {path}\n\n")
|
||||
console.write(content)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _ensure_prole_directories(self) -> None:
|
||||
paths = {
|
||||
'PROLE_HOME': self._resolve_env_value('PROLE_HOME', str(Path.home() / '.prole')),
|
||||
'PROLE_CONF': str(self._resolve_prole_conf_dir()),
|
||||
'PROLE_DATA': str(self._resolve_env_dir('PROLE_DATA', 'data')),
|
||||
'PROLE_LOGS': str(self._resolve_prole_logs_dir()),
|
||||
'PROLE_SERVICE': str(self._resolve_env_dir('PROLE_SERVICE', 'etc')),
|
||||
}
|
||||
for key, raw in paths.items():
|
||||
try:
|
||||
Path(raw).expanduser().mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if key not in os.environ and raw:
|
||||
os.environ[key] = raw
|
||||
|
||||
def _record_install_log(self, path) -> None:
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
p = Path(path)
|
||||
except Exception:
|
||||
return
|
||||
if not hasattr(self, '_install_run_log_paths'):
|
||||
self._install_run_log_paths = []
|
||||
sp = str(p)
|
||||
if sp not in self._install_run_log_paths:
|
||||
self._install_run_log_paths.append(sp)
|
||||
|
||||
def _update_env_namespace(self, namespace: str):
|
||||
try:
|
||||
existing = self._read_existing_env()
|
||||
defaults = self._env_defaults()
|
||||
values = {**defaults, **existing}
|
||||
values['NAMESPACE'] = namespace
|
||||
self._save_env_to_file(values)
|
||||
os.environ['NAMESPACE'] = namespace
|
||||
except Exception as e:
|
||||
print(f"[WARN] Failed to update env.sh namespace: {e}")
|
||||
|
||||
def _save_env_to_file(self, values: dict):
|
||||
home = Path(values['PROLE_HOME']).expanduser()
|
||||
# ensure base dir exists
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
# ensure subdirs exist
|
||||
for key in ('PROLE_CONF', 'PROLE_DATA', 'PROLE_LOGS', 'PROLE_SERVICE'):
|
||||
try:
|
||||
Path(values[key]).expanduser().mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
# write env.sh atomically
|
||||
content = []
|
||||
# Executable wrapper + sourceable config
|
||||
content.append('#!/usr/bin/env bash')
|
||||
content.append('# Prole environment configuration')
|
||||
content.append('# This file is generated by the installer. Source it in new shells, or execute as a wrapper:')
|
||||
content.append('# "$PROLE_HOME/env.sh" <command> [args…]')
|
||||
content.append('# shellcheck shell=bash')
|
||||
# Core PROLE_* directories
|
||||
for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'):
|
||||
content.append(f'export {k}="{values[k]}"')
|
||||
if values.get('NAMESPACE'):
|
||||
content.append(f'export NAMESPACE="{values["NAMESPACE"]}"')
|
||||
content.append('')
|
||||
# Ensure PATH contains common locations and $PROLE_HOME/bin (POSIX sh compatible)
|
||||
content.append('# Ensure PATH works for GUI-launched shells (Docker, etc.)')
|
||||
content.append('_prole_add_path() { case ":${PATH}:" in *":$1:"*) ;; *) PATH="$1:${PATH:-}" ;; esac; }')
|
||||
content.append('_prole_add_path "$PROLE_HOME/bin"')
|
||||
content.append('_prole_add_path "/opt/homebrew/bin"')
|
||||
content.append('_prole_add_path "/usr/local/bin"')
|
||||
content.append('_prole_add_path "/usr/bin"')
|
||||
content.append('_prole_add_path "/bin"')
|
||||
content.append('_prole_add_path "/usr/sbin"')
|
||||
content.append('_prole_add_path "/sbin"')
|
||||
content.append('export PATH')
|
||||
content.append('')
|
||||
content.append('# Add custom paths below if needed (examples):')
|
||||
content.append('')
|
||||
content.append('# If executed with arguments (and not sourced), run them under this environment')
|
||||
content.append('if [[ "${BASH_SOURCE[0]}" == "${0}" ]] && [ "$#" -gt 0 ]; then')
|
||||
content.append(' exec "$@"')
|
||||
content.append('fi')
|
||||
|
||||
new_content = '\n'.join(content) + '\n'
|
||||
out = home / 'env.sh'
|
||||
|
||||
should_write = True
|
||||
if out.exists():
|
||||
if out.read_text() == new_content:
|
||||
should_write = False
|
||||
# print(f"[OK] {out} is up to date.")
|
||||
|
||||
if should_write:
|
||||
tmp = home / 'env.sh.tmp'
|
||||
tmp.write_text(new_content)
|
||||
tmp.replace(out)
|
||||
try:
|
||||
os.chmod(out, 0o755)
|
||||
except Exception:
|
||||
pass
|
||||
# print(f"[OK] Wrote {out}")
|
||||
|
||||
# After creating env.sh, deploy additional resources as requested:
|
||||
# 1) Deploy init-port-forward.sh into $PROLE_HOME (and ensure it's executable)
|
||||
# 2) Copy contents of src/prole/etc (or fallback to top-level etc) into $PROLE_SERVICE
|
||||
try:
|
||||
prole_home = Path(values['PROLE_HOME']).expanduser()
|
||||
prole_service = Path(values['PROLE_SERVICE']).expanduser()
|
||||
|
||||
# Determine source for init-port-forward.sh
|
||||
# Preferred location under repo: PROJECT_ROOT/src/prole/etc/init-port-forward.sh
|
||||
init_pf_src_candidates = [
|
||||
PROJECT_ROOT / 'src' / 'prole' / 'etc' / 'init-port-forward.sh',
|
||||
PROJECT_ROOT / 'etc' / 'init-port-forward.sh',
|
||||
]
|
||||
init_pf_src = next((p for p in init_pf_src_candidates if p.exists()), None)
|
||||
if init_pf_src is not None:
|
||||
init_pf_dst = prole_home / 'init-port-forward.sh'
|
||||
try:
|
||||
data = init_pf_src.read_bytes()
|
||||
init_pf_dst.write_bytes(data)
|
||||
os.chmod(init_pf_dst, 0o755)
|
||||
except Exception:
|
||||
# best effort, ignore errors
|
||||
pass
|
||||
|
||||
# Determine etc directory source
|
||||
etc_src_candidates = [
|
||||
PROJECT_ROOT / 'src' / 'prole' / 'etc',
|
||||
PROJECT_ROOT / 'etc',
|
||||
]
|
||||
etc_src = next((p for p in etc_src_candidates if p.exists() and p.is_dir()), None)
|
||||
if etc_src is not None:
|
||||
try:
|
||||
prole_service.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Copy files (non-recursive: contents of etc root). If subdirectories exist, copy them recursively.
|
||||
import shutil
|
||||
|
||||
def _copy_item(src_path: Path, dst_path: Path):
|
||||
try:
|
||||
if src_path.is_dir():
|
||||
# copy directory tree
|
||||
if dst_path.exists():
|
||||
# remove then copy to keep in sync
|
||||
shutil.rmtree(dst_path, ignore_errors=True)
|
||||
shutil.copytree(src_path, dst_path)
|
||||
else:
|
||||
shutil.copy2(src_path, dst_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for item in etc_src.iterdir():
|
||||
_copy_item(item, prole_service / item.name)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# overall best-effort; do not fail env creation if copies fail
|
||||
pass
|
||||
|
||||
def _update_dependent_env_paths(self):
|
||||
try:
|
||||
new_home = Path(self._env_entries['PROLE_HOME'].get()).expanduser()
|
||||
except Exception:
|
||||
return
|
||||
mapping = {
|
||||
'PROLE_CONF': new_home / 'conf',
|
||||
'PROLE_DATA': new_home / 'data',
|
||||
'PROLE_LOGS': new_home / 'logs',
|
||||
'PROLE_SERVICE': new_home / 'etc',
|
||||
}
|
||||
# Update entries if they are empty or previously matched the old base
|
||||
for key, new_path in mapping.items():
|
||||
ent = self._env_entries.get(key)
|
||||
if not ent:
|
||||
continue
|
||||
cur = ent.get().strip()
|
||||
if not cur:
|
||||
ent.delete(0, 'end')
|
||||
ent.insert(0, str(new_path))
|
||||
continue
|
||||
# If cur looked like old_home/<suffix>, update it
|
||||
try:
|
||||
# detect suffix
|
||||
suffix = new_path.name
|
||||
if cur.endswith('/' + suffix):
|
||||
# replace base path
|
||||
ent.delete(0, 'end')
|
||||
ent.insert(0, str(new_path))
|
||||
except Exception:
|
||||
pass
|
||||
# done
|
||||
561
installer/ui/screens/navigation.py
Normal file
561
installer/ui/screens/navigation.py
Normal file
@ -0,0 +1,561 @@
|
||||
"""Sidebar navigation, page transitions, footer and app identity."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import config as inst_config
|
||||
from installer.core.env import get_resource_path
|
||||
|
||||
|
||||
class NavigationMixin:
|
||||
"""Sidebar navigation, page transitions, footer and app identity."""
|
||||
|
||||
def _set_app_identity(self):
|
||||
"""Set the installer identity: process/menu name and Dock icon on macOS.
|
||||
|
||||
Notes:
|
||||
- We set Tk's appname for consistency.
|
||||
- On macOS, attempt to set the process/menu name to 'Prole Database Installer'
|
||||
via NSProcessInfo if PyObjC is available.
|
||||
- Prefer the Prole Tools.app .icns from the built app; fallback to local PNG/GIF.
|
||||
"""
|
||||
# Set Tk application name
|
||||
try:
|
||||
self.root.tk.call('tk', 'appname', 'Prole Database Installer')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# macOS: set process/menu name and Dock icon via AppKit/Foundation
|
||||
if platform.system() == 'Darwin':
|
||||
# Try to set the visible process name for the menu bar
|
||||
try:
|
||||
from Foundation import NSProcessInfo
|
||||
NSProcessInfo.processInfo().setProcessName_("Prole Database Installer")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also try to retitle the first main menu item so the menu next to the Apple logo reads 'Prole Database Installer'
|
||||
try:
|
||||
from AppKit import NSApplication
|
||||
app = NSApplication.sharedApplication()
|
||||
main_menu = app.mainMenu()
|
||||
if main_menu is not None and main_menu.numberOfItems() > 0:
|
||||
first_item = main_menu.itemAtIndex_(0)
|
||||
if first_item is not None:
|
||||
first_item.setTitle_("Prole Database Installer")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
icns_candidates = [
|
||||
get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole Tools.icns'),
|
||||
get_resource_path('prole-app/dist/Prole Tools.app/Contents/Resources/Prole.icns'),
|
||||
]
|
||||
icns_path = next((p for p in icns_candidates if p.exists()), None)
|
||||
if icns_path is not None:
|
||||
try:
|
||||
# Use PyObjC if available
|
||||
from AppKit import NSApplication, NSImage
|
||||
img = NSImage.alloc().initWithContentsOfFile_(str(icns_path))
|
||||
if img is not None:
|
||||
NSApplication.sharedApplication().setApplicationIconImage_(img)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If no .icns was found, try setting Dock icon from configured PNG
|
||||
try:
|
||||
from AppKit import NSApplication, NSImage
|
||||
cfg_icon = inst_config.get_ui_icon_image_path()
|
||||
if cfg_icon.exists():
|
||||
png_img = NSImage.alloc().initWithContentsOfFile_(str(cfg_icon))
|
||||
if png_img is not None:
|
||||
NSApplication.sharedApplication().setApplicationIconImage_(png_img)
|
||||
# do not return; still set Tk icon below for consistency
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: Tk icon from image assets (PNG/GIF)
|
||||
# Prefer config-defined icon image
|
||||
try:
|
||||
cfg_icon = inst_config.get_ui_icon_image_path()
|
||||
except Exception:
|
||||
cfg_icon = get_resource_path('img/proleIcon.png')
|
||||
img_candidates = [
|
||||
cfg_icon,
|
||||
get_resource_path('img/prole-type.png'),
|
||||
get_resource_path('img/prole-type.gif'),
|
||||
get_resource_path('img/Prole.png'),
|
||||
get_resource_path('img/proleLogoSepia.png'),
|
||||
]
|
||||
for p in img_candidates:
|
||||
try:
|
||||
if p.exists():
|
||||
self._app_iconphoto = tk.PhotoImage(file=str(p))
|
||||
try:
|
||||
self.root.iconphoto(True, self._app_iconphoto)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def show_config_dialog(self):
|
||||
"""Show configuration dialog for Docker image import directory."""
|
||||
dialog = tk.Toplevel(self.root)
|
||||
dialog.title("Configuration")
|
||||
dialog.geometry("500x200")
|
||||
dialog.resizable(False, False)
|
||||
dialog.transient(self.root)
|
||||
dialog.grab_set()
|
||||
|
||||
# Center on parent
|
||||
x = self.root.winfo_x() + (self.root.winfo_width() // 2) - 250
|
||||
y = self.root.winfo_y() + (self.root.winfo_height() // 2) - 100
|
||||
dialog.geometry(f"+{x}+{y}")
|
||||
|
||||
container = tk.Frame(dialog, padx=20, pady=20)
|
||||
container.pack(fill='both', expand=True)
|
||||
|
||||
tk.Label(container, text="Docker Image Import Directory:", font=('SF Pro Text', 12, 'bold')).pack(anchor='w')
|
||||
tk.Label(container, text="Images found here will be loaded instead of pulled from Docker Hub.",
|
||||
font=('SF Pro Text', 10), fg='#666666').pack(anchor='w', pady=(0, 10))
|
||||
|
||||
row = tk.Frame(container)
|
||||
row.pack(fill='x')
|
||||
|
||||
entry = tk.Entry(row, textvariable=self.docker_import_dir, font=('SF Pro Text', 11))
|
||||
entry.pack(side='left', fill='x', expand=True, padx=(0, 5))
|
||||
|
||||
def browse():
|
||||
path = filedialog.askdirectory(initialdir=self.docker_import_dir.get() or Path.home())
|
||||
if path:
|
||||
self.docker_import_dir.set(path)
|
||||
|
||||
tk.Button(row, text="Browse...", command=browse).pack(side='right')
|
||||
|
||||
def save():
|
||||
path = self.docker_import_dir.get().strip()
|
||||
self.prole_cfg_data['Global']['DOCKER_IMPORT_DIR'] = path
|
||||
self._save_prole_cfg()
|
||||
dialog.destroy()
|
||||
|
||||
btns = tk.Frame(container)
|
||||
btns.pack(fill='x', pady=(20, 0))
|
||||
|
||||
tk.Button(btns, text="Save", command=save, width=10).pack(side='right')
|
||||
tk.Button(btns, text="Cancel", command=dialog.destroy, width=10).pack(side='right', padx=10)
|
||||
|
||||
def configure_styles(self):
|
||||
"""Configure ttk styles"""
|
||||
base_bg = 'white'
|
||||
self.style.configure('TFrame', background='white')
|
||||
self.style.configure('TLabel', background='white', foreground='black', font=('SF Pro Text', 11))
|
||||
self.style.configure('Header.TLabel', font=('SF Pro Text', 16, 'bold'))
|
||||
self.style.configure('Small.TLabel', font=('SF Pro Text', 10))
|
||||
|
||||
# Consistent button styling
|
||||
self.style.configure('Nav.TButton',
|
||||
font=('SF Pro Text', 11),
|
||||
padding=(12, 8),
|
||||
background='#F5F5DC',
|
||||
bordercolor='#F5F5DC',
|
||||
lightcolor='#F5F5DC',
|
||||
darkcolor='#F5F5DC',
|
||||
relief='flat')
|
||||
|
||||
# Map state colors to avoid black boxes on hover/active
|
||||
self.style.map('Nav.TButton',
|
||||
background=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')],
|
||||
bordercolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')],
|
||||
lightcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')],
|
||||
darkcolor=[('active', '#E5E5D5'), ('pressed', '#D5D5C5')])
|
||||
|
||||
# Notebook styling to match light theme and avoid dark mode shifts on macOS
|
||||
self.style.theme_use('default')
|
||||
self.style.configure('TNotebook', background='white', borderwidth=0, highlightthickness=0)
|
||||
self.style.configure('TNotebook.Tab',
|
||||
background='#F5F5DC',
|
||||
foreground='black',
|
||||
lightcolor='#F5F5DC',
|
||||
bordercolor='#CCCCCC',
|
||||
darkcolor='#F5F5DC',
|
||||
borderwidth=1,
|
||||
padding=[10, 5])
|
||||
self.style.map('TNotebook.Tab',
|
||||
background=[('selected', 'white')],
|
||||
bordercolor=[('selected', '#CCCCCC')],
|
||||
lightcolor=[('selected', 'white')],
|
||||
focuscolor=[('selected', 'white')])
|
||||
|
||||
# Ensure common controls inherit white background
|
||||
try:
|
||||
self.style.configure('TCheckbutton', background=base_bg, foreground='black')
|
||||
self.style.configure('TCombobox', fieldbackground='white', background=base_bg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _create_sidebar_nav(self):
|
||||
"""Create the left-hand navigation menu."""
|
||||
tk.Label(self.sidebar, text="INSTALLER", bg='#F5F5DC', fg='#8B8B7A',
|
||||
font=('SF Pro Text', 10, 'bold'), anchor='w').pack(fill='x', padx=20, pady=(20, 10))
|
||||
|
||||
for text, page_id in self.nav_items:
|
||||
lbl = tk.Label(self.sidebar, text=text, bg='#F5F5DC', fg='black',
|
||||
font=('SF Pro Text', 11), anchor='w', padx=20, pady=5, cursor='hand2')
|
||||
lbl.pack(fill='x')
|
||||
lbl.bind('<Button-1>', lambda e, p=page_id: self.show_page(p))
|
||||
self.nav_widgets[page_id] = lbl
|
||||
|
||||
def _update_nav_highlight(self, active_id):
|
||||
"""Highlight the current active page in the sidebar."""
|
||||
# Handle dep_id mapping to 'Dependencies'
|
||||
if active_id.startswith('dep_'):
|
||||
active_id = 'deps_summary'
|
||||
|
||||
for page_id, widget in self.nav_widgets.items():
|
||||
if page_id == active_id:
|
||||
widget.configure(bg='#E5E5D5', font=('SF Pro Text', 11, 'bold'))
|
||||
else:
|
||||
widget.configure(bg='#F5F5DC', font=('SF Pro Text', 11))
|
||||
|
||||
def create_navigation(self):
|
||||
pass
|
||||
|
||||
def on_prev(self):
|
||||
# Custom prev navigation for dependency pages when filtering
|
||||
current_id = self.pages[self.page_index][0]
|
||||
# Close Terminal if leaving build summary via Prev
|
||||
if current_id == 'build_summary':
|
||||
try:
|
||||
self.close_build_terminal()
|
||||
except Exception:
|
||||
pass
|
||||
if current_id.startswith('dep_'):
|
||||
seq = self._dep_navigation_sequence()
|
||||
try:
|
||||
i = seq.index(current_id)
|
||||
except ValueError:
|
||||
i = -1
|
||||
if i > 0:
|
||||
self.show_page(seq[i - 1])
|
||||
return
|
||||
else:
|
||||
# Go back to summary if there is no previous in sequence
|
||||
self.show_page('deps_summary')
|
||||
return
|
||||
if current_id == 'deps_summary':
|
||||
self.show_page('welcome')
|
||||
return
|
||||
if current_id == 'network_scan':
|
||||
# Go back to last relevant dep or deps_summary
|
||||
if self.all_dependencies_installed():
|
||||
self.show_page('deps_summary')
|
||||
else:
|
||||
seq = self._dep_navigation_sequence()
|
||||
self.show_page(seq[-1] if seq else 'deps_summary')
|
||||
return
|
||||
if current_id == 'env_setup':
|
||||
self.show_page('network_scan')
|
||||
return
|
||||
if current_id == 'init_cluster':
|
||||
if getattr(self, '_showing_service_overlay', False):
|
||||
self._showing_service_overlay = False
|
||||
self.show_page('init_cluster')
|
||||
return
|
||||
self.show_page('env_setup')
|
||||
return
|
||||
if current_id == 'init_password':
|
||||
self.show_page('init_cluster')
|
||||
return
|
||||
if current_id == 'init_db_build':
|
||||
self.show_page('init_password')
|
||||
return
|
||||
if current_id == 'init_scripts':
|
||||
if self.kerberos_enabled.get():
|
||||
self.show_page('kerberos_config')
|
||||
else:
|
||||
self.show_page('init_db_build')
|
||||
return
|
||||
if current_id == 'kerberos_config':
|
||||
self.show_page('init_db_build')
|
||||
return
|
||||
if current_id == 'ollama_config':
|
||||
self.show_page('init_scripts')
|
||||
return
|
||||
if current_id == 'supabase_config':
|
||||
self.show_page('ollama_config')
|
||||
return
|
||||
if current_id == 'init_cnpg_deploy':
|
||||
if self.supabase_enabled.get():
|
||||
self.show_page('supabase_config')
|
||||
return
|
||||
self.show_page('ollama_config')
|
||||
return
|
||||
if current_id == 'create_installer':
|
||||
self.show_page('init_cnpg_deploy')
|
||||
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]
|
||||
print(f"[DEBUG] on_next: current_id='{current_id}', page_index={self.page_index}")
|
||||
|
||||
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():
|
||||
print("[DEBUG] on_next: all deps installed, going to network_scan")
|
||||
self.show_page('network_scan')
|
||||
return
|
||||
|
||||
# Go to first missing dependency page
|
||||
seq = self._dep_navigation_sequence()
|
||||
print(f"[DEBUG] on_next: missing deps sequence: {seq}")
|
||||
if seq:
|
||||
self.show_page(seq[0])
|
||||
else:
|
||||
print("[DEBUG] on_next: all deps seem OK in sequence, going to network_scan")
|
||||
self.show_page('network_scan')
|
||||
return
|
||||
|
||||
if current_id.startswith('dep_'):
|
||||
# Navigate within dependency sequence
|
||||
seq = self._dep_navigation_sequence()
|
||||
print(f"[DEBUG] on_next: dep sequence: {seq}")
|
||||
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():
|
||||
print("[DEBUG] on_next: some deps still missing, returning to deps_summary")
|
||||
self.show_page('deps_summary')
|
||||
else:
|
||||
print("[DEBUG] on_next: all deps now installed, going to network_scan")
|
||||
self.show_page('network_scan')
|
||||
return
|
||||
|
||||
if current_id == 'network_scan':
|
||||
# Capture network scan info
|
||||
try:
|
||||
self.prole_cfg_data['Network']['KDC_AUTO_DETECTED'] = self.kerberos_kdc.get()
|
||||
self.prole_cfg_data['Network']['KERBEROS_AUTO_ENABLED'] = str(self.kerberos_enabled.get())
|
||||
self._save_prole_cfg()
|
||||
except Exception:
|
||||
pass
|
||||
self.show_page('env_setup')
|
||||
return
|
||||
|
||||
if current_id == 'env_setup':
|
||||
# Collect values, validate, write env.sh, then go to Database Creation
|
||||
vals = {}
|
||||
try:
|
||||
for k in ('PROLE_HOME','PROLE_CONF','PROLE_DATA','PROLE_LOGS','PROLE_SERVICE'):
|
||||
vals[k] = self._env_entries[k].get().strip()
|
||||
self.prole_cfg_data['System Environment'][k] = vals[k]
|
||||
vals['NAMESPACE'] = (self.db_namespace.get() or '').strip()
|
||||
except Exception:
|
||||
vals = self._env_defaults()
|
||||
# Basic validation: require non-empty PROLE_HOME
|
||||
if not vals.get('PROLE_HOME'):
|
||||
try:
|
||||
messagebox.showerror('Environment', 'Please specify PROLE_HOME')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
try:
|
||||
self._save_env_to_file(vals)
|
||||
# Reload our environment so subsequent steps (Build) inherit it
|
||||
self._after_env_saved()
|
||||
except Exception as e:
|
||||
try:
|
||||
messagebox.showwarning('Environment', f'Could not save env.sh: {e}')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
self.show_page('init_cluster')
|
||||
return
|
||||
|
||||
if current_id == 'init_password':
|
||||
p1 = self.db_password.get()
|
||||
p2 = self.db_password_confirm.get()
|
||||
if not p1:
|
||||
try:
|
||||
messagebox.showerror('Password', 'Password cannot be empty.')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if p1 != p2:
|
||||
try:
|
||||
messagebox.showerror('Password', 'Passwords do not match.')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if not self._validate_port_forward_overlaps():
|
||||
return
|
||||
self._run_preparation_overlay()
|
||||
return
|
||||
|
||||
if current_id == 'init_db_build':
|
||||
if self.kerberos_enabled.get():
|
||||
self.show_page('kerberos_config')
|
||||
else:
|
||||
self.show_page('init_scripts')
|
||||
return
|
||||
|
||||
if current_id == 'build':
|
||||
if getattr(self, '_built_success', False):
|
||||
self.show_page('create_installer')
|
||||
else:
|
||||
self.perform_build()
|
||||
return
|
||||
|
||||
if current_id == 'init_cluster':
|
||||
if not self._validate_and_save_cluster_config():
|
||||
return
|
||||
if not self._cluster_ready_for_navigation():
|
||||
return
|
||||
if not getattr(self, '_common_services_success', False):
|
||||
self._run_service_layer_overlay(next_page='init_password')
|
||||
return
|
||||
self.show_page('init_password')
|
||||
return
|
||||
|
||||
if current_id == 'init_scripts':
|
||||
self.prole_cfg_data['Initialization Scripts']['STATUS'] = 'Completed' if getattr(self, '_scripts_success', False) else 'Attempted'
|
||||
self._save_prole_cfg()
|
||||
self.show_page('ollama_config')
|
||||
return
|
||||
|
||||
if current_id == 'kerberos_config':
|
||||
# Capture kerberos config
|
||||
self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get())
|
||||
self.prole_cfg_data['Kerberos Authentication']['REALM'] = self.kerberos_realm.get()
|
||||
self.prole_cfg_data['Kerberos Authentication']['KDC'] = self.kerberos_kdc.get()
|
||||
self.prole_cfg_data['Kerberos Authentication']['SERVER'] = self.kerberos_kdc.get()
|
||||
self.prole_cfg_data['Kerberos Authentication']['USER'] = self.kerberos_user.get()
|
||||
self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = self.kerberos_password.get()
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PORT_FORWARD'] = os.environ.get('KRB5_AD_PORT_FORWARD', '1')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_TCP_PORTS'] = os.environ.get('KRB5_AD_TCP_PORTS', '88 389 445 464 636')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_UDP_PORTS'] = os.environ.get('KRB5_AD_UDP_PORTS', '88 464')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_HOST_NETWORK'] = os.environ.get('KRB5_AD_PROXY_HOST_NETWORK', '1')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_IMAGE'] = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_SERVICE'] = os.environ.get('KRB5_AD_SERVICE_NAME', 'prole-kerberos-ad-dc')
|
||||
self._save_prole_cfg()
|
||||
self.show_page('init_scripts')
|
||||
return
|
||||
|
||||
if current_id == 'ollama_config':
|
||||
self._save_ollama_config()
|
||||
if self.supabase_enabled.get():
|
||||
self.show_page('supabase_config')
|
||||
else:
|
||||
self.show_page('init_cnpg_deploy')
|
||||
return
|
||||
|
||||
if current_id == 'supabase_config':
|
||||
self.prole_cfg_data['Optional Features']['SUPABASE_ENABLED'] = str(self.supabase_enabled.get())
|
||||
self.prole_cfg_data['Supabase'] = {'STATUS': 'Deployed' if getattr(self, '_supabase_success', False) else 'Attempted'}
|
||||
self._save_prole_cfg()
|
||||
self.show_page('init_cnpg_deploy')
|
||||
return
|
||||
|
||||
if current_id == 'init_cnpg_deploy':
|
||||
self.prole_cfg_data['Deployment']['STATUS'] = 'Deployed' if getattr(self, '_cnpg_success', False) else 'Attempted'
|
||||
self._save_prole_cfg()
|
||||
self.show_page('create_installer')
|
||||
return
|
||||
|
||||
if current_id == 'create_installer':
|
||||
print("[DEBUG] on_next: at create_installer, Finish clicked. Closing.")
|
||||
if 'Install' in self.prole_cfg_data:
|
||||
self.prole_cfg_data['Install']['STATUS'] = 'Finished'
|
||||
self._save_prole_cfg()
|
||||
self.root.destroy()
|
||||
return
|
||||
|
||||
# Default next
|
||||
if self.page_index < len(self.pages) - 1:
|
||||
print(f"[DEBUG] on_next: default next to index {self.page_index + 1}")
|
||||
self.show_page(self.page_index + 1)
|
||||
else:
|
||||
print("[DEBUG] on_next: already at last page")
|
||||
return
|
||||
|
||||
def update_footer(self):
|
||||
# Default states for tk.Button
|
||||
self.prev_button.configure(state='normal')
|
||||
self.next_button.configure(state='normal')
|
||||
first = self.page_index == 0
|
||||
# Base label
|
||||
self.next_button.configure(text='Next')
|
||||
|
||||
if getattr(self, '_showing_service_overlay', False):
|
||||
if not getattr(self, '_common_services_success', False):
|
||||
self.next_button.configure(state='disabled')
|
||||
else:
|
||||
self.next_button.configure(state='normal')
|
||||
|
||||
# Page-specific adjustments
|
||||
pid = self.pages[self.page_index][0]
|
||||
if pid == 'build':
|
||||
# Build page: show Build or Next depending on state
|
||||
if getattr(self, '_built_success', False):
|
||||
self.next_button.configure(text='Next')
|
||||
else:
|
||||
self.next_button.configure(text='Build')
|
||||
elif pid == 'supabase_config':
|
||||
# Supabase screen: Next is disabled until success
|
||||
if getattr(self, '_supabase_success', False):
|
||||
self.next_button.configure(state='normal')
|
||||
else:
|
||||
self.next_button.configure(state='disabled')
|
||||
elif pid == 'init_scripts':
|
||||
# Initialization Scripts: Next is disabled until success
|
||||
if getattr(self, '_scripts_success', False):
|
||||
self.next_button.configure(state='normal')
|
||||
else:
|
||||
self.next_button.configure(state='disabled')
|
||||
# Visibility rules
|
||||
self.prev_button.pack_forget()
|
||||
self.next_button.pack_forget()
|
||||
if self.deploy_button:
|
||||
self.deploy_button.pack_forget()
|
||||
if self.launch_button:
|
||||
self.launch_button.pack_forget()
|
||||
if self.gear_button:
|
||||
self.gear_button.pack_forget()
|
||||
|
||||
if pid == 'init_password' and self.gear_button:
|
||||
self.gear_button.pack(side='left', padx=(20, 0), pady=12)
|
||||
|
||||
if pid == 'create_installer':
|
||||
self.next_button.configure(text='Finish')
|
||||
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
||||
return
|
||||
|
||||
if first:
|
||||
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
||||
else:
|
||||
# [Prev] [Next] clustered right
|
||||
self.next_button.pack(side='right', padx=(0, 20), pady=12)
|
||||
self.prev_button.pack(side='right', padx=(0, 8), pady=12)
|
||||
|
||||
def _splash_should_hide_nav(self) -> bool:
|
||||
"""Deprecated: Welcome page no longer performs dependency checks or gates navigation."""
|
||||
return False
|
||||
241
installer/ui/screens/network.py
Normal file
241
installer/ui/screens/network.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""Network scan screen."""
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from installer import screen as ui
|
||||
from installer.core.env import get_resource_path
|
||||
|
||||
|
||||
class NetworkScreenMixin:
|
||||
"""Network scan screen."""
|
||||
|
||||
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 prole-agent 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 prole-agent")
|
||||
# Ready to scan text (drawn on canvas)
|
||||
status_item = ui.canvas_text(self, 48, y, "Ready to prole-agent", 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)
|
||||
|
||||
# Standardized Console Output
|
||||
self.scan_results_console = self._create_console_output(y=320, title="Scan Output", width=880, height=380)
|
||||
self.scan_results_text = self.scan_results_console.text
|
||||
|
||||
if getattr(self, 'ansible_topology_summary', ''):
|
||||
self.scan_results_console.write(self.ansible_topology_summary + "\n")
|
||||
self.scan_results_console.write("Ansible topology loaded; network scan can refine detection.\n")
|
||||
|
||||
y = 740
|
||||
|
||||
# Start Network Scan Button (placed on canvas)
|
||||
self.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)
|
||||
self.btn_window = self.bg_canvas.create_window(48, y, window=self.scan_btn, anchor='nw')
|
||||
self._overlay_widgets.append(self.scan_btn)
|
||||
self._canvas_items.append(self.btn_window)
|
||||
|
||||
# Prepare animation frames
|
||||
if not GLOBAL_SCAN_FRAMES:
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
gif_path = get_resource_path('img/prole-type.gif')
|
||||
if gif_path.exists():
|
||||
gif = Image.open(str(gif_path))
|
||||
max_frames = 120
|
||||
try:
|
||||
while len(GLOBAL_SCAN_FRAMES) < max_frames:
|
||||
# Create a copy and resize
|
||||
frame = gif.copy().convert('RGBA')
|
||||
frame.thumbnail((24, 24), Image.LANCZOS)
|
||||
GLOBAL_SCAN_FRAMES.append(ImageTk.PhotoImage(frame))
|
||||
gif.seek(len(GLOBAL_SCAN_FRAMES))
|
||||
except EOFError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error loading scan animation: {e}")
|
||||
self._scan_frames = GLOBAL_SCAN_FRAMES
|
||||
|
||||
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 _animate_scan_button(self, frame_idx=0):
|
||||
if not getattr(self, '_scan_running', False) or not self._scan_frames:
|
||||
if hasattr(self, 'scan_btn'):
|
||||
self.scan_btn.config(image='', compound='none')
|
||||
return
|
||||
|
||||
self.scan_btn.config(image=self._scan_frames[frame_idx], compound='left')
|
||||
next_idx = (frame_idx + 1) % len(self._scan_frames)
|
||||
self.root.after(100, lambda: self._animate_scan_button(next_idx))
|
||||
|
||||
def _run_network_scan(self):
|
||||
if getattr(self, '_scan_running', False):
|
||||
return
|
||||
self._action_flags['network_scan.run'] = True
|
||||
self._scan_running = True
|
||||
self._scan_kdc_value = None
|
||||
ansible_kdc = ''
|
||||
try:
|
||||
ansible_kdc = (self.ansible_topology or {}).get('kdc_ip') or ''
|
||||
if not ansible_kdc:
|
||||
ansible_kdc = (self.prole_cfg_data.get('Network', {}) or {}).get('KDC_ANSIBLE_DETECTED', '')
|
||||
except Exception:
|
||||
ansible_kdc = ''
|
||||
|
||||
self.scan_results_console.clear()
|
||||
self.scan_results_console.write("Initializing network scan using prole-net/prole-agent ...\n")
|
||||
self.scan_status_var.set("Scanning...")
|
||||
|
||||
# Start animation
|
||||
if self._scan_frames:
|
||||
self._animate_scan_button()
|
||||
|
||||
def worker():
|
||||
try:
|
||||
# Use the new scan binary
|
||||
scan_binary = get_resource_path("prole-net/prole-agent")
|
||||
if not scan_binary.exists():
|
||||
self.safe_after(lambda: self.scan_results_console.write(f"Scan binary not found at {scan_binary}\n"))
|
||||
self.safe_after(lambda: self.scan_status_var.set("Scan failed"))
|
||||
self._scan_running = False
|
||||
return
|
||||
|
||||
# Create writable scan directory for output/cache
|
||||
# This avoids issues with PyInstaller's read-only _MEIPASS directory
|
||||
prole_home = Path.home() / ".prole"
|
||||
scan_dir = prole_home / "scan"
|
||||
scan_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Run scan from writable directory with 10s timeout and summary analysis
|
||||
# Set bufsize=0 for truly unbuffered binary stream reading
|
||||
# Ensure PYTHONUNBUFFERED=1 is set in case prole-agent is/uses Python
|
||||
env = os.environ.copy()
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
process = subprocess.Popen([str(scan_binary), "-t", "10"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0,
|
||||
env=env,
|
||||
cwd=str(scan_dir))
|
||||
|
||||
# Capture output in real-time using larger reads to avoid overhead
|
||||
fd = process.stdout.fileno()
|
||||
line_buffer = []
|
||||
last_ui_update = 0
|
||||
pending_output = []
|
||||
|
||||
def maybe_set_kdc(ip: str):
|
||||
if not ip:
|
||||
return
|
||||
if ansible_kdc:
|
||||
return
|
||||
if self._scan_kdc_value:
|
||||
return
|
||||
self._scan_kdc_value = ip
|
||||
self.safe_after(lambda i=ip: self.kerberos_kdc.set(i))
|
||||
self.safe_after(lambda: self.kerberos_enabled.set(True))
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Use a smaller chunk size to encourage more frequent reads
|
||||
chunk_bytes = os.read(fd, 1024)
|
||||
except (EOFError, OSError):
|
||||
chunk_bytes = b''
|
||||
|
||||
if not chunk_bytes:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
# If we have pending output, flush it before sleeping
|
||||
if pending_output:
|
||||
text_to_flush = "".join(pending_output)
|
||||
pending_output = []
|
||||
self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t))
|
||||
last_ui_update = time.time()
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
text = chunk_bytes.decode('utf-8', errors='replace')
|
||||
pending_output.append(text)
|
||||
|
||||
# Update more frequently if we have enough data or enough time has passed
|
||||
now = time.time()
|
||||
if now - last_ui_update > 0.05 or sum(len(x) for x in pending_output) > 2048:
|
||||
text_to_flush = "".join(pending_output)
|
||||
pending_output = []
|
||||
self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t))
|
||||
last_ui_update = now
|
||||
|
||||
# Accumulate for line parsing (for AD/KDC detection)
|
||||
for char in text:
|
||||
line_buffer.append(char)
|
||||
if char == '\n':
|
||||
line = "".join(line_buffer)
|
||||
line_buffer = []
|
||||
|
||||
# Parse "KDC is: IP" from the new summary analysis format
|
||||
if "KDC is:" in line:
|
||||
try:
|
||||
ip_part = line.split("KDC is:")[1].strip()
|
||||
ip = ip_part.split()[0].strip('[]():,')
|
||||
if ip:
|
||||
maybe_set_kdc(ip)
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
|
||||
elif "Active Directory" in line or "88" in line:
|
||||
parts = line.split()
|
||||
for part in parts:
|
||||
try:
|
||||
socket.inet_aton(part.strip('[]():,'))
|
||||
ip = part.strip('[]():,')
|
||||
maybe_set_kdc(ip)
|
||||
break
|
||||
except socket.error:
|
||||
continue
|
||||
|
||||
process.wait()
|
||||
|
||||
# Final flush of any remaining output
|
||||
if pending_output:
|
||||
text_to_flush = "".join(pending_output)
|
||||
self.safe_after(lambda t=text_to_flush: self.scan_results_console.write(t))
|
||||
|
||||
if process.returncode == 0:
|
||||
self.safe_after(lambda: self.scan_status_var.set("Scan complete"))
|
||||
else:
|
||||
self.safe_after(lambda: self.scan_status_var.set(f"Scan failed (code {process.returncode})"))
|
||||
|
||||
self._scan_running = False
|
||||
|
||||
except Exception as e:
|
||||
self.safe_after(lambda: self.scan_results_console.write(f"Scan error: {str(e)}\n"))
|
||||
self.safe_after(lambda: self.scan_status_var.set("Scan failed"))
|
||||
self._scan_running = False
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
567
installer/ui/screens/ollama.py
Normal file
567
installer/ui/screens/ollama.py
Normal file
@ -0,0 +1,567 @@
|
||||
"""Ollama LLM configuration screen."""
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import screen as ui
|
||||
from installer.core.env import (
|
||||
DEFAULT_OLLAMA_PORT,
|
||||
PROJECT_ROOT,
|
||||
_format_ollama_host,
|
||||
_parse_ollama_host,
|
||||
)
|
||||
from installer.config import _collect_cfg_vars, _expand_cfg_value
|
||||
|
||||
|
||||
class OllamaScreenMixin:
|
||||
"""Ollama LLM configuration screen."""
|
||||
|
||||
def _render_ollama_config_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('Ollama Server', y=150)
|
||||
self._render_paragraph(
|
||||
"Detect and select a network Ollama server. The selected host, port, and model are saved to prole.cfg "
|
||||
"and exported as OLLAMA_* for k3s hosts and services.",
|
||||
y=200
|
||||
)
|
||||
|
||||
x_label = 48
|
||||
x_field = 300
|
||||
y = 270
|
||||
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Host:', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
host_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_host, bg='white', fg='black',
|
||||
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
|
||||
relief='flat', font=('SF Pro Text', 11))
|
||||
host_window = self.bg_canvas.create_window(x_field, y-12, window=host_entry, anchor='nw', width=400, height=32)
|
||||
self._canvas_items.append(host_window)
|
||||
self._overlay_widgets.append(host_entry)
|
||||
|
||||
y += 42
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Port:', fill='black', font=('SF Pro Text', 12, 'bold')))
|
||||
port_entry = tk.Entry(self.bg_canvas, textvariable=self.ollama_server_port, bg='white', fg='black',
|
||||
insertbackground='black', highlightbackground='#CCCCCC', highlightthickness=1,
|
||||
relief='flat', font=('SF Pro Text', 11))
|
||||
port_window = self.bg_canvas.create_window(x_field, y-12, window=port_entry, anchor='nw', width=120, height=32)
|
||||
self._canvas_items.append(port_window)
|
||||
self._overlay_widgets.append(port_entry)
|
||||
|
||||
y += 42
|
||||
# Removed global model selector; model is now selected per-server row
|
||||
save_btn = tk.Button(self.bg_canvas, text='Save', command=self._save_ollama_config,
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 10), padx=16, pady=6)
|
||||
save_window = self.bg_canvas.create_window(x_field + 320, y-14, window=save_btn, anchor='nw')
|
||||
self._canvas_items.append(save_window)
|
||||
self._overlay_widgets.append(save_btn)
|
||||
|
||||
y += 42
|
||||
preview_item = ui.canvas_text(self, x_label, y, 'OLLAMA_HOST: ', fill='#6e6e73', font=('SF Pro Text', 10))
|
||||
self._canvas_items.append(preview_item)
|
||||
|
||||
def update_preview(*_args):
|
||||
host = (self.ollama_server_host.get() or '').strip()
|
||||
port = (self.ollama_server_port.get() or '').strip()
|
||||
url = _format_ollama_host(host, port)
|
||||
text = f"OLLAMA_HOST: {url}" if url else "OLLAMA_HOST: (not set)"
|
||||
try:
|
||||
self.bg_canvas.itemconfig(preview_item, text=text)
|
||||
except Exception:
|
||||
pass
|
||||
# No global model selector updates to avoid UI spin-lock
|
||||
|
||||
self.ollama_server_host.trace_add('write', update_preview)
|
||||
self.ollama_server_port.trace_add('write', update_preview)
|
||||
update_preview()
|
||||
|
||||
y += 40
|
||||
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Ollama Server Browser', fill='#1d1d1f', font=('SF Pro Text', 12, 'bold')))
|
||||
|
||||
refresh_btn = tk.Button(self.bg_canvas, text='Scan', command=self._refresh_ollama_table,
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 10), padx=10, pady=4)
|
||||
refresh_window = self.bg_canvas.create_window(x_label + 190, y - 8, window=refresh_btn, anchor='nw')
|
||||
self._canvas_items.append(refresh_window)
|
||||
self._overlay_widgets.append(refresh_btn)
|
||||
|
||||
self._ollama_status_var = tk.StringVar(value="Ready")
|
||||
status_item = ui.canvas_text(self, x_label + 270, y, "Status: Ready", fill='#6e6e73', font=('SF Pro Text', 10))
|
||||
self._canvas_items.append(status_item)
|
||||
|
||||
def update_status_text(*args):
|
||||
try:
|
||||
self.bg_canvas.itemconfig(status_item, text=f"Status: {self._ollama_status_var.get()}")
|
||||
except Exception:
|
||||
pass
|
||||
self._ollama_status_var.trace_add('write', update_status_text)
|
||||
|
||||
table_y = y + 30
|
||||
table_frame = tk.Frame(self.bg_canvas, bg='white', highlightbackground='#E0E0E0', highlightthickness=1)
|
||||
table_window = self.bg_canvas.create_window(x_label, table_y, window=table_frame, anchor='nw', width=900, height=220)
|
||||
self._canvas_items.append(table_window)
|
||||
self._overlay_widgets.append(table_frame)
|
||||
|
||||
# Add an editable Default Model column; available models shown for reference
|
||||
columns = ('select', 'host', 'port', 'default', 'models')
|
||||
tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=6)
|
||||
tree.heading('select', text='Select')
|
||||
tree.heading('host', text='Host')
|
||||
tree.heading('port', text='Port')
|
||||
tree.heading('default', text='Default Model')
|
||||
tree.heading('models', text='Models')
|
||||
tree.column('select', width=60, anchor='center')
|
||||
tree.column('host', width=200, anchor='w')
|
||||
tree.column('port', width=70, anchor='center')
|
||||
tree.column('default', width=180, anchor='w')
|
||||
tree.column('models', width=380, anchor='w')
|
||||
tree.pack(side='left', fill='both', expand=True)
|
||||
|
||||
scroll = ttk.Scrollbar(table_frame, orient='vertical', command=tree.yview)
|
||||
tree.configure(yscrollcommand=scroll.set)
|
||||
scroll.pack(side='right', fill='y')
|
||||
|
||||
self._overlay_widgets.append(tree)
|
||||
self._overlay_widgets.append(scroll)
|
||||
self._ollama_server_tree = tree
|
||||
|
||||
# Inline combobox to edit Default Model for the selected row
|
||||
self._ollama_row_model_combo = None
|
||||
|
||||
def _place_row_model_combo(item_id):
|
||||
try:
|
||||
# Compute bbox for 'default' column
|
||||
bbox = tree.bbox(item_id, column='default')
|
||||
if not bbox:
|
||||
return
|
||||
x, y, w, h = bbox
|
||||
# Create lazily
|
||||
if not self._ollama_row_model_combo:
|
||||
self._ollama_row_model_combo = ttk.Combobox(tree, state='readonly')
|
||||
self._ollama_row_model_combo.bind('<<ComboboxSelected>>', _on_row_model_change)
|
||||
# Identify models by key
|
||||
vals = tree.item(item_id, 'values')
|
||||
if not vals:
|
||||
return
|
||||
host = vals[1]
|
||||
port = vals[2] or DEFAULT_OLLAMA_PORT
|
||||
key = f"{host}:{port}"
|
||||
models = self._ollama_model_options.get(key, [])
|
||||
try:
|
||||
self._ollama_row_model_combo.configure(values=models)
|
||||
except Exception:
|
||||
pass
|
||||
# Set current selection
|
||||
current = (self._ollama_default_model_by_key.get(key) or (models[0] if models else ''))
|
||||
if current:
|
||||
self._ollama_row_model_combo.set(current)
|
||||
# Place within Treeview
|
||||
self._ollama_row_model_combo.place(x=x+1, y=y+1, width=max(w-2, 60), height=h-2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_row_model_change(_evt=None):
|
||||
try:
|
||||
if not self._ollama_row_model_combo:
|
||||
return
|
||||
sel = tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
item_id = sel[0]
|
||||
vals = tree.item(item_id, 'values')
|
||||
if not vals:
|
||||
return
|
||||
host = vals[1]
|
||||
port = vals[2] or DEFAULT_OLLAMA_PORT
|
||||
key = f"{host}:{port}"
|
||||
choice = self._ollama_row_model_combo.get().strip()
|
||||
self._ollama_default_model_by_key[key] = choice
|
||||
# If this row is the active selection for host/port, reflect into self.ollama_model
|
||||
current_key = self._ollama_current_key()
|
||||
if current_key == key and choice:
|
||||
try:
|
||||
self.ollama_model.set(choice)
|
||||
except Exception:
|
||||
pass
|
||||
# Update the table cell text
|
||||
new_vals = list(vals)
|
||||
# Default column index is 3
|
||||
if len(new_vals) >= 4:
|
||||
new_vals[3] = choice or '—'
|
||||
tree.item(item_id, values=new_vals)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_select(event):
|
||||
if getattr(self, '_refreshing_ollama_table', False):
|
||||
return
|
||||
sel = tree.selection()
|
||||
if not sel:
|
||||
# Hide editor when nothing selected
|
||||
if self._ollama_row_model_combo:
|
||||
self._ollama_row_model_combo.place_forget()
|
||||
return
|
||||
vals = tree.item(sel[0], 'values')
|
||||
if not vals:
|
||||
return
|
||||
host = vals[1]
|
||||
port = vals[2] or DEFAULT_OLLAMA_PORT
|
||||
self.ollama_server_host.set(host)
|
||||
self.ollama_server_port.set(port)
|
||||
key = f"{host}:{port}"
|
||||
# If we have a stored default model for this key, reflect into self.ollama_model for saving
|
||||
chosen = (self._ollama_default_model_by_key.get(key) or '')
|
||||
if chosen:
|
||||
try:
|
||||
self.ollama_model.set(chosen)
|
||||
except Exception:
|
||||
pass
|
||||
# Refresh to update the selection indicator, then place editor
|
||||
self._render_ollama_table_from_cache()
|
||||
# Reselect and place inline model editor over this row
|
||||
try:
|
||||
for item in tree.get_children():
|
||||
v = tree.item(item, 'values')
|
||||
if v and v[1] == host and (v[2] or DEFAULT_OLLAMA_PORT) == port:
|
||||
tree.selection_set(item)
|
||||
_place_row_model_combo(item)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
tree.bind('<<TreeviewSelect>>', on_select)
|
||||
# Also allow double-click on default cell to focus the combobox
|
||||
def on_double_click(event):
|
||||
if getattr(self, '_refreshing_ollama_table', False):
|
||||
return
|
||||
item_id = tree.identify_row(event.y)
|
||||
col = tree.identify_column(event.x)
|
||||
if not item_id:
|
||||
return
|
||||
if col in ('#4', 'default'):
|
||||
_place_row_model_combo(item_id)
|
||||
tree.bind('<Double-1>', on_double_click)
|
||||
|
||||
self._ollama_note_item = ui.canvas_text(self, x_label, table_y + 230, '', fill='#6e6e73', font=('SF Pro Text', 10))
|
||||
self._canvas_items.append(self._ollama_note_item)
|
||||
|
||||
self._render_ollama_table_from_cache()
|
||||
self._refresh_ollama_table()
|
||||
|
||||
def _ollama_current_key(self) -> str:
|
||||
host = (self.ollama_server_host.get() or '').strip()
|
||||
port = (self.ollama_server_port.get() or '').strip() or DEFAULT_OLLAMA_PORT
|
||||
if not host:
|
||||
return ''
|
||||
return f"{host}:{port}"
|
||||
|
||||
def _set_ollama_model_values(self, models: list[str]):
|
||||
combo = getattr(self, '_ollama_model_combo', None)
|
||||
if not combo:
|
||||
return
|
||||
if getattr(self, '_refreshing_ollama_table', False):
|
||||
return
|
||||
values = models or []
|
||||
try:
|
||||
combo.configure(values=values)
|
||||
except Exception:
|
||||
pass
|
||||
current = (self.ollama_model.get() or '').strip()
|
||||
if values and (not current or current not in values):
|
||||
self.ollama_model.set(values[0])
|
||||
|
||||
def _render_ollama_table_from_cache(self):
|
||||
rows = list(getattr(self, '_ollama_servers_cache', []) or [])
|
||||
notice = getattr(self, '_ollama_last_notice', '')
|
||||
self._update_ollama_table(rows, notice)
|
||||
|
||||
def _update_ollama_table(self, rows: list[dict], notice: str = ''):
|
||||
tree = getattr(self, '_ollama_server_tree', None)
|
||||
if not tree:
|
||||
return
|
||||
self._ollama_servers_cache = rows
|
||||
# Map of key -> list[str] available models
|
||||
self._ollama_model_options = {}
|
||||
# Map of key -> selected default model
|
||||
if not hasattr(self, '_ollama_default_model_by_key'):
|
||||
self._ollama_default_model_by_key = {}
|
||||
self._ollama_last_notice = notice or ''
|
||||
|
||||
for item in tree.get_children():
|
||||
tree.delete(item)
|
||||
|
||||
current_key = self._ollama_current_key()
|
||||
for row in rows:
|
||||
host = row.get('host', '')
|
||||
port = row.get('port', '') or DEFAULT_OLLAMA_PORT
|
||||
models = row.get('models', []) or []
|
||||
key = f"{host}:{port}"
|
||||
self._ollama_model_options[key] = models
|
||||
# Initialize default selection if missing
|
||||
if key not in self._ollama_default_model_by_key:
|
||||
self._ollama_default_model_by_key[key] = (models[0] if models else '')
|
||||
prefix = ' [✓] ' if key == current_key else ' [ ] '
|
||||
models_preview = ', '.join(models) if models else '—'
|
||||
default_model = self._ollama_default_model_by_key.get(key, '') or '—'
|
||||
try:
|
||||
tree.insert('', 'end', values=(prefix, host, port, default_model, models_preview))
|
||||
except Exception:
|
||||
# Fallback if columns not updated yet
|
||||
tree.insert('', 'end', values=(prefix, host, port, models_preview))
|
||||
|
||||
if current_key:
|
||||
for item in tree.get_children():
|
||||
vals = tree.item(item, 'values')
|
||||
if vals and f"{vals[1]}:{vals[2]}" == current_key:
|
||||
tree.selection_set(item)
|
||||
tree.see(item)
|
||||
break
|
||||
|
||||
note_item = getattr(self, '_ollama_note_item', None)
|
||||
if note_item and self.bg_canvas.winfo_exists():
|
||||
msg = notice if notice else ""
|
||||
self.bg_canvas.itemconfig(note_item, text=msg)
|
||||
|
||||
models = self._ollama_model_options.get(current_key, [])
|
||||
if models:
|
||||
self._set_ollama_model_values(models)
|
||||
|
||||
def _refresh_ollama_table(self):
|
||||
if getattr(self, '_refreshing_ollama_table', False):
|
||||
return
|
||||
self._refreshing_ollama_table = True
|
||||
self._ollama_model_options = {}
|
||||
if getattr(self, '_ollama_status_var', None):
|
||||
self._ollama_status_var.set("Scanning...")
|
||||
|
||||
def worker():
|
||||
notice = ''
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
script_path = PROJECT_ROOT / "etc" / "init_ollama.sh"
|
||||
if not script_path.exists():
|
||||
notice = f"Missing script: {script_path}"
|
||||
self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None)
|
||||
else:
|
||||
env = os.environ.copy()
|
||||
env["PROLE_HOME"] = str(PROJECT_ROOT)
|
||||
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
|
||||
rc, out = self._run_cmd_capture(['bash', str(script_path), 'scan'], env=env)
|
||||
if rc == 0:
|
||||
for line in (out or '').splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
parts = [p.strip() for p in line.split('\t')]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
host = parts[0]
|
||||
port = parts[1] or DEFAULT_OLLAMA_PORT
|
||||
model_list: list[str] = []
|
||||
if len(parts) > 2 and parts[2]:
|
||||
model_list = [m.strip() for m in parts[2].split(',') if m.strip() and m.strip() != '-']
|
||||
rows.append({'host': host, 'port': port, 'models': model_list})
|
||||
# Deduplicate by canonical host:port while preferring hostname labels
|
||||
if rows:
|
||||
def _is_ip(name: str) -> bool:
|
||||
if not name:
|
||||
return False
|
||||
return bool(re.match(r'^(?:\d{1,3}\.){3}\d{1,3}$', name)) or ':' in name
|
||||
|
||||
def _canon_host(name: str) -> str:
|
||||
n = (name or '').strip().lower()
|
||||
if n in {'localhost', '127.0.0.1', '::1', 'k3d.local'}:
|
||||
return '127.0.0.1'
|
||||
try:
|
||||
# Prefer IPv4
|
||||
return socket.gethostbyname(n)
|
||||
except Exception:
|
||||
return n
|
||||
|
||||
def _label_rank(name: str) -> int:
|
||||
n = (name or '').strip().lower()
|
||||
if n == 'k3d.local':
|
||||
return 0
|
||||
if not _is_ip(n) and n not in {'localhost'}:
|
||||
return 1 # other hostnames preferred after k3d.local
|
||||
if n == 'localhost':
|
||||
return 2
|
||||
return 3 # IPs last
|
||||
|
||||
merged: dict[tuple[str, str], dict] = {}
|
||||
for r in rows:
|
||||
disp_host = r.get('host', '')
|
||||
port = str(r.get('port', '') or DEFAULT_OLLAMA_PORT)
|
||||
canon = _canon_host(disp_host)
|
||||
key = (canon, port)
|
||||
cur = merged.get(key)
|
||||
if not cur:
|
||||
merged[key] = {
|
||||
'host': disp_host, # display label
|
||||
'port': port,
|
||||
'models': list(r.get('models', []) or [])
|
||||
}
|
||||
else:
|
||||
# Merge models
|
||||
existing = set(cur.get('models', []) or [])
|
||||
for m in (r.get('models', []) or []):
|
||||
if m not in existing:
|
||||
cur['models'].append(m)
|
||||
existing.add(m)
|
||||
# Pick better display host
|
||||
old_label = cur.get('host', '')
|
||||
if _label_rank(disp_host) < _label_rank(old_label):
|
||||
cur['host'] = disp_host
|
||||
# Convert back to list and sort by host
|
||||
rows = sorted(merged.values(), key=lambda d: (d.get('host') or ''))
|
||||
if rows:
|
||||
notice = f"Detected {len(rows)} server(s)."
|
||||
self.safe_after(lambda: self._ollama_status_var.set("Scan complete") if getattr(self, '_ollama_status_var', None) else None)
|
||||
else:
|
||||
notice = "No Ollama servers detected."
|
||||
self.safe_after(lambda: self._ollama_status_var.set("No servers found") if getattr(self, '_ollama_status_var', None) else None)
|
||||
else:
|
||||
notice = "Scan failed. See logs for details."
|
||||
self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None)
|
||||
except Exception as e:
|
||||
notice = f"Scan error: {e}"
|
||||
self.safe_after(lambda: self._ollama_status_var.set("Scan failed") if getattr(self, '_ollama_status_var', None) else None)
|
||||
finally:
|
||||
def finish():
|
||||
self._refreshing_ollama_table = False
|
||||
self._update_ollama_table(rows, notice)
|
||||
self.safe_after(finish)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _save_ollama_config(self):
|
||||
raw_host = (self.ollama_server_host.get() or '').strip()
|
||||
raw_port = (self.ollama_server_port.get() or '').strip()
|
||||
raw_model = (self.ollama_model.get() or '').strip()
|
||||
|
||||
host = raw_host
|
||||
port = raw_port
|
||||
if raw_host:
|
||||
parsed_host, parsed_port = _parse_ollama_host(raw_host)
|
||||
if parsed_host:
|
||||
host = parsed_host
|
||||
if not port and parsed_port:
|
||||
port = parsed_port
|
||||
if host and not port:
|
||||
port = DEFAULT_OLLAMA_PORT
|
||||
|
||||
if host and host != raw_host:
|
||||
self.ollama_server_host.set(host)
|
||||
if port and port != raw_port:
|
||||
self.ollama_server_port.set(port)
|
||||
|
||||
data = self.prole_cfg_data.setdefault('Ollama', {})
|
||||
if host:
|
||||
data['OLLAMA_SERVER_HOST'] = host
|
||||
data['OLLAMA_SERVER_PORT'] = port
|
||||
data['OLLAMA_HOST'] = _format_ollama_host(host, port)
|
||||
else:
|
||||
data.pop('OLLAMA_SERVER_HOST', None)
|
||||
data.pop('OLLAMA_SERVER_PORT', None)
|
||||
data.pop('OLLAMA_HOST', None)
|
||||
if raw_model:
|
||||
data['OLLAMA_MODEL'] = raw_model
|
||||
else:
|
||||
data.pop('OLLAMA_MODEL', None)
|
||||
|
||||
self._save_prole_cfg()
|
||||
if getattr(self, '_ollama_status_var', None):
|
||||
self._ollama_status_var.set("Saved")
|
||||
|
||||
def _apply_ollama_defaults(self):
|
||||
env_host = (os.environ.get('OLLAMA_SERVER_HOST') or '').strip()
|
||||
env_port = (os.environ.get('OLLAMA_SERVER_PORT') or '').strip()
|
||||
env_model = (os.environ.get('OLLAMA_MODEL') or '').strip()
|
||||
env_url = (os.environ.get('OLLAMA_HOST') or '').strip()
|
||||
if env_url and not env_host:
|
||||
parsed_host, parsed_port = _parse_ollama_host(env_url)
|
||||
if parsed_host:
|
||||
env_host = parsed_host
|
||||
if parsed_port and not env_port:
|
||||
env_port = parsed_port
|
||||
|
||||
if env_host and not self.ollama_server_host.get().strip():
|
||||
self.ollama_server_host.set(env_host)
|
||||
if env_port and not self.ollama_server_port.get().strip():
|
||||
self.ollama_server_port.set(env_port)
|
||||
if env_model and not self.ollama_model.get().strip():
|
||||
self.ollama_model.set(env_model)
|
||||
|
||||
cfg_host, cfg_port, cfg_model = self._read_ollama_cfg_values()
|
||||
if cfg_host and not self.ollama_server_host.get().strip():
|
||||
self.ollama_server_host.set(cfg_host)
|
||||
if cfg_port and not self.ollama_server_port.get().strip():
|
||||
self.ollama_server_port.set(cfg_port)
|
||||
if cfg_model and not self.ollama_model.get().strip():
|
||||
self.ollama_model.set(cfg_model)
|
||||
|
||||
def _read_ollama_cfg_values(self) -> tuple[str, str, str]:
|
||||
"""Return (host, port, model) from prole.cfg if present."""
|
||||
cfg_path = None
|
||||
try:
|
||||
if self._cfg_path_override is not None:
|
||||
cfg_path = self._cfg_path_override
|
||||
if cfg_path.is_dir():
|
||||
cfg_path = cfg_path / 'prole.cfg'
|
||||
else:
|
||||
cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg'
|
||||
except Exception:
|
||||
cfg_path = None
|
||||
if not cfg_path or not Path(cfg_path).exists():
|
||||
return '', '', ''
|
||||
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.optionxform = str
|
||||
try:
|
||||
cfg.read(cfg_path)
|
||||
except Exception:
|
||||
return '', '', ''
|
||||
cfg_vars = _collect_cfg_vars(cfg)
|
||||
|
||||
host_val = ''
|
||||
port_val = ''
|
||||
model_val = ''
|
||||
host_url = ''
|
||||
if cfg.has_section('Ollama'):
|
||||
sec = cfg['Ollama']
|
||||
host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip()
|
||||
port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip()
|
||||
model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip()
|
||||
host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip()
|
||||
if cfg.has_section('Global'):
|
||||
sec = cfg['Global']
|
||||
if not host_val:
|
||||
host_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_HOST', host_val), cfg_vars).strip()
|
||||
if not port_val:
|
||||
port_val = _expand_cfg_value(sec.get('OLLAMA_SERVER_PORT', port_val), cfg_vars).strip()
|
||||
if not model_val:
|
||||
model_val = _expand_cfg_value(sec.get('OLLAMA_MODEL', model_val), cfg_vars).strip()
|
||||
if not host_url:
|
||||
host_url = _expand_cfg_value(sec.get('OLLAMA_HOST', host_url), cfg_vars).strip()
|
||||
|
||||
if host_url and not host_val:
|
||||
parsed_host, parsed_port = _parse_ollama_host(host_url)
|
||||
if parsed_host:
|
||||
host_val = parsed_host
|
||||
if parsed_port and not port_val:
|
||||
port_val = parsed_port
|
||||
|
||||
return host_val, port_val, model_val
|
||||
959
installer/ui/screens/packaging.py
Normal file
959
installer/ui/screens/packaging.py
Normal file
@ -0,0 +1,959 @@
|
||||
"""Disk selection, installer packaging, DMG creation and build summary."""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import plistlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer.screen import TerminalConsole
|
||||
from installer import screen as ui
|
||||
from installer.core.env import PROJECT_ROOT, get_resource_path
|
||||
|
||||
|
||||
class PackagingScreenMixin:
|
||||
"""Disk selection, installer packaging, DMG creation and build summary."""
|
||||
|
||||
def _render_disk_selection_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, "Deployment Destination.", fill='#6e6e73',
|
||||
font=('SF Pro Text', 18), anchor='ne')
|
||||
|
||||
self._render_title('Select Destination', y=150)
|
||||
self._render_paragraph("Choose where you would like to deploy the built Prole application and its supporting artifacts.", y=210)
|
||||
|
||||
# Container for the two main options
|
||||
y_options = 300
|
||||
x_center = content_width // 2
|
||||
|
||||
# We need large friendly images. I'll use placeholders if I can't find specific ones.
|
||||
# But I'll try to use symbols or colors for now if images are missing.
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageTk
|
||||
# Using proleIcon.png as a placeholder for both for now, but I'll add distinct styling
|
||||
icon_path = PROJECT_ROOT / 'img' / 'proleIcon.png'
|
||||
icon_img = Image.open(str(icon_path)).resize((128, 128), Image.LANCZOS)
|
||||
self._disk_icon_tk = ImageTk.PhotoImage(icon_img)
|
||||
except Exception:
|
||||
self._disk_icon_tk = None
|
||||
|
||||
# Option 1: Removable Disk
|
||||
frame_usb = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20)
|
||||
usb_window = self.bg_canvas.create_window(x_center - 250, y_options, window=frame_usb, anchor='n', width=350)
|
||||
self._overlay_widgets.append(frame_usb)
|
||||
self._canvas_items.append(usb_window)
|
||||
|
||||
if self._disk_icon_tk:
|
||||
lbl_img_usb = tk.Label(frame_usb, image=self._disk_icon_tk, bg='white', cursor='hand2')
|
||||
lbl_img_usb.pack()
|
||||
lbl_img_usb.bind("<Button-1>", lambda e: self.selected_disk_type.set('removable'))
|
||||
|
||||
tk.Radiobutton(frame_usb, text="USB / Flash Drive", variable=self.selected_disk_type, value='removable',
|
||||
bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10)
|
||||
|
||||
# Dropdown for removable disks
|
||||
disk_names = [d[0] for d in self.removable_disks] or ["No removable disks detected"]
|
||||
if not self.selected_removable_disk.get() and self.removable_disks:
|
||||
self.selected_removable_disk.set(self.removable_disks[0][1])
|
||||
|
||||
self.disk_dropdown = ttk.Combobox(frame_usb, values=disk_names, state="readonly", width=30)
|
||||
self.disk_dropdown.pack(pady=5)
|
||||
if disk_names: self.disk_dropdown.current(0)
|
||||
|
||||
def on_disk_select(event):
|
||||
idx = self.disk_dropdown.current()
|
||||
if idx >= 0 and idx < len(self.removable_disks):
|
||||
self.selected_removable_disk.set(self.removable_disks[idx][1])
|
||||
self.selected_disk_type.set('removable')
|
||||
|
||||
self.disk_dropdown.bind("<<ComboboxSelected>>", on_disk_select)
|
||||
|
||||
# Option 2: Local Folder
|
||||
frame_local = tk.Frame(self.bg_canvas, bg='white', highlightthickness=1, highlightbackground='#CCCCCC', padx=20, pady=20)
|
||||
local_window = self.bg_canvas.create_window(x_center + 250, y_options, window=frame_local, anchor='n', width=350)
|
||||
self._overlay_widgets.append(frame_local)
|
||||
self._canvas_items.append(local_window)
|
||||
|
||||
if self._disk_icon_tk:
|
||||
lbl_img_local = tk.Label(frame_local, image=self._disk_icon_tk, bg='white', cursor='hand2')
|
||||
lbl_img_local.pack()
|
||||
lbl_img_local.bind("<Button-1>", lambda e: self.selected_disk_type.set('local'))
|
||||
|
||||
tk.Radiobutton(frame_local, text="Local Filesystem", variable=self.selected_disk_type, value='local',
|
||||
bg='white', font=('SF Pro Text', 14, 'bold')).pack(pady=10)
|
||||
|
||||
# Path input and browse
|
||||
path_frame = tk.Frame(frame_local, bg='white')
|
||||
path_frame.pack(fill='x', pady=5)
|
||||
|
||||
ent_path = tk.Entry(path_frame, textvariable=self.selected_local_path, font=('SF Pro Text', 10), width=30)
|
||||
ent_path.pack(side='left', padx=(0, 5))
|
||||
|
||||
def browse_local():
|
||||
from tkinter import filedialog
|
||||
d = filedialog.askdirectory(initialdir=self.selected_local_path.get())
|
||||
if d:
|
||||
self.selected_local_path.set(d)
|
||||
self.selected_disk_type.set('local')
|
||||
|
||||
btn_browse = tk.Button(path_frame, text="Browse...", command=browse_local, bg='#F5F5DC')
|
||||
btn_browse.pack(side='left')
|
||||
|
||||
def _render_build_summary_page(self):
|
||||
# Ensure slide_area is visible for the build log console
|
||||
self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1)
|
||||
self.slide_area.lift()
|
||||
|
||||
# Build Summary: show combined stdout/stderr from last build log
|
||||
self._render_title('Build Summary', y=40)
|
||||
if getattr(self, '_built_success', False):
|
||||
self._render_paragraph('✅ Build completed successfully. You can now drag Prole Tools.app into Applications. Output below:', y=88)
|
||||
else:
|
||||
# Troubleshooting header
|
||||
tips = (
|
||||
"❌ Build failed. Troubleshooting tips:\n"
|
||||
"• Ensure Xcode Command Line Tools are installed: xcode-select --install\n"
|
||||
"• Verify Swift toolchain and SPM networking (try again; network hiccups can happen)\n"
|
||||
"• If on Apple Silicon, ensure dependencies target arm64 or install via Homebrew\n"
|
||||
"• Clean derived data / SPM cache if needed: rm -rf ~/Library/Developer/Xcode/DerivedData\n"
|
||||
"• Check Docker status if Docker-related steps are used\n"
|
||||
"• Check the logs below for specific errors"
|
||||
)
|
||||
self._render_paragraph(tips, y=88)
|
||||
logp = getattr(self, 'last_build_log_path', None)
|
||||
# Place a scrollable console to show the log
|
||||
try:
|
||||
# Standardized Console Output area for build summary
|
||||
self.build_summary_console = self._create_console_output(y=160, title="Build Log Output", width=900, height=520)
|
||||
txt = self.build_summary_console.text
|
||||
|
||||
if logp and os.path.exists(logp):
|
||||
try:
|
||||
with open(logp, 'r', encoding='utf-8', errors='ignore') as fp:
|
||||
content = fp.read()
|
||||
self.build_summary_console.write(content)
|
||||
except Exception as e:
|
||||
self.build_summary_console.write(f"Failed to read log: {e}\n")
|
||||
else:
|
||||
self.build_summary_console.write('No build log available.')
|
||||
|
||||
# Keep standard y for links
|
||||
y_links = 760
|
||||
except Exception:
|
||||
# Fallback: just show path
|
||||
self._render_paragraph('No build log could be displayed.', y=120)
|
||||
y_links = 140
|
||||
|
||||
# Add link to open the log file in Finder/TextEdit
|
||||
if logp:
|
||||
link = ui.render_link(self, 56, y_links, 'Open build log file')
|
||||
self._canvas_items.append(link)
|
||||
|
||||
def _open_log(event):
|
||||
ex, ey = event.x, event.y
|
||||
bbox = self.bg_canvas.bbox(link)
|
||||
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
|
||||
try:
|
||||
if platform.system() == 'Darwin':
|
||||
subprocess.run(['open', logp])
|
||||
else:
|
||||
webbrowser.open(f'file://{logp}')
|
||||
except Exception:
|
||||
pass
|
||||
self.bg_canvas.bind('<Button-1>', _open_log)
|
||||
|
||||
# On success, also offer DMG packaging and opening options
|
||||
if getattr(self, '_built_success', False):
|
||||
try:
|
||||
dist_dir = str(self._get_prole_dist_dir())
|
||||
except Exception:
|
||||
dist_dir = None
|
||||
if dist_dir:
|
||||
y_links += 30
|
||||
# Create DMG
|
||||
link2 = ui.render_link(self, 56, y_links, 'Create Prole Tools.dmg')
|
||||
self._canvas_items.append(link2)
|
||||
|
||||
def _open_dist(event):
|
||||
ex, ey = event.x, event.y
|
||||
bbox = self.bg_canvas.bbox(link2)
|
||||
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
|
||||
try:
|
||||
# Build the DMG, then offer to open it
|
||||
self.create_dmg()
|
||||
self.open_dmg()
|
||||
except Exception:
|
||||
pass
|
||||
self.bg_canvas.bind('<Button-1>', _open_dist)
|
||||
|
||||
# Also link to open dist folder (fallback)
|
||||
y_links += 30
|
||||
link3 = ui.render_link(self, 56, y_links, 'Open dist folder')
|
||||
self._canvas_items.append(link3)
|
||||
|
||||
def _open_dist_folder(event):
|
||||
ex, ey = event.x, event.y
|
||||
bbox = self.bg_canvas.bbox(link3)
|
||||
if bbox and bbox[0] <= ex <= bbox[2] and bbox[1] <= ey <= bbox[3]:
|
||||
try:
|
||||
if platform.system() == 'Darwin':
|
||||
subprocess.run(['open', dist_dir])
|
||||
else:
|
||||
webbrowser.open(f'file://{dist_dir}')
|
||||
except Exception:
|
||||
pass
|
||||
self.bg_canvas.bind('<Button-1>', _open_dist_folder)
|
||||
|
||||
# ---------------- Drag-and-drop install (macOS Finder) ----------------
|
||||
def _render_create_installer_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('Post Install', y=150)
|
||||
self._render_paragraph('Review installation logs and save the deployment artifacts.', y=200)
|
||||
|
||||
# Tabs for output - using standardized appearance
|
||||
ui.canvas_text(self, 48, 260, "Installation Logs", fill='#1d1d1f', font=('SF Pro Text', 12, 'bold'))
|
||||
|
||||
# Use a background frame for the notebook to hide potential system borders
|
||||
notebook_bg = tk.Frame(self.bg_canvas, bg='white', highlightthickness=0, bd=0)
|
||||
self.install_tabs = ttk.Notebook(notebook_bg, style='TNotebook')
|
||||
self.install_tabs.pack(fill='both', expand=True, padx=1, pady=1)
|
||||
|
||||
tab_window = self.bg_canvas.create_window(48, 290, window=notebook_bg, anchor='nw', width=900, height=450)
|
||||
self._canvas_items.append(tab_window)
|
||||
self._overlay_widgets.append(notebook_bg)
|
||||
self._overlay_widgets.append(self.install_tabs)
|
||||
|
||||
self.install_consoles = {}
|
||||
|
||||
def _add_tab(title: str, content: str = "", script_name: str | None = None):
|
||||
# Use a background frame to ensure NO borders are visible around the console
|
||||
console_bg = tk.Frame(self.install_tabs, bg='white', highlightthickness=0, bd=0)
|
||||
self.install_tabs.add(console_bg, text=title)
|
||||
# Use TerminalConsole for consistent styling
|
||||
console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
|
||||
console.pack(fill='both', expand=True, padx=1, pady=1)
|
||||
if content:
|
||||
console.write(content)
|
||||
if script_name:
|
||||
self.install_consoles[script_name] = console
|
||||
return console
|
||||
|
||||
# Add existing logs
|
||||
logs = self._collect_install_logs()
|
||||
for p in logs:
|
||||
try:
|
||||
text = p.read_text(encoding='utf-8', errors='ignore')
|
||||
content = f"{p}\n\n{text}"
|
||||
except Exception as e:
|
||||
content = f"{p}\n\nFailed to read log: {e}\n"
|
||||
_add_tab(p.name, content)
|
||||
|
||||
# Add prole.cfg tab
|
||||
cfg_path = self._resolve_prole_conf_dir() / 'prole.cfg'
|
||||
try:
|
||||
cfg_text = cfg_path.read_text(encoding='utf-8', errors='ignore')
|
||||
cfg_content = f"{cfg_path}\n\n{cfg_text}"
|
||||
except Exception as e:
|
||||
cfg_content = f"{cfg_path}\n\nprole.cfg not found or unreadable: {e}\n"
|
||||
_add_tab('prole.cfg', cfg_content)
|
||||
|
||||
# Add final_deployment.sh tab
|
||||
_add_tab('final_deployment.sh', script_name='final_deployment.sh')
|
||||
# Add build-a-bao.sh tab
|
||||
_add_tab('build-a-bao.sh', script_name='build-a-bao.sh')
|
||||
|
||||
# Build-A-Bao Button
|
||||
self._build_a_bao_button = tk.Button(self.bg_canvas, text='Build-A-Bao', command=self.run_build_a_bao,
|
||||
bg='#F5F5DC', fg='black', activebackground='#E5E5D5',
|
||||
highlightbackground='#F5F5DC', highlightthickness=0,
|
||||
relief='flat', font=('SF Pro Text', 11), padx=16, pady=8)
|
||||
bao_btn_window = self.bg_canvas.create_window(48, 760, window=self._build_a_bao_button, anchor='nw', width=180)
|
||||
self._canvas_items.append(bao_btn_window)
|
||||
self._overlay_widgets.append(self._build_a_bao_button)
|
||||
|
||||
# Status Label
|
||||
self._build_a_bao_status_label = ui.canvas_text(self, 48, 802, "", fill='black', font=('SF Pro Text', 12))
|
||||
self._canvas_items.append(self._build_a_bao_status_label)
|
||||
|
||||
def _get_prole_dist_dir(self) -> Path:
|
||||
return PROJECT_ROOT / 'prole-tools-app' / 'dist'
|
||||
|
||||
def ensure_applications_symlink(self):
|
||||
"""Deprecated: no longer create /Applications symlink inside the repo.
|
||||
|
||||
We now place the symlink only inside the DMG staging directory to avoid
|
||||
confusing IDE indexers and to keep the workspace clean.
|
||||
"""
|
||||
return
|
||||
|
||||
def open_drag_install_window(self):
|
||||
"""Open the Prole DMG in Finder (macOS) for drag-and-drop install."""
|
||||
if platform.system() != 'Darwin':
|
||||
return
|
||||
p = self._get_dmg_paths()
|
||||
dmg = p['final_dmg']
|
||||
if not dmg.exists():
|
||||
try:
|
||||
self.create_dmg()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(['open', str(dmg)])
|
||||
except Exception:
|
||||
try:
|
||||
subprocess.run(['open', str(p['dist'])])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------------- DMG Packaging ----------------
|
||||
def _get_dmg_paths(self):
|
||||
# Ensure we have a valid path for DMG output.
|
||||
# Default to ~/Downloads if selected path is home or invalid.
|
||||
raw_path = self.selected_local_path.get()
|
||||
user_dist = Path(raw_path).expanduser()
|
||||
|
||||
if str(user_dist) == str(Path.home()):
|
||||
user_dist = Path.home() / 'Downloads'
|
||||
self.selected_local_path.set(str(user_dist))
|
||||
|
||||
try:
|
||||
user_dist.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
user_dist = self._get_prole_dist_dir()
|
||||
|
||||
dmg_name = 'Prole Tools.dmg'
|
||||
tmp_dmg = user_dist / 'Prole Tools.tmp.dmg'
|
||||
final_dmg = user_dist / dmg_name
|
||||
|
||||
# Use a temporary staging area in the app's dist dir to keep user folder clean
|
||||
app_dist = self._get_prole_dist_dir()
|
||||
staging = app_dist / 'dmg_stage'
|
||||
bg_dir = staging / '.background'
|
||||
bg_img = get_resource_path('img/proleLogoBlueprint.png')
|
||||
return {
|
||||
'dist': user_dist,
|
||||
'tmp_dmg': tmp_dmg,
|
||||
'final_dmg': final_dmg,
|
||||
'staging': staging,
|
||||
'bg_dir': bg_dir,
|
||||
'bg_img': bg_img,
|
||||
}
|
||||
|
||||
def create_dmg(self):
|
||||
"""Create a DMG containing Prole Tools.app, a 'setup' binary, and an /Applications symlink."""
|
||||
global installer_name
|
||||
if platform.system() != 'Darwin':
|
||||
return
|
||||
|
||||
p = self._get_dmg_paths()
|
||||
app_src = get_resource_path('prole-app/dist/Prole Tools.app')
|
||||
if not app_src.exists():
|
||||
print(f"Error: {app_src} not found. Run build first.")
|
||||
return
|
||||
|
||||
staging = p['staging']
|
||||
try:
|
||||
if staging.exists():
|
||||
print(f"Cleaning staging area: {staging}")
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. Build static installer binary using PyInstaller
|
||||
print("Building static installer binary...")
|
||||
installer_name = "Install Prole Infrastructure"
|
||||
icon_path = PROJECT_ROOT / 'img' / 'proleIconblueprint.png'
|
||||
try:
|
||||
# Use --onefile for a single executable
|
||||
cmd = [
|
||||
sys.executable, '-m', 'PyInstaller',
|
||||
'--onefile',
|
||||
'--name', installer_name,
|
||||
'--clean',
|
||||
'--noconsole', # It's a GUI app (tkinter)
|
||||
]
|
||||
if icon_path.exists():
|
||||
cmd.extend(['--icon', str(icon_path)])
|
||||
|
||||
cmd.append('install.py')
|
||||
|
||||
subprocess.check_call(cmd)
|
||||
setup_bin = PROJECT_ROOT / 'dist' / installer_name
|
||||
if setup_bin.exists():
|
||||
dst_setup = staging / installer_name
|
||||
if dst_setup.exists():
|
||||
if dst_setup.is_dir():
|
||||
shutil.rmtree(dst_setup)
|
||||
else:
|
||||
dst_setup.unlink()
|
||||
shutil.copy2(setup_bin, dst_setup)
|
||||
else:
|
||||
print(f"Error: PyInstaller failed to create '{installer_name}' binary.")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to create DMG: {e}")
|
||||
print(f"Warning: Failed to build setup binary with PyInstaller: {e}")
|
||||
|
||||
# 2. Copy Prole Tools.app to staging (at root for drag-and-drop)
|
||||
print("Copying Prole Tools.app to staging...")
|
||||
dst_app = staging / 'Prole Tools.app'
|
||||
if dst_app.exists():
|
||||
shutil.rmtree(dst_app)
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
shutil.copytree(app_src, dst_app, dirs_exist_ok=True)
|
||||
else:
|
||||
subprocess.check_call(['cp', '-R', str(app_src), str(dst_app)])
|
||||
|
||||
# Inject launcher wrapper into Prole Tools.app
|
||||
macos_dir = dst_app / 'Contents' / 'MacOS'
|
||||
launcher_path = macos_dir / 'Prole Tools'
|
||||
real_bin_path = macos_dir / 'ProleTools.bin'
|
||||
if launcher_path.exists() and launcher_path.is_file():
|
||||
if real_bin_path.exists():
|
||||
if real_bin_path.is_dir():
|
||||
shutil.rmtree(real_bin_path)
|
||||
else:
|
||||
real_bin_path.unlink()
|
||||
os.rename(launcher_path, real_bin_path)
|
||||
|
||||
script = """#!/bin/bash
|
||||
set -euo pipefail
|
||||
export PROLE_HOME="${PROLE_HOME:-$HOME/.prole}"
|
||||
if [ -f "$PROLE_HOME/env.sh" ]; then
|
||||
. "$PROLE_HOME/env.sh"
|
||||
fi
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec "$DIR/ProleTools.bin" "$@"
|
||||
"""
|
||||
with open(launcher_path, 'w') as fp:
|
||||
fp.write(script)
|
||||
os.chmod(launcher_path, 0o755)
|
||||
|
||||
# 3. Create Applications symlink
|
||||
try:
|
||||
os.symlink('/Applications', str(staging / 'Applications'))
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
# 4. Background image
|
||||
if p['bg_dir'].exists():
|
||||
shutil.rmtree(p['bg_dir'])
|
||||
p['bg_dir'].mkdir(parents=True, exist_ok=True)
|
||||
if p['bg_img'].exists():
|
||||
shutil.copy2(p['bg_img'], p['bg_dir'] / 'background.png')
|
||||
|
||||
# 5. Create the DMG
|
||||
tmp_dmg = p['tmp_dmg']
|
||||
final_dmg = p['final_dmg']
|
||||
if final_dmg.exists():
|
||||
os.remove(final_dmg)
|
||||
|
||||
# Create the DMG using hdiutil
|
||||
messagebox.showinfo("Creating DMG", "Building DMG image. This may take a minute...")
|
||||
subprocess.check_call([
|
||||
'hdiutil', 'create',
|
||||
'-volname', 'Prole',
|
||||
'-srcfolder', str(staging),
|
||||
'-ov', '-format', 'UDRW', # Create as Read/Write initially to modify view options
|
||||
str(tmp_dmg)
|
||||
])
|
||||
|
||||
# Positions in DMG:
|
||||
# [Install Prole Infrastructure] (left)
|
||||
# [Prole Tools.app] (center/right)
|
||||
# [Applications] (below Prole Tools.app)
|
||||
|
||||
# Note: We use the installer name in the AppleScript.
|
||||
# Finder items need to match the actual file names on disk.
|
||||
# \n in filename might be literal or interpreted.
|
||||
|
||||
# 6. Set DMG view options (large icons) using AppleScript
|
||||
print("Configuring DMG view options...")
|
||||
mount_point = Path('/Volumes/Prole')
|
||||
try:
|
||||
# Detach if already mounted
|
||||
subprocess.run(['hdiutil', 'detach', str(mount_point)], capture_output=True)
|
||||
|
||||
# Mount the temporary DMG
|
||||
subprocess.check_call(['hdiutil', 'attach', str(tmp_dmg), '-nobrowse'])
|
||||
|
||||
# Give it a moment to mount
|
||||
time.sleep(2)
|
||||
|
||||
if mount_point.exists():
|
||||
# Escape the installer name for AppleScript
|
||||
# Use the name that actually exists on disk.
|
||||
# PyInstaller might have replaced \n with something else in the filename if it was problematic,
|
||||
# but usually it's literal in the FS if allowed.
|
||||
|
||||
applescript = f'''
|
||||
tell application "Finder"
|
||||
tell disk "Prole"
|
||||
open
|
||||
set current view of container window to icon view
|
||||
set toolbar visible of container window to false
|
||||
set statusbar visible of container window to false
|
||||
set the_container to container window
|
||||
set bounds of the_container to {{400, 100, 1000, 600}}
|
||||
set icon_view_options to icon view options of the_container
|
||||
set icon size of icon_view_options to 192
|
||||
set arrangement of icon_view_options to not arranged
|
||||
set background picture of icon_view_options to file ".background:background.png"
|
||||
|
||||
-- Position icons
|
||||
set position of item "{installer_name}" of container window to {{150, 200}}
|
||||
set position of item "Prole Tools.app" of container window to {{450, 200}}
|
||||
set position of item "Applications" of container window to {{450, 400}}
|
||||
|
||||
update without registering applications
|
||||
delay 2
|
||||
close
|
||||
end tell
|
||||
end tell
|
||||
'''
|
||||
subprocess.run(['osascript', '-e', applescript])
|
||||
|
||||
# Detach
|
||||
subprocess.check_call(['hdiutil', 'detach', str(mount_point)])
|
||||
|
||||
# Convert to final compressed format
|
||||
if final_dmg.exists():
|
||||
os.remove(final_dmg)
|
||||
subprocess.check_call([
|
||||
'hdiutil', 'convert', str(tmp_dmg),
|
||||
'-format', 'UDZO',
|
||||
'-o', str(final_dmg)
|
||||
])
|
||||
if tmp_dmg.exists():
|
||||
os.remove(tmp_dmg)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to set DMG view options: {e}")
|
||||
# Fallback: just rename tmp_dmg if conversion/AppleScript failed
|
||||
if not final_dmg.exists():
|
||||
os.rename(tmp_dmg, final_dmg)
|
||||
|
||||
messagebox.showinfo("Success", f"Successfully created {final_dmg}")
|
||||
print(f"Successfully created {final_dmg}")
|
||||
|
||||
finally:
|
||||
# Clean up staging directory
|
||||
try:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
# Also clean up PyInstaller artifacts
|
||||
for d in ['build', 'dist']:
|
||||
p_path = PROJECT_ROOT / d
|
||||
if p_path.exists():
|
||||
# Be careful not to delete 'dist' if it contains our final DMG
|
||||
# However, create_dmg is usually run to build the DMG
|
||||
# and PyInstaller artifacts are usually temporary in this context.
|
||||
# We only remove them if they were created during this run.
|
||||
pass
|
||||
spec_file = PROJECT_ROOT / f"{installer_name}.spec"
|
||||
if spec_file.exists():
|
||||
os.remove(spec_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def open_dmg(self):
|
||||
"""Reveal the created DMG in Finder without mounting it inline.
|
||||
|
||||
This avoids blocking the installer process and lets macOS handle
|
||||
mounting/ejecting normally when the user opens the DMG.
|
||||
"""
|
||||
if platform.system() != 'Darwin':
|
||||
return
|
||||
p = self._get_dmg_paths()
|
||||
dmg = p['final_dmg']
|
||||
if not dmg.exists():
|
||||
# Try to create it first
|
||||
self.create_dmg()
|
||||
# Reveal the DMG in Finder (non-blocking); do NOT attach/mount inline
|
||||
try:
|
||||
subprocess.run(['open', '-R', str(dmg)])
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback: open the dist folder
|
||||
subprocess.run(['open', str(p['dist'])])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_install_screen(self):
|
||||
"""Create the dependency installer screen"""
|
||||
frame = tk.Frame(self.content_area, bg='#1a1a1a')
|
||||
self.screens['install'] = frame
|
||||
|
||||
# Title
|
||||
title = ttk.Label(frame, text="Install Dependencies", style='Title.TLabel')
|
||||
title.pack(pady=(0, 30))
|
||||
|
||||
# Instructions
|
||||
instructions = tk.Label(frame,
|
||||
text="Install the following dependencies to proceed with Prole deployment:",
|
||||
bg='#1a1a1a',
|
||||
fg='#aaaaaa',
|
||||
font=('Helvetica', 11))
|
||||
instructions.pack(pady=(0, 20))
|
||||
|
||||
# Dependencies list
|
||||
deps_frame = tk.Frame(frame, bg='#1a1a1a')
|
||||
deps_frame.pack(fill='both', expand=True)
|
||||
|
||||
dependencies = [
|
||||
{
|
||||
'name': 'Docker',
|
||||
'description': 'Container platform for running Prole services',
|
||||
'url': 'https://www.docker.com/products/docker-desktop',
|
||||
'install_cmd': None,
|
||||
'check_cmd': 'docker --version'
|
||||
},
|
||||
{
|
||||
'name': 'Homebrew',
|
||||
'description': 'Package manager for macOS',
|
||||
'url': 'https://brew.sh',
|
||||
'install_cmd': '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
|
||||
'check_cmd': 'brew --version'
|
||||
},
|
||||
{
|
||||
'name': 'k3d',
|
||||
'description': 'Lightweight wrapper to run k3s in Docker',
|
||||
'url': 'https://k3d.io',
|
||||
'install_cmd': 'brew install k3d',
|
||||
'check_cmd': 'k3d --version'
|
||||
},
|
||||
{
|
||||
'name': 'kubectl',
|
||||
'description': 'Kubernetes command-line tool',
|
||||
'url': 'https://kubernetes.io/docs/tasks/tools/',
|
||||
'install_cmd': 'brew install kubectl',
|
||||
'check_cmd': 'kubectl version --client'
|
||||
},
|
||||
{
|
||||
'name': 'Helm',
|
||||
'description': 'Kubernetes package manager',
|
||||
'url': 'https://helm.sh',
|
||||
'install_cmd': 'brew install helm',
|
||||
'check_cmd': 'helm version'
|
||||
},
|
||||
{
|
||||
'name': 'krew',
|
||||
'description': 'Kubectl plugin manager',
|
||||
'url': 'https://krew.sigs.k8s.io',
|
||||
'install_cmd': 'brew install krew',
|
||||
'check_cmd': 'kubectl krew version'
|
||||
},
|
||||
{
|
||||
'name': 'cmctl',
|
||||
'description': 'cert-manager CLI tool',
|
||||
'url': 'https://cert-manager.io',
|
||||
'install_cmd': 'brew install cmctl',
|
||||
'check_cmd': 'cmctl version'
|
||||
}
|
||||
]
|
||||
|
||||
self.dep_status = {}
|
||||
for dep in dependencies:
|
||||
self.create_dependency_card(deps_frame, dep)
|
||||
|
||||
# Generate installer script button
|
||||
script_btn = tk.Button(frame,
|
||||
text="Generate Installer Script",
|
||||
command=self.generate_installer_script,
|
||||
bg='#4a9eff',
|
||||
fg='white',
|
||||
activebackground='#3a8eef',
|
||||
font=('Helvetica', 12, 'bold'),
|
||||
padx=30,
|
||||
pady=15,
|
||||
cursor='hand2',
|
||||
relief='flat')
|
||||
script_btn.pack(pady=20)
|
||||
|
||||
def create_dependency_card(self, parent, dep):
|
||||
"""Create a dependency card with status and download link"""
|
||||
card = tk.Frame(parent, bg='#2a2a2a', relief='flat', bd=1)
|
||||
card.pack(fill='x', pady=5, padx=10)
|
||||
|
||||
# Left side - info
|
||||
info_frame = tk.Frame(card, bg='#2a2a2a')
|
||||
info_frame.pack(side='left', fill='both', expand=True, padx=15, pady=15)
|
||||
|
||||
name_label = tk.Label(info_frame,
|
||||
text=dep['name'],
|
||||
bg='#2a2a2a',
|
||||
fg='#ffffff',
|
||||
font=('Helvetica', 13, 'bold'),
|
||||
anchor='w')
|
||||
name_label.pack(fill='x')
|
||||
|
||||
desc_label = tk.Label(info_frame,
|
||||
text=dep['description'],
|
||||
bg='#2a2a2a',
|
||||
fg='#aaaaaa',
|
||||
font=('Helvetica', 10),
|
||||
anchor='w')
|
||||
desc_label.pack(fill='x', pady=(5, 0))
|
||||
|
||||
# Right side - status and actions
|
||||
action_frame = tk.Frame(card, bg='#2a2a2a')
|
||||
action_frame.pack(side='right', padx=15, pady=15)
|
||||
|
||||
# Status indicator
|
||||
status_label = tk.Label(action_frame,
|
||||
text="Checking...",
|
||||
bg='#2a2a2a',
|
||||
fg='#ffaa00',
|
||||
font=('Helvetica', 10))
|
||||
status_label.pack(side='left', padx=10)
|
||||
self.dep_status[dep['name']] = {'label': status_label, 'dep': dep}
|
||||
|
||||
# Download button
|
||||
download_btn = tk.Button(action_frame,
|
||||
text="Download",
|
||||
command=lambda url=dep['url']: webbrowser.open(url),
|
||||
bg='#28a745',
|
||||
fg='white',
|
||||
activebackground='#218838',
|
||||
font=('Helvetica', 10),
|
||||
padx=15,
|
||||
pady=5,
|
||||
cursor='hand2',
|
||||
relief='flat')
|
||||
download_btn.pack(side='left', padx=5)
|
||||
|
||||
# Check status
|
||||
self.check_dependency(dep['name'])
|
||||
|
||||
def check_dependency(self, name):
|
||||
"""Check if a dependency is installed"""
|
||||
dep_info = self.dep_status[name]
|
||||
dep = dep_info['dep']
|
||||
label = dep_info['label']
|
||||
|
||||
def check():
|
||||
try:
|
||||
result = subprocess.run(dep['check_cmd'].split(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5)
|
||||
if result.returncode == 0:
|
||||
label.configure(text="✓ Installed", fg='#28a745')
|
||||
else:
|
||||
label.configure(text="✗ Not Installed", fg='#dc3545')
|
||||
except Exception:
|
||||
label.configure(text="✗ Not Installed", fg='#dc3545')
|
||||
|
||||
threading.Thread(target=check, daemon=True).start()
|
||||
|
||||
def generate_installer_script(self):
|
||||
"""Generate installer script by composing per-dependency templates from installer/scripts."""
|
||||
script_path = PROJECT_ROOT / 'install_dependencies.sh'
|
||||
scripts_dir = PROJECT_ROOT / 'installer' / 'scripts'
|
||||
|
||||
# Expected order of templates
|
||||
expected = [
|
||||
'install_prole_homebrew.sh',
|
||||
'install_prole_k3d.sh',
|
||||
'install_prole_kubectl.sh',
|
||||
'install_prole_helm.sh',
|
||||
'install_prole_krew.sh',
|
||||
'install_prole_cmctl.sh',
|
||||
'install_prole_kubectl_plugins.sh',
|
||||
]
|
||||
|
||||
def _validate_and_strip(path: Path) -> str:
|
||||
"""Validate template format and return content stripped of shebang and initial set -e* line."""
|
||||
try:
|
||||
text = path.read_text(encoding='utf-8')
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to read {path.name}: {e}")
|
||||
|
||||
lines = text.splitlines()
|
||||
if not lines:
|
||||
raise RuntimeError(f"Template {path.name} is empty")
|
||||
# Validate shebang
|
||||
if not lines[0].startswith('#!/bin/bash'):
|
||||
raise RuntimeError(f"Template {path.name} must start with #!/bin/bash")
|
||||
# Ensure there is a set -e or set -euo pipefail somewhere within first 10 lines
|
||||
has_set = any('set -e' in l for l in lines[:10])
|
||||
if not has_set:
|
||||
raise RuntimeError(f"Template {path.name} must set '-e' or 'set -euo pipefail'")
|
||||
# Strip shebang
|
||||
i = 1
|
||||
# Optionally strip empty/comment lines immediately after shebang
|
||||
while i < len(lines) and lines[i].strip() == '':
|
||||
i += 1
|
||||
# If next non-empty is a "set -e*" line, drop it to avoid duplicates in composed script
|
||||
if i < len(lines) and lines[i].lstrip().startswith('set -e'):
|
||||
i += 1
|
||||
body = "\n".join(lines[i:]).strip() + "\n"
|
||||
return body
|
||||
|
||||
# Compose the final script
|
||||
errors: list[str] = []
|
||||
parts: list[str] = []
|
||||
|
||||
header = (
|
||||
"#!/bin/bash\n"
|
||||
"# Prole Dependencies Installer Script\n"
|
||||
"# Generated by Prole Installer\n\n"
|
||||
"set -euo pipefail\n\n"
|
||||
"echo \"Installing Prole dependencies...\"\n\n"
|
||||
)
|
||||
parts.append(header)
|
||||
|
||||
for fname in expected:
|
||||
p = scripts_dir / fname
|
||||
if not p.exists():
|
||||
errors.append(f"Missing template: {fname}")
|
||||
continue
|
||||
try:
|
||||
body = _validate_and_strip(p)
|
||||
parts.append(f"# ---- {fname} ----\n")
|
||||
parts.append(body)
|
||||
parts.append("\n")
|
||||
except Exception as e:
|
||||
errors.append(str(e))
|
||||
|
||||
parts.append('echo "All dependencies installed successfully!"\n')
|
||||
|
||||
if errors:
|
||||
messagebox.showerror("Template error", "\n".join(errors))
|
||||
return
|
||||
|
||||
try:
|
||||
with open(script_path, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write("".join(parts))
|
||||
os.chmod(script_path, 0o755)
|
||||
messagebox.showinfo("Success",
|
||||
f"Installer script generated at:\n{script_path}\n\n"
|
||||
"You can run it with: ./install_dependencies.sh")
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to generate script: {str(e)}")
|
||||
|
||||
def _collect_install_logs(self) -> list[Path]:
|
||||
logs_dir = self._resolve_prole_logs_dir()
|
||||
build_logs = []
|
||||
init_logs = []
|
||||
seen = set()
|
||||
|
||||
def _add(p: Path):
|
||||
sp = str(p)
|
||||
if sp in seen:
|
||||
return
|
||||
if not p.exists():
|
||||
return
|
||||
seen.add(sp)
|
||||
if p.name.startswith('build-'):
|
||||
build_logs.append(p)
|
||||
elif p.name.startswith('init_') or p.name.startswith('init-') or p.name == 'init_common_services.log':
|
||||
init_logs.append(p)
|
||||
|
||||
for raw in getattr(self, '_install_run_log_paths', []):
|
||||
try:
|
||||
_add(Path(raw))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
lbp = getattr(self, 'last_build_log_path', None)
|
||||
if lbp:
|
||||
_add(Path(lbp))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not build_logs:
|
||||
try:
|
||||
candidates = sorted(logs_dir.glob('build-*.log'), key=lambda p: p.stat().st_mtime)
|
||||
if candidates:
|
||||
_add(candidates[-1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not init_logs:
|
||||
try:
|
||||
candidates = sorted(logs_dir.glob('init_*.log'))
|
||||
for p in candidates:
|
||||
_add(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
build_logs.sort(key=lambda p: p.stat().st_mtime)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
init_logs.sort(key=lambda p: p.name)
|
||||
except Exception:
|
||||
pass
|
||||
return build_logs + init_logs
|
||||
|
||||
def get_removable_disks(self):
|
||||
"""Detect removable media on macOS using diskutil."""
|
||||
self.removable_disks = []
|
||||
if platform.system() != 'Darwin':
|
||||
return self.removable_disks
|
||||
|
||||
try:
|
||||
# Get list of all disks
|
||||
res = subprocess.run(['diskutil', 'list', '-plist'], capture_output=True, text=True)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
|
||||
import plistlib
|
||||
data = plistlib.loads(res.stdout.encode())
|
||||
all_disks = data.get('AllDisks', [])
|
||||
|
||||
for disk in all_disks:
|
||||
# Filter for whole disks to check if they are removable
|
||||
if not disk.startswith('disk') or 's' in disk:
|
||||
continue
|
||||
|
||||
info_res = subprocess.run(['diskutil', 'info', '-plist', disk], capture_output=True, text=True)
|
||||
if info_res.returncode == 0:
|
||||
info = plistlib.loads(info_res.stdout.encode())
|
||||
# Check for RemovableMedia or RemovableMediaOrExternalDevice
|
||||
# Also check BusProtocol to catch most USB sticks if they don't report as removable
|
||||
is_removable = (
|
||||
info.get('RemovableMedia', False) or
|
||||
info.get('RemovableMediaOrExternalDevice', False) or
|
||||
info.get('BusProtocol') in ['USB', 'FireWire', 'Thunderbolt']
|
||||
)
|
||||
|
||||
# Ensure it's not the internal system drive if we are using protocol as a hint
|
||||
if info.get('Internal', False) and info.get('BusProtocol') not in ['USB']:
|
||||
is_removable = False
|
||||
|
||||
if is_removable:
|
||||
# Found a removable disk, now find its mounted volumes
|
||||
# We look for partitions of this disk that are mounted
|
||||
for d2 in all_disks:
|
||||
if d2.startswith(disk + 's'):
|
||||
v_res = subprocess.run(['diskutil', 'info', '-plist', d2], capture_output=True, text=True)
|
||||
if v_res.returncode == 0:
|
||||
v_info = plistlib.loads(v_res.stdout.encode())
|
||||
mount_point = v_info.get('MountPoint')
|
||||
volume_name = v_info.get('VolumeName') or v_info.get('DeviceIdentifier')
|
||||
if mount_point:
|
||||
self.removable_disks.append((volume_name, mount_point))
|
||||
except Exception as e:
|
||||
print(f"Error detecting disks: {e}")
|
||||
|
||||
return self.removable_disks
|
||||
319
installer/ui/screens/security.py
Normal file
319
installer/ui/screens/security.py
Normal file
@ -0,0 +1,319 @@
|
||||
"""Kerberos configuration, secret management and Ansible vault."""
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import screen as ui
|
||||
from installer.core.env import (
|
||||
PROJECT_ROOT,
|
||||
_bool_str,
|
||||
_deployment_target_label,
|
||||
_ensure_ansible_vault_credentials,
|
||||
)
|
||||
from installer.config import _decrypt_prole_secret, _is_openbao_ref, _is_prole_secret
|
||||
|
||||
|
||||
class SecurityScreenMixin:
|
||||
"""Kerberos configuration, secret management and Ansible vault."""
|
||||
|
||||
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. Tests run inside the Kubernetes namespace to validate against your realm.", 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):
|
||||
self._action_flags['kerberos_config.test_connection'] = True
|
||||
realm = self.kerberos_realm.get().strip()
|
||||
user = self.kerberos_user.get().strip()
|
||||
password = self.kerberos_password.get().strip()
|
||||
kdc = self.kerberos_kdc.get().strip()
|
||||
|
||||
if not realm or not user or not password or not kdc:
|
||||
messagebox.showerror("Error", "Please fill in realm, KDC host, username, and password.")
|
||||
return
|
||||
|
||||
try:
|
||||
self.prole_cfg_data['Kerberos Authentication']['ENABLED'] = str(self.kerberos_enabled.get())
|
||||
self.prole_cfg_data['Kerberos Authentication']['REALM'] = realm
|
||||
self.prole_cfg_data['Kerberos Authentication']['KDC'] = kdc
|
||||
self.prole_cfg_data['Kerberos Authentication']['SERVER'] = kdc
|
||||
self.prole_cfg_data['Kerberos Authentication']['USER'] = user
|
||||
self.prole_cfg_data['Kerberos Authentication']['PASSWORD'] = password
|
||||
self.prole_cfg_data['Optional Features']['KERBEROS_ENABLED'] = str(self.kerberos_enabled.get())
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PORT_FORWARD'] = os.environ.get('KRB5_AD_PORT_FORWARD', '1')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_TCP_PORTS'] = os.environ.get('KRB5_AD_TCP_PORTS', '88 389 445 464 636')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_UDP_PORTS'] = os.environ.get('KRB5_AD_UDP_PORTS', '88 464')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_HOST_NETWORK'] = os.environ.get('KRB5_AD_PROXY_HOST_NETWORK', '1')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_IMAGE'] = os.environ.get('KRB5_AD_PROXY_IMAGE', 'alpine/socat')
|
||||
self.prole_cfg_data['Kerberos Authentication']['AD_PROXY_SERVICE'] = os.environ.get('KRB5_AD_SERVICE_NAME', 'prole-kerberos-ad-dc')
|
||||
self._save_prole_cfg()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.kerberos_status_text.delete('1.0', tk.END)
|
||||
self.kerberos_status_text.insert(tk.END, f"Testing {user}@{realm} inside the cluster...\n")
|
||||
|
||||
def worker():
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["PROLE_HOME"] = str(PROJECT_ROOT)
|
||||
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
|
||||
env["NAMESPACE"] = (self.db_namespace.get() or '').strip()
|
||||
env["SERVICE_NAMESPACE"] = self._get_service_namespace()
|
||||
env["KRB5_REALM"] = realm
|
||||
env["REALM"] = realm
|
||||
env["DOMAIN"] = realm.lower()
|
||||
env["KRB5_KDC"] = kdc
|
||||
env["KRB5_ADMIN"] = kdc
|
||||
env["KRB5_USER"] = user
|
||||
env["KRB5_PASSWORD"] = password
|
||||
env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get())
|
||||
env["ENABLED"] = env["KERBEROS_ENABLED"]
|
||||
mode = self._deployment_mode()
|
||||
if mode == "k3s":
|
||||
env.setdefault("KRB5_AD_PORT_FORWARD", "0")
|
||||
if mode:
|
||||
env["PROLE_MODE"] = mode
|
||||
env["DEPLOYMENT_MODE"] = mode
|
||||
env["DEPLOYMENT_TARGET"] = _deployment_target_label(self.cluster_env.get())
|
||||
mode_args = ["--mode", mode] if mode else []
|
||||
|
||||
def write_line(line):
|
||||
def _do_write(l=line):
|
||||
try:
|
||||
if self.kerberos_status_text.winfo_exists():
|
||||
self.kerberos_status_text.insert(tk.END, l)
|
||||
self.kerberos_status_text.see(tk.END)
|
||||
except (tk.TclError, RuntimeError):
|
||||
pass
|
||||
self.safe_after(_do_write)
|
||||
|
||||
write_line("\n==> init_kerberos.sh test\n")
|
||||
rc2 = self.controller.run_script(
|
||||
"init_kerberos.sh",
|
||||
args=mode_args + ["test"],
|
||||
env=env,
|
||||
on_line=write_line
|
||||
)
|
||||
if rc2 == 0:
|
||||
write_line("\nKerberos test completed successfully.\n")
|
||||
else:
|
||||
write_line(f"\nKerberos test failed with code {rc2}\n")
|
||||
|
||||
except Exception as e:
|
||||
def _do_error(msg=str(e)):
|
||||
try:
|
||||
if self.kerberos_status_text.winfo_exists():
|
||||
self.kerberos_status_text.insert(tk.END, f"Error: {msg}\n")
|
||||
except (tk.TclError, RuntimeError):
|
||||
pass
|
||||
self.safe_after(_do_error)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
def _secret_namespace(self) -> str:
|
||||
ns = ''
|
||||
try:
|
||||
ns = (self.db_namespace.get() or '').strip()
|
||||
except Exception:
|
||||
ns = ''
|
||||
if not ns:
|
||||
ns = os.environ.get('NAMESPACE', '') or 'default'
|
||||
return ns
|
||||
|
||||
def _load_secret_cache_from_cfg(self):
|
||||
try:
|
||||
if self._cfg_path_override is not None:
|
||||
cfg_path = self._cfg_path_override
|
||||
if cfg_path.is_dir():
|
||||
cfg_path = cfg_path / 'prole.cfg'
|
||||
else:
|
||||
conf_dir = self._resolve_prole_conf_dir()
|
||||
cfg_path = conf_dir / 'prole.cfg'
|
||||
except Exception:
|
||||
return
|
||||
if not cfg_path.exists():
|
||||
return
|
||||
cfg = configparser.ConfigParser(interpolation=None)
|
||||
cfg.optionxform = str
|
||||
cfg.read(cfg_path)
|
||||
for (section, key), _spec in SECRET_KEY_SPECS.items():
|
||||
if cfg.has_option(section, key):
|
||||
val = cfg.get(section, key, fallback="").strip()
|
||||
if val:
|
||||
self._cfg_secret_cache[(section, key)] = val
|
||||
if _is_openbao_ref(val):
|
||||
self._secrets_finalized = True
|
||||
|
||||
def _resolve_cached(pairs: list[tuple[str, str]]) -> str:
|
||||
for pair in pairs:
|
||||
val = self._cfg_secret_cache.get(pair, '')
|
||||
if val:
|
||||
return val
|
||||
return ''
|
||||
|
||||
db_val = _resolve_cached([
|
||||
("Inputs", "init_password.db_password"),
|
||||
("Global", "DB_PASSWORD"),
|
||||
])
|
||||
if db_val and _is_prole_secret(db_val):
|
||||
db_plain = _decrypt_prole_secret(db_val)
|
||||
elif db_val and not _is_openbao_ref(db_val):
|
||||
db_plain = db_val
|
||||
else:
|
||||
db_plain = ''
|
||||
if db_plain:
|
||||
try:
|
||||
self.db_password.set(db_plain)
|
||||
self.db_password_confirm.set(db_plain)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
krb_val = _resolve_cached([
|
||||
("Inputs", "kerberos_config.password"),
|
||||
("Kerberos Authentication", "PASSWORD"),
|
||||
])
|
||||
if krb_val and _is_prole_secret(krb_val):
|
||||
krb_plain = _decrypt_prole_secret(krb_val)
|
||||
elif krb_val and not _is_openbao_ref(krb_val):
|
||||
krb_plain = krb_val
|
||||
else:
|
||||
krb_plain = ''
|
||||
if krb_plain:
|
||||
try:
|
||||
self.kerberos_password.set(krb_plain)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _save_ansible_prole_vault(self, root_password: str):
|
||||
if not root_password:
|
||||
return
|
||||
|
||||
vault_path = PROJECT_ROOT / 'infrastructure' / 'inventory' / 'group_vars' / 'all' / 'prole_vault.yml'
|
||||
|
||||
_ensure_ansible_vault_credentials(prompt_ui=False)
|
||||
vault_pass_file = os.environ.get('ANSIBLE_VAULT_PASSWORD_FILE')
|
||||
|
||||
if not vault_pass_file or not Path(vault_pass_file).exists():
|
||||
for base in (Path.cwd(), PROJECT_ROOT):
|
||||
candidate = base / '.vault_pass'
|
||||
if candidate.is_file():
|
||||
vault_pass_file = str(candidate)
|
||||
break
|
||||
|
||||
if not vault_pass_file or not Path(vault_pass_file).exists():
|
||||
return
|
||||
|
||||
if shutil.which('ansible-vault') is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# We must decrypt if it is a prole secret
|
||||
plain_pass = root_password
|
||||
if _is_prole_secret(root_password):
|
||||
try:
|
||||
plain_pass = _decrypt_prole_secret(root_password)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
content = f"prole_root_password: \"{plain_pass}\"\n"
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||
f.write(content)
|
||||
tmp_name = f.name
|
||||
|
||||
try:
|
||||
os.chmod(tmp_name, 0o600)
|
||||
subprocess.run([
|
||||
'ansible-vault', 'encrypt', tmp_name,
|
||||
'--vault-password-file', vault_pass_file
|
||||
], check=True, capture_output=True)
|
||||
|
||||
shutil.move(tmp_name, str(vault_path))
|
||||
os.chmod(str(vault_path), 0o600)
|
||||
finally:
|
||||
if os.path.exists(tmp_name):
|
||||
try: os.unlink(tmp_name)
|
||||
except: pass
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to save ansible vault: {e}")
|
||||
1053
installer/ui/screens/services.py
Normal file
1053
installer/ui/screens/services.py
Normal file
File diff suppressed because it is too large
Load Diff
212
installer/ui/screens/supabase.py
Normal file
212
installer/ui/screens/supabase.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""Supabase configuration and deployment screen."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from installer import screen as ui
|
||||
from installer.core.env import PROJECT_ROOT, _normalize_cluster_env, _parse_bool
|
||||
|
||||
|
||||
class SupabaseScreenMixin:
|
||||
"""Supabase configuration and deployment screen."""
|
||||
|
||||
def _render_supabase_config_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('Prole::Supabase', y=150)
|
||||
|
||||
description = (
|
||||
"Launches the Supabase open-source stack via supabase/deploy.sh.\n"
|
||||
"Default mode is local (Docker Compose). Set SUPABASE_DEPLOY_MODE=k3d\n"
|
||||
"to deploy into the 'supabase' Kubernetes namespace."
|
||||
)
|
||||
self._render_paragraph(description, y=210)
|
||||
|
||||
y = 280
|
||||
self._supabase_status_var = tk.StringVar(value="Ready")
|
||||
status_item = ui.canvas_text(self, 48, y, "Status: Ready", fill='black', font=('SF Pro Text', 12))
|
||||
|
||||
def update_status_text(*args):
|
||||
try:
|
||||
self.bg_canvas.itemconfig(status_item, text=f"Status: {self._supabase_status_var.get()}")
|
||||
except Exception:
|
||||
pass
|
||||
self._supabase_status_var.trace_add('write', update_status_text)
|
||||
|
||||
# Standardized Console Output
|
||||
self._supabase_console = self._create_console_output(y=320, title="Deployment Output", width=880, height=380)
|
||||
|
||||
y = 740
|
||||
|
||||
# Deploy Supabase Button
|
||||
self._supabase_deploy_button = tk.Button(
|
||||
self.bg_canvas,
|
||||
text="Deploy Supabase",
|
||||
command=self.run_supabase_deploy,
|
||||
bg='#F5F5DC',
|
||||
fg='black',
|
||||
activebackground='#E5E5D5',
|
||||
activeforeground='black',
|
||||
highlightbackground='#F5F5DC',
|
||||
highlightcolor='#F5F5DC',
|
||||
highlightthickness=0,
|
||||
relief='flat',
|
||||
bd=0,
|
||||
cursor='hand2',
|
||||
disabledforeground='#8B8B7A',
|
||||
font=('SF Pro Text', 11),
|
||||
padx=20,
|
||||
pady=10,
|
||||
)
|
||||
btn_window = self.bg_canvas.create_window(48, y, window=self._supabase_deploy_button, anchor='nw')
|
||||
self._overlay_widgets.append(self._supabase_deploy_button)
|
||||
self._canvas_items.append(btn_window)
|
||||
|
||||
def run_supabase_deploy(self):
|
||||
if getattr(self, '_supabase_deploying', False):
|
||||
return
|
||||
|
||||
self._supabase_deploying = True
|
||||
self._supabase_deploy_button.configure(state='disabled')
|
||||
self._supabase_status_var.set("Deploying...")
|
||||
self._supabase_success = False
|
||||
self._supabase_console.clear()
|
||||
self.update_footer()
|
||||
|
||||
def worker():
|
||||
namespace = (self.db_namespace.get() or '').strip() or os.environ.get('NAMESPACE') or 'default'
|
||||
env = os.environ.copy()
|
||||
env["PROLE_HOME"] = str(PROJECT_ROOT)
|
||||
env["PROLE_SERVICE"] = str(PROJECT_ROOT)
|
||||
env["NAMESPACE"] = namespace
|
||||
|
||||
self._supabase_console.write("Starting Supabase deployment...\n")
|
||||
|
||||
script_path = PROJECT_ROOT / "supabase" / "deploy.sh"
|
||||
if not script_path.exists():
|
||||
self._supabase_console.write(f"Error: {script_path} not found.\n")
|
||||
self.safe_after(lambda: self._supabase_status_var.set("Failed (Script not found)"))
|
||||
self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal'))
|
||||
self._supabase_deploying = False
|
||||
return
|
||||
|
||||
mode = (os.environ.get("SUPABASE_DEPLOY_MODE") or os.environ.get("SUPABASE_MODE") or "").strip()
|
||||
if not mode:
|
||||
# Match cluster environment to supabase deploy mode
|
||||
cluster_env = _normalize_cluster_env(self.cluster_env.get())
|
||||
if cluster_env == 'dev':
|
||||
mode = "k3d"
|
||||
elif cluster_env in ('service', 'prod'):
|
||||
mode = "k8s"
|
||||
else:
|
||||
mode = "k3d"
|
||||
args = ["--mode", mode]
|
||||
cfg_path = None
|
||||
try:
|
||||
if self._cfg_path_override and self._cfg_path_override.exists():
|
||||
cfg_path = self._cfg_path_override
|
||||
else:
|
||||
conf_dir = self._resolve_prole_conf_dir()
|
||||
candidate = conf_dir / "prole.cfg"
|
||||
if candidate.exists():
|
||||
cfg_path = candidate
|
||||
if not cfg_path:
|
||||
candidate = PROJECT_ROOT / "conf" / "prole.cfg"
|
||||
if candidate.exists():
|
||||
cfg_path = candidate
|
||||
except Exception:
|
||||
cfg_path = None
|
||||
if cfg_path:
|
||||
args.extend(["-c", str(cfg_path)])
|
||||
if _parse_bool(os.environ.get("SUPABASE_USE_DEV_COMPOSE"), False):
|
||||
args.append("--with-dev-helpers")
|
||||
if _parse_bool(os.environ.get("SUPABASE_FOREGROUND"), False):
|
||||
args.append("--foreground")
|
||||
|
||||
self._supabase_console.write(f"Mode: {mode}\n")
|
||||
cmd = ["bash", str(script_path)] + args
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
except Exception as e:
|
||||
self._supabase_console.write(f"Failed to start deploy: {e}\n")
|
||||
self.safe_after(lambda: self._supabase_status_var.set("Failed (Launch error)"))
|
||||
self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal'))
|
||||
self._supabase_deploying = False
|
||||
return
|
||||
|
||||
if proc.stdout:
|
||||
for line in iter(proc.stdout.readline, ''):
|
||||
if line:
|
||||
self._supabase_console.write(line)
|
||||
proc.stdout.close()
|
||||
rc = proc.wait()
|
||||
|
||||
if rc == 0:
|
||||
self._supabase_console.write("\nSupabase deployment completed successfully.\n")
|
||||
ports_script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh"
|
||||
self.safe_after(lambda: self._supabase_status_var.set("Configuring Ports..."))
|
||||
|
||||
if not ports_script.exists():
|
||||
self._supabase_success = False
|
||||
self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports script not found)"))
|
||||
self._supabase_console.write(f"Error: {ports_script} not found.\n")
|
||||
else:
|
||||
self._supabase_console.write(f"\nConfiguring Supabase ports for namespace '{namespace}'...\n")
|
||||
self._supabase_console.write(f"> bash etc/init_supabase_ports.sh -n {namespace}\n\n")
|
||||
|
||||
try:
|
||||
ports_proc = subprocess.Popen(
|
||||
["bash", str(ports_script), "-n", namespace],
|
||||
cwd=str(PROJECT_ROOT),
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
except Exception as e:
|
||||
self._supabase_success = False
|
||||
self.safe_after(lambda: self._supabase_status_var.set("Failed (Ports launch error)"))
|
||||
self._supabase_console.write(f"Failed to start port wiring: {e}\n")
|
||||
else:
|
||||
if ports_proc.stdout:
|
||||
for line in iter(ports_proc.stdout.readline, ''):
|
||||
if line:
|
||||
self._supabase_console.write(line)
|
||||
ports_proc.stdout.close()
|
||||
ports_rc = ports_proc.wait()
|
||||
|
||||
if ports_rc == 0:
|
||||
self._supabase_success = True
|
||||
self.safe_after(lambda: self._supabase_status_var.set("Deployed Successfully"))
|
||||
self._supabase_console.write("\nSupabase port wiring completed successfully.\n")
|
||||
else:
|
||||
self._supabase_success = False
|
||||
self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Ports code {ports_rc})"))
|
||||
self._supabase_console.write(f"\nSupabase port wiring failed with exit code {ports_rc}.\n")
|
||||
else:
|
||||
self._supabase_success = False
|
||||
self.safe_after(lambda: self._supabase_status_var.set(f"Failed (Code {rc})"))
|
||||
self._supabase_console.write(f"\nSupabase deployment failed with exit code {rc}.\n")
|
||||
|
||||
self._supabase_deploying = False
|
||||
self.safe_after(lambda: self._supabase_deploy_button.configure(state='normal'))
|
||||
self.safe_after(self.update_footer)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
160
installer/ui/screens/validate.py
Normal file
160
installer/ui/screens/validate.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Validation screen with auto-refresh status monitoring."""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer.screen import TerminalConsole
|
||||
from installer import screen as ui
|
||||
|
||||
|
||||
class ValidateScreenMixin:
|
||||
"""Validation screen with auto-refresh status monitoring."""
|
||||
|
||||
def create_validate_screen(self):
|
||||
"""Create the Validate screen"""
|
||||
frame = self._page_container()
|
||||
self.screens['validate'] = frame
|
||||
|
||||
# Title
|
||||
title = ttk.Label(frame, text="Validate Deployment", style='Title.TLabel')
|
||||
title.pack(pady=(0, 20))
|
||||
|
||||
# Prometheus link
|
||||
prometheus_frame = tk.Frame(frame, bg='white')
|
||||
prometheus_frame.pack(pady=(0, 20))
|
||||
|
||||
prometheus_label = tk.Label(prometheus_frame,
|
||||
text="Prometheus: ",
|
||||
bg='white',
|
||||
fg='#6e6e73',
|
||||
font=('Helvetica', 11))
|
||||
prometheus_label.pack(side='left')
|
||||
|
||||
prometheus_link = tk.Label(prometheus_frame,
|
||||
text="http://localhost:9090",
|
||||
bg='white',
|
||||
fg='#4a9eff',
|
||||
font=('Helvetica', 11, 'underline'),
|
||||
cursor='hand2')
|
||||
prometheus_link.pack(side='left')
|
||||
prometheus_link.bind('<Button-1>', lambda e: webbrowser.open('http://localhost:9090'))
|
||||
|
||||
# Status display
|
||||
status_frame = tk.Frame(frame, bg='white')
|
||||
status_frame.pack(fill='both', expand=True, pady=10)
|
||||
|
||||
status_label = ttk.Label(status_frame, text="Cluster Status", style='Heading.TLabel')
|
||||
status_label.pack(anchor='w', pady=(0, 10))
|
||||
|
||||
# Status text area - using TerminalConsole for consistency
|
||||
# Use a background frame to ensure NO borders are visible around the console
|
||||
console_bg = tk.Frame(status_frame, bg='white', highlightthickness=0, bd=0)
|
||||
console_bg.pack(fill='both', expand=True, padx=1, pady=1)
|
||||
self._validation_console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0)
|
||||
self._validation_console.pack(fill='both', expand=True)
|
||||
self.status_text = self._validation_console.text
|
||||
|
||||
# Auto-refresh checkbox
|
||||
refresh_frame = tk.Frame(frame, bg='white')
|
||||
refresh_frame.pack(pady=10)
|
||||
|
||||
self.auto_refresh_var = tk.BooleanVar(value=True)
|
||||
refresh_check = tk.Checkbutton(refresh_frame,
|
||||
text="Auto-refresh every 10 seconds",
|
||||
variable=self.auto_refresh_var,
|
||||
bg='white',
|
||||
fg='#1d1d1f',
|
||||
selectcolor='#f0f0f0',
|
||||
activebackground='white',
|
||||
activeforeground='#1d1d1f',
|
||||
font=('Helvetica', 10),
|
||||
command=self.toggle_auto_refresh)
|
||||
refresh_check.pack(side='left', padx=10)
|
||||
|
||||
# Manual refresh button
|
||||
refresh_btn = tk.Button(refresh_frame,
|
||||
text="Refresh Now",
|
||||
command=self.refresh_status,
|
||||
bg='#4a9eff',
|
||||
fg='white',
|
||||
activebackground='#3a8eef',
|
||||
highlightbackground='white',
|
||||
font=('Helvetica', 10),
|
||||
padx=15,
|
||||
pady=5,
|
||||
cursor='hand2',
|
||||
relief='flat')
|
||||
refresh_btn.pack(side='left', padx=10)
|
||||
|
||||
# Start auto-refresh
|
||||
self.refresh_status()
|
||||
self.toggle_auto_refresh()
|
||||
|
||||
def toggle_auto_refresh(self):
|
||||
"""Toggle auto-refresh"""
|
||||
if self.auto_refresh_var.get():
|
||||
if not self.validation_running:
|
||||
self.validation_running = True
|
||||
self.validation_thread = threading.Thread(target=self.auto_refresh_loop, daemon=True)
|
||||
self.validation_thread.start()
|
||||
else:
|
||||
self.validation_running = False
|
||||
|
||||
def auto_refresh_loop(self):
|
||||
"""Auto-refresh loop"""
|
||||
while self.validation_running:
|
||||
time.sleep(10)
|
||||
if self.validation_running:
|
||||
self.root.after(0, self.refresh_status)
|
||||
|
||||
def refresh_status(self):
|
||||
"""Refresh the cluster status"""
|
||||
def update():
|
||||
try:
|
||||
full_status = ""
|
||||
|
||||
# Get k3d cluster list
|
||||
try:
|
||||
cluster_result = subprocess.run(['k3d', 'cluster', 'list'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5)
|
||||
full_status += f"=== k3d Cluster Status ===\n{cluster_result.stdout}\n\n"
|
||||
except Exception as e:
|
||||
full_status += f"=== k3d Cluster Status ===\nError: {str(e)}\n\n"
|
||||
|
||||
# Get kubectl cnpg status
|
||||
try:
|
||||
namespace = (self.db_namespace.get() or 'default').strip()
|
||||
cmd = self._kubectl_base_cmd() + ['cnpg', 'status', 'prole-db', '-n', namespace]
|
||||
result = subprocess.run(cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10)
|
||||
if result.returncode == 0:
|
||||
status_output = result.stdout
|
||||
else:
|
||||
# Try default namespace if prole fails, or just show error
|
||||
status_output = f"Error: {result.stderr}\n\nNote: Make sure kubectl cnpg plugin is installed and cluster exists in '{namespace}' namespace."
|
||||
|
||||
full_status += f"=== CloudNativePG Status (namespace: {namespace}) ===\n{status_output}\n"
|
||||
except FileNotFoundError:
|
||||
full_status += "=== CloudNativePG Status ===\nError: kubectl not found. Please install dependencies first.\n"
|
||||
except subprocess.TimeoutExpired:
|
||||
full_status += "=== CloudNativePG Status ===\nError: Command timed out\n"
|
||||
except Exception as e:
|
||||
full_status += f"=== CloudNativePG Status ===\nError: {str(e)}\n"
|
||||
|
||||
full_status += f"\nLast updated: {time.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
|
||||
self.status_text.delete('1.0', tk.END)
|
||||
self.status_text.insert('1.0', full_status)
|
||||
|
||||
except Exception as e:
|
||||
self.status_text.delete('1.0', tk.END)
|
||||
self.status_text.insert('1.0', f"Error: {str(e)}")
|
||||
|
||||
threading.Thread(target=update, daemon=True).start()
|
||||
108
installer/ui/screens/welcome.py
Normal file
108
installer/ui/screens/welcome.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""Welcome / splash screen."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
from installer import screen as ui
|
||||
|
||||
|
||||
class WelcomeScreenMixin:
|
||||
"""Welcome / splash screen."""
|
||||
|
||||
def _render_welcome_page(self):
|
||||
# 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, 220, welcome_text, fill='black', font=('SF Pro Text', 13), width=750)
|
||||
|
||||
# Ensure footer is updated (Next button visible)
|
||||
self.update_footer()
|
||||
|
||||
def _start_welcome_dependency_scan(self):
|
||||
self.splash_scan_running = True
|
||||
self.splash_scan_done_at = None
|
||||
self.splash_scan_started_at = time.time()
|
||||
|
||||
deps = list(self.dependencies)
|
||||
total = len(deps)
|
||||
results = {}
|
||||
|
||||
def set_status(text: str):
|
||||
try:
|
||||
if self._splash_status_item is not None:
|
||||
# Replace existing text to avoid creating many items
|
||||
self.bg_canvas.itemconfig(self._splash_status_item, text=text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def worker():
|
||||
missing_names = []
|
||||
for idx, dep in enumerate(deps, start=1):
|
||||
try:
|
||||
ok, location, version = self.get_dep_info(dep)
|
||||
except Exception:
|
||||
ok, location, version = False, None, None
|
||||
results[dep['id']] = (ok, location, version)
|
||||
# Update status text progressively
|
||||
if ok:
|
||||
txt = f"[{idx}/{total}] {dep['name']}: Installed"
|
||||
else:
|
||||
txt = f"[{idx}/{total}] {dep['name']}: Not installed"
|
||||
missing_names.append(dep['name'])
|
||||
self.safe_after(lambda s=txt: set_status(s))
|
||||
# tiny sleep to keep UI responsive without being too fast
|
||||
try:
|
||||
time.sleep(0.02)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Final message
|
||||
final_msg = 'All dependencies installed.' if not missing_names else (
|
||||
'Preparing to install ... ' + ', '.join(missing_names)
|
||||
)
|
||||
def on_done():
|
||||
set_status(final_msg)
|
||||
self.splash_scan_running = False
|
||||
self.splash_scan_done_at = time.time()
|
||||
# Re-evaluate footer to potentially show Next
|
||||
self.update_footer()
|
||||
# Important: schedule a follow-up refresh slightly after the debounce window
|
||||
# so the Next button becomes visible without requiring further UI events.
|
||||
self.safe_after(self.update_footer, delay=600)
|
||||
self.safe_after(on_done)
|
||||
|
||||
t = threading.Thread(target=worker, daemon=True)
|
||||
t.start()
|
||||
# Failsafe refresh slightly after the 8s gating window in case no UI events fire.
|
||||
self.safe_after(self.update_footer, delay=9000)
|
||||
|
||||
def _create_page_welcome(self):
|
||||
f = self._page_container()
|
||||
ttk.Label(f, text='Welcome to Prole', style='Title.TLabel').pack(anchor='w', padx=24, pady=(24, 6))
|
||||
msg = (
|
||||
'Thanks for joining Prole. We will prepare your system and install the software needed to build and run Prole.'
|
||||
)
|
||||
ttk.Label(f, text=msg, style='Body.TLabel', wraplength=800, justify='left').pack(anchor='w', padx=24)
|
||||
# self._register_page('welcome', f)
|
||||
@ -1,5 +1,5 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch, ANY
|
||||
from pathlib import Path
|
||||
from installer.core.milestones import (
|
||||
DependenciesMilestone, NetworkScanMilestone, EnvSetupMilestone,
|
||||
@ -106,26 +106,56 @@ class TestMilestones(unittest.TestCase):
|
||||
self.assertEqual(self.state.inputs.get('init_password.db_password'), 'resolved-secret-pw')
|
||||
self.assertEqual(self.state.inputs.get('init_password.db_password_confirm'), 'resolved-secret-pw')
|
||||
|
||||
def test_cluster_lifecycle_milestone_dev(self):
|
||||
@patch('subprocess.run')
|
||||
def test_cluster_lifecycle_milestone_dev(self, mock_subprocess_run):
|
||||
self.state.inputs['init_cluster.cluster_env'] = 'dev'
|
||||
self.state.inputs['init_cluster.start_cluster'] = 'True'
|
||||
self.state.controller.run_script.return_value = 0
|
||||
self.state.controller.check_docker_running.return_value = True
|
||||
self.state.controller.get_prole_db_version.return_value = "1.0.0"
|
||||
|
||||
# Mock k3d cluster list showing cluster exists and running
|
||||
mock_list_result = MagicMock()
|
||||
mock_list_result.stdout = "prole-dev-cluster 1/1 0/2 running"
|
||||
mock_list_result.returncode = 0
|
||||
# Mock docker image inspect (image not found locally)
|
||||
mock_inspect_result = MagicMock()
|
||||
mock_inspect_result.returncode = 1
|
||||
# Mock kubectl config use-context
|
||||
mock_ctx_result = MagicMock()
|
||||
mock_ctx_result.returncode = 0
|
||||
|
||||
mock_subprocess_run.side_effect = [mock_list_result, mock_ctx_result, mock_inspect_result]
|
||||
|
||||
milestone = ClusterLifecycleMilestone()
|
||||
milestone.execute(self.state)
|
||||
|
||||
from unittest.mock import ANY
|
||||
self.state.controller.run_script.assert_any_call("init_k3d.sh", args=["start"], env=ANY)
|
||||
# Verify k3d cluster list was called
|
||||
mock_subprocess_run.assert_any_call(
|
||||
['k3d', 'cluster', 'list', '--no-headers'],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
|
||||
def test_init_scripts_milestone(self):
|
||||
@patch('installer.core.milestones.InitializationScriptsMilestone._run_cmd')
|
||||
@patch('subprocess.run')
|
||||
def test_init_scripts_milestone(self, mock_subprocess_run, mock_run_cmd):
|
||||
self.state.controller.run_script.return_value = 0
|
||||
# Mock _run_cmd to return 0 (secrets exist)
|
||||
mock_run_cmd.return_value = 0
|
||||
|
||||
milestone = InitializationScriptsMilestone()
|
||||
milestone.execute(self.state)
|
||||
|
||||
from unittest.mock import ANY
|
||||
self.state.controller.run_script.assert_any_call("init_authority.sh", args=["update"], env=ANY)
|
||||
self.state.controller.run_script.assert_any_call("init_opentofu.sh", args=["-n", "default", "update"], env=ANY)
|
||||
# Verify current script list is called
|
||||
self.state.controller.run_script.assert_any_call(
|
||||
"init_common_services.sh", args=ANY, env=ANY, on_line=ANY
|
||||
)
|
||||
self.state.controller.run_script.assert_any_call(
|
||||
"init_cloudnative_pg.sh", args=ANY, env=ANY, on_line=ANY
|
||||
)
|
||||
self.state.controller.run_script.assert_any_call(
|
||||
"init_monitoring.sh", args=ANY, env=ANY, on_line=ANY
|
||||
)
|
||||
|
||||
def test_deployment_milestone(self):
|
||||
self.state.controller.run_script.return_value = 0
|
||||
@ -133,8 +163,9 @@ class TestMilestones(unittest.TestCase):
|
||||
milestone = DeploymentMilestone()
|
||||
milestone.execute(self.state)
|
||||
|
||||
from unittest.mock import ANY
|
||||
self.state.controller.run_script.assert_any_call("deploy_pipeline.sh", args=["--mode", "local", "apply"], env=ANY)
|
||||
self.state.controller.run_script.assert_any_call(
|
||||
"init_cloudnative_pg.sh", args=ANY, env=ANY, on_line=ANY
|
||||
)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@ -39,10 +39,12 @@ def test_resolve_prole_home_env(installer):
|
||||
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')
|
||||
with patch.dict(os.environ, {}, clear=True), \
|
||||
patch.object(installer, '_read_existing_env', return_value={}):
|
||||
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
|
||||
|
||||
@ -76,6 +76,7 @@ def test_navigation_flow_standard(mock_installer):
|
||||
assert mock_installer.pages[mock_installer.page_index][0] == 'init_cluster'
|
||||
|
||||
with patch.object(mock_installer, '_cluster_ready_for_navigation', return_value=True):
|
||||
mock_installer._common_services_success = True
|
||||
mock_installer.on_next()
|
||||
assert mock_installer.pages[mock_installer.page_index][0] == 'init_password'
|
||||
|
||||
@ -140,6 +141,8 @@ def test_navigation_to_create_installer(mock_installer):
|
||||
assert mock_installer.pages[mock_installer.page_index][0] == 'create_installer'
|
||||
|
||||
# Last page Finish label
|
||||
mock_installer.next_button = MagicMock()
|
||||
mock_installer.prev_button = MagicMock()
|
||||
mock_installer.update_footer()
|
||||
mock_installer.next_button.configure.assert_any_call(text='Finish')
|
||||
|
||||
|
||||
@ -66,11 +66,14 @@ def test_service_layer_overlay_behavior(mock_installer):
|
||||
assert mock_installer._common_services_success is False
|
||||
|
||||
# Next button should be disabled via update_footer
|
||||
mock_installer.next_button = MagicMock()
|
||||
mock_installer.prev_button = MagicMock()
|
||||
mock_installer.update_footer()
|
||||
mock_installer.next_button.configure.assert_any_call(state='disabled')
|
||||
|
||||
# Simulate success
|
||||
mock_installer._common_services_success = True
|
||||
mock_installer.next_button.reset_mock()
|
||||
mock_installer.update_footer()
|
||||
mock_installer.next_button.configure.assert_any_call(state='normal')
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user