diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 4278ec24..341cfe98 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -583,74 +583,27 @@ jobs: --report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json" node scripts/ql3-local-api-cancellation-live-audit.cjs \ --report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json" - - name: Package the tested native Local Alpha image + - name: Materialize and offline-audit the native Local Alpha trial kit if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} - IMAGE_ARCH: ${{ matrix.image_arch }} - BUNDLE_ROOT: ${{ runner.temp }}/ql3-alpha-local-${{ matrix.image_arch }} + OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }} run: | set -euo pipefail umask 077 - mkdir "${BUNDLE_ROOT}" - archive="${BUNDLE_ROOT}/qinglong3-local-trial-kit-${IMAGE_ARCH}.docker.tar" - docker image save --output "${archive}" \ - "${IMAGE}" \ - "qinglong3-local-operator:ci-${IMAGE_ARCH}" - cp "${RUNNER_TEMP}/ql3-local-application.cdx.json" \ - "${BUNDLE_ROOT}/qinglong3-local-application.cdx.json" - cp "${RUNNER_TEMP}/ql3-local-operator.cdx.json" \ - "${BUNDLE_ROOT}/qinglong3-local-operator.cdx.json" - cp docs/operations/ql3-alpha-candidate.md "${BUNDLE_ROOT}/README.md" - archive_sha256="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)" - ARCHIVE="${archive}" ARCHIVE_SHA256="${archive_sha256}" \ - MANIFEST="${BUNDLE_ROOT}/manifest.json" \ - node <<'NODE' - const fs = require('node:fs'); - const childProcess = require('node:child_process'); - const release = require('./ql3-release.json'); - const imageId = childProcess - .execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', process.env.IMAGE], { encoding: 'utf8' }) - .trim(); - const operatorImage = `qinglong3-local-operator:ci-${process.env.IMAGE_ARCH}`; - const operatorImageId = childProcess - .execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', operatorImage], { encoding: 'utf8' }) - .trim(); - const manifest = { - schemaVersion: 2, - schema: 'qinglong/alpha-local-trial-kit@v1', - maturity: 'alpha_candidate_not_public_release', - product: 'local', - version: release.version, - sourceRevision: process.env.GITHUB_SHA, - architecture: process.env.IMAGE_ARCH, - image: process.env.IMAGE, - imageId, - archive: process.env.ARCHIVE.split('/').pop(), - archiveSha256: process.env.ARCHIVE_SHA256, - operator: { - lifecycle: 'short-lived', - network: 'none-by-default', - image: operatorImage, - imageId: operatorImageId, - archive: process.env.ARCHIVE.split('/').pop(), - archiveSha256: process.env.ARCHIVE_SHA256, - }, - verification: { - osVulnerabilityPolicy: 'passed', - sbomInventoryReconciliation: 'passed', - router128MiBEntrypoint: 'passed', - operator128MiBEntrypoint: 'passed', - operatorPackageInventory: 'passed', - freshOwnerJourney: 'passed', - edgeFreshLifecycle: 'passed', - standaloneFreshLifecycle: 'passed', - localApiCancellation: 'passed', - }, - }; - fs.writeFileSync(process.env.MANIFEST, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' }); - NODE - - name: Upload the tested native Local Alpha image + node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ + --mode=create \ + --application-image="${IMAGE}" \ + --operator-image="${OPERATOR_IMAGE}" \ + --architecture=${{ matrix.image_arch }} \ + --source-revision=${{ github.sha }} \ + --application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json" \ + --operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json" \ + --readme=docs/operations/ql3-local-alpha-trial-kit.md \ + --output="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" + node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ + --mode=audit --bundle="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}" + - name: Upload the tested native Local Alpha trial kit if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/docs/adr/ADR-0503-downloadable-local-alpha-trial-kit.md b/docs/adr/ADR-0503-downloadable-local-alpha-trial-kit.md index 301c4669..f3874d1b 100644 --- a/docs/adr/ADR-0503-downloadable-local-alpha-trial-kit.md +++ b/docs/adr/ADR-0503-downloadable-local-alpha-trial-kit.md @@ -1,6 +1,6 @@ # ADR-0503:可下载的 Local Alpha 试运行套件 -- 状态:Proposed(实现完成,原生 Linux x64/arm64 CI 与实际归档待验收) +- 状态:Proposed(原生 Linux x64/arm64 已通过,实际可下载归档待维护者授权) - 日期:2026-08-26 - 决策:D-408 - 关联:ADR-0193、ADR-0195、ADR-0196、ADR-0425 @@ -84,4 +84,4 @@ Docker Desktop 的 bind mount 根目录可能把宿主当前 UID 映射为容器 - `ql3 --version` 与 `ql3 setup --help` 在 read-only、network none、128 MiB、0.5 CPU、32 PID 下通过; - Docker Desktop 完整旅程因 mount root UID 非等价失败,临时 credential/pepper 目录已删除;未把该结果记为通过。 -转为 Accepted 前必须取得同一提交的原生 Linux amd64/arm64 完整旅程成功记录,并重新执行 package、backend、artifact、dependency 和 release workflow 审计。实际双架构 trial-kit archive 仍需维护者明确授权手动生成;Public Release Set 是否把 operator 纳入正式签名/catalog,留给后续独立 release-set schema 决策。 +提交 `2253b99066e0c221e11dc01384f496ec2a50e4bd` 的 CI run `32918632202` 已在原生 Linux amd64/arm64 同时通过完整 fresh Owner、Edge/Standalone lifecycle 和 SQLite integrity,40 个 required job 全部成功且无重试。实际双架构 trial-kit archive 仍需维护者明确授权手动生成;转为 Accepted 前还必须记录两个可下载 artifact 的 digest 与离线复核结果。Public Release Set 是否把 operator 纳入正式签名/catalog,留给后续独立 release-set schema 决策。 diff --git a/docs/adr/ADR-0504-canonical-local-alpha-trial-kit-materialization.md b/docs/adr/ADR-0504-canonical-local-alpha-trial-kit-materialization.md new file mode 100644 index 00000000..8b027da1 --- /dev/null +++ b/docs/adr/ADR-0504-canonical-local-alpha-trial-kit-materialization.md @@ -0,0 +1,70 @@ +# ADR-0504:Local Alpha Trial Kit 单一物化与离线审计 + +- 状态:Accepted +- 日期:2026-08-26 +- 决策:D-409 +- 关联:ADR-0193、ADR-0195、ADR-0196、ADR-0503 + +## 背景 + +ADR-0503 已定义 Local Application 与短生命周期 operator 组成同架构 Trial Kit,并由原生 Linux x64/arm64 门验证完整 fresh 用户旅程。此前手动 artifact 步骤仍在 workflow shell/heredoc 内直接执行 `docker image save`、复制 SBOM 并拼装 manifest。该实现能产生文件,但本地维护者无法复用同一逻辑,下载者也没有一个拒绝额外文件、checksum 漂移或 SBOM 替换的离线审计入口。 + +阶段产物如果只有 CI 内联命令,没有唯一可执行的物化协议,仍可能出现“CI 声称通过、实际下载目录无法独立复核”的分叉。 + +## 决策 + +### 1. 使用一个仓库内 materializer 作为唯一写 authority + +新增 `ql3-local-alpha-trial-kit-bundle.cjs`,固定提供两个闭合模式: + +- `create`:验证 release identity、完整 source revision、Tier-1 架构、两个镜像的 ID/OS/架构/non-root user/OCI label,以及两份受审 CycloneDX SBOM;随后通过一次 `docker image save` 生成去重 archive; +- `audit`:不调用 Docker、不访问网络,只验证 manifest exact shape、闭合文件集、每个文件的 byte length/SHA-256、`SHA256SUMS`、两份 SBOM 的 profile/version 身份和两镜像 ID 的分离。 + +GitHub Actions 和本地阶段产物必须调用同一入口。workflow 不再拥有独立的 heredoc manifest 实现。 + +### 2. 套件是闭合目录,不是松散文件集合 + +每个架构的目录只允许六个 regular file: + +1. 一个同时包含 Application/operator 的 Docker archive; +2. Application CycloneDX SBOM; +3. operator CycloneDX SBOM; +4. 面向部署者的 `README.md`; +5. canonical `manifest.json`; +6. 覆盖前五个文件的 `SHA256SUMS`。 + +symlink、子目录、credential、keyring、数据库、日志、未声明证据或任意额外文件都失败关闭。创建目标必须是未使用的 canonical absolute path;任何失败都会删除本次半成品目录,既有目录不会被覆盖。 + +### 3. 离线验证不等同于重新证明 CI live gate + +manifest 中的 `verification` 是该 artifact 生成位置之前已通过的 workflow gate 声明。离线 auditor 证明目录内容未漂移、身份相互一致,不伪称在低配设备上重新执行 fresh Owner 或 lifecycle 门。实际加载后的设备 smoke 和生产发布签名仍是不同层级的证据。 + +普通 push/PR 继续只运行构建和 live gate,不上传大 archive。`workflow_dispatch + produce_alpha_artifacts=true` 仍需要维护者显式授权。 + +## 被拒绝的替代方案 + +### 保留 workflow heredoc,另写一个只读 auditor + +拒绝。写入与读取协议分离会形成两个 schema authority,测试只能证明 auditor 接受样例,不能证明 CI 实际写出的内容来自同一实现。 + +### 每个镜像各自生成 archive 和 checksum + +拒绝。它会在低容量设备上重复保存共享 Node layer,并破坏 ADR-0503 的单 archive 边界。 + +### 把 materializer 做成 workspace package + +拒绝。它是发布期仓库工具,不是运行时领域能力;新增单文件 package 会扩大 18-package 边界而不带来部署隔离。 + +## 影响 + +- 阶段产物可以在 CI 之外按同一协议生成并离线复核; +- 低配设备只需要 `sha256sum` 和 Docker 即可先验证文件再加载,不增加常驻运行时依赖或 RSS; +- manifest 从松散的顶层 image 字段收敛为 `archive/images/sboms/readme/verification` 的 exact shape;该 schema 尚未公开发布,因此不承担旧 artifact 兼容承诺; +- Cluster native image artifact 暂不复用此脚本,其单镜像/多角色发布语义与 Local Trial Kit 不同。 + +## 验证 + +- 单元测试覆盖正常物化/审计、错误 source revision、archive 篡改、额外文件和 SBOM 替换; +- Local operator 静态审计要求 workflow 同时调用 `create` 和 `audit`; +- 当前提交的真实 arm64 Application/operator 必须由该入口生成本地私有 Trial Kit,并再次执行离线审计和加载后最小 smoke; +- 正式 downloadable 双架构 artifact 仍由维护者授权的手动 workflow 生成。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 4dc7397f..eefefac0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -506,7 +506,8 @@ | [ADR-0500](./ADR-0500-short-lived-cluster-security-administration-command.md) | 短生命周期 Cluster Security Administration 产品命令 | Accepted | | [ADR-0501](./ADR-0501-opt-in-kubernetes-security-administration-job.md) | 可选的一次性 Kubernetes Security Administration Job | Accepted | | [ADR-0502](./ADR-0502-bounded-cluster-api-credential-pepper-keyring.md) | 有界 Cluster API Credential Pepper Keyring | Accepted | -| [ADR-0503](./ADR-0503-downloadable-local-alpha-trial-kit.md) | 可下载的 Local Alpha 试运行套件 | Proposed(实现完成;原生 Linux 双架构与实际归档待验收) | +| [ADR-0503](./ADR-0503-downloadable-local-alpha-trial-kit.md) | 可下载的 Local Alpha 试运行套件 | Proposed(原生 Linux 双架构已通过;实际可下载归档待维护者授权) | +| [ADR-0504](./ADR-0504-canonical-local-alpha-trial-kit-materialization.md) | Local Alpha Trial Kit 单一物化与离线审计 | Accepted | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index d667af97..3774770a 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -26,7 +26,7 @@ 该实物保存在工作区忽略目录,不进入 Git,也尚未上传 GitHub。公开下载仍需维护者明确授权上传。它只含 headless Application,没有可下载的 `ql3 setup/owner/task/...` 管理制品;因此它足以证明 runtime 工程可用性,但不能独立完成部署用户旅程。此前“单架构内部试运行材料”的表述按 D-408 收紧为“运行时工程候选”。 -ADR-0503 已增加独立的 `qinglong3-local-operator`:它复用现有统一 `ql3` CLI,每次执行一个 command-file 命令后退出,不进入常驻 Application。后续手动 Alpha run 会把 Application 与 operator 通过一次 `docker image save` 写入同一架构的去重 archive;只有原生 Linux amd64/arm64 都完成 fresh setup、首 Owner ceremony、Application active/stop 和 SQLite integrity 后,才能升级为 Local Alpha Trial Kit。 +ADR-0503 已增加独立的 `qinglong3-local-operator`:它复用现有统一 `ql3` CLI,每次执行一个 command-file 命令后退出,不进入常驻 Application。提交 `2253b99066e0c221e11dc01384f496ec2a50e4bd` 的原生 Linux amd64/arm64 已同时通过 fresh setup、首 Owner ceremony、Application active/stop 和 SQLite integrity,CI run `32918632202` 为 40/40。ADR-0504 又把一次 `docker image save`、manifest、SBOM、README、`SHA256SUMS` 和离线审计收敛为同一个 materializer;下一项未完成的外部里程碑是维护者授权生成并保留两个可下载 archive。 ## 生成 @@ -40,9 +40,9 @@ ADR-0503 已增加独立的 `qinglong3-local-operator`:它复用现有统一 ` Local artifact 含: - 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-.docker.tar`;共享 Node 基础层在 archive 中去重; -- schema 为 `qinglong/alpha-local-trial-kit@v1` 的 `manifest.json`,绑定版本、完整 source commit、架构、两个 image tag/image ID、共同 archive SHA-256 与已通过 gate; +- schema 为 `qinglong/alpha-local-trial-kit@v1` 的 `manifest.json`,通过 `archive/images/sboms/readme/verification` 绑定版本、完整 source commit、架构、两个 image tag/image ID、文件长度/SHA-256 与已通过 gate; - 与实际只读镜像 inventory 对账过的 CycloneDX SBOM; -- 本说明。 +- 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`。 Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manifest。 @@ -53,17 +53,15 @@ Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manife 在同架构 Linux Docker 主机上进入解压后的 artifact 目录: ```sh -archive="$(find . -maxdepth 1 -name '*.docker.tar' -type f -print -quit)" -expected="$(node -p "require('./manifest.json').archiveSha256")" -actual="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)" -test "${actual}" = "${expected}" +sha256sum --check SHA256SUMS +archive="$(node -p "require('./manifest.json').archive.file")" docker load --input "${archive}" -image="$(node -p "require('./manifest.json').image")" -expected_id="$(node -p "require('./manifest.json').imageId")" +image="$(node -p "require('./manifest.json').images.application.reference")" +expected_id="$(node -p "require('./manifest.json').images.application.id")" test "$(docker image inspect --format '{{.Id}}' "${image}")" = "${expected_id}" -operator_image="$(node -p "require('./manifest.json').operator.image")" -operator_expected_id="$(node -p "require('./manifest.json').operator.imageId")" +operator_image="$(node -p "require('./manifest.json').images.operator.reference")" +operator_expected_id="$(node -p "require('./manifest.json').images.operator.id")" test "$(docker image inspect --format '{{.Id}}' "${operator_image}")" = "${operator_expected_id}" docker run --rm --read-only --network none --cap-drop ALL \ --security-opt no-new-privileges "${image}" --help diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md new file mode 100644 index 00000000..f9dc3c95 --- /dev/null +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -0,0 +1,69 @@ +# QingLong 3.0 Local Alpha Trial Kit + +本目录是绑定一个 QingLong 3.0 源码提交和一个 Linux 架构的阶段试运行套件,不是公开 release 或生产升级承诺。它同时包含常驻 Application 镜像和短生命周期 operator 镜像;两者共享的 OCI layer 只在同一个 Docker archive 中保存一次。 + +## 适用范围 + +- `amd64` 或 `arm64` Linux Docker 主机; +- 低配路由/NAS 的 Edge profile,或资源较充足单机的 Standalone profile; +- fresh、隔离的测试数据目录; +- 离线导入、设备兼容验证和 3.0 Alpha 用户旅程验证。 + +不要把它直接用于生产数据、2.x 唯一数据目录或生产 Secret。Cluster/Kubernetes 节点应使用 Cluster Integration Candidate;本套件不包含 PostgreSQL HA、Worker 或 Cluster Admin。 + +## 离线验收 + +先在解压目录中执行不依赖 Node.js 的文件校验: + +```sh +sha256sum --check SHA256SUMS +``` + +`manifest.json` 必须满足: + +- `schema` 为 `qinglong/alpha-local-trial-kit@v1`; +- `sourceRevision` 是你准备试用的完整 40 位 commit; +- `architecture` 与主机相同; +- `maturity` 为 `alpha_candidate_not_public_release`。 + +如果同时持有 QingLong 源码和 Node.js 24,可执行严格的闭合文件集、manifest、SBOM 和 checksum 审计: + +```sh +node scripts/ql3-local-alpha-trial-kit-bundle.cjs \ + --mode=audit \ + --bundle=/absolute/path/to/this-directory +``` + +任一校验失败都不要加载或运行 archive。 + +## 加载与最小 smoke + +从 `manifest.json.archive.file` 找到 archive 后加载: + +```sh +docker load --input qinglong3-local-trial-kit-.docker.tar +``` + +以 manifest 中 `images.application.reference` 和 `images.operator.reference` 为准,分别核对 `docker image inspect` 返回的 image ID。然后执行无网络、只读 smoke: + +```sh +docker run --rm --read-only --network none --cap-drop ALL \ + --security-opt no-new-privileges \ + --help + +docker run --rm --read-only --network none --cap-drop ALL \ + --security-opt no-new-privileges \ + --version + +docker run --rm --read-only --network none --cap-drop ALL \ + --security-opt no-new-privileges \ + setup --help +``` + +## Fresh 试运行边界 + +完整 fresh setup、首 Owner ceremony、Application active、SIGTERM drain 和 SQLite integrity 已在同一架构的原生 Linux CI 中验证。实际部署时仍必须使用独立目录,并让 operator 以最终数据文件 POSIX owner 的 UID/GID 运行;operator 默认无网络且每次只执行一个命令后退出,不应作为 sidecar 或 daemon 常驻。 + +Edge 的验证上限为 Application 128 MiB、0.5 CPU、64 PID;Standalone 为 256 MiB、0.5 CPU、256 PID;operator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。 + +停止并删除 Alpha 容器即可回退 fresh 测试环境。若触碰 2.x 数据或进行迁移,必须使用项目既有 reconciliation/cutover/rollback 流程,不能只替换镜像。 diff --git a/package.json b/package.json index 1a87c9fb..a000141a 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "audit:run-management-kubernetes-live:ql3": "node scripts/ql3-run-management-kubernetes-live-audit.cjs", "test:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-contract.cjs", "test:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-live-contract.cjs", + "bundle:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-bundle.cjs", "audit:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-audit.cjs", "test:provider-credential-test-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-provider-credential-test-kubernetes-live-contract.cjs", "audit:provider-credential-test-kubernetes-live:ql3": "node scripts/ql3-provider-credential-test-kubernetes-live-audit.cjs", diff --git a/scripts/ql3-local-alpha-trial-kit-bundle.cjs b/scripts/ql3-local-alpha-trial-kit-bundle.cjs new file mode 100644 index 00000000..353008c7 --- /dev/null +++ b/scripts/ql3-local-alpha-trial-kit-bundle.cjs @@ -0,0 +1,642 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs'); +const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); + +const DEFAULT_ROOT = path.resolve(__dirname, '..'); +const SCHEMA = 'qinglong/alpha-local-trial-kit@v1'; +const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); +const ARCHIVE_MIN_BYTES = 1024; +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const MAX_README_BYTES = 512 * 1024; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const REVISION_PATTERN = /^[0-9a-f]{40}$/u; +const FILES = Object.freeze({ + applicationSbom: 'qinglong3-local-application.cdx.json', + operatorSbom: 'qinglong3-local-operator.cdx.json', + readme: 'README.md', + manifest: 'manifest.json', + checksums: 'SHA256SUMS', +}); +const VERIFICATION = Object.freeze({ + osVulnerabilityPolicy: 'passed', + sbomInventoryReconciliation: 'passed', + router128MiBEntrypoint: 'passed', + operator128MiBEntrypoint: 'passed', + operatorPackageInventory: 'passed', + freshOwnerJourney: 'passed', + edgeFreshLifecycle: 'passed', + standaloneFreshLifecycle: 'passed', + localApiCancellation: 'passed', +}); + +function fail(message) { + throw new Error(message); +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value)) === JSON.stringify(expected) + ); +} + +function assertCanonicalFile(filePath, maximumBytes, label) { + const resolved = path.resolve(filePath); + const stat = fs.lstatSync(resolved); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > maximumBytes || + fs.realpathSync(resolved) !== resolved + ) { + fail(`${label} must be one bounded canonical regular file`); + } + return resolved; +} + +function readBoundedJson(filePath, label) { + const resolved = assertCanonicalFile(filePath, MAX_JSON_BYTES, label); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(resolved, 'utf8')); + } catch { + fail(`${label} must contain valid JSON`); + } + return parsed; +} + +function sha256File(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + let bytesRead; + do { + bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead)); + } while (bytesRead > 0); + } finally { + fs.closeSync(descriptor); + } + return `sha256:${hash.digest('hex')}`; +} + +function writeExclusive(filePath, contents, mode = 0o600) { + const descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + mode, + ); + try { + fs.writeFileSync(descriptor, contents); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function copyExclusive(source, destination) { + fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL); + fs.chmodSync(destination, 0o600); +} + +function inspectDockerImage(image) { + const output = childProcess.execFileSync( + 'docker', + ['image', 'inspect', image], + { + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const parsed = JSON.parse(output); + if (!Array.isArray(parsed) || parsed.length !== 1) { + fail(`docker returned an invalid inspection for ${image}`); + } + return parsed[0]; +} + +function saveDockerImages(images, archivePath) { + childProcess.execFileSync( + 'docker', + ['image', 'save', '--output', archivePath, ...images], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); +} + +function validateImageReference(value, label) { + if ( + typeof value !== 'string' || + value.length < 3 || + value.length > 256 || + /[\s\0]/u.test(value) + ) { + fail(`${label} image reference is invalid`); + } + return value; +} + +function normalizeImageInspection(inspection, options) { + const { architecture, reference, revision, role, version } = options; + const labels = inspection?.Config?.Labels; + const expectedTitle = + role === 'application' + ? 'QingLong 3.0 Local Application' + : 'QingLong 3.0 Local Operator'; + if ( + !SHA256_PATTERN.test(inspection?.Id || '') || + inspection?.Os !== 'linux' || + inspection?.Architecture !== architecture || + inspection?.Config?.User !== '65532:65532' || + labels?.['org.opencontainers.image.title'] !== expectedTitle || + labels?.['org.opencontainers.image.source'] !== + 'https://github.com/whyour/qinglong' || + labels?.['org.opencontainers.image.revision'] !== revision || + labels?.['org.opencontainers.image.version'] !== version + ) { + fail(`${role} image identity is incompatible`); + } + if ( + role === 'application' && + (labels?.['io.qinglong.profile'] !== 'edge,standalone' || + labels?.['io.qinglong.ai'] !== 'excluded') + ) { + fail('application image profile is incompatible'); + } + if ( + role === 'operator' && + (labels?.['io.qinglong.lifecycle'] !== 'short-lived' || + labels?.['io.qinglong.authority'] !== 'local-owner-management' || + labels?.['io.qinglong.network'] !== 'none-by-default') + ) { + fail('operator image authority is incompatible'); + } + return { + reference, + id: inspection.Id, + os: 'linux', + architecture, + user: '65532:65532', + }; +} + +function validateSbom(document, options) { + auditClusterImageSbom(document, { + root: options.root, + image: options.profile, + }); + const properties = Object.fromEntries( + (document.metadata?.properties || []).map((entry) => [ + entry.name, + entry.value, + ]), + ); + if ( + document.metadata?.component?.version !== options.version || + properties['qinglong:image-profile'] !== options.profile + ) { + fail(`${options.profile} SBOM identity is incompatible`); + } +} + +function validateOfflineSbom(document, profile, version) { + const properties = Object.fromEntries( + (document?.metadata?.properties || []).map((entry) => [ + entry?.name, + entry?.value, + ]), + ); + if ( + document?.bomFormat !== 'CycloneDX' || + document?.specVersion !== '1.5' || + document?.version !== 1 || + !Array.isArray(document.components) || + !Array.isArray(document.dependencies) || + document.metadata?.component?.version !== version || + properties['qinglong:image-profile'] !== profile + ) { + fail(`${profile} offline SBOM identity is incompatible`); + } +} + +function archiveName(architecture) { + return `qinglong3-local-trial-kit-${architecture}.docker.tar`; +} + +function fileRecord(bundleRoot, name) { + const filePath = path.join(bundleRoot, name); + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2) { + fail(`bundle file is invalid: ${name}`); + } + return { + file: name, + sha256: sha256File(filePath), + bytes: stat.size, + }; +} + +function checksumContents(bundleRoot, names) { + return `${names + .map( + (name) => `${sha256File(path.join(bundleRoot, name)).slice(7)} ${name}`, + ) + .join('\n')}\n`; +} + +function validateCreateOptions(options) { + const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT)); + const outputRoot = path.resolve(options.outputRoot || ''); + const parent = path.dirname(outputRoot); + if ( + !ARCHITECTURES.includes(options.architecture) || + !REVISION_PATTERN.test(options.sourceRevision || '') || + !path.isAbsolute(outputRoot) || + fs.existsSync(outputRoot) || + fs.realpathSync(parent) !== parent + ) { + fail('create identity or output is invalid'); + } + return { + root, + outputRoot, + architecture: options.architecture, + sourceRevision: options.sourceRevision, + applicationImage: validateImageReference( + options.applicationImage, + 'application', + ), + operatorImage: validateImageReference(options.operatorImage, 'operator'), + applicationSbom: assertCanonicalFile( + options.applicationSbom, + MAX_JSON_BYTES, + 'application SBOM', + ), + operatorSbom: assertCanonicalFile( + options.operatorSbom, + MAX_JSON_BYTES, + 'operator SBOM', + ), + readme: assertCanonicalFile( + options.readme, + MAX_README_BYTES, + 'trial kit README', + ), + }; +} + +function createLocalAlphaTrialKit(options, adapters = {}) { + const normalized = validateCreateOptions(options); + const release = readReleaseIdentity(normalized.root); + const inspectImage = adapters.inspectImage || inspectDockerImage; + const saveImages = adapters.saveImages || saveDockerImages; + const applicationSbom = readBoundedJson( + normalized.applicationSbom, + 'application SBOM', + ); + const operatorSbom = readBoundedJson( + normalized.operatorSbom, + 'operator SBOM', + ); + validateSbom(applicationSbom, { + root: normalized.root, + profile: 'local', + version: release.version, + }); + validateSbom(operatorSbom, { + root: normalized.root, + profile: 'local-operator', + version: release.version, + }); + const application = normalizeImageInspection( + inspectImage(normalized.applicationImage), + { + architecture: normalized.architecture, + reference: normalized.applicationImage, + revision: normalized.sourceRevision, + role: 'application', + version: release.version, + }, + ); + const operator = normalizeImageInspection( + inspectImage(normalized.operatorImage), + { + architecture: normalized.architecture, + reference: normalized.operatorImage, + revision: normalized.sourceRevision, + role: 'operator', + version: release.version, + }, + ); + if (application.id === operator.id) fail('trial kit images must be distinct'); + + let created = false; + try { + fs.mkdirSync(normalized.outputRoot, { mode: 0o700 }); + created = true; + const archive = archiveName(normalized.architecture); + const archivePath = path.join(normalized.outputRoot, archive); + saveImages( + [normalized.applicationImage, normalized.operatorImage], + archivePath, + ); + const archiveStat = fs.lstatSync(archivePath); + if ( + !archiveStat.isFile() || + archiveStat.isSymbolicLink() || + archiveStat.size < ARCHIVE_MIN_BYTES + ) { + fail('Docker archive is invalid or unexpectedly small'); + } + fs.chmodSync(archivePath, 0o600); + copyExclusive( + normalized.applicationSbom, + path.join(normalized.outputRoot, FILES.applicationSbom), + ); + copyExclusive( + normalized.operatorSbom, + path.join(normalized.outputRoot, FILES.operatorSbom), + ); + copyExclusive( + normalized.readme, + path.join(normalized.outputRoot, FILES.readme), + ); + const manifest = { + schemaVersion: 2, + schema: SCHEMA, + maturity: 'alpha_candidate_not_public_release', + product: 'local', + version: release.version, + sourceRevision: normalized.sourceRevision, + architecture: normalized.architecture, + archive: fileRecord(normalized.outputRoot, archive), + images: { application, operator }, + sboms: { + application: fileRecord(normalized.outputRoot, FILES.applicationSbom), + operator: fileRecord(normalized.outputRoot, FILES.operatorSbom), + }, + readme: fileRecord(normalized.outputRoot, FILES.readme), + verification: { ...VERIFICATION }, + }; + writeExclusive( + path.join(normalized.outputRoot, FILES.manifest), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + const checkedFiles = [ + archive, + FILES.applicationSbom, + FILES.operatorSbom, + FILES.readme, + FILES.manifest, + ]; + writeExclusive( + path.join(normalized.outputRoot, FILES.checksums), + checksumContents(normalized.outputRoot, checkedFiles), + ); + auditLocalAlphaTrialKit({ bundleRoot: normalized.outputRoot }); + return manifest; + } catch (error) { + if (created) { + fs.rmSync(normalized.outputRoot, { recursive: true, force: true }); + } + throw error; + } +} + +function validateFileRecord(record, expectedName, bundleRoot) { + if ( + !exactKeys(record, ['file', 'sha256', 'bytes']) || + record.file !== expectedName || + !SHA256_PATTERN.test(record.sha256 || '') || + !Number.isSafeInteger(record.bytes) || + record.bytes < 2 + ) { + fail(`manifest file record is invalid: ${expectedName}`); + } + const actual = fileRecord(bundleRoot, expectedName); + if (actual.sha256 !== record.sha256 || actual.bytes !== record.bytes) { + fail(`bundle file differs from manifest: ${expectedName}`); + } +} + +function validateOfflineImage(image, role, manifest) { + if ( + !exactKeys(image, ['reference', 'id', 'os', 'architecture', 'user']) || + validateImageReference(image.reference, role) !== image.reference || + !SHA256_PATTERN.test(image.id || '') || + image.os !== 'linux' || + image.architecture !== manifest.architecture || + image.user !== '65532:65532' + ) { + fail(`${role} manifest image identity is incompatible`); + } +} + +function auditLocalAlphaTrialKit(options) { + const bundleRoot = fs.realpathSync(path.resolve(options.bundleRoot || '')); + if (!fs.lstatSync(bundleRoot).isDirectory()) { + fail('bundle root must be a canonical directory'); + } + const manifest = readBoundedJson( + path.join(bundleRoot, FILES.manifest), + 'trial kit manifest', + ); + if ( + !exactKeys(manifest, [ + 'schemaVersion', + 'schema', + 'maturity', + 'product', + 'version', + 'sourceRevision', + 'architecture', + 'archive', + 'images', + 'sboms', + 'readme', + 'verification', + ]) || + manifest.schemaVersion !== 2 || + manifest.schema !== SCHEMA || + manifest.maturity !== 'alpha_candidate_not_public_release' || + manifest.product !== 'local' || + typeof manifest.version !== 'string' || + !REVISION_PATTERN.test(manifest.sourceRevision || '') || + !ARCHITECTURES.includes(manifest.architecture) || + !exactKeys(manifest.images, ['application', 'operator']) || + !exactKeys(manifest.sboms, ['application', 'operator']) || + !exactKeys(manifest.verification, Object.keys(VERIFICATION)) || + JSON.stringify(manifest.verification) !== JSON.stringify(VERIFICATION) + ) { + fail('trial kit manifest identity or shape is incompatible'); + } + validateOfflineImage(manifest.images.application, 'application', manifest); + validateOfflineImage(manifest.images.operator, 'operator', manifest); + if (manifest.images.application.id === manifest.images.operator.id) { + fail('trial kit images must be distinct'); + } + const expectedArchive = archiveName(manifest.architecture); + validateFileRecord(manifest.archive, expectedArchive, bundleRoot); + if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) { + fail('Docker archive is unexpectedly small'); + } + validateFileRecord( + manifest.sboms.application, + FILES.applicationSbom, + bundleRoot, + ); + validateFileRecord(manifest.sboms.operator, FILES.operatorSbom, bundleRoot); + validateFileRecord(manifest.readme, FILES.readme, bundleRoot); + validateOfflineSbom( + readBoundedJson( + path.join(bundleRoot, FILES.applicationSbom), + 'application SBOM', + ), + 'local', + manifest.version, + ); + validateOfflineSbom( + readBoundedJson(path.join(bundleRoot, FILES.operatorSbom), 'operator SBOM'), + 'local-operator', + manifest.version, + ); + const expectedFiles = [ + FILES.checksums, + FILES.manifest, + FILES.readme, + FILES.applicationSbom, + FILES.operatorSbom, + expectedArchive, + ].sort(); + const actualFiles = fs + .readdirSync(bundleRoot, { withFileTypes: true }) + .map((entry) => { + if (!entry.isFile() || entry.isSymbolicLink()) { + fail(`bundle contains a non-regular entry: ${entry.name}`); + } + return entry.name; + }) + .sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + fail('bundle file set is not closed'); + } + const checkedFiles = [ + expectedArchive, + FILES.applicationSbom, + FILES.operatorSbom, + FILES.readme, + FILES.manifest, + ]; + const expectedChecksums = checksumContents(bundleRoot, checkedFiles); + const actualChecksums = fs.readFileSync( + path.join(bundleRoot, FILES.checksums), + 'utf8', + ); + if (actualChecksums !== expectedChecksums) { + fail('SHA256SUMS differs from the closed bundle file set'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-local-trial-kit-audit@v1', + sourceRevision: manifest.sourceRevision, + version: manifest.version, + architecture: manifest.architecture, + archiveSha256: manifest.archive.sha256, + applicationImageId: manifest.images.application.id, + operatorImageId: manifest.images.operator.id, + compatible: true, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z0-9-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) + fail('arguments are invalid'); + values[match[1]] = match[2]; + } + if (values.mode === 'audit') { + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['bundle', 'mode']) + ) { + fail('audit arguments are invalid'); + } + return { mode: 'audit', bundleRoot: path.resolve(values.bundle) }; + } + if (values.mode === 'create') { + const expected = [ + 'application-image', + 'application-sbom', + 'architecture', + 'mode', + 'operator-image', + 'operator-sbom', + 'output', + 'readme', + 'source-revision', + ]; + if ( + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('create arguments are invalid'); + } + return { + mode: 'create', + outputRoot: path.resolve(values.output), + architecture: values.architecture, + sourceRevision: values['source-revision'], + applicationImage: values['application-image'], + operatorImage: values['operator-image'], + applicationSbom: path.resolve(values['application-sbom']), + operatorSbom: path.resolve(values['operator-sbom']), + readme: path.resolve(values.readme), + }; + } + fail('mode is invalid'); +} + +function runCli(argv) { + const options = parseArguments(argv); + const report = + options.mode === 'create' + ? createLocalAlphaTrialKit(options) + : auditLocalAlphaTrialKit(options); + process.stdout.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 : 'trial kit bundle failed'}\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + FILES, + SCHEMA, + VERIFICATION, + archiveName, + auditLocalAlphaTrialKit, + createLocalAlphaTrialKit, + parseArguments, + runCli, + sha256File, +}); diff --git a/scripts/ql3-local-operator-image-audit.cjs b/scripts/ql3-local-operator-image-audit.cjs index 89d2ea20..f8511b15 100644 --- a/scripts/ql3-local-operator-image-audit.cjs +++ b/scripts/ql3-local-operator-image-audit.cjs @@ -189,10 +189,12 @@ function auditWorkflow(contents, findings) { 'image-ref: qinglong3-local-operator:ci-${{ matrix.image_arch }}', '"${OPERATOR_IMAGE}" --version', 'scripts/ql3-local-alpha-trial-kit-live-contract.cjs', - 'qinglong3-local-trial-kit-${IMAGE_ARCH}.docker.tar', - "schema: 'qinglong/alpha-local-trial-kit@v1'", - 'operatorImageId', - "freshOwnerJourney: 'passed'", + 'scripts/ql3-local-alpha-trial-kit-bundle.cjs', + '--mode=create', + '--mode=audit', + '--application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json"', + '--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json"', + '--readme=docs/operations/ql3-local-alpha-trial-kit.md', ]; for (const value of required) { if (!contents.includes(value)) diff --git a/test/back/ql3LocalAlphaTrialKitBundle.test.cjs b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs new file mode 100644 index 00000000..e3d943f9 --- /dev/null +++ b/test/back/ql3LocalAlphaTrialKitBundle.test.cjs @@ -0,0 +1,209 @@ +'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 { + auditLocalAlphaTrialKit, + createLocalAlphaTrialKit, + parseArguments, +} = require('../../scripts/ql3-local-alpha-trial-kit-bundle.cjs'); +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 = 'a'.repeat(40); + +function imageInspection( + role, + idCharacter = role === 'application' ? '1' : '2', +) { + return { + Id: `sha256:${idCharacter.repeat(64)}`, + Os: 'linux', + Architecture: 'arm64', + Config: { + User: '65532:65532', + Labels: { + 'org.opencontainers.image.title': + role === 'application' + ? 'QingLong 3.0 Local Application' + : 'QingLong 3.0 Local Operator', + 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', + 'org.opencontainers.image.revision': revision, + 'org.opencontainers.image.version': version, + ...(role === 'application' + ? { + 'io.qinglong.profile': 'edge,standalone', + 'io.qinglong.ai': 'excluded', + } + : { + 'io.qinglong.lifecycle': 'short-lived', + 'io.qinglong.authority': 'local-owner-management', + 'io.qinglong.network': 'none-by-default', + }), + }, + }, + }; +} + +function fixture(t) { + const fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-alpha-bundle-')), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const applicationSbom = path.join(fixtureRoot, 'application.json'); + const operatorSbom = path.join(fixtureRoot, 'operator.json'); + const readme = path.join(fixtureRoot, 'README-source.md'); + fs.writeFileSync( + applicationSbom, + `${JSON.stringify(createClusterImageSbom({ root, image: 'local' }))}\n`, + ); + fs.writeFileSync( + operatorSbom, + `${JSON.stringify( + createClusterImageSbom({ root, image: 'local-operator' }), + )}\n`, + ); + fs.writeFileSync(readme, '# Local Alpha Trial Kit\n'); + return { + fixtureRoot, + applicationSbom, + operatorSbom, + readme, + outputRoot: path.join(fixtureRoot, 'bundle'), + }; +} + +function createOptions(paths) { + return { + root, + outputRoot: paths.outputRoot, + architecture: 'arm64', + sourceRevision: revision, + applicationImage: 'qinglong3-local-application:test-arm64', + operatorImage: 'qinglong3-local-operator:test-arm64', + applicationSbom: paths.applicationSbom, + operatorSbom: paths.operatorSbom, + readme: paths.readme, + }; +} + +function adapters(overrides = {}) { + return { + inspectImage(image) { + return image.includes('operator') + ? imageInspection('operator') + : imageInspection('application'); + }, + saveImages(images, archivePath) { + assert.deepEqual(images, [ + 'qinglong3-local-application:test-arm64', + 'qinglong3-local-operator:test-arm64', + ]); + fs.writeFileSync(archivePath, Buffer.alloc(2048, 7), { flag: 'wx' }); + }, + ...overrides, + }; +} + +test('materializes and offline-audits one closed two-image trial kit', (t) => { + const paths = fixture(t); + const manifest = createLocalAlphaTrialKit(createOptions(paths), adapters()); + assert.equal(manifest.schema, 'qinglong/alpha-local-trial-kit@v1'); + assert.equal(manifest.sourceRevision, revision); + assert.equal(manifest.architecture, 'arm64'); + assert.equal(manifest.images.application.architecture, 'arm64'); + assert.equal(manifest.images.operator.architecture, 'arm64'); + assert.notEqual(manifest.images.application.id, manifest.images.operator.id); + const report = auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }); + assert.equal(report.compatible, true); + assert.equal(report.sourceRevision, revision); + assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ + 'README.md', + 'SHA256SUMS', + 'manifest.json', + 'qinglong3-local-application.cdx.json', + 'qinglong3-local-operator.cdx.json', + 'qinglong3-local-trial-kit-arm64.docker.tar', + ]); +}); + +test('fails closed and removes a partial output on incompatible image identity', (t) => { + const paths = fixture(t); + const options = createOptions(paths); + assert.throws( + () => + createLocalAlphaTrialKit( + options, + adapters({ + inspectImage(image) { + const inspection = image.includes('operator') + ? imageInspection('operator') + : imageInspection('application'); + inspection.Config.Labels['org.opencontainers.image.revision'] = + 'b'.repeat(40); + return inspection; + }, + }), + ), + /image identity is incompatible/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + +test('offline audit rejects archive mutation, extra files and SBOM substitution', (t) => { + for (const mutation of ['archive', 'extra', 'sbom']) { + const paths = fixture(t); + paths.outputRoot = path.join(paths.fixtureRoot, `bundle-${mutation}`); + createLocalAlphaTrialKit(createOptions(paths), adapters()); + if (mutation === 'archive') { + fs.appendFileSync( + path.join( + paths.outputRoot, + 'qinglong3-local-trial-kit-arm64.docker.tar', + ), + 'tamper', + ); + } else if (mutation === 'extra') { + fs.writeFileSync(path.join(paths.outputRoot, 'credential.txt'), 'secret'); + } else { + fs.copyFileSync( + path.join(paths.outputRoot, 'qinglong3-local-application.cdx.json'), + path.join(paths.outputRoot, 'qinglong3-local-operator.cdx.json'), + ); + } + assert.throws( + () => auditLocalAlphaTrialKit({ bundleRoot: paths.outputRoot }), + /differs|not closed|incompatible/, + mutation, + ); + } +}); + +test('CLI grammar is exact and separates create from offline audit', () => { + assert.deepEqual( + parseArguments(['--mode=audit', '--bundle=/tmp/ql3-bundle']), + { mode: 'audit', bundleRoot: '/tmp/ql3-bundle' }, + ); + assert.throws( + () => + parseArguments([ + '--mode=audit', + '--bundle=/tmp/ql3-bundle', + '--allow-extra=true', + ]), + /audit arguments are invalid/, + ); + assert.throws( + () => parseArguments(['--mode=create', '--output=/tmp/output']), + /create arguments are invalid/, + ); +}); diff --git a/test/back/ql3LocalOperatorImageAudit.test.cjs b/test/back/ql3LocalOperatorImageAudit.test.cjs index 97ae152c..1e238daf 100644 --- a/test/back/ql3LocalOperatorImageAudit.test.cjs +++ b/test/back/ql3LocalOperatorImageAudit.test.cjs @@ -102,7 +102,7 @@ test('rejects removal of the fresh Owner journey or two-image manifest', () => { 'removed-live-contract.cjs', ) .replace( - "schema: 'qinglong/alpha-local-trial-kit@v1'", + 'scripts/ql3-local-alpha-trial-kit-bundle.cjs', "schema: 'single-image'", ); fs.writeFileSync(