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
@@ -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, {