diff --git a/wfe-buildkit/Cargo.toml b/wfe-buildkit/Cargo.toml new file mode 100644 index 0000000..68e9384 --- /dev/null +++ b/wfe-buildkit/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wfe-buildkit" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "BuildKit image builder 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 } +regex = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } diff --git a/wfe-buildkit/README.md b/wfe-buildkit/README.md new file mode 100644 index 0000000..40bb295 --- /dev/null +++ b/wfe-buildkit/README.md @@ -0,0 +1,95 @@ +# wfe-buildkit + +BuildKit image builder executor for WFE. + +## What it does + +`wfe-buildkit` provides a `BuildkitStep` that implements the `StepBody` trait from `wfe-core`. It shells out to the `buildctl` CLI to build container images using BuildKit, capturing stdout/stderr and parsing image digests from the output. + +## Quick start + +Use it standalone: + +```rust +use wfe_buildkit::{BuildkitConfig, BuildkitStep}; + +let config = BuildkitConfig { + dockerfile: "Dockerfile".to_string(), + context: ".".to_string(), + tags: vec!["myapp:latest".to_string()], + push: true, + ..Default::default() +}; + +let step = BuildkitStep::new(config); + +// Inspect the command that would be executed. +let args = step.build_command(); +println!("{}", args.join(" ")); +``` + +Or use it through `wfe-yaml` with the `buildkit` feature: + +```yaml +workflow: + id: build-image + version: 1 + steps: + - name: build + type: buildkit + config: + dockerfile: Dockerfile + context: . + tags: + - myapp:latest + - myapp:v1.0 + push: true + build_args: + RUST_VERSION: "1.78" + cache_from: + - type=registry,ref=myapp:cache + cache_to: + - type=registry,ref=myapp:cache,mode=max + timeout: 10m +``` + +## Configuration + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `dockerfile` | String | Yes | - | Path to the Dockerfile | +| `context` | String | Yes | - | Build context directory | +| `target` | String | No | - | Multi-stage build target | +| `tags` | Vec\ | No | [] | Image tags | +| `build_args` | Map\ | No | {} | Build arguments | +| `cache_from` | Vec\ | No | [] | Cache import sources | +| `cache_to` | Vec\ | No | [] | Cache export destinations | +| `push` | bool | No | false | Push image after build | +| `output_type` | String | No | "image" | Output type: image, local, tar | +| `buildkit_addr` | String | No | unix:///run/buildkit/buildkitd.sock | BuildKit daemon address | +| `tls` | TlsConfig | No | - | TLS certificate paths | +| `registry_auth` | Map\ | No | {} | Registry credentials | +| `timeout_ms` | u64 | No | - | Execution timeout in milliseconds | + +## Output data + +After execution, the step writes the following keys into `output_data`: + +| Key | Description | +|---|---| +| `{step_name}.digest` | Image digest (sha256:...), if found in output | +| `{step_name}.tags` | Array of tags applied to the image | +| `{step_name}.stdout` | Full stdout from buildctl | +| `{step_name}.stderr` | Full stderr from buildctl | + +## Testing + +```sh +cargo test -p wfe-buildkit +``` + +The `build_command()` method returns the full argument list without executing, making it possible to test command construction without a running BuildKit daemon. + +## License + +MIT diff --git a/wfe-buildkit/src/config.rs b/wfe-buildkit/src/config.rs new file mode 100644 index 0000000..c4c7df0 --- /dev/null +++ b/wfe-buildkit/src/config.rs @@ -0,0 +1,178 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Configuration for a BuildKit image build step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildkitConfig { + /// Path to the Dockerfile (or directory containing it). + pub dockerfile: String, + /// Build context directory. + pub context: String, + /// Multi-stage build target. + pub target: Option, + /// Image tags to apply. + #[serde(default)] + pub tags: Vec, + /// Build arguments passed as `--opt build-arg:KEY=VALUE`. + #[serde(default)] + pub build_args: HashMap, + /// Cache import sources. + #[serde(default)] + pub cache_from: Vec, + /// Cache export destinations. + #[serde(default)] + pub cache_to: Vec, + /// Whether to push the built image. + #[serde(default)] + pub push: bool, + /// Output type: "image", "local", "tar". + pub output_type: Option, + /// BuildKit daemon address. + #[serde(default = "default_buildkit_addr")] + pub buildkit_addr: String, + /// TLS configuration for the BuildKit connection. + #[serde(default)] + pub tls: TlsConfig, + /// Registry authentication credentials keyed by registry host. + #[serde(default)] + pub registry_auth: HashMap, + /// Execution timeout in milliseconds. + pub timeout_ms: Option, +} + +/// TLS certificate paths for securing the BuildKit connection. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TlsConfig { + /// Path to the CA certificate. + pub ca: Option, + /// Path to the client certificate. + pub cert: Option, + /// Path to the client private key. + pub key: Option, +} + +/// Credentials for authenticating with a container registry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryAuth { + pub username: String, + pub password: String, +} + +fn default_buildkit_addr() -> String { + "unix:///run/buildkit/buildkitd.sock".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn serde_round_trip_full_config() { + let mut build_args = HashMap::new(); + build_args.insert("RUST_VERSION".to_string(), "1.78".to_string()); + + let mut registry_auth = HashMap::new(); + registry_auth.insert( + "ghcr.io".to_string(), + RegistryAuth { + username: "user".to_string(), + password: "pass".to_string(), + }, + ); + + let config = BuildkitConfig { + dockerfile: "./Dockerfile".to_string(), + context: ".".to_string(), + target: Some("runtime".to_string()), + tags: vec!["myapp:latest".to_string(), "myapp:v1.0".to_string()], + build_args, + cache_from: vec!["type=registry,ref=myapp:cache".to_string()], + cache_to: vec!["type=registry,ref=myapp:cache,mode=max".to_string()], + push: true, + output_type: Some("image".to_string()), + buildkit_addr: "tcp://buildkitd:1234".to_string(), + tls: TlsConfig { + ca: Some("/certs/ca.pem".to_string()), + cert: Some("/certs/cert.pem".to_string()), + key: Some("/certs/key.pem".to_string()), + }, + registry_auth, + timeout_ms: Some(300_000), + }; + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: BuildkitConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(config.dockerfile, deserialized.dockerfile); + assert_eq!(config.context, deserialized.context); + assert_eq!(config.target, deserialized.target); + assert_eq!(config.tags, deserialized.tags); + assert_eq!(config.build_args, deserialized.build_args); + assert_eq!(config.cache_from, deserialized.cache_from); + assert_eq!(config.cache_to, deserialized.cache_to); + assert_eq!(config.push, deserialized.push); + assert_eq!(config.output_type, deserialized.output_type); + assert_eq!(config.buildkit_addr, deserialized.buildkit_addr); + assert_eq!(config.tls.ca, deserialized.tls.ca); + assert_eq!(config.tls.cert, deserialized.tls.cert); + assert_eq!(config.tls.key, deserialized.tls.key); + assert_eq!(config.timeout_ms, deserialized.timeout_ms); + } + + #[test] + fn serde_round_trip_minimal_config() { + let json = r#"{ + "dockerfile": "Dockerfile", + "context": "." + }"#; + let config: BuildkitConfig = serde_json::from_str(json).unwrap(); + + assert_eq!(config.dockerfile, "Dockerfile"); + assert_eq!(config.context, "."); + assert_eq!(config.target, None); + assert!(config.tags.is_empty()); + assert!(config.build_args.is_empty()); + assert!(config.cache_from.is_empty()); + assert!(config.cache_to.is_empty()); + assert!(!config.push); + assert_eq!(config.output_type, None); + assert_eq!(config.buildkit_addr, "unix:///run/buildkit/buildkitd.sock"); + assert_eq!(config.timeout_ms, None); + + // Round-trip + let serialized = serde_json::to_string(&config).unwrap(); + let deserialized: BuildkitConfig = serde_json::from_str(&serialized).unwrap(); + assert_eq!(config.dockerfile, deserialized.dockerfile); + assert_eq!(config.context, deserialized.context); + } + + #[test] + fn default_buildkit_addr_value() { + let addr = default_buildkit_addr(); + assert_eq!(addr, "unix:///run/buildkit/buildkitd.sock"); + } + + #[test] + fn tls_config_defaults_to_none() { + let tls = TlsConfig::default(); + assert_eq!(tls.ca, None); + assert_eq!(tls.cert, None); + assert_eq!(tls.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!(auth.username, deserialized.username); + assert_eq!(auth.password, deserialized.password); + } +} diff --git a/wfe-buildkit/src/lib.rs b/wfe-buildkit/src/lib.rs new file mode 100644 index 0000000..fee18b7 --- /dev/null +++ b/wfe-buildkit/src/lib.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod step; + +pub use config::{BuildkitConfig, RegistryAuth, TlsConfig}; +pub use step::BuildkitStep; diff --git a/wfe-buildkit/src/step.rs b/wfe-buildkit/src/step.rs new file mode 100644 index 0000000..e8b4226 --- /dev/null +++ b/wfe-buildkit/src/step.rs @@ -0,0 +1,518 @@ +use std::collections::HashMap; +use std::path::Path; + +use async_trait::async_trait; +use regex::Regex; +use wfe_core::models::ExecutionResult; +use wfe_core::traits::step::{StepBody, StepExecutionContext}; +use wfe_core::WfeError; + +use crate::config::BuildkitConfig; + +/// A workflow step that builds container images via the `buildctl` CLI. +pub struct BuildkitStep { + config: BuildkitConfig, +} + +impl BuildkitStep { + /// Create a new BuildKit step from configuration. + pub fn new(config: BuildkitConfig) -> Self { + Self { config } + } + + /// Build the `buildctl` command arguments without executing. + /// + /// Returns the full argument list starting with "buildctl". Useful for + /// testing and debugging without a running BuildKit daemon. + pub fn build_command(&self) -> Vec { + let mut args: Vec = Vec::new(); + + args.push("buildctl".to_string()); + args.push("--addr".to_string()); + args.push(self.config.buildkit_addr.clone()); + + // TLS flags + 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()); + } + + args.push("build".to_string()); + args.push("--frontend".to_string()); + args.push("dockerfile.v0".to_string()); + + // Context directory + args.push("--local".to_string()); + args.push(format!("context={}", self.config.context)); + + // Dockerfile directory (parent of the Dockerfile path) + let dockerfile_dir = Path::new(&self.config.dockerfile) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + let dockerfile_dir = if dockerfile_dir.is_empty() { + ".".to_string() + } else { + dockerfile_dir + }; + args.push("--local".to_string()); + args.push(format!("dockerfile={dockerfile_dir}")); + + // Dockerfile filename override (if not just "Dockerfile") + let dockerfile_name = Path::new(&self.config.dockerfile) + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_else(|| "Dockerfile".to_string()); + if dockerfile_name != "Dockerfile" { + args.push("--opt".to_string()); + args.push(format!("filename={dockerfile_name}")); + } + + // Target + if let Some(ref target) = self.config.target { + args.push("--opt".to_string()); + args.push(format!("target={target}")); + } + + // Build arguments + let mut sorted_args: Vec<_> = self.config.build_args.iter().collect(); + sorted_args.sort_by_key(|(k, _)| (*k).clone()); + for (key, value) in &sorted_args { + args.push("--opt".to_string()); + args.push(format!("build-arg:{key}={value}")); + } + + // Output + let output_type = self + .config + .output_type + .as_deref() + .unwrap_or("image"); + if !self.config.tags.is_empty() { + let tag_names = self.config.tags.join(","); + args.push("--output".to_string()); + args.push(format!( + "type={output_type},name={tag_names},push={}", + self.config.push + )); + } else { + args.push("--output".to_string()); + args.push(format!("type={output_type}")); + } + + // Cache import + for cache in &self.config.cache_from { + args.push("--import-cache".to_string()); + args.push(cache.clone()); + } + + // Cache export + for cache in &self.config.cache_to { + args.push("--export-cache".to_string()); + args.push(cache.clone()); + } + + args + } + + /// Build environment variables for registry authentication. + pub fn build_registry_env(&self) -> HashMap { + let mut env = HashMap::new(); + for (host, auth) in &self.config.registry_auth { + let sanitized_host = host.replace(['.', '-'], "_").to_uppercase(); + env.insert( + format!("BUILDKIT_HOST_{sanitized_host}_USERNAME"), + auth.username.clone(), + ); + env.insert( + format!("BUILDKIT_HOST_{sanitized_host}_PASSWORD"), + auth.password.clone(), + ); + } + env + } +} + +/// Parse the image digest from buildctl output. +/// +/// Looks for patterns like `exporting manifest sha256:` or +/// `digest: sha256:` in the combined output. +pub fn parse_digest(output: &str) -> Option { + let re = Regex::new(r"(?:exporting manifest |digest: )sha256:([a-f0-9]{64})").unwrap(); + re.captures(output) + .map(|caps| format!("sha256:{}", &caps[1])) +} + +#[async_trait] +impl StepBody for BuildkitStep { + async fn run( + &mut self, + context: &StepExecutionContext<'_>, + ) -> wfe_core::Result { + let cmd_args = self.build_command(); + let registry_env = self.build_registry_env(); + + let program = &cmd_args[0]; + let args = &cmd_args[1..]; + + let mut cmd = tokio::process::Command::new(program); + cmd.args(args); + + // Set registry auth env vars. + for (key, value) in ®istry_env { + cmd.env(key, value); + } + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + // Execute with optional timeout. + 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 buildctl: {e}")) + })?, + Err(_) => { + return Err(WfeError::StepExecution(format!( + "buildctl timed out after {timeout_ms}ms" + ))); + } + } + } else { + cmd.output() + .await + .map_err(|e| WfeError::StepExecution(format!("Failed to spawn buildctl: {e}")))? + }; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + return Err(WfeError::StepExecution(format!( + "buildctl exited with code {code}\nstdout: {stdout}\nstderr: {stderr}" + ))); + } + + let step_name = context.step.name.as_deref().unwrap_or("unknown"); + + 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), + ); + + Ok(ExecutionResult { + proceed: true, + output_data: Some(serde_json::Value::Object(outputs)), + ..Default::default() + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + + use crate::config::{BuildkitConfig, RegistryAuth, TlsConfig}; + + fn minimal_config() -> BuildkitConfig { + BuildkitConfig { + dockerfile: "Dockerfile".to_string(), + context: ".".to_string(), + target: None, + tags: vec![], + build_args: HashMap::new(), + cache_from: vec![], + cache_to: vec![], + push: false, + output_type: None, + buildkit_addr: "unix:///run/buildkit/buildkitd.sock".to_string(), + tls: TlsConfig::default(), + registry_auth: HashMap::new(), + timeout_ms: None, + } + } + + #[test] + fn build_command_minimal() { + let step = BuildkitStep::new(minimal_config()); + let cmd = step.build_command(); + + assert_eq!(cmd[0], "buildctl"); + assert_eq!(cmd[1], "--addr"); + assert_eq!(cmd[2], "unix:///run/buildkit/buildkitd.sock"); + assert_eq!(cmd[3], "build"); + assert_eq!(cmd[4], "--frontend"); + assert_eq!(cmd[5], "dockerfile.v0"); + assert_eq!(cmd[6], "--local"); + assert_eq!(cmd[7], "context=."); + assert_eq!(cmd[8], "--local"); + assert_eq!(cmd[9], "dockerfile=."); + assert_eq!(cmd[10], "--output"); + assert_eq!(cmd[11], "type=image"); + } + + #[test] + fn build_command_with_target() { + let mut config = minimal_config(); + config.target = Some("runtime".to_string()); + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + let target_idx = cmd.iter().position(|a| a == "target=runtime").unwrap(); + assert_eq!(cmd[target_idx - 1], "--opt"); + } + + #[test] + fn build_command_with_tags_and_push() { + let mut config = minimal_config(); + config.tags = vec!["myapp:latest".to_string(), "myapp:v1.0".to_string()]; + config.push = true; + + 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,myapp:v1.0,push=true" + ); + } + + #[test] + fn build_command_with_build_args() { + let mut config = minimal_config(); + config + .build_args + .insert("RUST_VERSION".to_string(), "1.78".to_string()); + config + .build_args + .insert("BUILD_MODE".to_string(), "release".to_string()); + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + // Build args are sorted by key. + let first_arg_idx = cmd + .iter() + .position(|a| a == "build-arg:BUILD_MODE=release") + .unwrap(); + assert_eq!(cmd[first_arg_idx - 1], "--opt"); + + let second_arg_idx = cmd + .iter() + .position(|a| a == "build-arg:RUST_VERSION=1.78") + .unwrap(); + assert_eq!(cmd[second_arg_idx - 1], "--opt"); + assert!(first_arg_idx < second_arg_idx); + } + + #[test] + fn build_command_with_cache() { + let mut config = minimal_config(); + config.cache_from = vec!["type=registry,ref=myapp:cache".to_string()]; + config.cache_to = vec!["type=registry,ref=myapp:cache,mode=max".to_string()]; + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + let import_idx = cmd.iter().position(|a| a == "--import-cache").unwrap(); + assert_eq!(cmd[import_idx + 1], "type=registry,ref=myapp:cache"); + + let export_idx = cmd.iter().position(|a| a == "--export-cache").unwrap(); + assert_eq!( + cmd[export_idx + 1], + "type=registry,ref=myapp:cache,mode=max" + ); + } + + #[test] + fn build_command_with_tls() { + let mut config = minimal_config(); + config.tls = TlsConfig { + ca: Some("/certs/ca.pem".to_string()), + cert: Some("/certs/cert.pem".to_string()), + key: Some("/certs/key.pem".to_string()), + }; + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + let ca_idx = cmd.iter().position(|a| a == "--tlscacert").unwrap(); + assert_eq!(cmd[ca_idx + 1], "/certs/ca.pem"); + + let cert_idx = cmd.iter().position(|a| a == "--tlscert").unwrap(); + assert_eq!(cmd[cert_idx + 1], "/certs/cert.pem"); + + let key_idx = cmd.iter().position(|a| a == "--tlskey").unwrap(); + assert_eq!(cmd[key_idx + 1], "/certs/key.pem"); + + // TLS flags should come before "build" subcommand + let build_idx = cmd.iter().position(|a| a == "build").unwrap(); + assert!(ca_idx < build_idx); + assert!(cert_idx < build_idx); + assert!(key_idx < build_idx); + } + + #[test] + fn build_command_with_registry_auth() { + let mut config = minimal_config(); + config.registry_auth.insert( + "ghcr.io".to_string(), + RegistryAuth { + username: "user".to_string(), + password: "token".to_string(), + }, + ); + + let step = BuildkitStep::new(config); + let env = step.build_registry_env(); + + assert_eq!( + env.get("BUILDKIT_HOST_GHCR_IO_USERNAME"), + Some(&"user".to_string()) + ); + assert_eq!( + env.get("BUILDKIT_HOST_GHCR_IO_PASSWORD"), + Some(&"token".to_string()) + ); + } + + #[test] + fn build_command_with_custom_dockerfile_path() { + let mut config = minimal_config(); + config.dockerfile = "docker/Dockerfile.prod".to_string(); + + let step = BuildkitStep::new(config); + let cmd = step.build_command(); + + // Dockerfile directory should be "docker" + let df_idx = cmd.iter().position(|a| a == "dockerfile=docker").unwrap(); + assert_eq!(cmd[df_idx - 1], "--local"); + + // Non-default filename should be set + let filename_idx = cmd + .iter() + .position(|a| a == "filename=Dockerfile.prod") + .unwrap(); + assert_eq!(cmd[filename_idx - 1], "--opt"); + } + + #[test] + fn build_command_with_output_type_local() { + let mut config = minimal_config(); + config.output_type = Some("local".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=local"); + } + + #[test] + fn parse_digest_from_output() { + let output = "some build output\nexporting manifest sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\ndone"; + let digest = parse_digest(output); + assert_eq!( + digest, + Some( + "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + .to_string() + ) + ); + } + + #[test] + fn parse_digest_with_digest_prefix() { + let output = "digest: sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef\n"; + let digest = parse_digest(output); + assert_eq!( + digest, + Some( + "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + .to_string() + ) + ); + } + + #[test] + fn parse_digest_missing_returns_none() { + let output = "building image...\nall done!"; + let digest = parse_digest(output); + assert_eq!(digest, None); + } + + #[test] + fn parse_digest_partial_hash_returns_none() { + let output = "exporting manifest sha256:abcdef"; + let digest = parse_digest(output); + assert_eq!(digest, None); + } + + #[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()); + } +}