diff --git a/Cargo.lock b/Cargo.lock index 2ac41ec..c8e3e48 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -406,9 +406,10 @@ dependencies = [ "serde", "sha2", "suppaftp", + "sys-mount", "tar", "tempfile", - "thiserror", + "thiserror 2.0.18", "toml", "url", "walkdir", @@ -1653,7 +1654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1920,6 +1921,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "socket2" version = "0.6.2" @@ -1970,7 +1982,7 @@ dependencies = [ "futures-lite", "lazy-regex", "log", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -2004,6 +2016,19 @@ dependencies = [ "syn", ] +[[package]] +name = "sys-mount" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d361f5431256ea04c657c0dce767ad95e24fb054d99f0b8a9275cb1c9ea14bfb" +dependencies = [ + "bitflags", + "libc", + "smart-default", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "tar" version = "0.4.44" @@ -2028,13 +2053,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -2202,9 +2247,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" diff --git a/Cargo.toml b/Cargo.toml index 4feaadd..0f0f348 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ reqwest = { version = "0.13.2", default-features = false, features = ["blocking" filetime = "0.2.27" clap_complete = "4.5.66" clap_mangen = "0.2.31" +sys-mount = { version = "3.1.0", default-features = false } [dev-dependencies] tempfile = "=3.25.0" diff --git a/src/commands.rs b/src/commands.rs index c1e2d88..07eed35 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -4,8 +4,10 @@ use crate::{ signing, source, staging, ui, }; use anyhow::{Context, Result}; +use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle}; use std::collections::HashMap; use std::fs; +use std::io::IsTerminal; use std::path::{Path, PathBuf}; fn parse_licenses_from_toml(metadata: &toml::Value) -> Vec { @@ -920,6 +922,18 @@ fn human_bytes(bytes: u64) -> String { } } +fn binary_arch_from_filename(filename: &str) -> String { + let stem = filename + .strip_suffix(".depot.pkg.tar.zst") + .unwrap_or(filename); + let mut parts = stem.rsplitn(4, '-'); + parts + .next() + .filter(|s| !s.is_empty()) + .unwrap_or(std::env::consts::ARCH) + .to_string() +} + fn print_plan_summary(plan: &planner::ExecutionPlan) { let summary = plan.summary(); ui::info(format!( @@ -1027,24 +1041,46 @@ fn execute_install_plan_with_child_commands( "Downloading {} binary package(s) and detached signatures...", binary_phase_items.len() )); - for (idx, item) in binary_phase_items.iter().enumerate() { - ui::info(format!( - " [{}/{}] {}-{} ({})", - idx + 1, - binary_phase_items.len(), + let use_tty_progress = std::io::stderr().is_terminal(); + for item in &binary_phase_items { + let label = format!( + "{}-{}-{}", item.record.name, item.record.version, - item.repo_name - )); + binary_arch_from_filename(&item.record.filename) + ); + let pb = ProgressBar::new(item.record.size.max(1)); + pb.set_draw_target(if use_tty_progress { + ProgressDrawTarget::stderr() + } else { + ProgressDrawTarget::hidden() + }); + pb.set_style( + ProgressStyle::default_bar() + .template("{prefix:.bold} [{bar:40.cyan/blue}] {eta}") + .unwrap_or_else(|_| ProgressStyle::default_bar()) + .progress_chars("#>-"), + ); + pb.set_prefix(label); + let repo_cfg = config .binary_repos .get(&item.repo_name) .with_context(|| format!("Binary repo '{}' not found in config", item.repo_name))?; - let cached = db::repo::cache_binary_package_archive( + let mut progress_cb = |downloaded: u64, total: Option| { + if let Some(t) = total + && t > 0 + { + pb.set_length(t); + } + pb.set_position(downloaded); + }; + let cached = db::repo::cache_binary_package_archive_with_progress( &item.repo_name, repo_cfg, &item.record, &config.package_cache_dir, + Some(&mut progress_cb), ) .with_context(|| { format!( @@ -1052,6 +1088,7 @@ fn execute_install_plan_with_child_commands( item.record.filename, item.repo_name ) })?; + pb.finish_and_clear(); binary_archives.insert( (item.repo_name.clone(), item.record.filename.clone()), cached, @@ -1062,14 +1099,20 @@ fn execute_install_plan_with_child_commands( "Verifying checksums for {} binary package(s)...", binary_phase_items.len() )); - for (idx, item) in binary_phase_items.iter().enumerate() { - ui::info(format!( - " [{}/{}] {}-{}", - idx + 1, - binary_phase_items.len(), - item.record.name, - item.record.version - )); + let checksum_pb = ProgressBar::new(binary_phase_items.len() as u64); + checksum_pb.set_draw_target(if use_tty_progress { + ProgressDrawTarget::stderr() + } else { + ProgressDrawTarget::hidden() + }); + checksum_pb.set_style( + ProgressStyle::default_bar() + .template("{prefix:.bold} [{bar:40.cyan/blue}] {pos}/{len} {eta}") + .unwrap_or_else(|_| ProgressStyle::default_bar()) + .progress_chars("#>-"), + ); + checksum_pb.set_prefix("checksums"); + for item in &binary_phase_items { let cached = binary_archives .get(&(item.repo_name.clone(), item.record.filename.clone())) .with_context(|| { @@ -1085,20 +1128,28 @@ fn execute_install_plan_with_child_commands( item.record.filename, item.repo_name ) })?; + checksum_pb.inc(1); } + checksum_pb.finish_and_clear(); ui::info(format!( "Verifying detached signatures for {} binary package(s)...", binary_phase_items.len() )); - for (idx, item) in binary_phase_items.iter().enumerate() { - ui::info(format!( - " [{}/{}] {}-{}", - idx + 1, - binary_phase_items.len(), - item.record.name, - item.record.version - )); + let signature_pb = ProgressBar::new(binary_phase_items.len() as u64); + signature_pb.set_draw_target(if use_tty_progress { + ProgressDrawTarget::stderr() + } else { + ProgressDrawTarget::hidden() + }); + signature_pb.set_style( + ProgressStyle::default_bar() + .template("{prefix:.bold} [{bar:40.cyan/blue}] {pos}/{len} {eta}") + .unwrap_or_else(|_| ProgressStyle::default_bar()) + .progress_chars("#>-"), + ); + signature_pb.set_prefix("signatures"); + for item in &binary_phase_items { let repo_cfg = config .binary_repos .get(&item.repo_name) @@ -1124,7 +1175,9 @@ fn execute_install_plan_with_child_commands( item.record.filename, item.repo_name ) })?; + signature_pb.inc(1); } + signature_pb.finish_and_clear(); } let exe = std::env::current_exe().context("Failed to locate depot executable")?; @@ -1162,16 +1215,6 @@ fn execute_install_plan_with_child_commands( } } planner::PlanOrigin::Binary { repo_name, record } => { - ui::info(format!( - "[{}/{}] installing {} {}-{} from binary:{}", - idx + 1, - total_steps, - record.name, - record.version, - record.revision, - repo_name - )); - let cached = binary_archives .get(&(repo_name.clone(), record.filename.clone())) .with_context(|| { @@ -1191,6 +1234,8 @@ fn execute_install_plan_with_child_commands( planner::PlanOrigin::Installed => {} } } + + install::scripts::run_deferred_hooks_if_possible(rootfs)?; Ok(()) } @@ -1202,7 +1247,9 @@ pub fn run(cli: Cli) -> Result<()> { spec_or_archive, spec, } => { - warn_if_running_as_root_for_build("install", &cli.rootfs); + if !crate::fakeroot::is_root() { + anyhow::bail!("The 'install' command must be run as root"); + } let mut spec_path = spec.unwrap_or(spec_or_archive); // Load configuration early so we can use the configured repo clone dir @@ -1535,6 +1582,8 @@ pub fn run(cli: Cli) -> Result<()> { )); } + install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?; + if cli.clean { clean_build_workspace(&config)?; } @@ -1820,6 +1869,8 @@ pub fn run(cli: Cli) -> Result<()> { lib32_spec.package.name, lib32_spec.package.version )); } + + install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?; } else { if !cli.lib32_only { for out in pkg_spec.outputs() { diff --git a/src/db/repo.rs b/src/db/repo.rs index d99de69..72754fb 100644 --- a/src/db/repo.rs +++ b/src/db/repo.rs @@ -1661,9 +1661,15 @@ fn download_binary_package_archive( client: &reqwest::blocking::Client, pkg_url: &str, tmp_path: &Path, + progress_cb: &mut Option<&mut dyn FnMut(u64, Option)>, ) -> Result<()> { match copy_file_url_to_path(pkg_url, tmp_path)? { - FileUrlCopyOutcome::Copied => {} + FileUrlCopyOutcome::Copied => { + if let Some(cb) = progress_cb.as_mut() { + let total = fs::metadata(tmp_path).map(|m| m.len()).unwrap_or(0); + cb(total, Some(total)); + } + } FileUrlCopyOutcome::Missing => { anyhow::bail!("Failed to fetch {}: local file not found", pkg_url); } @@ -1676,10 +1682,29 @@ fn download_binary_package_archive( anyhow::bail!("Failed to fetch {}: HTTP {}", pkg_url, resp.status()); } + let total = resp.content_length(); + if let Some(cb) = progress_cb.as_mut() { + cb(0, total); + } + let mut out = fs::File::create(tmp_path) .with_context(|| format!("Failed to create {}", tmp_path.display()))?; - std::io::copy(&mut resp, &mut out) - .with_context(|| format!("Failed to save {}", tmp_path.display()))?; + let mut downloaded = 0u64; + let mut buf = [0u8; 64 * 1024]; + loop { + let n = resp + .read(&mut buf) + .with_context(|| format!("Failed to read {}", pkg_url))?; + if n == 0 { + break; + } + out.write_all(&buf[..n]) + .with_context(|| format!("Failed to save {}", tmp_path.display()))?; + downloaded = downloaded.saturating_add(n as u64); + if let Some(cb) = progress_cb.as_mut() { + cb(downloaded, total); + } + } out.flush() .with_context(|| format!("Failed to flush {}", tmp_path.display()))?; } @@ -1744,18 +1769,13 @@ fn verify_binary_package_signature( ); } - let verified_key = verify_with_any_trusted_public_key(rootfs, pkg_path, sig_path) + let _verified_key = verify_with_any_trusted_public_key(rootfs, pkg_path, sig_path) .with_context(|| { format!( "Failed to verify detached package signature for {}", pkg_path.display() ) })?; - crate::log_info!( - "Verified detached package signature for '{}' using {}", - pkg_path.display(), - verified_key.display() - ); Ok(()) } @@ -1766,6 +1786,19 @@ pub fn cache_binary_package_archive( repo: &crate::config::BinaryRepo, rec: &BinaryRepoPackageRecord, package_cache_dir: &Path, +) -> Result { + cache_binary_package_archive_with_progress(repo_name, repo, rec, package_cache_dir, None) +} + +/// Ensure a binary package archive and detached signature are present in cache +/// without performing checksum/signature verification, optionally reporting +/// download progress. +pub fn cache_binary_package_archive_with_progress( + repo_name: &str, + repo: &crate::config::BinaryRepo, + rec: &BinaryRepoPackageRecord, + package_cache_dir: &Path, + mut progress_cb: Option<&mut dyn FnMut(u64, Option)>, ) -> Result { let machine_arch = std::env::consts::ARCH; let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { @@ -1790,8 +1823,7 @@ pub fn cache_binary_package_archive( .context("Failed to build HTTP client for binary package fetch")?; let package_downloaded = if !package_path.exists() { - crate::log_info!("Fetching binary package: {}", pkg_url); - download_binary_package_archive(&client, &pkg_url, &tmp_path)?; + download_binary_package_archive(&client, &pkg_url, &tmp_path, &mut progress_cb)?; fs::rename(&tmp_path, &package_path).with_context(|| { format!( "Failed to move {} to {}", @@ -1801,7 +1833,12 @@ pub fn cache_binary_package_archive( })?; true } else { - crate::log_info!("Using cached binary package: {}", package_path.display()); + if let Some(cb) = progress_cb.as_mut() { + let total = fs::metadata(&package_path) + .with_context(|| format!("Failed to stat {}", package_path.display()))? + .len(); + cb(total, Some(total)); + } false }; diff --git a/src/install/scripts.rs b/src/install/scripts.rs index 39fbf2a..b5557ff 100644 --- a/src/install/scripts.rs +++ b/src/install/scripts.rs @@ -4,11 +4,14 @@ use crate::fakeroot; use crate::package::PackageSpec; use anyhow::{Context, Result, bail}; use std::fs; +use std::io::{BufRead, Write}; use std::path::{Component, Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; +use sys_mount::{Mount, MountFlags, Unmount, UnmountFlags}; use walkdir::WalkDir; const STAGED_SCRIPTS_DIR: &str = "scripts"; +const DEFERRED_HOOKS_FILE_REL: &str = "var/lib/depot/deferred-hooks.tsv"; const ALL_HOOKS: [Hook; 6] = [ Hook::PreInstall, Hook::PostInstall, @@ -62,6 +65,18 @@ impl Hook { } } + fn from_canonical_name(name: &str) -> Option { + match name { + "pre_install" => Some(Hook::PreInstall), + "post_install" => Some(Hook::PostInstall), + "pre_update" => Some(Hook::PreUpdate), + "post_update" => Some(Hook::PostUpdate), + "pre_remove" => Some(Hook::PreRemove), + "post_remove" => Some(Hook::PostRemove), + _ => None, + } + } + fn candidate_names(self) -> [String; 6] { let canonical = self.canonical_name(); let dashed = canonical.replace('_', "-"); @@ -102,6 +117,13 @@ impl Hook { } } +#[derive(Debug, Clone)] +struct DeferredHook { + pkg_name: String, + hook: Hook, + script_rel: PathBuf, +} + /// Return the staged scripts directory path inside a package staging tree. pub fn staged_scripts_dir(destdir: &Path) -> PathBuf { destdir.join(STAGED_SCRIPTS_DIR) @@ -233,61 +255,216 @@ pub fn run_hook_if_present( script_path.display() ); - let status = run_script_with_rootfs_context(&script_path, rootfs, pkg_name, hook)?; - - if !status.success() { - bail!( - "Lifecycle hook {} failed: {}", - hook.canonical_name(), - script_path.display() - ); + match run_script_with_rootfs_context(&script_path, rootfs, pkg_name, hook)? { + HookRunOutcome::Ran(status) => { + if !status.success() { + bail!( + "Lifecycle hook {} failed: {}", + hook.canonical_name(), + script_path.display() + ); + } + } + HookRunOutcome::Deferred => {} } Ok(true) } +enum HookRunOutcome { + Ran(std::process::ExitStatus), + Deferred, +} + +#[derive(Default)] +struct ChrootMountGuard { + mounted: Vec, +} + +impl ChrootMountGuard { + fn mount_path( + &mut self, + source: &Path, + target: &Path, + fstype: Option<&str>, + flags: MountFlags, + data: Option<&str>, + ) -> Result<()> { + let mut builder = Mount::builder().flags(flags); + if let Some(fs) = fstype { + builder = builder.fstype(fs); + } + if let Some(options) = data { + builder = builder.data(options); + } + let mount = builder + .mount(source, target) + .with_context(|| format!("Failed to mount {}", target.display()))?; + self.mounted.push(mount); + Ok(()) + } +} + +impl Drop for ChrootMountGuard { + fn drop(&mut self) { + for mount in self.mounted.iter().rev() { + if mount.unmount(UnmountFlags::empty()).is_ok() { + continue; + } + let _ = mount.unmount(UnmountFlags::DETACH); + } + } +} + +fn should_use_chroot(rootfs: &Path) -> bool { + let canonical_root = fs::canonicalize("/").ok(); + let canonical_rootfs = fs::canonicalize(rootfs).ok(); + match (canonical_rootfs, canonical_root) { + (Some(target), Some(root)) => target != root, + _ => rootfs != Path::new("/"), + } +} + +fn mount_chroot_filesystems(rootfs: &Path) -> Result { + let proc_dir = rootfs.join("proc"); + let dev_dir = rootfs.join("dev"); + let dev_pts_dir = dev_dir.join("pts"); + let sys_dir = rootfs.join("sys"); + + fs::create_dir_all(&proc_dir) + .with_context(|| format!("Failed to create {}", proc_dir.display()))?; + fs::create_dir_all(&dev_dir) + .with_context(|| format!("Failed to create {}", dev_dir.display()))?; + fs::create_dir_all(&dev_pts_dir) + .with_context(|| format!("Failed to create {}", dev_pts_dir.display()))?; + fs::create_dir_all(&sys_dir) + .with_context(|| format!("Failed to create {}", sys_dir.display()))?; + + let mut guard = ChrootMountGuard::default(); + guard.mount_path( + Path::new("proc"), + &proc_dir, + Some("proc"), + MountFlags::NODEV | MountFlags::NOEXEC | MountFlags::NOSUID, + None, + )?; + guard.mount_path(Path::new("/dev"), &dev_dir, None, MountFlags::BIND, None)?; + guard.mount_path( + Path::new("sysfs"), + &sys_dir, + Some("sysfs"), + MountFlags::NODEV | MountFlags::NOEXEC | MountFlags::NOSUID, + None, + )?; + if let Err(_e) = guard.mount_path( + Path::new("devpts"), + &dev_pts_dir, + Some("devpts"), + MountFlags::NOSUID | MountFlags::NOEXEC, + Some("gid=5,mode=620"), + ) { + guard.mount_path( + Path::new("devpts"), + &dev_pts_dir, + Some("devpts"), + MountFlags::NOSUID | MountFlags::NOEXEC, + None, + )?; + } + + maybe_bind_host_file_into_chroot(&mut guard, rootfs, Path::new("/etc/resolv.conf"))?; + Ok(guard) +} + +fn maybe_bind_host_file_into_chroot( + guard: &mut ChrootMountGuard, + rootfs: &Path, + host_path: &Path, +) -> Result<()> { + if !host_path.exists() { + return Ok(()); + } + + let rel = host_path + .strip_prefix(Path::new("/")) + .with_context(|| format!("Expected absolute host path: {}", host_path.display()))?; + let target = rootfs.join(rel); + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + if !target.exists() { + fs::File::create(&target) + .with_context(|| format!("Failed to create {}", target.display()))?; + } + + if let Err(err) = guard.mount_path(host_path, &target, None, MountFlags::BIND, None) { + crate::log_warn!( + "Failed to bind-mount {} into chroot at {}: {}", + host_path.display(), + target.display(), + err + ); + } + Ok(()) +} + fn run_script_with_rootfs_context( script_path: &Path, rootfs: &Path, pkg_name: &str, hook: Hook, -) -> Result { +) -> Result { // When root and rootfs provides /bin/sh, run inside a real chroot so scripts // can rely on `cd /` and relative paths resolving within that rootfs. if fakeroot::is_root() - && rootfs.join("bin/sh").exists() + && should_use_chroot(rootfs) && let Ok(rel) = script_path.strip_prefix(rootfs) { - let rel_script = format!("./{}", rel.to_string_lossy()); - return Command::new("chroot") - .arg(rootfs) - .arg("/bin/sh") - .arg("-c") - .arg("export DEPOT_PACKAGE DEPOT_ROOTFS DEPOT_ACTION DEPOT_PHASE; cd / && exec /bin/sh \"$1\"") - .arg("sh") - .arg(rel_script) - .env("DEPOT_PACKAGE", pkg_name) - .env("DEPOT_ROOTFS", "/") - .env("DEPOT_ACTION", hook.action()) - .env("DEPOT_PHASE", hook.phase()) - .status() - .with_context(|| { - format!( - "Failed to execute lifecycle hook {} in chroot at {}", - hook.canonical_name(), - rootfs.display() - ) - }); + if rootfs.join("bin/sh").exists() { + let status = run_hook_script_in_chroot(rootfs, rel, pkg_name, hook, false)?; + return Ok(HookRunOutcome::Ran(status)); + } + + // Post hooks can be deferred until the target shell exists and chroot + // execution is available. + if matches!( + hook, + Hook::PostInstall | Hook::PostUpdate | Hook::PostRemove + ) { + enqueue_deferred_hook(rootfs, pkg_name, hook, rel)?; + crate::log_warn!( + "Deferred lifecycle hook {} for {} until {} has /bin/sh", + hook.canonical_name(), + pkg_name, + rootfs.display() + ); + return Ok(HookRunOutcome::Deferred); + } } // Fallback (non-root / no rootfs shell / script outside rootfs): // execute with host /bin/sh while setting cwd to rootfs, so relative paths // inside scripts resolve against that rootfs root. - Command::new("/bin/sh") - .arg(script_path) + let script_arg = if let Ok(rel) = script_path.strip_prefix(rootfs) { + PathBuf::from(format!("./{}", rel.to_string_lossy())) + } else { + script_path.to_path_buf() + }; + let rootfs_env = if rootfs.is_absolute() { + rootfs.to_path_buf() + } else { + std::env::current_dir() + .context("Failed to resolve current working directory for DEPOT_ROOTFS")? + .join(rootfs) + }; + + let status = Command::new("/bin/sh") + .arg(script_arg) .current_dir(rootfs) .env("DEPOT_PACKAGE", pkg_name) - .env("DEPOT_ROOTFS", rootfs) + .env("DEPOT_ROOTFS", &rootfs_env) .env("DEPOT_ACTION", hook.action()) .env("DEPOT_PHASE", hook.phase()) .status() @@ -297,7 +474,201 @@ fn run_script_with_rootfs_context( hook.canonical_name(), script_path.display() ) - }) + })?; + Ok(HookRunOutcome::Ran(status)) +} + +fn run_hook_script_in_chroot( + rootfs: &Path, + rel_script: &Path, + pkg_name: &str, + hook: Hook, + quiet: bool, +) -> Result { + let _mounts = mount_chroot_filesystems(rootfs)?; + let rel_script = format!("./{}", rel_script.to_string_lossy()); + let mut cmd = Command::new("chroot"); + cmd.arg(rootfs) + .arg("/bin/sh") + .arg("-c") + .arg("export DEPOT_PACKAGE DEPOT_ROOTFS DEPOT_ACTION DEPOT_PHASE; cd / && exec /bin/sh \"$1\"") + .arg("sh") + .arg(rel_script) + .env("DEPOT_PACKAGE", pkg_name) + .env("DEPOT_ROOTFS", "/") + .env("DEPOT_ACTION", hook.action()) + .env("DEPOT_PHASE", hook.phase()); + if quiet { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + cmd.status().with_context(|| { + format!( + "Failed to execute lifecycle hook {} in chroot at {}", + hook.canonical_name(), + rootfs.display() + ) + }) +} + +fn deferred_hooks_file(rootfs: &Path) -> PathBuf { + rootfs.join(DEFERRED_HOOKS_FILE_REL) +} + +fn read_deferred_hooks(path: &Path) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + + let file = + fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?; + let reader = std::io::BufReader::new(file); + let mut hooks = Vec::new(); + + for (idx, line) in reader.lines().enumerate() { + let line = + line.with_context(|| format!("Failed to read deferred hook line {}", idx + 1))?; + if line.trim().is_empty() { + continue; + } + let mut parts = line.splitn(3, '\t'); + let pkg_name = parts + .next() + .filter(|s| !s.is_empty()) + .with_context(|| format!("Malformed deferred hook line {} (package)", idx + 1))?; + let hook_name = parts + .next() + .filter(|s| !s.is_empty()) + .with_context(|| format!("Malformed deferred hook line {} (hook)", idx + 1))?; + let script_rel = parts + .next() + .filter(|s| !s.is_empty()) + .with_context(|| format!("Malformed deferred hook line {} (script)", idx + 1))?; + let hook = Hook::from_canonical_name(hook_name).with_context(|| { + format!("Unknown deferred hook '{}' on line {}", hook_name, idx + 1) + })?; + + hooks.push(DeferredHook { + pkg_name: pkg_name.to_string(), + hook, + script_rel: PathBuf::from(script_rel), + }); + } + + Ok(hooks) +} + +fn write_deferred_hooks(path: &Path, hooks: &[DeferredHook]) -> Result<()> { + if hooks.is_empty() { + if path.exists() { + fs::remove_file(path) + .with_context(|| format!("Failed to remove {}", path.display()))?; + } + return Ok(()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + + let mut f = + fs::File::create(path).with_context(|| format!("Failed to create {}", path.display()))?; + for item in hooks { + writeln!( + f, + "{}\t{}\t{}", + item.pkg_name, + item.hook.canonical_name(), + item.script_rel.display() + ) + .with_context(|| format!("Failed to write {}", path.display()))?; + } + f.flush() + .with_context(|| format!("Failed to flush {}", path.display()))?; + Ok(()) +} + +fn enqueue_deferred_hook( + rootfs: &Path, + pkg_name: &str, + hook: Hook, + script_rel: &Path, +) -> Result<()> { + let path = deferred_hooks_file(rootfs); + let mut hooks = read_deferred_hooks(&path)?; + if hooks + .iter() + .any(|h| h.pkg_name == pkg_name && h.hook == hook && h.script_rel == script_rel) + { + return Ok(()); + } + hooks.push(DeferredHook { + pkg_name: pkg_name.to_string(), + hook, + script_rel: script_rel.to_path_buf(), + }); + write_deferred_hooks(&path, &hooks) +} + +/// Attempt to run deferred lifecycle hooks for this rootfs once. +/// +/// Deferred hooks are best-effort: failures are kept in queue for later retry. +pub fn run_deferred_hooks_if_possible(rootfs: &Path) -> Result<()> { + if !fakeroot::is_root() || !should_use_chroot(rootfs) || !rootfs.join("bin/sh").exists() { + return Ok(()); + } + + let path = deferred_hooks_file(rootfs); + let hooks = read_deferred_hooks(&path)?; + if hooks.is_empty() { + return Ok(()); + } + + let mut remaining = Vec::new(); + for item in hooks { + let script_path = rootfs.join(&item.script_rel); + if !script_path.exists() { + crate::log_warn!( + "Dropping deferred lifecycle hook {} for {} because script is missing: {}", + item.hook.canonical_name(), + item.pkg_name, + script_path.display() + ); + continue; + } + + match run_hook_script_in_chroot(rootfs, &item.script_rel, &item.pkg_name, item.hook, true) { + Ok(status) if status.success() => {} + Ok(status) => { + crate::log_warn!( + "Deferred lifecycle hook {} for {} failed with status {} (kept queued)", + item.hook.canonical_name(), + item.pkg_name, + status + ); + remaining.push(item); + } + Err(err) => { + crate::log_warn!( + "Deferred lifecycle hook {} for {} failed with error (kept queued): {}", + item.hook.canonical_name(), + item.pkg_name, + err + ); + remaining.push(item); + } + } + } + + write_deferred_hooks(&path, &remaining)?; + if !remaining.is_empty() { + crate::log_warn!( + "{} deferred lifecycle hook(s) remain queued in {}", + remaining.len(), + path.display() + ); + } + Ok(()) } fn resolve_hook_script(script_dir: &Path, hook: Hook, pkg_name: &str) -> Result> { @@ -762,6 +1133,60 @@ mod tests { assert!(err.to_string().contains("Ambiguous lifecycle hook")); } + #[test] + fn run_hook_if_present_with_relative_rootfs_uses_correct_script_and_env_paths() { + let cwd = std::env::current_dir().unwrap(); + let tmp = tempfile::Builder::new() + .prefix("depot-hook-rel-rootfs-") + .tempdir_in(&cwd) + .unwrap(); + let rootfs_abs = tmp.path().join("root"); + std::fs::create_dir_all(&rootfs_abs).unwrap(); + let rootfs_rel = rootfs_abs.strip_prefix(&cwd).unwrap().to_path_buf(); + let scripts = rootfs_rel.join("scripts"); + std::fs::create_dir_all(&scripts).unwrap(); + + std::fs::write( + scripts.join("post_install"), + "echo ok > \"$DEPOT_ROOTFS/hook.out\"\n", + ) + .unwrap(); + + let ran = run_hook_if_present(&scripts, Hook::PostInstall, &rootfs_rel, "foo").unwrap(); + assert!(ran); + assert_eq!( + std::fs::read_to_string(rootfs_abs.join("hook.out")).unwrap(), + "ok\n" + ); + } + + #[test] + fn deferred_hooks_file_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("hooks.tsv"); + let hooks = vec![ + DeferredHook { + pkg_name: "foo".into(), + hook: Hook::PostInstall, + script_rel: PathBuf::from("usr/share/depot/foo/scripts/post_install"), + }, + DeferredHook { + pkg_name: "bar".into(), + hook: Hook::PostUpdate, + script_rel: PathBuf::from("usr/share/depot/bar/scripts/post_update"), + }, + ]; + write_deferred_hooks(&path, &hooks).unwrap(); + let loaded = read_deferred_hooks(&path).unwrap(); + assert_eq!(loaded.len(), hooks.len()); + assert_eq!(loaded[0].pkg_name, hooks[0].pkg_name); + assert_eq!(loaded[0].hook, hooks[0].hook); + assert_eq!(loaded[0].script_rel, hooks[0].script_rel); + assert_eq!(loaded[1].pkg_name, hooks[1].pkg_name); + assert_eq!(loaded[1].hook, hooks[1].hook); + assert_eq!(loaded[1].script_rel, hooks[1].script_rel); + } + #[test] fn sync_staged_scripts_to_rootfs_replaces_existing_tree() { let tmp = tempfile::tempdir().unwrap();