"""prole.cfg persistence, input snapshot collection and port-forward management.""" import configparser import os import platform import socket import string import subprocess from pathlib import Path import tkinter as tk from tkinter import ttk, messagebox, filedialog from knoe import prole_conf from knoe.core.env import ( DEFAULT_ACTION_FLAGS, DEFAULT_OLLAMA_PORT, PROJECT_ROOT, _bool_str, _build_required_port_forwards, _cluster_env_radio_value, _default_opentofu_pipeline_url, _deployment_mode_from_env, _deployment_target_label, _detect_ansible_topology, _format_ansible_topology_summary, _normalize_k3s_token, _pf_extract_id, _pf_upsert_mapping, _read_k3s_cfg, _render_prole_cfg, ) from knoe.config import _collect_cfg_vars, _encrypt_cfg_secret, _expand_cfg_value class ConfigMixin: """prole.cfg persistence, input snapshot collection and port-forward management.""" # Namespace typing can trigger many trace events; debounce propagation to avoid # incremental substring rewrites across unrelated fields. NAMESPACE_PROPAGATE_DEBOUNCE_MS = 250 def _save_prole_cfg(self): """Generates prole.cfg; master configuration file containing all values used in install process.""" try: # Check port availability before saving # try: # port_val = int(self.db_host_port.get().strip()) # if not self._is_port_available(port_val): # self._show_port_error(port_val) # return # except ValueError: # messagebox.showerror("Invalid Port", "Please enter a valid numeric port.") # return # Determine path: conf/prole.cfg # If PROLE_CONF is set, use it. Otherwise fallback to PROJECT_ROOT/conf if self._cfg_path_override is not None: cfg_path = self._cfg_path_override if cfg_path.is_dir(): conf_dir = cfg_path cfg_path = conf_dir / "prole.cfg" else: conf_dir = cfg_path.parent else: prole_conf = os.environ.get("PROLE_CONF") if prole_conf: conf_dir = Path(prole_conf).expanduser() else: conf_dir = PROJECT_ROOT / "conf" os.environ.setdefault("PROLE_CONF", str(conf_dir)) cfg_path = conf_dir / "prole.cfg" conf_dir.mkdir(parents=True, exist_ok=True) # Ensure the active environment is explicit and stable via symlink entrypoint. try: prole_conf.activate_environment(conf_dir, self.cluster_env.get()) except Exception: # Best-effort: if activation fails, continue writing to legacy path. pass # Identify global candidates mode = self._deployment_mode() target_label = _deployment_target_label(self.cluster_env.get()) # New policy: do not persist default filesystem paths into prole.cfg. # Persist PROLE_HOME only when it differs from the installer default. default_home = str(Path(os.path.expanduser("~")) / "dev" / "prole") env_prole_home = (os.environ.get("PROLE_HOME") or "").strip() try: env_prole_home_norm = str(Path(env_prole_home).expanduser()) if env_prole_home else "" except Exception: env_prole_home_norm = env_prole_home prole_home_override = ( env_prole_home_norm if env_prole_home_norm and env_prole_home_norm != default_home else "" ) globals_to_save = { "PROLE_DB_USER": self.db_username.get(), "DB_PASSWORD": self._secret_cfg_value( "Global", "DB_PASSWORD", self.db_password.get(), "db", "password" ), "CLUSTER_ENV": self.cluster_env.get(), "DEPLOYMENT_MODE": mode, "DEPLOYMENT_TARGET": target_label, "NAMESPACE": (self.db_namespace.get() or "").strip(), "DB_HOST_PORT": (self.db_host_port.get() or "5432").strip(), "PROLE_OPENTOFU_URL": _default_opentofu_pipeline_url(), } if prole_home_override: globals_to_save["PROLE_HOME"] = prole_home_override # Persist the active kubectl context explicitly so shell helpers and scripts # do not rely on implicit kubectl state. try: ctx = "" if hasattr(self, "selected_kubectx"): ctx = (self.selected_kubectx.get() or "").strip() if ctx: globals_to_save["KUBECONTEXT"] = ctx except Exception: pass # Merge any other values already present in existing Global without overriding explicit UI values existing_global = dict(self.prole_cfg_data.get("Global", {})) for k, v in existing_global.items(): if k not in globals_to_save or not str(globals_to_save.get(k, "")).strip(): globals_to_save[k] = v # Ensure we never reintroduce default PROLE_HOME via existing config. try: if str(globals_to_save.get("PROLE_HOME", "")).strip() == default_home: globals_to_save.pop("PROLE_HOME", None) except Exception: pass globals_to_save.setdefault("DOCKER_PRELOAD", "false") # Re-assert dynamic/derived globals to ensure they are not overwritten by existing values globals_to_save["DB_PASSWORD"] = self._secret_cfg_value( "Global", "DB_PASSWORD", self.db_password.get(), "db", "password" ) globals_to_save["DEPLOYMENT_MODE"] = mode globals_to_save["DEPLOYMENT_TARGET"] = target_label globals_to_save["PROLE_K3S_SERVER"] = ( self.k3s_server_url.get() or "" ).strip() globals_to_save["PROLE_K3S_TOKEN"] = _encrypt_cfg_secret( self.k3s_token.get() or "" ) globals_to_save["SERVICE_NAMESPACE"] = self._get_service_namespace() self._sync_port_forward_mappings() # Inputs section (replayable UI inputs) try: inputs = self._collect_input_snapshot() except Exception: inputs = {} sections = { k: self.prole_cfg_data.get(k, {}) for k in [ "Welcome", "Dependencies", "Network", "System Environment", "Monitoring", "Kerberos Authentication", "Ollama", "Optional Features", "Database Creation", "Initialize Cluster", "Docker Build", "Initialization Scripts", "Deployment", "Install", ] } deployment_section = dict(sections.get("Deployment", {})) if mode: deployment_section.setdefault("MODE", mode) if target_label: deployment_section.setdefault("TARGET", target_label) sections["Deployment"] = deployment_section sections["Dev Cluster (k3d)"] = { **self.prole_cfg_data.get("Dev Cluster (k3d)", {}), "MODE": "k3d", "CLUSTER_ENV": "dev", "DISPLAY_NAME": "knoe-dev-cluster", "KUBECTL_CONTEXT": (self.selected_kubectx.get() or "").strip(), } sections["Service Cluster (k3s)"] = { **self.prole_cfg_data.get("Service Cluster (k3s)", {}), "MODE": "k3s", "CLUSTER_ENV": "prole-service-cluster", "DISPLAY_NAME": "prole-service-cluster", "K3S_SERVER_URL": (self.k3s_server_url.get() or "").strip(), "K3S_TOKEN": _encrypt_cfg_secret(self.k3s_token.get() or ""), "PIPELINE_URL": _default_opentofu_pipeline_url(), } sections["Prod Cluster (k8s)"] = { **self.prole_cfg_data.get("Prod Cluster (k8s)", {}), "MODE": "k8s", "CLUSTER_ENV": "prole-prod-cluster", "DISPLAY_NAME": "prole-prod-cluster", "ARTIFACTS_DIR": (self.prod_artifacts_path.get() or "").strip(), "PIPELINE_URL": _default_opentofu_pipeline_url(), } sections = self._sanitize_sections_for_cfg(sections) # Sync to Ansible Prole Vault self._save_ansible_prole_vault(self.db_password.get()) cfg_text = _render_prole_cfg(inputs, globals_to_save, sections) cfg_path.write_text(cfg_text) print(f"[DEBUG] prole.cfg saved to {cfg_path}") except Exception as e: print(f"[ERROR] Failed to save prole.cfg: {e}") def _collect_input_snapshot(self) -> dict: """Collect all possible user inputs for prole.cfg replay.""" inputs: dict[str, str] = {} def _set(key: str, val): inputs[key] = "" if val is None else str(val) def _set_bool(key: str, val: bool): inputs[key] = _bool_str(bool(val)) def _get_var(var, default=""): try: return var.get() except Exception: return default def _action(key: str, default: bool): return bool(self._action_flags.get(key, default)) # Dependencies _set_bool("dependencies.verify_all", _get_var(self.verify_mode, False)) _set_bool( "dependencies.auto_install_missing", _action( "dependencies.auto_install_missing", DEFAULT_ACTION_FLAGS.get("dependencies.auto_install_missing", True), ), ) for dep in self.dependencies: dep_key = f"dependencies.{dep['id']}.install" _set_bool(dep_key, _action(dep_key, True)) # Network scan _set_bool( "network_scan.run", _action( "network_scan.run", DEFAULT_ACTION_FLAGS.get("network_scan.run", True) ), ) # Environment setup values env_vals: dict[str, str] = {} defaults_raw: dict[str, str] = {} try: defaults_raw = dict(self._env_setup_defaults_raw()) env_vals.update(defaults_raw) except Exception: defaults_raw = {} try: env_vals.update(self._env_defaults()) except Exception: pass try: env_vals.update(self._read_existing_env()) except Exception: pass for k in ( "PROLE_HOME", "PROLE_CONF", "PROLE_DATA", "PROLE_LOGS", "PROLE_SERVICE", ): if os.environ.get(k): env_vals[k] = os.environ.get(k) if hasattr(self, "_env_entries"): for k, ent in self._env_entries.items(): try: v = ent.get().strip() if v: env_vals[k] = v except Exception: pass # New policy: store filesystem paths as resolved literals and only when # overridden (do not duplicate defaults, and never persist `$...`/`${...}`). resolved_defaults: dict[str, str] = {} resolved_now: dict[str, str] = {} try: if defaults_raw: resolved_defaults = self._resolve_env_paths_for_fs(defaults_raw) or {} resolved_now = self._resolve_env_paths_for_fs({**defaults_raw, **env_vals}) or {} else: # Best-effort fallback resolved_defaults = self._resolve_env_paths_for_fs(self._env_defaults()) or {} resolved_now = self._resolve_env_paths_for_fs(env_vals) or {} except Exception: resolved_defaults = {} resolved_now = {} for k in ( "PROLE_HOME", "PROLE_CONF", "PROLE_DATA", "PROLE_LOGS", "PROLE_SERVICE", ): rv = (resolved_now.get(k) or "").strip() dv = (resolved_defaults.get(k) or "").strip() if not rv: continue if "$" in rv: continue if dv and rv == dv: continue _set(f"env_setup.{k}", rv) ns_val = ( (str(_get_var(self.db_namespace, "")).strip()) if hasattr(self, "db_namespace") else "" ) if not ns_val: ns_val = env_vals.get("NAMESPACE", "") _set("env_setup.NAMESPACE", ns_val) # Database creation _set("init_password.db_namespace", ns_val) _set("init_password.db_username", _get_var(self.db_username, "")) db_pw = _get_var(self.db_password, "") db_pw_confirm = _get_var(self.db_password_confirm, "") or db_pw db_pw_cfg = self._secret_cfg_value( "Inputs", "init_password.db_password", db_pw, "db", "password" ) _set("init_password.db_password", db_pw_cfg) _set("init_password.db_password_confirm", db_pw_cfg or db_pw_confirm) _set("init_password.db_host_port", _get_var(self.db_host_port, "5432")) _set_bool( "init_password.generate_ssh_key", _action( "init_password.generate_ssh_key", DEFAULT_ACTION_FLAGS.get("init_password.generate_ssh_key", True), ), ) # Database options (distribution, version, extensions) _set("database_options.distribution", _get_var(self.db_distribution, "percona")) _set("database_options.version_type", _get_var(self.db_version_type, "v18")) for _ext in getattr(self, "extensions_list", []): _set_bool( f"database_options.ext.{_ext['id']}", _get_var(self.db_extensions.get(_ext["id"]), False), ) # Build DB image _set_bool( "init_db_build.run_build", _action( "init_db_build.run_build", DEFAULT_ACTION_FLAGS.get("init_db_build.run_build", True), ), ) # Cluster init + optional features _set("init_cluster.cluster_env", _get_var(self.cluster_env, "dev")) _set( "init_cluster.mode", _deployment_mode_from_env(_get_var(self.cluster_env, "dev")), ) _set( "init_cluster.deployment_target", _deployment_target_label(_get_var(self.cluster_env, "dev")), ) _set("init_cluster.k3s_server_url", _get_var(self.k3s_server_url, "")) _set( "init_cluster.k3s_token", _encrypt_cfg_secret(_get_var(self.k3s_token, "")) ) _set_bool( "init_cluster.supabase_enabled", _get_var(self.supabase_enabled, False) ) _set_bool("init_cluster.gitops_enabled", _get_var(self.gitops_enabled, False)) _set_bool( "init_cluster.argocd_enabled", _get_var(self.argocd_enabled, False) ) _set_bool( "init_cluster.kerberos_enabled", _get_var(self.kerberos_enabled, False) ) _set_bool( "init_cluster.at_rest_encryption_enabled", _get_var(self.at_rest_encryption_enabled, False), ) _set_bool( "init_cluster.start_cluster", _action( "init_cluster.start_cluster", DEFAULT_ACTION_FLAGS.get("init_cluster.start_cluster", True), ), ) # Kerberos config _set_bool("kerberos_config.enabled", _get_var(self.kerberos_enabled, False)) _set("kerberos_config.realm", _get_var(self.kerberos_realm, "")) _set("kerberos_config.kdc", _get_var(self.kerberos_kdc, "")) _set("kerberos_config.user", _get_var(self.kerberos_user, "")) krb_pw = _get_var(self.kerberos_password, "") _set( "kerberos_config.password", self._secret_cfg_value( "Inputs", "kerberos_config.password", krb_pw, "kerberos", "password" ), ) _set_bool( "kerberos_config.test_connection", _action( "kerberos_config.test_connection", DEFAULT_ACTION_FLAGS.get("kerberos_config.test_connection", False), ), ) # Ollama config _set("ollama_config.server_host", _get_var(self.ollama_server_host, "")) _set( "ollama_config.server_port", _get_var(self.ollama_server_port, DEFAULT_OLLAMA_PORT), ) _set("ollama_config.model", _get_var(self.ollama_model, "")) # Init scripts + deploy _set_bool( "init_scripts.run_scripts", _action( "init_scripts.run_scripts", DEFAULT_ACTION_FLAGS.get("init_scripts.run_scripts", True), ), ) _set_bool( "init_cnpg_deploy.run_deploy", _action( "init_cnpg_deploy.run_deploy", DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.run_deploy", True), ), ) _set_bool( "init_cnpg_deploy.force_rollout", _action( "init_cnpg_deploy.force_rollout", DEFAULT_ACTION_FLAGS.get("init_cnpg_deploy.force_rollout", False), ), ) # GitOps _set("gitops.namespace", _get_var(self.gitops_namespace, "gitea")) # ArgoCD _set("argocd.namespace", _get_var(self.argocd_namespace, "argocd")) # Disk selection (installer packaging) _set("disk_selection.disk_type", _get_var(self.selected_disk_type, "local")) _set( "disk_selection.removable_mount", _get_var(self.selected_removable_disk, "") ) _set( "disk_selection.local_path", _get_var(self.selected_local_path, str(Path.home())), ) # Build tools app _set("build.deploy_env", getattr(self, "deploy_env_value", "Dev")) _set_bool( "build.run_build", _action( "build.run_build", DEFAULT_ACTION_FLAGS.get("build.run_build", False) ), ) return inputs def _sync_port_forward_mappings(self): try: mode = self._deployment_mode() except Exception: mode = "" if not mode: try: mode = _deployment_mode_from_env(self.cluster_env.get()) except Exception: mode = "" if not mode: mode = "k3d" prefix = ( "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" ) pf_section = self.prole_cfg_data.get("Port Forwards", {}) if pf_section is None: pf_section = {} try: service_ns = (self._get_service_namespace() or "").strip() except Exception: service_ns = "" if not service_ns: service_ns = "default" argocd_ns = ( (self.prole_cfg_data.get("Global", {}) or {}) .get("ARGOCD_NAMESPACE", "") .strip() ) if not argocd_ns: argocd_ns = (os.environ.get("ARGOCD_NAMESPACE") or "").strip() if not argocd_ns: argocd_ns = "argocd" try: db_ns = (self.db_namespace.get() or "").strip() except Exception: db_ns = "" if not db_ns: db_ns = ( (self.prole_cfg_data.get("Global", {}) or {}) .get("NAMESPACE", "") .strip() ) if not db_ns: db_ns = (os.environ.get("NAMESPACE") or "").strip() if not db_ns: db_ns = "default" try: db_host_port = (self.db_host_port.get() or "").strip() except Exception: db_host_port = "" if not db_host_port: db_host_port = ( (self.prole_cfg_data.get("Global", {}) or {}) .get("DB_HOST_PORT", "") .strip() ) if not db_host_port: db_host_port = "5432" supabase_enabled = False try: supabase_enabled = bool(self.supabase_enabled.get()) except Exception: supabase_enabled = False supabase_ns = (os.environ.get("SUPABASE_NAMESPACE") or "").strip() if not supabase_ns: supabase_ns = ( (self.prole_cfg_data.get("Supabase", {}) or {}) .get("NAMESPACE", "") .strip() ) if not supabase_ns: supabase_ns = "supabase" gitops_enabled = False try: gitops_enabled = bool(self.gitops_enabled.get()) except Exception: gitops_enabled = False gitops_ns = (os.environ.get("GITEA_NAMESPACE") or "").strip() if not gitops_ns: gitops_ns = ( (self.prole_cfg_data.get("GitOps", {}) or {}) .get("GITOPS_NAMESPACE", "") .strip() ) if not gitops_ns: gitops_ns = (self.gitops_namespace.get() or "").strip() or "gitea" mappings = _build_required_port_forwards( mode=mode, service_ns=service_ns, argocd_ns=argocd_ns, db_ns=db_ns, db_host_port=db_host_port, supabase_enabled=supabase_enabled, supabase_namespace=supabase_ns, gitops_enabled=gitops_enabled, gitops_namespace=gitops_ns, ) for mapping in mappings: _pf_upsert_mapping(pf_section, prefix, mapping) self.prole_cfg_data["Port Forwards"] = pf_section self._update_legacy_port_mapping() def _update_legacy_port_mapping(self): conf_dir = PROJECT_ROOT / "conf" mapping_path = conf_dir / "port-mapping.cfg" pf_section = self.prole_cfg_data.get("Port Forwards", {}) if pf_section is None: pf_section = {} try: mode = self._deployment_mode() except Exception: mode = "k3d" prefix = ( "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" ) # Resolve ${NAMESPACE} for port-mapping entries try: db_ns = self.db_namespace.get().strip() except Exception: db_ns = "" if not db_ns: db_ns = ( (self.prole_cfg_data.get("Global", {}) or {}) .get("NAMESPACE", "") .strip() ) if not db_ns: db_ns = "default" lines = [ "# Port mappings for Prole Tools (generated).", "# Format: key: local=... remote=... ns=... svc=... address=...", "", ] # We want to maintain some order or just dump them for k, v in sorted(pf_section.items()): if k.startswith(prefix): # Parse the mapping string: id=...;namespace=...;target=...;address=...;hostPort=...;servicePort=...;protocol=...;description=... parts = {} for p in str(v).split(";"): if "=" in p: key_val = p.split("=", 1) if len(key_val) == 2: parts[key_val[0].strip()] = key_val[1].strip() if "id" in parts: m_id = parts["id"] local = parts.get("hostPort", "") remote = parts.get("servicePort", "") ns = parts.get("namespace", "") target = parts.get("target", "") addr = parts.get("address", "0.0.0.0") # Resolve ${NAMESPACE} references if ns.startswith("${") and "NAMESPACE" in ns: ns = db_ns # Sanitize: skip entries with non-string or mock values vals = [m_id, local, remote, ns, target, addr] if any( "MagicMock" in str(x) or not isinstance(x, str) for x in vals ): continue if local.startswith("${"): continue svc = target if svc.startswith("svc/"): svc = svc[4:] lines.append( f"{m_id}: local={local} remote={remote} ns={ns} svc={svc} address={addr}" ) try: mapping_path.write_text("\n".join(lines) + "\n") print(f"[DEBUG] port-mapping.cfg updated at {mapping_path}") except Exception as e: print(f"[ERROR] Failed to update port-mapping.cfg: {e}") def _add_port_mapping(self, mapping_str): mode = self._deployment_mode() prefix = ( "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" ) pf_section = self.prole_cfg_data.get("Port Forwards", {}) if pf_section is None: pf_section = {} if not _pf_upsert_mapping(pf_section, prefix, mapping_str): return self.prole_cfg_data["Port Forwards"] = pf_section self._save_prole_cfg() def _process_script_output_line(self, line): if "GRAFANA_ADMIN_PASSWORD=" in line: pwd = line.split("GRAFANA_ADMIN_PASSWORD=")[1].strip() if pwd: mon = self.prole_cfg_data.get("Monitoring", {}) if mon is None: mon = {} mon["GRAFANA_ADMIN_PASSWORD"] = pwd self.prole_cfg_data["Monitoring"] = mon self._save_prole_cfg() if "PORT_FORWARD_MAPPING:" in line: mapping = line.split("PORT_FORWARD_MAPPING:")[1].strip() if mapping: if _pf_extract_id(mapping) == "registry": return self._add_port_mapping(mapping) def _is_port_available(self, port: int) -> bool: """Check if a port is available on localhost.""" import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: s.bind(("127.0.0.1", port)) return True except socket.error: return False def _pf_extract_field(self, mapping_str: str, field: str) -> str: if not mapping_str or not field: return "" needle = f"{field}=" for part in str(mapping_str).split(";"): part = part.strip() if part.startswith(needle): return part[len(needle) :].strip() return "" def _validate_port_forward_overlaps(self) -> bool: try: self._sync_port_forward_mappings() except Exception: pass try: mode = self._deployment_mode() except Exception: mode = "" if not mode: try: mode = _deployment_mode_from_env(self.cluster_env.get()) except Exception: mode = "" if not mode: mode = "k3d" prefix = ( "PORT_FORWARD_K3S_MAPPING_" if mode == "k3s" else "PORT_FORWARD_K3D_MAPPING_" ) pf_section = self.prole_cfg_data.get("Port Forwards", {}) or {} port_index = {} for key, mapping in pf_section.items(): if not key.startswith(prefix): continue host_port = self._pf_extract_field(mapping, "hostPort") if not host_port: continue mapping_id = _pf_extract_id(mapping) target = self._pf_extract_field(mapping, "target") namespace = self._pf_extract_field(mapping, "namespace") label = mapping_id or target or key if namespace and target: label = f"{label} ({namespace}/{target})" port_index.setdefault(host_port, []).append(label) collisions = { port: labels for port, labels in port_index.items() if len(labels) > 1 } if not collisions: return True lines = [] for port, labels in sorted( collisions.items(), key=lambda item: int(item[0]) if str(item[0]).isdigit() else item[0], ): lines.append(f"{port}: {', '.join(sorted(labels))}") try: messagebox.showerror( "Port Collision", "Port forward mappings overlap:\n\n" + "\n".join(lines) + "\n\nPlease choose unique host ports before continuing.", ) except Exception: pass return False def _show_port_error(self, port: int): """Show error message when port is in use, with option to see lsof output.""" msg = f"Port {port} is already in use on the local host.\n\nPlease choose a different port or stop the service using it." dialog = tk.Toplevel(self.root) dialog.title("Port In Use") dialog.geometry("450x200") dialog.configure(bg="white") dialog.transient(self.root) dialog.grab_set() tk.Label( dialog, text="Port Conflict", font=("SF Pro Text", 14, "bold"), bg="white", fg="#ff3b30", ).pack(pady=(20, 10)) tk.Label( dialog, text=msg, font=("SF Pro Text", 11), bg="white", fg="black", wraplength=400, ).pack(pady=10) btn_frame = tk.Frame(dialog, bg="white") btn_frame.pack(pady=20) def show_lsof(): log_file = PROJECT_ROOT / "logs" / f"port_{port}_lsof.log" log_file.parent.mkdir(parents=True, exist_ok=True) try: res = subprocess.run( ["lsof", "-i", f":{port}"], capture_output=True, text=True ) content = ( res.stdout if res.stdout else f"No lsof output for port {port}. (Maybe permission denied?)" ) if res.stderr: content += "\n\nError:\n" + res.stderr log_file.write_text(content) # Open the log file if platform.system() == "Darwin": subprocess.run(["open", str(log_file)]) elif platform.system() == "Windows": os.startfile(str(log_file)) else: subprocess.run(["xdg-open", str(log_file)]) except Exception as e: messagebox.showerror("Error", f"Failed to run lsof: {e}") tk.Button( btn_frame, text="Show Logs (lsof)", command=show_lsof, bg="#F5F5DC", relief="flat", padx=10, ).pack(side="left", padx=10) tk.Button( btn_frame, text="OK", command=dialog.destroy, bg="#007aff", fg="white", relief="flat", padx=20, ).pack(side="left", padx=10) def _apply_ansible_topology_defaults(self): info = _detect_ansible_topology(PROJECT_ROOT) if not info: return self.ansible_topology = info self.ansible_topology_summary = _format_ansible_topology_summary(info) try: net = self.prole_cfg_data.get("Network", {}) if info.get("topology_json"): net["ANSIBLE_TOPOLOGY"] = info["topology_json"] if info.get("inventory_path"): net["ANSIBLE_INVENTORY"] = info["inventory_path"] if info.get("infrastructure_path"): net["ANSIBLE_INFRASTRUCTURE"] = info["infrastructure_path"] if info.get("domain"): net["ANSIBLE_DOMAIN"] = info["domain"] if info.get("realm"): net["ANSIBLE_REALM"] = info["realm"] if info.get("ad_dc_host"): net["AD_DC_HOST"] = info["ad_dc_host"] if info.get("ad_dc_ip"): net["AD_DC_IP"] = info["ad_dc_ip"] if info.get("kdc_ip"): net["KDC_ANSIBLE_DETECTED"] = info["kdc_ip"] self.prole_cfg_data["Network"] = net except Exception: pass try: if not self.kerberos_kdc.get().strip() and info.get("kdc_ip"): self.kerberos_kdc.set(info["kdc_ip"]) self.kerberos_enabled.set(True) if not self.kerberos_realm.get().strip() and info.get("realm"): self.kerberos_realm.set(info["realm"]) except Exception: pass def _read_k3s_cfg_values(self) -> tuple[str, str, str]: """Return (cluster_env, server_url, token) from prole.cfg if present. Delegates to the shared ``_read_k3s_cfg`` so every presentation layer (Tk, ncurses, silent) parses the same cfg sections. """ cfg_path = None try: if self._cfg_path_override is not None: cfg_path = self._cfg_path_override if cfg_path.is_dir(): cfg_path = cfg_path / "prole.cfg" else: cfg_path = self._resolve_prole_conf_dir() / "prole.cfg" except Exception: cfg_path = None return _read_k3s_cfg(cfg_path) def _apply_cluster_env_default_from_cfg(self): cfg_env, _cfg_server, _cfg_token = self._read_k3s_cfg_values() if not cfg_env: return try: current = (self.cluster_env.get() or "").strip() except Exception: current = "" if not current or current == "dev": try: self.cluster_env.set(_cluster_env_radio_value(cfg_env)) except Exception: pass self._set_deploy_target_from_cluster_env() def _apply_k3s_defaults(self): # Environment overrides env_server = ( os.environ.get("PROLE_K3S_SERVER") or os.environ.get("K3S_SERVER_URL") or "" ).strip() env_token = ( os.environ.get("PROLE_K3S_TOKEN") or os.environ.get("K3S_TOKEN") or "" ).strip() env_token = _normalize_k3s_token(env_token) if env_server and not self.k3s_server_url.get().strip(): self.k3s_server_url.set(env_server) if env_token and not self.k3s_token.get().strip(): self.k3s_token.set(env_token) # Config defaults _cfg_env, cfg_server, cfg_token = self._read_k3s_cfg_values() if cfg_server and not self.k3s_server_url.get().strip(): self.k3s_server_url.set(cfg_server) if cfg_token and not self.k3s_token.get().strip(): self.k3s_token.set(cfg_token) # Ansible defaults info = getattr(self, "ansible_topology", None) or _detect_ansible_topology( PROJECT_ROOT ) if info: if info.get("k3s_server_url") and not self.k3s_server_url.get().strip(): self.k3s_server_url.set(info["k3s_server_url"]) token_val = _normalize_k3s_token(info.get("k3s_token")) if token_val and not self.k3s_token.get().strip(): self.k3s_token.set(token_val) def _propagate_namespace_change(self, *args): """Reacts to namespace change by updating dependent inputs and config files. This is triggered by a `tk.StringVar` trace while the user types. We debounce the propagation so we only apply a single stable transition from the last synced namespace to the final value (instead of rewriting on each keystroke). """ try: new_ns = (self.db_namespace.get() or "").strip() except Exception: return if not new_ns: return # If there is no Tk root (e.g. some test harnesses), fall back to immediate. root = getattr(self, "root", None) if root is None: self._apply_pending_namespace_change(new_ns) return # Debounce: cancel any in-flight scheduled propagation and reschedule. after_id = getattr(self, "_namespace_propagate_after_id", None) if after_id: try: root.after_cancel(after_id) except Exception: pass self._pending_namespace_value = new_ns delay_ms = getattr(self, "_namespace_debounce_ms", None) if delay_ms is None: delay_ms = getattr(self, "NAMESPACE_PROPAGATE_DEBOUNCE_MS", 250) try: self._namespace_propagate_after_id = root.after( int(delay_ms), self._apply_pending_namespace_change ) except Exception: # If scheduling fails, apply immediately. self._apply_pending_namespace_change(new_ns) def _apply_pending_namespace_change(self, namespace: str | None = None): """Apply the pending namespace propagation using a stable old namespace.""" # Clear the timer id if we were invoked from `after`. try: self._namespace_propagate_after_id = None except Exception: pass if namespace is None: try: namespace = (getattr(self, "_pending_namespace_value", "") or "").strip() except Exception: namespace = "" new_ns = (namespace or "").strip() if not new_ns: return old_ns = (getattr(self, "_last_synced_ns", "") or "").strip() if not old_ns: # Initialize sync baseline and persist. try: self._last_synced_ns = new_ns except Exception: pass if new_ns == old_ns: return # Propagate to inputs and StringVars (e.g. OpenBao paths) if old_ns: # Do not rewrite independent namespaces. exclude_var_names = { "db_namespace", "service_namespace", "gitops_namespace", "argocd_namespace", } # 1. Sweep StringVars for attr_name in dir(self): if attr_name in exclude_var_names: continue try: attr = getattr(self, attr_name) if isinstance(attr, tk.StringVar) and attr != self.db_namespace: val = attr.get() if val and old_ns in val: attr.set(val.replace(old_ns, new_ns)) except Exception: pass # 2. Sweep inputs if hasattr(self, "inputs"): for k, v in list(self.inputs.items()): if not isinstance(v, str) or old_ns not in v: continue lk = (k or "").lower() if "service_namespace" in lk or lk.endswith("service_namespace"): continue self.inputs[k] = v.replace(old_ns, new_ns) # Update environment and save prole.cfg try: self._last_synced_ns = new_ns except Exception: pass try: self._update_env_namespace(new_ns) # Re-save prole.cfg so it's always in sync self._save_prole_cfg() except Exception: pass