From 0f4b6b43fbbc12c707d6cc4cf6d9c0b2bfc68c56 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 30 Aug 2026 19:50:24 +0800 Subject: [PATCH] feat(ql3): bind offline adopted target images --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 + ...ent-bound-offline-docker-adopted-target.md | 33 +++++++++ docs/adr/README.md | 1 + docs/operations/ql3-alpha-candidate.md | 1 + docs/operations/ql3-local-deployment.md | 40 +++++++++- .../adopted-bundle/adoptedBundle.ts | 3 +- .../src/deployment/adopted-bundle/contract.ts | 30 +++++++- .../src/deployment/adopted-bundle/material.ts | 73 ++++++++++++++++++- .../cutover/legacyRollbackContract.ts | 2 +- .../cutover/target-run/targetRunContract.ts | 14 ++-- .../target-run/targetRunRecordEvidence.ts | 10 ++- .../src/deployment/cutover/targetEvidence.ts | 5 +- .../src/deployment/foundation/targetImage.ts | 55 ++++++++++++++ .../test/adoptedDeploymentBundle.test.cjs | 49 ++++++++++++- .../test/cutoverTargetRun.test.cjs | 50 ++++++++++++- 15 files changed, 345 insertions(+), 23 deletions(-) create mode 100644 docs/adr/ADR-0522-content-bound-offline-docker-adopted-target.md create mode 100644 packages/ql3-local-owner-cli/src/deployment/foundation/targetImage.ts diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 3ff37b3e..7e8e173c 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-426b1/ADR-0522(源码候选,尚未形成新阶段实物):D-426a 暴露出真实的离线切换缺口:Trial Kit archive 只有本地 image reference 与 Docker content ID,既有 target barrier 却只接受 registry `name@sha256:...`;伪造 GHCR RepoDigest 或 Alpha release catalog 会污染正式发布权威。现统一引入 `{authority,reference,imageId}` target image identity,正式部署保持 `registry-digest`,离线 Trial Kit 使用 `local-image-id`,两者都同时核对容器 `Config.Image` 与 `.Image` content ID,并把三项绑定进 cutover journal。adopted bundle 新增 `docker-target` service kind,生成 Application v4 与内容绑定的 `service/docker-target.json`,固定 numeric UID:GID、restart=no、network none、read-only rootfs、drop ALL、no-new-privileges、Profile memory/PID 和 exact read-write/read-only mounts;descriptor 不创建或启动容器。没有新增 package、依赖或稳态进程。该切片只有定向源码测试证据,不能替代已交付的 D-426a 双架构 artifact;必须等 D-426b2 在 exact 上传 bundle 上实跑受认证 transform/apply、target start/stop 和 clean `rollback_candidate`,才允许宣称新阶段产物。 + - D-426a/ADR-0521(已交付同源双架构 headless Alpha 阶段实物):二十天研发的阶段产物继续从“只读盘点”推进到仍不触碰生产切换的 side-by-side 暂存。Trial Kit 新增 canonical `upgrade-rehearsal.sh`,只接受操作者从 D-425 完整 evidence 中审核并显式提交的 SQLite/data-directory plan digest;它核对整包与 exact Operator identity 后,在新的 `0700/0600` rehearsal root 内以 read-only legacy bind、无网络、只读 rootfs、drop-all、128 MiB/0.5 CPU/32 PID 顺序执行 SQLite stage/verify/activation 和完整目录 stage/verify。`stage-summary.json` 绑定 source/architecture、两个 reviewed plan、SQLite manifest/activation 与目录 manifest digest,并固定 `legacySource=read_only`、`cutover=not_authorized`。Trial Kit/verification/auditor 升为 `@v7/@v5/@v4`,Local milestone 升为 `@v4` 并绑定双架构 `upgradeRehearsalSha256`;显式 artifact job 必须在将要上传的 exact bundle 上从 readiness plan 接续实跑 rehearsal。提交 `7a8acacb6cb49bda2116bf029fbbfe447ae5d911` 的普通 CI [run 33306005705](https://github.com/whyour/qinglong/actions/runs/33306005705) 为 41 success/3 expected scope skip/0 fail,同源 Kubernetes deployment [run 33306005706](https://github.com/whyour/qinglong/actions/runs/33306005706) 成功;显式 Local headless [run 33306650776](https://github.com/whyour/qinglong/actions/runs/33306650776) 为 42 success/2 Cluster scope skip/0 fail,exact 双架构 bundle 实跑 readiness 与 rehearsal 后生成 amd64 `187,554,547` bytes、arm64 `184,786,163` bytes 和 `6,206` bytes milestone v4,均保留至 2026-09-29。下载后的 milestone checksum 与离线 auditor 返回 `compatible=true`,并确认两个架构的 rehearsal digest 不同。没有 transform/apply、Owner/Secret authority、2.x stop、3.0 target start、cutover、rollback、migration、依赖、package、daemon、listener、timer 或稳态资源增量;D-426b/c 才分别闭合 adopted start/clean rollback 与 write-after reconciliation。 - D-425/ADR-0520(已交付同源双架构 headless Alpha 阶段实物):二十天研发的 Local 阶段产物开始服务现有 2.x 部署,而不再只支持 fresh 数据根。Trial Kit 新增 manifest/checksum/auditor 共同绑定的 canonical `upgrade-readiness.sh`;它只接受 `edge|standalone`、canonical 2.x data root 和尚不存在的 evidence root,先核对 exact Operator image/source/architecture,再以当前 UID:GID、read-only legacy bind mount、只读 rootfs、`network=none`、drop-all capabilities、128 MiB memory/swap、0.5 CPU、32 PID 运行正式 `local-sqlite.adoption.inspect` 与 `local-data-directory.adoption.inspect`。两个完整 plan 写入独立 `0700/0600` evidence,不解析或代替人工审核,不执行 stage、activation、transform/apply、cutover 或 rollback。Trial Kit/verification/auditor 升级为 `@v6/@v4/@v3`,Local milestone 升级为 `@v3` 并直接绑定双架构 readiness script digest;显式 artifact job 必须在原生 amd64/arm64 上创建生产形态 2.x fixture,实跑将要上传的 exact 脚本并要求两个结果均为 `inspected`。无新 package、依赖、migration、listener、daemon、timer、watcher 或 Cluster authority;聚焦 bundle/milestone/stage/fixture 回归为 `30/30`。提交 `d6571e4b89eaf29ed6277dd08bbd7ffb57a3705d` 的普通 CI [run 33295923855](https://github.com/whyour/qinglong/actions/runs/33295923855) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33295923822](https://github.com/whyour/qinglong/actions/runs/33295923822) 成功;显式 Local headless [run 33300121149](https://github.com/whyour/qinglong/actions/runs/33300121149) 为 42 success/2 scope skip/0 fail,两个原生架构均实跑 exact readiness 并由 bundle auditor 返回 `compatible=true`,下载后的 milestone v3 再确认 source/run/attempt 与两个不同 readiness digest 闭合。amd64/arm64/milestone artifact 保留至 2026-09-29;它们仍为 `alpha_candidate_not_public_release`,该切片也仍只是完整 side-by-side 升级与 clean/write-after 回退 rehearsal 的第一阶段。 diff --git a/docs/adr/ADR-0522-content-bound-offline-docker-adopted-target.md b/docs/adr/ADR-0522-content-bound-offline-docker-adopted-target.md new file mode 100644 index 00000000..16296d1e --- /dev/null +++ b/docs/adr/ADR-0522-content-bound-offline-docker-adopted-target.md @@ -0,0 +1,33 @@ +# ADR-0522:内容绑定的离线 Docker Adopted Target + +- 状态:Accepted(D-426b 源码候选;阶段实物尚未闭合) +- 日期:2026-08-30 +- 决策:D-426b1 + +## 上下文 + +D-426a 已交付可下载、可校验、可执行 reviewed side-by-side stage 的双架构 Trial Kit,但其 Docker archive 只含本地 image reference 与 Docker content ID,不具备 registry `RepoDigest`。既有 target-start 契约只接受 `name@sha256:...`,因此下载产物虽然能完成 stage,却不能诚实创建并启动 adopted target。把本地镜像伪装成 GHCR digest,或为 Alpha branch 伪造 release catalog/tag provenance,都会破坏正式 Compose 发布链。 + +## 决策 + +目标镜像权威统一为 exact `targetImage`: + +- `registry-digest`:reference 必须是不可变 `name@sha256:...`; +- `local-image-id`:reference 只允许有界本地 tag,并必须来自同一 Trial Kit; +- 两种权威都必须携带 `sha256:...` Docker content ID。 + +target start/restart/stop 与 Legacy rollback 共享这一不可变对象。容器证据同时核对 `Config.Image` reference 和 `.Image` content ID;journal 中的镜像 digest 绑定 authority/reference/imageId 三者。content ID 漂移不会启动容器,而是收敛到 `manual_required`。 + +adopted bundle 增加 `docker-target` service kind,生成 `service/docker-target.json` 与 Application v4 config。descriptor 固定 numeric UID:GID、`restart=no`、无网络、read-only rootfs、drop ALL、no-new-privileges、Profile memory/PID 上限、deployment root 可写 mount 和 legacy source 只读 mount。它只准备创建材料,不创建或启动容器,也不授予 cutover。 + +正式 Compose 继续只接受 catalog-bound GHCR digest selection;systemd/OpenRC 不接收 Docker image authority。没有新增 workspace package、生产依赖、daemon、listener、timer、watcher、队列或稳态资源。 + +## 阶段实物门 + +本 ADR 当前只是源码候选,不得称为已交付的 D-426b 阶段实物。只有后续同源原生 amd64/arm64 artifact job 在将要上传的 exact Trial Kit 上完成受认证 transform/apply、生成并验证 `docker-target.json`、真实 target start/stop,并得到 clean `rollback_candidate`,再由 bundle/milestone auditor 闭合,才能把状态升级为“阶段实物已交付”。 + +在此之前,用户可继续使用 D-426a 的 `upgrade-rehearsal.sh` 完成 read-only legacy + side-by-side stage;不得从本 ADR 推导生产切换授权。 + +## 后续 + +D-426b2 接入受认证 transform/apply、descriptor 消费、真实 target start/stop 与 clean rollback candidate;D-426c 处理 target 写入后的 `reconciliation_required`。Public prerelease/release 仍需维护者显式授权。 diff --git a/docs/adr/README.md b/docs/adr/README.md index a9f18274..4a4b91f5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -525,6 +525,7 @@ | [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | Accepted(D-424 双架构 milestone 已交付) | | [ADR-0520](./ADR-0520-downloadable-local-legacy-upgrade-readiness.md) | 可下载的 Local Legacy 升级就绪盘点 | Accepted(D-425 双架构 Alpha 实物已交付) | | [ADR-0521](./ADR-0521-reviewed-side-by-side-local-upgrade-stage.md) | 受审核计划驱动的 Local Side-by-side 升级暂存 | Accepted(D-426a 双架构 Alpha 实物已交付) | +| [ADR-0522](./ADR-0522-content-bound-offline-docker-adopted-target.md) | 内容绑定的离线 Docker Adopted Target | Accepted(D-426b 源码候选;阶段实物尚未闭合) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index bb16226f..9911c0f2 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -32,6 +32,7 @@ | D-424 Console Secret-backed 自动化切片 | current-only metadata、强认证 AES-256-GCM create/rotate 与 Task pinned `SecretRef` 绑定已完成;真实 SQLite/loopback、本地与远端门、同源双架构 Console milestone 及离线 auditor 均通过 | Cluster 不复用 Local proof/custody;不提供明文读取、删除或历史浏览;仍不是正式发布或生产升级 | | D-425 2.x 升级就绪盘点 | 同源 v6 Trial Kit 已交付 amd64/arm64 headless 阶段实物;canonical `upgrade-readiness.sh` 把 2.x root 只读挂载,在 128 MiB/无网络边界内由 exact Operator 生成 SQLite 与完整目录两个计划;artifact job 实跑、bundle auditor 与 milestone v3 均闭合 | 只完成 inspect,不授权 stage、activation、cutover 或 rollback;不是 Public Release | | D-426a Side-by-side 暂存 | 同源 v7 Trial Kit 已交付 amd64/arm64 headless 阶段实物;reviewed-plan `upgrade-rehearsal.sh` 在新的私有 root 中执行 SQLite stage/verify/activation 与完整目录 stage/verify,legacy root 始终只读,summary 固定 `cutover=not_authorized`;exact artifact job 实跑且 milestone v4 离线审计闭合 | 不执行 transform/apply、目标启动、cutover 或回退;仍不是 Public Release | +| D-426b1 离线 adopted target 权威 | 源码候选已支持 registry digest 与 Trial Kit local image ID 两类强绑定,并可生成 Application v4 + `docker-target.json`;正式 Compose catalog 权威未放宽 | 尚未生成新的双架构 artifact;transform/apply、真实 start/stop、clean `rollback_candidate` 等待 D-426b2 exact-bundle 门,当前不可称为阶段实物 | D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,而且没有改名复用 run `33173769047` 的旧 archive。修复提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的普通主 CI [run 33229592307](https://github.com/whyour/qinglong/actions/runs/33229592307) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33229592293](https://github.com/whyour/qinglong/actions/runs/33229592293) 成功;随后显式 Local Console milestone [run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 为 42 success/2 scope skip/0 fail。由此 Web 创建能力已进入新的阶段实物,而不再只是候选源码。 diff --git a/docs/operations/ql3-local-deployment.md b/docs/operations/ql3-local-deployment.md index 78e0fc6d..4ca38bf4 100644 --- a/docs/operations/ql3-local-deployment.md +++ b/docs/operations/ql3-local-deployment.md @@ -222,7 +222,7 @@ D-64 全部完成,也不得自动重启 2.x。 ### 2.3 Docker adopted target 启动与重启屏障 ADR-0310 已补齐上述段落中的 Docker target barrier;ADR-0313 又补齐 Docker `manual_required` 的实例级 -lineage、只读诊断与双阶段新 ceremony 授权。写后回退仍未完成。target 容器必须由 operator 预先创建但保持停止,并满足:完整 ID、不可变 digest image、 +lineage、只读诊断与双阶段新 ceremony 授权。写后回退仍未完成。target 容器必须由 operator 预先创建但保持停止,并满足:完整 ID、受审核镜像引用和精确 Docker content ID、 `restart=no`、read-only rootfs、非 privileged、`no-new-privileges`,以及能把宿主机 Application v3 config、 legacy commitment、activation 和 source 精确映射到 config 中路径的唯一读写 bind mount。自动重启策略 `unless-stopped`/`always` 会绕过每代 Legacy recheck,因此 adopted target 不允许使用。 @@ -245,12 +245,19 @@ legacy commitment、activation 和 source 精确映射到 config 中路径的唯 "instanceId": "router-edge-1", "activationPath": "/opt/qinglong/private/qinglong3-activation.json", "legacySourcePath": "/opt/qinglong/data/database.sqlite", + "targetDatabasePath": "/opt/qinglong/data/database.ql3.sqlite", + "recoveryPath": "/opt/qinglong/data/database.recovery.sqlite", + "manifestPath": "/opt/qinglong/private/qinglong3-adoption.json", "expectedLegacyDatabasePath": "/ql/data/database.sqlite", "expectedActivationDigest": "REPLACE_WITH_64_HEX_ACTIVATION_DIGEST", "expectedLegacyCommitmentDigest": "REPLACE_WITH_64_HEX_COMMITMENT_DIGEST", "expectedLegacyContainerId": "REPLACE_WITH_FULL_64_HEX_LEGACY_ID", "expectedTargetContainerId": "REPLACE_WITH_FULL_64_HEX_TARGET_ID", - "expectedTargetImage": "registry.example/qinglong3@sha256:REPLACE_WITH_64_HEX_DIGEST", + "targetImage": { + "authority": "registry-digest", + "reference": "registry.example/qinglong3@sha256:REPLACE_WITH_64_HEX_DIGEST", + "imageId": "sha256:REPLACE_WITH_DOCKER_IMAGE_CONTENT_ID" + }, "applicationConfigPath": "/opt/qinglong3/local-application.json", "expectedTargetApplicationConfigPath": "/var/lib/qinglong3/local-application.json", "expectedTargetCommitmentPath": "/var/lib/qinglong3/service/cutovers/router-edge-1-ql3/0002-legacy-stopped.json", @@ -266,6 +273,12 @@ ql3-local-deploy cutover-target-start \ --command-file /secure/operator/qinglong3-target-start.json ``` +正式 registry 部署必须使用 `authority=registry-digest`,`reference` 仍只接受不可变 +`name@sha256:...`;`imageId` 从创建目标容器的同一 Docker daemon 读取。下载型 Alpha Trial Kit +没有可诚实声称的 registry RepoDigest,必须改用 `authority=local-image-id`、bundle manifest 中的 +本地 image reference 和 exact image ID。两种模式都会同时核对 `docker inspect` 的 +`Config.Image` 与 `.Image`,不会把 mutable local tag 单独当作权威。 + controller 先写固定的 `0003-target-start-decision.json`,其中状态为 `target_start_requested`,然后至多调用一次 `docker container start `。只有同一容器处于 running、identity/mount/config digest 不漂移,且 Application 写出一个校验通过的新 Linux startup receipt, @@ -299,6 +312,29 @@ Legacy container 必须再次证明与 `0002` 相同的完整 identity/source bi generation(60 条 target journal record),达到上限后必须进入新的受审 cutover/recovery ceremony,不能清理 旧记录腾位置。 +### 2.3.1 离线 Docker adopted target descriptor + +`local.deployment.adopted.prepare|verify` 的 service 现在可以显式选择: + +```json +{ + "kind": "docker-target", + "targetImage": { + "authority": "local-image-id", + "reference": "qinglong3-local-application:ci-amd64", + "imageId": "sha256:REPLACE_WITH_TRIAL_KIT_IMAGE_ID" + }, + "allowRootService": false +} +``` + +它生成 `service/docker-target.json` 和同一份 Application v4 配置,固定 numeric UID:GID、 +`restart=no`、无网络、read-only rootfs、drop ALL、no-new-privileges、Profile memory/PID 上限、 +deployment root 可写 mount 与 legacy source 只读 mount。该 descriptor 是内容绑定的创建输入, +不是启动授权;operator 仍须先按 descriptor 创建并保持容器停止,再把容器完整 ID 和同一个 +`targetImage` 交给 `target-start`。因此离线 Trial Kit 不需要伪造 GHCR catalog/release selection, +正式 Compose 路径也不因 Alpha 便利性而放宽。 + ### 2.4 `manual_required` 诊断与双阶段新 Ceremony 每个实例的当前 cutover 由以下私有 CAS head 固定: diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts index e6f3b238..b3ed40d9 100644 --- a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/adoptedBundle.ts @@ -29,7 +29,7 @@ export interface LocalDeploymentAdoptedBundleResult { readonly bundleDigest: string; readonly profile: 'edge' | 'standalone'; readonly service: Readonly<{ - kind: 'systemd' | 'openrc' | 'compose'; + kind: 'systemd' | 'openrc' | 'compose' | 'docker-target'; status: 'prepared' | 'existing' | 'verified'; }>; readonly applicationConfiguration: Readonly<{ @@ -117,6 +117,7 @@ function rejectAlternateDescriptors( 'qinglong3.service', 'qinglong3.openrc', 'compose.yaml', + 'docker-target.json', ]) { const candidate = path.join(serviceRoot, fileName); if (candidate !== selectedPath && fs.existsSync(candidate)) { diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts index dce531af..c6b3384f 100644 --- a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/contract.ts @@ -13,6 +13,10 @@ import { type LocalDeploymentProfile, } from '../foundation/contract'; import { LocalDeploymentConfigurationError } from '../foundation/error'; +import { + normalizeLocalDeploymentTargetImage, + type LocalDeploymentTargetImage, +} from '../foundation/targetImage'; const MAX_PATH_BYTES = 4_096; const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; @@ -36,13 +40,21 @@ export interface NormalizedLocalDeploymentAdoptedComposeService readonly releaseSelection: Readonly; } +export interface LocalDeploymentAdoptedDockerTargetService { + readonly kind: 'docker-target'; + readonly targetImage: Readonly; + readonly allowRootService: boolean; +} + export type LocalDeploymentAdoptedService = | LocalDeploymentProcessService - | LocalDeploymentAdoptedComposeService; + | LocalDeploymentAdoptedComposeService + | LocalDeploymentAdoptedDockerTargetService; export type NormalizedLocalDeploymentAdoptedService = | Readonly - | Readonly; + | Readonly + | Readonly; export interface LocalDeploymentAdoptedBundleCommand { readonly schemaVersion: 1; @@ -215,6 +227,20 @@ function normalizeService( ), }); } + if (service.kind === 'docker-target') { + exact(service, ['allowRootService', 'kind', 'targetImage'], 'service'); + return Object.freeze({ + kind: 'docker-target' as const, + targetImage: normalizeLocalDeploymentTargetImage( + service.targetImage, + 'service.targetImage', + ), + allowRootService: validateRootAcknowledgement( + service.allowRootService, + uid, + ), + }); + } if (service.kind !== 'compose') { throw new LocalDeploymentConfigurationError('service kind is invalid'); } diff --git a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts index c89b8501..7340190e 100644 --- a/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts +++ b/packages/ql3-local-owner-cli/src/deployment/adopted-bundle/material.ts @@ -66,7 +66,7 @@ export interface LocalDeploymentAdoptedBundleReceipt { readonly profile: 'edge' | 'standalone'; readonly instanceId: string; readonly cutoverId: string; - readonly serviceKind: 'systemd' | 'openrc' | 'compose'; + readonly serviceKind: 'systemd' | 'openrc' | 'compose' | 'docker-target'; readonly deploymentRootDigest: string; readonly sourcePathDigest: string; readonly applicationConfigDigest: string; @@ -308,6 +308,8 @@ export function adoptedBundlePaths( ? 'qinglong3.service' : command.options.service.kind === 'openrc' ? 'qinglong3.openrc' + : command.options.service.kind === 'docker-target' + ? 'docker-target.json' : 'compose.yaml'; return Object.freeze({ ownerPepperKeyring: path.join(root, 'owner-peppers'), @@ -626,7 +628,7 @@ function systemdDescriptor( uid: number, gid: number, ): string { - if (command.options.service.kind === 'compose') { + if (command.options.service.kind !== 'systemd') { configurationError('systemd descriptor requires a process service'); } const edge = command.options.profile === 'edge'; @@ -674,7 +676,7 @@ function openrcDescriptor( uid: number, gid: number, ): string { - if (command.options.service.kind === 'compose') { + if (command.options.service.kind !== 'openrc') { configurationError('OpenRC descriptor requires a process service'); } const edge = command.options.profile === 'edge'; @@ -703,6 +705,58 @@ function openrcDescriptor( ].join('\n'); } +function dockerTargetDescriptor( + command: Readonly, + configPath: string, + configDigest: string, + uid: number, + gid: number, +): string { + if (command.options.service.kind !== 'docker-target') { + configurationError('Docker target descriptor requires Docker authority'); + } + const edge = command.options.profile === 'edge'; + return `${JSON.stringify( + { + schemaVersion: 1, + schema: 'qinglong/local-adopted-docker-target@v1', + profile: command.options.profile, + instanceId: command.options.instanceId, + image: command.options.service.targetImage, + applicationConfig: { + hostPath: configPath, + containerPath: configPath, + digest: configDigest, + }, + container: { + user: `${uid}:${gid}`, + command: ['--config', configPath], + networkMode: 'none', + readOnlyRootFilesystem: true, + restartPolicy: 'no', + dropCapabilities: ['ALL'], + securityOptions: ['no-new-privileges'], + memoryBytes: edge ? 134_217_728 : 268_435_456, + pidsLimit: edge ? 64 : 256, + mounts: [ + { + source: command.options.deploymentRoot, + destination: command.options.deploymentRoot, + readOnly: false, + }, + { + source: command.request.storage.sourcePath, + destination: command.request.storage.sourcePath, + readOnly: true, + }, + ], + }, + }, + null, + 2, + )}\n`; +} + function composeDescriptor( command: Readonly, configDigest: string, @@ -773,6 +827,19 @@ function serviceDescriptor( mode: 0o700, }); } + if (command.options.service.kind === 'docker-target') { + return Object.freeze({ + fileName: 'docker-target.json', + contents: dockerTargetDescriptor( + command, + configPath, + configDigest, + uid, + gid, + ), + mode: 0o600, + }); + } return Object.freeze({ fileName: 'compose.yaml', contents: composeDescriptor(command, configDigest, uid, gid), diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/legacyRollbackContract.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/legacyRollbackContract.ts index b6bb6024..189c02e4 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/legacyRollbackContract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/legacyRollbackContract.ts @@ -100,7 +100,6 @@ export function normalizeLocalDeploymentLegacyRollbackCommand( 'expectedTargetApplicationConfigPath', 'expectedTargetCommitmentPath', 'expectedTargetContainerId', - 'expectedTargetImage', 'generation', 'instanceId', 'legacySourcePath', @@ -110,6 +109,7 @@ export function normalizeLocalDeploymentLegacyRollbackCommand( 'requestedAtMs', 'rollbackRequestedAtMs', 'targetDatabasePath', + 'targetImage', ], 'request', ); diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts index 4eb40564..a540468c 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunContract.ts @@ -6,6 +6,10 @@ import { LocalDeploymentConfigurationError, type LocalDeploymentProfile, } from '../../foundation/contract'; +import { + normalizeLocalDeploymentTargetImage, + type LocalDeploymentTargetImage, +} from '../../foundation/targetImage'; const MAX_PATH_BYTES = 4_096; const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; @@ -13,8 +17,6 @@ const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/; const CUTOVER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const DIGEST_PATTERN = /^[0-9a-f]{64}$/; const CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/; -const IMAGE_DIGEST_PATTERN = - /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; const MAX_TARGET_GENERATION = 15; export type LocalDeploymentTargetRunOperation = @@ -44,7 +46,7 @@ export interface LocalDeploymentTargetRunCommand { expectedLegacyCommitmentDigest: string; expectedLegacyContainerId: string; expectedTargetContainerId: string; - expectedTargetImage: string; + targetImage: Readonly; applicationConfigPath: string; expectedTargetApplicationConfigPath: string; expectedTargetCommitmentPath: string; @@ -194,7 +196,6 @@ export function normalizeLocalDeploymentTargetRunCommand( 'expectedTargetApplicationConfigPath', 'expectedTargetCommitmentPath', 'expectedTargetContainerId', - 'expectedTargetImage', 'generation', 'instanceId', 'legacySourcePath', @@ -203,6 +204,7 @@ export function normalizeLocalDeploymentTargetRunCommand( 'recoveryPath', 'requestedAtMs', 'targetDatabasePath', + 'targetImage', ], 'request', ); @@ -222,8 +224,6 @@ export function normalizeLocalDeploymentTargetRunCommand( typeof request.expectedTargetContainerId !== 'string' || !CONTAINER_ID_PATTERN.test(request.expectedTargetContainerId) || request.expectedTargetContainerId === request.expectedLegacyContainerId || - typeof request.expectedTargetImage !== 'string' || - !IMAGE_DIGEST_PATTERN.test(request.expectedTargetImage) || generation > MAX_TARGET_GENERATION || (command.operation === 'local.deployment.cutover.target-start' && generation !== 1) || @@ -292,7 +292,7 @@ export function normalizeLocalDeploymentTargetRunCommand( expectedLegacyCommitmentDigest: request.expectedLegacyCommitmentDigest, expectedLegacyContainerId: request.expectedLegacyContainerId, expectedTargetContainerId: request.expectedTargetContainerId, - expectedTargetImage: request.expectedTargetImage, + targetImage: normalizeLocalDeploymentTargetImage(request.targetImage), applicationConfigPath: safeAbsolutePath( request.applicationConfigPath, 'applicationConfigPath', diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunRecordEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunRecordEvidence.ts index 4346e1ea..c0975c72 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunRecordEvidence.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/target-run/targetRunRecordEvidence.ts @@ -68,9 +68,9 @@ export function targetRequestEvidence( targetContainerId: context.command.request.expectedTargetContainerId, targetContainerIdentityDigest: target.identityDigest, targetApplicationBindingDigest: target.applicationBindingDigest, - targetImageDigest: cutoverDigest( - context.command.request.expectedTargetImage, - ), + targetImageDigest: cutoverDigest({ + ...context.command.request.targetImage, + }), applicationConfigDigest: context.application.configDigest, previousStartupReceiptDigest, }); @@ -104,7 +104,9 @@ export function verifyTargetRequestEvidence( evidence.targetContainerId !== context.command.request.expectedTargetContainerId || evidence.targetImageDigest !== - cutoverDigest(context.command.request.expectedTargetImage) || + cutoverDigest({ + ...context.command.request.targetImage, + }) || evidence.applicationConfigDigest !== context.application.configDigest || typeof evidence.targetContainerIdentityDigest !== 'string' || !DIGEST_PATTERN.test(evidence.targetContainerIdentityDigest) || diff --git a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts index 5085663e..dcbff9f1 100644 --- a/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts +++ b/packages/ql3-local-owner-cli/src/deployment/cutover/targetEvidence.ts @@ -520,7 +520,8 @@ export function parseTargetContainerEvidence( hostConfig.Privileged === true || !Array.isArray(hostConfig.SecurityOpt) || !hostConfig.SecurityOpt.includes('no-new-privileges') || - config.Image !== command.request.expectedTargetImage || + config.Image !== command.request.targetImage.reference || + container.Image !== command.request.targetImage.imageId || JSON.stringify(config.Cmd) !== JSON.stringify([ '--config', @@ -583,6 +584,8 @@ export function parseTargetContainerEvidence( containerId: container.Id, created: container.Created, image: config.Image, + imageAuthority: command.request.targetImage.authority, + imageId: container.Image, name: container.Name, }), applicationBindingDigest: cutoverDigest({ diff --git a/packages/ql3-local-owner-cli/src/deployment/foundation/targetImage.ts b/packages/ql3-local-owner-cli/src/deployment/foundation/targetImage.ts new file mode 100644 index 00000000..fb76829e --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/foundation/targetImage.ts @@ -0,0 +1,55 @@ +import { LocalDeploymentConfigurationError } from './error'; + +const IMAGE_DIGEST_PATTERN = + /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; +const LOCAL_IMAGE_REFERENCE_PATTERN = + /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; +const IMAGE_ID_PATTERN = /^sha256:[0-9a-f]{64}$/; + +export interface LocalDeploymentTargetImage { + readonly authority: 'registry-digest' | 'local-image-id'; + readonly reference: string; + readonly imageId: string; +} + +export function normalizeLocalDeploymentTargetImage( + value: unknown, + label = 'targetImage', +): Readonly { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + throw new LocalDeploymentConfigurationError(`${label} must be an object`); + } + const image = value as Record; + const keys = Object.keys(image).sort(); + if ( + JSON.stringify(keys) !== + JSON.stringify(['authority', 'imageId', 'reference']) + ) { + throw new LocalDeploymentConfigurationError(`${label} shape is invalid`); + } + if ( + (image.authority !== 'registry-digest' && + image.authority !== 'local-image-id') || + typeof image.reference !== 'string' || + (image.authority === 'registry-digest' + ? !IMAGE_DIGEST_PATTERN.test(image.reference) + : !LOCAL_IMAGE_REFERENCE_PATTERN.test(image.reference)) || + typeof image.imageId !== 'string' || + !IMAGE_ID_PATTERN.test(image.imageId) + ) { + throw new LocalDeploymentConfigurationError( + `${label} identity is invalid`, + ); + } + return Object.freeze({ + authority: image.authority, + reference: image.reference, + imageId: image.imageId, + }); +} diff --git a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs index 96564d37..9f0ce1ea 100644 --- a/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs +++ b/packages/ql3-local-owner-cli/test/adoptedDeploymentBundle.test.cjs @@ -228,6 +228,16 @@ function fixture(t, kind) { releaseSelection: releaseSelection(managementRoot), allowRootService: rootAcknowledgement(), } + : kind === 'docker-target' + ? { + kind, + targetImage: { + authority: 'local-image-id', + reference: 'qinglong3-local-application:ci-amd64', + imageId: `sha256:${'d'.repeat(64)}`, + }, + allowRootService: rootAcknowledgement(), + } : { kind, nodeExecutable: fs.realpathSync(process.execPath), @@ -636,7 +646,7 @@ async function createFailedRestoreState(state, currentGeneration, suffix) { return failedCommand; } -for (const kind of ['systemd', 'openrc', 'compose']) { +for (const kind of ['systemd', 'openrc', 'compose', 'docker-target']) { test(`prepares and verifies an exact adopted ${kind} bundle without activation`, (t) => { const state = fixture(t, kind); const prepared = prepareLocalDeploymentAdoptedBundle(state.command); @@ -719,6 +729,43 @@ for (const kind of ['systemd', 'openrc', 'compose']) { 'utf8', ), ); + } else if (kind === 'docker-target') { + const descriptor = JSON.parse( + fs.readFileSync( + path.join(state.root, 'service/docker-target.json'), + 'utf8', + ), + ); + assert.equal( + descriptor.schema, + 'qinglong/local-adopted-docker-target@v1', + ); + assert.deepEqual( + descriptor.image, + state.command.options.service.targetImage, + ); + assert.equal(descriptor.container.restartPolicy, 'no'); + assert.equal(descriptor.container.readOnlyRootFilesystem, true); + assert.deepEqual(descriptor.container.dropCapabilities, ['ALL']); + assert.deepEqual(descriptor.container.securityOptions, [ + 'no-new-privileges', + ]); + assert.deepEqual(descriptor.container.mounts, [ + { + source: state.root, + destination: state.root, + readOnly: false, + }, + { + source: state.sourcePath, + destination: state.sourcePath, + readOnly: true, + }, + ]); + assert.equal( + fs.existsSync(path.join(state.root, 'service/compose.image.yaml')), + false, + ); } else { assert.equal( fs.existsSync(path.join(state.root, 'service/compose.image.yaml')), diff --git a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs index 3493e3d1..d42929f2 100644 --- a/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs +++ b/packages/ql3-local-owner-cli/test/cutoverTargetRun.test.cjs @@ -88,6 +88,7 @@ function targetInspection(state) { return JSON.stringify([ { Id: state.targetContainerId, + Image: state.targetImageId, Created: '2026-08-09T01:00:00.000000000Z', Name: '/qinglong3-target', State: { @@ -193,6 +194,8 @@ function fixture(t) { legacyContainerId: '7'.repeat(64), targetContainerId: '8'.repeat(64), targetImage: `registry.example/qinglong3@sha256:${'9'.repeat(64)}`, + targetImageAuthority: 'registry-digest', + targetImageId: `sha256:${'a'.repeat(64)}`, targetRunning: false, legacyRunning: false, nextProcessId: 10, @@ -290,7 +293,11 @@ function command(state, generation = 1) { expectedLegacyCommitmentDigest: state.legacyCommitmentDigest, expectedLegacyContainerId: state.legacyContainerId, expectedTargetContainerId: state.targetContainerId, - expectedTargetImage: state.targetImage, + targetImage: { + authority: state.targetImageAuthority, + reference: state.targetImage, + imageId: state.targetImageId, + }, applicationConfigPath: state.applicationConfigPath, expectedTargetApplicationConfigPath: state.targetApplicationConfigPath, @@ -497,6 +504,47 @@ test('starts an exact target once and replays the active commitment without Dock assert.equal(replay.recordDigest, active.recordDigest); }); +test('starts an offline Trial Kit image only when its local reference and content ID both match', async (t) => { + const state = fixture(t); + state.targetImageAuthority = 'local-image-id'; + state.targetImage = 'qinglong3-local-application:ci-amd64'; + const active = await runLocalDeploymentDockerTarget( + command(state), + harness(state), + ); + assert.equal(active.state, 'target_active'); +}); + +test('makes an offline Trial Kit target manual-required when its inspected content ID drifted', async (t) => { + const state = fixture(t); + state.targetImageAuthority = 'local-image-id'; + state.targetImage = 'qinglong3-local-application:ci-amd64'; + const request = command(state); + state.targetImageId = `sha256:${'b'.repeat(64)}`; + const controller = harness(state); + const result = await runLocalDeploymentDockerTarget(request, controller); + assert.equal(result.state, 'manual_required'); + assert.equal( + controller.calls.filter((args) => args[1] === 'start').length, + 0, + ); +}); + +test('rejects a mutable local tag under registry digest authority before Docker access', async (t) => { + const state = fixture(t); + const request = command(state); + request.request.targetImage.reference = + 'qinglong3-local-application:ci-amd64'; + await assert.rejects( + runLocalDeploymentDockerTarget(request, { + validateSocket() { + throw new Error('invalid image authority must not reach Docker'); + }, + }), + /targetImage identity is invalid/, + ); +}); + test('recovers a crash after the start barrier by inspection without repeating start', async (t) => { const state = fixture(t); const crashing = harness(state, {