12 KiB
ADR-0128:精确 Cluster 镜像 SBOM 与证明化多架构发布
- 状态:Accepted(control/admin 双镜像的精确 SBOM、原生双架构 CI、OCI digest graph 与独立 GHCR/Cosign/GitHub attestation 发布契约已完成;control 已有本地 arm64 inventory 与 amd64/arm64 OCI 实证,admin 已有本地 arm64 inventory,真实 admin 双架构 OCI 和远端受保护发布记录仍是 Release Gate)
- 日期:2026-07-24
- 关联 RFC:QL-RFC-0001 D-14、D-61、D-85、D-105、D-124、D-126
- 关联 ADR:ADR-0038、ADR-0042、ADR-0062、ADR-0088、ADR-0090、ADR-0126
ADR-0196 已把该供应链扩展为
control|admin|local三个 profile,并将唯一 发布入口迁移到.github/workflows/ql3-image-release.yml。本 ADR 以下 control/admin 数字保留为建立该机制时的历史证据。ADR-0254 随后删除 control/admin 可覆盖的NODE_IMAGEbuild arg,将两个 build/runtime stage 与 local 一起固定到同一 Node 多架构 digest,并增加六矩阵 OS vulnerability pre-publish gate。
背景
ADR-0126 已建立可构建的 Cluster 镜像,但“存在 lock”和“生成 SBOM”都不能单独
证明实际运行时闭包。对 builder/runtime 共用、同时声明 @types/pg 的 npm
manifest 执行 npm ci --omit=dev 时,Drizzle 的 optional peer 会让
@types/pg、@types/node 和 undici-types 仍进入生产镜像。另一方面,
npm sbom --package-lock-only --omit=dev 又会漏掉 lock 中标为
devOptional、但通过 pg 实际运行时可达的八个包。
发布侧如果只推 tag,则无法证明部署拿到哪个 manifest;只生成 CI 文件也不能 证明它已经在远端成功签名。因此需要把源码闭包、实际镜像清单、不可变 digest、 签名和证明拆成可独立判定的证据。
决策
1. Builder 与 production dependency root 分离
每个镜像分别保留两个 npm dependency root:
- builder manifest/lock 包含 TypeScript 和受审的 exact type dependency;
runtime-dependencies/package.json与 lock 只包含五个 exact production root dependency。
两者的 package name、version 和 production dependency map 必须完全相同。
production stage 只能对 production lock 执行 npm ci --omit=dev。不能依靠
npm prune、手工删除目录或 SBOM allowlist 隐藏 builder 泄漏。
control 与 admin 不能共用一个 production root:control 的第五个根是
@aws-sdk/client-s3,admin 的第五个根是 @kubernetes/client-node。两者拥有
独立 manifest/lock、Dockerfile、image repository 和 digest,防止短生命周期
Kubernetes/admin authority 进入常驻 control 镜像。
该目录是镜像构建输入,不是 workspace package,不增加 21 importer hard cap。
2. SBOM 从生产锁的可达图生成,并与实际镜像逐包对账
ql3-cluster-image-sbom.cjs 从五个 root dependency 遍历 npm v3 lock 中
dependencies + optionalDependencies 的实际解析位置。任何可达节点即使被 npm
标成 devOptional 也进入图;peer dependency 不因出现在 builder lock 就自动
进入生产图。
SBOM 必须显式选择 exact control|admin profile,生成的 CycloneDX 1.5 文档
分别固定包含:
- control:43 个外部 runtime component,
runtime-core、cluster-postgres、cluster-control三个内部 component,一个 image dependency root,47 个 dependency node; - admin:85 个外部 runtime component,
runtime-core、cluster-postgres、cluster-admin三个内部 component,一个独立 image dependency root,89 个 dependency node。
组件使用 name@version purl 作为 bom-ref,registry 包携带 lock integrity 的
SHA-512 和 distribution URL。内部 workspace:* 必须解析到 exact version。
重复 ref、缺边、多包、少包、版本漂移、typescript、profile/lock 错配或
closure 外的类型包全部失败关闭。admin production graph 中
@kubernetes/client-node 自身把 @types/js-yaml 声明为 runtime dependency;
该节点必须因 exact lock 可达而保留,不能继续用 @types/* 名称前缀冒充依赖
可达性判断。
CI 还必须在 UID 10001、只读根、no-new-privileges 容器中枚举实际
/opt/qinglong/node_modules,并要求其 name@version 集合与所选 profile 的
control 46 或 admin 87 个 SBOM component 完全相同。源码 SBOM 成功不能替代这一步,也不能
拿 control 的 46-component 文档证明 admin 镜像。
3. CI 原生验证 amd64 与 arm64
ql3-ci.yml 使用 control|admin × amd64|arm64 四项矩阵,在 GitHub 原生
ubuntu-24.04 与 ubuntu-24.04-arm runner 分别构建所选 production
Dockerfile,验证:
- runner Node architecture;
- image architecture 和
10001:10001; - SBOM 正/负向测试;
- 部署与发布静态契约;
- 实际镜像 46/88 包闭包。
该 job 成功后只证明两个单架构构建;它不等同于 registry 已存在一个多架构 manifest。
另一个独立 OCI evidence job 同样以 control|admin 为矩阵,必须使用 QEMU +
Buildx 分别构建 linux/amd64,linux/arm64,同时启用 SPDX SBOM 与 maximum
SLSA provenance,再离线审计每个完整 OCI layout。审计必须:
- 对每个 content-addressed blob 流式复算 SHA-256 并核对 descriptor size;
- 拒绝未引用 blob、缺平台、重复/未绑定 attestation 和超出容量的图;
- 分别复验两个 config 的 architecture、UID/GID、entrypoint、工作目录、端口、 production env、OCI label 和 source revision;
- 要求每个平台恰有一个 attestation manifest,且包含 SPDX 与 SLSA 两个 in-toto predicate;
- 从 Syft SPDX 中只选择
/opt/qinglong/node_modules,与所选 CycloneDX 的 control 46 或 admin 87 个 npm purl 精确对账; - 只允许 source revision 和无 credential 的标准 proxy build arg 进入 provenance。
本地 2026-07-24 证据的 root index 为
sha256:7859b32b136f7d82f4504b0e0895c560348e4acab460d4ece0127d47a62bea5e:
- amd64 manifest
sha256:d6cecd239953d0ed4791c58ee769d78656f913e7e158cdab373db468d6fb2814, compressed layer bytes 83,746,881; - arm64 manifest
sha256:f7b0b7e1ab868e09617527ca0d28ff8becab27d81ce008af685597dc1be768b7, compressed layer bytes 83,624,682; - layout 共 27 个 blob、172,835,866 bytes;两个平台都对账 46 个应用 npm package,并各自拥有 SPDX-2.3 与 SLSA provenance v1。
admin 历史本地 arm64 镜像已在 UID/GID 10001、只读根、
no-new-privileges 下完成实际 inventory 对账。ADR-0218 删除未使用的 Croner 后,
当前 lock/SBOM 为 84 external + 3 internal、87 component/88 dependency node;
更新后真实镜像 inventory 仍待依赖物化复验。本机 admin 双架构 OCI 实证尚未取得:
2026-07-25 两次 Buildx
尝试均在拉取官方 docker/buildkit-syft-scanner:stable-1 的 Docker Hub OAuth
token 阶段超时,未进入项目 Dockerfile 构建;该外部失败不能记为 OCI 通过。
4. Release 只围绕每个 pushed digest 建立 authority
独立 release workflow 只接受 v3.* tag 或显式 QingLong 3 SemVer dispatch。
它以 exact matrix 分别把 qinglong3-cluster-control 与
qinglong3-cluster-admin 的 linux/amd64,linux/arm64 manifest 发布到不同
GHCR repository,并以每个 build 返回的 sha256:* digest 作为该镜像唯一证明
subject。两个 job 不共享 digest、SBOM 或证明;tag 只用于发现,Kubernetes
生产 overlay 必须分别 pin 两个 digest。
提交的 CloudNativePG control/recovery overlay 使用不可拉取的全零 digest 作为
fail-closed 占位。私有部署输入必须分别替换 control/admin digest;静态部署门禁
拒绝任一 production-oriented overlay 回退到 newTag,也不允许用同一份
control 证明替代 admin Job。
每个 digest 必须同时具备:
- BuildKit
sbom: true; - BuildKit
provenance: mode=max; - Cosign GitHub OIDC keyless signature;
actions/attest生成并推送的 SLSA provenance;actions/attest生成并推送的受审 CycloneDX 应用 SBOM。
创建签名和证明之后,每个 matrix job 自身还必须从 GHCR 读取自己的
IMAGE@DIGEST 并失败关闭:
docker buildx imagetools inspect --raw的 root index 必须只有linux/amd64、linux/arm64两个 runnable manifest,以及与二者一对一 digest 绑定的两个 BuildKit attestation manifest;- Cosign verification 必须精确绑定当前 repository、release workflow path
和
GITHUB_REF组成的 certificate identity,并固定 GitHub Actions OIDC issuer,禁止宽泛 identity regexp; - GitHub SLSA 与 CycloneDX 必须作为两次独立
gh attestation verify执行,从 OCI registry 读取 bundle,绑定 repository、 signer workflow、GITHUB_SHA、GITHUB_REF,并拒绝 self-hosted runner; - CycloneDX verification 必须显式要求
https://cyclonedx.org/bompredicate,不能让默认 SLSA verification 冒充应用 SBOM 已验证。
workflow 只授予 contents: read、packages: write、id-token: write、
attestations: write 和 artifact-metadata: write。Pull Request 不得触发
发布。digest 形态不合法时必须在签名/证明前失败。release job 的 checkout、
Node、Docker、Cosign 和 attestation action 必须固定到已核验 tag 对应的完整
commit SHA;可移动 major tag 只保留为行尾可读注释,不能作为执行 ref。
不代表什么
本 ADR 当前本地证据不代表:
- release workflow 已在 GitHub-hosted runner 成功;本地双架构 OCI 证据不能 代替远端受保护发布环境;
- GHCR 已存在可拉取且彼此独立的 control/admin QingLong 3 digest;
- Cosign signature 或 GitHub attestation 已在远端 registry 可验证;
- 当前 base image 或 npm graph 没有漏洞;
- image 在目标 Kubernetes 节点满足容量、冷启动或网络策略要求;
- PostgreSQL operator、STONITH、Pod 分区和 CA 重叠轮换 Gate 已完成。
只有实际 release run、不可变 digest 拉取和独立 verify 记录才能关闭前三项。 workflow YAML 通过静态审计只证明发布意图没有被静默降级。
替代方案
- 直接使用
npm sbom --package-lock-only --omit=dev:拒绝。当前 lock 实测漏掉八个实际 runtime package。 - 只用
npm ci --omit=dev和共用 manifest:拒绝。optional peer 会留下 三个类型相关 package。 - 构建后手工删除
@types:拒绝。依赖变化时会静默漂移,且 lock 不再代表 安装输入。 - 只上传 SBOM 文件:拒绝。未绑定 pushed digest 的文件不能证明部署镜像。
- 只依赖可变 tag:拒绝。tag 可移动,不能作为 rollout 或审计 authority。
- PR 构建时直接发布:拒绝。扩大 token/registry 写权限和不受信代码风险。
- 写权限 job 使用 action major tag:拒绝。tag 可移动,release action 必须 使用完整 commit SHA。
验证
pnpm sbom:cluster-image:ql3pnpm sbom:cluster-image:ql3 --image=adminpnpm audit:cluster-image-release:ql3node --test test/back/ql3ClusterImageSbom.test.cjsnode --test test/back/ql3ClusterImageReleaseAudit.test.cjsnode --test test/back/ql3ClusterOciLayoutAudit.test.cjsnode --test test/back/ql3ClusterRemoteManifestAudit.test.cjsnode --test test/back/ql3ClusterDeploymentAudit.test.cjs- 生产 Dockerfile 实际 build;
- UID 10001、只读根容器内以
--image=control|admin --inventory-root分别对账 46/88 个 component; - CI 的 control/admin × 原生 amd64/arm64 image job;
- 每个 Buildx amd64/arm64 OCI output 后执行
ql3-cluster-oci-layout-audit.cjs --image=control|admin --layout=... --expected-revision=...; - release 后由 workflow 审计远端 manifest/attestation 一对一绑定,并以 exact workflow identity/source revision/source ref 分别执行 Cosign、GitHub SLSA 和 CycloneDX verify;独立发布记录仍必须保存该结果。
静态负向门必须拒绝缺 admin 或 control profile、profile/SBOM 错配、缺 arm64、 缺 OIDC 权限、缺 digest 签名、缺应用 SBOM证明、缺实际 image inventory、缺 远端 manifest audit、宽泛证书 identity、证明未绑定 source、CycloneDX predicate 漂移或 PR 发布。