feat(wfe-buildkit, wfe-containerd): add container executor crates
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.
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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<dyn StepBody>
|
||||
});
|
||||
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<dyn StepBody>
|
||||
});
|
||||
Ok((key, value, factory))
|
||||
}
|
||||
other => Err(YamlWorkflowError::Compilation(format!(
|
||||
"Unknown step type: '{other}'"
|
||||
))),
|
||||
@@ -346,6 +380,162 @@ fn parse_duration_ms(s: &str) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "buildkit")]
|
||||
fn build_buildkit_config(
|
||||
step: &YamlStep,
|
||||
) -> Result<BuildkitConfig, YamlWorkflowError> {
|
||||
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<ContainerdConfig, YamlWorkflowError> {
|
||||
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<ErrorBehavior, YamlWorkflowError> {
|
||||
match eb.behavior_type.as_str() {
|
||||
"retry" => {
|
||||
|
||||
@@ -58,6 +58,38 @@ pub struct StepConfig {
|
||||
pub permissions: Option<DenoPermissionsYaml>,
|
||||
#[serde(default)]
|
||||
pub modules: Vec<String>,
|
||||
// BuildKit fields
|
||||
pub dockerfile: Option<String>,
|
||||
pub context: Option<String>,
|
||||
pub target: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub build_args: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub cache_from: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub cache_to: Vec<String>,
|
||||
pub push: Option<bool>,
|
||||
pub buildkit_addr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tls: Option<TlsConfigYaml>,
|
||||
#[serde(default)]
|
||||
pub registry_auth: Option<HashMap<String, RegistryAuthYaml>>,
|
||||
// Containerd fields
|
||||
pub image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub command: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<VolumeMountYaml>,
|
||||
pub user: Option<String>,
|
||||
pub network: Option<String>,
|
||||
pub memory: Option<String>,
|
||||
pub cpu: Option<String>,
|
||||
pub pull: Option<String>,
|
||||
pub containerd_addr: Option<String>,
|
||||
/// CLI binary name for containerd steps: "nerdctl" (default) or "docker".
|
||||
pub cli: Option<String>,
|
||||
}
|
||||
|
||||
/// YAML-level permission configuration for Deno steps.
|
||||
@@ -84,6 +116,30 @@ pub struct DataRef {
|
||||
pub json_path: Option<String>,
|
||||
}
|
||||
|
||||
/// YAML-level TLS configuration for BuildKit steps.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct TlsConfigYaml {
|
||||
pub ca: Option<String>,
|
||||
pub cert: Option<String>,
|
||||
pub key: Option<String>,
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
Reference in New Issue
Block a user