Skip to main content

zlayer_builder/templates/
mod.rs

1//! Runtime templates for `ZLayer` builder
2//!
3//! This module provides pre-built Dockerfile templates for common runtimes,
4//! allowing users to build container images without writing Dockerfiles.
5//!
6//! # Usage
7//!
8//! Templates can be used via the `zlayer build` command:
9//!
10//! ```bash
11//! # Use a specific runtime template
12//! zlayer build --runtime node20
13//!
14//! # Auto-detect runtime from project files
15//! zlayer build --detect-runtime
16//! ```
17//!
18//! # Available Runtimes
19//!
20//! - **Node.js 20** (`node20`): Production-ready Node.js 20 with Alpine base
21//! - **Node.js 22** (`node22`): Production-ready Node.js 22 with Alpine base
22//! - **Python 3.12** (`python312`): Python 3.12 slim with pip packages
23//! - **Python 3.13** (`python313`): Python 3.13 slim with pip packages
24//! - **Rust** (`rust`): Static binary build with musl
25//! - **Go** (`go`): Static binary build with Alpine
26//! - **Deno** (`deno`): Official Deno runtime
27//! - **Bun** (`bun`): Official Bun runtime
28//!
29//! # Auto-Detection
30//!
31//! The [`detect_runtime`] function can automatically detect the appropriate
32//! runtime based on files present in the project directory:
33//!
34//! - `package.json` -> Node.js (unless Bun or Deno indicators present)
35//! - `bun.lockb` -> Bun
36//! - `deno.json` or `deno.jsonc` -> Deno
37//! - `Cargo.toml` -> Rust
38//! - `requirements.txt`, `pyproject.toml`, `setup.py` -> Python
39//! - `go.mod` -> Go
40
41mod detect;
42
43use std::fmt;
44use std::path::Path;
45use std::str::FromStr;
46
47pub use detect::{detect_runtime, detect_runtime_with_version};
48
49/// Supported runtime environments
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum Runtime {
52    /// Node.js 20 (LTS)
53    Node20,
54    /// Node.js 22 (Current)
55    Node22,
56    /// Python 3.12
57    Python312,
58    /// Python 3.13
59    Python313,
60    /// Rust (latest stable)
61    Rust,
62    /// Go (latest stable)
63    Go,
64    /// Deno (latest)
65    Deno,
66    /// Bun (latest)
67    Bun,
68    /// `WebAssembly` (delegates to `wasm:` build mode).
69    ///
70    /// The associated [`WasmTargetHint`] is a best-effort indicator of whether
71    /// the project builds a raw WASI module or a WASI component. Downstream
72    /// build logic treats this as guidance only; the actual target is still
73    /// driven by the `ZImagefile` `wasm:` section (or its defaults).
74    Wasm(WasmTargetHint),
75}
76
77/// Hint for the kind of `WebAssembly` artifact a project produces.
78///
79/// This is populated by [`Runtime::detect_from_path`] (via the `detect` module)
80/// and carried through `Runtime::Wasm(hint)` so callers can pick reasonable
81/// defaults when delegating to the `wasm:` build mode.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
83pub enum WasmTargetHint {
84    /// WASI preview1 / preview2 raw module (no component wrapper).
85    Module,
86    /// WASI component (cargo-component, jco, componentize-py, ...).
87    Component,
88    /// Unknown / let the builder pick its own default (currently preview2).
89    #[default]
90    Auto,
91}
92
93impl WasmTargetHint {
94    /// Short lowercase name used in diagnostics and YAML emission.
95    #[must_use]
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            Self::Module => "module",
99            Self::Component => "component",
100            Self::Auto => "auto",
101        }
102    }
103}
104
105impl Runtime {
106    /// Get all available runtimes
107    #[must_use]
108    pub fn all() -> &'static [RuntimeInfo] {
109        &[
110            RuntimeInfo {
111                runtime: Runtime::Node20,
112                name: "node20",
113                description: "Node.js 20 (LTS) - Alpine-based, production optimized",
114                detect_files: &["package.json"],
115            },
116            RuntimeInfo {
117                runtime: Runtime::Node22,
118                name: "node22",
119                description: "Node.js 22 (Current) - Alpine-based, production optimized",
120                detect_files: &["package.json"],
121            },
122            RuntimeInfo {
123                runtime: Runtime::Python312,
124                name: "python312",
125                description: "Python 3.12 - Slim Debian-based with pip",
126                detect_files: &["requirements.txt", "pyproject.toml", "setup.py"],
127            },
128            RuntimeInfo {
129                runtime: Runtime::Python313,
130                name: "python313",
131                description: "Python 3.13 - Slim Debian-based with pip",
132                detect_files: &["requirements.txt", "pyproject.toml", "setup.py"],
133            },
134            RuntimeInfo {
135                runtime: Runtime::Rust,
136                name: "rust",
137                description: "Rust - Static musl binary, minimal Alpine runtime",
138                detect_files: &["Cargo.toml"],
139            },
140            RuntimeInfo {
141                runtime: Runtime::Go,
142                name: "go",
143                description: "Go - Static binary, minimal Alpine runtime",
144                detect_files: &["go.mod"],
145            },
146            RuntimeInfo {
147                runtime: Runtime::Deno,
148                name: "deno",
149                description: "Deno - Official runtime with TypeScript support",
150                detect_files: &["deno.json", "deno.jsonc"],
151            },
152            RuntimeInfo {
153                runtime: Runtime::Bun,
154                name: "bun",
155                description: "Bun - Fast JavaScript runtime and bundler",
156                detect_files: &["bun.lockb"],
157            },
158            RuntimeInfo {
159                runtime: Runtime::Wasm(WasmTargetHint::Auto),
160                name: "wasm",
161                description: "WebAssembly - Delegates to wasm: build mode (auto-detects target)",
162                detect_files: &["cargo-component.toml", "componentize-py.config"],
163            },
164        ]
165    }
166
167    /// Parse a runtime from its name
168    #[must_use]
169    pub fn from_name(name: &str) -> Option<Runtime> {
170        let name_lower = name.to_lowercase();
171        match name_lower.as_str() {
172            "node20" | "node-20" | "nodejs20" | "node" => Some(Runtime::Node20),
173            "node22" | "node-22" | "nodejs22" => Some(Runtime::Node22),
174            "python312" | "python-312" | "python3.12" | "python" => Some(Runtime::Python312),
175            "python313" | "python-313" | "python3.13" => Some(Runtime::Python313),
176            "rust" | "rs" => Some(Runtime::Rust),
177            "go" | "golang" => Some(Runtime::Go),
178            "deno" => Some(Runtime::Deno),
179            "bun" => Some(Runtime::Bun),
180            "wasm" | "webassembly" => Some(Runtime::Wasm(WasmTargetHint::Auto)),
181            "wasm-module" | "wasm-preview1" | "wasm-preview2" => {
182                Some(Runtime::Wasm(WasmTargetHint::Module))
183            }
184            "wasm-component" | "wasi-component" => Some(Runtime::Wasm(WasmTargetHint::Component)),
185            _ => None,
186        }
187    }
188
189    /// Get information about this runtime
190    ///
191    /// # Panics
192    ///
193    /// Panics if the runtime variant is missing from the static info table (internal invariant).
194    #[must_use]
195    pub fn info(&self) -> &'static RuntimeInfo {
196        // WASM variants all share a single info entry regardless of the
197        // [`WasmTargetHint`] payload, so we collapse to the `Auto` hint for
198        // lookup.
199        let lookup = match self {
200            Runtime::Wasm(_) => Runtime::Wasm(WasmTargetHint::Auto),
201            other => *other,
202        };
203        Runtime::all()
204            .iter()
205            .find(|info| info.runtime == lookup)
206            .expect("All runtimes must have info")
207    }
208
209    /// Get the Dockerfile template for this runtime
210    ///
211    /// # Note on `Runtime::Wasm`
212    ///
213    /// The WASM variant does not produce a Dockerfile — it is a sentinel that
214    /// tells the builder to delegate to the `wasm:` build mode. The returned
215    /// string is a `ZImagefile` YAML snippet (see [`Runtime::wasm_zimagefile`])
216    /// so callers that feed `template()` output through the `ZImagefile` parser
217    /// will cleanly route into the WASM build path. Callers that feed it
218    /// through the Dockerfile parser must special-case `Runtime::Wasm(_)`.
219    #[must_use]
220    pub fn template(&self) -> &'static str {
221        match self {
222            Runtime::Node20 => include_str!("dockerfiles/node20.Dockerfile"),
223            Runtime::Node22 => include_str!("dockerfiles/node22.Dockerfile"),
224            Runtime::Python312 => include_str!("dockerfiles/python312.Dockerfile"),
225            Runtime::Python313 => include_str!("dockerfiles/python313.Dockerfile"),
226            Runtime::Rust => include_str!("dockerfiles/rust.Dockerfile"),
227            Runtime::Go => include_str!("dockerfiles/go.Dockerfile"),
228            Runtime::Deno => include_str!("dockerfiles/deno.Dockerfile"),
229            Runtime::Bun => include_str!("dockerfiles/bun.Dockerfile"),
230            Runtime::Wasm(hint) => Self::wasm_zimagefile(*hint),
231        }
232    }
233
234    /// Return a minimal `ZImagefile` YAML snippet for the WASM runtime.
235    ///
236    /// The snippet sets `wasm:` mode with defaults appropriate for the given
237    /// [`WasmTargetHint`]. The parser's `validate_wasm` accepts these defaults,
238    /// and the builder's WASM path will auto-detect the source language when
239    /// `language` is omitted.
240    fn wasm_zimagefile(hint: WasmTargetHint) -> &'static str {
241        match hint {
242            // Components default to preview2 (WASI component model).
243            WasmTargetHint::Component => "wasm:\n  target: preview2\n",
244            // Raw modules default to preview1, which is what a bare
245            // `wasm32-wasip1` Cargo build produces.
246            WasmTargetHint::Module => "wasm:\n  target: preview1\n",
247            // Unknown — pick the modern default and let the builder decide.
248            WasmTargetHint::Auto => "wasm: {}\n",
249        }
250    }
251
252    /// Get the canonical name for this runtime
253    #[must_use]
254    pub fn name(&self) -> &'static str {
255        self.info().name
256    }
257}
258
259impl fmt::Display for Runtime {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        write!(f, "{}", self.name())
262    }
263}
264
265impl FromStr for Runtime {
266    type Err = String;
267
268    fn from_str(s: &str) -> Result<Self, Self::Err> {
269        Runtime::from_name(s).ok_or_else(|| format!("Unknown runtime: {s}"))
270    }
271}
272
273/// Information about a runtime template
274#[derive(Debug, Clone, Copy)]
275pub struct RuntimeInfo {
276    /// The runtime enum value
277    pub runtime: Runtime,
278    /// Short name used in CLI (e.g., "node20")
279    pub name: &'static str,
280    /// Human-readable description
281    pub description: &'static str,
282    /// Files that indicate this runtime should be used
283    pub detect_files: &'static [&'static str],
284}
285
286/// List all available templates
287#[must_use]
288pub fn list_templates() -> Vec<&'static RuntimeInfo> {
289    Runtime::all().iter().collect()
290}
291
292/// Get template content for a runtime
293#[must_use]
294pub fn get_template(runtime: Runtime) -> &'static str {
295    runtime.template()
296}
297
298/// Get template content by runtime name
299#[must_use]
300pub fn get_template_by_name(name: &str) -> Option<&'static str> {
301    Runtime::from_name(name).map(|r| r.template())
302}
303
304/// Resolve runtime from either explicit name or auto-detection
305pub fn resolve_runtime(
306    runtime_name: Option<&str>,
307    context_path: impl AsRef<Path>,
308    use_version_hints: bool,
309) -> Option<Runtime> {
310    // If explicitly specified, use that
311    if let Some(name) = runtime_name {
312        return Runtime::from_name(name);
313    }
314
315    // Otherwise, auto-detect
316    if use_version_hints {
317        detect_runtime_with_version(context_path)
318    } else {
319        detect_runtime(context_path)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::Dockerfile;
327    use std::fs;
328    use tempfile::TempDir;
329
330    #[test]
331    fn test_runtime_from_name() {
332        assert_eq!(Runtime::from_name("node20"), Some(Runtime::Node20));
333        assert_eq!(Runtime::from_name("Node20"), Some(Runtime::Node20));
334        assert_eq!(Runtime::from_name("node"), Some(Runtime::Node20));
335        assert_eq!(Runtime::from_name("python"), Some(Runtime::Python312));
336        assert_eq!(Runtime::from_name("rust"), Some(Runtime::Rust));
337        assert_eq!(Runtime::from_name("go"), Some(Runtime::Go));
338        assert_eq!(Runtime::from_name("golang"), Some(Runtime::Go));
339        assert_eq!(Runtime::from_name("deno"), Some(Runtime::Deno));
340        assert_eq!(Runtime::from_name("bun"), Some(Runtime::Bun));
341        assert_eq!(Runtime::from_name("unknown"), None);
342    }
343
344    #[test]
345    fn test_runtime_info() {
346        let info = Runtime::Node20.info();
347        assert_eq!(info.name, "node20");
348        assert!(info.description.contains("Node.js"));
349        assert!(info.detect_files.contains(&"package.json"));
350    }
351
352    #[test]
353    fn test_all_templates_parse_correctly() {
354        for info in Runtime::all() {
355            // The WASM runtime emits a ZImagefile YAML snippet (routed to the
356            // `wasm:` build mode), not a Dockerfile — skip the Dockerfile
357            // parser for that variant.
358            if matches!(info.runtime, Runtime::Wasm(_)) {
359                continue;
360            }
361
362            let template = info.runtime.template();
363            let result = Dockerfile::parse(template);
364            assert!(
365                result.is_ok(),
366                "Template {} failed to parse: {:?}",
367                info.name,
368                result.err()
369            );
370
371            let dockerfile = result.unwrap();
372            assert!(
373                !dockerfile.stages.is_empty(),
374                "Template {} has no stages",
375                info.name
376            );
377        }
378    }
379
380    #[test]
381    fn test_runtime_wasm_from_name() {
382        assert_eq!(
383            Runtime::from_name("wasm"),
384            Some(Runtime::Wasm(WasmTargetHint::Auto))
385        );
386        assert_eq!(
387            Runtime::from_name("WASM"),
388            Some(Runtime::Wasm(WasmTargetHint::Auto))
389        );
390        assert_eq!(
391            Runtime::from_name("webassembly"),
392            Some(Runtime::Wasm(WasmTargetHint::Auto))
393        );
394        assert_eq!(
395            Runtime::from_name("wasm-component"),
396            Some(Runtime::Wasm(WasmTargetHint::Component))
397        );
398        assert_eq!(
399            Runtime::from_name("wasm-module"),
400            Some(Runtime::Wasm(WasmTargetHint::Module))
401        );
402    }
403
404    #[test]
405    fn test_runtime_wasm_template_is_zimagefile_yaml() {
406        let t = Runtime::Wasm(WasmTargetHint::Auto).template();
407        assert!(t.contains("wasm:"), "template should set wasm mode: {t}");
408
409        let component = Runtime::Wasm(WasmTargetHint::Component).template();
410        assert!(component.contains("preview2"), "component → preview2");
411
412        let module = Runtime::Wasm(WasmTargetHint::Module).template();
413        assert!(module.contains("preview1"), "module → preview1");
414    }
415
416    #[test]
417    fn test_runtime_wasm_info_lookup() {
418        // All WasmTargetHint variants must resolve through info() without
419        // panicking.
420        let auto = Runtime::Wasm(WasmTargetHint::Auto).info();
421        let module = Runtime::Wasm(WasmTargetHint::Module).info();
422        let component = Runtime::Wasm(WasmTargetHint::Component).info();
423        assert_eq!(auto.name, "wasm");
424        assert_eq!(module.name, "wasm");
425        assert_eq!(component.name, "wasm");
426    }
427
428    #[test]
429    fn test_node20_template_structure() {
430        let template = Runtime::Node20.template();
431        let dockerfile = Dockerfile::parse(template).expect("Should parse");
432
433        // Should be multi-stage
434        assert_eq!(dockerfile.stages.len(), 2);
435
436        // First stage is builder
437        assert_eq!(dockerfile.stages[0].name, Some("builder".to_string()));
438
439        // Final stage should have USER instruction for security
440        let final_stage = dockerfile.final_stage().unwrap();
441        let has_user = final_stage
442            .instructions
443            .iter()
444            .any(|i| matches!(i, crate::Instruction::User(_)));
445        assert!(has_user, "Node template should run as non-root user");
446    }
447
448    #[test]
449    fn test_rust_template_structure() {
450        let template = Runtime::Rust.template();
451        let dockerfile = Dockerfile::parse(template).expect("Should parse");
452
453        // Should be multi-stage
454        assert_eq!(dockerfile.stages.len(), 2);
455
456        // First stage is builder
457        assert_eq!(dockerfile.stages[0].name, Some("builder".to_string()));
458    }
459
460    #[test]
461    fn test_list_templates() {
462        let templates = list_templates();
463        assert!(!templates.is_empty());
464        assert!(templates.iter().any(|t| t.name == "node20"));
465        assert!(templates.iter().any(|t| t.name == "rust"));
466        assert!(templates.iter().any(|t| t.name == "go"));
467    }
468
469    #[test]
470    fn test_get_template_by_name() {
471        let template = get_template_by_name("node20");
472        assert!(template.is_some());
473        assert!(template.unwrap().contains("node:20"));
474
475        let template = get_template_by_name("unknown");
476        assert!(template.is_none());
477    }
478
479    #[test]
480    fn test_resolve_runtime_explicit() {
481        let dir = TempDir::new().unwrap();
482
483        // Explicit name takes precedence
484        let runtime = resolve_runtime(Some("rust"), dir.path(), false);
485        assert_eq!(runtime, Some(Runtime::Rust));
486    }
487
488    #[test]
489    fn test_resolve_runtime_detect() {
490        let dir = TempDir::new().unwrap();
491        fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
492
493        // Auto-detect when no name given
494        let runtime = resolve_runtime(None, dir.path(), false);
495        assert_eq!(runtime, Some(Runtime::Rust));
496    }
497
498    #[test]
499    fn test_runtime_display() {
500        assert_eq!(format!("{}", Runtime::Node20), "node20");
501        assert_eq!(format!("{}", Runtime::Rust), "rust");
502    }
503
504    #[test]
505    fn test_runtime_from_str() {
506        let runtime: Result<Runtime, _> = "node20".parse();
507        assert_eq!(runtime, Ok(Runtime::Node20));
508
509        let runtime: Result<Runtime, _> = "unknown".parse();
510        assert!(runtime.is_err());
511    }
512}