Compare commits
43 Commits
e2838bf5aa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
225157be63
|
|||
|
af73c14674
|
|||
|
b2d89ead7e
|
|||
|
88ec82c0a0
|
|||
|
9ffedb4d1a
|
|||
|
1db1048a2e
|
|||
|
d832666858
|
|||
|
45960e5f17
|
|||
|
97a78336fa
|
|||
|
c64c3146d7
|
|||
|
13c44daf9a
|
|||
|
dfd197415f
|
|||
|
73a61042e8
|
|||
|
2f5fb3b1e7
|
|||
|
c3a116203a
|
|||
|
dd9cc07285
|
|||
|
ab35af5fb5
|
|||
|
a975c24e54
|
|||
|
7e69131886
|
|||
|
d7a99f77f5
|
|||
|
b4a60e2ae2
|
|||
|
87ce0f648b
|
|||
|
29297d6f34
|
|||
|
843f28e8af
|
|||
|
35f9517732
|
|||
|
593793373a
|
|||
|
21bb76153e
|
|||
|
bd10a37c2a
|
|||
|
91c812a530
|
|||
|
70e6d8c051
|
|||
|
2f43ed1597
|
|||
|
182cc086c0
|
|||
|
a2d4f885b5
|
|||
|
650adc28a3
|
|||
| b724d46f2c | |||
|
216eee8f33
|
|||
|
64b51563e7
|
|||
|
d2914c63c6
|
|||
|
42e6165f78
|
|||
|
a444a5d8d2
|
|||
|
dd62baa455
|
|||
| 464e25de24 | |||
| 1b659ce6f4 |
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
branches: [ "main", "ci-test" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
components: rustfmt, clippy
|
||||
- name: Check format
|
||||
run: cargo fmt --check
|
||||
- name: Install build dependencies
|
||||
@@ -32,6 +32,12 @@ jobs:
|
||||
sudo apt-get install -y pkg-config libssl-dev libgpg-error-dev libgpgme-dev
|
||||
- name: Build
|
||||
run: cargo build
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
- name: Lint
|
||||
run: cargo clippy --all-targets --all-features
|
||||
env:
|
||||
RUSTFLAGS: -Dwarnings
|
||||
- name: Install runtime system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -43,3 +49,17 @@ jobs:
|
||||
env:
|
||||
RUST_LOG: debug
|
||||
run: timeout 30m cargo test -- --nocapture
|
||||
|
||||
snap:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
snap-file: ${{ steps.build-snap.outputs.snap }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: snapcore/action-build@v1
|
||||
id: build-snap
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: snap
|
||||
path: ${{ steps.build-snap.outputs.snap }}
|
||||
|
||||
@@ -27,8 +27,11 @@ xz2 = "0.1"
|
||||
serde_json = "1.0.145"
|
||||
directories = "6.0.0"
|
||||
ssh2 = "0.9.5"
|
||||
tempfile = "3.10.1"
|
||||
gpgme = "0.11"
|
||||
serde_yaml = "0.9"
|
||||
lazy_static = "1.4.0"
|
||||
|
||||
[dev-dependencies]
|
||||
test-log = "0.2.19"
|
||||
serial_test = "3.3.1"
|
||||
tempfile = "3.10.1"
|
||||
|
||||
@@ -24,8 +24,10 @@ Options:
|
||||
Commands and workflows include:
|
||||
```
|
||||
Commands:
|
||||
pull Get a source package from the archive or git
|
||||
pull Pull a source package from the archive or git
|
||||
chlog Auto-generate changelog entry, editing it, committing it afterwards
|
||||
build Build the source package (into a .dsc)
|
||||
deb Build the source package into binary package (.deb)
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
```
|
||||
|
||||
@@ -80,7 +82,7 @@ Missing features:
|
||||
- [ ] `pkh pull`
|
||||
- [x] Obtain package sources from git
|
||||
- [x] Obtain package sources from the archive (fallback)
|
||||
- [ ] Obtain package source from PPA (--ppa)
|
||||
- [x] Obtain package source from PPA (--ppa)
|
||||
- [ ] Obtain a specific version of the package
|
||||
- [x] Fetch the correct git branch for series on Ubuntu
|
||||
- [ ] Try to fetch the correct git branch for series on Debian, or fallback to the archive
|
||||
@@ -96,7 +98,7 @@ Missing features:
|
||||
- [ ] Three build modes:
|
||||
- [ ] Build locally (discouraged)
|
||||
- [x] Build using sbuild+unshare, with binary emulation (default)
|
||||
- [ ] Cross-compilation
|
||||
- [x] Cross-compilation
|
||||
- [ ] Async build
|
||||
- [ ] `pkh status`
|
||||
- [ ] Show build status
|
||||
|
||||
28
distro_info.yml
Normal file
28
distro_info.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
## Static data needed for pkh operations
|
||||
## Instead of hardcoding the data in code, data files allow to quickly
|
||||
## update and maintain such data in one unique place
|
||||
## The goal is to have the minimal possible set of data necessary
|
||||
## to grab the actual data. For example we don't want to store every Ubuntu
|
||||
## or Debian series, but rather an URL where we can properly access that data.
|
||||
dist_info:
|
||||
local: /usr/share/distro-info/{dist}
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/
|
||||
dist:
|
||||
debian:
|
||||
base_url: http://deb.debian.org/debian
|
||||
archive_keyring: https://ftp-master.debian.org/keys/archive-key-{series_num}.asc
|
||||
pockets:
|
||||
- proposed-updates
|
||||
- updates
|
||||
series:
|
||||
local: /usr/share/distro-info/debian.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv
|
||||
ubuntu:
|
||||
base_url: http://archive.ubuntu.com/ubuntu
|
||||
archive_keyring: http://archive.ubuntu.com/ubuntu/project/ubuntu-archive-keyring.gpg
|
||||
pockets:
|
||||
- proposed
|
||||
- updates
|
||||
series:
|
||||
local: /usr/share/distro-info/ubuntu.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/ubuntu.csv
|
||||
42
snap/snapcraft.yaml
Normal file
42
snap/snapcraft.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
name: pkh
|
||||
base: core24
|
||||
summary: pkh is a packaging helper for Debian/Ubuntu packages
|
||||
description: |
|
||||
pkh aims at wrapping the different debian tools and workflows
|
||||
into one tool, that would have the same interface for everything,
|
||||
while being smarter at integrating all workflows.
|
||||
adopt-info: pkh-part
|
||||
|
||||
confinement: devmode
|
||||
|
||||
apps:
|
||||
pkh:
|
||||
command: bin/pkh
|
||||
|
||||
parts:
|
||||
pkh-part:
|
||||
plugin: rust
|
||||
source: .
|
||||
override-pull: |
|
||||
craftctl default
|
||||
craftctl set version=$(git rev-parse --short=11 HEAD)
|
||||
craftctl set grade="devel"
|
||||
build-packages:
|
||||
- pkg-config
|
||||
- libssl-dev
|
||||
- libgpg-error-dev
|
||||
- libgpgme-dev
|
||||
stage-packages:
|
||||
- libgpgme11t64
|
||||
- git
|
||||
- curl
|
||||
- pristine-tar
|
||||
- sbuild
|
||||
- mmdebstrap
|
||||
- util-linux
|
||||
- dpkg-dev
|
||||
stage:
|
||||
- -usr/lib/x86_64-linux-gnu/libicuio.so.74.2
|
||||
- -usr/lib/x86_64-linux-gnu/libicutest.so.74.2
|
||||
- -usr/lib/x86_64-linux-gnu/libicutu.so.74.2
|
||||
- -usr/lib/x86_64-linux-gnu/libicui18n.so.74.2
|
||||
58
src/apt/keyring.rs
Normal file
58
src/apt/keyring.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
//! APT keyring management for mmdebstrap
|
||||
//!
|
||||
//! Provides a simple function to ensure that archive keyrings are available
|
||||
//! for mmdebstrap operations by downloading them from specified URLs.
|
||||
|
||||
use crate::context;
|
||||
use crate::distro_info;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Download a keyring into apt trusted.gpg.d directory, trusting that keyring
|
||||
pub async fn download_trust_keyring(
|
||||
ctx: Option<Arc<context::Context>>,
|
||||
series: &str,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let ctx = ctx.unwrap_or_else(context::current);
|
||||
|
||||
// Obtain keyring URL from distro_info
|
||||
let keyring_url = distro_info::get_keyring_url(series).await?;
|
||||
log::debug!("Downloading keyring from: {}", keyring_url);
|
||||
|
||||
// Create trusted.gpg.d directory if it doesn't exist
|
||||
let trusted_gpg_d = "/etc/apt/trusted.gpg.d";
|
||||
if !ctx.exists(Path::new(trusted_gpg_d))? {
|
||||
ctx.command("mkdir").arg("-p").arg(trusted_gpg_d).status()?;
|
||||
}
|
||||
|
||||
// Extract the original filename from the keyring URL
|
||||
let filename = keyring_url
|
||||
.split('/')
|
||||
.next_back()
|
||||
.unwrap_or("pkh-{}.gpg")
|
||||
.replace("{}", series);
|
||||
let keyring_path = format!("{}/{}", trusted_gpg_d, filename);
|
||||
|
||||
// Download the keyring directly to the final location using curl
|
||||
let mut curl_cmd = ctx.command("curl");
|
||||
curl_cmd
|
||||
.arg("-s")
|
||||
.arg("-f")
|
||||
.arg("-L")
|
||||
.arg(&keyring_url)
|
||||
.arg("--output")
|
||||
.arg(&keyring_path);
|
||||
|
||||
let status = curl_cmd.status()?;
|
||||
if !status.success() {
|
||||
return Err(format!("Failed to download keyring from {}", keyring_url).into());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Successfully downloaded and installed keyring for {} to {}",
|
||||
series,
|
||||
keyring_path
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
2
src/apt/mod.rs
Normal file
2
src/apt/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod keyring;
|
||||
pub mod sources;
|
||||
@@ -1,5 +1,5 @@
|
||||
/// APT sources.list management
|
||||
/// Provides a simple structure for managing APT repository sources
|
||||
//! APT sources.list management
|
||||
//! Provides a simple structure for managing APT repository sources
|
||||
use crate::context;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
@@ -16,8 +16,8 @@ pub struct SourceEntry {
|
||||
pub architectures: Vec<String>,
|
||||
/// Source URI
|
||||
pub uri: String,
|
||||
/// Source suite (series-pocket)
|
||||
pub suite: String,
|
||||
/// Source suites (series-pocket)
|
||||
pub suite: Vec<String>,
|
||||
}
|
||||
|
||||
impl SourceEntry {
|
||||
@@ -28,7 +28,7 @@ impl SourceEntry {
|
||||
components: Vec::new(),
|
||||
architectures: Vec::new(),
|
||||
uri: String::new(),
|
||||
suite: String::new(),
|
||||
suite: Vec::new(),
|
||||
};
|
||||
|
||||
for line in data.lines() {
|
||||
@@ -55,7 +55,10 @@ impl SourceEntry {
|
||||
// We only care about deb types
|
||||
}
|
||||
"URIs" => current_entry.uri = value.to_string(),
|
||||
"Suites" => current_entry.suite = value.to_string(),
|
||||
"Suites" => {
|
||||
current_entry.suite =
|
||||
value.split_whitespace().map(|s| s.to_string()).collect();
|
||||
}
|
||||
"Components" => {
|
||||
current_entry.components =
|
||||
value.split_whitespace().map(|s| s.to_string()).collect();
|
||||
@@ -123,7 +126,7 @@ impl SourceEntry {
|
||||
}
|
||||
|
||||
let uri = parts[1].to_string();
|
||||
let suite = parts[2].to_string();
|
||||
let suite = vec![parts[2].to_string()];
|
||||
let components: Vec<String> = parts[3..].iter().map(|&s| s.to_string()).collect();
|
||||
|
||||
Some(SourceEntry {
|
||||
@@ -139,26 +142,31 @@ impl SourceEntry {
|
||||
pub fn to_legacy(&self) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
// Start with "deb" type
|
||||
result.push_str("deb");
|
||||
// Legacy entries contain one suite per line
|
||||
for suite in &self.suite {
|
||||
// Start with "deb" type
|
||||
result.push_str("deb");
|
||||
|
||||
// Add architectures if present
|
||||
if !self.architectures.is_empty() {
|
||||
result.push_str(" [arch=");
|
||||
result.push_str(&self.architectures.join(","));
|
||||
result.push(']');
|
||||
}
|
||||
// Add architectures if present
|
||||
if !self.architectures.is_empty() {
|
||||
result.push_str(" [arch=");
|
||||
result.push_str(&self.architectures.join(","));
|
||||
result.push(']');
|
||||
}
|
||||
|
||||
// Add URI and suite
|
||||
result.push(' ');
|
||||
result.push_str(&self.uri);
|
||||
result.push(' ');
|
||||
result.push_str(&self.suite);
|
||||
|
||||
// Add components
|
||||
if !self.components.is_empty() {
|
||||
// Add URI and suite
|
||||
result.push(' ');
|
||||
result.push_str(&self.components.join(" "));
|
||||
result.push_str(&self.uri);
|
||||
result.push(' ');
|
||||
result.push_str(suite);
|
||||
|
||||
// Add components
|
||||
if !self.components.is_empty() {
|
||||
result.push(' ');
|
||||
result.push_str(&self.components.join(" "));
|
||||
}
|
||||
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
result
|
||||
@@ -275,12 +283,17 @@ mod tests {
|
||||
assert_eq!(sources.len(), 3);
|
||||
assert_eq!(sources[0].uri, "http://fr.archive.ubuntu.com/ubuntu/");
|
||||
assert_eq!(sources[0].architectures, vec!["amd64"]);
|
||||
assert_eq!(
|
||||
sources[0].suite,
|
||||
vec!["questing", "questing-updates", "questing-backports"]
|
||||
);
|
||||
assert_eq!(
|
||||
sources[0].components,
|
||||
vec!["main", "restricted", "universe", "multiverse"]
|
||||
);
|
||||
assert_eq!(sources[1].uri, "http://security.ubuntu.com/ubuntu/");
|
||||
assert_eq!(sources[1].architectures, vec!["amd64"]);
|
||||
assert_eq!(sources[1].suite, vec!["questing-security"]);
|
||||
assert_eq!(
|
||||
sources[1].components,
|
||||
vec!["main", "restricted", "universe", "multiverse"]
|
||||
@@ -288,6 +301,10 @@ mod tests {
|
||||
assert_eq!(sources[2].uri, "http://ports.ubuntu.com/ubuntu-ports/");
|
||||
assert_eq!(sources[2].architectures.len(), 1);
|
||||
assert_eq!(sources[2].architectures, vec!["riscv64"]);
|
||||
assert_eq!(
|
||||
sources[2].suite,
|
||||
vec!["questing", "questing-updates", "questing-backports"]
|
||||
);
|
||||
assert_eq!(
|
||||
sources[2].components,
|
||||
vec!["main", "restricted", "universe", "multiverse"]
|
||||
@@ -305,15 +322,15 @@ mod tests {
|
||||
let sources = parse_legacy(legacy);
|
||||
assert_eq!(sources.len(), 3);
|
||||
assert_eq!(sources[0].uri, "http://archive.ubuntu.com/ubuntu");
|
||||
assert_eq!(sources[0].suite, "resolute");
|
||||
assert_eq!(sources[0].suite, vec!["resolute"]);
|
||||
assert_eq!(sources[0].components, vec!["main", "universe"]);
|
||||
assert_eq!(sources[0].architectures, vec!["amd64"]);
|
||||
assert_eq!(sources[1].uri, "http://archive.ubuntu.com/ubuntu");
|
||||
assert_eq!(sources[1].suite, "resolute-updates");
|
||||
assert_eq!(sources[1].suite, vec!["resolute-updates"]);
|
||||
assert_eq!(sources[1].components, vec!["main"]);
|
||||
assert_eq!(sources[1].architectures, vec!["amd64", "i386"]);
|
||||
assert_eq!(sources[2].uri, "http://security.ubuntu.com/ubuntu");
|
||||
assert_eq!(sources[2].suite, "resolute-security");
|
||||
assert_eq!(sources[2].suite, vec!["resolute-security"]);
|
||||
assert_eq!(sources[2].components, vec!["main"]);
|
||||
}
|
||||
}
|
||||
21
src/build.rs
21
src/build.rs
@@ -23,16 +23,22 @@ pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
};
|
||||
|
||||
// Build arguments
|
||||
let mut args = vec!["-S", "-I", "-i", "-nc", "-d"];
|
||||
// Build command arguments
|
||||
let mut command = Command::new("dpkg-buildpackage");
|
||||
command
|
||||
.current_dir(cwd)
|
||||
.arg("-S")
|
||||
.arg("-I")
|
||||
.arg("-i")
|
||||
.arg("-nc")
|
||||
.arg("-d");
|
||||
|
||||
// If a signing key is found, use it for signing
|
||||
if let Some(key_id) = &signing_key {
|
||||
args.push("-k");
|
||||
args.push(key_id);
|
||||
command.arg(format!("--sign-keyid={}", key_id));
|
||||
log::info!("Using GPG key {} for signing", key_id);
|
||||
} else {
|
||||
args.push("--no-sign");
|
||||
command.arg("--no-sign");
|
||||
log::info!(
|
||||
"No GPG key found for {} ({}), building without signing",
|
||||
maintainer_name,
|
||||
@@ -40,10 +46,7 @@ pub fn build_source_package(cwd: Option<&Path>) -> Result<(), Box<dyn Error>> {
|
||||
);
|
||||
}
|
||||
|
||||
let status = Command::new("dpkg-buildpackage")
|
||||
.current_dir(cwd)
|
||||
.args(&args)
|
||||
.status()?;
|
||||
let status = command.status()?;
|
||||
|
||||
if !status.success() {
|
||||
return Err(format!("dpkg-buildpackage failed with status: {}", status).into());
|
||||
|
||||
@@ -146,13 +146,13 @@ pub fn parse_changelog_footer(path: &Path) -> Result<(String, String), Box<dyn s
|
||||
// Find the last maintainer line (format: -- Name <email> Date)
|
||||
let re = Regex::new(r"--\s*([^<]+?)\s*<([^>]+)>\s*")?;
|
||||
|
||||
if let Some(last_match) = re.captures_iter(&content).last() {
|
||||
let name = last_match
|
||||
if let Some(first_match) = re.captures_iter(&content).next() {
|
||||
let name = first_match
|
||||
.get(1)
|
||||
.map_or("", |m| m.as_str())
|
||||
.trim()
|
||||
.to_string();
|
||||
let email = last_match
|
||||
let email = first_match
|
||||
.get(2)
|
||||
.map_or("", |m| m.as_str())
|
||||
.trim()
|
||||
|
||||
@@ -33,6 +33,7 @@ pub trait ContextDriver {
|
||||
fn copy_path(&self, src: &Path, dest: &Path) -> io::Result<()>;
|
||||
fn read_file(&self, path: &Path) -> io::Result<String>;
|
||||
fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
|
||||
fn exists(&self, path: &Path) -> io::Result<bool>;
|
||||
}
|
||||
|
||||
/// Represents an execution environment (Local or via SSH).
|
||||
@@ -180,6 +181,11 @@ impl Context {
|
||||
self.driver().as_ref().unwrap().write_file(path, content)
|
||||
}
|
||||
|
||||
/// Check if a file or directory exists inside context
|
||||
pub fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
self.driver().as_ref().unwrap().exists(path)
|
||||
}
|
||||
|
||||
/// Create and obtain a specific driver for the context
|
||||
pub fn driver(
|
||||
&self,
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::api::ContextDriver;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub struct LocalDriver;
|
||||
|
||||
@@ -20,8 +21,34 @@ impl ContextDriver for LocalDriver {
|
||||
}
|
||||
|
||||
fn create_temp_dir(&self) -> io::Result<String> {
|
||||
let temp_dir = tempfile::Builder::new().prefix("pkh-").tempdir()?;
|
||||
Ok(temp_dir.keep().to_string_lossy().to_string())
|
||||
// Generate a unique temporary directory name with random string
|
||||
let base_timestamp = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let work_dir_name = if attempt == 0 {
|
||||
format!("pkh-{}", base_timestamp)
|
||||
} else {
|
||||
format!("pkh-{}-{}", base_timestamp, attempt)
|
||||
};
|
||||
|
||||
let temp_dir_path = std::env::temp_dir().join(&work_dir_name);
|
||||
|
||||
// Check if directory already exists
|
||||
if temp_dir_path.exists() {
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
std::fs::create_dir_all(&temp_dir_path)?;
|
||||
|
||||
// Return the path as a string
|
||||
return Ok(temp_dir_path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn retrieve_path(&self, src: &Path, dest: &Path) -> io::Result<()> {
|
||||
@@ -78,6 +105,10 @@ impl ContextDriver for LocalDriver {
|
||||
fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
|
||||
std::fs::write(path, content)
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
Ok(path.exists())
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
|
||||
|
||||
@@ -262,4 +262,14 @@ impl ContextDriver for SchrootDriver {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
let status = self.run(
|
||||
"test",
|
||||
&["-e".to_string(), path.to_string_lossy().to_string()],
|
||||
&[],
|
||||
None,
|
||||
)?;
|
||||
Ok(status.success())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,15 @@ impl ContextDriver for SshDriver {
|
||||
remote_file.write_all(content.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
let sess = connect_ssh(&self.host, self.user.as_deref(), self.port)?;
|
||||
let sftp = sess.sftp().map_err(io::Error::other)?;
|
||||
match sftp.stat(path) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SshDriver {
|
||||
|
||||
@@ -112,27 +112,41 @@ impl ContextDriver for UnshareDriver {
|
||||
}
|
||||
|
||||
fn create_temp_dir(&self) -> io::Result<String> {
|
||||
// Create a temporary directory inside the chroot
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
// Create a temporary directory inside the chroot with unique naming
|
||||
let base_timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let work_dir_name = format!("pkh-build-{}", timestamp);
|
||||
let work_dir_inside_chroot = format!("/tmp/{}", work_dir_name);
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
let work_dir_name = if attempt == 0 {
|
||||
format!("pkh-build-{}", base_timestamp)
|
||||
} else {
|
||||
format!("pkh-build-{}-{}", base_timestamp, attempt)
|
||||
};
|
||||
|
||||
// Create the directory on the host filesystem
|
||||
let host_path = Path::new(&self.path).join("tmp").join(&work_dir_name);
|
||||
std::fs::create_dir_all(&host_path)?;
|
||||
let work_dir_inside_chroot = format!("/tmp/{}", work_dir_name);
|
||||
let host_path = Path::new(&self.path).join("tmp").join(&work_dir_name);
|
||||
|
||||
debug!(
|
||||
"Created work directory: {} (host: {})",
|
||||
work_dir_inside_chroot,
|
||||
host_path.display()
|
||||
);
|
||||
// Check if directory already exists
|
||||
if host_path.exists() {
|
||||
attempt += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Return the path as it appears inside the chroot
|
||||
Ok(work_dir_inside_chroot)
|
||||
// Create the directory on the host filesystem
|
||||
std::fs::create_dir_all(&host_path)?;
|
||||
|
||||
debug!(
|
||||
"Created work directory: {} (host: {})",
|
||||
work_dir_inside_chroot,
|
||||
host_path.display()
|
||||
);
|
||||
|
||||
// Return the path as it appears inside the chroot
|
||||
return Ok(work_dir_inside_chroot);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_path(&self, src: &Path, dest: &Path) -> io::Result<()> {
|
||||
@@ -150,6 +164,11 @@ impl ContextDriver for UnshareDriver {
|
||||
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
||||
self.parent().write_file(&host_path, content)
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> io::Result<bool> {
|
||||
let host_path = Path::new(&self.path).join(path.to_string_lossy().trim_start_matches('/'));
|
||||
self.parent().exists(&host_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl UnshareDriver {
|
||||
@@ -175,6 +194,8 @@ impl UnshareDriver {
|
||||
.arg("--pid")
|
||||
.arg("--ipc")
|
||||
.arg("--uts")
|
||||
.arg("--user")
|
||||
.arg("--cgroup")
|
||||
.arg("--map-auto")
|
||||
.arg("-r")
|
||||
.arg("--mount")
|
||||
@@ -186,7 +207,11 @@ impl UnshareDriver {
|
||||
cmd.arg("-w").arg(dir);
|
||||
}
|
||||
|
||||
cmd.arg("--").arg(program).args(args);
|
||||
cmd.arg("--").arg("bash").arg("-c").arg(format!(
|
||||
"mount -t proc proc /proc; mkdir /dev/pts; mount -t devpts devpts /dev/pts; touch /dev/ptmx; mount --bind /dev/pts/ptmx /dev/ptmx; {} {}",
|
||||
program,
|
||||
args.join(" ")
|
||||
));
|
||||
|
||||
cmd
|
||||
}
|
||||
|
||||
218
src/deb/cross.rs
218
src/deb/cross.rs
@@ -51,145 +51,99 @@ pub fn ensure_repositories(arch: &str, series: &str) -> Result<(), Box<dyn Error
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Handle DEB822 format (Ubuntu 24.04+)
|
||||
// Load existing sources
|
||||
let mut sources = crate::apt::sources::load(Some(ctx.clone()))?;
|
||||
|
||||
// Ensure all components are enabled for the primary architecture
|
||||
for source in &mut sources {
|
||||
if source.uri.contains("archive.ubuntu.com") || source.uri.contains("security.ubuntu.com") {
|
||||
// Scope to local_arch if not already scoped
|
||||
if source.architectures.is_empty() {
|
||||
source.architectures.push(local_arch.clone());
|
||||
}
|
||||
|
||||
// Ensure all components are present
|
||||
let required_components = ["main", "restricted", "universe", "multiverse"];
|
||||
for &comp in &required_components {
|
||||
if !source.components.contains(&comp.to_string()) {
|
||||
source.components.push(comp.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all suites (pockets) are enabled, excluding 'proposed'
|
||||
let required_suites = [
|
||||
series.to_string(),
|
||||
format!("{}-updates", series),
|
||||
format!("{}-backports", series),
|
||||
format!("{}-security", series),
|
||||
];
|
||||
for suite in required_suites {
|
||||
if !source.suite.contains(&suite) {
|
||||
source.suite.push(suite);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if ports repository already exists for the target architecture
|
||||
let has_ports = sources
|
||||
.iter()
|
||||
.any(|s| s.uri.contains("ports.ubuntu.com") && s.architectures.contains(&arch.to_string()));
|
||||
|
||||
if !has_ports {
|
||||
// Add ports repository for the target architecture
|
||||
let ports_entry = crate::apt::sources::SourceEntry {
|
||||
enabled: true,
|
||||
components: vec![
|
||||
"main".to_string(),
|
||||
"restricted".to_string(),
|
||||
"universe".to_string(),
|
||||
"multiverse".to_string(),
|
||||
],
|
||||
architectures: vec![arch.to_string()],
|
||||
uri: "http://ports.ubuntu.com/ubuntu-ports".to_string(),
|
||||
suite: vec![
|
||||
format!("{series}"),
|
||||
format!("{series}-updates"),
|
||||
format!("{series}-backports"),
|
||||
format!("{series}-security"),
|
||||
],
|
||||
};
|
||||
sources.push(ports_entry);
|
||||
}
|
||||
|
||||
// Save the updated sources
|
||||
// Try to save in DEB822 format first, fall back to legacy format
|
||||
let deb822_path = "/etc/apt/sources.list.d/ubuntu.sources";
|
||||
let has_deb822 = ctx
|
||||
if 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()?;
|
||||
.success()
|
||||
{
|
||||
// For DEB822 format, we need to reconstruct the file content
|
||||
let mut content = String::new();
|
||||
for source in &sources {
|
||||
if !source.enabled {
|
||||
continue;
|
||||
}
|
||||
content.push_str("Types: deb\n");
|
||||
content.push_str(&format!("URIs: {}\n", source.uri));
|
||||
content.push_str(&format!("Suites: {}\n", source.suite.join(" ")));
|
||||
content.push_str(&format!("Components: {}\n", source.components.join(" ")));
|
||||
content.push_str("Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n");
|
||||
content.push_str(&format!(
|
||||
"Architectures: {}\n",
|
||||
source.architectures.join(" ")
|
||||
));
|
||||
content.push('\n');
|
||||
}
|
||||
ctx.write_file(std::path::Path::new(deb822_path), &content)?;
|
||||
} else {
|
||||
// Fall back to legacy format
|
||||
crate::apt::sources::save_legacy(Some(ctx.clone()), sources, "/etc/apt/sources.list")?;
|
||||
}
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ use xz2::read::XzDecoder;
|
||||
pub struct EphemeralContextGuard {
|
||||
previous_context: String,
|
||||
chroot_path: PathBuf,
|
||||
build_succeeded: bool,
|
||||
}
|
||||
|
||||
impl EphemeralContextGuard {
|
||||
/// Create a new ephemeral unshare context for the specified series
|
||||
pub fn new(series: &str) -> Result<Self, Box<dyn Error>> {
|
||||
pub async fn new(series: &str) -> Result<Self, Box<dyn Error>> {
|
||||
let current_context_name = context::manager().current_name();
|
||||
|
||||
// Create a temporary directory for the chroot
|
||||
@@ -30,7 +31,7 @@ impl EphemeralContextGuard {
|
||||
);
|
||||
|
||||
// Download and extract the chroot tarball
|
||||
Self::download_and_extract_chroot(series, &chroot_path)?;
|
||||
Self::download_and_extract_chroot(series, &chroot_path).await?;
|
||||
|
||||
// Switch to an ephemeral context to build the package in the chroot
|
||||
context::manager().set_current_ephemeral(Context::new(ContextConfig::Unshare {
|
||||
@@ -41,10 +42,11 @@ impl EphemeralContextGuard {
|
||||
Ok(Self {
|
||||
previous_context: current_context_name,
|
||||
chroot_path,
|
||||
build_succeeded: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn download_and_extract_chroot(
|
||||
async fn download_and_extract_chroot(
|
||||
series: &str,
|
||||
chroot_path: &PathBuf,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
@@ -58,10 +60,42 @@ impl EphemeralContextGuard {
|
||||
let tarball_filename = 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");
|
||||
let ctx = context::current();
|
||||
|
||||
// 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 {}...", series);
|
||||
Self::download_chroot_tarball(series, &tarball_path)?;
|
||||
Self::download_chroot_tarball(series, &tarball_path).await?;
|
||||
} else {
|
||||
log::debug!("Using cached chroot tarball for {}", series);
|
||||
}
|
||||
@@ -70,24 +104,61 @@ impl EphemeralContextGuard {
|
||||
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)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_chroot_tarball(series: &str, tarball_path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
async fn download_chroot_tarball(
|
||||
series: &str,
|
||||
tarball_path: &Path,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let ctx = context::current();
|
||||
|
||||
// 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()?;
|
||||
|
||||
// Make sure we have the right apt keyrings to mmdebstrap the chroot
|
||||
crate::apt::keyring::download_trust_keyring(Some(ctx.clone()), series).await?;
|
||||
|
||||
// Use mmdebstrap to download the tarball to the cache directory
|
||||
let status = context::current()
|
||||
let status = ctx
|
||||
.command("mmdebstrap")
|
||||
.arg("--variant=buildd")
|
||||
.arg("--mode=unshare")
|
||||
.arg("--include=mount")
|
||||
.arg("--format=tar")
|
||||
.arg(series)
|
||||
.arg(tarball_path.to_string_lossy().to_string())
|
||||
.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 {}", series).into());
|
||||
}
|
||||
|
||||
// Remove lockfile: tarball is fully downloaded
|
||||
let _ = ctx
|
||||
.command("rm")
|
||||
.arg("-f")
|
||||
.arg(lockfile_path.to_string_lossy().to_string())
|
||||
.status();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -108,41 +179,112 @@ impl EphemeralContextGuard {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_device_nodes(chroot_path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let ctx = context::current();
|
||||
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();
|
||||
|
||||
// Create new device nodes using fakeroot and mknod
|
||||
let status_null = ctx
|
||||
.command("sudo")
|
||||
.arg("mknod")
|
||||
.arg("-m")
|
||||
.arg("666")
|
||||
.arg(dev_null_path.to_string_lossy().to_string())
|
||||
.arg("c")
|
||||
.arg("1")
|
||||
.arg("3")
|
||||
.status()?;
|
||||
|
||||
let status_zero = ctx
|
||||
.command("sudo")
|
||||
.arg("mknod")
|
||||
.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...");
|
||||
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
|
||||
// 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();
|
||||
// Remove chroot directory only if build succeeded
|
||||
if self.build_succeeded {
|
||||
log::debug!(
|
||||
"Build succeeded, removing chroot directory: {}",
|
||||
self.chroot_path.display()
|
||||
);
|
||||
let result = context::current()
|
||||
.command("sudo")
|
||||
.arg("rm")
|
||||
.arg("-rf")
|
||||
.arg(&self.chroot_path)
|
||||
.status();
|
||||
|
||||
match result {
|
||||
Ok(status) => {
|
||||
if !status.success() {
|
||||
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 remove chroot directory {}",
|
||||
self.chroot_path.display()
|
||||
"Failed to execute cleanup command for {}: {}",
|
||||
self.chroot_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,26 @@ pub fn build(
|
||||
|
||||
let ctx = context::current();
|
||||
|
||||
// Parallel building: find local number of cores, and use that
|
||||
let num_cores = ctx
|
||||
.command("nproc")
|
||||
.output()
|
||||
.map(|output| {
|
||||
if output.status.success() {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.unwrap_or(1)
|
||||
} else {
|
||||
1 // Default to 1 if nproc fails
|
||||
}
|
||||
})
|
||||
.unwrap_or(1); // Default to 1 if we can't execute the command
|
||||
env.insert(
|
||||
"DEB_BUILD_OPTIONS".to_string(),
|
||||
format!("parallel={}", num_cores),
|
||||
);
|
||||
|
||||
if cross {
|
||||
log::debug!("Setting up environment for local cross build...");
|
||||
cross::setup_environment(&mut env, arch)?;
|
||||
@@ -31,7 +51,7 @@ pub fn build(
|
||||
}
|
||||
|
||||
// UBUNTU: Ensure 'universe' repository is enabled
|
||||
let mut sources = apt::load(None)?;
|
||||
let mut sources = apt::sources::load(None)?;
|
||||
let mut modified = false;
|
||||
for source in &mut sources {
|
||||
if source.uri.contains("ubuntu") && !source.components.contains(&"universe".to_string()) {
|
||||
@@ -40,7 +60,7 @@ pub fn build(
|
||||
}
|
||||
}
|
||||
if modified {
|
||||
apt::save_legacy(None, sources, "/etc/apt/sources.list")?;
|
||||
apt::sources::save_legacy(None, sources, "/etc/apt/sources.list")?;
|
||||
}
|
||||
|
||||
// Update package lists
|
||||
@@ -80,10 +100,16 @@ pub fn build(
|
||||
return Err("Could not install essential packages for the build".into());
|
||||
}
|
||||
|
||||
// Find the actual package directory
|
||||
let package_dir = crate::deb::find_package_directory(Path::new(build_root), package, version)?;
|
||||
let package_dir_str = package_dir
|
||||
.to_str()
|
||||
.ok_or("Invalid package directory path")?;
|
||||
|
||||
// Install build dependencies
|
||||
log::debug!("Installing build dependencies...");
|
||||
let mut cmd = ctx.command("apt-get");
|
||||
cmd.current_dir(format!("{build_root}/{package}"))
|
||||
cmd.current_dir(package_dir_str)
|
||||
.envs(env.clone())
|
||||
.arg("-y")
|
||||
.arg("build-dep");
|
||||
@@ -102,7 +128,7 @@ pub fn build(
|
||||
log::debug!("Building (debian/rules build) package...");
|
||||
let status = ctx
|
||||
.command("debian/rules")
|
||||
.current_dir(format!("{build_root}/{package}"))
|
||||
.current_dir(package_dir_str)
|
||||
.envs(env.clone())
|
||||
.arg("build")
|
||||
.status()?;
|
||||
@@ -113,7 +139,7 @@ pub fn build(
|
||||
// Run the 'binary' step to produce deb
|
||||
let status = ctx
|
||||
.command("fakeroot")
|
||||
.current_dir(format!("{build_root}/{package}"))
|
||||
.current_dir(package_dir_str)
|
||||
.envs(env.clone())
|
||||
.arg("debian/rules")
|
||||
.arg("binary")
|
||||
|
||||
147
src/deb/mod.rs
147
src/deb/mod.rs
@@ -17,7 +17,7 @@ pub enum BuildMode {
|
||||
}
|
||||
|
||||
/// Build package in 'cwd' to a .deb
|
||||
pub fn build_binary_package(
|
||||
pub async fn build_binary_package(
|
||||
arch: Option<&str>,
|
||||
series: Option<&str>,
|
||||
cwd: Option<&Path>,
|
||||
@@ -48,8 +48,8 @@ pub fn build_binary_package(
|
||||
};
|
||||
|
||||
// Create an ephemeral unshare context for all Local builds
|
||||
let _guard = if mode == BuildMode::Local {
|
||||
Some(ephemeral::EphemeralContextGuard::new(series)?)
|
||||
let mut guard = if mode == BuildMode::Local {
|
||||
Some(ephemeral::EphemeralContextGuard::new(series).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -82,9 +82,95 @@ pub fn build_binary_package(
|
||||
}
|
||||
}
|
||||
|
||||
// Mark build as successful to trigger chroot cleanup
|
||||
if let Some(ref mut g) = guard {
|
||||
g.mark_build_successful();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the current package directory by trying both patterns:
|
||||
/// - package/package
|
||||
/// - package/package-origversion
|
||||
pub(crate) fn find_package_directory(
|
||||
parent_dir: &Path,
|
||||
package: &str,
|
||||
version: &str,
|
||||
) -> Result<PathBuf, Box<dyn Error>> {
|
||||
let ctx = context::current();
|
||||
|
||||
// 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,
|
||||
@@ -95,7 +181,9 @@ fn find_dsc_file(
|
||||
let dsc_name = format!("{}_{}.dsc", package, version_without_epoch);
|
||||
let dsc_path = PathBuf::from(build_root).join(&dsc_name);
|
||||
|
||||
if !dsc_path.exists() {
|
||||
// Check if the .dsc file exists in current context
|
||||
let ctx = context::current();
|
||||
if !ctx.exists(&dsc_path)? {
|
||||
return Err(format!("Could not find .dsc file at {}", dsc_path.display()).into());
|
||||
}
|
||||
Ok(dsc_path)
|
||||
@@ -103,6 +191,7 @@ fn find_dsc_file(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serial_test::serial;
|
||||
async fn test_build_end_to_end(
|
||||
package: &str,
|
||||
series: &str,
|
||||
@@ -122,18 +211,24 @@ mod tests {
|
||||
let cwd = temp_dir.path();
|
||||
log::debug!("Created temporary directory: {}", cwd.display());
|
||||
|
||||
log::info!("Pulling package {} from Ubuntu {}...", package, series);
|
||||
crate::pull::pull(package, "", Some(series), "", "", dist, Some(cwd), None)
|
||||
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);
|
||||
|
||||
// Change directory to the package directory
|
||||
let cwd = cwd.join(package).join(package);
|
||||
let cwd = crate::deb::find_package_directory(cwd, package, &package_info.stanza.version)
|
||||
.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), Some(&cwd), cross, None)
|
||||
.await
|
||||
.expect("Cannot build binary package (deb)");
|
||||
log::info!("Successfully built binary package");
|
||||
|
||||
@@ -157,14 +252,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Tests below will be marked 'serial'
|
||||
// As builds are using ephemeral contexts, tests running on the same
|
||||
// process could use the ephemeral context of another thread and
|
||||
// interfere with each other.
|
||||
// FIXME: This is not ideal. In the future, we might want to
|
||||
// either explicitely pass context (instead of shared state) or
|
||||
// fork for building?
|
||||
#[tokio::test]
|
||||
#[test_log::test]
|
||||
#[serial]
|
||||
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")]
|
||||
#[serial]
|
||||
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]
|
||||
#[serial]
|
||||
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 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]
|
||||
#[serial]
|
||||
async fn test_deb_gcc_debian_end_to_end() {
|
||||
test_build_end_to_end("gcc-15", "sid", None, None, false).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,26 @@
|
||||
/// Call 'sbuild' with the dsc file to build the package with unshare
|
||||
use crate::context;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn build(
|
||||
package: &str,
|
||||
_version: &str,
|
||||
version: &str,
|
||||
arch: &str,
|
||||
series: &str,
|
||||
build_root: &str,
|
||||
cross: bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let ctx = context::current();
|
||||
|
||||
// Find the actual package directory
|
||||
let package_dir = crate::deb::find_package_directory(Path::new(build_root), package, version)?;
|
||||
let package_dir_str = package_dir
|
||||
.to_str()
|
||||
.ok_or("Invalid package directory path")?;
|
||||
|
||||
let mut cmd = ctx.command("sbuild");
|
||||
cmd.current_dir(format!("{}/{}", build_root, package));
|
||||
cmd.current_dir(package_dir_str);
|
||||
cmd.arg("--chroot-mode=unshare");
|
||||
cmd.arg("--no-clean-source");
|
||||
|
||||
|
||||
373
src/distro_info.rs
Normal file
373
src/distro_info.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
use chrono::NaiveDate;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::Deserialize;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Information about a specific distribution series
|
||||
pub struct SeriesInformation {
|
||||
/// Distribution series
|
||||
pub series: String,
|
||||
/// Codename, i.e. full name of series
|
||||
pub codename: String,
|
||||
/// Series version as numbers
|
||||
pub version: Option<String>,
|
||||
/// Series creation date
|
||||
pub created: NaiveDate,
|
||||
/// Series release date
|
||||
pub release: Option<NaiveDate>,
|
||||
/// Series end-of-life date
|
||||
pub eol: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeriesInfo {
|
||||
local: String,
|
||||
network: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DistData {
|
||||
base_url: String,
|
||||
archive_keyring: String,
|
||||
pockets: Vec<String>,
|
||||
series: SeriesInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Data {
|
||||
dist: std::collections::HashMap<String, DistData>,
|
||||
}
|
||||
|
||||
const DATA_YAML: &str = include_str!("../distro_info.yml");
|
||||
lazy_static! {
|
||||
static ref DATA: Data = serde_yaml::from_str(DATA_YAML).unwrap();
|
||||
}
|
||||
|
||||
fn parse_series_csv(content: &str) -> Result<Vec<SeriesInformation>, Box<dyn Error>> {
|
||||
let mut rdr = csv::ReaderBuilder::new()
|
||||
.flexible(true)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let headers = rdr.headers()?.clone();
|
||||
let series_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "series")
|
||||
.ok_or("Column 'series' not found")?;
|
||||
let codename_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "codename")
|
||||
.ok_or("Column 'codename' not found")?;
|
||||
let version_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "version")
|
||||
.ok_or("Column 'version' not found")?;
|
||||
let created_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "created")
|
||||
.ok_or("Column 'created' not found")?;
|
||||
let release_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "release")
|
||||
.ok_or("Column 'release' not found")?;
|
||||
let eol_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "eol")
|
||||
.ok_or("Column 'eol' not found")?;
|
||||
|
||||
let mut series_info_list = Vec::new();
|
||||
|
||||
for result in rdr.records() {
|
||||
let record = result?;
|
||||
let series = record.get(series_idx).unwrap().to_string();
|
||||
let codename = record.get(codename_idx).unwrap().to_string();
|
||||
let version = record.get(version_idx).map(|s| s.to_string());
|
||||
let created = record
|
||||
.get(created_idx)
|
||||
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap())
|
||||
.unwrap();
|
||||
let release = record
|
||||
.get(release_idx)
|
||||
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap());
|
||||
let eol = record
|
||||
.get(eol_idx)
|
||||
.map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").unwrap());
|
||||
|
||||
series_info_list.push(SeriesInformation {
|
||||
series,
|
||||
codename,
|
||||
version,
|
||||
created,
|
||||
release,
|
||||
eol,
|
||||
});
|
||||
}
|
||||
|
||||
// Revert to sort by most recent
|
||||
series_info_list.reverse();
|
||||
|
||||
Ok(series_info_list)
|
||||
}
|
||||
|
||||
/// Get time-ordered list of series information for a distribution, development series first
|
||||
pub async fn get_ordered_series(dist: &str) -> Result<Vec<SeriesInformation>, Box<dyn Error>> {
|
||||
let series_info = &DATA.dist.get(dist).unwrap().series;
|
||||
let content = if Path::new(series_info.local.as_str()).exists() {
|
||||
std::fs::read_to_string(format!("/usr/share/distro-info/{dist}.csv"))?
|
||||
} else {
|
||||
reqwest::get(series_info.network.as_str())
|
||||
.await?
|
||||
.text()
|
||||
.await?
|
||||
};
|
||||
|
||||
let series_info_list = parse_series_csv(&content)?;
|
||||
Ok(series_info_list)
|
||||
}
|
||||
|
||||
/// Get time-ordered list of series names for a distribution, development series first
|
||||
pub async fn get_ordered_series_name(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let series = get_ordered_series(dist).await?;
|
||||
Ok(series.iter().map(|info| info.series.clone()).collect())
|
||||
}
|
||||
|
||||
/// Get the latest released series for a dist (excluding future releases and special cases like sid)
|
||||
pub async fn get_latest_released_series(dist: &str) -> Result<String, Box<dyn Error>> {
|
||||
let series_info_list = get_ordered_series(dist).await?;
|
||||
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let mut released_series = Vec::new();
|
||||
|
||||
for series_info in series_info_list {
|
||||
// Skip 'sid' and series without release dates or with future release dates
|
||||
if series_info.series != "sid"
|
||||
&& series_info.series != "experimental"
|
||||
&& series_info.release.is_some()
|
||||
&& series_info.release.unwrap() <= today
|
||||
{
|
||||
released_series.push(series_info);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by release date descending (newest first)
|
||||
released_series.sort_by(|a, b| b.release.cmp(&a.release));
|
||||
|
||||
if let Some(latest) = released_series.first() {
|
||||
Ok(latest.series.clone())
|
||||
} else {
|
||||
Err("No released series found".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtain the distribution (eg. debian, ubuntu) from a distribution series (eg. noble, bookworm)
|
||||
pub async fn get_dist_from_series(series: &str) -> Result<String, Box<dyn Error>> {
|
||||
for dist in DATA.dist.keys() {
|
||||
if get_ordered_series_name(dist)
|
||||
.await?
|
||||
.contains(&series.to_string())
|
||||
{
|
||||
return Ok(dist.to_string());
|
||||
}
|
||||
}
|
||||
Err(format!("Unknown series: {}", series).into())
|
||||
}
|
||||
|
||||
/// Get the package pockets available for a given distribution
|
||||
///
|
||||
/// Example: get_dist_pockets(ubuntu) => ["proposed", "updates", ""]
|
||||
pub fn get_dist_pockets(dist: &str) -> Vec<String> {
|
||||
let mut pockets = DATA.dist.get(dist).unwrap().pockets.clone();
|
||||
|
||||
// Explicitely add 'main' pocket, which is just the empty string
|
||||
pockets.push("".to_string());
|
||||
|
||||
pockets
|
||||
}
|
||||
|
||||
/// Get the sources URL for a distribution, series, pocket, and component
|
||||
pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &str) -> String {
|
||||
let pocket_full = if pocket.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{}", pocket)
|
||||
};
|
||||
format!("{base_url}/dists/{series}{pocket_full}/{component}/source/Sources.gz")
|
||||
}
|
||||
|
||||
/// Get the archive base URL for a distribution
|
||||
///
|
||||
/// Example: ubuntu => http://archive.ubuntu.com/ubuntu
|
||||
pub fn get_base_url(dist: &str) -> String {
|
||||
DATA.dist.get(dist).unwrap().base_url.clone()
|
||||
}
|
||||
|
||||
/// Obtain the URL for the archive keyring of a distribution series
|
||||
pub async fn get_keyring_url(series: &str) -> Result<String, Box<dyn Error>> {
|
||||
let dist = get_dist_from_series(series).await?;
|
||||
let dist_data = DATA
|
||||
.dist
|
||||
.get(&dist)
|
||||
.ok_or(format!("Unsupported distribution: {}", dist))?;
|
||||
|
||||
// For Debian, we need the series number to form the keyring URL
|
||||
if dist == "debian" {
|
||||
// Special case for 'sid' - use the latest released version
|
||||
if series == "sid" || series == "experimental" {
|
||||
let latest_released = get_latest_released_series("debian").await?;
|
||||
let series_num = get_debian_series_number(&latest_released).await?.unwrap();
|
||||
// Replace {series_num} placeholder with the latest released series number
|
||||
Ok(dist_data
|
||||
.archive_keyring
|
||||
.replace("{series_num}", &series_num))
|
||||
} else {
|
||||
let series_num = get_debian_series_number(series).await?.unwrap();
|
||||
// Replace {series_num} placeholder with the actual series number
|
||||
Ok(dist_data
|
||||
.archive_keyring
|
||||
.replace("{series_num}", &series_num))
|
||||
}
|
||||
} else {
|
||||
// For other distributions like Ubuntu, use the keyring directly
|
||||
Ok(dist_data.archive_keyring.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtain the URL for the 'Release' file of a distribution series
|
||||
fn get_release_url(base_url: &str, series: &str, pocket: &str) -> String {
|
||||
let pocket_full = if pocket.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{}", pocket)
|
||||
};
|
||||
format!("{base_url}/dists/{series}{pocket_full}/Release")
|
||||
}
|
||||
|
||||
/// Obtain the components of a distribution series by parsing the 'Release' file
|
||||
pub async fn get_components(
|
||||
base_url: &str,
|
||||
series: &str,
|
||||
pocket: &str,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let url = get_release_url(base_url, series, pocket);
|
||||
log::debug!("Fetching Release file from: {}", url);
|
||||
|
||||
let content = reqwest::get(&url).await?.text().await?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Components:")
|
||||
&& let Some((_, components)) = line.split_once(':')
|
||||
{
|
||||
return Ok(components
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Components not found.".into())
|
||||
}
|
||||
|
||||
/// Map a Debian series name to its version number
|
||||
pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let series_info = &DATA.dist.get("debian").unwrap().series;
|
||||
let content = if Path::new(series_info.local.as_str()).exists() {
|
||||
std::fs::read_to_string(series_info.local.as_str())?
|
||||
} else {
|
||||
reqwest::get(series_info.network.as_str())
|
||||
.await?
|
||||
.text()
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut rdr = csv::ReaderBuilder::new()
|
||||
.flexible(true)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let headers = rdr.headers()?.clone();
|
||||
let series_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "series")
|
||||
.ok_or("Column 'series' not found")?;
|
||||
let version_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "version")
|
||||
.ok_or("Column 'version' not found")?;
|
||||
|
||||
for result in rdr.records() {
|
||||
let record = result?;
|
||||
if let (Some(s), Some(v)) = (record.get(series_idx), record.get(version_idx))
|
||||
&& s.to_lowercase() == series.to_lowercase()
|
||||
{
|
||||
return Ok(Some(v.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_debian_series() {
|
||||
let series = get_ordered_series_name("debian").await.unwrap();
|
||||
assert!(series.contains(&"sid".to_string()));
|
||||
assert!(series.contains(&"bookworm".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_ubuntu_series() {
|
||||
let series = get_ordered_series_name("ubuntu").await.unwrap();
|
||||
assert!(series.contains(&"noble".to_string()));
|
||||
assert!(series.contains(&"jammy".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dist_from_series() {
|
||||
assert_eq!(get_dist_from_series("sid").await.unwrap(), "debian");
|
||||
assert_eq!(get_dist_from_series("noble").await.unwrap(), "ubuntu");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_debian_series_number() {
|
||||
// Test with known Debian series
|
||||
let bookworm_number = get_debian_series_number("bookworm").await.unwrap();
|
||||
assert!(bookworm_number.is_some());
|
||||
assert_eq!(bookworm_number.unwrap(), "12");
|
||||
|
||||
let trixie_number = get_debian_series_number("trixie").await.unwrap();
|
||||
assert!(trixie_number.is_some());
|
||||
assert_eq!(trixie_number.unwrap(), "13");
|
||||
|
||||
// Test with unknown series
|
||||
let unknown_number = get_debian_series_number("unknown").await.unwrap();
|
||||
assert!(unknown_number.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_keyring_url_sid() {
|
||||
// Test that 'sid' uses the latest released version for keyring URL
|
||||
let sid_keyring = get_keyring_url("sid").await.unwrap();
|
||||
let latest_released = get_latest_released_series("debian").await.unwrap();
|
||||
let latest_keyring = get_keyring_url(&latest_released).await.unwrap();
|
||||
|
||||
// The keyring URL for 'sid' should be the same as the latest released version
|
||||
assert_eq!(sid_keyring, latest_keyring);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_latest_released_debian_series() {
|
||||
// Test that we get a valid released series
|
||||
let latest_released = get_latest_released_series("debian").await.unwrap();
|
||||
|
||||
// Should not be 'sid' or 'experimental'
|
||||
assert_ne!(latest_released, "sid");
|
||||
assert_ne!(latest_released, "experimental");
|
||||
|
||||
// Should have a version number
|
||||
let version = get_debian_series_number(&latest_released).await.unwrap();
|
||||
assert!(version.is_some());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ pub mod build;
|
||||
pub mod changelog;
|
||||
/// Build a Debian package into a binary (.deb)
|
||||
pub mod deb;
|
||||
/// Obtain general information about distribution, series, etc
|
||||
pub mod distro_info;
|
||||
/// Obtain information about one or multiple packages
|
||||
pub mod package_info;
|
||||
/// Download a source package locally
|
||||
|
||||
57
src/main.rs
57
src/main.rs
@@ -7,8 +7,6 @@ use pkh::context::ContextConfig;
|
||||
|
||||
extern crate flate2;
|
||||
|
||||
use pkh::pull::pull;
|
||||
|
||||
use pkh::changelog::generate_entry;
|
||||
|
||||
use indicatif_log_bridge::LogWrapper;
|
||||
@@ -39,6 +37,7 @@ fn main() {
|
||||
.required(false),
|
||||
)
|
||||
.arg(arg!(-v --version <version> "Target package version").required(false))
|
||||
.arg(arg!(--archive "Only use the archive to download package source, not git").required(false))
|
||||
.arg(arg!(--ppa <ppa> "Download the package from a specific PPA").required(false))
|
||||
.arg(arg!(<package> "Target package")),
|
||||
)
|
||||
@@ -49,10 +48,10 @@ fn main() {
|
||||
.arg(arg!(--backport "This changelog is for a backport entry").required(false))
|
||||
.arg(arg!(-v --version <version> "Target version").required(false)),
|
||||
)
|
||||
.subcommand(Command::new("build").about("Build the source package"))
|
||||
.subcommand(Command::new("build").about("Build the source package (into a .dsc)"))
|
||||
.subcommand(
|
||||
Command::new("deb")
|
||||
.about("Build the binary package")
|
||||
.about("Build the source package into binary package (.deb)")
|
||||
.arg(arg!(-s --series <series> "Target distribution series").required(false))
|
||||
.arg(arg!(-a --arch <arch> "Target architecture").required(false))
|
||||
.arg(arg!(--cross "Cross-compile for target architecture (instead of qemu-binfmt)")
|
||||
@@ -94,28 +93,37 @@ fn main() {
|
||||
let package = sub_matches.get_one::<String>("package").expect("required");
|
||||
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
||||
let dist = sub_matches.get_one::<String>("dist").map(|s| s.as_str());
|
||||
let version = sub_matches
|
||||
.get_one::<String>("version")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
let ppa = sub_matches
|
||||
.get_one::<String>("ppa")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
let version = sub_matches.get_one::<String>("version").map(|s| s.as_str());
|
||||
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
|
||||
let archive = sub_matches.get_one::<bool>("archive").unwrap_or(&false);
|
||||
|
||||
let (pb, progress_callback) = ui::create_progress_bar(&multi);
|
||||
|
||||
// Convert PPA to base URL if provided
|
||||
let base_url = ppa.and_then(|ppa_str| {
|
||||
// PPA format: user/ppa_name
|
||||
let parts: Vec<&str> = ppa_str.split('/').collect();
|
||||
if parts.len() == 2 {
|
||||
Some(pkh::package_info::ppa_to_base_url(parts[0], parts[1]))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
// Since pull is async, we need to block on it
|
||||
if let Err(e) = rt.block_on(pull(
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
"",
|
||||
ppa,
|
||||
dist,
|
||||
None,
|
||||
Some(&progress_callback),
|
||||
)) {
|
||||
if let Err(e) = rt.block_on(async {
|
||||
let package_info = pkh::package_info::lookup(
|
||||
package,
|
||||
version,
|
||||
series,
|
||||
"",
|
||||
dist,
|
||||
base_url.as_deref(),
|
||||
Some(&progress_callback),
|
||||
)
|
||||
.await?;
|
||||
pkh::pull::pull(&package_info, None, Some(&progress_callback), *archive).await
|
||||
}) {
|
||||
pb.finish_and_clear();
|
||||
error!("{}", e);
|
||||
std::process::exit(1);
|
||||
@@ -158,9 +166,10 @@ fn main() {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
if let Err(e) = rt.block_on(async {
|
||||
pkh::deb::build_binary_package(arch, series, Some(cwd.as_path()), *cross, mode)
|
||||
{
|
||||
.await
|
||||
}) {
|
||||
error!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
use chrono::NaiveDate;
|
||||
use flate2::read::GzDecoder;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::ProgressCallback;
|
||||
use log::{debug, warn};
|
||||
|
||||
const BASE_URL_UBUNTU: &str = "http://archive.ubuntu.com/ubuntu";
|
||||
const BASE_URL_DEBIAN: &str = "http://deb.debian.org/debian";
|
||||
/// Convert a PPA specification to a base URL
|
||||
///
|
||||
/// # Arguments
|
||||
/// * user: user for the PPA
|
||||
/// * name: name of the PPA
|
||||
///
|
||||
/// # Returns
|
||||
/// * The base URL for the PPA (e.g., "https://ppa.launchpadcontent.net/user/ppa_name/ubuntu/")
|
||||
pub fn ppa_to_base_url(user: &str, name: &str) -> String {
|
||||
format!("https://ppa.launchpadcontent.net/{}/{}/ubuntu", user, name)
|
||||
}
|
||||
|
||||
async fn check_launchpad_repo(package: &str) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let url = format!("https://git.launchpad.net/ubuntu/+source/{}", package);
|
||||
@@ -25,100 +32,6 @@ async fn check_launchpad_repo(package: &str) -> Result<Option<String>, Box<dyn E
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_series_csv(content: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let mut rdr = csv::ReaderBuilder::new()
|
||||
.flexible(true)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let headers = rdr.headers()?.clone();
|
||||
let series_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "series")
|
||||
.ok_or("Column 'series' not found")?;
|
||||
let created_idx = headers
|
||||
.iter()
|
||||
.position(|h| h == "created")
|
||||
.ok_or("Column 'created' not found")?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for result in rdr.records() {
|
||||
let record = result?;
|
||||
if let (Some(s), Some(c)) = (record.get(series_idx), record.get(created_idx))
|
||||
&& let Ok(date) = NaiveDate::parse_from_str(c, "%Y-%m-%d")
|
||||
{
|
||||
entries.push((s.to_string(), date));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
entries.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
Ok(entries.into_iter().map(|(s, _)| s).collect())
|
||||
}
|
||||
|
||||
async fn get_ordered_series(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let content = if Path::new(format!("/usr/share/distro-info/{dist}.csv").as_str()).exists() {
|
||||
std::fs::read_to_string(format!("/usr/share/distro-info/{dist}.csv"))?
|
||||
} else {
|
||||
reqwest::get(
|
||||
format!("https://salsa.debian.org/debian/distro-info-data/-/raw/main/{dist}.csv")
|
||||
.as_str(),
|
||||
)
|
||||
.await?
|
||||
.text()
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut series = parse_series_csv(&content)?;
|
||||
|
||||
// For Debian, ensure 'sid' is first if it's not (it usually doesn't have a date or is very old/new depending on file)
|
||||
// Actually in the file sid has 1993 date.
|
||||
// But we want to try 'sid' (unstable) first for Debian.
|
||||
if dist == "debian" {
|
||||
series.retain(|s| s != "sid");
|
||||
series.insert(0, "sid".to_string());
|
||||
}
|
||||
|
||||
Ok(series)
|
||||
}
|
||||
|
||||
// Keep existing functions for compatibility or refactor them to use get_ordered_series
|
||||
async fn get_series_from_url(url: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let content = reqwest::get(url).await?.text().await?;
|
||||
parse_series_csv(&content)
|
||||
}
|
||||
|
||||
fn get_series_from_file(path: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
parse_series_csv(&content)
|
||||
}
|
||||
|
||||
/// Obtain a list of series from a distribution
|
||||
pub async fn get_dist_series(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
if Path::new(format!("/usr/share/distro-info/{dist}.csv").as_str()).exists() {
|
||||
get_series_from_file(format!("/usr/share/distro-info/{dist}.csv").as_str())
|
||||
} else {
|
||||
get_series_from_url(
|
||||
format!("https://salsa.debian.org/debian/distro-info-data/-/raw/main/{dist}.csv")
|
||||
.as_str(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtain the distribution (eg. debian, ubuntu) from a distribution series (eg. noble, bookworm)
|
||||
pub async fn get_dist_from_series(series: &str) -> Result<String, Box<dyn Error>> {
|
||||
let debian_series = get_dist_series("debian").await?;
|
||||
if debian_series.contains(&series.to_string()) {
|
||||
return Ok("debian".to_string());
|
||||
}
|
||||
let ubuntu_series = get_dist_series("ubuntu").await?;
|
||||
if ubuntu_series.contains(&series.to_string()) {
|
||||
return Ok("ubuntu".to_string());
|
||||
}
|
||||
Err(format!("Unknown series: {}", series).into())
|
||||
}
|
||||
|
||||
/// A File used in a source package
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileEntry {
|
||||
@@ -173,66 +86,6 @@ impl PackageInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dist_pockets(dist: &str) -> Vec<&'static str> {
|
||||
match dist {
|
||||
"ubuntu" => vec!["proposed", "updates", ""],
|
||||
"debian" => vec!["proposed-updates", "updates", ""],
|
||||
_ => vec![""],
|
||||
}
|
||||
}
|
||||
|
||||
fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &str) -> String {
|
||||
let pocket_full = if pocket.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{}", pocket)
|
||||
};
|
||||
format!("{base_url}/dists/{series}{pocket_full}/{component}/source/Sources.gz")
|
||||
}
|
||||
|
||||
fn get_base_url(dist: &str) -> &str {
|
||||
match dist {
|
||||
"ubuntu" => BASE_URL_UBUNTU,
|
||||
"debian" => BASE_URL_DEBIAN,
|
||||
_ => panic!("Unknown distribution"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtain the URL for the 'Release' file of a distribution series
|
||||
fn get_release_url(base_url: &str, series: &str, pocket: &str) -> String {
|
||||
let pocket_full = if pocket.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{}", pocket)
|
||||
};
|
||||
format!("{base_url}/dists/{series}{pocket_full}/Release")
|
||||
}
|
||||
|
||||
/// Obtain the components of a distribution series by parsing the 'Release' file
|
||||
async fn get_components(
|
||||
base_url: &str,
|
||||
series: &str,
|
||||
pocket: &str,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let url = get_release_url(base_url, series, pocket);
|
||||
debug!("Fetching Release file from: {}", url);
|
||||
|
||||
let content = reqwest::get(&url).await?.text().await?;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Components:")
|
||||
&& let Some((_, components)) = line.split_once(':')
|
||||
{
|
||||
return Ok(components
|
||||
.split_whitespace()
|
||||
.map(|s| s.to_string())
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Components not found.".into())
|
||||
}
|
||||
|
||||
struct DebianSources {
|
||||
splitted_sources: std::str::Split<'static, &'static str>,
|
||||
}
|
||||
@@ -332,13 +185,14 @@ fn parse_sources(
|
||||
}
|
||||
|
||||
/// Get package information from a package, distribution series, and pocket
|
||||
pub async fn get(
|
||||
async fn get(
|
||||
package_name: &str,
|
||||
series: &str,
|
||||
pocket: &str,
|
||||
version: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
) -> Result<PackageInfo, Box<dyn Error>> {
|
||||
let dist = get_dist_from_series(series).await?;
|
||||
let dist = crate::distro_info::get_dist_from_series(series).await?;
|
||||
|
||||
// Handle Ubuntu case: Vcs-Git does not usually point to Launchpad but Salsa
|
||||
// We need to check manually if there is a launchpad repository for the package
|
||||
@@ -350,13 +204,22 @@ pub async fn get(
|
||||
preferred_vcs = Some(lp_url);
|
||||
}
|
||||
|
||||
let base_url = get_base_url(&dist);
|
||||
// Determine the base URL to use (either provided PPA URL or default archive)
|
||||
let distro_base_url = crate::distro_info::get_base_url(&dist);
|
||||
let base_url = if let Some(ppa_url) = base_url {
|
||||
ppa_url.to_string()
|
||||
} else {
|
||||
distro_base_url.clone()
|
||||
};
|
||||
|
||||
let components = get_components(base_url, series, pocket).await?;
|
||||
// If using a custom base URL (PPA), disable VCS lookup to force archive download
|
||||
let from_ppa = base_url != distro_base_url;
|
||||
|
||||
let components = crate::distro_info::get_components(&base_url, series, pocket).await?;
|
||||
debug!("Found components: {:?}", components);
|
||||
|
||||
for component in components {
|
||||
let url = get_sources_url(base_url, series, pocket, &component);
|
||||
let url = crate::distro_info::get_sources_url(&base_url, series, pocket, &component);
|
||||
|
||||
debug!("Fetching sources from: {}", url);
|
||||
|
||||
@@ -387,6 +250,11 @@ pub async fn get(
|
||||
preferred_vcs = Some(vcs.clone());
|
||||
}
|
||||
|
||||
// If downloading from PPA, make sure we don't use a VCS
|
||||
if from_ppa {
|
||||
preferred_vcs = None;
|
||||
}
|
||||
|
||||
let archive_url = format!("{base_url}/{0}", stanza.directory);
|
||||
return Ok(PackageInfo {
|
||||
dist,
|
||||
@@ -406,14 +274,15 @@ pub async fn get(
|
||||
}
|
||||
|
||||
/// Try to find package information in a distribution, trying all series and pockets
|
||||
pub async fn find_package(
|
||||
async fn find_package(
|
||||
package_name: &str,
|
||||
dist: &str,
|
||||
pocket: &str,
|
||||
version: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
progress: ProgressCallback<'_>,
|
||||
) -> Result<PackageInfo, Box<dyn Error>> {
|
||||
let series_list = get_ordered_series(dist).await?;
|
||||
let series_list = crate::distro_info::get_ordered_series_name(dist).await?;
|
||||
|
||||
for (i, series) in series_list.iter().enumerate() {
|
||||
if let Some(cb) = progress {
|
||||
@@ -421,13 +290,13 @@ pub async fn find_package(
|
||||
}
|
||||
|
||||
let pockets = if pocket.is_empty() {
|
||||
get_dist_pockets(dist)
|
||||
crate::distro_info::get_dist_pockets(dist)
|
||||
} else {
|
||||
vec![pocket]
|
||||
vec![pocket.to_string()]
|
||||
};
|
||||
|
||||
for p in pockets {
|
||||
match get(package_name, series, p, version).await {
|
||||
match get(package_name, series, &p, version, base_url).await {
|
||||
Ok(info) => {
|
||||
if i > 0 {
|
||||
warn!(
|
||||
@@ -452,6 +321,72 @@ pub async fn find_package(
|
||||
Err(format!("Package '{}' not found.", package_name).into())
|
||||
}
|
||||
|
||||
/// Lookup package information for a source package
|
||||
///
|
||||
/// This function obtains package information either directly from a specific series
|
||||
/// or by searching across all series in a distribution.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `package` - The name of the package to look up
|
||||
/// * `version` - Optional specific version to look for
|
||||
/// * `series` - Optional distribution series (e.g., "noble", "bookworm")
|
||||
/// * `pocket` - Pocket to search in (e.g., "updates", "security", or "" for main)
|
||||
/// * `dist` - Optional distribution name (e.g., "ubuntu", "debian")
|
||||
/// * `base_url` - Optional base URL for the package archive (e.g., "https://ppa.launchpadcontent.net/user/ppa/ubuntu/")
|
||||
/// * `progress` - Optional progress callback
|
||||
pub async fn lookup(
|
||||
package: &str,
|
||||
version: Option<&str>,
|
||||
series: Option<&str>,
|
||||
pocket: &str,
|
||||
dist: Option<&str>,
|
||||
base_url: Option<&str>,
|
||||
progress: ProgressCallback<'_>,
|
||||
) -> Result<PackageInfo, Box<dyn Error>> {
|
||||
// Obtain the package information, either directly in a series or with a search in all series
|
||||
let package_info = if let Some(s) = series {
|
||||
if let Some(cb) = progress {
|
||||
cb(
|
||||
&format!("Resolving package info for {}...", package),
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the package information from that series and pocket
|
||||
get(package, s, pocket, version, base_url).await?
|
||||
} else {
|
||||
let dist = dist.unwrap_or_else(||
|
||||
// Use auto-detection to see if current distro is ubuntu, or fallback to debian by default
|
||||
if std::process::Command::new("lsb_release").arg("-i").arg("-s").output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_lowercase()).unwrap_or_default() == "ubuntu" {
|
||||
"ubuntu"
|
||||
} else {
|
||||
"debian"
|
||||
}
|
||||
);
|
||||
|
||||
if let Some(cb) = progress {
|
||||
cb(
|
||||
&format!(
|
||||
"Searching for package {} in {}...",
|
||||
package,
|
||||
if base_url.is_none() { dist } else { "ppa" }
|
||||
),
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find the package in all series from that dist
|
||||
find_package(package, dist, pocket, version, base_url, progress).await?
|
||||
};
|
||||
|
||||
Ok(package_info)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -473,26 +408,6 @@ mod tests {
|
||||
assert!(url.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_debian_series() {
|
||||
let series = get_dist_series("debian").await.unwrap();
|
||||
assert!(series.contains(&"sid".to_string()));
|
||||
assert!(series.contains(&"bookworm".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_ubuntu_series() {
|
||||
let series = get_dist_series("ubuntu").await.unwrap();
|
||||
assert!(series.contains(&"noble".to_string()));
|
||||
assert!(series.contains(&"jammy".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dist_from_series() {
|
||||
assert_eq!(get_dist_from_series("sid").await.unwrap(), "debian");
|
||||
assert_eq!(get_dist_from_series("noble").await.unwrap(), "ubuntu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sources() {
|
||||
use flate2::Compression;
|
||||
@@ -530,7 +445,7 @@ Version: 1.0
|
||||
#[tokio::test]
|
||||
async fn test_find_package_fallback() {
|
||||
// python2.7 is in bullseye but not above
|
||||
let info = find_package("python2.7", "debian", "", None, None)
|
||||
let info = find_package("python2.7", "debian", "", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(info.stanza.package, "python2.7");
|
||||
@@ -540,7 +455,7 @@ Version: 1.0
|
||||
#[tokio::test]
|
||||
async fn test_find_package_devel() {
|
||||
// hello is in sid
|
||||
let info = find_package("hello", "debian", "", None, None)
|
||||
let info = find_package("hello", "debian", "", None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(info.stanza.package, "hello");
|
||||
|
||||
321
src/pull.rs
321
src/pull.rs
@@ -1,8 +1,8 @@
|
||||
use std::cmp::min;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::package_info;
|
||||
use crate::package_info::PackageInfo;
|
||||
|
||||
use std::process::Command;
|
||||
@@ -91,25 +91,93 @@ use futures_util::StreamExt;
|
||||
use tar::Archive;
|
||||
use xz2::read::XzDecoder;
|
||||
|
||||
fn extract_archive(path: &Path, dest: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let file = File::open(path)?;
|
||||
let filename = path.file_name().unwrap().to_string_lossy();
|
||||
fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), Box<dyn Error>> {
|
||||
if !dst.exists() {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
}
|
||||
|
||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
let tar = GzDecoder::new(file);
|
||||
let mut archive = Archive::new(tar);
|
||||
archive.unpack(dest)?;
|
||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||
let tar = XzDecoder::new(file);
|
||||
let mut archive = Archive::new(tar);
|
||||
archive.unpack(dest)?;
|
||||
} else {
|
||||
return Err(format!("Unsupported archive format: {}", filename).into());
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
|
||||
if src_path.is_dir() {
|
||||
copy_dir_all(&src_path, &dst_path)?;
|
||||
} else {
|
||||
std::fs::copy(&src_path, &dst_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper function to extract tar archive with progress tracking
|
||||
fn extract_tar_archive<D, F>(
|
||||
file_path: &Path,
|
||||
dest: &Path,
|
||||
progress: ProgressCallback<'_>,
|
||||
decoder_factory: F,
|
||||
) -> Result<Vec<String>, Box<dyn Error>>
|
||||
where
|
||||
D: std::io::Read,
|
||||
F: Fn(File) -> D,
|
||||
{
|
||||
let file = File::open(file_path)?;
|
||||
let decoder = decoder_factory(file);
|
||||
let mut archive = Archive::new(decoder);
|
||||
|
||||
// Get total number of entries for progress tracking
|
||||
let total_entries = archive.entries()?.count();
|
||||
let mut current_entry = 0;
|
||||
|
||||
// Reset the archive to read entries again
|
||||
let file = File::open(file_path)?;
|
||||
let decoder = decoder_factory(file);
|
||||
let mut archive = Archive::new(decoder);
|
||||
|
||||
let mut extracted_files = Vec::new();
|
||||
|
||||
for entry in archive.entries()? {
|
||||
let mut entry = entry?;
|
||||
let path = entry.path()?.to_path_buf();
|
||||
let dest_path = dest.join(&path);
|
||||
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Extract the file
|
||||
entry.unpack(&dest_path)?;
|
||||
extracted_files.push(dest_path.to_string_lossy().to_string());
|
||||
|
||||
current_entry += 1;
|
||||
|
||||
// Report progress
|
||||
if let Some(cb) = progress {
|
||||
cb("", "Extracting...", current_entry, total_entries);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(extracted_files)
|
||||
}
|
||||
|
||||
fn extract_archive(
|
||||
path: &Path,
|
||||
dest: &Path,
|
||||
progress: ProgressCallback<'_>,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let filename = path.file_name().unwrap().to_string_lossy();
|
||||
|
||||
if filename.ends_with(".tar.gz") || filename.ends_with(".tgz") {
|
||||
extract_tar_archive(path, dest, progress, GzDecoder::new)
|
||||
} else if filename.ends_with(".tar.xz") || filename.ends_with(".txz") {
|
||||
extract_tar_archive(path, dest, progress, XzDecoder::new)
|
||||
} else {
|
||||
Err(format!("Unsupported archive format: {}", filename).into())
|
||||
}
|
||||
}
|
||||
|
||||
fn checkout_pristine_tar(package_dir: &Path, filename: &str) -> Result<(), Box<dyn Error>> {
|
||||
let output = Command::new("pristine-tar")
|
||||
.current_dir(package_dir)
|
||||
@@ -307,90 +375,120 @@ async fn fetch_archive_sources(
|
||||
for file in &info.stanza.files {
|
||||
let url = format!("{}/{}", info.archive_url, file.name);
|
||||
download_file_checksum(&url, &file.sha256, package_dir, progress).await?;
|
||||
}
|
||||
|
||||
// Extract the debian tarball or diff
|
||||
let debian_file = info
|
||||
.stanza
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.name.contains(".debian.tar.") || f.name.contains(".diff.gz"));
|
||||
// Extract all tar archives, merging extracted directories
|
||||
if file.name.ends_with(".tar.gz") || file.name.ends_with(".tar.xz") {
|
||||
let path = package_dir.join(&file.name);
|
||||
let extract_dir = package_dir.join(&info.stanza.package);
|
||||
|
||||
if let Some(file) = debian_file {
|
||||
let path = package_dir.join(&file.name);
|
||||
let extract_dir = package_dir.join(&info.stanza.package);
|
||||
let extracted = extract_archive(&path, &extract_dir, progress)?;
|
||||
|
||||
if (file.name.ends_with(".tar.xz") || file.name.ends_with(".tar.gz"))
|
||||
&& let Err(e) = extract_archive(&path, &extract_dir)
|
||||
{
|
||||
return Err(format!("Failed to extract {}: {}", file.name, e).into());
|
||||
// Special case: the debian tar does only contain 'debian'
|
||||
if file.name.contains("debian.tar.") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// List root directories extracted and use the first one as the source directory
|
||||
debug!("Root directories extracted:");
|
||||
let mut source_dir: Option<PathBuf> = None;
|
||||
for file in &extracted {
|
||||
let path = Path::new(file);
|
||||
// Check if this is a directory and is at the archive root level
|
||||
// (i.e., the path relative to extract_dir has no parent components)
|
||||
if let Ok(relative_path) = path.strip_prefix(&extract_dir)
|
||||
&& relative_path.components().count() == 1
|
||||
&& path.is_dir()
|
||||
{
|
||||
debug!("- {}", relative_path.file_name().unwrap().to_string_lossy());
|
||||
// Use the first directory found as the source
|
||||
if source_dir.is_none() {
|
||||
source_dir = Some(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the extracted directory as the source, assuming there is only one
|
||||
if let Some(src_dir) = source_dir {
|
||||
let target_dir = package_dir.join(&info.stanza.package);
|
||||
|
||||
if target_dir.exists() {
|
||||
// Target exists, we need to merge contents
|
||||
for sub_entry in std::fs::read_dir(&src_dir)? {
|
||||
let sub_entry = sub_entry?;
|
||||
let sub_path = sub_entry.path();
|
||||
let target_path = target_dir.join(sub_entry.file_name());
|
||||
if sub_path.is_dir() {
|
||||
std::fs::create_dir_all(&target_path)?;
|
||||
// Recursively copy directory contents
|
||||
copy_dir_all(&sub_path, &target_path)?;
|
||||
} else {
|
||||
std::fs::copy(&sub_path, &target_path)?;
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(&src_dir)?;
|
||||
} else {
|
||||
std::fs::rename(&src_dir, &target_dir)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove archive after extraction
|
||||
std::fs::remove_file(&path)?;
|
||||
// Extract and apply .diff.gz if present (old packages)
|
||||
if file.name.ends_with(".diff.gz") {
|
||||
let diff_gz_path = package_dir.join(&file.name);
|
||||
let source_dir = package_dir.join(&info.stanza.package);
|
||||
|
||||
// Create the .diff file path by replacing .gz with empty string
|
||||
let diff_path = diff_gz_path.with_extension("");
|
||||
|
||||
// Decompress the .diff.gz file directly to .diff
|
||||
let input_file = File::open(&diff_gz_path)?;
|
||||
let mut decoder = GzDecoder::new(input_file);
|
||||
let mut output_file = File::create(&diff_path)?;
|
||||
|
||||
std::io::copy(&mut decoder, &mut output_file)?;
|
||||
|
||||
// Use relative path for the diff file (it's in the parent directory)
|
||||
let relative_diff_path =
|
||||
format!("../{}", diff_path.file_name().unwrap().to_string_lossy());
|
||||
|
||||
// Apply the patch using the patch command with relative path
|
||||
let output = Command::new("patch")
|
||||
.current_dir(&source_dir)
|
||||
.arg("-p1")
|
||||
.arg("--input")
|
||||
.arg(&relative_diff_path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(
|
||||
format!("Failed to apply patch: {}\n{}", diff_path.display(), stderr).into(),
|
||||
);
|
||||
}
|
||||
|
||||
debug!("Successfully applied patch: {}", diff_path.display());
|
||||
|
||||
// Clean up the extracted .diff file
|
||||
std::fs::remove_file(&diff_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull a source package locally
|
||||
/// Pull a source package locally using pre-retrieved package information
|
||||
///
|
||||
/// Will try to find the package information, and use it to download it over prefered way
|
||||
/// (either git or direct archive download), as well as orig tarball, inside 'package' directory
|
||||
/// The source will be extracted under 'package/package'
|
||||
/// This function takes a PackageInfo struct and downloads the package using the preferred method
|
||||
/// (either git or direct archive download), as well as orig tarball, inside 'package' directory.
|
||||
/// The source will be extracted under 'package/package'.
|
||||
pub async fn pull(
|
||||
package: &str,
|
||||
_version: &str,
|
||||
series: Option<&str>,
|
||||
pocket: &str,
|
||||
_ppa: &str,
|
||||
dist: Option<&str>,
|
||||
package_info: &PackageInfo,
|
||||
cwd: Option<&Path>,
|
||||
progress: ProgressCallback<'_>,
|
||||
) -> Result<PackageInfo, Box<dyn Error>> {
|
||||
let version_opt = if _version.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(_version)
|
||||
};
|
||||
|
||||
/* Obtain the package information, either directly in a series or with a search in all series */
|
||||
let package_info = if let Some(s) = series {
|
||||
if let Some(cb) = progress {
|
||||
cb(
|
||||
&format!("Resolving package info for {}...", package),
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the package information from that series and pocket
|
||||
package_info::get(package, s, pocket, version_opt).await?
|
||||
} else {
|
||||
let dist = dist.unwrap_or_else(||
|
||||
// Use auto-detection to see if current distro is ubuntu, or fallback to debian by default
|
||||
if std::process::Command::new("lsb_release").arg("-i").arg("-s").output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_lowercase()).unwrap_or_default() == "ubuntu" {
|
||||
"ubuntu"
|
||||
} else {
|
||||
"debian"
|
||||
}
|
||||
);
|
||||
|
||||
if let Some(cb) = progress {
|
||||
cb(
|
||||
&format!("Searching for package {} in {}...", package, dist),
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find the package in all series from that dist
|
||||
package_info::find_package(package, dist, pocket, version_opt, progress).await?
|
||||
};
|
||||
|
||||
force_archive: bool,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let package = &package_info.stanza.package;
|
||||
let series = &package_info.series;
|
||||
let package_dir = if let Some(path) = cwd {
|
||||
path.join(package)
|
||||
} else {
|
||||
@@ -398,15 +496,20 @@ pub async fn pull(
|
||||
};
|
||||
|
||||
/* Fetch the package: either via git (preferred VCS) or the archive */
|
||||
if let Some(ref url) = package_info.preferred_vcs {
|
||||
if let Some(ref url) = package_info.preferred_vcs
|
||||
&& !force_archive
|
||||
{
|
||||
// We have found a preferred VCS (git repository) for the package, so
|
||||
// we fetch the package from that repo.
|
||||
|
||||
// Depending on target series, we pick target branch; if no series is specified,
|
||||
// Depending on target series, we pick target branch; if latest series is specified,
|
||||
// we target the development branch, i.e. the default branch
|
||||
let branch_name = if let Some(s) = series {
|
||||
let branch_name = if crate::distro_info::get_ordered_series_name(package_info.dist.as_str())
|
||||
.await?[0]
|
||||
!= *series
|
||||
{
|
||||
if package_info.dist == "ubuntu" {
|
||||
Some(format!("{}/{}", package_info.dist, s))
|
||||
Some(format!("{}/{}", package_info.dist, series))
|
||||
} else {
|
||||
// Debian does not have reliable branch naming...
|
||||
// For now, we skip that part and clone default
|
||||
@@ -446,7 +549,7 @@ pub async fn pull(
|
||||
if let Some(cb) = progress {
|
||||
cb("Fetching orig tarball...", "", 0, 0);
|
||||
}
|
||||
fetch_orig_tarball(&package_info, Some(&package_dir), progress).await?;
|
||||
fetch_orig_tarball(package_info, Some(&package_dir), progress).await?;
|
||||
} else {
|
||||
debug!("Native package, skipping orig tarball fetch.");
|
||||
}
|
||||
@@ -454,35 +557,43 @@ pub async fn pull(
|
||||
if let Some(cb) = progress {
|
||||
cb("Fetching dsc file...", "", 0, 0);
|
||||
}
|
||||
fetch_dsc_file(&package_info, Some(&package_dir), progress).await?;
|
||||
fetch_dsc_file(package_info, Some(&package_dir), progress).await?;
|
||||
} else {
|
||||
// Fallback to archive fetching
|
||||
if let Some(cb) = progress {
|
||||
cb("Downloading from archive...", "", 0, 0);
|
||||
}
|
||||
fetch_archive_sources(&package_info, Some(&package_dir), progress).await?;
|
||||
fetch_archive_sources(package_info, Some(&package_dir), progress).await?;
|
||||
}
|
||||
|
||||
Ok(package_info)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn test_pull_package_end_to_end(package: &str, series: Option<&str>, dist: Option<&str>) {
|
||||
async fn test_pull_package_end_to_end(
|
||||
package: &str,
|
||||
series: Option<&str>,
|
||||
dist: Option<&str>,
|
||||
archive: Option<bool>,
|
||||
) {
|
||||
// This test verifies that 'pkh pull' clones the repo and fetches the tarball.
|
||||
|
||||
// For determinism, we require for tests that either a distro or series is specified,
|
||||
// as no distribution would mean fallback to system distro
|
||||
assert!(dist != None || series != None);
|
||||
assert!(dist.is_some() || series.is_some());
|
||||
|
||||
// Use a temp directory as working directory
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let cwd = temp_dir.path();
|
||||
|
||||
// Main 'pull' command: the one we want to test
|
||||
let info = pull(package, "", series, "", "", dist, Some(cwd), None)
|
||||
let info = crate::package_info::lookup(package, None, series, "", dist, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
pull(&info, Some(cwd), None, archive.unwrap_or(false))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -544,33 +655,39 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pull_hello_ubuntu_end_to_end() {
|
||||
test_pull_package_end_to_end("hello", Some("noble"), None).await;
|
||||
test_pull_package_end_to_end("hello", Some("noble"), None, None).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_pull_hello_debian_end_to_end() {
|
||||
test_pull_package_end_to_end("hello", Some("bookworm"), None).await;
|
||||
test_pull_package_end_to_end("hello", Some("bookworm"), None, None).await;
|
||||
}
|
||||
|
||||
/// Specific test for a package using a .diff.gz, instead of .debian and .orig
|
||||
#[tokio::test]
|
||||
async fn test_pull_linux_riscv_ubuntu_end_to_end() {
|
||||
test_pull_package_end_to_end("linux-riscv", Some("noble"), None, Some(true)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pull_2048_universe_ubuntu_end_to_end() {
|
||||
test_pull_package_end_to_end("2048", Some("noble"), None).await;
|
||||
test_pull_package_end_to_end("2048", Some("noble"), None, None).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_pull_1oom_contrib_debian_end_to_end() {
|
||||
test_pull_package_end_to_end("1oom", Some("trixie"), None).await;
|
||||
test_pull_package_end_to_end("1oom", Some("trixie"), None, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pull_agg_svn_fallback_ok() {
|
||||
test_pull_package_end_to_end("agg", Some("trixie"), None).await;
|
||||
test_pull_package_end_to_end("agg", Some("trixie"), None, None).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pull_hello_debian_latest_end_to_end() {
|
||||
test_pull_package_end_to_end("hello", None, Some("debian")).await;
|
||||
test_pull_package_end_to_end("hello", None, Some("debian"), None).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn test_pull_hello_ubuntu_latest_end_to_end() {
|
||||
test_pull_package_end_to_end("hello", None, Some("ubuntu")).await;
|
||||
test_pull_package_end_to_end("hello", None, Some("ubuntu"), None).await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user