mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): bind private release evidence receipts
This commit is contained in:
@@ -300,6 +300,7 @@ jobs:
|
||||
test/back/ql3ClusterImageSbom.test.cjs
|
||||
test/back/ql3ClusterImageReleaseAudit.test.cjs
|
||||
test/back/ql3ReleaseCandidateContract.test.cjs
|
||||
test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs
|
||||
test/back/ql3ReleaseSetContract.test.cjs
|
||||
test/back/ql3ReleaseCatalogContract.test.cjs
|
||||
test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs
|
||||
|
||||
@@ -92,6 +92,7 @@ jobs:
|
||||
- name: Re-audit commit-scoped private production evidence
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_SCOPE: ${{ inputs.release_scope }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
@@ -116,6 +117,40 @@ jobs:
|
||||
--ca-rollover-report="${evidence_dir}/worker-management-ca-rollover.json" \
|
||||
--source-commit="${GITHUB_SHA}" \
|
||||
--release-version="${RELEASE_VERSION}"
|
||||
receipt_root="${RUNNER_TEMP}/ql3-private-release-evidence-receipts"
|
||||
install -d -m 0700 "${receipt_root}"
|
||||
receipt="${receipt_root}/worker-management.json"
|
||||
node scripts/ql3-private-release-evidence-receipt-contract.cjs \
|
||||
--mode=worker-create \
|
||||
--version="${RELEASE_VERSION}" \
|
||||
--source-revision="${GITHUB_SHA}" \
|
||||
--source-ref="${GITHUB_REF}" \
|
||||
--release-scope="${RELEASE_SCOPE}" \
|
||||
--report="${evidence_dir}/worker-management-release-evidence.json" \
|
||||
--ceremony-report="${evidence_dir}/worker-management-ceremony.json" \
|
||||
--durable-audit-report="${evidence_dir}/worker-management-durable-audit.json" \
|
||||
--pki-rotation-report="${evidence_dir}/worker-management-pki-rotation-v2.json" \
|
||||
--ca-rollover-report="${evidence_dir}/worker-management-ca-rollover.json" \
|
||||
--output="${receipt}" > /dev/null
|
||||
node scripts/ql3-private-release-evidence-receipt-contract.cjs \
|
||||
--mode=audit \
|
||||
--version="${RELEASE_VERSION}" \
|
||||
--source-revision="${GITHUB_SHA}" \
|
||||
--source-ref="${GITHUB_REF}" \
|
||||
--release-scope="${RELEASE_SCOPE}" \
|
||||
--evidence-kind=worker-management \
|
||||
--receipt="${receipt}" > /dev/null
|
||||
|
||||
- name: Upload only the content-free Worker evidence receipt
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-worker-management
|
||||
path: ${{ runner.temp }}/ql3-private-release-evidence-receipts/worker-management.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
|
||||
cluster-dr-release-evidence:
|
||||
name: Audit private CloudNativePG disaster-recovery evidence
|
||||
@@ -138,6 +173,7 @@ jobs:
|
||||
- name: Re-audit commit-scoped private disaster-recovery evidence
|
||||
env:
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_SCOPE: ${{ inputs.release_scope }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
@@ -162,6 +198,36 @@ jobs:
|
||||
--report="${report}" \
|
||||
--source-commit="${GITHUB_SHA}" \
|
||||
--release-version="${RELEASE_VERSION}"
|
||||
receipt_root="${RUNNER_TEMP}/ql3-private-release-evidence-receipts"
|
||||
install -d -m 0700 "${receipt_root}"
|
||||
receipt="${receipt_root}/cloudnativepg-disaster-recovery.json"
|
||||
node scripts/ql3-private-release-evidence-receipt-contract.cjs \
|
||||
--mode=dr-create \
|
||||
--version="${RELEASE_VERSION}" \
|
||||
--source-revision="${GITHUB_SHA}" \
|
||||
--source-ref="${GITHUB_REF}" \
|
||||
--release-scope="${RELEASE_SCOPE}" \
|
||||
--report="${report}" \
|
||||
--output="${receipt}" > /dev/null
|
||||
node scripts/ql3-private-release-evidence-receipt-contract.cjs \
|
||||
--mode=audit \
|
||||
--version="${RELEASE_VERSION}" \
|
||||
--source-revision="${GITHUB_SHA}" \
|
||||
--source-ref="${GITHUB_REF}" \
|
||||
--release-scope="${RELEASE_SCOPE}" \
|
||||
--evidence-kind=cloudnativepg-disaster-recovery \
|
||||
--receipt="${receipt}" > /dev/null
|
||||
|
||||
- name: Upload only the content-free disaster-recovery evidence receipt
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-cloudnativepg-disaster-recovery
|
||||
path: ${{ runner.temp }}/ql3-private-release-evidence-receipts/cloudnativepg-disaster-recovery.json
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 9
|
||||
overwrite: false
|
||||
include-hidden-files: false
|
||||
|
||||
os-vulnerability:
|
||||
name: Scan ${{ matrix.image }} OS packages on ${{ matrix.image_arch }}
|
||||
@@ -610,11 +676,20 @@ jobs:
|
||||
name: Close and publish the complete deployment release set
|
||||
needs:
|
||||
- release-candidate
|
||||
- worker-management-release-evidence
|
||||
- cluster-dr-release-evidence
|
||||
- publish
|
||||
if: >-
|
||||
always() &&
|
||||
needs.release-candidate.result == 'success' &&
|
||||
needs.publish.result == 'success'
|
||||
needs.publish.result == 'success' &&
|
||||
(
|
||||
needs.release-candidate.outputs.cluster-evidence-required != 'true' ||
|
||||
(
|
||||
needs.worker-management-release-evidence.result == 'success' &&
|
||||
needs.cluster-dr-release-evidence.result == 'success'
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -639,6 +714,14 @@ jobs:
|
||||
path: ${{ runner.temp }}/release-records
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download the same-run content-free private evidence receipts
|
||||
if: needs.release-candidate.outputs.cluster-evidence-required == 'true'
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-*
|
||||
path: ${{ runner.temp }}/private-release-evidence-receipts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Aggregate and independently audit the complete release set
|
||||
id: release-set
|
||||
env:
|
||||
@@ -650,7 +733,9 @@ jobs:
|
||||
owner="${GITHUB_REPOSITORY_OWNER,,}"
|
||||
source_repository="${GITHUB_REPOSITORY,,}"
|
||||
candidate="${RUNNER_TEMP}/release-set-candidate.json"
|
||||
evidence_receipts="${RUNNER_TEMP}/private-release-evidence-receipts"
|
||||
bundle="${RUNNER_TEMP}/release-catalog-bundle"
|
||||
install -d -m 0700 "${evidence_receipts}"
|
||||
mkdir "${bundle}"
|
||||
report="${bundle}/qinglong3-release-set-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
|
||||
plan="${bundle}/qinglong3-release-catalog-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json"
|
||||
@@ -670,6 +755,7 @@ jobs:
|
||||
--repository-owner="${owner}" \
|
||||
--candidate="${candidate}" \
|
||||
--records="${RUNNER_TEMP}/release-records" \
|
||||
--evidence-receipts="${evidence_receipts}" \
|
||||
--output="${report}"
|
||||
node scripts/ql3-release-set-contract.cjs \
|
||||
--mode=audit \
|
||||
@@ -680,6 +766,7 @@ jobs:
|
||||
--repository-owner="${owner}" \
|
||||
--candidate="${candidate}" \
|
||||
--records="${RUNNER_TEMP}/release-records" \
|
||||
--evidence-receipts="${evidence_receipts}" \
|
||||
--report="${report}" > "${RUNNER_TEMP}/release-set-audit.json"
|
||||
node scripts/ql3-release-set-contract.cjs \
|
||||
--mode=inspect \
|
||||
|
||||
@@ -11,6 +11,28 @@
|
||||
|
||||
最新增量证据(2026-08-18):
|
||||
|
||||
- D-346/ADR-0438(已接受;首份真实公开收据待实际 release tag):Cluster/All 发布不再只通过 GitHub `needs` 保存“两个私有 evidence job
|
||||
成功”的瞬时调度事实。Worker management 与 CloudNativePG DR 私有 runner 在 source-aware gate 成功后,各自产生一个
|
||||
`qinglong/private-release-evidence-receipt@v1`:只投影 exact version/source tag/revision/scope、evidence kind、24 小时 freshness、
|
||||
原报告 SHA-256、可选静态审计摘要和自身 digest,显式禁止原始报告、路径、credential、token、Kubernetes object 与 transcript 发布,并声明
|
||||
公开消费者不能在缺少私有报告时重放现场结果。两个 1 天 artifact 均绑定同一 `run_id/run_attempt`,只作为跨 job 交接;长期 authority 升为
|
||||
`qinglong/release-set@v2`,其 digest 对完整收据对象做闭包。Local 必须零收据且不运行私有 evidence job;Cluster/All 必须按固定顺序恰好包含
|
||||
Worker 与 DR 两份同 identity 收据,缺失、重复、额外 kind、source/scope/freshness/static audit/self-digest 漂移均在 tag promotion 前失败关闭。
|
||||
OCI artifact/file media type同步升为 `application/vnd.qinglong.release-set.v2+json`,不在 v1 下偷换语义。Barman/cert-manager 静态 lock 继续
|
||||
保持 `releaseReady=false`:它们只证明供应链选择,某次发布的现场 ready 结论由 freshness-bound 收据链给出。实现不新增 package、生产依赖、
|
||||
数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源;低配设备仍只消费可信工作站生成的 Local selection 与 immutable
|
||||
image。定向 receipt/release-set/catalog/部署锁/workflow 契约 134/134;完整 backend 1,362 项为 1,360 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;package boundary 保持
|
||||
18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。14 档 Local artifact 全部 compatible 且与 D-345 字节基线完全一致:默认
|
||||
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 HA 在 Docker Engine
|
||||
恢复后以 18.6/arm64 重跑 142/142 gates、timeline `1→2`,独立 evidence audit compatible,mode-0600 报告 SHA-256 为
|
||||
`1e7c31cc6c7aa3e1e0398eb51696b6054850d5fc25910a08d6faffc9a75f1c6f`,container/network/volume 残留为 0。以上证明实现、
|
||||
失败关闭与本地/HA 回归边界;第一份线上公开收据及其 catalog 闭包仍只能由实际受保护 `v3` release tag dispatch 产生,不能由单测、
|
||||
workflow 静态审计或本地 fixture 冒充。
|
||||
|
||||
- D-345/ADR-0437(已接受;首份真实公开 catalog evidence 待实际 release tag):Local 发布不再只依赖 image publisher 在完整
|
||||
release-set/catalog 形成前直接掌握 digest 的 Compose rollout。`release-set` 成功后新增只读
|
||||
`release-catalog-local-deployment-live`,仅在 `local|all` scope 运行;它从公开 discovery ref 独立消费 immutable catalog,重建并审计
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# ADR-0438:内容无关的私有发布证据收据链
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-18
|
||||
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-333、D-335、D-336、D-346
|
||||
- 关联 ADR:ADR-0245、ADR-0264、ADR-0425、ADR-0427、ADR-0428、ADR-0430、ADR-0436、ADR-0437
|
||||
- Supersedes:ADR-0427 的 release-set v1 聚合边界,以及 ADR-0428 的 release-set v1 OCI media type
|
||||
|
||||
## 上下文
|
||||
|
||||
Cluster/All 发布已经要求两个 commit-scoped、24 小时内的私有门成功:Worker management 重新审计五份来源报告,CloudNativePG
|
||||
disaster recovery 重新审计对象存储 backup/WAL/latest restore/PITR、证书轮换和三项静态供应链锁。publisher 通过 `needs` 只能看到 job
|
||||
成功或失败,最终公开 release-set 却只记录候选策略中的“这些证据必须存在”,没有绑定本次 workflow attempt 实际审核的报告摘要。
|
||||
|
||||
因此 durable catalog 可以证明镜像、source 和 deployment family,却不能证明某个 Cluster release-set 对应哪一组私有发布证据。直接上传
|
||||
原始报告会泄漏生产环境事实并扩大保留面;只依赖 GitHub job 状态又会在 workflow artifact 过期后丢失可验证的发布闭包。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 两个私有 evidence job 在既有 release gate 成功后,各自产生一份
|
||||
`qinglong/private-release-evidence-receipt@v1`。收据精确绑定 release version、40-hex source revision、source tag、`cluster|all`
|
||||
scope、evidence kind、report SHA-256、观测/审核时间和 24 小时窗口,并具有自身 canonical SHA-256。
|
||||
2. Worker 收据只投影 source-aware 聚合报告摘要。CloudNativePG 收据额外投影 backup、Barman Cloud 与 cert-manager 三项静态审计结果摘要;
|
||||
静态 selection lock 继续保持 `releaseReady=false`,因为它自身不携带时效证据。实际某次发布是否 ready 由收据与 release-set 闭包决定,
|
||||
不能永久翻转一个静态文件来冒充持续有效的现场证据。
|
||||
3. 收据不得包含原始报告、路径、credential、token、连接信息、私钥、Kubernetes 对象或命令 transcript。它显式声明
|
||||
`sourceReportsUploaded=false`、`privateReportContentPublished=false`,并诚实记录公开消费者无法在缺少私有报告时重放外部结果。
|
||||
4. 私有 job 只上传收据文件,保留 1 天;原报告仍只存在于 commit-scoped ephemeral runner mount,禁止 cache 或 artifact 上传。artifact 名必须
|
||||
同时绑定 `run_id/run_attempt/evidenceKind`,下载时也必须使用同一模式。
|
||||
5. release-set 升级为 `qinglong/release-set@v2`:`local` scope 必须恰好零份收据;`cluster|all` 必须按固定顺序恰好包含
|
||||
`worker-management` 与 `cloudnativepg-disaster-recovery` 两份同 release identity 收据。缺失、重复、额外 kind、source/scope、freshness、
|
||||
static audit 或任一 digest 漂移均在 tag promotion 和 catalog publication 前失败关闭。
|
||||
6. release-set v2 把完整 content-free 收据嵌入自身并纳入 `releaseSetDigest`,随后由 file provenance、OCI catalog immutable digest、Cosign 与
|
||||
GitHub provenance 共同保护。原始 1 天 receipt artifact 只是跨 job 交接,长期 authority 是公开 catalog 中的 release-set v2。
|
||||
7. OCI artifact/file media type 升为 `application/vnd.qinglong.release-set.v2+json`。不能用 v1 media type 承载新增字段,也不接受 v1
|
||||
release-set 进入新部署锁或 catalog consumer。
|
||||
8. 不新增 workspace package、production dependency、数据库、migration、listener、timer、controller、Pod 或设备侧工具。所有新工作只发生在
|
||||
Cluster/All 的短生命周期 release runner;Local 发布不等待私有 evidence job,Edge/Standalone 只消费工作站生成的 selection 与 immutable
|
||||
image。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- 私有报告不新鲜或 source 不匹配:私有 job 失败,不生成收据;重新取得同 source 的现场证据后重跑受保护 tag。
|
||||
- 收据 artifact 缺失、来自其他 attempt 或目录中出现额外文件:release-set 聚合失败;不能以 job success 或手工 digest 替代。
|
||||
- 收据已生成但 publish matrix 失败:不 promotion;同一 tag 重跑必须重新审核仍在时效窗口内的证据并产生新收据。
|
||||
- catalog 消费者可验证收据结构、自摘要和 release-set 闭包,但不能声称重放了私有现场结果;需要审计原始事实时进入受控私有 evidence 环境。
|
||||
- v1 release-set:3.0 尚未正式发布,不提供隐式兼容;从同一 source 重新执行 release workflow 生成 v2。
|
||||
|
||||
## 部署与资源影响
|
||||
|
||||
- Local scope 的 release-set v2 收据数组为空,不启动两个私有 runner,不增加路由设备下载、CPU、内存或稳态连接。
|
||||
- Cluster/All 的公开 release-set 增加两份小型 content-free JSON,节点无需安装证据工具或访问私有报告;可信工作站随 catalog 一并审核。
|
||||
- 原始证据不离开私有 runner,公开内容仅能做 equality/source/freshness binding,不能反推出 credential 或生产对象内容。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 只依赖 GitHub `needs.<job>.result`
|
||||
|
||||
拒绝。它是瞬时调度事实,不能进入 release-set digest,也无法在 workflow 记录过期后由部署者独立核对。
|
||||
|
||||
### 上传全部私有证据到 release-set job
|
||||
|
||||
拒绝。公共 publisher 不需要原始生产事实;扩大跨 runner 传输和保留面违反最小披露边界。
|
||||
|
||||
### 把静态 Barman/cert-manager lock 永久改为 release-ready
|
||||
|
||||
拒绝。静态供应链选择没有 24 小时 freshness、backup/PITR 或 rotation 事实。ready 是 source-scoped release 结论,不是永久 lock 属性。
|
||||
|
||||
### 只在 image record 中加入一个布尔值
|
||||
|
||||
拒绝。布尔值无法绑定证据内容,也会把 deployment-family 级事实复制到每个镜像 publisher;release-set 才是跨镜像与私有证据的唯一终态 authority。
|
||||
|
||||
## 验证
|
||||
|
||||
- receipt contract 覆盖 Worker/DR exact projection、source/scope/freshness/report/static-lock/self-digest 漂移、私有字段注入、closed CLI、stable
|
||||
private read、mode `0600` no-replace 和内容无关输出;
|
||||
- release-set contract 覆盖 Local 零收据、Cluster/All exact-two、缺失/额外/漂移收据、v2 standalone inspection 与 digest 闭包;
|
||||
- workflow 静态门冻结同 attempt artifact、私有报告不上传、两份收据 create/audit、release-set 下载与 aggregate/audit handoff,以及 v2 OCI media type;
|
||||
- 定向契约 134/134,完整 backend 为 1,360 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;12 项架构/部署审计和 14 档
|
||||
Local artifact 全部 compatible,workspace 保持 18 packages 且没有 single-source/shallow-source package;
|
||||
- PostgreSQL 18.6/arm64 HA 重跑 142/142 gates、timeline `1→2`,独立 evidence audit compatible,报告 SHA-256 为
|
||||
`1e7c31cc6c7aa3e1e0398eb51696b6054850d5fc25910a08d6faffc9a75f1c6f`,container/network/volume 残留为 0;
|
||||
- 完整结果同步记录于 QL-RFC-0001 D-346;首份真实公开收据和 catalog 仍须由受保护 `v3` release tag 产生。
|
||||
@@ -441,6 +441,7 @@
|
||||
| [ADR-0435](./ADR-0435-uid-resource-version-fenced-kubernetes-resource-retirement.md) | UID/resourceVersion 围栏的 Kubernetes 资源退役 Ceremony | Accepted |
|
||||
| [ADR-0436](./ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md) | 发布后 Catalog-bound Kubernetes Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) |
|
||||
| [ADR-0437](./ADR-0437-post-publication-catalog-bound-local-release-gate.md) | 发布后 Catalog-bound Local Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) |
|
||||
| [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -13,6 +13,17 @@ ghcr.io/<owner>/qinglong3-release-catalog:v<version>-<scope>
|
||||
`ghcr.io/<owner>/qinglong3-release-catalog@sha256:<manifest-digest>`,验证这个 immutable reference 后,再从 JSON 的
|
||||
`images[].reference` 读取完整 `ghcr.io/<owner>/<repository>@sha256:<digest>`。
|
||||
|
||||
## 私有发布证据收据链
|
||||
|
||||
当前长期 authority 是 `qinglong/release-set@v2`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含
|
||||
`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v1`。每份收据绑定同一
|
||||
version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与
|
||||
cert-manager 三项静态审计摘要。
|
||||
|
||||
这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest,
|
||||
但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact
|
||||
从私有 job 交给 release-set job,随后完整嵌入 release-set v2 并由 durable catalog 长期保护。
|
||||
|
||||
## 发布流水线内置 Local 与 Cluster 下游门
|
||||
|
||||
`local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog
|
||||
@@ -393,12 +404,14 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计
|
||||
1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
|
||||
reference;discovery tag 无 authority。
|
||||
2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中
|
||||
`qinglong/release-set@v1` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||
`qinglong/release-set@v2` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
||||
4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的
|
||||
4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。
|
||||
static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。
|
||||
5. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的
|
||||
`newName`/digest 不是最终 authority。Local 必须生成并审计 v2 service selection。两族输出都必须绑定同一 catalog manifest、
|
||||
consumption report 与 release-set digest,并且只能消费 release set 中的 `@sha256:` reference。
|
||||
5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned
|
||||
6. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned
|
||||
kubectl/kubeconfig 和目标 cluster UID 绑定进 preflight/apply receipt;version/source/catalog tag 都只能用于发现,部署始终以
|
||||
release set 中的镜像 digest 为准。
|
||||
|
||||
|
||||
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
|
||||
'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
|
||||
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
|
||||
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -537,11 +537,16 @@ function auditReleaseWorkflow(source) {
|
||||
publishJob.if,
|
||||
) ||
|
||||
JSON.stringify(releaseSetJob?.needs) !==
|
||||
JSON.stringify(['release-candidate', 'publish']) ||
|
||||
JSON.stringify([
|
||||
'release-candidate',
|
||||
'worker-management-release-evidence',
|
||||
'cluster-dr-release-evidence',
|
||||
'publish',
|
||||
]) ||
|
||||
releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' ||
|
||||
releaseSetJob?.['timeout-minutes'] !== 20 ||
|
||||
typeof releaseSetJob?.if !== 'string' ||
|
||||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test(
|
||||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'[\s\S]*cluster-evidence-required != 'true'[\s\S]*worker-management-release-evidence\.result == 'success'[\s\S]*cluster-dr-release-evidence\.result == 'success'/.test(
|
||||
releaseSetJob.if,
|
||||
)
|
||||
) {
|
||||
@@ -630,7 +635,7 @@ function auditReleaseWorkflow(source) {
|
||||
}
|
||||
if (
|
||||
!Array.isArray(drEvidenceJob?.steps) ||
|
||||
drEvidenceJob.steps.length !== 3 ||
|
||||
drEvidenceJob.steps.length !== 4 ||
|
||||
drEvidenceJob.steps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
drEvidenceJob.steps[0]?.with?.['persist-credentials'] !== false ||
|
||||
@@ -640,7 +645,7 @@ function auditReleaseWorkflow(source) {
|
||||
typeof drEvidenceJob.steps[2]?.run !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
'private disaster-recovery evidence job must contain only the reviewed credential-free checkout, Node setup and audit steps',
|
||||
'private disaster-recovery evidence job must contain only the reviewed checkout, audit, content-free receipt and upload steps',
|
||||
);
|
||||
}
|
||||
const publishSteps = publishJob?.steps;
|
||||
@@ -707,7 +712,7 @@ function auditReleaseWorkflow(source) {
|
||||
const releaseSetSteps = releaseSetJob?.steps;
|
||||
if (
|
||||
!Array.isArray(releaseSetSteps) ||
|
||||
releaseSetSteps.length !== 15 ||
|
||||
releaseSetSteps.length !== 16 ||
|
||||
releaseSetSteps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
|
||||
@@ -723,54 +728,65 @@ function auditReleaseWorkflow(source) {
|
||||
path: '${{ runner.temp }}/release-records',
|
||||
'merge-multiple': true,
|
||||
}) ||
|
||||
releaseSetSteps[3]?.id !== 'release-set' ||
|
||||
!/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[3]?.run ?? '',
|
||||
) ||
|
||||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
|
||||
releaseSetSteps[3]?.if !==
|
||||
"needs.release-candidate.outputs.cluster-evidence-required == 'true'" ||
|
||||
releaseSetSteps[3]?.uses !==
|
||||
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' ||
|
||||
JSON.stringify(releaseSetSteps[3]?.with) !==
|
||||
JSON.stringify({
|
||||
pattern:
|
||||
'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-*',
|
||||
path: '${{ runner.temp }}/private-release-evidence-receipts',
|
||||
'merge-multiple': true,
|
||||
}) ||
|
||||
releaseSetSteps[4]?.id !== 'release-set' ||
|
||||
!/evidence_receipts="\$\{RUNNER_TEMP\}\/private-release-evidence-receipts"[\s\S]*install -d -m 0700 "\$\{evidence_receipts\}"[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*--evidence-receipts="\$\{evidence_receipts\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--evidence-receipts="\$\{evidence_receipts\}"[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[4]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[5]?.uses !==
|
||||
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
|
||||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
|
||||
releaseSetSteps[5]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[6]?.uses !==
|
||||
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
|
||||
releaseSetSteps[7]?.uses !==
|
||||
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
|
||||
!/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test(
|
||||
releaseSetSteps[7]?.run ?? '',
|
||||
releaseSetSteps[8]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[8]?.uses !==
|
||||
releaseSetSteps[9]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[8]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[9]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.release-set.outputs.report }}',
|
||||
}) ||
|
||||
releaseSetSteps[9]?.id !== 'catalog' ||
|
||||
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(
|
||||
releaseSetSteps[9]?.run ?? '',
|
||||
) ||
|
||||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[11]?.uses !==
|
||||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
|
||||
releaseSetSteps[11]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[12]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[11]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[12]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-name': '${{ steps.catalog.outputs.repository }}',
|
||||
'subject-digest': '${{ steps.catalog.outputs.digest }}',
|
||||
'push-to-registry': true,
|
||||
}) ||
|
||||
releaseSetSteps[12]?.id !== 'catalog-receipt' ||
|
||||
releaseSetSteps[13]?.id !== 'catalog-receipt' ||
|
||||
!/cosign verify[\s\S]*--certificate-identity "\$\{certificate_identity\}"[\s\S]*--certificate-oidc-issuer "https:\/\/token\.actions\.githubusercontent\.com"[\s\S]*"\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*gh attestation verify "oci:\/\/\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*--source-digest "\$\{GITHUB_SHA\}"[\s\S]*--source-ref "\$\{GITHUB_REF\}"[\s\S]*--deny-self-hosted-runners[\s\S]*--bundle-from-oci[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*--manifest-digest="\$\{DIGEST\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=audit[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[12]?.run ?? '',
|
||||
releaseSetSteps[13]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[13]?.uses !==
|
||||
releaseSetSteps[14]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[13]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[14]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
|
||||
}) ||
|
||||
releaseSetSteps[14]?.uses !==
|
||||
releaseSetSteps[15]?.uses !==
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||
JSON.stringify(releaseSetSteps[14]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[15]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
|
||||
path: '${{ steps.release-set.outputs.bundle }}',
|
||||
@@ -928,17 +944,50 @@ function auditReleaseWorkflow(source) {
|
||||
}
|
||||
if (
|
||||
!Array.isArray(evidenceJob?.steps) ||
|
||||
evidenceJob.steps.length !== 3 ||
|
||||
evidenceJob.steps.length !== 4 ||
|
||||
evidenceJob.steps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
evidenceJob.steps[0]?.with?.['persist-credentials'] !== false ||
|
||||
evidenceJob.steps[1]?.uses !==
|
||||
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
|
||||
evidenceJob.steps[1]?.with?.['node-version'] !== '24.18.0' ||
|
||||
typeof evidenceJob.steps[2]?.run !== 'string'
|
||||
typeof evidenceJob.steps[2]?.run !== 'string' ||
|
||||
evidenceJob.steps[3]?.uses !==
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||
JSON.stringify(evidenceJob.steps[3]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-worker-management',
|
||||
path: '${{ runner.temp }}/ql3-private-release-evidence-receipts/worker-management.json',
|
||||
'if-no-files-found': 'error',
|
||||
'retention-days': 1,
|
||||
'compression-level': 9,
|
||||
overwrite: false,
|
||||
'include-hidden-files': false,
|
||||
}) ||
|
||||
!Array.isArray(drEvidenceJob?.steps) ||
|
||||
drEvidenceJob.steps.length !== 4 ||
|
||||
drEvidenceJob.steps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
drEvidenceJob.steps[0]?.with?.['persist-credentials'] !== false ||
|
||||
drEvidenceJob.steps[1]?.uses !==
|
||||
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
|
||||
drEvidenceJob.steps[1]?.with?.['node-version'] !== '24.18.0' ||
|
||||
typeof drEvidenceJob.steps[2]?.run !== 'string' ||
|
||||
drEvidenceJob.steps[3]?.uses !==
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||
JSON.stringify(drEvidenceJob.steps[3]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-cloudnativepg-disaster-recovery',
|
||||
path: '${{ runner.temp }}/ql3-private-release-evidence-receipts/cloudnativepg-disaster-recovery.json',
|
||||
'if-no-files-found': 'error',
|
||||
'retention-days': 1,
|
||||
'compression-level': 9,
|
||||
overwrite: false,
|
||||
'include-hidden-files': false,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'private evidence job must contain only the reviewed credential-free checkout, Node setup and audit steps',
|
||||
'private evidence jobs must contain only reviewed credential-free checkout, audit, content-free receipt and upload steps',
|
||||
);
|
||||
}
|
||||
if (/actions\/cache@/.test(source)) {
|
||||
@@ -951,6 +1000,16 @@ function auditReleaseWorkflow(source) {
|
||||
/GITHUB_REF_TYPE}" != "tag"[\s\S]*GITHUB_REF_NAME}" != "v\$\{RELEASE_VERSION\}"/,
|
||||
'private evidence gate must bind dispatch version to the exact tag',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/ql3-private-release-evidence-receipt-contract\.cjs[\s\S]*--mode=worker-create[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--output="\$\{receipt\}"[\s\S]*--mode=audit[\s\S]*--evidence-kind=worker-management[\s\S]*--receipt="\$\{receipt\}"/,
|
||||
'Worker evidence job must create and audit only one content-free source-bound receipt',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/ql3-private-release-evidence-receipt-contract\.cjs[\s\S]*--mode=dr-create[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--report="\$\{report\}"[\s\S]*--output="\$\{receipt\}"[\s\S]*--mode=audit[\s\S]*--evidence-kind=cloudnativepg-disaster-recovery[\s\S]*--receipt="\$\{receipt\}"/,
|
||||
'disaster-recovery evidence job must create and audit only one content-free source-bound receipt',
|
||||
);
|
||||
requireExactOccurrences(
|
||||
source,
|
||||
/evidence_dir="\/run\/qinglong3-release-evidence\/\$\{GITHUB_SHA\}"/g,
|
||||
@@ -1199,13 +1258,15 @@ function auditReleaseWorkflow(source) {
|
||||
sourceAware: true,
|
||||
privateEphemeralRunner: true,
|
||||
maximumAgeSeconds: 86400,
|
||||
artifactUpload: false,
|
||||
privateReportUpload: false,
|
||||
contentFreeReceiptPublished: true,
|
||||
},
|
||||
cloudNativePgDisasterRecoveryEvidence: {
|
||||
sourceAware: true,
|
||||
privateEphemeralRunner: true,
|
||||
maximumAgeSeconds: 86400,
|
||||
artifactUpload: false,
|
||||
privateReportUpload: false,
|
||||
contentFreeReceiptPublished: true,
|
||||
staticLocksReaudited: true,
|
||||
},
|
||||
osVulnerabilityScan: {
|
||||
@@ -1241,6 +1302,7 @@ function auditReleaseWorkflow(source) {
|
||||
releaseSet: {
|
||||
sourceDerived: true,
|
||||
sameRunRecords: true,
|
||||
sameRunPrivateEvidenceReceipts: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
@@ -1250,7 +1312,7 @@ function auditReleaseWorkflow(source) {
|
||||
},
|
||||
durableCatalog: {
|
||||
repository: 'qinglong3-release-catalog',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v1+json',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v2+json',
|
||||
basenameOnly: true,
|
||||
crossRunnerDeterministic: true,
|
||||
byteExactRoundTrip: true,
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
auditWorkerCredentialManagementReleaseGate,
|
||||
} = require('./ql3-worker-credential-management-release-gate.cjs');
|
||||
const {
|
||||
validateCloudNativePgDrReleaseEvidence,
|
||||
} = require('./ql3-cloudnativepg-dr-evidence-audit.cjs');
|
||||
const {
|
||||
auditCloudNativePgBackup,
|
||||
} = require('./ql3-cloudnativepg-backup-audit.cjs');
|
||||
const {
|
||||
auditBarmanCloudSupplyChain,
|
||||
} = require('./ql3-barman-cloud-supply-chain-audit.cjs');
|
||||
const {
|
||||
auditCertManagerSelection,
|
||||
} = require('./ql3-cert-manager-selection-audit.cjs');
|
||||
|
||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1';
|
||||
const EVIDENCE_KINDS = Object.freeze([
|
||||
'worker-management',
|
||||
'cloudnativepg-disaster-recovery',
|
||||
]);
|
||||
const EVIDENCE_FIXTURES = Object.freeze({
|
||||
'worker-management': 'qinglong/worker-credential-management-release-gate@v1',
|
||||
'cloudnativepg-disaster-recovery':
|
||||
'qinglong/cloudnativepg-disaster-recovery@v1',
|
||||
});
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const MAX_EVIDENCE_AGE_SECONDS = 24 * 60 * 60;
|
||||
const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const REVISION_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
const VERSION_PATTERN =
|
||||
/^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u;
|
||||
const STATIC_AUDIT_NAMES = Object.freeze([
|
||||
'cloudnativepg-backup',
|
||||
'barman-cloud-supply-chain',
|
||||
'cert-manager-selection',
|
||||
]);
|
||||
|
||||
class QingLong3PrivateReleaseEvidenceReceiptError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3 private release evidence receipt failed: ${message}`);
|
||||
this.name = 'QingLong3PrivateReleaseEvidenceReceiptError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new QingLong3PrivateReleaseEvidenceReceiptError(message);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return `${JSON.stringify(value)}\n`;
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalAbsolute(filePath, label) {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
!path.isAbsolute(filePath) ||
|
||||
path.resolve(filePath) !== filePath
|
||||
) {
|
||||
fail(`${label} path must be canonical and absolute`);
|
||||
}
|
||||
if (fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath)) {
|
||||
fail(`${label} parent must be canonical`);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function readJsonFile(filePath, label, privateInput = false) {
|
||||
const resolved = canonicalAbsolute(filePath, label);
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
const descriptor = fs.openSync(resolved, flags);
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size < 2 ||
|
||||
before.size > MAX_JSON_BYTES ||
|
||||
fs.realpathSync(resolved) !== resolved ||
|
||||
(privateInput &&
|
||||
(before.uid !== process.getuid() || (before.mode & 0o777) !== 0o600))
|
||||
) {
|
||||
fail(`${label} must be one bounded canonical regular file`);
|
||||
}
|
||||
const bytes = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor);
|
||||
if (
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size ||
|
||||
before.mtimeMs !== after.mtimeMs ||
|
||||
bytes.length !== before.size
|
||||
) {
|
||||
bytes.fill(0);
|
||||
fail(`${label} changed while being read`);
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
bytes.fill(0);
|
||||
fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
if (canonicalJson(value) !== bytes.toString('utf8')) {
|
||||
bytes.fill(0);
|
||||
fail(`${label} must use exact canonical JSON encoding`);
|
||||
}
|
||||
return Object.freeze({ value, bytes });
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function writeNoReplace(filePath, value) {
|
||||
const resolved = canonicalAbsolute(filePath, 'output');
|
||||
if (fs.existsSync(resolved)) fail('output must not already exist');
|
||||
fs.writeFileSync(resolved, canonicalJson(value), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
}
|
||||
|
||||
function validateIdentity(options) {
|
||||
if (
|
||||
!VERSION_PATTERN.test(options.version || '') ||
|
||||
!REVISION_PATTERN.test(options.sourceRevision || '') ||
|
||||
options.sourceRef !== `refs/tags/v${options.version}` ||
|
||||
!['cluster', 'all'].includes(options.releaseScope)
|
||||
) {
|
||||
fail('release identity must be one exact Cluster-capable QingLong 3 tag');
|
||||
}
|
||||
if (!EVIDENCE_KINDS.includes(options.evidenceKind)) {
|
||||
fail('evidence kind is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function staticAuditReceipts(root) {
|
||||
const reports = [
|
||||
['cloudnativepg-backup', auditCloudNativePgBackup({ root })],
|
||||
['barman-cloud-supply-chain', auditBarmanCloudSupplyChain({ root })],
|
||||
['cert-manager-selection', auditCertManagerSelection({ root })],
|
||||
];
|
||||
if (reports.some(([, report]) => report.compatible !== true)) {
|
||||
fail('one or more static disaster-recovery locks are incompatible');
|
||||
}
|
||||
return Object.freeze(
|
||||
reports.map(([name, report]) =>
|
||||
Object.freeze({
|
||||
name,
|
||||
auditDigest: sha256(JSON.stringify(report)),
|
||||
compatible: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function assembleReceipt(options) {
|
||||
validateIdentity(options);
|
||||
if (!DIGEST_PATTERN.test(options.reportDigest || '')) {
|
||||
fail('private evidence report digest is invalid');
|
||||
}
|
||||
const observedAtMs = Date.parse(options.observedAt);
|
||||
const validatedAtMs = options.nowMs;
|
||||
if (
|
||||
!Number.isSafeInteger(validatedAtMs) ||
|
||||
!Number.isFinite(observedAtMs) ||
|
||||
observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS ||
|
||||
validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
||||
) {
|
||||
fail('private evidence is outside the release freshness window');
|
||||
}
|
||||
const staticAudits =
|
||||
options.evidenceKind === 'cloudnativepg-disaster-recovery'
|
||||
? staticAuditReceipts(options.root)
|
||||
: Object.freeze([]);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: RECEIPT_SCHEMA,
|
||||
release: {
|
||||
version: options.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
sourceRef: options.sourceRef,
|
||||
scope: options.releaseScope,
|
||||
},
|
||||
evidenceKind: options.evidenceKind,
|
||||
evidence: {
|
||||
fixture: options.fixture,
|
||||
observedAt: options.observedAt,
|
||||
validatedAt: new Date(validatedAtMs).toISOString(),
|
||||
maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS,
|
||||
reportDigest: options.reportDigest,
|
||||
sourceReportsUploaded: false,
|
||||
},
|
||||
staticAudits,
|
||||
verification: {
|
||||
sourceAwareAudit: true,
|
||||
privateEvidenceReplayed: true,
|
||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||
privateReportContentPublished: false,
|
||||
compatible: true,
|
||||
},
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
receiptDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function createWorkerReceipt(
|
||||
options,
|
||||
dependencies = {
|
||||
now: Date.now,
|
||||
auditGate: auditWorkerCredentialManagementReleaseGate,
|
||||
},
|
||||
) {
|
||||
const nowMs = dependencies.now();
|
||||
const gate = dependencies.auditGate(
|
||||
{
|
||||
reportFile: options.reportFile,
|
||||
ceremonyReportFile: options.ceremonyReportFile,
|
||||
durableAuditReportFile: options.durableAuditReportFile,
|
||||
pkiRotationReportFile: options.pkiRotationReportFile,
|
||||
caRolloverReportFile: options.caRolloverReportFile,
|
||||
sourceCommit: options.sourceRevision,
|
||||
releaseVersion: options.version,
|
||||
},
|
||||
{ now: () => nowMs },
|
||||
);
|
||||
const report = readJsonFile(
|
||||
options.reportFile,
|
||||
'Worker management evidence',
|
||||
true,
|
||||
);
|
||||
try {
|
||||
const receipt = assembleReceipt({
|
||||
...options,
|
||||
evidenceKind: 'worker-management',
|
||||
fixture: gate.fixture,
|
||||
observedAt: report.value.observedAt,
|
||||
reportDigest: gate.evidenceReportSha256,
|
||||
nowMs,
|
||||
});
|
||||
writeNoReplace(options.outputFile, receipt);
|
||||
return receipt;
|
||||
} finally {
|
||||
report.bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function createCloudNativePgReceipt(
|
||||
options,
|
||||
dependencies = {
|
||||
now: Date.now,
|
||||
validateEvidence: validateCloudNativePgDrReleaseEvidence,
|
||||
},
|
||||
) {
|
||||
const nowMs = dependencies.now();
|
||||
const report = readJsonFile(
|
||||
options.reportFile,
|
||||
'CloudNativePG disaster-recovery evidence',
|
||||
true,
|
||||
);
|
||||
try {
|
||||
const audit = dependencies.validateEvidence(report.value, {
|
||||
sourceCommit: options.sourceRevision,
|
||||
releaseVersion: options.version,
|
||||
nowMs,
|
||||
});
|
||||
if (!audit.compatible) {
|
||||
fail('CloudNativePG disaster-recovery evidence is incompatible');
|
||||
}
|
||||
const receipt = assembleReceipt({
|
||||
...options,
|
||||
evidenceKind: 'cloudnativepg-disaster-recovery',
|
||||
fixture: audit.fixture,
|
||||
observedAt: report.value.observedAt,
|
||||
reportDigest: sha256(report.bytes),
|
||||
nowMs,
|
||||
});
|
||||
writeNoReplace(options.outputFile, receipt);
|
||||
return receipt;
|
||||
} finally {
|
||||
report.bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
||||
validateIdentity(options);
|
||||
const expectedStaticNames =
|
||||
options.evidenceKind === 'cloudnativepg-disaster-recovery'
|
||||
? STATIC_AUDIT_NAMES
|
||||
: [];
|
||||
if (
|
||||
!exactKeys(actual, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'release',
|
||||
'evidenceKind',
|
||||
'evidence',
|
||||
'staticAudits',
|
||||
'verification',
|
||||
'receiptDigest',
|
||||
]) ||
|
||||
actual.schemaVersion !== 1 ||
|
||||
actual.schema !== RECEIPT_SCHEMA ||
|
||||
!exactKeys(actual.release, [
|
||||
'version',
|
||||
'sourceRevision',
|
||||
'sourceRef',
|
||||
'scope',
|
||||
]) ||
|
||||
JSON.stringify(actual.release) !==
|
||||
JSON.stringify({
|
||||
version: options.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
sourceRef: options.sourceRef,
|
||||
scope: options.releaseScope,
|
||||
}) ||
|
||||
actual.evidenceKind !== options.evidenceKind ||
|
||||
!exactKeys(actual.evidence, [
|
||||
'fixture',
|
||||
'observedAt',
|
||||
'validatedAt',
|
||||
'maximumAgeSeconds',
|
||||
'reportDigest',
|
||||
'sourceReportsUploaded',
|
||||
]) ||
|
||||
actual.evidence.fixture !== EVIDENCE_FIXTURES[options.evidenceKind] ||
|
||||
actual.evidence.maximumAgeSeconds !== MAX_EVIDENCE_AGE_SECONDS ||
|
||||
!DIGEST_PATTERN.test(actual.evidence.reportDigest || '') ||
|
||||
actual.evidence.sourceReportsUploaded !== false ||
|
||||
!Array.isArray(actual.staticAudits) ||
|
||||
JSON.stringify(actual.staticAudits.map((entry) => entry?.name)) !==
|
||||
JSON.stringify(expectedStaticNames) ||
|
||||
actual.staticAudits.some(
|
||||
(entry) =>
|
||||
!exactKeys(entry, ['name', 'auditDigest', 'compatible']) ||
|
||||
!DIGEST_PATTERN.test(entry.auditDigest || '') ||
|
||||
entry.compatible !== true,
|
||||
) ||
|
||||
!exactKeys(actual.verification, [
|
||||
'sourceAwareAudit',
|
||||
'privateEvidenceReplayed',
|
||||
'publicConsumerReplay',
|
||||
'privateReportContentPublished',
|
||||
'compatible',
|
||||
]) ||
|
||||
JSON.stringify(actual.verification) !==
|
||||
JSON.stringify({
|
||||
sourceAwareAudit: true,
|
||||
privateEvidenceReplayed: true,
|
||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||
privateReportContentPublished: false,
|
||||
compatible: true,
|
||||
})
|
||||
) {
|
||||
fail('receipt shape or release binding is invalid');
|
||||
}
|
||||
const observedAtMs = Date.parse(actual.evidence.observedAt);
|
||||
const validatedAtMs = Date.parse(actual.evidence.validatedAt);
|
||||
if (
|
||||
!Number.isFinite(observedAtMs) ||
|
||||
!Number.isFinite(validatedAtMs) ||
|
||||
observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS ||
|
||||
validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
||||
) {
|
||||
fail('receipt freshness binding is invalid');
|
||||
}
|
||||
const { receiptDigest, ...unsigned } = actual;
|
||||
if (
|
||||
!DIGEST_PATTERN.test(receiptDigest || '') ||
|
||||
receiptDigest !== sha256(JSON.stringify(unsigned))
|
||||
) {
|
||||
fail('receipt digest is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
compatible: true,
|
||||
evidenceKind: actual.evidenceKind,
|
||||
receiptDigest,
|
||||
reportDigest: actual.evidence.reportDigest,
|
||||
publicConsumerReplay: actual.verification.publicConsumerReplay,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (const argument of argv) {
|
||||
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
|
||||
if (!match || Object.hasOwn(values, match[1]))
|
||||
fail('arguments are invalid');
|
||||
values[match[1]] = match[2];
|
||||
}
|
||||
const identity = [
|
||||
'release-scope',
|
||||
'source-ref',
|
||||
'source-revision',
|
||||
'version',
|
||||
];
|
||||
const expected =
|
||||
values.mode === 'worker-create'
|
||||
? [
|
||||
'mode',
|
||||
...identity,
|
||||
'report',
|
||||
'ceremony-report',
|
||||
'durable-audit-report',
|
||||
'pki-rotation-report',
|
||||
'ca-rollover-report',
|
||||
'output',
|
||||
]
|
||||
: values.mode === 'dr-create'
|
||||
? ['mode', ...identity, 'report', 'output']
|
||||
: values.mode === 'audit'
|
||||
? ['mode', ...identity, 'evidence-kind', 'receipt']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(expected.sort())
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: values.mode,
|
||||
version: values.version,
|
||||
sourceRevision: values['source-revision'],
|
||||
sourceRef: values['source-ref'],
|
||||
releaseScope: values['release-scope'],
|
||||
...(values['evidence-kind']
|
||||
? { evidenceKind: values['evidence-kind'] }
|
||||
: {}),
|
||||
...(values.report ? { reportFile: values.report } : {}),
|
||||
...(values['ceremony-report']
|
||||
? { ceremonyReportFile: values['ceremony-report'] }
|
||||
: {}),
|
||||
...(values['durable-audit-report']
|
||||
? { durableAuditReportFile: values['durable-audit-report'] }
|
||||
: {}),
|
||||
...(values['pki-rotation-report']
|
||||
? { pkiRotationReportFile: values['pki-rotation-report'] }
|
||||
: {}),
|
||||
...(values['ca-rollover-report']
|
||||
? { caRolloverReportFile: values['ca-rollover-report'] }
|
||||
: {}),
|
||||
...(values.output ? { outputFile: values.output } : {}),
|
||||
...(values.receipt ? { receiptFile: values.receipt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(
|
||||
argv,
|
||||
root = path.resolve(__dirname, '..'),
|
||||
output = process.stdout,
|
||||
) {
|
||||
const options = parseArguments(argv);
|
||||
if (options.mode === 'audit') {
|
||||
const receipt = readJsonFile(options.receiptFile, 'receipt').value;
|
||||
const result = inspectPrivateReleaseEvidenceReceipt(receipt, options);
|
||||
output.write(canonicalJson(result));
|
||||
return result;
|
||||
}
|
||||
const createOptions = { ...options, root };
|
||||
const receipt =
|
||||
options.mode === 'worker-create'
|
||||
? createWorkerReceipt(createOptions)
|
||||
: createCloudNativePgReceipt(createOptions);
|
||||
output.write(canonicalJson(receipt));
|
||||
return receipt;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
runCli(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : 'receipt contract failed'}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
EVIDENCE_KINDS,
|
||||
RECEIPT_SCHEMA,
|
||||
QingLong3PrivateReleaseEvidenceReceiptError,
|
||||
createCloudNativePgReceipt,
|
||||
createWorkerReceipt,
|
||||
inspectPrivateReleaseEvidenceReceipt,
|
||||
parseArguments,
|
||||
runCli,
|
||||
});
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
|
||||
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
|
||||
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
|
||||
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v1+json';
|
||||
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v2+json';
|
||||
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
|
||||
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
||||
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
|
||||
|
||||
@@ -12,11 +12,15 @@ const {
|
||||
SCHEMA: RELEASE_CANDIDATE_SCHEMA,
|
||||
auditReleaseCandidateContract,
|
||||
} = require('./ql3-release-candidate-contract.cjs');
|
||||
const {
|
||||
EVIDENCE_KINDS,
|
||||
inspectPrivateReleaseEvidenceReceipt,
|
||||
} = require('./ql3-private-release-evidence-receipt-contract.cjs');
|
||||
const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
|
||||
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v1';
|
||||
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v2';
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
|
||||
@@ -237,9 +241,48 @@ function deploymentFamily(candidate, family, imageNames) {
|
||||
});
|
||||
}
|
||||
|
||||
function expectedEvidenceKinds(releaseScope) {
|
||||
return releaseScope === 'local' ? [] : [...EVIDENCE_KINDS];
|
||||
}
|
||||
|
||||
function validateEvidenceReceipts(receipts, candidate) {
|
||||
if (!Array.isArray(receipts)) {
|
||||
fail('private release evidence receipts must be an array');
|
||||
}
|
||||
const expectedKinds = expectedEvidenceKinds(candidate.release.scope);
|
||||
if (receipts.length !== expectedKinds.length) {
|
||||
fail('private release evidence receipt count differs from release scope');
|
||||
}
|
||||
const receiptsByKind = new Map();
|
||||
for (const receipt of receipts) {
|
||||
const evidenceKind = receipt?.evidenceKind;
|
||||
if (
|
||||
!expectedKinds.includes(evidenceKind) ||
|
||||
receiptsByKind.has(evidenceKind)
|
||||
) {
|
||||
fail('private release evidence receipts must be exact and unique');
|
||||
}
|
||||
inspectPrivateReleaseEvidenceReceipt(receipt, {
|
||||
version: candidate.release.version,
|
||||
sourceRevision: candidate.release.sourceRevision,
|
||||
sourceRef: candidate.release.sourceRef,
|
||||
releaseScope: candidate.release.scope,
|
||||
evidenceKind,
|
||||
});
|
||||
receiptsByKind.set(evidenceKind, receipt);
|
||||
}
|
||||
return Object.freeze(
|
||||
expectedKinds.map((evidenceKind) => receiptsByKind.get(evidenceKind)),
|
||||
);
|
||||
}
|
||||
|
||||
function createReleaseSet(options) {
|
||||
const candidate = verifyCandidate(options.candidate, options);
|
||||
const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner);
|
||||
const evidenceReceipts = validateEvidenceReceipts(
|
||||
options.evidenceReceipts,
|
||||
candidate,
|
||||
);
|
||||
if (!Array.isArray(options.records)) fail('image records must be an array');
|
||||
if (options.records.length !== candidate.images.length) {
|
||||
fail('image record count differs from the release candidate');
|
||||
@@ -281,6 +324,7 @@ function createReleaseSet(options) {
|
||||
local: deploymentFamily(candidate, 'local', localImages),
|
||||
cluster: deploymentFamily(candidate, 'cluster', clusterImages),
|
||||
},
|
||||
evidenceReceipts,
|
||||
images,
|
||||
promotion: {
|
||||
authority: 'complete_verified_release_set',
|
||||
@@ -292,6 +336,9 @@ function createReleaseSet(options) {
|
||||
requiredVerification: {
|
||||
imageKeylessSignature: true,
|
||||
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
|
||||
privateReleaseEvidenceReceipts: expectedEvidenceKinds(
|
||||
candidate.release.scope,
|
||||
),
|
||||
releaseSetBuildProvenance: true,
|
||||
},
|
||||
};
|
||||
@@ -313,6 +360,9 @@ function auditReleaseSet(actual, options) {
|
||||
imageCount: actual.images.length,
|
||||
images: Object.freeze(actual.images.map((entry) => entry.name)),
|
||||
references: Object.freeze(actual.images.map((entry) => entry.reference)),
|
||||
evidenceReceiptDigests: Object.freeze(
|
||||
actual.evidenceReceipts.map((entry) => entry.receiptDigest),
|
||||
),
|
||||
tagPromotionAuthority: actual.promotion.authority,
|
||||
});
|
||||
}
|
||||
@@ -337,6 +387,7 @@ function inspectReleaseSet(actual, options) {
|
||||
'repositoryOwner',
|
||||
'platforms',
|
||||
'deploymentFamilies',
|
||||
'evidenceReceipts',
|
||||
'images',
|
||||
'promotion',
|
||||
'requiredVerification',
|
||||
@@ -361,6 +412,7 @@ function inspectReleaseSet(actual, options) {
|
||||
JSON.stringify(actual.platforms) !==
|
||||
JSON.stringify(['linux/amd64', 'linux/arm64']) ||
|
||||
!exactKeys(actual.deploymentFamilies, ['local', 'cluster']) ||
|
||||
!Array.isArray(actual.evidenceReceipts) ||
|
||||
!Array.isArray(actual.images) ||
|
||||
!exactKeys(actual.promotion, [
|
||||
'authority',
|
||||
@@ -380,12 +432,16 @@ function inspectReleaseSet(actual, options) {
|
||||
!exactKeys(actual.requiredVerification, [
|
||||
'imageKeylessSignature',
|
||||
'imageAttestations',
|
||||
'privateReleaseEvidenceReceipts',
|
||||
'releaseSetBuildProvenance',
|
||||
]) ||
|
||||
JSON.stringify(actual.requiredVerification) !==
|
||||
JSON.stringify({
|
||||
imageKeylessSignature: true,
|
||||
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
|
||||
privateReleaseEvidenceReceipts: expectedEvidenceKinds(
|
||||
options.releaseScope,
|
||||
),
|
||||
releaseSetBuildProvenance: true,
|
||||
})
|
||||
) {
|
||||
@@ -448,6 +504,20 @@ function inspectReleaseSet(actual, options) {
|
||||
) {
|
||||
fail('standalone release set deployment families are invalid');
|
||||
}
|
||||
const expectedReceiptKinds = expectedEvidenceKinds(options.releaseScope);
|
||||
if (actual.evidenceReceipts.length !== expectedReceiptKinds.length) {
|
||||
fail('standalone release set evidence receipt count is invalid');
|
||||
}
|
||||
for (let index = 0; index < expectedReceiptKinds.length; index += 1) {
|
||||
const evidenceKind = expectedReceiptKinds[index];
|
||||
inspectPrivateReleaseEvidenceReceipt(actual.evidenceReceipts[index], {
|
||||
version: options.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
sourceRef: options.sourceRef,
|
||||
releaseScope: options.releaseScope,
|
||||
evidenceKind,
|
||||
});
|
||||
}
|
||||
const { releaseSetDigest, ...unsigned } = actual;
|
||||
if (
|
||||
!DIGEST_PATTERN.test(releaseSetDigest || '') ||
|
||||
@@ -462,8 +532,12 @@ function inspectReleaseSet(actual, options) {
|
||||
imageCount: actual.images.length,
|
||||
images: Object.freeze([...expectedNames]),
|
||||
references: Object.freeze(actual.images.map((entry) => entry.reference)),
|
||||
evidenceReceiptDigests: Object.freeze(
|
||||
actual.evidenceReceipts.map((entry) => entry.receiptDigest),
|
||||
),
|
||||
verification: 'standalone_structure_identity_and_self_digest',
|
||||
sourceRecordsReplayed: false,
|
||||
privateEvidenceReplayed: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,6 +566,33 @@ function readRecordDirectory(directoryPath, candidate) {
|
||||
);
|
||||
}
|
||||
|
||||
function readEvidenceReceiptDirectory(directoryPath, candidate) {
|
||||
const resolved = resolveCanonicalAbsolute(
|
||||
directoryPath,
|
||||
'private evidence receipts',
|
||||
);
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail('private evidence receipts must be one canonical directory');
|
||||
}
|
||||
const expectedNames = expectedEvidenceKinds(candidate.release.scope).map(
|
||||
(kind) => `${kind}.json`,
|
||||
);
|
||||
const actualNames = fs.readdirSync(resolved).sort();
|
||||
if (
|
||||
JSON.stringify(actualNames) !== JSON.stringify([...expectedNames].sort())
|
||||
) {
|
||||
fail('private evidence receipt directory differs from release scope');
|
||||
}
|
||||
return expectedNames.map((name) =>
|
||||
readCanonicalJson(path.join(resolved, name), 'private evidence receipt'),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (const argument of argv) {
|
||||
@@ -513,9 +614,9 @@ function parseArguments(argv) {
|
||||
values.mode === 'record-image'
|
||||
? [...common, 'digest', 'image', 'output']
|
||||
: values.mode === 'aggregate'
|
||||
? [...common, 'output', 'records']
|
||||
? [...common, 'evidence-receipts', 'output', 'records']
|
||||
: values.mode === 'audit'
|
||||
? [...common, 'records', 'report']
|
||||
? [...common, 'evidence-receipts', 'records', 'report']
|
||||
: values.mode === 'inspect'
|
||||
? [...identity, 'report']
|
||||
: [];
|
||||
@@ -537,6 +638,9 @@ function parseArguments(argv) {
|
||||
...(values.image ? { image: values.image } : {}),
|
||||
...(values.digest ? { digest: values.digest } : {}),
|
||||
...(values.records ? { records: values.records } : {}),
|
||||
...(values['evidence-receipts']
|
||||
? { evidenceReceipts: values['evidence-receipts'] }
|
||||
: {}),
|
||||
...(values.output ? { output: values.output } : {}),
|
||||
...(values.report ? { report: values.report } : {}),
|
||||
});
|
||||
@@ -558,11 +662,16 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
return record;
|
||||
}
|
||||
const records = readRecordDirectory(options.records, candidate);
|
||||
const evidenceReceipts = readEvidenceReceiptDirectory(
|
||||
options.evidenceReceipts,
|
||||
candidate,
|
||||
);
|
||||
if (options.mode === 'aggregate') {
|
||||
const releaseSet = createReleaseSet({
|
||||
...options,
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts,
|
||||
root,
|
||||
});
|
||||
writeNoReplace(options.output, releaseSet);
|
||||
@@ -574,6 +683,7 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
...options,
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts,
|
||||
root,
|
||||
});
|
||||
output.write(canonicalJson(audit));
|
||||
|
||||
@@ -54,13 +54,15 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
sourceAware: true,
|
||||
privateEphemeralRunner: true,
|
||||
maximumAgeSeconds: 86400,
|
||||
artifactUpload: false,
|
||||
privateReportUpload: false,
|
||||
contentFreeReceiptPublished: true,
|
||||
},
|
||||
cloudNativePgDisasterRecoveryEvidence: {
|
||||
sourceAware: true,
|
||||
privateEphemeralRunner: true,
|
||||
maximumAgeSeconds: 86400,
|
||||
artifactUpload: false,
|
||||
privateReportUpload: false,
|
||||
contentFreeReceiptPublished: true,
|
||||
staticLocksReaudited: true,
|
||||
},
|
||||
osVulnerabilityScan: {
|
||||
@@ -96,6 +98,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
releaseSet: {
|
||||
sourceDerived: true,
|
||||
sameRunRecords: true,
|
||||
sameRunPrivateEvidenceReceipts: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
@@ -105,7 +108,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
},
|
||||
durableCatalog: {
|
||||
repository: 'qinglong3-release-catalog',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v1+json',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v2+json',
|
||||
basenameOnly: true,
|
||||
crossRunnerDeterministic: true,
|
||||
byteExactRoundTrip: true,
|
||||
@@ -175,6 +178,17 @@ test('rejects removal of the durable release-catalog contract tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the private release evidence receipt contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs',
|
||||
'test/back/private-receipt-tests-removed.test.cjs',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/private evidence receipt/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the deployment-lock materialization contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3DeploymentLockContract.test.cjs',
|
||||
@@ -470,14 +484,14 @@ test('rejects a reusable private evidence runner', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects private evidence uploaded as a workflow artifact', () => {
|
||||
test('rejects private report content uploaded instead of its digest-only receipt', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' - name: Re-audit commit-scoped private production evidence',
|
||||
' - uses: actions/upload-artifact@v4\n - name: Re-audit commit-scoped private production evidence',
|
||||
'path: ${{ runner.temp }}/ql3-private-release-evidence-receipts/worker-management.json',
|
||||
'path: /run/qinglong3-release-evidence/${{ github.sha }}/worker-management-release-evidence.json',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/only the reviewed|never persist private evidence/,
|
||||
/content-free receipt and upload steps/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -655,8 +669,8 @@ test('rejects scanning a daemon tag instead of the exact OCI tar', () => {
|
||||
|
||||
test('rejects retaining scanned native release artifacts for more than one day', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' retention-days: 1',
|
||||
' retention-days: 2',
|
||||
'name: ql3-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}-${{ matrix.image_arch }}\n path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 1',
|
||||
'name: ql3-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}-${{ matrix.image_arch }}\n path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 2',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
@@ -666,8 +680,8 @@ test('rejects retaining scanned native release artifacts for more than one day',
|
||||
|
||||
test('rejects overwrite authority on a scanned native artifact', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' overwrite: false',
|
||||
' overwrite: true',
|
||||
'path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 1\n compression-level: 0\n overwrite: false',
|
||||
'path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 1\n compression-level: 0\n overwrite: true',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
@@ -676,10 +690,16 @@ test('rejects overwrite authority on a scanned native artifact', () => {
|
||||
});
|
||||
|
||||
test('rejects a movable upload-artifact action', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1',
|
||||
'actions/upload-artifact@v7.0.1',
|
||||
);
|
||||
const marker =
|
||||
' - name: Upload the scanned immutable native OCI artifact';
|
||||
const offset = releaseSource.indexOf(marker);
|
||||
assert.notEqual(offset, -1);
|
||||
const mutated = `${releaseSource.slice(0, offset)}${releaseSource
|
||||
.slice(offset)
|
||||
.replace(
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1',
|
||||
'actions/upload-artifact@v7.0.1',
|
||||
)}`;
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/upload only bound immutable evidence/,
|
||||
@@ -864,6 +884,34 @@ test('rejects release-set closure before every image publisher succeeds', () =>
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /complete publish matrix/);
|
||||
});
|
||||
|
||||
test('rejects release-set closure detached from same-run private evidence receipts', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-*',
|
||||
'ql3-private-release-evidence-${{ github.run_id }}-*',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects release-set aggregation that ignores private evidence receipts', () => {
|
||||
const marker =
|
||||
' - name: Aggregate and independently audit the complete release set';
|
||||
const offset = releaseSource.indexOf(marker);
|
||||
assert.notEqual(offset, -1);
|
||||
const mutated = `${releaseSource.slice(0, offset)}${releaseSource
|
||||
.slice(offset)
|
||||
.replace(
|
||||
' --evidence-receipts="${evidence_receipts}" \\',
|
||||
' --evidence-receipts="${RUNNER_TEMP}/empty" \\',
|
||||
)}`;
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects image records detached from the same workflow attempt', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-*',
|
||||
|
||||
@@ -42,6 +42,9 @@ const {
|
||||
catalogLockedArtifacts,
|
||||
deploymentArtifacts,
|
||||
} = require('../../scripts/ql3-kubernetes-deployment-live-contract.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
@@ -97,6 +100,7 @@ function releaseSet(scope) {
|
||||
root,
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
|
||||
...identity,
|
||||
releaseScope: scope,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
createCloudNativePgReceipt,
|
||||
createWorkerReceipt,
|
||||
inspectPrivateReleaseEvidenceReceipt,
|
||||
parseArguments,
|
||||
runCli,
|
||||
} = require('../../scripts/ql3-private-release-evidence-receipt-contract.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipt,
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
|
||||
const RELEASE = Object.freeze({
|
||||
version: '3.0.0-alpha.0',
|
||||
sourceRevision: 'a'.repeat(40),
|
||||
sourceRef: 'refs/tags/v3.0.0-alpha.0',
|
||||
scope: 'cluster',
|
||||
});
|
||||
|
||||
function options(evidenceKind) {
|
||||
return Object.freeze({
|
||||
version: RELEASE.version,
|
||||
sourceRevision: RELEASE.sourceRevision,
|
||||
sourceRef: RELEASE.sourceRef,
|
||||
releaseScope: RELEASE.scope,
|
||||
evidenceKind,
|
||||
});
|
||||
}
|
||||
|
||||
function temporaryDirectory(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-private-release-receipt-')),
|
||||
);
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
test('accepts only the exact Worker and disaster-recovery receipt projections', () => {
|
||||
const receipts = privateReleaseEvidenceReceipts(RELEASE);
|
||||
assert.deepEqual(
|
||||
receipts.map((receipt) => receipt.evidenceKind),
|
||||
['worker-management', 'cloudnativepg-disaster-recovery'],
|
||||
);
|
||||
for (const receipt of receipts) {
|
||||
const result = inspectPrivateReleaseEvidenceReceipt(
|
||||
receipt,
|
||||
options(receipt.evidenceKind),
|
||||
);
|
||||
assert.equal(result.compatible, true);
|
||||
assert.equal(result.receiptDigest, receipt.receiptDigest);
|
||||
assert.equal(
|
||||
result.publicConsumerReplay,
|
||||
'not_possible_without_private_reports',
|
||||
);
|
||||
assert.equal(JSON.stringify(receipt).includes('/run/qinglong3'), false);
|
||||
assert.equal(JSON.stringify(receipt).includes('token'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects source, scope, freshness, digest and static-lock drift', () => {
|
||||
const mutations = [
|
||||
(receipt) => {
|
||||
receipt.release.sourceRevision = 'b'.repeat(40);
|
||||
},
|
||||
(receipt) => {
|
||||
receipt.release.scope = 'all';
|
||||
},
|
||||
(receipt) => {
|
||||
receipt.evidence.observedAt = '2026-08-16T00:00:00.000Z';
|
||||
},
|
||||
(receipt) => {
|
||||
receipt.evidence.reportDigest = `sha256:${'0'.repeat(64)}`;
|
||||
},
|
||||
(receipt) => {
|
||||
receipt.staticAudits.pop();
|
||||
},
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const receipt = JSON.parse(
|
||||
JSON.stringify(
|
||||
privateReleaseEvidenceReceipt(
|
||||
RELEASE,
|
||||
'cloudnativepg-disaster-recovery',
|
||||
),
|
||||
),
|
||||
);
|
||||
mutate(receipt);
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectPrivateReleaseEvidenceReceipt(
|
||||
receipt,
|
||||
options('cloudnativepg-disaster-recovery'),
|
||||
),
|
||||
/receipt|freshness/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects publishing private material in an otherwise re-digested receipt', () => {
|
||||
const receipt = JSON.parse(
|
||||
JSON.stringify(privateReleaseEvidenceReceipt(RELEASE, 'worker-management')),
|
||||
);
|
||||
receipt.evidence.privateReport = { credential: 'must-not-publish' };
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectPrivateReleaseEvidenceReceipt(
|
||||
receipt,
|
||||
options('worker-management'),
|
||||
),
|
||||
/receipt shape/,
|
||||
);
|
||||
});
|
||||
|
||||
test('audit CLI accepts a canonical receipt and rejects open argument shapes', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const receiptPath = path.join(directory, 'worker-management.json');
|
||||
const receipt = privateReleaseEvidenceReceipt(RELEASE, 'worker-management');
|
||||
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
const args = [
|
||||
'--mode=audit',
|
||||
`--version=${RELEASE.version}`,
|
||||
`--source-revision=${RELEASE.sourceRevision}`,
|
||||
`--source-ref=${RELEASE.sourceRef}`,
|
||||
`--release-scope=${RELEASE.scope}`,
|
||||
'--evidence-kind=worker-management',
|
||||
`--receipt=${receiptPath}`,
|
||||
];
|
||||
const writes = [];
|
||||
assert.equal(
|
||||
runCli(args, path.resolve(__dirname, '../..'), {
|
||||
write(value) {
|
||||
writes.push(value);
|
||||
},
|
||||
}).compatible,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(writes.join('')).receiptDigest,
|
||||
receipt.receiptDigest,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArguments([...args, '--extra=true']),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
args.map((entry) =>
|
||||
entry === `--release-scope=${RELEASE.scope}`
|
||||
? '--release-scope=local'
|
||||
: entry,
|
||||
),
|
||||
path.resolve(__dirname, '../..'),
|
||||
{ write() {} },
|
||||
),
|
||||
/Cluster-capable/,
|
||||
);
|
||||
});
|
||||
|
||||
test('create wrappers publish only no-replace digest projections after their source-aware gates', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const reportPath = path.join(directory, 'private-report.json');
|
||||
fs.writeFileSync(
|
||||
reportPath,
|
||||
`${JSON.stringify({ observedAt: '2026-08-18T00:00:00.000Z' })}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const common = {
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
version: RELEASE.version,
|
||||
sourceRevision: RELEASE.sourceRevision,
|
||||
sourceRef: RELEASE.sourceRef,
|
||||
releaseScope: RELEASE.scope,
|
||||
reportFile: reportPath,
|
||||
};
|
||||
const now = () => Date.parse('2026-08-18T00:05:00.000Z');
|
||||
const workerPath = path.join(directory, 'worker.json');
|
||||
const worker = createWorkerReceipt(
|
||||
{
|
||||
...common,
|
||||
ceremonyReportFile: path.join(directory, 'private-ceremony.json'),
|
||||
durableAuditReportFile: path.join(directory, 'private-durable.json'),
|
||||
pkiRotationReportFile: path.join(directory, 'private-pki.json'),
|
||||
caRolloverReportFile: path.join(directory, 'private-ca.json'),
|
||||
outputFile: workerPath,
|
||||
},
|
||||
{
|
||||
now,
|
||||
auditGate(options) {
|
||||
assert.equal(options.sourceCommit, RELEASE.sourceRevision);
|
||||
return {
|
||||
fixture: 'qinglong/worker-credential-management-release-gate@v1',
|
||||
evidenceReportSha256: `sha256:${'5'.repeat(64)}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(worker.staticAudits.length, 0);
|
||||
assert.equal(fs.statSync(workerPath).mode & 0o777, 0o600);
|
||||
assert.equal(JSON.stringify(worker).includes('private-report'), false);
|
||||
|
||||
const drPath = path.join(directory, 'dr.json');
|
||||
const dr = createCloudNativePgReceipt(
|
||||
{ ...common, outputFile: drPath },
|
||||
{
|
||||
now,
|
||||
validateEvidence(_report, options) {
|
||||
assert.equal(options.sourceCommit, RELEASE.sourceRevision);
|
||||
return {
|
||||
fixture: 'qinglong/cloudnativepg-disaster-recovery@v1',
|
||||
compatible: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
dr.staticAudits.map((entry) => entry.name),
|
||||
[
|
||||
'cloudnativepg-backup',
|
||||
'barman-cloud-supply-chain',
|
||||
'cert-manager-selection',
|
||||
],
|
||||
);
|
||||
assert.equal(fs.statSync(drPath).mode & 0o777, 0o600);
|
||||
assert.throws(
|
||||
() =>
|
||||
createCloudNativePgReceipt(
|
||||
{ ...common, outputFile: drPath },
|
||||
{
|
||||
now,
|
||||
validateEvidence() {
|
||||
return {
|
||||
fixture: 'qinglong/cloudnativepg-disaster-recovery@v1',
|
||||
compatible: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
/output must not already exist/,
|
||||
);
|
||||
fs.chmodSync(reportPath, 0o640);
|
||||
assert.throws(
|
||||
() =>
|
||||
createWorkerReceipt(
|
||||
{
|
||||
...common,
|
||||
ceremonyReportFile: path.join(directory, 'private-ceremony.json'),
|
||||
durableAuditReportFile: path.join(directory, 'private-durable.json'),
|
||||
pkiRotationReportFile: path.join(directory, 'private-pki.json'),
|
||||
caRolloverReportFile: path.join(directory, 'private-ca.json'),
|
||||
outputFile: path.join(directory, 'untrusted-worker.json'),
|
||||
},
|
||||
{
|
||||
now,
|
||||
auditGate() {
|
||||
return {
|
||||
fixture: 'qinglong/worker-credential-management-release-gate@v1',
|
||||
evidenceReportSha256: `sha256:${'5'.repeat(64)}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
/bounded canonical regular file/,
|
||||
);
|
||||
});
|
||||
@@ -19,6 +19,9 @@ const {
|
||||
const {
|
||||
auditClusterImageCiWorkflow,
|
||||
} = require('../../scripts/ql3-cluster-image-release-audit.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const SCRIPT = path.join(
|
||||
@@ -53,6 +56,12 @@ function selectedImages(scope) {
|
||||
|
||||
function releaseSet(scope) {
|
||||
const selected = selectedImages(scope);
|
||||
const release = {
|
||||
version: VERSION,
|
||||
sourceRevision: REVISION,
|
||||
sourceRef: SOURCE_REF,
|
||||
scope,
|
||||
};
|
||||
const images = selected.map(([name, repository], index) => {
|
||||
const digest = `sha256:${(index + 1).toString(16).repeat(64)}`;
|
||||
const reference = `ghcr.io/${OWNER}/${repository}`;
|
||||
@@ -70,13 +79,8 @@ function releaseSet(scope) {
|
||||
const names = images.map((entry) => entry.name);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/release-set@v1',
|
||||
release: {
|
||||
version: VERSION,
|
||||
sourceRevision: REVISION,
|
||||
sourceRef: SOURCE_REF,
|
||||
scope,
|
||||
},
|
||||
schema: 'qinglong/release-set@v2',
|
||||
release,
|
||||
candidate: {
|
||||
schema: 'qinglong/release-candidate-contract@v1',
|
||||
contractDigest: `sha256:${'b'.repeat(64)}`,
|
||||
@@ -95,6 +99,7 @@ function releaseSet(scope) {
|
||||
images: names.filter((name) => name !== 'local'),
|
||||
},
|
||||
},
|
||||
evidenceReceipts: privateReleaseEvidenceReceipts(release),
|
||||
images,
|
||||
promotion: {
|
||||
authority: 'complete_verified_release_set',
|
||||
@@ -111,6 +116,10 @@ function releaseSet(scope) {
|
||||
'os-vulnerability',
|
||||
'release-candidate-contract',
|
||||
],
|
||||
privateReleaseEvidenceReceipts:
|
||||
scope === 'local'
|
||||
? []
|
||||
: ['worker-management', 'cloudnativepg-disaster-recovery'],
|
||||
releaseSetBuildProvenance: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,6 +28,9 @@ const {
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
@@ -65,6 +68,7 @@ function releaseSet(scope) {
|
||||
root,
|
||||
candidate,
|
||||
records,
|
||||
evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release),
|
||||
...identity,
|
||||
releaseScope: scope,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1';
|
||||
const FIXTURES = Object.freeze({
|
||||
'worker-management': 'qinglong/worker-credential-management-release-gate@v1',
|
||||
'cloudnativepg-disaster-recovery':
|
||||
'qinglong/cloudnativepg-disaster-recovery@v1',
|
||||
});
|
||||
const STATIC_AUDITS = Object.freeze([
|
||||
'cloudnativepg-backup',
|
||||
'barman-cloud-supply-chain',
|
||||
'cert-manager-selection',
|
||||
]);
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function privateReleaseEvidenceReceipt(release, evidenceKind) {
|
||||
const staticAudits =
|
||||
evidenceKind === 'cloudnativepg-disaster-recovery'
|
||||
? STATIC_AUDITS.map((name, index) => ({
|
||||
name,
|
||||
auditDigest: `sha256:${String(index + 7).repeat(64)}`,
|
||||
compatible: true,
|
||||
}))
|
||||
: [];
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: RECEIPT_SCHEMA,
|
||||
release: { ...release },
|
||||
evidenceKind,
|
||||
evidence: {
|
||||
fixture: FIXTURES[evidenceKind],
|
||||
observedAt: '2026-08-18T00:00:00.000Z',
|
||||
validatedAt: '2026-08-18T00:05:00.000Z',
|
||||
maximumAgeSeconds: 86_400,
|
||||
reportDigest: `sha256:${
|
||||
evidenceKind === 'worker-management' ? '5' : '6'
|
||||
}`.padEnd(71, evidenceKind === 'worker-management' ? '5' : '6'),
|
||||
sourceReportsUploaded: false,
|
||||
},
|
||||
staticAudits,
|
||||
verification: {
|
||||
sourceAwareAudit: true,
|
||||
privateEvidenceReplayed: true,
|
||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||
privateReportContentPublished: false,
|
||||
compatible: true,
|
||||
},
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
receiptDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function privateReleaseEvidenceReceipts(release) {
|
||||
return release.scope === 'local'
|
||||
? []
|
||||
: [
|
||||
privateReleaseEvidenceReceipt(release, 'worker-management'),
|
||||
privateReleaseEvidenceReceipt(
|
||||
release,
|
||||
'cloudnativepg-disaster-recovery',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
privateReleaseEvidenceReceipt,
|
||||
privateReleaseEvidenceReceipts,
|
||||
});
|
||||
@@ -19,6 +19,9 @@ const {
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
@@ -52,6 +55,10 @@ function recordsFor(releaseCandidate) {
|
||||
);
|
||||
}
|
||||
|
||||
function evidenceFor(releaseCandidate) {
|
||||
return privateReleaseEvidenceReceipts(releaseCandidate.release);
|
||||
}
|
||||
|
||||
function temporaryDirectory(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-set-')),
|
||||
@@ -71,6 +78,7 @@ test('aggregates the independent Local image into one immutable release set', ()
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
});
|
||||
@@ -88,6 +96,7 @@ test('aggregates the independent Local image into one immutable release set', ()
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
}).compatible,
|
||||
@@ -101,6 +110,7 @@ test('closes cluster and all scopes over the exact candidate image order', () =>
|
||||
root,
|
||||
candidate: clusterCandidate,
|
||||
records: recordsFor(clusterCandidate).reverse(),
|
||||
evidenceReceipts: evidenceFor(clusterCandidate),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
});
|
||||
@@ -114,12 +124,17 @@ test('closes cluster and all scopes over the exact candidate image order', () =>
|
||||
'admin',
|
||||
'worker',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
clusterSet.evidenceReceipts.map((entry) => entry.evidenceKind),
|
||||
['worker-management', 'cloudnativepg-disaster-recovery'],
|
||||
);
|
||||
|
||||
const allCandidate = candidate('all');
|
||||
const allSet = createReleaseSet({
|
||||
root,
|
||||
candidate: allCandidate,
|
||||
records: recordsFor(allCandidate),
|
||||
evidenceReceipts: evidenceFor(allCandidate),
|
||||
...identity,
|
||||
releaseScope: 'all',
|
||||
});
|
||||
@@ -127,6 +142,51 @@ test('closes cluster and all scopes over the exact candidate image order', () =>
|
||||
assert.deepEqual(allSet.deploymentFamilies.local.images, ['local']);
|
||||
});
|
||||
|
||||
test('requires exact private evidence receipts only for Cluster-capable scopes', () => {
|
||||
const releaseCandidate = candidate('cluster');
|
||||
const records = recordsFor(releaseCandidate);
|
||||
const receipts = evidenceFor(releaseCandidate);
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: receipts.slice(1),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
/receipt count differs/,
|
||||
);
|
||||
const drifted = JSON.parse(JSON.stringify(receipts));
|
||||
drifted[0].release.sourceRevision = 'c'.repeat(40);
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: drifted,
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
/receipt shape|release binding/,
|
||||
);
|
||||
const localCandidate = candidate('local');
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseSet({
|
||||
root,
|
||||
candidate: localCandidate,
|
||||
records: recordsFor(localCandidate),
|
||||
evidenceReceipts: [receipts[0]],
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
}),
|
||||
/receipt count differs/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects missing, duplicate and cross-candidate image evidence', () => {
|
||||
const releaseCandidate = candidate('cluster');
|
||||
const records = recordsFor(releaseCandidate);
|
||||
@@ -136,6 +196,7 @@ test('rejects missing, duplicate and cross-candidate image evidence', () => {
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: records.slice(1),
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
@@ -147,6 +208,7 @@ test('rejects missing, duplicate and cross-candidate image evidence', () => {
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: [records[0], records[0], records[2], records[3]],
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
@@ -160,6 +222,7 @@ test('rejects missing, duplicate and cross-candidate image evidence', () => {
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: [mutated, ...records.slice(1)],
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
@@ -199,6 +262,7 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () =>
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
});
|
||||
@@ -210,6 +274,7 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () =>
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records,
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'local',
|
||||
}),
|
||||
@@ -224,6 +289,7 @@ test('inspects a release set without short-lived candidate or image records', ()
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: recordsFor(releaseCandidate),
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: scope,
|
||||
});
|
||||
@@ -243,6 +309,7 @@ test('standalone inspection rejects image, family and self-digest drift', () =>
|
||||
root,
|
||||
candidate: releaseCandidate,
|
||||
records: recordsFor(releaseCandidate),
|
||||
evidenceReceipts: evidenceFor(releaseCandidate),
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
});
|
||||
@@ -256,6 +323,9 @@ test('standalone inspection rejects image, family and self-digest drift', () =>
|
||||
(value) => {
|
||||
value.releaseSetDigest = `sha256:${'0'.repeat(64)}`;
|
||||
},
|
||||
(value) => {
|
||||
value.evidenceReceipts[0].receiptDigest = `sha256:${'0'.repeat(64)}`;
|
||||
},
|
||||
]) {
|
||||
const drifted = JSON.parse(JSON.stringify(releaseSet));
|
||||
mutate(drifted);
|
||||
@@ -265,7 +335,7 @@ test('standalone inspection rejects image, family and self-digest drift', () =>
|
||||
...identity,
|
||||
releaseScope: 'cluster',
|
||||
}),
|
||||
/standalone release set/,
|
||||
/standalone release set|receipt digest/,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
@@ -283,7 +353,9 @@ test('standalone inspection rejects image, family and self-digest drift', () =>
|
||||
test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const recordsDirectory = path.join(directory, 'records');
|
||||
const evidenceDirectory = path.join(directory, 'evidence');
|
||||
fs.mkdirSync(recordsDirectory);
|
||||
fs.mkdirSync(evidenceDirectory);
|
||||
const releaseCandidate = candidate('local');
|
||||
const candidatePath = path.join(directory, 'candidate.json');
|
||||
writeCanonical(candidatePath, releaseCandidate);
|
||||
@@ -315,6 +387,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--evidence-receipts=${evidenceDirectory}`,
|
||||
`--output=${setPath}`,
|
||||
],
|
||||
root,
|
||||
@@ -326,6 +399,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||
'--mode=audit',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--evidence-receipts=${evidenceDirectory}`,
|
||||
`--report=${setPath}`,
|
||||
],
|
||||
root,
|
||||
@@ -356,6 +430,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--evidence-receipts=${evidenceDirectory}`,
|
||||
`--output=${setPath}`,
|
||||
],
|
||||
root,
|
||||
@@ -368,7 +443,9 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
|
||||
test('CLI rejects extra records, symlinks and open argument shapes', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const recordsDirectory = path.join(directory, 'records');
|
||||
const evidenceDirectory = path.join(directory, 'evidence');
|
||||
fs.mkdirSync(recordsDirectory);
|
||||
fs.mkdirSync(evidenceDirectory);
|
||||
const releaseCandidate = candidate('local');
|
||||
const candidatePath = path.join(directory, 'candidate.json');
|
||||
writeCanonical(candidatePath, releaseCandidate);
|
||||
@@ -392,6 +469,7 @@ test('CLI rejects extra records, symlinks and open argument shapes', (t) => {
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--evidence-receipts=${evidenceDirectory}`,
|
||||
`--output=${path.join(directory, 'set.json')}`,
|
||||
],
|
||||
root,
|
||||
@@ -409,6 +487,7 @@ test('CLI rejects extra records, symlinks and open argument shapes', (t) => {
|
||||
'--mode=aggregate',
|
||||
...common,
|
||||
`--records=${recordsDirectory}`,
|
||||
`--evidence-receipts=${evidenceDirectory}`,
|
||||
`--output=${path.join(directory, 'set.json')}`,
|
||||
],
|
||||
root,
|
||||
|
||||
Reference in New Issue
Block a user