first cycle lab release
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { absolute, verifyRelease, runtime, readSettings, withRuntimeLock } from './runtime.mjs';
|
||||
|
||||
const fail = message => { throw new Error(message); };
|
||||
const contained = (parent, child) => child === parent || child.startsWith(parent + path.sep);
|
||||
const safe = value => typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..');
|
||||
|
||||
function hashFile(file) {
|
||||
const digest = crypto.createHash('sha256'), buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
const handle = fs.openSync(file, 'r');
|
||||
try {
|
||||
let count;
|
||||
while ((count = fs.readSync(handle, buffer, 0, buffer.length, null))) digest.update(buffer.subarray(0, count));
|
||||
return digest.digest('hex');
|
||||
} finally { fs.closeSync(handle); }
|
||||
}
|
||||
|
||||
function run(command, args, env = process.env) {
|
||||
try { return execFileSync(command, args, { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }); }
|
||||
catch (error) { fail(`${command} failed: ${error.stderr?.toString().trim() || error.message}`); }
|
||||
}
|
||||
|
||||
export function runtimeInventory(directory) {
|
||||
const entries = [];
|
||||
function walk(file, relative) {
|
||||
if (relative !== '.' && !safe(relative)) fail(`Unsupported runtime path: ${relative}`);
|
||||
const stat = fs.lstatSync(file);
|
||||
if (!stat.isDirectory() && (!stat.isFile() || stat.nlink > 1)) fail(`Backup supports regular files/directories without links: ${relative}`);
|
||||
const record = { path: relative, type: stat.isDirectory() ? 'directory' : 'file', mode: stat.mode & 0o7777, uid: stat.uid, gid: stat.gid };
|
||||
if (stat.isFile()) Object.assign(record, { size: stat.size, sha256: hashFile(file) });
|
||||
entries.push(record);
|
||||
if (stat.isDirectory()) for (const child of fs.readdirSync(file).sort()) walk(path.join(file, child), relative === '.' ? child : `${relative}/${child}`);
|
||||
}
|
||||
walk(directory, '.');
|
||||
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
function payloadFiles(directory) {
|
||||
return runtimeInventory(directory).filter(entry => entry.type === 'file').map(entry => entry.path).sort();
|
||||
}
|
||||
|
||||
function writeChecksums(directory) {
|
||||
const lines = payloadFiles(directory).filter(file => file !== 'SHA256SUMS').map(file => `${hashFile(path.join(directory, file))} ${file}\n`);
|
||||
fs.writeFileSync(path.join(directory, 'SHA256SUMS'), lines.join(''));
|
||||
}
|
||||
|
||||
function verifyChecksums(directory) {
|
||||
const expected = new Map();
|
||||
for (const line of fs.readFileSync(path.join(directory, 'SHA256SUMS'), 'utf8').trim().split('\n')) {
|
||||
const match = /^([a-f0-9]{64}) (.+)$/.exec(line);
|
||||
if (!match || !safe(match[2]) || match[2] === 'SHA256SUMS' || expected.has(match[2])) fail('Invalid snapshot checksum file');
|
||||
expected.set(match[2], match[1]);
|
||||
}
|
||||
const actual = payloadFiles(directory).filter(file => file !== 'SHA256SUMS');
|
||||
assert.deepStrictEqual([...expected.keys()].sort(), actual, 'Snapshot checksum coverage differs');
|
||||
for (const [file, sha256] of expected) if (hashFile(path.join(directory, file)) !== sha256) fail(`Snapshot checksum mismatch: ${file}`);
|
||||
}
|
||||
|
||||
function archivePaths(archive) {
|
||||
const raw = run('tar', ['-I', 'zstd', '-tf', archive]).trim().split('\n');
|
||||
const paths = raw.map(entry => {
|
||||
if (entry === './' || entry === '.') return '.';
|
||||
const normalized = entry.replace(/^\.\//, '').replace(/\/$/, '');
|
||||
if (!safe(normalized)) fail('Unsafe path in backup archive');
|
||||
return normalized;
|
||||
});
|
||||
if (new Set(paths).size !== paths.length) fail('Duplicate archive entry');
|
||||
const listing = run('tar', ['-I', 'zstd', '-tvf', archive]);
|
||||
if (listing.trim().split('\n').some(line => !['-', 'd'].includes(line[0]))) fail('Backup archive contains unsupported links/special files');
|
||||
return paths.sort();
|
||||
}
|
||||
|
||||
function extract(archive, destination) {
|
||||
if (fs.existsSync(destination)) fail('Extraction destination already exists');
|
||||
fs.mkdirSync(destination);
|
||||
run('tar', ['--same-permissions', '--numeric-owner', '--same-owner', '-I', 'zstd', '-xf', archive, '-C', destination]);
|
||||
}
|
||||
|
||||
export function verifySnapshot(directory) {
|
||||
directory = absolute(directory, 'backup directory');
|
||||
verifyChecksums(directory);
|
||||
const metadata = JSON.parse(fs.readFileSync(path.join(directory, 'metadata.json'), 'utf8'));
|
||||
if (metadata.schema_version !== 1 || metadata.archive?.file !== 'runtime.tar.zst' || !Array.isArray(metadata.runtime_inventory) || metadata.exclusions?.length !== 0 || metadata.stopped_confirmed !== true) fail('Unsupported backup metadata');
|
||||
const archive = path.join(directory, metadata.archive.file);
|
||||
if (hashFile(archive) !== metadata.archive.sha256 || fs.statSync(archive).size !== metadata.archive.size) fail('Archive differs from backup metadata');
|
||||
const paths = new Set();
|
||||
for (const entry of metadata.runtime_inventory) {
|
||||
if ((entry.path !== '.' && !safe(entry.path)) || paths.has(entry.path) || !['file', 'directory'].includes(entry.type)) fail('Invalid snapshot inventory');
|
||||
paths.add(entry.path);
|
||||
if (![entry.mode, entry.uid, entry.gid].every(value => Number.isInteger(value) && value >= 0)) fail('Invalid snapshot ownership/mode');
|
||||
if (entry.type === 'file' && (!/^[a-f0-9]{64}$/.test(entry.sha256) || !Number.isSafeInteger(entry.size) || entry.size < 0)) fail('Invalid snapshot file hash/size');
|
||||
}
|
||||
assert.deepStrictEqual(archivePaths(archive), [...paths].sort(), 'Archive paths differ from runtime inventory');
|
||||
const release = verifyRelease(path.join(directory, 'release'));
|
||||
if (release.identity !== metadata.source_release?.manifest_sha256 || hashFile(path.join(directory, 'release/SHA256SUMS')) !== metadata.source_release.checksums_sha256 || metadata.runtime_binding?.release_manifest_sha256 !== release.identity || metadata.runtime_binding.schema_version !== 1 || !/^[a-z0-9][a-z0-9_-]*$/.test(metadata.runtime_binding.project)) fail('Backup is not linked to its retained release');
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function containers({ repository, env, lockToken }) {
|
||||
const text = runtime({ repository, env, lockToken, args: ['ps', '--all', '--format', 'json'], capture: true }).trim();
|
||||
const list = !text ? [] : text.startsWith('[') ? JSON.parse(text) : text.split('\n').map(line => JSON.parse(line));
|
||||
if (!Array.isArray(list) || list.some(item => typeof item.ID !== 'string' || !/^[a-f0-9]{12,64}$/.test(item.ID))) fail('Invalid container status');
|
||||
return list.length ? JSON.parse(run('docker', ['inspect', ...list.map(item => item.ID)], env)) : [];
|
||||
}
|
||||
|
||||
function summary(directory, release) {
|
||||
return { name: `${release.manifest.pack.name}-${release.manifest.pack.version}`, manifest_sha256: release.identity, checksums_sha256: hashFile(path.join(directory, 'SHA256SUMS')), pack: release.manifest.pack, image: release.manifest.server.image };
|
||||
}
|
||||
|
||||
function checkedLifecycle(options, args) {
|
||||
const status = runtime({ ...options, args });
|
||||
if (status !== 0) fail(`Compose ${args[0]} failed with exit ${status}`);
|
||||
}
|
||||
|
||||
function snapshotOperation({ repository, env = process.env, targetRelease, waitTimeout = 120, lockToken: heldToken }, apply) {
|
||||
const root = absolute(env.MC_ROOT, 'MC_ROOT'), data = absolute(path.join(root, 'runtime'), 'runtime');
|
||||
const sourcePath = absolute(env.MC_RELEASE_DIR, 'MC_RELEASE_DIR');
|
||||
const sourceRelease = verifyRelease(sourcePath);
|
||||
const target = targetRelease ? summary(absolute(targetRelease, 'target release'), verifyRelease(targetRelease)) : null;
|
||||
if (!Number.isInteger(waitTimeout) || waitTimeout < 1) fail('Invalid backup wait timeout');
|
||||
const backups = absolute(path.join(root, 'backups'), 'backups');
|
||||
if (contained(data, backups) || contained(sourcePath, backups)) fail('Backup directory overlaps runtime/release');
|
||||
const stateFile = absolute(path.join(root, '.mcpack-runtime.json'), 'runtime binding');
|
||||
if (!fs.existsSync(stateFile)) fail('Backup requires an initialized full-release runtime binding');
|
||||
const perform = lockToken => {
|
||||
const lifecycle = { repository, env, lockToken };
|
||||
checkedLifecycle(lifecycle, ['config', '--quiet']);
|
||||
const before = containers(lifecycle);
|
||||
if (before.length > 1 || before.some(container => container.State.Paused || container.State.Restarting || container.State.Dead)) fail('Backup requires one normally running/stopped server (or no container)');
|
||||
const priorState = before.length ? before[0].State.Running ? 'running' : 'stopped' : 'absent';
|
||||
if (!apply && priorState === 'running' && readSettings(absolute(env.MC_ENV_FILE ?? path.join(root, '.env'), 'MC_ENV_FILE')).MC_EULA !== 'TRUE') fail('Restoring running state requires MC_EULA=TRUE in local settings');
|
||||
const id = `${new Date().toISOString().replace(/[-:.]/g, '')}-${crypto.randomUUID()}`;
|
||||
const operationFile = path.join(root, '.mcpack-backup-operation.json');
|
||||
const operation = { schema_version: 1, id, phase: 'preflight', prior_state: priorState, source_release: sourceRelease.identity, target_release: target?.manifest_sha256 ?? null, stopped_confirmed: false, snapshot: null };
|
||||
const save = phase => {
|
||||
operation.phase = phase;
|
||||
fs.writeFileSync(operationFile + '.tmp', JSON.stringify(operation, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.renameSync(operationFile + '.tmp', operationFile);
|
||||
};
|
||||
let temporary, published, resuming = false;
|
||||
try {
|
||||
save('stopping');
|
||||
checkedLifecycle(lifecycle, ['stop']);
|
||||
if (containers(lifecycle).some(container => container.State.Running || container.State.Restarting || container.State.Paused)) fail('Server did not stop completely');
|
||||
operation.stopped_confirmed = true;
|
||||
const inventory = runtimeInventory(data);
|
||||
fs.mkdirSync(backups, { recursive: true });
|
||||
temporary = fs.mkdtempSync(path.join(backups, `.backup-${id}-`));
|
||||
save('archiving');
|
||||
const archive = path.join(temporary, 'runtime.tar.zst');
|
||||
run('tar', ['--format=pax', '-C', data, '-I', 'zstd', '-cf', archive, '.'], env);
|
||||
assert.deepStrictEqual(runtimeInventory(data), inventory, 'Runtime changed while archiving');
|
||||
fs.cpSync(sourcePath, path.join(temporary, 'release'), { recursive: true });
|
||||
if (verifyRelease(path.join(temporary, 'release')).identity !== sourceRelease.identity) fail('Retained release changed during copy');
|
||||
const metadata = { schema_version: 1, id, created_at: new Date().toISOString(), source_root: root, prior_state: priorState, stopped_confirmed: true, source_release: summary(sourcePath, sourceRelease), target_release: target, runtime_binding: JSON.parse(fs.readFileSync(stateFile, 'utf8')), exclusions: [], archive: { file: 'runtime.tar.zst', sha256: hashFile(archive), size: fs.statSync(archive).size }, runtime_inventory: inventory };
|
||||
fs.writeFileSync(path.join(temporary, 'metadata.json'), JSON.stringify(metadata, null, 2) + '\n');
|
||||
save('verifying');
|
||||
archivePaths(archive);
|
||||
const verification = path.join(temporary, '.verification');
|
||||
extract(archive, verification);
|
||||
assert.deepStrictEqual(runtimeInventory(verification), inventory, 'Extracted snapshot differs from stopped runtime');
|
||||
fs.rmSync(verification, { recursive: true });
|
||||
writeChecksums(temporary);
|
||||
verifySnapshot(temporary);
|
||||
published = path.join(backups, id);
|
||||
if (fs.existsSync(published)) fail('Snapshot ID already exists');
|
||||
fs.renameSync(temporary, published);
|
||||
temporary = undefined;
|
||||
operation.snapshot = published;
|
||||
save('published');
|
||||
if (apply) {
|
||||
save('applying');
|
||||
const result = apply({ snapshot: published, metadata, lockToken });
|
||||
save('complete');
|
||||
return result;
|
||||
}
|
||||
if (priorState === 'running') {
|
||||
resuming = true;
|
||||
save('resuming');
|
||||
checkedLifecycle(lifecycle, ['up', '-d', '--wait', '--wait-timeout', String(waitTimeout)]);
|
||||
operation.stopped_confirmed = false;
|
||||
}
|
||||
save('complete');
|
||||
return published;
|
||||
} catch (error) {
|
||||
if (resuming) {
|
||||
try { checkedLifecycle(lifecycle, ['stop']); operation.stopped_confirmed = !containers(lifecycle).some(container => container.State.Running); }
|
||||
catch { operation.stopped_confirmed = false; }
|
||||
} else if (apply && published) {
|
||||
// The apply callback may have started a server before failing. Never
|
||||
// report the pre-apply stopped state as the current container state.
|
||||
try { operation.stopped_confirmed = !containers(lifecycle).some(container => container.State.Running || container.State.Restarting || container.State.Paused); }
|
||||
catch { operation.stopped_confirmed = false; }
|
||||
}
|
||||
operation.error = error.message;
|
||||
save('failed');
|
||||
throw new Error(`${error.message}. ${published ? `Verified snapshot retained: ${published}. ` : 'No snapshot published. '}${operation.stopped_confirmed ? 'Runtime remains stopped.' : 'Inspect server state before continuing.'}`);
|
||||
} finally { if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); }
|
||||
};
|
||||
return heldToken ? perform(heldToken) : withRuntimeLock(root, perform);
|
||||
}
|
||||
|
||||
export const backup = options => snapshotOperation(options);
|
||||
|
||||
// Future deployment can only enter its apply phase under the same lock after
|
||||
// a stopped, verified snapshot. Failure never starts an older pack automatically.
|
||||
export function backupBeforeApply(options, apply) {
|
||||
if (typeof apply !== 'function' || !options.targetRelease) fail('Backup-before-apply requires a verified target release and apply callback');
|
||||
return snapshotOperation(options, apply);
|
||||
}
|
||||
|
||||
export function restoreSnapshot({ backupDirectory, destination }) {
|
||||
const snapshot = absolute(backupDirectory, 'backup directory');
|
||||
const metadata = verifySnapshot(snapshot);
|
||||
destination = absolute(destination, 'restore destination');
|
||||
if (fs.existsSync(destination)) fail('Restore destination must not exist; never extract over an existing runtime');
|
||||
if (contained(snapshot, destination) || contained(path.join(metadata.source_root, 'runtime'), destination)) fail('Restore destination overlaps snapshot/source runtime');
|
||||
const parent = path.dirname(destination);
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
const lock = path.join(parent, `.restore-${path.basename(destination)}.lock`);
|
||||
try { fs.mkdirSync(lock); } catch { fail('Another restore is running (or a stale restore lock remains)'); }
|
||||
let temporary;
|
||||
try {
|
||||
if (fs.existsSync(destination)) fail('Restore destination appeared during verification');
|
||||
temporary = fs.mkdtempSync(path.join(parent, `.restore-${path.basename(destination)}-`));
|
||||
extract(path.join(snapshot, 'runtime.tar.zst'), path.join(temporary, 'runtime'));
|
||||
assert.deepStrictEqual(runtimeInventory(path.join(temporary, 'runtime')), metadata.runtime_inventory, 'Restored runtime differs from snapshot inventory');
|
||||
fs.cpSync(path.join(snapshot, 'release'), path.join(temporary, 'release'), { recursive: true });
|
||||
if (verifyRelease(path.join(temporary, 'release')).identity !== metadata.source_release.manifest_sha256) fail('Restored release identity mismatch');
|
||||
fs.writeFileSync(path.join(temporary, '.mcpack-runtime.json'), JSON.stringify(metadata.runtime_binding, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.writeFileSync(path.join(temporary, 'restore-report.json'), JSON.stringify({ schema_version: 1, backup_id: metadata.id, restored_at: new Date().toISOString(), source_release: metadata.source_release, runtime_entries_verified: metadata.runtime_inventory.length, host_settings_copied: false }, null, 2) + '\n');
|
||||
if (fs.existsSync(destination)) fail('Restore destination appeared during extraction');
|
||||
fs.renameSync(temporary, destination);
|
||||
temporary = undefined;
|
||||
return destination;
|
||||
} finally {
|
||||
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
|
||||
fs.rmdirSync(lock);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user