235 lines
17 KiB
JavaScript
235 lines
17 KiB
JavaScript
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 }); }
|
|
}
|