test: add pkh test, the native DEP-8 runner for local and chroot
The DEP-8 run-time contract is one page, so pkh implements it natively instead of wrapping autopkgtest (see plans/pkh-test.md): the testbed is a pkh context — chroot mode reuses the deb ephemeral chroots and their cached mmdebstrap tarballs, local mode runs on the current context — and the binaries under test are the freshness-guarded pkh deb output next to the tree, rebuilt through the deb flow when stale or missing. Per test: restrictions gate on the testbed (isolation-machine, needs-reboot and container isolation skip with a pointer at --mode vm), the stanza Depends are expanded (@, @builddeps@, @recommends@) and installed, then the script or Test-Command runs in the staged tree with the AUTOPKGTEST_* (and legacy ADT_*) environment, a per-test timeout and the stderr/exit-77 verdict rules; flaky tests retry once. stdout, stderr and $AUTOPKGTEST_ARTIFACTS are collected per test under /var/tmp/pkh/tests/ (pruned separately). --shell/--shell-fail drop into the testbed for debugging. pin_pocket and install_injected_packages become crate-internal helpers shared with the deb flow, which they mirror.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
//! DEP-8 `Depends:` substitution: `@`, `@builddeps@` and `@recommends@`
|
||||
//! expanded into concrete package specs for `apt-get install`.
|
||||
//!
|
||||
//! The built binaries themselves are installed separately (from the
|
||||
//! staged `.deb` files, so tests exercise exactly what pkh built);
|
||||
//! expansion substitutes bare binary *names*, which apt then resolves
|
||||
//! against the installed binaries first and the archive second.
|
||||
|
||||
use crate::debian::control::ControlInfo;
|
||||
use crate::debian::deps::{Deps, ParseOpts};
|
||||
|
||||
/// Expand a test stanza's `Depends:` value into apt package specs.
|
||||
///
|
||||
/// `raw` is the value as written (`None` = absent, the DEP-8 default
|
||||
/// `@`). The substitutions, per DEP-8:
|
||||
///
|
||||
/// - `@` in an or-clause replicates the clause once per binary package
|
||||
/// built from the source, with the name substituted (other
|
||||
/// alternatives and version constraints are kept);
|
||||
/// - `@builddeps@` becomes the source's Build-Depends,
|
||||
/// Build-Depends-Indep and Build-Depends-Arch plus `build-essential`;
|
||||
/// - `@recommends@` becomes the Recommends of every binary package,
|
||||
/// with `${...}` substvar references dropped (autopkgtest's
|
||||
/// "variables stripped") but versions kept.
|
||||
///
|
||||
/// Returns one apt spec per comma-separated clause, in order; an empty
|
||||
/// result means nothing to install.
|
||||
pub fn expand_depends(
|
||||
raw: Option<&str>,
|
||||
control: &ControlInfo,
|
||||
binaries: &[String],
|
||||
host_arch: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let raw = raw.unwrap_or("@");
|
||||
let mut specs = Vec::new();
|
||||
for token in split_top_level_commas(raw) {
|
||||
match token.as_str() {
|
||||
"@builddeps@" => {
|
||||
for field in ["Build-Depends", "Build-Depends-Indep", "Build-Depends-Arch"] {
|
||||
if let Some(value) = non_empty(control.source.get(field)) {
|
||||
for clause in split_top_level_commas(value) {
|
||||
if !clause.is_empty() {
|
||||
specs.push(clause);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
specs.push("build-essential".to_string());
|
||||
}
|
||||
"@recommends@" => {
|
||||
for paragraph in &control.binaries {
|
||||
if let Some(value) = non_empty(paragraph.get("Recommends")) {
|
||||
specs.extend(name_only_clauses(value, host_arch)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
token if token.split_whitespace().any(|word| word == "@") => {
|
||||
// Textual substitution: DEP-8 @ is a word-level
|
||||
// placeholder the deps grammar does not know. The clause
|
||||
// replicates once per binary with the name substituted,
|
||||
// constraints and alternatives kept verbatim.
|
||||
for binary in binaries {
|
||||
specs.push(
|
||||
token
|
||||
.split_whitespace()
|
||||
.map(|word| {
|
||||
if word == "@" {
|
||||
binary.clone()
|
||||
} else {
|
||||
word.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
}
|
||||
token => specs.push(token.to_string()),
|
||||
}
|
||||
}
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
/// Split on commas that sit outside any parentheses: or-clauses carry
|
||||
/// their own commas inside `(...)` constraints, top-level commas separate
|
||||
/// dependencies.
|
||||
fn split_top_level_commas(input: &str) -> Vec<String> {
|
||||
let mut parts = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut current = String::new();
|
||||
for character in input.chars() {
|
||||
match character {
|
||||
'(' => {
|
||||
depth += 1;
|
||||
current.push(character);
|
||||
}
|
||||
')' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
current.push(character);
|
||||
}
|
||||
',' if depth == 0 => {
|
||||
parts.push(current.trim().to_string());
|
||||
current.clear();
|
||||
}
|
||||
_ => current.push(character),
|
||||
}
|
||||
}
|
||||
parts.push(current.trim().to_string());
|
||||
parts
|
||||
}
|
||||
|
||||
/// Render each or-clause of a dependency list, dropping alternatives
|
||||
/// that are `${...}` substvar references (autopkgtest's "variables
|
||||
/// stripped") and keeping everything else verbatim.
|
||||
fn name_only_clauses(input: &str, host_arch: &str) -> Result<Vec<String>, String> {
|
||||
let opts = ParseOpts::build_deps(host_arch.to_string(), Vec::new());
|
||||
let deps = Deps::parse(input, &opts)?;
|
||||
let mut clauses = Vec::new();
|
||||
for clause in deps.clauses() {
|
||||
let kept: Vec<String> = clause
|
||||
.iter()
|
||||
.filter(|relation| !relation.package.contains('$'))
|
||||
.map(|relation| relation.output())
|
||||
.collect();
|
||||
if !kept.is_empty() {
|
||||
clauses.push(kept.join(" | "));
|
||||
}
|
||||
}
|
||||
Ok(clauses)
|
||||
}
|
||||
|
||||
/// A field value with its surrounding whitespace trimmed; `None` when the
|
||||
/// field is absent or whitespace-only.
|
||||
fn non_empty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// The binary package names declared by a control file, in stanza order.
|
||||
pub fn binary_names(control: &ControlInfo) -> Vec<String> {
|
||||
control
|
||||
.binaries
|
||||
.iter()
|
||||
.filter_map(|paragraph| paragraph.get("Package"))
|
||||
.map(|name| name.trim().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn control() -> ControlInfo {
|
||||
ControlInfo::from_str(
|
||||
"Source: hello\n\
|
||||
Build-Depends: debhelper-compat (= 13),\n meson\n\
|
||||
Build-Depends-Indep: dh-python\n\
|
||||
\n\
|
||||
Package: hello\n\
|
||||
Architecture: any\n\
|
||||
Recommends: hello-doc, pulseaudio (>= 15)\n\
|
||||
\n\
|
||||
Package: hello-doc\n\
|
||||
Architecture: all\n\
|
||||
Recommends: hello\n",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn binaries() -> Vec<String> {
|
||||
vec!["hello".to_string(), "hello-doc".to_string()]
|
||||
}
|
||||
|
||||
/// The default (absent) Depends is `@`: one spec per binary.
|
||||
#[test]
|
||||
fn default_is_at() {
|
||||
let specs = expand_depends(None, &control(), &binaries(), "amd64").unwrap();
|
||||
assert_eq!(specs, ["hello", "hello-doc"]);
|
||||
}
|
||||
|
||||
/// An or-clause containing @ replicates once per binary, keeping the
|
||||
/// other alternatives and version constraints.
|
||||
#[test]
|
||||
fn or_clauses_replicate_per_binary() {
|
||||
let specs = expand_depends(
|
||||
Some("@ (>= 1.0) | fallback, jq"),
|
||||
&control(),
|
||||
&binaries(),
|
||||
"amd64",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
specs,
|
||||
[
|
||||
"hello (>= 1.0) | fallback",
|
||||
"hello-doc (>= 1.0) | fallback",
|
||||
"jq"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// @builddeps@ pulls the three Build-* fields plus build-essential.
|
||||
#[test]
|
||||
fn builddeps_expansion() {
|
||||
let specs =
|
||||
expand_depends(Some("@builddeps@, @"), &control(), &binaries(), "amd64").unwrap();
|
||||
assert_eq!(
|
||||
specs,
|
||||
[
|
||||
"debhelper-compat (= 13)",
|
||||
"meson",
|
||||
"dh-python",
|
||||
"build-essential",
|
||||
"hello",
|
||||
"hello-doc"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// @recommends@ collects every binary's Recommends, stripped to names.
|
||||
#[test]
|
||||
fn recommends_expansion() {
|
||||
let specs = expand_depends(Some("@recommends@"), &control(), &binaries(), "amd64").unwrap();
|
||||
assert_eq!(specs, ["hello-doc", "pulseaudio (>= 15)", "hello"]);
|
||||
}
|
||||
|
||||
/// Plain dependencies pass through untouched; a token mentioning @ in
|
||||
/// a version constraint is still a substitution.
|
||||
#[test]
|
||||
fn plain_tokens_pass_through() {
|
||||
let specs = expand_depends(
|
||||
Some("ca-certificates, python3:any"),
|
||||
&control(),
|
||||
&binaries(),
|
||||
"amd64",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(specs, ["ca-certificates", "python3:any"]);
|
||||
}
|
||||
|
||||
/// Commas inside version constraints do not split clauses.
|
||||
#[test]
|
||||
fn parenthesised_commas_stay_together() {
|
||||
let specs =
|
||||
expand_depends(Some("foo (>= 1) , bar"), &control(), &binaries(), "amd64").unwrap();
|
||||
assert_eq!(specs, ["foo (>= 1)", "bar"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user