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 pub ferro_version: String,
62}
63
64pub fn render_dockerfile(ctx: &DockerContext) -> String {
66 let frontend_stage = if ctx.has_frontend {
67 format!("{TYPES_GEN_STAGE_BODY}{FRONTEND_STAGE_WITH_TYPES_COPY_BODY}")
68 } else {
69 String::new()
70 };
71
72 let bin_copies = ctx
73 .bins
74 .iter()
75 .map(|b| format!("COPY --from=backend-builder /app/target/release/{b} /usr/local/bin/{b}"))
76 .collect::<Vec<_>>()
77 .join("\n");
78
79 let copy_dirs = ctx
80 .copy_dirs_present
81 .iter()
82 .map(|d| format!("COPY {d} {d}"))
83 .collect::<Vec<_>>()
84 .join("\n");
85
86 let runtime_apt = if ctx.runtime_apt.is_empty() {
87 String::new()
88 } else {
89 let pkgs = ctx.runtime_apt.join(" ");
90 format!(
91 "# ferro:runtime-apt\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends {pkgs} \\\n && rm -rf /var/lib/apt/lists/*"
92 )
93 };
94
95 let rust_image_tag = if ctx.rust_channel == "stable" {
101 "slim-bookworm".to_string()
102 } else {
103 format!("{}-slim-bookworm", ctx.rust_channel)
104 };
105
106 let entrypoint_block = format!(
107 "ENTRYPOINT [\"/usr/local/bin/{}\"]\nCMD [\"serve\"]",
108 ctx.web_bin
109 );
110
111 let rendered = DOCKERFILE_TPL
112 .replace("{{FRONTEND_STAGE}}", &frontend_stage)
113 .replace("{{RUST_IMAGE_TAG}}", &rust_image_tag)
114 .replace("{{FERRO_VERSION}}", &ctx.ferro_version)
115 .replace("{{ENTRYPOINT}}", &entrypoint_block)
116 .replace("{{BIN_COPIES}}", &bin_copies)
117 .replace("{{COPY_DIRS}}", ©_dirs)
118 .replace("{{RUNTIME_APT}}", &runtime_apt);
119
120 debug_assert!(
121 !rendered.contains("{{"),
122 "unresolved template token in rendered Dockerfile:\n{rendered}"
123 );
124 rendered
125}
126
127const TYPES_GEN_STAGE_BODY: &str = r#"
136FROM rust:{{RUST_IMAGE_TAG}} AS types-gen
137WORKDIR /app
138RUN cargo install ferro-cli --version {{FERRO_VERSION}} --locked
139COPY . .
140RUN ferro generate-types
141"#;
142
143const FRONTEND_STAGE_WITH_TYPES_COPY_BODY: &str = r#"
150FROM node:20-bookworm-slim AS frontend-builder
151WORKDIR /frontend
152COPY frontend/package.json frontend/package-lock.json* ./
153RUN npm ci || npm install
154COPY frontend/ ./
155COPY --from=types-gen /app/frontend/src/types ./src/types
156RUN npm run build
157"#;
158
159pub fn read_rust_channel(project_root: &Path) -> String {
164 let path = project_root.join("rust-toolchain.toml");
165 let Ok(content) = fs::read_to_string(&path) else {
166 return "stable".to_string();
167 };
168 let Ok(parsed) = content.parse::<Value>() else {
169 return "stable".to_string();
170 };
171 parsed
172 .get("toolchain")
173 .and_then(|t| t.get("channel"))
174 .and_then(|c| c.as_str())
175 .map(String::from)
176 .unwrap_or_else(|| "stable".to_string())
177}
178
179pub fn resolve_ferro_version(project_root: &Path) -> String {
189 if let Ok(lock) = fs::read_to_string(project_root.join("Cargo.lock")) {
190 if let Ok(parsed) = lock.parse::<Value>() {
191 if let Some(pkgs) = parsed.get("package").and_then(|v| v.as_array()) {
192 for pkg in pkgs {
193 let name = pkg.get("name").and_then(|n| n.as_str());
194 let ver = pkg.get("version").and_then(|v| v.as_str());
195 if name == Some("ferro-rs") {
196 if let Some(v) = ver {
197 return v.to_string();
198 }
199 }
200 }
201 }
202 }
203 }
204 env!("CARGO_PKG_VERSION").to_string()
205}
206
207pub fn docker_compose_template(
212 project_name: &str,
213 include_mailpit: bool,
214 include_minio: bool,
215) -> String {
216 let mailpit_service = if include_mailpit {
217 include_str!("files/docker/mailpit.service.tpl").replace("{project_name}", project_name)
218 } else {
219 String::new()
220 };
221
222 let minio_service = if include_minio {
223 include_str!("files/docker/minio.service.tpl").replace("{project_name}", project_name)
224 } else {
225 String::new()
226 };
227
228 let additional_volumes = if include_minio {
229 "\n minio_data:".to_string()
230 } else {
231 String::new()
232 };
233
234 include_str!("files/docker/docker-compose.yml.tpl")
235 .replace("{project_name}", project_name)
236 .replace("{mailpit_service}", &mailpit_service)
237 .replace("{minio_service}", &minio_service)
238 .replace("{additional_volumes}", &additional_volumes)
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use tempfile::TempDir;
245
246 fn ctx() -> DockerContext {
247 DockerContext {
248 rust_channel: "stable".to_string(),
249 has_frontend: false,
250 bins: vec!["app".to_string()],
251 web_bin: "app".to_string(),
252 copy_dirs_present: vec![],
253 runtime_apt: vec![],
254 ferro_version: "0.0.0-test".to_string(),
255 }
256 }
257
258 #[test]
259 fn frontend_stage_present_only_when_has_frontend() {
260 let mut c = ctx();
261 c.has_frontend = false;
262 assert!(!render_dockerfile(&c).contains("frontend-builder"));
263 c.has_frontend = true;
264 assert!(render_dockerfile(&c).contains("frontend-builder"));
265 }
266
267 #[test]
268 fn types_gen_stage_present_when_has_frontend() {
269 let mut c = ctx();
270 c.has_frontend = true;
271 let out = render_dockerfile(&c);
272 assert!(
273 out.contains("AS types-gen"),
274 "expected types-gen stage; got:\n{out}"
275 );
276 }
277
278 #[test]
279 fn types_gen_stage_absent_when_no_frontend() {
280 let mut c = ctx();
281 c.has_frontend = false;
282 let out = render_dockerfile(&c);
283 assert!(!out.contains("AS types-gen"));
284 assert!(!out.contains("frontend-builder"));
285 }
286
287 #[test]
288 fn copy_from_types_gen_before_npm_build() {
289 let mut c = ctx();
290 c.has_frontend = true;
291 let out = render_dockerfile(&c);
292 let copy_pos = out
293 .find("COPY --from=types-gen /app/frontend/src/types ./src/types")
294 .expect("missing COPY --from=types-gen line");
295 let build_pos = out
296 .find("RUN npm run build")
297 .expect("missing npm run build");
298 assert!(
299 copy_pos < build_pos,
300 "COPY --from=types-gen must appear before RUN npm run build (copy={copy_pos}, build={build_pos})"
301 );
302 }
303
304 #[test]
305 fn ferro_version_token_resolved() {
306 let mut c = ctx();
307 c.has_frontend = true;
308 c.ferro_version = "9.9.9".to_string();
309 let out = render_dockerfile(&c);
310 assert!(
311 out.contains("--version 9.9.9"),
312 "expected --version 9.9.9; got:\n{out}"
313 );
314 assert!(!out.contains("{{FERRO_VERSION}}"));
315 }
316
317 #[test]
318 fn types_gen_stage_uses_same_rust_image_tag() {
319 let mut c = ctx();
320 c.has_frontend = true;
321 c.rust_channel = "stable".into();
322 let out = render_dockerfile(&c);
323 assert!(out.contains("FROM rust:slim-bookworm AS types-gen"));
324 c.rust_channel = "1.90.0".into();
325 let out = render_dockerfile(&c);
326 assert!(out.contains("FROM rust:1.90.0-slim-bookworm AS types-gen"));
327 }
328
329 #[test]
330 fn no_unresolved_tokens_with_frontend_stage() {
331 let mut c = ctx();
332 c.has_frontend = true;
333 let out = render_dockerfile(&c);
334 assert!(
335 !out.contains("{{"),
336 "unresolved template token in rendered Dockerfile:\n{out}"
337 );
338 }
339
340 #[test]
341 fn resolve_ferro_version_reads_cargo_lock() {
342 use std::fs;
343 let tmp = TempDir::new().unwrap();
344 fs::write(
345 tmp.path().join("Cargo.lock"),
346 r#"
347[[package]]
348name = "other-crate"
349version = "0.5.0"
350
351[[package]]
352name = "ferro-rs"
353version = "1.2.3"
354 "#,
355 )
356 .unwrap();
357 assert_eq!(resolve_ferro_version(tmp.path()), "1.2.3");
358 }
359
360 #[test]
361 fn resolve_ferro_version_falls_back_when_lockfile_absent() {
362 let tmp = TempDir::new().unwrap();
363 let v = resolve_ferro_version(tmp.path());
364 assert_eq!(v, env!("CARGO_PKG_VERSION"));
365 assert!(!v.is_empty());
366 }
367
368 #[test]
369 fn resolve_ferro_version_falls_back_when_ferro_rs_absent() {
370 use std::fs;
371 let tmp = TempDir::new().unwrap();
372 fs::write(
373 tmp.path().join("Cargo.lock"),
374 r#"
375[[package]]
376name = "other-crate"
377version = "0.5.0"
378 "#,
379 )
380 .unwrap();
381 assert_eq!(resolve_ferro_version(tmp.path()), env!("CARGO_PKG_VERSION"));
382 }
383
384 #[test]
385 fn base_image_uses_rust_channel() {
386 let mut c = ctx();
387 c.rust_channel = "1.90.0".into();
388 let out = render_dockerfile(&c);
389 assert!(out.contains("rust:1.90.0-slim-bookworm"));
390 }
391
392 #[test]
396 fn stable_channel_emits_unversioned_slim_bookworm() {
397 let mut c = ctx();
398 c.rust_channel = "stable".into();
399 let out = render_dockerfile(&c);
400 assert!(out.contains("FROM rust:slim-bookworm AS chef"));
401 assert!(!out.contains("rust:stable-slim-bookworm"));
402 }
403
404 #[test]
405 fn multi_bin_emits_per_bin_copy_without_per_bin_build() {
406 let mut c = ctx();
407 c.bins = vec!["web".into(), "worker".into()];
408 c.web_bin = "web".into();
409 let out = render_dockerfile(&c);
410 assert_eq!(out.matches("cargo build --release --bin").count(), 0);
413 assert!(
414 out.contains("COPY --from=backend-builder /app/target/release/web /usr/local/bin/web")
415 );
416 assert!(out.contains(
417 "COPY --from=backend-builder /app/target/release/worker /usr/local/bin/worker"
418 ));
419 }
420
421 #[test]
422 fn copy_dirs_emits_only_present_entries() {
423 let mut c = ctx();
424 c.copy_dirs_present = vec!["themes".into(), "migrations".into()];
425 let out = render_dockerfile(&c);
426 assert!(out.contains("COPY themes themes"));
427 assert!(out.contains("COPY migrations migrations"));
428 assert!(!out.contains("COPY public public"));
429 }
430
431 #[test]
432 fn runtime_apt_empty_emits_no_block() {
433 let c = ctx();
434 let out = render_dockerfile(&c);
435 assert!(!out.contains("ferro:runtime-apt"));
436 }
437
438 #[test]
439 fn runtime_apt_nonempty_emits_marker_and_packages() {
440 let mut c = ctx();
441 c.runtime_apt = vec!["chromium".into(), "fonts-liberation".into()];
442 let out = render_dockerfile(&c);
443 assert!(out.contains("# ferro:runtime-apt"));
444 assert!(out.contains("chromium fonts-liberation"));
445 }
446
447 #[test]
448 fn dockerfile_copies_workspace_in_both_stages() {
449 let out = render_dockerfile(&ctx());
450 assert_eq!(out.matches("COPY . .").count(), 2);
453 assert!(!out.contains("Cargo.docker"));
454 }
455
456 #[test]
457 fn no_obsolete_phase_122_features() {
458 let out = render_dockerfile(&ctx());
459 for forbidden in ["GITHUB_TOKEN", "insteadOf", "rewrite-ferro-deps"] {
460 assert!(
461 !out.contains(forbidden),
462 "found forbidden token: {forbidden}"
463 );
464 }
465 }
466
467 #[test]
468 fn read_rust_channel_returns_default_when_missing() {
469 let tmp = TempDir::new().unwrap();
470 assert_eq!(read_rust_channel(tmp.path()), "stable");
471 }
472
473 #[test]
474 fn read_rust_channel_returns_channel_from_toolchain() {
475 let tmp = TempDir::new().unwrap();
476 fs::write(
477 tmp.path().join("rust-toolchain.toml"),
478 "[toolchain]\nchannel = \"1.90.0\"\n",
479 )
480 .unwrap();
481 assert_eq!(read_rust_channel(tmp.path()), "1.90.0");
482 }
483
484 #[test]
487 fn renderer_module_mentions_runtime_apt_marker() {
488 let mut c = ctx();
490 c.runtime_apt = vec!["foo".into()];
491 assert!(render_dockerfile(&c).contains("# ferro:runtime-apt"));
492 }
493
494 #[test]
496 fn dockerignore_is_static_passthrough() {
497 assert!(dockerignore_template().contains("database.db"));
498 }
499}
500
501#[cfg(test)]
502mod entrypoint_tests {
503 use super::*;
504
505 fn test_ctx_single(bin: &str) -> DockerContext {
506 DockerContext {
507 rust_channel: "stable".to_string(),
508 has_frontend: false,
509 bins: vec![bin.to_string()],
510 web_bin: bin.to_string(),
511 copy_dirs_present: vec![],
512 runtime_apt: vec![],
513 ferro_version: "0.0.0-test".to_string(),
514 }
515 }
516
517 fn test_ctx_multi(web: &str, bins: &[&str]) -> DockerContext {
518 DockerContext {
519 rust_channel: "stable".to_string(),
520 has_frontend: false,
521 bins: bins.iter().map(|s| s.to_string()).collect(),
522 web_bin: web.to_string(),
523 copy_dirs_present: vec![],
524 runtime_apt: vec![],
525 ferro_version: "0.0.0-test".to_string(),
526 }
527 }
528
529 #[test]
530 fn entrypoint_emitted_for_single_bin() {
531 let out = render_dockerfile(&test_ctx_single("myapp"));
532 assert!(out.contains(r#"ENTRYPOINT ["/usr/local/bin/myapp"]"#));
533 assert!(out.contains(r#"CMD ["serve"]"#));
534 }
535
536 #[test]
537 fn entrypoint_emitted_for_multi_bin() {
538 let out = render_dockerfile(&test_ctx_multi("api", &["api", "worker"]));
539 assert!(out.contains(r#"ENTRYPOINT ["/usr/local/bin/api"]"#));
540 }
541
542 #[test]
543 fn cmd_is_serve() {
544 let out = render_dockerfile(&test_ctx_single("x"));
545 assert!(out.contains(r#"CMD ["serve"]"#));
546 }
547
548 #[test]
549 fn dockerfile_single_build_invocation() {
550 let out = render_dockerfile(&test_ctx_single("x"));
551 let build_releases = out.matches("cargo build --release").count();
552 assert_eq!(
553 build_releases, 1,
554 "expected exactly one `cargo build --release`"
555 );
556 assert_eq!(out.matches("cargo build --release --bin").count(), 0);
557 }
558
559 #[test]
560 fn no_unresolved_tokens_in_dockerfile() {
561 let out = render_dockerfile(&test_ctx_single("x"));
562 assert!(!out.contains("{{"), "unresolved token(s) in output:\n{out}");
563 }
564
565 #[test]
566 fn dockerignore_whitelists_readme() {
567 let out = dockerignore_template();
568 assert!(out.contains("*.md"));
569 assert!(out.contains("!README.md"));
570 let idx_whitelist = out.find("!README.md").unwrap();
571 let before = &out[..idx_whitelist];
572 let last_line = before
573 .lines()
574 .rev()
575 .find(|l| !l.trim().is_empty())
576 .unwrap_or("");
577 assert!(
578 last_line.trim_start().starts_with('#'),
579 "expected comment above !README.md, got: {last_line}"
580 );
581 }
582}