diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 876912e..6584c2f 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -53,3 +53,93 @@ 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 + 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 + 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 \ + --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. + # 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 + # 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 + 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: 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: | + # 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/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index b6dbc51..c147861 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -151,6 +151,54 @@ 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 + 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 }} + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + VERSION: ${{ needs.release-please.outputs.root-version }} + run: | + # 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" \ + --registry-config "${config}" + done + publish-telemetry-addon: needs: release-please if: needs.release-please.outputs.telemetry-released == 'true' diff --git a/deploy/charts/apus-operator/.helmignore b/deploy/charts/apus-operator/.helmignore new file mode 100644 index 0000000..c6422be --- /dev/null +++ b/deploy/charts/apus-operator/.helmignore @@ -0,0 +1,27 @@ +# 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/ +# 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/Chart.yaml b/deploy/charts/apus-operator/Chart.yaml new file mode 100644 index 0000000..3f59909 --- /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.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 +maintainers: + - name: OneLiteFeather + url: https://onelitefeather.net +keywords: + - minecraft + - bluemap + - operator diff --git a/deploy/charts/apus-operator/README.md b/deploy/charts/apus-operator/README.md new file mode 100644 index 0000000..4b7e786 --- /dev/null +++ b/deploy/charts/apus-operator/README.md @@ -0,0 +1,119 @@ +# 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. + +### 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 +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/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/NOTES.txt b/deploy/charts/apus-operator/templates/NOTES.txt new file mode 100644 index 0000000..446525a --- /dev/null +++ b/deploy/charts/apus-operator/templates/NOTES.txt @@ -0,0 +1,44 @@ +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 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 <-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. +- `auth.jwksUri` 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/_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/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..2b0e2f9 --- /dev/null +++ b/deploy/charts/apus-platform/values.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["auth", "api", "ui"], + "properties": { + "auth": { + "type": "object", + "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." + } + } + } + } +} diff --git a/deploy/charts/apus-platform/values.yaml b/deploy/charts/apus-platform/values.yaml new file mode 100644 index 0000000..b07e1d7 --- /dev/null +++ b/deploy/charts/apus-platform/values.yaml @@ -0,0 +1,98 @@ +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: + # 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: "" + 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 + podAnnotations: {} + podLabels: {} + nodeSelector: {} + tolerations: [] + affinity: {} + +ingress: + enabled: false + className: nginx + annotations: {} + host: "" + tls: + enabled: false + secretName: "" + issuerRef: + name: "" + kind: ClusterIssuer 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: {} 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/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/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. 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): 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..e7b0861 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-helm-charts-design.md @@ -0,0 +1,357 @@ +# 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 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.** 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. 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": {