prole/knoe/ui/screens/cluster_nodes.py
chrisfu 4ee2b259c9 Checkpoint: rename installer to knoe + harden db build context
- Add build-context helper to copy Docker context safely (ignore runtime data, keep symlinks)

- Update UI and core actions to use ~/.prole/build and shared copy helper

- Add/adjust tests and scripts; introduce knoe ops helpers and update manifests

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 01:45:21 -07:00

439 lines
16 KiB
Python

"""Cluster Nodes screen: spreadsheet-like host/service placement intent."""
from __future__ import annotations
import json
import tkinter as tk
from tkinter import messagebox
from knoe import screen as ui
class ClusterNodesScreenMixin:
"""Horizontally scrollable host management spreadsheet.
Rows = hosts, columns = capabilities/services.
Service placement is split into:
- Primary Host: where the service should mainly live (single host per service)
- Enabled Here: where the service is allowed to run (multi-host)
"""
# Minimal, explicit service catalog for placement intent.
# IDs are used to generate labels/selectors.
CLUSTER_NODE_SERVICES: list[tuple[str, str]] = [
("argocd", "ArgoCD"),
("gitlab", "GitLab"),
("gitea", "Gitea"),
("cloudnativepg", "CloudNative-PG"),
("pihole", "Pi-hole"),
]
def _render_cluster_nodes_page(self):
# Gate: only for multi-node, non-k3d.
if not getattr(self, "_should_show_cluster_nodes_screen", lambda: True)():
self._render_title("Cluster Nodes", y=150)
self._render_paragraph(
"This screen is only shown for multi-node, non-k3d clusters.",
y=200,
wrap=860,
)
ui.canvas_text(
self,
48,
260,
"Continue to the next step using the sidebar or Next.",
fill="#6e6e73",
font=("SF Pro Text", 11),
)
return
self._render_title("Cluster Nodes", y=150)
self._render_paragraph(
"Manage which hosts primarily run which services, and where services are allowed to run. "
"This intent is later used to generate labels, taints, selectors, tolerations and affinity.",
y=200,
wrap=860,
)
hosts = self._cluster_nodes_hosts()
if not hosts:
ui.canvas_text(
self,
48,
270,
"No hosts detected. Configure Ansible inventory or cluster access, then return.",
fill="#ff3b30",
font=("SF Pro Text", 11, "bold"),
)
return
policy = self._cluster_nodes_load_policy(hosts)
# Keep in instance state for navigation (Next button) saves.
self._cluster_nodes_current_policy = policy
# Spreadsheet container
x0, y0 = 48, 260
width = 900
height = 520
outer = tk.Frame(self.bg_canvas, bg="white", highlightthickness=0, bd=0)
self._overlay_widgets.append(outer)
# Canvas inside a frame for horizontal scrolling.
table_canvas = tk.Canvas(
outer,
bg="white",
highlightthickness=0,
bd=0,
)
hbar = tk.Scrollbar(outer, orient="horizontal", command=table_canvas.xview)
vbar = tk.Scrollbar(outer, orient="vertical", command=table_canvas.yview)
table_canvas.configure(xscrollcommand=hbar.set, yscrollcommand=vbar.set)
hbar.pack(side="bottom", fill="x")
vbar.pack(side="right", fill="y")
table_canvas.pack(side="left", fill="both", expand=True)
# Place in the background canvas
win = self.bg_canvas.create_window(
x0, y0, window=outer, anchor="nw", width=width, height=height
)
self._canvas_items.append(win)
table_frame = tk.Frame(table_canvas, bg="white")
inner_win = table_canvas.create_window(0, 0, window=table_frame, anchor="nw")
def _on_frame_configure(_evt=None):
try:
table_canvas.configure(scrollregion=table_canvas.bbox("all"))
except Exception:
pass
def _on_canvas_configure(_evt=None):
# Keep the inner window left-aligned.
try:
table_canvas.itemconfigure(inner_win, height=table_canvas.winfo_height())
except Exception:
pass
table_frame.bind("<Configure>", _on_frame_configure)
table_canvas.bind("<Configure>", _on_canvas_configure)
# Column layout
services = [sid for sid, _ in self.CLUSTER_NODE_SERVICES]
service_titles = {sid: title for sid, title in self.CLUSTER_NODE_SERVICES}
# Host capability columns
base_cols = [
("host", "Host"),
("protected", "Protected"),
("reserved_hostports", "Reserved Host Ports"),
]
# Precreate vars
self._cluster_nodes_vars = {
"hosts": {},
"services": {},
"_primary_vars": {},
"_enabled_vars": {},
}
for h in hosts:
self._cluster_nodes_vars["hosts"][h] = {
"protected": tk.BooleanVar(
master=self.root, value=bool(policy["hosts"].get(h, {}).get("protected"))
),
"reserved_hostports": tk.BooleanVar(
master=self.root,
value=bool(policy["hosts"].get(h, {}).get("reserved_hostports")),
),
}
for sid in services:
self._cluster_nodes_vars["_primary_vars"][sid] = {}
self._cluster_nodes_vars["_enabled_vars"][sid] = {}
svc_pol = policy["services"].get(sid, {})
enabled = set(svc_pol.get("enabled_hosts") or [])
primary = (svc_pol.get("primary_host") or "").strip()
for h in hosts:
self._cluster_nodes_vars["_enabled_vars"][sid][h] = tk.BooleanVar(
master=self.root, value=(h in enabled)
)
self._cluster_nodes_vars["_primary_vars"][sid][h] = tk.BooleanVar(
master=self.root, value=(primary == h)
)
def _set_primary(service_id: str, host: str):
# Toggle: if already primary -> clear; else set and ensure enabled.
svc = policy["services"].setdefault(service_id, {})
cur = (svc.get("primary_host") or "").strip()
new_val = "" if cur == host else host
svc["primary_host"] = new_val
if new_val:
enabled_hosts = set(svc.get("enabled_hosts") or [])
enabled_hosts.add(new_val)
svc["enabled_hosts"] = sorted(enabled_hosts)
# Sync vars
for hh in hosts:
self._cluster_nodes_vars["_primary_vars"][service_id][hh].set(hh == new_val)
if new_val:
self._cluster_nodes_vars["_enabled_vars"][service_id][new_val].set(True)
def _toggle_enabled(service_id: str, host: str):
svc = policy["services"].setdefault(service_id, {})
enabled_hosts = set(svc.get("enabled_hosts") or [])
is_enabled = bool(self._cluster_nodes_vars["_enabled_vars"][service_id][host].get())
if is_enabled:
enabled_hosts.add(host)
else:
enabled_hosts.discard(host)
if (svc.get("primary_host") or "").strip() == host:
# Clearing enabled also clears primary.
svc["primary_host"] = ""
self._cluster_nodes_vars["_primary_vars"][service_id][host].set(False)
svc["enabled_hosts"] = sorted(enabled_hosts)
# Header rows
header_font = ("SF Pro Text", 10, "bold")
cell_font = ("SF Pro Text", 10)
def _hdr(lbl: str, r: int, c: int, colspan: int = 1):
w = tk.Label(
table_frame,
text=lbl,
bg="white",
fg="black",
font=header_font,
anchor="w",
padx=6,
pady=4,
)
w.grid(row=r, column=c, columnspan=colspan, sticky="nsew")
self._overlay_widgets.append(w)
# Column widths
col = 0
for _k, _title in base_cols:
table_frame.grid_columnconfigure(col, minsize=170 if _k == "host" else 150)
col += 1
# Group headers: Primary Host and Enabled Here
primary_start = len(base_cols)
enabled_start = primary_start + len(services)
_hdr("", 0, 0, colspan=len(base_cols))
_hdr("Primary Host", 0, primary_start, colspan=len(services))
_hdr("Enabled Here", 0, enabled_start, colspan=len(services))
# Second header row
c = 0
for _k, _title in base_cols:
_hdr(_title, 1, c)
c += 1
for sid in services:
_hdr(service_titles.get(sid, sid), 1, primary_start + services.index(sid))
for sid in services:
_hdr(service_titles.get(sid, sid), 1, enabled_start + services.index(sid))
# Rows
for r, host in enumerate(hosts, start=2):
# Host label
host_lbl = tk.Label(
table_frame,
text=host,
bg="white",
fg="black",
font=cell_font,
anchor="w",
padx=6,
pady=2,
)
host_lbl.grid(row=r, column=0, sticky="nsew")
self._overlay_widgets.append(host_lbl)
# Host flags
prot = tk.Checkbutton(
table_frame,
variable=self._cluster_nodes_vars["hosts"][host]["protected"],
bg="white",
activebackground="white",
)
prot.grid(row=r, column=1, sticky="nsew")
self._overlay_widgets.append(prot)
resv = tk.Checkbutton(
table_frame,
variable=self._cluster_nodes_vars["hosts"][host]["reserved_hostports"],
bg="white",
activebackground="white",
)
resv.grid(row=r, column=2, sticky="nsew")
self._overlay_widgets.append(resv)
# Service columns
for i, sid in enumerate(services):
# Primary (toggle checkbox)
cb1 = tk.Checkbutton(
table_frame,
variable=self._cluster_nodes_vars["_primary_vars"][sid][host],
command=lambda s=sid, h=host: _set_primary(s, h),
bg="white",
activebackground="white",
)
cb1.grid(row=r, column=primary_start + i, sticky="nsew")
self._overlay_widgets.append(cb1)
# Enabled
cb2 = tk.Checkbutton(
table_frame,
variable=self._cluster_nodes_vars["_enabled_vars"][sid][host],
command=lambda s=sid, h=host: _toggle_enabled(s, h),
bg="white",
activebackground="white",
)
cb2.grid(row=r, column=enabled_start + i, sticky="nsew")
self._overlay_widgets.append(cb2)
# Save button
def _save():
if not self._validate_and_save_cluster_nodes_policy(policy):
return
try:
messagebox.showinfo("Cluster Nodes", "Saved cluster node placement policy.")
except Exception:
pass
btn = tk.Button(
self.bg_canvas,
text="Save",
command=_save,
bg="#F5F5DC",
fg="black",
activebackground="#E5E5D5",
highlightbackground="#F5F5DC",
highlightthickness=0,
relief="flat",
font=("SF Pro Text", 11),
padx=14,
pady=6,
)
btn_win = self.bg_canvas.create_window(48, 800, window=btn, anchor="nw", width=180)
self._canvas_items.append(btn_win)
self._overlay_widgets.append(btn)
def _cluster_nodes_hosts(self) -> list[str]:
topo = getattr(self, "ansible_topology", None) or {}
groups = topo.get("groups") or {}
domain = (topo.get("domain") or "").strip()
hosts: list[str] = []
k3s_hosts = groups.get("k3s_hosts") or []
if isinstance(k3s_hosts, list) and k3s_hosts:
hosts = list(k3s_hosts)
else:
ip_hosts = topo.get("hosts") or {}
if isinstance(ip_hosts, dict):
hosts = sorted(ip_hosts.keys())
# Display as FQDN if domain is known and host is not already qualified.
out: list[str] = []
seen = set()
for h in hosts:
h = (h or "").strip()
if not h:
continue
disp = h
if domain and "." not in h:
disp = f"{h}.{domain}"
if disp not in seen:
seen.add(disp)
out.append(disp)
return out
def _cluster_nodes_load_policy(self, hosts: list[str]) -> dict:
sec = (getattr(self, "prole_cfg_data", None) or {}).get("Cluster Nodes", {})
raw = (sec or {}).get("POLICY_JSON", "")
if raw:
try:
pol = json.loads(raw)
if isinstance(pol, dict):
return self._cluster_nodes_normalize_policy(pol, hosts)
except Exception:
pass
# Default: enabled everywhere, no primary.
services = {}
for sid, _title in self.CLUSTER_NODE_SERVICES:
services[sid] = {"primary_host": "", "enabled_hosts": list(hosts)}
return {
"hosts": {h: {"protected": False, "reserved_hostports": False} for h in hosts},
"services": services,
}
def _cluster_nodes_normalize_policy(self, pol: dict, hosts: list[str]) -> dict:
out = {"hosts": {}, "services": {}}
host_set = set(hosts)
for h in hosts:
hpol = ((pol.get("hosts") or {}).get(h) or {}) if isinstance(pol.get("hosts"), dict) else {}
out["hosts"][h] = {
"protected": bool(hpol.get("protected")),
"reserved_hostports": bool(hpol.get("reserved_hostports")),
}
services_in = pol.get("services") if isinstance(pol.get("services"), dict) else {}
for sid, _title in self.CLUSTER_NODE_SERVICES:
svc = (services_in.get(sid) or {}) if isinstance(services_in, dict) else {}
enabled = [h for h in (svc.get("enabled_hosts") or []) if h in host_set]
primary = (svc.get("primary_host") or "").strip()
if primary and primary not in host_set:
primary = ""
if primary and primary not in enabled:
enabled = sorted(set(enabled + [primary]))
out["services"][sid] = {"primary_host": primary, "enabled_hosts": enabled}
return out
def _validate_and_save_cluster_nodes_policy(self, policy: dict) -> bool:
# Sync host flag vars back into policy
try:
for host, vars_ in (self._cluster_nodes_vars.get("hosts") or {}).items():
policy["hosts"].setdefault(host, {})
policy["hosts"][host]["protected"] = bool(vars_["protected"].get())
policy["hosts"][host]["reserved_hostports"] = bool(vars_["reserved_hostports"].get())
except Exception:
pass
# Validate primary implies enabled
for sid, _title in self.CLUSTER_NODE_SERVICES:
svc = policy.get("services", {}).get(sid, {})
primary = (svc.get("primary_host") or "").strip()
enabled = set(svc.get("enabled_hosts") or [])
if primary and primary not in enabled:
try:
messagebox.showerror(
"Cluster Nodes",
f"Service '{sid}': Primary Host must also be Enabled Here.",
)
except Exception:
pass
return False
try:
raw = json.dumps(policy, indent=2, sort_keys=True)
except Exception as e:
try:
messagebox.showerror("Cluster Nodes", f"Could not serialize policy: {e}")
except Exception:
pass
return False
if "Cluster Nodes" not in self.prole_cfg_data:
self.prole_cfg_data["Cluster Nodes"] = {}
self.prole_cfg_data["Cluster Nodes"]["POLICY_JSON"] = raw
try:
self._save_prole_cfg()
except Exception:
pass
return True