// scripts/stack-upgrade.sh (row 233, I3, #1735): root's upgrade and rollback of a stack whose units are // root's, driving stack-release.sh --units external (row 232). Design: r6 sections 4 and 7, // agents/darkwing/work/queue-195/DESIGN.md, as the I3 brief changes them; deviations: // agents/darkwing/work/queue-233/BUILD.md. The stand-ins and the module fixture are // stack-release-fixture.mjs's. Root's sudo, case2-unit.sh render and row 240 hostcheck are stand-ins // here; the stack's user's steps are the real stack-release.sh from the user's clone, except the // rehearsal, which the fixture's stub copies can't pass (its own tests are in stack-release.test.mjs). // The #385 R3-F1 cases run the real base and current case2-unit.sh instead of the render stand-in. import test from 'node:test'; import assert from 'node:assert/strict'; import {execFileSync,spawn,spawnSync} from 'node:child_process'; import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,copyFileSync,symlinkSync,statSync,realpathSync} from 'node:fs'; import {DatabaseSync} from 'node:sqlite'; import {join,dirname} from 'node:path'; import {once} from 'node:events'; import {B,C,C1,U,COPIES,NODE,ROOT,SBIN,base,fault,git,lines,logLines,mk,pointer,repo,sha,storeVersion,sysctl,units} from './stack-release-fixture.mjs'; // --- Root's stand-ins. Each is serialized into its own file with K, its baked paths. --- // sudo -u USER -H env PATH=P CMD...: the form the driver must use, logged to sudo.log, then CMD with // SBIN before P. A one-shot hook arm/.sh runs first and arm/.id replaces CMD's ID. The // rehearsal prints a fixed BACKUP_STORE line and exits faults/rehearse-rc unless faults/real-rehearse. function fakeSudo(K){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'); const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n); if(a[0]!=='-u'||!a[1]||a[2]!=='-H'||a[3]!=='env'||!/^PATH=/.test(a[4]||'')||a.length<6){console.error(`fake sudo: not the driver's form: ${a.join(' ')}`);process.exit(99)} const P=a[4].slice(5),cmd=a.slice(5); let key=path.basename(cmd[0]); if(cmd[0].endsWith('/scripts/stack-release.sh')){ key=cmd[1]; if(['activate','rollback'].includes(key)&&cmd.includes('--plan'))key+='-plan'; if(key==='confirm'&&cmd.includes('--rollback'))key='confirm-rollback'; }else if(cmd[0]==='git')key=`git-${cmd[3]}`; fs.appendFileSync(path.join(sd,'sudo.log'),JSON.stringify({user:a[1],path:P,key,cmd})+'\n'); const env={...process.env,PATH:`${K.sbin}:${P}`},arm=(n)=>path.join(sd,'arm',`${key}.${n}`); if(fs.existsSync(arm('sh'))){const run=arm('sh')+'.ran';fs.renameSync(arm('sh'),run);cp.spawnSync('bash',[run],{env,stdio:'inherit'})} if(fs.existsSync(arm('id'))){cmd[2]=fs.readFileSync(arm('id'),'utf8').trim();fs.rmSync(arm('id'))} if(key==='rehearse'&&!fs.existsSync(f('real-rehearse'))){ console.log(`fixture rehearse: ${cmd.slice(1).join(' ')}`); let line='BACKUP_STORE 20261020T000000Z-0123456789ab schema 3g';try{line=fs.readFileSync(f('rehearse-store-line'),'utf8').replace(/\n$/,'')}catch{} if(line)console.log(line); let rc=0;try{rc=Number(fs.readFileSync(f('rehearse-rc'),'utf8'))}catch{} process.exit(rc); } const r=cp.spawnSync(cmd[0],cmd.slice(1),{env,stdio:'inherit'});process.exit(r.status??1); } // case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its // WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console, // as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. A first // --check or --check-new (#1873) logs its argv and exits 1 under faults/check-fails-, else 0, // writing nothing. faults/render-fails- exits 1 first; faults/render-probe- (one-shot) first runs // probe.sh, a second driver, and records its exit and stderr. function fakeRender(K){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'); const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n); fs.appendFileSync(path.join(sd,'render.log'),JSON.stringify(a)+'\n'); if(a[0]==='--check'||a[0]==='--check-new'){ const c=a[a.indexOf('--copy')+1]; if(fs.existsSync(f(`check-fails-${c}`))){console.error(`fixture render: ${a[0]} ${c} fails`);process.exit(1)} process.exit(0); } const cut=a.indexOf('--'),pre=a.slice(0,cut),serve=a.slice(cut+1),get=(k)=>pre[pre.indexOf(k)+1]; const copy=get('--copy'),copies=path.join(get('--home'),'.local','share','mosaic-stack','copies'); if(fs.existsSync(f(`render-probe-${copy}`))){ fs.rmSync(f(`render-probe-${copy}`)); const p=cp.spawnSync('bash',[path.join(sd,'probe.sh')],{encoding:'utf8'}); fs.writeFileSync(path.join(sd,'probe.json'),JSON.stringify({code:p.status,err:p.stderr})); } if(fs.existsSync(f(`render-fails-${copy}`))){console.error(`fixture render: ${copy} fails`);process.exit(1)} const sh=(n,...x)=>cp.execFileSync('bash',[path.join(get('--home'),'src','mosaic-stack','scripts',n),...x],{stdio:['ignore','ignore','inherit']}); sh('bus-service.sh','install','--copy',copy,'--copies',copies,...(pre.includes('--pm')?['--pm']:[]),'--claude',get('--claude')); sh('webui-service.sh','install','--copy',copy,'--copies',copies,...serve); const names=[`mosaic-bus@${get(pre.includes('--business')?'--business':'--org')}.service`,'mosaic-webui.service']; if(pre.includes('--proxy')){ fs.writeFileSync(path.join(process.env.XDG_CONFIG_HOME,'systemd','user','mosaic-proxy.service'),`[Service]\nWorkingDirectory=${copies}/${copy}\nExecStart=${get('--node')} ${copies}/${copy}/packages/model-proxy/src/run.mjs\n`); names.unshift('mosaic-proxy.service'); } for(const u of names)cp.execFileSync(path.join(K.sbin,'systemctl'),['start',u],{stdio:'inherit'}); } // Root's systemctl: the fixture's, with systemd's no-op start of an active unit, and a one-shot // faults/stop-noop- that leaves the unit running, as a stop past its timeout would. function fakeSystemctl(K){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'); const a=process.argv.slice(2),sd=process.env.SD_STATE; fs.appendFileSync(path.join(sd,'root-systemctl.log'),a.join(' ')+'\n'); const noop=path.join(sd,'faults',`stop-noop-${a[1]}`); if(a[0]==='stop'&&fs.existsSync(noop)){fs.rmSync(noop);process.exit(0)} if(a[0]==='start'&&fs.existsSync(path.join(sd,'faults',`root-start-fails-${a[1]}`)))process.exit(1); if(a[0]==='start'){let s={};try{s=JSON.parse(fs.readFileSync(path.join(sd,'units.json'),'utf8'))}catch{}if((s[a[1]]||{}).state==='active')process.exit(0)} const r=cp.spawnSync(path.join(K.sbin,'systemctl'),a,{stdio:'inherit'});process.exit(r.status??1); } const FAKES=join(base,'upgrade-fakes');mkdirSync(FAKES); const K={sbin:SBIN,repo}; for(const [n,fn] of [['sudo',fakeSudo],['render',fakeRender],['systemctl',fakeSystemctl]])writeFileSync(join(FAKES,n),`#!${NODE}\n(${fn})(${JSON.stringify(K)});\n`,{mode:0o755}); writeFileSync(join(FAKES,'hostcheck'),'#!/bin/sh\necho "$*" >> "$SD_STATE/hostcheck.log"\nexit "$(cat "$SD_STATE/faults/hostcheck-rc" 2>/dev/null || echo 0)"\n',{mode:0o755}); const BUNDLE=join(base,'fixture.bundle');git(repo,'bundle','create','-q',BUNDLE,'refactor'); // Root's checkout of a reviewed commit: a clone of the fixture repo, detached at it. const checkouts=new Map(); const checkout=(id)=>{ if(!checkouts.has(id)){const d=join(base,`root-${id.slice(0,12)}`);git(base,'clone','-q','--no-checkout',repo,d);git(d,'checkout','-q','--detach',id);checkouts.set(id,d)} return checkouts.get(id); }; const TO=['--timeout','3']; // --- Per-test stack --- // The stack's user's home: its copies (the fixture's, through a link), its clone, its bin. Both units // installed on `copy` and active, adopted under --units external, and the I2 args file naming it. async function useed(t,{copy=B}={}){ const e=await mk(t),home=e.env.HOME; e.hc=join(home,'.local','share','mosaic-stack','copies');mkdirSync(dirname(e.hc),{recursive:true});symlinkSync(COPIES,e.hc); const bin=join(home,'.npm-global','bin');mkdirSync(bin,{recursive:true}); writeFileSync(join(bin,'mosaic'),readFileSync(join(e.bin,'mosaic'),'utf8').replaceAll(`${e.bin}/`,`${bin}/`),{mode:0o755}); copyFileSync(join(e.bin,'mosaic-old'),join(bin,'mosaic-old'));chmodSync(join(bin,'mosaic-old'),0o755); e.bin=bin;e.clone=join(home,'src','mosaic-stack');git(base,'clone','-q',repo,e.clone); 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', '3g')").run(); for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`); db.close(); const sh=(n,...a)=>execFileSync('bash',[join(e.clone,'scripts',n),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']}); sh('bus-service.sh','install','--copy',copy,'--copies',e.hc,'--claude',e.claude); sh('webui-service.sh','install','--copy',copy,'--copies',e.hc,'--port',e.port,'--business','acme'); sh('stack-copy.sh','wrapper',copy,'--copies',e.hc,'--bin',e.bin); execFileSync(join(SBIN,'systemctl'),['start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}}); sysctl(e,'start',e.con); sh('stack-release.sh','adopt','acme','--units','external','--copies',e.hc,'--bin',e.bin); e.etc=join(e.root,'etc');mkdirSync(e.etc);e.args=join(e.etc,'dev.args'); e.argsText=`--user\nfixture\n--home\n${home}\n--business\nacme\n--copy\n${copy}\n--node\n${NODE}\n--claude\n${e.claude}\n--\n--port\n${e.port}\n--business\nacme\n`; writeFileSync(e.args,e.argsText);chmodSync(e.args,0o640); e.run=join(e.root,'run');e.slog=join(e.root,'log','upgrade-dev.log');e.lockf=join(e.run,'upgrade-dev.lock'); mkdirSync(join(e.sd,'arm')); e.denv={...e.env,STACK_UPGRADE_ETC:e.etc,STACK_UPGRADE_RUN:e.run,STACK_UPGRADE_LOG_DIR:dirname(e.slog),STACK_UPGRADE_UNIT_DIR:e.units,STACK_UPGRADE_HOSTCHECK:join(FAKES,'hostcheck'),STACK_UPGRADE_RENDER:join(FAKES,'render'),STACK_UPGRADE_SUDO:join(FAKES,'sudo'),STACK_UPGRADE_SYSTEMCTL:join(FAKES,'systemctl')}; writeFileSync(join(e.sd,'probe.sh'),`exec bash ${join(checkout(C1),'scripts','stack-upgrade.sh')} upgrade --instance dev --bundle ${BUNDLE} --sha ${C1} --timeout 3\n`); writeFileSync(join(e.sd,'snap.jsonl'),''); return e; } // The driver from root's checkout of `at`. async function drive(e,at,...args){ const ch=spawn('bash',[join(checkout(at),'scripts','stack-upgrade.sh'),...args],{env:e.denv,stdio:['ignore','pipe','pipe']}); let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d}); const timer=setTimeout(()=>ch.kill('SIGKILL'),180000); const [code]=await once(ch,'close');clearTimeout(timer); return {code,out,err,all:out+err}; } const upgrade=(e,id,...a)=>drive(e,id,'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',id,...TO,...a); const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text); // The step log: every line `