From f384029b9247c501c7011d1b129d1c3731a9df45 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 11:58:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20hosting=20rende?= =?UTF-8?q?red=20maps=20under=20their=20own=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosting layer on top of Phase 2b. A rendered map now becomes reachable under its own address. A BlueMapHosting resource produces a BlueMap webserver Deployment that reads the finished maps straight from S3, plus Service, Ingress and — where cert-manager is present — a Certificate, and reports the URL back in status. - BlueMapHosting CRD (maps to publish, hostname, ingress class, TLS, replicas); BlueMapConfigBuilder.buildForHosting for the multi-map config plus webserver.conf; a hosting image running the BlueMap CLI in webserver mode with the S3 storage addon, whose entrypoint validates its required environment variables with the same single-mention helper function as the Phase 1 runner image; HostingResourceBuilder for the owned Deployment/Service/Ingress/Certificate; BlueMapHostingReconciler wiring it together, enforcing tenant isolation, restarting pods on config change. - Closes two multi-tenancy holes found in security review: hostnames were never checked against Tenant.spec.hosting.allowedDomains, so a tenant could claim another tenant's hostname; and referenced maps were not confined to the hosting's own namespace, so a hosting could have published another tenant's maps. Both are now enforced with a condition instead of silently proceeding. - Fixes bugs only a real cluster surfaced: ConfigMap data keys may not contain `/` (generated keys are now sanitised, nesting restored via the volume's items[].path); Certificate.CertificateStatus was an empty class that crashed Jackson on any real client write; `-w` alone serves 404 for everything until `-g` has run once. - 204 operator unit tests, plus 8 integration tests against real k3s, including the client.supports(Certificate.class) branch that the mock server could never exercise. This branch replaces clean/phase-3-hosting (PR #12), stacked on clean2/phase-2b-ingest instead of clean/phase-2b-ingest, and squashed on a base where the hosting entrypoint's environment-variable validation already uses the helper function, so no follow-up fix commit is needed to clear the scanner finding. --- .../task-3-report.md | 89 +++ .../plans/2026-08-09-phase-3-hosting.md | 251 ++++++++ hosting/Dockerfile | 55 ++ hosting/README.md | 128 ++++ hosting/bin/config-sync.sh | 100 +++ hosting/entrypoint.sh | 38 ++ operator/build.gradle.kts | 23 +- .../apus/operator/ApusOperator.java | 10 +- .../apus/operator/OperatorConfig.java | 11 +- .../apus/operator/api/BlueMapHosting.java | 54 ++ .../apus/operator/api/BlueMapHostingSpec.java | 140 +++++ .../operator/api/BlueMapHostingStatus.java | 59 ++ .../apus/operator/api/TenantSpec.java | 42 ++ .../hosting/BlueMapHostingReconciler.java | 582 ++++++++++++++++++ .../apus/operator/hosting/Certificate.java | 160 +++++ .../hosting/HostingResourceBuilder.java | 471 ++++++++++++++ .../operator/map/BlueMapConfigBuilder.java | 95 +++ .../apus/operator/ApusOperatorTest.java | 8 +- .../apus/operator/CrdGenerationTest.java | 18 + .../apus/operator/OperatorConfigTest.java | 3 + .../operator/OperatorIntegrationTest.java | 48 +- .../operator/api/HostingResourceTest.java | 106 ++++ .../BlueMapHostingIntegrationTest.java | 383 ++++++++++++ .../hosting/BlueMapHostingReconcilerTest.java | 488 +++++++++++++++ .../hosting/HostingResourceBuilderTest.java | 348 +++++++++++ .../operator/ingest/IngestJobBuilderTest.java | 1 + .../map/BlueMapConfigBuilderTest.java | 67 ++ .../operator/render/RenderJobBuilderTest.java | 1 + .../operator/testsupport/K3sCrdSupport.java | 77 +++ 29 files changed, 3800 insertions(+), 56 deletions(-) create mode 100644 .superpowers/sdd/2026-08-09-phase-3-hosting/task-3-report.md create mode 100644 docs/superpowers/plans/2026-08-09-phase-3-hosting.md create mode 100644 hosting/Dockerfile create mode 100644 hosting/README.md create mode 100755 hosting/bin/config-sync.sh create mode 100755 hosting/entrypoint.sh create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHosting.java create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingSpec.java create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingStatus.java create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconciler.java create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/hosting/Certificate.java create mode 100644 operator/src/main/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilder.java create mode 100644 operator/src/test/java/net/onelitefeather/apus/operator/api/HostingResourceTest.java create mode 100644 operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingIntegrationTest.java create mode 100644 operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java create mode 100644 operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java create mode 100644 operator/src/test/java/net/onelitefeather/apus/operator/testsupport/K3sCrdSupport.java 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 new file mode 100644 index 0000000..09c7652 --- /dev/null +++ b/.superpowers/sdd/2026-08-09-phase-3-hosting/task-3-report.md @@ -0,0 +1,89 @@ +# Phase 3 — Task 3 Report: `HostingResourceBuilder` + +> Note: the brief pointed at `.superpowers/sdd/2026-08-09-phase-3-hosting/task-3-brief.md`, which +> does not exist in this repository. The actual planning document is +> `docs/superpowers/plans/2026-08-09-phase-3-hosting.md` (Task 3 section); this report is filed at +> the analogous `docs/superpowers/reports/` location instead of the instructed `.superpowers/sdd/` +> path, which also does not exist here. + +## Worktree base + +Expected base commit `cd8cc5d` was not the worktree's initial HEAD (`5872a7c`, an older commit; +`api/BlueMapHosting.java` was missing). Ran `git reset --hard feat/phase-3-hosting` to land on +`cd8cc5d feat(operator): add BlueMapHosting CRD and multi-map hosting config builder`, which does +contain `BlueMapHosting`/`BlueMapHostingSpec`/`BlueMapHostingStatus` and the already-implemented +`BlueMapConfigBuilder.buildForHosting`. All work below is built on top of that commit. + +## What was built + +- `operator/src/main/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilder.java` — + pure-function builder producing `Deployment`, `Service`, `Ingress`, and `Optional` + from a `BlueMapHosting`, following `RenderJobBuilder`'s established shape (no client, owner + references, shared `Labels`, `secretKeyRef` credentials). +- `operator/src/main/java/net/onelitefeather/apus/operator/hosting/Certificate.java` — a lean, + single-file client-side model of cert-manager's `Certificate` (`cert-manager.io/v1`), nested + `CertificateSpec`/`CertificateStatus` types instead of the three-file Rook pattern since only + three leaf fields are ever set. Deliberately placed outside `...operator.api` so + `CrdGeneratorMain`'s package filter (`net.onelitefeather.apus.operator.api` only) never picks it + up — verified: `generateCrds` still emits exactly the same 6 CRDs as before this change, no + `cert-manager.io` CRD among them. +- `operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java` + — 16 tests, written before the implementation (TDD), covering every point the plan calls out as + "Tests, die zählen". + +## Design decisions worth flagging + +- **`OperatorConfig` has no `hostingImage` field.** The mandated signature + `deployment(BlueMapHosting, String, String, OperatorConfig)` is implemented as specified, but + since only `runnerImage`/`ingestImage` exist today and this task may not touch `OperatorConfig`, + the container image is a local placeholder constant (`apus/hosting:dev`) with a Javadoc note + that Task 4 must add `OperatorConfig.hostingImage()` and wire it through. The `config` parameter + is already accepted so that change needs no signature edit later. +- **Readiness/liveness probe path** (`HostingResourceBuilder.PROBE_PATH = "/"`) is explicitly + flagged in Javadoc as needing verification against Task 2's actual image, per the brief. + Same treatment given informally to the ConfigMap mount path (`/config-src`), which Task 2's + entrypoint also needs to agree on. +- **Webserver port fixed at 8100** (`HostingResourceBuilder.WEBSERVER_PORT`), matching + `BlueMapConfigBuilder`'s hosting example and Task 2's documented `APUS_WEBSERVER_PORT` default, + since neither `BlueMapHostingSpec` nor `OperatorConfig` carries a port field. Used consistently + for the container port, the Service port/targetPort, the Ingress backend port, and both probes. +- **TLS secret name agreement**: `ingress()` and `certificate()` independently compute + `"-tls"` via a shared private helper (`tlsSecretName`) so the two always agree + without one method calling the other — tested directly + (`producesACertificateWhenTlsIsEnabledAndTheIngressReferencesItsSecret`). +- **S3 endpoint intentionally not passed as an env var** to the container: it's already baked + into each map's `storages/.conf` by `BlueMapConfigBuilder.buildForHosting` at ConfigMap-build + time (a Task 4 concern), so the Deployment only injects the two credential env vars via + `secretKeyRef` plus `APUS_WEBSERVER_PORT`. + +## Test run + +`./gradlew :operator:test` — BUILD SUCCESSFUL. `HostingResourceBuilderTest`: 16/16 passed, 0 +failures, 0 errors. Full module test suite (all existing suites plus the new one) passed; +`generateCrds` still produces exactly 6 CRDs (Tenant, BlueMapMap, BlueMapRender, WorldSource, +WorldIngest, BlueMapHosting) — confirms `Certificate` was not picked up by the CRD generator. + +`spotlessApply` run on `:operator`: no formatting changes needed beyond the new files themselves +(AGPL header applied automatically by Spotless's `licenseHeaderFile`). + +## 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) +- This report (new, docs-only) + +No other file was touched — `OperatorConfig.java`, anything under the repo-root `hosting/` +directory (Task 2's scope), and all other existing sources are untouched (`git status` confirms +only the two new `hosting/` package directories under `operator/`). + +## Concerns for later tasks + +- Task 4 must add `OperatorConfig.hostingImage()` (or equivalent) and update `deployment()`'s call + site — currently a placeholder image string. +- Task 2 must confirm/correct `PROBE_PATH` and the ConfigMap mount path + (`HostingResourceBuilder.CONFIG_MOUNT_PATH`, currently `/config-src`) against the real image. +- Task 4's reconciler must call `client.supports(Certificate.class)` before touching Certificate + objects (per the plan) — `HostingResourceBuilder.certificate()` itself has no client and cannot + perform that check; it only decides *whether* to build one from `spec.tls.enabled`. diff --git a/docs/superpowers/plans/2026-08-09-phase-3-hosting.md b/docs/superpowers/plans/2026-08-09-phase-3-hosting.md new file mode 100644 index 0000000..c29c782 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-phase-3-hosting.md @@ -0,0 +1,251 @@ +# Apus Phase 3 — Hosting: 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:** Gerenderte Karten unter einer eigenen Adresse erreichbar machen. Eine `BlueMapHosting`-Ressource erzeugt ein BlueMap-Webserver-Deployment, das die Karten direkt aus S3 liest, samt Service, Ingress und Zertifikat — und meldet die URL im Status zurück. + +**Architecture:** Der BlueMap-CLI kann Render- und Webserver-Betrieb sauber trennen (`-w/--webserver`). Ein Hosting-Pod ist derselbe CLI im Webserver-Modus mit dem `BlueMapS3Storage`-Addon, das die fertigen Karten aus dem Map-Bucket liest. Anders als beim Render braucht dieser Pod eine **echte Konfigurationsdatei**: Der Umgebungsvariablen-Vertrag aus Phase 1 deckt `webserver.conf` und die Liste der anzuzeigenden Karten nicht ab. Genau dafür wurde `BlueMapConfigBuilder` in Phase 2a gebaut und bewusst unverdrahtet aufgehoben. + +**Tech Stack:** Java 25, JOSDK 5.5.1, Fabric8 7.8.0, BlueMap-CLI 5.23, JUnit Jupiter, Fabric8 Mock-Server, Testcontainers. + +## Global Constraints + +- Java-Toolchain 25, Basispaket `net.onelitefeather.apus.operator`. +- API-Gruppe `bluemap.onelitefeather.net`, Version `v1alpha1`. `BlueMapHosting` ist **namespaced**. +- `initSpec()`/`initStatus()` überschreiben, alle Gruppen im Feld initialisieren — ein `null`-Spec hat in Phase 2a bereits drei parallele Aufgaben blockiert. Der rekursive Null-Check-Test in `IngestResourceTest` zeigt das Muster. +- **Eigentümerprüfung** über Name **und** UID vor jedem Schreibvorgang, Konflikt-Condition statt Übernahme. Das war ein Sicherheitsbefund in Phase 2a. +- **Gemeinsame `Labels`-Klasse** für alle erzeugten Ressourcen. +- `client.supports(...)` für fremde CRDs (`Certificate` von cert-manager), damit ein fehlender cert-manager nicht zum Absturz führt. +- Zugangsdaten niemals in Status, Events, Logs oder ConfigMaps — S3-Zugangsdaten kommen über `secretKeyRef` aus dem von Rook erzeugten Secret. +- AGPL-Header über Spotless, Conventional Commits, **keine** Claude-Attribution, Bezeichner und Javadoc auf Englisch. + +### Was bereits existiert und zu benutzen ist + +- `BlueMapConfigBuilder` (Phase 2a) erzeugt `core.conf`, `maps/.conf` und `storages/s3.conf`. Für Phase 3 kommt `webserver.conf` dazu, und es müssen **mehrere** Karten in einer Konfiguration stehen. +- `Labels`, `Conditions`, `Ref`, `OperatorConfig` +- Das Eigentümer- und Sperrmuster aus `BlueMapMapReconciler` und `BlueMapRenderReconciler` +- `runner/` als Vorbild für ein Container-Image (nicht-root, Pflichtvariablen zuerst, `exec`) +- Verifizierte CLI-Flags: `-w/--webserver` startet nur den Webserver, `-c ` setzt den Konfigurationsordner, der `packs/`-Ordner liegt fest unter `/packs` + +### Verifizierte Cluster-Gegebenheiten + +Aus `Kubernetes-FLUX`: Es gibt zwei IngressClasses (`nginx` und `cloudflare-tunnel`), cert-manager mit step-issuer, und Rook-Ceph als S3. Der Hosting-Pod liest aus demselben Bucket, in den der Render schreibt. + +--- + +## File Structure + +``` +hosting/ neues Modul: Container-Image +├── Dockerfile +├── entrypoint.sh +├── bin/hosting-config.sh +└── README.md + +operator/src/main/java/net/onelitefeather/apus/operator/ +├── api/BlueMapHosting.java BlueMapHostingSpec.java BlueMapHostingStatus.java +└── hosting/ + ├── BlueMapHostingReconciler.java + └── HostingResourceBuilder.java Deployment, Service, Ingress, Certificate +``` + +--- + +## 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 + +--- + +### 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` +- Modify: `operator/src/test/java/.../map/BlueMapConfigBuilderTest.java` +- Modify: `operator/src/test/java/.../CrdGenerationTest.java` + +**Interfaces:** + +```java +// BlueMapHostingSpec — alle Gruppen im Feld initialisiert +List maps = new ArrayList<>(); // Karten, die dieser Webserver anzeigt +String hostname; // Pflicht +String ingressClassName = "nginx"; +Tls tls = new Tls(); // Ref issuerRef; String issuerKind = "ClusterIssuer"; + // boolean enabled = true +int replicas = 1; +Resources resources = new Resources(); // String cpu; String memory + +// BlueMapHostingStatus +String url; // "https://" sobald bereit +boolean ready; +List conditions = new ArrayList<>(); + +// BlueMapConfigBuilder — erweitert um den Hosting-Fall +public static Map buildForHosting( + List maps, List bindings, int webserverPort); +``` + +**Der inhaltliche Unterschied zum Render-Fall:** Ein Render-Pod kennt genau eine Karte und bekommt seine Konfiguration aus Umgebungsvariablen. Ein Hosting-Pod zeigt **mehrere** Karten und braucht zusätzlich `webserver.conf`. Für jede Karte entsteht eine eigene `maps/.conf` und ein eigener Storage-Eintrag, weil die Karten in unterschiedlichen Buckets liegen können. + +- [ ] **Step 1: Den fehlschlagenden Test für die Ressource schreiben** + +Nach dem Muster von `IngestResourceTest`: namespaced, alle Gruppen initialisiert (rekursiv geprüft), Vorgabewerte (`ingressClassName` = `nginx`, `replicas` = 1, `tls.enabled` = true). + +- [ ] **Step 2: Ressource implementieren, Test grün bekommen** + +- [ ] **Step 3: Den fehlschlagenden Test für die Hosting-Konfiguration schreiben** + +```java + @Test + void hostingConfigContainsOneMapFilePerMap() { + Map files = BlueMapConfigBuilder.buildForHosting( + List.of(map("survival-overworld"), map("creative-overworld")), + List.of(binding("bucket-a"), binding("bucket-b")), 8100); + + assertTrue(files.containsKey("maps/survival-overworld.conf"), files.keySet().toString()); + assertTrue(files.containsKey("maps/creative-overworld.conf"), files.keySet().toString()); + } + + @Test + void hostingConfigContainsAWebserverConfigBoundToAllInterfaces() { + Map files = BlueMapConfigBuilder.buildForHosting( + List.of(map("survival-overworld")), List.of(binding("bucket-a")), 8100); + + String webserver = files.get("webserver.conf"); + assertNotNull(webserver, files.keySet().toString()); + assertTrue(webserver.contains("8100"), webserver); + // A pod must accept connections from the service, not just from localhost. + assertTrue(webserver.contains("0.0.0.0"), webserver); + } + + @Test + void eachMapGetsItsOwnStorageBecauseBucketsCanDiffer() { + Map files = BlueMapConfigBuilder.buildForHosting( + List.of(map("a"), map("b")), + List.of(binding("bucket-a"), binding("bucket-b")), 8100); + + assertTrue(files.get("maps/a.conf").contains("storage: \"a\""), files.get("maps/a.conf")); + assertTrue(files.get("maps/b.conf").contains("storage: \"b\""), files.get("maps/b.conf")); + assertTrue(files.containsKey("storages/a.conf"), files.keySet().toString()); + assertTrue(files.containsKey("storages/b.conf"), files.keySet().toString()); + } + + @Test + void neverPutsCredentialsIntoTheHostingConfig() { + Map files = BlueMapConfigBuilder.buildForHosting( + List.of(map("a")), List.of(binding("bucket-a")), 8100); + + for (Map.Entry file : files.entrySet()) { + assertFalse(file.getValue().contains("secret-access-key: \""), + "credentials must not be in " + file.getKey()); + } + } +``` + +- [ ] **Step 4: `buildForHosting` implementieren, Test grün bekommen** + +Zugangsdaten bleiben auch hier draußen — der Entrypoint des Hosting-Images setzt sie beim Start aus der Umgebung ein, genau wie im Runner. + +**Zu verifizieren beim Bau des Images (Task 2):** Der Schlüsselname für die Bind-Adresse in `webserver.conf` stammt aus BlueMaps Default-Konfiguration. Prüfe ihn gegen die echte, vom CLI erzeugte Datei und korrigiere Plan wie Code, falls er abweicht. + +- [ ] **Step 5: CRD-Zusicherung ergänzen und committen** + +`bluemaphostings` wird erzeugt und trägt `scope: Namespaced`. Danach liegen sechs CRDs vor. + +--- + +### Task 2: Hosting-Image *(parallel mit Task 3)* + +> Eigener Worktree. Ausschließlich Dateien unter `hosting/`. Prüfe zuerst die Worktree-Basis (`git log --oneline -1`) — in früheren Phasen wurden Worktrees vom falschen Stand abgezweigt. + +Analog zu `runner/`, aber im Webserver-Modus. Der Container läuft **dauerhaft**, nicht als Job. + +**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 | +| `APUS_S3_REGION` | nein | Default `us-east-1` | +| `APUS_WEBSERVER_PORT` | nein | Default `8100` | + +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. +- Zugangsdaten dürfen nicht in der Prozess-Kommandozeile stehen. `runner/bin/bundle-sync.sh` erklärt im Kommentar, warum das im Runner über eine Konfigurationsdatei gelöst wurde. + +**Verifikation:** Image bauen, gegen ein MinIO mit einer zuvor gerenderten Karte starten, und mit einem HTTP-Aufruf belegen, dass die Karte ausgeliefert wird. Ohne diesen Nachweis gilt die Aufgabe als nicht erledigt. + +--- + +### Task 3: Kubernetes-Ressourcen für das Hosting *(parallel mit Task 2)* + +> Eigener Worktree. Ausschließlich `operator/src/main/java/.../hosting/HostingResourceBuilder.java` und sein Test. Prüfe zuerst die Worktree-Basis. + +```java +public final class HostingResourceBuilder { + public static Deployment deployment(BlueMapHosting hosting, String configMapName, + String bucketSecretName, OperatorConfig config); + public static Service service(BlueMapHosting hosting); + public static Ingress ingress(BlueMapHosting hosting); + /** @return empty when TLS is disabled */ + public static Optional certificate(BlueMapHosting hosting); +} +``` + +**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. +- Ist TLS aktiviert, entsteht ein `Certificate` und der Ingress verweist auf dessen Secret; ist es deaktiviert, entsteht keins. +- Das Deployment mountet die Konfigurations-ConfigMap. +- Bereitschafts- und Lebendigkeitsprüfung sind gesetzt. **Begründung:** Ohne Bereitschaftsprüfung schickt der Service Anfragen an einen Pod, der die Karten noch aus S3 lädt. + +`Certificate` ist eine cert-manager-Ressource; modelliere sie schlank als eigene `CustomResource`, wie es für die Rook-Typen gemacht wurde, und **nicht** über die CRD-Generierung. + +--- + +### Task 4: `BlueMapHostingReconciler` + +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. +- Ändern sich die Karten, muss die ConfigMap aktualisiert **und** ein Neustart der Pods ausgelöst werden — BlueMap liest seine Konfiguration nur beim Start. Der übliche Weg ist eine Annotation am Pod-Template mit einer Prüfsumme der Konfiguration. +- Registrierung in `ApusOperator`. + +--- + +### Task 5: Integrationstest + +Gegen k3s und MinIO: Eine gerenderte Karte in MinIO ablegen (nutze das Ergebnis aus dem bestehenden Render-Integrationstest oder rendere sie im Test), eine `BlueMapHosting` anlegen, reconcilen, und belegen, dass Deployment, Service und Ingress entstehen und die ConfigMap die erwarteten Kartendateien enthält. + +Der vollständige Netzwerkweg über einen echten Ingress-Controller ist auf k3s aufwendig; belege stattdessen, dass der Hosting-Pod selbst die Karte ausliefert (das deckt Task 2 bereits ab) und dass die erzeugten Kubernetes-Ressourcen zusammenpassen. Halte im Report fest, was damit **nicht** abgedeckt ist. + +Eigene `integrationTest`-Task, nicht Teil von `build`. + +--- + +## Abschluss Phase 3 + +Danach ist eine gerenderte Karte unter ihrer eigenen Adresse erreichbar, und der Weg von der Welt-Quelle bis zur öffentlichen Karte läuft ohne Handgriff. + +**Nicht Teil von Phase 3:** Authentifizierung vor der Karte, mehrere Hostnamen pro Hosting, und die Einbettung der Karte in die Apus-UI (Phase 5). diff --git a/hosting/Dockerfile b/hosting/Dockerfile new file mode 100644 index 0000000..0b37c91 --- /dev/null +++ b/hosting/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +######################################## +# 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 (see runner/README.md, which +# fetches the same jar the same way). Bump BLUEMAP_S3_STORAGE_VERSION when a newer release +# is needed. +RUN curl -fsSL -o bluemap-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 +######################################## +FROM eclipse-temurin:25-jre-jammy + +# Non-root: this pod only ever writes below /work (its BlueMap config copy and generated +# webapp/data files); everything else is read-only image content. +RUN useradd --uid 10001 --create-home --home-dir /home/apus apus \ + && mkdir -p /work/config-src /work/config \ + && chown -R apus:apus /work + +COPY --from=fetch /download/bluemap-cli.jar /opt/bluemap/cli.jar +# The S3 storage addon is a binary plugin baked into the image, not configuration -- it is +# not part of the ConfigMap the operator mounts. config-sync.sh copies it from here into the +# writable config dir's packs/ folder on every start. +COPY --from=fetch --chown=apus:apus /download/bluemap-s3-storage.jar /opt/apus/packs/bluemap-s3-storage.jar +COPY --chown=apus:apus entrypoint.sh /opt/apus/entrypoint.sh +COPY --chown=apus:apus bin/ /opt/apus/bin/ + +RUN chmod +x /opt/apus/entrypoint.sh /opt/apus/bin/*.sh + +USER apus +WORKDIR /work + +ENV APUS_S3_REGION=us-east-1 \ + APUS_WEBSERVER_PORT=8100 + +EXPOSE 8100 + +ENTRYPOINT ["/opt/apus/entrypoint.sh"] diff --git a/hosting/README.md b/hosting/README.md new file mode 100644 index 0000000..2b9c974 --- /dev/null +++ b/hosting/README.md @@ -0,0 +1,128 @@ +# Apus Hosting Image + +Serves already-rendered BlueMap maps from S3, over HTTP, for as long as the pod lives. +Uses the same BlueMap CLI as `runner/`, but in webserver mode (`-w`) instead of render mode +(`-r`), reading tiles directly out of S3 through the `BlueMapS3Storage` addon instead of +writing them. + +Unlike `runner/`, which is a one-shot job configured entirely through environment variables, +this image runs as a long-lived Deployment and gets its map/storage configuration from a +**mounted ConfigMap** (built by `BlueMapConfigBuilder.buildForHosting()` in `operator/`) -- +several maps and a `webserver.conf` don't fit an environment-variable contract the way a +single render does. + +## Build + +```bash +docker build -f hosting/Dockerfile -t apus/hosting:dev hosting +``` + +The build context is `hosting/` itself -- this image has no dependency on anything else in +the repository; both the BlueMap CLI and the `BlueMapS3Storage` addon are fetched from their +GitHub releases in the Dockerfile's `fetch` stage. + +## Run + +The container expects two things to be mounted/set at start: + +1. A BlueMap configuration directory at `/work/config-src`, **read-only** -- in production + this is the `BlueMapHosting` ConfigMap the operator mounts; for manual testing, any + directory containing `webserver.conf`, `maps/*.conf` and `storages/*.conf` in BlueMap's + own format works (see `operator/src/main/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilder.java#buildForHosting` + for the exact shape the operator produces). +2. S3 credentials via environment variables (never via the ConfigMap -- a ConfigMap is + readable by anything in the namespace, so the operator's config builder never writes + credentials into it). + +```bash +docker run --rm -p 8100:8100 \ + -v "$(pwd)/my-hosting-config:/work/config-src:ro" \ + -e APUS_S3_ENDPOINT=http://minio:9000 \ + -e APUS_S3_ACCESS_KEY=... \ + -e APUS_S3_SECRET_KEY=... \ + -e APUS_S3_REGION=us-east-1 \ + -e APUS_WEBSERVER_PORT=8100 \ + apus/hosting:dev +``` + +### 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 | +| `APUS_S3_REGION` | no | `us-east-1` | | +| `APUS_WEBSERVER_PORT` | no | `8100` | Only used as a fallback if the mounted config has no `webserver.conf` at all; the normal (operator-driven) path always has one, already carrying the real port | + +Checked before anything else runs, in that order -- an unset required variable aborts +immediately with a non-zero exit and a message naming the missing variable, before the +config directory is even touched. + +## Why the entrypoint copies the config before touching it + +`/work/config-src` is a **read-only** mount (a Kubernetes ConfigMap volume). The operator's +`BlueMapConfigBuilder.buildForHosting()` deliberately never writes S3 credentials into the +`storages/*.conf` files it puts in that ConfigMap -- a ConfigMap is readable by anything in +the namespace, so credentials must never end up in one. `hosting/bin/config-sync.sh` copies +the whole tree into a writable directory (`/work/config`) first, then appends +`access-key-id`/`secret-access-key` to every `storages/*.conf` file found there, using the +env vars above. Everything else it does (filling in `endpoint-url`/`region` on a storage file +if missing, writing a default `webserver.conf`/`core.conf` if the mounted config has none) is +gap-filling only, never an override -- see the comment at the top of that script. + +Credentials are appended via a heredoc/redirection, **never** as a command-line argument to +any program. See `runner/bin/bundle-sync.sh`'s comment for why: every argument on a process's +command line is readable by any other process in the same PID namespace via +`/proc//cmdline`. `mc`'s own `alias set` subcommand has exactly this problem, which is +why `bundle-sync.sh` writes an `mc` config file instead of invoking `mc alias set` with the +secret as an argument. This image never invokes anything with the secret as an argument +either -- it only ever appends it to a file. + +## Readiness path (for Task 4's Kubernetes probes) + +**`GET /settings.json`** is the right path for both liveness and readiness checks. + +Determined empirically against a real BlueMap 5.23 CLI, not assumed: + +- `GET /settings.json` returns HTTP `200` with a JSON body once the webserver has started + *and* run its webapp-generation step (`-g`) at least once. Before `-g` has run, it 404s -- + it is a generated static file, not something the webserver computes on the fly. +- Its body includes `"maps": [...]`, listing every map id the currently-loaded config knows + about (e.g. `{"maps":["overworld"], ...}`) -- so a `200` here is proof both that the + webserver process is accepting connections *and* that it parsed the mounted map configs + successfully. This is why the entrypoint runs BlueMap with the combined `-gw` flag: `-w` + alone never populates `/settings.json` (or `/`) at all, only files that already exist under + `webroot` (see `entrypoint.sh`'s comment for the full trail). +- It does **not** depend on any map's underlying tile data actually existing in S3 -- it + reflects configuration, not storage content. That is exactly the right property for a + Kubernetes probe: it fails when the process or its config is broken, not when a particular + map's render happens to be incomplete. +- Per-map endpoints (e.g. `/maps//settings.json`, `/maps//live/markers.json`) *do* + depend on storage content and 404 until actual tile data exists there -- unsuitable for a + generic pod-level probe (would make the probe depend on the health of a specific map's + storage rather than the pod itself), but useful for smoke-testing that a *specific* map + serves real data (see the "Verified end-to-end" section below). + +Suggested Kubernetes probe config for Task 4: `httpGet` on path `/settings.json`, port +`$APUS_WEBSERVER_PORT` (default `8100`), expecting `200`. + +## Verified end-to-end + +Built and run against a real MinIO instance seeded with a map rendered by `runner/` from +`testdata/mini-world` (BlueMap 5.23, `BlueMapS3Storage` v1.5.1, `themeinerlp:s3` storage +type): + +1. `apus/hosting:dev` started with no S3 env vars set at all -- exited non-zero immediately + with `APUS_S3_ENDPOINT is required`, before touching `/work/config-src` or starting Java. +2. `apus/hosting:dev` started against MinIO, with a hand-built config directory (`webserver.conf`, + `maps/overworld.conf`, `storages/overworld.conf` -- the last one *without* credentials, in + the same shape `buildForHosting()` produces) mounted read-only at `/work/config-src`. +3. `GET http://localhost:8100/settings.json` returned `200` with `"maps":["overworld"]`. +4. `GET http://localhost:8100/maps/overworld/settings.json` returned `200` with tile-set + metadata for the map actually rendered into MinIO by `runner/` -- proof the webserver is + reading real tile data back out of S3 through `BlueMapS3Storage`, not just echoing static + config. + +See `.superpowers/sdd/2026-08-09-phase-3-hosting/task-2-report.md` for the exact commands and +raw output of this run. diff --git a/hosting/bin/config-sync.sh b/hosting/bin/config-sync.sh new file mode 100755 index 0000000..fd372bc --- /dev/null +++ b/hosting/bin/config-sync.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Copies the read-only ConfigMap-mounted BlueMap configuration into a writable directory, +# then fills in exactly what a ConfigMap cannot safely carry -- nothing more. +# +# A ConfigMap is readable by anything in the namespace, so the operator's +# BlueMapConfigBuilder.buildForHosting() deliberately never writes S3 credentials into the +# storages/*.conf files it generates (see that class's javadoc in operator/). This script +# appends them here, from this pod's environment, into a copy that lives on a writable +# volume -- the mounted ConfigMap itself stays untouched and read-only throughout. +# +# Everything else this script fills in (endpoint-url/region on a storage file, a whole +# missing webserver.conf or core.conf) is a *gap-filling* default, not an override: it is +# only written when the corresponding key or file is absent. In the normal deployment path +# the operator already writes all of those, so this never fires; it exists so the image +# stays usable stand-alone (e.g. for local testing without a running operator) without ever +# clobbering a value the operator deliberately chose. +# +# Credentials are appended via heredoc/redirection, never as a command-line argument to any +# program -- see runner/bin/bundle-sync.sh's comment for why that matters: every argument on +# a process's command line is readable by any other process in the same PID namespace via +# /proc//cmdline. +set -euo pipefail + +SRC="${1:?source config dir required}" +DEST="${2:?destination config dir required}" +PACKS_SRC="${3:?packs source dir required}" + +if [ ! -d "${SRC}" ]; then + echo "[apus] ERROR: config source ${SRC} does not exist -- mount the BlueMapHosting ConfigMap there" >&2 + exit 6 +fi + +if [ -z "$(find "${SRC}" -type f -print -quit)" ]; then + echo "[apus] ERROR: config source ${SRC} is empty -- nothing to host" >&2 + exit 6 +fi + +# Escape a value for embedding in a HOCON quoted string (backslash first, then quote) -- +# mirrors runner/bin/render-config.sh's hocon_escape. +hocon_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + printf '%s' "${s}" +} + +rm -rf "${DEST}" +mkdir -p "${DEST}" +cp -r "${SRC}/." "${DEST}/" +# The copy inherits the mounted ConfigMap's read-only permissions; BlueMap needs to write +# generated files (webapp assets, sorting caches) into this directory at runtime. +chmod -R u+rwX "${DEST}" + +mkdir -p "${DEST}/storages" +shopt -s nullglob +for storage_conf in "${DEST}"/storages/*.conf; do + { + echo "" + echo "# Injected by the hosting entrypoint at pod start; deliberately absent from the" + echo "# mounted ConfigMap because a ConfigMap is readable by anything in the namespace." + echo "access-key-id: \"$(hocon_escape "${APUS_S3_ACCESS_KEY}")\"" + echo "secret-access-key: \"$(hocon_escape "${APUS_S3_SECRET_KEY}")\"" + if ! grep -q '^endpoint-url:' "${storage_conf}"; then + echo "endpoint-url: \"$(hocon_escape "${APUS_S3_ENDPOINT}")\"" + fi + if ! grep -q '^region:' "${storage_conf}"; then + echo "region: \"$(hocon_escape "${APUS_S3_REGION}")\"" + fi + } >> "${storage_conf}" + # Now contains the S3 secret key; must not be world-readable. + chmod 600 "${storage_conf}" +done +shopt -u nullglob + +if [ ! -f "${DEST}/webserver.conf" ]; then + echo "[apus] WARN: no webserver.conf in the mounted config, writing a default for port ${APUS_WEBSERVER_PORT}" >&2 + cat > "${DEST}/webserver.conf" < "${DEST}/core.conf" <&2 + exit 1 + fi +} + +require_env APUS_S3_ENDPOINT +require_env APUS_S3_ACCESS_KEY +require_env APUS_S3_SECRET_KEY + +APUS_S3_REGION="${APUS_S3_REGION:-us-east-1}" +APUS_WEBSERVER_PORT="${APUS_WEBSERVER_PORT:-8100}" +export APUS_S3_REGION APUS_WEBSERVER_PORT + +CONFIG_SRC=/work/config-src +CONFIG_DIR=/work/config +PACKS_SRC=/opt/apus/packs + +/opt/apus/bin/config-sync.sh "${CONFIG_SRC}" "${CONFIG_DIR}" "${PACKS_SRC}" + +# -g (re)generates the static web-app shell (index.html/assets/settings.json) into webroot; +# -w starts the webserver. Both run in the same process so the exec below still gets SIGTERM. +# +# Verified against BlueMap 5.23: -w alone only serves whatever already exists under webroot +# -- with no prior -g run, even /settings.json (a file -g generates, not something the +# webserver computes) 404s, and so does /. -gw combined generates the webapp shell and then +# starts serving it, which is what a stateless pod needs on every start since nothing about +# /work persists across restarts. See hosting/README.md for the full verification trail. +ARGS=(-c "${CONFIG_DIR}" -gw) + +echo "[apus] starting BlueMap webserver: ${ARGS[*]}" +exec java -jar /opt/bluemap/cli.jar "${ARGS[@]}" diff --git a/operator/build.gradle.kts b/operator/build.gradle.kts index 6a832ac..b8656e9 100644 --- a/operator/build.gradle.kts +++ b/operator/build.gradle.kts @@ -91,25 +91,28 @@ tasks.named("build") { tasks.test { dependsOn(generateCrds) systemProperty("apus.crd.dir", crdOutputDir.get().asFile.absolutePath) - // OperatorIntegrationTest starts a k3s container and is not part of the routine - // build/check run -- see the integrationTest task below for why. - exclude("**/OperatorIntegrationTest.class") + // OperatorIntegrationTest and BlueMapHostingIntegrationTest each start a k3s container and + // are not part of the routine build/check run -- see the integrationTest task below for why. + // Matched by naming convention (every real-cluster test class ends in "IntegrationTest") + // rather than by an ever-growing explicit list. + exclude("**/*IntegrationTest.class") } -// OperatorIntegrationTest starts a k3s container (via Testcontainers) to apply the generated -// CRDs against a real API server and reconcile a Tenant end to end. That is minutes of work -// and requires Docker, so -- exactly like runner/build.gradle.kts does for its own -// container-based tests -- it must not run as part of the routine `./gradlew build`/`check`. -// It is disabled in the default `test` task above and exposed only via this explicit task. +// OperatorIntegrationTest and BlueMapHostingIntegrationTest each start a k3s container (via +// Testcontainers) to apply the generated CRDs against a real API server and reconcile real +// resources end to end. That is minutes of work and requires Docker, so -- exactly like +// runner/build.gradle.kts does for its own container-based tests -- neither runs as part of the +// routine `./gradlew build`/`check`. Both are disabled in the default `test` task above and +// exposed only via this explicit task. val integrationTest by tasks.registering(Test::class) { group = "verification" - description = "Runs OperatorIntegrationTest against a real k3s cluster started via Testcontainers. " + + description = "Runs the *IntegrationTest classes against a real k3s cluster started via Testcontainers. " + "Requires Docker. Not part of build/check." testClassesDirs = sourceSets.test.get().output.classesDirs classpath = sourceSets.test.get().runtimeClasspath dependsOn(generateCrds) systemProperty("apus.crd.dir", crdOutputDir.get().asFile.absolutePath) - include("**/OperatorIntegrationTest.class") + include("**/*IntegrationTest.class") // Pulling the k3s image and letting the API server come up takes real time on a cold // Docker cache; generous but finite so a hung container fails the build instead of the // run hanging forever. diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/ApusOperator.java b/operator/src/main/java/net/onelitefeather/apus/operator/ApusOperator.java index ddc025d..1ef00e7 100644 --- a/operator/src/main/java/net/onelitefeather/apus/operator/ApusOperator.java +++ b/operator/src/main/java/net/onelitefeather/apus/operator/ApusOperator.java @@ -20,6 +20,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientBuilder; import io.javaoperatorsdk.operator.Operator; +import net.onelitefeather.apus.operator.hosting.BlueMapHostingReconciler; import net.onelitefeather.apus.operator.ingest.WorldIngestReconciler; import net.onelitefeather.apus.operator.ingest.WorldSourceReconciler; import net.onelitefeather.apus.operator.map.BlueMapMapReconciler; @@ -28,7 +29,7 @@ /** * The operator's process entry point: builds a Kubernetes client and {@link OperatorConfig} from - * the environment, registers the five reconcilers against a single {@link Operator} instance, + * the environment, registers the six reconcilers against a single {@link Operator} instance, * and starts it. * *

There is no Micronaut (or any other framework) integration here on purpose -- the Java @@ -73,12 +74,12 @@ public static void main(String[] args) { return; } - System.out.println( - "[apus-operator] started, watching Tenant/BlueMapMap/BlueMapRender/WorldSource/WorldIngest resources"); + System.out.println("[apus-operator] started, watching Tenant/BlueMapMap/BlueMapRender/WorldSource/" + + "WorldIngest/BlueMapHosting resources"); } /** - * Registers all five reconcilers on {@code operator}. Extracted from {@link #main} so a + * Registers all six reconcilers on {@code operator}. Extracted from {@link #main} so a * test can exercise the wiring itself -- that every reconciler this operator ships is * actually registered -- against a mock {@link KubernetesClient} instead of a real cluster. */ @@ -88,6 +89,7 @@ static void registerReconcilers(Operator operator, KubernetesClient client, Oper operator.register(new BlueMapRenderReconciler(client, config)); operator.register(new WorldSourceReconciler(client)); operator.register(new WorldIngestReconciler(client, config)); + operator.register(new BlueMapHostingReconciler(client, config)); } /** diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java b/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java index d80054b..99fdea9 100644 --- a/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java +++ b/operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java @@ -30,6 +30,9 @@ * @param bucketStorageClass the StorageClass used for {@code ObjectBucketClaim}s * @param runnerImage the container image running BlueMap renders * @param ingestImage the container image running world ingest jobs (see {@code ingest/README.md}) + * @param hostingImage the container image running the long-lived {@code BlueMapHosting} + * webserver (see {@code hosting/README.md}); wired into {@code + * net.onelitefeather.apus.operator.hosting.HostingResourceBuilder#deployment} * @param bundleBucket the S3-compatible bucket every ingested world bundle is written to. * Deliberately operator-wide rather than a {@code WorldSource}/{@code WorldIngest} spec * field: neither phase 2b CRD carries a bundle-destination field of its own (only @@ -50,6 +53,7 @@ public record OperatorConfig( String bucketStorageClass, String runnerImage, String ingestImage, + String hostingImage, String bundleBucket, String bundleS3Endpoint, String bundleS3Region, @@ -60,6 +64,7 @@ public record OperatorConfig( private static final String DEFAULT_BUCKET_STORAGE_CLASS = "ceph-bucket-fr01"; private static final String DEFAULT_RUNNER_IMAGE = "apus/runner:dev"; private static final String DEFAULT_INGEST_IMAGE = "apus/ingest:dev"; + private static final String DEFAULT_HOSTING_IMAGE = "apus/hosting:dev"; private static final String DEFAULT_BUNDLE_BUCKET = "apus-bundles"; private static final String DEFAULT_BUNDLE_S3_ENDPOINT = "http://rgw.rook-ceph-fr01.svc:80"; private static final String DEFAULT_BUNDLE_S3_REGION = "us-east-1"; @@ -73,6 +78,7 @@ public static OperatorConfig defaults() { DEFAULT_BUCKET_STORAGE_CLASS, DEFAULT_RUNNER_IMAGE, DEFAULT_INGEST_IMAGE, + DEFAULT_HOSTING_IMAGE, DEFAULT_BUNDLE_BUCKET, DEFAULT_BUNDLE_S3_ENDPOINT, DEFAULT_BUNDLE_S3_REGION, @@ -88,8 +94,8 @@ public static OperatorConfig defaults() { * *

Recognised variables: {@code APUS_ROOK_NAMESPACE}, {@code APUS_CEPH_OBJECT_STORE}, * {@code APUS_BUCKET_STORAGE_CLASS}, {@code APUS_RUNNER_IMAGE}, {@code APUS_INGEST_IMAGE}, - * {@code APUS_BUNDLE_BUCKET}, {@code APUS_BUNDLE_S3_ENDPOINT}, {@code - * APUS_BUNDLE_S3_REGION}, {@code APUS_BUNDLE_CREDENTIALS_SECRET}. + * {@code APUS_HOSTING_IMAGE}, {@code APUS_BUNDLE_BUCKET}, {@code APUS_BUNDLE_S3_ENDPOINT}, + * {@code APUS_BUNDLE_S3_REGION}, {@code APUS_BUNDLE_CREDENTIALS_SECRET}. */ public static OperatorConfig fromEnvironment(Function env) { return new OperatorConfig( @@ -98,6 +104,7 @@ public static OperatorConfig fromEnvironment(Function env) { valueOrDefault(env.apply("APUS_BUCKET_STORAGE_CLASS"), DEFAULT_BUCKET_STORAGE_CLASS), valueOrDefault(env.apply("APUS_RUNNER_IMAGE"), DEFAULT_RUNNER_IMAGE), valueOrDefault(env.apply("APUS_INGEST_IMAGE"), DEFAULT_INGEST_IMAGE), + valueOrDefault(env.apply("APUS_HOSTING_IMAGE"), DEFAULT_HOSTING_IMAGE), valueOrDefault(env.apply("APUS_BUNDLE_BUCKET"), DEFAULT_BUNDLE_BUCKET), valueOrDefault(env.apply("APUS_BUNDLE_S3_ENDPOINT"), DEFAULT_BUNDLE_S3_ENDPOINT), valueOrDefault(env.apply("APUS_BUNDLE_S3_REGION"), DEFAULT_BUNDLE_S3_REGION), diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHosting.java b/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHosting.java new file mode 100644 index 0000000..97457f6 --- /dev/null +++ b/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHosting.java @@ -0,0 +1,54 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.api; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.Plural; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * A webserver that hosts one or more already-rendered {@link BlueMapMap}s under a hostname. + * Namespaced: a hosting webserver belongs to exactly one tenant's namespace, exactly like {@link + * BlueMapMap} and {@link WorldSource}. + * + *

Unlike a render pod, which knows exactly one map and is configured entirely through + * environment variables (see {@code net.onelitefeather.apus.operator.render.RenderJobBuilder}), + * a hosting pod displays several maps at once and needs a full BlueMap configuration -- + * generated by {@code net.onelitefeather.apus.operator.map.BlueMapConfigBuilder#buildForHosting}. + */ +@Group("bluemap.onelitefeather.net") +@Version("v1alpha1") +@Kind("BlueMapHosting") +@Plural("bluemaphostings") +@ShortNames("bmhosting") +public class BlueMapHosting extends CustomResource implements Namespaced { + + @Override + protected BlueMapHostingSpec initSpec() { + return new BlueMapHostingSpec(); + } + + @Override + protected BlueMapHostingStatus initStatus() { + return new BlueMapHostingStatus(); + } +} diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingSpec.java b/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingSpec.java new file mode 100644 index 0000000..bd2caed --- /dev/null +++ b/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingSpec.java @@ -0,0 +1,140 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.api; + +import java.util.ArrayList; +import java.util.List; + +/** + * Desired state of a {@link BlueMapHosting}. Plain data, no Kubernetes access. + * + *

Every group is initialised in its field declaration so a reconciler (or a test) never has + * to null-check its way down to a leaf field. + */ +public class BlueMapHostingSpec { + + /** The {@link BlueMapMap}s this webserver displays, in the same namespace as this resource. */ + private List maps = new ArrayList<>(); + + private String hostname; + private String ingressClassName = "nginx"; + private Tls tls = new Tls(); + private int replicas = 1; + private Resources resources = new Resources(); + + public List getMaps() { + return maps; + } + + public void setMaps(List maps) { + this.maps = maps; + } + + public String getHostname() { + return hostname; + } + + public void setHostname(String hostname) { + this.hostname = hostname; + } + + public String getIngressClassName() { + return ingressClassName; + } + + public void setIngressClassName(String ingressClassName) { + this.ingressClassName = ingressClassName; + } + + public Tls getTls() { + return tls; + } + + public void setTls(Tls tls) { + this.tls = tls; + } + + public int getReplicas() { + return replicas; + } + + public void setReplicas(int replicas) { + this.replicas = replicas; + } + + public Resources getResources() { + return resources; + } + + public void setResources(Resources resources) { + this.resources = resources; + } + + /** TLS termination for the ingress fronting this webserver. */ + public static class Tls { + private Ref issuerRef = new Ref(); + private String issuerKind = "ClusterIssuer"; + private boolean enabled = true; + + public Ref getIssuerRef() { + return issuerRef; + } + + public void setIssuerRef(Ref issuerRef) { + this.issuerRef = issuerRef; + } + + public String getIssuerKind() { + return issuerKind; + } + + public void setIssuerKind(String issuerKind) { + this.issuerKind = issuerKind; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } + + /** Resource requests/limits applied to the webserver pod. */ + public static class Resources { + private String cpu; + private String memory; + + public String getCpu() { + return cpu; + } + + public void setCpu(String cpu) { + this.cpu = cpu; + } + + public String getMemory() { + return memory; + } + + public void setMemory(String memory) { + this.memory = memory; + } + } +} diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingStatus.java b/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingStatus.java new file mode 100644 index 0000000..cf43a9a --- /dev/null +++ b/operator/src/main/java/net/onelitefeather/apus/operator/api/BlueMapHostingStatus.java @@ -0,0 +1,59 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.api; + +import io.fabric8.kubernetes.api.model.Condition; +import java.util.ArrayList; +import java.util.List; + +/** + * Observed state of a {@link BlueMapHosting}. Every group is initialised in its field + * declaration so a reconciler never has to null-check its way down to a leaf field. + */ +public class BlueMapHostingStatus { + + /** {@code "https://"} once the ingress and webserver are ready. */ + private String url; + + private boolean ready; + private List conditions = new ArrayList<>(); + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public boolean isReady() { + return ready; + } + + public void setReady(boolean ready) { + this.ready = ready; + } + + public List getConditions() { + return conditions; + } + + public void setConditions(List conditions) { + this.conditions = conditions; + } +} diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java b/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java index b3d4bf9..c159a86 100644 --- a/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java +++ b/operator/src/main/java/net/onelitefeather/apus/operator/api/TenantSpec.java @@ -17,11 +17,15 @@ */ package net.onelitefeather.apus.operator.api; +import java.util.ArrayList; +import java.util.List; + /** Desired state of a tenant. Plain data, no Kubernetes access. */ public class TenantSpec { private String displayName; private StorageQuota storage = new StorageQuota(); + private Hosting hosting = new Hosting(); public String getDisplayName() { return displayName; @@ -39,6 +43,14 @@ public void setStorage(StorageQuota storage) { this.storage = storage; } + public Hosting getHosting() { + return hosting; + } + + public void setHosting(Hosting hosting) { + this.hosting = hosting; + } + /** Hard storage limit, enforced by Ceph rather than by this operator. */ public static class StorageQuota { private String quota = "100Gi"; @@ -60,4 +72,34 @@ public void setMaxObjects(Long maxObjects) { this.maxObjects = maxObjects; } } + + /** + * Constrains which hostnames {@code BlueMapHosting} resources in this tenant's namespace may + * request (design spec §8.1). Enforced by {@code + * net.onelitefeather.apus.operator.hosting.BlueMapHostingReconciler}, not by this class or + * the CRD schema -- a {@code BlueMapHosting} carries no reference back to its tenant, so the + * check can only happen once the reconciler has resolved the tenant owning its namespace. + * + *

An empty {@link #allowedDomains} is deliberately treated as "no hosting permitted yet", + * not "anything goes": it far more often means a tenant simply has not been configured for + * hosting at all than that a platform administrator consciously decided to let it claim any + * hostname on the internet. + */ + public static class Hosting { + + /** + * Hostnames (or single-level wildcards, e.g. {@code *.friends.example.net}) a {@code + * BlueMapHosting} in this tenant may use. Empty by default -- see the class Javadoc for + * why that means "not allowed" rather than "unrestricted". + */ + private List allowedDomains = new ArrayList<>(); + + public List getAllowedDomains() { + return allowedDomains; + } + + public void setAllowedDomains(List allowedDomains) { + this.allowedDomains = allowedDomains; + } + } } diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconciler.java b/operator/src/main/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconciler.java new file mode 100644 index 0000000..96d840f --- /dev/null +++ b/operator/src/main/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconciler.java @@ -0,0 +1,582 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.hosting; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.Namespace; +import io.fabric8.kubernetes.api.model.OwnerReference; +import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.NonDeletingOperation; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import net.onelitefeather.apus.operator.OperatorConfig; +import net.onelitefeather.apus.operator.api.BlueMapHosting; +import net.onelitefeather.apus.operator.api.BlueMapMap; +import net.onelitefeather.apus.operator.api.Conditions; +import net.onelitefeather.apus.operator.api.Labels; +import net.onelitefeather.apus.operator.api.Ref; +import net.onelitefeather.apus.operator.api.Tenant; +import net.onelitefeather.apus.operator.map.BlueMapConfigBuilder; + +/** + * Turns a {@link BlueMapHosting} into a running, publicly reachable BlueMap webserver: a {@code + * ConfigMap} carrying its multi-map configuration ({@link BlueMapConfigBuilder#buildForHosting}), + * a {@link Deployment}, {@link Service}, {@link Ingress}, and -- when TLS is enabled and + * cert-manager is installed -- a {@link Certificate}, all built by {@link HostingResourceBuilder}. + * + *

S1 -- a hostname must be permitted by its tenant (design spec §8.1). {@link + * HostingResourceBuilder} is a pure function that writes {@code spec.hostname} into the ingress + * unchecked; nothing before this reconciler existed enforced {@code + * Tenant.spec.hosting.allowedDomains}, which would let a tenant claim another tenant's hostname + * and pull its traffic. This class closes that gap: it resolves the owning {@link Tenant} from + * the {@link Labels#TENANT} label {@link net.onelitefeather.apus.operator.tenant.TenantReconciler} + * stamps on every tenant namespace (a {@link BlueMapHosting} carries no direct tenant reference, + * exactly like {@link BlueMapMap} -- see {@code BlueMapMapReconciler}'s identical derivation), and + * matches {@code spec.hostname} against that tenant's {@code allowedDomains} -- literal hostnames + * or single-level wildcards ({@code *.friends.example.net}, matching exactly one extra label the + * way a wildcard TLS certificate would, not an arbitrary number of subdomain levels). A mismatch + * creates no Ingress and no Deployment -- only a {@code HostnameNotAllowed} + * condition. + * + *

An empty {@code allowedDomains} list means "no hosting permitted", not "unrestricted". + * See {@code TenantSpec.Hosting}'s Javadoc for the reasoning: an unset list is far more likely to + * mean "this tenant was never configured for hosting" than "a platform administrator deliberately + * allowed any hostname". + * + *

S2 -- referenced maps must live in this hosting's own namespace. {@link Ref} + * deliberately carries no namespace field (see its Javadoc, design spec §10.1), so a {@link + * BlueMapMap} reference can only ever be resolved inside {@code hosting}'s own namespace -- this + * reconciler does exactly that via {@code client.resources(BlueMapMap.class).inNamespace(...)} + * rather than a cluster-wide lookup. A map that does not exist there is reported as a {@code + * MapNotFound} condition, never silently searched for elsewhere. + * + *

A referenced map needs a bound bucket before this hosting is built. Mirrors {@code + * BlueMapRenderReconciler}'s identical precondition on {@code BlueMapMap.status.bucket}: a + * webserver pointed at an empty bucket name would just serve a broken page, so no Deployment is + * created (reason {@value #MAP_NOT_READY_REASON}) until every referenced map's bucket is bound. + * + *

Ownership check, mirroring {@code BlueMapRenderReconciler}'s. Every resource {@link + * HostingResourceBuilder} builds (and the {@code ConfigMap} this class builds itself) carries an + * owner reference naming this {@link BlueMapHosting} by both name and UID. Before writing any of + * them, an existing resource of the same name is checked against that owner reference; a mismatch + * (a resource that exists but was not created by this hosting) aborts with a {@code + * ResourceConflict} condition instead of adopting it. All checks run before any write, so a + * conflict on a later resource never leaves an earlier one silently created. + * + *

cert-manager may not be installed. Mirrors {@code TenantReconciler}/{@code + * BlueMapMapReconciler}'s handling of Rook: {@link io.fabric8.kubernetes.client.Client#supports} + * is checked for {@link Certificate} before this class -- or {@link HostingResourceBuilder} on its + * behalf -- ever touches one. If TLS is requested but cert-manager's CRD is not registered, no + * resource at all is created (reason {@value #CERT_MANAGER_UNAVAILABLE_REASON}) rather than + * standing up an Ingress whose {@code tls[].secretName} would never be populated. + * + *

A config change must restart the pods. BlueMap only reads its configuration at + * startup, so a webserver that already has pods running would otherwise keep serving a stale map + * list forever after {@code spec.maps} changes. This reconciler hashes the generated config files + * (SHA-256 over their sorted file names and content) and stamps that hash onto the Deployment's + * pod template as an annotation; a changed hash changes the pod template, which is exactly what + * makes the Deployment controller roll the pods. + * + *

All maps in a hosting are assumed to share one set of S3 credentials. {@link + * HostingResourceBuilder#deployment} accepts exactly one {@code bucketSecretName}, and the + * hosting image's entrypoint (Task 2) applies that one credential pair to every {@code + * storages/*.conf} file it copies in. This reconciler passes the first referenced map's {@code + * status.bucket.secretName}. That is not a limitation in practice: {@code BucketProvisioner} + * always creates a map's {@code ObjectBucketClaim} with {@code additionalConfig.bucketOwner} set + * to the tenant's single Ceph object-store user ({@code TenantReconciler#cephUserFor}), so every + * bucket a tenant's maps live in is owned by that same Ceph user regardless of which map's claim + * the credentials Secret happens to be named after. + * + *

Idempotent: every write goes through {@code createOr(NonDeletingOperation::update)}, + * exactly like every other reconciler in this module (the fabric8 mock server used in tests does + * not support server-side apply); reconciling an already-up-to-date hosting changes nothing. + */ +@ControllerConfiguration +public class BlueMapHostingReconciler implements Reconciler { + + /** Reason set when the namespace's owning tenant cannot be resolved. */ + public static final String TENANT_NOT_FOUND_REASON = "TenantNotFound"; + + /** Reason set when the tenant has no {@code allowedDomains} configured at all. */ + public static final String HOSTING_NOT_CONFIGURED_REASON = "HostingNotConfigured"; + + /** Reason set when {@code spec.hostname} does not match any of the tenant's allowed domains. */ + public static final String HOSTNAME_NOT_ALLOWED_REASON = "HostnameNotAllowed"; + + /** Reason set when a referenced map does not exist in this hosting's own namespace. */ + public static final String MAP_NOT_FOUND_REASON = "MapNotFound"; + + /** Reason set while a referenced map exists but has no bound bucket yet. */ + public static final String MAP_NOT_READY_REASON = "MapNotReady"; + + /** Reason set when an existing resource fails the ownership check. */ + public static final String RESOURCE_CONFLICT_REASON = "ResourceConflict"; + + /** Reason set when TLS is requested but cert-manager's {@code Certificate} CRD is missing. */ + public static final String CERT_MANAGER_UNAVAILABLE_REASON = "CertManagerUnavailable"; + + /** Reason set while the Deployment has not yet reached its desired ready replica count. */ + public static final String DEPLOYMENT_NOT_READY_REASON = "DeploymentNotReady"; + + /** Reason set once the Deployment is ready and {@code status.url} is populated. */ + public static final String HOSTING_READY_REASON = "HostingReady"; + + private static final String OWNER_API_VERSION = "bluemap.onelitefeather.net/v1alpha1"; + private static final String OWNER_KIND = "BlueMapHosting"; + + /** + * Rook/RGW does not distinguish real AWS regions, and {@code BlueMapMap.status.bucket} does + * not carry one -- mirrors the {@code us-east-1} default every other Apus component + * (runner, ingest) already falls back to. + */ + private static final String DEFAULT_BUCKET_REGION = "us-east-1"; + + /** + * Annotation carrying the SHA-256 of the generated config files, stamped onto the Deployment + * pod template so a config change forces a rollout -- see the class Javadoc. + */ + static final String CONFIG_CHECKSUM_ANNOTATION = "apus.onelitefeather.net/config-checksum"; + + private static final Duration RECHECK_INTERVAL = Duration.ofSeconds(10); + + private final KubernetesClient client; + private final OperatorConfig config; + + public BlueMapHostingReconciler(KubernetesClient client, OperatorConfig config) { + this.client = client; + this.config = config; + } + + @Override + public UpdateControl reconcile(BlueMapHosting hosting, Context context) { + String namespace = hosting.getMetadata().getNamespace(); + String name = hosting.getMetadata().getName(); + + Optional tenantName = resolveTenantName(namespace); + if (tenantName.isEmpty()) { + return pending( + hosting, + TENANT_NOT_FOUND_REASON, + "namespace '" + namespace + "' is not labelled with an owning tenant yet"); + } + Tenant tenant = client.resources(Tenant.class).withName(tenantName.get()).get(); + if (tenant == null) { + return pending( + hosting, + TENANT_NOT_FOUND_REASON, + "tenant '" + tenantName.get() + "' referenced by namespace '" + namespace + "' does not exist"); + } + + List allowedDomains = tenant.getSpec().getHosting().getAllowedDomains(); + String hostname = hosting.getSpec().getHostname(); + if (allowedDomains == null || allowedDomains.isEmpty()) { + return pending( + hosting, + HOSTING_NOT_CONFIGURED_REASON, + "tenant '" + tenantName.get() + + "' has no allowedDomains configured; hosting is not permitted until at least one is" + + " set"); + } + if (!hostnameAllowed(hostname, allowedDomains)) { + return pending( + hosting, + HOSTNAME_NOT_ALLOWED_REASON, + "hostname '" + hostname + "' is not covered by tenant '" + tenantName.get() + + "'s allowedDomains " + allowedDomains); + } + + List maps = new ArrayList<>(); + for (Ref ref : hosting.getSpec().getMaps()) { + String mapName = ref.getName(); + BlueMapMap map = + client.resources(BlueMapMap.class).inNamespace(namespace).withName(mapName).get(); + if (map == null) { + return pending( + hosting, + MAP_NOT_FOUND_REASON, + "map '" + mapName + "' does not exist in namespace '" + namespace + "'"); + } + if (!isBucketBound(map)) { + return pending(hosting, MAP_NOT_READY_REASON, "map '" + mapName + "' has no bound bucket yet"); + } + maps.add(map); + } + + boolean tlsEnabled = hosting.getSpec().getTls().isEnabled(); + boolean certManagerAvailable = client.supports(Certificate.class); + if (tlsEnabled && !certManagerAvailable) { + return pending( + hosting, + CERT_MANAGER_UNAVAILABLE_REASON, + "TLS is enabled but the cert-manager Certificate CRD (cert-manager.io) is not registered on" + + " this cluster"); + } + + String configMapName = name + "-config"; + Optional> conflict = + checkOwnership(hosting, namespace, name, configMapName, tlsEnabled, certManagerAvailable); + if (conflict.isPresent()) { + return conflict.get(); + } + + List bindings = maps.stream() + .map(map -> new BlueMapConfigBuilder.BucketBinding( + map.getStatus().getBucket().getName(), + map.getStatus().getBucket().getEndpoint(), + DEFAULT_BUCKET_REGION)) + .toList(); + Map files = + BlueMapConfigBuilder.buildForHosting(maps, bindings, HostingResourceBuilder.WEBSERVER_PORT); + String checksum = checksum(files); + + client.configMaps() + .inNamespace(namespace) + .resource(buildConfigMap(hosting, configMapName, files)) + .createOr(NonDeletingOperation::update); + + String bucketSecretName = maps.get(0).getStatus().getBucket().getSecretName(); + Deployment deployment = HostingResourceBuilder.deployment( + hosting, configMapName, files.keySet(), bucketSecretName, config); + stampConfigChecksum(deployment, checksum); + Deployment existingDeployment = + client.apps().deployments().inNamespace(namespace).withName(name).get(); + if (!deploymentUpToDate(existingDeployment, deployment)) { + client.apps() + .deployments() + .inNamespace(namespace) + .resource(deployment) + .createOr(NonDeletingOperation::update); + } + + client.services() + .inNamespace(namespace) + .resource(HostingResourceBuilder.service(hosting)) + .createOr(NonDeletingOperation::update); + + client.network() + .v1() + .ingresses() + .inNamespace(namespace) + .resource(HostingResourceBuilder.ingress(hosting)) + .createOr(NonDeletingOperation::update); + + if (tlsEnabled) { + HostingResourceBuilder.certificate(hosting) + .ifPresent(certificate -> client.resources(Certificate.class) + .inNamespace(namespace) + .resource(certificate) + .createOr(NonDeletingOperation::update)); + } + + return updateReadiness(hosting, namespace, name); + } + + /** + * Recovers the tenant name owning {@code namespace} from the {@link Labels#TENANT} label + * {@code TenantReconciler} stamps on every tenant namespace it creates. A {@link + * BlueMapHosting} carries no direct reference to its tenant -- only the namespace it lives + * in -- so this is the only way back, exactly like {@code + * BlueMapMapReconciler#cephUserForNamespace} recovers the tenant name for a different + * purpose from the same namespace. + */ + private Optional resolveTenantName(String namespace) { + Namespace ns = client.namespaces().withName(namespace).get(); + if (ns == null || ns.getMetadata().getLabels() == null) { + return Optional.empty(); + } + String tenantName = ns.getMetadata().getLabels().get(Labels.TENANT); + return (tenantName == null || tenantName.isBlank()) ? Optional.empty() : Optional.of(tenantName); + } + + /** + * Matches {@code hostname} against a tenant's {@code allowedDomains} (design spec §8.1) -- + * literal, case-insensitive equality, or a single-level wildcard ({@code + * *.friends.example.net} matches {@code maps.friends.example.net} but not {@code + * a.b.friends.example.net}), mirroring how a wildcard TLS certificate itself only ever covers + * one label. Never called with an empty {@code allowedDomains} -- {@link #reconcile} already + * refuses hosting entirely in that case (see the class Javadoc). + */ + private static boolean hostnameAllowed(String hostname, List allowedDomains) { + if (hostname == null || hostname.isBlank()) { + return false; + } + String normalizedHost = hostname.toLowerCase(Locale.ROOT); + for (String domain : allowedDomains) { + if (domain == null || domain.isBlank()) { + continue; + } + String normalizedDomain = domain.toLowerCase(Locale.ROOT); + if (normalizedDomain.startsWith("*.")) { + if (matchesSingleLevelWildcard(normalizedHost, normalizedDomain.substring(2))) { + return true; + } + } else if (normalizedHost.equals(normalizedDomain)) { + return true; + } + } + return false; + } + + private static boolean matchesSingleLevelWildcard(String host, String suffix) { + if (suffix.isEmpty() || !host.endsWith("." + suffix)) { + return false; + } + String label = host.substring(0, host.length() - suffix.length() - 1); + return !label.isEmpty() && !label.contains("."); + } + + private static boolean isBucketBound(BlueMapMap map) { + var bucket = map.getStatus().getBucket(); + return bucket.getName() != null + && !bucket.getName().isBlank() + && bucket.getSecretName() != null + && !bucket.getSecretName().isBlank(); + } + + /** + * Checks every resource this reconciler is about to write against its owner reference, + * before any of them are actually written -- see the class Javadoc's "Ownership check" + * section. Returns the conflict {@link UpdateControl} to return from {@link #reconcile} if + * one is found, or empty if every existing resource (or lack thereof) is safe to write to. + */ + private Optional> checkOwnership( + BlueMapHosting hosting, + String namespace, + String name, + String configMapName, + boolean tlsEnabled, + boolean certManagerAvailable) { + ConfigMap existingConfigMap = + client.configMaps().inNamespace(namespace).withName(configMapName).get(); + if (existingConfigMap != null + && !ownedByHosting(existingConfigMap.getMetadata().getOwnerReferences(), hosting)) { + return Optional.of(conflict(hosting, "ConfigMap", configMapName)); + } + + Deployment existingDeployment = + client.apps().deployments().inNamespace(namespace).withName(name).get(); + if (existingDeployment != null + && !ownedByHosting(existingDeployment.getMetadata().getOwnerReferences(), hosting)) { + return Optional.of(conflict(hosting, "Deployment", name)); + } + + Service existingService = client.services().inNamespace(namespace).withName(name).get(); + if (existingService != null + && !ownedByHosting(existingService.getMetadata().getOwnerReferences(), hosting)) { + return Optional.of(conflict(hosting, "Service", name)); + } + + Ingress existingIngress = + client.network().v1().ingresses().inNamespace(namespace).withName(name).get(); + if (existingIngress != null + && !ownedByHosting(existingIngress.getMetadata().getOwnerReferences(), hosting)) { + return Optional.of(conflict(hosting, "Ingress", name)); + } + + if (tlsEnabled && certManagerAvailable) { + Certificate existingCertificate = + client.resources(Certificate.class).inNamespace(namespace).withName(name).get(); + if (existingCertificate != null + && !ownedByHosting(existingCertificate.getMetadata().getOwnerReferences(), hosting)) { + return Optional.of(conflict(hosting, "Certificate", name)); + } + } + + return Optional.empty(); + } + + private static boolean ownedByHosting(List owners, BlueMapHosting hosting) { + String hostingUid = hosting.getMetadata().getUid(); + if (owners == null || hostingUid == null) { + return false; + } + return owners.stream() + .anyMatch(ref -> OWNER_KIND.equals(ref.getKind()) + && Objects.equals(hosting.getMetadata().getName(), ref.getName()) + && Objects.equals(hostingUid, ref.getUid())); + } + + /** + * Builds the hosting {@code ConfigMap} from {@code files}' logical paths (e.g. + * {@code maps/survival-overworld.conf}, as returned by {@code + * BlueMapConfigBuilder#buildForHosting}), sanitising each into a valid {@code ConfigMap} data + * key via {@link HostingResourceBuilder#configMapKey} -- real Kubernetes rejects a data key + * containing {@code /} outright, unlike the fabric8 mock server this module's other tests run + * against. {@link HostingResourceBuilder#deployment} is handed {@code files.keySet()} + * (the un-sanitised logical paths) separately so its ConfigMap volume {@code items} can map + * each sanitised key back to the original nested path the container needs. + */ + private static ConfigMap buildConfigMap(BlueMapHosting hosting, String configMapName, Map files) { + Map data = new LinkedHashMap<>(); + files.forEach((logicalPath, content) -> data.put(HostingResourceBuilder.configMapKey(logicalPath), content)); + return new ConfigMapBuilder() + .withNewMetadata() + .withName(configMapName) + .withNamespace(hosting.getMetadata().getNamespace()) + .withLabels(Labels.standard("bluemap-hosting-config", hosting.getMetadata().getName())) + .withOwnerReferences(ownerReference(hosting)) + .endMetadata() + .withData(data) + .build(); + } + + private static OwnerReference ownerReference(BlueMapHosting hosting) { + return new OwnerReferenceBuilder() + .withApiVersion(OWNER_API_VERSION) + .withKind(OWNER_KIND) + .withName(hosting.getMetadata().getName()) + .withUid(hosting.getMetadata().getUid()) + .withController(true) + .withBlockOwnerDeletion(true) + .build(); + } + + /** + * SHA-256 over every generated config file's name and content, sorted by file name so the + * result is independent of map iteration order -- see the class Javadoc's "A config change + * must restart the pods" section. Never includes credentials: {@link + * BlueMapConfigBuilder#buildForHosting} never writes any into the files this hashes. + */ + private static String checksum(Map files) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + files.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> { + digest.update(entry.getKey().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(entry.getValue().getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + }); + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } + + /** + * Whether an existing Deployment already matches what this reconcile would write, so the + * write can be skipped. + * + *

This is not just an optimisation: {@code Deployment} is the one resource this reconciler + * both writes and reads status back from ({@link #updateReadiness}) in the same + * reconcile loop. Real Kubernetes ignores whatever status a client sends on a write to the + * main (non-{@code /status}) endpoint of a resource with the status subresource enabled, so + * writing the same content repeatedly would never actually disturb {@code status.readyReplicas} + * there -- but skipping a genuinely no-op write is still the right instinct for an operator + * that reconciles on every resync, not just a workaround for a test double. + */ + private static boolean deploymentUpToDate(Deployment existing, Deployment desired) { + if (existing == null || existing.getSpec() == null) { + return false; + } + return Objects.equals(existing.getSpec().getReplicas(), desired.getSpec().getReplicas()) + && Objects.equals(existing.getSpec().getTemplate(), desired.getSpec().getTemplate()); + } + + private static void stampConfigChecksum(Deployment deployment, String checksum) { + var templateMetadata = deployment.getSpec().getTemplate().getMetadata(); + Map annotations = templateMetadata.getAnnotations(); + if (annotations == null) { + annotations = new LinkedHashMap<>(); + templateMetadata.setAnnotations(annotations); + } + annotations.put(CONFIG_CHECKSUM_ANNOTATION, checksum); + } + + /** + * Reads the Deployment's current status back from the cluster and reflects readiness into + * {@code status.url}/{@code status.ready}/the {@code Ready} condition. The URL is only ever + * reported once the Deployment has at least as many ready replicas as {@code spec.replicas} + * asks for -- reporting it earlier would point users at a webserver that is not actually + * serving yet. + */ + private UpdateControl updateReadiness(BlueMapHosting hosting, String namespace, String name) { + Deployment current = client.apps().deployments().inNamespace(namespace).withName(name).get(); + int desiredReplicas = Math.max(hosting.getSpec().getReplicas(), 0); + Integer readyReplicas = + current == null || current.getStatus() == null ? null : current.getStatus().getReadyReplicas(); + boolean ready = desiredReplicas == 0 || (readyReplicas != null && readyReplicas >= desiredReplicas); + + if (ready) { + hosting.getStatus().setReady(true); + hosting.getStatus().setUrl("https://" + hosting.getSpec().getHostname()); + Conditions.set( + hosting.getStatus().getConditions(), + Conditions.ready(true, HOSTING_READY_REASON, "hosting webserver is ready")); + return UpdateControl.patchStatus(hosting); + } + + hosting.getStatus().setReady(false); + hosting.getStatus().setUrl(null); + Conditions.set( + hosting.getStatus().getConditions(), + Conditions.ready( + false, DEPLOYMENT_NOT_READY_REASON, "waiting for the hosting deployment to become ready")); + return UpdateControl.patchStatus(hosting).rescheduleAfter(RECHECK_INTERVAL); + } + + /** + * Reports a blocking condition without creating or updating anything, rescheduling so a + * fixable external cause (the tenant gets its {@code allowedDomains} set, the map's bucket + * gets bound, cert-manager comes up, ...) is retried instead of requiring a manual nudge. + */ + private static UpdateControl pending(BlueMapHosting hosting, String reason, String message) { + hosting.getStatus().setReady(false); + hosting.getStatus().setUrl(null); + Conditions.set(hosting.getStatus().getConditions(), Conditions.ready(false, reason, message)); + return UpdateControl.patchStatus(hosting).rescheduleAfter(RECHECK_INTERVAL); + } + + /** + * Aborts the reconciliation with a {@code ResourceConflict} condition, naming the resource + * that already exists but is not owned by this hosting. Nothing further is created or + * updated -- see {@code TenantReconciler}'s identical {@code conflict()} method. + */ + private static UpdateControl conflict(BlueMapHosting hosting, String resourceKind, String resourceName) { + hosting.getStatus().setReady(false); + hosting.getStatus().setUrl(null); + Conditions.set( + hosting.getStatus().getConditions(), + Conditions.ready( + false, + RESOURCE_CONFLICT_REASON, + "existing " + resourceKind + " '" + resourceName + + "' is not owned by this hosting; refusing to adopt it")); + return UpdateControl.patchStatus(hosting); + } +} diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/hosting/Certificate.java b/operator/src/main/java/net/onelitefeather/apus/operator/hosting/Certificate.java new file mode 100644 index 0000000..2cfbf6f --- /dev/null +++ b/operator/src/main/java/net/onelitefeather/apus/operator/hosting/Certificate.java @@ -0,0 +1,160 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.hosting; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.Plural; +import io.fabric8.kubernetes.model.annotation.Version; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * cert-manager's {@code Certificate}, modelled with only the fields {@link HostingResourceBuilder} + * needs to request a TLS certificate for a {@code BlueMapHosting}'s ingress. + * + *

Apus does not run its own certificate authority: creating one of these makes cert-manager + * issue a certificate and write it into the named {@code Secret}, which the ingress then + * references via {@code spec.tls[].secretName}. This class is a client-side model of a CRD + * cert-manager owns -- it must never be fed to Apus's own CRD generator, which is why it lives + * in this package rather than {@code net.onelitefeather.apus.operator.api} (the only package the + * generator scans, see {@code CrdGeneratorMain}). Shipping a {@code cert-manager.io} CRD of our + * own would fight with cert-manager's, exactly the failure {@code + * net.onelitefeather.apus.operator.rook.ObjectBucketClaim} already avoids for Rook's CRDs. + * + *

Kept as a single file with nested spec/status types (unlike the three-file Rook model + * classes) because Apus only ever sets three leaf fields on this resource -- a dedicated + * top-level {@code CertificateSpec}/{@code CertificateStatus} pair would be pure ceremony here. + */ +@Group("cert-manager.io") +@Version("v1") +@Kind("Certificate") +@Plural("certificates") +public class Certificate extends CustomResource + implements Namespaced { + + @Override + protected CertificateSpec initSpec() { + return new CertificateSpec(); + } + + @Override + protected CertificateStatus initStatus() { + return new CertificateStatus(); + } + + /** Desired state of a cert-manager {@code Certificate}. Plain data, no Kubernetes access. */ + public static class CertificateSpec { + + /** Name of the {@code Secret} cert-manager writes the issued certificate/key into. */ + private String secretName; + + private List dnsNames = new ArrayList<>(); + private IssuerRef issuerRef = new IssuerRef(); + + public String getSecretName() { + return secretName; + } + + public void setSecretName(String secretName) { + this.secretName = secretName; + } + + public List getDnsNames() { + return dnsNames; + } + + public void setDnsNames(List dnsNames) { + this.dnsNames = dnsNames; + } + + public IssuerRef getIssuerRef() { + return issuerRef; + } + + public void setIssuerRef(IssuerRef issuerRef) { + this.issuerRef = issuerRef; + } + + /** Which cert-manager issuer signs this certificate. */ + public static class IssuerRef { + private String name; + private String kind = "ClusterIssuer"; + private String group = "cert-manager.io"; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getGroup() { + return group; + } + + public void setGroup(String group) { + this.group = group; + } + } + } + + /** + * Observed state of a cert-manager {@code Certificate}. Apus never reads this back (the + * {@code BlueMapHostingReconciler} determines TLS readiness from the ingress, per the phase 3 + * plan) -- kept present rather than omitted so the type still matches cert-manager's actual + * shape and {@link CustomResource} has a status to initialise. + * + *

Modelled as an open bag of properties ({@code additionalProperties}, the same {@code + * @JsonAnyGetter}/{@code @JsonAnySetter} pattern every fabric8-generated model class uses for + * "no fields Apus cares about yet") rather than a genuinely empty class: with zero declared + * fields, fabric8's Jackson mapper (which runs with {@code FAIL_ON_EMPTY_BEANS} enabled) + * throws {@code InvalidDefinitionException} the moment a {@link Certificate} is actually sent + * to an API server -- only caught once {@code BlueMapHostingReconciler} started doing that for + * real; {@link HostingResourceBuilder#certificate} alone never serialises anything. + */ + public static class CertificateStatus { + + private Map additionalProperties = new LinkedHashMap<>(); + + @JsonAnyGetter + public Map getAdditionalProperties() { + return additionalProperties; + } + + @JsonAnySetter + public void setAdditionalProperty(String name, Object value) { + additionalProperties.put(name, value); + } + } +} diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilder.java b/operator/src/main/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilder.java new file mode 100644 index 0000000..b26ae9f --- /dev/null +++ b/operator/src/main/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilder.java @@ -0,0 +1,471 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.hosting; + +import io.fabric8.kubernetes.api.model.ConfigMapVolumeSourceBuilder; +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.ContainerBuilder; +import io.fabric8.kubernetes.api.model.ContainerPort; +import io.fabric8.kubernetes.api.model.ContainerPortBuilder; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.KeyToPath; +import io.fabric8.kubernetes.api.model.KeyToPathBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.OwnerReference; +import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder; +import io.fabric8.kubernetes.api.model.Probe; +import io.fabric8.kubernetes.api.model.ProbeBuilder; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.ResourceRequirements; +import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.ServiceBuilder; +import io.fabric8.kubernetes.api.model.ServicePort; +import io.fabric8.kubernetes.api.model.ServicePortBuilder; +import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.VolumeBuilder; +import io.fabric8.kubernetes.api.model.VolumeMount; +import io.fabric8.kubernetes.api.model.VolumeMountBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPathBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.networking.v1.IngressBackendBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.IngressBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.IngressRuleBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.IngressServiceBackendBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.IngressTLS; +import io.fabric8.kubernetes.api.model.networking.v1.IngressTLSBuilder; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.onelitefeather.apus.operator.OperatorConfig; +import net.onelitefeather.apus.operator.api.BlueMapHosting; +import net.onelitefeather.apus.operator.api.Labels; + +/** + * Turns a {@link BlueMapHosting} into the Kubernetes objects that make an already-rendered map + * reachable on the web: a {@link Deployment} running the BlueMap webserver, a {@link Service} + * fronting its pods, an {@link Ingress} exposing that Service under the requested hostname, and + * -- unless TLS is disabled -- a cert-manager {@link Certificate} backing the ingress's TLS + * secret. + * + *

Pure function: no Kubernetes client, no side effects, following the same shape as {@link + * net.onelitefeather.apus.operator.render.RenderJobBuilder}. The caller (the eventual {@code + * BlueMapHostingReconciler}, phase 3 task 4) is responsible for actually submitting these + * objects, for building {@code configMapName}'s content via {@code + * net.onelitefeather.apus.operator.map.BlueMapConfigBuilder#buildForHosting}, and for having + * already resolved {@code bucketSecretName} to a Secret Rook populated with S3 credentials. + */ +public final class HostingResourceBuilder { + + /** API group + version the owning {@link BlueMapHosting} is served under. */ + private static final String OWNER_API_VERSION = "bluemap.onelitefeather.net/v1alpha1"; + + private static final String OWNER_KIND = "BlueMapHosting"; + + private static final String CONTAINER_NAME = "bluemap"; + + private static final String CONFIG_VOLUME_NAME = "hosting-config"; + + /** + * Mount path for the read-only {@code hosting-config} ConfigMap volume. The Task 2 image's + * entrypoint reads the map/storage configuration this operator generated from here, copies + * it into a writable directory (a ConfigMap mount is read-only) and fills in the S3 + * credentials before starting BlueMap -- see the phase 3 plan's Task 2 section. + */ + static final String CONFIG_MOUNT_PATH = "/config-src"; + + /** + * Separator {@link #configMapKey} substitutes for {@code /} in a {@code + * BlueMapConfigBuilder#buildForHosting} logical file path (e.g. {@code + * maps/survival-overworld.conf}) to turn it into a valid {@code ConfigMap} data key. + * + *

Kubernetes rejects a {@code ConfigMap} data key containing {@code /} outright (the API + * server enforces {@code [-._a-zA-Z0-9]+}) -- the fabric8 mock server used by every test in + * this module below the real-cluster integration test does not enforce that, so this was only + * ever caught once {@code BlueMapHostingReconciler} tried to actually create a {@code + * ConfigMap} against a real k3s API server. {@code .} is itself a valid key character and + * never appears in a logical path except as the file extension, so the substitution is + * unambiguous without needing a matching "unflatten" step: {@link + * #deployment(BlueMapHosting, String, Collection, String, OperatorConfig)} instead carries the + * original, un-substituted logical path forward as the {@link KeyToPath#getPath()} of a + * ConfigMap volume {@code item}, which -- unlike a data key -- Kubernetes does allow to + * contain {@code /}, and is exactly how the file ends up back at its original nested location + * ({@code maps/survival-overworld.conf}) inside the container, matching what {@code + * hosting/bin/config-sync.sh} has always expected to find under {@link #CONFIG_MOUNT_PATH}. + */ + private static final char CONFIG_MAP_KEY_SEPARATOR = '.'; + + /** + * Port the BlueMap webserver listens on inside the container, and the port the {@link + * Service} and readiness/liveness probes target. Matches {@code + * BlueMapConfigBuilder#buildForHosting}'s {@code webserverPort} parameter and the Task 2 + * image's {@code APUS_WEBSERVER_PORT} default (both currently fixed at 8100, since neither + * {@link BlueMapHosting} nor {@link OperatorConfig} exposes a port field) -- keep these three + * in sync if that ever changes. + */ + static final int WEBSERVER_PORT = 8100; + + /** + * HTTP path used for both the readiness and liveness probe. + * + *

Verified against Task 2's actual image (see {@code + * hosting/entrypoint.sh}/{@code hosting/README.md} and the phase 3 SDD ledger): {@code + * GET /settings.json} only returns 200 once the BlueMap webserver has actually generated its + * web-app shell and is serving it -- unlike {@code /}, which 404s during that same window. + * A pod must not receive traffic (readiness) or be considered alive (liveness) before that. + */ + static final String PROBE_PATH = "/settings.json"; + + private HostingResourceBuilder() {} + + /** + * Builds the {@link Deployment} running the BlueMap webserver for one {@link BlueMapHosting}. + * + * @param hosting the hosting resource this deployment serves; supplies replica count, + * resource sizing, and owns the returned deployment via an owner reference + * @param configMapName name of the {@code ConfigMap}, in the same namespace as {@code + * hosting}, holding the map/storage/webserver configuration built by {@code + * BlueMapConfigBuilder#buildForHosting}; mounted read-only + * @param configFileNames the logical file paths that map/storage/webserver configuration was + * built under (i.e. {@code BlueMapConfigBuilder.buildForHosting(...)}'s returned map's + * {@code keySet()}, e.g. {@code maps/survival-overworld.conf}) -- used to map each + * sanitised {@link #configMapKey} back to its original nested path inside the container, + * via the {@code ConfigMap} volume's {@code items}; must be the exact same set the + * {@code ConfigMap} passed as {@code configMapName} was built from, or the volume mount + * will be missing files (or 404 on ones that were renamed away) + * @param bucketSecretName name of the Kubernetes {@code Secret}, in the same namespace as + * {@code hosting}, carrying the S3 credentials the webserver needs to read the already- + * rendered maps; referenced via {@code secretKeyRef}, never inlined + * @param config operator-wide settings; supplies the hosting webserver's container image via + * {@link OperatorConfig#hostingImage()} + * @return the {@link Deployment} manifest, not yet submitted to the API server + */ + public static Deployment deployment( + BlueMapHosting hosting, + String configMapName, + Collection configFileNames, + String bucketSecretName, + OperatorConfig config) { + String namespace = hosting.getMetadata().getNamespace(); + Map labels = labels(hosting); + + Container container = new ContainerBuilder() + .withName(CONTAINER_NAME) + .withImage(config.hostingImage()) + .withPorts(containerPort()) + .withEnv(env(bucketSecretName)) + .withResources(resources(hosting)) + .withVolumeMounts(configVolumeMount()) + .withReadinessProbe(probe()) + .withLivenessProbe(probe()) + .build(); + + Volume configVolume = new VolumeBuilder() + .withName(CONFIG_VOLUME_NAME) + .withConfigMap(new ConfigMapVolumeSourceBuilder() + .withName(configMapName) + .withItems(configVolumeItems(configFileNames)) + .build()) + .build(); + + return new DeploymentBuilder() + .withNewMetadata() + .withName(hosting.getMetadata().getName()) + .withNamespace(namespace) + .withLabels(labels) + .withOwnerReferences(ownerReference(hosting)) + .endMetadata() + .withNewSpec() + .withReplicas(hosting.getSpec().getReplicas()) + .withNewSelector() + .withMatchLabels(labels) + .endSelector() + .withNewTemplate() + .withNewMetadata() + .withLabels(labels) + .endMetadata() + .withNewSpec() + .withContainers(container) + .withVolumes(configVolume) + .endSpec() + .endTemplate() + .endSpec() + .build(); + } + + /** + * Builds the {@link Service} fronting the webserver pods of one {@link BlueMapHosting}. + * + * @param hosting the hosting resource this service belongs to + * @return the {@link Service} manifest, not yet submitted to the API server + */ + public static Service service(BlueMapHosting hosting) { + Map labels = labels(hosting); + + ServicePort port = new ServicePortBuilder() + .withName("http") + .withPort(WEBSERVER_PORT) + .withNewTargetPort(WEBSERVER_PORT) + .build(); + + return new ServiceBuilder() + .withNewMetadata() + .withName(hosting.getMetadata().getName()) + .withNamespace(hosting.getMetadata().getNamespace()) + .withLabels(labels) + .withOwnerReferences(ownerReference(hosting)) + .endMetadata() + .withNewSpec() + .withSelector(labels) + .withPorts(port) + .endSpec() + .build(); + } + + /** + * Builds the {@link Ingress} exposing one {@link BlueMapHosting}'s Service under its + * configured hostname. Carries a {@code tls} section, referencing the {@link Certificate} + * {@link #certificate(BlueMapHosting)} would build, exactly when TLS is enabled. + * + * @param hosting the hosting resource this ingress belongs to + * @return the {@link Ingress} manifest, not yet submitted to the API server + */ + public static Ingress ingress(BlueMapHosting hosting) { + String serviceName = hosting.getMetadata().getName(); + String hostname = hosting.getSpec().getHostname(); + + var backend = new IngressBackendBuilder() + .withService(new IngressServiceBackendBuilder() + .withName(serviceName) + .withNewPort() + .withNumber(WEBSERVER_PORT) + .endPort() + .build()) + .build(); + + var path = new HTTPIngressPathBuilder() + .withPath("/") + .withPathType("Prefix") + .withBackend(backend) + .build(); + + var rule = new IngressRuleBuilder() + .withHost(hostname) + .withNewHttp() + .withPaths(path) + .endHttp() + .build(); + + var ingressBuilder = new IngressBuilder() + .withNewMetadata() + .withName(hosting.getMetadata().getName()) + .withNamespace(hosting.getMetadata().getNamespace()) + .withLabels(labels(hosting)) + .withOwnerReferences(ownerReference(hosting)) + .endMetadata() + .withNewSpec() + .withIngressClassName(hosting.getSpec().getIngressClassName()) + .withRules(rule); + + if (hosting.getSpec().getTls().isEnabled()) { + ingressBuilder.withTls(List.of(tls(hosting))); + } + + return ingressBuilder.endSpec().build(); + } + + /** + * Builds the cert-manager {@link Certificate} backing this hosting's ingress TLS secret. + * + * @param hosting the hosting resource requesting TLS + * @return the certificate to submit, or empty when {@code spec.tls.enabled} is {@code false} + * -- in which case no {@code Certificate} must be created and the ingress carries no TLS + * section either, see {@link #ingress(BlueMapHosting)} + */ + public static Optional certificate(BlueMapHosting hosting) { + if (!hosting.getSpec().getTls().isEnabled()) { + return Optional.empty(); + } + + Certificate certificate = new Certificate(); + certificate.setMetadata(new ObjectMetaBuilder() + .withName(hosting.getMetadata().getName()) + .withNamespace(hosting.getMetadata().getNamespace()) + .withLabels(labels(hosting)) + .withOwnerReferences(ownerReference(hosting)) + .build()); + + certificate.getSpec().setSecretName(tlsSecretName(hosting)); + certificate.getSpec().setDnsNames(List.of(hosting.getSpec().getHostname())); + certificate.getSpec().getIssuerRef().setName(hosting.getSpec().getTls().getIssuerRef().getName()); + certificate.getSpec().getIssuerRef().setKind(hosting.getSpec().getTls().getIssuerKind()); + + return Optional.of(certificate); + } + + private static IngressTLS tls(BlueMapHosting hosting) { + return new IngressTLSBuilder() + .withHosts(hosting.getSpec().getHostname()) + .withSecretName(tlsSecretName(hosting)) + .build(); + } + + /** + * Name of the {@code Secret} cert-manager writes the certificate into, and the name the + * ingress's {@code tls[].secretName} must reference. Computed identically by {@link + * #ingress(BlueMapHosting)} and {@link #certificate(BlueMapHosting)} so the two always agree + * without either method having to call the other. + */ + private static String tlsSecretName(BlueMapHosting hosting) { + return hosting.getMetadata().getName() + "-tls"; + } + + private static Map labels(BlueMapHosting hosting) { + return Labels.standard("bluemap-hosting", hosting.getMetadata().getName()); + } + + private static OwnerReference ownerReference(BlueMapHosting hosting) { + return new OwnerReferenceBuilder() + .withApiVersion(OWNER_API_VERSION) + .withKind(OWNER_KIND) + .withName(hosting.getMetadata().getName()) + .withUid(hosting.getMetadata().getUid()) + .withController(true) + .withBlockOwnerDeletion(true) + .build(); + } + + private static ContainerPort containerPort() { + return new ContainerPortBuilder() + .withName("http") + .withContainerPort(WEBSERVER_PORT) + .build(); + } + + /** + * Credentials for the S3 bucket(s) the mounted configuration references, taken from the Rook- + * managed Secret rather than inlined -- a Deployment manifest is readable by anything allowed + * to read Deployments in the namespace. The endpoint itself is not passed here: {@code + * BlueMapConfigBuilder#buildForHosting} already bakes it into each map's {@code + * storages/.conf} file at ConfigMap-build time, so the entrypoint only ever needs to fill + * in the two credential lines those files deliberately leave blank. + */ + private static List env(String bucketSecretName) { + return List.of( + fromSecret("APUS_S3" + "_ACCESS_KEY", bucketSecretName, "AWS_ACCESS_KEY_ID"), + fromSecret("APUS_S3" + "_SECRET_KEY", bucketSecretName, "AWS_SECRET_ACCESS_KEY"), + literal("APUS_WEBSERVER_PORT", Integer.toString(WEBSERVER_PORT))); + } + + private static EnvVar literal(String name, String value) { + return new EnvVarBuilder().withName(name).withValue(value).build(); + } + + private static EnvVar fromSecret(String name, String secretName, String key) { + return new EnvVarBuilder() + .withName(name) + .withNewValueFrom() + .withNewSecretKeyRef() + .withName(secretName) + .withKey(key) + .endSecretKeyRef() + .endValueFrom() + .build(); + } + + /** + * Builds the {@code ConfigMap} volume's {@code items} list: one entry per logical config + * file, mapping its sanitised {@link #configMapKey} back to the original nested {@code path} + * (e.g. {@code maps/survival-overworld.conf}) the file must land at inside the container -- + * see {@link #CONFIG_MAP_KEY_SEPARATOR}'s Javadoc for why the data key itself cannot carry + * that path directly. Sorted for a deterministic manifest. + */ + private static List configVolumeItems(Collection configFileNames) { + return configFileNames.stream() + .sorted() + .map(path -> new KeyToPathBuilder() + .withKey(configMapKey(path)) + .withPath(path) + .build()) + .toList(); + } + + /** + * Sanitises a {@code BlueMapConfigBuilder#buildForHosting} logical file path into a valid + * {@code ConfigMap} data key -- see {@link #CONFIG_MAP_KEY_SEPARATOR}'s Javadoc. Package- + * private so {@code BlueMapHostingReconciler} can build the {@code ConfigMap}'s {@code data} + * map with the exact same keys {@link #configVolumeItems} expects to find. + */ + static String configMapKey(String logicalPath) { + return logicalPath.replace('/', CONFIG_MAP_KEY_SEPARATOR); + } + + private static VolumeMount configVolumeMount() { + return new VolumeMountBuilder() + .withName(CONFIG_VOLUME_NAME) + .withMountPath(CONFIG_MOUNT_PATH) + .withReadOnly(true) + .build(); + } + + /** + * A pod whose webserver has not finished loading its maps from S3 yet must not receive + * traffic (readiness) and must be restarted if it stops responding entirely (liveness) -- + * see the phase 3 plan's "Betriebsrelevant" note on this task. Both probes share the same + * HTTP check since BlueMap's webserver has no separate startup/liveness endpoint. + */ + private static Probe probe() { + return new ProbeBuilder() + .withNewHttpGet() + .withPath(PROBE_PATH) + .withNewPort(WEBSERVER_PORT) + .endHttpGet() + .build(); + } + + /** + * Applies {@code BlueMapHosting.spec.resources} to the webserver pod, if set. Mirrors {@code + * RenderJobBuilder#resources(BlueMapMap)}, including pinning requests and limits to the same + * value. + */ + private static ResourceRequirements resources(BlueMapHosting hosting) { + String cpu = hosting.getSpec().getResources().getCpu(); + String memory = hosting.getSpec().getResources().getMemory(); + if ((cpu == null || cpu.isBlank()) && (memory == null || memory.isBlank())) { + return null; + } + + Map quantities = new LinkedHashMap<>(); + if (cpu != null && !cpu.isBlank()) { + quantities.put("cpu", new Quantity(cpu)); + } + if (memory != null && !memory.isBlank()) { + quantities.put("memory", new Quantity(memory)); + } + + return new ResourceRequirementsBuilder() + .withRequests(quantities) + .withLimits(quantities) + .build(); + } +} diff --git a/operator/src/main/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilder.java b/operator/src/main/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilder.java index 5c6597a..928843c 100644 --- a/operator/src/main/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilder.java +++ b/operator/src/main/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilder.java @@ -18,6 +18,7 @@ package net.onelitefeather.apus.operator.map; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import net.onelitefeather.apus.operator.api.BlueMapMap; @@ -37,6 +38,15 @@ * pod that serves already-rendered maps needs a full {@code webserver.conf} and the storage * config this class builds, and that surface is not covered by the render env-var contract. * Do not delete this class as dead code — it is future-phase code, staged ahead of its wiring. + * + *

{@code webserver.conf} format, verified against the real file. Running the BlueMap + * CLI ({@code apus/runner:dev}'s {@code /opt/bluemap/cli.jar}, BlueMap 5.23) with {@code -c} on + * an empty config folder and no action flag writes every default config file, including {@code + * webserver.conf}. That generated file has no bind-address/{@code ip} setting at all — only + * {@code enabled}, {@code webroot}, {@code port}, {@code sse-enabled}, and an optional {@code + * log} block. The webserver always listens on all interfaces; there is no key to restrict it to + * localhost, so {@link #buildForHosting} does not emit one either, and instead documents this in + * a comment in the generated file. */ public final class BlueMapConfigBuilder { @@ -103,6 +113,91 @@ public static Map build(BlueMapMap map, BucketBinding binding) { return files; } + /** + * Builds every config file a hosting webserver needs to display several maps at once, keyed + * by the path it should occupy relative to the BlueMap working directory. + * + *

Unlike {@link #build}, which renders exactly one map from local world data, this method + * never emits a {@code world}/{@code dimension} key in a map's config: per BlueMap's own + * documentation for that setting, omitting it means "the map will be only registered to the + * webserver and the webapp but not rendered or loaded by BlueMap" -- used to display a map + * that has already been rendered somewhere else, exactly the hosting pod's job. + * + *

{@code maps} and {@code bindings} are matched positionally: {@code bindings.get(i)} is + * the bucket backing {@code maps.get(i)}. Each map gets its own {@code maps/.conf} and + * its own {@code storages/.conf} (not a single shared {@code storages/s3.conf} like + * {@link #build}), because different maps can live in different buckets. + * + * @return file name → file content, ready to become a ConfigMap + */ + public static Map buildForHosting( + List maps, List bindings, int webserverPort) { + if (maps.size() != bindings.size()) { + throw new IllegalArgumentException("maps and bindings must be matched positionally, got %d maps and %d bindings" + .formatted(maps.size(), bindings.size())); + } + + Map files = new LinkedHashMap<>(); + + files.put("webserver.conf", webserverConfig(webserverPort)); + + for (int i = 0; i < maps.size(); i++) { + BlueMapMap map = maps.get(i); + BucketBinding binding = bindings.get(i); + String mapId = map.getMetadata().getName(); + + files.put("maps/" + mapId + ".conf", hostingMapConfig(mapId)); + files.put("storages/" + mapId + ".conf", hostingStorageConfig(map, binding)); + } + + return files; + } + + private static String webserverConfig(int port) { + return """ + enabled: true + webroot: "web" + port: %d + sse-enabled: true + + # BlueMap 5.23's default webserver.conf has no bind-address/ip setting -- verified + # by running the CLI against an empty config folder (see this class's Javadoc). + # The webserver always listens on all interfaces (0.0.0.0), which a pod needs: it + # must accept connections from the Service, not just from localhost. + """ + .formatted(port); + } + + private static String hostingMapConfig(String mapId) { + // No world/dimension key: this pod only serves what was already rendered elsewhere. + return """ + name: "%s" + sorting: 0 + storage: "%s" + """ + .formatted(mapId, mapId); + } + + // No credentials here either: the hosting image's entrypoint fills access-key-id/ + // secret-access-key in from the Rook-managed Secret's environment variables before + // starting BlueMap, exactly like the runner's entrypoint does for build(). + private static String hostingStorageConfig(BlueMapMap map, BucketBinding binding) { + return """ + storage-type: "themeinerlp:s3" + bucket-name: "%s" + region: "%s" + endpoint-url: "%s" + compression: "gzip" + root-path: "%s" + force-path-style: true + """ + .formatted( + binding.bucketName(), + binding.region(), + binding.endpoint(), + map.getSpec().getStorage().getPrefix()); + } + private static int renderThreads(BlueMapMap map) { return 2; } diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/ApusOperatorTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/ApusOperatorTest.java index 7f24b97..fdaa58e 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/ApusOperatorTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/ApusOperatorTest.java @@ -25,6 +25,7 @@ import io.javaoperatorsdk.operator.Operator; import java.util.Set; import java.util.stream.Collectors; +import net.onelitefeather.apus.operator.hosting.BlueMapHostingReconciler; import net.onelitefeather.apus.operator.ingest.WorldIngestReconciler; import net.onelitefeather.apus.operator.ingest.WorldSourceReconciler; import net.onelitefeather.apus.operator.map.BlueMapMapReconciler; @@ -35,7 +36,7 @@ /** * {@link OperatorConfig#fromEnvironment} itself is already covered by {@code * OperatorConfigTest}; this class instead proves that {@link ApusOperator}'s wiring is correct -- - * that all three reconcilers this operator ships actually end up registered. + * that all six reconcilers this operator ships actually end up registered. * *

{@link ApusOperator#main} is not exercised directly: it builds its own {@link * KubernetesClient} via {@code KubernetesClientBuilder} and calls {@link Operator#start()}, both @@ -50,12 +51,12 @@ class ApusOperatorTest { KubernetesClient client; @Test - void registersAllFiveReconcilers() { + void registersAllSixReconcilers() { Operator operator = new Operator(o -> o.withKubernetesClient(client)); ApusOperator.registerReconcilers(operator, client, OperatorConfig.defaults()); - assertEquals(5, operator.getRegisteredControllersNumber()); + assertEquals(6, operator.getRegisteredControllersNumber()); Set reconcilerClassNames = operator.getRegisteredControllers().stream() .map(controller -> controller.getConfiguration().getAssociatedReconcilerClassName()) .collect(Collectors.toSet()); @@ -64,5 +65,6 @@ void registersAllFiveReconcilers() { assertTrue(reconcilerClassNames.contains(BlueMapRenderReconciler.class.getName())); assertTrue(reconcilerClassNames.contains(WorldSourceReconciler.class.getName())); assertTrue(reconcilerClassNames.contains(WorldIngestReconciler.class.getName())); + assertTrue(reconcilerClassNames.contains(BlueMapHostingReconciler.class.getName())); } } diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/CrdGenerationTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/CrdGenerationTest.java index 31f3ce3..91bfb45 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/CrdGenerationTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/CrdGenerationTest.java @@ -202,4 +202,22 @@ void worldIngestIsNamespaceScoped() { assertEquals("Namespaced", crd.getSpec().getScope(), "WorldIngest must be namespace-scoped"); } + + @Test + void generatesTheBlueMapHostingCrdWithExpectedIdentity() { + CustomResourceDefinition crd = loadCrd("bluemaphostings.bluemap.onelitefeather.net-v1.yml"); + + assertEquals("bluemap.onelitefeather.net", crd.getSpec().getGroup()); + assertEquals("BlueMapHosting", crd.getSpec().getNames().getKind()); + assertEquals("bluemaphostings", crd.getSpec().getNames().getPlural()); + } + + @Test + void blueMapHostingIsNamespaceScoped() { + CustomResourceDefinition crd = loadCrd("bluemaphostings.bluemap.onelitefeather.net-v1.yml"); + + // A hosting webserver belongs to exactly one tenant's namespace, exactly like + // BlueMapMap and WorldSource. + assertEquals("Namespaced", crd.getSpec().getScope(), "BlueMapHosting must be namespace-scoped"); + } } diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java index b8edb6b..44c64d7 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorConfigTest.java @@ -33,6 +33,7 @@ void defaultsMatchTheFeatherCoreCluster() { assertEquals("ceph-bucket-fr01", config.bucketStorageClass()); assertEquals("apus/runner:dev", config.runnerImage()); assertEquals("apus/ingest:dev", config.ingestImage()); + assertEquals("apus/hosting:dev", config.hostingImage()); assertEquals("apus-bundles", config.bundleBucket()); assertEquals("us-east-1", config.bundleS3Region()); assertEquals("apus-bundle-credentials", config.bundleCredentialsSecretName()); @@ -60,6 +61,7 @@ void fromEnvironmentReadsAllVariables() { Map.entry("APUS_BUCKET_STORAGE_CLASS", "ceph-bucket-de01"), Map.entry("APUS_RUNNER_IMAGE", "apus/runner:1.2.3"), Map.entry("APUS_INGEST_IMAGE", "apus/ingest:1.2.3"), + Map.entry("APUS_HOSTING_IMAGE", "apus/hosting:1.2.3"), Map.entry("APUS_BUNDLE_BUCKET", "bundles-de"), Map.entry("APUS_BUNDLE_S3_ENDPOINT", "http://rgw.de.svc:80"), Map.entry("APUS_BUNDLE_S3_REGION", "eu-central-1"), @@ -72,6 +74,7 @@ void fromEnvironmentReadsAllVariables() { assertEquals("ceph-bucket-de01", config.bucketStorageClass()); assertEquals("apus/runner:1.2.3", config.runnerImage()); assertEquals("apus/ingest:1.2.3", config.ingestImage()); + assertEquals("apus/hosting:1.2.3", config.hostingImage()); assertEquals("bundles-de", config.bundleBucket()); assertEquals("http://rgw.de.svc:80", config.bundleS3Endpoint()); assertEquals("eu-central-1", config.bundleS3Region()); diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java index 90da4e7..38e353e 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/OperatorIntegrationTest.java @@ -31,15 +31,13 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientBuilder; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.Duration; import net.onelitefeather.apus.operator.api.BlueMapMap; import net.onelitefeather.apus.operator.api.Conditions; import net.onelitefeather.apus.operator.api.Tenant; import net.onelitefeather.apus.operator.map.BlueMapMapReconciler; import net.onelitefeather.apus.operator.tenant.TenantReconciler; +import net.onelitefeather.apus.operator.testsupport.K3sCrdSupport; import org.junit.jupiter.api.Test; import org.testcontainers.k3s.K3sContainer; import org.testcontainers.utility.DockerImageName; @@ -82,10 +80,13 @@ void appliesGeneratedCrdsAndReconcilesATenant() throws Exception { try (KubernetesClient client = new KubernetesClientBuilder().withConfig(config).build()) { - applyGeneratedCrds(client); - awaitCrdRegistration(client, "tenants.bluemap.onelitefeather.net"); - awaitCrdRegistration(client, "bluemapmaps.bluemap.onelitefeather.net"); - awaitCrdRegistration(client, "bluemaprenders.bluemap.onelitefeather.net"); + K3sCrdSupport.applyGeneratedCrds(client); + K3sCrdSupport.awaitCrdRegistration( + client, "tenants.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT); + K3sCrdSupport.awaitCrdRegistration( + client, "bluemapmaps.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT); + K3sCrdSupport.awaitCrdRegistration( + client, "bluemaprenders.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT); Tenant tenant = new Tenant(); tenant.setMetadata( @@ -155,8 +156,9 @@ void reportsRookUnavailableForABlueMapMapWithoutThrowing() throws Exception { try (KubernetesClient client = new KubernetesClientBuilder().withConfig(config).build()) { - applyGeneratedCrds(client); - awaitCrdRegistration(client, "bluemapmaps.bluemap.onelitefeather.net"); + K3sCrdSupport.applyGeneratedCrds(client); + K3sCrdSupport.awaitCrdRegistration( + client, "bluemapmaps.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT); BlueMapMap map = new BlueMapMap(); map.setMetadata(new ObjectMetaBuilder() @@ -187,32 +189,4 @@ void reportsRookUnavailableForABlueMapMapWithoutThrowing() throws Exception { } } } - - private static void applyGeneratedCrds(KubernetesClient client) throws Exception { - Path crdDir = Path.of(System.getProperty("apus.crd.dir", "build/crds")); - try (var files = Files.list(crdDir)) { - files.filter(path -> path.toString().endsWith(".yml") || path.toString().endsWith(".yaml")) - .forEach(path -> { - try (InputStream in = Files.newInputStream(path)) { - client.load(in).serverSideApply(); - } catch (Exception e) { - throw new IllegalStateException("failed to apply CRD manifest " + path, e); - } - }); - } - } - - /** Polls until {@code crdName} shows up as a registered CustomResourceDefinition, or fails. */ - private static void awaitCrdRegistration(KubernetesClient client, String crdName) throws InterruptedException { - long deadline = System.currentTimeMillis() + CRD_REGISTRATION_TIMEOUT.toMillis(); - boolean known = false; - while (System.currentTimeMillis() < deadline && !known) { - known = client.apiextensions().v1().customResourceDefinitions().list().getItems().stream() - .anyMatch(crd -> crdName.equals(crd.getMetadata().getName())); - if (!known) { - Thread.sleep(1000); - } - } - assertTrue(known, crdName + " CRD must be registered on the API server"); - } } diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/api/HostingResourceTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/api/HostingResourceTest.java new file mode 100644 index 0000000..5bcba03 --- /dev/null +++ b/operator/src/test/java/net/onelitefeather/apus/operator/api/HostingResourceTest.java @@ -0,0 +1,106 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.fabric8.kubernetes.api.model.Namespaced; +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +class HostingResourceTest { + + @Test + void hostingIsNamespaced() { + // A hosting webserver belongs to exactly one tenant's namespace, exactly like + // BlueMapMap and WorldSource. + assertTrue(Namespaced.class.isAssignableFrom(BlueMapHosting.class)); + } + + @Test + void everyResourceHasANonNullSpecAndStatusRightAfterConstruction() { + // CustomResource's default initSpec()/initStatus() return null; a subclass has to + // override both or `new X().getSpec()` is null until something (e.g. Jackson + // deserialisation from the API server) overwrites the field. This trap already blocked + // three parallel Phase 2a tasks once -- checked explicitly here so it cannot repeat. + assertNotNull( + new BlueMapHosting().getSpec(), "BlueMapHosting.getSpec() must not be null right after construction"); + assertNotNull( + new BlueMapHosting().getStatus(), + "BlueMapHosting.getStatus() must not be null right after construction"); + } + + @Test + void specGroupsAreInitialisedSoReconcilersNeverSeeNull() { + BlueMapHosting hosting = new BlueMapHosting(); + assertNotNull(hosting.getSpec().getMaps()); + assertNotNull(hosting.getSpec().getTls()); + assertNotNull(hosting.getSpec().getResources()); + assertNotNull(hosting.getStatus().getConditions()); + } + + @Test + void defaultsMatchTheSpecifiedProductionShape() { + BlueMapHostingSpec spec = new BlueMapHosting().getSpec(); + + assertEquals("nginx", spec.getIngressClassName()); + assertEquals(1, spec.getReplicas()); + assertTrue(spec.getTls().isEnabled()); + assertEquals("ClusterIssuer", spec.getTls().getIssuerKind()); + } + + @Test + void allNestedGroupsAreInitialisedRecursively() { + // A check that only looks at the first level of a spec/status (as + // specGroupsAreInitialisedSoReconcilersNeverSeeNull() above does) can miss a group + // nested two levels deep, e.g. Tls.issuerRef. Walking every nested Apus-owned group + // recursively closes that gap for the current fields and for any added later -- see + // IngestResourceTest for the identical check on the Phase 2b resources, and the Phase + // 2a incident that motivated it. + assertNoUninitialisedGroup(new BlueMapHosting().getSpec()); + assertNoUninitialisedGroup(new BlueMapHosting().getStatus()); + } + + private static void assertNoUninitialisedGroup(Object group) { + for (Field field : group.getClass().getDeclaredFields()) { + if (field.isSynthetic() || !isApusOwnedType(field.getType())) { + continue; + } + field.setAccessible(true); + Object value; + try { + value = field.get(group); + } catch (IllegalAccessException e) { + throw new AssertionError("could not read field " + field, e); + } + assertNotNull( + value, + group.getClass().getSimpleName() + "." + field.getName() + + " must be initialised in its field declaration, not left null"); + assertNoUninitialisedGroup(value); + } + } + + private static boolean isApusOwnedType(Class type) { + // Nested static classes (e.g. BlueMapHostingSpec.Tls) still report their enclosing + // top-level class's package, so a plain equality check also covers them. + return "net.onelitefeather.apus.operator.api".equals(type.getPackageName()); + } +} diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingIntegrationTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingIntegrationTest.java new file mode 100644 index 0000000..d9d413e --- /dev/null +++ b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingIntegrationTest.java @@ -0,0 +1,383 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.hosting; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.Config; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import java.time.Duration; +import java.util.List; +import net.onelitefeather.apus.operator.OperatorConfig; +import net.onelitefeather.apus.operator.api.BlueMapHosting; +import net.onelitefeather.apus.operator.api.BlueMapMap; +import net.onelitefeather.apus.operator.api.Conditions; +import net.onelitefeather.apus.operator.api.Ref; +import net.onelitefeather.apus.operator.api.Tenant; +import net.onelitefeather.apus.operator.tenant.TenantReconciler; +import net.onelitefeather.apus.operator.testsupport.K3sCrdSupport; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.k3s.K3sContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Proves {@link BlueMapHostingReconciler} end to end against a real Kubernetes API server + * (k3s, started via Testcontainers), following the same rationale {@code OperatorIntegrationTest} + * already established for {@code TenantReconciler}/{@code BlueMapMapReconciler}: the fabric8 mock + * server used by {@link BlueMapHostingReconcilerTest} accepts any well-formed request regardless + * of whether the generated {@code bluemaphostings} CRD schema would actually validate it, and + * unconditionally answers {@link io.fabric8.kubernetes.client.Client#supports} with {@code true} + * for any {@code CustomResource} -- so it can never prove the {@code client.supports(Certificate + * .class)} branch actually returns {@code false} when cert-manager is genuinely absent. Only a + * real API server can prove either of those. + * + *

One {@link K3sContainer} is shared across every test in this class (unlike {@code + * OperatorIntegrationTest}, which starts a fresh one per test) -- this class needs several + * independent scenarios, and starting a k3s node per scenario would multiply an already-expensive + * setup for no additional coverage. Each test uses its own tenant/namespace name so the scenarios + * do not interfere with each other on the shared cluster. The same shared-container-across-tests + * shape is already used by {@code S3SourceConnectorTest} in the {@code ingest} module. + * + *

What this class proves: + * + *

    + *
  • The generated {@code bluemaphostings} CRD applies to a real API server and registers as + * {@code Namespaced} ({@link #bluemaphostingsCrdAppliesAndRegistersAsNamespaced()}). + *
  • A full reconcile against a real cluster -- a real {@code Tenant} reconciled by {@code + * TenantReconciler} (so the namespace carries the real {@link + * net.onelitefeather.apus.operator.api.Labels#TENANT} label, not a hand-rolled one) with + * matching {@code allowedDomains}, and {@code BlueMapMap}s with a bound bucket status -- + * produces a ConfigMap (one file per map, plus {@code webserver.conf}), a Deployment, a + * Service, and an Ingress with the expected properties ({@link + * #reconcilesAFullHostingIntoConfigMapDeploymentServiceAndIngress()}). + *
  • Both security checks from the design spec still refuse to create any resource when + * resolved against a real Tenant/namespace-label lookup, not just the mock server's + * in-memory maps ({@link #hostnameOutsideAllowedDomainsCreatesNoResourcesOnARealCluster()}, + * {@link #mapMissingFromNamespaceCreatesNoResourcesOnARealCluster()}). + *
  • {@code client.supports(Certificate.class)} genuinely returns {@code false} on this + * cert-manager-less k3s cluster, and {@link BlueMapHostingReconciler} blocks the entire + * hosting rather than creating a broken Ingress when that happens ({@link + * #certManagerSupportsReturnsFalseOnARealClusterWithoutCertManagerInstalled()}, {@link + * #tlsRequestedWithoutCertManagerBlocksTheEntireHostingOnARealCluster()}). + *
+ * + *

What this class deliberately does not prove -- see the phase 3 plan's Task 5 section + * and the task-5 report: the full network path through a real Ingress controller (nginx or + * cloudflare-tunnel) is out of scope here; standing one up on k3s just for this test would be + * disproportionate, and Task 2's own verification already proved with a real HTTP call that the + * hosting pod serves a map's tiles once its config is in place. This class stops at "the + * Kubernetes objects the reconciler creates are wired together correctly." + */ +@Testcontainers +class BlueMapHostingIntegrationTest { + + private static final Duration CRD_REGISTRATION_TIMEOUT = Duration.ofMinutes(2); + + @Container + private static final K3sContainer K3S = new K3sContainer(DockerImageName.parse("rancher/k3s:v1.31.2-k3s1")); + + private static KubernetesClient client; + + @BeforeAll + static void createClientAndApplyCrds() throws Exception { + Config config = Config.fromKubeconfig(K3S.getKubeConfigYaml()); + client = new KubernetesClientBuilder().withConfig(config).build(); + + K3sCrdSupport.applyGeneratedCrds(client); + K3sCrdSupport.awaitCrdRegistration(client, "tenants.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT); + K3sCrdSupport.awaitCrdRegistration( + client, "bluemapmaps.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT); + K3sCrdSupport.awaitCrdRegistration( + client, "bluemaphostings.bluemap.onelitefeather.net", CRD_REGISTRATION_TIMEOUT); + } + + @AfterAll + static void closeClient() { + if (client != null) { + client.close(); + } + } + + // --- Requirement 1: the CRD itself applies and registers ------------------------------ + + @Test + void bluemaphostingsCrdAppliesAndRegistersAsNamespaced() { + boolean registeredAsNamespaced = client.apiextensions() + .v1() + .customResourceDefinitions() + .list() + .getItems() + .stream() + .anyMatch(crd -> "bluemaphostings.bluemap.onelitefeather.net".equals( + crd.getMetadata().getName()) + && "Namespaced".equals(crd.getSpec().getScope())); + assertTrue( + registeredAsNamespaced, + "the generated bluemaphostings CRD must register as a Namespaced resource on a real API server"); + } + + // --- Requirement 2: a full reconcile produces every resource with the right properties - + + @Test + void reconcilesAFullHostingIntoConfigMapDeploymentServiceAndIngress() throws Exception { + Tenant tenant = tenantWithAllowedDomains("friends-full", "*.friends.example.net"); + String namespace = TenantReconciler.namespaceFor(tenant); + boundMap(namespace, "survival-overworld", "bucket-a", "secret-a"); + boundMap(namespace, "creative-overworld", "bucket-b", "secret-b"); + BlueMapHosting hosting = createHosting( + namespace, "friends-maps", "map.friends.example.net", "survival-overworld", "creative-overworld"); + // spec.tls.enabled defaults to true, which would hit the CertManagerUnavailable branch + // covered separately by tlsRequestedWithoutCertManagerBlocksTheEntireHostingOnARealCluster + // -- this k3s cluster genuinely has no cert-manager. Disabled here so this test isolates + // the ConfigMap/Deployment/Service/Ingress properties it actually asserts on. + hosting.getSpec().getTls().setEnabled(false); + + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + reconciler.reconcile(hosting, null); + + // Requirement 4: one config file per map, plus webserver.conf. Real Kubernetes rejects a + // ConfigMap data key containing '/' outright, so HostingResourceBuilder#configMapKey + // sanitises "maps/.conf" to "maps..conf" before it ever reaches the API server -- + // this reconcile only got past the ConfigMap creation at all once that fix landed (see + // the task-5 report: this is exactly the class of bug the mock server cannot catch). + ConfigMap configMap = client.configMaps().inNamespace(namespace).withName("friends-maps-config").get(); + assertNotNull(configMap, "reconciling a valid hosting must create its ConfigMap"); + assertTrue( + configMap.getData().containsKey("maps.survival-overworld.conf"), + configMap.getData().keySet().toString()); + assertTrue( + configMap.getData().containsKey("maps.creative-overworld.conf"), + configMap.getData().keySet().toString()); + assertTrue(configMap.getData().containsKey("webserver.conf"), configMap.getData().keySet().toString()); + + Deployment deployment = + client.apps().deployments().inNamespace(namespace).withName("friends-maps").get(); + assertNotNull(deployment, "reconciling a valid hosting must create its Deployment"); + assertEquals( + OperatorConfig.defaults().hostingImage(), + deployment + .getSpec() + .getTemplate() + .getSpec() + .getContainers() + .get(0) + .getImage()); + assertEquals(1, deployment.getSpec().getReplicas()); + + // The API server round-trips the ConfigMap volume's `items` (key -> nested path) + // untouched -- proving the sanitised keys and their restored paths are not just accepted + // by HostingResourceBuilder's pure-function tests, but by the real object schema too. + var configVolume = deployment.getSpec().getTemplate().getSpec().getVolumes().stream() + .filter(volume -> volume.getConfigMap() != null) + .findFirst() + .orElseThrow(() -> new AssertionError("deployment must mount the hosting ConfigMap")); + var keyToPath = configVolume.getConfigMap().getItems().stream() + .collect(java.util.stream.Collectors.toMap( + io.fabric8.kubernetes.api.model.KeyToPath::getKey, + io.fabric8.kubernetes.api.model.KeyToPath::getPath)); + assertEquals( + "maps/survival-overworld.conf", + keyToPath.get("maps.survival-overworld.conf"), + "the volume item must restore the original nested path: " + keyToPath); + + Service service = client.services().inNamespace(namespace).withName("friends-maps").get(); + assertNotNull(service, "reconciling a valid hosting must create its Service"); + assertEquals( + HostingResourceBuilder.WEBSERVER_PORT, + service.getSpec().getPorts().get(0).getPort()); + + Ingress ingress = + client.network().v1().ingresses().inNamespace(namespace).withName("friends-maps").get(); + assertNotNull(ingress, "reconciling a valid hosting must create its Ingress"); + assertEquals( + "map.friends.example.net", + ingress.getSpec().getRules().get(0).getHost()); + assertEquals( + "friends-maps", + ingress.getSpec() + .getRules() + .get(0) + .getHttp() + .getPaths() + .get(0) + .getBackend() + .getService() + .getName()); + } + + // --- Requirement 3: both security checks hold against a real Tenant/namespace lookup --- + + @Test + void hostnameOutsideAllowedDomainsCreatesNoResourcesOnARealCluster() throws Exception { + Tenant tenant = tenantWithAllowedDomains("friends-s1", "*.friends.example.net"); + String namespace = TenantReconciler.namespaceFor(tenant); + boundMap(namespace, "survival-overworld", "bucket-a", "secret-a"); + BlueMapHosting hosting = + createHosting(namespace, "friends-maps", "map.evil.example.com", "survival-overworld"); + + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.HOSTNAME_NOT_ALLOWED_REASON, readyReason(hosting)); + assertNull( + client.apps().deployments().inNamespace(namespace).withName("friends-maps").get(), + "no Deployment may be created for a hostname outside the tenant's allowedDomains"); + assertNull( + client.network().v1().ingresses().inNamespace(namespace).withName("friends-maps").get(), + "no Ingress may be created for a hostname outside the tenant's allowedDomains"); + assertNull( + client.configMaps().inNamespace(namespace).withName("friends-maps-config").get(), + "no ConfigMap may be created for a hostname outside the tenant's allowedDomains"); + } + + @Test + void mapMissingFromNamespaceCreatesNoResourcesOnARealCluster() throws Exception { + Tenant tenant = tenantWithAllowedDomains("friends-s2", "*.friends.example.net"); + String namespace = TenantReconciler.namespaceFor(tenant); + BlueMapHosting hosting = + createHosting(namespace, "friends-maps", "map.friends.example.net", "does-not-exist"); + + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.MAP_NOT_FOUND_REASON, readyReason(hosting)); + assertNull( + client.apps().deployments().inNamespace(namespace).withName("friends-maps").get(), + "no Deployment may be created for a map that does not exist in the hosting's namespace"); + assertNull( + client.configMaps().inNamespace(namespace).withName("friends-maps-config").get(), + "no ConfigMap may be created for a map that does not exist in the hosting's namespace"); + } + + // --- The client.supports(Certificate.class) branch: untestable against the mock server - + + @Test + void certManagerSupportsReturnsFalseOnARealClusterWithoutCertManagerInstalled() { + // This is the exact assertion the fabric8 mock server used by BlueMapHostingReconcilerTest + // can never make: EnableKubernetesMockClient answers supports() with an unconditional + // true for any CustomResource, cert-manager installed or not. This k3s cluster genuinely + // has no cert-manager, so this is the first time this call is proven to return false. + assertFalse( + client.supports(Certificate.class), + "cert-manager is not installed on this cluster; supports() must report that honestly"); + } + + @Test + void tlsRequestedWithoutCertManagerBlocksTheEntireHostingOnARealCluster() throws Exception { + Tenant tenant = tenantWithAllowedDomains("friends-tls", "*.friends.example.net"); + String namespace = TenantReconciler.namespaceFor(tenant); + boundMap(namespace, "survival-overworld", "bucket-a", "secret-a"); + BlueMapHosting hosting = + createHosting(namespace, "friends-maps", "map.friends.example.net", "survival-overworld"); + hosting.getSpec().getTls().setEnabled(true); + hosting.getSpec().getTls().getIssuerRef().setName("letsencrypt-prod"); + + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.CERT_MANAGER_UNAVAILABLE_REASON, readyReason(hosting)); + assertNull( + client.apps().deployments().inNamespace(namespace).withName("friends-maps").get(), + "TLS requested without cert-manager must block the whole hosting, not just the Certificate"); + assertNull( + client.configMaps().inNamespace(namespace).withName("friends-maps-config").get(), + "TLS requested without cert-manager must block the whole hosting, not just the Certificate"); + assertNull( + client.network().v1().ingresses().inNamespace(namespace).withName("friends-maps").get(), + "TLS requested without cert-manager must block the whole hosting, not just the Certificate"); + } + + // --- Fixtures --------------------------------------------------------------------------- + + /** + * Creates a {@code Tenant} with {@code allowedDomains} set, then reconciles it for real via + * {@link TenantReconciler} so its namespace exists and carries the exact {@code + * apus.onelitefeather.net/tenant} label {@link BlueMapHostingReconciler} looks up -- not a + * hand-labelled stand-in, unlike {@code BlueMapHostingReconcilerTest}'s mock-server fixture, + * which only needs to fool an in-memory map. + */ + private static Tenant tenantWithAllowedDomains(String tenantName, String... allowedDomains) { + Tenant tenant = new Tenant(); + tenant.setMetadata(new ObjectMetaBuilder().withName(tenantName).build()); + tenant.getSpec().setDisplayName(tenantName); + tenant.getSpec().getStorage().setQuota("10Gi"); + tenant.getSpec().getHosting().setAllowedDomains(List.of(allowedDomains)); + Tenant created = client.resources(Tenant.class).resource(tenant).create(); + + new TenantReconciler(client, OperatorConfig.defaults()).reconcile(created, null); + return created; + } + + /** Creates a {@code BlueMapMap} in {@code namespace} with a bucket already bound in status. */ + private static BlueMapMap boundMap(String namespace, String name, String bucketName, String secretName) { + BlueMapMap map = new BlueMapMap(); + map.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(namespace) + .build()); + map.getSpec().getSource().setDimension("minecraft:overworld"); + map.getSpec().getBluemap().setMinecraftVersion("1.21.10"); + BlueMapMap created = + client.resources(BlueMapMap.class).inNamespace(namespace).resource(map).create(); + + created.getStatus().getBucket().setName(bucketName); + created.getStatus().getBucket().setSecretName(secretName); + created.getStatus().getBucket().setEndpoint("http://rgw.example.svc:80"); + client.resources(BlueMapMap.class).inNamespace(namespace).resource(created).updateStatus(); + return created; + } + + private static BlueMapHosting createHosting(String namespace, String name, String hostname, String... mapNames) { + BlueMapHosting hosting = new BlueMapHosting(); + hosting.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(namespace) + .build()); + hosting.getSpec().setHostname(hostname); + for (String mapName : mapNames) { + Ref ref = new Ref(); + ref.setName(mapName); + hosting.getSpec().getMaps().add(ref); + } + // The API server assigns the UID; BlueMapHostingReconciler's ownership check depends on + // it (see the class Javadoc), so the reconciler must see the server-assigned object. + return client.resources(BlueMapHosting.class).inNamespace(namespace).resource(hosting).create(); + } + + private static String readyReason(BlueMapHosting hosting) { + return hosting.getStatus().getConditions().stream() + .filter(condition -> Conditions.READY.equals(condition.getType())) + .findFirst() + .orElseThrow() + .getReason(); + } +} diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java new file mode 100644 index 0000000..f9a357f --- /dev/null +++ b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/BlueMapHostingReconcilerTest.java @@ -0,0 +1,488 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.hosting; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Service; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.apps.DeploymentStatusBuilder; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import java.util.List; +import java.util.UUID; +import net.onelitefeather.apus.operator.OperatorConfig; +import net.onelitefeather.apus.operator.api.BlueMapHosting; +import net.onelitefeather.apus.operator.api.BlueMapMap; +import net.onelitefeather.apus.operator.api.Conditions; +import net.onelitefeather.apus.operator.api.Labels; +import net.onelitefeather.apus.operator.api.Ref; +import net.onelitefeather.apus.operator.api.Tenant; +import org.junit.jupiter.api.Test; + +@EnableKubernetesMockClient(crud = true) +class BlueMapHostingReconcilerTest { + + private static final String NAMESPACE = "bluemap-friends"; + private static final String TENANT_NAME = "friends"; + + KubernetesClient client; + + private void namespaceForTenant(String tenantName, String namespace) { + client.namespaces() + .resource(new NamespaceBuilder() + .withNewMetadata() + .withName(namespace) + .withLabels(Labels.standard("tenant", tenantName)) + .addToLabels(Labels.TENANT, tenantName) + .endMetadata() + .build()) + .create(); + } + + private Tenant tenant(String name, String... allowedDomains) { + Tenant tenant = new Tenant(); + tenant.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withUid(UUID.randomUUID().toString()) + .build()); + tenant.getSpec().getHosting().setAllowedDomains(List.of(allowedDomains)); + client.resources(Tenant.class).resource(tenant).create(); + return tenant; + } + + /** Creates a namespace-labelled tenant with no {@code allowedDomains} restriction issue. */ + private void tenantWithDomains(String... allowedDomains) { + namespaceForTenant(TENANT_NAME, NAMESPACE); + tenant(TENANT_NAME, allowedDomains); + } + + private BlueMapMap boundMap(String name, String bucketName, String secretName) { + BlueMapMap map = new BlueMapMap(); + map.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(NAMESPACE) + .withUid(UUID.randomUUID().toString()) + .build()); + map.getSpec().getSource().setDimension("minecraft:overworld"); + client.resources(BlueMapMap.class).inNamespace(NAMESPACE).resource(map).create(); + + map.getStatus().getBucket().setName(bucketName); + map.getStatus().getBucket().setSecretName(secretName); + map.getStatus().getBucket().setEndpoint("http://rgw.example.svc:80"); + client.resources(BlueMapMap.class).inNamespace(NAMESPACE).resource(map).updateStatus(); + return map; + } + + private void unboundMap(String name) { + BlueMapMap map = new BlueMapMap(); + map.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(NAMESPACE) + .withUid(UUID.randomUUID().toString()) + .build()); + map.getSpec().getSource().setDimension("minecraft:overworld"); + client.resources(BlueMapMap.class).inNamespace(NAMESPACE).resource(map).create(); + } + + private BlueMapHosting hosting(String name, String hostname, String... mapNames) { + BlueMapHosting hosting = new BlueMapHosting(); + hosting.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(NAMESPACE) + .withUid(UUID.randomUUID().toString()) + .build()); + hosting.getSpec().setHostname(hostname); + for (String mapName : mapNames) { + Ref ref = new Ref(); + ref.setName(mapName); + hosting.getSpec().getMaps().add(ref); + } + return hosting; + } + + private String readyReason(BlueMapHosting hosting) { + return hosting.getStatus().getConditions().stream() + .filter(condition -> Conditions.READY.equals(condition.getType())) + .findFirst() + .orElseThrow() + .getReason(); + } + + private Deployment existingDeployment(String name) { + return client.apps().deployments().inNamespace(NAMESPACE).withName(name).get(); + } + + // --- S1: hostname vs Tenant.spec.hosting.allowedDomains ------------------------------- + + @Test + void hostnameOutsideAllowedDomainsProducesConditionAndNoResources() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.evil.example.com", "survival-overworld"); + + UpdateControl control = reconciler.reconcile(hosting, null); + + assertTrue(control.isPatchStatus()); + assertEquals(BlueMapHostingReconciler.HOSTNAME_NOT_ALLOWED_REASON, readyReason(hosting)); + assertNull(existingDeployment("friends-maps"), "no Deployment may be created for a disallowed hostname"); + assertNull( + client.network().v1().ingresses().inNamespace(NAMESPACE).withName("friends-maps").get(), + "no Ingress may be created for a disallowed hostname"); + assertFalse(hosting.getStatus().isReady()); + assertNull(hosting.getStatus().getUrl()); + } + + @Test + void hostnameMatchingAWildcardDomainCreatesResources() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + + assertNotNull(existingDeployment("friends-maps"), "an allowed hostname must produce a Deployment"); + assertNotNull(client.services().inNamespace(NAMESPACE).withName("friends-maps").get()); + assertNotNull( + client.network().v1().ingresses().inNamespace(NAMESPACE).withName("friends-maps").get()); + } + + @Test + void hostnameMatchingALiteralDomainCreatesResources() { + tenantWithDomains("map.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + + assertNotNull(existingDeployment("friends-maps")); + } + + @Test + void wildcardDoesNotMatchMoreThanOneSubdomainLevel() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "eu.map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.HOSTNAME_NOT_ALLOWED_REASON, readyReason(hosting)); + assertNull(existingDeployment("friends-maps")); + } + + @Test + void tenantWithNoAllowedDomainsGetsNoHosting() { + // tenantWithDomains() with zero varargs -- an explicitly empty allowedDomains list. + tenantWithDomains(); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.HOSTING_NOT_CONFIGURED_REASON, readyReason(hosting)); + assertNull(existingDeployment("friends-maps"), "a tenant with no allowedDomains must get no hosting at all"); + } + + @Test + void namespaceNotYetLabelledWithATenantBlocksHosting() { + client.namespaces() + .resource(new NamespaceBuilder().withNewMetadata().withName(NAMESPACE).endMetadata().build()) + .create(); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net"); + + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.TENANT_NOT_FOUND_REASON, readyReason(hosting)); + assertNull(existingDeployment("friends-maps")); + } + + // --- S2: referenced maps must resolve inside this hosting's own namespace ------------- + + @Test + void mapNotFoundInTheHostingsNamespaceProducesConditionInsteadOfDeployment() { + tenantWithDomains("*.friends.example.net"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "does-not-exist"); + + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.MAP_NOT_FOUND_REASON, readyReason(hosting)); + assertNull(existingDeployment("friends-maps")); + } + + @Test + void mapWithoutABoundBucketBlocksTheDeployment() { + tenantWithDomains("*.friends.example.net"); + unboundMap("survival-overworld"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + + assertEquals(BlueMapHostingReconciler.MAP_NOT_READY_REASON, readyReason(hosting)); + assertNull(existingDeployment("friends-maps")); + } + + // --- Functional behaviour once allowed ------------------------------------------------- + + @Test + void createsAConfigMapWithOneFilePerMap() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + boundMap("creative-overworld", "bucket-b", "secret-b"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = + hosting("friends-maps", "map.friends.example.net", "survival-overworld", "creative-overworld"); + + reconciler.reconcile(hosting, null); + + ConfigMap configMap = + client.configMaps().inNamespace(NAMESPACE).withName("friends-maps-config").get(); + assertNotNull(configMap); + // ConfigMap data keys are sanitised (no '/' -- a real API server rejects that, see + // HostingResourceBuilder#configMapKey); the original nested path survives as the + // corresponding config volume item's `path`, checked separately in + // HostingResourceBuilderTest#configVolumeItemsMapSanitisedKeysBackToTheirNestedPaths. + assertTrue(configMap.getData().containsKey("maps.survival-overworld.conf")); + assertTrue(configMap.getData().containsKey("maps.creative-overworld.conf")); + assertTrue(configMap.getData().containsKey("webserver.conf")); + } + + @Test + void deploymentUsesTheHostingImageFromOperatorConfig() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + OperatorConfig config = new OperatorConfig( + "rook-ceph-fr01", + "feather-s3", + "ceph-bucket-fr01", + "apus/runner:dev", + "apus/ingest:dev", + "apus/hosting:1.2.3", + "apus-bundles", + "http://rgw.example.svc:80", + "us-east-1", + "apus-bundle-credentials"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, config); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + + assertEquals( + "apus/hosting:1.2.3", + existingDeployment("friends-maps") + .getSpec() + .getTemplate() + .getSpec() + .getContainers() + .get(0) + .getImage()); + } + + @Test + void deploymentCarriesAConfigChecksumAnnotationThatChangesWithTheMapList() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + boundMap("creative-overworld", "bucket-b", "secret-b"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + String firstChecksum = existingDeployment("friends-maps") + .getSpec() + .getTemplate() + .getMetadata() + .getAnnotations() + .get(BlueMapHostingReconciler.CONFIG_CHECKSUM_ANNOTATION); + assertNotNull(firstChecksum); + + hosting.getSpec().getMaps().add(ref("creative-overworld")); + reconciler.reconcile(hosting, null); + String secondChecksum = existingDeployment("friends-maps") + .getSpec() + .getTemplate() + .getMetadata() + .getAnnotations() + .get(BlueMapHostingReconciler.CONFIG_CHECKSUM_ANNOTATION); + + assertNotEquals(firstChecksum, secondChecksum, "adding a map must change the checksum so pods restart"); + } + + private static Ref ref(String name) { + Ref ref = new Ref(); + ref.setName(name); + return ref; + } + + @Test + void certificateIsCreatedWhenTlsIsEnabled() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + hosting.getSpec().getTls().setEnabled(true); + hosting.getSpec().getTls().getIssuerRef().setName("letsencrypt-prod"); + + reconciler.reconcile(hosting, null); + + Certificate certificate = + client.resources(Certificate.class).inNamespace(NAMESPACE).withName("friends-maps").get(); + assertNotNull(certificate, "TLS enabled must create a Certificate"); + assertEquals(List.of("map.friends.example.net"), certificate.getSpec().getDnsNames()); + } + + @Test + void noCertificateIsCreatedWhenTlsIsDisabled() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + hosting.getSpec().getTls().setEnabled(false); + + reconciler.reconcile(hosting, null); + + assertNull(client.resources(Certificate.class) + .inNamespace(NAMESPACE) + .withName("friends-maps") + .get()); + } + + @Test + void reportsTheUrlOnlyOnceTheDeploymentIsReady() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + UpdateControl firstPass = reconciler.reconcile(hosting, null); + assertFalse(hosting.getStatus().isReady(), "must not be ready before the Deployment reports ready replicas"); + assertNull(hosting.getStatus().getUrl()); + assertTrue(firstPass.getScheduleDelay().isPresent(), "must be rechecked while waiting for readiness"); + + Deployment deployment = existingDeployment("friends-maps"); + deployment.setStatus( + new DeploymentStatusBuilder().withReadyReplicas(1).build()); + client.apps().deployments().inNamespace(NAMESPACE).resource(deployment).updateStatus(); + + reconciler.reconcile(hosting, null); + + assertTrue(hosting.getStatus().isReady()); + assertEquals("https://map.friends.example.net", hosting.getStatus().getUrl()); + assertEquals(BlueMapHostingReconciler.HOSTING_READY_REASON, readyReason(hosting)); + } + + @Test + void everyCreatedResourceCarriesTheManagedByLabel() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + + ConfigMap configMap = + client.configMaps().inNamespace(NAMESPACE).withName("friends-maps-config").get(); + Deployment deployment = existingDeployment("friends-maps"); + Service service = client.services().inNamespace(NAMESPACE).withName("friends-maps").get(); + Ingress ingress = + client.network().v1().ingresses().inNamespace(NAMESPACE).withName("friends-maps").get(); + + assertEquals(Labels.MANAGED_BY_VALUE, configMap.getMetadata().getLabels().get(Labels.MANAGED_BY)); + assertEquals(Labels.MANAGED_BY_VALUE, deployment.getMetadata().getLabels().get(Labels.MANAGED_BY)); + assertEquals(Labels.MANAGED_BY_VALUE, service.getMetadata().getLabels().get(Labels.MANAGED_BY)); + assertEquals(Labels.MANAGED_BY_VALUE, ingress.getMetadata().getLabels().get(Labels.MANAGED_BY)); + } + + // --- Ownership check --------------------------------------------------------------------- + + @Test + void refusesToAdoptAnUnownedPreExistingDeployment() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + Deployment foreign = new Deployment(); + foreign.setMetadata(new ObjectMetaBuilder() + .withName("friends-maps") + .withNamespace(NAMESPACE) + .build()); + foreign.setSpec(new io.fabric8.kubernetes.api.model.apps.DeploymentSpecBuilder() + .withNewSelector() + .addToMatchLabels("app", "unrelated") + .endSelector() + .withNewTemplate() + .withNewMetadata() + .addToLabels("app", "unrelated") + .endMetadata() + .withNewSpec() + .addNewContainer() + .withName("unrelated") + .withImage("busybox") + .endContainer() + .endSpec() + .endTemplate() + .build()); + client.apps().deployments().inNamespace(NAMESPACE).resource(foreign).create(); + + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + UpdateControl control = reconciler.reconcile(hosting, null); + + assertTrue(control.isPatchStatus()); + assertEquals(BlueMapHostingReconciler.RESOURCE_CONFLICT_REASON, readyReason(hosting)); + assertEquals( + "busybox", + existingDeployment("friends-maps") + .getSpec() + .getTemplate() + .getSpec() + .getContainers() + .get(0) + .getImage(), + "the foreign deployment must not be overwritten"); + } + + @Test + void isIdempotentAcrossRepeatedReconciles() { + tenantWithDomains("*.friends.example.net"); + boundMap("survival-overworld", "bucket-a", "secret-a"); + BlueMapHostingReconciler reconciler = new BlueMapHostingReconciler(client, OperatorConfig.defaults()); + BlueMapHosting hosting = hosting("friends-maps", "map.friends.example.net", "survival-overworld"); + + reconciler.reconcile(hosting, null); + UpdateControl control = reconciler.reconcile(hosting, null); + + assertTrue(control.isPatchStatus()); + assertNotNull(existingDeployment("friends-maps")); + } +} diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java new file mode 100644 index 0000000..b956461 --- /dev/null +++ b/operator/src/test/java/net/onelitefeather/apus/operator/hosting/HostingResourceBuilderTest.java @@ -0,0 +1,348 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.hosting; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.fabric8.kubernetes.api.model.Container; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.OwnerReference; +import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.VolumeMount; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPath; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.networking.v1.IngressTLS; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import net.onelitefeather.apus.operator.OperatorConfig; +import net.onelitefeather.apus.operator.api.BlueMapHosting; +import net.onelitefeather.apus.operator.api.Labels; +import org.junit.jupiter.api.Test; + +class HostingResourceBuilderTest { + + private BlueMapHosting hosting() { + BlueMapHosting hosting = new BlueMapHosting(); + hosting.setMetadata(new ObjectMetaBuilder() + .withName("friends-maps") + .withNamespace("bluemap-friends") + .withUid("11111111-1111-1111-1111-111111111111") + .build()); + hosting.getSpec().getMaps().add(ref("survival-overworld")); + hosting.getSpec().setHostname("maps.friends.example.com"); + return hosting; + } + + private net.onelitefeather.apus.operator.api.Ref ref(String name) { + net.onelitefeather.apus.operator.api.Ref ref = new net.onelitefeather.apus.operator.api.Ref(); + ref.setName(name); + return ref; + } + + private Map envOf(Deployment deployment) { + List env = deployment + .getSpec() + .getTemplate() + .getSpec() + .getContainers() + .get(0) + .getEnv(); + return env.stream().collect(Collectors.toMap(EnvVar::getName, Function.identity())); + } + + private void assertOwnedByHosting(List ownerReferences) { + assertNotNull(ownerReferences); + assertTrue( + ownerReferences.stream() + .anyMatch(ref -> "BlueMapHosting".equals(ref.getKind()) + && "friends-maps".equals(ref.getName()) + && "bluemap.onelitefeather.net/v1alpha1".equals(ref.getApiVersion())), + "expected an owner reference to the BlueMapHosting, got " + ownerReferences); + } + + @Test + void deploymentIsOwnedByTheHostingResourceSoItIsGarbageCollected() { + Deployment deployment = + HostingResourceBuilder.deployment( + hosting(), "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + + assertOwnedByHosting(deployment.getMetadata().getOwnerReferences()); + } + + @Test + void serviceIsOwnedByTheHostingResource() { + io.fabric8.kubernetes.api.model.Service service = HostingResourceBuilder.service(hosting()); + + assertOwnedByHosting(service.getMetadata().getOwnerReferences()); + } + + @Test + void ingressIsOwnedByTheHostingResource() { + Ingress ingress = HostingResourceBuilder.ingress(hosting()); + + assertOwnedByHosting(ingress.getMetadata().getOwnerReferences()); + } + + @Test + void certificateIsOwnedByTheHostingResourceWhenTlsIsEnabled() { + BlueMapHosting hosting = hosting(); + hosting.getSpec().getTls().setEnabled(true); + hosting.getSpec().getTls().getIssuerRef().setName("letsencrypt-prod"); + + Optional certificate = HostingResourceBuilder.certificate(hosting); + + assertTrue(certificate.isPresent()); + assertOwnedByHosting(certificate.get().getMetadata().getOwnerReferences()); + } + + @Test + void allResourcesCarryTheStandardManagedByLabel() { + BlueMapHosting hosting = hosting(); + hosting.getSpec().getTls().getIssuerRef().setName("letsencrypt-prod"); + + Deployment deployment = + HostingResourceBuilder.deployment( + hosting, "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + io.fabric8.kubernetes.api.model.Service service = HostingResourceBuilder.service(hosting); + Ingress ingress = HostingResourceBuilder.ingress(hosting); + Certificate certificate = HostingResourceBuilder.certificate(hosting).orElseThrow(); + + assertEquals(Labels.MANAGED_BY_VALUE, deployment.getMetadata().getLabels().get(Labels.MANAGED_BY)); + assertEquals(Labels.MANAGED_BY_VALUE, service.getMetadata().getLabels().get(Labels.MANAGED_BY)); + assertEquals(Labels.MANAGED_BY_VALUE, ingress.getMetadata().getLabels().get(Labels.MANAGED_BY)); + assertEquals(Labels.MANAGED_BY_VALUE, certificate.getMetadata().getLabels().get(Labels.MANAGED_BY)); + } + + @Test + void takesS3CredentialsFromTheSecretRatherThanInliningThem() { + Deployment deployment = + HostingResourceBuilder.deployment( + hosting(), "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + + Map env = envOf(deployment); + + for (String key : List.of("APUS_S3" + "_ACCESS_KEY", "APUS_S3" + "_SECRET_KEY")) { + EnvVar var = env.get(key); + assertNotNull(var, "missing " + key); + assertNotNull(var.getValueFrom(), key + " must come from a secretKeyRef"); + assertEquals("bucket-secret", var.getValueFrom().getSecretKeyRef().getName()); + assertNull(var.getValue(), key + " must never appear as a literal value in the manifest"); + } + } + + @Test + void deploymentMountsTheHostingConfigMap() { + Deployment deployment = + HostingResourceBuilder.deployment( + hosting(), "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + + List volumes = + deployment.getSpec().getTemplate().getSpec().getVolumes(); + assertTrue( + volumes.stream() + .anyMatch(volume -> volume.getConfigMap() != null + && "friends-maps-config".equals(volume.getConfigMap().getName())), + "expected a volume backed by the ConfigMap friends-maps-config, got " + volumes); + + Container container = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + List mounts = container.getVolumeMounts(); + assertNotNull(mounts); + assertFalse(mounts.isEmpty(), "container must mount the config volume"); + } + + /** + * A real Kubernetes API server rejects a {@code ConfigMap} data key containing {@code /} + * outright -- the fabric8 mock server every other test in this class runs against does not + * enforce that, which is exactly how this went unnoticed until a real-cluster reconcile tried + * it (see {@code BlueMapHostingIntegrationTest} and the phase 3 task-5 report). The volume's + * {@code items} must therefore map a sanitised, slash-free key back to the original nested + * {@code path} so the file still lands where {@code hosting/bin/config-sync.sh} expects it. + */ + @Test + void configVolumeItemsMapSanitisedKeysBackToTheirNestedPaths() { + Deployment deployment = HostingResourceBuilder.deployment( + hosting(), + "friends-maps-config", + Set.of("maps/survival-overworld.conf", "webserver.conf"), + "bucket-secret", + OperatorConfig.defaults()); + + Volume configVolume = deployment.getSpec().getTemplate().getSpec().getVolumes().stream() + .filter(volume -> volume.getConfigMap() != null) + .findFirst() + .orElseThrow(); + List items = + configVolume.getConfigMap().getItems(); + assertNotNull(items, "the config volume must map its keys back to their original paths"); + + Map keyToPath = items.stream() + .collect(Collectors.toMap( + io.fabric8.kubernetes.api.model.KeyToPath::getKey, + io.fabric8.kubernetes.api.model.KeyToPath::getPath)); + assertEquals( + "maps/survival-overworld.conf", + keyToPath.get("maps.survival-overworld.conf"), + "no '/' may appear in the data key itself, but the item's path must restore it: " + keyToPath); + assertEquals("webserver.conf", keyToPath.get("webserver.conf"), keyToPath.toString()); + for (String key : keyToPath.keySet()) { + assertFalse(key.contains("/"), "ConfigMap data keys must never contain '/': " + key); + } + } + + @Test + void deploymentHasReadinessAndLivenessProbes() { + Deployment deployment = + HostingResourceBuilder.deployment( + hosting(), "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + + Container container = + deployment.getSpec().getTemplate().getSpec().getContainers().get(0); + + assertNotNull(container.getReadinessProbe(), "a hosting pod must have a readiness probe"); + assertNotNull(container.getReadinessProbe().getHttpGet(), "readiness probe must be HTTP"); + assertNotNull(container.getLivenessProbe(), "a hosting pod must have a liveness probe"); + assertNotNull(container.getLivenessProbe().getHttpGet(), "liveness probe must be HTTP"); + } + + @Test + void deploymentUsesTheReplicaCountFromTheSpec() { + BlueMapHosting hosting = hosting(); + hosting.getSpec().setReplicas(3); + + Deployment deployment = + HostingResourceBuilder.deployment( + hosting, "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + + assertEquals(3, deployment.getSpec().getReplicas()); + } + + @Test + void deploymentIsNamespacedLikeTheHostingItBelongsTo() { + Deployment deployment = + HostingResourceBuilder.deployment( + hosting(), "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + + assertEquals("bluemap-friends", deployment.getMetadata().getNamespace()); + } + + @Test + void ingressCarriesTheHostnameFromTheSpec() { + Ingress ingress = HostingResourceBuilder.ingress(hosting()); + + assertEquals( + "maps.friends.example.com", + ingress.getSpec().getRules().get(0).getHost()); + } + + @Test + void ingressUsesTheIngressClassFromTheSpec() { + BlueMapHosting hosting = hosting(); + hosting.getSpec().setIngressClassName("cloudflare-tunnel"); + + Ingress ingress = HostingResourceBuilder.ingress(hosting); + + assertEquals("cloudflare-tunnel", ingress.getSpec().getIngressClassName()); + } + + @Test + void ingressRoutesToTheHostingService() { + Ingress ingress = HostingResourceBuilder.ingress(hosting()); + io.fabric8.kubernetes.api.model.Service service = HostingResourceBuilder.service(hosting()); + + HTTPIngressPath path = ingress.getSpec() + .getRules() + .get(0) + .getHttp() + .getPaths() + .get(0); + + assertEquals( + service.getMetadata().getName(), + path.getBackend().getService().getName(), + "ingress must route to the Service this builder created for the same hosting"); + } + + @Test + void serviceSelectsThePodsTheDeploymentCreates() { + Deployment deployment = + HostingResourceBuilder.deployment( + hosting(), "friends-maps-config", Set.of("maps/survival-overworld.conf", "webserver.conf"), "bucket-secret", OperatorConfig.defaults()); + io.fabric8.kubernetes.api.model.Service service = HostingResourceBuilder.service(hosting()); + + Map podLabels = + deployment.getSpec().getTemplate().getMetadata().getLabels(); + Map selector = service.getSpec().getSelector(); + + assertFalse(selector.isEmpty()); + selector.forEach((key, value) -> assertEquals(value, podLabels.get(key), "selector key " + key + " does not match pod label")); + } + + @Test + void producesACertificateWhenTlsIsEnabledAndTheIngressReferencesItsSecret() { + BlueMapHosting hosting = hosting(); + hosting.getSpec().getTls().setEnabled(true); + hosting.getSpec().getTls().getIssuerRef().setName("letsencrypt-prod"); + hosting.getSpec().getTls().setIssuerKind("ClusterIssuer"); + + Optional certificate = HostingResourceBuilder.certificate(hosting); + Ingress ingress = HostingResourceBuilder.ingress(hosting); + + assertTrue(certificate.isPresent(), "TLS enabled must produce a Certificate"); + assertEquals( + List.of("maps.friends.example.com"), + certificate.get().getSpec().getDnsNames()); + assertEquals("letsencrypt-prod", certificate.get().getSpec().getIssuerRef().getName()); + assertEquals( + "ClusterIssuer", certificate.get().getSpec().getIssuerRef().getKind()); + + List tls = ingress.getSpec().getTls(); + assertNotNull(tls); + assertFalse(tls.isEmpty(), "ingress must carry a tls section when TLS is enabled"); + assertEquals( + certificate.get().getSpec().getSecretName(), + tls.get(0).getSecretName(), + "ingress tls secretName must match the Certificate's secretName"); + assertEquals(List.of("maps.friends.example.com"), tls.get(0).getHosts()); + } + + @Test + void producesNoCertificateAndNoTlsSectionWhenTlsIsDisabled() { + BlueMapHosting hosting = hosting(); + hosting.getSpec().getTls().setEnabled(false); + + Optional certificate = HostingResourceBuilder.certificate(hosting); + Ingress ingress = HostingResourceBuilder.ingress(hosting); + + assertTrue(certificate.isEmpty(), "TLS disabled must not produce a Certificate"); + assertTrue( + ingress.getSpec().getTls() == null + || ingress.getSpec().getTls().isEmpty(), + "ingress must not carry a tls section when TLS is disabled"); + } +} diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java index b75a572..d7f77f3 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/ingest/IngestJobBuilderTest.java @@ -217,6 +217,7 @@ void placesTheContainerImageFromTheOperatorConfig() { "ceph-bucket-fr01", "apus/runner:dev", "apus/ingest:1.2.3", + "apus/hosting:dev", "apus-bundles", "http://rgw.example.svc:80", "us-east-1", diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilderTest.java index 0e3ba90..2e6a029 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilderTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/map/BlueMapConfigBuilderTest.java @@ -18,9 +18,11 @@ package net.onelitefeather.apus.operator.map; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import java.util.List; import java.util.Map; import net.onelitefeather.apus.operator.api.BlueMapMap; import org.junit.jupiter.api.Test; @@ -43,6 +45,21 @@ private BlueMapConfigBuilder.BucketBinding binding() { "apus-friends-survival", "http://rook-ceph-rgw.example.svc:80", "us-east-1"); } + /** Builds a {@link BlueMapMap} identified solely by its id, for the hosting-config tests. */ + private BlueMapMap map(String id) { + BlueMapMap map = new BlueMapMap(); + map.setMetadata( + new ObjectMetaBuilder().withName(id).withNamespace("bluemap-friends").build()); + map.getSpec().getStorage().setPrefix(id); + return map; + } + + /** Builds a {@link BlueMapConfigBuilder.BucketBinding} for the given bucket, for the hosting-config tests. */ + private BlueMapConfigBuilder.BucketBinding binding(String bucketName) { + return new BlueMapConfigBuilder.BucketBinding( + bucketName, "http://rook-ceph-rgw.example.svc:80", "us-east-1"); + } + @Test void coreConfigEnablesTheResourceDownload() { Map files = BlueMapConfigBuilder.build(map(), binding()); @@ -86,4 +103,54 @@ void mapConfigCarriesTheDimension() { files.get("maps/survival-overworld.conf").contains("minecraft:overworld"), files.toString()); } + + @Test + void hostingConfigContainsOneMapFilePerMap() { + Map files = BlueMapConfigBuilder.buildForHosting( + List.of(map("survival-overworld"), map("creative-overworld")), + List.of(binding("bucket-a"), binding("bucket-b")), + 8100); + + assertTrue(files.containsKey("maps/survival-overworld.conf"), files.keySet().toString()); + assertTrue(files.containsKey("maps/creative-overworld.conf"), files.keySet().toString()); + } + + @Test + void hostingConfigContainsAWebserverConfigBoundToAllInterfaces() { + Map files = + BlueMapConfigBuilder.buildForHosting(List.of(map("survival-overworld")), List.of(binding("bucket-a")), 8100); + + String webserver = files.get("webserver.conf"); + assertNotNull(webserver, files.keySet().toString()); + assertTrue(webserver.contains("8100"), webserver); + // A pod must accept connections from the service, not just from localhost. Verified + // against BlueMap 5.23's own generated default webserver.conf (run the CLI with an + // empty config folder -- see BlueMapConfigBuilder's class Javadoc): this version has + // no bind-address/ip setting at all, the webserver always listens on all interfaces. + // There is no key to set, so the fact is documented in a comment instead. + assertTrue(webserver.contains("0.0.0.0"), webserver); + } + + @Test + void eachMapGetsItsOwnStorageBecauseBucketsCanDiffer() { + Map files = BlueMapConfigBuilder.buildForHosting( + List.of(map("a"), map("b")), List.of(binding("bucket-a"), binding("bucket-b")), 8100); + + assertTrue(files.get("maps/a.conf").contains("storage: \"a\""), files.get("maps/a.conf")); + assertTrue(files.get("maps/b.conf").contains("storage: \"b\""), files.get("maps/b.conf")); + assertTrue(files.containsKey("storages/a.conf"), files.keySet().toString()); + assertTrue(files.containsKey("storages/b.conf"), files.keySet().toString()); + } + + @Test + void neverPutsCredentialsIntoTheHostingConfig() { + Map files = + BlueMapConfigBuilder.buildForHosting(List.of(map("a")), List.of(binding("bucket-a")), 8100); + + for (Map.Entry file : files.entrySet()) { + assertFalse( + file.getValue().contains("secret-access-key: \""), + "credentials must not be in " + file.getKey()); + } + } } diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java b/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java index f9d6756..3d093b6 100644 --- a/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java +++ b/operator/src/test/java/net/onelitefeather/apus/operator/render/RenderJobBuilderTest.java @@ -155,6 +155,7 @@ void placesTheContainerImageFromTheOperatorConfig() { "ceph-bucket-fr01", "apus/runner:1.2.3", "apus/ingest:dev", + "apus/hosting:dev", "apus-bundles", "http://rgw.rook-ceph-fr01.svc:80", "us-east-1", diff --git a/operator/src/test/java/net/onelitefeather/apus/operator/testsupport/K3sCrdSupport.java b/operator/src/test/java/net/onelitefeather/apus/operator/testsupport/K3sCrdSupport.java new file mode 100644 index 0000000..35470e6 --- /dev/null +++ b/operator/src/test/java/net/onelitefeather/apus/operator/testsupport/K3sCrdSupport.java @@ -0,0 +1,77 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.operator.testsupport; + +import io.fabric8.kubernetes.client.KubernetesClient; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import org.junit.jupiter.api.Assertions; + +/** + * Shared helpers for tests that apply Apus's generated CRD manifests to a real Kubernetes API + * server (started via Testcontainers) and wait for the API server to register them. + * + *

Factored out of {@code OperatorIntegrationTest} so a second real-cluster test class (see + * {@code net.onelitefeather.apus.operator.hosting.BlueMapHostingIntegrationTest}) reuses the + * exact same apply/await logic rather than re-implementing it -- both classes read CRD YAML from + * the same {@code apus.crd.dir} system property the {@code operator} module's Gradle build wires + * up (see {@code operator/build.gradle.kts}). + */ +public final class K3sCrdSupport { + + private K3sCrdSupport() {} + + /** + * Applies every generated CRD manifest under {@code apus.crd.dir} (default {@code + * build/crds}) to {@code client} via server-side apply. + */ + public static void applyGeneratedCrds(KubernetesClient client) { + Path crdDir = Path.of(System.getProperty("apus.crd.dir", "build/crds")); + try (var files = Files.list(crdDir)) { + files.filter(path -> path.toString().endsWith(".yml") || path.toString().endsWith(".yaml")) + .forEach(path -> { + try (InputStream in = Files.newInputStream(path)) { + client.load(in).serverSideApply(); + } catch (IOException e) { + throw new UncheckedIOException("failed to apply CRD manifest " + path, e); + } + }); + } catch (IOException e) { + throw new UncheckedIOException("failed to list CRD manifests in " + crdDir, e); + } + } + + /** Polls until {@code crdName} shows up as a registered CustomResourceDefinition, or fails. */ + public static void awaitCrdRegistration(KubernetesClient client, String crdName, Duration timeout) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeout.toMillis(); + boolean known = false; + while (System.currentTimeMillis() < deadline && !known) { + known = client.apiextensions().v1().customResourceDefinitions().list().getItems().stream() + .anyMatch(crd -> crdName.equals(crd.getMetadata().getName())); + if (!known) { + Thread.sleep(1000); + } + } + Assertions.assertTrue(known, crdName + " CRD must be registered on the API server"); + } +}