Files
pkh/src/deb/mod.rs
T
vhaudiquet 9acf650fc9 deb,config: the staged-tree budget replaces the size cap
The size cap was the wrong shape: an --incremental user asked for a
carried tree, and the invariant that matters is which trees survive —
the newest successful one (the next run's automatic fork base) and the
newest one (what a failed run is re-forked from explicitly). The cap
is gone, with the tree measurement and manifest field it needed; the
retention planner now works on the records' outcomes alone and fills a
budget: staged_trees staged trees per target, the newer ones first,
the pair always among them. When the budget cannot hold the pair (one
staged tree configured, the newest build failed and an older
successful tree present) only one of the two is successful and that
one is kept; with no successful tree at all, the most recent one is.

The budget is a setting of the nested config file:

    deb:
      incremental:
        staged_trees: 2

(default 2; 0 clamps to 1 with a warning — the newest successful tree
always survives). The fork-base selection no longer truncates the
candidate list to the budget before ranking it: with a budget of one
and a failed newest tree, that truncation hid the successful record
and forked the failed one instead.
2026-10-03 23:43:10 +02:00

2158 lines
82 KiB
Rust

mod cross;
/// Ephemeral (per-build) unshare contexts, including the process-global
/// cleanup-hook registry drained by the SIGINT handler
pub(crate) mod ephemeral;
pub(crate) mod local;
/// Resumable build sessions: manifest, discovery, `pkh deb list` and the
/// host-tree snapshot/sync that keeps build artifacts alive across attempts
pub mod session;
use crate::context::{self, Context};
use crate::logfmt::{
AptInstallClassifier, AptUpdateClassifier, Classifier, GenericClassifier, MakeClassifier,
MmdebstrapClassifier, QuiltClassifier,
};
use crate::report::{BuildTarget, BuildView, TargetKind};
use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Build mode for the binary build
#[derive(PartialEq)]
pub enum BuildMode {
/// Local build, directly on the context
Local,
}
/// Phases of a binary build, announced to the [`BuildView`]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
/// Downloading the chroot tarball (mmdebstrap)
PreparingChroot,
/// Extracting the chroot tarball
ExtractingChroot,
/// Device nodes, /proc bind mount, etc.
FinalizingChroot,
/// apt-get update
UpdatingPackageLists,
/// Installing build-essential & co
InstallingEssentials,
/// quilt push -a
ApplyingPatches,
/// --inject packages
InjectingPackages,
/// apt-get build-dep
InstallingBuildDeps,
/// Drop the resumed tree's input-untracked stamp caches (quirk:
/// `resume_clear`)
ClearingResumeCaches,
/// Establish the unprivileged build identity for the rules steps
/// (rootless builds): a nested user namespace mapping the chroot's
/// root ownership onto the identity — the tree needs no chown — or,
/// when unavailable, a build user with a chown tree handover
PreparingBuildUser,
/// debian/rules build
Building,
/// fakeroot debian/rules binary
ProducingBinaries,
/// Retrieving produced .deb files
RetrievingArtifacts,
}
impl Phase {
/// Human-readable label displayed in the status bar
pub fn label(&self) -> &'static str {
match self {
Phase::PreparingChroot => "Preparing chroot",
Phase::ExtractingChroot => "Extracting chroot",
Phase::FinalizingChroot => "Finalizing chroot",
Phase::UpdatingPackageLists => "Updating package lists",
Phase::InstallingEssentials => "Installing essential packages",
Phase::ApplyingPatches => "Applying patches",
Phase::InjectingPackages => "Injecting packages",
Phase::InstallingBuildDeps => "Installing build dependencies",
Phase::ClearingResumeCaches => "Clearing resumed build caches",
Phase::PreparingBuildUser => "Preparing the build user",
Phase::Building => "Building package",
Phase::ProducingBinaries => "Producing binary packages",
Phase::RetrievingArtifacts => "Retrieving artifacts",
}
}
/// Stable machine name of the phase, as recorded in a build
/// record's profile (`pkh deb profile`); unlike [`Self::label`]
/// this is a storage key, never reworded.
pub fn key(&self) -> &'static str {
match self {
Phase::PreparingChroot => "preparing_chroot",
Phase::ExtractingChroot => "extracting_chroot",
Phase::FinalizingChroot => "finalizing_chroot",
Phase::UpdatingPackageLists => "updating_package_lists",
Phase::InstallingEssentials => "installing_essentials",
Phase::ApplyingPatches => "applying_patches",
Phase::InjectingPackages => "injecting_packages",
Phase::InstallingBuildDeps => "installing_build_deps",
Phase::ClearingResumeCaches => "clearing_resume_caches",
Phase::PreparingBuildUser => "preparing_build_user",
Phase::Building => "building",
Phase::ProducingBinaries => "producing_binaries",
Phase::RetrievingArtifacts => "retrieving_artifacts",
}
}
/// The phase `key` names, when it is one of the known machine
/// names; a key written by an older pkh whose phase has since been
/// renamed renders as its raw key.
pub fn from_key(key: &str) -> Option<Phase> {
let phase = match key {
"preparing_chroot" => Phase::PreparingChroot,
"extracting_chroot" => Phase::ExtractingChroot,
"finalizing_chroot" => Phase::FinalizingChroot,
"updating_package_lists" => Phase::UpdatingPackageLists,
"installing_essentials" => Phase::InstallingEssentials,
"applying_patches" => Phase::ApplyingPatches,
"injecting_packages" => Phase::InjectingPackages,
"installing_build_deps" => Phase::InstallingBuildDeps,
"clearing_resume_caches" => Phase::ClearingResumeCaches,
"preparing_build_user" => Phase::PreparingBuildUser,
"building" => Phase::Building,
"producing_binaries" => Phase::ProducingBinaries,
"retrieving_artifacts" => Phase::RetrievingArtifacts,
_ => return None,
};
Some(phase)
}
}
/// Default line classifier rewriting a phase's subprocess output
fn default_classifier(phase: Phase) -> Box<dyn Classifier> {
match phase {
Phase::PreparingChroot => Box::new(MmdebstrapClassifier::new()),
Phase::ExtractingChroot | Phase::FinalizingChroot => Box::new(GenericClassifier::new()),
Phase::UpdatingPackageLists => Box::new(AptUpdateClassifier::new()),
Phase::InstallingEssentials => Box::new(AptInstallClassifier::new("Installing essentials")),
Phase::ApplyingPatches => Box::new(QuiltClassifier::new(0)),
Phase::InjectingPackages => Box::new(AptInstallClassifier::new("Injecting packages")),
Phase::InstallingBuildDeps => {
Box::new(AptInstallClassifier::new("Installing build dependencies"))
}
Phase::ClearingResumeCaches | Phase::PreparingBuildUser => {
Box::new(GenericClassifier::new())
}
Phase::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
}
}
/// Records the wall-clock duration of every [`Phase`] the build flow
/// announces: one profile entry per executed phase, in execution
/// order. A phase closes when the next one is entered or at
/// [`BuildProfiler::finish`] — so a failed build's profile ends at the
/// phase the error propagated out of, its entry covering the phase
/// until the failure. Conditional phases that never ran (the chroot
/// tarball was cached, the device nodes were ready) record nothing.
pub(crate) struct BuildProfiler {
/// The phase being timed, since when.
open: Option<(Phase, std::time::Instant)>,
/// Closed phases, in execution order.
phases: Vec<session::PhaseTiming>,
}
impl BuildProfiler {
/// A profiler with nothing recorded yet.
pub(crate) fn new() -> Self {
Self {
open: None,
phases: Vec::new(),
}
}
/// Close the phase being timed (if any) and open `phase`.
pub(crate) fn enter(&mut self, phase: Phase) {
self.close_open();
self.open = Some((phase, std::time::Instant::now()));
}
/// Close the open phase and return the recorded profile.
pub(crate) fn finish(&mut self) -> session::BuildProfile {
self.close_open();
session::BuildProfile {
phases: std::mem::take(&mut self.phases),
}
}
fn close_open(&mut self) {
if let Some((phase, started)) = self.open.take() {
self.phases.push(session::PhaseTiming {
phase: phase.key().to_string(),
millis: started.elapsed().as_millis() as u64,
});
}
}
}
/// Enter `phase` on the view with its default line classifier, timing
/// it into `profiler`
pub(crate) fn enter_phase(view: &dyn BuildView, phase: Phase, profiler: &mut BuildProfiler) {
profiler.enter(phase);
view.phase(phase.label(), default_classifier(phase));
}
/// Parameters of one [`build_binary_package`] call.
pub struct DebBuildOptions<'a> {
/// Target architecture; defaults to the host architecture.
pub arch: Option<String>,
/// Target distribution series; defaults to the changelog series
/// (UNRELEASED resolves to the vendor's development series).
pub series: Option<String>,
/// Distribution pocket to resolve build-dependencies from.
pub pocket: Option<String>,
/// Base URL the build environment resolves from (`pkh deb --mirror`):
/// passed to mmdebstrap as the mirror positional, so the chroot
/// tarball's apt sources carry it and every apt inside the build
/// follows. A pinned runner (a caching proxy, a chosen mirror) uses
/// it to resolve every package from one URL; the mirror must serve
/// every suite and architecture the build resolves — the security
/// pocket and, for a cross build, the target architecture's archive
/// included. PPAs and other third-party sources are left alone.
pub mirror: Option<String>,
/// Source tree to build; defaults to the process working directory.
pub cwd: Option<PathBuf>,
/// Cross-compile for the target architecture instead of using
/// qemu-binfmt.
pub cross: bool,
/// Build mode; defaults to [`BuildMode::Local`].
pub mode: Option<BuildMode>,
/// PPAs to add for build-dependencies (`user/ppa_name`).
pub ppa: Vec<String>,
/// Packages to inject into the build environment before build-dep
/// (.deb paths, archive names or PPA packages).
pub inject: Vec<String>,
/// Parallel build jobs; defaults to the core count available in the
/// build context.
pub jobs: Option<usize>,
/// Explicit build context; defaults to the current context.
pub ctx: Option<Arc<Context>>,
/// Incremental request (`pkh deb --incremental [<id>]`): which
/// record to fork as the base of this build's staged tree, and
/// whether this build's own staged tree is retained. Defaults to a
/// plain run.
pub incremental: Incremental,
/// Reuse the shared ccache of this target (on by default; `pkh deb
/// --no-ccache` disables it — an escape hatch for `debian/rules`
/// whose own compiler handling conflicts with the shim).
pub ccache: bool,
/// Where build events (phases, progress, outcome) are reported.
pub view: &'a dyn BuildView,
}
/// The `--incremental` request of a `pkh deb` build: which record the
/// build forks its staged tree from, and whether its own staged tree is
/// retained afterwards (the record's metadata and log are kept for
/// every build either way).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Incremental {
/// A plain run (`pkh deb` without `--incremental`): build from
/// scratch in a record whose staged tree is dropped at the end —
/// metadata and log only remain.
#[default]
Off,
/// `--incremental`: fork the auto-selected base — the newest
/// successful record of this identity carrying a staged tree — and
/// retain the new record's staged tree. With no candidate the build
/// runs from scratch and its record becomes the identity's first
/// base.
Auto,
/// `--incremental <id>`: fork that exact record (unique id or
/// prefix, restricted to the records of this host tree; a FAILED
/// record is a valid base — the user pinned it deliberately) and
/// retain the new record's staged tree.
Id(String),
}
/// A view delegating every event to `inner` while teeing all captured
/// subprocess lines into a build record's log through [`RecordLogSink`]:
/// everything downstream (the chroot bootstrap, the build phases) asks
/// the view for its sink, so one wrapper tees the whole run.
struct RecordLogView<'a> {
/// The caller's view.
inner: &'a dyn BuildView,
/// The record log sink, forwarding to the inner view's sink too.
log: Arc<session::RecordLogSink>,
}
impl BuildView for RecordLogView<'_> {
fn target(&self, target: crate::report::BuildTarget) {
self.inner.target(target);
}
fn phase(&self, name: &str, classifier: Box<dyn Classifier>) {
self.inner.phase(name, classifier);
}
fn message(&self, text: &str) {
self.inner.message(text);
}
fn progress(&self, label: &str, pos: usize, total: usize) {
self.inner.progress(label, pos, total);
}
fn byte_progress(&self, label: &str, pos: u64, total: u64) {
self.inner.byte_progress(label, pos, total);
}
fn sink(&self) -> Option<Arc<dyn crate::context::LineSink>> {
Some(self.log.clone())
}
fn finish_success(&self, artifacts: &[PathBuf]) {
self.inner.finish_success(artifacts);
}
fn finish_failure(&self) {
self.inner.finish_failure();
}
fn suspend(&self) {
self.inner.suspend();
}
fn is_enabled(&self) -> bool {
self.inner.is_enabled()
}
}
impl Default for DebBuildOptions<'_> {
fn default() -> Self {
static QUIET: crate::report::Quiet = crate::report::Quiet;
DebBuildOptions {
arch: None,
series: None,
pocket: None,
mirror: None,
cwd: None,
cross: false,
mode: None,
ppa: Vec::new(),
inject: Vec::new(),
jobs: None,
ctx: None,
incremental: Incremental::Off,
ccache: true,
view: &QUIET,
}
}
}
/// Build package in 'cwd' to a .deb
///
/// Returns the list of produced artifacts (.deb files plus the upload
/// metadata `.buildinfo`/`.changes`) retrieved locally, identified from
/// `debian/files` and the native metadata generation rather than by
/// globbing the build root (which would surface stale files). Subprocess
/// output is captured through the view's sink (live view + tee log for the
/// terminal adapter); on failure the view is cleared and prints a summary
/// of captured errors.
pub async fn build_binary_package(
opts: DebBuildOptions<'_>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let view = opts.view;
let result = build_binary_package_impl(opts).await;
if result.is_err() {
view.finish_failure();
}
result
}
/// Implementation of [`build_binary_package`], without failure handling
async fn build_binary_package_impl(
opts: DebBuildOptions<'_>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let DebBuildOptions {
ref arch,
ref series,
ref pocket,
ref mirror,
ref cwd,
cross,
ref mode,
ref ppa,
ref inject,
ref jobs,
ref ctx,
ref incremental,
ccache,
view,
} = opts;
let cwd = cwd.as_deref().unwrap_or_else(|| Path::new("."));
// A mirror without a scheme cannot be an apt source URI: fail before
// any chroot work, where the failure would surface as an opaque
// apt-get update error.
if let Some(mirror) = mirror
&& !mirror.contains("://")
{
return Err(
format!("Invalid mirror '{mirror}': expected a base URL (scheme://host/path)").into(),
);
}
// Parse changelog to get package name, version and series
let changelog_path = cwd.join("debian/changelog");
let (package, version, package_series) =
crate::changelog::parse_changelog_header(&changelog_path)?;
// UNRELEASED is not a real archive series: without an explicit --series,
// build against the development series of the host vendor's distribution
// instead. An explicit --series always wins.
let mut series = if let Some(s) = series {
s.to_string()
} else if crate::distro_info::is_unreleased(&package_series) {
let dist = crate::build::env::current_vendor();
let series = crate::distro_info::effective_series(&package_series, &dist).await?;
log::info!(
"Changelog is UNRELEASED, building against series {}",
series
);
series
} else {
package_series.clone()
};
// A changelog (or an explicit --series) may carry a suite name instead of
// the series codename: Debian conventionally writes 'unstable' where the
// series data carries 'sid'. Resolve the alias so the codename identifies
// the target everywhere downstream — the chroot bootstrap, the caches and
// the distro-info lookups all key on codenames.
if let Some((_, codename)) = crate::distro_info::resolve_suite_alias(&series) {
log::info!("Suite '{}' resolved to series {}", series, codename);
series = codename;
}
// A changelog may also target a pocket suite ('trixie-security') rather
// than the bare series: split the pocket off so the build resolves
// against the base series with that pocket enabled — the same target an
// explicit '-s trixie -p security' names. An explicit --pocket wins.
// The pocket must be one of the base series' distro-info pockets, which
// also guards against series names merely containing a dash.
let mut pocket = pocket.clone();
if pocket.is_none()
&& let Some((base, pocket_name)) = series
.rsplit_once('-')
.map(|(b, p)| (b.to_string(), p.to_string()))
&& let Ok(dist) = crate::distro_info::get_dist_from_series(&base).await
&& let Ok(pockets) = crate::distro_info::get_dist_pockets(&dist)
&& pockets.iter().any(|p| p.as_str() == pocket_name)
{
pocket = Some(pocket_name.clone());
series = base;
log::info!(
"Pocket suite resolved to series {} (pocket {})",
series,
pocket_name
);
}
let series = series.as_str();
let current_arch = crate::get_current_arch();
let arch = arch.as_deref().unwrap_or(&current_arch);
// Make sure we select a specific mode, either using user-requested
// or by using default for user-supplied parameters
let default_mode = BuildMode::Local;
let mode = mode.as_ref().unwrap_or(&default_mode);
// Create an ephemeral unshare context for all Local builds
// Use qemu_binfmt when target architecture differs from host and cross is not requested
let chroot_arch = if mode == &BuildMode::Local && arch != current_arch && !cross {
Some(arch)
} else {
None
};
// Use provided context or get current
let base_ctx = ctx.clone().unwrap_or_else(context::current);
// Identify the target in the live view once the changelog is parsed, so
// even the chroot download output is attributed and tee'd
view.target(BuildTarget {
package: &package,
version: &version,
target: &format!("{series}/{arch}"),
display: format!("Building {package} ({version}) for {series}/{arch}"),
kind: TargetKind::Binary,
tee_log: true,
});
// Create an ephemeral unshare context for all Local builds. It is kept in
// this scope so it outlives the guarded section below and is only dropped
// once the live view has been cleared.
//
// Build records are a local-only feature: with a local base context
// every build runs in its own record under the sessions directory
// (forked from a previous one with `--incremental`, thrown away
// otherwise); every other base context keeps the historical anonymous
// temp chroot.
let session_enabled = *mode == BuildMode::Local
&& matches!(base_ctx.config, crate::context::ContextConfig::Local);
let host_tree = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
let identity = session::SessionIdentity {
package: package.clone(),
series: series.to_string(),
arch: arch.to_string(),
cross,
};
let mut keep_upper = false;
let session = if session_enabled {
match resolve_record(
incremental,
&host_tree,
&identity,
&version,
series,
chroot_arch,
mirror.as_deref(),
view,
) {
Ok((record, keep)) => {
keep_upper = keep;
Some(record)
}
Err(e) => {
view.finish_failure();
return Err(e);
}
}
} else {
None
};
// Tee every captured build line into the record's log: the wrapper
// view hands the combined sink to everything downstream (the chroot
// bootstrap, the build phases), so the log is a full tee whatever
// renders the output. A view that consumes lines itself (the live
// view on a terminal) is wrapped as-is; a view with NO sink (Quiet,
// i.e. --verbose or a piped stdout) would have let the subprocess
// inherit the terminal — the wrapper removes that fallback, so the
// raw lines are written by a terminal sink instead, and --verbose
// keeps showing raw output. A record-less build (non-local base
// context) keeps the plain view.
let record_log: Option<Result<Arc<session::RecordLogSink>, std::io::Error>> =
session.as_ref().map(|s| {
session::RecordLogSink::new(
s,
opts.view
.sink()
.or_else(|| Some(Arc::new(session::RawWriterSink::terminal()))),
)
.map(Arc::new)
});
let view: &dyn BuildView = match record_log {
Some(Ok(log)) => &RecordLogView {
inner: opts.view,
log,
},
Some(Err(e)) => {
log::warn!("Cannot open the build record's log: {e}");
opts.view
}
None => opts.view,
};
// The shared ccache of this target — skipped with --no-ccache.
let ccache_dir = (session_enabled && ccache).then(|| session::ccache_dir(&identity));
let mirror_arg = mirror.as_deref();
// The per-phase wall-clock profile of this run: fed by every
// enter_phase call from the chroot bootstrap to the artifact
// retrieval, recorded on the record at the end of the run.
let mut profiler = BuildProfiler::new();
let mut guard = if let Some(s) = session.clone() {
Some(
ephemeral::EphemeralContextGuard::new_for_session(
s,
series,
chroot_arch,
mirror_arg,
base_ctx.clone(),
view,
keep_upper,
ccache_dir.as_deref(),
&mut profiler,
)
.await?,
)
} else if *mode == BuildMode::Local {
Some(
ephemeral::EphemeralContextGuard::new_with_context(
series,
chroot_arch,
mirror_arg,
base_ctx.clone(),
view,
&mut profiler,
)
.await?,
)
} else {
None
};
// Determine the build context explicitly: for Local builds it is the
// ephemeral context the guard just created (taken from the guard itself,
// never from the process-global, which concurrent builds may have
// re-pointed at their own chroot); otherwise the base context is used
// directly.
let build_ctx = match guard.as_ref() {
Some(g) => g.context(),
None => base_ctx.clone(),
};
// The measured durations of the build's work phases, recorded on the
// record when the run ends — success or failure alike.
let mut timings = session::BuildTimings::default();
let result = async {
// Prepare build directory: the stable /tmp/pkh-build for session
// builds (see EphemeralContextGuard::build_root — the overlay
// upperdir mirrors paths relative to the merge point), a fresh
// timestamped temp dir otherwise.
let build_root = match guard.as_ref() {
Some(g) => g.build_root()?,
None => build_ctx.create_temp_dir()?,
};
// Ensure availability of all needed files for the build. Session
// builds sync the persistent overlay upperdir with the host tree
// first (propagating host-side deletions and modifications into
// the upperdir) and re-mount it, so the previous attempt's build
// artifacts (object files) are visible to make; a cold staging
// (no snapshot, or no overlay support) stages a fresh copy.
//
// The staged tree is the pointed-at tree itself — its content is
// the merge point of the build root — never its parent: siblings
// stay on the host, so a tree sitting in a busy directory does
// not drag it into the chroot.
let mut build_resume = false;
if let Some(s) = &session {
build_resume = session::sync_upper_for_resume(s, &host_tree).map_err(|e| {
format!(
"cannot sync the carried tree of the incremental build \
with the host tree: {e}"
)
})?;
if build_resume {
log::info!(
"Reusing the build artifacts of the previous attempt (incremental build)"
);
// Before the overlay mounts (see the function's doc): a
// setpriv-era tree handed over here lets the mapped
// build identity run the rules; a failed handover makes
// the identity setup fall back to the chown handover.
if let Err(e) = local::migrate_upper_ownership(s) {
log::warn!(
"cannot hand the staged tree to the invoking user \
({e}); falling back to the chown handover"
);
}
}
}
let staged_path = if let Some(s) = &session {
// Re-mount the session's persistent upperdir over the host
// tree. If overlay staging is unavailable (no overlayfs, mount
// failure, non-overlay driver), wipe the staged tree and
// re-stage a fresh copy: copying over the previous attempt's
// tree would merge stale build artifacts and resurrect
// host-deleted files into the build. Still environment-
// resumable, just not artifact-resumable.
let staging = match build_ctx.ensure_available_with_overlay(
&host_tree,
&build_root,
&s.upper_dir(),
&s.work_dir(),
) {
Ok(staging) => staging,
Err(e) => {
log::info!(
"Overlay staging unavailable ({}): the staged tree is \
re-staged from scratch, build artifacts are discarded",
e
);
s.wipe_staged_tree()?;
let path = build_ctx.ensure_available(&host_tree, &build_root)?;
build_resume = false;
crate::context::OverlayStaging {
path,
overlay: false,
}
}
};
if !staging.overlay {
// Copy fallback: the staged tree is fresh, there is nothing
// incremental to build on.
build_resume = false;
}
staging.path
} else {
build_ctx.ensure_available(&host_tree, &build_root)?
};
// The staged tree (build root + tree name) is where the build
// steps and the artifact collection work.
let build_root = staged_path.to_string_lossy().into_owned();
if let Some(s) = &session {
s.set_build_resume(build_resume);
}
// Resolve the package directory inside the staging area. The tree
// the caller pointed at is authoritative (its changelog defined the
// package/version/series above), so its staged content wins; the
// name-pattern search only runs when the tree holds a layout
// instead (a nested checkout, a version-suffixed unpack).
let package_dir = resolve_package_directory(
Path::new(&build_root),
&package,
&version,
series,
&build_ctx,
)?;
// Run the build using target build mode. It returns the exact set of
// artifacts produced by this build (binary packages registered in
// debian/files plus the generated .buildinfo/.changes), as paths
// inside the build context.
let remote_files: Vec<PathBuf> = match mode {
BuildMode::Local => {
local::build(
&package,
&version,
arch,
series,
pocket.as_deref(),
mirror.as_deref(),
&package_dir,
cross,
ppa,
inject,
build_ctx.clone(),
view,
*jobs,
session.as_ref(),
build_resume,
ccache,
// Rootless builds: only a context pkh controls (the
// ephemeral chroot) can carry the on-demand build user.
guard.is_some().then_some(local::BUILD_USER),
&mut timings,
&mut profiler,
)
.await?
}
};
// Retrieve the produced artifacts (binary packages plus the upload
// metadata) to the parent directory: the .debs land next to the
// tree, on the host, whatever the staging scope is.
enter_phase(view, Phase::RetrievingArtifacts, &mut profiler);
let total_debs = remote_files.len();
let parent_dir = host_tree
.parent()
.ok_or("Cannot find parent directory")?
.to_path_buf();
let mut artifacts = Vec::with_capacity(total_debs);
for (idx, remote_file) in remote_files.iter().enumerate() {
let file_name = remote_file.file_name().ok_or("Invalid remote filename")?;
let local_dest = parent_dir.join(file_name);
build_ctx.retrieve_path(remote_file, &local_dest)?;
artifacts.push(local_dest);
view.progress("Retrieving artifacts", idx + 1, total_debs);
}
view.finish_success(&artifacts);
Ok(artifacts)
}
.await;
// Clear the live view before returning: the ephemeral guard is dropped at
// the end of this function and its cleanup commands (umount, rm -rf of
// the chroot) inherit the terminal, so they must not fight the widget.
view.suspend();
// Mark the build as successful for the guard's drop, which tears the
// per-run chroot down and retains the record: metadata and log for
// every build, the staged tree exactly for `--incremental` runs.
if result.is_ok()
&& let Some(g) = &mut guard
{
g.mark_build_successful();
}
// Every record's outcome is written — plain runs leave a metadata
// record too.
if result.is_err()
&& let Some(s) = &session
{
s.set_outcome(session::OUTCOME_FAILED);
}
// The measured durations land in the record whatever the outcome: a
// failed build's partial timings and phase profile are data too.
// Nothing measured (the run ended before any timed command ran)
// records nothing.
if let Some(s) = &session {
let profile = profiler.finish();
if !profile.is_empty() {
s.set_profile(profile);
}
if !timings.is_empty() {
s.set_timings(timings);
}
}
result
}
/// The staged-tree copy of a fork is bounded (the base's whole tree)
/// and, without reflinks, takes minutes: announce it on the view, then
/// feed byte progress once the fork's watcher has measured the total.
/// Callers hand [`session::CopyProgress`] the box through `as_deref()`.
fn copy_report<'a>(
view: Option<&'a dyn BuildView>,
base_id: &str,
) -> Option<Box<dyn Fn(u64, u64) + Sync + 'a>> {
view.map(|view| {
let label = format!("Copying the staged tree of record {base_id}");
view.message(&label);
Box::new(move |pos: u64, total: u64| {
let size = crate::utils::size::format_bytes;
view.byte_progress(
&format!("{label} ({}/{})", size(pos), size(total)),
pos,
total,
)
}) as Box<dyn Fn(u64, u64) + Sync + 'a>
})
}
/// Resolve the record a build runs in, per the `--incremental` request,
/// against the real sessions root and the chroot tarball the bootstrap
/// would use today. See [`resolve_record_in`].
#[allow(clippy::too_many_arguments)]
fn resolve_record(
incremental: &Incremental,
host_tree: &Path,
identity: &session::SessionIdentity,
version: &str,
series: &str,
chroot_arch: Option<&str>,
mirror: Option<&str>,
view: &dyn BuildView,
) -> Result<(session::Session, bool), Box<dyn Error>> {
resolve_record_in(
&session::sessions_root(),
&session::cache_tarball_sha256(series, chroot_arch, mirror),
incremental,
host_tree,
identity,
version,
Some(view),
)
}
/// The testable core of [`resolve_record`] over an explicit sessions root
/// and cached-tarball digest (the fork gate's reference). `view` is the
/// live build view when there is one; the fork reports its staged-tree
/// copy through it.
///
/// Every local build runs in its own record: [`Incremental::Off`] gets a
/// brand-new one, [`Incremental::Auto`] and [`Incremental::Id`] fork the
/// selected base. The returned flag says whether the record's staged
/// tree is retained after the build — exactly the `--incremental` runs
/// retain theirs. Metadata and the log are kept for every build either
/// way.
fn resolve_record_in(
records_root: &Path,
cache_tarball_sha256: &str,
incremental: &Incremental,
host_tree: &Path,
identity: &session::SessionIdentity,
version: &str,
view: Option<&dyn BuildView>,
) -> Result<(session::Session, bool), Box<dyn Error>> {
match incremental {
Incremental::Off => Ok((
session::new_record_in(records_root, host_tree, identity, version)?,
false,
)),
Incremental::Auto => {
// A record locked by a live build cannot be a base: its
// staged tree may be mid-mutation by the running build.
let candidates: Vec<session::Session> =
session::fork_candidates_in(records_root, identity)
.into_iter()
.filter(|s| !session::lock_is_held(&s.lock_path()).unwrap_or(true))
.collect();
for base in session::rank_base_candidates(candidates) {
let why = if base.manifest().last_outcome == session::OUTCOME_SUCCESS {
"the newest successful record of the target"
} else {
"no successful record among the last usable ones; the newest record"
};
let report = copy_report(view, &base.manifest().id);
match session::fork_record_in(
records_root,
&base,
host_tree,
identity,
version,
cache_tarball_sha256,
report.as_deref(),
) {
Ok(record) => {
log::info!(
"Base of the incremental build: record {} ({why})",
base.manifest().id
);
return Ok((record, true));
}
Err(session::SessionOpenError::Busy(busy)) => {
log::info!("{busy}; trying the next candidate record");
}
Err(e) => return Err(e.into()),
}
}
log::info!(
"No record of {identity} carrying a staged tree to fork; building \
from scratch — this run's record becomes the target's first base"
);
Ok((
session::new_record_in(records_root, host_tree, identity, version)?,
true,
))
}
Incremental::Id(id) => {
let mut matches: Vec<session::Session> = session::by_id_prefix_in(records_root, id)
.into_iter()
.filter(|s| s.manifest().host_tree == host_tree.to_string_lossy())
.collect();
match matches.len() {
0 => Err(format!(
"no build record matches id '{id}' for this tree \
(see `pkh deb list`)"
)
.into()),
1 => {
let base = matches.remove(0);
log::info!(
"Base of the incremental build: record {} (pinned by id)",
base.manifest().id
);
let report = copy_report(view, &base.manifest().id);
Ok((
session::fork_record_in(
records_root,
&base,
host_tree,
identity,
version,
cache_tarball_sha256,
report.as_deref(),
)?,
true,
))
}
_ => {
let found = matches
.iter()
.map(|s| s.manifest().id.clone())
.collect::<Vec<_>>()
.join(", ");
Err(format!("ambiguous record id '{id}': matches {found}").into())
}
}
}
}
}
/// Resolve the package directory for a build inside the staged build root.
///
/// The staged tree is the merge point of the build root: its content is
/// the content of the tree the caller pointed at — whose changelog already
/// defined the package, version and series — so the package directory is
/// the root itself when the tree carries a `debian/` directory. Otherwise
/// the tree holds a layout to search (a nested checkout, a
/// version-suffixed unpack): [`find_package_directory`], including the
/// quirks overrides.
pub(crate) fn resolve_package_directory(
build_root: &Path,
package: &str,
version: &str,
series: &str,
ctx: &context::Context,
) -> Result<PathBuf, Box<dyn Error>> {
if ctx.is_dir(build_root)? && ctx.exists(&build_root.join("debian"))? {
log::debug!(
"Using the staged copy of the tree at {}",
build_root.display()
);
return Ok(build_root.to_path_buf());
}
find_package_directory(build_root, package, version, series, ctx)
}
/// Find the current package directory by trying both patterns:
/// - package/package
/// - package/package-origversion
/// - custom directories from quirks configuration
pub(crate) fn find_package_directory(
parent_dir: &Path,
package: &str,
version: &str,
series: &str,
ctx: &context::Context,
) -> Result<PathBuf, Box<dyn Error>> {
// Check quirks first for custom package directories
let custom_dirs = crate::quirks::get_package_directories(package, series);
for custom_dir in custom_dirs {
let package_dir = parent_dir.join(&custom_dir);
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
log::debug!(
"Found package directory via quirks: {}",
package_dir.display()
);
return Ok(package_dir);
}
}
// Try the current working directory name as a package directory
// This is useful when the user is inside a non-standard package directory
// e.g. ~/kernels/linux-riscv/linux-main -> tries "linux-main"
if let Ok(cwd) = std::env::current_dir()
&& let Some(cwd_name) = cwd.file_name()
{
let cwd_name_str = cwd_name.to_string_lossy();
let package_dir = parent_dir.join(cwd_name_str.as_ref());
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
log::debug!(
"Found package directory via CWD name: {}",
package_dir.display()
);
return Ok(package_dir);
}
}
// Try package/package pattern first
let package_dir = parent_dir.join(package).join(package);
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// Compute origversion from version: remove everything after first '-', after stripping epoch
let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version);
let origversion = version_without_epoch
.split_once('-')
.map(|(v, _)| v)
.unwrap_or(version);
// Try package/package-origversion pattern
let package_dir = parent_dir
.join(package)
.join(format!("{}-{}", package, origversion));
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// Try 'package' only
let package_dir = parent_dir.join(package);
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// Try package-origversion only
let package_dir = parent_dir.join(format!("{}-{}", package, origversion));
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// List all directories under 'package/' and log them
let package_parent = parent_dir;
if ctx.exists(package_parent)? {
log::debug!(
"Listing all directories under '{}':",
package_parent.display()
);
let entries = ctx.list_files(package_parent)?;
let mut found_dirs = Vec::new();
for entry in entries {
// list_files yields context-relative paths (e.g. rooted inside
// the chroot for an unshare context): classify through the
// context, a host-side stat would miss every entry.
let is_dir = ctx.is_dir(&entry)?;
log::debug!(" - {}", entry.display());
if is_dir && let Some(file_name) = entry.file_name() {
found_dirs.push(file_name.to_string_lossy().into_owned());
}
}
// If we found directories but none matched our patterns, provide helpful error
if !found_dirs.is_empty() {
return Err(format!(
"Could not find package directory for {} in {}. Found directories: {}",
package,
parent_dir.display(),
found_dirs.join(", ")
)
.into());
}
}
Err(format!(
"Could not find package directory for {} in {}",
package,
parent_dir.display()
)
.into())
}
fn find_dsc_file(
build_root: &str,
package: &str,
version: &str,
ctx: &Arc<Context>,
) -> Result<PathBuf, Box<dyn Error>> {
// Strip epoch from version (e.g., "1:2.3.4-5" -> "2.3.4-5")
let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version);
let dsc_name = format!("{}_{}.dsc", package, version_without_epoch);
let dsc_path = PathBuf::from(build_root).join(&dsc_name);
// Check if the .dsc file exists in context
if !ctx.exists(&dsc_path)? {
return Err(format!("Could not find .dsc file at {}", dsc_path.display()).into());
}
Ok(dsc_path)
}
/// Check whether an apt source URI points to a distribution archive
/// (as opposed to a PPA or another third-party repository): a thin,
/// dist-agnostic wrapper over [`crate::distro_info::is_official_source`],
/// unioned across every known distro. Where the distribution is known,
/// the dist-scoped check is preferred (it cannot misfire on another
/// distro's mirror); this fallback stays for the sites that cannot know.
pub(crate) fn is_archive_source(uri: &str) -> bool {
crate::distro_info::supported_dists()
.iter()
.any(|dist| crate::distro_info::is_official_source(dist, uri))
}
/// Whether `uri` counts as an archive source under a mirror pin: a
/// distribution archive source, or already the pinned mirror URL itself
/// — sources rewritten to it no longer carry an official host, but they
/// are exactly the sources a previous pass or build pinned.
pub(crate) fn is_archive_source_pinned(uri: &str, mirror: Option<&str>) -> bool {
is_archive_source(uri) || mirror.is_some_and(|m| uri == m)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
/// The archive check is dist-agnostic (any distro's official mirror
/// counts) and host-based: the country mirrors the old substring
/// checks matched (`fr.archive.ubuntu.com`) still count, look-alike
/// hosts and PPAs do not.
#[test]
fn archive_sources_are_official_mirrors_of_any_distro() {
for uri in [
"https://archive.ubuntu.com/ubuntu",
// Country mirrors front the Ubuntu archive.
"http://fr.archive.ubuntu.com/ubuntu",
"http://security.ubuntu.com/ubuntu",
"http://ports.ubuntu.com/ubuntu-ports",
"https://deb.debian.org/debian",
] {
assert!(is_archive_source(uri), "{uri}");
}
for uri in [
"https://ppa.launchpadcontent.net/user/ppa/ubuntu",
"http://notarchive.ubuntu.com/ubuntu",
"https://example.com/debian",
] {
assert!(!is_archive_source(uri), "{uri}");
}
}
/// Under a mirror pin, the pinned URL itself counts as an archive
/// source (a previous pass or build rewrote official sources to it);
/// without a pin the check is the plain archive check.
#[test]
fn archive_sources_under_a_mirror_pin() {
let mirror = "http://localhost:3142/ubuntu";
assert!(is_archive_source_pinned(mirror, Some(mirror)));
assert!(is_archive_source_pinned(
"http://archive.ubuntu.com/ubuntu",
Some(mirror)
));
assert!(!is_archive_source_pinned(
"https://ppa.launchpadcontent.net/user/ppa/ubuntu",
Some(mirror)
));
assert!(!is_archive_source_pinned(mirror, None));
}
/// Every phase's machine key round-trips through `Phase::from_key`:
/// the profile renderer relies on it to show a recorded entry as
/// the phase's human label, and a key/from_key drift would
/// downgrade the row to the raw key.
#[test]
fn phase_keys_round_trip() {
for phase in [
Phase::PreparingChroot,
Phase::ExtractingChroot,
Phase::FinalizingChroot,
Phase::UpdatingPackageLists,
Phase::InstallingEssentials,
Phase::ApplyingPatches,
Phase::InjectingPackages,
Phase::InstallingBuildDeps,
Phase::ClearingResumeCaches,
Phase::PreparingBuildUser,
Phase::Building,
Phase::ProducingBinaries,
Phase::RetrievingArtifacts,
] {
assert_eq!(Phase::from_key(phase.key()), Some(phase), "{}", phase.key());
assert_ne!(phase.key(), "", "{}", phase.key());
}
assert_eq!(Phase::from_key("nonesuch"), None);
}
/// The profiler closes each phase when the next opens and the last
/// one at `finish`, recording entries in execution order; an empty
/// run records nothing (the record must not claim an empty
/// profile), and a phase never entered leaves no entry.
#[test]
fn profiler_records_phases_in_execution_order() {
let mut profiler = BuildProfiler::new();
assert!(profiler.finish().is_empty());
let mut profiler = BuildProfiler::new();
profiler.enter(Phase::PreparingChroot);
std::thread::sleep(std::time::Duration::from_millis(5));
profiler.enter(Phase::UpdatingPackageLists);
std::thread::sleep(std::time::Duration::from_millis(2));
profiler.enter(Phase::Building);
std::thread::sleep(std::time::Duration::from_millis(2));
let profile = profiler.finish();
let phases: Vec<&str> = profile.phases.iter().map(|p| p.phase.as_str()).collect();
assert_eq!(
phases,
vec!["preparing_chroot", "updating_package_lists", "building"]
);
assert!(profile.total_millis() >= 9, "{profile:?}");
// finish() closes the last phase: nothing stays open afterwards.
assert!(profiler.finish().is_empty());
}
/// An unshare context mapped over `chroot_root`, parented on a local
/// context like the ephemeral build contexts are: exists/list_files/
/// is_dir answer through the path mapping, no namespace privileges
/// needed.
fn unshare_test_context(chroot_root: &Path) -> Context {
let base = Context::new(crate::context::ContextConfig::Local).unwrap();
Context::with_parent(
crate::context::ContextConfig::Unshare {
path: chroot_root.to_string_lossy().to_string(),
parent: None,
},
Arc::new(base),
)
}
/// The staging-area listing must classify entries through the context:
/// an unshare context returns build-root-relative paths that a host-side
/// stat never sees (they live under the chroot root on the host), which
/// used to silently empty the 'Found directories' list of the search
/// failure message — and with it every hint about the actual layout.
#[test]
fn find_package_directory_lists_staged_directories_through_the_context() {
let chroot = tempfile::tempdir().unwrap();
// Staged tree holding a single nested checkout whose name matches
// none of the search patterns (the embedded-caller layout:
// <job>/tree)
let staged_tree = chroot.path().join("tmp/pkh-build-1/j-42");
std::fs::create_dir_all(staged_tree.join("tree/debian")).unwrap();
let ctx = unshare_test_context(chroot.path());
let err = find_package_directory(
Path::new("/tmp/pkh-build-1/j-42"),
"bc",
"1.07.1-1ubuntu1",
"questing",
&ctx,
)
.expect_err("no candidate matches a tree named 'tree'");
let message = err.to_string();
assert!(
message.contains("Found directories: tree"),
"error should list the staged directories through the context: {message}"
);
}
/// The staged tree is the merge point of the build root: when the
/// pointed-at tree is itself the package, its content sits at the
/// root and resolves outright — even when the tree name matches none
/// of the search patterns (the embedded-caller layout: <job>/tree).
#[test]
fn resolve_package_directory_prefers_the_pointed_tree() {
let chroot = tempfile::tempdir().unwrap();
let staged_tree = chroot.path().join("tmp/pkh-build-1/tree");
std::fs::create_dir_all(staged_tree.join("debian/source")).unwrap();
let ctx = unshare_test_context(chroot.path());
let resolved = resolve_package_directory(
Path::new("/tmp/pkh-build-1/tree"),
"bc",
"1.07.1-1ubuntu1",
"questing",
&ctx,
)
.expect("the staged tree must resolve to the build root");
assert_eq!(resolved, PathBuf::from("/tmp/pkh-build-1/tree"));
}
/// When the staged tree holds a layout instead of the package itself
/// (no debian/ at its root), resolution falls back to the
/// name-pattern search over the staged content.
#[test]
fn resolve_package_directory_falls_back_to_the_name_search() {
let chroot = tempfile::tempdir().unwrap();
let staged_tree = chroot.path().join("tmp/pkh-build-1/checkout");
// A pulled-tree layout inside the pointed-at tree:
// <pkg>/<pkg>-<origversion>
std::fs::create_dir_all(staged_tree.join("bc/bc-1.07.1/debian")).unwrap();
let ctx = unshare_test_context(chroot.path());
let resolved = resolve_package_directory(
Path::new("/tmp/pkh-build-1/checkout"),
"bc",
"1.07.1-1ubuntu1",
"questing",
&ctx,
)
.expect("the pulled-tree layout must resolve via the name search");
assert_eq!(
resolved,
PathBuf::from("/tmp/pkh-build-1/checkout/bc/bc-1.07.1")
);
}
async fn test_build_end_to_end(
package: &str,
series: &str,
dist: Option<&str>,
arch: Option<&str>,
cross: bool,
) {
log::info!(
"Starting end-to-end test for package: {} (series: {}, arch: {:?}, cross: {})",
package,
series,
arch,
cross
);
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path();
log::debug!("Created temporary directory: {}", cwd.display());
log::info!("Pulling package {} from {}...", package, series);
let package_info = crate::package_info::lookup(
package,
None,
Some(series),
"",
dist,
None,
None,
&crate::report::Quiet,
)
.await
.expect("Cannot lookup package information");
crate::pull::pull(&package_info, Some(cwd), &crate::report::Quiet, true)
.await
.expect("Cannot pull package");
log::info!("Successfully pulled package {}", package);
// Create a fresh local context for this test
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
// Change directory to the package directory
let cwd = crate::deb::find_package_directory(
cwd,
package,
&package_info.stanza.version,
series,
&ctx,
)
.expect("Cannot find package directory");
log::debug!("Package directory: {}", cwd.display());
log::info!("Starting binary package build...");
crate::deb::build_binary_package(DebBuildOptions {
arch: arch.map(str::to_string),
series: Some(series.to_string()),
cross,
cwd: Some(cwd.to_path_buf()),
ctx: Some(ctx),
..Default::default()
})
.await
.expect("Cannot build binary package (deb)");
log::info!("Successfully built binary package");
// The run's record carries the per-phase timing profile: the
// metadata-only record of a plain run keeps the manifest too.
// (The record's host tree is the package directory the build
// was pointed at.)
let tree = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
let record =
crate::deb::session::find_record(&crate::deb::session::sessions_root(), &tree, None)
.expect("the build must leave a record for this tree");
let profile = record
.manifest()
.profile
.expect("the record must carry a phase profile");
assert!(
profile
.phases
.iter()
.any(|p| p.phase == "building" && p.millis > 0),
"{profile:?}"
);
// Check that the .deb files are present
let parent_dir = cwd.parent().expect("Cannot find parent directory");
let deb_files = std::fs::read_dir(parent_dir)
.expect("Cannot read build directory")
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "deb"))
.collect::<Vec<_>>();
log::info!("Found {} .deb files after build", deb_files.len());
for file in &deb_files {
log::debug!(" - {}", file.path().display());
}
assert!(!deb_files.is_empty(), "No .deb files found after build");
log::info!(
"End-to-end test completed successfully for package: {}",
package
);
}
// Tests no longer need to be 'serial' since each test uses its own
// explicit context instead of shared global state.
#[tokio::test]
#[test_log::test]
async fn test_deb_hello_ubuntu_end_to_end() {
test_build_end_to_end("hello", "noble", None, None, false).await;
}
/// This ensures that we can cross-build packages
#[tokio::test]
#[test_log::test]
#[cfg(target_arch = "x86_64")]
async fn test_deb_hello_ubuntu_cross_end_to_end() {
test_build_end_to_end("hello", "noble", None, Some("riscv64"), true).await;
}
/// This ensures that we can build packages from sid, even on older
/// releases. It can sometimes be difficult with mmdebstrap issues
/// for example.
#[tokio::test]
#[test_log::test]
async fn test_deb_hello_debian_sid_end_to_end() {
test_build_end_to_end("hello", "sid", None, None, false).await;
}
/// This is a specific test case for the linux-riscv package on Ubuntu
/// It is important to ensure that pkh can cross-compile linux-riscv, as
/// for risc-v hardware is still rare and cross-compilation is necessary
/// to debug and test
/// NOTE: Ideally, we want to run this in CI, but it takes more than 1h
/// to fully build the linux-riscv package on an amd64 builder, which is too
/// much time
/// The series is the current LTS (26.04) rather than an interim one:
/// interim series vanish from the mirrors a few months after their EOL
/// (questing is already unreachable), an LTS stays pullable for years.
#[ignore]
#[tokio::test]
#[test_log::test]
#[cfg(target_arch = "x86_64")]
async fn test_deb_linux_riscv_ubuntu_cross_end_to_end() {
test_build_end_to_end("linux-riscv", "resolute", None, Some("riscv64"), true).await;
}
/// KNOWN-BROKEN cross build of the noble-era kernel, kept as an
/// ignored fixture to work from. Noble controls declare their build
/// tools unqualified (the `:native` idiom landed later), so exact dpkg
/// semantics demand host-architecture instances of them
/// (python3:riscv64, gcc-13:riscv64, clang-17:riscv64, ...) and the
/// resulting two-architecture install set is unsolvable: t64
/// libraries (libclang1-17t64) conflict with their own foreign-arch
/// variant, and the riscv64 toolchain instances drag depends chains
/// (gcc:riscv64) that do not resolve from the chroot sources. A real
/// run fails at the apt transaction with 'Unable to correct problems'.
/// Resolute-era controls declare :native properly; see the test above.
#[ignore]
#[tokio::test]
#[test_log::test]
#[cfg(target_arch = "x86_64")]
async fn test_deb_linux_riscv_noble_cross_end_to_end() {
test_build_end_to_end("linux-riscv", "noble", None, Some("riscv64"), true).await;
}
/// This is a specific test case for the latest gcc package on Debian
/// The GCC package is complex and hard to build, with specific stages
/// and system-bound scripts. Building it requires specific things that
/// we want to ensure are not broken.
/// NOTE: Ideally, we want to run this in CI, but it takes more than 20h
/// to fully build the gcc-15 package on an amd64 builder, which is too
/// much time.
#[ignore]
#[cfg(target_arch = "x86_64")]
#[tokio::test]
#[test_log::test]
async fn test_deb_gcc_debian_end_to_end() {
test_build_end_to_end("gcc-15", "sid", None, None, false).await;
}
/// Create a synthetic source package that discriminates which architecture
/// is used to resolve Build-Depends-Indep during cross builds:
///
/// - 'libdb-dev' is an arch:any package that is not Multi-Arch: same, so an
/// amd64 copy can only be installed by replacing the arm64 one
/// - the arch-specific binary links against libdb for the host
/// architecture, so the build only succeeds if the arm64 libdb-dev was
/// left in place by the arch-independant build-dep pass
fn create_indep_cross_test_source(parent: &Path) -> PathBuf {
let pkg_dir = parent.join("pkh-crosstest");
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
std::fs::write(
pkg_dir.join("debian/changelog"),
"pkh-crosstest (1.0) noble; urgency=medium\n\n \
* Synthetic package exercising Build-Depends-Indep in cross builds.\n\n \
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/control"),
"Source: pkh-crosstest\n\
Section: devel\n\
Priority: optional\n\
Maintainer: pkh tests <pkh@example.com>\n\
Standards-Version: 4.7.4\n\
Build-Depends: debhelper-compat (= 13), libdb-dev\n\
Build-Depends-Indep: libdb-dev\n\
Architecture: any all\n\
\n\
Package: pkh-crosstest\n\
Architecture: any\n\
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
Description: Cross-build regression package for build-dep resolution\n \
Builds a host-architecture binary against libdb to detect a cross\n \
build environment damaged by a wrongly-scoped build-dep pass.\n\
\n\
Package: pkh-crosstest-data\n\
Architecture: all\n\
Description: Cross-build regression package data (arch-indep)\n \
Arch-indep binary so the indep build path is exercised.\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/rules"),
"#!/usr/bin/make -f\n\
%:\n\
\tdh $@\n\
\n\
override_dh_auto_build:\n\
\tprintf '#include <db.h>\\nint main(void){DB *d; return db_create(&d, NULL, 0);}\\n' > main.c\n\
\t$(DEB_HOST_GNU_TYPE)-gcc main.c -ldb -o pkh-crosstest\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
let rules = pkg_dir.join("debian/rules");
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&rules, perms).unwrap();
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
pkg_dir
}
/// This ensures the arch-independant build-dep pass of a cross build
/// resolves dependencies for the host architecture, like dpkg-checkbuilddeps
/// does, instead of re-resolving the whole Build-Depends field for the
/// native architecture, which swaps host-arch -dev packages for native ones
/// and breaks the cross build environment.
#[tokio::test]
#[test_log::test]
#[cfg(target_arch = "x86_64")]
async fn test_deb_cross_indep_host_arch_end_to_end() {
let temp_dir = tempfile::tempdir().unwrap();
let pkg_dir = create_indep_cross_test_source(temp_dir.path());
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
crate::deb::build_binary_package(DebBuildOptions {
arch: Some("arm64".to_string()),
series: Some("noble".to_string()),
cwd: Some(pkg_dir),
cross: true,
ctx: Some(ctx),
..Default::default()
})
.await
.expect("Cannot cross-build package declaring Build-Depends-Indep");
// Both binary packages must have been produced, including the
// arch-independant one
let deb_files: Vec<String> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert!(
deb_files
.iter()
.any(|f| f.starts_with("pkh-crosstest_1.0_arm64.deb")),
"arch-specific .deb not produced, got: {deb_files:?}"
);
assert!(
deb_files
.iter()
.any(|f| f.starts_with("pkh-crosstest-data_1.0_all.deb")),
"arch-independant .deb not produced, got: {deb_files:?}"
);
}
/// A cross build whose build-dependencies cannot be satisfied under
/// dpkg's cross semantics must abort at the install step, naming the
/// unsatisfied dependency: the expected-failure counterpart of the
/// cross builds above.
#[tokio::test]
#[test_log::test]
#[cfg(target_arch = "x86_64")]
async fn test_deb_cross_unsatisfiable_build_dep_fails_end_to_end() {
let temp_dir = tempfile::tempdir().unwrap();
let pkg_dir = temp_dir.path().join("pkh-crosstest");
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
std::fs::write(
pkg_dir.join("debian/changelog"),
"pkh-crosstest (1.0) noble; urgency=medium\n\n \
* Synthetic package declaring an unsatisfiable build-dependency.\n\n \
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/control"),
"Source: pkh-crosstest\n\
Section: devel\n\
Priority: optional\n\
Maintainer: pkh tests <pkh@example.com>\n\
Standards-Version: 4.7.4\n\
Build-Depends: pkh-no-such-package-xyz\n\
Architecture: any\n\
\n\
Package: pkh-crosstest\n\
Architecture: any\n\
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
Description: Cross-build negative regression package\n \
Declares a build-dependency absent from the archive.\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/rules"),
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
let rules = pkg_dir.join("debian/rules");
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&rules, perms).unwrap();
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
let err = crate::deb::build_binary_package(DebBuildOptions {
arch: Some("arm64".to_string()),
series: Some("noble".to_string()),
cwd: Some(pkg_dir),
cross: true,
ctx: Some(ctx),
..Default::default()
})
.await
.expect_err("an unsatisfiable build-dependency must fail the cross build");
assert!(
err.to_string().contains("pkh-no-such-package-xyz"),
"error should name the unsatisfied dependency: {err}"
);
}
/// An embedded-caller layout — the tree checked out at <job>/tree, a
/// name matching none of the search patterns — must build: the staged
/// copy of the tree the caller pointed at is resolved directly instead
/// of being re-derived from package/version names (which used to fail
/// with 'Could not find package directory').
#[tokio::test]
#[test_log::test]
async fn test_deb_builds_a_tree_named_directory_end_to_end() {
let temp_dir = tempfile::tempdir().unwrap();
let pkg_dir = temp_dir.path().join("j-42/tree");
std::fs::create_dir_all(pkg_dir.join("debian/source")).unwrap();
std::fs::write(
pkg_dir.join("debian/changelog"),
"pkh-treetest (1.0) noble; urgency=medium\n\n \
* Synthetic package built from a directory named 'tree'.\n\n \
-- pkh tests <pkh@example.com> Tue, 15 Sep 2026 08:00:00 +0000\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/control"),
"Source: pkh-treetest\n\
Section: devel\n\
Priority: optional\n\
Maintainer: pkh tests <pkh@example.com>\n\
Standards-Version: 4.7.4\n\
Build-Depends: debhelper-compat (= 13)\n\
Architecture: any\n\
\n\
Package: pkh-treetest\n\
Architecture: any\n\
Depends: ${misc:Depends}, ${shlibs:Depends}\n\
Description: Package-directory resolution regression package\n \
Its tree lives in a directory whose name matches none of the\n \
package-directory search patterns.\n",
)
.unwrap();
std::fs::write(
pkg_dir.join("debian/rules"),
"#!/usr/bin/make -f\n%:\n\tdh $@\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
let rules = pkg_dir.join("debian/rules");
let mut perms = std::fs::metadata(&rules).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&rules, perms).unwrap();
std::fs::write(pkg_dir.join("debian/source/format"), "3.0 (native)\n").unwrap();
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local).unwrap());
crate::deb::build_binary_package(DebBuildOptions {
series: Some("noble".to_string()),
cwd: Some(pkg_dir),
ctx: Some(ctx),
..Default::default()
})
.await
.expect("a tree named 'tree' must build");
let deb_files: Vec<String> = std::fs::read_dir(temp_dir.path().join("j-42"))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert!(
deb_files
.iter()
.any(|f| f.starts_with("pkh-treetest_1.0_") && f.ends_with(".deb")),
".deb not produced for the 'tree'-named directory, got: {deb_files:?}"
);
}
// -------------------------------------------------------------------
// Record resolution (`--incremental` base selection)
// -------------------------------------------------------------------
use crate::deb::session::record_fixtures::{
metadata_record, record_with_upper, sessions_fixture, test_identity,
};
use crate::deb::session::{self, SessionIdentity};
/// The sha of the fixture's cached tarball (the fork gate's
/// reference).
fn cache_sha(tarball: &std::path::Path) -> String {
session::sha256_file(tarball).unwrap()
}
/// A plain run resolves to a brand-new, lineage-free record whose
/// staged tree will be dropped — including when no record exists at
/// all yet.
#[test]
fn resolve_plain_run_builds_a_fresh_discardable_record() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let (record, keep_upper) = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Off,
&host_tree,
&identity,
"1.0",
None,
)
.expect("a plain run always resolves to a fresh record");
assert!(!keep_upper, "a plain run drops its staged tree");
assert_eq!(record.manifest().base, None);
assert!(!record.has_upper());
}
/// The user scenario, end to end over the record layout: record A
/// succeeds, a fork B of it fails — the default `--incremental`
/// selection forks A again (the newest success), never the failed B.
#[test]
fn resolve_auto_forks_the_newest_success_after_a_failed_fork() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let a = record_with_upper(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
Some(session::OUTCOME_SUCCESS),
);
let a_id = a.manifest().id;
let b = session::fork_record_in(
root.path(),
&a,
&host_tree,
&identity,
"1.0",
&cache_sha(&tarball),
None,
)
.unwrap();
let b_id = b.manifest().id;
b.set_outcome(session::OUTCOME_FAILED);
drop((a, b));
let (record, keep_upper) = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Auto,
&host_tree,
&identity,
"1.0",
None,
)
.expect("the auto selection must fork a candidate record");
assert!(keep_upper);
assert_eq!(record.manifest().base.as_deref(), Some(a_id.as_str()));
assert_ne!(record.manifest().id, b_id);
// The successful base's gate passes: its staged tree is carried.
assert!(record.has_upper());
}
/// A first `--incremental` run with no candidate builds completely
/// fresh and retains its staged tree — it becomes the identity's
/// first base.
#[test]
fn resolve_auto_without_candidates_builds_fresh_and_retains() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let (record, keep_upper) = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Auto,
&host_tree,
&identity,
"1.0",
None,
)
.expect("no candidates must degrade to a fresh build");
assert!(keep_upper, "the first record becomes a base");
assert_eq!(record.manifest().base, None);
}
/// Records without a staged tree (plain runs, metadata-only ones)
/// never make it into the selection: a newer successful
/// metadata-only record loses to an older failed one carrying a
/// staged tree.
#[test]
fn resolve_auto_skips_records_without_a_staged_tree() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let carrier = record_with_upper(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
Some(session::OUTCOME_FAILED),
);
let carrier_id = carrier.manifest().id;
drop(carrier);
// Newer and successful, but a plain run: no staged tree.
drop(metadata_record(
root.path(),
&host_tree,
&identity,
"1.0",
session::OUTCOME_SUCCESS,
));
let (record, _) = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Auto,
&host_tree,
&identity,
"1.0",
None,
)
.expect("the record carrying a staged tree is still the base");
assert_eq!(record.manifest().base.as_deref(), Some(carrier_id.as_str()));
}
/// `--incremental <id>` may pin a FAILED record — the user chose it
/// deliberately and its staged tree may be perfectly fine.
#[test]
fn resolve_pinned_id_forks_a_failed_record() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let failed = record_with_upper(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
Some(session::OUTCOME_FAILED),
);
let failed_id = failed.manifest().id;
drop(failed);
let (record, keep_upper) = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Id(failed_id.clone()),
&host_tree,
&identity,
"1.0",
None,
)
.expect("a pinned failed record is a valid base");
assert!(keep_upper);
assert_eq!(record.manifest().base.as_deref(), Some(failed_id.as_str()));
assert!(record.has_upper(), "the gate passes: sha and version match");
}
/// A pinned id is restricted to the records of this host tree: an id
/// recorded from another tree is refused.
#[test]
fn resolve_pinned_id_refuses_other_trees() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let other_tree = root.path().join("elsewhere");
let foreign = record_with_upper(
root.path(),
&tarball,
&other_tree,
&identity,
"1.0",
Some(session::OUTCOME_SUCCESS),
);
let foreign_id = foreign.manifest().id;
drop(foreign);
let err = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Id(foreign_id),
&host_tree,
&identity,
"1.0",
None,
)
.expect_err("records of other trees must not be forked by id");
assert!(
err.to_string().contains("no build record matches id"),
"wrong error: {err}"
);
}
/// An ambiguous id prefix is an error naming every match.
#[test]
fn resolve_pinned_id_refuses_ambiguous_prefixes() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
drop(record_with_upper(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
None,
));
drop(record_with_upper(
root.path(),
&tarball,
&host_tree,
&identity,
"1.0",
None,
));
// All record ids share the date-time prefix of their creation
// second; anything shorter is ambiguous.
let prefix = now_prefix(root.path(), &identity);
let err = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Id(prefix),
&host_tree,
&identity,
"1.0",
None,
)
.expect_err("an ambiguous prefix must be refused");
assert!(
err.to_string().contains("ambiguous record id"),
"wrong error: {err}"
);
}
/// The common date-time prefix of every record of the identity (the
/// timestamp of the test-second they were all created in).
fn now_prefix(root: &std::path::Path, identity: &SessionIdentity) -> String {
session::identity_records_in(root, identity)[0]
.manifest()
.id
.chars()
.take(8)
.collect()
}
/// A pinned record of another identity is refused: its staged tree
/// was built for a different target.
#[test]
fn resolve_pinned_id_refuses_foreign_identities() {
let (root, tarball) = sessions_fixture();
let identity = test_identity();
let other = SessionIdentity {
arch: "riscv64".to_string(),
..identity.clone()
};
let host_tree = root.path().join("tree");
let foreign = record_with_upper(
root.path(),
&tarball,
&host_tree,
&other,
"1.0",
Some(session::OUTCOME_SUCCESS),
);
let foreign_id = foreign.manifest().id;
drop(foreign);
let err = resolve_record_in(
root.path(),
&cache_sha(&tarball),
&Incremental::Id(foreign_id),
&host_tree,
&identity,
"1.0",
None,
)
.expect_err("a foreign-identity record must not be forked");
assert!(
err.to_string().contains("it was recorded for"),
"wrong error: {err}"
);
}
/// The record-log view keeps the terminal alive for sink-less
/// views: when the caller's view has no sink of its own (Quiet —
/// `--verbose` or a piped stdout), the raw terminal sink takes its
/// place, and every captured line lands BOTH in the record's
/// build.log and on the writers (the terminal, in production).
#[test]
fn record_log_view_shows_raw_output_when_the_view_has_no_sink() {
use crate::context::{LineSink, Stream};
use crate::deb::session::record_fixtures::SharedWriter;
let (root, _tarball) = sessions_fixture();
let identity = test_identity();
let host_tree = root.path().join("tree");
let record = session::new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap();
// The production wiring, with the terminal sink replaced by
// injectable capture buffers: the Quiet view has no sink, so
// the raw writer sink fills the fallback.
let stdout = SharedWriter::new();
let stderr = SharedWriter::new();
let quiet = crate::report::Quiet;
let fallback: Option<Arc<dyn LineSink>> = quiet.sink().or_else(|| {
Some(Arc::new(session::RawWriterSink::new(
Box::new(stdout.clone()),
Box::new(stderr.clone()),
)))
});
let log = Arc::new(session::RecordLogSink::new(&record, fallback).unwrap());
let view = RecordLogView { inner: &quiet, log };
let sink = view
.sink()
.expect("the record-log view always provides one");
sink.line(Stream::Stdout, "raw stdout line");
sink.line(Stream::Stderr, "raw stderr line");
assert_eq!(
stdout.contents(),
"raw stdout line\n",
"the fallback writer received the raw line"
);
assert_eq!(
stderr.contents(),
"raw stderr line\n",
"stderr lines reach the stderr writer"
);
assert_eq!(
std::fs::read_to_string(record.log_file()).unwrap(),
"raw stdout line\nraw stderr line\n",
"build.log keeps the full tee"
);
}
}