// scripts/stack-release.sh --units external (row 232, #1734). Design: r6 section 4 and the section 7 // test list, agents/darkwing/work/queue-195/DESIGN.md; deviations: agents/darkwing/work/queue-232/BUILD.md. // The stand-ins and the module fixture are stack-release.test.mjs's (stack-release-fixture.mjs). // Root's part (stop, unit install, start) runs here as the stand-in systemctl and bus-service.sh and // webui-service.sh installs; no sudo and no real unit. import test from 'node:test'; import assert from 'node:assert/strict'; import {execFileSync} from 'node:child_process'; import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,cpSync} from 'node:fs'; import {DatabaseSync} from 'node:sqlite'; import {join} from 'node:path'; import {B,C,C1,COPIES,ROOT,SBIN,base,fault,lines,logLines,mk,pointer,repo,run,seed,sha,storeVersion,sysctl,tree,units} from './stack-release-fixture.mjs'; // The commit row 232 builds on: the user-mode tests compare the candidate's calls and --plan bytes // with its scripts. A later row that changes user mode on purpose moves this. const BASE='cb5fdb064fb4f12edd045374dd463dfdc25c2b42'; const X=(e)=>({...e,flags:[...e.flags,'--units','external']}); const sh=(e,name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']}); // Root's steps: R1/A2 stop, A4 install and start. offset is the start's SD_CLOCK_OFFSET. const down=(e)=>{sysctl(e,'stop',e.con);sysctl(e,'stop',e.bus)}; const up=(e,copy,{pm=false,offset=0}={})=>{ sh(e,'bus-service.sh','install','--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude); sh(e,'webui-service.sh','install','--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme'); execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:String(offset)}}); sysctl(e,'start',e.con); }; // The stack on B for acme, adopted under --units external, then stopped by root unless live. async function xseed(t,{pm=false,live=false}={}){ const e=await mk(t);await seed(e,{pm,pointer:'none'});const x=X(e); const r=await run(x,'adopt','acme');assert.equal(r.code,0,r.err); if(!live)down(e); return {e,x}; } const lastAct=(e)=>logLines(e).filter((o)=>o.result==='started').at(-1).activation; const rs=(e,a)=>join(e.state,'stack-rollback',a); const open=(e)=>{const m=new Set();for(const o of logLines(e)){if(!o.activation)continue;if(o.result==='started')m.add(o.activation);else m.delete(o.activation)}return [...m]}; const mine=(e,a)=>logLines(e).filter((o)=>o.activation===a); const journalCalls=(e)=>lines(join(e.sd,'journal-since.log')); // A3: stop, stage TO. Returns the activation and the run. async function stage(e,x,to,...args){ const r=await run(x,'activate',to,'acme',...args); return {r,a:r.code===0||r.code===1?lastAct(e):null}; } // The started line's from, to and op on the activation's result line, and its backup the set's. function sameAsStarted(e,a){ const [s,f,...more]=mine(e,a);assert.equal(more.length,0,JSON.stringify(mine(e,a))); assert.equal(s.result,'started'); for(const k of ['op','from','to','activation'])assert.equal(f[k],s[k],k); assert.ok(['activate','rollback'].includes(f.op),f.op); assert.equal(f.units,'external'); const b=join(rs(e,a),'BACKUP');assert.equal(f.backup,existsSync(b)?readFileSync(b,'utf8').trim():null); return f; } // B1's end state: one started and one result line, nothing open, no marker or FAILED, and a // following --plan that doesn't refuse interrupted-run. async function closed(e,x,a){ sameAsStarted(e,a); assert.deepEqual(open(e),[]); for(const f of ['AWAITING-UNITS','FAILED'])assert.ok(!existsSync(join(rs(e,a),f)),f); const s=await run(x,'status','acme');assert.equal(s.code,0,s.err); assert.ok(!/interrupted run|awaiting units/.test(s.out),s.out); const p=await run(x,'activate',pointer(e).copy===C1?B:C1,'acme','--plan'); assert.ok(!(p.out+p.err).includes('interrupted-run'),p.out+p.err); } const storeSha=(e)=>sha(e.store); const backupStore=(e,a)=>join(e.data,'backups','stack',a,'bus','bus.sqlite'); test('row 232: confirm, revert, gate, --to and --expect-from need --units external; the user-only flags are usage under it, and so are row 273\'s --send and --no-sso',async(t)=>{ const {e,x}=await xseed(t); const logs=logLines(e).length; for(const a of [['confirm',C1,'acme'],['revert',C1,'acme'],['gate',B,'acme','--since','1'],['rollback','acme','--to',B],['activate',C1,'acme','--plan','--expect-from',B]]){ const r=await run(e,...a);assert.equal(r.code,4,a.join(' '));assert.match(r.err,/^usage: stack-release.sh adopt BUSINESS\n/); } for(const a of [['activate',C1,'acme','--pm'],['activate',C1,'acme','--tester-port','7444'],['activate',C1,'acme','--no-proof'],['activate',C1,'acme','--no-send'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--no-sso'],['activate',C1,'acme','--proof-header','X','--proof-file','/p'],['rollback','acme'],['gate',B,'acme'],['activate',C1,'acme','--expect-from',B],['confirm',C1,'acme','--rollback',B],['confirm',C1,'acme','--cause','A5']]){ const r=await run(x,...a);assert.equal(r.code,4,a.join(' ')); } assert.equal(logLines(e).length,logs); }); test('row 232: pm-on and activate --pm-after-harness refuse external-pm',async(t)=>{ const {e,x}=await xseed(t); const p=await run(x,'pm-on','acme');assert.equal(p.code,3); assert.match(p.err,/^stack-release: refusing external-pm: under --units external the PM is a flag in root's bus unit/); const a=await run(x,'activate',C1,'acme','--pm-after-harness');assert.equal(a.code,3); assert.match(a.err,/^stack-release: refusing external-pm: /); const l=logLines(e).slice(-2);assert.deepEqual(l.map((o)=>[o.result,o.code,o.activation]),[['refused','external-pm',null],['refused','external-pm',null]]); assert.equal(readdirSync(e.state).includes('stack-rollback'),false); }); test('row 232: units-running (N4) refuses while a unit is active and leaves the store, the log and TAKEN; failed counts as down',async(t)=>{ const {e,x}=await xseed(t,{live:true}); sysctl(e,'stop',e.con); const before=storeSha(e),logs=logLines(e).length; const r=await run(x,'activate',C,'acme','--migrate');assert.equal(r.code,3,r.out+r.err); assert.equal(r.err,`stack-release: refusing units-running: ${e.bus} is active; root stops the units first\n`); assert.equal(storeSha(e),before);assert.equal(storeVersion(e.store,true),'3g'); const l=logLines(e).slice(logs);assert.deepEqual(l.map((o)=>[o.result,o.code,o.activation,o.units]),[['refused','units-running',null,'external']]); assert.ok(!existsSync(join(e.data,'backups')));assert.ok(!existsSync(join(e.state,'stack-rollback'))); // The Console active and the bus stopped refuses too. sysctl(e,'stop',e.bus);sysctl(e,'start',e.con); const c=await run(x,'activate',C1,'acme');assert.equal(c.code,3);assert.ok(c.err.startsWith(`stack-release: refusing units-running: ${e.con} is active`),c.err); // A bus unit root's stop left failed is down. sysctl(e,'stop',e.con); const s=units(e);s[e.bus].state='failed';writeFileSync(join(e.sd,'units.json'),JSON.stringify(s)); const f=await run(x,'activate',C1,'acme');assert.equal(f.code,0,f.out+f.err); }); test('row 232: activate stages: the pointer stays, AWAITING-UNITS names TO, a backup of the stopped store, no directory in the set, no --user, no journal',async(t)=>{ const {e,x}=await xseed(t); const p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,'mosaic')),argv0=lines(join(e.sd,'argv.log')).length; const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); assert.equal(r.out.split('\n').at(-2),`stack-release: activate staged ${C1} for acme as ${a}; root installs and starts the units, then runs confirm ${C1} acme --units external`); assert.equal(readFileSync(e.pointer,'utf8'),p0); assert.equal(readFileSync(join(rs(e,a),'AWAITING-UNITS'),'utf8'),`${C1}\n`); assert.deepEqual(readdirSync(rs(e,a)).sort(),['AWAITING-UNITS','BACKUP','SHA256SUMS','UNITS.sha256','WRAPPER_MODE','pointer',`show-${e.bus}.txt`,`show-${e.con}.txt`,'steps.log','wrapper'].sort()); assert.equal(readFileSync(join(rs(e,a),'BACKUP'),'utf8'),`${a}\n`); assert.equal(sha(backupStore(e,a)),storeSha(e));assert.equal(readFileSync(join(e.data,'backups','stack',a,'SCHEMA'),'utf8'),'3g\n'); assert.equal(readFileSync(join(rs(e,a),'UNITS.sha256'),'utf8'),`${sha(join(e.units,e.tmpl))} ${join(e.units,e.tmpl)}\n${sha(join(e.units,e.con))} ${join(e.units,e.con)}\n`); assert.equal(sha(join(rs(e,a),'wrapper')),w0);assert.notEqual(sha(join(e.bin,'mosaic')),w0); assert.deepEqual(mine(e,a).map((o)=>o.result),['started']); assert.deepEqual([units(e)[e.bus].state,units(e)[e.con].state],['inactive','inactive']); const ext=lines(join(e.sd,'argv.log')).slice(argv0); assert.ok(ext.length>0);for(const c of ext)assert.match(c,/^(show|is-active|list-units) /,c); assert.deepEqual(journalCalls(e),[]); const s=await run(x,'status','acme');assert.equal(s.code,0,s.err); assert.ok(s.out.includes(`awaiting units: ${a} (copy ${C1})\n`),s.out);assert.ok(s.out.includes(`interrupted run: ${a} `),s.out); }); test('row 232: A3 order: capture-failed and marker-failed refuse with exit 3 and leave no marker or FAILED',async(t)=>{ const {e,x}=await xseed(t); fault(e,'no-fragment'); const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err); assert.match(r.err,/^stack-release: refusing capture-failed: could not write the rollback set .*; nothing was changed\n$/); const a=lastAct(e); assert.deepEqual(mine(e,a).map((o)=>[o.result,o.code]),[['started',undefined],['refused','capture-failed']]); for(const f of ['AWAITING-UNITS','FAILED'])assert.ok(!existsSync(join(rs(e,a),f)),f); assert.deepEqual(open(e),[]); rmSync(join(e.sd,'faults','no-fragment')); // The set can't be made: marker-failed, for that activation. chmodSync(join(e.state,'stack-rollback'),0o555); const m=await run(x,'activate',C1,'acme');assert.equal(m.code,3,m.out+m.err); assert.match(m.err,/^stack-release: refusing marker-failed: could not make the rollback set .*; nothing was changed\n$/); const b=lastAct(e);assert.notEqual(b,a); assert.deepEqual(mine(e,b).map((o)=>[o.result,o.code]),[['started',undefined],['refused','marker-failed']]); chmodSync(join(e.state,'stack-rollback'),0o755); assert.deepEqual(open(e),[]); }); test('row 232: confirm and revert refuse open-activation with no marker, two, the wrong copy, or one the log holds closed; refusals name no activation',async(t)=>{ const {e,x}=await xseed(t); const tryBoth=async(id,why)=>{ for(const v of ['confirm','revert']){ const o=open(e),logs=logLines(e).length; const r=await run(x,v,id,'acme');assert.equal(r.code,3,`${v} ${why}: ${r.out}${r.err}`); assert.match(r.err,/^stack-release: refusing open-activation: /,why); assert.deepEqual(logLines(e).slice(logs).map((l)=>[l.result,l.code,l.activation,l.op]),[['refused','open-activation',null,v]],why); assert.deepEqual(open(e),o,why); } }; await tryBoth(C1,'no marker'); // One marker whose activation the log never started. It sorts before any real one, so a confirm // that took the newest of two markers would take the real one. const fake=join(e.state,'stack-rollback','20000101T000000Z-'+C1.slice(0,12)); mkdirSync(fake,{recursive:true});writeFileSync(join(fake,'AWAITING-UNITS'),`${C1}\n`); await tryBoth(C1,'marker not open'); rmSync(fake,{recursive:true}); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); await tryBoth(B,'wrong copy'); mkdirSync(fake);writeFileSync(join(fake,'AWAITING-UNITS'),`${C1}\n`); await tryBoth(C1,'two markers'); rmSync(fake,{recursive:true}); // gate refuses nothing and writes nothing, open or not. const logs=readFileSync(e.log,'utf8'); const g=await run(x,'gate',C1,'acme','--since','1','--timeout','1');assert.equal(g.code,1); assert.equal(readFileSync(e.log,'utf8'),logs);assert.deepEqual(open(e),[a]); }); test('row 232: confirm polls: bus status good after 3 s fails with --timeout 1, writes FAILED and leaves the rest; FAILED then refuses activation-failed',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); up(e,C1); const p0=readFileSync(e.pointer,'utf8'),l0=readFileSync(e.log,'utf8'); fault(e,`status-until-${C1}`,String(Date.now()+3000)); const c=await run(x,'confirm',C1,'acme','--timeout','1');assert.equal(c.code,1,c.out+c.err); assert.match(c.err,new RegExp(`^stack-release: activate failed with bus-status; ${a} stays open`)); assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'bus-status\n'); assert.equal(readFileSync(e.pointer,'utf8'),p0);assert.equal(readFileSync(e.log,'utf8'),l0); assert.ok(existsSync(join(rs(e,a),'AWAITING-UNITS'))); const f=await run(x,'confirm',C1,'acme');assert.equal(f.code,3); assert.match(f.err,new RegExp(`^stack-release: refusing activation-failed: ${a} has a FAILED file`)); assert.equal(logLines(e).at(-1).activation,null);assert.deepEqual(open(e),[a]); }); test('row 232: confirm polls to a pass with --timeout 10: the pointer, an ok line that reads as the started run, no marker',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); up(e,C1); fault(e,`status-until-${C1}`,String(Date.now()+3000)); const c=await run(x,'confirm',C1,'acme','--timeout','10');assert.equal(c.code,0,c.out+c.err); assert.equal(c.out.split('\n').at(-2),`stack-release: activate ok: ${C1} is live for acme`); const p=pointer(e);assert.deepEqual([p.copy,p.activation,p.schema,p.pm,p.console],[C1,a,'3g',false,['--port',e.port,'--business','acme']]); const f=sameAsStarted(e,a);assert.deepEqual([f.result,f.op,f.from,f.to,f.backup],['ok','activate',B,C1,a]); assert.ok(!existsSync(join(rs(e,a),'AWAITING-UNITS'))); assert.deepEqual(journalCalls(e),[]); }); // N4 with the brief's numbers: the started line at 1792540800.900 floors to 1792540800. The host's // clock runs so boot status is after that second. const T0=1792540800,offsetTo=(s)=>Math.floor(Date.now()/1000)-s; function startedAt(e,a,ms){ const l=readFileSync(e.log,'utf8').split('\n').map((x)=>{if(!x)return x;const o=JSON.parse(x);if(o.activation===a&&o.result==='started')o.at=new Date(ms).toISOString();return JSON.stringify(o)}); writeFileSync(e.log,l.join('\n')); } test('row 232: stale-start: @1792540799 fails a start floor of 1792540800 with stale-start',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); startedAt(e,a,T0*1000+900);up(e,C1,{offset:offsetTo(T0+100)}); fault(e,'enter',JSON.stringify({value:T0-1})); const c=await run(x,'confirm',C1,'acme','--timeout','1');assert.equal(c.code,1,c.out+c.err); assert.ok(c.out.includes(`gate failed on ${C1}: stale-start (the bus started at @${T0-1}, before @${T0})`),c.out); assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'stale-start\n'); }); test('row 232: stale-start is a gate code: an enter time that turns to @1792540800 after 2 s passes with --timeout 10',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); startedAt(e,a,T0*1000+900);up(e,C1,{offset:offsetTo(T0+100)}); fault(e,'enter',JSON.stringify({value:T0-1,at:Date.now()+2000,then:T0})); const c=await run(x,'confirm',C1,'acme','--timeout','10');assert.equal(c.code,0,c.out+c.err); assert.equal(pointer(e).copy,C1); }); test('row 232: gate (N2, A2f): --since 1792540800 passes @1792540800 and fails @1792540799; it writes nothing',async(t)=>{ const {e,x}=await xseed(t); up(e,B,{offset:offsetTo(T0+100)}); const snap=()=>[readFileSync(e.log,'utf8'),readFileSync(e.pointer,'utf8'),tree(e.state).join('\n')]; const s0=snap(); fault(e,'enter',JSON.stringify({value:T0})); const g=await run(x,'gate',B,'acme','--since',String(T0));assert.equal(g.code,0,g.out+g.err); assert.equal(g.out,`stack-release: gate passed on ${B}\n`); fault(e,'enter',JSON.stringify({value:T0-1})); const f=await run(x,'gate',B,'acme','--since',String(T0),'--timeout','1');assert.equal(f.code,1,f.out+f.err); assert.ok(f.out.includes(`gate failed on ${B}: stale-start (the bus started at @${T0-1}, before @${T0})`),f.out); assert.deepEqual(snap(),s0); const u=await run(x,'gate',B,'acme');assert.equal(u.code,4); assert.deepEqual(journalCalls(e),[]); }); test('row 232: the external PM check reads launches list: one live run of launch.by since the start passes',async(t)=>{ const {e,x}=await xseed(t,{pm:true}); const cases=[['',0,''],['coder',0,''],['none',1,'no live launch of chief'],['twolive',1,'2 live launches of chief'],['early',1,'started before this start'],['dead',1,'no live launch of chief'],['other',1,'the launch of chief is for other']]; for(const [mode,code,why] of cases){ rmSync(join(e.sd,'launches.json'),{force:true}); if(mode)fault(e,`pm-${B}`,mode);else rmSync(join(e.sd,'faults',`pm-${B}`),{force:true}); const since=Math.floor(Date.now()/1000); up(e,B,{pm:true}); const g=await run(x,'gate',B,'acme','--since',String(since),'--timeout','1');assert.equal(g.code,code,`${mode}: ${g.out}${g.err}`); if(why)assert.ok(g.out.includes(`gate failed on ${B}: pm-launch-unconfirmed (`)&&g.out.includes(why),`${mode}: ${g.out}`); down(e); } assert.deepEqual(journalCalls(e),[]); }); test('row 232: revert puts the wrapper back and leaves the store when nothing migrated',async(t)=>{ const {e,x}=await xseed(t); const w0=sha(join(e.bin,'mosaic')); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); // A write to the stopped store after A3: revert has no MIGRATING, so it is not its to undo. const db=new DatabaseSync(e.store);db.prepare('INSERT INTO events (at) VALUES (?)').run('2001-01-01T00:00:00.000Z');db.close(); const s1=storeSha(e);assert.notEqual(s1,sha(backupStore(e,a))); const v=await run(x,'revert',C1,'acme');assert.equal(v.code,0,v.out+v.err); assert.equal(v.out.split('\n').at(-2),`stack-release: revert ok: ${a}'s wrapper is back; root installs the old units next`); assert.equal(sha(join(e.bin,'mosaic')),w0);assert.equal(storeSha(e),s1); assert.deepEqual(mine(e,a).map((o)=>o.result),['started']);assert.ok(existsSync(join(rs(e,a),'AWAITING-UNITS'))); }); test('row 232: confirm --rollback refuses rollback-mismatch, and logs rollback-failed with exit 2 when OLD fails its gate',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); const v=await run(x,'revert',C1,'acme');assert.equal(v.code,0,v.out+v.err); const m=await run(x,'confirm',C1,'acme','--rollback',C,'--cause','A5');assert.equal(m.code,3); assert.equal(m.err,`stack-release: refusing rollback-mismatch: ${C} is not the copy ${a} started from, ${B}\n`); assert.equal(logLines(e).at(-1).activation,null);assert.deepEqual(open(e),[a]); fault(e,`healthz-${B}`);up(e,B); const f=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A5','--timeout','1');assert.equal(f.code,2,f.out+f.err); assert.equal(f.err,`stack-release: rollback-failed: the run failed with unrecorded and the rollback with healthz; root stops the units. Rollback set: ${rs(e,a)}, backup ${join(e.data,'backups','stack',a)}\n`); const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback,l.rollbackCode],['rollback-failed','unrecorded','failed','healthz']); for(const n of ['AWAITING-UNITS','FAILED'])assert.ok(!existsSync(join(rs(e,a),n)),n); assert.deepEqual(open(e),[]); }); test('row 232: confirm --rollback after a --restore swap logs rollbackCode not-attempted; rollback --restore finds an external backup',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C,'--migrate');assert.equal(r.code,0,r.out+r.err); up(e,C);assert.equal((await run(x,'confirm',C,'acme')).code,0); down(e); const b=await run(x,'rollback','acme','--to',B,'--restore',a);assert.equal(b.code,0,b.out+b.err); const ra=lastAct(e); assert.ok(b.out.includes(`restore: ${join(e.data,'backups','stack',a)}; `),b.out); assert.ok(existsSync(join(rs(e,ra),'RESTORED')));assert.equal(readFileSync(join(rs(e,ra),'BACKUP'),'utf8'),`${a}\n`); assert.equal(storeSha(e),sha(backupStore(e,a))); const f=await run(x,'confirm',B,'acme','--rollback',C,'--cause','A4');assert.equal(f.code,2,f.out+f.err); const l=sameAsStarted(e,ra);assert.deepEqual([l.op,l.result,l.code,l.rollbackCode,l.backup],['rollback','rollback-failed','units-start','not-attempted',a]); }); // --- B1: real sequences on one log --- test('row 232: sequence 1: activate, confirm times out, revert, confirm --rollback --cause A5: gate-failed rollback ok',async(t)=>{ const {e,x}=await xseed(t); const w0=sha(join(e.bin,'mosaic')),p0=readFileSync(e.pointer,'utf8'); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); fault(e,`healthz-${C1}`);up(e,C1); const c=await run(x,'confirm',C1,'acme','--timeout','1');assert.equal(c.code,1,c.out+c.err); assert.deepEqual(mine(e,a).map((o)=>o.result),['started']); down(e); assert.equal((await run(x,'revert',C1,'acme')).code,0);assert.equal(sha(join(e.bin,'mosaic')),w0); up(e,B); const k=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A5');assert.equal(k.code,1,k.out+k.err); assert.equal(k.err,`stack-release: healthz; rolled back to ${B}, which passed the gate\n`); const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['gate-failed','healthz','ok']); assert.equal(readFileSync(e.pointer,'utf8'),p0); await closed(e,x,a); }); test('row 232: sequence 2: activate --migrate, the wrapper install fails, revert restores TAKEN, confirm --rollback --cause A3',async(t)=>{ const {e,x}=await xseed(t); const s0=storeSha(e); chmodSync(e.bin,0o555); const {r,a}=await stage(e,x,C,'--migrate');chmodSync(e.bin,0o755); assert.equal(r.code,1,r.out+r.err);assert.match(r.err,new RegExp(`^stack-release: activate failed with wrapper; ${a} stays open`)); assert.deepEqual(mine(e,a).map((o)=>o.result),['started']); for(const n of ['AWAITING-UNITS','MIGRATING'])assert.ok(existsSync(join(rs(e,a),n)),n); assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'wrapper\n'); assert.equal(storeVersion(e.store,false),'3h');assert.equal(sha(backupStore(e,a)),s0); const v=await run(x,'revert',C,'acme');assert.equal(v.code,0,v.out+v.err); assert.equal(v.out.split('\n').at(-2),`stack-release: revert ok: ${a}'s wrapper and store are back; root installs the old units next`); assert.equal(storeSha(e),s0); // revert runs again: the store already matches, and nothing moves. const posts=readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-')); assert.equal((await run(x,'revert',C,'acme')).code,0); assert.deepEqual(readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-')),posts); up(e,B); const k=await run(x,'confirm',C,'acme','--rollback',B,'--cause','A3');assert.equal(k.code,1,k.out+k.err); const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['failed','wrapper','ok']); await closed(e,x,a); }); test('row 232: sequence 3: activate, root\'s A4 fails, revert, confirm --rollback --cause A4: failed code units-start',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); assert.equal((await run(x,'revert',C1,'acme')).code,0); up(e,B); const k=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A4');assert.equal(k.code,1,k.out+k.err); assert.equal(k.err,`stack-release: units-start; rolled back to ${B}, which passed the gate\n`); const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['failed','units-start','ok']); await closed(e,x,a); }); test('row 232: sequence 4: a capture failure leaves nothing open, then A2f\'s gate OLD writes nothing',async(t)=>{ const {e,x}=await xseed(t); fault(e,'no-fragment'); const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);rmSync(join(e.sd,'faults','no-fragment')); const a=lastAct(e);assert.deepEqual(mine(e,a).map((o)=>[o.result,o.code]),[['started',undefined],['refused','capture-failed']]); assert.deepEqual(open(e),[]); const since=Math.floor(Date.now()/1000);up(e,B); const s0=[readFileSync(e.log,'utf8'),readFileSync(e.pointer,'utf8'),tree(e.state).join('\n')]; const g=await run(x,'gate',B,'acme','--since',String(since));assert.equal(g.code,0,g.out+g.err); assert.deepEqual([readFileSync(e.log,'utf8'),readFileSync(e.pointer,'utf8'),tree(e.state).join('\n')],s0); }); test('row 232: sequence 5: a confirm of the wrong copy is refused, and revert and confirm --rollback still close the activation',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); up(e,C1); const w=await run(x,'confirm',B,'acme');assert.equal(w.code,3);assert.match(w.err,/^stack-release: refusing open-activation: /); assert.equal(logLines(e).at(-1).activation,null);assert.deepEqual(open(e),[a]); down(e); assert.equal((await run(x,'revert',C1,'acme')).code,0); up(e,B); const k=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A5');assert.equal(k.code,1,k.out+k.err); const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['gate-failed','unrecorded','ok']); await closed(e,x,a); }); test('row 232: sequence 6: a revert that fails leaves the activation open, and the next --plan refuses interrupted-run',async(t)=>{ const {e,x}=await xseed(t); const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err); rmSync(join(rs(e,a),'wrapper')); const v=await run(x,'revert',C1,'acme');assert.equal(v.code,1,v.out+v.err); assert.equal(v.err,`stack-release: revert failed: the rollback set has no saved wrapper; ${a} stays open. Rollback set: ${rs(e,a)}\n`); assert.deepEqual(open(e),[a]);assert.ok(existsSync(join(rs(e,a),'AWAITING-UNITS'))); const p=await run(x,'activate',C,'acme','--plan','--migrate');assert.equal(p.code,3,p.out+p.err); assert.ok(p.out.includes(`refuses interrupted-run: activation ${a} started and has no final line`),p.out); }); test('row 232: sequence 7: adopt, two confirmed activates, rollback --to the previous copy, confirmed, logs op rollback',async(t)=>{ const {e,x}=await xseed(t); const one=await stage(e,x,C1);assert.equal(one.r.code,0,one.r.out+one.r.err); up(e,C1);assert.equal((await run(x,'confirm',C1,'acme')).code,0);down(e); const two=await stage(e,x,C,'--migrate');assert.equal(two.r.code,0,two.r.out+two.r.err); up(e,C);assert.equal((await run(x,'confirm',C,'acme')).code,0);down(e); const l1=sameAsStarted(e,one.a),l2=sameAsStarted(e,two.a); assert.deepEqual([l1.op,l1.result,l1.from,l1.to,l1.backup],['activate','ok',B,C1,one.a]); assert.deepEqual([l2.op,l2.result,l2.from,l2.to,l2.backup],['activate','ok',C1,C,two.a]); // N5: --expect-from names the copy root read from its units. const d1=await run(x,'activate',C1,'acme','--plan','--expect-from',C1);assert.equal(d1.code,3,d1.out+d1.err); assert.ok(d1.out.includes(`refuses pointer-differs: the pointer names ${C} and root's units name ${C1}; Jason or Mos settles it`),d1.out); const d2=await run(x,'rollback','acme','--to',C1,'--plan','--expect-from',B);assert.equal(d2.code,3,d2.out+d2.err); assert.ok(d2.out.includes(`refuses pointer-differs: the pointer names ${C} and root's units name ${B}`),d2.out); const ok=await run(x,'rollback','acme','--to',C1,'--restore',two.a,'--plan','--expect-from',C);assert.equal(ok.code,0,ok.out+ok.err); const logs=logLines(e).length; const no=await run(x,'rollback','acme','--to',B,'--plan');assert.equal(no.code,3); assert.equal(no.err,`stack-release: refusing to-not-previous: --to ${B} is not the previous copy; the log names ${C1}\n`); assert.equal(logLines(e).length,logs); const nr=await run(x,'rollback','acme','--to',B,'--restore',one.a);assert.equal(nr.code,3);assert.match(nr.err,/^stack-release: refusing to-not-previous: /); assert.deepEqual(logLines(e).slice(logs).map((o)=>[o.result,o.code,o.activation]),[['refused','to-not-previous',null]]); const rb=await run(x,'rollback','acme','--to',C1,'--restore',two.a);assert.equal(rb.code,0,rb.out+rb.err); const ra=lastAct(e); up(e,C1); const k=await run(x,'confirm',C1,'acme');assert.equal(k.code,0,k.out+k.err); assert.equal(k.out.split('\n').at(-2),`stack-release: rollback ok: ${C1} is live for acme`); const l3=sameAsStarted(e,ra);assert.deepEqual([l3.op,l3.result,l3.from,l3.to,l3.backup],['rollback','ok',C,C1,two.a]); assert.deepEqual([pointer(e).copy,pointer(e).schema],[C1,'3g']); const prev=await run(x,'rollback','acme','--to',B,'--plan');assert.match(prev.err,new RegExp(`the log names ${C}\\n$`)); await closed(e,x,ra); }); test('row 232: sequence 8: a migration that writes and fails: revert restores the backup byte for byte, confirm --rollback --cause A3',async(t)=>{ const {e,x}=await xseed(t); const s0=storeSha(e); fault(e,'migrate-fails'); const {r,a}=await stage(e,x,C,'--migrate');assert.equal(r.code,1,r.out+r.err);rmSync(join(e.sd,'faults','migrate-fails')); assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'migrate-failed\n');assert.ok(existsSync(join(rs(e,a),'MIGRATING'))); assert.equal(storeVersion(e.store,false),'3h-partial'); assert.equal((await run(x,'revert',C,'acme')).code,0); assert.equal(storeSha(e),s0);assert.equal(storeSha(e),sha(backupStore(e,a))); assert.deepEqual(readdirSync(join(e.data,'bus')),readdirSync(join(e.data,'backups','stack',a,'bus'))); up(e,B); const k=await run(x,'confirm',C,'acme','--rollback',B,'--cause','A3');assert.equal(k.code,1,k.out+k.err); const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['failed','migrate-failed','ok']); await closed(e,x,a); }); // --- User mode against the base --- // The fixture repo with the base's two scripts: everything else is the same tree and history. const baseRepo=join(base,'base-repo'); cpSync(repo,baseRepo,{recursive:true}); for(const f of ['scripts/stack-release.sh','scripts/stack-copy.sh'])writeFileSync(join(baseRepo,f),execFileSync('git',['-C',ROOT,'show',`${BASE}:${f}`]),{mode:0o755}); const BASE_SR=join(baseRepo,'scripts','stack-release.sh'); test('row 232: user mode: every systemctl call carries --user, and activate, rollback and check make the base\'s calls',async(t)=>{ const calls=async(sr,dir)=>{ const e=await mk(t);e.sr=sr;await seed(e); const n=lines(join(e.sd,'argv.log')).length; const a=await run(e,'activate',C1,'acme');assert.equal(a.code,0,a.out+a.err); const r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err); execFileSync('bash',[join(dir,'scripts','stack-copy.sh'),'check','--copies',COPIES,'--bin',e.bin,'--business','acme'],{env:e.env,stdio:'ignore'}); return lines(join(e.sd,'argv.log')).slice(n).map((l)=>l.split(e.root).join('').split(e.port).join('')); }; const cand=await calls(undefined,repo),old=await calls(BASE_SR,baseRepo); assert.ok(cand.length>20); for(const c of cand)assert.match(c,/^--user /,c); assert.deepEqual(cand,old); }); test('row 232: user mode: a default activate --plan prints the base\'s bytes',async(t)=>{ const e=await mk(t);await seed(e); for(const a of [['activate',C1,'acme','--plan'],['activate',C,'acme','--plan','--migrate'],['activate',C,'acme','--plan'],['rollback','acme','--plan']]){ const cand=await run(e,...a),old=await run({...e,sr:BASE_SR},...a); assert.deepEqual(cand,old,a.join(' ')); } assert.ok(!existsSync(join(e.state,'stack-rollback'))); });