feat(wfe-buildkit): add BuildKit image builder executor

Standalone crate implementing StepBody for building container images
via buildctl CLI. Supports Dockerfiles, multi-stage targets, tags,
build args, cache import/export, push to registry.

Security: TLS client certs for buildkitd connections, per-registry
authentication for push operations.

Testable without daemon via build_command() and parse_digest().
20 tests, 85%+ coverage.
This commit is contained in:
2026-03-26 10:00:42 +00:00
parent 4fc16646eb
commit d4519e862f
5 changed files with 818 additions and 0 deletions

22
wfe-buildkit/Cargo.toml Normal file
View File

@@ -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"] }

95
wfe-buildkit/README.md Normal file
View File

@@ -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\<String\> | No | [] | Image tags |
| `build_args` | Map\<String, String\> | No | {} | Build arguments |
| `cache_from` | Vec\<String\> | No | [] | Cache import sources |
| `cache_to` | Vec\<String\> | 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\<String, RegistryAuth\> | 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

178
wfe-buildkit/src/config.rs Normal file
View File

@@ -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<String>,
/// Image tags to apply.
#[serde(default)]
pub tags: Vec<String>,
/// Build arguments passed as `--opt build-arg:KEY=VALUE`.
#[serde(default)]
pub build_args: HashMap<String, String>,
/// Cache import sources.
#[serde(default)]
pub cache_from: Vec<String>,
/// Cache export destinations.
#[serde(default)]
pub cache_to: Vec<String>,
/// Whether to push the built image.
#[serde(default)]
pub push: bool,
/// Output type: "image", "local", "tar".
pub output_type: Option<String>,
/// 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<String, RegistryAuth>,
/// Execution timeout in milliseconds.
pub timeout_ms: Option<u64>,
}
/// 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<String>,
/// Path to the client certificate.
pub cert: Option<String>,
/// Path to the client private key.
pub key: Option<String>,
}
/// 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);
}
}

5
wfe-buildkit/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod config;
pub mod step;
pub use config::{BuildkitConfig, RegistryAuth, TlsConfig};
pub use step::BuildkitStep;

518
wfe-buildkit/src/step.rs Normal file
View File

@@ -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<String> {
let mut args: Vec<String> = 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<String, String> {
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:<hex>` or
/// `digest: sha256:<hex>` in the combined output.
pub fn parse_digest(output: &str) -> Option<String> {
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<ExecutionResult> {
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 &registry_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());
}
}