"""Initialization scripts, CNPG deployment and service-layer overlays.""" import os import platform import secrets import shlex import subprocess import threading import time import webbrowser from pathlib import Path 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 from installer.core.env import ( PROJECT_ROOT, _bool_str, _deployment_mode_from_env, _deployment_target_label, _normalize_cluster_env, ) class ServicesScreenMixin: """Initialization scripts, CNPG deployment and service-layer overlays.""" def _render_init_scripts_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("Initialization Scripts", y=150) self._render_paragraph( "Running initialization scripts to set up Garage, CloudNative-PG, and backups.", y=200, ) y = 260 y = self._render_init_scripts_section(48, y, 900, 450, title="Execution Output") # Use tk.Button self._init_scripts_button = tk.Button( self.bg_canvas, text="Run Scripts", command=self.run_init_scripts, bg="#F5F5DC", fg="black", activebackground="#E5E5D5", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 11), padx=16, pady=8, ) btn_window = self.bg_canvas.create_window( 48, y + 20, window=self._init_scripts_button, anchor="nw", width=180 ) self._canvas_items.append(btn_window) self._overlay_widgets.append(self._init_scripts_button) # Status Label self._init_scripts_status_label = ui.canvas_text( self, 240, y + 32, "", fill="black", font=("SF Pro Text", 12) ) self._canvas_items.append(self._init_scripts_status_label) def _render_init_scripts_section( self, x: int, y: int, width: int, height: int, title: str | None = "Execution Output", ) -> int: if title: title_item = ui.canvas_text( self, x, y, title, fill="#1d1d1f", font=("SF Pro Text", 12, "bold") ) self._canvas_items.append(title_item) y += 30 # 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.script_tabs = ttk.Notebook(notebook_bg, style="TNotebook") self.script_tabs.pack(fill="both", expand=True, padx=1, pady=1) tab_window = self.bg_canvas.create_window( x, y, window=notebook_bg, anchor="nw", width=width, height=height ) self._canvas_items.append(tab_window) self._overlay_widgets.append(notebook_bg) self._overlay_widgets.append(self.script_tabs) self.script_consoles = {} scripts = self._init_scripts_list() self._script_tab_index = {} for idx, (title, fname) in enumerate(scripts): # Use a background frame to ensure NO borders are visible around the console console_bg = tk.Frame( self.script_tabs, bg="white", highlightthickness=0, bd=0 ) self.script_tabs.add(console_bg, text=title) # Use TerminalConsole for consistent styling if fname == self.KUBECTL_STATUS_TAB: mono = ( ("Menlo", 9) if platform.system() == "Darwin" else ("Consolas", 9) ) console = ui.TerminalConsole( console_bg, highlightthickness=0, bd=0, font=mono, wrap="none", show_horizontal=True, ) else: console = ui.TerminalConsole(console_bg, highlightthickness=0, bd=0) console.pack(fill="both", expand=True, padx=1, pady=1) self.script_consoles[fname] = console self._script_tab_index[fname] = idx self._overlay_widgets.append(console_bg) self._overlay_widgets.append(console) self.script_tabs.bind( "<>", self._on_init_scripts_tab_changed ) self.safe_after(self._refresh_kubectl_status_tab, delay=50) return y + height def _init_scripts_list(self): scripts = [ ("Kubectl Status", self.KUBECTL_STATUS_TAB), ("Common Services", "init_common_services.sh"), ("Common Services Log", "common_services_log"), ("CloudNative-PG", "init_cloudnative_pg.sh"), ] scripts.append(("Prole DB Backup", "init_prole-db-backup.sh")) scripts.append(("Kong API Gateway", "init_kong.sh")) scripts.append(("Monitoring", "init_monitoring.sh")) mode = self._deployment_mode() if mode != "k3d": scripts.append(("Nginx Ingress", "init_nginx_ingress.sh")) if mode != "k3s": scripts.append(("Port Forwards", "init_port_forwards.sh")) return scripts def run_init_scripts(self): self._action_flags["init_scripts.run_scripts"] = True def worker(): def _select_tab(script_name): idx = getattr(self, "_script_tab_index", {}).get(script_name) if idx is None: return self.safe_after( lambda: ( self.script_tabs.select(idx) if self.script_tabs.winfo_exists() else None ) ) self.safe_after( lambda: ( self._init_scripts_button.configure(state="disabled") if self._init_scripts_button.winfo_exists() else None ) ) self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._init_scripts_status_label, text="Running scripts...", fill="blue", ) if self.bg_canvas.winfo_exists() else None ) ) password = self.db_password.get() # Prepare environment for scripts env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["PROLE_DB_USER"] = self.db_username.get() env["DB_PASSWORD"] = password env["GRAFANA_ADMIN_PASSWORD"] = password env["NAMESPACE"] = (self.db_namespace.get() or "").strip() env["SERVICE_NAMESPACE"] = self._get_service_namespace() if self.kerberos_realm.get().strip(): env["KRB5_REALM"] = self.kerberos_realm.get().strip() env["REALM"] = self.kerberos_realm.get().strip() env["DOMAIN"] = self.kerberos_realm.get().strip().lower() if self.kerberos_kdc.get().strip(): env["KRB5_KDC"] = self.kerberos_kdc.get().strip() env["KRB5_ADMIN"] = self.kerberos_kdc.get().strip() if self.kerberos_user.get().strip(): env["KRB5_USER"] = self.kerberos_user.get().strip() if self.kerberos_password.get().strip(): env["KRB5_PASSWORD"] = self.kerberos_password.get().strip() env["KERBEROS_ENABLED"] = _bool_str(self.kerberos_enabled.get()) env["ENABLED"] = env["KERBEROS_ENABLED"] mode = self._deployment_mode() 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 [] logs_dir = self._resolve_prole_logs_dir() try: logs_dir.mkdir(parents=True, exist_ok=True) except Exception: pass env.setdefault("PROLE_LOGS", str(logs_dir)) def _log_path_for(script_name: str) -> Path: base = Path(script_name).stem if base in ("common_services_log", "init_common_services"): return logs_dir / "init_common_services.log" return logs_dir / f"{base}.log" # Ensure port-forward mappings and port-mapping.cfg are populated # before init_nginx_ingress.sh runs (it reads port-mapping.cfg). try: self._sync_port_forward_mappings() self._update_legacy_port_mapping() except Exception: pass # 1. init_common_services.sh update overall_success = True if overall_success: script = "init_common_services.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} update...\n") svc_log_path = _log_path_for(script) self._last_common_services_log_path = svc_log_path env["COMMON_SERVICES_INIT_LOG"] = str(svc_log_path) self._record_install_log(svc_log_path) self.script_consoles["common_services_log"].clear() self.script_consoles["common_services_log"].write( f"Log file: {svc_log_path}\n\n" ) self.script_consoles[script].write(f"Log file: {svc_log_path}\n\n") try: svc_fp = svc_log_path.open("a", encoding="utf-8") except Exception: svc_fp = None def _svc_line(line): self.script_consoles[script].write(line) self.script_consoles["common_services_log"].write(line) self._process_script_output_line(line) if svc_fp: try: svc_fp.write(line) svc_fp.flush() except Exception: pass svc_args = list(mode_args) if self.kerberos_enabled.get(): svc_args.append("-k") svc_args.append("update") rc_svc = self.controller.run_script( script, args=svc_args, env=env, on_line=_svc_line ) if svc_fp: try: svc_fp.close() except Exception: pass if rc_svc != 0: self.script_consoles[script].write( f"\nERROR: {script} update failed with code {rc_svc}\n" ) overall_success = False else: self.script_consoles[script].write( f"\n{script} completed successfully.\n" ) else: self.script_consoles["init_common_services.sh"].write( "Skipping common services initialization because previous steps failed.\n" ) # 3. init_cloudnative_pg.sh initialize if overall_success: script = "init_cloudnative_pg.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} initialize...\n") self.script_consoles[script].write( "> bash etc/init_cloudnative_pg.sh initialize\n" ) log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open("a", encoding="utf-8") except Exception: log_fp = None def _cnpg_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc2 = self.controller.run_script( script, args=mode_args + ["initialize"], env=env, on_line=_cnpg_line ) if log_fp: try: log_fp.close() except Exception: pass if rc2 != 0: self.script_consoles[script].write( f"\nERROR: {script} initialize failed with code {rc2}\n" ) overall_success = False else: self.script_consoles[script].write( f"\n{script} completed successfully.\n" ) else: if "init_cloudnative_pg.sh" in self.script_consoles: self.script_consoles["init_cloudnative_pg.sh"].write( "Skipping CloudNative-PG initialization because previous steps failed.\n" ) # 4. init_prole-db-backup.sh start if overall_success: script = "init_prole-db-backup.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} start...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open("a", encoding="utf-8") except Exception: log_fp = None def _backup_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc_backup = self.controller.run_script( script, args=mode_args + ["start"], env=env, on_line=_backup_line ) if log_fp: try: log_fp.close() except Exception: pass if rc_backup != 0: self.script_consoles[script].write( f"\nERROR: {script} start failed with code {rc_backup}\n" ) overall_success = False else: self.script_consoles[script].write( f"\n{script} completed successfully.\n" ) else: self.script_consoles["init_prole-db-backup.sh"].write( "Skipping Prole DB backup because previous steps failed.\n" ) # 5. init_kong.sh start if overall_success: script = "init_kong.sh" if script in self.script_consoles: _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} start...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open("a", encoding="utf-8") except Exception: log_fp = None def _kong_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc_kong = self.controller.run_script( script, args=mode_args + ["start"], env=env, on_line=_kong_line ) if log_fp: try: log_fp.close() except Exception: pass if rc_kong != 0: self.script_consoles[script].write( f"\nERROR: {script} start failed with code {rc_kong}\n" ) overall_success = False else: self.script_consoles[script].write( f"\n{script} completed successfully.\n" ) else: if "init_kong.sh" in self.script_consoles: self.script_consoles["init_kong.sh"].write( "Skipping Kong because previous steps failed.\n" ) # 7. init_monitoring.sh initialize if overall_success: script = "init_monitoring.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} initialize...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open("a", encoding="utf-8") except Exception: log_fp = None def _monitoring_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass # Extract Grafana password if present if "GRAFANA_ADMIN_PASSWORD=" in line: pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip() if pwd: self.prole_cfg_data["Monitoring"] = { "GRAFANA_ADMIN_PASSWORD": pwd } self._save_prole_cfg() rc_mon = self.controller.run_script( script, args=mode_args + ["initialize"], env=env, on_line=_monitoring_line, ) if log_fp: try: log_fp.close() except Exception: pass if rc_mon != 0: self.script_consoles[script].write( f"\nERROR: {script} initialize failed with code {rc_mon}\n" ) overall_success = False else: self.script_consoles[script].write( f"\n{script} completed successfully.\n" ) else: if "init_monitoring.sh" in self.script_consoles: self.script_consoles["init_monitoring.sh"].write( "Skipping Monitoring initialization because previous steps failed.\n" ) # 8. init_nginx_ingress.sh initialize (k3s only — not used in k3d) if overall_success and mode != "k3d": script = "init_nginx_ingress.sh" _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} initialize...\n") log_path = _log_path_for(script) self._record_install_log(log_path) try: log_fp = log_path.open("a", encoding="utf-8") except Exception: log_fp = None def _ingress_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass rc_ing = self.controller.run_script( script, args=mode_args + ["initialize"], env=env, on_line=_ingress_line, ) if log_fp: try: log_fp.close() except Exception: pass if rc_ing != 0: self.script_consoles[script].write( f"\nERROR: {script} initialize failed with code {rc_ing}\n" ) overall_success = False else: self.script_consoles[script].write( f"\n{script} completed successfully.\n" ) elif mode != "k3d": self.script_consoles["init_nginx_ingress.sh"].write( "Skipping Nginx Ingress because previous steps failed.\n" ) # 9. init_port_forwards.sh start (k3d only — not used in k3s) if overall_success and mode != "k3s": script = "init_port_forwards.sh" if script in self.script_consoles: _select_tab(script) self.script_consoles[script].clear() self.script_consoles[script].write(f"Running {script} start...\n") def _pf_line(line): self.script_consoles[script].write(line) self._process_script_output_line(line) env_pf = dict(env) env_pf.setdefault("PORT_FORWARD_SKIP_VALIDATE", "1") env_pf.setdefault("PORT_FORWARD_SKIP_WAIT", "1") env_pf.setdefault("PORT_FORWARD_WAIT_TIMEOUT", "20") env_pf.setdefault("PORT_FORWARD_WAIT_INTERVAL", "2") rc_pf = self.controller.run_script( script, args=["--force", "--mode", mode, "start"], env=env_pf, on_line=_pf_line, ) if rc_pf != 0: self.script_consoles[script].write( f"\nERROR: {script} start failed with code {rc_pf}\n" ) overall_success = False else: running = False for _ in range(10): status_lines = [] def _status_line(line): status_lines.append(line) self.script_consoles[script].write(line) rc_status = self.controller.run_script( script, args=["--mode", mode, "status", "grafana"], env=env_pf, on_line=_status_line, ) running = any( l.lstrip().startswith("RUNNING") and "grafana" in l for l in status_lines ) if rc_status == 0 and running: break time.sleep(1) if not running: self.script_consoles[script].write( "\nERROR: Grafana port-forward not running after start.\n" ) overall_success = False else: self.script_consoles[script].write( "\nPort-forwards running (grafana ok).\n" ) # Verify critical secrets ns = env.get("NAMESPACE", "default") self.script_consoles["init_cloudnative_pg.sh"].write( f"\nVerifying critical secrets in namespace {ns}...\n" ) critical_secrets = ["prole-db-user", "prole-db-superuser", "cnpg-admin-key"] missing_secrets = [] for secret in critical_secrets: rc_s, _ = self._run_cmd_capture( ["kubectl", "get", "secret", secret, "-n", ns] ) if rc_s != 0: missing_secrets.append(secret) if missing_secrets: self.script_consoles["init_cloudnative_pg.sh"].write( f"CRITICAL: Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}.\n" ) self.script_consoles["init_cloudnative_pg.sh"].write( "Database initialization will fail without these secrets.\n" ) self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._init_scripts_status_label, text="Critical secret missing.", fill="#ff3b30", ) if self.bg_canvas.winfo_exists() else None ) ) overall_success = False if overall_success: self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._init_scripts_status_label, text="Initialization complete!", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) self._scripts_success = True else: self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._init_scripts_status_label, text="Initialization failed.", fill="#ff3b30", ) if self.bg_canvas.winfo_exists() else None ) ) self._scripts_success = False try: self.prole_cfg_data["Initialization Scripts"]["STATUS"] = ( "Completed" if self._scripts_success else "Attempted" ) self.safe_after(self._save_prole_cfg) except Exception: pass self.safe_after(lambda: self.update_footer()) self.safe_after( lambda: ( self._init_scripts_button.configure(state="normal") if self._init_scripts_button.winfo_exists() else None ) ) self.safe_after(self.check_services_status_async) threading.Thread(target=worker, daemon=True).start() def _on_init_scripts_tab_changed(self, _event=None): try: current_idx = self.script_tabs.index("current") except Exception: return if current_idx == self._script_tab_index.get(self.KUBECTL_STATUS_TAB): self._refresh_kubectl_status_tab() def _refresh_kubectl_status_tab(self): console = self.script_consoles.get(self.KUBECTL_STATUS_TAB) if not console: return namespace = ( (self.db_namespace.get() or "").strip() or os.environ.get("NAMESPACE") or "default" ) base_cmd = self._kubectl_base_cmd() def _display_cmd(args: list[str]) -> str: redacted = [] for item in base_cmd + args: if item.startswith("--token="): redacted.append("--token=***") else: redacted.append(item) return " ".join(shlex.quote(x) for x in redacted) def _run_kubectl(args: list[str]): env = os.environ.copy() env.setdefault("PROLE_HOME", str(PROJECT_ROOT)) try: res = subprocess.run( base_cmd + args, capture_output=True, text=True, env=env ) out = res.stdout or "" err = res.stderr or "" return res.returncode, out, err except Exception as exc: return 1, "", f"{exc}" def worker(): cmd_args = ["get", "pods", "-A", "-o", "wide"] code, out, err = _run_kubectl(cmd_args) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") header = f"# {_display_cmd(cmd_args)} ({now}) [highlight: namespace={namespace}]" lines = [ln.rstrip("\n") for ln in out.splitlines() if ln.strip() != ""] if code != 0 and not lines: lines = ["(no output)"] pod_rows = [] unhappy = [] if lines: for idx, line in enumerate(lines): if idx == 0: pod_rows.append({"raw": line, "ns": "", "unhappy": False}) continue parts = line.split() if len(parts) < 4: pod_rows.append({"raw": line, "ns": "", "unhappy": False}) continue ns, name, ready, status = parts[0], parts[1], parts[2], parts[3] status_lc = status.lower() happy_status = status_lc in ("running", "completed", "succeeded") ready_ok = True if status_lc == "running" and "/" in ready: try: a, b = ready.split("/", 1) ready_ok = a == b except Exception: ready_ok = False is_happy = happy_status and ( ready_ok or status_lc in ("completed", "succeeded") ) if not is_happy: unhappy.append( {"ns": ns, "name": name, "status": status, "ready": ready} ) pod_rows.append({"raw": line, "ns": ns, "unhappy": not is_happy}) details = "" if unhappy: blocks = ["", "# Unhealthy pods detected; collecting events + logs"] for pod in unhappy: ns = pod["ns"] name = pod["name"] blocks.append(f"\n## Events for {ns}/{name}") ec, eout, eerr = _run_kubectl( [ "get", "events", "-n", ns, "--field-selector", f"involvedObject.kind=Pod,involvedObject.name={name}", "--sort-by=.lastTimestamp", ] ) blocks.append(eout.strip() or eerr.strip() or "(no events output)") blocks.append(f"\n## Logs for {ns}/{name}") lc, lout, lerr = _run_kubectl( ["logs", "-n", ns, name, "--all-containers", "--tail=200"] ) blocks.append(lout.strip() or lerr.strip() or "(no logs output)") details = "\n".join(blocks).rstrip() + "\n" elif err.strip(): details = "\n# kubectl stderr\n" + err.strip() + "\n" def render(): if not console.text.winfo_exists(): return text = console.text text.configure(state="normal") text.delete("1.0", "end") text.tag_configure("header", foreground="#6e6e73") text.tag_configure("ns", background="#DDF4FF") text.tag_configure("bad", foreground="#B00020") text.insert("end", header + "\n\n", ("header",)) for row in pod_rows: tags = [] if row["ns"] == namespace: tags.append("ns") if row["unhappy"]: tags.append("bad") if row["raw"].strip(): text.insert("end", row["raw"] + "\n", tuple(tags)) if details: text.insert("end", "\n" + details) text.see("1.0") text.configure(state="disabled") self.safe_after(render) threading.Thread(target=worker, daemon=True).start() def _render_init_cnpg_deploy_page(self): # Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975 right_margin = content_width - 48 ui.canvas_text( self, right_margin, 40, "Prole", fill="#6e6e73", font=("SF Pro Text", 32, "bold"), anchor="ne", ) ui.canvas_text( self, right_margin, 85, "Infrastructure Automated.", fill="#6e6e73", font=("SF Pro Text", 18), anchor="ne", ) self._render_title("Deployment", y=150) self._render_paragraph( "Deploy the CloudNative-PG operator and cluster manifests to Kubernetes.", y=200, ) # Output Console self._cnpg_deploy_console = self._create_console_output( y=260, title="Deployment Output", width=900, height=520 ) # Use tk.Button self._cnpg_deploy_button = tk.Button( self.bg_canvas, text="Run Deployment", command=self.run_cnpg_deploy, bg="#F5F5DC", fg="black", activebackground="#E5E5D5", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 11), padx=16, pady=8, ) self._cnpg_deploy_button.update_idletasks() deploy_btn_height = self._cnpg_deploy_button.winfo_reqheight() or 32 # Deployment mode selector mode_label = ui.canvas_text( self, 48, 782, "Mode", fill="#6e6e73", font=("SF Pro Text", 10, "bold") ) self._canvas_items.append(mode_label) mode_values = [ "knoe-dev-cluster", "prole-service-cluster", "prole-prod-cluster", ] mode_frame = tk.Frame( self.bg_canvas, bg="#F5F5DC", width=180, height=deploy_btn_height ) mode_frame.pack_propagate(False) mode_combo = ttk.Combobox( mode_frame, textvariable=self.deploy_target, values=mode_values, state="readonly", width=24, ) mode_combo.pack(fill="both", expand=True) mode_combo_window = self.bg_canvas.create_window( 48, 800, window=mode_frame, anchor="nw", width=180, height=deploy_btn_height ) self._canvas_items.append(mode_combo_window) self._overlay_widgets.append(mode_frame) btn_window = self.bg_canvas.create_window( 240, 800, window=self._cnpg_deploy_button, anchor="nw", width=180 ) self._canvas_items.append(btn_window) self._overlay_widgets.append(self._cnpg_deploy_button) # Force Rollout Button self._cnpg_rollout_button = tk.Button( self.bg_canvas, text="Force Rollout", command=self.run_cnpg_rollout, bg="#F5F5DC", fg="black", activebackground="#E5E5D5", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 11), padx=16, pady=8, ) rollout_btn_window = self.bg_canvas.create_window( 420, 800, window=self._cnpg_rollout_button, anchor="nw", width=160 ) self._canvas_items.append(rollout_btn_window) self._overlay_widgets.append(self._cnpg_rollout_button) # Save Deployment Button (moved from Post Install) self._save_deployment_button = tk.Button( self.bg_canvas, text="Save Deployment", command=self.run_final_deployment, bg="#F5F5DC", fg="black", activebackground="#E5E5D5", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 11), padx=16, pady=8, ) save_btn_window = self.bg_canvas.create_window( 600, 800, window=self._save_deployment_button, anchor="nw", width=180 ) self._canvas_items.append(save_btn_window) self._overlay_widgets.append(self._save_deployment_button) # Status Labels (below the buttons row to avoid overlap) self._cnpg_deploy_status_label = ui.canvas_text( self, 48, 840, "", fill="black", font=("SF Pro Text", 12) ) self._canvas_items.append(self._cnpg_deploy_status_label) self._save_deployment_status_label = ui.canvas_text( self, 600, 840, "", fill="black", font=("SF Pro Text", 12) ) self._canvas_items.append(self._save_deployment_status_label) def run_cnpg_deploy(self): self._action_flags["init_cnpg_deploy.run_deploy"] = True def worker(): self.safe_after( lambda: ( self._cnpg_deploy_button.configure(state="disabled") if self._cnpg_deploy_button.winfo_exists() else None ) ) self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._cnpg_deploy_status_label, text="Deploying...", fill="blue" ) if self.bg_canvas.winfo_exists() else None ) ) target_value = "" try: target_value = (self.deploy_target.get() or "").strip() except Exception: target_value = "" if not target_value: try: target_value = (self.cluster_env.get() or "").strip() except Exception: target_value = "" target_key = _normalize_cluster_env(target_value) mode = _deployment_mode_from_env(target_value or self.cluster_env.get()) if target_key in ("service", "prod"): url = self._deployment_pipeline_url(target_key) self._cnpg_deploy_console.clear() self._cnpg_deploy_console.write( f"Opening deployment pipeline for {target_value or target_key}...\n" ) self._cnpg_deploy_console.write(f"{url}\n") try: webbrowser.open(url) except Exception: pass self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._cnpg_deploy_status_label, text="Deployment pipeline opened.", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) self._cnpg_success = False self.safe_after( lambda: ( self._cnpg_deploy_button.configure(state="normal") if self._cnpg_deploy_button.winfo_exists() else None ) ) return etc_dir = PROJECT_ROOT / "etc" # Prepare environment for scripts env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) env["NAMESPACE"] = (self.db_namespace.get() or "").strip() env["NAMESPACE"] = (self.db_namespace.get() or "").strip() if mode: env["PROLE_MODE"] = mode env["DEPLOYMENT_MODE"] = mode env["DEPLOYMENT_TARGET"] = _deployment_target_label( self.cluster_env.get() ) self._cnpg_deploy_console.clear() self._cnpg_deploy_console.write("Starting CloudNative-PG deployment...\n") self._cnpg_deploy_console.write( f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} deploy latest\n\n" ) cmd = ["bash", str(etc_dir / "init_cloudnative_pg.sh")] if mode: cmd += ["--mode", mode] cmd += ["deploy", "latest"] proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, ) while True: line = proc.stdout.readline() if not line and proc.poll() is not None: break if line: self._cnpg_deploy_console.write(line) if proc.returncode == 0: self._cnpg_deploy_console.write("\nDeployment successful!\n") self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._cnpg_deploy_status_label, text="Deployment successful!", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) self._cnpg_success = True else: self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._cnpg_deploy_status_label, text=f"Deployment failed (code {proc.returncode})", fill="#ff3b30", ) if self.bg_canvas.winfo_exists() else None ) ) self._cnpg_success = False self.safe_after( lambda: ( self._cnpg_deploy_button.configure(state="normal") if self._cnpg_deploy_button.winfo_exists() else None ) ) threading.Thread(target=worker, daemon=True).start() def run_cnpg_rollout(self): self._action_flags["init_cnpg_deploy.force_rollout"] = True def worker(): self.safe_after( lambda: ( self._cnpg_deploy_button.configure(state="disabled") if self._cnpg_deploy_button.winfo_exists() else None ) ) self.safe_after( lambda: ( self._cnpg_rollout_button.configure(state="disabled") if self._cnpg_rollout_button.winfo_exists() else None ) ) self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._cnpg_deploy_status_label, text="Rolling out...", fill="blue", ) if self.bg_canvas.winfo_exists() else None ) ) etc_dir = PROJECT_ROOT / "etc" # Prepare environment for scripts env = os.environ.copy() env["PROLE_HOME"] = str(PROJECT_ROOT) env["PROLE_SERVICE"] = str(PROJECT_ROOT) mode = self._deployment_mode() if mode: env["PROLE_MODE"] = mode env["DEPLOYMENT_MODE"] = mode env["DEPLOYMENT_TARGET"] = _deployment_target_label( self.cluster_env.get() ) self._cnpg_deploy_console.clear() self._cnpg_deploy_console.write( "Starting manual recreate rollout for prole-db cluster...\n" ) self._cnpg_deploy_console.write( f"> bash etc/init_cloudnative_pg.sh --mode {mode or 'k3d'} rollout\n\n" ) cmd = ["bash", str(etc_dir / "init_cloudnative_pg.sh")] if mode: cmd += ["--mode", mode] cmd += ["rollout"] proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, ) while True: line = proc.stdout.readline() if not line and proc.poll() is not None: break if line: self._cnpg_deploy_console.write(line) if proc.returncode == 0: self._cnpg_deploy_console.write("\nRollout successful!\n") self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._cnpg_deploy_status_label, text="Rollout successful!", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) else: self._cnpg_deploy_console.write( f"\nRollout failed with code {proc.returncode}\n" ) self.safe_after( lambda: ( self.bg_canvas.itemconfig( self._cnpg_deploy_status_label, text=f"Rollout failed (code {proc.returncode})", fill="#ff3b30", ) if self.bg_canvas.winfo_exists() else None ) ) self.safe_after( lambda: ( self._cnpg_deploy_button.configure(state="normal") if self._cnpg_deploy_button.winfo_exists() else None ) ) self.safe_after( lambda: ( self._cnpg_rollout_button.configure(state="normal") if self._cnpg_rollout_button.winfo_exists() else None ) ) def _run_service_layer_inline(self, next_page: str = "init_password"): """Run service-layer migration in the background without showing the overlay screen. Used when all common services are already green so the user can skip directly to Database Creation.""" def _inline_worker(): try: namespace = (self.db_namespace.get() or "").strip() or "default" env = self._script_env_for_namespace(namespace) env["SERVICE_NAMESPACE"] = self._get_service_namespace() rc = self.controller.run_script( "init_service_layer.sh", args=["migrate"], env=env, ) if rc == 0: self._common_services_success = True except Exception: pass # Navigate regardless — the service layer migration is best-effort # when all traffic lights are already green. self._common_services_success = True self.safe_after(lambda: self.show_page(next_page)) threading.Thread(target=_inline_worker, daemon=True).start() def _run_service_layer_overlay(self, next_page: str = "init_password"): """Ensure service-layer resources are ready before database creation.""" self._showing_service_overlay = True self._common_services_success = False self.update_footer() self._clear_canvas_page() # 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("Preparing Service Layer", y=150) self._render_paragraph( "Ensuring service-layer resources are ready before database creation.", y=200, ) console = self._create_console_output( y=260, title="Service Layer Output", width=900, height=520 ) status_label = ui.canvas_text( self, 240, 812, "Ready to deploy common services", fill="black", font=("SF Pro Text", 12), ) self._canvas_items.append(status_label) log_path = self._common_services_log_path() try: console.write(f"Log file: {log_path}\n\n") except Exception: pass def worker(): try: self.safe_after( lambda: ( self._deploy_services_button.configure(state="disabled") if self._deploy_services_button.winfo_exists() else None ) ) namespace = (self.db_namespace.get() or "").strip() or "default" env = self._script_env_for_namespace(namespace) env["SERVICE_NAMESPACE"] = self._get_service_namespace() try: log_fp = log_path.open("a", encoding="utf-8") log_fp.write( f"\n# Service layer deploy @ {time.strftime('%Y-%m-%d %H:%M:%S')}\n" ) log_fp.flush() except Exception: log_fp = None def _line(line: str): console.write(line) if log_fp: try: log_fp.write(line) log_fp.flush() except Exception: pass self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Deploying service layer...", fill="blue" ) if self.bg_canvas.winfo_exists() else None ) ) rc = self.controller.run_script( "init_service_layer.sh", args=["migrate"], env=env, on_line=_line ) if log_fp: try: log_fp.write(f"\nExit status: {rc}\n") log_fp.close() except Exception: pass if rc != 0: console.write(f"\nService layer setup failed (code {rc}).\n") self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text=f"Service layer failed (code {rc})", fill="#ff3b30", ) if self.bg_canvas.winfo_exists() else None ) ) self.safe_after( lambda: ( self._deploy_services_button.configure(state="normal") if self._deploy_services_button.winfo_exists() else None ) ) return self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Service layer ready. You may proceed.", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) self._common_services_success = True self.safe_after(self.update_footer) except Exception as e: console.write(f"\nService layer error: {e}\n") self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Service layer error", fill="#ff3b30" ) if self.bg_canvas.winfo_exists() else None ) ) self.safe_after( lambda: ( self._deploy_services_button.configure(state="normal") if self._deploy_services_button.winfo_exists() else None ) ) # Use tk.Button explicitly for control over button appearance and behavior self._deploy_services_button = tk.Button( self.bg_canvas, text="Deploy Services", command=lambda: threading.Thread(target=worker, daemon=True).start(), 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._deploy_services_button, anchor="nw", width=180 ) self._canvas_items.append(btn_window) self._overlay_widgets.append(self._deploy_services_button) def _run_preparation_overlay(self): """Prepare environment for database creation.""" self._action_flags["init_password.generate_ssh_key"] = True self._clear_canvas_page() # 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("Preparing", y=150) self._render_paragraph( "Preparing your environment for database creation.", y=200 ) # Output Console - standardized to match Docker Build screen console = self._create_console_output(y=260, title="", width=900, height=520) # Status Label status_label = ui.canvas_text( self, 48, 812, "Initializing...", fill="black", font=("SF Pro Text", 12) ) self._canvas_items.append(status_label) def _show_return_button(): def _place(): try: if getattr(self, "_prep_return_btn", None): self.bg_canvas.delete(getattr(self, "_prep_return_btn_window", None)) except Exception: pass btn = tk.Button( self.bg_canvas, text="Back to Database", command=lambda: self.show_page("init_password"), bg="#F5F5DC", fg="black", activebackground="#E5E5D5", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 11), padx=14, pady=6, ) btn_window = self.bg_canvas.create_window( 48, 840, window=btn, anchor="nw", width=180 ) self._prep_return_btn = btn self._prep_return_btn_window = btn_window self._overlay_widgets.append(btn) self._canvas_items.append(btn_window) self.safe_after(_place) def worker(): key_path = Path.home() / ".ssh" / "id_prole_ed25519" key_path.parent.mkdir(parents=True, exist_ok=True) password = self.db_password.get() namespace = (self.db_namespace.get() or "").strip() mode = _deployment_mode_from_env(self.cluster_env.get()) # Pre-pull dependent images into local registry before initialization if mode == "k3s": console.write( "[INFO] Skipping image pre-pull in k3s mode (images are handled by k3s/common-services import).\n" ) else: try: self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Pre-pulling images...", fill="blue" ) if self.bg_canvas.winfo_exists() else None ) ) pull_ok = self._prepull_images_to_registry( include_supabase=self.supabase_enabled.get(), include_kerberos_proxy=self.kerberos_enabled.get(), log=console.write, ) if not pull_ok: console.write( "\n[WARN] Image pre-pull failed or incomplete. Continuing...\n" ) except Exception as e: console.write(f"\n[WARN] Image pre-pull error: {e}\n") if key_path.exists(): console.write(f"Key already exists at {key_path}. Proceeding...\n") self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Keys present. Proceeding...", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) time.sleep(0.5) else: self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Preparing keys...", fill="blue" ) if self.bg_canvas.winfo_exists() else None ) ) cmd = [ "ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", self.db_username.get(), ] console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") proc = subprocess.Popen( cmd, 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: console.write(line) if proc.returncode != 0: console.write( f"\nPreparation failed, trying alternative (code {proc.returncode})\n" ) cmd = [ "ssh-keygen", "-t", "rsa", "-b", "4096", "-N", "", "-f", str(key_path), "-C", self.db_username.get(), ] console.write(f"Initializing secure access: {' '.join(cmd)}\n\n") proc = subprocess.Popen( cmd, 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: console.write(line) if proc.returncode == 0: console.write("\nPreparation completed successfully.\n") self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Preparation completed successfully.", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) else: console.write( f"\nError during preparation (code {proc.returncode})\n" ) self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text=f"Error during preparation (code {proc.returncode})", fill="#ff3b30", ) if self.bg_canvas.winfo_exists() else None ) ) _show_return_button() return # Initialize OpenBao and store keys/passwords self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="Initializing OpenBao...", fill="blue" ) if self.bg_canvas.winfo_exists() else None ) ) env = self._script_env_for_namespace(namespace) env["PROLE_DB_USER"] = self.db_username.get().strip() env["DB_PASSWORD"] = password env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str( self.at_rest_encryption_enabled.get() ) if self.at_rest_encryption_enabled.get(): console.write( "At-rest encryption enabled: generating/storing TDE keys in OpenBao...\n" ) rc = self.controller.run_script( "init_openbao.sh", args=[ "initialize", "--mode", mode, "--namespace", namespace, "--config", str(self.controller.cfg_path), ], env=env, stdin_text=f"{password}\n", on_line=lambda l: console.write(l), ) if rc != 0: console.write(f"\nOpenBao initialization failed (code {rc}).\n") self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text=f"OpenBao init failed (code {rc})", fill="#ff3b30", ) if self.bg_canvas.winfo_exists() else None ) ) _show_return_button() return # Move to next page self.safe_after( lambda: ( self.bg_canvas.itemconfig( status_label, text="OpenBao ready. Proceeding...", fill="#34c759", ) if self.bg_canvas.winfo_exists() else None ) ) self.root.after(1000, lambda: self.show_page("init_scripts")) threading.Thread(target=worker, daemon=True).start()