mirror of
https://github.com/dredx/prole.git
synced 2026-09-27 06:44:29 +00:00
- 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>
439 lines
16 KiB
Python
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
|