mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 20:15:19 +08:00
feat(ql3): make release evidence replay deterministic
This commit is contained in:
@@ -11,6 +11,24 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-18):
|
最新增量证据(2026-08-18):
|
||||||
|
|
||||||
|
- D-347/ADR-0439(已接受;首份真实线上重放待实际 release tag):修正 D-346 收据把私有 runner `validatedAt` wall-clock 写入 durable
|
||||||
|
JSON 导致同一 source/report 在 workflow 重跑时产生第二个 release identity 的问题。私有收据升为
|
||||||
|
`qinglong/private-release-evidence-receipt@v2`:创建时仍以当前私有时钟执行 source-aware gate 和 24 小时 freshness 双重验证,但 durable
|
||||||
|
projection 只保留不可变报告自身的 `observedAt`、report/static-lock/self digest,并显式声明 `freshnessValidatedAtCreation=true`、
|
||||||
|
`durableValidationClockPublished=false` 与公开 consumer 不能重放现场报告。相同 release identity、相同私有报告字节和相同 source-controlled
|
||||||
|
static locks 在不同有效运行时间必须生成逐字节相同收据。由于嵌入协议发生不兼容变化,release-set/OCI artifact media type 同步升为
|
||||||
|
`qinglong/release-set@v3`/`application/vnd.qinglong.release-set.v3+json`,不在 v2 下偷换语义;catalog recovery 收紧为
|
||||||
|
`republish_deterministic_content_then_verify_digest`,只允许相同不可变输入的同内容重放。Local 仍为零私有收据,新增工作只在短生命周期
|
||||||
|
release runner 发生,不新增 package、依赖、数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源。定向发布链
|
||||||
|
135/135;完整 backend 1,363 项为 1,361 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0。12 项 package boundary、Cluster
|
||||||
|
dependency、Edge import、Cluster/Worker/CloudNativePG 部署、backup、Barman/cert-manager selection、image release、deployment-lock
|
||||||
|
surface 与 release-version 审计全部 compatible;14 档 Local artifact 与既有字节基线完全一致:默认 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 以 18.6/arm64 重跑 142/142 gates、timeline `1→2`,独立 evidence audit
|
||||||
|
compatible,mode-0600 报告 SHA-256 为 `db72086e971fc91c25a4a482af6634acbf4d463e567c44f42af0e7d0cc24fca2`,
|
||||||
|
container/network/volume 残留为 0。真实线上 response-loss 重放仍只能由实际受保护 `v3` release tag 取得。
|
||||||
|
|
||||||
- D-346/ADR-0438(已接受;首份真实公开收据待实际 release tag):Cluster/All 发布不再只通过 GitHub `needs` 保存“两个私有 evidence job
|
- D-346/ADR-0438(已接受;首份真实公开收据待实际 release tag):Cluster/All 发布不再只通过 GitHub `needs` 保存“两个私有 evidence job
|
||||||
成功”的瞬时调度事实。Worker management 与 CloudNativePG DR 私有 runner 在 source-aware gate 成功后,各自产生一个
|
成功”的瞬时调度事实。Worker management 与 CloudNativePG DR 私有 runner 在 source-aware gate 成功后,各自产生一个
|
||||||
`qinglong/private-release-evidence-receipt@v1`:只投影 exact version/source tag/revision/scope、evidence kind、24 小时 freshness、
|
`qinglong/private-release-evidence-receipt@v1`:只投影 exact version/source tag/revision/scope、evidence kind、24 小时 freshness、
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# ADR-0439:确定性私有证据收据与 Release-set 重放
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-18
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-335、D-336、D-346、D-347
|
||||||
|
- 关联 ADR:ADR-0427、ADR-0428、ADR-0430、ADR-0438
|
||||||
|
- Supersedes:ADR-0438 的 `qinglong/private-release-evidence-receipt@v1` 与 `qinglong/release-set@v2` 聚合版本
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
ADR-0438 把 Worker management 与 CloudNativePG disaster-recovery 私有报告投影为 content-free 收据,并将两份收据纳入 durable
|
||||||
|
release-set。v1 收据同时持久化 `observedAt` 和私有 job 实际运行时的 `validatedAt`。后者来自 runner wall-clock,不属于不可变 source
|
||||||
|
evidence。
|
||||||
|
|
||||||
|
因此,同一 tag、同一 source revision、同一报告与同一静态 lock 只要在不同时间重跑,就会产生不同 receipt digest、release-set digest 和 OCI
|
||||||
|
catalog manifest digest。这与 catalog plan 已冻结的 `republish_same_content_then_verify_digest` 恢复语义矛盾:一次合法的 workflow response-loss
|
||||||
|
恢复可能生成第二个 release identity,甚至让无 authority 的 discovery tag 指向不同内容。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. 私有收据升级为 `qinglong/private-release-evidence-receipt@v2`,`schemaVersion=2`。收据继续绑定报告自身的 `observedAt`、24 小时最大年龄、
|
||||||
|
report digest、release identity、evidence kind、静态审计摘要和 self digest,但不持久化 runner validation wall-clock。
|
||||||
|
2. 创建路径仍必须取得当前私有 runner 时钟,并由既有 source-aware gate 以及 receipt assembler 双重验证:报告不得超过 24 小时,最多只允许五分钟
|
||||||
|
未来时钟偏差。移除 durable clock 绝不等于移除 freshness gate。
|
||||||
|
3. 收据显式声明 `freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`。公开消费者可验证结构、source/report equality 和
|
||||||
|
digest 闭包,但仍必须保持 `publicConsumerReplay=not_possible_without_private_reports`,不能把布尔声明冒充现场重放。
|
||||||
|
4. 相同 release identity、相同私有报告字节和相同 source-controlled static locks,无论私有 job 在有效窗口内何时重跑,必须生成逐字节相同的
|
||||||
|
receipt。报告自身使用 owner-private、mode `0600`、commit-scoped、no-replace 发布路径;不同报告 digest 不是同一次恢复输入。
|
||||||
|
5. release-set 升级为 `qinglong/release-set@v3`,OCI artifact/file media type 同步升级为
|
||||||
|
`application/vnd.qinglong.release-set.v3+json`。不能在 v2 release-set 或 media type 下静默替换嵌入收据的 schema 与字段。
|
||||||
|
6. catalog recovery 语义收紧为 `republish_deterministic_content_then_verify_digest`:只允许相同 source evidence 的确定性内容重发并回读同一 digest;
|
||||||
|
source/report/static-lock 发生变化时必须作为新输入失败关闭,不能解释为同一发布的 response-loss replay。
|
||||||
|
7. 不新增 package、production dependency、数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源。变化只影响短生命周期
|
||||||
|
release runner 的 JSON 字段和 SHA-256;Local scope 仍为零私有收据。
|
||||||
|
|
||||||
|
## 失败与恢复
|
||||||
|
|
||||||
|
- 当前时钟超出 freshness window:创建在写文件前失败;不得因 durable JSON 不含 wall-clock 而放宽。
|
||||||
|
- 同一 source report 重跑:v2 收据、v3 release-set、catalog plan 与 manifest 内容必须逐字节一致,可安全重发并验证同 digest。
|
||||||
|
- 同一 source 路径内容被替换:私有 no-replace/stable descriptor 边界或 report digest 闭包失败;不能覆盖既有发布身份。
|
||||||
|
- v1 receipt、v2 release-set 或 v2 media type:3.0 尚未正式发布,不做隐式迁移;从原始私有证据重新运行受保护 release workflow。
|
||||||
|
- 历史 catalog consumer:只接受 v3 release-set;不会把 v1/v2 解释成当前 schema。
|
||||||
|
|
||||||
|
## 部署与资源影响
|
||||||
|
|
||||||
|
- Edge/Standalone 设备下载的仍是一个 Local image digest 和小型 selection;不安装证据工具,不增加常驻 CPU/RSS、I/O 或连接。
|
||||||
|
- Cluster 节点仍只消费 catalog-bound image references;确定性发生在发布工作站,不引入 Kubernetes workload 或运行期控制面。
|
||||||
|
- v2 收据比 v1 少一个时间字符串,并增加两个固定 boolean;差异不改变制品预算或包拓扑。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 保留 `validatedAt` 并允许重跑产生新 catalog
|
||||||
|
|
||||||
|
拒绝。同一不可变 release source 将拥有多个仅由 runner 时钟区分的 durable identity,response-loss 无法与新发布区分。
|
||||||
|
|
||||||
|
### 把 `validatedAt` 固定为 `observedAt`
|
||||||
|
|
||||||
|
拒绝。两个字段相同会伪称验证与观察发生在同一时刻。删除未发布的 ephemeral clock,并明确声明其未持久化,更符合证据强度。
|
||||||
|
|
||||||
|
### 删除 freshness 检查以换取确定性
|
||||||
|
|
||||||
|
拒绝。确定性只约束 durable projection;私有创建仍必须以当前时钟重放 source-aware gate。
|
||||||
|
|
||||||
|
### 保持 release-set v2 与 OCI v2 media type
|
||||||
|
|
||||||
|
拒绝。嵌入对象从 receipt v1 变为 v2 是消费者可观察的协议变化,复用旧 schema/media type 会隐藏不兼容语义。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- receipt contract 必须证明相同报告在两个不同有效 validation clock 下生成相同对象和相同 canonical bytes,并拒绝重新注入 `validatedAt`;
|
||||||
|
- 既有 source/scope/report/static-lock/self-digest、私有字段、mode `0600`、stable read 与 no-replace 负向门全部保留;
|
||||||
|
- release-set、catalog、consumption、deployment-lock 与 workflow 审计必须只接受 receipt v2、release-set v3 和 OCI v3 media type;
|
||||||
|
- 定向发布链 135/135,完整 backend 1,363 项为 1,361 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,12 项静态审计与 14 档
|
||||||
|
Local artifact 均 compatible 且制品字节基线不变;PostgreSQL 18.6 arm64 HA 为 142/142、timeline `1→2`,独立报告审计 compatible,且
|
||||||
|
Docker container/network/volume 零残留;
|
||||||
|
- 完整测试、制品与运行门结果记录于 QL-RFC-0001 D-347;首份真实线上重放仍须由受保护 `v3` release tag 产生。
|
||||||
@@ -442,6 +442,7 @@
|
|||||||
| [ADR-0436](./ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md) | 发布后 Catalog-bound Kubernetes Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) |
|
| [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-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) |
|
| [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag) |
|
||||||
|
| [ADR-0439](./ADR-0439-deterministic-private-evidence-receipts-and-release-set-replay.md) | 确定性私有证据收据与 Release-set 重放 | Accepted(首份真实线上重放待实际 release tag) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,16 @@ ghcr.io/<owner>/qinglong3-release-catalog:v<version>-<scope>
|
|||||||
|
|
||||||
## 私有发布证据收据链
|
## 私有发布证据收据链
|
||||||
|
|
||||||
当前长期 authority 是 `qinglong/release-set@v2`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含
|
当前长期 authority 是 `qinglong/release-set@v3`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含
|
||||||
`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v1`。每份收据绑定同一
|
`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v2`。每份收据绑定同一
|
||||||
version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与
|
version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与
|
||||||
cert-manager 三项静态审计摘要。
|
cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 wall-clock:创建时仍必须以当前时钟完成 freshness gate,但 durable JSON 只绑定
|
||||||
|
不可变报告的 `observedAt`。因此相同 source/report/static-lock 在 workflow 重跑时逐字节相同,不会仅因重跑时间变化而生成第二个 catalog identity。
|
||||||
|
|
||||||
这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest,
|
这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest,
|
||||||
但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact
|
但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact
|
||||||
从私有 job 交给 release-set job,随后完整嵌入 release-set v2 并由 durable catalog 长期保护。
|
从私有 job 交给 release-set job,随后完整嵌入 release-set v3 并由 durable catalog 长期保护。公开收据同时声明
|
||||||
|
`freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`,避免把未发布的临时时钟伪装成可离线重放的现场证据。
|
||||||
|
|
||||||
## 发布流水线内置 Local 与 Cluster 下游门
|
## 发布流水线内置 Local 与 Cluster 下游门
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计
|
|||||||
1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
|
1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
|
||||||
reference;discovery tag 无 authority。
|
reference;discovery tag 无 authority。
|
||||||
2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中
|
2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中
|
||||||
`qinglong/release-set@v2` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
`qinglong/release-set@v3` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||||
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
||||||
4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。
|
4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。
|
||||||
static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。
|
static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。
|
||||||
|
|||||||
@@ -1303,6 +1303,7 @@ function auditReleaseWorkflow(source) {
|
|||||||
sourceDerived: true,
|
sourceDerived: true,
|
||||||
sameRunRecords: true,
|
sameRunRecords: true,
|
||||||
sameRunPrivateEvidenceReceipts: true,
|
sameRunPrivateEvidenceReceipts: true,
|
||||||
|
deterministicPrivateEvidenceReceipts: true,
|
||||||
exactScopeClosure: true,
|
exactScopeClosure: true,
|
||||||
standaloneInspection: true,
|
standaloneInspection: true,
|
||||||
tagPromotionAuthority: 'complete_verified_release_set',
|
tagPromotionAuthority: 'complete_verified_release_set',
|
||||||
@@ -1312,7 +1313,7 @@ function auditReleaseWorkflow(source) {
|
|||||||
},
|
},
|
||||||
durableCatalog: {
|
durableCatalog: {
|
||||||
repository: 'qinglong3-release-catalog',
|
repository: 'qinglong3-release-catalog',
|
||||||
artifactType: 'application/vnd.qinglong.release-set.v2+json',
|
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
||||||
basenameOnly: true,
|
basenameOnly: true,
|
||||||
crossRunnerDeterministic: true,
|
crossRunnerDeterministic: true,
|
||||||
byteExactRoundTrip: true,
|
byteExactRoundTrip: true,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const {
|
|||||||
auditCertManagerSelection,
|
auditCertManagerSelection,
|
||||||
} = require('./ql3-cert-manager-selection-audit.cjs');
|
} = require('./ql3-cert-manager-selection-audit.cjs');
|
||||||
|
|
||||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1';
|
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v2';
|
||||||
const EVIDENCE_KINDS = Object.freeze([
|
const EVIDENCE_KINDS = Object.freeze([
|
||||||
'worker-management',
|
'worker-management',
|
||||||
'cloudnativepg-disaster-recovery',
|
'cloudnativepg-disaster-recovery',
|
||||||
@@ -182,12 +182,12 @@ function assembleReceipt(options) {
|
|||||||
fail('private evidence report digest is invalid');
|
fail('private evidence report digest is invalid');
|
||||||
}
|
}
|
||||||
const observedAtMs = Date.parse(options.observedAt);
|
const observedAtMs = Date.parse(options.observedAt);
|
||||||
const validatedAtMs = options.nowMs;
|
const validationClockMs = options.nowMs;
|
||||||
if (
|
if (
|
||||||
!Number.isSafeInteger(validatedAtMs) ||
|
!Number.isSafeInteger(validationClockMs) ||
|
||||||
!Number.isFinite(observedAtMs) ||
|
!Number.isFinite(observedAtMs) ||
|
||||||
observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS ||
|
observedAtMs > validationClockMs + MAX_FUTURE_SKEW_MS ||
|
||||||
validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
validationClockMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000
|
||||||
) {
|
) {
|
||||||
fail('private evidence is outside the release freshness window');
|
fail('private evidence is outside the release freshness window');
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ function assembleReceipt(options) {
|
|||||||
? staticAuditReceipts(options.root)
|
? staticAuditReceipts(options.root)
|
||||||
: Object.freeze([]);
|
: Object.freeze([]);
|
||||||
const unsigned = {
|
const unsigned = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
schema: RECEIPT_SCHEMA,
|
schema: RECEIPT_SCHEMA,
|
||||||
release: {
|
release: {
|
||||||
version: options.version,
|
version: options.version,
|
||||||
@@ -208,7 +208,6 @@ function assembleReceipt(options) {
|
|||||||
evidence: {
|
evidence: {
|
||||||
fixture: options.fixture,
|
fixture: options.fixture,
|
||||||
observedAt: options.observedAt,
|
observedAt: options.observedAt,
|
||||||
validatedAt: new Date(validatedAtMs).toISOString(),
|
|
||||||
maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS,
|
maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS,
|
||||||
reportDigest: options.reportDigest,
|
reportDigest: options.reportDigest,
|
||||||
sourceReportsUploaded: false,
|
sourceReportsUploaded: false,
|
||||||
@@ -217,6 +216,8 @@ function assembleReceipt(options) {
|
|||||||
verification: {
|
verification: {
|
||||||
sourceAwareAudit: true,
|
sourceAwareAudit: true,
|
||||||
privateEvidenceReplayed: true,
|
privateEvidenceReplayed: true,
|
||||||
|
freshnessValidatedAtCreation: true,
|
||||||
|
durableValidationClockPublished: false,
|
||||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||||
privateReportContentPublished: false,
|
privateReportContentPublished: false,
|
||||||
compatible: true,
|
compatible: true,
|
||||||
@@ -323,7 +324,7 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
|||||||
'verification',
|
'verification',
|
||||||
'receiptDigest',
|
'receiptDigest',
|
||||||
]) ||
|
]) ||
|
||||||
actual.schemaVersion !== 1 ||
|
actual.schemaVersion !== 2 ||
|
||||||
actual.schema !== RECEIPT_SCHEMA ||
|
actual.schema !== RECEIPT_SCHEMA ||
|
||||||
!exactKeys(actual.release, [
|
!exactKeys(actual.release, [
|
||||||
'version',
|
'version',
|
||||||
@@ -342,7 +343,6 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
|||||||
!exactKeys(actual.evidence, [
|
!exactKeys(actual.evidence, [
|
||||||
'fixture',
|
'fixture',
|
||||||
'observedAt',
|
'observedAt',
|
||||||
'validatedAt',
|
|
||||||
'maximumAgeSeconds',
|
'maximumAgeSeconds',
|
||||||
'reportDigest',
|
'reportDigest',
|
||||||
'sourceReportsUploaded',
|
'sourceReportsUploaded',
|
||||||
@@ -363,6 +363,8 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
|||||||
!exactKeys(actual.verification, [
|
!exactKeys(actual.verification, [
|
||||||
'sourceAwareAudit',
|
'sourceAwareAudit',
|
||||||
'privateEvidenceReplayed',
|
'privateEvidenceReplayed',
|
||||||
|
'freshnessValidatedAtCreation',
|
||||||
|
'durableValidationClockPublished',
|
||||||
'publicConsumerReplay',
|
'publicConsumerReplay',
|
||||||
'privateReportContentPublished',
|
'privateReportContentPublished',
|
||||||
'compatible',
|
'compatible',
|
||||||
@@ -371,6 +373,8 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
sourceAwareAudit: true,
|
sourceAwareAudit: true,
|
||||||
privateEvidenceReplayed: true,
|
privateEvidenceReplayed: true,
|
||||||
|
freshnessValidatedAtCreation: true,
|
||||||
|
durableValidationClockPublished: false,
|
||||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||||
privateReportContentPublished: false,
|
privateReportContentPublished: false,
|
||||||
compatible: true,
|
compatible: true,
|
||||||
@@ -378,14 +382,7 @@ function inspectPrivateReleaseEvidenceReceipt(actual, options) {
|
|||||||
) {
|
) {
|
||||||
fail('receipt shape or release binding is invalid');
|
fail('receipt shape or release binding is invalid');
|
||||||
}
|
}
|
||||||
const observedAtMs = Date.parse(actual.evidence.observedAt);
|
if (!Number.isFinite(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');
|
fail('receipt freshness binding is invalid');
|
||||||
}
|
}
|
||||||
const { receiptDigest, ...unsigned } = actual;
|
const { receiptDigest, ...unsigned } = actual;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const {
|
|||||||
|
|
||||||
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
|
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
|
||||||
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
|
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
|
||||||
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v2+json';
|
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v3+json';
|
||||||
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
|
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
|
||||||
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
||||||
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
|
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
|
||||||
@@ -173,7 +173,7 @@ function createCatalogPlan(releaseSet, options) {
|
|||||||
discoveryTagAuthority: 'none',
|
discoveryTagAuthority: 'none',
|
||||||
immutableDigestAuthority: 'required',
|
immutableDigestAuthority: 'required',
|
||||||
roundTrip: 'byte_exact',
|
roundTrip: 'byte_exact',
|
||||||
recovery: 'republish_same_content_then_verify_digest',
|
recovery: 'republish_deterministic_content_then_verify_digest',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
|
|||||||
|
|
||||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||||
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
|
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
|
||||||
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v2';
|
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v3';
|
||||||
const MAX_JSON_BYTES = 1024 * 1024;
|
const MAX_JSON_BYTES = 1024 * 1024;
|
||||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||||
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
|
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u;
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
|||||||
sourceDerived: true,
|
sourceDerived: true,
|
||||||
sameRunRecords: true,
|
sameRunRecords: true,
|
||||||
sameRunPrivateEvidenceReceipts: true,
|
sameRunPrivateEvidenceReceipts: true,
|
||||||
|
deterministicPrivateEvidenceReceipts: true,
|
||||||
exactScopeClosure: true,
|
exactScopeClosure: true,
|
||||||
standaloneInspection: true,
|
standaloneInspection: true,
|
||||||
tagPromotionAuthority: 'complete_verified_release_set',
|
tagPromotionAuthority: 'complete_verified_release_set',
|
||||||
@@ -108,7 +109,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
|||||||
},
|
},
|
||||||
durableCatalog: {
|
durableCatalog: {
|
||||||
repository: 'qinglong3-release-catalog',
|
repository: 'qinglong3-release-catalog',
|
||||||
artifactType: 'application/vnd.qinglong.release-set.v2+json',
|
artifactType: 'application/vnd.qinglong.release-set.v3+json',
|
||||||
basenameOnly: true,
|
basenameOnly: true,
|
||||||
crossRunnerDeterministic: true,
|
crossRunnerDeterministic: true,
|
||||||
byteExactRoundTrip: true,
|
byteExactRoundTrip: true,
|
||||||
|
|||||||
@@ -119,6 +119,70 @@ test('rejects publishing private material in an otherwise re-digested receipt',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('omits the ephemeral validation clock and replays identical source evidence byte-for-byte', (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 createAt = (validationTime, outputFile) =>
|
||||||
|
createWorkerReceipt(
|
||||||
|
{
|
||||||
|
root: path.resolve(__dirname, '../..'),
|
||||||
|
version: RELEASE.version,
|
||||||
|
sourceRevision: RELEASE.sourceRevision,
|
||||||
|
sourceRef: RELEASE.sourceRef,
|
||||||
|
releaseScope: RELEASE.scope,
|
||||||
|
reportFile: reportPath,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
now: () => Date.parse(validationTime),
|
||||||
|
auditGate(_options, dependencies) {
|
||||||
|
assert.equal(dependencies.now(), Date.parse(validationTime));
|
||||||
|
return {
|
||||||
|
fixture: 'qinglong/worker-credential-management-release-gate@v1',
|
||||||
|
evidenceReportSha256: `sha256:${'5'.repeat(64)}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const firstPath = path.join(directory, 'first.json');
|
||||||
|
const secondPath = path.join(directory, 'second.json');
|
||||||
|
const first = createAt('2026-08-18T00:05:00.000Z', firstPath);
|
||||||
|
const second = createAt('2026-08-18T00:15:00.000Z', secondPath);
|
||||||
|
assert.deepEqual(second, first);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(secondPath, 'utf8'),
|
||||||
|
fs.readFileSync(firstPath, 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(Object.hasOwn(first.evidence, 'validatedAt'), false);
|
||||||
|
assert.equal(first.verification.freshnessValidatedAtCreation, true);
|
||||||
|
assert.equal(first.verification.durableValidationClockPublished, false);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createAt('2026-08-19T00:00:01.000Z', path.join(directory, 'stale.json')),
|
||||||
|
/freshness window/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const widened = JSON.parse(JSON.stringify(first));
|
||||||
|
widened.evidence.validatedAt = '2026-08-18T00:05:00.000Z';
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
inspectPrivateReleaseEvidenceReceipt(
|
||||||
|
widened,
|
||||||
|
options('worker-management'),
|
||||||
|
),
|
||||||
|
/receipt shape/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('audit CLI accepts a canonical receipt and rejects open argument shapes', (t) => {
|
test('audit CLI accepts a canonical receipt and rejects open argument shapes', (t) => {
|
||||||
const directory = temporaryDirectory(t);
|
const directory = temporaryDirectory(t);
|
||||||
const receiptPath = path.join(directory, 'worker-management.json');
|
const receiptPath = path.join(directory, 'worker-management.json');
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function releaseSet(scope) {
|
|||||||
const names = images.map((entry) => entry.name);
|
const names = images.map((entry) => entry.name);
|
||||||
const unsigned = {
|
const unsigned = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
schema: 'qinglong/release-set@v2',
|
schema: 'qinglong/release-set@v3',
|
||||||
release,
|
release,
|
||||||
candidate: {
|
candidate: {
|
||||||
schema: 'qinglong/release-candidate-contract@v1',
|
schema: 'qinglong/release-candidate-contract@v1',
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ test('plans deterministic Local, Cluster and All OCI catalog entries', () => {
|
|||||||
`ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`,
|
`ghcr.io/qinglong-release/qinglong3-release-catalog:v${version}-${scope}`,
|
||||||
);
|
);
|
||||||
assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE);
|
assert.equal(plan.catalog.artifactType, ARTIFACT_TYPE);
|
||||||
|
assert.equal(
|
||||||
|
plan.publicationPolicy.recovery,
|
||||||
|
'republish_deterministic_content_then_verify_digest',
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
plan.releaseSet.bytes,
|
plan.releaseSet.bytes,
|
||||||
Buffer.byteLength(`${JSON.stringify(set)}\n`),
|
Buffer.byteLength(`${JSON.stringify(set)}\n`),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1';
|
const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v2';
|
||||||
const FIXTURES = Object.freeze({
|
const FIXTURES = Object.freeze({
|
||||||
'worker-management': 'qinglong/worker-credential-management-release-gate@v1',
|
'worker-management': 'qinglong/worker-credential-management-release-gate@v1',
|
||||||
'cloudnativepg-disaster-recovery':
|
'cloudnativepg-disaster-recovery':
|
||||||
@@ -28,14 +28,13 @@ function privateReleaseEvidenceReceipt(release, evidenceKind) {
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
const unsigned = {
|
const unsigned = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 2,
|
||||||
schema: RECEIPT_SCHEMA,
|
schema: RECEIPT_SCHEMA,
|
||||||
release: { ...release },
|
release: { ...release },
|
||||||
evidenceKind,
|
evidenceKind,
|
||||||
evidence: {
|
evidence: {
|
||||||
fixture: FIXTURES[evidenceKind],
|
fixture: FIXTURES[evidenceKind],
|
||||||
observedAt: '2026-08-18T00:00:00.000Z',
|
observedAt: '2026-08-18T00:00:00.000Z',
|
||||||
validatedAt: '2026-08-18T00:05:00.000Z',
|
|
||||||
maximumAgeSeconds: 86_400,
|
maximumAgeSeconds: 86_400,
|
||||||
reportDigest: `sha256:${
|
reportDigest: `sha256:${
|
||||||
evidenceKind === 'worker-management' ? '5' : '6'
|
evidenceKind === 'worker-management' ? '5' : '6'
|
||||||
@@ -46,6 +45,8 @@ function privateReleaseEvidenceReceipt(release, evidenceKind) {
|
|||||||
verification: {
|
verification: {
|
||||||
sourceAwareAudit: true,
|
sourceAwareAudit: true,
|
||||||
privateEvidenceReplayed: true,
|
privateEvidenceReplayed: true,
|
||||||
|
freshnessValidatedAtCreation: true,
|
||||||
|
durableValidationClockPublished: false,
|
||||||
publicConsumerReplay: 'not_possible_without_private_reports',
|
publicConsumerReplay: 'not_possible_without_private_reports',
|
||||||
privateReportContentPublished: false,
|
privateReportContentPublished: false,
|
||||||
compatible: true,
|
compatible: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user