prole/knoe/ui/screens/__init__.py
2026-04-27 14:44:46 -07:00

1322 lines
53 KiB
Python

"""
Knoe Service Dependencies Installer — screen package.
This package splits the monolithic ``KnoeInstaller`` class into cohesive
mixin modules, one per logical screen / concern. The composed class is
assembled here and re-exported so that external callers (``install.py``,
tests) see the same public surface as before.
"""
from __future__ import annotations
import configparser
import signal
import socket
import sys
import threading
import time
import tkinter as tk
import uuid
from tkinter import ttk, messagebox, filedialog
import platform
from knoe import screen as ui
from knoe.config import (
_expand_cfg_value,
_collect_cfg_vars,
_parse_gcp_cfg,
_preserve_cfg_expr_for_persistence,
)
from knoe.core.controller import KnoeController
from knoe.core.env import (
_deployment_target_label,
_safe_str,
_normalize_cluster_env,
)
from knoe.core.onepassword import ensure_op_signed_in, ensure_knoey_vault
# Helper to set macOS application identity via Objective-C bridge.
def setup_macos_app_identity():
"""Set the installer identity: process/menu name on macOS.
This should be called AFTER the Tk root window is initialized.
"""
if platform.system() != "Darwin":
return
try:
import ctypes
import ctypes.util
objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc"))
objc.objc_getClass.restype = ctypes.c_void_p
objc.sel_registerName.restype = ctypes.c_void_p
objc.objc_msgSend.restype = ctypes.c_void_p
def msg(obj, sel_name, *args, restype=ctypes.c_void_p, argtypes=None):
if obj is None:
return None
sel = objc.sel_registerName(sel_name.encode("ascii"))
# Use argtypes to ensure correct calling convention
objc.objc_msgSend.restype = restype
if argtypes is not None:
objc.objc_msgSend.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + argtypes
else:
objc.objc_msgSend.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
return objc.objc_msgSend(obj, sel, *args)
def to_ns(py_str):
NSUTF8StringEncoding = 4
cls = objc.objc_getClass(b"NSString")
return msg(
cls,
"stringWithCString:encoding:",
py_str.encode("utf-8"),
NSUTF8StringEncoding,
argtypes=[ctypes.c_char_p, ctypes.c_uint],
)
app_name_str = "Knoe.DB Installer"
app_name_ns = to_ns(app_name_str)
# 1. Set process name
process_info_cls = objc.objc_getClass(b"NSProcessInfo")
if process_info_cls:
process_info = msg(process_info_cls, "processInfo")
if process_info:
# setProcessName: is often ignored for bundled apps but helps for scripts.
msg(process_info, "setProcessName:", app_name_ns, argtypes=[ctypes.c_void_p])
# 1a. Attempt to set process name via libc's setprogname
try:
libc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("c"))
if hasattr(libc, "setprogname"):
libc.setprogname.argtypes = [ctypes.c_char_p]
libc.setprogname(app_name_str.encode("utf-8"))
except Exception:
pass
# 1b. Manipulate argv[0] as a fallback for some systems
try:
# sys.argv[0] doesn't change the OS process name but can affect some libraries
import sys
sys.argv[0] = app_name_str
except Exception:
pass
# 1b. Try to set Dock name via LSSetApplicationInformationItem (SPI)
# This is a bit risky but often works for setting the Dock mouseover name at runtime.
try:
LSSetApplicationInformationItem = getattr(objc, "_LSSetApplicationInformationItem", None)
if LSSetApplicationInformationItem:
# kLSKeyAllFrontProcess = -2
# kLSRuntimeApplicationNameKey = "LSRuntimeApplicationName"
# _LSSetApplicationInformationItem(int, int, CFStringRef, CFStringRef, int*)
LSSetApplicationInformationItem.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
LSSetApplicationInformationItem.restype = ctypes.c_int
LSRuntimeApplicationNameKey = to_ns("LSRuntimeApplicationName")
LSSetApplicationInformationItem(-2, -1, LSRuntimeApplicationNameKey, app_name_ns, None)
except Exception:
pass
# 2. Set transformation state and Dock Icon
app_cls = objc.objc_getClass(b"NSApplication")
if app_cls:
app = msg(app_cls, "sharedApplication")
if app:
NSApplicationActivationPolicyRegular = 0
msg(app, "setActivationPolicy:", NSApplicationActivationPolicyRegular, argtypes=[ctypes.c_long])
# Set Dock Icon via ctypes since PyObjC might be missing
try:
from knoe import config as inst_config
cfg_icon = inst_config.get_ui_icon_image_path()
if cfg_icon.exists():
ns_image_cls = objc.objc_getClass(b"NSImage")
if ns_image_cls:
icon_path_ns = to_ns(str(cfg_icon))
img = msg(ns_image_cls, "alloc")
img = msg(img, "initWithContentsOfFile:", icon_path_ns, argtypes=[ctypes.c_void_p])
if img:
msg(app, "setApplicationIconImage:", img, argtypes=[ctypes.c_void_p])
except Exception:
pass
# 3. Update Bundle info
bundle_cls = objc.objc_getClass(b"NSBundle")
main_bundle = msg(bundle_cls, "mainBundle")
if main_bundle:
info_dict = msg(main_bundle, "localizedInfoDictionary")
if not info_dict:
info_dict = msg(main_bundle, "infoDictionary")
if info_dict:
# Some dicts might not support setObject:forKey:
set_obj_sel = objc.sel_registerName(b"setObject:forKey:")
if msg(info_dict, "respondsToSelector:", set_obj_sel, argtypes=[ctypes.c_void_p], restype=ctypes.c_bool):
msg(
info_dict,
"setObject:forKey:",
app_name_ns,
to_ns("CFBundleName"),
argtypes=[ctypes.c_void_p, ctypes.c_void_p],
)
msg(
info_dict,
"setObject:forKey:",
app_name_ns,
to_ns("CFBundleDisplayName"),
argtypes=[ctypes.c_void_p, ctypes.c_void_p],
)
# 3. Update Menu items
app_cls = objc.objc_getClass(b"NSApplication")
if app_cls:
app = msg(app_cls, "sharedApplication")
if app:
# IMPORTANT: Only modify menus if they are already initialized
main_menu = msg(app, "mainMenu")
if main_menu:
num_items = msg(main_menu, "numberOfItems", restype=ctypes.c_long)
if num_items > 0:
apple_menu_item = msg(
main_menu, "itemAtIndex:", 0, argtypes=[ctypes.c_long]
)
if apple_menu_item:
msg(
apple_menu_item,
"setTitle:",
app_name_ns,
argtypes=[ctypes.c_void_p],
)
apple_menu = msg(apple_menu_item, "submenu")
if apple_menu:
# About item
about_item = msg(
apple_menu, "itemAtIndex:", 0, argtypes=[ctypes.c_long]
)
if about_item:
msg(
about_item,
"setTitle:",
to_ns(f"About {app_name_str}"),
argtypes=[ctypes.c_void_p],
)
# Quit item - try by title first
quit_item = msg(
apple_menu,
"itemWithTitle:",
to_ns("Quit Python"),
argtypes=[ctypes.c_void_p],
)
if not quit_item:
# Search for terminate: action
num_menu_items = msg(
apple_menu, "numberOfItems", restype=ctypes.c_long
)
terminate_sel = objc.sel_registerName(b"terminate:")
for i in range(num_menu_items):
item = msg(
apple_menu,
"itemAtIndex:",
i,
argtypes=[ctypes.c_long],
)
if msg(item, "action") == terminate_sel:
quit_item = item
break
if quit_item:
msg(
quit_item,
"setTitle:",
to_ns(f"Quit {app_name_str}"),
argtypes=[ctypes.c_void_p],
)
except Exception:
pass
from knoe.core.env import * # noqa: F401,F403
from knoe.core.actions import (
KnoeInstaller,
KnoeConsoleInstaller,
_prepare_k3s_pipeline,
)
# Keep global frames list in memory (legacy requirement).
GLOBAL_SCAN_FRAMES: list = []
# ---------------------------------------------------------------------------
# Mixin imports — one per screen / concern
# ---------------------------------------------------------------------------
from knoe.ui.screens.base import ScreenBaseMixin
from knoe.ui.screens.navigation import NavigationMixin
from knoe.ui.screens.welcome import WelcomeScreenMixin
from knoe.ui.screens.dependencies import DependenciesScreenMixin
from knoe.ui.screens.network import NetworkScreenMixin
from knoe.ui.screens.environment import EnvironmentScreenMixin
from knoe.ui.screens.database import DatabaseScreenMixin
from knoe.ui.screens.database_options import DatabaseOptionsScreenMixin
from knoe.ui.screens.cluster import ClusterScreenMixin
from knoe.ui.screens.cluster_nodes import ClusterNodesScreenMixin
from knoe.ui.screens.services import ServicesScreenMixin
from knoe.ui.screens.security import SecurityScreenMixin
from knoe.ui.screens.knoe_users import KnoeUsersScreenMixin
from knoe.ui.screens.supabase import SupabaseScreenMixin
from knoe.ui.screens.gitops import GitOpsScreenMixin
from knoe.ui.screens.argocd import ArgoCDScreenMixin
from knoe.ui.screens.docker import DockerScreenMixin
from knoe.ui.screens.build import BuildScreenMixin
from knoe.ui.screens.packaging import PackagingScreenMixin
from knoe.ui.screens.deploy import DeployScreenMixin
from knoe.ui.screens.validate import ValidateScreenMixin
from knoe.ui.screens.cfg import ConfigMixin
# ---------------------------------------------------------------------------
# Composed installer class
# ---------------------------------------------------------------------------
class KnoeInstaller(
KnoeInstaller,
ScreenBaseMixin,
NavigationMixin,
WelcomeScreenMixin,
DependenciesScreenMixin,
NetworkScreenMixin,
EnvironmentScreenMixin,
DatabaseScreenMixin,
DatabaseOptionsScreenMixin,
ClusterScreenMixin,
ClusterNodesScreenMixin,
ServicesScreenMixin,
SecurityScreenMixin,
KnoeUsersScreenMixin,
SupabaseScreenMixin,
GitOpsScreenMixin,
ArgoCDScreenMixin,
DockerScreenMixin,
BuildScreenMixin,
PackagingScreenMixin,
DeployScreenMixin,
ValidateScreenMixin,
ConfigMixin,
):
"""Knoe GUI installer — assembled from per-screen mixins."""
KUBECTL_STATUS_TAB = "__kubectl_status__"
# ---- Data access bridge (satisfies KnoeInstallerBase contract) ----
_INPUT_VAR_MAP: dict[str, str] = {
"init_cluster.cluster_env": "cluster_env",
"init_password.db_namespace": "db_namespace",
"init_password.cluster_name": "cnpg_cluster_name",
"init_password.db_username": "db_username",
"init_password.db_password": "db_password",
"init_password.db_host_port": "db_host_port",
"env_setup.DATABASE_NAMESPACE": "db_namespace",
"env_setup.CLUSTER_NAME": "cnpg_cluster_name",
"kerberos_config.enabled": "kerberos_enabled",
"kerberos_config.realm": "kerberos_realm",
"kerberos_config.kdc": "kerberos_kdc",
"kerberos_config.user": "kerberos_user",
"kerberos_config.password": "kerberos_password",
"init_cluster.kerberos_enabled": "kerberos_enabled",
"init_cluster.supabase_enabled": "supabase_enabled",
"init_cluster.gitops_enabled": "gitops_enabled",
"init_cluster.argocd_enabled": "argocd_enabled",
"init_cluster.at_rest_encryption_enabled": "at_rest_encryption_enabled",
"init_cluster.selected_kubectx": "selected_kubectx",
"init_cluster.app_cluster_kubecontext": "app_cluster_kubecontext",
"init_cluster.db_cluster_kubecontext": "db_cluster_kubecontext",
"init_cluster.k3s_server_url": "k3s_server_url",
"init_cluster.k3s_token": "k3s_token",
"ollama_config.server_host": "ollama_server_host",
"ollama_config.server_port": "ollama_server_port",
"ollama_config.model": "ollama_model",
"gitops.namespace": "gitops_namespace",
"argocd.namespace": "argocd_namespace",
}
def _get_input(self, key: str, default: str | None = None) -> str:
"""Bridge for :class:`KnoeInstallerBase` — reads tk vars by key."""
if key.startswith("env_setup.") and hasattr(self, "_env_entries"):
env_key = key[len("env_setup.") :]
ent = self._env_entries.get(env_key)
if ent is not None:
try:
val = _safe_str(ent.get().strip())
if val:
return val
except Exception:
pass
attr = self._INPUT_VAR_MAP.get(key)
if attr:
var = getattr(self, attr, None)
if var is not None:
try:
val = var.get()
if hasattr(val, "__class__") and "MagicMock" in val.__class__.__name__:
return default if default is not None else ""
if isinstance(val, bool):
return "true" if val else "false"
return _safe_str(val)
except Exception:
pass
return default if default is not None else ""
# ------------------------------------------------------------------ init
def __init__(
self,
root,
config_path: str | None = None,
reset_cluster: bool = False,
verbose: bool = False,
):
# Resolve config path early for the controller
resolved_cfg = None
if config_path:
try:
resolved_cfg = Path(config_path).expanduser()
except Exception:
pass
if not resolved_cfg:
conf_dir = knoe_conf.resolve_knoe_conf_dir(PROJECT_ROOT)
resolved_cfg = knoe_conf.entrypoint_path(conf_dir)
self.controller = KnoeController(
PROJECT_ROOT, verbose=verbose, cfg_path=resolved_cfg
)
self.project_root = PROJECT_ROOT
self.cfg_path = resolved_cfg
from knoe.core.env import DEFAULT_ACTION_FLAGS
self._action_flags: dict[str, bool] = DEFAULT_ACTION_FLAGS.copy()
self._init_shared_state()
self._init_database_options_state()
self.screens = None
self.deploy_environment = None
self.root = root
self.root.title("Knoe.DB Installer")
try:
setup_macos_app_identity()
except Exception:
pass
self._cfg_path_override = None
# Center window on screen
window_width = 1300
window_height = 910
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
center_x = int(screen_width / 2 - window_width / 2)
center_y = int(screen_height / 2 - window_height / 2)
self.root.geometry(f"{window_width}x{window_height}+{center_x}+{center_y}")
self.root.resizable(False, False)
# Force light mode colors
self.root.configure(bg="white")
# Style configuration
self.style = ttk.Style()
self.configure_styles()
# Main container with two columns
self.main_container = tk.Frame(root, bg="white")
self.main_container.pack(fill="both", expand=True)
# Left Sidebar
self.sidebar = tk.Frame(self.main_container, bg="#F5F5DC", width=325)
self.sidebar.pack(side="left", fill="y")
self.sidebar.pack_propagate(False)
# Vertical Divider Line
self.divider = tk.Frame(
self.main_container, bg="#CCCCCC", width=1, highlightthickness=0, bd=0
)
self.divider.pack(side="left", fill="y")
# Right Content Area
self.content_area = tk.Frame(self.main_container, bg="white")
self.content_area.pack(side="left", fill="both", expand=True)
# Footer for Next/Prev buttons
btns = ui.create_nav_footer(
self.content_area,
buttons=[(1, "Previous"), (2, "Next"), (3, "Deploy"), (4, "Launch")],
commands={
1: self.on_prev,
2: self.on_next,
3: self.on_deploy,
4: self.on_launch,
},
style_name="Nav.TButton",
gear_command=self.show_config_dialog,
)
self.footer = btns.get("_footer")
self.prev_button = btns.get(1)
self.next_button = btns.get(2)
self.deploy_button = btns.get(3)
self.launch_button = btns.get(4)
self.gear_button = btns.get("gear")
# Background canvas
self._bg_pil = None
self._bg_tk = None
self.bg_canvas = tk.Canvas(
self.content_area, highlightthickness=0, bd=0, bg="white"
)
self.bg_canvas.pack(fill="both", expand=True)
self._bg_item = None
# Slide area overlay
self.slide_area = tk.Frame(self.bg_canvas, bg="white")
self.slide_area.place(relx=0, rely=0, relwidth=1, relheight=1)
self.slide_area.lower()
try:
from PIL import Image, ImageTk
bg_path = get_resource_path("img/knoeLogoSepia.png")
if bg_path.exists():
original = Image.open(str(bg_path)).convert("RGBA")
white_bg = Image.new("RGBA", original.size, (255, 255, 255, 255))
self._bg_pil = Image.blend(white_bg, original, 0.15)
def _render_bg(event=None):
if not self._bg_pil:
return
cw = max(1, self.bg_canvas.winfo_width())
ch = max(1, self.bg_canvas.winfo_height())
iw, ih = self._bg_pil.size
scale = max(cw / iw, ch / ih)
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
img = self._bg_pil.resize((nw, nh), Image.LANCZOS)
self._bg_tk = ImageTk.PhotoImage(img)
if self._bg_item is not None:
self.bg_canvas.delete(self._bg_item)
self._bg_item = self.bg_canvas.create_image(
cw // 2, ch // 2, image=self._bg_tk, anchor="center"
)
self.bg_canvas.tag_lower(self._bg_item)
self.bg_canvas.bind("<Configure>", _render_bg)
except Exception as e:
print(f"Error loading background: {e}")
# Navigation menu items
self.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"),
("Security", "security"),
]
self.nav_widgets = {}
self._mode_tab_widgets: dict = {}
self.deployment_mode = tk.StringVar(value=__import__("os").environ.get("KNOE_MODE", ""))
self._create_sidebar_nav()
# Validation attributes
self.validation_running = False
self.validation_thread = None
self._welcome_scan_started = False
self.splash_scan_running = False
self.splash_scan_done_at = None
self.splash_scan_started_at = None
self._splash_status_item = None
try:
self.expected_host = (platform.node() or socket.gethostname()).split(".")[0]
except Exception:
self.expected_host = None
# Canvas page state
self._canvas_page = None
self._canvas_items = []
self.canvas_renderers = {}
self._overlay_widgets = []
self._cursor_blink_after_id = None
self._cursor_blink_visible = False
self._k3s_status_after_id = None
self._k3s_status_active = False
self._k3s_status_inflight = False
self._k3s_service_status_console = None
self._k3s_service_deploy_console = None
self._k3s_service_notebook = None
self._k3s_service_status_tab = None
self._k3s_service_deploy_tab = None
self._common_services_success = False
self._all_services_green = False
self._showing_service_overlay = False
self._service_traffic_lights = {}
self._k3s_deploy_btn = None
self._last_registry_log_path = None
self._last_common_services_log_path = None
# Shared dependency catalog
self.dependencies = list(inst_config.DEPENDENCIES)
# Docker import directory
self.docker_import_dir = tk.StringVar()
# Initialize knoe.cfg data (override base with tk-aware version)
self.knoe_cfg_data = {
"Global": {},
"Welcome": {},
"Dependencies": {},
"Network": {},
"Port Forwards": {},
"System Environment": {},
"Monitoring": {},
"Kerberos Authentication": {},
"Knoe User Accounts": {},
"Ollama": {},
"Optional Features": {},
"Database Creation": {},
"Docker Build": {},
"Initialize Cluster": {},
"Cluster Nodes": {},
"Initialization Scripts": {},
"GitOps": {},
"Deployment": {},
"Dev Cluster (k3d)": {},
"Service Cluster (k3s)": {},
"Prod Cluster (k8s)": {},
"Install": {},
}
self._cfg_secret_cache = {}
self._secrets_finalized = False
# Must exist before reading knoe.cfg so saved values are applied (avoid silent fallbacks).
self.cluster_env = tk.StringVar(value="dev")
self.service_namespace = tk.StringVar(value="default")
self.gitops_enabled = tk.BooleanVar(value=False)
self.gitops_namespace = tk.StringVar(value="gitea")
self.gitops_node_selector = tk.StringVar(value="")
self.gitops_git_provider = tk.StringVar(value="Gitea")
self.argocd_enabled = tk.BooleanVar(value=False)
self.argocd_namespace = tk.StringVar(value="argocd")
self.argocd_node_selector = tk.StringVar(value="")
# Load existing docker_import_dir from knoe.cfg
try:
conf_dir = self._resolve_knoe_conf_dir()
try:
knoe_conf.ensure_entrypoint(conf_dir)
except Exception:
pass
cfg_path = knoe_conf.entrypoint_path(conf_dir)
if self._cfg_path_override:
cfg_path = self._cfg_path_override
if cfg_path.exists():
cfg = knoe_conf.load_layered_config(cfg_path)
cfg_vars = _collect_cfg_vars(cfg)
if cfg.has_section("Global"):
if "DOCKER_IMPORT_DIR" in cfg["Global"]:
docker_dir = _expand_cfg_value(
cfg["Global"]["DOCKER_IMPORT_DIR"], cfg_vars
)
self.docker_import_dir.set(docker_dir)
self.knoe_cfg_data["Global"]["DOCKER_IMPORT_DIR"] = docker_dir
saved_cluster = _expand_cfg_value(
cfg["Global"].get("CLUSTER_ENV", ""), cfg_vars
).strip()
saved_ns = _expand_cfg_value(
cfg["Global"].get("DATABASE_NAMESPACE", ""), cfg_vars
).strip()
if not saved_ns:
saved_ns = _expand_cfg_value(
cfg["Global"].get("NAMESPACE", ""), cfg_vars
).strip()
saved_cluster_name = _expand_cfg_value(
cfg["Global"].get("CLUSTER_NAME", ""), cfg_vars
).strip()
if not saved_cluster_name:
saved_cluster_name = _expand_cfg_value(
cfg["Global"].get("CNPG_CLUSTER_NAME", ""), cfg_vars
).strip()
saved_service_ns = _expand_cfg_value(
cfg["Global"].get("SERVICE_NAMESPACE", ""), cfg_vars
).strip()
saved_argocd_ns = _expand_cfg_value(
cfg["Global"].get("ARGOCD_NAMESPACE", ""), cfg_vars
).strip()
saved_gitops_node = _expand_cfg_value(
cfg["Global"].get("GITEA_NODE_SELECTOR", ""), cfg_vars
).strip()
saved_argocd_node = _expand_cfg_value(
cfg["Global"].get("ARGOCD_NODE_SELECTOR", ""), cfg_vars
).strip()
saved_gitops_ns = (
_expand_cfg_value(
cfg.get("GitOps", "GITOPS_NAMESPACE", fallback=""),
cfg_vars,
).strip()
if cfg.has_section("GitOps")
else ""
)
saved_argocd_enabled = (
_expand_cfg_value(
cfg.get(
"Optional Features",
"ARGOCD_ENABLED",
fallback="",
),
cfg_vars,
).strip()
if cfg.has_section("Optional Features")
else ""
)
saved_gitops_enabled = (
_expand_cfg_value(
cfg.get(
"Optional Features",
"GITOPS_ENABLED",
fallback="",
),
cfg_vars,
).strip()
if cfg.has_section("Optional Features")
else ""
)
if saved_cluster:
try:
self.cluster_env.set(saved_cluster)
except Exception:
pass
try:
self.knoe_cfg_data.setdefault("Global", {})[
"CLUSTER_ENV"
] = saved_cluster
except Exception:
pass
if saved_ns:
try:
self.db_namespace.set(saved_ns)
except Exception:
pass
try:
self.knoe_cfg_data.setdefault("Global", {})[
"DATABASE_NAMESPACE"
] = saved_ns
except Exception:
pass
if saved_cluster_name:
try:
self.cnpg_cluster_name.set(saved_cluster_name)
except Exception:
pass
try:
self.knoe_cfg_data.setdefault("Global", {})[
"CLUSTER_NAME"
] = saved_cluster_name
except Exception:
pass
if not saved_service_ns or saved_service_ns == "default":
# Seed knoe-system for all managed environments so the
# UI never shows the bare "default" namespace.
_env_hint = knoe_conf._env_from_mode_or_env_hint(
saved_cluster or os.environ.get("CLUSTER_ENV", "")
)
if _env_hint in ("dev", "service", "prod"):
saved_service_ns = "knoe-system"
if saved_service_ns:
try:
self.service_namespace.set(saved_service_ns)
except Exception:
pass
self.knoe_cfg_data["Global"][
"SERVICE_NAMESPACE"
] = saved_service_ns
if saved_argocd_ns:
try:
self.argocd_namespace.set(saved_argocd_ns)
except Exception:
pass
self.knoe_cfg_data.setdefault("Global", {})[
"ARGOCD_NAMESPACE"
] = saved_argocd_ns
if saved_gitops_ns:
try:
self.gitops_namespace.set(saved_gitops_ns)
except Exception:
pass
self.knoe_cfg_data.setdefault("GitOps", {})[
"NAMESPACE"
] = saved_gitops_ns
if saved_gitops_node:
try:
self.gitops_node_selector.set(saved_gitops_node)
except Exception:
pass
self.knoe_cfg_data.setdefault("Global", {})[
"GITEA_NODE_SELECTOR"
] = saved_gitops_node
if saved_argocd_node:
try:
self.argocd_node_selector.set(saved_argocd_node)
except Exception:
pass
self.knoe_cfg_data.setdefault("Global", {})[
"ARGOCD_NODE_SELECTOR"
] = saved_argocd_node
if saved_argocd_enabled:
val = saved_argocd_enabled.lower() in ("true", "1", "yes")
try:
self.argocd_enabled.set(val)
except Exception:
pass
self.knoe_cfg_data.setdefault("Optional Features", {})[
"ARGOCD_ENABLED"
] = str(val)
if saved_gitops_enabled:
val = saved_gitops_enabled.lower() in ("true", "1", "yes")
try:
self.gitops_enabled.set(val)
except Exception:
pass
self.knoe_cfg_data.setdefault("Optional Features", {})[
"GITOPS_ENABLED"
] = str(val)
if cfg.has_section("Monitoring"):
mon = {}
for k, v in cfg.items("Monitoring"):
try:
mon[k] = _expand_cfg_value(v, cfg_vars)
except Exception:
mon[k] = v
if mon:
self.knoe_cfg_data["Monitoring"] = mon
if cfg.has_section("Prod Cluster (k8s)"):
prod_sec = {}
for k, v in cfg.items("Prod Cluster (k8s)"):
try:
prod_sec[k.upper()] = _expand_cfg_value(v, cfg_vars)
except Exception:
prod_sec[k.upper()] = v
if prod_sec:
self.knoe_cfg_data["Prod Cluster (k8s)"].update(prod_sec)
gcp_cfg_path = conf_dir / "prod" / "gcp.cfg"
gcp_values = _parse_gcp_cfg(gcp_cfg_path)
if gcp_values:
self.knoe_cfg_data.setdefault("GCP", {}).update(
{k.upper(): v for k, v in gcp_values.items()}
)
# Preserve raw shell/config expressions for persistence so paths
# like `${HOME}/...` remain portable across hosts.
for section in cfg.sections():
section_data = self.knoe_cfg_data.setdefault(section, {})
for key, raw_value in cfg.items(section):
section_data[key] = _preserve_cfg_expr_for_persistence(
raw_value,
section_data.get(key, raw_value),
)
except Exception:
pass
# NOTE: Monitoring storage base paths are currently pinned in the
# monitoring deploy script/Helm values (not persisted in knoe.cfg).
self.knoe_cfg_data["Global"].setdefault(
"SERVICE_NAMESPACE", self.service_namespace.get()
)
# Disk selection variables
self.selected_disk_type = tk.StringVar(value="local")
self.selected_removable_disk = tk.StringVar()
self.selected_local_path = tk.StringVar(value=str(Path.home()))
self.removable_disks = []
# Wizard pages setup
self.pages = []
self.page_index = 0
# Cluster / k3d variables
# NOTE: cluster_env is initialized before config load; keep it stable here.
try:
if not (self.cluster_env.get() or "").strip():
self.cluster_env.set("dev")
except Exception:
self.cluster_env = tk.StringVar(value="dev")
self.k3d_cluster_name = tk.StringVar(value="knoe-dev-cluster")
self.k3d_cluster_list = tk.Variable(value=[])
self.selected_k3d_cluster = tk.StringVar()
try:
cfg_env = (self.knoe_cfg_data.get("Initialize Cluster", {}) or {}).get(
"ENVIRONMENT", ""
)
if _normalize_cluster_env(cfg_env) == "dev":
self.selected_k3d_cluster.set(cfg_env)
except Exception:
pass
self.deploy_target = tk.StringVar(
value=_deployment_target_label(self.cluster_env.get())
)
self._syncing_deploy_target = False
self._deploy_target_trace = self.deploy_target.trace_add(
"write", self._on_deploy_target_change
)
self.kubectx_list = tk.Variable(value=self._get_kubectx_list())
self.selected_kubectx = tk.StringVar()
self.app_cluster_kubecontext = tk.StringVar()
self.db_cluster_kubecontext = tk.StringVar()
try:
# Prefer explicit knoe.cfg selection; fall back to the dev-cluster section.
saved_ctx = (
(self.knoe_cfg_data.get("Global", {}) or {}).get("KUBECONTEXT", "")
or (self.knoe_cfg_data.get("Dev Cluster (k3d)", {}) or {}).get(
"KUBECTL_CONTEXT", ""
)
)
saved_ctx = (saved_ctx or "").strip()
if saved_ctx:
self.selected_kubectx.set(saved_ctx)
# Pre-populate dual App/DB context selectors from [Inputs]
inputs = self.knoe_cfg_data.get("Inputs", {}) or {}
saved_app = str(inputs.get("init_cluster.app_cluster_kubecontext") or "").strip()
saved_db = str(inputs.get("init_cluster.db_cluster_kubecontext") or "").strip()
if saved_app:
self.app_cluster_kubecontext.set(saved_app)
if saved_db:
self.db_cluster_kubecontext.set(saved_db)
except Exception:
pass
self.prod_artifacts_path = tk.StringVar(
value=str(PROJECT_ROOT / "data" / "staging")
)
saved_artifacts_dir = (
self.knoe_cfg_data.get("Prod Cluster (k8s)", {})
.get("ARTIFACTS_DIR", "")
.strip()
)
if saved_artifacts_dir:
try:
self.prod_artifacts_path.set(saved_artifacts_dir)
except Exception:
pass
# Stash GCP values so _ensure_prod_config_state can apply them lazily.
self._gcp_cfg_values = dict(self.knoe_cfg_data.get("GCP", {}))
# GitOps provider choice (gitea / argocd / none)
self.gitops_provider = tk.StringVar(value="gitea")
# Google Workspace
self.gworkspace_domain = tk.StringVar()
self.gworkspace_customer_id = tk.StringVar()
self.gworkspace_admin_email = tk.StringVar()
# Google Cloud
self.gcloud_project_id = tk.StringVar()
self.gcloud_region = tk.StringVar(value="us-central1")
self.gcloud_sa_key_path = tk.StringVar()
self.k3s_server_url = tk.StringVar()
self.k3s_token = tk.StringVar()
self.k3s_services_status = {
"registry": tk.StringVar(value="Unknown"),
"openbao": tk.StringVar(value="Unknown"),
"opentofu": tk.StringVar(value="Unknown"),
}
try:
self.db_username = tk.StringVar(value=os.getlogin())
except Exception:
self.db_username = tk.StringVar(value="knoe")
self.db_password = tk.StringVar()
self.db_password_confirm = tk.StringVar()
self.namespace_owner = self._get_local_owner()
self.db_namespace = tk.StringVar(value=self._initial_db_namespace())
self.cnpg_cluster_name = tk.StringVar(value=self._cnpg_cluster_name())
try:
_ns_from_cfg = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("DATABASE_NAMESPACE", "")
.strip()
)
if not _ns_from_cfg:
_ns_from_cfg = (
(self.knoe_cfg_data.get("Global", {}) or {})
.get("NAMESPACE", "")
.strip()
)
if _ns_from_cfg:
base = self._ensure_namespace_prefix(_ns_from_cfg)
ver = ""
try:
ver = (self.controller.get_knoe_db_version() or "").strip()
except Exception:
ver = ""
if ver:
ver_ns = self._sanitize_namespace(ver.replace(".", "-"))
base_ns = self._sanitize_namespace(base)
if base_ns.endswith(f"-{ver_ns}"):
self.db_namespace.set(base_ns)
else:
self.db_namespace.set(self._sanitize_namespace(f"{base_ns}-{ver_ns}"))
else:
self.db_namespace.set(self._sanitize_namespace(base))
except Exception:
pass
self.db_host_port = tk.StringVar(value="5432")
self._last_synced_ns = self.db_namespace.get()
self.db_namespace.trace_add("write", self._propagate_namespace_change)
# Kerberos / optional features
self.kerberos_enabled = tk.BooleanVar(value=False)
self.kerberos_realm = tk.StringVar()
self.kerberos_user = tk.StringVar(value="administrator")
self.kerberos_password = tk.StringVar()
self.kerberos_kdc = tk.StringVar()
self.supabase_enabled = tk.BooleanVar(value=False)
_global_cfg = self.knoe_cfg_data.get("Global", {}) or {}
_supabase_pv_node = (
(_global_cfg.get("SUPABASE_PV_NODE", "") or "").strip()
or (os.environ.get("SUPABASE_PV_NODE") or "").strip()
)
_supabase_pv_base = (
(_global_cfg.get("SUPABASE_PV_BASE_DIR", "") or "").strip()
or (_global_cfg.get("SUPABASE_PV_BASE", "") or "").strip()
or (os.environ.get("SUPABASE_PV_BASE_DIR") or "").strip()
or (os.environ.get("SUPABASE_PV_BASE") or "").strip()
or "/synology/d005"
)
self.supabase_pv_node = tk.StringVar(value=_supabase_pv_node)
self.supabase_pv_base_dir = tk.StringVar(value=_supabase_pv_base)
self.at_rest_encryption_enabled = tk.BooleanVar(value=True)
self.ollama_server_host = tk.StringVar()
self.ollama_server_port = tk.StringVar(value=DEFAULT_OLLAMA_PORT)
self.ollama_model = tk.StringVar()
self._ollama_servers_cache = []
self._ollama_model_options = {}
self.ansible_topology = {}
self.ansible_topology_summary = ""
try:
self._apply_ansible_topology_defaults()
except Exception:
pass
# Load encrypted secrets, apply cfg defaults
try:
self._load_secret_cache_from_cfg()
except Exception:
pass
try:
self._apply_cluster_env_default_from_cfg()
except Exception:
pass
try:
self._apply_k3s_defaults()
except Exception:
pass
# Create pages
self.page_frames = {}
self.verify_mode = tk.BooleanVar(value=False)
# Register canvas renderers
self._register_canvas_renderer("welcome", self._render_welcome_page)
self._register_canvas_renderer("network_scan", self._render_network_scan_page)
self._register_canvas_renderer("env_setup", self._render_env_setup_page)
self._register_canvas_renderer("deps_summary", self._render_deps_summary_page)
for dep in self.dependencies:
self._register_canvas_renderer(
f"dep_{dep['id']}", lambda d=dep: self._render_dependency_page(d)
)
self._register_canvas_renderer(
"kerberos_config", self._render_kerberos_config_page
)
self._register_canvas_renderer(
"knoe_users", self._render_knoe_users_page
)
self._register_canvas_renderer(
"supabase_config", self._render_supabase_config_page
)
self._register_canvas_renderer("argocd_config", self._render_argocd_config_page)
self._register_canvas_renderer("gitops_config", self._render_gitops_config_page)
self._register_canvas_renderer("init_cluster", self._render_init_cluster_page)
self._register_canvas_renderer("cluster_nodes", self._render_cluster_nodes_page)
self._register_canvas_renderer(
"common_services", self._render_common_services_page
)
self._register_canvas_renderer("init_db_build", self._render_init_db_build_page)
self._register_canvas_renderer("database_options", self._render_database_options_page)
self._register_canvas_renderer("init_password", self._render_init_password_page)
self._register_canvas_renderer(
"init_cnpg_deploy", self._render_init_cnpg_deploy_page
)
self._register_canvas_renderer("init_scripts", self._render_init_scripts_page)
self._register_canvas_renderer("build", self._render_build_page)
self._register_canvas_renderer(
"disk_selection", self._render_disk_selection_page
)
self._register_canvas_renderer("build_summary", self._render_build_summary_page)
self._register_canvas_renderer("security", self._render_security_page)
self._register_canvas_renderer(
"create_installer", self._render_create_installer_page
)
# Page ordering for navigation
self._register_page("welcome", None)
self._register_page("deps_summary", None)
for dep in self.dependencies:
self._register_page(f"dep_{dep['id']}", None)
self._register_page("network_scan", None)
self._register_page("env_setup", None)
self._register_page("init_cluster", None)
self._register_page("cluster_nodes", None)
self._register_page("common_services", None)
self._register_page("database_options", None)
self._register_page("init_db_build", None)
self._register_page("init_password", None)
self._register_page("init_scripts", None)
self._register_page("kerberos_config", None)
self._register_page("knoe_users", None)
self._register_page("argocd_config", None)
self._register_page("gitops_config", None)
self._register_page("supabase_config", None)
self._register_page("init_cnpg_deploy", None)
self._register_page("security", None)
self._register_page("build", None)
self._register_page("disk_selection", None)
self._register_page("build_summary", None)
self._register_page("create_installer", None)
# Initial render
self.root.after(
100, lambda: self.selected_local_path.set(str(self._get_knoe_dist_dir()))
)
self.update_footer()
self.show_page(0)
# ---------------------------------------------------------------------------
# Module-level helpers (kept for backward compatibility)
# ---------------------------------------------------------------------------
def has_display():
"""Check if a display is available for GUI."""
if os.environ.get("DISPLAY"):
return True
if platform.system() == "Darwin":
try:
test_root = tk.Tk()
test_root.withdraw()
test_root.destroy()
return True
except Exception:
return False
return False
def main():
import argparse
parser = argparse.ArgumentParser(description="Knoe.DB Installer")
parser.add_argument(
"-c",
"--config",
default=None,
help="Path to knoe.cfg for saving or silent replay",
)
parser.add_argument(
"-s",
"-S",
"--silent",
action="store_true",
help="Run unattended install in console mode using knoe.cfg",
)
parser.add_argument(
"-l",
"--log",
nargs="?",
const=True,
default=None,
help="Enable logging to file. Optionally provide a path (default: /tmp/knoe_install_output.log)",
)
parser.add_argument(
"--prepare-k3s-pipeline",
action="store_true",
help="Prepare OpenTofu k3s pipeline and run silent install test",
)
parser.add_argument(
"--reset",
action="store_true",
help="Reset k3d dev cluster or k3s service cluster",
)
parser.add_argument(
"--delete-db",
action="store_true",
default=False,
help=argparse.SUPPRESS,
)
parser.add_argument(
"--no-gui",
action="store_true",
help="Run installer with ncurses terminal interface",
)
parser.add_argument("--gui", action="store_true", help="Force GUI mode")
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)
parser.add_argument(
"-d", "--debug", action="store_true", help="Enable debug logging"
)
parser.add_argument(
"--repair",
action="store_true",
help="Run unattended repair flow using knoe.cfg",
)
parser.add_argument(
"--update",
action="store_true",
help="Run unattended update flow using knoe.cfg",
)
parser.add_argument(
"--min",
action="store_true",
help="Run absolute minimal installation (containerd based, no Kubernetes)",
)
parser.add_argument(
"command", nargs="?", default=None, help="Subcommand to run (e.g. monitor)"
)
parser.add_argument("command_args", nargs="*", help="Arguments for the subcommand")
args = parser.parse_args()
if args.delete_db and not args.reset:
parser.error("--delete-db requires --reset")
# Set up log file tee if --log is requested
log_file_path = None
if args.log is not None:
if args.log is True:
import tempfile
log_file_path = "/tmp/knoe_install_output.log"
else:
log_file_path = args.log
from knoe.config import setup_logging
setup_logging(verbose=args.verbose, debug=args.debug)
controller = KnoeController(
PROJECT_ROOT,
verbose=args.verbose,
cfg_path=Path(args.config).expanduser() if args.config else None,
)
if getattr(args, "min", False):
controller.state.inputs["init_cluster.cluster_env"] = "min"
# Enable Kerberos by default in min mode
controller.state.inputs["kerberos_config.enabled"] = "True"
# Skip heavier workloads
controller.state.inputs["init_cluster.supabase_enabled"] = "False"
controller.state.inputs["gitops.enabled"] = "False"
controller.state.inputs["argocd.enabled"] = "False"
if args.command == "monitor":
from knoe.core.monitor import run_monitor
pf_cfg = args.command_args[0] if args.command_args else None
sys.exit(run_monitor(controller, args.config, pf_cfg, verbose=args.verbose))
if args.repair and args.update:
parser.error("--repair and --update cannot be used together")
if args.repair or args.update:
args.silent = True
if args.silent:
ensure_op_signed_in()
ensure_knoey_vault()
installer = KnoeConsoleInstaller(
controller,
args.config,
reset_cluster=args.reset,
delete_db=args.delete_db,
)
if log_file_path:
installer.enable_log_file(log_file_path)
if args.update:
rc = installer.run_update()
elif args.repair:
rc = installer.run_repair()
else:
rc = installer.run()
if rc != 0:
sys.exit(rc)
if args.prepare_k3s_pipeline:
rc = _prepare_k3s_pipeline(controller, skip_validation=True)
sys.exit(rc)
if args.prepare_k3s_pipeline:
rc = _prepare_k3s_pipeline(controller)
sys.exit(rc)
use_gui = False
if args.gui:
use_gui = True
elif args.no_gui:
use_gui = False
else:
use_gui = has_display()
if use_gui:
try:
# Set macOS identity as early as possible
try:
setup_macos_app_identity()
except Exception:
pass
root = tk.Tk()
root.title("Knoe.DB Installer")
# Set macOS identity again AFTER tk.Tk() but BEFORE mainloop
try:
setup_macos_app_identity()
except Exception:
pass
window_width, window_height = 1000, 700
try:
sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
cx, cy = int(sw / 2 - window_width / 2), int(sh / 2 - window_height / 2)
root.geometry(f"{window_width}x{window_height}+{cx}+{cy}")
except Exception:
root.geometry(f"{window_width}x{window_height}")
ensure_op_signed_in()
ensure_knoey_vault()
KnoeInstaller(
root,
config_path=args.config,
reset_cluster=args.reset,
verbose=args.verbose,
)
root.mainloop()
except Exception as e:
print(f"Failed to start GUI: {e}", file=sys.stderr)
print("Falling back to ncurses interface...", file=sys.stderr)
time.sleep(1)
from knoe.ncurses_installer import run_ncurses_installer
run_ncurses_installer(controller)
else:
ensure_op_signed_in()
ensure_knoey_vault()
from knoe.ncurses_installer import run_ncurses_installer
run_ncurses_installer(controller)
if __name__ == "__main__":
main()