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