diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index ea8f00a4..aba93b17 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -873,6 +873,10 @@ jobs: artifact_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.artifactType)' "${PLAN}")" file_media_type="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.catalog.fileMediaType)' "${PLAN}")" file_name="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.releaseSet.fileName)' "${PLAN}")" + plan_digest="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.planDigest)' "${PLAN}")" + local_layout="${RUNNER_TEMP}/release-catalog-layout" + local_tag="ocidir://${local_layout}:candidate" + mkdir "${local_layout}" "${REGCTL}" artifact put \ --artifact-type "${artifact_type}" \ --file-media-type "${file_media_type}" \ @@ -883,20 +887,66 @@ jobs: --annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \ --annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY,,}" \ --annotation "org.opencontainers.image.version=${{ inputs.version }}" \ - "${discovery_tag}" - digest="$("${REGCTL}" image digest "${discovery_tag}")" - if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "release catalog did not resolve to an immutable digest" >&2 + "${local_tag}" + expected_digest="$("${REGCTL}" image digest "${local_tag}")" + if [[ ! "${expected_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "local release catalog did not resolve to an immutable digest" >&2 exit 1 fi - immutable_reference="${catalog_repository}@${digest}" + local_immutable="ocidir://${local_layout}@${expected_digest}" + local_manifest="${RUNNER_TEMP}/release-catalog-local-manifest.json" + "${REGCTL}" manifest get "${local_immutable}" --format raw-body > "${local_manifest}" + tags="${RUNNER_TEMP}/release-catalog-tags" + if ! "${REGCTL}" tag ls "${catalog_repository}" --format '{{ range .Tags }}{{ println . }}{{ end }}' > "${tags}"; then + staging_tag="${catalog_repository}:staging-${plan_digest#sha256:}" + "${REGCTL}" image copy "${local_immutable}" "${staging_tag}" + staged_digest="$("${REGCTL}" image digest "${staging_tag}")" + if [[ "${staged_digest}" != "${expected_digest}" ]]; then + echo "release catalog staging tag did not preserve the manifest digest" >&2 + exit 1 + fi + "${REGCTL}" tag ls "${catalog_repository}" --format '{{ range .Tags }}{{ println . }}{{ end }}' > "${tags}" + fi + inventory_decision="${RUNNER_TEMP}/release-catalog-tag-inventory-decision.json" + node scripts/ql3-release-catalog-contract.cjs \ + --mode=tag-inventory \ + --plan="${PLAN}" \ + --tag-inventory="${tags}" \ + --output="${inventory_decision}" > /dev/null + tag_state="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.observation)' "${inventory_decision}")" + observed_digest="absent" + if [[ "${tag_state}" == "present" ]]; then + observed_digest="$("${REGCTL}" image digest "${discovery_tag}")" + fi + decision="${RUNNER_TEMP}/release-catalog-publication-decision.json" + node scripts/ql3-release-catalog-contract.cjs \ + --mode=publication-decision \ + --plan="${PLAN}" \ + --manifest="${local_manifest}" \ + --manifest-digest="${expected_digest}" \ + --observed-discovery-digest="${observed_digest}" \ + --output="${decision}" > /dev/null + action="$(node -e 'const p=require(process.argv[1]);process.stdout.write(p.action)' "${decision}")" + if [[ "${action}" == "publish_if_absent" ]]; then + "${REGCTL}" image copy "${local_immutable}" "${discovery_tag}" + elif [[ "${action}" != "reuse_exact_digest" ]]; then + echo "release catalog publication action is invalid" >&2 + exit 1 + fi + digest="$("${REGCTL}" image digest "${discovery_tag}")" + if [[ "${digest}" != "${expected_digest}" ]]; then + echo "release catalog discovery tag does not resolve to the planned digest" >&2 + exit 1 + fi + immutable_reference="${catalog_repository}@${expected_digest}" roundtrip="${RUNNER_TEMP}/release-set-roundtrip.json" manifest="${RUNNER_TEMP}/release-catalog-manifest.json" "${REGCTL}" artifact get --file "${file_name}" "${immutable_reference}" > "${roundtrip}" cmp --silent "${RELEASE_SET}" "${roundtrip}" "${REGCTL}" manifest get "${immutable_reference}" --format raw-body > "${manifest}" + cmp --silent "${local_manifest}" "${manifest}" echo "repository=${catalog_repository}" >> "${GITHUB_OUTPUT}" - echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + echo "digest=${expected_digest}" >> "${GITHUB_OUTPUT}" echo "manifest=${manifest}" >> "${GITHUB_OUTPUT}" - name: Keylessly sign the immutable release-catalog digest diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index e8022c3f..c9bfa8ce 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,27 @@ 最新增量证据(2026-08-18): +- D-349/ADR-0441(已接受;首份真实 GHCR conflict/reuse 证据待实际 release tag):关闭 durable catalog discovery tag 的覆盖窗口。 + 之前 workflow 虽声明 `v-` 无部署 authority,却直接对该 tag 执行 `artifact put`;已有不同 digest 会先被覆盖, + response-loss 重跑也没有“相同复用、冲突拒绝”的可执行分支。catalog plan/receipt 现升级为 v2,publisher 必须先在 runner 私有 + `ocidir://` 生成 exact manifest;远端 repository 不存在时仅以 `staging-` 非权威 tag 建立可读 inventory。 + `qinglong/release-catalog-tag-inventory-decision@v1` 先执行 1 MiB、canonical line、OCI tag 字符集和无重复约束,随后 + `qinglong/release-catalog-publication-decision@v1` 将 absent 映射为 `publish_if_absent`、exact digest 映射为 + `reuse_exact_digest`,其他 digest 或无界/畸形 inventory 均在 discovery mutation 前失败。两条成功路径都按 immutable reference + 回读 release-set 和 raw manifest;receipt 明确绑定 `fail_closed_before_mutation` 与 + `reuse_exact_manifest_digest_only`。OCI registry 仍无 tag CAS,因此诚实保留 `registryTagCas=false`:同 ref concurrency 与组织 + package 权限限制外部 writer,最终回读检测发布期间竞争,consumer 永远只信任签名/attested immutable digest。本 Gate 不修改 + release-set v3/OCI media、package、生产依赖、数据库、Kubernetes object 或设备运行时。定向 receipt/set/catalog/consumption/ + deployment-lock/image-release 回归 143/143;完整 backend 1,371 项为 1,369 pass/2 条件 skip/0 fail,18-package clean + build/test 退出 0。12 项 package boundary、Cluster dependency、Edge import、Cluster/Worker/CloudNativePG 部署、backup、 + Barman/cert-manager selection、image release、deployment-lock surface 与 release-version 审计全部 compatible;14 档 Local + artifact 全部 compatible 且保持既有字节基线:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,adopted 为 + 2,809,185/2,809,308 bytes,application 为 3,632,769/3,632,889 bytes,application-api 为 + 3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes,application+AI 为 4,493,043/4,493,175 bytes, + MCP 为 7,315,930/7,316,038 bytes。真实 PostgreSQL 18.6 arm64 physical HA 再次通过 142/142、timeline `1→2`,报告 + SHA-256 为 `13e2f3793d7f418f0c1cc3b05206b393c4f09a45e1b9a5783219c12fb930b3dd`,离线审计通过且无 `ql3-ha-*` + Docker 容器、卷或网络残留。首份真实 GHCR conflict/reuse 结果仍只能由实际受保护 `v3` release tag 取得。 + - D-348/ADR-0440(已接受;首份真实线上闭合待实际 release tag):补齐 D-347 确定性收据与实际 tag promotion 之间的 freshness 窗口。此前私有 job 在收据创建时验证 24 小时 freshness,但 release-set aggregate 只检查 `observedAt` 可解析与 receipt digest;镜像构建、扫描和 attestation 延迟后,创建时有效的收据可能在闭合时已经过期。现在 `cluster|all` aggregate 与紧随其后的 source-record audit 必须各自取得 runner-owned 当前 diff --git a/docs/adr/ADR-0441-no-overwrite-release-catalog-discovery-publication.md b/docs/adr/ADR-0441-no-overwrite-release-catalog-discovery-publication.md new file mode 100644 index 00000000..6fcf0797 --- /dev/null +++ b/docs/adr/ADR-0441-no-overwrite-release-catalog-discovery-publication.md @@ -0,0 +1,93 @@ +# ADR-0441:Release Catalog Discovery Tag 无覆盖发布 + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-347、D-349 +- 关联 ADR:ADR-0428、ADR-0430、ADR-0439 +- Supersedes:ADR-0428 中允许直接向 discovery tag 重发内容的恢复语义,以及 catalog plan/receipt v1 + +## 上下文 + +ADR-0428 把 `v-` 定义为无部署 authority 的发现入口,并要求部署只信任验证后的 immutable manifest digest。 +ADR-0439 又要求相同 source evidence 的合法 workflow 重跑生成相同 release-set 与 catalog identity。 + +但发布 workflow 仍直接对远端 discovery tag 执行 `regctl artifact put`。OCI Distribution tag 没有跨 registry CAS;如果该 tag 已被外部 +写成另一个 digest,直接 put 会先覆盖冲突事实,再在新 digest 上完成验证。response-loss 重跑也无法区分“远端已经是同一确定内容”和“远端 +曾指向不同内容”。workflow 的同 ref concurrency 只能串行本仓库 Actions 运行,不能证明 registry 没有其他 package writer。 + +## 决策 + +1. catalog plan 升为 `qinglong/release-catalog-plan@v2`,publication policy 固定声明: + - staging/discovery tag authority 均为 `none`; + - discovery 冲突必须在 mutation 前失败; + - response-loss 只能复用 exact manifest digest; + - immutable digest 仍是唯一部署 authority。 +2. publisher 必须先把 canonical release-set 发布到 runner 私有的 `ocidir://` layout,并从该 layout 取得 manifest digest 和 raw + manifest。绝不再直接对 discovery tag 执行 `artifact put`。 +3. publisher 随后读取远端 catalog tag inventory。首次发布时 repository 可能尚不存在;只有在 inventory 读取失败后,才允许把本地 + immutable manifest copy 到 `staging-`。该 deterministic staging tag 无 authority,写后必须解析为同一 manifest + digest,再重新读取 inventory。第二次读取失败即失败关闭。 +4. `qinglong/release-catalog-tag-inventory-decision@v1` 对 tag inventory 执行最大 1 MiB、canonical line、OCI tag 字符集和 + 无重复校验。若 discovery tag 存在,必须读取其 digest;若缺失,则以显式 `absent` 状态进入 publication decision。 +5. 新的 `qinglong/release-catalog-publication-decision@v1` 只接受已通过 exact manifest contract 的本地 manifest: + - `absent` 产生 `publish_if_absent`; + - exact digest 产生 `reuse_exact_digest`,用于 response-loss 恢复; + - 任意不同、缺失格式或非 SHA-256 观察值在 discovery mutation 前失败。 +6. `publish_if_absent` 只能从本地 immutable reference copy 到 discovery tag;`reuse_exact_digest` 不写 registry。两条路径都必须 + 再读取 discovery digest、按 immutable reference round-trip release-set、逐字节比较本地/远端 raw manifest,然后才允许签名、 + provenance 和 receipt。 +7. catalog receipt 升为 `qinglong/release-catalog-receipt@v2`,明确绑定 + `fail_closed_before_mutation` 与 `reuse_exact_manifest_digest_only`。release-set v3 和 OCI media type 不变,因为被发布的 + canonical release-set bytes 没有改变。 +8. OCI registry 仍没有 tag CAS,因此本 Gate 不宣称能阻止仓库外 writer 在 inventory 与 copy 之间竞争。workflow 保持同 ref 串行, + package write authority 必须由组织治理;最终回读会发现写入期间的竞争,而任何稍后改写也不能改变 receipt/provenance 中的 immutable + digest。consumer 继续禁止把 discovery tag 当作 authority。 + +## 失败与恢复 + +- discovery 已指向不同 digest:不执行 discovery copy,不覆盖现场;操作者必须调查 tag/source/package authority,不能以重跑“修复”。 +- discovery 已指向 exact digest:不重新发布,继续 immutable round-trip、签名和 provenance 验证,得到与首次成功相同的 durable + plan/receipt bytes。 +- catalog repository 尚不存在:deterministic staging copy 只用于建立 repository 和确定可读 tag inventory;staging tag 永远不能进入 + deployment lock。 +- registry/auth/network 不可用:staging 或第二次 inventory 失败即停止;不能把不确定读取当成 tag 缺失。 +- discovery copy response 丢失:重跑后 inventory 必须看到 exact digest 并走 reuse;看到其他 digest 则失败。 + +## 部署与资源影响 + +- Edge/Standalone/路由设备继续只接收 catalog-bound Local selection 和 immutable image digest,不安装 regctl、Node、Cosign 或 GitHub + CLI,不增加 RSS、I/O、timer、listener、updater 或后台任务。 +- Cluster 节点、Kubernetes object、CloudNativePG、数据库、migration、SQL、Pool 与运行时镜像均无变化。 +- 新工作只在短生命周期发布 runner:一个私有 OCI layout、至多 1 MiB tag inventory、一次 publication decision 和必要时一次 staging + copy;不新增 workspace package、生产依赖或常驻服务。 + +## 被拒绝的替代方案 + +### 继续直接 artifact put 后再验 digest + +拒绝。后验一致只能证明覆盖后的结果,不能保留或拒绝原有冲突。 + +### 把 workflow concurrency 当作 registry CAS + +拒绝。它只串行同仓库同 ref 的 Actions run,无法约束其他 package token、组织管理员或 registry 外部 writer。 + +### 让 discovery tag 成为不可变部署入口 + +拒绝。OCI tag 仍可改写;唯一可持久签名、attest 和部署锁定的 authority 是 manifest digest。 + +### 为 catalog 新增常驻协调服务 + +拒绝。发布路径不值得引入新的服务、数据库和低配设备依赖;确定性 staging、冲突决策与 immutable consumer 已能把剩余竞态限制在非权威 tag。 + +## 验证 + +- contract 覆盖空/存在/重复/畸形/超限 tag inventory、absent publish、exact response-loss reuse、不同 digest 冲突、非法观察值和 + closed CLI; +- workflow 静态门拒绝 direct discovery `artifact put`、缺失 publication decision、缺失 exact reuse、无界/malformed inventory 和未回读 + immutable digest; +- catalog consumption、deployment-lock、Local/Cluster post-publication gate 必须继续只消费 immutable reference; +- 定向发布链 143/143,完整 backend 1,371 项为 1,369 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0; +- 12 项静态审计和 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 至 MCP 的既有字节/RSS 上限未漂移; +- PostgreSQL 18.6 arm64 physical HA 通过 142/142、timeline `1→2`,证据 SHA-256 为 + `13e2f3793d7f418f0c1cc3b05206b393c4f09a45e1b9a5783219c12fb930b3dd`,离线审计 compatible 且 Docker 资源零残留; +- 完整证据记录于 QL-RFC-0001 D-349;首份真实 GHCR conflict/reuse 证据仍须由受保护 `v3` release tag 产生。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 5bbef3d2..cee8cd1a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -444,6 +444,7 @@ | [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag) | | [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag) | | [ADR-0440](./ADR-0440-release-set-closure-private-evidence-freshness.md) | Release-set 闭合时私有证据 Freshness 重验证 | Accepted(首份真实线上闭合待实际 release tag) | +| [ADR-0441](./ADR-0441-no-overwrite-release-catalog-discovery-publication.md) | Release Catalog Discovery Tag 无覆盖发布 | Accepted(首份真实 GHCR conflict/reuse 证据待实际 release tag) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index a09bbdcc..ab6552f7 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -445,6 +445,20 @@ release-set、catalog immutable digest、两类 provenance 与 receipt 全部生 consumer 失败时不能宣布 Cluster release。不要给 consumer 临时增加写权限“修复”可见性或 tag,应修正 GHCR package visibility/retention 或发布配置后,对同一受保护 tag 重跑完整工作流并重新验证 exact digest。 +catalog discovery tag 另有独立的无覆盖协议。publisher 先在 runner 私有 `ocidir://` 中生成 exact manifest;不得直接向 +`v-` 执行 `artifact put`。远端 repository 不存在时只允许创建 +`staging-` 非权威 tag,以便重新取得不超过 1 MiB 的完整 tag inventory;inventory 必须先通过 +`qinglong/release-catalog-tag-inventory-decision@v1` 的 canonical line、OCI tag 字符集和无重复检查。随后: + +- discovery 缺失:publication decision 为 `publish_if_absent`,从本地 immutable reference copy 后立即回读; +- discovery 已是 exact manifest digest:decision 为 `reuse_exact_digest`,不写 registry,继续 immutable 验证; +- discovery 是其他 digest、inventory 无界/重复/畸形,或 repository 建立后仍无法读取:在覆盖前失败。 + +catalog plan/receipt 因此分别为 v2,receipt 固定声明 `fail_closed_before_mutation` 与 +`reuse_exact_manifest_digest_only`。GHCR tag 没有 CAS,组织仍必须限制 package writer;workflow 的同 ref concurrency 只能串行受保护 +tag 的本仓库 run。最终 digest/manifest 回读用于发现发布期间竞争,deployment consumer 仍只接受已签名和 attested 的 immutable reference, +绝不能因为 discovery 当前“看起来正确”而把 tag 写入 rollout。 + workflow bundle 当前保留 90 天;长期入口是 OCI catalog 的 immutable digest。GHCR 并非 WORM,release owner 仍须维护 package 可见性、读取权限和满足组织要求的 retention/备份策略。任何归档或镜像过程都不得改写 canonical JSON,并须保留 原 catalog manifest digest、receipt 与 provenance 关联。 diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index b3d9be85..6705cba8 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -760,7 +760,19 @@ function auditReleaseWorkflow(source) { 'subject-path': '${{ steps.release-set.outputs.report }}', }) || releaseSetSteps[10]?.id !== 'catalog' || - !/artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*image digest "\$\{discovery_tag\}"[\s\S]*artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*GITHUB_OUTPUT/.test( + !/local_tag="ocidir:\/\/\$\{local_layout\}:candidate"[\s\S]*artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*"\$\{local_tag\}"[\s\S]*expected_digest=.*image digest "\$\{local_tag\}"[\s\S]*local_immutable="ocidir:\/\/\$\{local_layout\}@\$\{expected_digest\}"/.test( + releaseSetSteps[10]?.run ?? '', + ) || + !/tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*staging_tag="\$\{catalog_repository\}:staging-\$\{plan_digest#sha256:\}"[\s\S]*image copy "\$\{local_immutable\}" "\$\{staging_tag\}"[\s\S]*tag ls "\$\{catalog_repository\}" --format '\{\{ range \.Tags \}\}\{\{ println \. \}\}\{\{ end \}\}'[\s\S]*--mode=tag-inventory[\s\S]*--plan="\$\{PLAN\}"[\s\S]*--tag-inventory="\$\{tags\}"[\s\S]*--output="\$\{inventory_decision\}"[\s\S]*tag_state=.*p\.observation/.test( + releaseSetSteps[10]?.run ?? '', + ) || + !/--mode=publication-decision[\s\S]*--manifest="\$\{local_manifest\}"[\s\S]*--manifest-digest="\$\{expected_digest\}"[\s\S]*--observed-discovery-digest="\$\{observed_digest\}"[\s\S]*action=.*p\.action[\s\S]*publish_if_absent[\s\S]*image copy "\$\{local_immutable\}" "\$\{discovery_tag\}"[\s\S]*reuse_exact_digest[\s\S]*digest=.*image digest "\$\{discovery_tag\}"[\s\S]*"\$\{digest\}" != "\$\{expected_digest\}"/.test( + releaseSetSteps[10]?.run ?? '', + ) || + !/artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*cmp --silent "\$\{local_manifest\}" "\$\{manifest\}"[\s\S]*GITHUB_OUTPUT/.test( + releaseSetSteps[10]?.run ?? '', + ) || + /artifact put[\s\S]{0,1200}"\$\{discovery_tag\}"/.test( releaseSetSteps[10]?.run ?? '', ) || !/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test( @@ -1315,12 +1327,22 @@ function auditReleaseWorkflow(source) { durableCatalog: { repository: 'qinglong3-release-catalog', artifactType: 'application/vnd.qinglong.release-set.v3+json', + planSchema: 'qinglong/release-catalog-plan@v2', + receiptSchema: 'qinglong/release-catalog-receipt@v2', + tagInventoryDecisionSchema: + 'qinglong/release-catalog-tag-inventory-decision@v1', + publicationDecisionSchema: + 'qinglong/release-catalog-publication-decision@v1', basenameOnly: true, crossRunnerDeterministic: true, byteExactRoundTrip: true, keylessSignatureVerified: true, githubProvenanceVerified: true, + deterministicStagingTag: true, discoveryTagAuthority: 'none', + discoveryTagConflictPolicy: 'fail_closed_without_overwrite', + responseLossRecovery: 'reuse_exact_manifest_digest_only', + registryTagCas: false, immutableDigestAuthority: 'verified', receiptAttested: true, }, diff --git a/scripts/ql3-release-catalog-contract.cjs b/scripts/ql3-release-catalog-contract.cjs index 1bfe1e59..9dbca814 100644 --- a/scripts/ql3-release-catalog-contract.cjs +++ b/scripts/ql3-release-catalog-contract.cjs @@ -10,8 +10,12 @@ const { inspectReleaseSet, } = require('./ql3-release-set-contract.cjs'); -const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1'; -const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1'; +const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v2'; +const CATALOG_PUBLICATION_DECISION_SCHEMA = + 'qinglong/release-catalog-publication-decision@v1'; +const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v2'; +const CATALOG_TAG_INVENTORY_DECISION_SCHEMA = + 'qinglong/release-catalog-tag-inventory-decision@v1'; const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v3+json'; const FILE_MEDIA_TYPE = ARTIFACT_TYPE; const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json'; @@ -19,9 +23,20 @@ const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json'; const OCI_EMPTY_CONFIG_DIGEST = 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const OCI_TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u; const SOURCE_REPOSITORY_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u; const MAX_FILE_BYTES = 1024 * 1024; +const PUBLICATION_POLICY = Object.freeze({ + title: 'basename_only', + crossRunnerDeterministic: true, + stagingTagAuthority: 'none', + discoveryTagAuthority: 'none', + immutableDigestAuthority: 'required', + roundTrip: 'byte_exact', + conflict: 'fail_closed_before_discovery_tag_mutation', + recovery: 'reuse_exact_manifest_digest_only', +}); class QingLong3ReleaseCatalogError extends Error { constructor(message) { @@ -167,14 +182,7 @@ function createCatalogPlan(releaseSet, options) { 'org.opencontainers.image.version': options.version, }, }, - publicationPolicy: { - title: 'basename_only', - crossRunnerDeterministic: true, - discoveryTagAuthority: 'none', - immutableDigestAuthority: 'required', - roundTrip: 'byte_exact', - recovery: 'republish_deterministic_content_then_verify_digest', - }, + publicationPolicy: { ...PUBLICATION_POLICY }, }; return Object.freeze({ ...unsigned, @@ -204,6 +212,8 @@ function validatePlanShape(plan) { ]) || plan.schemaVersion !== 1 || plan.schema !== CATALOG_PLAN_SCHEMA || + JSON.stringify(plan.publicationPolicy) !== + JSON.stringify(PUBLICATION_POLICY) || !DIGEST_PATTERN.test(plan.planDigest || '') ) { fail('catalog plan shape is invalid'); @@ -262,6 +272,93 @@ function validateManifest(plan, manifestContents, manifestDigest) { return manifest; } +function createCatalogPublicationDecision( + plan, + manifestContents, + manifestDigest, + observedDiscoveryDigest, +) { + validatePlanShape(plan); + validateManifest(plan, manifestContents, manifestDigest); + if ( + observedDiscoveryDigest !== 'absent' && + !DIGEST_PATTERN.test(observedDiscoveryDigest || '') + ) { + fail('observed discovery tag digest is invalid'); + } + if ( + observedDiscoveryDigest !== 'absent' && + observedDiscoveryDigest !== manifestDigest + ) { + fail('discovery tag already points at another catalog manifest'); + } + const action = + observedDiscoveryDigest === 'absent' + ? 'publish_if_absent' + : 'reuse_exact_digest'; + const unsigned = { + schemaVersion: 1, + schema: CATALOG_PUBLICATION_DECISION_SCHEMA, + planDigest: plan.planDigest, + catalog: { + discoveryTag: plan.catalog.discoveryTag, + manifestDigest, + immutableReference: `${plan.catalog.registryRepository}@${manifestDigest}`, + }, + observation: + observedDiscoveryDigest === 'absent' ? 'absent' : 'exact_manifest_digest', + action, + guards: { + stagingTagAuthority: 'none', + discoveryTagAuthority: 'none', + overwriteConflicts: false, + verifyAfterPublication: true, + }, + }; + return Object.freeze({ + ...unsigned, + decisionDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function createCatalogTagInventoryDecision(plan, inventoryContents) { + validatePlanShape(plan); + if ( + typeof inventoryContents !== 'string' || + Buffer.byteLength(inventoryContents) > MAX_FILE_BYTES || + (inventoryContents.length > 0 && !inventoryContents.endsWith('\n')) + ) { + fail('catalog tag inventory is invalid or unbounded'); + } + const tags = + inventoryContents.length === 0 + ? [] + : inventoryContents.slice(0, -1).split('\n'); + if ( + tags.some((value) => !OCI_TAG_PATTERN.test(value)) || + new Set(tags).size !== tags.length + ) { + fail('catalog tag inventory is malformed'); + } + const separator = plan.catalog.discoveryTag.lastIndexOf(':'); + const discoveryTagName = plan.catalog.discoveryTag.slice(separator + 1); + const unsigned = { + schemaVersion: 1, + schema: CATALOG_TAG_INVENTORY_DECISION_SCHEMA, + planDigest: plan.planDigest, + discoveryTag: plan.catalog.discoveryTag, + inventory: { + count: tags.length, + contentDigest: sha256(inventoryContents), + }, + observation: tags.includes(discoveryTagName) ? 'present' : 'absent', + }; + return Object.freeze({ + ...unsigned, + decisionDigest: sha256(JSON.stringify(unsigned)), + }); +} + function createCatalogReceipt(plan, manifestContents, manifestDigest) { validatePlanShape(plan); validateManifest(plan, manifestContents, manifestDigest); @@ -285,6 +382,8 @@ function createCatalogReceipt(plan, manifestContents, manifestDigest) { githubProvenance: 'source_tag_and_revision_bound', discoveryTagAuthority: 'none', immutableDigestAuthority: 'verified', + discoveryTagConflictPolicy: 'fail_closed_before_mutation', + responseLossRecovery: 'reuse_exact_manifest_digest_only', }, }; return Object.freeze({ @@ -305,6 +404,7 @@ function auditCatalogReceipt(actual, plan, manifestContents, manifestDigest) { catalogManifestDigest: actual.catalog.manifestDigest, immutableReference: actual.catalog.immutableReference, discoveryTagAuthority: actual.verification.discoveryTagAuthority, + discoveryTagConflictPolicy: actual.verification.discoveryTagConflictPolicy, }); } @@ -328,6 +428,17 @@ function parseArguments(argv) { const expected = values.mode === 'plan' ? [...identity, 'output', 'release-set'] + : values.mode === 'tag-inventory' + ? ['mode', 'output', 'plan', 'tag-inventory'] + : values.mode === 'publication-decision' + ? [ + 'manifest', + 'manifest-digest', + 'mode', + 'observed-discovery-digest', + 'output', + 'plan', + ] : values.mode === 'receipt' ? ['manifest', 'manifest-digest', 'mode', 'output', 'plan'] : values.mode === 'audit' @@ -362,7 +473,13 @@ function parseArguments(argv) { ...(values['manifest-digest'] ? { manifestDigest: values['manifest-digest'] } : {}), + ...(values['observed-discovery-digest'] + ? { observedDiscoveryDigest: values['observed-discovery-digest'] } + : {}), ...(values.receipt ? { receipt: values.receipt } : {}), + ...(values['tag-inventory'] + ? { tagInventory: values['tag-inventory'] } + : {}), ...(values.output ? { output: values.output } : {}), }); } @@ -383,7 +500,42 @@ function runCli(argv, output = process.stdout) { return plan; } const plan = readCanonicalJson(options.plan, 'catalog plan').value; + if (options.mode === 'tag-inventory') { + const inventoryPath = resolveCanonicalAbsolute( + options.tagInventory, + 'catalog tag inventory', + ); + const stat = fs.lstatSync(inventoryPath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size > MAX_FILE_BYTES || + fs.realpathSync(inventoryPath) !== inventoryPath || + fs.realpathSync(path.dirname(inventoryPath)) !== + path.dirname(inventoryPath) + ) { + fail('catalog tag inventory must be one bounded canonical regular file'); + } + const decision = createCatalogTagInventoryDecision( + plan, + fs.readFileSync(inventoryPath, 'utf8'), + ); + writeNoReplace(options.output, decision); + output.write(canonicalJson(decision)); + return decision; + } const manifest = readBoundedFile(options.manifest, 'catalog manifest'); + if (options.mode === 'publication-decision') { + const decision = createCatalogPublicationDecision( + plan, + manifest.contents, + options.manifestDigest, + options.observedDiscoveryDigest, + ); + writeNoReplace(options.output, decision); + output.write(canonicalJson(decision)); + return decision; + } if (options.mode === 'receipt') { const receipt = createCatalogReceipt( plan, @@ -419,7 +571,9 @@ if (require.main === module) { module.exports = Object.freeze({ ARTIFACT_TYPE, CATALOG_PLAN_SCHEMA, + CATALOG_PUBLICATION_DECISION_SCHEMA, CATALOG_RECEIPT_SCHEMA, + CATALOG_TAG_INVENTORY_DECISION_SCHEMA, FILE_MEDIA_TYPE, OCI_EMPTY_CONFIG_DIGEST, OCI_EMPTY_CONFIG_MEDIA_TYPE, @@ -428,7 +582,9 @@ module.exports = Object.freeze({ auditCatalogPlan, auditCatalogReceipt, createCatalogPlan, + createCatalogPublicationDecision, createCatalogReceipt, + createCatalogTagInventoryDecision, parseArguments, runCli, }); diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 6511dead..9710d580 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -111,12 +111,22 @@ test('accepts the reviewed native CI and digest release contracts', () => { durableCatalog: { repository: 'qinglong3-release-catalog', artifactType: 'application/vnd.qinglong.release-set.v3+json', + planSchema: 'qinglong/release-catalog-plan@v2', + receiptSchema: 'qinglong/release-catalog-receipt@v2', + tagInventoryDecisionSchema: + 'qinglong/release-catalog-tag-inventory-decision@v1', + publicationDecisionSchema: + 'qinglong/release-catalog-publication-decision@v1', basenameOnly: true, crossRunnerDeterministic: true, byteExactRoundTrip: true, keylessSignatureVerified: true, githubProvenanceVerified: true, + deterministicStagingTag: true, discoveryTagAuthority: 'none', + discoveryTagConflictPolicy: 'fail_closed_without_overwrite', + responseLossRecovery: 'reuse_exact_manifest_digest_only', + registryTagCas: false, immutableDigestAuthority: 'verified', receiptAttested: true, }, @@ -982,6 +992,50 @@ test('rejects a catalog publication without byte-exact round trip', () => { ); }); +test('rejects direct overwrite publication to the catalog discovery tag', () => { + const mutated = releaseSource.replace( + ' "${local_tag}"', + ' "${discovery_tag}"', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects catalog publication without a conflict-aware decision', () => { + const mutated = releaseSource.replace( + '--mode=publication-decision', + '--mode=receipt', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects catalog publication without bounded tag inventory classification', () => { + const mutated = releaseSource.replace( + '--mode=tag-inventory', + '--mode=publication-decision', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + +test('rejects response-loss recovery that cannot reuse an exact catalog digest', () => { + const mutated = releaseSource.replace( + ' elif [[ "${action}" != "reuse_exact_digest" ]]; then', + ' elif [[ "${action}" != "publish_if_absent" ]]; then', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /independently inspect, durably publish/, + ); +}); + test('rejects using the mutable catalog discovery tag as deployment authority', () => { const mutated = releaseSource.replace( 'artifact get --file "${file_name}" "${immutable_reference}"', diff --git a/test/back/ql3ReleaseCatalogContract.test.cjs b/test/back/ql3ReleaseCatalogContract.test.cjs index a7a7ce5d..65179425 100644 --- a/test/back/ql3ReleaseCatalogContract.test.cjs +++ b/test/back/ql3ReleaseCatalogContract.test.cjs @@ -8,13 +8,19 @@ const path = require('node:path'); const test = require('node:test'); const { ARTIFACT_TYPE, + CATALOG_PLAN_SCHEMA, + CATALOG_PUBLICATION_DECISION_SCHEMA, + CATALOG_RECEIPT_SCHEMA, + CATALOG_TAG_INVENTORY_DECISION_SCHEMA, OCI_EMPTY_CONFIG_DIGEST, OCI_EMPTY_CONFIG_MEDIA_TYPE, OCI_MANIFEST_MEDIA_TYPE, auditCatalogPlan, auditCatalogReceipt, createCatalogPlan, + createCatalogPublicationDecision, createCatalogReceipt, + createCatalogTagInventoryDecision, parseArguments, runCli, } = require('../../scripts/ql3-release-catalog-contract.cjs'); @@ -122,9 +128,15 @@ test('plans deterministic Local, Cluster and All OCI catalog entries', () => { `ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`, ); assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE); + assert.equal(plan.schema, CATALOG_PLAN_SCHEMA); + assert.equal(plan.publicationPolicy.stagingTagAuthority, 'none'); + assert.equal( + plan.publicationPolicy.conflict, + 'fail_closed_before_discovery_tag_mutation', + ); assert.equal( plan.publicationPolicy.recovery, - 'republish_deterministic_content_then_verify_digest', + 'reuse_exact_manifest_digest_only', ); assert.equal( plan.releaseSet.bytes, @@ -147,17 +159,131 @@ test('creates one digest-addressed receipt from the exact OCI manifest', () => { const manifest = manifestFor(plan); const manifestDigest = sha256(manifest); const receipt = createCatalogReceipt(plan, manifest, manifestDigest); + assert.equal(receipt.schema, CATALOG_RECEIPT_SCHEMA); assert.equal( receipt.catalog.immutableReference, `ghcr.io/qinglong-release/qinglong3-release-catalog@${manifestDigest}`, ); assert.equal(receipt.verification.discoveryTagAuthority, 'none'); + assert.equal( + receipt.verification.discoveryTagConflictPolicy, + 'fail_closed_before_mutation', + ); + assert.equal( + receipt.verification.responseLossRecovery, + 'reuse_exact_manifest_digest_only', + ); assert.equal( auditCatalogReceipt(receipt, plan, manifest, manifestDigest).compatible, true, ); }); +test('publishes only an absent discovery tag and exactly reuses response-loss recovery', () => { + const set = releaseSet('cluster'); + const plan = createCatalogPlan(set, { + ...identity, + releaseScope: 'cluster', + }); + const manifest = manifestFor(plan); + const manifestDigest = sha256(manifest); + const first = createCatalogPublicationDecision( + plan, + manifest, + manifestDigest, + 'absent', + ); + assert.equal(first.schema, CATALOG_PUBLICATION_DECISION_SCHEMA); + assert.equal(first.observation, 'absent'); + assert.equal(first.action, 'publish_if_absent'); + assert.equal(first.guards.overwriteConflicts, false); + const recovered = createCatalogPublicationDecision( + plan, + manifest, + manifestDigest, + manifestDigest, + ); + assert.equal(recovered.observation, 'exact_manifest_digest'); + assert.equal(recovered.action, 'reuse_exact_digest'); + assert.deepEqual(recovered.catalog, first.catalog); + assert.throws( + () => + createCatalogPublicationDecision( + plan, + manifest, + manifestDigest, + `sha256:${'f'.repeat(64)}`, + ), + /already points at another catalog manifest/, + ); + assert.throws( + () => + createCatalogPublicationDecision( + plan, + manifest, + manifestDigest, + 'missing', + ), + /observed discovery tag digest is invalid/, + ); + const weakenedPlan = JSON.parse(JSON.stringify(plan)); + weakenedPlan.publicationPolicy.recovery = + 'republish_deterministic_content_then_verify_digest'; + const { planDigest: ignored, ...unsigned } = weakenedPlan; + weakenedPlan.planDigest = sha256(JSON.stringify(unsigned)); + assert.throws( + () => + createCatalogPublicationDecision( + weakenedPlan, + manifest, + manifestDigest, + 'absent', + ), + /catalog plan shape is invalid/, + ); +}); + +test('classifies one bounded exact catalog tag inventory before mutation', () => { + const set = releaseSet('cluster'); + const plan = createCatalogPlan(set, { + ...identity, + releaseScope: 'cluster', + }); + const absent = createCatalogTagInventoryDecision( + plan, + 'staging-' + 'a'.repeat(64) + '\n', + ); + assert.equal(absent.schema, CATALOG_TAG_INVENTORY_DECISION_SCHEMA); + assert.equal(absent.observation, 'absent'); + assert.equal(absent.inventory.count, 1); + const present = createCatalogTagInventoryDecision( + plan, + `staging-${'a'.repeat(64)}\nv${version}-cluster\n`, + ); + assert.equal(present.observation, 'present'); + assert.equal(present.inventory.count, 2); + assert.throws( + () => + createCatalogTagInventoryDecision( + plan, + `v${version}-cluster\nv${version}-cluster\n`, + ), + /inventory is malformed/, + ); + assert.throws( + () => createCatalogTagInventoryDecision(plan, 'not a tag\n'), + /inventory is malformed/, + ); + assert.throws( + () => createCatalogTagInventoryDecision(plan, 'missing-newline'), + /inventory is invalid or unbounded/, + ); + assert.throws( + () => createCatalogTagInventoryDecision(plan, 'a'.repeat(1024 * 1024 + 1)), + /inventory is invalid or unbounded/, + ); +}); + test('rejects source ownership, mutable identity and release-set drift', () => { const set = releaseSet('local'); assert.throws( @@ -241,10 +367,56 @@ test('CLI plans, receipts and audits canonical no-replace evidence', (t) => { const output = { write() {} }; const plan = runCli(planArgs, output); assert.equal(fs.statSync(planPath).mode & 0o777, 0o600); + const tagInventoryPath = path.join(directory, 'tags.txt'); + fs.writeFileSync(tagInventoryPath, `v${version}-local\n`, { mode: 0o600 }); + assert.equal( + runCli( + [ + '--mode=tag-inventory', + `--plan=${planPath}`, + `--tag-inventory=${tagInventoryPath}`, + `--output=${path.join(directory, 'tag-inventory-decision.json')}`, + ], + output, + ).observation, + 'present', + ); const manifest = manifestFor(plan); const manifestPath = path.join(directory, 'manifest.json'); fs.writeFileSync(manifestPath, manifest, { mode: 0o600 }); const manifestDigest = sha256(manifest); + const decisionPath = path.join(directory, 'decision.json'); + assert.equal( + runCli( + [ + '--mode=publication-decision', + `--plan=${planPath}`, + `--manifest=${manifestPath}`, + `--manifest-digest=${manifestDigest}`, + '--observed-discovery-digest=absent', + `--output=${decisionPath}`, + ], + output, + ).action, + 'publish_if_absent', + ); + const conflictDecisionPath = path.join(directory, 'conflict-decision.json'); + assert.throws( + () => + runCli( + [ + '--mode=publication-decision', + '--plan=' + planPath, + '--manifest=' + manifestPath, + '--manifest-digest=' + manifestDigest, + '--observed-discovery-digest=sha256:' + 'f'.repeat(64), + '--output=' + conflictDecisionPath, + ], + output, + ), + /already points at another catalog manifest/, + ); + assert.equal(fs.existsSync(conflictDecisionPath), false); const receiptPath = path.join(directory, 'receipt.json'); runCli( [