mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 08:05:22 +08:00
feat(ql3): govern release version transitions
This commit is contained in:
@@ -301,8 +301,10 @@ jobs:
|
||||
test/back/ql3ClusterImageReleaseAudit.test.cjs
|
||||
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
||||
test/back/ql3LocalImageAudit.test.cjs
|
||||
test/back/ql3VersionTransition.test.cjs
|
||||
- name: Audit cluster image release contract
|
||||
run: |
|
||||
pnpm audit:release-version:ql3
|
||||
pnpm audit:image-release:ql3
|
||||
pnpm audit:local-image:ql3
|
||||
- name: Reject high or critical advisories in 3.0 Profile importers
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
最新增量证据(2026-08-16):
|
||||
|
||||
- D-334/ADR-0426(已接受):根级 canonical `ql3-release.json` 现在是唯一 QingLong 3 release identity authority,精确冻结 3.x SemVer、Node 24.18.0/engine、18-package 边界和 legacy 2.x 排除事实;发布候选、四组容器、Cluster/Worker/Console 部署、Local/Cluster image audit、CloudNativePG、物理 Edge 与外部恢复审计均改为读取同一 authority,candidate contract 额外绑定 identity schema 与 SHA-256。共享 CI 新增 `audit:release-version:ql3`,失败关闭 18 个 workspace、四组 build/runtime manifest+lock、Dockerfile Node/version label 与 242 个部署文本文件中的 32 个 image reference/36 个版本 occurrence。维护者升级版本必须走 closed `audit|plan|apply`:plan 只接受严格递增 exact v3 SemVer并生成 no-replace `0600`、逐文件 path/mode/replacement/before-after bytes+digest 和自身 digest;apply 先全量预检 65 文件/83 处替换,再用同目录确定性临时文件、fsync+rename 逐文件收敛,允许 source/target 混合状态原 plan 幂等恢复并生成 digest-bound report,绝不修改 legacy 根 2.x、自动 commit/tag/push 或宣称跨文件单事务。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、Pod、listener、timer、watcher 或任何低配/集群常驻开销。定向回归 177/177,backend 1,254 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;完整回归未发现数据库或部署拓扑漂移。
|
||||
- D-333/ADR-0425(已接受;公开发布结果待实际 tag):3.0 发布入口不再把所有部署者绑成一个不可分割矩阵。唯一 `.github/workflows/ql3-image-release.yml` 增加 closed `local|cluster|all` deployment-family scope;根级 source-derived release-candidate contract 从 exact `v3` SemVer/tag/40-hex revision、18 个边界审计通过且非 single/shallow 的 workspace、Node 24.18.0 engine、容器 runtime manifest/Dockerfile version、双架构和部署 profile 推导唯一 OS/publish matrix,并以 canonical SHA-256 失败关闭版本或源码漂移。`local` 只发布 AI-excluded Local image、只要求 Edge/Standalone digest rollout,不再等待 Worker management/CloudNativePG 私有 HA evidence;`cluster` 才要求两个 ephemeral private evidence gate,并闭合此前遗漏的 `qinglong3-worker`,与 control/control-ai/admin 一同进入 native amd64/arm64 build-once、Trivy OS scan、CycloneDX、OCI merge、Cosign 与 GitHub attestation 链;`all` 同时保留两族门禁。legacy 根 `2.21.0-14` 被显式标记为不参与 3.0 release identity,而不是伪改旧产品版本。Worker 现在有 27-component(24 external/3 internal)、28-node 的 production SBOM,BSD-3-Clause 纳入受审 allowlist,Worker config 固定 `65532:65532`、`worker` profile、`edge,node` capacity labels 和 3.0 version;control/admin 也补齐同一 version label。candidate contract 作为第四类 digest-bound GitHub predicate 发布并远端回读,Cluster Admin verifier/外部 ceremony/offline audit 同步升级为四类 attestation/八步 transcript。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、listener、timer、watcher 或低配设备常驻资源。定向 105/105、backend 1,246 pass/2 条件 skip/0 fail、18-package clean build/test 均通过;package boundary 确认为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release、OS vulnerability policy、Console/distribution 审计均 compatible,四个 runtime dependency root 的离线缓存审计为 0 vulnerability。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin npm pack 仍为 250 files、271,238-byte tarball、1,690,196-byte unpacked。由于本 Gate 不改变 schema、migration、SQL、role、Pool 或连接/HA 拓扑,不重复执行 PostgreSQL 门,继续复用 D-331 的 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。公开 tag/digest 尚不存在,因此不宣称真实 GHCR/Cosign/attestation 发布成功,在线依赖漏洞新鲜度与五镜像远端门由实际 release workflow 重新取得。
|
||||
- D-332/ADR-0424(实现门完成、外部验收待公开 release):从 exact reviewed `v3.*` source tag 执行的 Cluster Admin release workstation ceremony 已实现为根级 runner + 独立 offline auditor,不新增 workspace package、生产依赖、产品命令、镜像内容或常驻组件。runner 只接受 owner-bound `ghcr.io/<owner>/qinglong3-cluster-admin@sha256:<digest>`、40-hex source revision、完整 tag ref、canonical absolute `cosign|gh|docker`、current-owner `0600` 短期 GitHub token file 与 no-replace 私有 report;三个工具按绝对路径直接执行且前后复验 inode/size/SHA-256,不经 shell/ambient PATH,token 只注入 4 个 `gh attestation verify` 子进程。ceremony 精确验证 keyless workflow identity、provenance、CycloneDX、OS-vulnerability evidence 与 D-333 source-derived release-candidate contract,拉取并 inspect 同一 RepoDigest,再在 non-root/read-only/network-none/drop-ALL/no-new-privileges/128 MiB/0.25 CPU/32 PIDs 下运行 release image 内置 `evidence-verify` 检查固定非敏感 vector。成功报告只含 public release identity、tool/argv/stdout/stderr digest、字节数、isolation/limitation 与自身 canonical SHA-256,不含原始 transcript、token、路径或 workstation identity;offline auditor 只证明 canonical structure、digest 和 expected identity binding,明确 `externalResults=not_replayed`、`reportAttestation=none`、`actionAuthority=none`。定向正负门覆盖 token 隔离、mutable/source drift、tool/file authority drift、no-replace、结构重签和 report swapping;backend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow package;npm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;package/dependency/Edge import/Cluster deployment/image release/OS vulnerability/Console/distribution 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 与 MCP 也不变。本门无 schema/migration/SQL/role/Pool/连接拓扑变化,复用紧邻 D-331 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。由于当前没有公开 3.0 release digest,且工作站没有真实 `gh/cosign`,ADR-0424 必须保持 Proposed;stub 或本地 image 不能冒充最终外部 ceremony,公开 digest 可用后才记录真实 report/tool digest 并转 Accepted。
|
||||
- D-331/ADR-0423(已接受):`@qinglong/cluster-admin` 在既有 `copilot-console/` 职责目录增加独立 TypeScript evidence verifier,并以第 11 个静态产品命令 `ql3-cluster-admin evidence-verify --bundle=/absolute/evidence.json` 交付。它只通过 no-follow/stable descriptor 读取一个最大 512 KiB 的 canonical absolute UTF-8 JSON,拒绝 BOM、CRLF、minified、duplicate-key、symlink、relative path 与读取中漂移;独立固定检查 exact bundle/request shape、13 operations、16-entry/8 MiB/64-item/depth/key ceiling、安全字段白名单和顺序 typed alias,再重算不含 `contentDigest` 的 canonical SHA-256。结果明确只证明 `bundleDigest=verified`;没有原始 fact 时逐条 digest 为 `not_recomputed_without_raw_facts`,server signature/attestation/durable audit 均未验证且 action authority 为 none。实现不读 stdin/environment/context,不联网、不写文件、不新增 package、依赖、route、listener、数据库、Kubernetes workload 或 Edge/Standalone closure。定向门 18/18,Cluster Admin 387 pass/3 条件 skip,18-package clean build/test 退出 0,backend 1,225 pass/2 条件 skip/0 fail。真实 arm64 Admin image `qinglong3-cluster-admin:d331-local` 为 344,567,527 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 11 个命令、有效 bundle、tamper rejection 与零 verifier file write。npm pack dry-run 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;结构/依赖/部署/发布/Console 审计零 finding,workspace 保持 18 package、无 single/shallow package,Cluster Admin 122 个源码中 121 个位于领域目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes。因本门没有 schema/migration/SQL/role/Pool/连接拓扑变化,不重复冒充执行 HA,复用紧邻 D-330 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一门应完成公开 release digest 的外部工作站 ceremony,不得给 verifier 增加上传、签名或行动能力。
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR-0426:以单一源码身份治理 3.0 版本,并提供可恢复的版本迁移
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-16
|
||||
- 关联 RFC:QL-RFC-0001 D-01、D-03、D-14、D-42、D-61、D-186、D-333、D-334
|
||||
- 关联 ADR:ADR-0196、ADR-0253、ADR-0254、ADR-0255、ADR-0425
|
||||
|
||||
## 背景
|
||||
|
||||
QingLong 3.0 的当前版本同时存在于 18 个 workspace manifest、四组容器 build/runtime manifest 与 lock、
|
||||
四个 Dockerfile label,以及 Kubernetes/Console 部署材料。D-333 已能在候选发布时发现 version/tag 漂移,
|
||||
但没有定义哪个文件是版本 authority,也没有提供从一个版本安全迁移到下一个版本的正式路径。人工批量替换
|
||||
会漏改部署面、误改 legacy 2.x 根 package,或在进程中断后留下无法判断的新旧混合状态。
|
||||
|
||||
版本治理本身不应进入 Edge、Standalone 或 Cluster 常驻运行时,也不能为了统一版本引入新的 workspace package。
|
||||
|
||||
## 决策
|
||||
|
||||
### 1. `ql3-release.json` 是唯一 3.x release identity authority
|
||||
|
||||
根级 canonical JSON 固定 product、exact 3.x SemVer、Node 版本/engine、workspace package 数量以及 legacy 根排除事实。
|
||||
读取者只接受 bounded、canonical、non-symlink regular file、精确字段顺序和值;SemVer 必须同时通过 3.x 约束和
|
||||
标准 SemVer 校验。legacy 根 `package.json` 的 2.x version 明确不参与 QingLong 3 release identity。
|
||||
|
||||
发布候选、容器/部署审计和物理 Edge 证据不再各自保存一份 3.0 常量,而是读取同一 authority。D-333 candidate
|
||||
contract 还会携带 identity schema 与 canonical SHA-256,使发布证明能发现 authority 被事后替换。
|
||||
|
||||
### 2. CI 对完整版本表面执行失败关闭审计
|
||||
|
||||
`audit:release-version:ql3` 必须验证:
|
||||
|
||||
- 18 个 workspace version 与 Node engine;
|
||||
- 四组容器 build/runtime manifest、lock、Dockerfile Node base 和 OCI version label;
|
||||
- Kubernetes Cluster/Worker 与 Console 部署材料中的 QingLong 3 image/source tag;
|
||||
- legacy 根仍为不同的 2.x version,且没有被纳入迁移集合。
|
||||
|
||||
审计只读取源码文件,具有 4 MiB 单文件、512 个受管文件和 canonical path/symlink 上限,不启动 listener、timer、
|
||||
数据库或容器。共享 CI 与 image-release 静态审计均必须证明该 gate 存在,不能只依赖实际发布时才发现漂移。
|
||||
|
||||
### 3. 版本升级使用 review-first 的 `plan → apply` 两阶段协议
|
||||
|
||||
`ql3-version-transition.cjs` 只接受三个封闭模式:
|
||||
|
||||
1. `--mode=audit`:审计当前 identity;
|
||||
2. `--mode=plan --from=<current> --to=<newer> --output=<absolute>`:生成 no-replace `0600` plan;
|
||||
3. `--mode=apply --plan=<absolute> --report=<absolute>`:应用已审阅 plan 并生成 no-replace `0600` report。
|
||||
|
||||
目标必须是严格单调递增的 exact QingLong 3 SemVer;降级、相等版本、build metadata 和非 canonical SemVer 均拒绝。
|
||||
plan 精确列出每个 path、mode、替换次数、before/after bytes 与 SHA-256,并对 unsigned canonical 内容形成自身 digest。
|
||||
当前 `3.0.0-alpha.0 → 3.0.0-alpha.1` 计划覆盖 65 个文件、83 处替换,根 2.x package 不在集合中。
|
||||
|
||||
### 4. apply 必须先全量预检,再允许逐文件收敛
|
||||
|
||||
apply 在第一次写入前验证 plan 自身、legacy 版本、完整文件集合,以及每个受管文件的 mode 和 before/after digest。
|
||||
任何第三种状态都使整次操作在无源码 mutation 时失败。通过预检后,每个 source 状态文件先写同目录确定性临时文件、
|
||||
`fsync`,再 atomic rename;已经处于 target 状态的文件被计入 recovery,而不是报错。因而进程在部分 rename 后中断时,
|
||||
原 plan 可原样重放直至全部 target,成功后再运行完整 identity audit。report 区分 changed/already-current,并以 canonical
|
||||
SHA-256 绑定 plan 和结果。
|
||||
|
||||
该协议提供进程中断后的幂等恢复,不宣称跨 65 个文件的单事务原子性,也不替代 Git review/commit。机器断电时的目录项
|
||||
持久性由文件系统和 Git 工作区恢复承担;apply 不自动 commit、tag、push 或触发 release。
|
||||
|
||||
## 资源与权限边界
|
||||
|
||||
- 不新增 workspace package、生产 dependency、schema、migration、SQL、role、Pool、Pod 或容器;
|
||||
- 所有版本命令是维护者显式启动的短生命周期 Node 进程,Edge/Standalone/Cluster 运行时零常驻开销;
|
||||
- plan/report 必须写入 canonical absolute、尚不存在的路径,拒绝 symlink 与覆盖;
|
||||
- 工具只修改 plan 中经 before digest 证明的仓库文件,不触碰 legacy 根 version;
|
||||
- 版本迁移完成后仍须经过完整回归、GitNexus `detect-changes` 和人工阶段提交。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- audit 漂移:先修复 authority 或受管表面,不在发布 workflow 内临时覆盖;
|
||||
- plan 后源码漂移:废弃旧 plan,重新 audit/plan/review;
|
||||
- apply 部分完成:保留同一 plan,使用新 report path 原样重放;
|
||||
- report path 已存在:选择新 path,不覆盖旧证据;
|
||||
- 非 3.x、降级或非法 SemVer:拒绝迁移,另行走兼容/回滚决策;
|
||||
- Git review 发现非预期文件:不提交,修复受管集合或计划生成器后重新执行。
|
||||
|
||||
## 被拒绝的替代方案
|
||||
|
||||
### 让根 2.x `package.json` 成为 3.0 版本源
|
||||
|
||||
拒绝。该文件仍服务 legacy 产品与现有构建,强行改成 3.x 会把兼容线和新架构发布线混为一体。
|
||||
|
||||
### 在 release workflow 内直接 `sed` 全仓版本
|
||||
|
||||
拒绝。它没有可审阅的精确文件集合、before digest、全量预检或部分失败恢复,还会让 tag 构建修改 checkout。
|
||||
|
||||
### 为每个 package 使用独立版本
|
||||
|
||||
拒绝。3.0 当前发布的是同一产品候选和闭合镜像集合;独立版本会放大部署 compatibility matrix。若未来确需独立发布,
|
||||
应以新的 package/release RFC 显式改变 authority,而不是允许静默漂移。
|
||||
|
||||
## 验证
|
||||
|
||||
- 版本 identity/audit/plan/apply/replay/partial recovery/no-mutation preflight/CLI 负向门已实现;
|
||||
- release candidate、Cluster/Local image、OCI、部署、CloudNativePG、物理 Edge 与外部恢复定向回归 177/177;
|
||||
- backend 1,254 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 保持 18 个 package、
|
||||
`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster deployment、image release 与
|
||||
Local image 审计均 compatible;
|
||||
- 14 档 Local artifact 全部 compatible:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes、315 files、56 modules,
|
||||
application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持
|
||||
250 files、271,238-byte tarball、1,690,196-byte unpacked;
|
||||
- 格式与 `git diff --check` 已通过;GitNexus 索引与 `detect-changes` 在阶段提交前最终刷新;
|
||||
- 本 Gate 不修改数据库或 HA 拓扑,PostgreSQL physical HA 复用 D-331/D-333 的 18.6 arm64 142/142、timeline `1→2`
|
||||
基线;若完整回归发现数据库/部署契约漂移,则必须重新运行 PostgreSQL HA 门而不能复用。
|
||||
@@ -76,6 +76,7 @@
|
||||
"audit:edge-imports:ql3": "node scripts/ql3-edge-import-audit.cjs",
|
||||
"audit:cluster-dependencies:ql3": "node scripts/ql3-cluster-dependency-audit.cjs",
|
||||
"audit:package-boundaries:ql3": "node scripts/ql3-package-boundary-audit.cjs",
|
||||
"audit:release-version:ql3": "node scripts/ql3-version-transition.cjs --mode=audit",
|
||||
"test:service-manager-bridge:ql3": "node scripts/ql3-service-manager-bridge-docker-gate.cjs",
|
||||
"audit:service-manager-bridge-imports:ql3": "node scripts/ql3-service-bridge-import-audit.cjs",
|
||||
"audit:prompt-output-external-recovery-deployment:ql3": "node scripts/ql3-prompt-output-external-recovery-deployment-audit.cjs",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"schema": "qinglong/release-identity@v1",
|
||||
"product": "qinglong3",
|
||||
"version": "3.0.0-alpha.0",
|
||||
"node": {
|
||||
"version": "24.18.0",
|
||||
"engine": ">=24.18.0 <25"
|
||||
},
|
||||
"workspacePackageCount": 18,
|
||||
"legacyRootPackageExcluded": true
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const semver = require('semver');
|
||||
|
||||
const RELEASE_IDENTITY_PATH = 'ql3-release.json';
|
||||
const RELEASE_IDENTITY_SCHEMA = 'qinglong/release-identity@v1';
|
||||
const MAX_RELEASE_IDENTITY_BYTES = 4096;
|
||||
const VERSION_PATTERN =
|
||||
/^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u;
|
||||
|
||||
class QingLong3ReleaseIdentityError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3 release identity failed: ${message}`);
|
||||
this.name = 'QingLong3ReleaseIdentityError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new QingLong3ReleaseIdentityError(message);
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeReleaseIdentity(value) {
|
||||
if (
|
||||
!exactKeys(value, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'product',
|
||||
'version',
|
||||
'node',
|
||||
'workspacePackageCount',
|
||||
'legacyRootPackageExcluded',
|
||||
]) ||
|
||||
value.schemaVersion !== 1 ||
|
||||
value.schema !== RELEASE_IDENTITY_SCHEMA ||
|
||||
value.product !== 'qinglong3' ||
|
||||
typeof value.version !== 'string' ||
|
||||
!VERSION_PATTERN.test(value.version) ||
|
||||
semver.valid(value.version) !== value.version ||
|
||||
!exactKeys(value.node, ['version', 'engine']) ||
|
||||
value.node.version !== '24.18.0' ||
|
||||
value.node.engine !== '>=24.18.0 <25' ||
|
||||
value.workspacePackageCount !== 18 ||
|
||||
value.legacyRootPackageExcluded !== true
|
||||
) {
|
||||
fail('identity shape or value is incompatible');
|
||||
}
|
||||
return Object.freeze({
|
||||
...value,
|
||||
node: Object.freeze({ ...value.node }),
|
||||
});
|
||||
}
|
||||
|
||||
function readReleaseIdentity(root) {
|
||||
const resolvedRoot = fs.realpathSync(path.resolve(root));
|
||||
const filePath = path.join(resolvedRoot, RELEASE_IDENTITY_PATH);
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_RELEASE_IDENTITY_BYTES ||
|
||||
fs.realpathSync(filePath) !== filePath
|
||||
) {
|
||||
fail('identity file must be one bounded canonical regular file');
|
||||
}
|
||||
const contents = fs.readFileSync(filePath, 'utf8');
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(contents);
|
||||
} catch {
|
||||
fail('identity file must contain valid JSON');
|
||||
}
|
||||
const identity = normalizeReleaseIdentity(parsed);
|
||||
if (`${JSON.stringify(identity, null, 2)}\n` !== contents) {
|
||||
fail('identity file must use exact canonical JSON encoding');
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
MAX_RELEASE_IDENTITY_BYTES,
|
||||
RELEASE_IDENTITY_PATH,
|
||||
RELEASE_IDENTITY_SCHEMA,
|
||||
VERSION_PATTERN,
|
||||
QingLong3ReleaseIdentityError,
|
||||
normalizeReleaseIdentity,
|
||||
readReleaseIdentity,
|
||||
});
|
||||
@@ -8,12 +8,13 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const QL3_VERSION = readReleaseIdentity(ROOT).version;
|
||||
const NAMESPACE = 'qinglong3-system';
|
||||
const POSTGRES_CLUSTER = 'ql3-postgres';
|
||||
const APP_IMAGE =
|
||||
'registry.example.com/qinglong/qinglong3-cluster-control:3.0.0-alpha.0';
|
||||
const APP_IMAGE = `registry.example.com/qinglong/qinglong3-cluster-control:${QL3_VERSION}`;
|
||||
const APP_IMAGE_PLACEHOLDER = `registry.example.com/qinglong/qinglong3-cluster-control@sha256:${'0'.repeat(
|
||||
64,
|
||||
)}`;
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { resolve } = require('node:path');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const QL3_VERSION = readReleaseIdentity(resolve(__dirname, '..')).version;
|
||||
|
||||
const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/u;
|
||||
const ENTRYPOINT = [
|
||||
@@ -608,7 +611,7 @@ function main() {
|
||||
}
|
||||
}
|
||||
const version = runImage(image, ['--version']).trim();
|
||||
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
|
||||
if (version !== QL3_VERSION) fail('product version contract drifted');
|
||||
runOperatorContextContract(image);
|
||||
runConsoleContract(image);
|
||||
runEvidenceVerifierContract(image);
|
||||
|
||||
@@ -4,6 +4,9 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const yaml = require('js-yaml');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const QL3_VERSION = readReleaseIdentity(path.resolve(__dirname, '..')).version;
|
||||
|
||||
const EXPECTED_EXTERNAL_DEPENDENCIES = Object.freeze({
|
||||
'@aws-sdk/client-s3': '3.1093.0',
|
||||
@@ -97,7 +100,7 @@ function assertClusterAdminImageCommands(readFile, root, findings) {
|
||||
const podSpec = podSpecFor(document);
|
||||
for (const section of ['initContainers', 'containers']) {
|
||||
for (const container of podSpec?.[section] ?? []) {
|
||||
if (container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0') {
|
||||
if (container?.image !== `qinglong3-cluster-admin:${QL3_VERSION}`) {
|
||||
continue;
|
||||
}
|
||||
references += 1;
|
||||
@@ -457,8 +460,7 @@ function assertExactExternalClosure(readFile, root, findings) {
|
||||
);
|
||||
if (
|
||||
adminManifest.bin?.['ql3-cluster-admin'] !== 'dist/product-cli/cli.js' ||
|
||||
adminManifest.bin?.['ql3-copilot-mcp'] !==
|
||||
'dist/copilot-mcp/cli.js' ||
|
||||
adminManifest.bin?.['ql3-copilot-mcp'] !== 'dist/copilot-mcp/cli.js' ||
|
||||
adminManifest.exports?.['./copilot-mcp']?.require !==
|
||||
'./dist/copilot-mcp/server.js' ||
|
||||
adminManifest.bin?.['ql3-plugin-package-recover'] !==
|
||||
@@ -1174,7 +1176,7 @@ function assertKubernetes(readFile, root, findings) {
|
||||
);
|
||||
}
|
||||
if (
|
||||
recoveryContainer?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
recoveryContainer?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
JSON.stringify(recoveryContainer?.command) !==
|
||||
JSON.stringify([
|
||||
'node',
|
||||
@@ -1420,13 +1422,22 @@ function assertClusterAiComponent(readFile, root, findings) {
|
||||
'deploy/kubernetes/ql3-cluster/components/cluster-ai-copilot',
|
||||
);
|
||||
const copilotComponent = yaml.load(
|
||||
readFile(path.join(copilotComponentDirectory, 'kustomization.yaml'), 'utf8'),
|
||||
readFile(
|
||||
path.join(copilotComponentDirectory, 'kustomization.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const copilotPatch = yaml.load(
|
||||
readFile(path.join(copilotComponentDirectory, 'deployment-patch.yaml'), 'utf8'),
|
||||
readFile(
|
||||
path.join(copilotComponentDirectory, 'deployment-patch.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const copilotConfig = yaml.load(
|
||||
readFile(path.join(copilotComponentDirectory, 'copilot-configmap.yaml'), 'utf8'),
|
||||
readFile(
|
||||
path.join(copilotComponentDirectory, 'copilot-configmap.yaml'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const copilotOverlay = yaml.load(
|
||||
readFile(
|
||||
@@ -1496,8 +1507,8 @@ function assertClusterAiComponent(readFile, root, findings) {
|
||||
return (
|
||||
mount?.mountPath !== mountPath ||
|
||||
mount?.readOnly !== true ||
|
||||
projection?.name !== authorityName &&
|
||||
projection?.secretName !== authorityName ||
|
||||
(projection?.name !== authorityName &&
|
||||
projection?.secretName !== authorityName) ||
|
||||
projection?.defaultMode !== 0o440 ||
|
||||
projection?.optional === true ||
|
||||
JSON.stringify(projection?.items) !==
|
||||
@@ -1721,7 +1732,7 @@ function assertClusterAiComponent(readFile, root, findings) {
|
||||
if (
|
||||
patch?.kind !== 'Deployment' ||
|
||||
patch?.metadata?.name !== 'ql3-cluster-control' ||
|
||||
patchContainer?.image !== 'qinglong3-cluster-control-ai:3.0.0-alpha.0' ||
|
||||
patchContainer?.image !== `qinglong3-cluster-control-ai:${QL3_VERSION}` ||
|
||||
patchPod?.serviceAccountName !== undefined ||
|
||||
patchPod?.automountServiceAccountToken !== undefined ||
|
||||
authorityMount?.mountPath !== '/var/run/qinglong3/ai/provider-authority' ||
|
||||
@@ -1830,7 +1841,7 @@ function assertClusterAiComponent(readFile, root, findings) {
|
||||
const baseContainer = namedEntry(basePod?.containers, 'cluster-control');
|
||||
const baseEnv = environmentByName(baseContainer);
|
||||
if (
|
||||
baseContainer?.image !== 'qinglong3-cluster-control:3.0.0-alpha.0' ||
|
||||
baseContainer?.image !== `qinglong3-cluster-control:${QL3_VERSION}` ||
|
||||
[...baseEnv.keys()].some((name) => name.startsWith('QL3_CLUSTER_AI_')) ||
|
||||
namedEntry(baseContainer?.volumeMounts, 'cluster-ai-provider-authority') ||
|
||||
namedEntry(baseContainer?.volumeMounts, 'cluster-ai-provider-secrets') ||
|
||||
@@ -1841,9 +1852,10 @@ function assertClusterAiComponent(readFile, root, findings) {
|
||||
namedEntry(basePod?.volumes, 'cluster-ai-provider-authority') ||
|
||||
namedEntry(basePod?.volumes, 'cluster-ai-provider-secrets') ||
|
||||
namedEntry(basePod?.volumes, 'cluster-ai-prompt-output-keyring') ||
|
||||
[...copilotProjections].some(([name]) =>
|
||||
namedEntry(baseContainer?.volumeMounts, name) ||
|
||||
namedEntry(basePod?.volumes, name),
|
||||
[...copilotProjections].some(
|
||||
([name]) =>
|
||||
namedEntry(baseContainer?.volumeMounts, name) ||
|
||||
namedEntry(basePod?.volumes, name),
|
||||
)
|
||||
) {
|
||||
findings.push(
|
||||
@@ -1942,7 +1954,7 @@ function assertPluginPackageManagementDeployment(readFile, root, findings) {
|
||||
);
|
||||
}
|
||||
if (
|
||||
container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
container?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
JSON.stringify(container?.command) !==
|
||||
JSON.stringify([
|
||||
'node',
|
||||
@@ -2384,7 +2396,7 @@ function assertWorkerCredentialManagementDeployment(readFile, root, findings) {
|
||||
);
|
||||
}
|
||||
if (
|
||||
container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
container?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
JSON.stringify(container?.command) !==
|
||||
JSON.stringify([
|
||||
'node',
|
||||
@@ -2777,7 +2789,7 @@ function assertWorkerCredentialManagementClientOperation(
|
||||
const readinessScript = String(init?.args?.[0] ?? '');
|
||||
const clientScript = String(container?.args?.[0] ?? '');
|
||||
if (
|
||||
init?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
init?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
init?.imagePullPolicy !== 'IfNotPresent' ||
|
||||
JSON.stringify(init?.command) !== JSON.stringify(['node', '-e']) ||
|
||||
!readinessScript.includes(
|
||||
@@ -2790,7 +2802,7 @@ function assertWorkerCredentialManagementClientOperation(
|
||||
!readinessScript.includes('cert,') ||
|
||||
!readinessScript.includes('key,') ||
|
||||
!readinessScript.includes('attempt <= 30') ||
|
||||
container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
container?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
container?.imagePullPolicy !== 'IfNotPresent' ||
|
||||
JSON.stringify(container?.command) !== JSON.stringify(['/bin/sh', '-c']) ||
|
||||
!clientScript.includes('set -eu') ||
|
||||
@@ -3168,7 +3180,7 @@ function assertWorkerCredentialExecutorDeployment(readFile, root, findings) {
|
||||
);
|
||||
}
|
||||
if (
|
||||
container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
container?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
JSON.stringify(container?.command) !==
|
||||
JSON.stringify([
|
||||
'node',
|
||||
@@ -3488,7 +3500,11 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
'ServiceAccount',
|
||||
actionName,
|
||||
);
|
||||
const admissionConfig = namedResource(resources, 'ConfigMap', actionName + '-admission');
|
||||
const admissionConfig = namedResource(
|
||||
resources,
|
||||
'ConfigMap',
|
||||
actionName + '-admission',
|
||||
);
|
||||
const role = namedResource(resources, 'Role', name);
|
||||
const roleBinding = namedResource(resources, 'RoleBinding', name);
|
||||
const admissionPolicy = namedResource(
|
||||
@@ -3572,14 +3588,18 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
admissionPolicy?.spec?.failurePolicy !== 'Fail' ||
|
||||
admissionPolicy?.spec?.paramKind?.apiVersion !== 'v1' ||
|
||||
admissionPolicy?.spec?.paramKind?.kind !== 'ConfigMap' ||
|
||||
admissionPolicy?.spec?.matchConstraints?.resourceRules?.[0]?.operations?.[0] !==
|
||||
'CREATE' ||
|
||||
admissionPolicy?.spec?.matchConstraints?.resourceRules?.[0]?.resources?.[0] !==
|
||||
'jobs' ||
|
||||
admissionPolicy?.spec?.matchConstraints?.resourceRules?.[0]
|
||||
?.operations?.[0] !== 'CREATE' ||
|
||||
admissionPolicy?.spec?.matchConstraints?.resourceRules?.[0]
|
||||
?.resources?.[0] !== 'jobs' ||
|
||||
admissionPolicy?.spec?.matchConditions?.[0]?.expression !==
|
||||
"request.userInfo.username == 'system:serviceaccount:qinglong3-system:ql3-plugin-package-executor'" ||
|
||||
!admissionExpressions.includes("variables.executor.image == params.data.image") ||
|
||||
!admissionExpressions.includes('variables.pod.automountServiceAccountToken == false') ||
|
||||
!admissionExpressions.includes(
|
||||
'variables.executor.image == params.data.image',
|
||||
) ||
|
||||
!admissionExpressions.includes(
|
||||
'variables.pod.automountServiceAccountToken == false',
|
||||
) ||
|
||||
!admissionExpressions.includes('variables.values.secret.items.all') ||
|
||||
admissionBinding?.spec?.policyName !== actionName ||
|
||||
admissionBinding?.spec?.paramRef?.name !== actionName + '-admission' ||
|
||||
@@ -3602,7 +3622,7 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
);
|
||||
}
|
||||
if (
|
||||
container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
container?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
JSON.stringify(container?.command) !==
|
||||
JSON.stringify([
|
||||
'node',
|
||||
@@ -3660,7 +3680,10 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET',
|
||||
'postgresUrlSecretName',
|
||||
],
|
||||
['QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY', 'postgresUrlSecretKey'],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY',
|
||||
'postgresUrlSecretKey',
|
||||
],
|
||||
[
|
||||
'QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_AUTH_SECRET',
|
||||
'postgresAuthSecretName',
|
||||
@@ -3773,22 +3796,22 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
||||
);
|
||||
if (
|
||||
JSON.stringify(apiServerEgressExample) !==
|
||||
JSON.stringify([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/egress/-',
|
||||
value: {
|
||||
to: [
|
||||
{
|
||||
ipBlock: {
|
||||
cidr: 'REPLACE_WITH_API_SERVER_CIDR',
|
||||
},
|
||||
JSON.stringify([
|
||||
{
|
||||
op: 'add',
|
||||
path: '/spec/egress/-',
|
||||
value: {
|
||||
to: [
|
||||
{
|
||||
ipBlock: {
|
||||
cidr: 'REPLACE_WITH_API_SERVER_CIDR',
|
||||
},
|
||||
],
|
||||
ports: [{ protocol: 'TCP', port: 443 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
ports: [{ protocol: 'TCP', port: 443 }],
|
||||
},
|
||||
])
|
||||
},
|
||||
])
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
|
||||
@@ -282,6 +282,16 @@ function auditClusterImageCiWorkflow(
|
||||
/pnpm audit:image-release:ql3/,
|
||||
'image CI must audit the shared release workflow contract',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/test\/back\/ql3VersionTransition\.test\.cjs/,
|
||||
'supply-chain CI must run release version transition negative tests',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/pnpm audit:release-version:ql3/,
|
||||
'supply-chain CI must audit the source-derived release version identity',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/docker build[\s\S]*--file \$\{\{ matrix\.dockerfile \}\}[\s\S]*--target \$\{\{ matrix\.target \}\}/,
|
||||
@@ -375,6 +385,7 @@ function auditClusterImageCiWorkflow(
|
||||
clusterAdminOperatorContext: true,
|
||||
clusterAdminContextPreflight: true,
|
||||
clusterAdminContextReadiness: true,
|
||||
releaseVersionAudit: true,
|
||||
ociAttestations: true,
|
||||
osVulnerabilityScan: {
|
||||
scanner: 'trivy@0.70.0',
|
||||
|
||||
@@ -9,8 +9,10 @@ const {
|
||||
createClusterImageSbom,
|
||||
resolveImageProfile,
|
||||
} = require('./ql3-cluster-image-sbom.cjs');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const QL3_VERSION = readReleaseIdentity(DEFAULT_ROOT).version;
|
||||
const OCI_INDEX_MEDIA_TYPE = 'application/vnd.oci.image.index.v1+json';
|
||||
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
||||
const OCI_CONFIG_MEDIA_TYPE = 'application/vnd.oci.image.config.v1+json';
|
||||
@@ -196,7 +198,7 @@ function expectedImageConfig(architecture, revision, image) {
|
||||
'org.opencontainers.image.source':
|
||||
'https://github.com/whyour/qinglong',
|
||||
'org.opencontainers.image.title': 'QingLong 3.0 Worker',
|
||||
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||
'org.opencontainers.image.version': QL3_VERSION,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -233,7 +235,7 @@ function expectedImageConfig(architecture, revision, image) {
|
||||
'org.opencontainers.image.source':
|
||||
'https://github.com/whyour/qinglong',
|
||||
'org.opencontainers.image.title': 'QingLong 3.0 Local Application',
|
||||
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||
'org.opencontainers.image.version': QL3_VERSION,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -281,7 +283,7 @@ function expectedImageConfig(architecture, revision, image) {
|
||||
? 'QingLong 3.0 Cluster Control AI'
|
||||
: 'QingLong 3.0 Cluster Control'
|
||||
: 'QingLong 3.0 Cluster Admin',
|
||||
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||
'org.opencontainers.image.version': QL3_VERSION,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const IMAGE_DIRECTORY = 'deploy/containers/ql3-local-application';
|
||||
const QL3_VERSION = readReleaseIdentity(path.resolve(__dirname, '..')).version;
|
||||
const NODE_IMAGE =
|
||||
'node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d';
|
||||
const BUILD_DEPENDENCIES = Object.freeze({
|
||||
@@ -56,7 +58,7 @@ function auditManifest(manifest, runtime, findings) {
|
||||
const expectedName = '@qinglong/local-application-image';
|
||||
if (
|
||||
manifest.name !== expectedName ||
|
||||
manifest.version !== '3.0.0-alpha.0' ||
|
||||
manifest.version !== QL3_VERSION ||
|
||||
manifest.private !== true ||
|
||||
manifest.license !== 'Apache-2.0' ||
|
||||
manifest.engines?.node !== '>=24.18.0 <25'
|
||||
|
||||
@@ -16,8 +16,10 @@ const {
|
||||
parseProcStat,
|
||||
parseProcStatus,
|
||||
} = require('./ql3-physical-edge-idle-sampler.cjs');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const QL3_VERSION = readReleaseIdentity(path.resolve(__dirname, '..')).version;
|
||||
const MAX_INPUT_BYTES = 256 * 1024;
|
||||
const MAX_OUTPUT_BYTES = 64 * 1024;
|
||||
const MAX_ARTIFACT_FILES = 768;
|
||||
@@ -569,7 +571,7 @@ function collectArtifactIdentity(artifactRootInput) {
|
||||
const applicationEntrypoint = path.join(packageRoot, 'dist', 'cli.js');
|
||||
if (
|
||||
packageManifest.name !== '@qinglong/local-application' ||
|
||||
packageManifest.version !== '3.0.0-alpha.0' ||
|
||||
packageManifest.version !== QL3_VERSION ||
|
||||
packageManifest.bin?.['ql3-local-application'] !== 'dist/cli.js' ||
|
||||
packageManifest.engines?.node !== '>=24.18.0 <25' ||
|
||||
fs.realpathSync(applicationEntrypoint) !== applicationEntrypoint
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const yaml = require('js-yaml');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const QL3_VERSION = readReleaseIdentity(path.resolve(__dirname, '..')).version;
|
||||
|
||||
const OPERATION = path.join(
|
||||
'deploy',
|
||||
@@ -130,7 +133,7 @@ function auditPromptOutputExternalRecoveryDeployment(options = {}) {
|
||||
!Array.isArray(containers) ||
|
||||
containers.length !== 1 ||
|
||||
container?.name !== 'verifier' ||
|
||||
container?.image !== 'qinglong3-cluster-admin:3.0.0-alpha.0' ||
|
||||
container?.image !== `qinglong3-cluster-admin:${QL3_VERSION}` ||
|
||||
JSON.stringify(container?.command) !==
|
||||
JSON.stringify([
|
||||
'node',
|
||||
|
||||
@@ -6,6 +6,10 @@ const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { auditPackageBoundaries } = require('./ql3-package-boundary-audit.cjs');
|
||||
const {
|
||||
VERSION_PATTERN,
|
||||
readReleaseIdentity,
|
||||
} = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const SCHEMA = 'qinglong/release-candidate-contract@v1';
|
||||
@@ -13,8 +17,6 @@ const PREDICATE_TYPE =
|
||||
'https://qinglong.dev/attestations/release-candidate-contract/v1';
|
||||
const MAX_REPORT_BYTES = 1024 * 1024;
|
||||
const RELEASE_SCOPES = Object.freeze(['all', 'cluster', 'local']);
|
||||
const NODE_ENGINE = '>=24.18.0 <25';
|
||||
const NODE_VERSION = '24.18.0';
|
||||
const LOCAL_IMAGES = Object.freeze([
|
||||
Object.freeze({
|
||||
image: 'local',
|
||||
@@ -93,9 +95,7 @@ function selectedImages(scope) {
|
||||
function validateIdentity(options) {
|
||||
if (
|
||||
typeof options.version !== 'string' ||
|
||||
!/^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u.test(
|
||||
options.version,
|
||||
)
|
||||
!VERSION_PATTERN.test(options.version)
|
||||
) {
|
||||
fail('version must be an exact QingLong 3 SemVer');
|
||||
}
|
||||
@@ -113,11 +113,17 @@ function validateIdentity(options) {
|
||||
function createReleaseCandidateContract(options) {
|
||||
const root = path.resolve(options.root || DEFAULT_ROOT);
|
||||
validateIdentity(options);
|
||||
const releaseIdentity = readReleaseIdentity(root);
|
||||
if (options.version !== releaseIdentity.version) {
|
||||
fail('requested version differs from the repository release identity');
|
||||
}
|
||||
const boundaries = auditPackageBoundaries(root);
|
||||
if (
|
||||
!boundaries.compatible ||
|
||||
boundaries.workspacePackageCount !== 18 ||
|
||||
boundaries.workspacePackageHardCap !== 18 ||
|
||||
boundaries.workspacePackageCount !==
|
||||
releaseIdentity.workspacePackageCount ||
|
||||
boundaries.workspacePackageHardCap !==
|
||||
releaseIdentity.workspacePackageCount ||
|
||||
boundaries.singleSourcePackages.length !== 0 ||
|
||||
boundaries.shallowSourcePackages.length !== 0
|
||||
) {
|
||||
@@ -129,7 +135,7 @@ function createReleaseCandidateContract(options) {
|
||||
if (
|
||||
manifest.name !== entry.name ||
|
||||
manifest.version !== options.version ||
|
||||
manifest.engines?.node !== NODE_ENGINE
|
||||
manifest.engines?.node !== releaseIdentity.node.engine
|
||||
) {
|
||||
fail(`workspace release identity differs: ${entry.path}`);
|
||||
}
|
||||
@@ -147,7 +153,7 @@ function createReleaseCandidateContract(options) {
|
||||
);
|
||||
if (
|
||||
manifest.version !== options.version ||
|
||||
manifest.engines?.node !== NODE_ENGINE
|
||||
manifest.engines?.node !== releaseIdentity.node.engine
|
||||
) {
|
||||
fail(`image release identity differs: ${image.runtime_root}`);
|
||||
}
|
||||
@@ -156,7 +162,9 @@ function createReleaseCandidateContract(options) {
|
||||
'utf8',
|
||||
);
|
||||
if (
|
||||
!dockerfile.includes(`node:${NODE_VERSION}-bookworm-slim@sha256:`) ||
|
||||
!dockerfile.includes(
|
||||
`node:${releaseIdentity.node.version}-bookworm-slim@sha256:`,
|
||||
) ||
|
||||
!dockerfile.includes(
|
||||
`org.opencontainers.image.version=\"${options.version}\"`,
|
||||
)
|
||||
@@ -204,9 +212,14 @@ function createReleaseCandidateContract(options) {
|
||||
compatibility: {
|
||||
legacyRootPackageVersion: readJson(path.join(root, 'package.json'))
|
||||
.version,
|
||||
legacyRootExcludedFromReleaseIdentity: true,
|
||||
nodeVersion: NODE_VERSION,
|
||||
nodeEngine: NODE_ENGINE,
|
||||
legacyRootExcludedFromReleaseIdentity:
|
||||
releaseIdentity.legacyRootPackageExcluded,
|
||||
releaseIdentitySchema: releaseIdentity.schema,
|
||||
releaseIdentityDigest: sha256(
|
||||
Buffer.from(JSON.stringify(releaseIdentity)),
|
||||
),
|
||||
nodeVersion: releaseIdentity.node.version,
|
||||
nodeEngine: releaseIdentity.node.engine,
|
||||
platforms: ['linux/amd64', 'linux/arm64'],
|
||||
},
|
||||
workspace: {
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const semver = require('semver');
|
||||
const {
|
||||
RELEASE_IDENTITY_PATH,
|
||||
RELEASE_IDENTITY_SCHEMA,
|
||||
VERSION_PATTERN,
|
||||
readReleaseIdentity,
|
||||
} = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const PLAN_SCHEMA = 'qinglong/version-transition-plan@v1';
|
||||
const REPORT_SCHEMA = 'qinglong/version-transition-report@v1';
|
||||
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_PLAN_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_GOVERNED_FILES = 512;
|
||||
const CONTAINER_ROOTS = Object.freeze([
|
||||
'deploy/containers/ql3-cluster-control',
|
||||
'deploy/containers/ql3-cluster-admin',
|
||||
'deploy/containers/ql3-local-application',
|
||||
'deploy/containers/ql3-worker',
|
||||
]);
|
||||
const DEPLOYMENT_ROOTS = Object.freeze([
|
||||
'deploy/kubernetes/ql3-cluster',
|
||||
'deploy/kubernetes/ql3-worker',
|
||||
]);
|
||||
const DEPLOYMENT_FILES = Object.freeze([
|
||||
'deploy/console/ql3-cluster-copilot/README.md',
|
||||
]);
|
||||
const CONTAINER_FILES = Object.freeze([
|
||||
'Dockerfile',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'runtime-dependencies/package.json',
|
||||
'runtime-dependencies/package-lock.json',
|
||||
]);
|
||||
const TEXT_EXTENSIONS = new Set(['.json', '.md', '.yaml', '.yml']);
|
||||
const IMAGE_TAG_PATTERN =
|
||||
/qinglong3-(?:cluster-control-ai|cluster-control|cluster-admin|local-application|worker):([0-9A-Za-z.-]+)/gu;
|
||||
const SOURCE_TAG_PATTERN = /refs\/tags\/v(3\.[0-9A-Za-z.-]+)/gu;
|
||||
|
||||
class QingLong3VersionTransitionError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3 version transition failed: ${message}`);
|
||||
this.name = 'QingLong3VersionTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new QingLong3VersionTransitionError(message);
|
||||
}
|
||||
|
||||
function sha256(contents) {
|
||||
return `sha256:${crypto.createHash('sha256').update(contents).digest('hex')}`;
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRoot(root) {
|
||||
return fs.realpathSync(path.resolve(root || DEFAULT_ROOT));
|
||||
}
|
||||
|
||||
function resolveGovernedPath(root, relativePath) {
|
||||
if (
|
||||
typeof relativePath !== 'string' ||
|
||||
relativePath.length < 1 ||
|
||||
relativePath.length > 512 ||
|
||||
path.isAbsolute(relativePath) ||
|
||||
path.posix.normalize(relativePath) !== relativePath ||
|
||||
relativePath.includes('\\') ||
|
||||
relativePath.split('/').includes('..')
|
||||
) {
|
||||
fail('governed path must be one canonical repository-relative path');
|
||||
}
|
||||
const resolved = path.resolve(root, relativePath);
|
||||
if (!resolved.startsWith(`${root}${path.sep}`)) {
|
||||
fail('governed path escapes the repository');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readRegularFile(root, relativePath, maximumBytes = MAX_FILE_BYTES) {
|
||||
const filePath = resolveGovernedPath(root, relativePath);
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 1 ||
|
||||
stat.size > maximumBytes ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath)
|
||||
) {
|
||||
fail(`invalid governed regular file: ${relativePath}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
filePath,
|
||||
contents: fs.readFileSync(filePath, 'utf8'),
|
||||
mode: stat.mode & 0o777,
|
||||
});
|
||||
}
|
||||
|
||||
function readJson(root, relativePath) {
|
||||
const file = readRegularFile(root, relativePath);
|
||||
try {
|
||||
return Object.freeze({ ...file, value: JSON.parse(file.contents) });
|
||||
} catch {
|
||||
fail(`governed JSON is invalid: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function walkTextFiles(root, relativeDirectory, output = []) {
|
||||
const directory = resolveGovernedPath(root, relativeDirectory);
|
||||
const stat = fs.lstatSync(directory);
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
fs.realpathSync(directory) !== directory
|
||||
) {
|
||||
fail(`invalid governed directory: ${relativeDirectory}`);
|
||||
}
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const relativePath = path.posix.join(relativeDirectory, entry.name);
|
||||
if (entry.isSymbolicLink())
|
||||
fail(`symbolic link in governed tree: ${relativePath}`);
|
||||
if (entry.isDirectory()) {
|
||||
walkTextFiles(root, relativePath, output);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile())
|
||||
fail(`unsupported entry in governed tree: ${relativePath}`);
|
||||
if (TEXT_EXTENSIONS.has(path.extname(entry.name)))
|
||||
output.push(relativePath);
|
||||
if (output.length > MAX_GOVERNED_FILES)
|
||||
fail('governed file ceiling exceeded');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function workspaceManifestPaths(root) {
|
||||
const packagesRoot = resolveGovernedPath(root, 'packages');
|
||||
const entries = fs
|
||||
.readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith('ql3-'))
|
||||
.map((entry) => `packages/${entry.name}/package.json`)
|
||||
.sort();
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
function fixedContainerPaths() {
|
||||
return Object.freeze(
|
||||
CONTAINER_ROOTS.flatMap((root) =>
|
||||
CONTAINER_FILES.map((file) => `${root}/${file}`),
|
||||
).sort(),
|
||||
);
|
||||
}
|
||||
|
||||
function deploymentTextPaths(root) {
|
||||
return Object.freeze(
|
||||
[
|
||||
...DEPLOYMENT_ROOTS.flatMap((directory) =>
|
||||
walkTextFiles(root, directory),
|
||||
),
|
||||
...DEPLOYMENT_FILES,
|
||||
].sort(),
|
||||
);
|
||||
}
|
||||
|
||||
function versionOccurrences(contents, version) {
|
||||
return contents.split(version).length - 1;
|
||||
}
|
||||
|
||||
function taggedVersions(contents) {
|
||||
return Object.freeze([
|
||||
...[...contents.matchAll(IMAGE_TAG_PATTERN)].map((match) => match[1]),
|
||||
...[...contents.matchAll(SOURCE_TAG_PATTERN)].map((match) => match[1]),
|
||||
]);
|
||||
}
|
||||
|
||||
function auditReleaseVersionContract(rootInput = DEFAULT_ROOT) {
|
||||
const root = resolveRoot(rootInput);
|
||||
const identity = readReleaseIdentity(root);
|
||||
const legacyRoot = readJson(root, 'package.json').value;
|
||||
if (
|
||||
typeof legacyRoot.version !== 'string' ||
|
||||
!/^2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$/u.test(
|
||||
legacyRoot.version,
|
||||
) ||
|
||||
legacyRoot.version === identity.version
|
||||
) {
|
||||
fail('legacy root package must remain outside the 3.0 release identity');
|
||||
}
|
||||
|
||||
const workspacePaths = workspaceManifestPaths(root);
|
||||
if (workspacePaths.length !== identity.workspacePackageCount) {
|
||||
fail('workspace package count differs from the release identity');
|
||||
}
|
||||
for (const relativePath of workspacePaths) {
|
||||
const manifest = readJson(root, relativePath).value;
|
||||
if (
|
||||
typeof manifest.name !== 'string' ||
|
||||
!manifest.name.startsWith('@qinglong/') ||
|
||||
manifest.version !== identity.version ||
|
||||
manifest.engines?.node !== identity.node.engine
|
||||
) {
|
||||
fail(`workspace package release identity drifted: ${relativePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const containerRoot of CONTAINER_ROOTS) {
|
||||
const buildManifest = readJson(root, `${containerRoot}/package.json`).value;
|
||||
const buildLock = readJson(
|
||||
root,
|
||||
`${containerRoot}/package-lock.json`,
|
||||
).value;
|
||||
const runtimeManifest = readJson(
|
||||
root,
|
||||
`${containerRoot}/runtime-dependencies/package.json`,
|
||||
).value;
|
||||
const runtimeLock = readJson(
|
||||
root,
|
||||
`${containerRoot}/runtime-dependencies/package-lock.json`,
|
||||
).value;
|
||||
if (
|
||||
buildManifest.version !== identity.version ||
|
||||
runtimeManifest.version !== identity.version ||
|
||||
buildManifest.engines?.node !== identity.node.engine ||
|
||||
runtimeManifest.engines?.node !== identity.node.engine ||
|
||||
buildLock.version !== identity.version ||
|
||||
buildLock.packages?.['']?.version !== identity.version ||
|
||||
runtimeLock.version !== identity.version ||
|
||||
runtimeLock.packages?.['']?.version !== identity.version
|
||||
) {
|
||||
fail(
|
||||
`container manifest or lock release identity drifted: ${containerRoot}`,
|
||||
);
|
||||
}
|
||||
const dockerfile = readRegularFile(
|
||||
root,
|
||||
`${containerRoot}/Dockerfile`,
|
||||
).contents;
|
||||
if (
|
||||
!dockerfile.includes(
|
||||
`node:${identity.node.version}-bookworm-slim@sha256:`,
|
||||
) ||
|
||||
versionOccurrences(
|
||||
dockerfile,
|
||||
`org.opencontainers.image.version=\"${identity.version}\"`,
|
||||
) !== 1
|
||||
) {
|
||||
fail(`container Dockerfile release identity drifted: ${containerRoot}`);
|
||||
}
|
||||
}
|
||||
|
||||
let deploymentVersionOccurrences = 0;
|
||||
let deploymentImageReferences = 0;
|
||||
const deploymentFiles = deploymentTextPaths(root);
|
||||
for (const relativePath of deploymentFiles) {
|
||||
const contents = readRegularFile(root, relativePath).contents;
|
||||
const versions = taggedVersions(contents);
|
||||
if (versions.some((version) => version !== identity.version)) {
|
||||
fail(`deployment release identity drifted: ${relativePath}`);
|
||||
}
|
||||
deploymentImageReferences += [...contents.matchAll(IMAGE_TAG_PATTERN)]
|
||||
.length;
|
||||
deploymentVersionOccurrences += versionOccurrences(
|
||||
contents,
|
||||
identity.version,
|
||||
);
|
||||
}
|
||||
if (deploymentImageReferences < 4 || deploymentVersionOccurrences < 4) {
|
||||
fail('deployment release identity coverage is incomplete');
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schema: RELEASE_IDENTITY_SCHEMA,
|
||||
version: identity.version,
|
||||
nodeVersion: identity.node.version,
|
||||
nodeEngine: identity.node.engine,
|
||||
legacyRootPackageVersion: legacyRoot.version,
|
||||
legacyRootExcluded: true,
|
||||
workspacePackageCount: workspacePaths.length,
|
||||
containerRootCount: CONTAINER_ROOTS.length,
|
||||
deploymentFileCount: deploymentFiles.length,
|
||||
deploymentImageReferences,
|
||||
deploymentVersionOccurrences,
|
||||
compatible: true,
|
||||
});
|
||||
}
|
||||
|
||||
function governedTransitionPaths(root, version) {
|
||||
const deploymentPaths = deploymentTextPaths(root).filter((relativePath) =>
|
||||
readRegularFile(root, relativePath).contents.includes(version),
|
||||
);
|
||||
return Object.freeze(
|
||||
[
|
||||
RELEASE_IDENTITY_PATH,
|
||||
...workspaceManifestPaths(root),
|
||||
...fixedContainerPaths(),
|
||||
...deploymentPaths,
|
||||
].sort(),
|
||||
);
|
||||
}
|
||||
|
||||
function validateVersionTransition(sourceVersion, targetVersion) {
|
||||
if (
|
||||
typeof sourceVersion !== 'string' ||
|
||||
typeof targetVersion !== 'string' ||
|
||||
!VERSION_PATTERN.test(sourceVersion) ||
|
||||
!VERSION_PATTERN.test(targetVersion) ||
|
||||
semver.valid(sourceVersion) !== sourceVersion ||
|
||||
semver.valid(targetVersion) !== targetVersion ||
|
||||
!semver.gt(targetVersion, sourceVersion)
|
||||
) {
|
||||
fail('target must be one exact monotonically newer QingLong 3 SemVer');
|
||||
}
|
||||
}
|
||||
|
||||
function createVersionTransitionPlan(options = {}) {
|
||||
const root = resolveRoot(options.root || DEFAULT_ROOT);
|
||||
validateVersionTransition(options.sourceVersion, options.targetVersion);
|
||||
const audit = auditReleaseVersionContract(root);
|
||||
if (audit.version !== options.sourceVersion) {
|
||||
fail('source version differs from the current release identity');
|
||||
}
|
||||
const paths = governedTransitionPaths(root, options.sourceVersion);
|
||||
const entries = paths.map((relativePath) => {
|
||||
const file = readRegularFile(root, relativePath);
|
||||
const replacementCount = versionOccurrences(
|
||||
file.contents,
|
||||
options.sourceVersion,
|
||||
);
|
||||
if (replacementCount < 1) {
|
||||
fail(`governed transition target has no source version: ${relativePath}`);
|
||||
}
|
||||
const next = file.contents
|
||||
.split(options.sourceVersion)
|
||||
.join(options.targetVersion);
|
||||
return Object.freeze({
|
||||
path: relativePath,
|
||||
mode: file.mode,
|
||||
replacementCount,
|
||||
beforeBytes: Buffer.byteLength(file.contents),
|
||||
afterBytes: Buffer.byteLength(next),
|
||||
beforeDigest: sha256(file.contents),
|
||||
afterDigest: sha256(next),
|
||||
});
|
||||
});
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: PLAN_SCHEMA,
|
||||
sourceVersion: options.sourceVersion,
|
||||
targetVersion: options.targetVersion,
|
||||
legacyRootPackageVersion: audit.legacyRootPackageVersion,
|
||||
legacyRootExcluded: true,
|
||||
fileCount: entries.length,
|
||||
replacementCount: entries.reduce(
|
||||
(total, entry) => total + entry.replacementCount,
|
||||
0,
|
||||
),
|
||||
entries,
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function validatePlan(plan) {
|
||||
if (
|
||||
!exactKeys(plan, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'sourceVersion',
|
||||
'targetVersion',
|
||||
'legacyRootPackageVersion',
|
||||
'legacyRootExcluded',
|
||||
'fileCount',
|
||||
'replacementCount',
|
||||
'entries',
|
||||
'planDigest',
|
||||
]) ||
|
||||
plan.schemaVersion !== 1 ||
|
||||
plan.schema !== PLAN_SCHEMA ||
|
||||
plan.legacyRootExcluded !== true ||
|
||||
!Array.isArray(plan.entries) ||
|
||||
plan.entries.length < 1 ||
|
||||
plan.entries.length > MAX_GOVERNED_FILES ||
|
||||
plan.fileCount !== plan.entries.length
|
||||
) {
|
||||
fail('version transition plan shape is invalid');
|
||||
}
|
||||
validateVersionTransition(plan.sourceVersion, plan.targetVersion);
|
||||
let previousPath = '';
|
||||
let replacements = 0;
|
||||
for (const entry of plan.entries) {
|
||||
if (
|
||||
!exactKeys(entry, [
|
||||
'path',
|
||||
'mode',
|
||||
'replacementCount',
|
||||
'beforeBytes',
|
||||
'afterBytes',
|
||||
'beforeDigest',
|
||||
'afterDigest',
|
||||
]) ||
|
||||
typeof entry.path !== 'string' ||
|
||||
entry.path <= previousPath ||
|
||||
!Number.isSafeInteger(entry.mode) ||
|
||||
entry.mode < 0o400 ||
|
||||
entry.mode > 0o777 ||
|
||||
!Number.isSafeInteger(entry.replacementCount) ||
|
||||
entry.replacementCount < 1 ||
|
||||
!Number.isSafeInteger(entry.beforeBytes) ||
|
||||
entry.beforeBytes < 1 ||
|
||||
entry.beforeBytes > MAX_FILE_BYTES ||
|
||||
!Number.isSafeInteger(entry.afterBytes) ||
|
||||
entry.afterBytes < 1 ||
|
||||
entry.afterBytes > MAX_FILE_BYTES ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(entry.beforeDigest || '') ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(entry.afterDigest || '') ||
|
||||
entry.beforeDigest === entry.afterDigest
|
||||
) {
|
||||
fail('version transition plan entry is invalid');
|
||||
}
|
||||
previousPath = entry.path;
|
||||
replacements += entry.replacementCount;
|
||||
}
|
||||
if (plan.replacementCount !== replacements) {
|
||||
fail('version transition replacement count drifted');
|
||||
}
|
||||
const { planDigest, ...unsigned } = plan;
|
||||
if (planDigest !== sha256(JSON.stringify(unsigned))) {
|
||||
fail('version transition plan digest drifted');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function readPlan(filePath) {
|
||||
const resolved = path.resolve(filePath || '');
|
||||
if (!path.isAbsolute(filePath || '')) fail('plan path must be absolute');
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_PLAN_BYTES ||
|
||||
fs.realpathSync(resolved) !== resolved
|
||||
) {
|
||||
fail('plan must be one bounded canonical regular file');
|
||||
}
|
||||
let plan;
|
||||
try {
|
||||
plan = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
} catch {
|
||||
fail('plan must contain valid JSON');
|
||||
}
|
||||
return validatePlan(plan);
|
||||
}
|
||||
|
||||
function outputPathReady(filePath) {
|
||||
const resolved = path.resolve(filePath || '');
|
||||
if (
|
||||
!path.isAbsolute(filePath || '') ||
|
||||
fs.existsSync(resolved) ||
|
||||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||
) {
|
||||
fail('output must be unused in one canonical directory');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function writeNoReplace(filePath, value) {
|
||||
const resolved = outputPathReady(filePath);
|
||||
fs.writeFileSync(resolved, `${JSON.stringify(value)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
}
|
||||
|
||||
function validatePlanCoverage(root, plan) {
|
||||
const legacyVersion = readJson(root, 'package.json').value.version;
|
||||
if (legacyVersion !== plan.legacyRootPackageVersion) {
|
||||
fail('legacy root package changed after plan creation');
|
||||
}
|
||||
const expectedPaths = new Set([
|
||||
RELEASE_IDENTITY_PATH,
|
||||
...workspaceManifestPaths(root),
|
||||
...fixedContainerPaths(),
|
||||
]);
|
||||
for (const relativePath of deploymentTextPaths(root)) {
|
||||
const contents = readRegularFile(root, relativePath).contents;
|
||||
const versions = taggedVersions(contents);
|
||||
if (
|
||||
versions.some(
|
||||
(version) =>
|
||||
version !== plan.sourceVersion && version !== plan.targetVersion,
|
||||
)
|
||||
) {
|
||||
fail(`deployment version is outside the transition: ${relativePath}`);
|
||||
}
|
||||
if (
|
||||
contents.includes(plan.sourceVersion) ||
|
||||
contents.includes(plan.targetVersion)
|
||||
) {
|
||||
expectedPaths.add(relativePath);
|
||||
}
|
||||
}
|
||||
const actualPaths = plan.entries.map((entry) => entry.path);
|
||||
if (
|
||||
JSON.stringify([...expectedPaths].sort()) !== JSON.stringify(actualPaths)
|
||||
) {
|
||||
fail('version transition plan does not cover the exact governed file set');
|
||||
}
|
||||
}
|
||||
|
||||
function temporaryPath(filePath, planDigest) {
|
||||
return path.join(
|
||||
path.dirname(filePath),
|
||||
`.${path.basename(filePath)}.ql3-version-${planDigest.slice(7, 23)}.tmp`,
|
||||
);
|
||||
}
|
||||
|
||||
function materializeTarget(file, entry, plan) {
|
||||
const next = file.contents.split(plan.sourceVersion).join(plan.targetVersion);
|
||||
if (
|
||||
versionOccurrences(file.contents, plan.sourceVersion) !==
|
||||
entry.replacementCount ||
|
||||
Buffer.byteLength(next) !== entry.afterBytes ||
|
||||
sha256(next) !== entry.afterDigest
|
||||
) {
|
||||
fail(`source content no longer derives the planned target: ${entry.path}`);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyVersionTransitionPlan(planInput, options = {}) {
|
||||
const root = resolveRoot(options.root || DEFAULT_ROOT);
|
||||
const plan = validatePlan(planInput);
|
||||
outputPathReady(options.report);
|
||||
validatePlanCoverage(root, plan);
|
||||
|
||||
const states = plan.entries.map((entry) => {
|
||||
const file = readRegularFile(root, entry.path);
|
||||
const digest = sha256(file.contents);
|
||||
const state =
|
||||
digest === entry.beforeDigest
|
||||
? 'source'
|
||||
: digest === entry.afterDigest
|
||||
? 'target'
|
||||
: null;
|
||||
if (!state || file.mode !== entry.mode) {
|
||||
fail(`governed file drifted after plan creation: ${entry.path}`);
|
||||
}
|
||||
const next =
|
||||
state === 'source' ? materializeTarget(file, entry, plan) : null;
|
||||
const tempPath = temporaryPath(file.filePath, plan.planDigest);
|
||||
if (fs.existsSync(tempPath)) {
|
||||
const temp = fs.lstatSync(tempPath);
|
||||
const tempContents =
|
||||
temp.isFile() && !temp.isSymbolicLink()
|
||||
? fs.readFileSync(tempPath, 'utf8')
|
||||
: '';
|
||||
if (
|
||||
fs.realpathSync(tempPath) !== tempPath ||
|
||||
(temp.mode & 0o777) !== entry.mode ||
|
||||
sha256(tempContents) !== entry.afterDigest
|
||||
) {
|
||||
fail(`deterministic recovery file drifted: ${entry.path}`);
|
||||
}
|
||||
}
|
||||
return Object.freeze({ entry, file, state, next, tempPath });
|
||||
});
|
||||
|
||||
let changedFiles = 0;
|
||||
let alreadyCurrentFiles = 0;
|
||||
for (const state of states) {
|
||||
if (state.state === 'target') {
|
||||
alreadyCurrentFiles += 1;
|
||||
if (fs.existsSync(state.tempPath)) fs.unlinkSync(state.tempPath);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(state.tempPath)) {
|
||||
const descriptor = fs.openSync(
|
||||
state.tempPath,
|
||||
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
|
||||
state.entry.mode,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, state.next, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
fs.renameSync(state.tempPath, state.file.filePath);
|
||||
changedFiles += 1;
|
||||
}
|
||||
|
||||
const audit = auditReleaseVersionContract(root);
|
||||
if (audit.version !== plan.targetVersion) {
|
||||
fail('post-transition release identity is incompatible');
|
||||
}
|
||||
const reportUnsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: REPORT_SCHEMA,
|
||||
planDigest: plan.planDigest,
|
||||
sourceVersion: plan.sourceVersion,
|
||||
targetVersion: plan.targetVersion,
|
||||
fileCount: plan.fileCount,
|
||||
changedFiles,
|
||||
alreadyCurrentFiles,
|
||||
exactReplay: alreadyCurrentFiles === plan.fileCount,
|
||||
legacyRootPackageVersion: audit.legacyRootPackageVersion,
|
||||
legacyRootExcluded: true,
|
||||
compatible: true,
|
||||
};
|
||||
const report = Object.freeze({
|
||||
...reportUnsigned,
|
||||
reportDigest: sha256(JSON.stringify(reportUnsigned)),
|
||||
});
|
||||
writeNoReplace(options.report, report);
|
||||
return report;
|
||||
}
|
||||
|
||||
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 expected =
|
||||
values.mode === 'audit'
|
||||
? ['mode']
|
||||
: values.mode === 'plan'
|
||||
? ['from', 'mode', 'output', 'to']
|
||||
: values.mode === 'apply'
|
||||
? ['mode', 'plan', 'report']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze(values);
|
||||
}
|
||||
|
||||
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
const options = parseArguments(argv);
|
||||
if (options.mode === 'audit') {
|
||||
const audit = auditReleaseVersionContract(root);
|
||||
output.write(`${JSON.stringify(audit)}\n`);
|
||||
return audit;
|
||||
}
|
||||
if (options.mode === 'plan') {
|
||||
const plan = createVersionTransitionPlan({
|
||||
root,
|
||||
sourceVersion: options.from,
|
||||
targetVersion: options.to,
|
||||
});
|
||||
writeNoReplace(options.output, plan);
|
||||
output.write(`${JSON.stringify(plan)}\n`);
|
||||
return plan;
|
||||
}
|
||||
const plan = readPlan(options.plan);
|
||||
const report = applyVersionTransitionPlan(plan, {
|
||||
root,
|
||||
report: options.report,
|
||||
});
|
||||
output.write(`${JSON.stringify(report)}\n`);
|
||||
return report;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
runCli(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${
|
||||
error instanceof Error ? error.message : 'version transition failed'
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
CONTAINER_ROOTS,
|
||||
DEPLOYMENT_FILES,
|
||||
DEPLOYMENT_ROOTS,
|
||||
PLAN_SCHEMA,
|
||||
REPORT_SCHEMA,
|
||||
QingLong3VersionTransitionError,
|
||||
applyVersionTransitionPlan,
|
||||
auditReleaseVersionContract,
|
||||
createVersionTransitionPlan,
|
||||
parseArguments,
|
||||
readPlan,
|
||||
runCli,
|
||||
validatePlan,
|
||||
});
|
||||
@@ -12,9 +12,13 @@ const {
|
||||
reviewedOperatorManifest,
|
||||
verifyImageIds,
|
||||
} = require('../../scripts/ql3-cloudnativepg-live-contract.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
|
||||
const INDEX = `sha256:${'a'.repeat(64)}`;
|
||||
const PLATFORM = `sha256:${'b'.repeat(64)}`;
|
||||
const VERSION = readReleaseIdentity(path.resolve(__dirname, '../..')).version;
|
||||
|
||||
function pods(...imageIds) {
|
||||
return imageIds.map((imageID) => ({
|
||||
@@ -23,10 +27,7 @@ function pods(...imageIds) {
|
||||
}
|
||||
|
||||
test('extracts only one exact digest-pinned image reference', () => {
|
||||
assert.equal(
|
||||
imageDigest(`registry.example/operand:18.4@${INDEX}`),
|
||||
INDEX,
|
||||
);
|
||||
assert.equal(imageDigest(`registry.example/operand:18.4@${INDEX}`), INDEX);
|
||||
assert.throws(() => imageDigest('registry.example/operand:18.4'));
|
||||
assert.throws(() => imageDigest(`registry.example/operand@${INDEX}:tag`));
|
||||
});
|
||||
@@ -41,16 +42,16 @@ test('derives a normal tagged preload reference from a reviewed image', () => {
|
||||
});
|
||||
|
||||
test('replaces exactly one fail-closed application image only in live rendering', () => {
|
||||
const placeholder = `registry.example.com/qinglong/qinglong3-cluster-control@sha256:${'0'.repeat(64)}`;
|
||||
const placeholder = `registry.example.com/qinglong/qinglong3-cluster-control@sha256:${'0'.repeat(
|
||||
64,
|
||||
)}`;
|
||||
const rendered = `kind: Deployment\nspec:\n image: ${placeholder}\n`;
|
||||
assert.equal(
|
||||
localApplicationManifest(rendered),
|
||||
'kind: Deployment\nspec:\n image: registry.example.com/qinglong/qinglong3-cluster-control:3.0.0-alpha.0\n',
|
||||
`kind: Deployment\nspec:\n image: registry.example.com/qinglong/qinglong3-cluster-control:${VERSION}\n`,
|
||||
);
|
||||
assert.throws(() => localApplicationManifest('kind: Deployment\n'));
|
||||
assert.throws(() =>
|
||||
localApplicationManifest(`${rendered}---\n${rendered}`),
|
||||
);
|
||||
assert.throws(() => localApplicationManifest(`${rendered}---\n${rendered}`));
|
||||
});
|
||||
|
||||
test('accepts uniform runtime reporting of the reviewed index or platform digest', () => {
|
||||
@@ -74,7 +75,8 @@ test('accepts uniform runtime reporting of the reviewed index or platform digest
|
||||
|
||||
test('rejects tags, unknown digests, missing status and widened reviewed sets', () => {
|
||||
for (const invoke of [
|
||||
() => verifyImageIds(pods('registry.example/operand:18.4'), [INDEX], 'operand'),
|
||||
() =>
|
||||
verifyImageIds(pods('registry.example/operand:18.4'), [INDEX], 'operand'),
|
||||
() =>
|
||||
verifyImageIds(
|
||||
pods(`registry.example/operand@sha256:${'c'.repeat(64)}`),
|
||||
@@ -82,7 +84,12 @@ test('rejects tags, unknown digests, missing status and widened reviewed sets',
|
||||
'operand',
|
||||
),
|
||||
() => verifyImageIds([{ status: {} }], [INDEX], 'operand'),
|
||||
() => verifyImageIds(pods(`registry.example/operand@${INDEX}`), ['*'], 'operand'),
|
||||
() =>
|
||||
verifyImageIds(
|
||||
pods(`registry.example/operand@${INDEX}`),
|
||||
['*'],
|
||||
'operand',
|
||||
),
|
||||
]) {
|
||||
assert.throws(invoke);
|
||||
}
|
||||
@@ -90,7 +97,10 @@ test('rejects tags, unknown digests, missing status and widened reviewed sets',
|
||||
|
||||
test('creates the namespaced control identity before the migration Job', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const namespace = source.indexOf(
|
||||
@@ -105,10 +115,7 @@ test('creates the namespaced control identity before the migration Job', () => {
|
||||
assert.ok(namespace >= 0);
|
||||
assert.ok(serviceAccount > namespace);
|
||||
assert.ok(migration > serviceAccount);
|
||||
assert.match(
|
||||
source.slice(namespace, serviceAccount),
|
||||
/kubectl\(\[/,
|
||||
);
|
||||
assert.match(source.slice(namespace, serviceAccount), /kubectl\(\[/);
|
||||
assert.match(
|
||||
source.slice(namespace, migration),
|
||||
/'-n',\s*NAMESPACE,\s*'apply',\s*'-f',\s*'deploy\/kubernetes\/ql3-cluster\/base\/service-account\.yaml'/,
|
||||
@@ -117,7 +124,10 @@ test('creates the namespaced control identity before the migration Job', () => {
|
||||
|
||||
test('provisions the fail-closed worker ingress identity and derives all role evidence from one set', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /const roleList = ROLE_NAMES\.map/);
|
||||
@@ -144,7 +154,10 @@ test('provisions the fail-closed worker ingress identity and derives all role ev
|
||||
|
||||
test('preloads both lock-owned images before applying the operator manifest', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const preload = source.indexOf(
|
||||
@@ -158,14 +171,8 @@ test('preloads both lock-owned images before applying the operator manifest', ()
|
||||
const contract = source.slice(preload, manifest);
|
||||
assert.match(contract, /docker\(\['pull', reviewedImage\]\)/);
|
||||
assert.match(contract, /imageDigest\(reviewedImage\)/);
|
||||
assert.match(
|
||||
contract,
|
||||
/const preloadTag = imageTag\(reviewedImage\)/,
|
||||
);
|
||||
assert.match(
|
||||
contract,
|
||||
/docker\(\['tag', reviewedImage, preloadTag\]\)/,
|
||||
);
|
||||
assert.match(contract, /const preloadTag = imageTag\(reviewedImage\)/);
|
||||
assert.match(contract, /docker\(\['tag', reviewedImage, preloadTag\]\)/);
|
||||
assert.match(
|
||||
contract,
|
||||
/kind\(\['load', 'docker-image', preloadTag, '--name', clusterName\]\)/,
|
||||
@@ -195,12 +202,18 @@ test('rejects a canonical but checksum-unreviewed operator manifest', () => {
|
||||
|
||||
test('bounds remote manifest retries and removes disposable temporary state', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.resolve(__dirname, '../../scripts/ql3-cloudnativepg-live-contract.cjs'),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../scripts/ql3-cloudnativepg-live-contract.cjs',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /'--http1\.1'/);
|
||||
assert.match(source, /'--retry-max-time',\s*'300'/);
|
||||
assert.match(source, /reviewedOperatorManifest\(downloadedOperatorManifest\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/reviewedOperatorManifest\(downloadedOperatorManifest\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/fs\.rmSync\(tempDirectory, \{ recursive: true, force: true \}\)/,
|
||||
|
||||
@@ -5,8 +5,12 @@ const { test } = require('node:test');
|
||||
const {
|
||||
auditClusterDeployment,
|
||||
} = require('../../scripts/ql3-cluster-deployment-audit.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../..');
|
||||
const VERSION = readReleaseIdentity(ROOT).version;
|
||||
|
||||
function intercept(relativePath, transform) {
|
||||
const target = path.join(ROOT, relativePath);
|
||||
@@ -233,8 +237,8 @@ test('keeps Cluster AI optional with projected authority and an independent dige
|
||||
'deploy/kubernetes/ql3-cluster/base/deployment.yaml',
|
||||
(source) =>
|
||||
source.replace(
|
||||
'image: qinglong3-cluster-control:3.0.0-alpha.0',
|
||||
'image: qinglong3-cluster-control-ai:3.0.0-alpha.0',
|
||||
`image: qinglong3-cluster-control:${VERSION}`,
|
||||
`image: qinglong3-cluster-control-ai:${VERSION}`,
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -263,8 +267,7 @@ test('keeps Cluster AI optional with projected authority and an independent dige
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/kubernetes/ql3-cluster/overlays/cluster-ai-example/kustomization.yaml',
|
||||
(source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
),
|
||||
});
|
||||
assert.equal(tagBased.compatible, false);
|
||||
@@ -600,8 +603,7 @@ test('rejects widened lifecycle, authority or public inputs in the Approval clie
|
||||
],
|
||||
[
|
||||
'deploy/kubernetes/ql3-cluster/operations/approval-management-client/kustomization.yaml',
|
||||
(source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
'QL3_CLUSTER_APPROVAL_MANAGEMENT_CLIENT_OPT_IN',
|
||||
],
|
||||
[
|
||||
@@ -654,8 +656,7 @@ test('rejects widened lifecycle, authority or public inputs in the automation cl
|
||||
],
|
||||
[
|
||||
'deploy/kubernetes/ql3-cluster/operations/automation-management-client/kustomization.yaml',
|
||||
(source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
'QL3_CLUSTER_AUTOMATION_MANAGEMENT_CLIENT_RELEASE_DIGEST_PIN',
|
||||
],
|
||||
[
|
||||
@@ -726,8 +727,7 @@ test('rejects widened authority or lifecycle in the Worker management client', (
|
||||
],
|
||||
[
|
||||
'deploy/kubernetes/ql3-cluster/operations/worker-credential-management-client/kustomization.yaml',
|
||||
(source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
'QL3_CLUSTER_WORKER_CREDENTIAL_MANAGEMENT_CLIENT_RELEASE_DIGEST_PIN',
|
||||
],
|
||||
[
|
||||
@@ -840,8 +840,7 @@ test('rejects recurring, default-enabled or public-egress Worker execution', ()
|
||||
],
|
||||
[
|
||||
'deploy/kubernetes/ql3-cluster/operations/worker-credential-executor/cloudnative-pg/kustomization.yaml',
|
||||
(source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
'QL3_CLUSTER_WORKER_CREDENTIAL_EXECUTOR_CLOUDNATIVE_PG_AUTHORITY',
|
||||
],
|
||||
]) {
|
||||
@@ -917,8 +916,7 @@ test('rejects default or tag-based Worker management rollout', () => {
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/kubernetes/ql3-cluster/operations/worker-credential-management/cloudnative-pg/kustomization.yaml',
|
||||
(source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
),
|
||||
});
|
||||
assert.equal(tagBased.compatible, false);
|
||||
@@ -1114,8 +1112,7 @@ test('rejects default enablement or tag-based Package management rollout', () =>
|
||||
root: ROOT,
|
||||
readFile: intercept(
|
||||
'deploy/kubernetes/ql3-cluster/operations/plugin-package-management/cloudnative-pg/kustomization.yaml',
|
||||
(source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
(source) => source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
),
|
||||
});
|
||||
assert.equal(tagBased.compatible, false);
|
||||
@@ -1424,7 +1421,7 @@ test('rejects tag-based production overlays for either cluster image', () => {
|
||||
const report = auditClusterDeployment({
|
||||
root: ROOT,
|
||||
readFile: intercept(relativePath, (source) =>
|
||||
source.replace(/digest: sha256:0{64}/, 'newTag: 3.0.0-alpha.0'),
|
||||
source.replace(/digest: sha256:0{64}/, `newTag: ${VERSION}`),
|
||||
),
|
||||
});
|
||||
assert.equal(report.compatible, false);
|
||||
|
||||
@@ -30,6 +30,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
clusterAdminOperatorContext: true,
|
||||
clusterAdminContextPreflight: true,
|
||||
clusterAdminContextReadiness: true,
|
||||
releaseVersionAudit: true,
|
||||
ociAttestations: true,
|
||||
osVulnerabilityScan: {
|
||||
scanner: 'trivy@0.70.0',
|
||||
@@ -105,6 +106,17 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects removal of the source-derived release version audit', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'pnpm audit:release-version:ql3',
|
||||
'echo release-version-audit-removed',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/source-derived release version identity/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the native arm64 image gate', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64\n image: control',
|
||||
|
||||
@@ -10,8 +10,12 @@ const {
|
||||
componentRef,
|
||||
createClusterImageSbom,
|
||||
} = require('../../scripts/ql3-cluster-image-sbom.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
@@ -23,7 +27,7 @@ test('generates the exact reviewed cluster image runtime closure', () => {
|
||||
|
||||
assert.deepEqual(report, {
|
||||
image: 'control',
|
||||
root: 'pkg:npm/%40qinglong/cluster-control-image-dependencies@3.0.0-alpha.0',
|
||||
root: `pkg:npm/%40qinglong/cluster-control-image-dependencies@${version}`,
|
||||
components: 46,
|
||||
externalComponents: 43,
|
||||
internalComponents: 3,
|
||||
@@ -49,7 +53,7 @@ test('generates the optional Cluster AI image runtime closure', () => {
|
||||
|
||||
assert.deepEqual(report, {
|
||||
image: 'control-ai',
|
||||
root: 'pkg:npm/%40qinglong/cluster-control-image-dependencies@3.0.0-alpha.0',
|
||||
root: `pkg:npm/%40qinglong/cluster-control-image-dependencies@${version}`,
|
||||
components: 47,
|
||||
externalComponents: 43,
|
||||
internalComponents: 4,
|
||||
@@ -71,7 +75,7 @@ test('generates the independent reviewed cluster-admin image closure', () => {
|
||||
|
||||
assert.deepEqual(report, {
|
||||
image: 'admin',
|
||||
root: 'pkg:npm/%40qinglong/cluster-admin-image-dependencies@3.0.0-alpha.0',
|
||||
root: `pkg:npm/%40qinglong/cluster-admin-image-dependencies@${version}`,
|
||||
components: 91,
|
||||
externalComponents: 87,
|
||||
internalComponents: 4,
|
||||
@@ -107,7 +111,7 @@ test('generates the AI-excluded local application image closure', () => {
|
||||
|
||||
assert.deepEqual(report, {
|
||||
image: 'local',
|
||||
root: 'pkg:npm/%40qinglong/local-application-image@3.0.0-alpha.0',
|
||||
root: `pkg:npm/%40qinglong/local-application-image@${version}`,
|
||||
components: 10,
|
||||
externalComponents: 2,
|
||||
internalComponents: 8,
|
||||
@@ -133,7 +137,7 @@ test('generates the headless Worker image runtime closure', () => {
|
||||
|
||||
assert.deepEqual(report, {
|
||||
image: 'worker',
|
||||
root: 'pkg:npm/%40qinglong/worker-image-dependencies@3.0.0-alpha.0',
|
||||
root: `pkg:npm/%40qinglong/worker-image-dependencies@${version}`,
|
||||
components: 27,
|
||||
externalComponents: 24,
|
||||
internalComponents: 3,
|
||||
@@ -182,7 +186,7 @@ test('rejects widened metadata and root component drift', () => {
|
||||
|
||||
test('rejects a missing internal dependency edge', () => {
|
||||
const document = createClusterImageSbom({ root });
|
||||
const controlRef = componentRef('@qinglong/cluster-control', '3.0.0-alpha.0');
|
||||
const controlRef = componentRef('@qinglong/cluster-control', version);
|
||||
const edge = document.dependencies.find((entry) => entry.ref === controlRef);
|
||||
edge.dependsOn = edge.dependsOn.slice(1);
|
||||
|
||||
|
||||
@@ -16,8 +16,12 @@ const {
|
||||
const {
|
||||
createClusterImageSbom,
|
||||
} = require('../../scripts/ql3-cluster-image-sbom.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
const revision = 'fixture-revision';
|
||||
|
||||
function createFixture(t, options = {}) {
|
||||
@@ -164,7 +168,7 @@ function createFixture(t, options = {}) {
|
||||
: 'QingLong 3.0 Cluster Admin',
|
||||
...(isLocal || isWorker || isControl || image === 'admin'
|
||||
? {
|
||||
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||
'org.opencontainers.image.version': version,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
@@ -20,6 +20,11 @@ const {
|
||||
const {
|
||||
canonicalDigest,
|
||||
} = require('../../scripts/ql3-physical-edge-evidence.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
|
||||
const version = readReleaseIdentity(path.resolve(__dirname, '../..')).version;
|
||||
|
||||
const packages = [
|
||||
'@qinglong/local-admin',
|
||||
@@ -55,7 +60,7 @@ function artifactFixture(t) {
|
||||
packageName === '@qinglong/local-application'
|
||||
? {
|
||||
name: packageName,
|
||||
version: '3.0.0-alpha.0',
|
||||
version,
|
||||
engines: { node: '>=24.18.0 <25' },
|
||||
bin: { 'ql3-local-application': 'dist/cli.js' },
|
||||
}
|
||||
|
||||
@@ -11,12 +11,16 @@ const {
|
||||
parseArguments,
|
||||
runCli,
|
||||
} = require('../../scripts/ql3-release-candidate-contract.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const version = readReleaseIdentity(root).version;
|
||||
const identity = Object.freeze({
|
||||
version: '3.0.0-alpha.0',
|
||||
version,
|
||||
sourceRevision: 'a'.repeat(40),
|
||||
sourceRef: 'refs/tags/v3.0.0-alpha.0',
|
||||
sourceRef: `refs/tags/v${version}`,
|
||||
});
|
||||
|
||||
test('freezes an independent low-resource local release family', () => {
|
||||
@@ -35,6 +39,14 @@ test('freezes an independent low-resource local release family', () => {
|
||||
'standalone',
|
||||
]);
|
||||
assert.equal(contract.workspace.packageCount, 18);
|
||||
assert.equal(
|
||||
contract.compatibility.releaseIdentitySchema,
|
||||
'qinglong/release-identity@v1',
|
||||
);
|
||||
assert.match(
|
||||
contract.compatibility.releaseIdentityDigest,
|
||||
/^sha256:[a-f0-9]{64}$/u,
|
||||
);
|
||||
assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u);
|
||||
assert.deepEqual(
|
||||
auditReleaseCandidateContract(contract, {
|
||||
@@ -126,6 +138,18 @@ test('rejects tag, version and source identity drift', () => {
|
||||
}),
|
||||
/Git SHA-1/,
|
||||
);
|
||||
const nextVersion = `${version.slice(0, version.lastIndexOf('.') + 1)}1`;
|
||||
assert.throws(
|
||||
() =>
|
||||
createReleaseCandidateContract({
|
||||
root,
|
||||
...identity,
|
||||
version: nextVersion,
|
||||
sourceRef: `refs/tags/v${nextVersion}`,
|
||||
releaseScope: 'local',
|
||||
}),
|
||||
/repository release identity/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a source-derived report mutated after creation', () => {
|
||||
@@ -153,9 +177,9 @@ test('writes once and independently audits the exact report through the CLI', (t
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const report = path.join(directory, 'contract.json');
|
||||
const common = [
|
||||
'--version=3.0.0-alpha.0',
|
||||
`--version=${version}`,
|
||||
`--source-revision=${identity.sourceRevision}`,
|
||||
'--source-ref=refs/tags/v3.0.0-alpha.0',
|
||||
`--source-ref=refs/tags/v${version}`,
|
||||
'--release-scope=local',
|
||||
];
|
||||
const output = { write() {} };
|
||||
@@ -168,20 +192,16 @@ test('writes once and independently audits the exact report through the CLI', (t
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
['--mode=create', ...common, `--output=${report}`],
|
||||
root,
|
||||
output,
|
||||
),
|
||||
runCli(['--mode=create', ...common, `--output=${report}`], root, output),
|
||||
/output must be unused/,
|
||||
);
|
||||
});
|
||||
|
||||
test('parses only exact closed create and audit modes', () => {
|
||||
const common = [
|
||||
'--version=3.0.0-alpha.0',
|
||||
`--version=${version}`,
|
||||
`--source-revision=${identity.sourceRevision}`,
|
||||
'--source-ref=refs/tags/v3.0.0-alpha.0',
|
||||
`--source-ref=refs/tags/v${version}`,
|
||||
'--release-scope=local',
|
||||
];
|
||||
assert.equal(
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
'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 semver = require('semver');
|
||||
const {
|
||||
CONTAINER_ROOTS,
|
||||
DEPLOYMENT_FILES,
|
||||
DEPLOYMENT_ROOTS,
|
||||
applyVersionTransitionPlan,
|
||||
auditReleaseVersionContract,
|
||||
createVersionTransitionPlan,
|
||||
parseArguments,
|
||||
runCli,
|
||||
validatePlan,
|
||||
} = require('../../scripts/ql3-version-transition.cjs');
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const SOURCE_VERSION = readReleaseIdentity(root).version;
|
||||
const TARGET_VERSION = semver.inc(SOURCE_VERSION, 'prerelease');
|
||||
const LEGACY_VERSION = JSON.parse(
|
||||
fs.readFileSync(path.join(root, 'package.json')),
|
||||
).version;
|
||||
|
||||
function copyFile(sourceRoot, targetRoot, relativePath) {
|
||||
const source = path.join(sourceRoot, relativePath);
|
||||
const target = path.join(targetRoot, relativePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(source, target);
|
||||
fs.chmodSync(target, fs.statSync(source).mode & 0o777);
|
||||
}
|
||||
|
||||
function createFixture(t) {
|
||||
const fixture = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-version-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(fixture, { recursive: true, force: true }));
|
||||
copyFile(root, fixture, 'package.json');
|
||||
copyFile(root, fixture, 'ql3-release.json');
|
||||
for (const entry of fs.readdirSync(path.join(root, 'packages'), {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('ql3-')) {
|
||||
copyFile(root, fixture, `packages/${entry.name}/package.json`);
|
||||
}
|
||||
}
|
||||
for (const containerRoot of CONTAINER_ROOTS) {
|
||||
for (const relativePath of [
|
||||
'Dockerfile',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'runtime-dependencies/package.json',
|
||||
'runtime-dependencies/package-lock.json',
|
||||
]) {
|
||||
copyFile(root, fixture, `${containerRoot}/${relativePath}`);
|
||||
}
|
||||
}
|
||||
for (const deploymentRoot of DEPLOYMENT_ROOTS) {
|
||||
fs.cpSync(
|
||||
path.join(root, deploymentRoot),
|
||||
path.join(fixture, deploymentRoot),
|
||||
{ recursive: true, dereference: false },
|
||||
);
|
||||
}
|
||||
for (const relativePath of DEPLOYMENT_FILES) {
|
||||
copyFile(root, fixture, relativePath);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function replaceVersion(
|
||||
filePath,
|
||||
source = SOURCE_VERSION,
|
||||
target = TARGET_VERSION,
|
||||
) {
|
||||
const contents = fs.readFileSync(filePath, 'utf8');
|
||||
assert.equal(contents.includes(source), true);
|
||||
fs.writeFileSync(filePath, contents.split(source).join(target));
|
||||
}
|
||||
|
||||
test('audits one source-derived QingLong 3 release identity', () => {
|
||||
assert.deepEqual(auditReleaseVersionContract(root), {
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/release-identity@v1',
|
||||
version: SOURCE_VERSION,
|
||||
nodeVersion: '24.18.0',
|
||||
nodeEngine: '>=24.18.0 <25',
|
||||
legacyRootPackageVersion: LEGACY_VERSION,
|
||||
legacyRootExcluded: true,
|
||||
workspacePackageCount: 18,
|
||||
containerRootCount: 4,
|
||||
deploymentFileCount: 242,
|
||||
deploymentImageReferences: 32,
|
||||
deploymentVersionOccurrences: 36,
|
||||
compatible: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('plans the exact governed version surface without touching legacy 2.x', () => {
|
||||
const plan = createVersionTransitionPlan({
|
||||
root,
|
||||
sourceVersion: SOURCE_VERSION,
|
||||
targetVersion: TARGET_VERSION,
|
||||
});
|
||||
assert.equal(plan.fileCount, 65);
|
||||
assert.equal(plan.replacementCount, 83);
|
||||
assert.equal(plan.legacyRootPackageVersion, LEGACY_VERSION);
|
||||
assert.equal(plan.legacyRootExcluded, true);
|
||||
assert.equal(
|
||||
plan.entries.some((entry) => entry.path === 'package.json'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
plan.entries.filter((entry) => entry.path.startsWith('packages/')).length,
|
||||
18,
|
||||
);
|
||||
assert.match(plan.planDigest, /^sha256:[a-f0-9]{64}$/u);
|
||||
assert.equal(validatePlan(plan), plan);
|
||||
});
|
||||
|
||||
test('applies and exactly replays a complete version transition', (t) => {
|
||||
const fixture = createFixture(t);
|
||||
const plan = createVersionTransitionPlan({
|
||||
root: fixture,
|
||||
sourceVersion: SOURCE_VERSION,
|
||||
targetVersion: TARGET_VERSION,
|
||||
});
|
||||
const report = applyVersionTransitionPlan(plan, {
|
||||
root: fixture,
|
||||
report: path.join(fixture, 'first-report.json'),
|
||||
});
|
||||
assert.equal(report.changedFiles, plan.fileCount);
|
||||
assert.equal(report.alreadyCurrentFiles, 0);
|
||||
assert.equal(report.exactReplay, false);
|
||||
assert.equal(auditReleaseVersionContract(fixture).version, TARGET_VERSION);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(fixture, 'package.json'))).version,
|
||||
LEGACY_VERSION,
|
||||
);
|
||||
|
||||
const replay = applyVersionTransitionPlan(plan, {
|
||||
root: fixture,
|
||||
report: path.join(fixture, 'replay-report.json'),
|
||||
});
|
||||
assert.equal(replay.changedFiles, 0);
|
||||
assert.equal(replay.alreadyCurrentFiles, plan.fileCount);
|
||||
assert.equal(replay.exactReplay, true);
|
||||
});
|
||||
|
||||
test('resumes a partial transition using before and after digests', (t) => {
|
||||
const fixture = createFixture(t);
|
||||
const plan = createVersionTransitionPlan({
|
||||
root: fixture,
|
||||
sourceVersion: SOURCE_VERSION,
|
||||
targetVersion: TARGET_VERSION,
|
||||
});
|
||||
replaceVersion(path.join(fixture, 'ql3-release.json'));
|
||||
const report = applyVersionTransitionPlan(plan, {
|
||||
root: fixture,
|
||||
report: path.join(fixture, 'recovered-report.json'),
|
||||
});
|
||||
assert.equal(report.changedFiles, plan.fileCount - 1);
|
||||
assert.equal(report.alreadyCurrentFiles, 1);
|
||||
assert.equal(auditReleaseVersionContract(fixture).version, TARGET_VERSION);
|
||||
});
|
||||
|
||||
test('preflights every governed file before making a partial mutation', (t) => {
|
||||
const fixture = createFixture(t);
|
||||
const plan = createVersionTransitionPlan({
|
||||
root: fixture,
|
||||
sourceVersion: SOURCE_VERSION,
|
||||
targetVersion: TARGET_VERSION,
|
||||
});
|
||||
const drifted = path.join(fixture, plan.entries.at(-1).path);
|
||||
fs.appendFileSync(drifted, '\n');
|
||||
assert.throws(
|
||||
() =>
|
||||
applyVersionTransitionPlan(plan, {
|
||||
root: fixture,
|
||||
report: path.join(fixture, 'must-not-exist.json'),
|
||||
}),
|
||||
/drifted after plan creation/,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(fixture, 'ql3-release.json'))).version,
|
||||
SOURCE_VERSION,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(fixture, 'must-not-exist.json')), false);
|
||||
});
|
||||
|
||||
test('rejects invalid SemVer, downgrade, plan mutation and a symbolic-link identity', (t) => {
|
||||
assert.throws(
|
||||
() =>
|
||||
createVersionTransitionPlan({
|
||||
root,
|
||||
sourceVersion: SOURCE_VERSION,
|
||||
targetVersion: '3.0.0-alpha',
|
||||
}),
|
||||
/monotonically newer/,
|
||||
);
|
||||
const plan = createVersionTransitionPlan({
|
||||
root,
|
||||
sourceVersion: SOURCE_VERSION,
|
||||
targetVersion: TARGET_VERSION,
|
||||
});
|
||||
const mutatedPlan = JSON.parse(JSON.stringify(plan));
|
||||
mutatedPlan.entries[0].path = 'package.json';
|
||||
assert.throws(() => validatePlan(mutatedPlan), /plan entry is invalid/);
|
||||
|
||||
const fixture = createFixture(t);
|
||||
const invalidIdentityPath = path.join(fixture, 'ql3-release.json');
|
||||
const invalidIdentity = JSON.parse(fs.readFileSync(invalidIdentityPath));
|
||||
invalidIdentity.version = '3.0.0-alpha.01';
|
||||
fs.writeFileSync(
|
||||
invalidIdentityPath,
|
||||
`${JSON.stringify(invalidIdentity, null, 2)}\n`,
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseVersionContract(fixture),
|
||||
/identity shape or value is incompatible/,
|
||||
);
|
||||
copyFile(root, fixture, 'ql3-release.json');
|
||||
fs.renameSync(
|
||||
path.join(fixture, 'ql3-release.json'),
|
||||
path.join(fixture, 'identity-target.json'),
|
||||
);
|
||||
fs.symlinkSync(
|
||||
'identity-target.json',
|
||||
path.join(fixture, 'ql3-release.json'),
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseVersionContract(fixture),
|
||||
/canonical regular file/,
|
||||
);
|
||||
});
|
||||
|
||||
test('CLI writes no-replace plans and accepts only closed modes', (t) => {
|
||||
const directory = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-version-cli-')),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const planPath = path.join(directory, 'plan.json');
|
||||
const output = { write() {} };
|
||||
runCli(
|
||||
[
|
||||
'--mode=plan',
|
||||
`--from=${SOURCE_VERSION}`,
|
||||
`--to=${TARGET_VERSION}`,
|
||||
`--output=${planPath}`,
|
||||
],
|
||||
root,
|
||||
output,
|
||||
);
|
||||
assert.equal(fs.statSync(planPath).mode & 0o777, 0o600);
|
||||
assert.throws(
|
||||
() =>
|
||||
runCli(
|
||||
[
|
||||
'--mode=plan',
|
||||
`--from=${SOURCE_VERSION}`,
|
||||
`--to=${TARGET_VERSION}`,
|
||||
`--output=${planPath}`,
|
||||
],
|
||||
root,
|
||||
output,
|
||||
),
|
||||
/output must be unused/,
|
||||
);
|
||||
assert.deepEqual(parseArguments(['--mode=audit']), { mode: 'audit' });
|
||||
assert.throws(
|
||||
() => parseArguments(['--mode=audit', '--extra=true']),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user