use std::collections::HashMap;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ZBuildContext {
Short(String),
Full {
#[serde(alias = "context", default)]
workdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
file: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
args: HashMap<String, String>,
},
}
impl ZBuildContext {
#[must_use]
pub fn context_dir(&self, base: &Path) -> PathBuf {
match self {
Self::Short(path) => base.join(path),
Self::Full { workdir, .. } => match workdir {
Some(dir) => base.join(dir),
None => base.to_path_buf(),
},
}
}
#[must_use]
pub fn file(&self) -> Option<&str> {
match self {
Self::Short(_) => None,
Self::Full { file, .. } => file.as_deref(),
}
}
#[must_use]
pub fn args(&self) -> HashMap<String, String> {
match self {
Self::Short(_) => HashMap::new(),
Self::Full { args, .. } => args.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZImage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<ZBuildContext>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<ZStep>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stages: Option<IndexMap<String, ZStage>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wasm: Option<ZWasmConfig>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expose: Option<ZExpose>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cmd: Option<ZCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entrypoint: Option<ZCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub volumes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub healthcheck: Option<ZHealthcheck>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stopsignal: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub args: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZStage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build: Option<ZBuildContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub args: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<ZStep>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expose: Option<ZExpose>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entrypoint: Option<ZCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cmd: Option<ZCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub volumes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub healthcheck: Option<ZHealthcheck>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stopsignal: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZStep {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<ZCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy: Option<ZCopySources>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub add: Option<ZCopySources>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workdir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chmod: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cache: Vec<ZCacheMount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZCacheMount {
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sharing: Option<String>,
#[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
pub readonly: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ZCommand {
Shell(String),
Exec(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ZCopySources {
Single(String),
Multiple(Vec<String>),
}
impl ZCopySources {
#[must_use]
pub fn to_vec(&self) -> Vec<String> {
match self {
Self::Single(s) => vec![s.clone()],
Self::Multiple(v) => v.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ZExpose {
Single(u16),
Multiple(Vec<ZPortSpec>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ZPortSpec {
Number(u16),
WithProtocol(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZHealthcheck {
pub cmd: ZCommand,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_period: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retries: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ZWasmConfig {
#[serde(default = "default_wasm_target")]
pub target: String,
#[serde(default, skip_serializing_if = "crate::zimage::types::is_false")]
pub optimize: bool,
#[serde(
default = "default_wasm_opt_level",
skip_serializing_if = "Option::is_none"
)]
pub opt_level: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub world: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub build_args: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pre_build: Vec<ZCommand>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub post_build: Vec<ZCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub adapter: Option<String>,
}
fn default_wasm_target() -> String {
"preview2".to_string()
}
#[allow(clippy::unnecessary_wraps)]
fn default_wasm_opt_level() -> Option<String> {
Some("Oz".to_string())
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(v: &bool) -> bool {
!v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_runtime_mode_deserialize() {
let yaml = r#"
runtime: node22
cmd: "node server.js"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.runtime.as_deref(), Some("node22"));
assert!(matches!(img.cmd, Some(ZCommand::Shell(ref s)) if s == "node server.js"));
}
#[test]
fn test_single_stage_deserialize() {
let yaml = r#"
base: "alpine:3.19"
steps:
- run: "apk add --no-cache curl"
- copy: "app.sh"
to: "/usr/local/bin/app.sh"
chmod: "755"
- workdir: "/app"
env:
NODE_ENV: production
expose: 8080
cmd: ["./app.sh"]
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.base.as_deref(), Some("alpine:3.19"));
assert_eq!(img.steps.len(), 3);
assert_eq!(img.env.get("NODE_ENV").unwrap(), "production");
assert!(matches!(img.expose, Some(ZExpose::Single(8080))));
assert!(matches!(img.cmd, Some(ZCommand::Exec(ref v)) if v.len() == 1));
}
#[test]
fn test_multi_stage_deserialize() {
let yaml = r#"
stages:
builder:
base: "node:22-alpine"
workdir: "/src"
steps:
- copy: ["package.json", "package-lock.json"]
to: "./"
- run: "npm ci"
- copy: "."
to: "."
- run: "npm run build"
runtime:
base: "node:22-alpine"
workdir: "/app"
steps:
- copy: "dist"
from: builder
to: "/app"
cmd: ["node", "dist/index.js"]
expose: 3000
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let stages = img.stages.as_ref().unwrap();
assert_eq!(stages.len(), 2);
let keys: Vec<&String> = stages.keys().collect();
assert_eq!(keys, vec!["builder", "runtime"]);
let builder = &stages["builder"];
assert_eq!(builder.base.as_deref(), Some("node:22-alpine"));
assert_eq!(builder.steps.len(), 4);
let runtime = &stages["runtime"];
assert_eq!(runtime.steps.len(), 1);
assert_eq!(runtime.steps[0].from.as_deref(), Some("builder"));
}
#[test]
fn test_wasm_mode_deserialize() {
let yaml = r#"
wasm:
target: preview2
optimize: true
language: rust
wit: "./wit"
output: "./output.wasm"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let wasm = img.wasm.as_ref().unwrap();
assert_eq!(wasm.target, "preview2");
assert!(wasm.optimize);
assert_eq!(wasm.language.as_deref(), Some("rust"));
assert_eq!(wasm.wit.as_deref(), Some("./wit"));
assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
}
#[test]
fn test_wasm_defaults() {
let yaml = r"
wasm: {}
";
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let wasm = img.wasm.as_ref().unwrap();
assert_eq!(wasm.target, "preview2");
assert!(!wasm.optimize);
assert!(wasm.language.is_none());
assert_eq!(wasm.opt_level.as_deref(), Some("Oz"));
assert!(wasm.world.is_none());
assert!(wasm.features.is_empty());
assert!(wasm.build_args.is_empty());
assert!(wasm.pre_build.is_empty());
assert!(wasm.post_build.is_empty());
assert!(wasm.adapter.is_none());
}
#[test]
fn test_wasm_full_config() {
let yaml = r#"
wasm:
target: "preview2"
optimize: true
opt_level: "O3"
language: "rust"
world: "zlayer-http-handler"
wit: "./wit"
output: "./output.wasm"
features:
- json
- metrics
build_args:
CARGO_PROFILE_RELEASE_LTO: "true"
RUSTFLAGS: "-C target-feature=+simd128"
pre_build:
- "wit-bindgen tiny-go --world zlayer-http-handler --out-dir bindings/"
post_build:
- "wasm-tools component embed --world zlayer-http-handler wit/ output.wasm -o output.wasm"
adapter: "./wasi_snapshot_preview1.reactor.wasm"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let wasm = img.wasm.as_ref().unwrap();
assert_eq!(wasm.target, "preview2");
assert!(wasm.optimize);
assert_eq!(wasm.opt_level.as_deref(), Some("O3"));
assert_eq!(wasm.language.as_deref(), Some("rust"));
assert_eq!(wasm.world.as_deref(), Some("zlayer-http-handler"));
assert_eq!(wasm.wit.as_deref(), Some("./wit"));
assert_eq!(wasm.output.as_deref(), Some("./output.wasm"));
assert_eq!(wasm.features, vec!["json", "metrics"]);
assert_eq!(
wasm.build_args.get("CARGO_PROFILE_RELEASE_LTO").unwrap(),
"true"
);
assert_eq!(
wasm.build_args.get("RUSTFLAGS").unwrap(),
"-C target-feature=+simd128"
);
assert_eq!(wasm.pre_build.len(), 1);
assert_eq!(wasm.post_build.len(), 1);
assert_eq!(
wasm.adapter.as_deref(),
Some("./wasi_snapshot_preview1.reactor.wasm")
);
}
#[test]
fn test_zcommand_shell() {
let yaml = r#""echo hello""#;
let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(cmd, ZCommand::Shell(ref s) if s == "echo hello"));
}
#[test]
fn test_zcommand_exec() {
let yaml = r#"["echo", "hello"]"#;
let cmd: ZCommand = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(cmd, ZCommand::Exec(ref v) if v == &["echo", "hello"]));
}
#[test]
fn test_zcopy_sources_single() {
let yaml = r#""package.json""#;
let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
assert_eq!(src.to_vec(), vec!["package.json"]);
}
#[test]
fn test_zcopy_sources_multiple() {
let yaml = r#"["package.json", "tsconfig.json"]"#;
let src: ZCopySources = serde_yaml::from_str(yaml).unwrap();
assert_eq!(src.to_vec(), vec!["package.json", "tsconfig.json"]);
}
#[test]
fn test_zexpose_single() {
let yaml = "8080";
let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(exp, ZExpose::Single(8080)));
}
#[test]
fn test_zexpose_multiple() {
let yaml = r#"
- 8080
- "9090/udp"
"#;
let exp: ZExpose = serde_yaml::from_str(yaml).unwrap();
if let ZExpose::Multiple(ports) = exp {
assert_eq!(ports.len(), 2);
assert!(matches!(ports[0], ZPortSpec::Number(8080)));
assert!(matches!(ports[1], ZPortSpec::WithProtocol(ref s) if s == "9090/udp"));
} else {
panic!("Expected ZExpose::Multiple");
}
}
#[test]
fn test_healthcheck_deserialize() {
let yaml = r#"
cmd: "curl -f http://localhost/ || exit 1"
interval: "30s"
timeout: "10s"
start_period: "5s"
retries: 3
"#;
let hc: ZHealthcheck = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(hc.cmd, ZCommand::Shell(_)));
assert_eq!(hc.interval.as_deref(), Some("30s"));
assert_eq!(hc.timeout.as_deref(), Some("10s"));
assert_eq!(hc.start_period.as_deref(), Some("5s"));
assert_eq!(hc.retries, Some(3));
}
#[test]
fn test_cache_mount_deserialize() {
let yaml = r"
target: /var/cache/apt
id: apt-cache
sharing: shared
readonly: false
";
let cm: ZCacheMount = serde_yaml::from_str(yaml).unwrap();
assert_eq!(cm.target, "/var/cache/apt");
assert_eq!(cm.id.as_deref(), Some("apt-cache"));
assert_eq!(cm.sharing.as_deref(), Some("shared"));
assert!(!cm.readonly);
}
#[test]
fn test_step_with_cache_mounts() {
let yaml = r#"
run: "apt-get update && apt-get install -y curl"
cache:
- target: /var/cache/apt
id: apt-cache
sharing: shared
- target: /var/lib/apt
readonly: true
"#;
let step: ZStep = serde_yaml::from_str(yaml).unwrap();
assert!(step.run.is_some());
assert_eq!(step.cache.len(), 2);
assert_eq!(step.cache[0].target, "/var/cache/apt");
assert!(step.cache[1].readonly);
}
#[test]
fn test_deny_unknown_fields_zimage() {
let yaml = r#"
base: "alpine:3.19"
bogus_field: "should fail"
"#;
let result: Result<ZImage, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "Should reject unknown fields");
}
#[test]
fn test_deny_unknown_fields_zstep() {
let yaml = r#"
run: "echo hello"
bogus: "nope"
"#;
let result: Result<ZStep, _> = serde_yaml::from_str(yaml);
assert!(result.is_err(), "Should reject unknown fields on ZStep");
}
#[test]
fn test_roundtrip_serialize() {
let yaml = r#"
base: "alpine:3.19"
steps:
- run: "echo hello"
- copy: "."
to: "/app"
cmd: "echo done"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let serialized = serde_yaml::to_string(&img).unwrap();
let img2: ZImage = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(img.base, img2.base);
assert_eq!(img.steps.len(), img2.steps.len());
}
}