prole/knoe/ui/ncurses_ui.py
chrisfu 3312c39b1f feat: GCP/GKE CNPG hardening, Artifact Registry traffic light, and knoe-system namespace fixes
UI screens
- database.py: fix mode detection to use env_key priority (prod→k8s, service→k3s) so stale DEPLOYMENT_MODE never overrides the user's chosen environment
- database.py: Registry status reads ARTIFACT_REGISTRY_AVAILABLE persisted by cluster screen; uses SERVICE_NAMESPACE for Artifact Registry repo name
- cluster.py: add Artifact Registry traffic light (amber→green/red) to prod section; _check_artifact_registry_async persists ARTIFACT_REGISTRY_AVAILABLE into Global cfg
- cluster.py: re-trigger Artifact Registry check after GKE cluster selection so the light re-evaluates once region is available from KUBECONTEXT
- cluster_nodes.py: fix TclError on Python 3.14 — pady=(2,0) tuple → pady=2 scalar
- __init__.py: seed knoe-system namespace when saved value is "default", not only when empty
- services.py: replace hardcoded "Prole DB" log string with dynamic cnpg_cluster name

Core ops
- cloudnative_pg.py: replace one-shot Barman plugin retry with 6-attempt loop; first cert-manager/x509 failure triggers rollout restart + 30 s CA propagation wait; subsequent failures back off up to 60 s per attempt
- cloudnative_pg.py: TLS CA CN now uses cluster_name instead of hardcoded "Prole CNPG CA"
- registry.py, garage_store.py: refactored into per-mode modules (k3d/k3s/k8s registry and garage store, shared _garage_common)

Deploy / config
- deploy/gcp/gke/knoe-db.yaml: GKE-specific CNPG cluster manifest (rw/ro/r on separate nodes with premium-rwo storage)
- etc/init_common_services.sh, modes/k8s/knoe-db/.version: updated for current deploy
- kong-deployment.yaml: updated manifest

Tests
- test_cluster_nodes_render_smoke.py: add pack/grid, winfo_children, winfo_reqheight, update_idletasks, grid_slaves to dummy widgets; monkeypatch tk.Label so CNPG placement render completes without a real Tkinter root

Co-authored-by: Junie <junie@jetbrains.com>
2026-04-04 19:36:08 -07:00

410 lines
15 KiB
Python

"""Ncurses UI adapter for the installer runner.
Provides two concrete classes:
NcursesUI — minimal skeleton (original interface, unchanged)
CursesInstallerUI — full two-panel TUI with number-selection navigation
Layout
------
┌──────────────────────────────────────────────────────────────────────────┐
│ [ k3d ] [ k3s ] [ k8s ] mode strip (line 0) │
├──────────────────┬───────────────────────────────────────────────────────┤
│ [1] Welcome │ Screen title / content / log scroll │
│ [2] Dependencies│ │
│ ... │ Tab / Shift-Tab — cycle focusable fields │
│ [18] Post Install│ ↑ / ↓ — scroll log │
│ │ q / Esc — return focus to left panel │
├──────────────────┴───────────────────────────────────────────────────────┤
│ [q] quit [m] cycle mode [↑↓] navigate [Enter] select [Tab] fields │
└──────────────────────────────────────────────────────────────────────────┘
Number shortcuts: type a digit (or two digits within 1 s) to jump to that
nav item; press Enter to confirm.
Press 'm' at any time to cycle PROLE_MODE k3d → k3s → k8s → k3d.
"""
from __future__ import annotations
import curses
import os
import time
from collections import deque
from typing import Deque
from ..runner import InstallerRunner
from ..state import InstallerState
from ..milestone import Milestone
# ---------------------------------------------------------------------------
# Minimal skeleton (original public interface — unchanged)
# ---------------------------------------------------------------------------
class NcursesUI:
"""Minimal ncurses adapter that listens to runner events."""
def __init__(self, runner: InstallerRunner):
self.runner = runner
self._bind_runner()
def _bind_runner(self) -> None:
self.runner.on("on_enter_milestone", self.on_enter_milestone)
self.runner.on("on_validation_error", self.on_validation_error)
self.runner.on("on_progress", self.on_progress)
self.runner.on("on_complete", self.on_complete)
# Event handlers (override in concrete UI)
def on_enter_milestone(self, milestone: Milestone, state: InstallerState) -> None:
pass
def on_validation_error(
self, milestone: Milestone | None, state: InstallerState, errors: list[str]
) -> None:
pass
def on_progress(
self,
milestone: Milestone,
state: InstallerState,
message: str,
percent: float | None,
) -> None:
pass
def on_complete(self, milestone: Milestone | None, state: InstallerState) -> None:
pass
def start(self, start_id: str | None = None) -> bool:
"""Run the installer without a GUI main loop."""
return self.runner.run(start_id=start_id)
# ---------------------------------------------------------------------------
# Full two-panel TUI
# ---------------------------------------------------------------------------
_NAV_ITEMS = [
("Welcome", "welcome"),
("Dependencies", "deps_summary"),
("Network", "network_scan"),
("System Environment", "env_setup"),
("Cluster Environment", "init_cluster"),
("Cluster Nodes", "cluster_nodes"),
("Common Services", "common_services"),
("Database Options", "database_options"),
("Docker Build", "init_db_build"),
("Database Creation", "init_password"),
("Initialization Scripts", "init_scripts"),
("Knoe Authority", "kerberos_config"),
("Knoe Users", "knoe_users"),
("ArgoCD", "argocd_config"),
("GitOps", "gitops_config"),
("Supabase", "supabase_config"),
("Deployment", "init_cnpg_deploy"),
("Post Install", "create_installer"),
]
_MODES = ["k3d", "k3s", "k8s"]
_MODE_PAIRS = list(zip(_MODES, _MODES)) # (label, value)
def _left_panel_width() -> int:
longest = max(len(label) for label, _ in _NAV_ITEMS)
return max(24, longest + 8)
def _current_mode() -> str:
return os.environ.get("PROLE_MODE", "k3s")
def _set_mode(mode: str) -> None:
os.environ["PROLE_MODE"] = mode
def _cycle_mode() -> str:
current = _current_mode()
idx = _MODES.index(current) if current in _MODES else 0
new_mode = _MODES[(idx + 1) % len(_MODES)]
_set_mode(new_mode)
return new_mode
class CursesInstallerUI(NcursesUI):
"""Full two-panel ncurses TUI with number-selection navigation."""
# colour pair indices
_CP_NORMAL = 1
_CP_HIGHLIGHT = 2
_CP_HEADER = 3
_CP_MODE_ACTIVE = 4
_CP_MODE_INACTIVE = 5
_CP_STATUS = 6
_CP_LOG = 7
def __init__(self, runner: InstallerRunner):
super().__init__(runner)
self._log_lines: Deque[str] = deque(maxlen=500)
self._scroll_offset: int = 0
self._selected_index: int = 0 # highlighted in left panel
self._active_index: int = 0 # confirmed (right panel loaded)
self._right_focus: bool = False # True when Tab/arrow are for right panel
self._digit_buf: str = ""
self._digit_time: float = 0.0
self._running: bool = False
# ------------------------------------------------------------------
# Runner event handlers
# ------------------------------------------------------------------
def on_enter_milestone(self, milestone: Milestone, state: InstallerState) -> None:
self._log_lines.clear()
self._scroll_offset = 0
self._log(f"── {milestone.title} ──")
def on_progress(
self,
milestone: Milestone,
state: InstallerState,
message: str,
percent: float | None,
) -> None:
prefix = f"[{percent:5.1f}%] " if percent is not None else " "
self._log(f"{prefix}{message}")
def on_validation_error(
self, milestone: Milestone | None, state: InstallerState, errors: list[str]
) -> None:
for err in errors:
self._log(f"[ERROR] {err}")
def on_complete(self, milestone: Milestone | None, state: InstallerState) -> None:
self._log("── Complete ──")
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _log(self, msg: str) -> None:
for line in msg.splitlines() or [""]:
self._log_lines.append(line)
# ------------------------------------------------------------------
# curses entry point
# ------------------------------------------------------------------
def start(self, start_id: str | None = None) -> bool: # type: ignore[override]
return curses.wrapper(self._main, start_id)
def _main(self, stdscr: "curses.window", start_id: str | None) -> bool: # type: ignore[name-defined]
self._init_colors()
curses.curs_set(0)
stdscr.nodelay(True)
stdscr.keypad(True)
self._running = True
result: bool = False
while self._running:
rows, cols = stdscr.getmaxyx()
lw = min(_left_panel_width(), cols // 3)
rw = max(1, cols - lw - 1)
stdscr.erase()
self._draw_mode_strip(stdscr, cols)
self._draw_left(stdscr, lw, rows)
self._draw_divider(stdscr, lw, rows)
self._draw_right(stdscr, lw + 1, rw, rows)
self._draw_status_bar(stdscr, rows, cols)
stdscr.refresh()
key = stdscr.getch()
if key == -1:
time.sleep(0.05)
continue
result = self._handle_key(key, start_id)
if not self._running:
break
return result
# ------------------------------------------------------------------
# Colour initialisation
# ------------------------------------------------------------------
def _init_colors(self) -> None:
curses.start_color()
curses.use_default_colors()
curses.init_pair(self._CP_NORMAL, curses.COLOR_WHITE, -1)
curses.init_pair(self._CP_HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(self._CP_HEADER, curses.COLOR_CYAN, -1)
curses.init_pair(self._CP_MODE_ACTIVE, curses.COLOR_BLACK, curses.COLOR_GREEN)
curses.init_pair(self._CP_MODE_INACTIVE, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(self._CP_STATUS, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(self._CP_LOG, curses.COLOR_GREEN, -1)
# ------------------------------------------------------------------
# Drawing
# ------------------------------------------------------------------
def _draw_mode_strip(self, stdscr: "curses.window", cols: int) -> None:
active = _current_mode()
x = 1
try:
stdscr.addstr(0, 0, " " * (cols - 1), curses.color_pair(self._CP_NORMAL))
except curses.error:
pass
for mode in _MODES:
label = f" {mode} "
if active == mode:
attr = curses.color_pair(self._CP_MODE_ACTIVE) | curses.A_BOLD
else:
attr = curses.color_pair(self._CP_MODE_INACTIVE)
try:
stdscr.addstr(0, x, label, attr)
except curses.error:
pass
x += len(label) + 1
def _draw_left(self, stdscr: "curses.window", lw: int, rows: int) -> None:
# header
header = " INSTALLER"[:lw]
try:
stdscr.addstr(1, 0, header.ljust(lw), curses.color_pair(self._CP_HEADER) | curses.A_BOLD)
except curses.error:
pass
nav_rows = rows - 3 # row 0 = mode strip, row 1 = header, last = status
for i, (label, _) in enumerate(_NAV_ITEMS):
row = 2 + i
if row >= rows - 1:
break
num = i + 1
text = f" [{num:>2}] {label}"[:lw].ljust(lw)
if i == self._selected_index:
attr = curses.color_pair(self._CP_HIGHLIGHT) | curses.A_BOLD
elif i == self._active_index:
attr = curses.color_pair(self._CP_NORMAL) | curses.A_UNDERLINE
else:
attr = curses.color_pair(self._CP_NORMAL)
try:
stdscr.addstr(row, 0, text, attr)
except curses.error:
pass
def _draw_divider(self, stdscr: "curses.window", lw: int, rows: int) -> None:
for row in range(1, rows - 1):
try:
stdscr.addch(row, lw, curses.ACS_VLINE)
except curses.error:
pass
def _draw_right(self, stdscr: "curses.window", x0: int, rw: int, rows: int) -> None:
label, page_id = _NAV_ITEMS[self._active_index]
title = f" {label} ({page_id})"[:rw]
try:
stdscr.addstr(1, x0, title.ljust(rw), curses.color_pair(self._CP_HEADER) | curses.A_BOLD)
except curses.error:
pass
log_rows = rows - 3
visible = list(self._log_lines)
total = len(visible)
start = max(0, total - log_rows - self._scroll_offset)
end = max(0, total - self._scroll_offset)
for i, line in enumerate(visible[start:end]):
row = 2 + i
if row >= rows - 1:
break
try:
stdscr.addstr(row, x0, line[:rw].ljust(rw), curses.color_pair(self._CP_LOG))
except curses.error:
pass
def _draw_status_bar(self, stdscr: "curses.window", rows: int, cols: int) -> None:
hint = "[q] quit [m] cycle mode [↑↓] navigate [Enter] select [Tab] right panel"
try:
stdscr.addstr(rows - 1, 0, hint[:cols - 1].ljust(cols - 1),
curses.color_pair(self._CP_STATUS))
except curses.error:
pass
# ------------------------------------------------------------------
# Key handling
# ------------------------------------------------------------------
def _handle_key(self, key: int, start_id: str | None) -> bool:
# Quit
if key in (ord("q"), ord("Q"), 27): # 27 = Esc
self._running = False
return False
# Cycle mode
if key == ord("m"):
_cycle_mode()
return False
# Digit accumulation for number jump
if ord("0") <= key <= ord("9"):
now = time.monotonic()
if self._digit_buf and (now - self._digit_time) > 1.0:
self._digit_buf = ""
self._digit_buf += chr(key)
self._digit_time = now
# If we have 2 digits, resolve immediately
if len(self._digit_buf) == 2:
self._resolve_digit_buf()
return False
# Enter confirms digit buf or selected item
if key in (curses.KEY_ENTER, 10, 13):
if self._digit_buf:
self._resolve_digit_buf()
else:
self._active_index = self._selected_index
self._right_focus = True
self._scroll_offset = 0
return False
# Arrow navigation
if key == curses.KEY_UP:
if self._right_focus:
self._scroll_offset = min(self._scroll_offset + 1, max(0, len(self._log_lines) - 1))
else:
self._selected_index = max(0, self._selected_index - 1)
return False
if key == curses.KEY_DOWN:
if self._right_focus:
self._scroll_offset = max(0, self._scroll_offset - 1)
else:
self._selected_index = min(len(_NAV_ITEMS) - 1, self._selected_index + 1)
return False
# Tab — switch panel focus
if key == ord("\t"):
self._right_focus = True
self._active_index = self._selected_index
return False
# Shift-Tab (key code 353 in most terminals) or backtick — back to left
if key in (curses.KEY_BTAB, 353):
self._right_focus = False
return False
return False
def _resolve_digit_buf(self) -> None:
try:
n = int(self._digit_buf)
if 1 <= n <= len(_NAV_ITEMS):
self._selected_index = n - 1
self._active_index = n - 1
self._right_focus = True
self._scroll_offset = 0
except ValueError:
pass
finally:
self._digit_buf = ""