Step 4 of 6
OPS-330: validate the deploy config
OPS-330: Validate deploy configs before they reach production
A typo in a deploy config (
"replica": 3instead of"replicas") silently fell back to the default and took checkout down to one instance. Build a validator for CI: it reads the JSON config and either approves it or lists every problem at once, so people don't fix one error per CI run.
Two kinds of problems, two exit codes:
- The JSON doesn't match the schema (syntax error, missing field, wrong type, unknown field): serde finds these while deserializing.
#[serde(deny_unknown_fields)]turns the"replica"typo into an error instead of silently ignoring it. Exit code 2. - The values break business rules (too many replicas, an unknown region): your code checks these after deserializing, collecting every problem into a
Vec<String>instead of stopping at the first. Exit code 1.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Job {
name: String,
#[serde(default)]
retries: u32,
schedule: Option<String>,
}
fn problems(job: &Job) -> Vec<String> {
let mut found = Vec::new();
if job.name.is_empty() {
found.push("name: must not be empty".to_string());
}
if job.retries > 5 {
found.push(format!("retries: at most 5, got {}", job.retries));
}
if job.schedule.as_deref().is_some_and(|s| !s.starts_with("cron:")) {
found.push("schedule: must start with cron:".to_string());
}
found
}
fn main() {
let inputs = [
r#"{"name": "backup", "schedule": "cron:0 3 * * *"}"#,
r#"{"name": "", "retries": 9, "schedule": "daily"}"#,
r#"{"name": "sync", "retry": 2}"#,
r#"{"name": "sync", "retries": -1}"#,
];
for json in inputs {
match serde_json::from_str::<Job>(json) {
Err(e) => println!("schema error: {e}"),
Ok(job) => {
let found = problems(&job);
if found.is_empty() {
println!("ok: {job:?}");
} else {
println!("{} problems: {}", found.len(), found.join("; "));
}
}
}
}
}
ok: Job { name: "backup", retries: 0, schedule: Some("cron:0 3 * * *") }
3 problems: name: must not be empty; retries: at most 5, got 9; schedule: must start with cron:
schema error: unknown field `retry`, expected one of `name`, `retries`, `schedule` at line 1 column 24
schema error: invalid value: integer `-1`, expected u32 at line 1 column 30
#[serde(default)]fills in0whenretriesis missing; anOptionfield isNonewhen missing.option.is_some_and(|v| ...)is true when the Option holds a value that passes the test;as_deref()turns theOption<String>into anOption<&str>first.r#"..."#is a raw string: no escaping needed for the quotes inside.- serde's error messages include the line and column, which is exactly what someone fixing a config needs.
Common mistakes
- Stopping at the first problem. Returning
Errfrom the first failed check makes people fix configs one CI run at a time. Validation collects; parsing fails fast. - Forgetting
deny_unknown_fields. Without it, serde ignores keys it doesn't know, so typos pass silently. - Validating with types you could have used instead. A port that can't be above 65535 is just a
u16: serde rejects 70000 for you. - Printing a HashMap of env vars. Use a
BTreeMapso problems come out in a stable order.
Your task
The seed reads the config from standard input and deserializes it. Write fn validate(config: &Config) -> Vec<String> and the output. Check these rules, in this order, adding one message per problem:
servicemust start with a lowercase letter and contain onlya-z,0-9and-:service: must start with a letter and use only a-z, 0-9 and -replicasfrom 1 to 20:replicas: must be between 1 and 20, got Nportat least 1024:port: must be 1024 or higher, got Nregions: if empty,regions: at least one region is required; for each region not ineu-west,eu-central,us-east,us-west(in list order),regions: unknown region R; for each region listed again,regions: R is listed twice- for each
envkey (in sorted order) that isn'tUPPER_SNAKE_CASE(onlyA-Z,0-9and_, starting with a letter):env: KEY must be UPPER_SNAKE_CASE - if there is a
health_check: its path must start with/(health_check.path: must start with /), andinterval_secsfrom 1 to 300 (health_check.interval_secs: must be between 1 and 300, got N)
If there are no problems, print config OK: SERVICE, N replicas in R1, R2. Otherwise print config INVALID (N problems): followed by each problem on its own line as - PROBLEM, and exit with code 1. (The seed already handles schema errors with exit code 2.)
Challenge 2
OPS-334: Print the effective config
Half our configs leave fields out and rely on defaults nobody remembers. The deploy log should show the config as it will really be used.
Read a config from standard input where everything except service is optional, fill in the defaults, and print the result with serde_json::to_string_pretty. Defaults: replicas 2, port 8080, regions ["eu-west"], env empty, health_check {"path": "/health", "interval_secs": 30} (and inside a given health_check, a missing path or interval_secs gets its default too). Unknown fields are still errors. On a schema error print invalid config: ERROR and exit with code 2.
Use #[serde(default = "function_name")] for fields whose default isn't the type's Default, implement Default for HealthCheck, and put #[serde(default)] on the HealthCheck struct itself so its fields fall back individually. Derive Serialize too; fields print in declaration order and BTreeMap keys in sorted order.
Challenge 3
OPS-341: Detect config drift between staging and production
Incidents keep happening because production quietly differs from staging. We want a diff of the two configs, key by key.
Standard input holds two JSON documents (objects): staging, then a line containing only ---, then production. Parse both into serde_json::Value and print their differences, one per line, with dotted paths for nested objects (health_check.path):
- a key only in staging:
- PATH - a key only in production:
+ PATH: VALUE - a key in both with different values (where not both are objects):
~ PATH: OLD -> NEW - both are objects: compare their keys recursively
Values print as compact JSON (Value's Display, for example "info" with quotes, 3, ["eu-west"]). Visit keys in sorted order at every level (collect them into a BTreeSet to be sure). If there are no differences, print no drift. If a document isn't valid JSON, print NAME is not valid JSON: ERROR (NAME is staging or production) and exit with code 2.
Previous: SHOP-210: orders with validation Next: SHOP-240: the order workflow state machine