Files
testing/scenarios/onebox-backup-restore/scenario.ts
T

336 lines
11 KiB
TypeScript
Raw Normal View History

2026-04-29 14:11:10 +00:00
import { dirname, fromFileUrl, join, resolve } from '@std/path';
import { Onebox } from '../../../onebox/ts/classes/onebox.ts';
2026-05-24 06:00:23 +00:00
import { disableManagedDcRouterForScenario } from '../onebox-test-helpers.ts';
2026-04-29 14:11:10 +00:00
const scenarioName = 'onebox-backup-restore';
const smokeId = `onebox-backup-${Date.now().toString(36)}`;
const testingDir = resolve(dirname(fromFileUrl(import.meta.url)), '../..');
const buildDir = join(testingDir, '.nogit', scenarioName, smokeId);
const serviceName = `app-${Date.now().toString(36)}`;
const cloneServiceName = `${serviceName}-clone`;
const dockerServiceName = `onebox-${serviceName}`;
const cloneDockerServiceName = `onebox-${cloneServiceName}`;
2026-05-24 06:00:23 +00:00
const volumeName = `onebox-${serviceName}-data`;
const cloneVolumeName = `onebox-${cloneServiceName}-data`;
2026-04-29 14:11:10 +00:00
const delayFor = async (millisecondsArg: number) => {
await new Promise((resolveArg) => setTimeout(resolveArg, millisecondsArg));
};
2026-05-08 16:24:45 +00:00
const outputCommand = async (
commandArg: string,
argsArg: string[],
optionsArg: Pick<Deno.CommandOptions, 'stdout' | 'stderr'>,
timeoutMsArg = 30000,
) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMsArg);
try {
const command = new Deno.Command(commandArg, {
args: argsArg,
signal: controller.signal,
...optionsArg,
});
return await command.output();
} catch (error) {
if ((error as Error).name === 'AbortError') {
throw new Error(`${commandArg} ${argsArg.join(' ')} timed out after ${timeoutMsArg}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
};
2026-04-29 14:11:10 +00:00
const run = async (commandArg: string, argsArg: string[]) => {
2026-05-08 16:24:45 +00:00
const output = await outputCommand(commandArg, argsArg, {
2026-04-29 14:11:10 +00:00
stdout: 'piped',
stderr: 'piped',
});
const stdout = new TextDecoder().decode(output.stdout).trim();
const stderr = new TextDecoder().decode(output.stderr).trim();
if (stdout) {
console.log(stdout);
}
if (stderr) {
console.log(stderr);
}
if (!output.success) {
throw new Error(`${commandArg} ${argsArg.join(' ')} exited with ${output.code}`);
}
return { stdout, stderr };
};
const waitFor = async (checkFunctionArg: () => boolean | Promise<boolean>, messageArg: string) => {
const startTime = Date.now();
while (Date.now() - startTime < 120000) {
if (await checkFunctionArg()) {
return;
}
await delayFor(500);
}
throw new Error(`Timed out waiting for ${messageArg}`);
};
const dockerServiceExists = async (serviceNameArg: string) => {
2026-05-08 16:24:45 +00:00
const output = await outputCommand('docker', ['service', 'inspect', serviceNameArg], {
2026-04-29 14:11:10 +00:00
stdout: 'null',
stderr: 'null',
2026-05-08 16:24:45 +00:00
}, 15000);
return output.success;
2026-04-29 14:11:10 +00:00
};
const removeDockerService = async (serviceNameArg: string) => {
if (await dockerServiceExists(serviceNameArg)) {
await run('docker', ['service', 'rm', serviceNameArg]).catch(() => null);
await delayFor(2000);
}
};
2026-05-24 06:00:23 +00:00
const removeDockerVolume = async (volumeNameArg: string) => {
let lastErrorText = '';
const startTime = Date.now();
while (Date.now() - startTime < 30000) {
const output = await outputCommand(
'docker',
['volume', 'rm', '-f', volumeNameArg],
{
stdout: 'piped',
stderr: 'piped',
},
15000,
);
if (output.success) {
return;
}
lastErrorText = new TextDecoder().decode(output.stderr).trim();
await delayFor(1000);
}
console.log(
`[${scenarioName}] Failed to remove Docker volume ${volumeNameArg}: ${lastErrorText}`,
);
};
2026-04-29 14:11:10 +00:00
const assertNoPreexistingOneboxIngress = async () => {
if (await dockerServiceExists('onebox-smartproxy')) {
2026-05-24 06:00:23 +00:00
throw new Error(
'onebox-smartproxy already exists; refusing to overwrite a running Onebox ingress service',
);
2026-04-29 14:11:10 +00:00
}
};
const waitForDockerServiceRunning = async (serviceNameArg: string) => {
await waitFor(async () => {
2026-05-08 16:24:45 +00:00
const output = await outputCommand('docker', [
'service',
'ps',
serviceNameArg,
'--format',
'{{.CurrentState}}',
], {
2026-04-29 14:11:10 +00:00
stdout: 'piped',
stderr: 'null',
2026-05-08 16:24:45 +00:00
}, 15000);
2026-04-29 14:11:10 +00:00
if (!output.success) {
return false;
}
return new TextDecoder().decode(output.stdout).includes('Running');
}, `${serviceNameArg} running task`);
};
const waitForDockerServiceRemoved = async (serviceNameArg: string) => {
2026-05-24 06:00:23 +00:00
await waitFor(
async () => !(await dockerServiceExists(serviceNameArg)),
`${serviceNameArg} removal`,
);
};
const inspectDockerServiceJson = async <T>(serviceNameArg: string, formatArg: string) => {
const output = await outputCommand('docker', [
'service',
'inspect',
serviceNameArg,
'--format',
formatArg,
], {
stdout: 'piped',
stderr: 'piped',
}, 15000);
if (!output.success) {
throw new Error(`docker service inspect ${serviceNameArg} exited with ${output.code}`);
}
return JSON.parse(new TextDecoder().decode(output.stdout).trim()) as T;
};
const assertCloneVolumeMount = async () => {
const mounts = await inspectDockerServiceJson<Array<Record<string, unknown>>>(
cloneDockerServiceName,
'{{json .Spec.TaskTemplate.ContainerSpec.Mounts}}',
);
if (
!mounts.some((mountArg) => mountArg.Source === cloneVolumeName && mountArg.Target === '/data')
) {
throw new Error(
`Cloned service volume mount missing from Docker service: ${JSON.stringify(mounts)}`,
);
}
2026-04-29 14:11:10 +00:00
};
const ensureDockerReady = async () => {
await run('docker', ['version']);
const { stdout } = await run('docker', ['info', '--format', '{{.Swarm.LocalNodeState}}']);
if (stdout.trim() !== 'active') {
2026-05-24 06:00:23 +00:00
throw new Error(
'Docker Swarm must be active. In Vagrant this is handled by scripts/provision-vm.sh.',
);
2026-04-29 14:11:10 +00:00
}
};
const main = async () => {
let onebox: Onebox | undefined;
let deployedService = false;
let clonedService = false;
try {
await ensureDockerReady();
await assertNoPreexistingOneboxIngress();
await Deno.mkdir(buildDir, { recursive: true });
Deno.chdir(buildDir);
Deno.env.set('ONEBOX_DEV', 'true');
console.log(`[${scenarioName}] Starting Onebox from ${buildDir}`);
onebox = new Onebox();
2026-05-24 06:00:23 +00:00
disableManagedDcRouterForScenario(onebox);
2026-04-29 14:11:10 +00:00
await onebox.init();
await waitForDockerServiceRunning('onebox-smartproxy');
console.log(`[${scenarioName}] Deploying workload ${serviceName}`);
const service = await onebox.services.deployService({
name: serviceName,
image: 'caddy:2-alpine',
port: 80,
autoDNS: false,
envVars: {
ONEBOX_BACKUP_SCENARIO: smokeId,
},
2026-05-24 06:00:23 +00:00
volumes: [{ mountPath: '/data', backup: true }],
2026-04-29 14:11:10 +00:00
});
deployedService = true;
if (service.status !== 'running') {
throw new Error(`Unexpected deployed service state: ${JSON.stringify(service)}`);
}
await waitForDockerServiceRunning(dockerServiceName);
console.log(`[${scenarioName}] Creating backup for ${serviceName}`);
const backupResult = await onebox.backupManager.createBackup(serviceName);
const backupId = backupResult.backup.id;
if (!backupId || !backupResult.snapshotId || !backupResult.backup.snapshotId) {
2026-05-24 06:00:23 +00:00
throw new Error(
`Backup did not produce a ContainerArchive snapshot: ${JSON.stringify(backupResult)}`,
);
2026-04-29 14:11:10 +00:00
}
if (!backupResult.backup.includesImage) {
2026-05-24 06:00:23 +00:00
throw new Error(
`Backup did not include Docker image: ${JSON.stringify(backupResult.backup)}`,
);
2026-04-29 14:11:10 +00:00
}
if ((backupResult.backup.storedSizeBytes ?? 0) <= 0 || backupResult.backup.sizeBytes <= 0) {
throw new Error(`Backup size metadata is invalid: ${JSON.stringify(backupResult.backup)}`);
}
const backups = onebox.backupManager.listBackups(serviceName);
2026-05-24 06:00:23 +00:00
if (
!backups.some((backupArg) =>
backupArg.id === backupId && backupArg.snapshotId === backupResult.snapshotId
)
) {
2026-04-29 14:11:10 +00:00
throw new Error(`Backup not listed for service: ${JSON.stringify(backups)}`);
}
console.log(`[${scenarioName}] Restoring backup as ${cloneServiceName}`);
const restoreResult = await onebox.backupManager.restoreBackup(backupId, {
mode: 'clone',
newServiceName: cloneServiceName,
});
clonedService = true;
2026-05-24 06:00:23 +00:00
if (
restoreResult.service.name !== cloneServiceName || restoreResult.service.status !== 'running'
) {
throw new Error(
`Unexpected restored service state: ${JSON.stringify(restoreResult.service)}`,
);
2026-04-29 14:11:10 +00:00
}
if (restoreResult.service.domain !== undefined) {
2026-05-24 06:00:23 +00:00
throw new Error(
`Clone unexpectedly retained a domain: ${JSON.stringify(restoreResult.service)}`,
);
2026-04-29 14:11:10 +00:00
}
if (restoreResult.service.envVars.ONEBOX_BACKUP_SCENARIO !== smokeId) {
2026-05-24 06:00:23 +00:00
throw new Error(
`Clone did not preserve env vars: ${JSON.stringify(restoreResult.service.envVars)}`,
);
}
if (!restoreResult.service.volumes?.some((volumeArg) => volumeArg.mountPath === '/data')) {
throw new Error(
`Clone did not preserve volumes: ${JSON.stringify(restoreResult.service.volumes)}`,
);
2026-04-29 14:11:10 +00:00
}
if (restoreResult.warnings.length > 0) {
throw new Error(`Restore completed with warnings: ${restoreResult.warnings.join('; ')}`);
}
await waitForDockerServiceRunning(cloneDockerServiceName);
2026-05-24 06:00:23 +00:00
await assertCloneVolumeMount();
2026-04-29 14:11:10 +00:00
console.log(`[${scenarioName}] Removing restored workload ${cloneServiceName}`);
await onebox.services.removeService(cloneServiceName);
clonedService = false;
await waitForDockerServiceRemoved(cloneDockerServiceName);
console.log(`[${scenarioName}] Removing workload ${serviceName}`);
await onebox.services.removeService(serviceName);
deployedService = false;
await waitForDockerServiceRemoved(dockerServiceName);
console.log(`[${scenarioName}] Scenario passed`);
} finally {
if (onebox && clonedService) {
await onebox.services.removeService(cloneServiceName).catch((error) => {
2026-05-24 06:00:23 +00:00
console.log(
`[${scenarioName}] Failed to remove restored Onebox service: ${(error as Error).message}`,
);
2026-04-29 14:11:10 +00:00
});
}
if (onebox && deployedService) {
await onebox.services.removeService(serviceName).catch((error) => {
2026-05-24 06:00:23 +00:00
console.log(
`[${scenarioName}] Failed to remove Onebox service: ${(error as Error).message}`,
);
2026-04-29 14:11:10 +00:00
});
}
if (onebox) {
await onebox.shutdown().catch((error) => {
console.log(`[${scenarioName}] Failed to shut down Onebox: ${(error as Error).message}`);
});
}
await removeDockerService(cloneDockerServiceName);
await removeDockerService(dockerServiceName);
await removeDockerService('onebox-smartproxy');
2026-05-24 06:00:23 +00:00
await removeDockerVolume(cloneVolumeName);
await removeDockerVolume(volumeName);
2026-04-29 14:11:10 +00:00
}
};
2026-05-08 16:24:45 +00:00
try {
await main();
Deno.exit(0);
} catch (error) {
console.error(error);
Deno.exit(1);
}