skip to content
docsspec
github

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.

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:

  1. Reads the Zaentrum CR (CRD zaentrums.zaentrum.io, apiVersion zaentrum.io/v1alpha1, kind Zaentrum, namespace-scoped).
  2. Builds chart values from the spec and renders the embedded chart as a pure template engine (client-side render — not helm install).
  3. Applies every rendered object via server-side apply (SSA), so it owns the fields it manages and continually corrects drift.
  4. Writes back status — phase, currentVersion, availableUpdate, and a per-Deployment components[] list of ready/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.

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).

Terminal window
# 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:

ObjectNamePurpose
Namespacezaentrum-operator-systemWhere the controller runs.
CustomResourceDefinitionzaentrums.zaentrum.ioThe Zaentrum CR type (shortName stb).
CustomResourceDefinitionzaentrumaddons.zaentrum.ioThe ZaentrumAddon type (shortName zaddon): one addon chart per resource — see addon charts.
ServiceAccountzaentrum-operator-controller-managerThe controller identity.
ClusterRole / ClusterRoleBindingzaentrum-operator-manager-roleManage namespaces, Deployments, Services, ConfigMaps, Secrets, ServiceAccounts, PVCs, Jobs, Ingresses, OpenShift Routes, Roles/RoleBindings, zaentrums and zaentrumaddons.
ClusterRole / ClusterRoleBindingzaentrum-operator-leader-election-roleLeader-election leases + events.
Deploymentzaentrum-operator-controller-managerThe controller (/manager --leader-elect, image ghcr.io/zaentrum/operator:<tag>).

Confirm the controller is running:

Terminal window
oc -n zaentrum-operator-system rollout status deploy/zaentrum-operator-controller-manager
oc -n zaentrum-operator-system get pods

Then 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:

Terminal window
kubectl -n zaentrum get zaentrum -w
# NAME PHASE VERSION HOST AGE
# zaentrum Reconciling latest zaentrum.localhost 30s
# zaentrum Ready latest zaentrum.localhost 3m

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.

FieldTypeDefaultMeaning
spec.versionstringlatestImage tag applied to every ghcr.io/zaentrum/* image.
spec.channelenum stable | edgestableRelease train: stable serves the newest release, edge serves latest (releases).
spec.hostnamestringzaentrum.localhostThe single public host: OIDC issuer host + ingress/route host + Keycloak KC_HOSTNAME.
spec.partOfstringthe namespaceapp.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.replicasmap[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.
FieldTypeDefaultMeaning
identity.modeenum bundled | externalbundledbundled 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.issuerstring—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.issuerSchemeenum http | httpshttpScheme 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.clientIdstringchino-webPublic OIDC client id the web SPA authenticates as.
identity.audiencestringchinoExpected token audience services validate against.
identity.loginThemestring— (Keycloak default)Bundled Keycloak login theme name (the demo uses zaentrum).
identity.exposeAdminConsoleboolfalsePublish 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).

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).

FieldTypeDefaultMeaning
features.kafkabooltrueRun the bundled single-node KRaft broker (kafka:9092, PLAINTEXT).
features.gpuboolfalseEnable hardware (NVENC) transcoding on the stream plane (needs a GPU node + device plugin).
features.pipelineboolfalseEnable the media pipeline (analyzer / packager / transcoder / katalog-ingest).
FieldTypeDefaultMeaning
storage.mediaSizequantity50GiSize of the media library PVC.
storage.classNamestringcluster defaultOptional StorageClass for platform PVCs.
storage.provisionMedia*booltrueWhether the chart creates the media PVC. Set false when an external PV backs it (e.g. an NFS export).
storage.kafkaPvcstring""Name of a pre-created PVC to back the bundled Kafka log dir so topics survive a pod restart/reschedule. Empty → ephemeral emptyDir.
storage.kafkaNodestring""Pin the bundled Kafka broker to a node (kubernetes.io/hostname). Required when kafkaPvc is a node-local volume. Empty → unpinned.
storage.postgres.sizequantity10GiSize of the claim made for the bundled Postgres (databases.mode perApp or single), postgres-data, ReadWriteOnce.
storage.postgres.classNamestringstorage.className, else cluster defaultStorageClass of that claim.
storage.postgres.claimNamestring""An existing PersistentVolumeClaim to keep the data on instead; none is created then.
storage.postgres.migrateboolfalseMove 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 unless storage.kafkaPvc is set. Topics auto-create and katalog-manager retries transient produce errors, so the pipeline self-heals; setting kafkaPvc (+ kafkaNode for node-local volumes) makes it durable. Switching an existing broker from emptyDir to 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 until storage.postgres.migrate copies it over. The DatabasePersistent condition says which: OnClaim (True) on its claim; EmptyDir or OtherClaim (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. With databases.mode: external there is no claim, no copy and no condition.

FieldTypeDefaultMeaning
network.issuerHostAliasIPstring""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.
FieldTypeDefaultMeaning
routing.provisionIngress*booltrueRender a plain-Kubernetes Ingress (single-origin paths).
routing.provisionRoutes*boolfalseRender OpenShift Routes (single-origin paths). Enable on OKD/OpenShift.
FieldTypeDefaultMeaning
secrets.externalboolfalsetrue → 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.
FieldTypeDefaultMeaning
databases.modestring perApp | single | externalperAppperApp 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.chinostringchinochino database name.
databases.katalogstringkatalogkatalog database name.
databases.keycloakstringkeycloakkeycloak database name.
databases.portalstringportalportal database name.
FieldTypeDefaultMeaning
keycloak.imagestringquay.io/keycloak/keycloak:26.0.7Bundled Keycloak container image (bundled identity mode only).
FieldTypeDefaultMeaning
update.modeenum manual | automanualmanual 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.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).

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/v1alpha1
kind: Zaentrum
metadata:
name: zaentrum
namespace: zaentrum
spec:
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: manual

Set 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:

Terminal window
kubectl -n zaentrum get zaentrum zaentrum \
-o jsonpath='{.status.conditions[?(@.type=="SecretsGenerated")].message}'; echo
kubectl -n zaentrum get secret zaentrum-keycloak-admin \
-o jsonpath='{.data.realm-admin-password}' | base64 -d; echo

An 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.

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/v1alpha1
kind: Zaentrum
metadata:
name: zaentrum
namespace: zaentrum-demo
spec:
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 CI

The 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.

  • A CR value change (e.g. bump spec.version, change spec.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 a rollout 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.

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:

Terminal window
zae login --url https://zaentrum.example.org
zae platform status --url https://zaentrum.example.org # version, channel, update mode, phase, workloads, controller
zae platform controller --url https://zaentrum.example.org # what controller is in charge, and what updates it
zae platform update --apply --wait --url https://zaentrum.example.org
It canWhich CR field
Pin a version, or follow a channel againspec.version
Choose the release trainspec.channel
Let the operator update itself, or notspec.update.mode
Apply the update the operator discoveredspec.version ← status.availableUpdate
Scale or restart one workloadspec.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