Skip to content

Scan container images for R/Python libraries; survive legacy and broken images - #8

Merged
kennedydane merged 37 commits into
mainfrom
container-libraries
Aug 4, 2026
Merged

Scan container images for R/Python libraries; survive legacy and broken images#8
kennedydane merged 37 commits into
mainfrom
container-libraries

Conversation

@kennedydane

Copy link
Copy Markdown
Collaborator

Summary

Closes the README's "Container roots are not scanned for libraries" gap, and fixes today's production scan failure on the astro container root. Six commits:

  • 0b2c257 — text-based metadata parser + in-image find builders in libraries.py. The container scanner needs the module scanner's layout knowledge without its filesystem: library_from_metadata_text parses cat output with the same three header rules, and the two *_metadata_find_command builders render the layout constants as bounded in-image finds — kept beside those constants so a new layout is one edit. shlex.quote on every interpolated prefix: it comes from the image's own $PATH, the collector's first shell interpolation of image-controlled data.
  • d12b9ee — production hardening. Today's incident: a squashfs image renamed .sif made apptainer convert it to a /tmp sandbox on every exec (36 min CPU, permission-denied FATALs), and the unwrapped $PATH probe then killed the whole root's scan with exit 1. Discovery now sniffs leading bytes and skips known legacy formats (squashfs hsqs, raw ext superblock — a blocklist, so ordinary files aren't misjudged) with a loud warning; an image whose probes fail is recorded with its digest and empty listings (dropping it would be a removal under full-snapshot semantics); an unreadable/vanished file is skipped. A container root's scan now always survives its worst image. .simg files were already invisible to discovery (it globs *.sif); the sniff is for legacy images renamed .sif, which is exactly what production hit.
  • 4bafd9d — snapshot-cache format marker. A cached payload from an older collector validates perfectly while silently missing what the new scanner records (libraries=[]), and the digest-skip would reuse it forever. save_scan stamps CACHE_FORMAT; a mismatch is ignored at INFO — one full rescan per container root after upgrading, then normal speed. snapshot_cache.py joins CI's 100% coverage list.
  • 013e385 — the scan itself. Prefixes from the image's own PATH (bin/sbin parents, PATH order, deduped) — deliberately without modules.py's system-prefix exclusion, because inside an image /usr/local IS the payload (rocker R, conda). Two bounded in-image finds per prefix (R trees; python site-packages + Debian's dist-packages, .dist-info and both .egg-info shapes) and one cat per metadata file — the man walk's cost profile, absorbed by the digest-skip after the first scan. Output is deterministic and R-correct: site-library beats library (the copy library(x) loads), PATH order decides cross-prefix duplicates (conda's numpy beats the distro's), final sort by (ecosystem, name). Batching all cats into one delimited sh loop (~100× fewer execs) was measured and rejected: a metadata body containing the delimiter would silently shift versions onto wrong names.
  • c1f3191 — docs. README gap removed and renumbered ("module or container provides them"); CLAUDE.md pipeline rows updated; deploy guide warns about the one post-upgrade long scan.

No catalog-side changes: VersionPayload.libraries already existed and ingest/search/screens are kind-agnostic — container versions simply start filling the libraries tab and library search results.

The find commands were validated against real images (python:3.13-slim, Alpine/busybox find, debian:bookworm-slim, this repo's own web image — 46 dists found under its venv prefix, returned in readdir order, which is the empirical case for the explicit sort).

Testing

  • Full suite in the containerised harness: 1471 passed, 5 environment-dependent skips; coverage 100% total (both CI gates, including the newly-gated snapshot_cache.py).
  • pre-commit run --all-files and mypy clean.
  • 41 new tests across test_libraries.py, test_containers.py, test_snapshot_cache.py — including command-count proofs via a recording runtime (empty PATH ⇒ exactly two probes), both find-failure paths, per-file failure isolation, R-precedence, PATH-order dedup, and the format-marker rejection paths.

Deploy notes

  • The astro root's failing scan is fixed two ways: the legacy image is skipped by content sniffing, and even a still-broken image can no longer kill the scan.
  • First container scan after deploying this collector re-enters every image (cache format bump) and will run long once; subsequent scans digest-skip as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P7PAEFm2MwFjAaPAJLAqK4

kennedydane and others added 5 commits August 3, 2026 15:07
The container scanner needs libraries.py's knowledge without its
filesystem: library_from_metadata_text parses what an in-image cat
returned, and the two *_metadata_find_command builders render the
layout constants as bounded in-image finds — kept beside those
constants so a new layout is one edit, not a silent under-report in
whichever file was forgotten. The name field is now derived from the
ecosystem rather than passed beside it, making the wrong-field pairing
unrepresentable. dist-packages (Debian's site-packages rename) is
rendered for the container side only; the module-side walk is
deliberately unchanged. shlex.quote on every interpolated prefix: it
comes from the image's own $PATH — image-controlled data meeting a
shell string for the first time in this collector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7PAEFm2MwFjAaPAJLAqK4
Production: a squashfs image renamed .sif made apptainer convert it to
a /tmp sandbox on every exec — 36 minutes of CPU, permission-denied
FATALs — and the unwrapped PATH probe then killed the entire root's
scan with exit 1. Two fixes: discovery sniffs leading bytes and skips
known legacy formats (squashfs 'hsqs', raw ext superblock) with a loud
warning — a blocklist, so an unrecognised file still just fails fast;
and per-image isolation — probe failure records the image with its
digest and empty listings (dropping it would be a removal under
full-snapshot semantics), while an unreadable/vanished file is skipped
outright. A container root's scan now always survives its worst image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7PAEFm2MwFjAaPAJLAqK4
A cached payload written by an older collector validates perfectly —
that is the trap: with library scanning about to land, every
pre-upgrade payload carries libraries=[], and the digest-skip would
reuse it forever, producing nothing on exactly the roots the feature
is for, with no error anywhere. save_scan now stamps CACHE_FORMAT;
a load meeting any other (or no) marker ignores the cache at INFO —
the expected once-per-upgrade event, costing one full rescan. A
non-object cache file stays on the corrupt/WARNING arm. Also adds
snapshot_cache.py to CI's 100% list (and both doc copies): it is the
module that decides whether a rescan happens at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7PAEFm2MwFjAaPAJLAqK4
Closes the README's gap 4: the RStudio and python-ml containers now
report the libraries people actually search for. The container half of
libraries.py: prefixes come from the image's own PATH (bin/sbin
parents, PATH order, deduped) — deliberately without modules.py's
system-prefix exclusion, because inside an image /usr/local IS the
payload; two bounded in-image finds per prefix (built by libraries.py
beside its layout constants; dist-packages read container-side for
Debian bases) and one cat per metadata file, the man walk's cost
profile, absorbed by the digest-skip after first scan. find's readdir
order is sorted before dedup — site-library before library, so the
catalogue shows the copy library(x) loads — and PATH order makes the
cross-prefix dedup an answer: conda's numpy beats the distro's because
it is what the image's python imports. Batching every cat into one
delimited sh loop (~100x fewer execs) was measured and rejected: a
metadata body containing the delimiter would silently shift versions
onto wrong names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7PAEFm2MwFjAaPAJLAqK4
README loses its container-libraries gap (closed) and gains 'module or
container' in the headline library sentence; CLAUDE.md's Libraries and
Containers pipeline rows describe both sides, the legacy-format skip
and the probe-failure posture; the deploy guide warns that the first
container scan after an upgrade may ignore the cache and run long once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7PAEFm2MwFjAaPAJLAqK4
Copilot AI review requested due to automatic review settings August 3, 2026 13:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the ilifu collector’s container scanning so it can discover R/Python libraries inside container images (using in-image find + cat), and hardens container-root scans against legacy or broken images while keeping snapshot-cache correctness via a cache-format marker.

Changes:

  • Add container-image library discovery using shared metadata parsing and in-image find command builders.
  • Harden container discovery/scanning: skip legacy Singularity formats by sniffing magic bytes; record broken images with empty listings instead of killing the whole scan.
  • Introduce a CACHE_FORMAT marker in the snapshot cache so upgrades invalidate stale cached payloads safely; update docs/CI coverage gating accordingly.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
README.md Updates user-facing description and removes the “containers not scanned for libraries” known gap; updates coverage-gate text.
collector/src/ilifu_collector/libraries.py Adds shared metadata-text parser and in-image find command builders for container scanning.
collector/src/ilifu_collector/containers.py Implements library scanning inside images; adds legacy-image sniffing and improved failure isolation.
collector/src/ilifu_collector/snapshot_cache.py Adds cache-format marker enforcement to prevent digest-skip from reusing outdated payloads.
collector/tests/test_libraries.py Adds unit tests for metadata-text parsing and find command builders.
collector/tests/test_containers.py Adds tests for legacy-image skipping, probe failure isolation, and in-image library scanning behavior.
collector/tests/test_snapshot_cache.py Adds tests for cache format marker behavior (missing/newer format).
CLAUDE.md Updates pipeline documentation for container library scanning and cache format behavior.
ANSIBLE_DEPLOY.md Adds deploy note about one-time longer scans after collector upgrades due to cache invalidation.
.github/workflows/ci.yml Adds snapshot_cache.py to the 100%-coverage gated module list.
Suppressed comments (1)

collector/src/ilifu_collector/containers.py:337

  • The "could not read library metadata" warning drops the underlying ContainerExecutionError, which likely contains the apptainer stderr/exit code. Logging the exception message will help pinpoint whether the failure is permissions, a missing cat, or an image runtime error.
    try:
        return runtime.exec_capture(image_path, ('cat', file_path))
    except ContainerExecutionError:
        logger.warning('could not read library metadata %s inside %s', file_path, image_path)
        return None

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread collector/src/ilifu_collector/containers.py Outdated
Comment thread collector/src/ilifu_collector/containers.py Outdated
kennedydane and others added 13 commits August 3, 2026 17:56
Two review findings against ApptainerRuntime.exec_capture:

subprocess.run(text=True) decoded with the strict locale codec, so one
latin-1 byte in a cat-ed DESCRIPTION raised UnicodeDecodeError — a
ValueError no caller catches — killing the whole root's scan. Decode as
UTF-8 with errors='replace', the same rule libraries.py already states
for the module-side reader.

A FileNotFoundError from the spawn (apptainer absent from a systemd
timer's PATH) is an OSError, which containers.py treats as 'this image
is unreadable, skip it' — so a node that cannot scan anything uploaded
an empty snapshot and exited 0. The spawn failing now raises
ApptainerNotAvailableError (neither ContainerExecutionError nor
OSError), mirroring LmodNotAvailableError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
The ext-superblock test read two bytes at offset 1080 unconditionally,
inside the range where SIF stores UUIDs and free-form descriptor names —
so ~1 in 65536 valid images would sniff as legacy and be silently
removed from the catalogue on every rescan. The SIF magic at offset 32
is now checked first; the blocklist posture for unrecognised formats is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
The review's headline finding: an image whose probe failed was recorded
with its real digest and empty listings (correct — under full-snapshot
semantics absence means removal), but that payload was then written to
the snapshot cache, and a .sif's bytes never change — so the digest-skip
replayed the empty listings on every later scan and one 60s exec timeout
blanked the image in the catalogue forever.

scan_container_root now returns a ContainerScanResult naming every image
whose payload was built with any failed probe (env probes, binary and
man listings, library finds, cat reads), and run_scan caches only
cacheable_packages() — so the next scan re-probes exactly the degraded
images. CACHE_FORMAT bumps to 3 because a format-2 file may already hold
such payloads.

Two failure paths also narrowed while restructuring around the digest:
the PATH and MANPATH probes get separate try blocks (a flaky MANPATH
exec cost the binaries and libraries the answered PATH already
provided), and an OSError while hashing an image now reuses its cached
payload instead of dropping the version — a transient NFS blip was a
catalogue removal, complete with REMOVED change events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
The scan command translates ApptainerNotAvailableError into the same
BadParameter treatment LmodNotAvailableError gets, and watch checks for
the container kind's tool before starting its observer — for the reason
the lmod check already documents: the watch loop survives rescan
failures by design, so a missing binary discovered there is a daemon
logging the same traceback forever. run_scan also warns loudly when a
scan finds zero packages, because an empty snapshot means 'remove
everything' and the server's total-wipe refusal happens in a background
apply the collector never sees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Three library-scan hardenings from the review:

LibraryPayload construction was unguarded, so a Name past the contract's
200-char bound (files nobody in this project wrote) raised
ValidationError — caught on neither scan path — and killed the whole
root's scan, forever under watch. It now warns naming the source and
skips that one library.

The header-parse refactor had made a mid-read OSError return {} where
the old code returned the fields parsed so far — so an EIO late in a
megabytes-long header block dropped a library whose Name and Version
were already in hand, a REMOVED event under full-snapshot semantics.
_parse_metadata_headers now accumulates into a caller-owned dict.

The find builders' '|| true' rewrote every failure to success, making a
broken or absent in-image find (exit 127 — distroless and stripped
images) indistinguishable from 'no libraries installed'. It is now
'|| [ $? -eq 1 ]': find's partial-error exit stays tolerated (a missing
library tree is the normal case), anything else fails the exec so the
scanner warns and marks the image degraded. Prefixes are joined with
PurePosixPath, so the root prefix renders /lib rather than //lib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
…Error

AttributeError sat in the except tuple only so a non-dict JSON body's
.get() call would land in the unreadable-cache arm — which also silenced
any future AttributeError out of PackagePayload construction as
'ignoring unreadable scan cache', a permanent full-rescan-per-scan with
nothing to debug from. The shape check is now an isinstance raising
ValueError, and a genuine collector bug stays a traceback; a test pins
the escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
…sting

The review's cleanup batch, minus the exec batching (deferred to its own
PR — a TODO at the rejection site records the nonce-delimiter plan):

- PREFIX_BIN_DIR_NAMES lives in libraries.py, imported by both scanners
  and pinned identical by a test; the two private copies could drift,
  and a drift is one side silently deriving different prefixes from the
  same PATH.
- _install_prefixes gains the non-bin fallback modules._derive_prefix
  documents: a PATH entry that is not a bin directory is the prefix
  itself, so an image installing R under /opt/tools/lib is no longer
  scanned as having no libraries at all.
- The exec-splitlines-strip idiom, written three times, is one
  _exec_lines helper (None on failure, so callers keep distinguishing
  a broken exec from an empty listing), and the cat/zcat readers merge
  into _read_file_text.
- The per-loop ecosystems tuple becomes the module-level
  _ECOSYSTEM_FINDS table: a third ecosystem is a row, not three edits
  in a nested loop.
- The libraries.py docstring no longer overclaims 'a new layout is one
  edit' — the walk and the find patterns encode the layouts separately,
  deliberately so for dist-packages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
@kennedydane

Copy link
Copy Markdown
Collaborator Author

Multi-agent code review

Six agents: four independent finders (line-by-line, removed-behavior audit, cross-file tracing + Python pitfalls, cleanup/conventions) and two adversarial verifiers over the pooled candidates. 34 candidates, 1 refuted, the rest merged into the 21 findings below.

Note on line references. These were reviewed against local branch HEAD 72424e9, which is 13 commits ahead of this PR's head (c1f3191). Several findings concern code that only exists in those unpushed commits — tolerant_find_command arrived in 3bb4a2f, the up-front apptainer check in ef7172f — which is why this is a plain comment rather than inline anchors. Push the branch and I'll re-post these as inline review comments.


Blocking

1. find argument injection through an image-controlled $PATHcontainers.py:536 (also :546, libraries.py:374, :390)

shlex.quote protects the shell, not find's own argument parsing. shlex.quote('-delete') returns -delete unquoted (- is in shlex's safe set), so an image whose $PATH contains -delete renders:

find -delete -maxdepth 1 -type f -executable 2>/dev/null || [ $? -eq 1 ]

GNU find defaults its start point to . when the first argument is a predicate, and apptainer exec runs with the host cwd, bind-mounting $HOME/$PWD//tmp read-write. Reproduced by the verifier: in a directory containing two files, that exact command emptied it and exited 0 — the 2>/dev/null || [ $? -eq 1 ] wrapper swallows every complaint.

Same root cause, containers.py:334: _install_prefixes has no absoluteness check either. An image whose $PATH contains . or a bare bin yields prefix ., so r_metadata_find_command renders start points like lib/R/site-library, which apptainer resolves against the host cwd it bind-mounts — run the collector from a directory holding a lib/python3.11/site-packages tree and the host's distributions are catalogued as installed inside the image.

The fix is not more quoting: reject any PATH entry / prefix that is not absolute before it reaches a command builder.

2. || [ $? -eq 1 ] forgives every find failure, not just missing start points — libraries.py:333

The docstring's premise is too narrow. GNU find exits 1 for all errors, verified locally: an unknown predicate (find /tmp -maxdepth 1 -bogus → 1) and an existing-but-unreadable start directory (mode 000 → exit 1, no stdout) both exit 1, and wrapping either gives exit 0 with empty output.

apptainer exec runs as the invoking uid, so a root-owned mode-0700 directory on an image's PATH is exactly this case — as is any EACCES subdirectory partway through the recursive man and library finds, which then return a truncated listing recorded as a complete inventory. failures.mark() never fires, so the image is treated as cleanly scanned, save_scan persists the blank/partial payload, and since a .sif's bytes never change the digest-skip replays it forever. main at least logged could not list %s inside %s here.

This is the silent-blank-forever failure cacheable_packages exists to prevent, re-entering through the tolerance wrapper. Distinguish a missing start point from other exit-1 causes — pre-test the start points, or keep stderr and forgive only when the complaint is No such file or directory.

3. Every spawn-time OSError becomes ApptainerNotAvailableErrorruntime.py:109

This PR multiplies execs per image from a handful to one cat per metadata file plus two finds per prefix — hundreds for a conda image. One fork() returning EAGAIN/ENOMEM, or EMFILE, on a busy login node now raises ApptainerNotAvailableError, which no probe site catches (they all catch ContainerExecutionError). It unwinds scan_container_root, past run_scan, and cli.scan reports it as typer.BadParameter: cannot run apptainer: [Errno 11] ... — exit 2, an "Invalid value" argument error, no snapshot uploaded, and the operator pointed at a configuration problem that does not exist.

Verified by injecting BlockingIOError(11) into subprocess.run. Reserve the conversion for a genuinely missing or unrunnable binary; let a transient spawn failure stay a ContainerExecutionError so it costs one probe, not the scan.

4. Two paths turn a transient failure into a catalogue deletion

containers.py:292 — a failed PATH probe uploads blanks while previous_version is in hand. The comment is right that an omitted image is a removal — but under full-snapshot semantics these empty listings are a removal of everything the image contributed. Server side: ingest.py does version.binaries.all().delete() and version.libraries.all().delete(), and _sync_manpages deletes every stored key absent from the payload. One 60s --exec-timeout on a loaded node and blast/2.15.0 goes from 40 binaries and 12 man pages to zero of each. previous_version is right there at line 275 and is consulted only for the digest-match early return, while the sibling OSError path at line 164 deliberately does the opposite — the two disagree about whether a transiently unreachable image flaps out of the catalogue.

containers.py:157 — a digest read error with no cached payload drops the image. main called _compute_digest unguarded, so an EIO or stale NFS handle aborted the root's scan and the catalogue stayed intact. Now the image is omitted, which the server's diff turns into a deletion of the version (and the package, if it was the only one). Because CACHE_FORMAT moves to 3 in this same PR, load_previous_versions returns {} for every root on the first run after deploying, so every image sits on this branch for exactly one scan.


Plausible — worth a decision, not necessarily a change

  • containers.py:508 — one unreadable file permanently disqualifies its image from the scan cache. main only logged; this marks the whole image degraded, so a dangling symlink in man1/ or a root-owned mode-0600 page (apptainer runs as the invoking uid) sets degraded on every scan. cacheable_packages() drops that version, so a multi-GB .sif is re-hashed and fully re-probed every time — under watch, forever, which is precisely what snapshot_cache.py was added to eliminate. Consider not degrading the image when a single file read fails but the listings succeeded.
  • containers.py:205 — legacy images already in the catalogue are removed by the skip (accepted, noted for the deploy notes). If a root is entirely legacy the snapshot is empty, run_scan warns but still uploads, scan prints accepted and exits 0, and _refuse_a_total_wipe discards it in a background apply the collector never observes — so that root silently stops updating.
  • containers.py:264 — the sniff tests fixed offsets. Singularity 2.4+ images commonly carry a #!/usr/bin/env run-singularity launch header (the same 32-byte launch field SIF uses), which shifts hsqs to offset 32 — where this code instead compares SIF_MAGIC — and the ext magic off 1080. Apptainer's own detection searches the head buffer rather than testing offset 0. Worth a head -c 64 | xxd on the production image that caused the incident. The ext arm is also a bare two-byte test: any non-SIF file (a truncated build, an OCI archive) has a ~1/65536 chance of being dropped with a warning naming the wrong cause.
  • containers.py:489.splitlines() + .strip() with no -print0: an in-image path holding a newline is split into two bogus paths, both cats fail, and the image is degraded — hence cache-excluded and fully re-probed — on every scan for the life of the deployment. (The man-page half is safe; parse_manpage_filename rejects the split path before any cat.)
  • containers.py:400 — one apptainer exec cat per metadata file, on a first scan the CACHE_FORMAT bump forces for every root at once. Hundreds of distributions per conda image at ~0.2–1s per spawn. Not as bad as it first looks: _watch_loop calls maybe_rescan() synchronously, so rescans serialise rather than lapping. One very long scan, not a broken one — the nonce-batching TODO is the real fix.
  • containers.py:391 — two find execs per prefix where two per image would do; a stock image PATH dedups to three prefixes, so six container starts list library metadata. Both builders already emit multiple start points.
  • containers.py:96cacheable_packages() re-derives by string-matching str(image_path) what the scan loop at 166-169 already knew per version. The whole degraded-payloads-must-not-be-cached rule now rests on two independently-produced strings staying equal; normalise one and not the other and a degraded payload is cached forever with no error. Appending to a cacheable_versions list in the loop deletes the set, this method, and the model_copy rebuild.
  • containers.py:450 — R's precedence is encoded twice: _R_LIBRARY_DIR_NAMES order ("in R's own precedence order") drives the find, while this sort key restates it as the substring '/site-library/'. Add a tree to the tuple and the find picks it up while this ranks it last, so dedup keeps the bundled copy and the catalogue reports the version library(x) does not load.

Cleanup

  • modules.py:80 — the #: comment for the deleted _BIN_DIR_NAMES was left attached to _SYSTEM_PREFIXES, which now carries two contradictory descriptions.
  • cli.py:126 — the empty-scan warning fires for module roots too but closes with "check the collector log above for skipped images", advice a module scan never produces. (The still-upload half is documented as deliberate.)
  • cli.py:210 vs :333 — the same "check this kind's tool up front" rule in two encodings, one inline and container-only, one dispatched. A third kind in _VALID_KINDS and scan silently reverts to what this PR just fixed. One _require_tool_for(kind) used by both.
  • libraries.py:126'site-packages' spelled twice (also _SITE_PACKAGES, line 104), one feeding the module walk and one the in-image finds.
  • runtime.py:46find_apptainer() returns a resolved path both call sites discard while exec_capture re-resolves the bare name; the signature advertises a guarantee the code does not keep. Contrast find_lmod, whose return is consumed.
  • test_libraries.py:495 — local import logging duplicating the module-level one this same PR added.
  • test_libraries.py:36test_should_share_one_bin_dir_rule_between_the_two_scanners asserts two imports are the same object; _derive_prefix and _install_prefixes could both be broken and it would still pass, while counting toward the 100% gate. Feeding one PATH entry through both derivations is strictly stronger and just as cheap.

Refuted (1)

The .xz/.bz2/.zst man-page mojibake path cannot occur: _read_manpages only calls _read_file_text after parse_manpage_filename succeeds, and its regex rejects every non-.gz compression suffix, so such a page is logged and skipped before any cat.


The scan logic itself is sound and the test suite is genuinely thorough — everything above is about what happens when an image, a directory, or a fork() misbehaves. Fixes for the blocking items and the two data-loss paths are on the way in follow-up commits, under the rule that any doubt keeps an item in the catalogue: only a file actually gone from disk may become a removal. Legacy images are accepted as disappearing.

🤖 Generated with Claude Code

@kennedydane kennedydane left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-agent code review — inline

The findings from my earlier summary comment, now anchored to the lines they concern. That comment holds the overview and the reasoning for the ranking; these are the 21 findings themselves.

Six agents produced them: four independent finders (line-by-line, removed-behavior audit, cross-file tracing + Python pitfalls, cleanup/conventions) and two adversarial verifiers over the pooled candidates — 34 candidates, 1 refuted.

Fixes for the four blocking items and the two data-loss paths are already written and verified against the full suite; they are not in this push, which is the branch as reviewed.

🤖 Generated with Claude Code

Comment thread collector/src/ilifu_collector/containers.py Outdated
Comment thread collector/src/ilifu_collector/containers.py
Comment thread collector/src/ilifu_collector/libraries.py Outdated
Comment thread collector/src/ilifu_collector/runtime.py Outdated
Comment thread collector/src/ilifu_collector/containers.py Outdated
Comment thread collector/src/ilifu_collector/cli.py Outdated
Comment thread collector/src/ilifu_collector/libraries.py Outdated
Comment thread collector/src/ilifu_collector/runtime.py Outdated
Comment thread collector/tests/test_libraries.py Outdated
Comment thread collector/tests/test_libraries.py Outdated
kennedydane and others added 7 commits August 4, 2026 12:43
Every OSError out of subprocess.run became ApptainerNotAvailableError, a
class no probe site catches — so one fork() returning EAGAIN under memory
pressure, or EMFILE, unwound the whole root's scan and reported it to the
operator as a bad CLI argument. Scanning an image for libraries spawns one
exec per metadata file, which is what made a transient spawn failure likely
enough to matter.

Classified by what the failure says about the binary instead: a missing or
unrunnable apptainer still aborts loudly, and everything else degrades the
one probe the way a nonzero exit already did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Three changes to one mechanism, which is why they land together: the guard
and the sifting script live inside the same four command builders, and
separating them would mean landing a tree that does not run.

shlex.quote defends sh, not find's own argument parsing — quote('-delete')
is a no-op — and the directories interpolated into every in-image find come
from the image's own $PATH. Since apptainer exec runs with the host cwd
bind-mounted writable, an image carrying `-delete` on its PATH made find
default its start point to the operator's own directory and act there. A
relative entry was the quieter half of the same hole: it resolved against
that cwd too, so the host's own libraries could be catalogued as the
image's. Non-absolute entries are now dropped where the variable is read,
and every builder refuses one where the string is built.

The exit-status wrapper forgave more than it claimed. find exits 1 for any
error, not only a missing start point, so an unreadable directory or an
EACCES subdirectory partway through a recursive walk returned a truncated
listing as a complete one — uncounted as degraded, cached, and replayed by
the digest-skip forever. Start points are now sifted with [ -d ] before find
runs, so the ordinary case needs no forgiving and anything else fails; and
find's complaint reaches the log instead of /dev/null.

An image still on disk may appear in a snapshot only as a payload we trust.
A failed listing means the inventory may be silently incomplete, so the
image shows its last trusted scan, or the scan refuses to build a snapshot
at all rather than upload blanks that the server reads as a deletion. A
failed read of one file is different — one known item is missing and the
rest stands — so it keeps its payload. A reused payload carries its own
original digest, so it belongs in the cache; only a payload built around a
known gap stays out of it. An image whose bytes have vanished is the one
disappearance that is always legitimate, and is omitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
The operator-facing half of the trust rule. run_scan raises before a client
exists, so nothing is uploaded; scan echoes the sentence — which image, that
the catalogue is unchanged, and the two remedies — and exits 1 rather than
tracebacking at a systemd timer nobody is reading; and the watch loop keeps
watching, since a rescan that refuses is exactly the failure it already
survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
The pipeline row, the README's scan section and the deploy guide all
described an image whose probes fail as recorded with empty listings and the
root's scan as surviving anything. Both are now narrower and worth stating
precisely: the two disappearances that are allowed, when a scan refuses
instead, and that a reused payload is cached while a payload built around a
known gap is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Nine review findings, none of them a live bug and all of them the same
shape: a rule written down twice, or a name promising more than the code
keeps.

The scan loop now builds the cacheable payload list beside the uploaded one,
knowing per version which it is, instead of `cacheable_packages()` matching
`image_path` strings against a set of degraded paths afterwards. The rule
that a payload built around a known gap must never be cached rested on two
independently-produced spellings of one path staying equal; the day one is
normalised and the other is not, the gap becomes permanent, silently.

R's library precedence lived both in `_R_LIBRARY_DIR_NAMES`, documented as
being in R's own order, and as a `'/site-library/'` substring in the
container sort key. Adding a tree to the tuple would have had the find pick
it up while the ranking sorted its hits last, so the catalogue would report
the copy `library(x)` does not load. The rank is now derived from the
tuple's index, beside it.

`scan` checked apptainer inline and left lmod to the module scanner while
`watch` dispatched on kind; one `_require_tool_for` serves both, and a
module scan now fails up front on a node without lmod rather than one call
later. `find_apptainer` returned a resolved path both callers discarded and
`exec_capture` re-resolved anyway — it is `require_apptainer`, returning
nothing, because the path was a promise it could not keep. The empty-scan
warning stops telling a module root to look for skipped images.

Also: `_PYTHON_PACKAGES_DIR_NAMES` reuses `_SITE_PACKAGES` rather than
respelling it; a stale comment left over `_SYSTEM_PREFIXES` by an earlier
deletion is gone; and the test asserting two modules import the same object
now runs a PATH entry through both prefix derivations, which is what has to
agree — the old form held while both were broken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
The sniff recognised no legacy image on the real cluster. Since 2.4
Singularity prepends `#!/usr/bin/env run-singularity\n` so an image can be
executed directly, and the filesystem starts after those 31 bytes — a
hexdump of the ilifu root shows all 179 images carrying it, 171 SIF and 8
squashfs, and not one with `hsqs` at offset 0 where this looked. So every
one of the 8 was still converted to a /tmp sandbox on every exec, which is
the production incident this was written to end.

Each magic is now checked at both candidate starts, the file's own and past
the header. Both, rather than searched for freely: a two-byte value like the
ext magic matches often enough by chance that a free search would skip real
images. SIF stays at a fixed 32, because its launch field is fixed-size, and
a file carrying it is still never legacy.

The header search is bounded, so a binary whose first bytes happen to be
`#!` cannot send it hunting for a newline inside the filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
A failed `cat` logged which page or metadata file was missed but not what
went wrong, so a dangling symlink in `man1/`, a `.gz` that `zcat` rejects
and a page the invoking uid may not open all read identically in the log —
three different remedies behind one sentence. The listing path was given the
error's own text when the finds stopped discarding stderr; this is the other
half of that change, and the half Copilot's review asked for.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
kennedydane and others added 12 commits August 4, 2026 15:31
…old one's

The keep-on-None rule for deffile assumed the digest stays put while no
scan re-reads it. When the digest changes in the same update, that
assumption is false: the stored recipe describes the replaced image, so
a failed inspect on the new one must clear it rather than leave the
provenance tab showing stale, mismatched text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HC1i3ffXNK5znUTTKo3x4h
extra='forbid' means a collector upgraded ahead of the server 422s
every container upload, but the operator-facing deploy doc only pinned
both sides to one SHA without saying which side goes first or why.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HC1i3ffXNK5znUTTKo3x4h
Missing backends.py, permissions.py, walking.py, client.py, cli.py and
tasks.py — six of the seventeen modules ci.yml's domain_module_names
actually enforces, some predating this branch and some (snapshot_cache.py's
neighbours) touched by it without being reconciled against the full list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HC1i3ffXNK5znUTTKo3x4h
@kennedydane
kennedydane merged commit 61aef20 into main Aug 4, 2026
0 of 2 checks passed
@kennedydane
kennedydane deleted the container-libraries branch August 4, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants