mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
230 lines
12 KiB
Markdown
230 lines
12 KiB
Markdown
# 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_IMAGE` build arg,并增加跨架构 OS vulnerability pre-publish gate。2026-08-22 的 ADR-0254 修订进一步把
|
||
> build stage 保留在固定 Bookworm digest、runtime stage 切到零 HIGH/CRITICAL 的固定 Alpine 3.23 digest,覆盖
|
||
> `control|control-ai|admin|local|worker` 十个 native image。
|
||
|
||
## 背景
|
||
|
||
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 必须同时具备:
|
||
|
||
1. BuildKit `sbom: true`;
|
||
2. BuildKit `provenance: mode=max`;
|
||
3. Cosign GitHub OIDC keyless signature;
|
||
4. `actions/attest` 生成并推送的 SLSA provenance;
|
||
5. `actions/attest` 生成并推送的受审 CycloneDX 应用 SBOM。
|
||
|
||
创建签名和证明之后,每个 matrix job 自身还必须从 GHCR 读取自己的
|
||
`IMAGE@DIGEST` 并失败关闭:
|
||
|
||
1. `docker buildx imagetools inspect --raw` 的 root index 必须只有
|
||
`linux/amd64`、`linux/arm64` 两个 runnable manifest,以及与二者一对一
|
||
digest 绑定的两个 BuildKit attestation manifest;
|
||
2. Cosign verification 必须精确绑定当前 repository、release workflow path
|
||
和 `GITHUB_REF` 组成的 certificate identity,并固定 GitHub Actions OIDC
|
||
issuer,禁止宽泛 identity regexp;
|
||
3. GitHub SLSA 与 CycloneDX 必须作为两次独立
|
||
`gh attestation verify` 执行,从 OCI registry 读取 bundle,绑定 repository、
|
||
signer workflow、`GITHUB_SHA`、`GITHUB_REF`,并拒绝 self-hosted runner;
|
||
4. CycloneDX verification 必须显式要求
|
||
`https://cyclonedx.org/bom` predicate,不能让默认 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:ql3`
|
||
- `pnpm sbom:cluster-image:ql3 --image=admin`
|
||
- `pnpm audit:cluster-image-release:ql3`
|
||
- `node --test test/back/ql3ClusterImageSbom.test.cjs`
|
||
- `node --test test/back/ql3ClusterImageReleaseAudit.test.cjs`
|
||
- `node --test test/back/ql3ClusterOciLayoutAudit.test.cjs`
|
||
- `node --test test/back/ql3ClusterRemoteManifestAudit.test.cjs`
|
||
- `node --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 发布。
|