From 81f0d52f854e0d9b86548a2450e64b972d1fa4fd Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 29 Sep 2026 12:56:29 +0200 Subject: [PATCH] deb: rework build sessions into immutable per-build records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single mutable session per identity loses the last-good state: a failed run after a good one adopts the session in place and leaves only the failed state behind, so "continue from the last success" was impossible. Each build now records an immutable record under /var/tmp/pkh/sessions/// (chroot, tree-upper, journal, manifest), and --incremental forks a NEW record — a full copy of the base's chroot and, at the same changelog version, its staged artifacts — under the base's flock, never mutating the base itself. Selection picks the newest successful record of the identity among the last five usable, falling back to the newest when none succeeded, and --incremental pins an exact record (failed ones allowed: their environment may be fine). The lifecycle is strict keep-iff-incremental: plain runs build in a throwaway record discarded on success, failure and interrupt alike (the SIGINT cleanup hook follows the same policy), while --incremental records are always kept. Each identity keeps its newest five records; pkh prune's 7-day sweep and orphaned-lock cleanup follow the nested layout. --resume and --keep are removed (no aliases): their adoption-in-place and keep-on-success semantics are exactly what the record model replaces. pkh deb list shows id, outcome, tree version, fork lineage and age. README rewritten around the record workflow. --- README.md | 62 ++- src/deb/ephemeral.rs | 142 ++--- src/deb/mod.rs | 563 ++++++++++++++----- src/deb/session.rs | 1267 +++++++++++++++++++++++++++++++++++------- src/main.rs | 46 +- src/prune.rs | 172 ++++-- 6 files changed, 1736 insertions(+), 516 deletions(-) diff --git a/README.md b/README.md index 1d4d6d5..c88c953 100644 --- a/README.md +++ b/README.md @@ -120,43 +120,45 @@ Without a version tag, that search is capped at the last 500 commits (`--limit `, `0` = unlimited): beyond the cap, the entry simply starts at the oldest commit searched, which keeps the run fast on large histories. -### Incremental builds (build sessions) +### Incremental builds (build records) -Every `pkh deb` build records a **session** under `/var/tmp/pkh/sessions`: -the bootstrapped chroot, the installed build dependencies and the build -artifacts of the staged tree. When a build fails (or is interrupted), the -session is kept and can be resumed: +Every `pkh deb` build runs in its own immutable **record** under +`/var/tmp/pkh/sessions///`: the bootstrapped chroot, the +installed build dependencies and the build artifacts of the staged +tree. `--incremental` forks a previous record — a full copy, the base +is never modified — so a failed run can never destroy the last-good +environment it was built from: ``` -pkh deb # fails after 25 minutes -pkh deb --resume # reuses the chroot, build deps and objects; - # only what changed is recompiled -pkh deb list # the sessions of this tree, with their ids -pkh deb --resume # resume a specific session -pkh deb --incremental # one-flag iterate loop: adopts the newest - # session like `--resume` and keeps it on - # success (with `--resume `, only adds keep) -pkh deb --keep # keep the session even after a successful build - # (iterate: edit, `pkh deb --resume --keep`, ...) -pkh prune # garbage-collect old sessions (7-day retention) +pkh deb # fully ephemeral: nothing is kept, even on + # failure or interrupt +pkh deb --incremental # fork the newest successful record of the + # target (last five usable): the chroot, build + # deps and, at the same version, the objects + # are reused; only what changed is recompiled. + # The result is always kept (success or + # failure) for the next fork +pkh deb --incremental + # fork that exact record (see `pkh deb list`); + # a failed record is fine if its environment is +pkh deb list # the records of this tree: id, outcome, + # version, fork lineage, age +pkh prune # garbage-collect records (7-day retention) ``` -A plain `pkh deb` never reuses a session — everything is rechecked from -scratch — and it replaces the session of its target. `pkh deb --resume` -refuses to adopt a session built for a different series/architecture. - -`pkh deb --incremental` combines `--resume` (without an ID) with -`--keep`, so iterating is a single flag: each successful build keeps the -session and the next `--incremental` build reuses it, recompiling only -what changed. With an explicit `--resume ` it only adds keep. A plain -`pkh deb` still never reuses a session and consumes it on success. +Each target (series/arch) keeps its last five records — older ones are +pruned as new ones are created, and `pkh prune` removes those untouched +for a week. `pkh deb --incremental` never forks a record built for a +different series/architecture, and a plain `pkh deb` still never +reuses anything: it builds in a throwaway record that is discarded on +success, failure and interrupt alike. Packages whose `debian/rules` keeps an input-untracked stamp cache (the -kernels' `debian/stamps`) declare it in the quirks data (`resume_clear`): -resumed builds drop the cache so the wrapper steps — including the -kernel's flavour config export — re-run over the incremental inner -builds, and a config edit is never silently ignored. If a build died -hard (OOM kill), the next build unmounts and clears the leftover session +kernels' `debian/stamps`) declare it in the quirks data +(`resume_clear`): forked builds drop the cache so the wrapper steps — +including the kernel's flavour config export — re-run over the +incremental inner builds, and a config edit is never silently ignored. +If a build died hard (OOM kill), the next fork unmounts the leftover state first (escalating through sudo when needed). ### Running the DEP-8 tests diff --git a/src/deb/ephemeral.rs b/src/deb/ephemeral.rs index 0bb5f33..27e834e 100644 --- a/src/deb/ephemeral.rs +++ b/src/deb/ephemeral.rs @@ -22,10 +22,12 @@ use xz2::read::XzDecoder; // /proc and any overlayfs mounts. // --------------------------------------------------------------------------- -/// Interrupt-time teardown of a chroot tree. When `keep_tree` is set the -/// tree itself is left in place after unmounting (a resumable build -/// session: the interrupt is a pause, not a discard); otherwise this is -/// the historical full cleanup. +/// 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. /// /// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go /// through the context manager, the ephemeral context's driver (whose @@ -64,7 +66,7 @@ fn sigint_cleanup_chroot(chroot_path: &Path, keep_tree: bool) { if keep_tree { log::debug!( - "Interrupted build session kept at {} (resume with `pkh deb --resume`)", + "Interrupted build record kept at {} (fork it with `pkh deb --incremental`)", chroot_path.display() ); return; @@ -129,8 +131,17 @@ pub(crate) fn privileged_command(program: &str, is_root: bool) -> Command { } /// Privileged `rm -rf` of a tree (residual chroots hold root-owned device -/// nodes); shared with the session removal in [`crate::deb::session`]. +/// nodes); shared with the record removal in [`crate::deb::session`]. +/// +/// A tree the current user can fully remove needs no escalation, so the +/// plain removal runs first (record forks and discarded records carry +/// user-owned state); escalation only happens when it fails. pub(crate) fn privileged_remove(path: &Path) -> std::io::Result<()> { + match fs::remove_dir_all(path) { + Ok(()) => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => {} + } let is_root = crate::utils::root::is_root().unwrap_or(false); let status = privileged_command("rm", is_root) .arg("-rf") @@ -294,10 +305,10 @@ 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`]: a previous attempt's chroot is reused when -/// the caller verified it, teardown keeps the tree for a later `--resume`, -/// and a successful build removes the session unless `keep_on_success` is -/// set. +/// 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. 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 @@ -314,10 +325,10 @@ pub struct EphemeralContextGuard { /// Registration of the interrupt-time cleanup hook; deregistered when /// this guard drops, so the hook can never fire after the normal cleanup cleanup_hook: Option, - /// The resumable build session this guard works in, when any. + /// The build record this guard works in, when any. session: Option, - /// Whether a successful build keeps the session (`pkh deb --keep`). - keep_on_success: bool, + /// Whether the record is kept after the build (`pkh deb --incremental`). + persist_record: bool, } impl EphemeralContextGuard { @@ -340,17 +351,18 @@ impl EphemeralContextGuard { /// Create the ephemeral build context inside `session`'s chroot. /// /// # Arguments - /// * `session` - The resumable session owning the chroot (locked by the + /// * `session` - The build record owning the chroot (locked by the /// caller) /// * `series` - The distribution series (e.g., "noble", "sid") /// * `arch` - Optional target architecture, as for /// [`EphemeralContextGuard::new_with_context`] /// * `base_ctx` - The base context to use for chroot work - /// * `reuse_chroot` - Adopt the previous attempt's bootstrapped chroot + /// * `reuse_chroot` - Reuse the forked record's bootstrapped chroot /// instead of re-extracting the tarball; the caller must only set - /// this after the session's chroot passed the integrity probe - /// * `keep_on_success` - Keep the session after a successful build - /// (`pkh deb --keep`) + /// 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 pub async fn new_for_session( session: Session, series: &str, @@ -358,7 +370,7 @@ impl EphemeralContextGuard { base_ctx: Arc, view: &dyn BuildView, reuse_chroot: bool, - keep_on_success: bool, + persist_record: bool, ) -> Result> { Self::new_internal( Some(session), @@ -367,7 +379,7 @@ impl EphemeralContextGuard { base_ctx, view, reuse_chroot, - keep_on_success, + persist_record, ) .await } @@ -379,7 +391,7 @@ impl EphemeralContextGuard { base_ctx: Arc, view: &dyn BuildView, reuse_chroot: bool, - keep_on_success: bool, + persist_record: bool, ) -> Result> { // Save the globally-installed context so Drop can restore exactly // this handle: concurrent builds install their own ephemeral @@ -387,7 +399,7 @@ impl EphemeralContextGuard { // before this guard swapped anything in. let previous_context = context::current(); - // In session mode the chroot lives inside the session root (and is + // 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. let chroot_path = match &session { Some(s) => { @@ -412,14 +424,21 @@ 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. A session tree is - // kept on interrupt (a pause, not a discard); a plain temp chroot is - // still removed. + // 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. let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) { Some(crate::interrupt::register_cleanup_hook(Box::new({ - let chroot_path = chroot_path.clone(); - let keep_tree = session.is_some(); - move || sigint_cleanup_chroot(&chroot_path, keep_tree) + // 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) }))) } else { log::debug!( @@ -429,7 +448,7 @@ impl EphemeralContextGuard { None }; - // Download and extract the chroot tarball (or reuse the session's + // Download and extract the chroot tarball (or reuse the record's // prepared chroot) if let Err(e) = Self::download_and_extract_chroot( series, @@ -483,7 +502,7 @@ impl EphemeralContextGuard { base_ctx, cleanup_hook, session, - keep_on_success, + persist_record, }) } @@ -506,19 +525,19 @@ impl EphemeralContextGuard { session: Option<&Session>, reuse_chroot: bool, ) -> Result<(), Box> { - // Session resume: the previous attempt's chroot passed the caller's - // checks — skip the tarball extraction entirely, the environment - // (apt lists, installed build-deps) is still in there. + // 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 session {} (bootstrap skipped)", + "Reusing the prepared chroot of record {} (bootstrap skipped)", s.manifest().id ); Self::finalize_chroot(chroot_path, ctx, view)?; return Ok(()); } - // Not reusing (fresh session or failed probe): never extract + // Not reusing (fresh record or failed probe): never extract // over stale content. clear_dir(chroot_path)?; } @@ -600,8 +619,8 @@ 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 session: marker file (the resume - // integrity probe) and the tarball the chroot came from. + // 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)?; @@ -619,7 +638,7 @@ impl EphemeralContextGuard { tarball_sha256, ready: true, }); - log::debug!("Session {} chroot is ready", s.manifest().id); + log::debug!("Record {} chroot is ready", s.manifest().id); } Ok(()) @@ -864,7 +883,7 @@ impl EphemeralContextGuard { } /// The staged build root inside the chroot: the stable - /// `/tmp/pkh-build` for session builds (the overlay upperdir mirrors + /// `/tmp/pkh-build` for record builds (the overlay upperdir mirrors /// paths relative to the merge point, so the staged tree must live at /// the same in-chroot path on every attempt for the build artifacts to /// be reusable), a fresh timestamped temp dir otherwise. @@ -923,10 +942,11 @@ impl Drop for EphemeralContextGuard { // touches it. context::manager().set_current_ephemeral(self.previous_context.clone()); - // Session mode: the tree is a resumable build session. The /proc - // bind mount must come down in every case (a kept session has to be - // unmount-clean for the next resume); the tree itself stays unless - // a successful build without `--keep` consumes it. + // 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. 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); @@ -940,26 +960,7 @@ impl Drop for EphemeralContextGuard { .status() }; - if self.build_succeeded { - if self.keep_on_success { - session.set_outcome(session::OUTCOME_SUCCESS); - log::info!( - "Build session {} kept (resume with `pkh deb --resume`)", - session.manifest().id - ); - } else { - log::debug!( - "Build succeeded, removing build session {}", - session.root().display() - ); - session.remove(); - } - } else { - log::debug!( - "Build did not succeed: session {} kept (resume with `pkh deb --resume`)", - session.manifest().id - ); - } + session.dispose_after_build(self.persist_record, self.build_succeeded); return; } @@ -1085,6 +1086,21 @@ mod chroot_cleanup_tests { 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. + #[test] + fn sigint_cleanup_keeps_the_tree_when_asked() { + 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(); + + sigint_cleanup_chroot(&record_root, true); + + assert!(record_root.join("chroot/rootfs").exists()); + assert!(record_root.join("session.json").is_file()); + } + /// clear_dir removes content a plain `rm -rf` cannot: a root-owned /// 0700 directory (the shape of an overlay workdir left by a crashed /// build). Skipped when neither root nor working non-interactive sudo diff --git a/src/deb/mod.rs b/src/deb/mod.rs index 8a005bd..eec6e32 100644 --- a/src/deb/mod.rs +++ b/src/deb/mod.rs @@ -118,30 +118,34 @@ pub struct DebBuildOptions<'a> { pub jobs: Option, /// Explicit build context; defaults to the current context. pub ctx: Option>, - /// Resumable-session request: adopt a previous attempt's session - /// (`pkh deb --resume []`). Defaults to a fresh build, which - /// replaces the session of this identity without reading it. - pub resume: SessionResume, - /// Keep the session after a successful build (`pkh deb --keep`). - /// Without it a success consumes the session, as today's successful - /// build removes its chroot. - pub keep_session: bool, + /// Incremental request (`pkh deb --incremental []`): which + /// record to fork as the base of this build, and whether this + /// build's own record is kept. Defaults to a plain throwaway run. + pub incremental: Incremental, /// Where build events (phases, progress, outcome) are reported. pub view: &'a dyn BuildView, } -/// Which session a `pkh deb` build should adopt, if any. +/// The `--incremental` request of a `pkh deb` build: which record the +/// build forks its environment from, and whether its own record is +/// kept afterwards. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum SessionResume { - /// No adoption: build from scratch, replacing the session of this - /// identity (recording stays on). +pub enum Incremental { + /// A plain run (`pkh deb` without `--incremental`): build from + /// scratch in a throwaway record, discarded on success, failure + /// and interrupt alike. #[default] - Fresh, - /// Adopt the newest usable session recorded from this tree - /// (`pkh deb --resume`). - Latest, - /// Adopt the session with this id or unique id prefix - /// (`pkh deb --resume `). + Off, + /// `--incremental`: fork the auto-selected base — the newest + /// successful record of this identity among the last five usable — + /// and always keep the new record. With no usable candidate the + /// build runs from scratch and its record becomes the identity's + /// first base. + Auto, + /// `--incremental `: fork that exact record (unique id or + /// prefix, restricted to the records of this host tree; a FAILED + /// record is a valid base — its environment may be fine, and the + /// user pinned it deliberately) and always keep the new record. Id(String), } @@ -159,8 +163,7 @@ impl Default for DebBuildOptions<'_> { inject: Vec::new(), jobs: None, ctx: None, - resume: SessionResume::Fresh, - keep_session: false, + incremental: Incremental::Off, view: &QUIET, } } @@ -203,8 +206,7 @@ async fn build_binary_package_impl( ref inject, ref jobs, ref ctx, - ref resume, - keep_session, + ref incremental, view, } = opts; let cwd = cwd.as_deref().unwrap_or_else(|| Path::new(".")); @@ -264,10 +266,11 @@ async fn build_binary_package_impl( // this scope so it outlives the guarded section below and is only dropped // once the live view has been cleared. // - // Resumable build sessions are a local-only feature: with a local base - // context the chroot lives in a session root (recording always on, - // adoption only when requested); every other base context keeps the - // historical anonymous temp chroot. + // 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()); @@ -278,9 +281,20 @@ async fn build_binary_package_impl( cross, }; + let mut persist_record = false; let session = if session_enabled { - match resolve_session(resume, &host_tree, &identity, &version, series, chroot_arch) { - Ok(session) => session, + match resolve_record( + incremental, + &host_tree, + &identity, + &version, + series, + chroot_arch, + ) { + Ok((record, persist)) => { + persist_record = persist; + Some(record) + } Err(e) => { view.finish_failure(); return Err(e); @@ -289,9 +303,9 @@ async fn build_binary_package_impl( } else { None }; - let resume_chroot = session - .as_ref() - .is_some_and(|s| s.chroot_is_usable() && resume != &SessionResume::Fresh); + // Only a forked record carries a bootstrapped chroot at this point; + // fresh ones (plain runs and first `--incremental` runs) extract it. + let resume_chroot = session.as_ref().is_some_and(|s| s.chroot_is_usable()); let mut guard = if let Some(s) = session.clone() { Some( @@ -302,7 +316,7 @@ async fn build_binary_package_impl( base_ctx.clone(), view, resume_chroot, - keep_session, + persist_record, ) .await?, ) @@ -472,16 +486,19 @@ async fn build_binary_package_impl( // the chroot) inherit the terminal, so they must not fight the widget. view.suspend(); - // Mark build as successful to trigger chroot cleanup. The guard's drop - // disposes of the session: removed on a plain success, kept with - // `--keep` (outcome recorded there). + // Mark the build as successful for the guard's drop, which disposes of + // the record per the keep policy: an `--incremental` record is kept + // with its outcome recorded, a plain run's throwaway record is + // always discarded. if result.is_ok() - && let Some(ref mut g) = guard + && let Some(g) = &mut guard { g.mark_build_successful(); } - // Failures keep the session for a later `--resume`: record the outcome. + // A failed `--incremental` run keeps its record for a later fork: + // record the outcome (discarded plain runs need none). if result.is_err() + && persist_record && let Some(s) = &session { s.set_outcome(session::OUTCOME_FAILED); @@ -490,133 +507,134 @@ async fn build_binary_package_impl( result } -/// Resolve the session a build runs in. -/// -/// - [`SessionResume::Fresh`]: open (or create) the session of this -/// identity and reset it for a fresh attempt — the previous attempt's -/// content is replaced without being read. When a concurrent build holds -/// the session, build without one (the historical anonymous temp chroot). -/// - [`SessionResume::Latest`]: adopt the newest usable session recorded -/// from this tree whose identity matches this build; sessions for other -/// identities are a conflict (refused, never silently switched). -/// - [`SessionResume::Id`]: adopt the session matching the id (or unique -/// prefix) recorded from this tree. -/// -/// Returns the session handle, if any; `Ok(None, ..)` means "build without -/// a session". The bool says whether the previous attempt's chroot may be -/// reused (it passed the integrity probe and the bootstrap tarball still -/// matches). -fn resolve_session( - resume: &SessionResume, +/// 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>, -) -> Result, Box> { +) -> Result<(session::Session, bool), Box> { + let expected = ephemeral::chroot_tarball_path(series, chroot_arch).ok(); + resolve_record_in( + &session::sessions_root(), + expected.as_deref(), + incremental, + host_tree, + identity, + version, + ) +} + +/// The testable core of [`resolve_record`] over an explicit sessions root +/// and bootstrap tarball. +/// +/// Every local build runs in its own record: [`Incremental::Off`] gets a +/// brand-new throwaway one, [`Incremental::Auto`] and +/// [`Incremental::Id`] fork the selected base. The returned flag says +/// whether the record is kept after the build — exactly the +/// `--incremental` runs keep theirs. +/// +/// A base must be usable: its chroot passes the integrity probe and was +/// bootstrapped from the tarball this build would use (`expected_tarball` +/// is `None` when the path cannot be resolved — no record is usable +/// then, which degrades an `--incremental` run to a fresh build). +fn resolve_record_in( + records_root: &Path, + expected_tarball: Option<&Path>, + incremental: &Incremental, + host_tree: &Path, + identity: &session::SessionIdentity, + version: &str, +) -> Result<(session::Session, bool), Box> { let usable = |s: &session::Session| { - s.chroot_is_usable() && session::tarball_matches(s, series, chroot_arch) + s.chroot_is_usable() + && expected_tarball.is_some_and(|t| session::tarball_matches_path(s, t)) }; - match resume { - SessionResume::Fresh => match session::fresh_session(host_tree, identity, version) { - Ok(s) => Ok(Some(s)), - Err(session::SessionOpenError::Busy(busy)) => { - log::info!("{busy}; building without a session"); - Ok(None) - } - Err(session::SessionOpenError::Unusable(e)) => { - log::error!("{e}; building without a session"); - Ok(None) - } - Err(session::SessionOpenError::Other(e)) => Err(e.into()), - }, - SessionResume::Latest => { - let candidates: Vec = session::for_tree(host_tree) - .into_iter() - .filter(|s| usable(s)) - .collect(); - match candidates - .iter() - .find(|s| s.manifest().identity == *identity) - { - Some(s) => Ok(Some(adopt_session(s, identity, version)?)), - None if candidates.is_empty() => { - log::info!( - "No resumable build session found for this tree; building from scratch" - ); - Ok(None) - } - None => { - let found = candidates - .iter() - .map(|s| s.manifest().identity.to_string()) - .collect::>() - .join(", "); - Err(format!( - "cannot resume: the sessions of this tree were built for {found}, \ - but this build targets {identity}. Use `pkh deb list` and \ - `pkh deb --resume `, or adjust the selectors" - ) - .into()) + 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 + // chroot may be mid-mutation (apt always runs, deps land + // inside it), and the fork would copy that state. + let candidates: Vec = + session::identity_records_in(records_root, identity) + .into_iter() + .filter(|s| usable(s)) + .filter(|s| !session::lock_is_held(&s.lock_path()).unwrap_or(true)) + .take(session::KEEP_RECORDS) + .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) { + 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 usable record of {identity} 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, + )) } - SessionResume::Id(id) => { - let mut matches: Vec = session::by_id_prefix(id) + Incremental::Id(id) => { + let mut matches: Vec = session::by_id_prefix_in(records_root, id) .into_iter() .filter(|s| s.manifest().host_tree == host_tree.to_string_lossy()) .filter(|s| usable(s)) .collect(); match matches.len() { 0 => Err(format!( - "no usable build session matches id '{id}' for this tree \ + "no usable build record matches id '{id}' for this tree \ (see `pkh deb list`)" ) .into()), - 1 => Ok(Some(adopt_session(&matches.remove(0), identity, version)?)), + 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)?, + true, + )) + } _ => { let found = matches .iter() .map(|s| s.manifest().id.clone()) .collect::>() .join(", "); - Err(format!("ambiguous session id '{id}': matches {found}").into()) + Err(format!("ambiguous record id '{id}': matches {found}").into()) } } } } } -/// Lock a session for adoption and update its journal: outcome resets to -/// running, a changed changelog version drops the build artifacts (the -/// environment is kept), and the reuse decision for the chroot is made. -fn adopt_session( - session: &session::Session, - identity: &session::SessionIdentity, - version: &str, -) -> Result> { - session - .acquire() - .map_err(|busy| -> Box { format!("cannot resume: {busy}").into() })?; - let manifest = session.manifest(); - if manifest.tree_version != version { - log::info!( - "Version changed since the previous attempt ({}): build artifacts \ - are discarded, the environment is reused", - manifest.tree_version - ); - session.wipe_build_artifacts(); - session.set_tree_version(version); - } - session.set_outcome(session::OUTCOME_RUNNING); - log::info!( - "Resuming session {} for {identity}: the prepared chroot and \ - installed build dependencies are reused", - manifest.id - ); - Ok(session.clone()) -} - /// Resolve the package directory for a build inside the staged build root. /// /// The tree the caller pointed at is authoritative: `cwd`'s changelog @@ -1330,4 +1348,283 @@ mod tests { ".deb not produced for the 'tree'-named directory, got: {deb_files:?}" ); } + + // ------------------------------------------------------------------- + // Record resolution (`--incremental` base selection) + // ------------------------------------------------------------------- + + use crate::deb::session::record_fixtures::{ + record_with_environment, sessions_fixture, test_identity, + }; + use crate::deb::session::{self, SessionIdentity}; + + /// A plain run resolves to a brand-new, lineage-free record that the + /// caller will discard — 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, persist) = resolve_record_in( + root.path(), + Some(&tarball), + &Incremental::Off, + &host_tree, + &identity, + "1.0", + ) + .expect("a plain run always resolves to a fresh record"); + assert!(!persist, "a plain run discards its record"); + assert_eq!(record.manifest().base, None); + assert!(!record.chroot_is_usable()); + } + + /// 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_environment( + 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").unwrap(); + let b_id = b.manifest().id; + b.set_outcome(session::OUTCOME_FAILED); + drop((a, b)); + + let (record, persist) = resolve_record_in( + root.path(), + Some(&tarball), + &Incremental::Auto, + &host_tree, + &identity, + "1.0", + ) + .expect("the auto selection must fork a usable record"); + assert!(persist); + assert_eq!(record.manifest().base.as_deref(), Some(a_id.as_str())); + assert_ne!(record.manifest().id, b_id); + } + + /// A first `--incremental` run with no candidate builds completely + /// fresh and persists its record — it becomes the identity's first + /// base. + #[test] + fn resolve_auto_without_candidates_builds_fresh_and_persists() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + + let (record, persist) = resolve_record_in( + root.path(), + Some(&tarball), + &Incremental::Auto, + &host_tree, + &identity, + "1.0", + ) + .expect("no candidates must degrade to a fresh build"); + assert!(persist, "the first record becomes a base"); + assert_eq!(record.manifest().base, None); + } + + /// Non-usable records never make it into the selection: a newer + /// successful record with a broken chroot loses to an older usable + /// failed one. + #[test] + fn resolve_auto_skips_non_usable_records() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let usable = record_with_environment( + root.path(), + &tarball, + &host_tree, + &identity, + "1.0", + Some(session::OUTCOME_FAILED), + ); + let usable_id = usable.manifest().id; + drop(usable); + // Newer, successful, but its chroot never finished bootstrapping. + let broken = session::new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap(); + broken.set_outcome(session::OUTCOME_SUCCESS); + drop(broken); + + let (record, _) = resolve_record_in( + root.path(), + Some(&tarball), + &Incremental::Auto, + &host_tree, + &identity, + "1.0", + ) + .expect("the usable record is still a base"); + assert_eq!(record.manifest().base.as_deref(), Some(usable_id.as_str())); + } + + /// `--incremental ` may pin a FAILED record — its environment may + /// be fine and the user chose it deliberately. + #[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_environment( + root.path(), + &tarball, + &host_tree, + &identity, + "1.0", + Some(session::OUTCOME_FAILED), + ); + let failed_id = failed.manifest().id; + drop(failed); + + let (record, persist) = resolve_record_in( + root.path(), + Some(&tarball), + &Incremental::Id(failed_id.clone()), + &host_tree, + &identity, + "1.0", + ) + .expect("a pinned failed record is a valid base"); + assert!(persist); + assert_eq!(record.manifest().base.as_deref(), Some(failed_id.as_str())); + } + + /// 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_environment( + 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(), + Some(&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 usable 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_environment( + root.path(), + &tarball, + &host_tree, + &identity, + "1.0", + None, + )); + drop(record_with_environment( + 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(), + Some(&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 environment + /// was bootstrapped 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_environment( + 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(), + Some(&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}" + ); + } } diff --git a/src/deb/session.rs b/src/deb/session.rs index 7d97717..f2f74f1 100644 --- a/src/deb/session.rs +++ b/src/deb/session.rs @@ -1,27 +1,34 @@ -//! Resumable build sessions for `pkh deb`. +//! Immutable per-build records for `pkh deb`. //! -//! A session is one (package, series, arch, cross) build environment plus +//! A record is one (package, series, arch, cross) build environment plus //! its phase journal, persisted under the sessions directory -//! (`/var/tmp/pkh/sessions//` by default) so a later -//! `pkh deb --resume` can adopt it: the bootstrapped chroot, the installed -//! build-dependencies and the build artifacts produced inside the staged -//! tree (object files, ...) survive between attempts. +//! (`/var/tmp/pkh/sessions///` by default). Records are +//! never mutated after their run ends: the outcome is written once at the +//! end, and a later `pkh deb --incremental` FORKS a record — a full copy +//! into a new record — instead of adopting it in place, so a failed run +//! can never destroy the last-good environment it was built from. //! -//! The layout of one session root: +//! The layout of one record root: //! //! ```text //! /var/tmp/pkh/sessions// -//! session.json # the manifest + phase journal -//! .lock # advisory lock (flock) marking a live build -//! host-files.list # snapshot of the host tree at staging time -//! chroot/ # the chroot tree (marker: .pkh-ready) +//! / # one immutable record per build +//! session.json # the manifest + phase journal +//! host-files.list # snapshot of the host tree at staging time +//! chroot/ # the chroot tree (marker: .pkh-ready), with the +//! # persistent overlay upperdir inside +//! .lock # advisory lock (flock) marking a live build, +//! # outside the record root it guards //! ``` //! -//! Recording is always on for local builds (a plain `pkh deb` replaces the -//! session of its identity); reusing one is opt-in (`--resume`). This -//! module holds the manifest model, discovery, locking, the host-tree -//! snapshot used to propagate host-side deletions into the reused overlay -//! upperdir, and the `pkh deb list` rendering. The adoption policy lives in +//! Recording is always on for local builds: a plain `pkh deb` builds in a +//! throwaway record that is discarded on success, failure and interrupt +//! alike; an `--incremental` run forks a base record (auto-selected or +//! pinned by id) and always keeps its own record for future forks. This +//! module holds the manifest model, discovery, locking, forking, the +//! keep-last-5 pruning per identity, the host-tree snapshot used to +//! propagate host-side deletions into the reused overlay upperdir, and +//! the `pkh deb list` rendering. The base-selection policy lives in //! [`crate::deb`]. use std::collections::BTreeMap; @@ -36,19 +43,25 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -/// Manifest schema version: a session recorded by another schema is -/// ignored (and replaced by the next build) rather than adopted. +/// Manifest schema version: a record recorded by another schema is +/// ignored (and eventually pruned) rather than forked. pub(crate) const SCHEMA_VERSION: u32 = 1; -/// How long a session must be untouched before the default `pkh prune` +/// How long a record must be untouched before the default `pkh prune` /// removes it. pub const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); +/// How many records of one identity are kept when a new record is created +/// for it: the oldest ones beyond this count are pruned right away (the +/// 7-day [`SESSION_RETENTION`] sweep still applies on top, through +/// `pkh prune`). +pub(crate) const KEEP_RECORDS: usize = 5; + /// The build is running (`last_outcome`). pub const OUTCOME_RUNNING: &str = "running"; -/// The last build adopted from this session succeeded (`last_outcome`). +/// The build recorded in this record succeeded (`last_outcome`). pub const OUTCOME_SUCCESS: &str = "success"; -/// The last build adopted from this session failed (`last_outcome`). +/// The build recorded in this record failed (`last_outcome`). pub const OUTCOME_FAILED: &str = "failed"; /// Marker file written inside the chroot once the bootstrap (extraction, @@ -118,57 +131,62 @@ pub struct PhaseStamp { pub stamp: Option, } -/// The session manifest: identity, journal and tree state. +/// The record manifest: identity, journal and tree state. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionManifest { /// Manifest schema version ([`SCHEMA_VERSION`]). pub schema: u32, /// pkh version that last wrote the manifest. pub pkh: String, - /// Session id: the build-start timestamp of the latest attempt + /// Record id: the build-start timestamp of this record /// (`YYYYMMDDTHHMMSS`, UTC). pub id: String, - /// Canonical host tree the session was recorded from; `pkh deb list` - /// and `--resume` scope sessions to this. + /// Canonical host tree the record was recorded from; `pkh deb list` + /// and `--incremental ` scope records to this. pub host_tree: String, /// What the environment was built for. pub identity: SessionIdentity, /// RFC 3339 creation timestamp. pub created: String, - /// RFC 3339 timestamp of the latest attempt. + /// RFC 3339 timestamp of this record's build. pub last_used: String, - /// Outcome of the latest attempt ([`OUTCOME_RUNNING`]/[`OUTCOME_SUCCESS`]/[`OUTCOME_FAILED`]). + /// Outcome of this record's build ([`OUTCOME_RUNNING`]/[`OUTCOME_SUCCESS`]/[`OUTCOME_FAILED`]). pub last_outcome: String, + /// Id of the record this one was forked from, when any + /// (`--incremental` lineage; `None` when built from scratch). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option, /// Chroot bootstrap state. pub chroot: ChrootInfo, /// Phase journal: completed phases with their input stamps. pub phases: BTreeMap, - /// Changelog version of the latest attempt: a version change between - /// attempts discards the build artifacts (upperdir), not the - /// environment. + /// Changelog version of this record's build: a version change + /// between a base and its fork discards the build artifacts + /// (upperdir), not the environment. pub tree_version: String, - /// Whether the latest attempt staged the tree over the reused overlay - /// upperdir (build-level resume was active). + /// Whether this record's build staged the tree over the reused + /// overlay upperdir (build-level resume was active). pub build_resume: bool, } /// State shared through the [`Session`] handle: the manifest plus the /// advisory lock file held open for the lifetime of the handle. +#[derive(Debug)] struct SessionState { manifest: SessionManifest, lock: Option, } -/// A handle to one session root: manifest access, journal recording and +/// A handle to one record root: manifest access, journal recording and /// the paths of everything the build flow touches. Clones share the state. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Session { root: PathBuf, state: Arc>, } impl Session { - /// The session root directory. + /// The record root directory. pub fn root(&self) -> &Path { &self.root } @@ -182,13 +200,13 @@ impl Session { .clone() } - /// The chroot directory inside the session root. + /// The chroot directory inside the record root. pub fn chroot_path(&self) -> PathBuf { self.root.join("chroot") } - /// The persistent overlay upperdir (host-side path): build writes land - /// here and survive between attempts. + /// The persistent overlay upperdir (host-side path): the build's + /// writes land here and are forked with the record. pub fn upper_dir(&self) -> PathBuf { self.chroot_path().join("pkh-overlay/upper-build") } @@ -198,13 +216,13 @@ impl Session { self.chroot_path().join("pkh-overlay/work-build") } - /// The host-tree snapshot file inside the session root. + /// The host-tree snapshot file inside the record root. pub(crate) fn snapshot_file(&self) -> PathBuf { self.root.join(SNAPSHOT_NAME) } /// The chroot-side path of the staged build root (stable across - /// attempts, see [`BUILD_ROOT`]). + /// records, see [`BUILD_ROOT`]). pub fn build_root(&self) -> String { BUILD_ROOT.to_string() } @@ -227,7 +245,7 @@ impl Session { }); } - /// Record the outcome of the latest attempt and persist it. + /// Record the outcome of this record's build and persist it. pub fn set_outcome(&self, outcome: &str) { self.with_manifest(|manifest| { manifest.last_outcome = outcome.to_string(); @@ -242,14 +260,14 @@ impl Session { }); } - /// Record the changelog version of this attempt. + /// Record the changelog version of this record's build. pub fn set_tree_version(&self, version: &str) { self.with_manifest(|manifest| { manifest.tree_version = version.to_string(); }); } - /// Record whether this attempt reuses the overlay upperdir. + /// Record whether this record's build reused the overlay upperdir. pub fn set_build_resume(&self, value: bool) { self.with_manifest(|manifest| { manifest.build_resume = value; @@ -266,19 +284,49 @@ impl Session { f(&mut state.manifest); if let Err(e) = save_manifest(&self.root, &state.manifest) { log::warn!( - "Failed to save the build session manifest '{}': {}", + "Failed to save the build record manifest '{}': {}", self.root.join(MANIFEST_NAME).display(), e ); } } - /// Take the advisory lock marking a live build on this session. + /// Dispose of the record at the end of a build, per the keep policy: + /// an `--incremental` run (`persist`) always keeps its record — the + /// outcome is recorded on success, failures already carry theirs — + /// while a plain run's throwaway record is always discarded, on + /// success and failure alike. The interrupt path is handled by the + /// SIGINT cleanup hook, which follows the same policy. + pub(crate) fn dispose_after_build(&self, persist: bool, succeeded: bool) { + if !persist { + log::debug!( + "Discarding the throwaway build record {}", + self.root.display() + ); + self.remove(); + return; + } + if succeeded { + self.set_outcome(OUTCOME_SUCCESS); + log::info!( + "Build record {} kept (the next --incremental run forks it)", + self.manifest().id + ); + } else { + log::info!( + "Build record {} kept after the failed run (fork it with \ + `pkh deb --incremental `)", + self.manifest().id + ); + } + } + + /// Take the advisory lock marking a live build on this record. /// /// Fails with [`SessionBusy`] when another build holds it. The lock is /// released when the handle (and every clone) drops. /// - /// The lock file lives NEXT TO the session root (not inside it): a + /// The lock file lives NEXT TO the record root (not inside it): a /// teardown removes the root while still holding the lock, and a lock /// file inside it would be deleted under the holder — a concurrent /// opener would then create a fresh inode and lock nothing. @@ -310,8 +358,8 @@ impl Session { Ok(()) } - /// The advisory lock file path: `/.lock`, outside - /// the session root it guards (see [`Session::acquire`]). + /// The advisory lock file path: `/.lock`, outside + /// the record root it guards (see [`Session::acquire`]). pub(crate) fn lock_path(&self) -> PathBuf { let name = self .root @@ -321,7 +369,7 @@ impl Session { self.root.with_file_name(format!("{name}.{LOCK_NAME}")) } - /// Whether the chroot of this session passes the integrity probe + /// Whether the chroot of this record passes the integrity probe /// (bootstrap completed marker + a runnable `/bin/sh`). pub fn chroot_is_usable(&self) -> bool { let manifest = self.manifest(); @@ -345,22 +393,22 @@ impl Session { /// Wipe the staged tree inside the chroot (the stable build root). /// - /// Used when overlay staging is unavailable on a resumed session: the - /// fallback re-stages a fresh copy, and copying over the previous - /// attempt's tree would merge stale build artifacts and resurrect - /// host-deleted files into the build. + /// Used when overlay staging is unavailable on a forked record: the + /// fallback re-stages a fresh copy, and copying over the base's tree + /// would merge stale build artifacts and resurrect host-deleted files + /// into the build. pub fn wipe_staged_tree(&self) -> io::Result<()> { crate::deb::ephemeral::clear_dir(&self.chroot_path().join("tmp/pkh-build")) } - /// Remove the whole session tree (unmounting anything mounted under - /// it first), then unlink the lock file. The caller holds the flock; - /// an opener that raced ahead of the unlink failed its lock attempt - /// while we held it, and the next one creates a fresh session. + /// Remove the whole record tree (unmounting anything mounted under it + /// first), then unlink the lock file. The caller holds the flock; an + /// opener that raced ahead of the unlink failed its lock attempt + /// while we held it, and the next one creates a fresh record. pub fn remove(&self) { if let Err(e) = remove_session_dir(&self.root) { log::warn!( - "Failed to remove build session {}: {}", + "Failed to remove build record {}: {}", self.root.display(), e ); @@ -369,10 +417,10 @@ impl Session { } } -/// Error returned when a session is locked by another live build. +/// Error returned when a record is locked by another live build. #[derive(Debug)] pub struct SessionBusy { - /// The locked session root. + /// The locked record root. pub root: PathBuf, /// Underlying error detail. pub source: String, @@ -382,7 +430,7 @@ impl std::fmt::Display for SessionBusy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "build session {} is locked by another build", + "build record {} is locked by another build", self.root.display() ) } @@ -406,7 +454,7 @@ fn now_rfc3339() -> String { chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) } -/// Build-start timestamp used as session id (`YYYYMMDDTHHMMSS`, UTC). +/// Build-start timestamp used as record id (`YYYYMMDDTHHMMSS`, UTC). fn now_session_id() -> String { chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string() } @@ -503,27 +551,27 @@ pub fn control_stamp(host_tree: &Path, selectors: &str) -> Option { /// Persist a manifest atomically. /// -/// A handle may outlive its session (the build removed it on success, or a -/// later attempt replaced it); such a save must not resurrect the -/// directory, so a vanished root is skipped. +/// A handle may outlive its record (a plain run discards it at the end); +/// such a save must not resurrect the directory, so a vanished root is +/// skipped. fn save_manifest(root: &Path, manifest: &SessionManifest) -> io::Result<()> { if !root.try_exists()? { log::debug!( - "Session root '{}' is gone, skipping the manifest save", + "Record root '{}' is gone, skipping the manifest save", root.display() ); return Ok(()); } let tmp = root.join(format!("{MANIFEST_NAME}.tmp")); let content = serde_json::to_string_pretty(manifest) - .map_err(|e| io::Error::other(format!("cannot serialize the session manifest: {e}")))?; + .map_err(|e| io::Error::other(format!("cannot serialize the record manifest: {e}")))?; fs::write(&tmp, content)?; fs::rename(&tmp, root.join(MANIFEST_NAME)) } -/// Load one session from its root; `None` when there is no readable -/// current-schema manifest (the directory is then inert for discovery and -/// gets replaced by the next build). +/// Load one record from its root; `None` when there is no readable +/// current-schema manifest (the directory is then inert for discovery +/// and eventually collected by `pkh prune`). fn open_from_dir(root: PathBuf) -> Option { let content = fs::read_to_string(root.join(MANIFEST_NAME)).ok()?; let manifest: SessionManifest = serde_json::from_str(&content).ok()?; @@ -539,13 +587,14 @@ fn open_from_dir(root: PathBuf) -> Option { }) } -/// Error of the session setup paths. +/// Error of the record setup paths. #[derive(Debug)] pub enum SessionOpenError { - /// The session is locked by a concurrent build. + /// The record is locked by a concurrent build. Busy(SessionBusy), - /// The previous session could not be cleared (crashed-build leftovers - /// that even the privileged removal could not lift). + /// The record could not be forked or created (copy failures, + /// crashed-build leftovers that even the privileged removal could + /// not lift). Unusable(String), /// Anything else (I/O, manifest errors). Other(String), @@ -563,97 +612,438 @@ impl std::fmt::Display for SessionOpenError { impl std::error::Error for SessionOpenError {} -/// Open the session directory of `identity` (whether or not a previous -/// attempt created it) and reset it for a fresh build: the previous -/// attempt's content is wiped (except the lock file) and a new manifest is -/// written. Fails with [`SessionOpenError::Busy`] when a concurrent build -/// holds the session. -pub fn fresh_session( +/// The directory holding every record of `identity` under `root`: +/// `/`, with one `` subdirectory (and +/// its `.lock`) per build. +pub(crate) fn identity_dir(root: &Path, identity: &SessionIdentity) -> PathBuf { + root.join(slug_for(identity)) +} + +/// A record id not taken yet under `identity_dir`: the plain timestamp, +/// suffixed `-02`, `-03`, ... when earlier builds took the same second +/// (zero-padded so the ids keep sorting in creation order). +/// +/// Ids are allocated strictly beyond the highest one of the second — +/// freed slots are never reused: a re-created `stamp` while `stamp-02`, +/// ... of the same second still exist would sort before them and lie +/// about its creation order (the keep-last-5 pruning and the base +/// selection both order by id within one second). +fn unique_record_id(identity_dir: &Path) -> String { + let stamp = now_session_id(); + let suffix_prefix = format!("{stamp}-"); + // The plain stamp occupies slot 1 of the second. + let mut highest = if identity_dir.join(&stamp).exists() { + 1 + } else { + 0 + }; + if let Ok(entries) = fs::read_dir(identity_dir) { + for entry in entries.flatten() { + let Some(suffix) = entry + .file_name() + .to_string_lossy() + .strip_prefix(&suffix_prefix) + .and_then(|suffix| suffix.parse::().ok()) + else { + continue; + }; + highest = highest.max(suffix); + } + } + if highest == 0 { + stamp + } else { + format!("{stamp}-{:02}", highest + 1) + } +} + +/// Reserve a new record directory of `identity` under `root` and lock it: +/// the directory is created, its flock taken (so concurrent builds and +/// prune sweeps see it as live) and the manifest — built by `manifest` +/// from the reserved id — persisted. The loop absorbs the same-second +/// collisions with concurrent record creations. +fn reserve_record( + root: &Path, + identity: &SessionIdentity, + manifest: impl Fn(String) -> SessionManifest, +) -> Result { + let dir = identity_dir(root, identity); + fs::create_dir_all(&dir) + .map_err(|e| SessionOpenError::Other(format!("cannot create {}: {e}", dir.display())))?; + for _ in 0..16 { + let id = unique_record_id(&dir); + let record_dir = dir.join(&id); + if fs::create_dir(&record_dir).is_err() { + continue; // raced with a concurrent creation; take another id + } + let session = Session { + root: record_dir, + state: Arc::new(Mutex::new(SessionState { + manifest: manifest(id), + lock: None, + })), + }; + match session.acquire() { + Ok(()) => { + session.save().map_err(|e| { + SessionOpenError::Other(format!( + "cannot write the manifest of the new build record: {e}" + )) + })?; + return Ok(session); + } + // Someone stole this directory between its creation and the + // lock: impossible in practice (they cannot know the id + // before it exists), but move on rather than fail. + Err(_) => continue, + } + } + Err(SessionOpenError::Other( + "cannot reserve a fresh build record id: too many concurrent builds".to_string(), + )) +} + +/// A manifest for a record built from scratch: no base, no journal, no +/// chroot yet — the bootstrap fills the chroot state as it completes. +fn fresh_manifest( + id: String, + host_tree: &Path, + identity: &SessionIdentity, + tree_version: &str, +) -> SessionManifest { + SessionManifest { + schema: SCHEMA_VERSION, + pkh: env!("CARGO_PKG_VERSION").to_string(), + id, + host_tree: host_tree.to_string_lossy().into_owned(), + identity: identity.clone(), + created: now_rfc3339(), + last_used: now_rfc3339(), + last_outcome: OUTCOME_RUNNING.to_string(), + base: None, + chroot: ChrootInfo { + tarball: String::new(), + tarball_size: 0, + tarball_mtime_secs: 0, + tarball_sha256: String::new(), + ready: false, + }, + phases: BTreeMap::new(), + tree_version: tree_version.to_string(), + build_resume: false, + } +} + +/// Create the record a plain (non-incremental) `pkh deb` builds in: a +/// brand-new, empty record of `identity`, locked for this build. Whether +/// it is kept is the caller's keep policy (a plain run discards it). +pub fn new_record( host_tree: &Path, identity: &SessionIdentity, tree_version: &str, ) -> Result { - let root = sessions_root().join(slug_for(identity)); - fs::create_dir_all(&root) - .map_err(|e| SessionOpenError::Other(format!("cannot create {}: {e}", root.display())))?; + new_record_in(&sessions_root(), host_tree, identity, tree_version) +} - let session = match open_from_dir(root.clone()) { - Some(session) => session, - None => Session { - root: root.clone(), - state: Arc::new(Mutex::new(SessionState { - manifest: SessionManifest { - schema: SCHEMA_VERSION, - pkh: env!("CARGO_PKG_VERSION").to_string(), - id: now_session_id(), - host_tree: host_tree.to_string_lossy().into_owned(), - identity: identity.clone(), - created: now_rfc3339(), - last_used: now_rfc3339(), - last_outcome: OUTCOME_RUNNING.to_string(), - chroot: ChrootInfo { - tarball: String::new(), - tarball_size: 0, - tarball_mtime_secs: 0, - tarball_sha256: String::new(), - ready: false, - }, - phases: BTreeMap::new(), - tree_version: tree_version.to_string(), - build_resume: false, - }, - lock: None, - })), - }, - }; - - session.acquire().map_err(SessionOpenError::Busy)?; - - // Wipe the previous attempt (the lock file lives outside the root). A - // crashed build leaves its mounts behind and the kernel creates - // root-owned state inside overlay workdirs, so the clearing unmounts - // at depth and escalates via sudo when needed; see clear_dir. - crate::deb::ephemeral::clear_dir(&root).map_err(|e| { - SessionOpenError::Unusable(format!( - "cannot clear the previous build session {}: {e}. \ - Free it with `pkh prune` (or remove it manually) and rebuild", - root.display() - )) +/// The testable core of [`new_record`] over an explicit sessions root; +/// creating a record also prunes the identity's records beyond the newest +/// [`KEEP_RECORDS`]. +pub(crate) fn new_record_in( + root: &Path, + host_tree: &Path, + identity: &SessionIdentity, + tree_version: &str, +) -> Result { + let session = reserve_record(root, identity, |id| { + fresh_manifest(id, host_tree, identity, tree_version) })?; - - session.reset_fresh(host_tree, identity, tree_version); + prune_identity_records(root, identity); Ok(session) } -impl Session { - /// Replace the manifest with a fresh one for a new attempt (the - /// session lock must be held; used by [`fresh_session`]). - fn reset_fresh(&self, host_tree: &Path, identity: &SessionIdentity, tree_version: &str) { - self.with_manifest(|manifest| { - *manifest = SessionManifest { - schema: SCHEMA_VERSION, - pkh: env!("CARGO_PKG_VERSION").to_string(), - id: now_session_id(), - host_tree: host_tree.to_string_lossy().into_owned(), - identity: identity.clone(), - created: manifest.created.clone(), - last_used: now_rfc3339(), - last_outcome: OUTCOME_RUNNING.to_string(), - chroot: ChrootInfo { - tarball: String::new(), - tarball_size: 0, - tarball_mtime_secs: 0, - tarball_sha256: String::new(), - ready: false, - }, - phases: BTreeMap::new(), - tree_version: tree_version.to_string(), - build_resume: false, - }; - }); +/// Whether the base chroot holds device nodes (the `dev/null`/`dev/zero` +/// pair the bootstrap creates): copying them needs privileges, and a +/// plain copy of a chroot without them may still fail on root-only +/// files — the caller retries privileged in that case. +fn chroot_has_device_nodes(chroot: &Path) -> bool { + ["dev/null", "dev/zero"].iter().any(|rel| { + fs::symlink_metadata(chroot.join(rel)).is_ok_and(|meta| meta.file_type().is_char_device()) + }) +} + +/// `cp -a --reflink=auto` of `src` into `dst` (a full, metadata-preserving +/// copy; reflinks keep it cheap on CoW filesystems), privileged when +/// `escalate` is set, with a privileged retry when the plain copy fails +/// (root-only files a device probe cannot see). +fn copy_tree(src: &Path, dst: &Path, escalate: bool) -> io::Result<()> { + let is_root = crate::utils::root::is_root().unwrap_or(false); + // `src/.` into `dst`: an existing `dst` would otherwise receive a + // nested `dst/` (cp's copy-into semantics for existing + // destinations), and the fork's record root already has its chroot + // directory. Plain files copy as-is (their `/.` is invalid). + let (from, into) = if src.is_dir() { + fs::create_dir_all(dst)?; + (format!("{}/.", src.display()), dst.to_path_buf()) + } else { + (src.display().to_string(), dst.to_path_buf()) + }; + let run = |privileged: bool| { + crate::deb::ephemeral::privileged_command("cp", privileged || is_root) + .arg("-a") + .arg("--reflink=auto") + .arg(&from) + .arg(&into) + .status() + }; + let status = if escalate { + run(true) + } else { + match run(false) { + Ok(status) if status.success() => return Ok(()), + outcome => { + log::debug!( + "Unprivileged copy of {} failed ({:?}); retrying privileged", + src.display(), + outcome + ); + run(true) + } + } + }; + if matches!(&status, Ok(s) if s.success()) { + Ok(()) + } else { + Err(io::Error::other(format!( + "cp -a {} {} exited with {status:?}", + src.display(), + dst.display() + ))) } } +/// Fork `base` into a new record of `identity` under the sessions root: +/// the full chroot is copied (apt mutates it on every run, so it cannot +/// be shared between records), the tree-upper too when the changelog +/// version did not change, and the journal and chroot info are carried +/// over. The base itself is never modified. See [`fork_record_in`]. +pub fn fork_record( + base: &Session, + host_tree: &Path, + identity: &SessionIdentity, + version: &str, +) -> Result { + fork_record_in(&sessions_root(), base, host_tree, identity, version) +} + +/// The testable core of [`fork_record`] over an explicit sessions root. +/// +/// The copy happens while holding the base's flock: a concurrent build +/// may still be running in it (its chroot is mid-mutation), and a record +/// interrupted hard (SIGKILL) may carry leftover mounts — those are +/// unmounted first, like the crashed-session recovery of old. +pub(crate) fn fork_record_in( + root: &Path, + base: &Session, + host_tree: &Path, + identity: &SessionIdentity, + version: &str, +) -> Result { + let base_manifest = base.manifest(); + if base_manifest.identity != *identity { + return Err(SessionOpenError::Other(format!( + "cannot fork record {}: it was recorded for {}, but this build targets {}", + base_manifest.id, base_manifest.identity, identity + ))); + } + base.acquire().map_err(SessionOpenError::Busy)?; + fork_locked_record(root, base, &base_manifest, host_tree, identity, version) +} + +/// Fork with the base lock held; every failure path is reported through +/// [`SessionOpenError::Unusable`] so the caller can point at `pkh prune`. +fn fork_locked_record( + root: &Path, + base: &Session, + base_manifest: &SessionManifest, + host_tree: &Path, + identity: &SessionIdentity, + version: &str, +) -> Result { + // A hard-killed base may still carry its /proc bind mount and a + // mounted overlay (the staged tree): unmount them before copying, or + // the copy would traverse the mounted filesystems instead of the + // record's own directories. + unmount_under(&base.chroot_path()); + + let reuse_upper = base_manifest.tree_version == version; + let escalate = chroot_has_device_nodes(&base.chroot_path()); + let record = reserve_record(root, identity, |id| SessionManifest { + schema: SCHEMA_VERSION, + pkh: env!("CARGO_PKG_VERSION").to_string(), + id, + host_tree: host_tree.to_string_lossy().into_owned(), + identity: identity.clone(), + created: now_rfc3339(), + last_used: now_rfc3339(), + last_outcome: OUTCOME_RUNNING.to_string(), + base: Some(base_manifest.id.clone()), + chroot: base_manifest.chroot.clone(), + phases: base_manifest.phases.clone(), + tree_version: version.to_string(), + build_resume: false, + })?; + + let fork_result = copy_fork_content(base, &record, reuse_upper, escalate); + if let Err(e) = fork_result { + // The partial fork is inert (its manifest is written but the + // chroot is incomplete): remove it rather than leave a trap for + // the next selection. + let _ = remove_session_dir(record.root()); + return Err(SessionOpenError::Unusable(format!( + "cannot copy record {} into {}: {e}. Free the space or \ + remove old records with `pkh prune`, then rebuild", + base.root().display(), + record.root().display() + ))); + } + if !reuse_upper { + log::info!( + "Changelog version changed since record {} ({} -> {}): the \ + environment is forked, the build artifacts are rebuilt from scratch", + base_manifest.id, + base_manifest.tree_version, + version + ); + } + log::info!( + "Forked build record {} from record {}: the prepared chroot, \ + installed build dependencies{} are reused", + record.manifest().id, + base_manifest.id, + if reuse_upper { + " and the staged build artifacts" + } else { + "" + } + ); + prune_identity_records(root, identity); + Ok(record) +} + +/// Copy the forked content of `base` into `record`: the whole chroot +/// minus the per-tree staging state (`pkh-overlay`, `tmp/pkh-build`) +/// when the version changed and thus the upper must not be reused, plus +/// the host-tree snapshot (only meaningful with the upper it describes). +fn copy_fork_content( + base: &Session, + record: &Session, + reuse_upper: bool, + escalate: bool, +) -> io::Result<()> { + let src_chroot = base.chroot_path(); + let dst_chroot = record.chroot_path(); + fs::create_dir_all(&dst_chroot)?; + if reuse_upper { + copy_tree(&src_chroot, &dst_chroot, escalate)?; + } else { + // Copy the chroot entry by entry, skipping the overlay state and + // the staged tree of the previous version (a stale upper would + // shadow the new version's sources, and its objects are garbage). + for entry in fs::read_dir(&src_chroot)? { + let entry = entry?; + let name = entry.file_name(); + if name == "pkh-overlay" { + continue; + } + if name == "tmp" { + let src_tmp = entry.path(); + let dst_tmp = dst_chroot.join("tmp"); + fs::create_dir_all(&dst_tmp)?; + for tmp_entry in fs::read_dir(&src_tmp)? { + let tmp_entry = tmp_entry?; + if tmp_entry.file_name() == "pkh-build" { + continue; + } + copy_tree( + &tmp_entry.path(), + &dst_tmp.join(tmp_entry.file_name()), + escalate, + )?; + } + continue; + } + copy_tree(&entry.path(), &dst_chroot.join(&name), escalate)?; + } + } + if reuse_upper && base.snapshot_file().is_file() { + fs::copy(base.snapshot_file(), record.snapshot_file())?; + } + Ok(()) +} + +/// Unmount every mount at or under `path` (deepest first), best-effort: +/// used before forking a possibly hard-killed base and when removing a +/// record root. +pub(crate) fn unmount_under(path: &Path) { + let is_root = crate::utils::root::is_root().unwrap_or(false); + for mount in crate::deb::ephemeral::host_mounts_under(path) + .into_iter() + .rev() + { + if !crate::deb::ephemeral::unmount_path(&mount, is_root) { + log::warn!( + "Failed to unmount {} under {}", + mount.display(), + path.display() + ); + } + } +} + +/// Prune the records of `identity` beyond the newest [`KEEP_RECORDS`] +/// (oldest first). Records whose lock is held by a live build are never +/// removed; directories without a readable manifest are left to +/// `pkh prune` (they may be a record mid-creation). +pub(crate) fn prune_identity_records(root: &Path, identity: &SessionIdentity) { + let mut records: Vec = Vec::new(); + let Ok(entries) = fs::read_dir(identity_dir(root, identity)) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() + && let Some(record) = open_from_dir(path) + { + records.push(record); + } + } + records.sort_by(|a, b| record_ordering(a, b).reverse()); + for record in records.into_iter().skip(KEEP_RECORDS) { + let held = lock_is_held(&record.lock_path()).unwrap_or(true); + if held { + log::debug!( + "Record {} is locked by a live build; skipping the pruning", + record.manifest().id + ); + continue; + } + log::debug!( + "Pruning record {} beyond the newest {KEEP_RECORDS} of {}", + record.manifest().id, + identity + ); + record.remove(); + } +} + +/// Newness ordering of two records: by `created`, then id (both are +/// build-start timestamps of the same format). +fn record_ordering(a: &Session, b: &Session) -> std::cmp::Ordering { + let a = a.manifest(); + let b = b.manifest(); + a.created.cmp(&b.created).then(a.id.cmp(&b.id)) +} + /// Whether the lock file at `path` is currently held by a live build /// (flock taken). `Ok(false)` also covers a missing file. pub(crate) fn lock_is_held(path: &Path) -> io::Result { @@ -678,14 +1068,13 @@ pub(crate) fn lock_is_held(path: &Path) -> io::Result { Err(err) } -/// Whether the cached chroot tarball the session was bootstrapped from is -/// still the one the current bootstrap would use (name, then size/mtime, -/// falling back to the recorded SHA-256 when the cheap check drifts). -pub(crate) fn tarball_matches(session: &Session, series: &str, arch: Option<&str>) -> bool { - let Ok(expected) = crate::deb::ephemeral::chroot_tarball_path(series, arch) else { - return false; - }; - let Ok(metadata) = fs::metadata(&expected) else { +/// Whether the cached chroot tarball the record was bootstrapped from is +/// still the one the current bootstrap would use: the file name recorded +/// in the manifest, then the cheap size/mtime check, falling back to the +/// recorded SHA-256 when the cheap check drifts (the tarball was +/// re-created in the same second, ...). +pub(crate) fn tarball_matches_path(session: &Session, expected: &Path) -> bool { + let Ok(metadata) = fs::metadata(expected) else { return false; }; let info = &session.manifest().chroot; @@ -706,61 +1095,107 @@ pub(crate) fn tarball_matches(session: &Session, series: &str, arch: Option<&str if info.tarball_size == metadata.len() && info.tarball_mtime_secs == mtime_secs { return true; } - sha256_file(&expected).is_ok_and(|hash| hash == info.tarball_sha256) + sha256_file(expected).is_ok_and(|hash| hash == info.tarball_sha256) } -/// Load every discoverable session, newest first. -pub fn load_all() -> Vec { - let Ok(entries) = fs::read_dir(sessions_root()) else { +/// Load every discoverable record under `root` (any identity), newest +/// first. `root` is the sessions root: `///`. +pub(crate) fn load_all_in(root: &Path) -> Vec { + let mut records = Vec::new(); + let Ok(identities) = fs::read_dir(root) else { + return records; + }; + for identity_dir in identities.flatten().map(|entry| entry.path()) { + if !identity_dir.is_dir() { + continue; + } + let Ok(record_dirs) = fs::read_dir(&identity_dir) else { + continue; + }; + for record_dir in record_dirs.flatten().map(|entry| entry.path()) { + if record_dir.is_dir() + && let Some(record) = open_from_dir(record_dir) + { + records.push(record); + } + } + } + records.sort_by(|a, b| record_ordering(a, b).reverse()); + records +} + +/// The records of `identity` under `root`, newest first (the +/// auto-selection candidates of `--incremental`). +pub(crate) fn identity_records_in(root: &Path, identity: &SessionIdentity) -> Vec { + let dir = identity_dir(root, identity); + let Ok(entries) = fs::read_dir(&dir) else { return Vec::new(); }; - let mut sessions: Vec = entries + let mut records: Vec = entries .flatten() .map(|entry| entry.path()) .filter(|path| path.is_dir()) .filter_map(open_from_dir) + .filter(|record| record.manifest().identity == *identity) .collect(); - sessions.sort_by(|a, b| { - let a = a.manifest(); - let b = b.manifest(); - b.last_used.cmp(&a.last_used) - }); - sessions + records.sort_by(|a, b| record_ordering(a, b).reverse()); + records } -/// Sessions recorded from `tree` (any identity), newest first. +/// Records recorded from `tree` (any identity), newest first. pub fn for_tree(tree: &Path) -> Vec { let tree = tree.to_string_lossy().into_owned(); - load_all() + load_all_in(&sessions_root()) .into_iter() .filter(|s| s.manifest().host_tree == tree) .collect() } -/// Sessions whose id starts with `prefix` (the `pkh deb --resume ` +/// Records whose id starts with `prefix` (the `pkh deb --incremental ` /// lookup). Ambiguous prefixes yield several results; the caller rejects /// them. -pub fn by_id_prefix(prefix: &str) -> Vec { - load_all() +pub(crate) fn by_id_prefix_in(root: &Path, prefix: &str) -> Vec { + load_all_in(root) .into_iter() .filter(|s| s.manifest().id.starts_with(prefix)) .collect() } +/// Order the `--incremental` base candidates, best first: the usable +/// records are given newest-first, and the successful ones come before +/// the rest (each group keeps its newest-first order). The caller tries +/// them in order until one accepts the fork, so a busy or unworkable +/// favorite falls through to the next-best base. +pub(crate) fn rank_base_candidates(candidates: Vec) -> Vec { + let mut ranked: Vec = candidates + .iter() + .filter(|s| s.manifest().last_outcome == OUTCOME_SUCCESS) + .cloned() + .collect(); + ranked.extend( + candidates + .into_iter() + .filter(|s| s.manifest().last_outcome != OUTCOME_SUCCESS), + ); + ranked +} + /// One row of the `pkh deb list` table. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionListRow { - /// Session id (build-start timestamp of the latest attempt). + /// Record id (build-start timestamp). pub id: String, /// Source package name. pub package: String, - /// Changelog version of the latest attempt. + /// Changelog version of this record's build. pub version: String, /// `series/arch` target, with the cross marker folded into the arch. pub target: String, - /// Outcome of the latest attempt. + /// Id of the record this one was forked from, when any. + pub base: Option, + /// Outcome of this record's build. pub outcome: String, - /// Human-readable age of the latest attempt. + /// Human-readable age of this record's build. pub age: String, } @@ -779,6 +1214,7 @@ pub fn list_for_tree(tree: &Path) -> Vec { "{}/{}{}", manifest.identity.series, manifest.identity.arch, cross ), + base: manifest.base, outcome: manifest.last_outcome, age: age_of(&manifest.last_used), } @@ -812,13 +1248,15 @@ pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String { let mut out = String::new(); if rows.is_empty() { out.push_str(&format!( - "No build sessions recorded from {}.\n", + "No build records recorded from {}.\n", tree.display() )); return out; } - out.push_str(&format!("Sessions for {}:\n", tree.display())); - let headers = ["ID", "PACKAGE", "VERSION", "TARGET", "LAST RUN", "AGE"]; + out.push_str(&format!("Records for {}:\n", tree.display())); + let headers = [ + "ID", "PACKAGE", "VERSION", "TARGET", "BASE", "OUTCOME", "AGE", + ]; let table: Vec> = rows .iter() .map(|row| { @@ -827,6 +1265,7 @@ pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String { row.package.clone(), row.version.clone(), row.target.clone(), + row.base.clone().unwrap_or_else(|| "-".to_string()), row.outcome.clone(), row.age.clone(), ] @@ -862,7 +1301,7 @@ pub fn render_session_list(tree: &Path, rows: &[SessionListRow]) -> String { out.push_str(&line(row)); out.push('\n'); } - out.push_str("\nResume with: pkh deb --resume [] (* = cross build)\n"); + out.push_str("\nFork with: pkh deb --incremental [] (* = cross build)\n"); out } @@ -1136,14 +1575,15 @@ pub(crate) fn apply_upper_sync(upper_root: &Path, ops: &[UpperOp]) -> io::Result Ok(removed) } -/// Sync the persistent upperdir with the current host tree before a resume -/// re-mount. Returns whether build-level resume is possible (a usable -/// snapshot existed and the upper was synced): without a snapshot the -/// upper is wiped and the next build stages cold, but safely. +/// Sync the persistent upperdir with the current host tree before the +/// forked record re-mounts it. Returns whether build-level resume is +/// possible (a usable snapshot existed and the upper was synced): +/// without a snapshot the upper is wiped and the build stages cold, +/// but safely. pub(crate) fn sync_upper_for_resume(session: &Session, host_parent: &Path) -> io::Result { let Some(snapshot) = load_snapshot(&session.snapshot_file()) else { log::info!( - "No host-tree snapshot in the session: build artifacts are \ + "No host-tree snapshot in the record: build artifacts are \ discarded, the environment is still reused" ); session.wipe_build_artifacts(); @@ -1155,37 +1595,98 @@ pub(crate) fn sync_upper_for_resume(session: &Session, host_parent: &Path) -> io let ops = plan_upper_sync(&snapshot, ¤t, &upper_entries); let removed = apply_upper_sync(&session.upper_dir(), &ops)?; if removed > 0 { - log::debug!("Synced the session tree: {removed} stale upper entries dropped"); + log::debug!("Synced the record tree: {removed} stale upper entries dropped"); } write_snapshot(¤t, &session.snapshot_file())?; Ok(true) } +/// Record fixtures shared by the `deb` test modules (this one and +/// `crate::deb`): a synthetic sessions root plus records carrying a +/// complete, usable environment. +#[cfg(test)] +pub(crate) mod record_fixtures { + use super::*; + + pub fn test_identity() -> SessionIdentity { + SessionIdentity { + package: "pkh".to_string(), + series: "noble".to_string(), + arch: "amd64".to_string(), + cross: false, + } + } + + /// A synthetic sessions root and the chroot tarball its records were + /// bootstrapped from. + pub fn sessions_fixture() -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let tarball = root.path().join("noble-amd64-buildd.tar.xz"); + fs::write(&tarball, b"tarball-bytes").unwrap(); + (root, tarball) + } + + /// A record with a complete, usable environment: bootstrap marker, + /// `/bin/sh`, one apt-state file, the staged-tree state of a previous + /// build (upperdir + snapshot + staged mountpoint) and a chroot + /// bootstrapped from `tarball`. The returned handle holds the record + /// lock; drop it before forking or selecting from another handle. + pub fn record_with_environment( + root: &Path, + tarball: &Path, + host_tree: &Path, + identity: &SessionIdentity, + version: &str, + outcome: Option<&str>, + ) -> Session { + let session = new_record_in(root, host_tree, identity, version).unwrap(); + let chroot = session.chroot_path(); + fs::create_dir_all(chroot.join("bin")).unwrap(); + fs::create_dir_all(chroot.join("var/lib/dpkg")).unwrap(); + fs::write(chroot.join(".pkh-ready"), b"").unwrap(); + fs::write(chroot.join("bin/sh"), b"#!/bin/sh").unwrap(); + fs::write(chroot.join("var/lib/dpkg/status"), b"installed").unwrap(); + fs::create_dir_all(session.upper_dir().join("src")).unwrap(); + fs::write(session.upper_dir().join("src/main.o"), b"objects").unwrap(); + fs::create_dir_all(chroot.join("tmp/pkh-build/tree")).unwrap(); + fs::write(session.snapshot_file(), "f\t1\t10\ttree/src/a.c\n").unwrap(); + let metadata = fs::metadata(tarball).unwrap(); + session.set_chroot(ChrootInfo { + tarball: tarball.file_name().unwrap().to_string_lossy().into_owned(), + tarball_size: metadata.len(), + tarball_mtime_secs: metadata + .modified() + .unwrap() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + tarball_sha256: sha256_file(tarball).unwrap(), + ready: true, + }); + session.record_phase("apt_update", None); + if let Some(outcome) = outcome { + session.set_outcome(outcome); + } + session + } +} + // --------------------------------------------------------------------------- // Removal (shared by `pkh deb` teardown and `pkh prune`) // --------------------------------------------------------------------------- -/// Remove a session root: unmount every mount under it (the /proc bind +/// Remove a record root: unmount every mount under it (the /proc bind /// mount, overlay filesystems), then `rm -rf` it (privileged when needed: /// the chroot holds root-owned device nodes). pub(crate) fn remove_session_dir(root: &Path) -> io::Result<()> { - let is_root = crate::utils::root::is_root().unwrap_or(false); - let mounts = crate::deb::ephemeral::host_mounts_under(root); - for mount in mounts.into_iter().rev() { - if !crate::deb::ephemeral::unmount_path(&mount, is_root) { - log::warn!( - "Failed to unmount {} under {}", - mount.display(), - root.display() - ); - } - } + unmount_under(root); crate::deb::ephemeral::privileged_remove(root) } #[cfg(test)] mod tests { use super::*; + use record_fixtures::{record_with_environment, sessions_fixture, test_identity}; fn meta(mtime_ns: i128, size: u64) -> EntryMeta { EntryMeta { mtime_ns, size } @@ -1374,7 +1875,9 @@ mod tests { } /// Identity slugs are sanitized and collision-free, and the manifest - /// round-trips through JSON. + /// round-trips through JSON — the optional base lineage included, + /// and its absence (records from before fork lineage existed) parses + /// as `None`. #[test] fn slug_and_manifest_round_trip() { let identity = SessionIdentity { @@ -1399,6 +1902,7 @@ mod tests { created: now_rfc3339(), last_used: now_rfc3339(), last_outcome: OUTCOME_FAILED.to_string(), + base: Some("20260926T140000".to_string()), chroot: ChrootInfo { tarball: "stonking-arm64-buildd.tar.xz".to_string(), tarball_size: 1, @@ -1421,6 +1925,13 @@ mod tests { let loaded = open_from_dir(base.path().to_path_buf()).unwrap(); assert_eq!(loaded.manifest(), manifest); + // A manifest without the lineage field (the pre-fork format) + // still parses, with no base. + let mut value = serde_json::to_value(&manifest).unwrap(); + value.as_object_mut().unwrap().remove("base"); + let no_base: SessionManifest = serde_json::from_value(value).unwrap(); + assert_eq!(no_base.base, None); + // A foreign schema is not discoverable. let mut foreign = manifest.clone(); foreign.schema = SCHEMA_VERSION + 1; @@ -1428,7 +1939,8 @@ mod tests { assert!(open_from_dir(base.path().to_path_buf()).is_none()); } - /// `pkh deb list` renders a table; the empty case explains itself. + /// `pkh deb list` renders a table with the fork lineage; the empty + /// case explains itself. #[test] fn session_list_rendering() { let rows = vec![ @@ -1437,6 +1949,7 @@ mod tests { package: "linux".to_string(), version: "7.3.0-5.6~local2".to_string(), target: "stonking/arm64*".to_string(), + base: Some("20260926T140000".to_string()), outcome: OUTCOME_FAILED.to_string(), age: "2h".to_string(), }, @@ -1445,24 +1958,362 @@ mod tests { package: "linux".to_string(), version: "7.3.0-5.5~local1".to_string(), target: "stonking/riscv64*".to_string(), + base: None, outcome: OUTCOME_SUCCESS.to_string(), age: "1d".to_string(), }, ]; let rendered = render_session_list(Path::new("/home/me/linux"), &rows); assert!( - rendered.contains("Sessions for /home/me/linux:"), + rendered.contains("Records for /home/me/linux:"), "{rendered}" ); assert!(rendered.contains("20260926T143505"), "{rendered}"); assert!(rendered.contains("stonking/arm64*"), "{rendered}"); + // The lineage column shows the forked base, a dash for roots. + assert!(rendered.contains("20260926T140000"), "{rendered}"); assert!( - rendered.contains("Resume with: pkh deb --resume []"), + rendered.contains("Fork with: pkh deb --incremental []"), "{rendered}" ); let empty = render_session_list(Path::new("/home/me/linux"), &[]); - assert!(empty.contains("No build sessions recorded"), "{empty}"); + assert!(empty.contains("No build records recorded"), "{empty}"); + } + + // ------------------------------------------------------------------- + // Record layout, locking, forking, selection and pruning + // ------------------------------------------------------------------- + + /// A new record lives at `//` with its manifest on disk and + /// its lock beside the root; a concurrent handle sees it busy, and + /// same-second creations get distinct ids. + #[test] + fn record_layout_and_locking() { + let (root, _tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + + let record = new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap(); + let slug_dir = identity_dir(root.path(), &identity); + let record_dir = record.root(); + assert_eq!(record_dir.parent(), Some(slug_dir.as_path())); + assert!(record_dir.join("session.json").is_file()); + assert_eq!( + record.lock_path(), + slug_dir.join(format!("{}.lock", record.manifest().id)) + ); + let manifest = record.manifest(); + assert_eq!(manifest.last_outcome, OUTCOME_RUNNING); + assert_eq!(manifest.tree_version, "1.0"); + assert_eq!(manifest.base, None); + assert!(!manifest.chroot.ready); + + // The handle holds the flock: another handle sees the record busy. + assert!(lock_is_held(&record.lock_path()).unwrap()); + let other = open_from_dir(record_dir.to_path_buf()).unwrap(); + assert!(matches!(other.acquire(), Err(SessionBusy { .. }))); + drop(record); + assert!(!lock_is_held(&other.lock_path()).unwrap()); + + // Records created within the same second still get distinct ids + // (the suffix disambiguates), and discovery finds them newest + // first. + drop(new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap()); + let ids: Vec = identity_records_in(root.path(), &identity) + .into_iter() + .map(|r| r.manifest().id) + .collect(); + assert_eq!(ids.len(), 2); + assert_ne!(ids[0], ids[1]); + } + + /// The integrity probe requires the recorded bootstrap completion AND + /// the on-disk markers. + #[test] + fn chroot_usable_probe() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let bare = new_record_in(root.path(), &host_tree, &identity, "1.0").unwrap(); + assert!(!bare.chroot_is_usable()); // nothing bootstrapped yet + drop(bare); + let complete = + record_with_environment(root.path(), &tarball, &host_tree, &identity, "1.0", None); + assert!(complete.chroot_is_usable()); + } + + /// A record is a usable base only while its bootstrap tarball is + /// still the recorded one: the name must match, then the cheap + /// size/mtime check, with the recorded SHA as the drift fallback. + #[test] + fn tarball_match_checks() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let record = + record_with_environment(root.path(), &tarball, &host_tree, &identity, "1.0", None); + assert!(tarball_matches_path(&record, &tarball), "recorded tarball"); + + // Same content, drifted mtime: the cheap size/mtime check fails, + // the recorded SHA-256 still certifies the tarball. + bump_mtime(&tarball, std::time::Duration::from_secs(7200)); + assert!( + tarball_matches_path(&record, &tarball), + "the recorded sha certifies a re-touched tarball" + ); + + // A re-downloaded tarball (different bytes, drifted metadata): + // no match. + fs::write(&tarball, b"re-downloaded tarball").unwrap(); + bump_mtime(&tarball, std::time::Duration::from_secs(3600)); + assert!( + !tarball_matches_path(&record, &tarball), + "different content must not match" + ); + + // A tarball under another name: never a match. + let renamed = root.path().join("other-name.tar.xz"); + fs::rename(&tarball, &renamed).unwrap(); + assert!( + !tarball_matches_path(&record, &renamed), + "a renamed tarball bootstraps a different chroot" + ); + + // Missing file: never a match. + assert!( + !tarball_matches_path(&record, &tarball), + "a missing tarball cannot certify a chroot" + ); + } + + /// Move a file's mtime `by` into the future. + fn bump_mtime(path: &Path, by: std::time::Duration) { + let file = fs::OpenOptions::new().write(true).open(path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(std::time::SystemTime::now() + by)) + .unwrap(); + } + + /// Forking copies the whole base into a NEW record — chroot + /// environment, staged artifacts, snapshot, journal and chroot info — + /// records the lineage, and never touches the base: writes into the + /// fork stay in the fork. + #[test] + fn fork_copies_the_base_without_touching_it() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let base = + record_with_environment(root.path(), &tarball, &host_tree, &identity, "1.0", None); + let base_id = base.manifest().id; + let base_upper = base.upper_dir().join("src/main.o"); + let base_dpkg = base.chroot_path().join("var/lib/dpkg/status"); + + let fork = fork_record_in(root.path(), &base, &host_tree, &identity, "1.0").unwrap(); + let fork_manifest = fork.manifest(); + assert_ne!(fork_manifest.id, base_id); + assert_eq!(fork_manifest.base.as_deref(), Some(base_id.as_str())); + assert_eq!(fork_manifest.tree_version, "1.0"); + assert_eq!( + fork_manifest.chroot.tarball_sha256, + base.manifest().chroot.tarball_sha256 + ); + assert!(fork_manifest.phases.contains_key("apt_update")); + assert_eq!( + fs::read_to_string(fork.chroot_path().join("var/lib/dpkg/status")).unwrap(), + "installed" + ); + assert_eq!( + fs::read_to_string(fork.upper_dir().join("src/main.o")).unwrap(), + "objects" + ); + assert!(fork.snapshot_file().is_file()); + assert!(fork.chroot_is_usable()); + + // Mutating the fork leaves the base byte-identical. + fs::write(fork.upper_dir().join("src/main.o"), b"changed").unwrap(); + fs::write(fork.chroot_path().join("var/lib/dpkg/status"), b"mutated").unwrap(); + assert_eq!(fs::read_to_string(&base_upper).unwrap(), "objects"); + assert_eq!(fs::read_to_string(&base_dpkg).unwrap(), "installed"); + } + + /// A version change at the fork drops the tree state — upperdir, + /// staged tree, host-tree snapshot — while the environment (chroot, + /// journal, chroot info) is carried and the new version is recorded. + #[test] + fn fork_on_version_change_rebuilds_the_tree_state() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let base = record_with_environment( + root.path(), + &tarball, + &host_tree, + &identity, + "1.0", + Some(OUTCOME_SUCCESS), + ); + + let fork = fork_record_in(root.path(), &base, &host_tree, &identity, "1.1").unwrap(); + assert_eq!(fork.manifest().tree_version, "1.1"); + assert!(fork.chroot_path().join("var/lib/dpkg/status").is_file()); + assert!(fork.chroot_path().join(".pkh-ready").is_file()); + assert!(fork.chroot_is_usable()); + assert!(fork_manifest_has_phase(&fork, "apt_update")); + // The stale objects of version 1.0 must not survive into 1.1. + assert!(!fork.upper_dir().join("src/main.o").exists()); + assert!(!fork.chroot_path().join("tmp/pkh-build/tree").exists()); + assert!(!fork.snapshot_file().exists()); + } + + fn fork_manifest_has_phase(record: &Session, phase: &str) -> bool { + record.manifest().phases.contains_key(phase) + } + + /// The user scenario: a successful record A, a failed fork B of it — + /// the default selection must pick A (the newest success), not B + /// (the newest record); with no success at all it takes the newest. + #[test] + fn selection_prefers_the_newest_success_over_the_newest_failure() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let a = record_with_environment( + root.path(), + &tarball, + &host_tree, + &identity, + "1.0", + Some(OUTCOME_SUCCESS), + ); + let a_id = a.manifest().id; + let b = fork_record_in(root.path(), &a, &host_tree, &identity, "1.0").unwrap(); + let b_id = b.manifest().id; + b.set_outcome(OUTCOME_FAILED); + drop((a, b)); + + let candidates = identity_records_in(root.path(), &identity); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].manifest().id, b_id, "newest first"); + let ranked = rank_base_candidates(candidates); + assert_eq!(ranked[0].manifest().id, a_id, "the success wins"); + assert_eq!(ranked[1].manifest().id, b_id); + + // No success anywhere: the newest record is the fallback. + ranked[0].set_outcome(OUTCOME_FAILED); + drop(ranked); + let ranked = rank_base_candidates(identity_records_in(root.path(), &identity)); + assert_eq!(ranked[0].manifest().id, b_id); + } + + /// Records are scoped per identity: another package or target never + /// shows up in the candidates. + #[test] + fn identity_records_are_scoped_to_the_identity() { + 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"); + drop(record_with_environment( + root.path(), + &tarball, + &host_tree, + &identity, + "1.0", + None, + )); + drop(record_with_environment( + root.path(), + &tarball, + &host_tree, + &other, + "1.0", + None, + )); + assert_eq!(identity_records_in(root.path(), &identity).len(), 1); + assert_eq!(identity_records_in(root.path(), &other).len(), 1); + assert_eq!(load_all_in(root.path()).len(), 2); + } + + /// Creating a record prunes the identity's records beyond the newest + /// five, oldest first. + #[test] + fn creating_records_keeps_only_the_last_five() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + let mut first_id = None; + let mut last_id = None; + for i in 0..7 { + let record = record_with_environment( + root.path(), + &tarball, + &host_tree, + &identity, + &format!("1.{i}"), + None, + ); + let id = record.manifest().id; + drop(record); + first_id.get_or_insert(id.clone()); + last_id = Some(id); + // Older records vanish as soon as the count passes five. + let kept: Vec = identity_records_in(root.path(), &identity) + .into_iter() + .map(|r| r.manifest().id) + .collect(); + assert!(kept.len() <= KEEP_RECORDS, "left {kept:?} behind"); + if i >= KEEP_RECORDS { + assert_eq!(kept.len(), KEEP_RECORDS); + } + } + let kept: Vec = identity_records_in(root.path(), &identity) + .into_iter() + .map(|r| r.manifest().id) + .collect(); + assert_eq!(kept.len(), KEEP_RECORDS); + // The five kept are the newest five: the first record is gone, + // the last one stays. + assert!(!kept.contains(first_id.as_ref().unwrap())); + assert!(kept.contains(last_id.as_ref().unwrap())); + } + + /// The end-of-build disposition follows keep-iff-incremental: a + /// plain run's record is always discarded (success and failure), an + /// incremental run's record always kept, with the outcome recorded on + /// success. + #[test] + fn dispose_keeps_iff_incremental() { + let (root, tarball) = sessions_fixture(); + let identity = test_identity(); + let host_tree = root.path().join("tree"); + + let plain_success = + record_with_environment(root.path(), &tarball, &host_tree, &identity, "1.0", None); + plain_success.dispose_after_build(false, true); + assert!(!plain_success.root().exists(), "plain success discards"); + + let plain_failure = + record_with_environment(root.path(), &tarball, &host_tree, &identity, "1.0", None); + plain_failure.dispose_after_build(false, false); + assert!(!plain_failure.root().exists(), "plain failure discards"); + + let incremental_success = + record_with_environment(root.path(), &tarball, &host_tree, &identity, "1.0", None); + incremental_success.dispose_after_build(true, true); + assert!(incremental_success.root().exists(), "incremental keeps"); + assert_eq!(incremental_success.manifest().last_outcome, OUTCOME_SUCCESS); + drop(incremental_success); + + let incremental_failure = + record_with_environment(root.path(), &tarball, &host_tree, &identity, "1.0", None); + incremental_failure.set_outcome(OUTCOME_FAILED); + incremental_failure.dispose_after_build(true, false); + assert!(incremental_failure.root().exists(), "incremental keeps"); + assert_eq!(incremental_failure.manifest().last_outcome, OUTCOME_FAILED); } /// Ages render in the coarse units the list promises. diff --git a/src/main.rs b/src/main.rs index b7e94c0..f5e4c0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -365,25 +365,21 @@ fn main() { .long_help("Change build mode [local]\nDefault will chose depending on other parameters, don't provide if unsure")) .arg(arg!(-j --jobs "Number of parallel build jobs (default: number of CPUs available in the build context)").required(false)) .arg( - clap::Arg::new("resume") - .long("resume") + clap::Arg::new("incremental") + .long("incremental") .value_name("ID") .num_args(0..=1) .require_equals(false) .default_missing_value("") - .help("Resume a previous build session: reuse the chroot, build dependencies and build artifacts") - .long_help("Resume a previous build session of this tree: the prepared chroot and installed build dependencies are reused, and the build continues in the staged tree (make recompiles only what changed).\nWithout a value, the newest session of this tree is adopted; with an ID (as shown by 'pkh deb list'), that exact session.\nA plain build never reuses a session: everything is rechecked from scratch."), + .help("Incremental build: fork a previous build record of this target and keep the result") + .long_help("Incremental build: fork a previous build record of this target — its prepared chroot and installed build dependencies are copied into a new record, and at the same changelog version its build artifacts too (make recompiles only what changed).\nWithout a value, the newest successful record of the target (among the last five usable) is the base; with an ID (as shown by 'pkh deb list'), that exact record — a failed one is fine if its environment is.\nEvery --incremental run keeps its record, success or failure, so the next one can fork it; a plain 'pkh deb' is fully ephemeral (nothing is kept, even on failure or interrupt)."), ) - .arg(arg!(--keep "Keep the build session after a successful build (for a later --resume)") - .long_help("Keep the build session after a successful build, so a later 'pkh deb --resume' can iterate on it (e.g. after editing sources).\nWithout it, a successful build consumes the session (failures and interrupts always keep it).")) - .arg(arg!(--incremental "Reuse a previous build session of this target (like --resume) and keep the session even on success; the next --incremental build reuses the chroot, build deps and objects") - .required(false)) .arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false) .long_help("Show raw tool output instead of the live build view.\nAlso implied by RUST_LOG=debug for pkh's own logs.")) .subcommand( Command::new("list") - .about("List the resumable build sessions recorded from this tree") - .long_about("List the resumable build sessions recorded from this tree, one per target (series/arch), with the id to pass to 'pkh deb --resume '.\nRead-only: never builds or removes anything ('pkh prune' removes old sessions)."), + .about("List the build records recorded from this tree") + .long_about("List the build records recorded from this tree, with their outcome, version, fork lineage and the id to pass to 'pkh deb --incremental '.\nRead-only: never builds or removes anything (only the last five records per target are kept, 'pkh prune' removes the ones older than a week)."), ) ) .subcommand( @@ -891,9 +887,8 @@ fn main() { Some(("deb", sub_matches)) => { let cwd = current_dir_or_exit(); - // `pkh deb list`: read-only listing of the resumable build - // sessions recorded from this tree; never touches the build - // machinery. + // `pkh deb list`: read-only listing of the build records + // recorded from this tree; never touches the build machinery. if let Some(("list", _)) = sub_matches.subcommand() { let tree = cwd.canonicalize().unwrap_or(cwd.clone()); let rows = pkh::deb::session::list_for_tree(&tree); @@ -939,18 +934,18 @@ fn main() { }) }); - // Session handling: --resume [], --incremental and --keep - // (see the deb subcommand help). --incremental adopts the newest - // session when no explicit --resume ID is given, and always adds - // keep so the session survives success. - let incremental = sub_matches.get_flag("incremental"); - let resume = match sub_matches.get_one::("resume").map(String::as_str) { - Some("") => pkh::deb::SessionResume::Latest, - Some(id) => pkh::deb::SessionResume::Id(id.to_string()), - None if incremental => pkh::deb::SessionResume::Latest, - None => pkh::deb::SessionResume::Fresh, + // Incremental request: `--incremental` (no value) forks the + // auto-selected base, `--incremental ` that exact record; + // without the flag the run is fully ephemeral (see the deb + // subcommand help). + let incremental = match sub_matches + .get_one::("incremental") + .map(String::as_str) + { + Some("") => pkh::deb::Incremental::Auto, + Some(id) => pkh::deb::Incremental::Id(id.to_string()), + None => pkh::deb::Incremental::Off, }; - let keep_session = sub_matches.get_flag("keep") || incremental; // Live build view, unless --verbose (DebUi additionally disables // itself when stdout is not a terminal) @@ -976,8 +971,7 @@ fn main() { ppa, inject, jobs, - resume, - keep_session, + incremental, view, ..Default::default() }) diff --git a/src/prune.rs b/src/prune.rs index 7c598cc..4bf4de3 100644 --- a/src/prune.rs +++ b/src/prune.rs @@ -9,11 +9,12 @@ //! may contain root-owned device nodes (created via `mknod`), a //! bind-mounted `/proc` and overlay filesystems, so they require careful //! unmounting before they can be removed. -//! - **Resumable build sessions** under `/var/tmp/pkh/sessions` (see -//! [`crate::deb::session`]): a kept session is a resumable build; the -//! default policy removes the ones untouched for longer than the session -//! retention window plus the corrupt leftovers, `--all` removes every -//! session. +//! - **Build records** under `/var/tmp/pkh/sessions///` +//! (see [`crate::deb::session`]): one immutable record per build; the +//! default policy removes the ones untouched for longer than the +//! record retention window plus the corrupt leftovers, `--all` +//! removes every record (only the last five per target are kept by +//! `pkh deb` itself). //! - **Cached chroot tarballs** (`~/.cache/pkh/*-buildd.tar.xz`) and their //! **stale download lockfiles** (`~/.cache/pkh/*.lock` untouched for longer //! than [`LOCK_STALE_AFTER`]; younger lockfiles may belong to a concurrent @@ -97,12 +98,12 @@ enum Artifact { Tarball(PathBuf), /// A build log file under `/logs`. LogFile(PathBuf), - /// A resumable build session root (chroot + journal). + /// A build record root (chroot + journal), one per build. Session(PathBuf), /// A `pkh test` run directory (report artifacts). TestRun(PathBuf), - /// An orphaned session lock file (`.lock` whose session root is - /// gone). + /// An orphaned record lock file (`.lock` whose record + /// root is gone). SessionLock(PathBuf), } @@ -127,22 +128,26 @@ impl Artifact { Artifact::LockFile(_) => "stale lockfile", Artifact::Tarball(_) => "cached chroot tarball", Artifact::LogFile(_) => "build log", - Artifact::Session(_) => "resumable build session", + Artifact::Session(_) => "build record", Artifact::TestRun(_) => "pkh test run", - Artifact::SessionLock(_) => "stale session lockfile", + Artifact::SessionLock(_) => "stale record lockfile", } } } -/// Discover the build sessions that prune may remove under `sessions_dir`: -/// with `--all`, every session; otherwise only those untouched for longer -/// than the retention window ([`crate::deb::session::SESSION_RETENTION`]) -/// and the ones whose manifest is missing or unreadable (inert leftovers). +/// Discover the build records prune may remove under `sessions_dir` (the +/// record layout of [`crate::deb::session`]: one `` directory +/// — with its `.lock` beside it — per slug directory): with +/// `--all`, every record; otherwise only those untouched for longer than +/// the retention window plus the corrupt leftovers. /// -/// Orphaned lock files (`.lock` whose session root is gone — left -/// behind by failed/interrupted runs) are collected too: by default only -/// the ones no live build holds (probed with a non-blocking flock), with -/// `--all` every one of them. +/// Orphaned lock files (`.lock` whose record root is gone — +/// left behind by interrupted or discarded runs) are collected too: by +/// default only the ones no live build holds (probed with a non-blocking +/// flock), with `--all` every one of them. The pre-record single-session +/// layout kept its lock at the sessions root and its manifest directly +/// inside the slug directory; both are collected as the leftovers they +/// became. fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec { let Ok(entries) = fs::read_dir(sessions_dir) else { return Vec::new(); @@ -151,21 +156,64 @@ fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec { for entry in entries.flatten() { let path = entry.path(); let name = entry.file_name().to_string_lossy().into_owned(); + // The old layout's `.lock` at the root: orphaned once no + // slug directory of that name exists. if let Some(slug) = name.strip_suffix(".lock") { - if !path.is_file() || sessions_dir.join(slug).is_dir() { - continue; + if path.is_file() && !sessions_dir.join(slug).is_dir() { + collect_lock(&path, all, &mut artifacts); } - let held = crate::deb::session::lock_is_held(&path).unwrap_or(true); - if all || !held { - artifacts.push(Artifact::SessionLock(path)); + continue; + } + let Some(slug_dir) = path.is_dir().then_some(path) else { + continue; + }; + let Ok(records) = fs::read_dir(&slug_dir) else { + continue; + }; + for record in records.flatten() { + let path = record.path(); + let name = record.file_name().to_string_lossy().into_owned(); + if let Some(record_id) = name.strip_suffix(".lock") { + if path.is_file() && !slug_dir.join(record_id).is_dir() { + collect_lock(&path, all, &mut artifacts); + } + } else if path.is_dir() { + if is_prunable_session(&path, all) { + artifacts.push(Artifact::Session(path)); + } + } else if is_stale_path(&path, all) { + // A record root only ever holds directories; a plain + // file inside a slug directory is a leftover of the old + // flat layout (its session.json / host-tree snapshot). + artifacts.push(Artifact::Session(path)); } - } else if path.is_dir() && is_prunable_session(&path, all) { - artifacts.push(Artifact::Session(path)); } } artifacts } +/// Collect a lock file as prunable when no live build holds it (or `--all` +/// was requested). +fn collect_lock(path: &Path, all: bool, artifacts: &mut Vec) { + let held = crate::deb::session::lock_is_held(path).unwrap_or(true); + if all || !held { + artifacts.push(Artifact::SessionLock(path.to_path_buf())); + } +} + +/// Whether a path is untouched for longer than the record retention +/// window (mtime-based; the fallback for leftovers with no manifest). +fn is_stale_path(path: &Path, all: bool) -> bool { + all || fs::metadata(path) + .and_then(|meta| meta.modified()) + .is_ok_and(|mtime| { + chrono::Utc::now() + .signed_duration_since(chrono::DateTime::::from(mtime)) + .to_std() + .is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION) + }) +} + /// Discover the `pkh test` run directories prune may remove under /// `tests_dir`: with `--all`, every run; otherwise only those untouched /// for longer than the sessions retention window. @@ -177,24 +225,17 @@ fn discover_test_runs(tests_dir: &Path, all: bool) -> Vec { .flatten() .map(|entry| entry.path()) .filter(|path| path.is_dir()) - .filter(|path| { - all || fs::metadata(path) - .and_then(|meta| meta.modified()) - .is_ok_and(|mtime| { - chrono::Utc::now() - .signed_duration_since(chrono::DateTime::::from(mtime)) - .to_std() - .is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION) - }) - }) + .filter(|path| is_stale_path(path, all)) .map(Artifact::TestRun) .collect() } -/// Whether a session root is prunable under the given policy: everything +/// Whether a record root is prunable under the given policy: everything /// with `--all`, otherwise stale (untouched beyond the retention window) -/// or corrupt (no readable manifest) sessions. Live sessions record a -/// fresh `last_used` on every journal write and are never stale. +/// or corrupt (no readable manifest) records. Live records record a fresh +/// `last_used` when they are created and are never stale; a record whose +/// manifest is missing is mid-creation rather than corrupt while its +/// lock is held, and is left alone. fn is_prunable_session(path: &Path, all: bool) -> bool { if all { return true; @@ -213,8 +254,18 @@ fn is_prunable_session(path: &Path, all: bool) -> bool { .signed_duration_since(then) .to_std() .is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION), - // No readable manifest: an inert leftover. - None => true, + // No readable manifest: a leftover — unless a live build holds + // the record's lock (mid-creation or mid-fork); an unreadable + // probe also errs on keeping it. + None => { + let lock = path.with_file_name(format!( + "{}.lock", + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + )); + matches!(crate::deb::session::lock_is_held(&lock), Ok(false)) + } } } @@ -1221,7 +1272,8 @@ none /tmp/other proc rw 0 0 assert!(report.is_empty()); } - /// Write a minimal session manifest with the given `last_used`. + /// Write a minimal record manifest with the given `last_used` under + /// `sessions///`. fn write_session_manifest(session_dir: &Path, last_used: &str) { fs::create_dir_all(session_dir).unwrap(); fs::write( @@ -1231,15 +1283,16 @@ none /tmp/other proc rw 0 0 .unwrap(); } - /// Session pruning: stale and corrupt sessions go by default, live ones + /// Record pruning: stale and corrupt records go by default, live ones /// stay; `--all` takes everything. #[test] fn test_session_pruning_retention() { let temp = tempdir().unwrap(); let sessions = temp.path().join("sessions"); - let stale = sessions.join("stale"); - let fresh = sessions.join("fresh"); - let corrupt = sessions.join("corrupt"); + let slug = sessions.join("pkh-noble-amd64-1c0ffee0"); + let stale = slug.join("20200101T000000"); + let fresh = slug.join("20260929T000000"); + let corrupt = slug.join("20260929T000001"); write_session_manifest(&stale, "2020-01-01T00:00:00+00:00"); write_session_manifest( &fresh, @@ -1258,11 +1311,11 @@ none /tmp/other proc rw 0 0 .unwrap(); assert!(report.removed.contains(&stale)); assert!(report.removed.contains(&corrupt)); - assert!(fresh.exists(), "a live session is not pruned by default"); + assert!(fresh.exists(), "a live record is not pruned by default"); assert!(!stale.exists()); assert!(!corrupt.exists()); - // --all removes everything, including the live session. + // --all removes everything, including the live record. let report = prune_in_roots( temp.path(), None, @@ -1325,22 +1378,27 @@ none /tmp/other proc rw 0 0 assert!(!fresh.exists()); } - /// Orphaned session lock files are pruned (unless held by a live - /// build); locks whose session root still exists are never touched. + /// Orphaned record lock files are pruned (unless held by a live + /// build); locks whose record root still exists are never touched. + /// The pre-record layout's locks at the sessions root follow the same + /// rules. #[test] fn test_session_lock_pruning() { use std::os::unix::io::AsRawFd; let temp = tempdir().unwrap(); let sessions = temp.path().join("sessions"); - let orphan_free = sessions.join("free.lock"); - let orphan_held = sessions.join("held.lock"); - let rooted = sessions.join("rooted.lock"); - fs::create_dir_all(&sessions).unwrap(); + let slug = sessions.join("pkh-noble-amd64-1c0ffee0"); + let orphan_free = slug.join("20260929T000000.lock"); + let orphan_held = slug.join("20260929T000001.lock"); + let rooted = slug.join("20260929T000002.lock"); + let legacy = sessions.join("legacy-slug.lock"); + fs::create_dir_all(&slug).unwrap(); fs::write(&orphan_free, b"").unwrap(); fs::write(&orphan_held, b"").unwrap(); fs::write(&rooted, b"").unwrap(); - fs::create_dir_all(sessions.join("rooted")).unwrap(); + fs::create_dir_all(slug.join("20260929T000002")).unwrap(); + fs::write(&legacy, b"").unwrap(); // Hold one orphaned lock, as a live build would. let holder = fs::OpenOptions::new() @@ -1361,10 +1419,12 @@ none /tmp/other proc rw 0 0 .unwrap(); assert!(report.removed.contains(&orphan_free)); assert!(!orphan_free.exists()); + assert!(report.removed.contains(&legacy)); + assert!(!legacy.exists()); assert!(orphan_held.exists(), "a held lock belongs to a live build"); - assert!(rooted.exists(), "a lock guarding an existing root stays"); + assert!(rooted.exists(), "a lock guarding an existing record stays"); - // --all takes the held orphan too (the root it guarded is gone). + // --all takes the held orphan too (the record it guarded is gone). drop(holder); let report = prune_in_roots( temp.path(),