feat(ql3): materialize auditable local trial kits

This commit is contained in:
whyour
2026-08-26 09:58:29 +08:00
parent 2253b99066
commit 2620be0587
11 changed files with 1026 additions and 81 deletions
+15 -62
View File
@@ -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:
@@ -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 integrity40 个 required job 全部成功且无重试。实际双架构 trial-kit archive 仍需维护者明确授权手动生成;转为 Accepted 前还必须记录两个可下载 artifact 的 digest 与离线复核结果。Public Release Set 是否把 operator 纳入正式签名/catalog,留给后续独立 release-set schema 决策。
@@ -0,0 +1,70 @@
# ADR-0504Local 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 生成。
+2 -1
View File
@@ -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 |
## 规则
+9 -11
View File
@@ -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 integrityCI 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-<arch>.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
@@ -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-<arch>.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 \
<application-image> --help
docker run --rm --read-only --network none --cap-drop ALL \
--security-opt no-new-privileges \
<operator-image> --version
docker run --rm --read-only --network none --cap-drop ALL \
--security-opt no-new-privileges \
<operator-image> 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 PIDStandalone 为 256 MiB、0.5 CPU、256 PIDoperator 为 128 MiB、0.5 CPU、32 PID。这里的数值是试运行门,不是所有 workload 的容量承诺。
停止并删除 Alpha 容器即可回退 fresh 测试环境。若触碰 2.x 数据或进行迁移,必须使用项目既有 reconciliation/cutover/rollback 流程,不能只替换镜像。
+1
View File
@@ -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",
@@ -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,
});
+6 -4
View File
@@ -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))
@@ -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/,
);
});
@@ -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(