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}
69
70impl Runtime {
71    /// Get all available runtimes
72    #[must_use]
73    pub fn all() -> &'static [RuntimeInfo] {
74        &[
75            RuntimeInfo {
76                runtime: Runtime::Node20,
77                name: "node20",
78                description: "Node.js 20 (LTS) - Alpine-based, production optimized",
79                detect_files: &["package.json"],
80            },
81            RuntimeInfo {
82                runtime: Runtime::Node22,
83                name: "node22",
84                description: "Node.js 22 (Current) - Alpine-based, production optimized",
85                detect_files: &["package.json"],
86            },
87            RuntimeInfo {
88                runtime: Runtime::Python312,
89                name: "python312",
90                description: "Python 3.12 - Slim Debian-based with pip",
91                detect_files: &["requirements.txt", "pyproject.toml", "setup.py"],
92            },
93            RuntimeInfo {
94                runtime: Runtime::Python313,
95                name: "python313",
96                description: "Python 3.13 - Slim Debian-based with pip",
97                detect_files: &["requirements.txt", "pyproject.toml", "setup.py"],
98            },
99            RuntimeInfo {
100                runtime: Runtime::Rust,
101                name: "rust",
102                description: "Rust - Static musl binary, minimal Alpine runtime",
103                detect_files: &["Cargo.toml"],
104            },
105            RuntimeInfo {
106                runtime: Runtime::Go,
107                name: "go",
108                description: "Go - Static binary, minimal Alpine runtime",
109                detect_files: &["go.mod"],
110            },
111            RuntimeInfo {
112                runtime: Runtime::Deno,
113                name: "deno",
114                description: "Deno - Official runtime with TypeScript support",
115                detect_files: &["deno.json", "deno.jsonc"],
116            },
117            RuntimeInfo {
118                runtime: Runtime::Bun,
119                name: "bun",
120                description: "Bun - Fast JavaScript runtime and bundler",
121                detect_files: &["bun.lockb"],
122            },
123        ]
124    }
125
126    /// Parse a runtime from its name
127    #[must_use]
128    pub fn from_name(name: &str) -> Option<Runtime> {
129        let name_lower = name.to_lowercase();
130        match name_lower.as_str() {
131            "node20" | "node-20" | "nodejs20" | "node" => Some(Runtime::Node20),
132            "node22" | "node-22" | "nodejs22" => Some(Runtime::Node22),
133            "python312" | "python-312" | "python3.12" | "python" => Some(Runtime::Python312),
134            "python313" | "python-313" | "python3.13" => Some(Runtime::Python313),
135            "rust" | "rs" => Some(Runtime::Rust),
136            "go" | "golang" => Some(Runtime::Go),
137            "deno" => Some(Runtime::Deno),
138            "bun" => Some(Runtime::Bun),
139            _ => None,
140        }
141    }
142
143    /// Get information about this runtime
144    ///
145    /// # Panics
146    ///
147    /// Panics if the runtime variant is missing from the static info table (internal invariant).
148    #[must_use]
149    pub fn info(&self) -> &'static RuntimeInfo {
150        Runtime::all()
151            .iter()
152            .find(|info| info.runtime == *self)
153            .expect("All runtimes must have info")
154    }
155
156    /// Get the Dockerfile template for this runtime
157    #[must_use]
158    pub fn template(&self) -> &'static str {
159        match self {
160            Runtime::Node20 => include_str!("dockerfiles/node20.Dockerfile"),
161            Runtime::Node22 => include_str!("dockerfiles/node22.Dockerfile"),
162            Runtime::Python312 => include_str!("dockerfiles/python312.Dockerfile"),
163            Runtime::Python313 => include_str!("dockerfiles/python313.Dockerfile"),
164            Runtime::Rust => include_str!("dockerfiles/rust.Dockerfile"),
165            Runtime::Go => include_str!("dockerfiles/go.Dockerfile"),
166            Runtime::Deno => include_str!("dockerfiles/deno.Dockerfile"),
167            Runtime::Bun => include_str!("dockerfiles/bun.Dockerfile"),
168        }
169    }
170
171    /// Get the canonical name for this runtime
172    #[must_use]
173    pub fn name(&self) -> &'static str {
174        self.info().name
175    }
176}
177
178impl fmt::Display for Runtime {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        write!(f, "{}", self.name())
181    }
182}
183
184impl FromStr for Runtime {
185    type Err = String;
186
187    fn from_str(s: &str) -> Result<Self, Self::Err> {
188        Runtime::from_name(s).ok_or_else(|| format!("Unknown runtime: {s}"))
189    }
190}
191
192/// Information about a runtime template
193#[derive(Debug, Clone, Copy)]
194pub struct RuntimeInfo {
195    /// The runtime enum value
196    pub runtime: Runtime,
197    /// Short name used in CLI (e.g., "node20")
198    pub name: &'static str,
199    /// Human-readable description
200    pub description: &'static str,
201    /// Files that indicate this runtime should be used
202    pub detect_files: &'static [&'static str],
203}
204
205/// List all available templates
206#[must_use]
207pub fn list_templates() -> Vec<&'static RuntimeInfo> {
208    Runtime::all().iter().collect()
209}
210
211/// Get template content for a runtime
212#[must_use]
213pub fn get_template(runtime: Runtime) -> &'static str {
214    runtime.template()
215}
216
217/// Get template content by runtime name
218#[must_use]
219pub fn get_template_by_name(name: &str) -> Option<&'static str> {
220    Runtime::from_name(name).map(|r| r.template())
221}
222
223/// Resolve runtime from either explicit name or auto-detection
224pub fn resolve_runtime(
225    runtime_name: Option<&str>,
226    context_path: impl AsRef<Path>,
227    use_version_hints: bool,
228) -> Option<Runtime> {
229    // If explicitly specified, use that
230    if let Some(name) = runtime_name {
231        return Runtime::from_name(name);
232    }
233
234    // Otherwise, auto-detect
235    if use_version_hints {
236        detect_runtime_with_version(context_path)
237    } else {
238        detect_runtime(context_path)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::Dockerfile;
246    use std::fs;
247    use tempfile::TempDir;
248
249    #[test]
250    fn test_runtime_from_name() {
251        assert_eq!(Runtime::from_name("node20"), Some(Runtime::Node20));
252        assert_eq!(Runtime::from_name("Node20"), Some(Runtime::Node20));
253        assert_eq!(Runtime::from_name("node"), Some(Runtime::Node20));
254        assert_eq!(Runtime::from_name("python"), Some(Runtime::Python312));
255        assert_eq!(Runtime::from_name("rust"), Some(Runtime::Rust));
256        assert_eq!(Runtime::from_name("go"), Some(Runtime::Go));
257        assert_eq!(Runtime::from_name("golang"), Some(Runtime::Go));
258        assert_eq!(Runtime::from_name("deno"), Some(Runtime::Deno));
259        assert_eq!(Runtime::from_name("bun"), Some(Runtime::Bun));
260        assert_eq!(Runtime::from_name("unknown"), None);
261    }
262
263    #[test]
264    fn test_runtime_info() {
265        let info = Runtime::Node20.info();
266        assert_eq!(info.name, "node20");
267        assert!(info.description.contains("Node.js"));
268        assert!(info.detect_files.contains(&"package.json"));
269    }
270
271    #[test]
272    fn test_all_templates_parse_correctly() {
273        for info in Runtime::all() {
274            let template = info.runtime.template();
275            let result = Dockerfile::parse(template);
276            assert!(
277                result.is_ok(),
278                "Template {} failed to parse: {:?}",
279                info.name,
280                result.err()
281            );
282
283            let dockerfile = result.unwrap();
284            assert!(
285                !dockerfile.stages.is_empty(),
286                "Template {} has no stages",
287                info.name
288            );
289        }
290    }
291
292    #[test]
293    fn test_node20_template_structure() {
294        let template = Runtime::Node20.template();
295        let dockerfile = Dockerfile::parse(template).expect("Should parse");
296
297        // Should be multi-stage
298        assert_eq!(dockerfile.stages.len(), 2);
299
300        // First stage is builder
301        assert_eq!(dockerfile.stages[0].name, Some("builder".to_string()));
302
303        // Final stage should have USER instruction for security
304        let final_stage = dockerfile.final_stage().unwrap();
305        let has_user = final_stage
306            .instructions
307            .iter()
308            .any(|i| matches!(i, crate::Instruction::User(_)));
309        assert!(has_user, "Node template should run as non-root user");
310    }
311
312    #[test]
313    fn test_rust_template_structure() {
314        let template = Runtime::Rust.template();
315        let dockerfile = Dockerfile::parse(template).expect("Should parse");
316
317        // Should be multi-stage
318        assert_eq!(dockerfile.stages.len(), 2);
319
320        // First stage is builder
321        assert_eq!(dockerfile.stages[0].name, Some("builder".to_string()));
322    }
323
324    #[test]
325    fn test_list_templates() {
326        let templates = list_templates();
327        assert!(!templates.is_empty());
328        assert!(templates.iter().any(|t| t.name == "node20"));
329        assert!(templates.iter().any(|t| t.name == "rust"));
330        assert!(templates.iter().any(|t| t.name == "go"));
331    }
332
333    #[test]
334    fn test_get_template_by_name() {
335        let template = get_template_by_name("node20");
336        assert!(template.is_some());
337        assert!(template.unwrap().contains("node:20"));
338
339        let template = get_template_by_name("unknown");
340        assert!(template.is_none());
341    }
342
343    #[test]
344    fn test_resolve_runtime_explicit() {
345        let dir = TempDir::new().unwrap();
346
347        // Explicit name takes precedence
348        let runtime = resolve_runtime(Some("rust"), dir.path(), false);
349        assert_eq!(runtime, Some(Runtime::Rust));
350    }
351
352    #[test]
353    fn test_resolve_runtime_detect() {
354        let dir = TempDir::new().unwrap();
355        fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
356
357        // Auto-detect when no name given
358        let runtime = resolve_runtime(None, dir.path(), false);
359        assert_eq!(runtime, Some(Runtime::Rust));
360    }
361
362    #[test]
363    fn test_runtime_display() {
364        assert_eq!(format!("{}", Runtime::Node20), "node20");
365        assert_eq!(format!("{}", Runtime::Rust), "rust");
366    }
367
368    #[test]
369    fn test_runtime_from_str() {
370        let runtime: Result<Runtime, _> = "node20".parse();
371        assert_eq!(runtime, Ok(Runtime::Node20));
372
373        let runtime: Result<Runtime, _> = "unknown".parse();
374        assert!(runtime.is_err());
375    }
376}