Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
}