ferro_cli/templates/
docker.rs1use std::fs;
17use std::path::Path;
18
19use toml::Value;
20
21const DOCKERIGNORE_TPL: &str = include_str!("files/docker/dockerignore.tpl");
22const DOCKERFILE_TPL: &str = include_str!("files/docker/Dockerfile.tpl");
23
24pub fn dockerignore_template() -> &'static str {
26 DOCKERIGNORE_TPL
27}
28
29#[derive(Debug, Clone)]
33pub struct DockerContext {
34 pub rust_channel: String,
36 pub has_frontend: bool,
38 pub bins: Vec<String>,
47 pub web_bin: String,
49 pub copy_dirs_present: Vec<String>,
51 pub runtime_apt: Vec<String>,
53}
54
55pub fn render_dockerfile(ctx: &DockerContext) -> String {
57 let frontend_stage = if ctx.has_frontend {
58 FRONTEND_STAGE_BODY.to_string()
59 } else {
60 String::new()
61 };
62
63 let bin_copies = ctx
64 .bins
65 .iter()
66 .map(|b| format!("COPY --from=backend-builder /app/target/release/{b} /usr/local/bin/{b}"))
67 .collect::<Vec<_>>()
68 .join("\n");
69
70 let copy_dirs = ctx
71 .copy_dirs_present
72 .iter()
73 .map(|d| format!("COPY {d} {d}"))
74 .collect::<Vec<_>>()
75 .join("\n");
76
77 let runtime_apt = if ctx.runtime_apt.is_empty() {
78 String::new()
79 } else {
80 let pkgs = ctx.runtime_apt.join(" ");
81 format!(
82 "# ferro:runtime-apt\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends {pkgs} \\\n && rm -rf /var/lib/apt/lists/*"
83 )
84 };
85
86 let rust_image_tag = if ctx.rust_channel == "stable" {
92 "slim-bookworm".to_string()
93 } else {
94 format!("{}-slim-bookworm", ctx.rust_channel)
95 };
96
97 let entrypoint_block = format!(
98 "ENTRYPOINT [\"/usr/local/bin/{}\"]\nCMD [\"serve\"]",
99 ctx.web_bin
100 );
101
102 let rendered = DOCKERFILE_TPL
103 .replace("{{FRONTEND_STAGE}}", &frontend_stage)
104 .replace("{{RUST_IMAGE_TAG}}", &rust_image_tag)
105 .replace("{{ENTRYPOINT}}", &entrypoint_block)
106 .replace("{{BIN_COPIES}}", &bin_copies)
107 .replace("{{COPY_DIRS}}", ©_dirs)
108 .replace("{{RUNTIME_APT}}", &runtime_apt);
109
110 debug_assert!(
111 !rendered.contains("{{"),
112 "unresolved template token in rendered Dockerfile:\n{rendered}"
113 );
114 rendered
115}
116
117const FRONTEND_STAGE_BODY: &str = r#"
118FROM node:20-bookworm-slim AS frontend-builder
119WORKDIR /frontend
120COPY frontend/package.json frontend/package-lock.json* ./
121RUN npm ci || npm install
122COPY frontend/ ./
123RUN npm run build
124"#;
125
126pub fn read_rust_channel(project_root: &Path) -> String {
131 let path = project_root.join("rust-toolchain.toml");
132 let Ok(content) = fs::read_to_string(&path) else {
133 return "stable".to_string();
134 };
135 let Ok(parsed) = content.parse::<Value>() else {
136 return "stable".to_string();
137 };
138 parsed
139 .get("toolchain")
140 .and_then(|t| t.get("channel"))
141 .and_then(|c| c.as_str())
142 .map(String::from)
143 .unwrap_or_else(|| "stable".to_string())
144}
145
146pub fn docker_compose_template(
151 project_name: &str,
152 include_mailpit: bool,
153 include_minio: bool,
154) -> String {
155 let mailpit_service = if include_mailpit {
156 include_str!("files/docker/mailpit.service.tpl").replace("{project_name}", project_name)
157 } else {
158 String::new()
159 };
160
161 let minio_service = if include_minio {
162 include_str!("files/docker/minio.service.tpl").replace("{project_name}", project_name)
163 } else {
164 String::new()
165 };
166
167 let additional_volumes = if include_minio {
168 "\n minio_data:".to_string()
169 } else {
170 String::new()
171 };
172
173 include_str!("files/docker/docker-compose.yml.tpl")
174 .replace("{project_name}", project_name)
175 .replace("{mailpit_service}", &mailpit_service)
176 .replace("{minio_service}", &minio_service)
177 .replace("{additional_volumes}", &additional_volumes)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use tempfile::TempDir;
184
185 fn ctx() -> DockerContext {
186 DockerContext {
187 rust_channel: "stable".to_string(),
188 has_frontend: false,
189 bins: vec!["app".to_string()],
190 web_bin: "app".to_string(),
191 copy_dirs_present: vec![],
192 runtime_apt: vec![],
193 }
194 }
195
196 #[test]
197 fn frontend_stage_present_only_when_has_frontend() {
198 let mut c = ctx();
199 c.has_frontend = false;
200 assert!(!render_dockerfile(&c).contains("frontend-builder"));
201 c.has_frontend = true;
202 assert!(render_dockerfile(&c).contains("frontend-builder"));
203 }
204
205 #[test]
206 fn base_image_uses_rust_channel() {
207 let mut c = ctx();
208 c.rust_channel = "1.90.0".into();
209 let out = render_dockerfile(&c);
210 assert!(out.contains("rust:1.90.0-slim-bookworm"));
211 }
212
213 #[test]
217 fn stable_channel_emits_unversioned_slim_bookworm() {
218 let mut c = ctx();
219 c.rust_channel = "stable".into();
220 let out = render_dockerfile(&c);
221 assert!(out.contains("FROM rust:slim-bookworm AS chef"));
222 assert!(!out.contains("rust:stable-slim-bookworm"));
223 }
224
225 #[test]
226 fn multi_bin_emits_per_bin_copy_without_per_bin_build() {
227 let mut c = ctx();
228 c.bins = vec!["web".into(), "worker".into()];
229 c.web_bin = "web".into();
230 let out = render_dockerfile(&c);
231 assert_eq!(out.matches("cargo build --release --bin").count(), 0);
234 assert!(
235 out.contains("COPY --from=backend-builder /app/target/release/web /usr/local/bin/web")
236 );
237 assert!(out.contains(
238 "COPY --from=backend-builder /app/target/release/worker /usr/local/bin/worker"
239 ));
240 }
241
242 #[test]
243 fn copy_dirs_emits_only_present_entries() {
244 let mut c = ctx();
245 c.copy_dirs_present = vec!["themes".into(), "migrations".into()];
246 let out = render_dockerfile(&c);
247 assert!(out.contains("COPY themes themes"));
248 assert!(out.contains("COPY migrations migrations"));
249 assert!(!out.contains("COPY public public"));
250 }
251
252 #[test]
253 fn runtime_apt_empty_emits_no_block() {
254 let c = ctx();
255 let out = render_dockerfile(&c);
256 assert!(!out.contains("ferro:runtime-apt"));
257 }
258
259 #[test]
260 fn runtime_apt_nonempty_emits_marker_and_packages() {
261 let mut c = ctx();
262 c.runtime_apt = vec!["chromium".into(), "fonts-liberation".into()];
263 let out = render_dockerfile(&c);
264 assert!(out.contains("# ferro:runtime-apt"));
265 assert!(out.contains("chromium fonts-liberation"));
266 }
267
268 #[test]
269 fn dockerfile_copies_workspace_in_both_stages() {
270 let out = render_dockerfile(&ctx());
271 assert_eq!(out.matches("COPY . .").count(), 2);
274 assert!(!out.contains("Cargo.docker"));
275 }
276
277 #[test]
278 fn no_obsolete_phase_122_features() {
279 let out = render_dockerfile(&ctx());
280 for forbidden in ["GITHUB_TOKEN", "insteadOf", "rewrite-ferro-deps"] {
281 assert!(
282 !out.contains(forbidden),
283 "found forbidden token: {forbidden}"
284 );
285 }
286 }
287
288 #[test]
289 fn read_rust_channel_returns_default_when_missing() {
290 let tmp = TempDir::new().unwrap();
291 assert_eq!(read_rust_channel(tmp.path()), "stable");
292 }
293
294 #[test]
295 fn read_rust_channel_returns_channel_from_toolchain() {
296 let tmp = TempDir::new().unwrap();
297 fs::write(
298 tmp.path().join("rust-toolchain.toml"),
299 "[toolchain]\nchannel = \"1.90.0\"\n",
300 )
301 .unwrap();
302 assert_eq!(read_rust_channel(tmp.path()), "1.90.0");
303 }
304
305 #[test]
308 fn renderer_module_mentions_runtime_apt_marker() {
309 let mut c = ctx();
311 c.runtime_apt = vec!["foo".into()];
312 assert!(render_dockerfile(&c).contains("# ferro:runtime-apt"));
313 }
314
315 #[test]
317 fn dockerignore_is_static_passthrough() {
318 assert!(dockerignore_template().contains("database.db"));
319 }
320}
321
322#[cfg(test)]
323mod entrypoint_tests {
324 use super::*;
325
326 fn test_ctx_single(bin: &str) -> DockerContext {
327 DockerContext {
328 rust_channel: "stable".to_string(),
329 has_frontend: false,
330 bins: vec![bin.to_string()],
331 web_bin: bin.to_string(),
332 copy_dirs_present: vec![],
333 runtime_apt: vec![],
334 }
335 }
336
337 fn test_ctx_multi(web: &str, bins: &[&str]) -> DockerContext {
338 DockerContext {
339 rust_channel: "stable".to_string(),
340 has_frontend: false,
341 bins: bins.iter().map(|s| s.to_string()).collect(),
342 web_bin: web.to_string(),
343 copy_dirs_present: vec![],
344 runtime_apt: vec![],
345 }
346 }
347
348 #[test]
349 fn entrypoint_emitted_for_single_bin() {
350 let out = render_dockerfile(&test_ctx_single("myapp"));
351 assert!(out.contains(r#"ENTRYPOINT ["/usr/local/bin/myapp"]"#));
352 assert!(out.contains(r#"CMD ["serve"]"#));
353 }
354
355 #[test]
356 fn entrypoint_emitted_for_multi_bin() {
357 let out = render_dockerfile(&test_ctx_multi("api", &["api", "worker"]));
358 assert!(out.contains(r#"ENTRYPOINT ["/usr/local/bin/api"]"#));
359 }
360
361 #[test]
362 fn cmd_is_serve() {
363 let out = render_dockerfile(&test_ctx_single("x"));
364 assert!(out.contains(r#"CMD ["serve"]"#));
365 }
366
367 #[test]
368 fn dockerfile_single_build_invocation() {
369 let out = render_dockerfile(&test_ctx_single("x"));
370 let build_releases = out.matches("cargo build --release").count();
371 assert_eq!(
372 build_releases, 1,
373 "expected exactly one `cargo build --release`"
374 );
375 assert_eq!(out.matches("cargo build --release --bin").count(), 0);
376 }
377
378 #[test]
379 fn no_unresolved_tokens_in_dockerfile() {
380 let out = render_dockerfile(&test_ctx_single("x"));
381 assert!(!out.contains("{{"), "unresolved token(s) in output:\n{out}");
382 }
383
384 #[test]
385 fn dockerignore_whitelists_readme() {
386 let out = dockerignore_template();
387 assert!(out.contains("*.md"));
388 assert!(out.contains("!README.md"));
389 let idx_whitelist = out.find("!README.md").unwrap();
390 let before = &out[..idx_whitelist];
391 let last_line = before
392 .lines()
393 .rev()
394 .find(|l| !l.trim().is_empty())
395 .unwrap_or("");
396 assert!(
397 last_line.trim_start().starts_with('#'),
398 "expected comment above !README.md, got: {last_line}"
399 );
400 }
401}