import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { spawn, execFileSync } from 'node:child_process'; import { createInterface } from 'node:readline'; import { parseDocument } from 'yaml'; import { validateConfig, build } from './build.mjs'; import { inspectRollback } from './rollback.mjs'; import { deployableRelease, releaseName, sha256 } from './deployment.mjs'; export const shellQuote=value=>"'"+String(value).replaceAll("'","'\\''")+"'"; export function sshTarget(host,directory){ if(typeof host!=='string'||!/^([A-Za-z0-9._-]+@)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host))throw new Error('Use a safe SSH host/alias or user@host (ports and keys belong in SSH config)'); if(typeof directory!=='string'||!directory.startsWith('/')||directory==='/'||/[\\\r\n\0:]/.test(directory)||directory.split('/').slice(1).some(part=>!part||part==='.'||part==='..'))throw new Error('Use an absolute normalized non-root remote directory'); return{host,directory}; } const sshArgs=['-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ConnectTimeout=15','-o','ServerAliveInterval=15','-o','ServerAliveCountMax=3']; function command(program,args,{input,env=process.env}={}){ try{return execFileSync(program,args,{input,env,encoding:'utf8',stdio:['pipe','pipe','pipe'],maxBuffer:32*1024*1024});} catch(error){ let remoteMessage; for(const line of error.stdout?.toString().split('\n')??[])if(line.startsWith('MCPACK:'))try{const message=JSON.parse(line.slice(7));if(message.type==='error')remoteMessage=message.message;}catch{} throw new Error(`${program} failed: ${remoteMessage||error.stderr?.toString().trim()||error.message}`); } } export function agentBundle(repository,config){ const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'mcpack-agent-')); try{ for(const file of ['platform/runtime.mjs','platform/backups.mjs','platform/health.mjs','platform/deployment.mjs','platform/rollback.mjs','platform/compatibility.json','scripts/deploy-worker.mjs','scripts/server.mjs','scripts/server.sh','.tools/docker-compose','package.json']){ const destination=path.join(temporary,file);fs.mkdirSync(path.dirname(destination),{recursive:true});fs.copyFileSync(path.join(repository,file),destination); } fs.writeFileSync(path.join(temporary,'platform/config.yaml'),`deploy:\n healthcheck_timeout: ${config.deploy.healthcheck_timeout}\n`); fs.mkdirSync(path.join(temporary,'node_modules')); fs.cpSync(fs.realpathSync(path.join(repository,'node_modules/yaml')),path.join(temporary,'node_modules/yaml'),{recursive:true}); const entries=[]; const walk=(directory,relative='')=>{for(const entry of fs.readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,entry.name),name=relative?relative+'/'+entry.name:entry.name;if(entry.isDirectory())walk(file,name);else if(entry.isFile())entries.push({path:name,sha256:sha256(file)});else throw new Error('Agent contains links/special files');}}; walk(temporary);entries.sort((a,b)=>a.path.localeCompare(b.path)); fs.writeFileSync(path.join(temporary,'agent-files.json'),JSON.stringify(entries)); return{directory:temporary,identity:sha256(path.join(temporary,'agent-files.json'))}; }catch(error){fs.rmSync(temporary,{recursive:true,force:true});throw error;} } function upload(source,host,destination,connectionArgs=sshArgs){ // --secluded-args protects remote paths; SSH destination itself is validated. return command('rsync',['--archive','--secluded-args','--delete','-e',`ssh ${connectionArgs.map(shellQuote).join(' ')}`,source+path.sep,host+':'+destination+'/']); } const bootstrap=` const fs=require('fs'),path=require('path'),cp=require('child_process'); const root=process.argv[1]; if(Number(process.versions.node.split('.')[0])<22)throw Error('Host requires Node.js 22+'); let current='/';for(const part of root.split('/').filter(Boolean)){current=path.join(current,part);if(fs.lstatSync(current).isSymbolicLink())throw Error('Remote root contains symlink');} if(!fs.statSync(root).isDirectory())throw Error('Prepare remote root'); fs.accessSync(root,fs.constants.W_OK);fs.accessSync(path.join(root,'.env'),fs.constants.R_OK); for(const [cmd,args]of [['rsync',['--version']],['tar',['--version']],['zstd',['--version']],['docker',['info']]])cp.execFileSync(cmd,args,{stdio:'pipe'}); fs.mkdirSync(path.join(root,'.mcpack'),{recursive:true,mode:0o700}); if(fs.lstatSync(path.join(root,'.mcpack')).isSymbolicLink())throw Error('Unsafe agent directory'); `; const verifyAgent=` const fs=require('fs'),path=require('path'),crypto=require('crypto'); const [dir,expected]=process.argv.slice(1),hash=file=>crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if(hash(path.join(dir,'agent-files.json'))!==expected)throw Error('Agent checksum manifest mismatch'); const entries=JSON.parse(fs.readFileSync(path.join(dir,'agent-files.json'))),actual=[]; const walk=(base,rel='')=>{for(const entry of fs.readdirSync(base,{withFileTypes:true})){const file=path.join(base,entry.name),name=rel?rel+'/'+entry.name:entry.name;if(entry.isDirectory())walk(file,name);else if(entry.isFile())actual.push(name);else throw Error('Unsafe agent file');}}; walk(dir); if(JSON.stringify(actual.filter(n=>n!=='agent-files.json').sort())!==JSON.stringify(entries.map(e=>e.path).sort()))throw Error('Agent coverage mismatch'); for(const entry of entries){if(entry.path.split('/').some(p=>p==='..'||!p)||entry.path.startsWith('/'))throw Error('Unsafe agent path');if(hash(path.join(dir,entry.path))!==entry.sha256)throw Error('Agent file mismatch');} fs.chmodSync(path.join(dir,'.tools/docker-compose'),0o755); `; export async function deploy({root:repository,configFile='platform/config.yaml',artifact,sshConfig,remoteNode='node'}){ if (remoteNode !== 'node' && (!path.posix.isAbsolute(remoteNode) || /[\\\r\n\0]/.test(remoteNode))) throw new Error('remote-node must be node or an absolute executable path'); const connectionArgs = sshConfig ? ['-F', path.resolve(repository, sshConfig), ...sshArgs] : sshArgs; const nodeCommand = shellQuote(remoteNode); const document=parseDocument(fs.readFileSync(path.resolve(repository,configFile),'utf8')); if(document.errors.length)throw new Error('Invalid deploy YAML'); const config=validateConfig(document.toJS()),{host,directory}=sshTarget(config.server.host,config.server.directory); let releasePath=artifact?path.resolve(repository,artifact):null; if(!releasePath){ // Reuse the ready immutable artifact when present; build only when absent. const {parse}=await import('smol-toml'); const pack=parse(fs.readFileSync(path.resolve(repository,config.pack.directory,'pack.toml'),'utf8')); const expected=path.resolve(repository,config.build.output_directory,`${pack.name}-${pack.version}`); releasePath=fs.existsSync(expected)?expected:build({root:repository,configFile,releaseOnly:true}); } const release=deployableRelease(releasePath),name=releaseName(release.manifest); const bundle=agentBundle(repository,config); let agent; try{ command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(bootstrap)} ${shellQuote(directory)}`]); agent=directory+'/.mcpack/agent-'+crypto.randomUUID(); command('ssh',[...connectionArgs,host,`mkdir -m 700 -- ${shellQuote(agent)}`]); upload(bundle.directory,host,agent,connectionArgs); command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(verifyAgent)} ${shellQuote(agent)} ${shellQuote(bundle.identity)}`]); const releaseBytes=[...release.manifest.release_files.map(file=>file.path),'manifest.json','SHA256SUMS'].reduce((total,file)=>total+fs.statSync(path.join(releasePath,file)).size,0); const request={root:directory,targetName:name,targetIdentity:release.identity,targetChecksums:sha256(path.join(releasePath,'SHA256SUMS')),releaseBytes,timeout:config.deploy.healthcheck_timeout}; const child=spawn('ssh',[...connectionArgs,host,`${nodeCommand} ${shellQuote(agent+'/scripts/deploy-worker.mjs')}`],{stdio:['pipe','pipe','inherit']}); child.stdin.on('error',()=>{}); const completion=new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',(code,signal)=>resolve({code,signal}));}); child.stdin.write(JSON.stringify(request)+'\n'); let completed,remoteError; for await(const line of createInterface({input:child.stdout,terminal:false})){ if(!line.startsWith('MCPACK:')){console.log(line);continue;} const message=JSON.parse(line.slice(7)); if(message.type==='ready'){ if(typeof message.stage!=='string'||path.posix.dirname(message.stage)!==directory+'/releases'||!/^\.incoming-[A-Za-z0-9]+$/.test(path.posix.basename(message.stage))){child.stdin.end();throw new Error('Remote requested unsafe upload path');} try{upload(releasePath,host,message.stage,connectionArgs);child.stdin.write('{"type":"uploaded"}\n');} catch(error){remoteError=error.message;child.stdin.end('{"type":"abort"}\n');} }else if(message.type==='complete'){completed=message.state;child.stdin.end();} else if(message.type==='error'){remoteError=message.message;child.stdin.end();} } const exit=await completion; if(remoteError||!completed||exit.code!==0)throw new Error(remoteError||`SSH interrupted (exit ${exit.code}, signal ${exit.signal}); inspect ${directory}/deployment-state.json before retry`); console.log(`Deployment ${completed.noop?'already healthy':'complete'}: ${host}:${directory}/releases/${name}`); return completed; }finally{ // Keep remote agent for recovery. No secrets or host env are uploaded. fs.rmSync(bundle.directory,{recursive:true,force:true}); } } export async function rollback({root:repository,command:operation='rollback',configFile='platform/config.yaml',sshConfig,remoteNode='node',backupId,version,confirmCompatible=false,acceptProgressLoss=false}){ if(remoteNode!=='node'&&(!path.posix.isAbsolute(remoteNode)||/[\\\r\n\0]/.test(remoteNode)))throw new Error('Invalid remote Node path'); const document=parseDocument(fs.readFileSync(path.resolve(repository,configFile),'utf8')); if(document.errors.length)throw new Error('Invalid rollback config'); const config=validateConfig(document.toJS()),{host,directory}=sshTarget(config.server.host,config.server.directory); const connectionArgs=sshConfig?['-F',path.resolve(repository,sshConfig),...sshArgs]:sshArgs,nodeCommand=shellQuote(remoteNode); const request={root:directory,operation:'rollback',mode:operation==='rollback-pack'?'pack':'snapshot',backupId,version,confirmCompatible,acceptProgressLoss,timeout:config.deploy.healthcheck_timeout}; const apply=request.mode==='pack'?confirmCompatible:Boolean(backupId&&acceptProgressLoss); if(!apply){ const script=`const fs=require('fs'),path=require('path');console.log(JSON.stringify((${inspectRollback.toString()})(process.argv[1],JSON.parse(process.argv[2]),fs,path),null,2));`; const output=command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(script)} ${shellQuote(directory)} ${shellQuote(JSON.stringify({backupId,version}))}`]); console.log(output.trim()); console.log(request.mode==='pack'?'Plan only. Applying requires --confirm-compatible; SemVer does not prove world compatibility.':'Plan only. Applying a snapshot requires --backup ID --accept-progress-loss; all runtime progress since its timestamp will be replaced.'); return JSON.parse(output); } if(request.mode==='snapshot')console.log(`Snapshot ${backupId}: replacing runtime will lose progress after its timestamp. Current state will be backed up first.`); else console.log(`Pack-only rollback to ${version}: world compatibility explicitly confirmed; current runtime will not be restored.`); const bundle=agentBundle(repository,config); try{ command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(bootstrap)} ${shellQuote(directory)}`]); const agent=directory+'/.mcpack/agent-'+crypto.randomUUID(); command('ssh',[...connectionArgs,host,`mkdir -m 700 -- ${shellQuote(agent)}`]); upload(bundle.directory,host,agent,connectionArgs); command('ssh',[...connectionArgs,host,`${nodeCommand} -e ${shellQuote(verifyAgent)} ${shellQuote(agent)} ${shellQuote(bundle.identity)}`]); const output=command('ssh',[...connectionArgs,host,`${nodeCommand} ${shellQuote(agent+'/scripts/deploy-worker.mjs')}`],{input:JSON.stringify(request)+'\n'}); let result,error; for(const line of output.split('\n')){ if(!line.startsWith('MCPACK:')){if(line)console.log(line);continue;} const message=JSON.parse(line.slice(7));if(message.type==='complete')result=message.state;else if(message.type==='error')error=message.message; } if(error||!result)throw new Error(error||'Rollback SSH interrupted; inspect rollback-state.json before recovery'); console.log(`Rollback complete: ${result.target_release}. Current-state backup: ${result.backup_snapshot}`); return result; }finally{fs.rmSync(bundle.directory,{recursive:true,force:true});} }