first cycle lab release
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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});}
|
||||
}
|
||||
Reference in New Issue
Block a user