deb,quirks: generic dependency rules for build-dep workarounds
CI / build (push) Successful in 3m3s
CI / test (push) Skipped
CI / snap (push) Successful in 4m38s

The resolute linux and linux-riscv controls declare llvm-21-dev
unqualified while their other llvm pieces are :native, so the dpkg
cross rules resolve it against the host architecture — whose
dependency closure needs python3:riscv64, conflicting with the
python3 the control itself declares :native. No resolver can install
that set; sbuild fails on it identically.

Give the deb quirks a dependencies rule set rather than a one-off
native-qualification knob: replace rewrites a declared dependency by
name with a full dependency string (qualifier, version and
restrictions included), inject adds dependencies resolved as if
declared, drop ignores declared ones. Entries are scoped by series so
they can be dropped when the upstream packaging catches up, and rule
names that match nothing are warned about so stale quirks surface.

Ship the llvm-21-dev entry for the resolute kernels: resolve it as
llvm-21-dev:native, keeping the declared <!stage1> restriction.
This commit is contained in:
2026-09-20 19:10:06 +02:00
parent 31fe6dc524
commit ff1f8c7ccd
3 changed files with 240 additions and 21 deletions
+31 -3
View File
@@ -1,15 +1,43 @@
# Quirks configuration for package-specific workarounds # Quirks configuration for package-specific workarounds
# This file defines package-specific quirks that are applied during pull and deb operations # This file defines package-specific quirks that are applied during pull and deb operations
#
# Entries can be scoped with `series`: an empty list applies to every
# series, otherwise only the listed ones. Packaging workarounds should
# carry the series they were verified against so they can be dropped once
# the upstream packaging catches up.
quirks: quirks:
# The resolute kernels declare `llvm-21-dev` unqualified while their
# other llvm pieces are `:native`; the dpkg cross rules then resolve it
# against the host architecture, whose dependency closure conflicts with
# the `:native` python3. Resolve it against the build architecture
# until the control is fixed upstream.
linux:
deb:
series: [resolute]
dependencies:
replace:
llvm-21-dev: llvm-21-dev:native <!stage1>
linux-riscv:
deb:
series: [resolute]
dependencies:
replace:
llvm-21-dev: llvm-21-dev:native <!stage1>
# Add more packages and their quirks as needed # Add more packages and their quirks as needed
# example-package: # example-package:
# pull: # pull:
# method: archive # method: archive
# deb: # deb:
# extra_dependencies: # series: [noble]
# - another-dependency # dependencies:
# replace:
# old-dep: new-dep (>= 2) [linux-any]
# inject:
# - missing-dep
# drop:
# - broken-dep
# parameters: # parameters:
# key: value # key: value
+6 -1
View File
@@ -265,6 +265,7 @@ pub async fn build(
package, package,
version, version,
arch, arch,
series,
package_dir_str, package_dir_str,
build_root, build_root,
cross, cross,
@@ -564,6 +565,7 @@ fn install_build_dependencies(
package: &str, package: &str,
version: &str, version: &str,
arch: &str, arch: &str,
series: &str,
package_dir: &str, package_dir: &str,
build_root: &str, build_root: &str,
cross: bool, cross: bool,
@@ -610,10 +612,13 @@ fn install_build_dependencies(
union: false, union: false,
build_dep: true, build_dep: true,
}; };
let clauses: Vec<Vec<PkgRelation>> = Deps::parse(&deps_value, &parse_opts)? let mut clauses: Vec<Vec<PkgRelation>> = Deps::parse(&deps_value, &parse_opts)?
.clauses() .clauses()
.map(<[PkgRelation]>::to_vec) .map(<[PkgRelation]>::to_vec)
.collect(); .collect();
// Package-specific workarounds (see data/quirks.yml), before anything
// derives candidate queries or install specs from the clauses.
crate::quirks::apply_dependency_quirks(package, series, &mut clauses, &parse_opts)?;
let conflict_clauses: Vec<Vec<PkgRelation>> = if conflicts_value.trim().is_empty() { let conflict_clauses: Vec<Vec<PkgRelation>> = if conflicts_value.trim().is_empty() {
Vec::new() Vec::new()
} else { } else {
+203 -17
View File
@@ -4,15 +4,46 @@
//! and apply them during pull and deb operations. //! and apply them during pull and deb operations.
use crate::data::embed_data; use crate::data::embed_data;
use crate::debian::deps::{Deps, ParseOpts, PkgRelation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
/// Build-dependency resolution rules for a package
///
/// Applied after the declared Build-* fields are parsed and reduced,
/// before the resolver derives anything from them. Dependency strings
/// use the full dependency grammar: `name[:arch] [(op version)]
/// [arches] <restrictions>`.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct DependencyQuirks {
/// Declared dependency name -> dependency string to resolve in its
/// place. The replacement is parsed fresh and replaces the declared
/// dependency wholesale (qualifier, version, restrictions).
#[serde(default)]
pub replace: HashMap<String, String>,
/// Dependencies to resolve as if the control declared them.
#[serde(default)]
pub inject: Vec<String>,
/// Declared dependency names to ignore.
#[serde(default)]
pub drop: Vec<String>,
}
/// Quirks configuration for a specific operation (pull or deb) /// Quirks configuration for a specific operation (pull or deb)
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct OperationQuirks { pub struct OperationQuirks {
/// Extra dependencies to install before the operation /// Series the entry applies to. An empty list applies to every
/// series; packaging workarounds should carry the series they were
/// verified against, so they can be dropped once the upstream
/// packaging catches up.
#[serde(default)] #[serde(default)]
pub extra_dependencies: Vec<String>, pub series: Vec<String>,
/// Build-dependency resolution rules.
#[serde(default)]
pub dependencies: Option<DependencyQuirks>,
/// Additional parameters for the operation /// Additional parameters for the operation
#[serde(default)] #[serde(default)]
@@ -63,24 +94,29 @@ pub fn get_package_quirks<'a>(
config.quirks.get(package) config.quirks.get(package)
} }
/// Get deb-time extra dependencies for a package /// Whether a quirks entry applies to `series`: an empty series filter
/// /// matches every series, otherwise the series must be listed.
/// This function returns the list of extra dependencies that should be installed fn entry_applies_to_series(quirks: &OperationQuirks, series: &str) -> bool {
/// before building a package, as defined in the quirks configuration. quirks.series.is_empty() || quirks.series.iter().any(|s| s == series)
}
/// Get the build-dependency resolution rules of a package for a series
/// ///
/// # Arguments /// # Arguments
/// * `package` - The package name /// * `package` - The package name
/// * `series` - The distribution series (e.g. "resolute")
/// ///
/// # Returns /// # Returns
/// * `Vec<String>` - List of extra dependencies, or empty vector if none /// * `Option<DependencyQuirks>` - The rules, or None when the package has
pub fn get_deb_extra_dependencies(package: &str) -> Vec<String> { /// no deb entry or the entry does not apply to the series
if let Some(quirks) = get_package_quirks(&QUIRKS_DATA, package) pub fn get_deb_dependency_quirks(package: &str, series: &str) -> Option<DependencyQuirks> {
&& let Some(deb_quirks) = &quirks.deb let quirks = get_package_quirks(&QUIRKS_DATA, package)?;
{ let deb = quirks.deb.as_ref()?;
return deb_quirks.extra_dependencies.clone(); if entry_applies_to_series(deb, series) {
deb.dependencies.clone()
} else {
None
} }
Vec::new()
} }
/// Get package directories from quirks configuration /// Get package directories from quirks configuration
@@ -111,15 +147,165 @@ pub fn get_package_directories(package: &str) -> Vec<String> {
Vec::new() Vec::new()
} }
/// Apply the dependency quirks of `package` in `series` to parsed
/// build-dependency clauses
///
/// Rules apply in order — drop, replace, inject. `replace` matches by
/// declared name wherever the dependency appears; rule names that match
/// nothing are warned about, so stale quirks surface once the upstream
/// packaging is fixed.
pub fn apply_dependency_quirks(
package: &str,
series: &str,
clauses: &mut Vec<Vec<PkgRelation>>,
opts: &ParseOpts,
) -> Result<(), String> {
let Some(deps) = get_deb_dependency_quirks(package, series) else {
return Ok(());
};
apply_rules(clauses, &deps, opts)
}
/// Apply one set of dependency rules to parsed clauses.
fn apply_rules(
clauses: &mut Vec<Vec<PkgRelation>>,
deps: &DependencyQuirks,
opts: &ParseOpts,
) -> Result<(), String> {
for name in &deps.drop {
let hits = clauses
.iter()
.flatten()
.filter(|rel| &rel.package == name)
.count();
if hits == 0 {
log::warn!("dependency quirk: 'drop {name}' matched nothing");
}
}
if !deps.drop.is_empty() {
for clause in clauses.iter_mut() {
clause.retain(|rel| !deps.drop.iter().any(|name| name == &rel.package));
}
clauses.retain(|clause| !clause.is_empty());
}
for (declared, replacement) in &deps.replace {
let mut hits = 0;
for clause in clauses.iter_mut() {
for rel in clause.iter_mut() {
if rel.package == *declared {
*rel = crate::debian::deps::parse_simple(replacement, true)
.map_err(|e| format!("invalid replacement '{replacement}': {e}"))?;
hits += 1;
}
}
}
if hits == 0 {
log::warn!("dependency quirk: 'replace {declared}' matched nothing");
}
}
for injected in &deps.inject {
let parsed = Deps::parse(injected, opts)?;
clauses.extend(parsed.clauses().map(<[PkgRelation]>::to_vec));
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn parse(s: &str) -> PkgRelation {
crate::debian::deps::parse_simple(s, true).unwrap()
}
fn opts() -> ParseOpts {
ParseOpts {
host_arch: "riscv64".into(),
build_arch: "amd64".into(),
build_profiles: vec!["cross".into()],
reduce_restrictions: true,
union: false,
build_dep: true,
}
}
#[test] #[test]
fn test_unknown_package_has_no_quirks() { fn test_unknown_package_has_no_quirks() {
// A package absent from quirks.yml (currently every package) has no // A package absent from quirks.yml has no dependency rules nor
// extra dependencies nor custom directories, and must not panic // custom directories, and must not panic
assert!(get_deb_extra_dependencies("not-in-quirks").is_empty()); assert!(get_deb_dependency_quirks("not-in-quirks", "resolute").is_none());
assert!(get_package_directories("not-in-quirks").is_empty()); assert!(get_package_directories("not-in-quirks").is_empty());
} }
/// The linux dependency quirks are scoped to the series they were
/// verified against.
#[test]
fn linux_dependency_quirks_are_series_scoped() {
for package in ["linux", "linux-riscv"] {
let deps =
get_deb_dependency_quirks(package, "resolute").expect("the resolute entry applies");
assert_eq!(
deps.replace.get("llvm-21-dev").map(String::as_str),
Some("llvm-21-dev:native <!stage1>")
);
assert!(get_deb_dependency_quirks(package, "noble").is_none());
}
}
/// `replace` rewrites exactly the dependencies whose declared name
/// matches, wholesale: the replacement carries its own qualifier and
/// restrictions.
#[test]
fn replace_rewrites_matching_names_only() {
let mut clauses = vec![vec![
parse("llvm-21-dev <!stage1>"),
parse("clang-21:native"),
]];
let deps = DependencyQuirks {
replace: HashMap::from([(
"llvm-21-dev".to_string(),
"llvm-21-dev:native <!stage1>".to_string(),
)]),
..Default::default()
};
apply_rules(&mut clauses, &deps, &opts()).unwrap();
let rewritten = &clauses[0][0];
assert_eq!(rewritten.arch_qualifier.as_deref(), Some("native"));
assert_eq!(rewritten.restrictions.len(), 1);
assert_eq!(clauses[0][1].arch_qualifier.as_deref(), Some("native"));
}
/// `drop` removes named dependencies (empty clauses disappear) and
/// `inject` appends dependencies resolved like declared ones.
#[test]
fn drop_and_inject() {
let mut clauses = vec![vec![parse("broken-dep"), parse("keep-me")]];
let deps = DependencyQuirks {
inject: vec!["injected-dep:any".to_string()],
drop: vec!["broken-dep".to_string()],
..Default::default()
};
apply_rules(&mut clauses, &deps, &opts()).unwrap();
let names: Vec<&str> = clauses
.iter()
.flatten()
.map(|rel| rel.package.as_str())
.collect();
assert_eq!(names, ["keep-me", "injected-dep"]);
}
/// A replacement that does not parse is a quirk configuration error,
/// not a silent no-op.
#[test]
fn invalid_replacement_is_an_error() {
let mut clauses = vec![vec![parse("llvm-21-dev")]];
let deps = DependencyQuirks {
replace: HashMap::from([("llvm-21-dev".to_string(), "not@@valid".to_string())]),
..Default::default()
};
assert!(apply_rules(&mut clauses, &deps, &opts()).is_err());
}
} }