mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): close cluster alpha milestone
This commit is contained in:
+133
-41
@@ -838,51 +838,37 @@ jobs:
|
|||||||
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')
|
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')
|
||||||
env:
|
env:
|
||||||
IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }}
|
IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }}
|
||||||
IMAGE_ARCH: ${{ matrix.image_arch }}
|
|
||||||
IMAGE_ROLE: ${{ matrix.image }}
|
|
||||||
IMAGE_REPOSITORY: ${{ matrix.repository }}
|
|
||||||
BUNDLE_ROOT: ${{ runner.temp }}/ql3-alpha-${{ matrix.image }}-${{ matrix.image_arch }}
|
BUNDLE_ROOT: ${{ runner.temp }}/ql3-alpha-${{ matrix.image }}-${{ matrix.image_arch }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
umask 077
|
||||||
mkdir "${BUNDLE_ROOT}"
|
node scripts/ql3-cluster-alpha-bundle.cjs \
|
||||||
archive="${BUNDLE_ROOT}/${IMAGE_REPOSITORY}-${IMAGE_ARCH}.docker.tar"
|
--mode=record-verification \
|
||||||
docker image save --output "${archive}" "${IMAGE}"
|
--role=${{ matrix.image }} \
|
||||||
cp "${RUNNER_TEMP}/ql3-cluster-${IMAGE_ROLE}.cdx.json" \
|
--architecture=${{ matrix.image_arch }} \
|
||||||
"${BUNDLE_ROOT}/${IMAGE_REPOSITORY}.cdx.json"
|
--image="${IMAGE}" \
|
||||||
cp docs/operations/ql3-alpha-candidate.md "${BUNDLE_ROOT}/README.md"
|
--source-revision=${{ github.sha }} \
|
||||||
archive_sha256="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)"
|
--repository=${{ github.repository }} \
|
||||||
ARCHIVE="${archive}" ARCHIVE_SHA256="${archive_sha256}" \
|
--workflow-ref="${{ github.workflow_ref }}" \
|
||||||
MANIFEST="${BUNDLE_ROOT}/manifest.json" \
|
--workflow-sha=${{ github.workflow_sha }} \
|
||||||
node <<'NODE'
|
--event=${{ github.event_name }} \
|
||||||
const fs = require('node:fs');
|
--job=cluster-image \
|
||||||
const childProcess = require('node:child_process');
|
--run-id=${{ github.run_id }} \
|
||||||
const release = require('./ql3-release.json');
|
--run-attempt=${{ github.run_attempt }} \
|
||||||
const imageId = childProcess
|
--output="${RUNNER_TEMP}/ql3-cluster-${{ matrix.image }}-${{ matrix.image_arch }}-verification.json"
|
||||||
.execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', process.env.IMAGE], { encoding: 'utf8' })
|
node scripts/ql3-cluster-alpha-bundle.cjs \
|
||||||
.trim();
|
--mode=create \
|
||||||
const manifest = {
|
--role=${{ matrix.image }} \
|
||||||
schemaVersion: 1,
|
--architecture=${{ matrix.image_arch }} \
|
||||||
schema: 'qinglong/alpha-native-image@v1',
|
--image="${IMAGE}" \
|
||||||
maturity: 'alpha_candidate_not_public_release',
|
--source-revision=${{ github.sha }} \
|
||||||
product: 'cluster',
|
--sbom="${RUNNER_TEMP}/ql3-cluster-${{ matrix.image }}.cdx.json" \
|
||||||
role: process.env.IMAGE_ROLE,
|
--verification-evidence="${RUNNER_TEMP}/ql3-cluster-${{ matrix.image }}-${{ matrix.image_arch }}-verification.json" \
|
||||||
version: release.version,
|
--readme=docs/operations/ql3-alpha-candidate.md \
|
||||||
sourceRevision: process.env.GITHUB_SHA,
|
--output="${BUNDLE_ROOT}"
|
||||||
architecture: process.env.IMAGE_ARCH,
|
node scripts/ql3-cluster-alpha-bundle.cjs \
|
||||||
image: process.env.IMAGE,
|
--mode=offline-audit \
|
||||||
imageId,
|
--bundle="${BUNDLE_ROOT}"
|
||||||
archive: process.env.ARCHIVE.split('/').pop(),
|
|
||||||
archiveSha256: process.env.ARCHIVE_SHA256,
|
|
||||||
verification: {
|
|
||||||
osVulnerabilityPolicy: 'passed',
|
|
||||||
sbomInventoryReconciliation: 'passed',
|
|
||||||
nonRootRuntimeIdentity: 'passed',
|
|
||||||
clusterAdminProductFacade: process.env.IMAGE_ROLE === 'admin' ? 'passed' : 'not_applicable',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
fs.writeFileSync(process.env.MANIFEST, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' });
|
|
||||||
NODE
|
|
||||||
- name: Upload the tested native Cluster Alpha image
|
- name: Upload the tested native Cluster Alpha image
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')
|
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
@@ -1643,6 +1629,112 @@ jobs:
|
|||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
|
cluster-alpha-milestone:
|
||||||
|
name: Finalize the Cluster Alpha integration milestone
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')
|
||||||
|
needs:
|
||||||
|
- backend
|
||||||
|
- service-manager-bridge
|
||||||
|
- linux-resource-envelopes
|
||||||
|
- linux-resource-release-evidence
|
||||||
|
- supply-chain
|
||||||
|
- local-image
|
||||||
|
- cluster-image
|
||||||
|
- cluster-console-capacity-release-evidence
|
||||||
|
- image-oci
|
||||||
|
- worker-runtime
|
||||||
|
- local-profiles
|
||||||
|
- cluster-postgres
|
||||||
|
- cluster-postgres-ha
|
||||||
|
- cluster-cloudnativepg-live
|
||||||
|
- cluster-provider-credential-test-kubernetes-live
|
||||||
|
- cluster-secret-binding-mounted-provider-kubernetes-live
|
||||||
|
- cluster-vault-kv-worker-secret-live
|
||||||
|
- cluster-plugin-package-kubernetes-live
|
||||||
|
- cluster-plugin-package-recovery-e2e
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24.18.0'
|
||||||
|
- name: Download the exact control amd64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-control-amd64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-amd64
|
||||||
|
- name: Download the exact control arm64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-control-arm64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-arm64
|
||||||
|
- name: Download the exact control-ai amd64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-control-ai-amd64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-ai-amd64
|
||||||
|
- name: Download the exact control-ai arm64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-control-ai-arm64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-ai-arm64
|
||||||
|
- name: Download the exact admin amd64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-admin-amd64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/admin-amd64
|
||||||
|
- name: Download the exact admin arm64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-admin-arm64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/admin-arm64
|
||||||
|
- name: Download the exact worker amd64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-worker-amd64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/worker-amd64
|
||||||
|
- name: Download the exact worker arm64 Cluster Alpha bundle
|
||||||
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-worker-arm64
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/worker-arm64
|
||||||
|
- name: Close all native Cluster bundles into one milestone index
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
node scripts/ql3-cluster-alpha-milestone.cjs \
|
||||||
|
--mode=finalize \
|
||||||
|
--control-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-amd64" \
|
||||||
|
--control-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-arm64" \
|
||||||
|
--control-ai-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-ai-amd64" \
|
||||||
|
--control-ai-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-ai-arm64" \
|
||||||
|
--admin-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/admin-amd64" \
|
||||||
|
--admin-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/admin-arm64" \
|
||||||
|
--worker-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/worker-amd64" \
|
||||||
|
--worker-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/worker-arm64" \
|
||||||
|
--source-revision=${{ github.sha }} \
|
||||||
|
--repository=${{ github.repository }} \
|
||||||
|
--workflow-ref="${{ github.workflow_ref }}" \
|
||||||
|
--workflow-sha=${{ github.workflow_sha }} \
|
||||||
|
--event=${{ github.event_name }} \
|
||||||
|
--run-id=${{ github.run_id }} \
|
||||||
|
--run-attempt=${{ github.run_attempt }} \
|
||||||
|
--readme=docs/operations/ql3-cluster-alpha-milestone.md \
|
||||||
|
--output="${RUNNER_TEMP}/ql3-alpha-cluster-milestone"
|
||||||
|
node scripts/ql3-cluster-alpha-milestone.cjs \
|
||||||
|
--mode=audit \
|
||||||
|
--milestone="${RUNNER_TEMP}/ql3-alpha-cluster-milestone"
|
||||||
|
- name: Upload the closed Cluster Alpha milestone index
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
name: ql3-alpha-${{ github.sha }}-cluster-milestone
|
||||||
|
path: ${{ runner.temp }}/ql3-alpha-cluster-milestone
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 30
|
||||||
|
compression-level: 0
|
||||||
|
overwrite: false
|
||||||
|
include-hidden-files: false
|
||||||
|
|
||||||
local-alpha-milestone:
|
local-alpha-milestone:
|
||||||
name: Finalize the Local Alpha milestone
|
name: Finalize the Local Alpha milestone
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all')
|
if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all')
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-27):
|
最新增量证据(2026-08-27):
|
||||||
|
|
||||||
|
- D-414/ADR-0509(已实现,首份真实 Cluster milestone artifact 待授权):开发约二十天后,Cluster 阶段产物不再是八个由 workflow 内联 shell 自报 `passed` 的松散 archive。新增统一 `qinglong/alpha-cluster-image@v1` materializer/auditor,固定 control/control-ai/admin/worker 的 repository、OCI title、non-root user 和 amd64/arm64 identity;每个六文件 bundle 含单镜像 Docker archive、精确 SBOM、绑定 source/workflow SHA/ref/run/attempt/role/architecture/image ID 的 verification evidence、README、manifest 与 `SHA256SUMS`,下载后可无 Docker/网络离线复核闭合文件集和内容 digest。新增完整 CI 后置 `cluster-alpha-milestone` finalizer,重新下载八个 artifact,要求同一 version/source/run/attempt 且八个 image ID/archive digest/verification digest 互异,再上传 `qinglong/alpha-cluster-milestone@v1` 三文件索引;没有该索引的部分 archive 只是失败运行中间件。maturity 明确为 `cluster_integration_candidate_not_public_release`,适用于隔离 registry/K3s/Kubernetes 集成,不冒充受保护 tag、GHCR digest、签名、catalog 或生产 HA。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS;首份实际产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=cluster`。
|
||||||
|
|
||||||
- D-413/ADR-0508(已实现,首份真实 milestone artifact 待授权):Local Alpha 不再把两个矩阵 job 各自上传的 amd64/arm64 大归档直接视为阶段版本。新增独立发布期 `local-alpha-milestone` finalizer,只有完整 `QingLong 3.0 CI` 的 19 个 job dependency 全部成功后,才重新下载并离线审计两套 v2 Trial Kit,要求同一 version/source/workflow SHA/run/attempt、精确 `amd64|arm64`、四个不同 Application/operator image ID、两个不同 archive digest 与 verification digest,再上传 `qinglong/alpha-local-milestone@v1` 三文件小型索引。没有 index 的孤立 artifact 明确是失败运行中间文件,不能交付用户。手动门新增 `alpha_artifact_scope=local|cluster|all` 且默认 Local,避免路由/NAS 试用无谓生成八套 Cluster archive;完整测试不因 scope 缩小。显式 milestone 以 run ID 隔离并发域且不被后续普通 push 取消,source 仍绑定触发时 exact SHA。该 finalizer 不新增 workspace package、镜像 layer、设备依赖、daemon、listener、timer、连接池或稳态 RSS;真实可下载产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=local` 后记录 index 与双 bundle digest。
|
- D-413/ADR-0508(已实现,首份真实 milestone artifact 待授权):Local Alpha 不再把两个矩阵 job 各自上传的 amd64/arm64 大归档直接视为阶段版本。新增独立发布期 `local-alpha-milestone` finalizer,只有完整 `QingLong 3.0 CI` 的 19 个 job dependency 全部成功后,才重新下载并离线审计两套 v2 Trial Kit,要求同一 version/source/workflow SHA/run/attempt、精确 `amd64|arm64`、四个不同 Application/operator image ID、两个不同 archive digest 与 verification digest,再上传 `qinglong/alpha-local-milestone@v1` 三文件小型索引。没有 index 的孤立 artifact 明确是失败运行中间文件,不能交付用户。手动门新增 `alpha_artifact_scope=local|cluster|all` 且默认 Local,避免路由/NAS 试用无谓生成八套 Cluster archive;完整测试不因 scope 缩小。显式 milestone 以 run ID 隔离并发域且不被后续普通 push 取消,source 仍绑定触发时 exact SHA。该 finalizer 不新增 workspace package、镜像 layer、设备依赖、daemon、listener、timer、连接池或稳态 RSS;真实可下载产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=local` 后记录 index 与双 bundle digest。
|
||||||
|
|
||||||
- D-412/ADR-0507(已实现,真实公开发布待受保护 tag):Public Local Release Set 从“只签 Application、用户旅程却依赖另一个未发布 operator”的断层收敛为一对分别构建、扫描、签名和 multi-arch attestation 的镜像:`local` 是唯一常驻 Application,`local-operator` 只承担 setup/upgrade/recovery 等短生命周期 Owner authority。release candidate 的 Local scope 精确包含两者,并分别要求 Application Edge/Standalone rollout 与 operator `--version`/`setup --help` 门;CI OCI 证据、OS 漏洞策略、release-set、catalog consumption、final tag closure 和 Local selection 均按六镜像总闭包升级。`qinglong/release-set-image-record@v2`、`qinglong/release-set@v4`、`application/vnd.qinglong.release-set.v4+json`、`qinglong/local-compose-release-image@v3` 与 `qinglong/local-compose-image-selection@v3` 失败关闭旧孵化 schema。Compose revision 只保存 operator digest 作为管理 authority,不生成 operator service,所以路由设备稳态仍只有 Application,没有新增进程、listener、timer、端口或 RSS;管理动作才短暂下载/运行 operator。18-package clean build 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner CLI 为 301 total / 294 pass / 7 conditional skip / 0 fail,backend 为 1,610 total / 1,608 pass / 2 conditional skip / 0 fail,静态 release workflow 审计 100/100。该切片证明发布机制闭合,不冒充已经存在 GHCR tag 或真实用户可下载 Public Release Set;首份正式可交付物仍需受保护 release tag 的六镜像、签名、catalog-bound Local/Cluster 部署证据和终态 closure。
|
- D-412/ADR-0507(已实现,真实公开发布待受保护 tag):Public Local Release Set 从“只签 Application、用户旅程却依赖另一个未发布 operator”的断层收敛为一对分别构建、扫描、签名和 multi-arch attestation 的镜像:`local` 是唯一常驻 Application,`local-operator` 只承担 setup/upgrade/recovery 等短生命周期 Owner authority。release candidate 的 Local scope 精确包含两者,并分别要求 Application Edge/Standalone rollout 与 operator `--version`/`setup --help` 门;CI OCI 证据、OS 漏洞策略、release-set、catalog consumption、final tag closure 和 Local selection 均按六镜像总闭包升级。`qinglong/release-set-image-record@v2`、`qinglong/release-set@v4`、`application/vnd.qinglong.release-set.v4+json`、`qinglong/local-compose-release-image@v3` 与 `qinglong/local-compose-image-selection@v3` 失败关闭旧孵化 schema。Compose revision 只保存 operator digest 作为管理 authority,不生成 operator service,所以路由设备稳态仍只有 Application,没有新增进程、listener、timer、端口或 RSS;管理动作才短暂下载/运行 operator。18-package clean build 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner CLI 为 301 total / 294 pass / 7 conditional skip / 0 fail,backend 为 1,610 total / 1,608 pass / 2 conditional skip / 0 fail,静态 release workflow 审计 100/100。该切片证明发布机制闭合,不冒充已经存在 GHCR tag 或真实用户可下载 Public Release Set;首份正式可交付物仍需受保护 release tag 的六镜像、签名、catalog-bound Local/Cluster 部署证据和终态 closure。
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR-0509:Cluster Alpha Bundle 与跨架构里程碑闭合
|
||||||
|
|
||||||
|
- 状态:Accepted(首份实际 Cluster milestone artifact 待维护者授权)
|
||||||
|
- 日期:2026-08-28
|
||||||
|
- 决策:D-414
|
||||||
|
- 关联:ADR-0503、ADR-0506、ADR-0508
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
QingLong 3.0 已开发约二十天。Local Alpha 已有双架构 Trial Kit 和完整 CI 后置 milestone index,但 Cluster 的 control、control-ai、admin、worker 仍由矩阵 job 内的 shell 各自生成 Docker archive 和一份 v1 manifest。
|
||||||
|
|
||||||
|
旧 Cluster archive 没有 `SHA256SUMS`、闭合文件集、独立 verification evidence 或可离线 auditor;manifest 直接把 gate 写成 `passed`,没有绑定 workflow ref/SHA、run ID/attempt 和 exact image ID。八个 artifact 也没有完整 CI 后置的唯一完成信号。部署者无法区分“某个矩阵 job 上传成功”与“本次阶段版本整体成立”。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
### 1. 单镜像 bundle 由仓库脚本统一物化
|
||||||
|
|
||||||
|
新增 `ql3-cluster-alpha-bundle.cjs`,固定四角色的 repository、OCI title、runtime user 和两种架构。bundle schema 为 `qinglong/alpha-cluster-image@v1`,文件集恰好包含:单镜像 Docker archive、对应 CycloneDX SBOM、`verification-evidence.json`、README、manifest 和 `SHA256SUMS`。
|
||||||
|
|
||||||
|
物化前必须重新检查 image reference、ID、OS、architecture、non-root user、source/version/title label,复用现有 SBOM closure audit,并验证 evidence 绑定 exact source、role、architecture、image ID、workflow ref/SHA、run/attempt 和 gate 集。离线 audit 不调用 Docker或网络,拒绝额外文件、symlink、长度/digest、SBOM/evidence 或 checksum 漂移。
|
||||||
|
|
||||||
|
### 2. 八个 bundle 只有在完整 CI 后才能闭合
|
||||||
|
|
||||||
|
新增 `cluster-alpha-milestone` finalizer,依赖现有完整 CI 的 19 个顶层 job。它下载同一 source 的四角色乘 amd64/arm64 八个 artifact,逐个离线复审,并要求 version/source/run/attempt 一致,image ID、archive digest 和 verification digest 八项均互不复用。
|
||||||
|
|
||||||
|
成功后上传 `qinglong/alpha-cluster-milestone@v1` 三文件索引。索引保存八个 artifact 名、bundle manifest digest、archive digest、image ID 和 verification digest。缺少索引的零散 archive 是中间文件,不是阶段版本。
|
||||||
|
|
||||||
|
### 3. 仍不把 Integration Candidate 冒充 Public Release
|
||||||
|
|
||||||
|
Cluster milestone 的 maturity 固定为 `cluster_integration_candidate_not_public_release`。它可供隔离 registry/K3s/Kubernetes 节点做多组件集成,但没有受保护 tag、GHCR immutable digest、签名/attestation、release catalog 或生产 deployment lock。正式发布继续由 Public Release Set 独立裁决。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
- 保留 workflow 内联 shell:难以单测和离线复核,manifest 仍会自报通过。
|
||||||
|
- 只依赖 GitHub job 绿色状态:下载物与 workflow 主体没有内容级绑定,失败运行也可能留下部分 archive。
|
||||||
|
- 把八个 archive 合并为一个超大 artifact:会重复下载和存储,对只需要特定角色/架构的部署者不友好。
|
||||||
|
- 将 Cluster candidate 直接提升为正式发布:缺少 registry、签名、catalog 和生产部署闭环。
|
||||||
|
|
||||||
|
## 影响
|
||||||
|
|
||||||
|
- 显式 `alpha_artifact_scope=cluster|all` 时增加 verification 文件、离线 audit 和一次八 artifact finalization;普通 push/PR 不生成大产物;
|
||||||
|
- 每个部署者只下载目标架构/角色,milestone 本身保持三文件小索引;
|
||||||
|
- 新实现属于 repository release tooling,不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或稳态 RSS;
|
||||||
|
- Local 的路由/NAS 稳态边界不变,Cluster 节点获得可裁决的阶段集成产物。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- bundle 正向测试覆盖闭合六文件、image/SBOM/evidence identity 与离线 audit;
|
||||||
|
- 负向测试覆盖 root user、archive/SBOM/evidence 篡改、额外文件和 image subject 脱离;
|
||||||
|
- milestone 正向测试闭合八项,并覆盖跨 workflow run 混用、index mutation、额外文件和 workflow 门序;
|
||||||
|
- 静态 audit 固定 19 个完整 CI dependency、八 artifact 下载和 `finalize → audit → upload`;
|
||||||
|
- 首份真实产物仍需维护者显式授权 `produce_alpha_artifacts=true + alpha_artifact_scope=cluster`。
|
||||||
@@ -512,6 +512,7 @@
|
|||||||
| [ADR-0506](./ADR-0506-source-bound-local-alpha-verification-evidence.md) | 源码绑定的 Local Alpha 验证证据 | Accepted |
|
| [ADR-0506](./ADR-0506-source-bound-local-alpha-verification-evidence.md) | 源码绑定的 Local Alpha 验证证据 | Accepted |
|
||||||
| [ADR-0507](./ADR-0507-public-local-application-and-operator-release-pair.md) | Public Local Application 与 Operator 发布对 | Accepted(首份真实公开发布待受保护 tag) |
|
| [ADR-0507](./ADR-0507-public-local-application-and-operator-release-pair.md) | Public Local Application 与 Operator 发布对 | Accepted(首份真实公开发布待受保护 tag) |
|
||||||
| [ADR-0508](./ADR-0508-cross-architecture-local-alpha-milestone-closure.md) | 跨架构 Local Alpha 里程碑闭合 | Accepted(首份实际 milestone artifact 待维护者授权) |
|
| [ADR-0508](./ADR-0508-cross-architecture-local-alpha-milestone-closure.md) | 跨架构 Local Alpha 里程碑闭合 | Accepted(首份实际 milestone artifact 待维护者授权) |
|
||||||
|
| [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
| Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release |
|
| Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release |
|
||||||
| Public Release Set | 生产用户 | 受保护 tag、六镜像 multi-arch digest(Local Application/operator + Cluster 四角色)、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 |
|
| Public Release Set | 生产用户 | 受保护 tag、六镜像 multi-arch digest(Local Application/operator + Cluster 四角色)、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 |
|
||||||
|
|
||||||
只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
|
`Local Alpha Trial Kit + Local milestone index` 是本阶段的用户可试运行产物;`Cluster Integration Candidate + Cluster milestone index` 是集群部署者可下载、可离线验真的集成产物。单个 headless runtime、单个 Cluster archive 或没有 milestone index 的部分矩阵产物都只是工程中间件。Cluster milestone 仍不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
|
||||||
|
|
||||||
## 当前阶段实物(2026-08-27)
|
## 当前阶段实物(2026-08-27)
|
||||||
|
|
||||||
@@ -28,12 +28,14 @@ ADR-0506 现要求 `qinglong/alpha-local-trial-kit@v2` 额外包含 `verificatio
|
|||||||
|
|
||||||
## 生成
|
## 生成
|
||||||
|
|
||||||
在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交并设置 `produce_alpha_artifacts=true`。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。
|
在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交,设置 `produce_alpha_artifacts=true`,并明确选择 `alpha_artifact_scope=local|cluster|all`。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。
|
||||||
|
|
||||||
成功后同一次 run 生成、保留 30 天:
|
成功后同一次 run 生成、保留 30 天:
|
||||||
|
|
||||||
- `ql3-alpha-<commit>-local-amd64` 与 `ql3-alpha-<commit>-local-arm64`;
|
- `ql3-alpha-<commit>-local-amd64` 与 `ql3-alpha-<commit>-local-arm64`;
|
||||||
|
- `ql3-alpha-<commit>-local-milestone`;
|
||||||
- `ql3-alpha-<commit>-control-<arch>`、`control-ai-<arch>`、`admin-<arch>`、`worker-<arch>`。
|
- `ql3-alpha-<commit>-control-<arch>`、`control-ai-<arch>`、`admin-<arch>`、`worker-<arch>`。
|
||||||
|
- `ql3-alpha-<commit>-cluster-milestone`。
|
||||||
|
|
||||||
Local artifact 含:
|
Local artifact 含:
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ Local artifact 含:
|
|||||||
- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM;
|
- 与实际只读镜像 inventory 对账过的 CycloneDX SBOM;
|
||||||
- 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`。
|
- 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`。
|
||||||
|
|
||||||
Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manifest。
|
Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。
|
||||||
|
|
||||||
任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。
|
任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。
|
||||||
|
|
||||||
@@ -82,4 +84,4 @@ Cluster candidate 必须先导入隔离 registry 并重新绑定该 registry 的
|
|||||||
|
|
||||||
## 里程碑判定
|
## 里程碑判定
|
||||||
|
|
||||||
一次用户阶段里程碑只有同时记录以下事实才成立:源码 commit、版本、两种 Tier-1 架构的 Application/operator、完整 CI run、artifact 名与 digest、fresh setup→首 Owner→active→stop 的目标 Profile smoke、已知限制和回退路径。仅有源码、`dist/`、单元测试数字、Dockerfile、单个 headless runtime 或“理论上可构建”都不算用户可用产物。
|
一次 Local 用户阶段里程碑只有同时记录以下事实才成立:源码 commit、版本、两种 Tier-1 架构的 Application/operator、完整 CI run、artifact 名与 digest、fresh setup→首 Owner→active→stop 的目标 Profile smoke、已知限制和回退路径。一次 Cluster 集成里程碑还必须精确闭合四角色乘两架构、同一 run/attempt 和八个独立主体。仅有源码、`dist/`、单元测试数字、Dockerfile、单个 headless runtime 或“理论上可构建”都不算阶段可用产物。
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# QingLong 3.0 Cluster Alpha Integration Milestone
|
||||||
|
|
||||||
|
本目录是一次完整 Cluster Alpha integration milestone 的闭合索引,不包含八个大体积 Docker archive。`manifest.json` 精确列出同一次 GitHub Actions run 生成并离线复核的 control、control-ai、admin、worker 在 amd64、arm64 上的八个 artifact。只有该索引和目标 artifact 同时存在,才可把这次运行称为阶段性 Cluster 集成产物。
|
||||||
|
|
||||||
|
## 成熟度边界
|
||||||
|
|
||||||
|
- `maturity` 固定为 `cluster_integration_candidate_not_public_release`;
|
||||||
|
- 适用于隔离 registry、临时 Kubernetes/K3s 节点和非生产数据库上的集成验证;
|
||||||
|
- 不提供公开 GHCR tag、不可变 catalog selection、Cosign 签名、GitHub attestation、生产 HA、升级或长期支持承诺;
|
||||||
|
- 低配路由器/NAS 应选择 Local Alpha Trial Kit,不能部署四个 Cluster 角色。
|
||||||
|
|
||||||
|
任何单角色或单架构 artifact 提前上传都只是中间文件。缺少 `ql3-alpha-<sourceRevision>-cluster-milestone`、完整 CI 未成功、run/attempt 不一致或索引审计失败时,不得作为阶段交付物。
|
||||||
|
|
||||||
|
## 选择并验证下载物
|
||||||
|
|
||||||
|
1. 在本目录执行 `sha256sum --check SHA256SUMS`。
|
||||||
|
2. 确认 `manifest.json`:
|
||||||
|
- schema 为 `qinglong/alpha-cluster-milestone@v1`;
|
||||||
|
- `sourceRevision` 为目标完整 40 位提交;
|
||||||
|
- workflow event/job 为 `workflow_dispatch` / `cluster-alpha-milestone`;
|
||||||
|
- 对应 run ID/attempt 的完整 `QingLong 3.0 CI` 成功;
|
||||||
|
- `artifacts` 恰好包含四角色乘两架构的八项。
|
||||||
|
3. 按节点架构和需要的角色下载 artifact;一个可运行的完整 Cluster 测试部署通常需要 control、admin、worker,启用 AI 才增加 control-ai。
|
||||||
|
4. 对每个 bundle 执行其 `SHA256SUMS`,并核对 bundle `manifest.json` 的 digest 等于 milestone `bundleManifest.sha256`。
|
||||||
|
5. 使用隔离 registry 导入 Docker archive,再以导入后的 immutable digest 更新测试 deployment lock;不要直接依赖 archive 内的 `ci-*` tag。
|
||||||
|
|
||||||
|
持有同版本源码与 Node.js 24 时可离线复审:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/ql3-cluster-alpha-milestone.cjs \
|
||||||
|
--mode=audit \
|
||||||
|
--milestone=/absolute/path/to/ql3-alpha-cluster-milestone
|
||||||
|
|
||||||
|
node scripts/ql3-cluster-alpha-bundle.cjs \
|
||||||
|
--mode=offline-audit \
|
||||||
|
--bundle=/absolute/path/to/one-cluster-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
这两个命令不访问 Docker 或网络;它们验证闭合文件集、长度、SHA-256、SBOM identity、workflow evidence、角色/架构集合和八个主体互不复用。它们不会重新执行漏洞扫描或 Kubernetes live gate。
|
||||||
|
|
||||||
|
## 部署与回退
|
||||||
|
|
||||||
|
Cluster candidate 只应进入临时命名空间、隔离 registry 和可删除数据库。回退方式是删除测试 workload 与测试数据、撤销临时 credential,并回到原 deployment lock。生产 CloudNativePG、跨主机 STONITH/DR、CSI custody、外部 ingress TLS/IdP 和正式升级仍必须由 Public Release Set 的 catalog-bound deployment ceremony 证明。
|
||||||
@@ -41,6 +41,8 @@
|
|||||||
"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",
|
"bundle:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-bundle.cjs",
|
||||||
"finalize:local-alpha-milestone:ql3": "node scripts/ql3-local-alpha-milestone.cjs",
|
"finalize:local-alpha-milestone:ql3": "node scripts/ql3-local-alpha-milestone.cjs",
|
||||||
|
"bundle:cluster-alpha:ql3": "node scripts/ql3-cluster-alpha-bundle.cjs",
|
||||||
|
"finalize:cluster-alpha-milestone:ql3": "node scripts/ql3-cluster-alpha-milestone.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,745 @@
|
|||||||
|
#!/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-cluster-image@v1';
|
||||||
|
const VERIFICATION_SCHEMA = 'qinglong/alpha-cluster-image-verification@v1';
|
||||||
|
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
|
||||||
|
const ROLES = Object.freeze({
|
||||||
|
control: Object.freeze({
|
||||||
|
repository: 'qinglong3-cluster-control',
|
||||||
|
title: 'QingLong 3.0 Cluster Control',
|
||||||
|
user: '10001:10001',
|
||||||
|
}),
|
||||||
|
'control-ai': Object.freeze({
|
||||||
|
repository: 'qinglong3-cluster-control-ai',
|
||||||
|
title: 'QingLong 3.0 Cluster Control AI',
|
||||||
|
user: '10001:10001',
|
||||||
|
}),
|
||||||
|
admin: Object.freeze({
|
||||||
|
repository: 'qinglong3-cluster-admin',
|
||||||
|
title: 'QingLong 3.0 Cluster Admin',
|
||||||
|
user: '10001:10001',
|
||||||
|
}),
|
||||||
|
worker: Object.freeze({
|
||||||
|
repository: 'qinglong3-worker',
|
||||||
|
title: 'QingLong 3.0 Worker',
|
||||||
|
user: '65532:65532',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const WORKFLOW_IDENTITY = Object.freeze({
|
||||||
|
repository: 'whyour/qinglong',
|
||||||
|
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
|
||||||
|
event: 'workflow_dispatch',
|
||||||
|
job: 'cluster-image',
|
||||||
|
});
|
||||||
|
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 DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u;
|
||||||
|
const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u;
|
||||||
|
|
||||||
|
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 roleConfig(role) {
|
||||||
|
const config = ROLES[role];
|
||||||
|
if (!config) fail('Cluster image role is invalid');
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function names(role, architecture) {
|
||||||
|
const repository = roleConfig(role).repository;
|
||||||
|
return Object.freeze({
|
||||||
|
archive: `${repository}-${architecture}.docker.tar`,
|
||||||
|
sbom: `${repository}.cdx.json`,
|
||||||
|
verification: 'verification-evidence.json',
|
||||||
|
readme: 'README.md',
|
||||||
|
manifest: 'manifest.json',
|
||||||
|
checksums: 'SHA256SUMS',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
fail(`${label} must contain valid JSON`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 saveDockerImage(image, archivePath) {
|
||||||
|
childProcess.execFileSync(
|
||||||
|
'docker',
|
||||||
|
['image', 'save', '--output', archivePath, image],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImageReference(value, role, architecture) {
|
||||||
|
const expected = `${roleConfig(role).repository}:ci-${architecture}`;
|
||||||
|
if (value !== expected) fail('Cluster image reference is incompatible');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageInspection(inspection, options) {
|
||||||
|
const config = roleConfig(options.role);
|
||||||
|
const labels = inspection?.Config?.Labels;
|
||||||
|
if (
|
||||||
|
!SHA256_PATTERN.test(inspection?.Id || '') ||
|
||||||
|
inspection?.Os !== 'linux' ||
|
||||||
|
inspection?.Architecture !== options.architecture ||
|
||||||
|
inspection?.Config?.User !== config.user ||
|
||||||
|
labels?.['org.opencontainers.image.title'] !== config.title ||
|
||||||
|
labels?.['org.opencontainers.image.source'] !==
|
||||||
|
'https://github.com/whyour/qinglong' ||
|
||||||
|
labels?.['org.opencontainers.image.revision'] !== options.sourceRevision ||
|
||||||
|
labels?.['org.opencontainers.image.version'] !== options.version
|
||||||
|
) {
|
||||||
|
fail('Cluster image identity is incompatible');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
reference: options.reference,
|
||||||
|
id: inspection.Id,
|
||||||
|
os: 'linux',
|
||||||
|
architecture: options.architecture,
|
||||||
|
user: config.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSbom(document, options, offline = false) {
|
||||||
|
if (!offline) {
|
||||||
|
auditClusterImageSbom(document, {
|
||||||
|
root: options.root,
|
||||||
|
image: options.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 !== options.version ||
|
||||||
|
properties['qinglong:image-profile'] !== options.role
|
||||||
|
) {
|
||||||
|
fail('Cluster image SBOM identity is incompatible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gates(role) {
|
||||||
|
return Object.freeze({
|
||||||
|
osVulnerabilityPolicy: 'passed',
|
||||||
|
sbomInventoryReconciliation: 'passed',
|
||||||
|
nonRootRuntimeIdentity: 'passed',
|
||||||
|
clusterAdminProductFacade: role === 'admin' ? 'passed' : 'not_applicable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVerificationEvidence(document, expected) {
|
||||||
|
if (
|
||||||
|
!exactKeys(document, [
|
||||||
|
'schemaVersion',
|
||||||
|
'schema',
|
||||||
|
'subject',
|
||||||
|
'workflow',
|
||||||
|
'gates',
|
||||||
|
]) ||
|
||||||
|
document.schemaVersion !== 1 ||
|
||||||
|
document.schema !== VERIFICATION_SCHEMA ||
|
||||||
|
!exactKeys(document.subject, [
|
||||||
|
'version',
|
||||||
|
'sourceRevision',
|
||||||
|
'role',
|
||||||
|
'architecture',
|
||||||
|
'imageId',
|
||||||
|
]) ||
|
||||||
|
document.subject.version !== expected.version ||
|
||||||
|
document.subject.sourceRevision !== expected.sourceRevision ||
|
||||||
|
document.subject.role !== expected.role ||
|
||||||
|
document.subject.architecture !== expected.architecture ||
|
||||||
|
document.subject.imageId !== expected.imageId ||
|
||||||
|
!exactKeys(document.workflow, [
|
||||||
|
'repository',
|
||||||
|
'workflowRef',
|
||||||
|
'workflowSha',
|
||||||
|
'event',
|
||||||
|
'job',
|
||||||
|
'runId',
|
||||||
|
'runAttempt',
|
||||||
|
]) ||
|
||||||
|
document.workflow.repository !== WORKFLOW_IDENTITY.repository ||
|
||||||
|
document.workflow.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
|
||||||
|
document.workflow.workflowSha !== expected.sourceRevision ||
|
||||||
|
document.workflow.event !== WORKFLOW_IDENTITY.event ||
|
||||||
|
document.workflow.job !== WORKFLOW_IDENTITY.job ||
|
||||||
|
!DECIMAL_ID_PATTERN.test(document.workflow.runId || '') ||
|
||||||
|
!ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') ||
|
||||||
|
!exactKeys(document.gates, Object.keys(gates(expected.role))) ||
|
||||||
|
JSON.stringify(document.gates) !== JSON.stringify(gates(expected.role))
|
||||||
|
) {
|
||||||
|
fail('Cluster image verification evidence is incompatible');
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCommonIdentity(options) {
|
||||||
|
if (
|
||||||
|
!ARCHITECTURES.includes(options.architecture) ||
|
||||||
|
!Object.hasOwn(ROLES, options.role) ||
|
||||||
|
!REVISION_PATTERN.test(options.sourceRevision || '')
|
||||||
|
) {
|
||||||
|
fail('Cluster image identity is invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClusterAlphaVerificationEvidence(options, adapters = {}) {
|
||||||
|
validateCommonIdentity(options);
|
||||||
|
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
|
||||||
|
const output = path.resolve(options.output || '');
|
||||||
|
const parent = path.dirname(output);
|
||||||
|
if (
|
||||||
|
!path.isAbsolute(output) ||
|
||||||
|
fs.existsSync(output) ||
|
||||||
|
fs.realpathSync(parent) !== parent ||
|
||||||
|
options.repository !== WORKFLOW_IDENTITY.repository ||
|
||||||
|
options.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
|
||||||
|
options.workflowSha !== options.sourceRevision ||
|
||||||
|
options.eventName !== WORKFLOW_IDENTITY.event ||
|
||||||
|
options.job !== WORKFLOW_IDENTITY.job ||
|
||||||
|
!DECIMAL_ID_PATTERN.test(options.runId || '') ||
|
||||||
|
!ATTEMPT_PATTERN.test(options.runAttempt || '')
|
||||||
|
) {
|
||||||
|
fail('Cluster verification workflow identity or output is invalid');
|
||||||
|
}
|
||||||
|
const release = readReleaseIdentity(root);
|
||||||
|
const reference = validateImageReference(
|
||||||
|
options.image,
|
||||||
|
options.role,
|
||||||
|
options.architecture,
|
||||||
|
);
|
||||||
|
const inspectImage = adapters.inspectImage || inspectDockerImage;
|
||||||
|
const image = normalizeImageInspection(inspectImage(reference), {
|
||||||
|
reference,
|
||||||
|
role: options.role,
|
||||||
|
architecture: options.architecture,
|
||||||
|
sourceRevision: options.sourceRevision,
|
||||||
|
version: release.version,
|
||||||
|
});
|
||||||
|
const evidence = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: VERIFICATION_SCHEMA,
|
||||||
|
subject: {
|
||||||
|
version: release.version,
|
||||||
|
sourceRevision: options.sourceRevision,
|
||||||
|
role: options.role,
|
||||||
|
architecture: options.architecture,
|
||||||
|
imageId: image.id,
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
repository: options.repository,
|
||||||
|
workflowRef: options.workflowRef,
|
||||||
|
workflowSha: options.workflowSha,
|
||||||
|
event: options.eventName,
|
||||||
|
job: options.job,
|
||||||
|
runId: options.runId,
|
||||||
|
runAttempt: options.runAttempt,
|
||||||
|
},
|
||||||
|
gates: { ...gates(options.role) },
|
||||||
|
};
|
||||||
|
validateVerificationEvidence(evidence, evidence.subject);
|
||||||
|
writeExclusive(output, `${JSON.stringify(evidence, null, 2)}\n`);
|
||||||
|
return Object.freeze(evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Object.freeze({
|
||||||
|
file: name,
|
||||||
|
sha256: sha256File(filePath),
|
||||||
|
bytes: stat.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksumContents(bundleRoot, checkedNames) {
|
||||||
|
return `${checkedNames
|
||||||
|
.map(
|
||||||
|
(name) => `${sha256File(path.join(bundleRoot, name)).slice(7)} ${name}`,
|
||||||
|
)
|
||||||
|
.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClusterAlphaBundle(options, adapters = {}) {
|
||||||
|
validateCommonIdentity(options);
|
||||||
|
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
|
||||||
|
const outputRoot = path.resolve(options.outputRoot || '');
|
||||||
|
const parent = path.dirname(outputRoot);
|
||||||
|
if (
|
||||||
|
!path.isAbsolute(outputRoot) ||
|
||||||
|
fs.existsSync(outputRoot) ||
|
||||||
|
fs.realpathSync(parent) !== parent
|
||||||
|
) {
|
||||||
|
fail('Cluster bundle output is invalid');
|
||||||
|
}
|
||||||
|
const sbomPath = assertCanonicalFile(
|
||||||
|
options.sbom,
|
||||||
|
MAX_JSON_BYTES,
|
||||||
|
'Cluster SBOM',
|
||||||
|
);
|
||||||
|
const evidencePath = assertCanonicalFile(
|
||||||
|
options.verificationEvidence,
|
||||||
|
MAX_JSON_BYTES,
|
||||||
|
'Cluster verification evidence',
|
||||||
|
);
|
||||||
|
const readmePath = assertCanonicalFile(
|
||||||
|
options.readme,
|
||||||
|
MAX_README_BYTES,
|
||||||
|
'Cluster README',
|
||||||
|
);
|
||||||
|
const release = readReleaseIdentity(root);
|
||||||
|
const reference = validateImageReference(
|
||||||
|
options.image,
|
||||||
|
options.role,
|
||||||
|
options.architecture,
|
||||||
|
);
|
||||||
|
const inspectImage = adapters.inspectImage || inspectDockerImage;
|
||||||
|
const saveImage = adapters.saveImage || saveDockerImage;
|
||||||
|
const image = normalizeImageInspection(inspectImage(reference), {
|
||||||
|
reference,
|
||||||
|
role: options.role,
|
||||||
|
architecture: options.architecture,
|
||||||
|
sourceRevision: options.sourceRevision,
|
||||||
|
version: release.version,
|
||||||
|
});
|
||||||
|
const sbom = readBoundedJson(sbomPath, 'Cluster SBOM');
|
||||||
|
validateSbom(sbom, { root, role: options.role, version: release.version });
|
||||||
|
const evidence = readBoundedJson(
|
||||||
|
evidencePath,
|
||||||
|
'Cluster verification evidence',
|
||||||
|
);
|
||||||
|
validateVerificationEvidence(evidence, {
|
||||||
|
version: release.version,
|
||||||
|
sourceRevision: options.sourceRevision,
|
||||||
|
role: options.role,
|
||||||
|
architecture: options.architecture,
|
||||||
|
imageId: image.id,
|
||||||
|
});
|
||||||
|
const bundleNames = names(options.role, options.architecture);
|
||||||
|
let created = false;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(outputRoot, { mode: 0o700 });
|
||||||
|
created = true;
|
||||||
|
const archivePath = path.join(outputRoot, bundleNames.archive);
|
||||||
|
saveImage(reference, archivePath);
|
||||||
|
const archiveStat = fs.lstatSync(archivePath);
|
||||||
|
if (
|
||||||
|
!archiveStat.isFile() ||
|
||||||
|
archiveStat.isSymbolicLink() ||
|
||||||
|
archiveStat.size < ARCHIVE_MIN_BYTES
|
||||||
|
) {
|
||||||
|
fail('Cluster Docker archive is invalid or unexpectedly small');
|
||||||
|
}
|
||||||
|
fs.chmodSync(archivePath, 0o600);
|
||||||
|
copyExclusive(sbomPath, path.join(outputRoot, bundleNames.sbom));
|
||||||
|
copyExclusive(
|
||||||
|
evidencePath,
|
||||||
|
path.join(outputRoot, bundleNames.verification),
|
||||||
|
);
|
||||||
|
copyExclusive(readmePath, path.join(outputRoot, bundleNames.readme));
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: SCHEMA,
|
||||||
|
maturity: 'cluster_integration_candidate_not_public_release',
|
||||||
|
product: 'cluster',
|
||||||
|
role: options.role,
|
||||||
|
version: release.version,
|
||||||
|
sourceRevision: options.sourceRevision,
|
||||||
|
architecture: options.architecture,
|
||||||
|
image,
|
||||||
|
archive: fileRecord(outputRoot, bundleNames.archive),
|
||||||
|
sbom: fileRecord(outputRoot, bundleNames.sbom),
|
||||||
|
readme: fileRecord(outputRoot, bundleNames.readme),
|
||||||
|
verification: fileRecord(outputRoot, bundleNames.verification),
|
||||||
|
};
|
||||||
|
writeExclusive(
|
||||||
|
path.join(outputRoot, bundleNames.manifest),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeExclusive(
|
||||||
|
path.join(outputRoot, bundleNames.checksums),
|
||||||
|
checksumContents(outputRoot, [
|
||||||
|
bundleNames.archive,
|
||||||
|
bundleNames.sbom,
|
||||||
|
bundleNames.verification,
|
||||||
|
bundleNames.readme,
|
||||||
|
bundleNames.manifest,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
auditClusterAlphaBundle({ bundleRoot: outputRoot });
|
||||||
|
return Object.freeze(manifest);
|
||||||
|
} catch (error) {
|
||||||
|
if (created) fs.rmSync(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 auditClusterAlphaBundle(options) {
|
||||||
|
const bundleRoot = fs.realpathSync(path.resolve(options.bundleRoot || ''));
|
||||||
|
if (!fs.lstatSync(bundleRoot).isDirectory()) {
|
||||||
|
fail('Cluster bundle root must be a canonical directory');
|
||||||
|
}
|
||||||
|
const manifest = readBoundedJson(
|
||||||
|
path.join(bundleRoot, 'manifest.json'),
|
||||||
|
'manifest',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!exactKeys(manifest, [
|
||||||
|
'schemaVersion',
|
||||||
|
'schema',
|
||||||
|
'maturity',
|
||||||
|
'product',
|
||||||
|
'role',
|
||||||
|
'version',
|
||||||
|
'sourceRevision',
|
||||||
|
'architecture',
|
||||||
|
'image',
|
||||||
|
'archive',
|
||||||
|
'sbom',
|
||||||
|
'readme',
|
||||||
|
'verification',
|
||||||
|
]) ||
|
||||||
|
manifest.schemaVersion !== 1 ||
|
||||||
|
manifest.schema !== SCHEMA ||
|
||||||
|
manifest.maturity !== 'cluster_integration_candidate_not_public_release' ||
|
||||||
|
manifest.product !== 'cluster' ||
|
||||||
|
!Object.hasOwn(ROLES, manifest.role) ||
|
||||||
|
!ARCHITECTURES.includes(manifest.architecture) ||
|
||||||
|
typeof manifest.version !== 'string' ||
|
||||||
|
manifest.version.length < 3 ||
|
||||||
|
manifest.version.length > 64 ||
|
||||||
|
!REVISION_PATTERN.test(manifest.sourceRevision || '')
|
||||||
|
) {
|
||||||
|
fail('Cluster bundle manifest identity or shape is incompatible');
|
||||||
|
}
|
||||||
|
const bundleNames = names(manifest.role, manifest.architecture);
|
||||||
|
const expectedFiles = Object.values(bundleNames).sort();
|
||||||
|
const actualFiles = fs
|
||||||
|
.readdirSync(bundleRoot, { withFileTypes: true })
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||||
|
fail(`Cluster bundle contains a non-regular entry: ${entry.name}`);
|
||||||
|
}
|
||||||
|
return entry.name;
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||||
|
fail('Cluster bundle file set is not closed');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!exactKeys(manifest.image, [
|
||||||
|
'reference',
|
||||||
|
'id',
|
||||||
|
'os',
|
||||||
|
'architecture',
|
||||||
|
'user',
|
||||||
|
]) ||
|
||||||
|
manifest.image.reference !==
|
||||||
|
`${roleConfig(manifest.role).repository}:ci-${manifest.architecture}` ||
|
||||||
|
!SHA256_PATTERN.test(manifest.image.id || '') ||
|
||||||
|
manifest.image.os !== 'linux' ||
|
||||||
|
manifest.image.architecture !== manifest.architecture ||
|
||||||
|
manifest.image.user !== roleConfig(manifest.role).user
|
||||||
|
) {
|
||||||
|
fail('Cluster bundle image identity is incompatible');
|
||||||
|
}
|
||||||
|
validateFileRecord(manifest.archive, bundleNames.archive, bundleRoot);
|
||||||
|
if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) {
|
||||||
|
fail('Cluster bundle archive is unexpectedly small');
|
||||||
|
}
|
||||||
|
validateFileRecord(manifest.sbom, bundleNames.sbom, bundleRoot);
|
||||||
|
validateFileRecord(manifest.readme, bundleNames.readme, bundleRoot);
|
||||||
|
validateFileRecord(
|
||||||
|
manifest.verification,
|
||||||
|
bundleNames.verification,
|
||||||
|
bundleRoot,
|
||||||
|
);
|
||||||
|
const sbom = readBoundedJson(path.join(bundleRoot, bundleNames.sbom), 'SBOM');
|
||||||
|
validateSbom(sbom, { role: manifest.role, version: manifest.version }, true);
|
||||||
|
const evidence = readBoundedJson(
|
||||||
|
path.join(bundleRoot, bundleNames.verification),
|
||||||
|
'verification evidence',
|
||||||
|
);
|
||||||
|
validateVerificationEvidence(evidence, {
|
||||||
|
version: manifest.version,
|
||||||
|
sourceRevision: manifest.sourceRevision,
|
||||||
|
role: manifest.role,
|
||||||
|
architecture: manifest.architecture,
|
||||||
|
imageId: manifest.image.id,
|
||||||
|
});
|
||||||
|
const expectedChecksums = checksumContents(bundleRoot, [
|
||||||
|
bundleNames.archive,
|
||||||
|
bundleNames.sbom,
|
||||||
|
bundleNames.verification,
|
||||||
|
bundleNames.readme,
|
||||||
|
bundleNames.manifest,
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
fs.readFileSync(path.join(bundleRoot, bundleNames.checksums), 'utf8') !==
|
||||||
|
expectedChecksums
|
||||||
|
) {
|
||||||
|
fail('Cluster bundle SHA256SUMS differs from the closed file set');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/alpha-cluster-image-audit@v1',
|
||||||
|
role: manifest.role,
|
||||||
|
architecture: manifest.architecture,
|
||||||
|
sourceRevision: manifest.sourceRevision,
|
||||||
|
version: manifest.version,
|
||||||
|
workflowRunId: evidence.workflow.runId,
|
||||||
|
workflowRunAttempt: evidence.workflow.runAttempt,
|
||||||
|
imageId: manifest.image.id,
|
||||||
|
archiveSha256: manifest.archive.sha256,
|
||||||
|
verificationSha256: manifest.verification.sha256,
|
||||||
|
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' || values.mode === 'offline-audit') {
|
||||||
|
if (
|
||||||
|
JSON.stringify(Object.keys(values).sort()) !==
|
||||||
|
JSON.stringify(['bundle', 'mode'])
|
||||||
|
) {
|
||||||
|
fail('audit arguments are invalid');
|
||||||
|
}
|
||||||
|
return { mode: values.mode, bundleRoot: path.resolve(values.bundle) };
|
||||||
|
}
|
||||||
|
const common = {
|
||||||
|
architecture: values.architecture,
|
||||||
|
image: values.image,
|
||||||
|
role: values.role,
|
||||||
|
sourceRevision: values['source-revision'],
|
||||||
|
};
|
||||||
|
if (values.mode === 'record-verification') {
|
||||||
|
const expected = [
|
||||||
|
'architecture',
|
||||||
|
'event',
|
||||||
|
'image',
|
||||||
|
'job',
|
||||||
|
'mode',
|
||||||
|
'output',
|
||||||
|
'repository',
|
||||||
|
'role',
|
||||||
|
'run-attempt',
|
||||||
|
'run-id',
|
||||||
|
'source-revision',
|
||||||
|
'workflow-ref',
|
||||||
|
'workflow-sha',
|
||||||
|
].sort();
|
||||||
|
if (
|
||||||
|
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
|
||||||
|
) {
|
||||||
|
fail('verification arguments are invalid');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: values.mode,
|
||||||
|
...common,
|
||||||
|
output: path.resolve(values.output),
|
||||||
|
repository: values.repository,
|
||||||
|
workflowRef: values['workflow-ref'],
|
||||||
|
workflowSha: values['workflow-sha'],
|
||||||
|
eventName: values.event,
|
||||||
|
job: values.job,
|
||||||
|
runId: values['run-id'],
|
||||||
|
runAttempt: values['run-attempt'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const expected = [
|
||||||
|
'architecture',
|
||||||
|
'image',
|
||||||
|
'mode',
|
||||||
|
'output',
|
||||||
|
'readme',
|
||||||
|
'role',
|
||||||
|
'sbom',
|
||||||
|
'source-revision',
|
||||||
|
'verification-evidence',
|
||||||
|
].sort();
|
||||||
|
if (
|
||||||
|
values.mode !== 'create' ||
|
||||||
|
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
|
||||||
|
) {
|
||||||
|
fail('create arguments are invalid');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: values.mode,
|
||||||
|
...common,
|
||||||
|
outputRoot: path.resolve(values.output),
|
||||||
|
sbom: path.resolve(values.sbom),
|
||||||
|
verificationEvidence: path.resolve(values['verification-evidence']),
|
||||||
|
readme: path.resolve(values.readme),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(argv) {
|
||||||
|
const options = parseArguments(argv);
|
||||||
|
let report;
|
||||||
|
if (options.mode === 'record-verification') {
|
||||||
|
report = createClusterAlphaVerificationEvidence(options);
|
||||||
|
} else if (options.mode === 'create') {
|
||||||
|
report = createClusterAlphaBundle(options);
|
||||||
|
} else {
|
||||||
|
report = auditClusterAlphaBundle(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 : 'Cluster Alpha bundle failed'
|
||||||
|
}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Object.freeze({
|
||||||
|
ARCHITECTURES,
|
||||||
|
ROLES,
|
||||||
|
SCHEMA,
|
||||||
|
VERIFICATION_SCHEMA,
|
||||||
|
auditClusterAlphaBundle,
|
||||||
|
createClusterAlphaBundle,
|
||||||
|
createClusterAlphaVerificationEvidence,
|
||||||
|
names,
|
||||||
|
parseArguments,
|
||||||
|
runCli,
|
||||||
|
sha256File,
|
||||||
|
});
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const {
|
||||||
|
ARCHITECTURES,
|
||||||
|
ROLES,
|
||||||
|
auditClusterAlphaBundle,
|
||||||
|
sha256File,
|
||||||
|
} = require('./ql3-cluster-alpha-bundle.cjs');
|
||||||
|
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||||
|
|
||||||
|
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SCHEMA = 'qinglong/alpha-cluster-milestone@v1';
|
||||||
|
const SUBJECTS = Object.freeze(
|
||||||
|
Object.keys(ROLES).flatMap((role) =>
|
||||||
|
ARCHITECTURES.map((architecture) => `${role}-${architecture}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const FILES = Object.freeze({
|
||||||
|
readme: 'README.md',
|
||||||
|
manifest: 'manifest.json',
|
||||||
|
checksums: 'SHA256SUMS',
|
||||||
|
});
|
||||||
|
const WORKFLOW_IDENTITY = Object.freeze({
|
||||||
|
repository: 'whyour/qinglong',
|
||||||
|
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
|
||||||
|
event: 'workflow_dispatch',
|
||||||
|
job: 'cluster-alpha-milestone',
|
||||||
|
});
|
||||||
|
const REQUIRED_WORKFLOW_NEEDS = Object.freeze([
|
||||||
|
'backend',
|
||||||
|
'service-manager-bridge',
|
||||||
|
'linux-resource-envelopes',
|
||||||
|
'linux-resource-release-evidence',
|
||||||
|
'supply-chain',
|
||||||
|
'local-image',
|
||||||
|
'cluster-image',
|
||||||
|
'cluster-console-capacity-release-evidence',
|
||||||
|
'image-oci',
|
||||||
|
'worker-runtime',
|
||||||
|
'local-profiles',
|
||||||
|
'cluster-postgres',
|
||||||
|
'cluster-postgres-ha',
|
||||||
|
'cluster-cloudnativepg-live',
|
||||||
|
'cluster-provider-credential-test-kubernetes-live',
|
||||||
|
'cluster-secret-binding-mounted-provider-kubernetes-live',
|
||||||
|
'cluster-vault-kv-worker-secret-live',
|
||||||
|
'cluster-plugin-package-kubernetes-live',
|
||||||
|
'cluster-plugin-package-recovery-e2e',
|
||||||
|
]);
|
||||||
|
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
||||||
|
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
|
||||||
|
const DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u;
|
||||||
|
const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u;
|
||||||
|
const MAX_JSON_BYTES = 4 * 1024 * 1024;
|
||||||
|
const MAX_README_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
fail(`${label} must contain valid JSON`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fileRecord(filePath, name) {
|
||||||
|
const stat = fs.lstatSync(filePath);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2) {
|
||||||
|
fail(`milestone file is invalid: ${name}`);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
file: name,
|
||||||
|
sha256: sha256File(filePath),
|
||||||
|
bytes: stat.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checksumContents(root, checkedNames) {
|
||||||
|
return `${checkedNames
|
||||||
|
.map((name) => `${sha256File(path.join(root, name)).slice(7)} ${name}`)
|
||||||
|
.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactName(sourceRevision, role, architecture) {
|
||||||
|
return `ql3-alpha-${sourceRevision}-${role}-${architecture}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSubject(subject) {
|
||||||
|
const architecture = ARCHITECTURES.find((value) =>
|
||||||
|
subject.endsWith(`-${value}`),
|
||||||
|
);
|
||||||
|
if (!architecture) fail(`milestone subject is invalid: ${subject}`);
|
||||||
|
return Object.freeze({
|
||||||
|
role: subject.slice(0, -(architecture.length + 1)),
|
||||||
|
architecture,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIdentity(options) {
|
||||||
|
if (
|
||||||
|
!REVISION_PATTERN.test(options.sourceRevision || '') ||
|
||||||
|
options.repository !== WORKFLOW_IDENTITY.repository ||
|
||||||
|
options.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
|
||||||
|
options.workflowSha !== options.sourceRevision ||
|
||||||
|
options.eventName !== WORKFLOW_IDENTITY.event ||
|
||||||
|
!DECIMAL_ID_PATTERN.test(options.runId || '') ||
|
||||||
|
!ATTEMPT_PATTERN.test(options.runAttempt || '')
|
||||||
|
) {
|
||||||
|
fail('Cluster milestone workflow identity is invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFinalizeOptions(options) {
|
||||||
|
validateIdentity(options);
|
||||||
|
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
|
||||||
|
const outputRoot = path.resolve(options.outputRoot || '');
|
||||||
|
const parent = path.dirname(outputRoot);
|
||||||
|
if (
|
||||||
|
!path.isAbsolute(outputRoot) ||
|
||||||
|
fs.existsSync(outputRoot) ||
|
||||||
|
fs.realpathSync(parent) !== parent
|
||||||
|
) {
|
||||||
|
fail('Cluster milestone output is invalid');
|
||||||
|
}
|
||||||
|
const bundles = {};
|
||||||
|
for (const subject of SUBJECTS) {
|
||||||
|
const bundleRoot = fs.realpathSync(
|
||||||
|
path.resolve(options.bundles?.[subject] || ''),
|
||||||
|
);
|
||||||
|
if (!fs.lstatSync(bundleRoot).isDirectory()) {
|
||||||
|
fail(`${subject} bundle root is invalid`);
|
||||||
|
}
|
||||||
|
bundles[subject] = bundleRoot;
|
||||||
|
}
|
||||||
|
if (new Set(Object.values(bundles)).size !== SUBJECTS.length) {
|
||||||
|
fail('Cluster milestone subjects must use distinct bundles');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
root,
|
||||||
|
outputRoot,
|
||||||
|
bundles: Object.freeze(bundles),
|
||||||
|
readme: assertCanonicalFile(
|
||||||
|
options.readme,
|
||||||
|
MAX_README_BYTES,
|
||||||
|
'Cluster milestone README',
|
||||||
|
),
|
||||||
|
sourceRevision: options.sourceRevision,
|
||||||
|
repository: options.repository,
|
||||||
|
workflowRef: options.workflowRef,
|
||||||
|
workflowSha: options.workflowSha,
|
||||||
|
eventName: options.eventName,
|
||||||
|
runId: options.runId,
|
||||||
|
runAttempt: options.runAttempt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundleRecord(options, subject) {
|
||||||
|
const { role, architecture } = splitSubject(subject);
|
||||||
|
const bundleRoot = options.bundles[subject];
|
||||||
|
const report = auditClusterAlphaBundle({ bundleRoot });
|
||||||
|
if (
|
||||||
|
report.compatible !== true ||
|
||||||
|
report.role !== role ||
|
||||||
|
report.architecture !== architecture ||
|
||||||
|
report.sourceRevision !== options.sourceRevision ||
|
||||||
|
report.workflowRunId !== options.runId ||
|
||||||
|
report.workflowRunAttempt !== options.runAttempt
|
||||||
|
) {
|
||||||
|
fail(`${subject} bundle is detached from the Cluster milestone run`);
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
artifactName: artifactName(options.sourceRevision, role, architecture),
|
||||||
|
role,
|
||||||
|
architecture,
|
||||||
|
bundleManifest: fileRecord(
|
||||||
|
path.join(bundleRoot, 'manifest.json'),
|
||||||
|
'manifest.json',
|
||||||
|
),
|
||||||
|
archiveSha256: report.archiveSha256,
|
||||||
|
imageId: report.imageId,
|
||||||
|
verificationSha256: report.verificationSha256,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArtifactRecord(record, subject, manifest) {
|
||||||
|
const { role, architecture } = splitSubject(subject);
|
||||||
|
if (
|
||||||
|
!exactKeys(record, [
|
||||||
|
'artifactName',
|
||||||
|
'role',
|
||||||
|
'architecture',
|
||||||
|
'bundleManifest',
|
||||||
|
'archiveSha256',
|
||||||
|
'imageId',
|
||||||
|
'verificationSha256',
|
||||||
|
]) ||
|
||||||
|
record.artifactName !==
|
||||||
|
artifactName(manifest.sourceRevision, role, architecture) ||
|
||||||
|
record.role !== role ||
|
||||||
|
record.architecture !== architecture ||
|
||||||
|
!exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) ||
|
||||||
|
record.bundleManifest.file !== 'manifest.json' ||
|
||||||
|
!SHA256_PATTERN.test(record.bundleManifest.sha256 || '') ||
|
||||||
|
!Number.isSafeInteger(record.bundleManifest.bytes) ||
|
||||||
|
record.bundleManifest.bytes < 2 ||
|
||||||
|
!SHA256_PATTERN.test(record.archiveSha256 || '') ||
|
||||||
|
!SHA256_PATTERN.test(record.imageId || '') ||
|
||||||
|
!SHA256_PATTERN.test(record.verificationSha256 || '')
|
||||||
|
) {
|
||||||
|
fail(`${subject} milestone artifact record is incompatible`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWorkflow(document, sourceRevision) {
|
||||||
|
if (
|
||||||
|
!exactKeys(document, [
|
||||||
|
'repository',
|
||||||
|
'workflowRef',
|
||||||
|
'workflowSha',
|
||||||
|
'event',
|
||||||
|
'job',
|
||||||
|
'runId',
|
||||||
|
'runAttempt',
|
||||||
|
]) ||
|
||||||
|
document.repository !== WORKFLOW_IDENTITY.repository ||
|
||||||
|
document.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
|
||||||
|
document.workflowSha !== sourceRevision ||
|
||||||
|
document.event !== WORKFLOW_IDENTITY.event ||
|
||||||
|
document.job !== WORKFLOW_IDENTITY.job ||
|
||||||
|
!DECIMAL_ID_PATTERN.test(document.runId || '') ||
|
||||||
|
!ATTEMPT_PATTERN.test(document.runAttempt || '')
|
||||||
|
) {
|
||||||
|
fail('Cluster milestone manifest workflow identity is incompatible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditClusterAlphaMilestone(options) {
|
||||||
|
const milestoneRoot = fs.realpathSync(
|
||||||
|
path.resolve(options.milestoneRoot || ''),
|
||||||
|
);
|
||||||
|
if (!fs.lstatSync(milestoneRoot).isDirectory()) {
|
||||||
|
fail('Cluster milestone root must be a canonical directory');
|
||||||
|
}
|
||||||
|
const expectedFiles = Object.values(FILES).sort();
|
||||||
|
const actualFiles = fs
|
||||||
|
.readdirSync(milestoneRoot, { withFileTypes: true })
|
||||||
|
.map((entry) => {
|
||||||
|
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||||
|
fail(`Cluster milestone contains a non-regular entry: ${entry.name}`);
|
||||||
|
}
|
||||||
|
return entry.name;
|
||||||
|
})
|
||||||
|
.sort();
|
||||||
|
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||||
|
fail('Cluster milestone file set is not closed');
|
||||||
|
}
|
||||||
|
const manifest = readBoundedJson(
|
||||||
|
path.join(milestoneRoot, FILES.manifest),
|
||||||
|
'Cluster milestone manifest',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!exactKeys(manifest, [
|
||||||
|
'schemaVersion',
|
||||||
|
'schema',
|
||||||
|
'maturity',
|
||||||
|
'product',
|
||||||
|
'version',
|
||||||
|
'sourceRevision',
|
||||||
|
'workflow',
|
||||||
|
'artifacts',
|
||||||
|
'readme',
|
||||||
|
]) ||
|
||||||
|
manifest.schemaVersion !== 1 ||
|
||||||
|
manifest.schema !== SCHEMA ||
|
||||||
|
manifest.maturity !== 'cluster_integration_candidate_not_public_release' ||
|
||||||
|
manifest.product !== 'cluster' ||
|
||||||
|
typeof manifest.version !== 'string' ||
|
||||||
|
manifest.version.length < 3 ||
|
||||||
|
manifest.version.length > 64 ||
|
||||||
|
!REVISION_PATTERN.test(manifest.sourceRevision || '') ||
|
||||||
|
!exactKeys(manifest.artifacts, SUBJECTS) ||
|
||||||
|
!exactKeys(manifest.readme, ['file', 'sha256', 'bytes']) ||
|
||||||
|
manifest.readme.file !== FILES.readme ||
|
||||||
|
!SHA256_PATTERN.test(manifest.readme.sha256 || '') ||
|
||||||
|
!Number.isSafeInteger(manifest.readme.bytes) ||
|
||||||
|
manifest.readme.bytes < 2
|
||||||
|
) {
|
||||||
|
fail('Cluster milestone manifest identity or shape is incompatible');
|
||||||
|
}
|
||||||
|
validateWorkflow(manifest.workflow, manifest.sourceRevision);
|
||||||
|
for (const subject of SUBJECTS) {
|
||||||
|
validateArtifactRecord(manifest.artifacts[subject], subject, manifest);
|
||||||
|
}
|
||||||
|
const records = SUBJECTS.map((subject) => manifest.artifacts[subject]);
|
||||||
|
for (const field of ['imageId', 'archiveSha256', 'verificationSha256']) {
|
||||||
|
if (
|
||||||
|
new Set(records.map((record) => record[field])).size !== SUBJECTS.length
|
||||||
|
) {
|
||||||
|
fail(`Cluster milestone ${field} subjects are not distinct`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const actualReadme = fileRecord(
|
||||||
|
path.join(milestoneRoot, FILES.readme),
|
||||||
|
FILES.readme,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
actualReadme.sha256 !== manifest.readme.sha256 ||
|
||||||
|
actualReadme.bytes !== manifest.readme.bytes
|
||||||
|
) {
|
||||||
|
fail('Cluster milestone README differs from manifest');
|
||||||
|
}
|
||||||
|
const expectedChecksums = checksumContents(milestoneRoot, [
|
||||||
|
FILES.readme,
|
||||||
|
FILES.manifest,
|
||||||
|
]);
|
||||||
|
if (
|
||||||
|
fs.readFileSync(path.join(milestoneRoot, FILES.checksums), 'utf8') !==
|
||||||
|
expectedChecksums
|
||||||
|
) {
|
||||||
|
fail('Cluster milestone SHA256SUMS differs from the closed file set');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/alpha-cluster-milestone-audit@v1',
|
||||||
|
sourceRevision: manifest.sourceRevision,
|
||||||
|
version: manifest.version,
|
||||||
|
workflowRunId: manifest.workflow.runId,
|
||||||
|
workflowRunAttempt: manifest.workflow.runAttempt,
|
||||||
|
subjects: [...SUBJECTS],
|
||||||
|
compatible: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeClusterAlphaMilestone(options) {
|
||||||
|
const normalized = validateFinalizeOptions(options);
|
||||||
|
const artifacts = Object.fromEntries(
|
||||||
|
SUBJECTS.map((subject) => [subject, bundleRecord(normalized, subject)]),
|
||||||
|
);
|
||||||
|
const versions = new Set(
|
||||||
|
SUBJECTS.map(
|
||||||
|
(subject) =>
|
||||||
|
readBoundedJson(
|
||||||
|
path.join(normalized.bundles[subject], 'manifest.json'),
|
||||||
|
`${subject} bundle manifest`,
|
||||||
|
).version,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const release = readReleaseIdentity(normalized.root);
|
||||||
|
if (versions.size !== 1 || !versions.has(release.version)) {
|
||||||
|
fail('Cluster milestone bundles must have one release version');
|
||||||
|
}
|
||||||
|
for (const field of ['imageId', 'archiveSha256', 'verificationSha256']) {
|
||||||
|
if (
|
||||||
|
new Set(SUBJECTS.map((subject) => artifacts[subject][field])).size !==
|
||||||
|
SUBJECTS.length
|
||||||
|
) {
|
||||||
|
fail(`Cluster milestone ${field} subjects must be distinct`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let created = false;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(normalized.outputRoot, { mode: 0o700 });
|
||||||
|
created = true;
|
||||||
|
copyExclusive(
|
||||||
|
normalized.readme,
|
||||||
|
path.join(normalized.outputRoot, FILES.readme),
|
||||||
|
);
|
||||||
|
const manifest = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: SCHEMA,
|
||||||
|
maturity: 'cluster_integration_candidate_not_public_release',
|
||||||
|
product: 'cluster',
|
||||||
|
version: [...versions][0],
|
||||||
|
sourceRevision: normalized.sourceRevision,
|
||||||
|
workflow: {
|
||||||
|
repository: normalized.repository,
|
||||||
|
workflowRef: normalized.workflowRef,
|
||||||
|
workflowSha: normalized.workflowSha,
|
||||||
|
event: normalized.eventName,
|
||||||
|
job: WORKFLOW_IDENTITY.job,
|
||||||
|
runId: normalized.runId,
|
||||||
|
runAttempt: normalized.runAttempt,
|
||||||
|
},
|
||||||
|
artifacts,
|
||||||
|
readme: fileRecord(
|
||||||
|
path.join(normalized.outputRoot, FILES.readme),
|
||||||
|
FILES.readme,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
writeExclusive(
|
||||||
|
path.join(normalized.outputRoot, FILES.manifest),
|
||||||
|
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
writeExclusive(
|
||||||
|
path.join(normalized.outputRoot, FILES.checksums),
|
||||||
|
checksumContents(normalized.outputRoot, [FILES.readme, FILES.manifest]),
|
||||||
|
);
|
||||||
|
auditClusterAlphaMilestone({ milestoneRoot: normalized.outputRoot });
|
||||||
|
return Object.freeze(manifest);
|
||||||
|
} catch (error) {
|
||||||
|
if (created)
|
||||||
|
fs.rmSync(normalized.outputRoot, { recursive: true, force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countOccurrences(contents, token) {
|
||||||
|
return contents.split(token).length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobBlock(workflow, jobName) {
|
||||||
|
const header = `\n ${jobName}:\n`;
|
||||||
|
const start = workflow.indexOf(header);
|
||||||
|
if (start < 0) return '';
|
||||||
|
const remaining = workflow.slice(start + header.length);
|
||||||
|
const nextMatch = /\n [a-z0-9-]+:\n/u.exec(remaining);
|
||||||
|
const end = nextMatch
|
||||||
|
? start + header.length + nextMatch.index
|
||||||
|
: workflow.length;
|
||||||
|
return workflow.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditClusterAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
||||||
|
const workflow = fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
fs.realpathSync(path.resolve(root)),
|
||||||
|
'.github/workflows/ql3-ci.yml',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const findings = [];
|
||||||
|
const clusterCondition =
|
||||||
|
"github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')";
|
||||||
|
if (
|
||||||
|
countOccurrences(workflow, clusterCondition) !== 3 ||
|
||||||
|
!workflow.includes("github.run_id || 'validation'") ||
|
||||||
|
!workflow.includes(
|
||||||
|
"cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }}",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
findings.push('CLUSTER_MILESTONE_SCOPE_OR_CONCURRENCY_DRIFT');
|
||||||
|
}
|
||||||
|
const milestone = jobBlock(workflow, 'cluster-alpha-milestone');
|
||||||
|
const tokens = [
|
||||||
|
'name: Finalize the Cluster Alpha integration milestone',
|
||||||
|
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c',
|
||||||
|
'scripts/ql3-cluster-alpha-milestone.cjs',
|
||||||
|
'--mode=finalize',
|
||||||
|
'--mode=audit',
|
||||||
|
`name: ql3-alpha-${'${{ github.sha }}'}-cluster-milestone`,
|
||||||
|
'retention-days: 30',
|
||||||
|
'overwrite: false',
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
!milestone ||
|
||||||
|
tokens.some((token) => !milestone.includes(token)) ||
|
||||||
|
SUBJECTS.some(
|
||||||
|
(subject) =>
|
||||||
|
!milestone.includes(
|
||||||
|
`name: ql3-alpha-${'${{ github.sha }}'}-${subject}`,
|
||||||
|
),
|
||||||
|
) ||
|
||||||
|
REQUIRED_WORKFLOW_NEEDS.some(
|
||||||
|
(job) => !milestone.includes(` - ${job}\n`),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
findings.push('CLUSTER_MILESTONE_FINALIZER_CONTRACT_DRIFT');
|
||||||
|
}
|
||||||
|
const finalizerIndex = milestone.indexOf('--mode=finalize');
|
||||||
|
const auditIndex = milestone.indexOf('--mode=audit');
|
||||||
|
const uploadIndex = milestone.indexOf('actions/upload-artifact@');
|
||||||
|
if (
|
||||||
|
finalizerIndex < 0 ||
|
||||||
|
auditIndex <= finalizerIndex ||
|
||||||
|
uploadIndex <= auditIndex
|
||||||
|
) {
|
||||||
|
findings.push('CLUSTER_MILESTONE_GATE_ORDER_DRIFT');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/alpha-cluster-milestone-workflow-audit@v1',
|
||||||
|
requiredNeeds: [...REQUIRED_WORKFLOW_NEEDS],
|
||||||
|
subjects: [...SUBJECTS],
|
||||||
|
findings: Object.freeze(findings),
|
||||||
|
compatible: findings.length === 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(['milestone', 'mode'])
|
||||||
|
) {
|
||||||
|
fail('audit arguments are invalid');
|
||||||
|
}
|
||||||
|
return { mode: values.mode, milestoneRoot: path.resolve(values.milestone) };
|
||||||
|
}
|
||||||
|
if (values.mode === 'audit-workflow') {
|
||||||
|
if (
|
||||||
|
JSON.stringify(Object.keys(values).sort()) !==
|
||||||
|
JSON.stringify(['mode', 'root'])
|
||||||
|
) {
|
||||||
|
fail('workflow audit arguments are invalid');
|
||||||
|
}
|
||||||
|
return { mode: values.mode, root: path.resolve(values.root) };
|
||||||
|
}
|
||||||
|
const bundleArguments = SUBJECTS.map((subject) => `${subject}-bundle`);
|
||||||
|
const expected = [
|
||||||
|
...bundleArguments,
|
||||||
|
'event',
|
||||||
|
'mode',
|
||||||
|
'output',
|
||||||
|
'readme',
|
||||||
|
'repository',
|
||||||
|
'run-attempt',
|
||||||
|
'run-id',
|
||||||
|
'source-revision',
|
||||||
|
'workflow-ref',
|
||||||
|
'workflow-sha',
|
||||||
|
].sort();
|
||||||
|
if (
|
||||||
|
values.mode !== 'finalize' ||
|
||||||
|
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
|
||||||
|
) {
|
||||||
|
fail('finalize arguments are invalid');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: values.mode,
|
||||||
|
outputRoot: path.resolve(values.output),
|
||||||
|
bundles: Object.fromEntries(
|
||||||
|
SUBJECTS.map((subject) => [
|
||||||
|
subject,
|
||||||
|
path.resolve(values[`${subject}-bundle`]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
readme: path.resolve(values.readme),
|
||||||
|
sourceRevision: values['source-revision'],
|
||||||
|
repository: values.repository,
|
||||||
|
workflowRef: values['workflow-ref'],
|
||||||
|
workflowSha: values['workflow-sha'],
|
||||||
|
eventName: values.event,
|
||||||
|
runId: values['run-id'],
|
||||||
|
runAttempt: values['run-attempt'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(argv) {
|
||||||
|
const options = parseArguments(argv);
|
||||||
|
let report;
|
||||||
|
if (options.mode === 'finalize') {
|
||||||
|
report = finalizeClusterAlphaMilestone(options);
|
||||||
|
} else if (options.mode === 'audit-workflow') {
|
||||||
|
report = auditClusterAlphaMilestoneWorkflow(options.root);
|
||||||
|
if (!report.compatible) fail(JSON.stringify(report));
|
||||||
|
} else {
|
||||||
|
report = auditClusterAlphaMilestone(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
|
||||||
|
: 'Cluster Alpha milestone failed'
|
||||||
|
}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Object.freeze({
|
||||||
|
FILES,
|
||||||
|
REQUIRED_WORKFLOW_NEEDS,
|
||||||
|
SCHEMA,
|
||||||
|
SUBJECTS,
|
||||||
|
artifactName,
|
||||||
|
auditClusterAlphaMilestone,
|
||||||
|
auditClusterAlphaMilestoneWorkflow,
|
||||||
|
finalizeClusterAlphaMilestone,
|
||||||
|
parseArguments,
|
||||||
|
runCli,
|
||||||
|
});
|
||||||
@@ -465,6 +465,18 @@ function countOccurrences(contents, token) {
|
|||||||
return contents.split(token).length - 1;
|
return contents.split(token).length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jobBlock(workflow, jobName) {
|
||||||
|
const header = `\n ${jobName}:\n`;
|
||||||
|
const start = workflow.indexOf(header);
|
||||||
|
if (start < 0) return '';
|
||||||
|
const remaining = workflow.slice(start + header.length);
|
||||||
|
const nextMatch = /\n [a-z0-9-]+:\n/u.exec(remaining);
|
||||||
|
const end = nextMatch
|
||||||
|
? start + header.length + nextMatch.index
|
||||||
|
: workflow.length;
|
||||||
|
return workflow.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
||||||
const workflowPath = path.join(
|
const workflowPath = path.join(
|
||||||
fs.realpathSync(path.resolve(root)),
|
fs.realpathSync(path.resolve(root)),
|
||||||
@@ -473,7 +485,7 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
|||||||
const workflow = fs.readFileSync(workflowPath, 'utf8');
|
const workflow = fs.readFileSync(workflowPath, 'utf8');
|
||||||
const findings = [];
|
const findings = [];
|
||||||
const milestoneStart = workflow.indexOf('\n local-alpha-milestone:\n');
|
const milestoneStart = workflow.indexOf('\n local-alpha-milestone:\n');
|
||||||
const milestone = milestoneStart >= 0 ? workflow.slice(milestoneStart) : '';
|
const milestone = jobBlock(workflow, 'local-alpha-milestone');
|
||||||
const requiredWorkflowTokens = [
|
const requiredWorkflowTokens = [
|
||||||
'alpha_artifact_scope:',
|
'alpha_artifact_scope:',
|
||||||
'default: local',
|
'default: local',
|
||||||
@@ -492,7 +504,7 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
|||||||
"github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')";
|
"github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')";
|
||||||
if (
|
if (
|
||||||
countOccurrences(workflow, localScopeCondition) !== 3 ||
|
countOccurrences(workflow, localScopeCondition) !== 3 ||
|
||||||
countOccurrences(workflow, clusterScopeCondition) !== 2
|
countOccurrences(workflow, clusterScopeCondition) !== 3
|
||||||
) {
|
) {
|
||||||
findings.push('MILESTONE_SCOPE_CONTRACT_DRIFT');
|
findings.push('MILESTONE_SCOPE_CONTRACT_DRIFT');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'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 {
|
||||||
|
ROLES,
|
||||||
|
auditClusterAlphaBundle,
|
||||||
|
createClusterAlphaBundle,
|
||||||
|
createClusterAlphaVerificationEvidence,
|
||||||
|
} = require('../../scripts/ql3-cluster-alpha-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 inspection(role, architecture = 'arm64', idCharacter = '7') {
|
||||||
|
const config = ROLES[role];
|
||||||
|
return {
|
||||||
|
Id: `sha256:${idCharacter.repeat(64)}`,
|
||||||
|
Os: 'linux',
|
||||||
|
Architecture: architecture,
|
||||||
|
Config: {
|
||||||
|
User: config.user,
|
||||||
|
Labels: {
|
||||||
|
'org.opencontainers.image.title': config.title,
|
||||||
|
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
|
||||||
|
'org.opencontainers.image.revision': revision,
|
||||||
|
'org.opencontainers.image.version': version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t, role = 'admin') {
|
||||||
|
const fixtureRoot = fs.realpathSync(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-alpha-bundle-')),
|
||||||
|
);
|
||||||
|
t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
|
||||||
|
const paths = {
|
||||||
|
fixtureRoot,
|
||||||
|
sbom: path.join(fixtureRoot, 'source.cdx.json'),
|
||||||
|
evidence: path.join(fixtureRoot, 'source-verification.json'),
|
||||||
|
readme: path.join(fixtureRoot, 'source-README.md'),
|
||||||
|
outputRoot: path.join(fixtureRoot, 'bundle'),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
paths.sbom,
|
||||||
|
`${JSON.stringify(createClusterImageSbom({ root, image: role }))}\n`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(paths.readme, '# Cluster integration candidate\n');
|
||||||
|
createClusterAlphaVerificationEvidence(
|
||||||
|
verificationOptions(paths, role),
|
||||||
|
adapters(role),
|
||||||
|
);
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verificationOptions(paths, role = 'admin', overrides = {}) {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
output: paths.evidence,
|
||||||
|
role,
|
||||||
|
architecture: 'arm64',
|
||||||
|
image: `${ROLES[role].repository}:ci-arm64`,
|
||||||
|
sourceRevision: revision,
|
||||||
|
repository: 'whyour/qinglong',
|
||||||
|
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
|
||||||
|
workflowSha: revision,
|
||||||
|
eventName: 'workflow_dispatch',
|
||||||
|
job: 'cluster-image',
|
||||||
|
runId: '33073349397',
|
||||||
|
runAttempt: '2',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOptions(paths, role = 'admin') {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
outputRoot: paths.outputRoot,
|
||||||
|
role,
|
||||||
|
architecture: 'arm64',
|
||||||
|
image: `${ROLES[role].repository}:ci-arm64`,
|
||||||
|
sourceRevision: revision,
|
||||||
|
sbom: paths.sbom,
|
||||||
|
verificationEvidence: paths.evidence,
|
||||||
|
readme: paths.readme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapters(role = 'admin', overrides = {}) {
|
||||||
|
return {
|
||||||
|
inspectImage() {
|
||||||
|
return inspection(role);
|
||||||
|
},
|
||||||
|
saveImage(image, archivePath) {
|
||||||
|
assert.equal(image, `${ROLES[role].repository}:ci-arm64`);
|
||||||
|
fs.writeFileSync(archivePath, Buffer.alloc(2048, 9), { flag: 'wx' });
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('materializes and offline-audits one closed Cluster image bundle', (t) => {
|
||||||
|
const paths = fixture(t);
|
||||||
|
const manifest = createClusterAlphaBundle(createOptions(paths), adapters());
|
||||||
|
assert.equal(manifest.schema, 'qinglong/alpha-cluster-image@v1');
|
||||||
|
assert.equal(
|
||||||
|
manifest.maturity,
|
||||||
|
'cluster_integration_candidate_not_public_release',
|
||||||
|
);
|
||||||
|
assert.equal(manifest.role, 'admin');
|
||||||
|
assert.equal(manifest.image.user, '10001:10001');
|
||||||
|
assert.equal(manifest.verification.file, 'verification-evidence.json');
|
||||||
|
const report = auditClusterAlphaBundle({ bundleRoot: paths.outputRoot });
|
||||||
|
assert.equal(report.compatible, true);
|
||||||
|
assert.equal(report.workflowRunId, '33073349397');
|
||||||
|
assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [
|
||||||
|
'README.md',
|
||||||
|
'SHA256SUMS',
|
||||||
|
'manifest.json',
|
||||||
|
'qinglong3-cluster-admin-arm64.docker.tar',
|
||||||
|
'qinglong3-cluster-admin.cdx.json',
|
||||||
|
'verification-evidence.json',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binds verification evidence to role, architecture, image and exact CI run', (t) => {
|
||||||
|
const paths = fixture(t, 'worker');
|
||||||
|
const evidence = JSON.parse(fs.readFileSync(paths.evidence, 'utf8'));
|
||||||
|
assert.deepEqual(evidence.subject, {
|
||||||
|
version,
|
||||||
|
sourceRevision: revision,
|
||||||
|
role: 'worker',
|
||||||
|
architecture: 'arm64',
|
||||||
|
imageId: `sha256:${'7'.repeat(64)}`,
|
||||||
|
});
|
||||||
|
assert.equal(evidence.workflow.job, 'cluster-image');
|
||||||
|
assert.equal(evidence.gates.clusterAdminProductFacade, 'not_applicable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails closed before output on incompatible image identity', (t) => {
|
||||||
|
const paths = fixture(t);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createClusterAlphaBundle(
|
||||||
|
createOptions(paths),
|
||||||
|
adapters('admin', {
|
||||||
|
inspectImage() {
|
||||||
|
const value = inspection('admin');
|
||||||
|
value.Config.User = '0:0';
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
/image identity is incompatible/,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(paths.outputRoot), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline audit rejects archive, file-set, SBOM and evidence mutations', (t) => {
|
||||||
|
for (const mutation of ['archive', 'extra', 'sbom', 'evidence']) {
|
||||||
|
const paths = fixture(t);
|
||||||
|
paths.outputRoot = path.join(paths.fixtureRoot, `bundle-${mutation}`);
|
||||||
|
createClusterAlphaBundle(createOptions(paths), adapters());
|
||||||
|
if (mutation === 'archive') {
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(paths.outputRoot, 'qinglong3-cluster-admin-arm64.docker.tar'),
|
||||||
|
'tamper',
|
||||||
|
);
|
||||||
|
} else if (mutation === 'extra') {
|
||||||
|
fs.writeFileSync(path.join(paths.outputRoot, 'credential.txt'), 'secret');
|
||||||
|
} else if (mutation === 'sbom') {
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(paths.outputRoot, 'qinglong3-cluster-admin.cdx.json'),
|
||||||
|
'tamper',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(paths.outputRoot, 'verification-evidence.json'),
|
||||||
|
'tamper',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.throws(() =>
|
||||||
|
auditClusterAlphaBundle({ bundleRoot: paths.outputRoot }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects verification detached from the inspected image subject', (t) => {
|
||||||
|
const paths = fixture(t);
|
||||||
|
const evidence = JSON.parse(fs.readFileSync(paths.evidence, 'utf8'));
|
||||||
|
evidence.subject.imageId = `sha256:${'8'.repeat(64)}`;
|
||||||
|
fs.writeFileSync(paths.evidence, `${JSON.stringify(evidence)}\n`);
|
||||||
|
assert.throws(
|
||||||
|
() => createClusterAlphaBundle(createOptions(paths), adapters()),
|
||||||
|
/verification evidence is incompatible/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'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 {
|
||||||
|
ROLES,
|
||||||
|
createClusterAlphaBundle,
|
||||||
|
createClusterAlphaVerificationEvidence,
|
||||||
|
} = require('../../scripts/ql3-cluster-alpha-bundle.cjs');
|
||||||
|
const {
|
||||||
|
SUBJECTS,
|
||||||
|
auditClusterAlphaMilestone,
|
||||||
|
auditClusterAlphaMilestoneWorkflow,
|
||||||
|
finalizeClusterAlphaMilestone,
|
||||||
|
} = require('../../scripts/ql3-cluster-alpha-milestone.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 = 'b'.repeat(40);
|
||||||
|
const workflowRef =
|
||||||
|
'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next';
|
||||||
|
|
||||||
|
function splitSubject(subject) {
|
||||||
|
const architecture = subject.endsWith('-amd64') ? 'amd64' : 'arm64';
|
||||||
|
return { role: subject.slice(0, -(architecture.length + 1)), architecture };
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspection(role, architecture, idCharacter) {
|
||||||
|
const config = ROLES[role];
|
||||||
|
return {
|
||||||
|
Id: `sha256:${idCharacter.repeat(64)}`,
|
||||||
|
Os: 'linux',
|
||||||
|
Architecture: architecture,
|
||||||
|
Config: {
|
||||||
|
User: config.user,
|
||||||
|
Labels: {
|
||||||
|
'org.opencontainers.image.title': config.title,
|
||||||
|
'org.opencontainers.image.source': 'https://github.com/whyour/qinglong',
|
||||||
|
'org.opencontainers.image.revision': revision,
|
||||||
|
'org.opencontainers.image.version': version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t, runId = '33073349397') {
|
||||||
|
const fixtureRoot = fs.realpathSync(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-alpha-milestone-')),
|
||||||
|
);
|
||||||
|
t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
|
||||||
|
const readme = path.join(fixtureRoot, 'README-source.md');
|
||||||
|
fs.writeFileSync(readme, '# Cluster Alpha milestone\n');
|
||||||
|
const sboms = {};
|
||||||
|
for (const role of Object.keys(ROLES)) {
|
||||||
|
sboms[role] = path.join(fixtureRoot, `${role}.cdx.json`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
sboms[role],
|
||||||
|
`${JSON.stringify(createClusterImageSbom({ root, image: role }))}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const bundles = {};
|
||||||
|
SUBJECTS.forEach((subject, index) => {
|
||||||
|
const { role, architecture } = splitSubject(subject);
|
||||||
|
const idCharacter = String(index + 1);
|
||||||
|
const evidence = path.join(fixtureRoot, `${subject}-verification.json`);
|
||||||
|
const image = `${ROLES[role].repository}:ci-${architecture}`;
|
||||||
|
const inspectImage = () => inspection(role, architecture, idCharacter);
|
||||||
|
createClusterAlphaVerificationEvidence(
|
||||||
|
{
|
||||||
|
root,
|
||||||
|
output: evidence,
|
||||||
|
role,
|
||||||
|
architecture,
|
||||||
|
image,
|
||||||
|
sourceRevision: revision,
|
||||||
|
repository: 'whyour/qinglong',
|
||||||
|
workflowRef,
|
||||||
|
workflowSha: revision,
|
||||||
|
eventName: 'workflow_dispatch',
|
||||||
|
job: 'cluster-image',
|
||||||
|
runId,
|
||||||
|
runAttempt: '2',
|
||||||
|
},
|
||||||
|
{ inspectImage },
|
||||||
|
);
|
||||||
|
bundles[subject] = path.join(fixtureRoot, subject);
|
||||||
|
createClusterAlphaBundle(
|
||||||
|
{
|
||||||
|
root,
|
||||||
|
outputRoot: bundles[subject],
|
||||||
|
role,
|
||||||
|
architecture,
|
||||||
|
image,
|
||||||
|
sourceRevision: revision,
|
||||||
|
sbom: sboms[role],
|
||||||
|
verificationEvidence: evidence,
|
||||||
|
readme,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inspectImage,
|
||||||
|
saveImage(_reference, archivePath) {
|
||||||
|
fs.writeFileSync(archivePath, Buffer.alloc(2048, index + 1), {
|
||||||
|
flag: 'wx',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
fixtureRoot,
|
||||||
|
bundles,
|
||||||
|
readme,
|
||||||
|
outputRoot: path.join(fixtureRoot, 'milestone'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeOptions(paths, overrides = {}) {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
outputRoot: paths.outputRoot,
|
||||||
|
bundles: paths.bundles,
|
||||||
|
readme: paths.readme,
|
||||||
|
sourceRevision: revision,
|
||||||
|
repository: 'whyour/qinglong',
|
||||||
|
workflowRef,
|
||||||
|
workflowSha: revision,
|
||||||
|
eventName: 'workflow_dispatch',
|
||||||
|
runId: '33073349397',
|
||||||
|
runAttempt: '2',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('closes eight role/architecture bundles into one offline-verifiable index', (t) => {
|
||||||
|
const paths = fixture(t);
|
||||||
|
const manifest = finalizeClusterAlphaMilestone(finalizeOptions(paths));
|
||||||
|
assert.equal(manifest.schema, 'qinglong/alpha-cluster-milestone@v1');
|
||||||
|
assert.equal(Object.keys(manifest.artifacts).length, 8);
|
||||||
|
assert.deepEqual(Object.keys(manifest.artifacts), SUBJECTS);
|
||||||
|
assert.equal(
|
||||||
|
new Set(Object.values(manifest.artifacts).map((entry) => entry.imageId))
|
||||||
|
.size,
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
const report = auditClusterAlphaMilestone({
|
||||||
|
milestoneRoot: paths.outputRoot,
|
||||||
|
});
|
||||||
|
assert.equal(report.compatible, true);
|
||||||
|
assert.deepEqual(report.subjects, SUBJECTS);
|
||||||
|
assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [
|
||||||
|
'README.md',
|
||||||
|
'SHA256SUMS',
|
||||||
|
'manifest.json',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects one bundle detached from the authorized workflow run', (t) => {
|
||||||
|
const paths = fixture(t, '33073349398');
|
||||||
|
assert.throws(
|
||||||
|
() => finalizeClusterAlphaMilestone(finalizeOptions(paths)),
|
||||||
|
/detached from the Cluster milestone run/,
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(paths.outputRoot), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline milestone audit rejects index and file-set mutation', (t) => {
|
||||||
|
for (const mutation of ['manifest', 'extra']) {
|
||||||
|
const paths = fixture(t);
|
||||||
|
paths.outputRoot = path.join(paths.fixtureRoot, `milestone-${mutation}`);
|
||||||
|
finalizeClusterAlphaMilestone(finalizeOptions(paths));
|
||||||
|
if (mutation === 'manifest') {
|
||||||
|
fs.appendFileSync(path.join(paths.outputRoot, 'manifest.json'), 'tamper');
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(path.join(paths.outputRoot, 'secret.txt'), 'secret');
|
||||||
|
}
|
||||||
|
assert.throws(() =>
|
||||||
|
auditClusterAlphaMilestone({ milestoneRoot: paths.outputRoot }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workflow audit proves full CI closure before milestone upload', () => {
|
||||||
|
const report = auditClusterAlphaMilestoneWorkflow(root);
|
||||||
|
assert.equal(report.compatible, true);
|
||||||
|
assert.deepEqual(report.findings, []);
|
||||||
|
assert.equal(report.requiredNeeds.length, 19);
|
||||||
|
assert.deepEqual(report.subjects, SUBJECTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workflow audit rejects missing final offline audit', (t) => {
|
||||||
|
const fixtureRoot = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), 'ql3-cluster-alpha-workflow-'),
|
||||||
|
);
|
||||||
|
t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
|
||||||
|
const workflowDirectory = path.join(fixtureRoot, '.github/workflows');
|
||||||
|
fs.mkdirSync(workflowDirectory, { recursive: true });
|
||||||
|
const source = fs
|
||||||
|
.readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8')
|
||||||
|
.replace(
|
||||||
|
'node scripts/ql3-cluster-alpha-milestone.cjs \\\n --mode=audit',
|
||||||
|
'node scripts/ql3-cluster-alpha-milestone.cjs \\\n --mode=inspect',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(workflowDirectory, 'ql3-ci.yml'), source);
|
||||||
|
const report = auditClusterAlphaMilestoneWorkflow(fixtureRoot);
|
||||||
|
assert.equal(report.compatible, false);
|
||||||
|
assert.ok(
|
||||||
|
report.findings.includes('CLUSTER_MILESTONE_FINALIZER_CONTRACT_DRIFT'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -256,13 +256,19 @@ test('workflow audit rejects a partial milestone finalizer', (t) => {
|
|||||||
fs.mkdirSync(path.join(fixtureRoot, '.github/workflows'), {
|
fs.mkdirSync(path.join(fixtureRoot, '.github/workflows'), {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
const workflow = fs
|
const source = fs.readFileSync(
|
||||||
.readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8')
|
path.join(root, '.github/workflows/ql3-ci.yml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const marker = '\n local-alpha-milestone:\n';
|
||||||
|
const markerIndex = source.indexOf(marker);
|
||||||
|
const workflow = `${source.slice(0, markerIndex)}${source
|
||||||
|
.slice(markerIndex)
|
||||||
.replace(' - cluster-postgres-ha\n', '')
|
.replace(' - cluster-postgres-ha\n', '')
|
||||||
.replace(
|
.replace(
|
||||||
'scripts/ql3-local-alpha-milestone.cjs',
|
'scripts/ql3-local-alpha-milestone.cjs',
|
||||||
'scripts/unreviewed-finalizer.cjs',
|
'scripts/unreviewed-finalizer.cjs',
|
||||||
);
|
)}`;
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(fixtureRoot, '.github/workflows/ql3-ci.yml'),
|
path.join(fixtureRoot, '.github/workflows/ql3-ci.yml'),
|
||||||
workflow,
|
workflow,
|
||||||
|
|||||||
Reference in New Issue
Block a user