From 03d89eaa53850b361ef406ecde53540a5aa0df5e Mon Sep 17 00:00:00 2001 From: chrisfu Date: Thu, 23 Apr 2026 19:30:03 -0700 Subject: [PATCH] =?UTF-8?q?Phase=200:=20test=20pipeline=20foundation=20?= =?UTF-8?q?=E2=80=94=20pyproject.toml,=20IntelliJ=20run=20configs,=20cover?= =?UTF-8?q?age=20fix,=20welcome=20mode=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Junie --- .idea/runConfigurations/pytest_all.xml | 24 +++ .idea/runConfigurations/pytest_gke.xml | 24 +++ .idea/runConfigurations/pytest_k3d.xml | 24 +++ .idea/runConfigurations/pytest_min.xml | 24 +++ .idea/runConfigurations/pytest_unit.xml | 23 +++ knoe/ui/screens/__init__.py | 4 +- knoe/ui/screens/navigation.py | 68 ++++---- knoe/ui/screens/welcome.py | 214 +++++++++++++++++++----- pyproject.toml | 36 ++++ requirements-test.txt | 8 + tests/run_tests.sh | 22 ++- 11 files changed, 388 insertions(+), 83 deletions(-) create mode 100644 .idea/runConfigurations/pytest_all.xml create mode 100644 .idea/runConfigurations/pytest_gke.xml create mode 100644 .idea/runConfigurations/pytest_k3d.xml create mode 100644 .idea/runConfigurations/pytest_min.xml create mode 100644 .idea/runConfigurations/pytest_unit.xml create mode 100644 pyproject.toml create mode 100644 requirements-test.txt 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