first cycle lab release
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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<BigInt(bytes))fail(`Insufficient free space: need at least ${bytes} bytes, have ${available}`);
|
||||
}
|
||||
export function runtimeSize(root) {
|
||||
const directory=path.join(root,'runtime');
|
||||
let sum=0;
|
||||
const walk=file=>{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});}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user