mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): materialize auditable local trial kits
This commit is contained in:
@@ -583,74 +583,27 @@ jobs:
|
|||||||
--report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json"
|
--report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json"
|
||||||
node scripts/ql3-local-api-cancellation-live-audit.cjs \
|
node scripts/ql3-local-api-cancellation-live-audit.cjs \
|
||||||
--report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json"
|
--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
|
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts
|
||||||
env:
|
env:
|
||||||
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
|
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
|
||||||
IMAGE_ARCH: ${{ matrix.image_arch }}
|
OPERATOR_IMAGE: qinglong3-local-operator:ci-${{ matrix.image_arch }}
|
||||||
BUNDLE_ROOT: ${{ runner.temp }}/ql3-alpha-local-${{ matrix.image_arch }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
mkdir "${BUNDLE_ROOT}"
|
node scripts/ql3-local-alpha-trial-kit-bundle.cjs \
|
||||||
archive="${BUNDLE_ROOT}/qinglong3-local-trial-kit-${IMAGE_ARCH}.docker.tar"
|
--mode=create \
|
||||||
docker image save --output "${archive}" \
|
--application-image="${IMAGE}" \
|
||||||
"${IMAGE}" \
|
--operator-image="${OPERATOR_IMAGE}" \
|
||||||
"qinglong3-local-operator:ci-${IMAGE_ARCH}"
|
--architecture=${{ matrix.image_arch }} \
|
||||||
cp "${RUNNER_TEMP}/ql3-local-application.cdx.json" \
|
--source-revision=${{ github.sha }} \
|
||||||
"${BUNDLE_ROOT}/qinglong3-local-application.cdx.json"
|
--application-sbom="${RUNNER_TEMP}/ql3-local-application.cdx.json" \
|
||||||
cp "${RUNNER_TEMP}/ql3-local-operator.cdx.json" \
|
--operator-sbom="${RUNNER_TEMP}/ql3-local-operator.cdx.json" \
|
||||||
"${BUNDLE_ROOT}/qinglong3-local-operator.cdx.json"
|
--readme=docs/operations/ql3-local-alpha-trial-kit.md \
|
||||||
cp docs/operations/ql3-alpha-candidate.md "${BUNDLE_ROOT}/README.md"
|
--output="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}"
|
||||||
archive_sha256="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)"
|
node scripts/ql3-local-alpha-trial-kit-bundle.cjs \
|
||||||
ARCHIVE="${archive}" ARCHIVE_SHA256="${archive_sha256}" \
|
--mode=audit --bundle="${RUNNER_TEMP}/ql3-alpha-local-${{ matrix.image_arch }}"
|
||||||
MANIFEST="${BUNDLE_ROOT}/manifest.json" \
|
- name: Upload the tested native Local Alpha trial kit
|
||||||
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
|
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts
|
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0503:可下载的 Local Alpha 试运行套件
|
# ADR-0503:可下载的 Local Alpha 试运行套件
|
||||||
|
|
||||||
- 状态:Proposed(实现完成,原生 Linux x64/arm64 CI 与实际归档待验收)
|
- 状态:Proposed(原生 Linux x64/arm64 已通过,实际可下载归档待维护者授权)
|
||||||
- 日期:2026-08-26
|
- 日期:2026-08-26
|
||||||
- 决策:D-408
|
- 决策:D-408
|
||||||
- 关联:ADR-0193、ADR-0195、ADR-0196、ADR-0425
|
- 关联: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 下通过;
|
- `ql3 --version` 与 `ql3 setup --help` 在 read-only、network none、128 MiB、0.5 CPU、32 PID 下通过;
|
||||||
- Docker Desktop 完整旅程因 mount root UID 非等价失败,临时 credential/pepper 目录已删除;未把该结果记为通过。
|
- 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 决策。
|
||||||
|
|||||||
@@ -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 生成。
|
||||||
+2
-1
@@ -506,7 +506,8 @@
|
|||||||
| [ADR-0500](./ADR-0500-short-lived-cluster-security-administration-command.md) | 短生命周期 Cluster Security Administration 产品命令 | Accepted |
|
| [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-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-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 |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
该实物保存在工作区忽略目录,不进入 Git,也尚未上传 GitHub。公开下载仍需维护者明确授权上传。它只含 headless Application,没有可下载的 `ql3 setup/owner/task/...` 管理制品;因此它足以证明 runtime 工程可用性,但不能独立完成部署用户旅程。此前“单架构内部试运行材料”的表述按 D-408 收紧为“运行时工程候选”。
|
该实物保存在工作区忽略目录,不进入 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 含:
|
Local artifact 含:
|
||||||
|
|
||||||
- 一个包含 Application 与短生命周期 operator 的 `qinglong3-local-trial-kit-<arch>.docker.tar`;共享 Node 基础层在 archive 中去重;
|
- 一个包含 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;
|
- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM;
|
||||||
- 本说明。
|
- 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`。
|
||||||
|
|
||||||
Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manifest。
|
Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manifest。
|
||||||
|
|
||||||
@@ -53,17 +53,15 @@ Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manife
|
|||||||
在同架构 Linux Docker 主机上进入解压后的 artifact 目录:
|
在同架构 Linux Docker 主机上进入解压后的 artifact 目录:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
archive="$(find . -maxdepth 1 -name '*.docker.tar' -type f -print -quit)"
|
sha256sum --check SHA256SUMS
|
||||||
expected="$(node -p "require('./manifest.json').archiveSha256")"
|
|
||||||
actual="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)"
|
|
||||||
test "${actual}" = "${expected}"
|
|
||||||
|
|
||||||
|
archive="$(node -p "require('./manifest.json').archive.file")"
|
||||||
docker load --input "${archive}"
|
docker load --input "${archive}"
|
||||||
image="$(node -p "require('./manifest.json').image")"
|
image="$(node -p "require('./manifest.json').images.application.reference")"
|
||||||
expected_id="$(node -p "require('./manifest.json').imageId")"
|
expected_id="$(node -p "require('./manifest.json').images.application.id")"
|
||||||
test "$(docker image inspect --format '{{.Id}}' "${image}")" = "${expected_id}"
|
test "$(docker image inspect --format '{{.Id}}' "${image}")" = "${expected_id}"
|
||||||
operator_image="$(node -p "require('./manifest.json').operator.image")"
|
operator_image="$(node -p "require('./manifest.json').images.operator.reference")"
|
||||||
operator_expected_id="$(node -p "require('./manifest.json').operator.imageId")"
|
operator_expected_id="$(node -p "require('./manifest.json').images.operator.id")"
|
||||||
test "$(docker image inspect --format '{{.Id}}' "${operator_image}")" = "${operator_expected_id}"
|
test "$(docker image inspect --format '{{.Id}}' "${operator_image}")" = "${operator_expected_id}"
|
||||||
docker run --rm --read-only --network none --cap-drop ALL \
|
docker run --rm --read-only --network none --cap-drop ALL \
|
||||||
--security-opt no-new-privileges "${image}" --help
|
--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 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 流程,不能只替换镜像。
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"audit:run-management-kubernetes-live:ql3": "node scripts/ql3-run-management-kubernetes-live-audit.cjs",
|
"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-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",
|
"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",
|
"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",
|
"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",
|
"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,
|
||||||
|
});
|
||||||
@@ -189,10 +189,12 @@ function auditWorkflow(contents, findings) {
|
|||||||
'image-ref: qinglong3-local-operator:ci-${{ matrix.image_arch }}',
|
'image-ref: qinglong3-local-operator:ci-${{ matrix.image_arch }}',
|
||||||
'"${OPERATOR_IMAGE}" --version',
|
'"${OPERATOR_IMAGE}" --version',
|
||||||
'scripts/ql3-local-alpha-trial-kit-live-contract.cjs',
|
'scripts/ql3-local-alpha-trial-kit-live-contract.cjs',
|
||||||
'qinglong3-local-trial-kit-${IMAGE_ARCH}.docker.tar',
|
'scripts/ql3-local-alpha-trial-kit-bundle.cjs',
|
||||||
"schema: 'qinglong/alpha-local-trial-kit@v1'",
|
'--mode=create',
|
||||||
'operatorImageId',
|
'--mode=audit',
|
||||||
"freshOwnerJourney: 'passed'",
|
'--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) {
|
for (const value of required) {
|
||||||
if (!contents.includes(value))
|
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',
|
'removed-live-contract.cjs',
|
||||||
)
|
)
|
||||||
.replace(
|
.replace(
|
||||||
"schema: 'qinglong/alpha-local-trial-kit@v1'",
|
'scripts/ql3-local-alpha-trial-kit-bundle.cjs',
|
||||||
"schema: 'single-image'",
|
"schema: 'single-image'",
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
Reference in New Issue
Block a user