Skip to content

feat(toolchain): split the toolchain image into traust-toolchain and traust-runtime (XWING-2440) - #277

Merged
smith-xyz merged 1 commit into
developmentfrom
feat/toolchain-image-split
Oct 28, 2026
Merged

smith-xyz merged 1 commit into
developmentfrom
feat/toolchain-image-split

Conversation

@JGiola

@JGiola JGiola commented Oct 27, 2026

Copy link
Copy Markdown

Plan: plans/traust-standardization/11-consumer-adoption/08-jacopo-toolchain-image-split.md

Containerfile.toolchain was one "all-in-one" image: pinned scanner binaries, the whole Python stack and skill data. This splits it into traust-toolchain (pinned scanner binaries only) and traust-runtime (FROM it, by digest, plus traust-core/traust-engine/traust), so a scanner pin advance (weekly at most) never forces a rebuild of the Python layer, and vice versa. No vulnerability database ships in either image.

What

Containerfile.tools — three stages: two builders, one fresh final image.

  • go-builder: builds govulncheck and a statically-linked skopeo (CGO_ENABLED=0 — a dynamically-linked build there links against the builder's newer glibc, which the final UBI9 base can't satisfy) from registry.access.redhat.com/hi/go:1.26.9-builder, pinned by digest. skopeo's module path is go.podman.io/skopeo (the containers-org rename); 1.26 specifically because hi/go:1.27.3's stdlib net/http2 is incompatible with skopeo's resolved grpc v1.80.0.
  • tools-builder: downloads and compiles everything else (opengrep, gitleaks, syft, grype, cosign from pinned release assets; yara from source with --disable-shared so it statically links libyara; the pinned Go runtime; the isolated pip-audit venv) using a build toolchain (gcc, autotools, pkgconfig) that never reaches the final image.
  • tools: a fresh final stage, not a trimmed copy of either builder. Installs only git-core and python3.11 — driven by running ldd against every native binary (cosign/gitleaks/grype/syft/skopeo are fully static; opengrep needs only libc; yara/yarac need libc/libm; govulncheck needs libresolv/libc for its cgo DNS resolver — all already part of base glibc). git-core is a genuine runtime dependency (gitleaks history mode, yara rule-pack clone both declare it), not a build leftover.
  • Every binary verified against its exact pin at build time; a failed install fails the build (yara's install no longer swallows failures). opengrep installs from its pinned release asset, not the mutable install.sh URL. The Go pin now reads from config/external-tools.yaml (was hardcoded to 1.23.0 regardless of the manifest). Base image digests are inlined directly in each FROM line, matching this repo's existing convention.

Containerfile.runtime — FROM the tools image by digest (never a floating tag); traust-core/traust-engine/traust via uv sync --frozen --no-editable at their pinned Git SHAs; labels carry the toolchain hash and commit. No database population step (the old image's grype db update, silently ignored on failure, is gone).

scripts/containerfile-build-args.py — a pin-set hash over the resolved installed versions (sorted name=version pairs) tags both images and the toolchain:<hash> runner label; stable under comment/reordering edits to the manifest, changes only on a real version bump. --check validates every binary the tools image installs has a real pin.

scripts/build-toolchain-image.sh — rewritten to build both images, resolving the tools image's local digest automatically and passing it to the runtime build. Fixed a real shift-inside-a-for-loop argument-parsing bug along the way.

traust/context.py — register_providers() now receives feeds_cache=, resolved lazily so a laptop install with no progress_tracker configured keeps scanning tools' native caches unchanged. Needs traust-engine#104 (merged, scanner-db wiring): grype and govulncheck now read their databases from <feeds_cache>/tool-dbs/<tool> and raise before starting when that database is missing or unusable — confirmed necessary against the real binaries: grype fails on its own with an empty database, but govulncheck otherwise silently reports zero findings against an empty -db file:// path. The traust-engine pin is bumped to 402a8a2, the merged commit.

CI (.github/workflows/container-images.yml) — builds both images on every PR touching them, and checks every property this plan's "Done when" names: every binary at its pin, no Python stack or database in the tools image, no build toolchain or missing shared library, the runtime label names the toolchain hash, and a scan against an emptied database degrades rather than reporting zero.

Retired: Containerfile.toolchain (replaced), build-pinned-wheels.py (superseded by uv sync --frozen installing directly from the pinned Git sources).

Verification

Built and verified end to end with real podman build (not just written): both images build from a clean machine; every binary at its pin; no traust_core, no build toolchain, no database anywhere in the tools image (1.19GB, down from 1.64GB for the equivalent single-stage build); an empty mounted tool-dbs/grype makes grype exit non-zero with a clear error; the composed runtime image imports the full traust stack and runs its CLI.

  • uv run pytest tests/ -n auto: 2601 passed, 61 skipped (14 pre-existing failures confirmed present on a clean development checkout too — a local GPG-signing config issue, unrelated to this change)
  • uv run ruff check . / ruff format --check .: clean

Reviewer checks (from the plan)

  • No database or Python stack in the tools image
  • The runtime image builds FROM the tools image by digest
  • Labels say what each image is

Quality checklist

  • 1. Comments only for the non-trivial
  • 2. Docstrings don't bloat
  • 3. Don't reinvent
  • 4. Shared logic is a helper, in one place
  • 5. Tests cover the edges
  • 6. Integration tests are thorough and use real data, sanitized — exercised against real podman builds and real scanner binaries throughout development
  • 7. Minimal, clean code
  • 8. Queries are low latency, and you checked — no new database queries; this is container build/image wiring
  • 9. Tests change only on purpose, and a person said so — no existing test's assertions were changed; only new tests were added

Legacy files

File Verdict Where it went / why it's gone
Containerfile.toolchain Retired Replaced by Containerfile.tools + Containerfile.runtime
scripts/build-pinned-wheels.py Retired Superseded by uv sync --frozen --no-editable installing directly from the pinned Git sources in Containerfile.runtime

Changed tests

None — no existing test's assertions were changed, only new tests added.

  • A person reviewed each changed test and the behaviour change is intended (N/A — no changed tests)

@openshift-ci openshift-ci Bot added approved Indicates a PR has been approved by an approver from all required OWNERS files. needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. labels Oct 27, 2026
@JGiola
JGiola force-pushed the feat/toolchain-image-split branch from 3bfc5d7 to f3b8764 Compare October 27, 2026 15:43
@openshift-ci openshift-ci Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Oct 27, 2026
@gurnben

gurnben commented Oct 27, 2026

Copy link
Copy Markdown
Member

Reviewed — the split is sound and this is a net supply-chain improvement. /lgtm

What I checked:

  • Fresh final stage. Builders are abandoned; only binaries plus the runtime packages ldd showed are copied. USER 1001 on both final images, ENTRYPOINT ["python3.11", "-m", "traust.cli"].
  • No floating inputs. Bases are digest-pinned (hi/go:1.26.9-builder@sha256:c628…, ubi9/ubi-minimal:9.8-…@sha256:6f3a…), and the old curl …/opengrep/install.sh (which tracked main under an already-published tag) is replaced by a pinned release asset — that alone is worth the change.
  • Every install asserts its version and fails the build otherwise; no || true swallowing, set -eu throughout. The CGO_ENABLED=0 skopeo build with the glibc rationale is correct, and --disable-shared for yara removes the libyara/ldconfig coupling.
  • CI proves the invariants, not just that the build exits 0: pins match the inventory, every binary reports its pin, no Python stack / no vulnerability DB / no build toolchain in traust-toolchain, runtime builds FROM the toolchain by digest, the hash label is present, the CLI runs, and the emptied-database scan degrades instead of reporting zero. That last one is the test I would have asked for.

One non-blocking nit, and it is pre-existing rather than introduced here: release assets are checked against their reported version, not against a published digest. A tampered asset that lies about --version would pass. cosign is already in the image, so cosign verify-blob (or comparing the upstream .sha256 sidecar) on each download would close that gap — worth a follow-up issue rather than another round on this PR.

gurnben
gurnben previously approved these changes Oct 27, 2026
@openshift-ci openshift-ci Bot added the lgtm Indicates that a PR is ready to be merged. label Oct 27, 2026
@gurnben

gurnben commented Oct 27, 2026

Copy link
Copy Markdown
Member

/lgtm

@openshift-ci

openshift-ci Bot commented Oct 27, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: gurnben, JGiola

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@gurnben

gurnben commented Oct 27, 2026

Copy link
Copy Markdown
Member

Heads-up: this went CONFLICTING right after I approved it — #279 (traust-core loader) merged into main and rewrote src/traust/context.py, which this PR also touches for the feeds-cache wiring. Needs a rebase; the conflict should be confined to that file (the container/script/workflow changes are untouched by #279). Happy to push the rebase if you'd rather I did it, but it is your branch and the resolution is in your wiring code.

Also worth knowing for the rebase: #279 moved resolve_config_home onto traust_core.v1.context and dropped the local implementation, so if your hunks sit near the composition-root imports they will need taking core's side.

@openshift-ci openshift-ci Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Oct 28, 2026
…traust-runtime (XWING-2440)

External tooling and the traust Python stack become two separate images, so a
scanner pin advance (weekly at most) never forces a rebuild of the Python
layer, and vice versa. No vulnerability database ships in either image;
scanners read theirs from a mounted feeds cache.

Containerfile.tools (three stages: two builders, one fresh final image)
- go-builder: builds govulncheck and a statically-linked skopeo (CGO_ENABLED=0
  - a dynamically-linked build there links against the builder's newer glibc,
  which the final UBI9 base can't satisfy) from registry.access.redhat.com/
  hi/go:1.26.9-builder, pinned by digest. skopeo's module path is
  go.podman.io/skopeo (the containers-org rename); 1.26 specifically because
  hi/go:1.27.3's stdlib net/http2 is incompatible with skopeo's resolved
  grpc v1.80.0.
- tools-builder: downloads and compiles everything else (opengrep, gitleaks,
  syft, grype, cosign from pinned release assets; yara from source, built
  with --disable-shared so it statically links libyara; the pinned Go
  runtime; the isolated pip-audit venv) using a build toolchain (gcc,
  autotools, pkgconfig) that never reaches the final image.
- tools: a FRESH final stage, not a trimmed copy of either builder. Installs
  only git-core and python3.11 - the runtime dependency list is driven by
  running ldd against every native binary (cosign/gitleaks/grype/syft/skopeo
  are fully static; opengrep needs only libc; yara/yarac need libc/libm;
  govulncheck needs libresolv/libc for its cgo DNS resolver - all already
  part of base glibc). git-core is a genuine runtime dependency: gitleaks
  (history mode) and yara (rule-pack clone) both declare it in
  traust_engine.adapters.tools, not a build-time leftover.
- Every binary verified against its exact pin at build time; a failed
  install fails the build (yara's install no longer swallows failures).
  opengrep installs from its pinned release asset, not the mutable
  install.sh URL. The Go pin now reads from config/external-tools.yaml
  (was hardcoded to 1.23.0 regardless of the manifest).
- Base image digests are inlined directly in each FROM line, matching this
  repo's existing convention - not indirected through an ARG with a
  hardcoded default that nothing overrides.

Containerfile.runtime
- FROM the tools image by digest (never a floating tag). traust-core/
  traust-engine/traust installed via `uv sync --frozen --no-editable` at
  their pinned Git SHAs. Labels carry the toolchain hash and commit.
- No database population step (the old image's grype db update, silently
  ignored on failure, is gone).

scripts/containerfile-build-args.py
- A pin-set hash over the *resolved* installed versions (sorted name=version
  pairs) tags both images and the `toolchain:<hash>` runner label - stable
  under comment or reordering edits to external-tools.yaml, changes only on
  a real version bump. A --check flag validates every binary the tools image
  installs has a real pin, so a missing one fails loudly instead of falling
  back to a Containerfile-local default.

scripts/build-toolchain-image.sh: rewritten to build both images, resolving
the tools image's local digest automatically (`podman image inspect
--format '{{.Digest}}'` works without a registry push) and passing it to the
runtime build. Fixed a real shift-inside-a-for-loop argument-parsing bug
along the way (a bash gotcha: `shift` inside `for arg in "$@"` doesn't affect
the loop's iteration).

traust/context.py: register_providers() now receives feeds_cache=, resolved
lazily (same timing as the engine load) so a laptop install with no
progress_tracker configured keeps scanning tools' native caches unchanged.
Needs the engine's door for this (merged traust-engine#104, scanner-db
wiring): grype and govulncheck now read their databases from
<feeds_cache>/tool-dbs/<tool> and raise before starting when that database
is missing or unusable - confirmed necessary against the real binaries:
grype fails on its own with an empty database, but govulncheck otherwise
silently reports zero findings against an empty -db file:// path. The
traust-engine pin is bumped to 402a8a2, the merged commit.

CI (.github/workflows/container-images.yml): builds both images on every PR
touching them, and checks every property this plan's "Done when" names -
every binary at its pin, no Python stack or database in the tools image, no
build toolchain (gcc/make/automake/autoconf/libtool/pkg-config) or missing
shared library, the runtime label names the toolchain hash, and a scan
against an emptied database degrades (grype exits non-zero) rather than
reporting zero findings.

Retired: Containerfile.toolchain (replaced by the split), build-pinned-
wheels.py (superseded by `uv sync --frozen` installing directly from the
pinned Git sources).

Verified end to end with real podman builds (not just written): both images
build from a clean machine; every binary at its pin; no traust_core, no
build toolchain, no database anywhere in the tools image (1.19GB, down from
1.64GB for the equivalent single-stage build); an empty mounted
tool-dbs/grype makes grype exit non-zero with a clear error; the composed
runtime image imports the full traust stack and runs its CLI.

16 new tests (pin-set hash and inventory validation; feeds-cache wiring: no
cache configured leaves tools on native caches, FEEDS_CACHE_DIR env override,
locations.yaml resolution). Full suite: 2601 passed, 61 skipped; 14
pre-existing failures confirmed present on a clean development checkout too
(local GPG-signing configuration issue, unrelated to this change). Lint
clean.
@JGiola
JGiola force-pushed the feat/toolchain-image-split branch from f3b8764 to f64edbf Compare October 28, 2026 08:17
@openshift-ci openshift-ci Bot removed the lgtm Indicates that a PR is ready to be merged. label Oct 28, 2026
@openshift-ci

openshift-ci Bot commented Oct 28, 2026

Copy link
Copy Markdown

New changes are detected. LGTM label has been removed.

@openshift-ci openshift-ci Bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Oct 28, 2026
@smith-xyz
smith-xyz merged commit 7f17f86 into development Oct 28, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants