prole/installer/ui/screens/database.py
chrisfu 5618b662dd Remove prole-db-manager; simplify deployment via prole-authority; fix pg18 downgrade & cluster name
Summary:
Removed the prole-db-manager microservice and simplified deployment to use
prole-authority as the internal management and authorization point. Fixed two
blocking bugs that prevented silent install from completing on knoe-dev-cluster.

Removed: prole-db-manager
- Deleted db-manager-deployment.yaml and db-manager-service.yaml from opentofu manifests
- Deleted src/db-manager/ (Dockerfile, server.js, package.json, tests)
- Removed prole-db-manager port-forward mapping from installer/core/env.py
- Removed init_db_manager.sh from Initialization Scripts (milestones.py, actions.py)
- Removed init_certmgr.sh and init_db_manager.sh tabs from services screen (services.py)
- Removed live k8s Deployment/Service from knoe-dev-cluster

Fixed: PostgreSQL version downgrade error (pg17 -> pg18)
- Created conf/postgresql/.version with value 18
- Updated k8s/prole/prole-db.yaml and prole-db-recovery.yaml.tpl imageName to prole-db:18-089
- Fixed _init_database_options_state() to restore saved version_type from prole.cfg
  so db_version_type defaults to v18 (pg18) instead of silently reverting to pg17
- Added database_options.* keys to _collect_input_snapshot() in cfg.py so
  distribution, version_type, and all extension toggles persist to prole.cfg

Fixed: Cluster name inconsistency
- Removed stale prole-dev-cluster references; all scripts now use knoe-dev-cluster
- Added knoe-dev-cluster to mode-detection case in etc/prole_cfg.sh

Config: conf/prole.cfg
- Set kerberos_config.enabled = False, KERBEROS_AUTO_ENABLED = False
- Added database_options.distribution = percona, version_type = v18
- Added all 13 extension flags set to True (postgis, pgvector, pgcrypto, pgaudit,
  pg_repack, pg_stat_statements, pg_buffercache, pg_freespacemap, pgrowlocks,
  postgres_fdw, dblink, pg_stat_monitor, pgbadger)

Verification:
./install.py -s -l -v -c conf/prole.cfg completed successfully.
CNPG deployed prole-db:18-089 to knoe-dev-cluster; all milestones passed.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-01 20:40:44 -08:00

1347 lines
49 KiB
Python

"""Database creation, namespace management and DB build screens."""
import json
import os
import re
import shutil
import subprocess
import threading
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from installer import screen as ui
from installer.config import get_docker_build_platform_args
from installer.core.env import (
NAMESPACE_PREFIX,
POSTGRES_DB_NAME_MAX_LEN,
PROJECT_ROOT,
_bool_str,
_deployment_mode_from_env,
_safe_str,
_push_docker_image,
get_resource_path,
)
class DatabaseScreenMixin:
"""Database creation, namespace management and DB build screens."""
def _render_init_password_page(self):
# Letterhead at top right
content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
ui.canvas_text(
self,
right_margin,
40,
"Prole",
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"Infrastructure Automated.",
fill="#6e6e73",
font=("SF Pro Text", 18),
anchor="ne",
)
self._render_title("Database Creation", y=150)
self._render_paragraph(
"Create a database namespace for this deployment. The namespace is treated as a single database with a name and one root password. An ed25519 SSH key will be generated and stored in OpenBao for root access.",
y=200,
)
x_label = 48
x_field = 300
y = 270
# Database (namespace) name
self._canvas_items.append(
ui.canvas_text(
self,
x_label,
y,
"Database Name (Namespace):",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
ns_frame = tk.Frame(
self.bg_canvas,
bg="white",
highlightbackground="#CCCCCC",
highlightthickness=1,
bd=0,
)
ns_frame.pack_propagate(False)
prefix_label = tk.Label(
ns_frame,
text=self._namespace_prefix(),
fg="#9a9aa0",
bg="white",
font=("SF Pro Text", 11),
)
vcmd = (self.root.register(self._validate_namespace_suffix), "%P")
suffix_entry = tk.Entry(
ns_frame,
textvariable=self.db_namespace_suffix,
bg="white",
fg="black",
insertbackground="black",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 11),
validate="key",
validatecommand=vcmd,
)
prefix_label.pack(side="left", padx=(8, 2))
suffix_entry.pack(side="left", fill="both", expand=True, padx=(0, 8))
ns_window = self.bg_canvas.create_window(
x_field, y - 12, window=ns_frame, anchor="nw", width=400, height=32
)
self._canvas_items.append(ns_window)
self._overlay_widgets.append(ns_frame)
self._overlay_widgets.append(prefix_label)
self._overlay_widgets.append(suffix_entry)
y += 42
# Owner (local user)
self.db_username.set(self.namespace_owner)
self._canvas_items.append(
ui.canvas_text(
self,
x_label,
y,
"Owner:",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
owner_entry = tk.Entry(
self.bg_canvas,
textvariable=self.db_username,
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
owner_window = self.bg_canvas.create_window(
x_field, y - 12, window=owner_entry, anchor="nw", width=400, height=32
)
self._canvas_items.append(owner_window)
self._overlay_widgets.append(owner_entry)
y += 42
self._canvas_items.append(
ui.canvas_text(
self,
x_label,
y,
"Master Password:",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
# Use tk.Entry on canvas
p1 = tk.Entry(
self.bg_canvas,
textvariable=self.db_password,
show="*",
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
p1_window = self.bg_canvas.create_window(
x_field, y - 12, window=p1, anchor="nw", width=400, height=32
)
self._canvas_items.append(p1_window)
self._overlay_widgets.append(p1)
y += 42
self._canvas_items.append(
ui.canvas_text(
self,
x_label,
y,
"Confirm:",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
# Use tk.Entry on canvas
p2 = tk.Entry(
self.bg_canvas,
textvariable=self.db_password_confirm,
show="*",
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
p2_window = self.bg_canvas.create_window(
x_field, y - 12, window=p2, anchor="nw", width=400, height=32
)
self._canvas_items.append(p2_window)
self._overlay_widgets.append(p2)
y += 42
self._canvas_items.append(
ui.canvas_text(
self,
x_label,
y,
"Host Port Forward:",
fill="black",
font=("SF Pro Text", 12, "bold"),
)
)
port_entry = tk.Entry(
self.bg_canvas,
textvariable=self.db_host_port,
bg="white",
fg="black",
insertbackground="black",
highlightbackground="#CCCCCC",
highlightthickness=1,
relief="flat",
font=("SF Pro Text", 11),
)
port_window = self.bg_canvas.create_window(
x_field, y - 12, window=port_entry, anchor="nw", width=100, height=32
)
self._canvas_items.append(port_window)
self._overlay_widgets.append(port_entry)
# Indicator for password match (X or ✓)
self.password_indicator = ui.canvas_text(
self,
x_field + 410,
y,
"",
fill="#dc3545",
font=("SF Pro Text", 16, "bold"),
state="hidden",
)
self._canvas_items.append(self.password_indicator)
# Database browser
table_y = y + 50
self._canvas_items.append(
ui.canvas_text(
self,
x_label,
table_y,
"Database Browser",
fill="#1d1d1f",
font=("SF Pro Text", 12, "bold"),
)
)
refresh_btn = tk.Button(
self.bg_canvas,
text="Refresh",
command=self._refresh_namespace_table,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 10),
padx=10,
pady=4,
)
refresh_window = self.bg_canvas.create_window(
x_label + 160, table_y - 8, window=refresh_btn, anchor="nw"
)
self._canvas_items.append(refresh_window)
self._overlay_widgets.append(refresh_btn)
add_btn = tk.Button(
self.bg_canvas,
text="Add",
command=self._db_add_namespace,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 10),
padx=10,
pady=4,
)
add_window = self.bg_canvas.create_window(
x_label + 240, table_y - 8, window=add_btn, anchor="nw"
)
self._canvas_items.append(add_window)
self._overlay_widgets.append(add_btn)
edit_btn = tk.Button(
self.bg_canvas,
text="Edit",
command=self._db_edit_namespace,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 10),
padx=10,
pady=4,
)
edit_window = self.bg_canvas.create_window(
x_label + 300, table_y - 8, window=edit_btn, anchor="nw"
)
self._canvas_items.append(edit_window)
self._overlay_widgets.append(edit_btn)
delete_btn = tk.Button(
self.bg_canvas,
text="Delete",
command=self._db_delete_namespace,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 10),
padx=10,
pady=4,
)
delete_window = self.bg_canvas.create_window(
x_label + 360, table_y - 8, window=delete_btn, anchor="nw"
)
self._canvas_items.append(delete_window)
self._overlay_widgets.append(delete_btn)
self._db_action_buttons = [refresh_btn, add_btn, edit_btn, delete_btn]
table_frame = tk.Frame(
self.bg_canvas,
bg="white",
highlightbackground="#E0E0E0",
highlightthickness=1,
)
table_window = self.bg_canvas.create_window(
x_label,
table_y + 30,
window=table_frame,
anchor="nw",
width=900,
height=180,
)
self._canvas_items.append(table_window)
self._overlay_widgets.append(table_frame)
columns = ("select", "name", "owner", "port", "created", "status")
tree = ttk.Treeview(table_frame, columns=columns, show="headings", height=5)
tree.heading("select", text="Select")
tree.heading("name", text="Name")
tree.heading("owner", text="Owner")
tree.heading("port", text="Host Port")
tree.heading("created", text="Creation Date")
tree.heading("status", text="Status")
tree.column("select", width=60, anchor="center")
tree.column("name", width=180, anchor="w")
tree.column("owner", width=140, anchor="w")
tree.column("port", width=80, anchor="center")
tree.column("created", width=200, anchor="w")
tree.column("status", width=120, anchor="center")
tree.pack(side="left", fill="both", expand=True)
scroll = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=scroll.set)
scroll.pack(side="right", fill="y")
self._overlay_widgets.append(tree)
self._overlay_widgets.append(scroll)
self._db_namespace_tree = tree
def on_select(event):
if getattr(self, "_refreshing_ns_table", False):
return
sel = tree.selection()
if sel:
vals = tree.item(sel[0], "values")
if vals:
ns = vals[1]
if ns == "supabase":
# Do not allow selecting 'supabase' as the primary prole-db namespace
return
if ns == self.db_namespace.get():
# Already selected, don't trigger re-refresh/save
return
self.db_namespace.set(ns)
self._sync_namespace_suffix_from_full()
# Mark as selected in tree visually if needed, but we use the checkbox column
self._refresh_namespace_table()
self._save_prole_cfg()
if self.supabase_enabled.get():
self._db_sync_supabase_ports(ns)
tree.bind("<<TreeviewSelect>>", on_select)
self._db_namespace_note = ui.canvas_text(
self, x_label, table_y + 220, "", fill="#6e6e73", font=("SF Pro Text", 10)
)
self._canvas_items.append(self._db_namespace_note)
def on_password_change(*args):
p = self.db_password.get()
c = self.db_password_confirm.get()
if not p:
self.bg_canvas.itemconfigure(self.password_indicator, state="hidden")
elif p == c:
self.bg_canvas.itemconfigure(
self.password_indicator, text="", fill="#28a745", state="normal"
)
else:
self.bg_canvas.itemconfigure(
self.password_indicator, text="", fill="#dc3545", state="normal"
)
self.db_password.trace_add("write", on_password_change)
self.db_password_confirm.trace_add("write", on_password_change)
# Add trace to host port to update mappings immediately
def on_port_change(*args):
try:
self._save_prole_cfg()
except:
pass
self.db_host_port.trace_add("write", on_port_change)
# Trigger once in case they are already set
on_password_change()
self._refresh_namespace_table()
def _namespace_prefix(self) -> str:
return NAMESPACE_PREFIX
def _max_namespace_suffix_len(self) -> int:
return max(0, POSTGRES_DB_NAME_MAX_LEN - len(self._namespace_prefix()))
def _strip_namespace_prefix(self, name: str) -> str:
prefix = self._namespace_prefix()
if name.startswith(prefix):
return name[len(prefix) :]
return name
def _ensure_namespace_prefix(self, name: str) -> str:
cleaned = (name or "").strip()
prefix = self._namespace_prefix()
if not cleaned:
return prefix
if cleaned.startswith(prefix):
return cleaned
return f"{prefix}{cleaned}"
def _sync_namespace_suffix_from_full(self):
if self._updating_namespace_fields:
return
self._updating_namespace_fields = True
try:
full = (self.db_namespace.get() or "").strip()
full = self._ensure_namespace_prefix(full)
if full != self.db_namespace.get():
self.db_namespace.set(full)
self.db_namespace_suffix.set(self._strip_namespace_prefix(full))
finally:
self._updating_namespace_fields = False
def _sync_namespace_full_from_suffix(self):
if self._updating_namespace_fields:
return
self._updating_namespace_fields = True
try:
suffix = (self.db_namespace_suffix.get() or "").strip()
self.db_namespace.set(f"{self._namespace_prefix()}{suffix}")
finally:
self._updating_namespace_fields = False
def _validate_namespace_suffix(self, proposed: str) -> bool:
return len(proposed) <= self._max_namespace_suffix_len()
def _collect_namespace_rows(self):
owner = self.namespace_owner or self._get_local_owner()
ns_names = []
notice = ""
kubectl_ok = False
try:
base_cmd = self._kubectl_base_cmd()
result = subprocess.run(
base_cmd + ["get", "ns", "-o", "json"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
kubectl_ok = True
data = json.loads(result.stdout or "{}")
for item in data.get("items", []):
name = item.get("metadata", {}).get("name")
if name:
ns_names.append(name)
else:
notice = (
result.stderr or ""
).strip() or "kubectl returned a non-zero status."
except FileNotFoundError:
notice = "kubectl not found; showing local defaults."
except Exception as e:
notice = f"Unable to query namespaces: {e}"
current_ns = (self.db_namespace.get() or "").strip()
prefix = self._namespace_prefix()
ns_names = [
name for name in ns_names if name.startswith(prefix) or name == "supabase"
]
if (
current_ns
and (current_ns.startswith(prefix) or current_ns == "supabase")
and current_ns not in ns_names
):
ns_names.append(current_ns)
if not ns_names:
if current_ns and (
current_ns.startswith(prefix) or current_ns == "supabase"
):
ns_names = [current_ns]
else:
ns_names = []
pods_by_ns = {}
if kubectl_ok:
try:
base_cmd = self._kubectl_base_cmd()
pods_result = subprocess.run(
base_cmd + ["get", "pods", "--all-namespaces", "-o", "json"],
capture_output=True,
text=True,
timeout=8,
)
if pods_result.returncode == 0:
pods_data = json.loads(pods_result.stdout or "{}")
for pod in pods_data.get("items", []):
meta = pod.get("metadata", {})
status = pod.get("status", {})
ns = meta.get("namespace")
if not ns:
continue
entry = pods_by_ns.setdefault(
ns, {"starts": [], "running": False}
)
start_time = status.get("startTime")
if start_time:
entry["starts"].append(start_time)
if status.get("phase") == "Running":
entry["running"] = True
else:
notice = notice or (pods_result.stderr or "").strip() or notice
except Exception:
# Best effort only
pass
rows = []
# Filter out any non-string items (like MagicMocks in tests)
string_ns_names = [_safe_str(name) for name in ns_names if _safe_str(name)]
for ns in sorted(set(string_ns_names)):
entry = pods_by_ns.get(ns, {"starts": [], "running": False})
oldest = None
if entry["starts"]:
parsed = [self._parse_rfc3339(ts) for ts in entry["starts"]]
parsed = [p for p in parsed if p is not None]
if parsed:
oldest_dt = min(parsed)
oldest = oldest_dt.isoformat()
else:
oldest = min(entry["starts"])
creation = self._format_pod_time(oldest) if oldest else ""
status = "active" if entry["running"] else "inactive"
# Best effort to find port
port = ""
if ns == "supabase":
port = "5432"
else:
# For prole-db namespaces
if ns == (self.db_namespace.get() or "").strip():
if self.supabase_enabled.get():
port = "15432"
else:
port = self.db_host_port.get() or "5432"
else:
# If it's another prole-db namespace, we might not know its port easily
# but if Supabase is enabled globally, we assume standard alternate port
if self.supabase_enabled.get():
port = "15432"
else:
port = "5432"
rows.append((ns, owner, port, creation, status))
return rows, notice
def _refresh_namespace_table(self):
if getattr(self, "_refreshing_ns_table", False):
return
self._refreshing_ns_table = True
def worker():
try:
rows, notice = self._collect_namespace_rows()
current_ns = (self.db_namespace.get() or "").strip()
def update_ui():
tree = getattr(self, "_db_namespace_tree", None)
if not tree:
return
for item in tree.get_children():
tree.delete(item)
for row in rows:
# Add checkbox-like symbol for selection
ns_name = row[0]
prefix = " [✓] " if ns_name == current_ns else " [ ] "
display_row = (prefix,) + row
tree.insert("", "end", values=display_row)
if current_ns:
for item in tree.get_children():
vals = tree.item(item, "values")
if vals and vals[1] == current_ns:
tree.selection_set(item)
tree.see(item)
break
note_item = getattr(self, "_db_namespace_note", None)
if note_item and self.bg_canvas.winfo_exists():
msg = notice if notice else ""
self.bg_canvas.itemconfig(note_item, text=msg)
self.safe_after(update_ui)
finally:
self._refreshing_ns_table = False
threading.Thread(target=worker, daemon=True).start()
def _db_set_status(self, message: str, color: str = "#6e6e73"):
note_item = getattr(self, "_db_namespace_note", None)
if note_item and self.bg_canvas.winfo_exists():
self.bg_canvas.itemconfig(note_item, text=message or "", fill=color)
def _db_set_buttons_state(self, state: str):
buttons = getattr(self, "_db_action_buttons", None)
if not buttons:
return
for btn in buttons:
try:
if btn.winfo_exists():
btn.configure(state=state)
except Exception:
pass
def _db_selected_namespace(self) -> str:
tree = getattr(self, "_db_namespace_tree", None)
if not tree:
return ""
sel = tree.selection()
if not sel:
return ""
vals = tree.item(sel[0], "values")
if not vals:
return ""
ns = vals[1]
if ns == "supabase":
return ""
return ns
def _db_sync_supabase_ports(self, namespace):
if not self.supabase_enabled.get():
return
script = PROJECT_ROOT / "etc" / "init_supabase_ports.sh"
if not script.exists():
return
def worker():
try:
env = os.environ.copy()
env["PROLE_HOME"] = str(PROJECT_ROOT)
# Best effort: use kubectl context if selected
if hasattr(self, "selected_kubectx") and self.selected_kubectx.get():
env["KUBECONFIG"] = env.get(
"KUBECONFIG", ""
) # Ensure it's passed if set
subprocess.run(
["bash", str(script), "-n", namespace],
cwd=str(PROJECT_ROOT),
env=env,
capture_output=True,
text=True,
)
except:
pass
threading.Thread(target=worker, daemon=True).start()
def _db_action_log_path(self) -> Path:
logs_dir = PROJECT_ROOT / "logs"
try:
logs_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
return logs_dir / "db-actions.log"
def _db_log(self, text: str):
try:
with self._db_action_log_path().open("a", encoding="utf-8") as fp:
fp.write(text)
if not text.endswith("\n"):
fp.write("\n")
except Exception:
pass
def _db_add_namespace(self):
ns = (self.db_namespace.get() or "").strip()
if not ns:
messagebox.showerror("Database Name", "Database namespace cannot be empty.")
return
if not self._is_valid_namespace(ns):
messagebox.showerror(
"Database Name",
'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.',
)
return
self.db_namespace.set(ns)
def worker():
self.safe_after(lambda: self._db_set_buttons_state("disabled"))
self.safe_after(
lambda: self._db_set_status(f"Creating namespace {ns}...", "#1d1d1f")
)
cmd = self._kubectl_base_cmd() + ["create", "namespace", ns]
rc, out = self._run_cmd_capture(cmd)
if rc != 0 and "AlreadyExists" not in out:
self._db_log(out)
self.safe_after(self._refresh_namespace_table)
self.safe_after(
lambda: self._db_set_status(
f"Failed to create namespace {ns}. See logs/db-actions.log.",
"#ff3b30",
)
)
else:
self._update_env_namespace(ns)
self.safe_after(self._refresh_namespace_table)
self.safe_after(
lambda: self._db_set_status(f"Namespace {ns} is ready.", "#34c759")
)
self.safe_after(lambda: self._db_set_buttons_state("normal"))
threading.Thread(target=worker, daemon=True).start()
def _db_delete_namespace(self):
ns = self._db_selected_namespace() or (self.db_namespace.get() or "").strip()
if not ns:
messagebox.showerror(
"Delete Database", "Select a database namespace to delete."
)
return
if not messagebox.askyesno(
"Delete Database", f'Delete namespace "{ns}"? This cannot be undone.'
):
return
def worker():
self.safe_after(lambda: self._db_set_buttons_state("disabled"))
self.safe_after(
lambda: self._db_set_status(f"Deleting namespace {ns}...", "#1d1d1f")
)
cmd = self._kubectl_base_cmd() + ["delete", "namespace", ns]
rc, out = self._run_cmd_capture(cmd)
if rc != 0:
self._db_log(out)
self.safe_after(self._refresh_namespace_table)
self.safe_after(
lambda: self._db_set_status(
f"Failed to delete namespace {ns}. See logs/db-actions.log.",
"#ff3b30",
)
)
else:
self.safe_after(self._refresh_namespace_table)
self.safe_after(
lambda: self._db_set_status(f"Namespace {ns} deleted.", "#34c759")
)
self.safe_after(lambda: self._db_set_buttons_state("normal"))
threading.Thread(target=worker, daemon=True).start()
def _db_edit_namespace(self):
ns = self._db_selected_namespace() or (self.db_namespace.get() or "").strip()
if not ns:
messagebox.showerror(
"Edit Database", "Select a database namespace to edit."
)
return
self.db_namespace.set(ns)
if not self._is_valid_namespace(ns):
messagebox.showerror(
"Database Name",
'Namespace must be lowercase alphanumeric or "-", start/end with a letter or number, and be 63 characters or less.',
)
return
p1 = self.db_password.get()
p2 = self.db_password_confirm.get()
if not p1:
messagebox.showerror("Password", "Password cannot be empty.")
return
if p1 != p2:
messagebox.showerror("Password", "Passwords do not match.")
return
def worker():
self.safe_after(lambda: self._db_set_buttons_state("disabled"))
self.safe_after(
lambda: self._db_set_status(
f"Recreating SSH key for {ns}...", "#1d1d1f"
)
)
# We prefer ed25519, but fallback to rsa if not available
key_path = Path.home() / ".ssh" / "id_prole_ed25519"
pub_path = Path.home() / ".ssh" / "id_prole_ed25519.pub"
try:
if key_path.exists():
key_path.unlink()
if pub_path.exists():
pub_path.unlink()
except Exception:
pass
cmd = [
"ssh-keygen",
"-t",
"ed25519",
"-N",
"",
"-f",
str(key_path),
"-C",
self.db_username.get().strip(),
]
rc, out = self._run_cmd_capture(cmd)
if rc != 0:
self._db_log(f"ed25519 generation failed, falling back to rsa: {out}")
cmd = [
"ssh-keygen",
"-t",
"rsa",
"-b",
"4096",
"-N",
"",
"-f",
str(key_path),
"-C",
self.db_username.get().strip(),
]
rc, out = self._run_cmd_capture(cmd)
self._db_log(out)
if rc != 0:
self.safe_after(
lambda: self._db_set_status(
"Failed to recreate SSH key. See logs/db-actions.log.",
"#ff3b30",
)
)
self.safe_after(lambda: self._db_set_buttons_state("normal"))
return
self.safe_after(
lambda: self._db_set_status("Updating OpenBao...", "#1d1d1f")
)
env = self._script_env_for_namespace(ns)
env["AT_REST_ENCRYPTION_ENABLED"] = _bool_str(
self.at_rest_encryption_enabled.get()
)
script_path = str(PROJECT_ROOT / "etc" / "init_openbao.sh")
mode = _deployment_mode_from_env(self.cluster_env.get())
rc2, out2 = self._run_cmd_capture(
[
"bash",
script_path,
"initialize",
"--mode",
mode,
"--namespace",
ns,
"--config",
str(self.controller.cfg_path),
],
env=env,
stdin_text=f"{p1}\n",
)
self._db_log(out2)
if rc2 != 0:
self.safe_after(self._refresh_namespace_table)
self.safe_after(
lambda: self._db_set_status(
"OpenBao update failed. See logs/db-actions.log.", "#ff3b30"
)
)
else:
self._update_env_namespace(ns)
self.safe_after(self._refresh_namespace_table)
self.safe_after(
lambda: self._db_set_status(
f"Updated SSH key and OpenBao for {ns}.", "#34c759"
)
)
self.safe_after(lambda: self._db_set_buttons_state("normal"))
threading.Thread(target=worker, daemon=True).start()
def _render_init_db_build_page(self):
# Letterhead at top right
content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
ui.canvas_text(
self,
right_margin,
40,
"Prole",
fill="#6e6e73",
font=("SF Pro Text", 32, "bold"),
anchor="ne",
)
ui.canvas_text(
self,
right_margin,
85,
"Infrastructure Automated.",
fill="#6e6e73",
font=("SF Pro Text", 18),
anchor="ne",
)
self._render_title("Build Database Image", y=150)
self._render_paragraph(
"Building the prole-db Postgres image. This may take a few minutes.", y=200
)
# Local registry status (checked async)
self._db_registry_status_label = ui.canvas_text(
self,
48,
230,
"Local Registry: Checking...",
fill="#6e6e73",
font=("SF Pro Text", 11),
)
self._canvas_items.append(self._db_registry_status_label)
# Output Console
self._db_build_console = self._create_console_output(
y=260, title="Build Output", width=900, height=520
)
# Use tk.Button
self._db_build_button = tk.Button(
self.bg_canvas,
text="Start Build",
command=self.run_db_build,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 11),
padx=16,
pady=8,
)
btn_window = self.bg_canvas.create_window(
48, 800, window=self._db_build_button, anchor="nw", width=180
)
self._canvas_items.append(btn_window)
self._overlay_widgets.append(self._db_build_button)
# Status Label
self._db_build_status_label = ui.canvas_text(
self, 240, 812, "", fill="black", font=("SF Pro Text", 12)
)
self._canvas_items.append(self._db_build_status_label)
# Ensure registry check runs when entering the screen
self._ensure_db_build_registry_async()
def _ensure_db_build_registry_async(self):
def worker():
try:
if not self.check_docker_running():
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text="Local Registry: Docker not running",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
else None
)
)
return
info = self.ensure_local_registry_available()
if info:
host_registry, _cluster_registry = info
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text=f"Local Registry: {host_registry}",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()
else None
)
)
else:
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_registry_status_label,
text="Local Registry: unavailable",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
else None
)
)
log_path = getattr(self, "_last_registry_log_path", None)
if log_path and self._db_build_console:
self.safe_after(
lambda p=log_path: self._db_build_console.write(
f"Registry log: {p}\n"
)
)
except Exception as e:
msg = f"Local Registry: error ({e})"
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_registry_status_label, text=msg, fill="#ff3b30"
)
if self.bg_canvas.winfo_exists()
else None
)
)
log_path = getattr(self, "_last_registry_log_path", None)
if log_path and self._db_build_console:
self.safe_after(
lambda p=log_path: self._db_build_console.write(
f"Registry log: {p}\n"
)
)
threading.Thread(target=worker, daemon=True).start()
def run_db_build(self):
self._action_flags["init_db_build.run_build"] = True
def worker():
self.safe_after(
lambda: (
self._db_build_button.configure(state="disabled")
if self._db_build_button.winfo_exists()
else None
)
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label, text="Building...", fill="blue"
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
tag = self.get_prole_db_version()
image_name = f"prole-db:{tag}"
# Use $HOME/.prole/build for Docker build context
# This avoids issues with PyInstaller's temporary _MEIPASS directory
prole_home = Path.home() / ".prole"
build_dir = prole_home / "build" / "prole-db"
build_dir.mkdir(parents=True, exist_ok=True)
# Copy prole-db directory to writable location
source_dir = get_resource_path("prole-db")
if source_dir.exists():
import shutil
# Remove old build dir and copy fresh
if source_dir.resolve() != build_dir.resolve():
if build_dir.exists():
shutil.rmtree(build_dir)
shutil.copytree(source_dir, build_dir)
cwd = build_dir
# Fetch the generated public key
pub_key = ""
pub_key_path = Path.home() / ".ssh" / "id_prole_ed25519.pub"
if pub_key_path.exists():
pub_key = pub_key_path.read_text().strip()
username = self.db_username.get()
env_key = self._cluster_env_key()
cmd = ["docker", "build", "--progress=plain"]
cmd.extend(get_docker_build_platform_args(env_key))
cmd += [
"--build-arg",
f"PROLE_USER={username}",
"--build-arg",
f"PROLE_SSH_PUB_KEY={pub_key}",
"-t",
image_name,
".",
]
self._db_build_console.clear()
self._db_build_console.write(f"Building {image_name} in {cwd}...\n\n")
build_env = os.environ.copy()
build_env["PYTHONUNBUFFERED"] = "1"
proc = subprocess.Popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=build_env,
)
while True:
line = proc.stdout.readline()
if not line and proc.poll() is not None:
break
if line:
self._db_build_console.write(line)
if proc.returncode == 0:
self._db_build_console.write("\nBuild successful!\n")
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build successful! Tagging...",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
self._db_built_success = True
if env_key == "dev":
# Tag + push to local registry for k3d pulls
registry_info = self.ensure_local_registry_available()
import_tag = image_name
if registry_info:
host_registry, cluster_registry = registry_info
remote_tag = f"{host_registry}/prole-db:{tag}"
self._db_build_console.write(
f"Tagging image for registry: {remote_tag}\n"
)
tag_res = subprocess.run(
["docker", "tag", image_name, remote_tag],
capture_output=True,
text=True,
)
if tag_res.returncode != 0:
self._db_build_console.write(tag_res.stdout or "")
self._db_build_console.write(tag_res.stderr or "")
self._db_build_console.write(
"Tag failed; skipping push/import.\n"
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build ok, tag failed.",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label
in self.bg_canvas.find_all()
else None
)
)
self.safe_after(
lambda: (
self._db_build_button.configure(state="normal")
if self._db_build_button.winfo_exists()
else None
)
)
return
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Pushing to registry...",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label
in self.bg_canvas.find_all()
else None
)
)
if not _push_docker_image(
remote_tag, log_fn=self._db_build_console.write
):
self._db_build_console.write(
"Push failed; skipping import.\n"
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build ok, push failed.",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label
in self.bg_canvas.find_all()
else None
)
)
self.safe_after(
lambda: (
self._db_build_button.configure(state="normal")
if self._db_build_button.winfo_exists()
else None
)
)
return
self._db_build_console.write(f"Push complete: {remote_tag}\n")
if cluster_registry:
import_tag = f"{cluster_registry}/prole-db:{tag}"
# k3d registry is often reachable internally without the .localhost suffix
if cluster_registry.endswith(".localhost:5000"):
alt_registry = cluster_registry.replace(
".localhost", ""
)
alt_tag = f"{alt_registry}/prole-db:{tag}"
if alt_tag != import_tag:
self._db_build_console.write(
f"Tagging image for k3d import: {alt_tag}\n"
)
subprocess.run(
["docker", "tag", image_name, alt_tag],
capture_output=True,
text=True,
)
import_tag = alt_tag
else:
self._db_build_console.write(
"Local registry unavailable; skipping tag/push.\n"
)
# Import to k3d
cluster_name = "knoe-dev-cluster"
self._db_build_console.write(
f"Importing image to {cluster_name} ({import_tag})...\n"
)
subprocess.run(
["k3d", "image", "import", import_tag, "-c", cluster_name]
)
self._db_build_console.write("Import complete.\n")
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build, tag, push, and import complete.",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
else:
self._db_build_console.write(
"Import skipped for non-dev clusters.\n"
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text="Build complete. Import skipped.",
fill="#34c759",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
else:
self._db_build_console.write(
f"\nBuild failed with code {proc.returncode}\n"
)
self.safe_after(
lambda: (
self.bg_canvas.itemconfig(
self._db_build_status_label,
text=f"Build failed (code {proc.returncode})",
fill="#ff3b30",
)
if self.bg_canvas.winfo_exists()
and self._db_build_status_label in self.bg_canvas.find_all()
else None
)
)
self.safe_after(
lambda: (
self._db_build_button.configure(state="normal")
if self._db_build_button.winfo_exists()
else None
)
)
threading.Thread(target=worker, daemon=True).start()