mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 16:24:32 +00:00
- separate default app and db cluster contexts (knoe-dev-0 and knoe-cnpg-0) across actions, ops flows, and installer config\n- update CNPG placement and GKE manifests to keep PostgreSQL on the db cluster while app services stay on the app cluster\n- add GCS-backed Barman ObjectStore manifest and cross-cluster Garage patch workflow\n- refresh UI cluster/database/service screens and monitoring wiring for dual-cluster operation\n- add reset/patch scripts, db context selection test coverage, and architecture/network documentation updates Co-authored-by: Junie <junie@jetbrains.com>
2188 lines
85 KiB
Python
2188 lines
85 KiB
Python
"""Database creation, namespace management and DB build screens."""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import threading
|
|
from pathlib import Path
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, simpledialog
|
|
from knoe import screen as ui
|
|
from knoe.config import get_docker_build_platform_args, _parse_gcp_cfg
|
|
from knoe.core.env import (
|
|
PROJECT_ROOT,
|
|
_bool_str,
|
|
_deployment_mode_from_env,
|
|
_http_ping_registry,
|
|
_safe_str,
|
|
_push_docker_image,
|
|
get_resource_path,
|
|
resolve_prole_home,
|
|
)
|
|
from knoe.core.ops import openbao as openbao_ops
|
|
from knoe.core.ops import registry as registry_ops
|
|
from knoe.core.build_context import copy_build_context_dir
|
|
|
|
|
|
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,
|
|
"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("Database Creation", y=150)
|
|
self._render_paragraph(
|
|
"Configure the CloudNativePG target for this deployment. Choose the database namespace where CNPG resources will be deployed and the CNPG Cluster resource name. 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 operations should target the DB cluster context by default.
|
|
self._ensure_db_screen_context()
|
|
|
|
# Database namespace
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
x_label,
|
|
y,
|
|
"DATABASE_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)
|
|
|
|
# Placeholder 'db' label
|
|
placeholder_label = tk.Label(
|
|
ns_frame,
|
|
text="db",
|
|
fg="#CCCCCC",
|
|
bg="white",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
|
|
def update_placeholder(*_):
|
|
if self.db_namespace.get():
|
|
placeholder_label.place_forget()
|
|
else:
|
|
placeholder_label.place(x=8, y=5)
|
|
|
|
self.db_namespace.trace_add("write", update_placeholder)
|
|
|
|
vcmd = (self.root.register(self._validate_namespace), "%P")
|
|
namespace_entry = tk.Entry(
|
|
ns_frame,
|
|
textvariable=self.db_namespace,
|
|
bg="white",
|
|
fg="black",
|
|
insertbackground="black",
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
font=("SF Pro Text", 11),
|
|
validate="key",
|
|
validatecommand=vcmd,
|
|
)
|
|
namespace_entry.pack(side="left", fill="both", expand=True, padx=(8, 8))
|
|
update_placeholder()
|
|
|
|
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(namespace_entry)
|
|
|
|
y += 42
|
|
|
|
# CNPG cluster name
|
|
self._canvas_items.append(
|
|
ui.canvas_text(
|
|
self,
|
|
x_label,
|
|
y,
|
|
"CLUSTER_NAME:",
|
|
fill="black",
|
|
font=("SF Pro Text", 12, "bold"),
|
|
)
|
|
)
|
|
|
|
cluster_frame = tk.Frame(
|
|
self.bg_canvas,
|
|
bg="white",
|
|
highlightbackground="#CCCCCC",
|
|
highlightthickness=1,
|
|
bd=0,
|
|
)
|
|
cluster_frame.pack_propagate(False)
|
|
|
|
cluster_placeholder_label = tk.Label(
|
|
cluster_frame,
|
|
text="knoe-db",
|
|
fg="#CCCCCC",
|
|
bg="white",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
|
|
def update_cluster_placeholder(*_):
|
|
try:
|
|
cluster_value = self.cnpg_cluster_name.get()
|
|
except Exception:
|
|
cluster_value = ""
|
|
if cluster_value:
|
|
cluster_placeholder_label.place_forget()
|
|
else:
|
|
cluster_placeholder_label.place(x=8, y=5)
|
|
|
|
self.cnpg_cluster_name.trace_add("write", update_cluster_placeholder)
|
|
|
|
cluster_entry = tk.Entry(
|
|
cluster_frame,
|
|
textvariable=self.cnpg_cluster_name,
|
|
bg="white",
|
|
fg="black",
|
|
insertbackground="black",
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
font=("SF Pro Text", 11),
|
|
validate="key",
|
|
validatecommand=vcmd,
|
|
)
|
|
cluster_entry.pack(side="left", fill="both", expand=True, padx=(8, 8))
|
|
update_cluster_placeholder()
|
|
|
|
cluster_window = self.bg_canvas.create_window(
|
|
x_field, y - 12, window=cluster_frame, anchor="nw", width=400, height=32
|
|
)
|
|
self._canvas_items.append(cluster_window)
|
|
self._overlay_widgets.append(cluster_frame)
|
|
self._overlay_widgets.append(cluster_entry)
|
|
|
|
y += 42
|
|
|
|
# Owner (local user)
|
|
if not self.db_username.get():
|
|
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 knoe-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 _sync_namespace_suffix_from_full(self):
|
|
try:
|
|
full = (self.db_namespace.get() or "").strip()
|
|
except Exception:
|
|
return
|
|
|
|
suffix_var = getattr(self, "db_namespace_suffix", None)
|
|
if suffix_var is None or not hasattr(suffix_var, "set"):
|
|
return
|
|
|
|
try:
|
|
if full.startswith("knoe-"):
|
|
suffix_var.set(full[len("knoe-") :])
|
|
else:
|
|
suffix_var.set(full)
|
|
except Exception:
|
|
pass
|
|
|
|
def _validate_namespace(self, proposed: str) -> bool:
|
|
if not proposed:
|
|
return True
|
|
# Allow only valid k8s characters while typing
|
|
return re.match(r"^[a-z0-9-]*$", proposed) is not None
|
|
|
|
def _resolve_db_kube_context(self) -> str:
|
|
cfg = getattr(self, "prole_cfg_data", {}) or {}
|
|
glob = cfg.get("Global", {}) or {}
|
|
gcp = cfg.get("GCP", {}) or {}
|
|
|
|
for value in (
|
|
self._get_input("init_cluster.db_cluster_kubecontext", ""),
|
|
self._get_input("env_setup.DB_CLUSTER_KUBECONTEXT", ""),
|
|
glob.get("DB_CLUSTER_KUBECONTEXT", ""),
|
|
):
|
|
ctx = _safe_str(value)
|
|
if ctx:
|
|
return ctx
|
|
|
|
cluster_name = (
|
|
self._get_input("init_password.db_cluster_name", "")
|
|
or self._get_input("env_setup.DB_CLUSTER_NAME", "")
|
|
or glob.get("DB_CLUSTER_NAME", "")
|
|
or glob.get("CNPG_CLUSTER_NAME", "")
|
|
or "knoe-cnpg-0"
|
|
)
|
|
cluster_name = _safe_str(cluster_name)
|
|
|
|
selected_ctx = _safe_str(
|
|
self._get_input("init_cluster.selected_kubectx", "")
|
|
or glob.get("KUBECONTEXT", "")
|
|
)
|
|
|
|
project_id = _safe_str(gcp.get("project_id") or gcp.get("PROJECT_ID") or "")
|
|
region = _safe_str(
|
|
self._get_input("init_cluster.db_cluster_region", "")
|
|
or gcp.get("region")
|
|
or gcp.get("REGION")
|
|
or gcp.get("location")
|
|
or ""
|
|
)
|
|
|
|
if selected_ctx.startswith("gke_"):
|
|
parts = selected_ctx.split("_", 3)
|
|
if len(parts) == 4:
|
|
if not project_id:
|
|
project_id = _safe_str(parts[1])
|
|
if not region:
|
|
region = _safe_str(parts[2])
|
|
|
|
if project_id and region and cluster_name:
|
|
return f"gke_{project_id}_{region}_{cluster_name}"
|
|
|
|
if selected_ctx.startswith("gke_") and cluster_name:
|
|
parts = selected_ctx.split("_", 3)
|
|
if len(parts) == 4:
|
|
return f"{parts[0]}_{parts[1]}_{parts[2]}_{cluster_name}"
|
|
|
|
return selected_ctx
|
|
|
|
def _db_kubectl_base_cmd(self) -> list[str]:
|
|
mode = self._deployment_mode()
|
|
base_cmd = self._kubectl_base_cmd(mode=mode)
|
|
if mode != "k8s":
|
|
return base_cmd
|
|
|
|
target_ctx = self._resolve_db_kube_context()
|
|
if not target_ctx:
|
|
return base_cmd
|
|
|
|
cmd = list(base_cmd)
|
|
if "--context" in cmd:
|
|
try:
|
|
idx = cmd.index("--context")
|
|
if idx + 1 < len(cmd):
|
|
cmd[idx + 1] = target_ctx
|
|
return cmd
|
|
except Exception:
|
|
pass
|
|
|
|
cmd.extend(["--context", target_ctx])
|
|
return cmd
|
|
|
|
def _ensure_db_screen_context(self):
|
|
target_ctx = self._resolve_db_kube_context()
|
|
if not target_ctx:
|
|
return
|
|
|
|
selected_var = getattr(self, "selected_kubectx", None)
|
|
if not selected_var or not hasattr(selected_var, "get") or not hasattr(selected_var, "set"):
|
|
return
|
|
|
|
current_ctx = _safe_str(selected_var.get())
|
|
app_default_ctx = _safe_str((getattr(self, "prole_cfg_data", {}) or {}).get("Global", {}).get("KUBECONTEXT", ""))
|
|
|
|
# Keep explicit user context overrides; auto-switch only from blank/default app context.
|
|
if current_ctx and current_ctx != app_default_ctx:
|
|
return
|
|
|
|
if current_ctx != target_ctx:
|
|
selected_var.set(target_ctx)
|
|
try:
|
|
inputs = getattr(self, "inputs", None)
|
|
if isinstance(inputs, dict):
|
|
inputs["init_cluster.selected_kubectx"] = target_ctx
|
|
except Exception:
|
|
pass
|
|
|
|
def _db_target_note(self, namespace: str, notice: str = "") -> str:
|
|
ctx = self._resolve_db_kube_context() or "(default kubectl context)"
|
|
ns = _safe_str(namespace) or "(not set)"
|
|
base = f"Target context: {ctx} | Target namespace: {ns}"
|
|
return f"{base} | {notice}" if notice else base
|
|
|
|
def _collect_namespace_rows(
|
|
self,
|
|
*,
|
|
current_ns: str,
|
|
supabase_enabled: bool,
|
|
db_host_port: str,
|
|
):
|
|
owner = self.namespace_owner or self._get_local_owner()
|
|
ns_names = []
|
|
notice = ""
|
|
kubectl_ok = False
|
|
|
|
try:
|
|
base_cmd = self._db_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}"
|
|
|
|
# Filter: names containing 'db' or 'supabase'
|
|
ns_names = [
|
|
name for name in ns_names if "db" in name.lower() or name == "supabase"
|
|
]
|
|
if (
|
|
current_ns
|
|
and ("db" in current_ns.lower() or current_ns == "supabase")
|
|
and current_ns not in ns_names
|
|
):
|
|
ns_names.append(current_ns)
|
|
if not ns_names:
|
|
if current_ns and (
|
|
"db" in current_ns.lower() or current_ns == "supabase"
|
|
):
|
|
ns_names = [current_ns]
|
|
else:
|
|
ns_names = []
|
|
|
|
pods_by_ns = {}
|
|
if kubectl_ok:
|
|
try:
|
|
base_cmd = self._db_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 knoe-db namespaces
|
|
if ns == current_ns:
|
|
if supabase_enabled:
|
|
port = "15432"
|
|
else:
|
|
port = db_host_port or "5432"
|
|
else:
|
|
# If it's another knoe-db namespace, we might not know its port easily
|
|
# but if Supabase is enabled globally, we assume standard alternate port
|
|
if supabase_enabled:
|
|
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
|
|
|
|
# Snapshot any Tk-backed values on the main thread. The worker thread
|
|
# must never touch Tk objects.
|
|
current_ns = (self.db_namespace.get() or "").strip()
|
|
try:
|
|
supabase_enabled = bool(int(self.supabase_enabled.get()))
|
|
except Exception:
|
|
supabase_enabled = bool(self.supabase_enabled.get())
|
|
db_host_port = _safe_str(self.db_host_port.get())
|
|
|
|
def worker():
|
|
try:
|
|
rows, notice = self._collect_namespace_rows(
|
|
current_ns=current_ns,
|
|
supabase_enabled=supabase_enabled,
|
|
db_host_port=db_host_port,
|
|
)
|
|
|
|
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 = self._db_target_note(current_ns, notice)
|
|
self.bg_canvas.itemconfig(note_item, text=msg, fill="#6e6e73")
|
|
|
|
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():
|
|
namespace_var = getattr(self, "db_namespace", None)
|
|
ns = _safe_str(namespace_var.get()) if hasattr(namespace_var, "get") else ""
|
|
self.bg_canvas.itemconfig(
|
|
note_item,
|
|
text=self._db_target_note(ns, 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._db_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._db_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()
|
|
)
|
|
mode = _deployment_mode_from_env(self.cluster_env.get())
|
|
try:
|
|
openbao_ops.initialize(
|
|
namespace=ns,
|
|
env=env,
|
|
project_root=getattr(self.controller, "project_root", PROJECT_ROOT),
|
|
mode=mode,
|
|
log=lambda msg: self._db_log(
|
|
msg if msg.endswith("\n") else msg + "\n"
|
|
),
|
|
)
|
|
rc2 = 0
|
|
except Exception as _bao_exc:
|
|
rc2 = 1
|
|
self._db_log(f"OpenBao update failed: {_bao_exc}\n")
|
|
|
|
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,
|
|
"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("Build Database Image", y=150)
|
|
self._render_paragraph(
|
|
"Building the knoe-db Postgres image. This may take a few minutes.", y=200
|
|
)
|
|
|
|
# Registry status (checked async)
|
|
self._db_registry_status_label = ui.canvas_text(
|
|
self,
|
|
48,
|
|
230,
|
|
"Registry: Checking...",
|
|
fill="#6e6e73",
|
|
font=("SF Pro Text", 11),
|
|
)
|
|
self._canvas_items.append(self._db_registry_status_label)
|
|
|
|
# k3s registry initialization (shown only when needed)
|
|
self._db_init_registry_button = tk.Button(
|
|
self.bg_canvas,
|
|
text="Initialize Registry",
|
|
command=self._db_init_registry,
|
|
bg="#F5F5DC",
|
|
fg="black",
|
|
activebackground="#E5E5D5",
|
|
highlightbackground="#F5F5DC",
|
|
highlightthickness=0,
|
|
relief="flat",
|
|
font=("SF Pro Text", 11),
|
|
padx=16,
|
|
pady=6,
|
|
)
|
|
init_btn_window = self.bg_canvas.create_window(
|
|
48, 224, window=self._db_init_registry_button, anchor="nw", width=200
|
|
)
|
|
self._db_init_registry_button_canvas_window = init_btn_window
|
|
self._canvas_items.append(init_btn_window)
|
|
self._overlay_widgets.append(self._db_init_registry_button)
|
|
try:
|
|
self.bg_canvas.itemconfigure(init_btn_window, state="hidden")
|
|
except Exception:
|
|
pass
|
|
|
|
# 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 _load_gcp_cfg_into_prole_data(self):
|
|
"""Load conf/prod/gcp.cfg into prole_cfg_data["GCP"] for any keys not already set."""
|
|
import glob as _glob
|
|
gcp = self.prole_cfg_data.setdefault("GCP", {})
|
|
# Normalize any existing uppercase keys to lowercase (configparser preserves case
|
|
# when optionxform=str, so PROJECT_ID stays uppercase — normalize it here so all
|
|
# downstream lookups using gcp.get("project_id") work correctly).
|
|
for k in list(gcp.keys()):
|
|
low = k.lower()
|
|
if low != k and low not in gcp:
|
|
gcp[low] = gcp[k]
|
|
# Only load from gcp.cfg if project_id is still missing
|
|
if gcp.get("project_id", "").strip().strip('"'):
|
|
return
|
|
try:
|
|
conf_dir = Path(PROJECT_ROOT) / "conf"
|
|
found = sorted(_glob.glob(str(conf_dir / "**" / "gcp.cfg"), recursive=True))
|
|
prod_path = conf_dir / "prod" / "gcp.cfg"
|
|
chosen = str(prod_path) if str(prod_path) in found else (found[0] if found else None)
|
|
if not chosen:
|
|
return
|
|
loaded = _parse_gcp_cfg(chosen)
|
|
upper = {k.upper(): v for k, v in loaded.items()}
|
|
for key, val in upper.items():
|
|
low = key.lower()
|
|
if not gcp.get(low, "").strip():
|
|
gcp[low] = val
|
|
except Exception:
|
|
pass
|
|
|
|
def _ensure_db_build_registry_async(self):
|
|
def worker():
|
|
try:
|
|
env_key = "dev"
|
|
try:
|
|
env_key = self._cluster_env_key()
|
|
except Exception:
|
|
env_key = "dev"
|
|
|
|
# For named environments (prod → k8s, service → k3s) the env_key is the
|
|
# authoritative source of truth and must not be overridden by a stale
|
|
# DEPLOYMENT_MODE stored in prole_cfg_data (e.g. "k3d" left over from a
|
|
# previous dev session while the user has switched to prod).
|
|
# For the dev env, DEPLOYMENT_MODE may legitimately be set to "k3s" to
|
|
# test service-mode workloads, so we preserve that override there.
|
|
_env_based_mode = _deployment_mode_from_env(env_key)
|
|
if _env_based_mode in ("k8s", "k3s"):
|
|
mode = _env_based_mode
|
|
else:
|
|
mode = _deployment_mode_from_env(
|
|
((self.prole_cfg_data.get("Global", {}) or {}).get("DEPLOYMENT_MODE") or "").strip()
|
|
or env_key
|
|
)
|
|
|
|
def _show_init_button(show: bool):
|
|
try:
|
|
if not hasattr(self, "_db_init_registry_button_canvas_window"):
|
|
return
|
|
state = "normal" if show else "hidden"
|
|
self.bg_canvas.itemconfigure(
|
|
self._db_init_registry_button_canvas_window, state=state
|
|
)
|
|
except Exception:
|
|
return
|
|
|
|
# k3s: never warn about localhost registry. Instead offer init + show reachable status.
|
|
if mode == "k3s":
|
|
registry_url = ""
|
|
try:
|
|
registry_url = (self.ensure_registry_available(env_key) or "").strip()
|
|
except Exception:
|
|
registry_url = ""
|
|
|
|
def _is_unusable_hostport(value: str) -> bool:
|
|
raw = (value or "").strip()
|
|
if not raw or ":" not in raw:
|
|
return True
|
|
host, _port = raw.rsplit(":", 1)
|
|
host = (host or "").strip()
|
|
# Bind addresses are not usable endpoints and often display as confusing "000".
|
|
if host in ("0.0.0.0", "::", "127.0.0.1", "localhost"):
|
|
return True
|
|
return False
|
|
|
|
usable_registry_url = (
|
|
"" if _is_unusable_hostport(registry_url) else registry_url
|
|
)
|
|
|
|
registry_ns = "knoe-system"
|
|
try:
|
|
registry_ns = (self._registry_namespace() or registry_ns).strip()
|
|
except Exception:
|
|
registry_ns = "knoe-system"
|
|
cluster_registry = f"registry.{registry_ns}.svc.cluster.local:5000"
|
|
|
|
deployed_ready = False
|
|
try:
|
|
base_cmd = self._kubectl_base_cmd()
|
|
result = subprocess.run(
|
|
base_cmd
|
|
+ [
|
|
"get",
|
|
"deploy",
|
|
"registry",
|
|
"-n",
|
|
registry_ns,
|
|
"-o",
|
|
"json",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
data = json.loads(result.stdout or "{}")
|
|
status = data.get("status", {}) or {}
|
|
ready = int(status.get("readyReplicas") or 0)
|
|
available = int(status.get("availableReplicas") or 0)
|
|
deployed_ready = ready > 0 or available > 0
|
|
except Exception:
|
|
deployed_ready = False
|
|
|
|
reachable = False
|
|
if usable_registry_url and ":" in usable_registry_url:
|
|
host, port_s = usable_registry_url.rsplit(":", 1)
|
|
try:
|
|
reachable = _http_ping_registry(
|
|
host.strip(), int(port_s.strip())
|
|
)
|
|
except Exception:
|
|
reachable = False
|
|
|
|
# Prefer Kubernetes readiness for the initialization signal in k3s.
|
|
if deployed_ready:
|
|
if usable_registry_url and reachable:
|
|
msg = f"Registry: {usable_registry_url}"
|
|
color = "#34c759"
|
|
elif usable_registry_url:
|
|
msg = f"Registry: {usable_registry_url} (deployed)"
|
|
color = "#ff9500"
|
|
else:
|
|
msg = f"Registry: {cluster_registry} (deployed)"
|
|
color = "#ff9500"
|
|
|
|
self.safe_after(
|
|
lambda: (
|
|
_show_init_button(False),
|
|
self.bg_canvas.itemconfig(
|
|
self._db_registry_status_label,
|
|
text=msg,
|
|
fill=color,
|
|
),
|
|
)
|
|
)
|
|
return
|
|
|
|
# Not deployed -> prompt init action
|
|
msg = (
|
|
f"Registry: {usable_registry_url} (not initialized)"
|
|
if usable_registry_url
|
|
else "Registry: not initialized"
|
|
)
|
|
self.safe_after(
|
|
lambda: (
|
|
_show_init_button(True),
|
|
self.bg_canvas.itemconfig(
|
|
self._db_registry_status_label,
|
|
text=msg,
|
|
fill="#ff9500",
|
|
),
|
|
)
|
|
)
|
|
return
|
|
|
|
# k8s (GKE/prod): use GCP Artifact Registry — no local Docker registry needed
|
|
if mode == "k8s":
|
|
self._load_gcp_cfg_into_prole_data()
|
|
gcp = self.prole_cfg_data.get("GCP", {}) or {}
|
|
project_id = (gcp.get("project_id") or "").strip().strip('"')
|
|
# Extract region from KUBECONTEXT: gke_<project>_<region>_<cluster>
|
|
kubecontext = (
|
|
(self.prole_cfg_data.get("Global", {}) or {}).get("KUBECONTEXT") or ""
|
|
).strip()
|
|
region = ""
|
|
if kubecontext.startswith("gke_"):
|
|
parts = kubecontext.split("_", 3)
|
|
if len(parts) >= 3:
|
|
region = parts[2]
|
|
if project_id and region:
|
|
ar_repo = (
|
|
(self.prole_cfg_data.get("Global", {}) or {})
|
|
.get("SERVICE_NAMESPACE", "knoe-system")
|
|
.strip() or "knoe-system"
|
|
)
|
|
registry_url = f"{region}-docker.pkg.dev/{project_id}/{ar_repo}"
|
|
# Use the availability flag persisted by the Cluster Environment
|
|
# screen (_check_artifact_registry_async). If not yet set, assume
|
|
# available so we don't flash red on first open.
|
|
ar_available = (
|
|
(self.prole_cfg_data.get("Global", {}) or {})
|
|
.get("ARTIFACT_REGISTRY_AVAILABLE", "true")
|
|
.strip()
|
|
.lower()
|
|
)
|
|
if ar_available == "false":
|
|
msg = f"Registry: {registry_url} (unavailable)"
|
|
color = "#ff3b30"
|
|
else:
|
|
msg = f"Registry: {registry_url}"
|
|
color = "#34c759"
|
|
elif project_id:
|
|
registry_url = f"gcr.io/{project_id}"
|
|
msg = f"Registry: {registry_url}"
|
|
color = "#ff9500"
|
|
else:
|
|
msg = "Registry: GCP Artifact Registry (project not set)"
|
|
color = "#ff9500"
|
|
self.safe_after(
|
|
lambda m=msg, c=color: (
|
|
_show_init_button(False),
|
|
self.bg_canvas.itemconfig(
|
|
self._db_registry_status_label,
|
|
text=m,
|
|
fill=c,
|
|
),
|
|
)
|
|
)
|
|
return
|
|
# Non-k3s: keep local registry checks (requires Docker)
|
|
if not self.check_docker_running():
|
|
self.safe_after(
|
|
lambda: (
|
|
_show_init_button(False),
|
|
self.bg_canvas.itemconfig(
|
|
self._db_registry_status_label,
|
|
text="Registry: Docker not running",
|
|
fill="#ff3b30",
|
|
),
|
|
)
|
|
)
|
|
return
|
|
|
|
info = self.ensure_local_registry_available()
|
|
if info:
|
|
host_registry, _cluster_registry = info
|
|
self.safe_after(
|
|
lambda: (
|
|
_show_init_button(False),
|
|
self.bg_canvas.itemconfig(
|
|
self._db_registry_status_label,
|
|
text=f"Registry: {host_registry}",
|
|
fill="#34c759",
|
|
),
|
|
)
|
|
)
|
|
else:
|
|
self.safe_after(
|
|
lambda: (
|
|
_show_init_button(False),
|
|
self.bg_canvas.itemconfig(
|
|
self._db_registry_status_label,
|
|
text="Registry: unavailable",
|
|
fill="#ff3b30",
|
|
),
|
|
)
|
|
)
|
|
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"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 _db_rescan_registry_status_after_init(self):
|
|
"""Re-scan registry availability after init.
|
|
|
|
Registry pods/services may take a few seconds to become reachable after
|
|
`etc/init_registry.sh` finishes. Schedule a few checks so the UI status
|
|
flips to green once ready.
|
|
"""
|
|
|
|
try:
|
|
if getattr(self, "_db_registry_status_label", None) is not None:
|
|
self.safe_after(
|
|
lambda: self.bg_canvas.itemconfig(
|
|
self._db_registry_status_label,
|
|
text="Registry: Checking...",
|
|
fill="#6e6e73",
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Progressive delays to allow the in-cluster registry to become ready.
|
|
for d in (0, 1000, 2000, 4000, 8000, 15000):
|
|
self.safe_after(lambda: self._ensure_db_build_registry_async(), delay=d)
|
|
|
|
def _db_init_registry(self):
|
|
"""Initialize the k3s in-cluster registry (registry:2) via etc/init_registry.sh."""
|
|
|
|
def _resolve_argocd_namespace() -> str:
|
|
ns = (
|
|
(self.prole_cfg_data.get("Global", {}) or {})
|
|
.get("ARGOCD_NAMESPACE", "")
|
|
.strip()
|
|
)
|
|
if not ns:
|
|
ns = (os.environ.get("ARGOCD_NAMESPACE") or "").strip()
|
|
return ns or "argocd"
|
|
|
|
def _resolve_service_namespace_interactive() -> str:
|
|
# Prefer explicit UI value if available
|
|
ns = ""
|
|
try:
|
|
ns = (self.service_namespace.get() or "").strip()
|
|
except Exception:
|
|
ns = ""
|
|
if not ns:
|
|
ns = (
|
|
(self.prole_cfg_data.get("Global", {}) or {})
|
|
.get("SERVICE_NAMESPACE", "")
|
|
.strip()
|
|
)
|
|
if not ns:
|
|
ns = (os.environ.get("SERVICE_NAMESPACE") or "").strip()
|
|
|
|
if ns:
|
|
return ns
|
|
|
|
# UI is interactive: ask the user.
|
|
try:
|
|
ns2 = simpledialog.askstring(
|
|
"Service Namespace",
|
|
"Enter the service namespace for the k3s registry:",
|
|
parent=self.root,
|
|
)
|
|
except Exception:
|
|
ns2 = None
|
|
return (ns2 or "").strip()
|
|
|
|
def worker():
|
|
env_key = "service"
|
|
try:
|
|
env_key = self._cluster_env_key()
|
|
except Exception:
|
|
env_key = "service"
|
|
|
|
mode = _deployment_mode_from_env(env_key)
|
|
if mode != "k3s":
|
|
try:
|
|
messagebox.showinfo(
|
|
"Registry",
|
|
"Registry initialization is only required for k3s (Service) mode.",
|
|
)
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
service_ns = _resolve_service_namespace_interactive()
|
|
if not service_ns:
|
|
# In UI mode we must not guess; prompt is available above.
|
|
# Only fall back to Kubernetes 'default' when no input is available.
|
|
try:
|
|
if getattr(self, "root", None) is not None:
|
|
self.safe_after(
|
|
lambda: messagebox.showerror(
|
|
"Registry",
|
|
"Service namespace is required to initialize the registry.",
|
|
)
|
|
)
|
|
return
|
|
except Exception:
|
|
pass
|
|
service_ns = "default"
|
|
|
|
argocd_ns = _resolve_argocd_namespace()
|
|
|
|
self.safe_after(
|
|
lambda: (
|
|
self._db_init_registry_button.configure(state="disabled")
|
|
if self._db_init_registry_button.winfo_exists()
|
|
else None
|
|
)
|
|
)
|
|
if self._db_build_console:
|
|
self.safe_after(
|
|
lambda: self._db_build_console.write(
|
|
f"Running registry init in namespace {service_ns} (mode=k3s)\n\n"
|
|
)
|
|
)
|
|
|
|
try:
|
|
env_run = os.environ.copy()
|
|
env_run["PROLE_MODE"] = "k3s"
|
|
env_run["REGISTRY_NAMESPACE"] = service_ns
|
|
env_run["ARGOCD_NAMESPACE"] = argocd_ns
|
|
registry_ops.update(
|
|
namespace=service_ns,
|
|
env=env_run,
|
|
project_root=getattr(self.controller, "project_root", PROJECT_ROOT),
|
|
mode="k3s",
|
|
log=lambda msg: self._db_build_console.write(
|
|
msg if msg.endswith("\n") else msg + "\n"
|
|
)
|
|
if self._db_build_console
|
|
else None,
|
|
)
|
|
rc = 0
|
|
except Exception as e:
|
|
rc = 1
|
|
if self._db_build_console:
|
|
self._db_build_console.write(f"Registry init failed: {e}\n")
|
|
|
|
if self._db_build_console:
|
|
self._db_build_console.write(
|
|
f"\nRegistry init {'completed' if rc == 0 else 'failed'} (exit {rc}).\n"
|
|
)
|
|
|
|
self.safe_after(
|
|
lambda: (
|
|
self._db_init_registry_button.configure(state="normal")
|
|
if self._db_init_registry_button.winfo_exists()
|
|
else None
|
|
)
|
|
)
|
|
if rc == 0:
|
|
self._db_rescan_registry_status_after_init()
|
|
else:
|
|
self._ensure_db_build_registry_async()
|
|
|
|
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_knoe_db_version()
|
|
image_name = f"knoe-db:{tag}"
|
|
|
|
# Use $PROLE_HOME/build for Docker build context
|
|
# This avoids issues with PyInstaller's temporary _MEIPASS directory
|
|
prole_home = resolve_prole_home()
|
|
env_key = self._cluster_env_key()
|
|
mode_key = _deployment_mode_from_env(env_key) or "default"
|
|
build_dir = prole_home / "build" / mode_key / "knoe-db"
|
|
build_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Copy DB image build directory to writable location, unless a generated
|
|
# build context was already prepared by the Database Options screen.
|
|
marker = build_dir / ".prole_build_context_ready"
|
|
if not marker.exists():
|
|
source_dir = get_resource_path("knoe-db")
|
|
if not source_dir.exists():
|
|
source_dir = get_resource_path("knoe-db")
|
|
copy_build_context_dir(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()
|
|
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}/knoe-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}/knoe-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}/knoe-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
|
|
)
|
|
)
|
|
elif mode_key == "k8s":
|
|
# GKE/prod: push to GCP Artifact Registry
|
|
self._load_gcp_cfg_into_prole_data()
|
|
gcp_section = self.prole_cfg_data.get("GCP", {})
|
|
project_id = gcp_section.get("project_id", "").strip()
|
|
kubecontext = (
|
|
self.prole_cfg_data.get("Global", {}).get("KUBECONTEXT", "")
|
|
or os.environ.get("KUBECONTEXT", "")
|
|
).strip()
|
|
# parse region from gke_<project>_<region>_<cluster>
|
|
region = ""
|
|
parts = kubecontext.split("_")
|
|
if len(parts) >= 3:
|
|
region = parts[2]
|
|
if not project_id:
|
|
self._db_build_console.write(
|
|
"GCP project_id not set. Configure it on the Cluster Environment screen.\n"
|
|
)
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._db_build_status_label,
|
|
text="Build ok, GCP project not configured.",
|
|
fill="#ff3b30",
|
|
)
|
|
if self.bg_canvas.winfo_exists()
|
|
and self._db_build_status_label in self.bg_canvas.find_all()
|
|
else None
|
|
)
|
|
)
|
|
return
|
|
ar_repo = (
|
|
self.prole_cfg_data.get("Global", {})
|
|
.get("SERVICE_NAMESPACE", "knoe-system")
|
|
.strip() or "knoe-system"
|
|
)
|
|
if region:
|
|
ar_host = f"{region}-docker.pkg.dev"
|
|
remote_tag = f"{ar_host}/{project_id}/{ar_repo}/knoe-db:{tag}"
|
|
# configure docker credential helper for this AR host
|
|
self._db_build_console.write(
|
|
f"Configuring docker for Artifact Registry ({ar_host})...\n"
|
|
)
|
|
subprocess.run(
|
|
["gcloud", "auth", "configure-docker", ar_host, "--quiet"],
|
|
capture_output=True,
|
|
)
|
|
# ensure the repository exists in Artifact Registry
|
|
self._db_build_console.write(
|
|
f"Ensuring Artifact Registry repository '{ar_repo}' exists...\n"
|
|
)
|
|
repo_check = subprocess.run(
|
|
[
|
|
"gcloud", "artifacts", "repositories", "describe", ar_repo,
|
|
"--project", project_id,
|
|
"--location", region,
|
|
"--format", "value(name)",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if repo_check.returncode != 0:
|
|
self._db_build_console.write(
|
|
f"Repository '{ar_repo}' not found — creating it...\n"
|
|
)
|
|
create_res = subprocess.run(
|
|
[
|
|
"gcloud", "artifacts", "repositories", "create", ar_repo,
|
|
"--repository-format=docker",
|
|
"--project", project_id,
|
|
"--location", region,
|
|
f"--description={ar_repo} docker images",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if create_res.returncode != 0:
|
|
self._db_build_console.write(
|
|
f"[WARN] Could not create repository: {create_res.stderr.strip()}\n"
|
|
)
|
|
else:
|
|
self._db_build_console.write(
|
|
f"Repository '{ar_repo}' created.\n"
|
|
)
|
|
else:
|
|
self._db_build_console.write(
|
|
f"Repository '{ar_repo}' already exists.\n"
|
|
)
|
|
else:
|
|
remote_tag = f"gcr.io/{project_id}/knoe-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; cannot push.\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
|
|
)
|
|
)
|
|
return
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._db_build_status_label,
|
|
text="Pushing to Artifact Registry...",
|
|
fill="blue",
|
|
)
|
|
if self.bg_canvas.winfo_exists()
|
|
and self._db_build_status_label in self.bg_canvas.find_all()
|
|
else None
|
|
)
|
|
)
|
|
self._db_build_console.write(
|
|
f"Pushing image {remote_tag} ...\n"
|
|
)
|
|
if not _push_docker_image(
|
|
remote_tag, log_fn=self._db_build_console.write
|
|
):
|
|
self._db_build_console.write(
|
|
"Push failed. Ensure gcloud auth is valid and Artifact Registry API is enabled.\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
|
|
)
|
|
)
|
|
return
|
|
self._db_build_console.write(f"Push complete: {remote_tag}\n")
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._db_build_status_label,
|
|
text="Build, tag and push complete.",
|
|
fill="#34c759",
|
|
)
|
|
if self.bg_canvas.winfo_exists()
|
|
and self._db_build_status_label in self.bg_canvas.find_all()
|
|
else None
|
|
)
|
|
)
|
|
else:
|
|
# Service/prod clusters must pull from a registry; do not skip push.
|
|
registry_url = ""
|
|
try:
|
|
registry_url = (self.ensure_registry_available(env_key) or "").strip()
|
|
except Exception:
|
|
registry_url = ""
|
|
if not registry_url:
|
|
self._db_build_console.write(
|
|
"No registry configured/resolved for this environment. Initialize the registry first.\n"
|
|
)
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._db_build_status_label,
|
|
text="Build ok, registry not configured.",
|
|
fill="#ff3b30",
|
|
)
|
|
if self.bg_canvas.winfo_exists()
|
|
and self._db_build_status_label in self.bg_canvas.find_all()
|
|
else None
|
|
)
|
|
)
|
|
return
|
|
|
|
remote_tag = f"{registry_url}/knoe-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; cannot push.\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
|
|
)
|
|
)
|
|
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. Ensure the registry is initialized and reachable.\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
|
|
)
|
|
)
|
|
return
|
|
|
|
self._db_build_console.write(f"Push complete: {remote_tag}\n")
|
|
self.safe_after(
|
|
lambda: (
|
|
self.bg_canvas.itemconfig(
|
|
self._db_build_status_label,
|
|
text="Build, tag and push 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(
|
|
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()
|