diff --git a/.idea/runConfigurations/pytest_all.xml b/.idea/runConfigurations/pytest_all.xml
new file mode 100644
index 0000000..7da729f
--- /dev/null
+++ b/.idea/runConfigurations/pytest_all.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/runConfigurations/pytest_gke.xml b/.idea/runConfigurations/pytest_gke.xml
new file mode 100644
index 0000000..858be99
--- /dev/null
+++ b/.idea/runConfigurations/pytest_gke.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/runConfigurations/pytest_k3d.xml b/.idea/runConfigurations/pytest_k3d.xml
new file mode 100644
index 0000000..d94d551
--- /dev/null
+++ b/.idea/runConfigurations/pytest_k3d.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/runConfigurations/pytest_min.xml b/.idea/runConfigurations/pytest_min.xml
new file mode 100644
index 0000000..12e00f6
--- /dev/null
+++ b/.idea/runConfigurations/pytest_min.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/runConfigurations/pytest_unit.xml b/.idea/runConfigurations/pytest_unit.xml
new file mode 100644
index 0000000..c0f7d1f
--- /dev/null
+++ b/.idea/runConfigurations/pytest_unit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/knoe/ui/screens/__init__.py b/knoe/ui/screens/__init__.py
index d762f60..74c2e6b 100644
--- a/knoe/ui/screens/__init__.py
+++ b/knoe/ui/screens/__init__.py
@@ -390,6 +390,8 @@ class KnoeInstaller(
)
self.project_root = PROJECT_ROOT
self.cfg_path = resolved_cfg
+ from knoe.core.env import DEFAULT_ACTION_FLAGS
+ self._action_flags: dict[str, bool] = DEFAULT_ACTION_FLAGS.copy()
self._init_shared_state()
self._init_database_options_state()
self.screens = None
@@ -525,7 +527,7 @@ class KnoeInstaller(
]
self.nav_widgets = {}
self._mode_tab_widgets: dict = {}
- self.deployment_mode = tk.StringVar(value=__import__("os").environ.get("KNOE_MODE", "k3s"))
+ self.deployment_mode = tk.StringVar(value=__import__("os").environ.get("KNOE_MODE", ""))
self._create_sidebar_nav()
# Validation attributes
diff --git a/knoe/ui/screens/navigation.py b/knoe/ui/screens/navigation.py
index 8490825..2e7cbd5 100644
--- a/knoe/ui/screens/navigation.py
+++ b/knoe/ui/screens/navigation.py
@@ -262,44 +262,23 @@ class NavigationMixin:
except Exception:
pass
- _MODE_COLORS = {"k3d": "#4A90D9", "k3s": "#27AE60", "k8s": "#E67E22"}
+ _MODE_COLORS = {"min": "#8E44AD", "k3d": "#4A90D9", "k3s": "#27AE60", "gke": "#E67E22"}
def _set_deployment_mode(self, mode: str) -> None:
"""Switch the active deployment mode and persist it to the environment."""
self.deployment_mode.set(mode)
os.environ["KNOE_MODE"] = mode
- self._refresh_mode_tabs()
def _refresh_mode_tabs(self) -> None:
- """Update tab highlight colours to reflect the active deployment mode."""
- active = self.deployment_mode.get()
- for value, widget in self._mode_tab_widgets.items():
- if value == active:
- widget.configure(bg=self._MODE_COLORS.get(value, "#888"), fg="white")
- else:
- widget.configure(bg="#D5D5C5", fg="#555")
+ """No-op: mode tabs replaced by welcome screen card selector."""
+ pass
+
+ def _is_min_mode(self) -> bool:
+ """Return True when the user has selected minimal (containerd-only) mode."""
+ return self.deployment_mode.get() == "min"
def _create_sidebar_nav(self):
"""Create the left-hand navigation menu."""
- # ── Deployment mode tab strip ────────────────────────────────────
- mode_frame = tk.Frame(self.sidebar, bg="#F5F5DC")
- mode_frame.pack(fill="x", padx=12, pady=(14, 4))
- for label, value in [("k3d", "k3d"), ("k3s", "k3s"), ("k8s", "k8s")]:
- btn = tk.Label(
- mode_frame,
- text=label,
- bg="#D5D5C5",
- fg="#555",
- font=("SF Pro Text", 9, "bold"),
- padx=8,
- pady=3,
- cursor="hand2",
- )
- btn.pack(side="left", padx=2)
- btn.bind("", lambda e, v=value: self._set_deployment_mode(v))
- self._mode_tab_widgets[value] = btn
- self._refresh_mode_tabs()
-
# ── INSTALLER label ──────────────────────────────────────────────
tk.Label(
self.sidebar,
@@ -375,7 +354,10 @@ class NavigationMixin:
self.show_page(seq[-1] if seq else "deps_summary")
return
if current_id == "env_setup":
- self.show_page("network_scan")
+ if self._is_min_mode():
+ self.show_page("deps_summary")
+ else:
+ self.show_page("network_scan")
return
if current_id == "init_cluster":
if getattr(self, "_showing_service_overlay", False):
@@ -397,7 +379,10 @@ class NavigationMixin:
self.show_page("common_services")
return
if current_id == "init_password":
- self.show_page("init_db_build")
+ if self._is_min_mode():
+ self.show_page("env_setup")
+ else:
+ self.show_page("init_db_build")
return
if current_id == "init_db_build":
self.show_page("database_options")
@@ -426,6 +411,10 @@ class NavigationMixin:
if current_id == "init_cnpg_deploy":
self.show_page("supabase_config")
return
+ if current_id == "security":
+ if self._is_min_mode():
+ self.show_page("init_scripts")
+ return
if current_id == "create_installer":
self.show_page("init_cnpg_deploy")
return
@@ -492,6 +481,9 @@ class NavigationMixin:
return
if current_id == "network_scan":
+ if self._is_min_mode():
+ self.show_page("env_setup")
+ return
# Capture network scan info
try:
self.knoe_cfg_data["Network"][
@@ -593,7 +585,10 @@ class NavigationMixin:
except Exception:
pass
return
- self.show_page("init_cluster")
+ if self._is_min_mode():
+ self.show_page("init_password")
+ else:
+ self.show_page("init_cluster")
return
if current_id == "init_password":
@@ -678,7 +673,9 @@ class NavigationMixin:
"Completed" if getattr(self, "_scripts_success", False) else "Attempted"
)
self._save_knoe_cfg()
- if self.kerberos_enabled.get():
+ if self._is_min_mode():
+ self.show_page("security")
+ elif self.kerberos_enabled.get():
self.show_page("kerberos_config")
else:
self.show_page("argocd_config")
@@ -885,7 +882,12 @@ class NavigationMixin:
# Page-specific adjustments
pid = self.pages[self.page_index][0]
- if pid == "build":
+ if pid == "welcome":
+ if not self.deployment_mode.get():
+ self.next_button.configure(state="disabled")
+ else:
+ self.next_button.configure(state="normal")
+ elif pid == "build":
# Build page: show Build or Next depending on state
if getattr(self, "_built_success", False):
self.next_button.configure(text="Next")
diff --git a/knoe/ui/screens/welcome.py b/knoe/ui/screens/welcome.py
index 2adcbda..496f688 100644
--- a/knoe/ui/screens/welcome.py
+++ b/knoe/ui/screens/welcome.py
@@ -1,68 +1,198 @@
"""Welcome / splash screen."""
+import os
import threading
import time
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from knoe import screen as ui
+_WELCOME_CARDS = [
+ {
+ "mode": "min",
+ "title": "Just a Database",
+ "body": "One knoe-db container via containerd.\nNo Kubernetes needed. Perfect for\ndeveloping a Spring app locally.",
+ "badge": "Homebrew + 1Password",
+ "color": "#8E44AD",
+ },
+ {
+ "mode": "k3d",
+ "title": "Local Cluster",
+ "body": "k3s-in-Docker cluster on your Mac.\nFull CNPG database — add Supabase,\nArgoCD, Gitea or GitLab.",
+ "badge": "Docker + Homebrew + 1Password",
+ "color": "#4A90D9",
+ },
+ {
+ "mode": "k3s",
+ "title": "Homelab",
+ "body": "Multi-node k3s on real hardware.\nFull Kerberos auth stack, Garage S3\nand monitoring.",
+ "badge": "k3sup + Homebrew + 1Password",
+ "color": "#27AE60",
+ },
+ {
+ "mode": "gke",
+ "title": "Production",
+ "body": "Dual GKE clusters on Google Cloud.\nCNPG + GCS backups and Workload\nIdentity.",
+ "badge": "gcloud + 1Password",
+ "color": "#E67E22",
+ },
+]
+
+_MODE_TO_CLUSTER_ENV = {"min": "min", "k3d": "dev", "k3s": "service", "gke": "prod"}
+
+_MODE_NEEDS = {
+ "min": "You'll need: Homebrew + 1Password",
+ "k3d": "You'll need: Docker, Homebrew + 1Password",
+ "k3s": "You'll need: k3sup, Homebrew + 1Password",
+ "gke": "You'll need: gcloud CLI + 1Password",
+}
+
class WelcomeScreenMixin:
"""Welcome / splash screen."""
def _render_welcome_page(self):
- # Letterhead at top right
- content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx
+ content_width = self.bg_canvas.winfo_width() or 975
right_margin = content_width - 48
- ui.canvas_text(
- self,
- right_margin,
- 40,
- "knoe.dev",
- fill="#6e6e73",
- font=("SF Pro Text", 32, "bold"),
- anchor="ne",
- )
- ui.canvas_text(
- self,
- right_margin,
- 85,
- "infrastructure.auto()",
- fill="#6e6e73",
- font=("SF Pro Text", 18),
- anchor="ne",
- )
+ # Letterhead
+ ui.canvas_text(self, right_margin, 40, "knoe.dev",
+ fill="#6e6e73", font=("SF Pro Text", 32, "bold"), anchor="ne")
+ ui.canvas_text(self, right_margin, 85, "infrastructure.auto()",
+ fill="#6e6e73", font=("SF Pro Text", 18), anchor="ne")
- # Welcome title
- self._render_title("Welcome", y=150)
+ self._render_title("Welcome to Knoe.DB", y=145)
- welcome_text = (
- "This installer will guide you through the process of setting up the Knoe Database and its supporting "
- "infrastructure. We have designed this process to be as automated as possible, ensuring that your "
- "deployment is secure, efficient, and tailored to your specific network environment.\n\n"
- "What to expect:\n"
- "• Network Environment Discovery: We'll scan for existing services like Active Directory and DNS.\n"
- "• System Configuration: Setting up local paths and environment variables.\n"
- "• Dependency Management: Ensuring all required tools (Docker, k3d, etc.) are ready.\n"
- "• Database Initialization: Configuring passwords, Kerberos authentication, and deploying the database cluster.\n\n"
- "We are excited to have you join our community and start building with us. "
- "Welcome to the neighborhood! Let's get started by preparing your system for the Knoe experience."
+ intro = (
+ "Knoe.DB Installer sets up a production-grade PostgreSQL cluster with Kerberos "
+ "authentication.\nPick the mode that matches your hardware — we'll walk you through every step."
)
+ ui.canvas_text(self, 48, 200, intro,
+ fill="#1d1d1f", font=("SF Pro Text", 13), width=800)
- ui.canvas_text(
- self,
- 48,
- 220,
- welcome_text,
- fill="black",
- font=("SF Pro Text", 13),
- width=750,
+ # Mode selector cards
+ card_w = 185
+ card_h = 200
+ card_gap = 18
+ card_x0 = 48
+ card_y0 = 265
+
+ self._welcome_card_rects = {}
+
+ for i, card in enumerate(_WELCOME_CARDS):
+ x0 = card_x0 + i * (card_w + card_gap)
+ x1 = x0 + card_w
+ y0 = card_y0
+ y1 = card_y0 + card_h
+
+ bg = self.bg_canvas.create_rectangle(
+ x0, y0, x1, y1, fill="white", outline="#CCCCCC", width=2,
+ )
+ self._canvas_items.append(bg)
+
+ bar = self.bg_canvas.create_rectangle(
+ x0 + 1, y0 + 1, x1 - 1, y0 + 6, fill=card["color"], outline="",
+ )
+ self._canvas_items.append(bar)
+
+ mode_lbl = self.bg_canvas.create_text(
+ x0 + 14, y0 + 18, text=card["mode"].upper(),
+ fill=card["color"], font=("SF Pro Text", 9, "bold"), anchor="nw",
+ )
+ self._canvas_items.append(mode_lbl)
+
+ title_lbl = self.bg_canvas.create_text(
+ x0 + 14, y0 + 36, text=card["title"],
+ fill="#1d1d1f", font=("SF Pro Text", 13, "bold"), anchor="nw",
+ )
+ self._canvas_items.append(title_lbl)
+
+ body_lbl = self.bg_canvas.create_text(
+ x0 + 14, y0 + 62, text=card["body"],
+ fill="#555555", font=("SF Pro Text", 11), anchor="nw",
+ width=card_w - 28,
+ )
+ self._canvas_items.append(body_lbl)
+
+ badge_lbl = self.bg_canvas.create_text(
+ x0 + 14, y1 - 26, text=card["badge"],
+ fill=card["color"], font=("SF Pro Text", 9), anchor="nw",
+ width=card_w - 28,
+ )
+ self._canvas_items.append(badge_lbl)
+
+ self._welcome_card_rects[card["mode"]] = bg
+
+ mode = card["mode"]
+ for item_id in (bg, bar, mode_lbl, title_lbl, body_lbl, badge_lbl):
+ self.bg_canvas.tag_bind(
+ item_id, "",
+ lambda e, m=mode: self._on_welcome_card_click(m),
+ )
+ self.bg_canvas.tag_bind(
+ item_id, "",
+ lambda e, r=bg, m=mode: self.bg_canvas.itemconfig(
+ r, outline=self._MODE_COLORS.get(m, "#4A90D9")
+ if self.deployment_mode.get() != m else None
+ ),
+ )
+ self.bg_canvas.tag_bind(
+ item_id, "",
+ lambda e, r=bg, m=mode: self.bg_canvas.itemconfig(
+ r, outline=self._MODE_COLORS.get(m, "#4A90D9")
+ if self.deployment_mode.get() == m else "#CCCCCC"
+ ),
+ )
+
+ # "You'll need" status line
+ status_y = card_y0 + card_h + 20
+ self._splash_status_item = self.bg_canvas.create_text(
+ 48, status_y,
+ text="← Select a mode above to continue",
+ fill="#8B8B7A", font=("SF Pro Text", 12, "italic"), anchor="nw",
)
+ self._canvas_items.append(self._splash_status_item)
+
+ # Restore highlight if a mode was already chosen (e.g. loaded from config)
+ current = self.deployment_mode.get()
+ if current and current in self._welcome_card_rects:
+ self._highlight_welcome_card(current)
+ try:
+ self.bg_canvas.itemconfig(
+ self._splash_status_item,
+ text=_MODE_NEEDS.get(current, ""),
+ )
+ except Exception:
+ pass
- # Ensure footer is updated (Next button visible)
self.update_footer()
+ def _on_welcome_card_click(self, mode: str) -> None:
+ self.deployment_mode.set(mode)
+ os.environ["KNOE_MODE"] = mode
+ try:
+ self.cluster_env.set(_MODE_TO_CLUSTER_ENV.get(mode, "dev"))
+ except Exception:
+ pass
+ self._highlight_welcome_card(mode)
+ try:
+ if self._splash_status_item is not None:
+ self.bg_canvas.itemconfig(
+ self._splash_status_item,
+ text=_MODE_NEEDS.get(mode, ""),
+ )
+ except Exception:
+ pass
+ self.update_footer()
+
+ def _highlight_welcome_card(self, selected_mode: str) -> None:
+ for m, rect_id in getattr(self, "_welcome_card_rects", {}).items():
+ if m == selected_mode:
+ color = self._MODE_COLORS.get(m, "#4A90D9")
+ self.bg_canvas.itemconfig(rect_id, outline=color, width=3)
+ else:
+ self.bg_canvas.itemconfig(rect_id, outline="#CCCCCC", width=2)
+
def _start_welcome_dependency_scan(self):
self.splash_scan_running = True
self.splash_scan_done_at = None
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8996e5d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,36 @@
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+addopts = "-ra --tb=short"
+
+markers = [
+ "unit: pure unit test — no external services, no cluster, runs anywhere",
+ "integration: requires a running service, cluster, or network dependency",
+ "min: tests the min (containerd) deployment path",
+ "k3d: tests the k3d (local Docker cluster) deployment path",
+ "k3s: tests the k3s (homelab) deployment path",
+ "gke: tests the GKE (production) deployment path",
+]
+
+[tool.coverage.run]
+source = ["knoe"]
+branch = true
+omit = [
+ "tests/*",
+ "*/__init__.py",
+ "knoe/ui/*", # UI layer tested via integration only
+]
+
+[tool.coverage.report]
+show_missing = true
+skip_covered = false
+# Raised incrementally as each pipeline phase lands — see docs/pipeline-phases.md Appendix C
+fail_under = 0
+
+[tool.coverage.xml]
+output = "coverage.xml"
+
+[tool.coverage.html]
+directory = "htmlcov"
diff --git a/requirements-test.txt b/requirements-test.txt
new file mode 100644
index 0000000..094a6a6
--- /dev/null
+++ b/requirements-test.txt
@@ -0,0 +1,8 @@
+# Test-only dependencies — install alongside requirements.txt in CI and local dev
+# pip install -r requirements.txt -r requirements-test.txt
+
+pytest>=7.4
+pytest-cov>=4.1
+pytest-mock>=3.11
+coverage[toml]>=7.3
+vulture>=2.10
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index 500e688..15d31f8 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -1,17 +1,25 @@
#!/bin/bash
-# Run all unit tests and generate a coverage report
+# Run all unit tests and generate a coverage report.
+# Coverage config is in pyproject.toml [tool.coverage.*].
-export PYTHONPATH=$PYTHONPATH:.
+set -euo pipefail
-# Check if pytest-cov is installed
-if pytest --trace-config | grep -q "pytest_cov"; then
+export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}."
+
+if python -c "import pytest_cov" 2>/dev/null; then
echo "Running tests with coverage..."
- pytest --cov=knoe --cov=install --cov-report=term-missing --cov-report=html tests/
+ pytest \
+ --cov=knoe \
+ --cov-report=term-missing \
+ --cov-report=html \
+ --cov-report=xml \
+ tests/
RET=$?
- echo "Coverage report (HTML) generated in htmlcov/index.html"
+ echo "HTML coverage report: htmlcov/index.html"
+ echo "XML coverage report: coverage.xml"
exit $RET
else
echo "Warning: pytest-cov not found. Running tests without coverage."
- echo "To enable coverage, install it via: pip install pytest-cov"
+ echo "Install it with: pip install -r requirements-test.txt"
pytest tests/
fi