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(),