Phase 0: test pipeline foundation — pyproject.toml, IntelliJ run configs, coverage fix, welcome mode selector

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
chrisfu 2026-04-23 19:30:03 -07:00
parent 22906ee656
commit 03d89eaa53
11 changed files with 388 additions and 83 deletions

View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — all (with coverage)" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="--cov=knoe --cov-report=html --cov-report=term-missing tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — gke mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="gke" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m gke --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — k3d mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="k3d" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m k3d --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — min mode" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
<env name="KNOE_MODE" value="min" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m min --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,23 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="pytest — unit (fast, no deps)" type="PythonConfigurationType" factoryName="Python">
<module name="knoe-db" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONPATH" value="$PROJECT_DIR$" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="-m pytest" />
<option name="PARAMETERS" value="-m &quot;unit and not integration&quot; --cov=knoe --cov-report=term-missing -v tests/" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@ -390,6 +390,8 @@ class KnoeInstaller(
) )
self.project_root = PROJECT_ROOT self.project_root = PROJECT_ROOT
self.cfg_path = resolved_cfg 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_shared_state()
self._init_database_options_state() self._init_database_options_state()
self.screens = None self.screens = None
@ -525,7 +527,7 @@ class KnoeInstaller(
] ]
self.nav_widgets = {} self.nav_widgets = {}
self._mode_tab_widgets: dict = {} 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() self._create_sidebar_nav()
# Validation attributes # Validation attributes

View File

@ -262,44 +262,23 @@ class NavigationMixin:
except Exception: except Exception:
pass 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: def _set_deployment_mode(self, mode: str) -> None:
"""Switch the active deployment mode and persist it to the environment.""" """Switch the active deployment mode and persist it to the environment."""
self.deployment_mode.set(mode) self.deployment_mode.set(mode)
os.environ["KNOE_MODE"] = mode os.environ["KNOE_MODE"] = mode
self._refresh_mode_tabs()
def _refresh_mode_tabs(self) -> None: def _refresh_mode_tabs(self) -> None:
"""Update tab highlight colours to reflect the active deployment mode.""" """No-op: mode tabs replaced by welcome screen card selector."""
active = self.deployment_mode.get() pass
for value, widget in self._mode_tab_widgets.items():
if value == active: def _is_min_mode(self) -> bool:
widget.configure(bg=self._MODE_COLORS.get(value, "#888"), fg="white") """Return True when the user has selected minimal (containerd-only) mode."""
else: return self.deployment_mode.get() == "min"
widget.configure(bg="#D5D5C5", fg="#555")
def _create_sidebar_nav(self): def _create_sidebar_nav(self):
"""Create the left-hand navigation menu.""" """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("<Button-1>", lambda e, v=value: self._set_deployment_mode(v))
self._mode_tab_widgets[value] = btn
self._refresh_mode_tabs()
# ── INSTALLER label ────────────────────────────────────────────── # ── INSTALLER label ──────────────────────────────────────────────
tk.Label( tk.Label(
self.sidebar, self.sidebar,
@ -375,7 +354,10 @@ class NavigationMixin:
self.show_page(seq[-1] if seq else "deps_summary") self.show_page(seq[-1] if seq else "deps_summary")
return return
if current_id == "env_setup": 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 return
if current_id == "init_cluster": if current_id == "init_cluster":
if getattr(self, "_showing_service_overlay", False): if getattr(self, "_showing_service_overlay", False):
@ -397,7 +379,10 @@ class NavigationMixin:
self.show_page("common_services") self.show_page("common_services")
return return
if current_id == "init_password": 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 return
if current_id == "init_db_build": if current_id == "init_db_build":
self.show_page("database_options") self.show_page("database_options")
@ -426,6 +411,10 @@ class NavigationMixin:
if current_id == "init_cnpg_deploy": if current_id == "init_cnpg_deploy":
self.show_page("supabase_config") self.show_page("supabase_config")
return return
if current_id == "security":
if self._is_min_mode():
self.show_page("init_scripts")
return
if current_id == "create_installer": if current_id == "create_installer":
self.show_page("init_cnpg_deploy") self.show_page("init_cnpg_deploy")
return return
@ -492,6 +481,9 @@ class NavigationMixin:
return return
if current_id == "network_scan": if current_id == "network_scan":
if self._is_min_mode():
self.show_page("env_setup")
return
# Capture network scan info # Capture network scan info
try: try:
self.knoe_cfg_data["Network"][ self.knoe_cfg_data["Network"][
@ -593,7 +585,10 @@ class NavigationMixin:
except Exception: except Exception:
pass pass
return return
self.show_page("init_cluster") if self._is_min_mode():
self.show_page("init_password")
else:
self.show_page("init_cluster")
return return
if current_id == "init_password": if current_id == "init_password":
@ -678,7 +673,9 @@ class NavigationMixin:
"Completed" if getattr(self, "_scripts_success", False) else "Attempted" "Completed" if getattr(self, "_scripts_success", False) else "Attempted"
) )
self._save_knoe_cfg() 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") self.show_page("kerberos_config")
else: else:
self.show_page("argocd_config") self.show_page("argocd_config")
@ -885,7 +882,12 @@ class NavigationMixin:
# Page-specific adjustments # Page-specific adjustments
pid = self.pages[self.page_index][0] 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 # Build page: show Build or Next depending on state
if getattr(self, "_built_success", False): if getattr(self, "_built_success", False):
self.next_button.configure(text="Next") self.next_button.configure(text="Next")

View File

@ -1,68 +1,198 @@
"""Welcome / splash screen.""" """Welcome / splash screen."""
import os
import threading import threading
import time import time
import tkinter as tk import tkinter as tk
from tkinter import ttk, messagebox, filedialog from tkinter import ttk, messagebox, filedialog
from knoe import screen as ui 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: class WelcomeScreenMixin:
"""Welcome / splash screen.""" """Welcome / splash screen."""
def _render_welcome_page(self): def _render_welcome_page(self):
# Letterhead at top right content_width = self.bg_canvas.winfo_width() or 975
content_width = self.bg_canvas.winfo_width() or 975 # 1300 * 0.75 approx
right_margin = content_width - 48 right_margin = content_width - 48
ui.canvas_text( # Letterhead
self, ui.canvas_text(self, right_margin, 40, "knoe.dev",
right_margin, fill="#6e6e73", font=("SF Pro Text", 32, "bold"), anchor="ne")
40, ui.canvas_text(self, right_margin, 85, "infrastructure.auto()",
"knoe.dev", fill="#6e6e73", font=("SF Pro Text", 18), anchor="ne")
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 to Knoe.DB", y=145)
self._render_title("Welcome", y=150)
welcome_text = ( intro = (
"This installer will guide you through the process of setting up the Knoe Database and its supporting " "Knoe.DB Installer sets up a production-grade PostgreSQL cluster with Kerberos "
"infrastructure. We have designed this process to be as automated as possible, ensuring that your " "authentication.\nPick the mode that matches your hardware — we'll walk you through every step."
"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."
) )
ui.canvas_text(self, 48, 200, intro,
fill="#1d1d1f", font=("SF Pro Text", 13), width=800)
ui.canvas_text( # Mode selector cards
self, card_w = 185
48, card_h = 200
220, card_gap = 18
welcome_text, card_x0 = 48
fill="black", card_y0 = 265
font=("SF Pro Text", 13),
width=750, 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, "<Button-1>",
lambda e, m=mode: self._on_welcome_card_click(m),
)
self.bg_canvas.tag_bind(
item_id, "<Enter>",
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, "<Leave>",
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() 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): def _start_welcome_dependency_scan(self):
self.splash_scan_running = True self.splash_scan_running = True
self.splash_scan_done_at = None self.splash_scan_done_at = None

36
pyproject.toml Normal file
View File

@ -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"

8
requirements-test.txt Normal file
View File

@ -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

View File

@ -1,17 +1,25 @@
#!/bin/bash #!/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 export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}."
if pytest --trace-config | grep -q "pytest_cov"; then
if python -c "import pytest_cov" 2>/dev/null; then
echo "Running tests with coverage..." 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=$? 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 exit $RET
else else
echo "Warning: pytest-cov not found. Running tests without coverage." 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/ pytest tests/
fi fi