From 3c5fc1fb7ea307db78b6019139eded36ef32fede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Tue, 11 Aug 2026 23:28:25 +0200 Subject: [PATCH 1/4] feat(cli): implement test subcommand for autopkgtests --- .github/workflows/ci.yaml | 9 +- README.md | 2 +- debian/control | 1 + debian/tests/control | 2 + debian/tests/smoke | 5 + docs/index.md | 1 + docs/usage/build.md | 6 +- docs/usage/test.md | 80 ++++ packages/debmagic/README.md | 14 +- packages/debmagic/src/build/artifacts.rs | 97 +++- packages/debmagic/src/build/common.rs | 80 +++- packages/debmagic/src/build/driver_bare.rs | 14 +- packages/debmagic/src/build/driver_docker.rs | 21 +- packages/debmagic/src/build/driver_lxd.rs | 52 +- packages/debmagic/src/build/mod.rs | 117 +++-- packages/debmagic/src/build/source.rs | 1 + packages/debmagic/src/build_intent.rs | 15 + packages/debmagic/src/cli.rs | 71 ++- packages/debmagic/src/main.rs | 52 +- packages/debmagic/src/test/intent.rs | 154 ++++++ packages/debmagic/src/test/mod.rs | 7 + packages/debmagic/src/test/run.rs | 470 +++++++++++++++++++ 22 files changed, 1173 insertions(+), 98 deletions(-) create mode 100644 debian/tests/control create mode 100755 debian/tests/smoke create mode 100644 docs/usage/test.md create mode 100644 packages/debmagic/src/test/intent.rs create mode 100644 packages/debmagic/src/test/mod.rs create mode 100644 packages/debmagic/src/test/run.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a57d447..794de39 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -156,7 +156,9 @@ jobs: run: | mkdir -p /tmp/debmagic current="$(dpkg-parsechangelog -SSource)-$(dpkg-parsechangelog -SVersion)" - find /tmp/debmagic -mindepth 1 -maxdepth 1 -type d ! -name "$current" -exec rm -rf {} + + find /tmp/debmagic -mindepth 1 -maxdepth 1 -type d \ + ! -name "$current" ! -name "${current}-test" \ + -exec rm -rf {} + - name: Resolve CI image tag id: image run: | @@ -181,6 +183,11 @@ jobs: --persistent \ --incremental \ --driver-docker-base-image="${{ steps.image.outputs.tag }}" + - name: Run Debmagic test on ourself + run: | + cargo run --locked -p debmagic -- test \ + --driver=docker \ + --driver-docker-base-image="${{ steps.image.outputs.tag }}" - name: Push the CI image if: github.ref == 'refs/heads/main' && env.image_built == 'true' run: | diff --git a/README.md b/README.md index 9eb0082..1302807 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ To learn packaging with debmagic, follow **[the documentation!](https://debmagic | - | - | | `debmagic build binary` | Build a binary package in a container | | `debmagic build source` | Create a source package for upload | -| `debmagic test` | Run package tests | +| `debmagic test` | Run Debian autopkgtest tests (`debian/tests/`) against a prior build | | `debmagic check` | Lint the package | > [!TIP] diff --git a/debian/control b/debian/control index f06c3bf..fad3b26 100644 --- a/debian/control +++ b/debian/control @@ -1,4 +1,5 @@ Source: debmagic +Testsuite: autopkgtest Section: devel Maintainer: Debmagic Maintainers Uploaders: diff --git a/debian/tests/control b/debian/tests/control new file mode 100644 index 0000000..5794aa8 --- /dev/null +++ b/debian/tests/control @@ -0,0 +1,2 @@ +Tests: smoke +Depends: @ diff --git a/debian/tests/smoke b/debian/tests/smoke new file mode 100755 index 0000000..97c7a91 --- /dev/null +++ b/debian/tests/smoke @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +debmagic --version +debmagic test --help diff --git a/docs/index.md b/docs/index.md index 7a0b0dd..6e2e4f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,7 @@ usage/getting-started.md usage/build.md +usage/test.md usage/source.md usage/config.md usage/modules/index.md diff --git a/docs/usage/build.md b/docs/usage/build.md index 89d3096..6372665 100644 --- a/docs/usage/build.md +++ b/docs/usage/build.md @@ -32,6 +32,7 @@ debmagic build binary --driver lxd \ | `--apt-mirror ` | [Mirror URL](#mirror-selection) | | `--source-dir ` | Directory containing the `debian/` package directory | | `--output-dir ` | Directory to put the resulting build artifacts | +| `--shell-on-failure` | On build failure, drop into an interactive shell in the build environment when stdout is a TTY | [`debmagic shell`](#inspecting-a-failed-build) — attach an interactive shell to a build environment @@ -49,8 +50,9 @@ There's no auto-detection; pick one and pass it explicitly every time (or config ## Inspecting a failed build -By default a failed build tears down the container, so nothing is left to inspect. -If a build might fail and you need to inspect it afterwards, pass `--persistent` up front, then once the run finishes: +On failure the build environment is torn down by default. Pass `--shell-on-failure` to drop into an interactive shell inside the build environment when stdout is a TTY (destroyed on shell exit unless `--persistent` was used). + +To inspect after the run finishes, pass `--persistent` up front, then: ```shell # if you're in the package still diff --git a/docs/usage/test.md b/docs/usage/test.md new file mode 100644 index 0000000..1b6876d --- /dev/null +++ b/docs/usage/test.md @@ -0,0 +1,80 @@ +# Running package tests + +Quick reference for running a package's declared Debian autopkgtest tests with `debmagic test`. + +## TL;DR + +- Entry point: `debmagic test` — runs tests from `debian/tests/control`; needs a prior `debmagic build` +- Requires a completed build in the same build root (or pass `--changes` to point at exported artifacts) + +```shell +cd your-package +debmagic build binary --driver docker +debmagic test --driver docker +``` + +## What it does + +`debmagic test` installs the binary packages from a prior build and runs the package's declared autopkgtest tests (`debian/tests/control`) +inside a **fresh, separate** driver-managed environment. +The test environment is never the build environment — even when `--persistent` reuses a container across runs, +the test tree is reset and the `.debs` are reinstalled each time. + +The driver *is* the testbed: `autopkgtest` runs with the `null` backend inside the container (or on the host for the bare driver). No `autopkgtest-virt-*` backends are used. + +## Available options + +| Option | Description | +|---|---| +| `--driver <...>` | Test environment driver (defaults to the driver recorded in the prior build's `build.json`) | +| `--persistent` | Retain the test environment after the run for reattach/debug | +| `--strict` | Treat skipped tests and "no tests declared" as failures (exit code 2) | +| `--changes ` | Path to a `.changes` file whose directory supplies the built `.debs` (for pipeline use) | +| `--distro ` | Override the target distro for the test environment (defaults to the prior build's distro from `build.json`, not the changelog) | +| `--proposed` | Enable the `-proposed` pocket in the test environment | +| `--apt-mirror ` | Mirror URL (same as [`debmagic build`](build.md)) | +| `--source-dir ` | Directory containing the `debian/` package directory | +| `--allow-host-test` | Allow the bare driver, which runs autopkgtest as root on the host | +| `--shell-on-failure` | On test failure, drop into an interactive shell in the test environment when stdout is a TTY | + +Driver-specific flags (`--driver-docker-base-image`, `--driver-lxd-*`) mirror `debmagic build`. + +## Picking a driver + +Use the same drivers as for builds. Pass `--driver` explicitly (or rely on the driver recorded in the prior build's `build.json`): + +| Driver | Isolation | +|---|---| +| `lxd` / `incus` | Full container isolation | +| `docker` | Full container isolation | +| `bare` | None — tests run as root on the host; requires `--allow-host-test` | + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | All tests passed, or skips/no-tests were allowed | +| `1` | Test failure, testbed error, or other autopkgtest error | +| `2` | Strict-only failure: skipped tests or no tests declared under `--strict` | + +autopkgtest skips tests whose `Restrictions:` the `null` backend cannot satisfy (e.g. `isolation-container`, `isolation-machine`). Skips are reported loudly; use `--strict` to escalate them to exit code 2. + +If no `debian/tests/control` exists (or it declares no tests), the run exits 0 with a notice — or exit 2 under `--strict`. + +## Inspecting a failed test run + +On failure the test environment is torn down by default. Pass `--shell-on-failure` to drop into an interactive shell inside the test environment when stdout is a TTY (destroyed on shell exit unless `--persistent` was used). + +Test output and logs are exported to a `test/` subdirectory of the build root; the path is printed at the end of the run. + +## Prior build required + +By default `debmagic test` resolves the prior build from the build root (same layout as `debmagic shell`). If no build artifacts are found: +run `debmagic build` first + +Use `--changes` to supply a `.changes` file from an exported output directory instead. + +## Bare driver + +The bare driver runs autopkgtest as root directly on the host. +This violates the no-leak principle for normal use — pass `--allow-host-test` to opt in explicitly. diff --git a/packages/debmagic/README.md b/packages/debmagic/README.md index 8deaf3b..d6a4620 100644 --- a/packages/debmagic/README.md +++ b/packages/debmagic/README.md @@ -3,7 +3,8 @@ Modern, robust & easy tooling for building and packaging [Debian](https://debian.org)/[Ubuntu](https://ubuntu.com) packages — while staying backwards compatible. - **Build any package** in an isolated container environment with `debmagic build` -- **Test and lint** with `debmagic test` and `debmagic check` +- **Run Debian autopkgtest tests** against built packages with `debmagic test` +- **Lint** with `debmagic check` - **Debug** build environments interactively with `debmagic shell` ## Installation @@ -42,6 +43,15 @@ Create a source package (`.dsc`) without compilation: debmagic build source ``` +Run the package's declared Debian autopkgtest tests against a prior build: + +```shell +debmagic build binary --driver docker +debmagic test --driver docker +``` + +Use `--strict` to fail on skipped or undeclared tests (exit code 2). The bare driver requires `--allow-host-test`. + ### Useful options - `--distro ` — select the target distro/release (e.g. `trixie`, `noble`) if the changelog is ambiguous @@ -54,7 +64,7 @@ Any of these can be persisted in a `debmagic.toml` config file instead of repeat ### Inspecting a failed build -Failed builds tear down their environment by default. Build with `--persistent` up front, then attach an interactive shell inside the build environment: +Failed builds tear down their environment by default. Pass `--shell-on-failure` to drop into a shell when stdout is a TTY, or build with `--persistent` and attach afterwards: ```shell debmagic shell diff --git a/packages/debmagic/src/build/artifacts.rs b/packages/debmagic/src/build/artifacts.rs index d91c520..8041b08 100644 --- a/packages/debmagic/src/build/artifacts.rs +++ b/packages/debmagic/src/build/artifacts.rs @@ -1,13 +1,14 @@ use std::{ ffi::OsStr, - fs, + fs, io, path::{Component, Path, PathBuf}, }; use anyhow::{Context, anyhow, bail}; use debian_control::lossless::changes::Changes; -fn changes_file_in(build_dir: &Path) -> anyhow::Result { +/// Locate the single `.changes` file in a build work directory. +pub fn find_changes_file(build_dir: &Path) -> anyhow::Result { let mut paths = fs::read_dir(build_dir) .with_context(|| { format!( @@ -54,7 +55,7 @@ pub fn export_build_artifacts(build_dir: &Path, output_dir: &Path) -> anyhow::Re fs::create_dir_all(output_dir) .with_context(|| format!("failed to create output directory {}", output_dir.display()))?; - let changes_path = changes_file_in(build_dir)?; + let changes_path = find_changes_file(build_dir)?; let changes_metadata = fs::symlink_metadata(&changes_path)?; if !changes_metadata.file_type().is_file() { bail!( @@ -106,6 +107,74 @@ pub fn export_build_artifacts(build_dir: &Path, output_dir: &Path) -> anyhow::Re Ok(exported_changes) } +/// Copy a `.changes` file and every artifact it references into `dest_dir`. +pub fn copy_changes_artifacts(changes_path: &Path, dest_dir: &Path) -> anyhow::Result<()> { + fs::create_dir_all(dest_dir) + .with_context(|| format!("failed to create directory {}", dest_dir.display()))?; + + let changes_metadata = fs::symlink_metadata(changes_path)?; + if !changes_metadata.file_type().is_file() { + bail!( + "changes file {} is not a regular file", + changes_path.display() + ); + } + let source_dir = changes_path.parent().ok_or_else(|| { + anyhow!( + "changes file {} has no parent directory", + changes_path.display() + ) + })?; + let changes = Changes::from_file(changes_path) + .with_context(|| format!("failed to parse {}", changes_path.display()))?; + let files = changes + .files() + .ok_or_else(|| anyhow!("{} has no Files field", changes_path.display()))?; + + for file in files { + let filename = artifact_filename(&file.filename)?; + let source = source_dir.join(filename); + let metadata = fs::symlink_metadata(&source).with_context(|| { + format!( + "artifact {} referenced by {} does not exist", + source.display(), + changes_path.display() + ) + })?; + if !metadata.file_type().is_file() { + bail!("build artifact {} is not a regular file", source.display()); + } + let destination = dest_dir.join(filename); + reject_destination_symlink(&destination)?; + fs::copy(&source, destination) + .with_context(|| format!("failed to copy build artifact {}", source.display()))?; + } + + let changes_filename = changes_path + .file_name() + .ok_or_else(|| anyhow!("invalid .changes path: {}", changes_path.display()))?; + let destination = dest_dir.join(changes_filename); + reject_destination_symlink(&destination)?; + fs::copy(changes_path, &destination) + .with_context(|| format!("failed to copy {}", changes_path.display()))?; + Ok(()) +} + +/// Recursively copy a directory tree. Destination directories are created as needed. +pub(crate) fn copy_dir_all(src: &Path, dst: &Path) -> io::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let dest_path = dst.join(entry.file_name()); + if entry.file_type()?.is_dir() { + copy_dir_all(&entry.path(), &dest_path)?; + } else { + fs::copy(entry.path(), dest_path)?; + } + } + Ok(()) +} + #[cfg(test)] mod tests { use std::os::unix::fs::symlink; @@ -216,4 +285,26 @@ mod tests { fs::remove_dir_all(build_dir).unwrap(); fs::remove_dir_all(output_dir).unwrap(); } + + #[test] + fn copy_dir_all_copies_nested_files() { + let src = test_dir("copy-dir-src"); + let dst = test_dir("copy-dir-dst"); + fs::create_dir(src.join("nested")).unwrap(); + fs::write(src.join("root.txt"), "root").unwrap(); + fs::write(src.join("nested").join("child.txt"), "child").unwrap(); + + copy_dir_all(&src, &dst.join("copied")).unwrap(); + + assert_eq!( + fs::read_to_string(dst.join("copied").join("root.txt")).unwrap(), + "root" + ); + assert_eq!( + fs::read_to_string(dst.join("copied").join("nested").join("child.txt")).unwrap(), + "child" + ); + fs::remove_dir_all(src).unwrap(); + fs::remove_dir_all(dst).unwrap(); + } } diff --git a/packages/debmagic/src/build/common.rs b/packages/debmagic/src/build/common.rs index 796e708..b872eb4 100644 --- a/packages/debmagic/src/build/common.rs +++ b/packages/debmagic/src/build/common.rs @@ -116,6 +116,24 @@ pub enum SourceSyncMode { pub type DriverSpecificBuildMetadata = HashMap; +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnvironmentPurpose { + #[default] + Build, + Test, +} + +impl EnvironmentPurpose { + /// Extra part for environment fingerprints when purpose is not [`Self::Build`]. + pub fn fingerprint_part(self) -> Option<&'static str> { + match self { + Self::Build => None, + Self::Test => Some("test"), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BuildMetadata { pub config: BuildConfig, @@ -153,14 +171,20 @@ pub struct BuildConfig { /// Which source files are staged into the build tree. #[serde(default)] pub source_sync_mode: SourceSyncMode, + #[serde(default)] + pub purpose: EnvironmentPurpose, } impl BuildConfig { pub fn build_identifier(&self) -> String { - format!( + let base = format!( "{}-{}-{}", self.package_identifier, self.distro.distro, self.distro.codename - ) + ); + match self.purpose { + EnvironmentPurpose::Build => base, + EnvironmentPurpose::Test => format!("{base}-test"), + } } pub fn build_work_dir(&self) -> PathBuf { @@ -194,16 +218,28 @@ pub const APT_MIRROR_SCRIPT: &str = include_str!("scripts/mirror.py"); pub trait BuildDriver { fn get_build_metadata(&self) -> DriverSpecificBuildMetadata; - fn run_command_env( + fn run_command( &self, cmd: &[&str], cwd: &Path, requires_root: bool, env_add: &[(&str, &str)], - ) -> std::io::Result<()>; + ) -> std::io::Result; - fn run_command(&self, cmd: &[&str], cwd: &Path, requires_root: bool) -> std::io::Result<()> { - self.run_command_env(cmd, cwd, requires_root, &[]) + fn run_command_checked( + &self, + cmd: &[&str], + cwd: &Path, + requires_root: bool, + env_add: &[(&str, &str)], + ) -> std::io::Result<()> { + let code = self.run_command(cmd, cwd, requires_root, env_add)?; + if code != 0 { + return Err(std::io::Error::other(format!( + "Command failed with exit code: {code}" + ))); + } + Ok(()) } fn cleanup(&self) -> anyhow::Result<()>; @@ -276,15 +312,45 @@ mod tests { sign_key: None, sign_with: crate::build::signing::SignWith::Auto, source_sync_mode: crate::build::common::SourceSyncMode::Tracked, + purpose: EnvironmentPurpose::Build, } } #[test] - fn docker_identifier_replaces_debian_prerelease_tilde() { + fn build_identifier_unchanged_for_build_purpose() { let config = sample_config("debmagic-0.0.1~alpha2"); assert_eq!( config.build_identifier(), "debmagic-0.0.1~alpha2-debian-forky" ); } + + #[test] + fn build_identifier_differs_for_test_purpose() { + let mut config = sample_config("debmagic-0.0.1~alpha2"); + config.purpose = EnvironmentPurpose::Test; + assert_eq!( + config.build_identifier(), + "debmagic-0.0.1~alpha2-debian-forky-test" + ); + assert_ne!( + config.build_identifier(), + sample_config("debmagic-0.0.1~alpha2").build_identifier() + ); + } + + #[test] + fn build_config_without_purpose_deserializes_as_build() { + let json = r#"{ + "driver": "Docker", + "package_identifier": "pkg-1.0", + "build_root_dir": "/tmp/build", + "source_dir": "/tmp/src", + "output_dir": "/tmp/out", + "distro": { "distro": "Debian", "codename": "forky", "version": "15" }, + "sign_package": false + }"#; + let config: BuildConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.purpose, EnvironmentPurpose::Build); + } } diff --git a/packages/debmagic/src/build/driver_bare.rs b/packages/debmagic/src/build/driver_bare.rs index a163023..920f0dc 100644 --- a/packages/debmagic/src/build/driver_bare.rs +++ b/packages/debmagic/src/build/driver_bare.rs @@ -50,13 +50,13 @@ impl BuildDriver for DriverBare { DriverSpecificBuildMetadata::from([]) } - fn run_command_env( + fn run_command( &self, cmd: &[&str], cwd: &Path, requires_root: bool, env_add: &[(&str, &str)], - ) -> std::io::Result<()> { + ) -> std::io::Result { let mut full_cmd: Vec = Vec::new(); let is_root = unsafe { libc::geteuid() == 0 }; @@ -73,15 +73,7 @@ impl BuildDriver for DriverBare { command.envs(env_add.iter().copied()); let status = command.status()?; - - if status.success() { - Ok(()) - } else { - Err(std::io::Error::other(format!( - "Command failed with exit code: {:?}", - status.code() - ))) - } + Ok(status.code().unwrap_or(-1)) } fn cleanup(&self) -> anyhow::Result<()> { diff --git a/packages/debmagic/src/build/driver_docker.rs b/packages/debmagic/src/build/driver_docker.rs index 28d656f..d2e5a5d 100644 --- a/packages/debmagic/src/build/driver_docker.rs +++ b/packages/debmagic/src/build/driver_docker.rs @@ -256,8 +256,12 @@ impl DriverDocker { &uid, &gid, ]); - let desired_fingerprint = - environment_fingerprint(&["docker-container", &image_fingerprint, build_root.as_ref()]); + let mut container_fingerprint_parts = + vec!["docker-container", &image_fingerprint, build_root.as_ref()]; + if let Some(purpose) = config.purpose.fingerprint_part() { + container_fingerprint_parts.push(purpose); + } + let desired_fingerprint = environment_fingerprint(&container_fingerprint_parts); let container_name = resource_name( "debmagic", &config.package_name, @@ -319,8 +323,10 @@ impl DriverDocker { created_container = true; } + // cwd is the build root (the bind mount itself), not the source dir: + // create() must not assume the source tree has been staged yet. let update_result = driver - .run_command(&["apt-get", "update"], &config.build_source_dir(), true) + .run_command_checked(&["apt-get", "update"], &config.build_root_dir, true, &[]) .map_err(|error| anyhow!("Error running apt-get update in container: {error}")); if let Err(error) = update_result { if created_container && let Err(cleanup_error) = driver.container_remove_force() { @@ -360,13 +366,13 @@ impl BuildDriver for DriverDocker { container_name_metadata(&self.container_name) } - fn run_command_env( + fn run_command( &self, cmd: &[&str], cwd: &Path, requires_root: bool, env_add: &[(&str, &str)], - ) -> std::io::Result<()> { + ) -> std::io::Result { let container_path = self .translate_path_in_container(cwd) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; @@ -389,10 +395,7 @@ impl BuildDriver for DriverDocker { exec_cmd.args(cmd); let status = exec_cmd.status()?; - if !status.success() { - return Err(std::io::Error::other("Docker exec failed")); - } - Ok(()) + Ok(status.code().unwrap_or(-1)) } fn cleanup(&self) -> anyhow::Result<()> { diff --git a/packages/debmagic/src/build/driver_lxd.rs b/packages/debmagic/src/build/driver_lxd.rs index b571303..6abe99a 100644 --- a/packages/debmagic/src/build/driver_lxd.rs +++ b/packages/debmagic/src/build/driver_lxd.rs @@ -236,7 +236,7 @@ impl DriverLxd { let host_gid = unsafe { libc::getegid() }.to_string(); let build_root = config.build_root_dir.to_string_lossy(); let proposed_fingerprint = proposed.to_string(); - let desired_fingerprint = environment_fingerprint(&[ + let mut fingerprint_parts = vec![ variant.binary(), ENVIRONMENT_SETUP_VERSION, &base_image, @@ -246,7 +246,11 @@ impl DriverLxd { &host_uid, &host_gid, build_root.as_ref(), - ]); + ]; + if let Some(purpose) = config.purpose.fingerprint_part() { + fingerprint_parts.push(purpose); + } + let desired_fingerprint = environment_fingerprint(&fingerprint_parts); let mut base = Self { variant, @@ -330,10 +334,13 @@ impl DriverLxd { )?; if config.distro.distro == Distro::Ubuntu { - base.exec_in_container(&["cloud-init", "status", "--wait"], None, true, &[]) - .map_err(|e| { - anyhow::anyhow!("Error waiting for cloud-init to finish: {e}") - })?; + base.exec_in_container_checked( + &["cloud-init", "status", "--wait"], + None, + true, + &[], + ) + .map_err(|e| anyhow::anyhow!("Error waiting for cloud-init to finish: {e}"))?; } } @@ -341,14 +348,14 @@ impl DriverLxd { // previous invocation that crashed before finishing this setup (or a // long-lived incremental container with an aging package cache) // doesn't leave `apt-get build-dep` unable to resolve anything. - base.exec_in_container(&["apt-get", "update"], None, true, &[]) + base.exec_in_container_checked(&["apt-get", "update"], None, true, &[]) .map_err(|e| anyhow::anyhow!("Error running apt-get update in container: {e}"))?; if !reusing_container { // Install the base tooling that stock images don't include. // build-dep is intentionally omitted here: build.rs runs it for // every driver against the real mounted source tree. - base.exec_in_container( + base.exec_in_container_checked( &["apt-get", "install", "-y", "dpkg-dev", "python3"], None, true, @@ -362,7 +369,7 @@ impl DriverLxd { uid = BUILD_USER_UID, gid = BUILD_USER_GID, ); - base.exec_in_container(&["sh", "-ec", &ensure_build_user], None, true, &[]) + base.exec_in_container_checked(&["sh", "-ec", &ensure_build_user], None, true, &[]) .map_err(|e| anyhow::anyhow!("Error creating build user in container: {e}"))?; if apt_mirror.is_some() || proposed { @@ -382,9 +389,9 @@ impl DriverLxd { args.push("--proposed".to_string()); } let args = args.iter().map(String::as_str).collect::>(); - base.exec_in_container(&args, None, true, &[]) + base.exec_in_container_checked(&args, None, true, &[]) .map_err(|e| anyhow::anyhow!("Error configuring apt sources: {e}"))?; - base.exec_in_container(&["apt-get", "update"], None, true, &[]) + base.exec_in_container_checked(&["apt-get", "update"], None, true, &[]) .map_err(|e| { anyhow::anyhow!("Error updating configured apt sources: {e}") })?; @@ -481,7 +488,7 @@ impl DriverLxd { workdir: Option<&Path>, as_root: bool, env_add: &[(&str, &str)], - ) -> std::io::Result<()> { + ) -> std::io::Result { println!("[{}] $ {}", self.container_name, cmd.join(" ")); let mut exec_cmd = self.lxd_cmd("exec"); @@ -506,9 +513,20 @@ impl DriverLxd { exec_cmd.args(cmd); let status = exec_cmd.status()?; - if !status.success() { + Ok(status.code().unwrap_or(-1)) + } + + fn exec_in_container_checked( + &self, + cmd: &[&str], + workdir: Option<&Path>, + as_root: bool, + env_add: &[(&str, &str)], + ) -> std::io::Result<()> { + let code = self.exec_in_container(cmd, workdir, as_root, env_add)?; + if code != 0 { return Err(std::io::Error::other(format!( - "{} exec failed", + "{} exec failed with exit code {code}", self.variant.binary() ))); } @@ -525,13 +543,13 @@ impl BuildDriver for DriverLxd { meta } - fn run_command_env( + fn run_command( &self, cmd: &[&str], cwd: &Path, requires_root: bool, env_add: &[(&str, &str)], - ) -> std::io::Result<()> { + ) -> std::io::Result { let container_path = self .translate_path_in_container(cwd) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; @@ -550,7 +568,7 @@ impl BuildDriver for DriverLxd { fn reset_build_root(&self) -> std::io::Result<()> { self.with_running_container(|driver| { - driver.exec_in_container( + driver.exec_in_container_checked( &["find", BUILD_DIR_IN_CONTAINER, "-mindepth", "1", "-delete"], None, true, diff --git a/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index d6729b1..53a252c 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -12,7 +12,7 @@ use crate::build::source::{source_manifest_path, stage_source_tree}; use crate::build_intent::BuildIntent; use crate::{ build::{ - common::{BuildConfig, BuildDriver, BuildDriverType, BuildMetadata}, + common::{BuildConfig, BuildDriver, BuildDriverType, BuildMetadata, EnvironmentPurpose}, config::DriverConfig, driver_bare::DriverBare, driver_docker::DriverDocker, @@ -99,7 +99,7 @@ fn prepare_signing( } } -fn get_build_driver( +pub(crate) fn get_build_driver( config: &BuildConfig, driver_config: &DriverConfig, driver_overrides: &DriverOverrides, @@ -140,7 +140,7 @@ fn get_build_driver( } } -fn create_driver_from_metadata( +pub(crate) fn create_driver_from_metadata( config: &DriverConfig, metadata: &BuildMetadata, ) -> anyhow::Result> { @@ -171,6 +171,59 @@ fn create_driver_from_metadata( driver } +/// Remove `root` from the host. If files are owned by a container user the host +/// cannot delete, delete them from inside that environment first. Never requires +/// host root. +pub(crate) fn remove_environment_root( + root: &Path, + driver_config: &DriverConfig, +) -> anyhow::Result<()> { + if !root.exists() { + return Ok(()); + } + match fs::remove_dir_all(root) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + let metadata_path = root.join("build.json"); + if !metadata_path.is_file() { + return Err(e).with_context(|| { + format!( + "failed to remove {} (permission denied) and no build.json is present to delete files from inside the environment", + root.display() + ) + }); + } + let file = fs::OpenOptions::new() + .read(true) + .open(&metadata_path) + .with_context(|| format!("failed to open {}", metadata_path.display()))?; + let metadata: BuildMetadata = serde_json::from_reader(BufReader::new(&file)) + .with_context(|| format!("failed to parse {}", metadata_path.display()))?; + let driver = + create_driver_from_metadata(driver_config, &metadata).with_context(|| { + format!( + "failed to reattach to the environment at {} to delete privileged files", + root.display() + ) + })?; + driver.reset_build_root().with_context(|| { + format!( + "failed to delete files inside the environment at {}", + root.display() + ) + })?; + fs::remove_dir_all(root).with_context(|| { + format!( + "failed to remove {} after deleting its contents from inside the environment", + root.display() + ) + })?; + Ok(()) + } + Err(e) => Err(e).with_context(|| format!("failed to remove {}", root.display())), + } +} + impl Build { pub fn create( config: &BuildConfig, @@ -280,6 +333,7 @@ fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re persistent: intent.config.driver.persistent, incremental: intent.config.incremental, source_sync_mode: intent.config.source_sync_mode, + purpose: EnvironmentPurpose::Build, }; if intent.config.driver.persistent && build_root.exists() { @@ -309,33 +363,7 @@ fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re return Ok(build); } - if build_root.exists() - && let Err(e) = fs::remove_dir_all(&build_root) - { - if e.kind() == io::ErrorKind::PermissionDenied { - // Some files were created by a privileged user inside a container - // and can't be deleted by the host user directly. Load the previous - // build's driver and ask it to clean up from inside. - let metadata_path = build_root.join("build.json"); - if metadata_path.is_file() - && let Ok(file) = fs::OpenOptions::new().read(true).open(&metadata_path) - && let Ok(metadata) = - serde_json::from_reader::<_, BuildMetadata>(BufReader::new(&file)) - && let Ok(driver) = create_driver_from_metadata(&intent.config.driver, &metadata) - { - let _ = driver.reset_build_root(); - } - fs::remove_dir_all(&build_root).with_context(|| { - format!( - "failed to remove build root {}; try: sudo rm -rf {}", - build_root.display(), - build_root.display() - ) - })?; - } else { - return Err(e.into()); - } - } + remove_environment_root(&build_root, &intent.config.driver)?; build_config .create_dirs() @@ -386,12 +414,11 @@ struct BuildRequest<'a> { /// Shared build orchestration: prepare the environment, run `build_commands` /// in it, export the artifacts to the output dir, sign them if requested, and -/// clean up (dropping into a shell first on failure of an interactive binary -/// build). While `shell_on_failure` is set, a socket server lets concurrent +/// clean up (dropping into a shell first when `--shell-on-failure` is set). +/// While the run is in progress, a socket server lets concurrent /// `debmagic shell` sessions attach to the environment. fn run_build( request: &BuildRequest, - shell_on_failure: bool, build_commands: impl FnOnce(&Build) -> anyhow::Result<()>, ) -> anyhow::Result<()> { let build = prepare_build_env(request.intent, request.target) @@ -426,7 +453,7 @@ fn run_build( }); if let Err(error) = result { - if shell_on_failure && stdout().is_terminal() { + if request.intent.shell_on_failure && stdout().is_terminal() { eprintln!("Build failed: {error}. Dropping into shell..."); if let Err(shell_error) = build .driver @@ -434,8 +461,14 @@ fn run_build( { eprintln!("Dropping into shell failed: {shell_error}"); } + } else if request.intent.shell_on_failure { + eprintln!("Build failed: {error}"); + eprintln!( + "--shell-on-failure is set but stdout is not a TTY; skipping interactive shell" + ); } else { eprintln!("Build failed: {error}"); + eprintln!("Re-run with --shell-on-failure to inspect the build environment"); } if let Err(cleanup_error) = build.driver.cleanup() { eprintln!("Failed to clean up build environment: {cleanup_error}"); @@ -454,11 +487,12 @@ fn run_build( pub fn build_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Result<()> { let request = BuildRequest { intent, target }; - run_build(&request, true, |build| { - build.driver.run_command( + run_build(&request, |build| { + build.driver.run_command_checked( &["apt-get", "-y", "build-dep", "."], &build.config.build_source_dir(), true, + &[], )?; let inherited_options = std::env::var("DEB_BUILD_OPTIONS").ok(); let options = deb_build_options( @@ -473,7 +507,7 @@ pub fn build_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re dpkg_buildpackage_args.push("-nc"); } dpkg_buildpackage_args.push("-b"); - build.driver.run_command_env( + build.driver.run_command_checked( &dpkg_buildpackage_args, &build.config.build_source_dir(), false, @@ -517,20 +551,23 @@ pub fn build_source_package(intent: &BuildIntent, target: &PackageTarget) -> any } let request = BuildRequest { intent, target }; - run_build(&request, false, |build| { + run_build(&request, |build| { let build_source_dir = build.config.build_source_dir(); if build.config.clean { - build.driver.run_command( + build.driver.run_command_checked( &["apt-get", "-y", "build-dep", "."], &build_source_dir, true, + &[], )?; } let mut args = vec!["dpkg-buildpackage", "-S", "-d", "-us", "-uc", "-ui"]; if !build.config.clean { args.push("-nc"); } - build.driver.run_command(&args, &build_source_dir, false)?; + build + .driver + .run_command_checked(&args, &build_source_dir, false, &[])?; Ok(()) }) .context("failed to build source package") diff --git a/packages/debmagic/src/build/source.rs b/packages/debmagic/src/build/source.rs index c1e5eba..c447e9b 100644 --- a/packages/debmagic/src/build/source.rs +++ b/packages/debmagic/src/build/source.rs @@ -463,6 +463,7 @@ mod tests { persistent: true, incremental: true, source_sync_mode: SourceSyncMode::Worktree, + purpose: crate::build::common::EnvironmentPurpose::Build, }; build_config.create_dirs()?; let initial_entries = source_tree_entries(&source_dir, SourceSyncMode::Worktree)?; diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index b447abf..f2a3de0 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -32,6 +32,7 @@ pub struct BuildIntentInput { pub clean: Option, pub no_clean: Option, pub source_sync: Option, + pub shell_on_failure: bool, pub driver_overrides: DriverOverrides, } @@ -43,6 +44,7 @@ pub struct BuildIntent { pub source_dir: PathBuf, pub output_dir: PathBuf, pub driver: BuildDriverType, + pub shell_on_failure: bool, pub config: Config, pub driver_overrides: DriverOverrides, } @@ -128,6 +130,7 @@ pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result anyhow::Result<()> { + let dir = std::env::temp_dir(); + let mut input = base_input(dir); + input.shell_on_failure = true; + + let intent = resolve_build_intent(input)?; + assert!(intent.shell_on_failure); + Ok(()) + } + #[test] fn resolve_keeps_docker_base_image_override() -> anyhow::Result<()> { let dir = std::env::temp_dir(); diff --git a/packages/debmagic/src/cli.rs b/packages/debmagic/src/cli.rs index 6551fb3..1484902 100644 --- a/packages/debmagic/src/cli.rs +++ b/packages/debmagic/src/cli.rs @@ -19,7 +19,7 @@ pub enum Commands { Build(Box), #[command(about = "Open an interactive shell to the currently active build environment")] Shell(ShellSubcommandArgs), - #[command(about = "Run tests")] + #[command(about = "Run the package's declared Debian autopkgtest tests against a prior build")] Test(TestSubcommandArgs), #[command(about = "Check the project")] Check(CheckSubcommandArgs), @@ -147,6 +147,13 @@ pub struct CommonBuildArgs { )] pub no_clean: Option, + #[arg( + long = "shell-on-failure", + action = clap::ArgAction::SetTrue, + help = "On build failure, drop into an interactive shell in the build environment when stdout is a TTY" + )] + pub shell_on_failure: bool, + #[command(flatten)] pub common: CommonCli, @@ -200,6 +207,68 @@ pub struct ShellSubcommandArgs { #[derive(Args, Debug)] pub struct TestSubcommandArgs { + #[arg( + short, + long, + help = "Build driver type for the test environment. Defaults to the driver recorded in the prior build's build.json." + )] + pub driver: Option, + + #[arg(long, action = clap::ArgAction::SetTrue, help = "Keep the test environment for reuse after the test run finishes")] + pub persistent: Option, + + #[command(flatten)] + pub docker: DockerArgs, + + #[command(flatten)] + pub lxd: LxdArgs, + + #[arg( + long = "apt-mirror", + help = "Apt mirror URL to use inside the test environment instead of the default archive mirrors. Ignored by the bare driver." + )] + pub apt_mirror: Option, + + #[arg( + long, + action = clap::ArgAction::SetTrue, + help = "Also enable the '-proposed' pocket in the test environment. Ignored by the bare driver." + )] + pub proposed: Option, + + #[arg( + long, + help = "Override the target distribution for the test environment. Defaults to the distro recorded in the prior build's build.json, not the changelog." + )] + pub distro: Option, + + #[arg( + long, + action = clap::ArgAction::SetTrue, + help = "Treat skipped tests and 'no tests declared' as failures (exit code 2)" + )] + pub strict: bool, + + #[arg( + long, + help = "Path to a .changes file whose directory supplies the built .debs (for pipeline use)" + )] + pub changes: Option, + + #[arg( + long, + action = clap::ArgAction::SetTrue, + help = "Allow running tests with the bare driver, which executes autopkgtest as root on the host" + )] + pub allow_host_test: bool, + + #[arg( + long = "shell-on-failure", + action = clap::ArgAction::SetTrue, + help = "On test failure, drop into an interactive shell in the test environment when stdout is a TTY" + )] + pub shell_on_failure: bool, + #[command(flatten)] pub common: CommonCli, } diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index 5efad7d..81c80f0 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -1,4 +1,5 @@ use std::env; +use std::process::ExitCode; use anyhow::Context; use clap::{CommandFactory, Parser}; @@ -12,6 +13,7 @@ use crate::{ build_intent::{BuildIntentInput, load_config, resolve_build_intent}, cli::{BuildTarget, Cli, Commands}, package::{load_package_identity, resolve_package_target}, + test::{TestIntentInput, TestOutcome, resolve_test_intent, run_test}, }; pub mod build; @@ -19,8 +21,19 @@ pub mod build_intent; pub mod cli; pub mod config; pub mod package; +pub mod test; -fn main() -> anyhow::Result<()> { +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(error) => { + eprintln!("{error:?}"); + ExitCode::FAILURE + } + } +} + +fn run() -> anyhow::Result { let cli = Cli::parse(); let current_dir = env::current_dir()?; @@ -61,6 +74,7 @@ fn main() -> anyhow::Result<()> { clean: build_args.clean, no_clean: build_args.no_clean, source_sync: build_args.source_sync, + shell_on_failure: build_args.shell_on_failure, driver_overrides: DriverOverrides { apt_mirror: build_args.apt_mirror.clone(), proposed: build_args.proposed, @@ -93,8 +107,38 @@ fn main() -> anyhow::Result<()> { let identity = load_package_identity(&source_dir)?; get_shell_in_build(&config, &identity)?; } - Commands::Test(_args) => { - println!("Test subcommand! - not implemented"); + Commands::Test(args) => { + let intent = resolve_test_intent(TestIntentInput { + fallback_dir: current_dir.clone(), + source_dir: args.common.source_dir.clone(), + config_file: cli.config.clone(), + driver: args.driver, + persistent: args.persistent, + strict: args.strict, + changes: args.changes.clone(), + allow_host_test: args.allow_host_test, + shell_on_failure: args.shell_on_failure, + distro: args.distro.clone(), + driver_overrides: DriverOverrides { + apt_mirror: args.apt_mirror.clone(), + proposed: args.proposed, + docker: DriverDockerConfigOverrides { + base_image: args.docker.base_image.clone(), + }, + bare: DriverBareConfigOverrides {}, + lxd: DriverLxdConfigOverrides { + base_image: args.lxd.base_image.clone(), + project: args.lxd.project.clone(), + }, + }, + })?; + + let outcome = run_test(&intent).context("running tests failed")?; + return Ok(match outcome { + TestOutcome::Passed => ExitCode::SUCCESS, + TestOutcome::Failed => ExitCode::from(1), + TestOutcome::StrictFailure => ExitCode::from(2), + }); } Commands::Check(_args) => { println!("Check subcommand! - not implemented"); @@ -105,5 +149,5 @@ fn main() -> anyhow::Result<()> { } } - Ok(()) + Ok(ExitCode::SUCCESS) } diff --git a/packages/debmagic/src/test/intent.rs b/packages/debmagic/src/test/intent.rs new file mode 100644 index 0000000..3f27c92 --- /dev/null +++ b/packages/debmagic/src/test/intent.rs @@ -0,0 +1,154 @@ +use std::path::PathBuf; + +use anyhow::Context; + +use crate::{ + build::{common::BuildDriverType, config::DriverOverrides}, + build_intent::load_config, + config::Config, +}; + +/// Clap-free inputs for resolving a [`TestIntent`]. +#[derive(Debug, Clone)] +pub struct TestIntentInput { + /// Directory used when `source_dir` is unset (typically cwd). + pub fallback_dir: PathBuf, + pub source_dir: Option, + pub config_file: Option, + pub driver: Option, + pub persistent: Option, + pub strict: bool, + pub changes: Option, + pub allow_host_test: bool, + pub shell_on_failure: bool, + pub distro: Option, + pub driver_overrides: DriverOverrides, +} + +/// Fully resolved description of *how* a TestRun executes. +/// +/// Does not include *which* artifacts are being tested. +#[derive(Debug, Clone)] +pub struct TestIntent { + pub source_dir: PathBuf, + pub driver: Option, + pub strict: bool, + pub changes: Option, + pub allow_host_test: bool, + pub shell_on_failure: bool, + pub distro: Option, + pub config: Config, + pub driver_overrides: DriverOverrides, +} + +pub fn resolve_test_intent(input: TestIntentInput) -> anyhow::Result { + let source_dir = std::path::absolute(input.source_dir.unwrap_or(input.fallback_dir)) + .context("resolving source dir failed")?; + + let mut config = load_config(Some(&source_dir), input.config_file.as_deref())?; + + if let Some(persistent) = input.persistent { + config.driver.persistent = persistent; + } + + let changes = if let Some(changes) = input.changes { + Some(std::path::absolute(changes).context("resolving --changes path failed")?) + } else { + None + }; + + Ok(TestIntent { + source_dir, + driver: input.driver, + strict: input.strict, + changes, + allow_host_test: input.allow_host_test, + shell_on_failure: input.shell_on_failure, + distro: input.distro, + config, + driver_overrides: input.driver_overrides, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::build::{ + driver_bare::DriverBareConfigOverrides, driver_docker::DriverDockerConfigOverrides, + driver_lxd::DriverLxdConfigOverrides, + }; + + fn asset_config() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("assets") + .join("config1.toml") + } + + fn base_input(fallback: PathBuf) -> TestIntentInput { + TestIntentInput { + fallback_dir: fallback, + source_dir: None, + config_file: Some(asset_config()), + driver: None, + persistent: None, + strict: false, + changes: None, + allow_host_test: false, + shell_on_failure: false, + distro: None, + driver_overrides: DriverOverrides { + apt_mirror: None, + proposed: None, + docker: DriverDockerConfigOverrides { base_image: None }, + bare: DriverBareConfigOverrides {}, + lxd: DriverLxdConfigOverrides { + base_image: None, + project: None, + }, + }, + } + } + + #[test] + fn resolve_applies_persistent_override() -> anyhow::Result<()> { + let dir = std::env::temp_dir(); + let mut input = base_input(dir); + input.persistent = Some(false); + + let intent = resolve_test_intent(input)?; + assert!(!intent.config.driver.persistent); + Ok(()) + } + + #[test] + fn resolve_passes_through_strict() -> anyhow::Result<()> { + let dir = std::env::temp_dir(); + let mut input = base_input(dir); + input.strict = true; + + let intent = resolve_test_intent(input)?; + assert!(intent.strict); + Ok(()) + } + + #[test] + fn resolve_passes_through_shell_on_failure() -> anyhow::Result<()> { + let dir = std::env::temp_dir(); + let mut input = base_input(dir); + input.shell_on_failure = true; + + let intent = resolve_test_intent(input)?; + assert!(intent.shell_on_failure); + Ok(()) + } + + #[test] + fn resolve_absolutizes_source_dir() -> anyhow::Result<()> { + let dir = std::env::temp_dir(); + let intent = resolve_test_intent(base_input(dir.clone()))?; + assert!(intent.source_dir.is_absolute()); + assert_eq!(intent.source_dir, std::path::absolute(&dir)?); + Ok(()) + } +} diff --git a/packages/debmagic/src/test/mod.rs b/packages/debmagic/src/test/mod.rs new file mode 100644 index 0000000..969549b --- /dev/null +++ b/packages/debmagic/src/test/mod.rs @@ -0,0 +1,7 @@ +//! Run a package's declared Debian autopkgtest tests. + +pub mod intent; +mod run; + +pub use intent::{TestIntent, TestIntentInput, resolve_test_intent}; +pub use run::{TestOutcome, run_test}; diff --git a/packages/debmagic/src/test/run.rs b/packages/debmagic/src/test/run.rs new file mode 100644 index 0000000..75b1813 --- /dev/null +++ b/packages/debmagic/src/test/run.rs @@ -0,0 +1,470 @@ +use std::{ + fs, io, + io::{BufReader, IsTerminal, stdout}, + path::{Path, PathBuf}, +}; + +use super::intent::TestIntent; +use crate::build::config::DriverOverrides; +use crate::build::source::stage_source_tree; +use crate::package::PackageIdentity; +use crate::{ + build::{ + artifacts::{copy_changes_artifacts, copy_dir_all, find_changes_file}, + common::{BuildConfig, BuildDriver, BuildDriverType, BuildMetadata, EnvironmentPurpose}, + config::DriverConfig, + remove_environment_root, + signing::SignWith, + }, + package::load_package_identity, +}; +use anyhow::{Context, anyhow, bail}; +use debmagic_common::distro::DistroVersion; + +/// autopkgtest(1) exit status values (Debian autopkgtest 6.x). +/// Some codes combine categories (e.g. 6 = 4|2); treat them as bitmasks where noted. +pub const AUTOPKGTEST_EXIT_PASS: i32 = 0; +pub const AUTOPKGTEST_EXIT_SKIP: i32 = 2; +pub const AUTOPKGTEST_EXIT_FAIL: i32 = 4; +pub const AUTOPKGTEST_EXIT_NO_TESTS: i32 = 8; +pub const AUTOPKGTEST_EXIT_ERRONEOUS_PKG: i32 = 12; +pub const AUTOPKGTEST_EXIT_TESTBED_FAILURE: i32 = 16; +pub const AUTOPKGTEST_EXIT_OTHER: i32 = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestOutcome { + Passed, + Failed, + StrictFailure, +} + +struct TestRun { + config: BuildConfig, + driver: Box, +} + +fn get_build_root_and_identifier( + temp_build_dir: &Path, + identity: &PackageIdentity, +) -> (String, PathBuf) { + let package_identifier = format!("{}-{}", identity.name, identity.version); + let build_root = temp_build_dir.join(&package_identifier); + (package_identifier, build_root) +} + +fn test_build_root(build_root: &Path) -> PathBuf { + let package_identifier = build_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + build_root.with_file_name(format!("{package_identifier}-test")) +} + +fn map_autopkgtest_exit(exit_code: i32, strict: bool) -> TestOutcome { + if exit_code == AUTOPKGTEST_EXIT_PASS { + return TestOutcome::Passed; + } + if exit_code < 0 { + return TestOutcome::Failed; + } + + let has_fail = (exit_code & AUTOPKGTEST_EXIT_FAIL) != 0 + || exit_code == AUTOPKGTEST_EXIT_TESTBED_FAILURE + || exit_code == AUTOPKGTEST_EXIT_OTHER + || exit_code == AUTOPKGTEST_EXIT_ERRONEOUS_PKG; + if has_fail { + return TestOutcome::Failed; + } + + let has_skip = (exit_code & AUTOPKGTEST_EXIT_SKIP) != 0; + let has_no_tests = (exit_code & AUTOPKGTEST_EXIT_NO_TESTS) != 0; + if strict && (has_skip || has_no_tests) { + return TestOutcome::StrictFailure; + } + + TestOutcome::Passed +} + +fn lookup_distro(name: &str) -> anyhow::Result { + debmagic_common::distro::get_distro_version(name) + .ok_or_else(|| anyhow!("unknown distro codename '{name}'")) +} + +fn load_build_metadata(build_root: &Path) -> anyhow::Result { + let build_metadata_path = build_root.join("build.json"); + if !build_metadata_path.is_file() { + bail!("No build.json found"); + } + let file = fs::OpenOptions::new() + .read(true) + .open(&build_metadata_path)?; + let reader = BufReader::new(&file); + serde_json::from_reader(reader).with_context(|| { + format!( + "Failed to read build metadata from {} - invalid json", + build_metadata_path.display() + ) + }) +} + +impl TestRun { + fn create( + config: &BuildConfig, + driver_config: &DriverConfig, + driver_overrides: &DriverOverrides, + ) -> anyhow::Result { + let driver = crate::build::get_build_driver(config, driver_config, driver_overrides) + .context(format!("failed to create {:?} build driver", config.driver))?; + Ok(Self { + config: config.clone(), + driver, + }) + } + + fn write_metadata(&self) -> anyhow::Result<()> { + let metadata = BuildMetadata { + config: self.config.clone(), + driver_metadata: self.driver.get_build_metadata(), + }; + let path = self.config.build_root_dir.join("build.json"); + let json = serde_json::to_string_pretty(&metadata) + .context("Failed to serialize build metadata")?; + fs::write(path, json)?; + Ok(()) + } +} + +fn prepare_test_env( + intent: &TestIntent, + test_config: &BuildConfig, + identity: &PackageIdentity, + changes_path: &Path, +) -> anyhow::Result { + let test_root = &test_config.build_root_dir; + + if intent.config.driver.persistent && test_root.exists() { + let test_run = + TestRun::create(test_config, &intent.config.driver, &intent.driver_overrides).context( + format!("failed to create {:?} build driver", test_config.driver), + )?; + test_run + .driver + .reset_build_root() + .context("failed to reset persistent test directory")?; + test_config + .create_dirs() + .context("failed to create test directories")?; + stage_source_tree(test_config, identity)?; + copy_changes_artifacts(changes_path, &test_config.build_work_dir())?; + return Ok(test_run); + } + + remove_environment_root(test_root, &intent.config.driver)?; + + test_config + .create_dirs() + .context("failed to create test directories")?; + stage_source_tree(test_config, identity)?; + copy_changes_artifacts(changes_path, &test_config.build_work_dir())?; + + let test_run = TestRun::create(test_config, &intent.config.driver, &intent.driver_overrides)?; + Ok(test_run) +} + +fn print_autopkgtest_notices(exit_code: i32, summary_path: &Path) { + match fs::read_to_string(summary_path) { + Ok(summary) if !summary.trim().is_empty() => { + eprintln!("autopkgtest summary:"); + for line in summary.lines() { + eprintln!(" {line}"); + } + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + eprintln!( + "failed to read autopkgtest summary from {}: {error}", + summary_path.display() + ); + } + } + + if (exit_code & AUTOPKGTEST_EXIT_NO_TESTS) != 0 || exit_code == AUTOPKGTEST_EXIT_NO_TESTS { + eprintln!( + "WARNING: autopkgtest reported no tests declared in this package (exit code {exit_code})" + ); + } +} + +pub fn run_test(intent: &TestIntent) -> anyhow::Result { + let identity = load_package_identity(&intent.source_dir)?; + let (package_identifier, build_root) = + get_build_root_and_identifier(&intent.config.temp_build_dir, &identity); + + let changes_path = if let Some(ref explicit) = intent.changes { + if !explicit.is_file() { + bail!("--changes file {} does not exist", explicit.display()); + } + explicit.clone() + } else { + let build_metadata_path = build_root.join("build.json"); + if !build_metadata_path.is_file() { + bail!( + "no prior build found at {}; run `debmagic build binary` first", + build_root.display() + ); + } + find_changes_file(&build_root.join("work"))? + }; + + let prior_build = if build_root.join("build.json").is_file() { + Some(load_build_metadata(&build_root)?) + } else { + None + }; + + let driver = intent + .driver + .or_else(|| prior_build.as_ref().map(|metadata| metadata.config.driver)) + .ok_or_else(|| { + anyhow!( + "no driver specified and no prior build found; pass --driver or run `debmagic build binary` first" + ) + })?; + + if driver == BuildDriverType::Bare && !intent.allow_host_test { + bail!( + "the bare driver runs autopkgtest as root directly on the host; \ + pass --allow-host-test to opt in explicitly" + ); + } + + let distro = if let Some(ref override_distro) = intent.distro { + lookup_distro(override_distro)? + } else if let Some(ref metadata) = prior_build { + metadata.config.distro.clone() + } else { + bail!( + "no prior build metadata found; pass --distro when using --changes without a build root" + ); + }; + + let test_root = test_build_root(&build_root); + let output_dir = prior_build + .as_ref() + .map(|metadata| metadata.config.output_dir.clone()) + .unwrap_or_else(|| intent.source_dir.clone()); + + let test_config = BuildConfig { + driver, + package_name: identity.name.clone(), + package_identifier, + source_dir: intent.source_dir.clone(), + output_dir, + build_root_dir: test_root.clone(), + distro, + sign_package: false, + sign_with: SignWith::Auto, + sign_key: None, + build_debug_symbols: false, + clean: false, + persistent: intent.config.driver.persistent, + incremental: false, + source_sync_mode: intent.config.source_sync_mode, + purpose: EnvironmentPurpose::Test, + }; + + let test_run = prepare_test_env(intent, &test_config, &identity, &changes_path) + .context("failed to prepare test environment")?; + test_run + .write_metadata() + .context("failed to write test metadata")?; + + let apt_env = [("DEBIAN_FRONTEND", "noninteractive")]; + test_run.driver.run_command_checked( + &["apt-get", "update"], + &test_config.build_source_dir(), + true, + &apt_env, + )?; + test_run.driver.run_command_checked( + &["apt-get", "install", "-y", "autopkgtest"], + &test_config.build_source_dir(), + true, + &apt_env, + )?; + + let work_dir = test_config.build_work_dir(); + let changes_filename = changes_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("invalid .changes path: {}", changes_path.display()))?; + let source_tree_name = test_config.package_identifier.as_str(); + let autopkgtest_out_host = test_root.join("autopkgtest-out"); + if autopkgtest_out_host.exists() { + fs::remove_dir_all(&autopkgtest_out_host)?; + } + let output_dir_arg = "../autopkgtest-out"; + let summary_arg = "../autopkgtest-out/summary"; + + // Binary-only builds have no .dsc in the .changes; pass the staged source + // tree alongside the .changes so debian/tests/ is found without rebuilding + // (-B). See autopkgtest(1) "TESTING A DEBIAN PACKAGE" (.changes + tree). + let autopkgtest_cmd = [ + "autopkgtest", + "-B", + "--no-auto-control", + &format!("--output-dir={output_dir_arg}"), + &format!("--summary={summary_arg}"), + changes_filename, + &format!("{source_tree_name}/"), + "--", + "null", + ]; + + let exit_code = test_run + .driver + .run_command(&autopkgtest_cmd, &work_dir, true, &[]) + .unwrap_or(-1); + + let summary_path = autopkgtest_out_host.join("summary"); + print_autopkgtest_notices(exit_code, &summary_path); + + let export_root = prior_build + .as_ref() + .map(|_| build_root.as_path()) + .unwrap_or_else(|| changes_path.parent().unwrap()); + let exported_test_dir = export_root.join("test"); + if exported_test_dir.exists() { + fs::remove_dir_all(&exported_test_dir)?; + } + copy_dir_all(&autopkgtest_out_host, &exported_test_dir).with_context(|| { + format!( + "failed to copy test output to {}", + exported_test_dir.display() + ) + })?; + println!("Test output written to {}", exported_test_dir.display()); + + let outcome = map_autopkgtest_exit(exit_code, intent.strict); + + if outcome == TestOutcome::Failed { + eprintln!("Tests failed (autopkgtest exit code {exit_code})."); + eprintln!("Test logs: {}", exported_test_dir.display()); + if intent.shell_on_failure && stdout().is_terminal() { + eprintln!("Dropping into shell..."); + if let Err(shell_error) = test_run + .driver + .interactive_shell(&test_config.build_source_dir()) + { + eprintln!("Dropping into shell failed: {shell_error}"); + } + } else if intent.shell_on_failure { + eprintln!( + "--shell-on-failure is set but stdout is not a TTY; skipping interactive shell" + ); + } else { + eprintln!("Re-run with --shell-on-failure to inspect the test environment"); + } + } + + if !test_config.persistent { + // Clear container-owned files from the bind mount before destroying + // the container; otherwise the host user cannot remove them later. + if let Err(e) = test_run.driver.reset_build_root() { + eprintln!("Warning: failed to reset test root before cleanup: {e}"); + } + } + + if let Err(cleanup_error) = test_run.driver.cleanup() { + eprintln!("Failed to clean up test environment: {cleanup_error}"); + } + + Ok(outcome) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::package::PackageIdentity; + use debmagic_common::debian::version::PackageVersion; + + fn sample_identity() -> PackageIdentity { + PackageIdentity { + name: "pkg".to_string(), + version: PackageVersion::new(None, "1.0".to_string(), Some("1".to_string())), + source_dir: PathBuf::from("/src"), + } + } + + #[test] + fn test_build_root_appends_test_suffix() { + let (_, build_root) = + get_build_root_and_identifier(Path::new("/tmp/debmagic"), &sample_identity()); + assert_eq!(build_root, PathBuf::from("/tmp/debmagic/pkg-1.0-1")); + assert_eq!( + test_build_root(&build_root), + PathBuf::from("/tmp/debmagic/pkg-1.0-1-test") + ); + } + + #[test] + fn map_exit_pass() { + assert_eq!( + map_autopkgtest_exit(AUTOPKGTEST_EXIT_PASS, false), + TestOutcome::Passed + ); + assert_eq!( + map_autopkgtest_exit(AUTOPKGTEST_EXIT_PASS, true), + TestOutcome::Passed + ); + } + + #[test] + fn map_exit_fail_and_testbed_failure() { + for code in [ + AUTOPKGTEST_EXIT_FAIL, + 6, + AUTOPKGTEST_EXIT_ERRONEOUS_PKG, + 14, + AUTOPKGTEST_EXIT_TESTBED_FAILURE, + AUTOPKGTEST_EXIT_OTHER, + ] { + assert_eq!( + map_autopkgtest_exit(code, false), + TestOutcome::Failed, + "code {code}" + ); + assert_eq!( + map_autopkgtest_exit(code, true), + TestOutcome::Failed, + "code {code} strict" + ); + } + } + + #[test] + fn map_exit_spawn_failure_is_failed() { + assert_eq!(map_autopkgtest_exit(-1, false), TestOutcome::Failed); + } + + #[test] + fn map_exit_skip_and_no_tests_respects_strict() { + assert_eq!( + map_autopkgtest_exit(AUTOPKGTEST_EXIT_SKIP, false), + TestOutcome::Passed + ); + assert_eq!( + map_autopkgtest_exit(AUTOPKGTEST_EXIT_SKIP, true), + TestOutcome::StrictFailure + ); + assert_eq!( + map_autopkgtest_exit(AUTOPKGTEST_EXIT_NO_TESTS, false), + TestOutcome::Passed + ); + assert_eq!( + map_autopkgtest_exit(AUTOPKGTEST_EXIT_NO_TESTS, true), + TestOutcome::StrictFailure + ); + } +} From 4b4e84fc575b847b5758fd0bd268a41035c241fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Thu, 20 Aug 2026 21:44:01 +0200 Subject: [PATCH 2/4] refactor(cli): split out a driver module Cleanly separates out environment and driver specific functionality and configs which can be reused in both build and test modules. --- docs/usage/test.md | 6 +- packages/debmagic/src/build/common.rs | 356 ------------ packages/debmagic/src/build/mod.rs | 343 ++++-------- packages/debmagic/src/build/source.rs | 125 +++-- packages/debmagic/src/build_intent.rs | 20 +- packages/debmagic/src/cli.rs | 13 +- packages/debmagic/src/config.rs | 6 +- .../debmagic/src/{build => driver}/config.rs | 6 +- .../src/{build => driver}/driver_bare.rs | 58 +- .../src/{build => driver}/driver_docker.rs | 111 ++-- .../src/{build => driver}/driver_lxd.rs | 105 ++-- packages/debmagic/src/driver/mod.rs | 509 ++++++++++++++++++ .../src/{build => driver}/scripts/mirror.py | 0 packages/debmagic/src/main.rs | 14 +- packages/debmagic/src/{build => }/signing.rs | 2 +- packages/debmagic/src/test/intent.rs | 12 +- packages/debmagic/src/test/run.rs | 124 ++--- 17 files changed, 928 insertions(+), 882 deletions(-) delete mode 100644 packages/debmagic/src/build/common.rs rename packages/debmagic/src/{build => driver}/config.rs (76%) rename packages/debmagic/src/{build => driver}/driver_bare.rs (62%) rename packages/debmagic/src/{build => driver}/driver_docker.rs (84%) rename packages/debmagic/src/{build => driver}/driver_lxd.rs (90%) create mode 100644 packages/debmagic/src/driver/mod.rs rename packages/debmagic/src/{build => driver}/scripts/mirror.py (100%) rename packages/debmagic/src/{build => }/signing.rs (99%) diff --git a/docs/usage/test.md b/docs/usage/test.md index 1b6876d..790766f 100644 --- a/docs/usage/test.md +++ b/docs/usage/test.md @@ -26,11 +26,11 @@ The driver *is* the testbed: `autopkgtest` runs with the `null` backend inside t | Option | Description | |---|---| -| `--driver <...>` | Test environment driver (defaults to the driver recorded in the prior build's `build.json`) | +| `--driver <...>` | Test environment driver (defaults to the driver recorded in the prior build's `environment.json`) | | `--persistent` | Retain the test environment after the run for reattach/debug | | `--strict` | Treat skipped tests and "no tests declared" as failures (exit code 2) | | `--changes ` | Path to a `.changes` file whose directory supplies the built `.debs` (for pipeline use) | -| `--distro ` | Override the target distro for the test environment (defaults to the prior build's distro from `build.json`, not the changelog) | +| `--distro ` | Override the target distro for the test environment (defaults to the prior build's distro from `environment.json`, not the changelog) | | `--proposed` | Enable the `-proposed` pocket in the test environment | | `--apt-mirror ` | Mirror URL (same as [`debmagic build`](build.md)) | | `--source-dir ` | Directory containing the `debian/` package directory | @@ -41,7 +41,7 @@ Driver-specific flags (`--driver-docker-base-image`, `--driver-lxd-*`) mirror `d ## Picking a driver -Use the same drivers as for builds. Pass `--driver` explicitly (or rely on the driver recorded in the prior build's `build.json`): +Use the same drivers as for builds. Pass `--driver` explicitly (or rely on the driver recorded in the prior build's `environment.json`): | Driver | Isolation | |---|---| diff --git a/packages/debmagic/src/build/common.rs b/packages/debmagic/src/build/common.rs deleted file mode 100644 index b872eb4..0000000 --- a/packages/debmagic/src/build/common.rs +++ /dev/null @@ -1,356 +0,0 @@ -use std::{ - collections::HashMap, - fmt::Debug, - fs, - path::{Path, PathBuf}, - process::Command, -}; - -use anyhow::Context; -use clap::ValueEnum; -use debmagic_common::distro::DistroVersion; -use serde::{Deserialize, Serialize}; - -/// Path at which the build root is bind-mounted inside container-based -/// drivers (Docker, LXD, Incus). -pub const BUILD_DIR_IN_CONTAINER: &str = "/debmagic"; - -/// Rewrite a path inside the host's build root to the equivalent path inside -/// a container that has it bind-mounted at [`BUILD_DIR_IN_CONTAINER`]. -pub fn translate_path_in_container( - build_root_dir: &Path, - path_in_source: &Path, -) -> std::io::Result { - path_in_source - .strip_prefix(build_root_dir) - .map(|rel| Path::new(BUILD_DIR_IN_CONTAINER).join(rel)) - .map_err(|_| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - "Path is not relative to build root".to_string(), - ) - }) -} - -/// Run `cmd`, failing with `context` (and, on a clean but unsuccessful exit, -/// its exit status) if it can't be spawned or exits unsuccessfully. -pub fn run_checked(cmd: &mut Command, context: &str) -> anyhow::Result<()> { - let status = cmd - .status() - .with_context(|| format!("Error running {context}"))?; - if !status.success() { - anyhow::bail!("{context} failed (exit status: {status})"); - } - Ok(()) -} - -pub fn resource_name(prefix: &str, label: &str, identifier: &str) -> String { - const MAX_LEN: usize = 63; - const HASH_LEN: usize = 16; - - let mut hash = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, identifier.as_bytes()) - .simple() - .to_string(); - hash.truncate(HASH_LEN); - let max_label_len = MAX_LEN - prefix.len() - hash.len() - 2; - let label = label - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character.to_ascii_lowercase() - } else { - '-' - } - }) - .take(max_label_len) - .collect::(); - format!("{prefix}-{label}-{hash}") -} - -pub fn environment_fingerprint(parts: &[&str]) -> String { - uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, parts.join("\0").as_bytes()) - .simple() - .to_string() -} - -/// Metadata key under which container-based drivers store their container's -/// name for later reattachment via `from_build_metadata`. -const CONTAINER_NAME_KEY: &str = "container_name"; - -pub fn container_name_metadata(name: &str) -> DriverSpecificBuildMetadata { - DriverSpecificBuildMetadata::from([(CONTAINER_NAME_KEY.to_string(), name.to_string())]) -} - -pub fn container_name_from_metadata(build_metadata: &BuildMetadata) -> anyhow::Result { - build_metadata - .driver_metadata - .get(CONTAINER_NAME_KEY) - .cloned() - .context("build metadata has no container_name") -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize)] -pub enum BuildDriverType { - Docker, - Bare, - Lxd, - Incus, -} - -/// Selects which files from the source directory are staged into the build tree. -#[derive( - Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize, -)] -#[serde(rename_all = "snake_case")] -pub enum SourceSyncMode { - /// Git-tracked files, including uncommitted modifications. Untracked - /// files are not staged and reported as a warning. - #[default] - Tracked, - /// Like `tracked`, but the build fails if the worktree has uncommitted - /// changes or untracked files. - Committed, - /// All files except git-ignored ones, regardless of git tracking state. - Worktree, -} - -pub type DriverSpecificBuildMetadata = HashMap; - -#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EnvironmentPurpose { - #[default] - Build, - Test, -} - -impl EnvironmentPurpose { - /// Extra part for environment fingerprints when purpose is not [`Self::Build`]. - pub fn fingerprint_part(self) -> Option<&'static str> { - match self { - Self::Build => None, - Self::Test => Some("test"), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuildMetadata { - pub config: BuildConfig, - pub driver_metadata: DriverSpecificBuildMetadata, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuildConfig { - pub driver: BuildDriverType, - - #[serde(default)] - pub package_name: String, - pub package_identifier: String, - pub build_root_dir: PathBuf, - pub source_dir: PathBuf, - pub output_dir: PathBuf, - pub distro: DistroVersion, - pub sign_package: bool, - #[serde(default)] - pub sign_with: crate::build::signing::SignWith, - /// GPG key ID/email to sign with (debsign's `-k` option). - pub sign_key: Option, - /// Build the automatic `-dbgsym` debug symbol package alongside the regular binaries. - #[serde(default)] - pub build_debug_symbols: bool, - /// Run `debian/rules clean` before building. - #[serde(default)] - pub clean: bool, - /// Keep the build environment running after the build. - #[serde(default)] - pub persistent: bool, - /// Synchronize source inputs while preserving build-generated files. - #[serde(default)] - pub incremental: bool, - /// Which source files are staged into the build tree. - #[serde(default)] - pub source_sync_mode: SourceSyncMode, - #[serde(default)] - pub purpose: EnvironmentPurpose, -} - -impl BuildConfig { - pub fn build_identifier(&self) -> String { - let base = format!( - "{}-{}-{}", - self.package_identifier, self.distro.distro, self.distro.codename - ); - match self.purpose { - EnvironmentPurpose::Build => base, - EnvironmentPurpose::Test => format!("{base}-test"), - } - } - - pub fn build_work_dir(&self) -> PathBuf { - self.build_root_dir.join("work") - } - - pub fn build_temp_dir(&self) -> PathBuf { - self.build_root_dir.join("temp") - } - - pub fn build_source_dir(&self) -> PathBuf { - self.build_work_dir().join(&self.package_identifier) - } - - pub fn create_dirs(&self) -> std::io::Result<()> { - fs::create_dir_all(&self.output_dir)?; - fs::create_dir_all(self.build_work_dir())?; - fs::create_dir_all(self.build_temp_dir())?; - fs::create_dir_all(self.build_source_dir())?; - Ok(()) - } -} - -/// Source of a Python script that rewrites the default Debian/Ubuntu apt -/// sources to point at a mirror. Replaces the base image's default sources -/// file(s) outright with one debmagic owns, rather than parsing and -/// patching them in place, defaulting to the release/updates/security -/// pockets it detects from `/etc/os-release`. -pub const APT_MIRROR_SCRIPT: &str = include_str!("scripts/mirror.py"); - -pub trait BuildDriver { - fn get_build_metadata(&self) -> DriverSpecificBuildMetadata; - - fn run_command( - &self, - cmd: &[&str], - cwd: &Path, - requires_root: bool, - env_add: &[(&str, &str)], - ) -> std::io::Result; - - fn run_command_checked( - &self, - cmd: &[&str], - cwd: &Path, - requires_root: bool, - env_add: &[(&str, &str)], - ) -> std::io::Result<()> { - let code = self.run_command(cmd, cwd, requires_root, env_add)?; - if code != 0 { - return Err(std::io::Error::other(format!( - "Command failed with exit code: {code}" - ))); - } - Ok(()) - } - - fn cleanup(&self) -> anyhow::Result<()>; - - fn interactive_shell(&self, cwd: &Path) -> std::io::Result<()>; - - fn driver_type(&self) -> BuildDriverType; - - fn reset_build_root(&self) -> std::io::Result<()>; - - /// Sign `changes_file` (a path on the host) with `debsign` — on the host - /// for the bare driver, or inside a minimal same-distro container with - /// the host's gpg-agent socket forwarded in. `gpg` carries the agent - /// socket and key for container signing; bare ignores it. - fn sign_changes( - &self, - changes_file: &Path, - gpg: Option<&crate::build::signing::GpgForwarding>, - ) -> anyhow::Result<()>; - - fn reused_environment(&self) -> bool { - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use debmagic_common::distro::{Distro, DistroVersion}; - use std::path::PathBuf; - - #[test] - fn resource_names_are_valid_stable_and_distinct() { - let first = resource_name("debmagic", "package", "package-1.0~beta-1:2-debian-forky"); - let second = resource_name("debmagic", "package", "package-1.0-beta-1:2-debian-forky"); - - assert_eq!( - first, - resource_name("debmagic", "package", "package-1.0~beta-1:2-debian-forky") - ); - assert_ne!(first, second); - assert!(first.starts_with("debmagic-package-")); - assert_eq!(first.len(), "debmagic-package-".len() + 16); - assert!(first.len() <= 63); - assert!(first.chars().all(|character| character.is_ascii_lowercase() - || character.is_ascii_digit() - || character == '-')); - } - - fn sample_config(package_identifier: &str) -> BuildConfig { - BuildConfig { - driver: BuildDriverType::Docker, - package_identifier: package_identifier.to_string(), - build_root_dir: PathBuf::from("/tmp"), - source_dir: PathBuf::from("/tmp/src"), - output_dir: PathBuf::from("/tmp/out"), - distro: DistroVersion { - distro: Distro::Debian, - codename: "forky".to_string(), - version: "15".to_string(), - is_devel: false, - }, - sign_package: false, - incremental: false, - build_debug_symbols: false, - clean: false, - persistent: false, - package_name: "debmagic".to_string(), - sign_key: None, - sign_with: crate::build::signing::SignWith::Auto, - source_sync_mode: crate::build::common::SourceSyncMode::Tracked, - purpose: EnvironmentPurpose::Build, - } - } - - #[test] - fn build_identifier_unchanged_for_build_purpose() { - let config = sample_config("debmagic-0.0.1~alpha2"); - assert_eq!( - config.build_identifier(), - "debmagic-0.0.1~alpha2-debian-forky" - ); - } - - #[test] - fn build_identifier_differs_for_test_purpose() { - let mut config = sample_config("debmagic-0.0.1~alpha2"); - config.purpose = EnvironmentPurpose::Test; - assert_eq!( - config.build_identifier(), - "debmagic-0.0.1~alpha2-debian-forky-test" - ); - assert_ne!( - config.build_identifier(), - sample_config("debmagic-0.0.1~alpha2").build_identifier() - ); - } - - #[test] - fn build_config_without_purpose_deserializes_as_build() { - let json = r#"{ - "driver": "Docker", - "package_identifier": "pkg-1.0", - "build_root_dir": "/tmp/build", - "source_dir": "/tmp/src", - "output_dir": "/tmp/out", - "distro": { "distro": "Debian", "codename": "forky", "version": "15" }, - "sign_package": false - }"#; - let config: BuildConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.purpose, EnvironmentPurpose::Build); - } -} diff --git a/packages/debmagic/src/build/mod.rs b/packages/debmagic/src/build/mod.rs index 53a252c..ebf7cf2 100644 --- a/packages/debmagic/src/build/mod.rs +++ b/packages/debmagic/src/build/mod.rs @@ -7,39 +7,36 @@ use std::{ }; use crate::build::attach::{send_socket_command, start_socket_server}; -use crate::build::config::DriverOverrides; use crate::build::source::{source_manifest_path, stage_source_tree}; use crate::build_intent::BuildIntent; +use crate::driver::{ + Driver, DriverInstance, DriverType, Environment, EnvironmentMetadata, EnvironmentPurpose, + config::DriverConfig, create_driver, create_driver_from_metadata, remove_environment_root, +}; use crate::{ - build::{ - common::{BuildConfig, BuildDriver, BuildDriverType, BuildMetadata, EnvironmentPurpose}, - config::DriverConfig, - driver_bare::DriverBare, - driver_docker::DriverDocker, - driver_lxd::{DriverLxd, LxdVariant}, - }, config::Config, package::{PackageIdentity, PackageTarget}, + signing::{self, SignWith}, }; use anyhow::{Context, anyhow}; pub mod artifacts; pub mod attach; -pub mod common; -pub mod config; -pub mod driver_bare; -pub mod driver_docker; -pub mod driver_lxd; -pub mod signing; pub mod source; +pub use source::SourceSyncMode; + struct Build { - config: BuildConfig, - pub driver: Box, + environment: Environment, + driver: DriverInstance, /// Prepared when signing happens inside a container: agent socket + /// sign key, validated before the build starts. gpg_forwarding: Option, attached: bool, + output_dir: PathBuf, + sign_package: bool, + clean: bool, + build_debug_symbols: bool, } /// Where debsign will actually run for this build. @@ -53,14 +50,16 @@ enum SignLocation { /// need *before* the build starts, so a broken gpg setup doesn't waste a /// whole build. fn prepare_signing( - build_config: &BuildConfig, + environment: &Environment, + sign_with: SignWith, + sign_key: Option<&str>, ) -> anyhow::Result<(SignLocation, Option)> { - let container_driver = build_config.driver != BuildDriverType::Bare; + let container_driver = environment.driver != DriverType::Bare; let host_has_debsign = signing::check_host_debsign_available().is_ok(); - let location = match build_config.sign_with { - signing::SignWith::Host => SignLocation::Host, - signing::SignWith::Same => { + let location = match sign_with { + SignWith::Host => SignLocation::Host, + SignWith::Same => { if container_driver { SignLocation::Container } else { @@ -68,7 +67,7 @@ fn prepare_signing( SignLocation::Host } } - signing::SignWith::Auto => { + SignWith::Auto => { if host_has_debsign || !container_driver { SignLocation::Host } else { @@ -83,7 +82,7 @@ fn prepare_signing( Ok((location, None)) } SignLocation::Container => { - let sign_key = build_config.sign_key.clone().ok_or_else(|| { + let sign_key = sign_key.ok_or_else(|| { anyhow!( "signing in a container requires sign_key to be set \ (debsign's maintainer-based key lookup only works on the host)" @@ -91,158 +90,41 @@ fn prepare_signing( })?; let forwarding = signing::GpgForwarding { agent_extra_socket: signing::gpg_agent_extra_socket()?, - sign_key: sign_key.clone(), + sign_key: sign_key.to_string(), }; - signing::check_signing_key_available(&sign_key)?; + signing::check_signing_key_available(sign_key)?; Ok((location, Some(forwarding))) } } } -pub(crate) fn get_build_driver( - config: &BuildConfig, - driver_config: &DriverConfig, - driver_overrides: &DriverOverrides, -) -> anyhow::Result> { - let apt_mirror = driver_overrides - .apt_mirror - .as_deref() - .or(driver_config.apt_mirror.as_deref()); - let proposed = driver_overrides.proposed.unwrap_or(driver_config.proposed); - - match config.driver { - BuildDriverType::Docker => Ok(Box::new(DriverDocker::create( - config, - driver_config, - &driver_overrides.docker, - apt_mirror, - proposed, - )?)), - BuildDriverType::Bare => Ok(Box::new(DriverBare::create( - config, - driver_config, - &driver_overrides.bare, - ))), - BuildDriverType::Lxd | BuildDriverType::Incus => { - let variant = match config.driver { - BuildDriverType::Lxd => LxdVariant::Lxd, - _ => LxdVariant::Incus, - }; - Ok(Box::new(DriverLxd::create( - variant, - config, - driver_config, - &driver_overrides.lxd, - apt_mirror, - proposed, - )?)) - } - } -} - -pub(crate) fn create_driver_from_metadata( - config: &DriverConfig, - metadata: &BuildMetadata, -) -> anyhow::Result> { - let driver: anyhow::Result> = match &metadata.config.driver { - BuildDriverType::Docker => Ok(Box::new(DriverDocker::from_build_metadata( - &metadata.config, - config, - metadata, - )?)), - BuildDriverType::Bare => Ok(Box::new(DriverBare::from_build_metadata( - &metadata.config, - config, - metadata, - ))), - BuildDriverType::Lxd | BuildDriverType::Incus => { - let variant = match metadata.config.driver { - BuildDriverType::Lxd => LxdVariant::Lxd, - _ => LxdVariant::Incus, - }; - Ok(Box::new(DriverLxd::from_build_metadata( - variant, - &metadata.config, - config, - metadata, - )?)) - } - }; - driver -} - -/// Remove `root` from the host. If files are owned by a container user the host -/// cannot delete, delete them from inside that environment first. Never requires -/// host root. -pub(crate) fn remove_environment_root( - root: &Path, - driver_config: &DriverConfig, -) -> anyhow::Result<()> { - if !root.exists() { - return Ok(()); - } - match fs::remove_dir_all(root) { - Ok(()) => Ok(()), - Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { - let metadata_path = root.join("build.json"); - if !metadata_path.is_file() { - return Err(e).with_context(|| { - format!( - "failed to remove {} (permission denied) and no build.json is present to delete files from inside the environment", - root.display() - ) - }); - } - let file = fs::OpenOptions::new() - .read(true) - .open(&metadata_path) - .with_context(|| format!("failed to open {}", metadata_path.display()))?; - let metadata: BuildMetadata = serde_json::from_reader(BufReader::new(&file)) - .with_context(|| format!("failed to parse {}", metadata_path.display()))?; - let driver = - create_driver_from_metadata(driver_config, &metadata).with_context(|| { - format!( - "failed to reattach to the environment at {} to delete privileged files", - root.display() - ) - })?; - driver.reset_build_root().with_context(|| { - format!( - "failed to delete files inside the environment at {}", - root.display() - ) - })?; - fs::remove_dir_all(root).with_context(|| { - format!( - "failed to remove {} after deleting its contents from inside the environment", - root.display() - ) - })?; - Ok(()) - } - Err(e) => Err(e).with_context(|| format!("failed to remove {}", root.display())), - } -} - impl Build { - pub fn create( - config: &BuildConfig, - driver_config: &DriverConfig, - driver_overrides: &DriverOverrides, - ) -> anyhow::Result { - let driver = get_build_driver(config, driver_config, driver_overrides) - .context(format!("failed to create {:?} build driver", config.driver))?; - let gpg_forwarding = if config.sign_package { - let (_location, forwarding) = prepare_signing(config)?; + pub fn create(environment: Environment, intent: &BuildIntent) -> anyhow::Result { + let driver = create_driver( + &environment, + &intent.config.driver, + &intent.driver_overrides, + ) + .context(format!("failed to create {:?} driver", environment.driver))?; + let gpg_forwarding = if intent.config.sign_package { + let (_location, forwarding) = prepare_signing( + &environment, + intent.config.sign_with, + intent.config.sign_key.as_deref(), + )?; forwarding } else { None }; Ok(Self { - config: config.clone(), + environment, driver, gpg_forwarding, attached: false, + output_dir: intent.output_dir.clone(), + sign_package: intent.config.sign_package, + clean: intent.config.clean, + build_debug_symbols: intent.config.build_debug_symbols, }) } @@ -250,22 +132,20 @@ impl Build { build_root: &Path, driver_config: &DriverConfig, ) -> anyhow::Result { - let build_metadata_path = build_root.join("build.json"); - if !build_metadata_path.is_file() { - return Err(anyhow!("No build.json found")); + let metadata_path = build_root.join("environment.json"); + if !metadata_path.is_file() { + return Err(anyhow!("No environment.json found")); } - // read metadata from file - let file = fs::OpenOptions::new() - .read(true) - .open(&build_metadata_path)?; - let metadata = || -> anyhow::Result { + let file = fs::OpenOptions::new().read(true).open(&metadata_path)?; + let metadata = || -> anyhow::Result { let reader = BufReader::new(&file); - let metadata: BuildMetadata = serde_json::from_reader(reader).with_context(|| { - format!( - "Failed to read build metadata from {} - invalid json", - build_metadata_path.display() - ) - })?; + let metadata: EnvironmentMetadata = + serde_json::from_reader(reader).with_context(|| { + format!( + "Failed to read environment metadata from {} - invalid json", + metadata_path.display() + ) + })?; Ok(metadata) }(); @@ -277,14 +157,18 @@ impl Build { Ok(Self { gpg_forwarding: None, - config: metadata.config.clone(), + environment: metadata.environment.clone(), driver, attached, + output_dir: PathBuf::new(), + sign_package: false, + clean: false, + build_debug_symbols: false, }) } pub fn detach(&self) -> anyhow::Result<()> { - let build_root = &self.config.build_root_dir; + let build_root = &self.environment.root_dir; if self.attached { send_socket_command(build_root, "detach")?; } @@ -292,13 +176,13 @@ impl Build { } pub fn write_metadata(&self) -> anyhow::Result<()> { - let metadata = BuildMetadata { - config: self.config.clone(), - driver_metadata: self.driver.get_build_metadata(), + let metadata = EnvironmentMetadata { + environment: self.environment.clone(), + driver_metadata: self.driver.driver_metadata(), }; - let path = self.config.build_root_dir.join("build.json"); + let path = self.environment.root_dir.join("environment.json"); let json = serde_json::to_string_pretty(&metadata) - .context("Failed to serialize build metadata")?; + .context("Failed to serialize environment metadata")?; fs::write(path, json)?; Ok(()) } @@ -317,38 +201,28 @@ fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re let (package_identifier, build_root) = get_build_root_and_identifier(&intent.config.temp_build_dir, &target.identity); - let build_config = BuildConfig { + let environment = Environment { driver: intent.driver, package_name: target.identity.name.clone(), package_identifier, - source_dir: target.identity.source_dir.clone(), - output_dir: intent.output_dir.clone(), - build_root_dir: build_root.clone(), + root_dir: build_root.clone(), distro: target.distro.clone(), - sign_package: intent.config.sign_package, - sign_with: intent.config.sign_with, - sign_key: intent.config.sign_key.clone(), - build_debug_symbols: intent.config.build_debug_symbols, - clean: intent.config.clean, persistent: intent.config.driver.persistent, - incremental: intent.config.incremental, - source_sync_mode: intent.config.source_sync_mode, purpose: EnvironmentPurpose::Build, }; + let output_dir = &intent.output_dir; + let incremental = intent.config.incremental; + if intent.config.driver.persistent && build_root.exists() { // For persistent containers, starting first lets root inside delete // container-owned files the host user can't remove. - let build = Build::create( - &build_config, - &intent.config.driver, - &intent.driver_overrides, - ) - .context(format!("failed to create {:?} build driver", intent.driver))?; - if !intent.config.incremental || !source_manifest_path(&build_config).is_file() { + let build = Build::create(environment.clone(), intent) + .context(format!("failed to create {:?} driver", intent.driver))?; + if !incremental || !source_manifest_path(&environment).is_file() { build .driver - .reset_build_root() + .reset_root() .context("failed to reset persistent build directory")?; } else if !build.driver.reused_environment() { // A fresh environment (e.g. a new CI runner with a restored build @@ -356,27 +230,34 @@ fn prepare_build_env(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re // discards whatever the new toolchain/archive state invalidates. println!("Keeping incremental build tree in a fresh build environment"); } - build_config + fs::create_dir_all(output_dir).context("failed to create output directory")?; + environment .create_dirs() .context("failed to create build directories")?; - stage_source_tree(&build_config, &target.identity)?; + stage_source_tree( + &environment, + &target.identity, + intent.config.source_sync_mode, + incremental, + )?; return Ok(build); } remove_environment_root(&build_root, &intent.config.driver)?; - build_config + fs::create_dir_all(output_dir).context("failed to create output directory")?; + environment .create_dirs() .context("failed to create build directories")?; - stage_source_tree(&build_config, &target.identity)?; - - let build = Build::create( - &build_config, - &intent.config.driver, - &intent.driver_overrides, + stage_source_tree( + &environment, + &target.identity, + intent.config.source_sync_mode, + incremental, )?; - Ok(build) + + Build::create(environment, intent) } pub fn get_shell_in_build(config: &Config, identity: &PackageIdentity) -> anyhow::Result<()> { @@ -385,7 +266,7 @@ pub fn get_shell_in_build(config: &Config, identity: &PackageIdentity) -> anyhow let build = Build::from_build_root(&build_root, &config.driver)?; let result = build .driver - .interactive_shell(&build.config.build_source_dir()); + .interactive_shell(&build.environment.staged_source_dir()); build.detach()?; @@ -425,11 +306,11 @@ fn run_build( .context("failed to prepare build environment")?; build .write_metadata() - .context("failed to write build metadata")?; + .context("failed to write environment metadata")?; let should_exit = Arc::new(Mutex::new(false)); let socket_server_handle = - start_socket_server(&build.config.build_root_dir, should_exit.clone())?; + start_socket_server(&build.environment.root_dir, should_exit.clone())?; let stop_socket_server = || { *should_exit.lock().unwrap() = true; @@ -439,15 +320,14 @@ fn run_build( socket_server_handle.join().ok(); }; + let sign_key = request.intent.config.sign_key.as_deref(); let result = build_commands(&build).and_then(|()| { - let changes_file = artifacts::export_build_artifacts( - &build.config.build_work_dir(), - &build.config.output_dir, - )?; - if build.config.sign_package { + let changes_file = + artifacts::export_build_artifacts(&build.environment.work_dir(), &build.output_dir)?; + if build.sign_package { build .driver - .sign_changes(&changes_file, build.gpg_forwarding.as_ref())?; + .sign_changes(&changes_file, build.gpg_forwarding.as_ref(), sign_key)?; } Ok(()) }); @@ -457,7 +337,7 @@ fn run_build( eprintln!("Build failed: {error}. Dropping into shell..."); if let Err(shell_error) = build .driver - .interactive_shell(&build.config.build_source_dir()) + .interactive_shell(&build.environment.staged_source_dir()) { eprintln!("Dropping into shell failed: {shell_error}"); } @@ -490,18 +370,15 @@ pub fn build_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re run_build(&request, |build| { build.driver.run_command_checked( &["apt-get", "-y", "build-dep", "."], - &build.config.build_source_dir(), + &build.environment.staged_source_dir(), true, &[], )?; let inherited_options = std::env::var("DEB_BUILD_OPTIONS").ok(); - let options = deb_build_options( - inherited_options.as_deref(), - build.config.build_debug_symbols, - ); + let options = deb_build_options(inherited_options.as_deref(), build.build_debug_symbols); let env_add = [("DEB_BUILD_OPTIONS", options.as_str())]; let mut dpkg_buildpackage_args = vec!["dpkg-buildpackage", "-us", "-uc", "-ui"]; - if !build.config.clean { + if !build.clean { // Non-incremental builds already stage a clean source tree, while // incremental builds preserve their outputs intentionally. dpkg_buildpackage_args.push("-nc"); @@ -509,7 +386,7 @@ pub fn build_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Re dpkg_buildpackage_args.push("-b"); build.driver.run_command_checked( &dpkg_buildpackage_args, - &build.config.build_source_dir(), + &build.environment.staged_source_dir(), false, &env_add, )?; @@ -546,28 +423,28 @@ fn check_dpkg_buildpackage_available() -> anyhow::Result<()> { /// If `config.clean` is set, build-dependencies are installed before /// `dpkg-buildpackage` runs `debian/rules clean` once. pub fn build_source_package(intent: &BuildIntent, target: &PackageTarget) -> anyhow::Result<()> { - if intent.driver == BuildDriverType::Bare { + if intent.driver == DriverType::Bare { check_dpkg_buildpackage_available()?; } let request = BuildRequest { intent, target }; run_build(&request, |build| { - let build_source_dir = build.config.build_source_dir(); - if build.config.clean { + let staged_source_dir = build.environment.staged_source_dir(); + if build.clean { build.driver.run_command_checked( &["apt-get", "-y", "build-dep", "."], - &build_source_dir, + &staged_source_dir, true, &[], )?; } let mut args = vec!["dpkg-buildpackage", "-S", "-d", "-us", "-uc", "-ui"]; - if !build.config.clean { + if !build.clean { args.push("-nc"); } build .driver - .run_command_checked(&args, &build_source_dir, false, &[])?; + .run_command_checked(&args, &staged_source_dir, false, &[])?; Ok(()) }) .context("failed to build source package") diff --git a/packages/debmagic/src/build/source.rs b/packages/debmagic/src/build/source.rs index c447e9b..14036b3 100644 --- a/packages/debmagic/src/build/source.rs +++ b/packages/debmagic/src/build/source.rs @@ -16,9 +16,38 @@ use std::{fs, process::Command}; use anyhow::{Context, anyhow, bail}; use glob::glob; -use crate::build::common::{BuildConfig, SourceSyncMode}; +use clap::ValueEnum; + +use crate::driver::Environment; use crate::package::PackageIdentity; +/// Selects which files from the source directory are staged into the build tree. +#[derive( + Debug, + Default, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + ValueEnum, + serde::Deserialize, + serde::Serialize, +)] +#[serde(rename_all = "snake_case")] +pub enum SourceSyncMode { + /// Git-tracked files, including uncommitted modifications. Untracked + /// files are not staged and reported as a warning. + #[default] + Tracked, + /// Like `tracked`, but the build fails if the worktree has uncommitted + /// changes or untracked files. + Committed, + /// All files except git-ignored ones, regardless of git tracking state. + Worktree, +} + /// Paths of files tracked by git in `src`, as reported by `git ls-files`. /// Returns `None` if `src` is not inside a git worktree. fn git_tracked_paths(src: &Path) -> anyhow::Result>> { @@ -313,20 +342,24 @@ fn copy_glob(src_dir: &Path, pattern: &str, dest_dir: &Path) -> anyhow::Result<( Ok(()) } -pub fn source_manifest_path(build_config: &BuildConfig) -> PathBuf { - build_config.build_root_dir.join("source-manifest.json") +pub fn source_manifest_path(environment: &Environment) -> PathBuf { + environment.root_dir.join("source-manifest.json") } -fn write_source_manifest(build_config: &BuildConfig, entries: &[SourcePath]) -> anyhow::Result<()> { - let manifest_path = source_manifest_path(build_config); +fn write_source_manifest(environment: &Environment, entries: &[SourcePath]) -> anyhow::Result<()> { + let manifest_path = source_manifest_path(environment); let temporary_path = manifest_path.with_extension("json.tmp"); fs::write(&temporary_path, serde_json::to_vec_pretty(entries)?)?; fs::rename(temporary_path, manifest_path)?; Ok(()) } -fn sync_source_tree(build_config: &BuildConfig) -> anyhow::Result<()> { - let manifest_path = source_manifest_path(build_config); +fn sync_source_tree( + environment: &Environment, + source_dir: &Path, + source_sync_mode: SourceSyncMode, +) -> anyhow::Result<()> { + let manifest_path = source_manifest_path(environment); let previous: Vec = serde_json::from_reader(BufReader::new( fs::File::open(&manifest_path) .with_context(|| format!("failed to open {}", manifest_path.display()))?, @@ -335,7 +368,7 @@ fn sync_source_tree(build_config: &BuildConfig) -> anyhow::Result<()> { for entry in &previous { validate_source_path(&entry.path)?; } - let current = source_tree_entries(&build_config.source_dir, build_config.source_sync_mode)?; + let current = source_tree_entries(source_dir, source_sync_mode)?; let current_kinds = current .iter() @@ -347,7 +380,7 @@ fn sync_source_tree(build_config: &BuildConfig) -> anyhow::Result<()> { .collect::>(); stale.sort_by_key(|entry| Reverse(entry.path.components().count())); for entry in stale { - let destination = build_config.build_source_dir().join(&entry.path); + let destination = environment.staged_source_dir().join(&entry.path); if entry.kind == SourcePathKind::Directory && !current_kinds.contains_key(entry.path.as_path()) { @@ -365,20 +398,19 @@ fn sync_source_tree(build_config: &BuildConfig) -> anyhow::Result<()> { } } - copy_source_entries( - &build_config.source_dir, - &build_config.build_source_dir(), - ¤t, - )?; - write_source_manifest(build_config, ¤t) + copy_source_entries(source_dir, &environment.staged_source_dir(), ¤t)?; + write_source_manifest(environment, ¤t) } pub fn stage_source_tree( - build_config: &BuildConfig, + environment: &Environment, identity: &PackageIdentity, + source_sync_mode: SourceSyncMode, + incremental: bool, ) -> anyhow::Result<()> { - if build_config.source_sync_mode == SourceSyncMode::Tracked { - let untracked = git_untracked_paths(&build_config.source_dir); + let source_dir = &identity.source_dir; + if source_sync_mode == SourceSyncMode::Tracked { + let untracked = git_untracked_paths(source_dir); if !untracked.is_empty() { eprintln!( "debmagic: warning: {} untracked file(s) not staged into the build tree:", @@ -393,33 +425,29 @@ pub fn stage_source_tree( eprintln!(" git add them or use --source-sync worktree to include them"); } } - if build_config.incremental && source_manifest_path(build_config).is_file() { - sync_source_tree(build_config).context("failed to synchronize source tree")?; + if incremental && source_manifest_path(environment).is_file() { + sync_source_tree(environment, source_dir, source_sync_mode) + .context("failed to synchronize source tree")?; } else { - let entries = source_tree_entries(&build_config.source_dir, build_config.source_sync_mode)?; - copy_source_entries( - &build_config.source_dir, - &build_config.build_source_dir(), - &entries, - ) - .context("failed to copy source tree to build directory")?; - write_source_manifest(build_config, &entries)?; + let entries = source_tree_entries(source_dir, source_sync_mode)?; + copy_source_entries(source_dir, &environment.staged_source_dir(), &entries) + .context("failed to copy source tree to build directory")?; + write_source_manifest(environment, &entries)?; } - let source_parent = build_config - .source_dir + let source_parent = source_dir .parent() .ok_or_else(|| anyhow!("source directory has no parent"))?; let prefix = format!("{}_{}", identity.name, identity.version.upstream_version()); copy_glob( source_parent, &format!("{prefix}.orig.tar.*"), - &build_config.build_work_dir(), + &environment.work_dir(), )?; copy_glob( source_parent, &format!("{prefix}.orig-*.tar.*"), - &build_config.build_work_dir(), + &environment.work_dir(), )?; Ok(()) } @@ -428,7 +456,7 @@ pub fn stage_source_tree( mod tests { use std::os::unix::fs::MetadataExt; - use crate::build::common::BuildDriverType; + use crate::driver::{DriverType, Environment, EnvironmentPurpose}; use super::*; @@ -447,36 +475,27 @@ mod tests { fs::write(source_dir.join("cache/input.c"), "source")?; symlink("changed.txt", source_dir.join("link"))?; - let build_config = BuildConfig { - driver: BuildDriverType::Bare, + let environment = Environment { + driver: DriverType::Bare, package_name: "example".to_string(), package_identifier: "example-1.0".to_string(), - build_root_dir: build_root_dir.clone(), - source_dir: source_dir.clone(), - output_dir: test_root.join("output"), + root_dir: build_root_dir.clone(), distro: debmagic_common::distro::get_distro_version("trixie").unwrap(), - sign_package: false, - sign_with: crate::build::signing::SignWith::default(), - sign_key: None, - build_debug_symbols: false, - clean: false, persistent: true, - incremental: true, - source_sync_mode: SourceSyncMode::Worktree, - purpose: crate::build::common::EnvironmentPurpose::Build, + purpose: EnvironmentPurpose::Build, }; - build_config.create_dirs()?; + environment.create_dirs()?; let initial_entries = source_tree_entries(&source_dir, SourceSyncMode::Worktree)?; copy_source_entries( &source_dir, - &build_config.build_source_dir(), + &environment.staged_source_dir(), &initial_entries, )?; - write_source_manifest(&build_config, &initial_entries)?; + write_source_manifest(&environment, &initial_entries)?; let unchanged_inode = - fs::metadata(build_config.build_source_dir().join("unchanged.txt"))?.ino(); + fs::metadata(environment.staged_source_dir().join("unchanged.txt"))?.ino(); fs::write( - build_config.build_source_dir().join("cache/output.o"), + environment.staged_source_dir().join("cache/output.o"), "compiled", )?; @@ -488,9 +507,9 @@ mod tests { symlink("added.txt", source_dir.join("link"))?; fs::write(source_dir.join("added.txt"), "new")?; - sync_source_tree(&build_config)?; + sync_source_tree(&environment, &source_dir, SourceSyncMode::Worktree)?; - let staged = build_config.build_source_dir(); + let staged = environment.staged_source_dir(); assert_eq!(fs::read_to_string(staged.join("changed.txt"))?, "after"); assert_eq!(fs::read_to_string(staged.join("added.txt"))?, "new"); assert_eq!( diff --git a/packages/debmagic/src/build_intent.rs b/packages/debmagic/src/build_intent.rs index f2a3de0..533867b 100644 --- a/packages/debmagic/src/build_intent.rs +++ b/packages/debmagic/src/build_intent.rs @@ -3,12 +3,10 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use crate::{ - build::{ - common::{BuildDriverType, SourceSyncMode}, - config::DriverOverrides, - signing::SignWith, - }, + build::source::SourceSyncMode, config::Config, + driver::{DriverType, config::DriverOverrides}, + signing::SignWith, }; /// Clap-free inputs for resolving a [`BuildIntent`]. @@ -19,7 +17,7 @@ pub struct BuildIntentInput { pub source_dir: Option, pub output_dir: Option, pub config_file: Option, - pub driver: BuildDriverType, + pub driver: DriverType, pub persistent: Option, pub incremental: Option, /// Force incremental off (e.g. source-only builds). @@ -43,7 +41,7 @@ pub struct BuildIntentInput { pub struct BuildIntent { pub source_dir: PathBuf, pub output_dir: PathBuf, - pub driver: BuildDriverType, + pub driver: DriverType, pub shell_on_failure: bool, pub config: Config, pub driver_overrides: DriverOverrides, @@ -139,9 +137,9 @@ pub fn resolve_build_intent(input: BuildIntentInput) -> anyhow::Result PathBuf { @@ -157,7 +155,7 @@ mod tests { source_dir: None, output_dir: None, config_file: Some(asset_config()), - driver: BuildDriverType::Docker, + driver: DriverType::Docker, persistent: None, incremental: None, disable_incremental: false, diff --git a/packages/debmagic/src/cli.rs b/packages/debmagic/src/cli.rs index 1484902..5d20c5b 100644 --- a/packages/debmagic/src/cli.rs +++ b/packages/debmagic/src/cli.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; -use crate::build::common::{BuildDriverType, SourceSyncMode}; +use crate::build::source::SourceSyncMode; +use crate::driver::DriverType; use clap::{Args, Parser, Subcommand}; #[derive(Parser, Debug)] @@ -71,7 +72,7 @@ pub struct CommonBuildArgs { long, help = "Build driver type. Required for binary builds; source-only builds default to 'bare', since those need no build-deps or compilation." )] - pub driver: Option, + pub driver: Option, #[arg(long, action = clap::ArgAction::SetTrue, help = "Keep the build environment for reuse after the build finishes")] pub persistent: Option, @@ -125,7 +126,7 @@ pub struct CommonBuildArgs { long = "sign-with", help = "Where debsign runs: 'host' signs on the host (requires devscripts there), 'same' signs inside a minimal same-distro container with the host gpg-agent socket forwarded in (requires --sign-key), 'auto' (default) uses the host if debsign is available there, else a container. Defaults to the 'sign_with' setting in the config file." )] - pub sign_with: Option, + pub sign_with: Option, #[arg( long = "sign-key", @@ -210,9 +211,9 @@ pub struct TestSubcommandArgs { #[arg( short, long, - help = "Build driver type for the test environment. Defaults to the driver recorded in the prior build's build.json." + help = "Driver type for the test environment. Defaults to the driver recorded in the prior build's environment.json." )] - pub driver: Option, + pub driver: Option, #[arg(long, action = clap::ArgAction::SetTrue, help = "Keep the test environment for reuse after the test run finishes")] pub persistent: Option, @@ -238,7 +239,7 @@ pub struct TestSubcommandArgs { #[arg( long, - help = "Override the target distribution for the test environment. Defaults to the distro recorded in the prior build's build.json, not the changelog." + help = "Override the target distribution for the test environment. Defaults to the distro recorded in the prior build's environment.json, not the changelog." )] pub distro: Option, diff --git a/packages/debmagic/src/config.rs b/packages/debmagic/src/config.rs index 21ba279..8db86e5 100644 --- a/packages/debmagic/src/config.rs +++ b/packages/debmagic/src/config.rs @@ -1,8 +1,8 @@ use std::path::PathBuf; -use crate::build::common::SourceSyncMode; -use crate::build::config::DriverConfig; -use crate::build::signing::SignWith; +use crate::build::source::SourceSyncMode; +use crate::driver::config::DriverConfig; +use crate::signing::SignWith; use anyhow::{Context, anyhow}; use config::{Config as ConfigBuilder, File}; use serde::Deserialize; diff --git a/packages/debmagic/src/build/config.rs b/packages/debmagic/src/driver/config.rs similarity index 76% rename from packages/debmagic/src/build/config.rs rename to packages/debmagic/src/driver/config.rs index 79a4aaf..8d8c8d4 100644 --- a/packages/debmagic/src/build/config.rs +++ b/packages/debmagic/src/driver/config.rs @@ -1,8 +1,8 @@ use serde::Deserialize; -use crate::build::driver_bare::{DriverBareConfig, DriverBareConfigOverrides}; -use crate::build::driver_docker::{DriverDockerConfig, DriverDockerConfigOverrides}; -use crate::build::driver_lxd::{DriverLxdConfig, DriverLxdConfigOverrides}; +use crate::driver::driver_bare::{DriverBareConfig, DriverBareConfigOverrides}; +use crate::driver::driver_docker::{DriverDockerConfig, DriverDockerConfigOverrides}; +use crate::driver::driver_lxd::{DriverLxdConfig, DriverLxdConfigOverrides}; #[derive(Deserialize, Debug, Clone, Default)] #[serde(default)] diff --git a/packages/debmagic/src/build/driver_bare.rs b/packages/debmagic/src/driver/driver_bare.rs similarity index 62% rename from packages/debmagic/src/build/driver_bare.rs rename to packages/debmagic/src/driver/driver_bare.rs index 920f0dc..d27f9a8 100644 --- a/packages/debmagic/src/build/driver_bare.rs +++ b/packages/debmagic/src/driver/driver_bare.rs @@ -2,12 +2,7 @@ use std::{path::Path, process::Command}; use serde::{Deserialize, Serialize}; -use crate::build::{ - common::{ - BuildConfig, BuildDriver, BuildDriverType, BuildMetadata, DriverSpecificBuildMetadata, - }, - config::DriverConfig, -}; +use crate::driver::{Driver, DriverType, Environment, EnvironmentMetadata, config::DriverConfig}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] @@ -17,37 +12,47 @@ pub struct DriverBareConfig {} pub struct DriverBareConfigOverrides {} pub struct DriverBare { - config: BuildConfig, + environment: Environment, _driver_config: DriverConfig, } impl DriverBare { pub fn create( - config: &BuildConfig, + environment: &Environment, driver_config: &DriverConfig, _overrides: &DriverBareConfigOverrides, ) -> Self { Self { - config: config.clone(), + environment: environment.clone(), _driver_config: driver_config.clone(), } } - pub fn from_build_metadata( - config: &BuildConfig, + pub fn from_metadata( + environment: &Environment, driver_config: &DriverConfig, - _build_metadata: &BuildMetadata, + _metadata: &EnvironmentMetadata, ) -> Self { Self { - config: config.clone(), + environment: environment.clone(), _driver_config: driver_config.clone(), } } + + pub(crate) fn sign_changes( + &self, + changes_file: &Path, + _gpg: Option<&crate::signing::GpgForwarding>, + sign_key: Option<&str>, + ) -> anyhow::Result<()> { + crate::signing::check_host_debsign_available()?; + crate::signing::sign_on_host(changes_file, sign_key) + } } -impl BuildDriver for DriverBare { - fn get_build_metadata(&self) -> DriverSpecificBuildMetadata { - DriverSpecificBuildMetadata::from([]) +impl Driver for DriverBare { + fn driver_metadata(&self) -> std::collections::HashMap { + std::collections::HashMap::from([]) } fn run_command( @@ -83,28 +88,19 @@ impl BuildDriver for DriverBare { fn interactive_shell(&self, _cwd: &Path) -> std::io::Result<()> { println!( "source directory of current package build in {}", - self.config.build_source_dir().display() + self.environment.staged_source_dir().display() ); Ok(()) } - fn driver_type(&self) -> BuildDriverType { - BuildDriverType::Bare + fn driver_type(&self) -> DriverType { + DriverType::Bare } - fn reset_build_root(&self) -> std::io::Result<()> { - if self.config.build_root_dir.exists() { - std::fs::remove_dir_all(&self.config.build_root_dir)?; + fn reset_root(&self) -> std::io::Result<()> { + if self.environment.root_dir.exists() { + std::fs::remove_dir_all(&self.environment.root_dir)?; } Ok(()) } - - fn sign_changes( - &self, - changes_file: &Path, - _gpg: Option<&crate::build::signing::GpgForwarding>, - ) -> anyhow::Result<()> { - crate::build::signing::check_host_debsign_available()?; - crate::build::signing::sign_on_host(changes_file, self.config.sign_key.as_deref()) - } } diff --git a/packages/debmagic/src/build/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs similarity index 84% rename from packages/debmagic/src/build/driver_docker.rs rename to packages/debmagic/src/driver/driver_docker.rs index d2e5a5d..07b4fe3 100644 --- a/packages/debmagic/src/build/driver_docker.rs +++ b/packages/debmagic/src/driver/driver_docker.rs @@ -9,14 +9,11 @@ use anyhow::{Context, anyhow}; use debmagic_common::distro::DistroVersion; use serde::{Deserialize, Serialize}; -use crate::build::{ - common::{ - APT_MIRROR_SCRIPT, BUILD_DIR_IN_CONTAINER, BuildConfig, BuildDriver, BuildDriverType, - BuildMetadata, DriverSpecificBuildMetadata, container_name_from_metadata, - container_name_metadata, environment_fingerprint, resource_name, run_checked, - translate_path_in_container, - }, - config::DriverConfig, +use crate::driver::{ + APT_MIRROR_SCRIPT, Driver, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, + EnvironmentMetadata, config::DriverConfig, container_name_from_metadata, + container_name_metadata, environment_fingerprint, resource_name, run_checked, + translate_path_in_container, }; #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -70,7 +67,7 @@ fn shell_quote(s: &str) -> String { } pub struct DriverDocker { - config: BuildConfig, + environment: Environment, container_name: String, /// Base image of the distro, used to spin up minimal one-shot containers /// (e.g. for signing) that don't need the build environment's tooling. @@ -89,7 +86,7 @@ fn sanitize_docker_reference(name: &str) -> String { } fn build_build_image( - config: &BuildConfig, + environment: &Environment, base_image: &str, apt_mirror: Option<&str>, proposed: bool, @@ -97,14 +94,14 @@ fn build_build_image( ) -> anyhow::Result<()> { let apt_mirror_setup = if apt_mirror.is_some() || proposed { fs::write( - config.build_temp_dir().join(APT_MIRROR_SCRIPT_FILENAME), + environment.temp_dir().join(APT_MIRROR_SCRIPT_FILENAME), APT_MIRROR_SCRIPT, ) .map_err(|e| anyhow!("Failed to write apt mirror script: {e}"))?; let mut args = vec![ "--codename".to_string(), - shell_quote(&config.distro.codename), + shell_quote(&environment.distro.codename), ]; if let Some(mirror) = apt_mirror { args.extend(["--mirror".to_string(), shell_quote(mirror)]); @@ -126,9 +123,9 @@ fn build_build_image( let formatted_dockerfile = DOCKERFILE_TEMPLATE .replace("{base_image}", base_image) .replace("{apt_mirror_setup}", &apt_mirror_setup) - .replace("{build_dir}", BUILD_DIR_IN_CONTAINER); + .replace("{build_dir}", ENVIRONMENT_DIR_IN_CONTAINER); - let dockerfile_path = config.build_temp_dir().join("Dockerfile"); + let dockerfile_path = environment.temp_dir().join("Dockerfile"); fs::write(&dockerfile_path, formatted_dockerfile) .map_err(|e| anyhow!("Failed to write Dockerfile, {e}"))?; @@ -149,7 +146,7 @@ fn build_build_image( .args(&build_args) .args(["--tag", image_name, "-f"]) .arg(dockerfile_path) - .arg(config.build_temp_dir()); + .arg(environment.temp_dir()); run_checked(&mut build_cmd, "building docker image")?; @@ -231,26 +228,27 @@ impl DriverDocker { } pub fn create( - config: &BuildConfig, + environment: &Environment, driver_config: &DriverConfig, overrides: &DriverDockerConfigOverrides, apt_mirror: Option<&str>, proposed: bool, ) -> anyhow::Result { - let base_image = overrides - .base_image - .clone() - .unwrap_or_else(|| driver_config.docker.base_image_for_distro(&config.distro)); + let base_image = overrides.base_image.clone().unwrap_or_else(|| { + driver_config + .docker + .base_image_for_distro(&environment.distro) + }); let uid = unsafe { libc::geteuid() }.to_string(); let gid = unsafe { libc::getegid() }.to_string(); - let build_root = config.build_root_dir.to_string_lossy(); + let build_root = environment.root_dir.to_string_lossy(); let proposed_fingerprint = proposed.to_string(); let image_fingerprint = environment_fingerprint(&[ "docker", DOCKERFILE_TEMPLATE, APT_MIRROR_SCRIPT, &base_image, - &config.distro.codename, + &environment.distro.codename, apt_mirror.unwrap_or(""), &proposed_fingerprint, &uid, @@ -258,17 +256,17 @@ impl DriverDocker { ]); let mut container_fingerprint_parts = vec!["docker-container", &image_fingerprint, build_root.as_ref()]; - if let Some(purpose) = config.purpose.fingerprint_part() { + if let Some(purpose) = environment.purpose.fingerprint_part() { container_fingerprint_parts.push(purpose); } let desired_fingerprint = environment_fingerprint(&container_fingerprint_parts); let container_name = resource_name( "debmagic", - &config.package_name, - &sanitize_docker_reference(&config.build_identifier()), + &environment.package_name, + &sanitize_docker_reference(&environment.identifier()), ); let mut driver = Self { - config: config.clone(), + environment: environment.clone(), container_name, base_image: base_image.clone(), reused_environment: false, @@ -276,10 +274,10 @@ impl DriverDocker { let environment_matches = container_environment_fingerprint(&driver.container_name)? .as_deref() == Some(&desired_fingerprint); - driver.reused_environment = config.persistent && environment_matches; + driver.reused_environment = environment.persistent && environment_matches; let created_container; - if config.persistent && environment_matches { + if environment.persistent && environment_matches { created_container = false; if !driver.container_is_running()? { driver.container_start()?; @@ -295,7 +293,7 @@ impl DriverDocker { let docker_image_name = format!("debmagic-env-{}", &image_fingerprint[..16]); if !does_image_exist(&docker_image_name)? { build_build_image( - config, + environment, &base_image, apt_mirror, proposed, @@ -314,8 +312,8 @@ impl DriverDocker { "--mount", ]) .arg(bind_mount_arg( - &config.build_root_dir, - BUILD_DIR_IN_CONTAINER, + &environment.root_dir, + ENVIRONMENT_DIR_IN_CONTAINER, )) .arg(&docker_image_name), "starting docker container", @@ -326,7 +324,7 @@ impl DriverDocker { // cwd is the build root (the bind mount itself), not the source dir: // create() must not assume the source tree has been staged yet. let update_result = driver - .run_command_checked(&["apt-get", "update"], &config.build_root_dir, true, &[]) + .run_command_checked(&["apt-get", "update"], &environment.root_dir, true, &[]) .map_err(|error| anyhow!("Error running apt-get update in container: {error}")); if let Err(error) = update_result { if created_container && let Err(cleanup_error) = driver.container_remove_force() { @@ -340,15 +338,17 @@ impl DriverDocker { Ok(driver) } - pub fn from_build_metadata( - config: &BuildConfig, + pub fn from_metadata( + environment: &Environment, driver_config: &DriverConfig, - build_metadata: &BuildMetadata, + metadata: &EnvironmentMetadata, ) -> anyhow::Result { Ok(Self { - config: config.clone(), - container_name: container_name_from_metadata(build_metadata)?, - base_image: driver_config.docker.base_image_for_distro(&config.distro), + environment: environment.clone(), + container_name: container_name_from_metadata(metadata)?, + base_image: driver_config + .docker + .base_image_for_distro(&environment.distro), reused_environment: true, }) } @@ -357,12 +357,12 @@ impl DriverDocker { &self, path_in_source: &Path, ) -> Result { - translate_path_in_container(&self.config.build_root_dir, path_in_source) + translate_path_in_container(&self.environment.root_dir, path_in_source) } } -impl BuildDriver for DriverDocker { - fn get_build_metadata(&self) -> DriverSpecificBuildMetadata { +impl Driver for DriverDocker { + fn driver_metadata(&self) -> std::collections::HashMap { container_name_metadata(&self.container_name) } @@ -399,15 +399,21 @@ impl BuildDriver for DriverDocker { } fn cleanup(&self) -> anyhow::Result<()> { - if self.config.persistent { + if self.environment.persistent { Ok(()) } else { self.container_remove_force() } } - fn reset_build_root(&self) -> std::io::Result<()> { - let find_cmd = ["find", BUILD_DIR_IN_CONTAINER, "-mindepth", "1", "-delete"]; + fn reset_root(&self) -> std::io::Result<()> { + let find_cmd = [ + "find", + ENVIRONMENT_DIR_IN_CONTAINER, + "-mindepth", + "1", + "-delete", + ]; println!("[{}] $ {}", self.container_name, find_cmd.join(" ")); let status = Command::new("docker") .args(["exec", "--user", "root", &self.container_name]) @@ -449,22 +455,25 @@ impl BuildDriver for DriverDocker { Ok(()) } - fn driver_type(&self) -> BuildDriverType { - BuildDriverType::Docker + fn driver_type(&self) -> DriverType { + DriverType::Docker } +} - fn sign_changes( +impl DriverDocker { + pub(crate) fn sign_changes( &self, changes_file: &Path, - gpg: Option<&crate::build::signing::GpgForwarding>, + gpg: Option<&crate::signing::GpgForwarding>, + _sign_key: Option<&str>, ) -> anyhow::Result<()> { - use crate::build::signing; + use crate::signing; let gpg = gpg.context("docker container signing needs gpg forwarding info")?; let output_dir = changes_file .parent() .context("changes file has no parent directory")?; - let staging_dir = self.config.build_temp_dir().join("sign"); + let staging_dir = self.environment.temp_dir().join("sign"); signing::stage_signing_material(&staging_dir, &gpg.sign_key)?; let script = signing::sign_container_script( signing::changes_filename(changes_file)?, @@ -477,7 +486,7 @@ impl BuildDriver for DriverDocker { println!( "[docker] $ signing {} in a minimal {} container", changes_file.display(), - self.config.distro.codename + self.environment.distro.codename ); run_checked( Command::new("docker") diff --git a/packages/debmagic/src/build/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs similarity index 90% rename from packages/debmagic/src/build/driver_lxd.rs rename to packages/debmagic/src/driver/driver_lxd.rs index 6abe99a..559fe81 100644 --- a/packages/debmagic/src/build/driver_lxd.rs +++ b/packages/debmagic/src/driver/driver_lxd.rs @@ -8,14 +8,11 @@ use anyhow::Context as _; use debmagic_common::distro::Distro; use serde::{Deserialize, Serialize}; -use crate::build::{ - common::{ - APT_MIRROR_SCRIPT, BUILD_DIR_IN_CONTAINER, BuildConfig, BuildDriver, BuildDriverType, - BuildMetadata, DriverSpecificBuildMetadata, container_name_from_metadata, - container_name_metadata, environment_fingerprint, resource_name, run_checked, - translate_path_in_container, - }, - config::DriverConfig, +use crate::driver::{ + APT_MIRROR_SCRIPT, Driver, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, + EnvironmentMetadata, config::DriverConfig, container_name_from_metadata, + container_name_metadata, environment_fingerprint, resource_name, run_checked, + translate_path_in_container, }; // The binary name differs between LXD and Incus, but everything else is shared. @@ -108,7 +105,7 @@ pub struct DriverLxdConfigOverrides { pub struct DriverLxd { variant: LxdVariant, - config: BuildConfig, + environment: Environment, container_name: String, /// Resolved project name (None → omit `--project` flag). project: Option, @@ -213,14 +210,17 @@ impl DriverLxd { pub fn create( variant: LxdVariant, - config: &BuildConfig, + environment: &Environment, driver_config: &DriverConfig, overrides: &DriverLxdConfigOverrides, apt_mirror: Option<&str>, proposed: bool, ) -> anyhow::Result { - let container_name = - resource_name("debmagic", &config.package_name, &config.build_identifier()); + let container_name = resource_name( + "debmagic", + &environment.package_name, + &environment.identifier(), + ); // Project: CLI override > config file value let project = overrides @@ -230,31 +230,31 @@ impl DriverLxd { let base_image = overrides.base_image.clone().unwrap_or_else(|| { driver_config .lxd - .base_image_for_distro(variant, &config.distro) + .base_image_for_distro(variant, &environment.distro) }); let host_uid = unsafe { libc::geteuid() }.to_string(); let host_gid = unsafe { libc::getegid() }.to_string(); - let build_root = config.build_root_dir.to_string_lossy(); + let build_root = environment.root_dir.to_string_lossy(); let proposed_fingerprint = proposed.to_string(); let mut fingerprint_parts = vec![ variant.binary(), ENVIRONMENT_SETUP_VERSION, &base_image, - &config.distro.codename, + &environment.distro.codename, apt_mirror.unwrap_or(""), &proposed_fingerprint, &host_uid, &host_gid, build_root.as_ref(), ]; - if let Some(purpose) = config.purpose.fingerprint_part() { + if let Some(purpose) = environment.purpose.fingerprint_part() { fingerprint_parts.push(purpose); } let desired_fingerprint = environment_fingerprint(&fingerprint_parts); let mut base = Self { variant, - config: config.clone(), + environment: environment.clone(), container_name: container_name.clone(), project, base_image: base_image.clone(), @@ -264,7 +264,7 @@ impl DriverLxd { let container_entry = base.container_list_entry()?; let environment_matches = container_entry.is_some() && base.container_environment_fingerprint()?.as_deref() == Some(&desired_fingerprint); - let reusing_container = config.persistent && environment_matches; + let reusing_container = environment.persistent && environment_matches; base.reused_environment = reusing_container; let mut initialized_container = false; @@ -282,7 +282,7 @@ impl DriverLxd { } let mut init = base.lxd_cmd("init"); - if !config.persistent { + if !environment.persistent { init.arg("--ephemeral"); } init.args([&base_image, &container_name]); @@ -313,8 +313,8 @@ impl DriverLxd { let device_name = resource_name( "debmagic-src", - &config.package_name, - &config.build_identifier(), + &environment.package_name, + &environment.identifier(), ); run_checked( base.lxd_cmd("config") @@ -323,8 +323,8 @@ impl DriverLxd { .arg(&container_name) .arg(&device_name) .arg("disk") - .arg(format!("source={}", config.build_root_dir.display())) - .arg(format!("path={}", BUILD_DIR_IN_CONTAINER)), + .arg(format!("source={}", environment.root_dir.display())) + .arg(format!("path={}", ENVIRONMENT_DIR_IN_CONTAINER)), &format!("mounting build root into {} container", variant.binary()), )?; @@ -333,7 +333,7 @@ impl DriverLxd { &format!("starting {} container", variant.binary()), )?; - if config.distro.distro == Distro::Ubuntu { + if environment.distro.distro == Distro::Ubuntu { base.exec_in_container_checked( &["cloud-init", "status", "--wait"], None, @@ -373,14 +373,14 @@ impl DriverLxd { .map_err(|e| anyhow::anyhow!("Error creating build user in container: {e}"))?; if apt_mirror.is_some() || proposed { - let script_path = config.build_temp_dir().join("mirror.py"); + let script_path = environment.temp_dir().join("mirror.py"); fs::write(&script_path, APT_MIRROR_SCRIPT)?; let container_script_path = base.translate_path_in_container(&script_path)?; let mut args = vec![ "python3".to_string(), container_script_path.to_string_lossy().into_owned(), "--codename".to_string(), - config.distro.codename.clone(), + environment.distro.codename.clone(), ]; if let Some(mirror) = apt_mirror { args.extend(["--mirror".to_string(), mirror.to_string()]); @@ -422,22 +422,22 @@ impl DriverLxd { Ok(base) } - pub fn from_build_metadata( + pub fn from_metadata( variant: LxdVariant, - config: &BuildConfig, + environment: &Environment, driver_config: &DriverConfig, - build_metadata: &BuildMetadata, + metadata: &EnvironmentMetadata, ) -> anyhow::Result { - let project = build_metadata.driver_metadata.get("project").cloned(); + let project = metadata.driver_metadata.get("project").cloned(); Ok(Self { variant, - config: config.clone(), - container_name: container_name_from_metadata(build_metadata)?, + environment: environment.clone(), + container_name: container_name_from_metadata(metadata)?, project, base_image: driver_config .lxd - .base_image_for_distro(variant, &config.distro), + .base_image_for_distro(variant, &environment.distro), reused_environment: true, }) } @@ -446,7 +446,7 @@ impl DriverLxd { &self, path_in_source: &Path, ) -> Result { - translate_path_in_container(&self.config.build_root_dir, path_in_source) + translate_path_in_container(&self.environment.root_dir, path_in_source) } /// Run `action` with the container running, restoring a previously @@ -534,8 +534,8 @@ impl DriverLxd { } } -impl BuildDriver for DriverLxd { - fn get_build_metadata(&self) -> DriverSpecificBuildMetadata { +impl Driver for DriverLxd { + fn driver_metadata(&self) -> std::collections::HashMap { let mut meta = container_name_metadata(&self.container_name); if let Some(ref p) = self.project { meta.insert("project".to_string(), p.clone()); @@ -558,7 +558,7 @@ impl BuildDriver for DriverLxd { } fn cleanup(&self) -> anyhow::Result<()> { - if self.config.persistent { + if self.environment.persistent { Ok(()) } else { // Ephemeral containers are auto-deleted after stopping. @@ -566,10 +566,16 @@ impl BuildDriver for DriverLxd { } } - fn reset_build_root(&self) -> std::io::Result<()> { + fn reset_root(&self) -> std::io::Result<()> { self.with_running_container(|driver| { driver.exec_in_container_checked( - &["find", BUILD_DIR_IN_CONTAINER, "-mindepth", "1", "-delete"], + &[ + "find", + ENVIRONMENT_DIR_IN_CONTAINER, + "-mindepth", + "1", + "-delete", + ], None, true, &[], @@ -599,25 +605,28 @@ impl BuildDriver for DriverLxd { }) } - fn driver_type(&self) -> BuildDriverType { + fn driver_type(&self) -> DriverType { match self.variant { - LxdVariant::Lxd => BuildDriverType::Lxd, - LxdVariant::Incus => BuildDriverType::Incus, + LxdVariant::Lxd => DriverType::Lxd, + LxdVariant::Incus => DriverType::Incus, } } +} - fn sign_changes( +impl DriverLxd { + pub(crate) fn sign_changes( &self, changes_file: &Path, - gpg: Option<&crate::build::signing::GpgForwarding>, + gpg: Option<&crate::signing::GpgForwarding>, + _sign_key: Option<&str>, ) -> anyhow::Result<()> { - use crate::build::signing; + use crate::signing; let gpg = gpg.context("container signing needs gpg forwarding info")?; let output_dir = changes_file .parent() .context("changes file has no parent directory")?; - let staging_dir = self.config.build_temp_dir().join("sign"); + let staging_dir = self.environment.temp_dir().join("sign"); signing::stage_signing_material(&staging_dir, &gpg.sign_key)?; // No chown needed: raw.idmap maps container root to the host user. let script = signing::sign_container_script( @@ -628,8 +637,8 @@ impl BuildDriver for DriverLxd { let sign_container = resource_name( "debmagic-sign", - &self.config.package_name, - &self.config.build_identifier(), + &self.environment.package_name, + &self.environment.identifier(), ); let bin = self.variant.binary(); // The sign container is ephemeral; it disappears when stopped. diff --git a/packages/debmagic/src/driver/mod.rs b/packages/debmagic/src/driver/mod.rs new file mode 100644 index 0000000..d8eeec0 --- /dev/null +++ b/packages/debmagic/src/driver/mod.rs @@ -0,0 +1,509 @@ +use std::{ + collections::HashMap, + fmt::Debug, + fs, io, + path::{Path, PathBuf}, + process::Command, +}; + +use anyhow::Context; +use clap::ValueEnum; +use debmagic_common::distro::DistroVersion; +use serde::{Deserialize, Serialize}; + +use crate::driver::{ + config::{DriverConfig, DriverOverrides}, + driver_bare::DriverBare, + driver_docker::DriverDocker, + driver_lxd::{DriverLxd, LxdVariant}, +}; + +pub mod config; +pub mod driver_bare; +pub mod driver_docker; +pub mod driver_lxd; + +/// Path at which the environment root is bind-mounted inside container-based +/// drivers (Docker, LXD, Incus). +pub const ENVIRONMENT_DIR_IN_CONTAINER: &str = "/debmagic"; + +/// Rewrite a path inside the host's environment root to the equivalent path +/// inside a container that has it bind-mounted at [`ENVIRONMENT_DIR_IN_CONTAINER`]. +pub fn translate_path_in_container(root_dir: &Path, path_in_source: &Path) -> io::Result { + path_in_source + .strip_prefix(root_dir) + .map(|rel| Path::new(ENVIRONMENT_DIR_IN_CONTAINER).join(rel)) + .map_err(|_| { + io::Error::new( + io::ErrorKind::NotFound, + "Path is not relative to environment root".to_string(), + ) + }) +} + +/// Run `cmd`, failing with `context` (and, on a clean but unsuccessful exit, +/// its exit status) if it can't be spawned or exits unsuccessfully. +pub fn run_checked(cmd: &mut Command, context: &str) -> anyhow::Result<()> { + let status = cmd + .status() + .with_context(|| format!("Error running {context}"))?; + if !status.success() { + anyhow::bail!("{context} failed (exit status: {status})"); + } + Ok(()) +} + +pub fn resource_name(prefix: &str, label: &str, identifier: &str) -> String { + const MAX_LEN: usize = 63; + const HASH_LEN: usize = 16; + + let mut hash = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, identifier.as_bytes()) + .simple() + .to_string(); + hash.truncate(HASH_LEN); + let max_label_len = MAX_LEN - prefix.len() - hash.len() - 2; + let label = label + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .take(max_label_len) + .collect::(); + format!("{prefix}-{label}-{hash}") +} + +pub fn environment_fingerprint(parts: &[&str]) -> String { + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, parts.join("\0").as_bytes()) + .simple() + .to_string() +} + +/// Metadata key under which container-based drivers store their container's +/// name for later reattachment via `create_driver_from_metadata`. +const CONTAINER_NAME_KEY: &str = "container_name"; + +pub fn container_name_metadata(name: &str) -> HashMap { + HashMap::from([(CONTAINER_NAME_KEY.to_string(), name.to_string())]) +} + +pub fn container_name_from_metadata(metadata: &EnvironmentMetadata) -> anyhow::Result { + metadata + .driver_metadata + .get(CONTAINER_NAME_KEY) + .cloned() + .context("environment metadata has no container_name") +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize)] +pub enum DriverType { + Docker, + Bare, + Lxd, + Incus, +} + +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnvironmentPurpose { + #[default] + Build, + Test, +} + +impl EnvironmentPurpose { + /// Extra part for environment fingerprints when purpose is not [`Self::Build`]. + pub fn fingerprint_part(self) -> Option<&'static str> { + match self { + Self::Build => None, + Self::Test => Some("test"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Environment { + pub driver: DriverType, + #[serde(default)] + pub package_name: String, + pub package_identifier: String, + pub root_dir: PathBuf, + pub distro: DistroVersion, + #[serde(default)] + pub persistent: bool, + #[serde(default)] + pub purpose: EnvironmentPurpose, +} + +impl Environment { + pub fn identifier(&self) -> String { + let base = format!( + "{}-{}-{}", + self.package_identifier, self.distro.distro, self.distro.codename + ); + match self.purpose { + EnvironmentPurpose::Build => base, + EnvironmentPurpose::Test => format!("{base}-test"), + } + } + + pub fn work_dir(&self) -> PathBuf { + self.root_dir.join("work") + } + + pub fn temp_dir(&self) -> PathBuf { + self.root_dir.join("temp") + } + + pub fn staged_source_dir(&self) -> PathBuf { + self.work_dir().join(&self.package_identifier) + } + + pub fn create_dirs(&self) -> io::Result<()> { + fs::create_dir_all(self.work_dir())?; + fs::create_dir_all(self.temp_dir())?; + fs::create_dir_all(self.staged_source_dir())?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentMetadata { + pub environment: Environment, + pub driver_metadata: HashMap, +} + +/// Source of a Python script that rewrites the default Debian/Ubuntu apt +/// sources to point at a mirror. Replaces the base image's default sources +/// file(s) outright with one debmagic owns, rather than parsing and +/// patching them in place, defaulting to the release/updates/security +/// pockets it detects from `/etc/os-release`. +pub const APT_MIRROR_SCRIPT: &str = include_str!("scripts/mirror.py"); + +pub trait Driver { + fn driver_metadata(&self) -> HashMap; + + fn run_command( + &self, + cmd: &[&str], + cwd: &Path, + requires_root: bool, + env_add: &[(&str, &str)], + ) -> io::Result; + + fn run_command_checked( + &self, + cmd: &[&str], + cwd: &Path, + requires_root: bool, + env_add: &[(&str, &str)], + ) -> io::Result<()> { + let code = self.run_command(cmd, cwd, requires_root, env_add)?; + if code != 0 { + return Err(io::Error::other(format!( + "Command failed with exit code: {code}" + ))); + } + Ok(()) + } + + fn cleanup(&self) -> anyhow::Result<()>; + + fn interactive_shell(&self, cwd: &Path) -> io::Result<()>; + + fn driver_type(&self) -> DriverType; + + fn reset_root(&self) -> io::Result<()>; + + fn reused_environment(&self) -> bool { + true + } +} + +pub enum DriverInstance { + Docker(DriverDocker), + Bare(DriverBare), + Lxd(DriverLxd), +} + +impl Driver for DriverInstance { + fn driver_metadata(&self) -> HashMap { + match self { + Self::Docker(d) => d.driver_metadata(), + Self::Bare(d) => d.driver_metadata(), + Self::Lxd(d) => d.driver_metadata(), + } + } + + fn run_command( + &self, + cmd: &[&str], + cwd: &Path, + requires_root: bool, + env_add: &[(&str, &str)], + ) -> io::Result { + match self { + Self::Docker(d) => d.run_command(cmd, cwd, requires_root, env_add), + Self::Bare(d) => d.run_command(cmd, cwd, requires_root, env_add), + Self::Lxd(d) => d.run_command(cmd, cwd, requires_root, env_add), + } + } + + fn cleanup(&self) -> anyhow::Result<()> { + match self { + Self::Docker(d) => d.cleanup(), + Self::Bare(d) => d.cleanup(), + Self::Lxd(d) => d.cleanup(), + } + } + + fn interactive_shell(&self, cwd: &Path) -> io::Result<()> { + match self { + Self::Docker(d) => d.interactive_shell(cwd), + Self::Bare(d) => d.interactive_shell(cwd), + Self::Lxd(d) => d.interactive_shell(cwd), + } + } + + fn driver_type(&self) -> DriverType { + match self { + Self::Docker(d) => d.driver_type(), + Self::Bare(d) => d.driver_type(), + Self::Lxd(d) => d.driver_type(), + } + } + + fn reset_root(&self) -> io::Result<()> { + match self { + Self::Docker(d) => d.reset_root(), + Self::Bare(d) => d.reset_root(), + Self::Lxd(d) => d.reset_root(), + } + } + + fn reused_environment(&self) -> bool { + match self { + Self::Docker(d) => d.reused_environment(), + Self::Bare(d) => d.reused_environment(), + Self::Lxd(d) => d.reused_environment(), + } + } +} + +impl DriverInstance { + pub fn sign_changes( + &self, + changes_file: &Path, + gpg: Option<&crate::signing::GpgForwarding>, + sign_key: Option<&str>, + ) -> anyhow::Result<()> { + match self { + Self::Docker(d) => d.sign_changes(changes_file, gpg, sign_key), + Self::Bare(d) => d.sign_changes(changes_file, gpg, sign_key), + Self::Lxd(d) => d.sign_changes(changes_file, gpg, sign_key), + } + } +} + +pub fn create_driver( + environment: &Environment, + driver_config: &DriverConfig, + overrides: &DriverOverrides, +) -> anyhow::Result { + let apt_mirror = overrides + .apt_mirror + .as_deref() + .or(driver_config.apt_mirror.as_deref()); + let proposed = overrides.proposed.unwrap_or(driver_config.proposed); + + match environment.driver { + DriverType::Docker => Ok(DriverInstance::Docker(DriverDocker::create( + environment, + driver_config, + &overrides.docker, + apt_mirror, + proposed, + )?)), + DriverType::Bare => Ok(DriverInstance::Bare(DriverBare::create( + environment, + driver_config, + &overrides.bare, + ))), + DriverType::Lxd | DriverType::Incus => { + let variant = match environment.driver { + DriverType::Lxd => LxdVariant::Lxd, + _ => LxdVariant::Incus, + }; + Ok(DriverInstance::Lxd(DriverLxd::create( + variant, + environment, + driver_config, + &overrides.lxd, + apt_mirror, + proposed, + )?)) + } + } +} + +pub fn create_driver_from_metadata( + driver_config: &DriverConfig, + metadata: &EnvironmentMetadata, +) -> anyhow::Result { + match metadata.environment.driver { + DriverType::Docker => Ok(DriverInstance::Docker(DriverDocker::from_metadata( + &metadata.environment, + driver_config, + metadata, + )?)), + DriverType::Bare => Ok(DriverInstance::Bare(DriverBare::from_metadata( + &metadata.environment, + driver_config, + metadata, + ))), + DriverType::Lxd | DriverType::Incus => { + let variant = match metadata.environment.driver { + DriverType::Lxd => LxdVariant::Lxd, + _ => LxdVariant::Incus, + }; + Ok(DriverInstance::Lxd(DriverLxd::from_metadata( + variant, + &metadata.environment, + driver_config, + metadata, + )?)) + } + } +} + +/// Remove `root` from the host. If files are owned by a container user the host +/// cannot delete, delete them from inside that environment first. Never requires +/// host root. +pub fn remove_environment_root(root: &Path, driver_config: &DriverConfig) -> anyhow::Result<()> { + if !root.exists() { + return Ok(()); + } + match fs::remove_dir_all(root) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::PermissionDenied => { + let metadata_path = root.join("environment.json"); + if !metadata_path.is_file() { + return Err(e).with_context(|| { + format!( + "failed to remove {} (permission denied) and no environment.json is present to delete files from inside the environment", + root.display() + ) + }); + } + let file = fs::OpenOptions::new() + .read(true) + .open(&metadata_path) + .with_context(|| format!("failed to open {}", metadata_path.display()))?; + let metadata: EnvironmentMetadata = + serde_json::from_reader(std::io::BufReader::new(&file)) + .with_context(|| format!("failed to parse {}", metadata_path.display()))?; + let driver = + create_driver_from_metadata(driver_config, &metadata).with_context(|| { + format!( + "failed to reattach to the environment at {} to delete privileged files", + root.display() + ) + })?; + driver.reset_root().with_context(|| { + format!( + "failed to delete files inside the environment at {}", + root.display() + ) + })?; + fs::remove_dir_all(root).with_context(|| { + format!( + "failed to remove {} after deleting its contents from inside the environment", + root.display() + ) + })?; + Ok(()) + } + Err(e) => Err(e).with_context(|| format!("failed to remove {}", root.display())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use debmagic_common::distro::{Distro, DistroVersion}; + use std::path::PathBuf; + + #[test] + fn resource_names_are_valid_stable_and_distinct() { + let first = resource_name("debmagic", "package", "package-1.0~beta-1:2-debian-forky"); + let second = resource_name("debmagic", "package", "package-1.0-beta-1:2-debian-forky"); + + assert_eq!( + first, + resource_name("debmagic", "package", "package-1.0~beta-1:2-debian-forky") + ); + assert_ne!(first, second); + assert!(first.starts_with("debmagic-package-")); + assert_eq!(first.len(), "debmagic-package-".len() + 16); + assert!(first.len() <= 63); + assert!(first.chars().all(|character| character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '-')); + } + + fn sample_environment(package_identifier: &str) -> Environment { + Environment { + driver: DriverType::Docker, + package_identifier: package_identifier.to_string(), + root_dir: PathBuf::from("/tmp"), + distro: DistroVersion { + distro: Distro::Debian, + codename: "forky".to_string(), + version: "15".to_string(), + is_devel: false, + }, + persistent: false, + package_name: "debmagic".to_string(), + purpose: EnvironmentPurpose::Build, + } + } + + #[test] + fn identifier_unchanged_for_build_purpose() { + let environment = sample_environment("debmagic-0.0.1~alpha2"); + assert_eq!( + environment.identifier(), + "debmagic-0.0.1~alpha2-debian-forky" + ); + } + + #[test] + fn identifier_differs_for_test_purpose() { + let mut environment = sample_environment("debmagic-0.0.1~alpha2"); + environment.purpose = EnvironmentPurpose::Test; + assert_eq!( + environment.identifier(), + "debmagic-0.0.1~alpha2-debian-forky-test" + ); + assert_ne!( + environment.identifier(), + sample_environment("debmagic-0.0.1~alpha2").identifier() + ); + } + + #[test] + fn environment_without_purpose_deserializes_as_build() { + let json = r#"{ + "driver": "Docker", + "package_identifier": "pkg-1.0", + "root_dir": "/tmp/build", + "distro": { "distro": "Debian", "codename": "forky", "version": "15" } + }"#; + let environment: Environment = serde_json::from_str(json).unwrap(); + assert_eq!(environment.purpose, EnvironmentPurpose::Build); + } +} diff --git a/packages/debmagic/src/build/scripts/mirror.py b/packages/debmagic/src/driver/scripts/mirror.py similarity index 100% rename from packages/debmagic/src/build/scripts/mirror.py rename to packages/debmagic/src/driver/scripts/mirror.py diff --git a/packages/debmagic/src/main.rs b/packages/debmagic/src/main.rs index 81c80f0..08ff549 100644 --- a/packages/debmagic/src/main.rs +++ b/packages/debmagic/src/main.rs @@ -5,13 +5,13 @@ use anyhow::Context; use clap::{CommandFactory, Parser}; use crate::{ - build::{ - build_package, build_source_package, common::BuildDriverType, config::DriverOverrides, - driver_bare::DriverBareConfigOverrides, driver_docker::DriverDockerConfigOverrides, - driver_lxd::DriverLxdConfigOverrides, get_shell_in_build, - }, + build::{build_package, build_source_package, get_shell_in_build}, build_intent::{BuildIntentInput, load_config, resolve_build_intent}, cli::{BuildTarget, Cli, Commands}, + driver::{ + DriverType, config::DriverOverrides, driver_bare::DriverBareConfigOverrides, + driver_docker::DriverDockerConfigOverrides, driver_lxd::DriverLxdConfigOverrides, + }, package::{load_package_identity, resolve_package_target}, test::{TestIntentInput, TestOutcome, resolve_test_intent, run_test}, }; @@ -20,7 +20,9 @@ pub mod build; pub mod build_intent; pub mod cli; pub mod config; +pub mod driver; pub mod package; +pub mod signing; pub mod test; fn main() -> ExitCode { @@ -50,7 +52,7 @@ fn run() -> anyhow::Result { }; let driver = if is_source { - build_args.driver.unwrap_or(BuildDriverType::Bare) + build_args.driver.unwrap_or(DriverType::Bare) } else { build_args.driver.context( "--driver is required for binary builds (docker, bare, lxd or incus)", diff --git a/packages/debmagic/src/build/signing.rs b/packages/debmagic/src/signing.rs similarity index 99% rename from packages/debmagic/src/build/signing.rs rename to packages/debmagic/src/signing.rs index 7b07604..0e16121 100644 --- a/packages/debmagic/src/build/signing.rs +++ b/packages/debmagic/src/signing.rs @@ -15,7 +15,7 @@ use std::{ use anyhow::{Context, anyhow}; use serde::{Deserialize, Serialize}; -use crate::build::common::run_checked; +use crate::driver::run_checked; /// Where the forwarded agent socket is bind-mounted inside sign containers. /// A fixed, always-existing path; the script symlinks it to gpg's lookup diff --git a/packages/debmagic/src/test/intent.rs b/packages/debmagic/src/test/intent.rs index 3f27c92..7cb486f 100644 --- a/packages/debmagic/src/test/intent.rs +++ b/packages/debmagic/src/test/intent.rs @@ -3,9 +3,9 @@ use std::path::PathBuf; use anyhow::Context; use crate::{ - build::{common::BuildDriverType, config::DriverOverrides}, build_intent::load_config, config::Config, + driver::{DriverType, config::DriverOverrides}, }; /// Clap-free inputs for resolving a [`TestIntent`]. @@ -15,7 +15,7 @@ pub struct TestIntentInput { pub fallback_dir: PathBuf, pub source_dir: Option, pub config_file: Option, - pub driver: Option, + pub driver: Option, pub persistent: Option, pub strict: bool, pub changes: Option, @@ -31,7 +31,7 @@ pub struct TestIntentInput { #[derive(Debug, Clone)] pub struct TestIntent { pub source_dir: PathBuf, - pub driver: Option, + pub driver: Option, pub strict: bool, pub changes: Option, pub allow_host_test: bool, @@ -73,9 +73,9 @@ pub fn resolve_test_intent(input: TestIntentInput) -> anyhow::Result #[cfg(test)] mod tests { use super::*; - use crate::build::{ - driver_bare::DriverBareConfigOverrides, driver_docker::DriverDockerConfigOverrides, - driver_lxd::DriverLxdConfigOverrides, + use crate::driver::{ + config::DriverOverrides, driver_bare::DriverBareConfigOverrides, + driver_docker::DriverDockerConfigOverrides, driver_lxd::DriverLxdConfigOverrides, }; fn asset_config() -> PathBuf { diff --git a/packages/debmagic/src/test/run.rs b/packages/debmagic/src/test/run.rs index 75b1813..10abeac 100644 --- a/packages/debmagic/src/test/run.rs +++ b/packages/debmagic/src/test/run.rs @@ -5,17 +5,15 @@ use std::{ }; use super::intent::TestIntent; -use crate::build::config::DriverOverrides; use crate::build::source::stage_source_tree; +use crate::driver::{ + Driver, DriverInstance, DriverType, Environment, EnvironmentMetadata, EnvironmentPurpose, + config::{DriverConfig, DriverOverrides}, + create_driver, remove_environment_root, +}; use crate::package::PackageIdentity; use crate::{ - build::{ - artifacts::{copy_changes_artifacts, copy_dir_all, find_changes_file}, - common::{BuildConfig, BuildDriver, BuildDriverType, BuildMetadata, EnvironmentPurpose}, - config::DriverConfig, - remove_environment_root, - signing::SignWith, - }, + build::artifacts::{copy_changes_artifacts, copy_dir_all, find_changes_file}, package::load_package_identity, }; use anyhow::{Context, anyhow, bail}; @@ -39,8 +37,8 @@ pub enum TestOutcome { } struct TestRun { - config: BuildConfig, - driver: Box, + environment: Environment, + driver: DriverInstance, } fn get_build_root_and_identifier( @@ -90,45 +88,43 @@ fn lookup_distro(name: &str) -> anyhow::Result { .ok_or_else(|| anyhow!("unknown distro codename '{name}'")) } -fn load_build_metadata(build_root: &Path) -> anyhow::Result { - let build_metadata_path = build_root.join("build.json"); - if !build_metadata_path.is_file() { - bail!("No build.json found"); +fn load_environment_metadata(root: &Path) -> anyhow::Result { + let metadata_path = root.join("environment.json"); + if !metadata_path.is_file() { + bail!("No environment.json found"); } - let file = fs::OpenOptions::new() - .read(true) - .open(&build_metadata_path)?; + let file = fs::OpenOptions::new().read(true).open(&metadata_path)?; let reader = BufReader::new(&file); serde_json::from_reader(reader).with_context(|| { format!( - "Failed to read build metadata from {} - invalid json", - build_metadata_path.display() + "Failed to read environment metadata from {} - invalid json", + metadata_path.display() ) }) } impl TestRun { fn create( - config: &BuildConfig, + environment: &Environment, driver_config: &DriverConfig, driver_overrides: &DriverOverrides, ) -> anyhow::Result { - let driver = crate::build::get_build_driver(config, driver_config, driver_overrides) - .context(format!("failed to create {:?} build driver", config.driver))?; + let driver = create_driver(environment, driver_config, driver_overrides) + .context(format!("failed to create {:?} driver", environment.driver))?; Ok(Self { - config: config.clone(), + environment: environment.clone(), driver, }) } fn write_metadata(&self) -> anyhow::Result<()> { - let metadata = BuildMetadata { - config: self.config.clone(), - driver_metadata: self.driver.get_build_metadata(), + let metadata = EnvironmentMetadata { + environment: self.environment.clone(), + driver_metadata: self.driver.driver_metadata(), }; - let path = self.config.build_root_dir.join("build.json"); + let path = self.environment.root_dir.join("environment.json"); let json = serde_json::to_string_pretty(&metadata) - .context("Failed to serialize build metadata")?; + .context("Failed to serialize environment metadata")?; fs::write(path, json)?; Ok(()) } @@ -136,38 +132,37 @@ impl TestRun { fn prepare_test_env( intent: &TestIntent, - test_config: &BuildConfig, + environment: &Environment, identity: &PackageIdentity, changes_path: &Path, ) -> anyhow::Result { - let test_root = &test_config.build_root_dir; + let test_root = &environment.root_dir; if intent.config.driver.persistent && test_root.exists() { let test_run = - TestRun::create(test_config, &intent.config.driver, &intent.driver_overrides).context( - format!("failed to create {:?} build driver", test_config.driver), - )?; + TestRun::create(environment, &intent.config.driver, &intent.driver_overrides) + .context(format!("failed to create {:?} driver", environment.driver))?; test_run .driver - .reset_build_root() + .reset_root() .context("failed to reset persistent test directory")?; - test_config + environment .create_dirs() .context("failed to create test directories")?; - stage_source_tree(test_config, identity)?; - copy_changes_artifacts(changes_path, &test_config.build_work_dir())?; + stage_source_tree(environment, identity, intent.config.source_sync_mode, false)?; + copy_changes_artifacts(changes_path, &environment.work_dir())?; return Ok(test_run); } remove_environment_root(test_root, &intent.config.driver)?; - test_config + environment .create_dirs() .context("failed to create test directories")?; - stage_source_tree(test_config, identity)?; - copy_changes_artifacts(changes_path, &test_config.build_work_dir())?; + stage_source_tree(environment, identity, intent.config.source_sync_mode, false)?; + copy_changes_artifacts(changes_path, &environment.work_dir())?; - let test_run = TestRun::create(test_config, &intent.config.driver, &intent.driver_overrides)?; + let test_run = TestRun::create(environment, &intent.config.driver, &intent.driver_overrides)?; Ok(test_run) } @@ -207,8 +202,8 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { } explicit.clone() } else { - let build_metadata_path = build_root.join("build.json"); - if !build_metadata_path.is_file() { + let metadata_path = build_root.join("environment.json"); + if !metadata_path.is_file() { bail!( "no prior build found at {}; run `debmagic build binary` first", build_root.display() @@ -217,22 +212,22 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { find_changes_file(&build_root.join("work"))? }; - let prior_build = if build_root.join("build.json").is_file() { - Some(load_build_metadata(&build_root)?) + let prior_build = if build_root.join("environment.json").is_file() { + Some(load_environment_metadata(&build_root)?) } else { None }; let driver = intent .driver - .or_else(|| prior_build.as_ref().map(|metadata| metadata.config.driver)) + .or_else(|| prior_build.as_ref().map(|metadata| metadata.environment.driver)) .ok_or_else(|| { anyhow!( "no driver specified and no prior build found; pass --driver or run `debmagic build binary` first" ) })?; - if driver == BuildDriverType::Bare && !intent.allow_host_test { + if driver == DriverType::Bare && !intent.allow_host_test { bail!( "the bare driver runs autopkgtest as root directly on the host; \ pass --allow-host-test to opt in explicitly" @@ -242,7 +237,7 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { let distro = if let Some(ref override_distro) = intent.distro { lookup_distro(override_distro)? } else if let Some(ref metadata) = prior_build { - metadata.config.distro.clone() + metadata.environment.distro.clone() } else { bail!( "no prior build metadata found; pass --distro when using --changes without a build root" @@ -250,31 +245,18 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { }; let test_root = test_build_root(&build_root); - let output_dir = prior_build - .as_ref() - .map(|metadata| metadata.config.output_dir.clone()) - .unwrap_or_else(|| intent.source_dir.clone()); - let test_config = BuildConfig { + let environment = Environment { driver, package_name: identity.name.clone(), package_identifier, - source_dir: intent.source_dir.clone(), - output_dir, - build_root_dir: test_root.clone(), + root_dir: test_root.clone(), distro, - sign_package: false, - sign_with: SignWith::Auto, - sign_key: None, - build_debug_symbols: false, - clean: false, persistent: intent.config.driver.persistent, - incremental: false, - source_sync_mode: intent.config.source_sync_mode, purpose: EnvironmentPurpose::Test, }; - let test_run = prepare_test_env(intent, &test_config, &identity, &changes_path) + let test_run = prepare_test_env(intent, &environment, &identity, &changes_path) .context("failed to prepare test environment")?; test_run .write_metadata() @@ -283,23 +265,23 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { let apt_env = [("DEBIAN_FRONTEND", "noninteractive")]; test_run.driver.run_command_checked( &["apt-get", "update"], - &test_config.build_source_dir(), + &environment.staged_source_dir(), true, &apt_env, )?; test_run.driver.run_command_checked( &["apt-get", "install", "-y", "autopkgtest"], - &test_config.build_source_dir(), + &environment.staged_source_dir(), true, &apt_env, )?; - let work_dir = test_config.build_work_dir(); + let work_dir = environment.work_dir(); let changes_filename = changes_path .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| anyhow!("invalid .changes path: {}", changes_path.display()))?; - let source_tree_name = test_config.package_identifier.as_str(); + let source_tree_name = environment.package_identifier.as_str(); let autopkgtest_out_host = test_root.join("autopkgtest-out"); if autopkgtest_out_host.exists() { fs::remove_dir_all(&autopkgtest_out_host)?; @@ -355,7 +337,7 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { eprintln!("Dropping into shell..."); if let Err(shell_error) = test_run .driver - .interactive_shell(&test_config.build_source_dir()) + .interactive_shell(&environment.staged_source_dir()) { eprintln!("Dropping into shell failed: {shell_error}"); } @@ -368,10 +350,10 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { } } - if !test_config.persistent { + if !environment.persistent { // Clear container-owned files from the bind mount before destroying // the container; otherwise the host user cannot remove them later. - if let Err(e) = test_run.driver.reset_build_root() { + if let Err(e) = test_run.driver.reset_root() { eprintln!("Warning: failed to reset test root before cleanup: {e}"); } } From ed0fec41b5453b15c2bc78f4a8175ef0657e84a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Thu, 20 Aug 2026 21:48:49 +0200 Subject: [PATCH 3/4] refactor(cli): drop explicit apt update from test setup This is already done by the environment drivers. The only exception is the bare driver where we assume the host environment to provide an up-to-date apt update --- packages/debmagic/src/test/run.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/debmagic/src/test/run.rs b/packages/debmagic/src/test/run.rs index 10abeac..1afcdff 100644 --- a/packages/debmagic/src/test/run.rs +++ b/packages/debmagic/src/test/run.rs @@ -262,18 +262,11 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { .write_metadata() .context("failed to write test metadata")?; - let apt_env = [("DEBIAN_FRONTEND", "noninteractive")]; - test_run.driver.run_command_checked( - &["apt-get", "update"], - &environment.staged_source_dir(), - true, - &apt_env, - )?; test_run.driver.run_command_checked( &["apt-get", "install", "-y", "autopkgtest"], &environment.staged_source_dir(), true, - &apt_env, + &[("DEBIAN_FRONTEND", "noninteractive")], )?; let work_dir = environment.work_dir(); From 546d340b69d4ce986a45a7534c3dc9bedf28c351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Loipf=C3=BChrer?= Date: Thu, 20 Aug 2026 22:03:02 +0200 Subject: [PATCH 4/4] feat(cli): properly advertise container isolation to autopkgtest --- docs/usage/test.md | 10 ++- packages/debmagic/src/driver/driver_bare.rs | 8 +- packages/debmagic/src/driver/driver_docker.rs | 6 +- packages/debmagic/src/driver/driver_lxd.rs | 6 +- packages/debmagic/src/driver/mod.rs | 31 ++++++++ packages/debmagic/src/test/run.rs | 79 +++++++++++++++++-- 6 files changed, 125 insertions(+), 15 deletions(-) diff --git a/docs/usage/test.md b/docs/usage/test.md index 790766f..9297f69 100644 --- a/docs/usage/test.md +++ b/docs/usage/test.md @@ -43,12 +43,14 @@ Driver-specific flags (`--driver-docker-base-image`, `--driver-lxd-*`) mirror `d Use the same drivers as for builds. Pass `--driver` explicitly (or rely on the driver recorded in the prior build's `environment.json`): -| Driver | Isolation | +| Driver | Isolation the Environment provides | |---|---| -| `lxd` / `incus` | Full container isolation | -| `docker` | Full container isolation | +| `lxd` / `incus` | Container (`isolation-container`) | +| `docker` | Container (`isolation-container`) | | `bare` | None — tests run as root on the host; requires `--allow-host-test` | +The driver *is* the testbed, so autopkgtest is told to run tests whose isolation restrictions the environment actually satisfies (`--ignore-restrictions`, only for those rungs). Tests that declare `Restrictions: isolation-container` therefore run on Docker/LXD/Incus instead of skipping. `isolation-machine` is not provided by any current driver (none is a VM); those tests still skip. Bare provides nothing, even with `--allow-host-test`. + ## Exit codes | Code | Meaning | @@ -57,7 +59,7 @@ Use the same drivers as for builds. Pass `--driver` explicitly (or rely on the d | `1` | Test failure, testbed error, or other autopkgtest error | | `2` | Strict-only failure: skipped tests or no tests declared under `--strict` | -autopkgtest skips tests whose `Restrictions:` the `null` backend cannot satisfy (e.g. `isolation-container`, `isolation-machine`). Skips are reported loudly; use `--strict` to escalate them to exit code 2. +autopkgtest skips tests whose `Restrictions:` the Environment cannot satisfy (today: `isolation-machine` on every current driver). Skips are reported loudly; use `--strict` to escalate them to exit code 2. If no `debian/tests/control` exists (or it declares no tests), the run exits 0 with a notice — or exit 2 under `--strict`. diff --git a/packages/debmagic/src/driver/driver_bare.rs b/packages/debmagic/src/driver/driver_bare.rs index d27f9a8..5220978 100644 --- a/packages/debmagic/src/driver/driver_bare.rs +++ b/packages/debmagic/src/driver/driver_bare.rs @@ -2,7 +2,9 @@ use std::{path::Path, process::Command}; use serde::{Deserialize, Serialize}; -use crate::driver::{Driver, DriverType, Environment, EnvironmentMetadata, config::DriverConfig}; +use crate::driver::{ + Driver, DriverType, Environment, EnvironmentMetadata, IsolationCapability, config::DriverConfig, +}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] @@ -97,6 +99,10 @@ impl Driver for DriverBare { DriverType::Bare } + fn isolation_capability(&self) -> IsolationCapability { + IsolationCapability::None + } + fn reset_root(&self) -> std::io::Result<()> { if self.environment.root_dir.exists() { std::fs::remove_dir_all(&self.environment.root_dir)?; diff --git a/packages/debmagic/src/driver/driver_docker.rs b/packages/debmagic/src/driver/driver_docker.rs index 07b4fe3..4b3e2fa 100644 --- a/packages/debmagic/src/driver/driver_docker.rs +++ b/packages/debmagic/src/driver/driver_docker.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use crate::driver::{ APT_MIRROR_SCRIPT, Driver, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, - EnvironmentMetadata, config::DriverConfig, container_name_from_metadata, + EnvironmentMetadata, IsolationCapability, config::DriverConfig, container_name_from_metadata, container_name_metadata, environment_fingerprint, resource_name, run_checked, translate_path_in_container, }; @@ -458,6 +458,10 @@ impl Driver for DriverDocker { fn driver_type(&self) -> DriverType { DriverType::Docker } + + fn isolation_capability(&self) -> IsolationCapability { + IsolationCapability::Container + } } impl DriverDocker { diff --git a/packages/debmagic/src/driver/driver_lxd.rs b/packages/debmagic/src/driver/driver_lxd.rs index 559fe81..e22d4ac 100644 --- a/packages/debmagic/src/driver/driver_lxd.rs +++ b/packages/debmagic/src/driver/driver_lxd.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::driver::{ APT_MIRROR_SCRIPT, Driver, DriverType, ENVIRONMENT_DIR_IN_CONTAINER, Environment, - EnvironmentMetadata, config::DriverConfig, container_name_from_metadata, + EnvironmentMetadata, IsolationCapability, config::DriverConfig, container_name_from_metadata, container_name_metadata, environment_fingerprint, resource_name, run_checked, translate_path_in_container, }; @@ -611,6 +611,10 @@ impl Driver for DriverLxd { LxdVariant::Incus => DriverType::Incus, } } + + fn isolation_capability(&self) -> IsolationCapability { + IsolationCapability::Container + } } impl DriverLxd { diff --git a/packages/debmagic/src/driver/mod.rs b/packages/debmagic/src/driver/mod.rs index d8eeec0..3193571 100644 --- a/packages/debmagic/src/driver/mod.rs +++ b/packages/debmagic/src/driver/mod.rs @@ -106,6 +106,19 @@ pub enum DriverType { Incus, } +/// Isolation an Environment actually provides for a TestRun. +/// +/// A ladder: none, then container, then machine. An Environment advertises +/// its rung and every rung below. The Driver that created the Environment +/// reports the rung; it must not claim a rung it does not provide. +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] +pub enum IsolationCapability { + #[default] + None, + Container, + Machine, +} + #[derive(Debug, Copy, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EnvironmentPurpose { @@ -216,6 +229,9 @@ pub trait Driver { fn driver_type(&self) -> DriverType; + /// Isolation this Driver's Environment actually provides. + fn isolation_capability(&self) -> IsolationCapability; + fn reset_root(&self) -> io::Result<()>; fn reused_environment(&self) -> bool { @@ -276,6 +292,14 @@ impl Driver for DriverInstance { } } + fn isolation_capability(&self) -> IsolationCapability { + match self { + Self::Docker(d) => d.isolation_capability(), + Self::Bare(d) => d.isolation_capability(), + Self::Lxd(d) => d.isolation_capability(), + } + } + fn reset_root(&self) -> io::Result<()> { match self { Self::Docker(d) => d.reset_root(), @@ -506,4 +530,11 @@ mod tests { let environment: Environment = serde_json::from_str(json).unwrap(); assert_eq!(environment.purpose, EnvironmentPurpose::Build); } + + #[test] + fn isolation_capability_is_a_ladder() { + assert!(IsolationCapability::None < IsolationCapability::Container); + assert!(IsolationCapability::Container < IsolationCapability::Machine); + assert!(IsolationCapability::None < IsolationCapability::Machine); + } } diff --git a/packages/debmagic/src/test/run.rs b/packages/debmagic/src/test/run.rs index 1afcdff..8690b9b 100644 --- a/packages/debmagic/src/test/run.rs +++ b/packages/debmagic/src/test/run.rs @@ -8,6 +8,7 @@ use super::intent::TestIntent; use crate::build::source::stage_source_tree; use crate::driver::{ Driver, DriverInstance, DriverType, Environment, EnvironmentMetadata, EnvironmentPurpose, + IsolationCapability, config::{DriverConfig, DriverOverrides}, create_driver, remove_environment_root, }; @@ -191,6 +192,29 @@ fn print_autopkgtest_notices(exit_code: i32, summary_path: &Path) { } } +/// autopkgtest(1) `--ignore-restrictions` for this IsolationCapability and +/// every rung below it. +/// +/// virt-null cannot advertise isolation via `--fake-capability`: it always +/// sets a host downtmp prefix, and autopkgtest 5.49+ asserts that +/// isolation-container/machine and downtmp-host are mutually exclusive +/// (testbed failure, exit 16). Ignoring only the rungs this Environment +/// actually provides is the same honesty rule: isolation-container tests +/// run on Docker/LXD/Incus and still skip on Bare; isolation-machine still +/// skips on every current Driver. +fn autopkgtest_isolation_args(isolation: IsolationCapability) -> Vec<&'static str> { + match isolation { + IsolationCapability::None => vec![], + IsolationCapability::Container => vec!["--ignore-restrictions", "isolation-container"], + IsolationCapability::Machine => { + vec![ + "--ignore-restrictions", + "isolation-container,isolation-machine", + ] + } + } +} + pub fn run_test(intent: &TestIntent) -> anyhow::Result { let identity = load_package_identity(&intent.source_dir)?; let (package_identifier, build_root) = @@ -285,17 +309,22 @@ pub fn run_test(intent: &TestIntent) -> anyhow::Result { // Binary-only builds have no .dsc in the .changes; pass the staged source // tree alongside the .changes so debian/tests/ is found without rebuilding // (-B). See autopkgtest(1) "TESTING A DEBIAN PACKAGE" (.changes + tree). - let autopkgtest_cmd = [ - "autopkgtest", - "-B", - "--no-auto-control", - &format!("--output-dir={output_dir_arg}"), - &format!("--summary={summary_arg}"), + // IsolationCapabilities become `--ignore-restrictions` (autopkgtest args, + // not virt-null `--fake-capability`; see autopkgtest_isolation_args). + let output_dir_flag = format!("--output-dir={output_dir_arg}"); + let summary_flag = format!("--summary={summary_arg}"); + let source_tree_arg = format!("{source_tree_name}/"); + let isolation_args = autopkgtest_isolation_args(test_run.driver.isolation_capability()); + let mut autopkgtest_cmd = vec!["autopkgtest", "-B", "--no-auto-control"]; + autopkgtest_cmd.extend(isolation_args); + autopkgtest_cmd.extend([ + output_dir_flag.as_str(), + summary_flag.as_str(), changes_filename, - &format!("{source_tree_name}/"), + source_tree_arg.as_str(), "--", "null", - ]; + ]); let exit_code = test_run .driver @@ -442,4 +471,38 @@ mod tests { TestOutcome::StrictFailure ); } + + #[test] + fn autopkgtest_isolation_args_ignore_provided_rungs_only() { + assert_eq!( + autopkgtest_isolation_args(IsolationCapability::None), + [] as [&str; 0] + ); + assert_eq!( + autopkgtest_isolation_args(IsolationCapability::Container), + ["--ignore-restrictions", "isolation-container"] + ); + assert_eq!( + autopkgtest_isolation_args(IsolationCapability::Machine), + [ + "--ignore-restrictions", + "isolation-container,isolation-machine" + ] + ); + } + + #[test] + fn autopkgtest_isolation_args_never_use_fake_capability() { + for isolation in [ + IsolationCapability::None, + IsolationCapability::Container, + IsolationCapability::Machine, + ] { + let args = autopkgtest_isolation_args(isolation); + assert!( + !args.iter().any(|arg| arg.contains("fake-capability")), + "virt-null --fake-capability isolation-* crashes autopkgtest 5.49+ (downtmp_prefix assert); isolation={isolation:?} args={args:?}" + ); + } + } }