feat(wfe-buildkit): rewrite to use buildkit-client gRPC instead of CLI

Replaced buildctl CLI shell-out with direct gRPC communication via
buildkit-client crate. Connects to buildkitd daemon over Unix socket
or TCP with optional TLS.

Implementation:
- connect() with custom tonic UnixStream connector
- execute_build() implementing the solve protocol directly against
  ControlClient (session setup, file sync, frontend attributes)
- Extracts digest from containerimage.digest in solve response

Added custom lima template (test/lima/wfe-test.yaml) that provides
both buildkitd and containerd with host-forwarded Unix sockets for
reproducible integration testing.

E2E tests against real buildkitd daemon via WFE_BUILDKIT_ADDR env var.
54 tests total. 89% line coverage (cargo-llvm-cov with E2E).
This commit is contained in:
2026-03-26 11:18:22 +00:00
parent 30b26ca5f0
commit b02da21aac
4 changed files with 773 additions and 665 deletions

View File

@@ -0,0 +1,231 @@
//! Integration tests for wfe-buildkit using a real BuildKit daemon.
//!
//! These tests require a running BuildKit daemon. The socket path is read
//! from `WFE_BUILDKIT_ADDR`, falling back to
//! `unix:///Users/sienna/.lima/wfe-test/sock/buildkitd.sock`.
//!
//! If the daemon is not available, the tests are skipped gracefully.
use std::collections::HashMap;
use std::path::Path;
use wfe_buildkit::config::{BuildkitConfig, TlsConfig};
use wfe_buildkit::BuildkitStep;
use wfe_core::models::{ExecutionPointer, WorkflowInstance, WorkflowStep};
use wfe_core::traits::step::{StepBody, StepExecutionContext};
/// Get the BuildKit daemon address from the environment or use the default.
fn buildkit_addr() -> String {
std::env::var("WFE_BUILDKIT_ADDR").unwrap_or_else(|_| {
"unix:///Users/sienna/.lima/wfe-test/sock/buildkitd.sock".to_string()
})
}
/// Check whether the BuildKit daemon socket is reachable.
fn buildkitd_available() -> bool {
let addr = buildkit_addr();
if let Some(path) = addr.strip_prefix("unix://") {
Path::new(path).exists()
} else {
// For TCP endpoints, optimistically assume available.
true
}
}
fn make_test_context(
step_name: &str,
) -> (
WorkflowStep,
ExecutionPointer,
WorkflowInstance,
) {
let mut step = WorkflowStep::new(0, "buildkit");
step.name = Some(step_name.to_string());
let pointer = ExecutionPointer::new(0);
let instance = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
(step, pointer, instance)
}
#[tokio::test]
async fn build_simple_dockerfile_via_grpc() {
if !buildkitd_available() {
eprintln!(
"SKIP: BuildKit daemon not available at {}",
buildkit_addr()
);
return;
}
// Create a temp directory with a trivial Dockerfile.
let tmp = tempfile::tempdir().unwrap();
let dockerfile = tmp.path().join("Dockerfile");
std::fs::write(
&dockerfile,
"FROM alpine:latest\nRUN echo built\n",
)
.unwrap();
let config = BuildkitConfig {
dockerfile: "Dockerfile".to_string(),
context: tmp.path().to_string_lossy().to_string(),
target: None,
tags: vec![],
build_args: HashMap::new(),
cache_from: vec![],
cache_to: vec![],
push: false,
output_type: None,
buildkit_addr: buildkit_addr(),
tls: TlsConfig::default(),
registry_auth: HashMap::new(),
timeout_ms: Some(120_000), // 2 minutes
};
let mut step = BuildkitStep::new(config);
let (ws, pointer, instance) = make_test_context("integration-build");
let cancel = tokio_util::sync::CancellationToken::new();
let ctx = StepExecutionContext {
item: None,
execution_pointer: &pointer,
persistence_data: None,
step: &ws,
workflow: &instance,
cancellation_token: cancel,
};
let result = step.run(&ctx).await.expect("build should succeed");
assert!(result.proceed);
let data = result.output_data.expect("should have output_data");
let obj = data.as_object().expect("output_data should be an object");
// Without tags/push, BuildKit does not produce a digest in the exporter
// response. The build succeeds but the digest is absent.
assert!(
obj.contains_key("integration-build.stdout"),
"expected stdout key, got: {:?}",
obj.keys().collect::<Vec<_>>()
);
assert!(
obj.contains_key("integration-build.stderr"),
"expected stderr key, got: {:?}",
obj.keys().collect::<Vec<_>>()
);
// If a digest IS present (e.g., newer buildkitd versions), validate its format.
if let Some(digest_val) = obj.get("integration-build.digest") {
let digest = digest_val.as_str().unwrap();
assert!(
digest.starts_with("sha256:"),
"digest should start with sha256:, got: {digest}"
);
assert_eq!(
digest.len(),
7 + 64,
"digest should be sha256:<64hex>, got: {digest}"
);
}
}
#[tokio::test]
async fn build_with_build_args() {
if !buildkitd_available() {
eprintln!(
"SKIP: BuildKit daemon not available at {}",
buildkit_addr()
);
return;
}
let tmp = tempfile::tempdir().unwrap();
let dockerfile = tmp.path().join("Dockerfile");
std::fs::write(
&dockerfile,
"FROM alpine:latest\nARG MY_VAR=default\nRUN echo \"value=$MY_VAR\"\n",
)
.unwrap();
let mut build_args = HashMap::new();
build_args.insert("MY_VAR".to_string(), "custom_value".to_string());
let config = BuildkitConfig {
dockerfile: "Dockerfile".to_string(),
context: tmp.path().to_string_lossy().to_string(),
target: None,
tags: vec![],
build_args,
cache_from: vec![],
cache_to: vec![],
push: false,
output_type: None,
buildkit_addr: buildkit_addr(),
tls: TlsConfig::default(),
registry_auth: HashMap::new(),
timeout_ms: Some(120_000),
};
let mut step = BuildkitStep::new(config);
let (ws, pointer, instance) = make_test_context("build-args-test");
let cancel = tokio_util::sync::CancellationToken::new();
let ctx = StepExecutionContext {
item: None,
execution_pointer: &pointer,
persistence_data: None,
step: &ws,
workflow: &instance,
cancellation_token: cancel,
};
let result = step.run(&ctx).await.expect("build with args should succeed");
assert!(result.proceed);
let data = result.output_data.expect("should have output_data");
let obj = data.as_object().unwrap();
// Build should complete and produce output data entries.
assert!(
obj.contains_key("build-args-test.stdout"),
"expected stdout key, got: {:?}",
obj.keys().collect::<Vec<_>>()
);
}
#[tokio::test]
async fn connect_to_unavailable_daemon_returns_error() {
// Use a deliberately wrong address to test error handling.
let config = 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:///tmp/nonexistent-buildkitd.sock".to_string(),
tls: TlsConfig::default(),
registry_auth: HashMap::new(),
timeout_ms: Some(5_000),
};
let mut step = BuildkitStep::new(config);
let (ws, pointer, instance) = make_test_context("error-test");
let cancel = tokio_util::sync::CancellationToken::new();
let ctx = StepExecutionContext {
item: None,
execution_pointer: &pointer,
persistence_data: None,
step: &ws,
workflow: &instance,
cancellation_token: cancel,
};
let err = step.run(&ctx).await;
assert!(err.is_err(), "should fail when daemon is unavailable");
}