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}
54
55/// Render a Dockerfile from the supplied context. Pure string substitution.
56pub 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    // Docker Hub publishes `rust:slim-bookworm` (unversioned, tracks stable)
87    // and `rust:<version>-slim-bookworm` (e.g. `rust:1.90.0-slim-bookworm`).
88    // There is no `rust:stable-slim-bookworm` tag, so when the channel is the
89    // generic "stable" we drop the prefix instead of constructing a phantom
90    // tag that fails to pull.
91    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}}", &copy_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
126/// Read `[toolchain] channel` from `<root>/rust-toolchain.toml`. Defaults to
127/// `"stable"` when the file is missing or unparseable.
128///
129/// Note: `[[bin]]` enumeration is handled by `crate::project::read_bins`.
130pub 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
146// ============================================================================
147// docker-compose.yml renderer (unchanged from Phase 122)
148// ============================================================================
149
150pub 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    /// Regression: `rust:stable-slim-bookworm` does not exist on Docker Hub.
214    /// When the channel is the generic "stable", we must emit the unversioned
215    /// `rust:slim-bookworm` tag (which tracks stable) instead.
216    #[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        // D-10: no per-bin build invocations; the plain `cargo build --release`
232        // already builds every declared [[bin]].
233        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        // `COPY . .` provides the workspace for planner + builder stages;
272        // no dual-manifest overlay is copied on top.
273        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    /// Phase 122.2 §2: the renderer must mention the runtime-apt marker so the
306    /// pattern is greppable for downstream tooling and tests.
307    #[test]
308    fn renderer_module_mentions_runtime_apt_marker() {
309        // ferro:runtime-apt
310        let mut c = ctx();
311        c.runtime_apt = vec!["foo".into()];
312        assert!(render_dockerfile(&c).contains("# ferro:runtime-apt"));
313    }
314
315    /// Generate docker-compose.yml for local development
316    #[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}