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