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

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

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

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

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

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

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

982 lines
36 KiB
Python

"""
Ncurses-based Prole Installer.
Provides a terminal UI alternative to the Tk GUI in install.py.
Maintains the same screen order and operations.
"""
from __future__ import annotations
import curses
import getpass
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path
from . import config as inst_config
from .ncurses_ui import CursesWindow, NavFooter, InputField
from .core.stream_exec import run_streaming_cmd
from .core.env import (
_resolve_k3s_connection,
_verify_k3s_services_status,
_find_kubeconfig_file,
_read_k3s_cfg,
)
from .config import _write_k3s_kubeconfig, _merge_kubeconfig
class ProleNcursesInstaller:
"""Ncurses-based installer matching install.py screen flow."""
def __init__(self, stdscr, controller):
self.stdscr = stdscr
self.controller = controller
self.project_root = controller.project_root
# Screen state
self.page_index = 0
self.pages = [] # List of (page_id, render_func) tuples
self.running = True
self.status_message = ""
# Data storage (same as GUI version)
self.dependencies = inst_config.load_dependencies()
self.verify_mode = False
self.deploy_environment = None
self.selected_disk_type = None
self.selected_removable_disk = None
self.selected_local_path = None
# Environment/config variables
self.cluster_env = "k3d-knoe-dev-cluster"
self.cluster_environments = [
"knoe-dev-cluster",
"prole-service-cluster",
"prole-prod-cluster",
]
self.selected_env_index = 0
self.selected_dep_index = 0
self.kubectx_list = self._get_kubectx_list()
self.selected_kubectx_index = 0
self.k3s_services_status = {
"registry": "Unknown",
"openbao": "Unknown",
"opentofu": "Unknown",
}
# Defer creation until windows are initialized
self.prod_artifacts_path = None
# Connection details for remote k3s — resolved from env vars,
# prole.cfg *and* ansible topology so all UIs agree.
self.k3s_server, self.k3s_token = _resolve_k3s_connection(
project_root=self.project_root,
)
try:
self.namespace_owner = getpass.getuser()
except Exception:
self.namespace_owner = "prole"
self.db_username = self.namespace_owner
self.db_namespace = os.environ.get("NAMESPACE", "default")
self.db_password = ""
self.db_password_confirm = ""
# Kerberos config
self.kerberos_enabled = False
self.kerberos_realm = ""
self.kerberos_user = ""
self.kerberos_password = ""
self.kerberos_kdc = ""
# Validation state
self.validation_running = False
self.validation_results = {}
# Navigation focus
self.focus = "sidebar" # Can be "sidebar" or "main"
# Setup curses
curses.curs_set(0) # Hide cursor
curses.use_default_colors()
self._setup_colors()
# Get screen dimensions
self.height, self.width = stdscr.getmaxyx()
# Create windows
self._create_windows()
# Initialize fields that depend on windows
self.prod_artifacts_path = InputField(self.main_content_win, 15, 4, 50)
self.prod_artifacts_path.set_value(str(self.project_root / "data" / "staging"))
# Register all screens in order
self._register_screens()
# Setup footer
self.footer = NavFooter(self.footer_win, 3, self.width, 0)
self._update_footer()
def _setup_colors(self):
"""Initialize color pairs."""
if curses.has_colors():
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(2, curses.COLOR_GREEN, -1)
curses.init_pair(3, curses.COLOR_RED, -1)
curses.init_pair(4, curses.COLOR_YELLOW, -1)
curses.init_pair(5, curses.COLOR_CYAN, -1)
def _create_windows(self):
"""Create ncurses windows for layout."""
# Header (3 lines)
self.header_win = curses.newwin(3, self.width, 0, 0)
# Main content area (full height minus header and footer)
content_height = self.height - 6
self.content_win = curses.newwin(content_height, self.width, 3, 0)
# Footer (3 lines at bottom)
self.footer_win = curses.newwin(3, self.width, self.height - 3, 0)
# Sidebar (optional, can be rendered in content area)
self.sidebar_width = max(25, self.width // 3)
self.sidebar_win = curses.newwin(content_height, self.sidebar_width, 3, 0)
self.main_content_win = curses.newwin(
content_height,
self.width - self.sidebar_width - 1,
3,
self.sidebar_width + 1,
)
def _register_screens(self):
"""Register all installer screens in order."""
self.pages = [
("welcome", self._render_welcome),
("deps_summary", self._render_deps_summary),
("network_scan", self._render_network_scan),
("env_setup", self._render_env_setup),
("init_cluster", self._render_init_cluster),
("init_password", self._render_init_password),
("init_db_build", self._render_init_db_build),
("kerberos_config", self._render_kerberos_config),
("init_scripts", self._render_init_scripts),
("init_cnpg_deploy", self._render_init_cnpg_deploy),
("create_installer", self._render_create_installer),
]
# Add dynamic dependency screens
for dep in self.dependencies:
dep_id = f"dep_{dep['id']}"
self.pages.append((dep_id, lambda dep=dep: self._render_dependency(dep)))
def run(self):
"""Main event loop."""
while self.running:
self._render()
self._handle_input()
def _render(self):
"""Render the current screen with flicker-free updates."""
try:
# Erase all windows to prevent ghosting from previous frames
# Using erase() instead of clear() to avoid flickering
self.stdscr.erase()
self.header_win.erase()
self.sidebar_win.erase()
self.main_content_win.erase()
self.footer_win.erase()
# Render header
self._render_header()
# Render sidebar
self._render_sidebar()
# Render current page
if 0 <= self.page_index < len(self.pages):
_, render_func = self.pages[self.page_index]
render_func()
# Render footer
self.footer.render(focused=(self.focus == "footer"))
# Use noutrefresh for all windows to batch updates
# stdscr is the background, so refresh it first
self.stdscr.noutrefresh()
self.header_win.noutrefresh()
self.sidebar_win.noutrefresh()
self.main_content_win.noutrefresh()
self.footer_win.noutrefresh()
# Perform a single physical update to the terminal
curses.doupdate()
except curses.error:
pass
def _render_header(self):
"""Render the header."""
try:
title = "Prole Database Installer"
self.header_win.addstr(0, 2, title, curses.A_BOLD)
self.header_win.addstr(1, 2, "" * (self.width - 4))
if self.status_message:
self.header_win.addstr(2, 2, self.status_message[: self.width - 4])
except curses.error:
pass
def _render_sidebar(self):
"""Render the navigation sidebar."""
try:
nav_items = [
("Welcome", "welcome"),
("Dependencies", "deps_summary"),
("Network Scan", "network_scan"),
("Environment", "env_setup"),
("Cluster Environment", "init_cluster"),
("Password", "init_password"),
("Build", "init_db_build"),
("Scripts", "init_scripts"),
("Kerberos", "kerberos_config"),
("Deploy", "init_cnpg_deploy"),
("Installer", "create_installer"),
]
current_page_id = (
self.pages[self.page_index][0]
if self.page_index < len(self.pages)
else ""
)
self.sidebar_win.addstr(0, 1, "Navigation", curses.A_BOLD)
self.sidebar_win.addstr(1, 1, "" * (self.sidebar_width - 2))
for i, (label, page_id) in enumerate(nav_items):
y = i + 3
if y >= self.height - 6:
break
if page_id == current_page_id:
if self.focus == "sidebar":
attr = curses.A_REVERSE | curses.A_BOLD
else:
attr = curses.A_BOLD # Selected but not focused
else:
attr = curses.A_NORMAL
display_label = f" {label}"[: self.sidebar_width - 2]
self.sidebar_win.addstr(
y, 1, display_label.ljust(self.sidebar_width - 3), attr
)
# Draw vertical separator
for y in range(self.height - 6):
self.sidebar_win.addch(y, self.sidebar_width - 1, curses.ACS_VLINE)
except curses.error:
pass
def _handle_input(self):
"""Handle keyboard input."""
self.stdscr.timeout(100) # 100ms timeout for non-blocking input
try:
key = self.stdscr.getch()
if key == -1: # No input
return
elif key == ord("q") or key == ord("Q"):
self.running = False
return
# Handle Tab to switch focus
if key == ord("\t") or key == 9:
focus_order = ["sidebar", "main", "footer"]
idx = focus_order.index(self.focus)
self.focus = focus_order[(idx + 1) % len(focus_order)]
self._update_footer()
return
# Handle input based on focus
if self.focus == "sidebar":
if key == curses.KEY_UP or key == ord("k"):
self._on_prev()
return
elif key == curses.KEY_DOWN or key == ord("j"):
self._on_next()
return
elif self.focus == "main":
if self._handle_page_input(key):
return
elif self.focus == "footer":
if key == curses.KEY_LEFT or key == ord("h"):
self.footer.move_selection(-1)
elif key == curses.KEY_RIGHT or key == ord("l"):
self.footer.move_selection(1)
elif (
key == ord("\n")
or key == curses.KEY_ENTER
or key == 10
or key == 13
):
self.footer.activate_selected()
return
# Global keys (always active)
if key == ord("n"):
self._on_next()
elif key == ord("p"):
self._on_prev()
except curses.error:
pass
def _handle_page_input(self, key: int) -> bool:
"""Handle page-specific keyboard input. Returns True if handled."""
page_id = (
self.pages[self.page_index][0] if self.page_index < len(self.pages) else ""
)
if page_id == "network_scan":
if key == ord("s") or key == ord("S") or key == ord("\n"):
self._run_network_scan()
return True
return False
if page_id == "deps_summary":
if key == curses.KEY_UP or key == ord("k"):
self.selected_dep_index = (self.selected_dep_index - 1) % min(
len(self.dependencies), 10
)
return True
elif key == curses.KEY_DOWN or key == ord("j"):
self.selected_dep_index = (self.selected_dep_index + 1) % min(
len(self.dependencies), 10
)
return True
elif key == ord("v") or key == ord("V"):
self._verify_dependencies()
return True
elif key == ord(" ") or key == ord("\n"):
# In ncurses, we navigate to the dependency page to see details/install
dep = self.dependencies[self.selected_dep_index]
self.show_page(f"dep_{dep['id']}")
return True
elif page_id == "init_cluster":
if key == curses.KEY_UP or key == ord("k"):
self.selected_env_index = (self.selected_env_index - 1) % len(
self.cluster_environments
)
self.cluster_env = self.cluster_environments[self.selected_env_index]
if self.cluster_env == "prole-service-cluster":
self._verify_k3s_services()
return True
elif key == curses.KEY_DOWN or key == ord("j"):
self.selected_env_index = (self.selected_env_index + 1) % len(
self.cluster_environments
)
self.cluster_env = self.cluster_environments[self.selected_env_index]
if self.cluster_env == "prole-service-cluster":
self._verify_k3s_services()
return True
selected_env = self.cluster_environments[self.selected_env_index]
if selected_env == "knoe-dev-cluster":
if key == ord(" "): # Toggle/Select context
pass # We could implement sub-selection
elif key == curses.KEY_NPAGE: # Page Down to scroll context list?
self.selected_kubectx_index = (
self.selected_kubectx_index + 1
) % len(self.kubectx_list)
return True
elif key == curses.KEY_PPAGE: # Page Up to scroll context list?
self.selected_kubectx_index = (
self.selected_kubectx_index - 1
) % len(self.kubectx_list)
return True
elif selected_env == "prole-service-cluster":
if key == ord("d") or key == ord("D"):
self._deploy_k3s_services()
return True
elif selected_env == "prole-prod-cluster":
return self.prod_artifacts_path.handle_key(key)
return False
def _get_kubectx_list(self) -> list[str]:
"""Get list of kubernetes contexts."""
try:
res = subprocess.run(["kubectx"], capture_output=True, text=True)
if res.returncode == 0:
return res.stdout.strip().split("\n")
# Fallback to kubectl
res = subprocess.run(
["kubectl", "config", "get-contexts", "-o", "name"],
capture_output=True,
text=True,
)
if res.returncode == 0:
return res.stdout.strip().split("\n")
except Exception:
pass
return ["default"]
def _write_k3s_kubeconfig(self, server: str, token: str) -> Path:
"""Delegate to the shared ``_write_k3s_kubeconfig`` helper."""
return _write_k3s_kubeconfig(server, token)
def _verify_k3s_services(self):
"""Verify registry:2, OpenBao, and OpenTofu on remote k3s cluster.
Delegates to the shared ``_verify_k3s_services_status`` so the
same resolution logic (env → prole.cfg → ansible) is used.
"""
def _verify():
self.k3s_services_status = {
"registry": "Checking...",
"openbao": "Checking...",
"opentofu": "Checking...",
}
try:
self.k3s_services_status = _verify_k3s_services_status(
project_root=self.project_root,
)
except Exception as e:
self.status_message = f"K3s connection error: {str(e)}"
self.k3s_services_status = {
"registry": "Error",
"openbao": "Error",
"opentofu": "Error",
}
threading.Thread(target=_verify).start()
def _deploy_k3s_services(self):
"""Deploy registry, OpenBao, and OpenTofu to remote k3s cluster.
Uses the shared resolution (env → prole.cfg → ansible) for the
server URL / token and KUBECONFIG so behaviour matches the Tk UI.
"""
def _deploy():
self.status_message = "Deploying services to remote k3s..."
server, token = _resolve_k3s_connection(
project_root=self.project_root,
)
if not server or not token:
self.status_message = "Missing k3s server or token."
return
env = os.environ.copy()
env["PROLE_HOME"] = str(self.project_root)
env["PROLE_SERVICE"] = str(self.project_root)
env["NAMESPACE"] = self.db_namespace or "default"
env["DB_PASSWORD"] = self.db_password or ""
env["OPENTOFU_ADMIN_PASSWORD"] = self.db_password or ""
env["PROLE_MODE"] = "k3s"
# Resolve KUBECONFIG: prefer existing cert-based file,
# fall back to token-generated kubeconfig.
kubeconfig = _find_kubeconfig_file(env)
if not kubeconfig:
try:
kubeconfig = str(_write_k3s_kubeconfig(server, token))
except Exception:
pass
if kubeconfig:
env["KUBECONFIG"] = kubeconfig
try:
_merge_kubeconfig(kubeconfig)
except Exception:
pass
try:
argocd_ns = env.get("ARGOCD_NAMESPACE") or "argocd"
registry_ns = env.get("REGISTRY_NAMESPACE") or "default"
env["ARGOCD_NAMESPACE"] = argocd_ns
env["REGISTRY_NAMESPACE"] = registry_ns
self.controller.run_script(
"init_registry.sh",
args=[
"-n",
argocd_ns,
"--registry-namespace",
registry_ns,
"update",
],
env=env,
)
self.controller.run_script(
"init_openbao.sh", args=["-n", env["NAMESPACE"], "update"], env=env
)
self.controller.run_script(
"init_opentofu.sh", args=["-n", env["NAMESPACE"], "update"], env=env
)
except Exception as e:
self.status_message = f"Deploy error: {e}"
return
self._verify_k3s_services()
self.status_message = "Deployment triggered on remote k3s."
threading.Thread(target=_deploy).start()
def _update_footer(self):
"""Update footer buttons based on current page."""
page_id = (
self.pages[self.page_index][0] if self.page_index < len(self.pages) else ""
)
# Default buttons
prev_enabled = self.page_index > 0
next_label = "Next"
next_enabled = True
# Last page customization
if self.page_index == len(self.pages) - 1:
next_label = "Finish"
buttons = [
("Previous", self._on_prev, prev_enabled),
(next_label, self._on_next, next_enabled),
("Quit", lambda: setattr(self, "running", False), True),
]
self.footer.set_buttons(buttons)
# Set help text based on focus
if self.focus == "sidebar":
self.footer.set_help_text("TAB: Focus Main | UP/DN: Page")
elif self.focus == "main":
self.footer.set_help_text("TAB: Focus Footer | Arrows: Interact")
else:
self.footer.set_help_text("TAB: Focus Sidebar | Arrows: Select Button")
def _on_next(self):
"""Navigate to next page."""
if self.page_index < len(self.pages) - 1:
self.page_index += 1
self._update_footer()
def _on_prev(self):
"""Navigate to previous page."""
if self.page_index > 0:
self.page_index -= 1
self._update_footer()
def show_page(self, page_id: str):
"""Navigate to a specific page by ID."""
for i, (pid, _) in enumerate(self.pages):
if pid == page_id:
self.page_index = i
self._update_footer()
return
def _verify_dependencies(self):
"""Run dependency verification in background."""
if self.validation_running:
return
self.validation_running = True
def _worker():
for dep in self.dependencies:
did = dep["id"]
try:
res = inst_config.get_dep_info(dep)
self.validation_results[did] = res
except Exception:
self.validation_results[did] = (False, None, None)
time.sleep(0.05) # Feedback delay
self.validation_running = False
threading.Thread(target=_worker, daemon=True).start()
def _run_network_scan(self):
"""Run network scan via prole-agent."""
if getattr(self, "_scan_running", False):
return
self._scan_running = True
self.scan_results_console.clear()
self.scan_results_console.write("Starting network scan...\n")
def _worker():
try:
scan_binary = inst_config.get_resource_path("prole-net/prole-agent")
if not scan_binary.exists():
self.scan_results_console.write(
f"Error: scan binary not found at {scan_binary}\n"
)
self._scan_running = False
return
prole_home = Path.home() / ".prole"
scan_dir = prole_home / "scan"
scan_dir.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
pending_line = ""
def _handle_line(line: str):
if "KDC is:" in line:
try:
ip = (
line.split("KDC is:")[1]
.strip()
.split()[0]
.strip("[]():,")
)
if ip:
self.kerberos_kdc = ip
self.kerberos_enabled = True
except Exception:
pass
elif "Active Directory" in line or "88" in line:
match = re.search(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", line)
if match:
self.kerberos_kdc = match.group(0)
self.kerberos_enabled = True
def _on_chunk(text: str):
nonlocal pending_line
self.scan_results_console.write(text)
pending_line += text
while "\n" in pending_line:
line, pending_line = pending_line.split("\n", 1)
_handle_line(line + "\n")
rc = run_streaming_cmd(
[str(scan_binary)],
env=env,
cwd=str(scan_dir),
on_stdout=_on_chunk,
on_stderr=_on_chunk,
)
if pending_line:
_handle_line(pending_line)
if rc == 0:
self.scan_results_console.write("\nScan complete.\n")
else:
self.scan_results_console.write(f"\nScan failed (exit {rc})\n")
except Exception as e:
self.scan_results_console.write(f"\nError: {str(e)}\n")
finally:
self._scan_running = False
threading.Thread(target=_worker, daemon=True).start()
# ========================================================================
# Screen Renderers (11 main screens + dependency screens)
# ========================================================================
def _render_welcome(self):
"""Render the welcome screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Welcome to Prole Database Installer", y=2)
win.render_paragraph(
"This installer will guide you through setting up the Prole database system, "
"including Docker containers, dependencies, and deployment configuration.",
y=5,
)
win.render_paragraph(
"Navigation: Use TAB to switch focus between the sidebar and main content.",
y=10,
)
win.render_paragraph(
"Sidebar Focused: Use UP/DOWN to navigate between pages.", y=12
)
win.render_paragraph(
"Main Content Focused: Use Arrow Keys or specific shortcuts for each page.",
y=14,
)
def _render_deps_summary(self):
"""Render dependencies summary screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Dependencies Summary", y=2)
y = 5
win.render_text(y, 4, f"Total dependencies: {len(self.dependencies)}")
if not self.validation_running and not self.validation_results:
self._verify_dependencies()
y += 2
for i, dep in enumerate(self.dependencies[:10]): # Show first 10
did = dep["id"]
res = self.validation_results.get(did)
if res:
ok, _, version = res
else:
ok = dep.get("installed", False)
version = dep.get("version", "")
status_color = curses.color_pair(2) if ok else curses.color_pair(3)
status_char = "v" if ok else "X"
status_str = f"[{status_char}]"
is_selected = self.focus == "main" and i == self.selected_dep_index
attr = curses.A_REVERSE if is_selected else curses.A_NORMAL
win.render_text(y, 4, status_str, status_color | attr)
win.render_text(y, 8, dep["name"], attr)
if ok and version:
v_str = f" ({version})"[:20]
win.render_text(y, 8 + len(dep["name"]), v_str, curses.A_DIM | attr)
elif not ok:
win.render_text(
y, 8 + len(dep["name"]), " (Missing)", curses.A_DIM | attr
)
y += 1
if self.validation_running:
win.render_text(y + 1, 4, "Scanning system...", curses.color_pair(4))
else:
win.render_text(y + 1, 4, "Press 'v' to re-verify all", curses.A_DIM)
def _render_network_scan(self):
"""Render network scanning screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Network Configuration", y=2)
win.render_paragraph(
"Scanning network for existing services and configurations...", y=5
)
y = 8
h, w = self.main_content_win.getmaxyx()
if not hasattr(self, "scan_results_console"):
from .ncurses_ui import TerminalConsole
# height = content_height - header - footer - titles
self.scan_results_console = TerminalConsole(
self.main_content_win, h - 12, w - 8, y, 4
)
self.scan_results_console.render()
y_footer = h - 2
if getattr(self, "_scan_running", False):
win.render_text(
y_footer, 4, "Scanning in progress...", curses.color_pair(4)
)
else:
win.render_text(
y_footer, 4, "Press 's' to start network scan", curses.A_BOLD
)
def _render_env_setup(self):
"""Render environment setup screen."""
win = CursesWindow(self.main_content_win)
win.render_title("System Environment Setup", y=2)
win.render_paragraph(
"Configure system environment variables and paths for Prole installation.",
y=5,
)
y = 9
win.render_text(y, 4, f"PROLE_HOME: {self.project_root}")
y += 2
win.render_text(y, 4, f"Cluster Environment: {self.cluster_env}")
def _render_kerberos_config(self):
"""Render Kerberos configuration screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Kerberos Authentication", y=2)
win.render_paragraph(
"Configure Kerberos authentication for secure database access.", y=5
)
y = 9
enabled = "Enabled" if self.kerberos_enabled else "Disabled"
win.render_text(y, 4, f"Kerberos: {enabled}")
if self.kerberos_enabled:
y += 2
win.render_text(y, 4, f"Realm: {self.kerberos_realm}")
y += 1
win.render_text(y, 4, f"KDC: {self.kerberos_kdc}")
def _render_init_password(self):
"""Render database password initialization screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Database Creation", y=2)
win.render_paragraph(
"Define the database namespace and root password for this deployment.", y=5
)
y = 9
win.render_text(y, 4, f"Database Name (Namespace): {self.db_namespace}")
y += 2
win.render_text(y, 4, f"Owner (Local User): {self.db_username}")
y += 2
win.render_text(y, 4, "Password: " + "*" * len(self.db_password))
y += 1
win.render_text(y, 4, "Confirm: " + "*" * len(self.db_password_confirm))
def _render_init_db_build(self):
"""Render Docker build screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Build Database Container", y=2)
win.render_paragraph("Building Docker container for Prole database...", y=5)
win.render_text(
8, 4, "[Docker build process - console output would appear here]"
)
def _render_init_cluster(self):
"""Render cluster initialization screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Cluster Environment", y=2)
win.render_paragraph(
"Select the target cluster environment and verify k3d/k3s/prod plus OpenBao are available.",
y=5,
)
y = 9
for i, env in enumerate(self.cluster_environments):
selector = "(*)" if i == self.selected_env_index else "( )"
win.render_text(y, 4, f"{selector} {env}")
y += 1
y += 1
selected_env = self.cluster_environments[self.selected_env_index]
if selected_env == "knoe-dev-cluster":
win.render_text(
y,
4,
"Available kubectx contexts (Page Up/Down to scroll):",
curses.A_BOLD,
)
y += 1
start_idx = max(0, self.selected_kubectx_index - 2)
for i, ctx in enumerate(self.kubectx_list[start_idx : start_idx + 5]):
is_selected = (start_idx + i) == self.selected_kubectx_index
if is_selected:
attr = curses.A_REVERSE if self.focus == "main" else curses.A_BOLD
else:
attr = curses.A_NORMAL
win.render_text(y, 6, f"- {ctx}", attr)
y += 1
elif selected_env == "prole-service-cluster":
win.render_text(y, 4, "Remote K3s Service Status:", curses.A_BOLD)
y += 1
for service, status in self.k3s_services_status.items():
color = (
curses.color_pair(2)
if status == "Good"
else (
curses.color_pair(3) if status == "Failing" else curses.A_NORMAL
)
)
win.render_text(y, 6, f"{service}: ")
win.render_text(y, 6 + len(service) + 2, status, color)
y += 1
if any(s == "Failing" for s in self.k3s_services_status.values()):
win.render_text(
y + 1, 4, "Press 'd' to deploy missing services", curses.A_BOLD
)
elif selected_env == "prole-prod-cluster":
win.render_text(y, 4, "Local Artifact Staging Directory:", curses.A_BOLD)
y += 2
# Handle InputField render focus
self.prod_artifacts_path.render(focused=(self.focus == "main"))
win.render_text(y + 2, 4, "(Used by etc/deploy_pipeline.sh --mode gcp)")
def _render_init_scripts(self):
"""Render initialization scripts screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Initialization Scripts", y=2)
win.render_paragraph("Running database initialization scripts...", y=5)
def _render_init_cnpg_deploy(self):
"""Render CNPG deployment screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Deploy Prole Database", y=2)
win.render_paragraph(
"Deploying CloudNativePG operator and Prole database cluster...", y=5
)
def _render_create_installer(self):
"""Render installer creation screen."""
win = CursesWindow(self.main_content_win)
win.render_title("Create Installation Media", y=2)
win.render_paragraph("Create a bootable installer or deployment package.", y=5)
if self.selected_disk_type:
y = 9
win.render_text(y, 4, f"Type: {self.selected_disk_type}")
def _render_dependency(self, dep: dict):
"""Render individual dependency installation screen."""
win = CursesWindow(self.main_content_win)
win.render_title(f"Install: {dep['name']}", y=2)
if dep.get("description"):
win.render_paragraph(dep["description"], y=5)
y = 10
status = "Installed" if dep.get("installed") else "Not Installed"
win.render_text(y, 4, f"Status: {status}")
class NcursesUIAdapter:
"""Adapter skeleton that matches the Tkinter UI event interface."""
def __init__(self, runner, controller):
self.runner = runner
self.controller = controller
self._bind_runner()
def _bind_runner(self):
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 to bridge into curses rendering)
def on_enter_milestone(self, milestone, state):
pass
def on_validation_error(self, milestone, state, errors):
pass
def on_progress(self, milestone, state, message, percent):
pass
def on_complete(self, milestone, state):
pass
def start(self):
run_ncurses_installer(self.controller)
def run_ncurses_installer(controller):
"""Entry point for ncurses installer."""
def _main(stdscr):
installer = ProleNcursesInstaller(stdscr, controller)
installer.run()
try:
curses.wrapper(_main)
except KeyboardInterrupt:
pass
except Exception as e:
print(f"Error running ncurses installer: {e}", file=sys.stderr)
raise