123 lines
7.9 KiB
JavaScript
123 lines
7.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fixture, seal } from './helpers/runtime-fixture.mjs';
|
|
import { runtime, verifyRelease, withRuntimeLock } from '../platform/runtime.mjs';
|
|
import { deployOnHost, deployableRelease, releaseName, sha256 } from '../platform/deployment.mjs';
|
|
import { sshTarget, shellQuote } from '../platform/ssh-deploy.mjs';
|
|
import { parseArgs } from '../platform/commands.mjs';
|
|
|
|
import { setup, addConfig } from './helpers/deployment-fixture.mjs';
|
|
|
|
test('first deployment publishes checked release, starts under lock and commits active only after success',async t=>{
|
|
const f=setup(t),state=await f.deploy();
|
|
assert.equal(state.first_deploy,true);assert.equal(state.backup_snapshot,null);assert.equal(state.phase,'complete');
|
|
assert.equal(fs.statSync(path.join(f.host,'releases',state.target_release)).mode&0o777,0o755);
|
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
|
assert(!fs.existsSync(path.join(f.host,'previous-release')));
|
|
assert(!fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
|
assert(f.events().filter(e=>e.command==='up').every(e=>e.locked));
|
|
});
|
|
|
|
test('repeat same healthy release does not upload or restart',async t=>{
|
|
const f=setup(t);await f.deploy();
|
|
const result=await f.deploy(f.release,()=>{throw new Error('should not upload');});
|
|
assert.equal(result.noop,true);assert.equal(f.events().filter(e=>e.command==='up').length,1);
|
|
});
|
|
|
|
test('same name/version with another manifest is rejected before stopping',async t=>{
|
|
const f=setup(t);await f.deploy();
|
|
fs.appendFileSync(path.join(f.release,'fixture.mrpack'),'changed');seal(f.release);
|
|
await assert.rejects(f.deploy(),/immutable release collision/);
|
|
assert.equal(f.state().phase,'complete');assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
|
});
|
|
|
|
test('update requires verified backup, preserves unmanaged world and advances previous/active',async t=>{
|
|
const f=setup(t);await f.deploy();
|
|
fs.mkdirSync(path.join(f.host,'runtime/world'));fs.writeFileSync(path.join(f.host,'runtime/world/level.dat'),'valuable');
|
|
fs.mkdirSync(path.join(f.host,'runtime/mod-state'));fs.writeFileSync(path.join(f.host,'runtime/mod-state/progress'),'42');
|
|
const result=await f.deploy(f.next());
|
|
assert(result.backup_snapshot);assert(fs.existsSync(path.join(result.backup_snapshot,'runtime.tar.zst')));
|
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/world/level.dat'),'utf8'),'valuable');
|
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/mod-state/progress'),'utf8'),'42');
|
|
assert.equal(fs.readFileSync(path.join(f.host,'previous-release'),'utf8').trim(),'fixture-1.0.0');
|
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-2.0.0');
|
|
});
|
|
|
|
test('manual managed edit fails before stop/uploaded config replacement, untracked configs retained',async t=>{
|
|
const f=setup(t);addConfig(f.release);await f.deploy();
|
|
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
|
|
fs.writeFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'keep');
|
|
await assert.rejects(f.deploy(f.next()),/Managed config conflicts/);
|
|
assert.equal(f.state().conflicts[0].reason,'managed-file-edited');
|
|
assert.match(f.state().conflicts[0].diff,/\+manual-edit/);
|
|
assert.match(f.state().conflicts[0].diff,/-original/);
|
|
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
|
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/unmanaged.txt'),'utf8'),'keep');
|
|
});
|
|
|
|
test('transfer interruption does not stop source and records failed recoverable state',async t=>{
|
|
const f=setup(t);await f.deploy();
|
|
await assert.rejects(f.deploy(f.next(),()=>{throw new Error('transfer failed');}),/transfer failed/);
|
|
assert.equal(f.state().phase,'failed');assert.equal(f.state().applied,false);
|
|
assert.equal(f.events().filter(e=>e.command==='stop').length,0);
|
|
assert(!fs.readdirSync(path.join(f.host,'releases')).some(n=>n.startsWith('.incoming')));
|
|
await assert.rejects(f.deploy(f.next('3.0.0')),/Unresolved deployment state/);
|
|
});
|
|
|
|
test('backup failure prohibits target startup and preserves source pointer/binding',async t=>{
|
|
const f=setup(t);await f.deploy();
|
|
const binding=fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8');
|
|
fs.writeFileSync(path.join(f.root,'.tools/tar'),'#!/bin/sh\nexit 42\n',{mode:0o755});
|
|
await assert.rejects(f.deploy(f.next()),/No snapshot published/);
|
|
assert.equal(f.state().applied,false);assert.equal(f.state().backup_snapshot,null);
|
|
assert.equal(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'),'utf8'),binding);
|
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
|
assert.equal(f.events().filter(e=>e.command==='up').length,1);
|
|
});
|
|
|
|
test('failed target startup leaves stopped target binding, source pointers and source backup',async t=>{
|
|
const f=setup(t);await f.deploy();
|
|
const next=f.next();fs.writeFileSync(path.join(f.root,'.tools/fail-start'),'fail');
|
|
await assert.rejects(f.deploy(next),/Target startup\/health failed/);
|
|
const state=f.state();assert.equal(state.phase,'failed');assert.equal(state.applied,true);assert(state.backup_snapshot);assert.equal(state.stopped_confirmed,true);
|
|
assert.equal(fs.readFileSync(path.join(f.host,'active-release'),'utf8').trim(),'fixture-1.0.0');
|
|
assert.equal(JSON.parse(fs.readFileSync(path.join(f.host,'.mcpack-runtime.json'))).release_manifest_sha256,verifyRelease(next).identity);
|
|
});
|
|
|
|
test('async shared lock remains held during upload and rejects concurrent lifecycle/deploy',async t=>{
|
|
const f=setup(t);let releaseUpload,entered;
|
|
const waiting=new Promise(resolve=>entered=resolve),block=new Promise(resolve=>releaseUpload=resolve);
|
|
const deployment=f.deploy(f.release,async stage=>{entered();await block;fs.cpSync(f.release,stage,{recursive:true});});
|
|
await waiting;
|
|
assert(fs.existsSync(path.join(f.host,'.mcpack-runtime.lock')));
|
|
assert.throws(()=>f.run(['up','-d']),/runtime operation/);
|
|
await assert.rejects(f.deploy(),/runtime operation/);
|
|
releaseUpload();await deployment;
|
|
});
|
|
|
|
test('dirty release and unsafe SSH arguments rejected; CLI deploy flags scoped',t=>{
|
|
const f=setup(t);const manifest=JSON.parse(fs.readFileSync(path.join(f.release,'manifest.json')));manifest.git.dirty=true;fs.writeFileSync(path.join(f.release,'manifest.json'),JSON.stringify(manifest));seal(f.release);
|
|
assert.throws(()=>deployableRelease(f.release),/clean Git/);
|
|
for(const [host,dir]of [['-oProxyCommand=bad','/srv/mc'],['user@host;touch bad','/srv/mc'],['alias','/'],['alias','/srv/../mc'],['alias','relative']])assert.throws(()=>sshTarget(host,dir));
|
|
assert.deepEqual(sshTarget('user@mc-host','/srv/minecraft'),{host:'user@mc-host',directory:'/srv/minecraft'});
|
|
assert.equal(shellQuote("a'b"),"'a'\\''b'");
|
|
assert.equal(parseArgs(['deploy','--artifact','/release']).artifact,'/release');
|
|
for(const args of [['deploy','--yes'],['deploy','--release'],['build','--artifact','/release']])assert.throws(()=>parseArgs(args));
|
|
});
|
|
|
|
test('manual edit incorporated exactly in clean target is accepted',async t=>{
|
|
const f=setup(t);addConfig(f.release);await f.deploy();
|
|
fs.writeFileSync(path.join(f.host,'runtime/config/marker.txt'),'manual-edit');
|
|
const next=f.next('2.0.0',release=>{
|
|
fs.writeFileSync(path.join(release,'server/config/marker.txt'),'manual-edit');
|
|
const manifest=JSON.parse(fs.readFileSync(path.join(release,'manifest.json')));
|
|
manifest.managed_files.server[0].hashes.sha256=sha256(path.join(release,'server/config/marker.txt'));
|
|
fs.writeFileSync(path.join(release,'manifest.json'),JSON.stringify(manifest));
|
|
});
|
|
assert.equal((await f.deploy(next)).phase,'complete');
|
|
assert.equal(fs.readFileSync(path.join(f.host,'runtime/config/marker.txt'),'utf8'),'manual-edit');
|
|
});
|