Files
pkh/src/deb/ephemeral.rs
Valentin Haudiquet 4a73e6e1d6
Some checks failed
CI / build (push) Failing after 20m7s
CI / snap (push) Has been skipped
deb: fix race condition for tests
2026-03-18 17:35:38 +01:00

356 lines
12 KiB
Rust

use crate::context;
use crate::context::{Context, ContextConfig};
use directories::ProjectDirs;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tar::Archive;
use xz2::read::XzDecoder;
/// An ephemeral unshare context guard that creates and manages a temporary chroot environment
/// for building packages with unshare permissions.
pub struct EphemeralContextGuard {
previous_context: String,
chroot_path: PathBuf,
build_succeeded: bool,
}
impl EphemeralContextGuard {
/// Create a new ephemeral unshare context for the specified series
///
/// # 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)
pub async fn new(series: &str, arch: Option<&str>) -> Result<Self, Box<dyn Error>> {
let current_context_name = context::manager().current_name();
// Capture the current context once to avoid race conditions
// with other threads modifying the global context state
let ctx = context::current();
// Create a temporary directory for the chroot
let chroot_path_str = ctx.create_temp_dir()?;
let chroot_path = PathBuf::from(chroot_path_str);
log::debug!(
"Creating new chroot for {} (arch: {:?}) at {}...",
series,
arch,
chroot_path.display()
);
// Download and extract the chroot tarball
Self::download_and_extract_chroot(series, arch, &chroot_path, ctx.clone()).await?;
// Switch to an ephemeral context to build the package in the chroot
context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare {
path: chroot_path.to_string_lossy().to_string(),
parent: Some(current_context_name.clone()),
}));
Ok(Self {
previous_context: current_context_name,
chroot_path,
build_succeeded: false,
})
}
async fn download_and_extract_chroot(
series: &str,
arch: Option<&str>,
chroot_path: &PathBuf,
ctx: Arc<context::Context>,
) -> Result<(), Box<dyn Error>> {
// 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)?;
// Create tarball filename based on series and architecture
let tarball_filename = if let Some(a) = arch {
format!("{}-{}-buildd.tar.xz", series, a)
} else {
format!("{}-buildd.tar.xz", series)
};
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)? {
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
);
Self::download_chroot_tarball(series, arch, &tarball_path, ctx).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());
Self::extract_tarball(&tarball_path, chroot_path)?;
// Create device nodes in the chroot
log::debug!("Creating device nodes in chroot...");
Self::create_device_nodes(chroot_path, ctx_for_devices)?;
Ok(())
}
async fn download_chroot_tarball(
series: &str,
arch: Option<&str>,
tarball_path: &Path,
ctx: Arc<context::Context>,
) -> 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());
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,
) -> 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 all files to the chroot directory
archive.unpack(chroot_path)?;
Ok(())
}
fn create_device_nodes(
chroot_path: &Path,
ctx: Arc<context::Context>,
) -> Result<(), Box<dyn Error>> {
let dev_null_path = chroot_path.join("dev/null");
let dev_zero_path = chroot_path.join("dev/zero");
// Ensure /dev directory exists
fs::create_dir_all(chroot_path.join("dev"))?;
// Remove existing device nodes if they exist
let _ = ctx
.command("rm")
.arg("-f")
.arg(dev_null_path.to_string_lossy().to_string())
.status();
let _ = ctx
.command("rm")
.arg("-f")
.arg(dev_zero_path.to_string_lossy().to_string())
.status();
// Check if we're running as root
let is_root = crate::utils::root::is_root()?;
// Create new device nodes using mknod (with sudo if not root)
let mut cmd_null = ctx.command(if is_root { "mknod" } else { "sudo" });
if !is_root {
cmd_null.arg("mknod");
}
let status_null = cmd_null
.arg("-m")
.arg("666")
.arg(dev_null_path.to_string_lossy().to_string())
.arg("c")
.arg("1")
.arg("3")
.status()?;
let mut cmd_zero = ctx.command(if is_root { "mknod" } else { "sudo" });
if !is_root {
cmd_zero.arg("mknod");
}
let status_zero = cmd_zero
.arg("-m")
.arg("666")
.arg(dev_zero_path.to_string_lossy().to_string())
.arg("c")
.arg("1")
.arg("5")
.status()?;
if !status_null.success() || !status_zero.success() {
return Err("Failed to create device nodes".into());
}
Ok(())
}
/// Mark the build as successful, which will trigger chroot cleanup on drop
pub fn mark_build_successful(&mut self) {
self.build_succeeded = true;
}
}
impl Drop for EphemeralContextGuard {
fn drop(&mut self) {
log::debug!("Cleaning up ephemeral context ({:?})...", &self.chroot_path);
// Reset to normal context
if let Err(e) = context::manager().set_current(&self.previous_context) {
log::error!("Failed to restore context {}: {}", self.previous_context, e);
}
// 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);
let result = if is_root {
context::current()
.command("rm")
.arg("-rf")
.arg(&self.chroot_path)
.status()
} else {
context::current()
.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()
);
}
}
}