feat(ql3): close public local release pair

This commit is contained in:
whyour
2026-08-27 14:51:33 +08:00
parent 238df17fdf
commit 78c261b556
31 changed files with 558 additions and 91 deletions
+12 -6
View File
@@ -386,11 +386,14 @@ jobs:
--build-arg SOURCE_REVISION=${{ github.sha }} --build-arg SOURCE_REVISION=${{ github.sha }}
--tag "${OPERATOR_IMAGE}" --tag "${OPERATOR_IMAGE}"
. .
- name: Materialize the reviewed local OS vulnerability exceptions - name: Materialize the reviewed Local OS vulnerability exceptions
run: >- run: |
node scripts/ql3-image-os-vulnerability-policy.cjs node scripts/ql3-image-os-vulnerability-policy.cjs \
--image=local --image=local \
--output=${{ runner.temp }}/ql3-local-${{ matrix.image_arch }}.trivyignore.yaml --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 - name: Reject unexcepted high or critical local OS vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with: with:
@@ -420,7 +423,7 @@ jobs:
hide-progress: 'true' hide-progress: 'true'
timeout: '10m0s' timeout: '10m0s'
cache: 'false' 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 - name: Verify non-root identity and architecture
env: env:
IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }} IMAGE: qinglong3-local-application:ci-${{ matrix.image_arch }}
@@ -960,6 +963,9 @@ jobs:
- image: local - image: local
dockerfile: deploy/containers/ql3-local-application/Dockerfile dockerfile: deploy/containers/ql3-local-application/Dockerfile
target: runtime target: runtime
- image: local-operator
dockerfile: deploy/containers/ql3-local-operator/Dockerfile
target: runtime
- image: worker - image: worker
dockerfile: deploy/containers/ql3-worker/Dockerfile dockerfile: deploy/containers/ql3-worker/Dockerfile
target: runtime target: runtime
+48 -2
View File
@@ -564,6 +564,27 @@ jobs:
--docker-socket="${docker_socket}" \ --docker-socket="${docker_socket}" \
--profile=standalone --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 - name: Verify the keyless signature identity
env: env:
IMAGE: ${{ steps.identity.outputs.image }} IMAGE: ${{ steps.identity.outputs.image }}
@@ -658,6 +679,7 @@ jobs:
--repository-owner="${owner}" \ --repository-owner="${owner}" \
--candidate="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-contract.json" \ --candidate="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-contract.json" \
--image="${{ matrix.image }}" \ --image="${{ matrix.image }}" \
--local-role-verification="${{ matrix.local_role_verification }}" \
--digest="${DIGEST}" \ --digest="${DIGEST}" \
--output="${RUNNER_TEMP}/release-record/${{ matrix.image }}.json" --output="${RUNNER_TEMP}/release-record/${{ matrix.image }}.json"
@@ -1107,13 +1129,16 @@ jobs:
const fs = require('node:fs'); const fs = require('node:fs');
const selection = JSON.parse(fs.readFileSync(process.env.SELECTION, 'utf8')); const selection = JSON.parse(fs.readFileSync(process.env.SELECTION, 'utf8'));
if (!/^sha256:[a-f0-9]{64}$/.test(selection.selectionDigest || '') || 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); !/^ghcr\.io\/[a-z0-9._-]+\/qinglong3-local-application@sha256:[a-f0-9]{64}$/.test(selection.service?.image || '') ||
process.stdout.write(`selection=${process.env.SELECTION}\nselection-digest=${selection.selectionDigest}\nimage=${selection.service.image}\n`); !/^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 NODE
- name: Prove catalog-bound Edge and Standalone rollout - name: Prove catalog-bound Edge and Standalone rollout
env: env:
IMAGE: ${{ steps.local-selection.outputs.image }} IMAGE: ${{ steps.local-selection.outputs.image }}
OPERATOR_IMAGE: ${{ steps.local-selection.outputs.operator-image }}
RELEASE_SELECTION: ${{ steps.local-selection.outputs.selection }} RELEASE_SELECTION: ${{ steps.local-selection.outputs.selection }}
SELECTION_DIGEST: ${{ steps.local-selection.outputs.selection-digest }} SELECTION_DIGEST: ${{ steps.local-selection.outputs.selection-digest }}
EVIDENCE_ROOT: ${{ runner.temp }}/ql3-release-catalog-local-deployment EVIDENCE_ROOT: ${{ runner.temp }}/ql3-release-catalog-local-deployment
@@ -1124,6 +1149,27 @@ jobs:
umask 077 umask 077
install -d -m 0700 "${EVIDENCE_ROOT}" install -d -m 0700 "${EVIDENCE_ROOT}"
docker pull "${IMAGE}" 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_executable="$(realpath "$(command -v docker)")"
docker_socket="$(realpath /var/run/docker.sock)" docker_socket="$(realpath /var/run/docker.sock)"
for profile in edge standalone; do for profile in edge standalone; do
+3 -1
View File
@@ -11,11 +11,13 @@
最新增量证据(2026-08-27): 最新增量证据(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 退出 0package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`Local Owner CLI 为 301 total / 294 pass / 7 conditional skip / 0 failbackend 为 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 IDworkflow 精确绑定 `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/40Kubernetes run `32990652416` 与三节点 Security run `32990653482` 同源成功,证明源码的双架构门;但本地 `4239464a` v1 archive 不是 exact CI artifact,仍不能冒充 v2 用户 Alpha。该增强只增加一个小型发布期 JSON,不新增 workspace package、镜像 layer、设备依赖、常驻进程、RSS 或端口;首个真实双架构 v2 下载物仍需维护者显式授权 milestone workflow。 - 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 IDworkflow 精确绑定 `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/40Kubernetes 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 archiveSHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksumimage 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 的硬门。 - Alpha 阶段产物历史基线(当前性已由 D-411 收紧):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Application Docker archiveSHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksumimage 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 bytesread-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-<arch>.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 bytesread-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-<arch>.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 kitPublic 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 IDCluster 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` 认证 probeold/new 在 overlap 期间均认证成功并因无 Project role 返回 403,旧代引用从 1 收敛至 0contract 后 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/88source `f8934b401d724378fe5a6ea9dbe63e696b5480b9` 的远程 CI run `32898407637` 为 40/40CloudNativePG 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 TLSmaterial GC、持久 active catalog、索引/大规模查询计划、远程 UI/API 与双人复核仍是后续门禁。 - D-407/ADR-0502(已完成受审 Kubernetes live ceremony):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact IDCluster 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` 认证 probeold/new 在 overlap 期间均认证成功并因无 Project role 返回 403,旧代引用从 1 收敛至 0contract 后 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/88source `f8934b401d724378fe5a6ea9dbe63e696b5480b9` 的远程 CI run `32898407637` 为 40/40CloudNativePG 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 TLSmaterial GC、持久 active catalog、索引/大规模查询计划、远程 UI/API 与双人复核仍是后续门禁。
@@ -0,0 +1,65 @@
# ADR-0507Public 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 rolloutoperator 必须在 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 审计通过后才允许提交。
+1
View File
@@ -510,6 +510,7 @@
| [ADR-0504](./ADR-0504-canonical-local-alpha-trial-kit-materialization.md) | Local Alpha Trial Kit 单一物化与离线审计 | Accepted | | [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-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-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) |
## 规则 ## 规则
+1 -1
View File
@@ -9,7 +9,7 @@
| Runtime Engineering Candidate | QingLong 开发者、设备兼容测试者 | 单个常驻镜像的 OS 漏洞策略、SBOM/库存、资源门和生命周期 | 验证 runtime 可加载、可启动;缺少管理制品时不能称用户 Alpha | | 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 试运行;不承诺生产升级 | | 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 identityAdmin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release | | Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identityAdmin 额外通过产品 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 digestLocal Application/operator + Cluster 四角色)、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 |
只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。 只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
+12 -9
View File
@@ -15,7 +15,7 @@ ghcr.io/<owner>/qinglong3-release-catalog:v<version>-<scope>
## 私有发布证据收据链 ## 私有发布证据收据链
当前长期 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`。每份收据绑定同一 `worker-management``cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v2`。每份收据绑定同一
version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digestDR 收据还绑定 CloudNativePG backup、Barman Cloud 与 version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digestDR 收据还绑定 CloudNativePG backup、Barman Cloud 与
cert-manager 三项静态审计摘要。v2 收据不持久化私有 runner 的 wall-clock:创建时仍必须以当前时钟完成 freshness gate,但 durable JSON 只绑定 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 这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest
但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact 但必须保持 `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`,避免把未发布的临时时钟伪装成可离线重放的现场证据。 `freshnessValidatedAtCreation=true``durableValidationClockPublished=false`,避免把未发布的临时时钟伪装成可离线重放的现场证据。
创建时通过不等于可以无限期等待再闭合发布。`cluster|all` 的 release-set aggregate 与紧随其后的 independent audit 会各自从 runner 内部取得当前 创建时通过不等于可以无限期等待再闭合发布。`cluster|all` 的 release-set aggregate 与紧随其后的 independent audit 会各自从 runner 内部取得当前
@@ -42,8 +42,9 @@ attested 后写入。catalog 先存在而部署门失败时,不会产生正式
deployment-ready 版本公告。 deployment-ready 版本公告。
`local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog `local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog
重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v2 selection。该 selection 不是只做 JSON 重新完成发现、Cosign/GitHub provenance 验证和 three-file bundle audit,随后物化唯一 Local v3 selection。该 selection 同时绑定常驻
检查:同一个 immutable Local image 与 selection 会依次进入 Edge、Standalone 的正式 Compose rollout、SQLite backup/restore、evidence 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 collection 和 graceful stop。两个 content-free report 必须绑定同一 release-set、catalog manifest、consumption report 与 selection digest
任一 Profile 失败都会阻断 Local release。 任一 Profile 失败都会阻断 Local release。
@@ -100,9 +101,9 @@ attestation 仍必须通过受保护 release tag 或受控 release repository
| 部署类型 | release scope | 必须出现的镜像 | | 部署类型 | release scope | 必须出现的镜像 |
| --- | --- | --- | | --- | --- | --- |
| 低配路由器、Edge、Standalone | `local` | `local` | | 低配路由器、Edge、Standalone | `local` | `local``local-operator` |
| Kubernetes/Cluster | `cluster` | `control``control-ai``worker``admin` | | Kubernetes/Cluster | `cluster` | `control``control-ai``worker``admin` |
| 同时发布两族 | `all` | 上述个镜像 | | 同时发布两族 | `all` | 上述个镜像 |
Local 用户不需要下载 Cluster 镜像,也不依赖 CloudNativePG 或 Worker 私有发布证据。Cluster 运维者不能拿 Local Local 用户不需要下载 Cluster 镜像,也不依赖 CloudNativePG 或 Worker 私有发布证据。Cluster 运维者不能拿 Local
image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin 必须有各自 digest。 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 1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
referencediscovery tag 无 authority。 referencediscovery tag 无 authority。
2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中 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 与部署目标一致。 3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。 4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。
static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。 static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。
5. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的 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。 consumption report 与 release-set digest,并且只能消费 release set 中的 `@sha256:` reference。
6. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned 6. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned
kubectl/kubeconfig 和目标 cluster UID 绑定进 preflight/apply receiptversion/source/catalog tag 都只能用于发现,部署始终以 kubectl/kubeconfig 和目标 cluster UID 绑定进 preflight/apply receiptversion/source/catalog tag 都只能用于发现,部署始终以
@@ -466,7 +467,9 @@ SHA-256`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计
路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站 路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站
完成上述 ceremony 和 Local v2 selection 审计,再向设备传输已检查的 catalog-bound canonical JSON,并只把 `local` family 的 完成上述 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 私有发布证据。 设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。
如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与 如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与
@@ -22,11 +22,13 @@ import {
} from '../foundation/files'; } from '../foundation/files';
import { deploymentPaths } from '../foundation/render'; 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 CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1';
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
const IMAGE_PATTERN = const IMAGE_PATTERN =
/^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@sha256:[0-9a-f]{64}$/; /^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 VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/;
const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/; const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/;
const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
@@ -63,6 +65,7 @@ function selectionContents(selection: Readonly<ComposeImageSelection>): string {
` catalog_manifest_digest: ${selection.catalogManifestDigest}`, ` catalog_manifest_digest: ${selection.catalogManifestDigest}`,
` catalog_consumption_report_digest: ${selection.catalogConsumptionReportDigest}`, ` catalog_consumption_report_digest: ${selection.catalogConsumptionReportDigest}`,
` catalog_discovery_tag_authority: ${selection.catalogDiscoveryTagAuthority}`, ` catalog_discovery_tag_authority: ${selection.catalogDiscoveryTagAuthority}`,
` operator_image: ${selection.operatorImage}`,
` allow_root_service: ${selection.allowRootService}`, ` allow_root_service: ${selection.allowRootService}`,
'services:', 'services:',
' qinglong3:', ' qinglong3:',
@@ -94,7 +97,7 @@ function parseSelection(
label: string, label: string,
): Readonly<ComposeImageSelection> { ): Readonly<ComposeImageSelection> {
const match = 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, contents,
); );
if (!match) { if (!match) {
@@ -122,15 +125,17 @@ function parseSelection(
const catalogImmutableReference = match[14]!; const catalogImmutableReference = match[14]!;
const catalogManifestDigest = match[15]!; const catalogManifestDigest = match[15]!;
const catalogConsumptionReportDigest = match[16]!; const catalogConsumptionReportDigest = match[16]!;
const allowRootService = match[17] === 'true'; const operatorImage = match[17]!;
const image = match[18]!; const allowRootService = match[18] === 'true';
const labelGeneration = Number(match[19]); const image = match[19]!;
const labelMutationId = match[20]; const labelGeneration = Number(match[20]);
const labelSelectionDigest = match[21]; const labelMutationId = match[21];
const labelReleaseSetDigest = match[22]; const labelSelectionDigest = match[22];
const labelCatalogManifestDigest = match[23]; const labelReleaseSetDigest = match[23];
const labelCatalogReportDigest = match[24]; const labelCatalogManifestDigest = match[24];
const labelCatalogReportDigest = match[25];
const imageMatch = IMAGE_PATTERN.exec(image); const imageMatch = IMAGE_PATTERN.exec(image);
const operatorImageMatch = OPERATOR_IMAGE_PATTERN.exec(operatorImage);
if ( if (
generation < 1 || generation < 1 ||
previousGeneration !== generation - 1 || previousGeneration !== generation - 1 ||
@@ -150,6 +155,8 @@ function parseSelection(
!DIGEST_PATTERN.test(catalogManifestDigest) || !DIGEST_PATTERN.test(catalogManifestDigest) ||
!DIGEST_PATTERN.test(catalogConsumptionReportDigest) || !DIGEST_PATTERN.test(catalogConsumptionReportDigest) ||
!imageMatch || !imageMatch ||
!operatorImageMatch ||
operatorImageMatch[1] !== imageMatch[1] ||
catalogImmutableReference !== catalogImmutableReference !==
`ghcr.io/${imageMatch?.[1]}/qinglong3-release-catalog@${catalogManifestDigest}` || `ghcr.io/${imageMatch?.[1]}/qinglong3-release-catalog@${catalogManifestDigest}` ||
labelGeneration !== generation || labelGeneration !== generation ||
@@ -168,6 +175,7 @@ function parseSelection(
mutationId, mutationId,
changedAtMs, changedAtMs,
image, image,
operatorImage,
allowRootService, allowRootService,
selectionDigest, selectionDigest,
releaseSetDigest, releaseSetDigest,
@@ -235,6 +243,7 @@ function releaseAuthorityFromSelection(
): Readonly<LocalComposeReleaseAuthority> { ): Readonly<LocalComposeReleaseAuthority> {
return Object.freeze({ return Object.freeze({
image: selection.image, image: selection.image,
operatorImage: selection.operatorImage,
allowRootService: selection.allowRootService, allowRootService: selection.allowRootService,
selectionDigest: selection.selectionDigest, selectionDigest: selection.selectionDigest,
releaseSetDigest: selection.releaseSetDigest, releaseSetDigest: selection.releaseSetDigest,
@@ -3,7 +3,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const MAX_SELECTION_BYTES = 64 * 1024; 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 CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1';
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
const VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; 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 SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const IMAGE_PATTERN = const IMAGE_PATTERN =
/^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@(sha256:[a-f0-9]{64})$/; /^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 { export interface LocalComposeReleaseSelectionInput {
readonly path: string; readonly path: string;
@@ -19,6 +21,7 @@ export interface LocalComposeReleaseSelectionInput {
export interface LocalComposeReleaseAuthority { export interface LocalComposeReleaseAuthority {
readonly image: string; readonly image: string;
readonly operatorImage: string;
readonly allowRootService: boolean; readonly allowRootService: boolean;
readonly selectionDigest: string; readonly selectionDigest: string;
readonly releaseSetDigest: string; readonly releaseSetDigest: string;
@@ -171,6 +174,7 @@ export function resolveLocalComposeReleaseSelection(
'schemaVersion', 'schemaVersion',
'selectionDigest', 'selectionDigest',
'service', 'service',
'operator',
'verification', 'verification',
], ],
'release selection', 'release selection',
@@ -195,6 +199,7 @@ export function resolveLocalComposeReleaseSelection(
'catalog', 'catalog',
); );
exactKeys(value.service, ['allowRootService', 'image', 'kind'], 'service'); exactKeys(value.service, ['allowRootService', 'image', 'kind'], 'service');
exactKeys(value.operator, ['image', 'kind', 'network'], 'operator');
exactKeys( exactKeys(
value.verification, value.verification,
[ [
@@ -210,9 +215,13 @@ export function resolveLocalComposeReleaseSelection(
const release = value.release; const release = value.release;
const catalog = value.catalog; const catalog = value.catalog;
const service = value.service; const service = value.service;
const operator = value.operator;
const verification = value.verification; const verification = value.verification;
const image = typeof service.image === 'string' ? service.image : ''; const image = typeof service.image === 'string' ? service.image : '';
const imageMatch = IMAGE_PATTERN.exec(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 { selectionDigest, ...unsigned } = value;
const calculatedDigest = digest(JSON.stringify(unsigned)); const calculatedDigest = digest(JSON.stringify(unsigned));
if ( if (
@@ -239,10 +248,14 @@ export function resolveLocalComposeReleaseSelection(
catalog.releaseSetDigest !== value.releaseSetDigest || catalog.releaseSetDigest !== value.releaseSetDigest ||
catalog.discoveryTagAuthority !== 'none' || catalog.discoveryTagAuthority !== 'none' ||
!imageMatch || !imageMatch ||
!operatorImageMatch ||
operatorImageMatch[1] !== imageMatch[1] ||
catalog.immutableReference !== catalog.immutableReference !==
`ghcr.io/${imageMatch[1]}/qinglong3-release-catalog@${catalog.manifestDigest}` || `ghcr.io/${imageMatch[1]}/qinglong3-release-catalog@${catalog.manifestDigest}` ||
service.kind !== 'compose' || service.kind !== 'compose' ||
service.allowRootService !== allowRootService || service.allowRootService !== allowRootService ||
operator.kind !== 'short-lived' ||
operator.network !== 'none-by-default' ||
verification.releaseSet !== verification.releaseSet !==
'standalone_structure_identity_and_self_digest' || 'standalone_structure_identity_and_self_digest' ||
verification.sourceRecordsReplayed !== false || verification.sourceRecordsReplayed !== false ||
@@ -262,6 +275,7 @@ export function resolveLocalComposeReleaseSelection(
expectedSelectionDigest: input.expectedSelectionDigest, expectedSelectionDigest: input.expectedSelectionDigest,
authority: Object.freeze({ authority: Object.freeze({
image, image,
operatorImage,
allowRootService, allowRootService,
selectionDigest, selectionDigest,
releaseSetDigest: value.releaseSetDigest, releaseSetDigest: value.releaseSetDigest,
@@ -56,12 +56,16 @@ function releaseSelection(managementRoot, marker = 'a') {
const image = `ghcr.io/example/qinglong3-local-application@sha256:${marker.repeat( const image = `ghcr.io/example/qinglong3-local-application@sha256:${marker.repeat(
64, 64,
)}`; )}`;
const operatorImage = image.replace(
'qinglong3-local-application',
'qinglong3-local-operator',
);
const releaseSetDigest = prefixedDigest(`release-set:${image}`); const releaseSetDigest = prefixedDigest(`release-set:${image}`);
const manifestDigest = prefixedDigest(`catalog-manifest:${image}`); const manifestDigest = prefixedDigest(`catalog-manifest:${image}`);
const consumptionReportDigest = prefixedDigest(`catalog-report:${image}`); const consumptionReportDigest = prefixedDigest(`catalog-report:${image}`);
const unsigned = { const unsigned = {
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/local-compose-release-image@v2', schema: 'qinglong/local-compose-release-image@v3',
release: { release: {
version: '3.0.0-alpha.0', version: '3.0.0-alpha.0',
sourceRevision: '3'.repeat(40), sourceRevision: '3'.repeat(40),
@@ -86,6 +90,11 @@ function releaseSelection(managementRoot, marker = 'a') {
image, image,
allowRootService: rootAcknowledgement(), allowRootService: rootAcknowledgement(),
}, },
operator: {
kind: 'short-lived',
image: operatorImage,
network: 'none-by-default',
},
verification: { verification: {
releaseSet: 'standalone_structure_identity_and_self_digest', releaseSet: 'standalone_structure_identity_and_self_digest',
sourceRecordsReplayed: false, sourceRecordsReplayed: false,
@@ -31,12 +31,16 @@ function sha256(value) {
} }
function releaseSelectionForImage(managementRoot, image, allowRootService) { function releaseSelectionForImage(managementRoot, image, allowRootService) {
const operatorImage = image.replace(
'qinglong3-local-application',
'qinglong3-local-operator',
);
const releaseSetDigest = sha256(`release-set:${image}`); const releaseSetDigest = sha256(`release-set:${image}`);
const manifestDigest = sha256(`catalog-manifest:${image}`); const manifestDigest = sha256(`catalog-manifest:${image}`);
const consumptionReportDigest = sha256(`catalog-report:${image}`); const consumptionReportDigest = sha256(`catalog-report:${image}`);
const unsigned = { const unsigned = {
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/local-compose-release-image@v2', schema: 'qinglong/local-compose-release-image@v3',
release: { release: {
version: '3.0.0-alpha.0', version: '3.0.0-alpha.0',
sourceRevision: '3'.repeat(40), sourceRevision: '3'.repeat(40),
@@ -61,6 +65,11 @@ function releaseSelectionForImage(managementRoot, image, allowRootService) {
image, image,
allowRootService, allowRootService,
}, },
operator: {
kind: 'short-lived',
image: operatorImage,
network: 'none-by-default',
},
verification: { verification: {
releaseSet: 'standalone_structure_identity_and_self_digest', releaseSet: 'standalone_structure_identity_and_self_digest',
sourceRecordsReplayed: false, sourceRecordsReplayed: false,
@@ -393,7 +402,7 @@ function composeSelection({
const selected = JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8')); const selected = JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8'));
return [ return [
'x-qinglong-image-selection:', 'x-qinglong-image-selection:',
' schema: qinglong/local-compose-image-selection@v2', ' schema: qinglong/local-compose-image-selection@v3',
` generation: ${generation}`, ` generation: ${generation}`,
` previous_generation: ${previousGeneration}`, ` previous_generation: ${previousGeneration}`,
` rollback_target_generation: ${rollbackTargetGeneration}`, ` rollback_target_generation: ${rollbackTargetGeneration}`,
@@ -412,6 +421,7 @@ function composeSelection({
` catalog_manifest_digest: ${selected.catalog.manifestDigest}`, ` catalog_manifest_digest: ${selected.catalog.manifestDigest}`,
` catalog_consumption_report_digest: ${selected.catalog.consumptionReportDigest}`, ` catalog_consumption_report_digest: ${selected.catalog.consumptionReportDigest}`,
` catalog_discovery_tag_authority: ${selected.catalog.discoveryTagAuthority}`, ` catalog_discovery_tag_authority: ${selected.catalog.discoveryTagAuthority}`,
` operator_image: ${selected.operator.image}`,
` allow_root_service: ${selected.service.allowRootService}`, ` allow_root_service: ${selected.service.allowRootService}`,
'services:', 'services:',
' qinglong3:', ' qinglong3:',
@@ -1056,7 +1066,7 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async
); );
assert.match( assert.match(
fs.readFileSync(selectionPath, 'utf8'), fs.readFileSync(selectionPath, 'utf8'),
/^ schema: qinglong\/local-compose-image-selection@v2$/m, /^ schema: qinglong\/local-compose-image-selection@v3$/m,
); );
assert.match( assert.match(
fs.readFileSync(selectionPath, 'utf8'), fs.readFileSync(selectionPath, 'utf8'),
@@ -2200,6 +2210,16 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy
fs.readFileSync(selectionPath, 'utf8'), fs.readFileSync(selectionPath, 'utf8'),
new RegExp(`^ image: ${upgradedImage}$`, 'm'), 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(mode(path.join(revisions, '2.yaml')), 0o600);
assert.equal(fs.readFileSync(composePath, 'utf8'), stableDescriptor); 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, /^ generation: 3$/m);
assert.match(active, /^ rollback_target_generation: 1$/m); assert.match(active, /^ rollback_target_generation: 1$/m);
assert.match(active, new RegExp(`^ image: ${state.composeImage}$`, '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( assert.equal(
fs fs
.readFileSync(path.join(revisions, '2.yaml'), 'utf8') .readFileSync(path.join(revisions, '2.yaml'), 'utf8')
@@ -12,9 +12,15 @@ function writeSyntheticLocalReleaseSelection(options) {
const releaseSetDigest = sha256(`release-set:${options.image}`); const releaseSetDigest = sha256(`release-set:${options.image}`);
const manifestDigest = sha256(`catalog-manifest:${options.image}`); const manifestDigest = sha256(`catalog-manifest:${options.image}`);
const consumptionReportDigest = sha256(`catalog-report:${options.image}`); const consumptionReportDigest = sha256(`catalog-report:${options.image}`);
const operatorImage =
options.operatorImage ||
options.image.replace(
'/qinglong3-local-application@',
'/qinglong3-local-operator@',
);
const unsigned = { const unsigned = {
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/local-compose-release-image@v2', schema: 'qinglong/local-compose-release-image@v3',
release: { release: {
version: '3.0.0-alpha.0', version: '3.0.0-alpha.0',
sourceRevision: options.sourceRevision ?? '3'.repeat(40), sourceRevision: options.sourceRevision ?? '3'.repeat(40),
@@ -39,6 +45,11 @@ function writeSyntheticLocalReleaseSelection(options) {
image: options.image, image: options.image,
allowRootService: options.allowRootService, allowRootService: options.allowRootService,
}, },
operator: {
kind: 'short-lived',
image: operatorImage,
network: 'none-by-default',
},
verification: { verification: {
releaseSet: 'standalone_structure_identity_and_self_digest', releaseSet: 'standalone_structure_identity_and_self_digest',
sourceRecordsReplayed: false, sourceRecordsReplayed: false,
+44 -13
View File
@@ -168,6 +168,11 @@ function auditClusterImageCiWorkflow(
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile', dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
target: 'runtime', target: 'runtime',
}, },
{
image: 'local-operator',
dockerfile: 'deploy/containers/ql3-local-operator/Dockerfile',
target: 'runtime',
},
{ {
image: 'worker', image: 'worker',
dockerfile: 'deploy/containers/ql3-worker/Dockerfile', dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
@@ -185,7 +190,7 @@ function auditClusterImageCiWorkflow(
JSON.stringify(expectedOciMatrix) JSON.stringify(expectedOciMatrix)
) { ) {
throw new Error( 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 = { const expectedTrivyInputs = {
@@ -205,7 +210,13 @@ function auditClusterImageCiWorkflow(
localImageJob, localImageJob,
'qinglong3-local-application:ci-${{ matrix.image_arch }}', 'qinglong3-local-application:ci-${{ matrix.image_arch }}',
'${{ runner.temp }}/ql3-local-${{ matrix.image_arch }}.trivyignore.yaml', '${{ 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, clusterImageJob,
@@ -217,7 +228,8 @@ function auditClusterImageCiWorkflow(
const step = job?.steps?.find( const step = job?.steps?.find(
(entry) => (entry) =>
entry.uses === entry.uses ===
'aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25', 'aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25' &&
entry.with?.['image-ref'] === imageRef,
); );
if ( if (
!step || !step ||
@@ -246,14 +258,19 @@ function auditClusterImageCiWorkflow(
requireOccurrences( requireOccurrences(
source, source,
/node scripts\/ql3-image-os-vulnerability-policy\.cjs/g, /node scripts\/ql3-image-os-vulnerability-policy\.cjs/g,
2, 3,
'native image CI must materialize reviewed image-scoped Trivy exceptions', 'native image CI must materialize reviewed image-scoped Trivy exceptions',
); );
requirePattern( requirePattern(
source, 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', '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( requirePattern(
source, source,
/--image=\$\{\{ matrix\.image \}\}\s+--output=\$\{\{ runner\.temp \}\}\/ql3-\$\{\{ matrix\.image \}\}-\$\{\{ matrix\.image_arch \}\}\.trivyignore\.yaml/, /--image=\$\{\{ matrix\.image \}\}\s+--output=\$\{\{ runner\.temp \}\}\/ql3-\$\{\{ matrix\.image \}\}-\$\{\{ matrix\.image_arch \}\}\.trivyignore\.yaml/,
@@ -489,8 +506,8 @@ function auditClusterImageCiWorkflow(
); );
requirePattern( requirePattern(
source, 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/, /- 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 and worker images', 'OCI evidence CI must build independent control, control-ai, admin, local, local-operator and worker images',
); );
requirePattern( requirePattern(
source, source,
@@ -519,7 +536,14 @@ function auditClusterImageCiWorkflow(
'local image CI must generate and inventory-check the exact local SBOM profile', 'local image CI must generate and inventory-check the exact local SBOM profile',
); );
return { return {
images: ['control', 'control-ai', 'admin', 'local', 'worker'], images: [
'control',
'control-ai',
'admin',
'local',
'local-operator',
'worker',
],
nativeArchitectures: ['amd64', 'arm64'], nativeArchitectures: ['amd64', 'arm64'],
runtimeInventory: true, runtimeInventory: true,
clusterAdminProductFacade: true, clusterAdminProductFacade: true,
@@ -844,7 +868,7 @@ function auditReleaseWorkflow(source) {
); );
} }
if ( 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 ?? '', publishSteps[recordIndex]?.run ?? '',
) || ) ||
JSON.stringify(publishSteps[recordUploadIndex]?.with) !== JSON.stringify(publishSteps[recordUploadIndex]?.with) !==
@@ -991,10 +1015,10 @@ function auditReleaseWorkflow(source) {
localCatalogDeploymentSteps[5]?.run ?? '', localCatalogDeploymentSteps[5]?.run ?? '',
) || ) ||
localCatalogDeploymentSteps[6]?.id !== 'local-selection' || 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 ?? '', 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[7]?.run ?? '',
) || ) ||
localCatalogDeploymentSteps[8]?.if !== 'always()' || localCatalogDeploymentSteps[8]?.if !== 'always()' ||
@@ -1540,7 +1564,14 @@ function auditReleaseWorkflow(source) {
immutableArtifactRetentionDays: 1, immutableArtifactRetentionDays: 1,
attestedToPublishedDigest: true, attestedToPublishedDigest: true,
}, },
images: ['control', 'control-ai', 'admin', 'worker', 'local'], images: [
'control',
'control-ai',
'admin',
'worker',
'local',
'local-operator',
],
platforms: ['linux/amd64', 'linux/arm64'], platforms: ['linux/amd64', 'linux/arm64'],
keylessSignature: true, keylessSignature: true,
buildkitAttestations: ['sbom', 'provenance'], buildkitAttestations: ['sbom', 'provenance'],
@@ -1578,7 +1609,7 @@ function auditReleaseWorkflow(source) {
}, },
durableCatalog: { durableCatalog: {
repository: 'qinglong3-release-catalog', 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', planSchema: 'qinglong/release-catalog-plan@v2',
receiptSchema: 'qinglong/release-catalog-receipt@v2', receiptSchema: 'qinglong/release-catalog-receipt@v2',
tagInventoryDecisionSchema: tagInventoryDecisionSchema:
+36 -1
View File
@@ -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 isControl = image === 'control' || image === 'control-ai';
const isControlAi = image === 'control-ai'; const isControlAi = image === 'control-ai';
return { return {
@@ -529,7 +562,9 @@ function auditClusterOciLayout(options = {}) {
const image = resolveImageProfile(options.image).id; const image = resolveImageProfile(options.image).id;
const expectedPlatforms = options.expectedPlatforms || EXPECTED_PLATFORMS; const expectedPlatforms = options.expectedPlatforms || EXPECTED_PLATFORMS;
const maximumPlatformBytes = const maximumPlatformBytes =
image === 'local' ? 128 * 1024 * 1024 : 512 * 1024 * 1024; image === 'local' || image === 'local-operator'
? 128 * 1024 * 1024
: 512 * 1024 * 1024;
if ( if (
!path.isAbsolute(layoutRoot) || !path.isAbsolute(layoutRoot) ||
typeof expectedRevision !== 'string' || typeof expectedRevision !== 'string' ||
+8 -1
View File
@@ -14,7 +14,7 @@ const {
const { inspectReleaseSet } = require('./ql3-release-set-contract.cjs'); const { inspectReleaseSet } = require('./ql3-release-set-contract.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); 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 KUBERNETES_LOCK_SCHEMA = 'qinglong/kubernetes-deployment-lock@v2';
const MAX_RELEASE_SET_BYTES = 1024 * 1024; const MAX_RELEASE_SET_BYTES = 1024 * 1024;
const MAX_MANIFEST_BYTES = 8 * 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'); fail('allow-root-service must be an explicit boolean');
} }
const local = imageByName(releaseSet, 'local'); const local = imageByName(releaseSet, 'local');
const operator = imageByName(releaseSet, 'local-operator');
const unsigned = { const unsigned = {
schemaVersion: 1, schemaVersion: 1,
schema: LOCAL_SELECTION_SCHEMA, schema: LOCAL_SELECTION_SCHEMA,
@@ -205,6 +206,11 @@ function createLocalSelection(releaseSet, options) {
image: local.reference, image: local.reference,
allowRootService: options.allowRootService, allowRootService: options.allowRootService,
}, },
operator: {
kind: 'short-lived',
image: operator.reference,
network: 'none-by-default',
},
verification: { verification: {
releaseSet: inspection.verification, releaseSet: inspection.verification,
sourceRecordsReplayed: inspection.sourceRecordsReplayed, sourceRecordsReplayed: inspection.sourceRecordsReplayed,
@@ -233,6 +239,7 @@ function auditLocalSelection(actual, releaseSet, options) {
catalogManifestDigest: actual.catalog.manifestDigest, catalogManifestDigest: actual.catalog.manifestDigest,
immutableReference: actual.catalog.immutableReference, immutableReference: actual.catalog.immutableReference,
image: actual.service.image, image: actual.service.image,
operatorImage: actual.operator.image,
networkAccess: false, networkAccess: false,
deploymentMutation: false, deploymentMutation: false,
}); });
@@ -12,6 +12,7 @@ const IMAGES = Object.freeze([
'control', 'control',
'control-ai', 'control-ai',
'local', 'local',
'local-operator',
'worker', 'worker',
]); ]);
const MAX_POLICY_BYTES = 256 * 1024; const MAX_POLICY_BYTES = 256 * 1024;
@@ -108,6 +109,7 @@ function auditImageOsVulnerabilityPolicy(
control: 0, control: 0,
'control-ai': 0, 'control-ai': 0,
local: 0, local: 0,
'local-operator': 0,
worker: 0, worker: 0,
}), }),
}); });
@@ -118,6 +120,7 @@ function auditImageOsVulnerabilityPolicy(
control: 0, control: 0,
'control-ai': 0, 'control-ai': 0,
local: 0, local: 0,
'local-operator': 0,
worker: 0, worker: 0,
}; };
const seen = new Set(); const seen = new Set();
@@ -120,6 +120,14 @@ function resolveReleaseSelection(input, temporaryRoot, uid) {
const unsigned = { ...selection }; const unsigned = { ...selection };
delete unsigned.selectionDigest; delete unsigned.selectionDigest;
const computedDigest = sha256(JSON.stringify(unsigned)); 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 ( if (
!stat.isFile() || !stat.isFile() ||
stat.isSymbolicLink() || stat.isSymbolicLink() ||
@@ -135,7 +143,7 @@ function resolveReleaseSelection(input, temporaryRoot, uid) {
computedDigest !== input.releaseSelection.expectedSelectionDigest || computedDigest !== input.releaseSelection.expectedSelectionDigest ||
selection.selectionDigest !== computedDigest || selection.selectionDigest !== computedDigest ||
selection.schemaVersion !== 1 || 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( !/^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u.test(
selection.release?.version ?? '', selection.release?.version ?? '',
) || ) ||
@@ -148,6 +156,11 @@ function resolveReleaseSelection(input, temporaryRoot, uid) {
selection.service?.kind !== 'compose' || selection.service?.kind !== 'compose' ||
selection.service?.image !== input.image || selection.service?.image !== input.image ||
selection.service?.allowRootService !== (uid === 0) || 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 !== selection.catalog?.schema !==
'qinglong/release-catalog-consumption-ceremony@v1' || 'qinglong/release-catalog-consumption-ceremony@v1' ||
!DIGEST_PATTERN.test(selection.catalog?.manifestDigest ?? '') || !DIGEST_PATTERN.test(selection.catalog?.manifestDigest ?? '') ||
+17 -2
View File
@@ -38,6 +38,13 @@ const LOCAL_IMAGES = Object.freeze([
runtime_root: runtime_root:
'deploy/containers/ql3-local-application/runtime-dependencies', '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([ const CLUSTER_IMAGES = Object.freeze([
Object.freeze({ Object.freeze({
@@ -208,7 +215,15 @@ function createReleaseCandidateContract(options) {
version: manifest.version, 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) => const osMatrix = images.flatMap((image) =>
nativeArchitectures.map((architecture) => ({ nativeArchitectures.map((architecture) => ({
image: image.image, image: image.image,
@@ -285,7 +300,7 @@ function createReleaseCandidateContract(options) {
'durable-oci-release-catalog', 'durable-oci-release-catalog',
'offline-deployment-lock-materialization', 'offline-deployment-lock-materialization',
...(options.releaseScope !== 'cluster' ...(options.releaseScope !== 'cluster'
? ['edge-and-standalone-rollout'] ? ['edge-and-standalone-rollout', 'local-operator-entrypoint']
: []), : []),
...(options.releaseScope !== 'local' ...(options.releaseScope !== 'local'
? [ ? [
+1 -1
View File
@@ -16,7 +16,7 @@ const CATALOG_PUBLICATION_DECISION_SCHEMA =
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v2'; const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v2';
const CATALOG_TAG_INVENTORY_DECISION_SCHEMA = const CATALOG_TAG_INVENTORY_DECISION_SCHEMA =
'qinglong/release-catalog-tag-inventory-decision@v1'; '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 FILE_MEDIA_TYPE = ARTIFACT_TYPE;
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json'; const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json'; const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
@@ -311,10 +311,10 @@ function validatePublicationPlan(plan) {
!Array.isArray(plan.images) || !Array.isArray(plan.images) ||
plan.images.length !== plan.images.length !==
(plan.release.scope === 'local' (plan.release.scope === 'local'
? 1 ? 2
: plan.release.scope === 'cluster' : plan.release.scope === 'cluster'
? 4 ? 4
: 5) : 6)
) { ) {
fail('publication plan shape is invalid'); fail('publication plan shape is invalid');
} }
+34 -9
View File
@@ -19,8 +19,8 @@ const {
const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs'); const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..'); const DEFAULT_ROOT = path.resolve(__dirname, '..');
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1'; const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v2';
const RELEASE_SET_SCHEMA = 'qinglong/release-set@v3'; const RELEASE_SET_SCHEMA = 'qinglong/release-set@v4';
const MAX_JSON_BYTES = 1024 * 1024; const MAX_JSON_BYTES = 1024 * 1024;
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/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]; return matches[0];
} }
function expectedLocalRoleVerification(imageName) {
return imageName === 'local'
? 'application_rollout_verified'
: imageName === 'local-operator'
? 'operator_entrypoint_verified'
: 'not_applicable';
}
function deriveVerifiedImageRecord( function deriveVerifiedImageRecord(
candidate, candidate,
repositoryOwner, repositoryOwner,
@@ -166,7 +174,7 @@ function deriveVerifiedImageRecord(
remoteDigestVerified: true, remoteDigestVerified: true,
keylessSignatureVerified: true, keylessSignatureVerified: true,
githubAttestations: [...REQUIRED_IMAGE_ATTESTATIONS], githubAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
localProfileRolloutVerified: selected.image === 'local', localRoleVerification: expectedLocalRoleVerification(selected.image),
tagPromotion: 'deferred_to_complete_release_set', tagPromotion: 'deferred_to_complete_release_set',
}, },
}; };
@@ -179,6 +187,12 @@ function deriveVerifiedImageRecord(
function createVerifiedImageRecord(options) { function createVerifiedImageRecord(options) {
const candidate = verifyCandidate(options.candidate, options); const candidate = verifyCandidate(options.candidate, options);
const owner = normalizeRepositoryOwner(options.repositoryOwner); const owner = normalizeRepositoryOwner(options.repositoryOwner);
if (
options.localRoleVerification !==
expectedLocalRoleVerification(options.image)
) {
fail('local role verification differs from the selected image role');
}
return deriveVerifiedImageRecord( return deriveVerifiedImageRecord(
candidate, candidate,
owner, owner,
@@ -214,7 +228,7 @@ function validateImageRecord(record, candidate, repositoryOwner) {
'remoteDigestVerified', 'remoteDigestVerified',
'keylessSignatureVerified', 'keylessSignatureVerified',
'githubAttestations', 'githubAttestations',
'localProfileRolloutVerified', 'localRoleVerification',
'tagPromotion', 'tagPromotion',
]) ])
) { ) {
@@ -306,10 +320,14 @@ function createReleaseSet(options) {
return record; return record;
}); });
const localImages = orderedRecords 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); .map((record) => record.image.name);
const clusterImages = orderedRecords 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); .map((record) => record.image.name);
const images = orderedRecords.map((record) => ({ const images = orderedRecords.map((record) => ({
...record.image, ...record.image,
@@ -497,12 +515,16 @@ function inspectReleaseSet(actual, options) {
local: { local: {
selected: ['local', 'all'].includes(options.releaseScope), selected: ['local', 'all'].includes(options.releaseScope),
profiles: ['edge', 'standalone'], profiles: ['edge', 'standalone'],
images: expectedNames.filter((name) => name === 'local'), images: expectedNames.filter((name) =>
LOCAL_IMAGES.some((entry) => entry.image === name),
),
}, },
cluster: { cluster: {
selected: ['cluster', 'all'].includes(options.releaseScope), selected: ['cluster', 'all'].includes(options.releaseScope),
profiles: ['cluster', 'worker-edge', 'worker-node'], profiles: ['cluster', 'worker-edge', 'worker-node'],
images: expectedNames.filter((name) => name !== 'local'), images: expectedNames.filter((name) =>
CLUSTER_IMAGES.some((entry) => entry.image === name),
),
}, },
}; };
if ( if (
@@ -619,7 +641,7 @@ function parseArguments(argv) {
const common = ['candidate', ...identity]; const common = ['candidate', ...identity];
const expected = const expected =
values.mode === 'record-image' values.mode === 'record-image'
? [...common, 'digest', 'image', 'output'] ? [...common, 'digest', 'image', 'local-role-verification', 'output']
: values.mode === 'aggregate' : values.mode === 'aggregate'
? [...common, 'evidence-receipts', 'output', 'records'] ? [...common, 'evidence-receipts', 'output', 'records']
: values.mode === 'audit' : values.mode === 'audit'
@@ -643,6 +665,9 @@ function parseArguments(argv) {
releaseScope: values['release-scope'], releaseScope: values['release-scope'],
repositoryOwner: values['repository-owner'], repositoryOwner: values['repository-owner'],
...(values.image ? { image: values.image } : {}), ...(values.image ? { image: values.image } : {}),
...(values['local-role-verification']
? { localRoleVerification: values['local-role-verification'] }
: {}),
...(values.digest ? { digest: values.digest } : {}), ...(values.digest ? { digest: values.digest } : {}),
...(values.records ? { records: values.records } : {}), ...(values.records ? { records: values.records } : {}),
...(values['evidence-receipts'] ...(values['evidence-receipts']
+17 -3
View File
@@ -23,7 +23,14 @@ const releaseSource = fs.readFileSync(
test('accepts the reviewed native CI and digest release contracts', () => { test('accepts the reviewed native CI and digest release contracts', () => {
assert.deepEqual(auditClusterImageRelease(root), { assert.deepEqual(auditClusterImageRelease(root), {
ci: { ci: {
images: ['control', 'control-ai', 'admin', 'local', 'worker'], images: [
'control',
'control-ai',
'admin',
'local',
'local-operator',
'worker',
],
nativeArchitectures: ['amd64', 'arm64'], nativeArchitectures: ['amd64', 'arm64'],
runtimeInventory: true, runtimeInventory: true,
clusterAdminProductFacade: true, clusterAdminProductFacade: true,
@@ -85,7 +92,14 @@ test('accepts the reviewed native CI and digest release contracts', () => {
immutableArtifactRetentionDays: 1, immutableArtifactRetentionDays: 1,
attestedToPublishedDigest: true, attestedToPublishedDigest: true,
}, },
images: ['control', 'control-ai', 'admin', 'worker', 'local'], images: [
'control',
'control-ai',
'admin',
'worker',
'local',
'local-operator',
],
platforms: ['linux/amd64', 'linux/arm64'], platforms: ['linux/amd64', 'linux/arm64'],
keylessSignature: true, keylessSignature: true,
buildkitAttestations: ['sbom', 'provenance'], buildkitAttestations: ['sbom', 'provenance'],
@@ -123,7 +137,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
}, },
durableCatalog: { durableCatalog: {
repository: 'qinglong3-release-catalog', 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', planSchema: 'qinglong/release-catalog-plan@v2',
receiptSchema: 'qinglong/release-catalog-receipt@v2', receiptSchema: 'qinglong/release-catalog-receipt@v2',
tagInventoryDecisionSchema: tagInventoryDecisionSchema:
+35 -2
View File
@@ -29,6 +29,7 @@ function createFixture(t, options = {}) {
const isControl = image === 'control' || image === 'control-ai'; const isControl = image === 'control' || image === 'control-ai';
const isControlAi = image === 'control-ai'; const isControlAi = image === 'control-ai';
const isLocal = image === 'local'; const isLocal = image === 'local';
const isLocalOperator = image === 'local-operator';
const isWorker = image === 'worker'; const isWorker = image === 'worker';
const layoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-oci-layout-')); const layoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-oci-layout-'));
t.after(() => fs.rmSync(layoutRoot, { recursive: true, force: true })); t.after(() => fs.rmSync(layoutRoot, { recursive: true, force: true }));
@@ -103,7 +104,7 @@ function createFixture(t, options = {}) {
? '0:0' ? '0:0'
: isWorker : isWorker
? '65532:65532' ? '65532:65532'
: isLocal : isLocal || isLocalOperator
? '65532:65532' ? '65532:65532'
: '10001:10001', : '10001:10001',
...(isControl ? { ExposedPorts: { '5800/tcp': {} } } : {}), ...(isControl ? { ExposedPorts: { '5800/tcp': {} } } : {}),
@@ -119,6 +120,8 @@ function createFixture(t, options = {}) {
? '/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js' ? '/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js'
: isLocal : isLocal
? '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js' ? '/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 : isControl
? isControlAi ? isControlAi
? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js' ? '/opt/qinglong/node_modules/@qinglong/cluster-control/dist/aiCli.js'
@@ -144,8 +147,17 @@ function createFixture(t, options = {}) {
'io.qinglong.profile': 'edge,standalone', '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 'org.opencontainers.image.description': isLocal
? 'QingLong 3.0 AI-excluded Edge and Standalone runtime' ? 'QingLong 3.0 AI-excluded Edge and Standalone runtime'
: isLocalOperator
? 'QingLong 3.0 short-lived Local management authority'
: isWorker : isWorker
? 'QingLong 3.0 headless Remote Worker runtime' ? 'QingLong 3.0 headless Remote Worker runtime'
: isControl : isControl
@@ -159,6 +171,8 @@ function createFixture(t, options = {}) {
'https://github.com/whyour/qinglong', 'https://github.com/whyour/qinglong',
'org.opencontainers.image.title': isLocal 'org.opencontainers.image.title': isLocal
? 'QingLong 3.0 Local Application' ? 'QingLong 3.0 Local Application'
: isLocalOperator
? 'QingLong 3.0 Local Operator'
: isWorker : isWorker
? 'QingLong 3.0 Worker' ? 'QingLong 3.0 Worker'
: isControl : isControl
@@ -166,7 +180,11 @@ function createFixture(t, options = {}) {
? 'QingLong 3.0 Cluster Control AI' ? 'QingLong 3.0 Cluster Control AI'
: 'QingLong 3.0 Cluster Control' : 'QingLong 3.0 Cluster Control'
: 'QingLong 3.0 Cluster Admin', : 'QingLong 3.0 Cluster Admin',
...(isLocal || isWorker || isControl || image === 'admin' ...(isLocal ||
isLocalOperator ||
isWorker ||
isControl ||
image === 'admin'
? { ? {
'org.opencontainers.image.version': version, '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) => { test('accepts the headless Worker image and attestation closure', (t) => {
const report = auditClusterOciLayout({ const report = auditClusterOciLayout({
root, root,
+24 -3
View File
@@ -93,6 +93,12 @@ function releaseSet(scope) {
...identity, ...identity,
releaseScope: scope, releaseScope: scope,
image: entry.image, 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)}`, 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); 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']) { for (const scope of ['local', 'all']) {
const set = releaseSet(scope); const set = releaseSet(scope);
const selection = createLocalSelection( const selection = createLocalSelection(
@@ -437,7 +443,7 @@ test('selects one immutable Local Compose image without adding device work', ()
options(set, { allowRootService: false }), options(set, { allowRootService: false }),
); );
assert.equal(selection.deploymentFamily, 'local'); 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.releaseSetDigest, set.releaseSetDigest);
assert.equal( assert.equal(
selection.catalog.releaseSetDigest, 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.kind, 'compose');
assert.equal(selection.service.image, references(set).local); assert.equal(selection.service.image, references(set).local);
assert.equal(selection.service.allowRootService, false); 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.networkAccess, false);
assert.equal(selection.verification.deploymentMutation, false); assert.equal(selection.verification.deploymentMutation, false);
assert.equal( assert.equal(
@@ -500,6 +509,18 @@ test('Local selection rejects cluster scope, implicit root policy and drift', ()
), ),
/differs from the verified release set/, /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', () => { 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, /'consumption-bundle'/u);
assert.match(source, /'source-repository'/u); assert.match(source, /'source-repository'/u);
assert.doesNotMatch(source, /['"]release-set['"]/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); assert.match(source, /qinglong\/kubernetes-deployment-lock@v2/u);
}); });
@@ -56,6 +56,7 @@ test('accepts the empty fail-closed production exception policy', () => {
control: 0, control: 0,
'control-ai': 0, 'control-ai': 0,
local: 0, local: 0,
'local-operator': 0,
worker: 0, worker: 0,
}, },
}); });
@@ -31,7 +31,7 @@ test('freezes an independent low-resource local release family', () => {
}); });
assert.deepEqual( assert.deepEqual(
contract.images.map((entry) => entry.image), contract.images.map((entry) => entry.image),
['local'], ['local', 'local-operator'],
); );
assert.equal(contract.releasePlan.clusterEvidenceRequired, false); assert.equal(contract.releasePlan.clusterEvidenceRequired, false);
assert.deepEqual(contract.deploymentFamilies.local.profiles, [ assert.deepEqual(contract.deploymentFamilies.local.profiles, [
@@ -78,7 +78,7 @@ test('freezes an independent low-resource local release family', () => {
contractDigest: contract.contractDigest, contractDigest: contract.contractDigest,
releaseScope: 'local', releaseScope: 'local',
workspacePackageCount: 18, workspacePackageCount: 18,
images: ['local'], images: ['local', 'local-operator'],
clusterEvidenceRequired: false, clusterEvidenceRequired: false,
}, },
); );
@@ -141,12 +141,16 @@ test('combines local and cluster families without weakening either gate', () =>
}); });
assert.deepEqual( assert.deepEqual(
contract.images.map((entry) => entry.image), contract.images.map((entry) => entry.image),
['control', 'control-ai', 'admin', 'worker', 'local'], ['control', 'control-ai', 'admin', 'worker', 'local', 'local-operator'],
); );
assert.equal( assert.equal(
contract.requiredGates.includes('edge-and-standalone-rollout'), contract.requiredGates.includes('edge-and-standalone-rollout'),
true, true,
); );
assert.equal(
contract.requiredGates.includes('local-operator-entrypoint'),
true,
);
assert.equal( assert.equal(
contract.requiredGates.includes('cross-image-release-set'), contract.requiredGates.includes('cross-image-release-set'),
true, true,
@@ -46,7 +46,10 @@ function selectedImages(scope) {
['admin', 'qinglong3-cluster-admin'], ['admin', 'qinglong3-cluster-admin'],
['worker', 'qinglong3-worker'], ['worker', 'qinglong3-worker'],
]; ];
const local = [['local', 'qinglong3-local-application']]; const local = [
['local', 'qinglong3-local-application'],
['local-operator', 'qinglong3-local-operator'],
];
return scope === 'local' return scope === 'local'
? local ? local
: scope === 'cluster' : scope === 'cluster'
@@ -79,7 +82,7 @@ function releaseSet(scope) {
const names = images.map((entry) => entry.name); const names = images.map((entry) => entry.name);
const unsigned = { const unsigned = {
schemaVersion: 1, schemaVersion: 1,
schema: 'qinglong/release-set@v3', schema: 'qinglong/release-set@v4',
release, release,
candidate: { candidate: {
schema: 'qinglong/release-candidate-contract@v1', schema: 'qinglong/release-candidate-contract@v1',
@@ -91,12 +94,16 @@ function releaseSet(scope) {
local: { local: {
selected: ['local', 'all'].includes(scope), selected: ['local', 'all'].includes(scope),
profiles: ['edge', 'standalone'], profiles: ['edge', 'standalone'],
images: names.filter((name) => name === 'local'), images: names.filter((name) =>
['local', 'local-operator'].includes(name),
),
}, },
cluster: { cluster: {
selected: ['cluster', 'all'].includes(scope), selected: ['cluster', 'all'].includes(scope),
profiles: ['cluster', 'worker-edge', 'worker-node'], 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), evidenceReceipts: privateReleaseEvidenceReceipts(release),
@@ -392,8 +399,9 @@ test('supports the complete all-scope image family without runtime coupling', (t
'admin', 'admin',
'worker', 'worker',
'local', 'local',
'local-operator',
]); ]);
assert.equal(report.releaseSet.imageCount, 5); assert.equal(report.releaseSet.imageCount, 6);
assert.equal(report.claims.deploymentMutation, false); assert.equal(report.claims.deploymentMutation, false);
}); });
@@ -67,6 +67,12 @@ function releaseSet(scope) {
...identity, ...identity,
releaseScope: scope, releaseScope: scope,
image: entry.image, 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)}`, digest: `sha256:${String(index + 1).repeat(64)}`,
}), }),
); );
@@ -71,6 +71,12 @@ function releaseSet(scope) {
...identity, ...identity,
releaseScope: scope, releaseScope: scope,
image: entry.image, 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)}`, digest: `sha256:${String(index + 1).repeat(64)}`,
}), }),
); );
+30 -4
View File
@@ -51,6 +51,12 @@ function recordsFor(releaseCandidate) {
...identity, ...identity,
releaseScope: releaseCandidate.release.scope, releaseScope: releaseCandidate.release.scope,
image: entry.image, 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)}`, digest: `sha256:${String(index + 1).repeat(64)}`,
}), }),
); );
@@ -72,7 +78,7 @@ function writeCanonical(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); 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 releaseCandidate = candidate('local');
const records = recordsFor(releaseCandidate); const records = recordsFor(releaseCandidate);
const { validationClockMs: _unused, ...localIdentity } = identity; const { validationClockMs: _unused, ...localIdentity } = identity;
@@ -86,7 +92,7 @@ test('aggregates the independent Local image into one immutable release set', ()
}); });
assert.deepEqual( assert.deepEqual(
releaseSet.images.map((entry) => entry.name), releaseSet.images.map((entry) => entry.name),
['local'], ['local', 'local-operator'],
); );
assert.equal(releaseSet.deploymentFamilies.local.selected, true); assert.equal(releaseSet.deploymentFamilies.local.selected, true);
assert.equal(releaseSet.deploymentFamilies.cluster.selected, false); assert.equal(releaseSet.deploymentFamilies.cluster.selected, false);
@@ -140,8 +146,11 @@ test('closes cluster and all scopes over the exact candidate image order', () =>
...identity, ...identity,
releaseScope: 'all', releaseScope: 'all',
}); });
assert.equal(allSet.images.length, 5); assert.equal(allSet.images.length, 6);
assert.deepEqual(allSet.deploymentFamilies.local.images, ['local']); assert.deepEqual(allSet.deploymentFamilies.local.images, [
'local',
'local-operator',
]);
}); });
test('requires exact private evidence receipts only for Cluster-capable scopes', () => { 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', repositoryOwner: 'UPPERCASE',
releaseScope: 'local', releaseScope: 'local',
image: 'local', image: 'local',
localRoleVerification: 'application_rollout_verified',
digest: `sha256:${'1'.repeat(64)}`, digest: `sha256:${'1'.repeat(64)}`,
}), }),
/lowercase GitHub owner/, /lowercase GitHub owner/,
@@ -315,6 +325,7 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () =>
...identity, ...identity,
releaseScope: 'local', releaseScope: 'local',
image: 'local', image: 'local',
localRoleVerification: 'application_rollout_verified',
digest: 'latest', digest: 'latest',
}), }),
/exact SHA-256 digest/, /exact SHA-256 digest/,
@@ -436,6 +447,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
'--mode=record-image', '--mode=record-image',
...common, ...common,
'--image=local', '--image=local',
'--local-role-verification=application_rollout_verified',
`--digest=sha256:${'1'.repeat(64)}`, `--digest=sha256:${'1'.repeat(64)}`,
`--output=${recordPath}`, `--output=${recordPath}`,
], ],
@@ -443,6 +455,20 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => {
output, output,
); );
assert.equal(fs.statSync(recordPath).mode & 0o777, 0o600); 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'); const setPath = path.join(directory, 'release-set.json');
runCli( runCli(
[ [
+29 -6
View File
@@ -32,7 +32,10 @@ function publicationPlan() {
const version = '3.0.0-alpha.0'; const version = '3.0.0-alpha.0';
const sourceRevision = 'a'.repeat(40); const sourceRevision = 'a'.repeat(40);
const repository = 'ghcr.io/qinglong-release/qinglong3-local-application'; 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 imageDigest = `sha256:${'1'.repeat(64)}`;
const operatorDigest = `sha256:${'9'.repeat(64)}`;
const manifestDigest = `sha256:${'2'.repeat(64)}`; const manifestDigest = `sha256:${'2'.repeat(64)}`;
const unsigned = { const unsigned = {
schemaVersion: 1, 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({ return Object.freeze({
@@ -174,10 +193,14 @@ test('finalizes every exact tag and audits the live terminal state', () => {
const plan = publicationPlan(); const plan = publicationPlan();
const registry = new FakeRegistry(plan); const registry = new FakeRegistry(plan);
const observation = finalizeReleaseTags(plan, registry); const observation = finalizeReleaseTags(plan, registry);
assert.equal(registry.copyCalls().length, 2); assert.equal(registry.copyCalls().length, 4);
assert.equal(observation.tags.length, 2); assert.equal(observation.tags.length, 4);
assert.equal( 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, true,
); );
const copiesBeforeAudit = registry.copyCalls().length; const copiesBeforeAudit = registry.copyCalls().length;
@@ -185,7 +208,7 @@ test('finalizes every exact tag and audits the live terminal state', () => {
schemaVersion: 1, schemaVersion: 1,
planDigest: plan.planDigest, planDigest: plan.planDigest,
observationDigest: observation.observationDigest, observationDigest: observation.observationDigest,
tagCount: 2, tagCount: 4,
allTagsExactDigest: true, allTagsExactDigest: true,
registryMutation: false, registryMutation: false,
compatible: true, 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); assert.equal(registry.copyCalls().length, 1);
const observation = finalizeReleaseTags(plan, registry); const observation = finalizeReleaseTags(plan, registry);
assert.equal(registry.copyCalls().length, 2); assert.equal(registry.copyCalls().length, 4);
assert.equal(observation.tags.length, 2); assert.equal(observation.tags.length, 4);
assert.equal( assert.equal(
registry.resolveDigest(plan.images[0].tags[0].reference), registry.resolveDigest(plan.images[0].tags[0].reference),
plan.images[0].digest, plan.images[0].digest,