deb: cross-compilation, ephemeral contexts, local builds
All checks were successful
CI / build (push) Successful in 7m18s

Multiple changes:
- New contexts (schroot, unshare)
- Cross-building quirks, with ephemeral contexts and repositories management
- Contexts with parents, global context manager, better lifetime handling
- Local building of binary packages
- Pull: pulling dsc files by default
- Many small bugfixes and changes

Co-authored-by: Valentin Haudiquet <valentin.haudiquet@canonical.com>
Co-committed-by: Valentin Haudiquet <valentin.haudiquet@canonical.com>
This commit was merged in pull request #1.
This commit is contained in:
2025-12-25 17:10:44 +00:00
committed by Valentin Haudiquet
parent 88313b0c51
commit 1538e9ee19
19 changed files with 1784 additions and 301 deletions

338
src/deb/cross.rs Normal file
View File

@@ -0,0 +1,338 @@
use crate::context;
use crate::context::{Context, ContextConfig};
use directories::ProjectDirs;
use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use tar::Archive;
use xz2::read::XzDecoder;
pub struct EphemeralContextGuard {
previous_context: String,
chroot_path: PathBuf,
}
impl EphemeralContextGuard {
pub fn new(series: &str) -> Result<Self, Box<dyn Error>> {
let current_context_name = context::manager().current_name();
// Create a temporary directory for the chroot
let chroot_path_str = context::current().create_temp_dir()?;
let chroot_path = PathBuf::from(chroot_path_str);
log::debug!(
"Creating new chroot for {} at {}...",
series,
chroot_path.display()
);
// Download and extract the chroot tarball
Self::download_and_extract_chroot(series, &chroot_path)?;
// 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,
})
}
fn download_and_extract_chroot(
series: &str,
chroot_path: &PathBuf,
) -> Result<(), Box<dyn Error>> {
// 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
let tarball_filename = format!("{}-buildd.tar.xz", series);
let tarball_path = cache_dir.join(&tarball_filename);
// Download tarball if it doesn't exist
if !tarball_path.exists() {
log::debug!("Downloading chroot tarball for {}...", series);
Self::download_chroot_tarball(series, &tarball_path)?;
} else {
log::debug!("Using cached chroot tarball for {}", series);
}
// Extract tarball to chroot directory
log::debug!("Extracting chroot tarball to {}...", chroot_path.display());
Self::extract_tarball(&tarball_path, chroot_path)?;
Ok(())
}
fn download_chroot_tarball(series: &str, tarball_path: &Path) -> Result<(), Box<dyn Error>> {
// Use mmdebstrap to download the tarball to the cache directory
let status = context::current()
.command("mmdebstrap")
.arg("--variant=buildd")
.arg("--mode=unshare")
.arg("--format=tar")
.arg(series)
.arg(tarball_path.to_string_lossy().to_string())
.status()?;
if !status.success() {
return Err(format!("Failed to download chroot tarball for series {}", series).into());
}
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(())
}
}
impl Drop for EphemeralContextGuard {
fn drop(&mut self) {
log::debug!("Cleaning up ephemeral context...");
// 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
// We use the restored context to execute the cleanup command
let result = 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()
);
}
}
Err(e) => {
log::error!(
"Failed to execute cleanup command for {}: {}",
self.chroot_path.display(),
e
);
}
}
}
}
/// Set environment variables for cross-compilation
pub fn setup_environment(
env: &mut HashMap<String, String>,
arch: &str,
) -> Result<(), Box<dyn Error>> {
let dpkg_architecture = String::from_utf8(
context::current()
.command("dpkg-architecture")
.arg("-a")
.arg(arch)
.output()?
.stdout,
)?;
let env_var_regex = regex::Regex::new(r"(?<key>.*)=(?<value>.*)").unwrap();
for l in dpkg_architecture.lines() {
let capture = env_var_regex.captures(l).unwrap();
let key = capture.name("key").unwrap().as_str().to_string();
let value = capture.name("value").unwrap().as_str().to_string();
env.insert(key.clone(), value.clone());
if key == "DEB_HOST_GNU_TYPE" {
env.insert("CROSS_COMPILE".to_string(), format!("{value}-"));
}
}
env.insert("DEB_BUILD_PROFILES".to_string(), "cross".to_string());
env.insert("DEB_BUILD_OPTIONS".to_string(), "nocheck".to_string());
Ok(())
}
/// Ensure that repositories for target architecture are available
/// This also handles the 'ports.ubuntu.com' vs 'archive.ubuntu.com' on Ubuntu
pub fn ensure_repositories(arch: &str, series: &str) -> Result<(), Box<dyn Error>> {
let ctx = context::current();
let local_arch = crate::get_current_arch();
// Add target ('host') architecture
ctx.command("dpkg")
.arg("--add-architecture")
.arg(arch)
.status()?;
// Check if we are on Ubuntu
let os_release = String::from_utf8(ctx.command("cat").arg("/etc/os-release").output()?.stdout)?;
if !os_release.contains("ID=ubuntu") {
return Ok(());
}
// Handle DEB822 format (Ubuntu 24.04+)
let deb822_path = "/etc/apt/sources.list.d/ubuntu.sources";
let has_deb822 = ctx
.command("test")
.arg("-f")
.arg(deb822_path)
.status()?
.success();
if has_deb822 {
ensure_repositories_deb822(&ctx, arch, &local_arch, series, deb822_path)?;
} else {
ensure_repositories_legacy(&ctx, arch, &local_arch, series, "/etc/apt/sources.list")?;
}
Ok(())
}
fn ensure_repositories_deb822(
ctx: &context::Context,
arch: &str,
local_arch: &str,
series: &str,
deb822_path: &str,
) -> Result<(), Box<dyn Error>> {
// Scope existing to local_arch if not already scoped
ctx.command("sed")
.arg("-i")
.arg(format!("/URIs:.*\\(archive\\|security\\)\\.ubuntu\\.com/ {{ n; /^Architectures:/ ! i Architectures: {} }}", local_arch))
.arg(deb822_path)
.status()?;
// Ensure all components are enabled for the primary architecture
ctx.command("sed")
.arg("-i")
.arg("/URIs:.*\\(archive\\|security\\)\\.ubuntu\\.com/,/Components:/ s/^Components:.*/Components: main restricted universe multiverse/")
.arg(deb822_path)
.status()?;
// Ensure all suites (pockets) are enabled for the primary architecture
// Excluding 'proposed' as it contains unstable software
let suites = format!("{series} {series}-updates {series}-backports {series}-security");
ctx.command("sed")
.arg("-i")
.arg(format!(
"/URIs:.*\\(archive\\|security\\)\\.ubuntu\\.com/,/Suites:/ s/^Suites:.*/Suites: {}/",
suites
))
.arg(deb822_path)
.status()?;
// Add ports if not already present
let has_ports = ctx
.command("grep")
.arg("-q")
.arg("ports.ubuntu.com")
.arg(deb822_path)
.status()?
.success();
if !has_ports {
let ports_block = format!(
"\nTypes: deb\nURIs: http://ports.ubuntu.com/ubuntu-ports\nSuites: {series} {series}-updates {series}-backports {series}-security\nComponents: main restricted universe multiverse\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\nArchitectures: {arch}\n"
);
ctx.command("sh")
.arg("-c")
.arg(format!("echo '{}' >> {}", ports_block, deb822_path))
.status()?;
}
Ok(())
}
fn ensure_repositories_legacy(
ctx: &context::Context,
arch: &str,
local_arch: &str,
series: &str,
sources_path: &str,
) -> Result<(), Box<dyn Error>> {
// Scope archive.ubuntu.com and security.ubuntu.com to local_arch if not already scoped
ctx.command("sed")
.arg("-i")
.arg(format!(
r"/archive.ubuntu.com\|security.ubuntu.com/ {{ /arch=/ ! {{ /^deb \[/ ! s/^deb /deb [arch={}] /; /^deb \[/ s/^deb \[\([^]]*\)\]/deb [arch={} \1]/ }} }}",
local_arch, local_arch
))
.arg(sources_path)
.status()?;
// Ensure all components (main restricted universe multiverse) are present for all archive/security lines
ctx.command("sed")
.arg("-i")
.arg(r"/archive.ubuntu.com\|security.ubuntu.com/ s/\( main\)\?\([ ]\+restricted\)\?\([ ]\+universe\)\?\([ ]\+multiverse\)\?$/ main restricted universe multiverse/")
.arg(sources_path)
.status()?;
// Ensure all pockets exist. If not, we append them.
for pocket in ["", "-updates", "-backports", "-security"] {
let suite = format!("{}{}", series, pocket);
let has_suite = ctx
.command("grep")
.arg("-q")
.arg(format!(" {}", suite))
.arg(sources_path)
.status()?
.success();
if !has_suite {
let line = format!(
"deb [arch={}] http://archive.ubuntu.com/ubuntu/ {} main restricted universe multiverse",
local_arch, suite
);
ctx.command("sh")
.arg("-c")
.arg(format!("echo '{}' >> {}", line, sources_path))
.status()?;
}
}
// Add ports repository to sources.list if not already present
let has_ports = ctx
.command("grep")
.arg("-q")
.arg("ports.ubuntu.com")
.arg(sources_path)
.status()?
.success();
if !has_ports {
let ports_lines = format!(
"deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series} main restricted universe multiverse\n\
deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series}-updates main restricted universe multiverse\n\
deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series}-backports main restricted universe multiverse\n\
deb [arch={arch}] http://ports.ubuntu.com/ubuntu-ports {series}-security main restricted universe multiverse"
);
ctx.command("sh")
.arg("-c")
.arg(format!("echo '{}' >> {}", ports_lines, sources_path))
.status()?;
}
Ok(())
}