feat: add support for deferred lifecycle hooks and improve binary package download progress tracking

This commit is contained in:
2026-02-28 19:18:00 -06:00
parent c4e1f8fd29
commit c96a17c553
5 changed files with 657 additions and 86 deletions
Generated
+61 -4
View File
@@ -406,9 +406,10 @@ dependencies = [
"serde", "serde",
"sha2", "sha2",
"suppaftp", "suppaftp",
"sys-mount",
"tar", "tar",
"tempfile", "tempfile",
"thiserror", "thiserror 2.0.18",
"toml", "toml",
"url", "url",
"walkdir", "walkdir",
@@ -1653,7 +1654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -1920,6 +1921,17 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 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]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.2" version = "0.6.2"
@@ -1970,7 +1982,7 @@ dependencies = [
"futures-lite", "futures-lite",
"lazy-regex", "lazy-regex",
"log", "log",
"thiserror", "thiserror 2.0.18",
] ]
[[package]] [[package]]
@@ -2004,6 +2016,19 @@ dependencies = [
"syn", "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]] [[package]]
name = "tar" name = "tar"
version = "0.4.44" version = "0.4.44"
@@ -2028,13 +2053,33 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ 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]] [[package]]
@@ -2202,9 +2247,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"pin-project-lite", "pin-project-lite",
"tracing-attributes",
"tracing-core", "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]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.36" version = "0.1.36"
+1
View File
@@ -39,6 +39,7 @@ reqwest = { version = "0.13.2", default-features = false, features = ["blocking"
filetime = "0.2.27" filetime = "0.2.27"
clap_complete = "4.5.66" clap_complete = "4.5.66"
clap_mangen = "0.2.31" clap_mangen = "0.2.31"
sys-mount = { version = "3.1.0", default-features = false }
[dev-dependencies] [dev-dependencies]
tempfile = "=3.25.0" tempfile = "=3.25.0"
+86 -35
View File
@@ -4,8 +4,10 @@ use crate::{
signing, source, staging, ui, signing, source, staging, ui,
}; };
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
fn parse_licenses_from_toml(metadata: &toml::Value) -> Vec<String> { fn parse_licenses_from_toml(metadata: &toml::Value) -> Vec<String> {
@@ -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) { fn print_plan_summary(plan: &planner::ExecutionPlan) {
let summary = plan.summary(); let summary = plan.summary();
ui::info(format!( ui::info(format!(
@@ -1027,24 +1041,46 @@ fn execute_install_plan_with_child_commands(
"Downloading {} binary package(s) and detached signatures...", "Downloading {} binary package(s) and detached signatures...",
binary_phase_items.len() binary_phase_items.len()
)); ));
for (idx, item) in binary_phase_items.iter().enumerate() { let use_tty_progress = std::io::stderr().is_terminal();
ui::info(format!( for item in &binary_phase_items {
" [{}/{}] {}-{} ({})", let label = format!(
idx + 1, "{}-{}-{}",
binary_phase_items.len(),
item.record.name, item.record.name,
item.record.version, 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 let repo_cfg = config
.binary_repos .binary_repos
.get(&item.repo_name) .get(&item.repo_name)
.with_context(|| format!("Binary repo '{}' not found in config", 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<u64>| {
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, &item.repo_name,
repo_cfg, repo_cfg,
&item.record, &item.record,
&config.package_cache_dir, &config.package_cache_dir,
Some(&mut progress_cb),
) )
.with_context(|| { .with_context(|| {
format!( format!(
@@ -1052,6 +1088,7 @@ fn execute_install_plan_with_child_commands(
item.record.filename, item.repo_name item.record.filename, item.repo_name
) )
})?; })?;
pb.finish_and_clear();
binary_archives.insert( binary_archives.insert(
(item.repo_name.clone(), item.record.filename.clone()), (item.repo_name.clone(), item.record.filename.clone()),
cached, cached,
@@ -1062,14 +1099,20 @@ fn execute_install_plan_with_child_commands(
"Verifying checksums for {} binary package(s)...", "Verifying checksums for {} binary package(s)...",
binary_phase_items.len() binary_phase_items.len()
)); ));
for (idx, item) in binary_phase_items.iter().enumerate() { let checksum_pb = ProgressBar::new(binary_phase_items.len() as u64);
ui::info(format!( checksum_pb.set_draw_target(if use_tty_progress {
" [{}/{}] {}-{}", ProgressDrawTarget::stderr()
idx + 1, } else {
binary_phase_items.len(), ProgressDrawTarget::hidden()
item.record.name, });
item.record.version 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 let cached = binary_archives
.get(&(item.repo_name.clone(), item.record.filename.clone())) .get(&(item.repo_name.clone(), item.record.filename.clone()))
.with_context(|| { .with_context(|| {
@@ -1085,20 +1128,28 @@ fn execute_install_plan_with_child_commands(
item.record.filename, item.repo_name item.record.filename, item.repo_name
) )
})?; })?;
checksum_pb.inc(1);
} }
checksum_pb.finish_and_clear();
ui::info(format!( ui::info(format!(
"Verifying detached signatures for {} binary package(s)...", "Verifying detached signatures for {} binary package(s)...",
binary_phase_items.len() binary_phase_items.len()
)); ));
for (idx, item) in binary_phase_items.iter().enumerate() { let signature_pb = ProgressBar::new(binary_phase_items.len() as u64);
ui::info(format!( signature_pb.set_draw_target(if use_tty_progress {
" [{}/{}] {}-{}", ProgressDrawTarget::stderr()
idx + 1, } else {
binary_phase_items.len(), ProgressDrawTarget::hidden()
item.record.name, });
item.record.version 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 let repo_cfg = config
.binary_repos .binary_repos
.get(&item.repo_name) .get(&item.repo_name)
@@ -1124,7 +1175,9 @@ fn execute_install_plan_with_child_commands(
item.record.filename, item.repo_name 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")?; 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 } => { 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 let cached = binary_archives
.get(&(repo_name.clone(), record.filename.clone())) .get(&(repo_name.clone(), record.filename.clone()))
.with_context(|| { .with_context(|| {
@@ -1191,6 +1234,8 @@ fn execute_install_plan_with_child_commands(
planner::PlanOrigin::Installed => {} planner::PlanOrigin::Installed => {}
} }
} }
install::scripts::run_deferred_hooks_if_possible(rootfs)?;
Ok(()) Ok(())
} }
@@ -1202,7 +1247,9 @@ pub fn run(cli: Cli) -> Result<()> {
spec_or_archive, spec_or_archive,
spec, 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); let mut spec_path = spec.unwrap_or(spec_or_archive);
// Load configuration early so we can use the configured repo clone dir // 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 { if cli.clean {
clean_build_workspace(&config)?; clean_build_workspace(&config)?;
} }
@@ -1820,6 +1869,8 @@ pub fn run(cli: Cli) -> Result<()> {
lib32_spec.package.name, lib32_spec.package.version lib32_spec.package.name, lib32_spec.package.version
)); ));
} }
install::scripts::run_deferred_hooks_if_possible(&cli.rootfs)?;
} else { } else {
if !cli.lib32_only { if !cli.lib32_only {
for out in pkg_spec.outputs() { for out in pkg_spec.outputs() {
+49 -12
View File
@@ -1661,9 +1661,15 @@ fn download_binary_package_archive(
client: &reqwest::blocking::Client, client: &reqwest::blocking::Client,
pkg_url: &str, pkg_url: &str,
tmp_path: &Path, tmp_path: &Path,
progress_cb: &mut Option<&mut dyn FnMut(u64, Option<u64>)>,
) -> Result<()> { ) -> Result<()> {
match copy_file_url_to_path(pkg_url, tmp_path)? { 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 => { FileUrlCopyOutcome::Missing => {
anyhow::bail!("Failed to fetch {}: local file not found", pkg_url); 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()); 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) let mut out = fs::File::create(tmp_path)
.with_context(|| format!("Failed to create {}", tmp_path.display()))?; .with_context(|| format!("Failed to create {}", tmp_path.display()))?;
std::io::copy(&mut resp, &mut out) let mut downloaded = 0u64;
.with_context(|| format!("Failed to save {}", tmp_path.display()))?; 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() out.flush()
.with_context(|| format!("Failed to flush {}", tmp_path.display()))?; .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(|| { .with_context(|| {
format!( format!(
"Failed to verify detached package signature for {}", "Failed to verify detached package signature for {}",
pkg_path.display() pkg_path.display()
) )
})?; })?;
crate::log_info!(
"Verified detached package signature for '{}' using {}",
pkg_path.display(),
verified_key.display()
);
Ok(()) Ok(())
} }
@@ -1766,6 +1786,19 @@ pub fn cache_binary_package_archive(
repo: &crate::config::BinaryRepo, repo: &crate::config::BinaryRepo,
rec: &BinaryRepoPackageRecord, rec: &BinaryRepoPackageRecord,
package_cache_dir: &Path, package_cache_dir: &Path,
) -> Result<BinaryRepoCachedArchive> {
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<u64>)>,
) -> Result<BinaryRepoCachedArchive> { ) -> Result<BinaryRepoCachedArchive> {
let machine_arch = std::env::consts::ARCH; let machine_arch = std::env::consts::ARCH;
let base_url = repo.effective_url_for_arch(machine_arch).with_context(|| { 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")?; .context("Failed to build HTTP client for binary package fetch")?;
let package_downloaded = if !package_path.exists() { let package_downloaded = if !package_path.exists() {
crate::log_info!("Fetching binary package: {}", pkg_url); download_binary_package_archive(&client, &pkg_url, &tmp_path, &mut progress_cb)?;
download_binary_package_archive(&client, &pkg_url, &tmp_path)?;
fs::rename(&tmp_path, &package_path).with_context(|| { fs::rename(&tmp_path, &package_path).with_context(|| {
format!( format!(
"Failed to move {} to {}", "Failed to move {} to {}",
@@ -1801,7 +1833,12 @@ pub fn cache_binary_package_archive(
})?; })?;
true true
} else { } 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 false
}; };
+460 -35
View File
@@ -4,11 +4,14 @@ use crate::fakeroot;
use crate::package::PackageSpec; use crate::package::PackageSpec;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use std::fs; use std::fs;
use std::io::{BufRead, Write};
use std::path::{Component, Path, PathBuf}; 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; use walkdir::WalkDir;
const STAGED_SCRIPTS_DIR: &str = "scripts"; const STAGED_SCRIPTS_DIR: &str = "scripts";
const DEFERRED_HOOKS_FILE_REL: &str = "var/lib/depot/deferred-hooks.tsv";
const ALL_HOOKS: [Hook; 6] = [ const ALL_HOOKS: [Hook; 6] = [
Hook::PreInstall, Hook::PreInstall,
Hook::PostInstall, Hook::PostInstall,
@@ -62,6 +65,18 @@ impl Hook {
} }
} }
fn from_canonical_name(name: &str) -> Option<Self> {
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] { fn candidate_names(self) -> [String; 6] {
let canonical = self.canonical_name(); let canonical = self.canonical_name();
let dashed = canonical.replace('_', "-"); 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. /// Return the staged scripts directory path inside a package staging tree.
pub fn staged_scripts_dir(destdir: &Path) -> PathBuf { pub fn staged_scripts_dir(destdir: &Path) -> PathBuf {
destdir.join(STAGED_SCRIPTS_DIR) destdir.join(STAGED_SCRIPTS_DIR)
@@ -233,61 +255,216 @@ pub fn run_hook_if_present(
script_path.display() script_path.display()
); );
let status = run_script_with_rootfs_context(&script_path, rootfs, pkg_name, hook)?; match run_script_with_rootfs_context(&script_path, rootfs, pkg_name, hook)? {
HookRunOutcome::Ran(status) => {
if !status.success() { if !status.success() {
bail!( bail!(
"Lifecycle hook {} failed: {}", "Lifecycle hook {} failed: {}",
hook.canonical_name(), hook.canonical_name(),
script_path.display() script_path.display()
); );
}
}
HookRunOutcome::Deferred => {}
} }
Ok(true) Ok(true)
} }
enum HookRunOutcome {
Ran(std::process::ExitStatus),
Deferred,
}
#[derive(Default)]
struct ChrootMountGuard {
mounted: Vec<Mount>,
}
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<ChrootMountGuard> {
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( fn run_script_with_rootfs_context(
script_path: &Path, script_path: &Path,
rootfs: &Path, rootfs: &Path,
pkg_name: &str, pkg_name: &str,
hook: Hook, hook: Hook,
) -> Result<std::process::ExitStatus> { ) -> Result<HookRunOutcome> {
// When root and rootfs provides /bin/sh, run inside a real chroot so scripts // 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. // can rely on `cd /` and relative paths resolving within that rootfs.
if fakeroot::is_root() if fakeroot::is_root()
&& rootfs.join("bin/sh").exists() && should_use_chroot(rootfs)
&& let Ok(rel) = script_path.strip_prefix(rootfs) && let Ok(rel) = script_path.strip_prefix(rootfs)
{ {
let rel_script = format!("./{}", rel.to_string_lossy()); if rootfs.join("bin/sh").exists() {
return Command::new("chroot") let status = run_hook_script_in_chroot(rootfs, rel, pkg_name, hook, false)?;
.arg(rootfs) return Ok(HookRunOutcome::Ran(status));
.arg("/bin/sh") }
.arg("-c")
.arg("export DEPOT_PACKAGE DEPOT_ROOTFS DEPOT_ACTION DEPOT_PHASE; cd / && exec /bin/sh \"$1\"") // Post hooks can be deferred until the target shell exists and chroot
.arg("sh") // execution is available.
.arg(rel_script) if matches!(
.env("DEPOT_PACKAGE", pkg_name) hook,
.env("DEPOT_ROOTFS", "/") Hook::PostInstall | Hook::PostUpdate | Hook::PostRemove
.env("DEPOT_ACTION", hook.action()) ) {
.env("DEPOT_PHASE", hook.phase()) enqueue_deferred_hook(rootfs, pkg_name, hook, rel)?;
.status() crate::log_warn!(
.with_context(|| { "Deferred lifecycle hook {} for {} until {} has /bin/sh",
format!( hook.canonical_name(),
"Failed to execute lifecycle hook {} in chroot at {}", pkg_name,
hook.canonical_name(), rootfs.display()
rootfs.display() );
) return Ok(HookRunOutcome::Deferred);
}); }
} }
// Fallback (non-root / no rootfs shell / script outside rootfs): // Fallback (non-root / no rootfs shell / script outside rootfs):
// execute with host /bin/sh while setting cwd to rootfs, so relative paths // execute with host /bin/sh while setting cwd to rootfs, so relative paths
// inside scripts resolve against that rootfs root. // inside scripts resolve against that rootfs root.
Command::new("/bin/sh") let script_arg = if let Ok(rel) = script_path.strip_prefix(rootfs) {
.arg(script_path) 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) .current_dir(rootfs)
.env("DEPOT_PACKAGE", pkg_name) .env("DEPOT_PACKAGE", pkg_name)
.env("DEPOT_ROOTFS", rootfs) .env("DEPOT_ROOTFS", &rootfs_env)
.env("DEPOT_ACTION", hook.action()) .env("DEPOT_ACTION", hook.action())
.env("DEPOT_PHASE", hook.phase()) .env("DEPOT_PHASE", hook.phase())
.status() .status()
@@ -297,7 +474,201 @@ fn run_script_with_rootfs_context(
hook.canonical_name(), hook.canonical_name(),
script_path.display() 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<std::process::ExitStatus> {
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<Vec<DeferredHook>> {
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<Option<PathBuf>> { fn resolve_hook_script(script_dir: &Path, hook: Hook, pkg_name: &str) -> Result<Option<PathBuf>> {
@@ -762,6 +1133,60 @@ mod tests {
assert!(err.to_string().contains("Ambiguous lifecycle hook")); 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] #[test]
fn sync_staged_scripts_to_rootfs_replaces_existing_tree() { fn sync_staged_scripts_to_rootfs_replaces_existing_tree() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();