From 187e49dcaf7610ba9856854fd43e7011045e7973 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 08:24:22 +0200 Subject: [PATCH 01/18] docs: plan the remaining work as phases 7 to 9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 covers CI, release automation and packaging the three components that had no container image. Phase 8 covers the Kustomize base, metrics and the k3s end-to-end run. Phase 9 works through the five points the design spec itself lists as open in §15. The identity broker task carries the requirement that per-tenant roles must look identical whether a user signs in through the tenant's own federated IdP or through a local broker account, and that roles are never taken from a federated assertion. --- .../2026-08-12-phase-7-ci-und-auslieferung.md | 1026 ++++++++++++++ ...12-phase-8-deployment-und-observability.md | 1212 +++++++++++++++++ .../2026-08-12-phase-9-produktionshaerte.md | 782 +++++++++++ 3 files changed, 3020 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md create mode 100644 docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md create mode 100644 docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md diff --git a/docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md b/docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md new file mode 100644 index 0000000..115815f --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md @@ -0,0 +1,1026 @@ +# Apus Phase 7 — CI und Auslieferung: Implementierungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Jeder Commit wird automatisch gebaut und getestet, jede Komponente lässt sich als Container-Image oder Maven-Artefakt ausliefern, und Versionen entstehen aus Conventional Commits statt von Hand. + +**Architecture:** Apus konsumiert die zentralen wiederverwendbaren Workflows aus `OneLiteFeatherNET/workflows` statt eigene CI zu schreiben. Release Please verwaltet Versionen und Changelogs; `telemetry-addon` und `paper-worldpush` bekommen eigene Release-Spuren, weil sie an fremden Versionen (BlueMap- bzw. Paper-API) hängen — so vorgesehen in der Design-Spec §4. Die drei bisher nicht paketierten Komponenten (`operator`, `api`, `ui`) bekommen Dockerfiles im Stil der bestehenden (`runner`, `ingest`, `hosting`): Multi-Stage, non-root uid 10001, Build-Kontext ist das Repository-Root. + +**Tech Stack:** GitHub Actions (`OneLiteFeatherNET/workflows@v2.4.0`), Release Please (`googleapis/release-please-action@v5`), Renovate (zentrales OLF-Preset), Docker (Harbor-Registry), Gradle 9 mit Inline-Version-Catalog, Java 25 (Temurin). + +## Global Constraints + +- **Java-Toolchain 25 (Temurin)** in jedem Workflow — das ist der Default der zentralen Workflows; nicht überschreiben. +- **Wiederverwendbare Workflows werden auf den vollen SemVer-Tag gepinnt** — `@v2.4.0`, niemals `@main` und niemals `@v2`. +- **Kein `clean` in Gradle-Tasks der CI** — das entwertet den `setup-gradle`-Cache. +- **Der Versionsmarker lebt in `build.gradle.kts`, nicht in `gradle.properties`.** Aktuell steht `version = 999.0.0` in `gradle.properties`; dieser Eintrag wird ersatzlos entfernt. +- **Dockerfiles bauen aus dem Repository-Root als Kontext** und kopieren mit modulqualifiziertem Pfad (`COPY operator/... `), genau wie `runner/Dockerfile` und `ingest/Dockerfile` es tun. +- **Non-root in jedem Image:** Benutzer `apus`, uid 10001, Arbeitsverzeichnis unterhalb `/work` bzw. `/app`. +- **Jar-Dateinamen sind fest** (kein Glob im `COPY`), Konvention wie `ingest`: `archiveFileName.set("apus-.jar")`. +- **AGPL-Lizenzheader** über jede neue Java-Datei — Spotless erzwingt das via `.spotless/Copyright.java`. +- **Integrationstests laufen nicht im PR-Build.** `operator`, `runner` und `ingest` schließen `**/*IntegrationTest.class` aus `test` aus und tragen einen separaten `integrationTest`-Task; das bleibt so, weil diese Tests Docker und teilweise k3s brauchen. + +--- + +## Vorbedingung (einmalig, außerhalb des Repos) + +Das Renovate-Preset verlangt ein GitHub-Team als Reviewer. Ein Team `apus-maintainers` existiert in der Organisation **nicht** (geprüft am 2026-08-12 über `gh api orgs/OneLiteFeatherNET/teams`). Vor Task 2 ist entweder das Team anzulegen: + +```bash +gh api -X POST orgs/OneLiteFeatherNET/teams -f name='apus-maintainers' -f privacy='closed' +gh api -X PUT orgs/OneLiteFeatherNET/teams/apus-maintainers/repos/OneLiteFeatherNET/Apus -f permission='push' +``` + +oder in Task 2 stattdessen ein bestehendes Team einzusetzen — `infrastructure-core-team` ist der naheliegende Kandidat, da Apus Infrastruktur ist. Diese Entscheidung ist die einzige im gesamten Plan, die nicht aus dem Repository ableitbar ist. + +--- + +### Task 1: Root-README + +Das Repository hat keinen Einstiegspunkt. Modul-READMEs existieren für `runner`, `hosting`, `ingest`, `ui` und `testdata`, aber wer das Repository öffnet, findet keine Orientierung. + +**Files:** +- Create: `README.md` + +- [ ] **Schritt 1: README schreiben** + +Inhalt (vollständig, nicht kürzen): + +```markdown +# Apus + +Apus rendert Minecraft-Welten mit [BlueMap](https://bluemap.bluecolored.de/) auf Kubernetes +und hostet die Ergebnisse. Welt-Daten kommen aus mehreren, sehr unterschiedlichen Quellen; +ein ETL-Layer normalisiert sie, ein Operator führt Render- und Hosting-Jobs aus, eine +Oberfläche zeigt Fortschritt und erlaubt Bedienung ohne YAML. + +Das vollständige Design steht in +[`docs/superpowers/specs/2026-08-08-apus-design.md`](docs/superpowers/specs/2026-08-08-apus-design.md). + +## Module + +| Modul | Zweck | Auslieferung | +|---|---|---| +| `telemetry-addon` | BlueMap-Addon, exponiert Render-Fortschritt als JSON und Prometheus-Metriken | Maven | +| `ingest` | ETL: Connectoren (s3, pterodactyl, push, upload), Layout-Erkennung, Bundle-Writer | Container-Image | +| `runner` | BlueMap-CLI plus beide Addons, rendert eine Welt aus S3 nach S3 | Container-Image | +| `hosting` | Langlebiger BlueMap-Webserver, liest gerenderte Karten aus S3 | Container-Image | +| `operator` | Kubernetes-Operator, sechs CRDs, erzeugt Jobs/Deployments/Ingresses/Buckets | Container-Image | +| `api` | Micronaut-REST/SSE über den Custom Resources, Durchsetzungspunkt für Auth | Container-Image | +| `ui` | Nuxt-4-Dashboard für Mandanten und Plattform-Betreiber | Container-Image | +| `paper-worldpush` | Paper-Plugin, schiebt Welten vom laufenden Server nach Apus | Maven | + +## Bauen + +Voraussetzungen: JDK 25, Docker (für Integrationstests), pnpm (für `ui`). + + ./gradlew build # alle Java-Module, ohne Integrationstests + ./gradlew integrationTest # braucht Docker + ./gradlew :operator:generateCrds # erzeugt die sechs CRD-YAMLs nach operator/build/crds + + cd ui && pnpm install && pnpm test && pnpm lint + +## Entwicklung + +Der Kern des Systems ist das **World Bundle** — eine unveränderliche, normalisierte +Momentaufnahme einer Welt in S3. Links davon (Ingest) weiß niemand etwas von BlueMap, +rechts davon (Render, Hosting) niemand etwas von Pterodactyl oder ZIP-Uploads. Wer eine +neue Welt-Quelle anbindet, implementiert nur `WorldSourceConnector` in `ingest`. + +Commits folgen [Conventional Commits](https://www.conventionalcommits.org/) — Release +Please leitet daraus Version und Changelog ab. + +## Lizenz + +AGPL-3.0, siehe [LICENSE](LICENSE). +``` + +- [ ] **Schritt 2: Verifizieren, dass alle Links auflösen** + +Run: `ls docs/superpowers/specs/2026-08-08-apus-design.md LICENSE` +Expected: Beide Pfade existieren. + +- [ ] **Schritt 3: Commit** + +```bash +git add README.md +git commit -m "docs: add a root README with module overview and build instructions" +``` + +--- + +### Task 2: Renovate + +**Files:** +- Create: `renovate.json` + +**Interfaces:** +- Produces: Die Datei, über die Renovate ab jetzt auch die Workflow-Pins aus Task 4/5/9 aktualisiert. + +- [ ] **Schritt 1: `renovate.json` anlegen** + +```json +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>OneLiteFeatherNET/renovate:default(OneLiteFeatherNET/apus-maintainers)", + "github>OneLiteFeatherNET/renovate:paper" + ] +} +``` + +Das `:paper`-Flavour ist nötig, weil `paper-worldpush` gegen `io.papermc.paper:paper-api` baut, dessen Versionsschema `X.Y.Z-` von der SemVer-Standardauswertung falsch interpretiert wird. Ein `:minestom`-Flavour braucht Apus nicht. + +Keine eigenen `packageRules` — Patch-Automerge, Reviewer, Zeitzone, Office-Hours-Schedule, Semantic Commits, das `renovate`-Label und Vulnerability Alerts bringt das Preset bereits mit. + +- [ ] **Schritt 2: Team-Slug verifizieren** + +Run: `gh api orgs/OneLiteFeatherNET/teams --paginate -q '.[].slug' | grep -x apus-maintainers` +Expected: Ausgabe `apus-maintainers`. Schlägt das fehl, ist die Vorbedingung oben nicht erfüllt — entweder Team anlegen oder den Slug in `renovate.json` auf `infrastructure-core-team` ändern. + +- [ ] **Schritt 3: JSON validieren** + +Run: `python3 -c "import json;json.load(open('renovate.json'));print('ok')"` +Expected: `ok` + +- [ ] **Schritt 4: Commit** + +```bash +git add renovate.json +git commit -m "ci: adopt the central OneLiteFeather Renovate preset" +``` + +--- + +### Task 3: Versionsmarker und Release Please + +`gradle.properties` trägt heute `version = 999.0.0` — ein Platzhalter ohne Automatik dahinter. Release Please verlangt den Marker im jeweiligen `build.gradle.kts`. Apus bekommt drei Release-Spuren: das Gesamtprojekt (dessen Version die Container-Images tragen), `telemetry-addon` und `paper-worldpush`. + +**Files:** +- Modify: `gradle.properties` (Zeile `version = 999.0.0` entfernen) +- Modify: `build.gradle.kts` (Versionsmarker und Weitergabe an Subprojekte) +- Modify: `telemetry-addon/build.gradle.kts` (eigener Marker) +- Modify: `paper-worldpush/build.gradle.kts` (eigener Marker) +- Create: `release-please-config.json` +- Create: `.release-please-manifest.json` +- Create: `CHANGELOG.md` +- Create: `.github/workflows/release-please.yml` + +**Interfaces:** +- Produces: Die Workflow-Outputs `.--release_created`, `.--version`, `telemetry-addon--release_created`, `paper-worldpush--release_created`. Task 9 und Task 10 hängen sich daran. + +- [ ] **Schritt 1: Aktuellen Zustand festhalten** + +```bash +grep -n version gradle.properties +git rev-parse HEAD +``` + +Der ausgegebene SHA ist der `bootstrap-sha` für Schritt 4. Notieren. + +- [ ] **Schritt 2: Version aus `gradle.properties` entfernen und in `build.gradle.kts` verlegen** + +`gradle.properties`: die Zeile `version = 999.0.0` ersatzlos löschen. + +`build.gradle.kts` — der `subprojects`-Block erbt die Version bisher implizit über `gradle.properties`; das muss jetzt explizit geschehen: + +```kotlin +plugins { + alias(libs.plugins.spotless) apply false +} + +version = "0.1.0" // x-release-please-version + +subprojects { + apply(plugin = "java") + apply(plugin = "com.diffplug.spotless") + + // telemetry-addon and paper-worldpush carry their own release track (design spec §4) + // and set their own version; every other module ships as part of the project as a whole. + if (name != "telemetry-addon" && name != "paper-worldpush") { + version = rootProject.version + } + // ... bestehender Inhalt unverändert ... +} +``` + +- [ ] **Schritt 3: Eigene Marker in den beiden Modulen mit eigener Release-Spur** + +In `telemetry-addon/build.gradle.kts` als erste Zeile nach dem `plugins`-Block: + +```kotlin +version = "0.1.0" // x-release-please-version +``` + +Dasselbe in `paper-worldpush/build.gradle.kts`. + +- [ ] **Schritt 4: `release-please-config.json` anlegen** + +`` durch den SHA aus Schritt 1 ersetzen. + +```json +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": true, + "include-v-in-tag": true, + "separate-pull-requests": true, + "bootstrap-sha": "", + "pull-request-header": "", + "packages": { + ".": { + "package-name": "apus", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "build.gradle.kts" } + ] + }, + "telemetry-addon": { + "package-name": "telemetry-addon", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "telemetry-addon/build.gradle.kts" } + ] + }, + "paper-worldpush": { + "package-name": "paper-worldpush", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "paper-worldpush/build.gradle.kts" } + ] + } + } +} +``` + +`include-component-in-tag: true` ist bei mehreren Paketen zwingend, sonst kollidieren zwei Module auf demselben Git-Tag. `separate-pull-requests: true`, weil die drei Spuren unabhängig releasen sollen. + +- [ ] **Schritt 5: Manifest und Changelog anlegen** + +`.release-please-manifest.json`: + +```json +{ + ".": "0.1.0", + "telemetry-addon": "0.1.0", + "paper-worldpush": "0.1.0" +} +``` + +`CHANGELOG.md`: leere Datei anlegen (`: > CHANGELOG.md`). Release Please füllt sie; niemals von Hand editieren. + +- [ ] **Schritt 6: Workflow anlegen** + +`.github/workflows/release-please.yml`: + +```yaml +name: release-please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + root-released: ${{ steps.release.outputs['.--release_created'] }} + root-version: ${{ steps.release.outputs['.--version'] }} + telemetry-released: ${{ steps.release.outputs['telemetry-addon--release_created'] }} + paper-released: ${{ steps.release.outputs['paper-worldpush--release_created'] }} + steps: + - id: release + uses: googleapis/release-please-action@v5 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json +``` + +Die Publish-Jobs kommen in Task 9 (Images) und Task 10 (Maven) hinzu. Kein zusätzlicher `on: push: tags:`-Workflow — Release Please taggt mit dem Standard-`GITHUB_TOKEN` und löst damit keine Tag-Push-Workflows aus; zwei Publish-Pfade wären entweder tot oder würden sich auf demselben Tag ins Gehege kommen. + +- [ ] **Schritt 7: Verifizieren, dass Gradle die Version weiterhin auflöst** + +Run: `./gradlew :ingest:properties --property version && ./gradlew :telemetry-addon:properties --property version` +Expected: beide geben `version: 0.1.0` aus — die erste über `rootProject.version`, die zweite über den eigenen Marker. + +- [ ] **Schritt 8: Verifizieren, dass der Build unverändert durchläuft** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL. Der Jar-Dateiname von `ingest` ist versionsunabhängig festgelegt (`apus-ingest.jar`), die Umstellung darf daran nichts ändern — gegenprüfen mit `ls ingest/build/libs/`. + +- [ ] **Schritt 9: Commit** + +Der Commit-Typ muss `chore:` sein, damit die Einführung nicht selbst einen Versions-Bump auslöst. + +```bash +git add gradle.properties build.gradle.kts telemetry-addon/build.gradle.kts \ + paper-worldpush/build.gradle.kts release-please-config.json \ + .release-please-manifest.json CHANGELOG.md .github/workflows/release-please.yml +git commit -m "chore: manage versions and changelogs with release-please" +``` + +--- + +### Task 4: PR-Build + +**Files:** +- Create: `.github/workflows/build-pr.yml` + +- [ ] **Schritt 1: Workflow anlegen** + +```yaml +name: build-pr + +on: + pull_request: + branches: [main] + +jobs: + gradle: + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2.4.0 + with: + java-version: "25" + java-distribution: "temurin" + paths-filters: | + code: + - '**/*.java' + - '**/*.kts' + - '**/*.properties' + - 'gradle/**' + - 'gradlew' + - '.spotless/**' + secrets: inherit +``` + +Der Path-Filter-Schlüssel muss `code` heißen — so erwartet ihn der zentrale Workflow. Der `ui`-Teil hängt nicht an Gradle und bekommt einen eigenen Job in Schritt 2. + +- [ ] **Schritt 2: UI-Job im selben Workflow ergänzen** + +Nuxt/pnpm deckt der zentrale Katalog nicht ab; das ist ein legitimer repo-eigener Job: + +```yaml + ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: ui/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test +``` + +- [ ] **Schritt 3: Workflow-Syntax prüfen** + +Run: `python3 -c "import yaml;yaml.safe_load(open('.github/workflows/build-pr.yml'));print('ok')"` +Expected: `ok` + +- [ ] **Schritt 4: Lokal gegenprüfen, dass die Kommandos tatsächlich grün sind** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL + +Run: `cd ui && pnpm install --frozen-lockfile && pnpm lint && pnpm typecheck && pnpm test` +Expected: alle vier ohne Fehler. Schlägt hier etwas fehl, ist das ein echter Befund — dann diesen Task stoppen und den Fehler zuerst beheben, statt eine rote CI einzuchecken. + +- [ ] **Schritt 5: Commit** + +```bash +git add .github/workflows/build-pr.yml +git commit -m "ci: build and test Gradle modules and the UI on pull requests" +``` + +--- + +### Task 5: Markdown-Lint und Fork-PR-Schutz + +Das Repository trägt ungewöhnlich viel Dokumentation (Design-Spec, Pläne, Spike-Berichte, sechs READMEs) mit vielen Querverweisen. Kaputte Links fallen sonst niemandem auf. + +**Files:** +- Create: `.github/workflows/markdown-lint.yml` +- Create: `.github/workflows/close-invalid-prs.yml` +- Create: `.markdownlint-cli2.jsonc` + +- [ ] **Schritt 1: Markdownlint-Konfiguration anlegen** + +```jsonc +{ + "config": { + // The design spec and the plans use long prose lines; wrapping them would make + // diffs unreadable. + "MD013": false, + // Release Please writes the changelog; its heading structure is not ours to police. + "MD024": { "siblings_only": true } + }, + "ignores": [ + "**/node_modules/**", + "**/build/**", + "CHANGELOG.md" + ] +} +``` + +- [ ] **Schritt 2: Workflows anlegen** + +`.github/workflows/markdown-lint.yml`: + +```yaml +name: markdown-lint + +on: + pull_request: + branches: [main] + paths: + - '**/*.md' + +jobs: + lint: + uses: OneLiteFeatherNET/workflows/.github/workflows/markdown-lint.yml@v2.4.0 + secrets: inherit +``` + +`.github/workflows/close-invalid-prs.yml`: + +```yaml +name: close-invalid-prs + +on: + pull_request_target: + types: [opened] + +jobs: + close: + uses: OneLiteFeatherNET/workflows/.github/workflows/close-invalid-prs.yml@v2.4.0 + secrets: inherit +``` + +- [ ] **Schritt 3: Lint lokal ausführen** + +Run: `npx markdownlint-cli2 "**/*.md" "#ui/node_modules" "#**/build"` +Expected: keine Fehler. Treten welche auf, im selben Task beheben — entweder die Datei korrigieren oder, wenn die Regel für dieses Repository unsinnig ist, sie in `.markdownlint-cli2.jsonc` mit Begründung abschalten. + +- [ ] **Schritt 4: Commit** + +```bash +git add .github/workflows/markdown-lint.yml .github/workflows/close-invalid-prs.yml .markdownlint-cli2.jsonc +git commit -m "ci: lint markdown and close pull requests from fork default branches" +``` + +--- + +### Task 6: Container-Image für den Operator + +**Files:** +- Create: `operator/Dockerfile` +- Modify: `operator/build.gradle.kts` (Shadow-Plugin und fester Jar-Name) +- Modify: `settings.gradle.kts` — nur falls `shadow` im Katalog fehlt; er ist bereits als `version("shadow", "9.3.2")` vorhanden, dann entfällt die Änderung + +**Interfaces:** +- Consumes: `application { mainClass.set("net.onelitefeather.apus.operator.ApusOperator") }`, bereits vorhanden in `operator/build.gradle.kts:124`. +- Produces: `operator/build/libs/apus-operator.jar`, das der Dockerfile per festem Namen kopiert. + +- [ ] **Schritt 1: Shadow-Jar konfigurieren** + +In `operator/build.gradle.kts` das Plugin ergänzen (im `plugins`-Block, analog zu `ingest`): + +```kotlin +alias(libs.plugins.shadow) +``` + +und den Task konfigurieren: + +```kotlin +tasks { + shadowJar { + archiveClassifier.set("") + archiveBaseName.set("apus-operator") + // Fixed name instead of the default "apus-operator-.jar": operator/Dockerfile + // COPYs the file by name (no glob), the same convention ingest and telemetry-addon use. + archiveFileName.set("apus-operator.jar") + } + build { + dependsOn(shadowJar) + } +} +``` + +- [ ] **Schritt 2: Jar bauen und prüfen, dass er startfähig ist** + +Run: `./gradlew :operator:shadowJar && ls -la operator/build/libs/apus-operator.jar` +Expected: Datei existiert. + +Run: `unzip -p operator/build/libs/apus-operator.jar META-INF/MANIFEST.MF | grep Main-Class` +Expected: `Main-Class: net.onelitefeather.apus.operator.ApusOperator` + +- [ ] **Schritt 3: Dockerfile schreiben** + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:25-jre-jammy + +# Non-root, same convention as runner/Dockerfile and ingest/Dockerfile. The operator writes +# nothing to the filesystem at all -- it only talks to the Kubernetes API -- so it gets no +# writable directory beyond its home. +RUN useradd --uid 10001 --create-home --home-dir /home/apus apus + +# Built by: ./gradlew :operator:shadowJar +COPY --chown=apus:apus operator/build/libs/apus-operator.jar /opt/apus/operator.jar + +USER apus +WORKDIR /home/apus + +# The operator serves no traffic of its own; 8080 is only the metrics endpoint added in +# phase 8. Declared here so the port contract lives with the image. +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/opt/apus/operator.jar"] +``` + +- [ ] **Schritt 4: Image bauen und starten** + +Run: `docker build -f operator/Dockerfile -t apus-operator:test .` +Expected: erfolgreicher Build. + +Run: `docker run --rm apus-operator:test 2>&1 | head -20` +Expected: Der Operator startet und scheitert erwartbar an der fehlenden Kubernetes-Verbindung — nicht an `ClassNotFoundException` oder `no main manifest attribute`. Genau das unterscheidet ein funktionierendes Fat-Jar von einem kaputten. + +- [ ] **Schritt 5: Commit** + +```bash +git add operator/Dockerfile operator/build.gradle.kts +git commit -m "feat: package the operator as a container image" +``` + +--- + +### Task 7: Container-Image für die API + +**Files:** +- Create: `api/Dockerfile` +- Modify: `api/build.gradle.kts` (Shadow-Plugin und fester Jar-Name) + +**Interfaces:** +- Consumes: `application { mainClass.set("net.onelitefeather.apus.api.Application") }`, vorhanden in `api/build.gradle.kts:113`. +- Produces: `api/build/libs/apus-api.jar`. + +- [ ] **Schritt 1: Shadow-Jar konfigurieren** + +Im `plugins`-Block von `api/build.gradle.kts`: + +```kotlin +alias(libs.plugins.shadow) +``` + +```kotlin +tasks { + shadowJar { + archiveClassifier.set("") + archiveBaseName.set("apus-api") + archiveFileName.set("apus-api.jar") + // Micronaut ships service files (annotation-driven bean definitions, serde config) + // in META-INF/services; without merging them the shadowed jar starts but resolves + // no beans, which surfaces as a confusing "no route matched" at runtime rather + // than a build failure. + mergeServiceFiles() + } + build { + dependsOn(shadowJar) + } +} +``` + +- [ ] **Schritt 2: Jar bauen und Bean-Auflösung verifizieren** + +Run: `./gradlew :api:shadowJar` +Expected: BUILD SUCCESSFUL + +Run: `unzip -l api/build/libs/apus-api.jar | grep -c 'META-INF/services'` +Expected: eine Zahl größer 0 — schlägt das fehl, hat `mergeServiceFiles()` nicht gegriffen und die API würde zur Laufzeit keine Beans finden. + +- [ ] **Schritt 3: Dockerfile schreiben** + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:25-jre-jammy + +RUN useradd --uid 10001 --create-home --home-dir /home/apus apus + +# Built by: ./gradlew :api:shadowJar +COPY --chown=apus:apus api/build/libs/apus-api.jar /opt/apus/api.jar + +USER apus +WORKDIR /home/apus + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/opt/apus/api.jar"] +``` + +- [ ] **Schritt 4: Image bauen und Start prüfen** + +Run: `docker build -f api/Dockerfile -t apus-api:test .` +Expected: erfolgreicher Build. + +Run: `docker run --rm -p 8080:8080 -d --name apus-api-test apus-api:test && sleep 15 && docker logs apus-api-test | tail -20` +Expected: Micronaut-Startzeile (`Startup completed in ...ms`). Die JWT-Validierung braucht einen Issuer und wird beim ersten Request scheitern — der Start selbst muss aber sauber durchlaufen. + +Aufräumen: `docker rm -f apus-api-test` + +- [ ] **Schritt 5: Commit** + +```bash +git add api/Dockerfile api/build.gradle.kts +git commit -m "feat: package the API as a container image" +``` + +--- + +### Task 8: Container-Image für die UI + +Die UI läuft laut Design-Spec §11.2 als SPA (`ssr: false`). Ein statisches Build-Ergebnis, ausgeliefert von nginx, ist damit die passende Form — kein Node-Prozess im Cluster. + +**Files:** +- Create: `ui/Dockerfile` +- Create: `ui/nginx.conf` + +- [ ] **Schritt 1: Verifizieren, dass der SPA-Modus tatsächlich konfiguriert ist** + +Run: `grep -n 'ssr' ui/nuxt.config.ts` +Expected: `ssr: false`. Steht dort etwas anderes, ist dieser Task falsch zugeschnitten — dann statt nginx ein Node-Image mit `node .output/server/index.mjs` bauen und Schritt 2 überspringen. + +- [ ] **Schritt 2: nginx-Konfiguration schreiben** + +```nginx +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + + # Single-page app: every unknown path must fall back to index.html, otherwise a + # browser reload on /tenants/foo returns 404 instead of the app. + location / { + try_files $uri $uri/ /index.html; + } + + # Hashed build assets are immutable; index.html must never be cached, or a deploy + # leaves clients on the previous bundle. + location /_nuxt/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-store"; + } +} +``` + +- [ ] **Schritt 3: Dockerfile schreiben** + +```dockerfile +# syntax=docker/dockerfile:1 + +######################################## +# Stage 1: build the SPA +######################################## +FROM node:22-bookworm-slim AS build + +RUN corepack enable + +WORKDIR /src +COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY ui/ ./ +RUN pnpm generate + +######################################## +# Stage 2: serve it +######################################## +FROM nginxinc/nginx-unprivileged:1.29-alpine + +# The unprivileged nginx image already runs as uid 101; it needs no writable root and +# listens on 8080 rather than 80, which is why it is used instead of the stock image. +COPY ui/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/.output/public /usr/share/nginx/html + +EXPOSE 8080 +``` + +- [ ] **Schritt 4: Image bauen und ausliefern lassen** + +Run: `docker build -f ui/Dockerfile -t apus-ui:test .` +Expected: erfolgreicher Build. + +Run: `docker run --rm -d -p 8081:8080 --name apus-ui-test apus-ui:test && sleep 3 && curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:8081/` +Expected: `200` + +Run: `curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:8081/tenants/does-not-exist` +Expected: `200` — der SPA-Fallback greift. Kommt hier `404`, ist `try_files` falsch verdrahtet. + +Aufräumen: `docker rm -f apus-ui-test` + +- [ ] **Schritt 5: Commit** + +```bash +git add ui/Dockerfile ui/nginx.conf +git commit -m "feat: package the dashboard as a static nginx container image" +``` + +--- + +### Task 9: Images veröffentlichen + +Sechs Images: `runner`, `ingest`, `hosting`, `operator`, `api`, `ui`. Die ersten drei haben ihre Dockerfiles bereits, die letzten drei kommen aus Task 6–8. + +**Files:** +- Modify: `.github/workflows/release-please.yml` (Publish-Jobs anhängen) + +**Interfaces:** +- Consumes: `needs.release-please.outputs.root-released` und `root-version` aus Task 3. + +- [ ] **Schritt 1: Gradle-Job für die Jar-Artefakte ergänzen** + +Drei der sechs Images kopieren Gradle-Ausgaben (`telemetry-addon` für `runner`, `ingest`, `operator`, `api`). Der Build-Kontext muss diese Dateien also enthalten. In `.github/workflows/release-please.yml` nach dem `release-please`-Job: + +```yaml + build-context: + needs: release-please + if: needs.release-please.outputs.root-released == 'true' + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-docker-context.yml@v2.4.0 + with: + java-version: "25" + version: ${{ needs.release-please.outputs.root-version }} + gradle-command: "./gradlew :telemetry-addon:shadowJar :ingest:shadowJar :operator:shadowJar :api:shadowJar" + context-path: "." + artifact-name: "docker-context" + secrets: inherit +``` + +- [ ] **Schritt 2: Die sechs Publish-Jobs ergänzen** + +```yaml + publish-runner: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/runner" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "runner/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-ingest: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/ingest" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "ingest/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-hosting: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/hosting" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "hosting/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-operator: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/operator" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "operator/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-api: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/api" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "api/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-ui: + needs: release-please + if: needs.release-please.outputs.root-released == 'true' + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/ui" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "ui/Dockerfile" + secrets: inherit +``` + +`publish-ui` hängt bewusst **nicht** am `build-context`: Die UI baut sich im Dockerfile selbst aus den Quellen und braucht keine Gradle-Ausgabe. Die anderen fünf teilen sich einen Kontext-Artefakt-Namen — `artifact-name` muss überall exakt `docker-context` lauten, sonst findet der Publish-Job den Upload nicht. + +- [ ] **Schritt 3: Prüfen, dass `dockerfile` ein unterstützter Eingabewert ist** + +Run: `gh api repos/OneLiteFeatherNET/workflows/contents/.github/workflows/docker-publish.yml -q '.content' | base64 -d | grep -A3 -E '^\s+(dockerfile|context|artifact-name):'` +Expected: Die drei Eingaben erscheinen im `workflow_call`-`inputs`-Block. Fehlt `dockerfile`, unterstützt der zentrale Workflow nur den Standardnamen `Dockerfile` im Kontextverzeichnis — dann ist das eine Erweiterung am zentralen Workflow (Vorgehen laut `release-engineering:workflows`, Abschnitt „Introducing a new mechanic"), und dieser Task blockiert, bis die dort ergänzt und getaggt ist. + +- [ ] **Schritt 4: YAML validieren** + +Run: `python3 -c "import yaml;yaml.safe_load(open('.github/workflows/release-please.yml'));print('ok')"` +Expected: `ok` + +- [ ] **Schritt 5: Commit** + +```bash +git add .github/workflows/release-please.yml +git commit -m "ci: publish all six container images on release" +``` + +--- + +### Task 10: Maven-Veröffentlichung für die beiden Bibliotheken + +`telemetry-addon` konsumieren BlueMap-Nutzer, `paper-worldpush` Server-Betreiber. Beide sind ohne Publishing nicht erreichbar. + +**Files:** +- Modify: `telemetry-addon/build.gradle.kts` +- Modify: `paper-worldpush/build.gradle.kts` +- Modify: `.github/workflows/release-please.yml` + +- [ ] **Schritt 1: Publishing in beiden Modulen konfigurieren** + +In beiden `build.gradle.kts` (Modulname jeweils anpassen): + +```kotlin +plugins { + `maven-publish` + // ... vorhandene Plugins ... +} + +publishing { + publications { + create("maven") { + groupId = "net.onelitefeather.apus" + artifactId = "telemetry-addon" // bzw. "paper-worldpush" + // The shadow jar is the artifact consumers need -- the thin jar would leave + // them to resolve the relocated dependencies themselves. + artifact(tasks.named("shadowJar")) + } + } + repositories { + maven { + name = "OneLiteFeather" + url = uri("https://repo.onelitefeather.dev/onelitefeather") + credentials { + username = System.getenv("ONELITEFEATHER_USERNAME") + password = System.getenv("ONELITEFEATHER_PASSWORD") + } + } + } +} +``` + +- [ ] **Schritt 2: Repository-URL und Credential-Namen gegen ein bestehendes OLF-Projekt gegenprüfen** + +Run: `gh api repos/OneLiteFeatherNET/Aves/contents/build.gradle.kts -q '.content' | base64 -d | grep -A12 'repositories'` +Expected: URL und Umgebungsvariablennamen stimmen mit Schritt 1 überein. Weichen sie ab, gilt der Wert aus dem bestehenden Projekt — der zentrale `gradle-publish.yml`-Workflow reicht genau diese Secrets herein. + +- [ ] **Schritt 3: Lokal in ein Verzeichnis publizieren** + +Run: `./gradlew :telemetry-addon:publishToMavenLocal :paper-worldpush:publishToMavenLocal` +Expected: BUILD SUCCESSFUL + +Run: `find ~/.m2/repository/net/onelitefeather/apus -name '*.jar' | sort` +Expected: je ein Jar pro Modul. + +- [ ] **Schritt 4: Publish-Jobs anhängen** + +In `.github/workflows/release-please.yml`: + +```yaml + publish-telemetry-addon: + needs: release-please + if: needs.release-please.outputs.telemetry-released == 'true' + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0 + with: + java-version: "25" + java-distribution: "temurin" + build-task: ":telemetry-addon:build" + publish-task: ":telemetry-addon:publish" + secrets: inherit + + publish-paper-worldpush: + needs: release-please + if: needs.release-please.outputs.paper-released == 'true' + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0 + with: + java-version: "25" + java-distribution: "temurin" + build-task: ":paper-worldpush:build" + publish-task: ":paper-worldpush:publish" + secrets: inherit +``` + +Die projektqualifizierte Task-Syntax sorgt dafür, dass eine Veröffentlichung des einen Moduls das andere nicht mitzieht. + +- [ ] **Schritt 5: Commit** + +```bash +git add telemetry-addon/build.gradle.kts paper-worldpush/build.gradle.kts .github/workflows/release-please.yml +git commit -m "feat: publish telemetry-addon and paper-worldpush to the OneLiteFeather Maven repository" +``` + +--- + +### Task 11: Design-Spec nachziehen + +Die Spec führt in §13.2 „Phase 1 hat im Repository keinerlei CI-Konfiguration angelegt" als offenen Punkt. Nach diesem Plan stimmt das nicht mehr. + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` + +- [ ] **Schritt 1: §13.2, Zeile zum `telemetry-addon`, umschreiben** + +Der Satz „**Offen:** Eine CI-Matrix über unterstützte BlueMap-Versionen als Frühwarnsystem existiert nicht — Phase 1 hat im Repository keinerlei CI-Konfiguration angelegt." wird ersetzt durch: + +```markdown +**Teilweise offen:** CI existiert seit Phase 7 (`.github/workflows/build-pr.yml`), eine +Matrix über mehrere BlueMap-Versionen als Frühwarnsystem aber noch nicht — der +Contract-Test läuft gegen die eine im Katalog gepinnte Version. Bis eine Matrix existiert, +muss er vor jedem BlueMap-Upgrade weiterhin gezielt laufen. +``` + +- [ ] **Schritt 2: §0 um einen Absatz zum Auslieferungsstand ergänzen** + +Nach dem Absatz zu Region-Sharding einfügen: + +```markdown +**Auslieferung steht seit Phase 7.** Alle sechs Komponenten liegen als Container-Image vor +(`runner`, `ingest`, `hosting`, `operator`, `api`, `ui`), `telemetry-addon` und +`paper-worldpush` werden nach Maven veröffentlicht. Versionen und Changelogs entstehen +über Release Please aus Conventional Commits; `telemetry-addon` und `paper-worldpush` +tragen dabei eigene Release-Spuren, wie in §4 vorgesehen. Was weiterhin fehlt, sind die +Cluster-Manifeste und die Observability-Verdrahtung — siehe den Plan zu Phase 8. +``` + +- [ ] **Schritt 3: Markdown-Lint über die geänderte Datei** + +Run: `npx markdownlint-cli2 docs/superpowers/specs/2026-08-08-apus-design.md` +Expected: keine Fehler. + +- [ ] **Schritt 4: Commit** + +```bash +git add docs/superpowers/specs/2026-08-08-apus-design.md +git commit -m "docs: record the phase 7 delivery state in the design spec" +``` + +--- + +## Was dieser Plan bewusst nicht abdeckt + +- **Cluster-Manifeste, CRD-YAMLs, Metriken, Dashboards und der k3s-E2E-Lauf** — eigener Plan (Phase 8). Sie setzen die hier gebauten Images voraus, aber nicht umgekehrt. +- **Identity-Broker-Auswahl, RBAC-Härtung, Quota-Exit-Code, das Paper-Save-Fenster und die `emptyDir`-Grenze** — eigener Plan (Phase 9). Das sind inhaltliche Härtungen am bestehenden Code, keine Auslieferungsfragen. +- **Eine CI-Matrix über mehrere BlueMap-Versionen.** Sinnvoll, aber sie setzt voraus, dass der Contract-Test parametrierbar über die BlueMap-Version ist — das ist er heute nicht (die Version steht fest im Katalog und im `runner/Dockerfile`). Gehört in denselben Schritt wie eine Überarbeitung des Contract-Tests, nicht in die CI-Einführung. diff --git a/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md b/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md new file mode 100644 index 0000000..05fad20 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md @@ -0,0 +1,1212 @@ +# Apus Phase 8 — Deployment und Observability: Implementierungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Apus lässt sich per GitOps in einen Cluster ausrollen, und wer es betreibt, sieht am Dashboard, was das System gerade tut — statt es aus `kubectl`-Ausgaben zusammenzureimen. + +**Architecture:** Das Repository liefert eine Kustomize-Basis unter `deploy/`, die das Cluster-Repository (`Kubernetes-FLUX`) referenziert und über ein Overlay mit seinen eigenen Werten überschreibt. Die sechs CRD-YAMLs werden eingecheckt statt nur generiert, damit ein Ausrollen keinen Gradle-Lauf voraussetzt; ein Test hält die eingecheckte Fassung mit dem Generator synchron. Metriken folgen dem im Repository bereits etablierten Muster: der Operator exponiert sie wie das `telemetry-addon` über den JDK-eigenen `HttpServer`, die API über Micronauts Micrometer-Integration. + +**Tech Stack:** Kustomize, Prometheus Operator (`PodMonitor`/`ServiceMonitor` aus dem im Cluster vorhandenen kube-prometheus-stack), Micrometer 1.15, JOSDK 5.5.1, Grafana, k3s via Testcontainers. + +## Global Constraints + +- **Voraussetzung: Phase 7 ist abgeschlossen.** Die Manifeste referenzieren die dort gebauten Images (`apus/operator`, `apus/api`, `apus/ui`); ohne sie ist dieser Plan nicht ausrollbar. +- **Java-Toolchain 25**, Basispakete wie gehabt (`net.onelitefeather.apus.operator`, `...apus.api`). +- **AGPL-Lizenzheader** über jede neue Java-Datei; Spotless erzwingt ihn. +- **Neue Abhängigkeiten kommen in den Inline-Version-Catalog** in `settings.gradle.kts` — dieses Repository benutzt bewusst kein `libs.versions.toml`. Jede neue Version bekommt dort einen Kommentar, gegen was sie geprüft wurde, wie es die bestehenden Einträge tun. +- **Der Operator arbeitet strikt namespace-lokal** (Design-Spec §10.1). Die RBAC-Regeln dieses Plans dürfen daran nichts aufweichen. +- **Credentials erscheinen nie in Metriken, Labels oder Dashboards** (Design-Spec §12). +- **Integrationstests bleiben aus dem PR-Build ausgeschlossen** — der k3s-Test aus Task 8 folgt der bestehenden `*IntegrationTest`-Konvention. + +--- + +### Task 1: CRD-YAMLs einchecken und synchron halten + +Heute erzeugt `./gradlew :operator:generateCrds` die sechs CRDs nach `operator/build/crds`. Wer Apus ausrollt, braucht sie aber vor dem ersten Operator-Start — und ein Cluster-Repository soll dafür kein Gradle ausführen müssen. + +**Files:** +- Create: `deploy/crds/*.yaml` (sechs Dateien, Generator-Ausgabe) +- Create: `operator/src/test/java/net/onelitefeather/apus/operator/CrdsInSyncTest.java` +- Modify: `operator/build.gradle.kts` (Ausgabeverzeichnis des Generators zusätzlich nach `deploy/crds`) + +**Interfaces:** +- Consumes: `generateCrds` (JavaExec-Task, `operator/build.gradle.kts:62`), der nach `build/crds` schreibt. +- Produces: `deploy/crds/` als eingecheckte Quelle für Task 2. + +- [ ] **Schritt 1: CRDs erzeugen und Namen feststellen** + +Run: `./gradlew :operator:generateCrds && ls operator/build/crds/` +Expected: sechs YAML-Dateien. Die exakten Dateinamen notieren — sie werden in Schritt 3 gebraucht. + +- [ ] **Schritt 2: Failing test schreiben** + +```java +package net.onelitefeather.apus.operator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** + * Guards the CRDs checked in under {@code deploy/crds} against drift from the generator. + * + *

They are checked in so that rolling Apus out needs no Gradle run, which means nothing + * stops a CustomResource class from changing without its YAML following. This test is that + * something: it fails the build rather than letting a cluster receive a schema that no + * longer matches the code. + */ +class CrdsInSyncTest { + + @Test + void checkedInCrdsMatchTheGeneratedOnes() throws IOException { + Path generated = Path.of(System.getProperty("apus.crd.dir")); + Path checkedIn = Path.of("..", "deploy", "crds"); + + Map generatedFiles = read(generated); + Map checkedInFiles = read(checkedIn); + + assertEquals( + generatedFiles.keySet(), + checkedInFiles.keySet(), + "deploy/crds is missing or has extra files; run ./gradlew :operator:generateCrds"); + + generatedFiles.forEach((name, content) -> + assertEquals( + content, + checkedInFiles.get(name), + name + " differs; run ./gradlew :operator:generateCrds and commit the result")); + } + + @Test + void allSixCustomResourcesArePresent() throws IOException { + assertEquals(6, read(Path.of("..", "deploy", "crds")).size()); + } + + private static Map read(Path dir) throws IOException { + assertTrue(Files.isDirectory(dir), dir + " does not exist"); + try (Stream files = Files.list(dir)) { + return files.filter(p -> p.toString().endsWith(".yaml")) + .collect(Collectors.toMap( + p -> p.getFileName().toString(), + p -> { + try { + return Files.readString(p); + } catch (IOException e) { + throw new IllegalStateException(e); + } + })); + } + } +} +``` + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag bestätigen** + +Run: `./gradlew :operator:test --tests '*CrdsInSyncTest*'` +Expected: FAIL mit `../deploy/crds does not exist`. + +- [ ] **Schritt 4: Generator zusätzlich nach `deploy/crds` schreiben lassen** + +In `operator/build.gradle.kts` nach der `generateCrds`-Registrierung: + +```kotlin +val syncCrds by tasks.registering(Copy::class) { + description = "Copies the generated CRDs to deploy/crds, which is what gets rolled out." + group = "build" + from(generateCrds) + into(rootProject.layout.projectDirectory.dir("deploy/crds")) +} +``` + +- [ ] **Schritt 5: CRDs erzeugen und einchecken** + +Run: `./gradlew :operator:syncCrds && ls deploy/crds/` +Expected: dieselben sechs Dateien wie in Schritt 1. + +- [ ] **Schritt 6: Test läuft grün** + +Run: `./gradlew :operator:test --tests '*CrdsInSyncTest*'` +Expected: PASS + +- [ ] **Schritt 7: Gegenprobe, dass der Test Drift wirklich erkennt** + +```bash +printf '\n# drift\n' >> deploy/crds/$(ls deploy/crds | head -1) +./gradlew :operator:test --tests '*CrdsInSyncTest*' || echo "erkannt" +git checkout deploy/crds +``` + +Expected: `erkannt` — ein Test, der Drift nicht bemerkt, ist wertlos. + +- [ ] **Schritt 8: Commit** + +```bash +git add deploy/crds operator/build.gradle.kts operator/src/test/java/net/onelitefeather/apus/operator/CrdsInSyncTest.java +git commit -m "feat: check in the generated CRDs and guard them against drift" +``` + +--- + +### Task 2: Kustomize-Basis für den Operator + +**Files:** +- Create: `deploy/base/kustomization.yaml` +- Create: `deploy/base/namespace.yaml` +- Create: `deploy/base/operator-serviceaccount.yaml` +- Create: `deploy/base/operator-rbac.yaml` +- Create: `deploy/base/operator-deployment.yaml` +- Create: `deploy/README.md` + +**Interfaces:** +- Consumes: `deploy/crds/` aus Task 1; die Umgebungsvariablen aus `OperatorConfig` (`APUS_ROOK_NAMESPACE`, `APUS_CEPH_OBJECT_STORE`, `APUS_BUCKET_STORAGE_CLASS`, `APUS_RUNNER_IMAGE`, `APUS_INGEST_IMAGE`, `APUS_HOSTING_IMAGE`, `APUS_BUNDLE_BUCKET`, `APUS_BUNDLE_S3_ENDPOINT`, `APUS_BUNDLE_S3_REGION`, `APUS_BUNDLE_CREDENTIALS_SECRET`). +- Produces: die Basis, auf die Task 3 (API und UI) und Task 6 (PodMonitor) aufsetzen. + +- [ ] **Schritt 1: Namespace und ServiceAccount** + +`deploy/base/namespace.yaml`: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: apus-system +``` + +`deploy/base/operator-serviceaccount.yaml`: + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: apus-operator + namespace: apus-system +``` + +- [ ] **Schritt 2: RBAC** + +`deploy/base/operator-rbac.yaml`: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: apus-operator +rules: + # Own custom resources, including status and finalizers. + - apiGroups: ["bluemap.onelitefeather.net"] + resources: + - tenants + - worldsources + - worldingests + - bluemapmaps + - bluemaprenders + - bluemaphostings + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: + - tenants/status + - worldsources/status + - worldingests/status + - bluemapmaps/status + - bluemaprenders/status + - bluemaphostings/status + verbs: ["get", "update", "patch"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: + - tenants/finalizers + - bluemapmaps/finalizers + verbs: ["update"] + # A Tenant creates a namespace with its quota and network policy (design spec §8.1). + - apiGroups: [""] + resources: ["namespaces", "resourcequotas", "limitranges"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + # Renders and ingests are Jobs; hosting is a Deployment behind a Service and Ingress. + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services", "configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Reading the render pod's /progress endpoint and its termination message (design spec §7.2). + - apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list", "watch"] + # Rook provisions bucket, credentials secret and endpoint ConfigMap (design spec §9.1). + - apiGroups: ["objectbucket.io"] + resources: ["objectbucketclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["ceph.rook.io"] + resources: ["cephobjectstoreusers"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # The secrets Rook creates, wired into render jobs and hosting pods. Deliberately not + # cluster-wide write: the operator only ever reads them. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: apus-operator +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: apus-operator +subjects: + - kind: ServiceAccount + name: apus-operator + namespace: apus-system +``` + +- [ ] **Schritt 3: RBAC gegen den tatsächlichen Code prüfen** + +Run: `grep -rhoE '\b(Job|Deployment|Service|Ingress|ConfigMap|Secret|Namespace|ResourceQuota|LimitRange|NetworkPolicy|ObjectBucketClaim|CephObjectStoreUser|Pod)\b' operator/src/main/java --include='*.java' | sort -u` +Expected: Jeder ausgegebene Typ hat oben eine Regel. Fehlt einer, ergänzen — eine zu schmale ClusterRole äußert sich zur Laufzeit als `Forbidden` mitten in einer Reconciliation, nicht beim Start. + +- [ ] **Schritt 4: Operator-Deployment** + +`deploy/base/operator-deployment.yaml`: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: apus-operator + namespace: apus-system + labels: + app.kubernetes.io/name: apus-operator + app.kubernetes.io/part-of: apus +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: apus-operator + template: + metadata: + labels: + app.kubernetes.io/name: apus-operator + app.kubernetes.io/part-of: apus + spec: + serviceAccountName: apus-operator + securityContext: + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + containers: + - name: operator + image: harbor.onelitefeather.dev/apus/operator:0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: metrics + containerPort: 8080 + env: + # Defaults live in OperatorConfig; every value here is set explicitly so that + # what a cluster runs with is readable from the manifest rather than the code. + - name: APUS_ROOK_NAMESPACE + value: rook-ceph + - name: APUS_CEPH_OBJECT_STORE + value: ceph-objectstore + - name: APUS_BUCKET_STORAGE_CLASS + value: ceph-bucket + - name: APUS_RUNNER_IMAGE + value: harbor.onelitefeather.dev/apus/runner:0.1.0 + - name: APUS_INGEST_IMAGE + value: harbor.onelitefeather.dev/apus/ingest:0.1.0 + - name: APUS_HOSTING_IMAGE + value: harbor.onelitefeather.dev/apus/hosting:0.1.0 + - name: APUS_BUNDLE_BUCKET + value: apus-bundles + - name: APUS_BUNDLE_S3_ENDPOINT + value: http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc:80 + - name: APUS_BUNDLE_S3_REGION + value: us-east-1 + - name: APUS_BUNDLE_CREDENTIALS_SECRET + value: apus-bundle-credentials + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] +``` + +- [ ] **Schritt 5: Kustomization und README** + +`deploy/base/kustomization.yaml`: + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../crds + - namespace.yaml + - operator-serviceaccount.yaml + - operator-rbac.yaml + - operator-deployment.yaml +``` + +Dafür braucht `deploy/crds` eine eigene `kustomization.yaml`, die die sechs Dateien auflistet: + +```yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - +``` + +`deploy/README.md`: + +```markdown +# Ausrollen + +`base/` ist die vollständige, aber unkonfigurierte Kustomize-Basis. Cluster-spezifische +Werte — Registry, Image-Tags, Rook-Namen, Hostnamen — gehören in ein Overlay im +Cluster-Repository, nicht hierher. + + kubectl apply -k deploy/base # direkt, für einen Testcluster + kustomize build deploy/base | kubectl apply -f - + +Die CRDs unter `crds/` sind generiert. Sie werden nicht von Hand bearbeitet, sondern über + + ./gradlew :operator:syncCrds + +erneuert; `CrdsInSyncTest` bricht den Build, wenn das jemand vergisst. +``` + +- [ ] **Schritt 6: Manifeste validieren** + +Run: `kustomize build deploy/base > /tmp/apus-base.yaml && grep -c '^kind:' /tmp/apus-base.yaml` +Expected: mindestens 11 Objekte (6 CRDs, Namespace, ServiceAccount, ClusterRole, ClusterRoleBinding, Deployment). + +Run: `kubectl apply --dry-run=client -f /tmp/apus-base.yaml` +Expected: jede Zeile endet auf `(dry run)`, keine Fehler. + +- [ ] **Schritt 7: Commit** + +```bash +git add deploy/ +git commit -m "feat: add a Kustomize base for rolling out the operator" +``` + +--- + +### Task 3: Manifeste für API und UI + +**Files:** +- Create: `deploy/base/api-deployment.yaml` +- Create: `deploy/base/api-service.yaml` +- Create: `deploy/base/api-rbac.yaml` +- Create: `deploy/base/ui-deployment.yaml` +- Create: `deploy/base/ui-service.yaml` +- Create: `deploy/base/ingress.yaml` +- Modify: `deploy/base/kustomization.yaml` + +- [ ] **Schritt 1: RBAC der API ermitteln, statt sie zu raten** + +Run: `grep -rn 'resources(\|\.secrets()\|\.namespaces()\|customResources' api/src/main/java --include='*.java' | head -20` +Expected: eine Liste der tatsächlich angesprochenen Ressourcen. Die API liest die Custom Resources und — für den Push-Token-Lookup — Secrets. Genau diese und keine weiteren kommen in die Rolle. + +- [ ] **Schritt 2: API-RBAC schreiben** + +`deploy/base/api-rbac.yaml`: + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: apus-api + namespace: apus-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: apus-api +rules: + - apiGroups: ["bluemap.onelitefeather.net"] + resources: + - tenants + - worldsources + - worldingests + - bluemapmaps + - bluemaprenders + - bluemaphostings + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Service-token lookup. This is deliberately cluster-wide read on secrets today, which + # is wider than ideal -- see design spec §15, point 9. Narrowing it is scoped in the + # phase 9 plan; until then this rule must not be copied as a pattern for anything else. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: apus-api +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: apus-api +subjects: + - kind: ServiceAccount + name: apus-api + namespace: apus-system +``` + +- [ ] **Schritt 3: Deployments und Services** + +`deploy/base/api-deployment.yaml` — gleiche Struktur wie das Operator-Deployment (`securityContext`, `runAsUser: 10001`, `readOnlyRootFilesystem`), Image `harbor.onelitefeather.dev/apus/api:0.1.0`, `serviceAccountName: apus-api`, Port 8080, plus: + +```yaml + env: + - name: MICRONAUT_ENVIRONMENTS + value: k8s + # The issuer is the one open product decision (design spec §15, point 3). The + # overlay in the cluster repository supplies the real value; the base leaves + # it empty on purpose so that a half-configured rollout fails loudly at startup + # instead of accepting unvalidated tokens. + - name: MICRONAUT_SECURITY_TOKEN_JWT_SIGNATURES_JWKS_DEFAULT_URL + value: "" + readinessProbe: + httpGet: + path: /health/readiness + port: 8080 + initialDelaySeconds: 10 + livenessProbe: + httpGet: + path: /health/liveness + port: 8080 + initialDelaySeconds: 30 +``` + +`deploy/base/api-service.yaml` und `deploy/base/ui-service.yaml`: je ein `ClusterIP`-Service auf Port 8080 mit passendem Selector. + +`deploy/base/ui-deployment.yaml`: Image `harbor.onelitefeather.dev/apus/ui:0.1.0`, `runAsUser: 101` (die unprivilegierte nginx-Basis aus Phase 7, Task 8 läuft unter dieser uid — nicht 10001), Port 8080, `readOnlyRootFilesystem: false`, weil nginx sein Cache-Verzeichnis beschreibt. + +- [ ] **Schritt 4: Ingress** + +`deploy/base/ingress.yaml` — ein Host, zwei Pfade: `/api` auf den API-Service, `/` auf den UI-Service. `ingressClassName: nginx`, TLS über cert-manager, Hostname als Platzhalter `apus.example.net`, den das Overlay ersetzt. + +- [ ] **Schritt 5: Health-Endpunkte verifizieren, bevor die Probes eingecheckt werden** + +Run: `grep -rn 'micronaut-management\|endpoints:' api/build.gradle.kts api/src/main/resources/application.yml` +Expected: `micronaut-management` ist als Abhängigkeit vorhanden und `/health` aktiviert. Ist es das nicht, laufen die Probes ins Leere und der Pod wird endlos neu gestartet — dann zuerst Task 5 dieses Plans ausführen (der bringt `micronaut-management` mit) und danach hierher zurückkehren. + +- [ ] **Schritt 6: Kustomization erweitern und validieren** + +Die sechs neuen Dateien in `deploy/base/kustomization.yaml` unter `resources` ergänzen. + +Run: `kustomize build deploy/base | kubectl apply --dry-run=client -f -` +Expected: keine Fehler. + +- [ ] **Schritt 7: Commit** + +```bash +git add deploy/base +git commit -m "feat: add deployment manifests for the API and the dashboard" +``` + +--- + +### Task 4: Operator-Metriken + +Design-Spec §13.1 verlangt „Renders nach Phase, Ingest-Dauer, Quota-Auslastung je Mandant". Nichts davon existiert. + +**Files:** +- Modify: `settings.gradle.kts` (Micrometer im Katalog) +- Modify: `operator/build.gradle.kts` +- Create: `operator/src/main/java/net/onelitefeather/apus/operator/metrics/ApusMetrics.java` +- Create: `operator/src/main/java/net/onelitefeather/apus/operator/metrics/MetricsServer.java` +- Create: `operator/src/test/java/net/onelitefeather/apus/operator/metrics/ApusMetricsTest.java` +- Create: `operator/src/test/java/net/onelitefeather/apus/operator/metrics/MetricsServerTest.java` +- Modify: `operator/src/main/java/net/onelitefeather/apus/operator/ApusOperator.java` + +**Interfaces:** +- Produces: + ```java + public final class ApusMetrics { + public ApusMetrics(MeterRegistry registry); + public void recordRenderPhase(String tenant, String phase); + public void recordIngestDuration(String tenant, Duration duration); + public void recordStorageUsed(String tenant, long bytes); + public String scrape(); + } + public final class MetricsServer implements AutoCloseable { + public MetricsServer(int port, Supplier scrape); + public void start() throws IOException; + /** The port actually bound -- differs from the constructor argument when that was 0. */ + public int port(); + @Override public void close(); + } + ``` +- Consumes: `JOSDK 5.5.1`s `Metrics`-Schnittstelle für die Reconciliation-Metriken. + +- [ ] **Schritt 1: Katalogeinträge ergänzen** + +In `settings.gradle.kts` im `versionCatalogs`-Block: + +```kotlin +// Micrometer: the operator has no web framework to inherit a registry from, so it takes +// the Prometheus registry directly and serves it over the JDK HttpServer, exactly like +// telemetry-addon does. Version verified against Maven Central on 2026-08-12. +version("micrometer", "1.15.2") +library("micrometer.core", "io.micrometer", "micrometer-core").versionRef("micrometer") +library("micrometer.registry.prometheus", "io.micrometer", "micrometer-registry-prometheus") + .versionRef("micrometer") +// JOSDK's own reconciliation metrics (queue depth, reconciliation time, failures), bound +// to the same registry so operator-internal and Apus-domain metrics scrape together. +library("josdk.micrometer", "io.javaoperatorsdk", "micrometer-support").versionRef("josdk") +``` + +In `operator/build.gradle.kts` unter `dependencies`: + +```kotlin +implementation(libs.micrometer.core) +implementation(libs.micrometer.registry.prometheus) +implementation(libs.josdk.micrometer) +``` + +- [ ] **Schritt 2: Failing test für die Metriken schreiben** + +```java +package net.onelitefeather.apus.operator.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micrometer.prometheusmetrics.PrometheusConfig; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class ApusMetricsTest { + + @Test + void countsRendersPerTenantAndPhase() { + PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + ApusMetrics metrics = new ApusMetrics(registry); + + metrics.recordRenderPhase("friends-server", "Succeeded"); + metrics.recordRenderPhase("friends-server", "Succeeded"); + metrics.recordRenderPhase("friends-server", "Failed"); + + // Micrometer name is "apus_renders"; the Prometheus registry appends "_total" for + // counters, which is why the scraped name is apus_renders_total. Naming the meter + // apus_renders_total here would scrape as apus_renders_total_total. + assertEquals( + 2.0, + registry.counter("apus_renders", "tenant", "friends-server", "phase", "Succeeded") + .count()); + assertEquals( + 1.0, + registry.counter("apus_renders", "tenant", "friends-server", "phase", "Failed") + .count()); + } + + @Test + void recordsIngestDurationAsAHistogram() { + PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + ApusMetrics metrics = new ApusMetrics(registry); + + metrics.recordIngestDuration("friends-server", Duration.ofSeconds(42)); + + assertEquals(1L, registry.timer("apus_ingest_duration", "tenant", "friends-server").count()); + // The platform dashboard shows a 95th percentile, which needs buckets -- a plain + // timer scrapes count and sum only and would leave that panel empty. + assertTrue(metrics.scrape().contains("apus_ingest_duration_seconds_bucket"), metrics.scrape()); + } + + @Test + void exposesStorageUsedAsAGauge() { + PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + ApusMetrics metrics = new ApusMetrics(registry); + + metrics.recordStorageUsed("friends-server", 228730548224L); + metrics.recordStorageUsed("friends-server", 300000000000L); + + // A gauge, not a counter: the value goes down when a tenant deletes a map. + assertEquals( + 300000000000.0, + registry.get("apus_storage_used_bytes").tag("tenant", "friends-server").gauge().value()); + } + + @Test + void scrapeRendersPrometheusText() { + PrometheusMeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT); + ApusMetrics metrics = new ApusMetrics(registry); + metrics.recordRenderPhase("friends-server", "Succeeded"); + + String body = metrics.scrape(); + + assertTrue(body.contains("apus_renders_total"), body); + assertTrue(body.contains("tenant=\"friends-server\""), body); + } +} +``` + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag bestätigen** + +Run: `./gradlew :operator:test --tests '*ApusMetricsTest*'` +Expected: FAIL, `ApusMetrics` existiert nicht. + +- [ ] **Schritt 4: `ApusMetrics` implementieren** + +```java +package net.onelitefeather.apus.operator.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * The Apus-domain metrics from design spec §13.1. + * + *

Tenant is a label rather than part of the metric name: the platform dashboard needs to + * sum across tenants, which a name-per-tenant scheme makes impossible. The number of tenants + * is small and known (design spec §1.3), so the cardinality this adds is bounded. + */ +public final class ApusMetrics { + + private final MeterRegistry registry; + private final Map storageUsed = new ConcurrentHashMap<>(); + + public ApusMetrics(MeterRegistry registry) { + this.registry = registry; + } + + public void recordRenderPhase(String tenant, String phase) { + // No "_total" suffix here: the Prometheus registry adds it for counters, so the + // scraped name becomes apus_renders_total. + Counter.builder("apus_renders") + .description("Renders that reached a given phase") + .tag("tenant", tenant) + .tag("phase", phase) + .register(registry) + .increment(); + } + + public void recordIngestDuration(String tenant, Duration duration) { + Timer.builder("apus_ingest_duration") + .description("Wall-clock time an ingest job took") + // Buckets, not just count and sum: the platform dashboard renders a 95th + // percentile, which histogram_quantile cannot compute without them. + .publishPercentileHistogram() + .tag("tenant", tenant) + .register(registry) + .record(duration); + } + + public void recordStorageUsed(String tenant, long bytes) { + storageUsed + .computeIfAbsent(tenant, t -> { + AtomicLong holder = new AtomicLong(); + io.micrometer.core.instrument.Gauge.builder("apus_storage_used_bytes", holder, AtomicLong::get) + .description("Bytes a tenant currently occupies, as reported by RGW") + .tag("tenant", t) + .register(registry); + return holder; + }) + .set(bytes); + } + + public String scrape() { + if (registry instanceof PrometheusMeterRegistry prometheus) { + return prometheus.scrape(); + } + throw new IllegalStateException( + "scrape() needs a PrometheusMeterRegistry; got " + registry.getClass().getName()); + } +} +``` + +- [ ] **Schritt 5: Test läuft grün** + +Run: `./gradlew :operator:test --tests '*ApusMetricsTest*'` +Expected: PASS + +- [ ] **Schritt 6: Failing test für den Metrics-Server** + +```java +package net.onelitefeather.apus.operator.metrics; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.Test; + +class MetricsServerTest { + + @Test + void servesTheScrapeBodyOnSlashMetrics() throws Exception { + try (MetricsServer server = new MetricsServer(0, () -> "apus_renders_total 1.0\n")) { + server.start(); + + HttpResponse response = HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.port() + "/metrics")) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("apus_renders_total"), response.body()); + } + } + + @Test + void answers404ForEverythingElse() throws Exception { + try (MetricsServer server = new MetricsServer(0, () -> "")) { + server.start(); + + HttpResponse response = HttpClient.newHttpClient() + .send( + HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + server.port() + "/")) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(404, response.statusCode()); + } + } +} +``` + +- [ ] **Schritt 7: `MetricsServer` implementieren** + +Nach dem Vorbild von `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/TelemetryServer.java`: JDK-`HttpServer`, ein `HttpHandler` auf `/metrics`, `Executors.newVirtualThreadPerTaskExecutor()`, `port()`-Methode, die den effektiv gebundenen Port zurückgibt (nötig, weil der Test mit Port 0 bindet). + +- [ ] **Schritt 8: Test läuft grün** + +Run: `./gradlew :operator:test --tests '*MetricsServerTest*'` +Expected: PASS + +- [ ] **Schritt 9: In `ApusOperator` verdrahten** + +Im Start-Pfad eine `PrometheusMeterRegistry` anlegen, an `ApusMetrics` und an JOSDKs `MicrometerMetrics` übergeben (`Operator`-Konfiguration: `.withMetrics(MicrometerMetrics.newPerResourceCollectingMicrometerMetricsBuilder(registry).build())`), `MetricsServer` auf Port 8080 starten und beim Herunterfahren schließen. `ApusMetrics` an die Reconciler durchreichen, die die drei Ereignisse melden. + +- [ ] **Schritt 10: Gesamten Operator-Test-Lauf grün halten** + +Run: `./gradlew :operator:test` +Expected: BUILD SUCCESSFUL + +- [ ] **Schritt 11: Commit** + +```bash +git add settings.gradle.kts operator/ +git commit -m "feat: export operator metrics for renders, ingests and tenant storage" +``` + +--- + +### Task 5: API-Metriken + +**Files:** +- Modify: `settings.gradle.kts` +- Modify: `api/build.gradle.kts` +- Modify: `api/src/main/resources/application.yml` +- Create: `api/src/test/java/net/onelitefeather/apus/api/MetricsEndpointTest.java` + +- [ ] **Schritt 1: Katalog und Abhängigkeiten** + +```kotlin +// Micronaut Micrometer, per the OneLiteFeather observability baseline. Version taken from +// io.micronaut.platform:micronaut-platform:5.1.0, the same BOM the existing micronaut +// entries were cross-checked against. +version("micronaut-micrometer", "5.11.0") +library("micronaut.micrometer.bom", "io.micronaut.micrometer", "micronaut-micrometer-bom") + .versionRef("micronaut-micrometer") +library("micronaut.micrometer.core", "io.micronaut.micrometer", "micronaut-micrometer-core") + .withoutVersion() +library("micronaut.micrometer.registry.prometheus", "io.micronaut.micrometer", "micronaut-micrometer-registry-prometheus") + .withoutVersion() +library("micronaut.management", "io.micronaut", "micronaut-management").withoutVersion() +``` + +In `api/build.gradle.kts`: + +```kotlin +implementation(platform(libs.micronaut.micrometer.bom)) +implementation(libs.micronaut.micrometer.core) +implementation(libs.micronaut.micrometer.registry.prometheus) +implementation(libs.micronaut.management) +``` + +- [ ] **Schritt 2: Failing test schreiben** + +```java +package net.onelitefeather.apus.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +@MicronautTest +class MetricsEndpointTest { + + @Inject + @Client("/") + HttpClient client; + + @Test + void metricsRequireAuthentication() { + // The endpoint stays sensitive, per the OneLiteFeather security baseline: it is + // scraped by a PodMonitor inside the cluster, not exposed to the internet. + HttpClientResponseException thrown = org.junit.jupiter.api.Assertions.assertThrows( + HttpClientResponseException.class, + () -> client.toBlocking().exchange(HttpRequest.GET("/prometheus"))); + + assertEquals(HttpStatus.UNAUTHORIZED, thrown.getStatus()); + } + + @Test + void metricsExposeHttpServerRequests() { + String body = client.toBlocking() + .retrieve(HttpRequest.GET("/prometheus").basicAuth("metrics", "metrics")); + + assertTrue(body.contains("http_server_requests"), body); + } +} +``` + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag bestätigen** + +Run: `./gradlew :api:test --tests '*MetricsEndpointTest*'` +Expected: FAIL — der Endpunkt existiert nicht (404 statt 401). + +- [ ] **Schritt 4: `application.yml` ergänzen** + +```yaml +endpoints: + metrics: + enabled: true + sensitive: true + prometheus: + enabled: true + sensitive: true + health: + enabled: true + sensitive: false + details-visible: ANONYMOUS + +micronaut: + metrics: + enabled: true + binders: + jvm.enabled: true + web.enabled: true + uptime.enabled: true +``` + +`health` bleibt bewusst unauthentifiziert — Kubelet-Probes tragen kein Token. Details sind dabei unbedenklich, weil der Endpunkt nur innerhalb des Clusters erreichbar ist (kein Ingress-Pfad darauf). + +- [ ] **Schritt 5: Tests grün** + +Run: `./gradlew :api:test` +Expected: BUILD SUCCESSFUL + +- [ ] **Schritt 6: Commit** + +```bash +git add settings.gradle.kts api/ +git commit -m "feat: expose Prometheus metrics and health endpoints from the API" +``` + +--- + +### Task 6: Scrape-Konfiguration + +**Files:** +- Create: `deploy/base/podmonitor-render.yaml` +- Create: `deploy/base/servicemonitor-operator.yaml` +- Create: `deploy/base/servicemonitor-api.yaml` +- Create: `deploy/base/operator-service.yaml` +- Modify: `deploy/base/kustomization.yaml` + +- [ ] **Schritt 1: Label prüfen, unter dem der Operator seine Render-Pods markiert** + +Run: `grep -rn 'class Labels' -A 30 operator/src/main/java/net/onelitefeather/apus/operator/api/Labels.java` +Expected: die Konstanten für die Pod-Labels. Der `PodMonitor` muss exakt darauf selektieren — geraten führt zu einem Monitor, der nie etwas findet und dabei keinen Fehler wirft. + +- [ ] **Schritt 2: `PodMonitor` für Render-Pods** + +```yaml +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: apus-render + namespace: apus-system + labels: + app.kubernetes.io/part-of: apus +spec: + # Render pods live in the tenant namespaces, not in apus-system. + namespaceSelector: + any: true + selector: + matchLabels: + + podMetricsEndpoints: + - port: telemetry + path: /metrics + interval: 15s +``` + +Damit das greift, muss der Render-Job seinen Port benennen. Prüfen: + +Run: `grep -n 'containerPort\|withName' operator/src/main/java/net/onelitefeather/apus/operator/render/RenderJobBuilder.java` +Expected: ein benannter Port `telemetry` auf 8099. Fehlt der Name, im selben Task ergänzen und den zugehörigen `RenderJobBuilderTest` erweitern. + +- [ ] **Schritt 3: Service und `ServiceMonitor` für Operator und API** + +`operator-service.yaml`: ClusterIP-Service auf Port 8080, Name `metrics`, Selector `app.kubernetes.io/name: apus-operator`. + +Beide `ServiceMonitor`s selektieren auf denselben Labels; der für die API scrapt Pfad `/prometheus` und braucht die Basic-Auth- bzw. Token-Referenz, mit der der Endpunkt geschützt ist (`basicAuth` mit Verweis auf ein Secret, das das Overlay im Cluster-Repository liefert). + +- [ ] **Schritt 4: Validieren** + +Run: `kustomize build deploy/base | kubectl apply --dry-run=client -f - 2>&1 | tail -5` +Expected: keine Fehler. `PodMonitor`/`ServiceMonitor` erfordern die CRDs des Prometheus-Operators; ist der lokal nicht vorhanden, schlägt `--dry-run=client` **nicht** fehl (es prüft nur Struktur) — für die echte Prüfung `--dry-run=server` gegen einen Cluster mit kube-prometheus-stack verwenden. + +- [ ] **Schritt 5: Commit** + +```bash +git add deploy/base +git commit -m "feat: add scrape configuration for render pods, operator and API" +``` + +--- + +### Task 7: Grafana-Dashboards + +Design-Spec §13.1: „ein Grafana-Dashboard je Ebene (Plattform, Mandant)". + +**Files:** +- Create: `deploy/dashboards/apus-platform.json` +- Create: `deploy/dashboards/apus-tenant.json` +- Create: `deploy/base/dashboards-configmap.yaml` +- Modify: `deploy/base/kustomization.yaml` + +- [ ] **Schritt 1: Verfügbare Metriknamen zusammenstellen** + +Aus Task 4 und 5 sowie dem bestehenden `telemetry-addon`: + +Run: `grep -rn 'apus_' telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/PrometheusWriter.java operator/src/main/java/net/onelitefeather/apus/operator/metrics/ApusMetrics.java` +Expected: die vollständige Liste. Jedes Panel darf ausschließlich diese Namen verwenden — ein Dashboard mit erfundenen Metriken sieht korrekt aus und bleibt dauerhaft leer. + +- [ ] **Schritt 2: Plattform-Dashboard bauen** + +`deploy/dashboards/apus-platform.json`, Panels: +1. **Renders nach Phase** (Zeitreihe): `sum by (phase) (rate(apus_renders_total[5m]))` +2. **Fehlerquote** (Stat): `sum(rate(apus_renders_total{phase="Failed"}[1h])) / sum(rate(apus_renders_total[1h]))` +3. **Speicherverbrauch je Mandant** (Balken): `apus_storage_used_bytes` +4. **Ingest-Dauer, 95. Perzentil** (Zeitreihe): `histogram_quantile(0.95, sum by (le, tenant) (rate(apus_ingest_duration_seconds_bucket[30m])))` +5. **Reconciliation-Fehler des Operators** (Zeitreihe, aus JOSDKs Micrometer-Support): `sum by (name) (rate(operator_sdk_reconciliations_failed_total[5m]))` +6. **API-Latenz** (Zeitreihe): `histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket[5m])))` + +Als Template-Variable `datasource` vom Typ `prometheus`; keine fest verdrahtete Datenquellen-UID, sonst lässt sich das Dashboard in keinem zweiten Cluster importieren. + +- [ ] **Schritt 3: Mandanten-Dashboard bauen** + +`deploy/dashboards/apus-tenant.json` mit derselben Datenquellen-Variable plus einer Variable `tenant` (`label_values(apus_storage_used_bytes, tenant)`). Panels: laufende Renders mit Fortschritt (`apus_render_progress_ratio` und `apus_render_eta_seconds` — die Namen, die `PrometheusWriter` im `telemetry-addon` tatsächlich schreibt), letzte Ingest-Dauer, Speicherverbrauch gegen Quota, Render-Historie nach Phase — alle mit `{tenant="$tenant"}` gefiltert. + +Die Render-Metriken tragen allerdings **kein** `tenant`-Label: Das `telemetry-addon` läuft im Render-Pod und kennt nur `map`. Der Mandant kommt über die Pod-Labels herein, die der `PodMonitor` aus Task 6 anhängt — beim Bau der Panels ist zu prüfen, welches Label das ist (`grep` in `Labels.java`), und danach zu filtern. Wer stattdessen `{tenant="$tenant"}` auf `apus_render_progress_ratio` schreibt, bekommt ein dauerhaft leeres Panel. + +- [ ] **Schritt 4: JSON validieren** + +Run: `for f in deploy/dashboards/*.json; do python3 -c "import json,sys;json.load(open('$f'));print('$f ok')"; done` +Expected: beide `ok`. + +- [ ] **Schritt 5: Alle verwendeten Metriknamen gegen Schritt 1 gegenprüfen** + +Nicht gegen den Quellcode greppen, sondern gegen einen echten Scrape — die Meter-Namen im Code und die gescrapten Namen unterscheiden sich (`apus_renders` im Code, `apus_renders_total` im Scrape; `apus_ingest_duration` im Code, `apus_ingest_duration_seconds*` im Scrape). Ein Abgleich gegen den Quellcode würde genau deshalb Fehlalarme produzieren. + +```bash +# Scrape einer laufenden Instanz als Referenz nehmen: +kubectl -n apus-system port-forward svc/apus-operator 8080:8080 & +curl -s localhost:8080/metrics | grep -oE '^apus_[a-z_]+' | sort -u > /tmp/scraped.txt +grep -ohE 'apus_[a-z_]+' deploy/dashboards/*.json | sort -u > /tmp/used.txt +comm -23 /tmp/used.txt /tmp/scraped.txt +``` + +Expected: leere Ausgabe. Jeder Name, der hier erscheint, wird von keiner Instanz exportiert — entweder ein Tippfehler oder eine Metrik, die noch niemand schreibt. Beides muss vor dem Commit aufgelöst sein, denn ein Panel mit falschem Namen bleibt leer, ohne je einen Fehler zu zeigen. + +Metriken aus dem `telemetry-addon` (`apus_render_*`) erscheinen nicht im Operator-Scrape; für sie ist derselbe Abgleich gegen einen Render-Pod auf Port 8099 zu fahren. + +- [ ] **Schritt 6: ConfigMap für die Grafana-Sidecar-Erkennung** + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: apus-dashboards + namespace: apus-system + labels: + # The kube-prometheus-stack Grafana sidecar picks up ConfigMaps carrying this label. + grafana_dashboard: "1" +``` + +Die beiden JSON-Dateien werden über `configMapGenerator` in `kustomization.yaml` eingebunden, nicht von Hand in die ConfigMap kopiert: + +```yaml +configMapGenerator: + - name: apus-dashboards + namespace: apus-system + files: + - ../dashboards/apus-platform.json + - ../dashboards/apus-tenant.json + options: + labels: + grafana_dashboard: "1" + disableNameSuffixHash: true +``` + +- [ ] **Schritt 7: Commit** + +```bash +git add deploy/dashboards deploy/base +git commit -m "feat: add Grafana dashboards for the platform and tenant views" +``` + +--- + +### Task 8: Ende-zu-Ende-Lauf auf k3s + +Design-Spec §13.2 sieht vor: „k3s + S3: kompletter Durchlauf Ingest → Render → Hosting mit Mini-Welt". Vorhanden sind `PushIngestEndToEndTest` (Ingest allein) und `RenderEndToEndTest` (Render allein) — der Durchlauf über alle drei Stufen fehlt, und Hosting ist in keinem E2E-Test enthalten. + +**Files:** +- Create: `operator/src/test/java/net/onelitefeather/apus/operator/FullPipelineIntegrationTest.java` +- Modify: `operator/build.gradle.kts` (nur falls der `integrationTest`-Task angepasst werden muss) + +**Interfaces:** +- Consumes: die bestehende k3s-Testcontainers-Infrastruktur der vorhandenen `*IntegrationTest`-Klassen sowie `testdata/mini-world`. + +- [ ] **Schritt 1: Bestehende Integrationstest-Infrastruktur ansehen** + +Run: `ls operator/src/test/java/net/onelitefeather/apus/operator/*IntegrationTest.java && grep -n 'K3sContainer\|MinIOContainer\|LocallyRunOperatorExtension' operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java | head` +Expected: das vorhandene Muster für k3s- und MinIO-Container. Der neue Test übernimmt es unverändert, statt eine zweite Variante zu erfinden. + +- [ ] **Schritt 2: Failing test schreiben** + +Der Test fährt in einer Methode: +1. k3s starten, die sechs CRDs aus `deploy/crds` anwenden, den Operator über `LocallyRunOperatorExtension` gegen diesen Cluster laufen lassen. +2. MinIO starten, `testdata/mini-world` als Push-Quelle in den Staging-Prefix legen. +3. `Tenant` anlegen, auf `status.namespace` warten. +4. `WorldSource` (Typ `push`) und `WorldIngest` anlegen, warten bis `status.phase == "Succeeded"` und `status.bundle.path` gesetzt ist. +5. `BlueMapMap` anlegen, warten bis der erzeugte `BlueMapRender` auf `Succeeded` steht. +6. `BlueMapHosting` anlegen, warten bis `status.ready == true` und `status.url` gesetzt ist. +7. Prüfen, dass im Map-Bucket tatsächlich Kacheln liegen (`settings.json` und mindestens eine `.png`/`.prbm` unterhalb des Map-Prefix). + +Timeouts großzügig (Render der Mini-Welt: bis zu 10 Minuten), jede Wartestufe mit eigener aussagekräftiger Fehlermeldung, damit ein Fehlschlag zeigt, *welche* Stufe hängen blieb. + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag bestätigen** + +Run: `./gradlew :operator:integrationTest --tests '*FullPipelineIntegrationTest*'` +Expected: FAIL. Der Fehlschlag muss aus einer der Wartestufen kommen, nicht aus einem Kompilierfehler. + +- [ ] **Schritt 4: Test zum Laufen bringen** + +Was hier zu tun ist, hängt vom Fehlschlag ab. Erwartbare Stolpersteine, jeweils mit dem Ort, an dem sie zu beheben sind: +- Der Operator im Test kennt die Image-Namen nicht → `OperatorConfig`-Umgebungsvariablen im Test setzen, so wie das Deployment aus Task 2 es tut. +- Rook existiert im k3s-Testcluster nicht → der Test setzt `storage.bucketClaim` nicht auf `auto`, sondern legt Bucket und Secret direkt in MinIO an und referenziert sie; die Rook-Integration ist eigener Scope und in `OperatorIntegrationTest` bereits abgedeckt. +- Der Hosting-Pod braucht einen Ingress-Controller → im Test gegen den `Service` prüfen statt gegen die Ingress-URL; `status.ready` ist das Signal, nicht die externe Erreichbarkeit. + +- [ ] **Schritt 5: Test läuft grün, reproduzierbar** + +Run: `./gradlew :operator:integrationTest --tests '*FullPipelineIntegrationTest*'` (zweimal hintereinander) +Expected: beide Male PASS. Ein E2E-Test, der nur beim ersten Lauf grün ist, hat Zustandsreste und ist nicht fertig. + +- [ ] **Schritt 6: Sicherstellen, dass er nicht im PR-Build landet** + +Run: `./gradlew :operator:test --tests '*FullPipeline*' 2>&1 | grep -c 'No tests found'` +Expected: `1` — der Test greift die `*IntegrationTest`-Namenskonvention und ist damit aus `test` ausgeschlossen. + +- [ ] **Schritt 7: Commit** + +```bash +git add operator/src/test/java/net/onelitefeather/apus/operator/FullPipelineIntegrationTest.java +git commit -m "test: cover the full ingest, render and hosting pipeline on k3s" +``` + +--- + +### Task 9: Design-Spec nachziehen + +**Files:** +- Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` + +- [ ] **Schritt 1: §13.1 als umgesetzt kennzeichnen** + +Der Abschnitt beschreibt Metriken, Logs und Dashboards im Futur. Umschreiben auf den Ist-Zustand, mit den echten Dateinamen (`deploy/base/servicemonitor-*.yaml`, `deploy/dashboards/*.json`) und den tatsächlich exportierten Metriknamen. + +- [ ] **Schritt 2: §13.2, Zeile „E2E", auf den neuen Test verweisen** + +Ersetzen durch: `k3s + S3: kompletter Durchlauf Ingest → Render → Hosting mit Mini-Welt (`FullPipelineIntegrationTest`, Teil von `./gradlew :operator:integrationTest`)`. + +- [ ] **Schritt 3: §0 um den Deployment-Stand ergänzen** + +```markdown +**Ausrollbar seit Phase 8.** `deploy/base` ist eine vollständige Kustomize-Basis +(CRDs, Operator, API, UI, RBAC, Scrape-Konfiguration); cluster-spezifische Werte kommen +aus einem Overlay im Cluster-Repository. Operator und API exportieren Metriken, zwei +Grafana-Dashboards liegen unter `deploy/dashboards`. Was offen bleibt, sind die +inhaltlichen Härtungen aus §15 — siehe den Plan zu Phase 9. +``` + +- [ ] **Schritt 4: Lint und Commit** + +Run: `npx markdownlint-cli2 docs/superpowers/specs/2026-08-08-apus-design.md` +Expected: keine Fehler. + +```bash +git add docs/superpowers/specs/2026-08-08-apus-design.md +git commit -m "docs: record the phase 8 deployment and observability state" +``` + +--- + +## Was dieser Plan bewusst nicht abdeckt + +- **Das Flux-Overlay selbst.** Es gehört ins Cluster-Repository (`Kubernetes-FLUX`), nicht hierher: Registry-Hostnamen, Rook-Namen, Domains und Secret-Referenzen sind Cluster-Eigenschaften, keine Projekt-Eigenschaften. `deploy/base` ist so geschnitten, dass ein Overlay genau diese Werte patchen kann. +- **Alerting-Regeln.** Sinnvoll, aber sie brauchen erst Betriebserfahrung mit den neuen Metriken — Schwellwerte ohne Datengrundlage erzeugen nur Rauschen. +- **Die Härtungen aus §15** (Identity-Broker, RBAC-Verengung, Quota-Signal, Paper-Save-Fenster, `emptyDir`-Grenze) — eigener Plan (Phase 9). diff --git a/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md b/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md new file mode 100644 index 0000000..753ee41 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md @@ -0,0 +1,782 @@ +# Apus Phase 9 — Produktionshärte: Implementierungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die fünf Punkte abarbeiten, die die Design-Spec in §15 selbst als ungeklärt führt — damit ein produktiver Betrieb nicht auf Heuristiken, ungetesteten Annahmen und zu breiten Berechtigungen steht. + +**Architecture:** Fünf voneinander unabhängige Härtungen am bestehenden Code. Zwei davon ersetzen Rateverfahren durch Verträge (Quota-Exit-Code, Push-Token-Lookup), zwei schließen Testlücken gegen echte Fremdsysteme (Identity-Broker, Paper-Server), eine ist eine Messung, deren Ergebnis einen Default in der CR festlegt. + +**Tech Stack:** Java 25, JOSDK 5.5.1, Micronaut Security, Testcontainers (Keycloak, k3s, MinIO), MockBukkit, Bash (Runner-Entrypoint). + +## Global Constraints + +- **Java-Toolchain 25**, AGPL-Lizenzheader über jede neue Java-Datei, Spotless erzwingt ihn. +- **Neue Abhängigkeiten kommen in den Inline-Version-Catalog** in `settings.gradle.kts`, mit Kommentar, wogegen die Version geprüft wurde. +- **Credentials und Token erscheinen nie in CR-Status, Events, Logs oder Metriken** (Design-Spec §12). +- **Der Zeitvergleich in `FabricPushTokenRepository` bleibt konstant-zeitig und erschöpfend.** Er vergleicht heute per `MessageDigest.isEqual` gegen *jeden* Kandidaten ohne früh zurückzukehren — beides ist Absicht (Timing-Leck über Token-Präfix bzw. über die Anzahl existierender Secrets) und darf durch Task 2 nicht verlorengehen. +- **Die Tasks sind unabhängig** und können in beliebiger Reihenfolge oder parallel ausgeführt werden. Einzige Ausnahme: Task 2 sollte vor einem produktiven Ausrollen der Manifeste aus Phase 8 fertig sein, weil deren API-`ClusterRole` heute den weiten Zugriff festschreibt. + +--- + +### Task 1: Belastbares Quota-Signal aus dem Runner + +**Offener Punkt §15.7.** `BlueMapRenderReconciler` erkennt ein erschöpftes Speicherkontingent heute daran, dass die Terminierungsmeldung des Pods bestimmte Zeichenketten enthält (`UNAMBIGUOUS_QUOTA_TOKENS`, plus „quota" in Verbindung mit `bucket`/`rgw`/`ceph`). Das Kubelet-Vokabular enthält „quota" nie, und die Meldung ist ein Log-Ausschnitt ohne Vertrag. + +**Files:** +- Modify: `runner/entrypoint.sh` +- Modify: `runner/README.md` (Exit-Code-Tabelle) +- Modify: `operator/src/main/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconciler.java` +- Modify: `operator/src/test/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconcilerTest.java` + +**Interfaces:** +- Produces: Exit-Code `6` des Runner-Containers als Vertrag „Speicherkontingent erschöpft". Die bestehende `quotaExceededMessage(Pod)`-Heuristik bleibt als Fallback erhalten, wird aber nachrangig. + +- [ ] **Schritt 1: Feststellen, wo der Quota-Fehler tatsächlich auftritt** + +Run: `grep -n 'exit\|bluemap' runner/entrypoint.sh` +Expected: die Stelle, an der der BlueMap-CLI-Aufruf endet und sein Exit-Code ausgewertet wird. + +Run: `grep -rn 'QuotaExceeded\|quota' runner/bin/*.sh runner/README.md` +Expected: heute nichts. Damit ist belegt, dass der Runner das Signal derzeit nirgends erzeugt — genau die Lücke aus §15.7. + +Der Fehler entsteht beim Schreiben in den Map-Bucket, also innerhalb von BlueMap über `BlueMapS3Storage`, nicht im `bundle-sync` (der liest). Er erscheint folglich in BlueMaps Ausgabe, nicht als eigener Prozess-Exit. + +- [ ] **Schritt 2: Failing test auf Reconciler-Seite schreiben** + +In `BlueMapRenderReconcilerTest`: + +```java +@Test +void treatsExitCode6AsAStorageQuotaFailure() { + // Exit code 6 is the runner's contract for "the tenant's storage quota is exhausted" + // (runner/README.md). Unlike the log-text heuristic this is a promise the image makes, + // so it must win over any message parsing. + Pod pod = podTerminatedWith(6, ""); + + Optional message = reconciler.quotaExceededMessage(pod); + + assertTrue(message.isPresent()); + assertTrue(message.get().contains("quota"), message.get()); +} + +@Test +void doesNotTreatOtherExitCodesAsQuotaFailures() { + // Exit code 3 is "bundle sync failed", 4 "bundle not found", 5 "invalid configuration". + // None of them must end the render as StorageQuotaExceeded, because all three are + // retryable and a quota failure deliberately is not. + for (int code : new int[] {1, 3, 4, 5}) { + assertTrue(reconciler.quotaExceededMessage(podTerminatedWith(code, "")).isEmpty(), "exit " + code); + } +} + +@Test +void stillFallsBackToTheMessageHeuristicForOlderRunnerImages() { + // A cluster can be running an older runner image than the operator; the heuristic + // stays as a fallback rather than being deleted. + Pod pod = podTerminatedWith(1, "software.amazon.awssdk...: QuotaExceeded"); + + assertTrue(reconciler.quotaExceededMessage(pod).isPresent()); +} +``` + +`podTerminatedWith(int exitCode, String message)` als Hilfsmethode ergänzen, die einen `Pod` mit `status.containerStatuses[0].state.terminated.exitCode` und `.message` baut — analog zu den bereits vorhandenen Pod-Fixtures der Klasse. + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag bestätigen** + +Run: `./gradlew :operator:test --tests '*BlueMapRenderReconcilerTest*'` +Expected: FAIL — der erste Test, weil Exit-Code 6 heute nichts bedeutet. + +- [ ] **Schritt 4: Reconciler anpassen** + +`quotaExceededMessage(Pod)` prüft zuerst den Exit-Code: + +```java +/** Exit code the runner image uses for "the tenant's storage quota is exhausted". */ +public static final int RUNNER_EXIT_QUOTA_EXCEEDED = 6; +``` + +und liefert bei `exitCode == 6` unmittelbar eine Meldung zurück, ohne Textanalyse. Erst danach greift die bestehende Musterprüfung. Den Klassen-Javadoc (Zeilen 82–89) entsprechend aktualisieren: Das Verfahren ist ab jetzt vertragsbasiert mit Heuristik als Rückfallebene, nicht mehr umgekehrt. + +- [ ] **Schritt 5: Tests grün** + +Run: `./gradlew :operator:test --tests '*BlueMapRenderReconcilerTest*'` +Expected: PASS + +- [ ] **Schritt 6: Runner den Exit-Code tatsächlich setzen lassen** + +In `runner/entrypoint.sh` die BlueMap-Ausgabe mitschreiben und nach dem Lauf auswerten: + +```bash +# BlueMap exits non-zero for every failure alike. A storage-quota failure, though, must not +# be retried (design spec §12) -- so it gets its own exit code rather than being inferred +# from log text by the operator. The patterns are S3 error codes RGW returns once a user +# quota is exhausted; they are matched only when BlueMap itself failed. +readonly EXIT_QUOTA_EXCEEDED=6 + +java -jar /opt/bluemap/cli.jar "${bluemap_args[@]}" 2>&1 | tee /tmp/bluemap.log +bluemap_status="${PIPESTATUS[0]}" + +if [ "$bluemap_status" -ne 0 ] \ + && grep -qiE 'quotaexceeded|exceededquota|quota.*(bucket|rgw|ceph)' /tmp/bluemap.log; then + echo "storage quota exhausted while writing the map output" >&2 + exit "$EXIT_QUOTA_EXCEEDED" +fi + +exit "$bluemap_status" +``` + +Der genaue Einbau richtet sich nach der in Schritt 1 gefundenen Stelle; die Bedingung „nur wenn BlueMap ohnehin fehlgeschlagen ist" ist wesentlich, sonst kippt ein Render, der das Wort nur beiläufig geloggt hat. + +- [ ] **Schritt 7: Exit-Code-Verhalten des Skripts prüfen** + +```bash +docker build -f runner/Dockerfile -t apus-runner:quota-test . +docker run --rm --entrypoint bash apus-runner:quota-test -c ' + echo "software.amazon.awssdk: QuotaExceeded" > /tmp/bluemap.log + if grep -qiE "quotaexceeded|exceededquota|quota.*(bucket|rgw|ceph)" /tmp/bluemap.log; then exit 6; fi + exit 0' +echo "exit=$?" +``` + +Expected: `exit=6` + +- [ ] **Schritt 8: `runner/README.md` um die Exit-Code-Tabelle ergänzen** + +| Code | Bedeutung | Wiederholbar | +|---|---|---| +| 0 | Render erfolgreich | — | +| 1 | Allgemeiner Fehler | ja | +| 3 | Bundle-Sync fehlgeschlagen | ja | +| 4 | Bundle oder Manifest nicht gefunden | nein | +| 5 | Ungültige Konfiguration | nein | +| 6 | Speicherkontingent erschöpft | **nein** | + +- [ ] **Schritt 9: Commit** + +```bash +git add runner/entrypoint.sh runner/README.md operator/src/main/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconciler.java operator/src/test/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconcilerTest.java +git commit -m "feat: give the runner a dedicated exit code for exhausted storage quota" +``` + +--- + +### Task 2: Push-Token-Lookup ohne clusterweites Secret-Leserecht + +**Offener Punkt §15.9.** `FabricPushTokenRepository#resolveNamespace` sucht per Label über alle Namespaces. RBAC kann einen Label-Filter nicht einschränken, also braucht die API heute `get`/`list` auf **alle** Secrets im Cluster. Der Klassen-Javadoc skizziert den schmaleren Weg bereits — er wurde nur nicht umgesetzt. + +**Files:** +- Modify: `api/src/main/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepository.java` +- Modify: `api/src/test/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepositoryTest.java` +- Modify: `deploy/base/api-rbac.yaml` (aus Phase 8, Task 3) + +**Interfaces:** +- Consumes: `PushTokenSecrets.SECRET_NAME` (fester Name), `TenantRepository` (listet die cluster-scoped `Tenant`-Ressourcen), `TenantReconciler.namespaceFor(...)` (Namespace-Konvention). +- Produces: unverändert `Optional resolveNamespace(String rawToken)` — die Signatur bleibt, nur der Weg dahinter ändert sich. + +- [ ] **Schritt 1: Bestehende Zusicherungen der Klasse dokumentiert festhalten** + +Run: `sed -n '60,140p' api/src/main/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepository.java` +Expected: Der Javadoc beschreibt drei Eigenschaften, die erhalten bleiben müssen: konstantzeitiger Vergleich über `MessageDigest.isEqual`, erschöpfende Prüfung ohne frühen Ausstieg, und dass ein Fehlschlag keinen Hinweis auf existierende Mandanten gibt. + +Run: `grep -c '@Test' api/src/test/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepositoryTest.java` +Expected: eine Zahl > 0. Diese Tests sind die Absicherung des Umbaus — sie müssen nach dem Umbau unverändert grün sein. + +- [ ] **Schritt 2: Failing test für den neuen Zugriffsweg schreiben** + +```java +@Test +void readsOnlyTheFixedNameSecretInTenantNamespaces() { + // The whole point of the rewrite: no cluster-wide secret listing. The fake client + // records every request; a `list` on secrets means the RBAC grant could not be + // narrowed and the change failed its purpose. + server.expect() + .get() + .withPath("/apis/bluemap.onelitefeather.net/v1alpha1/tenants") + .andReturn(200, tenantList("friends-server", "other-server")) + .always(); + server.expect() + .get() + .withPath("/api/v1/namespaces/bluemap-friends-server/secrets/" + PushTokenSecrets.SECRET_NAME) + .andReturn(200, secretWithToken("the-token")) + .always(); + server.expect() + .get() + .withPath("/api/v1/namespaces/bluemap-other-server/secrets/" + PushTokenSecrets.SECRET_NAME) + .andReturn(404, null) + .always(); + + assertEquals(Optional.of("bluemap-friends-server"), repository.resolveNamespace("the-token")); + + assertTrue( + server.getRequestCount() > 0 + && requestPaths(server).stream().noneMatch(p -> p.matches("/api/v1/secrets.*")), + "must not list secrets cluster-wide"); +} + +@Test +void keepsCheckingEveryTenantAfterAMatch() { + // Stopping at the first match would leak, through response timing, how many tenants + // exist before the caller's own -- the property the current implementation protects. + // Three tenants, the match sitting on the first one: all three secrets must still + // have been fetched. + server.expect() + .get() + .withPath("/apis/bluemap.onelitefeather.net/v1alpha1/tenants") + .andReturn(200, tenantList("a-server", "b-server", "c-server")) + .always(); + expectSecret("bluemap-a-server", "the-token"); + expectSecret("bluemap-b-server", "other-token"); + expectSecret("bluemap-c-server", "third-token"); + + assertEquals(Optional.of("bluemap-a-server"), repository.resolveNamespace("the-token")); + + List paths = requestPaths(server); + for (String namespace : List.of("bluemap-a-server", "bluemap-b-server", "bluemap-c-server")) { + assertTrue( + paths.contains("/api/v1/namespaces/" + namespace + "/secrets/" + PushTokenSecrets.SECRET_NAME), + "did not read the secret in " + namespace + "; the scan returned early"); + } +} + +@Test +void returnsEmptyForAnUnknownTokenWithoutRevealingTenants() { + assertEquals(Optional.empty(), repository.resolveNamespace("wrong-token")); +} + +@Test +void toleratesATenantWithoutAPushTokenSecret() { + // A tenant that never had a service token issued yields 404 on the get; that is a + // normal state, not an error, and must not abort the scan for the remaining tenants. + server.expect() + .get() + .withPath("/apis/bluemap.onelitefeather.net/v1alpha1/tenants") + .andReturn(200, tenantList("no-token-server", "friends-server")) + .always(); + server.expect() + .get() + .withPath("/api/v1/namespaces/bluemap-no-token-server/secrets/" + PushTokenSecrets.SECRET_NAME) + .andReturn(404, null) + .always(); + expectSecret("bluemap-friends-server", "the-token"); + + assertEquals(Optional.of("bluemap-friends-server"), repository.resolveNamespace("the-token")); +} +``` + +- [ ] **Schritt 3: Test laufen lassen und Fehlschlag bestätigen** + +Run: `./gradlew :api:test --tests '*FabricPushTokenRepositoryTest*'` +Expected: FAIL — die neuen Tests, weil die Implementierung noch labelbasiert clusterweit sucht. + +- [ ] **Schritt 4: `resolveNamespace` umbauen** + +Neuer Ablauf: die cluster-scoped `Tenant`-Ressourcen listen, für jeden den Namespace über die bestehende Konvention bilden, und dort ein `get` auf das Secret mit festem Namen absetzen. Über alle Ergebnisse erschöpfend und konstantzeitig vergleichen, wie bisher. `404` je Namespace ist ein regulärer Fall. + +Den Javadoc-Abschnitt, der den weiten Zugriff als bewusste Abwägung beschreibt, durch die Beschreibung des jetzt umgesetzten Wegs ersetzen — inklusive der RBAC-Regel, die er ermöglicht. + +- [ ] **Schritt 5: Alle Tests der Klasse grün, auch die alten** + +Run: `./gradlew :api:test --tests '*FabricPushTokenRepositoryTest*' --tests '*PushControllerTest*'` +Expected: PASS, ohne dass ein vorbestehender Test angepasst werden musste. War eine Anpassung nötig, ist das ein Signal, dass sich beobachtbares Verhalten geändert hat — dann prüfen, ob das beabsichtigt ist. + +- [ ] **Schritt 6: RBAC verengen** + +In `deploy/base/api-rbac.yaml` die weite Secret-Regel ersetzen: + +```yaml + # Service-token lookup, narrowed in phase 9: the API only ever reads the one Secret + # literally named apus-push-token, in tenant namespaces it discovers through the + # cluster-scoped Tenant resources. It can no longer read any other secret anywhere. + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["apus-push-token"] + verbs: ["get"] +``` + +Den Warnhinweis-Kommentar, der auf §15.9 verwies, entfernen. + +- [ ] **Schritt 7: Prüfen, dass `resourceNames` den tatsächlichen Secret-Namen trifft** + +Run: `grep -n 'SECRET_NAME' api/src/main/java/net/onelitefeather/apus/api/rest/push/PushTokenSecrets.java` +Expected: der Wert stimmt exakt mit `resourceNames` überein. Weicht er ab, liest die API im Cluster gar nichts mehr und jeder Push schlägt mit 403 fehl. + +- [ ] **Schritt 8: Design-Spec §15, Punkt 9 als erledigt markieren** + +```markdown +9. ~~**RBAC für den Push-Token-Lookup der API breiter als ideal.**~~ **Erledigt (Phase 9).** + `FabricPushTokenRepository#resolveNamespace` enumeriert die cluster-scoped + `Tenant`-Ressourcen und liest je Mandanten-Namespace gezielt das Secret mit festem Namen + `apus-push-token` — nie mehr `list` über alle Secrets. Die Berechtigung der API ist + entsprechend auf `resourceNames: ["apus-push-token"]`, `verbs: ["get"]` verengt. Der + konstantzeitige, erschöpfende Vergleich bleibt unverändert erhalten. +``` + +- [ ] **Schritt 9: Commit** + +```bash +git add api/ deploy/base/api-rbac.yaml docs/superpowers/specs/2026-08-08-apus-design.md +git commit -m "fix: read only the fixed-name push token secret instead of listing all secrets" +``` + +--- + +### Task 3: Identity-Broker auswählen und die Anmeldung gegen einen echten Broker prüfen + +**Offene Punkte §0 und §15.3.** Die API validiert JWTs gegen einen konfigurierbaren Issuer, aber welches Produkt davor steht, ist nicht entschieden, und ein Lauf gegen einen echten Broker hat nie stattgefunden — die Auth-Tests arbeiten mit selbst ausgestellten Test-JWTs. + +**Files:** +- Create: `docs/superpowers/specs/2026-08-12-identity-broker-entscheidung.md` +- Modify: `settings.gradle.kts` (Keycloak-Testcontainer) +- Modify: `api/build.gradle.kts` +- Create: `api/src/test/java/net/onelitefeather/apus/api/security/RealBrokerAuthIntegrationTest.java` +- Create: `api/src/test/resources/keycloak/apus-realm.json` +- Create: `api/src/test/resources/keycloak/upstream-realm.json` (spielt den eigenen IdP eines Mandanten) +- Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` + +- [ ] **Schritt 1: Entscheidungsvorlage schreiben** + +`docs/superpowers/specs/2026-08-12-identity-broker-entscheidung.md`, mit Struktur: + +- **Anforderung** aus §10.3: Organisationen mit eigenem Identity-Provider je Organisation, Einladungs-Flows, ein einziger Issuer für Apus, Organisations-Claim bestimmt den Mandanten, Rollen `platform-admin`/`tenant-owner`/`tenant-operator`/`tenant-viewer`, mandantengebundene Service-Tokens mit Scope `world:push`. + +- **K.-o.-Kriterium: Rollenvergabe je Mandant, in beiden Anmeldewegen gleichermaßen.** Ein Mandant, der seinen eigenen IdP föderiert, und ein Mandant mit lokalen Accounts im Broker müssen **dieselbe** Rollenstruktur bekommen — dieselben vier Rollen, mandantenspezifisch vergeben, im Token an derselben Stelle und in derselben Form. Nur dann kommt die API mit einer einzigen Auswertung aus, statt zwei Token-Formate unterscheiden zu müssen. Das ist die Anforderung, an der die Produktwahl hängt, und sie ist kein Selbstläufer: + - Ein Broker, dessen Rollen realm- oder mandantenweit definiert sind statt je Organisation, erzwingt eine Behelfslösung über Gruppen oder Attribute. Die ist machbar, aber sie muss dann für den föderierten und den lokalen Weg identisch aussehen — sonst trägt ein föderierter Nutzer seine Rolle in einem anderen Claim als ein lokaler. + - Bei Föderation entscheidet zusätzlich das Mapping vom fremden IdP: Rollen dürfen **nicht** aus dem föderierten Token übernommen werden, sonst bestimmt der Mandant selbst, wer bei ihm `tenant-owner` ist — und nichts hindert einen fremden IdP daran, `platform-admin` zu behaupten. Die Rolle muss im Apus-Broker vergeben und dort in den ausgestellten Token geschrieben werden. +- **Kandidaten:** Keycloak ab 26 und Zitadel — beide in §10.3 bereits genannt. Beide bringen ein Organisationskonzept mit; sie unterscheiden sich darin, wie eng Rollen an eine Organisation gebunden werden können. Genau dieser Unterschied ist am K.-o.-Kriterium zu messen, nicht aus der Produktdokumentation abzuschreiben: In Schritt 3 steht ein Test bereit, der die Frage praktisch beantwortet. +- **Bewertungskriterien**, je Kandidat zu belegen statt zu behaupten: Rollenvergabe je Organisation im föderierten *und* im lokalen Weg (K.-o., siehe oben); Organisationsmodell und dessen Abbildung auf einen Token-Claim; Föderation je Organisation; Einladungs-Flow; Service-Accounts mit engem Scope; Betriebsaufwand im bestehenden Cluster (der laut §2 bereits einen OIDC-Provider für Outline, Grafana und Dependency-Track betreibt — welcher, ist zu ermitteln und wiegt schwer); Upgrade-Pfad. +- **Entscheidung** mit Begründung. +- **Konsequenz:** der konkrete Claim-Name, aus dem der Mandant abgeleitet wird, und wie Rollen im Token erscheinen. + +Run: `gh api repos/OneLiteFeatherNET/Kubernetes-FLUX/contents --jq '.[].name' 2>/dev/null | head -30` +Expected: eine Verzeichnisliste des Cluster-Repositories. Darin nach dem heute betriebenen OIDC-Provider suchen — die Entscheidung sollte ihn schwer gewichten, weil ein zweiter Broker im selben Cluster dauerhaft Betriebsaufwand ist. + +- [ ] **Schritt 2: Testcontainer in den Katalog aufnehmen** + +```kotlin +// Keycloak Testcontainer: proves the auth path against a real broker rather than +// self-issued test JWTs (design spec §15, point 3). Version verified against Maven +// Central on 2026-08-12. +version("keycloak-testcontainer", "3.7.0") +library("testcontainers.keycloak", "com.github.dasniko", "testcontainers-keycloak") + .versionRef("keycloak-testcontainer") +``` + +In `api/build.gradle.kts`: `testImplementation(libs.testcontainers.keycloak)`. + +Fällt die Entscheidung in Schritt 1 auf Zitadel, tritt an diese Stelle dessen Container-Image über `GenericContainer`; der Rest des Tasks bleibt unverändert, weil beide OIDC sprechen. + +- [ ] **Schritt 3: Failing test schreiben** + +```java +package net.onelitefeather.apus.api.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +// ... weitere Importe + +/** + * Proves the authentication path end to end against a real identity broker. + * + *

Every other auth test in this module signs its own JWTs, which means they prove that + * the code accepts tokens it minted itself -- not that it accepts the tokens the broker in + * front of Apus actually issues. Discovery, JWKS rotation, claim shape and audience + * handling are exactly the parts a self-signed token cannot exercise. + * + *

Named {@code *IntegrationTest} so it stays out of the pull-request build; it needs + * Docker. + */ +class RealBrokerAuthIntegrationTest { + + static KeycloakContainer keycloak = new KeycloakContainer() + .withRealmImportFile("keycloak/apus-realm.json"); + + @Test + void acceptsATokenIssuedByTheBrokerAndDerivesTheTenantFromIt() { + String token = obtainToken("friends-user", "secret"); + + HttpResponse response = get("/api/sources", token); + + assertEquals(200, response.statusCode()); + // The tenant comes from the organisation claim, never from the request. + assertTrue(response.body().contains("friends-server"), response.body()); + } + + @Test + void rejectsATokenFromADifferentIssuer() { + // A token that is structurally valid and correctly signed, but by someone else. + assertEquals(401, get("/api/sources", foreignIssuerToken()).statusCode()); + } + + @Test + void rejectsAnExpiredToken() { + assertEquals(401, get("/api/sources", expiredToken()).statusCode()); + } + + @Test + void mapsBrokerRolesOntoApusRoles() { + // platform-admin may list tenants; tenant-viewer may not. + assertEquals(200, get("/api/tenants", obtainToken("platform-admin-user", "secret")).statusCode()); + assertEquals(403, get("/api/tenants", obtainToken("friends-viewer", "secret")).statusCode()); + } + + @Test + void grantsTheSameRoleStructureToLocalAndFederatedUsers() { + // The core requirement: a tenant with its own federated IdP and a tenant with local + // broker accounts must yield the same four roles, tenant-scoped, in the same claim. + // If these two tokens differ in shape, the API needs two evaluation paths -- which + // is exactly what the product choice is meant to avoid. + String local = obtainToken("friends-operator", "secret"); // local account + String federated = obtainFederatedToken("other-operator", "secret"); // via the org's IdP + + assertEquals(rolesOf(local), rolesOf(federated), "role claim differs between login paths"); + assertEquals(Set.of("tenant-operator"), rolesOf(local)); + + // Same role, same permissions, different tenants. + assertEquals(200, post("/api/maps/survival-overworld/render", local).statusCode()); + assertEquals(200, post("/api/maps/other-overworld/render", federated).statusCode()); + } + + @Test + void scopesRolesToTheOwnTenantOnly() { + // A tenant-owner of one tenant is nobody in another. Without per-tenant role + // scoping, an owner role granted anywhere would be an owner role everywhere. + String friendsOwner = obtainToken("friends-owner", "secret"); + + assertEquals(200, get("/api/sources", friendsOwner).statusCode()); + // The other tenant's map is treated as non-existent, not as forbidden -- otherwise + // the API is a directory of foreign tenants (design spec §11.1). + assertEquals(404, post("/api/maps/other-overworld/render", friendsOwner).statusCode()); + } + + @Test + void ignoresRolesClaimedByAFederatedIdentityProvider() { + // The federated IdP is controlled by the tenant. If Apus took roles from the + // upstream token, a tenant could declare itself platform-admin. Roles must come + // from the Apus broker's own grant, never from the federated assertion. + String token = obtainFederatedTokenClaiming("other-operator", "secret", "platform-admin"); + + assertEquals(Set.of("tenant-operator"), rolesOf(token), "upstream role claim leaked through"); + assertEquals(403, get("/api/tenants", token).statusCode()); + } + + @Test + void survivesAKeyRotation() { + // The JWKS endpoint is re-fetched rather than cached forever: after the broker + // rotates its signing key, previously working tokens fail and new ones work. + String beforeRotation = obtainToken("friends-user", "secret"); + assertEquals(200, get("/api/sources", beforeRotation).statusCode()); + + rotateRealmSigningKey(); + + assertEquals(401, get("/api/sources", beforeRotation).statusCode()); + assertEquals(200, get("/api/sources", obtainToken("friends-user", "secret")).statusCode()); + } +} +``` + +Dazu zwei Realm-Definitionen unter `api/src/test/resources/keycloak/`: + +- `apus-realm.json` — der Apus-Realm: zwei Organisationen (`friends-server`, `other-server`), die vier Rollen, und die lokalen Testnutzer `platform-admin-user`, `friends-owner`, `friends-operator`, `friends-viewer`. `other-server` bekommt **keine** lokalen Nutzer; seine Mitglieder kommen aus der Föderation. +- `upstream-realm.json` — spielt den eigenen IdP des Mandanten `other-server`. Er enthält `other-operator` und ist im Apus-Realm als Identity-Provider der Organisation `other-server` eingetragen. Genau diese Föderationsstrecke ist der zweite Anmeldeweg, den das K.-o.-Kriterium verlangt. + +Beide Realms laufen im selben Keycloak-Container — ein zweiter Container wäre realistischer, kostet aber Laufzeit ohne die Frage besser zu beantworten: Für Apus ist der Upstream ohnehin nur ein OIDC-Endpunkt. + +`rolesOf(String token)` dekodiert den Token und liefert die Apus-Rollen als `Set` — an der Stelle, an der der Produktivcode sie liest, damit der Test nicht seine eigene Auswertung mitbringt und dabei am Code vorbeitestet. + +- [ ] **Schritt 4: Test laufen lassen und Fehlschlag bestätigen** + +Run: `./gradlew :api:integrationTest --tests '*RealBrokerAuthIntegrationTest*'` +Expected: FAIL. Existiert im `api`-Modul noch kein `integrationTest`-Task, ihn im selben Muster wie in `operator/build.gradle.kts` anlegen (Ausschluss von `**/*IntegrationTest.class` aus `test`, eigener Task mit denselben Source-Sets). + +- [ ] **Schritt 5: Auth-Konfiguration so lange anpassen, bis der Test grün ist** + +Erwartbare Befunde — jeder davon ist ein echter Fund, den die Test-JWTs bisher verdeckt haben: Der Claim-Name für die Organisation weicht vom angenommenen ab; die Rollen stecken verschachtelt in `realm_access.roles` statt flach in `roles`; die Audience-Prüfung ist zu lax oder zu streng; die JWKS-URL wird nur einmal geholt. Jeder dieser Punkte wird in `api/src/main/java/net/onelitefeather/apus/api/security/` behoben, nicht im Test weggemappt. + +**Der wahrscheinlichste harte Befund betrifft `grantsTheSameRoleStructureToLocalAndFederatedUsers`.** Wenn die Rollen des gewählten Brokers realm- statt organisationsweit definiert sind, lässt sich „`tenant-operator` bei `friends-server`, aber nirgends sonst" nicht direkt ausdrücken. Zwei Auswege, in dieser Reihenfolge zu prüfen: + +1. **Der Broker kann es nativ** — Rollen bzw. Grants je Organisation. Dann ist nichts zu tun außer sie so anzulegen. +2. **Der Broker kann es nicht** — dann Rollen als Gruppen der Form `:` modellieren und über einen Protocol-Mapper in einen flachen Claim schreiben. Der Mapper muss für lokale und föderierte Nutzer **derselbe** sein; nur so bleibt der Token in beiden Wegen gleich geformt. Diese Behelfslösung gehört dann ausdrücklich in die Entscheidungsvorlage aus Schritt 1 und in §10.3 der Spec — sie ist Betriebswissen, das sonst nur im Realm-Export steht. + +Fällt der Broker in Fall 2, ist das ein starkes Argument für den jeweils anderen Kandidaten. Der Test ist der Ort, an dem sich das entscheidet, bevor Betriebsaufwand entsteht. + +- [ ] **Schritt 6: Tests grün** + +Run: `./gradlew :api:test :api:integrationTest` +Expected: BUILD SUCCESSFUL + +- [ ] **Schritt 7: Spec nachziehen** + +§0 und §15 Punkt 3: Produktwahl eintragen, den Verweis auf „nie gegen einen echten Broker getestet" streichen und durch den Test verweisen. + +§10.3 um drei Angaben ergänzen, die dort heute fehlen und ohne die niemand einen zweiten Mandanten anlegen kann: +1. Der konkrete Claim-Name, aus dem der Mandant abgeleitet wird, und die Form, in der die Rollen im Token stehen. +2. Dass die Rollenstruktur für beide Anmeldewege identisch ist — föderierter IdP des Mandanten und lokale Accounts im Broker — samt der Modellierung, die das erreicht (nativ organisationsgebundene Rollen oder die Gruppen-Behelfslösung aus Schritt 5). +3. Dass Rollen **niemals** aus einem föderierten Token übernommen werden, sondern ausschließlich im Apus-Broker vergeben werden. Das ist keine Feinheit, sondern die Grenze, ab der ein Mandant sich sonst selbst zum `platform-admin` erklären könnte. + +- [ ] **Schritt 8: Commit** + +```bash +git add docs/superpowers/specs/ settings.gradle.kts api/ +git commit -m "feat: verify the auth path against a real identity broker" +``` + +--- + +### Task 4: Das Save-Fenster von `paper-worldpush` prüfen + +**Offener Punkt §15.8.** `BukkitSaveCoordinator` pausiert das Autosave und erzwingt einen Save, bevor kopiert wird. Ob das kurze Fenster auf einem laufenden Server einen konsistenten Snapshot liefert, wurde nie geprüft — es existiert nur Unit-Abdeckung für Kopierlogik, Konfiguration und den HTTP-Report-Weg. + +**Files:** +- Modify: `settings.gradle.kts` (MockBukkit) +- Modify: `paper-worldpush/build.gradle.kts` +- Create: `paper-worldpush/src/test/java/net/onelitefeather/apus/paper/BukkitSaveCoordinatorTest.java` +- Create: `paper-worldpush/src/test/java/net/onelitefeather/apus/paper/PushCycleConsistencyTest.java` +- Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` + +- [ ] **Schritt 1: Die tatsächliche Reihenfolge im Code feststellen** + +Run: `cat paper-worldpush/src/main/java/net/onelitefeather/apus/paper/BukkitSaveCoordinator.java` +Expected: die exakte Abfolge aus `disableAutoSave()`, `save()`/`forceSave()` und Wiederaktivierung, sowie auf welchem Thread sie läuft. Der Test muss genau diese Abfolge prüfen, nicht eine vermutete. + +Run: `grep -n 'SaveCoordinator\|copier' paper-worldpush/src/main/java/net/onelitefeather/apus/paper/PushCycleRunner.java` +Expected: wo der Koordinator im Zyklus aufgerufen wird — das ist die Naht, an der die Konsistenzfrage hängt. + +- [ ] **Schritt 2: MockBukkit aufnehmen** + +```kotlin +// MockBukkit: the design spec (§13.2) called for it from the start; without it +// BukkitSaveCoordinator -- the one class that talks to the running server -- has no test +// at all. Version must match the Paper API generation this module builds against. +version("mockbukkit", "4.62.2") +library("mockbukkit", "org.mockbukkit.mockbukkit", "mockbukkit-v1.21").versionRef("mockbukkit") +``` + +Die passende Artefakt- und Versionskombination gegen die im Katalog gepinnte Paper-API prüfen: + +Run: `grep -n 'paper' settings.gradle.kts | head` +Expected: die Paper-API-Version. MockBukkits Artefaktname trägt die Minecraft-Generation im Namen; sie muss dazu passen, sonst startet der Mock-Server mit einer Registry-Fehlermeldung. + +- [ ] **Schritt 3: Failing test für den Koordinator schreiben** + +```java +/** + * Covers the save window itself -- the one part of paper-worldpush that talks to a running + * server and, until now, had no test at all (design spec §15, point 8). + */ +class BukkitSaveCoordinatorTest { + + ServerMock server; + World world; + List calls; + + @BeforeEach + void setUp() { + server = MockBukkit.mock(); + world = server.addSimpleWorld("world"); + // MockBukkit's World does not record the call order by itself; a recording wrapper + // is what turns "both happened" into "they happened in this order". + calls = new ArrayList<>(); + } + + @AfterEach + void tearDown() { + MockBukkit.unmock(); + } + + @Test + void disablesAutoSaveBeforeForcingASave() { + // Order matters: forcing a save while autosave is still running can interleave two + // writers on the same region file, which is precisely the corruption this class exists + // to prevent. + world.setAutoSave(true); + + new BukkitSaveCoordinator(server).withSaveWindow(world, () -> calls.add("copy")); + + assertEquals(List.of("autoSaveOff", "save", "copy", "autoSaveOn"), calls); + } + + @Test + void restoresAutoSaveEvenWhenTheCopyFails() { + // A push that throws must not leave the server with autosave permanently off -- + // that would silently stop persisting player progress. + world.setAutoSave(true); + + assertThrows( + IllegalStateException.class, + () -> new BukkitSaveCoordinator(server).withSaveWindow(world, () -> { + throw new IllegalStateException("copy failed"); + })); + + assertTrue(world.isAutoSave(), "autosave stayed off after a failed push"); + } + + @Test + void restoresTheOriginalAutoSaveStateRatherThanForcingItOn() { + // A server that deliberately runs with autosave disabled must stay that way. + world.setAutoSave(false); + + new BukkitSaveCoordinator(server).withSaveWindow(world, () -> {}); + + assertFalse(world.isAutoSave(), "autosave was switched on by a push"); + } + + @Test + void runsTheSaveOnTheMainThread() { + // Bukkit's world save API is main-thread only; calling it from the async push + // thread throws at runtime on a real server but silently passes against a mock + // that does not enforce it -- so assert the thread explicitly. + AtomicReference saveThread = new AtomicReference<>(); + world.setAutoSave(true); + + CompletableFuture + .runAsync(() -> new BukkitSaveCoordinator(server) + .withSaveWindow(world, () -> saveThread.set(Thread.currentThread()))) + .join(); + + assertTrue( + server.isOnMainThread(saveThread.get()), + "the save ran on " + saveThread.get() + " instead of the server main thread"); + } +} +``` + +- [ ] **Schritt 4: Fehlschlag bestätigen** + +Run: `./gradlew :paper-worldpush:test --tests '*BukkitSaveCoordinatorTest*'` +Expected: FAIL + +- [ ] **Schritt 5: Konsistenztest über den ganzen Zyklus** + +`PushCycleConsistencyTest`: MockBukkit-Server mit einer Welt, während des Kopierens werden Region-Dateien fortlaufend verändert. Geprüft wird, dass das kopierte Ergebnis dem Stand zum Zeitpunkt des erzwungenen Saves entspricht und keine halb geschriebene Region enthält. + +Dieser Test kann echte Befunde produzieren. Findet er Inkonsistenzen, ist das **das erwartete Ergebnis dieses Tasks**, nicht sein Scheitern: §15.8 fragt genau danach. Der Fund gehört dann als eigener Abschnitt in die Spec und die Behebung in einen Folge-Task — nicht durch Abschwächen der Assertion aus der Welt geschafft. + +- [ ] **Schritt 6: Tests grün, Befunde dokumentiert** + +Run: `./gradlew :paper-worldpush:test` +Expected: BUILD SUCCESSFUL — bzw. ein dokumentierter, in der Spec festgehaltener Befund. + +- [ ] **Schritt 7: Spec nachziehen** + +§13.2 (Zeile `paper-worldpush`), §15 Punkt 8 und §0: den „Offen"-Vermerk durch das Testergebnis ersetzen. Bleibt der Lauf gegen einen echten Paper-Server unter Last aus (MockBukkit ersetzt ihn nicht vollständig), muss das ausdrücklich stehen bleiben — mit der Angabe, was MockBukkit abdeckt und was nicht. + +- [ ] **Schritt 8: Commit** + +```bash +git add settings.gradle.kts paper-worldpush/ docs/superpowers/specs/2026-08-08-apus-design.md +git commit -m "test: cover the paper-worldpush save window with MockBukkit" +``` + +--- + +### Task 5: Die `emptyDir`-Grenze messen + +**Offener Punkt §15.6.** „`emptyDir` genügt bis zu einer Größe, die von der Node-Ausstattung abhängt; darüber ist ein PVC nötig." Die Grenze wurde nie gemessen, obwohl sie laut Spec vor Phase 2 nachzuholen war — und der Operator legt heute trotzdem einen Default fest. + +**Files:** +- Create: `docs/superpowers/spikes/2026-08-12-emptydir-grenze.md` +- Create: `docs/superpowers/spikes/2026-08-12-emptydir-grenze/run-spike.sh` +- Modify: `operator/src/main/java/net/onelitefeather/apus/operator/render/RenderJobBuilder.java` +- Modify: `operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java` +- Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` + +- [ ] **Schritt 1: Den heutigen Default feststellen** + +Run: `grep -n -B3 -A10 'emptyDir\|EmptyDir' operator/src/main/java/net/onelitefeather/apus/operator/render/RenderJobBuilder.java` +Expected: der aktuell erzeugte Volume-Typ und, falls vorhanden, ein `sizeLimit`. Das ist der Wert, den die Messung bestätigen oder widerlegen soll. + +- [ ] **Schritt 2: Messskript schreiben** + +`run-spike.sh` im Stil des vorhandenen Sharding-Spikes (`docs/superpowers/spikes/2026-08-09-lowres-sharding-spike/run-spike.sh` als Vorlage lesen). Es fährt gegen einen Cluster: +1. Node-Ausstattung erheben: `kubectl get nodes -o json | jq '.items[].status.allocatable["ephemeral-storage"]'`. +2. Render-Jobs mit `emptyDir` und wachsenden Welt-Größen starten (1, 5, 10, 20, 40 GiB Bundle). +3. Je Lauf festhalten: Erfolg/Misserfolg, Grund bei Misserfolg (`Evicted` mit `ephemeral-storage`-Bezug ist der gesuchte Fall), Spitzenverbrauch über `kubectl top pod`. +4. Die kleinste Größe ermitteln, bei der ein Lauf durch Eviction scheitert. + +- [ ] **Schritt 3: Messung durchführen und Bericht schreiben** + +`2026-08-12-emptydir-grenze.md` nach dem Muster des Sharding-Spike-Berichts: Aufbau, Messwerte als Tabelle, Rohdaten unter `evidence/`, Auswertung, Entscheidung. + +Die Auswertung muss beantworten: Ab welcher Bundle-Größe reicht `emptyDir` auf der real vorhandenen Node-Ausstattung nicht mehr? Welcher Default folgt daraus? Ab welcher Größe soll der Operator automatisch auf ein PVC wechseln — oder soll er es nie automatisch tun und stattdessen ein Feld in der CR verlangen? + +- [ ] **Schritt 4: Failing test für das Ergebnis schreiben** + +Sobald die Entscheidung feststeht, in `RenderJobBuilderTest`: + +`MEASURED_LIMIT_GIB` ist der in Schritt 3 gemessene Wert; er wird als benannte Konstante in `RenderJobBuilder` geführt, damit im Test und im Produktivcode derselbe Wert steht. + +```java +@Test +void usesAPersistentVolumeClaimForBundlesAboveTheMeasuredLimit() { + // The limit comes from the phase 9 spike (docs/superpowers/spikes/2026-08-12-emptydir-grenze.md), + // not from a guess: below it emptyDir is faster and cheaper, above it the pod gets + // evicted mid-render. + long aboveLimit = (RenderJobBuilder.EMPTY_DIR_LIMIT_BYTES) + 1; + + Job job = builder.build(renderWithBundleSize(aboveLimit), mapFixture(), configFixture()); + + Volume volume = workVolumeOf(job); + assertNotNull(volume.getPersistentVolumeClaim(), "expected a PVC above the measured limit"); + assertNull(volume.getEmptyDir()); +} + +@Test +void usesEmptyDirBelowTheLimit() { + long belowLimit = RenderJobBuilder.EMPTY_DIR_LIMIT_BYTES - 1; + + Job job = builder.build(renderWithBundleSize(belowLimit), mapFixture(), configFixture()); + + Volume volume = workVolumeOf(job); + assertNotNull(volume.getEmptyDir(), "expected an emptyDir below the measured limit"); + assertNull(volume.getPersistentVolumeClaim()); +} + +@Test +void setsASizeLimitOnTheEmptyDirSoAnOverrunEvictsPredictably() { + // Without sizeLimit an overrunning pod can fill the node's disk and take unrelated + // workloads down with it. + Job job = builder.build(renderWithBundleSize(1_000_000L), mapFixture(), configFixture()); + + assertNotNull(workVolumeOf(job).getEmptyDir().getSizeLimit(), "emptyDir has no sizeLimit"); +} +``` + +`renderWithBundleSize(long)` baut einen `BlueMapRender`, dessen referenziertes Bundle-Manifest die angegebene `sizeBytes` trägt (Feld aus Design-Spec §5), `workVolumeOf(Job)` liest das Volume heraus, auf das der `bluemap`-Container mountet. Beide als Hilfsmethoden der Testklasse ergänzen, im Stil der dort bereits vorhandenen Fixtures. + +- [ ] **Schritt 5: `RenderJobBuilder` anpassen und Tests grün bekommen** + +Run: `./gradlew :operator:test --tests '*RenderJobBuilderTest*'` +Expected: PASS + +- [ ] **Schritt 6: Spec nachziehen** + +§15 Punkt 6 durch das Messergebnis ersetzen, mit Verweis auf den Spike-Bericht — im selben Stil, in dem §14 Phase 4 auf den Sharding-Spike verweist. §7.1 („`emptyDir` (oder PVC bei großen Welten)") um die konkrete Grenze ergänzen. + +- [ ] **Schritt 7: Commit** + +```bash +git add docs/superpowers/spikes/2026-08-12-emptydir-grenze* operator/ docs/superpowers/specs/2026-08-08-apus-design.md +git commit -m "feat: pick the render volume type from a measured limit instead of a guess" +``` + +--- + +## Reihenfolge und Abhängigkeiten + +| Task | Blockiert von | Kann parallel zu | +|---|---|---| +| 1 — Quota-Exit-Code | — | 2, 3, 4, 5 | +| 2 — Push-Token-RBAC | Phase 8 Task 3 (die Datei, die verengt wird) | 1, 3, 4, 5 | +| 3 — Identity-Broker | — (die Produktentscheidung in Schritt 1 ist der einzige Blocker) | 1, 2, 4, 5 | +| 4 — Save-Fenster | — | 1, 2, 3, 5 | +| 5 — `emptyDir`-Grenze | Zugang zu einem Cluster mit realistischer Node-Ausstattung | 1, 2, 3, 4 | + +Task 3 und Task 5 tragen echte Unsicherheit: Beide können ein Ergebnis liefern, das Folgearbeit auslöst — ein Claim-Format, das die Rollenabbildung ändert, oder eine Grenze, die einen PVC-Pfad im Operator nötig macht, den es heute nicht gibt. Das ist kein Planungsfehler, sondern der Grund, warum diese Punkte offen sind. + +## Was dieser Plan bewusst nicht abdeckt + +- **§15 Punkt 5 (`render-mask` und Kanten).** Er ist ausdrücklich nur relevant, falls in Phase 4 der Maskenweg gewählt worden wäre. Nach der Absage an Sharding (§14, Phase 4) hat er keinen Gegenstand mehr und sollte in der Spec als gegenstandslos markiert statt abgearbeitet werden. +- **§15 Punkt 2 (Bucket-Notifications als zweiter Erkennungsweg).** Die Spec führt ihn selbst nicht mehr als offen, sondern als mögliche spätere Härtung für den Fall, dass ein Schreiber seinen Completion-Callback verliert. Das ist ein eigenes Feature, keine Härtung des Bestehenden. +- **Eine CI-Matrix über mehrere BlueMap-Versionen.** Braucht zuerst einen parametrierbaren Contract-Test; siehe den Abschluss des Phase-7-Plans. From 8e71ffea7e69e0c0f103d0f85f275e25ae079f71 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 09:22:05 +0200 Subject: [PATCH 02/18] docs: add a root README with module overview and build instructions --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..96d51b0 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Apus + +Apus rendert Minecraft-Welten mit [BlueMap](https://bluemap.bluecolored.de/) auf Kubernetes +und hostet die Ergebnisse. Welt-Daten kommen aus mehreren, sehr unterschiedlichen Quellen; +ein ETL-Layer normalisiert sie, ein Operator führt Render- und Hosting-Jobs aus, eine +Oberfläche zeigt Fortschritt und erlaubt Bedienung ohne YAML. + +Das vollständige Design steht in +[`docs/superpowers/specs/2026-08-08-apus-design.md`](docs/superpowers/specs/2026-08-08-apus-design.md). + +## Module + +| Modul | Zweck | Auslieferung | +|---|---|---| +| `telemetry-addon` | BlueMap-Addon, exponiert Render-Fortschritt als JSON und Prometheus-Metriken | Maven | +| `ingest` | ETL: Connectoren (s3, pterodactyl, push, upload), Layout-Erkennung, Bundle-Writer | Container-Image | +| `runner` | BlueMap-CLI plus beide Addons, rendert eine Welt aus S3 nach S3 | Container-Image | +| `hosting` | Langlebiger BlueMap-Webserver, liest gerenderte Karten aus S3 | Container-Image | +| `operator` | Kubernetes-Operator, sechs CRDs, erzeugt Jobs/Deployments/Ingresses/Buckets | Container-Image | +| `api` | Micronaut-REST/SSE über den Custom Resources, Durchsetzungspunkt für Auth | Container-Image | +| `ui` | Nuxt-4-Dashboard für Mandanten und Plattform-Betreiber | Container-Image | +| `paper-worldpush` | Paper-Plugin, schiebt Welten vom laufenden Server nach Apus | Maven | + +## Bauen + +Voraussetzungen: JDK 25, Docker (für Integrationstests), pnpm (für `ui`). + + ./gradlew build # alle Java-Module, ohne Integrationstests + ./gradlew integrationTest # braucht Docker + ./gradlew :operator:generateCrds # erzeugt die sechs CRD-YAMLs nach operator/build/crds + + cd ui && pnpm install && pnpm test && pnpm lint + +## Entwicklung + +Der Kern des Systems ist das **World Bundle** — eine unveränderliche, normalisierte +Momentaufnahme einer Welt in S3. Links davon (Ingest) weiß niemand etwas von BlueMap, +rechts davon (Render, Hosting) niemand etwas von Pterodactyl oder ZIP-Uploads. Wer eine +neue Welt-Quelle anbindet, implementiert nur `WorldSourceConnector` in `ingest`. + +Commits folgen [Conventional Commits](https://www.conventionalcommits.org/) — Release +Please leitet daraus Version und Changelog ab. + +## Lizenz + +AGPL-3.0, siehe [LICENSE](LICENSE). From 66fb3eae96f5280d92d58d39d46805645a7a53f6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 09:27:50 +0200 Subject: [PATCH 03/18] ci: adopt the central OneLiteFeather Renovate preset --- renovate.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..b9a507f --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>OneLiteFeatherNET/renovate:default(OneLiteFeatherNET/apus-maintainers)", + "github>OneLiteFeatherNET/renovate:paper" + ] +} From 2d44ae33a84a1d8be30d8b638d40acfd3e0f9834 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 09:34:44 +0200 Subject: [PATCH 04/18] chore: manage versions and changelogs with release-please --- .github/workflows/release-please.yml | 24 +++++++++++++++++++++ .release-please-manifest.json | 5 +++++ CHANGELOG.md | 0 build.gradle.kts | 8 +++++++ gradle.properties | 1 - paper-worldpush/build.gradle.kts | 2 ++ release-please-config.json | 32 ++++++++++++++++++++++++++++ telemetry-addon/build.gradle.kts | 2 ++ 8 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 release-please-config.json diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..3da0685 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,24 @@ +name: release-please + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + root-released: ${{ steps.release.outputs['.--release_created'] }} + root-version: ${{ steps.release.outputs['.--version'] }} + telemetry-released: ${{ steps.release.outputs['telemetry-addon--release_created'] }} + paper-released: ${{ steps.release.outputs['paper-worldpush--release_created'] }} + steps: + - id: release + uses: googleapis/release-please-action@v5 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..9976a24 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,5 @@ +{ + ".": "0.1.0", + "telemetry-addon": "0.1.0", + "paper-worldpush": "0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/build.gradle.kts b/build.gradle.kts index adf4c64..b37df0b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,10 +2,18 @@ plugins { alias(libs.plugins.spotless) apply false } +version = "0.1.0" // x-release-please-version + subprojects { apply(plugin = "java") apply(plugin = "com.diffplug.spotless") + // telemetry-addon and paper-worldpush carry their own release track (design spec §4) + // and set their own version; every other module ships as part of the project as a whole. + if (name != "telemetry-addon" && name != "paper-worldpush") { + version = rootProject.version + } + extensions.configure { toolchain { languageVersion.set(JavaLanguageVersion.of(25)) diff --git a/gradle.properties b/gradle.properties index dbacd84..0b010d7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,3 @@ group = net.onelitefeather.apus -version = 999.0.0 org.gradle.caching = true org.gradle.parallel = true diff --git a/paper-worldpush/build.gradle.kts b/paper-worldpush/build.gradle.kts index dc072b3..188e838 100644 --- a/paper-worldpush/build.gradle.kts +++ b/paper-worldpush/build.gradle.kts @@ -2,6 +2,8 @@ plugins { alias(libs.plugins.shadow) } +version = "0.1.0" // x-release-please-version + dependencies { // Paper API only, never paper-server/paper-mojangapi -- a plugin compiles against the API // surface and runs inside whatever Paper build the operator actually deployed. See diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..726df1e --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": true, + "include-v-in-tag": true, + "separate-pull-requests": true, + "bootstrap-sha": "33487090", + "pull-request-header": "", + "packages": { + ".": { + "package-name": "apus", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "build.gradle.kts" } + ] + }, + "telemetry-addon": { + "package-name": "telemetry-addon", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "telemetry-addon/build.gradle.kts" } + ] + }, + "paper-worldpush": { + "package-name": "paper-worldpush", + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { "type": "generic", "path": "paper-worldpush/build.gradle.kts" } + ] + } + } +} diff --git a/telemetry-addon/build.gradle.kts b/telemetry-addon/build.gradle.kts index eb3dcdd..fcd38da 100644 --- a/telemetry-addon/build.gradle.kts +++ b/telemetry-addon/build.gradle.kts @@ -2,6 +2,8 @@ plugins { alias(libs.plugins.shadow) } +version = "0.1.0" // x-release-please-version + dependencies { compileOnly(libs.bluemap.api) compileOnly(libs.bluemap.core) From b17bbcc4e83ff6c1023ba254dc9888ff4d0257d4 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 09:42:32 +0200 Subject: [PATCH 05/18] ci: build and test Gradle modules and the UI on pull requests --- .github/workflows/build-pr.yml | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/build-pr.yml diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml new file mode 100644 index 0000000..6a7c6f3 --- /dev/null +++ b/.github/workflows/build-pr.yml @@ -0,0 +1,39 @@ +name: build-pr + +on: + pull_request: + branches: [main] + +jobs: + gradle: + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-build-pr.yml@v2.4.0 + with: + java-version: "25" + java-distribution: "temurin" + paths-filters: | + code: + - '**/*.java' + - '**/*.kts' + - '**/*.properties' + - 'gradle/**' + - 'gradlew' + - '.spotless/**' + secrets: inherit + + ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ui + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: pnpm + cache-dependency-path: ui/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm lint + - run: pnpm typecheck + - run: pnpm test From 495fc3c2a8aec6d8a46d43f4203eeb194b2872d5 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 09:50:52 +0200 Subject: [PATCH 06/18] ci: widen build-pr code filter and pin ui Node version to .nvmrc --- .github/workflows/build-pr.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 6a7c6f3..58871d0 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -18,6 +18,9 @@ jobs: - 'gradle/**' - 'gradlew' - '.spotless/**' + - '**/src/**/resources/**' + - '**/entrypoint.sh' + - '**/bin/*.sh' secrets: inherit ui: @@ -30,7 +33,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v5 with: - node-version: "22" + node-version-file: ui/.nvmrc cache: pnpm cache-dependency-path: ui/pnpm-lock.yaml - run: pnpm install --frozen-lockfile From 8f383c3d9c1588d1628a9a4fc8548b8f5b8ae7a7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 10:11:33 +0200 Subject: [PATCH 07/18] ci: lint markdown and close pull requests from fork default branches --- .github/workflows/close-invalid-prs.yml | 10 +++++++ .github/workflows/markdown-lint.yml | 12 ++++++++ .markdownlint-cli2.jsonc | 18 ++++++++++++ .../task-3-report.md | 1 + .../2026-08-09-phase-6-push/final-report.md | 8 ++--- .../2026-08-09-phase-6-push/task-2-report.md | 2 +- README.md | 2 +- .../plans/2026-08-08-phase-1-render-kern.md | 25 ++++++++++++++-- .../2026-08-08-phase-2a-operator-render.md | 29 +++++++++++++++++-- .../plans/2026-08-08-phase-2b-ingest.md | 13 +++++++-- .../plans/2026-08-09-phase-3-hosting.md | 11 +++++-- .../plans/2026-08-09-phase-5a-api.md | 6 ++-- .../2026-08-12-phase-7-ci-und-auslieferung.md | 18 +++++++++++- ...12-phase-8-deployment-und-observability.md | 19 ++++++++++++ .../2026-08-12-phase-9-produktionshaerte.md | 13 +++++++-- .../specs/2026-08-08-apus-design.md | 27 ++++++++--------- .../2026-08-09-lowres-sharding-spike.md | 8 ++--- hosting/README.md | 2 +- ingest/README.md | 4 +-- runner/README.md | 4 +-- testdata/README.md | 2 +- ui/README.md | 4 +-- 22 files changed, 191 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/close-invalid-prs.yml create mode 100644 .github/workflows/markdown-lint.yml create mode 100644 .markdownlint-cli2.jsonc diff --git a/.github/workflows/close-invalid-prs.yml b/.github/workflows/close-invalid-prs.yml new file mode 100644 index 0000000..1867f7d --- /dev/null +++ b/.github/workflows/close-invalid-prs.yml @@ -0,0 +1,10 @@ +name: close-invalid-prs + +on: + pull_request_target: + types: [opened] + +jobs: + close: + uses: OneLiteFeatherNET/workflows/.github/workflows/close-invalid-prs.yml@v2.4.0 + secrets: inherit diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml new file mode 100644 index 0000000..a1a1362 --- /dev/null +++ b/.github/workflows/markdown-lint.yml @@ -0,0 +1,12 @@ +name: markdown-lint + +on: + pull_request: + branches: [main] + paths: + - '**/*.md' + +jobs: + lint: + uses: OneLiteFeatherNET/workflows/.github/workflows/markdown-lint.yml@v2.4.0 + secrets: inherit diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..a216361 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,18 @@ +{ + "config": { + // The design spec and the plans use long prose lines; wrapping them would make + // diffs unreadable. + "MD013": false, + // Release Please writes the changelog; its heading structure is not ours to police. + "MD024": { "siblings_only": true }, + // SDD task briefs and reports under .superpowers/sdd/ are excerpts of a larger phase + // plan and intentionally start at the same heading level (###) they have there; + // requiring a top-level H1 would misrepresent their place in that hierarchy. + "MD041": false + }, + "ignores": [ + "**/node_modules/**", + "**/build/**", + "CHANGELOG.md" + ] +} diff --git a/.superpowers/sdd/2026-08-09-phase-3-hosting/task-3-report.md b/.superpowers/sdd/2026-08-09-phase-3-hosting/task-3-report.md index 09c7652..6ab855c 100644 --- a/.superpowers/sdd/2026-08-09-phase-3-hosting/task-3-report.md +++ b/.superpowers/sdd/2026-08-09-phase-3-hosting/task-3-report.md @@ -69,6 +69,7 @@ WorldIngest, BlueMapHosting) — confirms `Certificate` was not picked up by the ## File restriction compliance Only these files were created/modified: + - `operator/src/main/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilder.java` (new) - `operator/src/main/java/net/onelitefeather/apus/operator/hosting/Certificate.java` (new) - `operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java` (new) diff --git a/.superpowers/sdd/2026-08-09-phase-6-push/final-report.md b/.superpowers/sdd/2026-08-09-phase-6-push/final-report.md index 856c4eb..794e931 100644 --- a/.superpowers/sdd/2026-08-09-phase-6-push/final-report.md +++ b/.superpowers/sdd/2026-08-09-phase-6-push/final-report.md @@ -30,8 +30,8 @@ already resolves a token to a *namespace*, not a source, which only makes sense reading. - New `PushTokenSecrets` (operator, package `tenant`) is the single canonical definition of the - Secret shape (label, data key, fixed name `apus-push-token`, `generate()` using `SecureRandom` - + URL-safe base64, 256 bits). + Secret shape (label, data key, fixed name `apus-push-token`, `generate()` using `SecureRandom` + + URL-safe base64, 256 bits). - `TenantReconciler` creates this Secret once per tenant, alongside the namespace. Critically, it is **never regenerated** on later reconciles (no `createOr(update)` here) — a fresh random value on every resync would silently invalidate whatever `paper-worldpush` was already @@ -47,8 +47,8 @@ exists anywhere in this repo to hang it on): `FabricPushTokenRepository`'s Javad that its current `list()`-by-label lookup, unavoidably, needs `get`/`list` on **all** Secrets cluster-wide (Kubernetes RBAC cannot filter by label) — broader than ideal — and documents the concrete narrower alternative (enumerate tenants via the already-listable `Tenant` CR, then `get` -the fixed-name Secret per namespace, letting RBAC restrict to `resourceNames: ["apus-push-token"]` -+ `get` only) as a deliberate follow-up, not implemented now to avoid an invasive rewrite of +the fixed-name Secret per namespace, letting RBAC restrict to `resourceNames: ["apus-push-token"]` + +`get` only) as a deliberate follow-up, not implemented now to avoid an invasive rewrite of already-tested code under this task's scope. Flagged as a concern below and as open item 9 in the spec. diff --git a/.superpowers/sdd/2026-08-09-phase-6-push/task-2-report.md b/.superpowers/sdd/2026-08-09-phase-6-push/task-2-report.md index 98c4b3f..8443cbb 100644 --- a/.superpowers/sdd/2026-08-09-phase-6-push/task-2-report.md +++ b/.superpowers/sdd/2026-08-09-phase-6-push/task-2-report.md @@ -83,7 +83,7 @@ The one endpoint in the module that is **not** JWT-authenticated ## Which upload restrictions are actually enforced — the honest answer | Restriction | Status | How it was verified | -|---|---|---| +| --- | --- | --- | | **Confined to the caller's own tenant prefix** | **Enforced, structurally.** | `stagingKey` is a pure function of a server-derived namespace; unit-tested with adversarial input. S3 has no `..`-traversal semantics, so there is no string a caller can supply that escapes the prefix. | | **A presigned part URL can't be redirected to a different key** | **Enforced, confirmed against real MinIO.** | `MultipartUploadServiceIntegrationTest.aPresignedPartUrlCannotBeRedirectedToADifferentTenantsKey` swaps the tenant segment in a legitimate presigned URL and gets HTTP 403 from MinIO — SigV4 signs the exact key. | | **A part can't carry more bytes than it was sized for** | **Enforced, confirmed against real MinIO (2026-08-09).** | `Content-Length` is set on each presigned `UploadPartRequest`; AWS SDK v2 includes it among that URL's signed headers. Sending more bytes than declared gets HTTP 403 `SignatureDoesNotMatch` from MinIO before the extra bytes are accepted — I drove a real oversized `PUT` against a real MinIO instance rather than trusting SDK documentation (which does not state this explicitly). **Caveat**: verified against MinIO specifically, not independently re-verified against Ceph RGW (the actual production backend per design spec §9.1). Both implement SigV4 presigned-URL validation the same way, so I expect the same result, but that is an inference from one data point, not a second measurement. | diff --git a/README.md b/README.md index 96d51b0..5b459af 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Das vollständige Design steht in ## Module | Modul | Zweck | Auslieferung | -|---|---|---| +| --- | --- | --- | | `telemetry-addon` | BlueMap-Addon, exponiert Render-Fortschritt als JSON und Prometheus-Metriken | Maven | | `ingest` | ETL: Connectoren (s3, pterodactyl, push, upload), Layout-Erkennung, Bundle-Writer | Container-Image | | `runner` | BlueMap-CLI plus beide Addons, rendert eine Welt aus S3 nach S3 | Container-Image | diff --git a/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md b/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md index 3324159..d3b12eb 100644 --- a/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md +++ b/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md @@ -111,7 +111,7 @@ force-path-style: true ## File Structure -``` +```text Apus/ ├── settings.gradle.kts Module + Inline-Version-Catalog ├── build.gradle.kts Root: Spotless, Toolchain für alle Module @@ -174,7 +174,7 @@ Apus/ Aufgaben mit gleicher Gruppe können gleichzeitig von verschiedenen Agenten bearbeitet werden: | Gruppe | Aufgaben | Voraussetzung | -|---|---|---| +| --- | --- | --- | | A | Task 1 | — | | B | Task 2, Task 3, Task 4 | Task 1 | | C | Task 5, Task 6 | Task 2–4 | @@ -188,6 +188,7 @@ Task 2 (Snapshot + JSON), Task 3 (Probe) und Task 4 (HTTP-Server) berühren getr ### Task 1: Monorepo-Grundgerüst **Files:** + - Create: `settings.gradle.kts` - Create: `build.gradle.kts` - Create: `gradle.properties` @@ -198,6 +199,7 @@ Task 2 (Snapshot + JSON), Task 3 (Probe) und Task 4 (HTTP-Server) berühren getr - Create: `telemetry-addon/src/test/java/net/onelitefeather/apus/telemetry/BuildSetupTest.java` **Interfaces:** + - Consumes: nichts - Produces: Version-Catalog-Aliase `libs.bluemap.api`, `libs.bluemap.core`, `libs.bluemap.common`, `libs.junit.bom`, `libs.junit.jupiter`, `libs.junit.platform.launcher`, `libs.testcontainers.bom`, `libs.testcontainers.junit`, `libs.testcontainers.minio`, `libs.plugins.spotless`, `libs.plugins.shadow`. Modulname `:telemetry-addon`. @@ -446,6 +448,7 @@ git commit -m "build: set up Apus gradle monorepo with telemetry-addon module" ### Task 2: ProgressSnapshot und Serialisierung **Files:** + - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/ProgressSnapshot.java` - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/Numbers.java` - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/JsonWriter.java` @@ -456,8 +459,10 @@ git commit -m "build: set up Apus gradle monorepo with telemetry-addon module" - Test: `telemetry-addon/src/test/java/net/onelitefeather/apus/telemetry/PrometheusWriterTest.java` **Interfaces:** + - Consumes: nichts aus anderen Aufgaben - Produces: + ```java public record ProgressSnapshot( State state, // RENDERING, IDLE, STARTING, UNKNOWN @@ -922,6 +927,7 @@ git commit -m "feat(telemetry): add progress snapshot model with json and promet ### Task 3: Progress-Probe mit BlueMap-Zugriff **Files:** + - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/probe/RenderManagerAccess.java` - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/probe/RenderProgressProbe.java` - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/probe/BlueMapRenderManagerAccess.java` @@ -929,8 +935,10 @@ git commit -m "feat(telemetry): add progress snapshot model with json and promet - Test: `telemetry-addon/src/test/java/net/onelitefeather/apus/telemetry/probe/RenderProgressProbeTest.java` **Interfaces:** + - Consumes: `ProgressSnapshot` aus Task 2 (inklusive `unknown(String)` und `idle(int, int)`) - Produces: + ```java public interface RenderManagerAccess { boolean isRunning(); @@ -1308,13 +1316,16 @@ git commit -m "feat(telemetry): read render progress through a single blueMap se ### Task 4: HTTP-Server **Files:** + - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/TelemetryServer.java` - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/TelemetryConfig.java` - Test: `telemetry-addon/src/test/java/net/onelitefeather/apus/telemetry/TelemetryServerTest.java` **Interfaces:** + - Consumes: `ProgressSnapshot`, `JsonWriter`, `PrometheusWriter` aus Task 2 - Produces: + ```java public record TelemetryConfig(String bindAddress, int port, boolean enabled) { public static TelemetryConfig fromEnvironment(Function env); @@ -1632,11 +1643,13 @@ git commit -m "feat(telemetry): serve progress over http as json and prometheus ### Task 5: Addon-Entrypoint **Files:** + - Create: `telemetry-addon/src/main/java/net/onelitefeather/apus/telemetry/ApusTelemetryAddon.java` - Create: `telemetry-addon/src/main/resources/bluemap.addon.json` - Test: `telemetry-addon/src/test/java/net/onelitefeather/apus/telemetry/AddonManifestTest.java` **Interfaces:** + - Consumes: `TelemetryConfig`, `TelemetryServer` (Task 4), `RenderProgressProbe`, `BlueMapRenderManagerAccess` (Task 3) - Produces: Die Klasse `net.onelitefeather.apus.telemetry.ApusTelemetryAddon` als Addon-Entrypoint, referenziert in `bluemap.addon.json` @@ -1803,6 +1816,7 @@ git commit -m "feat(telemetry): add bluemap addon entrypoint and manifest" ### Task 6: Runner-Image **Files:** + - Create: `runner/Dockerfile` - Create: `runner/entrypoint.sh` - Create: `runner/bin/render-config.sh` @@ -1810,11 +1824,12 @@ git commit -m "feat(telemetry): add bluemap addon entrypoint and manifest" - Create: `runner/README.md` **Interfaces:** + - Consumes: `apus-telemetry-addon-.jar` aus Task 5 - Produces: Image `apus/runner:dev` mit folgendem Vertrag über Umgebungsvariablen: | Variable | Pflicht | Bedeutung | -|---|---|---| +| --- | --- | --- | | `APUS_MAP_ID` | ja | Map-Id, z.B. `overworld` | | `APUS_DIMENSION` | ja | `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end` | | `APUS_MC_VERSION` | ja | z.B. `1.21.10` | @@ -2118,6 +2133,7 @@ git commit -m "feat(runner): add container image running bluemap cli with apus a ### Task 7: Integrationstest gegen MinIO **Files:** + - Create: `testdata/README.md` - Create: `testdata/mini-world/` (Fixture) - Create: `runner/build.gradle.kts` @@ -2125,6 +2141,7 @@ git commit -m "feat(runner): add container image running bluemap cli with apus a - Modify: `settings.gradle.kts` (Modul `runner` ergänzen) **Interfaces:** + - Consumes: Image `apus/runner:dev` aus Task 6, der Umgebungsvariablen-Vertrag aus Task 6 - Produces: Bestätigung, dass `storage-type: "themeinerlp:s3"` und die kebab-case-Schlüssel korrekt sind; korrigiert bei Abweichung Task 6 **und** die Spec @@ -2375,10 +2392,12 @@ git commit -m "test(runner): verify end-to-end render from s3 to s3 against mini ### Task 8: Telemetrie im echten Render nachweisen **Files:** + - Modify: `runner/src/test/java/net/onelitefeather/apus/runner/RenderEndToEndTest.java` - Create: `runner/src/test/java/net/onelitefeather/apus/runner/TelemetryContractTest.java` **Interfaces:** + - Consumes: alles aus Task 7 - Produces: Der Nachweis, dass `/progress` während eines echten Renders belastbare Werte liefert — der Contract-Test, der ein BlueMap-Upgrade auffliegen lässt diff --git a/docs/superpowers/plans/2026-08-08-phase-2a-operator-render.md b/docs/superpowers/plans/2026-08-08-phase-2a-operator-render.md index 1322190..c60938b 100644 --- a/docs/superpowers/plans/2026-08-08-phase-2a-operator-render.md +++ b/docs/superpowers/plans/2026-08-08-phase-2a-operator-render.md @@ -83,7 +83,7 @@ Für beide CRDs gibt es keine fertigen Java-Modelle. Wir definieren schlanke eig ## File Structure -``` +```text operator/ ├── build.gradle.kts JOSDK, CRD-Generierung, Micronaut └── src/ @@ -122,7 +122,7 @@ Logik, die sie nutzt, in derselben Aufgabe stecken, hängt alles an allem — si vorgezogen, berühren die drei Folgeaufgaben komplett getrennte Dateien. | Gruppe | Aufgaben | Ausführung | -|---|---|---| +| --- | --- | --- | | A | Task 1 — Modul und CRD-Generierung | sequenziell (Fundament) | | B | Task 2 — vollständiges Datenmodell | sequenziell (alle bauen darauf) | | C | Task 3, Task 4, Task 5 | **parallel**, je eigener Worktree | @@ -136,6 +136,7 @@ müssten beide gegen Schnittstellen programmieren, die sich noch ändern — die fräße den Zeitgewinn wieder auf. **Dateien der parallelen Gruppe C** (nachweislich disjunkt): + - Task 3: `tenant/TenantReconciler.java` + zugehöriger Test - Task 4: `map/BucketProvisioner.java`, `map/BlueMapConfigBuilder.java` + Tests - Task 5: `render/RenderJobBuilder.java` + Test @@ -147,6 +148,7 @@ Keine der drei Aufgaben ändert eine Datei einer anderen oder die Build-Dateien. ### Task 1: Operator-Modul und CRD-Generierung **Files:** + - Modify: `settings.gradle.kts` (Modul `operator` und neue Katalog-Einträge) - Create: `operator/build.gradle.kts` - Create: `operator/src/main/java/net/onelitefeather/apus/operator/api/Tenant.java` (Minimalfassung, damit es etwas zu generieren gibt) @@ -155,6 +157,7 @@ Keine der drei Aufgaben ändert eine Datei einer anderen oder die Build-Dateien. - Test: `operator/src/test/java/net/onelitefeather/apus/operator/CrdGenerationTest.java` **Interfaces:** + - Consumes: nichts - Produces: Katalog-Aliase `libs.josdk`, `libs.josdk.junit`, `libs.crd.generator.api.v2`, `libs.crd.generator.collector`, `libs.fabric8.junit`; Gradle-Task `generateCrds`, die YAML nach `operator/build/crds/` schreibt; die Klasse `net.onelitefeather.apus.operator.api.Tenant` @@ -363,10 +366,12 @@ application { ``` > **Zu verifizieren in Step 5:** Der Hauptklassenname des Generator-CLI (`io.fabric8.crdv2.generator.cli.CRDGeneratorCLI`) und seine Argumentnamen stammen aus der Recherche, nicht aus einer Ausführung. Stimmt der Aufruf nicht, ermittle die echte Einstiegsklasse aus dem Jar und korrigiere Plan und Build: +> > ```bash > ./gradlew :operator:dependencies --configuration crdGenerator | grep crd-generator > unzip -l ~/.gradle/caches/modules-2/files-2.1/io.fabric8/crd-generator-api-v2/7.8.0/*/crd-generator-api-v2-7.8.0.jar | grep -iE "cli|Main" > ``` +> > Alternativ funktioniert immer der programmatische Weg: eine kleine Java-Klasse im `buildSrc` oder eine `JavaExec`-Task auf eine eigene Generator-Hauptklasse, die `new CRDGenerator().customResourceClasses(...).inOutputDir(dir).detailedGenerate()` aufruft. Wähle den Weg, der real funktioniert, und dokumentiere ihn. - [ ] **Step 4: Den fehlschlagenden Test schreiben** @@ -486,6 +491,7 @@ Alle Klassen hier sind reine Datenhalter ohne Kubernetes-Zugriff und ohne Logik. zugleich die Schnittstelle, die Phase 5 (API und UI) später wiederverwendet. **Files:** + - Create: `operator/src/main/java/net/onelitefeather/apus/operator/rook/ObjectBucketClaim.java` - Create: `operator/src/main/java/net/onelitefeather/apus/operator/rook/ObjectBucketClaimSpec.java` - Create: `operator/src/main/java/net/onelitefeather/apus/operator/rook/ObjectBucketClaimStatus.java` @@ -503,8 +509,10 @@ zugleich die Schnittstelle, die Phase 5 (API und UI) später wiederverwendet. - Modify: `operator/src/test/java/net/onelitefeather/apus/operator/CrdGenerationTest.java` (Zusicherungen für die beiden neuen CRDs) **Interfaces:** + - Consumes: `Tenant` und die CRD-Generierung aus Task 1 - Produces: + ```java // Beide sind namespaced. ObjectBucketClaim: spec.bucketName, spec.storageClassName, @@ -978,12 +986,15 @@ git commit -m "feat(operator): add the full apus and rook data model" > lege sie nicht erneut an und ändere sie nicht. **Files:** + - Create: `operator/src/main/java/net/onelitefeather/apus/operator/tenant/TenantReconciler.java` - Test: `operator/src/test/java/net/onelitefeather/apus/operator/tenant/TenantReconcilerTest.java` **Interfaces:** + - Consumes (alle aus Task 2 bzw. 1, unverändert zu benutzen): `Tenant`, `TenantSpec`, `TenantStatus`, `CephObjectStoreUser`, `Conditions.ready(...)`, `Conditions.set(...)`, `OperatorConfig.defaults()` - Produces: + ```java @ControllerConfiguration public class TenantReconciler implements Reconciler { @@ -992,6 +1003,7 @@ public class TenantReconciler implements Reconciler { public static String cephUserFor(Tenant tenant); // "apus-" } ``` + Der Reconciler erzeugt aus einem `Tenant`: Namespace `bluemap-`, `ResourceQuota`, `LimitRange` und einen `CephObjectStoreUser` mit der Quota. - [ ] **Step 1: Den fehlschlagenden Test schreiben** @@ -1294,14 +1306,17 @@ git commit -m "feat(operator): reconcile tenants into namespaces with quotas" > Berühre keine Datei aus Task 3 (`tenant/`) oder Task 5 (`render/`). **Files:** + - Create: `operator/src/main/java/net/onelitefeather/apus/operator/map/BucketProvisioner.java` - Create: `operator/src/main/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilder.java` - Test: `operator/src/test/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilderTest.java` - Test: `operator/src/test/java/net/onelitefeather/apus/operator/map/BucketProvisionerTest.java` **Interfaces:** + - Consumes: `ObjectBucketClaim` (Task 2), `OperatorConfig` (Task 3) - Produces: + ```java BlueMapMapSpec: source{sourceRef,world,dimension}, trigger{onNewBundle,schedule,concurrencyPolicy}, bluemap{version,configOverrides}, storage{bucketClaim,prefix}, @@ -1619,12 +1634,15 @@ git commit -m "feat(operator): provision map buckets through rook and build blue > befüllt; für deinen Test setzt du die Werte selbst. **Files:** + - Create: `operator/src/main/java/net/onelitefeather/apus/operator/render/RenderJobBuilder.java` - Test: `operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java` **Interfaces:** + - Consumes (alle aus Task 2, unverändert): `BlueMapMap`, `BlueMapRender`, `OperatorConfig` - Produces: + ```java public final class RenderJobBuilder { public static Job build(BlueMapRender render, BlueMapMap map, @@ -1757,14 +1775,17 @@ git commit -m "feat(operator): build render jobs against the phase 1 env contrac ### Task 6: Render-Reconciler mit Fortschritt und Nebenläufigkeitssperre **Files:** + - Create: `operator/src/main/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconciler.java` - Create: `operator/src/main/java/net/onelitefeather/apus/operator/render/ProgressPoller.java` - Test: `operator/src/test/java/net/onelitefeather/apus/operator/render/ProgressPollerTest.java` - Test: `operator/src/test/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconcilerTest.java` **Interfaces:** + - Consumes: `RenderJobBuilder` (Task 5), `BlueMapMap` (Task 4) - Produces: + ```java public final class ProgressPoller { /** Parses the /progress payload the telemetry addon serves. */ @@ -1775,6 +1796,7 @@ public final class ProgressPoller { ``` Zwei Verhaltensweisen sind hier entscheidend und in der Spec begründet: + - **`concurrencyPolicy: Forbid` ist Default** (§7.3): Zwei gleichzeitige Renders auf denselben Map-Storage können die Karte inkonsistent hinterlassen. Der Reconciler startet keinen Job, solange ein anderer `BlueMapRender` derselben Map in einer aktiven Phase steht. - **Ein überschrittenes Speicherlimit wird nicht wiederholt** (§12): Die Condition `StorageQuotaExceeded` beendet den Render endgültig, statt endlos gegen die Wand zu laufen. @@ -1919,10 +1941,12 @@ git commit -m "feat(operator): reconcile renders with progress and a concurrency ### Task 7: Operator-Einstiegspunkt **Files:** + - Create: `operator/src/main/java/net/onelitefeather/apus/operator/ApusOperator.java` - Test: `operator/src/test/java/net/onelitefeather/apus/operator/ApusOperatorTest.java` **Interfaces:** + - Consumes: alle Reconciler - Produces: ausführbare Hauptklasse; `OperatorConfig` aus Umgebungsvariablen @@ -1985,6 +2009,7 @@ git commit -m "feat(operator): add the operator entrypoint" ### Task 8: Integrationstest gegen einen echten Cluster **Files:** + - Create: `operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java` - Modify: `operator/build.gradle.kts` (eigene `integrationTest`-Task, wie im `runner`-Modul) diff --git a/docs/superpowers/plans/2026-08-08-phase-2b-ingest.md b/docs/superpowers/plans/2026-08-08-phase-2b-ingest.md index d814b68..f1b1733 100644 --- a/docs/superpowers/plans/2026-08-08-phase-2b-ingest.md +++ b/docs/superpowers/plans/2026-08-08-phase-2b-ingest.md @@ -34,7 +34,7 @@ ## File Structure -``` +```text ingest/ neues Modul, Container-Image analog zu runner/ ├── build.gradle.kts ├── Dockerfile @@ -71,7 +71,7 @@ operator/src/main/java/net/onelitefeather/apus/operator/ Dasselbe Muster wie in Phase 2a: Datenmodell zuerst, dann berühren die Folgeaufgaben getrennte Dateien. | Gruppe | Aufgaben | Ausführung | -|---|---|---| +| --- | --- | --- | | A | Task 1 — Modul und Datenmodell | sequenziell | | B | Task 2, Task 3, Task 4 | **parallel**, je eigener Worktree | | C | Task 5 — Ingest-Einstiegspunkt und Image | sequenziell | @@ -79,6 +79,7 @@ Dasselbe Muster wie in Phase 2a: Datenmodell zuerst, dann berühren die Folgeauf | E | Task 7 — Integrationstest | sequenziell | **Dateien der parallelen Gruppe** (disjunkt): + - Task 2: `LayoutDetector.java`, `WorldLayout.java` + Tests - Task 3: `BundleManifest.java`, `BundleWriter.java`, `S3Client.java` + Tests - Task 4: `connector/*` + Tests @@ -88,6 +89,7 @@ Dasselbe Muster wie in Phase 2a: Datenmodell zuerst, dann berühren die Folgeauf ### Task 1: Modul, CRDs und gemeinsames Datenmodell **Files:** + - Modify: `settings.gradle.kts` (Modul `ingest`, Katalog-Einträge für den S3-Client) - Create: `ingest/build.gradle.kts` - Create: `operator/src/main/java/.../api/WorldSource.java`, `WorldSourceSpec.java`, `WorldSourceStatus.java` @@ -197,12 +199,13 @@ git commit -m "feat(ingest): add world source and ingest custom resources" **Das ist der inhaltliche Kern des ETL-Layers.** Die Quellen liefern unterschiedliche Verzeichnisstrukturen; BlueMap braucht pro Karte einen definierten Pfad zur richtigen Dimension. | Layout | Erkennungsmerkmal | Abbildung | -|---|---|---| +| --- | --- | --- | | `vanilla` | `/region`, `/DIM-1/region`, `/DIM1/region` | direkt | | `bukkit` | `/region`, `_nether/DIM-1/region`, `_the_end/DIM1/region` | Ordner zusammenführen | | `nested` | genau ein Unterverzeichnis, darin eines der obigen | Ebene überspringen, erneut prüfen | **Interfaces:** + ```java public record WorldLayout(String kind, Map dimensions) {} // kind: "vanilla" | "bukkit"; dimensions: "overworld"/"the_nether"/"the_end" → Pfad zum region-Verzeichnis @@ -218,6 +221,7 @@ public final class LayoutDetector { Baue die Verzeichnisstrukturen im Test mit `@TempDir` auf — keine Fixture-Dateien nötig, es geht nur um Struktur. Testfälle, jeder mit eigener Begründung im Testnamen: + - Vanilla-Layout mit allen drei Dimensionen wird erkannt und korrekt zugeordnet. - Vanilla-Layout mit **nur** Overworld wird erkannt (kein Nether, kein End — das ist normal). - Bukkit-Layout mit `world`, `world_nether`, `world_the_end` wird erkannt und auf dieselben logischen Namen abgebildet. @@ -234,6 +238,7 @@ Testfälle, jeder mit eigener Begründung im Testnamen: > Eigener Worktree. Ausschließlich `BundleManifest.java`, `BundleWriter.java`, `S3Client.java` und Tests. **Interfaces:** + ```java public record BundleManifest( int schemaVersion, String tenant, String worldId, String version, @@ -257,6 +262,7 @@ public final class BundleWriter { Damit Task 3 nicht auf Task 2 warten muss, nimmt `BundleWriter` eine schmale Schnittstelle entgegen (`WorldLayoutLike` mit `kind()` und `dimensions()`), die Task 2s Record später erfüllt. Definiere sie in deinem eigenen Paket. **Tests, die zählen:** + - Das Manifest wird **zuletzt** geschrieben — prüfe die Reihenfolge der Schreibvorgänge über einen Fake-S3-Client, der sie protokolliert. Das ist der Commit-Punkt und die wichtigste Eigenschaft des Bundles. - Bricht das Schreiben mittendrin ab, existiert **kein** Manifest, das Bundle gilt also als nicht vorhanden. - Die Regionsliste im Manifest entspricht den tatsächlich geschriebenen `.mca`-Dateien; Koordinaten werden aus dem Dateinamen `r...mca` gelesen. @@ -270,6 +276,7 @@ Damit Task 3 nicht auf Task 2 warten muss, nimmt `BundleWriter` eine schmale Sch > Eigener Worktree. Ausschließlich `connector/*` und Tests. **Interfaces:** + ```java public interface WorldSourceConnector { String type(); diff --git a/docs/superpowers/plans/2026-08-09-phase-3-hosting.md b/docs/superpowers/plans/2026-08-09-phase-3-hosting.md index c29c782..f9fca07 100644 --- a/docs/superpowers/plans/2026-08-09-phase-3-hosting.md +++ b/docs/superpowers/plans/2026-08-09-phase-3-hosting.md @@ -35,7 +35,7 @@ Aus `Kubernetes-FLUX`: Es gibt zwei IngressClasses (`nginx` und `cloudflare-tunn ## File Structure -``` +```text hosting/ neues Modul: Container-Image ├── Dockerfile ├── entrypoint.sh @@ -54,13 +54,14 @@ operator/src/main/java/net/onelitefeather/apus/operator/ ## Parallelisierung | Gruppe | Aufgaben | Ausführung | -|---|---|---| +| --- | --- | --- | | A | Task 1 — CRD und Konfigurationserzeugung | sequenziell | | B | Task 2, Task 3 | **parallel**, je eigener Worktree | | C | Task 4 — Reconciler | sequenziell | | D | Task 5 — Integrationstest | sequenziell | **Dateien der parallelen Gruppe** (disjunkt): + - Task 2: alles unter `hosting/` - Task 3: `operator/.../hosting/HostingResourceBuilder.java` + Test @@ -69,6 +70,7 @@ operator/src/main/java/net/onelitefeather/apus/operator/ ### Task 1: `BlueMapHosting` und die Konfiguration für mehrere Karten **Files:** + - Create: `operator/src/main/java/.../api/BlueMapHosting.java`, `BlueMapHostingSpec.java`, `BlueMapHostingStatus.java` - Modify: `operator/src/main/java/.../map/BlueMapConfigBuilder.java` - Test: `operator/src/test/java/.../api/HostingResourceTest.java` @@ -175,7 +177,7 @@ Analog zu `runner/`, aber im Webserver-Modus. Der Container läuft **dauerhaft** **Umgebungsvariablen-Vertrag:** | Variable | Pflicht | Bedeutung | -|---|---|---| +| --- | --- | --- | | `APUS_S3_ENDPOINT` | ja | S3-Endpunkt | | `APUS_S3_ACCESS_KEY` | ja | Zugangsschlüssel | | `APUS_S3_SECRET_KEY` | ja | Geheimer Schlüssel | @@ -185,6 +187,7 @@ Analog zu `runner/`, aber im Webserver-Modus. Der Container läuft **dauerhaft** Die Karten- und Storage-Konfiguration kommt hier **als gemountete ConfigMap** — anders als beim Render, wo Umgebungsvariablen genügen. Der Entrypoint ergänzt nur die Zugangsdaten in den Storage-Dateien, die der Operator ohne sie erzeugt hat. Achte darauf: Eine gemountete ConfigMap ist schreibgeschützt, der Entrypoint muss also in ein beschreibbares Verzeichnis kopieren, bevor er ergänzt. **Betriebsrelevant:** + - Eine Bereitschaftsprüfung muss möglich sein. Prüfe, welchen Pfad BlueMaps Webserver ausliefert, und dokumentiere ihn — der Reconciler in Task 4 braucht ihn für die Probes. - `exec` für den Hauptprozess, damit `SIGTERM` ankommt. - Nicht-root. @@ -210,6 +213,7 @@ public final class HostingResourceBuilder { ``` **Tests, die zählen:** + - Alle erzeugten Ressourcen tragen die gemeinsamen `Labels` und eine `ownerReference` auf die `BlueMapHosting`, damit Kubernetes sie aufräumt. - Zugangsdaten kommen über `secretKeyRef`, niemals als Klartext im Manifest. - Der Ingress verweist auf den Service, der Service auf die Pods, und der Ingress trägt den Hostnamen aus der Spec. @@ -226,6 +230,7 @@ public final class HostingResourceBuilder { Erzeugt aus einer `BlueMapHosting`: ConfigMap (über `BlueMapConfigBuilder.buildForHosting`), Deployment, Service, Ingress, optional Certificate. Trägt die URL in den Status ein, sobald der Ingress bereit ist. **Bindend:** + - Eigentümerprüfung über Name und UID vor jedem Schreibvorgang. - Die referenzierten Karten müssen im selben Namespace liegen und einen gebundenen Bucket im Status haben. Fehlt eine, entsteht kein Deployment, sondern eine sprechende Condition — ein Webserver, der auf einen leeren Bucket zeigt, liefert eine kaputte Seite aus. - `client.supports(Certificate.class)` prüfen, bevor cert-manager-Ressourcen angefasst werden. diff --git a/docs/superpowers/plans/2026-08-09-phase-5a-api.md b/docs/superpowers/plans/2026-08-09-phase-5a-api.md index 4b1ee89..0233cae 100644 --- a/docs/superpowers/plans/2026-08-09-phase-5a-api.md +++ b/docs/superpowers/plans/2026-08-09-phase-5a-api.md @@ -28,7 +28,7 @@ Alle Custom Resources aus den Phasen 2a, 2b und 3 unter `net.onelitefeather.apus ## Parallelisierung | Gruppe | Aufgaben | Ausführung | -|---|---|---| +| --- | --- | --- | | A | Task 1 — Modul, Auth, Mandantenauflösung | sequenziell | | B | Task 2, Task 3 | **parallel**, je eigener Worktree | | C | Task 4 — Integrationstest | sequenziell | @@ -38,6 +38,7 @@ Alle Custom Resources aus den Phasen 2a, 2b und 3 unter `net.onelitefeather.apus ### Task 1: Modul, Authentifizierung und Mandantenauflösung **Files:** + - Modify: `settings.gradle.kts` (Modul `api`, Micronaut-Einträge im Katalog) - Create: `api/build.gradle.kts` - Create: `api/src/main/java/net/onelitefeather/apus/api/security/ApusPrincipal.java` @@ -66,6 +67,7 @@ public final class TenantResolver { **Recherchiere die Micronaut-Version real** gegen Maven Central und trage sie in den Inline-Version-Catalog ein. Für die Token-Validierung genügt `micronaut-security-jwt` gegen einen konfigurierbaren Issuer — welcher Identity-Broker davorsteht, ist bewusst offen (§15 der Spec). **Tests, die den Kern absichern:** + - Ein Token ohne Mandanten-Claim führt zu einer Ablehnung, nicht zu einem Standardmandanten. - Ein `platform-admin` darf mandantenübergreifend, ein `tenant-viewer` nicht schreiben. - Der Namespace wird ausschließlich aus dem Mandanten des Tokens gebildet — es gibt keinen Pfad, über den ein Parameter ihn beeinflusst. @@ -79,7 +81,7 @@ public final class TenantResolver { Endpunkte gemäß §11.1 der Spec: | Endpunkt | Rolle | -|---|---| +| --- | --- | | `GET /api/tenants`, `POST /api/tenants` | nur `platform-admin` | | `GET /api/sources`, `POST /api/sources` | eigener Mandant | | `GET /api/maps`, `GET /api/maps/{id}` | eigener Mandant | diff --git a/docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md b/docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md index 115815f..0030410 100644 --- a/docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md +++ b/docs/superpowers/plans/2026-08-12-phase-7-ci-und-auslieferung.md @@ -14,7 +14,7 @@ - **Wiederverwendbare Workflows werden auf den vollen SemVer-Tag gepinnt** — `@v2.4.0`, niemals `@main` und niemals `@v2`. - **Kein `clean` in Gradle-Tasks der CI** — das entwertet den `setup-gradle`-Cache. - **Der Versionsmarker lebt in `build.gradle.kts`, nicht in `gradle.properties`.** Aktuell steht `version = 999.0.0` in `gradle.properties`; dieser Eintrag wird ersatzlos entfernt. -- **Dockerfiles bauen aus dem Repository-Root als Kontext** und kopieren mit modulqualifiziertem Pfad (`COPY operator/... `), genau wie `runner/Dockerfile` und `ingest/Dockerfile` es tun. +- **Dockerfiles bauen aus dem Repository-Root als Kontext** und kopieren mit modulqualifiziertem Pfad (`COPY operator/...`), genau wie `runner/Dockerfile` und `ingest/Dockerfile` es tun. - **Non-root in jedem Image:** Benutzer `apus`, uid 10001, Arbeitsverzeichnis unterhalb `/work` bzw. `/app`. - **Jar-Dateinamen sind fest** (kein Glob im `COPY`), Konvention wie `ingest`: `archiveFileName.set("apus-.jar")`. - **AGPL-Lizenzheader** über jede neue Java-Datei — Spotless erzwingt das via `.spotless/Copyright.java`. @@ -40,6 +40,7 @@ oder in Task 2 stattdessen ein bestehendes Team einzusetzen — `infrastructure- Das Repository hat keinen Einstiegspunkt. Modul-READMEs existieren für `runner`, `hosting`, `ingest`, `ui` und `testdata`, aber wer das Repository öffnet, findet keine Orientierung. **Files:** + - Create: `README.md` - [ ] **Schritt 1: README schreiben** @@ -112,9 +113,11 @@ git commit -m "docs: add a root README with module overview and build instructio ### Task 2: Renovate **Files:** + - Create: `renovate.json` **Interfaces:** + - Produces: Die Datei, über die Renovate ab jetzt auch die Workflow-Pins aus Task 4/5/9 aktualisiert. - [ ] **Schritt 1: `renovate.json` anlegen** @@ -157,6 +160,7 @@ git commit -m "ci: adopt the central OneLiteFeather Renovate preset" `gradle.properties` trägt heute `version = 999.0.0` — ein Platzhalter ohne Automatik dahinter. Release Please verlangt den Marker im jeweiligen `build.gradle.kts`. Apus bekommt drei Release-Spuren: das Gesamtprojekt (dessen Version die Container-Images tragen), `telemetry-addon` und `paper-worldpush`. **Files:** + - Modify: `gradle.properties` (Zeile `version = 999.0.0` entfernen) - Modify: `build.gradle.kts` (Versionsmarker und Weitergabe an Subprojekte) - Modify: `telemetry-addon/build.gradle.kts` (eigener Marker) @@ -167,6 +171,7 @@ git commit -m "ci: adopt the central OneLiteFeather Renovate preset" - Create: `.github/workflows/release-please.yml` **Interfaces:** + - Produces: Die Workflow-Outputs `.--release_created`, `.--version`, `telemetry-addon--release_created`, `paper-worldpush--release_created`. Task 9 und Task 10 hängen sich daran. - [ ] **Schritt 1: Aktuellen Zustand festhalten** @@ -328,6 +333,7 @@ git commit -m "chore: manage versions and changelogs with release-please" ### Task 4: PR-Build **Files:** + - Create: `.github/workflows/build-pr.yml` - [ ] **Schritt 1: Workflow anlegen** @@ -409,6 +415,7 @@ git commit -m "ci: build and test Gradle modules and the UI on pull requests" Das Repository trägt ungewöhnlich viel Dokumentation (Design-Spec, Pläne, Spike-Berichte, sechs READMEs) mit vielen Querverweisen. Kaputte Links fallen sonst niemandem auf. **Files:** + - Create: `.github/workflows/markdown-lint.yml` - Create: `.github/workflows/close-invalid-prs.yml` - Create: `.markdownlint-cli2.jsonc` @@ -483,11 +490,13 @@ git commit -m "ci: lint markdown and close pull requests from fork default branc ### Task 6: Container-Image für den Operator **Files:** + - Create: `operator/Dockerfile` - Modify: `operator/build.gradle.kts` (Shadow-Plugin und fester Jar-Name) - Modify: `settings.gradle.kts` — nur falls `shadow` im Katalog fehlt; er ist bereits als `version("shadow", "9.3.2")` vorhanden, dann entfällt die Änderung **Interfaces:** + - Consumes: `application { mainClass.set("net.onelitefeather.apus.operator.ApusOperator") }`, bereits vorhanden in `operator/build.gradle.kts:124`. - Produces: `operator/build/libs/apus-operator.jar`, das der Dockerfile per festem Namen kopiert. @@ -569,10 +578,12 @@ git commit -m "feat: package the operator as a container image" ### Task 7: Container-Image für die API **Files:** + - Create: `api/Dockerfile` - Modify: `api/build.gradle.kts` (Shadow-Plugin und fester Jar-Name) **Interfaces:** + - Consumes: `application { mainClass.set("net.onelitefeather.apus.api.Application") }`, vorhanden in `api/build.gradle.kts:113`. - Produces: `api/build/libs/apus-api.jar`. @@ -654,6 +665,7 @@ git commit -m "feat: package the API as a container image" Die UI läuft laut Design-Spec §11.2 als SPA (`ssr: false`). Ein statisches Build-Ergebnis, ausgeliefert von nginx, ist damit die passende Form — kein Node-Prozess im Cluster. **Files:** + - Create: `ui/Dockerfile` - Create: `ui/nginx.conf` @@ -748,9 +760,11 @@ git commit -m "feat: package the dashboard as a static nginx container image" Sechs Images: `runner`, `ingest`, `hosting`, `operator`, `api`, `ui`. Die ersten drei haben ihre Dockerfiles bereits, die letzten drei kommen aus Task 6–8. **Files:** + - Modify: `.github/workflows/release-please.yml` (Publish-Jobs anhängen) **Interfaces:** + - Consumes: `needs.release-please.outputs.root-released` und `root-version` aus Task 3. - [ ] **Schritt 1: Gradle-Job für die Jar-Artefakte ergänzen** @@ -885,6 +899,7 @@ git commit -m "ci: publish all six container images on release" `telemetry-addon` konsumieren BlueMap-Nutzer, `paper-worldpush` Server-Betreiber. Beide sind ohne Publishing nicht erreichbar. **Files:** + - Modify: `telemetry-addon/build.gradle.kts` - Modify: `paper-worldpush/build.gradle.kts` - Modify: `.github/workflows/release-please.yml` @@ -979,6 +994,7 @@ git commit -m "feat: publish telemetry-addon and paper-worldpush to the OneLiteF Die Spec führt in §13.2 „Phase 1 hat im Repository keinerlei CI-Konfiguration angelegt" als offenen Punkt. Nach diesem Plan stimmt das nicht mehr. **Files:** + - Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` - [ ] **Schritt 1: §13.2, Zeile zum `telemetry-addon`, umschreiben** diff --git a/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md b/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md index 05fad20..314c538 100644 --- a/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md +++ b/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md @@ -25,11 +25,13 @@ Heute erzeugt `./gradlew :operator:generateCrds` die sechs CRDs nach `operator/build/crds`. Wer Apus ausrollt, braucht sie aber vor dem ersten Operator-Start — und ein Cluster-Repository soll dafür kein Gradle ausführen müssen. **Files:** + - Create: `deploy/crds/*.yaml` (sechs Dateien, Generator-Ausgabe) - Create: `operator/src/test/java/net/onelitefeather/apus/operator/CrdsInSyncTest.java` - Modify: `operator/build.gradle.kts` (Ausgabeverzeichnis des Generators zusätzlich nach `deploy/crds`) **Interfaces:** + - Consumes: `generateCrds` (JavaExec-Task, `operator/build.gradle.kts:62`), der nach `build/crds` schreibt. - Produces: `deploy/crds/` als eingecheckte Quelle für Task 2. @@ -158,6 +160,7 @@ git commit -m "feat: check in the generated CRDs and guard them against drift" ### Task 2: Kustomize-Basis für den Operator **Files:** + - Create: `deploy/base/kustomization.yaml` - Create: `deploy/base/namespace.yaml` - Create: `deploy/base/operator-serviceaccount.yaml` @@ -166,6 +169,7 @@ git commit -m "feat: check in the generated CRDs and guard them against drift" - Create: `deploy/README.md` **Interfaces:** + - Consumes: `deploy/crds/` aus Task 1; die Umgebungsvariablen aus `OperatorConfig` (`APUS_ROOK_NAMESPACE`, `APUS_CEPH_OBJECT_STORE`, `APUS_BUCKET_STORAGE_CLASS`, `APUS_RUNNER_IMAGE`, `APUS_INGEST_IMAGE`, `APUS_HOSTING_IMAGE`, `APUS_BUNDLE_BUCKET`, `APUS_BUNDLE_S3_ENDPOINT`, `APUS_BUNDLE_S3_REGION`, `APUS_BUNDLE_CREDENTIALS_SECRET`). - Produces: die Basis, auf die Task 3 (API und UI) und Task 6 (PodMonitor) aufsetzen. @@ -421,6 +425,7 @@ git commit -m "feat: add a Kustomize base for rolling out the operator" ### Task 3: Manifeste für API und UI **Files:** + - Create: `deploy/base/api-deployment.yaml` - Create: `deploy/base/api-service.yaml` - Create: `deploy/base/api-rbac.yaml` @@ -540,6 +545,7 @@ git commit -m "feat: add deployment manifests for the API and the dashboard" Design-Spec §13.1 verlangt „Renders nach Phase, Ingest-Dauer, Quota-Auslastung je Mandant". Nichts davon existiert. **Files:** + - Modify: `settings.gradle.kts` (Micrometer im Katalog) - Modify: `operator/build.gradle.kts` - Create: `operator/src/main/java/net/onelitefeather/apus/operator/metrics/ApusMetrics.java` @@ -549,7 +555,9 @@ Design-Spec §13.1 verlangt „Renders nach Phase, Ingest-Dauer, Quota-Auslastun - Modify: `operator/src/main/java/net/onelitefeather/apus/operator/ApusOperator.java` **Interfaces:** + - Produces: + ```java public final class ApusMetrics { public ApusMetrics(MeterRegistry registry); @@ -566,6 +574,7 @@ Design-Spec §13.1 verlangt „Renders nach Phase, Ingest-Dauer, Quota-Auslastun @Override public void close(); } ``` + - Consumes: `JOSDK 5.5.1`s `Metrics`-Schnittstelle für die Reconciliation-Metriken. - [ ] **Schritt 1: Katalogeinträge ergänzen** @@ -835,6 +844,7 @@ git commit -m "feat: export operator metrics for renders, ingests and tenant sto ### Task 5: API-Metriken **Files:** + - Modify: `settings.gradle.kts` - Modify: `api/build.gradle.kts` - Modify: `api/src/main/resources/application.yml` @@ -958,6 +968,7 @@ git commit -m "feat: expose Prometheus metrics and health endpoints from the API ### Task 6: Scrape-Konfiguration **Files:** + - Create: `deploy/base/podmonitor-render.yaml` - Create: `deploy/base/servicemonitor-operator.yaml` - Create: `deploy/base/servicemonitor-api.yaml` @@ -1022,6 +1033,7 @@ git commit -m "feat: add scrape configuration for render pods, operator and API" Design-Spec §13.1: „ein Grafana-Dashboard je Ebene (Plattform, Mandant)". **Files:** + - Create: `deploy/dashboards/apus-platform.json` - Create: `deploy/dashboards/apus-tenant.json` - Create: `deploy/base/dashboards-configmap.yaml` @@ -1037,6 +1049,7 @@ Expected: die vollständige Liste. Jedes Panel darf ausschließlich diese Namen - [ ] **Schritt 2: Plattform-Dashboard bauen** `deploy/dashboards/apus-platform.json`, Panels: + 1. **Renders nach Phase** (Zeitreihe): `sum by (phase) (rate(apus_renders_total[5m]))` 2. **Fehlerquote** (Stat): `sum(rate(apus_renders_total{phase="Failed"}[1h])) / sum(rate(apus_renders_total[1h]))` 3. **Speicherverbrauch je Mandant** (Balken): `apus_storage_used_bytes` @@ -1115,10 +1128,12 @@ git commit -m "feat: add Grafana dashboards for the platform and tenant views" Design-Spec §13.2 sieht vor: „k3s + S3: kompletter Durchlauf Ingest → Render → Hosting mit Mini-Welt". Vorhanden sind `PushIngestEndToEndTest` (Ingest allein) und `RenderEndToEndTest` (Render allein) — der Durchlauf über alle drei Stufen fehlt, und Hosting ist in keinem E2E-Test enthalten. **Files:** + - Create: `operator/src/test/java/net/onelitefeather/apus/operator/FullPipelineIntegrationTest.java` - Modify: `operator/build.gradle.kts` (nur falls der `integrationTest`-Task angepasst werden muss) **Interfaces:** + - Consumes: die bestehende k3s-Testcontainers-Infrastruktur der vorhandenen `*IntegrationTest`-Klassen sowie `testdata/mini-world`. - [ ] **Schritt 1: Bestehende Integrationstest-Infrastruktur ansehen** @@ -1129,6 +1144,7 @@ Expected: das vorhandene Muster für k3s- und MinIO-Container. Der neue Test üb - [ ] **Schritt 2: Failing test schreiben** Der Test fährt in einer Methode: + 1. k3s starten, die sechs CRDs aus `deploy/crds` anwenden, den Operator über `LocallyRunOperatorExtension` gegen diesen Cluster laufen lassen. 2. MinIO starten, `testdata/mini-world` als Push-Quelle in den Staging-Prefix legen. 3. `Tenant` anlegen, auf `status.namespace` warten. @@ -1147,6 +1163,7 @@ Expected: FAIL. Der Fehlschlag muss aus einer der Wartestufen kommen, nicht aus - [ ] **Schritt 4: Test zum Laufen bringen** Was hier zu tun ist, hängt vom Fehlschlag ab. Erwartbare Stolpersteine, jeweils mit dem Ort, an dem sie zu beheben sind: + - Der Operator im Test kennt die Image-Namen nicht → `OperatorConfig`-Umgebungsvariablen im Test setzen, so wie das Deployment aus Task 2 es tut. - Rook existiert im k3s-Testcluster nicht → der Test setzt `storage.bucketClaim` nicht auf `auto`, sondern legt Bucket und Secret direkt in MinIO an und referenziert sie; die Rook-Integration ist eigener Scope und in `OperatorIntegrationTest` bereits abgedeckt. - Der Hosting-Pod braucht einen Ingress-Controller → im Test gegen den `Service` prüfen statt gegen die Ingress-URL; `status.ready` ist das Signal, nicht die externe Erreichbarkeit. @@ -1173,6 +1190,7 @@ git commit -m "test: cover the full ingest, render and hosting pipeline on k3s" ### Task 9: Design-Spec nachziehen **Files:** + - Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` - [ ] **Schritt 1: §13.1 als umgesetzt kennzeichnen** @@ -1181,6 +1199,7 @@ Der Abschnitt beschreibt Metriken, Logs und Dashboards im Futur. Umschreiben auf - [ ] **Schritt 2: §13.2, Zeile „E2E", auf den neuen Test verweisen** + Ersetzen durch: `k3s + S3: kompletter Durchlauf Ingest → Render → Hosting mit Mini-Welt (`FullPipelineIntegrationTest`, Teil von `./gradlew :operator:integrationTest`)`. - [ ] **Schritt 3: §0 um den Deployment-Stand ergänzen** diff --git a/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md b/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md index 753ee41..e13d513 100644 --- a/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md +++ b/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md @@ -23,12 +23,14 @@ **Offener Punkt §15.7.** `BlueMapRenderReconciler` erkennt ein erschöpftes Speicherkontingent heute daran, dass die Terminierungsmeldung des Pods bestimmte Zeichenketten enthält (`UNAMBIGUOUS_QUOTA_TOKENS`, plus „quota" in Verbindung mit `bucket`/`rgw`/`ceph`). Das Kubelet-Vokabular enthält „quota" nie, und die Meldung ist ein Log-Ausschnitt ohne Vertrag. **Files:** + - Modify: `runner/entrypoint.sh` - Modify: `runner/README.md` (Exit-Code-Tabelle) - Modify: `operator/src/main/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconciler.java` - Modify: `operator/src/test/java/net/onelitefeather/apus/operator/render/BlueMapRenderReconcilerTest.java` **Interfaces:** + - Produces: Exit-Code `6` des Runner-Containers als Vertrag „Speicherkontingent erschöpft". Die bestehende `quotaExceededMessage(Pod)`-Heuristik bleibt als Fallback erhalten, wird aber nachrangig. - [ ] **Schritt 1: Feststellen, wo der Quota-Fehler tatsächlich auftritt** @@ -143,7 +145,7 @@ Expected: `exit=6` - [ ] **Schritt 8: `runner/README.md` um die Exit-Code-Tabelle ergänzen** | Code | Bedeutung | Wiederholbar | -|---|---|---| +| --- | --- | --- | | 0 | Render erfolgreich | — | | 1 | Allgemeiner Fehler | ja | | 3 | Bundle-Sync fehlgeschlagen | ja | @@ -165,11 +167,13 @@ git commit -m "feat: give the runner a dedicated exit code for exhausted storage **Offener Punkt §15.9.** `FabricPushTokenRepository#resolveNamespace` sucht per Label über alle Namespaces. RBAC kann einen Label-Filter nicht einschränken, also braucht die API heute `get`/`list` auf **alle** Secrets im Cluster. Der Klassen-Javadoc skizziert den schmaleren Weg bereits — er wurde nur nicht umgesetzt. **Files:** + - Modify: `api/src/main/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepository.java` - Modify: `api/src/test/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepositoryTest.java` - Modify: `deploy/base/api-rbac.yaml` (aus Phase 8, Task 3) **Interfaces:** + - Consumes: `PushTokenSecrets.SECRET_NAME` (fester Name), `TenantRepository` (listet die cluster-scoped `Tenant`-Ressourcen), `TenantReconciler.namespaceFor(...)` (Namespace-Konvention). - Produces: unverändert `Optional resolveNamespace(String rawToken)` — die Signatur bleibt, nur der Weg dahinter ändert sich. @@ -325,6 +329,7 @@ git commit -m "fix: read only the fixed-name push token secret instead of listin **Offene Punkte §0 und §15.3.** Die API validiert JWTs gegen einen konfigurierbaren Issuer, aber welches Produkt davor steht, ist nicht entschieden, und ein Lauf gegen einen echten Broker hat nie stattgefunden — die Auth-Tests arbeiten mit selbst ausgestellten Test-JWTs. **Files:** + - Create: `docs/superpowers/specs/2026-08-12-identity-broker-entscheidung.md` - Modify: `settings.gradle.kts` (Keycloak-Testcontainer) - Modify: `api/build.gradle.kts` @@ -511,6 +516,7 @@ Expected: BUILD SUCCESSFUL §0 und §15 Punkt 3: Produktwahl eintragen, den Verweis auf „nie gegen einen echten Broker getestet" streichen und durch den Test verweisen. §10.3 um drei Angaben ergänzen, die dort heute fehlen und ohne die niemand einen zweiten Mandanten anlegen kann: + 1. Der konkrete Claim-Name, aus dem der Mandant abgeleitet wird, und die Form, in der die Rollen im Token stehen. 2. Dass die Rollenstruktur für beide Anmeldewege identisch ist — föderierter IdP des Mandanten und lokale Accounts im Broker — samt der Modellierung, die das erreicht (nativ organisationsgebundene Rollen oder die Gruppen-Behelfslösung aus Schritt 5). 3. Dass Rollen **niemals** aus einem föderierten Token übernommen werden, sondern ausschließlich im Apus-Broker vergeben werden. Das ist keine Feinheit, sondern die Grenze, ab der ein Mandant sich sonst selbst zum `platform-admin` erklären könnte. @@ -529,6 +535,7 @@ git commit -m "feat: verify the auth path against a real identity broker" **Offener Punkt §15.8.** `BukkitSaveCoordinator` pausiert das Autosave und erzwingt einen Save, bevor kopiert wird. Ob das kurze Fenster auf einem laufenden Server einen konsistenten Snapshot liefert, wurde nie geprüft — es existiert nur Unit-Abdeckung für Kopierlogik, Konfiguration und den HTTP-Report-Weg. **Files:** + - Modify: `settings.gradle.kts` (MockBukkit) - Modify: `paper-worldpush/build.gradle.kts` - Create: `paper-worldpush/src/test/java/net/onelitefeather/apus/paper/BukkitSaveCoordinatorTest.java` @@ -676,6 +683,7 @@ git commit -m "test: cover the paper-worldpush save window with MockBukkit" **Offener Punkt §15.6.** „`emptyDir` genügt bis zu einer Größe, die von der Node-Ausstattung abhängt; darüber ist ein PVC nötig." Die Grenze wurde nie gemessen, obwohl sie laut Spec vor Phase 2 nachzuholen war — und der Operator legt heute trotzdem einen Default fest. **Files:** + - Create: `docs/superpowers/spikes/2026-08-12-emptydir-grenze.md` - Create: `docs/superpowers/spikes/2026-08-12-emptydir-grenze/run-spike.sh` - Modify: `operator/src/main/java/net/onelitefeather/apus/operator/render/RenderJobBuilder.java` @@ -690,6 +698,7 @@ Expected: der aktuell erzeugte Volume-Typ und, falls vorhanden, ein `sizeLimit`. - [ ] **Schritt 2: Messskript schreiben** `run-spike.sh` im Stil des vorhandenen Sharding-Spikes (`docs/superpowers/spikes/2026-08-09-lowres-sharding-spike/run-spike.sh` als Vorlage lesen). Es fährt gegen einen Cluster: + 1. Node-Ausstattung erheben: `kubectl get nodes -o json | jq '.items[].status.allocatable["ephemeral-storage"]'`. 2. Render-Jobs mit `emptyDir` und wachsenden Welt-Größen starten (1, 5, 10, 20, 40 GiB Bundle). 3. Je Lauf festhalten: Erfolg/Misserfolg, Grund bei Misserfolg (`Evicted` mit `ephemeral-storage`-Bezug ist der gesuchte Fall), Spitzenverbrauch über `kubectl top pod`. @@ -766,7 +775,7 @@ git commit -m "feat: pick the render volume type from a measured limit instead o ## Reihenfolge und Abhängigkeiten | Task | Blockiert von | Kann parallel zu | -|---|---|---| +| --- | --- | --- | | 1 — Quota-Exit-Code | — | 2, 3, 4, 5 | | 2 — Push-Token-RBAC | Phase 8 Task 3 (die Datei, die verengt wird) | 1, 3, 4, 5 | | 3 — Identity-Broker | — (die Produktentscheidung in Schritt 1 ist der einzige Blocker) | 1, 2, 4, 5 | diff --git a/docs/superpowers/specs/2026-08-08-apus-design.md b/docs/superpowers/specs/2026-08-08-apus-design.md index 3d85eb7..26cdfbd 100644 --- a/docs/superpowers/specs/2026-08-08-apus-design.md +++ b/docs/superpowers/specs/2026-08-08-apus-design.md @@ -12,6 +12,7 @@ und erlaubt Bedienung ohne YAML. ## 0. Stand der Umsetzung + *(Ergänzt nach Abschluss von Phase 6 — Einstieg für alle, die neu dazukommen.)* **Alle sechs Phasen aus §14 sind gebaut**, einschließlich Phase 6 (Push-Quellen: @@ -67,7 +68,7 @@ böswillige Nutzer ist es nicht. Drei Rollenebenen: | Ebene | Wer | Darf | -|---|---|---| +| --- | --- | --- | | Plattform | OLF als Betreiber | Mandanten anlegen, Quotas setzen, alles sehen | | Mandant-Verwaltung | Owner/Admin eines Mandanten | Quellen, Maps, Hosting, Mitglieder des eigenen Mandanten | | Mandant-Nutzung | Operator/Viewer | Renders auslösen bzw. nur zusehen | @@ -109,7 +110,7 @@ BlueMap-Version zu verifizieren: ## 3. Architekturüberblick -``` +```text Pterodactyl-API ─┐ Pull: Backup-Liste abfragen, tar.gz streamen S3-Bucket ───────┤ Pull: Prefix auf neue Objekte prüfen Paper-Plugin ────┤ Push: async + inkrementell in Staging-Prefix @@ -151,7 +152,7 @@ BlueMap, rechts davon niemand etwas von Pterodactyl, ZIP-Uploads oder Bukkit-Ord Alle in einem Gradle-Monorepo `Apus`, mehrmodulig. | Modul | Sprache/Stack | Zweck | -|---|---|---| +| --- | --- | --- | | `telemetry-addon` | Java 25, BlueMap-Addon | Exponiert Render-Fortschritt als JSON und Prometheus-Metriken | | `ingest` | Java 25 | ETL: Connector-SPI (s3, pterodactyl, push, upload), Layout-Erkennung, Bundle-Writer. Läuft als Job | | `runner` | Dockerfile + Entrypoint | BlueMap-CLI + beide Addons + Bundle-Sync | @@ -176,7 +177,7 @@ lediglich Integrationstests, die den Vertrag mit `ingest` prüfen. Ein Bundle ist eine unveränderliche, versionierte Momentaufnahme einer Welt in normalisierter Form. -``` +```text worlds//// manifest.json overworld/region/r.0.0.mca … @@ -229,7 +230,7 @@ interface WorldSourceConnector { ``` | Connector | Art | Extract | -|---|---|---| +| --- | --- | --- | | `pterodactyl` | Pull | Backup-Liste über die Client-API abfragen, ausgewähltes Backup als signierte URL laden, `tar.gz` streamen und nur Welt-Pfade herausschreiben | | `s3` | Pull | Bucket-Prefix auf neue Objekte prüfen, Archiv oder Ordnerstruktur laden | | `upload` | Push | Presigned Multipart in ein Staging-Prefix, Abschluss meldet die UI | @@ -243,7 +244,7 @@ selektiv geschrieben. Das gesamte Archiv landet nie auf der Platte. Der kritische Teil. Erkannt wird anhand der Verzeichnisstruktur: | Layout | Erkennungsmerkmal | Abbildung | -|---|---|---| +| --- | --- | --- | | `vanilla` | `/region`, `/DIM-1/region`, `/DIM1/region` | direkt | | `bukkit` | `/region`, `_nether/DIM-1/region`, `_the_end/DIM1/region` | Ordner zusammenführen | | `nested` | genau ein Unterordner, darin eines der obigen | Ebene überspringen, erneut prüfen | @@ -384,7 +385,7 @@ aus §9.1. Verifiziert und ausgeliefert in Phase 1 (`runner/entrypoint.sh`, `runner/bin/render-config.sh`): | Variable | Pflicht | Bedeutung | -|---|---|---| +| --- | --- | --- | | `APUS_MAP_ID` | ja | Map-Id, z.B. `overworld`. Wird als Pfadsegment verwendet — nur Kleinbuchstaben, Ziffern, `-`, `_` | | `APUS_DIMENSION` | ja | `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end` | | `APUS_MC_VERSION` | ja | z.B. `1.21.10` | @@ -596,7 +597,7 @@ Aus der CR und den Rook-Werten erzeugt der Operator für den Hosting-Pod die vol BlueMap-Konfiguration als ConfigMap (plus Secret für Zugangsdaten): | Datei | Inhalt | -|---|---| +| --- | --- | | `core.conf` | Datenverzeichnis, `render-threads`, Metrics deaktiviert, `accept-download: true` (**erforderlich** — ohne diesen Schlüssel lädt BlueMap die Minecraft-Ressourcen nicht und jeder Render schlägt mit Exit-Code 2 fehl), `scan-for-mod-resources: false` | | `storages/s3.conf` | Endpoint, Bucket, Path-Style-Zugriff, Credentials — für `BlueMapS3Storage` | | `maps/.conf` | Weltpfad aus dem Bundle-Manifest, Dimension, Render-Einstellungen | @@ -680,7 +681,7 @@ und damit den Namespace. Rollen: `platform-admin`, `tenant-owner`, `tenant-operator`, `tenant-viewer`. | Rolle | Darf | -|---|---| +| --- | --- | | `platform-admin` | Tenants anlegen/ändern/löschen, Quotas, clusterweite Sicht | | `tenant-owner` | alles im eigenen Mandanten inkl. Mitgliederverwaltung | | `tenant-operator` | Quellen und Maps pflegen, Renders auslösen | @@ -703,7 +704,7 @@ Micronaut, REST plus SSE. Die CRs sind die Quelle der Wahrheit; die API hält ke Kopie des Zustands, sondern liest über einen Informer-Cache. | Endpunkt | Zweck | -|---|---| +| --- | --- | | `GET /api/tenants` … | Plattformebene, nur `platform-admin` | | `GET /api/sources`, `POST /api/sources` | Quellen des eigenen Mandanten | | `POST /api/maps/{id}/render` | Render auslösen (erzeugt `BlueMapRender`) | @@ -733,7 +734,7 @@ Karte später eingebettet und gesteuert werden, statt nur verlinkt zu sein. Nich ## 12. Fehlerbehandlung | Fall | Verhalten | -|---|---| +| --- | --- | | Render-Pod stirbt (OOM, Eviction) | Neuer Pod setzt über den Render-State in S3 fort. Nach `backoffLimit` Phase `Failed`, letzter Fortschritt bleibt sichtbar | | Zwei Renders auf dieselbe Map | Durch `concurrencyPolicy: Forbid` verhindert, Lock über CR-Status | | Ingest bricht ab | Kein Manifest → Bundle gilt als nicht existent. Kein halber Zustand im Render-Pfad | @@ -758,7 +759,7 @@ Credentials erscheinen nie in CR-Status, Events oder Logs. ### 13.2 Tests | Baustein | Vorgehen | -|---|---| +| --- | --- | | `ingest` | Fixture-Archive je Layout (Pterodactyl-`tar.gz`, Bukkit-Split, Vanilla, ZIP mit Unterordner, defektes Archiv) gegen den Layout-Detektor. Reine Unit-Tests, plus MinIO-gestützte Integrationstests je Connector (`s3`, `pterodactyl`, `push`, `upload`) und ein Ende-zu-Ende-Test (`PushIngestEndToEndTest`), der einen kompletten Ingest-Lauf für Push/Upload-Quellen gegen echtes MinIO fährt | | `telemetry-addon` | Contract-Test pro BlueMap-Version: Mini-Welt rendern, `/progress` auf plausible Werte prüfen (deckt den Log-Tail-Weg ab, siehe §7.2). **Offen:** Eine CI-Matrix über unterstützte BlueMap-Versionen als Frühwarnsystem existiert nicht — Phase 1 hat im Repository keinerlei CI-Konfiguration angelegt. Bis dahin muss der Contract-Test vor jedem BlueMap-Upgrade manuell laufen | | `runner` | Integrationstest gegen S3-Testcontainer mit kleiner Welt, inkl. `IngestRenderContractTest` (Ingest → Bundle → Render Ende-zu-Ende) | @@ -885,7 +886,7 @@ für die Begründung. ## 16. Entscheidungen | Entscheidung | Begründung | -|---|---| +| --- | --- | | BlueMap-CLI unverändert statt eigenem Renderer | Geringste Kopplung an BlueMap-Interna; Upgrades sind ein Image-Tag | | Addon-API bevorzugt, Log-Tailing als tragender Weg in Phase 1 | Ursprünglich war Log-Parsing verworfen worden (kein Vertrag, Locale-/Formatrisiko). Verifiziert in Task 8 (§7.2): Der CLI konstruiert `BlueMapAPIImpl` unbedingt mit `Plugin = null`, wodurch der dokumentierte Addon-Weg (`plugin().getRenderManager()`) im CLI-Betrieb strukturell nie greift — es gibt dort kein über `BlueMapAPI` erreichbares `RenderManager`-Objekt. Log-Tailing auf `Logger.global` ist im CLI-Betrieb der einzige funktionierende Weg und bleibt hinter derselben `RenderManagerAccess`-Schnittstelle gekapselt; der Addon-Weg bleibt für einen künftigen Server-Plugin-Betrieb der bevorzugte, reichhaltigere Pfad | | Ein Addon, keine Aufteilung in Telemetry und Control | Der Operator löst Renders über CRs aus; ein Control-Endpunkt wäre ein zweiter Weg zum selben Ziel | diff --git a/docs/superpowers/spikes/2026-08-09-lowres-sharding-spike.md b/docs/superpowers/spikes/2026-08-09-lowres-sharding-spike.md index 4f87922..40b782e 100644 --- a/docs/superpowers/spikes/2026-08-09-lowres-sharding-spike.md +++ b/docs/superpowers/spikes/2026-08-09-lowres-sharding-spike.md @@ -54,7 +54,7 @@ The fixture was extended with two more region files pulled from `playerdata/`/`stats/`/`advancements/`. This turns the fixture into a contiguous 2×2 block of regions: -``` +```text region x=-1 region x=0 region z=0 r.-1.0.mca r.0.0.mca region z=1 r.-1.1.mca r.0.1.mca @@ -119,7 +119,7 @@ Scripts, all committed alongside this report in `docs/superpowers/spikes/2026-08-09-lowres-sharding-spike/`: | File | Purpose | -|---|---| +| --- | --- | | `spike-entrypoint.sh` | Runner entrypoint variant that adds `render-mask` to `map.conf` | | `run-spike.sh` | Orchestrates network, MinIO, seeding, reference render, N parallel repeats, sequential control | | `compare_tiles.py` | Mirrors a bucket's lowres tiles locally and diffs them (MD5 + per-pixel) against the reference | @@ -169,7 +169,7 @@ repeats — the same 7 files each time, and byte-for-byte (MD5) identical across repeats:** | Tile | Differing pixels | % of tile | -|---|---:|---:| +| --- | ---: | ---: | | `tiles/1/x-1/z1.png` | 456,494 / 502,002 | **90.93%** | | `tiles/2/x-1/z0.png` | 28,254 / 502,002 | 5.63% | | `tiles/1/x0/z1.png` | 1,928 / 502,002 | 0.38% | @@ -215,7 +215,7 @@ sufficient to fix this on its own. ### 4.4 Reproducibility | Run | Lowres tiles differing | Deterministic across repeats? | -|---|---:|---| +| --- | ---: | --- | | Concurrent × 3 | 7/24 (29%) each time | Yes — MD5-identical corrupted bytes in all 3 repeats | | Sequential × 1 | 10/24 (42%) | N/A (single ordering by construction) | diff --git a/hosting/README.md b/hosting/README.md index 2b9c974..38dd219 100644 --- a/hosting/README.md +++ b/hosting/README.md @@ -48,7 +48,7 @@ docker run --rm -p 8100:8100 \ ### Environment variables | Variable | Required | Default | Meaning | -|---|---|---|---| +| --- | --- | --- | --- | | `APUS_S3_ENDPOINT` | yes | -- | e.g. `http://minio:9000` | | `APUS_S3_ACCESS_KEY` | yes | -- | Access key | | `APUS_S3_SECRET_KEY` | yes | -- | Secret key | diff --git a/ingest/README.md b/ingest/README.md index 0329bf1..377b997 100644 --- a/ingest/README.md +++ b/ingest/README.md @@ -46,7 +46,7 @@ The full contract this image accepts, and the interface `IngestJobBuilder` (phas builds Kubernetes Jobs against -- the ingest equivalent of `runner/README.md`'s table. | Variable | Required | Default | Meaning | -|---|---|---|---| +| --- | --- | --- | --- | | `APUS_SOURCE_TYPE` | yes | — | `s3`, `pterodactyl`, `push`, or `upload` -- an unsupported value fails fast rather than being guessed at | | `APUS_WORLD_NAME` | yes | — | The world's folder name at the source, e.g. `world` | | `APUS_LAYOUT` | no | `auto` | `auto`, `vanilla`, or `bukkit`. `auto` lets `LayoutDetector` decide; any other value forces that layout and fails detection rather than falling back if the fetched data doesn't actually match it | @@ -89,7 +89,7 @@ message on stderr and a non-zero exit, **before** any connector is touched -- se ## Exit codes | Code | Meaning | -|---|---| +| --- | --- | | `0` | Bundle written successfully | | `1` | Configuration error: a required variable is missing/blank, or `APUS_SOURCE_TYPE` names an unimplemented source. Nothing was fetched. | | `2` | Layout detection failed -- no known world layout (vanilla/bukkit) could be recognised in the fetched data. The error message names the paths that were actually found. | diff --git a/runner/README.md b/runner/README.md index 3c9c322..68e18c1 100644 --- a/runner/README.md +++ b/runner/README.md @@ -67,7 +67,7 @@ Kubernetes operator will drive (see `docs/superpowers/specs/2026-08-08-apus-desi §7.4). | Variable | Required | Default | Meaning | -|---|---|---|---| +| --- | --- | --- | --- | | `APUS_MAP_ID` | yes | — | Map id, e.g. `overworld`. Used as a path segment (`maps/.conf`); must match `^[a-z0-9_-]+$`, `render-config.sh` rejects anything else with exit code `5` | | `APUS_DIMENSION` | yes | — | `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end` | | `APUS_MC_VERSION` | yes | — | Minecraft version, e.g. `1.21.10` | @@ -123,7 +123,7 @@ registers itself on BlueMap's own `Logger.global` (`de.bluecolored.bluemap.core.logger.Logger`/`MultiLogger`) and parses the exact progress line BlueMap's CLI already logs on its own during a render: -``` +```text updating map 'overworld': 35.208% (ETA: 38 seconds) ``` diff --git a/testdata/README.md b/testdata/README.md index c8310f7..70ca628 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -16,7 +16,7 @@ Regenerate the original two-region set with the snippet in ### Region layout -``` +```text region x=-1 region x=0 region z=0 r.-1.0.mca r.0.0.mca region z=1 r.-1.1.mca r.0.1.mca diff --git a/ui/README.md b/ui/README.md index 98ea61f..946e9b4 100644 --- a/ui/README.md +++ b/ui/README.md @@ -32,7 +32,7 @@ URL, OIDC issuer, OIDC client ID) — see "Configuration" below. ## Versions (pinned, verified against npm on 2026-08-09) | Package | Version | Why this one | -|---|---|---| +| --- | --- | --- | | `nuxt` | 4.5.2 | current stable Nuxt 4 | | `vue` | 3.5.41 | pulled in by Nuxt 4 | | `@nuxt/ui` | 4.10.0 | current stable; bundles its own Tailwind 4 wiring | @@ -62,7 +62,7 @@ Nuxt 4's actual current default: an `app/` directory holds everything client-sid with no Nitro API routes of its own; the only thing Nitro does here is serve the built static assets (see "Why no server-side session" below). -``` +```text app/ app.vue -- layouts/default.vue -- header + nav, wraps every page From 8f0b92e4a5336d0ff9133b9acc0e1f9e8a750cb7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 10:25:11 +0200 Subject: [PATCH 08/18] feat: package the operator as a container image --- operator/Dockerfile | 20 ++++++++++++++++++++ operator/build.gradle.kts | 14 ++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 operator/Dockerfile diff --git a/operator/Dockerfile b/operator/Dockerfile new file mode 100644 index 0000000..b50f224 --- /dev/null +++ b/operator/Dockerfile @@ -0,0 +1,20 @@ +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:25-jre-jammy + +# Non-root, same convention as runner/Dockerfile and ingest/Dockerfile. The operator writes +# nothing to the filesystem at all -- it only talks to the Kubernetes API -- so it gets no +# writable directory beyond its home. +RUN useradd --uid 10001 --create-home --home-dir /home/apus apus + +# Built by: ./gradlew :operator:shadowJar +COPY --chown=apus:apus operator/build/libs/apus-operator.jar /opt/apus/operator.jar + +USER apus +WORKDIR /home/apus + +# The operator serves no traffic of its own; 8080 is only the metrics endpoint added in +# phase 8. Declared here so the port contract lives with the image. +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/opt/apus/operator.jar"] diff --git a/operator/build.gradle.kts b/operator/build.gradle.kts index b8656e9..58d3873 100644 --- a/operator/build.gradle.kts +++ b/operator/build.gradle.kts @@ -2,6 +2,7 @@ import java.time.Duration plugins { application + alias(libs.plugins.shadow) } dependencies { @@ -123,3 +124,16 @@ val integrationTest by tasks.registering(Test::class) { application { mainClass.set("net.onelitefeather.apus.operator.ApusOperator") } + +tasks { + shadowJar { + archiveClassifier.set("") + archiveBaseName.set("apus-operator") + // Fixed name instead of the default "apus-operator-.jar": operator/Dockerfile + // COPYs the file by name (no glob), the same convention ingest and telemetry-addon use. + archiveFileName.set("apus-operator.jar") + } + build { + dependsOn(shadowJar) + } +} From 8c4c7033d24734d0a9e77a625693aefddee355a5 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 10:34:03 +0200 Subject: [PATCH 09/18] feat: package the API as a container image --- api/Dockerfile | 15 +++++++++++++++ api/build.gradle.kts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 api/Dockerfile diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..5b35904 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:25-jre-jammy + +RUN useradd --uid 10001 --create-home --home-dir /home/apus apus + +# Built by: ./gradlew :api:shadowJar +COPY --chown=apus:apus api/build/libs/apus-api.jar /opt/apus/api.jar + +USER apus +WORKDIR /home/apus + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/opt/apus/api.jar"] diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 9739e26..b1a85d7 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -6,6 +6,7 @@ evaluationDependsOn(":operator") plugins { application + alias(libs.plugins.shadow) } dependencies { @@ -113,6 +114,22 @@ application { mainClass.set("net.onelitefeather.apus.api.Application") } +tasks { + shadowJar { + archiveClassifier.set("") + archiveBaseName.set("apus-api") + archiveFileName.set("apus-api.jar") + // Micronaut ships service files (annotation-driven bean definitions, serde config) + // in META-INF/services; without merging them the shadowed jar starts but resolves + // no beans, which surfaces as a confusing "no route matched" at runtime rather + // than a build failure. + mergeServiceFiles() + } + build { + dependsOn(shadowJar) + } +} + // TenantIsolationIntegrationTest starts a k3s container (via Testcontainers), applies the // `:operator` module's generated CRDs to it, and proves cross-tenant isolation over a real, // JWT-authenticated HTTP call against a real API server -- minutes of work and Docker, exactly From d560ee59da6e381cae653a3aa9da8aed68385efa Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 10:44:43 +0200 Subject: [PATCH 10/18] feat: package the dashboard as a static nginx container image Multi-stage build compiles the SPA with `pnpm generate` (Node version taken from ui/.nvmrc, currently 24) and serves the static output via nginx-unprivileged (uid 101, port 8080), with try_files SPA fallback and a cache policy that never caches index.html while treating hashed _nuxt/ assets as immutable. Also adds a root .dockerignore: without it, `COPY ui/ ./` pulls the host's gitignored ui/node_modules over the image's freshly installed one and the build fails (or, worse, could ship stale local build output). --- .dockerignore | 14 ++++++++++++++ ui/Dockerfile | 28 ++++++++++++++++++++++++++++ ui/nginx.conf | 22 ++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 .dockerignore create mode 100644 ui/Dockerfile create mode 100644 ui/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2e851a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Applies to every image built from this repo root (build context is always `.`). +# +# ui/ (Nuxt) keeps build output and dependencies on disk even though they are gitignored (see +# .gitignore). Without this file, `COPY ui/ ./` in ui/Dockerfile would copy the host's +# node_modules/.output/.nuxt over the ones the build stage just produced -- pnpm aborts rather +# than silently overwrite a foreign node_modules, and even if it didn't, stale local build +# output could end up shipped in the image. +ui/node_modules/ +ui/.nuxt/ +ui/.output/ +ui/dist/ +ui/coverage/ + +.git/ diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..df69b0d --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +######################################## +# Stage 1: build the SPA +######################################## +# Version pinned to ui/.nvmrc (24); keep the two in sync. +FROM node:24-bookworm-slim AS build + +RUN corepack enable + +WORKDIR /src +COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY ui/ ./ +RUN pnpm generate + +######################################## +# Stage 2: serve it +######################################## +FROM nginxinc/nginx-unprivileged:1.29-alpine + +# The unprivileged nginx image already runs as uid 101; it needs no writable root and +# listens on 8080 rather than 80, which is why it is used instead of the stock image. +COPY ui/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /src/.output/public /usr/share/nginx/html + +EXPOSE 8080 diff --git a/ui/nginx.conf b/ui/nginx.conf new file mode 100644 index 0000000..ae6158f --- /dev/null +++ b/ui/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + + # Single-page app: every unknown path must fall back to index.html, otherwise a + # browser reload on /tenants/foo returns 404 instead of the app. + location / { + try_files $uri $uri/ /index.html; + } + + # Hashed build assets are immutable; index.html must never be cached, or a deploy + # leaves clients on the previous bundle. + location /_nuxt/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /index.html { + add_header Cache-Control "no-store"; + } +} From 03996ab161c0ac6fab5b27bb713e39b05e2c9b0d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 10:53:57 +0200 Subject: [PATCH 11/18] ci: publish all six container images on release --- .github/workflows/release-please.yml | 96 ++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 3da0685..ef111f4 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -22,3 +22,99 @@ jobs: with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + + build-context: + needs: release-please + if: needs.release-please.outputs.root-released == 'true' + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-docker-context.yml@v2.4.0 + with: + java-version: "25" + version: ${{ needs.release-please.outputs.root-version }} + gradle-command: "./gradlew :telemetry-addon:shadowJar :ingest:shadowJar :operator:shadowJar :api:shadowJar" + context-path: "." + artifact-name: "docker-context" + secrets: inherit + + publish-runner: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/runner" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "runner/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-ingest: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/ingest" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "ingest/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-hosting: + needs: release-please + if: needs.release-please.outputs.root-released == 'true' + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/hosting" + version: ${{ needs.release-please.outputs.root-version }} + context: "hosting" + dockerfile: "hosting/Dockerfile" + secrets: inherit + + publish-operator: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/operator" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "operator/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-api: + needs: [release-please, build-context] + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/api" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "api/Dockerfile" + artifact-name: "docker-context" + secrets: inherit + + publish-ui: + needs: release-please + if: needs.release-please.outputs.root-released == 'true' + permissions: + contents: read + id-token: write + uses: OneLiteFeatherNET/workflows/.github/workflows/docker-publish.yml@v2.4.0 + with: + image-name: "apus/ui" + version: ${{ needs.release-please.outputs.root-version }} + context: "." + dockerfile: "ui/Dockerfile" + secrets: inherit From 8ef7d8f85e86d76c66e9409ef5d4b3a03d29bdf6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 11:01:18 +0200 Subject: [PATCH 12/18] feat: publish telemetry-addon and paper-worldpush to the OneLiteFeather Maven repository --- .github/workflows/release-please.yml | 22 ++++++++++++++++++++ paper-worldpush/build.gradle.kts | 30 ++++++++++++++++++++++++++++ telemetry-addon/build.gradle.kts | 30 ++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index ef111f4..bdff3dc 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -118,3 +118,25 @@ jobs: context: "." dockerfile: "ui/Dockerfile" secrets: inherit + + publish-telemetry-addon: + needs: release-please + if: needs.release-please.outputs.telemetry-released == 'true' + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0 + with: + java-version: "25" + java-distribution: "temurin" + build-task: ":telemetry-addon:build" + publish-task: ":telemetry-addon:publish" + secrets: inherit + + publish-paper-worldpush: + needs: release-please + if: needs.release-please.outputs.paper-released == 'true' + uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0 + with: + java-version: "25" + java-distribution: "temurin" + build-task: ":paper-worldpush:build" + publish-task: ":paper-worldpush:publish" + secrets: inherit diff --git a/paper-worldpush/build.gradle.kts b/paper-worldpush/build.gradle.kts index 188e838..1ce0642 100644 --- a/paper-worldpush/build.gradle.kts +++ b/paper-worldpush/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + `maven-publish` alias(libs.plugins.shadow) } @@ -68,3 +69,32 @@ tasks { dependsOn(shadowJar) } } + +publishing { + publications { + create("maven") { + groupId = "net.onelitefeather.apus" + artifactId = "paper-worldpush" + // The shadow jar is the artifact consumers need -- the thin jar would leave + // them to resolve the relocated dependencies themselves. + artifact(tasks.named("shadowJar")) + } + } + repositories { + maven { + // Name and credential env vars match the OneLiteFeather-wide convention (verified + // against OneLiteFeatherNET/Aves and the central gradle-publish.yml reusable + // workflow, which injects exactly these two secrets). + name = "OneLiteFeatherRepository" + credentials(PasswordCredentials::class) { + username = System.getenv("ONELITEFEATHER_MAVEN_USERNAME") + password = System.getenv("ONELITEFEATHER_MAVEN_PASSWORD") + } + url = if (project.version.toString().contains("SNAPSHOT")) { + uri("https://repo.onelitefeather.dev/onelitefeather-snapshots") + } else { + uri("https://repo.onelitefeather.dev/onelitefeather-releases") + } + } + } +} diff --git a/telemetry-addon/build.gradle.kts b/telemetry-addon/build.gradle.kts index fcd38da..262c478 100644 --- a/telemetry-addon/build.gradle.kts +++ b/telemetry-addon/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + `maven-publish` alias(libs.plugins.shadow) } @@ -33,3 +34,32 @@ tasks { dependsOn(shadowJar) } } + +publishing { + publications { + create("maven") { + groupId = "net.onelitefeather.apus" + artifactId = "telemetry-addon" + // The shadow jar is the artifact consumers need -- the thin jar would leave + // them to resolve the relocated dependencies themselves. + artifact(tasks.named("shadowJar")) + } + } + repositories { + maven { + // Name and credential env vars match the OneLiteFeather-wide convention (verified + // against OneLiteFeatherNET/Aves and the central gradle-publish.yml reusable + // workflow, which injects exactly these two secrets). + name = "OneLiteFeatherRepository" + credentials(PasswordCredentials::class) { + username = System.getenv("ONELITEFEATHER_MAVEN_USERNAME") + password = System.getenv("ONELITEFEATHER_MAVEN_PASSWORD") + } + url = if (project.version.toString().contains("SNAPSHOT")) { + uri("https://repo.onelitefeather.dev/onelitefeather-snapshots") + } else { + uri("https://repo.onelitefeather.dev/onelitefeather-releases") + } + } + } +} From 0f7ac6c513a52670b8c11aee855014da7f6cefa9 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 11:08:20 +0200 Subject: [PATCH 13/18] docs: record the phase 7 delivery state in the design spec --- .../specs/2026-08-08-apus-design.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-08-apus-design.md b/docs/superpowers/specs/2026-08-08-apus-design.md index 26cdfbd..ccd7107 100644 --- a/docs/superpowers/specs/2026-08-08-apus-design.md +++ b/docs/superpowers/specs/2026-08-08-apus-design.md @@ -28,6 +28,13 @@ bei gleichzeitig laufenden Shards nach; die Entscheidung fiel zugunsten vertikal Skalierung über `render-threads` — siehe §14, Phase 4, für die volle Begründung. `BlueMapMap.spec.shards` existiert und bleibt bis auf Weiteres auf `1` beschränkt. +**Auslieferung steht seit Phase 7.** Alle sechs Komponenten liegen als Container-Image vor +(`runner`, `ingest`, `hosting`, `operator`, `api`, `ui`), `telemetry-addon` und +`paper-worldpush` werden nach Maven veröffentlicht. Versionen und Changelogs entstehen +über Release Please aus Conventional Commits; `telemetry-addon` und `paper-worldpush` +tragen dabei eigene Release-Spuren, wie in §4 vorgesehen. Was weiterhin fehlt, sind die +Cluster-Manifeste und die Observability-Verdrahtung — siehe den Plan zu Phase 8. + **Bewusst offen gelassene Punkte** (Details in §15): - **Identity-Broker nicht ausgewählt.** Die API validiert JWTs gegen einen @@ -761,7 +768,7 @@ Credentials erscheinen nie in CR-Status, Events oder Logs. | Baustein | Vorgehen | | --- | --- | | `ingest` | Fixture-Archive je Layout (Pterodactyl-`tar.gz`, Bukkit-Split, Vanilla, ZIP mit Unterordner, defektes Archiv) gegen den Layout-Detektor. Reine Unit-Tests, plus MinIO-gestützte Integrationstests je Connector (`s3`, `pterodactyl`, `push`, `upload`) und ein Ende-zu-Ende-Test (`PushIngestEndToEndTest`), der einen kompletten Ingest-Lauf für Push/Upload-Quellen gegen echtes MinIO fährt | -| `telemetry-addon` | Contract-Test pro BlueMap-Version: Mini-Welt rendern, `/progress` auf plausible Werte prüfen (deckt den Log-Tail-Weg ab, siehe §7.2). **Offen:** Eine CI-Matrix über unterstützte BlueMap-Versionen als Frühwarnsystem existiert nicht — Phase 1 hat im Repository keinerlei CI-Konfiguration angelegt. Bis dahin muss der Contract-Test vor jedem BlueMap-Upgrade manuell laufen | +| `telemetry-addon` | Contract-Test pro BlueMap-Version: Mini-Welt rendern, `/progress` auf plausible Werte prüfen (deckt den Log-Tail-Weg ab, siehe §7.2). **Teilweise offen:** CI existiert seit Phase 7 (`.github/workflows/build-pr.yml`), eine Matrix über mehrere BlueMap-Versionen als Frühwarnsystem aber noch nicht — der Contract-Test läuft gegen die eine im Katalog gepinnte Version. Bis eine Matrix existiert, muss er vor jedem BlueMap-Upgrade weiterhin gezielt laufen | | `runner` | Integrationstest gegen S3-Testcontainer mit kleiner Welt, inkl. `IngestRenderContractTest` (Ingest → Bundle → Render Ende-zu-Ende) | | `operator` | JOSDK `LocallyRunOperatorExtension` gegen k3s via Testcontainers, plus `EnableKubernetesMockClient`-Tests je Reconciler | | `api` | Micronaut-Tests gegen einen Fake-Kubernetes-Client bzw. `EnableKubernetesMockClient`, Auth-Fälle je Rolle. **Offen:** kein Lauf gegen einen echten Identity-Broker (siehe §0/§15, Punkt 3) | @@ -880,6 +887,16 @@ für die Begründung. 7. **Kein belastbares Quota-Signal aus dem Runner-Image.** `BlueMapRenderReconciler` erkennt ein Speicherlimit derzeit heuristisch aus dem Grund/der Meldung des terminierten Render-Pods (Muster wie `QuotaExceeded` oder "quota" kombiniert mit einem S3-Bezug wie `bucket`/`rgw`/`ceph`), gestützt auf `terminationMessagePolicy: FallbackToLogsOnError`, damit überhaupt eine Meldung ankommt. Das bleibt Best-Effort: das Kubelet-Vokabular für den Terminierungsgrund enthält "quota" nie, und die Meldung ist nur ein Log-Ausschnitt ohne Vertrag. Ein belastbares Signal (z. B. ein eigener Exit-Code des Runners für "Quota erschöpft") muss vor einem produktiven Einsatz nachgezogen werden, bevor mehr Verhalten (etwa automatische Benachrichtigungen) darauf aufbaut. 8. **`paper-worldpush`'s Save-Fenster ungetestet gegen einen echten Paper-Server.** §13.2 sah ursprünglich MockBukkit für die Kopierlogik plus einen Lauf gegen einen echten Paper-Server für `BukkitSaveCoordinator`s Autosave-Pause-und-Force-Save-Schritt vor; tatsächlich existiert nur Unit-Testabdeckung für Kopierlogik, Konfiguration und den HTTP-Report-Weg (`HttpPushNotifierTest` gegen einen lokalen `HttpServer`-Stub). Ob das kurze Zeitfenster zwischen `disableAutoSave()`/`forceSave()` und dem Beginn des inkrementellen Kopierens auf einem echten, unter Last laufenden Server tatsächlich einen konsistenten Snapshot liefert, ist vor einem produktiven Einsatz zu verifizieren. 9. **RBAC für den Push-Token-Lookup der API breiter als ideal.** `FabricPushTokenRepository#resolveNamespace` sucht (mangels Tenant-Hinweis im Request) per Label über alle Namespaces nach Service-Token-Secrets; Kubernetes-RBAC kann diesen Zugriff nicht auf das Label einschränken, sodass die schmalste *funktionierende* Berechtigung für das heutige Vorgehen trotzdem `get`/`list` auf **alle** Secrets im Cluster ist (siehe die Klassendoku für die volle Abwägung und einen skizzierten, aber nicht umgesetzten schmaleren Weg über `Tenant`-Enumeration + `get` mit festem Secret-Namen). +10. **Kein SLF4J-Provider im Runtime-Classpath — keine Anwendungslogs auf stdout.** In keinem + `build.gradle.kts` des Monorepos steht eine Abhängigkeit auf `logback-classic` oder einen + anderen SLF4J-Provider; für `api` und `operator` wurde das zusätzlich am laufenden Container + bestätigt (`SLF4J(W): No SLF4J providers were found` beim Start, Fallback auf den No-Op- + Logger). Damit schreibt aktuell kein gebautes Image Anwendungslogs nach stdout. Das betrifft + zwei Stellen der Spec direkt: §13.1 setzt voraus, dass Alloy Pod-Logs nach Loki weiterreicht, + und §11.1s `GET /api/renders/{id}/logs` liest genau diesen Loki-Stream für die API-SSE-Route — + beides bleibt ohne Wirkung, solange kein Modul einen Logging-Provider mitbringt. Muss vor der + Observability-Verdrahtung (Phase 8) behoben werden; diese Lücke wird hier nur festgehalten, + nicht behoben. --- From e455c8d6e0818ef18b3af2bd97a8408639b8618a Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 12:42:03 +0200 Subject: [PATCH 14/18] fix(runner): fetch the S3 storage addon in the image build runner/Dockerfile copied runner/vendor/BlueMapS3Storage.jar out of the build context, but runner/vendor/ is gitignored and therefore does not exist in a fresh clone -- the publish-runner job could never have built the image in CI. Download the addon in the existing fetch stage instead, pinned by BLUEMAP_S3_STORAGE_VERSION, exactly the way hosting/Dockerfile already fetches the same jar from the same release. The addon still lands at /work/config/packs/bluemap-s3-storage.jar. Verified by building the image with runner/vendor/ moved out of the way. --- runner/Dockerfile | 18 ++++++++++++++---- runner/README.md | 24 ++++++++++++------------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/runner/Dockerfile b/runner/Dockerfile index d521a8c..cab8747 100644 --- a/runner/Dockerfile +++ b/runner/Dockerfile @@ -1,19 +1,29 @@ # syntax=docker/dockerfile:1 ######################################## -# Stage 1: fetch the BlueMap CLI +# Stage 1: fetch the BlueMap CLI and the S3 storage addon ######################################## FROM eclipse-temurin:25-jre-jammy AS fetch ARG BLUEMAP_VERSION=5.23 +ARG BLUEMAP_S3_STORAGE_VERSION=1.5.1 RUN apt-get update \ && apt-get install -y --no-install-recommends curl ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /download +# BlueMapS3Storage release assets are versioned (e.g. BlueMapS3Storage-1.5.1.jar, not +# BlueMapS3Storage.jar), so releases/latest/download/BlueMapS3Storage.jar 404s -- the +# version-pinned URL below is the reproducible way to fetch it (hosting/Dockerfile fetches +# the same jar the same way). Bump BLUEMAP_S3_STORAGE_VERSION when a newer release is +# needed. Fetching it here instead of copying a checked-in runner/vendor/ jar is what makes +# this image buildable in CI at all: runner/vendor/ is gitignored, so it does not exist in +# a fresh clone. RUN curl -fsSL -o bluemap-cli.jar \ - "https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v${BLUEMAP_VERSION}/bluemap-${BLUEMAP_VERSION}-cli.jar" + "https://github.com/BlueMap-Minecraft/BlueMap/releases/download/v${BLUEMAP_VERSION}/bluemap-${BLUEMAP_VERSION}-cli.jar" \ + && curl -fsSL -o bluemap-s3-storage.jar \ + "https://github.com/TheMeinerLP/BlueMapS3Storage/releases/download/v${BLUEMAP_S3_STORAGE_VERSION}/BlueMapS3Storage-${BLUEMAP_S3_STORAGE_VERSION}.jar" ######################################## # Stage 2: runtime @@ -48,8 +58,8 @@ COPY --chown=apus:apus runner/bin/ /opt/apus/bin/ # Fixed file name (see telemetry-addon/build.gradle.kts archiveFileName) -- no glob, so # this keeps working once build/libs holds more than one version after a release. COPY --chown=apus:apus telemetry-addon/build/libs/apus-telemetry-addon.jar /work/config/packs/apus-telemetry.jar -# The S3 storage addon; see BlueMapS3Storage releases. -COPY --chown=apus:apus runner/vendor/BlueMapS3Storage.jar /work/config/packs/bluemap-s3-storage.jar +# The S3 storage addon; fetched in the stage above, see BlueMapS3Storage releases. +COPY --from=fetch --chown=apus:apus /download/bluemap-s3-storage.jar /work/config/packs/bluemap-s3-storage.jar RUN chmod +x /opt/apus/entrypoint.sh /opt/apus/bin/*.sh diff --git a/runner/README.md b/runner/README.md index 68e18c1..5a6c77c 100644 --- a/runner/README.md +++ b/runner/README.md @@ -6,16 +6,19 @@ Renders a Minecraft world from S3 with BlueMap and writes the result back to S3. ```bash ./gradlew :telemetry-addon:shadowJar -mkdir -p runner/vendor -curl -fsSL -o runner/vendor/BlueMapS3Storage.jar \ - https://github.com/TheMeinerLP/BlueMapS3Storage/releases/download/v1.5.1/BlueMapS3Storage-1.5.1.jar docker build -f runner/Dockerfile -t apus/runner:dev . ``` +The BlueMap CLI and the BlueMapS3Storage addon are downloaded by the image's own +`fetch` stage — no manual vendoring step is needed, which is also what lets CI build +this image from a fresh clone. Both versions are pinned as build args +(`BLUEMAP_VERSION`, `BLUEMAP_S3_STORAGE_VERSION`); `hosting/Dockerfile` fetches the same +two jars the same way. + The BlueMapS3Storage release asset is versioned (e.g. `BlueMapS3Storage-1.5.1.jar`, not `BlueMapS3Storage.jar`), so `releases/latest/download/BlueMapS3Storage.jar` -returns a 404 — the version-pinned URL above is the primary, reproducible way to -fetch it. Bump the `v1.5.1`/`1.5.1` in the URL when a newer release is needed. +returns a 404 — the version-pinned URL in the Dockerfile is the primary, reproducible +way to fetch it. Bump `BLUEMAP_S3_STORAGE_VERSION` when a newer release is needed. To find the current version without assuming `gh` is installed and authenticated, check the release page directly: @@ -28,14 +31,11 @@ gh release view --repo TheMeinerLP/BlueMapS3Storage --json assets \ --jq '.assets[] | select(.name | startswith("BlueMapS3Storage-")) | .url' ``` -If the download is unavailable, build it locally instead: - -```bash -(cd ../BlueMapS3Storage && ./gradlew shadowJar) -cp ../BlueMapS3Storage/build/libs/BlueMapS3Storage-*.jar runner/vendor/BlueMapS3Storage.jar -``` +To test an unreleased addon change, point the `curl` in the `fetch` stage at your own +artifact temporarily; the image intentionally has no path that copies a jar out of the +build context, because such a path is exactly what broke the CI build before. -The build context is the repository root, because the image needs the addon jar +The build context is the repository root, because the image needs the telemetry addon jar built by Gradle. ## Run From 0614747e0e7b77292733d08c4574a486fdb2f159 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 12:42:03 +0200 Subject: [PATCH 15/18] fix(release-please): resolve extra-files per package and pin the full bootstrap sha extra-files paths are resolved relative to the package path, so "telemetry-addon/build.gradle.kts" resolved to telemetry-addon/telemetry-addon/build.gradle.kts. Generic updaters use createIfMissing: false, so the miss was silent: tag, release and manifest were bumped while the version marker in the build file stayed at 0.1.0 and the publish job republished 0.1.0 over the previous release. Write both paths package-relative, matching the root package's entry. bootstrap-sha is compared against the full 40-character SHA, so the abbreviated value never matched and the first release PR would have swept in all of phases 1-6. --- release-please-config.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index 726df1e..6b7dbb4 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,7 +4,7 @@ "include-component-in-tag": true, "include-v-in-tag": true, "separate-pull-requests": true, - "bootstrap-sha": "33487090", + "bootstrap-sha": "334870908b723cdf0179ffcf4862051010ed0d0c", "pull-request-header": "", "packages": { ".": { @@ -18,14 +18,14 @@ "package-name": "telemetry-addon", "changelog-path": "CHANGELOG.md", "extra-files": [ - { "type": "generic", "path": "telemetry-addon/build.gradle.kts" } + { "type": "generic", "path": "build.gradle.kts" } ] }, "paper-worldpush": { "package-name": "paper-worldpush", "changelog-path": "CHANGELOG.md", "extra-files": [ - { "type": "generic", "path": "paper-worldpush/build.gradle.kts" } + { "type": "generic", "path": "build.gradle.kts" } ] } } From 3f73a8aaff51df878a10e6f87e6e1eebab51c743 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 12:42:20 +0200 Subject: [PATCH 16/18] ci: serialise the image publishes and slim the docker build context docker-publish.yml@v2.4.0 declares a concurrency group of docker-publish-${{ github.workflow }}-${{ github.ref }} with cancel-in-progress: false. Both expressions are identical for all six of our calls, so all six shared one group and the queued jobs cancelled each other. A caller cannot override a callee's concurrency, so chain the six publish jobs with needs: to keep at most one of them queued. Each job keeps a standalone !cancelled() gate, so the edges convey ordering only: nothing publishes without a release, and publish-hosting/publish-ui still take their content from the checkout rather than from the Gradle artifact. The proper long-term fix is a concurrency-suffix input on the central docker-publish.yml. context-path: "." shipped the whole working tree, including .git and every build directory, to five jobs that had already checked the repository out. Narrow it to the four shadow jars. docker-publish checks out first and only then downloads the artifact over the context, and upload-artifact roots a **-prefixed glob at the workspace, so the /build/libs/ prefix the Dockerfiles COPY by name is preserved. Give the two Maven publish jobs contents: read instead of inheriting the workflow-level contents: write, matching the six Docker jobs. --- .github/workflows/release-please.yml | 50 +++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index bdff3dc..116ce7f 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -31,12 +31,32 @@ jobs: java-version: "25" version: ${{ needs.release-please.outputs.root-version }} gradle-command: "./gradlew :telemetry-addon:shadowJar :ingest:shadowJar :operator:shadowJar :api:shadowJar" - context-path: "." + # Only the four shadow jars are needed by the Dockerfiles. Uploading "." would ship + # the whole working tree (including .git and every build/ directory -- hundreds of + # MB) because the central workflow passes context-path straight to + # actions/upload-artifact with include-hidden-files: true. + # upload-artifact roots the archive at the least common ancestor of the matched + # files; the four jars live in four different top-level modules, so that ancestor is + # the workspace root and the artifact keeps the `/build/libs/` prefix the + # Dockerfiles COPY by name. + context-path: "**/build/libs/*.jar" artifact-name: "docker-context" secrets: inherit + # The six publish-* jobs below are chained with `needs` on purpose -- do NOT "optimise" + # them back into parallel jobs. docker-publish.yml@v2.4.0 declares + # concurrency: group: docker-publish-${{ github.workflow }}-${{ github.ref }} + # which every one of these six calls evaluates to the identical string, and a caller + # cannot override a callee's concurrency. Running them in parallel therefore puts all six + # in one concurrency group where they cancel each other. Serialising them keeps at most + # one of them pending at any time. (The proper fix is a `concurrency-suffix` input on the + # central docker-publish.yml; that is an upstream change.) publish-runner: needs: [release-please, build-context] + # First link of the chain. The three jobs after it gate on build-context's own result + # rather than on their predecessor, so a single failing image does not silently skip + # the rest -- the chain exists for serialisation only, not to express a dependency. + if: ${{ !cancelled() && needs.build-context.result == 'success' }} permissions: contents: read id-token: write @@ -50,7 +70,8 @@ jobs: secrets: inherit publish-ingest: - needs: [release-please, build-context] + needs: [release-please, build-context, publish-runner] + if: ${{ !cancelled() && needs.build-context.result == 'success' }} permissions: contents: read id-token: write @@ -64,8 +85,12 @@ jobs: secrets: inherit publish-hosting: - needs: release-please - if: needs.release-please.outputs.root-released == 'true' + # publish-api is a chain link only (see the concurrency note above); hosting builds + # from the checked-out repo and needs no Gradle artifact, so `!cancelled()` keeps its + # gate standalone -- it publishes whenever a root release happened, even if one of the + # Gradle-based images failed. + needs: [release-please, publish-api] + if: ${{ !cancelled() && needs.release-please.outputs.root-released == 'true' }} permissions: contents: read id-token: write @@ -78,7 +103,8 @@ jobs: secrets: inherit publish-operator: - needs: [release-please, build-context] + needs: [release-please, build-context, publish-ingest] + if: ${{ !cancelled() && needs.build-context.result == 'success' }} permissions: contents: read id-token: write @@ -92,7 +118,8 @@ jobs: secrets: inherit publish-api: - needs: [release-please, build-context] + needs: [release-please, build-context, publish-operator] + if: ${{ !cancelled() && needs.build-context.result == 'success' }} permissions: contents: read id-token: write @@ -106,8 +133,9 @@ jobs: secrets: inherit publish-ui: - needs: release-please - if: needs.release-please.outputs.root-released == 'true' + # Last chain link; same standalone gate as publish-hosting. + needs: [release-please, publish-hosting] + if: ${{ !cancelled() && needs.release-please.outputs.root-released == 'true' }} permissions: contents: read id-token: write @@ -122,6 +150,10 @@ jobs: publish-telemetry-addon: needs: release-please if: needs.release-please.outputs.telemetry-released == 'true' + # Narrow the workflow-level `contents: write, pull-requests: write` down to what a + # Maven publish actually needs, matching the six Docker jobs. + permissions: + contents: read uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0 with: java-version: "25" @@ -133,6 +165,8 @@ jobs: publish-paper-worldpush: needs: release-please if: needs.release-please.outputs.paper-released == 'true' + permissions: + contents: read uses: OneLiteFeatherNET/workflows/.github/workflows/gradle-publish.yml@v2.4.0 with: java-version: "25" From 1851343135ad7bf7507805f1f605693a30529058 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 12:42:20 +0200 Subject: [PATCH 17/18] ci: repair the pull-request and markdown-lint workflows pnpm/action-setup reads ${GITHUB_WORKSPACE}/package.json by default; this repo has no root package.json and defaults.run.working-directory does not apply to action inputs, so the ui job failed on its first step on every pull request with "No pnpm version is specified." Point it at ui/package.json. The central markdown-lint workflow defaults config-file to .markdownlint.json, which does not exist here, so markdownlint-cli2 threw ENOENT on every docs PR. Pass .markdownlint-cli2.jsonc. Pin the Gradle PR build to ubuntu-latest: the central default is a three-OS matrix, this project targets Linux only and several tests are path-separator and line-ending sensitive. Restore .github/workflows/** in the code path filter so a CI-only change still runs the build, and add gradlew.bat. --- .github/workflows/build-pr.yml | 13 +++++++++++++ .github/workflows/markdown-lint.yml | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 58871d0..876912e 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -10,6 +10,10 @@ jobs: with: java-version: "25" java-distribution: "temurin" + # The central default is a three-OS matrix. Apus only ever ships to Linux + # containers, so windows/macos runners would burn minutes on a platform nothing + # deploys to. + runs-on: '["ubuntu-latest"]' paths-filters: | code: - '**/*.java' @@ -17,10 +21,14 @@ jobs: - '**/*.properties' - 'gradle/**' - 'gradlew' + - 'gradlew.bat' - '.spotless/**' - '**/src/**/resources/**' - '**/entrypoint.sh' - '**/bin/*.sh' + # Kept from the central default: a CI-only change must still run the build, + # otherwise a workflow edit merges without ever having been exercised. + - '.github/workflows/**' secrets: inherit ui: @@ -30,7 +38,12 @@ jobs: working-directory: ui steps: - uses: actions/checkout@v5 + # There is no root package.json; packageManager lives in ui/package.json, and + # `defaults.run.working-directory` does not apply to action inputs, so the action + # has to be told where to read the pnpm version from. - uses: pnpm/action-setup@v4 + with: + package_json_file: ui/package.json - uses: actions/setup-node@v5 with: node-version-file: ui/.nvmrc diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index a1a1362..6519c97 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -9,4 +9,8 @@ on: jobs: lint: uses: OneLiteFeatherNET/workflows/.github/workflows/markdown-lint.yml@v2.4.0 + with: + # The central default is `.markdownlint.json`, which this repo does not have -- + # markdownlint-cli2 then throws and the job is always red. + config-file: .markdownlint-cli2.jsonc secrets: inherit From af528b5812d89b68a31355e5d91dfa2611678492 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 12:42:20 +0200 Subject: [PATCH 18/18] chore: re-enable MD041 and correct the shadow-jar rationale The MD041 exemption cited SDD briefs and reports under .superpowers/sdd/, but those files are not tracked; the tracked tree lints clean with the rule on. The telemetry-addon publication comment claimed the thin jar would leave consumers resolving relocated dependencies. Every dependency of that module is compileOnly, so its shadow jar relocates nothing. State the actual reason it is published: it is the same file the runner image ships, so no second jar can drift from it. What is published is unchanged. --- .markdownlint-cli2.jsonc | 6 +----- telemetry-addon/build.gradle.kts | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index a216361..ba3f9ea 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -4,11 +4,7 @@ // diffs unreadable. "MD013": false, // Release Please writes the changelog; its heading structure is not ours to police. - "MD024": { "siblings_only": true }, - // SDD task briefs and reports under .superpowers/sdd/ are excerpts of a larger phase - // plan and intentionally start at the same heading level (###) they have there; - // requiring a top-level H1 would misrepresent their place in that hierarchy. - "MD041": false + "MD024": { "siblings_only": true } }, "ignores": [ "**/node_modules/**", diff --git a/telemetry-addon/build.gradle.kts b/telemetry-addon/build.gradle.kts index 262c478..b54d3d3 100644 --- a/telemetry-addon/build.gradle.kts +++ b/telemetry-addon/build.gradle.kts @@ -40,8 +40,11 @@ publishing { create("maven") { groupId = "net.onelitefeather.apus" artifactId = "telemetry-addon" - // The shadow jar is the artifact consumers need -- the thin jar would leave - // them to resolve the relocated dependencies themselves. + // Every dependency of this module is compileOnly, so shadowJar bundles and + // relocates nothing -- its output is content-identical to the thin jar. It is + // published anyway so that the artifact in the Maven repo is literally the same + // file the runner image ships (telemetry-addon/build/libs/apus-telemetry-addon.jar), + // leaving no second jar that could drift from it. artifact(tasks.named("shadowJar")) } }