crates/nornir-build-thing/src/kernel_trim/tests.rs
//! The static half's answers on small synthetic Kconfig trees — every answer
//! is checked against what Kconfig's rules say must happen, and the inverse
//! check is held to both directions.
use std::collections::{BTreeMap, BTreeSet};
use super::*;
use crate::kconfig_graph::kconfig_env;
use crate::kernel_requires::{KconfigReq, KernelRequirements};
/// A tree with every edge kind a trim meets: a gate (`depends on`), a forcing
/// `select`, a `default y if` on a selected symbol, a `default y if !X` that
/// APPEARS when X leaves, a promptless symbol held by a select, a derived
/// prompt symbol with `default y`, an int option, and a choice.
pub(crate) fn tree() -> tempfile::TempDir {
let d = tempfile::tempdir().unwrap();
std::fs::write(
d.path().join("Kconfig"),
r#"
config NET
bool "net"
config INET
bool "inet"
depends on NET
default y
config NETFILTER
bool "netfilter"
depends on NET
select SKB_EXT
config SKB_EXT
bool
config NF_CT
bool
default y if NETFILTER
config IPV6
bool "ipv6"
depends on INET
config BPF
bool
config BPF_SYSCALL
bool "bpf syscall"
select BPF
config CGROUP_BPF
bool "cgroup bpf"
depends on BPF_SYSCALL
default y
config LEGACY
bool
default y if !NETFILTER
config NR_SOCKS
int "socks"
depends on NET
default 4
choice
prompt "hz"
default HZ_100
config HZ_100
bool "100"
config HZ_1000
bool "1000"
endchoice
"#,
)
.unwrap();
d
}
pub(crate) const FRAGMENT: &str = "CONFIG_NET=y\nCONFIG_NETFILTER=y\nCONFIG_BPF_SYSCALL=y\nCONFIG_IPV6=y\n";
pub(crate) const BUILT: &str = "CONFIG_NET=y\nCONFIG_INET=y\nCONFIG_NETFILTER=y\nCONFIG_SKB_EXT=y\nCONFIG_NF_CT=y\nCONFIG_IPV6=y\nCONFIG_BPF=y\nCONFIG_BPF_SYSCALL=y\nCONFIG_CGROUP_BPF=y\nCONFIG_NR_SOCKS=4\nCONFIG_HZ_100=y\n";
pub(crate) fn reqs() -> KernelRequirements {
let mut r = KernelRequirements::default();
r.require("tunnr/rusternetes-node", "k8s", &KconfigReq::y("CONFIG_IPV6", "Cilium socket LB"));
r.require("tunnr/rusternetes-node", "appliance-boot", &KconfigReq::n("CONFIG_LEGACY", "never on an appliance"));
r
}
fn with_model<T>(f: impl FnOnce(&TrimModel) -> T) -> T {
let d = tree();
let g = KconfigGraph::read(d.path(), kconfig_env("x86_64", "6.12.104")).unwrap();
let r = reqs();
let m = TrimModel::new(&g, FRAGMENT, BUILT, "", &r);
f(&m)
}
pub(crate) fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
#[test]
fn the_unchanged_baseline_settles_to_itself() {
with_model(|m| assert!(m.model_noise().is_empty(), "noise: {:?}", m.model_noise()));
}
#[test]
fn dropping_a_head_takes_what_it_selects_and_what_defaults_on_it_and_names_what_appears() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_NETFILTER"]), true);
assert_eq!(w.leaving, s(&["CONFIG_NF_CT", "CONFIG_SKB_EXT"]));
assert!(w.declared_leaving.is_empty());
assert!(w.held.is_empty(), "{:?}", w.held);
// LEGACY is `default y if !NETFILTER` — it APPEARS, and a consumer
// vetoes it, so the drop is refused by that name.
assert_eq!(w.appearing, s(&["CONFIG_LEGACY"]));
assert_eq!(w.refused.len(), 1);
assert_eq!(w.refused[0].symbol, "CONFIG_LEGACY");
assert_eq!(w.refused[0].feature, "appliance-boot");
assert_eq!(w.fragment_lines(), "# CONFIG_NETFILTER is not set\n");
});
}
#[test]
fn a_drop_that_takes_a_required_symbol_is_refused_by_the_requirers_name() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_NET"]), false);
// INET gates IPV6; NET gates INET, NETFILTER, NR_SOCKS.
for gone in ["CONFIG_INET", "CONFIG_NF_CT", "CONFIG_SKB_EXT", "CONFIG_NR_SOCKS"] {
assert!(w.leaving.contains(&gone.to_string()), "{gone} must leave: {:?}", w.leaving);
}
assert_eq!(w.declared_leaving, s(&["CONFIG_IPV6", "CONFIG_NETFILTER"]), "fragment lines that lose their gate");
assert!(!w.is_clean());
let r: Vec<String> = w.refused.iter().map(Refused::render).collect();
assert_eq!(r, s(&["CONFIG_IPV6=y ← tunnr/rusternetes-node:k8s (Cilium socket LB)"]));
// Nothing outside NET's subtree moves.
assert!(!w.leaving.contains(&"CONFIG_CGROUP_BPF".to_string()));
assert!(!w.leaving.contains(&"CONFIG_HZ_100".to_string()), "a chosen choice member stays");
});
}
#[test]
fn dropping_the_required_symbol_itself_is_refused() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_IPV6"]), false);
assert!(w.leaving.is_empty());
assert_eq!(w.refused.len(), 1);
assert_eq!(w.refused[0].symbol, "CONFIG_IPV6");
});
}
#[test]
fn a_selected_symbol_cannot_be_dropped_while_its_selector_stays() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_BPF"]), false);
assert_eq!(w.held.len(), 1, "{:?}", w.held);
assert!(w.held[0].reason.contains("selected by CONFIG_BPF_SYSCALL"), "{}", w.held[0].reason);
assert!(w.leaving.is_empty(), "nothing leaves when the drop does not hold: {:?}", w.leaving);
// Dropping the selector takes both, and the derived prompt symbol.
let w = m.what_if(&s(&["CONFIG_BPF_SYSCALL"]), false);
assert!(w.is_clean(), "{w:?}");
assert_eq!(w.leaving, s(&["CONFIG_BPF", "CONFIG_CGROUP_BPF"]));
});
}
#[test]
fn a_promptless_symbol_held_by_a_default_is_named_as_such() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_NF_CT"]), false);
assert_eq!(w.held.len(), 1);
assert!(w.held[0].reason.contains("no prompt"), "{}", w.held[0].reason);
});
}
#[test]
fn a_derived_prompt_symbol_can_be_dropped_by_an_is_not_set_line() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_CGROUP_BPF"]), true);
assert!(w.is_clean(), "{w:?}");
assert!(w.leaving.is_empty());
assert!(w.appearing.is_empty());
assert_eq!(w.expected_gone(), BTreeSet::from(["CONFIG_CGROUP_BPF".to_string()]));
});
}
#[test]
fn a_symbol_that_is_not_on_is_named_and_not_silently_accepted() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_HZ_1000"]), false);
assert_eq!(w.held.len(), 1);
assert!(w.held[0].reason.contains("not on"));
});
}
#[test]
fn candidates_leave_out_required_refused_and_held_and_rank_by_size() {
let d = tree();
let g = KconfigGraph::read(d.path(), kconfig_env("x86_64", "6.12.104")).unwrap();
let r = reqs();
let mut objects = BTreeMap::new();
objects.insert("net/core.o".to_string(), 1000);
objects.insert("net/netfilter/nf.o".to_string(), 5000);
objects.insert("net/netfilter/ct.o".to_string(), 3000);
objects.insert("kernel/bpf/syscall.o".to_string(), 7000);
objects.insert("kernel/bpf/cgroup.o".to_string(), 2000);
let mk = vec![
("".to_string(), "obj-$(CONFIG_NET) += net/\nobj-y += kernel/\n".to_string()),
("net/".to_string(), "obj-y += core.o\nobj-$(CONFIG_NETFILTER) += netfilter/\n".to_string()),
("net/netfilter/".to_string(), "obj-y += nf.o\nobj-$(CONFIG_NF_CT) += ct.o\n".to_string()),
("kernel/bpf/".to_string(), "obj-$(CONFIG_BPF_SYSCALL) += syscall.o \\\n other.o\nobj-$(CONFIG_CGROUP_BPF) += cgroup.o\n".to_string()),
];
let sizes = TreeSizes::from_parts(&mk, objects, &|_| None);
let m = TrimModel::new(&g, FRAGMENT, BUILT, "", &r).with_sizes(&sizes, Some(9000));
let why = WhyIndex::of(FRAGMENT, &r).with_built_config(BUILT, &r);
let c = m.candidates(&why);
let names: Vec<&str> = c.rows.iter().map(|c| c.symbol.as_str()).collect();
// BPF_SYSCALL (syscall 7000 + cgroup 2000) > CGROUP_BPF (2000) > HZ_100 / INET (0).
assert_eq!(names[0], "CONFIG_BPF_SYSCALL");
assert_eq!(c.rows[0].estimate.unwrap().object_bytes, 9000);
assert_eq!(c.rows[0].estimate.unwrap().bz_bytes, 4500, "9000 bz / 18000 object bytes = 0.5");
assert_eq!(c.rows[0].class, "preset-only");
assert_eq!(names[1], "CONFIG_CGROUP_BPF");
assert!(!names.contains(&"CONFIG_IPV6"), "required by a feature");
assert!(!names.contains(&"CONFIG_NET"), "takes IPV6 along: refused");
assert!(!names.contains(&"CONFIG_NETFILTER"), "turns on the vetoed LEGACY: refused");
assert!(!names.contains(&"CONFIG_NF_CT"), "no prompt: never a candidate");
assert_eq!(c.required, 1);
assert_eq!(c.refused, 3, "NET, INET (gates IPV6), NETFILTER");
assert_eq!(c.preset_only().len(), 1);
assert!(c.render(20).contains("CONFIG_BPF_SYSCALL"));
}
#[test]
fn makefile_lines_are_read_with_continuations_and_composite_parts() {
let g = makefile_gates("obj-$(CONFIG_A) += a.o \\\n\tb/ # c.o\nfoo-$(CONFIG_B) += part.o\nccflags-$(CONFIG_C) += -DX\nobj-$(CONFIG_D) += $(x).o\n");
assert_eq!(
g,
vec![
("obj".to_string(), "A".to_string(), s(&["a.o", "b/"])),
("foo".to_string(), "B".to_string(), s(&["part.o"])),
]
);
let mut objects = BTreeMap::new();
objects.insert("d/foo.o".to_string(), 100);
objects.insert("d/b/x.o".to_string(), 40);
let t = TreeSizes::from_parts(
&[("d/".to_string(), "obj-$(CONFIG_A) += b/\nobj-y += foo.o\nfoo-$(CONFIG_B) += part.o\n".to_string())],
objects,
&|p| (p == "d/part.o").then_some(30),
);
assert_eq!(t.gates_of("d/b/x.o"), BTreeSet::from(["A".to_string()]));
assert_eq!(t.bytes_leaving(&BTreeSet::from(["A".to_string()])), 40);
assert_eq!(t.bytes_leaving(&BTreeSet::from(["B".to_string()])), 30, "a part of an object that stays");
assert_eq!(t.total_bytes(), 140);
}
#[test]
fn the_inverse_check_holds_only_when_every_listed_symbol_is_gone_and_nothing_else_moved() {
with_model(|m| {
let w = m.what_if(&s(&["CONFIG_BPF_SYSCALL"]), true);
let good = BUILT.replace("CONFIG_BPF=y\n", "").replace("CONFIG_BPF_SYSCALL=y\n", "# CONFIG_BPF_SYSCALL is not set\n").replace("CONFIG_CGROUP_BPF=y\n", "");
let ic = InverseCheck::of(&w, BUILT, &good, 1000, 900);
assert!(ic.holds(), "{}", ic.render());
assert_eq!(ic.delta(), -100);
assert_eq!(ic.gone_as_listed.len(), 3);
// The model said CGROUP_BPF leaves; a recompose where it stayed breaks the inverse.
let stayed = BUILT.replace("CONFIG_BPF=y\n", "").replace("CONFIG_BPF_SYSCALL=y\n", "");
let ic = InverseCheck::of(&w, BUILT, &stayed, 1000, 950);
assert!(!ic.holds());
assert_eq!(ic.listed_but_stayed, s(&["CONFIG_CGROUP_BPF"]));
// And something gone that was never listed breaks it too.
let extra = good.replace("CONFIG_HZ_100=y\n", "");
let ic = InverseCheck::of(&w, BUILT, &extra, 1000, 900);
assert_eq!(ic.gone_unlisted, s(&["CONFIG_HZ_100"]));
assert!(!ic.holds());
// An int option that moved is a change too.
let moved = good.replace("CONFIG_NR_SOCKS=4", "CONFIG_NR_SOCKS=8");
assert_eq!(InverseCheck::of(&w, BUILT, &moved, 1, 1).changed_values, s(&["CONFIG_NR_SOCKS: 4 -> 8"]));
});
}
#[test]
fn the_kernel_verb_parses_and_refuses_by_name() {
let a = |v: &[&str]| parse_kernel_verb(&s(v));
let v = a(&["what-if", "rusternetes-node", "--drop", "CONFIG_X", "--drop", "CONFIG_Y", "--measure", "--opt", "cni-cilium=on"]).unwrap();
assert_eq!(v.drops, s(&["CONFIG_X", "CONFIG_Y"]));
assert!(v.measure);
assert_eq!(v.line, "--build kernel --opt cni-cilium=on");
let c = a(&["candidates", "rusternetes-node-aya", "--top", "5"]).unwrap();
assert_eq!((c.top, c.line.as_str()), (5, "--build kernel"));
assert!(a(&["what-if", "row"]).is_err(), "no drop");
assert!(a(&["what-if", "row", "--drop", "X"]).is_err(), "not CONFIG_");
assert!(a(&["candidates", "row", "--measure"]).is_err());
assert!(a(&["shrink", "row"]).is_err());
}
/// A composer that hands back fixture trees — the verb's whole path, the
/// measure leg included, without a container.
pub(crate) struct FixtureComposer {
pub(crate) tree: std::path::PathBuf,
}
impl Composer for FixtureComposer {
fn compose(&self, k: &crate::kernel::KernelBuild, root: &std::path::Path) -> Result<Composed> {
let c = composed_paths(k, root);
std::fs::create_dir_all(&c.src).unwrap();
std::fs::copy(self.tree.join("Kconfig"), c.src.join("Kconfig")).unwrap();
let frag = k.decision_fragment.clone().unwrap_or_default();
let mut cfg = BUILT.to_string();
if frag.contains("# CONFIG_CGROUP_BPF is not set") {
cfg = cfg.replace("CONFIG_CGROUP_BPF=y\n", "");
}
std::fs::write(&c.config, &cfg).unwrap();
std::fs::write(&c.base_config, "").unwrap();
std::fs::write(&c.bz_image, vec![0u8; if cfg == BUILT { 1000 } else { 960 }]).unwrap();
Ok(c)
}
}
#[test]
fn the_what_if_verb_measures_through_the_composer_and_checks_the_inverse() {
let d = tree();
let work = tempfile::tempdir().unwrap();
let mut chosen = crate::kernel::KernelBuild { name: "k".into(), version: "6.12.104".into(), ..Default::default() };
chosen.profile = "vanilla".into();
chosen.preset = "full".into();
chosen.requirements = Some(reqs());
let row = RowKernel { row: "node".into(), line: "--build kernel".into(), chosen, user_fragment: Some(FRAGMENT.into()) };
let v = parse_kernel_verb(&s(&["what-if", "node", "--drop", "CONFIG_CGROUP_BPF", "--measure"])).unwrap();
let out = run_kernel_verb(&v, &row, &FixtureComposer { tree: d.path().to_path_buf() }, work.path()).unwrap();
assert!(out.contains("verdict: CLEAN"), "{out}");
assert!(out.contains("bzImage 1000 -> 960 bytes (-40)"), "{out}");
assert!(out.contains("inverse: HOLDS"), "{out}");
// A refused what-if is never built.
let v = parse_kernel_verb(&s(&["what-if", "node", "--drop", "CONFIG_IPV6", "--measure"])).unwrap();
let e = run_kernel_verb(&v, &row, &FixtureComposer { tree: d.path().to_path_buf() }, work.path()).unwrap_err();
assert!(format!("{e:#}").contains("REFUSED CONFIG_IPV6=y ← tunnr/rusternetes-node:k8s"), "{e:#}");
}
/// **Fix 13 (lane KTOOL-W2): `kernel.why.toml` is READ.** The build leaves the
/// solved why-index beside the `.config` (a sealed artifact, refreshed when a
/// kernel is served by pin), and the `candidates` verb used to throw it away
/// and walk the Kconfig tree again. It now reads the file when the file
/// describes exactly this fragment, these requirements and this `.config`, and
/// solves again only when it does not (absent, unreadable, or stale).
#[test]
fn candidates_read_the_built_why_index_and_solve_only_when_it_is_stale() {
let r = reqs();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(crate::kernel_requires::WHY_FILE);
let cheap = WhyIndex::of(FRAGMENT, &r).with_built_config(BUILT, &r);
// What the build wrote: the same index, its derived rows solved.
let mut built = cheap.clone();
for w in &mut built.rows {
if matches!(w.class, WhyClass::KconfigDerived { .. }) {
w.class = WhyClass::KconfigDerived {
head: "FROM_THE_FILE".into(),
head_class: "declared".into(),
chain: "FROM_THE_FILE -> x".into(),
heads: vec!["declared:FROM_THE_FILE".into()],
unexplained: String::new(),
};
}
}
assert!(built.rows.iter().any(|w| matches!(w.class, WhyClass::KconfigDerived { .. })), "the fixture has derived rows");
std::fs::write(&path, built.to_toml()).unwrap();
let mut solved = false;
let got = crate::kernel_trim::candidates_why(&path, FRAGMENT, BUILT, &r, |w| {
solved = true;
Ok(w)
});
assert!(!solved, "a matching kernel.why.toml is read, the tree is not walked again");
assert_eq!(got, built);
// Stale: the file describes another .config (one derived symbol more).
let mut stale = built.clone();
stale.rows.push(crate::kernel_requires::WhyRow {
symbol: "CONFIG_NOT_IN_THIS_BUILD".into(),
value: KconfigValue::parse("y"),
class: WhyClass::derived_unsolved(),
});
std::fs::write(&path, stale.to_toml()).unwrap();
let mut solved = false;
let got = crate::kernel_trim::candidates_why(&path, FRAGMENT, BUILT, &r, |w| {
solved = true;
Ok(w)
});
assert!(solved, "a stale file is never trusted");
assert_eq!(got, cheap);
// Absent, or not a why-index: solve; a failed solve leaves the unsolved index.
std::fs::write(&path, "not = [a why index").unwrap();
let got = crate::kernel_trim::candidates_why(&path, FRAGMENT, BUILT, &r, |_| Err(anyhow::anyhow!("no tree")));
assert_eq!(got, cheap);
std::fs::remove_file(&path).unwrap();
let got = crate::kernel_trim::candidates_why(&path, FRAGMENT, BUILT, &r, |w| Ok(w));
assert_eq!(got, cheap);
}