156 lines
8.2 KiB
JavaScript
156 lines
8.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fixture } from './helpers/runtime-fixture.mjs';
|
|
import { backup, backupBeforeApply, restoreSnapshot, verifySnapshot, runtimeInventory } from '../platform/backups.mjs';
|
|
import { verifyRelease, withRuntimeLock } from '../platform/runtime.mjs';
|
|
|
|
function seeded(t) {
|
|
const f = fixture(t);
|
|
assert.equal(f.run(['up', '-d']), 0);
|
|
for (const name of ['world', 'mod-state', 'config']) fs.mkdirSync(path.join(f.host, 'runtime', name));
|
|
for (const [file, value] of Object.entries({ 'world/level.dat': 'world-data', 'mod-state/progression.json': '{"progress":42}', 'config/unmanaged.txt': 'keep-config', 'server.properties': 'online-mode=false', 'ops.json': '[]', 'whitelist.json': '[]' })) fs.writeFileSync(path.join(f.host, 'runtime', file), value, { mode: 0o600 });
|
|
f.options = { repository: f.root, env: f.env };
|
|
f.state = () => JSON.parse(fs.readFileSync(path.join(f.root, '.tools/docker-state.json')));
|
|
f.events = () => JSON.parse(fs.readFileSync(path.join(f.root, '.tools/events.json')));
|
|
f.operation = () => JSON.parse(fs.readFileSync(path.join(f.host, '.mcpack-backup-operation.json')));
|
|
return f;
|
|
}
|
|
|
|
function brokenTar(f) {
|
|
const tar = path.join(f.root, '.tools/tar');
|
|
fs.writeFileSync(tar, '#!/bin/sh\necho intentional-archive-failure >&2\nexit 23\n', { mode: 0o755 });
|
|
}
|
|
|
|
test('running backup stops under shared lock, verifies full data and resumes same release', t => {
|
|
const f = seeded(t), inventory = runtimeInventory(path.join(f.host, 'runtime'));
|
|
const snapshot = backup(f.options), metadata = verifySnapshot(snapshot);
|
|
assert.deepEqual(metadata.runtime_inventory, inventory);
|
|
assert.equal(metadata.prior_state, 'running');
|
|
assert.deepEqual(metadata.exclusions, []);
|
|
assert.equal(metadata.source_release.manifest_sha256, verifyRelease(f.release).identity);
|
|
assert.equal(verifyRelease(path.join(snapshot, 'release')).identity, metadata.source_release.manifest_sha256);
|
|
assert.equal(f.state().running, true);
|
|
assert.deepEqual(f.events().slice(-2), [{ command: 'stop', locked: true }, { command: 'up', locked: true }]);
|
|
assert.equal(f.operation().phase, 'complete');
|
|
assert(!fs.existsSync(path.join(snapshot, '.env')));
|
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
|
});
|
|
|
|
test('stopped and absent servers stay stopped; snapshot IDs are unique', t => {
|
|
const f = seeded(t);
|
|
f.run(['stop']);
|
|
const first = backup(f.options);
|
|
assert.equal(verifySnapshot(first).prior_state, 'stopped');
|
|
assert.equal(f.state().running, false);
|
|
f.run(['down']);
|
|
const second = backup(f.options);
|
|
assert.equal(verifySnapshot(second).prior_state, 'absent');
|
|
assert.equal(f.state().present, false);
|
|
assert.notEqual(first, second);
|
|
});
|
|
|
|
test('restore verifies bytes, ownership/modes and retained release in new root only', t => {
|
|
const f = seeded(t), snapshot = backup(f.options), destination = path.join(f.root, 'restored');
|
|
assert.equal(restoreSnapshot({ backupDirectory: snapshot, destination }), destination);
|
|
assert.deepEqual(runtimeInventory(path.join(destination, 'runtime')), verifySnapshot(snapshot).runtime_inventory);
|
|
assert.equal(fs.readFileSync(path.join(destination, 'runtime/mod-state/progression.json'), 'utf8'), '{"progress":42}');
|
|
assert.equal(verifyRelease(path.join(destination, 'release')).identity, verifyRelease(f.release).identity);
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(destination, '.mcpack-runtime.json'))), JSON.parse(fs.readFileSync(path.join(f.host, '.mcpack-runtime.json'))));
|
|
assert(!fs.existsSync(path.join(destination, '.env')));
|
|
assert.throws(() => restoreSnapshot({ backupDirectory: snapshot, destination }), /must not exist/);
|
|
assert.equal(fs.readFileSync(path.join(destination, 'runtime/world/level.dat'), 'utf8'), 'world-data');
|
|
assert.equal(f.state().running, true);
|
|
});
|
|
|
|
test('corrupt or incomplete snapshot fails before creating restoration destination', t => {
|
|
const f = seeded(t), snapshot = backup(f.options), destination = path.join(f.root, 'restored');
|
|
fs.appendFileSync(path.join(snapshot, 'runtime.tar.zst'), 'corrupt');
|
|
assert.throws(() => restoreSnapshot({ backupDirectory: snapshot, destination }), /checksum mismatch/);
|
|
assert(!fs.existsSync(destination));
|
|
const second = backup(f.options);
|
|
fs.writeFileSync(path.join(second, 'unexpected'), 'uncovered');
|
|
assert.throws(() => restoreSnapshot({ backupDirectory: second, destination }), /coverage differs/);
|
|
assert(!fs.existsSync(destination));
|
|
});
|
|
|
|
test('archive failure blocks apply, leaves stopped runtime and publishes nothing', t => {
|
|
const f = seeded(t);
|
|
brokenTar(f);
|
|
let applied = false;
|
|
assert.throws(() => backupBeforeApply({ ...f.options, targetRelease: f.release }, () => { applied = true; }), /intentional-archive-failure.*No snapshot published.*Runtime remains stopped/);
|
|
assert.equal(applied, false);
|
|
assert.equal(f.state().running, false);
|
|
assert.equal(f.operation().phase, 'failed');
|
|
assert.deepEqual(fs.readdirSync(path.join(f.host, 'backups')), []);
|
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
|
});
|
|
|
|
test('apply starts only after verified snapshot, under same lock, without automatic restart', t => {
|
|
const f = seeded(t);
|
|
const result = backupBeforeApply({ ...f.options, targetRelease: f.release }, ({ snapshot, metadata, lockToken }) => {
|
|
assert.equal(f.state().running, false);
|
|
assert(fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
|
assert(lockToken);
|
|
assert.equal(verifySnapshot(snapshot).target_release.manifest_sha256, verifyRelease(f.release).identity);
|
|
assert.deepEqual(metadata.exclusions, []);
|
|
assert.throws(() => f.run(['up', '-d']), /lock/);
|
|
return 'applied';
|
|
});
|
|
assert.equal(result, 'applied');
|
|
assert.equal(f.state().running, false);
|
|
assert.equal(f.operation().phase, 'complete');
|
|
});
|
|
|
|
test('invalid target or existing lifecycle lock fails before server stop', t => {
|
|
const f = seeded(t);
|
|
assert.throws(() => backupBeforeApply({ ...f.options, targetRelease: path.join(f.root, 'missing') }, () => {}));
|
|
assert.equal(f.state().running, true);
|
|
withRuntimeLock(f.host, () => assert.throws(() => backup(f.options), /lock/));
|
|
assert.equal(f.state().running, true);
|
|
assert.equal(f.events().length, 1);
|
|
});
|
|
|
|
test('resume failure retains verified snapshot and returns server to stopped state', t => {
|
|
const f = seeded(t);
|
|
fs.writeFileSync(path.join(f.root, '.tools/fail-start'), 'fail');
|
|
assert.throws(() => backup(f.options), /Verified snapshot retained:.*Runtime remains stopped/);
|
|
assert.equal(f.state().running, false);
|
|
assert.equal(f.operation().phase, 'failed');
|
|
assert.equal(verifySnapshot(f.operation().snapshot).prior_state, 'running');
|
|
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
|
});
|
|
|
|
test('stop failure publishes nothing and does not claim runtime is stopped', t => {
|
|
const f = seeded(t);
|
|
fs.writeFileSync(path.join(f.root, '.tools/fail-stop'), 'fail');
|
|
assert.throws(() => backup(f.options), /Compose stop failed.*Inspect server state/);
|
|
assert.equal(f.state().running, true);
|
|
assert.equal(f.operation().snapshot, null);
|
|
assert.equal(f.operation().stopped_confirmed, false);
|
|
});
|
|
|
|
test('runtime links fail closed before archive publication', t => {
|
|
const f = seeded(t);
|
|
fs.symlinkSync('world/level.dat', path.join(f.host, 'runtime/link'));
|
|
assert.throws(() => backup(f.options), /without links/);
|
|
assert.equal(f.state().running, false);
|
|
assert.equal(f.operation().snapshot, null);
|
|
});
|
|
|
|
test('apply failure reports current state without restarting an older release', t => {
|
|
const f = seeded(t);
|
|
assert.throws(() => backupBeforeApply({ ...f.options, targetRelease: f.release }, ({ lockToken }) => {
|
|
// Simulate a deployment that started a server and then failed.
|
|
const stateFile = path.join(f.root, '.tools/docker-state.json');
|
|
fs.writeFileSync(stateFile, JSON.stringify({ present: true, running: true }));
|
|
assert(lockToken);
|
|
throw new Error('apply failed after start');
|
|
}), /Verified snapshot retained:.*Inspect server state/);
|
|
assert.equal(f.operation().stopped_confirmed, false);
|
|
assert.equal(f.state().running, true);
|
|
assert.equal(f.events().filter(event => event.command === 'up').length, 1);
|
|
verifySnapshot(f.operation().snapshot);
|
|
});
|