mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): close public local release pair
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner CLI 为 301 total / 294 pass / 7 conditional skip / 0 fail,backend 为 1,610 total / 1,608 pass / 2 conditional skip / 0 fail,静态 release workflow 审计 100/100。该切片证明发布机制闭合,不冒充已经存在 GHCR tag 或真实用户可下载 Public Release Set;首份正式可交付物仍需受保护 release tag 的六镜像、签名、catalog-bound Local/Cluster 部署证据和终态 closure。
|
||||||
|
|
||||||
- D-411/ADR-0506(已实现,真实 downloadable v2 artifact 待授权):Local Alpha materializer 不再凭调用 `create` 就把九个 gate 无条件写成 `passed`。bundle schema 升为 `qinglong/alpha-local-trial-kit@v2`,新增 `verification-evidence.json`,其 subject 精确绑定版本、source、Tier-1 架构与 Application/operator image ID,workflow 精确绑定 `whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next`、workflow SHA、`workflow_dispatch`、`local-image`、run ID/attempt。CI 静态门固定 `fresh journey → native cancellation → record-verification → create → audit → upload`,evidence 作为第七个闭合文件进入 manifest byte/SHA-256 与 `SHA256SUMS`;create/audit 均拒绝跨源码、跨架构、跨镜像或跨 workflow 复制。旧 v1 bundle 因没有来源证明只保留为工程候选。提交 `4239464a` 的主 CI run `32990652047` 已 40/40,Kubernetes run `32990652416` 与三节点 Security run `32990653482` 同源成功,证明源码的双架构门;但本地 `4239464a` v1 archive 不是 exact CI artifact,仍不能冒充 v2 用户 Alpha。该增强只增加一个小型发布期 JSON,不新增 workspace package、镜像 layer、设备依赖、常驻进程、RSS 或端口;首个真实双架构 v2 下载物仍需维护者显式授权 milestone workflow。
|
- D-411/ADR-0506(已实现,真实 downloadable v2 artifact 待授权):Local Alpha materializer 不再凭调用 `create` 就把九个 gate 无条件写成 `passed`。bundle schema 升为 `qinglong/alpha-local-trial-kit@v2`,新增 `verification-evidence.json`,其 subject 精确绑定版本、source、Tier-1 架构与 Application/operator image ID,workflow 精确绑定 `whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next`、workflow SHA、`workflow_dispatch`、`local-image`、run ID/attempt。CI 静态门固定 `fresh journey → native cancellation → record-verification → create → audit → upload`,evidence 作为第七个闭合文件进入 manifest byte/SHA-256 与 `SHA256SUMS`;create/audit 均拒绝跨源码、跨架构、跨镜像或跨 workflow 复制。旧 v1 bundle 因没有来源证明只保留为工程候选。提交 `4239464a` 的主 CI run `32990652047` 已 40/40,Kubernetes run `32990652416` 与三节点 Security run `32990653482` 同源成功,证明源码的双架构门;但本地 `4239464a` v1 archive 不是 exact CI artifact,仍不能冒充 v2 用户 Alpha。该增强只增加一个小型发布期 JSON,不新增 workspace package、镜像 layer、设备依赖、常驻进程、RSS 或端口;首个真实双架构 v2 下载物仍需维护者显式授权 milestone workflow。
|
||||||
|
|
||||||
- Alpha 阶段产物历史基线(当前性已由 D-411 收紧):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Application Docker archive(SHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksum;image identity/architecture/non-root user、read-only/no-network、128 MiB/0.5 CPU smoke、Edge/Standalone lifecycle/graceful stop/SQLite integrity、库存对账与 Trivy 0.70.0 HIGH/CRITICAL=0 已复验。但该 archive 只有 headless runtime,未携带完成 fresh setup/Owner 管理所需的独立 `ql3` 制品,因此按 D-408 重新准确分类为“运行时工程候选”,不再冒充完整用户 Alpha。macOS Docker Desktop 无法等价证明的 Local API cancellation 由原生 Linux arm64 job `97986754052` 通过;本机 Edge 首次 startup receipt 在 Docker Desktop 文件桥出现一次瞬态,精确重跑和原生 Linux门均通过,证据未隐藏首次失败。远端 CI run `32903679764` 首轮为 37/40(两项 GitHub action 内部 DNS 失败、一次 PostgreSQL 18 x64 scheduler 并发断言),failed-only attempt 2 收敛为 40/40;独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration/CNPG/PVC run `32903679570` 同源成功。该证据保留为演进记录,不再代表当前 v2 Alpha bundle 资格;public GHCR digest、签名/attestation、catalog、deployment-lock 与生产 HA/DR/CSI/IdP 仍是 Public Release Set 的硬门。
|
- Alpha 阶段产物历史基线(当前性已由 D-411 收紧):`QingLong 3.0 CI` 的显式 `produce_alpha_artifacts` 门只在手动里程碑运行中归档已经通过原生测试的镜像,而不把普通 push/PR 的中间构建冒充发布。source `e3c05862b8c2690d69f58b098cdc128a09c83f97` 已产出 Local arm64 Application Docker archive(SHA-256 `01afb30cbe0c21f980ca083ad98fd316e659941f940dd8930ffd9ccfa7153edf`,image ID `sha256:dfce2cc9d70044d75f72f2cc3075e1f24569fb9fe279d9c25a45698c19c3bde9`)、CycloneDX SBOM、release-candidate contract、manifest、verification evidence 与 checksum;image identity/architecture/non-root user、read-only/no-network、128 MiB/0.5 CPU smoke、Edge/Standalone lifecycle/graceful stop/SQLite integrity、库存对账与 Trivy 0.70.0 HIGH/CRITICAL=0 已复验。但该 archive 只有 headless runtime,未携带完成 fresh setup/Owner 管理所需的独立 `ql3` 制品,因此按 D-408 重新准确分类为“运行时工程候选”,不再冒充完整用户 Alpha。macOS Docker Desktop 无法等价证明的 Local API cancellation 由原生 Linux arm64 job `97986754052` 通过;本机 Edge 首次 startup receipt 在 Docker Desktop 文件桥出现一次瞬态,精确重跑和原生 Linux门均通过,证据未隐藏首次失败。远端 CI run `32903679764` 首轮为 37/40(两项 GitHub action 内部 DNS 失败、一次 PostgreSQL 18 x64 scheduler 并发断言),failed-only attempt 2 收敛为 40/40;独立 Kubernetes deployment run `32903679644` 与三节点 Security Administration/CNPG/PVC run `32903679570` 同源成功。该证据保留为演进记录,不再代表当前 v2 Alpha bundle 资格;public GHCR digest、签名/attestation、catalog、deployment-lock 与生产 HA/DR/CSI/IdP 仍是 Public Release Set 的硬门。
|
||||||
|
|
||||||
- D-408/ADR-0503(进行中):阶段产物成熟度现在按真实部署用户旅程而非“已有 Dockerfile/镜像”裁决。新增独立 `qinglong3-local-operator` 短生命周期镜像,复用既有 `@qinglong/local-owner-cli` 的统一 `ql3` 入口而不新增 workspace package;它默认 `65532:65532`、无端口、无 listener/daemon/timer、network none,和常驻 Local Application 保持物理制品分离,因此 Owner/bootstrap authority 不进入 runtime closure,Edge 稳态资源零变化。本机基于未提交工作树构建的 arm64 operator 原型 ID 为 `sha256:115e90a7442b3c92db0c566f8fc8a560e689878b67eace0236836681a14689ae`,运行库存为 9 package/904 files/9,479,647 bytes;read-only、drop ALL、no-new-privileges、128 MiB/0.5 CPU/32 PID 下的 `ql3 --version` 与 `ql3 setup --help` 已通过。这些数值只证明实现可构建,不冒充 commit-bound release evidence。Alpha workflow 将在同一原生 runner 上把 Application 与 operator 通过一次 `docker image save` 合并为去重的 `qinglong3-local-trial-kit-<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 bytes;read-only、drop ALL、no-new-privileges、128 MiB/0.5 CPU/32 PID 下的 `ql3 --version` 与 `ql3 setup --help` 已通过。这些数值只证明实现可构建,不冒充 commit-bound release evidence。Alpha workflow 将在同一原生 runner 上把 Application 与 operator 通过一次 `docker image save` 合并为去重的 `qinglong3-local-trial-kit-<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 kit;Public Release Set 的 operator artifact 决策已由 D-412 接受,但不等于正式 release 已产生。
|
||||||
|
|
||||||
- D-407/ADR-0502(已完成受审 Kubernetes live ceremony):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact ID;Cluster Control 按 credential record 精确选一把 key,未知 ID/material 一律 unavailable,绝不 fallback 或遍历,因此认证热路径仍为一次摘要。旧 raw pepper 只通过 `legacy-v1` singleton bridge 保持通用 CLI/进程兼容;Kubernetes Job 和常驻 Cluster Control manifest 已统一为 keyring-only,不再维护第二套单值 Secret 注入模式。新增 `pepper.references` 以数据库时间返回最多 64 个当前 latest active/unexpired credential ID 和 `hasMore`,只作为退休前检查,不执行删除。keyring 文件有 2 KiB、canonical/no-symlink/private/stable-read 边界,无 watcher/timer/新连接池;Edge/Standalone package、依赖与常驻资源零变化。远程 run `32893754795` 在 source `beb490c48c7d8ee4aee629924b5003fd8c73e9cb` 上完成 K3s `v1.34.3+k3s1` 三节点、CloudNativePG 1.30.0 三实例 PostgreSQL 18.4、三次真实双副本反亲和 rollout 和五次 `/api/v3` 认证 probe:old/new 在 overlap 期间均认证成功并因无 Project role 返回 403,旧代引用从 1 收敛至 0,contract 后 old 返回 401、new 仍返回 403;数据库保留 1 个旧代/3 个新代 credential version、四次授权拒绝与一次认证拒绝。首次远程失败还暴露了 no-symlink 运行时约束与 kubelet Atomic Writer 投影的架构冲突;最终部署用 hardened init container 固定解析一个 `..data` generation,将 CA/keyring 复制成 `0400` Pod-private 普通文件,常驻容器不再读取原始 symlink 投影。完整 backend 为 `1599 total / 1597 pass / 2 conditional skip / 0 fail`,治理/部署聚焦门为 88/88;source `f8934b401d724378fe5a6ea9dbe63e696b5480b9` 的远程 CI run `32898407637` 为 40/40,CloudNativePG failover、Plugin Package PostgreSQL OCI recovery、Secret rotation、Provider credential K3s/CNPG 等关键 live job 全部通过,独立 Kubernetes deployment run `32898407590` 同样通过。live 报告离线复审 `compatible=true/findings=[]`,SHA-256 为 `d9e9fd1395adcef60f7f360959fcad27a9b2f0b132869bc1c75043dedd400ff6`。该门关闭应用合同与权限边界,不冒充生产 control-plane HA、跨主机 STONITH/DR、加密 CSI 或外部 ingress TLS;material GC、持久 active catalog、索引/大规模查询计划、远程 UI/API 与双人复核仍是后续门禁。
|
- D-407/ADR-0502(已完成受审 Kubernetes live ceremony):Cluster API credential pepper 从“数据库保存 key ID、运行时却只有一个固定 material”收敛为最多 old/new 两代的显式 keyring。Security Administration 只用 active key 签发并持久化 exact ID;Cluster Control 按 credential record 精确选一把 key,未知 ID/material 一律 unavailable,绝不 fallback 或遍历,因此认证热路径仍为一次摘要。旧 raw pepper 只通过 `legacy-v1` singleton bridge 保持通用 CLI/进程兼容;Kubernetes Job 和常驻 Cluster Control manifest 已统一为 keyring-only,不再维护第二套单值 Secret 注入模式。新增 `pepper.references` 以数据库时间返回最多 64 个当前 latest active/unexpired credential ID 和 `hasMore`,只作为退休前检查,不执行删除。keyring 文件有 2 KiB、canonical/no-symlink/private/stable-read 边界,无 watcher/timer/新连接池;Edge/Standalone package、依赖与常驻资源零变化。远程 run `32893754795` 在 source `beb490c48c7d8ee4aee629924b5003fd8c73e9cb` 上完成 K3s `v1.34.3+k3s1` 三节点、CloudNativePG 1.30.0 三实例 PostgreSQL 18.4、三次真实双副本反亲和 rollout 和五次 `/api/v3` 认证 probe:old/new 在 overlap 期间均认证成功并因无 Project role 返回 403,旧代引用从 1 收敛至 0,contract 后 old 返回 401、new 仍返回 403;数据库保留 1 个旧代/3 个新代 credential version、四次授权拒绝与一次认证拒绝。首次远程失败还暴露了 no-symlink 运行时约束与 kubelet Atomic Writer 投影的架构冲突;最终部署用 hardened init container 固定解析一个 `..data` generation,将 CA/keyring 复制成 `0400` Pod-private 普通文件,常驻容器不再读取原始 symlink 投影。完整 backend 为 `1599 total / 1597 pass / 2 conditional skip / 0 fail`,治理/部署聚焦门为 88/88;source `f8934b401d724378fe5a6ea9dbe63e696b5480b9` 的远程 CI run `32898407637` 为 40/40,CloudNativePG failover、Plugin Package PostgreSQL OCI recovery、Secret rotation、Provider credential K3s/CNPG 等关键 live job 全部通过,独立 Kubernetes deployment run `32898407590` 同样通过。live 报告离线复审 `compatible=true/findings=[]`,SHA-256 为 `d9e9fd1395adcef60f7f360959fcad27a9b2f0b132869bc1c75043dedd400ff6`。该门关闭应用合同与权限边界,不冒充生产 control-plane HA、跨主机 STONITH/DR、加密 CSI 或外部 ingress TLS;material GC、持久 active catalog、索引/大规模查询计划、远程 UI/API 与双人复核仍是后续门禁。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR-0507:Public Local Application 与 Operator 发布对
|
||||||
|
|
||||||
|
- 状态:Accepted(首份真实公开发布待受保护 tag)
|
||||||
|
- 日期:2026-08-27
|
||||||
|
- 决策:D-412
|
||||||
|
- 关联:ADR-0432、ADR-0437、ADR-0503、ADR-0506
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Local 用户的完整 3.0 旅程已经物理分离为常驻 Application 与短生命周期 operator。前者只运行 Edge/Standalone 数据面,后者通过统一 `ql3` 入口承担 setup、upgrade 和 recovery。Alpha Trial Kit 已同时携带两者,但正式 Public Release Set 仍只列出 Application。
|
||||||
|
|
||||||
|
这会产生不可接受的发布断层:受保护 workflow 可以签名并闭合一个无法独立完成 fresh setup 的“Local release”,操作者只能另找未被同一 source、catalog 和 tag closure 证明的管理镜像。把管理命令重新塞回常驻 Application 又会扩大低配路由器的攻击面和稳态资源闭包。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
### 1. Local family 是两个独立镜像的精确闭包
|
||||||
|
|
||||||
|
`local` scope 必须恰好包含:
|
||||||
|
|
||||||
|
- `qinglong3-local-application`:唯一常驻 Edge/Standalone service;
|
||||||
|
- `qinglong3-local-operator`:只在显式管理动作期间运行的 Owner authority。
|
||||||
|
|
||||||
|
`all` scope 因此由 Local 两镜像和 Cluster 四镜像组成,共六个;`cluster` scope 仍为四个。两个 Local 镜像分别构建、执行双架构 OCI 证明、OS 漏洞门、digest 签名与 attestation,不能共享 digest record 或由其中一个代替另一个。
|
||||||
|
|
||||||
|
### 2. 角色验证必须显式记录
|
||||||
|
|
||||||
|
image record 升为 `qinglong/release-set-image-record@v2`。publisher 必须传入 candidate matrix 中的 exact `localRoleVerification`:Application 为 `application_rollout_verified`,operator 为 `operator_entrypoint_verified`,Cluster 角色为 `not_applicable`。Application 必须完成 Edge/Standalone rollout;operator 必须在 read-only、network none、drop-all、no-new-privileges、128 MiB、0.5 CPU、32 PID 边界内通过 `--version` 和 `setup --help`。
|
||||||
|
|
||||||
|
release-set 升为 `qinglong/release-set@v4`,OCI artifact/file media type 同步升为 `application/vnd.qinglong.release-set.v4+json`。旧孵化 schema 没有公开 3.0 消费者,因此失败关闭,不引入含糊的自动补全。
|
||||||
|
|
||||||
|
### 3. 目标选择绑定 operator,但不把它常驻化
|
||||||
|
|
||||||
|
Local catalog selection 升为 `qinglong/local-compose-release-image@v3`,必须同时绑定同一 owner、source 和 release-set 中的 Application/operator immutable digest。Compose revision 升为 `qinglong/local-compose-image-selection@v3`,保存 `operator_image` 作为 setup/upgrade/recovery authority,但 Compose service 仍只有 Application。
|
||||||
|
|
||||||
|
发布后的 Local gate 从公开 catalog 重建 selection,拉取并验证 operator 入口,再用 Application 完成 Edge 与 Standalone rollout。operator 不开端口、不运行 listener/daemon/timer,默认无网络;低配设备稳态不新增进程、RSS 或连接。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### Public Release Set 只发布 Application
|
||||||
|
|
||||||
|
拒绝。它没有覆盖 fresh setup 的真实用户旅程,并迫使用户使用 catalog 外管理制品。
|
||||||
|
|
||||||
|
### 将 Owner CLI 合并回 Application
|
||||||
|
|
||||||
|
拒绝。它把高权限管理闭包带进每个常驻低配设备进程,破坏物理隔离。
|
||||||
|
|
||||||
|
### 把 operator 配置为 Compose sidecar
|
||||||
|
|
||||||
|
拒绝。管理 authority 没有常驻需求;sidecar 会无谓增加稳态资源和攻击面。
|
||||||
|
|
||||||
|
## 影响
|
||||||
|
|
||||||
|
- Local 发布和首次拉取最多多一个 operator image;只运行 Application 的稳态不变;
|
||||||
|
- setup/upgrade/recovery 可在动作完成后删除 operator layer,下一次按 selection digest 重新获取;
|
||||||
|
- catalog、finalizer 和回退 revision 都能证明两个 Local 角色来自同一 release;
|
||||||
|
- 旧 v2 Local selection 与 Compose revision 失败关闭,孵化环境必须从 v4 catalog 重新物化;
|
||||||
|
- 该决策不产生真实 GHCR tag,也不把普通 CI artifact 声称为正式发布。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- release candidate、record、set、catalog、publication closure 和 deployment-lock 正反向测试覆盖 `local=2`、`cluster=4`、`all=6`;
|
||||||
|
- 普通 CI 对 operator 增加独立 multi-arch OCI layout、SBOM/provenance 和 image-scoped Trivy 证据;
|
||||||
|
- 受保护 release workflow 在 record 前验证角色,在 catalog consumption 后再次验证 operator 入口;
|
||||||
|
- Local Owner CLI 的 prepare、upgrade、rollback 和 adopted deployment fixtures 保留同一 operator digest;
|
||||||
|
- 完整 backend、package boundary、Local Owner CLI 和静态 workflow 审计通过后才允许提交。
|
||||||
@@ -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) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -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 identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release |
|
| Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release |
|
||||||
| Public Release Set | 生产用户 | 受保护 tag、五镜像 multi-arch digest、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 |
|
| Public Release Set | 生产用户 | 受保护 tag、六镜像 multi-arch digest(Local Application/operator + Cluster 四角色)、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 |
|
||||||
|
|
||||||
只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
|
只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。
|
||||||
|
|
||||||
|
|||||||
@@ -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 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与
|
version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 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
|
||||||
reference;discovery tag 无 authority。
|
reference;discovery 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 receipt;version/source/catalog tag 都只能用于发现,部署始终以
|
kubectl/kubeconfig 和目标 cluster UID 绑定进 preflight/apply receipt;version/source/catalog tag 都只能用于发现,部署始终以
|
||||||
@@ -466,7 +467,9 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计
|
|||||||
|
|
||||||
路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站
|
路由器或其他低配 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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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' ||
|
||||||
|
|||||||
@@ -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 ?? '') ||
|
||||||
|
|||||||
@@ -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'
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user