autumn-cli 0.9.0

CLI tool for the Autumn web framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! `autumn replay` -- replay a failure capsule against the application.

use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};

/// Environment variable the app binary reads to select replay mode.
pub const REPLAY_CAPSULE_ENV: &str = "AUTUMN_REPLAY_CAPSULE";

/// Exit code used for a capsule that is never replayed at all.
const EXIT_REFUSED: i32 = 2;

/// Clear the other internal one-shot mode env vars `AppBuilder::run` checks
/// *before* `AUTUMN_REPLAY_CAPSULE` in its dispatch chain.
///
/// `Command` inherits the parent process's environment by default, so any of
/// these left over in the CLI's own environment (e.g. from a wrapping script,
/// or a previous `autumn migrate`/`autumn task run ...`/`autumn db retention
/// --purge` invocation in the same shell) would silently hijack a replay into
/// a completely different — and potentially mutating — mode, since every one
/// of them is dispatched earlier than `AUTUMN_REPLAY_CAPSULE` in
/// `AppBuilder::run`. `AUTUMN_DB_RETENTION=purge` is the sharpest of them: it
/// would turn a read-only replay into a sweep that deletes rows from every
/// framework-owned dataset.
fn clear_competing_one_shot_env(command: &mut Command) {
    for var in [
        "AUTUMN_BUILD_STATIC",
        "AUTUMN_DUMP_ROUTES",
        "AUTUMN_DUMP_CACHE_COHERENCE",
        "AUTUMN_DUMP_DATA_FLOW",
        "AUTUMN_DUMP_AGENT_AUTHORITY",
        "AUTUMN_DUMP_GRAPH",
        "AUTUMN_DUMP_JOBS",
        "AUTUMN_LIST_TASKS",
        "AUTUMN_RUN_TASK",
        "AUTUMN_MIGRATE",
        "AUTUMN_RETENTION_DRY_RUN",
        "AUTUMN_DB_RETENTION",
        "AUTUMN_DATA_CAPSULE",
    ] {
        command.env_remove(var);
    }
}

/// Options controlling `autumn replay`.
pub struct ReplayOptions<'a> {
    pub capsule: &'a str,
    pub package: Option<&'a str>,
    pub bin: Option<&'a str>,
    /// Explicit `--profile`, or `None` to default to the capsule's recorded
    /// profile (falling back to `dev`).
    pub profile: Option<&'a str>,
    /// Explicit build kind: `Some(true)` for `--release`, `Some(false)` for
    /// `--debug`, or `None` to default to the build kind the capsule recorded
    /// (falling back to a debug build).
    pub build: Option<bool>,
    /// Cargo features forwarded to `cargo build --features`. The capsule does
    /// not record the recording binary's feature set, so feature-gated code
    /// the failure depends on needs these passed explicitly.
    pub features: Option<&'a str>,
    /// Forwarded to `cargo build --no-default-features`.
    pub no_default_features: bool,
}

/// Run `autumn replay`.
///
/// Compiles the app binary and runs it with `AUTUMN_REPLAY_CAPSULE` set, the
/// same delegation `autumn task` uses: the application — not the CLI — knows
/// its routes, state and configuration, so it is the only thing that can
/// rebuild itself around a recorded request. The child prints the verdict
/// (JSON on stdout, a human summary on stderr) and this process exits with the
/// child's code.
///
/// Unlike `autumn task`, no managed-Postgres environment is applied: a replay
/// serves its database from the capsule and must not attach to a live cluster.
pub fn run(opts: &ReplayOptions<'_>) {
    // Resolved before anything is compiled, so a typo'd path costs a second
    // rather than a build.
    let capsule = resolve_capsule_path(Path::new(opts.capsule)).unwrap_or_else(|error| {
        eprintln!("autumn replay: {error}");
        std::process::exit(EXIT_REFUSED);
    });

    let recorded_profile = recorded_profile(&capsule);
    let profile = effective_profile(opts.profile, recorded_profile.as_deref());
    let release = effective_release(opts.build, recorded_debug_assertions(&capsule));

    eprintln!("autumn replay {}\n", capsule.display());
    compile_binary(opts, release);
    let binary = crate::routes::find_binary_in_profile(
        opts.package,
        opts.bin,
        &crate::routes::CargoProfile::from_release(release),
    );

    let mut command = Command::new(&binary);
    clear_competing_one_shot_env(&mut command);
    command
        .env(REPLAY_CAPSULE_ENV, &capsule)
        .env("AUTUMN_ENV", &profile)
        .env("AUTUMN_PROFILE", &profile)
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());
    let status = command.status().unwrap_or_else(|error| {
        eprintln!("Failed to run {}: {error}", binary.display());
        std::process::exit(1);
    });

    std::process::exit(exit_code(status));
}

/// The profile the capsule recorded (`app.profile`), when it recorded one.
///
/// Best-effort: an unreadable or malformed capsule returns `None` here and is
/// then refused properly by the app binary, which owns capsule validation.
fn recorded_profile(capsule: &Path) -> Option<String> {
    let json = std::fs::read_to_string(capsule).ok()?;
    let value: serde_json::Value = serde_json::from_str(&json).ok()?;
    value
        .get("app")?
        .get("profile")?
        .as_str()
        .map(str::to_owned)
}

/// The profile the replayed app boots with.
///
/// An explicit `--profile` wins (with a warning when it differs from the
/// recording — profile-gated routes and configuration can then legitimately
/// diverge); otherwise the capsule's recorded profile, so the replay boots the
/// way the failing run did; `dev` only when the capsule recorded none.
fn effective_profile(explicit: Option<&str>, recorded: Option<&str>) -> String {
    match (explicit, recorded) {
        (Some(explicit), Some(recorded)) if explicit != recorded => {
            eprintln!(
                "warning: replaying with --profile {explicit}, but the capsule was recorded \
                 under the {recorded:?} profile — profile-gated routes and configuration may \
                 differ from the failing run"
            );
            explicit.to_owned()
        }
        (Some(explicit), _) => explicit.to_owned(),
        (None, Some(recorded)) => recorded.to_owned(),
        (None, None) => "dev".to_owned(),
    }
}

/// Whether the recording binary was compiled with `debug_assertions`
/// (`app.debug_assertions`), when the capsule recorded it.
///
/// Best-effort, like [`recorded_profile`]: an unreadable or malformed capsule
/// returns `None` here and is then refused properly by the app binary.
fn recorded_debug_assertions(capsule: &Path) -> Option<bool> {
    let json = std::fs::read_to_string(capsule).ok()?;
    let value: serde_json::Value = serde_json::from_str(&json).ok()?;
    value.get("app")?.get("debug_assertions")?.as_bool()
}

/// Whether the replay binary is compiled `--release`.
///
/// An explicit `--release`/`--debug` wins (with a warning when it differs
/// from the recording — `cfg(debug_assertions)`-gated code and release-only
/// behaviour can then legitimately diverge); otherwise the capsule's recorded
/// build kind, so a failure recorded by a release binary replays in one; a
/// debug build only when the capsule recorded none.
fn effective_release(explicit: Option<bool>, recorded_debug_assertions: Option<bool>) -> bool {
    let recorded = recorded_debug_assertions.map(|debug| !debug);
    match (explicit, recorded) {
        (Some(explicit), Some(recorded)) if explicit != recorded => {
            eprintln!(
                "warning: replaying with a {} build, but the capsule was recorded by a {} \
                 build — `cfg(debug_assertions)` code paths and release-only behaviour may \
                 differ from the failing run",
                build_kind(explicit),
                build_kind(recorded)
            );
            explicit
        }
        (Some(explicit), _) => explicit,
        (None, Some(recorded)) => recorded,
        (None, None) => false,
    }
}

/// Human name for a build kind, for the mismatch warning.
const fn build_kind(release: bool) -> &'static str {
    if release { "release" } else { "debug" }
}

/// Compile the app binary with the replay's build configuration.
///
/// A plain `cargo build` would produce a default-features debug binary no
/// matter how the failing binary was built, and `cfg(debug_assertions)`- or
/// feature-gated code would then diverge from the recording before the
/// request is even replayed.
fn compile_binary(opts: &ReplayOptions<'_>, release: bool) {
    let mut cargo = Command::new("cargo");
    cargo.arg("build");
    if release {
        cargo.arg("--release");
    }
    if let Some(features) = opts.features {
        cargo.args(["--features", features]);
    }
    if opts.no_default_features {
        cargo.arg("--no-default-features");
    }
    if let Some(pkg) = opts.package {
        cargo.args(["-p", pkg]);
    }
    if let Some(bin) = opts.bin {
        cargo.args(["--bin", bin]);
    }

    let status = crate::built_artifact::run_build(&mut cargo).expect("failed to run cargo build");
    if !status.success() {
        eprintln!("\u{2717} Compilation failed");
        std::process::exit(1);
    }
}

/// Resolve the capsule argument to an absolute path.
///
/// Absolute because the child is free to resolve relative paths against its own
/// working directory, and because the path is echoed back in the verdict.
fn resolve_capsule_path(path: &Path) -> Result<PathBuf, String> {
    if path.is_dir() {
        return Err(format!(
            "{} is a directory, not a capsule — pass the capsule JSON file inside it",
            path.display()
        ));
    }
    if !path.is_file() {
        return Err(format!(
            "no capsule at {} — pass the path to a capsule written by `[failure_capture]` \
             (default directory: tmp/autumn-capsules)",
            path.display()
        ));
    }
    std::fs::canonicalize(path)
        .map_err(|error| format!("could not resolve {}: {error}", path.display()))
}

/// The exit code to forward for a finished replay child.
///
/// The child's code *is* the verdict — 0 reproduced, 1 diverged or mismatched,
/// 2 refused — so it is passed through untouched. A child killed by a signal
/// has no code to forward and reports plain failure.
fn exit_code(status: ExitStatus) -> i32 {
    status.code().unwrap_or(1)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn replay_defaults_to_the_capsules_recorded_profile() {
        assert_eq!(effective_profile(None, Some("prod")), "prod");
        assert_eq!(effective_profile(None, None), "dev");
        assert_eq!(effective_profile(Some("staging"), Some("prod")), "staging");
        assert_eq!(effective_profile(Some("prod"), Some("prod")), "prod");
    }

    #[test]
    fn replay_defaults_to_the_capsules_recorded_build_kind() {
        // Recorded debug_assertions = false means a release build was failing.
        assert!(effective_release(None, Some(false)));
        assert!(!effective_release(None, Some(true)));
        // Old capsules recorded nothing: a debug build, as before.
        assert!(!effective_release(None, None));
        // An explicit flag always wins.
        assert!(effective_release(Some(true), Some(true)));
        assert!(!effective_release(Some(false), Some(false)));
    }

    #[test]
    fn recorded_debug_assertions_reads_app_debug_assertions_from_the_capsule_json() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("capsule.json");
        std::fs::write(
            &path,
            r#"{"app":{"name":"shop","profile":"prod","debug_assertions":false}}"#,
        )
        .expect("write");
        assert_eq!(recorded_debug_assertions(&path), Some(false));

        std::fs::write(&path, r#"{"app":{"name":"shop"}}"#).expect("write");
        assert_eq!(
            recorded_debug_assertions(&path),
            None,
            "capsules recorded before the field existed carry no build kind"
        );
    }

    #[test]
    fn recorded_profile_reads_app_profile_from_the_capsule_json() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("capsule.json");
        std::fs::write(&path, r#"{"app":{"name":"shop","profile":"prod"}}"#).expect("write");
        assert_eq!(recorded_profile(&path).as_deref(), Some("prod"));

        std::fs::write(&path, r#"{"app":{"name":"shop"}}"#).expect("write");
        assert_eq!(recorded_profile(&path), None);

        std::fs::write(&path, "not json").expect("write");
        assert_eq!(
            recorded_profile(&path),
            None,
            "a malformed capsule is the app binary's refusal to make, not ours"
        );
    }

    #[test]
    fn resolve_capsule_path_rejects_a_missing_file() {
        let dir = tempfile::tempdir().expect("tempdir");
        let missing = dir.path().join("nope.json");
        let error = resolve_capsule_path(&missing).expect_err("a missing capsule must be rejected");
        assert!(
            error.contains("nope.json"),
            "the error must name the path: {error}"
        );
        assert!(
            error.contains("tmp/autumn-capsules"),
            "the error must point at the capsule directory: {error}"
        );
    }

    #[test]
    fn resolve_capsule_path_returns_an_absolute_path() {
        let dir = tempfile::tempdir().expect("tempdir");
        let capsule = dir.path().join("capsule.json");
        std::fs::write(&capsule, "{}").expect("write capsule");
        let resolved = resolve_capsule_path(&capsule).expect("an existing capsule resolves");
        assert!(
            resolved.is_absolute(),
            "the child may run elsewhere: {}",
            resolved.display()
        );
    }

    #[test]
    fn resolve_capsule_path_rejects_a_directory() {
        let dir = tempfile::tempdir().expect("tempdir");
        let error = resolve_capsule_path(dir.path()).expect_err("a directory is not a capsule");
        assert!(
            error.contains("directory"),
            "the error must say what is wrong: {error}"
        );
    }

    #[test]
    fn clear_competing_one_shot_env_removes_every_var_checked_before_replay() {
        // AppBuilder::run checks build, route/job dumps, tasks, migration, and
        // retention dry-run modes *before* AUTUMN_REPLAY_CAPSULE in its
        // dispatch chain — any of them left over in the CLI's own environment
        // would hijack a replay into a completely different (potentially
        // mutating) mode via Command's default environment inheritance.
        let competing_vars = [
            "AUTUMN_BUILD_STATIC",
            "AUTUMN_DUMP_ROUTES",
            "AUTUMN_DUMP_CACHE_COHERENCE",
            "AUTUMN_DUMP_DATA_FLOW",
            "AUTUMN_DUMP_AGENT_AUTHORITY",
            "AUTUMN_DUMP_GRAPH",
            "AUTUMN_DUMP_JOBS",
            "AUTUMN_LIST_TASKS",
            "AUTUMN_RUN_TASK",
            "AUTUMN_MIGRATE",
            "AUTUMN_RETENTION_DRY_RUN",
            "AUTUMN_DB_RETENTION",
        ];
        let mut command = Command::new("true");
        for var in competing_vars {
            command.env(var, "1");
        }
        clear_competing_one_shot_env(&mut command);
        let remaining: Vec<&str> = command
            .get_envs()
            .filter_map(|(key, value)| {
                value
                    .and_then(|_| key.to_str())
                    .filter(|k| competing_vars.contains(k))
            })
            .collect();
        assert!(
            remaining.is_empty(),
            "these competing one-shot vars survived: {remaining:?}"
        );
    }

    #[test]
    fn an_inherited_framework_retention_purge_cannot_hijack_a_replay() {
        // AUTUMN_DB_RETENTION=purge is dispatched *before* AUTUMN_REPLAY_CAPSULE
        // in AppBuilder::run, so an inherited value would turn `autumn replay`
        // -- a read-only reproduction of a recorded failure -- into a
        // destructive sweep of every framework-owned dataset (#1605).
        let mut command = Command::new("true");
        command.env("AUTUMN_DB_RETENTION", "purge");
        clear_competing_one_shot_env(&mut command);
        let survived = command
            .get_envs()
            .any(|(key, value)| key == "AUTUMN_DB_RETENTION" && value.is_some());
        assert!(
            !survived,
            "an inherited AUTUMN_DB_RETENTION=purge must never survive into a replay"
        );
    }

    #[cfg(unix)]
    #[test]
    fn the_child_verdict_exit_code_is_forwarded_verbatim() {
        use std::os::unix::process::ExitStatusExt as _;

        // 0 reproduced, 1 diverged/mismatched, 2 refused — a collapsed 2 would
        // make "this capsule was never replayed" indistinguishable from "the
        // bug is gone".
        assert_eq!(exit_code(ExitStatus::from_raw(0)), 0);
        assert_eq!(exit_code(ExitStatus::from_raw(1 << 8)), 1);
        assert_eq!(exit_code(ExitStatus::from_raw(2 << 8)), 2);
        // Killed by a signal: no code to forward, report failure.
        assert_eq!(exit_code(ExitStatus::from_raw(9)), 1);
    }
}