mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): bind offline adopted target images
This commit is contained in:
@@ -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',
|
||||
|
||||
+6
-4
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user