feat(guest-image): pin the agent OCI image the guest rootfs derives from (RIG-3786) - #1275
Closed
rigel-mintaka wants to merge 5 commits into
Closed
rigel-mintaka wants to merge 5 commits into
rigel-mintaka wants to merge 5 commits into
Conversation
…rom (RIG-3786) The microVM guest rootfs is about to be derived from the PUBLISHED compass-agent image rather than re-evaluated from the agent's nix expressions. That makes the pin the whole provenance story, so this adds it first, on its own. guest-image/agent-oci.lock records the repo, an immutable per-commit tag, the manifest digest they resolve to, and every layer descriptor digest the rootfs's fixed-output fetches will key on. tools/guest-image/pin-core.ts enforces provenance rather than documenting it: the only accepted repo is ghcr.io/rigelbuild/compass-agent, the only accepted tag shape is the publish lane's immutable git-<sha12>, and a multi-platform index is refused outright because it would need a platform choice the lock cannot record. Layer order is preserved deliberately — the layers stack into the filesystem, so a reordered set fetches identical bytes and unpacks a different rootfs. The Renovate lockstep is the non-obvious half. A bare regex manager is NOT sufficient here, unlike the digest-only postgres pin where the rewrite completes the update: the lock carries fetch-key fields Renovate cannot compute, so a digest-only bump would leave the layer descriptors describing the previous manifest and redden every fixed-output fetch on every Renovate PR. That is the same stale-paired-field class refresh-devenv-lock.ts names for a rev-only rewrite, so this takes the same remedy: a customManager PLUS a branch-mode postUpgradeTask relock, solo-grouped so it owns the branch's single task slot, with the paired bot-config allowlist entry. Detection tracks the moving :latest digest, because the pinned tag is per-commit immutable and no datasource can order it. The relock resolves :latest back to whichever immutable tag shares its manifest, which is sound because the publish lane asserts the pair shares a config digest and fails closed. Verified live against the registry: git-ec4954bd9400 matches :latest on both manifest and config digest. Tag discovery walks the tag list newest-first. The registry lists tags oldest-first and the wanted tag is almost always newest, so in-order discovery cost a round trip per historical tag — measured at 121s, now 3s — and compares digests without fetching the ~120-layer manifest body. The project is registered in .moon/workspace.yml AND defines a `ci` aggregate: ci-matrix emits a target only for a project that defines one, so without it the guard suite would be registered and still silently inert. Co-authored-by: Matt Wilkinson <matt@rigel.build>
Review findings on the agent-image pin, plus one the review prompted me to measure. The digest no longer comes from a second registry read. `inspect` made two skopeo calls against a MUTABLE tag — one for the manifest body, one for the digest — so a publish landing between them would pair one manifest's layers with another's digest, and the lock would describe layers its own digest disowns. A manifest digest is by definition the sha256 of the manifest bytes, verified here against the registry's own reported digest on the live image, so one fetch hashed locally gives both and the digest provably describes the body that was pinned. The Renovate rule no longer inherits the repo-wide 5-day soak. This image is built by nix2container, which zeroes timestamps for reproducibility, so the registry reports Created: 0001-01-01T00:00:00Z and `internalChecksFilter: "strict"` over an unknown age would hold the digest permanently pending — zero PRs, the stale-pin-forever outcome the rule exists to prevent. Nulled with the git-refs siblings' rationale, NOT the postgres pin's, which keeps its soak precisely because Docker Hub carries a real timestamp. Two guards added: the cooldown is nulled, and the dep resolves enabled rather than being swallowed. Tag discovery filters candidates by the same predicate the lock validator enforces, so a malformed git-* tag sharing the digest is skipped rather than selected and then hard-failing the relock. The render test asserted renderLock against itself, which passes for any deterministic implementation; I tried three mutations before finding that canonical key order is structural in renderLock and therefore unfalsifiable. Replaced with the byte-exact output, which does fail when the indentation changes. Co-authored-by: Matt Wilkinson <matt@rigel.build>
…image The guest userland was a second nix evaluation of the agent image's expressions under a different nixpkgs pin, so the microVM guest and the published container could drift silently. The rootfs now unpacks the published image itself: per-layer fixed-output fetches keyed by the locked descriptor digests, applied in manifest order with each layer's whiteouts resolved against the lower layers before its own content lands. A boot layer is overlaid on top and wins every conflict: guestd as /sbin/init, modprobe, /lib/modules from the root-pinned kernel, and a writable /etc/resolv.conf. Two build-time gates keep it honest. The eval re-checks the lock's shape, and the userland contract is verified on the assembled tree by resolving every path component inside it -- the way the kernel will after switch_root -- so a missing /bin/sh breaks the build instead of shipping a guest that cannot arm egress. Refs RIG-3787 Co-authored-by: Matt Wilkinson <matt@rigel.build>
…unpacking Review findings on the OCI-derived rootfs, each with a reproduced counterexample: - The lock's manifest digest constrained nothing: only the layer descriptors decided what was unpacked, so a lock pairing that digest with another image's valid layers built clean. The manifest is now fetched fixed-output against the digest (a manifest digest is the sha256 of its body) and its own ordered layer list must equal the lock's. - A lower layer's absolute symlink used as a parent path let tar write, and the whiteout `rm -rf` delete, outside the staged root. Such parents are dropped before extraction and a `..` member now breaks the build. - The userland contract passed on a non-executable or a directory; it now requires an executable regular file. - setuid/setgid files are asserted absent rather than assumed. The packed image is byte-identical: the published layers trip none of these. Refs RIG-3787
…arkers Correctness-lens findings on the OCI-derived rootfs, each reproduced first: - Extraction used --no-same-permissions, so every member took the builder's umask instead of its published mode: /bin packed 0755 where the image ships 0555. Extract with -p and re-open staging write access on directories only, then restore the directories' published modes before packing. Directory members are selected by tar's type flag, since these layers list directories with no trailing slash. - The whiteout scan matched the marker prefix anywhere in a path, so an ordinary directory whose name contained it became a delete order. Reproduced deleting a lower-layer file outright. The match is now anchored to a marker-prefixed final component. - An opaque marker naming a missing directory was swallowed by a redirect to /dev/null plus a true fallback; it now breaks the build, since it means the layer stack is not what this build thinks it is. - The image root's mode was a chmod side effect (0555 inherited from the boot layer's store path until a later pass happened to widen it); it is now set outright. - The store-path loop word-split an unquoted command substitution under nullglob, so a path containing a glob metacharacter was silently dropped, yielding an incomplete rootfs. It now reads line-wise. - The contract resolver counted path components, not symlink hops, so a deep symlink-free path could be reported as a loop. - The lock guard called builtins.match on a non-string tag, replacing the curated error with a raw nix type error. Not fixed, measured: mkfs.erofs drops the sticky bit on /tmp whatever the staged mode, so /tmp packs 0777 rather than 1777. The rootfs boots read-only, so the lower layer's mode is not what makes /tmp writable. Refs RIG-3787
|
Merging to
After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here |
Contributor
Author
|
Compass engineering docs preview: https://compass-managed-rig-3787-der.compass-eng-docs.pages.dev Deployed from |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR is part of a stack containing 2 PRs:
mainCorrectness-lens findings on the OCI-derived rootfs, each reproduced first:
umask instead of its published mode: /bin packed 0755 where the image ships
0555. Extract with -p and re-open staging write access on directories only,
then restore the directories' published modes before packing. Directory
members are selected by tar's type flag, since these layers list directories
with no trailing slash.
ordinary directory whose name contained it became a delete order.
Reproduced deleting a lower-layer file outright. The match is now anchored
to a marker-prefixed final component.
/dev/null plus a true fallback; it now breaks the build, since it means the
layer stack is not what this build thinks it is.
layer's store path until a later pass happened to widen it); it is now set
outright.
nullglob, so a path containing a glob metacharacter was silently dropped,
yielding an incomplete rootfs. It now reads line-wise.
symlink-free path could be reported as a loop.
curated error with a raw nix type error.
Not fixed, measured: mkfs.erofs drops the sticky bit on /tmp whatever the
staged mode, so /tmp packs 0777 rather than 1777. The rootfs boots read-only,
so the lower layer's mode is not what makes /tmp writable.
Refs RIG-3787