#![allow(
unsafe_code,
clippy::borrow_as_ptr,
clippy::too_many_lines,
clippy::items_after_test_module
)]
mod commit;
mod exec;
mod layer;
mod scratch;
pub use commit::{
build_image_config_bytes, build_manifest_bytes, BuildCommitArtifacts, ImageConfigBuilder,
OCI_IMAGE_CONFIG_MEDIA_TYPE, OCI_IMAGE_MANIFEST_MEDIA_TYPE, OCI_WINDOWS_LAYER_MEDIA_TYPE,
};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use async_trait::async_trait;
use tracing::{debug, info, warn};
use crate::builder::{BuildOptions, BuiltImage, RegistryAuth};
use crate::dockerfile::{Dockerfile, ImageRef, Instruction};
use crate::error::{BuildError, Result};
use crate::tui::BuildEvent;
use super::{BuildBackend, ImageOs};
const DEFAULT_SCRATCH_SIZE_GB: u64 = 20;
fn default_storage_root() -> PathBuf {
if let Some(dir) = dirs::data_local_dir() {
dir.join("zlayer").join("builder-hcs")
} else {
PathBuf::from(r"C:\ProgramData\zlayer\builder-hcs")
}
}
pub struct HcsBackend {
storage_root: PathBuf,
registry: std::sync::Arc<zlayer_registry::ImagePuller>,
}
impl std::fmt::Debug for HcsBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HcsBackend")
.field("storage_root", &self.storage_root)
.finish_non_exhaustive()
}
}
impl HcsBackend {
pub async fn new() -> Result<Self> {
let root = default_storage_root();
Self::with_storage_root(root).await
}
pub async fn with_storage_root(storage_root: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&storage_root).map_err(|e| BuildError::ContextRead {
path: storage_root.clone(),
source: e,
})?;
let cache_type = zlayer_registry::CacheType::from_env()
.map_err(|e| BuildError::registry_error(format!("HCS blob cache from env: {e}")))?;
let blob_cache = cache_type
.build()
.await
.map_err(|e| BuildError::registry_error(format!("open HCS blob cache: {e}")))?;
let target = zlayer_spec::TargetPlatform::new(
zlayer_spec::OsKind::Windows,
zlayer_spec::ArchKind::Amd64,
);
let registry = std::sync::Arc::new(zlayer_registry::ImagePuller::with_platform(
blob_cache, target,
));
Ok(Self {
storage_root,
registry,
})
}
fn build_dir(&self, build_id: &str) -> PathBuf {
self.storage_root.join("builds").join(build_id)
}
fn send_event(event_tx: Option<&mpsc::Sender<BuildEvent>>, event: BuildEvent) {
if let Some(tx) = event_tx {
let _ = tx.send(event);
}
}
}
#[async_trait]
impl BuildBackend for HcsBackend {
async fn build_image(
&self,
context: &Path,
dockerfile: &Dockerfile,
options: &BuildOptions,
event_tx: Option<mpsc::Sender<BuildEvent>>,
) -> Result<BuiltImage> {
let started = std::time::Instant::now();
if dockerfile.stages.len() != 1 {
return Err(BuildError::NotSupported {
operation: format!(
"multi-stage Windows builds ({} stages) — HCS backend supports a single stage \
in the first iteration; track the follow-up at TODO(L-4-followup)",
dockerfile.stages.len()
),
});
}
let stage = &dockerfile.stages[0];
let base_ref = match &stage.base_image {
ImageRef::Registry { .. } => stage.base_image.to_string_ref(),
ImageRef::Stage(name) => {
return Err(BuildError::stage_not_found(name));
}
ImageRef::Scratch => {
return Err(BuildError::InvalidInstruction {
instruction: "FROM scratch".to_string(),
reason: "HCS builder requires a Windows base image — `scratch` cannot run HCS \
processes (no OS kernel, no cmd.exe). Use `mcr.microsoft.com/windows/\
nanoserver:...` or `.../servercore:...`."
.to_string(),
});
}
};
let build_id = new_build_id();
let build_dir = self.build_dir(&build_id);
std::fs::create_dir_all(&build_dir).map_err(|e| BuildError::ContextRead {
path: build_dir.clone(),
source: e,
})?;
let total_instructions_planned = stage.instructions.len();
Self::send_event(
event_tx.as_ref(),
BuildEvent::BuildStarted {
total_stages: 1,
total_instructions: total_instructions_planned,
},
);
Self::send_event(
event_tx.as_ref(),
BuildEvent::StageStarted {
index: 0,
name: stage.name.clone(),
base_image: base_ref.clone(),
},
);
info!(
build_id = %build_id,
base = %base_ref,
instructions = total_instructions_planned,
"HCS build starting"
);
let base_root = build_dir.join("base");
let base_artifacts = scratch::prepare_base_chain(&self.registry, &base_ref, &base_root)
.await
.map_err(|e| {
BuildError::registry_error(format!("pull windows base {base_ref}: {e}"))
})?;
let scratch_path = build_dir.join("scratch");
let scratch_layer = scratch::create_writable_layer(
&scratch_path,
&base_artifacts.parent_chain,
options
.platform
.as_deref()
.and_then(parse_scratch_size_gb)
.unwrap_or(DEFAULT_SCRATCH_SIZE_GB),
)
.map_err(|e| BuildError::LayerCreate {
message: format!("create scratch layer at {}: {e}", scratch_path.display()),
})?;
debug!(
scratch = %scratch_layer.layer_path().display(),
vhd_mount = %scratch_layer.vhd_mount_path(),
"scratch layer ready"
);
let mut config = ImageConfigBuilder::new();
if let Some(ref base_cfg) = base_artifacts.base_config {
config.inherit_from_base(base_cfg);
}
config.set_os_version(base_artifacts.os_version.clone());
let mut translator = crate::buildah::DockerfileTranslator::new(ImageOs::Windows);
for (inst_idx, instruction) in stage.instructions.iter().enumerate() {
Self::send_event(
event_tx.as_ref(),
BuildEvent::InstructionStarted {
stage: 0,
index: inst_idx,
instruction: format!("{instruction:?}"),
},
);
let inst_started = std::time::Instant::now();
let dispatch_result = dispatch_instruction(
instruction,
context,
scratch_layer.layer_path(),
&base_artifacts,
&mut translator,
&mut config,
event_tx.as_ref(),
)
.await;
match dispatch_result {
Ok(()) => {
#[allow(clippy::cast_possible_truncation)]
let elapsed = inst_started.elapsed().as_millis() as u64;
debug!(
stage = 0,
index = inst_idx,
elapsed_ms = elapsed,
"instruction complete"
);
Self::send_event(
event_tx.as_ref(),
BuildEvent::InstructionComplete {
stage: 0,
index: inst_idx,
cached: false,
},
);
}
Err(e) => {
if let Err(teardown_err) = scratch_layer.detach_and_destroy() {
warn!(
error = %teardown_err,
"failed to tear down scratch layer after instruction failure"
);
}
Self::send_event(
event_tx.as_ref(),
BuildEvent::BuildFailed {
error: e.to_string(),
},
);
return Err(e);
}
}
}
Self::send_event(event_tx.as_ref(), BuildEvent::StageComplete { index: 0 });
let export_dir = build_dir.join("export");
let diff_blob = layer::capture_diff_blob(
scratch_layer.layer_path(),
&base_artifacts.parent_chain,
&export_dir,
)
.map_err(|e| BuildError::LayerCreate {
message: format!("capture NTFS diff at {}: {e}", export_dir.display()),
})?;
scratch_layer
.detach_and_destroy()
.map_err(|e| BuildError::LayerCreate {
message: format!("tear down scratch layer: {e}"),
})?;
let oci_out = build_dir.join("oci");
let artifacts =
commit::write_oci_artifacts(&oci_out, &config, &base_artifacts.layer_blobs, &diff_blob)
.map_err(|e| BuildError::LayerCreate {
message: format!("write OCI artifacts: {e}"),
})?;
#[allow(clippy::cast_possible_truncation)]
let elapsed_ms = started.elapsed().as_millis() as u64;
let image_id = artifacts.manifest_digest.clone();
let mut tags = options.tags.clone();
if tags.is_empty() {
tags.push(format!("zlayer-windows-build:{build_id}"));
}
Self::send_event(
event_tx.as_ref(),
BuildEvent::BuildComplete {
image_id: image_id.clone(),
},
);
info!(
build_id = %build_id,
image_id = %image_id,
elapsed_ms = elapsed_ms,
layers = artifacts.layer_count,
"HCS build finished"
);
Ok(BuiltImage {
image_id,
tags,
layer_count: artifacts.layer_count,
size: artifacts.total_size,
build_time_ms: elapsed_ms,
is_manifest: false,
})
}
async fn push_image(&self, _tag: &str, _auth: Option<&RegistryAuth>) -> Result<()> {
Err(BuildError::NotSupported {
operation: "HCS backend push — push is routed through the registry client directly; \
wire this path when the HCS builder gains a push integration \
(TODO(L-4-followup))"
.to_string(),
})
}
async fn tag_image(&self, _image: &str, _new_tag: &str) -> Result<()> {
Err(BuildError::NotSupported {
operation: "HCS backend retag — tags are embedded in the OCI index annotations at \
build time; a standalone retag lands with the push path \
(TODO(L-4-followup))"
.to_string(),
})
}
async fn manifest_create(&self, _name: &str) -> Result<()> {
Err(BuildError::NotSupported {
operation: "HCS backend manifest create — manifest lists are buildah-specific; the \
HCS builder produces a single platform-specific image. Use a Linux peer \
with buildah for multi-platform manifest composition."
.to_string(),
})
}
async fn manifest_add(&self, _manifest: &str, _image: &str) -> Result<()> {
Err(BuildError::NotSupported {
operation: "HCS backend manifest add — see manifest_create for rationale".to_string(),
})
}
async fn manifest_push(&self, _name: &str, _destination: &str) -> Result<()> {
Err(BuildError::NotSupported {
operation: "HCS backend manifest push — see manifest_create for rationale".to_string(),
})
}
async fn is_available(&self) -> bool {
true
}
fn name(&self) -> &'static str {
"hcs"
}
}
async fn dispatch_instruction(
instruction: &Instruction,
context: &Path,
scratch_root: &Path,
base: &scratch::BaseChainArtifacts,
translator: &mut crate::buildah::DockerfileTranslator,
config: &mut ImageConfigBuilder,
event_tx: Option<&mpsc::Sender<BuildEvent>>,
) -> Result<()> {
match instruction {
Instruction::Run(run) => {
exec::run_in_compute_system(run, scratch_root, base, translator, config, event_tx).await
}
Instruction::Copy(copy) => {
if copy.from.is_some() {
return Err(BuildError::NotSupported {
operation: "COPY --from (multi-stage) — HCS backend supports single-stage \
builds in the first iteration (TODO(L-4-followup))"
.to_string(),
});
}
exec::copy_into_scratch(context, scratch_root, ©.sources, ©.destination)
}
Instruction::Add(add) => {
if add.sources.iter().any(|s| s.starts_with("http")) {
return Err(BuildError::NotSupported {
operation: "ADD <url> — HCS backend does not yet fetch URLs; use RUN with \
curl/Invoke-WebRequest or a multi-stage Linux builder \
(TODO(L-4-followup))"
.to_string(),
});
}
exec::copy_into_scratch(context, scratch_root, &add.sources, &add.destination)
}
Instruction::Env(env) => {
for (k, v) in &env.vars {
config.push_env(k, v);
}
Ok(())
}
Instruction::Workdir(dir) => {
config.set_working_dir(dir);
exec::ensure_workdir(scratch_root, dir)
}
Instruction::Expose(expose) => {
config.add_exposed_port(
expose.port,
matches!(expose.protocol, crate::dockerfile::ExposeProtocol::Tcp),
);
Ok(())
}
Instruction::Label(labels) => {
for (k, v) in labels {
config.add_label(k, v);
}
Ok(())
}
Instruction::User(user) => {
config.set_user(user);
Ok(())
}
Instruction::Entrypoint(cmd) => {
config.set_entrypoint(translator, cmd);
Ok(())
}
Instruction::Cmd(cmd) => {
config.set_cmd(translator, cmd);
Ok(())
}
Instruction::Volume(paths) => {
for p in paths {
config.add_volume(p);
}
Ok(())
}
Instruction::Shell(shell) => {
translator.set_shell_override(shell.clone());
config.set_shell(shell.clone());
Ok(())
}
Instruction::Arg(_) => {
Ok(())
}
Instruction::Stopsignal(signal) => {
config.set_stop_signal(signal);
Ok(())
}
Instruction::Healthcheck(hc) => {
config.set_healthcheck(hc.clone());
Ok(())
}
Instruction::Onbuild(_) => {
warn!("ONBUILD instruction ignored in HCS builder (not supported)");
Ok(())
}
}
}
fn parse_scratch_size_gb(platform: &str) -> Option<u64> {
let s = platform
.rsplit(',')
.find_map(|chunk| chunk.trim().strip_prefix("scratch:"))?;
let digits = s.strip_suffix('g').or_else(|| s.strip_suffix('G'))?;
digits.parse::<u64>().ok()
}
fn new_build_id() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let pid = u128::from(std::process::id());
let count = u128::from(COUNTER.fetch_add(1, Ordering::Relaxed));
let mixed = nanos ^ (pid.rotate_left(17)) ^ count.rotate_left(33);
format!("{:012x}", mixed & 0xFFFF_FFFF_FFFF)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_id_is_unique_and_hex_shaped() {
let a = new_build_id();
let b = new_build_id();
assert_ne!(a, b);
assert_eq!(a.len(), 12);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn parse_scratch_size_extracts_gb_suffix() {
assert_eq!(parse_scratch_size_gb("windows/amd64,scratch:40g"), Some(40));
assert_eq!(parse_scratch_size_gb("scratch:2G"), Some(2));
assert_eq!(parse_scratch_size_gb("windows/amd64"), None);
assert_eq!(parse_scratch_size_gb(""), None);
}
#[test]
fn default_storage_root_ends_in_zlayer_builder_hcs() {
let root = default_storage_root();
let s = root.to_string_lossy().to_ascii_lowercase();
assert!(s.contains("zlayer"));
assert!(s.contains("builder-hcs"));
}
}