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:
22
wfe-buildkit/Cargo.toml
Normal file
22
wfe-buildkit/Cargo.toml
Normal 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
95
wfe-buildkit/README.md
Normal 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
178
wfe-buildkit/src/config.rs
Normal 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
5
wfe-buildkit/src/lib.rs
Normal 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
518
wfe-buildkit/src/step.rs
Normal 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 ®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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user