Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,16 @@ full cold-read/resource acceptance gates listed in `docs/spec/README.md`.
`walgit-telemetry` takes `&TelemetryConfig` and any `Settings`, not the server `Config`. The registry's full
`Config` is built from `NatsConfig` in `walgit_nats::publisher_config`.

- **D53 (2026-10-28): `WALGIT__` overrides fail closed.** An environment override this build cannot
apply (malformed name, unknown section or key, wrong type) is a startup and `config check` error that
names each variable and the nearest known key; nothing of the batch is applied. This supersedes the
warn-and-ignore loader (2026-08-21: a key newer than one host's image crash-looped it): a host running
on a default it was told to change is the worse failure, and the file already refuses unknown keys.
D52 stands: an override under another binary's `FOREIGN` path is still skipped without a warning, so one
environment can configure both binaries; only a name unknown to both binaries, or a wrong-typed value for
a key this binary owns, is refused. Roll the image before the env that needs it; gate the rollout on
`walgit config check --env-file` run by the binary that will serve.

## 5. Working rules

- **No backwards compatibility (pre-1.0, banner at top):** change the shape and delete the old one in the same
Expand Down
19 changes: 3 additions & 16 deletions crates/walgit-cli/src/config_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use walgit_config::{Config, Settings};

pub async fn run(action: ConfigAction, cfg: &Arc<Config>) -> Result<()> {
match action {
ConfigAction::Check { env_files, strict } => {
ConfigAction::Check { env_files } => {
let mut cfg: Config = (**cfg).clone();
let mut vars: Vec<(String, String)> = Vec::new();
for f in &env_files {
Expand All @@ -25,22 +25,9 @@ pub async fn run(action: ConfigAction, cfg: &Arc<Config>) -> Result<()> {
}
}
}
let ignored = cfg.apply_env_report(vars.into_iter())?;
cfg.apply_env(vars.into_iter())?;
cfg.validate()?;
for (k, why) in &ignored {
eprintln!("ignored {k}: {why}");
}
if ignored.is_empty() {
println!("config OK");
} else {
println!(
"config OK ({} override(s) ignored — unknown in this build)",
ignored.len()
);
if strict {
std::process::exit(3);
}
}
println!("config OK");
Ok(())
}
ConfigAction::Dump => {
Expand Down
7 changes: 2 additions & 5 deletions crates/walgit-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,15 +375,12 @@ enum ConfigAction {
/// Parse and validate the config file; print OK or the error.
Check {
/// Also apply `WALGIT__*` overrides from these env files (KEY=VALUE
/// lines; `#` comments) on top of the process env, report every key
/// this build ignores, and exit 3 when `--strict` and any was ignored.
/// lines; `#` comments) on top of the process env; an override this
/// build cannot apply fails the check exactly as it fails startup.
/// Useful in a process supervisor's pre-start check to validate an env
/// file against the binary that will actually run.
#[arg(long = "env-file")]
env_files: Vec<std::path::PathBuf>,
/// Exit 3 if any override was ignored (unknown in this build).
#[arg(long)]
strict: bool,
},
/// Print the effective config as TOML.
Dump,
Expand Down
94 changes: 94 additions & 0 deletions crates/walgit-cli/tests/config_env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Test fixtures use panics to fail the test.
#![allow(clippy::unwrap_used)]
//! `WALGIT__` overrides fail closed through the real binaries: a typo'd
//! override stops `config check` and server startup (before anything binds),
//! naming the variable; a valid one is applied.

use std::process::{Command, Output};

const TYPO: (&str, &str) = ("WALGIT__SERVER__LSITEN", "127.0.0.1:0");
const HINT: &str = "WALGIT__SERVER__LSITEN: unknown key `lsiten` in [server]; did you mean WALGIT__SERVER__LISTEN?";

/// A binary with `--config /dev/null` (defaults + env, D39) and only `env`:
/// nothing from the test runner's environment leaks in.
fn run(bin: &str, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new(bin);
cmd.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.args(["--config", "/dev/null"])
.args(args);
for (k, v) in env {
cmd.env(k, v);
}
cmd.output().unwrap()
}

fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}

#[test]
fn server_startup_refuses_a_typod_override() {
// Spawned, not `output()`: a server that ignored the typo would serve forever.
let mut child = Command::new(env!("CARGO_BIN_EXE_walgit-server"))
.env_clear()
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
.env(TYPO.0, TYPO.1)
.args(["--config", "/dev/null"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_mins(1);
while child.try_wait().unwrap().is_none() {
if std::time::Instant::now() > deadline {
child.kill().unwrap();
panic!("walgit-server started with {} set", TYPO.0);
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(1), "{}", stderr(&out));
assert!(stderr(&out).contains(HINT), "{}", stderr(&out));
}

#[test]
fn config_check_refuses_a_typod_override_in_the_env_and_in_an_env_file() {
let walgit = env!("CARGO_BIN_EXE_walgit");
let out = run(walgit, &["config", "check"], &[TYPO]);
assert!(!out.status.success(), "process env typo accepted");
assert!(stderr(&out).contains(HINT), "{}", stderr(&out));

let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join("walgit.env");
std::fs::write(
&env_file,
format!(
"# host overrides\nWALGIT__WAL__MAX_BATCH=7\n{}={}\n",
TYPO.0, TYPO.1
),
)
.unwrap();
let path = env_file.to_str().unwrap();
let out = run(walgit, &["config", "check", "--env-file", path], &[]);
assert!(!out.status.success(), "env-file typo accepted");
assert!(stderr(&out).contains(HINT), "{}", stderr(&out));
assert!(!String::from_utf8_lossy(&out.stdout).contains("config OK"));

std::fs::write(&env_file, "WALGIT__WAL__MAX_BATCH=7\n").unwrap();
let out = run(walgit, &["config", "check", "--env-file", path], &[]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(String::from_utf8_lossy(&out.stdout).contains("config OK"));
}

#[test]
fn a_valid_override_is_applied() {
let out = run(
env!("CARGO_BIN_EXE_walgit"),
&["config", "dump"],
&[("WALGIT__WAL__MAX_BATCH", "7")],
);
assert!(out.status.success(), "{}", stderr(&out));
let dump = String::from_utf8_lossy(&out.stdout);
assert!(dump.contains("max_batch = 7"), "{dump}");
}
Loading
Loading