first cycle lab release

This commit is contained in:
2026-10-04 00:30:09 +07:00
parent 85f6897365
commit d134daafd8
92 changed files with 5623 additions and 9 deletions
+152
View File
@@ -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});
}
});
}