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
+25
View File
@@ -0,0 +1,25 @@
# Platform manifest
`config.yaml` читается `mcpack build`: paths и server defaults влияют на релиз.
Имя/версия сборки, Minecraft, loader и моды остаются в TOML packwiz.
Схема config — `schemas/config.schema.json`; CLI проверяет её правила и
дополнительные ограничения путей. YAML/TOML parses выполняются библиотеками
с версиями из `package-lock.json`.
`server.host` и `server.directory` выбирают SSH host и отдельный root сборки.
`deploy.healthcheck_timeout` используется локальным server wrapper при
`up/start/restart --wait` и `health`, если timeout не передан явно.
Target и transport используются командой deploy и не копируются в release
payload. Local build не требует SSH. Контракт — [docs/deploy.md](../docs/deploy.md).
`compatibility.json` фиксирует подтверждённые сочетания Minecraft/loader/image
и Java. Добавлять запись только после реальной integration-проверки.
Это запись доказанной совместимости, а `toolchain.lock.env` определяет
инструменты текущего build. Изменение image требует новой проверки.
`build.mjs` — orchestration вокруг packwiz и Compose. Dependency resolver
и загрузка mod metadata остаются задачей packwiz.
Контракт и manifest описаны в [docs/build.md](../docs/build.md).
`health.mjs` проверяет стабильную готовность Minecraft и сохраняет диагностику;
контракт — [docs/health.md](../docs/health.md).
+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);
}
}
+325
View File
@@ -0,0 +1,325 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { isIP } from 'node:net';
import { execFileSync } from 'node:child_process';
import { parseDocument, stringify as yamlStringify } from 'yaml';
import { parse as tomlParse } from 'smol-toml';
const fail = message => { throw new Error(message); };
const hash = (data, algorithm = 'sha256') => crypto.createHash(algorithm).update(data).digest('hex');
const fileHash = file => hash(fs.readFileSync(file));
const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const requiredString = (value, label) => typeof value === 'string' && value.length > 0 ? value : fail(`${label} must be a nonempty string`);
const secretName = name => (/^\.env(?:\..*)?$/.test(name) && name !== '.env.example') || /^(id_rsa|id_ed25519)$/.test(name);
function run(command, args, { cwd, env = process.env, log = () => {} } = {}) {
try {
const output = execFileSync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
if (output.trim()) log(output.trim());
return output;
} catch (error) {
fail(`${path.basename(command)} failed: ${error.stderr?.toString().trim() || error.message}`);
}
}
function yaml(text, label) {
const doc = parseDocument(text, { uniqueKeys: true });
if (doc.errors.length || doc.warnings.length) fail(`${label}: ${[...doc.errors, ...doc.warnings].map(e => e.message).join('; ')}`);
return doc.toJS({ maxAliasCount: 50 });
}
function keys(value, allowed, label) {
if (!plain(value)) fail(`${label} must be a mapping`);
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`Unknown ${label} key: ${key}`);
for (const key of allowed) if (!(key in value)) fail(`Missing ${label}.${key}`);
}
export function validateConfig(config) {
keys(config, ['schema_version', 'pack', 'build', 'server', 'deploy'], 'config');
if (config.schema_version !== 1) fail('Unsupported config schema_version');
keys(config.pack, ['directory'], 'pack');
keys(config.build, ['output_directory'], 'build');
keys(config.server, ['host', 'directory', 'memory', 'port'], 'server');
keys(config.deploy, ['transport', 'healthcheck_timeout'], 'deploy');
requiredString(config.pack.directory, 'pack.directory');
requiredString(config.build.output_directory, 'build.output_directory');
if (!/^[1-9]\d*[MG]$/.test(config.server.memory)) fail('server.memory must use M or G, for example 2G');
if (!Number.isInteger(config.server.port) || config.server.port < 1 || config.server.port > 65535) fail('Invalid server.port');
for (const key of ['host', 'directory']) if (config.server[key] !== null) requiredString(config.server[key], `server.${key}`);
if ((config.server.host === null) !== (config.server.directory === null)) fail('Set both server.host and server.directory, or leave both null');
if (config.server.directory !== null && !config.server.directory.startsWith('/')) fail('Remote server.directory must be absolute');
if (config.deploy.transport !== 'ssh') fail('Only SSH deploy transport is supported');
if (!Number.isInteger(config.deploy.healthcheck_timeout) || config.deploy.healthcheck_timeout < 1) fail('Invalid healthcheck_timeout');
return config;
}
function safeRelative(value) {
return typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..');
}
function inside(root, value) {
const result = path.resolve(root, requiredString(value, 'path'));
const relative = path.relative(root, result);
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail(`Path must be inside repository: ${value}`);
let current = root;
for (const part of relative.split(path.sep)) {
current = path.join(current, part);
try {
if (fs.lstatSync(current).isSymbolicLink()) fail(`Symlinks are not allowed: ${value}`);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
return result;
}
function files(directory) {
const result = [];
function walk(current) {
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const file = path.join(current, entry.name);
if (secretName(entry.name)) fail(`Known secret filename is not allowed in source: ${file}`);
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) fail(`Only regular files/directories are allowed: ${file}`);
if (entry.isDirectory()) walk(file);
else {
const relative = path.relative(directory, file).split(path.sep).join('/');
if (!safeRelative(relative)) fail(`Invalid file path: ${relative}`);
result.push(relative);
}
}
}
walk(directory);
return result.sort();
}
function readLock(file) {
const result = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const match = /^([A-Z][A-Z0-9_]*)=(\S+)$/.exec(line);
if (!match || match[1] in result) fail('Invalid or duplicate toolchain lock entry');
result[match[1]] = match[2];
}
for (const key of ['PACKWIZ_BINARY_SHA256', 'COMPOSE_SHA256']) if (!/^[a-f0-9]{64}$/.test(result[key])) fail(`Invalid ${key}`);
if (!/^[a-f0-9]{40}$/.test(result.PACKWIZ_COMMIT)) fail('Invalid PACKWIZ_COMMIT');
if (!/^itzg\/minecraft-server@sha256:[a-f0-9]{64}$/.test(result.MC_IMAGE)) fail('MC_IMAGE must be pinned by digest');
requiredString(result.COMPOSE_VERSION, 'COMPOSE_VERSION');
return result;
}
function validatePack(directory, compatibility, image) {
const tree = files(directory);
const pack = tomlParse(fs.readFileSync(path.join(directory, 'pack.toml'), 'utf8'));
for (const key of ['name', 'version']) if (typeof pack[key] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pack[key])) fail(`Invalid pack ${key}`);
if (pack['pack-format'] !== 'packwiz:1.1.0') fail('Unsupported pack format');
const minecraft = requiredString(pack.versions?.minecraft, 'Minecraft version');
const loaders = Object.keys(pack.versions).filter(key => key !== 'minecraft');
if (loaders.length !== 1) fail('Specify exactly one loader');
const loader = loaders[0], loaderVersion = requiredString(pack.versions[loader], 'loader version');
const verified = compatibility.verified?.find(v => v.minecraft === minecraft && v.loader === loader && v.loader_version === loaderVersion && v.image === image);
if (!verified || verified.java_major !== 21) fail('Minecraft/loader/image combination has not been verified by the spike');
if (!safeRelative(pack.index?.file) || pack.index['hash-format'] !== 'sha256') fail('Pack index must have a safe path and SHA256');
const indexFile = path.join(directory, pack.index.file);
if (fileHash(indexFile) !== pack.index.hash) fail('Stale pack index hash: run packwiz refresh explicitly');
const index = tomlParse(fs.readFileSync(indexFile, 'utf8'));
if (index['hash-format'] !== 'sha256' || !Array.isArray(index.files)) fail('Unsupported or missing index files');
const targets = new Set(), modIds = new Set(), indexed = new Set();
for (const entry of index.files) {
if (!safeRelative(entry.file) || indexed.has(entry.file)) fail(`Unsafe or duplicate indexed path: ${entry.file}`);
indexed.add(entry.file);
const file = path.join(directory, entry.file);
if (!tree.includes(entry.file) || fileHash(file) !== entry.hash) fail(`Missing or stale indexed file: ${entry.file}; run packwiz refresh explicitly`);
if (entry.metafile) {
const mod = tomlParse(fs.readFileSync(file, 'utf8'));
if (!safeRelative(mod.filename) || mod.filename.includes('/')) fail(`Invalid mod filename: ${entry.file}`);
if (!['client', 'server', 'both'].includes(mod.side)) fail(`Explicit side metadata required: ${entry.file}`);
const target = path.posix.join(path.posix.dirname(entry.file), mod.filename);
if (targets.has(target)) fail(`Duplicate mod target: ${target}`);
targets.add(target);
const modId = mod.update?.modrinth?.['mod-id'];
if (modId && modIds.has(modId)) fail(`Duplicate Modrinth project: ${modId}`);
if (modId) modIds.add(modId);
const download = mod.download;
let url;
try { url = new URL(download?.url); } catch { fail(`Missing download URL: ${entry.file}`); }
if (url.protocol !== 'https:' || url.username || url.password) fail(`Download URL must be HTTPS without credentials: ${entry.file}`);
const length = { sha512: 128, sha256: 64, sha1: 40, md5: 32 }[download['hash-format']];
if (!length || !new RegExp(`^[a-f0-9]{${length}}$`).test(download.hash)) fail(`Invalid download hash: ${entry.file}`);
}
}
return { pack, minecraft, loader, loaderVersion, verified, tree };
}
function generatedServer(root, release, artifact, config, lock) {
const source = path.join(root, 'server');
const compose = yaml(fs.readFileSync(path.join(source, 'compose.yaml'), 'utf8'), 'server compose');
const service = compose.services?.minecraft;
if (!service || Object.keys(compose.services).length !== 1) fail('Expected one minecraft Compose service');
if (service.environment?.TYPE !== 'MODRINTH' || service.environment?.ONLINE_MODE !== 'false' || service.environment?.ENFORCE_SECURE_PROFILE !== 'false') fail('Compose must use MODRINTH and offline authentication');
service.image = lock.MC_IMAGE;
service.ports = [`\${MC_BIND_ADDRESS:-127.0.0.1}:\${MC_PORT:-${config.server.port}}:25565`];
service.environment.MEMORY = `\${MC_MEMORY:-${config.server.memory}}`;
service.environment.MODRINTH_MODPACK = `/modpacks/${artifact}`;
service.environment.SYNC_SKIP_NEWER_IN_DESTINATION = 'false';
service.volumes = [
{ type: 'bind', source: '${MC_ROOT:?Set absolute server root}/runtime', target: '/data' },
{ type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}', target: '/modpacks', read_only: true },
{ type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}/server/config', target: '/config', read_only: true }
];
const destination = path.join(release, 'server');
fs.mkdirSync(destination);
fs.writeFileSync(path.join(destination, 'compose.yaml'), yamlStringify(compose));
// Allowlist local settings; never copy server/.env or deployment target.
const example = readLockExample(path.join(source, '.env.example'));
example.MC_EULA = 'FALSE';
example.MC_MEMORY = config.server.memory;
example.MC_PORT = String(config.server.port);
fs.writeFileSync(path.join(destination, '.env.example'), '# Accept Minecraft EULA before setting MC_EULA=TRUE.\n' + Object.entries(example).map(([key, value]) => `${key}=${value}\n`).join(''));
fs.cpSync(path.join(source, 'config'), path.join(destination, 'config'), { recursive: true });
fs.writeFileSync(path.join(destination, 'README.md'), `# Immutable server release\n\nCopy server/.env.example to a local settings file outside this release.\nAccept the Minecraft EULA before setting MC_EULA=TRUE.\n\nSet MC_ROOT to an absolute server root and MC_RELEASE_DIR to this release.\nUse the same Compose project name for all releases:\n\n\`\`\`bash\ndocker compose --project-name minecraft-lab-spike --env-file /absolute/local-settings.env -f "$MC_RELEASE_DIR/server/compose.yaml" config --quiet\n\`\`\`\n\nThis release uses offline authentication. MC_BIND_ADDRESS selects the IPv4 interface; default is 127.0.0.1, use 0.0.0.0 for remote LAN access.\nserver/config overrides corresponding pack settings at startup.\nRuntime-generated server.properties is configured through Compose environment.\n`);
return compose;
}
function readLockExample(file) {
const result = {};
const allowed = ['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_BIND_ADDRESS'];
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const match = /^([A-Z_]+)=([A-Za-z0-9.]+)$/.exec(line);
if (!match || !allowed.includes(match[1]) || match[1] in result) fail('Unexpected setting in server/.env.example');
result[match[1]] = match[2];
}
if (result.MC_BIND_ADDRESS && isIP(result.MC_BIND_ADDRESS) !== 4) fail('Invalid MC_BIND_ADDRESS in env example');
return result;
}
function managedFiles(extracted, index, release) {
const sides = { client: new Map(), server: new Map() };
for (const file of index.files ?? []) {
if (!safeRelative(file.path) || !plain(file.hashes) || !file.hashes.sha512 || !file.hashes.sha1) fail('Invalid exported mod metadata');
for (const side of ['client', 'server']) if (file.env?.[side] !== 'unsupported') {
if (sides[side].has(file.path)) fail(`Duplicate exported target: ${file.path}`);
sides[side].set(file.path, { path: file.path, owner: 'pack', policy: 'replace', hashes: file.hashes });
}
}
for (const prefix of ['overrides', 'client-overrides', 'server-overrides']) {
const directory = path.join(extracted, prefix);
if (!fs.existsSync(directory)) continue;
for (const file of files(directory)) for (const side of ['client', 'server']) {
if (prefix !== 'overrides' && prefix !== `${side}-overrides`) continue;
sides[side].set(file, { path: file, owner: 'pack', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) } });
}
}
const directory = path.join(release, 'server/config');
for (const file of files(directory)) {
const target = `config/${file}`;
const previous = sides.server.get(target);
sides.server.set(target, { path: target, owner: 'server', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) }, ...(previous ? { overrides: previous.owner } : {}) });
}
return Object.fromEntries(Object.entries(sides).map(([side, entries]) => [side, [...entries.values()].sort((a, b) => a.path.localeCompare(b.path))]));
}
export function packwizContext({ root, configFile = 'platform/config.yaml' }) {
root = fs.realpathSync(root);
const config = validateConfig(yaml(fs.readFileSync(inside(root, configFile), 'utf8'), 'platform config'));
if (path.isAbsolute(config.pack.directory)) fail('Pack directory must be relative to repository');
const source = inside(root, config.pack.directory);
inside(root, path.join(config.pack.directory, 'pack.toml'));
if (!fs.statSync(path.join(source, 'pack.toml')).isFile()) fail('pack.toml must be a regular file');
const lock = readLock(inside(root, 'toolchain.lock.env'));
const executable = path.join(root, '.tools/packwiz/packwiz');
if (!fs.existsSync(executable)) fail('Tools missing: run scripts/spike-tools.sh');
if (fileHash(executable) !== lock.PACKWIZ_BINARY_SHA256) fail('Pinned packwiz checksum mismatch');
return { source, executable, args: ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')] };
}
export function build({ root, configFile = 'platform/config.yaml', releaseOnly = false, outputDirectory, log = console.log }) {
root = fs.realpathSync(root);
const configuration = inside(root, configFile);
const config = validateConfig(yaml(fs.readFileSync(configuration, 'utf8'), 'platform config'));
if (path.isAbsolute(config.pack.directory) || path.isAbsolute(config.build.output_directory)) fail('Pack/output directories must be relative to repository');
const source = inside(root, config.pack.directory), output = inside(root, outputDirectory ?? config.build.output_directory);
// Build may never write into source, runtime, tooling, or a parent thereof.
const protectedPaths = [source, ...['server', 'platform', 'scripts', 'runtime', '.tools', '.git', 'node_modules', 'docs', 'examples', 'testdata'].map(v => path.join(root, v))];
for (const protectedPath of protectedPaths) if (output === protectedPath || output.startsWith(protectedPath + path.sep) || protectedPath.startsWith(output + path.sep)) fail('Output overlaps a protected/source directory');
for (const value of ['server/compose.yaml', 'server/.env.example', 'server/config', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json']) inside(root, value);
const lock = readLock(path.join(root, 'toolchain.lock.env'));
const compatibility = JSON.parse(fs.readFileSync(path.join(root, 'platform/compatibility.json'), 'utf8'));
const validated = validatePack(source, compatibility, lock.MC_IMAGE);
const { pack, minecraft, loader, loaderVersion, verified } = validated;
const toolFiles = { packwiz: path.join(root, '.tools/packwiz/packwiz'), compose: path.join(root, '.tools/docker-compose') };
for (const [tool, file] of Object.entries(toolFiles)) {
if (!fs.existsSync(file)) fail('Tools missing: run scripts/spike-tools.sh');
if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} checksum mismatch`);
}
const gitCommit = run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim();
const tracked = run('git', ['ls-files', '-z'], { cwd: root }).split('\0').filter(Boolean);
const outputRelative = path.relative(root, output).split(path.sep).join('/');
if (tracked.some(file => file !== `${outputRelative}/.gitkeep` && (file === outputRelative || file.startsWith(outputRelative + '/')))) fail('Output directory contains tracked files');
const statusArgs = ['status', '--porcelain', '--untracked-files=all', '--', '.', `:(exclude)${outputRelative}`];
const gitStatus = run('git', statusArgs, { cwd: root });
const gitDirty = gitStatus.length > 0;
if (tracked.some(file => file.split('/').some(secretName))) fail('Known secret path is tracked by Git');
if (releaseOnly && gitDirty) fail('--release requires a clean Git worktree');
const releaseName = `${pack.name}-${pack.version}`, artifact = `${releaseName}.mrpack`, destination = path.join(output, releaseName);
if (fs.existsSync(destination)) fail(`Release already exists: ${destination}; choose a new pack version or output directory`);
fs.mkdirSync(output, { recursive: true });
const lockDirectory = path.join(output, `.build-${releaseName}.lock`);
try { fs.mkdirSync(lockDirectory); } catch { fail(`Build is already locked: ${lockDirectory}`); }
let temporary;
try {
temporary = fs.mkdtempSync(path.join(output, `.build-${releaseName}-`));
const copied = path.join(temporary, 'source'), release = path.join(temporary, 'release');
fs.cpSync(source, copied, { recursive: true });
fs.mkdirSync(release);
const inputFiles = [...validated.tree.map(file => path.join(source, file)), ...['server/compose.yaml', 'server/.env.example', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json', 'package.json', 'platform/build.mjs', 'platform/commands.mjs', 'platform/runtime.mjs', 'platform/backups.mjs', 'platform/health.mjs', 'platform/deployment.mjs', 'platform/ssh-deploy.mjs', 'platform/rollback.mjs', 'scripts/deploy-worker.mjs', 'scripts/mcpack.mjs'].map(file => path.join(root, file)), ...files(path.join(root, 'server/config')).map(file => path.join(root, 'server/config', file)), configuration];
const inputs = [...new Set(inputFiles)].sort().map(file => ({ path: path.relative(root, file).split(path.sep).join('/'), sha256: fileHash(file) }));
const copiedHashes = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
for (const [file, sha256] of copiedHashes) if (sha256 !== fileHash(path.join(source, file))) fail(`Source changed while copying: ${file}`);
const packwizArgs = ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')];
run(toolFiles.packwiz, [...packwizArgs, 'refresh'], { cwd: copied, log });
const refreshed = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
if (JSON.stringify(copiedHashes) !== JSON.stringify(refreshed)) fail('Stale source: run packwiz refresh explicitly; build never changes source');
run(toolFiles.packwiz, [...packwizArgs, 'modrinth', 'export', '--output', path.join(release, artifact)], { cwd: copied, log });
const exportedTree = files(copied).map(file => [file, fileHash(path.join(copied, file))]);
if (JSON.stringify(copiedHashes) !== JSON.stringify(exportedTree)) fail('Packwiz export changed source metadata');
const archive = path.join(release, artifact), extracted = path.join(temporary, 'extracted');
const zipPaths = run('bsdtar', ['-tf', archive]).trim().split('\n').filter(Boolean);
for (const file of zipPaths) if (!safeRelative(file.replace(/\/$/, ''))) fail(`Unsafe archive path: ${file}`);
fs.mkdirSync(extracted);
run('bsdtar', ['--no-same-owner', '-xf', archive, '-C', extracted]);
files(extracted);
const exported = JSON.parse(fs.readFileSync(path.join(extracted, 'modrinth.index.json'), 'utf8'));
if (exported.name !== pack.name || exported.versionId !== pack.version || exported.dependencies?.minecraft !== minecraft || exported.dependencies?.[loader] !== loaderVersion || Object.keys(exported.dependencies).length !== 2) fail('Export metadata differs from source pack');
generatedServer(root, release, artifact, config, lock);
const composeEnv = { PATH: process.env.PATH, HOME: process.env.HOME, MC_ROOT: root, MC_RELEASE_DIR: release };
run(toolFiles.compose, ['--project-name', 'minecraft-lab-spike', '--env-file', path.join(release, 'server/.env.example'), '-f', path.join(release, 'server/compose.yaml'), 'config', '--quiet'], { env: composeEnv });
const managed = managedFiles(extracted, exported, release);
const manifest = {
schema_version: 1,
pack: { name: pack.name, version: pack.version, minecraft, loader: { id: loader, version: loaderVersion } },
artifact,
git: { commit: gitCommit, dirty: gitDirty },
tools: { packwiz: { commit: lock.PACKWIZ_COMMIT, sha256: lock.PACKWIZ_BINARY_SHA256 }, compose: { version: lock.COMPOSE_VERSION, sha256: lock.COMPOSE_SHA256 }, node: process.version, npm_lock_sha256: fileHash(path.join(root, 'package-lock.json')) },
server: { image: lock.MC_IMAGE, java_major: verified.java_major, memory: config.server.memory, port: config.server.port, compose_project: 'minecraft-lab-spike' },
source_files: inputs,
managed_files: managed,
runtime_policy: { unmanaged: 'preserve', defaultconfigs: 'seed-only' },
release_files: files(release).map(file => ({ path: file, sha256: fileHash(path.join(release, file)) }))
};
fs.writeFileSync(path.join(release, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
const checksums = files(release).map(file => `${fileHash(path.join(release, file))} ${file}\n`).join('');
fs.writeFileSync(path.join(release, 'SHA256SUMS'), checksums);
run('sha256sum', ['-c', 'SHA256SUMS'], { cwd: release, log });
for (const input of inputs) if (fileHash(path.join(root, input.path)) !== input.sha256) fail(`Source changed during build: ${input.path}`);
for (const [tool, file] of Object.entries(toolFiles)) if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} changed during build`);
if (run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim() !== gitCommit || run('git', statusArgs, { cwd: root }) !== gitStatus) fail('Git state changed during build');
if (fs.existsSync(destination)) fail('Release destination appeared during build');
fs.renameSync(release, destination);
return destination;
} finally {
if (temporary) fs.rmSync(temporary, { recursive: true, force: true });
fs.rmdirSync(lockDirectory);
}
}
+103
View File
@@ -0,0 +1,103 @@
import { spawnSync } from 'node:child_process';
import { packwizContext } from './build.mjs';
export const usage = `Usage:
./mcpack build [--config FILE] [--release]
./mcpack release VERSION [--config FILE]
./mcpack add <modrinth-slug|ID|URL|search> [--yes] [--config FILE]
./mcpack remove <mod> [--yes] [--config FILE]
./mcpack update <mod> [--yes] [--config FILE]
./mcpack update --all [--yes] [--config FILE]
./mcpack deploy [--artifact RELEASE_DIR] [--config FILE] [--ssh-config FILE] [--remote-node FILE]
./mcpack rollback [--backup ID --accept-progress-loss] [--config FILE] [--ssh-config FILE] [--remote-node FILE]
./mcpack rollback-pack VERSION [--confirm-compatible] [--config FILE] [--ssh-config FILE] [--remote-node FILE]
./mcpack backup [--target-release DIR] [--wait-timeout SECONDS]
./mcpack restore --backup SNAPSHOT_DIR --destination NEW_SERVER_ROOT
Mod commands edit packwiz source and retain packwiz prompts/output.
Development builds may be dirty; --release requires a clean Git worktree.`;
export function parseArgs(args) {
args = [...args];
const command = args.shift();
if (!['build', 'release', 'add', 'remove', 'update', 'backup', 'restore', 'deploy', 'rollback', 'rollback-pack'].includes(command)) throw new Error(usage);
const options = { command, configFile: 'platform/config.yaml', releaseOnly: false, yes: false, all: false };
const seen = new Set();
while (args.length) {
const argument = args.shift();
const option = argument === '-y' ? '--yes' : argument;
if (['--config', '--release', '--yes', '--all', '--target-release', '--wait-timeout', '--backup', '--destination', '--artifact', '--ssh-config', '--remote-node', '--confirm-compatible', '--accept-progress-loss'].includes(option)) {
if (seen.has(option)) throw new Error(`Repeated argument: ${option}`);
seen.add(option);
if (['--config', '--target-release', '--wait-timeout', '--backup', '--destination', '--artifact', '--ssh-config', '--remote-node'].includes(option)) {
const value = args.shift();
if (!value || value.startsWith('-') || /[\0\r\n]/.test(value)) throw new Error(`Missing or invalid value: ${option}`);
options[{'--config':'configFile','--target-release':'targetRelease','--wait-timeout':'waitTimeout','--backup':'backupDirectory','--destination':'destination','--artifact':'artifact','--ssh-config':'sshConfig','--remote-node':'remoteNode'}[option]] = value;
} else options[option === '--release' ? 'releaseOnly' : option.slice(2)] = true;
} else {
if (argument.startsWith('-') || options.mod || /[\0\r\n]/.test(argument)) throw new Error(`Unexpected argument: ${argument}`);
options.mod = argument;
}
}
if (command === 'rollback' || command === 'rollback-pack') {
if (options.yes || options.all || options.releaseOnly || options.artifact || options.targetRelease || options.waitTimeout || options.destination) throw new Error('Invalid rollback arguments');
if (command === 'rollback-pack') {
if (!options.mod || options.backupDirectory || options['accept-progress-loss']) throw new Error('rollback-pack requires a version and optional --confirm-compatible');
options.version = options.mod;
options.confirmCompatible = Boolean(options['confirm-compatible']);
} else {
if (options.mod || options['confirm-compatible'] || (options['accept-progress-loss'] && !options.backupDirectory)) throw new Error('rollback accepts only an optional snapshot ID and progress-loss acknowledgment');
options.backupId = options.backupDirectory;
options.acceptProgressLoss = Boolean(options['accept-progress-loss']);
}
return options;
}
if (options['confirm-compatible'] || options['accept-progress-loss']) throw new Error('Confirmation option is only for rollback');
if (command === 'deploy') {
if (options.mod || options.yes || options.all || options.releaseOnly || options.targetRelease || options.waitTimeout || options.backupDirectory || options.destination) throw new Error('deploy accepts only --artifact and --config');
return options;
}
if (options.artifact || options.sshConfig || options.remoteNode) throw new Error('--artifact is only valid for deploy');
const backupOptions = ['--target-release', '--wait-timeout', '--backup', '--destination'];
if (command === 'backup' || command === 'restore') {
if (options.mod || options.yes || options.all || options.releaseOnly || seen.has('--config')) throw new Error('Invalid backup/restore arguments');
if (command === 'backup') {
if (options.backupDirectory || options.destination) throw new Error('backup accepts only --target-release and --wait-timeout');
if (options.waitTimeout && (!/^[1-9]\d*$/.test(options.waitTimeout) || !Number.isSafeInteger(Number(options.waitTimeout)))) throw new Error('Invalid --wait-timeout');
options.waitTimeout = Number(options.waitTimeout ?? 120);
} else if (!options.backupDirectory || !options.destination || options.targetRelease || options.waitTimeout) throw new Error('restore requires --backup and --destination');
return options;
}
if (backupOptions.some(option => seen.has(option))) throw new Error('Backup/restore option used with another command');
if(command==='release'){
if(!options.mod||options.yes||options.all||options.releaseOnly)throw new Error('release requires VERSION and accepts only --config');
options.version=options.mod;return options;
}
if (command === 'build') {
if (options.mod || options.yes || options.all) throw new Error('build accepts only --config and --release');
} else {
if (options.releaseOnly) throw new Error('--release is only valid for build');
if (command !== 'update' && options.all) throw new Error('--all is only valid for update');
if (options.all && options.mod) throw new Error('Choose update <mod> or update --all');
if (!options.all && !options.mod) throw new Error(`${command} requires a mod`);
}
return options;
}
export function packwizArguments(options) {
const arguments_ = options.yes ? ['--yes'] : [];
if (options.command === 'add') {
arguments_.push('modrinth', 'add', options.mod);
} else if (options.command === 'remove') arguments_.push('remove', options.mod);
else if (options.command === 'update') arguments_.push('update', ...(options.all ? ['--all'] : [options.mod]));
else throw new Error('Expected a mod command');
return arguments_;
}
export function manageMod({ root, ...options }) {
const context = packwizContext({ root, configFile: options.configFile });
const result = spawnSync(context.executable, [...context.args, ...packwizArguments(options)], { cwd: context.source, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.signal) throw new Error(`packwiz terminated by ${result.signal}`);
return result.status ?? 1;
}
+13
View File
@@ -0,0 +1,13 @@
{
"schema_version": 1,
"verified": [
{
"minecraft": "1.21.1",
"loader": "neoforge",
"loader_version": "21.1.252",
"java_major": 21,
"image": "itzg/minecraft-server@sha256:99dc8b37f97c3b95915452180bbedf29a7ac8252932e372ce6e5b6e383a5d1e2",
"evidence": "docs/spike-results.md"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
schema_version: 1
pack:
directory: ./pack
build:
output_directory: ./dist
server:
host: crab@192.168.1.61
directory: /home/crab/workspace/minecraft-lab/minecraft-lab-neoforge-spike
memory: 2G
port: 25565
deploy:
transport: ssh
healthcheck_timeout: 120
+183
View File
@@ -0,0 +1,183 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { absolute, verifyRelease, runtime, readSettings, withRuntimeLock } from './runtime.mjs';
import { backupBeforeApply } from './backups.mjs';
const fail = message => { throw new Error(message); };
export const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const safe = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value) && !['.','..'].includes(value);
export function releaseName(manifest) {
if (!safe(manifest.pack?.name) || !safe(manifest.pack?.version)) fail('Unsafe release name/version');
return `${manifest.pack.name}-${manifest.pack.version}`;
}
export function deployableRelease(directory) {
const release = verifyRelease(directory);
if (release.manifest.git?.dirty !== false || !/^[a-f0-9]{40,64}$/.test(release.manifest.git?.commit)) fail('Deploy requires a release built from a clean Git worktree');
releaseName(release.manifest);
return release;
}
export function atomicJson(file, value) {
absolute(file, 'state path');
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
fs.renameSync(temporary, file);
}
export function pointer(root, name) {
const file = absolute(path.join(root, name), name);
if (!fs.existsSync(file)) return null;
const value = fs.readFileSync(file,'utf8').trim();
if (!safe(value)) fail(`Invalid ${name}`);
return value;
}
export function writePointer(root, name, value) {
const file = absolute(path.join(root,name), name), temp = `${file}.${crypto.randomUUID()}.tmp`;
fs.writeFileSync(temp, value+'\n', { flag:'wx',mode:0o600 });
fs.renameSync(temp,file);
}
export function managed(release) {
const files = release.manifest.managed_files?.server;
if (!Array.isArray(files)) fail('Release lacks managed server inventory');
const entries = new Map();
for (const file of files) {
if (typeof file.path !== 'string' || !file.path || /[\\\r\n\0]/.test(file.path) || file.path.split('/').some(p=>!p||p==='.'||p==='..') || file.path.startsWith('/') || entries.has(file.path) || !['replace','seed-only','preserve'].includes(file.policy) || !file.hashes || !Object.keys(file.hashes).length) fail('Invalid managed runtime inventory');
for (const [algorithm,hash] of Object.entries(file.hashes)) if (!['sha1','sha256','sha512'].includes(algorithm) || !/^[a-f0-9]+$/.test(hash)) fail('Invalid managed runtime hash');
entries.set(file.path,file);
}
return entries;
}
export function configConflicts(root, source, target, sourceDirectory) {
const old = source ? managed(source) : new Map(), next = managed(target), conflicts=[];
for (const [relative, file] of old) {
if (file.policy !== 'replace') continue;
const actual = absolute(path.join(root,'runtime',relative), 'managed runtime path');
if (!fs.existsSync(actual) || !fs.lstatSync(actual).isFile()) { conflicts.push({path:relative,reason:'managed-file-missing'}); continue; }
const bytes=fs.readFileSync(actual);
const replacement=next.get(relative);
// A new clean release may deliberately incorporate the exact manual edit.
// Allow that reviewed content; do not allow an unrelated overwrite/removal.
if(replacement?.policy==='replace'&&Object.entries(replacement.hashes).every(([algorithm,expected])=>crypto.createHash(algorithm).update(bytes).digest('hex')===expected))continue;
for(const [algorithm,expected] of Object.entries(file.hashes)) {
const found=crypto.createHash(algorithm).update(bytes).digest('hex');
if(found!==expected){
const conflict={path:relative,reason:'managed-file-edited',algorithm,expected,actual:found};
const original=sourceDirectory&&file.owner==='server'?path.join(sourceDirectory,'server',relative):null;
if(original&&fs.existsSync(original)&&bytes.length<=16384&&fs.statSync(original).size<=16384){
const before=fs.readFileSync(original,'utf8'),after=bytes.toString('utf8');
if(!before.includes('\0')&&!after.includes('\0'))conflict.diff=`--- release/${relative}\n+++ runtime/${relative}\n`+before.split('\n').map(line=>'-'+line).join('\n')+'\n'+after.split('\n').map(line=>'+'+line).join('\n');
}
conflicts.push(conflict);break;
}
}
}
for (const [relative,file] of next) if(file.policy==='replace'&&!old.has(relative)&&fs.existsSync(absolute(path.join(root,'runtime',relative),'managed runtime path')))conflicts.push({path:relative,reason:'new-managed-path-already-exists'});
return conflicts;
}
export function space(root, bytes) {
const stats = fs.statfsSync(root,{bigint:true}), available=stats.bavail*stats.bsize;
if(available<BigInt(bytes))fail(`Insufficient free space: need at least ${bytes} bytes, have ${available}`);
}
export function runtimeSize(root) {
const directory=path.join(root,'runtime');
let sum=0;
const walk=file=>{const stat=fs.lstatSync(file);if(stat.isSymbolicLink()||(!stat.isFile()&&!stat.isDirectory()))fail('Runtime contains unsupported links/special files');if(stat.isFile())sum+=stat.size;else for(const child of fs.readdirSync(file))walk(path.join(file,child));};
if(fs.existsSync(directory))walk(directory);
return sum;
}
// receive(stage) runs while the same host lock is held. It must finish upload
// before resolving. No stop or data mutation occurs during transmission.
export async function deployOnHost({ repository, root, targetName, targetIdentity, targetChecksums, releaseBytes, timeout=120, receive, env=process.env, execute=runtime }) {
root=absolute(root,'server root');
if(!safe(targetName)||!/^[a-f0-9]{64}$/.test(targetIdentity)||!/^[a-f0-9]{64}$/.test(targetChecksums)||!Number.isSafeInteger(releaseBytes)||releaseBytes<1||!Number.isSafeInteger(timeout)||timeout<=10)fail('Invalid deploy request');
if(!fs.statSync(root).isDirectory())fail('Prepare server root before deploying');
const settings=readSettings(absolute(path.join(root,'.env'),'host settings'));
if(settings.MC_EULA!=='TRUE')fail('Prepare local host EULA acceptance before deploy');
fs.accessSync(root,fs.constants.W_OK);
return await withRuntimeLock(root,async lockToken=>{
const rollbackState=absolute(path.join(root,'rollback-state.json'),'rollback state');
if(fs.existsSync(rollbackState)&&JSON.parse(fs.readFileSync(rollbackState)).phase!=='complete')fail('Unresolved rollback state; inspect rollback-state.json before deployment');
const stateFile=absolute(path.join(root,'deployment-state.json'),'deployment state');
if(fs.existsSync(stateFile)&&JSON.parse(fs.readFileSync(stateFile)).phase!=='complete')fail('Unresolved deployment state; inspect deployment-state.json and recover before a new operation');
const releases=absolute(path.join(root,'releases'),'releases');
fs.mkdirSync(releases,{recursive:true});
const active=pointer(root,'active-release'), previous=pointer(root,'previous-release');
if(!active&&previous)fail('Previous pointer exists without active release');
const sourcePath=active?path.join(releases,active):null;
const source=sourcePath?deployableRelease(sourcePath):null;
const bindingFile=absolute(path.join(root,'.mcpack-runtime.json'),'runtime binding');
if(source){
if(releaseName(source.manifest)!==active)fail('Active directory/name mismatch');
const binding=JSON.parse(fs.readFileSync(bindingFile));
if(binding.release_manifest_sha256!==source.identity)fail('Applied runtime differs from active release; manual recovery required');
}else if(fs.existsSync(bindingFile)||(fs.existsSync(path.join(root,'runtime'))&&fs.readdirSync(path.join(root,'runtime')).some(name=>name!=='.gitkeep')))fail('First deploy requires an empty unbound runtime');
const project=settings.MC_PROJECT_NAME??source?.manifest.server.compose_project;
const lifecycle=(release,args)=>execute({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:release,MC_ENV_FILE:path.join(root,'.env'),...(project?{MC_PROJECT_NAME:project}:{})},args,lockToken});
if(source && lifecycle(sourcePath,['config','--quiet'])!==0)fail('Source Compose validation failed');
const final=absolute(path.join(releases,targetName),'target release');
let target=fs.existsSync(final)?deployableRelease(final):null;
if(target&&(target.identity!==targetIdentity||sha256(path.join(final,'SHA256SUMS'))!==targetChecksums))fail('Same release version has different contents; immutable release collision');
const state={schema_version:1,id:crypto.randomUUID(),created_at:new Date().toISOString(),phase:'preflight',source_release:active,target_release:targetName,source_manifest_sha256:source?.identity??null,target_manifest_sha256:targetIdentity,target_checksums_sha256:targetChecksums,backup_snapshot:null,first_deploy:!source,applied:false,pointers_before:{active,previous}};
const save=phase=>{state.phase=phase;state.updated_at=new Date().toISOString();atomicJson(stateFile,state);};
let staging;
try{
save('preflight');
space(root,releaseBytes*2+runtimeSize(root)*3+64*1024*1024);
if(!target){
staging=fs.mkdtempSync(path.join(releases,'.incoming-'));
state.staging_directory=staging;
save('uploading');
await receive(staging);
target=deployableRelease(staging);
if(target.identity!==targetIdentity||releaseName(target.manifest)!==targetName||sha256(path.join(staging,'SHA256SUMS'))!==targetChecksums)fail('Transferred release differs from requested identity');
save('publishing');
// mkdtemp is private; the container UID must traverse the mounted release.
fs.chmodSync(staging,0o755);
fs.renameSync(staging,final);staging=null;
}
const conflicts=configConflicts(root,source,target,sourcePath);
if(conflicts.length){state.conflicts=conflicts;fail(`Managed config conflicts: ${JSON.stringify(conflicts)}. Incorporate edits into source; runtime was not replaced`);}
// Validate target Compose before stopping, without changing existing binding.
const validationRoot=fs.mkdtempSync(path.join(root,'.validate-'));
try{if(execute({repository,env:{...env,MC_ROOT:validationRoot,MC_RELEASE_DIR:final,MC_ENV_FILE:path.join(root,'.env'),...(project?{MC_PROJECT_NAME:project}:{})},args:['config','--quiet']})!==0)fail('Target Compose validation failed');}
finally{fs.rmdirSync(validationRoot);}
if(source?.identity===target.identity){
save('checking-current');
if(lifecycle(sourcePath,['health','--wait-timeout',String(timeout)])!==0)fail('Already active release is unhealthy; no automatic restart');
state.noop=true;save('complete');return state;
}
const apply=({snapshot}={})=>{
state.backup_snapshot=snapshot??null;save('applying');
// Recheck after stop/backup, immediately before replacing managed files.
const stoppedConflicts=configConflicts(root,source,target,sourcePath);
if(stoppedConflicts.length){state.conflicts=stoppedConflicts;fail(`Managed configs changed during deployment: ${JSON.stringify(stoppedConflicts)}`);}
state.applied=true;save('applying');
if(source){
const next=managed(target);
for(const [relative,file]of managed(source))if(file.policy==='replace'&&!next.has(relative))fs.unlinkSync(absolute(path.join(root,'runtime',relative),'removed managed path'));
atomicJson(bindingFile,{schema_version:1,project,release_manifest_sha256:target.identity});
}
save('starting');
if(lifecycle(final,['up','-d','--wait','--wait-timeout',String(timeout)])!==0)fail('Target startup/health failed; inspect diagnostics');
save('committing');
if(active)writePointer(root,'previous-release',active);
writePointer(root,'active-release',targetName);
save('complete');return state;
};
if(source){
save('backing-up');
return backupBeforeApply({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:sourcePath,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},targetRelease:final,waitTimeout:timeout,lockToken},apply);
}
return apply();
}catch(error){
if(state.applied){
try{state.stop_exit=lifecycle(final,['stop']);state.stopped_confirmed=state.stop_exit===0;}catch(stopError){state.stop_error=stopError.message;}
}
// The backup status preserves a published snapshot even if failure occurred
// before the apply callback wrote the deployment record.
if(!state.backup_snapshot && state.phase==='backing-up')try{state.backup_snapshot=JSON.parse(fs.readFileSync(path.join(root,'.mcpack-backup-operation.json'))).snapshot;}catch{}
state.error=error.message;state.recovery=state.applied?'Restore snapshot into a separate root with its retained source release; do not start old pack on this runtime':'Runtime was not applied; inspect backup/server state before resuming source release';
save('failed');throw new Error(`${error.message}. State: ${stateFile}. ${state.recovery}`);
}finally{if(staging)fs.rmSync(staging,{recursive:true,force:true});}
});
}
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { performance } from 'node:perf_hooks';
const sleep = milliseconds => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
// Synchronous so the lifecycle lock remains held through startup/readiness.
// Adapters also allow deterministic testing without real time or Docker.
export function waitForHealth({ inspect, probe, timeoutSeconds = 120, stableSeconds = 10, now = () => performance.now(), pause = sleep, startedAtMs }) {
if (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || !Number.isSafeInteger(stableSeconds) || stableSeconds < 1 || stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed positive stability window');
const started = startedAtMs ?? now(), deadline = started + timeoutSeconds * 1000;
let identity, restarts, startedAt, healthySince, lastState = null, lastProbe = null;
const result = (ok, reason) => ({ ok, reason, elapsed_ms: Math.round(now() - started), timeout_seconds: timeoutSeconds, stable_seconds: stableSeconds, state: lastState, probe: lastProbe });
const budget = () => Math.max(1, Math.ceil(deadline - now()));
try {
while (now() < deadline) {
const container = inspect(budget());
if (!container) return result(false, 'missing-container');
lastState = { id: container.Id, status: container.State?.Status, running: container.State?.Running, restarting: container.State?.Restarting, paused: container.State?.Paused, dead: container.State?.Dead, exit_code: container.State?.ExitCode, oom_killed: container.State?.OOMKilled, error: container.State?.Error, restart_count: container.RestartCount, started_at: container.State?.StartedAt, health: container.State?.Health?.Status };
if (identity === undefined) { identity = container.Id; restarts = container.RestartCount; startedAt = container.State?.StartedAt; }
if (!identity || !Number.isSafeInteger(restarts) || restarts < 0) return result(false, 'invalid-container-state');
if (identity !== container.Id) return result(false, 'container-replaced');
if (container.State?.Restarting || container.RestartCount !== restarts || container.State?.StartedAt !== startedAt) return result(false, 'restart-loop');
if (!container.State?.Running || container.State?.Dead || ['exited', 'dead', 'removing'].includes(container.State?.Status)) return result(false, 'crash');
if (container.State?.Paused) return result(false, 'paused');
if (!container.State?.Health) return result(false, 'missing-healthcheck');
if (now() >= deadline) break;
if (container.State.Health.Status === 'healthy') {
lastProbe = probe(container.Id, budget());
if (now() >= deadline) break;
if (lastProbe.status === 0) {
healthySince ??= now();
if (now() - healthySince >= stableSeconds * 1000) return result(true, 'healthy');
} else healthySince = undefined;
} else healthySince = undefined;
pause(Math.min(1000, Math.max(0, deadline - now())));
}
return result(false, 'timeout');
} catch (error) {
lastProbe = { ...lastProbe, error: error.message };
return result(false, now() >= deadline ? 'timeout' : 'inspection-error');
}
}
export function saveHealthReport({ root, project, releaseIdentity, result, logs }) {
const directory = path.join(root, 'diagnostics');
if (fs.existsSync(directory) && (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink())) throw new Error('Unsafe diagnostics directory');
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const id = `${new Date().toISOString().replace(/[-:.]/g, '')}-${crypto.randomUUID()}`;
const reportPath = path.join(directory, `health-${id}.json`), logsPath = path.join(directory, `health-${id}.log`);
let backup = null;
try { backup = JSON.parse(fs.readFileSync(path.join(root, '.mcpack-backup-operation.json'), 'utf8')).snapshot ?? null; } catch { /* No prior backup. */ }
const report = { schema_version: 1, created_at: new Date().toISOString(), project, release_manifest_sha256: releaseIdentity, ...result, recovery: { runtime_preserved: true, release_binding_preserved: true, active_release_changed: false, automatic_rollback: false, backup_snapshot: backup }, logs: logsPath };
fs.writeFileSync(logsPath, logs, { mode: 0o600, flag: 'wx' });
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
return { reportPath, logsPath };
}
export function healthOptions(args, defaultTimeout = 120) {
args = args.flatMap(item => {
const match = /^(--wait-timeout|--stable-seconds)=(.*)$/.exec(item);
if (item.startsWith('--wait=')) throw new Error('Use --wait without a value');
return match ? [match[1], match[2]] : [item];
});
const command = args[0], wait = command === 'health' || args.includes('--wait');
let timeoutSeconds = defaultTimeout, stableSeconds = 10;
const seen = new Set(), forwarded = [command === 'health' ? 'ps' : command];
for (let index = 1; index < args.length; index++) {
const item = args[index];
if (['--wait', '--wait-timeout', '--stable-seconds'].includes(item)) {
if (seen.has(item)) throw new Error(`Repeated health argument: ${item}`);
seen.add(item);
if (item === '--wait') continue;
const value = args[++index];
if (!/^[1-9]\d*$/.test(value ?? '') || !Number.isSafeInteger(Number(value))) throw new Error(`Invalid ${item}`);
if (item === '--wait-timeout') timeoutSeconds = Number(value); else stableSeconds = Number(value);
} else {
if (command === 'health') throw new Error(`Unexpected health argument: ${item}`);
forwarded.push(item);
}
}
if (seen.size && !wait) throw new Error('Health options require --wait');
if (wait && !['up', 'start', 'restart', 'health'].includes(command)) throw new Error('--wait supports up/start/restart/health only');
if (wait && stableSeconds >= timeoutSeconds) throw new Error('Health timeout must exceed stability window; use --stable-seconds');
if (wait && command === 'up' && (forwarded.includes('--abort-on-container-exit') || forwarded.includes('--abort-on-container-failure') || forwarded.includes('--attach') || forwarded.includes('--attach-dependencies') || forwarded.includes('--exit-code-from'))) throw new Error('Health startup requires detached Compose');
if (wait && command === 'up' && !forwarded.includes('-d') && !forwarded.includes('--detach')) forwarded.push('-d');
return { wait, timeoutSeconds, stableSeconds, forwarded };
}
+63
View File
@@ -0,0 +1,63 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import {execFileSync} from 'node:child_process';
import {parse as parseToml} from 'smol-toml';
import {parseDocument} from 'yaml';
import {build,validateConfig} from './build.mjs';
import {verifyRelease,absolute} from './runtime.mjs';
export const validVersion=value=>typeof value==='string'&&/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/.test(value);
export function release({root,version,configFile='platform/config.yaml',buildRelease=build,log=console.log}){
root=fs.realpathSync(root);
const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
if(!validVersion(version))throw new Error('Release version must be SemVer, for example 0.0.6');
if(fs.realpathSync(git(['rev-parse','--show-toplevel']))!==root)throw new Error('Release requires the repository root');
if(git(['status','--porcelain','--untracked-files=all']))throw new Error('Release requires a clean Git worktree, including untracked files');
if(git(['rev-parse','--is-shallow-repository'])==='true')throw new Error('Release requires complete local tag history; use a full clone');
git(['symbolic-ref','--quiet','HEAD']);
git(['var','GIT_AUTHOR_IDENT']);git(['var','GIT_COMMITTER_IDENT']);
const configuration=absolute(path.resolve(root,configFile),'release config');
if(!configuration.startsWith(root+path.sep))throw new Error('Config must be inside repository');
const doc=parseDocument(fs.readFileSync(configuration,'utf8'));
if(doc.errors.length||doc.warnings.length)throw new Error('Invalid release config');
const config=validateConfig(doc.toJS());
const inside=value=>{const file=absolute(path.resolve(root,value),'release path');if(!file.startsWith(root+path.sep))throw new Error('Release path must be inside repository');return file;};
const packFile=inside(path.join(config.pack.directory,'pack.toml')),output=inside(config.build.output_directory);
const relative=path.relative(root,packFile),original=fs.readFileSync(packFile,'utf8'),pack=parseToml(original);
if(!git(['ls-files','--',relative]))throw new Error('pack.toml must be tracked');
if(!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pack.name))throw new Error('Unsafe pack name');
if(version===pack.version)throw new Error('Choose a new release version');
const tag='v'+version,destination=path.join(output,pack.name+'-'+version);
if(fs.existsSync(destination)||git(['tag','--list',tag]))throw new Error('Release version or tag already exists');
const matches=[...original.matchAll(/^version\s*=\s*(["']).*?\1[^\r\n]*$/gm)];
if(matches.length!==1||parseToml(original.replace(matches[0][0],`version = "${version}"`)).version!==version)throw new Error('Expected one top-level version assignment in pack.toml');
const start=git(['rev-parse','HEAD']);
const lock=path.join(root,'.tools/release.lock');fs.mkdirSync(path.dirname(lock),{recursive:true});
try{fs.mkdirSync(lock);}catch{throw new Error('Release is locked; inspect .tools/release.lock');}
const candidate=path.join(output,'.release-check-'+crypto.randomUUID());
const updated=original.replace(matches[0][0],`version = "${version}"`);
let committed=false,staged=false,artifact;
try{
if(git(['rev-parse','HEAD'])!==start||git(['status','--porcelain','--untracked-files=all']))throw new Error('Git state changed before preparation');
fs.writeFileSync(packFile,updated);
log('Validating candidate build before committing version.');
buildRelease({root,configFile,outputDirectory:candidate,log});
if(git(['rev-parse','HEAD'])!==start||git(['diff','--cached','--name-only'])||git(['status','--porcelain','--untracked-files=all','--','.',`:(exclude)${relative}`]))throw new Error('Git state changed during release preparation');
git(['add','--',relative]);staged=true;
git(['commit','-m',`Release ${version}`]);committed=true;
log('Building final release from version commit.');
artifact=buildRelease({root,configFile,releaseOnly:true,log});
const verified=verifyRelease(artifact),commit=git(['rev-parse','HEAD']);
if(verified.manifest.git.dirty!==false||verified.manifest.git.commit!==commit||verified.manifest.pack.version!==version||path.resolve(artifact)!==destination||git(['status','--porcelain','--untracked-files=all']))throw new Error('Final release provenance differs from clean version commit');
git(['tag','-a',tag,'-m',`Release ${version}`,commit]);
return {directory:artifact,version,tag,commit};
}catch(error){
const headChanged=git(['rev-parse','HEAD'])!==start;
const canRestore=!committed&&!headChanged&&fs.readFileSync(packFile,'utf8')===updated;
if(canRestore){
fs.writeFileSync(packFile,original);if(staged)git(['reset','--quiet','HEAD','--',relative]);
}
throw new Error(`${error.message}. ${committed?'Version commit retained; inspect artifact and Git state before recovery. No tag was created by the failed operation.':canRestore?'Version restored; no release commit or tag created.':'Concurrent changes retained; inspect Git and source before recovery.'}`);
}finally{fs.rmSync(candidate,{recursive:true,force:true});fs.rmdirSync(lock);}
}
+152
View File
@@ -0,0 +1,152 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import assert from 'node:assert/strict';
import { absolute, runtime, readSettings, withRuntimeLock } from './runtime.mjs';
import { verifySnapshot, restoreSnapshot, runtimeInventory, backupBeforeApply } from './backups.mjs';
import { deployableRelease, releaseName, atomicJson, pointer, writePointer, managed, configConflicts, space, runtimeSize } from './deployment.mjs';
const fail=message=>{throw new Error(message);};
const safe=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)&&!['.','..'].includes(value);
// This function is self-contained: SSH can execute it read-only without uploading
// an agent, creating directories, obtaining a lock, stopping, or building.
export function inspectRollback(root, request={}, fsModule=fs, pathModule=path){
const safe=value=>typeof value==='string'&&/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)&&!['.','..'].includes(value);
if(!pathModule.isAbsolute(root)||root==='/'||/[\\\r\n\0]/.test(root))throw new Error('Invalid rollback root');
let current=pathModule.parse(root).root;
for(const part of root.slice(current.length).split('/').filter(Boolean)){current=pathModule.join(current,part);if(fsModule.lstatSync(current).isSymbolicLink())throw new Error('Rollback root contains symlink');}
const json=file=>{const target=pathModule.join(root,file);if(!fsModule.existsSync(target))return null;if(fsModule.lstatSync(target).isSymbolicLink())throw new Error('Unsafe state link');return JSON.parse(fsModule.readFileSync(target,'utf8'));};
const pointer=name=>{const file=pathModule.join(root,name);if(!fsModule.existsSync(file))return null;if(fsModule.lstatSync(file).isSymbolicLink())throw new Error('Unsafe pointer link');const value=fsModule.readFileSync(file,'utf8').trim();if(!safe(value))throw new Error('Invalid release pointer');return value;};
const active=pointer('active-release'),previous=pointer('previous-release');
if(request.backupId&&!safe(request.backupId))throw new Error('Use a snapshot ID, not a path');
if(request.version&&!safe(request.version))throw new Error('Unsafe target version');
const backups=pathModule.join(root,'backups');
if(fsModule.existsSync(backups)&&fsModule.lstatSync(backups).isSymbolicLink())throw new Error('Unsafe backups directory');
const snapshots=fsModule.existsSync(backups)?fsModule.readdirSync(backups).filter(safe).sort().map(id=>{
const directory=pathModule.join(backups,id),file=pathModule.join(directory,'metadata.json');
if(!fsModule.lstatSync(directory).isDirectory()||fsModule.lstatSync(directory).isSymbolicLink()||!fsModule.existsSync(file)||fsModule.lstatSync(file).isSymbolicLink())return null;
const data=JSON.parse(fsModule.readFileSync(file,'utf8'));
return{id,created_at:data.created_at,source_release:data.source_release?.name};
}).filter(Boolean):[];
const deployment=json('deployment-state.json'),rollback=json('rollback-state.json'),binding=json('.mcpack-runtime.json');
return{schema_version:1,action:'plan-only',root,active,previous,actual_manifest_sha256:binding?.release_manifest_sha256??null,deployment_phase:deployment?.phase??null,rollback_phase:rollback?.phase??null,snapshots,requested:request,target_checks:'full validation occurs only on explicit apply',notice:request.backupId?'Snapshot recovery replaces all runtime data; progress since its timestamp will be lost':'Pack-only rollback does not restore the world; compatibility requires explicit human confirmation'};
}
export function rollbackOnHost({repository,root,mode,backupId,version,confirmCompatible=false,acceptProgressLoss=false,timeout=120,env=process.env,execute=runtime}){
root=absolute(root,'server root');
if(!['pack','snapshot'].includes(mode)||!Number.isSafeInteger(timeout)||timeout<=10)fail('Invalid rollback request');
if(mode==='pack'&&(!safe(version)||!confirmCompatible||backupId))fail('Pack-only rollback requires a version and --confirm-compatible');
if(mode==='snapshot'&&(!safe(backupId)||!acceptProgressLoss||version))fail('Snapshot rollback requires an ID and --accept-progress-loss');
const settings=readSettings(absolute(path.join(root,'.env'),'host settings'));
if(settings.MC_EULA!=='TRUE')fail('Host EULA must already be accepted');
return withRuntimeLock(root,lockToken=>{
const rollbackFile=absolute(path.join(root,'rollback-state.json'),'rollback state');
if(fs.existsSync(rollbackFile)&&JSON.parse(fs.readFileSync(rollbackFile)).phase!=='complete')fail('Unresolved rollback state; inspect/recover before another rollback');
const deploymentFile=absolute(path.join(root,'deployment-state.json'),'deployment state');
const priorDeployment=fs.existsSync(deploymentFile)?JSON.parse(fs.readFileSync(deploymentFile)):null;
if(mode==='pack'&&priorDeployment&&priorDeployment.phase!=='complete')fail('Failed/incomplete deployment requires snapshot recovery, not pack-only rollback');
const active=pointer(root,'active-release'),previous=pointer(root,'previous-release');
const bindingFile=absolute(path.join(root,'.mcpack-runtime.json'),'binding');
const binding=JSON.parse(fs.readFileSync(bindingFile));
if(binding.schema_version!==1||!/^[a-z0-9][a-z0-9_-]*$/.test(binding.project)||!/^[a-f0-9]{64}$/.test(binding.release_manifest_sha256))fail('Invalid runtime binding');
const releases=absolute(path.join(root,'releases'),'releases');
let source,sourcePath,sourceName;
// Recover the actually applied release, even if deployment failed before
// advancing active-release. Never guess it from the success pointer.
const preferred=[priorDeployment?.target_release,active,...fs.readdirSync(releases)].filter(safe);
for(const name of [...new Set(preferred)]){
const directory=absolute(path.join(releases,name),'release');
if(!fs.existsSync(directory)||!fs.statSync(directory).isDirectory())continue;
const release=deployableRelease(directory);
if(release.identity===binding.release_manifest_sha256){source=release;sourcePath=directory;sourceName=name;break;}
}
if(!source||releaseName(source.manifest)!==sourceName)fail('No verified retained release matches the actual runtime binding');
if(mode==='pack'&&sourceName!==active)fail('Pack-only rollback requires actual binding to match active release');
const project=binding.project;
if(settings.MC_PROJECT_NAME&&settings.MC_PROJECT_NAME!==project)fail('Host project differs from runtime binding');
const lifecycle=(release,args)=>execute({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:release,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},args,lockToken});
if(lifecycle(sourcePath,['config','--quiet'])!==0)fail('Current Compose validation failed');
let snapshot,metadata,target,targetPath,targetName;
if(mode==='snapshot'){
snapshot=absolute(path.join(root,'backups',backupId),'snapshot');metadata=verifySnapshot(snapshot);
if(metadata.id!==backupId||metadata.runtime_binding.project!==project)fail('Snapshot identity/project differs from this server');
target=deployableRelease(path.join(snapshot,'release'));targetName=releaseName(target.manifest);
if(target.manifest.pack.name!==source.manifest.pack.name)fail('Snapshot belongs to another modpack');
targetPath=path.join(releases,targetName);
if(fs.existsSync(targetPath)&&deployableRelease(targetPath).identity!==target.identity)fail('Retained target version has different contents');
}else{
targetName=version.startsWith(source.manifest.pack.name+'-')?version:source.manifest.pack.name+'-'+version;
if(!safe(targetName))fail('Unsafe rollback release name');
targetPath=absolute(path.join(releases,targetName),'rollback release');target=deployableRelease(targetPath);
if(releaseName(target.manifest)!==targetName||target.manifest.pack.name!==source.manifest.pack.name)fail('Target belongs to another modpack');
const conflicts=configConflicts(root,source,target,sourcePath);
if(conflicts.length)fail(`Managed config conflicts: ${JSON.stringify(conflicts)}`);
}
space(root,runtimeSize(root)*3+(metadata?.runtime_inventory.reduce((sum,file)=>sum+(file.size??0),0)??0)+64*1024*1024);
const state={schema_version:1,id:crypto.randomUUID(),operation:'rollback',mode,created_at:new Date().toISOString(),phase:'preflight',source_release:sourceName,source_manifest_sha256:source.identity,target_release:targetName,target_manifest_sha256:target.identity,requested_snapshot:snapshot??null,snapshot_created_at:metadata?.created_at??null,progress_loss_accepted:mode==='snapshot',compatibility_confirmed:mode==='pack',backup_snapshot:null,applied:false,runtime_replaced:false,pointers_before:{active,previous}};
const save=phase=>{state.phase=phase;state.updated_at=new Date().toISOString();atomicJson(rollbackFile,state);};
let restored;
try{
save('validating-target');
if(mode==='snapshot'){
restored=path.join(root,'.rollback-restore-'+state.id);state.restore_directory=restored;
save('restoring-stage');restoreSnapshot({backupDirectory:snapshot,destination:restored});
if(!fs.existsSync(targetPath)){
const stage=fs.mkdtempSync(path.join(releases,'.rollback-release-'));
try{fs.cpSync(path.join(restored,'release'),stage,{recursive:true});if(deployableRelease(stage).identity!==target.identity)fail('Target changed while copying');fs.chmodSync(stage,0o755);fs.renameSync(stage,targetPath);}
finally{if(fs.existsSync(stage))fs.rmSync(stage,{recursive:true,force:true});}
}
}
const validation=fs.mkdtempSync(path.join(root,'.validate-rollback-'));
try{if(execute({repository,env:{...env,MC_ROOT:validation,MC_RELEASE_DIR:targetPath,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},args:['config','--quiet']})!==0)fail('Target Compose validation failed');}
finally{
const overlay=path.join(validation,'.mcpack-host-ports.yaml');if(fs.existsSync(overlay))fs.unlinkSync(overlay);
fs.rmdirSync(validation);
}
save('backing-up-current');
return backupBeforeApply({repository,env:{...env,MC_ROOT:root,MC_RELEASE_DIR:sourcePath,MC_ENV_FILE:path.join(root,'.env'),MC_PROJECT_NAME:project},targetRelease:targetPath,waitTimeout:timeout,lockToken},({snapshot:currentBackup})=>{
state.backup_snapshot=currentBackup;save('applying');
if(mode==='pack'){
const conflicts=configConflicts(root,source,target,sourcePath);if(conflicts.length)fail(`Configs changed while backing up: ${JSON.stringify(conflicts)}`);
}
if(lifecycle(sourcePath,['down'])!==0)fail('Could not remove stopped source container');
state.container_removed=true;state.applied=true;save('applying');
if(mode==='snapshot'){
const retiredParent=absolute(path.join(root,'retired-runtimes',state.id),'retired runtime');fs.mkdirSync(retiredParent,{recursive:true,mode:0o700});
state.retired_runtime=path.join(retiredParent,'runtime');save('moving-current-runtime');
fs.renameSync(path.join(root,'runtime'),state.retired_runtime);state.current_runtime_moved=true;save('attaching-snapshot');
fs.renameSync(path.join(restored,'runtime'),path.join(root,'runtime'));state.runtime_replaced=true;save('verifying-restored');
assert.deepStrictEqual(runtimeInventory(path.join(root,'runtime')),metadata.runtime_inventory,'Attached runtime differs from snapshot');
}else{
const next=managed(target);for(const [relative,file]of managed(source))if(file.policy==='replace'&&!next.has(relative))fs.unlinkSync(absolute(path.join(root,'runtime',relative),'removed managed file'));
}
atomicJson(bindingFile,{schema_version:1,project,release_manifest_sha256:target.identity});
save('starting');
if(lifecycle(targetPath,['up','-d','--wait','--wait-timeout',String(timeout)])!==0)fail('Rollback startup/health failed; inspect diagnostics');
save('committing');
if(active&&active!==targetName)writePointer(root,'previous-release',active);
writePointer(root,'active-release',targetName);
const history=absolute(path.join(root,'operation-history'),'operation history');fs.mkdirSync(history,{recursive:true,mode:0o700});
if(priorDeployment){state.prior_deployment_state=path.join(history,'deployment-before-'+state.id+'.json');atomicJson(state.prior_deployment_state,priorDeployment);}
atomicJson(deploymentFile,{...state,phase:'complete',resolved_deployment:priorDeployment?.phase!=='complete'?priorDeployment?.id??null:null});
save('complete');return state;
});
}catch(error){
if(state.applied){
try{
const actual=JSON.parse(fs.readFileSync(bindingFile)).release_manifest_sha256;
const stopPath=actual===target.identity?targetPath:sourcePath;
state.stop_exit=lifecycle(stopPath,['stop']);state.stopped_confirmed=state.stop_exit===0;
}catch(stopError){state.stop_error=stopError.message;}
}
if(!state.backup_snapshot&&state.phase==='backing-up-current')try{state.backup_snapshot=JSON.parse(fs.readFileSync(path.join(root,'.mcpack-backup-operation.json'))).snapshot;}catch{}
state.error=error.message;state.recovery=state.applied?'No automatic restart/rollback. Preserve retired runtime and current backup; inspect binding, pointers and diagnostics before explicit recovery':'Current runtime was not replaced. Inspect stop/backup state before resuming source';
save('failed');throw new Error(`${error.message}. State: ${rollbackFile}. ${state.recovery}`);
}finally{
// The displaced world is never removed. A restored stage with its runtime
// still present is retained on failure for diagnosis/recovery.
if(restored&&state.phase==='complete')fs.rmSync(restored,{recursive:true,force:true});
}
});
}
+234
View File
@@ -0,0 +1,234 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { execFileSync, spawnSync } from 'node:child_process';
import { isIP } from 'node:net';
import { parseDocument } from 'yaml';
import { performance } from 'node:perf_hooks';
import { healthOptions, waitForHealth, saveHealthReport } from './health.mjs';
const fail = message => { throw new Error(message); };
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const safe = value => typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..');
const contained = (parent, child) => child === parent || child.startsWith(parent + path.sep);
export function absolute(value, label) {
if (!value || !path.isAbsolute(value) || /[\0\r\n]/.test(value)) fail(`${label} must be an absolute path`);
const result = path.resolve(value);
let current = path.parse(result).root;
for (const part of result.slice(current.length).split(path.sep).filter(Boolean)) {
current = path.join(current, part);
try { if (fs.lstatSync(current).isSymbolicLink()) fail(`${label} must not contain symlinks`); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
}
return result;
}
function tree(directory) {
const result = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) result.push(...tree(file).map(child => `${entry.name}/${child}`));
else if (entry.isFile()) result.push(entry.name);
else fail('Release must contain only regular files/directories');
}
return result.sort();
}
export function verifyRelease(directory) {
directory = absolute(directory, 'MC_RELEASE_DIR');
const actual = tree(directory);
const checksums = 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' || checksums.has(match[2])) fail('Malformed or duplicate release checksum');
checksums.set(match[2], match[1]);
}
if (JSON.stringify([...checksums.keys()].sort()) !== JSON.stringify(actual.filter(file => file !== 'SHA256SUMS'))) fail('Release checksum coverage is incomplete');
for (const [file, expected] of checksums) if (sha256(path.join(directory, file)) !== expected) fail(`Release checksum mismatch: ${file}`);
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
if (manifest.schema_version !== 1 || !safe(manifest.artifact) || manifest.artifact.includes('/') || !checksums.has(manifest.artifact)) fail('Unsupported or invalid release manifest');
if (!/^itzg\/minecraft-server@sha256:[a-f0-9]{64}$/.test(manifest.server?.image)) fail('Release image must be pinned by digest');
if (!/^[a-f0-9]{64}$/.test(manifest.tools?.compose?.sha256)) fail('Missing pinned Compose checksum');
const payload = new Map();
for (const file of manifest.release_files ?? []) {
if (!safe(file.path) || payload.has(file.path) || checksums.get(file.path) !== file.sha256) fail('Manifest payload does not match checksums');
payload.set(file.path, file.sha256);
}
if (JSON.stringify([...payload.keys()].sort()) !== JSON.stringify(actual.filter(file => !['manifest.json', 'SHA256SUMS'].includes(file)))) fail('Manifest payload coverage is incomplete');
for (const required of ['server/compose.yaml', 'server/.env.example', manifest.artifact]) if (!checksums.has(required)) fail(`Release file missing: ${required}`);
if (!fs.statSync(path.join(directory, 'server/config')).isDirectory()) fail('Server config directory missing');
return { manifest, identity: checksums.get('manifest.json') };
}
export function readSettings(file) {
const result = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const match = /^([A-Z][A-Z0-9_]*)=([A-Za-z0-9_.-]+)$/.exec(line.trim());
if (!match || !['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_PROJECT_NAME', 'MC_BIND_ADDRESS'].includes(match[1]) || match[1] in result) fail('Invalid local settings: use simple KEY=value entries from .env.example');
result[match[1]] = match[2];
}
if (!['TRUE', 'FALSE'].includes(result.MC_EULA)) fail('Set MC_EULA explicitly to TRUE or FALSE in the local settings file');
for (const key of ['MC_UID', 'MC_GID']) if (!/^(0|[1-9]\d*)$/.test(result[key])) fail(`Set numeric ${key} explicitly`);
if (result.MC_MEMORY && !/^[1-9]\d*[MG]$/.test(result.MC_MEMORY)) fail('Invalid MC_MEMORY');
if (result.MC_PORT && (!/^\d+$/.test(result.MC_PORT) || Number(result.MC_PORT) < 1 || Number(result.MC_PORT) > 65535)) fail('Invalid MC_PORT');
if (result.MC_BIND_ADDRESS && isIP(result.MC_BIND_ADDRESS) !== 4) fail('MC_BIND_ADDRESS must be an IPv4 address');
return result;
}
const lockTokens = new WeakSet();
export function withRuntimeLock(root, operation) {
root = absolute(root, 'MC_ROOT');
const directory = path.join(root, '.mcpack-runtime.lock');
try { fs.mkdirSync(directory); } catch { fail('Another runtime operation is running (or a stale .mcpack-runtime.lock remains)'); }
const token = { root };
lockTokens.add(token);
const release = () => { lockTokens.delete(token); fs.rmdirSync(directory); };
try {
const result = operation(token);
if (result && typeof result.then === 'function') return Promise.resolve(result).finally(release);
release();
return result;
} catch (error) { if (lockTokens.has(token)) release(); throw error; }
}
export function runtime({ repository, args, env = process.env, lockToken, capture = false }) {
const commands = ['config', 'up', 'start', 'stop', 'restart', 'ps', 'logs', 'down', 'create', 'exec', 'health'];
if (!args.length || args[0] === '--help') {
console.log('MC_ROOT=/absolute/server-root MC_RELEASE_DIR=/absolute/release scripts/server.sh <config|up|start|stop|restart|ps|logs|down|create|exec|health> [compose options]\nReadiness: up/start/restart --wait or health [--wait-timeout SECONDS] [--stable-seconds SECONDS]. Settings: MC_ENV_FILE or MC_ROOT/.env.');
return args.length ? 0 : 1;
}
const command = args[0];
if (!commands.includes(command)) fail('Unsupported lifecycle command');
if (capture && !['ps', 'config'].includes(command)) fail('Capture is only allowed for read-only queries');
if (args.some(arg => /^(?:-[fp]|--(?:file|project-name|env-file|project-directory)(?:=|$))/.test(arg))) fail('Compose identity/file options are controlled by the wrapper');
repository = fs.realpathSync(repository);
let defaultTimeout = 120;
if (command === 'health' || args.includes('--wait')) {
const configFile = path.join(repository, 'platform/config.yaml');
if (fs.existsSync(configFile)) {
const document = parseDocument(fs.readFileSync(configFile, 'utf8'));
if (document.errors.length) fail('Invalid platform config for health timeout');
defaultTimeout = document.toJS().deploy?.healthcheck_timeout;
if (!Number.isSafeInteger(defaultTimeout) || defaultTimeout < 1) fail('Invalid configured healthcheck_timeout');
}
}
const health = healthOptions(args, defaultTimeout);
const root = absolute(env.MC_ROOT, 'MC_ROOT');
if (lockToken && (!lockTokens.has(lockToken) || lockToken.root !== root)) fail('Invalid runtime operation lock token');
const release = absolute(env.MC_RELEASE_DIR, 'MC_RELEASE_DIR');
if (!fs.statSync(root).isDirectory()) fail('Create MC_ROOT before running the server wrapper');
const data = absolute(path.join(root, 'runtime'), 'runtime path');
if (contained(data, release) || contained(release, data) || contained(release, root)) fail('Runtime/server root must not overlap immutable release');
const envFile = absolute(env.MC_ENV_FILE ?? path.join(root, '.env'), 'MC_ENV_FILE');
if (contained(release, envFile)) fail('Local settings must be outside the immutable release');
const settings = readSettings(envFile);
const starts = ['up', 'start', 'restart', 'create'].includes(command);
if (starts && settings.MC_EULA !== 'TRUE') fail('Startup requires your EULA acceptance: set MC_EULA=TRUE in local settings');
const { manifest, identity } = verifyRelease(release);
const compatibility = JSON.parse(fs.readFileSync(path.join(repository, 'platform/compatibility.json'), 'utf8'));
if (!compatibility.verified.some(item => item.minecraft === manifest.pack?.minecraft && item.loader === manifest.pack?.loader?.id && item.loader_version === manifest.pack?.loader?.version && item.image === manifest.server.image && item.java_major === manifest.server.java_major)) fail('Release Minecraft/loader/image/Java combination is not verified');
const executable = path.join(repository, '.tools/docker-compose');
if (sha256(executable) !== manifest.tools.compose.sha256) fail('Pinned Compose checksum mismatch');
const project = env.MC_PROJECT_NAME ?? settings.MC_PROJECT_NAME ?? manifest.server.compose_project;
if (typeof project !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(project)) fail('Invalid stable Compose project name');
const stateFile = absolute(path.join(root, '.mcpack-runtime.json'), 'runtime binding');
const binding = { schema_version: 1, project, release_manifest_sha256: identity };
const checkBinding = () => {
if (fs.existsSync(stateFile)) {
const previous = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
if (previous.schema_version !== 1 || previous.project !== project || previous.release_manifest_sha256 !== identity) fail('Runtime is bound to another project/release; changing it requires the future backup/deploy workflow');
} else if (starts && fs.existsSync(data) && fs.readdirSync(data).some(file => file !== '.gitkeep')) fail('Nonempty runtime has no release binding; migrate through the backup/deploy workflow');
};
checkBinding();
// Host settings have one source of truth; ambient MC_* cannot override .env.
const composeEnv = { ...env, MC_ROOT: root, MC_RELEASE_DIR: release };
for (const key of ['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_PROJECT_NAME', 'MC_BIND_ADDRESS']) delete composeEnv[key];
Object.assign(composeEnv, settings);
const base = ['--project-name', project, '--env-file', envFile, '-f', path.join(release, 'server/compose.yaml')];
// Legacy releases hardcoded loopback. Keep immutable Compose intact while
// honoring the user's explicit host networking preference during rollback.
let portOverlay;
const sourceCompose = fs.readFileSync(path.join(release, 'server/compose.yaml'), 'utf8');
if (settings.MC_BIND_ADDRESS && !sourceCompose.includes('MC_BIND_ADDRESS') && Array.isArray(parseDocument(sourceCompose).toJS()?.services?.minecraft?.ports)) {
portOverlay = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpack-host-ports-'));
const file = path.join(portOverlay, 'ports.yaml');
const endpoint = `${settings.MC_BIND_ADDRESS}:${settings.MC_PORT ?? manifest.server.port ?? 25565}:25565`;
fs.writeFileSync(file, `services:\n minecraft:\n ports: !override\n - ${JSON.stringify(endpoint)}\n`, { mode: 0o600 });
base.push('-f', file);
}
try {
const compiled = JSON.parse(execFileSync(executable, [...base, 'config', '--format', 'json'], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
const service = compiled.services?.minecraft;
if (!service || service.image !== manifest.server.image || service.environment?.TYPE !== 'MODRINTH' || service.environment?.ONLINE_MODE !== 'false' || service.environment?.ENFORCE_SECURE_PROFILE !== 'false' || service.environment?.MODRINTH_MODPACK !== `/modpacks/${manifest.artifact}`) fail('Compiled Compose does not match offline release contract');
for (const [target, source, readOnly] of [['/data', data, false], ['/modpacks', release, true], ['/config', path.join(release, 'server/config'), true]]) {
const mount = service.volumes?.find(volume => volume.target === target);
if (!mount || mount.type !== 'bind' || mount.source !== source || Boolean(mount.read_only) !== readOnly) fail(`Invalid absolute bind mount: ${target}`);
}
const mutations = ['up', 'start', 'stop', 'restart', 'down', 'create'].includes(command);
const checkProjectOwner = () => {
const output = execFileSync(executable, [...base, 'ps', '--all', '--format', 'json'], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
const containers = !output ? [] : output.startsWith('[') ? JSON.parse(output) : output.split('\n').map(line => JSON.parse(line));
if (!Array.isArray(containers)) fail('Unexpected Compose project status');
const ids = containers.map(container => container.ID);
if (ids.some(id => typeof id !== 'string' || !/^[a-f0-9]{12,64}$/.test(id))) fail('Unexpected Compose container identity');
if (!ids.length) return;
const inspected = JSON.parse(execFileSync('docker', ['inspect', ...ids], { env: composeEnv, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }));
if (inspected.some(container => !container.Mounts?.some(mount => mount.Type === 'bind' && mount.Destination === '/data' && mount.Source === data))) fail('Compose project belongs to another runtime root; choose a distinct stable MC_PROJECT_NAME');
};
const perform = () => {
if (mutations) checkBinding();
if (mutations || command === 'exec' || health.wait) checkProjectOwner();
if (health.wait && !fs.existsSync(stateFile) && !starts) fail('Health requires a bound runtime');
if (starts) {
fs.mkdirSync(data, { recursive: true });
fs.accessSync(data, fs.constants.W_OK);
if (!fs.existsSync(stateFile)) fs.writeFileSync(stateFile, JSON.stringify(binding, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
}
const diagnostic = readiness => {
const logResult = spawnSync(executable, [...base, 'logs', '--no-color', '--tail', '100'], { env: composeEnv, encoding: 'utf8', timeout: 10000, maxBuffer: 1024 * 1024 });
const logs = (logResult.stdout ?? '') + (logResult.stderr ?? '') + (logResult.error ? `\nLog collection failed: ${logResult.error.message}` : '');
const files = saveHealthReport({ root, project, releaseIdentity: identity, result: readiness, logs });
console.error(`Health ${readiness.reason}. Report: ${files.reportPath}\nLogs: ${files.logsPath}\nRecovery: runtime and release binding preserved; no automatic rollback.`);
return 1;
};
const healthStarted = performance.now();
const result = command === 'health' ? { status: 0 } : spawnSync(executable, [...base, ...health.forwarded], { env: composeEnv, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: capture ? 'utf8' : undefined, timeout: health.wait ? health.timeoutSeconds * 1000 : undefined });
if (health.wait && (result.error || result.signal || result.status !== 0)) return diagnostic({ ok: false, reason: result.error?.code === 'ETIMEDOUT' ? 'timeout' : 'compose-start-failed', compose_exit: result.status, error: result.error?.message, signal: result.signal });
if (result.error) throw result.error;
if (result.signal) fail(`Compose terminated by ${result.signal}`);
if (capture) {
if (result.status !== 0) fail(`Compose query failed: ${result.stderr?.trim() || result.status}`);
return result.stdout;
}
if (health.wait) {
const readiness = waitForHealth({ ...health, startedAtMs: healthStarted,
inspect: timeout => {
const queryStarted = performance.now();
const output = execFileSync(executable, [...base, 'ps', '--all', '--format', 'json', 'minecraft'], { env: composeEnv, encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
const list = !output ? [] : output.startsWith('[') ? JSON.parse(output) : output.split('\n').map(line => JSON.parse(line));
if (!Array.isArray(list) || list.length > 1) fail('Unexpected Minecraft container count');
if (!list.length) return null;
if (!/^[a-f0-9]{12,64}$/.test(list[0].ID)) fail('Unexpected Minecraft container identity');
const remaining = Math.ceil(timeout - (performance.now() - queryStarted));
if (remaining <= 0) fail('Health query deadline exceeded');
const inspected = JSON.parse(execFileSync('docker', ['inspect', list[0].ID], { env: composeEnv, encoding: 'utf8', timeout: remaining, stdio: ['ignore', 'pipe', 'pipe'] }));
if (inspected.length !== 1 || !inspected[0].Mounts?.some(mount => mount.Type === 'bind' && mount.Destination === '/data' && mount.Source === data)) fail('Health container belongs to another runtime');
return inspected[0];
},
probe: (id, timeout) => {
const probe = spawnSync('docker', ['exec', id, 'mc-health'], { env: composeEnv, encoding: 'utf8', timeout: Math.min(timeout, 10000), maxBuffer: 1024 * 1024 });
return { status: probe.status, stdout: probe.stdout?.trim(), stderr: probe.stderr?.trim(), error: probe.error?.message };
}
});
if (!readiness.ok) return diagnostic(readiness);
console.log(`Healthy: Minecraft status query passed; stable for ${health.stableSeconds}s (${project}).`);
}
return result.status ?? 1;
};
return (mutations || health.wait) && !lockToken ? withRuntimeLock(root, perform) : perform();
} finally { if (portOverlay) fs.rmSync(portOverlay, { recursive: true, force: true }); }
}
View File
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Minecraft platform configuration v1",
"type": "object",
"additionalProperties": false,
"required": ["schema_version", "pack", "build", "server", "deploy"],
"properties": {
"schema_version": { "const": 1 },
"pack": {
"type": "object", "additionalProperties": false, "required": ["directory"],
"properties": { "directory": { "type": "string", "minLength": 1 } }
},
"build": {
"type": "object", "additionalProperties": false, "required": ["output_directory"],
"properties": { "output_directory": { "type": "string", "minLength": 1 } }
},
"server": {
"type": "object", "additionalProperties": false,
"required": ["host", "directory", "memory", "port"],
"properties": {
"host": { "type": ["string", "null"], "minLength": 1 },
"directory": { "type": ["string", "null"], "pattern": "^/" },
"memory": { "type": "string", "pattern": "^[1-9][0-9]*[MG]$" },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
},
"oneOf": [
{ "properties": { "host": { "type": "null" }, "directory": { "type": "null" } } },
{ "properties": { "host": { "type": "string" }, "directory": { "type": "string" } } }
]
},
"deploy": {
"type": "object", "additionalProperties": false,
"required": ["transport", "healthcheck_timeout"],
"properties": {
"transport": { "const": "ssh" },
"healthcheck_timeout": { "type": "integer", "minimum": 1 }
}
}
}
}
+155
View File
@@ -0,0 +1,155 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { spawn, execFileSync } from 'node:child_process';
import { createInterface } from 'node:readline';
import { parseDocument } from 'yaml';
import { validateConfig, build } from './build.mjs';
import { inspectRollback } from './rollback.mjs';
import { deployableRelease, releaseName, sha256 } from './deployment.mjs';
export const shellQuote=value=>"'"+String(value).replaceAll("'","'\\''")+"'";
export function sshTarget(host,directory){
if(typeof host!=='string'||!/^([A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host))throw new Error('Use a safe SSH host/alias or user@host (ports and keys belong in SSH config)');
if(typeof directory!=='string'||!directory.startsWith('/')||directory==='/'||/[\\\r\n\0:]/.test(directory)||directory.split('/').slice(1).some(part=>!part||part==='.'||part==='..'))throw new Error('Use an absolute normalized non-root remote directory');
return{host,directory};
}
const sshArgs=['-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=15','-o','ServerAliveInterval=15','-o','ServerAliveCountMax=3'];
function command(program,args,{input,env=process.env}={}){
try{return execFileSync(program,args,{input,env,encoding:'utf8',stdio:['pipe','pipe','pipe'],maxBuffer:32*1024*1024});}
catch(error){
let remoteMessage;
for(const line of error.stdout?.toString().split('\n')??[])if(line.startsWith('MCPACK:'))try{const message=JSON.parse(line.slice(7));if(message.type==='error')remoteMessage=message.message;}catch{}
throw new Error(`${program} failed: ${remoteMessage||error.stderr?.toString().trim()||error.message}`);
}
}
export function agentBundle(repository,config){
const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'mcpack-agent-'));
try{
for(const file of ['platform/runtime.mjs','platform/backups.mjs','platform/health.mjs','platform/deployment.mjs','platform/rollback.mjs','platform/compatibility.json','scripts/deploy-worker.mjs','scripts/server.mjs','scripts/server.sh','.tools/docker-compose','package.json']){
const destination=path.join(temporary,file);fs.mkdirSync(path.dirname(destination),{recursive:true});fs.copyFileSync(path.join(repository,file),destination);
}
fs.writeFileSync(path.join(temporary,'platform/config.yaml'),`deploy:\n healthcheck_timeout: ${config.deploy.healthcheck_timeout}\n`);
fs.mkdirSync(path.join(temporary,'node_modules'));
fs.cpSync(fs.realpathSync(path.join(repository,'node_modules/yaml')),path.join(temporary,'node_modules/yaml'),{recursive:true});
const entries=[];
const walk=(directory,relative='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,entry.name),name=relative?relative+'/'+entry.name:entry.name;if(entry.isDirectory())walk(file,name);else if(entry.isFile())entries.push({path:name,sha256:sha256(file)});else throw new Error('Agent contains links/special files');}};
walk(temporary);entries.sort((a,b)=>a.path.localeCompare(b.path));
fs.writeFileSync(path.join(temporary,'agent-files.json'),JSON.stringify(entries));
return{directory:temporary,identity:sha256(path.join(temporary,'agent-files.json'))};
}catch(error){fs.rmSync(temporary,{recursive:true,force:true});throw error;}
}
function upload(source,host,destination,connectionArgs=sshArgs){
// --secluded-args protects remote paths; SSH destination itself is validated.
return command('rsync',['--archive','--secluded-args','--delete','-e',`ssh ${connectionArgs.map(shellQuote).join(' ')}`,source+path.sep,host+':'+destination+'/']);
}
const bootstrap=`
const fs=require('fs'),path=require('path'),cp=require('child_process');
const root=process.argv[1];
if(Number(process.versions.node.split('.')[0])<22)throw Error('Host requires Node.js 22+');
let current='/';for(const part of root.split('/').filter(Boolean)){current=path.join(current,part);if(fs.lstatSync(current).isSymbolicLink())throw Error('Remote root contains symlink');}
if(!fs.statSync(root).isDirectory())throw Error('Prepare remote root');
fs.accessSync(root,fs.constants.W_OK);fs.accessSync(path.join(root,'.env'),fs.constants.R_OK);
for(const [cmd,args]of [['rsync',['--version']],['tar',['--version']],['zstd',['--version']],['docker',['info']]])cp.execFileSync(cmd,args,{stdio:'pipe'});
fs.mkdirSync(path.join(root,'.mcpack'),{recursive:true,mode:0o700});
if(fs.lstatSync(path.join(root,'.mcpack')).isSymbolicLink())throw Error('Unsafe agent directory');
`;
const verifyAgent=`
const fs=require('fs'),path=require('path'),crypto=require('crypto');
const [dir,expected]=process.argv.slice(1),hash=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
if(hash(path.join(dir,'agent-files.json'))!==expected)throw Error('Agent checksum manifest mismatch');
const entries=JSON.parse(fs.readFileSync(path.join(dir,'agent-files.json'))),actual=[];
const walk=(base,rel='')=>{for(const entry of fs.readdirSync(base,{withFileTypes:true})){const file=path.join(base,entry.name),name=rel?rel+'/'+entry.name:entry.name;if(entry.isDirectory())walk(file,name);else if(entry.isFile())actual.push(name);else throw Error('Unsafe agent file');}};
walk(dir);
if(JSON.stringify(actual.filter(n=>n!=='agent-files.json').sort())!==JSON.stringify(entries.map(e=>e.path).sort()))throw Error('Agent coverage mismatch');
for(const entry of entries){if(entry.path.split('/').some(p=>p==='..'||!p)||entry.path.startsWith('/'))throw Error('Unsafe agent path');if(hash(path.join(dir,entry.path))!==entry.sha256)throw Error('Agent file mismatch');}
fs.chmodSync(path.join(dir,'.tools/docker-compose'),0o755);
`;
export async function deploy({root:repository,configFile='platform/config.yaml',artifact,sshConfig,remoteNode='node'}){
if (remoteNode !== 'node' && (!path.posix.isAbsolute(remoteNode) || /[\\\r\n\0]/.test(remoteNode))) throw new Error('remote-node must be node or an absolute executable path');
const connectionArgs = sshConfig ? ['-F', path.resolve(repository, sshConfig), ...sshArgs] : sshArgs;
const nodeCommand = shellQuote(remoteNode);
const document=parseDocument(fs.readFileSync(path.resolve(repository,configFile),'utf8'));
if(document.errors.length)throw new Error('Invalid deploy YAML');
const config=validateConfig(document.toJS()),{host,directory}=sshTarget(config.server.host,config.server.directory);
let releasePath=artifact?path.resolve(repository,artifact):null;
if(!releasePath){
// Reuse the ready immutable artifact when present; build only when absent.
const {parse}=await import('smol-toml');
const pack=parse(fs.readFileSync(path.resolve(repository,config.pack.directory,'pack.toml'),'utf8'));
const expected=path.resolve(repository,config.build.output_directory,`${pack.name}-${pack.version}`);
releasePath=fs.existsSync(expected)?expected:build({root:repository,configFile,releaseOnly:true});
}
const release=deployableRelease(releasePath),name=releaseName(release.manifest);
const bundle=agentBundle(repository,config);
let agent;
try{
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(bootstrap)} ${shellQuote(directory)}`]);
agent=directory+'/.mcpack/agent-'+crypto.randomUUID();
command('ssh',[...connectionArgs,host,`mkdir -m 700 -- ${shellQuote(agent)}`]);
upload(bundle.directory,host,agent,connectionArgs);
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(verifyAgent)} ${shellQuote(agent)} ${shellQuote(bundle.identity)}`]);
const releaseBytes=[...release.manifest.release_files.map(file=>file.path),'manifest.json','SHA256SUMS'].reduce((total,file)=>total+fs.statSync(path.join(releasePath,file)).size,0);
const request={root:directory,targetName:name,targetIdentity:release.identity,targetChecksums:sha256(path.join(releasePath,'SHA256SUMS')),releaseBytes,timeout:config.deploy.healthcheck_timeout};
const child=spawn('ssh',[...connectionArgs,host,`${nodeCommand} ${shellQuote(agent+'/scripts/deploy-worker.mjs')}`],{stdio:['pipe','pipe','inherit']});
child.stdin.on('error',()=>{});
const completion=new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',(code,signal)=>resolve({code,signal}));});
child.stdin.write(JSON.stringify(request)+'\n');
let completed,remoteError;
for await(const line of createInterface({input:child.stdout,terminal:false})){
if(!line.startsWith('MCPACK:')){console.log(line);continue;}
const message=JSON.parse(line.slice(7));
if(message.type==='ready'){
if(typeof message.stage!=='string'||path.posix.dirname(message.stage)!==directory+'/releases'||!/^\.incoming-[A-Za-z0-9]+$/.test(path.posix.basename(message.stage))){child.stdin.end();throw new Error('Remote requested unsafe upload path');}
try{upload(releasePath,host,message.stage,connectionArgs);child.stdin.write('{"type":"uploaded"}\n');}
catch(error){remoteError=error.message;child.stdin.end('{"type":"abort"}\n');}
}else if(message.type==='complete'){completed=message.state;child.stdin.end();}
else if(message.type==='error'){remoteError=message.message;child.stdin.end();}
}
const exit=await completion;
if(remoteError||!completed||exit.code!==0)throw new Error(remoteError||`SSH interrupted (exit ${exit.code}, signal ${exit.signal}); inspect ${directory}/deployment-state.json before retry`);
console.log(`Deployment ${completed.noop?'already healthy':'complete'}: ${host}:${directory}/releases/${name}`);
return completed;
}finally{
// Keep remote agent for recovery. No secrets or host env are uploaded.
fs.rmSync(bundle.directory,{recursive:true,force:true});
}
}
export async function rollback({root:repository,command:operation='rollback',configFile='platform/config.yaml',sshConfig,remoteNode='node',backupId,version,confirmCompatible=false,acceptProgressLoss=false}){
if(remoteNode!=='node'&&(!path.posix.isAbsolute(remoteNode)||/[\\\r\n\0]/.test(remoteNode)))throw new Error('Invalid remote Node path');
const document=parseDocument(fs.readFileSync(path.resolve(repository,configFile),'utf8'));
if(document.errors.length)throw new Error('Invalid rollback config');
const config=validateConfig(document.toJS()),{host,directory}=sshTarget(config.server.host,config.server.directory);
const connectionArgs=sshConfig?['-F',path.resolve(repository,sshConfig),...sshArgs]:sshArgs,nodeCommand=shellQuote(remoteNode);
const request={root:directory,operation:'rollback',mode:operation==='rollback-pack'?'pack':'snapshot',backupId,version,confirmCompatible,acceptProgressLoss,timeout:config.deploy.healthcheck_timeout};
const apply=request.mode==='pack'?confirmCompatible:Boolean(backupId&&acceptProgressLoss);
if(!apply){
const script=`const fs=require('fs'),path=require('path');console.log(JSON.stringify((${inspectRollback.toString()})(process.argv[1],JSON.parse(process.argv[2]),fs,path),null,2));`;
const output=command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(script)} ${shellQuote(directory)} ${shellQuote(JSON.stringify({backupId,version}))}`]);
console.log(output.trim());
console.log(request.mode==='pack'?'Plan only. Applying requires --confirm-compatible; SemVer does not prove world compatibility.':'Plan only. Applying a snapshot requires --backup ID --accept-progress-loss; all runtime progress since its timestamp will be replaced.');
return JSON.parse(output);
}
if(request.mode==='snapshot')console.log(`Snapshot ${backupId}: replacing runtime will lose progress after its timestamp. Current state will be backed up first.`);
else console.log(`Pack-only rollback to ${version}: world compatibility explicitly confirmed; current runtime will not be restored.`);
const bundle=agentBundle(repository,config);
try{
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(bootstrap)} ${shellQuote(directory)}`]);
const agent=directory+'/.mcpack/agent-'+crypto.randomUUID();
command('ssh',[...connectionArgs,host,`mkdir -m 700 -- ${shellQuote(agent)}`]);
upload(bundle.directory,host,agent,connectionArgs);
command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(verifyAgent)} ${shellQuote(agent)} ${shellQuote(bundle.identity)}`]);
const output=command('ssh',[...connectionArgs,host,`${nodeCommand} ${shellQuote(agent+'/scripts/deploy-worker.mjs')}`],{input:JSON.stringify(request)+'\n'});
let result,error;
for(const line of output.split('\n')){
if(!line.startsWith('MCPACK:')){if(line)console.log(line);continue;}
const message=JSON.parse(line.slice(7));if(message.type==='complete')result=message.state;else if(message.type==='error')error=message.message;
}
if(error||!result)throw new Error(error||'Rollback SSH interrupted; inspect rollback-state.json before recovery');
console.log(`Rollback complete: ${result.target_release}. Current-state backup: ${result.backup_snapshot}`);
return result;
}finally{fs.rmSync(bundle.directory,{recursive:true,force:true});}
}