diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index 2eff074d..772a942b 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -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) diff --git a/lib/__tests__/video-transcode-deploy-drain.test.ts b/lib/__tests__/video-transcode-deploy-drain.test.ts index cd595f9f..37836e22 100644 --- a/lib/__tests__/video-transcode-deploy-drain.test.ts +++ b/lib/__tests__/video-transcode-deploy-drain.test.ts @@ -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) diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 8db6a736..fcd5a2d0 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -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)" diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 94f136a4..4c2aa7ea 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -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)