first cycle lab release

This commit is contained in:
2026-10-04 00:30:09 +07:00
parent 85f6897365
commit d134daafd8
92 changed files with 5623 additions and 9 deletions
+244
View File
@@ -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);
}
}