diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index b175b31c..85d02acf 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -386,11 +386,14 @@ jobs: --build-arg SOURCE_REVISION=${{ github.sha }} --tag "${OPERATOR_IMAGE}" . - - name: Materialize the reviewed local OS vulnerability exceptions - run: >- - node scripts/ql3-image-os-vulnerability-policy.cjs - --image=local - --output=${{ runner.temp }}/ql3-local-${{ matrix.image_arch }}.trivyignore.yaml + - name: Materialize the reviewed Local OS vulnerability exceptions + run: | + node scripts/ql3-image-os-vulnerability-policy.cjs \ + --image=local \ + --output=${{ runner.temp }}/ql3-local-${{ matrix.image_arch }}.trivyignore.yaml + node scripts/ql3-image-os-vulnerability-policy.cjs \ + --image=local-operator \ + --output=${{ runner.temp }}/ql3-local-operator-${{ matrix.image_arch }}.trivyignore.yaml - name: Reject unexcepted high or critical local OS vulnerabilities uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: @@ -420,7 +423,7 @@ jobs: hide-progress: 'true' timeout: '10m0s' cache: 'false' - trivyignores: ${{ runner.temp }}/ql3-local-${{ matrix.image_arch }}.trivyignore.yaml + trivyignores: ${{ runner.temp }}/ql3-local-operator-${{ matrix.image_arch }}.trivyignore.yaml - name: Verify non-root identity and architecture env: IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} @@ -960,6 +963,9 @@ jobs: - image: local dockerfile: deploy/containers/ql3-local-application/Dockerfile target: runtime + - image: local-operator + dockerfile: deploy/containers/ql3-local-operator/Dockerfile + target: runtime - image: worker dockerfile: deploy/containers/ql3-worker/Dockerfile target: runtime diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index 2c8c84e3..5e0f54c7 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -564,6 +564,27 @@ jobs: --docker-socket="${docker_socket}" \ --profile=standalone + - name: Verify the short-lived Local operator entrypoint + if: matrix.image == 'local-operator' + env: + IMAGE: ${{ steps.identity.outputs.image }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + docker pull "${IMAGE}@${DIGEST}" + for arguments in "--version" "setup --help"; do + read -r -a argv <<< "${arguments}" + docker run --rm \ + --read-only \ + --network none \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --memory 128m \ + --cpus 0.5 \ + --pids-limit 32 \ + "${IMAGE}@${DIGEST}" "${argv[@]}" + done + - name: Verify the keyless signature identity env: IMAGE: ${{ steps.identity.outputs.image }} @@ -658,6 +679,7 @@ jobs: --repository-owner="${owner}" \ --candidate="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-contract.json" \ --image="${{ matrix.image }}" \ + --local-role-verification="${{ matrix.local_role_verification }}" \ --digest="${DIGEST}" \ --output="${RUNNER_TEMP}/release-record/${{ matrix.image }}.json" @@ -1107,13 +1129,16 @@ jobs: const fs = require('node:fs'); const selection = JSON.parse(fs.readFileSync(process.env.SELECTION, 'utf8')); if (!/^sha256:[a-f0-9]{64}$/.test(selection.selectionDigest || '') || - !/^ghcr\.io\/[a-z0-9._-]+\/qinglong3-local-application@sha256:[a-f0-9]{64}$/.test(selection.service?.image || '')) process.exit(1); - process.stdout.write(`selection=${process.env.SELECTION}\nselection-digest=${selection.selectionDigest}\nimage=${selection.service.image}\n`); + !/^ghcr\.io\/[a-z0-9._-]+\/qinglong3-local-application@sha256:[a-f0-9]{64}$/.test(selection.service?.image || '') || + !/^ghcr\.io\/[a-z0-9._-]+\/qinglong3-local-operator@sha256:[a-f0-9]{64}$/.test(selection.operator?.image || '') || + selection.operator?.kind !== 'short-lived' || selection.operator?.network !== 'none-by-default') process.exit(1); + process.stdout.write(`selection=${process.env.SELECTION}\nselection-digest=${selection.selectionDigest}\nimage=${selection.service.image}\noperator-image=${selection.operator.image}\n`); NODE - name: Prove catalog-bound Edge and Standalone rollout env: IMAGE: ${{ steps.local-selection.outputs.image }} + OPERATOR_IMAGE: ${{ steps.local-selection.outputs.operator-image }} RELEASE_SELECTION: ${{ steps.local-selection.outputs.selection }} SELECTION_DIGEST: ${{ steps.local-selection.outputs.selection-digest }} EVIDENCE_ROOT: ${{ runner.temp }}/ql3-release-catalog-local-deployment @@ -1124,6 +1149,27 @@ jobs: umask 077 install -d -m 0700 "${EVIDENCE_ROOT}" docker pull "${IMAGE}" + docker pull "${OPERATOR_IMAGE}" + docker run --rm --read-only \ + --network none \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --memory=128m \ + --memory-swap=128m \ + --cpus=0.5 \ + --pids-limit=32 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ + "${OPERATOR_IMAGE}" --version + docker run --rm --read-only \ + --network none \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --memory=128m \ + --memory-swap=128m \ + --cpus=0.5 \ + --pids-limit=32 \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=8m \ + "${OPERATOR_IMAGE}" setup --help docker_executable="$(realpath "$(command -v docker)")" docker_socket="$(realpath /var/run/docker.sock)" for profile in edge standalone; do diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 03f8e86c..ca6cd256 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,11 +11,13 @@ 最新增量证据(2026-08-27): +- 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-411/ADR-0506(已实现,真实 downloadable v2 artifact 待授权):Local Alpha materializer 不再凭调用 `create` 就把九个 gate 无条件写成 `passed`。bundle schema 升为 `qinglong/alpha-local-trial-kit@v2`,新增 `verification-evidence.json`,其 subject 精确绑定版本、source、Tier-1 架构与 Application/operator image ID,workflow 精确绑定 `whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next`、workflow SHA、`workflow_dispatch`、`local-image`、run ID/attempt。CI 静态门固定 `fresh journey → native cancellation → record-verification → create → audit → upload`,evidence 作为第七个闭合文件进入 manifest byte/SHA-256 与 `SHA256SUMS`;create/audit 均拒绝跨源码、跨架构、跨镜像或跨 workflow 复制。旧 v1 bundle 因没有来源证明只保留为工程候选。提交 `4239464a` 的主 CI run `32990652047` 已 40/40,Kubernetes run `32990652416` 与三节点 Security run `32990653482` 同源成功,证明源码的双架构门;但本地 `4239464a` v1 archive 不是 exact CI artifact,仍不能冒充 v2 用户 Alpha。该增强只增加一个小型发布期 JSON,不新增 workspace package、镜像 layer、设备依赖、常驻进程、RSS 或端口;首个真实双架构 v2 下载物仍需维护者显式授权 milestone workflow。 - Alpha 阶段产物历史基线(当前性已由 D-411 收紧):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Application Docker archive(SHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksum;image identity/architecture/non-root user、read-only/no-network、128 MiB/0.5 CPU smoke、Edge/Standalone lifecycle/graceful stop/SQLite integrity、库存对账与 Trivy 0.70.0 HIGH/CRITICAL=0 已复验。但该 archive 只有 headless runtime,未携带完成 fresh setup/Owner 管理所需的独立 `ql3` 制品,因此按 D-408 重新准确分类为“运行时工程候选”,不再冒充完整用户 Alpha。macOS Docker Desktop 无法等价证明的 Local API cancellation 由原生 Linux arm64 job `97986754052` 通过;本机 Edge 首次 startup receipt 在 Docker Desktop 文件桥出现一次瞬态,精确重跑和原生 Linux门均通过,证据未隐藏首次失败。远端 CI run `32903679764` 首轮为 37/40(两项 GitHub action 内部 DNS 失败、一次 PostgreSQL 18 x64 scheduler 并发断言),failed-only attempt 2 收敛为 40/40;独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration/CNPG/PVC run `32903679570` 同源成功。该证据保留为演进记录,不再代表当前 v2 Alpha bundle 资格;public GHCR digest、签名/attestation、catalog、deployment-lock 与生产 HA/DR/CSI/IdP 仍是 Public Release Set 的硬门。 -- D-408/ADR-0503(进行中):阶段产物成熟度现在按真实部署用户旅程而非“已有 Dockerfile/镜像”裁决。新增独立 `qinglong3-local-operator` 短生命周期镜像,复用既有 `@qinglong/local-owner-cli` 的统一 `ql3` 入口而不新增 workspace package;它默认 `65532:65532`、无端口、无 listener/daemon/timer、network none,和常驻 Local Application 保持物理制品分离,因此 Owner/bootstrap authority 不进入 runtime closure,Edge 稳态资源零变化。本机基于未提交工作树构建的 arm64 operator 原型 ID 为 `sha256:115e90a7442b3c92db0c566f8fc8a560e689878b67eace0236836681a14689ae`,运行库存为 9 package/904 files/9,479,647 bytes;read-only、drop ALL、no-new-privileges、128 MiB/0.5 CPU/32 PID 下的 `ql3 --version` 与 `ql3 setup --help` 已通过。这些数值只证明实现可构建,不冒充 commit-bound release evidence。Alpha workflow 将在同一原生 runner 上把 Application 与 operator 通过一次 `docker image save` 合并为去重的 `qinglong3-local-trial-kit-.docker.tar`,manifest 同时绑定两个 image ID、共同 archive SHA-256、source/version/architecture,并从镜像入口完成 fresh setup exact replay、Identity provision、challenge、首 Owner claim/ack、Application active/SIGTERM drain 和 SQLite integrity。Docker Desktop bind mount 根目录会把宿主 UID 501 映射为容器 root、子文件仍为 501,不能等价满足完整 POSIX lineage;本机失败被记录为平台不等价,未放宽门禁或伪装通过。D-408 转 Accepted 仍需同一提交的原生 Linux x64/arm64 journey 成功和完整回归;实际上传双架构 trial kit 仍需维护者显式授权,Public Release Set 是否正式增加 operator artifact 另行决策。 +- D-408/ADR-0503(进行中):阶段产物成熟度现在按真实部署用户旅程而非“已有 Dockerfile/镜像”裁决。新增独立 `qinglong3-local-operator` 短生命周期镜像,复用既有 `@qinglong/local-owner-cli` 的统一 `ql3` 入口而不新增 workspace package;它默认 `65532:65532`、无端口、无 listener/daemon/timer、network none,和常驻 Local Application 保持物理制品分离,因此 Owner/bootstrap authority 不进入 runtime closure,Edge 稳态资源零变化。本机基于未提交工作树构建的 arm64 operator 原型 ID 为 `sha256:115e90a7442b3c92db0c566f8fc8a560e689878b67eace0236836681a14689ae`,运行库存为 9 package/904 files/9,479,647 bytes;read-only、drop ALL、no-new-privileges、128 MiB/0.5 CPU/32 PID 下的 `ql3 --version` 与 `ql3 setup --help` 已通过。这些数值只证明实现可构建,不冒充 commit-bound release evidence。Alpha workflow 将在同一原生 runner 上把 Application 与 operator 通过一次 `docker image save` 合并为去重的 `qinglong3-local-trial-kit-.docker.tar`,manifest 同时绑定两个 image ID、共同 archive SHA-256、source/version/architecture,并从镜像入口完成 fresh setup exact replay、Identity provision、challenge、首 Owner claim/ack、Application active/SIGTERM drain 和 SQLite integrity。Docker Desktop bind mount 根目录会把宿主 UID 501 映射为容器 root、子文件仍为 501,不能等价满足完整 POSIX lineage;本机失败被记录为平台不等价,未放宽门禁或伪装通过。D-408 转 Accepted 仍需实际上传同一提交的原生 Linux x64/arm64 v2 trial kit;Public Release Set 的 operator artifact 决策已由 D-412 接受,但不等于正式 release 已产生。 - D-407/ADR-0502(已完成受审 Kubernetes live ceremony):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact ID;Cluster Control 按 credential record 精确选一把 key,未知 ID/material 一律 unavailable,绝不 fallback 或遍历,因此认证热路径仍为一次摘要。旧 raw pepper 只通过 `legacy-v1` singleton bridge 保持通用 CLI/进程兼容;Kubernetes Job 和常驻 Cluster Control manifest 已统一为 keyring-only,不再维护第二套单值 Secret 注入模式。新增 `pepper.references` 以数据库时间返回最多 64 个当前 latest active/unexpired credential ID 和 `hasMore`,只作为退休前检查,不执行删除。keyring 文件有 2 KiB、canonical/no-symlink/private/stable-read 边界,无 watcher/timer/新连接池;Edge/Standalone package、依赖与常驻资源零变化。远程 run `32893754795` 在 source `beb490c48c7d8ee4aee629924b5003fd8c73e9cb` 上完成 K3s `v1.34.3+k3s1` 三节点、CloudNativePG 1.30.0 三实例 PostgreSQL 18.4、三次真实双副本反亲和 rollout 和五次 `/api/v3` 认证 probe:old/new 在 overlap 期间均认证成功并因无 Project role 返回 403,旧代引用从 1 收敛至 0,contract 后 old 返回 401、new 仍返回 403;数据库保留 1 个旧代/3 个新代 credential version、四次授权拒绝与一次认证拒绝。首次远程失败还暴露了 no-symlink 运行时约束与 kubelet Atomic Writer 投影的架构冲突;最终部署用 hardened init container 固定解析一个 `..data` generation,将 CA/keyring 复制成 `0400` Pod-private 普通文件,常驻容器不再读取原始 symlink 投影。完整 backend 为 `1599 total / 1597 pass / 2 conditional skip / 0 fail`,治理/部署聚焦门为 88/88;source `f8934b401d724378fe5a6ea9dbe63e696b5480b9` 的远程 CI run `32898407637` 为 40/40,CloudNativePG failover、Plugin Package PostgreSQL OCI recovery、Secret rotation、Provider credential K3s/CNPG 等关键 live job 全部通过,独立 Kubernetes deployment run `32898407590` 同样通过。live 报告离线复审 `compatible=true/findings=[]`,SHA-256 为 `d9e9fd1395adcef60f7f360959fcad27a9b2f0b132869bc1c75043dedd400ff6`。该门关闭应用合同与权限边界,不冒充生产 control-plane HA、跨主机 STONITH/DR、加密 CSI 或外部 ingress TLS;material GC、持久 active catalog、索引/大规模查询计划、远程 UI/API 与双人复核仍是后续门禁。 diff --git a/docs/adr/ADR-0507-public-local-application-and-operator-release-pair.md b/docs/adr/ADR-0507-public-local-application-and-operator-release-pair.md new file mode 100644 index 00000000..1668b0e7 --- /dev/null +++ b/docs/adr/ADR-0507-public-local-application-and-operator-release-pair.md @@ -0,0 +1,65 @@ +# ADR-0507:Public Local Application 与 Operator 发布对 + +- 状态:Accepted(首份真实公开发布待受保护 tag) +- 日期:2026-08-27 +- 决策:D-412 +- 关联:ADR-0432、ADR-0437、ADR-0503、ADR-0506 + +## 背景 + +Local 用户的完整 3.0 旅程已经物理分离为常驻 Application 与短生命周期 operator。前者只运行 Edge/Standalone 数据面,后者通过统一 `ql3` 入口承担 setup、upgrade 和 recovery。Alpha Trial Kit 已同时携带两者,但正式 Public Release Set 仍只列出 Application。 + +这会产生不可接受的发布断层:受保护 workflow 可以签名并闭合一个无法独立完成 fresh setup 的“Local release”,操作者只能另找未被同一 source、catalog 和 tag closure 证明的管理镜像。把管理命令重新塞回常驻 Application 又会扩大低配路由器的攻击面和稳态资源闭包。 + +## 决策 + +### 1. Local family 是两个独立镜像的精确闭包 + +`local` scope 必须恰好包含: + +- `qinglong3-local-application`:唯一常驻 Edge/Standalone service; +- `qinglong3-local-operator`:只在显式管理动作期间运行的 Owner authority。 + +`all` scope 因此由 Local 两镜像和 Cluster 四镜像组成,共六个;`cluster` scope 仍为四个。两个 Local 镜像分别构建、执行双架构 OCI 证明、OS 漏洞门、digest 签名与 attestation,不能共享 digest record 或由其中一个代替另一个。 + +### 2. 角色验证必须显式记录 + +image record 升为 `qinglong/release-set-image-record@v2`。publisher 必须传入 candidate matrix 中的 exact `localRoleVerification`:Application 为 `application_rollout_verified`,operator 为 `operator_entrypoint_verified`,Cluster 角色为 `not_applicable`。Application 必须完成 Edge/Standalone rollout;operator 必须在 read-only、network none、drop-all、no-new-privileges、128 MiB、0.5 CPU、32 PID 边界内通过 `--version` 和 `setup --help`。 + +release-set 升为 `qinglong/release-set@v4`,OCI artifact/file media type 同步升为 `application/vnd.qinglong.release-set.v4+json`。旧孵化 schema 没有公开 3.0 消费者,因此失败关闭,不引入含糊的自动补全。 + +### 3. 目标选择绑定 operator,但不把它常驻化 + +Local catalog selection 升为 `qinglong/local-compose-release-image@v3`,必须同时绑定同一 owner、source 和 release-set 中的 Application/operator immutable digest。Compose revision 升为 `qinglong/local-compose-image-selection@v3`,保存 `operator_image` 作为 setup/upgrade/recovery authority,但 Compose service 仍只有 Application。 + +发布后的 Local gate 从公开 catalog 重建 selection,拉取并验证 operator 入口,再用 Application 完成 Edge 与 Standalone rollout。operator 不开端口、不运行 listener/daemon/timer,默认无网络;低配设备稳态不新增进程、RSS 或连接。 + +## 被拒绝的替代方案 + +### Public Release Set 只发布 Application + +拒绝。它没有覆盖 fresh setup 的真实用户旅程,并迫使用户使用 catalog 外管理制品。 + +### 将 Owner CLI 合并回 Application + +拒绝。它把高权限管理闭包带进每个常驻低配设备进程,破坏物理隔离。 + +### 把 operator 配置为 Compose sidecar + +拒绝。管理 authority 没有常驻需求;sidecar 会无谓增加稳态资源和攻击面。 + +## 影响 + +- Local 发布和首次拉取最多多一个 operator image;只运行 Application 的稳态不变; +- setup/upgrade/recovery 可在动作完成后删除 operator layer,下一次按 selection digest 重新获取; +- catalog、finalizer 和回退 revision 都能证明两个 Local 角色来自同一 release; +- 旧 v2 Local selection 与 Compose revision 失败关闭,孵化环境必须从 v4 catalog 重新物化; +- 该决策不产生真实 GHCR tag,也不把普通 CI artifact 声称为正式发布。 + +## 验证 + +- release candidate、record、set、catalog、publication closure 和 deployment-lock 正反向测试覆盖 `local=2`、`cluster=4`、`all=6`; +- 普通 CI 对 operator 增加独立 multi-arch OCI layout、SBOM/provenance 和 image-scoped Trivy 证据; +- 受保护 release workflow 在 record 前验证角色,在 catalog consumption 后再次验证 operator 入口; +- Local Owner CLI 的 prepare、upgrade、rollback 和 adopted deployment fixtures 保留同一 operator digest; +- 完整 backend、package boundary、Local Owner CLI 和静态 workflow 审计通过后才允许提交。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 261ee210..28a01288 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -510,6 +510,7 @@ | [ADR-0504](./ADR-0504-canonical-local-alpha-trial-kit-materialization.md) | Local Alpha Trial Kit 单一物化与离线审计 | Accepted | | [ADR-0505](./ADR-0505-pinned-alpine-openssl-runtime-security-patch.md) | 固定 Alpine OpenSSL 运行时安全补丁 | 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) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index a5c7efc5..bfdd122b 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -9,7 +9,7 @@ | Runtime Engineering Candidate | QingLong 开发者、设备兼容测试者 | 单个常驻镜像的 OS 漏洞策略、SBOM/库存、资源门和生命周期 | 验证 runtime 可加载、可启动;缺少管理制品时不能称用户 Alpha | | Local Alpha Trial Kit | amd64/arm64 路由器、NAS、单机试用者 | 同源 Application + 短生命周期 operator、fresh setup/Owner/active/stop 完整旅程、SBOM/库存与资源门 | 一个去重 Docker archive 完成隔离 fresh 试运行;不承诺生产升级 | | Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release | -| Public Release Set | 生产用户 | 受保护 tag、五镜像 multi-arch digest、签名/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。 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index d681cd26..0a3e5249 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -15,7 +15,7 @@ ghcr.io//qinglong3-release-catalog:v- ## 私有发布证据收据链 -当前长期 authority 是 `qinglong/release-set@v3`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含 +当前长期 authority 是 `qinglong/release-set@v4`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含 `worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v2`。每份收据绑定同一 version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与 cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 wall-clock:创建时仍必须以当前时钟完成 freshness gate,但 durable JSON 只绑定 @@ -23,7 +23,7 @@ cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 w 这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest, 但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact -从私有 job 交给 release-set job,随后完整嵌入 release-set v3 并由 durable catalog 长期保护。公开收据同时声明 +从私有 job 交给 release-set job,随后完整嵌入 release-set v4 并由 durable catalog 长期保护。公开收据同时声明 `freshnessValidatedAtCreation=true` 与 `durableValidationClockPublished=false`,避免把未发布的临时时钟伪装成可离线重放的现场证据。 创建时通过不等于可以无限期等待再闭合发布。`cluster|all` 的 release-set aggregate 与紧随其后的 independent audit 会各自从 runner 内部取得当前 @@ -42,8 +42,9 @@ attested 后写入。catalog 先存在而部署门失败时,不会产生正式 deployment-ready 版本公告。 `local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog -重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v2 selection。该 selection 不是只做 JSON -检查:同一个 immutable Local image 与 selection 会依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence +重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v3 selection。该 selection 同时绑定常驻 +Application 与短生命周期 operator,但不会把 operator 写成 Compose service。operator 先在无网络、只读、drop-all 和 128 MiB/0.5 CPU +边界内完成入口验证;随后同一个 immutable Application 与 selection 依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence collection 和 graceful stop。两个 content-free report 必须绑定同一 release-set、catalog manifest、consumption report 与 selection digest; 任一 Profile 失败都会阻断 Local release。 @@ -100,9 +101,9 @@ attestation 仍必须通过受保护 release tag 或受控 release repository | 部署类型 | release scope | 必须出现的镜像 | | --- | --- | --- | -| 低配路由器、Edge、Standalone | `local` | `local` | +| 低配路由器、Edge、Standalone | `local` | `local`、`local-operator` | | Kubernetes/Cluster | `cluster` | `control`、`control-ai`、`worker`、`admin` | -| 同时发布两族 | `all` | 上述五个镜像 | +| 同时发布两族 | `all` | 上述六个镜像 | Local 用户不需要下载 Cluster 镜像,也不依赖 CloudNativePG 或 Worker 私有发布证据。Cluster 运维者不能拿 Local image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin 必须有各自 digest。 @@ -451,12 +452,12 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计 1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable reference;discovery tag 无 authority。 2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中 - `qinglong/release-set@v3` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。 + `qinglong/release-set@v4` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。 3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。 4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。 static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。 5. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的 - `newName`/digest 不是最终 authority。Local 必须生成并审计 v2 service selection。两族输出都必须绑定同一 catalog manifest、 + `newName`/digest 不是最终 authority。Local 必须生成并审计 v3 Application/operator selection。两族输出都必须绑定同一 catalog manifest、 consumption report 与 release-set digest,并且只能消费 release set 中的 `@sha256:` reference。 6. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned kubectl/kubeconfig 和目标 cluster UID 绑定进 preflight/apply receipt;version/source/catalog tag 都只能用于发现,部署始终以 @@ -466,7 +467,9 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计 路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站 完成上述 ceremony 和 Local v2 selection 审计,再向设备传输已检查的 catalog-bound canonical JSON,并只把 `local` family 的 -immutable image reference 写入 compose/rollout。 +immutable Application image reference 写入 compose/rollout。operator 镜像只在 setup、upgrade、recovery 等显式管理动作期间按 digest 拉取并短暂运行; +它没有 listener、timer 或常驻 service,因此设备稳态仍只有 Application。设备不执行管理动作时可以不保留 operator layer,但每次管理动作必须先验证 +selection 中的 exact operator digest,不能用 Application 入口或宿主 Node 代替。 设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。 如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与 diff --git a/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts b/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts index 15cfb2ce..f678d7f7 100644 --- a/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts +++ b/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts @@ -22,11 +22,13 @@ import { } from '../foundation/files'; import { deploymentPaths } from '../foundation/render'; -const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v2'; +const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v3'; const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1'; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; const IMAGE_PATTERN = /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@sha256:[0-9a-f]{64}$/; +const OPERATOR_IMAGE_PATTERN = + /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-operator@sha256:[0-9a-f]{64}$/; const VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/; const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; @@ -63,6 +65,7 @@ function selectionContents(selection: Readonly): string { ` catalog_manifest_digest: ${selection.catalogManifestDigest}`, ` catalog_consumption_report_digest: ${selection.catalogConsumptionReportDigest}`, ` catalog_discovery_tag_authority: ${selection.catalogDiscoveryTagAuthority}`, + ` operator_image: ${selection.operatorImage}`, ` allow_root_service: ${selection.allowRootService}`, 'services:', ' qinglong3:', @@ -94,7 +97,7 @@ function parseSelection( label: string, ): Readonly { const match = - /^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v2\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\n release_selection_digest: (sha256:[a-f0-9]{64})\n release_set_digest: (sha256:[a-f0-9]{64})\n release_version: ([^\n]+)\n release_source_revision: ([a-f0-9]{40})\n release_source_ref: ([^\n]+)\n release_scope: (local|all)\n catalog_schema: qinglong\/release-catalog-consumption-ceremony@v1\n catalog_source_repository: ([^\n]+)\n catalog_workflow_identity: ([^\n]+)\n catalog_immutable_reference: ([^\n]+)\n catalog_manifest_digest: (sha256:[a-f0-9]{64})\n catalog_consumption_report_digest: (sha256:[a-f0-9]{64})\n catalog_discovery_tag_authority: none\n allow_root_service: (true|false)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n io\.qinglong\.release\.selection: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.set: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-manifest: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-report: "(sha256:[a-f0-9]{64})"\n$/.exec( + /^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v3\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\n release_selection_digest: (sha256:[a-f0-9]{64})\n release_set_digest: (sha256:[a-f0-9]{64})\n release_version: ([^\n]+)\n release_source_revision: ([a-f0-9]{40})\n release_source_ref: ([^\n]+)\n release_scope: (local|all)\n catalog_schema: qinglong\/release-catalog-consumption-ceremony@v1\n catalog_source_repository: ([^\n]+)\n catalog_workflow_identity: ([^\n]+)\n catalog_immutable_reference: ([^\n]+)\n catalog_manifest_digest: (sha256:[a-f0-9]{64})\n catalog_consumption_report_digest: (sha256:[a-f0-9]{64})\n catalog_discovery_tag_authority: none\n operator_image: ([^\n]+)\n allow_root_service: (true|false)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n io\.qinglong\.release\.selection: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.set: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-manifest: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-report: "(sha256:[a-f0-9]{64})"\n$/.exec( contents, ); if (!match) { @@ -122,15 +125,17 @@ function parseSelection( const catalogImmutableReference = match[14]!; const catalogManifestDigest = match[15]!; const catalogConsumptionReportDigest = match[16]!; - const allowRootService = match[17] === 'true'; - const image = match[18]!; - const labelGeneration = Number(match[19]); - const labelMutationId = match[20]; - const labelSelectionDigest = match[21]; - const labelReleaseSetDigest = match[22]; - const labelCatalogManifestDigest = match[23]; - const labelCatalogReportDigest = match[24]; + const operatorImage = match[17]!; + const allowRootService = match[18] === 'true'; + const image = match[19]!; + const labelGeneration = Number(match[20]); + const labelMutationId = match[21]; + const labelSelectionDigest = match[22]; + const labelReleaseSetDigest = match[23]; + const labelCatalogManifestDigest = match[24]; + const labelCatalogReportDigest = match[25]; const imageMatch = IMAGE_PATTERN.exec(image); + const operatorImageMatch = OPERATOR_IMAGE_PATTERN.exec(operatorImage); if ( generation < 1 || previousGeneration !== generation - 1 || @@ -150,6 +155,8 @@ function parseSelection( !DIGEST_PATTERN.test(catalogManifestDigest) || !DIGEST_PATTERN.test(catalogConsumptionReportDigest) || !imageMatch || + !operatorImageMatch || + operatorImageMatch[1] !== imageMatch[1] || catalogImmutableReference !== `ghcr.io/${imageMatch?.[1]}/qinglong3-release-catalog@${catalogManifestDigest}` || labelGeneration !== generation || @@ -168,6 +175,7 @@ function parseSelection( mutationId, changedAtMs, image, + operatorImage, allowRootService, selectionDigest, releaseSetDigest, @@ -235,6 +243,7 @@ function releaseAuthorityFromSelection( ): Readonly { return Object.freeze({ image: selection.image, + operatorImage: selection.operatorImage, allowRootService: selection.allowRootService, selectionDigest: selection.selectionDigest, releaseSetDigest: selection.releaseSetDigest, diff --git a/packages/ql3-local-owner-cli/src/deployment/compose/releaseSelection.ts b/packages/ql3-local-owner-cli/src/deployment/compose/releaseSelection.ts index 277c6900..6415faee 100644 --- a/packages/ql3-local-owner-cli/src/deployment/compose/releaseSelection.ts +++ b/packages/ql3-local-owner-cli/src/deployment/compose/releaseSelection.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; const MAX_SELECTION_BYTES = 64 * 1024; -const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v2'; +const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v3'; const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1'; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; const VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; @@ -11,6 +11,8 @@ const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/; const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const IMAGE_PATTERN = /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@(sha256:[a-f0-9]{64})$/; +const OPERATOR_IMAGE_PATTERN = + /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-operator@(sha256:[a-f0-9]{64})$/; export interface LocalComposeReleaseSelectionInput { readonly path: string; @@ -19,6 +21,7 @@ export interface LocalComposeReleaseSelectionInput { export interface LocalComposeReleaseAuthority { readonly image: string; + readonly operatorImage: string; readonly allowRootService: boolean; readonly selectionDigest: string; readonly releaseSetDigest: string; @@ -171,6 +174,7 @@ export function resolveLocalComposeReleaseSelection( 'schemaVersion', 'selectionDigest', 'service', + 'operator', 'verification', ], 'release selection', @@ -195,6 +199,7 @@ export function resolveLocalComposeReleaseSelection( 'catalog', ); exactKeys(value.service, ['allowRootService', 'image', 'kind'], 'service'); + exactKeys(value.operator, ['image', 'kind', 'network'], 'operator'); exactKeys( value.verification, [ @@ -210,9 +215,13 @@ export function resolveLocalComposeReleaseSelection( const release = value.release; const catalog = value.catalog; const service = value.service; + const operator = value.operator; const verification = value.verification; const image = typeof service.image === 'string' ? service.image : ''; const imageMatch = IMAGE_PATTERN.exec(image); + const operatorImage = + typeof operator.image === 'string' ? operator.image : ''; + const operatorImageMatch = OPERATOR_IMAGE_PATTERN.exec(operatorImage); const { selectionDigest, ...unsigned } = value; const calculatedDigest = digest(JSON.stringify(unsigned)); if ( @@ -239,10 +248,14 @@ export function resolveLocalComposeReleaseSelection( catalog.releaseSetDigest !== value.releaseSetDigest || catalog.discoveryTagAuthority !== 'none' || !imageMatch || + !operatorImageMatch || + operatorImageMatch[1] !== imageMatch[1] || catalog.immutableReference !== `ghcr.io/${imageMatch[1]}/qinglong3-release-catalog@${catalog.manifestDigest}` || service.kind !== 'compose' || service.allowRootService !== allowRootService || + operator.kind !== 'short-lived' || + operator.network !== 'none-by-default' || verification.releaseSet !== 'standalone_structure_identity_and_self_digest' || verification.sourceRecordsReplayed !== false || @@ -262,6 +275,7 @@ export function resolveLocalComposeReleaseSelection( expectedSelectionDigest: input.expectedSelectionDigest, authority: Object.freeze({ image, + operatorImage, allowRootService, selectionDigest, releaseSetDigest: value.releaseSetDigest, diff --git a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs index e500162c..96564d37 100644 --- a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs +++ b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs @@ -56,12 +56,16 @@ function releaseSelection(managementRoot, marker = 'a') { const image = `ghcr.io/example/qinglong3-local-application@sha256:${marker.repeat( 64, )}`; + const operatorImage = image.replace( + 'qinglong3-local-application', + 'qinglong3-local-operator', + ); const releaseSetDigest = prefixedDigest(`release-set:${image}`); const manifestDigest = prefixedDigest(`catalog-manifest:${image}`); const consumptionReportDigest = prefixedDigest(`catalog-report:${image}`); const unsigned = { schemaVersion: 1, - schema: 'qinglong/local-compose-release-image@v2', + schema: 'qinglong/local-compose-release-image@v3', release: { version: '3.0.0-alpha.0', sourceRevision: '3'.repeat(40), @@ -86,6 +90,11 @@ function releaseSelection(managementRoot, marker = 'a') { image, allowRootService: rootAcknowledgement(), }, + operator: { + kind: 'short-lived', + image: operatorImage, + network: 'none-by-default', + }, verification: { releaseSet: 'standalone_structure_identity_and_self_digest', sourceRecordsReplayed: false, diff --git a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs index b3ced77a..3ed5ac36 100644 --- a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs +++ b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs @@ -31,12 +31,16 @@ function sha256(value) { } function releaseSelectionForImage(managementRoot, image, allowRootService) { + const operatorImage = image.replace( + 'qinglong3-local-application', + 'qinglong3-local-operator', + ); const releaseSetDigest = sha256(`release-set:${image}`); const manifestDigest = sha256(`catalog-manifest:${image}`); const consumptionReportDigest = sha256(`catalog-report:${image}`); const unsigned = { schemaVersion: 1, - schema: 'qinglong/local-compose-release-image@v2', + schema: 'qinglong/local-compose-release-image@v3', release: { version: '3.0.0-alpha.0', sourceRevision: '3'.repeat(40), @@ -61,6 +65,11 @@ function releaseSelectionForImage(managementRoot, image, allowRootService) { image, allowRootService, }, + operator: { + kind: 'short-lived', + image: operatorImage, + network: 'none-by-default', + }, verification: { releaseSet: 'standalone_structure_identity_and_self_digest', sourceRecordsReplayed: false, @@ -393,7 +402,7 @@ function composeSelection({ const selected = JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8')); return [ 'x-qinglong-image-selection:', - ' schema: qinglong/local-compose-image-selection@v2', + ' schema: qinglong/local-compose-image-selection@v3', ` generation: ${generation}`, ` previous_generation: ${previousGeneration}`, ` rollback_target_generation: ${rollbackTargetGeneration}`, @@ -412,6 +421,7 @@ function composeSelection({ ` catalog_manifest_digest: ${selected.catalog.manifestDigest}`, ` catalog_consumption_report_digest: ${selected.catalog.consumptionReportDigest}`, ` catalog_discovery_tag_authority: ${selected.catalog.discoveryTagAuthority}`, + ` operator_image: ${selected.operator.image}`, ` allow_root_service: ${selected.service.allowRootService}`, 'services:', ' qinglong3:', @@ -1056,7 +1066,7 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async ); assert.match( fs.readFileSync(selectionPath, 'utf8'), - /^ schema: qinglong\/local-compose-image-selection@v2$/m, + /^ schema: qinglong\/local-compose-image-selection@v3$/m, ); assert.match( fs.readFileSync(selectionPath, 'utf8'), @@ -2200,6 +2210,16 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy fs.readFileSync(selectionPath, 'utf8'), new RegExp(`^ image: ${upgradedImage}$`, 'm'), ); + assert.match( + fs.readFileSync(selectionPath, 'utf8'), + new RegExp( + `^ operator_image: ${upgradedImage.replace( + '/qinglong3-local-application@', + '/qinglong3-local-operator@', + )}$`, + 'm', + ), + ); assert.equal(mode(path.join(revisions, '2.yaml')), 0o600); assert.equal(fs.readFileSync(composePath, 'utf8'), stableDescriptor); @@ -2224,6 +2244,16 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy assert.match(active, /^ generation: 3$/m); assert.match(active, /^ rollback_target_generation: 1$/m); assert.match(active, new RegExp(`^ image: ${state.composeImage}$`, 'm')); + assert.match( + active, + new RegExp( + `^ operator_image: ${state.composeImage.replace( + '/qinglong3-local-application@', + '/qinglong3-local-operator@', + )}$`, + 'm', + ), + ); assert.equal( fs .readFileSync(path.join(revisions, '2.yaml'), 'utf8') diff --git a/scripts/lib/ql3-local-release-selection-test-fixture.cjs b/scripts/lib/ql3-local-release-selection-test-fixture.cjs index 0f928247..80e9f934 100644 --- a/scripts/lib/ql3-local-release-selection-test-fixture.cjs +++ b/scripts/lib/ql3-local-release-selection-test-fixture.cjs @@ -12,9 +12,15 @@ function writeSyntheticLocalReleaseSelection(options) { const releaseSetDigest = sha256(`release-set:${options.image}`); const manifestDigest = sha256(`catalog-manifest:${options.image}`); const consumptionReportDigest = sha256(`catalog-report:${options.image}`); + const operatorImage = + options.operatorImage || + options.image.replace( + '/qinglong3-local-application@', + '/qinglong3-local-operator@', + ); const unsigned = { schemaVersion: 1, - schema: 'qinglong/local-compose-release-image@v2', + schema: 'qinglong/local-compose-release-image@v3', release: { version: '3.0.0-alpha.0', sourceRevision: options.sourceRevision ?? '3'.repeat(40), @@ -39,6 +45,11 @@ function writeSyntheticLocalReleaseSelection(options) { image: options.image, allowRootService: options.allowRootService, }, + operator: { + kind: 'short-lived', + image: operatorImage, + network: 'none-by-default', + }, verification: { releaseSet: 'standalone_structure_identity_and_self_digest', sourceRecordsReplayed: false, diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 18b6e0bd..ddac2a65 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -168,6 +168,11 @@ function auditClusterImageCiWorkflow( dockerfile: 'deploy/containers/ql3-local-application/Dockerfile', target: 'runtime', }, + { + image: 'local-operator', + dockerfile: 'deploy/containers/ql3-local-operator/Dockerfile', + target: 'runtime', + }, { image: 'worker', dockerfile: 'deploy/containers/ql3-worker/Dockerfile', @@ -185,7 +190,7 @@ function auditClusterImageCiWorkflow( JSON.stringify(expectedOciMatrix) ) { throw new Error( - 'image CI matrices must contain only exact control/control-ai/admin/local/worker amd64/arm64 evidence targets', + 'image CI matrices must contain only exact control/control-ai/admin/local/local-operator/worker amd64/arm64 evidence targets', ); } const expectedTrivyInputs = { @@ -205,7 +210,13 @@ function auditClusterImageCiWorkflow( localImageJob, 'qinglong3-local-application:ci-${{ matrix.image_arch }}', '${{ runner.temp }}/ql3-local-${{ matrix.image_arch }}.trivyignore.yaml', - 'local', + 'Local application', + ], + [ + localImageJob, + 'qinglong3-local-operator:ci-${{ matrix.image_arch }}', + '${{ runner.temp }}/ql3-local-operator-${{ matrix.image_arch }}.trivyignore.yaml', + 'Local operator', ], [ clusterImageJob, @@ -217,7 +228,8 @@ function auditClusterImageCiWorkflow( const step = job?.steps?.find( (entry) => entry.uses === - 'aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25', + 'aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25' && + entry.with?.['image-ref'] === imageRef, ); if ( !step || @@ -246,14 +258,19 @@ function auditClusterImageCiWorkflow( requireOccurrences( source, /node scripts\/ql3-image-os-vulnerability-policy\.cjs/g, - 2, + 3, 'native image CI must materialize reviewed image-scoped Trivy exceptions', ); requirePattern( source, - /--image=local\s+--output=\$\{\{ runner\.temp \}\}\/ql3-local-\$\{\{ matrix\.image_arch \}\}\.trivyignore\.yaml/, + /--image=local \\\s+--output=\$\{\{ runner\.temp \}\}\/ql3-local-\$\{\{ matrix\.image_arch \}\}\.trivyignore\.yaml/, 'local native image CI must materialize the local exception view', ); + requirePattern( + source, + /--image=local-operator \\\s+--output=\$\{\{ runner\.temp \}\}\/ql3-local-operator-\$\{\{ matrix\.image_arch \}\}\.trivyignore\.yaml/, + 'Local operator native image CI must materialize its own exception view', + ); requirePattern( source, /--image=\$\{\{ matrix\.image \}\}\s+--output=\$\{\{ runner\.temp \}\}\/ql3-\$\{\{ matrix\.image \}\}-\$\{\{ matrix\.image_arch \}\}\.trivyignore\.yaml/, @@ -489,8 +506,8 @@ function auditClusterImageCiWorkflow( ); requirePattern( source, - /- image: control\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime\s+- image: control-ai\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime-ai\s+- image: admin\s+dockerfile: deploy\/containers\/ql3-cluster-admin\/Dockerfile\s+target: runtime\s+- image: local\s+dockerfile: deploy\/containers\/ql3-local-application\/Dockerfile\s+target: runtime\s+- image: worker\s+dockerfile: deploy\/containers\/ql3-worker\/Dockerfile\s+target: runtime/, - 'OCI evidence CI must build independent control, control-ai, admin, local and worker images', + /- image: control\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime\s+- image: control-ai\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime-ai\s+- image: admin\s+dockerfile: deploy\/containers\/ql3-cluster-admin\/Dockerfile\s+target: runtime\s+- image: local\s+dockerfile: deploy\/containers\/ql3-local-application\/Dockerfile\s+target: runtime\s+- image: local-operator\s+dockerfile: deploy\/containers\/ql3-local-operator\/Dockerfile\s+target: runtime\s+- image: worker\s+dockerfile: deploy\/containers\/ql3-worker\/Dockerfile\s+target: runtime/, + 'OCI evidence CI must build independent control, control-ai, admin, local, local-operator and worker images', ); requirePattern( source, @@ -519,7 +536,14 @@ function auditClusterImageCiWorkflow( 'local image CI must generate and inventory-check the exact local SBOM profile', ); return { - images: ['control', 'control-ai', 'admin', 'local', 'worker'], + images: [ + 'control', + 'control-ai', + 'admin', + 'local', + 'local-operator', + 'worker', + ], nativeArchitectures: ['amd64', 'arm64'], runtimeInventory: true, clusterAdminProductFacade: true, @@ -844,7 +868,7 @@ function auditReleaseWorkflow(source) { ); } if ( - !/ql3-release-set-contract\.cjs[\s\S]*--mode=record-image[\s\S]*--version="\$\{RELEASE_VERSION\}"[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--repository-owner="\$\{owner\}"[\s\S]*--candidate="\$\{RUNNER_TEMP\}\/\$\{\{ matrix\.repository \}\}-release-candidate-contract\.json"[\s\S]*--image="\$\{\{ matrix\.image \}\}"[\s\S]*--digest="\$\{DIGEST\}"/.test( + !/ql3-release-set-contract\.cjs[\s\S]*--mode=record-image[\s\S]*--version="\$\{RELEASE_VERSION\}"[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--repository-owner="\$\{owner\}"[\s\S]*--candidate="\$\{RUNNER_TEMP\}\/\$\{\{ matrix\.repository \}\}-release-candidate-contract\.json"[\s\S]*--image="\$\{\{ matrix\.image \}\}"[\s\S]*--local-role-verification="\$\{\{ matrix\.local_role_verification \}\}"[\s\S]*--digest="\$\{DIGEST\}"/.test( publishSteps[recordIndex]?.run ?? '', ) || JSON.stringify(publishSteps[recordUploadIndex]?.with) !== @@ -991,10 +1015,10 @@ function auditReleaseWorkflow(source) { localCatalogDeploymentSteps[5]?.run ?? '', ) || localCatalogDeploymentSteps[6]?.id !== 'local-selection' || - !/ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-create[\s\S]*--consumption-bundle="\$\{CONSUMPTION_BUNDLE\}"[\s\S]*--allow-root-service=false[\s\S]*--output="\$\{selection\}"[\s\S]*ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-audit[\s\S]*--selection="\$\{selection\}"[\s\S]*selection-digest=\$\{selection\.selectionDigest\}[\s\S]*image=\$\{selection\.service\.image\}/u.test( + !/ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-create[\s\S]*--consumption-bundle="\$\{CONSUMPTION_BUNDLE\}"[\s\S]*--allow-root-service=false[\s\S]*--output="\$\{selection\}"[\s\S]*ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-audit[\s\S]*--selection="\$\{selection\}"[\s\S]*selection-digest=\$\{selection\.selectionDigest\}[\s\S]*image=\$\{selection\.service\.image\}[\s\S]*operator-image=\$\{selection\.operator\.image\}/u.test( localCatalogDeploymentSteps[6]?.run ?? '', ) || - !/docker pull "\$\{IMAGE\}"[\s\S]*for profile in edge standalone[\s\S]*ql3-local-compose-rollout-live-contract\.cjs[\s\S]*--image="\$\{IMAGE\}"[\s\S]*--profile="\$\{profile\}"[\s\S]*--release-selection="\$\{RELEASE_SELECTION\}"[\s\S]*--expected-selection-digest="\$\{SELECTION_DIGEST\}"[\s\S]*verified_release_catalog[\s\S]*catalogConsumptionDigest/u.test( + !/docker pull "\$\{IMAGE\}"[\s\S]*docker pull "\$\{OPERATOR_IMAGE\}"[\s\S]*"\$\{OPERATOR_IMAGE\}" --version[\s\S]*"\$\{OPERATOR_IMAGE\}" setup --help[\s\S]*for profile in edge standalone[\s\S]*ql3-local-compose-rollout-live-contract\.cjs[\s\S]*--image="\$\{IMAGE\}"[\s\S]*--profile="\$\{profile\}"[\s\S]*--release-selection="\$\{RELEASE_SELECTION\}"[\s\S]*--expected-selection-digest="\$\{SELECTION_DIGEST\}"[\s\S]*verified_release_catalog[\s\S]*catalogConsumptionDigest/u.test( localCatalogDeploymentSteps[7]?.run ?? '', ) || localCatalogDeploymentSteps[8]?.if !== 'always()' || @@ -1540,7 +1564,14 @@ function auditReleaseWorkflow(source) { immutableArtifactRetentionDays: 1, attestedToPublishedDigest: true, }, - images: ['control', 'control-ai', 'admin', 'worker', 'local'], + images: [ + 'control', + 'control-ai', + 'admin', + 'worker', + 'local', + 'local-operator', + ], platforms: ['linux/amd64', 'linux/arm64'], keylessSignature: true, buildkitAttestations: ['sbom', 'provenance'], @@ -1578,7 +1609,7 @@ function auditReleaseWorkflow(source) { }, durableCatalog: { repository: 'qinglong3-release-catalog', - artifactType: 'application/vnd.qinglong.release-set.v3+json', + artifactType: 'application/vnd.qinglong.release-set.v4+json', planSchema: 'qinglong/release-catalog-plan@v2', receiptSchema: 'qinglong/release-catalog-receipt@v2', tagInventoryDecisionSchema: diff --git a/scripts/ql3-cluster-oci-layout-audit.cjs b/scripts/ql3-cluster-oci-layout-audit.cjs index db1722ab..57c14713 100644 --- a/scripts/ql3-cluster-oci-layout-audit.cjs +++ b/scripts/ql3-cluster-oci-layout-audit.cjs @@ -241,6 +241,39 @@ function expectedImageConfig(architecture, revision, image) { }, }; } + if (image === 'local-operator') { + return { + architecture, + os: 'linux', + config: { + User: '65532:65532', + Env: [ + 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + 'NODE_VERSION=24.18.0', + 'YARN_VERSION=1.22.22', + 'NODE_ENV=production', + ], + Entrypoint: [ + 'node', + '/opt/qinglong/node_modules/@qinglong/local-owner-cli/dist/product-cli/cli.js', + ], + WorkingDir: '/opt/qinglong', + Labels: { + 'io.qinglong.authority': 'local-owner-management', + 'io.qinglong.lifecycle': 'short-lived', + 'io.qinglong.network': 'none-by-default', + 'org.opencontainers.image.description': + 'QingLong 3.0 short-lived Local management authority', + 'org.opencontainers.image.licenses': 'Apache-2.0', + 'org.opencontainers.image.revision': revision, + 'org.opencontainers.image.source': + 'https://github.com/whyour/qinglong', + 'org.opencontainers.image.title': 'QingLong 3.0 Local Operator', + 'org.opencontainers.image.version': QL3_VERSION, + }, + }, + }; + } const isControl = image === 'control' || image === 'control-ai'; const isControlAi = image === 'control-ai'; return { @@ -529,7 +562,9 @@ function auditClusterOciLayout(options = {}) { const image = resolveImageProfile(options.image).id; const expectedPlatforms = options.expectedPlatforms || EXPECTED_PLATFORMS; const maximumPlatformBytes = - image === 'local' ? 128 * 1024 * 1024 : 512 * 1024 * 1024; + image === 'local' || image === 'local-operator' + ? 128 * 1024 * 1024 + : 512 * 1024 * 1024; if ( !path.isAbsolute(layoutRoot) || typeof expectedRevision !== 'string' || diff --git a/scripts/ql3-deployment-lock-contract.cjs b/scripts/ql3-deployment-lock-contract.cjs index 9b4954ed..6c3da5e3 100644 --- a/scripts/ql3-deployment-lock-contract.cjs +++ b/scripts/ql3-deployment-lock-contract.cjs @@ -14,7 +14,7 @@ const { const { inspectReleaseSet } = require('./ql3-release-set-contract.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v2'; +const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v3'; const KUBERNETES_LOCK_SCHEMA = 'qinglong/kubernetes-deployment-lock@v2'; const MAX_RELEASE_SET_BYTES = 1024 * 1024; const MAX_MANIFEST_BYTES = 8 * 1024 * 1024; @@ -193,6 +193,7 @@ function createLocalSelection(releaseSet, options) { fail('allow-root-service must be an explicit boolean'); } const local = imageByName(releaseSet, 'local'); + const operator = imageByName(releaseSet, 'local-operator'); const unsigned = { schemaVersion: 1, schema: LOCAL_SELECTION_SCHEMA, @@ -205,6 +206,11 @@ function createLocalSelection(releaseSet, options) { image: local.reference, allowRootService: options.allowRootService, }, + operator: { + kind: 'short-lived', + image: operator.reference, + network: 'none-by-default', + }, verification: { releaseSet: inspection.verification, sourceRecordsReplayed: inspection.sourceRecordsReplayed, @@ -233,6 +239,7 @@ function auditLocalSelection(actual, releaseSet, options) { catalogManifestDigest: actual.catalog.manifestDigest, immutableReference: actual.catalog.immutableReference, image: actual.service.image, + operatorImage: actual.operator.image, networkAccess: false, deploymentMutation: false, }); diff --git a/scripts/ql3-image-os-vulnerability-policy.cjs b/scripts/ql3-image-os-vulnerability-policy.cjs index 081901f4..71360200 100644 --- a/scripts/ql3-image-os-vulnerability-policy.cjs +++ b/scripts/ql3-image-os-vulnerability-policy.cjs @@ -12,6 +12,7 @@ const IMAGES = Object.freeze([ 'control', 'control-ai', 'local', + 'local-operator', 'worker', ]); const MAX_POLICY_BYTES = 256 * 1024; @@ -108,6 +109,7 @@ function auditImageOsVulnerabilityPolicy( control: 0, 'control-ai': 0, local: 0, + 'local-operator': 0, worker: 0, }), }); @@ -118,6 +120,7 @@ function auditImageOsVulnerabilityPolicy( control: 0, 'control-ai': 0, local: 0, + 'local-operator': 0, worker: 0, }; const seen = new Set(); diff --git a/scripts/ql3-local-compose-rollout-live-contract.cjs b/scripts/ql3-local-compose-rollout-live-contract.cjs index ad485214..6a2a6224 100644 --- a/scripts/ql3-local-compose-rollout-live-contract.cjs +++ b/scripts/ql3-local-compose-rollout-live-contract.cjs @@ -120,6 +120,14 @@ function resolveReleaseSelection(input, temporaryRoot, uid) { const unsigned = { ...selection }; delete unsigned.selectionDigest; const computedDigest = sha256(JSON.stringify(unsigned)); + const applicationImageMatch = + /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@sha256:[a-f0-9]{64}$/u.exec( + selection.service?.image ?? '', + ); + const operatorImageMatch = + /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-operator@sha256:[a-f0-9]{64}$/u.exec( + selection.operator?.image ?? '', + ); if ( !stat.isFile() || stat.isSymbolicLink() || @@ -135,7 +143,7 @@ function resolveReleaseSelection(input, temporaryRoot, uid) { computedDigest !== input.releaseSelection.expectedSelectionDigest || selection.selectionDigest !== computedDigest || selection.schemaVersion !== 1 || - selection.schema !== 'qinglong/local-compose-release-image@v2' || + selection.schema !== 'qinglong/local-compose-release-image@v3' || !/^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u.test( selection.release?.version ?? '', ) || @@ -148,6 +156,11 @@ function resolveReleaseSelection(input, temporaryRoot, uid) { selection.service?.kind !== 'compose' || selection.service?.image !== input.image || selection.service?.allowRootService !== (uid === 0) || + !applicationImageMatch || + !operatorImageMatch || + applicationImageMatch[1] !== operatorImageMatch[1] || + selection.operator?.kind !== 'short-lived' || + selection.operator?.network !== 'none-by-default' || selection.catalog?.schema !== 'qinglong/release-catalog-consumption-ceremony@v1' || !DIGEST_PATTERN.test(selection.catalog?.manifestDigest ?? '') || diff --git a/scripts/ql3-release-candidate-contract.cjs b/scripts/ql3-release-candidate-contract.cjs index 9789d3d1..7b619bfa 100644 --- a/scripts/ql3-release-candidate-contract.cjs +++ b/scripts/ql3-release-candidate-contract.cjs @@ -38,6 +38,13 @@ const LOCAL_IMAGES = Object.freeze([ runtime_root: 'deploy/containers/ql3-local-application/runtime-dependencies', }), + Object.freeze({ + image: 'local-operator', + repository: 'qinglong3-local-operator', + dockerfile: 'deploy/containers/ql3-local-operator/Dockerfile', + target: 'runtime', + runtime_root: 'deploy/containers/ql3-local-operator/runtime-dependencies', + }), ]); const CLUSTER_IMAGES = Object.freeze([ Object.freeze({ @@ -208,7 +215,15 @@ function createReleaseCandidateContract(options) { version: manifest.version, }); }); - const publishMatrix = images.map(({ dockerfile, target, ...image }) => image); + const publishMatrix = images.map(({ dockerfile, target, ...image }) => ({ + ...image, + local_role_verification: + image.image === 'local' + ? 'application_rollout_verified' + : image.image === 'local-operator' + ? 'operator_entrypoint_verified' + : 'not_applicable', + })); const osMatrix = images.flatMap((image) => nativeArchitectures.map((architecture) => ({ image: image.image, @@ -285,7 +300,7 @@ function createReleaseCandidateContract(options) { 'durable-oci-release-catalog', 'offline-deployment-lock-materialization', ...(options.releaseScope !== 'cluster' - ? ['edge-and-standalone-rollout'] + ? ['edge-and-standalone-rollout', 'local-operator-entrypoint'] : []), ...(options.releaseScope !== 'local' ? [ diff --git a/scripts/ql3-release-catalog-contract.cjs b/scripts/ql3-release-catalog-contract.cjs index 9dbca814..12aa4896 100644 --- a/scripts/ql3-release-catalog-contract.cjs +++ b/scripts/ql3-release-catalog-contract.cjs @@ -16,7 +16,7 @@ const CATALOG_PUBLICATION_DECISION_SCHEMA = const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v2'; const CATALOG_TAG_INVENTORY_DECISION_SCHEMA = 'qinglong/release-catalog-tag-inventory-decision@v1'; -const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v3+json'; +const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v4+json'; const FILE_MEDIA_TYPE = ARTIFACT_TYPE; const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json'; const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json'; diff --git a/scripts/ql3-release-publication-closure-contract.cjs b/scripts/ql3-release-publication-closure-contract.cjs index 30c827df..d737f5aa 100644 --- a/scripts/ql3-release-publication-closure-contract.cjs +++ b/scripts/ql3-release-publication-closure-contract.cjs @@ -311,10 +311,10 @@ function validatePublicationPlan(plan) { !Array.isArray(plan.images) || plan.images.length !== (plan.release.scope === 'local' - ? 1 + ? 2 : plan.release.scope === 'cluster' ? 4 - : 5) + : 6) ) { fail('publication plan shape is invalid'); } diff --git a/scripts/ql3-release-set-contract.cjs b/scripts/ql3-release-set-contract.cjs index 1ebf7fff..5f24d9f7 100644 --- a/scripts/ql3-release-set-contract.cjs +++ b/scripts/ql3-release-set-contract.cjs @@ -19,8 +19,8 @@ const { const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); -const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1'; -const RELEASE_SET_SCHEMA = 'qinglong/release-set@v3'; +const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v2'; +const RELEASE_SET_SCHEMA = 'qinglong/release-set@v4'; const MAX_JSON_BYTES = 1024 * 1024; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u; @@ -135,6 +135,14 @@ function selectedImage(candidate, imageName) { return matches[0]; } +function expectedLocalRoleVerification(imageName) { + return imageName === 'local' + ? 'application_rollout_verified' + : imageName === 'local-operator' + ? 'operator_entrypoint_verified' + : 'not_applicable'; +} + function deriveVerifiedImageRecord( candidate, repositoryOwner, @@ -166,7 +174,7 @@ function deriveVerifiedImageRecord( remoteDigestVerified: true, keylessSignatureVerified: true, githubAttestations: [...REQUIRED_IMAGE_ATTESTATIONS], - localProfileRolloutVerified: selected.image === 'local', + localRoleVerification: expectedLocalRoleVerification(selected.image), tagPromotion: 'deferred_to_complete_release_set', }, }; @@ -179,6 +187,12 @@ function deriveVerifiedImageRecord( function createVerifiedImageRecord(options) { const candidate = verifyCandidate(options.candidate, options); const owner = normalizeRepositoryOwner(options.repositoryOwner); + if ( + options.localRoleVerification !== + expectedLocalRoleVerification(options.image) + ) { + fail('local role verification differs from the selected image role'); + } return deriveVerifiedImageRecord( candidate, owner, @@ -214,7 +228,7 @@ function validateImageRecord(record, candidate, repositoryOwner) { 'remoteDigestVerified', 'keylessSignatureVerified', 'githubAttestations', - 'localProfileRolloutVerified', + 'localRoleVerification', 'tagPromotion', ]) ) { @@ -306,10 +320,14 @@ function createReleaseSet(options) { return record; }); const localImages = orderedRecords - .filter((record) => record.image.name === 'local') + .filter((record) => + LOCAL_IMAGES.some((entry) => entry.image === record.image.name), + ) .map((record) => record.image.name); const clusterImages = orderedRecords - .filter((record) => record.image.name !== 'local') + .filter((record) => + CLUSTER_IMAGES.some((entry) => entry.image === record.image.name), + ) .map((record) => record.image.name); const images = orderedRecords.map((record) => ({ ...record.image, @@ -497,12 +515,16 @@ function inspectReleaseSet(actual, options) { local: { selected: ['local', 'all'].includes(options.releaseScope), profiles: ['edge', 'standalone'], - images: expectedNames.filter((name) => name === 'local'), + images: expectedNames.filter((name) => + LOCAL_IMAGES.some((entry) => entry.image === name), + ), }, cluster: { selected: ['cluster', 'all'].includes(options.releaseScope), profiles: ['cluster', 'worker-edge', 'worker-node'], - images: expectedNames.filter((name) => name !== 'local'), + images: expectedNames.filter((name) => + CLUSTER_IMAGES.some((entry) => entry.image === name), + ), }, }; if ( @@ -619,7 +641,7 @@ function parseArguments(argv) { const common = ['candidate', ...identity]; const expected = values.mode === 'record-image' - ? [...common, 'digest', 'image', 'output'] + ? [...common, 'digest', 'image', 'local-role-verification', 'output'] : values.mode === 'aggregate' ? [...common, 'evidence-receipts', 'output', 'records'] : values.mode === 'audit' @@ -643,6 +665,9 @@ function parseArguments(argv) { releaseScope: values['release-scope'], repositoryOwner: values['repository-owner'], ...(values.image ? { image: values.image } : {}), + ...(values['local-role-verification'] + ? { localRoleVerification: values['local-role-verification'] } + : {}), ...(values.digest ? { digest: values.digest } : {}), ...(values.records ? { records: values.records } : {}), ...(values['evidence-receipts'] diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 60636e39..e3c0c009 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -23,7 +23,14 @@ const releaseSource = fs.readFileSync( test('accepts the reviewed native CI and digest release contracts', () => { assert.deepEqual(auditClusterImageRelease(root), { ci: { - images: ['control', 'control-ai', 'admin', 'local', 'worker'], + images: [ + 'control', + 'control-ai', + 'admin', + 'local', + 'local-operator', + 'worker', + ], nativeArchitectures: ['amd64', 'arm64'], runtimeInventory: true, clusterAdminProductFacade: true, @@ -85,7 +92,14 @@ test('accepts the reviewed native CI and digest release contracts', () => { immutableArtifactRetentionDays: 1, attestedToPublishedDigest: true, }, - images: ['control', 'control-ai', 'admin', 'worker', 'local'], + images: [ + 'control', + 'control-ai', + 'admin', + 'worker', + 'local', + 'local-operator', + ], platforms: ['linux/amd64', 'linux/arm64'], keylessSignature: true, buildkitAttestations: ['sbom', 'provenance'], @@ -123,7 +137,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { }, durableCatalog: { repository: 'qinglong3-release-catalog', - artifactType: 'application/vnd.qinglong.release-set.v3+json', + artifactType: 'application/vnd.qinglong.release-set.v4+json', planSchema: 'qinglong/release-catalog-plan@v2', receiptSchema: 'qinglong/release-catalog-receipt@v2', tagInventoryDecisionSchema: diff --git a/test/back/ql3ClusterOciLayoutAudit.test.cjs b/test/back/ql3ClusterOciLayoutAudit.test.cjs index 1d63b5ba..49524fcb 100644 --- a/test/back/ql3ClusterOciLayoutAudit.test.cjs +++ b/test/back/ql3ClusterOciLayoutAudit.test.cjs @@ -29,6 +29,7 @@ function createFixture(t, options = {}) { const isControl = image === 'control' || image === 'control-ai'; const isControlAi = image === 'control-ai'; const isLocal = image === 'local'; + const isLocalOperator = image === 'local-operator'; const isWorker = image === 'worker'; const layoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-oci-layout-')); t.after(() => fs.rmSync(layoutRoot, { recursive: true, force: true })); @@ -103,7 +104,7 @@ function createFixture(t, options = {}) { ? '0:0' : isWorker ? '65532:65532' - : isLocal + : isLocal || isLocalOperator ? '65532:65532' : '10001:10001', ...(isControl ? { ExposedPorts: { '5800/tcp': {} } } : {}), @@ -119,6 +120,8 @@ function createFixture(t, options = {}) { ? '/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js' : isLocal ? '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js' + : isLocalOperator + ? '/opt/qinglong/node_modules/@qinglong/local-owner-cli/dist/product-cli/cli.js' : isControl ? isControlAi ? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js' @@ -144,8 +147,17 @@ function createFixture(t, options = {}) { 'io.qinglong.profile': 'edge,standalone', } : {}), + ...(isLocalOperator + ? { + 'io.qinglong.authority': 'local-owner-management', + 'io.qinglong.lifecycle': 'short-lived', + 'io.qinglong.network': 'none-by-default', + } + : {}), 'org.opencontainers.image.description': isLocal ? 'QingLong 3.0 AI-excluded Edge and Standalone runtime' + : isLocalOperator + ? 'QingLong 3.0 short-lived Local management authority' : isWorker ? 'QingLong 3.0 headless Remote Worker runtime' : isControl @@ -159,6 +171,8 @@ function createFixture(t, options = {}) { 'https://github.com/whyour/qinglong', 'org.opencontainers.image.title': isLocal ? 'QingLong 3.0 Local Application' + : isLocalOperator + ? 'QingLong 3.0 Local Operator' : isWorker ? 'QingLong 3.0 Worker' : isControl @@ -166,7 +180,11 @@ function createFixture(t, options = {}) { ? 'QingLong 3.0 Cluster Control AI' : 'QingLong 3.0 Cluster Control' : 'QingLong 3.0 Cluster Admin', - ...(isLocal || isWorker || isControl || image === 'admin' + ...(isLocal || + isLocalOperator || + isWorker || + isControl || + image === 'admin' ? { 'org.opencontainers.image.version': version, } @@ -379,6 +397,21 @@ test('accepts the AI-excluded local image and attestation closure', (t) => { ); }); +test('accepts the short-lived Local operator image and attestation closure', (t) => { + const report = auditClusterOciLayout({ + root, + layoutRoot: createFixture(t, { image: 'local-operator' }), + expectedRevision: revision, + image: 'local-operator', + }); + assert.equal(report.image, 'local-operator'); + assert.equal(report.maximumPlatformBytes, 128 * 1024 * 1024); + assert.deepEqual( + report.platforms.map((entry) => entry.spdxApplicationPackages), + [9, 9], + ); +}); + test('accepts the headless Worker image and attestation closure', (t) => { const report = auditClusterOciLayout({ root, diff --git a/test/back/ql3DeploymentLockContract.test.cjs b/test/back/ql3DeploymentLockContract.test.cjs index e6c686e6..000c03ee 100644 --- a/test/back/ql3DeploymentLockContract.test.cjs +++ b/test/back/ql3DeploymentLockContract.test.cjs @@ -93,6 +93,12 @@ function releaseSet(scope) { ...identity, releaseScope: scope, image: entry.image, + localRoleVerification: + entry.image === 'local' + ? 'application_rollout_verified' + : entry.image === 'local-operator' + ? 'operator_entrypoint_verified' + : 'not_applicable', digest: `sha256:${String(index + 1).repeat(64)}`, }), ); @@ -429,7 +435,7 @@ test('selects catalog-backed live artifacts only for one complete environment', assert.equal(artifacts.report.requiredImages.length, 4); }); -test('selects one immutable Local Compose image without adding device work', () => { +test('selects immutable Local runtime and short-lived operator images without adding steady-state device work', () => { for (const scope of ['local', 'all']) { const set = releaseSet(scope); const selection = createLocalSelection( @@ -437,7 +443,7 @@ test('selects one immutable Local Compose image without adding device work', () options(set, { allowRootService: false }), ); assert.equal(selection.deploymentFamily, 'local'); - assert.equal(selection.schema, 'qinglong/local-compose-release-image@v2'); + assert.equal(selection.schema, 'qinglong/local-compose-release-image@v3'); assert.equal(selection.releaseSetDigest, set.releaseSetDigest); assert.equal( selection.catalog.releaseSetDigest, @@ -451,6 +457,9 @@ test('selects one immutable Local Compose image without adding device work', () assert.equal(selection.service.kind, 'compose'); assert.equal(selection.service.image, references(set).local); assert.equal(selection.service.allowRootService, false); + assert.equal(selection.operator.kind, 'short-lived'); + assert.equal(selection.operator.image, references(set)['local-operator']); + assert.equal(selection.operator.network, 'none-by-default'); assert.equal(selection.verification.networkAccess, false); assert.equal(selection.verification.deploymentMutation, false); assert.equal( @@ -500,6 +509,18 @@ test('Local selection rejects cluster scope, implicit root policy and drift', () ), /differs from the verified release set/, ); + const operatorDrifted = JSON.parse(JSON.stringify(selection)); + operatorDrifted.operator.image = + 'ghcr.io/example/qinglong3-local-operator:latest'; + assert.throws( + () => + auditLocalSelection( + operatorDrifted, + localSet, + options(localSet, { allowRootService: true }), + ), + /differs from the verified release set/, + ); }); test('deployment materialization rejects missing or mismatched catalog authority', () => { @@ -947,7 +968,7 @@ test('deployment-lock CLI cannot regress to a loose release-set input', () => { assert.match(source, /'consumption-bundle'/u); assert.match(source, /'source-repository'/u); assert.doesNotMatch(source, /['"]release-set['"]/u); - assert.match(source, /qinglong\/local-compose-release-image@v2/u); + assert.match(source, /qinglong\/local-compose-release-image@v3/u); assert.match(source, /qinglong\/kubernetes-deployment-lock@v2/u); }); diff --git a/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs b/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs index 83bcd8a7..2a7d9a7c 100644 --- a/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs +++ b/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs @@ -56,6 +56,7 @@ test('accepts the empty fail-closed production exception policy', () => { control: 0, 'control-ai': 0, local: 0, + 'local-operator': 0, worker: 0, }, }); diff --git a/test/back/ql3ReleaseCandidateContract.test.cjs b/test/back/ql3ReleaseCandidateContract.test.cjs index db859084..fee1e63d 100644 --- a/test/back/ql3ReleaseCandidateContract.test.cjs +++ b/test/back/ql3ReleaseCandidateContract.test.cjs @@ -31,7 +31,7 @@ test('freezes an independent low-resource local release family', () => { }); assert.deepEqual( contract.images.map((entry) => entry.image), - ['local'], + ['local', 'local-operator'], ); assert.equal(contract.releasePlan.clusterEvidenceRequired, false); assert.deepEqual(contract.deploymentFamilies.local.profiles, [ @@ -78,7 +78,7 @@ test('freezes an independent low-resource local release family', () => { contractDigest: contract.contractDigest, releaseScope: 'local', workspacePackageCount: 18, - images: ['local'], + images: ['local', 'local-operator'], clusterEvidenceRequired: false, }, ); @@ -141,12 +141,16 @@ test('combines local and cluster families without weakening either gate', () => }); assert.deepEqual( contract.images.map((entry) => entry.image), - ['control', 'control-ai', 'admin', 'worker', 'local'], + ['control', 'control-ai', 'admin', 'worker', 'local', 'local-operator'], ); assert.equal( contract.requiredGates.includes('edge-and-standalone-rollout'), true, ); + assert.equal( + contract.requiredGates.includes('local-operator-entrypoint'), + true, + ); assert.equal( contract.requiredGates.includes('cross-image-release-set'), true, diff --git a/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs index a46416a9..d922b572 100644 --- a/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs +++ b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs @@ -46,7 +46,10 @@ function selectedImages(scope) { ['admin', 'qinglong3-cluster-admin'], ['worker', 'qinglong3-worker'], ]; - const local = [['local', 'qinglong3-local-application']]; + const local = [ + ['local', 'qinglong3-local-application'], + ['local-operator', 'qinglong3-local-operator'], + ]; return scope === 'local' ? local : scope === 'cluster' @@ -79,7 +82,7 @@ function releaseSet(scope) { const names = images.map((entry) => entry.name); const unsigned = { schemaVersion: 1, - schema: 'qinglong/release-set@v3', + schema: 'qinglong/release-set@v4', release, candidate: { schema: 'qinglong/release-candidate-contract@v1', @@ -91,12 +94,16 @@ function releaseSet(scope) { local: { selected: ['local', 'all'].includes(scope), profiles: ['edge', 'standalone'], - images: names.filter((name) => name === 'local'), + images: names.filter((name) => + ['local', 'local-operator'].includes(name), + ), }, cluster: { selected: ['cluster', 'all'].includes(scope), profiles: ['cluster', 'worker-edge', 'worker-node'], - images: names.filter((name) => name !== 'local'), + images: names.filter((name) => + ['control', 'control-ai', 'admin', 'worker'].includes(name), + ), }, }, evidenceReceipts: privateReleaseEvidenceReceipts(release), @@ -392,8 +399,9 @@ test('supports the complete all-scope image family without runtime coupling', (t 'admin', 'worker', 'local', + 'local-operator', ]); - assert.equal(report.releaseSet.imageCount, 5); + assert.equal(report.releaseSet.imageCount, 6); assert.equal(report.claims.deploymentMutation, false); }); diff --git a/test/back/ql3ReleaseCatalogContract.test.cjs b/test/back/ql3ReleaseCatalogContract.test.cjs index 65179425..7362e6b8 100644 --- a/test/back/ql3ReleaseCatalogContract.test.cjs +++ b/test/back/ql3ReleaseCatalogContract.test.cjs @@ -67,6 +67,12 @@ function releaseSet(scope) { ...identity, releaseScope: scope, image: entry.image, + localRoleVerification: + entry.image === 'local' + ? 'application_rollout_verified' + : entry.image === 'local-operator' + ? 'operator_entrypoint_verified' + : 'not_applicable', digest: `sha256:${String(index + 1).repeat(64)}`, }), ); diff --git a/test/back/ql3ReleasePublicationClosureContract.test.cjs b/test/back/ql3ReleasePublicationClosureContract.test.cjs index aef51572..0ca27658 100644 --- a/test/back/ql3ReleasePublicationClosureContract.test.cjs +++ b/test/back/ql3ReleasePublicationClosureContract.test.cjs @@ -71,6 +71,12 @@ function releaseSet(scope) { ...identity, releaseScope: scope, image: entry.image, + localRoleVerification: + entry.image === 'local' + ? 'application_rollout_verified' + : entry.image === 'local-operator' + ? 'operator_entrypoint_verified' + : 'not_applicable', digest: `sha256:${String(index + 1).repeat(64)}`, }), ); diff --git a/test/back/ql3ReleaseSetContract.test.cjs b/test/back/ql3ReleaseSetContract.test.cjs index 455e3d27..1febc18a 100644 --- a/test/back/ql3ReleaseSetContract.test.cjs +++ b/test/back/ql3ReleaseSetContract.test.cjs @@ -51,6 +51,12 @@ function recordsFor(releaseCandidate) { ...identity, releaseScope: releaseCandidate.release.scope, image: entry.image, + localRoleVerification: + entry.image === 'local' + ? 'application_rollout_verified' + : entry.image === 'local-operator' + ? 'operator_entrypoint_verified' + : 'not_applicable', digest: `sha256:${String(index + 1).repeat(64)}`, }), ); @@ -72,7 +78,7 @@ function writeCanonical(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); } -test('aggregates the independent Local image into one immutable release set', () => { +test('aggregates both independent Local images into one immutable release set', () => { const releaseCandidate = candidate('local'); const records = recordsFor(releaseCandidate); const { validationClockMs: _unused, ...localIdentity } = identity; @@ -86,7 +92,7 @@ test('aggregates the independent Local image into one immutable release set', () }); assert.deepEqual( releaseSet.images.map((entry) => entry.name), - ['local'], + ['local', 'local-operator'], ); assert.equal(releaseSet.deploymentFamilies.local.selected, true); assert.equal(releaseSet.deploymentFamilies.cluster.selected, false); @@ -140,8 +146,11 @@ test('closes cluster and all scopes over the exact candidate image order', () => ...identity, releaseScope: 'all', }); - assert.equal(allSet.images.length, 5); - assert.deepEqual(allSet.deploymentFamilies.local.images, ['local']); + assert.equal(allSet.images.length, 6); + assert.deepEqual(allSet.deploymentFamilies.local.images, [ + 'local', + 'local-operator', + ]); }); test('requires exact private evidence receipts only for Cluster-capable scopes', () => { @@ -303,6 +312,7 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () => repositoryOwner: 'UPPERCASE', releaseScope: 'local', image: 'local', + localRoleVerification: 'application_rollout_verified', digest: `sha256:${'1'.repeat(64)}`, }), /lowercase GitHub owner/, @@ -315,6 +325,7 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () => ...identity, releaseScope: 'local', image: 'local', + localRoleVerification: 'application_rollout_verified', digest: 'latest', }), /exact SHA-256 digest/, @@ -436,6 +447,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => { '--mode=record-image', ...common, '--image=local', + '--local-role-verification=application_rollout_verified', `--digest=sha256:${'1'.repeat(64)}`, `--output=${recordPath}`, ], @@ -443,6 +455,20 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => { output, ); assert.equal(fs.statSync(recordPath).mode & 0o777, 0o600); + const operatorRecordPath = path.join(recordsDirectory, 'local-operator.json'); + runCli( + [ + '--mode=record-image', + ...common, + '--image=local-operator', + '--local-role-verification=operator_entrypoint_verified', + `--digest=sha256:${'2'.repeat(64)}`, + `--output=${operatorRecordPath}`, + ], + root, + output, + ); + assert.equal(fs.statSync(operatorRecordPath).mode & 0o777, 0o600); const setPath = path.join(directory, 'release-set.json'); runCli( [ diff --git a/test/back/ql3ReleaseTagFinalizer.test.cjs b/test/back/ql3ReleaseTagFinalizer.test.cjs index 50a44a4a..7a4c7917 100644 --- a/test/back/ql3ReleaseTagFinalizer.test.cjs +++ b/test/back/ql3ReleaseTagFinalizer.test.cjs @@ -32,7 +32,10 @@ function publicationPlan() { const version = '3.0.0-alpha.0'; const sourceRevision = 'a'.repeat(40); const repository = 'ghcr.io/qinglong-release/qinglong3-local-application'; + const operatorRepository = + 'ghcr.io/qinglong-release/qinglong3-local-operator'; const imageDigest = `sha256:${'1'.repeat(64)}`; + const operatorDigest = `sha256:${'9'.repeat(64)}`; const manifestDigest = `sha256:${'2'.repeat(64)}`; const unsigned = { schemaVersion: 1, @@ -90,6 +93,22 @@ function publicationPlan() { }, ], }, + { + name: 'local-operator', + registryRepository: operatorRepository, + immutableReference: `${operatorRepository}@${operatorDigest}`, + digest: operatorDigest, + tags: [ + { + kind: 'version', + reference: `${operatorRepository}:${version}`, + }, + { + kind: 'source', + reference: `${operatorRepository}:sha-${sourceRevision}`, + }, + ], + }, ], }; return Object.freeze({ @@ -174,10 +193,14 @@ test('finalizes every exact tag and audits the live terminal state', () => { const plan = publicationPlan(); const registry = new FakeRegistry(plan); const observation = finalizeReleaseTags(plan, registry); - assert.equal(registry.copyCalls().length, 2); - assert.equal(observation.tags.length, 2); + assert.equal(registry.copyCalls().length, 4); + assert.equal(observation.tags.length, 4); assert.equal( - observation.tags.every((tag) => tag.digest === plan.images[0].digest), + observation.tags.every( + (tag) => + tag.digest === + plan.images.find((image) => image.name === tag.image)?.digest, + ), true, ); const copiesBeforeAudit = registry.copyCalls().length; @@ -185,7 +208,7 @@ test('finalizes every exact tag and audits the live terminal state', () => { schemaVersion: 1, planDigest: plan.planDigest, observationDigest: observation.observationDigest, - tagCount: 2, + tagCount: 4, allTagsExactDigest: true, registryMutation: false, compatible: true, @@ -228,8 +251,8 @@ test('recovers copy response loss by reusing exact tags and only filling absence ); assert.equal(registry.copyCalls().length, 1); const observation = finalizeReleaseTags(plan, registry); - assert.equal(registry.copyCalls().length, 2); - assert.equal(observation.tags.length, 2); + assert.equal(registry.copyCalls().length, 4); + assert.equal(observation.tags.length, 4); assert.equal( registry.resolveDigest(plan.images[0].tags[0].reference), plan.images[0].digest,