mirror of
https://github.com/dredx/prole.git
synced 2026-09-24 17:34:31 +00:00
814 lines
40 KiB
Python
814 lines
40 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,
|
|
_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)
|
|
|
|
# Optional Features moved above browser
|
|
y += 50
|
|
self._canvas_items.append(ui.canvas_text(self, x_label, y, 'Optional Features:', fill='black', font=('SF Pro Text', 13, 'bold')))
|
|
|
|
feature_options = [
|
|
('Enable Supabase', self.supabase_enabled),
|
|
('Enable Kerberos Authentication', self.kerberos_enabled),
|
|
('Enable At-Rest Encryption', self.at_rest_encryption_enabled),
|
|
]
|
|
|
|
fx = x_label + 20
|
|
fy = y + 36
|
|
for label, var in feature_options:
|
|
cb = tk.Checkbutton(self.bg_canvas, text=label, variable=var,
|
|
bg='white', fg='black', activebackground='white',
|
|
selectcolor='white', font=('SF Pro Text', 11),
|
|
command=self._save_prole_cfg)
|
|
cb_window = self.bg_canvas.create_window(fx, fy, window=cb, anchor='nw')
|
|
self._canvas_items.append(cb_window)
|
|
self._overlay_widgets.append(cb)
|
|
fx += 280
|
|
|
|
# Database browser
|
|
table_y = fy + 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 = []
|
|
for ns in sorted(set(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")
|
|
rc2, out2 = self._run_cmd_capture(['bash', script_path, 'initialize'], 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 = "prole-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()
|