300 lines
25 KiB
JavaScript
300 lines
25 KiB
JavaScript
import {test} from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {mkdtemp,mkdir,writeFile,readFile,cp,symlink,rm,stat} from 'node:fs/promises';
|
||
import {join} from 'node:path';
|
||
import {tmpdir} from 'node:os';
|
||
import {spawnSync} from 'node:child_process';
|
||
import {createHash} from 'node:crypto';
|
||
import {gzipSync} from 'node:zlib';
|
||
import {packageStandalone} from '../scripts/package-standalone.mjs';
|
||
const projectRoot=new URL('../',import.meta.url);
|
||
async function fixture(t) {
|
||
const root=await mkdtemp(join(tmpdir(),'91-release-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||
async function put(path,text='fixture') {await mkdir(join(root,path,'..'),{recursive:true});await writeFile(join(root,path),text);}
|
||
for(const [env,build] of [['test','.next-test'],['production','.next']]) {
|
||
await put(`${build}/standalone/server.js`,'module.exports=true;');
|
||
await put(`${build}/standalone/${build}/BUILD_ID`,'build-'+env);
|
||
await put(`${build}/standalone/${build}/cache/seo/private.json`);
|
||
await put(`${build}/standalone/.env.local`,'SENSITIVE=do-not-ship');
|
||
await put(`${build}/standalone/config/secrets/private-key`);
|
||
await put(`${build}/standalone/dist/production/config/production.local.env`);
|
||
await put(`${build}/standalone/tests/fixtures/private.json`);
|
||
await put(`${build}/standalone/${build==='.next'?'.next-test':'.next'}/cache/seo/old.json`);
|
||
await put(`${build}/static/chunks/app.js`);
|
||
await put(`config/${env}.env`,`API_BASE_URL=https://api.test\nPORT=21021\n`);
|
||
await put(`config/${env}.local.env`,'SENSITIVE=do-not-ship');
|
||
}
|
||
await put('public/assets/logo.svg');await put('public/._logo.svg');
|
||
await put('package.json','{}');await put('package-lock.json','{}');
|
||
await cp(new URL('scripts',projectRoot),join(root,'scripts'),{recursive:true});
|
||
await cp(new URL('node_modules/dotenv',projectRoot),join(root,'node_modules/dotenv'),{recursive:true});
|
||
return {root,put};
|
||
}
|
||
|
||
test('standalone releases include only their profile and assets, exclude local secrets/cache, and preserve other releases',async t=>{
|
||
const {root,put}=await fixture(t);
|
||
for(const [env,build] of [['test','.next-test'],['production','.next']]) {
|
||
const release=await packageStandalone(env,{root});
|
||
for(const file of ['start.mjs','server.js','package-lock.json','node_modules/dotenv/package.json','scripts/check-health.mjs',build+'/BUILD_ID',build+'/static/chunks/app.js','public/assets/logo.svg',`config/${env}.env`])assert.ok((await stat(join(release,file))).isFile(),file);
|
||
for(const file of ['.env.local','public/._logo.svg',`config/${env}.local.env`,'config/secrets','dist','tests',build+'/cache',(build==='.next'?'.next-test':'.next')])await assert.rejects(stat(join(release,file)),{code:'ENOENT'});
|
||
const launch=spawnSync(process.execPath,[join(release,'start.mjs')],{encoding:'utf8',env:{PATH:process.env.PATH}});
|
||
assert.equal(launch.status,0,launch.stderr);
|
||
}
|
||
await put('dist/production/keep','previous production');
|
||
await put('dist/test/stale','remove stale file');await packageStandalone('test',{root});
|
||
assert.equal(await readFile(join(root,'dist/production/keep'),'utf8'),'previous production');
|
||
await assert.rejects(stat(join(root,'dist/test/stale')),{code:'ENOENT'});
|
||
});
|
||
|
||
test('invalid environments and symlink destinations fail without deleting unrelated data',async t=>{
|
||
const {root,put}=await fixture(t);
|
||
await put('outside/keep','do not delete');await mkdir(join(root,'dist'));
|
||
await symlink(join(root,'outside'),join(root,'dist/test'));
|
||
await assert.rejects(packageStandalone('test',{root}),/Unsafe release directory/);
|
||
await assert.rejects(packageStandalone('../outside',{root}),/Usage/);
|
||
assert.equal(await readFile(join(root,'outside/keep'),'utf8'),'do not delete');
|
||
await rm(join(root,'dist'),{recursive:true});await symlink(join(root,'outside'),join(root,'dist'));
|
||
await assert.rejects(packageStandalone('production',{root}),/Unsafe release directory/);
|
||
assert.equal(await readFile(join(root,'outside/keep'),'utf8'),'do not delete');
|
||
});
|
||
|
||
test('dry runs reflect current configuration, allow overrides and never build/upload; invalid deployment targets fail early',async t=>{
|
||
const dir=await mkdtemp(join(tmpdir(),'91-deploy-stubs-'));t.after(()=>rm(dir,{recursive:true,force:true}));
|
||
const marker=join(dir,'executed');
|
||
for(const command of ['npm','ssh','scp'])await writeFile(join(dir,command),`#!/bin/bash\nprintf '%s\\n' '${command}' >> "$MARKER"\nexit 99\n`,{mode:0o755});
|
||
const env={...process.env,PATH:dir+':'+process.env.PATH,MARKER:marker,DEPLOY_DRY_RUN:'0'};
|
||
for(const key of ['DEPLOY_HOST','DEPLOY_DIR','DEPLOY_RUN_DIR','DEPLOY_JUMP_HOST','DEPLOY_JUMP_DIR','DEPLOY_SSH_PORT','DEPLOY_SCP_PORT','DEPLOY_CHECK_URL','DEPLOY_PROGRAM','DEPLOY_PACKAGE_NAME','DEPLOY_TARGET_ARCH','DEPLOY_TARGET_LIBC'])delete env[key];
|
||
// Deployment addresses/ports are operator configuration, not application invariants.
|
||
// Verify reporting consistency and explicit overrides rather than pinning a server.
|
||
for(const script of ['deploy_test.sh','deploy_prod.sh']) {
|
||
for(const args of [['--dry-run'],['--package-only','--dry-run']]) {
|
||
const result=spawnSync('bash',[script,...args],{encoding:'utf8',env});
|
||
assert.equal(result.status,0,result.stderr);assert.match(result.stdout,/API=(?:current|https?:\/\/\S+)\s+PORT=\d+/);
|
||
assert.doesNotMatch(result.stdout,/undefined/);
|
||
if(!args.includes('--package-only')) {
|
||
const help=spawnSync('bash',[script,'--help'],{encoding:'utf8',env});assert.equal(help.status,0,help.stderr);
|
||
if(script==='deploy_test.sh') {
|
||
assert.match(result.stdout,/上传 \S+\.tar\.gz、\S+\.tar\.gz\.sha256 和 up_next_h5\.sh/);
|
||
assert.doesNotMatch(result.stdout,/dist\.zip/);
|
||
const target=result.stdout.match(/发布目标:(\S+):(\/[^(\n]+)(SSH (\d+))。/);
|
||
assert.ok(target,`${script} 未报告有效上传目标:${result.stdout}`);
|
||
const [,host,directory,port]=target;
|
||
assert.ok(Number(port)>0&&Number(port)<=65535);
|
||
assert.ok(help.stdout.includes(`${host}:${port} ${directory.replace(/\/$/,'')}/`),help.stdout);
|
||
const execute=result.stdout.match(/上传成功后:ssh -p (\d+) (\S+) "cd (\/\S+) && bash run\.sh"/);
|
||
assert.ok(execute,result.stdout);assert.equal(execute[1],port);assert.equal(execute[2],host);
|
||
assert.ok(help.stdout.includes(`DEPLOY_RUN_DIR(${execute[3]})`),help.stdout);
|
||
}else {
|
||
const upload=result.stdout.match(/^\s+scp -P (\d+) (\S+) (\S+) ([^:\s]+):(\/\S*)$/m);
|
||
const execute=result.stdout.match(/^\s+ssh -p (\d+) (\S+) "cd (\/\S+) && bash rsupdate\.sh"$/m);
|
||
assert.ok(upload,`${script} 未报告有效上传命令:${result.stdout}`);
|
||
assert.ok(execute,`${script} 未报告有效执行命令:${result.stdout}`);
|
||
const [,scpPort,archive,checksum,host,directory]=upload;
|
||
const [,sshPort,sshHost,runDirectory]=execute;
|
||
assert.equal(sshHost,host);assert.equal(runDirectory,directory.replace(/\/$/,''));
|
||
assert.equal(checksum,archive.replace(/\.tar\.gz$/,'.md5'));
|
||
assert.ok(help.stdout.includes(`${host}:${directory}`),help.stdout);
|
||
for(const [key,value] of [['DEPLOY_SCP_PORT',scpPort],['DEPLOY_SSH_PORT',sshPort],['DEPLOY_PACKAGE_NAME',archive]])assert.ok(help.stdout.includes(`${key}(${value})`),help.stdout);
|
||
}
|
||
}
|
||
}
|
||
const customEnv={...env,DEPLOY_HOST:'user@custom-test',DEPLOY_DIR:'/srv/custom-test',DEPLOY_RUN_DIR:'/srv/custom-run',DEPLOY_JUMP_HOST:'user@custom-prod',DEPLOY_JUMP_DIR:'/srv/custom-prod/',DEPLOY_SSH_PORT:'2202',DEPLOY_SCP_PORT:'2203',DEPLOY_PACKAGE_NAME:'custom_package.tar.gz'};
|
||
const custom=spawnSync('bash',[script,'--dry-run'],{encoding:'utf8',env:customEnv});
|
||
assert.equal(custom.status,0,custom.stderr);
|
||
assert.ok(custom.stdout.includes(script==='deploy_test.sh'?'user@custom-test:/srv/custom-test':'user@custom-prod:/srv/custom-prod/'));
|
||
assert.ok(custom.stdout.includes('2202'));if(script==='deploy_prod.sh')assert.ok(custom.stdout.includes('2203'));
|
||
assert.ok(custom.stdout.includes('custom_package.tar.gz'));
|
||
if(script==='deploy_test.sh')assert.ok(custom.stdout.includes('custom_package.tar.gz.sha256'));
|
||
const customHelp=spawnSync('bash',[script,'--help'],{encoding:'utf8',env:customEnv});assert.equal(customHelp.status,0,customHelp.stderr);
|
||
assert.ok(customHelp.stdout.includes('2202'));assert.ok(customHelp.stdout.includes(script==='deploy_test.sh'?'/srv/custom-run':'custom_package.tar.gz'));
|
||
if(script==='deploy_test.sh')assert.match(custom.stdout,/ssh -p 2202 user@custom-test "cd \/srv\/custom-run && bash run\.sh"/);
|
||
else assert.match(custom.stdout,/ssh -p 2202 user@custom-prod "cd \/srv\/custom-prod && bash rsupdate\.sh"/);
|
||
assert.ok(customHelp.stdout.includes('custom_package.tar.gz'));
|
||
const invalidArchive=spawnSync('bash',[script,'--package-only'],{encoding:'utf8',env:{...env,DEPLOY_PACKAGE_NAME:'../invalid.zip'}});
|
||
assert.notEqual(invalidArchive.status,0);assert.match(invalidArchive.stderr,/\.tar\.gz 文件名/);
|
||
const invalidHost=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_HOST:'bad;host',DEPLOY_JUMP_HOST:'bad;host'}});
|
||
assert.notEqual(invalidHost.status,0);assert.match(invalidHost.stderr,/格式无效/);
|
||
const invalid=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_HOST:'user@server',DEPLOY_DIR:'/srv/../other',DEPLOY_JUMP_HOST:'user@server',DEPLOY_JUMP_DIR:'/srv/../other'}});
|
||
assert.notEqual(invalid.status,0);assert.match(invalid.stderr,/绝对路径/);
|
||
const invalidPort=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_SSH_PORT:'0'}});
|
||
assert.notEqual(invalidPort.status,0);assert.match(invalidPort.stderr,/1–65535/);
|
||
if(script==='deploy_test.sh'){
|
||
const invalidRun=spawnSync('bash',[script],{encoding:'utf8',env:{...env,DEPLOY_RUN_DIR:'/srv/../other'}});
|
||
assert.notEqual(invalidRun.status,0);assert.match(invalidRun.stderr,/DEPLOY_RUN_DIR 必须是普通绝对路径/);
|
||
}
|
||
}
|
||
await assert.rejects(stat(marker),{code:'ENOENT'});
|
||
});
|
||
|
||
test('test deployment uploads tar.gz and checksum before run.sh, preserves its execution directory, and stops on failure',async t=>{
|
||
const {root}=await fixture(t),bin=join(root,'bin'),marker=join(root,'commands.jsonl');
|
||
await mkdir(bin);await mkdir(join(root,'deploy'));await mkdir(join(root,'node_modules/next'));
|
||
await cp(new URL('deploy_test.sh',projectRoot),join(root,'deploy_test.sh'));
|
||
await cp(new URL('deploy/up_next_h5.sh',projectRoot),join(root,'deploy/up_next_h5.sh'));
|
||
for(const name of ['npm','ssh','scp']) {
|
||
const code=`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args,testEnvironment:process.env.TEST_APP_ENV})+'\\n');`+
|
||
(name==='npm'?`if(args.join(' ')==='run bundle:test'){const r=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','test'],{stdio:'inherit'});process.exit(r.status??1);}`:
|
||
name==='scp'?`process.exit(Number(process.env.SCP_EXIT||0));`:
|
||
`if(args.at(-1).includes('bash run.sh'))process.exit(Number(process.env.RUN_EXIT||0));`);
|
||
await writeFile(join(bin,name),'#!'+process.execPath+'\n'+code+'\n',{mode:0o755});
|
||
}
|
||
const archive='fixture_test.tar.gz';
|
||
const env={...process.env,PATH:bin+':'+process.env.PATH,MARKER:marker,DEPLOY_DRY_RUN:'0',DEPLOY_HOST:'fixture-host',DEPLOY_DIR:'/srv/fixture',DEPLOY_SSH_PORT:'2202',DEPLOY_PACKAGE_NAME:archive,DEPLOY_CHECK_URL:'',SCP_EXIT:'0',RUN_EXIT:'0'};
|
||
delete env.DEPLOY_RUN_DIR;
|
||
const run=(options={},args=[])=>spawnSync('bash',['deploy_test.sh',...args],{cwd:root,encoding:'utf8',env:{...env,...options}});
|
||
const commands=async()=>(await readFile(marker,'utf8')).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));
|
||
// Server directories are operator configuration. Compare actual execution with
|
||
// the script's advertised default rather than pinning a historical path.
|
||
const defaultRunDirectory=()=>{
|
||
const help=run({},['--help']);assert.equal(help.status,0,help.stderr);
|
||
const configured=help.stdout.match(/DEPLOY_RUN_DIR(([^)]+))/);
|
||
assert.ok(configured,help.stdout);return configured[1];
|
||
};
|
||
const runDirectory=defaultRunDirectory();
|
||
const result=run();
|
||
assert.equal(result.status,0,result.stdout+result.stderr);
|
||
const calls=await commands();
|
||
const npm=calls.filter(call=>call.command==='npm');assert.deepEqual(npm.map(call=>call.args),[['run','build:test'],['test'],['run','bundle:test']]);assert.equal(npm[1].testEnvironment,'test');
|
||
assert.deepEqual(calls.filter(call=>call.command==='scp').map(call=>call.args),[['-P','2202',archive,archive+'.sha256','deploy/up_next_h5.sh','fixture-host:/srv/fixture/']]);
|
||
assert.deepEqual(calls.filter(call=>call.command==='ssh').map(call=>call.args),[['-p','2202','fixture-host',"mkdir -p '/srv/fixture'"],['-p','2202','fixture-host',`cd '${runDirectory}' && bash run.sh`]]);
|
||
assert.deepEqual(calls.map(call=>call.command),['npm','npm','npm','ssh','scp','ssh']);
|
||
assert.match(result.stdout,/测试服 run\.sh 执行完成/);
|
||
const bytes=await readFile(join(root,archive));assert.equal(bytes.subarray(0,2).toString('hex'),'1f8b');
|
||
assert.equal(await readFile(join(root,archive+'.sha256'),'utf8'),createHash('sha256').update(bytes).digest('hex')+' '+archive+'\n');
|
||
assert.equal(await readFile(join(root,archive.replace(/\.tar\.gz$/,'.md5')),'utf8'),createHash('md5').update(bytes).digest('hex')+'\n');
|
||
const contents=spawnSync('tar',['-tzf',join(root,archive)],{encoding:'utf8'});assert.equal(contents.status,0,contents.stderr);
|
||
assert.match(contents.stdout,/\.next-test\/BUILD_ID/);assert.match(contents.stdout,/config\/test\.env/);
|
||
assert.doesNotMatch(contents.stdout,/node_modules|\.local\.env|\/cache\//);
|
||
for(const [name,options,args,status] of [
|
||
['custom-run-directory',{DEPLOY_RUN_DIR:'/srv/custom-run'},[],0],
|
||
['upload-failed',{SCP_EXIT:'23'},[],23],
|
||
['run-failed',{RUN_EXIT:'42'},[],42],
|
||
['package-only',{},['--package-only'],0],
|
||
])await t.test(name,async()=>{
|
||
await writeFile(marker,'');const result=run(options,args);assert.equal(result.status,status,result.stdout+result.stderr);
|
||
const calls=await commands(),ssh=calls.filter(call=>call.command==='ssh');
|
||
if(name==='package-only')assert.ok(!calls.some(call=>['ssh','scp'].includes(call.command)));
|
||
else if(name==='upload-failed'){
|
||
assert.equal(ssh.length,1);assert.doesNotMatch(ssh[0].args.at(-1),/run\.sh/);assert.doesNotMatch(result.stdout,/上传完成|执行完成/);
|
||
}else{
|
||
assert.equal(ssh.length,2);assert.equal(calls.at(-1).command,'ssh');
|
||
assert.equal(ssh[1].args.at(-1),`cd '${options.DEPLOY_RUN_DIR||runDirectory}' && bash run.sh`);
|
||
if(name==='run-failed')assert.doesNotMatch(result.stdout,/执行完成/);
|
||
}
|
||
});
|
||
await t.test('changing the configured default directory does not require updating path assertions',async()=>{
|
||
const script=join(root,'deploy_test.sh'),original=await readFile(script,'utf8');
|
||
const changed=original.replace(/^RUN_DIR=.*$/m,'RUN_DIR="${DEPLOY_RUN_DIR:-/srv/operator-default}"');
|
||
assert.notEqual(changed,original);
|
||
try{
|
||
await writeFile(script,changed);await writeFile(marker,'');
|
||
const directory=defaultRunDirectory();assert.equal(directory,'/srv/operator-default');
|
||
const result=run();assert.equal(result.status,0,result.stdout+result.stderr);
|
||
const calls=await commands();assert.equal(calls.at(-1).command,'ssh');
|
||
assert.equal(calls.at(-1).args.at(-1),`cd '${directory}' && bash run.sh`);
|
||
}finally{await writeFile(script,original);}
|
||
});
|
||
});
|
||
|
||
test('production uploads before running the exact allowed SSH command, and stops on failure',async t=>{
|
||
const {root}=await fixture(t),bin=join(root,'bin'),marker=join(root,'production-commands.jsonl');
|
||
await mkdir(bin);await mkdir(join(root,'node_modules/next'));
|
||
await cp(new URL('deploy_prod.sh',projectRoot),join(root,'deploy_prod.sh'));
|
||
for(const name of ['npm','ssh','scp']){
|
||
const code=`const fs=require('node:fs'),args=process.argv.slice(2);fs.appendFileSync(process.env.MARKER,JSON.stringify({command:${JSON.stringify(name)},args})+'\\n');`+
|
||
(name==='npm'?`if(args.join(' ')==='run bundle'){const result=require('node:child_process').spawnSync(process.execPath,['scripts/package-standalone.mjs','production'],{stdio:'inherit'});process.exit(result.status??1);}if(args[1]==='bundle:linux'){const destination='dist/production-linux-x64-glibc';fs.rmSync(destination,{recursive:true,force:true});fs.cpSync('dist/production',destination,{recursive:true});fs.writeFileSync(destination+'/runtime-target.json',JSON.stringify({platform:'linux',arch:'x64',libc:'glibc'}));}`:
|
||
name==='scp'?`process.exit(Number(process.env.SCP_EXIT||0));`:
|
||
`if(args.at(-1)!==process.env.ALLOWED_REMOTE_COMMAND){console.error('权限不足');process.exit(1);}process.stdout.write(process.env.RUN_OUTPUT||'');process.exit(Number(process.env.RUN_EXIT||0));`);
|
||
await writeFile(join(bin,name),'#!'+process.execPath+'\n'+code+'\n',{mode:0o755});
|
||
}
|
||
const archive='fixture_production.tar.gz';
|
||
const env={...process.env,PATH:bin+':'+process.env.PATH,MARKER:marker,DEPLOY_DRY_RUN:'0',DEPLOY_JUMP_HOST:'fixture-host',DEPLOY_JUMP_DIR:'/srv/production/',DEPLOY_SCP_PORT:'2203',DEPLOY_SSH_PORT:'2202',DEPLOY_TARGET_ARCH:'x64',DEPLOY_TARGET_LIBC:'glibc',DEPLOY_PACKAGE_NAME:archive,ALLOWED_REMOTE_COMMAND:'cd /srv/production && bash rsupdate.sh',SCP_EXIT:'0',RUN_EXIT:'0',RUN_OUTPUT:''};
|
||
const run=(options={},args=[])=>spawnSync('bash',['deploy_prod.sh',...args],{cwd:root,encoding:'utf8',env:{...env,...options}});
|
||
const commands=async()=>(await readFile(marker,'utf8')).trim().split('\n').filter(Boolean).map(line=>JSON.parse(line));
|
||
const result=run();assert.equal(result.status,0,result.stdout+result.stderr);
|
||
const calls=await commands();
|
||
assert.deepEqual(calls.map(call=>call.command),['npm','npm','npm','scp','ssh']);
|
||
assert.deepEqual(calls.filter(call=>call.command==='npm').map(call=>call.args),[['run','check'],['run','bundle'],['run','bundle:linux','--','x64','glibc']]);
|
||
assert.deepEqual(calls.find(call=>call.command==='scp').args,['-P','2203',archive,'fixture_production.md5','fixture-host:/srv/production/']);
|
||
assert.deepEqual(calls.find(call=>call.command==='ssh').args,['-p','2202','fixture-host',env.ALLOWED_REMOTE_COMMAND]);
|
||
assert.match(result.stdout,/正式服 rsupdate\.sh 执行完成/);
|
||
assert.equal(await readFile(join(root,'fixture_production.md5'),'utf8'),createHash('md5').update(await readFile(join(root,archive))).digest('hex')+'\n');
|
||
for(const [name,options,args,status] of [
|
||
['upload-failed',{SCP_EXIT:'23'},[],23],
|
||
['remote-failed',{RUN_EXIT:'42'},[],1],
|
||
['restricted-ssh-denied',{ALLOWED_REMOTE_COMMAND:'a different command'},[],1],
|
||
['remote-error-output',{RUN_OUTPUT:'FATAL: service did not start\n'},[],1],
|
||
['remote-curl-401',{RUN_OUTPUT:'curl: (22) The requested URL returned error: 401\n发布成功\n'},[],1],
|
||
['custom-directory',{DEPLOY_JUMP_DIR:'/srv/custom-production/',ALLOWED_REMOTE_COMMAND:'cd /srv/custom-production && bash rsupdate.sh'},[],0],
|
||
['package-only',{},['--package-only'],0],
|
||
])await t.test(name,async()=>{
|
||
await writeFile(marker,'');const result=run(options,args);assert.equal(result.status,status,result.stdout+result.stderr);
|
||
const calls=await commands();
|
||
if(name==='package-only')assert.ok(!calls.some(call=>['scp','ssh'].includes(call.command)));
|
||
else if(name==='upload-failed')assert.ok(!calls.some(call=>call.command==='ssh'));
|
||
else assert.equal(calls.at(-1).command,'ssh');
|
||
if(name==='restricted-ssh-denied')assert.match(result.stdout,/权限不足/);
|
||
if(name==='custom-directory'){
|
||
assert.equal(calls.at(-1).args.at(-1),options.ALLOWED_REMOTE_COMMAND);
|
||
assert.equal(calls.find(call=>call.command==='scp').args.at(-1),'fixture-host:/srv/custom-production/');
|
||
const preview=run(options,['--dry-run']);assert.equal(preview.status,0,preview.stderr);
|
||
assert.ok(preview.stdout.includes('"'+options.ALLOWED_REMOTE_COMMAND+'"'),preview.stdout);
|
||
}
|
||
if(status)assert.doesNotMatch(result.stdout,/执行完成/);
|
||
});
|
||
});
|
||
|
||
test('tar.gz server updates reject invalid archives before stopping services and roll back unhealthy replacements',async t=>{
|
||
for(const scenario of ['checksum','archive','link','traversal','install','health','success','custom-name'])await t.test(scenario,async t=>{
|
||
const root=await mkdtemp(join(tmpdir(),'91-server-update-'));t.after(()=>rm(root,{recursive:true,force:true}));
|
||
const bin=join(root,'bin'),upload=join(root,'package');await mkdir(bin);await mkdir(upload);
|
||
async function put(base,path,text='fixture') {await mkdir(join(base,path,'..'),{recursive:true});await writeFile(join(base,path),text);}
|
||
await cp(new URL('deploy/up_next_h5.sh',projectRoot),join(root,'up_next_h5.sh'));
|
||
await put(root,'next/version','previous');await put(root,'next/config/test.local.env','PORT=21022\n');
|
||
await put(root,'next/.next-test/cache/seo/keep.json','previous public SEO');
|
||
for(const file of ['start.mjs','server.js','.next-test/BUILD_ID','.next-test/static/app.js','public/logo.svg','package-lock.json'])await put(upload,file);
|
||
await put(upload,'package.json','{}');await put(upload,'version','replacement');
|
||
await put(upload,'config/test.env','API_BASE_URL=https://fixture.test\nPORT=21021\n');
|
||
await cp(new URL('scripts/next-env.mjs',projectRoot),join(upload,'scripts/next-env.mjs'));
|
||
await put(upload,'scripts/check-health.mjs','process.exit(Number(process.env.HEALTH_EXIT||0));');
|
||
const packageName=scenario==='custom-name'?'custom_release.tar.gz':'web_91dj_h5.tar.gz';
|
||
if(scenario==='link')await symlink(join(root,'untouched'),join(upload,'public/unsafe-link'));
|
||
const packed=spawnSync('tar',['--no-xattrs','-czf',join(root,packageName),'-C',upload,'.'],{encoding:'utf8',env:{...process.env,COPYFILE_DISABLE:'1'}});assert.equal(packed.status,0,packed.stderr);
|
||
if(scenario==='archive')await writeFile(join(root,packageName),'not a gzip archive');
|
||
if(scenario==='traversal') {
|
||
const header=Buffer.alloc(512);
|
||
for(const [offset,value]of [[0,'../escaped-file'],[100,'0000644\0'],[108,'0000000\0'],[116,'0000000\0'],[124,'00000000000\0'],[136,'00000000000\0'],[257,'ustar\0'],[263,'00']])header.write(value,offset);
|
||
header.fill(32,148,156);header[156]=48;
|
||
header.write([...header].reduce((total,byte)=>total+byte,0).toString(8).padStart(6,'0')+'\0 ',148);
|
||
await writeFile(join(root,packageName),gzipSync(Buffer.concat([header,Buffer.alloc(1024)])));
|
||
}
|
||
const hash=createHash('sha256').update(await readFile(join(root,packageName))).digest('hex');
|
||
await writeFile(join(root,packageName+'.sha256'),(scenario==='checksum'?'0'.repeat(64):hash)+' '+packageName+'\n');
|
||
const stubs={
|
||
npm:`const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,'npm\\n');if(process.env.INSTALL_FAIL==='1')process.exit(1);fs.mkdirSync('node_modules',{recursive:true});fs.cpSync(process.env.DOTENV_SOURCE,'node_modules/dotenv',{recursive:true});`,
|
||
supervisorctl:`const fs=require('node:fs');fs.appendFileSync(process.env.MARKER,process.argv.slice(2).join(' ')+'\\n');if(process.argv[2]==='status')console.log('91dj_next_h5 RUNNING pid 100');`,
|
||
sha256sum:`const fs=require('node:fs'),crypto=require('node:crypto');const expected=fs.readFileSync(process.argv[3],'utf8').split(/\\s/)[0],actual=crypto.createHash('sha256').update(fs.readFileSync(process.argv[3].replace(/\\.sha256$/,''))).digest('hex');process.exit(expected===actual?0:1);`,
|
||
};
|
||
for(const [name,code]of Object.entries(stubs))await writeFile(join(bin,name),'#!'+process.execPath+'\n'+code+'\n',{mode:0o755});
|
||
const marker=join(root,'commands');
|
||
// The fake Supervisor has its own service; caller deployment overrides must not select a real service here.
|
||
const result=spawnSync('bash',[join(root,'up_next_h5.sh')],{encoding:'utf8',env:{...process.env,DEPLOY_PROGRAM:'91dj_next_h5',DEPLOY_PACKAGE_NAME:packageName,PATH:bin+':'+process.env.PATH,MARKER:marker,DOTENV_SOURCE:new URL('node_modules/dotenv',projectRoot).pathname,INSTALL_FAIL:scenario==='install'?'1':'0',HEALTH_EXIT:scenario==='health'?'1':'0'}});
|
||
const commands=await readFile(marker,'utf8');
|
||
if(['success','custom-name'].includes(scenario)) {
|
||
assert.equal(result.status,0,result.stderr);assert.equal(await readFile(join(root,'next/version'),'utf8'),'replacement');
|
||
assert.equal(await readFile(join(root,'next/config/test.local.env'),'utf8'),'PORT=21022\n');
|
||
assert.equal(await readFile(join(root,'next/.next-test/cache/seo/keep.json'),'utf8'),'previous public SEO');
|
||
}else {
|
||
assert.notEqual(result.status,0);assert.equal(await readFile(join(root,'next/version'),'utf8'),'previous');
|
||
if(scenario==='health')assert.equal(commands.match(/start 91dj_next_h5/g)?.length,2,commands);
|
||
else assert.doesNotMatch(commands,/stop |start /);
|
||
if(['checksum','archive','link','traversal'].includes(scenario))assert.doesNotMatch(commands,/npm/);
|
||
if(scenario==='archive')assert.match(result.stderr,/tar.gz 压缩包损坏/);
|
||
if(scenario==='link')assert.match(result.stderr,/不允许链接/);
|
||
if(scenario==='traversal')assert.match(result.stderr,/非法 tar.gz 路径/);
|
||
}
|
||
await assert.rejects(stat(join(root,'escaped-file')),{code:'ENOENT'});
|
||
await assert.rejects(stat(join(root,'.91dj_next_h5.lock')),{code:'ENOENT'});
|
||
});
|
||
});
|