Scan container images for R/Python libraries; survive legacy and broken images - #8
Conversation
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
There was a problem hiding this comment.
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
findcommand 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_FORMATmarker 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 missingcat, 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.
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
…the docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
…stings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
…shing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Multi-agent code reviewSix 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.
Blocking1.
|
kennedydane
left a comment
There was a problem hiding this comment.
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
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
…hing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
…loy it Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQdB8PTAXeH8S7eJ56svPz
…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
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 inlibraries.py. The container scanner needs the module scanner's layout knowledge without its filesystem:library_from_metadata_textparsescatoutput with the same three header rules, and the two*_metadata_find_commandbuilders render the layout constants as bounded in-imagefinds — kept beside those constants so a new layout is one edit.shlex.quoteon 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.sifmade apptainer convert it to a/tmpsandbox on every exec (36 min CPU, permission-denied FATALs), and the unwrapped$PATHprobe then killed the whole root's scan with exit 1. Discovery now sniffs leading bytes and skips known legacy formats (squashfshsqs, 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..simgfiles 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_scanstampsCACHE_FORMAT; a mismatch is ignored at INFO — one full rescan per container root after upgrading, then normal speed.snapshot_cache.pyjoins CI's 100% coverage list.013e385— the scan itself. Prefixes from the image's ownPATH(bin/sbinparents, PATH order, deduped) — deliberately withoutmodules.py's system-prefix exclusion, because inside an image/usr/localIS the payload (rocker R, conda). Two bounded in-imagefinds per prefix (R trees; pythonsite-packages+ Debian'sdist-packages,.dist-infoand both.egg-infoshapes) and onecatper metadata file — the man walk's cost profile, absorbed by the digest-skip after the first scan. Output is deterministic and R-correct:site-librarybeatslibrary(the copylibrary(x)loads), PATH order decides cross-prefix duplicates (conda'snumpybeats the distro's), final sort by(ecosystem, name). Batching allcats into one delimitedshloop (~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.librariesalready 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
snapshot_cache.py).pre-commit run --all-filesand mypy clean.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
🤖 Generated with Claude Code
https://claude.ai/code/session_01P7PAEFm2MwFjAaPAJLAqK4