Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
52feb55
docs: design the Helm charts that replace the planned Kustomize base
TheMeinerLP Aug 13, 2026
1f16d04
docs: plan the Helm chart implementation
TheMeinerLP Aug 13, 2026
3903e1c
feat(helm): scaffold the apus-operator chart
TheMeinerLP Aug 13, 2026
eaa0a48
feat(helm): ship the CRDs as templates that upgrade cleanly
TheMeinerLP Aug 13, 2026
deccdc2
feat(helm): deploy the operator with its service account and RBAC
TheMeinerLP Aug 13, 2026
d5cee74
feat(helm): add metrics wiring, values schema and operator chart docs
TheMeinerLP Aug 13, 2026
01d1627
feat(helm): add the apus-platform chart with the API deployment
TheMeinerLP Aug 13, 2026
b5396b9
feat(helm): add the dashboard, ingress and values schema to apus-plat…
TheMeinerLP Aug 13, 2026
aecbdc7
feat(helm): version the charts with the release and publish them to H…
TheMeinerLP Aug 13, 2026
458646d
ci: lint, render and schema-check the Helm charts on pull requests
TheMeinerLP Aug 13, 2026
1c74fa4
ci: pin azure/setup-helm to v4.2.2 for stable schema error format
TheMeinerLP Aug 13, 2026
62c4d1c
docs: replace the Kustomize tasks in the phase 8 plan with the Helm c…
TheMeinerLP Aug 13, 2026
dcfcb5a
fix(helm): let the operator create the push-token secret and cert-man…
TheMeinerLP Aug 13, 2026
494baa0
ci: validate the rendered charts offline and require auth.jwksUri
TheMeinerLP Aug 13, 2026
ebdc4ea
ci: push the charts without helm registry login
TheMeinerLP Aug 13, 2026
03fa72f
docs(helm): keep the chart artifact and its notes honest
TheMeinerLP Aug 13, 2026
7076a20
docs: record the dashboard and pull-secret gaps and repoint phase 9 t…
TheMeinerLP Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 48 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
27 changes: 27 additions & 0 deletions deploy/charts/apus-operator/.helmignore
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions deploy/charts/apus-operator/Chart.yaml
Original file line number Diff line number Diff line change
@@ -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
119 changes: 119 additions & 0 deletions deploy/charts/apus-operator/README.md
Original file line number Diff line number Diff line change
@@ -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=<new-release> \
meta.helm.sh/release-namespace=<new-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 <release>`) for how
to create your first `Tenant`.
Loading
Loading