Persistence & Writing
save
#![allow(unused)] fn main() { use dynamic_config::{save, Format}; save(&config, "config.toml", Format::Toml, "db")?; }
save is a free function taking any Serialize value: writing a file is an
operation on a value, not a property of a type, so nothing needs to be
declared to use it. The output is nested under the given key, so what comes
out can be read straight back in by a builder with the same key. Written
through a temporary file and renamed, because the watcher is very likely
watching that directory and a partial file would look like a broken edit.
Secrets are written in the clear — #[config(secret)] keeps a value out
of logs, not out of a file the program was asked to write. On Unix the file is
created 0600.
save_new and save_encrypted
Two companions to save, with the same atomic write:
save_new(&value, path, format, key)— the same, refusing if the file exists, for a setup wizard that must not overwrite what somebody wrote.save_encrypted(&value, path, format, key, &encryptor)— the same, encrypted to a recipient list, the counterpart to reading asecrets.json.age. Requires thedecryptfeature; see Encryption.
save and the cache create their file 0600 and refuse to follow a symlink
planted at the temporary path.
cache
#![allow(unused)] fn main() { use dynamic_config::CacheMode; DbConfig::builder("db") .file("config.toml") .cache("/var/lib/app/last.json", CacheMode::Redacted) .init()?; }
Writes the resolved configuration to that path after every clean init() and
every clean reload, and reads it back if a cold start fails. A failed reload never touches it — a
running process already has something better to fall back on, the snapshot it is
currently serving.
Recovery is loud: it logs a warning naming what failed, because a service quietly running on yesterday's configuration is its own kind of outage. See Last known good for what ends up on disk.
Cache modes
The second argument to .cache(path, mode) is a CacheMode:
CacheMode::Redacted, CacheMode::Full or CacheMode::Fingerprint. An enum
rather than a string, so a typo is a compile error rather than a refused load.
Redacted is the choice to reach for first: the cache is written on every
clean load, and secrets landing on disk should be a decision, not a side
effect — writing them is what Full spells out. See
Last known good.
Redacted and Fingerprint need to know which fields are secret, which only
the generated builder() on a #[dynamic_config] type carries — on a bare
Builder::new, those modes are refused at init rather than silently caching
everything. CacheMode::Full, spelled out, still works there.
Encrypted, the fourth answer
With the decrypt feature, cache_encrypted(path, encryptor) collapses
the trade-off the three modes exist to navigate: full fidelity — recovery
needs nothing from the live environment — with nothing readable on disk.
#![allow(unused)] fn main() { AppConfig::builder("app") .file("config.toml") .cache_encrypted("/var/lib/app/last.json.age", encryptor) .init()?; }
The cache is written through the Encryptor the caller constructs — the
recipients live there, at the call site that owns them — and recovered
through the installed Decryptor, the same door
encrypted_file(..) reads through, so one set_decryptor covers both.
The path carries the format under the encryption suffix, exactly like an
encrypted source file: last.json.age is JSON.
Last known good
A process that cannot read its configuration should normally refuse to start — that is the point of failing loudly. There is one case where refusing is worse: a machine reboots, something on disk is half-written or a mount has not appeared yet, and a service that would otherwise have come up sits dead until a person notices.
#![allow(unused)] fn main() { DbConfig::builder("db") .file("/etc/app/config.toml") .cache("/var/lib/app/last.json", CacheMode::Redacted) .init()?; }
Every clean init() and reload writes the resolved configuration there. A cold start that
fails reads it back, logs a warning naming the failure, and runs. It is opt-in,
and deliberately loud.
Recovery lives in init(), not load(). load() is the pure one — it reads the
sources and hands back a value — and a function that quietly returned yesterday's
answer instead would be a poor thing to build anything else on.
What ends up on disk
A resolved configuration holds every value, including the ones
#[config(secret)] exists to keep out of logs. There is no way to make that not
a trade-off, so it is a choice with three answers rather than a default nobody
was told about.
CacheMode | On disk | Recovers | For |
|---|---|---|---|
Full | everything, secrets included | completely | a host you already trust with the secrets — they were in memory anyway |
Redacted | everything except #[config(secret)] fields | only if the secrets arrive from somewhere live | secrets injected through the environment, which is the shape most deployments already have |
Fingerprint | a hash and the key names — no value anywhere | never | somewhere no value may be written, when the diagnosis is still worth having |
On Unix the file is written 0600. That is the most that can be done without
refusing the request; the rest is documented rather than solved.
Fingerprint does not pretend it can recover — a failed start still fails.
What it buys is which keys have moved since the last time this worked, which is
usually the first thing anyone wants:
[dynamic-config] DbConfig: cannot start: no such file (/etc/app/config.toml).
Since the last good configuration: pool.max_size is gone, pool.max is new
The drift goes to the log rather than into the returned error, because the error belongs to the caller's own handling and this is a note for whoever reads the logs afterwards.
Recovery reads no files
The files are what broke. Recovery loads from the cache plus the environment,
flags and runtime overrides — never from the sources whose failure caused it,
because a malformed file fails to parse whatever sits underneath it. The
environment still wins over the cache, so a Redacted cache and
APP_DB_PASSWORD recover between them. A configured .validate(f) still runs
on the recovered value — yesterday's configuration has to meet today's rules.
Only a cold start
A failed reload never consults the cache. A running process already has something better than yesterday's configuration to fall back on: the snapshot it is currently serving.