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