diff --git a/.github/workflows/ci_test.yml b/.github/workflows/ci_test.yml index a193079c..e400acdf 100644 --- a/.github/workflows/ci_test.yml +++ b/.github/workflows/ci_test.yml @@ -185,6 +185,7 @@ jobs: --test llms_txt \ --test mcp_registry \ --test mcp_stdio \ + --test module_tree_dump_script \ --test oom_profile \ --test optimisation_criteria \ --test pr_merge_order \ diff --git a/CHANGELOG.md b/CHANGELOG.md index ed98bc60..8bf1cb4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to this project are documented here. ### Added +- `stack provided` includes the one-based build-list line on every + `provided` and `unparsed` finding in its text report, as it already did for + hook, toolchain, family-lock, and licensed findings. +- `scripts/dump-module-trees.sh` captures the live site, older EESSI, and + target EESSI `module -t avail` inventories in isolated, purged shells for + `stack gap`. + It sources the Lmod bash init when the shell has no `module` function. + It exits with the module name and the Lmod error when a purge or load fails. + It then writes no inventory for that tree. +- `stack gap` writes `version-change.tsv`, one row per version in the site + and older-EESSI inventories. A module in the target is `moved` and names + its EESSI version; an excluded module is `site-only`; every other missing + route is `gone` until it lands. - `stack shard` splits one Jenkins build list into shards that can build at the same time. A line's set is its closure over `--easyconfigs` minus the modules in `--installed-modules-dir` Lmod diff --git a/docs/orgmode/howto/build-list-provided.org b/docs/orgmode/howto/build-list-provided.org index 77c1d99b..b0cc7d64 100644 --- a/docs/orgmode/howto/build-list-provided.org +++ b/docs/orgmode/howto/build-list-provided.org @@ -29,10 +29,10 @@ eb-stack stack provided \ That command printed: #+begin_example -provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list -provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list -provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list -unparsed not-a-version.eb list=tests/provided_fixtures/site.list +provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list line=3 +provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 +provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 +unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7 toolchain fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 uses=foss-2025b; a site-layer line on EESSI 2026.06 must use a 2026.1 toolchain or GCCcore-15.2.0 below it (GCCcore-15.2.0, GCC-15.2.0, gfbf-2026.1, gompi-2026.1, foss-2026.1) hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks hits=3 unparsed=1 allowed=0 provided=3 toolchain=1 hooks=1 family=0 licensed=0 diff --git a/docs/orgmode/howto/module-gap.org b/docs/orgmode/howto/module-gap.org index 13ef27c5..a4b8e07a 100644 --- a/docs/orgmode/howto/module-gap.org +++ b/docs/orgmode/howto/module-gap.org @@ -20,6 +20,62 @@ A toolchain whose version is the generation counts as on it, so generation in ~data/eessi-generations.toml~, such as ~llvm-compilers-21.1.8~. +* Capture the live inventories + +The helper uses the shell's ~module~ function. +A non-interactive shell often has none, so the helper then sources ~$LMOD_PKG/init/bash~, or ~$MODULESHOME/init/bash~ when the first is missing. +It exits 2 when neither file exists. +Its four arguments are the output directory, the site meta-module, the +older EESSI module, and the target EESSI module. Use ~-~ for the site +argument when the base module path is the live site tree. + +#+begin_src bash +scripts/dump-module-trees.sh \ + /tmp/module-dumps - EESSI/2025.06 EESSI/2026.06 +#+end_src + +Each inventory is captured in a separately purged subshell, so loading +one EESSI version cannot add paths to the next dump. The files are +~/tmp/module-dumps/site.txt~, ~eessi-older.txt~, and +~eessi-target.txt~. Pass them as ~--site~, ~--older~, and +~--installed~, respectively. They are the raw ~module -t avail~ +output; ~stack gap~ ignores path headers and default markers. + +The purge and load output for each tree goes to a ~.module.log~ file next to its inventory, such as ~eessi-target.module.log~. +If the purge or the load fails, or ~module is-loaded~ does not report the module afterwards, the helper prints the module name and that log and exits 1. +It writes no inventory for that tree, so ~stack gap~ never reads a purged base tree as an older or target inventory. +Before the first dump, the helper deletes all three inventories from any earlier run, so a failed run leaves none of them behind. + +* Read the open-pull-request inventories + +The two ~fetch-prs.sh~ examples produce the JSON accepted by +~--open-layer~ and ~--open-easyconfigs~. Run each script in the clone +named in its comment: + +#+begin_src bash +EB_STACK=$PWD +(cd "$EB_EESSI_SOFTWARE_LAYER" && \ + "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ + EESSI/software-layer 2026.06 /tmp/open-layer) +(cd "$EB_EASYCONFIGS_DEVELOP" && \ + "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ + easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) + +layer_args=() +for file in /tmp/open-layer/prs/*.json; do + layer_args+=(--open-layer "$file") +done +easyconfig_args=() +for file in /tmp/open-easyconfigs/prs/*.json; do + easyconfig_args+=(--open-easyconfigs "$file") +done +#+end_src + +Add ~"${layer_args[@]}"~ and ~"${easyconfig_args[@]}"~ to the +~stack gap~ command. The fetch scripts only read pull requests. A +closed pull request disappears from their rewritten ~prs/~ directory +on the next run. + * Run the fixture The nine names are in ~tests/gap_routes/site.txt~. The easyconfig tree @@ -74,6 +130,14 @@ Each route also has its own file: ~in-stack.tsv~, ~site-only.tsv~, and ~bump.tsv~. An ~open-layer~ match is the easystack line the pull request adds, not a dependency that line would build. +~version-change.tsv~ is the user migration table. It keeps the module +versions exactly as the live dumps spell them, including a toolchain +suffix. An ~in-stack~ name is ~moved~ and names the target EESSI +version. An ~excluded~ name is ~site-only~. Every other route is +~gone~ from the opened stack until that route lands. A name with two +old versions has two rows; multiple target versions are comma-separated. +The columns are ~name~, ~old_version~, ~eessi_version~, and ~status~. + ~eb_stack_gap~ writes the same files. ~open_layer~ and ~open_easyconfigs~ are arrays of the same pull-request JSON ~pr-overlap --pr~ reads. diff --git a/docs/orgmode/reference/cli.org b/docs/orgmode/reference/cli.org index 055fe029..741a5eab 100644 --- a/docs/orgmode/reference/cli.org +++ b/docs/orgmode/reference/cli.org @@ -677,7 +677,10 @@ version counts. The command writes ~gap.tsv~, one TSV per route (~site-only.tsv~ for ~excluded~, ~bump.tsv~ for ~no-recipe~, and ~in-stack.tsv~, ~filtered.tsv~, ~open-layer.tsv~, ~recipe.tsv~, ~open-easyconfigs.tsv~), ~roots.txt~ for ~stack batch --roots-file~ (the -~recipe~ rows only), and ~bump-sources.txt~ for ~package bump --latest~. +~recipe~ rows only), ~bump-sources.txt~ for ~package bump --latest~, and +~version-change.tsv~. The last file has one row per old module version: +~moved~ names the target EESSI version, ~site-only~ is an ~excluded~ +name, and every other missing route is ~gone~ until it lands. ~stack provided~ reads each ~--build-list~ (a Jenkins list or one easyconfig path per line; ~#~ comments are skipped) and fails when a @@ -694,7 +697,7 @@ path, not left as site layer. ~--licensed~ and ~--licensed-file~ warn for group-restricted names; ~data/site-licensed.txt~ is that list for VASP, MATLAB, ANSYS, ORCA, and Gurobi. With neither flag there is no warning. ~--strict~ makes a remaining warning exit 1. Stdout lists each -hit as ~provided name= list=~ and ends with +hit as ~provided name= list= line=~ and ends with ~hits= unparsed= allowed= provided= toolchain= hooks= family= licensed=~. ~--format~ is ~text~ by default, ~github~ for workflow commands, or ~json~. ~--json~ is the same as ~--format json~. See [[file:../howto/build-list-provided.org][fail a build list that names software the baseline provides]] diff --git a/llms-full.txt b/llms-full.txt index a0858441..cfe70331 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4051,10 +4051,10 @@ eb-stack stack provided \ That command printed: ``` -provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list -provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list -provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list -unparsed not-a-version.eb list=tests/provided_fixtures/site.list +provided GROMACS-2026.0-foss-2026.1.eb name=GROMACS list=tests/provided_fixtures/site.list line=3 +provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 +provided fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 +unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7 toolchain fixtures/gromacs_2025_to_next/easyconfigs/foss-2025b/Python-3.12.3-foss-2025b.eb name=Python list=tests/provided_fixtures/site.list line=5 uses=foss-2025b; a site-layer line on EESSI 2026.06 must use a 2026.1 toolchain or GCCcore-15.2.0 below it (GCCcore-15.2.0, GCC-15.2.0, gfbf-2026.1, gompi-2026.1, foss-2026.1) hooks FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4 sets=--hooks=/sw/hooks/site.py; which replaces EESSI's hooks hits=3 unparsed=1 allowed=0 provided=3 toolchain=1 hooks=1 family=0 licensed=0 @@ -4167,6 +4167,62 @@ A toolchain whose version is the generation counts as on it, so generation in `data/eessi-generations.toml`, such as `llvm-compilers-21.1.8`. +# Capture the live inventories + +The helper uses the shell's `module` function. +A non-interactive shell often has none, so the helper then sources `$LMOD_PKG/init/bash`, or `$MODULESHOME/init/bash` when the first is missing. +It exits 2 when neither file exists. +Its four arguments are the output directory, the site meta-module, the +older EESSI module, and the target EESSI module. Use `-` for the site +argument when the base module path is the live site tree. + +```bash +scripts/dump-module-trees.sh \ + /tmp/module-dumps - EESSI/2025.06 EESSI/2026.06 +``` + +Each inventory is captured in a separately purged subshell, so loading +one EESSI version cannot add paths to the next dump. The files are +`/tmp/module-dumps/site.txt`, `eessi-older.txt`, and +`eessi-target.txt`. Pass them as `--site`, `--older`, and +`--installed`, respectively. They are the raw `module -t avail` +output; `stack gap` ignores path headers and default markers. + +The purge and load output for each tree goes to a `.module.log` file next to its inventory, such as `eessi-target.module.log`. +If the purge or the load fails, or `module is-loaded` does not report the module afterwards, the helper prints the module name and that log and exits 1. +It writes no inventory for that tree, so `stack gap` never reads a purged base tree as an older or target inventory. +Before the first dump, the helper deletes all three inventories from any earlier run, so a failed run leaves none of them behind. + +# Read the open-pull-request inventories + +The two `fetch-prs.sh` examples produce the JSON accepted by +`--open-layer` and `--open-easyconfigs`. Run each script in the clone +named in its comment: + +```bash +EB_STACK=$PWD +(cd "$EB_EESSI_SOFTWARE_LAYER" && \ + "$EB_STACK/examples/eessi-pr-overlap/fetch-prs.sh" \ + EESSI/software-layer 2026.06 /tmp/open-layer) +(cd "$EB_EASYCONFIGS_DEVELOP" && \ + "$EB_STACK/examples/easyconfigs-pr-overlap/fetch-prs.sh" \ + easybuilders/easybuild-easyconfigs /tmp/open-easyconfigs) + +layer_args=() +for file in /tmp/open-layer/prs/*.json; do + layer_args+=(--open-layer "$file") +done +easyconfig_args=() +for file in /tmp/open-easyconfigs/prs/*.json; do + easyconfig_args+=(--open-easyconfigs "$file") +done +``` + +Add `"${layer_args[@]}"` and `"${easyconfig_args[@]}"` to the +`stack gap` command. The fetch scripts only read pull requests. A +closed pull request disappears from their rewritten `prs/` directory +on the next run. + # Run the fixture The nine names are in `tests/gap_routes/site.txt`. The easyconfig tree @@ -4221,6 +4277,14 @@ Each route also has its own file: `in-stack.tsv`, `site-only.tsv`, and `bump.tsv`. An `open-layer` match is the easystack line the pull request adds, not a dependency that line would build. +`version-change.tsv` is the user migration table. It keeps the module +versions exactly as the live dumps spell them, including a toolchain +suffix. An `in-stack` name is `moved` and names the target EESSI +version. An `excluded` name is `site-only`. Every other route is +`gone` from the opened stack until that route lands. A name with two +old versions has two rows; multiple target versions are comma-separated. +The columns are `name`, `old_version`, `eessi_version`, and `status`. + `eb_stack_gap` writes the same files. `open_layer` and `open_easyconfigs` are arrays of the same pull-request JSON `pr-overlap --pr` reads. @@ -5821,7 +5885,10 @@ version counts. The command writes `gap.tsv`, one TSV per route (`site-only.tsv` for `excluded`, `bump.tsv` for `no-recipe`, and `in-stack.tsv`, `filtered.tsv`, `open-layer.tsv`, `recipe.tsv`, `open-easyconfigs.tsv`), `roots.txt` for `stack batch --roots-file` (the -`recipe` rows only), and `bump-sources.txt` for `package bump --latest`. +`recipe` rows only), `bump-sources.txt` for `package bump --latest`, and +`version-change.tsv`. The last file has one row per old module version: +`moved` names the target EESSI version, `site-only` is an `excluded` +name, and every other missing route is `gone` until it lands. `stack provided` reads each `--build-list` (a Jenkins list or one easyconfig path per line; `#` comments are skipped) and fails when a @@ -5838,7 +5905,7 @@ path, not left as site layer. `--licensed` and `--licensed-file` warn for group-restricted names; `data/site-licensed.txt` is that list for VASP, MATLAB, ANSYS, ORCA, and Gurobi. With neither flag there is no warning. `--strict` makes a remaining warning exit 1. Stdout lists each -hit as `provided name= list=` and ends with +hit as `provided name= list= line=` and ends with `hits= unparsed= allowed= provided= toolchain= hooks= family= licensed=`. `--format` is `text` by default, `github` for workflow commands, or `json`. `--json` is the same as `--format json`. See [fail a build list that names software the baseline provides](https://eb-stack.rgoswami.me/../howto/build-list-provided.html.md) diff --git a/scripts/dump-module-trees.sh b/scripts/dump-module-trees.sh new file mode 100755 index 00000000..3d347d54 --- /dev/null +++ b/scripts/dump-module-trees.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Capture the live module inventories that `eb-stack stack gap` compares. +# +# Usage: +# dump-module-trees.sh OUT_DIR SITE_MODULE OLDER_EESSI_MODULE TARGET_EESSI_MODULE +# +# Pass - for SITE_MODULE to capture the base site tree without loading a +# meta-module. When the shell has no `module` function, the helper sources +# $LMOD_PKG/init/bash, then $MODULESHOME/init/bash. Each tree's purge and +# load output goes to OUT_DIR/.module.log. A failed purge or load, or a +# meta-module that is not loaded afterwards, exits without writing that tree. +set -euo pipefail + +readonly exit_failure=1 +readonly exit_usage=2 + +if [[ $# -ne 4 ]]; then + echo "usage: $0 OUT_DIR SITE_MODULE OLDER_EESSI_MODULE TARGET_EESSI_MODULE" >&2 + exit "$exit_usage" +fi + +readonly out_dir=$1 +readonly site_module=$2 +readonly older_module=$3 +readonly target_module=$4 + +module_is_function() { + [[ $(type -t module) == function ]] +} + +# A non-interactive bash does not read the profile that defines `module`. +initialize_module() { + local init + module_is_function && return 0 + for init in "${LMOD_PKG:+$LMOD_PKG/init/bash}" \ + "${MODULESHOME:+$MODULESHOME/init/bash}"; do + [[ -n $init && -e $init ]] || continue + # Lmod init is not nounset-safe, and a site's init may run a bare command + # that fails. Check for the function afterwards instead. + set +eu + # shellcheck disable=SC1090 + source "$init" + set -eu + module_is_function && return 0 + done + echo "dump-module-trees: the module command is not initialized," \ + "and neither \$LMOD_PKG/init/bash nor \$MODULESHOME/init/bash exists" >&2 + exit "$exit_usage" +} + +initialize_module +mkdir -p "$out_dir" + +fail_tree() { + local label=$1 + local message=$2 + local log=$3 + echo "dump-module-trees: ${label} tree: ${message}" >&2 + cat "$log" >&2 + exit "$exit_failure" +} + +dump_tree() { + local label=$1 + local selector=$2 + local output=$3 + local temporary="${output}.tmp" + local log="${output%.txt}.module.log" + + current_temporary=$temporary + ( + module --force purge >"$log" 2>&1 || + fail_tree "$label" "module --force purge failed" "$log" + if [[ $selector != - ]]; then + module load "$selector" >>"$log" 2>&1 || + fail_tree "$label" "module load ${selector} failed" "$log" + module is-loaded "$selector" >>"$log" 2>&1 || + fail_tree "$label" "${selector} is not loaded after module load" "$log" + fi + module -t avail >"$temporary" 2>&1 + ) + mv "$temporary" "$output" + current_temporary= + printf '%s\t%s\n' "$label" "$output" +} + +# The inventory being written, removed on exit if it was never moved into place. +current_temporary= +trap '[[ -z $current_temporary ]] || rm -f -- "$current_temporary"' EXIT +readonly site_output="$out_dir/site.txt" +readonly older_output="$out_dir/eessi-older.txt" +readonly target_output="$out_dir/eessi-target.txt" + +# A failed run must not leave an earlier run's inventory behind, including the +# trees after the one that failed. +rm -f -- "$site_output" "$older_output" "$target_output" +dump_tree site "$site_module" "$site_output" +dump_tree older "$older_module" "$older_output" +dump_tree target "$target_module" "$target_output" diff --git a/src/gap.rs b/src/gap.rs index 1b152b18..98ddbc78 100644 --- a/src/gap.rs +++ b/src/gap.rs @@ -53,6 +53,12 @@ pub struct OpenCarry { pub label: String, } +/// Module names and the versions the live listing exposes. +/// +/// A name with an empty set came from a bare module name, so its version is +/// unknown rather than absent from the inventory. +pub type ModuleInventory = BTreeMap>; + /// The lists [`gap`] reads. pub struct GapInput<'a> { /// Site package names. @@ -100,11 +106,19 @@ pub fn names_from_listing(text: &str) -> BTreeSet { /// not. A file is `module -t avail`, `module spider -t`, or the same /// `Name/version` lines as [`names_from_listing`]. pub fn names_from_module_source(path: &Path) -> Result> { + Ok(inventory_from_module_source(path)?.into_keys().collect()) +} + +/// Module names and versions from a listing file or an Lmod module tree. +/// +/// Versions are kept as Lmod spells them, including any toolchain suffix. +/// Bare names have an empty version set. +pub fn inventory_from_module_source(path: &Path) -> Result { if path.is_dir() { - names_from_module_tree(path).with_context(|| path.display().to_string()) + inventory_from_module_tree(path).with_context(|| path.display().to_string()) } else { let text = std::fs::read_to_string(path).with_context(|| path.display().to_string())?; - Ok(names_from_live_listing(&text)) + Ok(inventory_from_live_listing(&text)) } } @@ -113,24 +127,35 @@ pub fn names_from_module_source(path: &Path) -> Result> { /// Path headers, `(default)`, and a `Name: Name/version, ...` spider line /// are read. A plain `Name/version` or bare name is unchanged. pub fn names_from_live_listing(text: &str) -> BTreeSet { - let mut names = BTreeSet::new(); + inventory_from_live_listing(text).into_keys().collect() +} + +/// Module names and versions in `module -t avail` or `module spider -t` +/// output. +pub fn inventory_from_live_listing(text: &str) -> ModuleInventory { + let mut inventory = ModuleInventory::new(); for line in text.lines() { for module in live_module_tokens(line) { - let Some(name) = module.split('/').next() else { + let mut parts = module.splitn(2, '/'); + let Some(name) = parts.next() else { continue; }; - if !name.is_empty() { - names.insert(name.to_string()); + if name.is_empty() { + continue; + } + let versions = inventory.entry(name.to_string()).or_default(); + if let Some(version) = parts.next().filter(|version| !version.is_empty()) { + versions.insert(version.to_string()); } } } - names + inventory } -fn names_from_module_tree(root: &Path) -> std::io::Result> { - let mut names = BTreeSet::new(); - walk_module_tree(root, &mut names)?; - Ok(names) +fn inventory_from_module_tree(root: &Path) -> std::io::Result { + let mut inventory = ModuleInventory::new(); + walk_module_tree(root, &mut inventory)?; + Ok(inventory) } /// One tab-separated file per route `stack gap` writes, besides `gap.tsv`. @@ -318,6 +343,93 @@ pub fn render_tsv(rows: &[GapRow]) -> String { out } +/// One previous-stack module version and where users find it in the new +/// stack. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct VersionChangeRow { + /// EasyBuild software name. + pub name: String, + /// Version in the site or older-EESSI inventory. `None` for a bare name. + #[serde(skip_serializing_if = "Option::is_none")] + pub old_version: Option, + /// Version in the target EESSI inventory. Multiple target versions are + /// comma-separated. Set only for `moved`. + #[serde(skip_serializing_if = "Option::is_none")] + pub eessi_version: Option, + /// `moved`, `gone`, or `site-only`. + pub status: String, +} + +fn inventory_versions<'a>( + inventory: &'a ModuleInventory, + name: &str, +) -> Option<&'a BTreeSet> { + inventory.get(name).or_else(|| { + inventory + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, versions)| versions) + }) +} + +/// Turn gap routes into the user-facing version change table. +/// +/// An `in-stack` module moved to EESSI, an `excluded` module remains +/// site-only, and every other route is gone from the opened stack until that +/// route lands. One row is written per old module version; a bare old name +/// still contributes one row with no version. +pub fn version_changes( + rows: &[GapRow], + old: &ModuleInventory, + eessi: &ModuleInventory, +) -> Vec { + let mut changes = Vec::new(); + for row in rows { + let status = match row.route.as_str() { + "in-stack" => "moved", + "excluded" => "site-only", + _ => "gone", + }; + let eessi_version = (status == "moved") + .then(|| inventory_versions(eessi, &row.name)) + .flatten() + .filter(|versions| !versions.is_empty()) + .map(|versions| versions.iter().cloned().collect::>().join(",")); + let old_versions = inventory_versions(old, &row.name); + if let Some(versions) = old_versions.filter(|versions| !versions.is_empty()) { + changes.extend(versions.iter().map(|version| VersionChangeRow { + name: row.name.clone(), + old_version: Some(version.clone()), + eessi_version: eessi_version.clone(), + status: status.to_string(), + })); + } else { + changes.push(VersionChangeRow { + name: row.name.clone(), + old_version: None, + eessi_version, + status: status.to_string(), + }); + } + } + changes +} + +/// A tab-separated version change table with a header line. +pub fn render_version_changes(rows: &[VersionChangeRow]) -> String { + let mut out = String::from("name\told_version\teessi_version\tstatus\n"); + for row in rows { + out.push_str(&format!( + "{}\t{}\t{}\t{}\n", + row.name, + row.old_version.as_deref().unwrap_or("-"), + row.eessi_version.as_deref().unwrap_or("-"), + row.status + )); + } + out +} + /// The package name an easyconfig filename names. /// /// `hwloc-CUDA-2.11.2-GCCcore-15.2.0.eb` is `hwloc-CUDA`. A path contributes @@ -388,7 +500,7 @@ pub fn carries_from_layer_version( out } -fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result<()> { +fn walk_module_tree(dir: &Path, inventory: &mut ModuleInventory) -> std::io::Result<()> { let entries = std::fs::read_dir(dir).map_err(|error| io_at(dir, error))?; for entry in entries { let entry = entry.map_err(|error| io_at(dir, error))?; @@ -406,7 +518,7 @@ fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result Err(_) => continue, } } else if file_type.is_dir() { - walk_module_tree(&path, names)?; + walk_module_tree(&path, inventory)?; continue; } if !is_module_file(&file_name) { @@ -423,7 +535,10 @@ fn walk_module_tree(dir: &Path, names: &mut BTreeSet) -> std::io::Result if stem.is_empty() || stem == "default" { continue; } - names.insert(software.to_string()); + inventory + .entry(software.to_string()) + .or_default() + .insert(stem.to_string()); } Ok(()) } @@ -866,6 +981,35 @@ Where: "LAMMPS".to_string(), ]) ); + let inventory = inventory_from_live_listing(text); + assert_eq!( + inventory["zlib"], + BTreeSet::from(["1.2.13".to_string(), "1.3.1".to_string()]) + ); + assert_eq!(inventory["GCCcore"], BTreeSet::from(["14.2.0".to_string()])); + assert!(inventory["LAMMPS"].is_empty()); + } + + #[test] + fn version_changes_name_moved_gone_and_site_only_rows() { + let old = inventory_from_live_listing( + "FFTW/3.3.10-gompi-2025b\nLAMMPS/2024.0-foss-2025b\nMATLAB/2024b\n", + ); + let eessi = inventory_from_live_listing("FFTW/3.3.10-gompi-2026.1\n"); + let rows = vec![ + row("FFTW", "in-stack", None, None, None), + row("LAMMPS", "open-layer", None, None, None), + row("MATLAB", "excluded", None, None, None), + ]; + assert_eq!( + render_version_changes(&version_changes(&rows, &old, &eessi)), + "\ +name\told_version\teessi_version\tstatus +FFTW\t3.3.10-gompi-2025b\t3.3.10-gompi-2026.1\tmoved +LAMMPS\t2024.0-foss-2025b\t-\tgone +MATLAB\t2024b\t-\tsite-only +" + ); } #[test] @@ -883,7 +1027,10 @@ Where: let binutils = root.path().join("Compiler/GCCcore/13.2.0/binutils"); std::fs::create_dir_all(&binutils).unwrap(); std::fs::write(binutils.join("2.42.lua"), "").unwrap(); - let names = names_from_module_tree(root.path()).unwrap(); + let names: BTreeSet = inventory_from_module_tree(root.path()) + .unwrap() + .into_keys() + .collect(); assert_eq!( names, BTreeSet::from([ diff --git a/src/main.rs b/src/main.rs index 779e30a9..e37960da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1136,7 +1136,8 @@ enum StackCommand { /// this generation), or `excluded`. A directory is an Lmod tree. A file /// is `module -t avail`, `module spider -t`, or `Name/version`. Writes /// `gap.tsv`, one TSV per route, `roots.txt` for `stack batch - /// --roots-file`, and `bump-sources.txt`. + /// --roots-file`, `bump-sources.txt`, and `version-change.tsv` with old + /// versions moved to EESSI, gone, or kept site-only. #[command( after_long_help = "Examples:\n eb-stack stack gap --site tests/rebuild_set_fixtures/installed.txt --installed tests/pr_overlap_layer/installed.txt --easyconfigs tests/rebuild_set_fixtures/release --generation 2026.1 --out-dir gap\n\nThose paths are files in this repository. A missing --site file exits 1 and names the path." )] diff --git a/src/mcp.rs b/src/mcp.rs index bf8df831..7484f61b 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -380,7 +380,7 @@ fn tool_catalog() -> Vec { ), tool_with_optional( "eb_stack_gap", - "Classify every package in site and older module trees against a target stack, as `stack gap` does: in-stack, excluded (site-only), filtered, open-layer, recipe, open-easyconfigs, or no-recipe (a bump). A path may be an Lmod directory or module -t avail / module spider -t text. installed repeats for a CPU tree and an accelerator tree. eessi_version keeps open software-layer easystacks of that version. Writes gap.tsv, one TSV per route, roots.txt and bump-sources.txt under out_dir.", + "Classify every package in site and older module trees against a target stack, as `stack gap` does: in-stack, excluded (site-only), filtered, open-layer, recipe, open-easyconfigs, or no-recipe (a bump). A path may be an Lmod directory or module -t avail / module spider -t text. installed repeats for a CPU tree and an accelerator tree. eessi_version keeps open software-layer easystacks of that version. Writes gap.tsv, one TSV per route, roots.txt, bump-sources.txt, and version-change.tsv (old versions moved to EESSI, gone, or site-only) under out_dir.", &[ ("installed", "array"), ("easyconfigs", "array"), diff --git a/src/provided.rs b/src/provided.rs index 816d5e51..1ea83b83 100644 --- a/src/provided.rs +++ b/src/provided.rs @@ -19,6 +19,7 @@ use crate::easystack::filename_toolchain; use crate::gap::{names_from_listing, software_name}; use crate::hierarchy::known_hierarchy; use crate::jenkins::parse_build_list; +use crate::shard::parse_lines; use anyhow::{Context, Result}; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -437,16 +438,12 @@ struct Entry { } fn entries(text: &str) -> Vec { - text.lines() - .enumerate() - .filter_map(|(index, raw)| { - let trimmed = raw.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - return None; - } - let line = parse_build_list(trimmed).into_iter().next()?; + parse_lines(text) + .into_iter() + .filter_map(|shard_line| { + let line = parse_build_list(&shard_line.text).into_iter().next()?; Some(Entry { - line: index + 1, + line: shard_line.number, file: line.file, flags: line.flags, }) @@ -636,12 +633,15 @@ fn render_text(report: &ProvidedReport) -> String { let mut out = String::new(); for hit in &report.hits { out.push_str(&format!( - "provided {} name={} list={}\n", - hit.file, hit.name, hit.list + "provided {} name={} list={} line={}\n", + hit.file, hit.name, hit.list, hit.line )); } for line in &report.unparsed { - out.push_str(&format!("unparsed {} list={}\n", line.file, line.list)); + out.push_str(&format!( + "unparsed {} list={} line={}\n", + line.file, line.list, line.line + )); } for note in report .toolchain diff --git a/src/shard.rs b/src/shard.rs index 8e60a461..870c59da 100644 --- a/src/shard.rs +++ b/src/shard.rs @@ -267,9 +267,9 @@ pub fn plan( } } -/// Read a weights file: `moduleseconds` per line, `#` comments and blank -/// lines skipped. A fractional value rounds up to whole seconds. A later line -/// for the same module replaces an earlier one. +/// Read a weights file: `module`, a tab, then `seconds` per line. `#` comments +/// and blank lines are skipped. A fractional value rounds up to whole seconds. +/// A later line for the same module replaces an earlier one. pub fn parse_weights(text: &str) -> Result, String> { let mut out = BTreeMap::new(); for (i, raw) in text.lines().enumerate() { diff --git a/src/site_commands.rs b/src/site_commands.rs index 4e82a7a3..30a9646c 100644 --- a/src/site_commands.rs +++ b/src/site_commands.rs @@ -1205,7 +1205,8 @@ impl StackGapRun { /// each gap. Writes `gap.tsv`, one TSV per route, `roots.txt` (the recipes /// on the generation, the input of `stack batch --roots-file`), and /// `bump-sources.txt` (the newest recipe on any toolchain for each name -/// without one). +/// without one), and `version-change.tsv` (old module versions moved to the +/// target EESSI version, gone, or kept site-only). pub fn run_stack_gap(args: &StackGapRequest) -> Result { use std::collections::{BTreeMap, BTreeSet}; if args.site.is_empty() && args.older.is_empty() { @@ -1214,26 +1215,31 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { if args.installed.is_empty() { return Err(usage("pass at least one --installed target tree")); } - let mut site = BTreeSet::new(); + let mut site_inventory = crate::gap::ModuleInventory::new(); for path in &args.site { - site.extend( - crate::gap::names_from_module_source(path) + merge_module_inventory( + &mut site_inventory, + crate::gap::inventory_from_module_source(path) .with_context(|| format!("--site {}", path.display()))?, ); } for path in &args.older { - site.extend( - crate::gap::names_from_module_source(path) + merge_module_inventory( + &mut site_inventory, + crate::gap::inventory_from_module_source(path) .with_context(|| format!("--older {}", path.display()))?, ); } - let mut stack = BTreeSet::new(); + let site: BTreeSet = site_inventory.keys().cloned().collect(); + let mut stack_inventory = crate::gap::ModuleInventory::new(); for path in &args.installed { - stack.extend( - crate::gap::names_from_module_source(path) + merge_module_inventory( + &mut stack_inventory, + crate::gap::inventory_from_module_source(path) .with_context(|| format!("--installed {}", path.display()))?, ); } + let stack: BTreeSet = stack_inventory.keys().cloned().collect(); let filter_specs = dep_filter::specs_from_cli_or_env(&args.filter_deps); let filter = FilterDeps::parse(&filter_specs) .map_err(|error| usage(format!("--filter-deps: {error}")))?; @@ -1313,6 +1319,11 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { &args.out_dir.join("gap.tsv"), &crate::gap::render_tsv(&rows), )?; + let version_changes = crate::gap::version_changes(&rows, &site_inventory, &stack_inventory); + crate::write_text( + &args.out_dir.join("version-change.tsv"), + &crate::gap::render_version_changes(&version_changes), + )?; for (route, filename) in crate::gap::ROUTE_FILES { let subset: Vec<_> = rows .iter() @@ -1347,6 +1358,15 @@ pub fn run_stack_gap(args: &StackGapRequest) -> Result { }) } +fn merge_module_inventory( + into: &mut crate::gap::ModuleInventory, + from: crate::gap::ModuleInventory, +) { + for (name, versions) in from { + into.entry(name).or_default().extend(versions); + } +} + /// Arguments for `stack batch`. pub struct StackBatchRequest { /// Root easyconfig file names. diff --git a/tests/gap_routes/one-per-route.txt b/tests/gap_routes/one-per-route.txt new file mode 100644 index 00000000..9f96c6cc --- /dev/null +++ b/tests/gap_routes/one-per-route.txt @@ -0,0 +1,7 @@ +BLAST+/2.16.0-gompi-2024a +CP2K/2025.2-foss-2025a +FFTW/3.3.10-gompi-2026.1 +LAMMPS/2024.2 +MATLAB/2024b +QuantumESPRESSO/7.4 +zlib/1.3.1 diff --git a/tests/module_tree_dump_script.rs b/tests/module_tree_dump_script.rs new file mode 100644 index 00000000..1e506dac --- /dev/null +++ b/tests/module_tree_dump_script.rs @@ -0,0 +1,211 @@ +//! The live-module dump helper isolates and records all three inventories. + +use std::ffi::OsStr; +use std::path::Path; +use std::process::{Command, Output}; + +fn root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +/// A `module` stub that loads each EESSI version and lists one module per tree. +/// `purge`, `load_target` and `is_loaded_target` are the shell bodies of the +/// purge branch and the target's load and is-loaded branches, so a case can +/// make any one of them fail. +fn module_stub(purge: &str, load_target: &str, is_loaded_target: &str) -> String { + format!( + r#" +module() {{ + case "$1 $2" in + "--force purge") {purge} ;; + "load EESSI/2025.06") export TEST_MODULE=older ;; + "load EESSI/2026.06") {load_target} ;; + "is-loaded EESSI/2025.06") [[ ${{TEST_MODULE:-}} == older ]] ;; + "is-loaded EESSI/2026.06") {is_loaded_target} ;; + "-t avail") + case "${{TEST_MODULE:-site}}" in + site) printf 'SiteApp/1.0\n' ;; + older) printf 'LAMMPS/2024.0\n' ;; + target) printf 'FFTW/3.3.10-gompi-2026.1\n' ;; + esac + ;; + *) return 2 ;; + esac +}} +"# + ) +} + +const PURGE: &str = "unset TEST_MODULE"; +const LOAD_TARGET: &str = "export TEST_MODULE=target"; +const IS_LOADED_TARGET: &str = "[[ ${TEST_MODULE:-} == target ]]"; +/// The helper's exit status when no `module` function can be initialized. +const EXIT_USAGE: i32 = 2; + +/// Run the helper with a clean environment, so a `module` function exported +/// by the calling shell cannot stand in for the stub. +fn run_helper, V: AsRef>( + output_dir: &Path, + env: impl IntoIterator, +) -> Output { + let mut command = Command::new(root().join("scripts/dump-module-trees.sh")); + command.env_clear(); + if let Some(path) = std::env::var_os("PATH") { + command.env("PATH", path); + } + command + .envs(env) + .arg(output_dir) + .args(["-", "EESSI/2025.06", "EESSI/2026.06"]) + .output() + .expect("dump helper") +} + +fn assert_all_trees(output: &Output, output_dir: &Path) { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + std::fs::read_to_string(output_dir.join("site.txt")).unwrap(), + "SiteApp/1.0\n" + ); + assert_eq!( + std::fs::read_to_string(output_dir.join("eessi-older.txt")).unwrap(), + "LAMMPS/2024.0\n" + ); + assert_eq!( + std::fs::read_to_string(output_dir.join("eessi-target.txt")).unwrap(), + "FFTW/3.3.10-gompi-2026.1\n" + ); +} + +fn assert_target_refused(output: &Output, output_dir: &Path, reason: &str) { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "{stderr}"); + assert!(stderr.contains("EESSI/2026.06"), "{stderr}"); + assert!(stderr.contains(reason), "{stderr}"); + assert!( + !output_dir.join("eessi-target.txt").exists(), + "a target inventory exists after a failed load" + ); + assert!(!output_dir.join("eessi-target.txt.tmp").exists()); +} + +#[test] +fn helper_dumps_site_older_and_target_module_trees() { + let temporary = tempfile::tempdir().expect("temp"); + let bash_env = temporary.path().join("module-init.sh"); + std::fs::write(&bash_env, module_stub(PURGE, LOAD_TARGET, IS_LOADED_TARGET)) + .expect("module init"); + let output_dir = temporary.path().join("dumps"); + let output = run_helper(&output_dir, [("BASH_ENV", &bash_env)]); + assert_all_trees(&output, &output_dir); +} + +#[test] +fn helper_sources_the_lmod_init_under_moduleshome() { + let temporary = tempfile::tempdir().expect("temp"); + let modules_home = temporary.path().join("lmod"); + std::fs::create_dir_all(modules_home.join("init")).expect("init dir"); + std::fs::write( + modules_home.join("init/bash"), + module_stub(PURGE, LOAD_TARGET, IS_LOADED_TARGET), + ) + .expect("module init"); + let output_dir = temporary.path().join("dumps"); + let output = run_helper(&output_dir, [("MODULESHOME", &modules_home)]); + assert_all_trees(&output, &output_dir); +} + +#[test] +fn helper_exits_when_no_lmod_init_exists() { + let temporary = tempfile::tempdir().expect("temp"); + let output_dir = temporary.path().join("dumps"); + let missing = temporary.path().join("missing"); + let output = run_helper( + &output_dir, + [("LMOD_PKG", &missing), ("MODULESHOME", &missing)], + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!(output.status.code(), Some(EXIT_USAGE), "{stderr}"); + assert!(stderr.contains("not initialized"), "{stderr}"); + assert!(!output_dir.exists()); +} + +#[test] +fn helper_fails_with_the_lmod_error_when_a_load_fails() { + let temporary = tempfile::tempdir().expect("temp"); + let bash_env = temporary.path().join("module-init.sh"); + let failing_load = "echo 'Lmod has detected the following error: no such module' >&2; return 1"; + std::fs::write( + &bash_env, + module_stub(PURGE, failing_load, IS_LOADED_TARGET), + ) + .expect("module init"); + let output_dir = temporary.path().join("dumps"); + std::fs::create_dir_all(&output_dir).expect("dump dir"); + std::fs::write(output_dir.join("eessi-target.txt"), "Stale/1.0\n").expect("stale inventory"); + let output = run_helper(&output_dir, [("BASH_ENV", &bash_env)]); + assert_target_refused(&output, &output_dir, "no such module"); +} + +#[test] +fn helper_fails_when_the_module_is_not_loaded_after_load() { + let temporary = tempfile::tempdir().expect("temp"); + let bash_env = temporary.path().join("module-init.sh"); + std::fs::write(&bash_env, module_stub(PURGE, "true", IS_LOADED_TARGET)).expect("module init"); + let output_dir = temporary.path().join("dumps"); + let output = run_helper(&output_dir, [("BASH_ENV", &bash_env)]); + assert_target_refused(&output, &output_dir, "is not loaded"); +} + +#[test] +fn helper_prefers_the_lmod_init_under_lmod_pkg() { + let temporary = tempfile::tempdir().expect("temp"); + let lmod_pkg = temporary.path().join("lmod-pkg"); + std::fs::create_dir_all(lmod_pkg.join("init")).expect("init dir"); + std::fs::write( + lmod_pkg.join("init/bash"), + module_stub(PURGE, LOAD_TARGET, IS_LOADED_TARGET), + ) + .expect("module init"); + let modules_home = temporary.path().join("modules-home"); + std::fs::create_dir_all(modules_home.join("init")).expect("init dir"); + std::fs::write( + modules_home.join("init/bash"), + "echo 'MODULESHOME init sourced' >&2\nmodule() { return 1; }\n", + ) + .expect("module init"); + let output_dir = temporary.path().join("dumps"); + let output = run_helper( + &output_dir, + [("LMOD_PKG", &lmod_pkg), ("MODULESHOME", &modules_home)], + ); + assert_all_trees(&output, &output_dir); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("MODULESHOME init sourced"), "{stderr}"); +} + +#[test] +fn helper_fails_with_the_lmod_error_when_a_purge_fails() { + let temporary = tempfile::tempdir().expect("temp"); + let bash_env = temporary.path().join("module-init.sh"); + let failing_purge = "echo 'purge refused by the stub' >&2; return 1"; + std::fs::write( + &bash_env, + module_stub(failing_purge, LOAD_TARGET, IS_LOADED_TARGET), + ) + .expect("module init"); + let output_dir = temporary.path().join("dumps"); + std::fs::create_dir_all(&output_dir).expect("dump dir"); + std::fs::write(output_dir.join("site.txt"), "Stale/1.0\n").expect("stale inventory"); + let output = run_helper(&output_dir, [("BASH_ENV", &bash_env)]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!output.status.success(), "{stderr}"); + assert!(stderr.contains("module --force purge failed"), "{stderr}"); + assert!(stderr.contains("purge refused by the stub"), "{stderr}"); + assert!(!output_dir.join("site.txt").exists()); + assert!(!output_dir.join("site.txt.tmp").exists()); +} diff --git a/tests/stack_gap.rs b/tests/stack_gap.rs index c5181a9a..e317cf1c 100644 --- a/tests/stack_gap.rs +++ b/tests/stack_gap.rs @@ -96,6 +96,59 @@ fn routes_are_written_one_file_per_set() { } } +#[test] +fn route_fixture_has_one_row_for_every_route() { + let out = tempfile::tempdir().expect("temp"); + let output = Command::new(env!("CARGO_BIN_EXE_eb-stack")) + .current_dir(root()) + .env_remove("EASYBUILD_FILTER_DEPS") + .args([ + "stack", + "gap", + "--site", + "tests/gap_routes/one-per-route.txt", + "--installed", + "tests/gap_routes/installed.txt", + "--easyconfigs", + "tests/gap_routes/easyconfigs", + "--generation", + "2026.1", + "--exclude-file", + "tests/gap_routes/site-only.tsv", + "--filter-deps", + "zlib", + "--open-layer", + "tests/gap_routes/open-layer.json", + "--open-easyconfigs", + "tests/gap_routes/open-easyconfigs.json", + "--out-dir", + ]) + .arg(out.path()) + .output() + .expect("stack gap"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stdout}{stderr}"); + assert!( + stdout.contains( + "site=7 in-stack=1 filtered=1 open-layer=1 recipe=1 open-easyconfigs=1 no-recipe=1 excluded=1" + ), + "{stdout}" + ); + for name in [ + "in-stack.tsv", + "site-only.tsv", + "filtered.tsv", + "open-layer.tsv", + "recipe.tsv", + "open-easyconfigs.tsv", + "bump.tsv", + ] { + let text = std::fs::read_to_string(out.path().join(name)).unwrap(); + assert_eq!(text.lines().count(), 2, "{name}:\n{text}"); + } +} + #[test] fn live_module_trees_union_the_site_an_older_eessi_and_both_target_dirs() { let out = tempfile::tempdir().expect("temp"); @@ -159,4 +212,27 @@ fn live_module_trees_union_the_site_an_older_eessi_and_both_target_dirs() { ); assert!(gap.contains("QuantumESPRESSO\tno-recipe\t"), "{gap}"); assert!(!gap.contains("software-layer#1600"), "{gap}"); + let changes = std::fs::read_to_string(out.path().join("version-change.tsv")).unwrap(); + assert!( + changes.starts_with("name\told_version\teessi_version\tstatus\n"), + "{changes}" + ); + assert!( + changes.contains("CUDA\t12.6.0\t12.6.0\tmoved\n"), + "{changes}" + ); + assert!( + changes.contains("FFTW\t3.3.10\t3.3.10\tmoved\n"), + "{changes}" + ); + assert!( + changes.contains("LAMMPS\t2023.0\t-\tgone\n") + && changes.contains("LAMMPS\t2024.0\t-\tgone\n"), + "{changes}" + ); + assert!( + changes.contains("MATLAB\t2024b\t-\tsite-only\n") + && changes.contains("code-server\t4.96.2\t-\tsite-only\n"), + "{changes}" + ); } diff --git a/tests/stack_provided.rs b/tests/stack_provided.rs index 9b8f4f1d..182812a9 100644 --- a/tests/stack_provided.rs +++ b/tests/stack_provided.rs @@ -32,12 +32,23 @@ fn a_line_the_baseline_provides_exits_1_and_an_allow_keeps_it() { let stderr = String::from_utf8_lossy(&failed.stderr); assert_eq!(failed.status.code(), Some(1), "{stdout}{stderr}"); assert!( - stdout.contains("provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW "), + stdout.contains( + "provided FFTW-3.3.10-gompi-2026.1.eb name=FFTW list=tests/provided_fixtures/site.list line=4" + ), + "{stdout}" + ); + assert!( + stdout.contains("name=Python list=tests/provided_fixtures/site.list line=5"), + "{stdout}" + ); + assert!( + stdout.contains("name=GROMACS list=tests/provided_fixtures/site.list line=3"), + "{stdout}" + ); + assert!( + stdout.contains("unparsed not-a-version.eb list=tests/provided_fixtures/site.list line=7"), "{stdout}" ); - assert!(stdout.contains("name=Python "), "{stdout}"); - assert!(stdout.contains("name=GROMACS "), "{stdout}"); - assert!(stdout.contains("unparsed not-a-version.eb "), "{stdout}"); assert!(!stdout.contains("VASP"), "{stdout}"); assert!( stderr.contains("3 build-list lines name software the baseline already provides"),