deb: shrink records to upper, journal and log; keep metadata for every build

Records carried a full chroot each, and space turned out to be the hard
constraint: five records per target multiplied by a multi-GB chroot does
not fit. The chroot is now per-run scratch — every build extracts a
fresh one from the sha-verified mmdebstrap tarball cache into its
record's chroot/ dir and removes it at the end (success, failure and
interrupt included); resume_chroot, the .pkh-ready probe and
chroot_is_usable are gone, and crashed-run leftovers are swept when the
next run of that identity starts.

What a record keeps instead: manifest.json (renamed, now with the
tarball sha it used, a kept_upper flag and a tree-only journal — the
environment phases rerun in every fresh chroot), a log/build.log full
tee of the build output (kept for EVERY build; plain runs become
metadata-only records, so keep-iff-incremental now applies to the
staged tree alone), and — for --incremental runs — tree-upper/ with its
host-files.list. The fork gate carries a base's staged tree only while
its recorded tarball sha still matches the cache and the changelog
version is unchanged; a fork candidate is any record with a staged tree.

The keep-last-5 cap now applies to staged trees: a sixth retention
degrades the oldest record to metadata-only instead of deleting it.
New `pkh deb log [-f] [<id>]` prints (or follows) a record's build log,
and `pkh deb list` shows the staged-tree state and plain-run records
too. README rewritten around the shrunk model.
This commit is contained in:
2026-09-29 14:00:34 +02:00
parent 81f0d52f85
commit dd2634abe3
7 changed files with 1287 additions and 946 deletions
+180 -161
View File
@@ -13,7 +13,7 @@ use tar::Archive;
use xz2::read::XzDecoder;
// ---------------------------------------------------------------------------
// Interrupt-time chroot cleanup
// Interrupt-time cleanup
//
// On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in
// [`EphemeralContextGuard::new_with_context`] right before exiting — the
@@ -22,34 +22,61 @@ use xz2::read::XzDecoder;
// /proc and any overlayfs mounts.
// ---------------------------------------------------------------------------
/// Interrupt-time teardown of a build tree. When `keep_tree` is set the
/// tree itself is left in place after unmounting (an `--incremental`
/// record: the interrupt is a pause, not a discard); otherwise this is
/// the historical full cleanup. For a plain `pkh deb` the `path` is the
/// whole throwaway record root, so the record is removed — the record
/// model's keep-iff-incremental policy applies to interrupts too.
/// Interrupt-time teardown of an anonymous temp chroot (no build record):
/// unmount everything under it, then remove it — a plain `rm -rf` with a
/// retry loop, best-effort. See [`sigint_cleanup_record`] for records.
fn sigint_cleanup_chroot(chroot_path: &Path) {
let is_root = unsafe { libc::geteuid() } == 0;
unmount_under_logged(chroot_path);
remove_with_retries(chroot_path, is_root);
}
/// Interrupt-time teardown of a build record: unmount everything under
/// the record root, remove the per-run state (the chroot and the overlay
/// scratch workdir) and — unless `keep_upper` is set — the staged tree
/// and its host-tree snapshot. The manifest and the build log always
/// stay: every build leaves a metadata record, the interrupt included.
///
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
/// through the context manager, the ephemeral context's driver (whose
/// `cleanup()` unmounts the tracked overlays) or the base context's command
/// builder: interrupt-time hooks must be self-contained, and those
/// machineries may be mid-mutation on the interrupted thread. Instead it
/// only reads /proc/mounts and spawns umount/rm directly.
///
/// On a full cleanup it removes the tree regardless of the build result:
/// the build was aborted, and leaving a still-mounted chroot behind is
/// exactly the leak this hook exists to prevent.
/// `cleanup()` unmounts the tracked overlays) or the base context's
/// command builder: interrupt-time hooks must be self-contained, and
/// those machineries may be mid-mutation on the interrupted thread.
/// Instead it only reads /proc/mounts and spawns umount/rm directly.
///
/// Best-effort by design: if a child process still holds a mount busy or
/// privilege escalation is unavailable, individual steps fail; failures are
/// logged (pointing at `pkh prune` for the leftovers) and never panic.
fn sigint_cleanup_chroot(chroot_path: &Path, keep_tree: bool) {
/// privilege escalation is unavailable, individual steps fail; failures
/// are logged (pointing at `pkh prune` for the leftovers) and never
/// panic.
fn sigint_cleanup_record(record_root: &Path, keep_upper: bool) {
let is_root = unsafe { libc::geteuid() } == 0;
unmount_under_logged(record_root);
// Per-run state first: the chroot holds the /proc bind mount and the
// device nodes, the workdir holds root-owned overlay scratch.
for dir in ["chroot", "tree-upper-work"] {
remove_with_retries(&record_root.join(dir), is_root);
}
if keep_upper {
log::debug!(
"Interrupted build record kept its staged tree at {} (fork it \
with `pkh deb --incremental`)",
record_root.display()
);
} else {
for path in ["tree-upper", "host-files.list"] {
remove_with_retries(&record_root.join(path), is_root);
}
}
}
// Unmount children before parents: /proc/mounts lists mounts roughly in
// creation order, so walk it in reverse
let mounts = host_mounts_under(chroot_path);
for mount_point in mounts.into_iter().rev() {
/// Unmount every mount at or under `base`, deepest first, logging the
/// failures (interrupt-time: leftover mounts are `pkh prune`'s to
/// collect).
fn unmount_under_logged(base: &Path) {
let is_root = unsafe { libc::geteuid() } == 0;
// Unmount children before parents: /proc/mounts lists mounts roughly
// in creation order, so walk it in reverse.
for mount_point in host_mounts_under(base).into_iter().rev() {
if unmount_path(&mount_point, is_root) {
log::debug!(
"Unmounted {} during interrupt cleanup",
@@ -63,19 +90,13 @@ fn sigint_cleanup_chroot(chroot_path: &Path, keep_tree: bool) {
);
}
}
}
if keep_tree {
log::debug!(
"Interrupted build record kept at {} (fork it with `pkh deb --incremental`)",
chroot_path.display()
);
return;
}
// Remove the chroot tree itself (tolerates a missing directory). A
// child the Ctrl+C interrupted may still be finishing its writeout —
// dpkg defers SIGINT until it reaches a safe state — so retry while rm
// reports the tree non-empty instead of leaving it half-removed.
/// Privileged `rm -rf` with a retry loop: a child the Ctrl+C interrupted
/// may still be finishing its writeout — dpkg defers SIGINT until it
/// reaches a safe state — so retry while rm reports the tree non-empty
/// instead of leaving it half-removed. A missing path succeeds at once.
fn remove_with_retries(path: &Path, is_root: bool) {
const RETRIES: usize = 10;
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
let mut last = None;
@@ -86,7 +107,7 @@ fn sigint_cleanup_chroot(chroot_path: &Path, keep_tree: bool) {
last = Some(
privileged_command("rm", is_root)
.arg("-rf")
.arg(chroot_path)
.arg(path)
.status(),
);
if matches!(&last, Some(Ok(status)) if status.success()) {
@@ -95,22 +116,19 @@ fn sigint_cleanup_chroot(chroot_path: &Path, keep_tree: bool) {
}
match last {
Some(Ok(status)) if status.success() => {
log::debug!(
"Removed chroot {} during interrupt cleanup",
chroot_path.display()
);
log::debug!("Removed {} during interrupt cleanup", path.display());
}
Some(Ok(status)) => {
log::error!(
"Failed to remove chroot {} during interrupt cleanup \
"Failed to remove {} during interrupt cleanup \
(rm exited with {status}); run `pkh prune`",
chroot_path.display()
path.display()
);
}
Some(Err(e)) => {
log::error!(
"Failed to run rm for chroot {} during interrupt cleanup: {e}; run `pkh prune`",
chroot_path.display()
"Failed to run rm for {} during interrupt cleanup: {e}; run `pkh prune`",
path.display()
);
}
None => unreachable!("at least one rm attempt ran"),
@@ -305,10 +323,11 @@ fn unescape_mount_field(field: &str) -> String {
/// for building packages with unshare permissions.
///
/// In session mode ([`EphemeralContextGuard::new_for_session`]) the chroot
/// lives inside a [`Session`] record: a forked record's chroot is reused
/// when the caller verified it, and the keep policy is strict — the record
/// is kept exactly for `--incremental` runs (`persist_record`), discarded
/// for plain ones whatever the outcome.
/// lives inside a [`Session`] record's `chroot/` directory — extracted
/// fresh from the tarball cache on every run (nothing chroot-shaped is
/// persisted) and removed at the end of the run. The record itself is
/// always kept as metadata and log; its staged tree is retained exactly
/// for `--incremental` runs (`keep_upper`).
pub struct EphemeralContextGuard {
/// The ephemeral build context this guard created (an unshare context
/// bound to the chroot, parented on the base context). Held explicitly so
@@ -327,8 +346,9 @@ pub struct EphemeralContextGuard {
cleanup_hook: Option<CleanupHookGuard>,
/// The build record this guard works in, when any.
session: Option<Session>,
/// Whether the record is kept after the build (`pkh deb --incremental`).
persist_record: bool,
/// Whether the record's staged tree is retained after the build
/// (`pkh deb --incremental`).
keep_upper: bool,
}
impl EphemeralContextGuard {
@@ -345,7 +365,7 @@ impl EphemeralContextGuard {
base_ctx: Arc<Context>,
view: &dyn BuildView,
) -> Result<Self, Box<dyn Error>> {
Self::new_internal(None, series, arch, base_ctx, view, false, false).await
Self::new_internal(None, series, arch, base_ctx, view, false).await
}
/// Create the ephemeral build context inside `session`'s chroot.
@@ -357,31 +377,18 @@ impl EphemeralContextGuard {
/// * `arch` - Optional target architecture, as for
/// [`EphemeralContextGuard::new_with_context`]
/// * `base_ctx` - The base context to use for chroot work
/// * `reuse_chroot` - Reuse the forked record's bootstrapped chroot
/// instead of re-extracting the tarball; the caller must only set
/// this after the record's chroot passed the integrity probe
/// * `persist_record` - Keep the record after the build
/// (`pkh deb --incremental`); a plain run's record is discarded
/// whatever the outcome
/// * `keep_upper` - Retain the record's staged tree after the build
/// (`pkh deb --incremental`); a plain run's record keeps metadata
/// and log only
pub async fn new_for_session(
session: Session,
series: &str,
arch: Option<&str>,
base_ctx: Arc<Context>,
view: &dyn BuildView,
reuse_chroot: bool,
persist_record: bool,
keep_upper: bool,
) -> Result<Self, Box<dyn Error>> {
Self::new_internal(
Some(session),
series,
arch,
base_ctx,
view,
reuse_chroot,
persist_record,
)
.await
Self::new_internal(Some(session), series, arch, base_ctx, view, keep_upper).await
}
async fn new_internal(
@@ -390,8 +397,7 @@ impl EphemeralContextGuard {
arch: Option<&str>,
base_ctx: Arc<Context>,
view: &dyn BuildView,
reuse_chroot: bool,
persist_record: bool,
keep_upper: bool,
) -> Result<Self, Box<dyn Error>> {
// Save the globally-installed context so Drop can restore exactly
// this handle: concurrent builds install their own ephemeral
@@ -399,8 +405,8 @@ impl EphemeralContextGuard {
// before this guard swapped anything in.
let previous_context = context::current();
// In session mode the chroot lives inside the record root (and is
// reused when the caller says so); otherwise it is a fresh temp dir.
// In session mode the chroot lives inside the record root, as
// per-run scratch; otherwise it is a fresh temp dir.
let chroot_path = match &session {
Some(s) => {
let path = s.chroot_path();
@@ -424,22 +430,23 @@ impl EphemeralContextGuard {
// base context: the hook must be self-contained (stored path +
// direct umount/rm subprocesses) and cannot go through `base_ctx`.
// For remote or nested bases the chroot lives elsewhere, and
// leftovers stay handled by `pkh prune` as before. An
// `--incremental` record is kept on interrupt (a pause, not a
// discard); a plain run's whole record root and a plain temp chroot
// are removed.
// leftovers stay handled by `pkh prune` as before.
//
// Session mode tears the record's per-run state down (chroot and
// overlay workdir) and keeps its staged tree exactly for
// `--incremental`; a plain temp chroot is removed entirely.
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
Some(crate::interrupt::register_cleanup_hook(Box::new({
// Session mode tears the whole record root down (the
// manifest is part of the record); plain temp chroots
// only ever had the chroot dir.
let cleanup_path = match &session {
Some(s) => s.root().to_path_buf(),
None => chroot_path.clone(),
};
let keep_tree = persist_record;
move || sigint_cleanup_chroot(&cleanup_path, keep_tree)
})))
let hook: Box<dyn Fn() + Send> = match session.as_ref().map(|s| s.root().to_path_buf())
{
Some(record_root) => {
Box::new(move || sigint_cleanup_record(&record_root, keep_upper))
}
None => {
let chroot_path = chroot_path.clone();
Box::new(move || sigint_cleanup_chroot(&chroot_path))
}
};
Some(crate::interrupt::register_cleanup_hook(hook))
} else {
log::debug!(
"Base context is not local; skipping interrupt-time cleanup registration for {}",
@@ -448,18 +455,11 @@ impl EphemeralContextGuard {
None
};
// Download and extract the chroot tarball (or reuse the record's
// prepared chroot)
if let Err(e) = Self::download_and_extract_chroot(
series,
arch,
&chroot_path,
base_ctx.clone(),
view,
session.as_ref(),
reuse_chroot,
)
.await
// Download and extract the chroot tarball — always fresh; the
// record's chroot directory is per-run scratch.
if let Err(e) =
Self::download_and_extract_chroot(series, arch, &chroot_path, base_ctx.clone(), view)
.await
{
// On a Ctrl+C the interrupt watchdog owns the tree: keep the
// hook registered (forgetting the guard) so it removes the
@@ -476,6 +476,17 @@ impl EphemeralContextGuard {
return Err(e);
}
// Record the tarball digest this environment came from: the fork
// gate of a later `--incremental` run compares it against the
// cache to decide whether the record's staged tree is still
// reusable.
if let Some(s) = &session
&& let Ok(tarball) = chroot_tarball_path(series, arch)
&& let Ok(sha) = session::sha256_file(&tarball)
{
s.set_tarball_sha256(&sha);
}
// Switch to an ephemeral context to build the package in the chroot.
// The parent is the base context itself (the one that bootstrapped
// the chroot), wired through `with_parent` instead of a config-name
@@ -502,7 +513,7 @@ impl EphemeralContextGuard {
base_ctx,
cleanup_hook,
session,
persist_record,
keep_upper,
})
}
@@ -522,25 +533,10 @@ impl EphemeralContextGuard {
chroot_path: &PathBuf,
ctx: Arc<context::Context>,
view: &dyn BuildView,
session: Option<&Session>,
reuse_chroot: bool,
) -> Result<(), Box<dyn Error>> {
// Record fork: the base's chroot passed the caller's checks —
// skip the tarball extraction entirely, the environment (apt
// lists, installed build-deps) is still in there.
if let Some(s) = session {
if reuse_chroot && s.chroot_is_usable() {
log::info!(
"Reusing the prepared chroot of record {} (bootstrap skipped)",
s.manifest().id
);
Self::finalize_chroot(chroot_path, ctx, view)?;
return Ok(());
}
// Not reusing (fresh record or failed probe): never extract
// over stale content.
clear_dir(chroot_path)?;
}
// The chroot is per-run scratch: nothing is reused, so never
// extract over stale content.
clear_dir(chroot_path)?;
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
let ctx_for_devices = ctx.clone();
@@ -619,33 +615,13 @@ impl EphemeralContextGuard {
// Create device nodes in the chroot and bind-mount /proc
Self::finalize_chroot(chroot_path, ctx_for_devices, view)?;
// Record the bootstrap in the record: marker file (the integrity
// probe) and the tarball the chroot came from.
if let Some(s) = session {
std::fs::write(chroot_path.join(".pkh-ready"), b"")?;
let metadata = fs::metadata(&tarball_path)?;
let tarball_sha256 = session::sha256_file(&tarball_path)
.map_err(|e| format!("cannot hash the chroot tarball: {e}"))?;
s.set_chroot(session::ChrootInfo {
tarball: tarball_filename,
tarball_size: metadata.len(),
tarball_mtime_secs: metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0),
tarball_sha256,
ready: true,
});
log::debug!("Record {} chroot is ready", s.manifest().id);
}
// The bootstrap's tarball digest is recorded by the caller (the
// record's fork gate compares it against the cache later).
Ok(())
}
/// Device nodes and the /proc bind mount for a (possibly reused)
/// chroot: idempotent finalization, safe to run on every attempt.
/// Device nodes and the /proc bind mount for a fresh chroot:
/// idempotent finalization, safe to run on every run.
fn finalize_chroot(
chroot_path: &Path,
ctx: Arc<context::Context>,
@@ -660,8 +636,8 @@ impl EphemeralContextGuard {
// Bind mount /proc from host into chroot (before entering unshare namespace)
// This allows /proc to work in containers where mounting inside unshare fails.
// A reused chroot may still carry the mount from the previous
// attempt: only mount when it is not already there.
// A chroot reused within the same run may still carry the mount:
// only mount when it is not already there.
if is_mountpoint(&chroot_path.join("proc")) {
log::debug!("/proc is already bind-mounted in the chroot");
} else {
@@ -908,6 +884,11 @@ impl Drop for EphemeralContextGuard {
// drained, forgetting is a harmless no-op).
if crate::interrupt::interrupted() {
context::manager().set_current_ephemeral(self.previous_context.clone());
// The record keeps its metadata: mark the aborted build as
// failed before the hook tears the per-run state down.
if let Some(session) = &self.session {
session.set_outcome(session::OUTCOME_FAILED);
}
if let Some(hook) = self.cleanup_hook.take() {
std::mem::forget(hook);
}
@@ -942,11 +923,11 @@ impl Drop for EphemeralContextGuard {
// touches it.
context::manager().set_current_ephemeral(self.previous_context.clone());
// Session mode: the tree is a build record. The /proc bind mount
// must come down in every case (a kept record has to be
// unmount-clean for the next fork); the record itself follows the
// keep policy — kept exactly for `--incremental` runs, discarded
// for plain ones whatever the outcome.
// Session mode: the record's per-run chroot comes down in every
// case (nothing chroot-shaped is persisted — success, failure and
// interrupt all end the run), then the record is retained:
// metadata and log always, the staged tree exactly for
// `--incremental` runs.
if let Some(session) = &self.session {
let proc_path = self.chroot_path.join("proc");
let is_root = crate::utils::root::is_root().unwrap_or(false);
@@ -960,7 +941,8 @@ impl Drop for EphemeralContextGuard {
.status()
};
session.dispose_after_build(self.persist_record, self.build_succeeded);
session.remove_run_state();
session.retain_upper(self.keep_upper, self.build_succeeded);
return;
}
@@ -1058,7 +1040,7 @@ mod chroot_cleanup_tests {
fn sigint_cleanup_of_missing_chroot_is_a_noop() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("no-such-chroot");
sigint_cleanup_chroot(&missing, false);
sigint_cleanup_chroot(&missing);
assert!(!missing.exists());
}
@@ -1081,24 +1063,61 @@ mod chroot_cleanup_tests {
std::fs::create_dir_all(chroot.join("rootfs")).unwrap();
std::fs::write(chroot.join("rootfs").join("file.txt"), "data").unwrap();
sigint_cleanup_chroot(&chroot, false);
sigint_cleanup_chroot(&chroot);
assert!(!chroot.exists());
}
/// The interrupt teardown of a kept tree (an `--incremental` record)
/// unmounts and leaves the tree in place — the pause, not the discard.
/// The record interrupt teardown removes the per-run state and keeps
/// the manifest and log; an `--incremental` record keeps its staged
/// tree, a plain one loses it.
#[test]
fn sigint_cleanup_keeps_the_tree_when_asked() {
fn sigint_record_teardown_keeps_metadata_and_drops_the_chroot() {
let is_root = unsafe { libc::geteuid() } == 0;
if !is_root
&& !privileged_command("true", false)
.status()
.is_ok_and(|s| s.success())
{
return;
}
let dir = tempfile::tempdir().unwrap();
let record_root = dir.path().join("record");
std::fs::create_dir_all(record_root.join("chroot/rootfs")).unwrap();
std::fs::write(record_root.join("session.json"), "{}").unwrap();
let incremental = dir.path().join("incremental");
let plain = dir.path().join("plain");
for root in [&incremental, &plain] {
std::fs::create_dir_all(root.join("chroot/rootfs")).unwrap();
std::fs::create_dir_all(root.join("tree-upper/src")).unwrap();
std::fs::create_dir_all(root.join("log")).unwrap();
std::fs::write(root.join("manifest.json"), "{}").unwrap();
std::fs::write(root.join("log/build.log"), "output\n").unwrap();
std::fs::write(root.join("host-files.list"), "f\t1\t1\ta\n").unwrap();
}
sigint_cleanup_chroot(&record_root, true);
sigint_cleanup_record(&incremental, true);
sigint_cleanup_record(&plain, false);
assert!(record_root.join("chroot/rootfs").exists());
assert!(record_root.join("session.json").is_file());
for root in [&incremental, &plain] {
assert!(!root.join("chroot").exists(), "the chroot is per-run");
assert!(root.join("manifest.json").is_file(), "metadata stays");
assert!(root.join("log/build.log").is_file(), "the log stays");
}
assert!(
incremental.join("tree-upper/src").exists(),
"an interrupted incremental run keeps its staged tree"
);
assert!(
incremental.join("host-files.list").is_file(),
"the snapshot stays with the staged tree"
);
assert!(
!plain.join("tree-upper").exists(),
"an interrupted plain run drops its staged tree"
);
assert!(
!plain.join("host-files.list").exists(),
"the snapshot goes with the staged tree"
);
}
/// clear_dir removes content a plain `rm -rf` cannot: a root-owned