A changelog may 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. The pocket must be one of
the base series' distro-info pockets, which also guards against
series names merely containing a dash; an explicit --pocket wins.
Debian serves security updates from a dedicated archive: appending
the security suite to the main mirror would 404 on 'apt-get update'
(ubuntu serves every pocket from the same archive, where appending
works) - clone the pinned source against deb.debian.org/debian-security
instead, as mmdebstrap does.
1894 lines
71 KiB
Rust
1894 lines
71 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,
|
|
/// 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::Building => "Building package",
|
|
Phase::ProducingBinaries => "Producing binary packages",
|
|
Phase::RetrievingArtifacts => "Retrieving artifacts",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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::Building | Phase::ProducingBinaries => Box::new(MakeClassifier::new()),
|
|
Phase::RetrievingArtifacts => Box::new(GenericClassifier::new()),
|
|
}
|
|
}
|
|
|
|
/// Enter `phase` on the view with its default line classifier
|
|
pub(crate) fn enter_phase(view: &dyn BuildView, phase: 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 among the last five 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 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(¤t_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(),
|
|
) {
|
|
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();
|
|
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(),
|
|
)
|
|
.await?,
|
|
)
|
|
} else if *mode == BuildMode::Local {
|
|
Some(
|
|
ephemeral::EphemeralContextGuard::new_with_context(
|
|
series,
|
|
chroot_arch,
|
|
mirror_arg,
|
|
base_ctx.clone(),
|
|
view,
|
|
)
|
|
.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.
|
|
let parent_dir = host_tree
|
|
.parent()
|
|
.ok_or("Cannot find parent directory")?
|
|
.to_path_buf();
|
|
let mut build_resume = false;
|
|
if let Some(s) = &session {
|
|
build_resume = session::sync_upper_for_resume(s, &parent_dir)?;
|
|
if build_resume {
|
|
log::info!(
|
|
"Reusing the build artifacts of the previous attempt (incremental build)"
|
|
);
|
|
}
|
|
}
|
|
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(
|
|
&parent_dir,
|
|
&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(&parent_dir, &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(&parent_dir, &build_root)?
|
|
};
|
|
// The staged parent (build root + workspace 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 copy wins; the
|
|
// name-pattern search only runs as a fallback.
|
|
let package_dir = resolve_package_directory(
|
|
Path::new(&build_root),
|
|
cwd,
|
|
&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(),
|
|
&build_root,
|
|
&package_dir,
|
|
cross,
|
|
ppa,
|
|
inject,
|
|
build_ctx.clone(),
|
|
view,
|
|
*jobs,
|
|
session.as_ref(),
|
|
build_resume,
|
|
ccache,
|
|
&mut timings,
|
|
)
|
|
.await?
|
|
}
|
|
};
|
|
|
|
// Retrieve the produced artifacts (binary packages plus the upload
|
|
// metadata) to the parent directory.
|
|
enter_phase(view, Phase::RetrievingArtifacts);
|
|
let total_debs = remote_files.len();
|
|
|
|
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 are data too. Nothing measured (the
|
|
// run ended before any timed command) records nothing.
|
|
if let Some(s) = &session
|
|
&& !timings.is_empty()
|
|
{
|
|
s.set_timings(timings);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// 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`].
|
|
fn resolve_record(
|
|
incremental: &Incremental,
|
|
host_tree: &Path,
|
|
identity: &session::SessionIdentity,
|
|
version: &str,
|
|
series: &str,
|
|
chroot_arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
) -> 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,
|
|
)
|
|
}
|
|
|
|
/// The testable core of [`resolve_record`] over an explicit sessions root
|
|
/// and cached-tarball digest (the fork gate's reference).
|
|
///
|
|
/// 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,
|
|
) -> 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))
|
|
.take(session::KEEP_UPPERS)
|
|
.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"
|
|
};
|
|
match session::fork_record_in(
|
|
records_root,
|
|
&base,
|
|
host_tree,
|
|
identity,
|
|
version,
|
|
cache_tarball_sha256,
|
|
) {
|
|
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
|
|
);
|
|
Ok((
|
|
session::fork_record_in(
|
|
records_root,
|
|
&base,
|
|
host_tree,
|
|
identity,
|
|
version,
|
|
cache_tarball_sha256,
|
|
)?,
|
|
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 tree the caller pointed at is authoritative: `cwd`'s changelog
|
|
/// already defined the package, version and series for this build, so its
|
|
/// staged copy is used outright when it carries a `debian/` tree. The
|
|
/// name-pattern search ([`find_package_directory`], including the quirks
|
|
/// overrides) only runs when that copy cannot be resolved — a default `.`
|
|
/// cwd has no basename, and the pointed-at tree may live outside the staged
|
|
/// parent. Embedded callers are the motivation: their working directory
|
|
/// names (`tree`, `checkout`, ...) match none of the search patterns.
|
|
pub(crate) fn resolve_package_directory(
|
|
build_root: &Path,
|
|
cwd: &Path,
|
|
package: &str,
|
|
version: &str,
|
|
series: &str,
|
|
ctx: &context::Context,
|
|
) -> Result<PathBuf, Box<dyn Error>> {
|
|
if let Some(tree_name) = cwd.file_name() {
|
|
let staged_tree = build_root.join(tree_name);
|
|
if ctx.is_dir(&staged_tree)? && ctx.exists(&staged_tree.join("debian"))? {
|
|
log::debug!(
|
|
"Using the staged copy of {} at {}",
|
|
cwd.display(),
|
|
staged_tree.display()
|
|
);
|
|
return Ok(staged_tree);
|
|
}
|
|
}
|
|
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));
|
|
}
|
|
|
|
/// 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 parent holding a single tree whose name matches none of
|
|
// the search patterns (the embedded-caller layout: <job>/tree)
|
|
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
|
std::fs::create_dir_all(staged_parent.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}"
|
|
);
|
|
}
|
|
|
|
/// An explicit cwd must resolve to its staged copy even when its name
|
|
/// matches none of the search patterns: the pointed-at tree is what the
|
|
/// parsed changelog came from.
|
|
#[test]
|
|
fn resolve_package_directory_prefers_the_pointed_tree() {
|
|
let chroot = tempfile::tempdir().unwrap();
|
|
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
|
std::fs::create_dir_all(staged_parent.join("tree/debian/source")).unwrap();
|
|
|
|
let ctx = unshare_test_context(chroot.path());
|
|
let resolved = resolve_package_directory(
|
|
Path::new("/tmp/pkh-build-1/j-42"),
|
|
Path::new("/work/jobs/j-42/tree"),
|
|
"bc",
|
|
"1.07.1-1ubuntu1",
|
|
"questing",
|
|
&ctx,
|
|
)
|
|
.expect("the staged copy of the pointed-at tree must resolve");
|
|
|
|
assert_eq!(resolved, PathBuf::from("/tmp/pkh-build-1/j-42/tree"));
|
|
}
|
|
|
|
/// When the pointed-at tree is not in the staging area under its own
|
|
/// name, resolution falls back to the name-pattern search.
|
|
#[test]
|
|
fn resolve_package_directory_falls_back_to_the_name_search() {
|
|
let chroot = tempfile::tempdir().unwrap();
|
|
let staged_parent = chroot.path().join("tmp/pkh-build-1/j-42");
|
|
// Staged copy of a pulled tree: <pkg>/<pkg>-<origversion>
|
|
std::fs::create_dir_all(staged_parent.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/j-42"),
|
|
// A tree never staged under that name
|
|
Path::new("/work/other/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/j-42/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, None)
|
|
.await
|
|
.expect("Cannot lookup package information");
|
|
crate::pull::pull(&package_info, Some(cwd), None, 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");
|
|
|
|
// 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",
|
|
)
|
|
.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),
|
|
)
|
|
.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",
|
|
)
|
|
.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",
|
|
)
|
|
.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",
|
|
)
|
|
.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",
|
|
)
|
|
.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",
|
|
)
|
|
.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",
|
|
)
|
|
.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",
|
|
)
|
|
.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"
|
|
);
|
|
}
|
|
}
|