Files
2026-10-04 00:30:09 +07:00

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);
});