deploy: compare portable image config digests

This commit is contained in:
chenpei 2026-08-12 22:04:53 +08:00
parent be3b5f9cee
commit 440eee42e1
4 changed files with 57 additions and 11 deletions

View File

@ -60,14 +60,12 @@ DOCKER_BUILDKIT=1 docker build \
-t "youyidian-3d-migrator:$TARGET_SHA" .
docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' \
"youyidian-3d-migrator:$TARGET_SHA"
MIGRATOR_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "youyidian-3d-migrator:$TARGET_SHA")"
MIGRATOR_BAKED_SHA="$(docker run --rm --network none --read-only \
--entrypoint cat "youyidian-3d-migrator:$TARGET_SHA" /app/DEPLOYMENT_COMMIT)"
test "$MIGRATOR_BAKED_SHA" = "$TARGET_SHA"
printf '%s\n' "$MIGRATOR_IMAGE_ID"
```
revision label 和 baked SHA 必须精确等于目标 SHA。`MIGRATOR_IMAGE_ID` 是独立于 runner 的值,二者不得混用。
revision label 和 baked SHA 必须精确等于目标 SHA。构建机和生产机可能运行不同版本的 Docker,`docker image inspect .Id` 对带 provenance 的镜像可能分别显示 manifest-list digest 与 config digest,因此不能直接把构建机 `.Id` 当成跨主机身份凭证。下一步必须从 `docker save` 的可信归档中提取该镜像唯一的 config digest,再与生产装载后的 `.Id` 比较。
### 1.3 通过已固定主机指纹的 SSH 传输 migrator
@ -81,6 +79,17 @@ trap 'rm -f -- "$MIGRATOR_ARCHIVE"' EXIT
docker save --output "$MIGRATOR_ARCHIVE" "$MIGRATOR_IMAGE"
MIGRATOR_ARCHIVE_SHA256="$(sha256sum "$MIGRATOR_ARCHIVE" | awk '{print $1}')"
test -n "$MIGRATOR_ARCHIVE_SHA256"
MIGRATOR_IMAGE_ID="$(tar -xOf "$MIGRATOR_ARCHIVE" manifest.json | node -e '
const fs = require("node:fs")
const image = process.argv[1]
const entries = JSON.parse(fs.readFileSync(0, "utf8"))
const matches = entries.filter((entry) => Array.isArray(entry.RepoTags) && entry.RepoTags.includes(image))
if (matches.length !== 1 || typeof matches[0].Config !== "string") process.exit(2)
const configMatch = /^blobs\/sha256\/([0-9a-f]{64})$/.exec(matches[0].Config)
if (!configMatch) process.exit(3)
process.stdout.write(`sha256:${configMatch[1]}`)
' "$MIGRATOR_IMAGE")"
test -n "$MIGRATOR_IMAGE_ID"
SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile=/root/.ssh/gongxiangp2-production-known_hosts)

View File

@ -439,19 +439,24 @@ describe('production video transcode drain gate', () => {
expect(script).not.toContain('git remote set-url origin "$REPO_URL"')
})
test('生产按不可变 manifest digest 拉取并二次核对构建机 image ID', () => {
const captureId = script.indexOf('EXPECTED_IMAGE_ID="$(docker image inspect')
const captureDigest = script.indexOf('EXPECTED_MANIFEST_DIGEST="$(curl -fsSI', captureId)
const immutableReference = script.indexOf('REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}@${EXPECTED_MANIFEST_DIGEST}"', captureDigest)
test('生产按不可变 manifest 拉取并用平台 config digest 跨 Docker 版本核对镜像', () => {
const captureDigest = script.indexOf('EXPECTED_MANIFEST_DIGEST="$(curl -fsSI')
const capturePlatform = script.indexOf('EXPECTED_PLATFORM_MANIFEST_DIGEST="$(printf', captureDigest)
const captureId = script.indexOf('EXPECTED_IMAGE_ID="$(printf', capturePlatform)
const immutableReference = script.indexOf('REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}@${EXPECTED_MANIFEST_DIGEST}"', captureId)
const remoteCompare = script.indexOf('ACTUAL_PROD_IMAGE_ID="$(prod_ssh', immutableReference)
const payloadCompare = script.indexOf('ACTUAL_IMAGE_ID="$(docker image inspect', remoteCompare)
const createCandidate = script.indexOf('create_container "$NEXT_CONTAINER" "$REGISTRY_COMMIT"', payloadCompare)
expect(captureId).toBeGreaterThan(0)
expect(captureDigest).toBeGreaterThan(captureId)
expect(captureDigest).toBeGreaterThan(0)
expect(capturePlatform).toBeGreaterThan(captureDigest)
expect(captureId).toBeGreaterThan(capturePlatform)
expect(script).toContain('application/vnd.oci.image.index.v1+json')
expect(script).toContain('tolower($1) == "docker-content-digest:" { print $2 }')
expect(script).not.toContain("awk -F ': *' 'tolower($1) == \"docker-content-digest\"")
expect(script).toContain('entry.platform.os === "linux"')
expect(script).toContain('entry.platform.architecture === "amd64"')
expect(script).toContain('document.config && document.config.digest')
expect(immutableReference).toBeGreaterThan(captureDigest)
expect(remoteCompare).toBeGreaterThan(immutableReference)
expect(payloadCompare).toBeGreaterThan(remoteCompare)

View File

@ -912,8 +912,6 @@ if [ "$SKIP_PUSH" != "1" ]; then
start_remote_step "Push Image"
docker push "$LOCAL_REGISTRY_COMMIT"
docker push "$LOCAL_REGISTRY_MOVING"
EXPECTED_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$REGISTRY_TAG" 2>/dev/null || true)"
[[ "$EXPECTED_IMAGE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Built image ID is not one exact sha256 digest"
EXPECTED_MANIFEST_DIGEST="$(curl -fsSI \
-H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
"http://localhost:${REGISTRY_PORT}/v2/${IMAGE_NAME}/manifests/${COMMIT_TAG}" \
@ -921,6 +919,37 @@ if [ "$SKIP_PUSH" != "1" ]; then
| awk 'tolower($1) == "docker-content-digest:" { print $2 }' \
| tail -n 1)"
[[ "$EXPECTED_MANIFEST_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Registry did not return one immutable manifest digest for the exact commit tag"
REGISTRY_MANIFEST_JSON="$(curl -fsS \
-H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
"http://localhost:${REGISTRY_PORT}/v2/${IMAGE_NAME}/manifests/${COMMIT_TAG}")"
EXPECTED_PLATFORM_MANIFEST_DIGEST="$(printf '%s' "$REGISTRY_MANIFEST_JSON" | node -e '
const document = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
if (!Array.isArray(document.manifests)) {
if (!document.config || typeof document.config.digest !== "string") process.exit(2)
process.stdout.write("self")
process.exit(0)
}
const matches = document.manifests.filter((entry) =>
entry && entry.platform && entry.platform.os === "linux" && entry.platform.architecture === "amd64"
)
if (matches.length !== 1 || typeof matches[0].digest !== "string") process.exit(3)
process.stdout.write(matches[0].digest)
')"
if [ "$EXPECTED_PLATFORM_MANIFEST_DIGEST" = "self" ]; then
REGISTRY_PLATFORM_MANIFEST_JSON="$REGISTRY_MANIFEST_JSON"
else
[[ "$EXPECTED_PLATFORM_MANIFEST_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Registry index did not contain exactly one linux/amd64 manifest"
REGISTRY_PLATFORM_MANIFEST_JSON="$(curl -fsS \
-H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \
"http://localhost:${REGISTRY_PORT}/v2/${IMAGE_NAME}/manifests/${EXPECTED_PLATFORM_MANIFEST_DIGEST}")"
fi
EXPECTED_IMAGE_ID="$(printf '%s' "$REGISTRY_PLATFORM_MANIFEST_JSON" | node -e '
const document = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
const digest = document && document.config && document.config.digest
if (typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest)) process.exit(2)
process.stdout.write(digest)
')"
[[ "$EXPECTED_IMAGE_ID" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "Registry platform manifest did not contain one exact config digest"
REGISTRY_COMMIT="${BUILD_SERVER_IP}:${REGISTRY_PORT}/${IMAGE_NAME}@${EXPECTED_MANIFEST_DIGEST}"
ok "Pushed image tags: $COMMIT_TAG, $MOVING_TAG"
ok "Pinned production image: $REGISTRY_COMMIT ($EXPECTED_IMAGE_ID)"

View File

@ -440,6 +440,9 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(migrator, /command -v pg_restore >\/dev\/null/)
assert.match(migrator, /command -v psql >\/dev\/null/)
assert.match(migrator, /COPY --from=builder \/app\/scripts\/create-verified-production-backup\.mjs/)
assert.match(sop, /tar -xOf "\$MIGRATOR_ARCHIVE" manifest\.json/)
assert.match(sop, /entry\.RepoTags\.includes\(image\)/)
assert.match(sop, /\^blobs\\\/sha256\\\/\(\[0-9a-f\]\{64\}\)\$/)
const scriptsExcludeIndex = dockerIgnore.lastIndexOf('scripts/**')
assert.ok(scriptsExcludeIndex >= 0)
assert.ok(dockerIgnore.lastIndexOf('!scripts/safe-production-migrate.mjs') > scriptsExcludeIndex)