Files
pkh/src/deb/mod.rs
T
vhaudiquet 182a06ffbe deb: add -j/--jobs to control parallel build jobs
By default the number of parallel jobs is detected with nproc inside
the build context. Add a -j/--jobs option so an explicit count can be
honored instead, threading it through build_binary_package and
local::build into DEB_BUILD_OPTIONS=parallel=N.
2026-09-10 15:27:50 +02:00

502 lines
16 KiB
Rust

mod cross;
mod ephemeral;
mod local;
use crate::context::{self, Context};
use crate::ui::deb::{DebUi, Phase};
use std::error::Error;
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// Build mode for the binary build
#[derive(PartialEq)]
pub enum BuildMode {
/// Local build, directly on the context
Local,
}
/// Build package in 'cwd' to a .deb
///
/// Returns the list of produced .deb files retrieved locally. When `ui` is
/// set, a live view (status bar + rolling log pane) is displayed and all
/// subprocess output is captured through it; on failure the widget is cleared
/// and a summary of captured errors is printed.
#[allow(clippy::too_many_arguments)]
pub async fn build_binary_package(
arch: Option<&str>,
series: Option<&str>,
pocket: Option<&str>,
cwd: Option<&Path>,
cross: bool,
mode: Option<BuildMode>,
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Option<Arc<Context>>,
ui: Option<Arc<DebUi>>,
jobs: Option<usize>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let result = build_binary_package_impl(
arch,
series,
pocket,
cwd,
cross,
mode,
ppa,
inject_packages,
ctx,
&ui,
jobs,
)
.await;
if let (Some(u), Err(_)) = (&ui, &result) {
u.finish_failure();
}
result
}
/// Implementation of [`build_binary_package`], without failure handling
#[allow(clippy::too_many_arguments)]
async fn build_binary_package_impl(
arch: Option<&str>,
series: Option<&str>,
pocket: Option<&str>,
cwd: Option<&Path>,
cross: bool,
mode: Option<BuildMode>,
ppa: Option<&[&str]>,
inject_packages: Option<&[&str]>,
ctx: Option<Arc<Context>>,
ui: &Option<Arc<DebUi>>,
jobs: Option<usize>,
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
let cwd = cwd.unwrap_or_else(|| Path::new("."));
// Parse changelog to get package name, version and series
let changelog_path = cwd.join("debian/changelog");
let (package, version, package_series) =
crate::changelog::parse_changelog_header(&changelog_path)?;
let series = if let Some(s) = series {
s
} else {
&package_series
};
let current_arch = crate::get_current_arch();
let arch = arch.unwrap_or(&current_arch);
// Make sure we select a specific mode, either using user-requested
// or by using default for user-supplied parameters
let mode = if let Some(m) = mode {
m
} else {
// By default, we use local build
BuildMode::Local
};
// Create an ephemeral unshare context for all Local builds
// Use qemu_binfmt when target architecture differs from host and cross is not requested
let chroot_arch = if mode == BuildMode::Local && arch != current_arch && !cross {
Some(arch)
} else {
None
};
// Use provided context or get current
let base_ctx = ctx.unwrap_or_else(context::current);
// Identify the target in the live UI once the changelog is parsed, so
// even the chroot download output is attributed and tee'd
if let Some(u) = ui {
u.set_target(&package, &version, series, arch);
}
// Create an ephemeral unshare context for all Local builds. It is kept in
// this scope so it outlives the guarded section below and is only dropped
// once the live view has been cleared.
let mut guard = if mode == BuildMode::Local {
Some(
ephemeral::EphemeralContextGuard::new_with_context(
series,
chroot_arch,
base_ctx.clone(),
ui.clone(),
)
.await?,
)
} else {
None
};
let result = async {
// Get the build context - either the ephemeral context or the base context
let build_ctx = if mode == BuildMode::Local {
context::current()
} else {
base_ctx.clone()
};
// Prepare build directory
let build_root = build_ctx.create_temp_dir()?;
// Ensure availability of all needed files for the build
let parent_dir = cwd.parent().ok_or("Cannot find parent directory")?;
build_ctx.ensure_available(parent_dir, &build_root)?;
let parent_dir_name = parent_dir
.file_name()
.ok_or("Cannot find parent directory name")?;
let build_root = format!("{}/{}", build_root, parent_dir_name.to_str().unwrap());
// Run the build using target build mode
match mode {
BuildMode::Local => {
local::build(
&package,
&version,
arch,
series,
pocket,
&build_root,
cross,
ppa,
inject_packages,
build_ctx.clone(),
ui.clone(),
jobs,
)
.await?
}
}
// Retrieve produced artifacts (.deb files plus the upload metadata
// (.buildinfo/.changes) generated natively after the build)
if let Some(u) = ui {
u.phase(Phase::RetrievingArtifacts);
}
let remote_files = build_ctx.list_files(Path::new(&build_root))?;
let deb_files: Vec<PathBuf> = remote_files
.into_iter()
.filter(|f| {
f.extension().is_some_and(|ext| {
matches!(
ext.to_str(),
Some("deb") | Some("buildinfo") | Some("changes")
)
})
})
.collect();
let total_debs = deb_files.len();
let mut artifacts = Vec::with_capacity(total_debs);
for (idx, remote_file) in deb_files.iter().enumerate() {
let file_name = remote_file.file_name().ok_or("Invalid remote filename")?;
let local_dest = parent_dir.join(file_name);
build_ctx.retrieve_path(remote_file, &local_dest)?;
artifacts.push(local_dest);
if let Some(u) = ui {
u.count_progress("Retrieving artifacts", idx + 1, total_debs);
}
}
if let Some(u) = ui {
u.finish_success(&artifacts, u.elapsed());
}
Ok(artifacts)
}
.await;
// Clear the live view before returning: the ephemeral guard is dropped at
// the end of this function and its cleanup commands (umount, rm -rf of
// the chroot) inherit the terminal, so they must not fight the widget.
if let Some(u) = ui {
u.suspend();
}
// Mark build as successful to trigger chroot cleanup
if result.is_ok()
&& let Some(ref mut g) = guard
{
g.mark_build_successful();
}
result
}
/// Find the current package directory by trying both patterns:
/// - package/package
/// - package/package-origversion
/// - custom directories from quirks configuration
pub(crate) fn find_package_directory(
parent_dir: &Path,
package: &str,
version: &str,
ctx: &context::Context,
) -> Result<PathBuf, Box<dyn Error>> {
// Check quirks first for custom package directories
let custom_dirs = crate::quirks::get_package_directories(package);
for custom_dir in custom_dirs {
let package_dir = parent_dir.join(&custom_dir);
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
log::debug!(
"Found package directory via quirks: {}",
package_dir.display()
);
return Ok(package_dir);
}
}
// Try the current working directory name as a package directory
// This is useful when the user is inside a non-standard package directory
// e.g. ~/kernels/linux-riscv/linux-main -> tries "linux-main"
if let Ok(cwd) = std::env::current_dir()
&& let Some(cwd_name) = cwd.file_name()
{
let cwd_name_str = cwd_name.to_string_lossy();
let package_dir = parent_dir.join(cwd_name_str.as_ref());
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
log::debug!(
"Found package directory via CWD name: {}",
package_dir.display()
);
return Ok(package_dir);
}
}
// Try package/package pattern first
let package_dir = parent_dir.join(package).join(package);
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// Compute origversion from version: remove everything after first '-', after stripping epoch
let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version);
let origversion = version_without_epoch
.split_once('-')
.map(|(v, _)| v)
.unwrap_or(version);
// Try package/package-origversion pattern
let package_dir = parent_dir
.join(package)
.join(format!("{}-{}", package, origversion));
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// Try 'package' only
let package_dir = parent_dir.join(package);
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// Try package-origversion only
let package_dir = parent_dir.join(format!("{}-{}", package, origversion));
if ctx.exists(&package_dir)? && ctx.exists(&package_dir.join("debian"))? {
return Ok(package_dir);
}
// List all directories under 'package/' and log them
let package_parent = parent_dir;
if ctx.exists(package_parent)? {
log::debug!(
"Listing all directories under '{}':",
package_parent.display()
);
let entries = ctx.list_files(package_parent)?;
let mut found_dirs = Vec::new();
for entry in entries {
if entry.is_dir() {
if let Some(file_name) = entry.file_name() {
found_dirs.push(file_name.to_string_lossy().into_owned());
}
log::debug!(" - {}", entry.display());
}
}
// If we found directories but none matched our patterns, provide helpful error
if !found_dirs.is_empty() {
return Err(format!(
"Could not find package directory for {} in {}. Found directories: {}",
package,
parent_dir.display(),
found_dirs.join(", ")
)
.into());
}
}
Err(format!(
"Could not find package directory for {} in {}",
package,
parent_dir.display()
)
.into())
}
fn find_dsc_file(
build_root: &str,
package: &str,
version: &str,
ctx: &Arc<Context>,
) -> Result<PathBuf, Box<dyn Error>> {
// Strip epoch from version (e.g., "1:2.3.4-5" -> "2.3.4-5")
let version_without_epoch = version.split_once(':').map(|(_, v)| v).unwrap_or(version);
let dsc_name = format!("{}_{}.dsc", package, version_without_epoch);
let dsc_path = PathBuf::from(build_root).join(&dsc_name);
// Check if the .dsc file exists in context
if !ctx.exists(&dsc_path)? {
return Err(format!("Could not find .dsc file at {}", dsc_path.display()).into());
}
Ok(dsc_path)
}
/// Check whether an apt source URI points to a distribution archive
/// (as opposed to a PPA or another third-party repository)
pub(crate) fn is_archive_source(uri: &str) -> bool {
uri.contains("archive.ubuntu.com")
|| uri.contains("security.ubuntu.com")
|| uri.contains("ports.ubuntu.com")
|| uri.contains("deb.debian.org")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
async fn test_build_end_to_end(
package: &str,
series: &str,
dist: Option<&str>,
arch: Option<&str>,
cross: bool,
) {
log::info!(
"Starting end-to-end test for package: {} (series: {}, arch: {:?}, cross: {})",
package,
series,
arch,
cross
);
let temp_dir = tempfile::tempdir().unwrap();
let cwd = temp_dir.path();
log::debug!("Created temporary directory: {}", cwd.display());
log::info!("Pulling package {} from {}...", package, series);
let package_info =
crate::package_info::lookup(package, None, Some(series), "", dist, None, None)
.await
.expect("Cannot lookup package information");
crate::pull::pull(&package_info, Some(cwd), None, true)
.await
.expect("Cannot pull package");
log::info!("Successfully pulled package {}", package);
// Create a fresh local context for this test
let ctx = Arc::new(Context::new(crate::context::ContextConfig::Local));
// Change directory to the package directory
let cwd =
crate::deb::find_package_directory(cwd, package, &package_info.stanza.version, &ctx)
.expect("Cannot find package directory");
log::debug!("Package directory: {}", cwd.display());
log::info!("Starting binary package build...");
crate::deb::build_binary_package(
arch,
Some(series),
None,
Some(&cwd),
cross,
None,
None,
None,
Some(ctx),
None,
None,
)
.await
.expect("Cannot build binary package (deb)");
log::info!("Successfully built binary package");
// Check that the .deb files are present
let parent_dir = cwd.parent().expect("Cannot find parent directory");
let deb_files = std::fs::read_dir(parent_dir)
.expect("Cannot read build directory")
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "deb"))
.collect::<Vec<_>>();
log::info!("Found {} .deb files after build", deb_files.len());
for file in &deb_files {
log::debug!(" - {}", file.path().display());
}
assert!(!deb_files.is_empty(), "No .deb files found after build");
log::info!(
"End-to-end test completed successfully for package: {}",
package
);
}
// Tests no longer need to be 'serial' since each test uses its own
// explicit context instead of shared global state.
#[tokio::test]
#[test_log::test]
async fn test_deb_hello_ubuntu_end_to_end() {
test_build_end_to_end("hello", "noble", None, None, false).await;
}
/// This ensures that we can cross-build packages
#[tokio::test]
#[test_log::test]
#[cfg(target_arch = "x86_64")]
async fn test_deb_hello_ubuntu_cross_end_to_end() {
test_build_end_to_end("hello", "noble", None, Some("riscv64"), true).await;
}
/// This ensures that we can build packages from sid, even on older
/// releases. It can sometimes be difficult with mmdebstrap issues
/// for example.
#[tokio::test]
#[test_log::test]
async fn test_deb_hello_debian_sid_end_to_end() {
test_build_end_to_end("hello", "sid", None, None, false).await;
}
/// This is a specific test case for the linux-riscv package on Ubuntu
/// It is important to ensure that pkh can cross-compile linux-riscv, as
/// for risc-v hardware is still rare and cross-compilation is necessary
/// to debug and test
/// NOTE: Ideally, we want to run this in CI, but it takes more than 1h
/// to fully build the linux-riscv package on an amd64 builder, which is too
/// much time
#[ignore]
#[tokio::test]
#[test_log::test]
#[cfg(target_arch = "x86_64")]
async fn test_deb_linux_riscv_ubuntu_cross_end_to_end() {
test_build_end_to_end("linux-riscv", "questing", None, Some("riscv64"), true).await;
}
/// This is a specific test case for the latest gcc package on Debian
/// The GCC package is complex and hard to build, with specific stages
/// and system-bound scripts. Building it requires specific things that
/// we want to ensure are not broken.
/// NOTE: Ideally, we want to run this in CI, but it takes more than 20h
/// to fully build the gcc-15 package on an amd64 builder, which is too
/// much time.
#[ignore]
#[cfg(target_arch = "x86_64")]
#[tokio::test]
#[test_log::test]
async fn test_deb_gcc_debian_end_to_end() {
test_build_end_to_end("gcc-15", "sid", None, None, false).await;
}
}