ferro_cli/commands/
docker_init.rs1use std::fs;
16use std::path::{Path, PathBuf};
17
18use crate::deploy::bin_detect::detect_web_bin;
19use crate::project::{find_project_root, package_name, read_bins, read_deploy_metadata};
20use crate::templates::docker::{
21 dockerignore_template, read_rust_channel, render_dockerfile, resolve_ferro_version,
22 DockerContext,
23};
24
25pub(crate) struct RenderedFile {
28 pub relative_path: PathBuf,
29 pub contents: String,
30}
31
32pub(crate) fn print_dry_run(files: &[RenderedFile]) {
33 for f in files {
34 println!("--- {} ---", f.relative_path.display());
35 println!("{}", f.contents);
36 }
37}
38
39pub fn run(force: bool) {
42 run_with(force, None, false);
43}
44
45pub fn run_with(force: bool, ferro_version: Option<String>, dry_run: bool) {
47 if let Err(e) = execute(force, ferro_version.as_deref(), dry_run) {
48 eprintln!("docker:init failed: {e:#}");
49 }
50}
51
52pub fn execute(force: bool, ferro_version_flag: Option<&str>, dry_run: bool) -> anyhow::Result<()> {
55 let root = find_project_root(None)
56 .map_err(|e| anyhow::anyhow!("could not locate project Cargo.toml: {e}"))?;
57
58 let metadata = read_deploy_metadata(&root)?;
59 let rust_channel = read_rust_channel(&root);
60 let bins: Vec<String> = read_bins(&root).into_iter().map(|b| b.name).collect();
61 let has_frontend = root.join("frontend/package.json").is_file();
62 let copy_dirs_present: Vec<String> = metadata
63 .copy_dirs
64 .iter()
65 .filter(|d| root.join(d).exists())
66 .cloned()
67 .collect();
68
69 let web_bin = detect_web_bin(&root)?;
70 let ferro_version = ferro_version_flag
71 .map(|s| s.to_string())
72 .unwrap_or_else(|| resolve_ferro_version(&root));
73 let ctx = DockerContext {
74 rust_channel,
75 has_frontend,
76 bins,
77 web_bin,
78 copy_dirs_present,
79 runtime_apt: metadata.runtime_apt.clone(),
80 ferro_version,
81 };
82
83 let dockerfile = render_dockerfile(&ctx);
86
87 let files: Vec<RenderedFile> = vec![
88 RenderedFile {
89 relative_path: "Dockerfile".into(),
90 contents: dockerfile,
91 },
92 RenderedFile {
93 relative_path: ".dockerignore".into(),
94 contents: dockerignore_template().to_string(),
95 },
96 ];
97
98 if dry_run {
99 print_dry_run(&files);
102 return Ok(());
103 }
104
105 for f in &files {
107 let target = root.join(&f.relative_path);
108 write_if_absent_or_force(&target, &f.contents, force)?;
109 }
110
111 println!(
112 "docker:init wrote Dockerfile and .dockerignore in {}",
113 root.display()
114 );
115
116 let pkg = package_name(&root);
118 print!("{}", docker_init_footer(&pkg));
119 Ok(())
120}
121
122fn docker_init_footer(pkg: &str) -> String {
125 format!(
126 "\nNext steps:\n docker build -t {pkg}:test .\n docker run --rm -p 8080:8080 --env-file .env.production {pkg}:test\n"
127 )
128}
129
130fn write_if_absent_or_force(path: &Path, content: &str, force: bool) -> anyhow::Result<()> {
131 if path.exists() && !force {
132 println!(
133 "skip {}: already exists (use --force to overwrite)",
134 path.display()
135 );
136 return Ok(());
137 }
138 fs::write(path, content)
139 .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?;
140 Ok(())
141}
142
143#[cfg(test)]
144mod footer_tests {
145 use super::*;
146
147 #[test]
148 fn docker_init_footer_contents() {
149 let s = docker_init_footer("myapp");
150 assert!(s.contains("docker build"));
151 assert!(s.contains("docker run"));
152 assert!(s.contains("--env-file .env.production"));
153 assert!(s.contains("myapp:test"));
154 }
155
156 #[test]
157 fn docker_init_footer_line_count() {
158 let s = docker_init_footer("app");
159 let n = s.lines().filter(|l| !l.trim().is_empty()).count();
160 assert!((3..=5).contains(&n), "footer has {n} non-empty lines: {s}");
161 assert!(s.is_ascii(), "footer must be ASCII-only");
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::fs;
169 use tempfile::TempDir;
170
171 #[test]
174 fn dockerfile_pins_to_cargo_lock_ferro_version() {
175 let tmp = TempDir::new().unwrap();
176 fs::write(
179 tmp.path().join("Cargo.toml"),
180 "[package]\nname = \"smoke\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"smoke\"\npath = \"src/main.rs\"\n",
181 )
182 .unwrap();
183 fs::write(
184 tmp.path().join("Cargo.lock"),
185 "[[package]]\nname = \"ferro-rs\"\nversion = \"9.9.9\"\n",
186 )
187 .unwrap();
188 fs::create_dir_all(tmp.path().join("src")).unwrap();
189 fs::write(tmp.path().join("src/main.rs"), "fn main(){}").unwrap();
190 fs::create_dir_all(tmp.path().join("frontend")).unwrap();
191 fs::write(tmp.path().join("frontend/package.json"), "{}").unwrap();
192
193 let bins: Vec<String> = vec!["smoke".to_string()];
196 let ctx = DockerContext {
197 rust_channel: read_rust_channel(tmp.path()),
198 has_frontend: tmp.path().join("frontend/package.json").is_file(),
199 bins,
200 web_bin: "smoke".to_string(),
201 copy_dirs_present: vec![],
202 runtime_apt: vec![],
203 ferro_version: resolve_ferro_version(tmp.path()),
204 };
205 let out = render_dockerfile(&ctx);
206 assert!(
207 out.contains("--version 9.9.9"),
208 "expected the rendered Dockerfile to pin ferro-cli to 9.9.9 (from Cargo.lock); got:\n{out}"
209 );
210 assert!(out.contains("AS types-gen"));
211 }
212
213 #[test]
214 fn dockerfile_falls_back_to_env_version_when_no_cargo_lock() {
215 let tmp = TempDir::new().unwrap();
216 fs::create_dir_all(tmp.path().join("frontend")).unwrap();
217 fs::write(tmp.path().join("frontend/package.json"), "{}").unwrap();
218 let ctx = DockerContext {
219 rust_channel: read_rust_channel(tmp.path()),
220 has_frontend: true,
221 bins: vec!["smoke".to_string()],
222 web_bin: "smoke".to_string(),
223 copy_dirs_present: vec![],
224 runtime_apt: vec![],
225 ferro_version: resolve_ferro_version(tmp.path()),
226 };
227 let out = render_dockerfile(&ctx);
228 let expected = format!("--version {}", env!("CARGO_PKG_VERSION"));
229 assert!(out.contains(&expected), "expected '{expected}' in:\n{out}");
230 }
231}