first cycle lab release
This commit is contained in:
@@ -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).
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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});}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }); }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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});}
|
||||
}
|
||||
Reference in New Issue
Block a user