use std::collections::HashMap;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::backend::ImageOs;
#[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, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ZIsolation {
Auto,
Sandbox,
Vz,
VzLinux,
Vm,
Native,
Container,
}
#[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 os: Option<ImageOs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isolation: Option<ZIsolation>,
#[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>,
}
impl ZImage {
#[must_use]
pub fn resolve_target_os(&self) -> Option<ImageOs> {
if let Some(os) = self.os {
return Some(os);
}
if let Some(platform) = self.platform.as_deref() {
if let Ok(os) = platform.parse::<ImageOs>() {
return Some(os);
}
}
None
}
#[must_use]
pub fn resolve_isolation(&self) -> Option<zlayer_types::spec::RuntimeIsolation> {
use crate::backend::ImageOs;
use zlayer_types::spec::RuntimeIsolation as RI;
self.isolation.map(|z| match z {
ZIsolation::Auto => RI::Auto,
ZIsolation::Sandbox | ZIsolation::Native => RI::Sandbox,
ZIsolation::Vz => RI::Vz,
ZIsolation::VzLinux => RI::VzLinux,
ZIsolation::Vm => RI::Vm,
ZIsolation::Container => match self.resolve_target_os() {
Some(ImageOs::Darwin) => RI::Vz,
_ => RI::VzLinux,
},
})
}
}
#[must_use]
pub fn detect_image_os_from_binary(
zimage: &ZImage,
context: &std::path::Path,
) -> Option<crate::backend::ImageOs> {
let cmd = zimage.entrypoint.as_ref().or(zimage.cmd.as_ref())?;
let prog = command_first_token(cmd)?;
if prog.is_empty() {
return None;
}
let prog_path = Path::new(&prog);
let prog_base = prog_path.file_name()?;
let prog_parent = prog_path.parent();
for step in &zimage.steps {
let (Some(to), Some(copy)) = (step.to.as_deref(), step.copy.as_ref()) else {
continue;
};
let to_norm = Path::new(to.trim_end_matches('/'));
let exact = to_norm == prog_path;
let is_parent_dir = prog_parent.is_some_and(|p| !p.as_os_str().is_empty() && to_norm == p);
if !exact && !is_parent_dir {
continue;
}
for src in copy.to_vec() {
let base = context.join(&src);
for candidate in [base.clone(), base.join(prog_base)] {
if let Some(os) = read_magic_and_classify(&candidate) {
return Some(os);
}
}
}
}
None
}
fn command_first_token(cmd: &ZCommand) -> Option<String> {
match cmd {
ZCommand::Exec(v) => v.first().cloned(),
ZCommand::Shell(s) => s.split_whitespace().next().map(ToString::to_string),
}
}
fn read_magic_and_classify(path: &Path) -> Option<crate::backend::ImageOs> {
use std::io::Read;
if !path.is_file() {
return None;
}
let mut file = std::fs::File::open(path).ok()?;
let mut buf = [0u8; 8];
let n = file.read(&mut buf).ok()?;
if n == 0 {
return None;
}
crate::backend::image_os_from_magic(&buf[..n])
}
#[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 = "Option::is_none")]
pub os: Option<ImageOs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isolation: Option<ZIsolation>,
#[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>,
#[serde(
default = "default_wasm_oci",
skip_serializing_if = "crate::zimage::types::is_true"
)]
pub oci: bool,
}
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
}
#[allow(clippy::trivially_copy_pass_by_ref)]
pub(crate) fn is_true(v: &bool) -> bool {
*v
}
fn default_wasm_oci() -> bool {
true
}
#[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());
assert!(
wasm.oci,
"default ZWasmConfig.oci must be true so OCI packaging still happens unless explicitly opted out"
);
}
#[test]
fn test_wasm_oci_opt_out() {
let yaml = r"
wasm:
oci: false
";
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let wasm = img.wasm.as_ref().unwrap();
assert!(
!wasm.oci,
"wasm.oci: false must deserialize to ZWasmConfig.oci == false"
);
}
#[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_zimage_os_field_linux() {
let yaml = r#"
base: "alpine:3.19"
os: linux
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.os, Some(ImageOs::Linux));
assert_eq!(img.resolve_target_os(), Some(ImageOs::Linux));
}
#[test]
fn test_zimage_os_field_windows() {
let yaml = r#"
base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
os: windows
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.os, Some(ImageOs::Windows));
assert_eq!(img.resolve_target_os(), Some(ImageOs::Windows));
}
#[test]
fn test_zimage_os_missing_is_none() {
let yaml = r#"
base: "alpine:3.19"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.os, None);
assert_eq!(img.resolve_target_os(), None);
}
#[test]
fn test_zimage_os_wins_over_platform() {
let yaml = r#"
base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
platform: "linux/amd64"
os: windows
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.platform.as_deref(), Some("linux/amd64"));
assert_eq!(img.os, Some(ImageOs::Windows));
assert_eq!(
img.resolve_target_os(),
Some(ImageOs::Windows),
"explicit os: must win over OS inferred from platform:"
);
}
#[test]
fn test_zimage_os_inferred_from_platform() {
let yaml = r#"
base: "alpine:3.19"
platform: "linux/arm64"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.os, None);
assert_eq!(
img.resolve_target_os(),
Some(ImageOs::Linux),
"resolve_target_os must fall back to parsing platform:"
);
}
#[test]
fn test_zimage_os_unknown_platform_ignored() {
let yaml = r#"
base: "alpine:3.19"
platform: "amd64"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.resolve_target_os(), None);
}
#[test]
fn test_zstage_os_field() {
let yaml = r#"
stages:
builder:
base: "alpine:3.19"
os: linux
runtime:
base: "mcr.microsoft.com/windows/nanoserver:ltsc2022"
os: windows
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let stages = img.stages.as_ref().unwrap();
assert_eq!(stages["builder"].os, Some(ImageOs::Linux));
assert_eq!(stages["runtime"].os, Some(ImageOs::Windows));
}
#[test]
fn test_zimage_os_field_darwin() {
let yaml = r#"
base: "scratch"
os: darwin
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.os, Some(ImageOs::Darwin));
assert_eq!(img.resolve_target_os(), Some(ImageOs::Darwin));
}
#[test]
fn test_zimage_os_inferred_from_platform_darwin() {
let yaml = r#"
base: "scratch"
platform: "darwin/arm64"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.os, None);
assert_eq!(
img.resolve_target_os(),
Some(ImageOs::Darwin),
"resolve_target_os must infer Darwin from a darwin/* platform:"
);
}
#[test]
fn test_detect_image_os_from_binary() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!(
"zlayer-detect-os-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
std::fs::create_dir_all(&dir).unwrap();
let make_image = |entrypoint: Option<&str>| {
let mut steps = Vec::new();
if entrypoint.is_some() {
steps.push(ZStep {
run: None,
copy: Some(ZCopySources::Single("./task-executor".to_string())),
add: None,
env: None,
workdir: None,
user: None,
to: Some("/usr/local/bin/task-executor".to_string()),
from: None,
owner: None,
chmod: None,
cache: Vec::new(),
});
}
ZImage {
version: None,
runtime: None,
base: Some("scratch".to_string()),
build: None,
steps,
platform: None,
os: None,
isolation: None,
stages: None,
wasm: None,
env: HashMap::new(),
workdir: None,
expose: None,
cmd: None,
entrypoint: entrypoint.map(|e| ZCommand::Exec(vec![e.to_string()])),
user: None,
labels: HashMap::new(),
volumes: Vec::new(),
healthcheck: None,
stopsignal: None,
args: HashMap::new(),
}
};
{
let mut f = std::fs::File::create(dir.join("task-executor")).unwrap();
f.write_all(&[0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00])
.unwrap();
f.flush().unwrap();
let img = make_image(Some("/usr/local/bin/task-executor"));
assert_eq!(
detect_image_os_from_binary(&img, &dir),
Some(ImageOs::Darwin),
"Mach-O magic at the COPY source must classify as Darwin"
);
}
{
let mut f = std::fs::File::create(dir.join("task-executor")).unwrap();
f.write_all(&[0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00])
.unwrap();
f.flush().unwrap();
let img = make_image(Some("/usr/local/bin/task-executor"));
assert_eq!(
detect_image_os_from_binary(&img, &dir),
Some(ImageOs::Linux),
"ELF magic at the COPY source must classify as Linux"
);
}
{
let img = make_image(None);
assert_eq!(
detect_image_os_from_binary(&img, &dir),
None,
"an image with no entrypoint or cmd must yield None"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_zimage_isolation_vz() {
use zlayer_types::spec::RuntimeIsolation as RI;
let yaml = r#"
base: "alpine:3.19"
isolation: vz
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.isolation, Some(ZIsolation::Vz));
assert_eq!(img.resolve_isolation(), Some(RI::Vz));
}
#[test]
fn test_zimage_isolation_vz_linux() {
use zlayer_types::spec::RuntimeIsolation as RI;
let yaml = r#"
base: "alpine:3.19"
isolation: vz-linux
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.isolation, Some(ZIsolation::VzLinux));
assert_eq!(img.resolve_isolation(), Some(RI::VzLinux));
}
#[test]
fn test_zimage_isolation_native_is_sandbox() {
use zlayer_types::spec::RuntimeIsolation as RI;
let yaml = r#"
base: "alpine:3.19"
isolation: native
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.isolation, Some(ZIsolation::Native));
assert_eq!(
img.resolve_isolation(),
Some(RI::Sandbox),
"`native` is a friendly synonym for `sandbox`"
);
}
#[test]
fn test_zimage_isolation_container_darwin_is_vz() {
use zlayer_types::spec::RuntimeIsolation as RI;
let yaml = r#"
base: "scratch"
os: darwin
isolation: container
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.isolation, Some(ZIsolation::Container));
assert_eq!(
img.resolve_isolation(),
Some(RI::Vz),
"`container` on a darwin target resolves to vz"
);
}
#[test]
fn test_zimage_isolation_container_default_is_vz_linux() {
use zlayer_types::spec::RuntimeIsolation as RI;
let yaml = r#"
base: "alpine:3.19"
isolation: container
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.isolation, Some(ZIsolation::Container));
assert_eq!(img.resolve_isolation(), Some(RI::VzLinux));
}
#[test]
fn test_zimage_isolation_absent_is_none() {
let yaml = r#"
base: "alpine:3.19"
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
assert_eq!(img.isolation, None);
assert_eq!(img.resolve_isolation(), None);
}
#[test]
fn test_zstage_isolation_field() {
let yaml = r#"
stages:
builder:
base: "alpine:3.19"
isolation: sandbox
runtime:
base: "alpine:3.19"
isolation: vz-linux
"#;
let img: ZImage = serde_yaml::from_str(yaml).unwrap();
let stages = img.stages.as_ref().unwrap();
assert_eq!(stages["builder"].isolation, Some(ZIsolation::Sandbox));
assert_eq!(stages["runtime"].isolation, Some(ZIsolation::VzLinux));
}
#[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());
}
}