"""Database Options screen for selecting distribution, version and extensions.""" import os import re import threading import tkinter as tk from pathlib import Path from tkinter import ttk, messagebox import json from knoe import screen as ui from knoe.core.build_context import copy_build_context_dir from knoe.core.env import ( PROJECT_ROOT, _deployment_mode_from_env, _safe_str, get_resource_path, resolve_knoe_home, ) class DatabaseOptionsScreenMixin: """Mixin for the Database Options screen.""" _DB_BUILD_CONTEXT_READY_MARKER = ".knoe_build_context_ready" def _runtime_knoe_home(self) -> Path: knoe_home = ( getattr(self, "knoe_cfg_data", {}) .get("System Environment", {}) .get("KNOE_HOME") ) if knoe_home: try: return Path(str(knoe_home)).expanduser() except Exception: pass return Path(resolve_knoe_home()) def _db_mode_key(self) -> str: env_key = "" try: env_key = _safe_str(self.cluster_env.get()) except Exception: env_key = _safe_str(os.environ.get("CLUSTER_ENV") or "") mode = _deployment_mode_from_env(env_key) or _safe_str(os.environ.get("KNOE_MODE")) return mode or "default" def _init_database_options_state(self): # Load saved inputs (available when knoe.cfg was previously saved) _saved = getattr(self, "knoe_cfg_data", {}).get("Inputs", {}) self.db_at_rest_encryption = tk.BooleanVar(value=True) _saved_dist = _saved.get("database_options.distribution", "percona") self.db_distribution = tk.StringVar(value=_saved_dist or "percona") _saved_vtype = _saved.get("database_options.version_type", "v18") self.db_version_type = tk.StringVar(value=_saved_vtype or "v18") # Derive selected version from version_type; default to pg18 _vtype_to_ver = {"stable": "15", "current": "16", "latest": "17", "v18": "18"} self.db_selected_version = tk.StringVar( value=_vtype_to_ver.get(self.db_version_type.get(), "18") ) # Extensions state: dict of id -> BooleanVar self.db_extensions = {} self.extensions_list = [ {"id": "postgis", "name": "PostGIS", "description": "Spatial and geographic objects for PostgreSQL"}, {"id": "pgvector", "name": "pgvector", "description": "Vector similarity search"}, {"id": "pg_cron", "name": "pg_cron", "description": "Job scheduler for PostgreSQL"}, {"id": "pgcrypto", "name": "pgcrypto", "description": "Cryptographic functions", "in_contrib": True}, {"id": "pgaudit", "name": "pgAudit", "description": "Audit logging"}, {"id": "pg_repack", "name": "pg_repack", "description": "Reorganize tables with minimal locks"}, {"id": "pg_stat_statements", "name": "pg_stat_statements", "description": "Track execution statistics", "in_contrib": True}, {"id": "pg_buffercache", "name": "pg_buffercache", "description": "Examine shared buffer cache", "in_contrib": True}, {"id": "pg_freespacemap", "name": "pg_freespacemap", "description": "Examine free space map", "in_contrib": True}, {"id": "pgrowlocks", "name": "pgrowlocks", "description": "Show row-level locking information", "in_contrib": True}, {"id": "postgres_fdw", "name": "postgres_fdw", "description": "Foreign-data wrapper for PostgreSQL", "in_contrib": True}, {"id": "dblink", "name": "dblink", "description": "Connect to other databases", "in_contrib": True}, {"id": "pg_stat_monitor", "name": "pg_stat_monitor", "description": "Statistics collector (Percona only)", "percona_only": True}, {"id": "pg_tde", "name": "pg_tde", "description": "Transparent Data Encryption dependency (pre-installed)", "percona_only": True, "preinstalled": True, "locked": True, "no_extension": True}, {"id": "pgbadger", "name": "pgBadger", "description": "Log analyzer (Package only, no extension)", "no_extension": True}, ] # Default enabled extensions (all on for percona v18) default_enabled = ["postgis", "pgvector", "pg_cron", "pgcrypto", "pgaudit", "pg_repack", "pg_stat_statements", "pg_buffercache", "pg_freespacemap", "pgrowlocks", "postgres_fdw", "dblink", "pg_stat_monitor", "pg_tde", "pgbadger"] for ext in self.extensions_list: saved_key = f"database_options.ext.{ext['id']}" if saved_key in _saved: is_enabled = _saved[saved_key].lower() == "true" else: is_enabled = ext["id"] in default_enabled self.db_extensions[ext["id"]] = tk.BooleanVar(value=is_enabled) self.db_versions_data = {} self._load_database_versions() def _load_database_versions(self): # Trigger refresh script def refresh(): try: knoe_home = self.knoe_cfg_data.get("System Environment", {}).get("KNOE_HOME") if not knoe_home: knoe_home = str(resolve_knoe_home()) self.controller.run_script("init_database_options.sh", env={"KNOE_HOME": knoe_home}) # Reload after script finishes version_file = Path(knoe_home) / "conf" / "database_versions.json" if version_file.exists(): with open(version_file, "r") as f: self.db_versions_data = json.load(f) # We might need to schedule a UI refresh if the screen is already visible if hasattr(self, "bg_canvas"): self.root.after(0, self._refresh_database_options_ui) except Exception as e: print(f"Error refreshing database versions: {e}") threading.Thread(target=refresh, daemon=True).start() knoe_home = self.knoe_cfg_data.get("System Environment", {}).get("KNOE_HOME") if not knoe_home: knoe_home = str(resolve_knoe_home()) version_file = Path(knoe_home) / "conf" / "database_versions.json" if version_file.exists(): try: with open(version_file, "r") as f: self.db_versions_data = json.load(f) except Exception: pass if not self.db_versions_data: # Fallback defaults self.db_versions_data = { "postgresql": {"stable": "15", "current": "16", "latest": "17"}, "percona": {"stable": "15", "current": "16", "latest": "17", "v18": "18"} } def _render_database_options_page(self): self._render_title("Database Options", y=150) self._render_paragraph( "Configure your database distribution, version, and extensions. At-rest encryption requires Percona Distribution.", y=200, ) x_label = 48 x_field = 300 y = 260 # At-Rest Encryption self._canvas_items.append( ui.canvas_text( self, x_label, y, "At-Rest Encryption:", fill="black", font=("SF Pro Text", 12, "bold"), ) ) def on_encryption_toggle(): if self.db_at_rest_encryption.get(): self.db_distribution.set("percona") else: self.db_distribution.set("postgresql") self._refresh_database_options_ui() self._save_knoe_cfg() enc_cb = tk.Checkbutton( self.bg_canvas, text="Enabled (Uses Percona Distribution)", variable=self.db_at_rest_encryption, command=on_encryption_toggle, bg="white", fg="black", activebackground="white", selectcolor="white", highlightbackground="#F5F5DC", highlightthickness=0, relief="flat", font=("SF Pro Text", 11), ) enc_window = self.bg_canvas.create_window(x_field, y - 10, window=enc_cb, anchor="nw") self._canvas_items.append(enc_window) self._overlay_widgets.append(enc_cb) y += 50 # Distribution self._canvas_items.append( ui.canvas_text( self, x_label, y, "Distribution:", fill="black", font=("SF Pro Text", 12, "bold"), ) ) dist_label = tk.Label( self.bg_canvas, textvariable=self.db_distribution, bg="white", fg="black", font=("SF Pro Text", 11), ) dist_window = self.bg_canvas.create_window(x_field, y - 10, window=dist_label, anchor="nw") self._canvas_items.append(dist_window) self._overlay_widgets.append(dist_label) y += 50 # Version Selection (Combobox) self._canvas_items.append( ui.canvas_text( self, x_label, y, "Database Version:", fill="black", font=("SF Pro Text", 12, "bold"), ) ) self.db_version_combo = ttk.Combobox( self.bg_canvas, textvariable=self.db_selected_version, state="readonly", width=30, font=("SF Pro Text", 11), ) v_win = self.bg_canvas.create_window(x_field, y - 10, window=self.db_version_combo, anchor="nw") self._canvas_items.append(v_win) self._overlay_widgets.append(self.db_version_combo) self.db_version_combo.bind("<>", lambda _: self._save_knoe_cfg()) y += 50 # Extensions self._canvas_items.append( ui.canvas_text( self, x_label, y, "Extensions Browser:", fill="black", font=("SF Pro Text", 12, "bold"), ) ) ext_frame = tk.Frame( self.bg_canvas, bg="white", highlightbackground="#E0E0E0", highlightthickness=1, ) ext_window = self.bg_canvas.create_window( x_label, y + 30, window=ext_frame, anchor="nw", width=900, height=300, ) self._canvas_items.append(ext_window) self._overlay_widgets.append(ext_frame) # Extension table (browser style) columns = ("enabled", "name", "description", "notes") self.ext_tree = ttk.Treeview(ext_frame, columns=columns, show="headings", height=10) self.ext_tree.heading("enabled", text="Enabled") self.ext_tree.heading("name", text="Extension Name") self.ext_tree.heading("description", text="Description") self.ext_tree.heading("notes", text="Notes") self.ext_tree.column("enabled", width=80, anchor="center") self.ext_tree.column("name", width=200, anchor="w") self.ext_tree.column("description", width=420, anchor="w") self.ext_tree.column("notes", width=200, anchor="w") self.ext_tree.pack(side="left", fill="both", expand=True) scroll = ttk.Scrollbar(ext_frame, orient="vertical", command=self.ext_tree.yview) self.ext_tree.configure(yscrollcommand=scroll.set) scroll.pack(side="right", fill="y") self._overlay_widgets.append(self.ext_tree) self._overlay_widgets.append(scroll) def on_ext_click(event): item = self.ext_tree.identify_row(event.y) if item and item in self.db_extensions: ext = next((e for e in self.extensions_list if e["id"] == item), {}) if ext.get("locked"): return current = self.db_extensions[item].get() self.db_extensions[item].set(not current) self._save_knoe_cfg() self._refresh_extensions_table() self.ext_tree.bind("", on_ext_click) self._refresh_extensions_table() self._refresh_database_options_ui() def _refresh_extensions_table(self): for item in self.ext_tree.get_children(): self.ext_tree.delete(item) for ext in self.extensions_list: ext_var = self.db_extensions.get(ext["id"]) is_enabled = ext_var.get() if ext_var else False enabled = "[ ✓ ]" if is_enabled else "[ ]" notes = [] if ext.get("preinstalled"): notes.append("Pre-installed") if ext.get("locked"): notes.append("Always enabled") if ext.get("in_contrib"): notes.append("In contrib") if ext.get("percona_only"): notes.append("Percona only") if ext.get("no_extension"): notes.append("Package only") self.ext_tree.insert( "", "end", iid=ext["id"], values=(enabled, ext["name"], ext["description"], "; ".join(notes)), ) def _refresh_database_options_ui(self): dist = self.db_distribution.get() v_data = self.db_versions_data.get(dist, {}) combo_values = [] if "latest" in v_data: combo_values.append(f"{v_data['latest']} (Latest)") if "current" in v_data: combo_values.append(f"{v_data['current']} (Current)") if "stable" in v_data: combo_values.append(f"{v_data['stable']} (Stable)") if dist == "percona" and "v18" in v_data: combo_values.append(f"{v_data['v18']} (Percona 18)") if hasattr(self, "db_version_combo"): self.db_version_combo.configure(values=combo_values) # Match current selection to one of the values if possible current_ver = self.db_selected_version.get() found = False for val in combo_values: if val.startswith(current_ver): self.db_selected_version.set(val) found = True break if not found and combo_values: self.db_selected_version.set(combo_values[0]) def _generate_knoe_db_dockerfile(self): dist = _safe_str(self.db_distribution.get()) version_full = _safe_str(self.db_selected_version.get()) # Extract major version from "17 (Latest)" etc. version = version_full.split()[0] if version_full else "17" knoe_home = self._runtime_knoe_home() mode_key = self._db_mode_key() build_dir = knoe_home / "build" / mode_key / "knoe-db" build_dir.mkdir(parents=True, exist_ok=True) # Copy DB image build directory to writable location. # This avoids mutating the repo working tree and keeps k3d/k3s build contexts isolated. source_dir = get_resource_path("knoe-db") if not source_dir.exists(): source_dir = get_resource_path("knoe-db") copy_build_context_dir(source_dir, build_dir) template_file = source_dir / f"Dockerfile.{dist}.template" if not template_file.exists(): messagebox.showerror("Error", f"Template not found: {template_file}") return False template = template_file.read_text() # Prepare extension steps install_steps_list = [] create_steps_list = [] for ext in self.extensions_list: if self.db_extensions.get(ext["id"], tk.BooleanVar()).get(): ext_id = ext["id"] # Skip percona-only extensions if not percona if ext.get("percona_only") and dist != "percona": continue # 1. Handle Installation Steps if not ext.get("in_contrib"): pkg = "" if dist == "percona": if ext_id == "postgis": pkg = f"percona-postgresql-{version}-postgis-3" elif ext_id == "pg_repack": # Percona uses 'repack' instead of 'pg_repack' in package name pkg = f"percona-postgresql-{version}-repack" elif ext_id == "pg_cron": # pg_cron is bundled with Percona PG packages in this build flow. pkg = "" elif ext_id == "pg_tde": # pg_tde uses Percona-specific package naming handled separately in templates. pkg = "" elif ext_id == "pgbadger": pkg = "percona-pgbadger" elif ext_id == "pg_stat_monitor": pkg = f"percona-pg-stat-monitor{version}" else: pkg = f"percona-postgresql-{version}-{ext_id}" else: # Standard PostgreSQL if ext_id == "postgis": pkg = f"postgresql-{version}-postgis-3" elif ext_id == "pg_cron": pkg = f"postgresql-{version}-cron" elif ext_id == "pg_repack": pkg = f"postgresql-{version}-repack" elif ext_id == "pgbadger": pkg = "pgbadger" else: pkg = f"postgresql-{version}-{ext_id}" if pkg: install_steps_list.append(f" apt-get install -y --no-install-recommends {pkg};") # 2. Handle Extension Creation (SQL) if not ext.get("no_extension"): sql_name = ext_id if ext_id == "pgvector": sql_name = "vector" if sql_name == 'postgis': create_steps_list.append(' echo " CREATE EXTENSION IF NOT EXISTS postgis SCHEMA knoe;" >> /docker-entrypoint-initdb.d/20_create_extensions.sh;') create_steps_list.append(' echo " DO \\$\\$ BEGIN IF EXISTS (SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = ''postgis'' AND n.nspname <> ''knoe'') THEN ALTER EXTENSION postgis SET SCHEMA knoe; END IF; END \\$\\$;" >> /docker-entrypoint-initdb.d/20_create_extensions.sh;') create_steps_list.append(' echo " CREATE EXTENSION IF NOT EXISTS postgis_topology SCHEMA knoe;" >> /docker-entrypoint-initdb.d/20_create_extensions.sh;') elif sql_name == 'pg_tde': create_steps_list.append(' echo " CREATE EXTENSION IF NOT EXISTS pg_tde SCHEMA knoe;" >> /docker-entrypoint-initdb.d/20_create_extensions.sh;') create_steps_list.append(' echo " DO \\$\\$ BEGIN IF EXISTS (SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = ''pg_tde'' AND n.nspname <> ''knoe'') THEN ALTER EXTENSION pg_tde SET SCHEMA knoe; END IF; END \\$\\$;" >> /docker-entrypoint-initdb.d/20_create_extensions.sh;') else: create_steps_list.append(f' echo " CREATE EXTENSION IF NOT EXISTS {sql_name};" >> /docker-entrypoint-initdb.d/20_create_extensions.sh;') # Join with backslash continuation and ensure no trailing newline to avoid blank lines in Dockerfile. # If empty, we use a no-op ':' to maintain valid shell syntax across continuations. install_steps = " \\\n".join(install_steps_list) + " \\" if install_steps_list else " :; \\" create_steps = " \\\n".join(create_steps_list) + " \\" if create_steps_list else " :; \\" content = template.replace("{{MAJOR_VERSION}}", version) content = content.replace("{{EXTENSION_INSTALL_STEPS}}", install_steps) content = content.replace("{{EXTENSION_CREATE_STEPS}}", create_steps) dockerfile_path = build_dir / "Dockerfile" dockerfile_path.write_text(content) # Marker used by build steps to avoid clobbering a generated Dockerfile. (build_dir / self._DB_BUILD_CONTEXT_READY_MARKER).write_text("generated\n") # Increment version self._increment_knoe_db_version(version, knoe_home=knoe_home, mode_key=mode_key) return True def _increment_knoe_db_version(self, major_minor, *, knoe_home: Path, mode_key: str): mode_root = Path(knoe_home) / "modes" / (mode_key or "default") version_file = mode_root / "knoe-db" / ".version" version_file.parent.mkdir(parents=True, exist_ok=True) current_counter = 0 if version_file.exists(): try: val = version_file.read_text().strip() if val.isdigit(): current_counter = int(val) except Exception: pass new_counter = current_counter + 1 version_file.write_text(str(new_counter)) pg_version_file = mode_root / "conf" / "postgresql" / ".version" pg_version_file.parent.mkdir(parents=True, exist_ok=True) pg_version_file.write_text(major_minor) return f"{major_minor}-{str(new_counter).zfill(3)}"