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

Rendering

The agent does one resolution, through the same engine every binding uses, and writes the resolved document — so the file on disk is what an in-process consumer would have computed, not a second dialect.

The output format follows --out's extension: .json, .toml, .yaml, .ini, .properties.

The flat formats are legal here and refused by the engine's save — both on purpose. save's contract is a typed round trip, which a string-widening format cannot keep. A rendered file for a consumer is a different contract, and the agent owns it, stated:

  • Nested tables become dotted keys (properties) or sections (INI).
  • A string that would widen on the way back in — "1.10", "true" — is double-quoted in INI, so the round trip through the engine's own parser answers the same document. There is a test that holds exactly this.
  • Arrays are refused, by path. Neither format has them; inventing an encoding would be a dialect of one. Render to json/toml/yaml when the document has lists.

Writes are write-then-rename, so a watching application sees whole files — the same courtesy an atomic-save editor pays, and the reason the engine's own watcher tolerates a 25ms grace.

On a fetch failure the sidecar keeps the last rendered file and says so in its log — keep-last-good, the organisation's standing behaviour. An init run with nothing yet rendered fails instead, which fails the pod, which is what an init container is for.

Templates

Without one, the agent renders the resolved document verbatim — same keys, same shapes, only the format changes with the extension. That is the right default and it stays the default.

A template takes over when verbatim cannot serve: an application that wants DATABASE_URL=postgres://… assembled from three keys, a framework with its own nesting, a file with a header. The template owns the output bytes, which also frees the extension — .env and .conf become legal exactly there.

apiVersion: v1
kind: ConfigMap
metadata:
  name: billing-template
data:
  template: |
    DATABASE_URL=postgres://{{ db.user }}@{{ db.host }}:{{ db.port }}/billing
    BETA={{ flags.beta }}
    dynamic-config.rs/path: "/config/app.env"
    dynamic-config.rs/template-configmap: "billing-template"
    # or, for one-liners:
    dynamic-config.rs/template: "db={{ db.host }}:{{ db.port }}"

The syntax is minijinja's — Jinja2: {{ value }}, {% for %}, {% if %}, filters. The template's context is the resolved document, the same value every binding reads, so a template cannot see anything the application could not.

The semantics that matter in production:

  • Undefined is strict. {{ db.hots }} is a render error, not an empty string — a typo that silently renders nothing would ship a broken file with a clean exit code. At startup the error is fatal and lands in the pod's events; during a watch, the running pod keeps its last good file, like any fetch failure.
  • Booleans render as true/false, not Python's True — a template writes config files, and every format this agent speaks spells them lowercase.
  • The trailing newline survives. Env files want one; what the template author wrote is what lands.
  • The ConfigMap is re-read at every render, so editing the template takes effect on the next tick — no rollout. It is also why the template belongs in a ConfigMap: it is code, and it gets reviewed and versioned like code.
  • template and template-configmap together are refused at admission: one template, one place.

The filters this agent adds

minijinja's built-ins, plus six that a configuration template needs and minijinja either does not ship or ships behind a feature this build does not carry:

filterfor
b64encodea Kubernetes Secret's data is base64, so a template writing one has to encode
b64decodethe other direction; a value that is not base64, or not UTF-8 once decoded, is a render error rather than mojibake
jsontojson is behind a disabled feature, and a template that cannot emit JSON is missing the format half its consumers read
yamlthe same, for the other half
quotea password with a # in it ends a line in half the formats here
requiredstrict undefined already refuses a missing key; this refuses one that is present and empty, which is the shape a missing secret usually arrives in. {{ db.password | required("no password in the vault path") }} names the field instead of writing a blank one

What is not here, and will not be: a filter that reaches the network, the filesystem or the environment. The pipeline is fetch → resolve → validate → template, and a template that could fetch would make the same input render differently on two pods.

Checking the document before it is published

    dynamic-config.rs/schema-configmap: "billing-schema"   # or "billing-schema/other-key.json"

The resolved document is validated against a JSON Schema before anything is written. A document that fails is refused, the last good file keeps serving, and the failure is a log line and a counter — the same shape as any other render failure.

The bindings already validate: a Rust, Python or Node application gets a typed refusal from the engine. This is the door for everyone else — the Java service reading a .properties file, the daemon reading YAML — for whom a port: "abc" would otherwise be discovered at startup, one restart after the bad document was published.

The schema is re-read every render, like a template, so tightening it does not need a rollout.

Several files, one fetch

    dynamic-config.rs/path: "/config/app.yaml"
    dynamic-config.rs/also.db: "/config/db.env"
    dynamic-config.rs/also-section.db: "database"

More files cut from the same fetched document, published all or none: one fetch, one generation, and a failure in the third does not leave the first two on disk. Each file is still its own atomic rename — a reader can catch the gap between two of them, which is a rename apart rather than a fetch apart.

Several stores cannot share a generation. Two stores have no common instant, and no protocol either of them speaks can say "these two reads are the same version", so a second store is a named render with a generation of its own.

What the application is running

    dynamic-config.rs/meta: "true"

Writes a sibling file — /config/app.yaml gets /config/.app.yaml.meta — holding the SHA-256 of the rendered bytes, the store's own revision, and when the render landed. Same atomic rename, same mode.

It answers a question an application cannot otherwise ask about itself: which configuration am I running? Two pods holding the same file is a claim nobody can check from inside either of them; two pods printing the same digest is one anybody can. It describes the render and never contains it — no values reach it, ever.