Skip to main content

ferro_cli/templates/
docker.rs

1// ============================================================================
2// Docker Templates
3// ============================================================================
4//
5// Phase 122.2 §2: new Dockerfile renderer driven by metadata + rust-toolchain
6// + [[bin]] enumeration. No GITHUB_TOKEN, no shell scripts, no workspace
7// member walking. The static .dockerignore template is reused for the
8// `docker:init` and `new` scaffolds.
9//
10// Phase 127 Plan 02: the ENTRYPOINT block is composed from a caller-resolved
11// `web_bin` (see `crate::deploy::bin_detect::detect_web_bin`) — the renderer
12// stays pure and does no I/O. `detect_web_bin` is called at the boundary in
13// `commands::docker_init` so tests can exercise the renderer with any
14// arbitrary web_bin without touching the filesystem.
15
16use 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
24/// Static `.dockerignore` body. Phase 122.2 Plan 06 owns the canonical content.
25pub fn dockerignore_template() -> &'static str {
26    DOCKERIGNORE_TPL
27}
28
29/// Inputs the Dockerfile renderer needs. All fields come from the project
30/// (rust-toolchain.toml, Cargo.toml, on-disk dirs, deploy metadata) and are
31/// fully resolved by the caller — `render_dockerfile` itself does no I/O.
32#[derive(Debug, Clone)]
33pub struct DockerContext {
34    /// Rust release channel — e.g. "stable", "1.90.0".
35    pub rust_channel: String,
36    /// Whether `frontend/package.json` exists in the project root.
37    pub has_frontend: bool,
38    /// All `[[bin]]` names declared in the project Cargo.toml.
39    ///
40    /// Callers obtain bin entries via `crate::project::read_bins` (which
41    /// returns `Vec<BinEntry>`) and convert to names at the call site:
42    /// `bins: read_bins(root).into_iter().map(|b| b.name).collect()`.
43    /// The renderer only needs names; keeping this field as `Vec<String>`
44    /// preserves the "pure render" boundary (no project-module types leak into
45    /// the template module).
46    pub bins: Vec<String>,
47    /// Resolved web bin name (D-02). Used for the runtime ENTRYPOINT.
48    pub web_bin: String,
49    /// `metadata.copy_dirs` filtered down to dirs that actually exist.
50    pub copy_dirs_present: Vec<String>,
51    /// Verbatim `metadata.runtime_apt`.
52    pub runtime_apt: Vec<String>,
53    /// Resolved ferro-cli version used by the `types-gen` Docker stage's
54    /// `cargo install ferro-cli --version ...` pin. Caller-resolved via
55    /// `resolve_ferro_version(root)`, which parses the project's Cargo.lock
56    /// for the `ferro-rs` package and falls back to `env!("CARGO_PKG_VERSION")`
57    /// when absent. Never empty.
58    ///
59    /// Phase 156 (D-16/D-21): closes the convention contradiction by ensuring
60    /// `frontend/src/types/` is regenerated inside the Docker build context.
61    pub ferro_version: String,
62}
63
64/// Render a Dockerfile from the supplied context. Pure string substitution.
65pub 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    // Docker Hub publishes `rust:slim-bookworm` (unversioned, tracks stable)
96    // and `rust:<version>-slim-bookworm` (e.g. `rust:1.90.0-slim-bookworm`).
97    // There is no `rust:stable-slim-bookworm` tag, so when the channel is the
98    // generic "stable" we drop the prefix instead of constructing a phantom
99    // tag that fails to pull.
100    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}}", &copy_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
127/// Phase 156 §6 (D-15): the new `types-gen` Rust stage. Emitted unconditionally
128/// when `has_frontend == true`. Uses the same `rust:{{RUST_IMAGE_TAG}}` base as
129/// the backend builder chain; the slim variant has the full Rust toolchain
130/// (verified — see RESEARCH Pitfall 5).
131///
132/// Pins `ferro-cli` to `{{FERRO_VERSION}}` (resolved from the project's
133/// Cargo.lock). The `--locked` flag uses ferro-cli's published Cargo.lock for
134/// reproducibility.
135const 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
143/// Phase 156 §6 (D-15): the frontend stage gains `COPY --from=types-gen` so
144/// the gitignored `frontend/src/types/` is materialized inside the build
145/// context before `npm run build` (which invokes `tsc`). Replaces
146/// `FRONTEND_STAGE_BODY` when `has_frontend == true`. The COPY line is
147/// positioned immediately BEFORE `RUN npm run build` — order matters: tsc
148/// resolves `./types/*` imports during `npm run build`.
149const 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
159/// Read `[toolchain] channel` from `<root>/rust-toolchain.toml`. Defaults to
160/// `"stable"` when the file is missing or unparseable.
161///
162/// Note: `[[bin]]` enumeration is handled by `crate::project::read_bins`.
163pub 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
179/// Phase 156 §6 (D-16/D-21): resolve the ferro-cli version to pin in the
180/// `types-gen` Docker stage. Parses the project's `Cargo.lock` for the
181/// `ferro-rs` package; falls back to the current binary's own version
182/// (`env!("CARGO_PKG_VERSION")`) when the lockfile is absent or has no
183/// `ferro-rs` entry. Never returns an empty string.
184///
185/// Used by `crate::commands::docker_init`, `crate::doctor::checks::docker_template_drift`,
186/// and the `gestiscilo_fixture` integration test to construct `DockerContext` at
187/// the I/O boundary before passing the resolved version into the pure renderer.
188pub 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
207// ============================================================================
208// docker-compose.yml renderer (unchanged from Phase 122)
209// ============================================================================
210
211pub 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    /// Regression: `rust:stable-slim-bookworm` does not exist on Docker Hub.
393    /// When the channel is the generic "stable", we must emit the unversioned
394    /// `rust:slim-bookworm` tag (which tracks stable) instead.
395    #[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        // D-10: no per-bin build invocations; the plain `cargo build --release`
411        // already builds every declared [[bin]].
412        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        // `COPY . .` provides the workspace for planner + builder stages;
451        // no dual-manifest overlay is copied on top.
452        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    /// Phase 122.2 §2: the renderer must mention the runtime-apt marker so the
485    /// pattern is greppable for downstream tooling and tests.
486    #[test]
487    fn renderer_module_mentions_runtime_apt_marker() {
488        // ferro:runtime-apt
489        let mut c = ctx();
490        c.runtime_apt = vec!["foo".into()];
491        assert!(render_dockerfile(&c).contains("# ferro:runtime-apt"));
492    }
493
494    /// Generate docker-compose.yml for local development
495    #[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}