"""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("", _on_frame_configure) table_canvas.bind("", _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