diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 7d413909..fa1c63b5 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -56,6 +56,11 @@ projects: # the CI sweep — that suite is what catches a build-arg or closure-root drift, # since no compass CI step can build the image itself today. runner-image: 'tools/runner-image' + # The agent-image pin the microVM guest rootfs derives from. Registered so + # its typecheck + pure-core unit tests ride the CI sweep — that suite is what + # catches a provenance or layer-order drift, and moon discovers projects only + # from this map, so unregistered the whole guard suite is silently inert. + guest-image: 'tools/guest-image' # The Compass native-app release bundle: a heavy nix build (realises the # WebKitGTK cc/pkg-config closure) that stages the versioned tarball. Same # affected-detection posture as compass-agent-image — registered here so the diff --git a/bun.lock b/bun.lock index 786a6194..fcea345e 100644 --- a/bun.lock +++ b/bun.lock @@ -169,6 +169,16 @@ "typescript": "catalog:", }, }, + "tools/guest-image": { + "name": "@compass/guest-image", + "bin": { + "guest-image-pin-agent": "./pin-agent-image.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/inline-sql-gate": { "name": "@compass/inline-sql-gate", "bin": { @@ -487,6 +497,8 @@ "@compass/forge-linear-token": ["@compass/forge-linear-token@workspace:tools/forge-linear-token"], + "@compass/guest-image": ["@compass/guest-image@workspace:tools/guest-image"], + "@compass/inline-sql-gate": ["@compass/inline-sql-gate@workspace:tools/inline-sql-gate"], "@compass/macos-bundle": ["@compass/macos-bundle@workspace:tools/macos-bundle"], diff --git a/guest-image/agent-oci.lock b/guest-image/agent-oci.lock new file mode 100644 index 00000000..bb7afe76 --- /dev/null +++ b/guest-image/agent-oci.lock @@ -0,0 +1,127 @@ +{ + "repo": "ghcr.io/rigelbuild/compass-agent", + "tag": "git-7d22c69390cb", + "digest": "sha256:634aeee15954f43b9a009b2dd9fe2ad3b454f64dd09c9491957c03d0313a6823", + "layers": [ + "sha256:989a42bd3ddf0028fce105bf936074ae1cad375bbabf868ba358161d6eddea56", + "sha256:50173e59368b86248a8fc447563bc692eb54cea61522d423c562037b27273b1f", + "sha256:609f4904737a247c83c6f5e57210a99c625ee6fef0e1a2bab7c1368f5cb7da95", + "sha256:9b1563075c8b42c7e91675d2c44177dfdfd47087996152d063e04b2c838c8f57", + "sha256:a27e5e982aec83990632c6891f73050659a47e9425d2f8ff4ba27e3a60fd3a05", + "sha256:83e4a642b15fa357dd60c58e34dce2dbe8bca77866e602c3ddb457fb86ed3791", + "sha256:a28acb06d258001260b8879bc49bad34a40235752ec067ebafff194c015bab78", + "sha256:1eefdcdcb94a4c13c0bb56dcb22a00c4b07ebf09b2a13441d6feb4f774765592", + "sha256:2b835b65e2621850178daf39a8b51501a77e0141ab1997fa49959855de597200", + "sha256:b4939a5f31ab0f55db6a137ece82f2d1dd837fd9a75a335535a3cdf34ea4ed0c", + "sha256:88337f8b7d5b5415714dc5db31f95397e6c0d69797e977ae630a45c6fd7b1f70", + "sha256:ce855785a761c203e0b4992059938de647b375b3c99662ec9a7b9446a39b46b7", + "sha256:a65a251cbf515329613b066e7137ff1bb7e76605afff8b63f5a1eece0fad4814", + "sha256:5087d0bd2d08b75bccdb9d9e0cdf3fe643d36a420750efb68b9ccb7d7cbfaf42", + "sha256:97290c6058d9cdb192d4a844731078d54b76e17e01926866c8b002ca0a22cf81", + "sha256:12d9b9a35a002c04b40dfa8ce19f1930844c756132db2a0acc9114e6d3c77be2", + "sha256:c904b30222e4fb1b1dfd2bef268e7acd862d30a027688ad705fe0ca824aa25b7", + "sha256:befa4be2046b290e0f318c2bae8d0c6e0b60fcbbec0e8b9c4e523e0fa2920fab", + "sha256:1a5561045f0d6f709a5af083cc9229d7c2a107e71d41b33c1a8360fea3e88266", + "sha256:20d466aba19a30ed1f9baf7721d7f3dec6343e9c2e56cabe71d080a01158c059", + "sha256:fb54e13d029c33ad1ee18e64b535951a2a38fc7fbdf54c7d1cf9a4079e2ba5cf", + "sha256:5cb7cb4604ee7d5784b1eec9595ba61d4710ebe5144d88d564f2d866fc36f63d", + "sha256:dfc15617d544c97e54dd90b3aa4ae226ea154ea20b19225bb0c7a9ae813f2f81", + "sha256:06d8edbc5d4b45454ed65016ebd473df9a73d731a1a318c61fb339690b2b2a0a", + "sha256:0198a70f4b3c743a14950e34e5faad8a10e05279da9d1ee23d9c61784189c911", + "sha256:85b200ba6a9563b01210fb6db146d687216b4f1e4ead539686522d9b745c9da4", + "sha256:e8e32b46884a076249c1ba0d8f8009f044908801b07e428e83e369ca52281bb2", + "sha256:8e8004a82bd80ac730a45ff11144afd39237e36e622ae1eae305e421a4ed9023", + "sha256:a2784d4ed644739b1e62165bfe0dc7691fabcfe85e953256efa54eb227c70575", + "sha256:9a110d87dee34a853f538f53f50df90c23f74bb53db2c53d3a2107155004b0cd", + "sha256:13de35f6e89265878c1ae16ab4229918fa813ec44be7157d2ada66599bf6be5f", + "sha256:f3a08b5ecac5276869ebd366319e108122fc87163c05bc9675d0f028f790467f", + "sha256:df281230c7a1c6db680798e6f966ced09429944ff0c5712eaed9232d11fab644", + "sha256:4fada2aae32b9938dc63e1b2ed0598a3975324ed6736c707e52ceecaeebdf5e1", + "sha256:4244ec3726b6b1a71d3c4cd9f0302a1a152a24b3f028b0080e35496ee1ab1dab", + "sha256:4eac009357ae2dd5ceba989358f0fa6ee78b5f183cd67f056a99dd02345e870b", + "sha256:a829f97edb5088eff29dfebae5d3660958e7d975885fecc51ce70ead4833a66c", + "sha256:6c7632a46e4ff00f8169a235bdbcf13ba72c8cedceea9dd668554e5603818ecd", + "sha256:cb66e85841af9451aa00ab4fe86b4cd882e72a5773c077f37925306bcfa1d246", + "sha256:4ca9a76c303917d8c4ebde29863514bd6d76e24d787f5f0e2e9adf9036912683", + "sha256:00f060b0330fc649c90ff8aed0d03759f4cdee31b2be667c09ce2160a6458636", + "sha256:bdfff65a8c7c8621704da3d42c65121a6ea8523e258aa59d015b23beb0420502", + "sha256:0d5cfe1be61877a897aea120f0928e295bfc0a88958ffc641faa19f9983302e4", + "sha256:728eac9836a08e8de1694f02fc8c29eac6170b3ef9286ee64eaecc4cfa53df8e", + "sha256:5d5bbab83a69df879f9af7f9560249903b5e4cd3ad008e35b7961abce039cbdc", + "sha256:246ed1b833889333ed005b5c3a72dd39235d1ad8211d6c70f86b86ebb407ef85", + "sha256:775d60e8d947f0b14ca30badc625df4b7d80bf87ba5e1b172d764052697a10fb", + "sha256:c964d986e2041dc5918b96df2d84106893a0099661f0e677634a8984fe965259", + "sha256:12c3a013e98cc6b4336bcf4c050a4b74e1deca42fcc2bcc9a6166977a357e475", + "sha256:e2926a71d238fac16fb251b1f1a316399b4aa05bf606b6be7ef307c50833a0d8", + "sha256:74ead26ad73b3ccdf0366060c36bed040a3bc72e78a6676619ff5f92abbc6999", + "sha256:a754daf94f43269c51efba261e2a1f2a2fe334c394537dbac00dc3f8d8df4a59", + "sha256:2baf24046fe64dd59b1daa3412ad8d0311a1d49f6f658e876e129a462a968a05", + "sha256:0e213195a5c4a56ba80f8fbcfcfab538763f98911e7e8c72bd6d671362fb743f", + "sha256:f41f723d758771feea97eb49b70accb3f71eb584909635ef32332b7093e664b5", + "sha256:ab2beaf90a91a92a339fc96abb64fdb39ed513cd19fcfb492e4cd546bb25acf2", + "sha256:f0c6726cd51279b847ff36fa7ff0d687a7959900e7544171df2bb2e8e3caf2d0", + "sha256:c40dd490d44cdc6440cb2683092145444b8f42886d07b714c75c1c2aaf103e50", + "sha256:b516c2ee390cbc32fd7310cc7af36eb246125c694444742c73e520cecae0e4df", + "sha256:5b1711b70bea797e1ce3412fbcf6a08db518a7c6824e76979b21068a97bd2cf9", + "sha256:9f7536248f41ecdf13dacde207e17123c60da3a94bd69a6ef8f7bd4d139eb960", + "sha256:5bc8acf2dea1d01db0898633dfd1bde185ceb274b7585b948d0dd324e2f0e3f5", + "sha256:66bece8745d11e9544e544fcb28a11392715247cb7e20c3be437ec570c59ba44", + "sha256:e31da11550fa2b023bdc7b75deaef05a255cd60de8ab2ab3b6bfccb7c27ef592", + "sha256:25ed9f7c6766d6942a72491c4df4f5e6ece1800dfee40763900a0fa43a2be5b5", + "sha256:9d19949397a22dc46c92e39023bd819dbd9c97998f7ec1540bb4e7cf780962be", + "sha256:06220d2ab5c467b16cfd243be799ea404c8f27aa9915bdf7d519d11d92e50fdb", + "sha256:ff4a05359598d8e219aa9bcb50e84c0155c30befb4a60eb470ab0f2867f7c7f0", + "sha256:49829c965f4ae698d07769fdcebaa3c6c99f87c99df9c03e263ba3aed7d644df", + "sha256:f5c5ed6c6f2df8e23f17f0db989fb12516eb7626e08ac1e5a7cb12d58b6b3d45", + "sha256:ac4c11458e54c5a34854ba6d54ffc262d9c6450d261b16069302d9367cde5698", + "sha256:8f9d57a9adef65c895542b90d3235b272e6349d574e4644e07b1b0a7519e2b5e", + "sha256:6e2f7a0e9efa1a18ea3829f0393c89bc9761cc6ae96a6aa9107d087f2c621c74", + "sha256:6aaf42086be878b2d8547a0565c022249cc1d05fe9402982f77eb51c53697b61", + "sha256:65294ae739b115754167fa6e5e737721a1ae59a8f28a3750642c24dbfaedc162", + "sha256:0b60a919cb943e44ec9b862389f2c86b8d86eb4ad3b855d5346bf11df1c34550", + "sha256:715f8f27508def1aae7ab159b1ea75b5924364c5f21b5d494eaddc2fdb79fc7f", + "sha256:b65b930bbd24ab4b07c3fbdc7c57c209c1b9905fe628c83cbc933e0c30577445", + "sha256:97ad1a929ca4155ee00fdc2d4f6d2b57278ae06e0c395230ec7113d821b3527d", + "sha256:bc8a89708d25b218cd31000d8eac61f324d49562f2c330197f1af68fc9bc652c", + "sha256:46cab03582f2a2720809cbbe22b8d9b445cb22d19de646d77fa0446ded5b5802", + "sha256:99b2de06e987ead1a31fd34a7f62653e0b8048659419c705ffe4c9e9657d419d", + "sha256:ef475c65af727c6a13570a37f1ef362c242f43a5f1a90a8a55f3e2f7bbce2d70", + "sha256:3ef92c85b3f0bec77b09c39249546b7f91fc7c371918ab6ab9948e59bd917fa6", + "sha256:ca2641e89ea1c87034ed4a274c3dc991d33f9447bb68b6f22e4b7d4edcf068c2", + "sha256:96e4db8add2228b01419c910d8ebc2ba01e2f242a610d74f7bbf677eafb53a40", + "sha256:7e69e346d08c4457b0e63f09960dfe24f752c960a864c092d48763b62c49e564", + "sha256:6e35e26aa14caa1f1815b6371660534eae37889992c0132c195eb525231f27c2", + "sha256:7306b70d6767a21686f9fed6e16919ba392093fbd58368be9a02b39f62cd3461", + "sha256:65f85994228e6dde888ff7fa7b6d96d92da9b98c9501186586fc7e53daeac2c3", + "sha256:1f952f741c864361946ac2407d122b6fb9b3f123129b148de514efedd3235d12", + "sha256:c958165a9542d4bf4393b9604b9eb6be3c7d9bba5804951826dcb096e3b67b95", + "sha256:ecac70470eabab90c8bed7011dacbe94f194d71fc9f54a9b34872bd0f98f6b88", + "sha256:a02defd4db5cc2b025f7a1c5ce7993a870176448e46c0a927f0af0713a3b6314", + "sha256:401e76c82da6cceb5ad589a2d2f0c4147edfac4f8201beddd2aa6bb50e7a529b", + "sha256:754284604d7120599f55426b596d3059a5f165d0a0f6945ff0c8a0c7dbe78d7d", + "sha256:aaae4d6fac166dd8391dd6658edd2a3f301221b39e71326e5ea5f1d6adf1b064", + "sha256:5a9b6b2a22916fcb634f882a6db07eac6d3adc76091acd1b0bc4ebac2d3f8a93", + "sha256:0f7b3d9b68a4182a7a1a69210f185b79602d7c4af167a8e0ef3f8164cd38c31f", + "sha256:7de18bb60520597810e1a194bedfea095e6ef1e50e1ded8bae5932b5de912f28", + "sha256:fc47246e342a6eb0a98ba9de3c22935a3efb207145225c11618c80231c1bb0d4", + "sha256:ee0a9aa87179703abee4b1cb415e07824062f39c2b04ee4f60a76df6966f3d74", + "sha256:203eeea7b1eb8205b03425e85285b5da506690c259ab0c6fa04ca8e96324696b", + "sha256:6e73d91899a55c1f0675d38edd0c204464dc292e8a00c1098c0d35a21838562b", + "sha256:c0b09b3836b9186903c246773edc9f8f87a3a03b961b0caecbe9eb90d19959c1", + "sha256:f32d5c68675ebc9e082de3df1038c3ad1d89d201505e621a85ba3577c2b215af", + "sha256:c2beac798a2dbe5871128478b2a3f10f538c8b8261e5c38eaf31e66aaa5edfa9", + "sha256:52380eaa83367c693530b0a7090ae394d01921000edc44670afbabce927f6e7f", + "sha256:7a844f24a8e47aa904741a2df961abec6d4afacc165ebef763c4d29af2dc5df3", + "sha256:402112cc028033bdb988d0cd3ef05efdc03c69f00edfa283631154282f38a65a", + "sha256:1a27431e1302ba23b4bc81e831d1d8dc8b15b4865fab9597ee3e8820c75937e7", + "sha256:4f1af9cd0cd69eb34e1768141d28c910c60362948689abc3386cd89f7e0151a1", + "sha256:fe2778aea835fa21fa28f93259017b12a4374b776047abb8735200cfc94f56ff", + "sha256:d4cf521bbf13c6e071ac0175177694f462483fc293e6753680cae7e40535f603", + "sha256:d0b7a7f7092eaccf2be2d988d5ce399fc40c137ff7c51c55cdc81e6ac91add9e", + "sha256:1d8ff8656e5da54cde4f2fff6b262d6d99f54ea1ce119ad655aef28131c6e107", + "sha256:d7594f8803ea13a94c80a3345f2706b0cb69d07c428c77ba632230fcb4248e8b", + "sha256:ee18288abf12a859737dc7bacfef6506657ea5582e184745f1e433422df945eb", + "sha256:462cce1dc39d5d73bafee275866bf3de96ca4f25594fe84e13ba873eef95b953", + "sha256:7564bc82edf713cb15a3d5406a213a27cff25f83663e1954c3bcc35c35df011e" + ] +} diff --git a/tools/guest-image/biome.json b/tools/guest-image/biome.json new file mode 100644 index 00000000..ece11dd9 --- /dev/null +++ b/tools/guest-image/biome.json @@ -0,0 +1,4 @@ +{ + "extends": "//", + "linter": { "rules": { "suspicious": { "noConsole": "off" } } } +} diff --git a/tools/guest-image/moon.yml b/tools/guest-image/moon.yml new file mode 100644 index 00000000..609d0775 --- /dev/null +++ b/tools/guest-image/moon.yml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# guest-image: the agent-image pin the microVM guest rootfs derives from. +# TypeScript rather than a shell script (the no-bash-gate CI task). +# +# The pin CLI itself talks to a registry, so it is not a gate task. The +# typecheck/test tasks here ARE ordinary bun gates: they cover the pure core — +# the provenance rejections and the layer-descriptor extraction whose drift +# would silently make the rootfs unreproducible or stack a different +# filesystem. +# +# A bun/TypeScript CLI, hoisted root-workspace member (`bun` tag): install is +# inherited via .moon/tasks/tag-bun.yml and lint/format are whole-repo root +# tasks, so this leaf carries no own bun.lock. +layer: 'tool' +language: 'typescript' +tags: ['bun', 'ci-group.bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock'] + test: + # The pure core (pin-core.ts): every provenance rejection (foreign repo, + # moving tag, malformed digest), the manifest-shape refusals, and the + # layer order that determines the stacked filesystem. + inputs: ['*.ts', 'tsconfig.json', '/tsconfig.base.json', 'package.json', '/bun.lock'] + # The CI aggregate. Without it ci-matrix contributes NO target for this + # project (it emits `:ci` only for a project defining a `ci` task), so + # the guard suite above would never run on a PR — registered in + # .moon/workspace.yml and still silently inert. + ci: + deps: ['typecheck', 'test'] + options: + cache: false diff --git a/tools/guest-image/package.json b/tools/guest-image/package.json new file mode 100644 index 00000000..f959a577 --- /dev/null +++ b/tools/guest-image/package.json @@ -0,0 +1,14 @@ +{ + "name": "@compass/guest-image", + "private": true, + "type": "module", + "description": "Pin the published compass-agent image the microVM guest rootfs derives from: resolve an immutable git- tag to its manifest digest and layer descriptor set, and write guest-image/agent-oci.lock. The lock is the rootfs's whole provenance story, so the pin enforces it rather than documenting it.", + "module": "pin-agent-image.ts", + "bin": { + "guest-image-pin-agent": "./pin-agent-image.ts" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/guest-image/pin-agent-image.ts b/tools/guest-image/pin-agent-image.ts new file mode 100644 index 00000000..f5c79eb7 --- /dev/null +++ b/tools/guest-image/pin-agent-image.ts @@ -0,0 +1,228 @@ +// Pin the published compass-agent image the guest rootfs derives from +// (guest-image/agent-oci.lock). +// +// Two modes. `--tag git-` pins an explicit build. `--relock` is the +// Renovate postUpgradeTask: it re-derives the whole lock after the regex +// manager bumped part of it. +// +// WHY --relock EXISTS (do not "simplify" it to a bare regex bump). The lock +// carries the per-layer descriptor digests the nix fixed-output fetches key +// on, and Renovate cannot compute those: a regex manager can move the tag and +// manifest digest, but the layer set it leaves behind still describes the OLD +// manifest, so every fetch would fail on every Renovate PR. This is the same +// failure class tools/renovate/refresh-devenv-lock.ts names for devenv — a +// rev-only rewrite leaves paired fields stale — and the same remedy: a +// customManager PLUS a postUpgradeTask that makes the lock genuinely +// consistent. The digest-only DefaultPostgresImage pin is NOT the precedent; +// there a regex rewrite completes the update. +// +// HOW A NEW PIN IS DISCOVERED. The pinned tag is per-commit immutable +// (`git-`), so no datasource can order it and Renovate tracks the +// moving `:latest` digest instead. The relock then resolves which immutable +// tag `:latest` currently points at. That is sound because the publish lane +// asserts `:latest` and `:git-` share a config digest and fails closed +// otherwise (.github/workflows/release.yml, publish-image). +// +// Needs `skopeo` and network on PATH. Reads are anonymous: the package is +// public, so no registry credentials are required. + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + AGENT_REPO, + EXIT, + isBuildTag, + lockFromInspect, + locksEqual, + PinError, + type PinLock, + renderLock, + validatePin, +} from "./pin-core.ts"; + +const LOCK_PATH = join( + import.meta.dir, + "..", + "..", + "guest-image", + "agent-oci.lock", +); + +/** Where the moving tag lives. Only ever used to DISCOVER an immutable tag; + * never written into a lock. */ +const DISCOVERY_TAG = "latest"; + +type Inspected = { digest: string; manifest: unknown }; + +async function skopeo(args: string[]): Promise { + const proc = Bun.spawn(["skopeo", ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) { + throw new PinError( + `skopeo ${args.join(" ")} failed (exit ${code}): ${err.trim()}`, + EXIT.registryFailed, + ); + } + return out; +} + +/** The manifest body, plus the digest OF THAT BODY. + * + * One fetch, hashed locally, because a manifest digest is by definition the + * sha256 of the manifest bytes (verified against the registry's own reported + * digest). Asking the registry separately would be two reads of a MUTABLE tag, + * so a publish landing between them would pair one manifest's body with + * another's digest — and the lock would describe layers the digest disowns. */ +async function inspect(reference: string): Promise { + const raw = await skopeo(["inspect", "--raw", `docker://${reference}`]); + + let manifest: unknown; + try { + manifest = JSON.parse(raw); + } catch (err) { + throw new PinError( + `could not parse the manifest for ${reference}: ${String(err)}`, + EXIT.registryFailed, + ); + } + const digest = `sha256:${new Bun.CryptoHasher("sha256").update(raw).digest("hex")}`; + return { digest, manifest }; +} + +/** The digest a reference resolves to, without pulling the manifest body. + * Tag discovery compares only digests, and the agent manifest is ~120 layers, + * so fetching bodies would download a lot to read one field. */ +async function resolvedDigest(reference: string): Promise { + const meta = await skopeo([ + "inspect", + "--format", + "{{.Digest}}", + `docker://${reference}`, + ]); + const digest = meta.trim(); + if (!digest) { + throw new PinError( + `skopeo reported no digest for ${reference}`, + EXIT.registryFailed, + ); + } + return digest; +} + +/** Resolve which immutable build tag `:latest` currently points at, by + * matching manifest digests. Fails loud rather than falling back to pinning + * `:latest`, which would defeat the whole lock. + * + * Newest-first: the registry lists tags oldest-first, and the tag we want is + * almost always the newest, so walking the list in order costs a round trip + * per historical tag (measured: ~115 misses, two minutes) to find the one at + * the end. Reversing makes the common case one probe. */ +async function discoverBuildTag(): Promise { + const target = await resolvedDigest(`${AGENT_REPO}:${DISCOVERY_TAG}`); + const listed = await skopeo(["list-tags", `docker://${AGENT_REPO}`]); + + let tags: unknown; + try { + tags = (JSON.parse(listed) as Record).Tags; + } catch (err) { + throw new PinError( + `could not parse tag list: ${String(err)}`, + EXIT.registryFailed, + ); + } + if (!Array.isArray(tags)) { + throw new PinError("registry returned no tag list", EXIT.registryFailed); + } + + // Filter by the same predicate the lock validator enforces, not a looser + // `git-` prefix: a malformed `git-*` tag that matched the digest would be + // selected here and then hard-fail the whole relock downstream. + const candidates = tags.filter(isBuildTag).reverse(); + for (const tag of candidates) { + if ((await resolvedDigest(`${AGENT_REPO}:${tag}`)) === target) return tag; + } + throw new PinError( + `no git- tag resolves to the same manifest as :${DISCOVERY_TAG} (${target}) across ${candidates.length} candidate tag(s); the publish lane asserts the pair shares a digest, so this means an incoherent publish`, + EXIT.digestMismatch, + ); +} + +function readLock(): PinLock | undefined { + let body: string; + try { + body = readFileSync(LOCK_PATH, "utf8"); + } catch { + return undefined; + } + try { + return validatePin(JSON.parse(body)); + } catch (err) { + if (err instanceof PinError) throw err; + throw new PinError( + `${LOCK_PATH} is not valid JSON: ${String(err)}`, + EXIT.badLock, + ); + } +} + +async function pin(tag: string): Promise { + const { digest, manifest } = await inspect(`${AGENT_REPO}:${tag}`); + const next = lockFromInspect(AGENT_REPO, tag, digest, manifest); + + const current = readLock(); + if (current && locksEqual(current, next)) { + console.log(`agent-oci.lock already pins ${tag} (${digest}) — no change`); + return 0; + } + + writeFileSync(LOCK_PATH, renderLock(next)); + console.log( + `pinned ${AGENT_REPO}:${tag}\n digest: ${digest}\n layers: ${next.layers.length}`, + ); + return 0; +} + +function usage(): number { + console.error( + "usage: bun tools/guest-image/pin-agent-image.ts --tag git-\n" + + " bun tools/guest-image/pin-agent-image.ts --relock", + ); + return EXIT.usage; +} + +async function main(): Promise { + const argv = process.argv.slice(2); + + if (argv.includes("--relock")) { + if (argv.length !== 1) return usage(); + // Self-gate: a relock on a branch with no lock is a no-op, so the task + // stays cheap on every unrelated Renovate branch. + if (!readLock()) { + console.log("no agent-oci.lock on this branch — nothing to relock"); + return 0; + } + return await pin(await discoverBuildTag()); + } + + const i = argv.indexOf("--tag"); + const tag = i === -1 ? undefined : argv[i + 1]; + if (tag === undefined || argv.length !== 2) return usage(); + return await pin(tag); +} + +try { + process.exit(await main()); +} catch (err) { + if (err instanceof PinError) { + console.error(`pin-agent-image: ${err.message}`); + process.exit(err.code); + } + throw err; +} diff --git a/tools/guest-image/pin-core.test.ts b/tools/guest-image/pin-core.test.ts new file mode 100644 index 00000000..d1e133d5 --- /dev/null +++ b/tools/guest-image/pin-core.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_REPO, + digestRef, + EXIT, + isBuildTag, + isImageDigest, + layerDigests, + lockFromInspect, + locksEqual, + PinError, + type PinLock, + renderLock, + validatePin, +} from "./pin-core.ts"; + +const DIGEST = + "sha256:a2c90fd55c3e015c64a279973121d03c91736eed68d0cb0260cea9ff9b4ab94e"; +const LAYER_A = + "sha256:989a42bd3ddf0028fce105bf936074ae1cad375bbabf868ba358161d6eddea56"; +const LAYER_B = + "sha256:50173e59368b86248a8fc447563bc692eb54cea61522d423c562037b27273b1f"; +const TAG = "git-ec4954bd9400"; + +const lock = (over: Partial = {}): PinLock => ({ + repo: AGENT_REPO, + tag: TAG, + digest: DIGEST, + layers: [LAYER_A, LAYER_B], + ...over, +}); + +const manifest = (over: Record = {}) => ({ + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: { mediaType: "application/vnd.oci.image.config.v1+json" }, + layers: [ + { + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + digest: LAYER_A, + size: 898639, + }, + { + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + digest: LAYER_B, + size: 146238, + }, + ], + ...over, +}); + +/** The exit code a thrown PinError carries, so a test names the fault class + * rather than just asserting that something threw. */ +const codeOf = (fn: () => unknown): number => { + try { + fn(); + } catch (err) { + if (err instanceof PinError) return err.code; + throw err; + } + throw new Error("expected a PinError, nothing was thrown"); +}; + +describe("validatePin provenance", () => { + test("rejects a repo other than the agent image", () => { + // The rootfs inherits this image's whole userland, so another repo + // silently swaps the agent out. + expect( + codeOf(() => validatePin(lock({ repo: "ghcr.io/evil/agent" }))), + ).toBe(EXIT.provenance); + }); + + test("rejects a moving tag", () => { + expect(codeOf(() => validatePin(lock({ tag: "latest" })))).toBe( + EXIT.provenance, + ); + }); + + test("rejects a git tag of the wrong hex width", () => { + expect(codeOf(() => validatePin(lock({ tag: "git-abc" })))).toBe( + EXIT.provenance, + ); + }); + + test("rejects a non-hex git tag", () => { + expect(codeOf(() => validatePin(lock({ tag: "git-zzzzzzzzzzzz" })))).toBe( + EXIT.provenance, + ); + }); + + test("accepts the publish lane's tag shape", () => { + expect(validatePin(lock()).tag).toBe(TAG); + }); +}); + +describe("validatePin shape", () => { + test("rejects a malformed manifest digest", () => { + expect(codeOf(() => validatePin(lock({ digest: "sha256:abc" })))).toBe( + EXIT.badLock, + ); + }); + + test("rejects a bare hex digest without its algorithm", () => { + expect(codeOf(() => validatePin(lock({ digest: DIGEST.slice(7) })))).toBe( + EXIT.badLock, + ); + }); + + test("rejects an empty layer set, which would fetch nothing", () => { + expect(codeOf(() => validatePin(lock({ layers: [] })))).toBe(EXIT.badLock); + }); + + test("rejects a malformed layer digest", () => { + expect( + codeOf(() => validatePin(lock({ layers: [LAYER_A, "sha256:nope"] }))), + ).toBe(EXIT.badLock); + }); + + test("rejects a non-object lock", () => { + expect(codeOf(() => validatePin([lock()]))).toBe(EXIT.badLock); + expect(codeOf(() => validatePin(null))).toBe(EXIT.badLock); + }); + + test("returns the layers in order", () => { + expect(validatePin(lock()).layers).toEqual([LAYER_A, LAYER_B]); + }); +}); + +describe("lockFromInspect", () => { + test("builds a lock from the resolved digest and manifest", () => { + expect(lockFromInspect(AGENT_REPO, TAG, DIGEST, manifest())).toEqual( + lock(), + ); + }); + + test("refuses a foreign repo before touching the manifest", () => { + expect( + codeOf(() => + lockFromInspect("ghcr.io/other/img", TAG, DIGEST, manifest()), + ), + ).toBe(EXIT.provenance); + }); + + test("refuses a moving tag", () => { + expect( + codeOf(() => lockFromInspect(AGENT_REPO, "latest", DIGEST, manifest())), + ).toBe(EXIT.provenance); + }); + + test("treats a malformed registry digest as a registry fault", () => { + // Distinct from a bad lock: nothing on disk is wrong, the registry + // answered with something unusable. + expect( + codeOf(() => lockFromInspect(AGENT_REPO, TAG, "garbage", manifest())), + ).toBe(EXIT.registryFailed); + }); +}); + +describe("layerDigests", () => { + test("refuses a multi-platform index", () => { + // An index would need a platform choice the lock has nowhere to record. + expect( + codeOf(() => + layerDigests( + manifest({ mediaType: "application/vnd.oci.image.index.v1+json" }), + ), + ), + ).toBe(EXIT.registryFailed); + }); + + test("refuses a docker v2 manifest", () => { + expect( + codeOf(() => + layerDigests( + manifest({ + mediaType: "application/vnd.docker.distribution.manifest.v2+json", + }), + ), + ), + ).toBe(EXIT.registryFailed); + }); + + test("refuses an unexpected layer media type", () => { + expect( + codeOf(() => + layerDigests( + manifest({ + layers: [ + { + mediaType: "application/vnd.oci.image.layer.v1.tar+zstd", + digest: LAYER_A, + }, + ], + }), + ), + ), + ).toBe(EXIT.registryFailed); + }); + + test("refuses a manifest with no layers", () => { + expect(codeOf(() => layerDigests(manifest({ layers: [] })))).toBe( + EXIT.registryFailed, + ); + }); + + test("preserves manifest order, which determines the stacked filesystem", () => { + // Asserted in BOTH directions on purpose: LAYER_B sorts before LAYER_A, + // so an extractor that sorted would still satisfy the flipped case alone. + expect(layerDigests(manifest())).toEqual([LAYER_A, LAYER_B]); + + const flipped = manifest({ + layers: [ + { + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + digest: LAYER_B, + }, + { + mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", + digest: LAYER_A, + }, + ], + }); + expect(layerDigests(flipped)).toEqual([LAYER_B, LAYER_A]); + }); +}); + +describe("locksEqual", () => { + test("an unchanged lock compares equal, so a relock no-ops", () => { + expect(locksEqual(lock(), lock())).toBe(true); + }); + + test("a moved digest compares unequal", () => { + expect( + locksEqual(lock(), lock({ digest: `sha256:${"b".repeat(64)}` })), + ).toBe(false); + }); + + test("a reordered layer set compares unequal", () => { + // The bug the relock exists to catch: same bytes, different filesystem. + expect(locksEqual(lock(), lock({ layers: [LAYER_B, LAYER_A] }))).toBe( + false, + ); + }); + + test("a dropped layer compares unequal", () => { + expect(locksEqual(lock(), lock({ layers: [LAYER_A] }))).toBe(false); + }); +}); + +describe("rendering and refs", () => { + test("renders the exact committed bytes for a known lock", () => { + // Pinned against literal output, not against renderLock itself: the + // file is read by the nix eval and rewritten by the relock, so the + // indentation and key order ARE the contract. A self-comparison would + // pass for any deterministic implementation. + expect(renderLock(lock())).toBe( + `{\n\t"repo": "${AGENT_REPO}",\n\t"tag": "${TAG}",\n\t"digest": "${DIGEST}",\n\t"layers": [\n\t\t"${LAYER_A}",\n\t\t"${LAYER_B}"\n\t]\n}\n`, + ); + }); + + test("renders valid JSON that round-trips through validatePin", () => { + expect(validatePin(JSON.parse(renderLock(lock())))).toEqual(lock()); + }); + + test("ends with a newline", () => { + expect(renderLock(lock()).endsWith("}\n")).toBe(true); + }); + + test("pins by digest, never by tag", () => { + expect(digestRef(lock())).toBe(`${AGENT_REPO}@${DIGEST}`); + expect(digestRef(lock())).not.toContain(TAG); + }); +}); + +describe("digest and tag predicates", () => { + test("isImageDigest requires the algorithm and full width", () => { + expect(isImageDigest(DIGEST)).toBe(true); + expect(isImageDigest(DIGEST.slice(0, 20))).toBe(false); + expect(isImageDigest(DIGEST.toUpperCase())).toBe(false); + expect(isImageDigest(undefined)).toBe(false); + }); + + test("isBuildTag accepts only the immutable publish tag", () => { + expect(isBuildTag(TAG)).toBe(true); + expect(isBuildTag("latest")).toBe(false); + expect(isBuildTag("git-ec4954bd940")).toBe(false); + expect(isBuildTag(`${TAG}-dirty`)).toBe(false); + }); +}); diff --git a/tools/guest-image/pin-core.ts b/tools/guest-image/pin-core.ts new file mode 100644 index 00000000..27f0d936 --- /dev/null +++ b/tools/guest-image/pin-core.ts @@ -0,0 +1,240 @@ +// The pure core of the agent-image pin. Every function is a total map over its +// inputs with no I/O, so pin-core.test.ts can drive each mapping — and each +// fail-closed edge — without a registry or a subprocess. +// +// The guest rootfs derives from a PUBLISHED agent image rather than from the +// agent's nix expressions, so the lock is the whole provenance story: it names +// the one repo the rootfs may come from, an immutable per-commit tag, the +// manifest digest those resolve to, and each layer descriptor digest the +// fixed-output fetches key on. Provenance is enforced here rather than +// documented, because a lock that pointed somewhere else would silently make +// the rootfs unreproducible. + +/** Exit codes, numbered so a failed run names its own fault in CI. Distinct + * codes matter because the recoveries differ: 3 is a bad/hostile pin to + * correct, 4 is a registry/transport fault to retry, 5 means the registry no + * longer resolves the tag to the pinned digest (a re-pushed tag or a stale + * lock), 6 means the lock on disk is malformed. */ +export const EXIT = { + usage: 2, + provenance: 3, + registryFailed: 4, + digestMismatch: 5, + badLock: 6, +} as const; + +/** The ONLY repo a guest rootfs may derive from. The rootfs inherits this + * image's entire userland, so accepting another repo would swap the agent + * — and its nixpkgs closure — for something unreviewed. */ +export const AGENT_REPO = "ghcr.io/rigelbuild/compass-agent"; + +/** The publish lane's immutable per-commit tag (`git-` + 12 hex). `:latest` + * moves, so it is a DISCOVERY handle only and never what a lock pins. */ +const BUILD_TAG_PATTERN = /^git-[0-9a-f]{12}$/; + +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +/** The agent image is a single-platform OCI manifest whose layers are gzipped + * tarballs; the pin refuses anything else rather than guessing, because an + * index would need a platform choice the lock has nowhere to record. */ +const MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json"; +const LAYER_MEDIA_TYPE = "application/vnd.oci.image.layer.v1.tar+gzip"; + +export type PinLock = { + repo: string; + tag: string; + digest: string; + layers: string[]; +}; + +/** A pin fault that carries the exit code the CLI should die with, so the + * shell never has to re-derive which kind of failure it caught. */ +export class PinError extends Error { + readonly code: number; + + constructor(message: string, code: number) { + super(message); + this.name = "PinError"; + this.code = code; + } +} + +export function isImageDigest(value: unknown): value is string { + return typeof value === "string" && DIGEST_PATTERN.test(value); +} + +export function isBuildTag(value: unknown): value is string { + return typeof value === "string" && BUILD_TAG_PATTERN.test(value); +} + +/** The immutable reference the nix fetches resolve. Never `repo:tag`: a tag is + * a mutable pointer, and GHCR has no server-side tag immutability. */ +export function digestRef(lock: PinLock): string { + return `${lock.repo}@${lock.digest}`; +} + +/** + * Validate a lock parsed off disk, returning it narrowed. + * + * Throws rather than returning a partial lock: every caller (the nix eval's + * shape re-check, the relock's consistency gate) treats a malformed lock as + * fatal, and a lenient parse would let a half-valid lock reach a fetch. + */ +export function validatePin(value: unknown): PinLock { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new PinError("lock is not a JSON object", EXIT.badLock); + } + const lock = value as Record; + + if (lock.repo !== AGENT_REPO) { + throw new PinError( + `lock repo must be ${AGENT_REPO}, got ${JSON.stringify(lock.repo)}: the guest rootfs derives its whole userland from this image, so another repo would make it unreproducible`, + EXIT.provenance, + ); + } + if (!isBuildTag(lock.tag)) { + throw new PinError( + `lock tag must match git-<12 hex>, got ${JSON.stringify(lock.tag)}: only the publish lane's per-commit tag is immutable, so a moving tag cannot be pinned`, + EXIT.provenance, + ); + } + if (!isImageDigest(lock.digest)) { + throw new PinError( + `lock digest must be sha256:<64 hex>, got ${JSON.stringify(lock.digest)}`, + EXIT.badLock, + ); + } + if (!Array.isArray(lock.layers) || lock.layers.length === 0) { + throw new PinError( + "lock layers must be a non-empty array: they are the fixed-output fetch keys, so an empty set would fetch nothing", + EXIT.badLock, + ); + } + const layers = lock.layers.filter(isImageDigest); + if (layers.length !== lock.layers.length) { + const bad = lock.layers.find((l) => !isImageDigest(l)); + throw new PinError( + `lock layers must each be sha256:<64 hex>, got ${JSON.stringify(bad)}`, + EXIT.badLock, + ); + } + + return { + repo: AGENT_REPO, + tag: lock.tag, + digest: lock.digest, + layers, + }; +} + +/** + * Build a lock from a `skopeo inspect --raw` manifest plus the digest the + * registry resolved for `tag`. + * + * The manifest digest is an INPUT, not something derived from the manifest + * body: it is what the registry returned for the tag, and recomputing it here + * would only re-assert our own hashing rather than what the registry serves. + */ +export function lockFromInspect( + repo: string, + tag: string, + digest: string, + manifest: unknown, +): PinLock { + if (repo !== AGENT_REPO) { + throw new PinError( + `refusing to pin ${repo}: the guest rootfs may only derive from ${AGENT_REPO}`, + EXIT.provenance, + ); + } + if (!isBuildTag(tag)) { + throw new PinError( + `refusing to pin tag ${JSON.stringify(tag)}: expected the publish lane's immutable git-<12 hex> tag`, + EXIT.provenance, + ); + } + if (!isImageDigest(digest)) { + throw new PinError( + `registry returned a malformed digest for ${tag}: ${JSON.stringify(digest)}`, + EXIT.registryFailed, + ); + } + + return { + repo, + tag, + digest, + layers: layerDigests(manifest), + }; +} + +/** + * The layer descriptor digests, in manifest order. + * + * Order is load-bearing: the layers stack into the rootfs, so a reordered set + * would fetch the same bytes and unpack a different filesystem. + */ +export function layerDigests(manifest: unknown): string[] { + if (typeof manifest !== "object" || manifest === null) { + throw new PinError("manifest is not a JSON object", EXIT.registryFailed); + } + const m = manifest as Record; + + if (m.mediaType !== MANIFEST_MEDIA_TYPE) { + throw new PinError( + `expected a single-platform OCI manifest (${MANIFEST_MEDIA_TYPE}), got ${JSON.stringify(m.mediaType)}: an index would need a platform choice the lock cannot record`, + EXIT.registryFailed, + ); + } + if (!Array.isArray(m.layers) || m.layers.length === 0) { + throw new PinError("manifest carries no layers", EXIT.registryFailed); + } + + return m.layers.map((entry, i) => { + const layer: Record = + typeof entry === "object" && entry !== null + ? (entry as Record) + : {}; + if (layer.mediaType !== LAYER_MEDIA_TYPE) { + throw new PinError( + `layer ${i} has media type ${JSON.stringify(layer.mediaType)}, expected ${LAYER_MEDIA_TYPE}`, + EXIT.registryFailed, + ); + } + const digest = layer.digest; + if (!isImageDigest(digest)) { + throw new PinError( + `layer ${i} has a malformed digest: ${JSON.stringify(digest)}`, + EXIT.registryFailed, + ); + } + return digest; + }); +} + +/** Whether a relock would change anything. The relock self-gates on this so it + * is a cheap no-op on every unrelated Renovate branch. */ +export function locksEqual(a: PinLock, b: PinLock): boolean { + return ( + a.repo === b.repo && + a.tag === b.tag && + a.digest === b.digest && + a.layers.length === b.layers.length && + a.layers.every((l, i) => l === b.layers[i]) + ); +} + +/** Render a lock as the committed file body: stable key order and a trailing + * newline, so a relock that changed nothing produces a byte-identical file. */ +export function renderLock(lock: PinLock): string { + return `${JSON.stringify( + { + repo: lock.repo, + tag: lock.tag, + digest: lock.digest, + layers: lock.layers, + }, + null, + "\t", + )}\n`; +} diff --git a/tools/guest-image/tsconfig.json b/tools/guest-image/tsconfig.json new file mode 100644 index 00000000..d40cc9e5 --- /dev/null +++ b/tools/guest-image/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"], + "moduleDetection": "force", + "allowJs": true, + "allowImportingTsExtensions": true, + "noUncheckedIndexedAccess": true, + "types": ["bun"] + } +} diff --git a/tools/renovate/bot-config.json5 b/tools/renovate/bot-config.json5 index b35b0112..48a17b1a 100644 --- a/tools/renovate/bot-config.json5 +++ b/tools/renovate/bot-config.json5 @@ -82,7 +82,7 @@ // addon, so the workflow sets RENOVATE_X_IGNORE_RE2=true to take the RegExp path // quietly.) // - // Seven entries, all load-bearing: + // Eight entries, all load-bearing: // 1. the toolchain-hash refresh, which re-prefetches the vendored-binary // sha256 pins a tools/toolchain/versions/*.nix bump invalidates; // 2. the catalog lockfile regeneration. Renovate's custom.regex manager exports @@ -130,9 +130,15 @@ // channel script's tail (biome eval, catalog pin, bun.lock, flake // lockstep) has no counterpart in this scope: agent-image bakes no dev // shell (`packages = [ ]`) and has no flake. - // (1), (3), (4), (5), (6), and (7) are `bun