switchyard/adapters/smoke.rs
1//! Smoke test adapter used to validate post-apply health.
2//!
3//! Minimal expectations for integrators:
4//! - Implement `SmokeTestRunner` and inject it via `Switchyard::with_smoke_runner(...)`.
5//! - Ensure the runner is deterministic and safe to execute repeatedly.
6//! - At minimum, validate that each `EnsureSymlink` target resolves to the intended source.
7//! - Optionally perform additional invariant checks (e.g., permissions, executable bits).
8//!
9//! Behavior in `ApplyMode::Commit` when `policy.require_smoke_in_commit=true`:
10//! - If no runner is configured, apply fails with `E_SMOKE` and auto-rollback (unless `disable_auto_rollback`).
11//! - If the runner returns `Err(SmokeFailure)`, apply fails with `E_SMOKE` and auto-rollback (unless disabled).
12//!
13use crate::types::plan::Plan;
14
15#[derive(Debug, Copy, Clone)]
16pub struct SmokeFailure;
17
18pub trait SmokeTestRunner: Send + Sync {
19 /// Run smoke tests for the given plan.
20 /// # Errors
21 /// Returns `SmokeFailure` if smoke tests fail.
22 fn run(&self, plan: &Plan) -> Result<(), SmokeFailure>;
23}
24
25/// `DefaultSmokeRunner` implements a minimal, no-op smoke suite.
26/// In Sprint 2, the adapter is made available and can be enabled by integrators.
27/// Future iterations will implement the SPEC ยง11 command set.
28#[derive(Debug, Default, Copy, Clone)]
29pub struct DefaultSmokeRunner;
30
31impl SmokeTestRunner for DefaultSmokeRunner {
32 fn run(&self, plan: &Plan) -> Result<(), SmokeFailure> {
33 // Deterministic subset: validate that each EnsureSymlink target points to the source.
34 for act in &plan.actions {
35 if let crate::types::Action::EnsureSymlink { source, target } = act {
36 let Ok(md) = std::fs::symlink_metadata(target.as_path()) else {
37 return Err(SmokeFailure);
38 };
39 if !md.file_type().is_symlink() {
40 return Err(SmokeFailure);
41 }
42 let Ok(link) = std::fs::read_link(target.as_path()) else {
43 return Err(SmokeFailure);
44 };
45 // Resolve relative link against target parent
46 let resolved = if link.is_relative() {
47 match target.as_path().parent() {
48 Some(parent) => parent.join(link),
49 None => link,
50 }
51 } else {
52 link
53 };
54 // Compare canonicalized paths where possible
55 let want = std::fs::canonicalize(source.as_path())
56 .unwrap_or_else(|_| source.as_path().clone());
57 let got = std::fs::canonicalize(&resolved).unwrap_or(resolved);
58 if want != got {
59 return Err(SmokeFailure);
60 }
61 }
62 }
63 Ok(())
64 }
65}