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.
502 lines
16 KiB
Rust
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(¤t_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;
|
|
}
|
|
}
|