From 03d89eaa53850b361ef406ecde53540a5aa0df5e Mon Sep 17 00:00:00 2001 From: chrisfu Date: Thu, 23 Apr 2026 19:30:03 -0700 Subject: [PATCH 01/12] =?UTF-8?q?Phase=200:=20test=20pipeline=20foundation?= =?UTF-8?q?=20=E2=80=94=20pyproject.toml,=20IntelliJ=20run=20configs,=20co?= =?UTF-8?q?verage=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 From d3520d4837d991fecace17fc57a9888e9b92b744 Mon Sep 17 00:00:00 2001 From: chrisfu Date: Thu, 23 Apr 2026 19:30:14 -0700 Subject: [PATCH 02/12] docs: update Phase 0 commit summary and resumption checklist Co-authored-by: Junie --- docs/pipeline-phases.md | 757 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 757 insertions(+) create mode 100644 docs/pipeline-phases.md diff --git a/docs/pipeline-phases.md b/docs/pipeline-phases.md new file mode 100644 index 0000000..39d491d --- /dev/null +++ b/docs/pipeline-phases.md @@ -0,0 +1,757 @@ +# Knoe.DB Autobuild & Test Pipeline — Phase Reference + +> **How to use this document** +> Each phase has a **Status** line, a **Commit Summary** section (filled in after the phase lands), and a **Resumption Checklist** so any developer can pick up where we left off without needing context from a previous session. Update the Status and Commit Summary before starting the next phase. + +--- + +## Context + +The project has **four deployment modes** that must each produce a stable, reproducible build: + +| Mode | What it is | CI tool | +|---|---|---| +| `min` | Single `knoe-db` container via containerd, no Kubernetes | Gitea Actions (bare macOS runner) | +| `k3d` | k3s-in-Docker local cluster — CNPG + optional Supabase / ArgoCD / Gitea / GitLab | Gitea Actions (Docker-enabled runner) | +| `k3s` | Homelab multi-node k3s on physical hardware | Deferred (evaluate after GitLab) | +| `gke` | Dual GKE clusters on Google Cloud (production) | GitLab CI | + +**Primary goal:** auto-build on every push to `main` so we have confidence that builds are stable before we touch anything else. + +**Secondary goal:** once we have stable builds, identify unused code with `vulture` + coverage diff, then prune toward an *instructable* codebase — a project a junior developer can read and fully understand. + +--- + +## Phase 0 — Foundation + +**Status:** ✅ Complete +**Branch / PRs:** *(fill in after merge)* + +### What this phase does + +Fixes the broken test infrastructure so IntelliJ can discover tests and `make test` actually measures the right code. No new tests are written here — just scaffolding that every subsequent phase depends on. + +### Deliverables + +| File | Change | +|---|---| +| `pyproject.toml` | Single source of truth for pytest config + coverage config. Replaces the broken `.coveragerc` (which pointed to `installer/` — a directory that no longer exists) | +| `requirements-test.txt` | Explicit test dependencies (pytest, coverage, vulture, etc.) separated from runtime deps | +| `.idea/runConfigurations/pytest_all.xml` | IntelliJ: run all tests with coverage | +| `.idea/runConfigurations/pytest_unit.xml` | IntelliJ: run unit-only tests (fast, no external deps) | +| `.idea/runConfigurations/pytest_min.xml` | IntelliJ: run tests tagged `@pytest.mark.min` | +| `.idea/runConfigurations/pytest_k3d.xml` | IntelliJ: run tests tagged `@pytest.mark.k3d` | +| `.idea/runConfigurations/pytest_gke.xml` | IntelliJ: run tests tagged `@pytest.mark.gke` | +| `tests/run_tests.sh` | Fix `--cov` source from `install` to `knoe` | + +### Commit Summary + +0052a4d Phase 0: test pipeline foundation — pyproject.toml, IntelliJ run configs, coverage fix, welcome mode selector + +### Known pre-existing issues (fix in Phase 1, not Phase 0) + +`knoe/core/ops/cloudnative_pg.py:1372` contains: +```python +f"jsonpath={{.data.{field.replace('.', '\\.')}}}", +``` +Python ≥ 3.12 allows backslashes in f-strings (PEP 701) but this still triggers a `SyntaxError` on Python 3.14 under certain parse modes. This causes **30 test collection errors** — the tests themselves are not broken. Fix: extract the replacement to a variable before the f-string. Tagged as Phase 1 work. + +### Resumption Checklist + +Before picking up work on Phase 1, verify: + +- [x] `pytest tests/` collects 441+ tests from the project root +- [x] `pytest -m unit` runs and reports a coverage number against `knoe/` +- [x] IntelliJ shows the five run configs in the Run/Debug Configurations dropdown +- [x] `python -c "from knoe.ui.screens import KnoeInstaller"` succeeds +- [x] The 30 collection errors in `cloudnative_pg.py` are logged as Phase 1 work + +--- + +## Phase 1 — `min` Mode Pipeline + +**Status:** 🔲 Not started +**Depends on:** Phase 0 complete + +### What this phase does + +Establishes the first green autobuild. The `min` pipeline is the simplest possible CI: a bare macOS runner (no Docker, no Kubernetes) running pytest. It exercises the welcome-screen mode selector, the min-mode navigation fast-path, and the `init_min.sh` script. Trigger: push to `main` on the Gitea remote. + +### Architecture + +``` +push to main (Gitea) + │ + ▼ +Gitea Actions + │ + ▼ +act_runner ←── bare macOS (dev machine or Mac mini) + │ labels: [self-hosted, macos, min] + ▼ requires: Homebrew + Python 3.11 +pytest -m "min or unit" + │ + ▼ +coverage.xml ──► uploaded as artifact +``` + +**Runner setup (one-time, not in CI):** +```bash +# On the macOS runner machine: +brew install gitea-act-runner +act_runner register \ + --instance https:/// \ + --token \ + --labels "self-hosted,macos,min" \ + --name "knoe-min-runner" +act_runner daemon +``` + +### Deliverables + +| File | Purpose | +|---|---| +| `.gitea/workflows/ci-min.yml` | Gitea Actions workflow — push-triggered, runs on bare macOS runner | +| `tests/modes/__init__.py` | Package marker | +| `tests/modes/test_min_mode.py` | Mode-specific tests for min path | +| `tests/modes/conftest.py` | Mode fixtures (mock containerd, mock 1Password CLI) | + +### `.gitea/workflows/ci-min.yml` + +```yaml +name: CI — min mode + +on: + push: + branches: [main] + +jobs: + min-unit: + runs-on: [self-hosted, macos, min] + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + pip install -r requirements.txt -r requirements-test.txt + + - name: Run min + unit tests + run: | + pytest -m "min or unit" \ + --cov=knoe \ + --cov-report=xml \ + --cov-report=term-missing \ + -v \ + tests/ + env: + PYTHONPATH: ${{ github.workspace }} + KNOE_MODE: min + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-min + path: coverage.xml + retention-days: 14 +``` + +### Tests to write in `tests/modes/test_min_mode.py` + +Each test name is intentionally human-readable — they become living documentation for junior developers. + +| Test | What it proves | +|---|---| +| `test_welcome_mode_selector_starts_with_no_selection` | `deployment_mode` defaults to `""` on fresh install — forces explicit choice | +| `test_clicking_min_card_sets_cluster_env_to_min` | Card click wires `cluster_env = "min"` correctly | +| `test_clicking_min_card_enables_next_button` | Welcome Next is disabled until a card is clicked | +| `test_min_nav_env_setup_goes_to_init_password_not_cluster` | Verifies min fast-path in `on_next` | +| `test_min_nav_init_scripts_goes_to_security_not_kerberos` | Min skips Kerberos, ArgoCD, GitOps, Supabase | +| `test_min_nav_prev_from_init_password_returns_to_env_setup` | Symmetric back-navigation | +| `test_min_nav_prev_from_security_returns_to_init_scripts` | Symmetric back-navigation | +| `test_normalize_cluster_env_min_returns_min` | `_normalize_cluster_env("min") == "min"` | +| `test_deployment_mode_from_env_min_returns_min` | `_deployment_mode_from_env("min") == "min"` | +| `test_init_min_sh_runs_without_error` | `subprocess` call to `init_min.sh initialize` exits 0 (macOS only, `@pytest.mark.min`) | + +### Commit Summary + +*(Fill in after phase lands)* + +``` +# example: +# abc1234 Phase 1: add .gitea/workflows/ci-min.yml +# def5678 Phase 1: add tests/modes/test_min_mode.py +# ghi9012 Phase 1: add tests/modes/conftest.py +``` + +### Resumption Checklist + +- [ ] Push to `main` triggers `ci-min` workflow in Gitea Actions UI +- [ ] `pytest -m "min or unit"` exits 0 locally +- [ ] All 10 tests in `test_min_mode.py` pass +- [ ] Coverage artifact appears in the Gitea Actions run summary +- [ ] Badge shows green on Gitea repo homepage (optional but nice) + +--- + +## Phase 2 — `k3d` Mode Pipeline + +**Status:** 🔲 Not started +**Depends on:** Phase 1 complete + +### What this phase does + +Adds the k3d integration pipeline — a Docker-enabled runner that creates a real k3d cluster, deploys CNPG, verifies the database is reachable, then tears it down. This is the first pipeline that proves an actual database cluster starts correctly. Estimated runtime: 8–15 minutes. + +### Architecture + +``` +push to main (Gitea) + │ + ▼ +Gitea Actions + │ + ├─ job: k3d-unit (fast, no cluster) ─────────────► coverage-k3d-unit.xml + │ + └─ job: k3d-integration (depends on unit) ──────► coverage-k3d-integration.xml + │ + ├── k3d cluster create knoe-ci + ├── apply CNPG operator + ├── apply knoe-db Cluster CR + ├── pytest -m "k3d and integration" + └── k3d cluster delete knoe-ci (always) +``` + +**Runner setup (one-time):** +Same `act_runner` binary but registered with labels `self-hosted,macos,k3d` on a Docker-enabled machine (Docker Desktop or Colima). + +### Deliverables + +| File | Purpose | +|---|---| +| `.gitea/workflows/ci-k3d.yml` | Two-job workflow: unit then integration | +| `tests/modes/test_k3d_mode.py` | k3d unit + integration tests | +| `tests/modes/fixtures/knoe-db-test.yaml` | Minimal CNPG `Cluster` CR for test use (single instance, tiny storage) | +| `tests/modes/fixtures/cnpg-operator-values.yaml` | Minimal Helm values for CNPG operator in CI | + +### `.gitea/workflows/ci-k3d.yml` + +```yaml +name: CI — k3d mode + +on: + push: + branches: [main] + +jobs: + k3d-unit: + runs-on: [self-hosted, macos, k3d] + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: "3.11" } + - run: pip install -r requirements.txt -r requirements-test.txt + - name: Unit tests (k3d, no cluster) + run: pytest -m "k3d and unit" --cov=knoe --cov-report=xml tests/ + env: + PYTHONPATH: ${{ github.workspace }} + KNOE_MODE: k3d + - uses: actions/upload-artifact@v4 + with: { name: coverage-k3d-unit, path: coverage.xml } + + k3d-integration: + runs-on: [self-hosted, macos, k3d] + needs: k3d-unit + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: { python-version: "3.11" } + - run: pip install -r requirements.txt -r requirements-test.txt + + - name: Install k3d + kubectl + run: | + brew install k3d kubectl + + - name: Create test cluster + run: | + k3d cluster create knoe-ci \ + --agents 1 \ + --k3s-arg '--disable=traefik@server:0' \ + --wait + kubectl cluster-info --context k3d-knoe-ci + + - name: Deploy CNPG operator + run: | + kubectl apply --server-side \ + -f https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/main/releases/cnpg-1.23.0.yaml + kubectl wait --for=condition=Available \ + deployment/cnpg-controller-manager \ + -n cnpg-system --timeout=120s + + - name: Integration tests (live cluster) + run: | + pytest -m "k3d and integration" \ + --cov=knoe \ + --cov-report=xml \ + -v \ + tests/ + env: + PYTHONPATH: ${{ github.workspace }} + KNOE_MODE: k3d + KUBECONFIG: ${{ env.HOME }}/.kube/config + + - name: Tear down cluster + if: always() + run: k3d cluster delete knoe-ci + + - uses: actions/upload-artifact@v4 + with: { name: coverage-k3d-integration, path: coverage.xml } +``` + +### Tests to write in `tests/modes/test_k3d_mode.py` + +**Unit tests** (`@pytest.mark.k3d @pytest.mark.unit`): + +| Test | What it proves | +|---|---| +| `test_k3d_cluster_env_normalizes_to_dev` | `_normalize_cluster_env("dev") == "dev"` | +| `test_welcome_k3d_card_sets_cluster_env_dev` | Card click → `cluster_env = "dev"` | +| `test_k3d_nav_proceeds_through_cluster_screens` | k3d mode does NOT skip `init_cluster` | +| `test_k3d_supabase_option_visible` | Supabase toggle is reachable in k3d nav flow | +| `test_cluster_lifecycle_milestone_uses_k3d_script` | `ClusterLifecycleMilestone` calls k3d cluster-create command for `dev` env | + +**Integration tests** (`@pytest.mark.k3d @pytest.mark.integration`): + +| Test | What it proves | +|---|---| +| `test_k3d_cluster_api_is_reachable` | kubectl can reach `k3d-knoe-ci` API server | +| `test_cnpg_operator_crds_registered` | `Cluster` CRD exists after operator deploy | +| `test_knoe_db_cluster_pod_starts` | Apply test CR → at least one PostgreSQL pod reaches `Running` | +| `test_database_accepts_connections` | psql `SELECT 1` succeeds against the CNPG service | +| `test_cluster_delete_is_clean` | After delete, no k3d cluster named `knoe-ci` remains | + +### Commit Summary + +*(Fill in after phase lands)* + +### Resumption Checklist + +- [ ] Push to `main` triggers both `k3d-unit` and `k3d-integration` jobs in Gitea +- [ ] `k3d-integration` passes (PostgreSQL pod reaches Running state) +- [ ] Both coverage artifacts appear in the run summary +- [ ] `k3d cluster list` shows no leftover `knoe-ci` cluster after the run + +--- + +## Phase 3 — GKE Pipeline (GitLab CI, expanded) + +**Status:** 🔲 Not started +**Depends on:** Phase 2 complete + +### What this phase does + +Replaces the current single-job `.gitlab-ci.yml` (which only runs `install.sh -S`) with a proper multi-stage pipeline: lint → unit tests → GKE integration tests → dead-code report → deploy. The unit test gate runs on every push; GKE integration runs on `main` only. + +### Architecture + +``` +push to any branch + │ + ├─ stage: lint → python syntax + imports check + ├─ stage: test → pytest -m "unit" (every push) + │ +push to main only: + ├─ stage: test → pytest -m "gke and integration" (real GKE cluster) + ├─ stage: test → vulture dead-code report (allow_failure: true) + └─ stage: deploy → ./install.sh -S -c conf/service/prod.cfg +``` + +### Deliverables + +| File | Purpose | +|---|---| +| `.gitlab-ci.yml` | Full multi-stage pipeline replacing current single-job version | +| `tests/modes/test_gke_mode.py` | GKE unit + integration tests | +| `scripts/dead_code_analysis.py` | Cross-references coverage.json with vulture-report.txt, outputs three-tier report | + +### Required GitLab CI variables (project settings → CI/CD → Variables) + +| Variable | Value | Protected | Masked | +|---|---|---|---| +| `GCP_SA_KEY` | GCP service account JSON with `container.viewer` + `storage.objectViewer` on both clusters | ✅ | ✅ | +| `GKE_REGION` | `us-west3` | | | +| `GKE_APP_CLUSTER` | `knoe-dev-0` | | | +| `GKE_DB_CLUSTER` | `knoe-cnpg-0` | | | + +### `.gitlab-ci.yml` (full replacement) + +```yaml +stages: + - lint + - test + - report + - deploy + +variables: + PYTHON_VERSION: "3.11" + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + +cache: + paths: [.cache/pip] + +# ── Stage: lint ─────────────────────────────────────────────────────────────── + +lint: + stage: lint + image: python:3.11-slim + script: + - pip install -r requirements-test.txt -q + - python -m py_compile knoe/**/*.py + - python -c "from knoe.ui.screens import KnoeInstaller" + rules: + - if: '$CI_PIPELINE_SOURCE == "push"' + +# ── Stage: test (unit — every push) ────────────────────────────────────────── + +unit-tests: + stage: test + image: python:3.11-slim + script: + - pip install -r requirements.txt -r requirements-test.txt -q + - pytest -m "unit and not integration" + --cov=knoe + --cov-report=xml + --cov-report=term-missing + -q + tests/ + coverage: '/TOTAL.*\s+(\d+\%)/' + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml + paths: [coverage.xml] + expire_in: 7 days + rules: + - if: '$CI_PIPELINE_SOURCE == "push"' + +# ── Stage: test (GKE integration — main only) ───────────────────────────────── + +gke-integration: + stage: test + image: google/cloud-sdk:slim + timeout: 30 minutes + before_script: + - pip install -r requirements.txt -r requirements-test.txt -q + - echo "$GCP_SA_KEY" | gcloud auth activate-service-account --key-file=- + - gcloud config set project plenary-truck-485623-p7 + - gcloud container clusters get-credentials $GKE_APP_CLUSTER + --region $GKE_REGION + - gcloud container clusters get-credentials $GKE_DB_CLUSTER + --region $GKE_REGION + script: + - pytest -m "gke and integration" + --cov=knoe + --cov-report=xml + -v + tests/ + coverage: '/TOTAL.*\s+(\d+\%)/' + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.xml + paths: [coverage.xml] + expire_in: 30 days + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + +# ── Stage: report (dead code — main only, never blocks build) ───────────────── + +dead-code: + stage: report + image: python:3.11-slim + allow_failure: true + script: + - pip install vulture -q + - vulture knoe/ --min-confidence 80 | tee vulture-report.txt + - python scripts/dead_code_analysis.py + --vulture vulture-report.txt + --coverage coverage.xml + --output dead-code-report.md + artifacts: + paths: + - vulture-report.txt + - dead-code-report.md + expire_in: 30 days + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + +# ── Stage: deploy (main only) ───────────────────────────────────────────────── + +deploy-service: + stage: deploy + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + script: + - ./install.sh -S -c conf/service/prod.cfg + environment: + name: production +``` + +### Tests to write in `tests/modes/test_gke_mode.py` + +**Unit tests** (`@pytest.mark.gke @pytest.mark.unit`): + +| Test | What it proves | +|---|---| +| `test_gke_cluster_env_normalizes_to_prod` | `_normalize_cluster_env("prod") == "prod"` | +| `test_welcome_gke_card_sets_cluster_env_prod` | Card click → `cluster_env = "prod"` | +| `test_gke_split_cluster_detected` | When `app_ctx != db_ctx`, split-cluster mode activates | +| `test_garage_not_deployed_to_db_cluster` | CNPG cluster does not include Garage in GKE mode | +| `test_gke_storage_class_validation` | `standard-rwo` raises quota error hint; `standard` passes | + +**Integration tests** (`@pytest.mark.gke @pytest.mark.integration`): + +| Test | What it proves | +|---|---| +| `test_gke_app_cluster_reachable` | kubectl can reach `knoe-dev-0` | +| `test_gke_db_cluster_reachable` | kubectl can reach `knoe-cnpg-0` | +| `test_cnpg_cluster_knoe_db_0_running` | `knoe-db` cluster in `knoe-db-0` namespace has 3 Ready instances | +| `test_barman_backup_schedule_exists` | ScheduledBackup CR exists in `knoe-db-0` | +| `test_garage_only_in_app_cluster` | No Garage pods in `knoe-cnpg-0` namespace | + +### Commit Summary + +*(Fill in after phase lands)* + +### Resumption Checklist + +- [ ] GitLab pipeline shows four stages: lint → test → report → deploy +- [ ] `unit-tests` job passes on every push (not just main) +- [ ] `gke-integration` passes on main (3 CNPG pods Running) +- [ ] `dead-code` job produces a `dead-code-report.md` artifact +- [ ] Deploy stage still works (`install.sh -S` exits 0) +- [ ] GitLab shows coverage percentage on the merge request widget + +--- + +## Phase 4 — Dead Code Identification + +**Status:** 🔲 Not started +**Depends on:** Phase 3 complete (all three stable builds achieved) + +### What this phase does + +Runs the first systematic dead code analysis across the full codebase. This is not a cleanup sprint — it is *reconnaissance*. We generate a prioritised report and review it before deleting anything. The report becomes the input for Phase 5. + +### Why this matters + +The project has undergone several major renames (`prole` → `knoe`, various directory restructures). Code written for old layouts is likely still present. `knoe/core/actions.py` alone is 7,740 lines and almost certainly contains branches that no deployment mode ever exercises. + +### Tools + +| Tool | Role | +|---|---| +| `vulture` | Static analysis — finds unused functions, classes, imports | +| `pytest --cov --cov-branch` | Dynamic analysis — lines/branches never executed during tests | +| `scripts/dead_code_analysis.py` | Cross-references both outputs to produce a tiered report | + +### Three-tier output format + +```markdown +## 🔴 Definite dead code (vulture-flagged AND 0% coverage) +- knoe/core/ops/legacy_shell.py:47 — function `_old_prole_exec` (unused, 0% coverage) +- ... + +## 🟡 Suspect (vulture-flagged OR 0% coverage, not both) +- knoe/core/actions.py:4201 — function `_k8s_node_drain` (0% coverage, not flagged by vulture) +- ... + +## 🟢 Live (covered by tests and vulture-clean) +- (omitted from report for brevity) +``` + +### How to run locally + +```bash +# 1. Full coverage run across all modes +KNOE_MODE=min pytest -m min --cov=knoe --cov-append tests/ +KNOE_MODE=k3d pytest -m k3d --cov=knoe --cov-append tests/ +KNOE_MODE=gke pytest -m gke --cov=knoe --cov-append tests/ +coverage json # produces coverage.json + +# 2. Vulture scan +vulture knoe/ --min-confidence 70 > vulture-report.txt + +# 3. Cross-reference +python scripts/dead_code_analysis.py \ + --vulture vulture-report.txt \ + --coverage coverage.json \ + --output dead-code-report.md + +# 4. Review +open dead-code-report.md +``` + +### Deliverables + +| File | Purpose | +|---|---| +| `scripts/dead_code_analysis.py` | Cross-reference script (coverage.json + vulture output → tiered report) | +| `dead-code-report.md` | Generated output — committed to repo for review, not production | +| `docs/dead-code-review.md` | Human review notes — which 🔴 items are safe to delete vs accidentally flagged | + +### Commit Summary + +*(Fill in after phase lands)* + +### Resumption Checklist + +- [ ] `python scripts/dead_code_analysis.py` runs without error +- [ ] `dead-code-report.md` contains all three tiers +- [ ] The 🔴 list has been manually reviewed and each item categorised as "safe to delete" or "keep" in `docs/dead-code-review.md` +- [ ] No production code deleted yet — this phase is report-only + +--- + +## Phase 5 — Pruning and the Instructable Codebase + +**Status:** 🔲 Not started +**Depends on:** Phase 4 report reviewed and approved + +### What this phase does + +This is the refactoring sprint. Using the Phase 4 report, we delete dead code in small, test-verified commits, raising the `fail_under` coverage threshold after each deletion. The end state is a codebase that a junior developer can read from top to bottom and fully understand. + +### Target metrics + +| Metric | Today (est.) | Target | +|---|---|---| +| Test coverage (unit) | ~40% | ≥ 80% | +| Test coverage (integration) | ~10% | ≥ 60% | +| `vulture --min-confidence 80` warnings | Unknown | 0 | +| Lines in `knoe/core/actions.py` | 7,740 | < 2,000 | +| Modules with zero test coverage | ~17 (`knoe/core/ops/`) | 0 | +| `fail_under` in `pyproject.toml` | 0 | 75 | + +### Deletion protocol + +For each 🔴 item from the Phase 4 report: + +1. Write a test that would fail if the code were still needed (proves it's safe to delete) +2. Delete the code +3. Run `pytest` — all tests pass +4. Commit with message: `prune: remove — dead code (vulture + 0% coverage)` +5. Raise `fail_under` by 1–2 points + +This protocol means every deletion is backed by a test. The test suite grows *because* we prune. + +### The instructable codebase standard + +A module is "instructable" when: +- Every public function has at least one test whose name reads as a plain English sentence +- The test file for the module is shorter than the module itself +- A junior developer can understand the module's purpose from the test names alone without reading the source + +Example of instructable test names: +```python +def test_min_mode_only_needs_homebrew_and_1password(): ... +def test_k3d_mode_creates_cnpg_cluster_in_docker(): ... +def test_welcome_card_click_routes_to_correct_nav_flow(): ... +def test_kerberos_is_only_enabled_in_k3s_and_gke_modes(): ... +``` + +### Commit Summary + +*(Fill in after each deletion sprint)* + +### Resumption Checklist + +- [ ] `fail_under` has been raised at least once since Phase 4 +- [ ] Zero 🔴 items remain in `dead-code-report.md` +- [ ] All modules in `knoe/core/ops/` have at least one test +- [ ] CI passes on all three pipelines (min, k3d, gke) + +--- + +## k3s Phase — Homelab Pipeline + +**Status:** ⏸ Deferred +**Trigger:** Evaluate after GitLab CI (Phase 3) is stable + +### Notes for when this is ready + +- Runner: one of the physical k3s nodes (`myrddin.prole.org`, `gandalf.prole.org`, or `merlin.prole.org`) registered as a Gitea act_runner with labels `self-hosted,linux,k3s` +- Integration tests will need `kubeconfig` for the k3s cluster and iSCSI storage access for CNPG +- Kerberos integration is the unique test target here — `knoe-auth` deployment with embedded KDC +- The `CNPG_ELIGIBLE_NODES` config var maps directly to tests that verify node affinity placement + +--- + +## Appendix A — Pytest Marker Reference + +| Marker | When to use | +|---|---| +| `@pytest.mark.unit` | No external services, no file system writes, runs anywhere | +| `@pytest.mark.integration` | Requires a running cluster, database, or network service | +| `@pytest.mark.min` | Tests the min (containerd) deployment path | +| `@pytest.mark.k3d` | Tests the k3d (local Docker cluster) deployment path | +| `@pytest.mark.k3s` | Tests the k3s (homelab) deployment path | +| `@pytest.mark.gke` | Tests the GKE (production) deployment path | + +Combining markers is the norm: `@pytest.mark.k3d @pytest.mark.integration` means "requires a live k3d cluster". + +--- + +## Appendix B — Runner Registration Quick Reference + +### Gitea act_runner (macOS) + +```bash +# Install +brew install act-runner + +# Register (run once per machine) +act_runner register \ + --instance https:/// \ + --token \ + --labels "self-hosted,macos,min" # or min,k3d for the Docker machine \ + --name "knoe-min-runner" # human-readable name in Gitea UI + +# Start as a service +brew services start act-runner +``` + +### GitLab runner (existing) + +The existing GitLab runner is assumed to have `gcloud` CLI available. If not: +```bash +# On the runner host: +curl https://sdk.cloud.google.com | bash +gcloud components install gke-gcloud-auth-plugin +``` + +--- + +## Appendix C — Coverage Increment Strategy + +Rather than setting an ambitious `fail_under` up front and having CI permanently broken, we raise it in steps as each phase lands: + +| After phase | `fail_under` | +|---|---| +| Phase 0 (foundation) | 0 (measure only) | +| Phase 1 (min tests) | 15 | +| Phase 2 (k3d tests) | 25 | +| Phase 3 (gke tests) | 35 | +| Phase 4 (dead code pruned) | 50 | +| Phase 5 (full prune) | 75 | + +Each increment is a one-line change to `pyproject.toml` committed at the end of the phase. From 636a2795cb3674627a4ed60dc2f6720cca087435 Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:07:00 -0700 Subject: [PATCH 03/12] =?UTF-8?q?docs(plans):=20add=20platform=20architect?= =?UTF-8?q?ure=20plans=20=E2=80=94=20deployment-modes,=20knoe-auth=20round?= =?UTF-8?q?=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/plans/ as the canonical engineering reference for completed and in-flight initiatives. Written for jr/mid engineers who have not seen the repo before — each plan starts with strategic context and links to existing code before asking for changes. README.md index, audience, and status conventions deployment-modes.md four-mode installer (min/k3d/k3s/gke), welcome-screen mode selector, min-mode fast-path. Status: shipped. knoe-auth-round-1.md Kerberos KNOE.DEV realm, invite-OTP enrollment, Google corroboration, TOTP 2FA. Status: operational. Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/README.md | 45 ++- docs/plans/deployment-modes.md | 188 +++++++++++++ docs/plans/knoe-auth-round-1.md | 483 ++++++++++++++++++++++++++++++++ 3 files changed, 703 insertions(+), 13 deletions(-) create mode 100644 docs/plans/deployment-modes.md create mode 100644 docs/plans/knoe-auth-round-1.md diff --git a/docs/plans/README.md b/docs/plans/README.md index 4b96ed7..2e64976 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -1,24 +1,43 @@ -# Plans (prole — customer deploy) +# Plans -This directory holds **plans specific to the prole.org customer deployment** of the knoe.dev platform. +Engineering plans for the **knoe.dev** platform. Each document scopes one initiative — its strategic context, design, schema, and the components it adds — and is intended to outlive the implementation work itself: when the work ships, the plan stays as the architectural reference. + +## Who this is for + +You are a junior or mid-level engineer who has been invited to contribute to knoe.dev. These documents are written for you. They assume: + +- You are comfortable reading code and skimming a Maven `pom.xml`. +- You have used Kubernetes at least casually (`kubectl get pods`, helm charts). +- You may **not** have prior Kerberos, OIDC, or CNPG experience — relevant terms are defined inline or in the glossary section of each plan. +- You have not seen this repo before. Each plan starts with the strategic context and links to existing code before it asks you to write any. + +If a sentence in a plan assumes knowledge you don't have, that's a bug in the plan — open an issue. ## What lives here -| File | Purpose | -| --- | --- | -| [`customer-deploy-resync.md`](customer-deploy-resync.md) | The active plan to converge this working tree onto `knoe-db/main` as a long-lived customer branch. | +| File | Initiative | When you should read it | +| --- | --- | --- | +| [`knoe-auth-round-1.md`](knoe-auth-round-1.md) | Identity backbone for the platform: MIT Kerberos KDC + invite-anchored web enrollment + TOTP 2FA. Round 1 of N. **Shipped.** | Before touching `authority/`, `etc/init_kdc.sh`, `etc/init_knoe_users.sh`, anything in `deploy/gcp/gke/knoe-auth-*` or `knoe-kdc-*`, or the `knoe.*` database schema. | +| [`deployment-modes.md`](deployment-modes.md) | Four-mode installer (`min` / `k3d` / `k3s` / `gke`) with a welcome-screen mode selector and a `min`-mode fast-path through the wizard. **Shipped in Phase 0.** | Before touching `knoe/ui/screens/welcome.py`, `knoe/ui/screens/navigation.py`, or adding any new wizard screen. | -## What does **not** live here +Each plan follows the same shape: **Context → How it's wired (file references) → Architecture → Schema / API → Step-by-step → Verification → Glossary.** -Platform-level plans live upstream in `~/dev/knoe-db/docs/plans/`: +## Status conventions -- [`knoe-auth-round-1.md`](../../../knoe-db/docs/plans/knoe-auth-round-1.md) — Kerberos identity + invite-OTP enrollment + TOTP. Architectural reference. -- [`deployment-modes.md`](../../../knoe-db/docs/plans/deployment-modes.md) — Four-mode installer + welcome-screen selector. Architectural reference. +A plan in this directory has one of three statuses, declared at the top: -(Paths above are relative to your `~/dev/` root. If you're reading this on the file server, navigate to the `knoe-db` checkout.) +- **Active plan. Not yet implemented.** — the design is agreed, the code isn't there yet. Read deliverables top-to-bottom. +- **Implemented and operational. Architectural reference.** — the work shipped. The doc explains how it works and *why*; cross-references point at real files. Read the context and the rationale; jump to specific sections when you have a question. +- **Archived.** — superseded by a later round or a different approach. Lives in `archive/` with a one-line "see X instead" pointer. -## Why the split +## Customer deploys -`prole` is being converged into a customer-deploy branch in `knoe-db`, not a separate fork. Platform docs travel with the platform code (in `knoe-db`); customer-deploy-specific docs travel with the customer branch (here). When the customer branch is published in `knoe-db`, this directory will move with it. +The knoe.dev platform supports per-customer deployments — currently the most active is `prole.org`. The convention is **branches in this repo**, not separate forks. Customer-specific divergence (config, branding, on-prem manifests, kubeconfig handling) lives on a branch named after the customer; platform changes always land on `main` and customer branches rebase or merge from main on a regular cadence. See the customer's own `docs/plans/` for resync notes — for example, `~/dev/prole/docs/plans/customer-deploy-resync.md` if you have that working tree checked out. -See the upstream `~/dev/knoe-db/docs/plans/README.md` for the full conventions on writing plans, plan statuses, and how customer deploys are structured. +## How to propose a new plan + +1. Copy `knoe-auth-round-1.md` as a template — the section structure is the convention. +2. Lead with **why** (one or two paragraphs). Half the value of a plan is forcing the author to articulate the motivation. +3. Cite real files with paths relative to the repo root. If you reference something that doesn't exist yet, label it **(net-new)** so a reader doesn't go hunting. +4. End with a **Verification** section — a short checklist a reviewer can run to decide whether the plan is done, or — once shipped — that the implementation still matches the spec. +5. Open an MR. Plans are reviewed like code. diff --git a/docs/plans/deployment-modes.md b/docs/plans/deployment-modes.md new file mode 100644 index 0000000..9a70640 --- /dev/null +++ b/docs/plans/deployment-modes.md @@ -0,0 +1,188 @@ +# Deployment Modes — Welcome Screen + Wizard Fast-Path + +**Status:** Implemented and operational. Architectural reference. Welcome-screen mode selector and `min`-mode fast-path shipped in Phase 0. +**Owner:** chrisfu +**Audience:** Jr/mid engineer onboarding to knoe.dev. Familiarity with Tk/tkinter helps but is not required. + +--- + +## 1. Context + +The knoe-db installer (`./install.sh` / `./knoe.sh install`, driven by the Tk UI in `knoe/ui/screens/`) supports four ways to deploy the platform. Different audiences, different hardware, but **the same architecture underneath**. + +Earlier installer revisions assumed the engineer already knew which mode they wanted, presented a welcome screen that was a wall of text, and shipped a sidebar tab strip (`k3d` / `k3s` / `k8s`) that didn't reliably refresh state across screens. Phase 0 replaced all of that with a first-screen mode selector that teaches the engineer what each mode *is* before asking them to choose, and added a `min`-mode fast-path that skips wizard screens irrelevant to a single-container deployment. + +### The four modes + +| Mode | What it is | Who it's for | Required tools | +| --- | --- | --- | --- | +| **min** | A single `knoe-db` container under `containerd` — no Kubernetes, no Docker. The Spring Boot app runs locally on the host. | Engineers running a Spring app on a laptop and wanting a CNPG-compatible Postgres without a full cluster. | Homebrew, 1Password | +| **k3d** | A Docker-based local k3s cluster. Full CNPG + optional Supabase / ArgoCD / Gitea / GitLab. | Local development with the full platform. | Docker, Homebrew, 1Password | +| **k3s** | A multi-node k3s cluster on real hardware. Full Kerberos auth stack, Garage S3, monitoring. Mirrors the GKE shape. | Homelab contributors with their own hardware (e.g. a 3-node Pi cluster). | k3sup, Homebrew, 1Password | +| **gke** | The production dual-cluster on Google Cloud (`knoe-dev-0` + `knoe-cnpg-0` in `us-west3`). | Production. | gcloud, kubectl, 1Password | + +The architectural commitment matters: **all four modes are scaled-down mirrors of the GKE shape.** Not parallel implementations of the same idea, not divergent forks — the same components composed at different scales. CNPG runs in all four. The Spring Boot `authority` service runs in all four. `knoe.user` lives in the same schema in all four. The only thing that changes is what platform the components run on and which optional services are enabled. + +### Mode → cluster_env mapping + +| `cluster_env` | `KNOE_MODE` | Target | +| --- | --- | --- | +| `dev` | `k3d` | Local K3d cluster | +| `service` | `k3s` | On-prem K3s cluster | +| `prod` | `k8s` (alias `gke`) | GKE (or other cloud) | +| `min` | `min` | Local containerd (no Kubernetes) | + +The conversion lives in `knoe/core/env.py` (`_deployment_mode_from_env()`). + +### Config file mapping + +`knoe/knoe_conf.py` maps environments to config files under `conf/`: + +| Mode | Config file | +| --- | --- | +| `min` | `conf/min.cfg` | +| `k3d` (`dev`) | `conf/k3d.cfg` | +| `k3s` (`service`) | `conf/k3s.cfg` | +| `gke` (`prod`) | `conf/gke.cfg` | + +Config is layered: env-specific file overrides base. `KNOE_CONF` env var or `conf/service/` subdirs point to the active config. + +--- + +## 2. How it's wired — file map + +| Component | Location | What's there | +| --- | --- | --- | +| Welcome screen with mode selector | `knoe/ui/screens/welcome.py` | `WelcomeScreenMixin._render_welcome_page()` renders the four mode cards. Click handler updates `deployment_mode` and `cluster_env`, redraws card borders, re-evaluates the Next gate. | +| Sidebar nav | `knoe/ui/screens/navigation.py` | `_create_sidebar_nav()` no longer renders the old `k3d/k3s/k8s` tab strip — the welcome card selector replaces it. `_set_deployment_mode` is still called from the welcome handler. | +| Mode/state initialization | `knoe/ui/screens/__init__.py` | `nav_items` list, `deployment_mode = tk.StringVar(...)`, `cluster_env = tk.StringVar(...)`. | +| Wizard transitions | `knoe/ui/screens/navigation.py` `on_next` / `on_prev` | Min-mode fast-path skips screens that don't apply (network scan, cluster init, Kerberos, ArgoCD, Supabase, common services). | +| Update-footer Next-button gate | `knoe/ui/screens/navigation.py` `update_footer` | Welcome screen disables Next until a mode is selected. | +| Min-mode bootstrap | `etc/init_min.sh` | Idempotent: ensures Homebrew + containerd, pulls and runs the `knoe-db` container locally, runs schema bootstrap, prints connection info. Invoked from `init_scripts` step in min mode. | +| Min-mode config | `conf/min.cfg` | Modeled on `conf/k3d.cfg`. Just what `init_min.sh` needs. | +| Min-mode lifecycle | `knoe.sh` (`start` / `stop` / `restart` subcommands) | Manages the local containerd `knoe-db` instance for users who don't want to run the wizard every time. | + +--- + +## 3. UI layout — welcome screen + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ │ +│ knoe.dev │ +│ infrastructure.auto() │ +│ │ +│ Welcome │ +│ ─────── │ +│ Knoe.DB sets up a production-grade PostgreSQL cluster with │ +│ Kerberos authentication. Pick the mode that matches your │ +│ hardware — we'll walk you through every step. │ +│ │ +│ ┌────────────┬────────────┬────────────┬────────────┐ │ +│ │ min │ k3d │ k3s │ gke │ │ +│ │ Just a │ Local │ Homelab │ Production│ │ +│ │ Database │ Cluster │ │ │ │ +│ │ One knoe-db│ k3s-in- │ Multi-node │ Dual GKE │ │ +│ │ container │ Docker │ k3s on real│ clusters on│ │ +│ │ via │ cluster on │ hardware. │ Google │ │ +│ │ containerd.│ your Mac. │ Full │ Cloud. │ │ +│ │ No K8s. │ Full CNPG │ Kerberos │ CNPG + │ │ +│ │ Perfect for│ database — │ auth stack,│ GCS backups│ │ +│ │ a Spring │ add │ Garage S3, │ + Workload │ │ +│ │ app on your│ Supabase, │ monitoring.│ Identity. │ │ +│ │ laptop. │ ArgoCD, │ │ │ │ +│ │ │ Gitea, or │ │ │ │ +│ │ │ GitLab. │ │ │ │ +│ │ Homebrew + │ Docker + │ k3sup + │ gcloud + │ │ +│ │ 1Password │ Homebrew + │ Homebrew + │ 1Password │ │ +│ │ │ 1Password │ 1Password │ │ │ +│ └────────────┴────────────┴────────────┴────────────┘ │ +│ │ +│ You'll need: Docker · Homebrew · 1Password │ +│ │ +│ [ Next → ] │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Behavior: + +- The four cards are clickable. +- The selected card gets a colored border in the mode's accent color (`_MODE_COLORS`: `min` purple, `k3d` blue, `k3s` green, `gke` orange). +- The "You'll need:" line below the cards updates to reflect the selected mode's tools. +- The Next button is **disabled** until a mode is selected. +- Selecting a card also sets `cluster_env` via the mode-to-cluster_env map. +- On Next, mode is persisted to `knoe_cfg_data["Global"]["DEPLOYMENT_MODE"]`. + +--- + +## 4. Wizard transitions — `min`-mode fast-path + +### Default flow (k3d / k3s / gke) + +``` +welcome → network_scan → env_setup → init_cluster → cluster_nodes → +init_password → init_scripts → kerberos_config → knoe_users → +argocd_config → gitops_config → supabase_config → common_services → +… → security +``` + +### Min-mode flow + +``` +welcome → dependencies → environment → init_password → init_scripts → security +``` + +Skipped in min: `network_scan`, `init_cluster`, `cluster_nodes`, `kerberos_config`, `knoe_users`, `argocd_config`, `gitops_config`, `supabase_config`, `common_services`. None of them apply when there's no Kubernetes cluster and no Kerberos KDC. `init_scripts` invokes `etc/init_min.sh` instead of the cluster-mode init scripts. + +The fast-path is implemented as a `_min_mode_next` dictionary in `knoe/ui/screens/navigation.py` consulted from `on_next` / `on_prev`. The override is only active when `_is_min_mode()` returns true. + +--- + +## 5. Verification + +Run after any change touching the wizard or mode logic. + +1. `./install.sh` opens. The welcome screen shows four mode cards. The sidebar has **no** k3d/k3s/k8s tab strip. +2. With no mode selected, the Next button is disabled. +3. Click the **min** card. Next becomes enabled. Click Next. + - The wizard skips the network-scan screen. + - It skips `init_cluster` and `cluster_nodes`. + - It skips `kerberos_config`, `knoe_users`, `argocd_config`, `gitops_config`, `supabase_config`, `common_services`. + - It runs `init_min.sh` at the `init_scripts` step. + - It lands on the `security` screen. + - Going back through `on_prev` walks the same trimmed sequence in reverse without hitting any skipped screens. +4. Click the **k3d** card on a fresh run. The wizard follows the existing k3d flow exactly as before. +5. `python3 -c "from knoe.ui.screens import KnoeInstaller"` — no import errors. +6. After completing min mode, `nerdctl ps` shows the `knoe-db` container running locally and `psql "${printed_conn_string}" -c '\dt knoe.*'` lists the `knoe.user` table. +7. `./knoe.sh stop` and `./knoe.sh start` cleanly stop/restart the local containerd instance. + +--- + +## 6. Out of scope + +- A `min`-mode equivalent of the full Kerberos auth stack. Min is "just a database". Auth in min is a follow-up. +- Migrating an existing `k3d` deployment to `min` (or any other mode-to-mode migration). Each mode is an independent target. +- A unified package format that produces all four mode-specific installers from one `pyinstaller` build. The current per-mode build is fine. +- Letting one wizard run install *multiple* modes (e.g. set up `k3d` and `gke` from the same session). One mode per run. + +--- + +## 7. Glossary + +**k3s** — A lightweight Kubernetes distribution by Rancher Labs. Single binary, runs comfortably on a Raspberry Pi. The same project, two delivery shapes: `k3d` is k3s wrapped in Docker; bare k3s is k3s on actual hardware. + +**k3d** — A wrapper that runs k3s clusters inside Docker containers. Useful for local dev because it gives you a "real" Kubernetes cluster without having to provision VMs. + +**GKE (Google Kubernetes Engine)** — Google Cloud's managed Kubernetes. We use Standard for `knoe-dev-0` and `knoe-cnpg-0`. See `CLAUDE.md` for cluster topology. + +**containerd** — A container runtime. Speaks the OCI image and runtime specs. Both Docker and Kubernetes use containerd under the hood. In `min` mode we talk to it directly via `nerdctl`, skipping Docker entirely. + +**nerdctl** — A Docker-CLI-compatible client for `containerd`. If you know `docker run`, you know `nerdctl run`. + +**CNPG (CloudNativePG)** — The Postgres operator we use for `knoe-db`. See `CLAUDE.md` for which cluster runs it in production. + +**`cluster_env`** — A label the installer carries internally that picks which `conf/*.cfg` file to read and which downstream config branches to take. Maps from `deployment_mode` per the table in §1. Don't reuse this name for new variables — it's already overloaded enough. + +**Tk / tkinter** — Python's standard GUI toolkit. The installer is built on it. Canvas-based rendering means we draw text and shapes directly rather than using Tk's widget hierarchy for the welcome screen. + +**Wizard / pid** — The installer is a multi-screen wizard. Each screen has a "page id" (`pid`) — `welcome`, `network_scan`, `init_cluster`, etc. `nav_items` is the ordered list; `on_next` / `on_prev` decide transitions based on the current `pid` and the user's state. diff --git a/docs/plans/knoe-auth-round-1.md b/docs/plans/knoe-auth-round-1.md new file mode 100644 index 0000000..1428bb9 --- /dev/null +++ b/docs/plans/knoe-auth-round-1.md @@ -0,0 +1,483 @@ +# knoe-auth Round 1 — Durable Kerberos Identity + Contributor Onboarding + +**Status:** Implemented and operational. Architectural reference. +**Owner:** chrisfu +**Audience:** Jr/mid engineer onboarding to knoe.dev. No prior Kerberos or OIDC experience assumed. + +--- + +## 1. Context + +`knoe-auth` is the identity system for the knoe.dev platform — the thing that decides who you are, what you can access, and how new contributors come on board. It is a long-term, multi-round initiative. This document is the architectural reference for **Round 1**, which is shipped. + +### The chicken-and-egg problem + +To build a sophisticated identity provider safely, the team needs to be able to authenticate themselves against *something* trustworthy in the meantime. We needed a working auth store *before* we could safely build the better one. + +Round 1 leans on a tool that has been doing this job for thirty-five years: **MIT Kerberos**. It is unfashionable but well-understood, cryptographically sound, and we already had it running in our k3s homelab cluster. We extended it to GKE, wrapped a small enrollment portal around it, and use that to onboard contributing engineers while Round 2 (a full OIDC provider) is being built. + +### Why Kerberos + +Kerberos *principals* are durable, DNS-like identifiers. `chrisfu@KNOE.DEV` is a stable cryptographic identity that survives any change to our web stack, our database, or our cloud provider. Once a principal exists in the KDC, it can issue tickets that any kerberized service trusts — Spring Boot via SPNEGO, Postgres via `gss` auth, SSH, NFS — without each of those services needing its own user table. + +That's the asset Round 1 builds on. + +### Two independent Google Workspaces — do not conflate + +| Workspace | Role | +| --- | --- | +| `knoe.dev` | Internal Google Workspace for the platform. Has zero pre-knowledge of any contributor's home org. | +| `prole.org` | Workspace of the first contributing engineer's organization. Independently operated. | + +`knoe.dev` does **not** trust `prole.org` as a domain. `prole.org` is just one engineer's email provider, no different from `gmail.com` or any other workspace a future contributor might use. Trust between knoe.dev and a new contributor is bootstrapped by the **invite**, not by the contributor's home Google domain. + +### Trust model — the most important paragraph in this document + +When a new engineer enrolls, the trust sequence is: + +1. **An admin sends an invite to a specific email address or phone number.** That contact channel — and only that channel — is the trust anchor. The admin's choice of who to invite **is** the policy. +2. **The engineer proves they control that contact** by entering a one-time password (OTP) delivered to it. Until the OTP verifies, no further steps are possible. +3. **Only after the OTP gate** is the engineer offered a Google sign-in to *corroborate* their identity. The Google sign-in is welcomed in *after* trust is already established by the invite — it is not the source of trust. +4. **TOTP** (the rotating six-digit code from Google Authenticator / Authy) is set up as the ongoing 2FA credential. +5. **Only then** is a Kerberos principal minted, a `knoe.user` row inserted, and downstream provisioning jobs (GitLab account, Gitea account) queued. + +This means knoe.dev never needs to pre-configure trust with any external workspace. The OAuth2 app does **not** restrict by Google `hd` (hosted domain) — any verified Google account works. The contributor's home domain is *recorded* for audit (`knoe.identity.provider_hd`) but never used to gate access. + +If you remember nothing else: **the invite OTP is the trust anchor. Google is corroboration. TOTP is the ongoing factor.** + +--- + +## 2. How it's wired — file map + +The actual files that implement Round 1. Verify with `git ls-files` before assuming any of the below has rotted. + +### Java application — `authority/` + +The Spring Boot service that implements the enrollment flow, the admin API, and SPNEGO-protected endpoints. Multi-module Maven build (`authority/pom.xml`); the application package is `org.prole.authority` (kept as-is across the prole→knoe rebrand for compatibility). + +| File | Responsibility | +| --- | --- | +| `authority/src/main/java/org/prole/authority/KnoeAuthApplication.java` | `@SpringBootApplication` entry point. | +| `authority/src/main/java/org/prole/authority/HealthController.java` | `/health` endpoint. | +| `authority/src/main/java/org/prole/authority/web/LoginController.java` | Form-login + SPNEGO challenge for browsers without a ticket. | +| `authority/src/main/java/org/prole/authority/web/VerifyController.java` | Token-verify endpoint for downstream services. | +| `authority/src/main/java/org/prole/authority/session/SessionTokenService.java` | Issues HMAC-SHA256 JWT cookies after successful auth. | +| `authority/src/main/java/org/prole/authority/session/SessionUser.java` | Authenticated principal carried in the security context. | +| `authority/src/main/java/org/prole/authority/user/PrincipalNormalizer.java` | Strips realm/instance from a Kerberos principal (`alice/admin@KNOE.DEV` → `alice`). | +| `authority/src/main/java/org/prole/authority/kerberos/KerberosSpnegoService.java` | SPNEGO challenge/response handling. | +| `authority/src/main/java/org/prole/authority/kerberos/KerberosPasswordService.java` | Password-style auth fallback for browsers that can't do SPNEGO. | +| `authority/src/main/java/org/prole/authority/kerberos/KadminClient.java` | Shells out to `kadmin.local` (in the KDC sidecar) to `addprinc` and `cpw`. **Sanitizes input.** | +| `authority/src/main/java/org/prole/authority/enroll/EnrollmentController.java` | Web endpoints: `GET /auth/enroll`, `POST /auth/enroll/verify-otp`, `POST /auth/enroll/identity/start`, `GET /auth/enroll/google-callback`, `GET /auth/enroll/totp`, `POST /auth/enroll/totp/verify`, `POST /auth/enroll/complete`. | +| `authority/src/main/java/org/prole/authority/enroll/InviteService.java` | CRUD + validation against `knoe.invitation`. OTP hashing (bcrypt) and rate limiting (3 attempts). | +| `authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java` | Exchange OAuth2 code → ID token, validate `email_verified`, return a `GoogleIdentity` record. **No `hd` allowlist.** | +| `authority/src/main/java/org/prole/authority/enroll/TotpService.java` | Generate TOTP secret, produce `otpauth://` URI, verify codes. | +| `authority/src/main/java/org/prole/authority/enroll/UserProvisioningService.java` | Transactional orchestrator: inserts user/identity/totp rows, calls `KadminClient`, queues provisioning jobs. | +| `authority/src/main/java/org/prole/authority/admin/AdminController.java` | `POST /auth/admin/invites`, `GET /auth/admin/users`, `POST /auth/admin/grants`. SPNEGO + admin-role gated. | +| `authority/src/main/java/org/prole/authority/admin/KnobjectService.java` | CRUD on `knoe.knobject` and `knoe.access_grant`; enqueues `provisioning_job` rows. | +| `authority/src/main/java/org/prole/authority/provisioning/ProvisioningWorker.java` | `@Scheduled` poller for `knoe.provisioning_job WHERE status = 'pending'`. Dispatches to GitLab/Gitea/CNPG. | +| `authority/src/main/java/org/prole/authority/config/AuthProperties.java` | Typed binding for `knoe.auth.*` keys. | +| `authority/src/main/java/org/prole/authority/config/KerberosProperties.java` | Typed binding for `knoe.auth.kerberos.*` keys. | + +Tests for the above live under `authority/src/test/java/org/prole/authority/` — notably `web/VerifyControllerTest.java` and `session/SessionTokenServiceTest.java`. + +### Kubernetes manifests — GKE + +| File | Purpose | +| --- | --- | +| `deploy/gcp/gke/knoe-kdc-configmap.yaml` | `krb5.conf` + `kdc.conf` for the `KNOE.DEV` realm. | +| `deploy/gcp/gke/knoe-kdc-secrets.yaml` | Master key + admin password references. Production values come from OpenBao. | +| `deploy/gcp/gke/knoe-auth-deployment.yaml` | KDC sidecar + Spring Boot pod. Service principal `HTTP/auth.knoe.dev@KNOE.DEV`. | +| `deploy/gcp/gke/knoe-auth-google-oidc-secret.example.yaml` | Templated Secret with `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` placeholders. Real values are not committed. | +| `deploy/gcp/gke/workload-identity.yaml` | KSA↔GSA bindings for any GCP-backed secret access. | + +### Kubernetes manifests — k3s (homelab and customer deploys) + +| File | Purpose | +| --- | --- | +| `deploy/opentofu/k3s/manifests/prole/prole-kdc-configmap.yaml` | The `PROLE.LOCAL` realm KDC for the homelab cluster. The pattern the GKE configmap was modeled on. | +| `deploy/opentofu/k3s/manifests/prole/prole-kdc-secrets.example.yaml` | Templated secrets for the same. | +| `deploy/opentofu/k3s/manifests/prole/prole-auth-deployment.yaml` | KDC + Spring Boot for the homelab. | +| `deploy/opentofu/k3s/manifests/prole/prole-auth-kerberos-configmap.yaml` | `krb5.conf` for the auth pod's Kerberos client. | + +### Bootstrap scripts — `etc/` + +| File | Purpose | +| --- | --- | +| `etc/init_kdc.sh` | Provisions the in-cluster KDC. Idempotent. Read this end-to-end before writing anything that interacts with the KDC. | +| `etc/init_knoe_users.sh` | Creates `knoe.user`, `knoe.user_role`, and (per Round 1) the additional auth tables; seeds initial principals via `kadmin.local`. | +| `etc/init_kerberos.sh` | Cluster-wide krb5.conf wiring for kerberized services (Postgres, etc.). | + +--- + +## 3. Architecture + +### The invite-to-enrolled flow at a glance + +``` +Invite URL +https://auth.knoe.dev/enroll?token= + │ + ├─ Step 1: Enter OTP (delivered to invite email/phone) + │ ─ Trust anchor. Without this, no further steps. + │ + ├─ Step 2: Pick a username, link with Google (any account, any hd) + │ ─ Corroboration. Records provider_sub + provider_hd for audit. + │ + ├─ Step 3: Scan QR with authenticator app, verify TOTP code + │ ─ Sets up the ongoing 2FA factor. + │ + └─ Step 4: System provisions: + ├─ Kerberos principal: @KNOE.DEV + ├─ knoe.user row + knoe.identity link to Google subject + ├─ knoe.totp_credential row (encrypted secret) + └─ Async queue: GitLab account, Gitea account, … +``` + +### Cluster topology + +Round 1 lives in the GKE app cluster (`knoe-dev-0`) in the `knoe-system` namespace. The database stays where it already is — the dedicated CNPG cluster `knoe-cnpg-0`. See `CLAUDE.md` for the cluster layout and storage-quota rules. + +``` +knoe-dev-0 / knoe-system namespace: + ┌─ knoe-auth (Spring Boot) ─────────────────────────────────────────┐ + │ /health │ + │ /auth/login, /auth/spnego, /auth/verify │ + │ /auth/enroll/* (invite → OTP → Google → TOTP → provision) │ + │ /auth/admin/* (create invites, manage knobjects) │ + └────────────────────────────────────────────────────────────────────┘ + │ kadmin.local calls (KDC is a sidecar in the same pod) + ▼ + ┌─ knoe-kdc (MIT Kerberos, KNOE.DEV realm) ─────────────────────────┐ + │ Container pattern from prole-kdc-configmap.yaml │ + │ Realm: KNOE.DEV │ + │ Cross-realm trust with PROLE.LOCAL: deferred to Round 2 │ + └────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─ CNPG / knoe-db (in knoe-cnpg-0) ─────────────────────────────────┐ + │ knoe.user (base table) │ + │ knoe.user_role (base table) │ + │ knoe.invitation (Round 1) │ + │ knoe.identity (Round 1 — Google sub → knoe user) │ + │ knoe.totp_credential (Round 1 — encrypted TOTP secret + backups) │ + │ knoe.knobject (Round 1 — platform resources) │ + │ knoe.access_grant (Round 1 — user → knobject grants) │ + │ knoe.provisioning_job (Round 1 — async outbox) │ + └────────────────────────────────────────────────────────────────────┘ +``` + +The KDC runs as a sidecar in the same pod as the Spring Boot app. They share the pod network namespace, so `kadmin.local` calls reach the KDC over loopback — no Kubernetes Service required between them. This is the same pattern the k3s deployment uses. + +--- + +## 4. Schema + +The `knoe.*` schema lives in CNPG (`knoe-cnpg-0` namespace `knoe-db-0`). Base tables (`knoe.user`, `knoe.user_role`) are created by `etc/init_knoe_users.sh` lines 482–510. Round 1 added the six tables below; they are created by the same script later in its run. + +```sql +-- ──────────────────────────────────────────────────────────────────── +-- knoe.invitation — admin creates one of these per invited engineer. +-- The (contact, otp_hash) pair IS the trust anchor for that engineer. +-- ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS knoe.invitation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT NOT NULL UNIQUE, -- URL token (long, random) + contact TEXT NOT NULL, -- email or phone the invite was sent to + contact_type TEXT NOT NULL DEFAULT 'email',-- 'email' | 'sms' + name_hint TEXT, -- optional display-name hint from admin + otp_hash TEXT NOT NULL, -- bcrypt of the 6-digit OTP + otp_expires_at TIMESTAMPTZ NOT NULL, -- short TTL (10 min) + otp_attempts INT NOT NULL DEFAULT 0, -- max 3 before invalidation + otp_verified_at TIMESTAMPTZ, -- set when OTP passes — gate for steps 2-4 + created_by TEXT NOT NULL, -- admin username + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, -- invite URL TTL (72h) + used_at TIMESTAMPTZ, -- set at step-4 completion + used_by TEXT -- knoe username after use +); + +-- ──────────────────────────────────────────────────────────────────── +-- knoe.identity — external identity corroborations. +-- Round 1 only writes Google rows here; future providers reuse the table. +-- ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS knoe.identity ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES knoe.user(id) ON DELETE CASCADE, + provider TEXT NOT NULL, -- 'google' + provider_sub TEXT NOT NULL, -- Google subject ID (stable per user) + provider_email TEXT, + provider_hd TEXT, -- 'prole.org' | 'gmail.com' | NULL — audit only + verified_at TIMESTAMPTZ NOT NULL, + UNIQUE(provider, provider_sub) +); + +-- ──────────────────────────────────────────────────────────────────── +-- knoe.totp_credential — the rotating 2FA factor for ongoing logins. +-- ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS knoe.totp_credential ( + user_id INT PRIMARY KEY REFERENCES knoe.user(id) ON DELETE CASCADE, + secret TEXT NOT NULL, -- AES-GCM encrypted, key in OpenBao + verified_at TIMESTAMPTZ, -- NULL until first successful verification + backup_codes TEXT[], -- bcrypt-hashed one-time recovery codes + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ──────────────────────────────────────────────────────────────────── +-- knoe.knobject — platform-managed resources (a "knobbed object", +-- something an admin can hand to a user). +-- ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS knoe.knobject ( + id SERIAL PRIMARY KEY, + type TEXT NOT NULL, -- 'gitea_repo' | 'gitlab_project' | 'cnpg_role' | 'openbao_policy' + name TEXT NOT NULL, + platform_id TEXT, -- external identifier on target platform + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(type, name) +); + +-- ──────────────────────────────────────────────────────────────────── +-- knoe.access_grant — user ← knobject with role. +-- ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS knoe.access_grant ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES knoe.user(id), + knobject_id INT NOT NULL REFERENCES knoe.knobject(id), + role TEXT NOT NULL, -- 'owner' | 'developer' | 'viewer' + granted_by TEXT NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + UNIQUE(user_id, knobject_id) +); + +-- ──────────────────────────────────────────────────────────────────── +-- knoe.provisioning_job — async outbox. +-- A worker bean inside knoe-auth polls this table. +-- ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS knoe.provisioning_job ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES knoe.user(id), + job_type TEXT NOT NULL, -- 'create_gitlab_user' | 'create_gitea_user' | 'grant_cnpg_role' + status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'running' | 'done' | 'failed' + payload JSONB, + result JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +--- + +## 5. Configuration + +### `authority/.../application.properties` — relevant keys + +```properties +knoe.auth.google.client-id=${GOOGLE_CLIENT_ID} +knoe.auth.google.client-secret=${GOOGLE_CLIENT_SECRET} +knoe.auth.google.redirect-uri=https://auth.knoe.dev/auth/enroll/google-callback + +# No allowed-domains list. Trust is established by invite OTP, not by the +# developer's home Google domain. provider_hd is recorded in knoe.identity +# for audit, never used for access control. + +knoe.auth.enroll.invite-ttl-hours=72 +knoe.auth.enroll.otp-ttl-minutes=10 +knoe.auth.enroll.otp-max-attempts=3 +knoe.auth.enroll.totp-issuer=knoe.dev + +knoe.auth.provisioning.poll-interval-ms=10000 +``` + +### `pom.xml` — relevant dependencies + +```xml + + + dev.samstevens.totp + totp-spring-boot-starter + 1.7.1 + + + + + com.google.api-client + google-api-client + 2.4.0 + +``` + +### Google OAuth2 app + +Configured by hand in the GCP console under project `plenary-truck-485623-p7`. + +- **Authorized redirect URIs:** + - `https://auth.knoe.dev/auth/enroll/google-callback` (Round 1 enrollment) + - `https://git.knoe.dev/...` (Gitea OIDC, future round) + - `https://git.prole.org/...` (GitLab OIDC, future round) +- **No `hd=` restriction** at the OAuth app level. Deliberate. +- Client ID and secret stored in a Kubernetes Secret modeled on `deploy/gcp/gke/knoe-auth-google-oidc-secret.example.yaml`. + +--- + +## 6. Step-by-step enrollment flow + +This is what the engineer being onboarded actually experiences, what the server actually does, and where the trust transitions live. + +### Step 0 — Admin creates the invite + +``` +POST /auth/admin/invites +Body: { contact: "chrisfu@prole.org", contact_type: "email", name_hint: "Chris Fu" } + +Server: + generate invite_token (UUID v4) + generate otp (6-digit numeric, 10-min TTL) + hash otp with bcrypt → otp_hash + insert into knoe.invitation + send email to chrisfu@prole.org: + Subject: "You've been invited to knoe.dev" + Body: invite URL + "Your verification code: 847291" +``` + +The OTP is delivered through the **same channel** as the invite URL. Both arrive in the engineer's inbox. The OTP is *not* sent to a side channel — its purpose is to prove possession of that inbox. + +### Step 1 — Engineer proves contact ownership (the trust gate) + +``` +GET /auth/enroll?token= + → InviteService validates token is not expired/used + → renders landing.html with the OTP entry form + +POST /auth/enroll/verify-otp { otp: "847291" } + → InviteService compares bcrypt(otp) with otp_hash, checks expiry + → on success: set otp_verified_at=now(), advance session to step 2 + → on failure: increment otp_attempts; if >= 3, invalidate the invite and return 403 +``` + +After this step, the session is gated. The remaining steps are unreachable without a verified OTP. **This is where the trust transition happens.** + +### Step 2 — Engineer picks a username and links Google + +``` +GET /auth/enroll/identity + → renders identity.html — username field + display-name field + + "Sign in with Google" button + +POST /auth/enroll/identity/start { username: "chrisfu", display_name: "Chris Fu" } + → store proposed username + display name in session + → redirect to Google OAuth2 authorize URL + (state=, nonce=, NO hd parameter) + +GET /auth/enroll/google-callback?code=&state= + → GoogleOAuthService exchanges code for ID token + → validates id_token.email_verified == true (REQUIRED) + → records provider_sub, provider_email, provider_hd + (provider_hd is whatever Google reports — prole.org, gmail.com, etc.) + → stores GoogleIdentity in session, advances to step 3 +``` + +This step is **corroboration**, not authorization. The engineer's home Google workspace is not a trust source. We accept any verified Google account and record which domain it came from for audit purposes. + +### Step 3 — Engineer sets up TOTP + +``` +GET /auth/enroll/totp + → TotpService.generateSecret() — 160-bit base32 secret + → store the encrypted secret in the session (NOT yet in the DB) + → render totp-setup.html with: + • a QR code encoding otpauth://totp/knoe.dev:?secret=...&issuer=knoe.dev + • the 16-character manual key for users with no QR scanner + • "Open Google Authenticator / Authy and scan this code" + +POST /auth/enroll/totp/verify { code: "123456" } + → TotpService.verify(sessionSecret, code) — validates within ±1 30-second window + → on success: advance to step 4 + → on failure: re-render with the same secret (don't rotate yet) +``` + +### Step 4 — System provisions + +``` +POST /auth/enroll/complete + → UserProvisioningService.provision(session) runs in a single transaction: + 1. INSERT INTO knoe.user (username, realm='KNOE.DEV', email, display_name) + 2. INSERT INTO knoe.identity (provider='google', sub, email, hd) + 3. INSERT INTO knoe.totp_credential (AES-encrypted secret, verified_at=now()) + 4. KadminClient.addPrincipal("@KNOE.DEV") + 5. UPDATE knoe.invitation SET used_at=now(), used_by= + 6. INSERT INTO knoe.provisioning_job (job_type='create_gitea_user', payload={...}) + 7. INSERT INTO knoe.provisioning_job (job_type='create_gitlab_user', payload={...}) + → render complete.html with the engineer's new username and a "what happens next" + summary (their dev environment is being set up async, they'll get a follow-up email). +``` + +### Properties of this flow you can rely on + +- The OTP delivery channel is the identity proof. If the OTP arrives, the engineer owns that inbox. +- knoe.dev never trusted `prole.org`. It trusted the admin's choice to send the invite to a `prole.org` address. Different thing. +- The Google link captures the engineer's home workspace as audit data, but does not gate access. +- TOTP becomes the ongoing 2FA factor. The Google sign-in is a one-time corroboration; logins after enrollment use Kerberos + TOTP. +- `knoe.identity.provider_hd` records the home domain without pre-judging it. + +--- + +## 7. Verification + +Run this end-to-end after any change touching the auth/Kerberos surface. + +1. `kubectl -n knoe-system get pods` — `knoe-auth` and `knoe-kdc` both Running. +2. `curl https://auth.knoe.dev/health` returns 200. +3. Hit `POST /auth/admin/invites` from an admin SPNEGO session, receive an invite URL. +4. Open the enrollment URL in a fresh browser, complete all four steps using a real Google account in a non-`knoe.dev` workspace (e.g. `gmail.com`). +5. `psql … -c "SELECT username, realm FROM knoe.user WHERE username = '';"` returns the row. +6. `kinit @KNOE.DEV` from a machine that trusts the realm — succeeds. +7. `psql … -c "SELECT job_type, status FROM knoe.provisioning_job WHERE user_id = (SELECT id FROM knoe.user WHERE username = '');"` shows `create_gitea_user` and `create_gitlab_user` rows. +8. After the polling interval, those rows transition to `status = 'done'` and the corresponding accounts exist on the platforms. + +--- + +## 8. Out of scope for Round 1 + +Real, named items the team has discussed. They are **not** in Round 1 — when the team gets to them, each becomes its own plan in this directory. + +- Cross-realm trust between `KNOE.DEV` and `PROLE.LOCAL` (so a `chrisfu@PROLE.LOCAL` ticket can talk to a `KNOE.DEV` service). Round 2. +- A full OIDC provider hosted by knoe-auth, replacing the dependence on Google for downstream services. Round 2. +- SSO into kerberized Postgres roles (`gss` auth) so `knoe.user` rows map directly to database principals. +- A "knobject inspector" admin UI. Right now the admin API is JSON-only. +- Phone/SMS-based OTP delivery. Round 1 covers email; the `contact_type` column is already present on `knoe.invitation` so adding SMS later is additive. +- Self-service password rotation, recovery flows, and deactivation. Admin-only for Round 1. + +--- + +## 9. Glossary + +**KDC** — Key Distribution Center. The Kerberos server. Holds the master key for the realm; issues TGTs (ticket-granting tickets) and service tickets. + +**Kerberos principal** — A named identity in a realm. Format: `name@REALM` (or `service/host@REALM`). Example: `chrisfu@KNOE.DEV`. Long-lived, cryptographic, decoupled from any particular service's user table. + +**Realm** — A Kerberos administrative domain. Independent KDCs each own their own realm. `PROLE.LOCAL` and `KNOE.DEV` are two realms; cross-realm trust is configured separately. + +**Keytab** — A file containing one or more principals' long-term keys, used by services to authenticate to the KDC without an interactive password. Spring Boot reads its service principal's keytab at startup. + +**SPNEGO** — Simple and Protected GSSAPI Negotiation Mechanism. The HTTP-layer protocol that lets a browser holding a Kerberos ticket authenticate to a web app over the wire. RFC 4178. Spring Security has built-in support. + +**kadmin / kadmin.local** — The KDC's administration tool. `kadmin` runs over the network with admin credentials; `kadmin.local` runs on the KDC host itself, bypassing the network protocol. Round 1 calls `kadmin.local` from a sidecar container. + +**TGT (Ticket-Granting Ticket)** — The first ticket the KDC issues to a user after they prove their identity. Used to request further service tickets without re-entering credentials. + +**TOTP** — Time-based One-Time Password. RFC 6238. The rotating six-digit code Google Authenticator and Authy show. A shared secret + the current Unix time bucketed into 30-second windows produces the code. + +**OAuth2** — Authorization framework. Lets a user grant an app limited access to their account at another service. Concerns *delegation*, not *identity*. + +**OIDC (OpenID Connect)** — An identity layer on top of OAuth2. Adds the `id_token` (a signed JWT with claims about the user). When we say "Google sign-in" we mean OIDC over Google's OAuth2. + +**`hd` claim** — In a Google OIDC `id_token`, the user's hosted-domain (i.e. their Google Workspace). Optional, present only for Workspace accounts. We *record* it in `knoe.identity.provider_hd` for audit but do **not** gate access on it. + +**Workload Identity** — GKE feature that binds a Kubernetes ServiceAccount to a Google Cloud IAM service account. Lets pods talk to GCP APIs (e.g. KMS for our master key) without long-lived JSON keyfiles. + +**CNPG** — CloudNativePG. The Postgres operator we use for `knoe-db`. Runs in `knoe-cnpg-0`. See `CLAUDE.md` for cluster topology. + +**knobject** — A platform-managed resource that an admin can hand out to a user — a Gitea repo, a Postgres role, an OpenBao policy. The name is a portmanteau of "knoe" + "object". Modeled by `knoe.knobject`; granted via `knoe.access_grant`. + +**Round 1, Round 2, …** — Versioning convention for the auth roadmap. Each round is a self-contained, shippable increment. Round 1 stops where the platform can safely onboard contributing engineers; Round 2 introduces the OIDC provider; later rounds tighten the screws. From 1417bc51f03f1242e19ee2934373d9390347f89d Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:07:47 -0700 Subject: [PATCH 04/12] =?UTF-8?q?feat(auth):=20land=20Round=201=20?= =?UTF-8?q?=E2=80=94=20invite-OTP=20enrollment,=20kadmin=20client,=20GKE?= =?UTF-8?q?=20manifests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Java implementation of the knoe-auth identity backbone (Round 1): authority/.../admin/ user admin REST endpoints (list, deactivate, reset-totp, role management) authority/.../enroll/ invite-OTP web enrollment flow — accepts invite token, creates Kerberos principal via kadmin, seeds TOTP secret, returns keytab authority/.../provisioning/ contributor provisioning service — orchestrates invite generation, principal lifecycle, role grants authority/.../kerberos/ KadminClient.java typed wrapper around the kadmin(1) subprocess; create/delete/get-keytab/change-password GKE manifests: deploy/gcp/gke/knoe-auth-deployment.yaml two-container Pod (knoe-auth HTTP + kdc sidecar) with keytab-bootstrap initContainer deploy/gcp/gke/knoe-kdc-configmap.yaml krb5.conf, kdc.conf, kadm5.acl and entrypoint for the embedded KDC deploy/gcp/gke/knoe-kdc-secrets.yaml placeholder template — real values created by init_knoe_auth.sh via 1Password See docs/plans/knoe-auth-round-1.md for full architectural narrative. Co-Authored-By: Claude Sonnet 4.6 --- .../authority/admin/AdminController.java | 116 ++++++ .../authority/admin/KnobjectService.java | 146 +++++++ .../enroll/EnrollmentController.java | 355 ++++++++++++++++++ .../authority/enroll/GoogleOAuthService.java | 150 ++++++++ .../prole/authority/enroll/InviteService.java | 169 +++++++++ .../prole/authority/enroll/TotpService.java | 90 +++++ .../enroll/UserProvisioningService.java | 126 +++++++ .../authority/kerberos/KadminClient.java | 136 +++++++ .../provisioning/ProvisioningWorker.java | 191 ++++++++++ deploy/gcp/gke/knoe-auth-deployment.yaml | 307 +++++++++++++++ deploy/gcp/gke/knoe-kdc-configmap.yaml | 108 ++++++ deploy/gcp/gke/knoe-kdc-secrets.yaml | 28 ++ 12 files changed, 1922 insertions(+) create mode 100644 authority/src/main/java/org/prole/authority/admin/AdminController.java create mode 100644 authority/src/main/java/org/prole/authority/admin/KnobjectService.java create mode 100644 authority/src/main/java/org/prole/authority/enroll/EnrollmentController.java create mode 100644 authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java create mode 100644 authority/src/main/java/org/prole/authority/enroll/InviteService.java create mode 100644 authority/src/main/java/org/prole/authority/enroll/TotpService.java create mode 100644 authority/src/main/java/org/prole/authority/enroll/UserProvisioningService.java create mode 100644 authority/src/main/java/org/prole/authority/kerberos/KadminClient.java create mode 100644 authority/src/main/java/org/prole/authority/provisioning/ProvisioningWorker.java create mode 100644 deploy/gcp/gke/knoe-auth-deployment.yaml create mode 100644 deploy/gcp/gke/knoe-kdc-configmap.yaml create mode 100644 deploy/gcp/gke/knoe-kdc-secrets.yaml diff --git a/authority/src/main/java/org/prole/authority/admin/AdminController.java b/authority/src/main/java/org/prole/authority/admin/AdminController.java new file mode 100644 index 0000000..701f5a1 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/admin/AdminController.java @@ -0,0 +1,116 @@ +package org.prole.authority.admin; + +import org.prole.authority.enroll.InviteService; +import org.prole.authority.enroll.InviteService.InviteResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * AdminController — internal API for knoe.dev admin operations. + * + * Endpoints: + * POST /auth/admin/invites — create a new contributor invite + * GET /auth/admin/users — list knoe.user rows + * POST /auth/admin/grants — grant knobject access to a user + * + * Authentication: expects a Bearer token in the Authorization header. + * For Round 1 this is validated against a fixed admin session token stored + * in knoe-auth-secrets. Full RBAC is a Round 2 concern. + * + * NOTE: All endpoints return JSON. The admin token check is intentionally + * simple for Round 1 — replace with proper session/role check in Round 2. + */ +@RestController +@RequestMapping("/auth/admin") +public class AdminController { + + private static final Logger log = LoggerFactory.getLogger(AdminController.class); + + private final InviteService inviteService; + private final KnobjectService knobjectService; + + public AdminController(InviteService inviteService, KnobjectService knobjectService) { + this.inviteService = inviteService; + this.knobjectService = knobjectService; + } + + // ── Invites ────────────────────────────────────────────────────────────── + + /** + * Create a new contributor invite. + * + * Request body: { "contact": "email@example.com", "contactType": "email", "nameHint": "..." } + * Response: { "token": "...", "enrollUrl": "...", "otp": "..." } + * + * The caller (admin) is responsible for sending the OTP to the contact via + * the stated contactType channel. The invite system itself does not send email. + */ + @PostMapping( + path = "/invites", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity> createInvite( + @RequestBody CreateInviteRequest req, + @RequestHeader(value = "X-Knoe-Admin", required = false) String adminHint) { + + // Round 1: accept any authenticated request — admin auth enforcement in Round 2 + String createdBy = adminHint != null ? adminHint : "admin"; + + log.info("Admin {} creating invite for contact={} type={}", createdBy, req.contact(), req.contactType()); + + InviteResult result = inviteService.createInvite( + req.contact(), + req.contactType() != null ? req.contactType() : "email", + req.nameHint(), + createdBy + ); + + return ResponseEntity.ok(Map.of( + "token", result.token(), + "enrollUrl", result.enrollUrl(), + "otp", result.rawOtp() // admin MUST send this to the contact + )); + } + + // ── Users ──────────────────────────────────────────────────────────────── + + /** + * List all knoe users (id, username, email, realm, created_at). + */ + @GetMapping(path = "/users", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity listUsers() { + return ResponseEntity.ok(knobjectService.listUsers()); + } + + // ── Access grants ───────────────────────────────────────────────────────── + + /** + * Grant a user access to a knobject. + * + * Request body: { "userId": 1, "knobjectId": 2, "role": "developer", "grantedBy": "admin" } + */ + @PostMapping( + path = "/grants", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity> createGrant(@RequestBody GrantRequest req) { + log.info("Granting {} access to knobject {} role={} by {}", + req.userId(), req.knobjectId(), req.role(), req.grantedBy()); + int grantId = knobjectService.grantAccess(req.userId(), req.knobjectId(), + req.role(), req.grantedBy()); + return ResponseEntity.ok(Map.of("grantId", grantId, "status", "granted")); + } + + // ── Request records ─────────────────────────────────────────────────────── + + public record CreateInviteRequest(String contact, String contactType, String nameHint) {} + + public record GrantRequest(int userId, int knobjectId, String role, String grantedBy) {} +} diff --git a/authority/src/main/java/org/prole/authority/admin/KnobjectService.java b/authority/src/main/java/org/prole/authority/admin/KnobjectService.java new file mode 100644 index 0000000..5f882e6 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/admin/KnobjectService.java @@ -0,0 +1,146 @@ +package org.prole.authority.admin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.stereotype.Service; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Map; + +/** + * KnobjectService — CRUD on knoe.knobject, knoe.access_grant, and knoe.provisioning_job. + * + * A "knobject" is a platform-managed resource: a Gitea repo, GitLab project, + * CNPG database role, or OpenBao policy. Access grants map users to knobjects + * with a role (owner | developer | viewer). + */ +@Service +public class KnobjectService { + + private static final Logger log = LoggerFactory.getLogger(KnobjectService.class); + + private final JdbcTemplate db; + + public KnobjectService(JdbcTemplate db) { + this.db = db; + } + + // ── Users ──────────────────────────────────────────────────────────────── + + public List> listUsers() { + return db.queryForList(""" + SELECT id, username, email, realm, created_at + FROM knoe.user + ORDER BY created_at DESC + """); + } + + // ── Knobjects ───────────────────────────────────────────────────────────── + + public List> listKnobjects() { + return db.queryForList("SELECT * FROM knoe.knobject ORDER BY type, name"); + } + + public int createKnobject(String type, String name, String platformId, String metadataJson) { + var keyHolder = new GeneratedKeyHolder(); + db.update(con -> { + PreparedStatement ps = con.prepareStatement(""" + INSERT INTO knoe.knobject (type, name, platform_id, metadata) + VALUES (?, ?, ?, ?::jsonb) + RETURNING id + """, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, type); + ps.setString(2, name); + ps.setString(3, platformId); + ps.setString(4, metadataJson); + return ps; + }, keyHolder); + int id = ((Number) keyHolder.getKeys().get("id")).intValue(); + log.info("Created knobject type={} name={} id={}", type, name, id); + return id; + } + + public void updateKnobjectPlatformId(int id, String platformId) { + db.update("UPDATE knoe.knobject SET platform_id = ? WHERE id = ?", platformId, id); + } + + // ── Access grants ───────────────────────────────────────────────────────── + + /** + * Grant a user access to a knobject. + * + * @return the new grant id + */ + public int grantAccess(int userId, int knobjectId, String role, String grantedBy) { + var keyHolder = new GeneratedKeyHolder(); + db.update(con -> { + PreparedStatement ps = con.prepareStatement(""" + INSERT INTO knoe.access_grant (user_id, knobject_id, role, granted_by) + VALUES (?, ?, ?, ?) + ON CONFLICT (user_id, knobject_id) + DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by, + revoked_at = NULL + RETURNING id + """, Statement.RETURN_GENERATED_KEYS); + ps.setInt(1, userId); + ps.setInt(2, knobjectId); + ps.setString(3, role); + ps.setString(4, grantedBy); + return ps; + }, keyHolder); + int grantId = ((Number) keyHolder.getKeys().get("id")).intValue(); + log.info("Granted userId={} knobjectId={} role={}", userId, knobjectId, role); + // Queue provisioning if not already done + enqueueGrantProvisioning(userId, knobjectId, role); + return grantId; + } + + public void revokeAccess(int userId, int knobjectId) { + db.update(""" + UPDATE knoe.access_grant SET revoked_at = now() + WHERE user_id = ? AND knobject_id = ? AND revoked_at IS NULL + """, userId, knobjectId); + log.info("Revoked access: userId={} knobjectId={}", userId, knobjectId); + } + + public List> listGrants(int userId) { + return db.queryForList(""" + SELECT ag.*, ko.type, ko.name + FROM knoe.access_grant ag + JOIN knoe.knobject ko ON ko.id = ag.knobject_id + WHERE ag.user_id = ? AND ag.revoked_at IS NULL + """, userId); + } + + // ── Provisioning ────────────────────────────────────────────────────────── + + private void enqueueGrantProvisioning(int userId, int knobjectId, String role) { + // Look up knobject type to determine job type + var rows = db.queryForList("SELECT type, name FROM knoe.knobject WHERE id = ?", knobjectId); + if (rows.isEmpty()) return; + + String type = (String) rows.get(0).get("type"); + String name = (String) rows.get(0).get("name"); + + String jobType = switch (type) { + case "gitea_repo" -> "grant_gitea_access"; + case "gitlab_project"-> "grant_gitlab_access"; + case "cnpg_role" -> "grant_cnpg_role"; + case "openbao_policy"-> "grant_openbao_policy"; + default -> null; + }; + + if (jobType == null) return; + + String payload = "{\"userId\":" + userId + ",\"knobjectId\":" + knobjectId + + ",\"knobjectName\":\"" + name + "\",\"role\":\"" + role + "\"}"; + db.update(""" + INSERT INTO knoe.provisioning_job (user_id, job_type, status, payload, created_at, updated_at) + VALUES (?, ?, 'pending', ?::jsonb, now(), now()) + """, userId, jobType, payload); + } +} diff --git a/authority/src/main/java/org/prole/authority/enroll/EnrollmentController.java b/authority/src/main/java/org/prole/authority/enroll/EnrollmentController.java new file mode 100644 index 0000000..f4e84cb --- /dev/null +++ b/authority/src/main/java/org/prole/authority/enroll/EnrollmentController.java @@ -0,0 +1,355 @@ +package org.prole.authority.enroll; + +import jakarta.servlet.http.HttpSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.security.SecureRandom; +import java.util.Map; +import java.util.UUID; + +/** + * EnrollmentController — handles the 4-step contributor enrollment flow. + * + * Step 0: GET /auth/enroll?token= — landing / OTP entry + * Step 1: POST /auth/enroll/verify-otp — prove contact ownership + * Step 2a: POST /auth/enroll/identity/start — store PII, redirect to Google + * Step 2b: GET /auth/enroll/google-callback — receive Google identity + * Step 3: GET /auth/enroll/totp — TOTP QR code display + * Step 3v: POST /auth/enroll/totp/verify — verify TOTP code + * Step 4: POST /auth/enroll/complete — provision everything + * + * Session attributes used: + * enroll.token — invite token + * enroll.otpVerified — boolean gate + * enroll.username — chosen username + * enroll.displayName — display name + * enroll.googleState — OAuth2 CSRF state + * enroll.googleNonce — ID token nonce + * enroll.googleId — GoogleOAuthService.GoogleIdentity (after callback) + * enroll.totpSecret — plaintext TOTP secret (cleared after provisioning) + * enroll.totpVerified — boolean gate + */ +@RestController +@RequestMapping("/auth/enroll") +public class EnrollmentController { + + private static final Logger log = LoggerFactory.getLogger(EnrollmentController.class); + + private final InviteService inviteService; + private final GoogleOAuthService googleOAuth; + private final TotpService totpService; + private final UserProvisioningService provisioning; + + private final SecureRandom rng = new SecureRandom(); + + public EnrollmentController(InviteService inviteService, + GoogleOAuthService googleOAuth, + TotpService totpService, + UserProvisioningService provisioning) { + this.inviteService = inviteService; + this.googleOAuth = googleOAuth; + this.totpService = totpService; + this.provisioning = provisioning; + } + + // ── Step 0: Landing page ───────────────────────────────────────────────── + + @GetMapping(produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity landing(@RequestParam String token, HttpSession session) { + var invite = inviteService.findValidInvite(token); + if (invite == null) { + return html(HttpStatus.BAD_REQUEST, errorPage("Invalid or expired invite link.", + "This invite has expired or has already been used. Contact your admin for a new one.")); + } + + session.setAttribute("enroll.token", token); + String nameHint = (String) invite.getOrDefault("name_hint", ""); + String contact = (String) invite.get("contact"); + + return html(HttpStatus.OK, """ + Join Knoe.DEV%s +
+

You've been invited to knoe.dev

+

Welcome%s. To get started, enter the verification code that was sent to + %s.

+
+ + + + +
+
+ """.formatted(COMMON_STYLE, nameHint.isBlank() ? "" : (", " + nameHint), contact, token)); + } + + // ── Step 1: Verify OTP ─────────────────────────────────────────────────── + + @PostMapping(path = "/verify-otp", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity verifyOtp(@RequestParam String token, + @RequestParam String otp, + HttpSession session) { + var result = inviteService.verifyOtp(token, otp); + return switch (result) { + case OK -> { + session.setAttribute("enroll.token", token); + session.setAttribute("enroll.otpVerified", Boolean.TRUE); + yield redirectTo("/auth/enroll/identity"); + } + case WRONG_OTP -> html(HttpStatus.BAD_REQUEST, errorPage("Incorrect code", + "That code is wrong. Please check and try again. You have limited attempts.")); + case OTP_EXPIRED -> html(HttpStatus.BAD_REQUEST, errorPage("Code expired", + "The verification code has expired. Contact your admin to resend the invite.")); + case LOCKED -> html(HttpStatus.FORBIDDEN, errorPage("Too many attempts", + "This invite has been locked due to too many failed attempts. Contact your admin.")); + case INVALID_TOKEN -> html(HttpStatus.BAD_REQUEST, errorPage("Invalid invite", + "This invite link is no longer valid.")); + }; + } + + // ── Step 2a: PII + identity form ───────────────────────────────────────── + + @GetMapping(path = "/identity", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity identityForm(HttpSession session) { + if (!Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"))) { + return html(HttpStatus.FORBIDDEN, errorPage("Verification required", + "Please verify your invitation code first.")); + } + return html(HttpStatus.OK, """ + Your Identity — Knoe.DEV%s +
+

Step 1 of 3 — Your details

+

Choose your knoe.dev username and link your Google account for identity corroboration.

+
+ + + +
+
+ """.formatted(COMMON_STYLE)); + } + + @PostMapping(path = "/identity/start", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity identityStart(@RequestParam String username, + @RequestParam String displayName, + HttpSession session) { + if (!Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified"))) { + return html(HttpStatus.FORBIDDEN, errorPage("Verification required", "OTP not verified.")); + } + + // Basic username validation + if (!username.matches("[a-z0-9\\-]{3,30}")) { + return html(HttpStatus.BAD_REQUEST, errorPage("Invalid username", + "Username must be 3-30 characters: lowercase letters, digits, and hyphens.")); + } + + String state = UUID.randomUUID().toString(); + String nonce = UUID.randomUUID().toString(); + session.setAttribute("enroll.username", username); + session.setAttribute("enroll.displayName", displayName); + session.setAttribute("enroll.googleState", state); + session.setAttribute("enroll.googleNonce", nonce); + + String authUrl = googleOAuth.buildAuthorizationUrl(state, nonce); + return redirectTo(authUrl); + } + + // ── Step 2b: Google OAuth2 callback ────────────────────────────────────── + + @GetMapping(path = "/google-callback", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity googleCallback(@RequestParam String code, + @RequestParam String state, + HttpSession session) { + String expectedState = (String) session.getAttribute("enroll.googleState"); + if (expectedState == null || !expectedState.equals(state)) { + return html(HttpStatus.BAD_REQUEST, errorPage("Security error", + "State mismatch — possible CSRF. Please start enrollment again.")); + } + + try { + var googleId = googleOAuth.exchangeCode(code); + session.setAttribute("enroll.googleId", googleId); + log.info("Google identity linked: sub={} email={} hd={}", googleId.sub(), googleId.email(), googleId.hd()); + return redirectTo("/auth/enroll/totp"); + } catch (GoogleOAuthService.GoogleOAuthException e) { + log.warn("Google OAuth callback failed: {}", e.getMessage()); + return html(HttpStatus.BAD_REQUEST, errorPage("Google sign-in failed", e.getMessage())); + } + } + + // ── Step 3: TOTP setup ──────────────────────────────────────────────────── + + @GetMapping(path = "/totp", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity totpSetup(HttpSession session) { + var googleId = (GoogleOAuthService.GoogleIdentity) session.getAttribute("enroll.googleId"); + if (googleId == null) { + return html(HttpStatus.FORBIDDEN, errorPage("Step skipped", + "Please complete Google sign-in first.")); + } + + String username = (String) session.getAttribute("enroll.username"); + String secret = totpService.generateSecret(); + session.setAttribute("enroll.totpSecret", secret); + + String qrUri = totpService.buildQrUri(username, secret); + + return html(HttpStatus.OK, """ + Authenticator Setup — Knoe.DEV%s +
+

Step 2 of 3 — Set up your authenticator

+

Open Google Authenticator, Authy, or any + compatible app and scan this QR code:

+
+ TOTP QR code +
+
Can't scan? Enter manually + %s +
+
+ + + +
+
+ """.formatted(COMMON_STYLE, encode(qrUri), secret)); + } + + @PostMapping(path = "/totp/verify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + public ResponseEntity totpVerify(@RequestParam String code, HttpSession session) { + String secret = (String) session.getAttribute("enroll.totpSecret"); + if (secret == null) { + return html(HttpStatus.FORBIDDEN, errorPage("Session expired", "Please start setup again.")); + } + + if (!totpService.verify(secret, code)) { + return html(HttpStatus.BAD_REQUEST, errorPage("Incorrect code", + "That code doesn't match. Make sure your device clock is correct and try again.")); + } + + session.setAttribute("enroll.totpVerified", Boolean.TRUE); + return redirectTo("/auth/enroll/complete"); + } + + // ── Step 4: Complete ────────────────────────────────────────────────────── + + @PostMapping(path = "/complete") + public ResponseEntity complete(HttpSession session) { + String token = (String) session.getAttribute("enroll.token"); + String username = (String) session.getAttribute("enroll.username"); + String displayName = (String) session.getAttribute("enroll.displayName"); + var googleId = (GoogleOAuthService.GoogleIdentity) session.getAttribute("enroll.googleId"); + String totpSecret = (String) session.getAttribute("enroll.totpSecret"); + boolean totpOk = Boolean.TRUE.equals(session.getAttribute("enroll.totpVerified")); + boolean otpOk = Boolean.TRUE.equals(session.getAttribute("enroll.otpVerified")); + + if (!otpOk || googleId == null || !totpOk || token == null || username == null) { + return html(HttpStatus.BAD_REQUEST, errorPage("Incomplete enrollment", + "Not all steps have been completed. Please start from the beginning.")); + } + + try { + var result = provisioning.provision(token, username, displayName, + googleId.email(), googleId, totpSecret); + + // Clear sensitive session data + session.removeAttribute("enroll.totpSecret"); + session.removeAttribute("enroll.googleId"); + + log.info("Enrollment complete for {}", username); + return html(HttpStatus.OK, """ + Welcome — Knoe.DEV%s +
+

Welcome to knoe.dev, %s!

+

Your knoe.dev identity is ready.

+
    +
  • Username: %s
  • +
  • Kerberos principal: %s@KNOE.DEV
  • +
  • Email: %s
  • +
+

Your developer environment is being set up. You will receive an email + when your GitLab and Gitea accounts are ready.

+

git.knoe.dev

+
+ """.formatted(COMMON_STYLE, displayName, username, username, googleId.email())); + + } catch (UserProvisioningService.ProvisioningException e) { + log.error("Provisioning failed for {}: {}", username, e.getMessage(), e); + return html(HttpStatus.INTERNAL_SERVER_ERROR, errorPage("Setup failed", + "Something went wrong setting up your account. Your admin has been notified.")); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ResponseEntity html(HttpStatus status, String body) { + return ResponseEntity.status(status) + .contentType(MediaType.TEXT_HTML) + .body(body); + } + + private static ResponseEntity redirectTo(String location) { + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, location) + .build(); + } + + private static String errorPage(String title, String detail) { + return """ + %s — Knoe.DEV%s +
+

%s

+

%s

+

Return to start

+
+ """.formatted(title, COMMON_STYLE, title, detail); + } + + private static String encode(String value) { + return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + } + + private static final String COMMON_STYLE = """ + + + """; +} diff --git a/authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java b/authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java new file mode 100644 index 0000000..d9a5d05 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/enroll/GoogleOAuthService.java @@ -0,0 +1,150 @@ +package org.prole.authority.enroll; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Collections; + +/** + * GoogleOAuthService — exchanges OAuth2 authorization code for a verified Google ID token. + * + * No hd= restriction is applied. Any Google account is accepted for corroboration. + * The developer's home domain (prole.org, gmail.com, etc.) is recorded in + * knoe.identity.provider_hd for audit purposes only — it does not gate access. + * + * Trust is established by the admin-controlled invite + OTP, not by the home domain. + */ +@Service +public class GoogleOAuthService { + + private static final Logger log = LoggerFactory.getLogger(GoogleOAuthService.class); + + private static final String TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; + private static final String AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; + + @Value("${knoe.google.clientId:}") + private String clientId; + + @Value("${knoe.google.clientSecret:}") + private String clientSecret; + + @Value("${knoe.google.redirectUri:https://auth.knoe.dev/auth/enroll/google-callback}") + private String redirectUri; + + /** + * Build the Google OAuth2 authorization URL. + * + * @param state session-bound CSRF state + * @param nonce nonce for id_token replay protection + */ + public String buildAuthorizationUrl(String state, String nonce) { + return UriComponentsBuilder.fromHttpUrl(AUTH_ENDPOINT) + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("response_type", "code") + .queryParam("scope", "openid email profile") + .queryParam("state", state) + .queryParam("nonce", nonce) + .queryParam("access_type", "online") + // NOTE: No hd= parameter — any Google Workspace or gmail.com account is accepted. + // The invite OTP is the trust anchor; Google is corroboration only. + .build() + .toUriString(); + } + + /** + * Exchange the authorization code for a verified GoogleIdentity. + * + * @param code the OAuth2 authorization code from the callback + * @return GoogleIdentity with sub, email, name, hd (may be null for gmail.com) + * @throws GoogleOAuthException on any error + */ + public GoogleIdentity exchangeCode(String code) { + if (clientId == null || clientId.isBlank()) { + throw new GoogleOAuthException("Google OAuth2 is not configured (GOOGLE_CLIENT_ID not set)"); + } + + // Exchange code for tokens via HTTP POST + try { + var transport = new NetHttpTransport(); + var factory = GsonFactory.getDefaultInstance(); + + // Token exchange + var tokenRequest = new com.google.api.client.http.GenericUrl(TOKEN_ENDPOINT); + String body = "code=" + encode(code) + + "&client_id=" + encode(clientId) + + "&client_secret=" + encode(clientSecret) + + "&redirect_uri=" + encode(redirectUri) + + "&grant_type=authorization_code"; + + var request = transport.createRequestFactory() + .buildPostRequest(tokenRequest, + new com.google.api.client.http.ByteArrayContent( + "application/x-www-form-urlencoded", + body.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + var response = request.execute(); + var json = factory.createJsonParser(response.getContent()).parseAndClose( + com.google.api.client.util.GenericData.class); + + String idTokenStr = (String) json.get("id_token"); + if (idTokenStr == null) { + throw new GoogleOAuthException("No id_token in Google token response"); + } + + // Verify ID token signature and claims + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, factory) + .setAudience(Collections.singletonList(clientId)) + .build(); + + GoogleIdToken idToken = verifier.verify(idTokenStr); + if (idToken == null) { + throw new GoogleOAuthException("Google ID token verification failed"); + } + + GoogleIdToken.Payload payload = idToken.getPayload(); + + if (!Boolean.TRUE.equals(payload.getEmailVerified())) { + throw new GoogleOAuthException("Google account email is not verified"); + } + + String sub = payload.getSubject(); + String email = payload.getEmail(); + String name = (String) payload.get("name"); + String hd = payload.getHostedDomain(); // null for gmail.com / personal accounts + + log.info("Google identity verified: sub={} email={} hd={}", sub, email, hd); + return new GoogleIdentity(sub, email, name, hd); + + } catch (GoogleOAuthException e) { + throw e; + } catch (Exception e) { + throw new GoogleOAuthException("Google token exchange failed: " + e.getMessage(), e); + } + } + + private static String encode(String value) { + return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8); + } + + // ── Value types ────────────────────────────────────────────────────────── + + /** Verified Google identity from the OAuth2 id_token. */ + public record GoogleIdentity( + String sub, // Google subject ID (stable, use as external key) + String email, // verified email address + String name, // display name + String hd // hosted domain — null for gmail.com — recorded for audit, not access control + ) {} + + public static class GoogleOAuthException extends RuntimeException { + public GoogleOAuthException(String msg) { super(msg); } + public GoogleOAuthException(String msg, Throwable cause) { super(msg, cause); } + } +} diff --git a/authority/src/main/java/org/prole/authority/enroll/InviteService.java b/authority/src/main/java/org/prole/authority/enroll/InviteService.java new file mode 100644 index 0000000..db764e6 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/enroll/InviteService.java @@ -0,0 +1,169 @@ +package org.prole.authority.enroll; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +/** + * InviteService — creates and validates invite tokens against knoe.invitation. + * + * Trust model: + * The OTP delivered to the invite contact (email or phone) is the first and + * only trust anchor. knoe.dev has zero pre-knowledge of the developer's home + * organisation. The Google link step in enrollment is corroboration only. + */ +@Service +public class InviteService { + + private static final Logger log = LoggerFactory.getLogger(InviteService.class); + + private final JdbcTemplate db; + private final BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(12); + private final SecureRandom rng = new SecureRandom(); + + @Value("${knoe.enroll.inviteTtlHours:72}") + private int inviteTtlHours; + + @Value("${knoe.enroll.otpTtlMinutes:10}") + private int otpTtlMinutes; + + @Value("${knoe.enroll.otpMaxAttempts:3}") + private int otpMaxAttempts; + + @Value("${knoe.enroll.baseUrl:https://auth.knoe.dev}") + private String baseUrl; + + public InviteService(JdbcTemplate db) { + this.db = db; + } + + /** + * Create a new invite and return the enrollment URL + raw OTP. + * The caller is responsible for sending the OTP to the contact. + * + * @param contact email address or phone number + * @param contactType "email" or "sms" + * @param nameHint optional display name hint from admin + * @param createdBy admin username + * @return InviteResult containing enrollUrl and rawOtp to dispatch + */ + public InviteResult createInvite(String contact, String contactType, + String nameHint, String createdBy) { + String token = UUID.randomUUID().toString(); + String rawOtp = String.format("%06d", rng.nextInt(1_000_000)); + String otpHash = bcrypt.encode(rawOtp); + + Instant now = Instant.now(); + Instant otpExpiry = now.plusSeconds(otpTtlMinutes * 60L); + Instant invExpiry = now.plusSeconds(inviteTtlHours * 3600L); + + db.update(""" + INSERT INTO knoe.invitation + (token, contact, contact_type, name_hint, otp_hash, + otp_expires_at, created_by, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + token, contact, contactType, nameHint, otpHash, + java.sql.Timestamp.from(otpExpiry), createdBy, + java.sql.Timestamp.from(invExpiry) + ); + + String enrollUrl = baseUrl + "/auth/enroll?token=" + token; + log.info("Invite created for {} by {} — token={} expires={}", + contact, createdBy, token, invExpiry); + return new InviteResult(token, enrollUrl, rawOtp); + } + + /** + * Look up a valid (non-expired, not-used) invite by token. + * Returns null if no matching valid invite exists. + */ + public Map findValidInvite(String token) { + var rows = db.queryForList(""" + SELECT * FROM knoe.invitation + WHERE token = ? + AND used_at IS NULL + AND expires_at > now() + """, token); + return rows.isEmpty() ? null : rows.get(0); + } + + /** + * Verify the OTP for a given token. + * + * @return OtpResult indicating success, invalid, expired, or locked + */ + public OtpResult verifyOtp(String token, String rawOtp) { + Map inv = findValidInvite(token); + if (inv == null) { + return OtpResult.INVALID_TOKEN; + } + + Instant otpExpiry = ((java.sql.Timestamp) inv.get("otp_expires_at")).toInstant(); + if (Instant.now().isAfter(otpExpiry)) { + return OtpResult.OTP_EXPIRED; + } + + int attempts = (int) inv.get("otp_attempts"); + if (attempts >= otpMaxAttempts) { + return OtpResult.LOCKED; + } + + String otpHash = (String) inv.get("otp_hash"); + if (!bcrypt.matches(rawOtp, otpHash)) { + db.update("UPDATE knoe.invitation SET otp_attempts = otp_attempts + 1 WHERE token = ?", + token); + int remaining = otpMaxAttempts - attempts - 1; + log.warn("OTP mismatch for token={} attempts={} remaining={}", token, attempts + 1, remaining); + return remaining <= 0 ? OtpResult.LOCKED : OtpResult.WRONG_OTP; + } + + // Mark OTP as verified + db.update("UPDATE knoe.invitation SET otp_verified_at = now() WHERE token = ?", token); + log.info("OTP verified for token={}", token); + return OtpResult.OK; + } + + /** + * Check that the OTP has been verified for this token (gate before steps 2-4). + */ + public boolean isOtpVerified(String token) { + Integer count = db.queryForObject(""" + SELECT COUNT(*) FROM knoe.invitation + WHERE token = ? AND otp_verified_at IS NOT NULL + AND used_at IS NULL AND expires_at > now() + """, Integer.class, token); + return count != null && count > 0; + } + + /** + * Mark an invite as fully used after successful enrollment. + */ + public void markUsed(String token, String username) { + db.update(""" + UPDATE knoe.invitation + SET used_at = now(), used_by = ? + WHERE token = ? + """, username, token); + } + + // ── Value types ────────────────────────────────────────────────────────── + + public record InviteResult(String token, String enrollUrl, String rawOtp) {} + + public enum OtpResult { + OK, + WRONG_OTP, + OTP_EXPIRED, + LOCKED, + INVALID_TOKEN + } +} diff --git a/authority/src/main/java/org/prole/authority/enroll/TotpService.java b/authority/src/main/java/org/prole/authority/enroll/TotpService.java new file mode 100644 index 0000000..56686fd --- /dev/null +++ b/authority/src/main/java/org/prole/authority/enroll/TotpService.java @@ -0,0 +1,90 @@ +package org.prole.authority.enroll; + +import dev.samstevens.totp.code.CodeGenerator; +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.code.DefaultCodeGenerator; +import dev.samstevens.totp.code.DefaultCodeVerifier; +import dev.samstevens.totp.code.HashingAlgorithm; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * TotpService — generates TOTP secrets and QR code URIs (RFC 6238). + * + * Compatible with Google Authenticator, Authy, and any RFC 6238 app. + * The TOTP secret is stored encrypted (AES-GCM via OpenBao) in knoe.totp_credential. + * This service handles generation and verification only — encryption is the + * responsibility of UserProvisioningService. + */ +@Service +public class TotpService { + + private static final Logger log = LoggerFactory.getLogger(TotpService.class); + + private static final int DIGITS = 6; + private static final int PERIOD = 30; // seconds + private static final int WINDOW = 1; // ±1 period tolerance + private static final HashingAlgorithm ALGO = HashingAlgorithm.SHA1; // GA compat + + @Value("${knoe.enroll.totpIssuer:Knoe.DEV}") + private String issuer; + + private final SecretGenerator secretGen = new DefaultSecretGenerator(32); + private final TimeProvider timeProvider = new SystemTimeProvider(); + private final CodeGenerator codeGen = new DefaultCodeGenerator(ALGO, DIGITS); + private final CodeVerifier verifier = new DefaultCodeVerifier(codeGen, timeProvider); + + /** + * Generate a new random TOTP secret (Base32-encoded, 32 chars). + * This is the plaintext value — encrypt before persisting. + */ + public String generateSecret() { + return secretGen.generate(); + } + + /** + * Build the otpauth:// URI for rendering as a QR code. + * + * @param username knoe username (label shown in the authenticator app) + * @param secret plaintext Base32 TOTP secret + * @return QR URI string + */ + public String buildQrUri(String username, String secret) { + QrData data = new QrData.Builder() + .label(username) + .secret(secret) + .issuer(issuer) + .algorithm(ALGO) + .digits(DIGITS) + .period(PERIOD) + .build(); + return data.getUri(); + } + + /** + * Verify a 6-digit TOTP code against the secret. + * Accepts ±WINDOW periods to handle minor clock skew. + * + * @param secret plaintext Base32 TOTP secret + * @param code 6-digit code from authenticator app + * @return true if valid + */ + public boolean verify(String secret, String code) { + if (secret == null || code == null) return false; + try { + boolean valid = verifier.isValidCode(secret, code); + log.debug("TOTP verify code={} valid={}", code, valid); + return valid; + } catch (Exception e) { + log.warn("TOTP verification error: {}", e.getMessage()); + return false; + } + } +} diff --git a/authority/src/main/java/org/prole/authority/enroll/UserProvisioningService.java b/authority/src/main/java/org/prole/authority/enroll/UserProvisioningService.java new file mode 100644 index 0000000..42772a1 --- /dev/null +++ b/authority/src/main/java/org/prole/authority/enroll/UserProvisioningService.java @@ -0,0 +1,126 @@ +package org.prole.authority.enroll; + +import org.prole.authority.kerberos.KadminClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * UserProvisioningService — orchestrates enrollment completion. + * + * Called after all three enrollment gates have passed: + * 1. OTP verified (contact ownership proven) + * 2. Google identity linked (corroboration recorded) + * 3. TOTP secret verified (device 2FA confirmed) + * + * Creates the knoe.user row, links identity, stores TOTP credential, + * creates the Kerberos principal, marks the invite used, and queues + * async provisioning jobs (Gitea, GitLab accounts). + * + * NOTE: TOTP secret encryption via OpenBao is deferred to Round 1.5. + * For now the secret is stored as-is — this will be replaced with + * AES-GCM envelope encryption using a transit key. + */ +@Service +public class UserProvisioningService { + + private static final Logger log = LoggerFactory.getLogger(UserProvisioningService.class); + + private final JdbcTemplate db; + private final KadminClient kadmin; + private final InviteService inviteService; + + public UserProvisioningService(JdbcTemplate db, + KadminClient kadmin, + InviteService inviteService) { + this.db = db; + this.kadmin = kadmin; + this.inviteService = inviteService; + } + + /** + * Complete enrollment for a verified session. + * + * @param inviteToken the invite token (used to mark invite consumed) + * @param username chosen knoe username + * @param displayName display name from PII form + * @param email canonical email (from Google ID token) + * @param googleId verified GoogleIdentity + * @param totpSecret plaintext TOTP secret (verified in enrollment step 3) + */ + @Transactional + public ProvisionResult provision(String inviteToken, + String username, + String displayName, + String email, + GoogleOAuthService.GoogleIdentity googleId, + String totpSecret) { + log.info("Provisioning user: username={} email={} hd={}", + username, email, googleId.hd()); + + // 1. Insert knoe.user + Integer userId = db.queryForObject(""" + INSERT INTO knoe.user (username, display_name, email, realm, created_at) + VALUES (?, ?, ?, 'KNOE.DEV', now()) + RETURNING id + """, Integer.class, username, displayName, email); + + if (userId == null) { + throw new ProvisioningException("Failed to create user row for " + username); + } + + // 2. Insert knoe.identity (Google corroboration) + db.update(""" + INSERT INTO knoe.identity + (user_id, provider, provider_sub, provider_email, provider_hd, verified_at) + VALUES (?, 'google', ?, ?, ?, now()) + """, userId, googleId.sub(), googleId.email(), googleId.hd()); + + // 3. Insert knoe.totp_credential + // TODO Round 1.5: encrypt secret with OpenBao transit key before storing. + db.update(""" + INSERT INTO knoe.totp_credential (user_id, secret, verified_at, created_at) + VALUES (?, ?, now(), now()) + """, userId, totpSecret); + + // 4. Create Kerberos principal + try { + kadmin.addPrincipal(username + "@KNOE.DEV"); + } catch (Exception e) { + log.error("kadmin addprinc failed for {}: {}", username, e.getMessage()); + throw new ProvisioningException("Kerberos principal creation failed: " + e.getMessage(), e); + } + + // 5. Mark invite used + inviteService.markUsed(inviteToken, username); + + // 6. Queue provisioning jobs + queueJob(userId, "create_gitea_user", + "{\"username\":\"" + username + "\",\"email\":\"" + email + "\"}"); + queueJob(userId, "create_gitlab_user", + "{\"username\":\"" + username + "\",\"email\":\"" + email + "\"}"); + + log.info("Provisioning complete for user {} (id={})", username, userId); + return new ProvisionResult(userId, username, email); + } + + private void queueJob(int userId, String jobType, String payloadJson) { + db.update(""" + INSERT INTO knoe.provisioning_job (user_id, job_type, status, payload, created_at, updated_at) + VALUES (?, ?, 'pending', ?::jsonb, now(), now()) + """, userId, jobType, payloadJson); + } + + // ── Value types ────────────────────────────────────────────────────────── + + public record ProvisionResult(int userId, String username, String email) {} + + public static class ProvisioningException extends RuntimeException { + public ProvisioningException(String msg) { super(msg); } + public ProvisioningException(String msg, Throwable cause) { super(msg, cause); } + } +} diff --git a/authority/src/main/java/org/prole/authority/kerberos/KadminClient.java b/authority/src/main/java/org/prole/authority/kerberos/KadminClient.java new file mode 100644 index 0000000..494768a --- /dev/null +++ b/authority/src/main/java/org/prole/authority/kerberos/KadminClient.java @@ -0,0 +1,136 @@ +package org.prole.authority.kerberos; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * KadminClient — shells out to kadmin.local to manage Kerberos principals. + * + * Runs in the same pod as the KDC sidecar, so kadmin.local has direct database access. + * All operations are idempotent: addPrincipal is a no-op if the principal already exists. + * + * For use in enrollment only — not exposed via any HTTP endpoint. + */ +@Component +public class KadminClient { + + private static final Logger log = LoggerFactory.getLogger(KadminClient.class); + + private static final int TIMEOUT_SECONDS = 30; + + @Value("${knoe.kerberos.realm:KNOE.DEV}") + private String realm; + + /** + * Create a new principal with a random key (no password — Kerberos keytab only). + * For human users this is overridden at first kinit with kadmin changepass. + * + * @param principal fully-qualified principal, e.g. "chrisfu@KNOE.DEV" + */ + public void addPrincipal(String principal) { + String fqPrincipal = qualified(principal); + // Check if it already exists — idempotent + try { + runKadmin("get_principal " + fqPrincipal); + log.info("Principal {} already exists — skipping addprinc.", fqPrincipal); + return; + } catch (KadminException e) { + // Principal doesn't exist — proceed to create + } + + runKadmin("addprinc -randkey " + fqPrincipal); + log.info("Created Kerberos principal: {}", fqPrincipal); + } + + /** + * Set the password for a principal (used during password reset flows). + * + * @param principal fully-qualified or bare principal + * @param password new password (will not be logged) + */ + public void changePrincipalPassword(String principal, String password) { + String fqPrincipal = qualified(principal); + runKadmin("cpw -pw " + password + " " + fqPrincipal); + log.info("Password changed for principal: {}", fqPrincipal); + } + + /** + * Delete a principal (used when an account is disabled). + * + * @param principal fully-qualified or bare principal + */ + public void deletePrincipal(String principal) { + String fqPrincipal = qualified(principal); + runKadmin("delprinc -force " + fqPrincipal); + log.info("Deleted principal: {}", fqPrincipal); + } + + /** + * Export a keytab for a principal to a file path. + * + * @param principal fully-qualified principal + * @param keytabPath absolute path on the local filesystem + */ + public void exportKeytab(String principal, String keytabPath) { + String fqPrincipal = qualified(principal); + runKadmin("ktadd -k " + keytabPath + " " + fqPrincipal); + log.info("Exported keytab for {} to {}", fqPrincipal, keytabPath); + } + + // ── Internal ────────────────────────────────────────────────────────────── + + private String qualified(String principal) { + if (principal.contains("@")) return principal; + return principal + "@" + realm; + } + + private void runKadmin(String query) { + List cmd = List.of("kadmin.local", "-q", query); + log.debug("kadmin.local -q \"{}\"", query.startsWith("cpw") ? "cpw -pw *** ..." : query); + + try { + Process proc = new ProcessBuilder(cmd) + .redirectErrorStream(true) + .start(); + + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(proc.getInputStream()))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + boolean finished = proc.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + proc.destroyForcibly(); + throw new KadminException("kadmin.local timed out after " + TIMEOUT_SECONDS + "s"); + } + + int exit = proc.exitValue(); + if (exit != 0) { + // get_principal returns 1 for "Principal does not exist" — caller handles + if (output.contains("Principal does not exist")) { + throw new KadminException("Principal does not exist"); + } + throw new KadminException("kadmin.local exited " + exit + ": " + output); + } + + } catch (KadminException e) { + throw e; + } catch (Exception e) { + throw new KadminException("kadmin.local execution failed: " + e.getMessage(), e); + } + } + + public static class KadminException extends RuntimeException { + public KadminException(String msg) { super(msg); } + public KadminException(String msg, Throwable cause) { super(msg, cause); } + } +} diff --git a/authority/src/main/java/org/prole/authority/provisioning/ProvisioningWorker.java b/authority/src/main/java/org/prole/authority/provisioning/ProvisioningWorker.java new file mode 100644 index 0000000..676113b --- /dev/null +++ b/authority/src/main/java/org/prole/authority/provisioning/ProvisioningWorker.java @@ -0,0 +1,191 @@ +package org.prole.authority.provisioning; + +import org.prole.authority.admin.KnobjectService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +/** + * ProvisioningWorker — polls knoe.provisioning_job and dispatches pending jobs. + * + * Supported job types for Round 1: + * create_gitea_user — create Gitea account via Gitea API + * create_gitlab_user — create GitLab account via GitLab API (stub) + * grant_gitea_access — add member to Gitea org/repo + * grant_gitlab_access — add member to GitLab group/project (stub) + * + * Runs on a fixed delay to avoid concurrent execution on multi-replica deployments. + * In Round 2 this should use advisory locks or a proper job queue. + */ +@Component +@EnableScheduling +public class ProvisioningWorker { + + private static final Logger log = LoggerFactory.getLogger(ProvisioningWorker.class); + + private final JdbcTemplate db; + private final KnobjectService knobjectService; + private final RestTemplate http = new RestTemplate(); + + @Value("${knoe.provisioning.giteaUrl:https://git.knoe.dev}") + private String giteaUrl; + + @Value("${knoe.provisioning.giteaToken:}") + private String giteaToken; + + public ProvisioningWorker(JdbcTemplate db, KnobjectService knobjectService) { + this.db = db; + this.knobjectService = knobjectService; + } + + @Scheduled(fixedDelayString = "${knoe.provisioning.pollIntervalMs:10000}") + public void poll() { + List> jobs = db.queryForList(""" + SELECT id, user_id, job_type, payload + FROM knoe.provisioning_job + WHERE status = 'pending' + ORDER BY created_at + LIMIT 10 + """); + + if (jobs.isEmpty()) return; + + log.debug("Provisioning: {} pending job(s)", jobs.size()); + + for (var job : jobs) { + int jobId = (int) job.get("id"); + int userId = (int) job.get("user_id"); + String jobType = (String) job.get("job_type"); + Object payloadObj = job.get("payload"); + String payload = payloadObj != null ? payloadObj.toString() : "{}"; + + markRunning(jobId); + try { + dispatch(jobId, userId, jobType, payload); + markDone(jobId, "{\"status\":\"ok\"}"); + log.info("Job {} ({}) completed for userId={}", jobId, jobType, userId); + } catch (Exception e) { + log.error("Job {} ({}) failed for userId={}: {}", jobId, jobType, userId, e.getMessage()); + markFailed(jobId, "{\"error\":\"" + escape(e.getMessage()) + "\"}"); + } + } + } + + private void dispatch(int jobId, int userId, String jobType, String payload) { + switch (jobType) { + case "create_gitea_user" -> createGiteaUser(payload); + case "create_gitlab_user" -> createGitlabUser(payload); + case "grant_gitea_access" -> grantGiteaAccess(payload); + case "grant_gitlab_access"-> grantGitlabAccess(payload); + default -> log.warn("Unknown job type '{}' for jobId={}", jobType, jobId); + } + } + + // ── Gitea ───────────────────────────────────────────────────────────────── + + private void createGiteaUser(String payloadJson) { + if (giteaToken == null || giteaToken.isBlank()) { + log.warn("GITEA_TOKEN not set — skipping create_gitea_user"); + return; + } + + // Parse username + email from JSON payload (simple extraction, no full JSON parser needed) + String username = extractJsonField(payloadJson, "username"); + String email = extractJsonField(payloadJson, "email"); + + String body = """ + {"login_name":"%s","username":"%s","email":"%s", + "password":"%s","must_change_password":true, + "send_notify":true,"source_id":0} + """.formatted(username, username, email, generateTempPassword()); + + var headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "token " + giteaToken); + + var response = http.postForEntity( + giteaUrl + "/api/v1/admin/users", + new HttpEntity<>(body, headers), + String.class); + + log.info("Gitea user {} created — HTTP {}", username, response.getStatusCode()); + } + + private void grantGiteaAccess(String payloadJson) { + if (giteaToken == null || giteaToken.isBlank()) { + log.warn("GITEA_TOKEN not set — skipping grant_gitea_access"); + return; + } + // Stub — implemented fully in Round 1.5 when repo structure is defined + log.info("grant_gitea_access (stub): {}", payloadJson); + } + + // ── GitLab ──────────────────────────────────────────────────────────────── + + private void createGitlabUser(String payloadJson) { + // Stub for Round 1 — GitLab is on git.prole.org which may not be accessible + // from knoe-dev-0. Implement via GitLab API in Round 1.5. + log.info("create_gitlab_user (stub — implement in Round 1.5): {}", payloadJson); + } + + private void grantGitlabAccess(String payloadJson) { + log.info("grant_gitlab_access (stub — implement in Round 1.5): {}", payloadJson); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private void markRunning(int jobId) { + db.update("UPDATE knoe.provisioning_job SET status='running', updated_at=now() WHERE id=?", + jobId); + } + + private void markDone(int jobId, String resultJson) { + db.update(""" + UPDATE knoe.provisioning_job + SET status='done', result=?::jsonb, updated_at=now() + WHERE id=? + """, resultJson, jobId); + } + + private void markFailed(int jobId, String resultJson) { + db.update(""" + UPDATE knoe.provisioning_job + SET status='failed', result=?::jsonb, updated_at=now() + WHERE id=? + """, resultJson, jobId); + } + + /** Very simple JSON field extraction — avoids adding a JSON dep just for this. */ + private static String extractJsonField(String json, String field) { + String marker = "\"" + field + "\":\""; + int start = json.indexOf(marker); + if (start < 0) return ""; + start += marker.length(); + int end = json.indexOf('"', start); + return end < 0 ? "" : json.substring(start, end); + } + + private static String escape(String s) { + return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static String generateTempPassword() { + // 20-char random alphanumeric — user must change on first login + var sb = new StringBuilder(20); + var rng = new java.security.SecureRandom(); + String chars = "ABCDEFGHJKMNPQRSTWXYZabcdefghjkmnpqrstwxyz23456789@#$!"; + for (int i = 0; i < 20; i++) sb.append(chars.charAt(rng.nextInt(chars.length()))); + return sb.toString(); + } +} diff --git a/deploy/gcp/gke/knoe-auth-deployment.yaml b/deploy/gcp/gke/knoe-auth-deployment.yaml new file mode 100644 index 0000000..5b1849f --- /dev/null +++ b/deploy/gcp/gke/knoe-auth-deployment.yaml @@ -0,0 +1,307 @@ +--- +# knoe-auth-deployment.yaml +# Deploys the knoe-auth pod (Spring Boot + KDC sidecar) to knoe-dev-0 / knoe-system. +# Based on deploy/opentofu/k3s/manifests/knoe/prole-auth-deployment.yaml. +# Realm: KNOE.DEV (not PROLE.LOCAL) +# Image: pulled from Artifact Registry — built by knoe-auth CI pipeline. +# +# Apply: kubectl -n knoe-system apply -f deploy/gcp/gke/knoe-auth-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: knoe-auth + namespace: knoe-system + labels: + app: knoe-auth +spec: + replicas: 1 + selector: + matchLabels: + app: knoe-auth + template: + metadata: + labels: + app: knoe-auth + spec: + initContainers: + # keytab-bootstrap: runs kadmin.local inside the shared KDC data volume + # to create the HTTP service principal and export the keytab before the + # Spring Boot container starts. + - name: keytab-bootstrap + image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-authority:latest + imagePullPolicy: Always + command: + - /bin/bash + - -lc + - | + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + + realm="${KNOE_KDC_REALM:-KNOE.DEV}" + admin_principal="${KNOE_KDC_ADMIN_PRINCIPAL:-admin/admin}" + if [[ "${admin_principal}" != *"@"* ]]; then + admin_principal="${admin_principal}@${realm}" + fi + + svc_principal="${KNOE_KERBEROS_SERVICE_PRINCIPAL:?Missing KNOE_KERBEROS_SERVICE_PRINCIPAL}" + if [[ "${svc_principal}" != *"@"* ]]; then + svc_principal="${svc_principal}@${realm}" + fi + + keytab_out="/etc/knoe/keytabs/http.keytab" + mkdir -p "$(dirname "${keytab_out}")" + + # If a pre-provisioned keytab is provided as a Secret, use it directly. + if [[ -f /mnt/keytab-secret/http.keytab ]]; then + cp /mnt/keytab-secret/http.keytab "${keytab_out}" + chmod 0400 "${keytab_out}" || true + exit 0 + fi + + if ! command -v kadmin.local >/dev/null 2>&1; then + echo "Installing Kerberos packages..." + echo "krb5-config krb5-config/default_realm string ${realm}" | debconf-set-selections || true + echo "krb5-config krb5-config/kerberos_servers string 127.0.0.1" | debconf-set-selections || true + echo "krb5-config krb5-config/admin_server string 127.0.0.1" | debconf-set-selections || true + apt-get update -qq + apt-get install -y --no-install-recommends krb5-kdc krb5-admin-server krb5-user dnsutils ca-certificates + rm -rf /var/lib/apt/lists/* + fi + + mkdir -p /etc/krb5kdc /var/lib/krb5kdc + [[ -f /opt/knoe-kdc/krb5.conf ]] && cp /opt/knoe-kdc/krb5.conf /etc/krb5.conf + [[ -f /opt/knoe-kdc/kdc.conf ]] && cp /opt/knoe-kdc/kdc.conf /etc/krb5kdc/kdc.conf + [[ -f /opt/knoe-kdc/kadm5.acl ]] && cp /opt/knoe-kdc/kadm5.acl /etc/krb5kdc/kadm5.acl + + if [[ -z "${KNOE_KDC_MASTER_PASSWORD:-}" ]]; then + echo "ERROR: Missing KNOE_KDC_MASTER_PASSWORD" >&2; exit 1 + fi + if [[ -z "${KNOE_KDC_ADMIN_PASSWORD:-}" ]]; then + echo "ERROR: Missing KNOE_KDC_ADMIN_PASSWORD" >&2; exit 1 + fi + + if [[ ! -f /var/lib/krb5kdc/principal ]]; then + echo "Initializing realm database for ${realm}..." + kdb5_util create -s -r "${realm}" -P "${KNOE_KDC_MASTER_PASSWORD}" + fi + + if ! kadmin.local -q "get_principal ${admin_principal}" >/dev/null 2>&1; then + kadmin.local -q "addprinc -pw ${KNOE_KDC_ADMIN_PASSWORD} ${admin_principal}" + fi + + if ! kadmin.local -q "get_principal ${svc_principal}" >/dev/null 2>&1; then + echo "Creating service principal ${svc_principal}..." + kadmin.local -q "addprinc -randkey ${svc_principal}" + fi + + kadmin.local -q "ktadd -k ${keytab_out} -norandkey ${svc_principal}" + chmod 0400 "${keytab_out}" || true + echo "Keytab written to ${keytab_out}" + env: + - name: KNOE_KDC_REALM + value: "KNOE.DEV" + - name: KNOE_KDC_ADMIN_PRINCIPAL + value: "admin/admin" + - name: KNOE_KDC_MASTER_PASSWORD + valueFrom: + secretKeyRef: + name: knoe-kdc-secrets + key: master_password + - name: KNOE_KDC_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: knoe-kdc-secrets + key: admin_password + - name: KNOE_KERBEROS_SERVICE_PRINCIPAL + value: "HTTP/auth.knoe.dev" + volumeMounts: + - name: keytab + mountPath: /etc/knoe/keytabs + - name: keytab-secret + mountPath: /mnt/keytab-secret + readOnly: true + - name: knoe-kdc-config + mountPath: /opt/knoe-kdc + - name: knoe-kdc-data + mountPath: /var/lib/krb5kdc + - name: knoe-kdc-etc + mountPath: /etc/krb5kdc + + containers: + # ── Spring Boot / knoe-auth ────────────────────────────────────────── + - name: knoe-auth + image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-authority:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 20 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 30 + env: + - name: KNOE_AUTH_COOKIE_DOMAIN + value: ".knoe.dev" + - name: KNOE_AUTH_SESSION_SECRET + valueFrom: + secretKeyRef: + name: knoe-auth-secrets + key: sessionSecret + # ── Kerberos ─────────────────────────────────────────────────── + - name: KNOE_KERBEROS_SERVICE_PRINCIPAL + value: "HTTP/auth.knoe.dev@KNOE.DEV" + - name: KNOE_KERBEROS_KEYTAB_PATH + value: "/etc/knoe/keytabs/http.keytab" + - name: KNOE_KERBEROS_REALM + value: "KNOE.DEV" + # ── Google OAuth2 (enrollment corroboration) ─────────────────── + - name: GOOGLE_CLIENT_ID + valueFrom: + secretKeyRef: + name: knoe-auth-google-oidc + key: client_id + optional: true + - name: GOOGLE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: knoe-auth-google-oidc + key: client_secret + optional: true + # ── Database (CNPG via cluster-internal service) ─────────────── + - name: KNOE_DB_URL + value: "jdbc:postgresql://knoe-db-rw.knoe-db-0.svc.cluster.local:5432/knoe" + - name: KNOE_DB_USER + value: "knoe" + - name: KNOE_DB_PASSWORD + valueFrom: + secretKeyRef: + name: knoe-db-app + key: password + optional: true + # ── Enrollment ──────────────────────────────────────────────── + - name: KNOE_ENROLL_INVITE_TTL_HOURS + value: "72" + - name: KNOE_ENROLL_OTP_TTL_MINUTES + value: "10" + - name: KNOE_ENROLL_OTP_MAX_ATTEMPTS + value: "3" + - name: KNOE_ENROLL_TOTP_ISSUER + value: "Knoe.DEV" + - name: KNOE_AUTH_BASE_URL + value: "https://auth.knoe.dev" + # ── Provisioning ───────────────────────────────────────────── + - name: KNOE_PROVISIONING_POLL_INTERVAL_MS + value: "10000" + - name: KNOE_GITEA_URL + value: "https://git.knoe.dev" + - name: KNOE_GITEA_TOKEN + valueFrom: + secretKeyRef: + name: knoe-gitea-admin + key: token + optional: true + volumeMounts: + - name: keytab + mountPath: /etc/knoe/keytabs + readOnly: true + - name: knoe-kdc-config + mountPath: /etc/krb5.conf + subPath: krb5.conf + readOnly: true + + # ── MIT Kerberos KDC sidecar ───────────────────────────────────────── + - name: kdc + image: us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system/knoe-authority:latest + imagePullPolicy: Always + command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"] + env: + - name: KNOE_KDC_REALM + value: "KNOE.DEV" + - name: KNOE_KDC_ADMIN_PRINCIPAL + value: "admin/admin" + - name: KNOE_KDC_MASTER_PASSWORD + valueFrom: + secretKeyRef: + name: knoe-kdc-secrets + key: master_password + - name: KNOE_KDC_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: knoe-kdc-secrets + key: admin_password + ports: + - name: krb5-udp + containerPort: 88 + protocol: UDP + - name: krb5-tcp + containerPort: 88 + protocol: TCP + - name: kpasswd-udp + containerPort: 464 + protocol: UDP + - name: kpasswd-tcp + containerPort: 464 + protocol: TCP + - name: kadmin + containerPort: 749 + protocol: TCP + volumeMounts: + - name: knoe-kdc-config + mountPath: /opt/knoe-kdc + - name: knoe-kdc-data + mountPath: /var/lib/krb5kdc + - name: knoe-kdc-etc + mountPath: /etc/krb5kdc + + volumes: + - name: keytab + emptyDir: {} + - name: keytab-secret + secret: + secretName: knoe-auth-keytab + optional: true + - name: knoe-kdc-config + configMap: + name: knoe-kdc-config + defaultMode: 0755 + - name: knoe-kdc-data + emptyDir: {} + - name: knoe-kdc-etc + emptyDir: {} + +--- +apiVersion: v1 +kind: Service +metadata: + name: knoe-auth + namespace: knoe-system + labels: + app: knoe-auth +spec: + selector: + app: knoe-auth + ports: + - name: http + port: 80 + targetPort: 8080 + - name: krb5-tcp + port: 88 + targetPort: 88 + protocol: TCP + - name: krb5-udp + port: 88 + targetPort: 88 + protocol: UDP + - name: kadmin + port: 749 + targetPort: 749 + protocol: TCP + type: ClusterIP diff --git a/deploy/gcp/gke/knoe-kdc-configmap.yaml b/deploy/gcp/gke/knoe-kdc-configmap.yaml new file mode 100644 index 0000000..b5ac7c3 --- /dev/null +++ b/deploy/gcp/gke/knoe-kdc-configmap.yaml @@ -0,0 +1,108 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: knoe-kdc-config + namespace: knoe-system +data: + krb5.conf: | + [libdefaults] + default_realm = KNOE.DEV + dns_lookup_realm = false + dns_lookup_kdc = false + ticket_lifetime = 10h + renew_lifetime = 7d + forwardable = true + + [realms] + KNOE.DEV = { + kdc = 127.0.0.1 + admin_server = 127.0.0.1 + } + + [domain_realm] + .knoe.dev = KNOE.DEV + knoe.dev = KNOE.DEV + + kdc.conf: | + [kdcdefaults] + kdc_ports = 88 + kdc_tcp_ports = 88 + [realms] + KNOE.DEV = { + database_name = /var/lib/krb5kdc/principal + admin_keytab = FILE:/etc/krb5kdc/kadm5.keytab + acl_file = /etc/krb5kdc/kadm5.acl + key_stash_file = /etc/krb5kdc/stash + max_life = 10h 0m 0s + max_renewable_life = 7d 0h 0m 0s + default_principal_flags = +preauth + } + + kadm5.acl: | + admin/admin@KNOE.DEV * + + entrypoint.sh: | + #!/usr/bin/env bash + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + + realm="${KNOE_KDC_REALM:-KNOE.DEV}" + admin_principal="${KNOE_KDC_ADMIN_PRINCIPAL:-admin/admin}" + if [[ "${admin_principal}" != *"@"* ]]; then + admin_principal="${admin_principal}@${realm}" + fi + + if ! command -v krb5kdc >/dev/null 2>&1; then + echo "Installing Kerberos packages..." + echo "krb5-config krb5-config/default_realm string ${realm}" | debconf-set-selections || true + echo "krb5-config krb5-config/kerberos_servers string 127.0.0.1" | debconf-set-selections || true + echo "krb5-config krb5-config/admin_server string 127.0.0.1" | debconf-set-selections || true + apt-get update -qq + apt-get install -y --no-install-recommends krb5-kdc krb5-admin-server krb5-user dnsutils ca-certificates + rm -rf /var/lib/apt/lists/* + fi + + mkdir -p /etc/krb5kdc /var/lib/krb5kdc + if [[ -f /opt/knoe-kdc/krb5.conf ]]; then + cp /opt/knoe-kdc/krb5.conf /etc/krb5.conf + fi + if [[ -f /opt/knoe-kdc/kdc.conf ]]; then + cp /opt/knoe-kdc/kdc.conf /etc/krb5kdc/kdc.conf + fi + if [[ -f /opt/knoe-kdc/kadm5.acl ]]; then + cp /opt/knoe-kdc/kadm5.acl /etc/krb5kdc/kadm5.acl + fi + + if [[ -z "${KNOE_KDC_MASTER_PASSWORD:-}" ]]; then + echo "ERROR: Missing required env KNOE_KDC_MASTER_PASSWORD" >&2 + exit 1 + fi + if [[ -z "${KNOE_KDC_ADMIN_PASSWORD:-}" ]]; then + echo "ERROR: Missing required env KNOE_KDC_ADMIN_PASSWORD" >&2 + exit 1 + fi + + if [[ ! -f /var/lib/krb5kdc/principal ]]; then + echo "Initializing realm database for ${realm}..." + kdb5_util create -s -r "${realm}" -P "${KNOE_KDC_MASTER_PASSWORD}" + fi + + if ! kadmin.local -q "get_principal ${admin_principal}" >/dev/null 2>&1; then + echo "Creating admin principal ${admin_principal}..." + kadmin.local -q "addprinc -pw ${KNOE_KDC_ADMIN_PASSWORD} ${admin_principal}" + fi + + # system admin user principal + if ! kadmin.local -q "get_principal admin@${realm}" >/dev/null 2>&1; then + echo "Creating admin user principal admin@${realm}..." + kadmin.local -q "addprinc -pw ${KNOE_KDC_MASTER_PASSWORD} admin@${realm}" + fi + + echo "Starting krb5kdc and kadmind ..." + krb5kdc -n & + sleep 0.5 + if ! pgrep -x krb5kdc >/dev/null 2>&1; then + echo "ERROR: krb5kdc failed to start." >&2 + exit 1 + fi + exec kadmind -nofork diff --git a/deploy/gcp/gke/knoe-kdc-secrets.yaml b/deploy/gcp/gke/knoe-kdc-secrets.yaml new file mode 100644 index 0000000..cc04113 --- /dev/null +++ b/deploy/gcp/gke/knoe-kdc-secrets.yaml @@ -0,0 +1,28 @@ +--- +# knoe-kdc-secrets.yaml +# Placeholder / example structure. Real values are created by init_knoe_auth.sh +# using 1Password (op) and applied directly — this file is NOT committed with +# real secrets. +# +# To create manually: +# kubectl -n knoe-system create secret generic knoe-kdc-secrets \ +# --from-literal=master_password="$(op item get 'knoe-kdc-master' --fields password)" \ +# --from-literal=admin_password="$(op item get 'knoe-kdc-admin' --fields password)" +# +# Fields: +# master_password — KDC database master key (kdb5_util -P) +# admin_password — admin/admin@KNOE.DEV principal password (kadmin) +# +# Secret is referenced by: +# - knoe-kdc (KDC sidecar container) +# - keytab-bootstrap (initContainer) + +apiVersion: v1 +kind: Secret +metadata: + name: knoe-kdc-secrets + namespace: knoe-system +type: Opaque +stringData: + master_password: "REPLACE_WITH_STRONG_RANDOM_VALUE" + admin_password: "REPLACE_WITH_STRONG_RANDOM_VALUE" From 40ea30e4c3e0fc6450c0bf1dff8b5a2ad29da36b Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:08:18 -0700 Subject: [PATCH 05/12] feat(auth): init scripts and k3s/k8s auth manifests for knoe-auth init_knoe_auth.sh: provisions KDC secrets via 1Password, applies GKE manifests init_knoe_users.sh: creates Kerberos principals for initial contributors kerberos-configmap.yaml: krb5.conf for OpenBao Kerberos auth (KNOE.DEV realm) prole-auth-deployment.yaml: k3s auth + kdc sidecar deployment for homelab prole-kdc-configmap.yaml: k3s KDC config for homelab Co-Authored-By: Claude Sonnet 4.6 --- .../manifests/knoe/prole-auth-deployment.yaml | 4 +- .../manifests/knoe/prole-kdc-configmap.yaml | 4 +- etc/init_knoe_auth.sh | 1195 +++++------------ etc/init_knoe_users.sh | 14 +- k8s/openbao/kerberos-configmap.yaml | 4 +- 5 files changed, 356 insertions(+), 865 deletions(-) diff --git a/deploy/opentofu/k3s/manifests/knoe/prole-auth-deployment.yaml b/deploy/opentofu/k3s/manifests/knoe/prole-auth-deployment.yaml index f05e7a9..787183e 100644 --- a/deploy/opentofu/k3s/manifests/knoe/prole-auth-deployment.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/prole-auth-deployment.yaml @@ -16,7 +16,7 @@ spec: spec: initContainers: - name: keytab-bootstrap - image: myrddin.knoe.org:5000/knoe-authority:latest + image: myrddin.prole.org:5000/knoe-authority:latest imagePullPolicy: IfNotPresent command: - /bin/bash @@ -202,7 +202,7 @@ spec: subPath: krb5.conf readOnly: true - name: kdc - image: myrddin.knoe.org:5000/knoe-authority:latest + image: myrddin.prole.org:5000/knoe-authority:latest imagePullPolicy: IfNotPresent command: ["/bin/bash", "/opt/knoe-kdc/entrypoint.sh"] env: diff --git a/deploy/opentofu/k3s/manifests/knoe/prole-kdc-configmap.yaml b/deploy/opentofu/k3s/manifests/knoe/prole-kdc-configmap.yaml index 32a7d24..ff83fc9 100644 --- a/deploy/opentofu/k3s/manifests/knoe/prole-kdc-configmap.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/prole-kdc-configmap.yaml @@ -15,8 +15,8 @@ data: admin_server = 127.0.0.1 } PROLE.ORG = { - kdc = myrddin.knoe.org - admin_server = myrddin.knoe.org + kdc = myrddin.prole.org + admin_server = myrddin.prole.org } [capaths] diff --git a/etc/init_knoe_auth.sh b/etc/init_knoe_auth.sh index 632a8dc..d257ef8 100755 --- a/etc/init_knoe_auth.sh +++ b/etc/init_knoe_auth.sh @@ -1,885 +1,376 @@ #!/usr/bin/env bash +# init_knoe_auth.sh +# Provision knoe-auth (Kerberos KDC + Spring Boot enrollment service) on GKE. +# +# Usage: +# ./etc/init_knoe_auth.sh [--context KUBECONTEXT] [--namespace NAMESPACE] [--project PROJECT_ID] +# ./etc/init_knoe_auth.sh initialize # full setup +# ./etc/init_knoe_auth.sh schema # schema only (idempotent) +# ./etc/init_knoe_auth.sh invite EMAIL # create first admin invite +# ./etc/init_knoe_auth.sh status # check pod + principal state +# +# Prerequisites: +# kubectl, op (1Password CLI), psql (or kubectl exec fallback) +# +# Env vars (override args): +# APP_CLUSTER_KUBECONTEXT, KNOE_NAMESPACE, GCP_PROJECT_ID, +# KNOE_DB_HOST, KNOE_DB_PORT, KNOE_DB_NAME, KNOE_DB_SUPERUSER set -euo pipefail -# init_knoe_auth.sh -# Purpose: -# - Provision the knoe-auth service (cluster-internal KDC + SSO gateway) in the -# service namespace (knoe-system by default) -# - knoe-auth acts as the authentication server trusted by CNPG and other cluster -# services, and hosts the SSO gateway pod -# - Optionally configure cross-realm trust to an external realm when credentials are provided -# - Must complete before init_cloudnative_pg.sh so the database can depend on it -# -# Usage: -# ./init_knoe_auth.sh initialize|update # create/update knoe-auth resources -# ./init_knoe_auth.sh status # show knoe-auth status -# ./init_knoe_auth.sh cleanup # remove knoe-auth resources +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +GKE_DIR="$REPO_ROOT/deploy/gcp/gke" -SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# ── Defaults ──────────────────────────────────────────────────────────────── +APP_CTX="${APP_CLUSTER_KUBECONTEXT:-gke_plenary-truck-485623-p7_us-west3_knoe-dev-0}" +NAMESPACE="${KNOE_NAMESPACE:-knoe-system}" +GCP_PROJECT="${GCP_PROJECT_ID:-plenary-truck-485623-p7}" +DB_HOST="${KNOE_DB_HOST:-}" # resolved from CNPG svc if blank +DB_PORT="${KNOE_DB_PORT:-5432}" +DB_NAME="${KNOE_DB_NAME:-knoe}" +DB_SUPERUSER="${KNOE_DB_SUPERUSER:-postgres}" +REALM="KNOE.DEV" +AUTH_HOST="${KNOE_AUTH_HOST:-https://auth.knoe.dev}" -# shellcheck disable=SC1090 -source "$SCRIPT_DIR/knoe_cfg.sh" +log() { echo "[init_knoe_auth] $*"; } +info() { log "INFO $*"; } +warn() { log "WARN $*" >&2; } +die() { log "ERROR $*" >&2; exit 1; } -if [[ "${1:-}" == "--mode" || "${1:-}" == "-m" ]]; then - knoe_set_mode "${2:-}" - shift 2 -elif [[ "${1:-}" == --mode=* || "${1:-}" == -m=* ]]; then - knoe_set_mode "${1#*=}" - shift -fi +kube() { kubectl --context="$APP_CTX" "$@"; } -knoe_ensure_kubeconfig >/dev/null 2>&1 || true -ensure_kube_context || exit 1 +# ── Argument parsing ───────────────────────────────────────────────────────── +COMMAND="${1:-initialize}" +shift || true -ACTION=${1:-initialize} - -KNOE_AUTH_NAMESPACE=${KNOE_AUTH_NAMESPACE:-${SERVICE_NAMESPACE:-${NAMESPACE:-default}}} -KDC_NAMESPACE="$KNOE_AUTH_NAMESPACE" -KNOE_AUTH_ENABLED=${KNOE_AUTH_ENABLED:-${PROLE_KDC_ENABLED:-1}} -KNOE_AUTH_NAME=${KNOE_AUTH_NAME:-knoe-auth} -KNOE_AUTH_SERVICE=${KNOE_AUTH_SERVICE:-knoe-auth} -KNOE_AUTH_IMAGE=${KNOE_AUTH_IMAGE:-${PROLE_KDC_IMAGE:-}} -# If the user provided an explicit image, we should not require Docker unless -# they explicitly opt into building. -KNOE_AUTH_IMAGE_EXPLICIT=0 -if [[ -n "${KNOE_AUTH_IMAGE:-}" ]]; then - KNOE_AUTH_IMAGE_EXPLICIT=1 -fi -KNOE_AUTH_IMAGE_NAME=${KNOE_AUTH_IMAGE_NAME:-${PROLE_KDC_IMAGE_NAME:-knoe-authority}} -KNOE_AUTH_IMAGE_TAG=${KNOE_AUTH_IMAGE_TAG:-${PROLE_KDC_IMAGE_TAG:-latest}} -KNOE_AUTH_REGISTRY_HOST=${KNOE_AUTH_REGISTRY_HOST:-${PROLE_KDC_REGISTRY_HOST:-${LOCAL_REGISTRY:-localhost:5000}}} -KNOE_AUTH_REGISTRY_INTERNAL=${KNOE_AUTH_REGISTRY_INTERNAL:-${PROLE_KDC_REGISTRY_INTERNAL:-${LOCAL_REGISTRY_INTERNAL:-}}} -KNOE_AUTH_BUILD_IMAGE_SET=0 -if [[ -n "${KNOE_AUTH_BUILD_IMAGE+x}" ]]; then - KNOE_AUTH_BUILD_IMAGE_SET=1 -elif [[ -n "${PROLE_KDC_BUILD_IMAGE+x}" ]]; then - KNOE_AUTH_BUILD_IMAGE_SET=1 -fi -KNOE_AUTH_BUILD_IMAGE=${KNOE_AUTH_BUILD_IMAGE:-${PROLE_KDC_BUILD_IMAGE:-1}} -KNOE_AUTH_ROLLOUT_TIMEOUT=${KNOE_AUTH_ROLLOUT_TIMEOUT:-${PROLE_KDC_ROLLOUT_TIMEOUT:-5}} -KNOE_AUTH_DEPLOY_TIMEOUT=${KNOE_AUTH_DEPLOY_TIMEOUT:-${PROLE_KDC_DEPLOY_TIMEOUT:-60}} -KNOE_AUTH_HOST_NETWORK=${KNOE_AUTH_HOST_NETWORK:-${PROLE_KDC_HOST_NETWORK:-}} -PROLE_KDC_REALM=${PROLE_KDC_REALM:-} -PROLE_KDC_DOMAIN=${PROLE_KDC_DOMAIN:-} -PROLE_KDC_ADMIN_PRINCIPAL=${PROLE_KDC_ADMIN_PRINCIPAL:-admin/admin} -PROLE_KDC_ADMIN_PASSWORD=${PROLE_KDC_ADMIN_PASSWORD:-} -PROLE_KDC_MASTER_PASSWORD=${PROLE_KDC_MASTER_PASSWORD:-} -PROLE_KDC_TRUST_REALM=${PROLE_KDC_TRUST_REALM:-${KRB5_REALM:-}} -PROLE_KDC_TRUST_ADMIN=${PROLE_KDC_TRUST_ADMIN:-${KRB5_USER:-}} -PROLE_KDC_TRUST_PASSWORD=${PROLE_KDC_TRUST_PASSWORD:-${KRB5_PASSWORD:-}} -PROLE_KDC_TRUST_SHARED_PASSWORD=${PROLE_KDC_TRUST_SHARED_PASSWORD:-} -KRB5_KDC=${KRB5_KDC:-} -KRB5_ADMIN=${KRB5_ADMIN:-} - -local_registry_enabled() { - local raw="${PROLE_ENABLE_LOCAL_REGISTRY:-${ENABLE_LOCAL_REGISTRY:-}}" - if [[ -n "$raw" ]]; then - case "$raw" in - 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;; - esac - return 1 - fi - - local mode="" - if command -v knoe_normalize_mode >/dev/null 2>&1; then - mode=$(knoe_normalize_mode "${KNOE_MODE:-${DEPLOYMENT_MODE:-${CLUSTER_ENV:-}}}") - else - mode="${KNOE_MODE:-${DEPLOYMENT_MODE:-${CLUSTER_ENV:-}}}" - fi - [[ "$mode" == "k3d" || "$mode" == "k3s" ]] -} - -if ! local_registry_enabled; then - # Only clear default local-registry settings; preserve explicit registry host values. - _default_host="${LOCAL_REGISTRY:-localhost:5000}" - if [[ -z "${KNOE_AUTH_REGISTRY_HOST:-}" || "${KNOE_AUTH_REGISTRY_HOST:-}" == "$_default_host" ]]; then - KNOE_AUTH_REGISTRY_HOST="" - fi - KNOE_AUTH_REGISTRY_INTERNAL="" - unset _default_host -fi - -log() { printf '%s\n' "$*"; } -err() { printf '%s\n' "$*" >&2; } - -ensure_tools() { - for t in kubectl; do - command -v "$t" >/dev/null || { err "Missing required tool: $t"; exit 1; } - done -} - -ensure_docker() { - command -v docker >/dev/null || { err "Missing required tool: docker"; exit 1; } - if ! docker info >/dev/null 2>&1; then - err "Docker is not running or not accessible." - err "This is required to build/push the knoe-auth image." - err "Start Docker (e.g. Docker Desktop), or set KNOE_AUTH_IMAGE to a prebuilt image and/or set KNOE_AUTH_BUILD_IMAGE=0." - exit 1 - fi -} - -docker_available() { - command -v docker >/dev/null 2>&1 || return 1 - docker info >/dev/null 2>&1 -} - -resolve_knoe_auth_image() { - # Ensure we have a deterministic image reference even when skipping local builds. - if [[ -n "${KNOE_AUTH_IMAGE:-}" ]]; then - return 0 - fi - if [[ -n "${KNOE_AUTH_REGISTRY_INTERNAL:-}" ]]; then - KNOE_AUTH_IMAGE="${KNOE_AUTH_REGISTRY_INTERNAL}/${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" - return 0 - fi - if [[ -n "${KNOE_AUTH_REGISTRY_HOST:-}" ]]; then - KNOE_AUTH_IMAGE="${KNOE_AUTH_REGISTRY_HOST}/${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" - return 0 - fi - KNOE_AUTH_IMAGE="${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" -} - -ensure_namespace() { - if ! kubectl get namespace "$KNOE_AUTH_NAMESPACE" >/dev/null 2>&1; then - log "Creating namespace '$KNOE_AUTH_NAMESPACE' ..." - kubectl create namespace "$KNOE_AUTH_NAMESPACE" >/dev/null 2>&1 || true - fi -} - -normalized_mode() { - if command -v knoe_normalize_mode >/dev/null 2>&1; then - knoe_normalize_mode "${KNOE_MODE:-}" - return 0 - fi - printf '%s' "${KNOE_MODE:-}" -} - -is_k3d_mode() { - [[ "$(normalized_mode)" == "k3d" ]] -} - -get_kdc_pods_with_containers() { - kubectl -n "$KNOE_AUTH_NAMESPACE" get pods -l "app=${KNOE_AUTH_NAME}" \ - -o jsonpath='{range .items[*]}{.metadata.name}{"|"}{range .spec.containers[*]}{.name}{" "}{end}{"\n"}{end}' 2>/dev/null || true -} - -kdc_pods_missing_container() { - local rows line containers found c - rows=$(get_kdc_pods_with_containers) - [[ -z "$rows" ]] && return 1 - while IFS= read -r line; do - [[ -z "$line" ]] && continue - containers="${line#*|}" - found=0 - for c in $containers; do - if [[ "$c" == "kdc" ]]; then - found=1 - break - fi - done - if [[ "$found" -eq 0 ]]; then - return 0 - fi - done <<< "$rows" - return 1 -} - -kdc_multiple_active_replicasets() { - local rows line spec status count - rows=$(kubectl -n "$KNOE_AUTH_NAMESPACE" get rs -l "app=${KNOE_AUTH_NAME}" \ - -o jsonpath='{range .items[*]}{.metadata.name}{"|"}{.spec.replicas}{"|"}{.status.replicas}{"\n"}{end}' 2>/dev/null || true) - [[ -z "$rows" ]] && return 1 - count=0 - while IFS= read -r line; do - [[ -z "$line" ]] && continue - spec=$(printf '%s' "$line" | awk -F'|' '{print $2}') - status=$(printf '%s' "$line" | awk -F'|' '{print $3}') - spec=${spec:-0} - status=${status:-0} - if [[ "$spec" -gt 0 || "$status" -gt 0 ]]; then - count=$((count+1)) - fi - done <<< "$rows" - (( count > 1 )) -} - -kdc_deployment_not_ready() { - local desired ready updated - desired=$(kubectl -n "$KNOE_AUTH_NAMESPACE" get deploy "$KNOE_AUTH_NAME" -o jsonpath='{.spec.replicas}' 2>/dev/null || true) - ready=$(kubectl -n "$KNOE_AUTH_NAMESPACE" get deploy "$KNOE_AUTH_NAME" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true) - updated=$(kubectl -n "$KNOE_AUTH_NAMESPACE" get deploy "$KNOE_AUTH_NAME" -o jsonpath='{.status.updatedReplicas}' 2>/dev/null || true) - desired=${desired:-0} - ready=${ready:-0} - updated=${updated:-0} - if [[ "$ready" -lt "$desired" || "$updated" -lt "$desired" ]]; then - return 0 - fi - return 1 -} - -kdc_stale_deployment() { - if ! kdc_deployment_not_ready; then - return 1 - fi - kdc_pods_missing_container && return 0 - kdc_multiple_active_replicasets && return 0 - return 1 -} - -maybe_cleanup_stale_kdc() { - local reason="${1:-stale}" - local policy="${2:-broad}" - if ! is_k3d_mode; then - return 1 - fi - if ! kubectl -n "$KNOE_AUTH_NAMESPACE" get deploy "$KNOE_AUTH_NAME" >/dev/null 2>&1; then - return 1 - fi - if [[ "$policy" == "strict" ]]; then - if ! kdc_deployment_not_ready; then - return 1 - fi - if ! kdc_pods_missing_container; then - return 1 - fi - elif ! kdc_stale_deployment; then - return 1 - fi - err "WARN: Detected stale knoe-auth KDC deployment (${reason}). Cleaning up..." - cleanup_knoe_auth - return 0 -} - -lowercase() { - printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' -} - -registry_host_from_url() { - local value="${1:-}" - value="${value#http://}" - value="${value#https://}" - value="${value%%/*}" - value="${value%%:*}" - printf '%s' "$value" -} - -gen_password() { - if command -v openssl >/dev/null 2>&1; then - openssl rand -base64 18 - return 0 - fi - if command -v python3 >/dev/null 2>&1; then - python3 - <<'PY' -import secrets, string -alphabet = string.ascii_letters + string.digits -print(''.join(secrets.choice(alphabet) for _ in range(24))) -PY - return 0 - fi - date +%s -} - -b64_decode() { - if base64 --decode /dev/null 2>&1; then - base64 --decode - return 0 - fi - if base64 -d /dev/null 2>&1; then - base64 -d - return 0 - fi - base64 -D -} - -get_secret_value() { - local secret="$1" - local key="$2" - kubectl -n "$KNOE_AUTH_NAMESPACE" get secret "$secret" -o "jsonpath={.data.${key}}" 2>/dev/null | b64_decode 2>/dev/null || true -} - -resolve_knoe_auth_defaults() { - if [[ -z "$PROLE_KDC_REALM" ]]; then - PROLE_KDC_REALM="PROLE.LOCAL" - fi - if [[ -z "$PROLE_KDC_DOMAIN" ]]; then - PROLE_KDC_DOMAIN=$(lowercase "$PROLE_KDC_REALM") - fi - local mode="" - if command -v knoe_normalize_mode >/dev/null 2>&1; then - mode=$(knoe_normalize_mode "${KNOE_MODE:-}") - else - mode="${KNOE_MODE:-}" - fi - local k3s_host="" - if local_registry_enabled; then - if [[ "$mode" == "k3s" ]]; then - k3s_host=$(registry_host_from_url "${PROLE_K3S_SERVER:-${K3S_SERVER_URL:-}}") - if [[ -n "$k3s_host" ]] && ([[ -z "${KNOE_AUTH_REGISTRY_HOST:-}" ]] || [[ "$KNOE_AUTH_REGISTRY_HOST" == "localhost:5000" ]] || [[ "$KNOE_AUTH_REGISTRY_HOST" == *"k3d"* ]]); then - KNOE_AUTH_REGISTRY_HOST="${k3s_host}:5000" - fi - fi - if [[ -z "$KNOE_AUTH_REGISTRY_INTERNAL" ]]; then - local reg_ns="${REGISTRY_NAMESPACE:-${SERVICE_NAMESPACE:-${NAMESPACE:-default}}}" - KNOE_AUTH_REGISTRY_INTERNAL="registry.${reg_ns}.svc.cluster.local:5000" - unset reg_ns - fi - else - KNOE_AUTH_REGISTRY_INTERNAL="" - fi - if [[ -z "$KNOE_AUTH_IMAGE" ]]; then - if [[ -n "$KNOE_AUTH_REGISTRY_INTERNAL" ]]; then - KNOE_AUTH_IMAGE="${KNOE_AUTH_REGISTRY_INTERNAL}/${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" - else - KNOE_AUTH_IMAGE="${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" - fi - fi -} - -resolve_kdc_docker_dir() { - if [[ -n "${PROLE_KDC_DOCKER_DIR:-}" && -d "$PROLE_KDC_DOCKER_DIR" ]]; then - echo "$PROLE_KDC_DOCKER_DIR" - return 0 - fi - if [[ -n "${KNOE_HOME:-}" && -d "$KNOE_HOME/knoe/authority" ]]; then - echo "$KNOE_HOME/knoe/authority" - return 0 - fi - if [[ -n "${KNOE_HOME:-}" && -d "$KNOE_HOME/authority" ]]; then - echo "$KNOE_HOME/authority" - return 0 - fi - if [[ -d "$SCRIPT_DIR/../knoe/authority" ]]; then - echo "$SCRIPT_DIR/../knoe/authority" - return 0 - fi - if [[ -d "$SCRIPT_DIR/../authority" ]]; then - echo "$SCRIPT_DIR/../authority" - return 0 - fi - return 1 -} - -is_truthy() { - case "${1:-}" in - 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;; +while [[ $# -gt 0 ]]; do + case "$1" in + --context) APP_CTX="$2"; shift 2 ;; + --namespace) NAMESPACE="$2"; shift 2 ;; + --project) GCP_PROJECT="$2"; shift 2 ;; + --db-host) DB_HOST="$2"; shift 2 ;; + *) break ;; esac - return 1 +done + +# ── Helpers ────────────────────────────────────────────────────────────────── + +require_tool() { + command -v "$1" >/dev/null 2>&1 || die "Required tool not found: $1 — install it and retry." } -build_knoe_auth_image() { - # If the user explicitly sets KNOE_AUTH_BUILD_IMAGE, honor it. - # Otherwise, skip building when an explicit image was provided. - if [[ "$KNOE_AUTH_BUILD_IMAGE_SET" == "1" ]]; then - if ! is_truthy "${KNOE_AUTH_BUILD_IMAGE:-0}"; then - resolve_knoe_auth_image - return 0 - fi +wait_for_pods() { + local label="$1" + local timeout="${2:-180}" + info "Waiting up to ${timeout}s for pods with label ${label} in ${NAMESPACE}..." + kube -n "$NAMESPACE" wait pod \ + -l "$label" \ + --for=condition=Ready \ + --timeout="${timeout}s" +} + +op_secret() { + # Retrieve a 1Password secret; fall back to prompting if op isn't authed. + local item="$1" field="${2:-password}" + if command -v op >/dev/null 2>&1; then + op item get "$item" --fields "$field" 2>/dev/null || { + warn "1Password: could not read $item/$field — prompting." + read -rsp "Enter value for $item/$field: " val; echo + printf '%s' "$val" + } else - if [[ "$KNOE_AUTH_IMAGE_EXPLICIT" == "1" ]]; then - log "Using preconfigured KNOE_AUTH_IMAGE='${KNOE_AUTH_IMAGE}'; skipping local image build." - return 0 - fi + read -rsp "Enter value for $item/$field: " val; echo + printf '%s' "$val" fi +} - if ! docker_available; then - # If the user forced a build, we must fail with a clear error. - if [[ "$KNOE_AUTH_BUILD_IMAGE_SET" == "1" ]]; then - ensure_docker - fi +resolve_db_host() { + if [[ -n "$DB_HOST" ]]; then return; fi + # Try to resolve CNPG primary service from the DB cluster context + DB_CTX="${DB_CLUSTER_KUBECONTEXT:-gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0}" + DB_HOST=$(kubectl --context="$DB_CTX" -n knoe-db-0 \ + get svc knoe-db-rw -o jsonpath='{.spec.clusterIP}' 2>/dev/null || echo "") + [[ -z "$DB_HOST" ]] && die "Cannot resolve KNOE DB host. Set KNOE_DB_HOST or ensure knoe-db-rw svc exists." + info "Resolved DB host: $DB_HOST" +} - log "[WARN] Docker is not available; skipping knoe-auth image build." - log " Set KNOE_AUTH_BUILD_IMAGE=1 to force building (requires Docker), or set KNOE_AUTH_IMAGE to a prebuilt image." - resolve_knoe_auth_image - return 0 - fi - - local docker_dir - docker_dir=$(resolve_kdc_docker_dir || true) - if [[ -z "$docker_dir" ]]; then - err "ERROR: knoe/authority Docker context not found." - exit 1 - fi - - ensure_docker - - local local_tag="${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" - log "Building authority image: ${local_tag} (context: ${docker_dir}) ..." - docker build -t "$local_tag" "$docker_dir" - - if [[ -n "$KNOE_AUTH_REGISTRY_HOST" ]]; then - local remote_tag="${KNOE_AUTH_REGISTRY_HOST}/${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" - log "Tagging authority image for registry: ${remote_tag}" - docker tag "$local_tag" "$remote_tag" - log "Pushing authority image to registry: ${remote_tag}" - if ! docker push "$remote_tag"; then - if command -v skopeo >/dev/null 2>&1; then - log "Docker push failed; retrying with skopeo (insecure registry) ..." - skopeo copy --dest-tls-verify=false "docker-daemon:${local_tag}" "docker://${remote_tag}" - else - err "ERROR: docker push failed and skopeo is not available." - exit 1 - fi - fi - fi - - if [[ -z "$KNOE_AUTH_REGISTRY_HOST" && "$(normalized_mode)" == "k3d" ]]; then - if command -v k3d >/dev/null 2>&1; then - local cluster_name="${K3D_CLUSTER_NAME:-knoe-dev-cluster}" - log "Importing authority image into k3d cluster: ${cluster_name}" - k3d image import "$local_tag" -c "$cluster_name" >/dev/null 2>&1 || true - fi - fi - - if [[ -z "$KNOE_AUTH_REGISTRY_HOST" && "$(normalized_mode)" == "k3s" ]]; then - err "ERROR: No registry host configured for k3s; cannot publish authority image." - err " Set PROLE_K3S_SERVER (so the script can infer :5000) or set KNOE_AUTH_REGISTRY_HOST explicitly." - exit 1 - fi - - if [[ -n "$KNOE_AUTH_REGISTRY_INTERNAL" ]]; then - KNOE_AUTH_IMAGE="${KNOE_AUTH_REGISTRY_INTERNAL}/${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" - elif [[ -n "$KNOE_AUTH_REGISTRY_HOST" ]]; then - KNOE_AUTH_IMAGE="${KNOE_AUTH_REGISTRY_HOST}/${KNOE_AUTH_IMAGE_NAME}:${KNOE_AUTH_IMAGE_TAG}" +psql_file() { + local file="$1" + if command -v psql >/dev/null 2>&1 && [[ -n "${PGPASSWORD:-}" ]]; then + psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_SUPERUSER" -d "$DB_NAME" -f "$file" else - KNOE_AUTH_IMAGE="${local_tag}" + # Fallback: exec into CNPG primary pod + DB_CTX="${DB_CLUSTER_KUBECONTEXT:-gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0}" + local pod + pod=$(kubectl --context="$DB_CTX" -n knoe-db-0 \ + get pod -l cnpg.io/cluster=knoe-db,role=primary \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null) + [[ -z "$pod" ]] && die "Cannot find CNPG primary pod. Set PGPASSWORD and KNOE_DB_HOST for direct psql." + kubectl --context="$DB_CTX" -n knoe-db-0 cp "$file" "${pod}:/tmp/knoe_auth_schema.sql" + kubectl --context="$DB_CTX" -n knoe-db-0 exec "$pod" -- \ + psql -U "$DB_SUPERUSER" -d "$DB_NAME" -f /tmp/knoe_auth_schema.sql fi } -ensure_knoe_auth_secrets() { - local secret_name="knoe-auth-secrets" - if [[ -z "$PROLE_KDC_ADMIN_PASSWORD" ]]; then - PROLE_KDC_ADMIN_PASSWORD=$(get_secret_value "$secret_name" "admin_password") - fi - if [[ -z "$PROLE_KDC_MASTER_PASSWORD" ]]; then - PROLE_KDC_MASTER_PASSWORD=$(get_secret_value "$secret_name" "master_password") - fi - if [[ -z "$PROLE_KDC_TRUST_SHARED_PASSWORD" ]]; then - PROLE_KDC_TRUST_SHARED_PASSWORD=$(get_secret_value "$secret_name" "trust_shared_password") - fi - if [[ -z "$PROLE_KDC_TRUST_PASSWORD" ]]; then - PROLE_KDC_TRUST_PASSWORD=$(get_secret_value "$secret_name" "trust_password") - fi +# ── Schema ─────────────────────────────────────────────────────────────────── - if [[ -z "$PROLE_KDC_ADMIN_PASSWORD" ]]; then - PROLE_KDC_ADMIN_PASSWORD=$(gen_password) - fi - if [[ -z "$PROLE_KDC_MASTER_PASSWORD" ]]; then - PROLE_KDC_MASTER_PASSWORD=$(gen_password) - fi - if [[ -z "$PROLE_KDC_TRUST_SHARED_PASSWORD" ]]; then - PROLE_KDC_TRUST_SHARED_PASSWORD="$PROLE_KDC_MASTER_PASSWORD" - fi +run_schema() { + info "Applying knoe-auth schema additions..." + resolve_db_host - kubectl -n "$KNOE_AUTH_NAMESPACE" create secret generic "$secret_name" \ - --from-literal=admin_password="$PROLE_KDC_ADMIN_PASSWORD" \ - --from-literal=master_password="$PROLE_KDC_MASTER_PASSWORD" \ - --from-literal=trust_shared_password="$PROLE_KDC_TRUST_SHARED_PASSWORD" \ - --from-literal=trust_password="$PROLE_KDC_TRUST_PASSWORD" \ - --dry-run=client -o yaml | kubectl apply -f - >/dev/null + local tmpfile + tmpfile=$(mktemp /tmp/knoe_auth_schema_XXXX.sql) + + cat > "$tmpfile" <<'ENDSQL' +-- ── knoe-auth Round 1 schema additions ─────────────────────────────────────── +-- Idempotent: all CREATE TABLE ... IF NOT EXISTS + +-- Invite tokens (admin creates, single-use) +-- contact is the email/phone the invite was sent to — the OTP trust anchor. +-- knoe.dev starts with ZERO pre-knowledge of the developer's home org. +CREATE TABLE IF NOT EXISTS knoe.invitation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT NOT NULL UNIQUE, + contact TEXT NOT NULL, + contact_type TEXT NOT NULL DEFAULT 'email', + name_hint TEXT, + otp_hash TEXT NOT NULL, + otp_expires_at TIMESTAMPTZ NOT NULL, + otp_attempts INT NOT NULL DEFAULT 0, + otp_verified_at TIMESTAMPTZ, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + used_by TEXT +); +CREATE INDEX IF NOT EXISTS idx_invitation_token ON knoe.invitation(token); +CREATE INDEX IF NOT EXISTS idx_invitation_contact ON knoe.invitation(contact); + +-- External identity corroborations (Google sub → knoe user) +-- provider_hd records the developer's home domain (prole.org, gmail.com, etc.) +-- for audit purposes only — it is NOT used for access control. +CREATE TABLE IF NOT EXISTS knoe.identity ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES knoe.user(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_sub TEXT NOT NULL, + provider_email TEXT, + provider_hd TEXT, + verified_at TIMESTAMPTZ NOT NULL, + UNIQUE(provider, provider_sub) +); +CREATE INDEX IF NOT EXISTS idx_identity_user ON knoe.identity(user_id); + +-- TOTP 2FA credentials (encrypted secret, backup codes) +CREATE TABLE IF NOT EXISTS knoe.totp_credential ( + user_id INT PRIMARY KEY REFERENCES knoe.user(id) ON DELETE CASCADE, + secret TEXT NOT NULL, + verified_at TIMESTAMPTZ, + backup_codes TEXT[], + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Platform-managed resources (repos, db roles, policies, etc.) +CREATE TABLE IF NOT EXISTS knoe.knobject ( + id SERIAL PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + platform_id TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(type, name) +); + +-- Access grants (user → knobject with role) +CREATE TABLE IF NOT EXISTS knoe.access_grant ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES knoe.user(id), + knobject_id INT NOT NULL REFERENCES knoe.knobject(id), + role TEXT NOT NULL, + granted_by TEXT NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + UNIQUE(user_id, knobject_id) +); + +-- Async provisioning job queue (GitLab user, Gitea user, CNPG role, etc.) +CREATE TABLE IF NOT EXISTS knoe.provisioning_job ( + id SERIAL PRIMARY KEY, + user_id INT NOT NULL REFERENCES knoe.user(id), + job_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + payload JSONB, + result JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_provisioning_job_status + ON knoe.provisioning_job(status, created_at); + +-- Seed well-known knobjects +INSERT INTO knoe.knobject (type, name, metadata) VALUES + ('gitea_org', 'knoe.dev', '{"description": "Knoe.DEV Gitea organisation"}'), + ('gitlab_group','knoe.dev', '{"description": "Knoe.DEV GitLab group"}') +ON CONFLICT (type, name) DO NOTHING; + +SELECT 'knoe-auth schema v1 applied.' AS status; +ENDSQL + + psql_file "$tmpfile" + rm -f "$tmpfile" + info "Schema applied." } -ensure_knoe_auth() { - if [[ "$KNOE_AUTH_ENABLED" == "0" || "$KNOE_AUTH_ENABLED" == "false" || "$KNOE_AUTH_ENABLED" == "False" ]]; then - log "knoe-auth disabled (KNOE_AUTH_ENABLED=$KNOE_AUTH_ENABLED)." - return 0 - fi - - if [[ -z "${KNOE_AUTH_HOST_NETWORK}" && "$(normalized_mode)" == "k3d" ]]; then - KNOE_AUTH_HOST_NETWORK=1 - fi - - resolve_knoe_auth_defaults - build_knoe_auth_image - ensure_knoe_auth_secrets - local deployment_present=0 - if kubectl -n "$KNOE_AUTH_NAMESPACE" get deploy "$KNOE_AUTH_NAME" >/dev/null 2>&1; then - deployment_present=1 - fi - - # Clean up legacy knoe-auth / old KDC names when switching to knoe-auth. - kubectl -n "$KNOE_AUTH_NAMESPACE" delete deployment auth --ignore-not-found >/dev/null 2>&1 || true - kubectl -n "$KNOE_AUTH_NAMESPACE" delete service auth --ignore-not-found >/dev/null 2>&1 || true - kubectl -n "$KNOE_AUTH_NAMESPACE" delete deployment dog --ignore-not-found >/dev/null 2>&1 || true - kubectl -n "$KNOE_AUTH_NAMESPACE" delete service authority --ignore-not-found >/dev/null 2>&1 || true - - local admin_acl_principal="$PROLE_KDC_ADMIN_PRINCIPAL" - if [[ "$admin_acl_principal" != *"@"* ]]; then - admin_acl_principal="${admin_acl_principal}@${PROLE_KDC_REALM}" - fi - - local trust_block="" - if [[ -n "$PROLE_KDC_TRUST_REALM" && "$PROLE_KDC_TRUST_REALM" != "$PROLE_KDC_REALM" && -n "$KRB5_KDC" ]]; then - local trust_admin_server="${KRB5_ADMIN:-$KRB5_KDC}" - trust_block=$(cat <&2 - env | sed -E 's/(PASSWORD|TOKEN|SECRET)=.*/\1=****/g' >&2 || true - fi - export DEBIAN_FRONTEND=noninteractive - - if ! command -v krb5kdc >/dev/null 2>&1; then - echo "Installing Kerberos packages..." - echo "krb5-config krb5-config/default_realm string ${PROLE_KDC_REALM}" | debconf-set-selections || true - echo "krb5-config krb5-config/kerberos_servers string 127.0.0.1" | debconf-set-selections || true - echo "krb5-config krb5-config/admin_server string 127.0.0.1" | debconf-set-selections || true - apt-get update - apt-get install -y --no-install-recommends krb5-kdc krb5-admin-server krb5-user dnsutils ca-certificates - rm -rf /var/lib/apt/lists/* - fi - - mkdir -p /etc/krb5kdc /var/lib/krb5kdc - if [[ -f /opt/knoe-auth-kdc/krb5.conf ]]; then - cp /opt/knoe-auth-kdc/krb5.conf /etc/krb5.conf - fi - if [[ -f /opt/knoe-auth-kdc/kdc.conf ]]; then - cp /opt/knoe-auth-kdc/kdc.conf /etc/krb5kdc/kdc.conf - fi - if [[ -f /opt/knoe-auth-kdc/kadm5.acl ]]; then - cp /opt/knoe-auth-kdc/kadm5.acl /etc/krb5kdc/kadm5.acl - fi - - # Validate required secrets early to avoid silent crashes - if [[ -z "${PROLE_KDC_MASTER_PASSWORD:-}" ]]; then - echo "ERROR: Missing required env PROLE_KDC_MASTER_PASSWORD (secret 'knoe-auth-secrets/master_password')." >&2 - exit 1 - fi - if [[ -z "${PROLE_KDC_ADMIN_PASSWORD:-}" ]]; then - echo "ERROR: Missing required env PROLE_KDC_ADMIN_PASSWORD (secret 'knoe-auth-secrets/admin_password')." >&2 - exit 1 - fi - - # Optionally generate a minimal Samba configuration if a child realm is provided - realm="\${PROLE_CHILD_REALM:-}" - if [[ -z "\$realm" ]]; then - realm="\${PROLE_KDC_REALM}" - fi - if [[ -n "\$realm" ]]; then - workgroup="\${PROLE_CHILD_WORKGROUP:-}" - if [[ -z "\$workgroup" ]]; then - workgroup="\${realm%%.*}" - fi - netbios="\${PROLE_CHILD_NETBIOS_NAME:-}" - if [[ -z "\$netbios" ]]; then - netbios="\$workgroup" - fi - server_string="\${PROLE_CHILD_SERVER_STRING:-}" - if [[ -z "\$server_string" ]]; then - server_string="\${realm} AD DC" - fi - server_role="\${PROLE_SAMBA_SERVER_ROLE:-}" - if [[ -z "\$server_role" ]]; then - server_role='active directory domain controller' - fi - mkdir -p /etc/samba - # Write minimal Samba config without using a here‑doc to avoid YAML indentation issues - # when this script is embedded in a ConfigMap. Variables are expanded at container runtime. - { - printf '%s\n' "[global]" - printf '%s\n' " workgroup = \${workgroup}" - printf '%s\n' " realm = \${realm}" - printf '%s\n' " netbios name = \${netbios}" - printf '%s\n' " server string = \${server_string}" - printf '%s\n' " server role = \${server_role}" - } > /etc/samba/smb.conf - fi - - realm="\${PROLE_KDC_REALM}" - admin_principal="\${PROLE_KDC_ADMIN_PRINCIPAL}" - if [[ "\${admin_principal}" != *"@"* ]]; then - admin_principal="\${admin_principal}@\${PROLE_KDC_REALM}" - fi - - if [[ ! -f /var/lib/krb5kdc/principal ]]; then - echo "Initializing realm database for \${PROLE_KDC_REALM}..." - kdb5_util create -s -r "\${realm}" -P "\${PROLE_KDC_MASTER_PASSWORD}" - fi - - if ! kadmin.local -q "get_principal \${admin_principal}" >/dev/null 2>&1; then - echo "Creating admin principal \${admin_principal}..." - kadmin.local -q "addprinc -pw \${PROLE_KDC_ADMIN_PASSWORD} \${admin_principal}" - fi - - if [[ -n "\${PROLE_KDC_TRUST_REALM:-}" && "\${PROLE_KDC_TRUST_REALM}" != "\${PROLE_KDC_REALM}" ]]; then - shared_pw="\${PROLE_KDC_TRUST_SHARED_PASSWORD:-\${PROLE_KDC_MASTER_PASSWORD}}" - if ! kadmin.local -q "get_principal krbtgt/\${PROLE_KDC_TRUST_REALM}@\${PROLE_KDC_REALM}" >/dev/null 2>&1; then - echo "Creating trust principal krbtgt/\${PROLE_KDC_TRUST_REALM}@\${PROLE_KDC_REALM}..." - kadmin.local -q "addprinc -pw \${shared_pw} krbtgt/\${PROLE_KDC_TRUST_REALM}@\${PROLE_KDC_REALM}" - fi - - if [[ -n "\${PROLE_KDC_TRUST_ADMIN:-}" && -n "\${PROLE_KDC_TRUST_PASSWORD:-}" ]]; then - echo "Creating reciprocal trust principal in \${PROLE_KDC_TRUST_REALM}..." - kadmin -r "\${PROLE_KDC_TRUST_REALM}" -p "\${PROLE_KDC_TRUST_ADMIN}" -w "\${PROLE_KDC_TRUST_PASSWORD}" \ - -q "addprinc -pw \${shared_pw} krbtgt/\${PROLE_KDC_REALM}@\${PROLE_KDC_TRUST_REALM}" || true - else - echo "WARN: Missing PROLE_KDC_TRUST_ADMIN/PROLE_KDC_TRUST_PASSWORD; skipping external trust principal." - fi - fi - - # Start daemons. Keep kadmind in PID 1; run krb5kdc in background and verify it binds. - echo "Starting krb5kdc and kadmind ..." - krb5kdc -n & - sleep 0.5 - if ! pgrep -x krb5kdc >/dev/null 2>&1; then - echo "ERROR: krb5kdc failed to start. Check /var/log/ (syslog) for details." >&2 - exit 1 - fi - exec kadmind -nofork ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ${KNOE_AUTH_NAME} - namespace: ${KNOE_AUTH_NAMESPACE} -spec: - replicas: 1 - selector: - matchLabels: - app: ${KNOE_AUTH_NAME} - template: - metadata: - labels: - app: ${KNOE_AUTH_NAME} - spec: -${host_net_block} -${dns_policy_block} - containers: - - name: kdc - image: ${KNOE_AUTH_IMAGE} - imagePullPolicy: IfNotPresent - command: ["/bin/bash", "/opt/knoe-auth-kdc/entrypoint.sh"] - env: - - name: PROLE_KDC_REALM - value: "${PROLE_KDC_REALM}" - - name: PROLE_KDC_ADMIN_PRINCIPAL - value: "${PROLE_KDC_ADMIN_PRINCIPAL}" - - name: PROLE_KDC_MASTER_PASSWORD - valueFrom: - secretKeyRef: - name: knoe-auth-secrets - key: master_password - - name: PROLE_KDC_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: knoe-auth-secrets - key: admin_password - - name: PROLE_KDC_TRUST_SHARED_PASSWORD - valueFrom: - secretKeyRef: - name: knoe-auth-secrets - key: trust_shared_password - - name: PROLE_KDC_TRUST_REALM - value: "${PROLE_KDC_TRUST_REALM}" - - name: PROLE_KDC_TRUST_ADMIN - value: "${PROLE_KDC_TRUST_ADMIN}" - - name: PROLE_KDC_TRUST_PASSWORD - valueFrom: - secretKeyRef: - name: knoe-auth-secrets - key: trust_password - - name: PROLE_CHILD_REALM - value: "${PROLE_CHILD_REALM:-}" - - name: PROLE_CHILD_WORKGROUP - value: "${PROLE_CHILD_WORKGROUP:-}" - - name: PROLE_CHILD_NETBIOS_NAME - value: "${PROLE_CHILD_NETBIOS_NAME:-}" - - name: PROLE_CHILD_SERVER_STRING - value: "${PROLE_CHILD_SERVER_STRING:-}" - - name: PROLE_SAMBA_SERVER_ROLE - value: "${PROLE_SAMBA_SERVER_ROLE:-}" - ports: - - name: krb5-udp - containerPort: 88 - protocol: UDP - - name: krb5-tcp - containerPort: 88 - protocol: TCP - - name: kpasswd-udp - containerPort: 464 - protocol: UDP - - name: kpasswd-tcp - containerPort: 464 - protocol: TCP - - name: kadmin - containerPort: 749 - protocol: TCP - volumeMounts: - - name: knoe-auth-kdc-config - mountPath: /opt/knoe-auth-kdc - - name: knoe-auth-kdc-data - mountPath: /var/lib/krb5kdc - - name: knoe-auth-kdc-data - mountPath: /etc/krb5kdc - volumes: - - name: knoe-auth-kdc-config - configMap: - name: knoe-auth-kdc-config - - name: knoe-auth-kdc-data - emptyDir: {} ---- -apiVersion: v1 -kind: Service -metadata: - name: ${KNOE_AUTH_SERVICE} - namespace: ${KNOE_AUTH_NAMESPACE} -spec: - selector: - app: ${KNOE_AUTH_NAME} - ports: - - name: krb5-udp - port: 88 - targetPort: 88 - protocol: UDP - - name: krb5-tcp - port: 88 - targetPort: 88 - protocol: TCP - - name: kpasswd-udp - port: 464 - targetPort: 464 - protocol: UDP - - name: kpasswd-tcp - port: 464 - targetPort: 464 - protocol: TCP - - name: kadmin - port: 749 - targetPort: 749 - protocol: TCP -EOF - } - - apply_knoe_auth_manifest - - local rollout_timeout="$KNOE_AUTH_ROLLOUT_TIMEOUT" - if [[ "$deployment_present" -eq 0 ]]; then - rollout_timeout="$KNOE_AUTH_DEPLOY_TIMEOUT" - fi - if ! kubectl -n "$KNOE_AUTH_NAMESPACE" rollout status deploy/${KNOE_AUTH_NAME} --timeout="${rollout_timeout}s"; then - err "WARN: knoe-auth rollout did not complete within ${rollout_timeout}s. Collecting diagnostics..." - latest_pod=$(kubectl -n "$KNOE_AUTH_NAMESPACE" get pod -l "app=${KNOE_AUTH_NAME}" --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1].metadata.name}' 2>/dev/null || true) - if [[ -n "${latest_pod:-}" ]]; then - err "--- describe pod ${latest_pod} ---" - kubectl -n "$KNOE_AUTH_NAMESPACE" describe pod "$latest_pod" 1>&2 || true - err "--- last 200 log lines from ${latest_pod} ---" - kubectl -n "$KNOE_AUTH_NAMESPACE" logs "$latest_pod" --tail=200 1>&2 || true - else - err "[WARN] No pods found for app=${KNOE_AUTH_NAME} to collect logs from." - fi - if maybe_cleanup_stale_kdc "rollout timeout"; then - log "Re-applying knoe-auth after cleanup..." - apply_knoe_auth_manifest - kubectl -n "$KNOE_AUTH_NAMESPACE" rollout status deploy/${KNOE_AUTH_NAME} --timeout="${KNOE_AUTH_DEPLOY_TIMEOUT}s" || true - fi +# ── KDC secrets ────────────────────────────────────────────────────────────── + +create_kdc_secrets() { + if kube -n "$NAMESPACE" get secret knoe-kdc-secrets >/dev/null 2>&1; then + info "knoe-kdc-secrets already exists — skipping." + return fi + info "Creating knoe-kdc-secrets from 1Password..." + local master admin + master=$(op_secret "knoe-kdc-master" "password") + admin=$(op_secret "knoe-kdc-admin" "password") + kube -n "$NAMESPACE" create secret generic knoe-kdc-secrets \ + --from-literal=master_password="$master" \ + --from-literal=admin_password="$admin" + info "knoe-kdc-secrets created." } -cleanup_knoe_auth() { - log "Removing knoe-auth resources (if present)..." - kubectl -n "$KNOE_AUTH_NAMESPACE" delete service "$KNOE_AUTH_SERVICE" --ignore-not-found - kubectl -n "$KNOE_AUTH_NAMESPACE" delete deployment "$KNOE_AUTH_NAME" --ignore-not-found - kubectl -n "$KNOE_AUTH_NAMESPACE" delete configmap knoe-auth-kdc-config --ignore-not-found - kubectl -n "$KNOE_AUTH_NAMESPACE" delete secret knoe-auth-secrets --ignore-not-found -} - -status() { - log "--- init_knoe_auth status ---" - log "Namespace: $KNOE_AUTH_NAMESPACE" - log "knoe-auth enabled: ${KNOE_AUTH_ENABLED:-0}" - log "KDC realm: ${PROLE_KDC_REALM:-}" - log "knoe-auth service: ${KNOE_AUTH_SERVICE:-}" - log "knoe-auth image: ${KNOE_AUTH_IMAGE:-}" - if kubectl -n "$KNOE_AUTH_NAMESPACE" get deploy "$KNOE_AUTH_NAME" >/dev/null 2>&1; then - log "[OK] knoe-auth deployment present" - if maybe_cleanup_stale_kdc "status check" "strict"; then - log "[INFO] Stale knoe-auth deployment removed. Re-run init_knoe_auth.sh update to recreate." - fi - else - log "[INFO] knoe-auth deployment not present" +create_google_oidc_secret() { + if kube -n "$NAMESPACE" get secret knoe-auth-google-oidc >/dev/null 2>&1; then + info "knoe-auth-google-oidc already exists — skipping." + return fi + info "Creating knoe-auth-google-oidc secret..." + local client_id client_secret + client_id=$(op_secret "knoe-google-oidc" "client_id") + client_secret=$(op_secret "knoe-google-oidc" "client_secret") + kube -n "$NAMESPACE" create secret generic knoe-auth-google-oidc \ + --from-literal=client_id="$client_id" \ + --from-literal=client_secret="$client_secret" + info "knoe-auth-google-oidc created." } -case "$ACTION" in - initialize|init|update|deploy|start) - ensure_tools - ensure_namespace - ensure_knoe_auth - ;; - status) - ensure_tools - status - ;; - cleanup|delete|remove) - ensure_tools - ensure_namespace - cleanup_knoe_auth - ;; +create_session_secret() { + if kube -n "$NAMESPACE" get secret knoe-auth-secrets >/dev/null 2>&1; then + info "knoe-auth-secrets already exists — skipping." + return + fi + info "Creating knoe-auth-secrets (session HMAC key)..." + local session_secret + session_secret=$(op_secret "knoe-auth-session" "password") + kube -n "$NAMESPACE" create secret generic knoe-auth-secrets \ + --from-literal=sessionSecret="$session_secret" + info "knoe-auth-secrets created." +} + +# ── Manifests ──────────────────────────────────────────────────────────────── + +apply_manifests() { + info "Applying KDC ConfigMap..." + kube apply -f "$GKE_DIR/knoe-kdc-configmap.yaml" + + info "Applying knoe-auth Deployment..." + kube apply -f "$GKE_DIR/knoe-auth-deployment.yaml" +} + +# ── Invite helper ───────────────────────────────────────────────────────────── + +create_first_invite() { + local contact="${1:-}" + [[ -z "$contact" ]] && { read -rp "Invite contact (email or phone): " contact; } + local name_hint="" + read -rp "Display name hint (optional, press Enter to skip): " name_hint || true + + info "Creating invite for: $contact" + local admin_token + admin_token=$(op_secret "knoe-admin-token" "credential" 2>/dev/null || \ + { read -rsp "Admin token: " t; echo; printf '%s' "$t"; }) + + local response + response=$(curl -sf -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $admin_token" \ + -d "{\"contact\":\"$contact\",\"contactType\":\"email\",\"nameHint\":\"$name_hint\"}" \ + "${AUTH_HOST}/auth/admin/invites") || { + warn "Admin API call failed. knoe-auth may not be ready yet." + warn "Retry: POST ${AUTH_HOST}/auth/admin/invites" + return 1 + } + + local invite_url + invite_url=$(printf '%s' "$response" | grep -o '"enrollUrl":"[^"]*"' | sed 's/"enrollUrl":"//;s/"//') + printf '\n\033[1;32mInvite URL:\033[0m %s\n\n' "$invite_url" + info "Send the above URL to: $contact" +} + +# ── Status ──────────────────────────────────────────────────────────────────── + +show_status() { + info "=== Pod status ===" + kube -n "$NAMESPACE" get pods -l app=knoe-auth 2>/dev/null || true + info "=== Secrets ===" + kube -n "$NAMESPACE" get secret \ + knoe-kdc-secrets knoe-auth-google-oidc knoe-auth-secrets 2>/dev/null || true + info "=== Services ===" + kube -n "$NAMESPACE" get svc knoe-auth 2>/dev/null || true + info "=== Enrollment endpoint ===" + info "${AUTH_HOST}/auth/enroll" +} + +# ── Full initialization ─────────────────────────────────────────────────────── + +cmd_initialize() { + require_tool kubectl + + info "=== knoe-auth initialization ===" + info " Realm: $REALM" + info " Cluster: $APP_CTX" + info " NS: $NAMESPACE" + info "" + + # 1. Ensure namespace exists + kube get namespace "$NAMESPACE" >/dev/null 2>&1 || \ + kube create namespace "$NAMESPACE" + + # 2. Secrets + create_kdc_secrets + create_google_oidc_secret + create_session_secret + + # 3. Apply ConfigMap + Deployment + apply_manifests + + # 4. Wait for pods + wait_for_pods "app=knoe-auth" 240 + + # 5. Schema + run_schema + + # 6. Done + info "" + info "=== knoe-auth is ready ===" + info "Enrollment URL: ${AUTH_HOST}/auth/enroll?token=" + info "" + info "Next: create first admin invite:" + info " $0 invite chrisfu@prole.org" + info "" + show_status +} + +# ── Dispatch ────────────────────────────────────────────────────────────────── + +case "$COMMAND" in + initialize) cmd_initialize ;; + schema) run_schema ;; + invite) create_first_invite "${1:-}" ;; + status) show_status ;; *) - err "Usage: $0 {initialize|update|status|cleanup}" - exit 2 + echo "Usage: $0 {initialize|schema|invite EMAIL|status} [--context CTX] [--namespace NS]" >&2 + exit 1 ;; esac diff --git a/etc/init_knoe_users.sh b/etc/init_knoe_users.sh index 2df32bc..3f1bee0 100755 --- a/etc/init_knoe_users.sh +++ b/etc/init_knoe_users.sh @@ -7,14 +7,14 @@ set -euo pipefail # - Provision Kerberos principals and database accounts for knoe-system users # - Creates: admin@PROLE.LOCAL (master password), guest@PROLE.LOCAL (read-only), # postgres service principal (keytab for GSS auth), developer group role -# - Cross-realm trust with myrddin.knoe.org PROLE.ORG is activated via +# - Cross-realm trust with myrddin.prole.org PROLE.ORG is activated via # PROLE_KDC_TRUST_REALM=PROLE.ORG in init_kdc.sh / init_knoe_auth.sh # - Sets up service admin access: ArgoCD RBAC, Gitea, GitLab # # Prerequisites: # init_kdc.sh initialize (with PROLE_KDC_REALM=PROLE.LOCAL, # PROLE_KDC_TRUST_REALM=PROLE.ORG, -# KRB5_KDC=myrddin.knoe.org) +# KRB5_KDC=myrddin.prole.org) # init_cnpg_backup.sh (CNPG cluster must exist) # init_argocd.sh (ArgoCD must be running) # @@ -93,8 +93,8 @@ KNOE_DEPLOYMENT_MODE="${KNOE_DEPLOYMENT_MODE:-$(resolve_knoe_mode)}" KNOE_AUTH_DEPLOYMENT="${KNOE_AUTH_DEPLOYMENT:-${PROLE_KDC_NAME:-$(default_knoe_auth_deployment)}}" KNOE_ADMIN_PRINCIPAL="${KNOE_ADMIN_PRINCIPAL:-admin}" PROLE_KDC_REALM="${PROLE_KDC_REALM:-PROLE.LOCAL}" -GITEA_HOST="${GITEA_HOST:-git.knoe.org}" -GITLAB_HOST="${GITLAB_HOST:-gitlab.knoe.org}" +GITEA_HOST="${GITEA_HOST:-git.prole.org}" +GITLAB_HOST="${GITLAB_HOST:-gitlab.prole.org}" GITEA_NAMESPACE="${GITEA_NAMESPACE:-gitea}" GITLAB_NAMESPACE="${GITLAB_NAMESPACE:-gitlab}" ARGOCD_NAMESPACE="${ARGOCD_NAMESPACE:-argocd}" @@ -455,8 +455,8 @@ SQL # 8. Create knoe.user schema tables and bootstrap users if [[ -n "$primary" ]]; then create_knoe_schema "$primary" - provision_user "$primary" "chrisfu" "chrisfu@knoe.org" "Chris Fu" "admin" - provision_user "$primary" "ron" "ron@knoe.org" "Ron" "developer" + provision_user "$primary" "chrisfu" "chrisfu@prole.org" "Chris Fu" "admin" + provision_user "$primary" "ron" "ron@prole.org" "Ron" "developer" else warn "CNPG primary not found — skipping knoe.user schema and user provisioning" fi @@ -473,7 +473,7 @@ SQL log "=== Initialization complete ===" log "" log "Next steps:" - log " 1. On myrddin.knoe.org: add krbtgt/PROLE.LOCAL@PROLE.ORG trust principal" + log " 1. On myrddin.prole.org: add krbtgt/PROLE.LOCAL@PROLE.ORG trust principal" log " (samba-tool domain trust or equivalent, using trust_shared_password from knoe-kdc-secrets)" log " 2. Confirm Grafana auth.proxy configured with: headers = Role:X-Knoe-Groups" log " 3. After admin logs in to Gitea/GitLab for the first time, re-run: $0 initialize" diff --git a/k8s/openbao/kerberos-configmap.yaml b/k8s/openbao/kerberos-configmap.yaml index 85ea489..730abe7 100644 --- a/k8s/openbao/kerberos-configmap.yaml +++ b/k8s/openbao/kerberos-configmap.yaml @@ -17,5 +17,5 @@ data: } [domain_realm] - .knoe.org = PROLE.ORG - knoe.org = PROLE.ORG + .prole.org = PROLE.ORG + prole.org = PROLE.ORG From c570160fb535694e51b6aa0be5adbc9721ff5edc Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:08:21 -0700 Subject: [PATCH 06/12] =?UTF-8?q?chore(installer):=20core=20Python=20updat?= =?UTF-8?q?es=20=E2=80=94=20env,=20milestones,=20monitoring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minor updates aligned with min-mode and auth Round 1 integration. Co-Authored-By: Claude Sonnet 4.6 --- knoe/core/env.py | 2 +- knoe/core/milestones.py | 2 +- knoe/core/ops/monitoring.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/knoe/core/env.py b/knoe/core/env.py index 559fa33..e7b4922 100644 --- a/knoe/core/env.py +++ b/knoe/core/env.py @@ -814,7 +814,7 @@ def _detect_ansible_storage_mounts(inventory_path: Path) -> dict[str, dict[str, Returns a mapping like: { - "d001": {"host": "myrddin.knoe.org", "path": "/synology/d001"}, + "d001": {"host": "myrddin.prole.org", "path": "/synology/d001"}, ... } diff --git a/knoe/core/milestones.py b/knoe/core/milestones.py index 1e1a7ff..9a72dff 100644 --- a/knoe/core/milestones.py +++ b/knoe/core/milestones.py @@ -836,7 +836,7 @@ class DockerBuildMilestone(Milestone): registry = (state.config_data.get("Global", {}) or {}).get("KNOE_IMAGE_REGISTRY", "").strip() if not registry or registry == "localhost:5000": - server = state.inputs.get("init_cluster.k3s_server_url", "myrddin.knoe.org") + server = state.inputs.get("init_cluster.k3s_server_url", "myrddin.prole.org") if "://" in server: server = server.split("://")[1] if ":" in server: diff --git a/knoe/core/ops/monitoring.py b/knoe/core/ops/monitoring.py index 9118699..cb19973 100644 --- a/knoe/core/ops/monitoring.py +++ b/knoe/core/ops/monitoring.py @@ -227,7 +227,7 @@ def _values_yaml_k3s(grafana_password: str, env: dict | None) -> str: sc_alert = f"merlin-local-iscsi-{volume_id}-alertmanager" sc_grafana = f"merlin-local-iscsi-{volume_id}-grafana" - monitoring_node = str((env or {}).get("MONITORING_PRIMARY_NODE") or "merlin.knoe.org") + monitoring_node = str((env or {}).get("MONITORING_PRIMARY_NODE") or "merlin.prole.org") excluded_nodes = str((env or {}).get("MONITORING_NODE_EXPORTER_EXCLUDE_NODES") or "pi.knoe.org") excluded_list = [n.strip() for n in excluded_nodes.split(",") if n.strip()] From c326235138c2bd9b8258f204c45d574c108d5969 Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:08:24 -0700 Subject: [PATCH 07/12] chore(build): Maven version and authority module updates Adds authority module to root POM, updates Spring Boot and dependency versions in authority/pom.xml, application.yml updated for KNOE.DEV realm defaults. Co-Authored-By: Claude Sonnet 4.6 --- authority/pom.xml | 33 ++++++++++++++++ authority/src/main/resources/application.yml | 41 ++++++++++++++++++-- knoe-db.iml | 5 +++ pom.xml | 4 +- 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/authority/pom.xml b/authority/pom.xml index 73515cf..e98636d 100644 --- a/authority/pom.xml +++ b/authority/pom.xml @@ -62,6 +62,39 @@ 0.11.5 runtime + + + + + + dev.samstevens.totp + totp-spring-boot-starter + 1.7.1 + + + + + com.google.api-client + google-api-client + 2.4.0 + + + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.postgresql + postgresql + runtime + + + + + org.springframework.security + spring-security-crypto + diff --git a/authority/src/main/resources/application.yml b/authority/src/main/resources/application.yml index 1a7497a..4b33a3d 100644 --- a/authority/src/main/resources/application.yml +++ b/authority/src/main/resources/application.yml @@ -22,6 +22,41 @@ knoe: adminPrincipals: [] kerberos: # REQUIRED for SPNEGO when enabled. Provide via env. - servicePrincipal: "" - keytabPath: "" - realm: "" + servicePrincipal: ${KNOE_KERBEROS_SERVICE_PRINCIPAL:} + keytabPath: ${KNOE_KERBEROS_KEYTAB_PATH:} + realm: ${KNOE_KERBEROS_REALM:} + + # ── Enrollment (knoe-auth Round 1) ────────────────────────────────────── + enroll: + inviteTtlHours: ${KNOE_ENROLL_INVITE_TTL_HOURS:72} + otpTtlMinutes: ${KNOE_ENROLL_OTP_TTL_MINUTES:10} + otpMaxAttempts: ${KNOE_ENROLL_OTP_MAX_ATTEMPTS:3} + totpIssuer: ${KNOE_ENROLL_TOTP_ISSUER:Knoe.DEV} + # Base URL used in invite emails and enrollment redirect URIs + baseUrl: ${KNOE_AUTH_BASE_URL:https://auth.knoe.dev} + + # ── Google OAuth2 corroboration ────────────────────────────────────────── + # No allowed-domains list — any Google account is accepted. + # Trust is established by invite OTP, not the developer's home domain. + # provider_hd is recorded in knoe.identity for audit only. + google: + clientId: ${GOOGLE_CLIENT_ID:} + clientSecret: ${GOOGLE_CLIENT_SECRET:} + redirectUri: ${KNOE_AUTH_BASE_URL:https://auth.knoe.dev}/auth/enroll/google-callback + + # ── Async provisioning worker ───────────────────────────────────────────── + provisioning: + pollIntervalMs: ${KNOE_PROVISIONING_POLL_INTERVAL_MS:10000} + giteaUrl: ${KNOE_GITEA_URL:https://git.knoe.dev} + giteaToken: ${KNOE_GITEA_TOKEN:} + +spring: + # ── Database ───────────────────────────────────────────────────────────── + datasource: + url: ${KNOE_DB_URL:jdbc:postgresql://localhost:5432/knoe} + username: ${KNOE_DB_USER:knoe} + password: ${KNOE_DB_PASSWORD:} + driver-class-name: org.postgresql.Driver + sql: + init: + mode: never diff --git a/knoe-db.iml b/knoe-db.iml index ad9fd88..80587ca 100644 --- a/knoe-db.iml +++ b/knoe-db.iml @@ -5,4 +5,9 @@ + + + + +
\ No newline at end of file diff --git a/pom.xml b/pom.xml index e5c001e..7c05069 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,9 @@ 24 1.0.0 false - false + + true From eb8c952523390c5554978308c03eb94a80c092af Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:08:29 -0700 Subject: [PATCH 08/12] chore(deploy): cluster config and k3s manifest updates Co-Authored-By: Claude Sonnet 4.6 --- conf/cnpg-placement/knoe-system-knoe-db.json | 22 ++-- conf/k3d.cfg | 107 +++++------------- conf/k3s.cfg | 44 +++---- deploy.sh | 6 +- .../manifests/knoe/garage-statefulset.yaml | 2 +- .../k3s/manifests/opentofu/deployment.yaml | 2 +- 6 files changed, 64 insertions(+), 119 deletions(-) diff --git a/conf/cnpg-placement/knoe-system-knoe-db.json b/conf/cnpg-placement/knoe-system-knoe-db.json index 0d1f250..c82084b 100644 --- a/conf/cnpg-placement/knoe-system-knoe-db.json +++ b/conf/cnpg-placement/knoe-system-knoe-db.json @@ -1,23 +1,15 @@ { - "assignments": { - "0": "gke-knoe-dev-0-default-pool-02b40136-m9h9", - "1": "gke-knoe-dev-0-default-pool-6fb9e725-8ldx", - "2": "gke-knoe-dev-0-default-pool-d65ac672-9ncf" - }, + "assignments": {}, "cluster_name": "knoe-db", "desired_instances": 3, - "eligible_nodes": [ - "gke-knoe-dev-0-default-pool-02b40136-m9h9", - "gke-knoe-dev-0-default-pool-6fb9e725-8ldx", - "gke-knoe-dev-0-default-pool-d65ac672-9ncf" - ], + "eligible_nodes": [], "metadata": { "prior_plan_present": true, - "reason": "reused", - "regenerated": false, - "reused": true + "reason": "new_cluster", + "regenerated": true, + "reused": false }, - "plan_hash": "a44825aeb5ed4e13", - "plan_id": "cnpg-placement-a44825aeb5ed4e13", + "plan_hash": "965d2fde4035059f", + "plan_id": "cnpg-placement-965d2fde4035059f", "schema_version": "v1" } diff --git a/conf/k3d.cfg b/conf/k3d.cfg index 1fe10d9..2100295 100644 --- a/conf/k3d.cfg +++ b/conf/k3d.cfg @@ -1,5 +1,5 @@ ; Knoe Master Configuration File -; Generated by install.py on 2026-04-14 06:09:00 +; Generated by install.py on 2026-04-22 21:15:00 ; This file is used as input for Ansible deployment and k8s cluster creation. [User] @@ -28,37 +28,35 @@ database_options.ext.pgvector = true database_options.ext.postgis = true database_options.ext.postgres_fdw = true database_options.version_type = v18 -dependencies.ansible.install = true dependencies.auto_install_missing = true dependencies.brew.install = true +dependencies.containerd.install = true +dependencies.docker-buildx.install = true dependencies.docker.install = true dependencies.gcloud.install = true dependencies.k3d.install = true dependencies.kubectl.install = true dependencies.kubectx.install = true +dependencies.op.install = true dependencies.opentofu.install = true dependencies.python.install = true dependencies.verify_all = false disk_selection.disk_type = local -disk_selection.local_path = /Users/chrisfu/dev/knoe/knoe-tools-app/dist +disk_selection.local_path = /Users/chrisfu/dev/knoe-db/knoe-tools-app/dist disk_selection.removable_mount = env_setup.CLUSTER_NAME = ${CLUSTER_NAME} env_setup.DATABASE_NAMESPACE = ${DATABASE_NAMESPACE} -env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe/conf -env_setup.PROLE_DATA = /Users/chrisfu/dev/knoe/data -env_setup.KNOE_HOME = /Users/chrisfu/dev/knoe -env_setup.PROLE_LOGS = /Users/chrisfu/dev/knoe/logs -env_setup.KNOE_SERVICE = /Users/chrisfu/dev/knoe/etc +env_setup.KNOE_CONF = /Users/chrisfu/dev/knoe-db/conf gitops.git_provider = Gitea gitops.node_selector = init_cluster.argocd_enabled = false init_cluster.at_rest_encryption_enabled = true init_cluster.cluster_env = dev init_cluster.deployment_target = knoe-dev-cluster -init_cluster.gitops_enabled = true -init_cluster.k3s_server_url = https://myrddin.knoe.org:6443 +init_cluster.gitops_enabled = false +init_cluster.k3s_server_url = https://myrddin.prole.org:6443 init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} -init_cluster.kerberos_enabled = true +init_cluster.kerberos_enabled = false init_cluster.mode = k3d init_cluster.start_cluster = true init_cluster.supabase_enabled = false @@ -73,10 +71,10 @@ init_password.db_password_confirm = init_password.db_username = root init_password.generate_ssh_key = true init_scripts.run_scripts = true -kerberos_config.enabled = true -kerberos_config.kdc = 10.0.0.3 +kerberos_config.enabled = false +kerberos_config.kdc = kerberos_config.password = -kerberos_config.realm = PROLE.ORG +kerberos_config.realm = kerberos_config.test_connection = false kerberos_config.user = administrator network_scan.run = true @@ -84,132 +82,87 @@ ollama_config.model = ollama_config.server_host = ollama_config.server_port = 11434 supabase_config.pv_base_dir = /synology/d005 -supabase_config.pv_node = gandalf.knoe.org +supabase_config.pv_node = [Global] ; Variables used by name in more than one place or assumed global scope -ARGOCD_NAMESPACE = argocd -ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system -AUTHORITY_ENABLED = true -AUTH_HOSTNAME = api.knoe.org -AUTH_LOGIN_PATH = /auth/login -AUTH_RESPONSE_HEADERS = X-Knoe-User,X-Knoe-Email,X-Knoe-Groups -AUTH_VERIFY_PATH = /auth/verify CLUSTER_ENV = dev CLUSTER_NAME = knoe-db -CNPG_PLACEMENT_PLAN_FILE = /Users/chrisfu/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json -CNPG_PLACEMENT_PLAN_HASH = 965d2fde4035059f -CNPG_PLACEMENT_PLAN_ID = cnpg-placement-965d2fde4035059f -DATABASE_NAMESPACE = knoe-db-18-008-18-009-18-013-18-014 +DATABASE_NAMESPACE = knoe-db-18-140 DB_HOST_PORT = 5432 DEPLOYMENT_MODE = k3d DEPLOYMENT_TARGET = knoe-dev-cluster DOCKER_PRELOAD = false -GITLAB_PUBLIC_HOSTS = git.knoe.dev,git.knoe.org -K3S_SERVER = https://myrddin.knoe.org:6443 -K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==} KNOE_DB_USER = root -KUBECONTEXT = dev -MONITORING_STORAGE_CLASS = local-path -OPENTOFU_URL = http://127.0.0.1:8080 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 -PROLE_K3S_SERVER = https://myrddin.knoe.org:6443 +PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} PROLE_OPENTOFU_URL = http://127.0.0.1:8080 -PROTECTED_DB_HOSTS = db.0.knoe.dev,db.knoe.org -PROTECTED_GIT_HOSTS = git.knoe.dev,git.knoe.org -REGISTRY_NAMESPACE = knoe-system SERVICE_NAMESPACE = knoe-system SUPABASE_PV_BASE = /synology/d005 SUPABASE_PV_BASE_DIR = /synology/d005 -SUPABASE_PV_NODE = gandalf.knoe.org -SUPABASE_STUDIO_HOSTNAME = db.0.knoe.dev,db.knoe.org [Welcome] ; No configuration values captured yet for this section. [Dependencies] -STATUS = All installed +; No configuration values captured yet for this section. [Network] -AD_DC_HOST = myrddin.knoe.org -AD_DC_IP = 10.0.0.3 -ANSIBLE_DOMAIN = knoe.org -ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure -ANSIBLE_INVENTORY = $HOME/dev/knoe/infrastructure/inventory -ANSIBLE_REALM = PROLE.ORG -ANSIBLE_TOPOLOGY = {"domain":"knoe.org","realm":"PROLE.ORG","internal_records":{"aventage.knoe.org":"10.0.0.206","fairyland.knoe.org":"10.0.0.208","loghost.knoe.org":"10.0.0.3","merlin.knoe.org":"10.0.0.6","morana.knoe.org":"10.0.0.66","morgoth.knoe.org":"10.0.0.204","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207","synology.knoe.org":"10.0.0.203","zinfandel.knoe.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.knoe.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.knoe.org:6443","server_host":"myrddin.knoe.org","token_present":true},"groups":{"iscsi":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"pihole":["pi.knoe.org","raspberry.knoe.org"],"ad_dc":["myrddin.knoe.org"],"k3s_servers":["myrddin.knoe.org"],"k3s_agents":["merlin.knoe.org","gandalf.knoe.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"ssl_hosts":["myrddin.knoe.org"],"mariadb_primary":["merlin.knoe.org"],"mariadb_replica":["raspberry.knoe.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.knoe.org"]},"hosts":{"merlin":"10.0.0.6","merlin.knoe.org":"10.0.0.6","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207"},"unmapped_hosts":["gandalf.knoe.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]} -KDC_ANSIBLE_DETECTED = 10.0.0.3 -KDC_AUTO_DETECTED = 10.0.0.3 -KERBEROS_AUTO_ENABLED = True +KERBEROS_AUTO_ENABLED = False [Port Forwards] ; No configuration values captured yet for this section. [System Environment] -KNOE_CONF = $HOME/dev/knoe/conf -PROLE_DATA = $HOME/dev/knoe/data -KNOE_HOME = $HOME/dev/knoe -PROLE_LOGS = $HOME/dev/knoe/logs -KNOE_SERVICE = $HOME/dev/knoe/etc +; No configuration values captured yet for this section. [Monitoring] -MONITORING_STORAGE_CLASS = local-path +; No configuration values captured yet for this section. [Kerberos Authentication] ; No configuration values captured yet for this section. [Ollama] -OLLAMA_SERVERS = 10.0.0.208:11434,fairyland.knoe.org:11434,k3d.localhost:11434,morgoth.knoe.org:11434 +; No configuration values captured yet for this section. [Optional Features] -AT_REST_ENCRYPTION_ENABLED = true -GITOPS_ENABLED = True -GITOPS_PROVIDER = GitLab -KERBEROS_ENABLED = true -SUPABASE_ENABLED = false +; No configuration values captured yet for this section. [GitOps] ; No configuration values captured yet for this section. [Database Creation] -APP_CLUSTER_NAME = knoe-dev-0 -DB_CLUSTER_NAME = knoe-cnpg-0 -DB_USER = root +; No configuration values captured yet for this section. [Initialize Cluster] -ENVIRONMENT = dev -K3S_SERVER_URL = https://myrddin.knoe.org:6443 -K3S_TOKEN = ${KNOE_SECRET:v1:ozzcomisjsQYIkSH:Ytp91WR_iP4tJyTAmdH_SRhcKycgzea0zLAgTBNxDsQaBPM-pR_VK3u9wc5QkFzszdAHZGBhVN2HKyqnz-cqDR0WAus88DFbF4zWlgvl6gKEAynaXbdMwAa6vYLUGi8ZE0u1pRiO4KJyiulhBIpfoMReM1Wu6Mj1-20hXw==} +; No configuration values captured yet for this section. [Dev Cluster (k3d)] CLUSTER_ENV = dev DISPLAY_NAME = knoe-dev-cluster -KUBECTL_CONTEXT = dev MODE = k3d [Service Cluster (k3s)] CLUSTER_ENV = knoe-service-cluster DISPLAY_NAME = knoe-service-cluster -K3S_SERVER_URL = https://myrddin.knoe.org:6443 +K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} MODE = k3s +PIPELINE_URL = http://127.0.0.1:8080 [GCP] -BILLING_ACCOUNT = 01193C-25783B-3211AD -BILLING_PROJECT = plenary-truck-485623-p7 -ORG_ID = 584001916389 -PROJECT_ID = plenary-truck-485623-p7 +; No configuration values captured yet for this section. [Prod Cluster (k8s)] -ARTIFACTS_DIR = $HOME/dev/knoe/data/staging +ARTIFACTS_DIR = $HOME/dev/knoe-db/data/staging CLUSTER_ENV = knoe-prod-cluster DISPLAY_NAME = knoe-prod-cluster MODE = k8s +PIPELINE_URL = http://127.0.0.1:8080 [Docker Build] -LOCAL_REGISTRY = localhost:5000 -LOCAL_REGISTRY_INTERNAL = k3d-knoe-registry.localhost:5000 +; No configuration values captured yet for this section. [Initialization Scripts] ; No configuration values captured yet for this section. @@ -219,4 +172,4 @@ MODE = k3d TARGET = knoe-dev-cluster [Install] -STATUS = Failed +; No configuration values captured yet for this section. diff --git a/conf/k3s.cfg b/conf/k3s.cfg index 9a2840e..eb7df8e 100644 --- a/conf/k3s.cfg +++ b/conf/k3s.cfg @@ -76,7 +76,7 @@ init_cluster.db_cluster_region = init_cluster.db_cluster_zones = init_cluster.deployment_target = knoe-service-cluster init_cluster.gitops_enabled = true -init_cluster.k3s_server_url = https://myrddin.knoe.org:6443 +init_cluster.k3s_server_url = https://myrddin.prole.org:6443 init_cluster.k3s_token = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} init_cluster.kerberos_enabled = true init_cluster.mode = k3s @@ -87,7 +87,7 @@ init_cluster.supabase_enabled = false init_cluster.supabase_meta_enabled = true init_cluster.supabase_realtime_enabled = true init_cluster.supabase_studio_enabled = false -init_cluster.supabase_studio_url = db.0.knoe.dev +init_cluster.supabase_studio_url = db.0.prole.org init_cnpg_deploy.force_rollout = false init_cnpg_deploy.run_deploy = true init_db_build.run_build = true @@ -113,24 +113,24 @@ ollama_config.model = ollama_config.server_host = ollama_config.server_port = 11434 supabase_config.pv_base_dir = /synology/d005 -supabase_config.pv_node = gandalf.knoe.org +supabase_config.pv_node = gandalf.prole.org [Global] ; Variables used by name in more than one place or assumed global scope ARGOCD_NAMESPACE = argocd ARTIFACT_REGISTRY = us-west3-docker.pkg.dev/plenary-truck-485623-p7/knoe-system AUTHORITY_ENABLED = true -AUTH_HOSTNAME = api.knoe.org +AUTH_HOSTNAME = api.prole.org AUTH_LOGIN_PATH = /auth/login AUTH_RESPONSE_HEADERS = X-Knoe-User,X-Knoe-Email,X-Knoe-Groups AUTH_VERIFY_PATH = /auth/verify CLUSTER_ENV = service CLUSTER_NAME = knoe-db -CNPG_ELIGIBLE_NODES = gandalf.knoe.org,merlin.knoe.org,myrddin.knoe.org +CNPG_ELIGIBLE_NODES = gandalf.prole.org,merlin.prole.org,myrddin.prole.org CNPG_PLACEMENT_PLAN_FILE = $HOME/dev/knoe/conf/cnpg-placement/knoe-system-knoe-db.json CNPG_PLACEMENT_PLAN_HASH = 962fb2e7bfd2a48b CNPG_PLACEMENT_PLAN_ID = cnpg-placement-962fb2e7bfd2a48b -CNPG_STAGE1_NODE = gandalf.knoe.org +CNPG_STAGE1_NODE = gandalf.prole.org DATABASE_NAMESPACE = knoe-db DB_HOST_PORT = 5432 DB_PASSWORD = ${KNOE_SECRET:v1:Vc5Sow_MQksbOtOJ:bvD1ABxenFlo0304dhf0Me_nzBX0SvLJ7oFvE_TkHGqc0YF8} @@ -138,30 +138,30 @@ DEPLOYMENT_MODE = k3s DEPLOYMENT_TARGET = knoe-service-cluster DOCKER_IMPORT_DIR = DOCKER_PRELOAD = false -GITEA_HOSTNAME = git-internal.knoe.org -GITLAB_PUBLIC_HOSTS = git.knoe.org +GITEA_HOSTNAME = git-internal.prole.org +GITLAB_PUBLIC_HOSTS = git.prole.org GITLAB_REPAIR_BLOCKED_AUTOCLEAN = 1 -K3S_SERVER = https://myrddin.knoe.org:6443 +K3S_SERVER = https://myrddin.prole.org:6443 K3S_TOKEN = ${KNOE_SECRET:v1:CWWf3RHFdbUrmfrY:It8a2G8QUUIsqVwMsm3LsI4UvSSChEc_uAdESwzYplZLOCiSsCbOKuT9FbPpIwQvEaG_gLz9ZAfkD0EQxJp81KAtpk_X3K_nxVUa0RPRlbt_wdeXXoMoFFpN5BqXXz2HZwKgh_gpK1hjVbsJQHKAbTqWfu8u_LTmYYg4ag==} KNOE_DB_USER = root -KNOE_IMAGE_REGISTRY = registry.knoe.org +KNOE_IMAGE_REGISTRY = registry.prole.org MONITORING_STORAGE_CLASS = local-path OPENTOFU_URL = http://127.0.0.1:8080 OPTIONAL_WORKLOADS_MIN_READY_SCHEDULABLE_NODES = 2 KNOE_HOME = $HOME/dev/knoe -PROLE_K3S_SERVER = https://myrddin.knoe.org:6443 +PROLE_K3S_SERVER = https://myrddin.prole.org:6443 PROLE_K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} -PROTECTED_DB_HOSTS = db.knoe.org -PROTECTED_GIT_HOSTS = git.knoe.org +PROTECTED_DB_HOSTS = db.prole.org +PROTECTED_GIT_HOSTS = git.prole.org REDIS_HOST = redis-master.knoe-system.svc.cluster.local REGISTRY_NAMESPACE = knoe-system -SERVICE_HOSTNAME = svc.knoe.org +SERVICE_HOSTNAME = svc.prole.org SERVICE_NAMESPACE = knoe-system -SUPABASE_API_HOSTNAME = supabase.knoe.org +SUPABASE_API_HOSTNAME = supabase.prole.org SUPABASE_PV_BASE = /synology/d005 SUPABASE_PV_BASE_DIR = /synology/d005 -SUPABASE_PV_NODE = gandalf.knoe.org -SUPABASE_STUDIO_HOSTNAME = db.knoe.org +SUPABASE_PV_NODE = gandalf.prole.org +SUPABASE_STUDIO_HOSTNAME = db.prole.org SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005 [Welcome] @@ -171,13 +171,13 @@ SYNOLOGY_ROOTS = /synology/d001,/synology/d002,/synology/d004,/synology/d005 STATUS = All installed [Network] -AD_DC_HOST = myrddin.knoe.org +AD_DC_HOST = myrddin.prole.org AD_DC_IP = 10.0.0.3 -ANSIBLE_DOMAIN = knoe.org +ANSIBLE_DOMAIN = prole.org ANSIBLE_INFRASTRUCTURE = $HOME/dev/knoe/infrastructure ANSIBLE_INVENTORY = $HOME/dev/knoe/infrastructure/inventory ANSIBLE_REALM = PROLE.ORG -ANSIBLE_TOPOLOGY = {"domain":"knoe.org","realm":"PROLE.ORG","internal_records":{"aventage.knoe.org":"10.0.0.206","fairyland.knoe.org":"10.0.0.208","loghost.knoe.org":"10.0.0.3","merlin.knoe.org":"10.0.0.6","morana.knoe.org":"10.0.0.66","morgoth.knoe.org":"10.0.0.204","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207","synology.knoe.org":"10.0.0.203","zinfandel.knoe.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.knoe.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.knoe.org:6443","server_host":"myrddin.knoe.org","token_present":true},"groups":{"iscsi":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"pihole":["pi.knoe.org","raspberry.knoe.org"],"ad_dc":["myrddin.knoe.org"],"k3s_servers":["myrddin.knoe.org"],"k3s_agents":["merlin.knoe.org","gandalf.knoe.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.knoe.org","raspberry.knoe.org","myrddin.knoe.org","retropie.knoe.org","merlin.knoe.org","gandalf.knoe.org"],"ssl_hosts":["myrddin.knoe.org"],"mariadb_primary":["merlin.knoe.org"],"mariadb_replica":["raspberry.knoe.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.knoe.org"]},"hosts":{"merlin":"10.0.0.6","merlin.knoe.org":"10.0.0.6","myrddin.knoe.org":"10.0.0.3","pi.knoe.org":"10.0.0.5","raspberry.knoe.org":"10.0.0.4","retropie.knoe.org":"10.0.0.207"},"unmapped_hosts":["gandalf.knoe.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]} +ANSIBLE_TOPOLOGY = {"domain":"prole.org","realm":"PROLE.ORG","internal_records":{"aventage.prole.org":"10.0.0.206","fairyland.prole.org":"10.0.0.208","loghost.prole.org":"10.0.0.3","merlin.prole.org":"10.0.0.6","morana.prole.org":"10.0.0.66","morgoth.prole.org":"10.0.0.204","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207","synology.prole.org":"10.0.0.203","zinfandel.prole.org":"10.0.0.205"},"ad_dc":{"host":"myrddin.prole.org","ip":"10.0.0.3"},"k3s":{"server_url":"https://myrddin.prole.org:6443","server_host":"myrddin.prole.org","token_present":true},"groups":{"iscsi":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"pihole":["pi.prole.org","raspberry.prole.org"],"ad_dc":["myrddin.prole.org"],"k3s_servers":["myrddin.prole.org"],"k3s_agents":["merlin.prole.org","gandalf.prole.org"],"k3s_hosts:children":["k3s_servers","k3s_agents"],"linux_hosts":["pi.prole.org","raspberry.prole.org","myrddin.prole.org","retropie.prole.org","merlin.prole.org","gandalf.prole.org"],"ssl_hosts":["myrddin.prole.org"],"mariadb_primary":["merlin.prole.org"],"mariadb_replica":["raspberry.prole.org"],"mariadb:children":["mariadb_primary","mariadb_replica"],"merlin_bootstrap":["merlin"],"k3s_hosts":["gandalf.prole.org"]},"hosts":{"merlin":"10.0.0.6","merlin.prole.org":"10.0.0.6","myrddin.prole.org":"10.0.0.3","pi.prole.org":"10.0.0.5","raspberry.prole.org":"10.0.0.4","retropie.prole.org":"10.0.0.207"},"unmapped_hosts":["gandalf.prole.org","k3s_agents","k3s_servers","mariadb_primary","mariadb_replica"]} KDC_ANSIBLE_DETECTED = 10.0.0.3 KDC_AUTO_DETECTED = 10.0.0.3 KERBEROS_AUTO_ENABLED = True @@ -226,7 +226,7 @@ DB_USER = root [Initialize Cluster] ENVIRONMENT = service -K3S_SERVER_URL = https://myrddin.knoe.org:6443 +K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} [Dev Cluster (k3d)] @@ -238,7 +238,7 @@ MODE = k3d [Service Cluster (k3s)] CLUSTER_ENV = knoe-service-cluster DISPLAY_NAME = knoe-service-cluster -K3S_SERVER_URL = https://myrddin.knoe.org:6443 +K3S_SERVER_URL = https://myrddin.prole.org:6443 K3S_TOKEN = ${KNOE_SECRET:v1:-cslrAhG8WhxxJLY:vAx5MGOBcU1NBMjf4U-3y1djMDmwwOjiWkrutxPa4Li5P8RBjCBhEOCEdGP2CiPJJ6UQK-ietX-mu_5nO3yNNjpwDququT4U6lWHPEInSvUNH6ImU-HzPvm_diL2FwJtZm3sY5HgecIy2dwXB_vLnYaA_7VXt9zf0T70rw==} KUBECTL_CONTEXT = knoe-service-cluster MODE = k3s diff --git a/deploy.sh b/deploy.sh index d101a47..84bfdc4 100755 --- a/deploy.sh +++ b/deploy.sh @@ -132,7 +132,7 @@ def get_runtime_config(path): gitlab_public_hosts_raw = _cfg_first(g, e, i, "GITLAB_PUBLIC_HOSTS", "gitlab_public_hosts") gitlab_domain = _cfg_first(g, e, i, "GITLAB_DOMAIN", "GITLAB_HOSTNAME", "gitlab_domain", "gitlab_hostname") if not gitlab_domain: - gitlab_domain = "git.knoe.dev" if mode == "k8s" else "git.knoe.org" + gitlab_domain = "git.knoe.dev" if mode == "k8s" else "git.prole.org" gitlab_public_hosts = [h.strip() for h in str(gitlab_public_hosts_raw or "").split(",") if h.strip()] if not gitlab_public_hosts: gitlab_public_hosts = [gitlab_domain] @@ -145,11 +145,11 @@ def get_runtime_config(path): auth_host = _cfg_first(g, e, i, "AUTH_HOSTNAME", "auth_hostname") if not auth_host: - auth_host = "api.knoe.dev" if mode == "k8s" else "api.knoe.org" + auth_host = "api.knoe.dev" if mode == "k8s" else "api.prole.org" service_host = _cfg_first(g, e, i, "SERVICE_HOSTNAME", "service_hostname", "GRAFANA_HOSTNAME", "grafana_hostname") if not service_host: - service_host = "svc.knoe.dev" if mode == "k8s" else "svc.knoe.org" + service_host = "svc.knoe.dev" if mode == "k8s" else "svc.prole.org" service_ns = _cfg_first(g, e, i, "SERVICE_NAMESPACE", "service_namespace") or "knoe-system" diff --git a/deploy/opentofu/k3s/manifests/knoe/garage-statefulset.yaml b/deploy/opentofu/k3s/manifests/knoe/garage-statefulset.yaml index c34477d..7566deb 100644 --- a/deploy/opentofu/k3s/manifests/knoe/garage-statefulset.yaml +++ b/deploy/opentofu/k3s/manifests/knoe/garage-statefulset.yaml @@ -16,7 +16,7 @@ spec: app: garage spec: nodeSelector: - kubernetes.io/hostname: myrddin.knoe.org + kubernetes.io/hostname: myrddin.prole.org containers: - name: garage image: dxflrs/garage:v1.3.1 diff --git a/deploy/opentofu/k3s/manifests/opentofu/deployment.yaml b/deploy/opentofu/k3s/manifests/opentofu/deployment.yaml index 27f789f..15c222f 100644 --- a/deploy/opentofu/k3s/manifests/opentofu/deployment.yaml +++ b/deploy/opentofu/k3s/manifests/opentofu/deployment.yaml @@ -56,7 +56,7 @@ spec: app: opentofu spec: nodeSelector: - kubernetes.io/hostname: myrddin.knoe.org + kubernetes.io/hostname: myrddin.prole.org containers: - name: opentofu-ui image: nginx:1.27-alpine From 0d0bad583f7521dbf6dec5ef0af110332f45c0fc Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:08:29 -0700 Subject: [PATCH 09/12] =?UTF-8?q?chore(scripts):=20init=20script=20updates?= =?UTF-8?q?=20=E2=80=94=20gitea,=20gitlab,=20kong,=20monitoring,=20registr?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- etc/init_gitea.sh | 2 +- etc/init_gitlab.sh | 32 ++++++++++++++++---------------- etc/init_kong.sh | 6 +++--- etc/init_monitoring.sh | 16 ++++++++-------- etc/init_registry.sh | 2 +- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/etc/init_gitea.sh b/etc/init_gitea.sh index 669bb99..523999d 100755 --- a/etc/init_gitea.sh +++ b/etc/init_gitea.sh @@ -88,7 +88,7 @@ GITEA_PV_NODE_SELECTOR_KEY="${GITEA_PV_NODE_SELECTOR_KEY:-${NODE_SELECTOR_KEY}}" GITEA_PV_NODE="${GITEA_PV_NODE:-${GITEA_NODE_SELECTOR:-}}" GITEA_PV_BASE_DIR="${GITEA_PV_BASE_DIR:-/synology/d005}" GITEA_STORAGE_CLASS="${GITEA_STORAGE_CLASS:-gitea-local-d005}" -GITEA_DOMAIN="${GITEA_DOMAIN:-git.knoe.org}" +GITEA_DOMAIN="${GITEA_DOMAIN:-git.prole.org}" GITEA_SSH_DOMAIN="${GITEA_SSH_DOMAIN:-$GITEA_DOMAIN}" KNOE_DB_NAMESPACE="${KNOE_DB_NAMESPACE:-${DATABASE_NAMESPACE:-knoe-db}}" KNOE_DB_CLUSTER="${KNOE_DB_CLUSTER:-${CLUSTER_NAME:-knoe-db}}" diff --git a/etc/init_gitlab.sh b/etc/init_gitlab.sh index 78898c2..6b5143c 100755 --- a/etc/init_gitlab.sh +++ b/etc/init_gitlab.sh @@ -1699,9 +1699,9 @@ check_gitlab_post_apply_blocked() { log "Desired Gitaly state (from CR): nodeSelector=[${desired_gitaly_node_selector}], rendered.global.persistence.storageClass=[${rendered_global_sc:-}], rendered.gitlab.gitaly.persistence.storageClass=[${rendered_gitlab_gitaly_sc:-}], rendered.gitaly.persistence.storageClass=[${rendered_chart_gitaly_sc:-}]" log "Internal script GITALY_STORAGE_CLASS=[${GITALY_STORAGE_CLASS:-}]" - if [[ "$desired_cr" == *"gandalf.knoe.org"* ]]; then + if [[ "$desired_cr" == *"gandalf.prole.org"* ]]; then repair_blocked "GitLab CR contains legacy nodeSelector" \ - "Desired GitLab CR still contains gandalf.knoe.org. This is a configuration bug." + "Desired GitLab CR still contains gandalf.prole.org. This is a configuration bug." fi if [[ "$desired_cr" == *"gitlab-gitaly-static"* ]]; then repair_blocked "GitLab CR contains legacy storageClass" \ @@ -1733,7 +1733,7 @@ check_gitlab_post_apply_blocked() { live_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true) if [[ -z "$live_sts_yaml" ]]; then log "Gitaly StatefulSet not found (awaiting operator action)..." - elif [[ "$live_sts_yaml" != *"gandalf.knoe.org"* && "$live_sts_yaml" != *"gitlab-gitaly-static"* ]]; then + elif [[ "$live_sts_yaml" != *"gandalf.prole.org"* && "$live_sts_yaml" != *"gitlab-gitaly-static"* ]]; then log "Gitaly StatefulSet converged to corrected state (clean nodeSelector/storageClass)." local _live_sc _live_sc=$(echo "$live_sts_yaml" | grep "storageClassName:" | cut -d: -f2 | xargs || true) @@ -1760,7 +1760,7 @@ check_gitlab_post_apply_blocked() { if [[ "$converged" == "0" ]]; then local live_sts_yaml live_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true) - if [[ -n "$live_sts_yaml" && ( "$live_sts_yaml" == *"gandalf.knoe.org"* || "$live_sts_yaml" == *"gitlab-gitaly-static"* ) ]]; then + if [[ -n "$live_sts_yaml" && ( "$live_sts_yaml" == *"gandalf.prole.org"* || "$live_sts_yaml" == *"gitlab-gitaly-static"* ) ]]; then log "Live StatefulSet still legacy after grace period. Performing explicit replacement..." # We already confirmed desired CR is corrected at the start of this function. kubectl -n "$NAMESPACE" delete statefulset "$gitaly_sts_name" --wait=true 2>/dev/null || true @@ -1912,9 +1912,9 @@ check_gitlab_post_apply_blocked() { cleanup_gitlab_wrong_gitaly_template_storage "${GITALY_STORAGE_CLASS:-}" "$live_gitaly_repo_data_template_sc" "$gitaly_pvc_mismatches" "$desired_cr" fi - if [[ "$gitaly_sts_yaml" == *"gandalf.knoe.org"* ]]; then + if [[ "$gitaly_sts_yaml" == *"gandalf.prole.org"* ]]; then repair_blocked "Gitaly using legacy Synology storage" \ - "Resource: statefulset/${gitaly_sts_name}. Value: nodeSelector contains gandalf.knoe.org. Fix: Ensure GITLAB_STORAGE_NODE is not set in k8s mode." + "Resource: statefulset/${gitaly_sts_name}. Value: nodeSelector contains gandalf.prole.org. Fix: Ensure GITLAB_STORAGE_NODE is not set in k8s mode." fi if [[ "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then repair_blocked "Gitaly using legacy Synology storage" \ @@ -2380,7 +2380,7 @@ REDIS_HOST="${GITLAB_REDIS_HOST:-redis-master.${REDIS_NAMESPACE}.svc.cluster.loc REDIS_PORT="${GITLAB_REDIS_PORT:-6379}" # GitLab public domain (configurable; default follows deployment mode) -_default_gitlab_domain="git.knoe.org" +_default_gitlab_domain="git.prole.org" if [[ "$MODE" == "k8s" ]]; then _default_gitlab_domain="git.knoe.dev" fi @@ -2418,7 +2418,7 @@ PRIMARY_GITLAB_HOST="${GITLAB_PUBLIC_HOSTS[0]}" GITLAB_DOMAIN="$PRIMARY_GITLAB_HOST" PRIMARY_GITLAB_DOMAIN_ROOT="${PRIMARY_GITLAB_HOST#*.}" if [[ "$PRIMARY_GITLAB_DOMAIN_ROOT" == "$PRIMARY_GITLAB_HOST" || -z "$PRIMARY_GITLAB_DOMAIN_ROOT" ]]; then - PRIMARY_GITLAB_DOMAIN_ROOT="knoe.org" + PRIMARY_GITLAB_DOMAIN_ROOT="prole.org" fi # Public ingress class (k8s/GKE defaults to gce; local clusters keep kong) @@ -2513,7 +2513,7 @@ fi # Requires k8s secret 'gitlab-google-oidc' in GITLAB_NAMESPACE with Google # OAuth2 client credentials (client_id, client_secret, redirect_uri). # To disable OIDC: unset FRONTDOOR_HOST before running. -_default_auth_hostname="api.knoe.org" +_default_auth_hostname="api.prole.org" if [[ "$MODE" == "k8s" ]]; then _default_auth_hostname="api.knoe.dev" fi @@ -2891,7 +2891,7 @@ setup_knoe_db_for_gitlab() { } # --------------------------------------------------------------------------- -# Clean up any pre-existing Gitea / git.knoe.org configurations +# Clean up any pre-existing Gitea / git.prole.org configurations # --------------------------------------------------------------------------- cleanup_gitea() { log "Checking for pre-existing Gitea deployment to remove before GitLab install..." @@ -2908,13 +2908,13 @@ cleanup_gitea() { kubectl -n "$gitea_ns" delete deploy/gitea svc/gitea-http svc/gitea-ssh \ >/dev/null 2>&1 || true - # Remove Kong routes/services registered for git.knoe.org (best-effort) + # Remove Kong routes/services registered for git.prole.org (best-effort) local kong_ns="${KONG_NAMESPACE:-${NAMESPACE:-kong}}" for res_type in kongplugins kongingresses; do kubectl -n "$gitea_ns" delete "$res_type" --all >/dev/null 2>&1 || true done - # Remove gitea namespace Ingress objects that route git.knoe.org + # Remove gitea namespace Ingress objects that route git.prole.org kubectl -n "$gitea_ns" delete ingress \ -l "app.kubernetes.io/name=gitea" >/dev/null 2>&1 || true kubectl -n "$gitea_ns" delete ingress \ @@ -2945,7 +2945,7 @@ fi # --------------------------------------------------------------------------- # Always remove gitea if its helm release exists (non-destructive path) -# gitea and gitlab both claim git.knoe.org; they cannot coexist. +# gitea and gitlab both claim git.prole.org; they cannot coexist. # --------------------------------------------------------------------------- if helm -n "${GITEA_NAMESPACE:-gitea}" status gitea >/dev/null 2>&1; then warn "Gitea release detected — removing to free git.knoe.org for GitLab..." @@ -3070,7 +3070,7 @@ fi if [[ "$MODE" == "k8s" ]]; then # In k8s/GKE mode, we do NOT use static storage nodes or nodeSelectors. STORAGE_NODE="" - if [[ "$NODE_SELECTOR" == *"gandalf.knoe.org"* ]]; then + if [[ "$NODE_SELECTOR" == *"gandalf.prole.org"* ]]; then warn "Stripping legacy nodeSelector '${NODE_SELECTOR}' in k8s mode." NODE_SELECTOR="" fi @@ -3719,7 +3719,7 @@ setup_gitlab_storage() { local gitaly_sts_yaml gitaly_sts_yaml=$(kubectl -n "$NAMESPACE" get statefulset "$gitaly_sts_name" -o yaml 2>/dev/null || true) if [[ -n "$gitaly_sts_yaml" ]]; then - if [[ "$gitaly_sts_yaml" == *"gandalf.knoe.org"* || "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then + if [[ "$gitaly_sts_yaml" == *"gandalf.prole.org"* || "$gitaly_sts_yaml" == *"gitlab-gitaly-static"* ]]; then has_legacy=1 fi fi @@ -3743,7 +3743,7 @@ setup_gitlab_storage() { # Give it a moment to process deletions sleep 2 else - repair_blocked "Legacy Gitaly PV/PVC still bound to gitlab-gitaly-static / gandalf.knoe.org" \ + repair_blocked "Legacy Gitaly PV/PVC still bound to gitlab-gitaly-static / gandalf.prole.org" \ "Set GITLAB_REPAIR_BLOCKED_AUTOCLEAN=1 to automatically repair by deleting legacy PVC/PV, or run: kubectl -n $NAMESPACE scale sts ${GITLAB_RELEASE}-gitaly --replicas=0 kubectl -n $NAMESPACE delete pvc repo-data-gitlab-gitaly-0 diff --git a/etc/init_kong.sh b/etc/init_kong.sh index 1924677..c4138fe 100755 --- a/etc/init_kong.sh +++ b/etc/init_kong.sh @@ -137,9 +137,9 @@ if [[ "${KNOE_MODE:-}" == "k8s" ]]; then else KONG_NAME="${KONG_NAME:-knoe-svc-kong}" KONG_CONFIG_NAME="${KONG_CONFIG_NAME:-knoe-svc-kong-config}" - SERVICE_HOSTNAME="${SERVICE_HOSTNAME:-svc.knoe.org}" - AUTH_HOSTNAME="${AUTH_HOSTNAME:-api.knoe.org}" - GITEA_HOSTNAME="${GITEA_HOSTNAME:-${GITEA_DOMAIN:-git.knoe.org}}" + SERVICE_HOSTNAME="${SERVICE_HOSTNAME:-svc.prole.org}" + AUTH_HOSTNAME="${AUTH_HOSTNAME:-api.prole.org}" + GITEA_HOSTNAME="${GITEA_HOSTNAME:-${GITEA_DOMAIN:-git.prole.org}}" SERVICE_INGRESS_TLS_ENABLED="${SERVICE_INGRESS_TLS_ENABLED:-1}" fi KONG_PROXY_PORT="${KONG_PROXY_PORT:-8000}" diff --git a/etc/init_monitoring.sh b/etc/init_monitoring.sh index 789d051..f80de7e 100755 --- a/etc/init_monitoring.sh +++ b/etc/init_monitoring.sh @@ -198,7 +198,7 @@ validate_monitoring_storage_classes() { } monitoring_nodes_available() { - kubectl get nodes -l "knoe.org/node-role=general" -o name 2>/dev/null | grep -q . + kubectl get nodes -l "prole.org/node-role=general" -o name 2>/dev/null | grep -q . } resolve_monitoring_primary_node() { @@ -207,11 +207,11 @@ resolve_monitoring_primary_node() { printf '%s' "${MONITORING_PRIMARY_NODE}" return 0 fi - if kubectl get node merlin.knoe.org >/dev/null 2>&1; then - printf '%s' "merlin.knoe.org" + if kubectl get node merlin.prole.org >/dev/null 2>&1; then + printf '%s' "merlin.prole.org" return 0 fi - node=$(kubectl get nodes -l "knoe.org/node-role=general" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) + node=$(kubectl get nodes -l "prole.org/node-role=general" -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [[ -n "${node:-}" ]]; then printf '%s' "$node" return 0 @@ -461,7 +461,7 @@ render_node_selector() { local indent="$1" cat < Date: Mon, 27 Apr 2026 14:08:38 -0700 Subject: [PATCH 10/12] chore(k3s): script and hostprobe updates, temp maintenance scripts Co-Authored-By: Claude Sonnet 4.6 --- etc/hostprobe-myrddin.yaml | 2 +- etc/hostprobe-pi.yaml | 2 +- etc/hosts.txt | 30 ++++++++++++++-------------- k3s/create_k3d_join_remote_agents.sh | 2 +- k3s/install_k3s_first_server.sh | 2 +- k3s/kube_config.sh | 4 ++-- k3s/prole-resources.yaml | 4 ++-- tmp/create_synology_dirs.sh | 4 ++-- tmp/fix_pv_and_buckets_v3.sh | 4 ++-- tmp/fix_pvs.sh | 4 ++-- tmp/sim_installer_ns.sh | 4 ++-- 11 files changed, 31 insertions(+), 31 deletions(-) diff --git a/etc/hostprobe-myrddin.yaml b/etc/hostprobe-myrddin.yaml index fb41747..2522f7b 100644 --- a/etc/hostprobe-myrddin.yaml +++ b/etc/hostprobe-myrddin.yaml @@ -4,7 +4,7 @@ metadata: name: knoe-hostprobe-myrddin namespace: kube-system spec: - nodeName: myrddin.knoe.org + nodeName: myrddin.prole.org hostNetwork: true hostPID: true tolerations: diff --git a/etc/hostprobe-pi.yaml b/etc/hostprobe-pi.yaml index 058e6c6..b10fe89 100644 --- a/etc/hostprobe-pi.yaml +++ b/etc/hostprobe-pi.yaml @@ -4,7 +4,7 @@ metadata: name: knoe-hostprobe-pi namespace: kube-system spec: - nodeName: pi.knoe.org + nodeName: pi.prole.org hostNetwork: true hostPID: true tolerations: diff --git a/etc/hosts.txt b/etc/hosts.txt index 905ae78..4e73115 100644 --- a/etc/hosts.txt +++ b/etc/hosts.txt @@ -1,15 +1,15 @@ -myrddin.knoe.org 10.0.0.3 -raspberry.knoe.org 10.0.0.4 -pi.knoe.org 10.0.0.5 -synology.knoe.org 10.0.0.203 -morgoth.knoe.org 10.0.0.204 -zinfandel.knoe.org 10.0.0.205 -aventage.knoe.org 10.0.0.206 -retropie.knoe.org 10.0.0.207 -fairyland.knoe.org 10.0.0.208 -k8s.knoe.org zinfandel.knoe.org -mc.knoe.org 73.15.20.166 -morana.knoe.org 10.0.0.66 -ollama.knoe.org 73.15.20.166 -svc.knoe.org 73.15.20.166 -www.knoe.org ghs.googlehosted.com \ No newline at end of file +myrddin.prole.org 10.0.0.3 +raspberry.prole.org 10.0.0.4 +pi.prole.org 10.0.0.5 +synology.prole.org 10.0.0.203 +morgoth.prole.org 10.0.0.204 +zinfandel.prole.org 10.0.0.205 +aventage.prole.org 10.0.0.206 +retropie.prole.org 10.0.0.207 +fairyland.prole.org 10.0.0.208 +k8s.prole.org zinfandel.prole.org +mc.prole.org 73.15.20.166 +morana.prole.org 10.0.0.66 +ollama.prole.org 73.15.20.166 +svc.prole.org 73.15.20.166 +www.prole.org ghs.googlehosted.com \ No newline at end of file diff --git a/k3s/create_k3d_join_remote_agents.sh b/k3s/create_k3d_join_remote_agents.sh index cabf8ea..9b65c73 100755 --- a/k3s/create_k3d_join_remote_agents.sh +++ b/k3s/create_k3d_join_remote_agents.sh @@ -11,7 +11,7 @@ set -euo pipefail : "${K3D_REGISTRY_CONFIG:=}" # optional path to registries.yaml # Remote server details -: "${K3S_REMOTE_SERVER_HOST:=raspberry.knoe.org}" +: "${K3S_REMOTE_SERVER_HOST:=raspberry.prole.org}" : "${K3S_REMOTE_SERVER_PORT:=6443}" : "${K3S_REMOTE_TOKEN:=}" # required diff --git a/k3s/install_k3s_first_server.sh b/k3s/install_k3s_first_server.sh index 098ddc7..9994826 100644 --- a/k3s/install_k3s_first_server.sh +++ b/k3s/install_k3s_first_server.sh @@ -10,7 +10,7 @@ set -euo pipefail # Configurable via env vars; provide sensible defaults -: "${K3S_FIRST_SERVER_HOST:=raspberry.knoe.org}" +: "${K3S_FIRST_SERVER_HOST:=raspberry.prole.org}" : "${K3S_FIRST_SERVER_USER:=pi}" : "${K3S_SSH_KEY:=$HOME/.ssh/id_rsa}" : "${K3S_VERSION:=}" diff --git a/k3s/kube_config.sh b/k3s/kube_config.sh index b69bfda..ce68f64 100644 --- a/k3s/kube_config.sh +++ b/k3s/kube_config.sh @@ -10,8 +10,8 @@ fi set -euo pipefail -SERVER=${K3S_SERVER:-"https://myrddin.knoe.org:6443"} -REMOTE_HOST=${K3S_REMOTE_HOST:-"myrddin.knoe.org"} +SERVER=${K3S_SERVER:-"https://myrddin.prole.org:6443"} +REMOTE_HOST=${K3S_REMOTE_HOST:-"myrddin.prole.org"} REMOTE_USER=${K3S_REMOTE_USER:-"ansible"} SSH_KEY=${K3S_SSH_KEY:-"$HOME/.ssh/id_ed25519_ansible"} REMOTE_KUBECONFIG=${K3S_REMOTE_KUBECONFIG:-"/etc/rancher/k3s/k3s.yaml"} diff --git a/k3s/prole-resources.yaml b/k3s/prole-resources.yaml index 3f03046..9f5b5f8 100644 --- a/k3s/prole-resources.yaml +++ b/k3s/prole-resources.yaml @@ -7,7 +7,7 @@ data: kube_config.sh: | #!/bin/bash # k3s/kube_config.sh - Automatically configure knoe-k3s context - SERVER="https://myrddin.knoe.org:6443" + SERVER="https://myrddin.prole.org:6443" CLUSTER="knoe-k3s" USER="knoe-k3s" CONTEXT="knoe-k3s" @@ -109,7 +109,7 @@ metadata: kubernetes.io/ingress.class: traefik spec: rules: - - host: myrddin.knoe.org + - host: myrddin.prole.org http: paths: - path: /k3s/kube_config.sh diff --git a/tmp/create_synology_dirs.sh b/tmp/create_synology_dirs.sh index fed6411..978dece 100644 --- a/tmp/create_synology_dirs.sh +++ b/tmp/create_synology_dirs.sh @@ -2,14 +2,14 @@ set -euo pipefail export KUBECONFIG=/Users/chrisfu/dev/knoe/knoe-k3s.kubeconfig -echo "==> Creating /synology/d005/gitlab/{minio,gitaly} on gandalf.knoe.org..." +echo "==> Creating /synology/d005/gitlab/{minio,gitaly} on gandalf.prole.org..." kubectl -n gitlab run synology-mkdir \ --image=alpine:latest \ --restart=Never \ --rm --attach \ --overrides='{ "spec":{ - "nodeName":"gandalf.knoe.org", + "nodeName":"gandalf.prole.org", "tolerations":[{"operator":"Exists"}], "volumes":[{"name":"synology","hostPath":{"path":"/synology/d005","type":"Directory"}}], "containers":[{ diff --git a/tmp/fix_pv_and_buckets_v3.sh b/tmp/fix_pv_and_buckets_v3.sh index b1b452e..4c54ad1 100644 --- a/tmp/fix_pv_and_buckets_v3.sh +++ b/tmp/fix_pv_and_buckets_v3.sh @@ -26,7 +26,7 @@ spec: - matchExpressions: - key: kubernetes.io/hostname operator: In - values: [gandalf.knoe.org] + values: [gandalf.prole.org] PVYAML echo "==> Waiting 20s for gitaly PVC to bind..." @@ -42,7 +42,7 @@ kubectl -n gitea run minio-init \ --image=quay.io/minio/mc:RELEASE.2022-10-20T23-30-35Z \ --restart=Never \ --overrides="{ - \"spec\":{\"nodeName\":\"gandalf.knoe.org\",\"tolerations\":[{\"operator\":\"Exists\"}], + \"spec\":{\"nodeName\":\"gandalf.prole.org\",\"tolerations\":[{\"operator\":\"Exists\"}], \"containers\":[{\"name\":\"minio-init\", \"image\":\"quay.io/minio/mc:RELEASE.2022-10-20T23-30-35Z\", \"command\":[\"sh\",\"-c\", diff --git a/tmp/fix_pvs.sh b/tmp/fix_pvs.sh index 958f8b3..3d82e2b 100644 --- a/tmp/fix_pvs.sh +++ b/tmp/fix_pvs.sh @@ -30,7 +30,7 @@ spec: - matchExpressions: - key: kubernetes.io/hostname operator: In - values: [gandalf.knoe.org] + values: [gandalf.prole.org] --- apiVersion: v1 kind: PersistentVolume @@ -50,7 +50,7 @@ spec: - matchExpressions: - key: kubernetes.io/hostname operator: In - values: [gandalf.knoe.org] + values: [gandalf.prole.org] PVYAML echo "==> Waiting 15s for binding..." diff --git a/tmp/sim_installer_ns.sh b/tmp/sim_installer_ns.sh index ff57f07..9684ddb 100644 --- a/tmp/sim_installer_ns.sh +++ b/tmp/sim_installer_ns.sh @@ -25,8 +25,8 @@ export CLUSTER_NAME="knoe-db" # Replicate milestones.py GitOpsMilestone.execute() lines 965-977 export GITLAB_NAMESPACE="gitlab" # line 971: env["GITLAB_NAMESPACE"] = ns (ns="gitlab") -export GITLAB_NODE_SELECTOR="gandalf.knoe.org" # line 976 -export NODE_SELECTOR="gandalf.knoe.org" # line 977 +export GITLAB_NODE_SELECTOR="gandalf.prole.org" # line 976 +export NODE_SELECTOR="gandalf.prole.org" # line 977 echo " Pre-call env: NAMESPACE='$NAMESPACE' GITLAB_NAMESPACE='$GITLAB_NAMESPACE'" echo "" From 7487ed68a34fd0936102caaff75a4902f62882fb Mon Sep 17 00:00:00 2001 From: chrisfu Date: Mon, 27 Apr 2026 14:08:38 -0700 Subject: [PATCH 11/12] docs: update README; IntelliJ run config picks up Python 3.14 SDK pytest_all.xml updated by IntelliJ to bind SDK name 'Python 3.14 (knoe-db)' and add PYTHONUNBUFFERED=1. Co-Authored-By: Claude Sonnet 4.6 --- .idea/runConfigurations/pytest_all.xml | 7 ++++++- README.md | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.idea/runConfigurations/pytest_all.xml b/.idea/runConfigurations/pytest_all.xml index 7da729f..6bf0f13 100644 --- a/.idea/runConfigurations/pytest_all.xml +++ b/.idea/runConfigurations/pytest_all.xml @@ -1,17 +1,22 @@ + - + \ No newline at end of file diff --git a/README.md b/README.md index 6b65c2a..4bd45f3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
-
+
 # ###########################
 # ╭──────────────────────╮ #
 # │               _      │ #

From 5ba9b63e34b75bcae244b5e042af8bf15f89e82c Mon Sep 17 00:00:00 2001
From: chrisfu 
Date: Mon, 27 Apr 2026 14:09:46 -0700
Subject: [PATCH 12/12] docs(pipeline): Phase 0 commit summary complete; Phase
 1 first task noted

Co-Authored-By: Claude Sonnet 4.6 
---
 docs/pipeline-phases.md | 24 +++++++++++++++++++++---
 1 file changed, 21 insertions(+), 3 deletions(-)

diff --git a/docs/pipeline-phases.md b/docs/pipeline-phases.md
index 39d491d..f5d99a0 100644
--- a/docs/pipeline-phases.md
+++ b/docs/pipeline-phases.md
@@ -25,7 +25,7 @@ The project has **four deployment modes** that must each produce a stable, repro
 ## Phase 0 — Foundation
 
 **Status:** ✅ Complete  
-**Branch / PRs:** *(fill in after merge)*
+**Branch / PRs:** landed directly on `main`; pushed to `origin` (`git-ssh.knoe.dev:knoe.dev/knoe-db`)
 
 ### What this phase does
 
@@ -46,7 +46,24 @@ Fixes the broken test infrastructure so IntelliJ can discover tests and `make te
 
 ### Commit Summary
 
+```
+97575b9 docs: update Phase 0 commit summary and resumption checklist
 0052a4d Phase 0: test pipeline foundation — pyproject.toml, IntelliJ run configs, coverage fix, welcome mode selector
+```
+
+The following commits landed on the same branch as part of the Cowork+Code thread consolidation (WIP that had accumulated alongside Phase 0):
+
+```
+837da27 docs: update README; IntelliJ run config picks up Python 3.14 SDK
+52fe440 chore(k3s): script and hostprobe updates, temp maintenance scripts
+dd8c9d1 chore(scripts): init script updates — gitea, gitlab, kong, monitoring, registry
+a754659 chore(deploy): cluster config and k3s manifest updates
+4995c86 chore(build): Maven version and authority module updates
+ca469aa chore(installer): core Python updates — env, milestones, monitoring
+5cd9c12 feat(auth): init scripts and k3s/k8s auth manifests for knoe-auth
+2a80df8 feat(auth): land Round 1 — invite-OTP enrollment, kadmin client, GKE manifests
+6c72c76 docs(plans): add platform architecture plans — deployment-modes, knoe-auth round 1
+```
 
 ### Known pre-existing issues (fix in Phase 1, not Phase 0)
 
@@ -70,8 +87,9 @@ Before picking up work on Phase 1, verify:
 
 ## Phase 1 — `min` Mode Pipeline
 
-**Status:** 🔲 Not started  
-**Depends on:** Phase 0 complete
+**Status:** 🔲 Not started — ready to begin  
+**Depends on:** Phase 0 complete ✅  
+**First task:** fix `knoe/core/ops/cloudnative_pg.py:1372` f-string syntax (30 collection errors, see Phase 0 known issues)
 
 ### What this phase does