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