Skip to main content

ferro_cli/doctor/checks/
docker_template_drift.rs

1//! Doctor check: flag when the committed `Dockerfile` has drifted from what
2//! the current scaffolder would generate.
3//!
4//! Severity is `Warn`, not `Error` — hand-editing the Dockerfile is
5//! legitimate. The check exists to inform, not to block. See Phase 131
6//! research open question 4 for rationale.
7
8use crate::deploy::bin_detect::detect_web_bin;
9use crate::doctor::check::{CheckCategory, CheckResult, DoctorCheck};
10use crate::project::{read_bins, read_deploy_metadata};
11use crate::templates::docker::{
12    read_rust_channel, render_dockerfile, resolve_ferro_version, DockerContext,
13};
14use std::fs;
15use std::path::Path;
16
17const NAME: &str = "docker_template_drift";
18
19pub struct DockerTemplateDriftCheck;
20
21impl DoctorCheck for DockerTemplateDriftCheck {
22    fn name(&self) -> &'static str {
23        NAME
24    }
25
26    fn category(&self) -> CheckCategory {
27        CheckCategory::Deploy
28    }
29
30    fn run(&self, root: &Path) -> CheckResult {
31        check_impl(root)
32    }
33}
34
35pub(crate) fn check_impl(root: &Path) -> CheckResult {
36    let dockerfile = root.join("Dockerfile");
37
38    if !dockerfile.is_file() {
39        return CheckResult::ok(NAME, "skipped (Dockerfile absent)");
40    }
41
42    let committed = match fs::read_to_string(&dockerfile) {
43        Ok(s) => s,
44        Err(e) => return CheckResult::error(NAME, format!("read failed: {e}")),
45    };
46
47    let metadata = match read_deploy_metadata(root) {
48        Ok(m) => m,
49        Err(e) => return CheckResult::error(NAME, format!("metadata: {e}")),
50    };
51
52    let bins: Vec<String> = read_bins(root).into_iter().map(|b| b.name).collect();
53
54    let web_bin = match detect_web_bin(root) {
55        Ok(w) => w,
56        Err(e) => return CheckResult::error(NAME, format!("web_bin: {e}")),
57    };
58
59    let copy_dirs_present: Vec<String> = metadata
60        .copy_dirs
61        .iter()
62        .filter(|d| root.join(d.as_str()).exists())
63        .cloned()
64        .collect();
65
66    let ctx = DockerContext {
67        rust_channel: read_rust_channel(root),
68        has_frontend: root.join("frontend/package.json").is_file(),
69        bins,
70        web_bin,
71        copy_dirs_present,
72        runtime_apt: metadata.runtime_apt,
73        ferro_version: resolve_ferro_version(root),
74    };
75
76    let rendered = render_dockerfile(&ctx);
77
78    if rendered.trim_end() == committed.trim_end() {
79        CheckResult::ok(NAME, "Dockerfile matches scaffolder output")
80    } else {
81        CheckResult::warn(NAME, "Dockerfile has drifted from scaffolder")
82            .with_details("run `ferro docker:init --dry-run` to inspect the delta")
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use crate::doctor::check::CheckStatus;
90    use std::fs;
91    use tempfile::TempDir;
92
93    fn write(p: &Path, body: &str) {
94        if let Some(parent) = p.parent() {
95            fs::create_dir_all(parent).unwrap();
96        }
97        fs::write(p, body).unwrap();
98    }
99
100    fn minimal_cargo_toml(name: &str) -> String {
101        format!(
102            "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"{name}\"\npath = \"src/main.rs\"\n"
103        )
104    }
105
106    #[test]
107    fn name_and_category() {
108        assert_eq!(DockerTemplateDriftCheck.name(), NAME);
109        assert_eq!(DockerTemplateDriftCheck.category(), CheckCategory::Deploy);
110    }
111
112    #[test]
113    fn docker_template_drift_ok_when_dockerfile_absent() {
114        let td = TempDir::new().unwrap();
115        write(&td.path().join("Cargo.toml"), &minimal_cargo_toml("sample"));
116        let r = check_impl(td.path());
117        assert_eq!(r.status, CheckStatus::Ok);
118        assert!(r.message.contains("skipped"));
119    }
120
121    #[test]
122    fn docker_template_drift_ok_on_matching_fixture() {
123        use crate::templates::docker::DockerContext;
124
125        // Build a minimal project that matches the freshly-rendered Dockerfile.
126        let td = TempDir::new().unwrap();
127        write(
128            &td.path().join("Cargo.toml"),
129            "[package]\nname = \"sample\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"sample\"\npath = \"src/main.rs\"\n",
130        );
131
132        // Render a Dockerfile and write it to the tempdir so the check can
133        // verify it matches.
134        let ctx = DockerContext {
135            rust_channel: "stable".to_string(),
136            has_frontend: false,
137            bins: vec!["sample".to_string()],
138            web_bin: "sample".to_string(),
139            copy_dirs_present: vec![],
140            runtime_apt: vec![],
141            ferro_version: "0.0.0-test".to_string(),
142        };
143        let rendered = render_dockerfile(&ctx);
144        write(&td.path().join("Dockerfile"), &rendered);
145
146        let r = check_impl(td.path());
147        assert_eq!(
148            r.status,
149            CheckStatus::Ok,
150            "matching Dockerfile must not warn: {}",
151            r.message
152        );
153        assert!(r.message.contains("matches"));
154    }
155
156    #[test]
157    fn docker_template_drift_warn_on_mutation() {
158        use crate::templates::docker::DockerContext;
159
160        let td = TempDir::new().unwrap();
161        write(
162            &td.path().join("Cargo.toml"),
163            "[package]\nname = \"sample\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"sample\"\npath = \"src/main.rs\"\n",
164        );
165
166        // Render the correct Dockerfile, then append a spurious comment.
167        let ctx = DockerContext {
168            rust_channel: "stable".to_string(),
169            has_frontend: false,
170            bins: vec!["sample".to_string()],
171            web_bin: "sample".to_string(),
172            copy_dirs_present: vec![],
173            runtime_apt: vec![],
174            ferro_version: "0.0.0-test".to_string(),
175        };
176        let mut rendered = render_dockerfile(&ctx);
177        rendered.push_str("# spurious comment added by hand\n");
178        write(&td.path().join("Dockerfile"), &rendered);
179
180        let r = check_impl(td.path());
181        assert_eq!(
182            r.status,
183            CheckStatus::Warn,
184            "mutated Dockerfile must warn: {}",
185            r.message
186        );
187        assert!(r.message.contains("drifted"));
188        assert!(r.details.as_ref().unwrap().contains("docker:init"));
189    }
190}