diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 2dc108e5..720bdb69 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -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 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 5ed7c705..07f8bdfd 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -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//qinglong3-cluster-admin@sha256:`、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 增加上传、签名或行动能力。 diff --git a/docs/adr/ADR-0426-source-derived-release-version-transition.md b/docs/adr/ADR-0426-source-derived-release-version-transition.md new file mode 100644 index 00000000..7e45f6a9 --- /dev/null +++ b/docs/adr/ADR-0426-source-derived-release-version-transition.md @@ -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= --to= --output=`:生成 no-replace `0600` plan; +3. `--mode=apply --plan= --report=`:应用已审阅 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 门而不能复用。 diff --git a/package.json b/package.json index 2603ad64..41aa0332 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ql3-release.json b/ql3-release.json new file mode 100644 index 00000000..986f1359 --- /dev/null +++ b/ql3-release.json @@ -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 +} diff --git a/scripts/lib/ql3-release-identity.cjs b/scripts/lib/ql3-release-identity.cjs new file mode 100644 index 00000000..009f4c64 --- /dev/null +++ b/scripts/lib/ql3-release-identity.cjs @@ -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, +}); diff --git a/scripts/ql3-cloudnativepg-live-contract.cjs b/scripts/ql3-cloudnativepg-live-contract.cjs index da1c4580..443e8b76 100644 --- a/scripts/ql3-cloudnativepg-live-contract.cjs +++ b/scripts/ql3-cloudnativepg-live-contract.cjs @@ -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, )}`; diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs index 991f915e..ad516a5a 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -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); diff --git a/scripts/ql3-cluster-deployment-audit.cjs b/scripts/ql3-cluster-deployment-audit.cjs index d45df8a7..e7bc0923 100644 --- a/scripts/ql3-cluster-deployment-audit.cjs +++ b/scripts/ql3-cluster-deployment-audit.cjs @@ -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( diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 06ee471b..daabf09a 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -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', diff --git a/scripts/ql3-cluster-oci-layout-audit.cjs b/scripts/ql3-cluster-oci-layout-audit.cjs index fe2c2ea9..607dad66 100644 --- a/scripts/ql3-cluster-oci-layout-audit.cjs +++ b/scripts/ql3-cluster-oci-layout-audit.cjs @@ -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, }, }, }; diff --git a/scripts/ql3-local-image-audit.cjs b/scripts/ql3-local-image-audit.cjs index fcd1ca63..f89b284f 100644 --- a/scripts/ql3-local-image-audit.cjs +++ b/scripts/ql3-local-image-audit.cjs @@ -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' diff --git a/scripts/ql3-physical-edge-application-start.cjs b/scripts/ql3-physical-edge-application-start.cjs index c5ea8a4d..9a77031c 100644 --- a/scripts/ql3-physical-edge-application-start.cjs +++ b/scripts/ql3-physical-edge-application-start.cjs @@ -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 diff --git a/scripts/ql3-prompt-output-external-recovery-deployment-audit.cjs b/scripts/ql3-prompt-output-external-recovery-deployment-audit.cjs index daead581..2bb8c3b7 100644 --- a/scripts/ql3-prompt-output-external-recovery-deployment-audit.cjs +++ b/scripts/ql3-prompt-output-external-recovery-deployment-audit.cjs @@ -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', diff --git a/scripts/ql3-release-candidate-contract.cjs b/scripts/ql3-release-candidate-contract.cjs index efabdf01..05664914 100644 --- a/scripts/ql3-release-candidate-contract.cjs +++ b/scripts/ql3-release-candidate-contract.cjs @@ -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: { diff --git a/scripts/ql3-version-transition.cjs b/scripts/ql3-version-transition.cjs new file mode 100644 index 00000000..72fc4e1c --- /dev/null +++ b/scripts/ql3-version-transition.cjs @@ -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, +}); diff --git a/test/back/ql3CloudNativePgLiveContract.test.cjs b/test/back/ql3CloudNativePgLiveContract.test.cjs index 5bdb3b3a..87f9c702 100644 --- a/test/back/ql3CloudNativePgLiveContract.test.cjs +++ b/test/back/ql3CloudNativePgLiveContract.test.cjs @@ -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 \}\)/, diff --git a/test/back/ql3ClusterDeploymentAudit.test.cjs b/test/back/ql3ClusterDeploymentAudit.test.cjs index a62671e2..911704f1 100644 --- a/test/back/ql3ClusterDeploymentAudit.test.cjs +++ b/test/back/ql3ClusterDeploymentAudit.test.cjs @@ -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); diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index e44d0d45..7db98a1c 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -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', diff --git a/test/back/ql3ClusterImageSbom.test.cjs b/test/back/ql3ClusterImageSbom.test.cjs index c132731e..5245e505 100644 --- a/test/back/ql3ClusterImageSbom.test.cjs +++ b/test/back/ql3ClusterImageSbom.test.cjs @@ -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); diff --git a/test/back/ql3ClusterOciLayoutAudit.test.cjs b/test/back/ql3ClusterOciLayoutAudit.test.cjs index 4c892a06..3c66824f 100644 --- a/test/back/ql3ClusterOciLayoutAudit.test.cjs +++ b/test/back/ql3ClusterOciLayoutAudit.test.cjs @@ -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, } : {}), }, diff --git a/test/back/ql3PhysicalEdgeApplicationStart.test.cjs b/test/back/ql3PhysicalEdgeApplicationStart.test.cjs index 36bd098a..d3108869 100644 --- a/test/back/ql3PhysicalEdgeApplicationStart.test.cjs +++ b/test/back/ql3PhysicalEdgeApplicationStart.test.cjs @@ -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' }, } diff --git a/test/back/ql3ReleaseCandidateContract.test.cjs b/test/back/ql3ReleaseCandidateContract.test.cjs index da2f5243..c41ab3ea 100644 --- a/test/back/ql3ReleaseCandidateContract.test.cjs +++ b/test/back/ql3ReleaseCandidateContract.test.cjs @@ -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( diff --git a/test/back/ql3VersionTransition.test.cjs b/test/back/ql3VersionTransition.test.cjs new file mode 100644 index 00000000..4655be4d --- /dev/null +++ b/test/back/ql3VersionTransition.test.cjs @@ -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/, + ); +});