Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Operator

Shipped as of 0.1.1: a DynamicConfigRender becomes a ConfigMap, reconciled through the SAME source construction and rendering the sidecar agent uses — one implementation, no drift between the two paths a document can take into a pod. The CRDs stay generated from the Rust types (deploy/crds.json, drift-gated in CI), and the e2e suite drives the full loop: apply, render, propagate, delete, garbage-collect.

DynamicConfigClass — name the store once

The class bundles source, endpoint and the token Secret, so pods stop repeating them:

apiVersion: dynamic-config.rs/v1alpha1
kind: DynamicConfigClass
metadata:
  name: infra-consul
  namespace: billing
spec:
  source: consul
  endpoint: http://consul.infra.svc:8500
  tokenSecret: consul-agent-token     # optional; its `token` key travels

A pod then says only what is its own:

metadata:
  annotations:
    dynamic-config.rs/inject: "true"
    dynamic-config.rs/class: "infra-consul"
    dynamic-config.rs/key: "myapp/config.json"
    dynamic-config.rs/path: "/config/rendered.toml"

The long-form annotations keep working forever; the class is sugar over them, not a replacement.

DynamicConfigRender — a ConfigMap instead of a sidecar

For workloads that cannot take an injected container — third-party charts, jobs, anything sidecar-averse — the operator renders into a ConfigMap on a cadence, and the workload mounts it like any other:

apiVersion: dynamic-config.rs/v1alpha1
kind: DynamicConfigRender
metadata:
  name: billing-config
  namespace: billing
spec:
  class: infra-consul
  key: myapp/config.json
  target:
    configMap: billing-rendered
    file: config.properties        # the extension picks the format here too
  intervalSeconds: 30
status:                             # written by the operator
  renderedAt: "2026-08-18T09:00:00Z"
  lastError: null                   # kind and path only, never a value

Classes: namespaced, or cluster-scoped

Two class kinds, the same split External Secrets Operator drew between SecretStore and ClusterSecretStore:

  • DynamicConfigClass (namespaced) — a team's own store, its tokenSecret read from the same namespace. Self-service, blast radius one namespace.
  • ClusterDynamicConfigClass (cluster-scoped) — the platform team's store, defined once. Its credential names the namespace it lives in explicitly, so tenants reference the class without ever being able to read the credential:
apiVersion: dynamic-config.rs/v1alpha1
kind: ClusterDynamicConfigClass
metadata:
  name: platform-consul
spec:
  source: consul
  endpoint: http://consul.infra.svc:8500
  tokenSecret:
    name: consul-token
    namespace: platform        # RBAC keeps tenants out of here
    key: token                 # `token` by default
  namespaces: [team-a, team-b] # the allowlist; absent = every namespace

A tenant's Render opts in by kind:

spec:
  class: platform-consul
  classKind: ClusterDynamicConfigClass

Two rules, out loud. The allowlist is enforced at reconcile: a Render in a namespace the class does not list fails with the class named, in status.lastError — the platform team's boundary, not a convention. And the credential read happens with the operator's identity, which is why the operator's RBAC has cluster get on Secrets: the tenant's own service account never touches the platform namespace. Editing a cluster class re-renders every Render referencing it, in every namespace — the same live wiring the namespaced class has.

Targets: a ConfigMap, or a Secret

target names exactly one destination:

  target:
    configMap: billing-rendered   # workloads that mount files
    file: config.properties
  target:
    secret: myapp-env             # workloads that read SECRETS natively
    shape: envEntries             # …or take environment through envFrom

The Secret target is for the consumers the file path cannot reach: an operator that watches Kubernetes Secrets reacts to every reconcile with no pod restart — the Secret is a live object — and an envFrom block turns shape: envEntries (every leaf of the resolved document, dotted paths upper-snaked: db.pool_size → DB_POOL_SIZE) into environment variables at the next container start. Environment freezes at start; that is Kubernetes' rule, and this page will not pretend otherwise. A vault class is allowed into the Secret target — that is the container a secret store's document belongs in — while the ConfigMap target keeps refusing it.

Feeding a name someone else already chose

The commonest enterprise shape: a helm chart or an operator demands a Secret by name — auth.existingSecret in half the chart ecosystem, a secretName: field in an operator's CRD — and refuses env vars or files. That named Secret is exactly what a Render produces:

spec:
  class: platform-vault
  classKind: ClusterDynamicConfigClass
  key: secret/postgres
  target:
    secret: pg-credentials
    shape: entries          # leaf keys VERBATIM: postgres-password, …
helm install db bitnami/postgresql --set auth.existingSecret=pg-credentials

Three shapes, three contracts: file when the consumer wants one document under one key; envEntries when it reads through envFrom (keys upper-snaked to the env dialect); entries when the key names are someone else's contract — every leaf verbatim, postgres-password staying postgres-password, because any mangling breaks a name you do not own. The password never appears in values, in git, or in a developer's hands; the store document is the single source, and the Secret follows it on the Render's interval.

Deleting the Render deletes its target unless it says otherwise:

spec:
  target:
    secret: billing-credentials
    deletionPolicy: Retain          # or Delete, the default

Delete owns the target via ownerReferences, so the cleanup is Kubernetes' own garbage collector — no finalizer to get wrong. Retain writes no owner reference at all, which is the right answer for a Secret something else still needs: deleting the Render that produced a credential should not be the same act as revoking it everywhere.

status.renderedAt says when the last render landed and status.lastError carries kind and shape only, never a value. status.observedGeneration is the metadata.generation this status was written for, so a controller or a kubectl wait can tell "this succeeded" from "this has not been looked at since it changed".

Why a render is not ready

The Ready condition carries a machine-readable reason, and the three are kept apart because they belong to different people:

reasonwhat it meanswho fixes it
ClassNotFoundthe class this render names does not existwhoever wrote the Render
ClassNotAllowedit exists, and does not admit this namespacethe platform team that owns the class
RenderFailedthe store could not be read, or the document could not be rendereddepends on the message

Before 0.3.0 all three were RenderFailed, so every alert grepped a message. These strings are API on the same terms as the annotation contract: a dashboard aggregates on them, and a rename is a breaking change.

Two honesty notes, stated before the reconciler shipped and still true:

  • ConfigMap propagation is slow — the kubelet syncs mounted ConfigMaps on its own cadence (up to a minute-plus; the engine book's Kubernetes Files page walks the mechanism). The sidecar's emptyDir is the low-latency path; DynamicConfigRender trades latency for no-sidecar.
  • A ConfigMap is not a Secret. The reconciler refuses vault classes into ConfigMaps with exactly that sentence; the secret: target is the lift, and the refusal message points at it.

The class annotation — still ahead

dynamic-config.rs/class on a pod (the webhook resolving a class so annotations shrink) is contract-only: it needs the webhook to read CRs, which trades away its zero-RBAC posture the way selfRotate does, and that trade is taken per-feature, not by default.

What the operator will not do

Own lifecycles inside pods, restart workloads on render, or template documents. It renders and it reports; reacting is the workload's business, and the whole engine exists so reacting is cheap.