Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions debian/control
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Source: debmagic
Testsuite: autopkgtest
Section: devel
Maintainer: Debmagic Maintainers <debmagic@sft.lol>
Uploaders:
Expand Down
2 changes: 2 additions & 0 deletions debian/tests/control
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Tests: smoke
Depends: @
5 changes: 5 additions & 0 deletions debian/tests/smoke
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/sh
set -e

debmagic --version
debmagic test --help
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

usage/getting-started.md
usage/build.md
usage/test.md
usage/source.md
usage/config.md
usage/modules/index.md
Expand Down
88 changes: 88 additions & 0 deletions docs/usage/test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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` against the `.debs` of a prior `debmagic build`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.debs is not right, just leave it out and say need prior 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>` | Path to a `.changes` file whose directory supplies the built `.debs` (for pipeline use) |
| `--distro <name>` | Override the target distro for the test environment (defaults to the prior build's distro from `build.json`, not the changelog) |
| `--proposed` | Enable the `<release>-proposed` pocket in the test environment |
| `--apt-mirror <url>` | Mirror URL (same as [`debmagic build`](build.md)) |
| `--source-dir <dir>` | Directory containing the `debian/` package directory |
| `--allow-host-test` | Allow the bare driver, which runs autopkgtest as root on the host |

[`debmagic shell`](#inspecting-a-failed-test-run) — attach an interactive shell to a test environment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how do we select build/test environment?


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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can pass capabilities to the null driver which it then pretends to have. and we are in a container (or even lxd vm), so we can give those caps to the null driver.


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 with a TTY, `debmagic test` offers an interactive shell inside the test environment (destroyed on shell exit unless `--persistent` was used). With a persistent test environment:

```shell
# if you're in the package still
debmagic shell
# from the outside:
debmagic shell --source-dir /path/to/parent/of/debian/dir
```

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.
12 changes: 11 additions & 1 deletion packages/debmagic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <codename>` — select the target distro/release (e.g. `trixie`, `noble`) if the changelog is ambiguous
Expand Down
58 changes: 56 additions & 2 deletions packages/debmagic/src/build/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use std::{
use anyhow::{Context, anyhow, bail};
use debian_control::lossless::changes::Changes;

fn changes_file_in(build_dir: &Path) -> anyhow::Result<PathBuf> {
/// Locate the single `.changes` file in a build work directory.
pub fn find_changes_file(build_dir: &Path) -> anyhow::Result<PathBuf> {
let mut paths = fs::read_dir(build_dir)
.with_context(|| {
format!(
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -106,6 +107,59 @@ 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(())
}

#[cfg(test)]
mod tests {
use std::os::unix::fs::symlink;
Expand Down
78 changes: 74 additions & 4 deletions packages/debmagic/src/build/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,24 @@ pub enum SourceSyncMode {

pub type DriverSpecificBuildMetadata = HashMap<String, String>;

#[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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -194,13 +218,29 @@ pub const APT_MIRROR_SCRIPT: &str = include_str!("scripts/mirror.py");
pub trait BuildDriver {
fn get_build_metadata(&self) -> DriverSpecificBuildMetadata;

fn run_command_exit_status(
&self,
cmd: &[&str],
cwd: &Path,
requires_root: bool,
env_add: &[(&str, &str)],
) -> std::io::Result<i32>;

fn run_command_env(
&self,
cmd: &[&str],
cwd: &Path,
requires_root: bool,
env_add: &[(&str, &str)],
) -> std::io::Result<()>;
) -> std::io::Result<()> {
let code = self.run_command_exit_status(cmd, cwd, requires_root, env_add)?;
if code != 0 {
return Err(std::io::Error::other(format!(
"Command failed with exit code: {code}"
)));
}
Ok(())
}

fn run_command(&self, cmd: &[&str], cwd: &Path, requires_root: bool) -> std::io::Result<()> {
self.run_command_env(cmd, cwd, requires_root, &[])
Expand Down Expand Up @@ -276,15 +316,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);
}
}
Loading
Loading