Skip to content

fix(config): reject malformed env overrides and expand ${VAR} in TOML - #300

Merged
farhan-syah merged 4 commits into
mainfrom
fix/config-startup-gate
Sep 7, 2026
Merged

fix(config): reject malformed env overrides and expand ${VAR} in TOML#300
farhan-syah merged 4 commits into
mainfrom
fix/config-startup-gate

Conversation

@farhan-syah

@farhan-syah farhan-syah commented Sep 6, 2026

Copy link
Copy Markdown
Member

Closes #277. Closes #278.

What this does and why

Both issues are one defect at two stages of config load. An operator supplies a value from the environment, and the server silently uses a different one.

Stage Today This PR
File text → toml::from_str ${DATA_DIR} stays literal, or serde throws a type error Expanded from the environment before parsing
Parsed config → env overrides A malformed value logs a warning and keeps the default Startup fails and names every bad value

The rule

An operator-supplied environment value is honored, or the process refuses to start. It is never replaced by a config value or a compiled default.

Three kinds of violation:

  • UnparseableNODEDB_DATA_PLANE_CORES=abc.
  • Out of domain — a zero core count, or a WAL buffer under 64KiB.
  • Unsatisfiable here — TLS on with no certificate material, or NODEDB_NODE_ID with no [cluster] section.

One exception. A request the process already satisfies is honored. NODEDB_TLS_PGWIRE=false with no [server.tls] section leaves a plaintext listener, which is what was asked.

The gate collects every violation and reports them together, so one typo never hides the next:

Error: configuration error: invalid value 'abc' for NODEDB_DATA_PLANE_CORES: expected a positive integer; invalid value '4096' for NODEDB_WAL_WRITE_BUFFER_SIZE: expected a memory size of at least 64KiB

Why a table, not 30 Results

The override surface had grown three different swallow styles. warn!-and-continue in env/*.rs. A silent if let Ok(..) && let Ok(..) in observability.rs. And .ok().and_then(..).unwrap_or(default) in the bootstrap and cluster paths. Each variable carried its own hand-written read-parse-fallback block, which is how they drifted.

Threading Result through those blocks fixes today's variables and leaves the next one free to repeat the mistake. The gate is one table instead. Each NODEDB_* variable is a row of name, apply function, and redact flag. Adding a variable is adding a row, and there is no per-variable code to write a swallow into. Nine hand-written parser modules are deleted.

Also in this change

  • NODEDB_TLS_CERT_PATH and NODEDB_TLS_KEY_PATH create a [server.tls] section when the file has none. One without the other is rejected.
  • Five variables move from loop-spawn reads into config. A value read after the process is up cannot refuse a boot.
  • Every toggle shares one vocabulary: true/1/yes and false/0/no. The observability toggles took only true/false.
  • NODEDB_OTLP_EXPORT_ENDPOINT requires an http:// or https:// scheme.
  • NODEDB_DATA_PLANE_CORES=0 is rejected. It parses as a usize and asks the Data Plane for a shard count no query can reach.
  • A set-but-empty value is rejected everywhere. NODEDB_DATA_DIR= is a failed template substitution, not a request.
  • The .max(10) clamp in scope/expiry.rs is gone. An operator who sets 5 gets a startup error naming the floor, not a silent 10.
  • Every bound holds whatever set the value. ServerConfig::validate() re-checks all 14 constrained fields on the loaded config. A TOML file therefore cannot reach a field the gate guards. Both paths read one set of bounds in config/server/domain.rs.

Expansion details

${VAR} substitution is textual and runs before the parse, so quoting is the operator's choice. data_dir = "${DATA_DIR}" yields a string and bare ports.pgwire = ${PORT} yields an integer. $${NAME} escapes to a literal ${NAME}.

A six-state scanner tracks TOML strings and comments, so a placeholder inside a comment is left alone. A shipped template can carry a commented-out example line without requiring the variable it names.

No shell expressions, no command substitution, no recursive expansion, no ${VAR:-default}. Substituted values are never logged, only the variable names.

Commits

Commit Scope
0e72933 The override gate, its table, the shared bounds, and the relocated variables
28d9200 ${VAR} expansion in ServerConfig::from_file
e681470 docs/getting-started.md

How to test

cargo nextest run --workspace --exclude nodedb-cluster-tests --all-features --no-fail-fast
cargo nextest run -p nodedb-cluster-tests --all-features --no-fail-fast
cargo clippy --all-targets -- -D warnings

Both stages pass on this branch, with no flaky retries.

The change adds 76 tests. 59 cover the gate across nodedb/tests/config_env_{listeners,sizing,durability,cluster,observability,maintenance}.rs, one per variable and constraint. 17 more are inline, covering the expansion scanner and ServerConfig::from_file.

Every gate test asserts that the error names both the variable and the rejected value. An error carrying neither reads like the warn-and-continue it replaces, so the value check alone is not enough.

To see it by hand:

NODEDB_DATA_PLANE_CORES=abc ./target/release/nodedb   # exits non-zero, names the variable
printf '[server]\ndata_dir = "${DATA_DIR}"\n' > /tmp/n.toml
./target/release/nodedb --config /tmp/n.toml          # exits non-zero, names DATA_DIR
DATA_DIR=/var/lib/nodedb ./target/release/nodedb --config /tmp/n.toml   # boots

Tradeoffs and alternatives

This is a behavior change, and deployments can break on it. A node that boots today on a mistyped variable stops booting. That is the point of the fix, and the pre-1.0 window is the right time for it.

Well-formed but inapplicable values now fail too. NODEDB_TLS_* or NODEDB_NODE_ID set with no matching config section stops startup. This costs the pattern of one shared env block across a TLS and a non-TLS deployment. The alternative drops the operator's intent silently. On a TLS toggle that means serving plaintext to someone who asked for TLS.

Expansion skips comments, which costs a scanner. A regex over the file text is far shorter. It also fails the first template shipping a commented-out # data_dir = "${DATA_DIR}". That refuses a boot over a variable the server never reads.

Rejected: a strict-config opt-in flag. Enforcement of an invariant that must always hold does not belong behind a flag.

Rejected: threading Result through the existing per-variable blocks. Smaller diff, same shape, and it leaves the next variable free to swallow again.

No performance-sensitive path changes, so there are no benchmark numbers to report.

@farhan-syah
farhan-syah force-pushed the fix/config-startup-gate branch from f28b3e3 to 75af8dd Compare September 6, 2026 20:12
…oring them

Replace the per-module NODEDB_* env parsing (checkpoint, cluster,
host_ports, numeric, timeseries, tls, wal, dispatch, helpers) with a
single table-driven gate that validates every override and fails boot
on a bad value rather than silently falling back to a default.

Move loop intervals and the cluster join-retry policy (clone sweep,
constraint reconcile, scope expiry, join attempts/backoff) out of ad
hoc std::env::var reads scattered across background loops and into
ServerConfig/MaintenanceTuning/ClusterSettings fields, so the same
validated value reaches both production and the cluster test harness.
Lets operators reference environment variables directly in a config
file's TOML source, substituted before parsing. Comments and escaped
$${NAME} are left untouched, and an unset or malformed placeholder
fails config load instead of falling through to a TOML parse error.
Values applied later via NODEDB_* env overrides still take precedence
over an expanded placeholder.
Cover the new config surface: sync_host, TLS certificate material,
WAL tuning, cluster settings, maintenance intervals, and
observability options, plus the startup-validation error format and
${VAR} placeholder expansion in TOML files.
@farhan-syah
farhan-syah force-pushed the fix/config-startup-gate branch from 75af8dd to e681470 Compare September 6, 2026 20:32
@farhan-syah farhan-syah added the run-ci Opt this PR into the full test suite; re-add to force a re-run label Sep 6, 2026
Reset tenant rate counters each second in the pgwire test harness's
poller loop, since the server's own background loop that normally
does this is never started for tests, so quotas hit once a test
exceeded them. Shrink the memtable budget in the timeseries flush
tests instead of ingesting millions of rows to cross the shipped
64 MiB default, which timed out on loaded CI runners. Rework the
reindex-concurrent stall bound to scale with the rebuild window's
own duration and gate on p99 rather than a single slowest sample,
throttle its query load below the tenant's default rate limit, and
surface the full error chain from failed queries.
@farhan-syah
farhan-syah force-pushed the fix/config-startup-gate branch from 662dc05 to fc7cc00 Compare September 7, 2026 02:33
@farhan-syah
farhan-syah merged commit dabe9d5 into main Sep 7, 2026
13 checks passed
@farhan-syah
farhan-syah deleted the fix/config-startup-gate branch September 7, 2026 04:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Opt this PR into the full test suite; re-add to force a re-run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support expansion in TOML config files Malformed NODEDB_DATA_PLANE_CORES falls back to defaults instead of failing startup

1 participant