Running Zaentrum with the operator
The Zaentrum operator is a Kubernetes controller that turns a single Zaentrum
custom resource into the whole platform — the catalog core, the media pipeline,
the per-product streaming backends, the web/mobile/TV clients, the bundled
Postgres/Valkey/Kafka, and OIDC (roughly 16 services). You declare the platform
you want in one CR; the operator reconciles it and reports progress on the CR
status.
What the operator is, and why
Section titled “What the operator is, and why”The operator renders the entire platform from one canonical Helm chart
(operator/platform/chart) that is go:embed-ed into the operator image. The
chart values map 1:1 onto the Zaentrum CR spec (see the comments in
values.yaml), so the CR is the
values file. On every reconcile the operator:
- Reads the
ZaentrumCR (CRDzaentrums.zaentrum.io, apiVersionzaentrum.io/v1alpha1, kindZaentrum, namespace-scoped). - Builds chart values from the spec and renders the embedded chart as a pure
template engine (client-side render — not
helm install). - Applies every rendered object via server-side apply (SSA), so it owns the fields it manages and continually corrects drift.
- Writes back
status—phase,currentVersion,availableUpdate, and a per-Deploymentcomponents[]list ofready/image— so you can watch the rollout converge.
Because the chart is embedded in the operator image, this is also the day-2
control loop: it reconciles on CR changes and on a resync interval, keeps the
declared replica counts (a raw Deployment edit is reverted on the next pass),
and follows the configured release channel: stable names the newest
release, edge the moving latest (releases). A
new install starts on what its channel serves. With spec.update.mode: manual
(the default) an install then keeps the version it runs and surfaces the next
one on status.availableUpdate; with auto it rolls to it itself. On every
reconcile the operator also resolves each ghcr.io/zaentrum/* image to the
digest of the tag it renders, so a newly published image rolls on its own where
that tag moves — latest, on edge — while a release's tag never does.
A chart change is not a live change. The chart ships inside the operator image. Editing
operator/platform/chart/**has no effect until the operator image is rebuilt and the operator is rolled. See updating.md.
The same CR is the unit of configuration in every deploy topology — the
all-in-one appliance, self-host on your own cluster (see
self-hosting.md), and the reference demo (see
reference-demo.md). The portal's operator console
(/portal/operator) and the zae CLI read and
write this CR.
Install
Section titled “Install”Installing the operator applies its manifest bundle — the CRD, the cluster
RBAC, and the controller-manager Deployment into the zaentrum-operator-system
namespace. This is a one-time, cluster-admin bootstrap step (see
prerequisites.md for cluster requirements).
# a release's operator, for the stable channel (the default):oc apply -f https://github.com/zaentrum/zaentrum-operator/releases/latest/download/operator-install.yaml# or the newest build of main, for edge:# oc apply -f https://raw.githubusercontent.com/zaentrum/zaentrum-operator/main/deploy/operator-install.yaml(kubectl apply -f the same URL on a plain Kubernetes cluster.) A release's manifest pins the
controller to ghcr.io/zaentrum/operator:vX.Y.Z (…/releases/download/vX.Y.Z/… is that one
release); main's pins an immutable ghcr.io/zaentrum/operator:sha-<commit>. Either way an
install is reproducible and rolling back is applying the previous manifest. On OpenShift or
any OLM cluster, the OLM bundle is the alternative — see
the bundle.
This creates, from deploy/operator-install.yaml:
| Object | Name | Purpose |
|---|---|---|
Namespace | zaentrum-operator-system | Where the controller runs. |
CustomResourceDefinition | zaentrums.zaentrum.io | The Zaentrum CR type (shortName stb). |
CustomResourceDefinition | zaentrumaddons.zaentrum.io | The ZaentrumAddon type (shortName zaddon): one addon chart per resource — see addon charts. |
ServiceAccount | zaentrum-operator-controller-manager | The controller identity. |
ClusterRole / ClusterRoleBinding | zaentrum-operator-manager-role | Manage namespaces, Deployments, Services, ConfigMaps, Secrets, ServiceAccounts, PVCs, Jobs, Ingresses, OpenShift Routes, Roles/RoleBindings, zaentrums and zaentrumaddons. |
ClusterRole / ClusterRoleBinding | zaentrum-operator-leader-election-role | Leader-election leases + events. |
Deployment | zaentrum-operator-controller-manager | The controller (/manager --leader-elect, image ghcr.io/zaentrum/operator:<tag>). |
Confirm the controller is running:
oc -n zaentrum-operator-system rollout status deploy/zaentrum-operator-controller-manageroc -n zaentrum-operator-system get podsThen create a namespace for the platform and apply a Zaentrum CR into it
(examples below). Watch it converge with the printer columns from the CRD:
kubectl -n zaentrum get zaentrum -w# NAME PHASE VERSION HOST AGE# zaentrum Reconciling latest zaentrum.localhost 30s# zaentrum Ready latest zaentrum.localhost 3mZaentrum CR spec reference
Section titled “Zaentrum CR spec reference”Every field of spec (authoritative source:
operator/api/v1alpha1/zaentrum_types.go).
Defaults are the CRD-embedded defaults; a self-host deployment can leave most of
them unset.
Top level
Section titled “Top level”| Field | Type | Default | Meaning |
|---|---|---|---|
spec.version | string | latest | Image tag applied to every ghcr.io/zaentrum/* image. |
spec.channel | enum stable | edge | stable | Release train: stable serves the newest release, edge serves latest (releases). |
spec.hostname | string | zaentrum.localhost | The single public host: OIDC issuer host + ingress/route host + Keycloak KC_HOSTNAME. |
spec.partOf | string | the namespace | app.kubernetes.io/part-of label value on all workloads. |
spec.imagePullSecrets | []string | [] | Pull secret names added to every workload (private registries; empty for public ghcr). |
spec.replicas | map[string]int32 | {} | Per-Deployment replica overrides by name, e.g. {"chino-api": 2, "katalog-api": 3}. Unlisted app services stay at 1. Stateful backers (postgres/valkey/kafka/keycloak) are not scalable this way. Set from the portal operator console; the operator reconciles it so the change persists. |
spec.identity — OIDC
Section titled “spec.identity — OIDC”| Field | Type | Default | Meaning |
|---|---|---|---|
identity.mode | enum bundled | external | bundled | bundled ships in-cluster Keycloak + the zaentrum realm import; external uses your own OIDC provider — services validate its tokens directly — and does not render Keycloak. The CRD accepts no other value: broker (ADR-0007) is not built. |
identity.issuer | string | — | Explicit public issuer URL. Empty → derived as <scheme>://<hostname>/auth/realms/zaentrum, in external mode too (it is not rejected), so set it there. |
identity.issuerScheme | enum http | https | http | Scheme of the derived issuer + Keycloak KC_HOSTNAME. Use https when TLS is terminated at the edge — needed for any host but a localhost name, where plain http cannot sign in. |
identity.clientId | string | chino-web | Public OIDC client id the web SPA authenticates as. |
identity.audience | string | chino | Expected token audience services validate against. |
identity.loginTheme | string | — (Keycloak default) | Bundled Keycloak login theme name (the demo uses zaentrum). |
identity.exposeAdminConsole | bool | false | Publish the bundled Keycloak's admin console and admin API on the public host, under /auth/admin (the Ingress or Routes then send /auth whole to Keycloak). Off: the public host routes only what people sign in through — /auth/realms (the login and account pages, the OIDC endpoints) and /auth/resources — and the console answers only through a port-forward, at http://localhost:8080/auth/admin/ after kubectl port-forward svc/keycloak 8080:80 (the admin console). |
Sign-in redirects
Section titled “Sign-in redirects”With bundled identity, a sign-in returns only to the platform's own origins, derived
from hostname and the routing: the public URL — with plain http beside it where
OpenShift Routes serve the host, and https://<routing.hosts.chino> for chino-web in
subdomains routing. chino-web returns to <origin>/auth/callback; the portal and the
catalog consoles (zaentrum-web) to <origin>/portal/, /katalog/ and
/katalog-manage/, each followed by auth/callback; the phone and tablet app to its own
scheme; the TV app signs in with the device grant, and zae keeps its loopback
redirects. Web origins are those origins; signed out, chino-web returns to the bare
origin, the portal and the consoles to their base paths.
A new realm imports those lists. On a realm that exists the operator runs a realm Job
(zaentrum-realm-<suffix>) that writes exactly those lists into these clients whenever
they change, and once a day besides; it changes nothing else of a client and no other
client, and the RealmConfigured condition reports its last run. So the wildcard (*)
redirect URIs and web origins an earlier import gave zaentrum-web, chino-web and
chino-mobile are replaced — and so is a redirect added to these clients by hand. A
client served from any other address needs a client of its own in the realm, made in the
admin console, with the audience the services
validate (identity.audience).
spec.features
Section titled “spec.features”| Field | Type | Default | Meaning |
|---|---|---|---|
features.kafka | bool | true | Run the bundled single-node KRaft broker (kafka:9092, PLAINTEXT). |
features.gpu | bool | false | Enable hardware (NVENC) transcoding on the stream plane (needs a GPU node + device plugin). |
features.pipeline | bool | false | Enable the media pipeline (analyzer / packager / transcoder / katalog-ingest). |
spec.storage
Section titled “spec.storage”| Field | Type | Default | Meaning |
|---|---|---|---|
storage.mediaSize | quantity | 50Gi | Size of the media library PVC. |
storage.className | string | cluster default | Optional StorageClass for platform PVCs. |
storage.provisionMedia | *bool | true | Whether the chart creates the media PVC. Set false when an external PV backs it (e.g. an NFS export). |
storage.kafkaPvc | string | "" | Name of a pre-created PVC to back the bundled Kafka log dir so topics survive a pod restart/reschedule. Empty → ephemeral emptyDir. |
storage.kafkaNode | string | "" | Pin the bundled Kafka broker to a node (kubernetes.io/hostname). Required when kafkaPvc is a node-local volume. Empty → unpinned. |
storage.postgres.size | quantity | 10Gi | Size of the claim made for the bundled Postgres (databases.mode perApp or single), postgres-data, ReadWriteOnce. |
storage.postgres.className | string | storage.className, else cluster default | StorageClass of that claim. |
storage.postgres.claimName | string | "" | An existing PersistentVolumeClaim to keep the data on instead; none is created then. |
storage.postgres.migrate | bool | false | Move a database that lives anywhere else — on the emptyDir an install from before the claim runs on, or on another claim — onto the claim: a Job copies every database into it from the running Postgres, and only once the copy has succeeded does the Postgres switch over. Without it the operator never moves the database, since a Postgres started on a new volume starts empty. |
Bundled Kafka defaults to
emptyDir, so topics are lost on a broker restart unlessstorage.kafkaPvcis set. Topics auto-create and katalog-manager retries transient produce errors, so the pipeline self-heals; settingkafkaPvc(+kafkaNodefor node-local volumes) makes it durable. Switching an existing broker fromemptyDirto a PVC is a known trap — see troubleshooting.md.
The bundled Postgres of a new install starts on its claim, so users, watch state and the catalog outlive the pod. Where its data is, though, is read from the running Postgres before every render, and one that already runs elsewhere — on an
emptyDir, as the chart had it before, or on another claim — stays there untilstorage.postgres.migratecopies it over. TheDatabasePersistentcondition says which:OnClaim(True) on its claim;EmptyDirorOtherClaim(False) where it ran before;Migrating(False) while the copy runs;Migrated(True) once the copy succeeded, as the Postgres switches;MigrationFailed(False), with the reason, when the copy failed and the Postgres stays. Writes made while it copies, until the switch, are not carried across — see the bundled Postgres in the operator's README. Withdatabases.mode: externalthere is no claim, no copy and no condition.
spec.network
Section titled “spec.network”| Field | Type | Default | Meaning |
|---|---|---|---|
network.issuerHostAliasIP | string | "" | Adds a hostAliases entry (this IP → the public host) to the OIDC validators so in-cluster token validation reaches an edge-terminated HTTPS issuer (split-horizon). Empty → no hostAliases. |
spec.routing
Section titled “spec.routing”| Field | Type | Default | Meaning |
|---|---|---|---|
routing.provisionIngress | *bool | true | Render a plain-Kubernetes Ingress (single-origin paths). |
routing.provisionRoutes | *bool | false | Render OpenShift Routes (single-origin paths). Enable on OKD/OpenShift. |
spec.secrets
Section titled “spec.secrets”| Field | Type | Default | Meaning |
|---|---|---|---|
secrets.external | bool | false | true → the platform's Secrets are pre-created (e.g. by CI) and left alone: the operator makes, reads and reports none of them. false → the operator generates each Secret the platform needs once, from crypto/rand, owns it and never rotates it — zaentrum-db, zaentrum-stream-signing and, with bundled identity, zaentrum-keycloak, zaentrum-keycloak-admin (the first administrator's one-time password among them) and zaentrum-demo-user. |
spec.databases
Section titled “spec.databases”| Field | Type | Default | Meaning |
|---|---|---|---|
databases.mode | string perApp | single | external | perApp | perApp gives each service its own database; single shares one — both on the bundled Postgres, which keeps its data on the claim storage.postgres describes. external uses your own Postgres (databases.external.host, .port, .sslmode) and renders none. |
databases.chino | string | chino | chino database name. |
databases.katalog | string | katalog | katalog database name. |
databases.keycloak | string | keycloak | keycloak database name. |
databases.portal | string | portal | portal database name. |
spec.keycloak
Section titled “spec.keycloak”| Field | Type | Default | Meaning |
|---|---|---|---|
keycloak.image | string | quay.io/keycloak/keycloak:26.0.7 | Bundled Keycloak container image (bundled identity mode only). |
spec.update
Section titled “spec.update”| Field | Type | Default | Meaning |
|---|---|---|---|
update.mode | enum manual | auto | manual | manual never bumps spec.version on its own: an install keeps the version it runs and reports the channel's next one in status.availableUpdate; auto lets the reconciler move to the channel's tag. A channel on latest (edge) is followed in both. |
Status (read-only)
Section titled “Status (read-only)”status.phase, status.currentVersion, status.availableUpdate,
status.observedGeneration, status.conditions[] (standard Kubernetes
conditions — among them SecretsGenerated, which says where the first
administrator's one-time password is, or which Secrets still hold an earlier
chart's values; DatabasePersistent, which says where the bundled Postgres
keeps its data; and RealmConfigured, the last run of the realm Job that keeps
the sign-in redirects in step), and status.components[]
(name, ready, image per managed Deployment).
Example CRs
Section titled “Example CRs”Minimal self-host
Section titled “Minimal self-host”A single-node self-host on your own cluster: bundled identity, bundled Kafka, a 50 GiB media PVC on the default StorageClass, plain-Kubernetes Ingress, manual updates. Everything else takes its default.
apiVersion: zaentrum.io/v1alpha1kind: Zaentrummetadata: name: zaentrum namespace: zaentrumspec: version: latest hostname: zaentrum.example.com # the one public host — set to your real host identity: mode: bundled issuerScheme: https # TLS in front of the Ingress; plain http signs in on localhost only clientId: chino-web audience: chino features: kafka: true # bundled broker gpu: false pipeline: true # scan → enrich → analyze → transcode → package storage: mediaSize: 50Gi kafkaPvc: kafka-log # optional: durable topics (pre-create the PVC) routing: provisionIngress: true update: mode: manualSet spec.hostname to the public host you will reach Zaentrum at. In bundled
mode the operator derives the OIDC issuer, the ingress/route host, and Keycloak's
KC_HOSTNAME from that single name. Serve that host over https: Keycloak marks
its login cookies Secure, so over plain http only a localhost name can sign
in, and the Android phone and TV apps refuse http. The Ingress carries no TLS
section — terminate TLS in front of it, and see
split-horizon issuer resolution.
When status.phase reaches Ready, open
https://<hostname> and sign in as admin with the one-time password the operator
generated for this install; Keycloak has you choose a new one at that first sign-in. The
SecretsGenerated condition says where the password is, never what it is:
kubectl -n zaentrum get zaentrum zaentrum \ -o jsonpath='{.status.conditions[?(@.type=="SecretsGenerated")].message}'; echokubectl -n zaentrum get secret zaentrum-keycloak-admin \ -o jsonpath='{.data.realm-admin-password}' | base64 -d; echoAn install made from an earlier chart keeps the values that chart shipped to every
install. SecretsGenerated is then False, reason PublishedDefaults, and names each
Secret and key; the operator replaces none of them, as each has to change where it is used
first — see
the platform's Secrets
in the operator's README. The rest of the first run is a TMDB key and your library, which the
portal's Set Up Your Server checklist walks an admin through — see
self-hosting.md. Further accounts are
made in Keycloak's admin console, which is not on the public host unless
identity.exposeAdminConsole publishes it: a port-forward reaches it, as the master
realm's bootstrap admin — see the admin console.
Reference-demo profile
Section titled “Reference-demo profile”The public demo (zaentrum.demo.nalet.cloud, namespace zaentrum-demo) runs on
an OKD cluster with edge-terminated TLS, an external NFS PV for media, and
CI-created secrets. It corresponds to
values-demo.yaml. Full deploy
mechanics are in reference-demo.md.
apiVersion: zaentrum.io/v1alpha1kind: Zaentrummetadata: name: zaentrum namespace: zaentrum-demospec: version: latest hostname: zaentrum.demo.nalet.cloud partOf: zaentrum-demo imagePullSecrets: [] # ghcr.io/zaentrum/* is public: leave EMPTY. Name secrets here only for a # private mirror — a dead credential makes even a public image unpullable. identity: mode: bundled issuerScheme: https # TLS terminated at the OpenShift router loginTheme: zaentrum features: kafka: true pipeline: true # the demo runs the full media pipeline gpu: true # NVENC on a GPU node storage: provisionMedia: false # an external NFS PV backs the media PVC kafkaPvc: kafka-log # node-local PV → durable topics kafkaNode: <node> # pin the broker to the node holding that PV network: issuerHostAliasIP: "<router-ip>" # HostNetwork router IP → split-horizon issuer routing: provisionIngress: false provisionRoutes: true # single-origin OpenShift Routes secrets: external: true # zaentrum-* secrets pre-created by CIThe demo deliberately keeps a few things the operator does not render: the
media PVC (storage.provisionMedia: false), the CI-created secrets
(secrets.external: true), and the seed/scan/enqueue/kafka-topics choreography
Jobs. See reference-demo.md.
Updating
Section titled “Updating”- A CR value change (e.g. bump
spec.version, changespec.replicas): edit the CR and re-apply; the operator reconciles it via SSA. - An app image change: push the app repo → GitHub Actions builds
ghcr.io/zaentrum/<app>:latest; roll the app tier to pull it (the demo CI does arollout restart, excluding postgres/valkey/kafka). - A chart or operator-code change: rebuild and roll the operator image — the embedded chart re-renders only after that. The controller itself is updated outside the platform, by the channel that installed it: see updating the operator.
For the operator's own update loop (spec.update.mode / spec.channel), the
reconciler resolves the channel and surfaces its tag on status.availableUpdate
when the install runs something else; auto rolls it, manual waits for it to
be applied. How releases reach the channels: releases.
Updating from the command line
Section titled “Updating from the command line”A CR value change — the first case above, and the one you make most often —
needs no cluster access. The zae CLI
drives it through the portal's operator console with an admin bearer:
zae login --url https://zaentrum.example.orgzae platform status --url https://zaentrum.example.org # version, channel, update mode, phase, workloads, controllerzae platform controller --url https://zaentrum.example.org # what controller is in charge, and what updates itzae platform update --apply --wait --url https://zaentrum.example.org| It can | Which CR field |
|---|---|
| Pin a version, or follow a channel again | spec.version |
| Choose the release train | spec.channel |
| Let the operator update itself, or not | spec.update.mode |
| Apply the update the operator discovered | spec.version ← status.availableUpdate |
| Scale or restart one workload | spec.replicas[<name>], or the Deployment |
| Show the controller in charge, and name what updates it (never do it) | status.controller (read-only) |
It prints what will change, asks, and with --wait follows the rollout. The
wait is about that write: the portal answers it with the CR's new
metadata.generation, and zae waits until status.observedGeneration has
reached it, status.currentVersion is the new version, and every
operator-managed Deployment has finished rolling — observedGeneration caught
up, every pod from the new revision, no pod from the old one left, all of
them available. Waiting on the replica counts alone passes mid-rollout: a
one-replica service surges, so for the whole startup of the new pod the
counters are the right size and are describing the old one. Stateful services
are protected and refused. The full reference, including what a restart and a
scale wait for, is
the CLI contract.
The controller itself is shown from the CLI, never updated by it. The
controller-manager runs in zaentrum-operator-system, outside the platform
namespace and outside the portal's permissions, so nothing that talks to the
portal can change its image — but the operator reports itself on
status.controller, so zae platform controller (and the portal's operator
console) can say which build is in charge, whether something newer exists, and
which of the three channels updates it: an OLM subscription, the pinned
deploy/operator-install.yaml
applied as cluster-admin (normally through your deployment repository), or the
appliance's own update. That is the third case above — the embedded chart
re-renders on the next reconcile once the new controller runs. The full page is
updating the operator; the platform's own updates
are updating.md.
See also: prerequisites.md · self-hosting.md · reference-demo.md · updating.md · updating-the-operator.md · troubleshooting.md