From 52feb55c3b3a4dc4141e337170606e0b5bc60b96 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 15:12:23 +0200 Subject: [PATCH 01/17] docs: design the Helm charts that replace the planned Kustomize base Two charts in this repository, versioned with the code and published as OCI artifacts to Harbor: apus-operator (CRDs plus controller, the minimum Apus needs) and apus-platform (API and dashboard). runner, ingest and hosting get no chart of their own -- the operator creates them from custom resources. CRDs ship as templates with helm.sh/resource-policy: keep rather than in Helm's crds/ directory, which is never updated on upgrade. --- .../specs/2026-08-13-helm-charts-design.md | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-13-helm-charts-design.md diff --git a/docs/superpowers/specs/2026-08-13-helm-charts-design.md b/docs/superpowers/specs/2026-08-13-helm-charts-design.md new file mode 100644 index 0000000..3ff7951 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-helm-charts-design.md @@ -0,0 +1,326 @@ +# Apus — Helm Charts: Design + +**Stand:** 2026-08-13 +**Status:** Entwurf zur Freigabe + +Apus wird über zwei Helm Charts ausgerollt, die im Apus-Repository leben, gemeinsam mit dem +Code versioniert und als OCI-Artefakte nach Harbor veröffentlicht werden. Sie ersetzen die +Kustomize-Basis, die der Phase-8-Plan bisher vorsah. + +--- + +## 1. Ausgangslage + +Apus hat heute **keine** Deployment-Beschreibung. Der Phase-8-Plan sieht eine Kustomize-Basis +unter `deploy/base` vor; davon ist nichts gebaut. Es gibt also nichts zu migrieren. + +Im Cluster-Repository (`Kubernetes-FLUX`) existieren zwei etablierte Muster nebeneinander: + +- **Eigene Charts** liegen unter `helm/` (`leantime`, `micronaut`, `outline`, `shlink`) + und werden per `HelmRelease` mit `sourceRef: GitRepository helmcharts` referenziert. +- **Fremde Charts** kommen als OCI-Artefakt über `OCIRepository`, etwa der + kube-prometheus-stack von `ghcr.io` mit + `layerSelector.mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip`. + +Das zweite Muster ist der Weg, den Apus geht: Apus ist aus Sicht des Cluster-Repositories +kein hauseigenes Manifest, sondern ein versioniertes Produkt mit eigenem Release-Zyklus. + +Das vorhandene `helm/micronaut`-Chart (v0.5.2) enthält Deployment, Service, Ingress, +HTTPRoute, ConfigMap, Secret, ServiceAccount, RBAC, HPA, PDB und ServiceMonitor. Es dient +als **Vorlage** für Struktur, Label-Konventionen und `values.yaml`-Gliederung — als +Dependency ist es nicht nutzbar, weil es unpubliziert im Cluster-Repository liegt. + +--- + +## 2. Entscheidungen + +| Frage | Entscheidung | Begründung | +| --- | --- | --- | +| Helm oder Kustomize | **Helm ersetzt Kustomize** | Zwei parallele Deployment-Beschreibungen für dieselben Komponenten driften auseinander; das Cluster-Repository arbeitet ohnehin mit Helm | +| Schnitt | **Zwei Charts**: `apus-operator`, `apus-platform` | Die Trennlinie liegt dort, wo sie im Design ohnehin liegt: Der Operator ist der Kern und funktioniert allein (Spec §14, Phase 2 „für interne Nutzung bereits vollständig brauchbar"), API und UI sind die Oberfläche darüber | +| Ort | **Apus-Repository**, `deploy/charts/`, OCI nach Harbor | Chart und Code versionieren gemeinsam; die Kombination Chart↔Image kann nicht auseinanderlaufen | +| CRDs | **Als Templates** mit `helm.sh/resource-policy: keep` | Helms `crds/`-Verzeichnis wird bei `helm upgrade` nie aktualisiert; Apus' CRDs werden generiert und ändern sich mit jeder Phase | +| Mandanten | **Nicht im Chart** | Mandanten sind Betriebsdaten, keine Installationsdaten (Spec §14). Ein `helm uninstall` dürfte sie nicht mitreißen | + +--- + +## 3. Was die Charts ausrollen — und was nicht + +Von den sechs Komponenten installiert Helm nur drei. Das ist keine Lücke, sondern folgt der +Architektur: + +| Komponente | Weg in den Cluster | +| --- | --- | +| `operator` | `apus-operator` — Deployment, cluster-weite RBAC | +| die sechs CRDs | `apus-operator` — Templates mit `resource-policy: keep` | +| `api` | `apus-platform` — Deployment, Service, Ingress, ServiceMonitor | +| `ui` | `apus-platform` — Deployment, Service, Ingress | +| `runner` | **vom Operator erzeugt** aus `BlueMapRender` (Job) | +| `ingest` | **vom Operator erzeugt** aus `WorldIngest` (Job) | +| `hosting` | **vom Operator erzeugt** aus `BlueMapHosting` (Deployment + Service + Ingress) | + +Für die letzten drei reicht Helm nur die Image-Referenz durch — sie erscheinen in +`apus-operator`s `values.yaml` als `images.runner`, `images.ingest`, `images.hosting` und +landen als `APUS_RUNNER_IMAGE`/`APUS_INGEST_IMAGE`/`APUS_HOSTING_IMAGE` im Operator-Deployment. +Ein eigenes Chart für `hosting` wäre fachlich falsch: Es würde einen Webserver anlegen, den +der Operator gleich noch einmal erzeugt. + +--- + +## 4. Chart `apus-operator` + +Das Minimum, mit dem Apus arbeitet. Wer ausschließlich über `kubectl` und Git fährt, +installiert nur dieses Chart. + +```text +deploy/charts/apus-operator/ + Chart.yaml + values.yaml + values.schema.json + .helmignore + README.md + templates/ + _helpers.tpl + crds.yaml # die sechs CRDs, resource-policy: keep + deployment.yaml + serviceaccount.yaml + rbac.yaml # ClusterRole + ClusterRoleBinding + service.yaml # nur der Metrics-Port + servicemonitor.yaml # optional, .Values.metrics.serviceMonitor.enabled + NOTES.txt +``` + +`values.yaml`-Oberfläche, gegliedert nach dem, was ein Betreiber tatsächlich entscheiden muss: + +```yaml +image: + repository: harbor.onelitefeather.dev/apus/operator + tag: "" # leer => .Chart.AppVersion + pullPolicy: IfNotPresent + +# Die Images, die der Operator für die von ihm erzeugten Workloads einsetzt. +# Default ist jeweils dieselbe Version wie der Operator selbst. +images: + runner: + repository: harbor.onelitefeather.dev/apus/runner + tag: "" + ingest: + repository: harbor.onelitefeather.dev/apus/ingest + tag: "" + hosting: + repository: harbor.onelitefeather.dev/apus/hosting + tag: "" + +# Rook/Ceph, aus dem der Operator Buckets und Mandanten-Nutzer bezieht (Spec §9.1). +rook: + namespace: rook-ceph + cephObjectStore: ceph-objectstore + bucketStorageClass: ceph-bucket + +# Der plattformweite Bundle-Bucket (Spec §5) -- Installationsvoraussetzung, kein Inhalt. +bundles: + bucket: apus-bundles + s3Endpoint: "" + s3Region: us-east-1 + credentialsSecret: apus-bundle-credentials + +metrics: + enabled: true + port: 8080 + serviceMonitor: + enabled: false + +resources: {} +nodeSelector: {} +tolerations: [] +affinity: [] +podSecurityContext: {} +securityContext: {} +``` + +**CRDs.** `templates/crds.yaml` entsteht beim Chart-Bau aus `deploy/crds/` (den in Phase 8 +eingecheckten Generator-Ausgaben), jede Ressource mit + +```yaml +metadata: + annotations: + helm.sh/resource-policy: keep +``` + +Damit werden sie bei `helm upgrade` mit aktualisiert, bei `helm uninstall` aber behalten — +sonst würde das Deinstallieren des Charts sämtliche `Tenant`-, `BlueMapMap`- und +`BlueMapHosting`-Ressourcen im Cluster mitlöschen. + +Ein Schalter `crds.install: true` erlaubt es, sie abzuschalten, wenn eine Organisation CRDs +getrennt verwaltet. Der Default ist `true`. + +**RBAC.** Die ClusterRole ist die aus dem Phase-8-Plan (Task 2), unverändert in ihrem Umfang: +eigene Custom Resources samt Status und Finalizern, Namespaces/ResourceQuotas/LimitRanges und +NetworkPolicies für Mandanten, Jobs/Deployments/Services/ConfigMaps/Ingresses für die +erzeugten Workloads, `pods` und `pods/log` lesend für die Fortschrittsermittlung, +`objectbucketclaims` und `cephobjectstoreusers` für Rook, `secrets` **nur lesend**, `events` +schreibend. + +--- + +## 5. Chart `apus-platform` + +REST-API und Dashboard. Setzt ein installiertes `apus-operator` voraus — die CRDs müssen +existieren, bevor die API sie liest. + +```text +deploy/charts/apus-platform/ + Chart.yaml + values.yaml + values.schema.json + .helmignore + README.md + templates/ + _helpers.tpl + api-deployment.yaml + api-service.yaml + api-rbac.yaml + api-servicemonitor.yaml + ui-deployment.yaml + ui-service.yaml + ingress.yaml + NOTES.txt +``` + +Die `api-rbac.yaml` trägt die in Phase 9 verengte Berechtigung: `secrets` nur mit +`resourceNames: ["apus-push-token"]` und `verbs: ["get"]`. Wird Phase 9 noch nicht umgesetzt +sein, wenn dieses Chart entsteht, trägt es die heutige, breitere Regel — mit einem Kommentar, +der auf §15 Punkt 9 verweist, damit die Verengung nicht vergessen wird. + +`values.yaml` deckt zusätzlich zu den üblichen Bild-/Ressourcen-/Ingress-Blöcken den +Identity-Broker ab: + +```yaml +auth: + issuer: "" # Pflichtwert, ohne den die API nicht startet + jwksUri: "" + audience: apus +``` + +`issuer` hat bewusst **keinen** Default: Ein halb konfiguriertes Deployment muss beim Start +scheitern, nicht Token ungeprüft akzeptieren. `values.schema.json` erzwingt das, sodass +`helm install` ohne Issuer mit einer verständlichen Meldung abbricht statt mit einem +CrashLoop. + +--- + +## 6. Versionierung und Veröffentlichung + +Beide Charts werden von Release Please mitversioniert, im Root-Track — dieselbe Version, in +der auch die Images entstehen. `Chart.yaml` bekommt je einen Marker: + +```yaml +version: 0.2.1 # x-release-please-version +appVersion: "0.2.1" # x-release-please-version +``` + +und `release-please-config.json` je einen `extra-files`-Eintrag im Root-Paket. Damit gilt: +`apus-operator-0.3.0` referenziert `apus/operator:0.3.0`, weil `image.tag` leer bleibt und auf +`.Chart.AppVersion` zurückfällt. Die Kombination kann nicht auseinanderlaufen. + +Veröffentlicht wird nach dem Muster der Images, im selben `release-please.yml`, gegated auf +`root-released`: + +```bash +helm package deploy/charts/apus-operator +helm push apus-operator-.tgz oci:///apus/charts +``` + +Ein zentraler wiederverwendbarer Workflow dafür existiert im OLF-Katalog **nicht** — dort gibt +es nur `docker-publish`, `gradle-*`, `markdown-lint`, `pr-lint`, `close-invalid-prs` und +`release-please`. Apus bekommt deshalb zunächst einen repo-eigenen Job. Sobald ein zweites +OLF-Projekt Charts veröffentlicht, gehört er als `helm-publish.yml` in das +`workflows`-Repository; der repo-eigene Job wird dann dagegen ersetzt. + +**Offen und vor dem ersten Chart-Push zu klären:** Der Image-Push nach Harbor scheitert +derzeit mit `empty challenge header` (Registry-Authentifizierung). Solange das ungelöst ist, +wird auch ein Chart-Push scheitern — beide gehen an dieselbe Registry. + +--- + +## 7. Einbindung ins Cluster-Repository + +Nach dem Muster, das dort für den kube-prometheus-stack bereits läuft: + +```yaml +apiVersion: source.toolkit.fluxcd.io/v1 +kind: OCIRepository +metadata: + name: apus-operator + namespace: flux-system +spec: + interval: 5m + layerSelector: + mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip + operation: copy + url: oci://harbor.onelitefeather.dev/apus/charts/apus-operator + ref: + semver: "=0.3.0" +``` + +plus ein `HelmRelease` je Chart unter `apps/base/apus/`. Die cluster-spezifischen Werte +(Registry-Host, Rook-Namen, Hostnamen, Issuer) stehen dort in `values:` — nicht im Chart. +Renovate hält die `semver`-Pins aktuell, wie bei den anderen OCI-Quellen. + +--- + +## 8. Prüfung + +| Ebene | Vorgehen | +| --- | --- | +| Statisch | `helm lint` und `helm template` für beide Charts im PR-Build; das gerenderte Ergebnis durch `kubectl apply --dry-run=client` | +| Schema | `helm template` ohne `auth.issuer` muss **fehlschlagen** — sonst greift `values.schema.json` nicht | +| Werte-Matrix | `helm template` mit Default-Werten, mit allen Schaltern an (`metrics.serviceMonitor`, `ingress`), und mit `crds.install: false` | +| Installation | Der k3s-Integrationstest aus Phase 8 Task 8 installiert künftig das Chart, statt Manifeste einzeln anzuwenden — damit ist der Ausrollweg selbst getestet, nicht nur sein Ergebnis | +| Upgrade | `helm upgrade` von der vorigen Chart-Version auf die aktuelle im selben k3s-Test, um zu belegen, dass die CRDs tatsächlich mit aktualisiert werden | + +Der Upgrade-Test ist der wichtigste Punkt der Tabelle: Er prüft genau die Eigenschaft, wegen +der CRDs als Templates statt im `crds/`-Verzeichnis liegen. + +--- + +## 9. Auswirkung auf den Phase-8-Plan + +`docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md` wird angepasst: + +- **Task 1 (CRDs einchecken)** bleibt unverändert — die Charts konsumieren `deploy/crds/`. +- **Task 2 und 3** (Kustomize-Basis für Operator, API und UI) werden durch die beiden Charts + ersetzt. +- **Task 6 (Scrape-Konfiguration)** verschiebt sich teilweise in die Charts: `ServiceMonitor` + für Operator und API werden Templates. Der `PodMonitor` für die vom Operator erzeugten + Render-Pods bleibt eigenständig, weil er Pods in Mandanten-Namespaces selektiert, die kein + Chart kennt. +- **Task 7 (Dashboards)** bleibt, wandert aber als optionale ConfigMap ins + `apus-platform`-Chart (`dashboards.enabled`). +- **Task 8 (k3s-E2E)** installiert künftig das Chart. +- Die Tasks 4 und 5 (Metriken in Operator und API) sind unberührt. + +--- + +## 10. Nicht-Ziele + +- **Kein Chart für `runner`, `ingest` oder `hosting`.** Sie werden vom Operator erzeugt. +- **Keine Mandanten, Quellen oder Karten im Chart.** Betriebsdaten, nicht Installationsdaten. +- **Kein Umbrella-Chart** über beide. Wer beides will, installiert zwei Releases; ein Umbrella + brächte eine dritte Version, die mit den anderen beiden synchron gehalten werden müsste. +- **Keine Migration.** Es gibt keine bestehende Kustomize-Installation. + +--- + +## 11. Offene Punkte + +1. **Harbor-Authentifizierung.** Der Image-Push scheitert aktuell mit `empty challenge header`; + der Chart-Push geht an dieselbe Registry und wird ohne Lösung ebenso scheitern. Zu klären, + bevor der Publish-Job gebaut wird. +2. **Harbor-Projekt für Charts.** Ob `apus/charts` als Repository-Pfad im bestehenden + Projekt `apus` liegt oder ein eigenes Harbor-Projekt bekommt, ist eine Betriebsentscheidung. +3. **Chart-Publishing im zentralen Katalog.** Zunächst repo-eigener Job; die Aufnahme in + `OneLiteFeatherNET/workflows` steht an, sobald ein zweites Projekt Charts veröffentlicht. +4. **`values.schema.json`-Umfang.** Der Issuer ist als Pflichtfeld gesetzt. Ob weitere Werte + (Rook-Namen, Bundle-Bucket) ebenfalls erzwungen werden sollen, entscheidet sich beim Bauen + an der Frage, ob ein sinnvoller Default existiert. From 1f16d04e67475f423acc2baa81fb68e1033e2e28 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 15:18:03 +0200 Subject: [PATCH 02/17] docs: plan the Helm chart implementation --- .../plans/2026-08-13-helm-charts.md | 1048 +++++++++++++++++ 1 file changed, 1048 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-13-helm-charts.md diff --git a/docs/superpowers/plans/2026-08-13-helm-charts.md b/docs/superpowers/plans/2026-08-13-helm-charts.md new file mode 100644 index 0000000..8e3ab89 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-helm-charts.md @@ -0,0 +1,1048 @@ +# Apus Helm Charts: Implementierungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Apus lässt sich mit zwei `helm install`-Aufrufen ausrollen, und die Chart-Version bestimmt zwingend die Image-Version, die dabei zum Einsatz kommt. + +**Architecture:** Zwei Charts unter `deploy/charts/`: `apus-operator` (die sechs CRDs, der Controller, cluster-weite RBAC) und `apus-platform` (API und Dashboard). Sie werden von Release Please im Root-Track mitversioniert und als OCI-Artefakte nach Harbor veröffentlicht. `runner`, `ingest` und `hosting` bekommen kein Chart — der Operator erzeugt sie aus Custom Resources; Helm reicht nur ihre Image-Referenzen durch. + +**Tech Stack:** Helm 4, Kubernetes, Prometheus Operator (`ServiceMonitor`), GitHub Actions, Release Please. + +## Global Constraints + +- **Das Design steht in `docs/superpowers/specs/2026-08-13-helm-charts-design.md`.** Bei Widersprüchen zwischen diesem Plan und der Spec gilt die Spec; melde den Widerspruch. +- **Vorlage ist `helm/micronaut` im Cluster-Repository** (`OneLiteFeatherNET/Kubernetes-FLUX`, v0.5.2). Struktur, Label-Konventionen und `_helpers.tpl`-Aufbau werden von dort übernommen, damit die Charts sich vertraut anfühlen. Es ist **keine** Dependency — es liegt unpubliziert in einem anderen Repository. +- **Standard-Labels** nach Kubernetes-Konvention: `app.kubernetes.io/name`, `/instance`, `/version`, `/component`, `/part-of: apus`, `/managed-by: {{ .Release.Service }}`. +- **`image.tag` bleibt in allen Charts leer** und fällt auf `.Chart.AppVersion` zurück. Ein fest eingetragener Tag im Chart wäre genau der Drift, den dieses Design verhindern soll. +- **Kein `crds/`-Verzeichnis.** CRDs sind Templates mit `helm.sh/resource-policy: keep`. +- **Keine Mandanten, Quellen oder Karten** in den Charts (Spec §14, Design §10). +- **Non-root:** Java-Container laufen als uid 10001, der nginx-basierte UI-Container als uid 101 — das entspricht den in Phase 7 gebauten Images. +- Conventional Commits, keine Claude/AI-Attribution. +- `helm` (v4.2.2) und `kubectl` sind auf der Maschine verfügbar. + +### Was bereits existiert + +- Sechs Container-Images aus Phase 7: `apus/operator`, `apus/api`, `apus/ui`, `apus/runner`, `apus/ingest`, `apus/hosting`. +- `OperatorConfig` liest: `APUS_ROOK_NAMESPACE`, `APUS_CEPH_OBJECT_STORE`, `APUS_BUCKET_STORAGE_CLASS`, `APUS_RUNNER_IMAGE`, `APUS_INGEST_IMAGE`, `APUS_HOSTING_IMAGE`, `APUS_BUNDLE_BUCKET`, `APUS_BUNDLE_S3_ENDPOINT`, `APUS_BUNDLE_S3_REGION`, `APUS_BUNDLE_CREDENTIALS_SECRET`. +- `.github/workflows/release-please.yml` mit den Outputs `release_created`/`version` (Root, **ohne** Präfix) und `telemetry-addon--release_created`/`paper-worldpush--release_created`. +- **Noch nicht vorhanden:** `deploy/crds/` — das legt Phase 8 Task 1 an. Task 2 dieses Plans erzeugt es notfalls selbst; siehe dort. + +--- + +### Task 1: Gerüst für `apus-operator` + +**Files:** + +- Create: `deploy/charts/apus-operator/Chart.yaml` +- Create: `deploy/charts/apus-operator/values.yaml` +- Create: `deploy/charts/apus-operator/.helmignore` +- Create: `deploy/charts/apus-operator/templates/_helpers.tpl` + +**Interfaces:** + +- Produces: die Helper `apus-operator.name`, `apus-operator.fullname`, `apus-operator.labels`, `apus-operator.selectorLabels`, `apus-operator.serviceAccountName`, `apus-operator.image`. Alle folgenden Tasks dieses Charts benutzen sie. + +- [ ] **Schritt 1: Vorlage lesen** + +```bash +gh api repos/OneLiteFeatherNET/Kubernetes-FLUX/contents/helm/micronaut/templates/_helpers.tpl --jq '.content' | base64 -d +gh api repos/OneLiteFeatherNET/Kubernetes-FLUX/contents/helm/micronaut/.helmignore --jq '.content' | base64 -d +``` + +Übernimm die Struktur, nicht den Inhalt eins zu eins — die Namen tragen `apus-operator` statt `micronaut`. + +- [ ] **Schritt 2: `Chart.yaml`** + +```yaml +apiVersion: v2 +name: apus-operator +description: The Apus operator and its custom resource definitions — renders Minecraft worlds with BlueMap on Kubernetes +type: application +# Both markers are rewritten by release-please in the root track, so the chart +# version and the images it deploys always come from the same release. +version: "0.0.0" # x-release-please-version +appVersion: "0.0.0" # x-release-please-version +home: https://github.com/OneLiteFeatherNET/Apus +sources: + - https://github.com/OneLiteFeatherNET/Apus +maintainers: + - name: OneLiteFeather + url: https://onelitefeather.net +keywords: + - minecraft + - bluemap + - operator +``` + +`"0.0.0"` ist der Bootstrap-Wert; Task 9 setzt ihn auf die aktuelle Release-Version und trägt die Marker in `release-please-config.json` ein. + +- [ ] **Schritt 3: `values.yaml`** + +Genau die Oberfläche aus dem Design, §4: + +```yaml +image: + repository: harbor.onelitefeather.dev/apus/operator + # Empty on purpose: falls back to .Chart.AppVersion so the chart version and the + # image version cannot drift apart. Override only to pin a hotfix image. + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# The images the operator uses for the workloads it creates itself (renders, ingests, +# hosting). They are not deployed by this chart -- the operator builds Jobs and +# Deployments from custom resources and needs to know which image to reference. +images: + runner: + repository: harbor.onelitefeather.dev/apus/runner + tag: "" + ingest: + repository: harbor.onelitefeather.dev/apus/ingest + tag: "" + hosting: + repository: harbor.onelitefeather.dev/apus/hosting + tag: "" + +crds: + # The six CRDs ship as templates so that `helm upgrade` actually updates them. + # Set to false only if your organisation manages CRDs separately. + install: true + +rook: + namespace: rook-ceph + cephObjectStore: ceph-objectstore + bucketStorageClass: ceph-bucket + +bundles: + bucket: apus-bundles + s3Endpoint: "" + s3Region: us-east-1 + credentialsSecret: apus-bundle-credentials + +metrics: + enabled: true + port: 8080 + serviceMonitor: + enabled: false + interval: 30s + labels: {} + +serviceAccount: + create: true + name: "" + annotations: {} + +rbac: + create: true + +replicaCount: 1 + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + +nodeSelector: {} +tolerations: [] +affinity: {} +``` + +- [ ] **Schritt 4: `_helpers.tpl`** + +```gotemplate +{{- define "apus-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "apus-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "apus-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "apus-operator.labels" -}} +helm.sh/chart: {{ include "apus-operator.chart" . }} +{{ include "apus-operator.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: apus +{{- end }} + +{{- define "apus-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "apus-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "apus-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "apus-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Resolves an image reference, defaulting the tag to the chart's appVersion. +Usage: {{ include "apus-operator.image" (dict "image" .Values.image "ctx" .) }} +*/}} +{{- define "apus-operator.image" -}} +{{- $tag := .image.tag | default .ctx.Chart.AppVersion -}} +{{- printf "%s:%s" .image.repository $tag -}} +{{- end }} +``` + +- [ ] **Schritt 5: `helm lint` läuft** + +Run: `helm lint deploy/charts/apus-operator` +Expected: `1 chart(s) linted, 0 chart(s) failed`. Ein Chart ohne Templates ist zulässig; die Warnung über fehlende Templates ist in Ordnung, ein Fehler nicht. + +- [ ] **Schritt 6: Der Image-Helper tut, was er soll** + +```bash +helm template t deploy/charts/apus-operator --show-only templates/_helpers.tpl 2>/dev/null || true +``` + +`_helpers.tpl` rendert nichts Eigenes — die eigentliche Prüfung folgt in Task 3, sobald das Deployment den Helper benutzt. Notiere das im Report, statt einen Scheinbeleg zu konstruieren. + +- [ ] **Schritt 7: Commit** + +```bash +git add deploy/charts/apus-operator +git commit -m "feat(helm): scaffold the apus-operator chart" +``` + +--- + +### Task 2: CRDs als Template + +**Files:** + +- Create: `deploy/charts/apus-operator/templates/crds.yaml` +- Modify: `operator/build.gradle.kts` — nur falls `deploy/crds/` noch nicht existiert, siehe Schritt 1 + +**Interfaces:** + +- Consumes: `deploy/crds/*.yaml`, die generierten CRD-Definitionen. +- Produces: die sechs CRDs als Chart-Ressourcen mit `helm.sh/resource-policy: keep`. + +- [ ] **Schritt 1: Prüfen, ob die CRDs eingecheckt sind** + +Run: `ls deploy/crds/*.yaml 2>/dev/null | wc -l` + +- Ergebnis `6`: weiter mit Schritt 2. +- Ergebnis `0`: Phase 8 Task 1 ist noch nicht gelaufen. Hole das hier nach, aber **nur** den Teil, den dieser Task braucht: + +```bash +./gradlew :operator:generateCrds +mkdir -p deploy/crds +cp operator/build/crds/*.yaml deploy/crds/ +``` + +Vermerke im Report, dass du das getan hast — Phase 8 Task 1 legt zusätzlich den `syncCrds`-Task und `CrdsInSyncTest` an, was hier bewusst **nicht** dupliziert wird. + +- [ ] **Schritt 2: Die tatsächlichen Dateinamen feststellen** + +Run: `ls deploy/crds/` +Expected: sechs Dateien. Notiere die exakten Namen — Schritt 3 listet sie namentlich auf, ohne Glob, damit ein umbenanntes CRD auffällt statt still zu verschwinden. + +- [ ] **Schritt 3: `templates/crds.yaml`** + +```gotemplate +{{- if .Values.crds.install }} +{{- /* +The CRDs ship as templates rather than in Helm's crds/ directory on purpose: Helm +installs that directory once and never touches it again, so `helm upgrade` would +silently leave an old schema in place while the new operator reads fields it does +not know. resource-policy: keep makes uninstall keep them, so removing the chart +does not delete every Tenant, BlueMapMap and BlueMapHosting in the cluster. +*/ -}} +{{- range $path, $_ := .Files.Glob "crds/*.yaml" }} +{{- $crd := $.Files.Get $path | fromYaml }} +--- +{{ $.Files.Get $path | trim }} +{{- end }} +{{- end }} +``` + +**Achtung:** `.Files.Glob` liest nur Dateien **innerhalb** des Chart-Verzeichnisses. `deploy/crds/` liegt außerhalb. Löse das so: + +Der Chart bekommt ein eigenes `crds/`-Verzeichnis (nicht Helms Sonderverzeichnis auf oberster Ebene, sondern ein normales Datenverzeichnis), das beim Bau aus `deploy/crds/` befüllt wird. Da Helm den Namen `crds/` auf Chart-Ebene reserviert, verwende **`files/crds/`**: + +```gotemplate +{{- if .Values.crds.install }} +{{- range $path, $_ := .Files.Glob "files/crds/*.yaml" }} +--- +{{ $.Files.Get $path | trim }} +{{- end }} +{{- end }} +``` + +und ergänze in jeder Datei die Annotation. Weil die generierten CRDs sie nicht mitbringen, patcht ein kleines Skript sie beim Kopieren ein — siehe Schritt 4. + +- [ ] **Schritt 4: Kopier- und Patch-Skript** + +`deploy/charts/apus-operator/sync-crds.sh`: + +```bash +#!/usr/bin/env bash +# Copies the generated CRDs into the chart and annotates them so that `helm uninstall` +# keeps them. Run after ./gradlew :operator:generateCrds whenever a CRD changes. +set -euo pipefail + +root="$(cd "$(dirname "$0")/../../.." && pwd)" +src="$root/deploy/crds" +dst="$(dirname "$0")/files/crds" + +mkdir -p "$dst" +rm -f "$dst"/*.yaml + +for f in "$src"/*.yaml; do + name="$(basename "$f")" + # yq is not a dependency of this repo; the annotation is inserted with awk so the + # script needs nothing beyond coreutils. + awk ' + /^metadata:/ && !done { + print + print " annotations:" + print " helm.sh/resource-policy: keep" + done = 1 + next + } + { print } + ' "$f" > "$dst/$name" +done + +echo "copied $(ls -1 "$dst"/*.yaml | wc -l) CRDs into the chart" +``` + +- [ ] **Schritt 5: Skript ausführen und Ergebnis prüfen** + +Run: `chmod +x deploy/charts/apus-operator/sync-crds.sh && deploy/charts/apus-operator/sync-crds.sh` +Expected: `copied 6 CRDs into the chart` + +Run: `grep -c 'helm.sh/resource-policy: keep' deploy/charts/apus-operator/files/crds/*.yaml` +Expected: jede der sechs Dateien meldet `1`. Meldet eine `0`, hat das `awk`-Muster nicht gegriffen — dann liegt `metadata:` dort nicht am Zeilenanfang, und das Skript muss angepasst werden statt die Datei von Hand zu editieren. + +- [ ] **Schritt 6: Rendern und prüfen** + +Run: `helm template t deploy/charts/apus-operator | grep -c 'kind: CustomResourceDefinition'` +Expected: `6` + +Run: `helm template t deploy/charts/apus-operator --set crds.install=false | grep -c 'kind: CustomResourceDefinition' || echo 0` +Expected: `0` — der Schalter greift. + +Run: `helm template t deploy/charts/apus-operator | kubectl apply --dry-run=client -f - 2>&1 | grep -c 'created (dry run)'` +Expected: mindestens `6` — die gerenderten CRDs sind gültige Kubernetes-Objekte. + +- [ ] **Schritt 7: Commit** + +```bash +git add deploy/charts/apus-operator +git commit -m "feat(helm): ship the CRDs as templates that upgrade cleanly" +``` + +--- + +### Task 3: Operator-Deployment, ServiceAccount und RBAC + +**Files:** + +- Create: `deploy/charts/apus-operator/templates/serviceaccount.yaml` +- Create: `deploy/charts/apus-operator/templates/rbac.yaml` +- Create: `deploy/charts/apus-operator/templates/deployment.yaml` + +**Interfaces:** + +- Consumes: die Helper aus Task 1, die Umgebungsvariablen von `OperatorConfig`. + +- [ ] **Schritt 1: Die tatsächlich benötigten Rechte aus dem Code ableiten** + +Run: `grep -rhoE '\b(Job|Deployment|Service|Ingress|ConfigMap|Secret|Namespace|ResourceQuota|LimitRange|NetworkPolicy|ObjectBucketClaim|CephObjectStoreUser|Pod|Event)\b' operator/src/main/java --include='*.java' | sort -u` + +Jeder Typ in der Ausgabe braucht eine Regel. Fehlt einer, äußert sich das zur Laufzeit als `Forbidden` mitten in einer Reconciliation — nicht beim Start. + +- [ ] **Schritt 2: ServiceAccount** + +```gotemplate +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "apus-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} +``` + +- [ ] **Schritt 3: RBAC** + +`templates/rbac.yaml`, umschlossen von `{{- if .Values.rbac.create }}`. Der Regelsatz ist der aus dem Phase-8-Plan, Task 2, Schritt 2 — übernimm ihn vollständig: eigene Custom Resources samt `/status` und `/finalizers`; `namespaces`, `resourcequotas`, `limitranges`; `networkpolicies`; `jobs`; `deployments`; `services`, `configmaps`; `ingresses`; `pods` und `pods/log` **nur lesend**; `objectbucketclaims`; `cephobjectstoreusers`; `secrets` **nur `get`/`list`/`watch`**; `events` mit `create`/`patch`. + +Namen: `{{ include "apus-operator.fullname" . }}` für ClusterRole und ClusterRoleBinding, damit zwei Releases im selben Cluster nicht kollidieren. + +- [ ] **Schritt 4: Deployment** + +```gotemplate +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "apus-operator.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + # The operator holds a lease-free single-writer position: two instances would + # reconcile the same resources concurrently. Recreate, never RollingUpdate. + type: Recreate + selector: + matchLabels: + {{- include "apus-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "apus-operator.labels" . | nindent 8 }} + {{- with .Values.podLabels }}{{- toYaml . | nindent 8 }}{{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "apus-operator.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: operator + image: {{ include "apus-operator.image" (dict "image" .Values.image "ctx" .) }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + {{- if .Values.metrics.enabled }} + ports: + - name: metrics + containerPort: {{ .Values.metrics.port }} + protocol: TCP + {{- end }} + env: + - name: APUS_ROOK_NAMESPACE + value: {{ .Values.rook.namespace | quote }} + - name: APUS_CEPH_OBJECT_STORE + value: {{ .Values.rook.cephObjectStore | quote }} + - name: APUS_BUCKET_STORAGE_CLASS + value: {{ .Values.rook.bucketStorageClass | quote }} + - name: APUS_RUNNER_IMAGE + value: {{ include "apus-operator.image" (dict "image" .Values.images.runner "ctx" .) | quote }} + - name: APUS_INGEST_IMAGE + value: {{ include "apus-operator.image" (dict "image" .Values.images.ingest "ctx" .) | quote }} + - name: APUS_HOSTING_IMAGE + value: {{ include "apus-operator.image" (dict "image" .Values.images.hosting "ctx" .) | quote }} + - name: APUS_BUNDLE_BUCKET + value: {{ .Values.bundles.bucket | quote }} + - name: APUS_BUNDLE_S3_ENDPOINT + value: {{ .Values.bundles.s3Endpoint | quote }} + - name: APUS_BUNDLE_S3_REGION + value: {{ .Values.bundles.s3Region | quote }} + - name: APUS_BUNDLE_CREDENTIALS_SECRET + value: {{ .Values.bundles.credentialsSecret | quote }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + # readOnlyRootFilesystem is on; the JVM still needs a writable temp dir. + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} +``` + +- [ ] **Schritt 5: Die Umgebungsvariablen gegen `OperatorConfig` gegenprüfen** + +Run: `grep -oE 'APUS_[A-Z_]+' operator/src/main/java/net/onelitefeather/apus/operator/OperatorConfig.java | sort -u` + +Vergleiche mit den zehn Variablen im Deployment. Eine im Code gelesene, im Chart fehlende Variable bekommt stillschweigend ihren Default — genau das soll das Chart verhindern. Eine im Chart gesetzte, im Code unbekannte Variable ist toter Ballast. + +- [ ] **Schritt 6: Rendern und prüfen** + +Run: `helm template t deploy/charts/apus-operator | kubectl apply --dry-run=client -f - 2>&1 | tail -5` +Expected: keine Fehler. + +Run: `helm template t deploy/charts/apus-operator --set image.tag="" | grep 'image:'` +Expected: Alle vier Image-Referenzen tragen die `appVersion` als Tag, nicht `:` allein und nicht `latest`. + +Run: `helm template t deploy/charts/apus-operator --set images.runner.tag=1.2.3 | grep APUS_RUNNER_IMAGE -A1` +Expected: `...apus/runner:1.2.3` — der Override greift, ohne die anderen zu beeinflussen. + +- [ ] **Schritt 7: Commit** + +```bash +git add deploy/charts/apus-operator +git commit -m "feat(helm): deploy the operator with its service account and RBAC" +``` + +--- + +### Task 4: Metrics-Service, ServiceMonitor, NOTES und Schema + +**Files:** + +- Create: `deploy/charts/apus-operator/templates/service.yaml` +- Create: `deploy/charts/apus-operator/templates/servicemonitor.yaml` +- Create: `deploy/charts/apus-operator/templates/NOTES.txt` +- Create: `deploy/charts/apus-operator/values.schema.json` +- Create: `deploy/charts/apus-operator/README.md` + +- [ ] **Schritt 1: Service und ServiceMonitor** + +Beide umschlossen von `{{- if .Values.metrics.enabled }}` bzw. zusätzlich `.Values.metrics.serviceMonitor.enabled`. Der Service ist `ClusterIP` mit dem einen Port `metrics`; der ServiceMonitor selektiert auf `apus-operator.selectorLabels` und scrapt Pfad `/metrics` im Intervall aus den Werten. + +**Hinweis:** Der Operator exportiert seine Metriken erst nach Phase 8 Task 4. Bis dahin liefert der Endpunkt nichts — der ServiceMonitor ist deshalb per Default `false`. Schreibe das in den Kommentar über dem Template, damit niemand ihn einschaltet und sich über leere Panels wundert. + +- [ ] **Schritt 2: `values.schema.json`** + +Erzwinge nur, was ohne sinnvollen Default nicht funktioniert: + +```json +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["image", "images", "rook", "bundles"], + "properties": { + "image": { + "type": "object", + "required": ["repository"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] } + } + }, + "crds": { + "type": "object", + "properties": { "install": { "type": "boolean" } } + }, + "bundles": { + "type": "object", + "required": ["bucket", "s3Endpoint"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "s3Endpoint": { + "type": "string", + "minLength": 1, + "description": "S3 endpoint of the bundle bucket. No default exists -- a wrong or empty endpoint makes every ingest fail at runtime instead of at install time." + } + } + }, + "replicaCount": { "type": "integer", "minimum": 1, "maximum": 1 } + } +} +``` + +`replicaCount` ist auf genau `1` beschränkt: Zwei Operator-Instanzen würden dieselben Ressourcen gleichzeitig reconcilen. + +- [ ] **Schritt 3: Das Schema greift wirklich** + +Run: `helm template t deploy/charts/apus-operator --set bundles.s3Endpoint="" 2>&1 | tail -3` +Expected: FEHLER, der `s3Endpoint` nennt. Läuft es durch, ist das Schema wirkungslos und der Task nicht fertig. + +Run: `helm template t deploy/charts/apus-operator --set replicaCount=2 2>&1 | tail -3` +Expected: FEHLER wegen `maximum`. + +Run: `helm template t deploy/charts/apus-operator --set bundles.s3Endpoint=http://rook-ceph-rgw.rook-ceph.svc >/dev/null && echo OK` +Expected: `OK` + +- [ ] **Schritt 4: `NOTES.txt` und `README.md`** + +`NOTES.txt` sagt nach der Installation, was als Nächstes zu tun ist: dass noch kein Mandant existiert und wie man einen anlegt (`kubectl apply` mit einem Minimal-`Tenant`), und dass `apus-platform` die Oberfläche nachliefert. + +`README.md` dokumentiert die Werte-Tabelle. Erzeuge sie nicht von Hand aus dem Kopf, sondern aus `values.yaml`, damit sie vollständig ist. + +- [ ] **Schritt 5: `helm lint` mit Werten** + +Run: `helm lint deploy/charts/apus-operator --set bundles.s3Endpoint=http://example` +Expected: 0 failed. + +- [ ] **Schritt 6: Commit** + +```bash +git add deploy/charts/apus-operator +git commit -m "feat(helm): add metrics wiring, values schema and operator chart docs" +``` + +--- + +### Task 5: Chart `apus-platform` — Gerüst und API + +**Files:** + +- Create: `deploy/charts/apus-platform/Chart.yaml` +- Create: `deploy/charts/apus-platform/values.yaml` +- Create: `deploy/charts/apus-platform/.helmignore` +- Create: `deploy/charts/apus-platform/templates/_helpers.tpl` +- Create: `deploy/charts/apus-platform/templates/api-deployment.yaml` +- Create: `deploy/charts/apus-platform/templates/api-service.yaml` +- Create: `deploy/charts/apus-platform/templates/api-rbac.yaml` + +**Interfaces:** + +- Produces: Helper analog zu Task 1, aber mit Komponenten-Suffix: `apus-platform.api.fullname`, `apus-platform.ui.fullname`, `apus-platform.labels`, `apus-platform.componentLabels` (nimmt den Komponentennamen als Argument). + +- [ ] **Schritt 1: Gerüst analog zu Task 1** + +`Chart.yaml` wie dort, Name `apus-platform`, Beschreibung „The Apus REST API and dashboard". Beide Versionsmarker mit `"0.0.0"`. + +Die Helper brauchen eine Erweiterung, weil dieses Chart **zwei** Workloads enthält: + +```gotemplate +{{- define "apus-platform.componentLabels" -}} +{{- $ctx := .ctx -}} +helm.sh/chart: {{ include "apus-platform.chart" $ctx }} +app.kubernetes.io/name: {{ include "apus-platform.name" $ctx }} +app.kubernetes.io/instance: {{ $ctx.Release.Name }} +app.kubernetes.io/component: {{ .component }} +app.kubernetes.io/version: {{ $ctx.Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ $ctx.Release.Service }} +app.kubernetes.io/part-of: apus +{{- end }} + +{{- define "apus-platform.componentSelectorLabels" -}} +app.kubernetes.io/name: {{ include "apus-platform.name" .ctx }} +app.kubernetes.io/instance: {{ .ctx.Release.Name }} +app.kubernetes.io/component: {{ .component }} +{{- end }} +``` + +Ohne `component` im Selector würden API- und UI-Deployment einander die Pods wegnehmen — beide hätten denselben Selector. + +- [ ] **Schritt 2: `values.yaml`** + +Zwei Blöcke `api:` und `ui:`, jeweils mit `image`, `replicaCount`, `resources`, `podSecurityContext`, `securityContext`, plus gemeinsam `ingress:` und `auth:`: + +```yaml +auth: + # No default on purpose. The API validates JWTs against this issuer; an empty value + # must fail the install rather than let the API start and accept unvalidated tokens. + issuer: "" + jwksUri: "" + audience: apus + +api: + image: + repository: harbor.onelitefeather.dev/apus/api + tag: "" + pullPolicy: IfNotPresent + replicaCount: 1 + podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + memory: 1Gi + metrics: + serviceMonitor: + enabled: false + +ui: + image: + repository: harbor.onelitefeather.dev/apus/ui + tag: "" + pullPolicy: IfNotPresent + replicaCount: 2 + podSecurityContext: + runAsNonRoot: true + # The unprivileged nginx image runs as uid 101, not 10001 like the Java images. + runAsUser: 101 + seccompProfile: + type: RuntimeDefault + securityContext: + allowPrivilegeEscalation: false + # nginx writes its cache and pid below /tmp and /var/cache; not read-only. + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + +ingress: + enabled: false + className: nginx + annotations: {} + host: "" + tls: + enabled: false + secretName: "" + issuerRef: + name: "" + kind: ClusterIssuer +``` + +- [ ] **Schritt 3: API-Deployment** + +Wie das Operator-Deployment, aber mit `strategy: RollingUpdate` (die API ist zustandslos und darf parallel laufen), Port 8080, den Auth-Umgebungsvariablen und Probes: + +```yaml + readinessProbe: + httpGet: + path: /health/readiness + port: http + initialDelaySeconds: 10 + livenessProbe: + httpGet: + path: /health/liveness + port: http + initialDelaySeconds: 30 +``` + +- [ ] **Schritt 4: Prüfen, dass die Health-Endpunkte existieren** + +Run: `grep -rn 'micronaut-management' api/build.gradle.kts; grep -rn -A3 'endpoints:' api/src/main/resources/application.yml` + +Fehlt `micronaut-management` oder ist `/health` nicht aktiviert, laufen die Probes ins Leere und der Pod wird endlos neu gestartet. Ist das der Fall: Probes **weglassen**, im Report vermerken und auf Phase 8 Task 5 verweisen, der die Abhängigkeit einführt. Rate nicht. + +- [ ] **Schritt 5: API-RBAC** + +ClusterRole mit den Custom Resources und der Secret-Regel. Prüfe zuerst, welche der beiden Fassungen gilt: + +Run: `grep -n 'resolveNamespace' -A20 api/src/main/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepository.java | head -30` + +- Sucht der Code weiterhin per Label über alle Namespaces: breite Regel (`secrets`, `get`/`list`) **mit** Kommentar, der auf Spec §15 Punkt 9 und Phase 9 Task 2 verweist. +- Enumeriert er Tenants und liest ein Secret mit festem Namen: verengte Regel mit `resourceNames: ["apus-push-token"]`, `verbs: ["get"]`. + +- [ ] **Schritt 6: Rendern und prüfen** + +Run: `helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net | kubectl apply --dry-run=client -f - 2>&1 | tail -3` +Expected: keine Fehler. + +Run: `helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net | grep -A3 'matchLabels'` +Expected: Der Selector enthält `app.kubernetes.io/component`. + +- [ ] **Schritt 7: Commit** + +```bash +git add deploy/charts/apus-platform +git commit -m "feat(helm): add the apus-platform chart with the API deployment" +``` + +--- + +### Task 6: UI, Ingress, Schema und Doku für `apus-platform` + +**Files:** + +- Create: `deploy/charts/apus-platform/templates/ui-deployment.yaml` +- Create: `deploy/charts/apus-platform/templates/ui-service.yaml` +- Create: `deploy/charts/apus-platform/templates/api-servicemonitor.yaml` +- Create: `deploy/charts/apus-platform/templates/ingress.yaml` +- Create: `deploy/charts/apus-platform/templates/NOTES.txt` +- Create: `deploy/charts/apus-platform/values.schema.json` +- Create: `deploy/charts/apus-platform/README.md` + +- [ ] **Schritt 1: UI-Deployment und -Service** + +Port 8080 (die unprivilegierte nginx-Basis lauscht dort), `runAsUser: 101`, `readOnlyRootFilesystem: false`. Readiness-Probe auf `/` — die UI ist statisch, ein 200 auf der Wurzel ist ein ausreichendes Signal. + +- [ ] **Schritt 2: Ingress** + +Ein Host, zwei Pfade: `/api` auf den API-Service, `/` auf den UI-Service. `pathType: Prefix`. Reihenfolge beachten — `/api` muss vor `/` stehen, sonst schluckt der Catch-all die API. + +TLS über `cert-manager`, wenn `ingress.tls.enabled`; dann die Annotation `cert-manager.io/cluster-issuer` aus `issuerRef`. + +- [ ] **Schritt 3: `values.schema.json` mit dem Pflicht-Issuer** + +```json +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["auth", "api", "ui"], + "properties": { + "auth": { + "type": "object", + "required": ["issuer"], + "properties": { + "issuer": { + "type": "string", + "minLength": 1, + "format": "uri", + "description": "OIDC issuer the API validates tokens against. Deliberately has no default: an unset issuer must fail the install, never start an API that accepts unvalidated tokens." + } + } + } + } +} +``` + +- [ ] **Schritt 4: Beweisen, dass der Issuer erzwungen wird** + +Run: `helm template t deploy/charts/apus-platform 2>&1 | tail -3` +Expected: FEHLER, der `issuer` nennt. **Läuft das durch, ist der wichtigste Sicherheitsaspekt dieses Charts wirkungslos** — dann ist der Task nicht fertig. + +Run: `helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net >/dev/null && echo OK` +Expected: `OK` + +- [ ] **Schritt 5: Ingress-Reihenfolge prüfen** + +Run: `helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net --set ingress.enabled=true --set ingress.host=apus.example.net | grep -A2 'paths:'` +Expected: `/api` erscheint vor `/`. + +- [ ] **Schritt 6: Commit** + +```bash +git add deploy/charts/apus-platform +git commit -m "feat(helm): add the dashboard, ingress and values schema to apus-platform" +``` + +--- + +### Task 7: Versionierung und Veröffentlichung + +**Files:** + +- Modify: `release-please-config.json` +- Modify: `deploy/charts/apus-operator/Chart.yaml` (Bootstrap-Version) +- Modify: `deploy/charts/apus-platform/Chart.yaml` (Bootstrap-Version) +- Modify: `.github/workflows/release-please.yml` + +**Interfaces:** + +- Consumes: die Outputs `release_created` und `version` des `release-please`-Jobs. **Ohne** `.--`-Präfix — das Root-Paket ist die Ausnahme von der Präfix-Regel. + +- [ ] **Schritt 1: Aktuelle Version feststellen** + +Run: `python3 -c "import json;print(json.load(open('.release-please-manifest.json'))['.'])"` + +Trage diesen Wert als `version` und `appVersion` in beide `Chart.yaml` ein, statt `"0.0.0"` stehen zu lassen — sonst bumpt Release Please von einer Version, die nie existiert hat. + +- [ ] **Schritt 2: `extra-files` ergänzen** + +Im Root-Paket von `release-please-config.json`: + +```json +"extra-files": [ + { "type": "generic", "path": "build.gradle.kts" }, + { "type": "generic", "path": "deploy/charts/apus-operator/Chart.yaml" }, + { "type": "generic", "path": "deploy/charts/apus-platform/Chart.yaml" } +] +``` + +Für das Root-Paket (`.`) werden die Pfade **nicht** mit dem Paketpfad präfixiert; sie gelten repo-relativ. Für die beiden Komponenten-Pakete wäre das anders — hier ist es korrekt so. + +- [ ] **Schritt 3: Publish-Job anhängen** + +```yaml + publish-charts: + needs: [release-please, publish-ui] + # Last link of the publish chain (see the concurrency note above). Charts go to the + # same registry as the images, so they share its serialisation constraint. + if: ${{ !cancelled() && needs.release-please.outputs.root-released == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - uses: azure/setup-helm@v4 + - name: Package and push charts + env: + HARBOR_REGISTRY: ${{ secrets.HARBOR_REGISTRY }} + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + VERSION: ${{ needs.release-please.outputs.root-version }} + run: | + printf '%s' "${HARBOR_PASSWORD}" | \ + helm registry login "${HARBOR_REGISTRY}" -u "${HARBOR_USERNAME}" --password-stdin + for chart in apus-operator apus-platform; do + helm package "deploy/charts/${chart}" + helm push "${chart}-${VERSION}.tgz" "oci://${HARBOR_REGISTRY}/apus/charts" + done +``` + +- [ ] **Schritt 4: YAML und JSON validieren** + +Run: `python3 -c "import yaml,json; yaml.safe_load(open('.github/workflows/release-please.yml')); json.load(open('release-please-config.json')); print('ok')"` +Expected: `ok` + +- [ ] **Schritt 5: Verpacken lokal beweisen** + +Run: `helm package deploy/charts/apus-operator -d /tmp && helm package deploy/charts/apus-platform -d /tmp && ls -la /tmp/apus-*.tgz` +Expected: zwei Archive, deren Dateinamen die Version aus Schritt 1 tragen. + +Run: `helm show chart /tmp/apus-operator-*.tgz | grep -E '^(version|appVersion)'` +Expected: beide gleich der Version aus Schritt 1. + +**Der Push selbst ist hier nicht zu testen.** Die Registry lehnt derzeit auch Image-Pushes ab (`empty challenge header`, siehe Design §11 Punkt 1). Vermerke das im Report; ein fehlgeschlagener Push-Versuch ist kein Fehler dieses Tasks. + +- [ ] **Schritt 6: Commit** + +```bash +git add release-please-config.json deploy/charts .github/workflows/release-please.yml +git commit -m "feat(helm): version the charts with the release and publish them to Harbor" +``` + +--- + +### Task 8: Charts im PR-Build prüfen + +**Files:** + +- Modify: `.github/workflows/build-pr.yml` + +- [ ] **Schritt 1: Job ergänzen** + +```yaml + helm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: azure/setup-helm@v4 + - name: Lint charts + run: | + helm lint deploy/charts/apus-operator --set bundles.s3Endpoint=http://example + helm lint deploy/charts/apus-platform --set auth.issuer=https://id.example.net + - name: Render charts + run: | + helm template t deploy/charts/apus-operator --set bundles.s3Endpoint=http://example > /tmp/operator.yaml + helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net > /tmp/platform.yaml + - name: The values schema actually rejects missing required values + run: | + # A schema that never rejects anything is worse than none: it looks like a guard. + if helm template t deploy/charts/apus-platform >/dev/null 2>&1; then + echo "values.schema.json did not reject a missing auth.issuer" >&2 + exit 1 + fi + if helm template t deploy/charts/apus-operator --set bundles.s3Endpoint="" >/dev/null 2>&1; then + echo "values.schema.json did not reject an empty bundles.s3Endpoint" >&2 + exit 1 + fi + - name: Validate against the Kubernetes API schema + run: | + kubectl apply --dry-run=client -f /tmp/operator.yaml + kubectl apply --dry-run=client -f /tmp/platform.yaml +``` + +- [ ] **Schritt 2: Path-Filter erweitern** + +Der `code`-Filter des Gradle-Jobs bleibt unberührt. Der neue `helm`-Job braucht keinen Filter — er läuft in Sekunden. + +- [ ] **Schritt 3: Die Schema-Gegenprobe lokal nachstellen** + +Run: `helm template t deploy/charts/apus-platform >/dev/null 2>&1; echo "exit=$?"` +Expected: `exit=1` — genau die Bedingung, auf die der CI-Schritt prüft. + +- [ ] **Schritt 4: YAML validieren und committen** + +Run: `python3 -c "import yaml;yaml.safe_load(open('.github/workflows/build-pr.yml'));print('ok')"` + +```bash +git add .github/workflows/build-pr.yml +git commit -m "ci: lint, render and schema-check the Helm charts on pull requests" +``` + +--- + +### Task 9: Phase-8-Plan und Design-Spec nachziehen + +**Files:** + +- Modify: `docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md` +- Modify: `docs/superpowers/specs/2026-08-08-apus-design.md` + +- [ ] **Schritt 1: Phase-8-Plan anpassen** + +Nach Design §9: + +- **Task 1** (CRDs einchecken) bleibt wortgleich — die Charts konsumieren `deploy/crds/`. +- **Task 2 und 3** (Kustomize-Basis für Operator, API, UI) werden ersetzt durch einen Verweis auf `docs/superpowers/plans/2026-08-13-helm-charts.md`. Lösche die Task-Inhalte, ersetze sie durch einen kurzen Absatz, der erklärt, dass Helm den Kustomize-Ansatz abgelöst hat und wo die Arbeit jetzt steht. Nummeriere die verbleibenden Tasks **nicht** um — das würde alle Querverweise brechen. +- **Task 6** (Scrape-Konfiguration): Die beiden `ServiceMonitor` sind jetzt Chart-Templates. Der `PodMonitor` für Render-Pods bleibt als eigenständige Aufgabe, weil er Pods in Mandanten-Namespaces selektiert, die kein Chart kennt. +- **Task 7** (Dashboards): Die ConfigMap wandert als optionale `dashboards.enabled`-Ressource ins `apus-platform`-Chart. +- **Task 8** (k3s-E2E): installiert künftig die Charts statt einzelner Manifeste; ergänze einen Schritt, der `helm upgrade` von der vorigen auf die aktuelle Chart-Version prüft, weil das die Eigenschaft belegt, wegen der die CRDs Templates sind. +- **Global Constraints** des Phase-8-Plans: Der Satz zur Kustomize-Basis wird auf Helm umgeschrieben. + +- [ ] **Schritt 2: Design-Spec §0 ergänzen** + +Ein Absatz, dass Apus über zwei Helm Charts ausgerollt wird, mit Verweis auf +`docs/superpowers/specs/2026-08-13-helm-charts-design.md`. Keine Wiederholung der Details. + +- [ ] **Schritt 3: Markdown-Lint** + +Run: `npx markdownlint-cli2 docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md docs/superpowers/specs/2026-08-08-apus-design.md` +Expected: 0 issues. + +- [ ] **Schritt 4: Commit** + +```bash +git add docs/ +git commit -m "docs: replace the Kustomize tasks in the phase 8 plan with the Helm charts" +``` + +--- + +## Was dieser Plan nicht abdeckt + +- **Die Einbindung ins Cluster-Repository** (`OCIRepository` plus `HelmRelease` unter `apps/base/apus/`). Sie gehört ins Cluster-Repository, nicht hierher, und setzt voraus, dass die Charts einmal veröffentlicht wurden. Design §7 beschreibt das Zielbild. +- **Die Harbor-Authentifizierung.** Der Chart-Push wird scheitern, solange der Image-Push mit `empty challenge header` scheitert. Das ist ein Betriebsproblem, kein Chart-Problem. +- **Ein Umbrella-Chart** über beide — bewusst nicht, siehe Design §10. From 3903e1c5035167ebb7dfece54dca08412e241f6a Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 15:19:48 +0200 Subject: [PATCH 03/17] feat(helm): scaffold the apus-operator chart --- deploy/charts/apus-operator/.helmignore | 23 +++++ deploy/charts/apus-operator/Chart.yaml | 18 ++++ .../apus-operator/templates/_helpers.tpl | 50 +++++++++++ deploy/charts/apus-operator/values.yaml | 84 +++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 deploy/charts/apus-operator/.helmignore create mode 100644 deploy/charts/apus-operator/Chart.yaml create mode 100644 deploy/charts/apus-operator/templates/_helpers.tpl create mode 100644 deploy/charts/apus-operator/values.yaml diff --git a/deploy/charts/apus-operator/.helmignore b/deploy/charts/apus-operator/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/deploy/charts/apus-operator/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deploy/charts/apus-operator/Chart.yaml b/deploy/charts/apus-operator/Chart.yaml new file mode 100644 index 0000000..1747c9d --- /dev/null +++ b/deploy/charts/apus-operator/Chart.yaml @@ -0,0 +1,18 @@ +apiVersion: v2 +name: apus-operator +description: The Apus operator and its custom resource definitions — renders Minecraft worlds with BlueMap on Kubernetes +type: application +# Both markers are rewritten by release-please in the root track, so the chart +# version and the images it deploys always come from the same release. +version: "0.0.0" # x-release-please-version +appVersion: "0.0.0" # x-release-please-version +home: https://github.com/OneLiteFeatherNET/Apus +sources: + - https://github.com/OneLiteFeatherNET/Apus +maintainers: + - name: OneLiteFeather + url: https://onelitefeather.net +keywords: + - minecraft + - bluemap + - operator diff --git a/deploy/charts/apus-operator/templates/_helpers.tpl b/deploy/charts/apus-operator/templates/_helpers.tpl new file mode 100644 index 0000000..0922e4c --- /dev/null +++ b/deploy/charts/apus-operator/templates/_helpers.tpl @@ -0,0 +1,50 @@ +{{- define "apus-operator.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "apus-operator.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "apus-operator.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "apus-operator.labels" -}} +helm.sh/chart: {{ include "apus-operator.chart" . }} +{{ include "apus-operator.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: apus +{{- end }} + +{{- define "apus-operator.selectorLabels" -}} +app.kubernetes.io/name: {{ include "apus-operator.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "apus-operator.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "apus-operator.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Resolves an image reference, defaulting the tag to the chart's appVersion. +Usage: {{ include "apus-operator.image" (dict "image" .Values.image "ctx" .) }} +*/}} +{{- define "apus-operator.image" -}} +{{- $tag := .image.tag | default .ctx.Chart.AppVersion -}} +{{- printf "%s:%s" .image.repository $tag -}} +{{- end }} diff --git a/deploy/charts/apus-operator/values.yaml b/deploy/charts/apus-operator/values.yaml new file mode 100644 index 0000000..6f74533 --- /dev/null +++ b/deploy/charts/apus-operator/values.yaml @@ -0,0 +1,84 @@ +image: + repository: harbor.onelitefeather.dev/apus/operator + # Empty on purpose: falls back to .Chart.AppVersion so the chart version and the + # image version cannot drift apart. Override only to pin a hotfix image. + tag: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# The images the operator uses for the workloads it creates itself (renders, ingests, +# hosting). They are not deployed by this chart -- the operator builds Jobs and +# Deployments from custom resources and needs to know which image to reference. +images: + runner: + repository: harbor.onelitefeather.dev/apus/runner + tag: "" + ingest: + repository: harbor.onelitefeather.dev/apus/ingest + tag: "" + hosting: + repository: harbor.onelitefeather.dev/apus/hosting + tag: "" + +crds: + # The six CRDs ship as templates so that `helm upgrade` actually updates them. + # Set to false only if your organisation manages CRDs separately. + install: true + +rook: + namespace: rook-ceph + cephObjectStore: ceph-objectstore + bucketStorageClass: ceph-bucket + +bundles: + bucket: apus-bundles + s3Endpoint: "" + s3Region: us-east-1 + credentialsSecret: apus-bundle-credentials + +metrics: + enabled: true + port: 8080 + serviceMonitor: + enabled: false + interval: 30s + labels: {} + +serviceAccount: + create: true + name: "" + annotations: {} + +rbac: + create: true + +replicaCount: 1 + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 512Mi + +nodeSelector: {} +tolerations: [] +affinity: {} From eaa0a48fc6bcd0efb7e2aa9bb5c868268a63de66 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 15:26:38 +0200 Subject: [PATCH 04/17] feat(helm): ship the CRDs as templates that upgrade cleanly --- ...ostings.bluemap.onelitefeather.net-v1.yaml | 85 +++++++++++++ ...mapmaps.bluemap.onelitefeather.net-v1.yaml | 117 +++++++++++++++++ ...renders.bluemap.onelitefeather.net-v1.yaml | 79 ++++++++++++ ...tenants.bluemap.onelitefeather.net-v1.yaml | 73 +++++++++++ ...ingests.bluemap.onelitefeather.net-v1.yaml | 86 +++++++++++++ ...sources.bluemap.onelitefeather.net-v1.yaml | 119 ++++++++++++++++++ deploy/charts/apus-operator/sync-crds.sh | 29 +++++ .../charts/apus-operator/templates/crds.yaml | 13 ++ ...ostings.bluemap.onelitefeather.net-v1.yaml | 83 ++++++++++++ ...mapmaps.bluemap.onelitefeather.net-v1.yaml | 115 +++++++++++++++++ ...renders.bluemap.onelitefeather.net-v1.yaml | 77 ++++++++++++ ...tenants.bluemap.onelitefeather.net-v1.yaml | 71 +++++++++++ ...ingests.bluemap.onelitefeather.net-v1.yaml | 84 +++++++++++++ ...sources.bluemap.onelitefeather.net-v1.yaml | 117 +++++++++++++++++ 14 files changed, 1148 insertions(+) create mode 100644 deploy/charts/apus-operator/files/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/charts/apus-operator/files/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/charts/apus-operator/files/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/charts/apus-operator/files/crds/worldingests.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/charts/apus-operator/files/crds/worldsources.bluemap.onelitefeather.net-v1.yaml create mode 100755 deploy/charts/apus-operator/sync-crds.sh create mode 100644 deploy/charts/apus-operator/templates/crds.yaml create mode 100644 deploy/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/crds/tenants.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/crds/worldingests.bluemap.onelitefeather.net-v1.yaml create mode 100644 deploy/crds/worldsources.bluemap.onelitefeather.net-v1.yaml diff --git a/deploy/charts/apus-operator/files/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..f01b6b2 --- /dev/null +++ b/deploy/charts/apus-operator/files/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,85 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + name: bluemaphostings.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: BlueMapHosting + plural: bluemaphostings + shortNames: + - bmhosting + singular: bluemaphosting + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + hostname: + type: string + ingressClassName: + type: string + maps: + items: + properties: + name: + type: string + type: object + type: array + replicas: + type: integer + resources: + properties: + cpu: + type: string + memory: + type: string + type: object + tls: + properties: + enabled: + type: boolean + issuerKind: + type: string + issuerRef: + properties: + name: + type: string + type: object + type: object + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + ready: + type: boolean + url: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/apus-operator/files/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..25e830c --- /dev/null +++ b/deploy/charts/apus-operator/files/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,117 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + name: bluemapmaps.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: BlueMapMap + plural: bluemapmaps + shortNames: + - bmmap + singular: bluemapmap + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + bluemap: + properties: + configOverrides: + additionalProperties: + type: string + type: object + minecraftVersion: + type: string + version: + type: string + type: object + historyLimit: + type: integer + purgeOnDelete: + type: boolean + resources: + properties: + cpu: + type: string + memory: + type: string + type: object + shards: + type: integer + source: + properties: + dimension: + type: string + sourceRef: + properties: + name: + type: string + type: object + world: + type: string + type: object + storage: + properties: + bucketClaim: + type: string + prefix: + type: string + type: object + trigger: + properties: + concurrencyPolicy: + type: string + onNewBundle: + type: boolean + schedule: + type: string + type: object + type: object + status: + properties: + bucket: + properties: + endpoint: + type: string + name: + type: string + secretName: + type: string + type: object + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + latestRender: + properties: + name: + type: string + phase: + type: string + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/apus-operator/files/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..a60eb9e --- /dev/null +++ b/deploy/charts/apus-operator/files/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,79 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + name: bluemaprenders.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: BlueMapRender + plural: bluemaprenders + shortNames: + - bmrender + singular: bluemaprender + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + bundleUrl: + type: string + bundleVersion: + type: string + force: + type: boolean + mapRef: + properties: + name: + type: string + type: object + type: object + status: + properties: + completionTime: + type: string + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + jobName: + type: string + phase: + type: string + progress: + properties: + currentMap: + type: string + degraded: + type: boolean + etaSeconds: + type: integer + percent: + type: number + type: object + startTime: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..9f9e856 --- /dev/null +++ b/deploy/charts/apus-operator/files/crds/tenants.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,73 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + name: tenants.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: Tenant + plural: tenants + shortNames: + - bmtenant + singular: tenant + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + displayName: + type: string + hosting: + properties: + allowedDomains: + items: + type: string + type: array + type: object + storage: + properties: + maxObjects: + type: integer + quota: + type: string + type: object + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + namespace: + type: string + objectStoreUser: + type: string + pushTokenSecret: + type: string + storageUsedBytes: + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/apus-operator/files/crds/worldingests.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/worldingests.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..847609d --- /dev/null +++ b/deploy/charts/apus-operator/files/crds/worldingests.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,86 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + name: worldingests.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: WorldIngest + plural: worldingests + shortNames: + - bmingest + singular: worldingest + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + sourceRef: + properties: + name: + type: string + type: object + sourceVersion: + type: string + worldName: + type: string + type: object + status: + properties: + bundle: + properties: + dimensions: + items: + type: string + type: array + path: + type: string + version: + type: string + type: object + completionTime: + type: string + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + jobName: + type: string + phase: + type: string + progress: + properties: + bytesDone: + type: integer + bytesTotal: + type: integer + percent: + type: number + type: object + startTime: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/apus-operator/files/crds/worldsources.bluemap.onelitefeather.net-v1.yaml b/deploy/charts/apus-operator/files/crds/worldsources.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..2e1da3b --- /dev/null +++ b/deploy/charts/apus-operator/files/crds/worldsources.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,119 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + helm.sh/resource-policy: keep + name: worldsources.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: WorldSource + plural: worldsources + shortNames: + - bmsource + singular: worldsource + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + poll: + type: string + pterodactyl: + properties: + credentialsSecretRef: + properties: + name: + type: string + type: object + panelUrl: + type: string + select: + type: string + serverId: + type: string + type: object + retention: + properties: + keepVersions: + type: integer + type: object + s3: + properties: + bucket: + type: string + credentialsSecretRef: + properties: + name: + type: string + type: object + endpoint: + type: string + prefix: + type: string + type: object + type: + type: string + worlds: + items: + properties: + layout: + type: string + minecraftVersion: + type: string + name: + type: string + type: object + type: array + type: object + status: + properties: + activeIngest: + properties: + name: + type: string + phase: + type: string + type: object + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + lastPollTime: + type: string + lastSeenVersion: + type: string + latestBundle: + properties: + dimensions: + items: + type: string + type: array + path: + type: string + version: + type: string + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/apus-operator/sync-crds.sh b/deploy/charts/apus-operator/sync-crds.sh new file mode 100755 index 0000000..96c6603 --- /dev/null +++ b/deploy/charts/apus-operator/sync-crds.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Copies the generated CRDs into the chart and annotates them so that `helm uninstall` +# keeps them. Run after ./gradlew :operator:generateCrds whenever a CRD changes. +set -euo pipefail + +root="$(cd "$(dirname "$0")/../../.." && pwd)" +src="$root/deploy/crds" +dst="$(dirname "$0")/files/crds" + +mkdir -p "$dst" +rm -f "$dst"/*.yaml + +for f in "$src"/*.yaml; do + name="$(basename "$f")" + # yq is not a dependency of this repo; the annotation is inserted with awk so the + # script needs nothing beyond coreutils. + awk ' + /^metadata:/ && !done { + print + print " annotations:" + print " helm.sh/resource-policy: keep" + done = 1 + next + } + { print } + ' "$f" > "$dst/$name" +done + +echo "copied $(ls -1 "$dst"/*.yaml | wc -l) CRDs into the chart" diff --git a/deploy/charts/apus-operator/templates/crds.yaml b/deploy/charts/apus-operator/templates/crds.yaml new file mode 100644 index 0000000..90e061a --- /dev/null +++ b/deploy/charts/apus-operator/templates/crds.yaml @@ -0,0 +1,13 @@ +{{- if .Values.crds.install }} +{{- /* +The CRDs ship as templates rather than in Helm's crds/ directory on purpose: Helm +installs that directory once and never touches it again, so `helm upgrade` would +silently leave an old schema in place while the new operator reads fields it does +not know. resource-policy: keep makes uninstall keep them, so removing the chart +does not delete every Tenant, BlueMapMap and BlueMapHosting in the cluster. +*/ -}} +{{- range $path, $_ := .Files.Glob "files/crds/*.yaml" }} +--- +{{ $.Files.Get $path | trim }} +{{- end }} +{{- end }} diff --git a/deploy/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml b/deploy/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..3fdd6c0 --- /dev/null +++ b/deploy/crds/bluemaphostings.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,83 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: bluemaphostings.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: BlueMapHosting + plural: bluemaphostings + shortNames: + - bmhosting + singular: bluemaphosting + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + hostname: + type: string + ingressClassName: + type: string + maps: + items: + properties: + name: + type: string + type: object + type: array + replicas: + type: integer + resources: + properties: + cpu: + type: string + memory: + type: string + type: object + tls: + properties: + enabled: + type: boolean + issuerKind: + type: string + issuerRef: + properties: + name: + type: string + type: object + type: object + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + ready: + type: boolean + url: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml b/deploy/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..3b42e32 --- /dev/null +++ b/deploy/crds/bluemapmaps.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,115 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: bluemapmaps.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: BlueMapMap + plural: bluemapmaps + shortNames: + - bmmap + singular: bluemapmap + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + bluemap: + properties: + configOverrides: + additionalProperties: + type: string + type: object + minecraftVersion: + type: string + version: + type: string + type: object + historyLimit: + type: integer + purgeOnDelete: + type: boolean + resources: + properties: + cpu: + type: string + memory: + type: string + type: object + shards: + type: integer + source: + properties: + dimension: + type: string + sourceRef: + properties: + name: + type: string + type: object + world: + type: string + type: object + storage: + properties: + bucketClaim: + type: string + prefix: + type: string + type: object + trigger: + properties: + concurrencyPolicy: + type: string + onNewBundle: + type: boolean + schedule: + type: string + type: object + type: object + status: + properties: + bucket: + properties: + endpoint: + type: string + name: + type: string + secretName: + type: string + type: object + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + latestRender: + properties: + name: + type: string + phase: + type: string + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml b/deploy/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..7b299b3 --- /dev/null +++ b/deploy/crds/bluemaprenders.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,77 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: bluemaprenders.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: BlueMapRender + plural: bluemaprenders + shortNames: + - bmrender + singular: bluemaprender + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + bundleUrl: + type: string + bundleVersion: + type: string + force: + type: boolean + mapRef: + properties: + name: + type: string + type: object + type: object + status: + properties: + completionTime: + type: string + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + jobName: + type: string + phase: + type: string + progress: + properties: + currentMap: + type: string + degraded: + type: boolean + etaSeconds: + type: integer + percent: + type: number + type: object + startTime: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/tenants.bluemap.onelitefeather.net-v1.yaml b/deploy/crds/tenants.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..e0e2fb7 --- /dev/null +++ b/deploy/crds/tenants.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,71 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: tenants.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: Tenant + plural: tenants + shortNames: + - bmtenant + singular: tenant + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + displayName: + type: string + hosting: + properties: + allowedDomains: + items: + type: string + type: array + type: object + storage: + properties: + maxObjects: + type: integer + quota: + type: string + type: object + type: object + status: + properties: + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + namespace: + type: string + objectStoreUser: + type: string + pushTokenSecret: + type: string + storageUsedBytes: + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/worldingests.bluemap.onelitefeather.net-v1.yaml b/deploy/crds/worldingests.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..dd10a99 --- /dev/null +++ b/deploy/crds/worldingests.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,84 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: worldingests.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: WorldIngest + plural: worldingests + shortNames: + - bmingest + singular: worldingest + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + sourceRef: + properties: + name: + type: string + type: object + sourceVersion: + type: string + worldName: + type: string + type: object + status: + properties: + bundle: + properties: + dimensions: + items: + type: string + type: array + path: + type: string + version: + type: string + type: object + completionTime: + type: string + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + jobName: + type: string + phase: + type: string + progress: + properties: + bytesDone: + type: integer + bytesTotal: + type: integer + percent: + type: number + type: object + startTime: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/crds/worldsources.bluemap.onelitefeather.net-v1.yaml b/deploy/crds/worldsources.bluemap.onelitefeather.net-v1.yaml new file mode 100644 index 0000000..5d31e2a --- /dev/null +++ b/deploy/crds/worldsources.bluemap.onelitefeather.net-v1.yaml @@ -0,0 +1,117 @@ +# Generated by Fabric8 CRDGenerator, manual edits might get overwritten! +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: worldsources.bluemap.onelitefeather.net +spec: + group: bluemap.onelitefeather.net + names: + kind: WorldSource + plural: worldsources + shortNames: + - bmsource + singular: worldsource + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + spec: + properties: + poll: + type: string + pterodactyl: + properties: + credentialsSecretRef: + properties: + name: + type: string + type: object + panelUrl: + type: string + select: + type: string + serverId: + type: string + type: object + retention: + properties: + keepVersions: + type: integer + type: object + s3: + properties: + bucket: + type: string + credentialsSecretRef: + properties: + name: + type: string + type: object + endpoint: + type: string + prefix: + type: string + type: object + type: + type: string + worlds: + items: + properties: + layout: + type: string + minecraftVersion: + type: string + name: + type: string + type: object + type: array + type: object + status: + properties: + activeIngest: + properties: + name: + type: string + phase: + type: string + type: object + conditions: + items: + properties: + lastTransitionTime: + type: string + message: + type: string + observedGeneration: + type: integer + reason: + type: string + status: + type: string + type: + type: string + type: object + type: array + lastPollTime: + type: string + lastSeenVersion: + type: string + latestBundle: + properties: + dimensions: + items: + type: string + type: array + path: + type: string + version: + type: string + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} From deccdc2048c464b7d3f0e5a4846a8c54ae2b2438 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 15:35:23 +0200 Subject: [PATCH 05/17] feat(helm): deploy the operator with its service account and RBAC --- .../apus-operator/templates/deployment.yaml | 87 +++++++++++++++++++ .../charts/apus-operator/templates/rbac.yaml | 87 +++++++++++++++++++ .../templates/serviceaccount.yaml | 13 +++ 3 files changed, 187 insertions(+) create mode 100644 deploy/charts/apus-operator/templates/deployment.yaml create mode 100644 deploy/charts/apus-operator/templates/rbac.yaml create mode 100644 deploy/charts/apus-operator/templates/serviceaccount.yaml diff --git a/deploy/charts/apus-operator/templates/deployment.yaml b/deploy/charts/apus-operator/templates/deployment.yaml new file mode 100644 index 0000000..63182eb --- /dev/null +++ b/deploy/charts/apus-operator/templates/deployment.yaml @@ -0,0 +1,87 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "apus-operator.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-operator.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + # The operator holds a lease-free single-writer position: two instances would + # reconcile the same resources concurrently. Recreate, never RollingUpdate. + type: Recreate + selector: + matchLabels: + {{- include "apus-operator.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "apus-operator.labels" . | nindent 8 }} + {{- with .Values.podLabels }}{{- toYaml . | nindent 8 }}{{- end }} + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "apus-operator.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: operator + image: {{ include "apus-operator.image" (dict "image" .Values.image "ctx" .) }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + {{- if .Values.metrics.enabled }} + ports: + - name: metrics + containerPort: {{ .Values.metrics.port }} + protocol: TCP + {{- end }} + env: + - name: APUS_ROOK_NAMESPACE + value: {{ .Values.rook.namespace | quote }} + - name: APUS_CEPH_OBJECT_STORE + value: {{ .Values.rook.cephObjectStore | quote }} + - name: APUS_BUCKET_STORAGE_CLASS + value: {{ .Values.rook.bucketStorageClass | quote }} + - name: APUS_RUNNER_IMAGE + value: {{ include "apus-operator.image" (dict "image" .Values.images.runner "ctx" .) | quote }} + - name: APUS_INGEST_IMAGE + value: {{ include "apus-operator.image" (dict "image" .Values.images.ingest "ctx" .) | quote }} + - name: APUS_HOSTING_IMAGE + value: {{ include "apus-operator.image" (dict "image" .Values.images.hosting "ctx" .) | quote }} + - name: APUS_BUNDLE_BUCKET + value: {{ .Values.bundles.bucket | quote }} + - name: APUS_BUNDLE_S3_ENDPOINT + value: {{ .Values.bundles.s3Endpoint | quote }} + - name: APUS_BUNDLE_S3_REGION + value: {{ .Values.bundles.s3Region | quote }} + - name: APUS_BUNDLE_CREDENTIALS_SECRET + value: {{ .Values.bundles.credentialsSecret | quote }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + # readOnlyRootFilesystem is on; the JVM still needs a writable temp dir. + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/apus-operator/templates/rbac.yaml b/deploy/charts/apus-operator/templates/rbac.yaml new file mode 100644 index 0000000..103a87f --- /dev/null +++ b/deploy/charts/apus-operator/templates/rbac.yaml @@ -0,0 +1,87 @@ +{{- if .Values.rbac.create }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "apus-operator.fullname" . }} + labels: + {{- include "apus-operator.labels" . | nindent 4 }} +rules: + # Own custom resources, including status and finalizers. + - apiGroups: ["bluemap.onelitefeather.net"] + resources: + - tenants + - worldsources + - worldingests + - bluemapmaps + - bluemaprenders + - bluemaphostings + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: + - tenants/status + - worldsources/status + - worldingests/status + - bluemapmaps/status + - bluemaprenders/status + - bluemaphostings/status + verbs: ["get", "update", "patch"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: + - tenants/finalizers + - bluemapmaps/finalizers + verbs: ["update"] + # A Tenant creates a namespace with its quota and network policy (design spec §8.1). + - apiGroups: [""] + resources: ["namespaces", "resourcequotas", "limitranges"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + - apiGroups: ["networking.k8s.io"] + resources: ["networkpolicies"] + verbs: ["get", "list", "watch", "create", "update", "patch"] + # Renders and ingests are Jobs; hosting is a Deployment behind a Service and Ingress. + - apiGroups: ["batch"] + resources: ["jobs"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["services", "configmaps"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # Reading the render pod's /progress endpoint and its termination message (design spec §7.2). + - apiGroups: [""] + resources: ["pods", "pods/log"] + verbs: ["get", "list", "watch"] + # Rook provisions bucket, credentials secret and endpoint ConfigMap (design spec §9.1). + - apiGroups: ["objectbucket.io"] + resources: ["objectbucketclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: ["ceph.rook.io"] + resources: ["cephobjectstoreusers"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # The secrets Rook creates, wired into render jobs and hosting pods. Deliberately not + # cluster-wide write: the operator only ever reads them. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "apus-operator.fullname" . }} + labels: + {{- include "apus-operator.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "apus-operator.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "apus-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/charts/apus-operator/templates/serviceaccount.yaml b/deploy/charts/apus-operator/templates/serviceaccount.yaml new file mode 100644 index 0000000..ff4065d --- /dev/null +++ b/deploy/charts/apus-operator/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "apus-operator.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-operator.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} From d5cee74c832b72cec6fb7700c3f6ac2d5c9cd29c Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 15:42:14 +0200 Subject: [PATCH 06/17] feat(helm): add metrics wiring, values schema and operator chart docs --- deploy/charts/apus-operator/README.md | 86 +++++++++++++++++++ .../charts/apus-operator/templates/NOTES.txt | 42 +++++++++ .../apus-operator/templates/service.yaml | 18 ++++ .../templates/servicemonitor.yaml | 26 ++++++ .../charts/apus-operator/values.schema.json | 33 +++++++ 5 files changed, 205 insertions(+) create mode 100644 deploy/charts/apus-operator/README.md create mode 100644 deploy/charts/apus-operator/templates/NOTES.txt create mode 100644 deploy/charts/apus-operator/templates/service.yaml create mode 100644 deploy/charts/apus-operator/templates/servicemonitor.yaml create mode 100644 deploy/charts/apus-operator/values.schema.json diff --git a/deploy/charts/apus-operator/README.md b/deploy/charts/apus-operator/README.md new file mode 100644 index 0000000..3e5ec60 --- /dev/null +++ b/deploy/charts/apus-operator/README.md @@ -0,0 +1,86 @@ +# apus-operator + +The Apus operator and its custom resource definitions — renders Minecraft worlds with +BlueMap on Kubernetes. + +This chart installs: + +- The six Apus CRDs (`Tenant`, `WorldSource`, `WorldIngest`, `BlueMapMap`, `BlueMapRender`, + `BlueMapHosting`), shipped as templates so `helm upgrade` actually updates their schema. +- The operator `Deployment` (a single, non-scalable replica; see `replicaCount` below). +- Cluster-wide RBAC (`ClusterRole`/`ClusterRoleBinding`) the operator needs to own its + CRDs and to create the Jobs, Deployments, Services and Ingresses that render, ingest + and host worlds. +- Optionally, a metrics `Service` and a Prometheus Operator `ServiceMonitor`. + +It does **not** install a user interface. See the `apus-platform` chart for the REST API +and dashboard. + +## Installing + +```bash +helm install apus-operator deploy/charts/apus-operator \ + --set bundles.s3Endpoint=http://rook-ceph-rgw.rook-ceph.svc +``` + +`bundles.s3Endpoint` has no default and is enforced by `values.schema.json` — see +[Values](#values) below. + +## Values + +The table is derived from [`values.yaml`](./values.yaml); every key defined there is +listed here. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `image.repository` | string | `"harbor.onelitefeather.dev/apus/operator"` | Operator container image repository. | +| `image.tag` | string | `""` | Image tag. Empty on purpose: falls back to `.Chart.AppVersion` so the chart version and the image version cannot drift apart. Override only to pin a hotfix image. | +| `image.pullPolicy` | string | `"IfNotPresent"` | Image pull policy. | +| `imagePullSecrets` | list | `[]` | Secrets used to pull the operator image. | +| `nameOverride` | string | `""` | Overrides `apus-operator.name`. | +| `fullnameOverride` | string | `""` | Overrides `apus-operator.fullname`. | +| `images.runner.repository` | string | `"harbor.onelitefeather.dev/apus/runner"` | Image the operator references when it builds render Jobs. Not deployed by this chart. | +| `images.runner.tag` | string | `""` | Falls back to `.Chart.AppVersion`, same as `image.tag`. | +| `images.ingest.repository` | string | `"harbor.onelitefeather.dev/apus/ingest"` | Image the operator references when it builds ingest Jobs. Not deployed by this chart. | +| `images.ingest.tag` | string | `""` | Falls back to `.Chart.AppVersion`, same as `image.tag`. | +| `images.hosting.repository` | string | `"harbor.onelitefeather.dev/apus/hosting"` | Image the operator references when it builds hosting Deployments. Not deployed by this chart. | +| `images.hosting.tag` | string | `""` | Falls back to `.Chart.AppVersion`, same as `image.tag`. | +| `crds.install` | bool | `true` | Installs the six CRDs as templates. Set to `false` only if your organisation manages CRDs separately. | +| `rook.namespace` | string | `"rook-ceph"` | Namespace of the Rook-Ceph deployment the operator provisions buckets against. | +| `rook.cephObjectStore` | string | `"ceph-objectstore"` | Name of the `CephObjectStore` used for per-tenant buckets. | +| `rook.bucketStorageClass` | string | `"ceph-bucket"` | Storage class used for `ObjectBucketClaim`s the operator creates. | +| `bundles.bucket` | string | `"apus-bundles"` | Bucket that holds render bundles shared across tenants. | +| `bundles.s3Endpoint` | string | `""` | S3 endpoint of the bundle bucket. **Required** — enforced by `values.schema.json`, since a wrong or empty endpoint makes every ingest fail at runtime instead of at install time. | +| `bundles.s3Region` | string | `"us-east-1"` | S3 region of the bundle bucket. | +| `bundles.credentialsSecret` | string | `"apus-bundle-credentials"` | Secret holding the bundle bucket credentials. | +| `metrics.enabled` | bool | `true` | Exposes the operator's metrics port on the Deployment and creates the metrics `Service`. | +| `metrics.port` | int | `8080` | Container and Service port for metrics. | +| `metrics.serviceMonitor.enabled` | bool | `false` | Creates a Prometheus Operator `ServiceMonitor`. Defaults to `false` because the operator does not export metrics yet — that lands in Phase 8 Task 4. Enabling it before then wires Prometheus to an endpoint with no data. | +| `metrics.serviceMonitor.interval` | string | `"30s"` | Scrape interval used by the `ServiceMonitor`. | +| `metrics.serviceMonitor.labels` | object | `{}` | Extra labels added to the `ServiceMonitor`, e.g. to match a Prometheus instance's `serviceMonitorSelector`. | +| `serviceAccount.create` | bool | `true` | Creates a `ServiceAccount` for the operator. | +| `serviceAccount.name` | string | `""` | Name of the `ServiceAccount`. Defaults to `apus-operator.fullname` when empty. | +| `serviceAccount.annotations` | object | `{}` | Annotations added to the `ServiceAccount`. | +| `rbac.create` | bool | `true` | Creates the `ClusterRole` and `ClusterRoleBinding` the operator needs. | +| `replicaCount` | int | `1` | Number of operator replicas. Fixed to exactly `1` by `values.schema.json` — two instances would reconcile the same resources concurrently. | +| `podAnnotations` | object | `{}` | Extra annotations added to the operator pod. | +| `podLabels` | object | `{}` | Extra labels added to the operator pod. | +| `podSecurityContext` | object | `{"runAsNonRoot": true, "runAsUser": 10001, "seccompProfile": {"type": "RuntimeDefault"}}` | Pod-level security context. | +| `securityContext` | object | `{"allowPrivilegeEscalation": false, "readOnlyRootFilesystem": true, "capabilities": {"drop": ["ALL"]}}` | Container-level security context. | +| `resources` | object | `{"requests": {"cpu": "100m", "memory": "256Mi"}, "limits": {"memory": "512Mi"}}` | Resource requests/limits for the operator container. | +| `nodeSelector` | object | `{}` | Node selector for the operator pod. | +| `tolerations` | list | `[]` | Tolerations for the operator pod. | +| `affinity` | object | `{}` | Affinity rules for the operator pod. | + +## Values schema + +`values.schema.json` enforces only what has no sensible default: + +- `bundles.s3Endpoint` must be a non-empty string. +- `replicaCount` must be exactly `1`. +- `image.pullPolicy`, if set, must be `Always`, `IfNotPresent` or `Never`. + +## After installing + +See the post-install notes (`helm install` output, or `helm get notes `) for how +to create your first `Tenant`. diff --git a/deploy/charts/apus-operator/templates/NOTES.txt b/deploy/charts/apus-operator/templates/NOTES.txt new file mode 100644 index 0000000..693e5ec --- /dev/null +++ b/deploy/charts/apus-operator/templates/NOTES.txt @@ -0,0 +1,42 @@ +The apus-operator is installed. + +1. Check that the controller came up: + + kubectl get deployment {{ include "apus-operator.fullname" . }} -n {{ .Release.Namespace }} + +2. No tenant exists yet. The operator does nothing until a Tenant custom resource is + created -- it is what causes a namespace, quota and network policy to be provisioned + (design spec §8.1). Create a minimal one: + + cat < Date: Thu, 13 Aug 2026 15:51:42 +0200 Subject: [PATCH 07/17] feat(helm): add the apus-platform chart with the API deployment --- deploy/charts/apus-platform/.helmignore | 23 +++++ deploy/charts/apus-platform/Chart.yaml | 19 ++++ .../apus-platform/templates/_helpers.tpl | 96 +++++++++++++++++++ .../templates/api-deployment.yaml | 76 +++++++++++++++ .../apus-platform/templates/api-rbac.yaml | 78 +++++++++++++++ .../apus-platform/templates/api-service.yaml | 16 ++++ deploy/charts/apus-platform/values.yaml | 88 +++++++++++++++++ 7 files changed, 396 insertions(+) create mode 100644 deploy/charts/apus-platform/.helmignore create mode 100644 deploy/charts/apus-platform/Chart.yaml create mode 100644 deploy/charts/apus-platform/templates/_helpers.tpl create mode 100644 deploy/charts/apus-platform/templates/api-deployment.yaml create mode 100644 deploy/charts/apus-platform/templates/api-rbac.yaml create mode 100644 deploy/charts/apus-platform/templates/api-service.yaml create mode 100644 deploy/charts/apus-platform/values.yaml diff --git a/deploy/charts/apus-platform/.helmignore b/deploy/charts/apus-platform/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/deploy/charts/apus-platform/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deploy/charts/apus-platform/Chart.yaml b/deploy/charts/apus-platform/Chart.yaml new file mode 100644 index 0000000..a0e6e15 --- /dev/null +++ b/deploy/charts/apus-platform/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +name: apus-platform +description: The Apus REST API and dashboard +type: application +# Both markers are rewritten by release-please in the root track, so the chart +# version and the images it deploys always come from the same release. +version: "0.0.0" # x-release-please-version +appVersion: "0.0.0" # x-release-please-version +home: https://github.com/OneLiteFeatherNET/Apus +sources: + - https://github.com/OneLiteFeatherNET/Apus +maintainers: + - name: OneLiteFeather + url: https://onelitefeather.net +keywords: + - minecraft + - bluemap + - api + - dashboard diff --git a/deploy/charts/apus-platform/templates/_helpers.tpl b/deploy/charts/apus-platform/templates/_helpers.tpl new file mode 100644 index 0000000..7e9ee1b --- /dev/null +++ b/deploy/charts/apus-platform/templates/_helpers.tpl @@ -0,0 +1,96 @@ +{{- define "apus-platform.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "apus-platform.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "apus-platform.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +This chart deploys two workloads (api, ui) from one release, so a plain fullname would +collide between them. Every per-component template goes through this helper instead -- +usage: {{ include "apus-platform.componentFullname" (dict "ctx" . "component" "api") }} +*/}} +{{- define "apus-platform.componentFullname" -}} +{{- $ctx := .ctx -}} +{{- printf "%s-%s" (include "apus-platform.fullname" $ctx) .component | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "apus-platform.api.fullname" -}} +{{- include "apus-platform.componentFullname" (dict "ctx" . "component" "api") }} +{{- end }} + +{{- define "apus-platform.ui.fullname" -}} +{{- include "apus-platform.componentFullname" (dict "ctx" . "component" "ui") }} +{{- end }} + +{{/* +Chart-wide labels, without a component. For resources that span both workloads (for +example a shared Ingress) rather than belonging to just the API or the UI. +*/}} +{{- define "apus-platform.labels" -}} +helm.sh/chart: {{ include "apus-platform.chart" . }} +app.kubernetes.io/name: {{ include "apus-platform.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: apus +{{- end }} + +{{/* +Labels for a single component's resources. Usage: +{{ include "apus-platform.componentLabels" (dict "ctx" . "component" "api") }} +*/}} +{{- define "apus-platform.componentLabels" -}} +{{- $ctx := .ctx -}} +helm.sh/chart: {{ include "apus-platform.chart" $ctx }} +app.kubernetes.io/name: {{ include "apus-platform.name" $ctx }} +app.kubernetes.io/instance: {{ $ctx.Release.Name }} +app.kubernetes.io/component: {{ .component }} +app.kubernetes.io/version: {{ $ctx.Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ $ctx.Release.Service }} +app.kubernetes.io/part-of: apus +{{- end }} + +{{/* +Selector labels for a single component. Deliberately narrower than componentLabels -- +selectors must never change across an upgrade, so this only carries the fields a +Deployment's selector actually needs. Without app.kubernetes.io/component here, the API +and UI Deployments would have identical selectors and steal each other's pods. Usage: +{{ include "apus-platform.componentSelectorLabels" (dict "ctx" . "component" "api") }} +*/}} +{{- define "apus-platform.componentSelectorLabels" -}} +app.kubernetes.io/name: {{ include "apus-platform.name" .ctx }} +app.kubernetes.io/instance: {{ .ctx.Release.Name }} +app.kubernetes.io/component: {{ .component }} +{{- end }} + +{{- define "apus-platform.api.serviceAccountName" -}} +{{- if .Values.api.serviceAccount.create }} +{{- default (include "apus-platform.api.fullname" .) .Values.api.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.api.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Resolves an image reference, defaulting the tag to the chart's appVersion. +Usage: {{ include "apus-platform.image" (dict "image" .Values.api.image "ctx" .) }} +*/}} +{{- define "apus-platform.image" -}} +{{- $tag := .image.tag | default .ctx.Chart.AppVersion -}} +{{- printf "%s:%s" .image.repository $tag -}} +{{- end }} diff --git a/deploy/charts/apus-platform/templates/api-deployment.yaml b/deploy/charts/apus-platform/templates/api-deployment.yaml new file mode 100644 index 0000000..5a8985a --- /dev/null +++ b/deploy/charts/apus-platform/templates/api-deployment.yaml @@ -0,0 +1,76 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "apus-platform.api.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "api") | nindent 4 }} +spec: + replicas: {{ .Values.api.replicaCount }} + strategy: + # The API is stateless -- every instance validates JWTs and talks to the Kubernetes + # API itself, no local state to lose by running old and new pods side by side. + type: RollingUpdate + selector: + matchLabels: + {{- include "apus-platform.componentSelectorLabels" (dict "ctx" . "component" "api") | nindent 6 }} + template: + metadata: + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "api") | nindent 8 }} + {{- with .Values.api.podLabels }}{{- toYaml . | nindent 8 }}{{- end }} + {{- with .Values.api.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "apus-platform.api.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.api.podSecurityContext | nindent 8 }} + containers: + - name: api + image: {{ include "apus-platform.image" (dict "image" .Values.api.image "ctx" .) }} + imagePullPolicy: {{ .Values.api.image.pullPolicy }} + securityContext: + {{- toYaml .Values.api.securityContext | nindent 12 }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + # Maps 1:1 onto application.yml's micronaut.security.token.jwt.claims-validators.issuer + # and .signatures.jwks.apus-issuer.jwks-uri -- there is no default for either (see + # values.yaml's auth.issuer comment). + - name: APUS_JWT_ISSUER + value: {{ .Values.auth.issuer | quote }} + - name: APUS_JWT_JWKS_URI + value: {{ .Values.auth.jwksUri | quote }} + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + # No readiness/liveness probes: api/build.gradle.kts does not depend on + # micronaut-management yet, so /health/readiness and /health/liveness do not exist on + # this classpath -- probing them would just crash-loop the pod. Phase 8 Task 5 adds the + # dependency; add the probes back then. + volumeMounts: + # readOnlyRootFilesystem is on; the JVM still needs a writable temp dir. + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/apus-platform/templates/api-rbac.yaml b/deploy/charts/apus-platform/templates/api-rbac.yaml new file mode 100644 index 0000000..56b4ca0 --- /dev/null +++ b/deploy/charts/apus-platform/templates/api-rbac.yaml @@ -0,0 +1,78 @@ +{{- if .Values.api.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "apus-platform.api.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "api") | nindent 4 }} + {{- with .Values.api.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} +{{- if .Values.api.rbac.create }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "apus-platform.api.fullname" . }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "api") | nindent 4 }} +rules: + # The dashboard's own writes: create tenants and let admins re-save them, create world + # sources and renders through the UI's forms, kick off ingests. Everything else the API + # reads is created and owned by the operator (design spec §8-§11), never by the API. + - apiGroups: ["bluemap.onelitefeather.net"] + resources: ["tenants"] + verbs: ["get", "list", "create", "update"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: ["worldsources"] + verbs: ["get", "list", "create"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: ["worldingests"] + verbs: ["create"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: ["bluemapmaps"] + verbs: ["get", "list"] + # get/watch back the SSE render-progress stream (design spec §11.1); create starts a render. + - apiGroups: ["bluemap.onelitefeather.net"] + resources: ["bluemaprenders"] + verbs: ["get", "list", "watch", "create"] + - apiGroups: ["bluemap.onelitefeather.net"] + resources: ["bluemaphostings"] + verbs: ["get", "list"] + # Fallback log source when no Loki is configured (design spec §11.1): finds a render job's + # pod by its job-name label and tails its log directly. The Loki path avoids this entirely. + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] + # RBAC for the push-token lookup broader than ideal (design spec §15 point 9, tracked as + # phase 9 task 2). FabricPushTokenRepository#resolveNamespace still searches by label across + # every namespace -- RBAC cannot restrict a grant by a Secret's label or content, only by + # resource type/verb/name, so the narrowest grant that makes today's lookup work is still + # get/list on every Secret in the cluster. Narrow this to + # resourceNames: ["apus-push-token"] once phase 9 task 2 switches resolveNamespace to + # enumerating Tenants and getting the fixed-name Secret in each namespace instead. + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "apus-platform.api.fullname" . }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "api") | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "apus-platform.api.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "apus-platform.api.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/deploy/charts/apus-platform/templates/api-service.yaml b/deploy/charts/apus-platform/templates/api-service.yaml new file mode 100644 index 0000000..ae355ef --- /dev/null +++ b/deploy/charts/apus-platform/templates/api-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "apus-platform.api.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "api") | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "apus-platform.componentSelectorLabels" (dict "ctx" . "component" "api") | nindent 4 }} + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP diff --git a/deploy/charts/apus-platform/values.yaml b/deploy/charts/apus-platform/values.yaml new file mode 100644 index 0000000..f197e1f --- /dev/null +++ b/deploy/charts/apus-platform/values.yaml @@ -0,0 +1,88 @@ +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +auth: + # No default on purpose. The API validates JWTs against this issuer; an empty value + # must fail the install rather than let the API start and accept unvalidated tokens. + issuer: "" + jwksUri: "" + # Not wired into the API's environment yet -- audience validation is not implemented + # in application.yml (only issuer and jwks-uri are, see design spec §15 point 3, the + # identity-broker product choice is still open). Declared here so the schema in the + # next task has somewhere to point once it lands. + audience: apus + +api: + image: + repository: harbor.onelitefeather.dev/apus/api + tag: "" + pullPolicy: IfNotPresent + replicaCount: 1 + podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + seccompProfile: + type: RuntimeDefault + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + memory: 1Gi + metrics: + serviceMonitor: + enabled: false + serviceAccount: + create: true + name: "" + annotations: {} + rbac: + create: true + podAnnotations: {} + podLabels: {} + nodeSelector: {} + tolerations: [] + affinity: {} + +ui: + image: + repository: harbor.onelitefeather.dev/apus/ui + tag: "" + pullPolicy: IfNotPresent + replicaCount: 2 + podSecurityContext: + runAsNonRoot: true + # The unprivileged nginx image runs as uid 101, not 10001 like the Java images. + runAsUser: 101 + seccompProfile: + type: RuntimeDefault + securityContext: + allowPrivilegeEscalation: false + # nginx writes its cache and pid below /tmp and /var/cache; not read-only. + readOnlyRootFilesystem: false + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + memory: 128Mi + +ingress: + enabled: false + className: nginx + annotations: {} + host: "" + tls: + enabled: false + secretName: "" + issuerRef: + name: "" + kind: ClusterIssuer From b5396b93712ef05f5f456dfbd23fcfbf1b4fd8fc Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 16:01:41 +0200 Subject: [PATCH 08/17] feat(helm): add the dashboard, ingress and values schema to apus-platform --- deploy/charts/apus-platform/README.md | 106 ++++++++++++++++++ .../charts/apus-platform/templates/NOTES.txt | 41 +++++++ .../templates/api-servicemonitor.yaml | 26 +++++ .../apus-platform/templates/ingress.yaml | 47 ++++++++ .../templates/ui-deployment.yaml | 75 +++++++++++++ .../apus-platform/templates/ui-service.yaml | 16 +++ .../charts/apus-platform/values.schema.json | 19 ++++ deploy/charts/apus-platform/values.yaml | 10 ++ 8 files changed, 340 insertions(+) create mode 100644 deploy/charts/apus-platform/README.md create mode 100644 deploy/charts/apus-platform/templates/NOTES.txt create mode 100644 deploy/charts/apus-platform/templates/api-servicemonitor.yaml create mode 100644 deploy/charts/apus-platform/templates/ingress.yaml create mode 100644 deploy/charts/apus-platform/templates/ui-deployment.yaml create mode 100644 deploy/charts/apus-platform/templates/ui-service.yaml create mode 100644 deploy/charts/apus-platform/values.schema.json diff --git a/deploy/charts/apus-platform/README.md b/deploy/charts/apus-platform/README.md new file mode 100644 index 0000000..5c2e99a --- /dev/null +++ b/deploy/charts/apus-platform/README.md @@ -0,0 +1,106 @@ +# apus-platform + +The Apus REST API and dashboard — lets you manage tenants, worlds and renders through a +web UI or `curl` instead of talking to the Kubernetes API directly. + +This chart installs: + +- The API `Deployment` and `Service`, plus its `ServiceAccount` and cluster-wide RBAC + (`ClusterRole`/`ClusterRoleBinding`) needed to read and write the Apus custom resources + (`Tenant`, `WorldSource`, `WorldIngest`, `BlueMapMap`, `BlueMapRender`, `BlueMapHosting`) + and to tail render-job Pod logs as a Loki-less fallback. +- The UI `Deployment` and `Service` — a prebuilt static SPA served by an unprivileged nginx. +- Optionally, a single `Ingress` that routes `/api` to the API and `/` to the UI. +- Optionally, a Prometheus Operator `ServiceMonitor` for the API. + +It assumes an installed `apus-operator`: the CRDs the API reads must already exist. See +the `apus-operator` chart for those. + +## Installing + +```bash +helm install apus-platform deploy/charts/apus-platform \ + --set auth.issuer=https://id.example.net +``` + +`auth.issuer` has no default and is enforced by `values.schema.json` — see +[Values schema](#values-schema) below. The API validates every JWT against this issuer; an +unset issuer must fail the install, not start an API that accepts unvalidated tokens. + +To expose both workloads through a single host: + +```bash +helm install apus-platform deploy/charts/apus-platform \ + --set auth.issuer=https://id.example.net \ + --set ingress.enabled=true \ + --set ingress.host=apus.example.net \ + --set ingress.tls.enabled=true \ + --set ingress.tls.issuerRef.name=letsencrypt-prod +``` + +## Values + +The table is derived from [`values.yaml`](./values.yaml); every key defined there is +listed here. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `imagePullSecrets` | list | `[]` | Secrets used to pull the API and UI images. | +| `nameOverride` | string | `""` | Overrides `apus-platform.name`. | +| `fullnameOverride` | string | `""` | Overrides `apus-platform.fullname`. | +| `auth.issuer` | string | `""` | OIDC issuer the API validates tokens against. **Required** — enforced by `values.schema.json`, since an unset issuer would let the API start and accept unvalidated tokens. | +| `auth.jwksUri` | string | `""` | JWKS URI the API fetches signing keys from. | +| `auth.audience` | string | `"apus"` | Not wired into the API's environment yet — audience validation is not implemented in `application.yml` (only issuer and JWKS URI are). Declared here so a schema addition has somewhere to point once it lands. | +| `api.image.repository` | string | `"harbor.onelitefeather.dev/apus/api"` | API container image repository. | +| `api.image.tag` | string | `""` | Image tag. Empty on purpose: falls back to `.Chart.AppVersion` so the chart version and the image version cannot drift apart. | +| `api.image.pullPolicy` | string | `"IfNotPresent"` | Image pull policy. | +| `api.replicaCount` | int | `1` | Number of API replicas. The API is stateless, safe to scale. | +| `api.podSecurityContext` | object | `{"runAsNonRoot": true, "runAsUser": 10001, "seccompProfile": {"type": "RuntimeDefault"}}` | Pod-level security context for the API. | +| `api.securityContext` | object | `{"allowPrivilegeEscalation": false, "readOnlyRootFilesystem": true, "capabilities": {"drop": ["ALL"]}}` | Container-level security context for the API. | +| `api.resources` | object | `{"requests": {"cpu": "200m", "memory": "512Mi"}, "limits": {"memory": "1Gi"}}` | Resource requests/limits for the API container. | +| `api.metrics.serviceMonitor.enabled` | bool | `false` | Creates a Prometheus Operator `ServiceMonitor` for the API. Defaults to `false` because the API does not export metrics yet — that lands in Phase 8 Task 5. Enabling it before then wires Prometheus to a 404. | +| `api.metrics.serviceMonitor.interval` | string | `"30s"` | Scrape interval used by the API `ServiceMonitor`. | +| `api.metrics.serviceMonitor.labels` | object | `{}` | Extra labels added to the API `ServiceMonitor`, e.g. to match a Prometheus instance's `serviceMonitorSelector`. | +| `api.serviceAccount.create` | bool | `true` | Creates a `ServiceAccount` for the API. | +| `api.serviceAccount.name` | string | `""` | Name of the API `ServiceAccount`. Defaults to `apus-platform.api.fullname` when empty. | +| `api.serviceAccount.annotations` | object | `{}` | Annotations added to the API `ServiceAccount`. | +| `api.rbac.create` | bool | `true` | Creates the `ClusterRole` and `ClusterRoleBinding` the API needs. | +| `api.podAnnotations` | object | `{}` | Extra annotations added to the API pod. | +| `api.podLabels` | object | `{}` | Extra labels added to the API pod. | +| `api.nodeSelector` | object | `{}` | Node selector for the API pod. | +| `api.tolerations` | list | `[]` | Tolerations for the API pod. | +| `api.affinity` | object | `{}` | Affinity rules for the API pod. | +| `ui.image.repository` | string | `"harbor.onelitefeather.dev/apus/ui"` | UI container image repository. | +| `ui.image.tag` | string | `""` | Image tag. Empty on purpose, same reasoning as `api.image.tag`. | +| `ui.image.pullPolicy` | string | `"IfNotPresent"` | Image pull policy. | +| `ui.replicaCount` | int | `2` | Number of UI replicas. The UI is a stateless static SPA, safe to scale. | +| `ui.podSecurityContext` | object | `{"runAsNonRoot": true, "runAsUser": 101, "seccompProfile": {"type": "RuntimeDefault"}}` | Pod-level security context for the UI. `runAsUser: 101`, not `10001` like the Java images — the unprivileged nginx base image runs as uid 101. | +| `ui.securityContext` | object | `{"allowPrivilegeEscalation": false, "readOnlyRootFilesystem": false, "capabilities": {"drop": ["ALL"]}}` | Container-level security context for the UI. `readOnlyRootFilesystem: false` — nginx writes its cache and pid below `/tmp` and `/var/cache`. | +| `ui.resources` | object | `{"requests": {"cpu": "50m", "memory": "64Mi"}, "limits": {"memory": "128Mi"}}` | Resource requests/limits for the UI container. | +| `ui.podAnnotations` | object | `{}` | Extra annotations added to the UI pod. | +| `ui.podLabels` | object | `{}` | Extra labels added to the UI pod. | +| `ui.nodeSelector` | object | `{}` | Node selector for the UI pod. | +| `ui.tolerations` | list | `[]` | Tolerations for the UI pod. | +| `ui.affinity` | object | `{}` | Affinity rules for the UI pod. | +| `ingress.enabled` | bool | `false` | Creates a single `Ingress` routing `/api` to the API and `/` to the UI. | +| `ingress.className` | string | `"nginx"` | `ingressClassName` on the `Ingress`. | +| `ingress.annotations` | object | `{}` | Extra annotations added to the `Ingress`. | +| `ingress.host` | string | `""` | Hostname the `Ingress` routes. Required when `ingress.enabled` is `true` (not enforced by the schema — the chart still renders without it, just with an empty host). | +| `ingress.tls.enabled` | bool | `false` | Enables TLS on the `Ingress` via cert-manager: adds the `cert-manager.io/cluster-issuer` annotation from `ingress.tls.issuerRef.name` and a `tls:` block. | +| `ingress.tls.secretName` | string | `""` | Secret cert-manager writes the certificate to. Defaults to `-tls` when empty. | +| `ingress.tls.issuerRef.name` | string | `""` | Name of the cert-manager `ClusterIssuer` (or `Issuer`) to request certificates from. | +| `ingress.tls.issuerRef.kind` | string | `"ClusterIssuer"` | Declared for completeness; the chart always emits the `cert-manager.io/cluster-issuer` annotation regardless of this value — set a namespaced `Issuer` up via `ingress.annotations` instead if you need one. | + +## Values schema + +`values.schema.json` enforces only what has no sensible default: + +- `auth.issuer` must be a non-empty, valid URI. + +Everything else (image repositories, resource sizes, replica counts, ingress host, …) has a +working default and is left unenforced. + +## After installing + +See the post-install notes (`helm install` output, or `helm get notes `) for how +to check both workloads came up and how to reach the dashboard. diff --git a/deploy/charts/apus-platform/templates/NOTES.txt b/deploy/charts/apus-platform/templates/NOTES.txt new file mode 100644 index 0000000..605c83b --- /dev/null +++ b/deploy/charts/apus-platform/templates/NOTES.txt @@ -0,0 +1,41 @@ +The apus-platform chart (REST API and dashboard) is installed. + +1. Check that both workloads came up: + + kubectl get deployment {{ include "apus-platform.api.fullname" . }} -n {{ .Release.Namespace }} + kubectl get deployment {{ include "apus-platform.ui.fullname" . }} -n {{ .Release.Namespace }} + +{{- if .Values.ingress.enabled }} + +2. The dashboard and API are reachable through the ingress: + + http{{ if .Values.ingress.tls.enabled }}s{{ end }}://{{ .Values.ingress.host }}/ (dashboard) + http{{ if .Values.ingress.tls.enabled }}s{{ end }}://{{ .Values.ingress.host }}/api (API) +{{- else }} + +2. ingress.enabled is false -- reach the workloads from inside the cluster only: + + kubectl port-forward svc/{{ include "apus-platform.ui.fullname" . }} 8080:80 -n {{ .Release.Namespace }} + kubectl port-forward svc/{{ include "apus-platform.api.fullname" . }} 8081:80 -n {{ .Release.Namespace }} +{{- end }} + +3. This chart assumes an installed apus-operator: the CRDs the API reads (Tenant, + WorldSource, WorldIngest, BlueMapMap, BlueMapRender, BlueMapHosting) must already exist, + or every dashboard page that lists them will error. + +4. The API validates every token against auth.issuer ({{ .Values.auth.issuer }}). If your + identity broker's issuer or JWKS URI changes, upgrade with the new auth.issuer/ + auth.jwksUri -- there is no default, so a stale value fails closed rather than silently. + +{{- if .Values.api.metrics.serviceMonitor.enabled }} + +5. api.metrics.serviceMonitor.enabled is set, but the API does not export metrics yet (that + lands in Phase 8 Task 5). The ServiceMonitor is installed, but /prometheus does not exist + on this image yet, so Prometheus will only see failed scrapes until that phase ships. +{{- else }} + +5. Metrics are not scraped (api.metrics.serviceMonitor.enabled is false by default) -- the + API does not export Prometheus metrics yet, so there is nothing to scrape until Phase 8 + Task 5 ships. Once it does, /prometheus is Basic-Auth protected; a ServiceMonitor turned + on before that endpoint carries credentials will fail to scrape. +{{- end }} diff --git a/deploy/charts/apus-platform/templates/api-servicemonitor.yaml b/deploy/charts/apus-platform/templates/api-servicemonitor.yaml new file mode 100644 index 0000000..5002dbe --- /dev/null +++ b/deploy/charts/apus-platform/templates/api-servicemonitor.yaml @@ -0,0 +1,26 @@ +{{- /* +The API does not export Prometheus metrics yet -- Phase 8 Task 5 adds micronaut-management +and micronaut-micrometer, exposing /prometheus on the same port as the app. Until then that +path 404s, so api.metrics.serviceMonitor.enabled defaults to false; turning it on before then +just wires Prometheus up to a 404, leaving empty Grafana panels with no indication why. +*/ -}} +{{- if .Values.api.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "apus-platform.api.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "api") | nindent 4 }} + {{- with .Values.api.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: + {{- include "apus-platform.componentSelectorLabels" (dict "ctx" . "component" "api") | nindent 6 }} + endpoints: + - port: http + path: /prometheus + interval: {{ .Values.api.metrics.serviceMonitor.interval }} +{{- end }} diff --git a/deploy/charts/apus-platform/templates/ingress.yaml b/deploy/charts/apus-platform/templates/ingress.yaml new file mode 100644 index 0000000..b85288a --- /dev/null +++ b/deploy/charts/apus-platform/templates/ingress.yaml @@ -0,0 +1,47 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "apus-platform.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-platform.labels" . | nindent 4 }} + {{- if or .Values.ingress.tls.enabled .Values.ingress.annotations }} + annotations: + {{- if .Values.ingress.tls.enabled }} + cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.issuerRef.name | quote }} + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host | quote }} + secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" (include "apus-platform.fullname" .)) }} + {{- end }} + rules: + - host: {{ .Values.ingress.host | quote }} + http: + paths: + # /api must come before / -- ingress-nginx (and most controllers) evaluate paths + # in the order they appear for a given host, so a catch-all "/" listed first would + # swallow every API request and the UI's own index.html would answer them instead. + - path: /api + pathType: Prefix + backend: + service: + name: {{ include "apus-platform.api.fullname" . }} + port: + name: http + - path: / + pathType: Prefix + backend: + service: + name: {{ include "apus-platform.ui.fullname" . }} + port: + name: http +{{- end }} diff --git a/deploy/charts/apus-platform/templates/ui-deployment.yaml b/deploy/charts/apus-platform/templates/ui-deployment.yaml new file mode 100644 index 0000000..5d9b437 --- /dev/null +++ b/deploy/charts/apus-platform/templates/ui-deployment.yaml @@ -0,0 +1,75 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "apus-platform.ui.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "ui") | nindent 4 }} +spec: + replicas: {{ .Values.ui.replicaCount }} + strategy: + # The UI is a stateless static SPA served by nginx -- no local state to lose by running + # old and new pods side by side. + type: RollingUpdate + selector: + matchLabels: + {{- include "apus-platform.componentSelectorLabels" (dict "ctx" . "component" "ui") | nindent 6 }} + template: + metadata: + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "ui") | nindent 8 }} + {{- with .Values.ui.podLabels }}{{- toYaml . | nindent 8 }}{{- end }} + {{- with .Values.ui.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.ui.podSecurityContext | nindent 8 }} + containers: + - name: ui + image: {{ include "apus-platform.image" (dict "image" .Values.ui.image "ctx" .) }} + imagePullPolicy: {{ .Values.ui.image.pullPolicy }} + securityContext: + {{- toYaml .Values.ui.securityContext | nindent 12 }} + ports: + # The unprivileged nginx base image (nginxinc/nginx-unprivileged) listens on + # 8080 and runs as uid 101, not 80/root like the stock image. + - name: http + containerPort: 8080 + protocol: TCP + # The UI is a prebuilt static SPA: config (API base URL, OIDC issuer/client) is + # baked in at build time, not read from the environment at runtime -- there is + # nothing to inject here. + readinessProbe: + # Static SPA: a 200 on the root is a sufficient signal, no management endpoint + # needed like the API. + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + {{- toYaml .Values.ui.resources | nindent 12 }} + {{- with .Values.ui.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.ui.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/apus-platform/templates/ui-service.yaml b/deploy/charts/apus-platform/templates/ui-service.yaml new file mode 100644 index 0000000..03f76f1 --- /dev/null +++ b/deploy/charts/apus-platform/templates/ui-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "apus-platform.ui.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "apus-platform.componentLabels" (dict "ctx" . "component" "ui") | nindent 4 }} +spec: + type: ClusterIP + selector: + {{- include "apus-platform.componentSelectorLabels" (dict "ctx" . "component" "ui") | nindent 4 }} + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP diff --git a/deploy/charts/apus-platform/values.schema.json b/deploy/charts/apus-platform/values.schema.json new file mode 100644 index 0000000..c40d9f7 --- /dev/null +++ b/deploy/charts/apus-platform/values.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["auth", "api", "ui"], + "properties": { + "auth": { + "type": "object", + "required": ["issuer"], + "properties": { + "issuer": { + "type": "string", + "minLength": 1, + "format": "uri", + "description": "OIDC issuer the API validates tokens against. Deliberately has no default: an unset issuer must fail the install, never start an API that accepts unvalidated tokens." + } + } + } + } +} diff --git a/deploy/charts/apus-platform/values.yaml b/deploy/charts/apus-platform/values.yaml index f197e1f..b07e1d7 100644 --- a/deploy/charts/apus-platform/values.yaml +++ b/deploy/charts/apus-platform/values.yaml @@ -37,7 +37,12 @@ api: memory: 1Gi metrics: serviceMonitor: + # The API does not export Prometheus metrics yet (Phase 8 Task 5 adds + # micronaut-management and micrometer, exposing /prometheus). Off by default: turning + # this on before then just wires Prometheus up to a 404, not empty data. enabled: false + interval: 30s + labels: {} serviceAccount: create: true name: "" @@ -74,6 +79,11 @@ ui: memory: 64Mi limits: memory: 128Mi + podAnnotations: {} + podLabels: {} + nodeSelector: {} + tolerations: [] + affinity: {} ingress: enabled: false From aecbdc74adf37ae933acf0eb743c21bfdd130e69 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 16:08:17 +0200 Subject: [PATCH 09/17] feat(helm): version the charts with the release and publish them to Harbor --- .github/workflows/release-please.yml | 25 +++++++++++++++++++++++++ deploy/charts/apus-operator/Chart.yaml | 4 ++-- deploy/charts/apus-platform/Chart.yaml | 4 ++-- release-please-config.json | 4 +++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b6dbc51..1e6c3eb 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -151,6 +151,31 @@ jobs: dockerfile: "ui/Dockerfile" secrets: inherit + publish-charts: + needs: [release-please, publish-ui] + # Last link of the publish chain (see the concurrency note above). Charts go to the + # same registry as the images, so they share its serialisation constraint. + if: ${{ !cancelled() && needs.release-please.outputs.root-released == 'true' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - uses: azure/setup-helm@v4 + - name: Package and push charts + env: + HARBOR_REGISTRY: ${{ secrets.HARBOR_REGISTRY }} + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + VERSION: ${{ needs.release-please.outputs.root-version }} + run: | + printf '%s' "${HARBOR_PASSWORD}" | \ + helm registry login "${HARBOR_REGISTRY}" -u "${HARBOR_USERNAME}" --password-stdin + for chart in apus-operator apus-platform; do + helm package "deploy/charts/${chart}" + helm push "${chart}-${VERSION}.tgz" "oci://${HARBOR_REGISTRY}/apus/charts" + done + publish-telemetry-addon: needs: release-please if: needs.release-please.outputs.telemetry-released == 'true' diff --git a/deploy/charts/apus-operator/Chart.yaml b/deploy/charts/apus-operator/Chart.yaml index 1747c9d..3f59909 100644 --- a/deploy/charts/apus-operator/Chart.yaml +++ b/deploy/charts/apus-operator/Chart.yaml @@ -4,8 +4,8 @@ description: The Apus operator and its custom resource definitions — renders M type: application # Both markers are rewritten by release-please in the root track, so the chart # version and the images it deploys always come from the same release. -version: "0.0.0" # x-release-please-version -appVersion: "0.0.0" # x-release-please-version +version: "0.2.1" # x-release-please-version +appVersion: "0.2.1" # x-release-please-version home: https://github.com/OneLiteFeatherNET/Apus sources: - https://github.com/OneLiteFeatherNET/Apus diff --git a/deploy/charts/apus-platform/Chart.yaml b/deploy/charts/apus-platform/Chart.yaml index a0e6e15..7b64f49 100644 --- a/deploy/charts/apus-platform/Chart.yaml +++ b/deploy/charts/apus-platform/Chart.yaml @@ -4,8 +4,8 @@ description: The Apus REST API and dashboard type: application # Both markers are rewritten by release-please in the root track, so the chart # version and the images it deploys always come from the same release. -version: "0.0.0" # x-release-please-version -appVersion: "0.0.0" # x-release-please-version +version: "0.2.1" # x-release-please-version +appVersion: "0.2.1" # x-release-please-version home: https://github.com/OneLiteFeatherNET/Apus sources: - https://github.com/OneLiteFeatherNET/Apus diff --git a/release-please-config.json b/release-please-config.json index 6b7dbb4..039a753 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -11,7 +11,9 @@ "package-name": "apus", "changelog-path": "CHANGELOG.md", "extra-files": [ - { "type": "generic", "path": "build.gradle.kts" } + { "type": "generic", "path": "build.gradle.kts" }, + { "type": "generic", "path": "deploy/charts/apus-operator/Chart.yaml" }, + { "type": "generic", "path": "deploy/charts/apus-platform/Chart.yaml" } ] }, "telemetry-addon": { From 458646db6ef2d33b6840f70bc218e734ecf85b65 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 16:14:58 +0200 Subject: [PATCH 10/17] ci: lint, render and schema-check the Helm charts on pull requests --- .github/workflows/build-pr.yml | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 876912e..60eef01 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -53,3 +53,44 @@ jobs: - run: pnpm lint - run: pnpm typecheck - run: pnpm test + + helm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: azure/setup-helm@v4 + - name: Lint charts + run: | + helm lint deploy/charts/apus-operator --set bundles.s3Endpoint=http://example + helm lint deploy/charts/apus-platform --set auth.issuer=https://id.example.net + - name: Render charts + run: | + helm template t deploy/charts/apus-operator --set bundles.s3Endpoint=http://example > /tmp/operator.yaml + helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net > /tmp/platform.yaml + - name: The values schema actually rejects missing required values + run: | + # A schema that never rejects anything is worse than none: it looks like a guard. + # A `helm template` that fails for an unrelated reason would also pass a bare + # exit-code check, so grep the error for the field the schema is supposed to guard. + if error=$(helm template t deploy/charts/apus-platform 2>&1 >/dev/null); then + echo "values.schema.json did not reject a missing auth.issuer" >&2 + exit 1 + fi + if ! grep -q '/auth/issuer' <<<"$error"; then + echo "helm template failed, but not because of auth.issuer:" >&2 + echo "$error" >&2 + exit 1 + fi + if error=$(helm template t deploy/charts/apus-operator --set bundles.s3Endpoint="" 2>&1 >/dev/null); then + echo "values.schema.json did not reject an empty bundles.s3Endpoint" >&2 + exit 1 + fi + if ! grep -q '/bundles/s3Endpoint' <<<"$error"; then + echo "helm template failed, but not because of bundles.s3Endpoint:" >&2 + echo "$error" >&2 + exit 1 + fi + - name: Validate against the Kubernetes API schema + run: | + kubectl apply --dry-run=client -f /tmp/operator.yaml + kubectl apply --dry-run=client -f /tmp/platform.yaml From 1c74fa40faffa746b62d5a005a73faf00792f192 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 16:20:45 +0200 Subject: [PATCH 11/17] ci: pin azure/setup-helm to v4.2.2 for stable schema error format --- .github/workflows/build-pr.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 60eef01..59aada5 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -59,6 +59,13 @@ jobs: steps: - uses: actions/checkout@v5 - uses: azure/setup-helm@v4 + with: + # Pinned on purpose: the schema checks below grep for JSON-Pointer paths + # (/auth/issuer), which Helm only emits since it moved to jsonschema/v6 + # after 3.18.4. An older Helm would fail these checks with a misleading + # message even though the schema is fine. setup-helm's own fallback + # version is 3.18.3, so "latest" is not safe to rely on here. + version: v4.2.2 - name: Lint charts run: | helm lint deploy/charts/apus-operator --set bundles.s3Endpoint=http://example From 62c4d1c47595271d970e7975edf459638e3e48ad Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 16:37:05 +0200 Subject: [PATCH 12/17] docs: replace the Kustomize tasks in the phase 8 plan with the Helm charts --- ...12-phase-8-deployment-und-observability.md | 602 +++++------------- .../specs/2026-08-08-apus-design.md | 8 +- 2 files changed, 163 insertions(+), 447 deletions(-) diff --git a/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md b/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md index 314c538..dc34207 100644 --- a/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md +++ b/docs/superpowers/plans/2026-08-12-phase-8-deployment-und-observability.md @@ -4,9 +4,16 @@ **Goal:** Apus lässt sich per GitOps in einen Cluster ausrollen, und wer es betreibt, sieht am Dashboard, was das System gerade tut — statt es aus `kubectl`-Ausgaben zusammenzureimen. -**Architecture:** Das Repository liefert eine Kustomize-Basis unter `deploy/`, die das Cluster-Repository (`Kubernetes-FLUX`) referenziert und über ein Overlay mit seinen eigenen Werten überschreibt. Die sechs CRD-YAMLs werden eingecheckt statt nur generiert, damit ein Ausrollen keinen Gradle-Lauf voraussetzt; ein Test hält die eingecheckte Fassung mit dem Generator synchron. Metriken folgen dem im Repository bereits etablierten Muster: der Operator exponiert sie wie das `telemetry-addon` über den JDK-eigenen `HttpServer`, die API über Micronauts Micrometer-Integration. +**Architecture:** Apus wird über zwei Helm Charts unter `deploy/charts/` ausgerollt +(`apus-operator`, `apus-platform`) statt über eine Kustomize-Basis — siehe +`docs/superpowers/plans/2026-08-13-helm-charts.md` und Design-Spec §9. Die sechs +CRD-YAMLs werden eingecheckt statt nur generiert, damit ein Ausrollen keinen Gradle-Lauf +voraussetzt; ein Test hält die eingecheckte Fassung mit dem Generator synchron. Metriken +folgen dem im Repository bereits etablierten Muster: der Operator exponiert sie wie das +`telemetry-addon` über den JDK-eigenen `HttpServer`, die API über Micronauts +Micrometer-Integration. -**Tech Stack:** Kustomize, Prometheus Operator (`PodMonitor`/`ServiceMonitor` aus dem im Cluster vorhandenen kube-prometheus-stack), Micrometer 1.15, JOSDK 5.5.1, Grafana, k3s via Testcontainers. +**Tech Stack:** Helm, Prometheus Operator (`PodMonitor`/`ServiceMonitor` aus dem im Cluster vorhandenen kube-prometheus-stack), Micrometer 1.15, JOSDK 5.5.1, Grafana, k3s via Testcontainers. ## Global Constraints @@ -24,6 +31,16 @@ Heute erzeugt `./gradlew :operator:generateCrds` die sechs CRDs nach `operator/build/crds`. Wer Apus ausrollt, braucht sie aber vor dem ersten Operator-Start — und ein Cluster-Repository soll dafür kein Gradle ausführen müssen. +**Extension-Hinweis:** Der Generator schreibt seine Ausgabe mit der Endung `.yml`, nicht +`.yaml`. `deploy/crds/` und die davon abgeleiteten `deploy/charts/apus-operator/files/crds/` +benutzen durchgängig `.yaml` — dieser Plan macht `deploy/crds/` konsistent dazu, indem der +Copy-Task in Schritt 4 beim Kopieren umbenennt, statt die Endung des Generators zu +übernehmen. Grund für diese Wahl statt umgekehrt (alles auf `.yml` umzustellen): `deploy/crds/` +existiert im Repository bereits mit sechs `.yaml`-Dateien (eingecheckt beim Bau der Helm +Charts), und jede Glob-Regel, die darauf aufsetzt — im Chart-Template, in `sync-crds.sh`, +in der Doku — erwartet `.yaml`. Auf `.yml` umzustellen hieße, all das nachträglich zu ändern, +ohne einen Vorteil dafür zu bekommen. + **Files:** - Create: `deploy/crds/*.yaml` (sechs Dateien, Generator-Ausgabe) @@ -38,7 +55,9 @@ Heute erzeugt `./gradlew :operator:generateCrds` die sechs CRDs nach `operator/b - [ ] **Schritt 1: CRDs erzeugen und Namen feststellen** Run: `./gradlew :operator:generateCrds && ls operator/build/crds/` -Expected: sechs YAML-Dateien. Die exakten Dateinamen notieren — sie werden in Schritt 3 gebraucht. +Expected: sechs Dateien mit der Endung `.yml` (nicht `.yaml` — das ist die tatsächliche +Ausgabe des Generators, unabhängig von diesem Plan). Die exakten Dateinamen notieren — sie +werden in Schritt 3 gebraucht. - [ ] **Schritt 2: Failing test schreiben** @@ -95,9 +114,13 @@ class CrdsInSyncTest { private static Map read(Path dir) throws IOException { assertTrue(Files.isDirectory(dir), dir + " does not exist"); try (Stream files = Files.list(dir)) { - return files.filter(p -> p.toString().endsWith(".yaml")) + // The generator writes .yml; syncCrds (below) renames to .yaml on the way into + // deploy/crds, matching what the chart's files/crds already uses. Matching by + // extension-less basename lets the two directories carry different extensions + // without the drift check missing a renamed-but-changed file. + return files.filter(p -> p.toString().endsWith(".yaml") || p.toString().endsWith(".yml")) .collect(Collectors.toMap( - p -> p.getFileName().toString(), + p -> p.getFileName().toString().replaceFirst("\\.ya?ml$", ""), p -> { try { return Files.readString(p); @@ -121,17 +144,19 @@ In `operator/build.gradle.kts` nach der `generateCrds`-Registrierung: ```kotlin val syncCrds by tasks.registering(Copy::class) { - description = "Copies the generated CRDs to deploy/crds, which is what gets rolled out." + description = "Copies the generated CRDs to deploy/crds, renaming .yml to .yaml -- " + + "the generator's own extension, but not what deploy/crds and the chart use." group = "build" from(generateCrds) into(rootProject.layout.projectDirectory.dir("deploy/crds")) + rename { fileName -> fileName.replace(Regex("\\.yml$"), ".yaml") } } ``` - [ ] **Schritt 5: CRDs erzeugen und einchecken** Run: `./gradlew :operator:syncCrds && ls deploy/crds/` -Expected: dieselben sechs Dateien wie in Schritt 1. +Expected: dieselben sechs Dateinamen wie in Schritt 1, jetzt mit der Endung `.yaml`. - [ ] **Schritt 6: Test läuft grün** @@ -157,386 +182,19 @@ git commit -m "feat: check in the generated CRDs and guard them against drift" --- -### Task 2: Kustomize-Basis für den Operator - -**Files:** - -- Create: `deploy/base/kustomization.yaml` -- Create: `deploy/base/namespace.yaml` -- Create: `deploy/base/operator-serviceaccount.yaml` -- Create: `deploy/base/operator-rbac.yaml` -- Create: `deploy/base/operator-deployment.yaml` -- Create: `deploy/README.md` - -**Interfaces:** - -- Consumes: `deploy/crds/` aus Task 1; die Umgebungsvariablen aus `OperatorConfig` (`APUS_ROOK_NAMESPACE`, `APUS_CEPH_OBJECT_STORE`, `APUS_BUCKET_STORAGE_CLASS`, `APUS_RUNNER_IMAGE`, `APUS_INGEST_IMAGE`, `APUS_HOSTING_IMAGE`, `APUS_BUNDLE_BUCKET`, `APUS_BUNDLE_S3_ENDPOINT`, `APUS_BUNDLE_S3_REGION`, `APUS_BUNDLE_CREDENTIALS_SECRET`). -- Produces: die Basis, auf die Task 3 (API und UI) und Task 6 (PodMonitor) aufsetzen. - -- [ ] **Schritt 1: Namespace und ServiceAccount** +### Task 2 und 3: ersetzt durch die Helm Charts -`deploy/base/namespace.yaml`: - -```yaml -apiVersion: v1 -kind: Namespace -metadata: - name: apus-system -``` - -`deploy/base/operator-serviceaccount.yaml`: - -```yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: apus-operator - namespace: apus-system -``` - -- [ ] **Schritt 2: RBAC** - -`deploy/base/operator-rbac.yaml`: - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: apus-operator -rules: - # Own custom resources, including status and finalizers. - - apiGroups: ["bluemap.onelitefeather.net"] - resources: - - tenants - - worldsources - - worldingests - - bluemapmaps - - bluemaprenders - - bluemaphostings - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["bluemap.onelitefeather.net"] - resources: - - tenants/status - - worldsources/status - - worldingests/status - - bluemapmaps/status - - bluemaprenders/status - - bluemaphostings/status - verbs: ["get", "update", "patch"] - - apiGroups: ["bluemap.onelitefeather.net"] - resources: - - tenants/finalizers - - bluemapmaps/finalizers - verbs: ["update"] - # A Tenant creates a namespace with its quota and network policy (design spec §8.1). - - apiGroups: [""] - resources: ["namespaces", "resourcequotas", "limitranges"] - verbs: ["get", "list", "watch", "create", "update", "patch"] - - apiGroups: ["networking.k8s.io"] - resources: ["networkpolicies"] - verbs: ["get", "list", "watch", "create", "update", "patch"] - # Renders and ingests are Jobs; hosting is a Deployment behind a Service and Ingress. - - apiGroups: ["batch"] - resources: ["jobs"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: [""] - resources: ["services", "configmaps"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["networking.k8s.io"] - resources: ["ingresses"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # Reading the render pod's /progress endpoint and its termination message (design spec §7.2). - - apiGroups: [""] - resources: ["pods", "pods/log"] - verbs: ["get", "list", "watch"] - # Rook provisions bucket, credentials secret and endpoint ConfigMap (design spec §9.1). - - apiGroups: ["objectbucket.io"] - resources: ["objectbucketclaims"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - - apiGroups: ["ceph.rook.io"] - resources: ["cephobjectstoreusers"] - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # The secrets Rook creates, wired into render jobs and hosting pods. Deliberately not - # cluster-wide write: the operator only ever reads them. - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "list", "watch"] - - apiGroups: [""] - resources: ["events"] - verbs: ["create", "patch"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: apus-operator -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: apus-operator -subjects: - - kind: ServiceAccount - name: apus-operator - namespace: apus-system -``` - -- [ ] **Schritt 3: RBAC gegen den tatsächlichen Code prüfen** - -Run: `grep -rhoE '\b(Job|Deployment|Service|Ingress|ConfigMap|Secret|Namespace|ResourceQuota|LimitRange|NetworkPolicy|ObjectBucketClaim|CephObjectStoreUser|Pod)\b' operator/src/main/java --include='*.java' | sort -u` -Expected: Jeder ausgegebene Typ hat oben eine Regel. Fehlt einer, ergänzen — eine zu schmale ClusterRole äußert sich zur Laufzeit als `Forbidden` mitten in einer Reconciliation, nicht beim Start. - -- [ ] **Schritt 4: Operator-Deployment** - -`deploy/base/operator-deployment.yaml`: - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: apus-operator - namespace: apus-system - labels: - app.kubernetes.io/name: apus-operator - app.kubernetes.io/part-of: apus -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: apus-operator - template: - metadata: - labels: - app.kubernetes.io/name: apus-operator - app.kubernetes.io/part-of: apus - spec: - serviceAccountName: apus-operator - securityContext: - runAsNonRoot: true - runAsUser: 10001 - seccompProfile: - type: RuntimeDefault - containers: - - name: operator - image: harbor.onelitefeather.dev/apus/operator:0.1.0 - imagePullPolicy: IfNotPresent - ports: - - name: metrics - containerPort: 8080 - env: - # Defaults live in OperatorConfig; every value here is set explicitly so that - # what a cluster runs with is readable from the manifest rather than the code. - - name: APUS_ROOK_NAMESPACE - value: rook-ceph - - name: APUS_CEPH_OBJECT_STORE - value: ceph-objectstore - - name: APUS_BUCKET_STORAGE_CLASS - value: ceph-bucket - - name: APUS_RUNNER_IMAGE - value: harbor.onelitefeather.dev/apus/runner:0.1.0 - - name: APUS_INGEST_IMAGE - value: harbor.onelitefeather.dev/apus/ingest:0.1.0 - - name: APUS_HOSTING_IMAGE - value: harbor.onelitefeather.dev/apus/hosting:0.1.0 - - name: APUS_BUNDLE_BUCKET - value: apus-bundles - - name: APUS_BUNDLE_S3_ENDPOINT - value: http://rook-ceph-rgw-ceph-objectstore.rook-ceph.svc:80 - - name: APUS_BUNDLE_S3_REGION - value: us-east-1 - - name: APUS_BUNDLE_CREDENTIALS_SECRET - value: apus-bundle-credentials - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - memory: 512Mi - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: ["ALL"] -``` - -- [ ] **Schritt 5: Kustomization und README** - -`deploy/base/kustomization.yaml`: - -```yaml -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - ../crds - - namespace.yaml - - operator-serviceaccount.yaml - - operator-rbac.yaml - - operator-deployment.yaml -``` - -Dafür braucht `deploy/crds` eine eigene `kustomization.yaml`, die die sechs Dateien auflistet: - -```yaml -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - -``` - -`deploy/README.md`: - -```markdown -# Ausrollen - -`base/` ist die vollständige, aber unkonfigurierte Kustomize-Basis. Cluster-spezifische -Werte — Registry, Image-Tags, Rook-Namen, Hostnamen — gehören in ein Overlay im -Cluster-Repository, nicht hierher. - - kubectl apply -k deploy/base # direkt, für einen Testcluster - kustomize build deploy/base | kubectl apply -f - - -Die CRDs unter `crds/` sind generiert. Sie werden nicht von Hand bearbeitet, sondern über - - ./gradlew :operator:syncCrds - -erneuert; `CrdsInSyncTest` bricht den Build, wenn das jemand vergisst. -``` - -- [ ] **Schritt 6: Manifeste validieren** - -Run: `kustomize build deploy/base > /tmp/apus-base.yaml && grep -c '^kind:' /tmp/apus-base.yaml` -Expected: mindestens 11 Objekte (6 CRDs, Namespace, ServiceAccount, ClusterRole, ClusterRoleBinding, Deployment). - -Run: `kubectl apply --dry-run=client -f /tmp/apus-base.yaml` -Expected: jede Zeile endet auf `(dry run)`, keine Fehler. - -- [ ] **Schritt 7: Commit** - -```bash -git add deploy/ -git commit -m "feat: add a Kustomize base for rolling out the operator" -``` - ---- - -### Task 3: Manifeste für API und UI - -**Files:** - -- Create: `deploy/base/api-deployment.yaml` -- Create: `deploy/base/api-service.yaml` -- Create: `deploy/base/api-rbac.yaml` -- Create: `deploy/base/ui-deployment.yaml` -- Create: `deploy/base/ui-service.yaml` -- Create: `deploy/base/ingress.yaml` -- Modify: `deploy/base/kustomization.yaml` - -- [ ] **Schritt 1: RBAC der API ermitteln, statt sie zu raten** - -Run: `grep -rn 'resources(\|\.secrets()\|\.namespaces()\|customResources' api/src/main/java --include='*.java' | head -20` -Expected: eine Liste der tatsächlich angesprochenen Ressourcen. Die API liest die Custom Resources und — für den Push-Token-Lookup — Secrets. Genau diese und keine weiteren kommen in die Rolle. - -- [ ] **Schritt 2: API-RBAC schreiben** - -`deploy/base/api-rbac.yaml`: - -```yaml -apiVersion: v1 -kind: ServiceAccount -metadata: - name: apus-api - namespace: apus-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: apus-api -rules: - - apiGroups: ["bluemap.onelitefeather.net"] - resources: - - tenants - - worldsources - - worldingests - - bluemapmaps - - bluemaprenders - - bluemaphostings - verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # Service-token lookup. This is deliberately cluster-wide read on secrets today, which - # is wider than ideal -- see design spec §15, point 9. Narrowing it is scoped in the - # phase 9 plan; until then this rule must not be copied as a pattern for anything else. - - apiGroups: [""] - resources: ["secrets"] - verbs: ["get", "list"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: apus-api -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: apus-api -subjects: - - kind: ServiceAccount - name: apus-api - namespace: apus-system -``` - -- [ ] **Schritt 3: Deployments und Services** - -`deploy/base/api-deployment.yaml` — gleiche Struktur wie das Operator-Deployment (`securityContext`, `runAsUser: 10001`, `readOnlyRootFilesystem`), Image `harbor.onelitefeather.dev/apus/api:0.1.0`, `serviceAccountName: apus-api`, Port 8080, plus: - -```yaml - env: - - name: MICRONAUT_ENVIRONMENTS - value: k8s - # The issuer is the one open product decision (design spec §15, point 3). The - # overlay in the cluster repository supplies the real value; the base leaves - # it empty on purpose so that a half-configured rollout fails loudly at startup - # instead of accepting unvalidated tokens. - - name: MICRONAUT_SECURITY_TOKEN_JWT_SIGNATURES_JWKS_DEFAULT_URL - value: "" - readinessProbe: - httpGet: - path: /health/readiness - port: 8080 - initialDelaySeconds: 10 - livenessProbe: - httpGet: - path: /health/liveness - port: 8080 - initialDelaySeconds: 30 -``` - -`deploy/base/api-service.yaml` und `deploy/base/ui-service.yaml`: je ein `ClusterIP`-Service auf Port 8080 mit passendem Selector. - -`deploy/base/ui-deployment.yaml`: Image `harbor.onelitefeather.dev/apus/ui:0.1.0`, `runAsUser: 101` (die unprivilegierte nginx-Basis aus Phase 7, Task 8 läuft unter dieser uid — nicht 10001), Port 8080, `readOnlyRootFilesystem: false`, weil nginx sein Cache-Verzeichnis beschreibt. - -- [ ] **Schritt 4: Ingress** - -`deploy/base/ingress.yaml` — ein Host, zwei Pfade: `/api` auf den API-Service, `/` auf den UI-Service. `ingressClassName: nginx`, TLS über cert-manager, Hostname als Platzhalter `apus.example.net`, den das Overlay ersetzt. - -- [ ] **Schritt 5: Health-Endpunkte verifizieren, bevor die Probes eingecheckt werden** - -Run: `grep -rn 'micronaut-management\|endpoints:' api/build.gradle.kts api/src/main/resources/application.yml` -Expected: `micronaut-management` ist als Abhängigkeit vorhanden und `/health` aktiviert. Ist es das nicht, laufen die Probes ins Leere und der Pod wird endlos neu gestartet — dann zuerst Task 5 dieses Plans ausführen (der bringt `micronaut-management` mit) und danach hierher zurückkehren. - -- [ ] **Schritt 6: Kustomization erweitern und validieren** - -Die sechs neuen Dateien in `deploy/base/kustomization.yaml` unter `resources` ergänzen. - -Run: `kustomize build deploy/base | kubectl apply --dry-run=client -f -` -Expected: keine Fehler. - -- [ ] **Schritt 7: Commit** - -```bash -git add deploy/base -git commit -m "feat: add deployment manifests for the API and the dashboard" -``` +Ursprünglich: Kustomize-Basis für Operator (Task 2), API und UI (Task 3). Diese beiden +Tasks sind überholt. Statt einer Kustomize-Basis unter `deploy/base` rollt +Apus über zwei Helm Charts unter `deploy/charts/` aus — `apus-operator` (die sechs CRDs +als Templates, der Operator, cluster-weite RBAC) und `apus-platform` (API, UI, Ingress). +Umsetzung und Design stehen in `docs/superpowers/plans/2026-08-13-helm-charts.md` und +`docs/superpowers/specs/2026-08-13-helm-charts-design.md`; beide Charts sind abgeschlossen +und in diesem Repository unter `deploy/charts/apus-operator` und `deploy/charts/apus-platform` +eingecheckt. Die Werte, die diese beiden Tasks ursprünglich als Manifest-Inhalt vorsahen — +`OperatorConfig`-Umgebungsvariablen, RBAC-Regeln, Health-Probes, Ingress-Pfade — finden sich +1:1 in den `values.yaml`/`values.schema.json` der beiden Charts wieder; dieser Plan +wiederholt sie nicht. --- @@ -965,15 +623,25 @@ git commit -m "feat: expose Prometheus metrics and health endpoints from the API --- -### Task 6: Scrape-Konfiguration +### Task 6: Scrape-Konfiguration für Render-Pods + +Die beiden `ServiceMonitor`s für Operator und API aus der ursprünglichen Fassung dieses +Tasks sind bereits Chart-Templates +(`deploy/charts/apus-operator/templates/servicemonitor.yaml`, +`deploy/charts/apus-platform/templates/api-servicemonitor.yaml`), zusammen mit den +zugehörigen `Service`s (`deploy/charts/apus-operator/templates/service.yaml`, +`deploy/charts/apus-platform/templates/api-service.yaml`). Beide `ServiceMonitor`s sind +standardmäßig aus (`metrics.serviceMonitor.enabled: false` bzw. +`api.metrics.serviceMonitor.enabled: false`), bis Task 4 bzw. Task 5 dieses Plans die +Metriken tatsächlich exportieren — vorher würden sie nur einen leeren Endpunkt scrapen. + +Offen bleibt nur der `PodMonitor` für die vom Operator erzeugten Render-Pods: Er selektiert +Pods in Mandanten-Namespaces, die kein Chart kennt (Design-Spec §9), und ist deshalb kein +Chart-Template, sondern ein eigenständiges Manifest außerhalb der Charts. **Files:** -- Create: `deploy/base/podmonitor-render.yaml` -- Create: `deploy/base/servicemonitor-operator.yaml` -- Create: `deploy/base/servicemonitor-api.yaml` -- Create: `deploy/base/operator-service.yaml` -- Modify: `deploy/base/kustomization.yaml` +- Create: `deploy/podmonitor-render.yaml` - [ ] **Schritt 1: Label prüfen, unter dem der Operator seine Render-Pods markiert** @@ -987,6 +655,8 @@ apiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: name: apus-render + # Muss in demselben Namespace liegen wie die apus-operator-Chart-Installation, damit ein + # Prometheus, dessen podMonitorNamespaceSelector diesen Namespace einschließt, ihn findet. namespace: apus-system labels: app.kubernetes.io/part-of: apus @@ -1008,36 +678,38 @@ Damit das greift, muss der Render-Job seinen Port benennen. Prüfen: Run: `grep -n 'containerPort\|withName' operator/src/main/java/net/onelitefeather/apus/operator/render/RenderJobBuilder.java` Expected: ein benannter Port `telemetry` auf 8099. Fehlt der Name, im selben Task ergänzen und den zugehörigen `RenderJobBuilderTest` erweitern. -- [ ] **Schritt 3: Service und `ServiceMonitor` für Operator und API** - -`operator-service.yaml`: ClusterIP-Service auf Port 8080, Name `metrics`, Selector `app.kubernetes.io/name: apus-operator`. - -Beide `ServiceMonitor`s selektieren auf denselben Labels; der für die API scrapt Pfad `/prometheus` und braucht die Basic-Auth- bzw. Token-Referenz, mit der der Endpunkt geschützt ist (`basicAuth` mit Verweis auf ein Secret, das das Overlay im Cluster-Repository liefert). - -- [ ] **Schritt 4: Validieren** +- [ ] **Schritt 3: Validieren** -Run: `kustomize build deploy/base | kubectl apply --dry-run=client -f - 2>&1 | tail -5` -Expected: keine Fehler. `PodMonitor`/`ServiceMonitor` erfordern die CRDs des Prometheus-Operators; ist der lokal nicht vorhanden, schlägt `--dry-run=client` **nicht** fehl (es prüft nur Struktur) — für die echte Prüfung `--dry-run=server` gegen einen Cluster mit kube-prometheus-stack verwenden. +Run: `kubectl apply --dry-run=client -f deploy/podmonitor-render.yaml` +Expected: keine Fehler. Der `PodMonitor` erfordert die CRDs des Prometheus-Operators; ist der +lokal nicht vorhanden, schlägt `--dry-run=client` **nicht** fehl (es prüft nur Struktur) — für +die echte Prüfung `--dry-run=server` gegen einen Cluster mit kube-prometheus-stack verwenden. -- [ ] **Schritt 5: Commit** +- [ ] **Schritt 4: Commit** ```bash -git add deploy/base -git commit -m "feat: add scrape configuration for render pods, operator and API" +git add deploy/podmonitor-render.yaml +git commit -m "feat: add scrape configuration for render pods" ``` --- ### Task 7: Grafana-Dashboards -Design-Spec §13.1: „ein Grafana-Dashboard je Ebene (Plattform, Mandant)". +Design-Spec §13.1: „ein Grafana-Dashboard je Ebene (Plattform, Mandant)". Die ConfigMap, +die diese Dashboards für die Grafana-Sidecar-Erkennung bereitstellt, wandert gegenüber der +ursprünglichen Fassung dieses Tasks als optionale `dashboards.enabled`-Ressource ins +`apus-platform`-Chart, statt über `kustomization.yaml` in die Kustomize-Basis eingebunden zu +werden (Design-Spec §9). **Files:** -- Create: `deploy/dashboards/apus-platform.json` -- Create: `deploy/dashboards/apus-tenant.json` -- Create: `deploy/base/dashboards-configmap.yaml` -- Modify: `deploy/base/kustomization.yaml` +- Create: `deploy/charts/apus-platform/files/dashboards/apus-platform.json` +- Create: `deploy/charts/apus-platform/files/dashboards/apus-tenant.json` +- Create: `deploy/charts/apus-platform/templates/dashboards-configmap.yaml` +- Modify: `deploy/charts/apus-platform/values.yaml` (`dashboards.enabled`, `dashboards.labels`) +- Modify: `deploy/charts/apus-platform/values.schema.json` +- Modify: `deploy/charts/apus-platform/README.md` (Werte-Tabelle) - [ ] **Schritt 1: Verfügbare Metriknamen zusammenstellen** @@ -1048,7 +720,7 @@ Expected: die vollständige Liste. Jedes Panel darf ausschließlich diese Namen - [ ] **Schritt 2: Plattform-Dashboard bauen** -`deploy/dashboards/apus-platform.json`, Panels: +`deploy/charts/apus-platform/files/dashboards/apus-platform.json`, Panels: 1. **Renders nach Phase** (Zeitreihe): `sum by (phase) (rate(apus_renders_total[5m]))` 2. **Fehlerquote** (Stat): `sum(rate(apus_renders_total{phase="Failed"}[1h])) / sum(rate(apus_renders_total[1h]))` @@ -1061,13 +733,13 @@ Als Template-Variable `datasource` vom Typ `prometheus`; keine fest verdrahtete - [ ] **Schritt 3: Mandanten-Dashboard bauen** -`deploy/dashboards/apus-tenant.json` mit derselben Datenquellen-Variable plus einer Variable `tenant` (`label_values(apus_storage_used_bytes, tenant)`). Panels: laufende Renders mit Fortschritt (`apus_render_progress_ratio` und `apus_render_eta_seconds` — die Namen, die `PrometheusWriter` im `telemetry-addon` tatsächlich schreibt), letzte Ingest-Dauer, Speicherverbrauch gegen Quota, Render-Historie nach Phase — alle mit `{tenant="$tenant"}` gefiltert. +`deploy/charts/apus-platform/files/dashboards/apus-tenant.json` mit derselben Datenquellen-Variable plus einer Variable `tenant` (`label_values(apus_storage_used_bytes, tenant)`). Panels: laufende Renders mit Fortschritt (`apus_render_progress_ratio` und `apus_render_eta_seconds` — die Namen, die `PrometheusWriter` im `telemetry-addon` tatsächlich schreibt), letzte Ingest-Dauer, Speicherverbrauch gegen Quota, Render-Historie nach Phase — alle mit `{tenant="$tenant"}` gefiltert. Die Render-Metriken tragen allerdings **kein** `tenant`-Label: Das `telemetry-addon` läuft im Render-Pod und kennt nur `map`. Der Mandant kommt über die Pod-Labels herein, die der `PodMonitor` aus Task 6 anhängt — beim Bau der Panels ist zu prüfen, welches Label das ist (`grep` in `Labels.java`), und danach zu filtern. Wer stattdessen `{tenant="$tenant"}` auf `apus_render_progress_ratio` schreibt, bekommt ein dauerhaft leeres Panel. - [ ] **Schritt 4: JSON validieren** -Run: `for f in deploy/dashboards/*.json; do python3 -c "import json,sys;json.load(open('$f'));print('$f ok')"; done` +Run: `for f in deploy/charts/apus-platform/files/dashboards/*.json; do python3 -c "import json,sys;json.load(open('$f'));print('$f ok')"; done` Expected: beide `ok`. - [ ] **Schritt 5: Alle verwendeten Metriknamen gegen Schritt 1 gegenprüfen** @@ -1075,10 +747,11 @@ Expected: beide `ok`. Nicht gegen den Quellcode greppen, sondern gegen einen echten Scrape — die Meter-Namen im Code und die gescrapten Namen unterscheiden sich (`apus_renders` im Code, `apus_renders_total` im Scrape; `apus_ingest_duration` im Code, `apus_ingest_duration_seconds*` im Scrape). Ein Abgleich gegen den Quellcode würde genau deshalb Fehlalarme produzieren. ```bash -# Scrape einer laufenden Instanz als Referenz nehmen: -kubectl -n apus-system port-forward svc/apus-operator 8080:8080 & +# Scrape einer laufenden Instanz als Referenz nehmen. Der Service-Name kommt aus +# deploy/charts/apus-operator/templates/service.yaml -- -apus-operator-metrics. +kubectl -n apus-system port-forward svc/apus-operator-metrics 8080:8080 & curl -s localhost:8080/metrics | grep -oE '^apus_[a-z_]+' | sort -u > /tmp/scraped.txt -grep -ohE 'apus_[a-z_]+' deploy/dashboards/*.json | sort -u > /tmp/used.txt +grep -ohE 'apus_[a-z_]+' deploy/charts/apus-platform/files/dashboards/*.json | sort -u > /tmp/used.txt comm -23 /tmp/used.txt /tmp/scraped.txt ``` @@ -1086,39 +759,50 @@ Expected: leere Ausgabe. Jeder Name, der hier erscheint, wird von keiner Instanz Metriken aus dem `telemetry-addon` (`apus_render_*`) erscheinen nicht im Operator-Scrape; für sie ist derselbe Abgleich gegen einen Render-Pod auf Port 8099 zu fahren. -- [ ] **Schritt 6: ConfigMap für die Grafana-Sidecar-Erkennung** +- [ ] **Schritt 6: ConfigMap als optionales Chart-Template** -```yaml +`deploy/charts/apus-platform/templates/dashboards-configmap.yaml` — nach demselben Muster +wie `deploy/charts/apus-operator/templates/crds.yaml` (das die CRDs aus `files/crds/*.yaml` +liest): ein `.Files.Glob` über die im Chart mitgelieferten JSON-Dateien, kein Kopieren von +Hand. + +```gotemplate +{{- if .Values.dashboards.enabled }} apiVersion: v1 kind: ConfigMap metadata: - name: apus-dashboards - namespace: apus-system + name: {{ include "apus-platform.fullname" . }}-dashboards + namespace: {{ .Release.Namespace }} labels: + {{- include "apus-platform.labels" . | nindent 4 }} # The kube-prometheus-stack Grafana sidecar picks up ConfigMaps carrying this label. grafana_dashboard: "1" +data: + {{- range $path, $_ := .Files.Glob "files/dashboards/*.json" }} + {{ base $path }}: |- + {{- $.Files.Get $path | nindent 4 }} + {{- end }} +{{- end }} ``` -Die beiden JSON-Dateien werden über `configMapGenerator` in `kustomization.yaml` eingebunden, nicht von Hand in die ConfigMap kopiert: +In `values.yaml`: ```yaml -configMapGenerator: - - name: apus-dashboards - namespace: apus-system - files: - - ../dashboards/apus-platform.json - - ../dashboards/apus-tenant.json - options: - labels: - grafana_dashboard: "1" - disableNameSuffixHash: true +dashboards: + # The dashboards reference metric names that don't exist until Task 4 and Task 5 of the + # phase 8 plan land in the operator and the API. Off by default for the same reason the + # ServiceMonitors default to off -- enabling it earlier just leaves every panel empty. + enabled: false ``` -- [ ] **Schritt 7: Commit** +- [ ] **Schritt 7: Validieren und Commit** + +Run: `helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net --set dashboards.enabled=true | grep -c 'kind: ConfigMap'` +Expected: mindestens `1`. ```bash -git add deploy/dashboards deploy/base -git commit -m "feat: add Grafana dashboards for the platform and tenant views" +git add deploy/charts/apus-platform +git commit -m "feat(helm): add Grafana dashboards to the apus-platform chart" ``` --- @@ -1134,7 +818,7 @@ Design-Spec §13.2 sieht vor: „k3s + S3: kompletter Durchlauf Ingest → Rende **Interfaces:** -- Consumes: die bestehende k3s-Testcontainers-Infrastruktur der vorhandenen `*IntegrationTest`-Klassen sowie `testdata/mini-world`. +- Consumes: die bestehende k3s-Testcontainers-Infrastruktur der vorhandenen `*IntegrationTest`-Klassen sowie `testdata/mini-world`; die beiden Helm Charts unter `deploy/charts/`. - [ ] **Schritt 1: Bestehende Integrationstest-Infrastruktur ansehen** @@ -1145,7 +829,7 @@ Expected: das vorhandene Muster für k3s- und MinIO-Container. Der neue Test üb Der Test fährt in einer Methode: -1. k3s starten, die sechs CRDs aus `deploy/crds` anwenden, den Operator über `LocallyRunOperatorExtension` gegen diesen Cluster laufen lassen. +1. k3s starten, die sechs CRDs über `helm install apus-operator deploy/charts/apus-operator --set bundles.s3Endpoint=` einspielen (statt einzelne CRD-Manifeste anzuwenden — das Chart installiert sie als Templates, siehe `deploy/charts/apus-operator/templates/crds.yaml`), den Operator-Reconciler zusätzlich über `LocallyRunOperatorExtension` gegen denselben Cluster laufen lassen. 2. MinIO starten, `testdata/mini-world` als Push-Quelle in den Staging-Prefix legen. 3. `Tenant` anlegen, auf `status.namespace` warten. 4. `WorldSource` (Typ `push`) und `WorldIngest` anlegen, warten bis `status.phase == "Succeeded"` und `status.bundle.path` gesetzt ist. @@ -1164,7 +848,7 @@ Expected: FAIL. Der Fehlschlag muss aus einer der Wartestufen kommen, nicht aus Was hier zu tun ist, hängt vom Fehlschlag ab. Erwartbare Stolpersteine, jeweils mit dem Ort, an dem sie zu beheben sind: -- Der Operator im Test kennt die Image-Namen nicht → `OperatorConfig`-Umgebungsvariablen im Test setzen, so wie das Deployment aus Task 2 es tut. +- Der Operator im Test kennt die Image-Namen nicht → `OperatorConfig`-Umgebungsvariablen im Test setzen, so wie das `apus-operator`-Chart sie über seine `images.*`-Werte in das Deployment einsetzt (`deploy/charts/apus-operator/templates/deployment.yaml`). - Rook existiert im k3s-Testcluster nicht → der Test setzt `storage.bucketClaim` nicht auf `auto`, sondern legt Bucket und Secret direkt in MinIO an und referenziert sie; die Rook-Integration ist eigener Scope und in `OperatorIntegrationTest` bereits abgedeckt. - Der Hosting-Pod braucht einen Ingress-Controller → im Test gegen den `Service` prüfen statt gegen die Ingress-URL; `status.ready` ist das Signal, nicht die externe Erreichbarkeit. @@ -1173,12 +857,30 @@ Was hier zu tun ist, hängt vom Fehlschlag ab. Erwartbare Stolpersteine, jeweils Run: `./gradlew :operator:integrationTest --tests '*FullPipelineIntegrationTest*'` (zweimal hintereinander) Expected: beide Male PASS. Ein E2E-Test, der nur beim ersten Lauf grün ist, hat Zustandsreste und ist nicht fertig. -- [ ] **Schritt 6: Sicherstellen, dass er nicht im PR-Build landet** +- [ ] **Schritt 6: `helm upgrade` mit geändertem CRD-Schema prüfen** + +Grund für diesen Schritt: Er belegt die Eigenschaft, wegen der die CRDs in +`deploy/charts/apus-operator/templates/crds.yaml` liegen und nicht in Helms `crds/`-Sonder- +verzeichnis (Design-Spec §9, Task-2-Bericht der Helm Charts) — Letzteres installiert Helm +einmalig und rührt es bei `helm upgrade` nie wieder an, ein geändertes Schema bliebe im +Cluster hängen. + +Gegen denselben k3s-Cluster aus Schritt 1: + +1. `helm install apus-operator deploy/charts/apus-operator --set bundles.s3Endpoint=` mit dem Chart-Stand *vor* dieser Änderung (letzter Git-Tag bzw. letztes veröffentlichtes Chart-Archiv aus Harbor). +2. Lokal ein Feld im generierten CRD-Schema ändern (z. B. eine neue optionale Property auf einer der `@Group`-annotierten Spec-Klassen), dann `./gradlew :operator:syncCrds` und `deploy/charts/apus-operator/sync-crds.sh` laufen lassen. +3. `helm upgrade apus-operator deploy/charts/apus-operator --set bundles.s3Endpoint=` mit dem geänderten Chart-Stand. +4. `kubectl get crd bluemapmaps.bluemap.onelitefeather.net -o jsonpath='{.spec.versions[0].schema.openAPIV3Schema.properties.spec.properties}'` gegen das neue Feld prüfen. + +Run: das Vorstehende als Shell-Sequenz oder als eigener Testfall neben `FullPipelineIntegrationTest`. +Expected: Das neue Feld erscheint im Cluster-Schema nach dem `helm upgrade`, ohne dass irgendjemand die CRD von Hand neu angewendet hat. Die Änderung am Schema danach wieder verwerfen (`git checkout` auf die betroffene Spec-Klasse), damit dieser Schritt keine echte CRD-Änderung hinterlässt. + +- [ ] **Schritt 7: Sicherstellen, dass er nicht im PR-Build landet** Run: `./gradlew :operator:test --tests '*FullPipeline*' 2>&1 | grep -c 'No tests found'` Expected: `1` — der Test greift die `*IntegrationTest`-Namenskonvention und ist damit aus `test` ausgeschlossen. -- [ ] **Schritt 7: Commit** +- [ ] **Schritt 8: Commit** ```bash git add operator/src/test/java/net/onelitefeather/apus/operator/FullPipelineIntegrationTest.java @@ -1195,7 +897,11 @@ git commit -m "test: cover the full ingest, render and hosting pipeline on k3s" - [ ] **Schritt 1: §13.1 als umgesetzt kennzeichnen** -Der Abschnitt beschreibt Metriken, Logs und Dashboards im Futur. Umschreiben auf den Ist-Zustand, mit den echten Dateinamen (`deploy/base/servicemonitor-*.yaml`, `deploy/dashboards/*.json`) und den tatsächlich exportierten Metriknamen. +Der Abschnitt beschreibt Metriken, Logs und Dashboards im Futur. Umschreiben auf den +Ist-Zustand, mit den echten Dateinamen (`deploy/charts/apus-operator/templates/servicemonitor.yaml`, +`deploy/charts/apus-platform/templates/api-servicemonitor.yaml`, +`deploy/podmonitor-render.yaml`, `deploy/charts/apus-platform/templates/dashboards-configmap.yaml`) +und den tatsächlich exportierten Metriknamen. - [ ] **Schritt 2: §13.2, Zeile „E2E", auf den neuen Test verweisen** @@ -1204,12 +910,18 @@ Ersetzen durch: `k3s + S3: kompletter Durchlauf Ingest → Render → Hosting mi - [ ] **Schritt 3: §0 um den Deployment-Stand ergänzen** +Ein Absatz zu Phase-8-§0 ist bereits durch die Helm-Charts-Arbeit vorhanden (siehe deren +Task 9); dieser Schritt erweitert ihn um das, was diese Phase zusätzlich liefert, statt ihn +zu ersetzen: + ```markdown -**Ausrollbar seit Phase 8.** `deploy/base` ist eine vollständige Kustomize-Basis -(CRDs, Operator, API, UI, RBAC, Scrape-Konfiguration); cluster-spezifische Werte kommen -aus einem Overlay im Cluster-Repository. Operator und API exportieren Metriken, zwei -Grafana-Dashboards liegen unter `deploy/dashboards`. Was offen bleibt, sind die -inhaltlichen Härtungen aus §15 — siehe den Plan zu Phase 9. +**Ausrollbar seit Phase 8.** Die beiden Helm Charts unter `deploy/charts/` +(`apus-operator`, `apus-platform`) installieren CRDs, Operator, API, UI, RBAC und +Scrape-Konfiguration; cluster-spezifische Werte kommen über `values:` aus der +`HelmRelease` im Cluster-Repository. Operator und API exportieren Metriken, zwei +Grafana-Dashboards liegen als optionale Ressource im `apus-platform`-Chart +(`dashboards.enabled`). Was offen bleibt, sind die inhaltlichen Härtungen aus §15 — siehe +den Plan zu Phase 9. ``` - [ ] **Schritt 4: Lint und Commit** @@ -1226,6 +938,6 @@ git commit -m "docs: record the phase 8 deployment and observability state" ## Was dieser Plan bewusst nicht abdeckt -- **Das Flux-Overlay selbst.** Es gehört ins Cluster-Repository (`Kubernetes-FLUX`), nicht hierher: Registry-Hostnamen, Rook-Namen, Domains und Secret-Referenzen sind Cluster-Eigenschaften, keine Projekt-Eigenschaften. `deploy/base` ist so geschnitten, dass ein Overlay genau diese Werte patchen kann. +- **Das Flux-Overlay selbst** (`OCIRepository` plus `HelmRelease`). Es gehört ins Cluster-Repository (`Kubernetes-FLUX`), nicht hierher: Registry-Hostnamen, Rook-Namen, Domains und Secret-Referenzen sind Cluster-Eigenschaften, keine Projekt-Eigenschaften. Die beiden Helm Charts unter `deploy/charts/` sind so geschnitten, dass eine `HelmRelease` genau diese Werte per `values:` überschreiben kann; siehe Design-Spec §9 und `docs/superpowers/plans/2026-08-13-helm-charts.md`. - **Alerting-Regeln.** Sinnvoll, aber sie brauchen erst Betriebserfahrung mit den neuen Metriken — Schwellwerte ohne Datengrundlage erzeugen nur Rauschen. - **Die Härtungen aus §15** (Identity-Broker, RBAC-Verengung, Quota-Signal, Paper-Save-Fenster, `emptyDir`-Grenze) — eigener Plan (Phase 9). diff --git a/docs/superpowers/specs/2026-08-08-apus-design.md b/docs/superpowers/specs/2026-08-08-apus-design.md index ccd7107..e7995e1 100644 --- a/docs/superpowers/specs/2026-08-08-apus-design.md +++ b/docs/superpowers/specs/2026-08-08-apus-design.md @@ -32,8 +32,12 @@ Skalierung über `render-threads` — siehe §14, Phase 4, für die volle Begrü (`runner`, `ingest`, `hosting`, `operator`, `api`, `ui`), `telemetry-addon` und `paper-worldpush` werden nach Maven veröffentlicht. Versionen und Changelogs entstehen über Release Please aus Conventional Commits; `telemetry-addon` und `paper-worldpush` -tragen dabei eigene Release-Spuren, wie in §4 vorgesehen. Was weiterhin fehlt, sind die -Cluster-Manifeste und die Observability-Verdrahtung — siehe den Plan zu Phase 8. +tragen dabei eigene Release-Spuren, wie in §4 vorgesehen. Was weiterhin fehlt, ist die +Observability-Verdrahtung (Metriken, Dashboards) — siehe den Plan zu Phase 8. + +**Ausrollbar über Helm.** Apus wird über zwei Helm Charts unter `deploy/charts/` +(`apus-operator`, `apus-platform`) ausgerollt; sie lösen die ursprünglich für Phase 8 +vorgesehene Kustomize-Basis ab. Details: `docs/superpowers/specs/2026-08-13-helm-charts-design.md`. **Bewusst offen gelassene Punkte** (Details in §15): From dcfcb5a6ce850d24899210c0f248c0f542dbbb5b Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 17:11:23 +0200 Subject: [PATCH 13/17] fix(helm): let the operator create the push-token secret and cert-manager certificates The ClusterRole granted secrets get/list/watch with a comment claiming the operator only ever reads them. TenantReconciler creates the apus-push-token Secret in every tenant namespace on first reconcile, so the very first Tenant -- the first thing NOTES.txt tells a user to create -- failed with a 403. The same role had no cert-manager.io rule at all, while BlueMapHostingReconciler does createOr(update) on a Certificate whenever a BlueMapHosting has TLS enabled. On a cluster with cert-manager installed, every TLS hosting failed the same way. Both grants are as narrow as RBAC allows: the push-token secret has a fixed name but an unbounded set of namespaces, which resourceNames cannot express, and neither resource is ever updated, patched or deleted beyond what createOr(update) needs. Also corrects the namespace rule's comment -- TenantReconciler creates no NetworkPolicy today, the rule is ahead of the code. --- .../charts/apus-operator/templates/rbac.yaml | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/deploy/charts/apus-operator/templates/rbac.yaml b/deploy/charts/apus-operator/templates/rbac.yaml index 103a87f..82bd8b9 100644 --- a/deploy/charts/apus-operator/templates/rbac.yaml +++ b/deploy/charts/apus-operator/templates/rbac.yaml @@ -30,10 +30,13 @@ rules: - tenants/finalizers - bluemapmaps/finalizers verbs: ["update"] - # A Tenant creates a namespace with its quota and network policy (design spec §8.1). + # A Tenant creates a namespace with its ResourceQuota and LimitRange (design spec §8.1). - apiGroups: [""] resources: ["namespaces", "resourcequotas", "limitranges"] verbs: ["get", "list", "watch", "create", "update", "patch"] + # The per-tenant NetworkPolicy of design spec §8.1 is not implemented yet -- TenantReconciler + # creates none today. The rule is kept so enabling it later is a code change rather than a + # code change plus a chart upgrade nobody remembers; it grants nothing that is used right now. - apiGroups: ["networking.k8s.io"] resources: ["networkpolicies"] verbs: ["get", "list", "watch", "create", "update", "patch"] @@ -50,6 +53,18 @@ rules: - apiGroups: ["networking.k8s.io"] resources: ["ingresses"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # A BlueMapHosting with spec.tls.enabled requests its ingress certificate from cert-manager: + # BlueMapHostingReconciler does client.resources(Certificate.class)...createOr(update), and + # that Certificate is @Group("cert-manager.io") @Version("v1") @Plural("certificates"). + # Granted unconditionally, not behind a value: the reconciler probes with client.supports() + # and stays out of the way on a cluster without cert-manager, so the rule is inert there -- + # whereas a missing rule turns every TLS hosting into a 403 nobody expects at that point. + # get/create/update is what createOr(update) exercises; list/watch keep the rule shaped like + # its siblings above and cost nothing on a type the operator may already write. No delete: + # the certificate carries the hosting as its owner reference and is garbage-collected. + - apiGroups: ["cert-manager.io"] + resources: ["certificates"] + verbs: ["get", "list", "watch", "create", "update", "patch"] # Reading the render pod's /progress endpoint and its termination message (design spec §7.2). - apiGroups: [""] resources: ["pods", "pods/log"] @@ -61,11 +76,20 @@ rules: - apiGroups: ["ceph.rook.io"] resources: ["cephobjectstoreusers"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - # The secrets Rook creates, wired into render jobs and hosting pods. Deliberately not - # cluster-wide write: the operator only ever reads them. + # Two different kinds of secret. The operator only ever reads the ones Rook creates + # (bucket credentials, wired into render jobs and hosting pods), but it *creates* the + # push-token secret itself: TenantReconciler builds `apus-push-token` in every tenant + # namespace on first reconcile (create() only -- it deliberately never rewrites it, since + # regenerating the token would break every paper-worldpush already configured with it). + # Without `create` the very first Tenant reconcile fails with a 403. + # This cannot be narrowed with resourceNames: the name is fixed, but the namespace is not + # -- a new one appears with every Tenant -- and a ClusterRole rule cannot say "this name, + # in any namespace, but nothing else". No update/patch/delete on purpose: the operator + # performs none of them on secrets, and the push token is garbage-collected through the + # Tenant owner reference rather than deleted by hand. - apiGroups: [""] resources: ["secrets"] - verbs: ["get", "list", "watch"] + verbs: ["get", "list", "watch", "create"] - apiGroups: [""] resources: ["events"] verbs: ["create", "patch"] From 494baa0b87c7d90eb807d572c4117652a8bbb1af Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 17:11:33 +0200 Subject: [PATCH 14/17] ci: validate the rendered charts offline and require auth.jwksUri `kubectl apply --dry-run=client` is not an offline check: since kubectl 1.26 it downloads the OpenAPI document from the API server before validating, so on a runner with no cluster it resolves to localhost:8080 and dies with "failed to download openapi ... connection refused". The step only ever passed because it was run on a machine with a live kubeconfig. kubeconform validates against published JSON schemas and never contacts an API server; the step is named for what it now does. auth.jwksUri is as mandatory as auth.issuer -- application.yml wires both from the environment with no default -- but nothing enforced it, so an install without it produced an API that rejects every token at runtime. It is now required by the same schema and guarded by its own CI assertion, run with the issuer supplied so the assertion cannot pass on the issuer error alone. --- .github/workflows/build-pr.yml | 52 +++++++++++++++++-- .../charts/apus-platform/values.schema.json | 8 ++- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 59aada5..6584c2f 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -69,11 +69,15 @@ jobs: - name: Lint charts run: | helm lint deploy/charts/apus-operator --set bundles.s3Endpoint=http://example - helm lint deploy/charts/apus-platform --set auth.issuer=https://id.example.net + helm lint deploy/charts/apus-platform \ + --set auth.issuer=https://id.example.net \ + --set auth.jwksUri=https://id.example.net/keys - name: Render charts run: | helm template t deploy/charts/apus-operator --set bundles.s3Endpoint=http://example > /tmp/operator.yaml - helm template t deploy/charts/apus-platform --set auth.issuer=https://id.example.net > /tmp/platform.yaml + helm template t deploy/charts/apus-platform \ + --set auth.issuer=https://id.example.net \ + --set auth.jwksUri=https://id.example.net/keys > /tmp/platform.yaml - name: The values schema actually rejects missing required values run: | # A schema that never rejects anything is worse than none: it looks like a guard. @@ -88,6 +92,20 @@ jobs: echo "$error" >&2 exit 1 fi + # jwksUri is checked separately with the issuer supplied: otherwise the run above + # would "pass" this assertion on the issuer error alone and an unenforced jwksUri + # would go unnoticed. Without it the API has no signing keys and rejects every + # token at runtime -- the same failure mode the issuer guard exists to prevent. + if error=$(helm template t deploy/charts/apus-platform \ + --set auth.issuer=https://id.example.net 2>&1 >/dev/null); then + echo "values.schema.json did not reject a missing auth.jwksUri" >&2 + exit 1 + fi + if ! grep -q '/auth/jwksUri' <<<"$error"; then + echo "helm template failed, but not because of auth.jwksUri:" >&2 + echo "$error" >&2 + exit 1 + fi if error=$(helm template t deploy/charts/apus-operator --set bundles.s3Endpoint="" 2>&1 >/dev/null); then echo "values.schema.json did not reject an empty bundles.s3Endpoint" >&2 exit 1 @@ -97,7 +115,31 @@ jobs: echo "$error" >&2 exit 1 fi - - name: Validate against the Kubernetes API schema + - name: Install kubeconform + env: + # Pinned for the same reason as helm above: a validator that changes its schema + # handling between releases would turn a red CI run into something nobody can + # reproduce locally. + KUBECONFORM_VERSION: v0.8.0 + run: | + # Unpacked into RUNNER_TEMP and put on PATH via GITHUB_PATH rather than into + # /usr/local/bin: no sudo, and nothing outside the job's own workspace is touched. + curl -sSfL "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" \ + | tar -xz -C "${RUNNER_TEMP}" kubeconform + echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}" + - name: Validate the rendered manifests against the Kubernetes schemas run: | - kubectl apply --dry-run=client -f /tmp/operator.yaml - kubectl apply --dry-run=client -f /tmp/platform.yaml + # kubeconform, not `kubectl apply --dry-run=client`: that dry-run is not offline. + # Since kubectl 1.26 it downloads the OpenAPI document from the API server before it + # validates anything, so on a runner with no cluster it falls back to localhost:8080 + # and dies with "failed to download openapi ... connection refused". kubeconform + # validates against the published JSON schemas and never contacts an API server -- + # which is also why this step is a schema check, not an API-server check. + # + # -ignore-missing-schemas is needed and safe here: the upstream schema set ships no + # schema for CustomResourceDefinition itself, and the only non-built-in kinds these + # charts render are exactly those six CRDs (plus a ServiceMonitor when + # metrics.serviceMonitor.enabled is set). No chart renders a custom resource, so + # nothing that would need a CRD-derived schema is silently skipped. + # -strict rejects unknown fields, which is where the typos actually live. + kubeconform -strict -summary -ignore-missing-schemas /tmp/operator.yaml /tmp/platform.yaml diff --git a/deploy/charts/apus-platform/values.schema.json b/deploy/charts/apus-platform/values.schema.json index c40d9f7..2b0e2f9 100644 --- a/deploy/charts/apus-platform/values.schema.json +++ b/deploy/charts/apus-platform/values.schema.json @@ -5,13 +5,19 @@ "properties": { "auth": { "type": "object", - "required": ["issuer"], + "required": ["issuer", "jwksUri"], "properties": { "issuer": { "type": "string", "minLength": 1, "format": "uri", "description": "OIDC issuer the API validates tokens against. Deliberately has no default: an unset issuer must fail the install, never start an API that accepts unvalidated tokens." + }, + "jwksUri": { + "type": "string", + "minLength": 1, + "format": "uri", + "description": "JWKS endpoint the API fetches signing keys from. As mandatory as the issuer and enforced for the same reason: application.yml wires both from the environment with no default (micronaut.security.token.jwt.signatures.jwks.apus-issuer.jwks-uri), so an empty value leaves the API with no signing keys and it rejects every token at runtime instead of failing at install time." } } } From ebdc4ea6d03efb74f6576b5b5ed6ef4defde8580 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 17:11:41 +0200 Subject: [PATCH 15/17] ci: push the charts without helm registry login `helm registry login` opens with an anonymous connectivity ping, which the Harbor behind this registry answers with a 401 that carries no WWW-Authenticate header -- the `empty challenge header` failure the image push hit until docker-publish.yml switched to `regctl registry login --skip-check`. Helm has no --skip-check, so the same idea is applied one level down: write the credential file the login would have written and pass it to `helm push` with --registry-config, which authenticates on the push request itself. Keeping helm as the pushing client means the artifact stays exactly what `helm pull` expects, instead of hand-assembling chart media types with regctl. Verified against a local registry with basic auth: the push succeeds with the hand-written config and no login, and fails with "basic credential not found" without it. It could not be verified against the real Harbor. Also pins azure/setup-helm to the same v4.2.2 as build-pr.yml, so the chart that ships is packaged by the Helm the PR job linted it with. --- .github/workflows/release-please.yml | 29 +++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 1e6c3eb..c147861 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -162,6 +162,10 @@ jobs: steps: - uses: actions/checkout@v5 - uses: azure/setup-helm@v4 + with: + # Same pin, same reason as build-pr.yml (see the comment there): the chart that ships + # must be packaged by the exact Helm version the PR job linted and rendered it with. + version: v4.2.2 - name: Package and push charts env: HARBOR_REGISTRY: ${{ secrets.HARBOR_REGISTRY }} @@ -169,11 +173,30 @@ jobs: HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} VERSION: ${{ needs.release-please.outputs.root-version }} run: | - printf '%s' "${HARBOR_PASSWORD}" | \ - helm registry login "${HARBOR_REGISTRY}" -u "${HARBOR_USERNAME}" --password-stdin + # Deliberately no `helm registry login`. Like regctl's login it starts with an + # anonymous connectivity ping, and this Harbor answers that with a 401 carrying no + # WWW-Authenticate header -- the `empty challenge header` failure the image push hit + # until the org's docker-publish.yml switched to `regctl registry login --skip-check` + # ("skips its anonymous connectivity ping, which a private registry answers with 401; + # credentials are exercised during the push"). Helm has no --skip-check, so the same + # idea is applied one level down: write the credential file `helm registry login` + # would have written and hand it to `helm push`, which authenticates on the push + # request itself. The file is docker's config.json format -- the format Helm's own + # login writes and its ORAS client reads. + # Pushing the .tgz with regctl instead would drop Helm from the publish path entirely + # and make us hand-assemble the chart's OCI config and layer media types; keeping + # `helm push` means the artifact stays exactly what `helm pull` expects. + config="${RUNNER_TEMP}/helm-registry-config.json" + umask 077 + printf '{"auths":{"%s":{"auth":"%s"}}}' \ + "${HARBOR_REGISTRY}" \ + "$(printf '%s:%s' "${HARBOR_USERNAME}" "${HARBOR_PASSWORD}" | base64 -w0)" \ + > "${config}" + trap 'rm -f "${config}"' EXIT for chart in apus-operator apus-platform; do helm package "deploy/charts/${chart}" - helm push "${chart}-${VERSION}.tgz" "oci://${HARBOR_REGISTRY}/apus/charts" + helm push "${chart}-${VERSION}.tgz" "oci://${HARBOR_REGISTRY}/apus/charts" \ + --registry-config "${config}" done publish-telemetry-addon: From 03fa72f0d362f1daeb1ece86e6c3edea5679999b Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Thu, 13 Aug 2026 17:11:53 +0200 Subject: [PATCH 16/17] docs(helm): keep the chart artifact and its notes honest sync-crds.sh is a developer script that copies generated CRDs into the chart; it has no purpose inside the published package and is now ignored (confirmed with tar -tzf after helm package). The operator's NOTES.txt claimed a Tenant provisions a network policy (TenantReconciler creates none) and that the /metrics endpoint exists and Prometheus will scrape it (the operator has no HTTP server at all until Phase 8 Task 4 -- a scrape gets connection refused, not empty data). Both READMEs now document that reinstalling under a different release name fails on the CRDs, which carry meta.helm.sh/release-name from the first install and survive uninstall through helm.sh/resource-policy: keep, plus the two ways out. The platform README says plainly that its own resources are unaffected rather than repeating the warning as if it applied there. --- deploy/charts/apus-operator/.helmignore | 4 +++ deploy/charts/apus-operator/README.md | 33 +++++++++++++++++++ .../charts/apus-operator/templates/NOTES.txt | 18 +++++----- deploy/charts/apus-platform/README.md | 26 ++++++++++++--- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/deploy/charts/apus-operator/.helmignore b/deploy/charts/apus-operator/.helmignore index 0e8a0eb..c6422be 100644 --- a/deploy/charts/apus-operator/.helmignore +++ b/deploy/charts/apus-operator/.helmignore @@ -21,3 +21,7 @@ .idea/ *.tmproj .vscode/ +# Developer tooling: sync-crds.sh copies the generated CRDs from deploy/crds/ into +# files/crds/ and only ever runs in this repository. It has no purpose inside the packaged +# chart -- the CRDs it produces are already there. +sync-crds.sh diff --git a/deploy/charts/apus-operator/README.md b/deploy/charts/apus-operator/README.md index 3e5ec60..4b7e786 100644 --- a/deploy/charts/apus-operator/README.md +++ b/deploy/charts/apus-operator/README.md @@ -26,6 +26,39 @@ helm install apus-operator deploy/charts/apus-operator \ `bundles.s3Endpoint` has no default and is enforced by `values.schema.json` — see [Values](#values) below. +### Reinstalling under a different release name + +The six CRDs are annotated `helm.sh/resource-policy: keep`, so `helm uninstall` leaves them +in the cluster together with the `meta.helm.sh/release-name` and `meta.helm.sh/release-namespace` +annotations of the release that first installed them. Installing again under a *different* +release name or in a different namespace therefore fails, for every CRD at once: + +```text +Error: rendered manifests contain a resource that already exists. Unable to continue with +install: CustomResourceDefinition "tenants.bluemap.onelitefeather.net" in namespace "" exists +and cannot be imported into the current release: invalid ownership metadata; annotation +validation error: key "meta.helm.sh/release-name" must equal "apus": current value is +"apus-operator" +``` + +This is not a broken cluster — the CRDs are exactly where they should be, only labelled as +belonging to the old release. Two ways out: + +- Hand the CRDs to the new release: + + ```bash + for crd in tenants worldsources worldingests bluemapmaps bluemaprenders bluemaphostings; do + kubectl annotate crd "${crd}.bluemap.onelitefeather.net" \ + meta.helm.sh/release-name= \ + meta.helm.sh/release-namespace= --overwrite + done + ``` + +- Or leave them out of the new release entirely with `--set crds.install=false`, which is + also the right choice when CRDs are managed separately from the operator. + +Reinstalling under the *same* release name in the same namespace needs neither. + ## Values The table is derived from [`values.yaml`](./values.yaml); every key defined there is diff --git a/deploy/charts/apus-operator/templates/NOTES.txt b/deploy/charts/apus-operator/templates/NOTES.txt index 693e5ec..446525a 100644 --- a/deploy/charts/apus-operator/templates/NOTES.txt +++ b/deploy/charts/apus-operator/templates/NOTES.txt @@ -5,8 +5,9 @@ The apus-operator is installed. kubectl get deployment {{ include "apus-operator.fullname" . }} -n {{ .Release.Namespace }} 2. No tenant exists yet. The operator does nothing until a Tenant custom resource is - created -- it is what causes a namespace, quota and network policy to be provisioned - (design spec §8.1). Create a minimal one: + created -- it is what provisions a namespace with a ResourceQuota, a LimitRange and the + `apus-push-token` Secret (design spec §8.1; the per-tenant NetworkPolicy described there + is not implemented yet). Create a minimal one: cat < Date: Thu, 13 Aug 2026 17:12:02 +0200 Subject: [PATCH 17/17] docs: record the dashboard and pull-secret gaps and repoint phase 9 task 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 9 Task 2 pointed at deploy/base/api-rbac.yaml, a Kustomize file this branch made sure will never exist. The API RBAC now lives only in the apus-platform chart, whose own comment hands the secret narrowing off to that task. Design spec §11 gains two open points that are real but out of the charts' reach: the dashboard cannot be configured at all (empty OIDC values are frozen into the image, NUXT_PUBLIC_* needs a Nitro server the nginx image does not contain, so no installation can log in), and nothing gives the operator-created Jobs and Deployments an image pull secret while their images default to a private Harbor project. Both fixes belong in the UI build and the operator code respectively. §11.1 and §11.4 are updated to what the branch actually built, including that the chart push has not been exercised against the real registry. --- .../2026-08-12-phase-9-produktionshaerte.md | 9 ++-- .../specs/2026-08-13-helm-charts-design.md | 43 ++++++++++++++++--- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md b/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md index e13d513..b93f97f 100644 --- a/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md +++ b/docs/superpowers/plans/2026-08-12-phase-9-produktionshaerte.md @@ -170,7 +170,10 @@ git commit -m "feat: give the runner a dedicated exit code for exhausted storage - Modify: `api/src/main/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepository.java` - Modify: `api/src/test/java/net/onelitefeather/apus/api/rest/push/FabricPushTokenRepositoryTest.java` -- Modify: `deploy/base/api-rbac.yaml` (aus Phase 8, Task 3) +- Modify: `deploy/charts/apus-platform/templates/api-rbac.yaml` — das Kustomize-Overlay + `deploy/base/api-rbac.yaml` aus dem ursprünglichen Phase-8-Plan entsteht nicht mehr; die + API-RBAC lebt seit den Helm-Charts nur noch in diesem Template, dessen Kommentar + ausdrücklich auf „phase 9 task 2" verweist. **Interfaces:** @@ -285,7 +288,7 @@ Expected: PASS, ohne dass ein vorbestehender Test angepasst werden musste. War e - [ ] **Schritt 6: RBAC verengen** -In `deploy/base/api-rbac.yaml` die weite Secret-Regel ersetzen: +In `deploy/charts/apus-platform/templates/api-rbac.yaml` die weite Secret-Regel ersetzen: ```yaml # Service-token lookup, narrowed in phase 9: the API only ever reads the one Secret @@ -318,7 +321,7 @@ Expected: der Wert stimmt exakt mit `resourceNames` überein. Weicht er ab, lies - [ ] **Schritt 9: Commit** ```bash -git add api/ deploy/base/api-rbac.yaml docs/superpowers/specs/2026-08-08-apus-design.md +git add api/ deploy/charts/apus-platform/templates/api-rbac.yaml docs/superpowers/specs/2026-08-08-apus-design.md git commit -m "fix: read only the fixed-name push token secret instead of listing all secrets" ``` diff --git a/docs/superpowers/specs/2026-08-13-helm-charts-design.md b/docs/superpowers/specs/2026-08-13-helm-charts-design.md index 3ff7951..e7b0861 100644 --- a/docs/superpowers/specs/2026-08-13-helm-charts-design.md +++ b/docs/superpowers/specs/2026-08-13-helm-charts-design.md @@ -314,13 +314,44 @@ der CRDs als Templates statt im `crds/`-Verzeichnis liegen. ## 11. Offene Punkte -1. **Harbor-Authentifizierung.** Der Image-Push scheitert aktuell mit `empty challenge header`; - der Chart-Push geht an dieselbe Registry und wird ohne Lösung ebenso scheitern. Zu klären, - bevor der Publish-Job gebaut wird. +1. **Harbor-Authentifizierung.** Der Image-Push scheiterte mit `empty challenge header`, bis + `docker-publish.yml` auf `regctl registry login --skip-check` umgestellt hat — der + anonyme Connectivity-Ping vor dem eigentlichen Push ist die Ursache. Der Chart-Push geht an + dieselbe Registry und vermeidet den Ping jetzt auf demselben Weg: kein + `helm registry login`, stattdessen wird die Credential-Datei direkt geschrieben und per + `--registry-config` an `helm push` übergeben (`release-please.yml`). **Nicht gegen die + echte Registry getestet** — lokal ist nur nachgewiesen, dass `helm push` mit einer + handgeschriebenen Credential-Datei und ohne vorherigen Login gegen eine Registry mit + Basic-Auth durchläuft. Ob Harbor sich beim Push selbst zufriedengibt, zeigt erst der erste + Release-Lauf. 2. **Harbor-Projekt für Charts.** Ob `apus/charts` als Repository-Pfad im bestehenden Projekt `apus` liegt oder ein eigenes Harbor-Projekt bekommt, ist eine Betriebsentscheidung. 3. **Chart-Publishing im zentralen Katalog.** Zunächst repo-eigener Job; die Aufnahme in `OneLiteFeatherNET/workflows` steht an, sobald ein zweites Projekt Charts veröffentlicht. -4. **`values.schema.json`-Umfang.** Der Issuer ist als Pflichtfeld gesetzt. Ob weitere Werte - (Rook-Namen, Bundle-Bucket) ebenfalls erzwungen werden sollen, entscheidet sich beim Bauen - an der Frage, ob ein sinnvoller Default existiert. +4. **`values.schema.json`-Umfang.** Issuer und JWKS-URI sind als Pflichtfelder gesetzt — beide + kommen in `application.yml` ohne Default aus der Umgebung, ein leerer Wert lässt die API + also entweder ungeprüfte Token akzeptieren oder mangels Signaturschlüsseln jedes Token + ablehnen. Ob weitere Werte (Rook-Namen, Bundle-Bucket) ebenfalls erzwungen werden sollen, + entscheidet sich an der Frage, ob ein sinnvoller Default existiert. +5. **Das Dashboard ist gar nicht konfigurierbar.** `ui/nuxt.config.ts` setzt `oidcIssuer` und + `oidcClientId` auf `''`, `ui/Dockerfile` ruft `pnpm generate` ohne Build-Argumente auf, und + ins nginx-Image wandert nur `.output/public`. Damit sind die leeren OIDC-Werte im + veröffentlichten Image eingefroren: `NUXT_PUBLIC_*` wirkt zur Laufzeit nur mit einem + Nitro-Server, den dieses Image nicht enthält. Konsequenz: **keine Installation kann sich + anmelden**, unabhängig davon, was im Chart steht — `apus-platform` reicht die Werte heute + bewusst nur an die API weiter, das UI-Deployment bekommt sie nicht, weil es sie nicht lesen + könnte. Das ist kein Chart-, sondern ein UI-/Build-Problem: die Reparatur ändert, wie das UI + gebaut wird (Build-Args plus `pnpm generate` je Installation, oder ein zur Laufzeit + geladenes `config.json` neben `index.html`, oder doch ein Nitro-Server im Image). Erst + danach ist im Chart überhaupt etwas zu verdrahten. Blockiert damit jede echte + Inbetriebnahme des Dashboards. +6. **Kein Image-Pull-Secret für die vom Operator erzeugten Workloads.** Die Render- und + Ingest-Jobs sowie die Hosting-Deployments, die der Operator baut, tragen weder ein + `imagePullSecrets` noch einen ServiceAccount — die Charts setzen die zugehörigen Images + aber per Default auf ein privates Harbor-Projekt. Auf einem Cluster ohne node-weite + Registry-Credentials bleibt damit jeder Render-Job in `ImagePullBackOff` hängen, während + Operator und API selbst laufen (deren Pull-Secret setzt das Chart). Der Fix gehört in den + Operator-Code (die Ressourcen-Builder in `render`, `ingest`, `hosting`), nicht in die + Charts; die Charts können ihn nur begleiten, indem sie den Namen des Secrets bzw. des + ServiceAccounts als Wert an die Operator-Konfiguration durchreichen. `imagePullSecrets` in + `values.yaml` deckt heute ausschließlich die Pods, die die Charts selbst erzeugen.