#![cfg(target_os = "linux")]
use std::path::{Path, PathBuf};
use std::process::Command;
use zlayer_builder::ImageBuilder;
use zlayer_types::builder::BuilderBackendKind;
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/dockerfiles")
.join(name)
}
fn unique_tag(name: &str) -> String {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
format!("zlayer-compat-e2e/{name}:{ts}")
}
fn require_buildd() {
if let Ok(p) = std::env::var("ZLAYER_BUILDD_BIN") {
assert!(
Path::new(&p).is_file(),
"ZLAYER_BUILDD_BIN={p} is not a file"
);
return;
}
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("CARGO_MANIFEST_DIR has at least 2 parents");
let candidate = repo_root.join("bin/zlayer-buildd/zlayer-buildd");
assert!(
candidate.is_file(),
"zlayer-buildd sidecar binary not found at {} — build it with \
`cd bin/zlayer-buildd && make build` or set ZLAYER_BUILDD_BIN",
candidate.display()
);
#[allow(unsafe_code)]
unsafe {
std::env::set_var("ZLAYER_BUILDD_BIN", &candidate);
}
}
async fn build_fixture(
name: &str,
tag: &str,
build_args: &[(&str, &str)],
backend: BuilderBackendKind,
) {
if backend == BuilderBackendKind::BuildahSidecar {
require_buildd();
}
let ctx = fixture(name);
let mut builder = ImageBuilder::new(&ctx)
.await
.unwrap_or_else(|e| panic!("ImageBuilder::new({name}): {e}"))
.dockerfile(ctx.join("Dockerfile"))
.tag(tag.to_string())
.with_backend_override(Some(backend))
.with_host_network(true);
for (k, v) in build_args {
builder = builder.build_arg(*k, *v);
}
match builder.build().await {
Ok(r) => {
eprintln!("built {name} -> {tag} via {} ({r:?})", backend.as_str());
}
Err(e) => panic!(
"building fixture {name} via {} failed: {e:#}",
backend.as_str()
),
}
}
fn storage_flags(backend: BuilderBackendKind) -> Vec<String> {
if backend != BuilderBackendKind::BuildahSidecar {
return Vec::new();
}
let storage = zlayer_paths::ZLayerDirs::system_default()
.buildd()
.join("storage");
vec![
"--storage-driver".into(),
"vfs".into(),
"--root".into(),
storage.join("graph").to_string_lossy().into_owned(),
"--runroot".into(),
storage.join("run").to_string_lossy().into_owned(),
]
}
fn buildah_cmd(storage: &[String]) -> Command {
let mut cmd = Command::new("buildah");
cmd.args(storage);
cmd
}
fn read_file_in_image(storage: &[String], tag: &str, path: &str) -> String {
let from = buildah_cmd(storage)
.args(["from", "--pull=never", tag])
.output()
.expect("buildah from");
assert!(
from.status.success(),
"buildah from {tag}: {}",
String::from_utf8_lossy(&from.stderr)
);
let cid = String::from_utf8_lossy(&from.stdout).trim().to_string();
let run = buildah_cmd(storage)
.env("BUILDAH_ISOLATION", "chroot")
.args(["run", &cid, "--", "cat", path])
.output()
.expect("buildah run");
let content = String::from_utf8_lossy(&run.stdout).into_owned();
let stderr = String::from_utf8_lossy(&run.stderr).into_owned();
let _ = buildah_cmd(storage).args(["rm", &cid]).output();
assert!(
run.status.success(),
"reading {path} from {tag} failed: {stderr}"
);
content
}
fn rmi(storage: &[String], tag: &str) {
let _ = buildah_cmd(storage).args(["rmi", "-f", tag]).output();
}
#[tokio::test]
#[ignore = "live e2e: needs buildah + zlayer-buildd + network (run with --ignored --test-threads=1)"]
async fn arg_conditional_default_branch() {
let tag = unique_tag("arg-conditional-default");
build_fixture(
"arg-conditional",
&tag,
&[("APP_NAME", "zworker")],
BuilderBackendKind::BuildahSidecar,
)
.await;
let storage = storage_flags(BuilderBackendKind::BuildahSidecar);
let artifact = read_file_in_image(&storage, &tag, "/usr/local/bin/zapp-artifact");
assert_eq!(artifact.trim(), "built zworker default");
rmi(&storage, &tag);
}
#[tokio::test]
#[ignore = "live e2e: needs buildah + zlayer-buildd + network (run with --ignored --test-threads=1)"]
async fn arg_conditional_feature_branch() {
let tag = unique_tag("arg-conditional-features");
build_fixture(
"arg-conditional",
&tag,
&[("APP_NAME", "zworker-tts"), ("APP_FEATURES", "tts,gpu")],
BuilderBackendKind::BuildahSidecar,
)
.await;
let storage = storage_flags(BuilderBackendKind::BuildahSidecar);
let artifact = read_file_in_image(&storage, &tag, "/usr/local/bin/zapp-artifact");
assert_eq!(artifact.trim(), "built zworker-tts with features tts,gpu");
rmi(&storage, &tag);
}
#[tokio::test]
#[ignore = "live e2e: needs buildah + network (run with --ignored --test-threads=1)"]
async fn arg_conditional_via_cli_fallback() {
let tag = unique_tag("arg-conditional-cli");
build_fixture(
"arg-conditional",
&tag,
&[("APP_NAME", "zworker"), ("APP_FEATURES", "cli-path")],
BuilderBackendKind::BuildahCli,
)
.await;
let storage = storage_flags(BuilderBackendKind::BuildahCli);
let artifact = read_file_in_image(&storage, &tag, "/usr/local/bin/zapp-artifact");
assert_eq!(artifact.trim(), "built zworker with features cli-path");
rmi(&storage, &tag);
}
#[tokio::test]
#[ignore = "live e2e: needs buildah + zlayer-buildd + network (run with --ignored --test-threads=1)"]
async fn cache_mount_run() {
let tag = unique_tag("cache-mount");
build_fixture("cache-mount", &tag, &[], BuilderBackendKind::BuildahSidecar).await;
let storage = storage_flags(BuilderBackendKind::BuildahSidecar);
let runs = read_file_in_image(&storage, &tag, "/cache-runs.txt");
let count: u64 = runs
.trim()
.parse()
.unwrap_or_else(|e| panic!("cache-runs.txt not a number ({e}): {runs:?}"));
assert!(count >= 1, "cache mount RUN left no stamp count: {runs:?}");
let from = buildah_cmd(&storage)
.args(["from", "--pull=never", &tag])
.output()
.expect("buildah from");
assert!(from.status.success());
let cid = String::from_utf8_lossy(&from.stdout).trim().to_string();
let probe = buildah_cmd(&storage)
.env("BUILDAH_ISOLATION", "chroot")
.args([
"run",
&cid,
"--",
"sh",
"-c",
"test ! -e /var/cache/compat-e2e/stamp",
])
.output()
.expect("buildah run");
let _ = buildah_cmd(&storage).args(["rm", &cid]).output();
assert!(
probe.status.success(),
"cache mount contents leaked into the committed image"
);
rmi(&storage, &tag);
}
#[tokio::test]
#[ignore = "live e2e: needs buildah + zlayer-buildd + network (run with --ignored --test-threads=1)"]
async fn multi_stage_arg_scoping() {
let tag = unique_tag("multi-stage-args");
build_fixture(
"multi-stage-args",
&tag,
&[("MESSAGE", "hello-from-producer")],
BuilderBackendKind::BuildahSidecar,
)
.await;
let storage = storage_flags(BuilderBackendKind::BuildahSidecar);
let final_txt = read_file_in_image(&storage, &tag, "/final.txt");
assert_eq!(final_txt.trim(), "hello-from-producer");
rmi(&storage, &tag);
}
#[tokio::test]
#[ignore = "live e2e: needs buildah + zlayer-buildd + network (run with --ignored --test-threads=1)"]
async fn metadata_instructions() {
let tag = unique_tag("metadata");
build_fixture("metadata", &tag, &[], BuilderBackendKind::BuildahSidecar).await;
let storage = storage_flags(BuilderBackendKind::BuildahSidecar);
let env_txt = read_file_in_image(&storage, &tag, "/app/env.txt");
assert_eq!(env_txt.trim(), "production:8080");
let workdir_txt = read_file_in_image(&storage, &tag, "/app/workdir.txt");
assert_eq!(workdir_txt.trim(), "/app/data");
let inspect = buildah_cmd(&storage)
.args(["inspect", "--type", "image", &tag])
.output()
.expect("buildah inspect");
assert!(inspect.status.success());
let doc: serde_json::Value = serde_json::from_slice(&inspect.stdout).expect("inspect json");
let config = &doc["OCIv1"]["config"];
let labels = &config["Labels"];
assert_eq!(
labels["com.zlayer.test"], "dockerfile-compat",
"LABEL missing from image config: {labels}"
);
let exposed = config["ExposedPorts"]
.as_object()
.map(|m| m.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
assert!(
exposed.iter().any(|p| p.starts_with("8080")),
"EXPOSE 8080 missing from image config: {exposed:?}"
);
let entrypoint = config["Entrypoint"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
assert_eq!(entrypoint, ["/bin/sh", "-c"], "ENTRYPOINT mismatch");
rmi(&storage, &tag);
}