ferro_cli/doctor/checks/
docker_template_drift.rs1use 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 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 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 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}