From 30b26ca5f0f73c02ca8f125de68b007d1aa4d4ac Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Thu, 26 Mar 2026 10:28:53 +0000 Subject: [PATCH] feat(wfe-buildkit, wfe-containerd): add container executor crates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone workspace crates for BuildKit image building and containerd container execution. Config types, YAML schema integration, compiler dispatch, validation rules, and mock-based unit tests. Current implementation shells out to buildctl/nerdctl — will be replaced with proper gRPC clients (buildkit-client, containerd protos) in a follow-up. Config types, YAML integration, and test infrastructure are stable and reusable. wfe-buildkit: 60 tests, 97.9% library coverage wfe-containerd: 61 tests, 97.8% library coverage 447 total workspace tests. --- Cargo.toml | 4 +- wfe-buildkit/Cargo.toml | 2 + wfe-buildkit/src/config.rs | 180 +++++ wfe-buildkit/src/lib.rs | 2 +- wfe-buildkit/src/step.rs | 678 +++++++++++++++-- wfe-containerd/Cargo.toml | 23 + wfe-containerd/README.md | 70 ++ wfe-containerd/src/config.rs | 226 ++++++ wfe-containerd/src/lib.rs | 5 + wfe-containerd/src/step.rs | 1051 +++++++++++++++++++++++++++ wfe-containerd/tests/integration.rs | 532 ++++++++++++++ wfe-yaml/Cargo.toml | 4 + wfe-yaml/src/compiler.rs | 190 +++++ wfe-yaml/src/schema.rs | 56 ++ wfe-yaml/src/validation.rs | 84 +++ 15 files changed, 3056 insertions(+), 51 deletions(-) create mode 100644 wfe-containerd/Cargo.toml create mode 100644 wfe-containerd/README.md create mode 100644 wfe-containerd/src/config.rs create mode 100644 wfe-containerd/src/lib.rs create mode 100644 wfe-containerd/src/step.rs create mode 100644 wfe-containerd/tests/integration.rs diff --git a/Cargo.toml b/Cargo.toml index 8f914ca..9c23436 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["wfe-core", "wfe-sqlite", "wfe-postgres", "wfe-opensearch", "wfe-valkey", "wfe", "wfe-yaml"] +members = ["wfe-core", "wfe-sqlite", "wfe-postgres", "wfe-opensearch", "wfe-valkey", "wfe", "wfe-yaml", "wfe-buildkit", "wfe-containerd"] resolver = "2" [workspace.package] @@ -44,6 +44,8 @@ wfe-postgres = { version = "1.0.0", path = "wfe-postgres" } wfe-opensearch = { version = "1.0.0", path = "wfe-opensearch" } wfe-valkey = { version = "1.0.0", path = "wfe-valkey" } wfe-yaml = { version = "1.0.0", path = "wfe-yaml" } +wfe-buildkit = { version = "1.0.0", path = "wfe-buildkit" } +wfe-containerd = { version = "1.0.0", path = "wfe-containerd" } # YAML serde_yaml = "0.9" diff --git a/wfe-buildkit/Cargo.toml b/wfe-buildkit/Cargo.toml index 68e9384..258b841 100644 --- a/wfe-buildkit/Cargo.toml +++ b/wfe-buildkit/Cargo.toml @@ -19,4 +19,6 @@ regex = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } +tokio-util = "0.7" diff --git a/wfe-buildkit/src/config.rs b/wfe-buildkit/src/config.rs index c4c7df0..e66337b 100644 --- a/wfe-buildkit/src/config.rs +++ b/wfe-buildkit/src/config.rs @@ -175,4 +175,184 @@ mod tests { assert_eq!(auth.username, deserialized.username); assert_eq!(auth.password, deserialized.password); } + + #[test] + fn serde_custom_addr() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "buildkit_addr": "tcp://remote:1234" + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.buildkit_addr, "tcp://remote:1234"); + } + + #[test] + fn serde_with_timeout() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "timeout_ms": 60000 + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.timeout_ms, Some(60000)); + } + + #[test] + fn serde_with_tags_and_push() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "tags": ["myapp:latest", "myapp:v1.0"], + "push": true + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.tags, vec!["myapp:latest", "myapp:v1.0"]); + assert!(config.push); + } + + #[test] + fn serde_with_build_args() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "build_args": {"VERSION": "1.0", "DEBUG": "false"} + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.build_args.len(), 2); + assert_eq!(config.build_args["VERSION"], "1.0"); + assert_eq!(config.build_args["DEBUG"], "false"); + } + + #[test] + fn serde_with_cache_config() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "cache_from": ["type=registry,ref=cache:latest"], + "cache_to": ["type=registry,ref=cache:latest,mode=max"] + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.cache_from.len(), 1); + assert_eq!(config.cache_to.len(), 1); + } + + #[test] + fn serde_with_output_type() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "output_type": "tar" + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.output_type, Some("tar".to_string())); + } + + #[test] + fn serde_with_registry_auth() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "registry_auth": { + "ghcr.io": {"username": "bot", "password": "tok"}, + "docker.io": {"username": "u", "password": "p"} + } + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.registry_auth.len(), 2); + assert_eq!(config.registry_auth["ghcr.io"].username, "bot"); + assert_eq!(config.registry_auth["docker.io"].password, "p"); + } + + #[test] + fn serde_with_tls() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "tls": { + "ca": "/certs/ca.pem", + "cert": "/certs/cert.pem", + "key": "/certs/key.pem" + } + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.tls.ca, Some("/certs/ca.pem".to_string())); + assert_eq!(config.tls.cert, Some("/certs/cert.pem".to_string())); + assert_eq!(config.tls.key, Some("/certs/key.pem".to_string())); + } + + #[test] + fn serde_partial_tls() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "tls": {"ca": "/certs/ca.pem"} + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.tls.ca, Some("/certs/ca.pem".to_string())); + assert_eq!(config.tls.cert, None); + assert_eq!(config.tls.key, None); + } + + #[test] + fn serde_empty_tls_object() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": ".", + "tls": {} + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.tls.ca, None); + assert_eq!(config.tls.cert, None); + assert_eq!(config.tls.key, None); + } + + #[test] + fn tls_config_clone() { + let tls = TlsConfig { + ca: Some("ca".to_string()), + cert: Some("cert".to_string()), + key: Some("key".to_string()), + }; + let cloned = tls.clone(); + assert_eq!(tls.ca, cloned.ca); + assert_eq!(tls.cert, cloned.cert); + assert_eq!(tls.key, cloned.key); + } + + #[test] + fn tls_config_debug() { + let tls = TlsConfig::default(); + let debug = format!("{:?}", tls); + assert!(debug.contains("TlsConfig")); + } + + #[test] + fn buildkit_config_debug() { + let json = r#"{"dockerfile": "Dockerfile", "context": "."}"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + let debug = format!("{:?}", config); + assert!(debug.contains("BuildkitConfig")); + } + + #[test] + fn registry_auth_clone() { + let auth = RegistryAuth { + username: "u".to_string(), + password: "p".to_string(), + }; + let cloned = auth.clone(); + assert_eq!(auth.username, cloned.username); + assert_eq!(auth.password, cloned.password); + } + + #[test] + fn buildkit_config_clone() { + let json = r#"{"dockerfile": "Dockerfile", "context": "."}"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + let cloned = config.clone(); + assert_eq!(config.dockerfile, cloned.dockerfile); + assert_eq!(config.context, cloned.context); + assert_eq!(config.buildkit_addr, cloned.buildkit_addr); + } } diff --git a/wfe-buildkit/src/lib.rs b/wfe-buildkit/src/lib.rs index fee18b7..2c3b902 100644 --- a/wfe-buildkit/src/lib.rs +++ b/wfe-buildkit/src/lib.rs @@ -2,4 +2,4 @@ pub mod config; pub mod step; pub use config::{BuildkitConfig, RegistryAuth, TlsConfig}; -pub use step::BuildkitStep; +pub use step::{build_output_data, parse_digest, BuildkitStep}; diff --git a/wfe-buildkit/src/step.rs b/wfe-buildkit/src/step.rs index e8b4226..a231ea4 100644 --- a/wfe-buildkit/src/step.rs +++ b/wfe-buildkit/src/step.rs @@ -151,6 +151,49 @@ pub fn parse_digest(output: &str) -> Option { .map(|caps| format!("sha256:{}", &caps[1])) } +/// Build the output data JSON object from step execution results. +/// +/// Assembles a `serde_json::Value::Object` containing the step's stdout, +/// stderr, digest (if found), and tags (if any). +pub fn build_output_data( + step_name: &str, + stdout: &str, + stderr: &str, + digest: Option<&str>, + tags: &[String], +) -> serde_json::Value { + let mut outputs = serde_json::Map::new(); + + if let Some(digest) = digest { + outputs.insert( + format!("{step_name}.digest"), + serde_json::Value::String(digest.to_string()), + ); + } + + if !tags.is_empty() { + outputs.insert( + format!("{step_name}.tags"), + serde_json::Value::Array( + tags.iter() + .map(|t| serde_json::Value::String(t.clone())) + .collect(), + ), + ); + } + + outputs.insert( + format!("{step_name}.stdout"), + serde_json::Value::String(stdout.to_string()), + ); + outputs.insert( + format!("{step_name}.stderr"), + serde_json::Value::String(stderr.to_string()), + ); + + serde_json::Value::Object(outputs) +} + #[async_trait] impl StepBody for BuildkitStep { async fn run( @@ -208,40 +251,17 @@ impl StepBody for BuildkitStep { let combined_output = format!("{stdout}\n{stderr}"); let digest = parse_digest(&combined_output); - let mut outputs = serde_json::Map::new(); - - if let Some(ref digest) = digest { - outputs.insert( - format!("{step_name}.digest"), - serde_json::Value::String(digest.clone()), - ); - } - - if !self.config.tags.is_empty() { - outputs.insert( - format!("{step_name}.tags"), - serde_json::Value::Array( - self.config - .tags - .iter() - .map(|t| serde_json::Value::String(t.clone())) - .collect(), - ), - ); - } - - outputs.insert( - format!("{step_name}.stdout"), - serde_json::Value::String(stdout), - ); - outputs.insert( - format!("{step_name}.stderr"), - serde_json::Value::String(stderr), + let output_data = build_output_data( + step_name, + &stdout, + &stderr, + digest.as_deref(), + &self.config.tags, ); Ok(ExecutionResult { proceed: true, - output_data: Some(serde_json::Value::Object(outputs)), + output_data: Some(output_data), ..Default::default() }) } @@ -273,6 +293,10 @@ mod tests { } } + // --------------------------------------------------------------- + // build_command tests + // --------------------------------------------------------------- + #[test] fn build_command_minimal() { let step = BuildkitStep::new(minimal_config()); @@ -320,6 +344,22 @@ mod tests { ); } + #[test] + fn build_command_tags_no_push() { + let mut config = minimal_config(); + config.tags = vec!["myapp:latest".to_string()]; + config.push = false; + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + let output_idx = cmd.iter().position(|a| a == "--output").unwrap(); + assert_eq!( + cmd[output_idx + 1], + "type=image,name=myapp:latest,push=false" + ); + } + #[test] fn build_command_with_build_args() { let mut config = minimal_config(); @@ -367,6 +407,40 @@ mod tests { ); } + #[test] + fn build_command_with_multiple_cache_sources() { + let mut config = minimal_config(); + config.cache_from = vec![ + "type=registry,ref=myapp:cache".to_string(), + "type=local,src=/tmp/cache".to_string(), + ]; + config.cache_to = vec![ + "type=registry,ref=myapp:cache,mode=max".to_string(), + "type=local,dest=/tmp/cache".to_string(), + ]; + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + let import_positions: Vec = cmd + .iter() + .enumerate() + .filter(|(_, a)| *a == "--import-cache") + .map(|(i, _)| i) + .collect(); + assert_eq!(import_positions.len(), 2); + assert_eq!(cmd[import_positions[0] + 1], "type=registry,ref=myapp:cache"); + assert_eq!(cmd[import_positions[1] + 1], "type=local,src=/tmp/cache"); + + let export_positions: Vec = cmd + .iter() + .enumerate() + .filter(|(_, a)| *a == "--export-cache") + .map(|(i, _)| i) + .collect(); + assert_eq!(export_positions.len(), 2); + } + #[test] fn build_command_with_tls() { let mut config = minimal_config(); @@ -395,6 +469,23 @@ mod tests { assert!(key_idx < build_idx); } + #[test] + fn build_command_with_partial_tls() { + let mut config = minimal_config(); + config.tls = TlsConfig { + ca: Some("/certs/ca.pem".to_string()), + cert: None, + key: None, + }; + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + assert!(cmd.contains(&"--tlscacert".to_string())); + assert!(!cmd.contains(&"--tlscert".to_string())); + assert!(!cmd.contains(&"--tlskey".to_string())); + } + #[test] fn build_command_with_registry_auth() { let mut config = minimal_config(); @@ -451,6 +542,135 @@ mod tests { assert_eq!(cmd[output_idx + 1], "type=local"); } + #[test] + fn build_command_output_type_tar() { + let mut config = minimal_config(); + config.output_type = Some("tar".to_string()); + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + let output_idx = cmd.iter().position(|a| a == "--output").unwrap(); + assert_eq!(cmd[output_idx + 1], "type=tar"); + } + + #[test] + fn build_command_dockerfile_at_root() { + // When dockerfile is just a bare filename (no path component), + // the directory should be "." and no filename opt is emitted. + let config = minimal_config(); // dockerfile = "Dockerfile" + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + assert!(cmd.contains(&"dockerfile=.".to_string())); + // "Dockerfile" is the default so no --opt filename=... should appear + assert!(!cmd.iter().any(|a| a.starts_with("filename="))); + } + + #[test] + fn build_command_custom_addr() { + let mut config = minimal_config(); + config.buildkit_addr = "tcp://buildkitd:1234".to_string(); + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + assert_eq!(cmd[1], "--addr"); + assert_eq!(cmd[2], "tcp://buildkitd:1234"); + } + + #[test] + fn build_command_all_options_combined() { + let mut config = minimal_config(); + config.buildkit_addr = "tcp://remote:9999".to_string(); + config.dockerfile = "ci/Dockerfile.ci".to_string(); + config.context = "/workspace".to_string(); + config.target = Some("final".to_string()); + config.tags = vec!["img:v1".to_string()]; + config.push = true; + config.build_args.insert("A".to_string(), "1".to_string()); + config.cache_from = vec!["type=local,src=/c".to_string()]; + config.cache_to = vec!["type=local,dest=/c".to_string()]; + config.tls = TlsConfig { + ca: Some("ca".to_string()), + cert: Some("cert".to_string()), + key: Some("key".to_string()), + }; + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + // Verify key elements exist + assert!(cmd.contains(&"tcp://remote:9999".to_string())); + assert!(cmd.contains(&"context=/workspace".to_string())); + assert!(cmd.contains(&"dockerfile=ci".to_string())); + assert!(cmd.contains(&"filename=Dockerfile.ci".to_string())); + assert!(cmd.contains(&"target=final".to_string())); + assert!(cmd.contains(&"build-arg:A=1".to_string())); + assert!(cmd.iter().any(|a| a.starts_with("type=image,name=img:v1,push=true"))); + } + + // --------------------------------------------------------------- + // build_registry_env tests + // --------------------------------------------------------------- + + #[test] + fn build_registry_env_sanitizes_host() { + let mut config = minimal_config(); + config.registry_auth.insert( + "my-registry.example.com".to_string(), + RegistryAuth { + username: "u".to_string(), + password: "p".to_string(), + }, + ); + + let step = BuildkitStep::new(config); + let env = step.build_registry_env(); + + assert!(env.contains_key("BUILDKIT_HOST_MY_REGISTRY_EXAMPLE_COM_USERNAME")); + assert!(env.contains_key("BUILDKIT_HOST_MY_REGISTRY_EXAMPLE_COM_PASSWORD")); + } + + #[test] + fn build_registry_env_empty_when_no_auth() { + let step = BuildkitStep::new(minimal_config()); + let env = step.build_registry_env(); + assert!(env.is_empty()); + } + + #[test] + fn build_registry_env_multiple_registries() { + let mut config = minimal_config(); + config.registry_auth.insert( + "ghcr.io".to_string(), + RegistryAuth { + username: "gh_user".to_string(), + password: "gh_pass".to_string(), + }, + ); + config.registry_auth.insert( + "docker.io".to_string(), + RegistryAuth { + username: "dh_user".to_string(), + password: "dh_pass".to_string(), + }, + ); + + let step = BuildkitStep::new(config); + let env = step.build_registry_env(); + + assert_eq!(env.len(), 4); + assert_eq!(env["BUILDKIT_HOST_GHCR_IO_USERNAME"], "gh_user"); + assert_eq!(env["BUILDKIT_HOST_GHCR_IO_PASSWORD"], "gh_pass"); + assert_eq!(env["BUILDKIT_HOST_DOCKER_IO_USERNAME"], "dh_user"); + assert_eq!(env["BUILDKIT_HOST_DOCKER_IO_PASSWORD"], "dh_pass"); + } + + // --------------------------------------------------------------- + // parse_digest tests + // --------------------------------------------------------------- + #[test] fn parse_digest_from_output() { let output = "some build output\nexporting manifest sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\ndone"; @@ -492,27 +712,387 @@ mod tests { } #[test] - fn build_registry_env_sanitizes_host() { - let mut config = minimal_config(); - config.registry_auth.insert( - "my-registry.example.com".to_string(), - RegistryAuth { - username: "u".to_string(), - password: "p".to_string(), - }, - ); - - let step = BuildkitStep::new(config); - let env = step.build_registry_env(); - - assert!(env.contains_key("BUILDKIT_HOST_MY_REGISTRY_EXAMPLE_COM_USERNAME")); - assert!(env.contains_key("BUILDKIT_HOST_MY_REGISTRY_EXAMPLE_COM_PASSWORD")); + fn parse_digest_empty_input() { + assert_eq!(parse_digest(""), None); } #[test] - fn build_registry_env_empty_when_no_auth() { - let step = BuildkitStep::new(minimal_config()); - let env = step.build_registry_env(); - assert!(env.is_empty()); + fn parse_digest_wrong_prefix() { + // Has the hash but without a recognized prefix + let output = + "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + assert_eq!(parse_digest(output), None); + } + + #[test] + fn parse_digest_uppercase_hex_returns_none() { + // Regex expects lowercase hex + let output = "exporting manifest sha256:ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; + assert_eq!(parse_digest(output), None); + } + + #[test] + fn parse_digest_multiline_with_noise() { + let output = r#" +[+] Building 12.3s (8/8) FINISHED + => exporting to image + => exporting manifest sha256:aabbccdd0011223344556677aabbccdd0011223344556677aabbccdd00112233 + => done +"#; + assert_eq!( + parse_digest(output), + Some("sha256:aabbccdd0011223344556677aabbccdd0011223344556677aabbccdd00112233".to_string()) + ); + } + + #[test] + fn parse_digest_first_match_wins() { + let hash1 = "a".repeat(64); + let hash2 = "b".repeat(64); + let output = format!( + "exporting manifest sha256:{hash1}\ndigest: sha256:{hash2}" + ); + let digest = parse_digest(&output).unwrap(); + assert_eq!(digest, format!("sha256:{hash1}")); + } + + // --------------------------------------------------------------- + // build_output_data tests + // --------------------------------------------------------------- + + #[test] + fn build_output_data_with_digest_and_tags() { + let digest = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + let tags = vec!["myapp:latest".to_string(), "myapp:v1".to_string()]; + let result = build_output_data("build", "out", "err", Some(digest), &tags); + + let obj = result.as_object().unwrap(); + assert_eq!(obj["build.digest"], digest); + assert_eq!( + obj["build.tags"], + serde_json::json!(["myapp:latest", "myapp:v1"]) + ); + assert_eq!(obj["build.stdout"], "out"); + assert_eq!(obj["build.stderr"], "err"); + } + + #[test] + fn build_output_data_without_digest() { + let result = build_output_data("step1", "hello", "", None, &[]); + + let obj = result.as_object().unwrap(); + assert!(!obj.contains_key("step1.digest")); + assert!(!obj.contains_key("step1.tags")); + assert_eq!(obj["step1.stdout"], "hello"); + assert_eq!(obj["step1.stderr"], ""); + } + + #[test] + fn build_output_data_with_digest_no_tags() { + let digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let result = build_output_data("img", "ok", "warn", Some(digest), &[]); + + let obj = result.as_object().unwrap(); + assert_eq!(obj["img.digest"], digest); + assert!(!obj.contains_key("img.tags")); + assert_eq!(obj["img.stdout"], "ok"); + assert_eq!(obj["img.stderr"], "warn"); + } + + #[test] + fn build_output_data_no_digest_with_tags() { + let tags = vec!["app:v2".to_string()]; + let result = build_output_data("s", "", "", None, &tags); + + let obj = result.as_object().unwrap(); + assert!(!obj.contains_key("s.digest")); + assert_eq!(obj["s.tags"], serde_json::json!(["app:v2"])); + } + + #[test] + fn build_output_data_empty_strings() { + let result = build_output_data("x", "", "", None, &[]); + let obj = result.as_object().unwrap(); + assert_eq!(obj["x.stdout"], ""); + assert_eq!(obj["x.stderr"], ""); + assert_eq!(obj.len(), 2); + } + + // --------------------------------------------------------------- + // Integration tests using mock buildctl + // --------------------------------------------------------------- + + /// Helper to create a StepExecutionContext for testing. + fn make_test_context( + step_name: &str, + ) -> ( + wfe_core::models::WorkflowStep, + wfe_core::models::ExecutionPointer, + wfe_core::models::WorkflowInstance, + ) { + let mut step = wfe_core::models::WorkflowStep::new(0, "buildkit"); + step.name = Some(step_name.to_string()); + let pointer = wfe_core::models::ExecutionPointer::new(0); + let instance = + wfe_core::models::WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + (step, pointer, instance) + } + + #[cfg(unix)] + fn write_mock_buildctl(dir: &std::path::Path, script: &str) { + let path = dir.join("buildctl"); + std::fs::write(&path, script).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[cfg(unix)] + fn path_with_prefix(prefix: &std::path::Path) -> String { + let current = std::env::var("PATH").unwrap_or_default(); + format!("{}:{current}", prefix.display()) + } + + #[cfg(unix)] + #[tokio::test] + async fn run_with_mock_buildctl_success_with_digest() { + let tmp = tempfile::tempdir().unwrap(); + let digest_hash = "a".repeat(64); + let script = format!( + "#!/bin/sh\necho \"exporting manifest sha256:{digest_hash}\"\nexit 0\n" + ); + write_mock_buildctl(tmp.path(), &script); + + let mut config = minimal_config(); + config.tags = vec!["myapp:latest".to_string()]; + + let mut step = BuildkitStep::new(config); + + let (ws, pointer, instance) = make_test_context("build-img"); + let cancel = tokio_util::sync::CancellationToken::new(); + let ctx = wfe_core::traits::step::StepExecutionContext { + item: None, + execution_pointer: &pointer, + persistence_data: None, + step: &ws, + workflow: &instance, + cancellation_token: cancel, + }; + + // Override PATH so our mock is found first + let new_path = path_with_prefix(tmp.path()); + unsafe { std::env::set_var("PATH", &new_path) }; + + let result = step.run(&ctx).await.unwrap(); + + assert!(result.proceed); + let data = result.output_data.unwrap(); + let obj = data.as_object().unwrap(); + assert_eq!( + obj["build-img.digest"], + format!("sha256:{digest_hash}") + ); + assert_eq!( + obj["build-img.tags"], + serde_json::json!(["myapp:latest"]) + ); + assert!(obj.contains_key("build-img.stdout")); + assert!(obj.contains_key("build-img.stderr")); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_with_mock_buildctl_success_no_digest() { + let tmp = tempfile::tempdir().unwrap(); + write_mock_buildctl(tmp.path(), "#!/bin/sh\necho \"build complete\"\nexit 0\n"); + + let mut step = BuildkitStep::new(minimal_config()); + + let (ws, pointer, instance) = make_test_context("no-digest"); + let cancel = tokio_util::sync::CancellationToken::new(); + let ctx = wfe_core::traits::step::StepExecutionContext { + item: None, + execution_pointer: &pointer, + persistence_data: None, + step: &ws, + workflow: &instance, + cancellation_token: cancel, + }; + + let new_path = path_with_prefix(tmp.path()); + unsafe { std::env::set_var("PATH", &new_path) }; + + let result = step.run(&ctx).await.unwrap(); + + assert!(result.proceed); + let data = result.output_data.unwrap(); + let obj = data.as_object().unwrap(); + assert!(!obj.contains_key("no-digest.digest")); + assert!(!obj.contains_key("no-digest.tags")); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_with_mock_buildctl_nonzero_exit() { + let tmp = tempfile::tempdir().unwrap(); + write_mock_buildctl( + tmp.path(), + "#!/bin/sh\necho \"error: something failed\" >&2\nexit 1\n", + ); + + let mut step = BuildkitStep::new(minimal_config()); + + let (ws, pointer, instance) = make_test_context("fail-step"); + let cancel = tokio_util::sync::CancellationToken::new(); + let ctx = wfe_core::traits::step::StepExecutionContext { + item: None, + execution_pointer: &pointer, + persistence_data: None, + step: &ws, + workflow: &instance, + cancellation_token: cancel, + }; + + let new_path = path_with_prefix(tmp.path()); + unsafe { std::env::set_var("PATH", &new_path) }; + + let err = step.run(&ctx).await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("exited with code 1"), "got: {msg}"); + assert!(msg.contains("something failed"), "got: {msg}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_with_mock_buildctl_timeout() { + let tmp = tempfile::tempdir().unwrap(); + write_mock_buildctl(tmp.path(), "#!/bin/sh\nsleep 60\n"); + + let mut config = minimal_config(); + config.timeout_ms = Some(100); // 100ms timeout + + let mut step = BuildkitStep::new(config); + + let (ws, pointer, instance) = make_test_context("timeout-step"); + let cancel = tokio_util::sync::CancellationToken::new(); + let ctx = wfe_core::traits::step::StepExecutionContext { + item: None, + execution_pointer: &pointer, + persistence_data: None, + step: &ws, + workflow: &instance, + cancellation_token: cancel, + }; + + let new_path = path_with_prefix(tmp.path()); + unsafe { std::env::set_var("PATH", &new_path) }; + + let err = step.run(&ctx).await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("timed out after 100ms"), "got: {msg}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_with_missing_buildctl() { + // Use a temp dir with no buildctl script and make it the only PATH entry + let tmp = tempfile::tempdir().unwrap(); + + let mut step = BuildkitStep::new(minimal_config()); + + let (ws, pointer, instance) = make_test_context("missing"); + let cancel = tokio_util::sync::CancellationToken::new(); + let ctx = wfe_core::traits::step::StepExecutionContext { + item: None, + execution_pointer: &pointer, + persistence_data: None, + step: &ws, + workflow: &instance, + cancellation_token: cancel, + }; + + // Set PATH to empty dir so buildctl is not found + unsafe { std::env::set_var("PATH", tmp.path()) }; + + let err = step.run(&ctx).await.unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("Failed to spawn buildctl"), + "got: {msg}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_with_mock_buildctl_stderr_output() { + let tmp = tempfile::tempdir().unwrap(); + let digest_hash = "b".repeat(64); + let script = format!( + "#!/bin/sh\necho \"stdout line\" \necho \"digest: sha256:{digest_hash}\" >&2\nexit 0\n" + ); + write_mock_buildctl(tmp.path(), &script); + + let mut config = minimal_config(); + config.tags = vec!["app:v2".to_string()]; + + let mut step = BuildkitStep::new(config); + + let (ws, pointer, instance) = make_test_context("stderr-test"); + let cancel = tokio_util::sync::CancellationToken::new(); + let ctx = wfe_core::traits::step::StepExecutionContext { + item: None, + execution_pointer: &pointer, + persistence_data: None, + step: &ws, + workflow: &instance, + cancellation_token: cancel, + }; + + let new_path = path_with_prefix(tmp.path()); + unsafe { std::env::set_var("PATH", &new_path) }; + + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + let obj = data.as_object().unwrap(); + + // Digest should be found from stderr (combined output is searched) + assert_eq!( + obj["stderr-test.digest"], + format!("sha256:{digest_hash}") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn run_with_unnamed_step_uses_unknown() { + let tmp = tempfile::tempdir().unwrap(); + write_mock_buildctl(tmp.path(), "#!/bin/sh\necho ok\nexit 0\n"); + + let mut step = BuildkitStep::new(minimal_config()); + + // Create a step with no name + let ws = wfe_core::models::WorkflowStep::new(0, "buildkit"); + let pointer = wfe_core::models::ExecutionPointer::new(0); + let instance = + wfe_core::models::WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let cancel = tokio_util::sync::CancellationToken::new(); + let ctx = wfe_core::traits::step::StepExecutionContext { + item: None, + execution_pointer: &pointer, + persistence_data: None, + step: &ws, + workflow: &instance, + cancellation_token: cancel, + }; + + let new_path = path_with_prefix(tmp.path()); + unsafe { std::env::set_var("PATH", &new_path) }; + + let result = step.run(&ctx).await.unwrap(); + let data = result.output_data.unwrap(); + let obj = data.as_object().unwrap(); + + // Should use "unknown" as step name + assert!(obj.contains_key("unknown.stdout")); + assert!(obj.contains_key("unknown.stderr")); } } diff --git a/wfe-containerd/Cargo.toml b/wfe-containerd/Cargo.toml new file mode 100644 index 0000000..cba5c76 --- /dev/null +++ b/wfe-containerd/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "wfe-containerd" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "containerd container runner executor for WFE" + +[dependencies] +wfe-core = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } +tempfile = { workspace = true } +tokio-util = "0.7" diff --git a/wfe-containerd/README.md b/wfe-containerd/README.md new file mode 100644 index 0000000..70ac354 --- /dev/null +++ b/wfe-containerd/README.md @@ -0,0 +1,70 @@ +# wfe-containerd + +Containerd container runner executor for WFE. + +## What it does + +`wfe-containerd` runs containers via `nerdctl` as workflow steps. It pulls images, manages registry authentication, and executes containers with configurable networking, resource limits, volume mounts, and TLS settings. Output is captured and parsed for `##wfe[output key=value]` directives, following the same convention as the shell executor. + +## Quick start + +Add a containerd step to your YAML workflow: + +```yaml +workflow: + id: container-pipeline + version: 1 + steps: + - name: run-tests + type: containerd + config: + image: node:20-alpine + run: npm test + network: none + memory: 512m + cpu: "1.0" + timeout: 5m + env: + NODE_ENV: test + volumes: + - source: /workspace + target: /app + readonly: true +``` + +Enable the feature in `wfe-yaml`: + +```toml +[dependencies] +wfe-yaml = { version = "1.0.0", features = ["containerd"] } +``` + +## Configuration + +| Field | Type | Default | Description | +|---|---|---|---| +| `image` | `String` | required | Container image to run | +| `run` | `String` | - | Shell command (uses `sh -c`) | +| `command` | `Vec` | - | Command array (mutually exclusive with `run`) | +| `env` | `HashMap` | `{}` | Environment variables | +| `volumes` | `Vec` | `[]` | Volume mounts | +| `working_dir` | `String` | - | Working directory inside container | +| `user` | `String` | `65534:65534` | User/group to run as (nobody by default) | +| `network` | `String` | `none` | Network mode: `none`, `host`, or `bridge` | +| `memory` | `String` | - | Memory limit (e.g. `512m`, `1g`) | +| `cpu` | `String` | - | CPU limit (e.g. `1.0`, `0.5`) | +| `pull` | `String` | `if-not-present` | Pull policy: `always`, `if-not-present`, `never` | +| `containerd_addr` | `String` | `/run/containerd/containerd.sock` | Containerd socket address | +| `tls` | `TlsConfig` | - | TLS configuration for containerd connection | +| `registry_auth` | `HashMap` | `{}` | Registry authentication per registry hostname | +| `timeout` | `String` | - | Execution timeout (e.g. `30s`, `5m`) | + +## Output parsing + +The step captures stdout and stderr. Lines matching `##wfe[output key=value]` are extracted as workflow outputs. Raw stdout, stderr, and exit code are also available under `{step_name}.stdout`, `{step_name}.stderr`, and `{step_name}.exit_code`. + +## Security defaults + +- Runs as nobody (`65534:65534`) by default +- Network disabled (`none`) by default +- Containers are always `--rm` (removed after execution) diff --git a/wfe-containerd/src/config.rs b/wfe-containerd/src/config.rs new file mode 100644 index 0000000..702c4a6 --- /dev/null +++ b/wfe-containerd/src/config.rs @@ -0,0 +1,226 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContainerdConfig { + pub image: String, + pub command: Option>, + pub run: Option, + #[serde(default)] + pub env: HashMap, + #[serde(default)] + pub volumes: Vec, + pub working_dir: Option, + #[serde(default = "default_user")] + pub user: String, + #[serde(default = "default_network")] + pub network: String, + pub memory: Option, + pub cpu: Option, + #[serde(default = "default_pull")] + pub pull: String, + #[serde(default = "default_containerd_addr")] + pub containerd_addr: String, + /// CLI binary name: "nerdctl" (default) or "docker". + #[serde(default = "default_cli")] + pub cli: String, + #[serde(default)] + pub tls: TlsConfig, + #[serde(default)] + pub registry_auth: HashMap, + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeMountConfig { + pub source: String, + pub target: String, + #[serde(default)] + pub readonly: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TlsConfig { + pub ca: Option, + pub cert: Option, + pub key: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryAuth { + pub username: String, + pub password: String, +} + +fn default_user() -> String { + "65534:65534".to_string() +} + +fn default_network() -> String { + "none".to_string() +} + +fn default_pull() -> String { + "if-not-present".to_string() +} + +fn default_containerd_addr() -> String { + "/run/containerd/containerd.sock".to_string() +} + +fn default_cli() -> String { + "nerdctl".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn serde_round_trip_full_config() { + let config = ContainerdConfig { + image: "alpine:3.18".to_string(), + command: Some(vec!["echo".to_string(), "hello".to_string()]), + run: None, + env: HashMap::from([("FOO".to_string(), "bar".to_string())]), + volumes: vec![VolumeMountConfig { + source: "/host/path".to_string(), + target: "/container/path".to_string(), + readonly: true, + }], + working_dir: Some("/app".to_string()), + user: "1000:1000".to_string(), + network: "host".to_string(), + memory: Some("512m".to_string()), + cpu: Some("1.0".to_string()), + pull: "always".to_string(), + containerd_addr: "/custom/containerd.sock".to_string(), + cli: "nerdctl".to_string(), + tls: TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: Some("/cert.pem".to_string()), + key: Some("/key.pem".to_string()), + }, + registry_auth: HashMap::from([( + "registry.example.com".to_string(), + RegistryAuth { + username: "user".to_string(), + password: "pass".to_string(), + }, + )]), + timeout_ms: Some(30000), + }; + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: ContainerdConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.image, config.image); + assert_eq!(deserialized.command, config.command); + assert_eq!(deserialized.run, config.run); + assert_eq!(deserialized.env, config.env); + assert_eq!(deserialized.volumes.len(), 1); + assert_eq!(deserialized.volumes[0].source, "/host/path"); + assert_eq!(deserialized.volumes[0].readonly, true); + assert_eq!(deserialized.working_dir, Some("/app".to_string())); + assert_eq!(deserialized.user, "1000:1000"); + assert_eq!(deserialized.network, "host"); + assert_eq!(deserialized.memory, Some("512m".to_string())); + assert_eq!(deserialized.cpu, Some("1.0".to_string())); + assert_eq!(deserialized.pull, "always"); + assert_eq!(deserialized.containerd_addr, "/custom/containerd.sock"); + assert_eq!(deserialized.tls.ca, Some("/ca.pem".to_string())); + assert_eq!(deserialized.tls.cert, Some("/cert.pem".to_string())); + assert_eq!(deserialized.tls.key, Some("/key.pem".to_string())); + assert!(deserialized.registry_auth.contains_key("registry.example.com")); + assert_eq!(deserialized.timeout_ms, Some(30000)); + } + + #[test] + fn serde_round_trip_minimal_config() { + let json = r#"{"image": "alpine:latest"}"#; + let config: ContainerdConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.image, "alpine:latest"); + assert_eq!(config.command, None); + assert_eq!(config.run, None); + assert!(config.env.is_empty()); + assert!(config.volumes.is_empty()); + assert_eq!(config.working_dir, None); + assert_eq!(config.user, "65534:65534"); + assert_eq!(config.network, "none"); + assert_eq!(config.memory, None); + assert_eq!(config.cpu, None); + assert_eq!(config.pull, "if-not-present"); + assert_eq!(config.containerd_addr, "/run/containerd/containerd.sock"); + assert_eq!(config.timeout_ms, None); + + // Round-trip + let serialized = serde_json::to_string(&config).unwrap(); + let deserialized: ContainerdConfig = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized.image, "alpine:latest"); + assert_eq!(deserialized.user, "65534:65534"); + } + + #[test] + fn default_values() { + let json = r#"{"image": "busybox"}"#; + let config: ContainerdConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.user, "65534:65534"); + assert_eq!(config.network, "none"); + assert_eq!(config.pull, "if-not-present"); + assert_eq!(config.containerd_addr, "/run/containerd/containerd.sock"); + } + + #[test] + fn volume_mount_serde() { + let vol = VolumeMountConfig { + source: "/data".to_string(), + target: "/mnt/data".to_string(), + readonly: false, + }; + let json = serde_json::to_string(&vol).unwrap(); + let deserialized: VolumeMountConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.source, "/data"); + assert_eq!(deserialized.target, "/mnt/data"); + assert_eq!(deserialized.readonly, false); + + // With readonly=true + let vol_ro = VolumeMountConfig { + source: "/src".to_string(), + target: "/dest".to_string(), + readonly: true, + }; + let json_ro = serde_json::to_string(&vol_ro).unwrap(); + let deserialized_ro: VolumeMountConfig = serde_json::from_str(&json_ro).unwrap(); + assert_eq!(deserialized_ro.readonly, true); + } + + #[test] + fn tls_config_defaults() { + let tls = TlsConfig::default(); + assert_eq!(tls.ca, None); + assert_eq!(tls.cert, None); + assert_eq!(tls.key, None); + + let json = r#"{}"#; + let deserialized: TlsConfig = serde_json::from_str(json).unwrap(); + assert_eq!(deserialized.ca, None); + assert_eq!(deserialized.cert, None); + assert_eq!(deserialized.key, None); + } + + #[test] + fn registry_auth_serde() { + let auth = RegistryAuth { + username: "admin".to_string(), + password: "secret123".to_string(), + }; + let json = serde_json::to_string(&auth).unwrap(); + let deserialized: RegistryAuth = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.username, "admin"); + assert_eq!(deserialized.password, "secret123"); + } +} diff --git a/wfe-containerd/src/lib.rs b/wfe-containerd/src/lib.rs new file mode 100644 index 0000000..2b3c9d4 --- /dev/null +++ b/wfe-containerd/src/lib.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod step; + +pub use config::{ContainerdConfig, RegistryAuth, TlsConfig, VolumeMountConfig}; +pub use step::ContainerdStep; diff --git a/wfe-containerd/src/step.rs b/wfe-containerd/src/step.rs new file mode 100644 index 0000000..591d348 --- /dev/null +++ b/wfe-containerd/src/step.rs @@ -0,0 +1,1051 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use wfe_core::WfeError; +use wfe_core::models::ExecutionResult; +use wfe_core::traits::step::{StepBody, StepExecutionContext}; + +use crate::config::ContainerdConfig; + +pub struct ContainerdStep { + config: ContainerdConfig, +} + +impl ContainerdStep { + pub fn new(config: ContainerdConfig) -> Self { + Self { config } + } + + /// Build the pull command arguments using the configured CLI binary. + pub fn build_pull_command(&self) -> Vec { + let mut args = vec![ + self.config.cli.clone(), + "--address".to_string(), + self.config.containerd_addr.clone(), + ]; + + self.append_tls_flags(&mut args); + + args.push("pull".to_string()); + args.push(self.config.image.clone()); + + args + } + + /// Build the nerdctl run command arguments. + pub fn build_run_command(&self) -> Vec { + self.build_run_command_with_extra_env(&HashMap::new()) + } + + /// Build the run command arguments with extra environment variables + /// injected from workflow data, using the configured CLI binary. + pub fn build_run_command_with_extra_env( + &self, + workflow_env: &HashMap, + ) -> Vec { + let mut args = vec![ + self.config.cli.clone(), + "--address".to_string(), + self.config.containerd_addr.clone(), + ]; + + self.append_tls_flags(&mut args); + + args.push("run".to_string()); + args.push("--rm".to_string()); + + args.push("--net".to_string()); + args.push(self.config.network.clone()); + + args.push("--user".to_string()); + args.push(self.config.user.clone()); + + if let Some(ref memory) = self.config.memory { + args.push("--memory".to_string()); + args.push(memory.clone()); + } + + if let Some(ref cpu) = self.config.cpu { + args.push("--cpus".to_string()); + args.push(cpu.clone()); + } + + if let Some(ref working_dir) = self.config.working_dir { + args.push("-w".to_string()); + args.push(working_dir.clone()); + } + + // Inject workflow data env vars first, then config env vars (config overrides). + for (key, value) in workflow_env { + args.push("-e".to_string()); + args.push(format!("{key}={value}")); + } + + for (key, value) in &self.config.env { + args.push("-e".to_string()); + args.push(format!("{key}={value}")); + } + + for vol in &self.config.volumes { + args.push("-v".to_string()); + if vol.readonly { + args.push(format!("{}:{}:ro", vol.source, vol.target)); + } else { + args.push(format!("{}:{}", vol.source, vol.target)); + } + } + + args.push(self.config.image.clone()); + + // Add command or run + if let Some(ref run) = self.config.run { + args.push("sh".to_string()); + args.push("-c".to_string()); + args.push(run.clone()); + } else if let Some(ref command) = self.config.command { + args.extend(command.iter().cloned()); + } + + args + } + + /// Parse ##wfe[output key=value] lines from stdout. + pub fn parse_outputs(stdout: &str) -> HashMap { + let mut outputs = HashMap::new(); + for line in stdout.lines() { + if let Some(rest) = line.strip_prefix("##wfe[output ") + && let Some(rest) = rest.strip_suffix(']') + && let Some(eq_pos) = rest.find('=') + { + let name = rest[..eq_pos].trim().to_string(); + let value = rest[eq_pos + 1..].to_string(); + outputs.insert(name, value); + } + } + outputs + } + + /// Build the output data JSON value from step execution results. + pub fn build_output_data( + step_name: &str, + stdout: &str, + stderr: &str, + exit_code: i32, + parsed_outputs: &HashMap, + ) -> serde_json::Value { + let mut outputs = serde_json::Map::new(); + for (key, value) in parsed_outputs { + outputs.insert(key.clone(), serde_json::Value::String(value.clone())); + } + outputs.insert( + format!("{step_name}.stdout"), + serde_json::Value::String(stdout.to_string()), + ); + outputs.insert( + format!("{step_name}.stderr"), + serde_json::Value::String(stderr.to_string()), + ); + outputs.insert( + format!("{step_name}.exit_code"), + serde_json::Value::Number(serde_json::Number::from(exit_code)), + ); + serde_json::Value::Object(outputs) + } + + /// Build login commands for each configured registry auth entry. + /// Returns a list of (registry, args) tuples. + pub fn build_login_commands(&self) -> Vec<(String, Vec)> { + let mut commands = Vec::new(); + for (registry, auth) in &self.config.registry_auth { + let mut args = vec![ + self.config.cli.clone(), + "--address".to_string(), + self.config.containerd_addr.clone(), + ]; + self.append_tls_flags(&mut args); + args.push("login".to_string()); + args.push("-u".to_string()); + args.push(auth.username.clone()); + args.push("-p".to_string()); + args.push(auth.password.clone()); + args.push(registry.clone()); + commands.push((registry.clone(), args)); + } + commands + } + + fn append_tls_flags(&self, args: &mut Vec) { + if let Some(ref ca) = self.config.tls.ca { + args.push("--tlscacert".to_string()); + args.push(ca.clone()); + } + if let Some(ref cert) = self.config.tls.cert { + args.push("--tlscert".to_string()); + args.push(cert.clone()); + } + if let Some(ref key) = self.config.tls.key { + args.push("--tlskey".to_string()); + args.push(key.clone()); + } + } + + /// Run registry login commands for each configured registry auth entry. + async fn run_registry_logins(&self) -> Result<(), WfeError> { + for (registry, auth) in &self.config.registry_auth { + let mut cmd = tokio::process::Command::new(&self.config.cli); + cmd.arg("--address") + .arg(&self.config.containerd_addr); + + self.append_tls_flags_to_command(&mut cmd); + + cmd.arg("login") + .arg("-u") + .arg(&auth.username) + .arg("-p") + .arg(&auth.password) + .arg(registry); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let cli = &self.config.cli; + let output = cmd + .output() + .await + .map_err(|e| WfeError::StepExecution(format!("Failed to run {cli} login for {registry}: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WfeError::StepExecution(format!( + "{cli} login failed for {registry}: {stderr}" + ))); + } + } + Ok(()) + } + + fn append_tls_flags_to_command(&self, cmd: &mut tokio::process::Command) { + if let Some(ref ca) = self.config.tls.ca { + cmd.arg("--tlscacert").arg(ca); + } + if let Some(ref cert) = self.config.tls.cert { + cmd.arg("--tlscert").arg(cert); + } + if let Some(ref key) = self.config.tls.key { + cmd.arg("--tlskey").arg(key); + } + } +} + +#[async_trait] +impl StepBody for ContainerdStep { + async fn run(&mut self, context: &StepExecutionContext<'_>) -> wfe_core::Result { + // Run registry logins if configured. + if !self.config.registry_auth.is_empty() { + self.run_registry_logins().await?; + } + + // Pull image if needed. + let should_pull = match self.config.pull.as_str() { + "always" => true, + "never" => false, + // "if-not-present" — always attempt pull, nerdctl handles caching + _ => true, + }; + + if should_pull { + let pull_args = self.build_pull_command(); + let mut pull_cmd = tokio::process::Command::new(&pull_args[0]); + pull_cmd.args(&pull_args[1..]); + pull_cmd.stdout(std::process::Stdio::piped()); + pull_cmd.stderr(std::process::Stdio::piped()); + + let pull_output = pull_cmd + .output() + .await + .map_err(|e| WfeError::StepExecution(format!("Failed to pull image: {e}")))?; + + if !pull_output.status.success() { + let stderr = String::from_utf8_lossy(&pull_output.stderr); + return Err(WfeError::StepExecution(format!( + "Image pull failed: {stderr}" + ))); + } + } + + // Build workflow env vars (same pattern as shell executor). + let mut workflow_env = HashMap::new(); + if let Some(data_obj) = context.workflow.data.as_object() { + for (key, value) in data_obj { + let env_key = key.to_uppercase(); + let env_val = match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + }; + workflow_env.insert(env_key, env_val); + } + } + + // Build and spawn the run command. + let run_args = self.build_run_command_with_extra_env(&workflow_env); + let mut cmd = tokio::process::Command::new(&run_args[0]); + cmd.args(&run_args[1..]); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + // Execute with optional timeout. + let cli = &self.config.cli; + let output = if let Some(timeout_ms) = self.config.timeout_ms { + let duration = std::time::Duration::from_millis(timeout_ms); + match tokio::time::timeout(duration, cmd.output()).await { + Ok(result) => result.map_err(|e| { + WfeError::StepExecution(format!("Failed to spawn {cli} run: {e}")) + })?, + Err(_) => { + return Err(WfeError::StepExecution(format!( + "Container execution timed out after {timeout_ms}ms" + ))); + } + } + } else { + cmd.output() + .await + .map_err(|e| WfeError::StepExecution(format!("Failed to spawn {cli} run: {e}")))? + }; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let exit_code = output.status.code().unwrap_or(-1); + + if !output.status.success() { + return Err(WfeError::StepExecution(format!( + "Container exited with code {exit_code}\nstdout: {stdout}\nstderr: {stderr}" + ))); + } + + // Parse ##wfe[output key=value] lines from stdout. + let parsed = Self::parse_outputs(&stdout); + let step_name = context.step.name.as_deref().unwrap_or("unknown"); + let output_data = Self::build_output_data(step_name, &stdout, &stderr, exit_code, &parsed); + + Ok(ExecutionResult { + proceed: true, + output_data: Some(output_data), + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{RegistryAuth, TlsConfig, VolumeMountConfig}; + use pretty_assertions::assert_eq; + + fn minimal_config() -> ContainerdConfig { + ContainerdConfig { + image: "alpine:3.18".to_string(), + command: None, + run: Some("echo hello".to_string()), + env: HashMap::new(), + volumes: vec![], + working_dir: None, + user: "65534:65534".to_string(), + network: "none".to_string(), + memory: None, + cpu: None, + pull: "if-not-present".to_string(), + containerd_addr: "/run/containerd/containerd.sock".to_string(), + cli: "nerdctl".to_string(), + tls: TlsConfig::default(), + registry_auth: HashMap::new(), + timeout_ms: None, + } + } + + // ── build_run_command ─────────────────────────────────────────────── + + #[test] + fn build_run_command_minimal() { + let step = ContainerdStep::new(minimal_config()); + let args = step.build_run_command(); + + assert_eq!(args[0], "nerdctl"); + assert_eq!(args[1], "--address"); + assert_eq!(args[2], "/run/containerd/containerd.sock"); + assert_eq!(args[3], "run"); + assert_eq!(args[4], "--rm"); + assert_eq!(args[5], "--net"); + assert_eq!(args[6], "none"); + assert_eq!(args[7], "--user"); + assert_eq!(args[8], "65534:65534"); + assert_eq!(args[9], "alpine:3.18"); + assert_eq!(args[10], "sh"); + assert_eq!(args[11], "-c"); + assert_eq!(args[12], "echo hello"); + } + + #[test] + fn build_run_command_with_command_array() { + let mut config = minimal_config(); + config.run = None; + config.command = Some(vec!["echo".to_string(), "hello".to_string(), "world".to_string()]); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let len = args.len(); + assert_eq!(args[len - 3], "echo"); + assert_eq!(args[len - 2], "hello"); + assert_eq!(args[len - 1], "world"); + // Should NOT contain sh -c + assert!(!args.contains(&"sh".to_string()) || args.iter().position(|a| a == "sh").is_none()); + } + + #[test] + fn build_run_command_no_command_no_run() { + let mut config = minimal_config(); + config.run = None; + config.command = None; + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + // Should end with the image name, no trailing command + assert_eq!(args.last().unwrap(), "alpine:3.18"); + } + + #[test] + fn build_run_command_with_env() { + let mut config = minimal_config(); + config.env = HashMap::from([ + ("FOO".to_string(), "bar".to_string()), + ("BAZ".to_string(), "qux".to_string()), + ]); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let mut env_pairs: Vec = Vec::new(); + let mut i = 0; + while i < args.len() { + if args[i] == "-e" { + env_pairs.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + env_pairs.sort(); + assert!(env_pairs.contains(&"BAZ=qux".to_string())); + assert!(env_pairs.contains(&"FOO=bar".to_string())); + } + + #[test] + fn build_run_command_with_volumes() { + let mut config = minimal_config(); + config.volumes = vec![VolumeMountConfig { + source: "/host/data".to_string(), + target: "/container/data".to_string(), + readonly: false, + }]; + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(args.contains(&"-v".to_string())); + assert!(args.contains(&"/host/data:/container/data".to_string())); + } + + #[test] + fn build_run_command_with_volumes_readonly() { + let mut config = minimal_config(); + config.volumes = vec![VolumeMountConfig { + source: "/host/data".to_string(), + target: "/container/data".to_string(), + readonly: true, + }]; + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(args.contains(&"-v".to_string())); + assert!(args.contains(&"/host/data:/container/data:ro".to_string())); + } + + #[test] + fn build_run_command_with_multiple_volumes() { + let mut config = minimal_config(); + config.volumes = vec![ + VolumeMountConfig { + source: "/a".to_string(), + target: "/b".to_string(), + readonly: false, + }, + VolumeMountConfig { + source: "/c".to_string(), + target: "/d".to_string(), + readonly: true, + }, + ]; + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(args.contains(&"/a:/b".to_string())); + assert!(args.contains(&"/c:/d:ro".to_string())); + } + + #[test] + fn build_run_command_with_resource_limits() { + let mut config = minimal_config(); + config.memory = Some("512m".to_string()); + config.cpu = Some("1.5".to_string()); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let mem_idx = args.iter().position(|a| a == "--memory").unwrap(); + assert_eq!(args[mem_idx + 1], "512m"); + + let cpu_idx = args.iter().position(|a| a == "--cpus").unwrap(); + assert_eq!(args[cpu_idx + 1], "1.5"); + } + + #[test] + fn build_run_command_memory_only() { + let mut config = minimal_config(); + config.memory = Some("1g".to_string()); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(args.contains(&"--memory".to_string())); + assert!(!args.contains(&"--cpus".to_string())); + } + + #[test] + fn build_run_command_cpu_only() { + let mut config = minimal_config(); + config.cpu = Some("2.0".to_string()); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(args.contains(&"--cpus".to_string())); + assert!(!args.contains(&"--memory".to_string())); + } + + #[test] + fn build_run_command_with_network_host() { + let mut config = minimal_config(); + config.network = "host".to_string(); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let net_idx = args.iter().position(|a| a == "--net").unwrap(); + assert_eq!(args[net_idx + 1], "host"); + } + + #[test] + fn build_run_command_with_network_bridge() { + let mut config = minimal_config(); + config.network = "bridge".to_string(); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let net_idx = args.iter().position(|a| a == "--net").unwrap(); + assert_eq!(args[net_idx + 1], "bridge"); + } + + #[test] + fn build_run_command_with_working_dir() { + let mut config = minimal_config(); + config.working_dir = Some("/app".to_string()); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let w_idx = args.iter().position(|a| a == "-w").unwrap(); + assert_eq!(args[w_idx + 1], "/app"); + } + + #[test] + fn build_run_command_without_working_dir() { + let config = minimal_config(); + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(!args.contains(&"-w".to_string())); + } + + #[test] + fn build_run_command_with_user() { + let mut config = minimal_config(); + config.user = "1000:1000".to_string(); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let user_idx = args.iter().position(|a| a == "--user").unwrap(); + assert_eq!(args[user_idx + 1], "1000:1000"); + } + + #[test] + fn build_run_command_root_user() { + let mut config = minimal_config(); + config.user = "0:0".to_string(); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let user_idx = args.iter().position(|a| a == "--user").unwrap(); + assert_eq!(args[user_idx + 1], "0:0"); + } + + #[test] + fn build_run_command_with_tls() { + let mut config = minimal_config(); + config.tls = TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: Some("/cert.pem".to_string()), + key: Some("/key.pem".to_string()), + }; + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + let ca_idx = args.iter().position(|a| a == "--tlscacert").unwrap(); + assert_eq!(args[ca_idx + 1], "/ca.pem"); + + let cert_idx = args.iter().position(|a| a == "--tlscert").unwrap(); + assert_eq!(args[cert_idx + 1], "/cert.pem"); + + let key_idx = args.iter().position(|a| a == "--tlskey").unwrap(); + assert_eq!(args[key_idx + 1], "/key.pem"); + } + + #[test] + fn build_run_command_with_partial_tls() { + let mut config = minimal_config(); + config.tls = TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: None, + key: None, + }; + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(args.contains(&"--tlscacert".to_string())); + assert!(!args.contains(&"--tlscert".to_string())); + assert!(!args.contains(&"--tlskey".to_string())); + } + + #[test] + fn build_run_command_no_tls() { + let config = minimal_config(); + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert!(!args.contains(&"--tlscacert".to_string())); + assert!(!args.contains(&"--tlscert".to_string())); + assert!(!args.contains(&"--tlskey".to_string())); + } + + #[test] + fn build_run_command_with_custom_address() { + let mut config = minimal_config(); + config.containerd_addr = "/custom/sock".to_string(); + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + assert_eq!(args[1], "--address"); + assert_eq!(args[2], "/custom/sock"); + } + + #[test] + fn build_run_command_with_all_flags() { + let mut config = minimal_config(); + config.network = "host".to_string(); + config.user = "1000:1000".to_string(); + config.memory = Some("256m".to_string()); + config.cpu = Some("0.5".to_string()); + config.working_dir = Some("/workspace".to_string()); + config.env = HashMap::from([("KEY".to_string(), "val".to_string())]); + config.volumes = vec![VolumeMountConfig { + source: "/src".to_string(), + target: "/dst".to_string(), + readonly: true, + }]; + config.tls = TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: Some("/cert.pem".to_string()), + key: Some("/key.pem".to_string()), + }; + + let step = ContainerdStep::new(config); + let args = step.build_run_command(); + + // Verify all flags are present + assert!(args.contains(&"--net".to_string())); + assert!(args.contains(&"host".to_string())); + assert!(args.contains(&"--user".to_string())); + assert!(args.contains(&"1000:1000".to_string())); + assert!(args.contains(&"--memory".to_string())); + assert!(args.contains(&"256m".to_string())); + assert!(args.contains(&"--cpus".to_string())); + assert!(args.contains(&"0.5".to_string())); + assert!(args.contains(&"-w".to_string())); + assert!(args.contains(&"/workspace".to_string())); + assert!(args.contains(&"-e".to_string())); + assert!(args.contains(&"KEY=val".to_string())); + assert!(args.contains(&"-v".to_string())); + assert!(args.contains(&"/src:/dst:ro".to_string())); + assert!(args.contains(&"--tlscacert".to_string())); + assert!(args.contains(&"--tlscert".to_string())); + assert!(args.contains(&"--tlskey".to_string())); + } + + // ── build_run_command_with_extra_env ──────────────────────────────── + + #[test] + fn build_run_command_with_extra_env_merges() { + let mut config = minimal_config(); + config.env = HashMap::from([("CONFIG_VAR".to_string(), "from_config".to_string())]); + + let step = ContainerdStep::new(config); + let workflow_env = HashMap::from([("WF_VAR".to_string(), "from_workflow".to_string())]); + let args = step.build_run_command_with_extra_env(&workflow_env); + + let mut env_pairs: Vec = Vec::new(); + let mut i = 0; + while i < args.len() { + if args[i] == "-e" { + env_pairs.push(args[i + 1].clone()); + i += 2; + } else { + i += 1; + } + } + + assert!(env_pairs.contains(&"CONFIG_VAR=from_config".to_string())); + assert!(env_pairs.contains(&"WF_VAR=from_workflow".to_string())); + } + + #[test] + fn build_run_command_with_extra_env_empty() { + let config = minimal_config(); + let step = ContainerdStep::new(config); + let args_no_extra = step.build_run_command_with_extra_env(&HashMap::new()); + let args_default = step.build_run_command(); + + assert_eq!(args_no_extra, args_default); + } + + // ── build_pull_command ────────────────────────────────────────────── + + #[test] + fn build_pull_command_basic() { + let step = ContainerdStep::new(minimal_config()); + let args = step.build_pull_command(); + + assert_eq!(args[0], "nerdctl"); + assert_eq!(args[1], "--address"); + assert_eq!(args[2], "/run/containerd/containerd.sock"); + assert_eq!(args[3], "pull"); + assert_eq!(args[4], "alpine:3.18"); + } + + #[test] + fn build_pull_command_with_tls() { + let mut config = minimal_config(); + config.tls = TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: None, + key: None, + }; + + let step = ContainerdStep::new(config); + let args = step.build_pull_command(); + + let ca_idx = args.iter().position(|a| a == "--tlscacert").unwrap(); + assert_eq!(args[ca_idx + 1], "/ca.pem"); + assert!(args.iter().position(|a| a == "--tlscert").is_none()); + assert!(args.iter().position(|a| a == "--tlskey").is_none()); + } + + #[test] + fn build_pull_command_with_full_tls() { + let mut config = minimal_config(); + config.tls = TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: Some("/cert.pem".to_string()), + key: Some("/key.pem".to_string()), + }; + + let step = ContainerdStep::new(config); + let args = step.build_pull_command(); + + assert!(args.contains(&"--tlscacert".to_string())); + assert!(args.contains(&"--tlscert".to_string())); + assert!(args.contains(&"--tlskey".to_string())); + // pull should still be present after tls flags + assert!(args.contains(&"pull".to_string())); + } + + #[test] + fn build_pull_command_custom_address() { + let mut config = minimal_config(); + config.containerd_addr = "/var/run/custom.sock".to_string(); + + let step = ContainerdStep::new(config); + let args = step.build_pull_command(); + + assert_eq!(args[2], "/var/run/custom.sock"); + } + + // ── parse_outputs ────────────────────────────────────────────────── + + #[test] + fn parse_outputs_empty() { + let outputs = ContainerdStep::parse_outputs(""); + assert!(outputs.is_empty()); + } + + #[test] + fn parse_outputs_single() { + let stdout = "some log line\n##wfe[output version=1.2.3]\nmore logs\n"; + let outputs = ContainerdStep::parse_outputs(stdout); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs.get("version").unwrap(), "1.2.3"); + } + + #[test] + fn parse_outputs_multiple() { + let stdout = "##wfe[output foo=bar]\n##wfe[output baz=qux]\n"; + let outputs = ContainerdStep::parse_outputs(stdout); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs.get("foo").unwrap(), "bar"); + assert_eq!(outputs.get("baz").unwrap(), "qux"); + } + + #[test] + fn parse_outputs_mixed_with_regular_stdout() { + let stdout = "Starting container...\n\ + Pulling image...\n\ + ##wfe[output digest=sha256:abc123]\n\ + Running tests...\n\ + ##wfe[output result=pass]\n\ + Done.\n"; + let outputs = ContainerdStep::parse_outputs(stdout); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs.get("digest").unwrap(), "sha256:abc123"); + assert_eq!(outputs.get("result").unwrap(), "pass"); + } + + #[test] + fn parse_outputs_no_wfe_lines() { + let stdout = "line 1\nline 2\nline 3\n"; + let outputs = ContainerdStep::parse_outputs(stdout); + assert!(outputs.is_empty()); + } + + #[test] + fn parse_outputs_value_with_equals_sign() { + let stdout = "##wfe[output url=https://example.com?a=1&b=2]\n"; + let outputs = ContainerdStep::parse_outputs(stdout); + assert_eq!(outputs.len(), 1); + assert_eq!( + outputs.get("url").unwrap(), + "https://example.com?a=1&b=2" + ); + } + + #[test] + fn parse_outputs_ignores_malformed_lines() { + let stdout = "##wfe[output ]\n\ + ##wfe[output no_equals]\n\ + ##wfe[output valid=yes]\n\ + ##wfe[output_extra bad=val]\n"; + let outputs = ContainerdStep::parse_outputs(stdout); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs.get("valid").unwrap(), "yes"); + } + + #[test] + fn parse_outputs_overwrites_duplicate_keys() { + let stdout = "##wfe[output key=first]\n##wfe[output key=second]\n"; + let outputs = ContainerdStep::parse_outputs(stdout); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs.get("key").unwrap(), "second"); + } + + // ── build_output_data ────────────────────────────────────────────── + + #[test] + fn build_output_data_basic() { + let parsed = HashMap::from([("result".to_string(), "success".to_string())]); + let data = ContainerdStep::build_output_data( + "my_step", + "hello world\n", + "", + 0, + &parsed, + ); + + let obj = data.as_object().unwrap(); + assert_eq!(obj.get("result").unwrap(), "success"); + assert_eq!(obj.get("my_step.stdout").unwrap(), "hello world\n"); + assert_eq!(obj.get("my_step.stderr").unwrap(), ""); + assert_eq!(obj.get("my_step.exit_code").unwrap(), 0); + } + + #[test] + fn build_output_data_no_parsed_outputs() { + let data = ContainerdStep::build_output_data( + "step1", + "out", + "err", + 1, + &HashMap::new(), + ); + + let obj = data.as_object().unwrap(); + assert_eq!(obj.len(), 3); // stdout, stderr, exit_code + assert_eq!(obj.get("step1.stdout").unwrap(), "out"); + assert_eq!(obj.get("step1.stderr").unwrap(), "err"); + assert_eq!(obj.get("step1.exit_code").unwrap(), 1); + } + + #[test] + fn build_output_data_with_multiple_parsed_outputs() { + let parsed = HashMap::from([ + ("a".to_string(), "1".to_string()), + ("b".to_string(), "2".to_string()), + ("c".to_string(), "3".to_string()), + ]); + let data = ContainerdStep::build_output_data("s", "", "", 0, &parsed); + + let obj = data.as_object().unwrap(); + assert_eq!(obj.get("a").unwrap(), "1"); + assert_eq!(obj.get("b").unwrap(), "2"); + assert_eq!(obj.get("c").unwrap(), "3"); + // Plus the 3 standard keys + assert_eq!(obj.len(), 6); + } + + #[test] + fn build_output_data_negative_exit_code() { + let data = ContainerdStep::build_output_data("s", "", "", -1, &HashMap::new()); + let obj = data.as_object().unwrap(); + assert_eq!(obj.get("s.exit_code").unwrap(), -1); + } + + // ── build_login_commands ─────────────────────────────────────────── + + #[test] + fn build_login_commands_empty_registry_auth() { + let config = minimal_config(); + let step = ContainerdStep::new(config); + let commands = step.build_login_commands(); + assert!(commands.is_empty()); + } + + #[test] + fn build_login_commands_single_registry() { + let mut config = minimal_config(); + config.registry_auth = HashMap::from([( + "registry.example.com".to_string(), + RegistryAuth { + username: "user".to_string(), + password: "pass".to_string(), + }, + )]); + + let step = ContainerdStep::new(config); + let commands = step.build_login_commands(); + + assert_eq!(commands.len(), 1); + let (registry, args) = &commands[0]; + assert_eq!(registry, "registry.example.com"); + assert_eq!(args[0], "nerdctl"); + assert_eq!(args[1], "--address"); + assert_eq!(args[2], "/run/containerd/containerd.sock"); + assert!(args.contains(&"login".to_string())); + assert!(args.contains(&"-u".to_string())); + assert!(args.contains(&"user".to_string())); + assert!(args.contains(&"-p".to_string())); + assert!(args.contains(&"pass".to_string())); + assert!(args.contains(&"registry.example.com".to_string())); + } + + #[test] + fn build_login_commands_multiple_registries() { + let mut config = minimal_config(); + config.registry_auth = HashMap::from([ + ( + "reg1.io".to_string(), + RegistryAuth { + username: "u1".to_string(), + password: "p1".to_string(), + }, + ), + ( + "reg2.io".to_string(), + RegistryAuth { + username: "u2".to_string(), + password: "p2".to_string(), + }, + ), + ]); + + let step = ContainerdStep::new(config); + let commands = step.build_login_commands(); + + assert_eq!(commands.len(), 2); + let registries: Vec<&String> = commands.iter().map(|(r, _)| r).collect(); + assert!(registries.contains(&&"reg1.io".to_string())); + assert!(registries.contains(&&"reg2.io".to_string())); + } + + #[test] + fn build_login_commands_with_tls() { + let mut config = minimal_config(); + config.tls = TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: Some("/cert.pem".to_string()), + key: None, + }; + config.registry_auth = HashMap::from([( + "secure.io".to_string(), + RegistryAuth { + username: "admin".to_string(), + password: "secret".to_string(), + }, + )]); + + let step = ContainerdStep::new(config); + let commands = step.build_login_commands(); + + assert_eq!(commands.len(), 1); + let (_, args) = &commands[0]; + assert!(args.contains(&"--tlscacert".to_string())); + assert!(args.contains(&"--tlscert".to_string())); + assert!(!args.contains(&"--tlskey".to_string())); + } + + // ── ContainerdStep::new ──────────────────────────────────────────── + + #[test] + fn new_creates_step_with_config() { + let config = minimal_config(); + let step = ContainerdStep::new(config.clone()); + // Verify it stored the config by checking a generated command + let args = step.build_run_command(); + assert!(args.contains(&config.image)); + } +} diff --git a/wfe-containerd/tests/integration.rs b/wfe-containerd/tests/integration.rs new file mode 100644 index 0000000..2e75203 --- /dev/null +++ b/wfe-containerd/tests/integration.rs @@ -0,0 +1,532 @@ +use std::collections::HashMap; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; + +use tempfile::TempDir; +use wfe_containerd::config::{ContainerdConfig, RegistryAuth, TlsConfig}; +use wfe_containerd::ContainerdStep; +use wfe_core::models::{ExecutionPointer, WorkflowInstance, WorkflowStep}; +use wfe_core::traits::step::{StepBody, StepExecutionContext}; + +fn minimal_config() -> ContainerdConfig { + ContainerdConfig { + image: "alpine:3.18".to_string(), + command: None, + run: Some("echo hello".to_string()), + env: HashMap::new(), + volumes: vec![], + working_dir: None, + user: "65534:65534".to_string(), + network: "none".to_string(), + memory: None, + cpu: None, + pull: "never".to_string(), + containerd_addr: "/run/containerd/containerd.sock".to_string(), + cli: "nerdctl".to_string(), + tls: TlsConfig::default(), + registry_auth: HashMap::new(), + timeout_ms: None, + } +} + +/// Create a fake nerdctl script in a temp dir. +fn create_fake_nerdctl(dir: &TempDir, script: &str) -> String { + let nerdctl_path = dir.path().join("nerdctl"); + let mut file = std::fs::File::create(&nerdctl_path).unwrap(); + file.write_all(script.as_bytes()).unwrap(); + std::fs::set_permissions(&nerdctl_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + dir.path().to_string_lossy().to_string() +} + +fn make_context<'a>( + step: &'a WorkflowStep, + workflow: &'a WorkflowInstance, + pointer: &'a ExecutionPointer, +) -> StepExecutionContext<'a> { + StepExecutionContext { + item: None, + execution_pointer: pointer, + persistence_data: None, + step, + workflow, + cancellation_token: tokio_util::sync::CancellationToken::new(), + } +} + +/// Wrapper for set_var that handles the Rust 2024 unsafe requirement. +fn set_path(value: &str) { + // SAFETY: These tests run sequentially (nextest runs each test in its own process) + // so concurrent mutation of environment variables is not a concern. + unsafe { + std::env::set_var("PATH", value); + } +} + +// ── Happy-path: run succeeds with output parsing ─────────────────── + +#[tokio::test] +async fn run_success_with_outputs() { + let tmp = TempDir::new().unwrap(); + let wfe_marker = "##wfe[output"; + let script = format!( + "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) echo 'hello from container'\n\ + echo '{wfe_marker} result=success]'\n\ + echo '{wfe_marker} version=1.0.0]'\n\ + exit 0;;\n\ + pull) echo 'Pulling...'; exit 0;;\n\ + login) exit 0;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n" + ); + let bin_dir = create_fake_nerdctl(&tmp, &script); + + let mut config = minimal_config(); + config.pull = "always".to_string(); + let mut step = ContainerdStep::new(config); + + let mut named_step = WorkflowStep::new(0, "containerd"); + named_step.name = Some("my_container".to_string()); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&named_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let result = result.expect("run should succeed"); + assert!(result.proceed); + let output = result.output_data.unwrap(); + let obj = output.as_object().unwrap(); + assert_eq!(obj.get("result").unwrap(), "success"); + assert_eq!(obj.get("version").unwrap(), "1.0.0"); + assert!( + obj.get("my_container.stdout") + .unwrap() + .as_str() + .unwrap() + .contains("hello from container") + ); + assert_eq!(obj.get("my_container.exit_code").unwrap(), 0); +} + +// ── Non-zero exit code ───────────────────────────────────────────── + +#[tokio::test] +async fn run_container_failure() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) echo 'something went wrong' >&2; exit 1;;\n\ + pull) exit 0;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let config = minimal_config(); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let err = result.expect_err("should fail with non-zero exit"); + let msg = format!("{err}"); + assert!(msg.contains("Container exited with code 1"), "got: {msg}"); + assert!(msg.contains("something went wrong"), "got: {msg}"); +} + +// ── Pull failure ─────────────────────────────────────────────────── + +#[tokio::test] +async fn run_pull_failure() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + pull) echo 'image not found' >&2; exit 1;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let mut config = minimal_config(); + config.pull = "always".to_string(); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let err = result.expect_err("should fail on pull"); + let msg = format!("{err}"); + assert!(msg.contains("Image pull failed"), "got: {msg}"); +} + +// ── Timeout ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn run_timeout() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) sleep 30; exit 0;;\n\ + pull) exit 0;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let mut config = minimal_config(); + config.timeout_ms = Some(100); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let err = result.expect_err("should timeout"); + let msg = format!("{err}"); + assert!(msg.contains("timed out"), "got: {msg}"); +} + +// ── Missing nerdctl binary ───────────────────────────────────────── + +#[tokio::test] +async fn run_missing_nerdctl() { + let tmp = TempDir::new().unwrap(); + let bin_dir = tmp.path().to_string_lossy().to_string(); + + let config = minimal_config(); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&bin_dir); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let err = result.expect_err("should fail with missing binary"); + let msg = format!("{err}"); + assert!( + msg.contains("Failed to spawn nerdctl run") || msg.contains("Failed to pull image") + || msg.contains("Failed to spawn docker run"), + "got: {msg}" + ); +} + +// ── pull=never skips pull ────────────────────────────────────────── + +#[tokio::test] +async fn run_skip_pull_when_never() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) echo ran; exit 0;;\n\ + pull) echo 'pull should not be called' >&2; exit 1;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let mut config = minimal_config(); + config.pull = "never".to_string(); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + result.expect("should succeed without pulling"); +} + +// ── Workflow data is injected as env vars ────────────────────────── + +#[tokio::test] +async fn run_injects_workflow_data_as_env() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) shift\n\ + for arg in \"$@\"; do echo \"ARG:$arg\"; done\n\ + exit 0;;\n\ + pull) exit 0;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let mut config = minimal_config(); + config.pull = "never".to_string(); + config.run = None; + config.command = Some(vec!["true".to_string()]); + let mut step = ContainerdStep::new(config); + + let mut wf_step = WorkflowStep::new(0, "containerd"); + wf_step.name = Some("env_test".to_string()); + let workflow = WorkflowInstance::new( + "test-wf", + 1, + serde_json::json!({"my_key": "my_value", "count": 42}), + ); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let result = result.expect("should succeed"); + let stdout = result + .output_data + .unwrap() + .as_object() + .unwrap() + .get("env_test.stdout") + .unwrap() + .as_str() + .unwrap() + .to_string(); + + // The workflow data should have been injected as uppercase env vars + assert!(stdout.contains("MY_KEY=my_value"), "stdout: {stdout}"); + assert!(stdout.contains("COUNT=42"), "stdout: {stdout}"); +} + +// ── Step name defaults to "unknown" when None ────────────────────── + +#[tokio::test] +async fn run_unnamed_step_uses_unknown() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) echo output; exit 0;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let config = minimal_config(); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let result = result.expect("should succeed"); + let output = result.output_data.unwrap(); + let obj = output.as_object().unwrap(); + assert!(obj.contains_key("unknown.stdout")); + assert!(obj.contains_key("unknown.stderr")); + assert!(obj.contains_key("unknown.exit_code")); +} + +// ── Registry login failure ───────────────────────────────────────── + +#[tokio::test] +async fn run_login_failure() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + login) echo unauthorized >&2; exit 1;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 0\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let mut config = minimal_config(); + config.registry_auth = HashMap::from([( + "registry.example.com".to_string(), + RegistryAuth { + username: "user".to_string(), + password: "wrong".to_string(), + }, + )]); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + let err = result.expect_err("should fail on login"); + let msg = format!("{err}"); + assert!(msg.contains("login failed"), "got: {msg}"); +} + +// ── Successful login with TLS then run ───────────────────────────── + +#[tokio::test] +async fn run_login_success_with_tls() { + let tmp = TempDir::new().unwrap(); + let script = "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) echo ok; exit 0;;\n\ + pull) exit 0;;\n\ + login) exit 0;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n"; + let bin_dir = create_fake_nerdctl(&tmp, script); + + let mut config = minimal_config(); + config.pull = "never".to_string(); + config.tls = TlsConfig { + ca: Some("/ca.pem".to_string()), + cert: Some("/cert.pem".to_string()), + key: Some("/key.pem".to_string()), + }; + config.registry_auth = HashMap::from([( + "secure.io".to_string(), + RegistryAuth { + username: "admin".to_string(), + password: "secret".to_string(), + }, + )]); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + result.expect("should succeed with login + TLS"); +} + +// ── pull=if-not-present triggers pull ────────────────────────────── + +#[tokio::test] +async fn run_pull_if_not_present() { + let tmp = TempDir::new().unwrap(); + // Track that pull was called via a marker file + let marker = tmp.path().join("pull_called"); + let marker_str = marker.to_string_lossy(); + let script = format!( + "#!/bin/sh\n\ + while [ $# -gt 0 ]; do\n\ + case \"$1\" in\n\ + run) echo ran; exit 0;;\n\ + pull) touch {marker_str}; exit 0;;\n\ + --*) shift; shift;;\n\ + *) shift;;\n\ + esac\n\ + done\n\ + exit 1\n" + ); + let bin_dir = create_fake_nerdctl(&tmp, &script); + + let mut config = minimal_config(); + config.pull = "if-not-present".to_string(); + let mut step = ContainerdStep::new(config); + + let wf_step = WorkflowStep::new(0, "containerd"); + let workflow = WorkflowInstance::new("test-wf", 1, serde_json::json!({})); + let pointer = ExecutionPointer::new(0); + let ctx = make_context(&wf_step, &workflow, &pointer); + + let original_path = std::env::var("PATH").unwrap_or_default(); + set_path(&format!("{bin_dir}:{original_path}")); + + let result = step.run(&ctx).await; + + set_path(&original_path); + + result.expect("should succeed"); + assert!(marker.exists(), "pull should have been called for if-not-present"); +} diff --git a/wfe-yaml/Cargo.toml b/wfe-yaml/Cargo.toml index eaa5a8f..56f8a25 100644 --- a/wfe-yaml/Cargo.toml +++ b/wfe-yaml/Cargo.toml @@ -7,6 +7,8 @@ description = "YAML workflow definitions for WFE" [features] default = [] deno = ["deno_core", "deno_error", "url", "reqwest"] +buildkit = ["wfe-buildkit"] +containerd = ["wfe-containerd"] [dependencies] wfe-core = { workspace = true } @@ -22,6 +24,8 @@ deno_core = { workspace = true, optional = true } deno_error = { workspace = true, optional = true } url = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } +wfe-buildkit = { workspace = true, optional = true } +wfe-containerd = { workspace = true, optional = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/wfe-yaml/src/compiler.rs b/wfe-yaml/src/compiler.rs index 139da7f..35467e0 100644 --- a/wfe-yaml/src/compiler.rs +++ b/wfe-yaml/src/compiler.rs @@ -8,6 +8,10 @@ use crate::error::YamlWorkflowError; use crate::executors::shell::{ShellConfig, ShellStep}; #[cfg(feature = "deno")] use crate::executors::deno::{DenoConfig, DenoPermissions, DenoStep}; +#[cfg(feature = "buildkit")] +use wfe_buildkit::{BuildkitConfig, BuildkitStep}; +#[cfg(feature = "containerd")] +use wfe_containerd::{ContainerdConfig, ContainerdStep}; use crate::schema::{WorkflowSpec, YamlErrorBehavior, YamlStep}; /// Factory type alias for step creation closures. @@ -250,6 +254,36 @@ fn build_step_config_and_factory( }); Ok((key, value, factory)) } + #[cfg(feature = "buildkit")] + "buildkit" => { + let config = build_buildkit_config(step)?; + let key = format!("wfe_yaml::buildkit::{}", step.name); + let value = serde_json::to_value(&config).map_err(|e| { + YamlWorkflowError::Compilation(format!( + "Failed to serialize buildkit config: {e}" + )) + })?; + let config_clone = config.clone(); + let factory: StepFactory = Box::new(move || { + Box::new(BuildkitStep::new(config_clone.clone())) as Box + }); + Ok((key, value, factory)) + } + #[cfg(feature = "containerd")] + "containerd" => { + let config = build_containerd_config(step)?; + let key = format!("wfe_yaml::containerd::{}", step.name); + let value = serde_json::to_value(&config).map_err(|e| { + YamlWorkflowError::Compilation(format!( + "Failed to serialize containerd config: {e}" + )) + })?; + let config_clone = config.clone(); + let factory: StepFactory = Box::new(move || { + Box::new(ContainerdStep::new(config_clone.clone())) as Box + }); + Ok((key, value, factory)) + } other => Err(YamlWorkflowError::Compilation(format!( "Unknown step type: '{other}'" ))), @@ -346,6 +380,162 @@ fn parse_duration_ms(s: &str) -> Option { } } +#[cfg(feature = "buildkit")] +fn build_buildkit_config( + step: &YamlStep, +) -> Result { + let config = step.config.as_ref().ok_or_else(|| { + YamlWorkflowError::Compilation(format!( + "BuildKit step '{}' is missing 'config' section", + step.name + )) + })?; + + let dockerfile = config.dockerfile.clone().ok_or_else(|| { + YamlWorkflowError::Compilation(format!( + "BuildKit step '{}' must have 'config.dockerfile'", + step.name + )) + })?; + + let context = config.context.clone().ok_or_else(|| { + YamlWorkflowError::Compilation(format!( + "BuildKit step '{}' must have 'config.context'", + step.name + )) + })?; + + let timeout_ms = config.timeout.as_ref().and_then(|t| parse_duration_ms(t)); + + let tls = config + .tls + .as_ref() + .map(|t| wfe_buildkit::TlsConfig { + ca: t.ca.clone(), + cert: t.cert.clone(), + key: t.key.clone(), + }) + .unwrap_or_default(); + + let registry_auth = config + .registry_auth + .as_ref() + .map(|ra| { + ra.iter() + .map(|(k, v)| { + ( + k.clone(), + wfe_buildkit::RegistryAuth { + username: v.username.clone(), + password: v.password.clone(), + }, + ) + }) + .collect() + }) + .unwrap_or_default(); + + Ok(BuildkitConfig { + dockerfile, + context, + target: config.target.clone(), + tags: config.tags.clone(), + build_args: config.build_args.clone(), + cache_from: config.cache_from.clone(), + cache_to: config.cache_to.clone(), + push: config.push.unwrap_or(false), + output_type: None, + buildkit_addr: config + .buildkit_addr + .clone() + .unwrap_or_else(|| "unix:///run/buildkit/buildkitd.sock".to_string()), + tls, + registry_auth, + timeout_ms, + }) +} + +#[cfg(feature = "containerd")] +fn build_containerd_config( + step: &YamlStep, +) -> Result { + let config = step.config.as_ref().ok_or_else(|| { + YamlWorkflowError::Compilation(format!( + "Containerd step '{}' is missing 'config' section", + step.name + )) + })?; + + let image = config.image.clone().ok_or_else(|| { + YamlWorkflowError::Compilation(format!( + "Containerd step '{}' must have 'config.image'", + step.name + )) + })?; + + let timeout_ms = config.timeout.as_ref().and_then(|t| parse_duration_ms(t)); + + let tls = config + .tls + .as_ref() + .map(|t| wfe_containerd::TlsConfig { + ca: t.ca.clone(), + cert: t.cert.clone(), + key: t.key.clone(), + }) + .unwrap_or_default(); + + let registry_auth = config + .registry_auth + .as_ref() + .map(|ra| { + ra.iter() + .map(|(k, v)| { + ( + k.clone(), + wfe_containerd::RegistryAuth { + username: v.username.clone(), + password: v.password.clone(), + }, + ) + }) + .collect() + }) + .unwrap_or_default(); + + let volumes = config + .volumes + .iter() + .map(|v| wfe_containerd::VolumeMountConfig { + source: v.source.clone(), + target: v.target.clone(), + readonly: v.readonly, + }) + .collect(); + + Ok(ContainerdConfig { + image, + command: config.command.clone(), + run: config.run.clone(), + env: config.env.clone(), + volumes, + working_dir: config.working_dir.clone(), + user: config.user.clone().unwrap_or_else(|| "65534:65534".to_string()), + network: config.network.clone().unwrap_or_else(|| "none".to_string()), + memory: config.memory.clone(), + cpu: config.cpu.clone(), + pull: config.pull.clone().unwrap_or_else(|| "if-not-present".to_string()), + containerd_addr: config + .containerd_addr + .clone() + .unwrap_or_else(|| "/run/containerd/containerd.sock".to_string()), + cli: config.cli.clone().unwrap_or_else(|| "nerdctl".to_string()), + tls, + registry_auth, + timeout_ms, + }) +} + fn map_error_behavior(eb: &YamlErrorBehavior) -> Result { match eb.behavior_type.as_str() { "retry" => { diff --git a/wfe-yaml/src/schema.rs b/wfe-yaml/src/schema.rs index 6da8a4d..db3835e 100644 --- a/wfe-yaml/src/schema.rs +++ b/wfe-yaml/src/schema.rs @@ -58,6 +58,38 @@ pub struct StepConfig { pub permissions: Option, #[serde(default)] pub modules: Vec, + // BuildKit fields + pub dockerfile: Option, + pub context: Option, + pub target: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub build_args: HashMap, + #[serde(default)] + pub cache_from: Vec, + #[serde(default)] + pub cache_to: Vec, + pub push: Option, + pub buildkit_addr: Option, + #[serde(default)] + pub tls: Option, + #[serde(default)] + pub registry_auth: Option>, + // Containerd fields + pub image: Option, + #[serde(default)] + pub command: Option>, + #[serde(default)] + pub volumes: Vec, + pub user: Option, + pub network: Option, + pub memory: Option, + pub cpu: Option, + pub pull: Option, + pub containerd_addr: Option, + /// CLI binary name for containerd steps: "nerdctl" (default) or "docker". + pub cli: Option, } /// YAML-level permission configuration for Deno steps. @@ -84,6 +116,30 @@ pub struct DataRef { pub json_path: Option, } +/// YAML-level TLS configuration for BuildKit steps. +#[derive(Debug, Deserialize, Clone)] +pub struct TlsConfigYaml { + pub ca: Option, + pub cert: Option, + pub key: Option, +} + +/// YAML-level registry auth configuration for BuildKit steps. +#[derive(Debug, Deserialize, Clone)] +pub struct RegistryAuthYaml { + pub username: String, + pub password: String, +} + +/// YAML-level volume mount configuration for containerd steps. +#[derive(Debug, Deserialize, Clone)] +pub struct VolumeMountYaml { + pub source: String, + pub target: String, + #[serde(default)] + pub readonly: bool, +} + #[derive(Debug, Deserialize)] pub struct YamlErrorBehavior { #[serde(rename = "type")] diff --git a/wfe-yaml/src/validation.rs b/wfe-yaml/src/validation.rs index 4437ff2..6c21453 100644 --- a/wfe-yaml/src/validation.rs +++ b/wfe-yaml/src/validation.rs @@ -89,6 +89,90 @@ fn validate_steps( } } + // BuildKit steps must have config with dockerfile and context. + if let Some(ref step_type) = step.step_type + && step_type == "buildkit" + { + let config = step.config.as_ref().ok_or_else(|| { + YamlWorkflowError::Validation(format!( + "BuildKit step '{}' must have a 'config' section", + step.name + )) + })?; + if config.dockerfile.is_none() { + return Err(YamlWorkflowError::Validation(format!( + "BuildKit step '{}' must have 'config.dockerfile'", + step.name + ))); + } + if config.context.is_none() { + return Err(YamlWorkflowError::Validation(format!( + "BuildKit step '{}' must have 'config.context'", + step.name + ))); + } + if config.push.unwrap_or(false) && config.tags.is_empty() { + return Err(YamlWorkflowError::Validation(format!( + "BuildKit step '{}' has push=true but no tags specified", + step.name + ))); + } + } + + // Containerd steps must have config with image and exactly one of run or command. + if let Some(ref step_type) = step.step_type + && step_type == "containerd" + { + let config = step.config.as_ref().ok_or_else(|| { + YamlWorkflowError::Validation(format!( + "Containerd step '{}' must have a 'config' section", + step.name + )) + })?; + if config.image.is_none() { + return Err(YamlWorkflowError::Validation(format!( + "Containerd step '{}' must have 'config.image'", + step.name + ))); + } + let has_run = config.run.is_some(); + let has_command = config.command.is_some(); + if !has_run && !has_command { + return Err(YamlWorkflowError::Validation(format!( + "Containerd step '{}' must have 'config.run' or 'config.command'", + step.name + ))); + } + if has_run && has_command { + return Err(YamlWorkflowError::Validation(format!( + "Containerd step '{}' cannot have both 'config.run' and 'config.command'", + step.name + ))); + } + if let Some(ref network) = config.network { + match network.as_str() { + "none" | "host" | "bridge" => {} + other => { + return Err(YamlWorkflowError::Validation(format!( + "Containerd step '{}' has invalid network '{}'. Must be none, host, or bridge", + step.name, other + ))); + } + } + } + if let Some(ref pull) = config.pull { + match pull.as_str() { + "always" | "if-not-present" | "never" => {} + other => { + return Err(YamlWorkflowError::Validation(format!( + "Containerd step '{}' has invalid pull policy '{}'. Must be always, if-not-present, or never", + step.name, other + ))); + } + } + } + } + // Validate step-level error behavior. if let Some(ref eb) = step.error_behavior { validate_error_behavior_type(&eb.behavior_type)?;