mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): validate release tags before registry mutation
This commit is contained in:
@@ -304,6 +304,7 @@ jobs:
|
||||
test/back/ql3ReleaseSetContract.test.cjs
|
||||
test/back/ql3ReleaseCatalogContract.test.cjs
|
||||
test/back/ql3ReleaseDeploymentReadinessContract.test.cjs
|
||||
test/back/ql3ReleaseTagFinalizer.test.cjs
|
||||
test/back/ql3ReleasePublicationClosureContract.test.cjs
|
||||
test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs
|
||||
test/back/ql3DeploymentLockContract.test.cjs
|
||||
|
||||
@@ -1579,100 +1579,17 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const {
|
||||
createPublicationTagObservation,
|
||||
} = require('./scripts/ql3-release-publication-closure-contract.cjs');
|
||||
const plan = JSON.parse(fs.readFileSync(process.env.PUBLICATION_PLAN, 'utf8'));
|
||||
const regctl = process.env.REGCTL;
|
||||
const maxInventoryBytes = 1024 * 1024;
|
||||
const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u;
|
||||
const run = (args) => {
|
||||
const result = spawnSync(regctl, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: maxInventoryBytes,
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`regctl ${args.join(' ')} failed`);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
const states = [];
|
||||
for (const image of plan.images) {
|
||||
const source = run(['image', 'digest', image.immutableReference]).trim();
|
||||
if (source !== image.digest) {
|
||||
throw new Error('source digest drifted before promotion');
|
||||
}
|
||||
const inventoryContents = run([
|
||||
'tag',
|
||||
'ls',
|
||||
image.registryRepository,
|
||||
'--format',
|
||||
'{{ range .Tags }}{{ println . }}{{ end }}',
|
||||
]);
|
||||
if (
|
||||
Buffer.byteLength(inventoryContents) > maxInventoryBytes ||
|
||||
(inventoryContents.length > 0 && !inventoryContents.endsWith('\n'))
|
||||
) {
|
||||
throw new Error('release tag inventory is invalid or unbounded');
|
||||
}
|
||||
const inventory = inventoryContents.length === 0
|
||||
? []
|
||||
: inventoryContents.slice(0, -1).split('\n');
|
||||
if (
|
||||
inventory.some((tag) => !tagPattern.test(tag)) ||
|
||||
new Set(inventory).size !== inventory.length
|
||||
) {
|
||||
throw new Error('release tag inventory is malformed');
|
||||
}
|
||||
const inventorySet = new Set(inventory);
|
||||
for (const tag of image.tags) {
|
||||
const tagName = tag.reference.slice(image.registryRepository.length + 1);
|
||||
const present = inventorySet.has(tagName);
|
||||
if (present) {
|
||||
const current = run(['image', 'digest', tag.reference]).trim();
|
||||
if (current !== image.digest) {
|
||||
throw new Error('release tag already points at another digest');
|
||||
}
|
||||
}
|
||||
states.push({ image, tag, present });
|
||||
}
|
||||
}
|
||||
for (const state of states) {
|
||||
if (!state.present) {
|
||||
run([
|
||||
'image',
|
||||
'copy',
|
||||
state.image.immutableReference,
|
||||
state.tag.reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
const observedTags = [];
|
||||
for (const state of states) {
|
||||
const promoted = run(['image', 'digest', state.tag.reference]).trim();
|
||||
if (promoted !== state.image.digest) {
|
||||
throw new Error('promoted tag does not resolve to the release-set digest');
|
||||
}
|
||||
observedTags.push({
|
||||
image: state.image.name,
|
||||
kind: state.tag.kind,
|
||||
reference: state.tag.reference,
|
||||
digest: promoted,
|
||||
});
|
||||
}
|
||||
const observation = createPublicationTagObservation(plan, observedTags);
|
||||
const descriptor = fs.openSync(process.env.TAG_OBSERVATIONS, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(observation)}\n`);
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
NODE
|
||||
node scripts/ql3-release-tag-finalizer.cjs \
|
||||
--mode=finalize \
|
||||
--plan="${PUBLICATION_PLAN}" \
|
||||
--regctl="${REGCTL}" \
|
||||
--output="${TAG_OBSERVATIONS}" > /dev/null
|
||||
node scripts/ql3-release-tag-finalizer.cjs \
|
||||
--mode=audit \
|
||||
--plan="${PUBLICATION_PLAN}" \
|
||||
--regctl="${REGCTL}" \
|
||||
--observation="${TAG_OBSERVATIONS}" > \
|
||||
"${RUNNER_TEMP}/release-tag-finalization-audit.json"
|
||||
|
||||
- name: Close and audit the deployment-ready public tag set
|
||||
env:
|
||||
|
||||
@@ -11,6 +11,24 @@
|
||||
|
||||
最新增量证据(2026-08-18):
|
||||
|
||||
- D-352/ADR-0444(已接受;首份真实 GHCR response-loss 重放待实际 release tag):修复 D-351 终态 finalizer 中仍残留的
|
||||
mutation-before-validation 风险。原 workflow 内联 Node publisher 直接解析 plan 并执行 registry copy,直到生成 observation 时才间接验证
|
||||
plan self-digest;篡改 plan 可能先产生错误副作用。promotion 现唯一进入 `scripts/ql3-release-tag-finalizer.cjs`:只读取 current-user
|
||||
`0700/0600` canonical plan,在第一条 registry command 前完整验证 publication plan v2、deployment readiness、repository、immutable
|
||||
source 和 exact tags;固定 `regctl` dev/inode/size/uid/mode/time identity 并在每条命令前后复验。所有 repository 的 source digest、最大
|
||||
1 MiB canonical inventory 与既有目标 tag 必须在首次 copy 前全量通过,最后一个冲突同样保持零 mutation;copy-after-write response loss
|
||||
不猜测结果,同 source 重跑复用 exact tag 并只补 absent。最终 observation 以 `0600` no-replace 发布,closure 前再执行一次
|
||||
`registryMutation=false` 的 live terminal audit。hermetic registry 已覆盖 plan 篡改零调用、全局 conflict 零写、response-loss 重放、畸形/
|
||||
超限 inventory、错误终态 digest、no-replace CLI 与 executable drift;workflow 不再保留高权限 Node heredoc。publication/observation/
|
||||
closure schema 和无 CAS/跨仓库事务声明不变,不新增 package、生产依赖、数据库、Kubernetes object 或设备常驻资源。阶段门已重跑:finalizer/
|
||||
publication/workflow/Console distribution 聚焦测试 116/116,完整 backend 1,403 pass + 2 条条件 skip/0 fail,18-package clean
|
||||
build/test 退出 0,14/14 静态审计与 14/14 artifact 档位全部 compatible。artifact 保持 D-351 字节基线:基础 Edge/Standalone
|
||||
`2589890/2589968`、adopted `2809185/2809308`、application `3632769/3632889`、application-api
|
||||
`3800322/3800466`、AI `3069143/3069233`、application+AI `4493043/4493175`、MCP `7315930/7316038`。D-352 不修改
|
||||
package/runtime、数据库/schema、镜像内容或 Kubernetes/Compose 部署面,因而没有制造新的 PostgreSQL HA/K3s 物理环境证据;本阶段复用紧邻
|
||||
D-351 已通过的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 与三节点 K3s v1.34.3/arm64 synthetic fixture 作为未变部署面的基线,
|
||||
不把它们冒充 D-352 新运行的证据。真实 GHCR response-loss 仍只能由受保护 release tag 或受控 release repository 演练产生。
|
||||
|
||||
- D-351/ADR-0443(已接受;首份真实 GHCR deployment-ready finalization 待实际 release tag):D-350 的 catalog-ready
|
||||
publication 继续收紧为 deployment-ready publication。`release-set` job 只发布、验签并 attested immutable catalog,不再登录 registry
|
||||
或修改最终 image tag;`local|all` 必须先完成 Edge 与 Standalone 的 catalog-bound 正式 Compose rollout,`cluster|all` 必须先完成三节点
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-336、D-344、D-345、D-350、D-351
|
||||
- 关联 ADR:ADR-0436、ADR-0437、ADR-0441、ADR-0442
|
||||
- Supersedes:ADR-0442 中“catalog receipt attested 后即可 mutation 最终 image tag”的发布顺序
|
||||
- Amended by:ADR-0444 将最终 tag mutation 从 YAML heredoc 收敛为 pre-registry validated、可重放演练的独立 finalizer
|
||||
|
||||
## 上下文
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-0444:Fail-closed Release Tag Finalizer 与重放演练
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-18
|
||||
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-350、D-351、D-352
|
||||
- 关联 ADR:ADR-0441、ADR-0442、ADR-0443
|
||||
- Amends:ADR-0443 的最终 tag mutation 实现,不改变 deployment-ready 发布语义
|
||||
|
||||
## 上下文
|
||||
|
||||
ADR-0443 已把最终 tag mutation 移到 scope-exact deployment readiness attestation 之后,但真正执行 promotion 的代码仍是一段嵌在
|
||||
GitHub Actions YAML 中的 Node heredoc。它直接 `JSON.parse` publication plan,读取 source/inventory 并执行 `regctl image copy`;只有全部
|
||||
copy 和最终回读完成后,`createPublicationTagObservation` 才间接调用 plan validator。
|
||||
|
||||
因此,若 runner 上的 plan bytes 在生成后被替换、截断或重写为另一组 repository/tag,旧实现可能先尝试 registry mutation,再因 plan
|
||||
self-digest 或 exact tag 结构不合法而失败。workflow 正则审计只能检查源码片段存在,不能直接执行“最后一个 repository 冲突时零写”或“copy
|
||||
已落地但客户端丢失响应后精确重放”的状态机。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 最终 tag promotion 只通过 `scripts/ql3-release-tag-finalizer.cjs` 执行;release workflow 不再包含拥有 registry mutation authority 的
|
||||
inline Node heredoc。
|
||||
2. finalizer 只接受 current-user `0700` canonical parent 中的单链接 `0600` canonical publication plan。它在创建 registry adapter、读取
|
||||
immutable source、列 tag 或 copy 之前执行完整 `validatePublicationPlan`,包括 v2 schema、self-digest、release/source/scope、readiness、
|
||||
repository、immutable reference 和两个 exact target tag。
|
||||
3. `regctl` 必须是 canonical absolute、current-user、单链接、不可被 group/other 写入的 executable。finalizer 固定其 dev/inode/size/
|
||||
uid/mode/mtime/ctime,并在每个 registry command 前后复验;漂移立即失败。
|
||||
4. mutation 前必须完成所有 image repository 的全量 preflight:逐一回读 immutable source digest;取得最大 1 MiB、换行闭合、合法且无重复的
|
||||
tag inventory;解析每个已存在目标 tag 并要求 exact digest。任一 source、inventory 或目标冲突时,全局 copy 次数必须为零。
|
||||
5. 全量 preflight 成功后,只对 absent target 执行 `regctl image copy`。exact target 不重写。copy 返回失败时不猜测远端是否已提交,也不删除
|
||||
已正确写入的 tag;同一 protected source tag 重跑会重新 inventory,复用 exact tag 并只补 absent tag。
|
||||
6. copy 阶段结束后按 publication plan 固定顺序回读所有 tag,创建 canonical
|
||||
`qinglong/release-publication-tag-observation@v1`。输出只允许在 `0700` parent 下以 `0600` no-replace 创建。
|
||||
7. workflow 在 closure 前再次以 `--mode=audit` 读取 plan 与 observation,重新执行 source、inventory、全部 tag digest 和 observation
|
||||
self-digest 检查;audit 明确 `registryMutation=false`。只有 live terminal audit 成功才创建 closure receipt。
|
||||
8. publication plan、tag observation 与 closure schema 不升级;本 ADR 修复执行顺序和可测试性,不改变 release identity。OCI registry 仍没有 tag
|
||||
CAS 或跨 repository transaction,closure 继续诚实声明 `registryTagCas=false` 与 `crossRepositoryAtomicity=false`。
|
||||
|
||||
## 故障与恢复
|
||||
|
||||
- plan 非 canonical、权限过宽、self-digest 错误或 tag/repository 漂移:任何 registry command 前失败。
|
||||
- immutable source、inventory 或任一既有 tag 不确定:所有 repository 保持零 mutation。
|
||||
- copy 在远端提交后丢失响应:本轮失败且不生成 observation;重跑复用已经 exact 的 tag,只补 absent tag。
|
||||
- copy 成功但 tag 回读不是计划 digest:不生成 observation/closure,不声称发布完成。
|
||||
- observation 已存在:finalize 不覆盖;运维者应先执行 read-only audit,不能删除证据后伪造另一份终态。
|
||||
- `regctl` 在运行中被替换或改写:命令前后 identity 复验失败,后续 mutation 停止。
|
||||
|
||||
## 部署与资源影响
|
||||
|
||||
- 不新增 workspace package、生产依赖、数据库、schema、migration、Kubernetes object、RBAC 或部署服务。
|
||||
- Edge、Standalone、低配路由器和 Cluster 节点不执行 finalizer,不增加产物体积、RSS、磁盘写、listener、timer、watcher 或常驻进程。
|
||||
- 增量工作只发生在短生命周期 release runner;每个 repository 一次最大 1 MiB inventory,以及 promotion 后一次小型只读 audit。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 继续依赖 YAML 正则审计 inline publisher
|
||||
|
||||
拒绝。正则能证明片段存在,不能证明 plan validation 发生在第一条 registry command 之前,也不能执行 response-loss 状态机。
|
||||
|
||||
### mutation 后再验证 plan
|
||||
|
||||
拒绝。失败关闭必须保护副作用边界;终态 validator 不能撤销已经写错的 tag。
|
||||
|
||||
### copy 失败时立即覆盖或删除目标 tag
|
||||
|
||||
拒绝。客户端错误不能区分远端未提交和响应丢失;删除或覆盖会扩大不确定性,并与 no-CAS 事实冲突。
|
||||
|
||||
### 把 hermetic rehearsal 当作真实 GHCR 证据
|
||||
|
||||
拒绝。fake registry 证明状态机与零写/重放性质,不证明 GHCR 权限、网络、Cosign、GitHub attestation 或组织级并发控制。
|
||||
|
||||
## 验证
|
||||
|
||||
- finalizer 单测覆盖正常 promotion/read-only audit、pre-registry plan rejection、全局 conflict 零写、copy-after-write response loss 重放、
|
||||
malformed/unbounded inventory、错误终态 digest、canonical private no-replace CLI 与 executable identity 漂移;
|
||||
- workflow audit 强制 CI 执行 finalizer tests、唯一脚本入口、`finalize → audit → closure` 顺序,并拒绝 inline heredoc 回归;
|
||||
- 完整仓库、产物档位和部署 Gate 的阶段结果记录在 QL-RFC-0001 D-352;真实 GHCR response-loss 证据仍须由受保护 release tag 或受控
|
||||
release repository 演练产生。
|
||||
@@ -447,6 +447,7 @@
|
||||
| [ADR-0441](./ADR-0441-no-overwrite-release-catalog-discovery-publication.md) | Release Catalog Discovery Tag 无覆盖发布 | Accepted(首份真实 GHCR conflict/reuse 证据待实际 release tag) |
|
||||
| [ADR-0442](./ADR-0442-catalog-ready-terminal-release-tag-publication.md) | Catalog-ready 的终态 Release Tag 发布与闭合收据 | Superseded by ADR-0443(bounded promotion/closure 机制保留) |
|
||||
| [ADR-0443](./ADR-0443-deployment-ready-terminal-release-finalization.md) | Deployment-ready 的终态 Release Finalization | Accepted(首份真实 GHCR deployment-ready finalization 待实际 release tag) |
|
||||
| [ADR-0444](./ADR-0444-fail-closed-release-tag-finalizer-and-replay-rehearsal.md) | Fail-closed Release Tag Finalizer 与重放演练 | Accepted(首份真实 GHCR response-loss 重放待实际 release tag) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -81,6 +81,21 @@ readiness 是本次 release workflow 的操作证据,不是新的镜像内容
|
||||
readiness digest 不同;release-set 与 immutable catalog digest 仍必须相同,既有 exact tag 只能复用,不同 digest 继续失败。OCI registry
|
||||
没有跨 repository 事务或 tag CAS,因此 closure 证明的是观测到的终态,不是原子提交。
|
||||
|
||||
### Fail-closed tag finalizer
|
||||
|
||||
最终 tag writer 不再由 workflow 内联脚本实现。`ql3-release-tag-finalizer.cjs --mode=finalize` 必须先从 owner-private `0700` 目录稳定读取
|
||||
`0600` canonical publication plan,完整验证 plan self-digest、deployment readiness 和 exact repository/tag,再固定 checksum-verified
|
||||
`regctl` 的文件 identity。任何 registry mutation 前,它会遍历所有 image repository,验证 immutable source、最大 1 MiB 的合法无重复 tag
|
||||
inventory,以及每个已存在目标 tag 的 exact digest;任一冲突时 copy 次数为零。
|
||||
|
||||
promotion 只填补 absent tag,不重写 exact tag。若 `regctl image copy` 在远端提交后丢失响应,本轮不会生成 observation;同一 protected
|
||||
source tag 重跑会重新 inventory,复用已存在 exact tag并只补缺失项。不得手工删除“可能已成功”的 tag,也不得把任意 registry error 降级为
|
||||
absent。全部 tag 最终回读后,observation 以 `0600` no-replace 生成;紧接着的 `--mode=audit` 再次只读验证 source、inventory、live tag 与
|
||||
observation,成功后才允许 closure。
|
||||
|
||||
hermetic response-loss rehearsal 只证明状态机和零写/精确重放性质,不是 GHCR 在线证据。真实组织权限、网络响应丢失、并发 writer 与 GitHub
|
||||
attestation 仍必须通过受保护 release tag 或受控 release repository 演练取得。
|
||||
|
||||
## 选择 scope
|
||||
|
||||
| 部署类型 | release scope | 必须出现的镜像 |
|
||||
|
||||
@@ -258,6 +258,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
'Verify the durable catalog and create its immutable receipt',
|
||||
'Attest the immutable release-catalog receipt',
|
||||
'Attest deployment readiness before any final tag mutation',
|
||||
'scripts/ql3-release-tag-finalizer.cjs',
|
||||
'Promote final tags only after every required deployment gate',
|
||||
'Close and audit the deployment-ready public tag set',
|
||||
'Attest the deployment-ready release publication closure receipt',
|
||||
|
||||
@@ -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\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.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',
|
||||
/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\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseTagFinalizer\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.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, tag finalizer and workflow negative tests; catalog consumption ceremony is mandatory',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -1024,9 +1024,10 @@ function auditReleaseWorkflow(source) {
|
||||
}) ||
|
||||
finalizationSteps[11]?.uses !==
|
||||
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
|
||||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/u.test(
|
||||
!/ql3-release-tag-finalizer\.cjs[\s\S]*--mode=finalize[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--regctl="\$\{REGCTL\}"[\s\S]*--output="\$\{TAG_OBSERVATIONS\}"[\s\S]*ql3-release-tag-finalizer\.cjs[\s\S]*--mode=audit[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--regctl="\$\{REGCTL\}"[\s\S]*--observation="\$\{TAG_OBSERVATIONS\}"[\s\S]*release-tag-finalization-audit\.json/u.test(
|
||||
finalizationSteps[12]?.run ?? '',
|
||||
) ||
|
||||
/node\s+<<['"]?NODE/u.test(finalizationSteps[12]?.run ?? '') ||
|
||||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/u.test(
|
||||
finalizationSteps[13]?.run ?? '',
|
||||
) ||
|
||||
@@ -1454,9 +1455,13 @@ function auditReleaseWorkflow(source) {
|
||||
receiptAttested: true,
|
||||
},
|
||||
finalPublicationClosure: {
|
||||
finalizer: 'scripts/ql3-release-tag-finalizer.cjs',
|
||||
planSchema: 'qinglong/release-publication-plan@v2',
|
||||
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
|
||||
receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
|
||||
planValidatedBeforeRegistryAccess: true,
|
||||
hermeticResponseLossRehearsal: true,
|
||||
liveTerminalAuditBeforeClosure: true,
|
||||
catalogReadyBeforeTagMutation: true,
|
||||
deploymentReadyBeforeTagMutation: true,
|
||||
allTagsExactDigest: true,
|
||||
|
||||
@@ -606,4 +606,6 @@ module.exports = Object.freeze({
|
||||
createPublicationTagObservation,
|
||||
parseArguments,
|
||||
runCli,
|
||||
validatePublicationPlan,
|
||||
validateTagObservation,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const {
|
||||
createPublicationTagObservation,
|
||||
validatePublicationPlan,
|
||||
validateTagObservation,
|
||||
} = require('./ql3-release-publication-closure-contract.cjs');
|
||||
|
||||
const MAX_JSON_BYTES = 1024 * 1024;
|
||||
const MAX_INVENTORY_BYTES = 1024 * 1024;
|
||||
const TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u;
|
||||
|
||||
class QingLong3ReleaseTagFinalizerError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3 release tag finalizer failed: ${message}`);
|
||||
this.name = 'QingLong3ReleaseTagFinalizerError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new QingLong3ReleaseTagFinalizerError(message);
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return `${JSON.stringify(value)}\n`;
|
||||
}
|
||||
|
||||
function currentUid() {
|
||||
if (typeof process.getuid !== 'function') {
|
||||
fail('a POSIX current user is required');
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function resolveCanonicalAbsolute(input, label) {
|
||||
if (
|
||||
typeof input !== 'string' ||
|
||||
!path.isAbsolute(input) ||
|
||||
path.resolve(input) !== input
|
||||
) {
|
||||
fail(`${label} path must be canonical and absolute`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function validatePrivateParent(parent, label) {
|
||||
const uid = currentUid();
|
||||
const stat = fs.lstatSync(parent);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o700 ||
|
||||
fs.realpathSync(parent) !== parent
|
||||
) {
|
||||
fail(`${label} parent must be a canonical current-user 0700 directory`);
|
||||
}
|
||||
}
|
||||
|
||||
function readPrivateCanonicalJson(filePath, label) {
|
||||
const resolved = resolveCanonicalAbsolute(filePath, label);
|
||||
validatePrivateParent(path.dirname(resolved), label);
|
||||
let descriptor;
|
||||
let before;
|
||||
let after;
|
||||
let bytes;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
resolved,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
before = fs.fstatSync(descriptor);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.uid !== currentUid() ||
|
||||
(before.mode & 0o777) !== 0o600 ||
|
||||
before.nlink !== 1 ||
|
||||
before.size < 2 ||
|
||||
before.size > MAX_JSON_BYTES ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail(`${label} must be a bounded current-user 0600 regular file`);
|
||||
}
|
||||
bytes = fs.readFileSync(descriptor);
|
||||
after = fs.fstatSync(descriptor);
|
||||
} catch (error) {
|
||||
if (error instanceof QingLong3ReleaseTagFinalizerError) throw error;
|
||||
fail(`${label} cannot be read through a stable descriptor`);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
if (
|
||||
before.dev !== after.dev ||
|
||||
before.ino !== after.ino ||
|
||||
before.size !== after.size ||
|
||||
before.mtimeMs !== after.mtimeMs ||
|
||||
before.ctimeMs !== after.ctimeMs ||
|
||||
bytes.byteLength !== before.size
|
||||
) {
|
||||
fail(`${label} changed while being read`);
|
||||
}
|
||||
const contents = bytes.toString('utf8');
|
||||
if (!Buffer.from(contents, 'utf8').equals(bytes)) {
|
||||
fail(`${label} must contain valid UTF-8`);
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch {
|
||||
fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
if (canonicalJson(value) !== contents) {
|
||||
fail(`${label} must use canonical JSON encoding`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function writePrivateNoReplace(filePath, value) {
|
||||
const resolved = resolveCanonicalAbsolute(filePath, 'output');
|
||||
validatePrivateParent(path.dirname(resolved), 'output');
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(resolved, 'wx', 0o600);
|
||||
fs.writeFileSync(descriptor, canonicalJson(value));
|
||||
fs.fsyncSync(descriptor);
|
||||
} catch {
|
||||
fail('output must be published once as a private regular file');
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRegistryAdapter(registry) {
|
||||
if (
|
||||
registry === null ||
|
||||
typeof registry !== 'object' ||
|
||||
typeof registry.resolveDigest !== 'function' ||
|
||||
typeof registry.listTags !== 'function' ||
|
||||
typeof registry.copyImage !== 'function'
|
||||
) {
|
||||
fail('registry adapter is incomplete');
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function parseTagInventory(contents) {
|
||||
if (
|
||||
typeof contents !== 'string' ||
|
||||
Buffer.byteLength(contents, 'utf8') > MAX_INVENTORY_BYTES ||
|
||||
(contents.length > 0 && !contents.endsWith('\n'))
|
||||
) {
|
||||
fail('release tag inventory is invalid or unbounded');
|
||||
}
|
||||
const tags = contents.length === 0 ? [] : contents.slice(0, -1).split('\n');
|
||||
if (
|
||||
tags.some((tag) => !TAG_PATTERN.test(tag)) ||
|
||||
new Set(tags).size !== tags.length
|
||||
) {
|
||||
fail('release tag inventory is malformed');
|
||||
}
|
||||
return Object.freeze([...tags]);
|
||||
}
|
||||
|
||||
function tagName(image, tag) {
|
||||
return tag.reference.slice(image.registryRepository.length + 1);
|
||||
}
|
||||
|
||||
function preflightReleaseTags(plan, registryInput) {
|
||||
validatePublicationPlan(plan);
|
||||
const registry = validateRegistryAdapter(registryInput);
|
||||
const states = [];
|
||||
for (const image of plan.images) {
|
||||
const sourceDigest = registry.resolveDigest(image.immutableReference);
|
||||
if (sourceDigest !== image.digest) {
|
||||
fail('source digest drifted before promotion');
|
||||
}
|
||||
const inventory = new Set(
|
||||
parseTagInventory(registry.listTags(image.registryRepository)),
|
||||
);
|
||||
for (const tag of image.tags) {
|
||||
const present = inventory.has(tagName(image, tag));
|
||||
if (present && registry.resolveDigest(tag.reference) !== image.digest) {
|
||||
fail('release tag already points at another digest');
|
||||
}
|
||||
states.push(Object.freeze({ image, tag, present }));
|
||||
}
|
||||
}
|
||||
return Object.freeze(states);
|
||||
}
|
||||
|
||||
function observeExactTags(plan, states, registry) {
|
||||
const observedTags = [];
|
||||
for (const state of states) {
|
||||
const digest = registry.resolveDigest(state.tag.reference);
|
||||
if (digest !== state.image.digest) {
|
||||
fail('promoted tag does not resolve to the release-set digest');
|
||||
}
|
||||
observedTags.push({
|
||||
image: state.image.name,
|
||||
kind: state.tag.kind,
|
||||
reference: state.tag.reference,
|
||||
digest,
|
||||
});
|
||||
}
|
||||
return createPublicationTagObservation(plan, observedTags);
|
||||
}
|
||||
|
||||
function finalizeReleaseTags(plan, registryInput) {
|
||||
validatePublicationPlan(plan);
|
||||
const registry = validateRegistryAdapter(registryInput);
|
||||
const states = preflightReleaseTags(plan, registry);
|
||||
for (const state of states) {
|
||||
if (!state.present) {
|
||||
registry.copyImage(state.image.immutableReference, state.tag.reference);
|
||||
}
|
||||
}
|
||||
return observeExactTags(plan, states, registry);
|
||||
}
|
||||
|
||||
function auditReleaseTags(plan, observation, registryInput) {
|
||||
validatePublicationPlan(plan);
|
||||
validateTagObservation(plan, observation);
|
||||
const registry = validateRegistryAdapter(registryInput);
|
||||
const states = preflightReleaseTags(plan, registry);
|
||||
if (states.some((state) => !state.present)) {
|
||||
fail('publication audit found an absent final tag');
|
||||
}
|
||||
const observed = observeExactTags(plan, states, registry);
|
||||
if (JSON.stringify(observed) !== JSON.stringify(observation)) {
|
||||
fail('publication observation differs from live registry state');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
planDigest: plan.planDigest,
|
||||
observationDigest: observation.observationDigest,
|
||||
tagCount: observation.tags.length,
|
||||
allTagsExactDigest: true,
|
||||
registryMutation: false,
|
||||
compatible: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRegctlExecutable(input) {
|
||||
const resolved = resolveCanonicalAbsolute(input, 'regctl');
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== currentUid() ||
|
||||
stat.nlink !== 1 ||
|
||||
(stat.mode & 0o111) === 0 ||
|
||||
(stat.mode & 0o022) !== 0 ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail('regctl must be a canonical current-user non-writable executable');
|
||||
}
|
||||
return Object.freeze({
|
||||
path: resolved,
|
||||
dev: stat.dev,
|
||||
ino: stat.ino,
|
||||
size: stat.size,
|
||||
uid: stat.uid,
|
||||
mode: stat.mode,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
ctimeMs: stat.ctimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
function revalidateRegctlExecutable(identity) {
|
||||
const stat = fs.lstatSync(identity.path);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(identity.path) !== identity.path ||
|
||||
stat.dev !== identity.dev ||
|
||||
stat.ino !== identity.ino ||
|
||||
stat.size !== identity.size ||
|
||||
stat.uid !== identity.uid ||
|
||||
stat.mode !== identity.mode ||
|
||||
stat.mtimeMs !== identity.mtimeMs ||
|
||||
stat.ctimeMs !== identity.ctimeMs
|
||||
) {
|
||||
fail('regctl executable identity changed during finalization');
|
||||
}
|
||||
}
|
||||
|
||||
function createRegctlAdapter(regctlInput) {
|
||||
const identity = resolveRegctlExecutable(regctlInput);
|
||||
const run = (operation, args, timeout = 30_000) => {
|
||||
revalidateRegctlExecutable(identity);
|
||||
const result = spawnSync(identity.path, args, {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_INVENTORY_BYTES,
|
||||
timeout,
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
revalidateRegctlExecutable(identity);
|
||||
if (result.error || result.status !== 0) {
|
||||
fail(`registry command failed during ${operation}`);
|
||||
}
|
||||
return result.stdout;
|
||||
};
|
||||
return Object.freeze({
|
||||
resolveDigest(reference) {
|
||||
return run('digest resolution', ['image', 'digest', reference]).trim();
|
||||
},
|
||||
listTags(repository) {
|
||||
return run('bounded tag inventory', [
|
||||
'tag',
|
||||
'ls',
|
||||
repository,
|
||||
'--format',
|
||||
'{{ range .Tags }}{{ println . }}{{ end }}',
|
||||
]);
|
||||
},
|
||||
copyImage(source, target) {
|
||||
run('tag promotion', ['image', 'copy', source, target], 120_000);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (const argument of argv) {
|
||||
const separator = argument.indexOf('=');
|
||||
if (
|
||||
!argument.startsWith('--') ||
|
||||
separator < 3 ||
|
||||
separator === argument.length - 1
|
||||
) {
|
||||
fail('arguments must use --name=value');
|
||||
}
|
||||
const key = argument.slice(2, separator);
|
||||
if (Object.hasOwn(values, key)) fail('arguments must be unique');
|
||||
values[key] = argument.slice(separator + 1);
|
||||
}
|
||||
const expected =
|
||||
values.mode === 'finalize'
|
||||
? ['mode', 'output', 'plan', 'regctl']
|
||||
: values.mode === 'audit'
|
||||
? ['mode', 'observation', 'plan', 'regctl']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify([...expected].sort())
|
||||
) {
|
||||
fail(
|
||||
'usage: --mode=finalize --plan=/absolute/plan.json --regctl=/absolute/regctl --output=/absolute/observation.json or --mode=audit --plan=/absolute/plan.json --regctl=/absolute/regctl --observation=/absolute/observation.json',
|
||||
);
|
||||
}
|
||||
return Object.freeze(values);
|
||||
}
|
||||
|
||||
function runCli(argv, output = process.stdout, dependencies = {}) {
|
||||
const options = parseArguments(argv);
|
||||
const plan = readPrivateCanonicalJson(options.plan, 'publication plan');
|
||||
validatePublicationPlan(plan);
|
||||
const registry = dependencies.registry ?? createRegctlAdapter(options.regctl);
|
||||
if (options.mode === 'finalize') {
|
||||
const observation = finalizeReleaseTags(plan, registry);
|
||||
writePrivateNoReplace(options.output, observation);
|
||||
output.write(canonicalJson(observation));
|
||||
return observation;
|
||||
}
|
||||
const observation = readPrivateCanonicalJson(
|
||||
options.observation,
|
||||
'tag observation',
|
||||
);
|
||||
const audit = auditReleaseTags(plan, observation, registry);
|
||||
output.write(canonicalJson(audit));
|
||||
return audit;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
runCli(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'release tag finalization failed'
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
MAX_INVENTORY_BYTES,
|
||||
QingLong3ReleaseTagFinalizerError,
|
||||
auditReleaseTags,
|
||||
createRegctlAdapter,
|
||||
finalizeReleaseTags,
|
||||
parseArguments,
|
||||
parseTagInventory,
|
||||
preflightReleaseTags,
|
||||
revalidateRegctlExecutable,
|
||||
runCli,
|
||||
});
|
||||
@@ -78,6 +78,15 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
],
|
||||
[
|
||||
'.github/workflows/ql3-image-release.yml',
|
||||
(source) =>
|
||||
source.replaceAll(
|
||||
'scripts/ql3-release-tag-finalizer.cjs',
|
||||
'scripts/unvalidated-inline-publisher.cjs',
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
],
|
||||
];
|
||||
for (const [target, transform, code] of fixtures) {
|
||||
const report = auditClusterCopilotConsoleDistribution({
|
||||
|
||||
@@ -136,9 +136,13 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
receiptAttested: true,
|
||||
},
|
||||
finalPublicationClosure: {
|
||||
finalizer: 'scripts/ql3-release-tag-finalizer.cjs',
|
||||
planSchema: 'qinglong/release-publication-plan@v2',
|
||||
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
|
||||
receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
|
||||
planValidatedBeforeRegistryAccess: true,
|
||||
hermeticResponseLossRehearsal: true,
|
||||
liveTerminalAuditBeforeClosure: true,
|
||||
catalogReadyBeforeTagMutation: true,
|
||||
deploymentReadyBeforeTagMutation: true,
|
||||
allTagsExactDigest: true,
|
||||
@@ -219,7 +223,7 @@ test('rejects removal of the durable release-catalog contract tests', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
/durable catalog, deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -230,7 +234,7 @@ test('rejects removal of the final publication closure contract tests', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
/durable catalog, deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,7 +245,18 @@ test('rejects removal of the deployment readiness contract tests', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
/durable catalog, deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the fail-closed tag finalizer tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3ReleaseTagFinalizer.test.cjs',
|
||||
'test/back/tag-finalizer-tests-removed.test.cjs',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/tag finalizer.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -263,7 +278,7 @@ test('rejects removal of the deployment-lock materialization contract tests', ()
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/deployment-lock and workflow negative tests/,
|
||||
/deployment-lock.*workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1170,14 +1185,29 @@ test('rejects deployment readiness without its own pre-promotion attestation', (
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects final tag promotion without bounded repository inventory', () => {
|
||||
test('rejects final tag promotion that bypasses the validated finalizer', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
" 'tag',\n 'ls',\n image.registryRepository,",
|
||||
" 'image',\n 'digest',\n image.registryRepository,",
|
||||
' --mode=finalize \\',
|
||||
' --mode=audit \\',
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects final tag promotion without a live read-only terminal audit', () => {
|
||||
const audited = [
|
||||
' --mode=audit \\',
|
||||
' --plan="${PUBLICATION_PLAN}" \\',
|
||||
' --regctl="${REGCTL}"',
|
||||
].join('\n');
|
||||
const unaudited = [
|
||||
' --mode=finalize \\',
|
||||
' --plan="${PUBLICATION_PLAN}" \\',
|
||||
' --regctl="${REGCTL}"',
|
||||
].join('\n');
|
||||
const mutated = releaseSource.replace(audited, unaudited);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects omission of the final publication closure audit', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' --mode=close \\',
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
PUBLICATION_PLAN_SCHEMA,
|
||||
} = require('../../scripts/ql3-release-publication-closure-contract.cjs');
|
||||
const {
|
||||
SCHEMA: DEPLOYMENT_READINESS_SCHEMA,
|
||||
} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs');
|
||||
const {
|
||||
RELEASE_SET_SCHEMA,
|
||||
} = require('../../scripts/ql3-release-set-contract.cjs');
|
||||
const {
|
||||
MAX_INVENTORY_BYTES,
|
||||
auditReleaseTags,
|
||||
createRegctlAdapter,
|
||||
finalizeReleaseTags,
|
||||
parseArguments,
|
||||
runCli,
|
||||
} = require('../../scripts/ql3-release-tag-finalizer.cjs');
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function publicationPlan() {
|
||||
const version = '3.0.0-alpha.0';
|
||||
const sourceRevision = 'a'.repeat(40);
|
||||
const repository = 'ghcr.io/qinglong-release/qinglong3-local-application';
|
||||
const imageDigest = `sha256:${'1'.repeat(64)}`;
|
||||
const manifestDigest = `sha256:${'2'.repeat(64)}`;
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: PUBLICATION_PLAN_SCHEMA,
|
||||
release: {
|
||||
version,
|
||||
sourceRevision,
|
||||
sourceRef: `refs/tags/v${version}`,
|
||||
scope: 'local',
|
||||
},
|
||||
releaseSet: {
|
||||
schema: RELEASE_SET_SCHEMA,
|
||||
releaseSetDigest: `sha256:${'3'.repeat(64)}`,
|
||||
contentDigest: `sha256:${'4'.repeat(64)}`,
|
||||
},
|
||||
catalog: {
|
||||
planDigest: `sha256:${'5'.repeat(64)}`,
|
||||
receiptDigest: `sha256:${'6'.repeat(64)}`,
|
||||
manifestDigest,
|
||||
immutableReference: `ghcr.io/qinglong-release/qinglong3-release-catalog@${manifestDigest}`,
|
||||
},
|
||||
deploymentReadiness: {
|
||||
schema: DEPLOYMENT_READINESS_SCHEMA,
|
||||
receiptDigest: `sha256:${'7'.repeat(64)}`,
|
||||
finalizerConsumptionDigest: `sha256:${'8'.repeat(64)}`,
|
||||
requiredDeploymentFamilies: ['local'],
|
||||
},
|
||||
requiredPrerequisites: {
|
||||
releaseSetProvenance: 'attested_before_catalog_publication',
|
||||
catalogSignature: 'verified_exact_workflow_identity',
|
||||
catalogProvenance: 'verified_source_tag_and_revision',
|
||||
catalogReceipt: 'attested_before_deployment_gates',
|
||||
deploymentReadiness: 'scope_exact_receipt_attested_before_tag_promotion',
|
||||
},
|
||||
promotionPolicy: {
|
||||
authority: 'verified_catalog_bound_deployments',
|
||||
inventory: 'bounded_exact_repository_tags',
|
||||
conflict: 'fail_closed_before_any_tag_mutation',
|
||||
recovery: 'reuse_exact_digest_only',
|
||||
finalVerification: 'all_tags_exact_digest',
|
||||
crossRepositoryAtomicity: false,
|
||||
registryTagCas: false,
|
||||
},
|
||||
images: [
|
||||
{
|
||||
name: 'local',
|
||||
registryRepository: repository,
|
||||
immutableReference: `${repository}@${imageDigest}`,
|
||||
digest: imageDigest,
|
||||
tags: [
|
||||
{ kind: 'version', reference: `${repository}:${version}` },
|
||||
{
|
||||
kind: 'source',
|
||||
reference: `${repository}:sha-${sourceRevision}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function splitTag(reference) {
|
||||
const separator = reference.lastIndexOf(':');
|
||||
return [reference.slice(0, separator), reference.slice(separator + 1)];
|
||||
}
|
||||
|
||||
class FakeRegistry {
|
||||
constructor(plan) {
|
||||
this.sources = new Map(
|
||||
plan.images.map((image) => [image.immutableReference, image.digest]),
|
||||
);
|
||||
this.repositories = new Map(
|
||||
plan.images.map((image) => [image.registryRepository, new Map()]),
|
||||
);
|
||||
this.calls = [];
|
||||
this.inventoryOverride = undefined;
|
||||
this.copyFailureAfterWrite = false;
|
||||
this.copyDrift = false;
|
||||
}
|
||||
|
||||
resolveDigest(reference) {
|
||||
this.calls.push(['resolveDigest', reference]);
|
||||
if (reference.includes('@')) return this.sources.get(reference);
|
||||
const [repository, tag] = splitTag(reference);
|
||||
return this.repositories.get(repository)?.get(tag);
|
||||
}
|
||||
|
||||
listTags(repository) {
|
||||
this.calls.push(['listTags', repository]);
|
||||
if (this.inventoryOverride !== undefined) return this.inventoryOverride;
|
||||
const tags = [...(this.repositories.get(repository)?.keys() ?? [])].sort();
|
||||
return tags.length === 0 ? '' : `${tags.join('\n')}\n`;
|
||||
}
|
||||
|
||||
copyImage(source, target) {
|
||||
this.calls.push(['copyImage', source, target]);
|
||||
const [repository, tag] = splitTag(target);
|
||||
const digest = this.copyDrift
|
||||
? `sha256:${'f'.repeat(64)}`
|
||||
: this.sources.get(source);
|
||||
this.repositories.get(repository).set(tag, digest);
|
||||
if (this.copyFailureAfterWrite) {
|
||||
this.copyFailureAfterWrite = false;
|
||||
throw new Error('simulated response loss');
|
||||
}
|
||||
}
|
||||
|
||||
seed(reference, digest) {
|
||||
const [repository, tag] = splitTag(reference);
|
||||
this.repositories.get(repository).set(tag, digest);
|
||||
}
|
||||
|
||||
copyCalls() {
|
||||
return this.calls.filter(([operation]) => operation === 'copyImage');
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryDirectory(t) {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-tag-finalizer-')),
|
||||
);
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return directory;
|
||||
}
|
||||
|
||||
function writeCanonical(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
}
|
||||
|
||||
test('finalizes every exact tag and audits the live terminal state', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
const observation = finalizeReleaseTags(plan, registry);
|
||||
assert.equal(registry.copyCalls().length, 2);
|
||||
assert.equal(observation.tags.length, 2);
|
||||
assert.equal(
|
||||
observation.tags.every((tag) => tag.digest === plan.images[0].digest),
|
||||
true,
|
||||
);
|
||||
const copiesBeforeAudit = registry.copyCalls().length;
|
||||
assert.deepEqual(auditReleaseTags(plan, observation, registry), {
|
||||
schemaVersion: 1,
|
||||
planDigest: plan.planDigest,
|
||||
observationDigest: observation.observationDigest,
|
||||
tagCount: 2,
|
||||
allTagsExactDigest: true,
|
||||
registryMutation: false,
|
||||
compatible: true,
|
||||
});
|
||||
assert.equal(registry.copyCalls().length, copiesBeforeAudit);
|
||||
});
|
||||
|
||||
test('validates the complete plan before any registry observation or mutation', () => {
|
||||
const plan = structuredClone(publicationPlan());
|
||||
plan.images[0].tags[0].reference =
|
||||
'ghcr.io/attacker/other-repository:3.0.0-alpha.0';
|
||||
const { planDigest: _discarded, ...unsigned } = plan;
|
||||
plan.planDigest = sha256(JSON.stringify(unsigned));
|
||||
const registry = new FakeRegistry(publicationPlan());
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/publication plan tag is invalid/,
|
||||
);
|
||||
assert.deepEqual(registry.calls, []);
|
||||
});
|
||||
|
||||
test('preflights every conflict before the first tag mutation', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.seed(plan.images[0].tags[1].reference, `sha256:${'e'.repeat(64)}`);
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/already points at another digest/,
|
||||
);
|
||||
assert.equal(registry.copyCalls().length, 0);
|
||||
});
|
||||
|
||||
test('recovers copy response loss by reusing exact tags and only filling absence', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.copyFailureAfterWrite = true;
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/simulated response loss/,
|
||||
);
|
||||
assert.equal(registry.copyCalls().length, 1);
|
||||
const observation = finalizeReleaseTags(plan, registry);
|
||||
assert.equal(registry.copyCalls().length, 2);
|
||||
assert.equal(observation.tags.length, 2);
|
||||
assert.equal(
|
||||
registry.resolveDigest(plan.images[0].tags[0].reference),
|
||||
plan.images[0].digest,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed or unbounded inventory before mutation', () => {
|
||||
for (const inventory of [
|
||||
'duplicate\nduplicate\n',
|
||||
'not canonical',
|
||||
`${'x'.repeat(MAX_INVENTORY_BYTES + 1)}\n`,
|
||||
]) {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.inventoryOverride = inventory;
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/inventory is malformed|inventory is invalid or unbounded/,
|
||||
);
|
||||
assert.equal(registry.copyCalls().length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails terminal observation when a copy resolves to the wrong digest', () => {
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
registry.copyDrift = true;
|
||||
assert.throws(
|
||||
() => finalizeReleaseTags(plan, registry),
|
||||
/promoted tag does not resolve/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runs canonical private no-replace finalize and read-only audit stages', (t) => {
|
||||
const directory = temporaryDirectory(t);
|
||||
const plan = publicationPlan();
|
||||
const registry = new FakeRegistry(plan);
|
||||
const planPath = path.join(directory, 'plan.json');
|
||||
const observationPath = path.join(directory, 'observation.json');
|
||||
writeCanonical(planPath, plan);
|
||||
const output = { write() {} };
|
||||
const observation = runCli(
|
||||
[
|
||||
'--mode=finalize',
|
||||
`--plan=${planPath}`,
|
||||
'--regctl=/unused/injected-regctl',
|
||||
`--output=${observationPath}`,
|
||||
],
|
||||
output,
|
||||
{ registry },
|
||||
);
|
||||
assert.equal(fs.statSync(observationPath).mode & 0o777, 0o600);
|
||||
assert.equal(
|
||||
fs.readFileSync(observationPath, 'utf8'),
|
||||
`${JSON.stringify(observation)}\n`,
|
||||
);
|
||||
const audit = runCli(
|
||||
[
|
||||
'--mode=audit',
|
||||
`--plan=${planPath}`,
|
||||
'--regctl=/unused/injected-regctl',
|
||||
`--observation=${observationPath}`,
|
||||
],
|
||||
output,
|
||||
{ registry },
|
||||
);
|
||||
assert.equal(audit.compatible, true);
|
||||
assert.equal(audit.registryMutation, false);
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
[
|
||||
'--mode=finalize',
|
||||
`--plan=${planPath}`,
|
||||
'--regctl=/unused/injected-regctl',
|
||||
`--output=${observationPath}`,
|
||||
],
|
||||
output,
|
||||
{ registry },
|
||||
),
|
||||
/output must be published once/,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts only closed CLI modes and a hardened regctl executable', (t) => {
|
||||
assert.deepEqual(
|
||||
parseArguments([
|
||||
'--mode=finalize',
|
||||
'--plan=/private/plan.json',
|
||||
'--regctl=/private/regctl',
|
||||
'--output=/private/observation.json',
|
||||
]),
|
||||
{
|
||||
mode: 'finalize',
|
||||
plan: '/private/plan.json',
|
||||
regctl: '/private/regctl',
|
||||
output: '/private/observation.json',
|
||||
},
|
||||
);
|
||||
for (const argv of [
|
||||
['--mode=finalize', '--plan=/p', '--regctl=/r'],
|
||||
[
|
||||
'--mode=audit',
|
||||
'--plan=/p',
|
||||
'--regctl=/r',
|
||||
'--observation=/o',
|
||||
'--force=true',
|
||||
],
|
||||
['--mode=unknown', '--plan=/p', '--regctl=/r', '--output=/o'],
|
||||
]) {
|
||||
assert.throws(() => parseArguments(argv), /usage/);
|
||||
}
|
||||
|
||||
const directory = temporaryDirectory(t);
|
||||
const executable = path.join(directory, 'regctl');
|
||||
fs.writeFileSync(executable, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const pinnedAdapter = createRegctlAdapter(executable);
|
||||
assert.equal(typeof pinnedAdapter.resolveDigest, 'function');
|
||||
fs.chmodSync(executable, 0o700);
|
||||
assert.throws(
|
||||
() => pinnedAdapter.resolveDigest('ghcr.io/example/image@sha256:digest'),
|
||||
/identity changed/,
|
||||
);
|
||||
fs.chmodSync(executable, 0o775);
|
||||
assert.throws(() => createRegctlAdapter(executable), /non-writable/);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
const alias = path.join(directory, 'regctl-alias');
|
||||
fs.symlinkSync(executable, alias);
|
||||
assert.throws(() => createRegctlAdapter(alias), /non-writable/);
|
||||
});
|
||||
Reference in New Issue
Block a user