mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
- improve installer/action/controller flow and shell-variable expansion handling across screens\n- adjust Supabase Helm rendering and storage deployment templates\n- align monitoring, cloudnative-pg and repair pipeline behavior with updated config paths\n- refresh and expand installer/core regression tests around milestones, navigation and repair logic Co-authored-by: Junie <junie@jetbrains.com>
1888 lines
73 KiB
Python
1888 lines
73 KiB
Python
"""Initialization scripts, CNPG deployment and service-layer overlays."""
|
|
|
|
import os
|
|
import shlex
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import tkinter as tk
|
|
import webbrowser
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from tkinter import ttk, messagebox, filedialog
|
|
|
|
import platform
|
|
from knoe import screen as ui
|
|
from knoe.core.env import (
|
|
PROJECT_ROOT,
|
|
_bool_str,
|
|
_deployment_mode_from_env,
|
|
_deployment_target_label,
|
|
_normalize_cluster_env,
|
|
)
|
|
from knoe.core.ops import garage_store as garage_store_ops
|
|
from knoe.core.ops import monitoring as monitoring_ops
|
|
from knoe.core.ops import openbao as openbao_ops
|
|
from knoe.core.ops import opentofu as opentofu_ops
|
|
from knoe.core.ops import registry as registry_ops
|
|
from knoe.core.ops.cloudnative_pg import (
|
|
initialize as cnpg_initialize,
|
|
deploy as cnpg_deploy,
|
|
rollout as cnpg_rollout,
|
|
)
|
|
from knoe.core.policy import (
|
|
POLICY_CFG_KEY,
|
|
OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES,
|
|
evaluate_optional_workloads_allowed,
|
|
)
|
|
|
|
|
|
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(
|
|
"<<NotebookTabChanged>>", 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_cnpg_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["KNOE_DB_USER"] = self.db_username.get()
|
|
env["DB_PASSWORD"] = password
|
|
env["GRAFANA_ADMIN_PASSWORD"] = password
|
|
db_namespace = (self.db_namespace.get() or "").strip()
|
|
if not db_namespace:
|
|
db_namespace = (
|
|
((getattr(self, "prole_cfg_data", {}) or {}).get("Global", {}) or {})
|
|
.get("DATABASE_NAMESPACE", "")
|
|
.strip()
|
|
)
|
|
if not db_namespace:
|
|
db_namespace = "default"
|
|
|
|
env["DATABASE_NAMESPACE"] = db_namespace
|
|
env["NAMESPACE"] = db_namespace
|
|
env["PROLE_NAMESPACE"] = db_namespace
|
|
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 []
|
|
|
|
raw_min = (
|
|
(getattr(self, "prole_cfg_data", {}) or {})
|
|
.get("Global", {})
|
|
.get(POLICY_CFG_KEY, "")
|
|
)
|
|
try:
|
|
min_nodes = int(
|
|
str(raw_min).strip()
|
|
or str(OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES)
|
|
)
|
|
except Exception:
|
|
min_nodes = OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES
|
|
opt_allowed, _count, opt_reason = evaluate_optional_workloads_allowed(
|
|
env=env, min_nodes=min_nodes
|
|
)
|
|
|
|
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 start
|
|
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} start...\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
|
|
|
|
rc_svc = 0
|
|
svc_namespace = (
|
|
env.get("SERVICE_NAMESPACE")
|
|
or env.get("NAMESPACE")
|
|
or "default"
|
|
)
|
|
registry_ns = env.get("REGISTRY_NAMESPACE") or svc_namespace
|
|
project_root = getattr(self.controller, "project_root", PROJECT_ROOT)
|
|
|
|
def _svc_log(msg: str) -> None:
|
|
_svc_line(msg if msg.endswith("\n") else msg + "\n")
|
|
|
|
try:
|
|
_svc_log(f"[INFO] Registry update namespace={registry_ns}")
|
|
registry_ops.update(
|
|
namespace=registry_ns,
|
|
env=env,
|
|
project_root=project_root,
|
|
mode=mode,
|
|
log=_svc_log,
|
|
)
|
|
_svc_log(f"[INFO] OpenBao update namespace={svc_namespace}")
|
|
openbao_ops.update(
|
|
namespace=svc_namespace,
|
|
env=env,
|
|
project_root=project_root,
|
|
mode=mode,
|
|
log=_svc_log,
|
|
)
|
|
_svc_log(f"[INFO] Garage update namespace={svc_namespace}")
|
|
garage_store_ops.update(
|
|
namespace=svc_namespace,
|
|
env=env,
|
|
project_root=project_root,
|
|
mode=mode,
|
|
log=_svc_log,
|
|
)
|
|
_svc_log(f"[INFO] OpenTofu update namespace={svc_namespace}")
|
|
opentofu_ops.update(
|
|
namespace=svc_namespace,
|
|
env=env,
|
|
project_root=project_root,
|
|
mode=mode,
|
|
log=_svc_log,
|
|
)
|
|
except Exception as _svc_exc:
|
|
_svc_log(f"[ERROR] Common services Python execution failed: {_svc_exc}")
|
|
rc_svc = 1
|
|
if svc_fp:
|
|
try:
|
|
svc_fp.close()
|
|
except Exception:
|
|
pass
|
|
|
|
if rc_svc != 0:
|
|
self.script_consoles[script].write(
|
|
f"\nERROR: {script} start 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"
|
|
)
|
|
|
|
# 2. CloudNative-PG initialization (Python)
|
|
cnpg_tab = "init_cloudnative_pg.sh"
|
|
if overall_success:
|
|
_select_tab(cnpg_tab)
|
|
self.script_consoles[cnpg_tab].clear()
|
|
self.script_consoles[cnpg_tab].write("Running CloudNative-PG initialization...\n")
|
|
|
|
log_path = _log_path_for(cnpg_tab)
|
|
self._record_install_log(log_path)
|
|
try:
|
|
log_fp = log_path.open("a", encoding="utf-8")
|
|
except Exception:
|
|
log_fp = None
|
|
|
|
def _cnpg_log(msg):
|
|
line = msg if msg.endswith("\n") else msg + "\n"
|
|
self.script_consoles[cnpg_tab].write(line)
|
|
self._process_script_output_line(line)
|
|
if log_fp:
|
|
try:
|
|
log_fp.write(line)
|
|
log_fp.flush()
|
|
except Exception:
|
|
pass
|
|
|
|
cnpg_ns = env.get("DATABASE_NAMESPACE") or env.get("NAMESPACE", "default")
|
|
cnpg_cluster = env.get("CLUSTER_NAME") or env.get("CNPG_CLUSTER_NAME") or "knoe-db"
|
|
cnpg_project_root = getattr(self.controller, "project_root", PROJECT_ROOT)
|
|
try:
|
|
cnpg_initialize(
|
|
namespace=cnpg_ns,
|
|
cluster_name=cnpg_cluster,
|
|
env=env,
|
|
project_root=cnpg_project_root,
|
|
log=_cnpg_log,
|
|
mode=mode,
|
|
)
|
|
self.script_consoles[cnpg_tab].write(
|
|
"\nCloudNative-PG initialization completed successfully.\n"
|
|
)
|
|
except Exception as _cnpg_exc:
|
|
self.script_consoles[cnpg_tab].write(
|
|
f"\nERROR: CloudNative-PG initialization failed: {_cnpg_exc}\n"
|
|
)
|
|
overall_success = False
|
|
finally:
|
|
if log_fp:
|
|
try:
|
|
log_fp.close()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
if cnpg_tab in self.script_consoles:
|
|
self.script_consoles[cnpg_tab].write(
|
|
"Skipping CloudNative-PG initialization because previous steps failed.\n"
|
|
)
|
|
|
|
# 4. init_cnpg_backup.sh start
|
|
if overall_success:
|
|
script = "init_cnpg_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_cnpg_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
|
|
|
|
kong_ns = env.get("NAMESPACE") or "knoe-db"
|
|
rc_kong = self.controller.run_script(
|
|
script, args=mode_args + ["-n", kong_ns, "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"
|
|
)
|
|
|
|
# 6. init_monitoring.sh initialize
|
|
if overall_success and opt_allowed:
|
|
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:
|
|
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()
|
|
|
|
rc_mon = 0
|
|
monitoring_ns = env.get("MONITORING_NAMESPACE") or "monitoring"
|
|
|
|
def _monitoring_log(msg: str) -> None:
|
|
_monitoring_line(msg if msg.endswith("\n") else msg + "\n")
|
|
|
|
try:
|
|
monitoring_ops.initialize(
|
|
namespace=monitoring_ns,
|
|
env=env,
|
|
mode=mode,
|
|
log=_monitoring_log,
|
|
)
|
|
except Exception as _mon_exc:
|
|
_monitoring_log(f"[ERROR] Monitoring Python execution failed: {_mon_exc}")
|
|
rc_mon = 1
|
|
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:
|
|
mon = self.prole_cfg_data.get("Monitoring", {}) or {}
|
|
if not str(mon.get("GRAFANA_ADMIN_PASSWORD") or "").strip():
|
|
mon["GRAFANA_ADMIN_PASSWORD"] = env.get(
|
|
"GRAFANA_ADMIN_PASSWORD", ""
|
|
)
|
|
self.prole_cfg_data["Monitoring"] = mon
|
|
try:
|
|
self._save_prole_cfg()
|
|
except Exception:
|
|
pass
|
|
self.script_consoles[script].write(
|
|
f"\n{script} completed successfully.\n"
|
|
)
|
|
elif overall_success and not opt_allowed:
|
|
script = "init_monitoring.sh"
|
|
_select_tab(script)
|
|
self.script_consoles[script].clear()
|
|
msg = f"[SKIP] Monitoring disabled by policy: {opt_reason}\n"
|
|
self.script_consoles[script].write(msg)
|
|
|
|
mon = (getattr(self, "prole_cfg_data", {}) or {}).get("Monitoring")
|
|
if mon is None:
|
|
mon = {}
|
|
mon["STATUS"] = "Skipped"
|
|
self.prole_cfg_data["Monitoring"] = mon
|
|
try:
|
|
self._save_prole_cfg()
|
|
except Exception:
|
|
pass
|
|
else:
|
|
if "init_monitoring.sh" in self.script_consoles:
|
|
self.script_consoles["init_monitoring.sh"].write(
|
|
"Skipping Monitoring initialization because previous steps failed.\n"
|
|
)
|
|
|
|
# 7. 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"
|
|
)
|
|
|
|
# 8. 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 = (
|
|
str(env.get("DATABASE_NAMESPACE") or env.get("NAMESPACE") or "default").strip()
|
|
or "default"
|
|
)
|
|
kubectl_base_cmd = ["kubectl"]
|
|
try:
|
|
resolved_base_cmd = list(self._kubectl_base_cmd(mode))
|
|
if resolved_base_cmd:
|
|
kubectl_base_cmd = resolved_base_cmd
|
|
except Exception:
|
|
pass
|
|
self.script_consoles["init_cloudnative_pg.sh"].write(
|
|
f"\nVerifying critical secrets in namespace {ns}...\n"
|
|
)
|
|
critical_secrets = ["knoe-db-user", "knoe-db-superuser", "cnpg-admin-key"]
|
|
missing_secrets = []
|
|
for secret in critical_secrets:
|
|
rc_s, _ = self._run_cmd_capture(
|
|
kubectl_base_cmd + ["get", "secret", secret, "-n", ns]
|
|
)
|
|
if rc_s != 0:
|
|
missing_secrets.append(secret)
|
|
|
|
has_secret_check_warnings = False
|
|
if missing_secrets:
|
|
has_secret_check_warnings = True
|
|
self.script_consoles["init_cloudnative_pg.sh"].write(
|
|
f"WARNING: Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}.\n"
|
|
)
|
|
self.script_consoles["init_cloudnative_pg.sh"].write(
|
|
"Continuing because initialization scripts succeeded; verify CNPG secrets before production use.\n"
|
|
)
|
|
|
|
if overall_success:
|
|
status_text = "Initialization complete!"
|
|
status_fill = "#34c759"
|
|
if has_secret_check_warnings:
|
|
status_text = "Initialization complete (warnings)."
|
|
status_fill = "#ff9f0a"
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._init_scripts_status_label,
|
|
text=status_text,
|
|
fill=status_fill,
|
|
)
|
|
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()
|
|
)
|
|
|
|
ns = env.get("NAMESPACE", "default")
|
|
cluster_name = env.get("CLUSTER_NAME") or env.get("CNPG_CLUSTER_NAME") or "knoe-db"
|
|
cnpg_project_root = getattr(self.controller, "project_root", PROJECT_ROOT)
|
|
|
|
self._cnpg_deploy_console.clear()
|
|
self._cnpg_deploy_console.write("Starting CloudNative-PG deployment (Python)...\n")
|
|
|
|
def _deploy_log(msg):
|
|
line = msg if msg.endswith("\n") else msg + "\n"
|
|
self._cnpg_deploy_console.write(line)
|
|
|
|
try:
|
|
cnpg_deploy(
|
|
namespace=ns,
|
|
cluster_name=cluster_name,
|
|
env=env,
|
|
project_root=cnpg_project_root,
|
|
log=_deploy_log,
|
|
mode=mode,
|
|
)
|
|
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
|
|
except Exception as _deploy_exc:
|
|
self._cnpg_deploy_console.write(f"\nDeployment failed: {_deploy_exc}\n")
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._cnpg_deploy_status_label,
|
|
text=f"Deployment failed: {_deploy_exc}",
|
|
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
|
|
)
|
|
)
|
|
|
|
# Prepare environment
|
|
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()
|
|
)
|
|
ns = (self.db_namespace.get() or "").strip() or env.get("NAMESPACE", "default")
|
|
env["NAMESPACE"] = ns
|
|
cluster_name = env.get("CLUSTER_NAME") or env.get("CNPG_CLUSTER_NAME") or "knoe-db"
|
|
cnpg_project_root = getattr(self.controller, "project_root", PROJECT_ROOT)
|
|
|
|
self._cnpg_deploy_console.clear()
|
|
self._cnpg_deploy_console.write(
|
|
"Starting manual recreate rollout for knoe-db cluster (Python)...\n"
|
|
)
|
|
|
|
def _rollout_log(msg):
|
|
line = msg if msg.endswith("\n") else msg + "\n"
|
|
self._cnpg_deploy_console.write(line)
|
|
|
|
try:
|
|
cnpg_rollout(
|
|
namespace=ns,
|
|
cluster_name=cluster_name,
|
|
env=env,
|
|
log=_rollout_log,
|
|
)
|
|
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
|
|
)
|
|
)
|
|
except Exception as _rollout_exc:
|
|
self._cnpg_deploy_console.write(f"\nRollout failed: {_rollout_exc}\n")
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._cnpg_deploy_status_label,
|
|
text=f"Rollout failed: {_rollout_exc}",
|
|
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 the in-cluster registry/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["KNOE_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 = 0
|
|
try:
|
|
openbao_ops.initialize(
|
|
namespace=namespace,
|
|
env=env,
|
|
project_root=getattr(self.controller, "project_root", PROJECT_ROOT),
|
|
mode=mode,
|
|
log=lambda l: console.write(l if l.endswith("\n") else l + "\n"),
|
|
)
|
|
except Exception as _bao_exc:
|
|
rc = 1
|
|
console.write(f"\nOpenBao initialization failed: {_bao_exc}\n")
|
|
if rc != 0:
|
|
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
|
|
|
|
# Materialize DB/CNPG secrets in Kubernetes using the user-entered password.
|
|
# This prevents later CNPG initialization from failing due to missing secrets.
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
status_label,
|
|
text="Ensuring database secrets...",
|
|
fill="blue",
|
|
)
|
|
if self.bg_canvas.winfo_exists()
|
|
else None
|
|
)
|
|
)
|
|
try:
|
|
self.ensure_db_k8s_secrets(
|
|
namespace,
|
|
password,
|
|
log_fn=lambda m: console.write(f"{m}\n") if m else None,
|
|
)
|
|
except Exception as e:
|
|
console.write(f"\nFailed to ensure database secrets: {e}\n")
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
status_label,
|
|
text="Database secrets error",
|
|
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()
|