RustArena

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": 3 instead 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:

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

Common mistakes

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:

  1. service must start with a lowercase letter and contain only a-z, 0-9 and -: service: must start with a letter and use only a-z, 0-9 and -
  2. replicas from 1 to 20: replicas: must be between 1 and 20, got N
  3. port at least 1024: port: must be 1024 or higher, got N
  4. regions: if empty, regions: at least one region is required; for each region not in eu-west, eu-central, us-east, us-west (in list order), regions: unknown region R; for each region listed again, regions: R is listed twice
  5. for each env key (in sorted order) that isn't UPPER_SNAKE_CASE (only A-Z, 0-9 and _, starting with a letter): env: KEY must be UPPER_SNAKE_CASE
  6. if there is a health_check: its path must start with / (health_check.path: must start with /), and interval_secs from 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):

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