first cycle lab release
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { build } from '../platform/build.mjs';
|
||||
|
||||
import {fixture,commit} from './helpers/build-fixture.mjs';
|
||||
const repository=fileURLToPath(new URL('../',import.meta.url));
|
||||
const sha=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const git=(root,args)=>execFileSync('git',args,{cwd:root,stdio:'pipe'});
|
||||
function refresh(root) {
|
||||
const directory = path.join(root, 'pack');
|
||||
const pack = parse(fs.readFileSync(path.join(directory, 'pack.toml'), 'utf8'));
|
||||
const index = parse(fs.readFileSync(path.join(directory, pack.index.file), 'utf8'));
|
||||
for (const entry of index.files) entry.hash = sha(path.join(directory, entry.file));
|
||||
fs.writeFileSync(path.join(directory, pack.index.file), stringify(index));
|
||||
pack.index.hash = sha(path.join(directory, pack.index.file));
|
||||
fs.writeFileSync(path.join(directory, 'pack.toml'), stringify(pack));
|
||||
}
|
||||
|
||||
const perform = (root, options = {}) => build({ root, log: () => {}, ...options });
|
||||
const leftovers = root => fs.existsSync(path.join(root, 'dist')) ? fs.readdirSync(path.join(root, 'dist')).filter(file => file.startsWith('.build-')) : [];
|
||||
|
||||
test('full clean release has portable Compose, complete checksums, ownership and no secrets/target', t => {
|
||||
const root = fixture(t);
|
||||
const configFile = path.join(root, 'platform/config.yaml');
|
||||
const config = parseYaml(fs.readFileSync(configFile, 'utf8'));
|
||||
config.server.memory = '3G'; config.server.port = 25570;
|
||||
config.server.host = 'private-deployment-host'; config.server.directory = '/private/remote/path';
|
||||
config.build.output_directory = './releases';
|
||||
fs.writeFileSync(configFile, stringifyYaml(config));
|
||||
fs.writeFileSync(path.join(root, 'server/.env'), 'PRIVATE_TOKEN=secret-local-value\nMC_EULA=TRUE\n');
|
||||
commit(root);
|
||||
const release = perform(root, { releaseOnly: true });
|
||||
assert.equal(path.basename(path.dirname(release)), 'releases');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(release, 'manifest.json')));
|
||||
assert.equal(manifest.git.dirty, false);
|
||||
assert.equal(manifest.git.commit, git(root, ['rev-parse', 'HEAD']).toString().trim());
|
||||
assert.equal(manifest.server.memory, '3G');
|
||||
assert.equal(manifest.server.port, 25570);
|
||||
assert(manifest.managed_files.client.some(file => /MouseTweaks/.test(file.path)));
|
||||
assert(!manifest.managed_files.server.some(file => /MouseTweaks/.test(file.path)));
|
||||
const serverMarker = manifest.managed_files.server.find(file => file.path === 'config/spike-marker.txt');
|
||||
assert.equal(serverMarker.owner, 'server');
|
||||
assert.equal(serverMarker.overrides, 'pack');
|
||||
assert.equal(serverMarker.hashes.sha256, sha(path.join(root, 'server/config/spike-marker.txt')));
|
||||
for (const file of manifest.release_files) assert.equal(file.sha256, sha(path.join(release, file.path)));
|
||||
const checksumLines = fs.readFileSync(path.join(release, 'SHA256SUMS'), 'utf8').trim().split('\n');
|
||||
assert.equal(checksumLines.length, manifest.release_files.length + 1);
|
||||
assert(checksumLines.some(line => line.endsWith(' manifest.json')));
|
||||
execFileSync('sha256sum', ['-c', 'SHA256SUMS'], { cwd: release });
|
||||
const composeText = fs.readFileSync(path.join(release, 'server/compose.yaml'), 'utf8');
|
||||
const compose = parseYaml(composeText).services.minecraft;
|
||||
assert.equal(compose.image, manifest.server.image);
|
||||
assert.equal(compose.environment.MEMORY, '${MC_MEMORY:-3G}');
|
||||
assert.equal(compose.ports[0], '${MC_BIND_ADDRESS:-127.0.0.1}:${MC_PORT:-25570}:25565');
|
||||
assert.equal(compose.environment.MODRINTH_MODPACK, `/modpacks/${manifest.artifact}`);
|
||||
assert(compose.volumes.every(volume => volume.source.startsWith('${MC_')));
|
||||
for (const file of ['manifest.json', 'server/compose.yaml', 'server/.env.example']) {
|
||||
const text = fs.readFileSync(path.join(release, file), 'utf8');
|
||||
for (const secret of ['secret-local-value', 'private-deployment-host', '/private/remote/path', root]) assert(!text.includes(secret));
|
||||
}
|
||||
assert.match(fs.readFileSync(path.join(release, 'server/.env.example'), 'utf8'), /MC_EULA=FALSE/);
|
||||
assert.deepEqual(leftovers(root), []);
|
||||
});
|
||||
|
||||
test('development build records dirty state and leaves source unchanged', t => {
|
||||
const root = fixture(t);
|
||||
fs.writeFileSync(path.join(root, 'local-note.txt'), 'dirty\n');
|
||||
const before = sha(path.join(root, 'pack/pack.toml'));
|
||||
const release = perform(root);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(release, 'manifest.json'))).git.dirty, true);
|
||||
assert.equal(sha(path.join(root, 'pack/pack.toml')), before);
|
||||
});
|
||||
|
||||
test('existing immutable release is never overwritten', t => {
|
||||
const root = fixture(t), release = perform(root);
|
||||
const before = sha(path.join(release, 'SHA256SUMS'));
|
||||
assert.throws(() => perform(root), /already exists/);
|
||||
assert.equal(sha(path.join(release, 'SHA256SUMS')), before);
|
||||
});
|
||||
|
||||
test('stale metadata is rejected before export', t => {
|
||||
const root = fixture(t);
|
||||
fs.appendFileSync(path.join(root, 'pack/config/spike-marker.txt'), 'stale\n');
|
||||
assert.throws(() => perform(root), /stale indexed file/);
|
||||
});
|
||||
|
||||
test('refresh changes in temporary copy fail safely without changing source', t => {
|
||||
const root = fixture(t), index = path.join(root, 'pack/index.toml'), before = sha(index);
|
||||
fs.writeFileSync(path.join(root, '.tools/state.json'), '{"stale":true}');
|
||||
assert.throws(() => perform(root), /Stale source/);
|
||||
assert.equal(sha(index), before);
|
||||
assert.deepEqual(fs.readdirSync(path.join(root, 'dist')), ['.gitkeep']);
|
||||
});
|
||||
|
||||
test('failed export leaves no partial release or build lock', t => {
|
||||
const root = fixture(t);
|
||||
fs.writeFileSync(path.join(root, '.tools/state.json'), '{"exportFailure":true}');
|
||||
assert.throws(() => perform(root), /Simulated export failure/);
|
||||
assert.deepEqual(fs.readdirSync(path.join(root, 'dist')), ['.gitkeep']);
|
||||
});
|
||||
|
||||
test('unverified loader/image combination is rejected', t => {
|
||||
const root = fixture(t), file = path.join(root, 'pack/pack.toml');
|
||||
const pack = parse(fs.readFileSync(file, 'utf8'));
|
||||
pack.versions.neoforge = '21.1.251';
|
||||
fs.writeFileSync(file, stringify(pack));
|
||||
assert.throws(() => perform(root), /not been verified/);
|
||||
});
|
||||
|
||||
test('duplicate mod definitions fail despite a consistent index', t => {
|
||||
const root = fixture(t), directory = path.join(root, 'pack');
|
||||
const indexFile = path.join(directory, 'index.toml'), index = parse(fs.readFileSync(indexFile, 'utf8'));
|
||||
const entry = index.files.find(file => file.file.includes('mouse-tweaks'));
|
||||
fs.copyFileSync(path.join(directory, entry.file), path.join(directory, 'mods/duplicate.pw.toml'));
|
||||
index.files.push({ ...entry, file: 'mods/duplicate.pw.toml' });
|
||||
fs.writeFileSync(indexFile, stringify(index));
|
||||
refresh(root);
|
||||
assert.throws(() => perform(root), /Duplicate mod target/);
|
||||
});
|
||||
|
||||
test('invalid/duplicate config keys and protected output paths are rejected', t => {
|
||||
const root = fixture(t), file = path.join(root, 'platform/config.yaml');
|
||||
fs.appendFileSync(file, '\nschema_version: 1\n');
|
||||
assert.throws(() => perform(root), /unique/);
|
||||
fs.copyFileSync(path.join(repository, 'platform/config.yaml'), file);
|
||||
const config = parseYaml(fs.readFileSync(file, 'utf8'));
|
||||
config.build.output_directory = './pack/output';
|
||||
fs.writeFileSync(file, stringifyYaml(config));
|
||||
assert.throws(() => perform(root), /overlaps/);
|
||||
config.build.output_directory = '../outside';
|
||||
fs.writeFileSync(file, stringifyYaml(config));
|
||||
assert.throws(() => perform(root), /inside repository/);
|
||||
});
|
||||
|
||||
test('release mode rejects dirty worktree', t => {
|
||||
const root = fixture(t);
|
||||
fs.writeFileSync(path.join(root, 'untracked.txt'), 'dirty');
|
||||
assert.throws(() => perform(root, { releaseOnly: true }), /clean Git worktree/);
|
||||
});
|
||||
|
||||
test('tracked known secrets are rejected', t => {
|
||||
const root = fixture(t);
|
||||
fs.writeFileSync(path.join(root, 'server/.env'), 'PRIVATE_TOKEN=x');
|
||||
git(root, ['add', '-f', 'server/.env']);
|
||||
commit(root);
|
||||
assert.throws(() => perform(root), /secret path is tracked/);
|
||||
});
|
||||
|
||||
test('tampered tool and source symlinks fail validation', t => {
|
||||
const root = fixture(t);
|
||||
const marker = path.join(root, 'pack/config/spike-marker.txt');
|
||||
fs.unlinkSync(marker); fs.symlinkSync('/tmp/nonexistent-mcpack-marker', marker);
|
||||
assert.throws(() => perform(root), /regular files/);
|
||||
fs.unlinkSync(marker); fs.copyFileSync(path.join(repository, 'pack/config/spike-marker.txt'), marker);
|
||||
fs.appendFileSync(path.join(root, '.tools/packwiz/packwiz'), '\n// tampered\n');
|
||||
assert.throws(() => perform(root), /checksum mismatch/);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseArgs, packwizArguments } from '../platform/commands.mjs';
|
||||
|
||||
const repository = fileURLToPath(new URL('../', import.meta.url));
|
||||
|
||||
test('mod commands map to packwiz without automatic yes', () => {
|
||||
assert.deepEqual(packwizArguments(parseArgs(['add', 'mouse-tweaks'])), ['modrinth', 'add', 'mouse-tweaks']);
|
||||
assert.deepEqual(packwizArguments(parseArgs(['remove', 'mouse-tweaks'])), ['remove', 'mouse-tweaks']);
|
||||
assert.deepEqual(packwizArguments(parseArgs(['update', 'mouse-tweaks'])), ['update', 'mouse-tweaks']);
|
||||
assert.deepEqual(packwizArguments(parseArgs(['update', '--all'])), ['update', '--all']);
|
||||
});
|
||||
|
||||
test('add forwards literal search and yes while config selects project only', () => {
|
||||
const options = parseArgs(['add', 'some search $(literal)', '-y', '--config', 'platform/other.yaml']);
|
||||
assert.equal(options.configFile, 'platform/other.yaml');
|
||||
assert.deepEqual(packwizArguments(options), ['--yes', 'modrinth', 'add', 'some search $(literal)']);
|
||||
const versionUrl = 'https://modrinth.com/mod/sophisticated-backpacks/version/pJxNzk4X';
|
||||
assert.deepEqual(packwizArguments(parseArgs(['add', versionUrl])), ['modrinth', 'add', versionUrl]);
|
||||
});
|
||||
|
||||
test('invalid argument combinations fail before any tool invocation', () => {
|
||||
for (const args of [[], ['add'], ['remove'], ['update'], ['update', '--all', 'mod'], ['add', 'mod', '--all'], ['remove', 'mod', '--version-id', 'v'], ['build', '--yes'], ['add', 'mod', '--release'], ['add', 'mod', '--config'], ['update', '--all', '--all'], ['add', 'a', 'b'], ['build', '--config', '--release']]) {
|
||||
assert.throws(() => parseArgs(args));
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI preserves packwiz stdout/stderr/exit and configured cwd with separate argv', t => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-command-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const file of ['platform', 'scripts', 'package.json', 'toolchain.lock.env']) fs.cpSync(path.join(repository, file), path.join(root, file), { recursive: true });
|
||||
fs.symlinkSync(path.join(repository, 'node_modules'), path.join(root, 'node_modules'), 'dir');
|
||||
fs.mkdirSync(path.join(root, 'custom-pack'));
|
||||
fs.writeFileSync(path.join(root, 'custom-pack/pack.toml'), 'name = "fixture"\n');
|
||||
const configuration = path.join(root, 'platform/config.yaml');
|
||||
fs.writeFileSync(configuration, fs.readFileSync(configuration, 'utf8').replace('./pack', './custom-pack'));
|
||||
fs.mkdirSync(path.join(root, '.tools/packwiz'), { recursive: true });
|
||||
const executable = path.join(root, '.tools/packwiz/packwiz');
|
||||
const record = path.join(root, '.tools/record.json');
|
||||
fs.writeFileSync(executable, `#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
fs.writeFileSync(${JSON.stringify(record)},JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2)}));
|
||||
console.log('packwiz original stdout');
|
||||
console.error('packwiz original stderr');
|
||||
process.exit(17);
|
||||
`);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(executable)).digest('hex');
|
||||
const lock = path.join(root, 'toolchain.lock.env');
|
||||
fs.writeFileSync(lock, fs.readFileSync(lock, 'utf8').replace(/^PACKWIZ_BINARY_SHA256=.*$/m, `PACKWIZ_BINARY_SHA256=${sha256}`));
|
||||
const result = spawnSync(process.execPath, [path.join(root, 'scripts/mcpack.mjs'), 'add', 'some search $(literal)', '--yes'], { cwd: os.tmpdir(), encoding: 'utf8' });
|
||||
assert.equal(result.status, 17);
|
||||
assert.match(result.stdout, /packwiz original stdout/);
|
||||
assert.match(result.stderr, /packwiz original stderr/);
|
||||
assert(!result.stderr.includes('mcpack:'));
|
||||
const invocation = JSON.parse(fs.readFileSync(record));
|
||||
assert.equal(invocation.cwd, path.join(root, 'custom-pack'));
|
||||
assert.deepEqual(invocation.args.slice(4), ['--yes', 'modrinth', 'add', 'some search $(literal)']);
|
||||
fs.appendFileSync(executable, '\n// tampered\n');
|
||||
const rejected = spawnSync(process.execPath, [path.join(root, 'scripts/mcpack.mjs'), 'remove', 'fixture'], { encoding: 'utf8' });
|
||||
assert.equal(rejected.status, 1);
|
||||
assert.match(rejected.stderr, /checksum mismatch/);
|
||||
});
|
||||
|
||||
test('backup and restore parse only their own complete arguments', () => {
|
||||
assert.equal(parseArgs(['backup']).waitTimeout, 120);
|
||||
assert.equal(parseArgs(['backup', '--target-release', '/release', '--wait-timeout', '30']).waitTimeout, 30);
|
||||
assert.equal(parseArgs(['restore', '--backup', '/snapshot', '--destination', '/new']).destination, '/new');
|
||||
for (const args of [['backup', '--wait-timeout', '0'], ['backup', '--wait-timeout', 'NaN'], ['backup', '--release'], ['backup', '--destination', '/new'], ['restore'], ['restore', '--backup', '/snap'], ['restore', '--backup', '/snap', '--destination', '/new', '--wait-timeout', '3'], ['build', '--target-release', '/release']]) assert.throws(() => parseArgs(args));
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fixture, seal } from './helpers/runtime-fixture.mjs';
|
||||
import { runtime, verifyRelease, withRuntimeLock } from '../platform/runtime.mjs';
|
||||
import { deployOnHost, deployableRelease, releaseName, sha256 } from '../platform/deployment.mjs';
|
||||
import { sshTarget, shellQuote } from '../platform/ssh-deploy.mjs';
|
||||
import { parseArgs } from '../platform/commands.mjs';
|
||||
|
||||
import { setup, addConfig } from './helpers/deployment-fixture.mjs';
|
||||
|
||||
test('first deployment publishes checked release, starts under lock and commits active only after success',async t=>{
|
||||
const f=setup(t),state=await f.deploy();
|
||||
assert.equal(state.first_deploy,true);assert.equal(state.backup_snapshot,null);assert.equal(state.phase,'complete');
|
||||
assert.equal(fs.statSync(path.join(f.host,'releases',state.target_release)).mode&0o777,0o755);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
||||
assert(!fs.existsSync(path.join(f.host,'previous-release')));
|
||||
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||
assert(f.events().filter(e=>e.command==='up').every(e=>e.locked));
|
||||
});
|
||||
|
||||
test('repeat same healthy release does not upload or restart',async t=>{
|
||||
const f=setup(t);await f.deploy();
|
||||
const result=await f.deploy(f.release,()=>{throw new Error('should not upload');});
|
||||
assert.equal(result.noop,true);assert.equal(f.events().filter(e=>e.command==='up').length,1);
|
||||
});
|
||||
|
||||
test('same name/version with another manifest is rejected before stopping',async t=>{
|
||||
const f=setup(t);await f.deploy();
|
||||
fs.appendFileSync(path.join(f.release,'fixture.mrpack'),'changed');seal(f.release);
|
||||
await assert.rejects(f.deploy(),/immutable release collision/);
|
||||
assert.equal(f.state().phase,'complete');assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
||||
});
|
||||
|
||||
test('update requires verified backup, preserves unmanaged world and advances previous/active',async t=>{
|
||||
const f=setup(t);await f.deploy();
|
||||
fs.mkdirSync(path.join(f.host,'runtime/world'));fs.writeFileSync(path.join(f.host,'runtime/world/level.dat'),'valuable');
|
||||
fs.mkdirSync(path.join(f.host,'runtime/mod-state'));fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'42');
|
||||
const result=await f.deploy(f.next());
|
||||
assert(result.backup_snapshot);assert(fs.existsSync(path.join(result.backup_snapshot,'runtime.tar.zst')));
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/level.dat'),'utf8'),'valuable');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'42');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'previous-release'),'utf8').trim(),'fixture-1.0.0');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
|
||||
});
|
||||
|
||||
test('manual managed edit fails before stop/uploaded config replacement, untracked configs retained',async t=>{
|
||||
const f=setup(t);addConfig(f.release);await f.deploy();
|
||||
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
|
||||
fs.writeFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'keep');
|
||||
await assert.rejects(f.deploy(f.next()),/Managed config conflicts/);
|
||||
assert.equal(f.state().conflicts[0].reason,'managed-file-edited');
|
||||
assert.match(f.state().conflicts[0].diff,/\+manual-edit/);
|
||||
assert.match(f.state().conflicts[0].diff,/-original/);
|
||||
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'utf8'),'keep');
|
||||
});
|
||||
|
||||
test('transfer interruption does not stop source and records failed recoverable state',async t=>{
|
||||
const f=setup(t);await f.deploy();
|
||||
await assert.rejects(f.deploy(f.next(),()=>{throw new Error('transfer failed');}),/transfer failed/);
|
||||
assert.equal(f.state().phase,'failed');assert.equal(f.state().applied,false);
|
||||
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
||||
assert(!fs.readdirSync(path.join(f.host,'releases')).some(n=>n.startsWith('.incoming')));
|
||||
await assert.rejects(f.deploy(f.next('3.0.0')),/Unresolved deployment state/);
|
||||
});
|
||||
|
||||
test('backup failure prohibits target startup and preserves source pointer/binding',async t=>{
|
||||
const f=setup(t);await f.deploy();
|
||||
const binding=fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8');
|
||||
fs.writeFileSync(path.join(f.root,'.tools/tar'),'#!/bin/sh\nexit 42\n',{mode:0o755});
|
||||
await assert.rejects(f.deploy(f.next()),/No snapshot published/);
|
||||
assert.equal(f.state().applied,false);assert.equal(f.state().backup_snapshot,null);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'),binding);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
||||
assert.equal(f.events().filter(e=>e.command==='up').length,1);
|
||||
});
|
||||
|
||||
test('failed target startup leaves stopped target binding, source pointers and source backup',async t=>{
|
||||
const f=setup(t);await f.deploy();
|
||||
const next=f.next();fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
|
||||
await assert.rejects(f.deploy(next),/Target startup\/health failed/);
|
||||
const state=f.state();assert.equal(state.phase,'failed');assert.equal(state.applied,true);assert(state.backup_snapshot);assert.equal(state.stopped_confirmed,true);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(next).identity);
|
||||
});
|
||||
|
||||
test('async shared lock remains held during upload and rejects concurrent lifecycle/deploy',async t=>{
|
||||
const f=setup(t);let releaseUpload,entered;
|
||||
const waiting=new Promise(resolve=>entered=resolve),block=new Promise(resolve=>releaseUpload=resolve);
|
||||
const deployment=f.deploy(f.release,async stage=>{entered();await block;fs.cpSync(f.release,stage,{recursive:true});});
|
||||
await waiting;
|
||||
assert(fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||
assert.throws(()=>f.run(['up','-d']),/runtime operation/);
|
||||
await assert.rejects(f.deploy(),/runtime operation/);
|
||||
releaseUpload();await deployment;
|
||||
});
|
||||
|
||||
test('dirty release and unsafe SSH arguments rejected; CLI deploy flags scoped',t=>{
|
||||
const f=setup(t);const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));manifest.git.dirty=true;fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
|
||||
assert.throws(()=>deployableRelease(f.release),/clean Git/);
|
||||
for(const [host,dir]of [['-oProxyCommand=bad','/srv/mc'],['user@host;touch bad','/srv/mc'],['alias','/'],['alias','/srv/../mc'],['alias','relative']])assert.throws(()=>sshTarget(host,dir));
|
||||
assert.deepEqual(sshTarget('user@mc-host','/srv/minecraft'),{host:'user@mc-host',directory:'/srv/minecraft'});
|
||||
assert.equal(shellQuote("a'b"),"'a'\\''b'");
|
||||
assert.equal(parseArgs(['deploy','--artifact','/release']).artifact,'/release');
|
||||
for(const args of [['deploy','--yes'],['deploy','--release'],['build','--artifact','/release']])assert.throws(()=>parseArgs(args));
|
||||
});
|
||||
|
||||
test('manual edit incorporated exactly in clean target is accepted',async t=>{
|
||||
const f=setup(t);addConfig(f.release);await f.deploy();
|
||||
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
|
||||
const next=f.next('2.0.0',release=>{
|
||||
fs.writeFileSync(path.join(release,'server/config/marker.txt'),'manual-edit');
|
||||
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));
|
||||
manifest.managed_files.server[0].hashes.sha256=sha256(path.join(release,'server/config/marker.txt'));
|
||||
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));
|
||||
});
|
||||
assert.equal((await f.deploy(next)).phase,'complete');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { waitForHealth, healthOptions } from '../platform/health.mjs';
|
||||
import { fixture } from './helpers/runtime-fixture.mjs';
|
||||
|
||||
const healthy = () => ({ Id:'a'.repeat(64), RestartCount:0, State:{Status:'running',Running:true,StartedAt:'start',Health:{Status:'healthy'}} });
|
||||
function check(inspect, probe=()=>({status:0,stdout:'Minecraft 1.21.1'}), options={}) {
|
||||
let time=0;
|
||||
return waitForHealth({inspect,probe,timeoutSeconds:6,stableSeconds:2,now:()=>time,pause:ms=>{time+=ms;},...options});
|
||||
}
|
||||
|
||||
test('readiness requires repeated successful Minecraft queries and stable container', () => {
|
||||
let queries=0;
|
||||
const result=check(healthy,()=>{queries++;return{status:0};});
|
||||
assert.equal(result.ok,true);
|
||||
assert.equal(result.elapsed_ms,2000);
|
||||
assert.equal(queries,3);
|
||||
});
|
||||
|
||||
test('running TCP-ready/healthy container with failed Minecraft query times out', () => {
|
||||
const result=check(healthy,()=>({status:1,stderr:'protocol unavailable'}));
|
||||
assert.equal(result.reason,'timeout');
|
||||
assert.equal(result.probe.status,1);
|
||||
});
|
||||
|
||||
test('starting or unhealthy cannot pass and transient failures reset stability window', () => {
|
||||
for(const status of ['starting','unhealthy'])assert.equal(check(()=>({...healthy(),State:{...healthy().State,Health:{Status:status}}})).reason,'timeout');
|
||||
let calls=0;
|
||||
assert.equal(check(healthy,()=>({status:++calls===2?1:0})).elapsed_ms,4000);
|
||||
});
|
||||
|
||||
test('crash/OOM and paused fail immediately with diagnostic state', () => {
|
||||
const dead=healthy();Object.assign(dead.State,{Running:false,Status:'exited',ExitCode:137,OOMKilled:true});
|
||||
const result=check(()=>dead);
|
||||
assert.equal(result.reason,'crash');assert.equal(result.state.oom_killed,true);assert.equal(result.state.exit_code,137);
|
||||
const paused=healthy();paused.State.Paused=true;
|
||||
assert.equal(check(()=>paused).reason,'paused');
|
||||
});
|
||||
|
||||
test('restarting, increasing restart count, replacement and start time changes fail', () => {
|
||||
const restart=healthy();restart.State.Restarting=true;
|
||||
assert.equal(check(()=>restart).reason,'restart-loop');
|
||||
for(const change of [c=>c.RestartCount++,c=>c.State.StartedAt='new',c=>c.Id='b'.repeat(64)]){
|
||||
let calls=0;assert.match(check(()=>{const c=healthy();if(calls++)change(c);return c;}).reason,/restart-loop|container-replaced/);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing container/healthcheck and inspection errors never report success', () => {
|
||||
assert.equal(check(()=>null).reason,'missing-container');
|
||||
assert.equal(check(()=>{const c=healthy();delete c.State.Health;return c;}).reason,'missing-healthcheck');
|
||||
assert.equal(check(()=>{throw new Error('Docker denied');}).reason,'inspection-error');
|
||||
});
|
||||
|
||||
test('health flags require positive bounded options and detached startup', () => {
|
||||
assert.deepEqual(healthOptions(['up','--wait','--wait-timeout','30','--stable-seconds','2']).forwarded,['up','-d']);
|
||||
assert.equal(healthOptions(['health','--wait-timeout','60']).timeoutSeconds,60);
|
||||
for(const args of [['up','--wait-timeout','30'],['health','--wait-timeout','NaN'],['health','--wait-timeout','3'],['health','--stable-seconds','0'],['stop','--wait'],['health','--wait-timeout','20','--wait-timeout','30'],['up','--wait','--abort-on-container-exit']])assert.throws(()=>healthOptions(args));
|
||||
});
|
||||
|
||||
test('failed waited startup writes report/logs, preserves binding, releases lifecycle lock', t => {
|
||||
const f=fixture(t);
|
||||
fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'');
|
||||
assert.equal(f.run(['up','--wait','--wait-timeout','30']),1);
|
||||
const files=fs.readdirSync(path.join(f.host,'diagnostics'));
|
||||
const report=JSON.parse(fs.readFileSync(path.join(f.host,'diagnostics',files.find(name=>name.endsWith('.json')))));
|
||||
assert.equal(report.reason,'compose-start-failed');assert.equal(report.compose_exit,19);
|
||||
assert.equal(report.recovery.automatic_rollback,false);
|
||||
assert(fs.existsSync(report.logs));
|
||||
assert(fs.existsSync(path.join(f.host,'.mcpack-runtime.json')));
|
||||
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||
});
|
||||
|
||||
test('deadline includes launch time and a late successful query cannot pass', () => {
|
||||
assert.equal(check(healthy,()=>({status:0}),{startedAtMs:-5000}).reason,'timeout');
|
||||
let time=0;
|
||||
const result=waitForHealth({inspect:healthy,probe:()=>{time+=6000;return{status:0};},timeoutSeconds:6,stableSeconds:2,now:()=>time,pause:ms=>{time+=ms;}});
|
||||
assert.equal(result.reason,'timeout');
|
||||
assert.equal(healthOptions(['up','--wait','--wait-timeout=30']).timeoutSeconds,30);
|
||||
assert.throws(()=>healthOptions(['up','--wait=true']));
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||
import { build } from '../../platform/build.mjs';
|
||||
|
||||
const repository = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const sha = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const git = (root, args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' });
|
||||
|
||||
export function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-test-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const file of ['pack', 'server', 'platform', 'scripts', '.gitignore', 'package.json', 'package-lock.json', 'toolchain.lock.env']) {
|
||||
fs.cpSync(path.join(repository, file), path.join(root, file), { recursive: true, filter: file => path.basename(file) !== '.env' && path.basename(file) !== 'compose.override.yaml' });
|
||||
}
|
||||
fs.mkdirSync(path.join(root, '.tools/packwiz'), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, 'dist'));
|
||||
fs.writeFileSync(path.join(root, 'dist/.gitkeep'), '');
|
||||
fs.copyFileSync(path.join(repository, '.tools/docker-compose'), path.join(root, '.tools/docker-compose'));
|
||||
const mock = path.join(root, '.tools/packwiz/packwiz');
|
||||
fs.writeFileSync(mock, `#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { parse } from ${JSON.stringify(import.meta.resolve('smol-toml'))};
|
||||
const state = JSON.parse(fs.readFileSync(${JSON.stringify(path.join(root, '.tools/state.json'))}, 'utf8'));
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('refresh')) {
|
||||
if (state.stale) fs.appendFileSync('index.toml', '\\n# mock refresh changed index\\n');
|
||||
process.exit(0);
|
||||
}
|
||||
if (state.exportFailure) { console.error('Simulated export failure'); process.exit(7); }
|
||||
const pack = parse(fs.readFileSync('pack.toml', 'utf8'));
|
||||
const index = parse(fs.readFileSync(pack.index.file, 'utf8'));
|
||||
const archive = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-mock-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(archive, 'overrides'));
|
||||
const metadata = [];
|
||||
for (const entry of index.files) {
|
||||
if (entry.metafile) {
|
||||
const mod = parse(fs.readFileSync(entry.file, 'utf8'));
|
||||
metadata.push({path:path.posix.join(path.posix.dirname(entry.file),mod.filename),hashes:{sha512:mod.download.hash,sha1:'1'.repeat(40)},env:{client:mod.side==='server'?'unsupported':'required',server:mod.side==='client'?'unsupported':'required'},downloads:[mod.download.url]});
|
||||
} else {
|
||||
const destination = path.join(archive, 'overrides', entry.file);
|
||||
fs.mkdirSync(path.dirname(destination), {recursive:true});
|
||||
fs.copyFileSync(entry.file, destination);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(archive,'modrinth.index.json'),JSON.stringify({formatVersion:1,game:'minecraft',name:pack.name,versionId:pack.version,dependencies:pack.versions,files:metadata}));
|
||||
execFileSync('bsdtar',['--format','zip','-cf',args[args.indexOf('--output')+1],'-C',archive,'modrinth.index.json','overrides']);
|
||||
} finally { fs.rmSync(archive,{recursive:true,force:true}); }
|
||||
`);
|
||||
fs.chmodSync(mock, 0o755);
|
||||
fs.writeFileSync(path.join(root, '.tools/state.json'), '{}');
|
||||
let lock = fs.readFileSync(path.join(root, 'toolchain.lock.env'), 'utf8');
|
||||
lock = lock.replace(/^PACKWIZ_BINARY_SHA256=.*$/m, `PACKWIZ_BINARY_SHA256=${sha(mock)}`);
|
||||
fs.writeFileSync(path.join(root, 'toolchain.lock.env'), lock);
|
||||
git(root, ['init', '-q']);
|
||||
commit(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
export function commit(root) {
|
||||
git(root, ['add', '-A']);
|
||||
git(root, ['-c', 'user.name=Test', '-c', 'user.email=test@localhost', 'commit', '--allow-empty', '-qm', 'Fixture']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fixture, seal } from './runtime-fixture.mjs';
|
||||
import { runtime } from '../../platform/runtime.mjs';
|
||||
import { deployOnHost, deployableRelease, releaseName, sha256 } from '../../platform/deployment.mjs';
|
||||
|
||||
export function setup(t){
|
||||
const f=fixture(t);
|
||||
const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));
|
||||
manifest.git={dirty:false,commit:'1'.repeat(40)};manifest.managed_files={server:[]};
|
||||
fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
|
||||
// Health's actual waiting/state machine is tested separately. This adapter
|
||||
// substitutes readiness, while retaining real binding/lock/Compose checks.
|
||||
f.execute=options=>{
|
||||
const args=options.args[0]==='health'?['ps']:options.args.filter((item,index,array)=>item!=='--wait'&&item!=='--wait-timeout'&&array[index-1]!=='--wait-timeout');
|
||||
const result=runtime({...options,args});
|
||||
if(result===0&&args[0]==='up'){
|
||||
const config=path.join(options.env.MC_RELEASE_DIR,'server/config');
|
||||
for(const file of fs.readdirSync(config)){
|
||||
fs.mkdirSync(path.join(options.env.MC_ROOT,'runtime/config'),{recursive:true});fs.copyFileSync(path.join(config,file),path.join(options.env.MC_ROOT,'runtime/config',file));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
f.deploy=(release=f.release,receive)=>{
|
||||
const verified=deployableRelease(release);
|
||||
return deployOnHost({repository:f.root,root:f.host,targetName:releaseName(verified.manifest),targetIdentity:verified.identity,targetChecksums:sha256(path.join(release,'SHA256SUMS')),releaseBytes:1024,timeout:30,env:f.env,execute:f.execute,receive:receive??(stage=>fs.cpSync(release,stage,{recursive:true}))});
|
||||
};
|
||||
f.next=(version='2.0.0',edit=()=>{})=>{
|
||||
const release=path.join(f.root,'release-'+version);fs.cpSync(f.release,release,{recursive:true});
|
||||
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));manifest.pack.version=version;
|
||||
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));edit(release);seal(release);return release;
|
||||
};
|
||||
f.state=()=>JSON.parse(fs.readFileSync(path.join(f.host,'deployment-state.json')));
|
||||
f.events=()=>JSON.parse(fs.readFileSync(path.join(f.root,'.tools/events.json')));
|
||||
return f;
|
||||
}
|
||||
export function addConfig(release,value='original'){
|
||||
fs.writeFileSync(path.join(release,'server/config/marker.txt'),value);
|
||||
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));
|
||||
manifest.release_files.push({path:'server/config/marker.txt'});
|
||||
manifest.managed_files.server.push({path:'config/marker.txt',owner:'server',policy:'replace',hashes:{sha256:sha256(path.join(release,'server/config/marker.txt'))}});
|
||||
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));seal(release);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runtime } from '../../platform/runtime.mjs';
|
||||
|
||||
const repository = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const sha = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
|
||||
export function seal(release) {
|
||||
const manifestFile = path.join(release, 'manifest.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestFile));
|
||||
for (const entry of manifest.release_files) entry.sha256 = sha(path.join(release, entry.path));
|
||||
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
|
||||
const files = [...manifest.release_files.map(entry => entry.path), 'manifest.json'].sort();
|
||||
fs.writeFileSync(path.join(release, 'SHA256SUMS'), files.map(file => `${sha(path.join(release, file))} ${file}\n`).join(''));
|
||||
}
|
||||
|
||||
export function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-runtime-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'platform'));
|
||||
fs.copyFileSync(path.join(repository, 'platform/compatibility.json'), path.join(root, 'platform/compatibility.json'));
|
||||
fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}');
|
||||
fs.mkdirSync(path.join(root, '.tools'));
|
||||
// Build a minimal sealed release so tests work in a clean clone without dist.
|
||||
const release = path.join(root, 'release');
|
||||
fs.mkdirSync(path.join(release, 'server/config'), { recursive: true });
|
||||
fs.writeFileSync(path.join(release, 'fixture.mrpack'), 'runtime fixture payload');
|
||||
fs.writeFileSync(path.join(release, 'server/compose.yaml'), 'services: {}\n');
|
||||
fs.copyFileSync(path.join(repository, 'server/.env.example'), path.join(release, 'server/.env.example'));
|
||||
const executable = path.join(root, '.tools/docker-compose');
|
||||
fs.writeFileSync(executable, `#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const args=process.argv.slice(2),env=process.env;
|
||||
const m=JSON.parse(fs.readFileSync(path.join(env.MC_RELEASE_DIR,'manifest.json')));
|
||||
const stateFile=${JSON.stringify(path.join(root, '.tools/docker-state.json'))};
|
||||
const state=fs.existsSync(stateFile)?JSON.parse(fs.readFileSync(stateFile)):{present:false,running:false};
|
||||
if(args.includes('config')){
|
||||
const wrong=fs.existsSync(${JSON.stringify(path.join(root, '.tools/wrong-contract'))});
|
||||
console.log(JSON.stringify({services:{minecraft:{image:m.server.image,environment:{TYPE:'MODRINTH',ONLINE_MODE:wrong?'true':'false',ENFORCE_SECURE_PROFILE:'false',MODRINTH_MODPACK:'/modpacks/'+m.artifact},volumes:[{type:'bind',source:path.join(env.MC_ROOT,'runtime'),target:'/data'},{type:'bind',source:env.MC_RELEASE_DIR,target:'/modpacks',read_only:true},{type:'bind',source:path.join(env.MC_RELEASE_DIR,'server/config'),target:'/config',read_only:true}]}}}));
|
||||
}else if(args.includes('ps')){
|
||||
console.log(JSON.stringify(state.present||fs.existsSync(${JSON.stringify(path.join(root, '.tools/existing-project'))})?[{ID:'a'.repeat(64)}]:[]));
|
||||
}else{
|
||||
fs.writeFileSync(${JSON.stringify(path.join(root, '.tools/invocation.json'))},JSON.stringify({args,uid:env.MC_UID,gid:env.MC_GID,bind:env.MC_BIND_ADDRESS,root:env.MC_ROOT,release:env.MC_RELEASE_DIR}));
|
||||
const command=args[6];
|
||||
const eventsFile=${JSON.stringify(path.join(root, '.tools/events.json'))};
|
||||
const events=fs.existsSync(eventsFile)?JSON.parse(fs.readFileSync(eventsFile)):[];
|
||||
events.push({command,locked:fs.existsSync(path.join(env.MC_ROOT,'.mcpack-runtime.lock'))});
|
||||
fs.writeFileSync(eventsFile,JSON.stringify(events));
|
||||
if(command==='up'&&fs.existsSync(${JSON.stringify(path.join(root, '.tools/fail-start'))}))process.exit(19);
|
||||
if(command==='stop'&&fs.existsSync(${JSON.stringify(path.join(root, '.tools/fail-stop'))}))process.exit(18);
|
||||
if(command==='up'||command==='start')Object.assign(state,{present:true,running:true});
|
||||
if(command==='stop')state.running=false;
|
||||
if(command==='down')Object.assign(state,{present:false,running:false});
|
||||
fs.writeFileSync(stateFile,JSON.stringify(state));
|
||||
}
|
||||
`);
|
||||
fs.chmodSync(executable, 0o755);
|
||||
const docker = path.join(root, '.tools/docker');
|
||||
fs.writeFileSync(docker, `#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const stateFile=${JSON.stringify(path.join(root, '.tools/docker-state.json'))};
|
||||
const state=fs.existsSync(stateFile)?JSON.parse(fs.readFileSync(stateFile)):{present:false,running:false};
|
||||
const other=fs.existsSync(${JSON.stringify(path.join(root, '.tools/existing-project'))});
|
||||
if(process.argv[2]==='exec'){console.log('Minecraft status fixture');process.exit(0);}
|
||||
console.log(JSON.stringify([{Id:'a'.repeat(64),RestartCount:0,Mounts:[{Type:'bind',Destination:'/data',Source:other?'/another/runtime':path.join(process.env.MC_ROOT,'runtime')}],State:{Status:state.running?'running':'exited',Running:state.running,Restarting:false,Paused:false,Dead:false,StartedAt:'fixture-start',ExitCode:0,Health:{Status:'healthy'}}}]));
|
||||
`);
|
||||
fs.chmodSync(docker, 0o755);
|
||||
const verified = JSON.parse(fs.readFileSync(path.join(root, 'platform/compatibility.json'))).verified[0];
|
||||
fs.writeFileSync(path.join(release, 'manifest.json'), JSON.stringify({ schema_version: 1, artifact: 'fixture.mrpack', pack: { name: 'fixture', version: '1.0.0', minecraft: verified.minecraft, loader: { id: verified.loader, version: verified.loader_version } }, server: { image: verified.image, java_major: verified.java_major, compose_project: 'fixture-server' }, tools: { compose: { sha256: sha(executable) } }, release_files: ['fixture.mrpack', 'server/compose.yaml', 'server/.env.example'].map(file => ({ path: file })) }));
|
||||
seal(release);
|
||||
const host = path.join(root, 'host');
|
||||
fs.mkdirSync(host);
|
||||
fs.copyFileSync(path.join(repository, 'server/.env.example'), path.join(host, '.env'));
|
||||
fs.writeFileSync(path.join(host, '.env'), fs.readFileSync(path.join(host, '.env'), 'utf8').replace('MC_EULA=FALSE', 'MC_EULA=TRUE'));
|
||||
const env = { ...process.env, PATH: `${path.join(root, '.tools')}${path.delimiter}${process.env.PATH}`, MC_ROOT: host, MC_RELEASE_DIR: release, MC_PROJECT_NAME: 'fixture-server' };
|
||||
return { root, host, release, env, run: args => runtime({ repository: root, args, env }) };
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {release,validVersion} from '../platform/release.mjs';
|
||||
import {parseArgs} from '../platform/commands.mjs';
|
||||
import {fixture,commit} from './helpers/build-fixture.mjs';
|
||||
import {build} from '../platform/build.mjs';
|
||||
import {verifyRelease} from '../platform/runtime.mjs';
|
||||
const git=(root,args)=>execFileSync('git',args,{cwd:root,encoding:'utf8'}).trim();
|
||||
|
||||
test('release CLI and supported SemVer reject ambiguous flags and versions',()=>{
|
||||
assert.equal(parseArgs(['release','0.0.6']).version,'0.0.6');
|
||||
for(const args of [['release'],['release','0.0.6','--release'],['release','0.0.6','--yes'],['release','0.0.6','--backup','x']])assert.throws(()=>parseArgs(args));
|
||||
for(const version of ['0.0.6','1.0.0-rc.1'])assert(validVersion(version));
|
||||
for(const version of ['01.0.0','1.0','1.0.0-01','v1.0.0','../1','1.0.0+metadata'])assert(!validVersion(version));
|
||||
});
|
||||
test('release builds twice, commits only version, verifies checksums and tags final commit',t=>{
|
||||
const root=fixture(t),start=git(root,['rev-parse','HEAD']),original=fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8');
|
||||
const result=release({root,version:'0.0.6',log:()=>{}});
|
||||
assert.notEqual(result.commit,start);assert.equal(git(root,['rev-parse','v0.0.6^{}']),result.commit);
|
||||
assert.equal(git(root,['diff-tree','--no-commit-id','--name-only','-r',result.commit]),'pack/pack.toml');
|
||||
assert.equal(git(root,['status','--porcelain']),'');assert.equal(verifyRelease(result.directory).manifest.git.dirty,false);
|
||||
assert(fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8').includes('version = "0.0.6"'));
|
||||
assert(!fs.existsSync(path.join(root,'.tools/release.lock')));
|
||||
assert(fs.readdirSync(path.join(root,'dist')).every(name=>!name.startsWith('.release-check-')));
|
||||
assert.notEqual(original,fs.readFileSync(path.join(root,'pack/pack.toml'),'utf8'));
|
||||
});
|
||||
test('dirty tree, existing tag and existing artifact refuse before changing source',t=>{
|
||||
const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8');
|
||||
fs.writeFileSync(path.join(root,'untracked.txt'),'dirty');assert.throws(()=>release({root,version:'0.0.6'}),/clean/);fs.unlinkSync(path.join(root,'untracked.txt'));
|
||||
git(root,['tag','v0.0.6']);assert.throws(()=>release({root,version:'0.0.6'}),/already exists/);
|
||||
fs.mkdirSync(path.join(root,'dist/minecraft-lab-neoforge-spike-0.0.7'));assert.throws(()=>release({root,version:'0.0.7'}),/already exists/);
|
||||
assert.equal(fs.readFileSync(file,'utf8'),original);
|
||||
});
|
||||
test('candidate export failure restores exact source and leaves HEAD/tag/output untouched',t=>{
|
||||
const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8'),head=git(root,['rev-parse','HEAD']);
|
||||
fs.writeFileSync(path.join(root,'.tools/state.json'),JSON.stringify({exportFailure:true}));
|
||||
assert.throws(()=>release({root,version:'0.0.6',log:()=>{}}),/Version restored/);
|
||||
assert.equal(fs.readFileSync(file,'utf8'),original);assert.equal(git(root,['rev-parse','HEAD']),head);assert.equal(git(root,['tag','--list']),'');assert.equal(git(root,['status','--porcelain']),'');
|
||||
assert(!fs.existsSync(path.join(root,'dist/minecraft-lab-neoforge-spike-0.0.6')));
|
||||
});
|
||||
test('final build failure retains clean version commit but never creates tag',t=>{
|
||||
const root=fixture(t),head=git(root,['rev-parse','HEAD']);let calls=0;
|
||||
assert.throws(()=>release({root,version:'0.0.6',log:()=>{},buildRelease:options=>{if(++calls===2)throw new Error('final failure');return build(options);}}),/Version commit retained/);
|
||||
assert.notEqual(git(root,['rev-parse','HEAD']),head);assert.equal(git(root,['tag','--list']),'');assert.equal(git(root,['status','--porcelain']),'');
|
||||
});
|
||||
test('release shared preparation lock refuses concurrent operation',t=>{
|
||||
const root=fixture(t);fs.mkdirSync(path.join(root,'.tools/release.lock'));
|
||||
assert.throws(()=>release({root,version:'0.0.6'}),/locked/);
|
||||
});
|
||||
test('commit hook rejection restores source and unstages only the release version',t=>{
|
||||
const root=fixture(t),file=path.join(root,'pack/pack.toml'),original=fs.readFileSync(file,'utf8'),head=git(root,['rev-parse','HEAD']);
|
||||
const hook=path.join(root,'.git/hooks/pre-commit');fs.writeFileSync(hook,'#!/bin/sh\nexit 1\n',{mode:0o755});
|
||||
assert.throws(()=>release({root,version:'0.0.6',log:()=>{}}),/Version restored/);
|
||||
assert.equal(fs.readFileSync(file,'utf8'),original);assert.equal(git(root,['status','--porcelain']),'');assert.equal(git(root,['rev-parse','HEAD']),head);
|
||||
});
|
||||
test('concurrent source edit during candidate validation is preserved on failure',t=>{
|
||||
const root=fixture(t),file=path.join(root,'pack/pack.toml');
|
||||
assert.throws(()=>release({root,version:'0.0.6',log:()=>{},buildRelease:()=>{fs.appendFileSync(file,'\n# concurrent operator edit\n');throw new Error('candidate failed');}}),/Concurrent changes retained/);
|
||||
assert(fs.readFileSync(file,'utf8').endsWith('# concurrent operator edit\n'));assert.equal(git(root,['tag','--list']),'');
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {setup} from './helpers/deployment-fixture.mjs';
|
||||
import {rollbackOnHost,inspectRollback} from '../platform/rollback.mjs';
|
||||
import {verifySnapshot} from '../platform/backups.mjs';
|
||||
import {verifyRelease} from '../platform/runtime.mjs';
|
||||
import {parseArgs} from '../platform/commands.mjs';
|
||||
|
||||
async function seeded(t){
|
||||
const f=setup(t);await f.deploy();
|
||||
fs.mkdirSync(path.join(f.host,'runtime/world'));fs.writeFileSync(path.join(f.host,'runtime/world/progress'),'before');
|
||||
fs.mkdirSync(path.join(f.host,'runtime/mod-state'));fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'before');
|
||||
f.second=f.next();const update=await f.deploy(f.second);f.snapshot=update.backup_snapshot;f.backupId=path.basename(f.snapshot);
|
||||
fs.writeFileSync(path.join(f.host,'runtime/world/progress'),'after');fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'after');
|
||||
fs.writeFileSync(path.join(f.host,'runtime/new-file'),'new');
|
||||
f.rollback=options=>rollbackOnHost({repository:f.root,root:f.host,env:f.env,execute:f.execute,timeout:30,...options});
|
||||
f.rollbackState=()=>JSON.parse(fs.readFileSync(path.join(f.host,'rollback-state.json')));
|
||||
return f;
|
||||
}
|
||||
|
||||
test('no-mode plan is read-only and explicit modes require separate confirmations',async t=>{
|
||||
const f=await seeded(t),files=fs.readdirSync(f.host),events=f.events().length;
|
||||
const plan=inspectRollback(f.host);
|
||||
assert.equal(plan.action,'plan-only');assert.equal(plan.active,'fixture-2.0.0');assert.equal(plan.previous,'fixture-1.0.0');assert.equal(plan.snapshots[0].id,f.backupId);
|
||||
assert.deepEqual(fs.readdirSync(f.host),files);assert.equal(f.events().length,events);
|
||||
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0'}),/confirm-compatible/);
|
||||
assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId}),/accept-progress-loss/);
|
||||
assert(!fs.existsSync(path.join(f.host,'rollback-state.json')));
|
||||
});
|
||||
|
||||
test('snapshot rollback replaces full runtime, retains displaced progress/current backup and env',async t=>{
|
||||
const f=await seeded(t),settings=fs.readFileSync(path.join(f.host,'.env'),'utf8');
|
||||
const state=f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true});
|
||||
assert.equal(state.phase,'complete');assert.equal(state.runtime_replaced,true);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'before');assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'before');
|
||||
assert(!fs.existsSync(path.join(f.host,'runtime/new-file')));
|
||||
assert.equal(fs.readFileSync(path.join(state.retired_runtime,'world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(state.retired_runtime,'new-file'),'utf8'),'new');
|
||||
assert.equal(verifySnapshot(state.backup_snapshot).source_release.name,'fixture-2.0.0');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'.env'),'utf8'),settings);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');assert.equal(fs.readFileSync(path.join(f.host,'previous-release'),'utf8').trim(),'fixture-2.0.0');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(f.release).identity);
|
||||
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
||||
});
|
||||
|
||||
test('pack-only uses retained release without restore/build and preserves current world',async t=>{
|
||||
const f=await seeded(t);
|
||||
const state=f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true});
|
||||
assert.equal(state.mode,'pack');assert.equal(state.runtime_replaced,false);assert(state.backup_snapshot);
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'after');
|
||||
assert(fs.existsSync(path.join(f.host,'runtime/new-file')));assert.equal(state.target_release,'fixture-1.0.0');
|
||||
});
|
||||
|
||||
test('corrupt snapshot or release is rejected before stopping/current backup',async t=>{
|
||||
const f=await seeded(t),events=f.events().length;
|
||||
fs.appendFileSync(path.join(f.snapshot,'runtime.tar.zst'),'corrupt');
|
||||
assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}),/checksum mismatch/);
|
||||
assert.equal(f.events().length,events);assert(!fs.existsSync(path.join(f.host,'rollback-state.json')));
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after');
|
||||
});
|
||||
|
||||
test('current-state backup failure blocks runtime swap and pointer/binding changes',async t=>{
|
||||
const f=await seeded(t),binding=fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8');
|
||||
fs.writeFileSync(path.join(f.root,'.tools/tar'),'#!/bin/sh\nexit 42\n',{mode:0o755});
|
||||
// Pack-only avoids snapshot validation's tar and reaches backup failure.
|
||||
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/No snapshot published/);
|
||||
assert.equal(f.rollbackState().applied,false);assert.equal(f.rollbackState().phase,'failed');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'),binding);assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'after');
|
||||
});
|
||||
|
||||
test('failed snapshot startup retains both worlds and source pointers, never auto starts previous pack',async t=>{
|
||||
const f=await seeded(t);fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
|
||||
assert.throws(()=>f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true}),/Rollback startup\/health failed/);
|
||||
const state=f.rollbackState();assert.equal(state.phase,'failed');assert.equal(state.stopped_confirmed,true);assert(state.backup_snapshot);
|
||||
assert.equal(fs.readFileSync(path.join(state.retired_runtime,'world/progress'),'utf8'),'after');assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/progress'),'utf8'),'before');
|
||||
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(f.release).identity);
|
||||
const up=f.events().filter(e=>e.command==='up');assert.equal(up.length,3);
|
||||
assert.throws(()=>f.rollback({mode:'pack',version:'2.0.0',confirmCompatible:true}),/Unresolved rollback state/);
|
||||
await assert.rejects(f.deploy(f.second),/Unresolved rollback state/);
|
||||
});
|
||||
|
||||
test('explicit snapshot recovery resolves a failed deployment using actual applied binding',async t=>{
|
||||
const f=await seeded(t),third=f.next('3.0.0');fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
|
||||
await assert.rejects(f.deploy(third),/Target startup\/health failed/);
|
||||
const failure=f.state();assert.equal(failure.phase,'failed');
|
||||
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/snapshot recovery/);
|
||||
fs.unlinkSync(path.join(f.root,'.tools/fail-start'));
|
||||
const state=f.rollback({mode:'snapshot',backupId:f.backupId,acceptProgressLoss:true});
|
||||
assert.equal(state.source_release,'fixture-3.0.0');assert.equal(state.target_release,'fixture-1.0.0');assert.equal(state.phase,'complete');
|
||||
assert.equal(JSON.parse(fs.readFileSync(state.prior_deployment_state)).phase,'failed');assert.equal(f.state().phase,'complete');
|
||||
assert.equal(f.state().resolved_deployment,failure.id);
|
||||
});
|
||||
|
||||
test('lock and snapshot ID/path validation precede mutation',async t=>{
|
||||
const f=await seeded(t);fs.mkdirSync(path.join(f.host,'.mcpack-runtime.lock'));
|
||||
assert.throws(()=>f.rollback({mode:'pack',version:'1.0.0',confirmCompatible:true}),/runtime operation/);
|
||||
fs.rmdirSync(path.join(f.host,'.mcpack-runtime.lock'));
|
||||
assert.throws(()=>f.rollback({mode:'snapshot',backupId:'../escape',acceptProgressLoss:true}),/Snapshot rollback requires/);
|
||||
assert.throws(()=>inspectRollback(f.host,{backupId:'../escape'}),/snapshot ID/);
|
||||
});
|
||||
|
||||
test('CLI confirmation flags cannot substitute for each other or leak to other commands',()=>{
|
||||
assert.equal(parseArgs(['rollback']).acceptProgressLoss,false);
|
||||
assert.equal(parseArgs(['rollback','--backup','id','--accept-progress-loss']).acceptProgressLoss,true);
|
||||
assert.equal(parseArgs(['rollback-pack','0.0.4','--confirm-compatible']).confirmCompatible,true);
|
||||
for(const args of [['rollback','--yes'],['rollback','--accept-progress-loss'],['rollback','--confirm-compatible'],['rollback-pack','0.0.4','--accept-progress-loss'],['rollback-pack'],['deploy','--confirm-compatible'],['restore','--backup','id','--destination','/new','--accept-progress-loss']])assert.throws(()=>parseArgs(args));
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import * as cryptoModule from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { verifyRelease, readSettings, runtime } from '../platform/runtime.mjs';
|
||||
import { fixture, seal } from './helpers/runtime-fixture.mjs';
|
||||
|
||||
test('release validation rejects tampering, incomplete coverage, and extra files', t => {
|
||||
const f = fixture(t);
|
||||
assert(verifyRelease(f.release).identity);
|
||||
fs.appendFileSync(path.join(f.release, 'fixture.mrpack'), 'tampered');
|
||||
assert.throws(() => verifyRelease(f.release), /checksum mismatch/);
|
||||
seal(f.release);
|
||||
fs.writeFileSync(path.join(f.release, 'extra.txt'), 'unexpected');
|
||||
assert.throws(() => verifyRelease(f.release), /coverage/);
|
||||
});
|
||||
|
||||
test('EULA/identity must be explicit and startup refusal leaves data/binding absent', t => {
|
||||
const f = fixture(t), settings = path.join(f.host, '.env');
|
||||
fs.writeFileSync(settings, fs.readFileSync(settings, 'utf8').replace('MC_EULA=TRUE', 'MC_EULA=FALSE'));
|
||||
assert.throws(() => f.run(['up', '-d']), /EULA acceptance/);
|
||||
assert(!fs.existsSync(path.join(f.host, 'runtime')));
|
||||
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||
fs.appendFileSync(settings, 'MC_UID=123\n');
|
||||
assert.throws(() => readSettings(settings), /Invalid local settings/);
|
||||
});
|
||||
|
||||
test('fresh up binds identity, preserves local UID over ambient env, and same-release recreate works', t => {
|
||||
const f = fixture(t);
|
||||
f.env.MC_UID = '999';
|
||||
assert.equal(f.run(['up', '-d']), 0);
|
||||
const state = JSON.parse(fs.readFileSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||
assert.equal(state.project, 'fixture-server');
|
||||
assert.equal(state.release_manifest_sha256, verifyRelease(f.release).identity);
|
||||
const invocation = JSON.parse(fs.readFileSync(path.join(f.root, '.tools/invocation.json')));
|
||||
assert.equal(invocation.uid, '1000');
|
||||
assert.equal(invocation.gid, '1000');
|
||||
assert.deepEqual(invocation.args.slice(0, 2), ['--project-name', 'fixture-server']);
|
||||
fs.writeFileSync(path.join(f.host, 'runtime/world-probe'), 'preserve');
|
||||
assert.equal(f.run(['create', '--force-recreate']), 0);
|
||||
assert.equal(fs.readFileSync(path.join(f.host, 'runtime/world-probe'), 'utf8'), 'preserve');
|
||||
});
|
||||
|
||||
test('another release/project cannot be applied to a bound runtime', t => {
|
||||
const f = fixture(t);
|
||||
f.run(['up', '-d']);
|
||||
f.env.MC_PROJECT_NAME = 'another-server';
|
||||
assert.throws(() => f.run(['up', '-d']), /bound to another/);
|
||||
f.env.MC_PROJECT_NAME = 'fixture-server';
|
||||
fs.appendFileSync(path.join(f.release, 'fixture.mrpack'), 'another release');
|
||||
seal(f.release);
|
||||
assert.throws(() => f.run(['up', '-d']), /bound to another/);
|
||||
});
|
||||
|
||||
test('existing unidentified world cannot be silently adopted', t => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(path.join(f.host, 'runtime'));
|
||||
fs.writeFileSync(path.join(f.host, 'runtime/level.dat'), 'valuable data');
|
||||
assert.throws(() => f.run(['up', '-d']), /Nonempty runtime/);
|
||||
assert.equal(fs.readFileSync(path.join(f.host, 'runtime/level.dat'), 'utf8'), 'valuable data');
|
||||
});
|
||||
|
||||
test('failed Compose startup preserves binding, propagates exit code, and releases lock', t => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(path.join(f.root, '.tools/fail-start'), '');
|
||||
assert.equal(f.run(['up', '-d']), 19);
|
||||
assert(fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||
});
|
||||
|
||||
test('operation lock and compiled offline contract are enforced before runtime mutation', t => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(path.join(f.host, '.mcpack-runtime.lock'));
|
||||
assert.throws(() => f.run(['up', '-d']), /Another runtime operation/);
|
||||
assert(!fs.existsSync(path.join(f.host, 'runtime')));
|
||||
fs.rmdirSync(path.join(f.host, '.mcpack-runtime.lock'));
|
||||
fs.writeFileSync(path.join(f.root, '.tools/wrong-contract'), '');
|
||||
assert.throws(() => f.run(['up', '-d']), /offline release contract/);
|
||||
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||
});
|
||||
|
||||
test('unsafe paths and Compose file/project overrides are rejected', t => {
|
||||
const f = fixture(t);
|
||||
assert.throws(() => f.run(['up', '-f', 'another.yaml']), /controlled by the wrapper/);
|
||||
assert.throws(() => f.run(['up', '-fanother.yaml']), /controlled by the wrapper/);
|
||||
f.env.MC_ENV_FILE = path.join(f.release, 'server/.env.example');
|
||||
assert.throws(() => f.run(['up']), /outside the immutable release/);
|
||||
delete f.env.MC_ENV_FILE;
|
||||
f.env.MC_ROOT = 'relative';
|
||||
assert.throws(() => f.run(['up']), /absolute path/);
|
||||
});
|
||||
|
||||
test('existing Compose project with another data root cannot be replaced or stopped', t => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(path.join(f.root, '.tools/existing-project'), '');
|
||||
assert.throws(() => f.run(['up', '-d']), /belongs to another runtime root/);
|
||||
assert.throws(() => f.run(['down']), /belongs to another runtime root/);
|
||||
assert(!fs.existsSync(path.join(f.host, 'runtime')));
|
||||
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.json')));
|
||||
assert(!fs.existsSync(path.join(f.host, '.mcpack-runtime.lock')));
|
||||
});
|
||||
|
||||
test('bind address is validated and host settings override ambient bind address', t => {
|
||||
const f=fixture(t),settings=path.join(f.host,'.env');
|
||||
assert.equal(readSettings(settings).MC_BIND_ADDRESS,'127.0.0.1');
|
||||
fs.writeFileSync(settings,fs.readFileSync(settings,'utf8').replace('MC_BIND_ADDRESS=127.0.0.1','MC_BIND_ADDRESS=0.0.0.0'));
|
||||
assert.equal(readSettings(settings).MC_BIND_ADDRESS,'0.0.0.0');
|
||||
f.env.MC_BIND_ADDRESS='127.0.0.1';
|
||||
assert.equal(f.run(['up','-d']),0);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(f.root,'.tools/invocation.json'))).bind,'0.0.0.0');
|
||||
fs.writeFileSync(settings,fs.readFileSync(settings,'utf8').replace('MC_BIND_ADDRESS=0.0.0.0','MC_BIND_ADDRESS=invalid.host'));
|
||||
assert.throws(()=>readSettings(settings),/IPv4 address/);
|
||||
});
|
||||
|
||||
test('legacy immutable Compose honors explicit host bind using a temporary port-only overlay', t => {
|
||||
const f=fixture(t),repository=new URL('../',import.meta.url).pathname;
|
||||
fs.copyFileSync(path.join(repository,'.tools/docker-compose'),path.join(f.root,'.tools/docker-compose'));
|
||||
const image=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json'))).server.image;
|
||||
const compose=`services:\n minecraft:\n image: ${image}\n environment:\n TYPE: MODRINTH\n ONLINE_MODE: "false"\n ENFORCE_SECURE_PROFILE: "false"\n MODRINTH_MODPACK: /modpacks/fixture.mrpack\n ports:\n - "127.0.0.1:25565:25565"\n volumes:\n - "\${MC_ROOT}/runtime:/data"\n - "\${MC_RELEASE_DIR}:/modpacks:ro"\n - "\${MC_RELEASE_DIR}/server/config:/config:ro"\n`;
|
||||
fs.writeFileSync(path.join(f.release,'server/compose.yaml'),compose);
|
||||
const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));
|
||||
const {createHash}=cryptoModule;
|
||||
manifest.tools.compose.sha256=createHash('sha256').update(fs.readFileSync(path.join(f.root,'.tools/docker-compose'))).digest('hex');
|
||||
fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
|
||||
fs.writeFileSync(path.join(f.host,'.env'),fs.readFileSync(path.join(f.host,'.env'),'utf8').replace('MC_BIND_ADDRESS=127.0.0.1','MC_BIND_ADDRESS=0.0.0.0'));
|
||||
const before=fs.readFileSync(path.join(f.release,'server/compose.yaml'),'utf8');
|
||||
const result=JSON.parse(runtime({repository:f.root,env:f.env,args:['config','--format','json'],capture:true}));
|
||||
assert.equal(result.services.minecraft.ports.length,1);assert.equal(result.services.minecraft.ports[0].host_ip,'0.0.0.0');
|
||||
assert.equal(fs.readFileSync(path.join(f.release,'server/compose.yaml'),'utf8'),before);
|
||||
assert(!fs.existsSync(path.join(f.host,'runtime')));
|
||||
});
|
||||
Reference in New Issue
Block a user