mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 10:13:58 +00:00
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>
1347 lines
49 KiB
Python
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()
|