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

View File

@ -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("<Button-1>", 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,6 +354,9 @@ class NavigationMixin:
self.show_page(seq[-1] if seq else "deps_summary")
return
if current_id == "env_setup":
if self._is_min_mode():
self.show_page("deps_summary")
else:
self.show_page("network_scan")
return
if current_id == "init_cluster":
@ -397,6 +379,9 @@ class NavigationMixin:
self.show_page("common_services")
return
if current_id == "init_password":
if self._is_min_mode():
self.show_page("env_setup")
else:
self.show_page("init_db_build")
return
if current_id == "init_db_build":
@ -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,6 +585,9 @@ class NavigationMixin:
except Exception:
pass
return
if self._is_min_mode():
self.show_page("init_password")
else:
self.show_page("init_cluster")
return
@ -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")

View File

@ -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",
# 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")
self._render_title("Welcome to Knoe.DB", y=145)
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,
right_margin,
85,
"infrastructure.auto()",
fill="#6e6e73",
font=("SF Pro Text", 18),
anchor="ne",
ui.canvas_text(self, 48, 200, intro,
fill="#1d1d1f", font=("SF Pro Text", 13), width=800)
# 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, "<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"
),
)
# Welcome title
self._render_title("Welcome", y=150)
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."
# "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)
ui.canvas_text(
self,
48,
220,
welcome_text,
fill="black",
font=("SF Pro Text", 13),
width=750,
# 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

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