control-board serve.test: the non-loopback refusal test took a port from a probe-and-close helper and then called startServer on it. It now binds its own listener, keeps it for the whole test, and closes it in finally, so the port cannot be taken by another process in between. stack-release.test T-I4: the free port goes only to webui-service.sh install rows that refuse before any listen, so it is now a closed lease (leaseClosedPort), which refuses connects and blocks other binds. The 22 other freePort callers hand the number to `stack-release.sh rehearse`, which probes it and starts a Console later. That needs a script change and is left as a design, with a comment above freePort naming the window.
2748 lines
207 KiB
JavaScript
2748 lines
207 KiB
JavaScript
import { leaseClosedPort, 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 {businessImportedFiles} from './business-imports.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: initial activation drops legacy preview send; the pair goes before the kept --tester-port and 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);
|
|
assert.deepEqual(pointer(e).console,[...pre,'--tester-port','7333']);
|
|
assert.match(stepText(e),/Z drop --send; header preview is read-only \(#385\)\n/);
|
|
const out=await planOnly(e,0,'activate',C1,'acme',...pair(f));
|
|
assert.deepEqual(proofLines(out),[`proof: on, header x-console-proof, file ${f}`]);
|
|
assert.deepEqual(sendLines(out),['send: off']);
|
|
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(` ${PREVIEW.join(' ')} ${pair(f).join(' ')} --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),'--tester-port','7333']);
|
|
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${[...pre,...pair(f),'--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'),'--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('Mos2255 source component: legacy preview send drops while explicit and SSO paths keep their guards',()=>{
|
|
const source=readFileSync(SR,'utf8');
|
|
const exact=(start,end)=>{
|
|
const a=source.indexOf(start),b=source.indexOf(end,a);
|
|
assert.ok(a>=0&&b>a,`missing source component ${start}`);
|
|
return source.slice(a,b);
|
|
};
|
|
const component=[
|
|
exact('check_refuse() {','\n\n# Item 1 and item 4'),
|
|
exact('has_flag() {','\n\n# Row 200'),
|
|
exact('send_for_target() {','\n\n# Row 187:'),
|
|
exact('sso_for_target() {','\n\n# Row 200 item 2'),
|
|
].join('\n');
|
|
const runCase=(setup)=>{
|
|
const r=spawnSync('/bin/bash',['-c',`${component}
|
|
PLAN=1; PLAN_REFUSE=(); SEND=0; NO_SEND=0; NO_SSO=0
|
|
${setup}
|
|
send_for_target
|
|
printf 'console=%s\\nplan=%s\\nstep=%s\\nrefuses=%s\\n' "\${RUN_CONSOLE[*]}" "$SEND_PLAN" "$SEND_STEP" "\${PLAN_REFUSE[*]}"
|
|
`],{encoding:'utf8'});
|
|
assert.equal(r.status,0,r.stderr);
|
|
return Object.fromEntries(r.stdout.trimEnd().split('\n').map((line)=>line.split(/=(.*)/s).slice(0,2)));
|
|
};
|
|
const preview='--port 7332 --business acme --public-host console.example --auth-header x-user --allow-user jason';
|
|
assert.deepEqual(runCase(`P_CONSOLE=(${preview} --send); RUN_CONSOLE=("\${P_CONSOLE[@]}")`),{
|
|
console:preview,
|
|
plan:'send: dropped; header preview is read-only (#385)',
|
|
step:'drop --send; header preview is read-only (#385)',
|
|
refuses:'',
|
|
});
|
|
assert.match(runCase(`SEND=1; P_CONSOLE=(${preview} --send); RUN_CONSOLE=("\${P_CONSOLE[@]}")`).refuses,/^send-needs-sso: --send needs --sso/);
|
|
assert.deepEqual(runCase('P_CONSOLE=(--port 7332 --business acme --sso --send --tester-port 7333); RUN_CONSOLE=("${P_CONSOLE[@]}")'),{
|
|
console:'--port 7332 --business acme --sso --send --tester-port 7333',plan:'send: carried',step:'',refuses:'',
|
|
});
|
|
assert.deepEqual(runCase(`NO_SEND=1; P_CONSOLE=(${preview} --send); RUN_CONSOLE=("\${P_CONSOLE[@]}")`),{
|
|
console:preview,plan:'send: dropped; --no-send was given',step:'drop --send; --no-send was given',refuses:'',
|
|
});
|
|
assert.deepEqual(runCase(`NO_SSO=1; P_CONSOLE=(${preview} --sso --send); RUN_CONSOLE=(${preview} --send)`),{
|
|
console:preview,plan:'send: dropped; --no-sso drops --send (F39)',step:'drop --send; --no-sso was given',refuses:'',
|
|
});
|
|
assert.deepEqual(runCase(`P_CONSOLE=(${preview} --sso --send); RUN_CONSOLE=(${preview} --send)`),{
|
|
console:preview,plan:'send: dropped; the run drops --sso, and --send needs it (F39)',step:'drop --send; the run drops --sso',refuses:'',
|
|
});
|
|
// R2-I1 (#385): activate --sso on a legacy preview --send pointer. The real sso_for_target builds the
|
|
// run's flags from the pointer's, and send_for_target carries --send after --sso, before the tester flag.
|
|
assert.deepEqual(runCase(`SSO=1; P_CONSOLE=(${preview} --send --tester-socket); RUN_CONSOLE=("\${P_CONSOLE[@]}"); sso_for_target`),{
|
|
console:'--port 7332 --business acme --sso --send --tester-socket',plan:'send: carried',step:'',refuses:'',
|
|
});
|
|
});
|
|
|
|
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: dropped; header preview is read-only (#385)']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
|
|
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(` ${noSend(base).join(' ')}`));
|
|
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: a legacy pointer with both a pair and --send drops send implicitly or explicitly while preserving proof choices',async(t)=>{
|
|
const e=await mk(t),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400),base=await seedPreview(e,undefined,[...pair(f),'--send']),pre=noSend(base),plain=base.slice(0,-5);
|
|
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: dropped; header preview is read-only (#385)']);
|
|
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']);
|
|
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,pre);
|
|
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,[...plain,...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),k=await proofFile(h),hbase=await seedPreview(h,undefined,[...pair(k),'--send']),hpre=hbase.slice(0,-5);
|
|
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,new RegExp(`Z drop --proof-header x-console-proof --proof-file ${k}; --no-proof was given\\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: a pre-SSO activation drops legacy send; --sso then drops preview and proof before the tester port',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),'--tester-port','7333']);
|
|
await ssoConfig(e);
|
|
const want=['--port',e.port,'--business','acme','--sso','--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: off',
|
|
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --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),...sendLines(out),...ssoLines(out)],['proof: off','send: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme`]);
|
|
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']);
|
|
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`);
|
|
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
|
|
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','--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);
|
|
// 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(` ${want.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);
|
|
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
|
|
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);
|
|
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.
|
|
// Row 294: config.mjs imports access.mjs, which imports transport.mjs, so each copy carries both.
|
|
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/cli/src/access.mjs','packages/cli/src/transport.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});
|
|
// Row 333 R2 (#1831): what the business loader imports from outside its directory, model-quota.mjs included.
|
|
for(const p of businessImportedFiles(repo))if(!existsSync(join(d,p)))cpSync(join(repo,p),join(d,p));
|
|
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';
|
|
// Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which
|
|
// probes it and starts a Console on it later. Another process can take the port in between. The script
|
|
// takes no port 0 and learns no bound port, so that window stays here; a rehearsal that listens on its
|
|
// own is the only form that closes it. A caller that never listens takes leaseClosedPort(t) instead.
|
|
const freePort=async()=>String(await leasePort({after:laneAfter}));
|
|
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.dir!==null, 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=String(await leaseClosedPort(t));
|
|
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();}
|
|
});
|
|
// Duckworth C1 (duckworth-0229): B's fixture loader (LOADER_OLD) is today's loader with a shorter
|
|
// first list, so it still reads the row 270 keys. This stand-in is C1's loader without the row 270
|
|
// concat, the list the loader had before bce491715 added the first of the two keys.
|
|
const PRE270=(now)=>{const old=now.replace('.concat(["compactionReadBytes", "compactionStreams"])','');assert.notEqual(old,now);return old};
|
|
test('config-any-key: a pre-270 target refuses each row 270 key on a full activate and rollback, naming the key',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 original=readFileSync(e.cfg,'utf8'),now=join(COPIES,C1,'scripts/mosaic-config.mjs');
|
|
assert.equal(Object.hasOwn(JSON.parse(original),'console'),false);
|
|
const undo=anyConfigLoader(join(COPIES,B,'scripts/mosaic-config.mjs'),PRE270(readFileSync(now,'utf8')));
|
|
try{
|
|
for(const [key,value] of [['compactionReadBytes',1048576],['compactionStreams',1024]]){
|
|
writeFileSync(e.cfg,JSON.stringify({...JSON.parse(original),[key]:value}));
|
|
// C1's own loader reads the key, so the refusal is the target's.
|
|
const ok=spawnSync(NODE,[now,'validate'],{encoding:'utf8',env:{...e.env,MOSAIC_CONFIG:e.cfg}});assert.equal(ok.status,0,ok.stderr);
|
|
const why=`mosaic-config: unsupported configuration key: "${key}"`;
|
|
await refused(e,['activate',B,'acme'],'config-console-key',ANY_CONFIG_MSG(e,B,'activate',why));
|
|
await refused(e,['rollback','acme'],'config-console-key',ANY_CONFIG_MSG(e,B,'rollback',why));
|
|
}
|
|
}finally{undo();writeFileSync(e.cfg,original);}
|
|
r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
|
|
});
|
|
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',1024]]){
|
|
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'));
|
|
}
|
|
// Row 203's text for a console or federation key stays word for word with no loader too, even
|
|
// when a later key comes first in the file.
|
|
for(const [key,value] of [['console',{}],['federation',{self:'hub',peers:[]}]]){
|
|
writeFileSync(e.cfg,JSON.stringify({...original,compactionStreams:1024,[key]:value}));r=isolatedConfigChecks(e);
|
|
assert.equal(r.status,3,r.stderr);assert.equal(r.stderr,`config-console-key: ${KEY_MSG(e,'old','activate','it has no scripts/mosaic-config.mjs')}\n`);
|
|
}
|
|
});
|
|
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);
|
|
});
|