Add builder VM support for cross-platform and unprivileged builds
Introduce the forge-builder crate that automatically delegates builds to
an ephemeral VM when the host can't build locally (e.g., QCOW2 targets
without root, or OmniOS images on Linux). The builder detects these
conditions, spins up a VM via vm-manager with user-mode networking,
uploads inputs, streams the remote build output, and retrieves artifacts.
Key changes:
- New forge-builder crate with detection, binary resolution, VM lifecycle
management, file transfer, and miette diagnostic errors
- BuilderNode added to spec-parser schema for per-spec VM config
- --local and --use-builder CLI flags on the build command
- Feature-gated (default on) integration in forger CLI
- Fix ext4 QCOW2 grub-install failure by using absolute paths in chroot
- Improve debootstrap to pass --components and write full sources.list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:17:30 +01:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use tracing::info;
|
|
|
|
|
|
|
|
|
|
use vm_manager::ssh;
|
|
|
|
|
|
|
|
|
|
use crate::error::BuilderError;
|
|
|
|
|
use crate::lifecycle::BuilderSession;
|
|
|
|
|
|
2026-02-16 00:12:13 +01:00
|
|
|
const REMOTE_BUILD_DIR: &str = "/var/tmp/forger-build";
|
Add builder VM support for cross-platform and unprivileged builds
Introduce the forge-builder crate that automatically delegates builds to
an ephemeral VM when the host can't build locally (e.g., QCOW2 targets
without root, or OmniOS images on Linux). The builder detects these
conditions, spins up a VM via vm-manager with user-mode networking,
uploads inputs, streams the remote build output, and retrieves artifacts.
Key changes:
- New forge-builder crate with detection, binary resolution, VM lifecycle
management, file transfer, and miette diagnostic errors
- BuilderNode added to spec-parser schema for per-spec VM config
- --local and --use-builder CLI flags on the build command
- Feature-gated (default on) integration in forger CLI
- Fix ext4 QCOW2 grub-install failure by using absolute paths in chroot
- Improve debootstrap to pass --components and write full sources.list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:17:30 +01:00
|
|
|
|
|
|
|
|
/// Upload all build inputs to the builder VM.
|
|
|
|
|
pub fn upload_build_inputs(
|
|
|
|
|
session: &BuilderSession,
|
|
|
|
|
forger_binary: &Path,
|
|
|
|
|
spec_path: &Path,
|
|
|
|
|
files_dir: &Path,
|
|
|
|
|
) -> Result<(), BuilderError> {
|
|
|
|
|
let sess = &session.ssh_session;
|
|
|
|
|
|
|
|
|
|
// Create remote build directory
|
|
|
|
|
ssh::exec(sess, &format!("mkdir -p {REMOTE_BUILD_DIR}/output"))
|
|
|
|
|
.map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("mkdir: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
// Upload forger binary
|
|
|
|
|
info!("Uploading forger binary to builder VM");
|
|
|
|
|
let remote_forger = PathBuf::from(format!("{REMOTE_BUILD_DIR}/forger"));
|
|
|
|
|
ssh::upload(sess, forger_binary, &remote_forger).map_err(|e| {
|
|
|
|
|
BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("upload forger binary: {e}"),
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
// Make executable
|
|
|
|
|
ssh::exec(sess, &format!("chmod +x {REMOTE_BUILD_DIR}/forger")).map_err(|e| {
|
|
|
|
|
BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("chmod forger: {e}"),
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
// Upload spec file
|
|
|
|
|
info!("Uploading spec file");
|
|
|
|
|
let remote_spec = PathBuf::from(format!("{REMOTE_BUILD_DIR}/spec.kdl"));
|
|
|
|
|
ssh::upload(sess, spec_path, &remote_spec).map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("upload spec: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
2026-02-16 00:12:13 +01:00
|
|
|
// Upload sibling .kdl files (base/include references resolved relative to spec dir)
|
|
|
|
|
if let Some(spec_dir) = spec_path.parent() {
|
|
|
|
|
if let Ok(entries) = std::fs::read_dir(spec_dir) {
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
let path = entry.path();
|
|
|
|
|
if path.extension().is_some_and(|e| e == "kdl") && path != spec_path {
|
|
|
|
|
let filename = path.file_name().unwrap();
|
|
|
|
|
let remote_path =
|
|
|
|
|
PathBuf::from(format!("{REMOTE_BUILD_DIR}/{}", filename.to_string_lossy()));
|
|
|
|
|
info!(file = %filename.to_string_lossy(), "Uploading include file");
|
|
|
|
|
ssh::upload(sess, &path, &remote_path).map_err(|e| {
|
|
|
|
|
BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("upload include {}: {e}", filename.to_string_lossy()),
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add builder VM support for cross-platform and unprivileged builds
Introduce the forge-builder crate that automatically delegates builds to
an ephemeral VM when the host can't build locally (e.g., QCOW2 targets
without root, or OmniOS images on Linux). The builder detects these
conditions, spins up a VM via vm-manager with user-mode networking,
uploads inputs, streams the remote build output, and retrieves artifacts.
Key changes:
- New forge-builder crate with detection, binary resolution, VM lifecycle
management, file transfer, and miette diagnostic errors
- BuilderNode added to spec-parser schema for per-spec VM config
- --local and --use-builder CLI flags on the build command
- Feature-gated (default on) integration in forger CLI
- Fix ext4 QCOW2 grub-install failure by using absolute paths in chroot
- Improve debootstrap to pass --components and write full sources.list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:17:30 +01:00
|
|
|
// Upload files/ directory if it exists (tar locally → upload → extract remotely)
|
|
|
|
|
if files_dir.exists() && files_dir.is_dir() {
|
|
|
|
|
upload_directory(sess, files_dir, &format!("{REMOTE_BUILD_DIR}/files"))?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Upload a local directory to the VM by creating a tar, uploading, and extracting.
|
|
|
|
|
fn upload_directory(
|
|
|
|
|
sess: &ssh2::Session,
|
|
|
|
|
local_dir: &Path,
|
|
|
|
|
remote_dir: &str,
|
|
|
|
|
) -> Result<(), BuilderError> {
|
|
|
|
|
info!(local = %local_dir.display(), remote = %remote_dir, "Uploading directory to builder VM");
|
|
|
|
|
|
|
|
|
|
// Create a tar archive in memory
|
|
|
|
|
let mut tar_buf = Vec::new();
|
|
|
|
|
{
|
|
|
|
|
let mut ar = tar::Builder::new(&mut tar_buf);
|
|
|
|
|
ar.append_dir_all(".", local_dir)
|
|
|
|
|
.map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("tar {}: {e}", local_dir.display()),
|
|
|
|
|
})?;
|
|
|
|
|
ar.finish().map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("tar finish: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write tar to a temp file so we can upload it
|
|
|
|
|
let tmp = tempfile::NamedTempFile::new().map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("tempfile: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
std::fs::write(tmp.path(), &tar_buf).map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("write tar: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
let remote_tar = PathBuf::from(format!("{remote_dir}.tar"));
|
|
|
|
|
ssh::upload(sess, tmp.path(), &remote_tar).map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("upload tar: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
// Extract on remote
|
|
|
|
|
ssh::exec(
|
|
|
|
|
sess,
|
|
|
|
|
&format!("mkdir -p {remote_dir} && tar xf {remote_dir}.tar -C {remote_dir} && rm {remote_dir}.tar"),
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| BuilderError::TransferFailed {
|
|
|
|
|
detail: format!("extract tar: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Download build artifacts from the builder VM.
|
|
|
|
|
pub fn download_artifacts(
|
|
|
|
|
session: &BuilderSession,
|
|
|
|
|
output_dir: &Path,
|
|
|
|
|
) -> Result<(), BuilderError> {
|
|
|
|
|
let sess = &session.ssh_session;
|
|
|
|
|
let remote_output = format!("{REMOTE_BUILD_DIR}/output");
|
|
|
|
|
|
2026-02-16 00:12:13 +01:00
|
|
|
// List files in remote output directory (use ls -1 for portability; GNU
|
|
|
|
|
// find -printf is not available on illumos)
|
Add builder VM support for cross-platform and unprivileged builds
Introduce the forge-builder crate that automatically delegates builds to
an ephemeral VM when the host can't build locally (e.g., QCOW2 targets
without root, or OmniOS images on Linux). The builder detects these
conditions, spins up a VM via vm-manager with user-mode networking,
uploads inputs, streams the remote build output, and retrieves artifacts.
Key changes:
- New forge-builder crate with detection, binary resolution, VM lifecycle
management, file transfer, and miette diagnostic errors
- BuilderNode added to spec-parser schema for per-spec VM config
- --local and --use-builder CLI flags on the build command
- Feature-gated (default on) integration in forger CLI
- Fix ext4 QCOW2 grub-install failure by using absolute paths in chroot
- Improve debootstrap to pass --components and write full sources.list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:17:30 +01:00
|
|
|
let (stdout, _, exit_code) = ssh::exec(
|
|
|
|
|
sess,
|
2026-02-16 00:12:13 +01:00
|
|
|
&format!("ls -1 {remote_output}/ 2>/dev/null"),
|
Add builder VM support for cross-platform and unprivileged builds
Introduce the forge-builder crate that automatically delegates builds to
an ephemeral VM when the host can't build locally (e.g., QCOW2 targets
without root, or OmniOS images on Linux). The builder detects these
conditions, spins up a VM via vm-manager with user-mode networking,
uploads inputs, streams the remote build output, and retrieves artifacts.
Key changes:
- New forge-builder crate with detection, binary resolution, VM lifecycle
management, file transfer, and miette diagnostic errors
- BuilderNode added to spec-parser schema for per-spec VM config
- --local and --use-builder CLI flags on the build command
- Feature-gated (default on) integration in forger CLI
- Fix ext4 QCOW2 grub-install failure by using absolute paths in chroot
- Improve debootstrap to pass --components and write full sources.list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:17:30 +01:00
|
|
|
)
|
|
|
|
|
.map_err(|e| BuilderError::DownloadFailed {
|
|
|
|
|
detail: format!("list remote files: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
if exit_code != 0 {
|
|
|
|
|
return Err(BuilderError::DownloadFailed {
|
|
|
|
|
detail: "failed to list remote output directory".to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::fs::create_dir_all(output_dir).map_err(|e| BuilderError::DownloadFailed {
|
|
|
|
|
detail: format!("create output dir: {e}"),
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
for filename in stdout.lines() {
|
|
|
|
|
let filename = filename.trim();
|
|
|
|
|
if filename.is_empty() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let remote_path = PathBuf::from(format!("{remote_output}/{filename}"));
|
|
|
|
|
let local_path = output_dir.join(filename);
|
|
|
|
|
|
|
|
|
|
info!(file = %filename, "Downloading artifact from builder VM");
|
|
|
|
|
ssh::download(sess, &remote_path, &local_path).map_err(|e| {
|
|
|
|
|
BuilderError::DownloadFailed {
|
|
|
|
|
detail: format!("download {filename}: {e}"),
|
|
|
|
|
}
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|