1610 lines
63 KiB
Rust
1610 lines
63 KiB
Rust
use crate::context::{self, Context, ContextConfig};
|
|
use crate::deb::session::{self, Session};
|
|
use crate::deb::{Phase, enter_phase};
|
|
use crate::interrupt::CleanupHookGuard;
|
|
use crate::report::BuildView;
|
|
use directories::ProjectDirs;
|
|
use sha2::{Digest, Sha256};
|
|
use std::error::Error;
|
|
use std::fs;
|
|
use std::os::unix::io::AsRawFd;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
use tar::Archive;
|
|
use xz2::read::XzDecoder;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interrupt-time cleanup
|
|
//
|
|
// On Ctrl-C, the watchdog in `crate::interrupt` runs the hook registered in
|
|
// [`EphemeralContextGuard::new_with_context`] right before exiting — the
|
|
// interrupt sequence skips all destructors, which would otherwise leak the
|
|
// freshly bootstrapped chroot under /tmp together with its bind-mounted
|
|
// /proc and any overlayfs mounts.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Interrupt-time teardown of an anonymous temp chroot (no build record):
|
|
/// unmount everything under it, then remove it — a plain `rm -rf` with a
|
|
/// retry loop, best-effort. See [`sigint_cleanup_record`] for records.
|
|
fn sigint_cleanup_chroot(chroot_path: &Path) {
|
|
let is_root = unsafe { libc::geteuid() } == 0;
|
|
unmount_under_logged(chroot_path);
|
|
remove_with_retries(chroot_path, is_root);
|
|
}
|
|
|
|
/// Interrupt-time teardown of a build record: unmount everything under
|
|
/// the record root, remove the per-run state (the chroot and the overlay
|
|
/// scratch workdir) and, for a plain run, the staged tree and its
|
|
/// host-tree snapshot. An `--incremental` run keeps whatever staging
|
|
/// created — the keep only claims a staged tree when one exists on
|
|
/// disk; a run interrupted before staging keeps none. The manifest and
|
|
/// the build log always stay: every build leaves a metadata record, the
|
|
/// interrupt included.
|
|
///
|
|
/// Unlike [`EphemeralContextGuard::drop`], this deliberately does NOT go
|
|
/// through the context manager, the ephemeral context's driver (whose
|
|
/// `cleanup()` unmounts the tracked overlays) or the base context's
|
|
/// command builder: interrupt-time hooks must be self-contained, and
|
|
/// those machineries may be mid-mutation on the interrupted thread.
|
|
/// Instead it only reads /proc/mounts and spawns umount/rm directly.
|
|
///
|
|
/// Best-effort by design: if a child process still holds a mount busy or
|
|
/// privilege escalation is unavailable, individual steps fail; failures
|
|
/// are logged (pointing at `pkh prune` for the leftovers) and never
|
|
/// panic.
|
|
fn sigint_cleanup_record(record_root: &Path, keep_upper: bool) {
|
|
let is_root = unsafe { libc::geteuid() } == 0;
|
|
unmount_under_logged(record_root);
|
|
// Per-run state first: the chroot holds the /proc bind mount and the
|
|
// device nodes, the workdir holds root-owned overlay scratch.
|
|
for dir in ["chroot", "tree-upper-work"] {
|
|
remove_with_retries(&record_root.join(dir), is_root);
|
|
}
|
|
if keep_upper && record_root.join("tree-upper").is_dir() {
|
|
log::debug!(
|
|
"Interrupted build record kept its staged tree at {} (fork it \
|
|
with `pkh deb --incremental`)",
|
|
record_root.display()
|
|
);
|
|
} else if keep_upper {
|
|
// The run ended before staging created one: keep the message
|
|
// honest — there is no staged tree to fork.
|
|
log::debug!(
|
|
"Interrupted build record kept at {} (no staged tree was \
|
|
created — the run ended before staging)",
|
|
record_root.display()
|
|
);
|
|
} else {
|
|
for path in ["tree-upper", "host-files.list"] {
|
|
remove_with_retries(&record_root.join(path), is_root);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Unmount every mount at or under `base`, deepest first, logging the
|
|
/// failures (interrupt-time: leftover mounts are `pkh prune`'s to
|
|
/// collect).
|
|
fn unmount_under_logged(base: &Path) {
|
|
let is_root = unsafe { libc::geteuid() } == 0;
|
|
// Unmount children before parents: /proc/mounts lists mounts roughly
|
|
// in creation order, so walk it in reverse.
|
|
for mount_point in host_mounts_under(base).into_iter().rev() {
|
|
if unmount_path(&mount_point, is_root) {
|
|
log::debug!(
|
|
"Unmounted {} during interrupt cleanup",
|
|
mount_point.display()
|
|
);
|
|
} else {
|
|
log::error!(
|
|
"Failed to unmount {} during interrupt cleanup; \
|
|
run `pkh prune` once the mount is free",
|
|
mount_point.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Privileged `rm -rf` with a retry loop: a child the Ctrl+C interrupted
|
|
/// may still be finishing its writeout — dpkg defers SIGINT until it
|
|
/// reaches a safe state — so retry while rm reports the tree non-empty
|
|
/// instead of leaving it half-removed. A missing path succeeds at once.
|
|
fn remove_with_retries(path: &Path, is_root: bool) {
|
|
const RETRIES: usize = 10;
|
|
const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
|
|
let mut last = None;
|
|
for attempt in 0..=RETRIES {
|
|
if attempt > 0 {
|
|
std::thread::sleep(RETRY_DELAY);
|
|
}
|
|
last = Some(
|
|
privileged_command("rm", is_root)
|
|
.arg("-rf")
|
|
.arg(path)
|
|
.status(),
|
|
);
|
|
if matches!(&last, Some(Ok(status)) if status.success()) {
|
|
break;
|
|
}
|
|
}
|
|
match last {
|
|
Some(Ok(status)) if status.success() => {
|
|
log::debug!("Removed {} during interrupt cleanup", path.display());
|
|
}
|
|
Some(Ok(status)) => {
|
|
log::error!(
|
|
"Failed to remove {} during interrupt cleanup \
|
|
(rm exited with {status}); run `pkh prune`",
|
|
path.display()
|
|
);
|
|
}
|
|
Some(Err(e)) => {
|
|
log::error!(
|
|
"Failed to run rm for {} during interrupt cleanup: {e}; run `pkh prune`",
|
|
path.display()
|
|
);
|
|
}
|
|
None => unreachable!("at least one rm attempt ran"),
|
|
}
|
|
}
|
|
|
|
/// Build a `Command` for `program`, wrapped in non-interactive sudo when not
|
|
/// running as root: interrupt cleanup must never block on a password prompt,
|
|
/// so without cached credentials the command fails fast and is logged instead
|
|
pub(crate) fn privileged_command(program: &str, is_root: bool) -> Command {
|
|
if is_root {
|
|
Command::new(program)
|
|
} else {
|
|
let mut cmd = Command::new("sudo");
|
|
cmd.arg("-n").arg(program);
|
|
cmd
|
|
}
|
|
}
|
|
|
|
/// Privileged `rm -rf` of a tree (residual chroots hold root-owned device
|
|
/// 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")
|
|
.arg(path)
|
|
.status()
|
|
.map_err(|e| std::io::Error::other(format!("failed to run rm: {e}")))?;
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
Err(std::io::Error::other(format!(
|
|
"rm -rf {} exited with {status}",
|
|
path.display()
|
|
)))
|
|
}
|
|
}
|
|
|
|
/// Clear a directory's contents, keeping the directory itself.
|
|
///
|
|
/// Built for re-cleaning a crashed build's leftovers: a SIGKILLed build
|
|
/// (OOM, power loss) leaves its overlay mounts and /proc bind mount behind,
|
|
/// and the kernel creates root-owned `work` state inside an overlay
|
|
/// workdir that a plain `rm -rf` cannot remove. So every child is first
|
|
/// unmounted at depth (re-reading /proc/mounts until nothing is left under
|
|
/// it), then removed with a privileged `rm -rf` fallback.
|
|
///
|
|
/// Returns an error when some content could not be removed: callers should
|
|
/// refuse to layer a fresh build over a half-cleared tree.
|
|
pub(crate) fn clear_dir(dir: &Path) -> std::io::Result<()> {
|
|
fs::create_dir_all(dir)?;
|
|
const MAX_UNMOUNT_PASSES: usize = 10;
|
|
for entry in fs::read_dir(dir)?.flatten() {
|
|
let path = entry.path();
|
|
// Unmount everything under the child, deepest first; the loop
|
|
// handles stacks (a re-mount over an earlier, never-unmounted one —
|
|
// exactly what consecutive crashed builds leave behind).
|
|
for _ in 0..MAX_UNMOUNT_PASSES {
|
|
let mounts = host_mounts_under(&path);
|
|
if mounts.is_empty() {
|
|
break;
|
|
}
|
|
let is_root = crate::utils::root::is_root().unwrap_or(false);
|
|
for mount in mounts.into_iter().rev() {
|
|
if !unmount_path(&mount, is_root) {
|
|
log::warn!("Failed to unmount {} (retrying)", mount.display());
|
|
}
|
|
}
|
|
}
|
|
let result = if path.is_dir() {
|
|
fs::remove_dir_all(&path)
|
|
} else {
|
|
fs::remove_file(&path)
|
|
}
|
|
.or_else(|_| privileged_remove(&path));
|
|
if let Err(e) = result {
|
|
return Err(std::io::Error::other(format!(
|
|
"cannot remove {}: {e}",
|
|
path.display()
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether `path` is currently a mount point (according to /proc/mounts).
|
|
fn is_mountpoint(path: &Path) -> bool {
|
|
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
|
let Ok(mounts_text) = fs::read_to_string("/proc/mounts") else {
|
|
return false;
|
|
};
|
|
mounts_text.lines().any(|line| {
|
|
line.split_whitespace()
|
|
.nth(1)
|
|
.map(|mp| unescape_mount_field(mp) == path.to_string_lossy())
|
|
.unwrap_or(false)
|
|
})
|
|
}
|
|
|
|
/// The cached chroot tarball path for a series/arch, as used by the
|
|
/// bootstrap: the record's fork gate compares the tarball's digest
|
|
/// against the sha recorded in a base's manifest. A mirror pin is hashed
|
|
/// into the name (before the `-buildd.tar.xz` suffix prune matches), so
|
|
/// a pinned tarball never collides with the distro-default one — a
|
|
/// `--mirror` run over a stale distro tarball would silently resolve
|
|
/// from the distro, exactly the lie the pin must not tell.
|
|
pub(crate) fn chroot_tarball_path(
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
) -> Result<PathBuf, Box<dyn Error>> {
|
|
let proj_dirs =
|
|
ProjectDirs::from("com", "pkh", "pkh").ok_or("Could not determine project directories")?;
|
|
Ok(proj_dirs
|
|
.cache_dir()
|
|
.join(chroot_tarball_filename(series, arch, mirror)))
|
|
}
|
|
|
|
/// The cached chroot tarball's file name for a series/arch/mirror
|
|
pub(crate) fn chroot_tarball_filename(
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
) -> String {
|
|
let target = match arch {
|
|
Some(a) => format!("{series}-{a}"),
|
|
None => series.to_string(),
|
|
};
|
|
match mirror {
|
|
None => format!("{target}-buildd.tar.xz"),
|
|
Some(m) => {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(m.as_bytes());
|
|
let hash = format!("{:x}", hasher.finalize());
|
|
format!("{target}-{}-buildd.tar.xz", &hash[..12])
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Root of the shared apt download caches (`/var/tmp/pkh/apt-cache`),
|
|
/// one `<series>-<arch>` subdirectory per target.
|
|
pub(crate) const APT_CACHE_ROOT: &str = "/var/tmp/pkh/apt-cache";
|
|
|
|
/// The shared apt download cache of a `series`/`arch` target:
|
|
/// `/var/tmp/pkh/apt-cache/<series>-<arch>/`, bind-mounted over each
|
|
/// chroot's `/var/cache/apt/archives` so dependency installs reuse the
|
|
/// `.debs` previous runs downloaded instead of fetching them again.
|
|
/// `arch` is the chroot's architecture (the foreign one for qemu-binfmt
|
|
/// builds, the host one otherwise), keyed like the chroot tarball cache.
|
|
pub(crate) fn apt_cache_dir(series: &str, arch: Option<&str>) -> PathBuf {
|
|
// The host architecture when the chroot is not a foreign one.
|
|
let arch = arch
|
|
.map(str::to_string)
|
|
.unwrap_or_else(crate::get_current_arch);
|
|
PathBuf::from(APT_CACHE_ROOT).join(format!("{series}-{arch}"))
|
|
}
|
|
|
|
/// Name of the apt configuration file that keeps downloaded packages
|
|
/// (without it apt deletes the `.debs` it installs, and the shared
|
|
/// cache would never fill).
|
|
pub(crate) const APT_KEEP_CONF: &str = "99pkh-shared-download-cache";
|
|
|
|
/// Write the apt configuration keeping downloaded packages into the
|
|
/// chroot, so installs populate the (shared) download cache.
|
|
pub(crate) fn write_apt_keep_conf(chroot_path: &Path) -> std::io::Result<()> {
|
|
let conf_dir = chroot_path.join("etc/apt/apt.conf.d");
|
|
fs::create_dir_all(&conf_dir)?;
|
|
fs::write(
|
|
conf_dir.join(APT_KEEP_CONF),
|
|
"APT::Keep-Downloaded-Packages \"true\";\n",
|
|
)
|
|
}
|
|
|
|
/// The in-chroot mount point of the shared ccache directory.
|
|
pub(crate) const CCACHE_MOUNT: &str = "/var/cache/pkh-ccache";
|
|
|
|
/// The in-chroot directory holding the ccache compiler shims
|
|
/// (gcc/g++/cc/c++ → `/usr/bin/ccache`), prepended to the build's PATH
|
|
/// by [`crate::deb::local`].
|
|
pub(crate) const CCACHE_SHIM_DIR: &str = "/usr/lib/pkh-ccache-shim";
|
|
|
|
/// Bind-mount the shared ccache directory of a build identity over the
|
|
/// chroot's [`CCACHE_MOUNT`] and install the compiler shims, so the
|
|
/// builds of that target reuse each other's compiled objects (content
|
|
/// keyed, see the ccache env in [`crate::deb::local`]). ccache is
|
|
/// designed for concurrent access to one cache — no cross-run lock is
|
|
/// needed.
|
|
///
|
|
/// Failures are warnings, never errors: an unshared cache dir makes
|
|
/// ccache per-run (still correct, just slower), and a missing shim
|
|
/// leaves the build on the real compilers.
|
|
fn share_ccache(chroot_path: &Path, cache: &Path, ctx: Arc<context::Context>) {
|
|
if let Err(e) = fs::create_dir_all(cache).and_then(|()| install_ccache_shim(chroot_path)) {
|
|
log::warn!("Cannot share the ccache {}: {e}", cache.display());
|
|
return;
|
|
}
|
|
let mount_point = chroot_path.join(CCACHE_MOUNT.trim_start_matches('/'));
|
|
if let Err(e) = fs::create_dir_all(&mount_point) {
|
|
log::warn!(
|
|
"Cannot create the ccache mount point {}: {e}",
|
|
mount_point.display()
|
|
);
|
|
return;
|
|
}
|
|
// Check if we're running as root to avoid unnecessary sudo
|
|
let is_root = crate::utils::root::is_root().unwrap_or(false);
|
|
let mut cmd = ctx.command(if is_root { "mount" } else { "sudo" });
|
|
if !is_root {
|
|
cmd.arg("mount");
|
|
}
|
|
match cmd.arg("--bind").arg(cache).arg(&mount_point).status() {
|
|
Ok(status) if status.success() => {
|
|
log::debug!(
|
|
"Bind-mounted the shared ccache {} over {}",
|
|
cache.display(),
|
|
mount_point.display()
|
|
);
|
|
}
|
|
outcome => {
|
|
log::warn!(
|
|
"Cannot bind-mount the shared ccache {} over {}: {:?}; \
|
|
this run's cache is not shared",
|
|
cache.display(),
|
|
mount_point.display(),
|
|
outcome
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Install the ccache PATH shim inside the chroot: `gcc`, `g++`, `cc`
|
|
/// and `c++` symlinking to the ccache binary the essentials install, in
|
|
/// a directory nothing else populates. The absolute target resolves
|
|
/// inside the chroot, where the shim dir is prepended to PATH.
|
|
pub(crate) fn install_ccache_shim(chroot_path: &Path) -> std::io::Result<()> {
|
|
let shim_dir = chroot_path.join(CCACHE_SHIM_DIR.trim_start_matches('/'));
|
|
fs::create_dir_all(&shim_dir)?;
|
|
for compiler in ["gcc", "g++", "cc", "c++"] {
|
|
let shim = shim_dir.join(compiler);
|
|
match fs::symlink_metadata(&shim) {
|
|
Ok(_) => fs::remove_file(&shim)?, // idempotent re-install
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
std::os::unix::fs::symlink("/usr/bin/ccache", &shim)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Unmount `path`, falling back to a lazy unmount if the first attempt fails
|
|
/// because something still holds the mount busy (e.g. an interrupted child
|
|
/// that has not exited yet); returns whether the mount is gone
|
|
pub(crate) fn unmount_path(path: &Path, is_root: bool) -> bool {
|
|
if privileged_command("umount", is_root)
|
|
.arg(path)
|
|
.status()
|
|
.is_ok_and(|s| s.success())
|
|
{
|
|
return true;
|
|
}
|
|
privileged_command("umount", is_root)
|
|
.arg("-l")
|
|
.arg(path)
|
|
.status()
|
|
.is_ok_and(|s| s.success())
|
|
}
|
|
|
|
/// Collect the host-side mount points at or below `base`, in /proc/mounts
|
|
/// order (empty if /proc/mounts cannot be read)
|
|
pub(crate) fn host_mounts_under(base: &Path) -> Vec<PathBuf> {
|
|
let mut mounts = Vec::new();
|
|
let Ok(mounts_text) = fs::read_to_string("/proc/mounts") else {
|
|
return mounts;
|
|
};
|
|
// Compare against the canonical path: /proc/mounts shows resolved paths,
|
|
// while the chroot path may go through a symlinked TMPDIR
|
|
let base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
|
|
for line in mounts_text.lines() {
|
|
let mut fields = line.split_whitespace();
|
|
let (Some(_device), Some(mount_point)) = (fields.next(), fields.next()) else {
|
|
continue;
|
|
};
|
|
let path = PathBuf::from(unescape_mount_field(mount_point));
|
|
if path.starts_with(&base) && !mounts.contains(&path) {
|
|
mounts.push(path);
|
|
}
|
|
}
|
|
mounts
|
|
}
|
|
|
|
/// Decode the octal escapes /proc/mounts uses in its path fields
|
|
/// (`\040` for space, `\011` for tab, `\012` for newline, `\134` for backslash)
|
|
fn unescape_mount_field(field: &str) -> String {
|
|
let bytes = field.as_bytes();
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i] == b'\\'
|
|
&& i + 4 <= bytes.len()
|
|
&& bytes[i + 1..i + 4]
|
|
.iter()
|
|
.all(|b| (b'0'..=b'7').contains(b))
|
|
&& let Ok(value) = u8::from_str_radix(&field[i + 1..i + 4], 8)
|
|
{
|
|
out.push(value);
|
|
i += 4;
|
|
} else {
|
|
out.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
}
|
|
String::from_utf8_lossy(&out).into_owned()
|
|
}
|
|
|
|
/// An ephemeral unshare context guard that creates and manages a temporary chroot environment
|
|
/// for building packages with unshare permissions.
|
|
///
|
|
/// In session mode ([`EphemeralContextGuard::new_for_session`]) the chroot
|
|
/// lives inside a [`Session`] record's `chroot/` directory — extracted
|
|
/// fresh from the tarball cache on every run (nothing chroot-shaped is
|
|
/// persisted) and removed at the end of the run. The record itself is
|
|
/// always kept as metadata and log; its staged tree is retained exactly
|
|
/// for `--incremental` runs (`keep_upper`).
|
|
pub struct EphemeralContextGuard {
|
|
/// The ephemeral build context this guard created (an unshare context
|
|
/// bound to the chroot, parented on the base context). Held explicitly so
|
|
/// cleanup and the build itself never depend on the process-global
|
|
/// "current" context, which concurrent builds swap for their own.
|
|
ephemeral_ctx: Arc<Context>,
|
|
/// The context that was current (globally) when this guard was created,
|
|
/// restored on drop. Saving the handle instead of a config name is what
|
|
/// keeps concurrent builds from restoring over each other.
|
|
previous_context: Arc<Context>,
|
|
chroot_path: PathBuf,
|
|
build_succeeded: bool,
|
|
base_ctx: Arc<Context>,
|
|
/// 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<CleanupHookGuard>,
|
|
/// The build record this guard works in, when any.
|
|
session: Option<Session>,
|
|
/// Whether the record's staged tree is retained after the build
|
|
/// (`pkh deb --incremental`).
|
|
keep_upper: bool,
|
|
/// The host-side lock of the shared apt download cache, held while
|
|
/// its bind mount is up (see
|
|
/// [`EphemeralContextGuard::share_apt_cache`]); released when the
|
|
/// guard drops, together with the mount. Held for its drop side
|
|
/// effect alone, hence the underscore: it is never read.
|
|
_apt_cache_lock: Option<fs::File>,
|
|
}
|
|
|
|
impl EphemeralContextGuard {
|
|
/// Create a new ephemeral unshare context with an explicit base context
|
|
///
|
|
/// # Arguments
|
|
/// * `series` - The distribution series (e.g., "noble", "sid")
|
|
/// * `arch` - Optional target architecture. If provided and different from host,
|
|
/// downloads a chroot for that architecture (uses qemu_binfmt transparently)
|
|
/// * `base_ctx` - The base context to use for creating the chroot
|
|
pub async fn new_with_context(
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
base_ctx: Arc<Context>,
|
|
view: &dyn BuildView,
|
|
) -> Result<Self, Box<dyn Error>> {
|
|
Self::new_internal(None, series, arch, mirror, base_ctx, view, false, None).await
|
|
}
|
|
|
|
/// Create the ephemeral build context inside `session`'s chroot.
|
|
///
|
|
/// # Arguments
|
|
/// * `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
|
|
/// * `keep_upper` - Retain the record's staged tree after the build
|
|
/// (`pkh deb --incremental`); a plain run's record keeps metadata
|
|
/// and log only
|
|
/// * `ccache_dir` - The shared ccache directory to mount at the
|
|
/// chroot's `/var/cache/pkh-ccache` (`pkh deb` computes it from the
|
|
/// build identity; `None` leaves the chroot unshared)
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn new_for_session(
|
|
session: Session,
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
base_ctx: Arc<Context>,
|
|
view: &dyn BuildView,
|
|
keep_upper: bool,
|
|
ccache_dir: Option<&Path>,
|
|
) -> Result<Self, Box<dyn Error>> {
|
|
Self::new_internal(
|
|
Some(session),
|
|
series,
|
|
arch,
|
|
mirror,
|
|
base_ctx,
|
|
view,
|
|
keep_upper,
|
|
ccache_dir,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn new_internal(
|
|
session: Option<Session>,
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
base_ctx: Arc<Context>,
|
|
view: &dyn BuildView,
|
|
keep_upper: bool,
|
|
ccache_dir: Option<&Path>,
|
|
) -> Result<Self, Box<dyn Error>> {
|
|
// Save the globally-installed context so Drop can restore exactly
|
|
// this handle: concurrent builds install their own ephemeral
|
|
// overrides, so the only safe restoration value is the one observed
|
|
// before this guard swapped anything in.
|
|
let previous_context = context::current();
|
|
|
|
// In session mode the chroot lives inside the record root, as
|
|
// per-run scratch; otherwise it is a fresh temp dir.
|
|
let chroot_path = match &session {
|
|
Some(s) => {
|
|
let path = s.chroot_path();
|
|
fs::create_dir_all(&path)?;
|
|
path
|
|
}
|
|
None => PathBuf::from(base_ctx.create_temp_dir()?),
|
|
};
|
|
|
|
log::debug!(
|
|
"Creating new chroot for {} (arch: {:?}) at {}...",
|
|
series,
|
|
arch,
|
|
chroot_path.display()
|
|
);
|
|
|
|
// Register the interrupt-time cleanup hook before any heavy work: if
|
|
// the user hits Ctrl-C during bootstrap or the build itself, the
|
|
// interrupt watchdog unmounts the chroot's mounts through this hook
|
|
// (see `sigint_cleanup_chroot`). This only works for a local
|
|
// 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.
|
|
//
|
|
// Session mode tears the record's per-run state down (chroot and
|
|
// overlay workdir) and keeps its staged tree exactly for
|
|
// `--incremental`; a plain temp chroot is removed entirely.
|
|
let cleanup_hook = if matches!(base_ctx.config, ContextConfig::Local) {
|
|
let hook: Box<dyn Fn() + Send> = match session.as_ref().map(|s| s.root().to_path_buf())
|
|
{
|
|
Some(record_root) => {
|
|
Box::new(move || sigint_cleanup_record(&record_root, keep_upper))
|
|
}
|
|
None => {
|
|
let chroot_path = chroot_path.clone();
|
|
Box::new(move || sigint_cleanup_chroot(&chroot_path))
|
|
}
|
|
};
|
|
Some(crate::interrupt::register_cleanup_hook(hook))
|
|
} else {
|
|
log::debug!(
|
|
"Base context is not local; skipping interrupt-time cleanup registration for {}",
|
|
chroot_path.display()
|
|
);
|
|
None
|
|
};
|
|
|
|
// Download and extract the chroot tarball — always fresh; the
|
|
// record's chroot directory is per-run scratch. The shared apt
|
|
// cache's lock (when the cache is shared) is held from here on.
|
|
let apt_cache_lock = match Self::download_and_extract_chroot(
|
|
series,
|
|
arch,
|
|
mirror,
|
|
&chroot_path,
|
|
base_ctx.clone(),
|
|
view,
|
|
ccache_dir,
|
|
)
|
|
.await
|
|
{
|
|
Ok(lock) => lock,
|
|
Err(e) => {
|
|
// On a Ctrl+C the interrupt watchdog owns the tree: keep the
|
|
// hook registered (forgetting the guard) so it removes the
|
|
// partial directory, instead of the historical behavior of
|
|
// leaving it in place. Without an interrupt this is a plain
|
|
// bootstrap failure and the partial directory stays, as before.
|
|
if crate::interrupt::interrupted()
|
|
&& let Some(hook) = cleanup_hook
|
|
{
|
|
std::mem::forget(hook);
|
|
} else {
|
|
drop(cleanup_hook);
|
|
}
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
// Record the tarball digest this environment came from: the fork
|
|
// gate of a later `--incremental` run compares it against the
|
|
// cache to decide whether the record's staged tree is still
|
|
// reusable.
|
|
if let Some(s) = &session
|
|
&& let Ok(tarball) = chroot_tarball_path(series, arch, mirror)
|
|
&& let Ok(sha) = session::sha256_file(&tarball)
|
|
{
|
|
s.set_tarball_sha256(&sha);
|
|
}
|
|
|
|
// Switch to an ephemeral context to build the package in the chroot.
|
|
// The parent is the base context itself (the one that bootstrapped
|
|
// the chroot), wired through `with_parent` instead of a config-name
|
|
// lookup, so an explicit non-current base (e.g. ssh) is used for
|
|
// everything that runs inside the chroot. The Arc stays in the
|
|
// guard: the build and the cleanup use it directly.
|
|
let ephemeral_ctx = Arc::new(Context::with_parent(
|
|
ContextConfig::Unshare {
|
|
path: chroot_path.to_string_lossy().to_string(),
|
|
// The real parent is bound below via `with_parent`; the
|
|
// config field is only used for contexts read from the
|
|
// persisted configuration.
|
|
parent: None,
|
|
},
|
|
base_ctx.clone(),
|
|
));
|
|
context::manager().set_current_ephemeral(ephemeral_ctx.clone());
|
|
|
|
Ok(Self {
|
|
previous_context,
|
|
ephemeral_ctx,
|
|
chroot_path,
|
|
build_succeeded: false,
|
|
base_ctx,
|
|
cleanup_hook,
|
|
session,
|
|
keep_upper,
|
|
_apt_cache_lock: apt_cache_lock,
|
|
})
|
|
}
|
|
|
|
/// The ephemeral build context created by this guard
|
|
///
|
|
/// Callers must take the context from here rather than from
|
|
/// [`crate::context::current()`]: the process-global is a shared swap
|
|
/// slot that another concurrent build may have re-pointed at its own
|
|
/// chroot, while this handle is guaranteed to be this guard's context.
|
|
pub fn context(&self) -> Arc<Context> {
|
|
Arc::clone(&self.ephemeral_ctx)
|
|
}
|
|
|
|
async fn download_and_extract_chroot(
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
chroot_path: &PathBuf,
|
|
ctx: Arc<context::Context>,
|
|
view: &dyn BuildView,
|
|
ccache_dir: Option<&Path>,
|
|
) -> Result<Option<fs::File>, Box<dyn Error>> {
|
|
// The chroot is per-run scratch: nothing is reused, so never
|
|
// extract over stale content.
|
|
clear_dir(chroot_path)?;
|
|
|
|
// Clone ctx for use in create_device_nodes after download_chroot_tarball consumes it
|
|
let ctx_for_devices = ctx.clone();
|
|
// Get project directories for caching
|
|
let proj_dirs = ProjectDirs::from("com", "pkh", "pkh")
|
|
.ok_or("Could not determine project directories")?;
|
|
let cache_dir = proj_dirs.cache_dir();
|
|
fs::create_dir_all(cache_dir)?;
|
|
|
|
let tarball_filename = chroot_tarball_filename(series, arch, mirror);
|
|
let tarball_path = cache_dir.join(&tarball_filename);
|
|
|
|
// Check for existing lockfile, and wait for a timeout if it exists
|
|
// After timeout, warn the user
|
|
let lockfile_path = tarball_path.with_extension("lock");
|
|
|
|
// Check if lockfile exists and wait for it to be removed
|
|
let mut wait_time = 0;
|
|
let timeout = 300; // 5 minutes timeout
|
|
let poll_interval = 5; // Check every 5 seconds
|
|
|
|
while ctx.exists(&lockfile_path)? {
|
|
// Stop waiting on a Ctrl+C: the interrupt watchdog removes the
|
|
// (yet empty) chroot and exits without waiting for the poll
|
|
if crate::interrupt::interrupted() {
|
|
return Err("Interrupted while waiting for the chroot tarball".into());
|
|
}
|
|
if wait_time >= timeout {
|
|
log::warn!(
|
|
"Lockfile {} exists and has been present for more than {} seconds. \
|
|
Another process may be downloading the chroot tarball. Continuing anyway...",
|
|
lockfile_path.display(),
|
|
timeout
|
|
);
|
|
break;
|
|
}
|
|
|
|
log::info!(
|
|
"Lockfile {} exists, waiting for download to complete... ({}s/{})",
|
|
lockfile_path.display(),
|
|
wait_time,
|
|
timeout
|
|
);
|
|
|
|
std::thread::sleep(std::time::Duration::from_secs(poll_interval));
|
|
wait_time += poll_interval;
|
|
}
|
|
|
|
// Download tarball if it doesn't exist
|
|
if !tarball_path.exists() {
|
|
log::debug!(
|
|
"Downloading chroot tarball for {} (arch: {:?})...",
|
|
series,
|
|
arch
|
|
);
|
|
enter_phase(view, Phase::PreparingChroot);
|
|
Self::download_chroot_tarball(series, arch, mirror, &tarball_path, ctx, view).await?;
|
|
} else {
|
|
log::debug!(
|
|
"Using cached chroot tarball for {} (arch: {:?})",
|
|
series,
|
|
arch
|
|
);
|
|
}
|
|
|
|
// Extract tarball to chroot directory
|
|
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
|
|
enter_phase(view, Phase::ExtractingChroot);
|
|
Self::extract_tarball(&tarball_path, chroot_path, view)?;
|
|
|
|
// Create device nodes, bind-mount /proc and share the apt cache
|
|
let apt_cache_lock =
|
|
Self::finalize_chroot(series, arch, chroot_path, ctx_for_devices, view, ccache_dir)?;
|
|
|
|
// The bootstrap's tarball digest is recorded by the caller (the
|
|
// record's fork gate compares it against the cache later).
|
|
Ok(apt_cache_lock)
|
|
}
|
|
|
|
/// Device nodes, the /proc bind mount, the shared apt download cache
|
|
/// and the shared ccache for a fresh chroot: idempotent
|
|
/// finalization, safe to run on every run. Returns the shared apt
|
|
/// cache's lock, held while its mount is up (see
|
|
/// [`EphemeralContextGuard::share_apt_cache`]).
|
|
fn finalize_chroot(
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
chroot_path: &Path,
|
|
ctx: Arc<context::Context>,
|
|
view: &dyn BuildView,
|
|
ccache_dir: Option<&Path>,
|
|
) -> Result<Option<fs::File>, Box<dyn Error>> {
|
|
if !Self::device_nodes_ready(chroot_path) {
|
|
// The tar crate materializes the tarball's device entries as
|
|
// empty regular files (see [Self::DEVICE_NODES]), so readiness
|
|
// is keyed on the nodes being char devices, not on presence.
|
|
log::debug!("Creating device nodes in chroot...");
|
|
enter_phase(view, Phase::FinalizingChroot);
|
|
Self::create_device_nodes(chroot_path, ctx.clone())?;
|
|
}
|
|
|
|
// Bind mount /proc from host into chroot (before entering unshare namespace)
|
|
// This allows /proc to work in containers where mounting inside unshare fails.
|
|
// A chroot reused within the same run may still carry the mount:
|
|
// only mount when it is not already there.
|
|
if is_mountpoint(&chroot_path.join("proc")) {
|
|
log::debug!("/proc is already bind-mounted in the chroot");
|
|
} else {
|
|
log::debug!("Bind-mounting /proc into chroot...");
|
|
Self::bind_mount_proc(chroot_path, ctx.clone())?;
|
|
}
|
|
|
|
if let Some(dir) = ccache_dir {
|
|
share_ccache(chroot_path, dir, ctx.clone());
|
|
}
|
|
Ok(Self::share_apt_cache(series, arch, chroot_path, ctx))
|
|
}
|
|
|
|
/// Bind-mount the shared apt download cache of `series`/`arch` over
|
|
/// the chroot's `/var/cache/apt/archives`, so dependency installs
|
|
/// reuse the `.debs` previous runs downloaded instead of fetching
|
|
/// them again. The apt conf keeping downloaded packages
|
|
/// ([`write_apt_keep_conf`]) makes the installs populate it.
|
|
///
|
|
/// While the mount is up, the run holds a host-side flock on the
|
|
/// cache's lock file (returned to the caller, released when the
|
|
/// guard drops): apt's own `archives/lock` is acquired inside the
|
|
/// chroot's pid namespace, where a concurrent build's apt appears
|
|
/// as "held by process 0" — an invisible process it refuses to wait
|
|
/// for — so pkh serializes the apt traffic of builds sharing one
|
|
/// cache itself. Concurrent builds of the same target wait for each
|
|
/// other instead of racing apt.
|
|
///
|
|
/// Any failure is a warning, never an error: the chroot keeps its
|
|
/// own cache dir and everything stays correct, just slower.
|
|
fn share_apt_cache(
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
chroot_path: &Path,
|
|
ctx: Arc<context::Context>,
|
|
) -> Option<fs::File> {
|
|
let cache = apt_cache_dir(series, arch);
|
|
if let Err(e) = fs::create_dir_all(cache.join("partial")) {
|
|
log::warn!(
|
|
"Cannot create the shared apt cache {}: {e}; using the \
|
|
chroot's own cache",
|
|
cache.display()
|
|
);
|
|
return None;
|
|
}
|
|
let archives = chroot_path.join("var/cache/apt/archives");
|
|
if let Err(e) =
|
|
fs::create_dir_all(&archives).and_then(|()| write_apt_keep_conf(chroot_path))
|
|
{
|
|
log::warn!(
|
|
"Cannot prepare the chroot's apt cache for sharing: {e}; \
|
|
using the chroot's own cache"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
// Serialize with the other runs sharing this cache BEFORE apt
|
|
// can run in the chroot. A held lock is a live run: wait for it
|
|
// (a crashed pkh releases its flock with its process).
|
|
let lock_path = cache.with_extension("lock");
|
|
let lock = match fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&lock_path)
|
|
{
|
|
Ok(file) => file,
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Cannot open the shared apt cache lock {}: {e}; using \
|
|
the chroot's own cache",
|
|
lock_path.display()
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
let taken = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX) };
|
|
if taken != 0 {
|
|
log::warn!(
|
|
"Cannot lock the shared apt cache {}: {}; using the \
|
|
chroot's own cache",
|
|
lock_path.display(),
|
|
std::io::Error::last_os_error()
|
|
);
|
|
return None;
|
|
}
|
|
|
|
// Check if we're running as root to avoid unnecessary sudo
|
|
let is_root = crate::utils::root::is_root().unwrap_or(false);
|
|
let mut cmd = ctx.command(if is_root { "mount" } else { "sudo" });
|
|
if !is_root {
|
|
cmd.arg("mount");
|
|
}
|
|
let status = cmd.arg("--bind").arg(&cache).arg(&archives).status();
|
|
match status {
|
|
Ok(status) if status.success() => {
|
|
log::debug!(
|
|
"Bind-mounted the shared apt cache {} over {}",
|
|
cache.display(),
|
|
archives.display()
|
|
);
|
|
Some(lock)
|
|
}
|
|
outcome => {
|
|
log::warn!(
|
|
"Cannot bind-mount the shared apt cache {} over {}: \
|
|
{:?}; using the chroot's own cache",
|
|
cache.display(),
|
|
archives.display(),
|
|
outcome
|
|
);
|
|
None // dropping the lock: nothing is shared
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn download_chroot_tarball(
|
|
series: &str,
|
|
arch: Option<&str>,
|
|
mirror: Option<&str>,
|
|
tarball_path: &Path,
|
|
ctx: Arc<context::Context>,
|
|
view: &dyn BuildView,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
// Create a lock file to make sure that noone tries to use the file while it's not fully downloaded
|
|
let lockfile_path = tarball_path.with_extension("lock");
|
|
ctx.command("touch")
|
|
.arg(lockfile_path.to_string_lossy().to_string())
|
|
.status()?;
|
|
|
|
// Download the keyring(s)
|
|
let keyring_dir =
|
|
crate::apt::keyring::download_cache_keyrings(Some(ctx.clone()), series).await?;
|
|
|
|
// Use mmdebstrap to download the tarball to the cache directory
|
|
let mut cmd = ctx.command("mmdebstrap");
|
|
cmd.arg("--variant=buildd")
|
|
.arg("--mode=unshare")
|
|
.arg("--include=mount,curl,ca-certificates")
|
|
.arg("--format=tar")
|
|
.arg(format!("--keyring={}", keyring_dir.display()))
|
|
// Setup hook to copy keyrings into the chroot so apt inside can use them
|
|
.arg("--setup-hook=mkdir -p \"$1/etc/apt/trusted.gpg.d\"")
|
|
.arg(format!(
|
|
"--setup-hook=cp {}/*.gpg \"$1/etc/apt/trusted.gpg.d/\"",
|
|
keyring_dir.display()
|
|
));
|
|
|
|
// Add architecture if specified
|
|
if let Some(a) = arch {
|
|
cmd.arg(format!("--arch={}", a));
|
|
}
|
|
|
|
cmd.arg(series)
|
|
.arg(tarball_path.to_string_lossy().to_string());
|
|
|
|
// The mirror positional: without it mmdebstrap writes the distro
|
|
// defaults into the tarball's apt sources verbatim; with it, the
|
|
// tarball's sources.list carries the mirror, so every apt inside
|
|
// the build follows.
|
|
if let Some(m) = mirror {
|
|
cmd.arg(m);
|
|
}
|
|
|
|
if let Some(s) = view.sink() {
|
|
cmd.capture(s);
|
|
}
|
|
|
|
let status = cmd.status()?;
|
|
|
|
if !status.success() {
|
|
// Remove file on error
|
|
let _ = ctx
|
|
.command("rm")
|
|
.arg("-f")
|
|
.arg(tarball_path.to_string_lossy().to_string())
|
|
.status();
|
|
let _ = ctx
|
|
.command("rm")
|
|
.arg("-f")
|
|
.arg(lockfile_path.to_string_lossy().to_string())
|
|
.status();
|
|
return Err(format!(
|
|
"Failed to download chroot tarball for series {} (arch: {:?})",
|
|
series, arch
|
|
)
|
|
.into());
|
|
}
|
|
|
|
// Remove lockfile: tarball is fully downloaded
|
|
let _ = ctx
|
|
.command("rm")
|
|
.arg("-f")
|
|
.arg(lockfile_path.to_string_lossy().to_string())
|
|
.status();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_tarball(
|
|
tarball_path: &PathBuf,
|
|
chroot_path: &PathBuf,
|
|
view: &dyn BuildView,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
// Create the chroot directory
|
|
fs::create_dir_all(chroot_path)?;
|
|
|
|
// Open the tarball file
|
|
let tarball_file = std::fs::File::open(tarball_path)?;
|
|
let xz_decoder = XzDecoder::new(tarball_file);
|
|
let mut archive = Archive::new(xz_decoder);
|
|
|
|
// Extract entries one by one so progress can be reported (a full
|
|
// second decompression pass just to count entries upfront would be
|
|
// too expensive for multi-hundred-MB chroot tarballs)
|
|
let mut count = 0usize;
|
|
for entry in archive.entries()? {
|
|
// Bail on a Ctrl+C before the interrupt watchdog's rm -rf races
|
|
// this loop writing entries into the tree being removed
|
|
if crate::interrupt::interrupted() {
|
|
return Err("Interrupted while extracting the chroot".into());
|
|
}
|
|
let mut entry = entry?;
|
|
entry.unpack_in(chroot_path)?;
|
|
count += 1;
|
|
if count.is_multiple_of(100) {
|
|
view.message(&format!("Extracting chroot… ({count} files)"));
|
|
}
|
|
}
|
|
view.message(&format!("Extracting chroot… ({count} files)"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The device nodes a buildd chroot needs, as (name, major, minor).
|
|
///
|
|
/// The chroot tarballs carry these as char-device entries, but the
|
|
/// tar crate cannot create special files: it treats entry types it
|
|
/// does not recognize as regular ones, so extraction leaves *empty
|
|
/// regular files* at these paths. Anything redirecting into them
|
|
/// then writes real data, and a reader gets garbage (a `/dev/null`
|
|
/// passed to `dpkg-gencontrol -T` aborts the build with "bad line
|
|
/// in substvars file"). Hence the nodes are (re)created with mknod,
|
|
/// gated on being char devices rather than merely existing.
|
|
const DEVICE_NODES: &[(&str, u32, u32)] = &[
|
|
("console", 5, 1),
|
|
("full", 1, 7),
|
|
("null", 1, 3),
|
|
("ptmx", 5, 2),
|
|
("random", 1, 8),
|
|
("tty", 5, 0),
|
|
("urandom", 1, 9),
|
|
("zero", 1, 5),
|
|
];
|
|
|
|
/// Whether every node in [`Self::DEVICE_NODES`] exists as a char
|
|
/// device in the chroot.
|
|
fn device_nodes_ready(chroot_path: &Path) -> bool {
|
|
use std::os::unix::fs::FileTypeExt;
|
|
|
|
Self::DEVICE_NODES.iter().all(|(name, _, _)| {
|
|
chroot_path
|
|
.join("dev")
|
|
.join(name)
|
|
.metadata()
|
|
.is_ok_and(|meta| meta.file_type().is_char_device())
|
|
})
|
|
}
|
|
|
|
fn create_device_nodes(
|
|
chroot_path: &Path,
|
|
ctx: Arc<context::Context>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
// Ensure /dev directory exists
|
|
fs::create_dir_all(chroot_path.join("dev"))?;
|
|
|
|
// Check if we're running as root
|
|
let is_root = crate::utils::root::is_root()?;
|
|
|
|
for (name, major, minor) in Self::DEVICE_NODES {
|
|
let path = chroot_path.join("dev").join(name);
|
|
|
|
// Clear whatever is there: the empty regular file the
|
|
// extraction leaves behind, or a node from an earlier run.
|
|
let _ = ctx
|
|
.command("rm")
|
|
.arg("-f")
|
|
.arg(path.to_string_lossy().to_string())
|
|
.status();
|
|
|
|
// Create the node using mknod (with sudo if not root)
|
|
let mut cmd = ctx.command(if is_root { "mknod" } else { "sudo" });
|
|
if !is_root {
|
|
cmd.arg("mknod");
|
|
}
|
|
let status = cmd
|
|
.arg("-m")
|
|
.arg("666")
|
|
.arg(path.to_string_lossy().to_string())
|
|
.arg("c")
|
|
.arg(major.to_string())
|
|
.arg(minor.to_string())
|
|
.status()?;
|
|
if !status.success() {
|
|
return Err(format!("Failed to create device node /dev/{name}").into());
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Bind mount /proc from host into the chroot
|
|
/// This is done before entering the unshare namespace, so it works in containers
|
|
fn bind_mount_proc(
|
|
chroot_path: &Path,
|
|
ctx: Arc<context::Context>,
|
|
) -> Result<(), Box<dyn Error>> {
|
|
let proc_path = chroot_path.join("proc");
|
|
|
|
// Ensure /proc directory exists in chroot
|
|
fs::create_dir_all(&proc_path)?;
|
|
|
|
// Check if we're running as root
|
|
let is_root = crate::utils::root::is_root()?;
|
|
|
|
// Bind mount host's /proc into chroot (with sudo if not root)
|
|
let mut cmd = ctx.command(if is_root { "mount" } else { "sudo" });
|
|
if !is_root {
|
|
cmd.arg("mount");
|
|
}
|
|
let status = cmd
|
|
.arg("--bind")
|
|
.arg("/proc")
|
|
.arg(proc_path.to_string_lossy().to_string())
|
|
.status()?;
|
|
|
|
if !status.success() {
|
|
log::warn!(
|
|
"Could not bind-mount /proc into chroot at {}. Some packages may not install correctly.",
|
|
proc_path.display()
|
|
);
|
|
} else {
|
|
log::debug!("Bind-mounted /proc into chroot at {}", proc_path.display());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Mark the build as successful, which will trigger chroot cleanup on drop
|
|
pub fn mark_build_successful(&mut self) {
|
|
self.build_succeeded = true;
|
|
}
|
|
|
|
/// The staged build root inside the chroot: the stable
|
|
/// `/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.
|
|
pub fn build_root(&self) -> Result<String, Box<dyn Error>> {
|
|
match &self.session {
|
|
Some(s) => {
|
|
std::fs::create_dir_all(s.chroot_path().join("tmp/pkh-build"))?;
|
|
Ok(s.build_root())
|
|
}
|
|
None => Ok(self.ephemeral_ctx.create_temp_dir()?),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for EphemeralContextGuard {
|
|
fn drop(&mut self) {
|
|
// On Ctrl+C the interrupt watchdog owns the chroot teardown through
|
|
// the registered hook: duplicating it here would race the hook's
|
|
// umount/rm (mounts vanish under each other). Dropping this guard
|
|
// would normally deregister the hook, so while the watchdog runs it
|
|
// must be leaked instead to keep it registered (if it was already
|
|
// drained, forgetting is a harmless no-op).
|
|
if crate::interrupt::interrupted() {
|
|
context::manager().set_current_ephemeral(self.previous_context.clone());
|
|
// The record keeps its metadata: record the aborted build
|
|
// honestly before the hook tears the per-run state down —
|
|
// failed, and kept_upper only if a staged tree exists.
|
|
if let Some(session) = &self.session {
|
|
session.record_interrupted(self.keep_upper);
|
|
}
|
|
if let Some(hook) = self.cleanup_hook.take() {
|
|
std::mem::forget(hook);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Deregister the interrupt-time cleanup hook first: the normal
|
|
// cleanup below takes care of the chroot, so the hook must not fire
|
|
// afterwards. (If a SIGINT arrived mid-drop and the hook is already
|
|
// running concurrently, deregistration simply does not find it —
|
|
// both paths are individually idempotent and failure-tolerant.)
|
|
if let Some(mut cleanup_hook) = self.cleanup_hook.take() {
|
|
cleanup_hook.deregister();
|
|
}
|
|
|
|
log::debug!("Cleaning up ephemeral context ({:?})...", self.chroot_path);
|
|
|
|
// Clean up any overlay mounts before resetting the context. This
|
|
// explicitly targets the context this guard created — never
|
|
// `context::current()`, which a concurrent build may have re-pointed
|
|
// at its own chroot. The actual unmount commands run via the parent
|
|
// (base) context, so they work regardless.
|
|
if let Err(e) = self.ephemeral_ctx.cleanup() {
|
|
log::warn!("Failed to clean up overlay mounts: {}", e);
|
|
}
|
|
|
|
// Restore the context that was current when this guard was created,
|
|
// not whatever is globally current at drop time (another concurrent
|
|
// build's override may be installed there). This only swaps the
|
|
// in-memory handle: the persisted configuration still names the
|
|
// context selected by the user, as `set_current_ephemeral` never
|
|
// touches it.
|
|
context::manager().set_current_ephemeral(self.previous_context.clone());
|
|
|
|
// Session mode: the record's per-run chroot comes down in every
|
|
// case (nothing chroot-shaped is persisted — success, failure and
|
|
// interrupt all end the run), then the record is retained:
|
|
// metadata and log always, the staged tree exactly for
|
|
// `--incremental` runs.
|
|
if let Some(session) = &self.session {
|
|
let proc_path = self.chroot_path.join("proc");
|
|
let is_root = crate::utils::root::is_root().unwrap_or(false);
|
|
let _ = if is_root {
|
|
self.base_ctx.command("umount").arg(&proc_path).status()
|
|
} else {
|
|
self.base_ctx
|
|
.command("sudo")
|
|
.arg("umount")
|
|
.arg(&proc_path)
|
|
.status()
|
|
};
|
|
|
|
session.remove_run_state();
|
|
session.retain_upper(self.keep_upper, self.build_succeeded);
|
|
return;
|
|
}
|
|
|
|
// Remove chroot directory only if build succeeded
|
|
if self.build_succeeded {
|
|
log::debug!(
|
|
"Build succeeded, removing chroot directory: {}",
|
|
self.chroot_path.display()
|
|
);
|
|
// Check if we're running as root to avoid unnecessary sudo
|
|
let is_root = crate::utils::root::is_root().unwrap_or(false);
|
|
|
|
// Unmount everything under the chroot before removing it:
|
|
// /proc, and the shared apt download cache bind-mounted over
|
|
// /var/cache/apt/archives — an rm -rf through that mount
|
|
// would delete the shared cache itself.
|
|
for mount in host_mounts_under(&self.chroot_path).into_iter().rev() {
|
|
if !unmount_path(&mount, is_root) {
|
|
log::warn!(
|
|
"Failed to unmount {} under {}",
|
|
mount.display(),
|
|
self.chroot_path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
let result = if is_root {
|
|
self.base_ctx
|
|
.command("rm")
|
|
.arg("-rf")
|
|
.arg(&self.chroot_path)
|
|
.status()
|
|
} else {
|
|
self.base_ctx
|
|
.command("sudo")
|
|
.arg("rm")
|
|
.arg("-rf")
|
|
.arg(&self.chroot_path)
|
|
.status()
|
|
};
|
|
|
|
match result {
|
|
Ok(status) => {
|
|
if !status.success() {
|
|
log::error!(
|
|
"Failed to remove chroot directory {}",
|
|
self.chroot_path.display()
|
|
);
|
|
} else {
|
|
log::debug!(
|
|
"Successfully removed chroot directory: {}",
|
|
self.chroot_path.display()
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::error!(
|
|
"Failed to execute cleanup command for {}: {}",
|
|
self.chroot_path.display(),
|
|
e
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
log::debug!(
|
|
"Build did not succeed or was not marked as successful, keeping chroot directory: {}",
|
|
self.chroot_path.display()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod chroot_cleanup_tests {
|
|
use super::*;
|
|
|
|
/// The tarball cache name: per series/arch as before, and a distinct
|
|
/// deterministic name per pinned mirror — hashed BEFORE the
|
|
/// `-buildd.tar.xz` suffix, which is the pattern prune matches (a
|
|
/// pinned tarball pruning like any other).
|
|
#[test]
|
|
fn tarball_filename_keys_the_mirror_in() {
|
|
assert_eq!(
|
|
chroot_tarball_filename("noble", None, None),
|
|
"noble-buildd.tar.xz"
|
|
);
|
|
assert_eq!(
|
|
chroot_tarball_filename("noble", Some("riscv64"), None),
|
|
"noble-riscv64-buildd.tar.xz"
|
|
);
|
|
|
|
let pinned =
|
|
chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3142/ubuntu"));
|
|
assert!(pinned.starts_with("noble-amd64-"));
|
|
assert!(pinned.ends_with("-buildd.tar.xz"));
|
|
// Deterministic, and distinct per mirror (and from the unpinned).
|
|
assert_eq!(
|
|
pinned,
|
|
chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3142/ubuntu"))
|
|
);
|
|
assert_ne!(
|
|
pinned,
|
|
chroot_tarball_filename("noble", Some("amd64"), Some("http://localhost:3143/ubuntu"))
|
|
);
|
|
assert_ne!(
|
|
pinned,
|
|
chroot_tarball_filename("noble", Some("amd64"), None)
|
|
);
|
|
}
|
|
|
|
/// /proc/mounts path fields use octal escapes for whitespace and
|
|
/// backslashes; anything else must be kept verbatim.
|
|
#[test]
|
|
fn mount_field_unescaping_decodes_octal_escapes() {
|
|
assert_eq!(unescape_mount_field("/mnt/plain"), "/mnt/plain");
|
|
assert_eq!(
|
|
unescape_mount_field("/mnt/with\\040space"),
|
|
"/mnt/with space"
|
|
);
|
|
assert_eq!(unescape_mount_field("/mnt/with\\011tab"), "/mnt/with\ttab");
|
|
assert_eq!(unescape_mount_field("back\\134slash"), "back\\slash");
|
|
// Not an escape sequence: kept verbatim
|
|
assert_eq!(unescape_mount_field("back\\9slash"), "back\\9slash");
|
|
assert_eq!(unescape_mount_field("trailing\\"), "trailing\\");
|
|
}
|
|
|
|
/// Interrupt cleanup of a path that has no mounts and does not exist must
|
|
/// be a harmless no-op (no panic, nothing left behind).
|
|
#[test]
|
|
fn sigint_cleanup_of_missing_chroot_is_a_noop() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let missing = dir.path().join("no-such-chroot");
|
|
sigint_cleanup_chroot(&missing);
|
|
assert!(!missing.exists());
|
|
}
|
|
|
|
/// What the tar crate leaves after extracting the chroot tarball:
|
|
/// every device entry materializes as an empty *regular* file. The
|
|
/// readiness guard must reject that shape, or `/dev/null` stays a
|
|
/// regular file and a reader of a redirected write sees garbage (the
|
|
/// `dpkg-gencontrol -T/dev/null` substvars abort).
|
|
#[test]
|
|
fn device_nodes_reject_regular_files_left_by_extraction() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let chroot = dir.path().join("chroot");
|
|
std::fs::create_dir_all(chroot.join("dev")).unwrap();
|
|
for (name, _, _) in EphemeralContextGuard::DEVICE_NODES {
|
|
std::fs::write(chroot.join("dev").join(name), b"").unwrap();
|
|
}
|
|
|
|
assert!(
|
|
!EphemeralContextGuard::device_nodes_ready(&chroot),
|
|
"regular files must not count as ready device nodes"
|
|
);
|
|
}
|
|
|
|
/// The ready case without root: a symlink to the host's real node,
|
|
/// whose metadata resolves to the char device.
|
|
#[test]
|
|
fn device_nodes_accept_real_char_devices() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let chroot = dir.path().join("chroot");
|
|
std::fs::create_dir_all(chroot.join("dev")).unwrap();
|
|
for (name, _, _) in EphemeralContextGuard::DEVICE_NODES {
|
|
std::os::unix::fs::symlink(format!("/dev/{name}"), chroot.join("dev").join(name))
|
|
.unwrap();
|
|
}
|
|
|
|
assert!(EphemeralContextGuard::device_nodes_ready(&chroot));
|
|
}
|
|
|
|
/// A real directory with no mounts under it is simply removed. Skipped
|
|
/// when non-root without working non-interactive sudo, since removal then
|
|
/// legitimately fails (and is only logged).
|
|
#[test]
|
|
fn sigint_cleanup_removes_an_unmounted_directory() {
|
|
let is_root = unsafe { libc::geteuid() } == 0;
|
|
if !is_root
|
|
&& !privileged_command("true", false)
|
|
.status()
|
|
.is_ok_and(|s| s.success())
|
|
{
|
|
return;
|
|
}
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let chroot = dir.path().join("chroot");
|
|
std::fs::create_dir_all(chroot.join("rootfs")).unwrap();
|
|
std::fs::write(chroot.join("rootfs").join("file.txt"), "data").unwrap();
|
|
|
|
sigint_cleanup_chroot(&chroot);
|
|
|
|
assert!(!chroot.exists());
|
|
}
|
|
|
|
/// The record interrupt teardown removes the per-run state and keeps
|
|
/// the manifest and log; an `--incremental` record keeps its staged
|
|
/// tree, a plain one loses it.
|
|
#[test]
|
|
fn sigint_record_teardown_keeps_metadata_and_drops_the_chroot() {
|
|
let is_root = unsafe { libc::geteuid() } == 0;
|
|
if !is_root
|
|
&& !privileged_command("true", false)
|
|
.status()
|
|
.is_ok_and(|s| s.success())
|
|
{
|
|
return;
|
|
}
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let incremental = dir.path().join("incremental");
|
|
let plain = dir.path().join("plain");
|
|
for root in [&incremental, &plain] {
|
|
std::fs::create_dir_all(root.join("chroot/rootfs")).unwrap();
|
|
std::fs::create_dir_all(root.join("tree-upper/src")).unwrap();
|
|
std::fs::create_dir_all(root.join("log")).unwrap();
|
|
std::fs::write(root.join("manifest.json"), "{}").unwrap();
|
|
std::fs::write(root.join("log/build.log"), "output\n").unwrap();
|
|
std::fs::write(root.join("host-files.list"), "f\t1\t1\ta\n").unwrap();
|
|
}
|
|
|
|
sigint_cleanup_record(&incremental, true);
|
|
sigint_cleanup_record(&plain, false);
|
|
|
|
for root in [&incremental, &plain] {
|
|
assert!(!root.join("chroot").exists(), "the chroot is per-run");
|
|
assert!(root.join("manifest.json").is_file(), "metadata stays");
|
|
assert!(root.join("log/build.log").is_file(), "the log stays");
|
|
}
|
|
assert!(
|
|
incremental.join("tree-upper/src").exists(),
|
|
"an interrupted incremental run keeps its staged tree"
|
|
);
|
|
assert!(
|
|
incremental.join("host-files.list").is_file(),
|
|
"the snapshot stays with the staged tree"
|
|
);
|
|
assert!(
|
|
!plain.join("tree-upper").exists(),
|
|
"an interrupted plain run drops its staged tree"
|
|
);
|
|
assert!(
|
|
!plain.join("host-files.list").exists(),
|
|
"the snapshot goes with the staged tree"
|
|
);
|
|
}
|
|
|
|
/// clear_dir removes content a plain `rm -rf` cannot: a root-owned
|
|
/// 0700 directory (the shape of an overlay workdir left by a crashed
|
|
/// build). Skipped when neither root nor working non-interactive sudo
|
|
/// is available, since escalation is then legitimately impossible.
|
|
#[test]
|
|
fn clear_dir_removes_root_owned_subdirectories() {
|
|
let is_root = unsafe { libc::geteuid() } == 0;
|
|
if !is_root
|
|
&& !privileged_command("true", false)
|
|
.status()
|
|
.is_ok_and(|s| s.success())
|
|
{
|
|
return;
|
|
}
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cleared = dir.path().join("chroot");
|
|
std::fs::create_dir_all(cleared.join("stale")).unwrap();
|
|
std::fs::write(cleared.join("stale").join("f"), "x").unwrap();
|
|
// Root-owned, non-traversable: the crashed-workdir shape.
|
|
let _ = privileged_command("chmod", is_root)
|
|
.arg("-R")
|
|
.arg("0700")
|
|
.arg(cleared.join("stale"))
|
|
.status();
|
|
let _ = privileged_command("chown", is_root)
|
|
.arg("-R")
|
|
.arg("root:root")
|
|
.arg(cleared.join("stale"))
|
|
.status();
|
|
|
|
clear_dir(&cleared).expect("clear_dir removes root-owned leftovers");
|
|
|
|
let read = std::fs::read_dir(&cleared).unwrap();
|
|
assert_eq!(read.count(), 0, "the directory must be empty");
|
|
}
|
|
|
|
/// The shared apt cache is keyed per series and chroot architecture
|
|
/// (the foreign arch for qemu-binfmt builds, the host one
|
|
/// otherwise), like the chroot tarball cache.
|
|
#[test]
|
|
fn apt_cache_dirs_are_keyed_per_series_and_arch() {
|
|
assert_eq!(
|
|
apt_cache_dir("noble", Some("riscv64")),
|
|
PathBuf::from("/var/tmp/pkh/apt-cache/noble-riscv64")
|
|
);
|
|
let native = apt_cache_dir("noble", None);
|
|
assert_eq!(
|
|
native,
|
|
PathBuf::from(format!(
|
|
"/var/tmp/pkh/apt-cache/noble-{}",
|
|
crate::get_current_arch()
|
|
))
|
|
);
|
|
assert_ne!(native, apt_cache_dir("sid", None), "per series");
|
|
}
|
|
|
|
/// The apt configuration keeping downloaded packages lands in the
|
|
/// chroot's apt.conf.d, so installs populate the shared cache.
|
|
#[test]
|
|
fn apt_keep_conf_lands_in_the_chroot() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let chroot = dir.path().join("chroot");
|
|
write_apt_keep_conf(&chroot).unwrap();
|
|
let conf = chroot.join("etc/apt/apt.conf.d").join(APT_KEEP_CONF);
|
|
let content = fs::read_to_string(&conf).unwrap();
|
|
assert_eq!(content, "APT::Keep-Downloaded-Packages \"true\";\n");
|
|
|
|
// Idempotent: a second write does not fail or duplicate.
|
|
write_apt_keep_conf(&chroot).unwrap();
|
|
assert_eq!(fs::read_to_string(&conf).unwrap(), content);
|
|
}
|
|
|
|
/// The ccache shim installs the four compiler symlinks pointing at
|
|
/// the ccache binary, and reinstalls cleanly over an existing one.
|
|
#[test]
|
|
fn ccache_shim_installs_the_four_compiler_links() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let chroot = dir.path().join("chroot");
|
|
|
|
install_ccache_shim(&chroot).unwrap();
|
|
|
|
let shim_dir = chroot.join(CCACHE_SHIM_DIR.trim_start_matches('/'));
|
|
for compiler in ["gcc", "g++", "cc", "c++"] {
|
|
let target = fs::read_link(shim_dir.join(compiler))
|
|
.unwrap_or_else(|_| panic!("the {compiler} shim must be a symlink"));
|
|
assert_eq!(target, Path::new("/usr/bin/ccache"));
|
|
}
|
|
|
|
// A second install refreshes the links instead of failing.
|
|
install_ccache_shim(&chroot).unwrap();
|
|
assert!(fs::symlink_metadata(shim_dir.join("gcc")).is_ok());
|
|
}
|
|
}
|