prole/knoe/ui/screens/services.py

2128 lines
83 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."""
# Traffic light color constants
_TL_YELLOW = "#ff9f0a"
_TL_GREEN = "#34c759"
_TL_RED = "#ff3b30"
def _set_init_light(self, key: str, state: str) -> None:
"""Update a traffic light on the Initialization Scripts page.
state: 'yellow' | 'green' | 'red'
"""
color = {
"yellow": self._TL_YELLOW,
"green": self._TL_GREEN,
"red": self._TL_RED,
}.get(state, self._TL_YELLOW)
light = getattr(self, "_init_traffic_lights", {}).get(key)
if light is None:
return
try:
self.bg_canvas.itemconfig(light, fill=color)
except Exception:
pass
def _build_gke_registry_env(self) -> dict[str, str]:
"""Resolve GKE Artifact Registry env values from persisted config.
Returns a dict with any available keys among:
- ``GCP_PROJECT_ID``
- ``GCP_REGION``
- ``ARTIFACT_REGISTRY``
"""
resolved: dict[str, str] = {}
try:
self._load_gcp_cfg_into_knoe_data()
except Exception:
pass
gcp = ((getattr(self, "knoe_cfg_data", {}) or {}).get("GCP", {}) or {})
global_cfg = ((getattr(self, "knoe_cfg_data", {}) or {}).get("Global", {}) or {})
project_id = (
(gcp.get("project_id") or gcp.get("PROJECT_ID") or "")
.strip()
.strip('"')
)
if project_id:
resolved["GCP_PROJECT_ID"] = project_id
region = (
(gcp.get("region") or gcp.get("REGION") or gcp.get("location") or "")
.strip()
.strip('"')
)
if not region:
kubecontext = (global_cfg.get("KUBECONTEXT") or "").strip()
if kubecontext.startswith("gke_"):
parts = kubecontext.split("_", 3)
if len(parts) >= 3:
region = (parts[2] or "").strip()
if region:
resolved["GCP_REGION"] = region
artifact_registry = (global_cfg.get("ARTIFACT_REGISTRY") or "").strip().strip('"')
if not artifact_registry and project_id and region:
repo = (global_cfg.get("SERVICE_NAMESPACE") or "knoe-system").strip() or "knoe-system"
artifact_registry = f"{region}-docker.pkg.dev/{project_id}/{repo}"
if artifact_registry:
resolved["ARTIFACT_REGISTRY"] = artifact_registry
return resolved
def _render_init_scripts_page(self):
content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
app_cluster = (
self._get_input("init_password.app_cluster_name", "")
or self._get_input("env_setup.APP_CLUSTER_NAME", "")
or "knoe-dev-0"
).strip() or "knoe-dev-0"
db_cluster = (
self._get_input("init_password.db_cluster_name", "")
or self._get_input("env_setup.DB_CLUSTER_NAME", "")
or "knoe-cnpg-0"
).strip() or "knoe-cnpg-0"
ui.canvas_text(
self, right_margin, 40, "knoe.dev",
fill="#6e6e73", font=("SF Pro Text", 32, "bold"), anchor="ne",
)
ui.canvas_text(
self, right_margin, 85, "infrastructure.auto()",
fill="#6e6e73", font=("SF Pro Text", 18), anchor="ne",
)
self._render_title("Initialization Scripts", y=150)
ui.canvas_text(
self,
48,
178,
f"Application Cluster: {app_cluster} (Autopilot)",
fill="#1d1d1f",
font=("SF Pro Text", 11),
anchor="nw",
)
ui.canvas_text(
self,
48,
196,
f"Database Cluster: {db_cluster} (Standard)",
fill="#1d1d1f",
font=("SF Pro Text", 11),
anchor="nw",
)
ui.canvas_text(
self,
48,
214,
"CloudNativePG runs on the dedicated Standard GKE cluster; platform services remain on the Autopilot cluster.",
fill="#6e6e73",
font=("SF Pro Text", 10),
anchor="nw",
)
x_label = 48
gy = 242 # top of traffic-light section
# --- Traffic light section ---
# Column x positions
col_common = x_label
col_db = 330
col_plat = 645
# Group headers
for col_x, header in (
(col_common, "Common Services"),
(col_db, "Database Services"),
(col_plat, "Platform Services"),
):
ui.canvas_text(
self, col_x, gy, header,
fill="#1d1d1f", font=("SF Pro Text", 11, "bold"), anchor="nw",
)
# Common Services — one indicator
lights: dict[str, int] = {}
ly = gy + 24
indicator = self.bg_canvas.create_oval(
col_common, ly, col_common + 14, ly + 14,
fill=self._TL_YELLOW, outline="#8e8e93", width=1,
)
self._canvas_items.append(indicator)
ui.canvas_text(
self, col_common + 20, ly + 7, "Services Ready",
fill="black", font=("SF Pro Text", 10), anchor="w",
)
lights["common_services"] = indicator
# Database Services — knoe-db-1/2/3 + barman-cloud
db_items = [
("knoe_db_1", "knoe-db-1"),
("knoe_db_2", "knoe-db-2"),
("knoe_db_3", "knoe-db-3"),
("barman_cloud", "barman-cloud (backup)"),
]
for i, (key, label) in enumerate(db_items):
ly = gy + 24 + i * 24
ind = self.bg_canvas.create_oval(
col_db, ly, col_db + 14, ly + 14,
fill=self._TL_YELLOW, outline="#8e8e93", width=1,
)
self._canvas_items.append(ind)
ui.canvas_text(
self, col_db + 20, ly + 7, label,
fill="black", font=("SF Pro Text", 10), anchor="w",
)
lights[key] = ind
# Platform Services — monitoring + kong/ingress
plat_items = [
("monitoring", "Monitoring"),
("kong", "Kong / Ingress"),
]
for i, (key, label) in enumerate(plat_items):
ly = gy + 24 + i * 24
ind = self.bg_canvas.create_oval(
col_plat, ly, col_plat + 14, ly + 14,
fill=self._TL_YELLOW, outline="#8e8e93", width=1,
)
self._canvas_items.append(ind)
ui.canvas_text(
self, col_plat + 20, ly + 7, label,
fill="black", font=("SF Pro Text", 10), anchor="w",
)
lights[key] = ind
self._init_traffic_lights = lights
# Thin separator below lights
sep_y = gy + 125
self._canvas_items.append(
self.bg_canvas.create_line(
x_label, sep_y, right_margin, sep_y,
fill="#e0e0e0", width=1,
)
)
# --- Tabbed output section ---
nb_y = sep_y + 10
nb_height = 415
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_win = self.bg_canvas.create_window(
x_label, nb_y, window=notebook_bg, anchor="nw",
width=right_margin - x_label, height=nb_height,
)
self._canvas_items.append(tab_win)
self._overlay_widgets.extend([notebook_bg, self.script_tabs])
self.script_consoles = {}
self._script_tab_index = {}
tab_defs = [
("Common Services", "tab_common"),
("Database Services", "tab_database"),
("Platform Services", "tab_platform"),
("Kubectl Status", self.KUBECTL_STATUS_TAB),
]
for idx, (tab_title, key) in enumerate(tab_defs):
console_bg = tk.Frame(self.script_tabs, bg="white", highlightthickness=0, bd=0)
self.script_tabs.add(console_bg, text=tab_title)
if key == 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[key] = console
self._script_tab_index[key] = idx
self._overlay_widgets.extend([console_bg, console])
self.script_tabs.bind("<<NotebookTabChanged>>", self._on_init_scripts_tab_changed)
self.safe_after(self._refresh_kubectl_status_tab, delay=50)
# Run button + status label
btn_y = nb_y + nb_height + 12
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(
x_label, btn_y, window=self._init_scripts_button, anchor="nw", width=180
)
self._canvas_items.append(btn_window)
self._overlay_widgets.append(self._init_scripts_button)
self._init_scripts_status_label = ui.canvas_text(
self, x_label + 196, btn_y + 12, "", fill="black", font=("SF Pro Text", 12)
)
self._canvas_items.append(self._init_scripts_status_label)
# On page load: if previously completed check live state; else stay yellow
already_done = getattr(self, "_scripts_success", False) or (
(self.knoe_cfg_data.get("Initialization Scripts") or {}).get("STATUS") == "Completed"
)
if already_done:
self.safe_after(self._check_init_scripts_status_async)
def run_init_scripts(self):
self._action_flags["init_scripts.run_scripts"] = True
def worker():
# Map script filenames → category tab key
_SCRIPT_TO_TAB = {
"init_common_services.sh": "tab_common",
"common_services_log": "tab_common",
"init_cloudnative_pg.sh": "tab_database",
"init_cnpg_backup.sh": "tab_database",
"init_kong.sh": "tab_platform",
"init_monitoring.sh": "tab_platform",
"init_nginx_ingress.sh": "tab_platform",
"init_port_forwards.sh": "tab_platform",
}
def _select_tab(script_name):
cat = _SCRIPT_TO_TAB.get(script_name, script_name)
idx = getattr(self, "_script_tab_index", {}).get(cat)
if idx is None:
return
self.safe_after(
lambda: (
self.script_tabs.select(idx)
if self.script_tabs.winfo_exists()
else None
)
)
def _con(script_name):
"""Return the console for a script (mapped to category tab)."""
cat = _SCRIPT_TO_TAB.get(script_name, script_name)
return self.script_consoles.get(cat) or self.script_consoles.get(script_name)
# Reset all traffic lights to yellow at start of run
for _lk in ("common_services", "knoe_db_1", "knoe_db_2", "knoe_db_3",
"barman_cloud", "monitoring", "kong"):
self.safe_after(lambda k=_lk: self._set_init_light(k, "yellow"))
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["KNOE_HOME"] = str(PROJECT_ROOT)
env["KNOE_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, "knoe_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["KNOE_MODE"] = mode
env["DEPLOYMENT_MODE"] = mode
env["DEPLOYMENT_TARGET"] = _deployment_target_label(
self.cluster_env.get()
)
if mode == "k8s":
for _k, _v in self._build_gke_registry_env().items():
env[_k] = _v
# Inject explicit cluster contexts so all kubectl/helm ops use the right cluster.
# Common services (garage, openbao, kong, monitoring) → APP cluster.
# CNPG + backup → DB cluster (cnpg_env below).
_app_ctx = (
self._cluster_kubecontext("app")
or (self.knoe_cfg_data.get("Global", {}) or {}).get(
"APP_CLUSTER_KUBECONTEXT", ""
)
).strip()
_db_ctx = (
self._cluster_kubecontext("db")
or (self.knoe_cfg_data.get("Global", {}) or {}).get(
"DB_CLUSTER_KUBECONTEXT", ""
)
).strip()
if _app_ctx:
env["APP_CLUSTER_KUBECONTEXT"] = _app_ctx
env["KUBECONTEXT"] = _app_ctx # default = app cluster
if _db_ctx:
env["DB_CLUSTER_KUBECONTEXT"] = _db_ctx
cnpg_env = dict(env)
if _db_ctx:
cnpg_env["KUBECONTEXT"] = _db_ctx
else:
cnpg_env = dict(env)
mode_args = ["--mode", mode] if mode else []
raw_min = (
(getattr(self, "knoe_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_knoe_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)
_con(script).clear()
_con(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)
_con(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):
_con("init_common_services.sh").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:
_con(script).write(
f"\nERROR: {script} start failed with code {rc_svc}\n"
)
overall_success = False
self.safe_after(lambda: self._set_init_light("common_services", "red"))
else:
_con(script).write(f"\n{script} completed successfully.\n")
self.safe_after(lambda: self._set_init_light("common_services", "green"))
else:
_con("init_common_services.sh").write(
"Skipping common services initialization because previous steps failed.\n"
)
# 2. init_monitoring.sh initialize — runs before CNPG so PodMonitor CRD is present
# when the CNPG cluster is created (enablePodMonitor: true in knoe-db.yaml).
if overall_success and opt_allowed:
script = "init_monitoring.sh"
_select_tab(script)
_con(script).clear()
_con(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):
_con("init_monitoring.sh").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.knoe_cfg_data.get("Monitoring", {})
if mon is None:
mon = {}
mon["GRAFANA_ADMIN_PASSWORD"] = pwd
self.knoe_cfg_data["Monitoring"] = mon
self._save_knoe_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:
_con(script).write(
f"\nERROR: {script} initialize failed with code {rc_mon}\n"
)
overall_success = False
self.safe_after(lambda: self._set_init_light("monitoring", "red"))
else:
mon = self.knoe_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.knoe_cfg_data["Monitoring"] = mon
try:
self._save_knoe_cfg()
except Exception:
pass
_con(script).write(f"\n{script} completed successfully.\n")
self.safe_after(lambda: self._set_init_light("monitoring", "green"))
elif overall_success and not opt_allowed:
script = "init_monitoring.sh"
_select_tab(script)
_con(script).clear()
_con(script).write(f"[SKIP] Monitoring disabled by policy: {opt_reason}\n")
mon = (getattr(self, "knoe_cfg_data", {}) or {}).get("Monitoring") or {}
mon["STATUS"] = "Skipped"
self.knoe_cfg_data["Monitoring"] = mon
try:
self._save_knoe_cfg()
except Exception:
pass
self.safe_after(lambda: self._set_init_light("monitoring", "green"))
else:
_con("init_monitoring.sh").write(
"Skipping Monitoring initialization because previous steps failed.\n"
)
# 3. CloudNative-PG initialization (Python)
cnpg_script = "init_cloudnative_pg.sh"
if overall_success:
_con("init_monitoring.sh").write(
"\nMonitoring done — running database initialization (see Database tab)...\n"
)
_select_tab(cnpg_script)
_con(cnpg_script).clear()
_con(cnpg_script).write("Running CloudNative-PG initialization...\n")
log_path = _log_path_for(cnpg_script)
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"
_con("init_cloudnative_pg.sh").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=cnpg_env,
project_root=cnpg_project_root,
log=_cnpg_log,
mode=mode,
)
_con(cnpg_script).write(
"\nCloudNative-PG initialization completed successfully.\n"
)
for _lk in ("knoe_db_1", "knoe_db_2", "knoe_db_3"):
self.safe_after(lambda k=_lk: self._set_init_light(k, "green"))
except Exception as _cnpg_exc:
_con(cnpg_script).write(
f"\nERROR: CloudNative-PG initialization failed: {_cnpg_exc}\n"
)
overall_success = False
for _lk in ("knoe_db_1", "knoe_db_2", "knoe_db_3"):
self.safe_after(lambda k=_lk: self._set_init_light(k, "red"))
finally:
if log_fp:
try:
log_fp.close()
except Exception:
pass
else:
_con("init_cloudnative_pg.sh").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)
_con(script).clear()
_con(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):
_con("init_cnpg_backup.sh").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=cnpg_env, on_line=_backup_line
)
if log_fp:
try:
log_fp.close()
except Exception:
pass
if rc_backup != 0:
_con(script).write(f"\nERROR: {script} start failed with code {rc_backup}\n")
overall_success = False
self.safe_after(lambda: self._set_init_light("barman_cloud", "red"))
else:
_con(script).write(f"\n{script} completed successfully.\n")
self.safe_after(lambda: self._set_init_light("barman_cloud", "green"))
else:
_con("init_cnpg_backup.sh").write(
f"Skipping {cnpg_cluster} backup because previous steps failed.\n"
)
# 5. init_kong.sh start
if overall_success:
script = "init_kong.sh"
_select_tab(script)
_con(script).clear()
_con(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):
_con("init_kong.sh").write(line)
self._process_script_output_line(line)
if log_fp:
try:
log_fp.write(line)
log_fp.flush()
except Exception:
pass
_svc_ns = (env.get("SERVICE_NAMESPACE") or "").strip()
if not _svc_ns or _svc_ns == "default":
_svc_ns = (self._get_service_namespace() or "").strip()
kong_ns = _svc_ns if (_svc_ns and _svc_ns != "default") else "knoe-system"
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:
_con(script).write(f"\nERROR: {script} start failed with code {rc_kong}\n")
overall_success = False
else:
_con("init_kong.sh").write(
"Skipping Kong because previous steps failed.\n"
)
# 6. 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)
_con(script).clear()
_con(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):
_con("init_nginx_ingress.sh").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:
_con(script).write(f"\nERROR: {script} initialize failed with code {rc_ing}\n")
overall_success = False
self.safe_after(lambda: self._set_init_light("kong", "red"))
else:
_con(script).write(f"\n{script} completed successfully.\n")
self.safe_after(lambda: self._set_init_light("kong", "green"))
elif mode != "k3d":
_con("init_nginx_ingress.sh").write(
"Skipping Nginx Ingress because previous steps failed.\n"
)
# 7. init_port_forwards.sh start (k3d only — not used in k3s)
# Port-forwards are a post-installation convenience and are non-fatal:
# they only cover services deployed in this run, and optional services
# (argocd, dashboard) may not be installed yet.
if overall_success and mode != "k3s":
script = "init_port_forwards.sh"
_select_tab(script)
_con(script).clear()
_con(script).write(f"Running {script} start...\n")
def _pf_line(line):
_con("init_port_forwards.sh").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:
_con(script).write(
f"\n[WARN] {script} start exited {rc_pf} — port-forwards may not be running.\n"
"This is non-fatal; re-run 'init_port_forwards.sh start' manually after install.\n"
)
else:
_con(script).write("\nPort-forwards started.\n")
self.safe_after(lambda: self._set_init_light("kong", "green"))
# 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
_con("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
_con("init_cloudnative_pg.sh").write(
f"WARNING: Missing secrets in namespace '{ns}': {', '.join(missing_secrets)}.\n"
)
_con("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.knoe_cfg_data["Initialization Scripts"]["STATUS"] = (
"Completed" if self._scripts_success else "Attempted"
)
self.safe_after(self._save_knoe_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("KNOE_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 _check_init_scripts_status_async(self):
"""Query the live cluster and flip traffic lights to reflect current state.
Called on page load when a previous successful run is recorded, so the
user sees green lights without re-running the scripts.
"""
def worker():
ns = (
(self.db_namespace.get() or "").strip()
or os.environ.get("NAMESPACE")
or "default"
)
base_cmd = list(self._kubectl_base_cmd())
def _kubectl(*args):
try:
res = subprocess.run(
base_cmd + list(args),
capture_output=True, text=True,
env=os.environ.copy(),
)
return res.returncode, res.stdout, res.stderr
except Exception as exc:
return 1, "", str(exc)
# Common Services: any running pod in the service namespace
svc_ns = (self._get_service_namespace() or ns).strip() or ns
rc, out, _ = _kubectl(
"get", "pods", "-n", svc_ns,
"--field-selector=status.phase=Running", "-o", "name",
)
cs_ok = rc == 0 and bool(out.strip())
self.safe_after(
lambda ok=cs_ok: self._set_init_light("common_services", "green" if ok else "yellow")
)
# Database Services: knoe-db-1/2/3 pods
cnpg_cluster = (
os.environ.get("CNPG_CLUSTER_NAME")
or os.environ.get("CLUSTER_NAME")
or "knoe-db"
)
for i, key in enumerate(("knoe_db_1", "knoe_db_2", "knoe_db_3"), 1):
pod_name = f"{cnpg_cluster}-{i}"
rc_p, out_p, _ = _kubectl("get", "pod", pod_name, "-n", ns)
is_ready = rc_p == 0 and "Running" in out_p
self.safe_after(
lambda ok=is_ready, k=key: self._set_init_light(k, "green" if ok else "yellow")
)
# Barman-cloud: any completed backup
rc_b, out_b, _ = _kubectl(
"get", "backup", "-n", ns, "-o",
"jsonpath={.items[*].status.phase}",
)
barman_ok = rc_b == 0 and "completed" in out_b.lower()
self.safe_after(
lambda ok=barman_ok: self._set_init_light("barman_cloud", "green" if ok else "yellow")
)
# Monitoring: any running pod in monitoring namespace
mon_ns = os.environ.get("MONITORING_NAMESPACE") or "monitoring"
rc_m, out_m, _ = _kubectl(
"get", "pods", "-n", mon_ns,
"--field-selector=status.phase=Running", "-o", "name",
)
mon_ok = rc_m == 0 and bool(out_m.strip())
self.safe_after(
lambda ok=mon_ok: self._set_init_light("monitoring", "green" if ok else "yellow")
)
# Kong / Ingress: check kong or ingress-nginx namespace
kong_ok = False
for ns_check in ("kong", "ingress-nginx", ns):
rc_k, out_k, _ = _kubectl(
"get", "pods", "-n", ns_check,
"--field-selector=status.phase=Running", "-o", "name",
)
if rc_k == 0 and any(
"kong" in line or "ingress" in line
for line in out_k.splitlines()
):
kong_ok = True
break
self.safe_after(
lambda ok=kong_ok: self._set_init_light("kong", "green" if ok else "yellow")
)
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,
"knoe.dev",
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"infrastructure.auto()",
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",
"knoe-service-cluster",
"knoe-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["KNOE_HOME"] = str(PROJECT_ROOT)
env["KNOE_SERVICE"] = str(PROJECT_ROOT)
env["NAMESPACE"] = (self.db_namespace.get() or "").strip()
env["NAMESPACE"] = (self.db_namespace.get() or "").strip()
if mode:
env["KNOE_MODE"] = mode
env["DEPLOYMENT_MODE"] = mode
env["DEPLOYMENT_TARGET"] = _deployment_target_label(
self.cluster_env.get()
)
if mode == "k8s":
for _k, _v in self._build_gke_registry_env().items():
env[_k] = _v
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["KNOE_HOME"] = str(PROJECT_ROOT)
env["KNOE_SERVICE"] = str(PROJECT_ROOT)
mode = self._deployment_mode()
if mode:
env["KNOE_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, cluster_role="app")
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,
"knoe.dev",
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"infrastructure.auto()",
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, cluster_role="app")
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,
"knoe.dev",
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"infrastructure.auto()",
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_knoe_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, cluster_role="app")
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()