2658 lines
201 KiB
JavaScript
2658 lines
201 KiB
JavaScript
import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs';
|
|
import { after as laneAfter } from 'node:test';
|
|
// scripts/stack-release.sh (D1, #1659). Design, the T table and the mutants:
|
|
// agents/darkwing/work/queue-162/BUILD.md. Every run uses a temp data root, temp units and stand-in
|
|
// systemctl, journalctl and systemd-analyze on PATH; nothing here reads ~/.mosaic-dev or a live unit.
|
|
import test, {after} from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {execFileSync,spawn,spawnSync} from 'node:child_process';
|
|
import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,appendFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,lstatSync,symlinkSync,linkSync,readdirSync,realpathSync} from 'node:fs';
|
|
import {createServer} from 'node:http';
|
|
import {createHash} from 'node:crypto';
|
|
import {DatabaseSync} from 'node:sqlite';
|
|
import {tmpdir} from 'node:os';
|
|
import {join,dirname,basename} from 'node:path';
|
|
import {fileURLToPath,pathToFileURL} from 'node:url';
|
|
import {once} from 'node:events';
|
|
import {ALGS} from '../../webui/src/sso-config.mjs';
|
|
import {A,B,BUS,C,C1,CON,COPIES,EXAMPLE,F,KINDS_NEW,KINDS_OLD,M,MARKER,NODE,ROOT,S,SBIN,SR,backupSum,base,biz,calls,commit,commitLines,edit,fault,fp,git,healthz,holder,journalctl,kept,lines,logLines,migrateStub,mk,mosaicStub,named,pkg,planBackup,planNoBackup,planTail,pointer,posts,repo,rolledBack,run,script,seed,sha,snaps,startsStops,storeText,storeVersion,sysctl,systemctl,systemdAnalyze,tracked,tree,U,units} from './stack-release-fixture.mjs';
|
|
|
|
// --- The T table ---
|
|
|
|
test('T1 adopt writes the pointer from the units once; a failing check refuses check-failed',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pointer:'none'});
|
|
const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err);
|
|
assert.equal(r.out,`adopted ${B} for acme (pm 0, schema 3g)\n`);
|
|
const {at,activation,...p}=pointer(e);
|
|
assert.deepEqual(p,{copy:B,business:'acme',pm:false,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'});
|
|
assert.match(activation,new RegExp(`^\\d{8}T\\d{6}Z-${B.slice(0,12)}$`));assert.match(at,/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
|
|
assert.deepEqual(logLines(e).map((l)=>[l.op,l.from,l.to,l.result]),[['adopt',null,B,'ok']]);
|
|
const again=await run(e,'adopt','acme');assert.equal(again.code,3);
|
|
assert.equal(again.err,`stack-release: refusing already-adopted: ${e.pointer} exists (copy ${B})\n`);
|
|
assert.equal(logLines(e).at(-1).code,'already-adopted');
|
|
const f=await mk(t);await seed(f,{pointer:'none'});fault(f,`check-${B}`);
|
|
const bad=await run(f,'adopt','acme');assert.equal(bad.code,3);
|
|
assert.match(bad.err,/^stack-release: refusing check-failed: stack-copy.sh check did not pass:\nbus: /);
|
|
assert.equal(pointer(f),null);
|
|
});
|
|
|
|
test('T2 activate installs exactly the pointer\'s pm, claude and Console flags',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.err+r.out);
|
|
assert.ok(r.out.endsWith(`stack-release: activate ok: ${C1} is live for acme\n`),r.out);
|
|
const bus=readFileSync(join(e.units,'[email protected]'),'utf8').split('\n'),con=readFileSync(join(e.units,CON),'utf8').split('\n');
|
|
assert.ok(bus.includes(`ExecStart=${COPIES}/${C1}/scripts/mosaic bus start %i`),bus.join('\n'));
|
|
assert.ok(bus.includes(`Environment=MOSAIC_CLAUDE_BIN=${e.claude}`));
|
|
const exec=con.find((l)=>l.startsWith('ExecStart='));
|
|
assert.ok(exec.endsWith(` ${COPIES}/${C1}/packages/webui/src/cli.mjs serve --checkout ${repo} --port ${e.port} --business acme`),exec);
|
|
assert.equal(readFileSync(join(e.bin,'mosaic'),'utf8').trimEnd().split('\n').at(-1),`exec "${COPIES}/${C1}/scripts/mosaic" "$@"`);
|
|
const {at,activation,...p}=pointer(e);
|
|
assert.deepEqual(p,{copy:C1,business:'acme',pm:false,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'});
|
|
});
|
|
|
|
test('T3 a hand-edited unit refuses unit-flags-drift, names the difference and changes nothing',async(t)=>{
|
|
const cases=[
|
|
['pm',(e)=>edit(join(e.units,'[email protected]'),' bus start %i\n',' bus start %i --pm\n'),()=>' pm: pointer 0, unit 1'],
|
|
['claude',(e)=>{const d=join(e.units,`${BUS}.d`);mkdirSync(d);writeFileSync(join(d,'claude.conf'),'[Service]\nEnvironment=MOSAIC_CLAUDE_BIN=/opt/other/claude\n')},(e)=>` claude: pointer ${e.claude}, unit /opt/other/claude`],
|
|
['console',(e)=>edit(join(e.units,CON),'--business acme\n','--business acme --board http://127.0.0.1:9\n'),(e)=>` console: pointer --port ${e.port} --business acme, unit --port ${e.port} --business acme --board http://127.0.0.1:9`],
|
|
];
|
|
for(const [name,change,line] of cases)await t.test(name,async(t)=>{
|
|
const e=await mk(t);await seed(e);change(e);
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,3);
|
|
assert.equal(r.err,`stack-release: refusing unit-flags-drift: the units differ from the pointer; settle it first:\n${line(e)}\n`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
assert.equal(logLines(e).length,logs+1);assert.equal(logLines(e).at(-1).code,'unit-flags-drift');
|
|
});
|
|
});
|
|
|
|
test('T4 each item 3 refusal changes nothing and logs one line with its code',async(t)=>{
|
|
const setUnit=(e,u,state)=>{const s=units(e);s[u]={...(s[u]||{pid:null,wd:''}),state};writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))};
|
|
const cases=[
|
|
['copy-missing',()=>M,()=>`no copy ${M} in ${COPIES}`],
|
|
['copy-unreachable',()=>U,()=>`${U} is not reachable from refactor and origin/refactor`],
|
|
['copy-schema-unknown',()=>F,()=>`cannot read SCHEMA_VERSION and its chain from ${COPIES}/${F}/packages/bus/src/store.mjs`],
|
|
['already-active',()=>B,()=>`${B} is the pointer's copy`],
|
|
['instance-busy',(e)=>{setUnit(e,'[email protected]','active');return C1},()=>'[email protected] is active; the copy and wrapper switch reach it too','another business active'],
|
|
['instance-busy',(e)=>{setUnit(e,BUS,'activating');return C1},()=>`${BUS} is activating`,'this business activating'],
|
|
['drop-in-override',(e)=>{const d=join(e.units,`${BUS}.d`);mkdirSync(d);writeFileSync(join(d,'override.conf'),'[Service]\nWorkingDirectory=/elsewhere\n');return C1},(e)=>`${join(e.units,`${BUS}.d`,'override.conf')} overrides ExecStart or WorkingDirectory; the operator removes it as in TOOLS.md step 4`],
|
|
['no-pointer',()=>C1,(e)=>`no ${e.pointer}; run adopt first`,'',{pointer:'none'}],
|
|
];
|
|
for(const [code,setup,msg,label,opts] of cases)await t.test(label?`${code} (${label})`:code,async(t)=>{
|
|
const e=await mk(t);await seed(e,opts);const id=setup(e);
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
|
const r=await run(e,'activate',id,'acme');assert.equal(r.code,3,r.err);
|
|
assert.equal(r.err,`stack-release: refusing ${code}: ${msg(e)}\n`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
assert.equal(logLines(e).length,logs+1);assert.deepEqual([logLines(e).at(-1).result,logLines(e).at(-1).code],['refused',code]);
|
|
});
|
|
});
|
|
|
|
test('T5 an activate with no migration stops the Console then the bus, installs, starts the bus then the Console',async(t)=>{
|
|
const e=await mk(t);await seed(e);const n=calls(e).length;
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.err);
|
|
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
|
const c=calls(e).slice(n),reload=c.indexOf('daemon-reload');
|
|
assert.ok(reload>c.indexOf(`stop ${BUS}`)&&reload<c.indexOf(`start ${BUS}`),'daemon-reload between the stops and the starts');
|
|
assert.equal(pointer(e).copy,C1);assert.equal(units(e)[BUS].wd,join(COPIES,C1));
|
|
// Row 185: a same-schema activate backs up the stopped store too (T47 checks the backup).
|
|
assert.deepEqual(readdirSync(join(e.data,'backups','stack')),[pointer(e).activation]);
|
|
const l=logLines(e).slice(-2);assert.deepEqual(l.map((x)=>[x.op,x.from,x.to,x.result]),[['activate',B,C1,'started'],['activate',B,C1,'ok']]);
|
|
});
|
|
|
|
test('T6 a migration without --migrate rolls back with migration-needs-flag',async(t)=>{
|
|
const e=await mk(t);await seed(e);const before=fp(e);
|
|
const r=await run(e,'activate',C,'acme','--timeout','3');assert.equal(r.code,1,r.out);
|
|
assert.equal(r.err,rolledBack('migration-needs-flag'));
|
|
assert.deepEqual(fp(e),before);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[BUS].wd,units(e)[CON].state],['active',join(COPIES,B),'active']);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.result,l.code,l.rollback],['gate-failed','migration-needs-flag','ok']);
|
|
assert.equal(storeVersion(e.store,true),'3g');assert.ok(!existsSync(join(e.data,'backups','stack')));
|
|
});
|
|
|
|
test('T7 --migrate takes the backup before the migration runs',async(t)=>{
|
|
const e=await mk(t);await seed(e);const before=fp(e);
|
|
const r=await run(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.err+r.out);
|
|
const act=pointer(e).activation;
|
|
assert.deepEqual(JSON.parse(readFileSync(join(e.sd,'migrate.json'),'utf8')),{backups:[{name:act,sums:true}]});
|
|
assert.equal(backupSum(e,act),before.store);
|
|
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C,'3h']);assert.equal(storeVersion(e.store,true),'3h');
|
|
assert.equal(logLines(e).at(-1).backup,act);
|
|
});
|
|
|
|
test('T8 a failed migration restores the backup and keeps the store it replaced',async(t)=>{
|
|
const e=await mk(t);await seed(e);const before=fp(e);fault(e,'migrate-fails');
|
|
const r=await run(e,'activate',C,'acme','--migrate','--timeout','3');assert.equal(r.code,1,r.out);
|
|
assert.equal(r.err,rolledBack('migrate-failed'));
|
|
assert.equal(posts(e).length,1);assert.equal(storeVersion(join(e.data,posts(e)[0],'bus.sqlite'),false),'3h-partial');
|
|
const act=logLines(e).at(-1).activation;
|
|
assert.equal(sha(e.store),backupSum(e,act));assert.equal(sha(e.store),before.store);
|
|
assert.deepEqual(fp(e),before);assert.equal(units(e)[BUS].wd,join(COPIES,B));
|
|
});
|
|
|
|
test('T9 a store outside the target\'s chain rolls back with store-newer',async(t)=>{
|
|
const e=await mk(t);await seed(e,{store:'3z'});const before=fp(e);
|
|
const r=await run(e,'activate',C,'acme','--migrate','--timeout','3');assert.equal(r.code,1,r.out);
|
|
assert.equal(r.err,rolledBack('store-newer'));
|
|
assert.deepEqual(fp(e),before);assert.equal(storeVersion(e.store,true),'3z');assert.ok(!existsSync(join(e.data,'backups','stack')));
|
|
});
|
|
|
|
test('T10 each gate check failing alone rolls back and puts every file back',async(t)=>{
|
|
const cases=[['units-inactive',`start-dies-${C1}`],['bus-status',`status-${C1}`],['healthz',`healthz-${C1}`],['copy-check',`check-${C1}`],['pm-launch-unconfirmed',`pm-${C1}`,'none']];
|
|
for(const [code,name,text] of cases)await t.test(code,async(t)=>{
|
|
const pm=code==='pm-launch-unconfirmed',e=await mk(t);await seed(e,{pm});fault(e,name,text);
|
|
const before=fp(e);
|
|
const r=await run(e,'activate',C1,'acme',...(pm?['--pm']:[]),'--timeout','3');assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.err,rolledBack(code));assert.ok(r.out.includes(`stack-release: gate failed on ${C1}: ${code}`),r.out);
|
|
assert.deepEqual(fp(e),before);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[BUS].wd],['active',join(COPIES,B)]);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.result,l.code,l.rollback],['gate-failed',code,'ok']);
|
|
});
|
|
});
|
|
|
|
test('T10b the PM check needs exactly one launched line for a live run of this start',async(t)=>{
|
|
const cases=[
|
|
['no launched line','none',/\(no launched line since the start\)/],
|
|
['two lines','two',/\(2 launched lines since the start\)/],
|
|
['a changed word','badword',/\(the launched line does not parse: launched pm-acme: job run-\d+, claude opus\)/],
|
|
['run not in launches list','notlisted',/\(run run-\d+ is not in launches list\)/],
|
|
['run started before the start','early',/\(run run-\d+ started before this start\)/],
|
|
['a human launch with no journal line','human',/\(no launched line since the start\)/],
|
|
['run for another business','other',/\(run run-\d+ is for other\)/],
|
|
['run not live','dead',/\(run run-\d+ is not live\)/],
|
|
];
|
|
for(const [name,mode,why] of cases)await t.test(name,async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});fault(e,`pm-${C1}`,mode);
|
|
const r=await run(e,'activate',C1,'acme','--pm','--timeout','3');assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.err,rolledBack('pm-launch-unconfirmed'));
|
|
const gate=r.out.split('\n').find((l)=>l.startsWith(`stack-release: gate failed on ${C1}: pm-launch-unconfirmed`));
|
|
assert.ok(gate,r.out);assert.match(gate,why);
|
|
});
|
|
await t.test('one well-formed line and a live run pass',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});
|
|
const r=await run(e,'activate',C1,'acme','--pm');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(readFileSync(join(e.units,'[email protected]'),'utf8').includes(`ExecStart=${COPIES}/${C1}/scripts/mosaic bus start %i --pm\n`));
|
|
assert.match(lines(join(e.sd,'journal-since.log')).at(-1),/^@\d+$/);
|
|
assert.equal(pointer(e).pm,true);
|
|
});
|
|
});
|
|
|
|
test('T11 a rollback whose gate fails too: rollback-failed, exit 2, both units stopped, no retry',async(t)=>{
|
|
const e=await mk(t);await seed(e);fault(e,`healthz-${C1}`);fault(e,`healthz-${B}`);const n=calls(e).length;
|
|
const r=await run(e,'activate',C1,'acme','--timeout','3');assert.equal(r.code,2,r.out+r.err);
|
|
const l=logLines(e).at(-1),rs=join(e.state,'stack-rollback',l.activation);
|
|
assert.equal(r.err,`stack-release: rollback-failed: the run failed with healthz and the rollback with healthz; both units are stopped. Rollback set: ${rs}, backup ${join(e.data,'backups','stack',l.activation)}\n`);
|
|
assert.deepEqual([l.result,l.code,l.rollback,l.rollbackCode],['rollback-failed','healthz','failed','healthz']);
|
|
// Row 185: the same-schema backup is named and was not restored, so no post-attempt store.
|
|
assert.equal(l.backup,l.activation);assert.equal(posts(e).length,0);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['inactive','inactive']);
|
|
const ss=startsStops(e,n);
|
|
assert.deepEqual(ss.filter((c)=>c.startsWith('start ')),[`start ${BUS}`,`start ${CON}`,`start ${BUS}`,`start ${CON}`]);
|
|
assert.deepEqual(ss.slice(-2),[`stop ${CON}`,`stop ${BUS}`]);
|
|
assert.equal(pointer(e).copy,B);
|
|
});
|
|
|
|
test('T12 rollback takes the previous copy from the log',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C1,'acme')).code,0);
|
|
const r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(r.out.endsWith(`stack-release: rollback ok: ${B} is live for acme\n`),r.out);
|
|
assert.equal(pointer(e).copy,B);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['rollback',C1,B,'ok']);
|
|
});
|
|
|
|
test('T13 a rollback across a migration needs --restore, then restores and goes live',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation,storeC=sha(e.store);
|
|
const no=await run(e,'rollback','acme');assert.equal(no.code,3);
|
|
assert.equal(no.err,`stack-release: refusing schema-differs: the store is at 3h and ${B} expects 3g; pass --restore ACTIVATION\n`);
|
|
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,0,r.out+r.err);
|
|
const o=join(e.data,'backups','stack',act);
|
|
assert.match(r.out,new RegExp(`^restore: ${o}; the store now in place moves to ${e.data}/bus\\.post-attempt-\\d{8}T\\d{6}Z and is kept; 0 event\\(s\\) were written after the backup\\n`));
|
|
assert.deepEqual([pointer(e).copy,pointer(e).schema],[B,'3g']);assert.equal(storeVersion(e.store,true),'3g');
|
|
assert.equal(sha(e.store),backupSum(e,act));
|
|
assert.equal(posts(e).length,1);assert.equal(sha(join(e.data,posts(e)[0],'bus.sqlite')),storeC);
|
|
assert.equal(logLines(e).at(-1).backup,act);
|
|
});
|
|
|
|
test('T14 events written after the backup need --events N with the right N',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
|
const db=new DatabaseSync(e.store);for(let i=0;i<2;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close();
|
|
const want='stack-release: refusing post-backup-events: 2 event(s) were written after the backup; Jason decides, then --events 2\n';
|
|
for(const extra of [[],['--events','1']]){
|
|
const before=fp(e),r=await run(e,'rollback','acme','--restore',act,...extra);
|
|
assert.equal(r.code,3,extra.join(' '));assert.equal(r.err,want);assert.deepEqual(fp(e),before);
|
|
}
|
|
const r=await run(e,'rollback','acme','--restore',act,'--events','2');assert.equal(r.code,0,r.out+r.err);
|
|
assert.match(r.out,/; 2 event\(s\) were written after the backup\n/);
|
|
assert.deepEqual([pointer(e).copy,pointer(e).schema],[B,'3g']);
|
|
});
|
|
|
|
test('T14b a rollback --restore whose gate fails stops both units and skips item 6',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation,storeC=sha(e.store),before=fp(e);
|
|
fault(e,`healthz-${B}`);const n=calls(e).length;
|
|
const r=await run(e,'rollback','acme','--restore',act,'--timeout','3');assert.equal(r.code,2,r.out+r.err);
|
|
const l=logLines(e).at(-1),rs=join(e.state,'stack-rollback',l.activation),post=join(e.data,posts(e)[0]);
|
|
assert.equal(r.err,`stack-release: rollback-failed: the run failed with healthz and the rollback with not-attempted; both units are stopped. Rollback set: ${rs}, backup ${join(e.data,'backups','stack',act)}, post-attempt store ${post}\n`);
|
|
assert.deepEqual([l.result,l.code,l.rollbackCode],['rollback-failed','healthz','not-attempted']);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['inactive','inactive']);
|
|
assert.equal(posts(e).length,1);assert.equal(sha(join(post,'bus.sqlite')),storeC);assert.equal(sha(e.store),backupSum(e,act));
|
|
assert.deepEqual(startsStops(e,n).filter((c)=>c.startsWith('start ')),[`start ${BUS}`,`start ${CON}`]);
|
|
assert.equal(pointer(e).copy,C);assert.equal(sha(e.pointer),before.pointer);
|
|
});
|
|
|
|
test('T14c a rollback without --restore whose gate fails runs item 6',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C1,'acme')).code,0);const before=fp(e);
|
|
fault(e,`healthz-${B}`);
|
|
const r=await run(e,'rollback','acme','--timeout','3');assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.err,rolledBack('healthz',C1));
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.result,l.code,l.rollback],['rollback','gate-failed','healthz','ok']);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual([units(e)[BUS].state,units(e)[BUS].wd],['active',join(COPIES,C1)]);
|
|
assert.deepEqual(readdirSync(join(e.data,'bus')).filter((n)=>n.startsWith('bus.sqlite')).sort(),['bus.sqlite','bus.sqlite-shm','bus.sqlite-wal']);
|
|
});
|
|
|
|
test('T15 --plan names the migration and the restore and changes nothing',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const quiet=async(...args)=>{
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length,r=await run(e,...args);
|
|
assert.equal(r.code,0,r.err);assert.equal(r.err,'');
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
return r.out;
|
|
};
|
|
assert.ok((await quiet('activate',C,'acme','--plan')).includes('needs: --migrate (not given; the run would roll back with migration-needs-flag)\n'));
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
|
assert.equal(await quiet('rollback','acme','--plan'),[
|
|
'plan: rollback for acme',`from: ${C}`,`to: ${B}`,
|
|
'store schema: 3h (read from the store the running bus holds)','target schema: 3g',
|
|
`restore: the store must go back to 3g; needs --restore ${act}`,
|
|
'commits: the run takes away 2 (newest first, at most 200):',...commitLines(`${B}..${C}`),
|
|
...planTail(e,B),''].join('\n'));
|
|
});
|
|
|
|
test('T26 --plan lists the commits an activate adds, after the migration lines',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const head=(mig)=>['plan: activate for acme',`from: ${B}`,`to: ${C}`,'store schema: 3g (read from the store the running bus holds)','target schema: 3h',
|
|
'migration: 3g to 3h, after a backup of the stopped store','migration steps: 3g to 3h',`needs: --migrate (${mig})`,'needs: Jason present for this run (decision 96 condition 2)'];
|
|
const adds=commitLines(`${B}..${C}`);
|
|
assert.equal(adds.length,2);assert.match(adds[0],/^ {2}[0-9a-f]{7,} row 904: C$/);assert.match(adds[1],/^ {2}[0-9a-f]{7,} row 903: C1$/);
|
|
for(const [flags,mig,backup] of [[[],'not given; the run would roll back with migration-needs-flag',planNoBackup],[['--migrate'],'given',planBackup(e)]]){
|
|
const r=await run(e,'activate',C,'acme','--plan',...flags);assert.equal(r.code,0,r.err);
|
|
assert.equal(r.out,[...head(mig),backup,'commits: the run adds 2 (newest first, at most 200):',...adds,...planTail(e,C),''].join('\n'));
|
|
}
|
|
const same=await run(e,'activate',C1,'acme','--plan');assert.equal(same.code,0,same.err);
|
|
assert.ok(same.out.includes(`target schema: 3g\nmigration: none; the store is at the target's version\n${planBackup(e)}\ncommits: the run adds 1 (newest first, at most 200):\n`),same.out);
|
|
});
|
|
|
|
test('T27 --plan names each migration step, or says the steps cannot be read',async(t)=>{
|
|
const e=await mk(t);await seed(e,{store:'3f',pointer:'hand'});
|
|
const two=await run(e,'activate',C,'acme','--plan');assert.equal(two.code,0,two.err);
|
|
assert.ok(two.out.includes('\nmigration: 3f to 3h, after a backup of the stopped store\nmigration steps: 3f to 3g, 3g to 3h\nneeds: --migrate'),two.out);
|
|
const none=await run(e,'activate',C1,'acme','--plan');assert.equal(none.code,0,none.err);
|
|
assert.ok(none.out.includes(`\nmigration: 3f to 3g, after a backup of the stopped store\nmigration steps: cannot be read from ${COPIES}/${C1}/packages/bus/src/store.mjs\nneeds: --migrate`),none.out);
|
|
const short=await run(e,'activate',A,'acme','--plan');assert.equal(short.code,0,short.err);
|
|
assert.ok(short.out.includes(`\nmigration: 3f to 3g, after a backup of the stopped store\nmigration steps: cannot be read from ${COPIES}/${A}/packages/bus/src/store.mjs\nneeds: --migrate`),short.out);
|
|
});
|
|
|
|
test('T25 --plan refuses copies-unrelated when neither copy is an ancestor of the other',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pointer:'hand'});
|
|
const r=await run(e,'activate',S,'acme','--plan');assert.equal(r.code,3);
|
|
assert.equal(r.err,`stack-release: refusing copies-unrelated: neither ${B} nor ${S} is an ancestor of the other, so the plan cannot list the commits\n`);
|
|
assert.equal(r.out,'');assert.ok(!existsSync(e.log));assert.ok(!existsSync(e.lock));
|
|
});
|
|
|
|
test('T23 --plan reads the store without changing a file, stopped or held',async(t)=>{
|
|
const want=['target schema: 3h','migration: 3g to 3h, after a backup of the stopped store','migration steps: 3g to 3h','needs: --migrate (not given; the run would roll back with migration-needs-flag)','needs: Jason present for this run (decision 96 condition 2)'];
|
|
for(const [name,stop,src] of [['stopped store',true,'read from the stopped store'],['held store',false,'read from the store the running bus holds']])await t.test(name,async(t)=>{
|
|
const e=await mk(t);await seed(e,{pointer:'hand'});if(stop)sysctl(e,'stop',BUS);
|
|
const before=tree(e.data),units0=[sha(join(e.units,'[email protected]')),sha(join(e.units,CON)),sha(join(e.bin,'mosaic'))],store0=sha(e.store);
|
|
const r=await run(e,'activate',C,'acme','--plan');assert.equal(r.code,0,r.err);
|
|
const out=r.out.split('\n');
|
|
assert.ok(out.includes(`store schema: 3g (${src})`),r.out);
|
|
assert.deepEqual(out.slice(out.indexOf('target schema: 3h'),out.indexOf('target schema: 3h')+5),want);
|
|
assert.deepEqual(tree(e.data),before);assert.equal(sha(e.store),store0);
|
|
assert.deepEqual([sha(join(e.units,'[email protected]')),sha(join(e.units,CON)),sha(join(e.bin,'mosaic'))],units0);
|
|
assert.ok(!existsSync(e.log));assert.ok(!existsSync(e.lock));
|
|
if(stop)assert.deepEqual(readdirSync(join(e.data,'bus')),['bus.sqlite']);
|
|
});
|
|
});
|
|
|
|
test('T24 business-mismatch and capture-failed refuse with exit 3; a short id is a usage error',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
let n=calls(e).length,logs=logLines(e).length;
|
|
const r=await run(e,'activate',C1,'other');assert.equal(r.code,3);
|
|
assert.equal(r.err,'stack-release: refusing business-mismatch: the pointer is for acme\n');
|
|
assert.equal(logLines(e).length,logs+1);assert.equal(logLines(e).at(-1).code,'business-mismatch');assert.deepEqual(startsStops(e,n),[]);
|
|
const sets=join(e.state,'stack-rollback');mkdirSync(sets);chmodSync(sets,0o555);
|
|
n=calls(e).length;logs=logLines(e).length;const before=fp(e);
|
|
const c=await run(e,'activate',C1,'acme');assert.equal(c.code,3);
|
|
assert.match(c.err,new RegExp(`^stack-release: refusing capture-failed: could not write the rollback set ${sets}/\\d{8}T\\d{6}Z-${C1.slice(0,12)}; nothing was stopped\\n$`));
|
|
const l=logLines(e).slice(logs);
|
|
assert.deepEqual(l.map((x)=>[x.result,x.code]),[['started',undefined],['refused','capture-failed']]);assert.equal(l[0].activation,l[1].activation);
|
|
assert.deepEqual(startsStops(e,n),[]);assert.deepEqual(fp(e),before);
|
|
const s=await run(e,'status','acme');assert.equal(s.code,0,s.err);assert.ok(!s.out.includes('interrupted run'),s.out);
|
|
logs=logLines(e).length;
|
|
const u=await run(e,'activate','abc123','acme');assert.equal(u.code,4);assert.match(u.err,/^usage: stack-release.sh adopt BUSINESS\n/);
|
|
assert.equal(logLines(e).length,logs);
|
|
});
|
|
|
|
test('T16 status prints the pointer, the log, the check, bus status and drift, and writes nothing',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const before=tree(e.data),fp0=fp(e);
|
|
const r=await run(e,'status','acme');assert.equal(r.code,0,r.err);
|
|
const copy=join(COPIES,B);
|
|
assert.equal(r.out,[
|
|
`pointer: ${e.pointer}`,...readFileSync(e.pointer,'utf8').trimEnd().split('\n').map((l)=>` ${l}`),
|
|
'log (last five lines):',...lines(e.log).map((l)=>` ${l}`),
|
|
'check:',` bus: ${copy}`,` Console: ${copy}`,` CLI: ${copy}`,' result: pass',
|
|
'bus status:',` bus: fixture status for ${B}`,
|
|
'unit flags: match the pointer',
|
|
'drift: 4 commit(s) on refactor since the pointer\'s copy',''].join('\n'));
|
|
assert.deepEqual(tree(e.data),before);assert.deepEqual(fp(e),fp0);
|
|
});
|
|
|
|
test('T19 a started line with no final line: status shows it and every changing verb refuses',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const act=`20261018T000000Z-${C1.slice(0,12)}`;
|
|
appendFileSync(e.log,JSON.stringify({at:'2026-10-18T00:00:00Z',op:'activate',from:B,to:C1,backup:null,activation:act,result:'started'})+'\n');
|
|
const s=await run(e,'status','acme');assert.equal(s.code,0);
|
|
assert.ok(s.out.includes(`interrupted run: ${act} (started, no final line; Jason or Mos settles it)\n`),s.out);
|
|
for(const args of [['adopt','acme'],['activate',C1,'acme'],['rollback','acme']]){
|
|
const before=fp(e),n=calls(e).length,r=await run(e,...args);
|
|
assert.equal(r.code,3,args.join(' '));
|
|
assert.equal(r.err,`stack-release: refusing interrupted-run: activation ${act} started and has no final line; Jason or Mos settles it (status shows it)\n`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
}
|
|
});
|
|
|
|
test('T20 a held run lock refuses run-locked without a log line; status and --plan still run',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const h=spawn('flock',[e.lock,'sleep','30'],{detached:true,stdio:'ignore'});
|
|
t.after(()=>{try{process.kill(-h.pid,'SIGKILL')}catch{}});
|
|
const end=Date.now()+10000;
|
|
while(spawnSync('flock',['-n',e.lock,'true']).status!==1){assert.ok(Date.now()<end,'the lock holder did not take the lock');await new Promise((r)=>setTimeout(r,50))}
|
|
for(const args of [['adopt','acme'],['activate',C1,'acme'],['rollback','acme']]){
|
|
const before=fp(e),logs=logLines(e).length,r=await run(e,...args);
|
|
assert.equal(r.code,3,args.join(' '));
|
|
assert.equal(r.err,`stack-release: refusing run-locked: another stack-release run holds ${e.lock}\n`);
|
|
assert.equal(logLines(e).length,logs);assert.deepEqual(fp(e),before);
|
|
}
|
|
assert.equal((await run(e,'status','acme')).code,0);
|
|
const p=await run(e,'activate',C1,'acme','--plan');assert.equal(p.code,0,p.err);
|
|
});
|
|
|
|
test('T21 a bus unit with no MOSAIC_CLAUDE_BIN: adopt and activate refuse no-claude-pin',async(t)=>{
|
|
const drop=(e)=>edit(join(e.units,'[email protected]'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'');
|
|
const want=`stack-release: refusing no-claude-pin: ${BUS} has no MOSAIC_CLAUDE_BIN\n`;
|
|
const a=await mk(t);await seed(a,{pointer:'none'});drop(a);
|
|
const r=await run(a,'adopt','acme');assert.equal(r.code,3);assert.equal(r.err,want);assert.equal(pointer(a),null);
|
|
const b=await mk(t);await seed(b);drop(b);const before=fp(b),n=calls(b).length;
|
|
const s=await run(b,'activate',C1,'acme');assert.equal(s.code,3);assert.equal(s.err,want);
|
|
assert.deepEqual(fp(b),before);assert.deepEqual(startsStops(b,n),[]);
|
|
});
|
|
|
|
test('T22 the stopped store read: a -wal or -shm refuses store-unclean and is left alone; a clean read adds no file',async(t)=>{
|
|
for(const ext of ['-wal','-shm'])await t.test(`bus.sqlite${ext}`,async(t)=>{
|
|
const e=await mk(t);await seed(e);fault(e,`stop-leaves${ext}`);
|
|
const r=await run(e,'activate',C1,'acme','--timeout','3');assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.err,rolledBack('store-unclean'));
|
|
assert.ok(r.out.includes(`stack-release: ${e.store}${ext} is present; the store is left as it is for Jason or Mos\n`),r.out);
|
|
const s=snaps(e),first=s[0],start=s.find((x)=>x.ev==='start');
|
|
assert.equal(first.ev,'stop');assert.ok(`bus.sqlite${ext}` in first.files,JSON.stringify(first));
|
|
assert.deepEqual(start.files,first.files);
|
|
});
|
|
await t.test('clean',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const s=snaps(e),stop=s.find((x)=>x.ev==='stop'),start=s.find((x)=>x.ev==='start');
|
|
assert.deepEqual(Object.keys(stop.files),['bus.sqlite']);assert.deepEqual(start.files,stop.files);
|
|
});
|
|
});
|
|
|
|
// --- Row 166 (#1663): docs/plans/2026-10-18_stack-release-hardening.md items 1 to 4 ---
|
|
|
|
test('T28 a unit path, drop-in directory or drop-in file that cannot be read, or a relative unit path, refuses drop-in-override before any stop',async(t)=>{
|
|
const cases=[
|
|
['unit path',(e)=>[e.units,`cannot inspect unit path ${e.units}`]],
|
|
['drop-in directory',(e)=>{const d=join(e.units,`${BUS}.d`);mkdirSync(d);writeFileSync(join(d,'note.conf'),'[Service]\n');return [d,`cannot inspect drop-ins ${d}`]}],
|
|
['drop-in file',(e)=>{const d=join(e.units,`${BUS}.d`),f=join(d,'claude.conf');mkdirSync(d);writeFileSync(f,'[Service]\n');return [f,`cannot read drop-in ${f}`]}],
|
|
];
|
|
for(const [name,make] of cases)await t.test(name,async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const [p,msg]=make(e),before=fp(e),n=calls(e).length,logs=logLines(e).length,mode=statSync(p).mode&0o7777;
|
|
chmodSync(p,0);
|
|
let r;try{r=await run(e,'activate',C1,'acme')}finally{chmodSync(p,mode)}
|
|
assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,`stack-release: refusing drop-in-override: ${msg}\n`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['active','active']);
|
|
assert.equal(logLines(e).length,logs+1);assert.equal(logLines(e).at(-1).code,'drop-in-override');
|
|
});
|
|
// install_guard refuses a unit path that isn't absolute, so the precheck does too.
|
|
await t.test('relative unit path',async(t)=>{
|
|
const e=await mk(t);await seed(e);fault(e,'unit-path','systemd/user');
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);
|
|
assert.equal(r.err,'stack-release: refusing drop-in-override: invalid systemd unit path systemd/user\n');
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['active','active']);
|
|
assert.equal(logLines(e).length,logs+1);assert.equal(logLines(e).at(-1).code,'drop-in-override');
|
|
});
|
|
});
|
|
|
|
test('T29 a bus that stops during the held-store read: --plan says so, rollback --restore refuses restore-mismatch',async(t)=>{
|
|
await t.test('--plan',async(t)=>{
|
|
const e=await mk(t);await seed(e);fault(e,'bus-stops-after','1');
|
|
const before=fp(e),logs=logLines(e).length;
|
|
const r=await run(e,'activate',C,'acme','--plan');assert.equal(r.code,0,r.err);assert.equal(r.err,'');
|
|
assert.equal(r.out,['plan: activate for acme',`from: ${B}`,`to: ${C}`,
|
|
'store schema: unknown (the bus stopped during the read, so the store state changed; run --plan again)','target schema: 3h',planBackup(e),
|
|
'commits: the run adds 2 (newest first, at most 200):',...commitLines(`${B}..${C}`),...planTail(e,C),''].join('\n'));
|
|
assert.deepEqual(fp(e),before);assert.equal(logLines(e).length,logs);
|
|
});
|
|
await t.test('rollback --restore',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
|
const stamp=readFileSync(join(e.data,'backups','stack',act,'STAMP'),'utf8').replace(/\n+$/,'');
|
|
fault(e,'bus-stops-after','1');const before=fp(e),n=calls(e).length;
|
|
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,3,r.out+r.err);
|
|
assert.equal(r.err,`stack-release: refusing restore-mismatch: the bus stopped while the events after ${stamp} were counted, so the store state changed; nothing was stopped\n`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(posts(e).length,0);
|
|
assert.equal(logLines(e).at(-1).code,'restore-mismatch');
|
|
});
|
|
});
|
|
|
|
test('T30 a copy check that agrees on another copy fails the gate with copy-check and rolls back',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
// A repo of its own, so the stand-in check replaces stack-copy.sh for this test only. Sourced, the
|
|
// stand-in is the real script; run with `check`, it reports all three on the pointer's copy.
|
|
const own=join(e.root,'repo');cpSync(repo,own,{recursive:true});
|
|
const sc=join(own,'scripts','stack-copy.sh'),at=join(COPIES,B);cpSync(sc,join(own,'scripts','stack-copy.real.sh'));
|
|
writeFileSync(sc,`#!/usr/bin/env bash\nreal="$(dirname "\${BASH_SOURCE[0]}")/stack-copy.real.sh"\nif [ "\${BASH_SOURCE[0]}" != "$0" ]; then source "$real"; return 0; fi\nif [ "\${1:-}" = check ]; then printf 'bus: %s\\nConsole: %s\\nCLI: %s\\n' '${at}' '${at}' '${at}'; exit 0; fi\nexec bash "$real" "$@"\n`);
|
|
e.sr=join(own,'scripts','stack-release.sh');
|
|
const r=await run(e,'activate',C1,'acme','--timeout','3');assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.err,rolledBack('copy-check'));
|
|
assert.ok(r.out.includes(`stack-release: gate failed on ${C1}: copy-check (the bus, Console and CLI agree on ${at}, not ${join(COPIES,C1)})\n`),r.out);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.result,l.code,l.rollback],['activate','gate-failed','copy-check','ok']);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual([units(e)[BUS].state,units(e)[BUS].wd],['active',at]);
|
|
});
|
|
|
|
test('T31 a quoted or escaped Environment= or ExecStart= is unreadable: refusals by name, nothing read from /a',async(t)=>{
|
|
const forms=[
|
|
['Environment double quotes',(e)=>edit(join(e.units,'[email protected]'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment="MOSAIC_CLAUDE_BIN=/a b/claude"\n')],
|
|
['Environment single quotes',(e)=>edit(join(e.units,'[email protected]'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment=\'MOSAIC_CLAUDE_BIN=/a b/claude\'\n')],
|
|
['Environment backslash',(e)=>edit(join(e.units,'[email protected]'),`Environment=MOSAIC_CLAUDE_BIN=${e.claude}\n`,'Environment=MOSAIC_CLAUDE_BIN=/a\\ b/claude\n')],
|
|
['ExecStart quotes',(e)=>edit(join(e.units,'[email protected]'),' bus start %i\n',' bus start %i "--pm"\n')],
|
|
['Console ExecStart quotes',(e)=>edit(join(e.units,CON),' --business acme\n',' --business "acme"\n')],
|
|
];
|
|
for(const [name,change] of forms)await t.test(name,async(t)=>{
|
|
const e=await mk(t);await seed(e);change(e);const before=fp(e),n=calls(e).length;
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);
|
|
assert.equal(r.err,`stack-release: refusing unit-flags-drift: cannot read ${BUS} or ${CON}\n`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
const s=await run(e,'status','acme');assert.equal(s.code,0,s.err);assert.ok(s.out.includes('unit flags: cannot read the units\n'),s.out);
|
|
const a=await mk(t);await seed(a,{pointer:'none'});change(a);
|
|
const d=await run(a,'adopt','acme');assert.equal(d.code,3,d.out+d.err);
|
|
assert.equal(d.err,`stack-release: refusing check-failed: cannot read ${BUS} or ${CON}\n`);assert.equal(pointer(a),null);
|
|
for(const x of [r,d])assert.ok(!x.err.includes('/a'),x.err);
|
|
});
|
|
});
|
|
|
|
// --- Row 169 (#1666): docs/plans/2026-10-19_pm-after-harness.md items 1 to 4 ---
|
|
|
|
const HARNESS={harness:['claude-code']};
|
|
const busUnit=(e)=>readFileSync(join(e.units,'[email protected]'),'utf8');
|
|
const execLine=(to,pm)=>`ExecStart=${COPIES}/${to}/scripts/mosaic bus start %i${pm?' --pm':''}\n`;
|
|
const sinceLines=(e)=>lines(join(e.sd,'journal-since.log')).length;
|
|
const PENDING_MSG='the pointer is pending (activate --pm-after-harness); run pm-on or rollback first';
|
|
// The stack after phase 1: seeded on B with --pm, then activate C1 --pm-after-harness.
|
|
async function pending(e){
|
|
await seed(e,{pm:true});
|
|
const r=await run(e,'activate',C1,'acme','--pm-after-harness');assert.equal(r.code,0,r.out+r.err);
|
|
}
|
|
// A refusal before anything stops: exit 3, the message, no file changed, no start or stop, one log line.
|
|
async function refused(e,args,code,msg){
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
|
const r=await run(e,...args);assert.equal(r.code,3,r.out+r.err);(e.outs??=[]).push(r.out,r.err);
|
|
assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
assert.equal(logLines(e).length,logs+1);assert.deepEqual([logLines(e).at(-1).result,logLines(e).at(-1).code],['refused',code]);
|
|
}
|
|
|
|
test('T32 item 1: --pm-after-harness refuses a target without harness or a file that sets it, and passes without the PM',async(t)=>{
|
|
await t.test('usage: not with --pm, and only for activate',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});const logs=logLines(e).length;
|
|
for(const args of [['activate',C1,'acme','--pm','--pm-after-harness'],['activate',C1,'acme','--pm-after-harness','--pm'],['rollback','acme','--pm-after-harness'],['pm-on','acme','--pm-after-harness']]){
|
|
const r=await run(e,...args);assert.equal(r.code,4,args.join(' '));assert.match(r.err,/^usage: stack-release.sh adopt BUSINESS\n/);
|
|
}
|
|
assert.equal(logLines(e).length,logs);
|
|
});
|
|
await t.test('pm-after-harness-target',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});
|
|
await refused(e,['activate',A,'acme','--pm-after-harness'],'pm-after-harness-target',`${A} has no harness plug-in kind, so there is nothing to wait for; use --pm`);
|
|
});
|
|
await t.test('pm-after-harness-set',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});
|
|
for(const [h,shown] of [[['claude-code'],'["claude-code"]'],[[],'[]']]){
|
|
biz(e,{harness:h});
|
|
await refused(e,['activate',C1,'acme','--pm-after-harness'],'pm-after-harness-set',`the business file already sets harness ${shown}; use --pm`);
|
|
}
|
|
});
|
|
await t.test('a pointer without pm',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
await refused(e,['activate',C1,'acme','--pm-after-harness'],'unit-flags-drift','the units differ from the pointer; settle it first:\n pm: pointer 0, --pm-after-harness needs a pointer with pm 1');
|
|
});
|
|
await t.test('phase 1 installs without --pm, skips pm_launched and writes the pending pointer',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});const since=sinceLines(e),n=calls(e).length;
|
|
const r=await run(e,'activate',C1,'acme','--pm-after-harness');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(r.out.endsWith(`stack-release: activate ok: ${C1} is live for acme without its PM; the PM is pending: Jason's plugins set of harness, then pm-on\n`),r.out);
|
|
assert.ok(busUnit(e).includes(execLine(C1,false)),busUnit(e));
|
|
assert.equal(sinceLines(e),since);
|
|
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
|
const {at,activation,...p}=pointer(e);
|
|
assert.deepEqual(p,{copy:C1,business:'acme',pm:false,pmPending:true,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'});
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.pm],['activate',B,C1,'ok','pending']);
|
|
});
|
|
});
|
|
|
|
test('T33 item 2: read_pointer takes pmPending only as true beside pm false; a pending pointer refuses activate and shows in status',async(t)=>{
|
|
await t.test('read_pointer',async(t)=>{
|
|
const e=await mk(t);await seed(e);const good=pointer(e);
|
|
for(const bad of [{pmPending:false},{pmPending:'true'},{pmPending:1},{pmPending:null},{pm:true,pmPending:true}]){
|
|
writeFileSync(e.pointer,JSON.stringify({...good,...bad})+'\n');
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
|
for(const args of [['activate',C1,'acme'],['status','acme']]){
|
|
const r=await run(e,...args);assert.equal(r.code,1,`${JSON.stringify(bad)} ${args[0]}`);
|
|
assert.ok(r.err.endsWith(`stack-release: ${e.pointer} is unreadable; Jason or Mos settles it\n`),r.err);
|
|
}
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
}
|
|
});
|
|
await t.test('pm-pending, already-adopted and the status line',async(t)=>{
|
|
const e=await mk(t);await pending(e);
|
|
for(const args of [['activate',C,'acme','--migrate'],['activate',C,'acme','--pm','--migrate'],['activate',C1,'acme','--pm-after-harness']])await refused(e,args,'pm-pending',PENDING_MSG);
|
|
await refused(e,['adopt','acme'],'already-adopted',`${e.pointer} exists (copy ${C1})`);
|
|
const before=tree(e.data),r=await run(e,'status','acme');assert.equal(r.code,0,r.err);
|
|
const out=r.out.split('\n');
|
|
assert.deepEqual(out.slice(0,3),[`pointer: ${e.pointer}`,` ${readFileSync(e.pointer,'utf8').trimEnd()}`,'pm: pending (activate --pm-after-harness); next: Jason\'s plugins set of harness, then stack-release.sh pm-on acme']);
|
|
assert.ok(out.includes('unit flags: match the pointer'),r.out);
|
|
assert.deepEqual(tree(e.data),before);
|
|
});
|
|
});
|
|
|
|
test('T34 item 3: pm-on refuses before any stop, passes with the PM, or puts the phase-1 bus back',async(t)=>{
|
|
await t.test('not-pending',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});biz(e,HARNESS);
|
|
await refused(e,['pm-on','acme'],'not-pending','the pointer is not pending; pm-on follows activate --pm-after-harness');
|
|
});
|
|
await t.test('harness-unset',async(t)=>{
|
|
const e=await mk(t);await pending(e);
|
|
await refused(e,['pm-on','acme'],'harness-unset','the business file sets no harness; Jason sets it with plugins set first');
|
|
biz(e,{harness:[]});
|
|
await refused(e,['pm-on','acme'],'harness-unset','the business file\'s harness is [], not a non-empty list');
|
|
});
|
|
await t.test('a launched line passes: the bus alone restarts with --pm',async(t)=>{
|
|
const e=await mk(t);await pending(e);biz(e,HARNESS);
|
|
const since=sinceLines(e),n=calls(e).length,con=sha(join(e.units,CON)),act=pointer(e).activation;
|
|
const r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(r.out.endsWith(`stack-release: pm-on ok: the PM is live on ${C1} for acme\n`),r.out);
|
|
assert.deepEqual(startsStops(e,n),[`stop ${BUS}`,`start ${BUS}`]);
|
|
assert.ok(busUnit(e).includes(execLine(C1,true)),busUnit(e));assert.equal(sha(join(e.units,CON)),con);
|
|
assert.equal(sinceLines(e),since+1);assert.match(lines(join(e.sd,'journal-since.log')).at(-1),/^@\d+$/);
|
|
const {at,activation,...p}=pointer(e);assert.notEqual(activation,act);
|
|
assert.deepEqual(p,{copy:C1,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'});
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.to,l.result,l.pm,l.activation],['pm-on',C1,'ok','on',activation]);
|
|
const s=await run(e,'status','acme');assert.equal(s.code,0,s.err);assert.ok(!s.out.includes('pm: pending'),s.out);
|
|
});
|
|
await t.test('a failed pm_launched puts the phase-1 unit back: exit 1, still pending',async(t)=>{
|
|
const e=await mk(t);await pending(e);biz(e,HARNESS);fault(e,`pm-${C1}`,'none');
|
|
const before=fp(e),p0=readFileSync(e.pointer,'utf8'),n=calls(e).length;
|
|
const r=await run(e,'pm-on','acme','--timeout','3');assert.equal(r.code,1,r.out+r.err);
|
|
const l=logLines(e).at(-1),rs=join(e.state,'stack-rollback',l.activation);
|
|
assert.equal(r.err,`stack-release: pm-on failed with pm-launch-unconfirmed (no launched line since the start); the bus runs ${C1} without its PM again and passed the phase-1 gate, and the pointer stays pending. Fix harness with plugins set, then run pm-on again. Rollback set: ${rs}\n`);
|
|
assert.deepEqual([l.op,l.result,l.code,l.why],['pm-on','pm-on-failed','pm-launch-unconfirmed','no launched line since the start']);
|
|
assert.deepEqual(fp(e),before);assert.ok(busUnit(e).includes(execLine(C1,false)));
|
|
assert.equal(readFileSync(e.pointer,'utf8'),p0);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[BUS].wd,units(e)[CON].state],['active',join(COPIES,C1),'active']);
|
|
assert.deepEqual(startsStops(e,n),[`stop ${BUS}`,`start ${BUS}`,`stop ${BUS}`,`start ${BUS}`]);
|
|
});
|
|
await t.test('a failed phase-1 gate too: pm-on-down, exit 2, both units stopped',async(t)=>{
|
|
const e=await mk(t);await pending(e);biz(e,HARNESS);fault(e,`healthz-${C1}`);
|
|
const p0=readFileSync(e.pointer,'utf8'),store=sha(e.store);
|
|
const r=await run(e,'pm-on','acme','--timeout','3');assert.equal(r.code,2,r.out+r.err);
|
|
const l=logLines(e).at(-1),rs=join(e.state,'stack-rollback',l.activation);
|
|
assert.equal(r.err,`stack-release: pm-on-down: pm-on failed with healthz and the phase-1 bus with healthz; both units are stopped. Rollback set: ${rs}\n`);
|
|
assert.deepEqual([l.result,l.code,l.phase1],['pm-on-down','healthz','healthz']);
|
|
assert.deepEqual([units(e)[BUS].state,units(e)[CON].state],['inactive','inactive']);
|
|
assert.equal(readFileSync(e.pointer,'utf8'),p0);assert.equal(sha(e.store),store);
|
|
assert.ok(busUnit(e).includes(execLine(C1,false)));
|
|
});
|
|
});
|
|
|
|
test('T35 item 4: rollback from pending installs --pm; a plug-in kind the target lacks refuses by name',async(t)=>{
|
|
await t.test('rollback from pending gates pm_launched on the old copy',async(t)=>{
|
|
const e=await mk(t);await pending(e);const since=sinceLines(e);
|
|
const r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(busUnit(e).includes(execLine(B,true)),busUnit(e));
|
|
assert.equal(sinceLines(e),since+1);
|
|
const {at,activation,...p}=pointer(e);
|
|
assert.deepEqual(p,{copy:B,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g'});
|
|
});
|
|
await t.test('after the harness set, rollback refuses until harness is gone',async(t)=>{
|
|
const e=await mk(t);await pending(e);biz(e,HARNESS);
|
|
assert.equal((await run(e,'pm-on','acme')).code,0);
|
|
await refused(e,['rollback','acme'],'unknown-kind-for-target',`the business file names plug-in kind(s) harness, which ${B} does not know; set each to null with plugins set through the live bus first`);
|
|
biz(e);
|
|
const r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(busUnit(e).includes(execLine(B,true)));assert.deepEqual([pointer(e).copy,pointer(e).pm],[B,true]);
|
|
});
|
|
await t.test('activate names every kind the target lacks',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
biz(e,{...HARNESS,widget:{}});
|
|
await refused(e,['activate',A,'acme'],'unknown-kind-for-target',`the business file names plug-in kind(s) harness widget, which ${A} does not know`);
|
|
await refused(e,['activate',C1,'acme'],'unknown-kind-for-target',`the business file names plug-in kind(s) widget, which ${C1} does not know`);
|
|
});
|
|
});
|
|
|
|
test('T36 --plan shows the pending state and lists the row 169 refusals it can see',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});
|
|
const quiet=async(code,...args)=>{
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length,r=await run(e,...args);
|
|
assert.equal(r.code,code,r.out+r.err);assert.equal(r.err,'');
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
return r.out.split('\n');
|
|
};
|
|
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
|
|
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
|
biz(e,HARNESS);
|
|
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
|
|
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
|
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
|
|
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
|
|
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
|
|
out=await quiet(3,'activate',C,'acme','--plan');
|
|
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
|
out=await quiet(0,'rollback','acme','--plan');
|
|
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
|
});
|
|
|
|
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
|
|
const testerFlags=(e,port='7333')=>['--port',e.port,'--business','acme','--tester-port',port];
|
|
const consoleText=(e)=>readFileSync(join(e.units,CON),'utf8');
|
|
const stepText=(e)=>readFileSync(join(e.state,'stack-rollback',pointer(e).activation,'steps.log'),'utf8');
|
|
|
|
test('T37 tester-port activate writes pointer and unit, replaces the port, and supported re-pin and rollback keep it',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
let r=await run(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,testerFlags(e));assert.match(consoleText(e),/--tester-port 7333(?:\n|$)/);
|
|
r=await run(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
r=await run(e,'activate',B,'acme','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,testerFlags(e,'7334'));
|
|
assert.match(consoleText(e),/--tester-port 7334(?:\n|$)/);assert.doesNotMatch(consoleText(e),/--tester-port 7333/);
|
|
assert.equal((consoleText(e).match(/--tester-port/g)||[]).length,1);
|
|
r=await run(e,'activate',C1,'acme','--plan');assert.equal(r.code,0,r.out+r.err);assert.match(r.out,/Console: .*--tester-port 7334/);
|
|
r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);assert.deepEqual(pointer(e).console,testerFlags(e,'7334'));
|
|
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
|
assert.deepEqual(pointer(e).console,testerFlags(e,'7334'));assert.match(consoleText(e),/--tester-port 7334(?:\n|$)/);
|
|
});
|
|
|
|
test('T38 tester-port rollback to unsupported target drops flag in plan, steps, unit and pointer',async(t)=>{
|
|
const e=await mk(t);await seed(e,{copy:A});
|
|
let r=await run(e,'activate',B,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
|
|
const before=fp(e),n=calls(e).length;
|
|
r=await run(e,'rollback','acme','--plan');assert.equal(r.code,0,r.out+r.err);
|
|
assert.match(r.out,/drop[^\n]*--tester-port 7333/i);assert.doesNotMatch(r.out.split('\n').find((l)=>l.startsWith('Console: ')),/--tester-port/);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,A);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme']);assert.doesNotMatch(consoleText(e),/--tester-port/);
|
|
assert.match(stepText(e),/drop[^\n]*--tester-port 7333/i);
|
|
});
|
|
|
|
test('T39 tester-port explicit unsupported target reads target copy and refuses before stopping either unit',async(t)=>{
|
|
const e=await mk(t);await seed(e);const before=fp(e),n=calls(e).length;
|
|
const r=await run(e,'activate',A,'acme','--tester-port','7333');assert.equal(r.code,3,r.out+r.err);
|
|
assert.match(r.err,/refusing tester-port-target:/);assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
assert.equal(units(e)[BUS].state,'active');assert.equal(units(e)[CON].state,'active');
|
|
// The repository's Console accepts the flag while A's Console does not: probing REPO fails this test.
|
|
assert.match(readFileSync(join(repo,'packages/webui/src/cli.mjs'),'utf8'),/--tester-port/);
|
|
});
|
|
|
|
test('T40 tester-port invalid values refuse exit 4 before stopping, including tunnel and main ports',async(t)=>{
|
|
const e=await mk(t);await seed(e);const before=fp(e),n=calls(e).length;
|
|
for(const value of ['7332',e.port,'1023','65536','x','7333.5']){
|
|
const r=await run(e,'activate',C1,'acme','--tester-port',value);assert.equal(r.code,4,`${value}: ${r.out}${r.err}`);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
}
|
|
});
|
|
|
|
test('T41 tester-port inherited activate to unsupported target drops it and retains drift refusal and failed-run recovery',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
let r=await run(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
|
|
r=await run(e,'activate',A,'acme','--plan');assert.equal(r.code,0,r.out+r.err);assert.match(r.out,/drop[^\n]*--tester-port 7333/i);
|
|
fault(e,`check-${A}`);const before=fp(e);
|
|
r=await run(e,'activate',A,'acme','--timeout','3');assert.equal(r.code,1,r.out+r.err);assert.deepEqual(fp(e),before);
|
|
rmSync(join(e.sd,'faults',`check-${A}`));
|
|
edit(join(e.units,CON),'--tester-port 7333','--tester-port 7334');const n=calls(e).length;
|
|
r=await run(e,'activate',A,'acme');assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/unit-flags-drift/);assert.deepEqual(startsStops(e,n),[]);
|
|
edit(join(e.units,CON),'--tester-port 7334','--tester-port 7333');
|
|
r=await run(e,'activate',A,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme']);assert.doesNotMatch(consoleText(e),/--tester-port/);
|
|
assert.match(stepText(e),/drop[^\n]*--tester-port 7333/i);
|
|
});
|
|
|
|
test('T42 tester-port pm-on preserves pointer Console flags',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});
|
|
let r=await run(e,'activate',C1,'acme','--pm-after-harness','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
|
|
biz(e,HARNESS);
|
|
r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,testerFlags(e));assert.match(consoleText(e),/--tester-port 7333(?:\n|$)/);
|
|
});
|
|
|
|
// Row 180 item 1 (P19 391): a date stand-in prints one second for every call that names a time,
|
|
// so each run below starts in that second, as two runs within one real second do. Calls for
|
|
// +%s (the gate's start mark) pass through, so each gate still reads only its own start.
|
|
function pinClock(e){
|
|
const dir=join(e.root,'clock'),second=Math.floor(Date.now()/1000);mkdirSync(dir);
|
|
writeFileSync(join(dir,'date'),`#!/bin/sh\ncase "$*" in *%s*) exec /usr/bin/date "$@" ;; esac\nexec /usr/bin/date -d @${second} "$@"\n`,{mode:0o755});
|
|
e.env={...e.env,PATH:`${dir}:${e.env.PATH}`};
|
|
return execFileSync(join(dir,'date'),['-u','+%Y%m%dT%H%M%SZ'],{env:e.env,encoding:'utf8'}).trim();
|
|
}
|
|
const rollbackSets=(e)=>readdirSync(join(e.state,'stack-rollback')).sort();
|
|
|
|
test('T43 row 180 item 1: runs in one second get distinct activation ids, rollback sets and post-attempt stores',async(t)=>{
|
|
await t.test('activate then pm-on on the same copy',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});const sec=pinClock(e),id=`${sec}-${C1.slice(0,12)}`;
|
|
let r=await run(e,'activate',C1,'acme','--pm-after-harness');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).activation,id);
|
|
biz(e,HARNESS);
|
|
r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).activation,`${id}-1`);
|
|
assert.deepEqual(logLines(e).slice(-4).map((l)=>[l.op,l.result,l.activation]),[['activate','started',id],['activate','ok',id],['pm-on','started',`${id}-1`],['pm-on','ok',`${id}-1`]]);
|
|
assert.deepEqual(rollbackSets(e),[id,`${id}-1`]);
|
|
for(const a of [id,`${id}-1`])assert.ok(existsSync(join(e.state,'stack-rollback',a,'SHA256SUMS')),a);
|
|
});
|
|
await t.test('two restores: an old-form id and a suffixed one, each with its own post-attempt store',async(t)=>{
|
|
const e=await mk(t);await seed(e);const sec=pinClock(e),c=`${sec}-${C.slice(0,12)}`,b=`${sec}-${B.slice(0,12)}`;
|
|
for(const [act,restore] of [[c,b],[`${c}-1`,`${b}-1`]]){
|
|
let r=await run(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).activation,act);
|
|
assert.ok(existsSync(join(e.data,'backups','stack',act,'SHA256SUMS')),act);
|
|
r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,0,r.out+r.err);
|
|
const post=join(e.data,`bus.post-attempt-${sec}${restore.slice(b.length)}`);
|
|
assert.ok(r.out.startsWith(`restore: ${join(e.data,'backups','stack',act)}; the store now in place moves to ${post} and is kept; 0 event(s) were written after the backup\n`),r.out);
|
|
assert.deepEqual([pointer(e).copy,pointer(e).activation,pointer(e).schema],[B,restore,'3g']);
|
|
assert.equal(logLines(e).at(-1).backup,act);
|
|
}
|
|
assert.deepEqual(posts(e).sort(),[`bus.post-attempt-${sec}`,`bus.post-attempt-${sec}-1`]);
|
|
assert.deepEqual(readdirSync(join(e.data,'backups','stack')).sort(),[c,`${c}-1`]);
|
|
assert.deepEqual(rollbackSets(e),[c,`${c}-1`,b,`${b}-1`].sort());
|
|
});
|
|
await t.test('adopt takes the next free id too',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pointer:'none'});const id=`${pinClock(e)}-${B.slice(0,12)}`;
|
|
mkdirSync(join(e.state,'stack-rollback',id),{recursive:true});
|
|
const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).activation,`${id}-1`);
|
|
assert.deepEqual(logLines(e).map((l)=>[l.op,l.result,l.activation]),[['adopt','ok',`${id}-1`]]);
|
|
});
|
|
await t.test('all ten names taken: capture-failed before anything stops, and the log line names no activation',async(t)=>{
|
|
const e=await mk(t);await seed(e);const sec=pinClock(e),id=`${sec}-${C1.slice(0,12)}`;
|
|
// A rollback set, a backup or a post-attempt store each takes a name.
|
|
for(const s of ['','-1','-2','-3','-4','-5','-6','-7'])mkdirSync(join(e.state,'stack-rollback',`${id}${s}`),{recursive:true});
|
|
mkdirSync(join(e.data,'backups','stack',`${id}-8`),{recursive:true});writeFileSync(join(e.data,`bus.post-attempt-${sec}-9`),'');
|
|
await refused(e,['activate',C1,'acme'],'capture-failed',`every activation id for ${id} is taken; nothing was stopped`);
|
|
assert.equal(logLines(e).at(-1).activation,null);
|
|
});
|
|
await t.test('--restore takes an id with or without -1 to -9 and nothing else',async(t)=>{
|
|
const e=await mk(t);await seed(e);const id=`20261020T013708Z-${B.slice(0,12)}`;
|
|
for(const bad of [`${id}-0`,`${id}-10`,`${id}-a`,`${id}-`,`${id}1`]){
|
|
const r=await run(e,'rollback','acme','--restore',bad,'--plan');assert.equal(r.code,4,bad);
|
|
}
|
|
for(const good of [id,`${id}-1`,`${id}-9`]){
|
|
const r=await run(e,'rollback','acme','--restore',good,'--plan');assert.notEqual(r.code,4,`${good}: ${r.err}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
test('T44 row 180 item 2: --plan lists tester-port-invalid and tester-port-target with its other refusals',async(t)=>{
|
|
const usageText=async(e)=>(await run(e,'--help')).err;
|
|
await t.test('a bad tester port',async(t)=>{
|
|
const e=await mk(t);await seed(e,{pm:true});biz(e,HARNESS);
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length,p0=readFileSync(e.pointer,'utf8');
|
|
const rule=(port,main)=>`--tester-port ${port} is not a whole port from 1024 to 65535 other than 7332${main?` and the Console's --port ${main}`:''}`;
|
|
// The fixture's Console has --port e.port; a pointer without --port has the default 7332, which
|
|
// the reason names once. --plan reads the pointer's flags and not the units, so only it changes.
|
|
const noPort=()=>{const q=JSON.parse(p0);q.console=q.console.filter((f,i,a)=>f!=='--port'&&a[i-1]!=='--port');writeFileSync(e.pointer,JSON.stringify(q)+'\n')};
|
|
for(const [port,reason,edit] of [['80',rule('80',e.port)],[e.port,rule(e.port,e.port)],['80',rule('80'),noPort],['7332',rule('7332'),noPort]]){
|
|
if(edit)edit();
|
|
const r=await run(e,'activate',C1,'acme','--pm-after-harness','--plan','--tester-port',port);
|
|
assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,'');
|
|
assert.deepEqual(r.out.split('\n').filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: ${reason}`,'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm']);
|
|
const u=await run(e,'activate',C1,'acme','--pm-after-harness','--tester-port',port);
|
|
assert.equal(u.code,4,u.out+u.err);assert.equal(u.out,'');assert.equal(u.err,await usageText(e));
|
|
writeFileSync(e.pointer,p0);
|
|
}
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
});
|
|
await t.test('without --plan a bad port is usage before any pointer read',async(t)=>{
|
|
// parse_opts checks it, so a host with no pointer gets usage and not no-pointer (exit 3, a log line).
|
|
const e=await mk(t);await seed(e,{pointer:'none'});
|
|
const n=calls(e).length,u=await run(e,'activate',C1,'acme','--tester-port','80');
|
|
assert.equal(u.code,4,u.out+u.err);assert.equal(u.out,'');assert.equal(u.err,await usageText(e));
|
|
assert.deepEqual(logLines(e),[]);assert.deepEqual(startsStops(e,n),[]);
|
|
});
|
|
await t.test('a target Console without --tester-port',async(t)=>{
|
|
const e=await mk(t);await seed(e);biz(e,HARNESS);
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
|
const r=await run(e,'activate',A,'acme','--tester-port','7333','--plan');
|
|
assert.equal(r.code,3,r.out+r.err);assert.equal(r.err,'');
|
|
assert.deepEqual(r.out.split('\n').filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-target: ${A}'s Console does not support --tester-port`,`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`]);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
await refused(e,['activate',A,'acme','--tester-port','7333'],'tester-port-target',`${A}'s Console does not support --tester-port`);
|
|
});
|
|
});
|
|
|
|
// Row 181 (#1679, P19 390): COUNTED and STORE_JS as the script under test has them. The fixture
|
|
// tables carry only seq and at, which is all the count reads.
|
|
const srText=readFileSync(SR,'utf8');
|
|
const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
|
|
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
|
|
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
|
|
// well as T46.
|
|
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels'];
|
|
const LATE='2999-01-01T00:00:00.000Z';
|
|
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
|
|
|
|
test('T45 row 181 items 2 and 3: the restore guard counts events and every COUNTED table',async(t)=>{
|
|
await t.test('both modes count events and each listed table, and a table the store lacks counts 0',async(t)=>{
|
|
const d=mkdtempSync(join(tmpdir(),'sr181-'));t.after(()=>rmSync(d,{recursive:true,force:true}));
|
|
const stamp='2020-01-01T00:00:00.000Z',count=(f,mode)=>execFileSync(NODE,['-e',STORE_JS,'--',mode,f,stamp,COUNTED.join(' ')],{encoding:'utf8'});
|
|
const store=(name,rows,tables=rows)=>{
|
|
const f=join(d,name);for(const x of ['events',...tables])addRow(f,x,'2000-01-01T00:00:00.000Z');for(const x of rows)addRow(f,x);return f;
|
|
};
|
|
const both=(f,want)=>{const s=sha(f);for(const mode of ['count','heldcount'])assert.equal(count(f,mode),want,`${basename(f)} ${mode}`);assert.equal(sha(f),s);assert.deepEqual(readdirSync(d).filter((n)=>n.startsWith(`${basename(f)}-`)),[])};
|
|
for(const x of LISTED)both(store(`${x}.sqlite`,['events',x]),`2\t1 event(s), 1 ${x} row(s)\n`);
|
|
both(store('brief.sqlite',['events','usage_records','instance_names']),'3\t1 event(s), 1 instance_names row(s), 1 usage_records row(s)\n');
|
|
// A 3f store: no usage_records table, and nothing to refuse.
|
|
both(store('3f.sqlite',['events'],['events','instance_names']),'1\t1 event(s)\n');
|
|
both(store('none.sqlite',[],['instance_names']),'0\t0 event(s)\n');
|
|
});
|
|
for(const x of ['instance_names','usage_records'])await t.test(`one ${x} row and no event refuses before any stop; --events 1 restores`,async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
|
addRow(e.store,x);
|
|
const want=`stack-release: refusing post-backup-events: 0 event(s), 1 ${x} row(s) were written after the backup; Jason decides, then --events 1\n`;
|
|
for(const extra of [[],['--events','2']]){
|
|
const before=fp(e),n=calls(e).length,r=await run(e,'rollback','acme','--restore',act,...extra);
|
|
assert.equal(r.code,3,extra.join(' '));assert.equal(r.err,want);assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
}
|
|
const r=await run(e,'rollback','acme','--restore',act,'--events','1');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(r.out.includes(`; 0 event(s), 1 ${x} row(s) were written after the backup\n`),r.out);
|
|
assert.deepEqual([pointer(e).copy,pointer(e).schema],[B,'3g']);assert.equal(sha(e.store),backupSum(e,act));
|
|
});
|
|
await t.test('a listed table that cannot be read refuses restore-mismatch before any stop',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
|
const stamp=readFileSync(join(e.data,'backups','stack',act,'STAMP'),'utf8').replace(/\n+$/,'');
|
|
const db=new DatabaseSync(e.store);db.exec('CREATE TABLE usage_records (seq INTEGER PRIMARY KEY)');db.close();
|
|
const before=fp(e),n=calls(e).length,r=await run(e,'rollback','acme','--restore',act);
|
|
assert.equal(r.code,3,r.out+r.err);assert.ok(r.err.endsWith(`stack-release: refusing restore-mismatch: cannot count the rows after ${stamp}\n`),r.err);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).at(-1).code,'restore-mismatch');
|
|
});
|
|
await t.test('the in-run recount counts the listed tables too',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C,'acme','--migrate')).code,0);const act=pointer(e).activation;
|
|
fault(e,'stop-writes-row','thread_opens');
|
|
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.err,rolledBack('post-backup-events',C));
|
|
assert.ok(r.out.includes('; 0 event(s) were written after the backup\n'),r.out);
|
|
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C,'3h']);assert.equal(posts(e).length,0);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.result,l.code,l.rollback],['gate-failed','post-backup-events','ok']);
|
|
});
|
|
});
|
|
|
|
test('T46 row 181 item 4: every table in the bus schema is in exactly one of four lists',()=>{
|
|
// The schema a target copy's store gets. Lists 1 and 2 come from the script, so a table added to
|
|
// COUNTED or dropped from it shows here. Row 181's BUILD.md names each writer.
|
|
const db=new DatabaseSync(':memory:');db.exec(readFileSync(join(ROOT,'packages','bus','schema.sql'),'utf8'));
|
|
const tables=db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r)=>r.name);
|
|
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
|
|
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
|
|
const untimed={
|
|
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
|
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
|
host_grants:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
|
hosts:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
|
meta:'key and value only; its one UPDATE is the migration step',
|
|
message_threads:'no time column; each row goes with a messages row, which is counted',
|
|
sqlite_sequence:'SQLite\'s own AUTOINCREMENT table',
|
|
task_comment_cursors:'no time column; a restore drops its advances uncounted (BUILD.md)',
|
|
};
|
|
const lists=[['events'],COUNTED,Object.keys(always),Object.keys(untimed)],all=lists.flat();
|
|
assert.equal(new Set(all).size,all.length,`a table is in two lists: ${all.filter((n,i)=>all.indexOf(n)!==i)}`);
|
|
assert.deepEqual(tables.filter((n)=>!all.includes(n)),[],'tables in no list');
|
|
assert.deepEqual(all.filter((n)=>!tables.includes(n)),[],'listed tables the schema lacks');
|
|
for(const n of lists.slice(0,3).flat())assert.ok(hasAt(n),`${n} has at`);
|
|
for(const n of lists[3])assert.ok(!hasAt(n),`${n} has no at`);
|
|
db.close();
|
|
});
|
|
|
|
// Row 185 (#1683): every activate with a store backs it up under its activation id, before any
|
|
// migration. Only a backup taken before a migration (TAKEN) is restored after a gate failure.
|
|
test('T47 row 185 test 1: a same-schema activate leaves a whole backup and the log line names it',async(t)=>{
|
|
const e=await mk(t);await seed(e);const before=fp(e);
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const act=pointer(e).activation,o=join(e.data,'backups','stack',act);
|
|
assert.ok(r.out.includes(`backup: ${o}\n`),r.out);
|
|
for(const f of ['SHA256SUMS','DATA_ROOT','SCHEMA','STAMP'])assert.ok(existsSync(join(o,f)),f);
|
|
assert.equal(readFileSync(join(o,'DATA_ROOT'),'utf8'),`${e.data}\n`);
|
|
assert.equal(readFileSync(join(o,'SCHEMA'),'utf8'),`${storeVersion(e.store,true)}\n`);assert.equal(storeVersion(e.store,true),'3g');
|
|
assert.match(readFileSync(join(o,'STAMP'),'utf8'),/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\n$/);
|
|
assert.equal(backupSum(e,act),before.store);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.result,l.activation,l.backup],['activate','ok',act,act]);
|
|
});
|
|
|
|
test('T48 row 185 test 2: a gate failure on a same-schema activate rolls back without a restore',async(t)=>{
|
|
const e=await mk(t);await seed(e);const before=fp(e);
|
|
fault(e,`start-writes-row-${C1}`);fault(e,`healthz-${C1}`);
|
|
const r=await run(e,'activate',C1,'acme','--timeout','3');assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.err,rolledBack('healthz'));
|
|
const l=logLines(e).at(-1),act=l.activation;
|
|
assert.deepEqual([l.result,l.code,l.rollback,l.backup],['gate-failed','healthz','ok',act]);
|
|
// The store as the target's bus left it when the rollback stopped it, after the failed gate.
|
|
const failed=snaps(e).filter((x)=>x.ev==='stop').at(-1).files['bus.sqlite'][2];
|
|
assert.notEqual(failed,backupSum(e,act),'the target start wrote a row after the backup');
|
|
assert.equal(sha(e.store),failed);assert.equal(posts(e).length,0);
|
|
assert.deepEqual({...fp(e),store:before.store},before);assert.equal(units(e)[BUS].wd,join(COPIES,B));
|
|
});
|
|
|
|
test('T49 row 185 test 3: a same-schema rollback --restore restores, or refuses rows written after STAMP',async(t)=>{
|
|
await t.test('no rows after STAMP: restores and goes live',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C1,'acme')).code,0);const act=pointer(e).activation,storeC1=sha(e.store);
|
|
const r=await run(e,'rollback','acme','--restore',act);assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(r.out.startsWith(`restore: ${join(e.data,'backups','stack',act)}; the store now in place moves to `),r.out);
|
|
assert.ok(r.out.includes('; 0 event(s) were written after the backup\n'),r.out);
|
|
assert.deepEqual([pointer(e).copy,pointer(e).schema],[B,'3g']);assert.equal(sha(e.store),backupSum(e,act));
|
|
assert.equal(posts(e).length,1);assert.equal(sha(join(e.data,posts(e)[0],'bus.sqlite')),storeC1);
|
|
assert.equal(logLines(e).at(-1).backup,act);
|
|
});
|
|
await t.test('one instance_names row after STAMP refuses post-backup-events; --events 1 restores',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
assert.equal((await run(e,'activate',C1,'acme')).code,0);const act=pointer(e).activation;
|
|
addRow(e.store,'instance_names');
|
|
const before=fp(e),n=calls(e).length,no=await run(e,'rollback','acme','--restore',act);
|
|
assert.equal(no.code,3,no.out+no.err);
|
|
assert.equal(no.err,'stack-release: refusing post-backup-events: 0 event(s), 1 instance_names row(s) were written after the backup; Jason decides, then --events 1\n');
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);
|
|
const r=await run(e,'rollback','acme','--restore',act,'--events','1');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual([pointer(e).copy,pointer(e).schema],[B,'3g']);assert.equal(sha(e.store),backupSum(e,act));
|
|
});
|
|
});
|
|
|
|
test('T50 row 185 test 4: activate --plan prints the backup line and writes nothing',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length;
|
|
const r=await run(e,'activate',C1,'acme','--plan');assert.equal(r.code,0,r.err);assert.equal(r.err,'');
|
|
assert.deepEqual(r.out.split('\n').filter((x)=>x.startsWith('backup: ')),[planBackup(e)]);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
assert.ok(!existsSync(join(e.data,'backups')));
|
|
// Rollback is unchanged: it takes no backup, so its plan has no backup line.
|
|
assert.equal((await run(e,'activate',C1,'acme')).code,0);
|
|
const rb=await run(e,'rollback','acme','--plan');assert.equal(rb.code,0,rb.err);
|
|
assert.deepEqual(rb.out.split('\n').filter((x)=>x.startsWith('backup')),[]);
|
|
});
|
|
|
|
test('T51 row 185 test 5: an activate with no store file takes no backup',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
sysctl(e,'stop',BUS);for(const f of readdirSync(join(e.data,'bus')))rmSync(join(e.data,'bus',f));
|
|
const p=await run(e,'activate',C1,'acme','--plan');assert.equal(p.code,0,p.err);
|
|
assert.deepEqual(p.out.split('\n').filter((x)=>x.startsWith('backup')),['backup: none; there is no store file']);
|
|
const r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.ok(!existsSync(join(e.data,'backups')));assert.ok(!r.out.includes('backup: '),r.out);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.result,l.backup],['ok',null]);
|
|
});
|
|
|
|
// Row 186 (#1684): the activate plan prints the backup line only when the run reaches backup_store.
|
|
// Each case plans, checks nothing was written, then runs and checks a backup exists exactly when the plan said so.
|
|
test('T52 row 186: the activate plan\'s backup line says what the run will do',async(t)=>{
|
|
const newer='migration: none possible; 3z is not in 3h\'s migration chain, so the run would roll back with store-newer';
|
|
const cases=[
|
|
['same schema','3g',C1,[],'migration: none; the store is at the target\'s version',true,0],
|
|
['same schema with --migrate','3g',C1,['--migrate'],'migration: none; the store is at the target\'s version',true,0],
|
|
['in chain with --migrate','3g',C,['--migrate'],'needs: --migrate (given)',true,0],
|
|
['in chain without --migrate','3g',C,[],'needs: --migrate (not given; the run would roll back with migration-needs-flag)',false,1],
|
|
['outside the chain','3z',C,[],newer,false,1],
|
|
['outside the chain with --migrate','3z',C,['--migrate'],newer,false,1],
|
|
];
|
|
for(const [name,store,to,flags,why,backup,code] of cases)await t.test(name,async(t)=>{
|
|
const e=await mk(t);await seed(e,{store});
|
|
const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length;
|
|
const p=await run(e,'activate',to,'acme','--plan',...flags);assert.equal(p.code,0,p.err);assert.equal(p.err,'');
|
|
const out=p.out.split('\n');assert.ok(out.includes(why),p.out);
|
|
assert.deepEqual(out.filter((x)=>x.startsWith('backup')),[backup?planBackup(e):planNoBackup]);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
assert.ok(!existsSync(join(e.data,'backups')));
|
|
const r=await run(e,'activate',to,'acme',...flags,'--timeout','3');assert.equal(r.code,code,r.out+r.err);
|
|
assert.equal(existsSync(join(e.data,'backups','stack')),backup);assert.equal(logLines(e).at(-1).backup??null,backup?logLines(e).at(-1).activation:null);
|
|
});
|
|
});
|
|
|
|
// Row 178: proof metadata uses temp fixture files, never the installed slot.
|
|
async function seedProof(e) {
|
|
await seed(e);
|
|
const file=join(e.root,'proof.fixture');writeFileSync(file,'fixture_proxy_proof_012345678901234567890123456789',{mode:0o600});
|
|
const extra=['--public-host','console.example','--auth-header','x-user','--allow-user','jason','--proof-header','x-console-proof','--proof-file',file];
|
|
const p=pointer(e);p.console.push(...extra);writeFileSync(e.pointer,JSON.stringify(p)+'\n');
|
|
const unit=join(e.units,CON);edit(unit,` --business acme\n`,` --business acme ${extra.join(' ')}\n`);
|
|
return p.console;
|
|
}
|
|
test('T178 proxy proof header and path survive re-pin and rollback unchanged in the pointer and unit',async t=>{
|
|
const e=await mk(t),expected=await seedProof(e);
|
|
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,expected);assert.ok(consoleText(e).includes(expected.join(' ')));
|
|
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,expected);assert.ok(consoleText(e).includes(expected.join(' ')));
|
|
});
|
|
test('T178 proxy proof pair survives a tester-port replacement as well',async t=>{
|
|
const e=await mk(t),expected=await seedProof(e);
|
|
const r=await run(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...expected,'--tester-port','7333']);
|
|
assert.ok(consoleText(e).includes([...expected,'--tester-port','7333'].join(' ')));
|
|
});
|
|
|
|
// Row 187 (#1685): activate sets, carries and drops the proof pair. Every proof file here holds
|
|
// SENTINEL, which no output, unit, pointer, log or steps log may hold (brief test 8), and an
|
|
// inotify watch counts each open or read of it, which must stay 0: the script checks the file
|
|
// with lstat and stat only. Python's ctypes reaches inotify(7); node's fs.watch has no IN_ACCESS.
|
|
const SENTINEL='row187_sentinel_proof_value_0123456789abcdef';
|
|
const PREVIEW=['--public-host','console.example','--auth-header','x-user','--allow-user','jason'];
|
|
const WATCH=`import ctypes,os,select,struct,sys
|
|
libc=ctypes.CDLL(None,use_errno=True)
|
|
fd=libc.inotify_init1(0)
|
|
if fd<0 or libc.inotify_add_watch(fd,os.fsencode(sys.argv[1]),0x21)<0:sys.exit(2)
|
|
print('ready',flush=True)
|
|
n=0
|
|
def drain(t):
|
|
global n
|
|
while select.select([fd],[],[],t)[0]:
|
|
b=os.read(fd,65536);i=0
|
|
while i<len(b):n+=1;i+=16+struct.unpack_from('iIII',b,i)[3]
|
|
t=0
|
|
while True:
|
|
r=select.select([fd,0],[],[])[0]
|
|
if fd in r:drain(0)
|
|
if 0 in r and not os.read(0,1):break
|
|
drain(0)
|
|
print(n,flush=True)`;
|
|
// A subtest that fails before noLeak leaves its watches running; they end with the file.
|
|
const WATCHERS=new Set();
|
|
after(()=>{for(const ch of WATCHERS)ch.kill('SIGKILL')});
|
|
// A proof file under dir (e.root, outside the data root, unless given), with its read watch running.
|
|
async function proofFile(e,name='proof.187',mode=0o600,dir=e.root){
|
|
const f=join(dir,name);writeFileSync(f,SENTINEL+'\n',{mode});chmodSync(f,mode);
|
|
const ch=spawn('python3',['-I','-c',WATCH,f],{stdio:['pipe','pipe','inherit']});
|
|
let out='';ch.stdout.on('data',(d)=>{out+=d});
|
|
await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))});
|
|
WATCHERS.add(ch);ch.on('close',()=>WATCHERS.delete(ch));
|
|
(e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return Number(out.split('\n')[1])});
|
|
return f;
|
|
}
|
|
async function prun(e,...args){const r=await run(e,...args);(e.outs??=[]).push(r.out,r.err);return r}
|
|
// Brief test 8 and the read mutant, at the end of each case.
|
|
async function noLeak(e){
|
|
const hits=[];
|
|
const walk=(q)=>{let st;try{st=lstatSync(q)}catch{return}if(st.isDirectory()){for(const n of readdirSync(q))walk(join(q,n))}else if(st.isFile()&&readFileSync(q).includes(SENTINEL))hits.push(q)};
|
|
for(const d of [e.data,e.units,e.sd,e.bin])walk(d);
|
|
(e.outs??[]).forEach((o,i)=>{if(o.includes(SENTINEL))hits.push(`run output ${i}`)});
|
|
assert.deepEqual(hits,[]);
|
|
for(const stop of e.watches??[])assert.equal(await stop(),0,'the run opened or read the proof file');
|
|
}
|
|
// The seeded stack with the preview flags, and any flags in more, in the pointer and the Console unit.
|
|
async function seedPreview(e,opts,more=[]){
|
|
await seed(e,opts);
|
|
const p=pointer(e);p.console.push(...PREVIEW,...more);writeFileSync(e.pointer,JSON.stringify(p)+'\n');
|
|
edit(join(e.units,CON),' --business acme\n',` --business acme ${[...PREVIEW,...more].join(' ')}\n`);
|
|
return p.console;
|
|
}
|
|
const pair=(f,h='x-console-proof')=>['--proof-header',h,'--proof-file',f];
|
|
const conExec=(e)=>consoleText(e).split('\n').find((l)=>l.startsWith('ExecStart='));
|
|
// --plan: exit code, nothing written, nothing started or stopped, no log line; the output lines.
|
|
async function planOnly(e,code,...args){
|
|
const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length;
|
|
const r=await prun(e,...args,'--plan');assert.equal(r.code,code,r.out+r.err);assert.equal(r.err,'');
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
return r.out.split('\n');
|
|
}
|
|
const proofLines=(out)=>out.filter((l)=>l.startsWith('proof:'));
|
|
const CARRY_MSG=(to)=>`${to}'s Console does not support --proof-header, which the pointer's Console flags carry; give --no-proof to drop the pair`;
|
|
const HEADER_MSG=(h)=>`--proof-header ${h} must be a header name of a-z, 0-9 and - (64 at most, lowercased) other than host, origin, x-mosaic-tester and the --auth-header value`;
|
|
|
|
test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{
|
|
await t.test('tests 1 and 9: the pair goes after the preview flags, before --send and the kept --tester-port, as the unit renders it; a new pair replaces it',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),g=await proofFile(e,'proof2.187',0o400);
|
|
const pre=base.slice(0,-1);
|
|
let r=await prun(e,'activate',C1,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
|
|
const out=await planOnly(e,0,'activate',C1,'acme',...pair(f));
|
|
assert.deepEqual(proofLines(out),[`proof: on, header x-console-proof, file ${f}`]);
|
|
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(` ${PREVIEW.join(' ')} ${pair(f).join(' ')} --send --tester-port 7333`));
|
|
r=await prun(e,'activate',C1,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']);
|
|
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${[...pre,...pair(f),'--send','--tester-port','7333'].join(' ')}`),conExec(e));
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
r=await prun(e,'activate',C1,'acme',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two'),'--send','--tester-port','7333']);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
assert.equal(conExec(e).match(/--proof-header/g).length,1);assert.ok(!conExec(e).includes(f));
|
|
await noLeak(e);
|
|
});
|
|
await t.test('tests 2 and 3: an activate of the live copy writes the pair; a later activate and a rollback carry it',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e),f=await proofFile(e);
|
|
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' ')));
|
|
assert.deepEqual(proofLines(await planOnly(e,0,'activate',C1,'acme')),['proof: carried, header x-console-proof']);
|
|
r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' ')));
|
|
assert.deepEqual(proofLines(await planOnly(e,0,'rollback','acme')),['proof: carried, header x-console-proof']);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' ')));
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 2: only a pair change may name the live copy; the log keeps from and to, and rollback still goes to the earlier copy',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e),f=await proofFile(e);
|
|
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
|
|
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
|
|
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
r=await prun(e,'activate',C1,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
let l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);assert.ok(l.backup);
|
|
assert.deepEqual(pointer(e).console,[...base,...pair(f)]);
|
|
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
|
|
const out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
|
|
assert.deepEqual(proofLines(out),['proof: dropped; --no-proof drops header x-console-proof']);
|
|
assert.ok(out.includes('commits: the run adds 0 (newest first, at most 200):'),out.join('\n'));
|
|
r=await prun(e,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
|
l=logLines(e).at(-1);assert.deepEqual([l.from,l.to,l.result],[C1,C1,'ok']);
|
|
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 2: a live-copy activate says only the Console flags change, and backs up the stopped store before it installs',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e),f=await proofFile(e);
|
|
const NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
|
for(const args of [pair(f),['--no-proof']]){
|
|
const out=await planOnly(e,0,'activate',B,'acme',...args);
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[NOTE]);assert.equal(out[out.indexOf(`to: ${B}`)+1],NOTE);
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('backup')),[planBackup(e)]);
|
|
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
|
|
const r=await prun(e,'activate',B,'acme',...args);assert.equal(r.code,0,r.out+r.err);
|
|
const act=pointer(e).activation,l=logLines(e).at(-1);
|
|
assert.deepEqual([l.from,l.to,l.result,l.backup],[B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
|
|
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
|
const steps=stepText(e),at=(x)=>steps.indexOf(x);
|
|
assert.ok(at('stopped both units')>=0&&at('stopped both units')<at('backup: ')&&at('backup: ')<at(`installed `),steps);
|
|
assert.deepEqual(pointer(e).console,args[0]==='--no-proof'?base:[...base,...pair(f)]);
|
|
}
|
|
assert.deepEqual(readdirSync(join(e.data,'backups','stack')).length,2);
|
|
const out=await planOnly(e,0,'activate',C1,'acme');assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 3: pm-on keeps the pair',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,{pm:true}),f=await proofFile(e);
|
|
let r=await prun(e,'activate',C1,'acme','--pm-after-harness',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
biz(e,HARNESS);
|
|
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);assert.ok(conExec(e).endsWith(pair(f).join(' ')));
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 4: --no-proof drops the pair from the pointer and the unit and says so; with no pair it says so and passes',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e),f=await proofFile(e);
|
|
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(proofLines(await planOnly(e,0,'activate',C1,'acme','--no-proof')),['proof: dropped; --no-proof drops header x-console-proof']);
|
|
r=await prun(e,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/);
|
|
assert.match(stepText(e),new RegExp(`drop --proof-header x-console-proof --proof-file ${f}; --no-proof was given\\n`));
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
assert.deepEqual(proofLines(await planOnly(e,0,'activate',B,'acme','--no-proof')),['proof: off; --no-proof was given and the pointer holds no pair']);
|
|
r=await prun(e,'activate',B,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/);
|
|
assert.match(stepText(e),/--no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);
|
|
assert.deepEqual(proofLines(await planOnly(e,0,'activate',C1,'acme')),['proof: off']);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 5: each refusal exits 3 before anything stops',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);
|
|
const f=await proofFile(e),open=await proofFile(e,'open.187',0o644),loose=await proofFile(e,'loose.187',0o640);
|
|
const link=join(e.root,'link.187');symlinkSync(f,link);
|
|
const dir=join(e.root,'dir.187');mkdirSync(dir,{mode:0o700});
|
|
const odd=join(e.root,'odd 187');writeFileSync(odd,SENTINEL+'\n',{mode:0o600});
|
|
await refused(e,['activate',A,'acme',...pair(f)],'proof-target',`${A}'s Console does not support --proof-header`);
|
|
for(const h of ['Host','origin','x-mosaic-tester','X-User','-lead','under_score','a'.repeat(65)]){
|
|
await refused(e,['activate',C1,'acme',...pair(f,h)],'proof-header-invalid',HEADER_MSG(h));
|
|
}
|
|
const inCopies=join(COPIES,'proof-r187.187');writeFileSync(inCopies,SENTINEL+'\n',{mode:0o600});t.after(()=>rmSync(inCopies,{force:true}));
|
|
await refused(e,['activate',C1,'acme',...pair(inCopies)],'proof-file-invalid',`--proof-file ${inCopies} is inside the repository or the copies`);
|
|
rmSync(inCopies);
|
|
for(const [p,why] of [['proof.187','is not an absolute path'],[odd,'holds a character outside [A-Za-z0-9/._+-]'],[link,'is a symlink'],[dir,'is not a regular file'],[join(e.root,'none.187'),'cannot be found'],[open,'has mode 644, not 600 or 400'],[loose,'has mode 640, not 600 or 400']]){
|
|
await refused(e,['activate',C1,'acme',...pair(p)],'proof-file-invalid',`--proof-file ${p} ${why}`);
|
|
}
|
|
const g=await mk(t);await seed(g);const h=await proofFile(g);
|
|
await refused(g,['activate',C1,'acme',...pair(h)],'proof-needs-preview','the proof pair needs --public-host, --auth-header and --allow-user in the Console flags; the run\'s flags lack --public-host --auth-header --allow-user');
|
|
for(const x of [e,g])await noLeak(x);
|
|
});
|
|
await t.test('tests 5 and 9: --plan lists every proof refusal with the others and writes nothing',async(t)=>{
|
|
const e=await mk(t);await seed(e);biz(e,HARNESS);const open=await proofFile(e,'open.187',0o644);
|
|
const out=await planOnly(e,3,'activate',A,'acme',...pair(open,'host'));
|
|
assert.deepEqual(proofLines(out),[`proof: on, header host, file ${open}`]);
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[
|
|
`refuses proof-target: ${A}'s Console does not support --proof-header`,
|
|
'refuses proof-needs-preview: the proof pair needs --public-host, --auth-header and --allow-user in the Console flags; the run\'s flags lack --public-host --auth-header --allow-user',
|
|
`refuses proof-header-invalid: ${HEADER_MSG('host')}`,
|
|
`refuses proof-file-invalid: --proof-file ${open} has mode 644, not 600 or 400`,
|
|
`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`]);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 6: usage errors',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);const f=await proofFile(e);
|
|
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
|
for(const args of [['activate',C1,'acme','--proof-header','x-console-proof'],['activate',C1,'acme','--proof-file',f],['activate',C1,'acme','--proof-header','','--proof-file',f],
|
|
['activate',C1,'acme','--no-proof',...pair(f)],['activate',C1,'acme',...pair(f),'--no-proof'],['activate',C1,'acme','--no-proof','--proof-header','x-console-proof'],
|
|
['rollback','acme',...pair(f)],['rollback','acme','--no-proof'],['rollback','acme','--proof-header','x-console-proof'],['pm-on','acme',...pair(f)],['pm-on','acme','--no-proof'],['pm-on','acme','--proof-file',f],
|
|
['activate',C1,'acme','--plan','--proof-file',f]]){
|
|
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
|
}
|
|
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\]/);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 7: a rollback to a copy without proof support drops the pair and says so',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,{copy:A}),f=await proofFile(e);
|
|
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
const out=await planOnly(e,0,'rollback','acme');
|
|
assert.deepEqual(proofLines(out),[`proof: dropped; ${A}'s Console does not support --proof-header, so the rollback drops header x-console-proof`]);
|
|
assert.doesNotMatch(out.find((l)=>l.startsWith('Console: ')),/--proof-/);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,A);assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/);
|
|
assert.match(stepText(e),new RegExp(`drop --proof-header x-console-proof --proof-file ${f}; the target Console does not support it\\n`));
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 7: an activate to a copy without proof support refuses proof-target without --no-proof and passes with it',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e),f=await proofFile(e);
|
|
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
const out=await planOnly(e,3,'activate',A,'acme');
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-target: ${CARRY_MSG(A)}`]);
|
|
await refused(e,['activate',A,'acme'],'proof-target',CARRY_MSG(A));
|
|
assert.deepEqual(proofLines(await planOnly(e,0,'activate',A,'acme','--no-proof')),['proof: dropped; --no-proof drops header x-console-proof']);
|
|
r=await prun(e,'activate',A,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,A);assert.deepEqual(pointer(e).console,base);assert.doesNotMatch(conExec(e),/--proof-/);
|
|
assert.match(stepText(e),/drop --proof-header x-console-proof [^\n]*; --no-proof was given\n/);
|
|
await noLeak(e);
|
|
});
|
|
});
|
|
|
|
// Row 188 (#1686, P19 409 and 410): the checks on a carried pair, and a proof file inside REPO.
|
|
// A file gone from its path keeps a hard link under e.root, so the inode and its read watch
|
|
// outlive the path: inotify sends IN_IGNORED, which the watch would count, when a watched inode goes.
|
|
const carried=['proof: carried, header x-console-proof'];
|
|
test('T54 row 188: a carried pair gets the proof file checks, and a proof file inside the repository refuses',async(t)=>{
|
|
const changes=[
|
|
['chmod 644',(e,f)=>{chmodSync(f,0o644);return 'has mode 644, not 600 or 400'}],
|
|
['a symlink to a mode-600 file',async(e,f)=>{const g=await proofFile(e,'swap.188');linkSync(f,join(e.root,'kept.188'));rmSync(f);symlinkSync(g,f);return 'is a symlink'}],
|
|
['removal',(e,f)=>{linkSync(f,join(e.root,'kept.188'));rmSync(f);return 'cannot be found'}],
|
|
];
|
|
for(const [name,change] of changes){
|
|
await t.test(`test 1: an activate that carries the pair refuses after ${name}, in the plan and the run, before anything stops`,async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e),f=await proofFile(e);
|
|
const r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
const why=`--proof-file ${f} ${await change(e,f)}`;
|
|
const out=await planOnly(e,3,'activate',C1,'acme');
|
|
assert.deepEqual(proofLines(out),carried);assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]);
|
|
await refused(e,['activate',C1,'acme'],'proof-file-invalid',why);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);
|
|
await noLeak(e);
|
|
});
|
|
}
|
|
await t.test('test 2: a rollback that carries the pair refuses after chmod 644, in the plan and the run, before anything stops',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e),f=await proofFile(e);
|
|
const r=await prun(e,'activate',C1,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
chmodSync(f,0o644);const why=`--proof-file ${f} has mode 644, not 600 or 400`;
|
|
const out=await planOnly(e,3,'rollback','acme');
|
|
assert.ok(out.includes(`to: ${B}`),out.join('\n'));
|
|
assert.deepEqual(proofLines(out),carried);assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]);
|
|
await refused(e,['rollback','acme'],'proof-file-invalid',why);
|
|
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,[...base,...pair(f)]);
|
|
await noLeak(e);
|
|
});
|
|
// Tests 3 and 4: a mode-600 file of this uid, so only the realpath check can refuse it.
|
|
const inRepo=async(t,e,dir)=>{t.after(()=>rmSync(dir,{recursive:true,force:true}));mkdirSync(dir);return proofFile(e,'proof.188',0o600,dir)};
|
|
await t.test('test 3: a proof file inside the repository refuses, in the plan and the run, before anything stops',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);const f=await inRepo(t,e,join(repo,'proof-r188'));
|
|
const why=`--proof-file ${f} is inside the repository or the copies`;
|
|
const out=await planOnly(e,3,'activate',C1,'acme',...pair(f));
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]);
|
|
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',why);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 4: a path outside the repository through a directory symlink into it refuses on its realpath',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);await inRepo(t,e,join(repo,'proof-r188'));
|
|
const link=join(e.root,'repo-link.188');symlinkSync(join(repo,'proof-r188'),link);
|
|
const f=join(link,'proof.188');assert.ok(!lstatSync(f).isSymbolicLink());assert.ok(!f.startsWith(`${repo}/`));
|
|
const why=`--proof-file ${f} is inside the repository or the copies`;
|
|
const out=await planOnly(e,3,'activate',C1,'acme',...pair(f));
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses proof-file-invalid: ${why}`]);
|
|
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',why);
|
|
await noLeak(e);
|
|
});
|
|
});
|
|
|
|
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
|
|
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
|
|
const noSend=(c)=>c.filter((x)=>x!=='--send');
|
|
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
|
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
|
|
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
|
|
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
|
|
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
|
|
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
|
|
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
|
const act=pointer(e).activation,l=logLines(e).at(-1);
|
|
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
|
|
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
|
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
|
|
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
|
|
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
|
|
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
|
const g=await mk(t);await seedPreview(g);
|
|
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
|
for(const x of [e,g])await noLeak(x);
|
|
});
|
|
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
|
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
|
|
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
|
|
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
|
|
out=await planOnly(e,0,'activate',C1,'acme');
|
|
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
|
|
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
|
|
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
|
|
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
|
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
|
|
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
|
|
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
|
|
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
|
|
assert.doesNotMatch(stepText(e),/drop --send/);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
|
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
|
assert.doesNotMatch(stepText(e),/--send/);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
|
|
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
biz(e,HARNESS);
|
|
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
|
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
|
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
|
|
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
|
|
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
|
}
|
|
assert.match(usageText,/\| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
|
|
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
|
|
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
|
|
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
|
|
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
|
|
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
|
|
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
|
|
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
|
|
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
|
|
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
|
|
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
|
|
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
|
|
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
|
|
const steps=stepText(h);
|
|
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
|
|
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
for(const x of [e,h])await noLeak(x);
|
|
});
|
|
});
|
|
|
|
// Row 200 (#1698): activate --sso. The config gets console.sso with one provider `a`, and its slot
|
|
// file sits where cli.mjs's readSlot looks, beside the config, with a read watch: the run may stat
|
|
// it but never open it. ES256 passes every copy's checker; RS256 passes C1's and fails C's.
|
|
const SLOT='console-sso-a';
|
|
const slotPath=(e)=>join(dirname(e.cfg),'secrets','mosaic-stack',SLOT);
|
|
const SSO_KEY={sso:{publicOrigin:'https://console.example.test',providers:[{id:'a',issuer:'https://op.example.test/',clientId:'mosaic-console',clientSecret:{slot:SLOT},algs:['ES256'],subjects:[{sub:'subject-1',name:'jason'}]}]}};
|
|
function setKey(e,key){const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(key===undefined)delete c.console;else c.console=key;writeFileSync(e.cfg,JSON.stringify(c))}
|
|
async function ssoConfig(e,{key=SSO_KEY,slot=true}={}){
|
|
setKey(e,key);mkdirSync(dirname(slotPath(e)),{recursive:true,mode:0o700});
|
|
return slot?proofFile(e,SLOT,0o600,dirname(slotPath(e))):slotPath(e);
|
|
}
|
|
const ssoLines=(out)=>out.filter((l)=>l.startsWith('sso:'));
|
|
const DROPS='--public-host console.example --auth-header x-user --allow-user jason';
|
|
const SHOW=`show-${CON}.txt`;
|
|
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
|
|
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
|
|
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --tester-port as the pointer has it`;
|
|
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console or federation key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
|
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
|
|
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
|
|
function setEdit(set,name,from,to,sums=true){
|
|
const f=join(set,name);chmodSync(set,0o700);chmodSync(f,0o600);edit(f,from,to);
|
|
if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))}
|
|
}
|
|
|
|
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
|
|
await t.test('item 1: --sso drops the preview set and the pair, puts --sso before --send and the tester port, and the plan names what it drops and carries',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
|
|
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']);
|
|
await ssoConfig(e);
|
|
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
|
|
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
|
|
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: carried',
|
|
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
|
|
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
|
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
|
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
const steps=stepText(e);
|
|
assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps);
|
|
assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
|
|
assert.deepEqual([...proofLines(out),...ssoLines(out)],['proof: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send`]);
|
|
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
|
assert.doesNotMatch(stepText(e),/proof/);
|
|
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
|
|
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
|
|
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
|
|
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
|
|
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
|
|
assert.match(stepText(e),/^serve: .* --sso --send$/m);
|
|
r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g);
|
|
r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
|
|
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
|
|
for(const x of [e,g])await noLeak(x);
|
|
});
|
|
await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);const f=await proofFile(e);
|
|
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
|
for(const args of [['activate',C1,'acme','--sso',...pair(f)],['activate',C1,'acme','--sso','--no-proof'],['activate',C1,'acme','--no-proof','--sso','--plan'],
|
|
['rollback','acme','--sso'],['pm-on','acme','--sso'],['status','acme','--sso'],['adopt','acme','--sso']]){
|
|
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
|
}
|
|
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false});
|
|
const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
|
|
put();
|
|
await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`);
|
|
setKey(e,undefined);
|
|
// N4: from another copy, the refusal names the re-pin without --sso as the first step.
|
|
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`);
|
|
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
|
r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err);
|
|
await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`);
|
|
setKey(e,SSO_KEY);
|
|
const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`;
|
|
rmSync(slot);
|
|
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing'));
|
|
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot);
|
|
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link'));
|
|
rmSync(slot);mkdirSync(slot,{mode:0o700});
|
|
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file'));
|
|
for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))}
|
|
put(0o600,'');
|
|
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty'));
|
|
// The slot directory resolving into the repository: the file is no link, but its real path is.
|
|
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
|
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
|
const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
|
|
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies'));
|
|
// --plan lists the sso refusal with the rest and writes nothing.
|
|
const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80');
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]);
|
|
rmSync(secrets);mkdirSync(secrets,{mode:0o700});put();
|
|
r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const why='mosaic-config: unsupported configuration key: "console"';
|
|
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
|
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
|
const out=await planOnly(e,3,'rollback','acme');
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]);
|
|
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
|
|
await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
|
|
setKey(e,undefined);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('row 203: a config whose federation key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
// B's loader has no federation key in its list, as a pre-203 loader has none: it refuses the key.
|
|
const setFed=(fed)=>{const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(fed===undefined)delete c.federation;else c.federation=fed;writeFileSync(e.cfg,JSON.stringify(c))};
|
|
setFed({self:'hub',peers:[{id:'factory',origin:'https://mosaic.woltje.com',label:'Software factory'}]});
|
|
const why='mosaic-config: unsupported configuration key: "federation"';
|
|
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
|
|
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
|
|
setFed(undefined);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const set=arrivalSet(e,C1);
|
|
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
// The latest set holds the --sso Console; the arrival set holds B's.
|
|
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
|
assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /);
|
|
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"'));
|
|
setKey(e,undefined);
|
|
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
|
|
const out=await planOnly(e,0,'rollback','acme');
|
|
assert.deepEqual(ssoLines(out),[ROLLBACK_PLAN(set)]);
|
|
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${want.join(' ')}`);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
|
|
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
|
assert.ok(stepText(e).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${SHOW}\n`));
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
// The plain case: the preview set and pair come back as they were. --send lost its --sso, so it goes (row 273, F39 b).
|
|
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
|
|
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
|
await ssoConfig(g);
|
|
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
setKey(g,undefined);
|
|
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)]);
|
|
for(const x of [e,g])await noLeak(x);
|
|
});
|
|
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
setKey(e,undefined);
|
|
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
|
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
|
|
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the Console in ${show} already ran with --sso`));
|
|
let out=await planOnly(e,3,'rollback','acme');
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses sso-rollback-flags: ${RB_MSG(`the Console in ${show} already ran with --sso`)}`]);
|
|
setEdit(set,SHOW,' --sso --public-host ',' --public-host ',false);
|
|
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} does not match the set's SHA256SUMS`));
|
|
setEdit(set,SHOW,' serve ',' ',true);
|
|
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} holds no Console argv this script can read`));
|
|
rmSync(show);
|
|
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${set} has no ${SHOW} and SHA256SUMS`));
|
|
writeFileSync(show,text);setEdit(set,SHOW,'\n','\n',true);
|
|
// A log whose arrival line came from another copy names no set for B.
|
|
const log=readFileSync(e.log,'utf8');chmodSync(e.log,0o600);
|
|
writeFileSync(e.log,log.replaceAll(`"from":"${B}","to":"${C1}"`,`"from":"${A}","to":"${C1}"`));
|
|
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the log names no activation that moved the stack from ${B} onto ${C1}`));
|
|
writeFileSync(e.log,readFileSync(e.log,'utf8').replaceAll(`"from":"${A}","to":"${C1}"`,`"from":"${B}","to":"${C1}"`));
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{
|
|
const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link);
|
|
const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e);
|
|
const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t57-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true}));
|
|
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`);
|
|
await ssoConfig(e,{slot:false});
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
|
|
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
|
|
const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
|
|
await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);
|
|
// load_config exits first, before the pointer, the log or a unit is read; config_checks' own
|
|
// refusal is the guard for a file that changes after that (BUILD.md, round 2).
|
|
const good=readFileSync(e.cfg,'utf8');writeFileSync(e.cfg,good.slice(0,-1));
|
|
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
|
|
for(const args of [['activate',C1,'acme'],['rollback','acme'],['activate',C1,'acme','--plan']]){
|
|
const r=await prun(e,...args);assert.equal(r.code,1,args.join(' '));assert.match(r.err,/common: configuration load failed\n$/);
|
|
}
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
writeFileSync(e.cfg,good);
|
|
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{
|
|
const e=await mk(t),a=await named(e,'alpha');await seed(a);
|
|
const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart='));
|
|
assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a));
|
|
await ssoConfig(a,{slot:false});
|
|
await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`);
|
|
await proofFile(a,SLOT,0o600,dirname(slotPath(a)));
|
|
const want=['--port',a.port,'--business','acme','--sso'];
|
|
const out=await planOnly(a,0,'activate',C1,'acme','--sso');
|
|
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
|
let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want);
|
|
assert.ok(unit().endsWith(` ${want.join(' ')}`),unit());
|
|
assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`));
|
|
assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written');
|
|
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
setKey(a,undefined);
|
|
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
|
|
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]);
|
|
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
|
|
assert.ok(stepText(a).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${show}\n`));
|
|
await noLeak(a);
|
|
});
|
|
await t.test('row 238 (#1744): on a named instance the sso-config first step names --instance NAME in both commands',async(t)=>{
|
|
const e=await mk(t),a=await named(e,'alpha');await seed(a);
|
|
assert.equal(JSON.parse(readFileSync(a.cfg,'utf8')).console,undefined);
|
|
await refused(a,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} --instance alpha without --sso first, then write console.sso, then run activate ${C1} --instance alpha --sso`);
|
|
const out=await planOnly(a,3,'activate',C1,'acme','--sso');
|
|
assert.ok(out.includes(`refuses sso-config: ${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} --instance alpha without --sso first, then write console.sso, then run activate ${C1} --instance alpha --sso`),out.join('\n'));
|
|
await noLeak(a);
|
|
});
|
|
});
|
|
|
|
// Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and
|
|
// activate --no-sso turns --sso off with the Console flags from the set of the activation that turned
|
|
// it on, dropping --send. The tester follows the pointer.
|
|
const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)";
|
|
const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off";
|
|
const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`;
|
|
const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`;
|
|
const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
|
test('T58 row 273: activate --send and --no-sso',async(t)=>{
|
|
await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
|
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
|
|
const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send');
|
|
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
|
|
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
|
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
|
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
|
const steps=stepText(e);
|
|
assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
// The same copy with send on again is already active.
|
|
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
|
|
const out=await planOnly(e,0,'activate',C1,'acme','--send');
|
|
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']);
|
|
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
|
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
|
const want=['--port',e.port,'--business','acme','--sso','--send'];
|
|
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
|
assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
|
|
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
|
|
// Another copy carries both.
|
|
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T2: --send without --sso, given or carried, refuses before any stop, and --plan lists it',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);
|
|
await refused(e,['activate',C1,'acme','--send'],'send-needs-sso',SEND_MSG);
|
|
await refused(e,['activate',B,'acme','--send'],'send-needs-sso',SEND_MSG);
|
|
const out=await planOnly(e,3,'activate',C1,'acme','--send');
|
|
assert.deepEqual(sendLines(out),['send: off']);
|
|
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses send-needs-sso: ${SEND_MSG}`]);
|
|
// A pointer that already holds --send without --sso still refuses a --send.
|
|
const g=await mk(t);await seedPreview(g,undefined,['--send']);
|
|
await refused(g,['activate',C1,'acme','--send'],'send-needs-sso',SEND_MSG);
|
|
await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`);
|
|
for(const x of [e,g])await noLeak(x);
|
|
});
|
|
await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
|
const set=arrivalSet(e,C1);
|
|
setKey(e,undefined);
|
|
const out=await planOnly(e,0,'rollback','acme');
|
|
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]);
|
|
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${base.join(' ')}`);
|
|
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
|
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,base);
|
|
assert.doesNotMatch(conExec(e),/ --sso( |$)| --send( |$)/);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${base.join(' ')}`),conExec(e));
|
|
assert.ok(stepText(e).includes('Z drop --send; the run drops --sso\n'),stepText(e));
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e);
|
|
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
|
|
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
|
for(const args of [['activate',C1,'acme','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'],
|
|
['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'],
|
|
['activate',C1,'acme','--no-sso',...pair(f)],['activate',C1,'acme','--no-sso','--no-proof'],
|
|
['rollback','acme','--send'],['rollback','acme','--no-sso'],['pm-on','acme','--send'],['pm-on','acme','--no-sso'],['status','acme','--no-sso'],['adopt','acme','--send']]){
|
|
r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
|
}
|
|
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso \| --no-sso\] \[--send \| --no-send\]/);
|
|
assert.match(usageText,/\n--send needs --sso in the run's Console flags, given or carried\. --no-sso needs --sso in the\n/);
|
|
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const pre=conExec(e),want=noSend(base);
|
|
assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
|
|
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
const set=setOf(e,pointer(e).activation);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
|
|
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
|
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
|
|
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
|
|
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
|
|
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
|
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
|
|
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
|
|
assert.equal(conExec(e),pre.replace(' --send --tester-port 7333',' --tester-port 7333'));
|
|
const steps=stepText(e);
|
|
assert.ok(steps.includes('Z drop --send; --no-sso was given\n'),steps);
|
|
assert.ok(steps.includes(`Z turn off --sso; the Console flags come from ${set}/${SHOW}\n`),steps);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
// --sso is off now, so a second --no-sso has nothing to turn off.
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
|
|
// The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ.
|
|
let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
|
|
const set=setOf(e,pointer(e).activation);
|
|
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']);
|
|
const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334'];
|
|
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
|
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]);
|
|
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
|
assert.doesNotMatch(stepText(e),/drop --send/);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{
|
|
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
|
|
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
const set=setOf(e,pointer(e).activation);
|
|
// Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it.
|
|
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
|
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2);
|
|
for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /);
|
|
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
|
|
assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]);
|
|
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,base);assert.equal(conExec(e),pre);
|
|
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
const first=setOf(e,pointer(e).activation);
|
|
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
|
|
// The set of the activation that turned --sso on now claims its Console ran with it too, sums intact.
|
|
setEdit(first,SHOW,' --public-host ',' --sso --public-host ');
|
|
const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`);
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why);
|
|
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]);
|
|
await noLeak(e);
|
|
});
|
|
await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
|
|
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
|
await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
|
|
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]);
|
|
// One set, from the activation that turned --sso on.
|
|
await ssoConfig(e);
|
|
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
|
|
const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8');
|
|
// An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above.
|
|
const logText=readFileSync(e.log,'utf8');
|
|
appendFileSync(e.log,JSON.stringify({at:'2026-10-22T00:00:00Z',op:'activate',from:C1,to:C1,backup:null,activation:null,result:'ok'})+'\n');
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG('an ok line in the log names no activation'));
|
|
writeFileSync(e.log,logText);
|
|
setEdit(set,SHOW,' --allow-user jason',' --allow-user mallory',false);
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${show} does not match the set's SHA256SUMS`));
|
|
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${OFF_MSG(`${show} does not match the set's SHA256SUMS`)}`]);
|
|
setEdit(set,SHOW,' --allow-user mallory',' --sso --allow-user jason');
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`the Console in ${show} already ran with --sso, and the log names no earlier activation`));
|
|
setEdit(set,SHOW,' serve ',' ');
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${show} holds no Console argv this script can read`));
|
|
rmSync(show);
|
|
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG(`${set} has no ${SHOW} and SHA256SUMS`));
|
|
writeFileSync(show,text);setEdit(set,SHOW,'\n','\n');
|
|
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
|
|
assert.doesNotMatch(conExec(e),/ --sso( |$)| --send( |$)/);
|
|
// A pointer with --sso and only the adopt line: no activation took a set.
|
|
const g=await mk(t);await seedPreview(g,undefined,['--sso']);
|
|
await refused(g,['activate',C1,'acme','--no-sso'],'sso-off-flags',OFF_MSG('the log names no activation with a rollback set'));
|
|
for(const x of [e,g])await noLeak(x);
|
|
});
|
|
});
|
|
|
|
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
|
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
|
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
|
// migrate-schema.mjs. A rehearsal starts its processes with only HOME, PATH and MOSAIC_CONFIG, so
|
|
// the stand-ins take their faults from $HOME/faults and record what they did in $HOME.
|
|
|
|
// The bus host. bus start writes the store's writer.lock, a meta row in the store, and its state
|
|
// beside the scratch store, and prints the host-up line; without --no-launch it starts the PM, which here means a line in
|
|
// $HOME/starts.log and a sessions.json. bus status --json answers from that state.
|
|
function rHost(){
|
|
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
|
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
|
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
|
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
|
|
const data=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot,state=path.join(data,'fixture-host.json');
|
|
const flags=["--pm","--no-launch"];
|
|
if(a.join(' ')==='bus status --json'){
|
|
if(fault(`status-${id}`))process.exit(1);
|
|
let s={host:null,boot:null};try{s=JSON.parse(fs.readFileSync(state,'utf8'));s.host.live=alive(s.host.pid)}catch{}
|
|
if(fault('status-other-host')&&s.host)s.host.pid=process.ppid;
|
|
console.log(JSON.stringify(s));process.exit(0);
|
|
}
|
|
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
|
|
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
|
|
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
|
|
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
|
|
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
|
|
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
|
|
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('fixture_host_start',?)").run(at);db.close();
|
|
fs.writeFileSync(state,JSON.stringify({host:{business:b,pid:process.pid,live:true,startedAt:at,notifier:'off'},boot:{ok:true,business:b,at}}));
|
|
if(fault('host-launches-dir'))fs.mkdirSync(path.join(data,'launches'));
|
|
// host-touches lists files a careless host would write: the live config, business file or backup.
|
|
if(fault('host-touches'))for(const f of fs.readFileSync(path.join(home,'faults','host-touches'),'utf8').split('\n').filter(Boolean))fs.appendFileSync(f,'x');
|
|
if(!off||fault('host-launches')){
|
|
fs.appendFileSync(path.join(home,'starts.log'),`pm-${b}\n`);
|
|
fs.mkdirSync(path.join(data,'bus-host'),{recursive:true});
|
|
fs.writeFileSync(path.join(data,'bus-host','sessions.json'),JSON.stringify({sessionsVersion:1,sessions:[{pid:process.pid}]}));
|
|
}
|
|
console.log(`bus host up: business ${b}, socket ${path.join(data,'bus','broker.sock')}, notifier off${off?', launches disabled':''}`);
|
|
process.on('SIGTERM',()=>{if(fault('host-ignores-term'))return;if(!fault('host-keeps-lock'))fs.rmSync(lock,{force:true});process.exit(0)});
|
|
setInterval(()=>{},1<<30);
|
|
}
|
|
|
|
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
|
|
function rConsole(){
|
|
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
|
|
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
|
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
|
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
|
|
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot})+'\n');
|
|
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
|
|
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
|
|
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
|
|
process.on('SIGTERM',()=>process.exit(0));
|
|
}
|
|
|
|
// migrate-schema.mjs: sets its copy's SCHEMA_VERSION, or exit 1 under migrate-fails.
|
|
function rMigrate(){
|
|
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
|
const home=process.env.HOME,data=process.argv[2];
|
|
const v=/^export const SCHEMA_VERSION = '([A-Za-z0-9]+)';$/m.exec(fs.readFileSync(path.join(import.meta.dirname,'store.mjs'),'utf8'))[1];
|
|
fs.appendFileSync(path.join(home,'migrate.log'),`${data}\n`);
|
|
if(fs.existsSync(path.join(home,'faults','migrate-fails'))){console.error('fixture migrate: failed');process.exit(1)}
|
|
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(v);db.close();
|
|
console.log(`bus schema ${v} ready`);
|
|
}
|
|
|
|
// The copy's Store: refuses a store at another version with schema-mismatch, as the real one does.
|
|
const rStore=(v,froms)=>`${storeText(v,froms)}import {DatabaseSync} from 'node:sqlite';
|
|
import {join} from 'node:path';
|
|
export class Store {
|
|
constructor(root) {
|
|
this.db = new DatabaseSync(join(root, 'bus', 'bus.sqlite'));
|
|
if (this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== SCHEMA_VERSION) { this.db.close(); throw Error('schema-mismatch'); }
|
|
}
|
|
get(sql, ...args) { return this.db.prepare(sql).get(...args); }
|
|
close() { this.db.close(); }
|
|
}
|
|
`;
|
|
|
|
// B (3g) and C (3h) have --no-launch. A is a copy from before row 163, whose host has no such flag.
|
|
// C1's scripts/mosaic runs B's host, as a copy-check failure needs.
|
|
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
|
|
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
|
|
const d=join(RCOPIES,id);
|
|
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','packages/library/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
|
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
|
|
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
|
|
execFileSync('chmod',['-R','a-w',d]);
|
|
}
|
|
|
|
const TESTER='7499';
|
|
const freePort=async()=>String(await leasePort({after:laneAfter},{closed:true}));
|
|
const rh=(e,...p)=>join(e.env.HOME,...p);
|
|
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
|
|
|
|
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
|
|
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
|
|
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
|
|
async function rmk(t,{copy=B,store='3g'}={}){
|
|
const e=await mk(t);
|
|
t.after(()=>{
|
|
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
|
|
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
|
});
|
|
const db=new DatabaseSync(e.store);
|
|
db.exec('PRAGMA journal_mode=WAL');
|
|
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
|
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
|
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
|
|
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
|
|
db.close();
|
|
mkdirSync(e.state,{recursive:true});
|
|
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme','--tester-port',TESTER],schema:store,activation:'manual',at:'2026-10-20T00:00:00Z'})+'\n');
|
|
e.act=`20261020T000000Z-${copy.slice(0,12)}`;e.backup=join(e.data,'backups','stack',e.act);
|
|
mkdirSync(join(e.backup,'bus'),{recursive:true,mode:0o700});
|
|
cpSync(e.store,join(e.backup,'bus','bus.sqlite'));
|
|
writeFileSync(join(e.backup,'SHA256SUMS'),`${sha(e.store)} bus.sqlite\n`);
|
|
for(const [f,text] of [['DATA_ROOT',e.data],['SCHEMA',store],['STAMP','2026-10-20T00:00:00.000Z']])writeFileSync(join(e.backup,f),`${text}\n`);
|
|
e.biz=join(dirname(e.cfg),'businesses','acme.json');
|
|
return e;
|
|
}
|
|
|
|
// What a rehearsal may never change: the live store, config, business file, pointer and log, and
|
|
// the backup, by sha256 of every file.
|
|
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
|
|
|
|
async function rehearse(e,id,...args){
|
|
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
|
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
|
|
e.during?.(ch);
|
|
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
|
const [code]=await once(ch,'close');clearTimeout(timer);
|
|
const m=/\nrehearsal directory: (.*)\n$/.exec(out);
|
|
return {code,out,err,dir:m?m[1]:null,names:out.split('\n').filter((l)=>/^(PASS|FAIL) /.test(l)).map((l)=>l.replace(/^(PASS|FAIL) ([^:]*).*/,'$1 $2'))};
|
|
}
|
|
// A script of its own whose text has `from` replaced, for the guard tests. The replacement is a
|
|
// function so a shell "$$" in `to` stays as written.
|
|
function ownScript(e,from,to){
|
|
const own=join(e.root,'repo');cpSync(repo,own,{recursive:true});
|
|
e.sr=join(own,'scripts','stack-release.sh');
|
|
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
|
|
}
|
|
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
|
|
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
|
|
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
|
|
|
|
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
|
|
const e=await rmk(t),before=untouched(e),port=await freePort();
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
|
|
assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(r.names,[...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
|
|
assert.ok(r.out.endsWith(`REHEARSAL_PASS\nrehearsal directory: ${r.dir}\n`),r.out);
|
|
assert.equal(r.err,'');
|
|
assert.equal(untouched(e),before);
|
|
const R=r.dir;assert.equal(dirname(R),join(e.data,'rehearsals'));assert.match(basename(R),new RegExp(`^\\d{8}T\\d{6}Z-${B.slice(0,12)}$`));
|
|
for(const d of [dirname(R),R,join(R,'placeholders'),join(R,'config')])assert.equal(statSync(d).mode&0o777,0o700,d);
|
|
for(const f of ['steps.log','backup.before','live.before','business.diff','host.log','console.log','config/config.json','data/bus/bus.sqlite'])assert.ok(existsSync(join(R,f)),f);
|
|
// The host wrote the scratch store, a copy: the backup has no such row.
|
|
const metaOf=(f)=>{const db=new DatabaseSync(f,{readOnly:true});try{return db.prepare("SELECT value FROM meta WHERE key='fixture_host_start'").get()?.value}finally{db.close()}};
|
|
assert.match(metaOf(join(R,'data','bus','bus.sqlite')),/^\d{4}-/);assert.equal(metaOf(join(e.backup,'bus','bus.sqlite')),undefined);
|
|
// The scratch config differs from the live one only in dataRoot.
|
|
assert.deepEqual(JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')),{...JSON.parse(readFileSync(e.cfg,'utf8')),dataRoot:join(R,'data')});
|
|
// The notifier config: binding null at 0600.
|
|
const nf=join(R,'data','notify','acme','notify.json');
|
|
assert.deepEqual(JSON.parse(readFileSync(nf,'utf8')),{notifyVersion:1,binding:null});assert.equal(statSync(nf).mode&0o777,0o600);
|
|
// The scratch business: no tracker.* variable, and every reference a 0600 placeholder with its date kept.
|
|
const live=JSON.parse(readFileSync(e.biz,'utf8')),sb=JSON.parse(readFileSync(join(R,'config','businesses','acme.json'),'utf8'));
|
|
assert.ok(Object.keys(live.vars).some((k)=>k.startsWith('tracker.')));
|
|
assert.deepEqual(Object.keys(sb.vars).filter((k)=>k.startsWith('tracker.')),[]);
|
|
const refs=[...Object.entries(sb.roles).flatMap(([n,r])=>Object.entries(r.credentials??{}).map(([s,ref])=>[`${n}-${s}`,ref,live.roles[n].credentials[s]])),...Object.entries(sb.tracker.sync.credentials).map(([s,ref])=>[`@sync-${s}`,ref,live.tracker.sync.credentials[s]])];
|
|
assert.ok(refs.length>=2);
|
|
for(const [name,ref,old] of refs){
|
|
const f=join(R,'placeholders',name);
|
|
assert.deepEqual(ref,{...old,file:f});assert.equal(readFileSync(f,'utf8'),'rehearsal-placeholder-token');assert.equal(statSync(f).mode&0o777,0o600);
|
|
}
|
|
assert.deepEqual(readdirSync(join(R,'placeholders')).sort(),refs.map(([n])=>n).sort());
|
|
// Everything else in the business is as it was.
|
|
const strip=(b)=>{const c=structuredClone(b);for(const k of Object.keys(c.vars))if(k.startsWith('tracker.'))delete c.vars[k];for(const r of Object.values(c.roles))delete r.credentials;delete c.tracker.sync.credentials;return c};
|
|
assert.deepEqual(strip(sb),strip(live));
|
|
const diff=readFileSync(join(R,'business.diff'),'utf8');assert.match(diff,/^-.*"tracker\.baseUrl"/m);assert.match(diff,/^\+.*placeholders\/pm-/m);
|
|
// The host ran with launches disabled and started no PM; the Console got no --send.
|
|
assert.deepEqual(lines(rh(e,'host-args.log')),['bus start acme --no-launch']);
|
|
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme`]);
|
|
assert.equal(existsSync(rh(e,'starts.log')),false);
|
|
assert.equal(existsSync(join(R,'data','bus','writer.lock')),false);
|
|
assert.deepEqual(lines(join(R,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
|
|
});
|
|
|
|
test('R2 rehearse runs the migration checks when the schema differs; the live store stays at 3g',async(t)=>{
|
|
const e=await rmk(t),before=untouched(e),port=await freePort();
|
|
const r=await rehearse(e,C,'--backup',e.backup,'--port',port,'--timeout','10');
|
|
assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','PASS migrate','PASS new-opens-migrated','PASS migrate-again','PASS old-refuses-migrated','PASS gate','PASS no-session',...POST]);
|
|
assert.ok(r.out.includes('PASS migrate: 3g to 3h\n'),r.out);
|
|
assert.equal(untouched(e),before);assert.equal(storeVersion(e.store,false),'3g');
|
|
assert.equal(storeVersion(join(r.dir,'data','bus','bus.sqlite'),false),'3h');
|
|
assert.equal(storeVersion(join(r.dir,'refuse','bus','bus.sqlite'),false),'3g');
|
|
assert.deepEqual(lines(rh(e,'migrate.log')),[join(r.dir,'data'),join(r.dir,'data')]);
|
|
});
|
|
|
|
test('R3 a failing migration gives REHEARSAL_FAIL migrate and starts nothing',async(t)=>{
|
|
const e=await rmk(t),before=untouched(e);rfault(e,'migrate-fails');
|
|
const r=await rehearse(e,C,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','FAIL migrate','PASS backup-unchanged','PASS live-config-unchanged']);
|
|
assert.ok(r.out.includes('FAIL migrate: migrate-schema.mjs exit 1: fixture migrate: failed\n'),r.out);
|
|
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
|
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
|
assert.equal(untouched(e),before);
|
|
});
|
|
|
|
test('R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name',async(t)=>{
|
|
for(const [f,name,why] of [[`host-fails-${B}`,'host-up','exited'],[`status-${B}`,'bus-status','bus status failed'],['status-other-host','bus-status','the host this run started'],['host-launches','no-session','bus-host/sessions.json'],['host-launches-dir','no-session','found launches under'],['host-keeps-lock','host-released-store','writer.lock is still there']]){
|
|
const e=await rmk(t),before=untouched(e);rfault(e,f);
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,f+r.out+r.err);
|
|
assert.ok(r.names.includes(`FAIL ${name}`),f+r.out);assert.equal(r.names.filter((n)=>n.startsWith('FAIL')).length,1,f+r.out);
|
|
assert.ok(r.out.split('\n').some((l)=>l.startsWith(`FAIL ${name}: `)&&l.includes(why)),f+r.out);
|
|
assert.ok(r.names.includes('PASS console-stopped')&&r.names.includes('PASS backup-unchanged'),f+r.out);
|
|
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
|
assert.equal(untouched(e),before);
|
|
}
|
|
});
|
|
|
|
test('R5 a host that ignores SIGTERM gets SIGKILL and FAIL host-stopped',async(t)=>{
|
|
const e=await rmk(t);rfault(e,'host-ignores-term');
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','2');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.ok(/^FAIL host-stopped: pid \d+ was still running 2s after SIGTERM; sent SIGKILL$/m.test(r.out),r.out);
|
|
});
|
|
|
|
test('R6 a Console that does not answer /healthz gives REHEARSAL_FAIL healthz',async(t)=>{
|
|
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.ok(r.out.includes('FAIL healthz: no answer within 3s\n'),r.out);
|
|
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),['FAIL healthz']);
|
|
});
|
|
|
|
test('R7 a host on another copy fails copy-match',async(t)=>{
|
|
const e=await rmk(t);
|
|
const r=await rehearse(e,C1,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.ok(r.out.includes(`FAIL copy-match: the host's command line is not ${join(RCOPIES,C1)}'s cli.mjs bus start acme --no-launch\n`),r.out);
|
|
});
|
|
|
|
test('R8 a scratch port in use or reserved refuses, and the rehearsal takes no other port',async(t)=>{
|
|
const e=await rmk(t),before=untouched(e);
|
|
const held=createServer();held.listen(0,'127.0.0.1');await once(held,'listening');t.after(()=>held.close());
|
|
const p=String(held.address().port);
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',p);
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.out,`PASS copy: ${B}, schema 3g\nFAIL port-in-use: 127.0.0.1:${p} is in use; the rehearsal takes no other port\nREHEARSAL_FAIL\n`);
|
|
for(const q of ['7332','7333',e.port,TESTER]){
|
|
const s=await rehearse(e,B,'--backup',e.act,'--port',q);
|
|
assert.equal(s.code,1,s.out+s.err);
|
|
assert.equal(s.out,`PASS copy: ${B}, schema 3g\nFAIL port-reserved: --port ${q} is a live Console port (7332, 7333 and the pointer's: ${e.port} ${TESTER})\nREHEARSAL_FAIL\n`);
|
|
}
|
|
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
|
assert.equal(untouched(e),before);
|
|
});
|
|
|
|
test('R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts',async(t)=>{
|
|
const e=await rmk(t);
|
|
// The live pm gitea reference names a file that is there, as a live token would be.
|
|
const tok=join(e.root,'live-gitea.token');writeFileSync(tok,'live-token-value',{mode:0o600});
|
|
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.roles.pm.credentials.gitea.file=tok;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
|
const before=untouched(e);
|
|
ownScript(e,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.ok(r.out.includes(`FAIL credential-ref-live: pm gitea is ${tok}, not a file in ${join(r.dir,'placeholders')}\n`),r.out);
|
|
assert.deepEqual(r.names.slice(-3),['FAIL credential-ref-live','PASS backup-unchanged','PASS live-config-unchanged']);
|
|
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
|
assert.equal(untouched(e),before);
|
|
// An env reference names no file at all; the guard says which variable it is.
|
|
const f=await rmk(t);
|
|
const fb=JSON.parse(readFileSync(f.biz,'utf8'));const {file,...rest}=fb.roles.pm.credentials.gitea;fb.roles.pm.credentials.gitea={...rest,env:'REHEARSAL_GITEA_TOKEN'};writeFileSync(f.biz,JSON.stringify(fb,null,2)+'\n',{mode:0o600});
|
|
ownScript(f,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
|
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(s.code,1,s.out+s.err);
|
|
assert.ok(s.out.includes('FAIL credential-ref-live: pm gitea is env REHEARSAL_GITEA_TOKEN\n'),s.out);
|
|
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
|
});
|
|
|
|
test('R10 mos-0962: a scratch business that still sets a tracker fails tracker-configured',async(t)=>{
|
|
const e=await rmk(t);
|
|
const proj=join(e.root,'project');mkdirSync(join(proj,'.mosaic'),{recursive:true});
|
|
writeFileSync(join(proj,'.mosaic','project.json'),JSON.stringify({projectVersion:1,id:'stack',vars:{'tracker.project':7}}),{mode:0o600});
|
|
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.projects.stack.root=proj;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
|
ownScript(e,'if(k.startsWith("tracker."))delete vars[k]','if(k.startsWith("tracker-kept."))delete vars[k]');
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.ok(r.out.includes('FAIL tracker-configured: bootConfig sets trackers for acme\n'),r.out);
|
|
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
|
});
|
|
|
|
test('R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name',async(t)=>{
|
|
// mosaic-config already refuses the live path itself, which contains the scratch config directory;
|
|
// the guard catches a path that reaches the live data root through a link.
|
|
const e=await rmk(t),link=join(e.root,'live-link');symlinkSync(e.data,link);
|
|
ownScript(e,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(link)};`);
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.ok(r.out.includes(`FAIL data-root-live: the scratch config names dataRoot ${link}, which resolves to the live data root ${e.data}\n`),r.out);
|
|
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
|
// A root that is neither live nor this rehearsal's, and one that is not there.
|
|
for(const [dir,made,msg] of [['other-root',true,(d,R)=>`the scratch config resolves to ${d}, not ${join(R,'data')}`],['no-root',false,(d)=>`${d} does not exist`]]){
|
|
const g=await rmk(t),d=join(g.root,dir);if(made)mkdirSync(d);
|
|
ownScript(g,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(d)};`);
|
|
const q=await rehearse(g,B,'--backup',g.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(q.code,1,dir+q.out+q.err);
|
|
assert.ok(q.out.includes(`FAIL data-root-scratch: ${msg(d,q.dir)}\n`),dir+q.out);
|
|
assert.equal(existsSync(rh(g,'host-args.log')),false);
|
|
}
|
|
const f=await rmk(t);
|
|
ownScript(f,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json"`,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json" && mkdir "$R/data/bus-host" && printf '{"sessionsVersion":1,"sessions":[{"pid":%s}]}\\n' "$$" > "$R/data/bus-host/sessions.json"`);
|
|
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(s.code,1,s.out+s.err);
|
|
assert.ok(/^FAIL sessions-live: \S+\/bus-host\/sessions\.json names running pid\(s\) \d+$/m.test(s.out),s.out);
|
|
// The file is left as it was: the rehearsal never deletes it and retries.
|
|
assert.ok(existsSync(join(s.dir,'data','bus-host','sessions.json')));
|
|
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
|
});
|
|
|
|
test('R12 mos-0962: a notifier config with a binding fails notify-off',async(t)=>{
|
|
const e=await rmk(t);
|
|
ownScript(e,`printf '{"notifyVersion":1,"binding":null}\\n'`,`printf '{"notifyVersion":1,"binding":"discord"}\\n'`);
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.ok(r.out.includes('FAIL notify-off: '),r.out);
|
|
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
|
});
|
|
|
|
test('R13 the pre-checks: an old copy, a backup that fails its sums, a missing backup or pointer, and usage',async(t)=>{
|
|
const e=await rmk(t),before=untouched(e),port=await freePort();
|
|
const old=await rehearse(e,A,'--backup',e.act,'--port',port);
|
|
assert.equal(old.code,1);assert.equal(old.out,`FAIL copy-without-no-launch: ${A}'s bus host has no --no-launch flag\nREHEARSAL_FAIL\n`);
|
|
const none=await rehearse(e,B,'--backup','20261020T000000Z-000000000000','--port',port);
|
|
assert.equal(none.code,1);assert.ok(none.out.includes(`FAIL backup-missing: ${join(e.data,'backups','stack','20261020T000000Z-000000000000')} has no bus/bus.sqlite and SHA256SUMS\n`),none.out);
|
|
const f=await rmk(t);appendFileSync(join(f.backup,'bus','bus.sqlite'),'x');
|
|
const bad=await rehearse(f,B,'--backup',f.act,'--port',port);
|
|
assert.equal(bad.code,1);assert.ok(bad.out.includes(`FAIL backup-sums: ${f.backup}/bus does not match its SHA256SUMS\n`),bad.out);
|
|
const g=await rmk(t);rmSync(g.pointer);
|
|
const np=await rehearse(g,B,'--backup',g.act,'--port',port);
|
|
assert.equal(np.code,1);assert.match(np.out,/^FAIL no-pointer: /);
|
|
for(const args of [['--port',port],['--backup',e.act],['--backup',e.act,'--port','80'],['--backup',e.act,'--port','70000'],['--backup','relative/dir','--port',port],['--backup',e.act,'--port',port,'--pm'],['--backup',e.act,'--port',port,'--plan']]){
|
|
const u=await rehearse(e,B,...args);assert.equal(u.code,4,args.join(' ')+u.out+u.err);assert.match(u.err,/^usage: /);
|
|
}
|
|
assert.deepEqual([...rDirs(e),...rDirs(f),...rDirs(g)],[]);
|
|
assert.equal(untouched(e),before);
|
|
});
|
|
|
|
test('R14 a host that writes the live config, the live business file or the backup fails the end check by name',async(t)=>{
|
|
for(const [pick,name] of [[(e)=>e.cfg,'live-config-unchanged'],[(e)=>e.biz,'live-config-unchanged'],[(e)=>join(e.backup,'bus','bus.sqlite'),'backup-unchanged']]){
|
|
const e=await rmk(t),f=pick(e);rfault(e,'host-touches');writeFileSync(rh(e,'faults','host-touches'),`${f}\n`);
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
|
|
assert.equal(r.code,1,f+r.out+r.err);
|
|
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${name}`],f+r.out);
|
|
assert.ok(r.names.includes('PASS gate')&&r.names.includes('PASS host-stopped'),f+r.out);
|
|
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
|
}
|
|
});
|
|
|
|
test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks',async(t)=>{
|
|
for(const sig of ['SIGTERM','SIGINT','SIGHUP']){
|
|
// The Console never answers /healthz, so the gate is still waiting when the signal comes.
|
|
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
|
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill(sig)};
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
|
assert.equal(r.code,1,sig+r.out+r.err);
|
|
assert.deepEqual(r.names,[...PRE,'PASS migration','FAIL signal','PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'],sig+r.out);
|
|
assert.ok(r.out.includes('FAIL signal: the rehearsal was interrupted\n'),r.out);
|
|
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
|
const pid=JSON.parse(readFileSync(join(r.dir,'data','fixture-host.json'),'utf8')).host.pid;
|
|
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
|
|
}
|
|
});
|
|
|
|
// --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) ---
|
|
|
|
// The log's lines, as the runs write them, with only the fields from_log reads set.
|
|
function rlog(e,...ls){
|
|
mkdirSync(e.state,{recursive:true});
|
|
writeFileSync(e.log,ls.map(([op,to,backup],i)=>JSON.stringify({at:`2026-10-20T00:00:0${i}Z`,op,from:null,to,backup,activation:`20261020T00000${i}Z-${to.slice(0,12)}`,result:'ok'})+'\n').join(''));
|
|
}
|
|
|
|
test('row 233: --backup last takes the newest backup the log records; a later line with backup null does not hide it, and the store line names it',async(t)=>{
|
|
const e=await rmk(t);
|
|
// An older backup that isn't on disk: a run that took it would fail backup-missing.
|
|
rlog(e,['activate',C1,`20261019T000000Z-${C1.slice(0,12)}`],['activate',B,e.act],['rollback',B,null]);
|
|
const l0=readFileSync(e.log,'utf8'),before=untouched(e);
|
|
const r=await rehearse(e,B,'--backup','last','--port',await freePort(),'--timeout','10');
|
|
assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(r.names,['PASS backup-last',...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
|
|
assert.ok(r.out.startsWith(`PASS backup-last: the log's newest backup is ${e.act}\n`),r.out);
|
|
assert.ok(r.out.includes(`\nBACKUP_STORE ${e.act} schema 3g\n`),r.out);
|
|
assert.equal(readFileSync(e.log,'utf8'),l0);assert.equal(untouched(e),before);
|
|
// The rehearsal's own steps.log has the PASS and FAIL lines only.
|
|
assert.deepEqual(lines(join(r.dir,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
|
|
});
|
|
|
|
test('row 233: --backup last with no backup in the log, or no log, exits 3 with no-backup and makes no rehearsal directory',async(t)=>{
|
|
const e=await rmk(t);
|
|
for(const how of ['no log','adopt only']){
|
|
if(how==='adopt only')rlog(e,['adopt',B,null]);
|
|
const r=await rehearse(e,B,'--backup','last','--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,3,how+r.out+r.err);
|
|
assert.equal(r.out,'FAIL no-backup: the log records no backup\nREHEARSAL_FAIL\n',how);
|
|
assert.equal(r.dir,null);assert.deepEqual(rDirs(e),[],how);assert.ok(!existsSync(join(e.data,'rehearsals')),how);
|
|
}
|
|
});
|
|
|
|
test('row 233: --backup last refuses a newest backup that is not an activation id, with exit 1',async(t)=>{
|
|
const e=await rmk(t);
|
|
rlog(e,['activate',B,'../../etc']);
|
|
const r=await rehearse(e,B,'--backup','last','--port',await freePort(),'--timeout','3');
|
|
assert.equal(r.code,1,r.out+r.err);
|
|
assert.equal(r.out,'FAIL backup-unreadable: the log\'s newest backup is not an activation id\nREHEARSAL_FAIL\n');
|
|
assert.deepEqual(rDirs(e),[]);
|
|
});
|
|
|
|
// --- Row 190 (#1688): stack instances on one host. Design and the T-I table:
|
|
// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in
|
|
// stack-instances.test.mjs; these need the units, the stand-ins and stack-release.sh. Every existing
|
|
// test above is D1's "no flag, no marker" case and passes unchanged.
|
|
|
|
const shOut=(env,name,...a)=>spawnSync('bash',[join(repo,'scripts',name),...a],{env,encoding:'utf8'});
|
|
// The Console template's comment on the variables its unit sets, as the default and a named instance render it.
|
|
const CONSOLE_VARS={
|
|
default:'# and 94). The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
|
|
named:'# and 94). The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
|
|
};
|
|
const SHARED_DROPINS=['mosaic-bus-.service.d','mosaic-webui-.service.d','mosaic-discord-.service.d'];
|
|
// Every path under each of ps with its mode, size, mtime and, for a file, its sha256.
|
|
function stamp(...ps){
|
|
const out=[];
|
|
const walk=(q)=>{let st;try{st=lstatSync(q)}catch{out.push(`${q} absent`);return}
|
|
out.push(`${q} ${st.mode.toString(8)} ${st.size} ${st.mtimeMs}${st.isFile()?' '+sha(q):''}`);
|
|
if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))};
|
|
for(const p of ps)walk(p);return out;
|
|
}
|
|
const dirsUnder=(p)=>stamp(p).filter((l)=>!l.endsWith(' absent')).map((l)=>l.split(' ')[0]).filter((q)=>lstatSync(q).isDirectory());
|
|
// The T53 inotify watch on a directory: it counts each open or read of the directory or a file in it.
|
|
async function watchDir(e,d){
|
|
const ch=spawn('python3',['-I','-c',WATCH,d],{stdio:['pipe','pipe','inherit']});
|
|
let out='';ch.stdout.on('data',(b)=>{out+=b});
|
|
await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))});
|
|
WATCHERS.add(ch);ch.on('close',()=>WATCHERS.delete(ch));
|
|
(e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return [d,Number(out.split('\n')[1])]});
|
|
}
|
|
|
|
test('T56 row 190: named instances side by side on one unit directory, $BIN and set of stand-ins',async(t)=>{
|
|
await t.test('T-I3 each instance renders its own config, session env and Description, and nothing of another',async(t)=>{
|
|
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');
|
|
for(const [x,y] of [[a,b],[b,a]]){
|
|
const renders={
|
|
bus:shOut(x.env,'bus-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--claude',e.claude),
|
|
console:shOut(x.env,'webui-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--port',x.port,'--business','acme'),
|
|
discord:shOut(x.env,'discord-service.sh','render','--instance',x.name),
|
|
};
|
|
for(const [k,r] of Object.entries(renders)){
|
|
assert.equal(r.status,0,`${x.name} ${k}: ${r.stderr}`);
|
|
assert.ok(r.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${x.cfg}\n`),`${x.name} ${k}`);
|
|
assert.match(r.stdout,new RegExp(`\\nDescription=[^\\n]* \\[${x.name}\\]\\n`),`${x.name} ${k}`);
|
|
for(const not of [y.name,'mosaic-dev'])assert.ok(!r.stdout.includes(not),`${x.name} ${k} holds ${not}`);
|
|
}
|
|
assert.ok(renders.bus.stdout.includes(`\nEnvironmentFile=-${dirname(x.cfg)}/secrets/claude-session.env\n`),x.name);
|
|
assert.ok(renders.console.stdout.includes(`\n${CONSOLE_VARS.named}\n`),x.name);
|
|
assert.ok(!renders.console.stdout.includes('sets no MOSAIC_'),x.name);
|
|
assert.equal(renders.bus.stdout.match(/^Environment(File)?=.*$/gm).filter((l)=>l.includes('MOSAIC_CONFIG')||l.startsWith('EnvironmentFile')).length,2,x.name);
|
|
}
|
|
// Sage's N3 ruling: the default Console keeps the template's comment byte for byte.
|
|
const dflt=shOut(e.env,'webui-service.sh','render','--copy',B,'--copies',COPIES,'--business','acme');
|
|
assert.equal(dflt.status,0,dflt.stderr);
|
|
assert.ok(dflt.stdout.includes(`\n${CONSOLE_VARS.default}\n`),dflt.stdout);
|
|
assert.ok(!dflt.stdout.includes('MOSAIC_CONFIG'),dflt.stdout);
|
|
// Filbert round 2: under --sso the Console reads console.sso from the config MOSAIC_CONFIG names (row 192),
|
|
// so a named --sso render carries both.
|
|
const sso=shOut(a.env,'webui-service.sh','render','--instance',a.name,'--copy',B,'--copies',COPIES,'--port',a.port,'--business','acme','--sso');
|
|
assert.equal(sso.status,0,sso.stderr);
|
|
assert.match(sso.stdout,/^ExecStart=.* --sso(?: |$)/m,sso.stdout);
|
|
assert.ok(sso.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`),sso.stdout);
|
|
await seed(a);await seed(b);
|
|
for(const x of [a,b]){
|
|
const r=shOut(x.env,'discord-service.sh','install','--instance',x.name,'--dir',e.units,'--no-reload');assert.equal(r.status,0,r.stderr);
|
|
for(const u of [x.tmpl,x.con,`mosaic-discord-${x.name}@.service`])assert.ok(existsSync(join(e.units,u)),u);
|
|
}
|
|
assert.deepEqual(readdirSync(e.units).sort(),[a.tmpl,a.con,`[email protected]`,b.tmpl,b.con,`[email protected]`].sort());
|
|
for(const x of [a,b])assert.equal(readFileSync(join(e.bin,x.wrapper),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${x.name} (row 190)`);
|
|
});
|
|
|
|
await t.test('T-I4 a Console port another installed Console takes refuses port-claimed, and a named instance keeps off the default ports',async(t)=>{
|
|
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);
|
|
const held=`port ${a.port} is taken by ${join(e.units,a.con)}`,free=await freePort();
|
|
const install=(x,...flags)=>shOut(x.env,'webui-service.sh','install',...x.flags,'--copy',B,'--copies',COPIES,...flags,'--business','acme');
|
|
const unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
|
|
for(const [label,x,flags,code,msg] of [
|
|
['beta on alpha\'s port',b,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
|
|
['beta\'s tester port on alpha\'s port',b,['--port',free,'--tester-port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
|
|
['the default on alpha\'s port',e,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
|
|
['beta with no --port',b,[],4,'webui-service: stack instance beta needs --port; it has no default port\n'],
|
|
...['7330','7332','7333'].map((p)=>[`beta on ${p}`,b,['--port',p],4,`webui-service: port-reserved: ${p} is reserved for the default instance (7330 7332 7333)\n`]),
|
|
['beta\'s tester port on 7333',b,['--port',free,'--tester-port','7333'],4,'webui-service: port-reserved: 7333 is reserved for the default instance (7330 7332 7333)\n'],
|
|
]){
|
|
const r=install(x,...flags);assert.equal(r.status,code,`${label}: ${r.stderr}`);assert.equal(r.stderr,msg,label);
|
|
assert.deepEqual(stamp(e.units),unitsBefore,label);assert.deepEqual(stamp(e.bin),binBefore,label);
|
|
}
|
|
// beta on its own port passes: the seed installs both its units and adopts.
|
|
await seed(b);assert.ok(existsSync(join(e.units,b.con)));
|
|
// stack-release: a target Console port another Console takes refuses before anything stops.
|
|
const refused=async(label,...args)=>{
|
|
const before=fp(b),n=calls(e).length,logs=logLines(b).length;
|
|
const r=await run(b,'activate',C1,'acme',...args);assert.equal(r.code,3,`${label}: ${r.err}`);
|
|
assert.equal(r.err,`stack-release: refusing port-claimed: ${held}\n`,label);
|
|
assert.deepEqual(fp(b),before,label);assert.deepEqual(startsStops(e,n),[],label);
|
|
assert.equal(logLines(b).length,logs+1,label);assert.deepEqual([logLines(b).at(-1).result,logLines(b).at(-1).code],['refused','port-claimed'],label);
|
|
};
|
|
await refused('--tester-port','--tester-port',a.port);
|
|
const p=pointer(b);p.console[1]=a.port;writeFileSync(b.pointer,JSON.stringify(p)+'\n');
|
|
await refused('the pointer\'s --port');
|
|
const plan=await run(b,'activate',C1,'acme','--plan');assert.equal(plan.code,3,plan.err);
|
|
assert.ok(plan.out.split('\n').includes(`refuses port-claimed: ${held}`),plan.out);
|
|
});
|
|
|
|
// T-I6: activate and rollback on x leave y's every file, directory and the shared prefix drop-in
|
|
// directories as they were, and open nothing of y's config dir or data root.
|
|
const noCrossReach=async(t,xn,yn)=>{
|
|
const e=await mk(t),x=await named(e,xn),y=await named(e,yn);
|
|
for(const d of [...SHARED_DROPINS,`${x.tmpl}.d`,`${x.con}.d`,`${y.tmpl}.d`,`${y.con}.d`]){mkdirSync(join(e.units,d));writeFileSync(join(e.units,d,'t-i6.conf'),`# row 190 T-I6: ${d}\n`)}
|
|
await seed(x);await seed(y);
|
|
const mine=()=>stamp(dirname(y.cfg),y.data,...[y.tmpl,y.con,`${y.tmpl}.d`,`${y.con}.d`,...SHARED_DROPINS].map((u)=>join(e.units,u)),join(e.bin,y.wrapper),join(e.bin,'mosaic'));
|
|
const before=mine(),n=calls(e).length;
|
|
const watched=[...dirsUnder(dirname(y.cfg)),...dirsUnder(y.data)];
|
|
for(const d of [dirname(y.cfg),y.data,join(y.data,'bus'),y.state])assert.ok(watched.includes(d),d);
|
|
for(const d of watched)await watchDir(e,d);
|
|
const act=await run(x,'activate',C1,'acme');assert.equal(act.code,0,act.out+act.err);
|
|
const back=await run(x,'rollback','acme');assert.equal(back.code,0,back.out+back.err);
|
|
assert.deepEqual((await Promise.all(e.watches.map((s)=>s()))).filter(([,c])=>c!==0),[],`${xn}'s runs opened ${yn}'s files`);
|
|
assert.deepEqual(mine(),before);
|
|
assert.deepEqual(calls(e).slice(n).filter((c)=>c.includes(yn)),[]);
|
|
const sets=readdirSync(join(x.state,'stack-rollback')).sort();assert.equal(sets.length,2,sets.join(' '));
|
|
for(const s of sets){
|
|
const rs=join(x.state,'stack-rollback',s);
|
|
assert.deepEqual(readdirSync(rs).sort(),[x.tmpl,`${x.tmpl}.d`,x.con,`${x.con}.d`,'wrapper','WRAPPER_MODE',`show-${x.bus}.txt`,`show-${x.con}.txt`,'SHA256SUMS','steps.log'].sort(),s);
|
|
assert.equal(readFileSync(join(rs,'wrapper'),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${xn} (row 190)`);
|
|
}
|
|
assert.equal(pointer(x).copy,B);assert.equal(pointer(y).copy,B);
|
|
};
|
|
await t.test('T-I6 alpha\'s activate and rollback reach nothing of beta\'s',(t)=>noCrossReach(t,'alpha','beta'));
|
|
await t.test('T-I6 swapped: beta\'s reach nothing of alpha\'s',(t)=>noCrossReach(t,'beta','alpha'));
|
|
|
|
await t.test('T-I7 MOSAIC_CONFIG naming alpha\'s config with no flag refuses instance-root-mismatch and changes nothing',async(t)=>{
|
|
const e=await mk(t),a=await named(e,'alpha');await seed(a);
|
|
const x={...a,env:{...a.env,MOSAIC_CONFIG:a.cfg},flags:[]};
|
|
const before=fp(a),n=calls(e).length,logs=logLines(a).length,unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
|
|
const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.err);assert.match(r.err,/instance-root-mismatch/);assert.equal(r.out,'');
|
|
const i=shOut(x.env,'bus-service.sh','install','--copy',C1,'--copies',COPIES,'--claude',e.claude);
|
|
assert.equal(i.status,3,i.stderr);assert.match(i.stderr,/instance-root-mismatch/);
|
|
assert.deepEqual(calls(e).slice(n),[]);assert.deepEqual(fp(a),before);assert.equal(logLines(a).length,logs);
|
|
assert.deepEqual(stamp(e.units),unitsBefore);assert.deepEqual(stamp(e.bin),binBefore);
|
|
});
|
|
|
|
await t.test('T-I9 another instance\'s bus active refuses nothing; one of this instance\'s does',async(t)=>{
|
|
const e=await mk(t);await seed(e);
|
|
const a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);await seed(b);
|
|
for(const x of [e,b]){const r=await run(x,'activate',C1,'acme');assert.equal(r.code,0,`${x.name??'default'}: ${r.out+r.err}`)}
|
|
const setUnit=(u,state)=>{const s=units(e);if(state)s[u]={...(s[u]||{pid:null,wd:''}),state};else delete s[u];writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))};
|
|
for(const [u,state,msg] of [['[email protected]','active','[email protected] is active; the copy and wrapper switch reach it too'],[a.bus,'activating',`${a.bus} is activating`]]){
|
|
const was=units(e)[u];setUnit(u,state);
|
|
const before=fp(a),n=calls(e).length;
|
|
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,3,r.err);
|
|
assert.equal(r.err,`stack-release: refusing instance-busy: ${msg}\n`);assert.deepEqual(fp(a),before);assert.deepEqual(startsStops(e,n),[]);
|
|
setUnit(u,was&&was.state);if(was){const s=units(e);s[u]=was;writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))}
|
|
}
|
|
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
});
|
|
});
|
|
|
|
test('row196 explicit socket conversion refuses inherited TCP before stop and rollback disables socket',async t=>{
|
|
const e=await mk(t),base=await seedPreview(e,{copy:A}),proof=await proofFile(e);
|
|
let r=await run(e,'activate',B,'acme','--tester-port','7333');assert.equal(r.code,0,r.out+r.err);
|
|
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
|
|
chmodSync(file,0o644);writeFileSync(file,'// MOSAIC_TESTER_SOCKET_V1\n// --tester-socket\n// --proof-header\n');chmodSync(file,0o444);
|
|
t.after(()=>{chmodSync(file,0o644);writeFileSync(file,original);chmodSync(file,0o444);});
|
|
const module=join(COPIES,C1,'packages/bus/src/interpreter.mjs');
|
|
chmodSync(dirname(module),0o755);
|
|
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
|
|
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
|
|
const n=calls(e).length;
|
|
r=await run(e,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-transport-migration/);assert.deepEqual(startsStops(e,n),[]);
|
|
r=await run(e,'activate',C1,'acme','--tester-socket','--plan');assert.equal(r.code,0,r.out+r.err);assert.match(r.out,/--tester-socket/);assert.match(r.out,/drop.*--tester-port 7333/);
|
|
r=await run(e,'activate',C1,'acme','--tester-socket',...pair(proof));assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,[...base,...pair(proof),'--tester-socket']);assert.match(consoleText(e),/--tester-socket/);assert.doesNotMatch(consoleText(e),/--tester-port/);
|
|
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.doesNotMatch(consoleText(e),/--tester-(socket|port)/);assert.match(r.out,/drop.*--tester-socket/);
|
|
await noLeak(e);
|
|
mkdirSync(join(e.data,'console'),{mode:0o755});
|
|
const unsafeBefore=calls(e).length;
|
|
r=await run(e,'activate',C1,'acme','--tester-socket');
|
|
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(e,unsafeBefore),[]);
|
|
const long=await mk(t,'data-'+ 'a'.repeat(100));await seed(long,{copy:B});
|
|
const before=calls(long).length;
|
|
r=await run(long,'activate',C1,'acme','--tester-socket');
|
|
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]);
|
|
});
|
|
|
|
// Row196 merge guard: without --send, SSO insertion must still precede the socket flag.
|
|
test('row196 SSO socket conversion pins pointer and unit argv order without send',async t=>{
|
|
const e=await mk(t);await seedPreview(e,{copy:B});await ssoConfig(e);
|
|
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
|
|
chmodSync(file,0o644);writeFileSync(file,original+'\n// MOSAIC_TESTER_SOCKET_V1\n// --tester-socket\n');chmodSync(file,0o444);
|
|
t.after(()=>{chmodSync(file,0o644);writeFileSync(file,original);chmodSync(file,0o444);});
|
|
const module=join(COPIES,C1,'packages/bus/src/interpreter.mjs');
|
|
chmodSync(dirname(module),0o755);
|
|
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
|
|
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
|
|
const r=await prun(e,'activate',C1,'acme','--sso','--tester-socket');assert.equal(r.code,0,r.out+r.err);
|
|
const want=['--port',e.port,'--business','acme','--sso','--tester-socket'];
|
|
assert.deepEqual(pointer(e).console,want);
|
|
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
|
|
assert.doesNotMatch(conExec(e),/ --send(?: |$)|--tester-port|--public-host|--proof-header/);
|
|
const status=await prun(e,'status','acme');assert.equal(status.code,0,status.out+status.err);assert.match(status.out,/unit flags: match the pointer/);
|
|
await noLeak(e);
|
|
});
|
|
|
|
// T10 activation: exercise the shell caller as well as interpreter.mjs itself.
|
|
test('row196 interpreter activation checks receipt, identity, plan and startup log',async t=>{
|
|
const cli=join(COPIES,C1,'packages/webui/src/cli.mjs'),old=readFileSync(cli,'utf8');
|
|
const module=join(COPIES,C1,'packages/bus/src/interpreter.mjs');
|
|
const identity={path:'/usr/bin/python3',sha256:'fixture-root-owned-ELF'};
|
|
const source=`import fs from 'node:fs';import {join} from 'node:path';
|
|
const root=process.env.HOME,sd=process.env.SD_STATE;
|
|
const fault=n=>fs.existsSync(join(sd,'faults',n));
|
|
export function inspectInterpreter(){
|
|
if(fault('interpreter-unsafe'))throw Error('unsafe interpreter');
|
|
const seen=join(root,'interpreter-inspected'),changed=fault('interpreter-drift')&&fs.existsSync(seen);
|
|
fs.writeFileSync(seen,'inspected');
|
|
return {...${JSON.stringify(identity)},...(changed?{sha256:'replacement-ELF'}:{})};
|
|
}
|
|
export function verifyInterpreterReceipt(directory,expected,since){
|
|
fs.appendFileSync(join(root,'interpreter-verified.jsonl'),JSON.stringify({directory,expected,since})+'\\n');
|
|
if(fault('interpreter-receipt-refuse'))throw Error('receipt does not match started bus');
|
|
return true;
|
|
}
|
|
`;
|
|
const putModule=()=>{chmodSync(dirname(module),0o755);writeFileSync(module,source);chmodSync(module,0o444);chmodSync(dirname(module),0o555);};
|
|
chmodSync(cli,0o644);writeFileSync(cli,old+'\n// MOSAIC_TESTER_SOCKET_V1\n');chmodSync(cli,0o444);putModule();
|
|
t.after(()=>{chmodSync(cli,0o644);writeFileSync(cli,old);chmodSync(cli,0o444);chmodSync(dirname(module),0o755);rmSync(module,{force:true});chmodSync(dirname(module),0o555);});
|
|
await t.test('plan and started log preserve the same interpreter JSON',async t=>{
|
|
const e=await mk(t);await seed(e);
|
|
const plan=await run(e,'activate',C1,'acme','--tester-socket','--plan');assert.equal(plan.code,0,plan.out+plan.err);
|
|
const line=/^interpreter: (.+)$/m.exec(plan.out);assert.ok(line,plan.out);assert.deepEqual(JSON.parse(line[1]),identity);
|
|
const r=await run(e,'activate',C1,'acme','--tester-socket');assert.equal(r.code,0,r.out+r.err);
|
|
const started=logLines(e).find(x=>x.op==='activate'&&x.result==='started');assert.ok(started);
|
|
assert.equal(started.interpreter,line[1]);assert.deepEqual(JSON.parse(started.interpreter),identity);
|
|
const verified=lines(join(e.env.HOME,'interpreter-verified.jsonl')).map(JSON.parse);
|
|
assert.ok(verified.length>0);assert.equal(verified[0].directory,join(e.data,'bus'));assert.deepEqual(verified[0].expected,identity);assert.ok(Number(verified[0].since)>0);
|
|
});
|
|
for(const scenario of ['interpreter-receipt-refuse','interpreter-drift'])await t.test(scenario+' fails at the gate and restores the pointer',async t=>{
|
|
const e=await mk(t);await seed(e);const before=readFileSync(e.pointer,'utf8'),n=calls(e).length,logs=logLines(e).length;fault(e,scenario);
|
|
const r=await run(e,'activate',C1,'acme','--tester-socket','--timeout','1');
|
|
assert.equal(r.code,1,r.out+r.err);assert.match(r.out+r.err,/interpreter-receipt/);assert.equal(readFileSync(e.pointer,'utf8'),before);
|
|
assert.ok(startsStops(e,n).some(x=>x.startsWith('start ')),startsStops(e,n).join('\n'));
|
|
const added=logLines(e).slice(logs);assert.ok(added.some(x=>x.result==='gate-failed'&&x.code==='interpreter-receipt'&&x.rollback==='ok'),JSON.stringify(added));
|
|
assert.equal(added.some(x=>x.result==='ok'),false,JSON.stringify(added));
|
|
});
|
|
for(const scenario of ['missing-verifier','interpreter-unsafe'])await t.test(scenario+' refuses before any start or stop',async t=>{
|
|
const e=await mk(t);await seed(e);const before=readFileSync(e.pointer,'utf8'),n=calls(e).length;
|
|
if(scenario==='missing-verifier'){chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);}else fault(e,scenario);
|
|
try{
|
|
for(const flags of [['--plan'],[]]){
|
|
const r=await run(e,'activate',C1,'acme','--tester-socket',...flags);
|
|
assert.equal(r.code,3,r.out+r.err);assert.match(r.out+r.err,/interpreter-unsafe/);
|
|
assert.equal(readFileSync(e.pointer,'utf8'),before);assert.deepEqual(startsStops(e,n),[]);
|
|
}
|
|
}finally{if(scenario==='missing-verifier')putModule();}
|
|
});
|
|
});
|
|
|
|
test('row196 rehearsal selects socket only for marked copies and uses scratch dataRoot',async t=>{
|
|
const file=join(RCOPIES,B,'packages/webui/src/cli.mjs'),old=readFileSync(file,'utf8');
|
|
const put=text=>{chmodSync(file,0o644);writeFileSync(file,text);chmodSync(file,0o444);};
|
|
t.after(()=>put(old));
|
|
for(const marked of [true,false])await t.test(marked?'socket copy':'legacy copy',async t=>{
|
|
put(old+(marked?'\n// MOSAIC_TESTER_SOCKET_V1\n':''));
|
|
const e=await rmk(t),before=untouched(e),port=await freePort();
|
|
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');assert.equal(r.code,0,r.out+r.err);
|
|
const contexts=lines(rh(e,'console-context.jsonl')).map(JSON.parse);assert.equal(contexts.length,1);
|
|
const context=contexts[0];assert.deepEqual(context.argv,['serve','--port',port,'--business','acme',...(marked?['--tester-socket']:[])]);
|
|
assert.equal(context.argv.includes('--tester-socket'),marked);assert.equal(context.argv.includes('--tester-port'),false);
|
|
assert.equal(context.dataRoot,join(r.dir,'data'));assert.notEqual(context.dataRoot,e.data);
|
|
assert.equal(JSON.parse(readFileSync(join(r.dir,'config/config.json'),'utf8')).dataRoot,context.dataRoot);
|
|
assert.equal(untouched(e),before);
|
|
});
|
|
});
|
|
|
|
// #1797: validate every config with the target loader, even without console.
|
|
function anyConfigLoader(file,text){
|
|
const old=existsSync(file)?readFileSync(file):null;
|
|
if(old!==null)chmodSync(file,0o644);
|
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,text);
|
|
return ()=>{if(old===null)rmSync(file);else{writeFileSync(file,old);chmodSync(file,0o444);}};
|
|
}
|
|
const ANY_CONFIG_MSG=(e,to,op,why)=>`${e.cfg} is refused by ${to}'s loader (${why}); remove the refused key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
|
|
test('config-any-key: a non-console target refusal stops activate and rollback before any stop',async t=>{
|
|
const e=await mk(t);await seed(e);
|
|
let r=await run(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
|
const why='mosaic-config: unsupported configuration key: "execution"';
|
|
const undo=anyConfigLoader(join(COPIES,B,'scripts/mosaic-config.mjs'),`console.error(${JSON.stringify(why)});process.exitCode=2;\n`);
|
|
try{
|
|
assert.equal(Object.hasOwn(JSON.parse(readFileSync(e.cfg,'utf8')),'console'),false);
|
|
for(const op of ['activate','rollback']){
|
|
const args=op==='activate'?['activate',B,'acme']:['rollback','acme'];
|
|
await refused(e,args,'config-console-key',ANY_CONFIG_MSG(e,B,op,why));
|
|
}
|
|
const out=await planOnly(e,3,'rollback','acme');
|
|
assert.deepEqual(out.filter(l=>l.startsWith('refuses ')),[`refuses config-console-key: ${ANY_CONFIG_MSG(e,B,'rollback',why)}`]);
|
|
}finally{undo();}
|
|
});
|
|
test('config-any-key: accepting loaders run validate with the selected config on activate and rollback',async t=>{
|
|
const e=await mk(t);await seed(e);const witness=join(e.root,'loader-calls.jsonl'),canary='resolved-config-must-not-leak';
|
|
const undos=[B,C1].map(id=>anyConfigLoader(join(COPIES,id,'scripts/mosaic-config.mjs'),`import fs from 'node:fs';fs.appendFileSync(${JSON.stringify(witness)},JSON.stringify({copy:${JSON.stringify(id)},args:process.argv.slice(2),config:process.env.MOSAIC_CONFIG})+'\\n');console.log(${JSON.stringify(canary)});\n`));
|
|
try{
|
|
for(const args of [['activate',C1,'acme'],['rollback','acme']]){
|
|
const r=await run(e,...args);assert.equal(r.code,0,r.out+r.err);assert.equal((r.out+r.err).includes(canary),false);
|
|
}
|
|
assert.deepEqual(lines(witness).map(JSON.parse),[
|
|
{copy:C1,args:['validate'],config:e.cfg},{copy:B,args:['validate'],config:e.cfg},
|
|
]);
|
|
}finally{for(const undo of undos)undo();}
|
|
});
|
|
function isolatedConfigChecks(e){
|
|
const source=readFileSync(SR,'utf8'),body=source.split('config_checks() {')[1]?.split('\n}\n')[0];assert.ok(body,'config_checks exists');
|
|
return spawnSync('bash',['-c','config_checks() {'+body+'\n}\ncheck_refuse(){ printf "%s: %s\\n" "$1" "$2" >&2; exit 3; }; config_checks'],{
|
|
encoding:'utf8',env:{...e.env,COPIES:join(e.root,'target-copies'),TO:'old',OP:'activate'},
|
|
});
|
|
}
|
|
test('config-any-key: missing-loader copies accept only the four oldest keys',async t=>{
|
|
const e=await mk(t);let r=isolatedConfigChecks(e);assert.equal(r.status,0,r.stderr);
|
|
const original=JSON.parse(readFileSync(e.cfg,'utf8'));
|
|
for(const [key,value] of [['compactionReadBytes',1048576],['compactionStreams',['messages']]]){
|
|
writeFileSync(e.cfg,JSON.stringify({...original,[key]:value}));r=isolatedConfigChecks(e);
|
|
assert.equal(r.status,3,r.stderr);assert.match(r.stderr,new RegExp(`configuration key: "${key}"`));
|
|
assert.ok(r.stderr.includes('restore the config.json backup from the window\'s step 1'));
|
|
}
|
|
});
|
|
test('config-any-key: missing config passes but malformed JSON refuses without calling the loader',async t=>{
|
|
const e=await mk(t),file=join(e.root,'target-copies/old/scripts/mosaic-config.mjs'),witness=join(e.root,'validate-called');
|
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,`import fs from 'node:fs';fs.writeFileSync(${JSON.stringify(witness)},'called');\n`);
|
|
rmSync(e.cfg);let r=isolatedConfigChecks(e);assert.equal(r.status,0,r.stderr);assert.equal(existsSync(witness),false);
|
|
writeFileSync(e.cfg,'{');r=isolatedConfigChecks(e);assert.equal(r.status,3,r.stderr);assert.match(r.stderr,/cannot be read as JSON/);assert.equal(existsSync(witness),false);
|
|
});
|