Files
minecraft-lab/platform/build.mjs
T
2026-10-04 00:30:09 +07:00

326 lines
23 KiB
JavaScript

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);
}
}