feat(ql3): bind offline adopted target images

This commit is contained in:
whyour
2026-08-30 19:50:24 +08:00
parent 38d85952e7
commit 0f4b6b43fb
15 changed files with 345 additions and 23 deletions
+2
View File
@@ -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 mountsdescriptor 不创建或启动容器。没有新增 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 failexact 双架构 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 的第一阶段。
@@ -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 IDjournal 中的镜像 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 selectionsystemd/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 candidateD-426c 处理 target 写入后的 `reconciliation_required`。Public prerelease/release 仍需维护者显式授权。
+1
View File
@@ -525,6 +525,7 @@
| [ADR-0519](./ADR-0519-local-console-secret-backed-automation.md) | Local Console Secret-backed 自动化 | AcceptedD-424 双架构 milestone 已交付) |
| [ADR-0520](./ADR-0520-downloadable-local-legacy-upgrade-readiness.md) | 可下载的 Local Legacy 升级就绪盘点 | AcceptedD-425 双架构 Alpha 实物已交付) |
| [ADR-0521](./ADR-0521-reviewed-side-by-side-local-upgrade-stage.md) | 受审核计划驱动的 Local Side-by-side 升级暂存 | AcceptedD-426a 双架构 Alpha 实物已交付) |
| [ADR-0522](./ADR-0522-content-bound-offline-docker-adopted-target.md) | 内容绑定的离线 Docker Adopted Target | AcceptedD-426b 源码候选;阶段实物尚未闭合) |
## 规则
+1
View File
@@ -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/verifylegacy 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 权威未放宽 | 尚未生成新的双架构 artifacttransform/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 创建能力已进入新的阶段实物,而不再只是候选源码。
+38 -2
View File
@@ -222,7 +222,7 @@ D-64 全部完成,也不得自动重启 2.x。
### 2.3 Docker adopted target 启动与重启屏障
ADR-0310 已补齐上述段落中的 Docker target barrierADR-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 <exact-id>`。只有同一容器处于
running、identity/mount/config digest 不漂移,且 Application 写出一个校验通过的新 Linux startup receipt
@@ -299,6 +312,29 @@ Legacy container 必须再次证明与 `0002` 相同的完整 identity/source bi
generation60 条 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 固定:
@@ -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)) {
@@ -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<ResolvedLocalComposeReleaseSelection>;
}
export interface LocalDeploymentAdoptedDockerTargetService {
readonly kind: 'docker-target';
readonly targetImage: Readonly<LocalDeploymentTargetImage>;
readonly allowRootService: boolean;
}
export type LocalDeploymentAdoptedService =
| LocalDeploymentProcessService
| LocalDeploymentAdoptedComposeService;
| LocalDeploymentAdoptedComposeService
| LocalDeploymentAdoptedDockerTargetService;
export type NormalizedLocalDeploymentAdoptedService =
| Readonly<LocalDeploymentProcessService>
| Readonly<NormalizedLocalDeploymentAdoptedComposeService>;
| Readonly<NormalizedLocalDeploymentAdoptedComposeService>
| Readonly<LocalDeploymentAdoptedDockerTargetService>;
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');
}
@@ -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<NormalizedLocalDeploymentAdoptedBundleCommand>,
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<NormalizedLocalDeploymentAdoptedBundleCommand>,
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),
@@ -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',
);
@@ -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<LocalDeploymentTargetImage>;
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',
@@ -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) ||
@@ -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({
@@ -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<LocalDeploymentTargetImage> {
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<string, unknown>;
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,
});
}
@@ -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')),
@@ -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, {