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'); });