diff --git a/packages/cli/tests/stack-upgrade.test.mjs b/packages/cli/tests/stack-upgrade.test.mjs index aca3e3f51..34a6f171f 100644 --- a/packages/cli/tests/stack-upgrade.test.mjs +++ b/packages/cli/tests/stack-upgrade.test.mjs @@ -9,7 +9,7 @@ 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} from 'node:fs'; +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'; @@ -47,7 +47,7 @@ function fakeSudo(K){ // 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. faults/render-fails- exits 1 first; faults/render-probe- (one-shot) first runs +// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. 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'); @@ -64,7 +64,7 @@ function fakeRender(K){ 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('--business')}.service`,'mosaic-webui.service']; + 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'); @@ -592,6 +592,108 @@ test('#385 R2-F1 controls: library args without --send and a factory args file p assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]); }); +// Rename r2, "Selector scopes and parser matrix": --org is the same option as --business before -- (the +// unit's flags) and after it (the serve flags). selArgs moves the business into each scope as given: the +// unit's selectors first, the serve flags' last, the other lines as useed wrote them. +const selArgs=(e,outer,serve)=>{ + const l=e.argsText.trimEnd().split('\n'),cut=l.indexOf('--'),drop=(a)=>{a.splice(a.indexOf('--business'),2);return a}; + return [...outer,...drop(l.slice(0,cut)),'--',...drop(l.slice(cut+1)),...serve].join('\n')+'\n'; +}; +test('rename r2: --org or --business names the business before and after --, read_args passes either spelling or both, matched, in either order',async(t)=>{ + const e=await useed(t); + // A bundle that does not exist stops the run at its first step after read_args, with nothing stopped. + const gone=join(e.root,'no.bundle'); + for(const [outer,serve] of [ + [['--org','acme'],['--org','acme']], + [['--business','acme'],['--org','acme']], + [['--org','acme'],['--business','acme']], + [['--business','acme','--org','acme'],['--org','acme','--business','acme']], + [['--org','acme','--business','acme'],['--business','acme','--org','acme']], + ]){ + const why=`${outer.join(' ')} -- ${serve.join(' ')}`,text=selArgs(e,outer,serve); + writeFileSync(e.args,text);chmodSync(e.args,0o640); + const n=existsSync(e.slog)?slog(e).length:0; + const r=await drive(e,C1,'upgrade','--instance','dev','--bundle',gone,'--sha',C1,...TO);assert.equal(r.code,3,`${why}: ${r.all}`); + assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','fetch','result'],why); + assert.match(slog(e).at(-1),/^result exit=3 fetch-failed /,why); + assert.equal(readFileSync(e.args,'utf8'),text,why); + } + assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]); +}); + +test('rename r2: an upgrade from an args file that spells both selectors --org keeps every line but --copy\'s value, and the render gets them as written',async(t)=>{ + const e=await useed(t); + e.argsText=selArgs(e,['--org','acme'],['--org','acme']);writeFileSync(e.args,e.argsText); + arm(e,'confirm.sh',`cp ${e.args} ${join(e.sd,'args-at-confirm')}\n`); + const r=await upgrade(e,C1);assert.equal(r.code,0,r.all); + assert.equal(r.out.split('\n').at(-2),`stack-upgrade: upgrade ok: ${C1} is live for acme`); + assert.deepEqual(names(e),[...PRE,...A2,'stage','render','confirm','args','result']); + assert.equal(readFileSync(join(e.sd,'args-at-confirm'),'utf8'),e.argsText); + const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText); + assert.equal(readFileSync(e.args,'utf8'),after); + assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']); + assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]); + // The stack's user's steps get the business as a word, never a spelling. + for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k); + assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org'))); + assert.deepEqual(live(e),['active','active',C1,C1]); +}); + +test('rename r2: a bad selector in either scope, or scopes that name different businesses, refuse args-unreadable before anything stops',async(t)=>{ + const e=await useed(t),f=e.args; + const l=e.argsText.trimEnd().split('\n'),pre=l.slice(0,l.indexOf('--')).filter((x,i,a)=>x!=='--business'&&a[i-1]!=='--business'); + const before=(m)=>`${f} before --: ${m}`,after=(m)=>`${f} after --: ${m}`; + const cases=[ + ['unit: two spellings, two businesses',selArgs(e,['--business','acme','--org','beta'],['--business','acme']),before('--business acme and --org beta conflict')], + ['unit: two businesses, the other order',selArgs(e,['--org','acme','--business','beta'],['--business','acme']),before('--org acme and --business beta conflict')], + ['unit: --org twice',selArgs(e,['--org','acme','--org','acme'],['--business','acme']),before('--org given twice')], + ['unit: --business twice',selArgs(e,['--business','acme','--business','acme'],['--business','acme']),before('--business given twice')], + ['unit: --org at the end',[...pre,'--org'].join('\n')+'\n',before('--org needs one value')], + ['unit: --org with an empty line',selArgs(e,['--org',''],['--business','acme']),before('--org needs one value')], + ['unit: --org before a flag',selArgs(e,['--org'],['--business','acme']),before('--org needs one value, not --user')], + ['unit: --business before --',[...pre,'--business','--','--port',e.port,'--business','acme'].join('\n')+'\n',before('--business needs one value, not --')], + ['unit: an invalid business',selArgs(e,['--org','Acme'],['--org','Acme']),before('--org Acme is not valid')], + ['unit: --org as --home\'s value is no selector',selArgs(e,['--business','acme'],['--business','acme']).replace(`--home\n${e.env.HOME}\n`,'--home\n--org\n'),`${f} lacks a plain --user, --home, --business, --node or --copy`], + ['serve: two spellings, two businesses',selArgs(e,['--business','acme'],['--business','acme','--org','beta']),after('--business acme and --org beta conflict')], + ['serve: two businesses, the other order',selArgs(e,['--business','acme'],['--org','beta','--business','acme']),after('--org beta and --business acme conflict')], + ['serve: --org twice',selArgs(e,['--business','acme'],['--org','acme','--org','acme']),after('--org given twice')], + ['serve: --business twice',selArgs(e,['--org','acme'],['--business','acme','--business','acme']),after('--business given twice')], + ['serve: --org at the end',selArgs(e,['--business','acme'],['--org']),after('--org needs one value')], + ['serve: --org before a flag',selArgs(e,['--business','acme'],['--org','--sso']),after('--org needs one value, not --sso')], + ['serve: an invalid business',selArgs(e,['--business','acme'],['--org','acme_x']),after('--org acme_x is not valid')], + ['serve: another business',selArgs(e,['--business','acme'],['--org','beta']),`${f} names business acme before -- and beta after it`], + ['serve: another business, unit spelled --org',selArgs(e,['--org','acme'],['--business','beta']),`${f} names business acme before -- and beta after it`], + ['serve: no selector',selArgs(e,['--business','acme'],[]),`${f} needs --business or --org after --, as case2-unit.sh does`], + ['serve: --business as --allow-user\'s value is no selector',selArgs(e,['--business','acme'],['--allow-user','--business']),`${f} needs --business or --org after --, as case2-unit.sh does`], + ['serve: no -- at all',[...pre,'--org','acme'].join('\n')+'\n',`${f} needs --business or --org after --, as case2-unit.sh does`], + ['serve: a flag case2-unit.sh does not take',selArgs(e,['--business','acme'],['--business','acme','--tester']),`${f} has --tester after --, which case2-unit.sh does not take`], + ]; + for(const [why,text,msg] of cases){ + writeFileSync(e.args,text);chmodSync(e.args,0o640); + const n=existsSync(e.slog)?slog(e).length:0; + const r=await upgrade(e,C1);assert.equal(r.code,3,`${why}: ${r.all}`); + assert.equal(r.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${msg} (nothing was stopped)`,why); + assert.deepEqual(slog(e).slice(n).map((x)=>x.split(' ')[0]),['start','result'],why); + assert.equal(readFileSync(e.args,'utf8'),text,why);assert.deepEqual(readdirSync(e.etc),['dev.args'],why); + } + // A rollback reads the same file the same way. + writeFileSync(e.args,cases[0][1]); + const rb=await drive(e,B,'rollback','--instance','dev','--to',B,...TO);assert.equal(rb.code,3,rb.all); + assert.equal(rb.err.split('\n').at(-2),`stack-upgrade: args-unreadable: ${cases[0][2]} (nothing was stopped)`); + assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'render.log'))); + assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]); +}); + +test('rename r2: a checkout without scripts/selector.sh refuses checkout-unreadable before reading the args file',async(t)=>{ + const e=await useed(t),d=join(realpathSync(e.root),'lone'); + mkdirSync(join(d,'scripts'),{recursive:true});copyFileSync(join(checkout(C1),'scripts','stack-upgrade.sh'),join(d,'scripts','stack-upgrade.sh')); + const r=spawnSync('bash',[join(d,'scripts','stack-upgrade.sh'),'upgrade','--instance','dev','--bundle',BUNDLE,'--sha',C1,...TO],{env:e.denv,encoding:'utf8'}); + assert.equal(r.status,3,r.stdout+r.stderr); + assert.equal(r.stderr.split('\n').at(-2),`stack-upgrade: checkout-unreadable: cannot read ${d}/scripts/selector.sh (nothing was stopped)`); + assert.deepEqual(slog(e).map((x)=>x.split(' ')[0]),['start','result']); + assert.ok(!existsSync(join(e.sd,'sudo.log')));assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));argsKept(e); +}); + // R3-F1 (#385, PR 1836): a library host rendered by base case2-unit.sh has --send in its installed Console // unit's ExecStart, not only in its args file. With the args line removed, A2 would stop both units and A4 // and render-old would both refuse the installed unit (check_unit's ExecStart and the full-text match), so diff --git a/scripts/stack-upgrade.sh b/scripts/stack-upgrade.sh index 731abc093..0f92b3a71 100755 --- a/scripts/stack-upgrade.sh +++ b/scripts/stack-upgrade.sh @@ -12,10 +12,13 @@ # Root runs it from a root-owned checkout of the reviewed commit, never from a copy, because the # stack's user can write the copies. The unit values come from /etc/mosaic-stack/.args, # which I2's install writes: one case2-unit.sh argument per line, the serve flags after `--`. Only -# --copy changes, and the file changes only after confirm passes. The stack's user runs its steps -# from its own clone, /src/mosaic-stack, through sudo. Root reads none of that user's output -# but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its step -# log after a match against a fixed pattern and decides nothing on. +# --copy changes, and the file changes only after confirm passes. --org is the same option as +# --business in each of the file's two scopes, case2-unit.sh's own flags before `--` and the serve +# flags after it: each scope takes each spelling once, a second spelling only with the same value, +# and both scopes must name the business, the same one (rename design r2). The stack's user runs +# its steps from its own clone, /src/mosaic-stack, through sudo. Root reads none of that user's +# output but exit codes and the rehearsal's BACKUP_STORE line (sage-1653), which it copies into its +# step log after a match against a fixed pattern and decides nothing on. # # --no-rehearsal is for a stack whose log records no backup. The rehearsal runs either way: exit 3 # from it (no-backup) goes on only with the flag, and exit 0 with the flag refuses @@ -119,6 +122,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")" # --- The args file and the stack's user --- +# The selector scopes: selector.sh defines functions only, from this root-owned checkout, as RENDER is. +. "$REPO/scripts/selector.sh" || refuse checkout-unreadable "cannot read $REPO/scripts/selector.sh" +args_business_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]*$ ]]; } +args_outer_refused() { refuse args-unreadable "$ETC/$INSTANCE.args before --: $2"; } +args_serve_refused() { refuse args-unreadable "$ETC/$INSTANCE.args after --: $2"; } + # I2's file: one case2-unit.sh argument per line, owned by root with no group or other write. Sets # ARGS (the lines), S_USER, S_HOME, BUSINESS, NODE, ARGS_COPY, ARGS_PROFILE and ARGS_PROXY, and the paths derived # from them. @@ -126,8 +135,12 @@ logl "start rc=0 argv:$(printf ' %q' "$0" "${ARGV[@]}")" # --profile wins, as there), and the current one refuses it. A2 would stop both units, and A4 and # render-old would both fail on the same args, so a library file holding --send after -- refuses # here, before anything stops. The file is never rewritten; the operator removes the line. +# Rename r2: case2-unit.sh also refuses a file whose serve flags name no selector or another +# business, so that refuses here too. The serve flags are read with case2-unit.sh's grammar, which +# says which of them take a value, so a value is never taken for a selector; a token that grammar +# lacks refuses, as the render would. read_args() { - local f="$ETC/$INSTANCE.args" st i copies=0 v profile=library + local f="$ETC/$INSTANCE.args" st i sep copies=0 v profile=library served [ -f "$f" ] && [ ! -L "$f" ] || refuse args-unreadable "no regular file $f" st="$(stat -c '%u %a' "$f")" || refuse args-unreadable "cannot stat $f" [ "${st% *}" = "$(id -u)" ] && (( (8#${st#* } & 8#022) == 0 )) || refuse args-unreadable "$f must be $(id -un)'s with no group or other write (it is uid ${st% *}, mode ${st#* })" @@ -138,10 +151,13 @@ read_args() { --) break ;; --pm) ;; --proxy) ARGS_PROXY=1 ;; - --user|--home|--business|--copy|--node|--profile|--claude) + --business|--org) + selector_take outer id args_outer_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}" + BUSINESS="${ARGS[i + 1]}"; i=$((i + 1)) ;; + --user|--home|--copy|--node|--profile|--claude) v="${ARGS[i + 1]:-}" case "${ARGS[i]}" in - --user) S_USER="$v" ;; --home) S_HOME="$v" ;; --business) BUSINESS="$v" ;; --node) NODE="$v" ;; + --user) S_USER="$v" ;; --home) S_HOME="$v" ;; --node) NODE="$v" ;; --copy) ARGS_COPY="$v"; copies=$((copies + 1)) ;; --profile) profile="$v" ;; esac @@ -149,6 +165,7 @@ read_args() { *) refuse args-unreadable "$f has ${ARGS[i]} before --, which case2-unit.sh does not take" ;; esac done + sep=$i [ "$copies" = 1 ] || refuse args-unreadable "$f needs exactly one --copy before --, not $copies" ARGS_PROFILE="$profile" if [ "$profile" = library ]; then @@ -160,6 +177,18 @@ read_args() { [[ "$S_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] && [[ "$S_HOME" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] && [[ "$NODE" =~ ^/[A-Za-z0-9/._+-]+$ ]] && [[ "$ARGS_COPY" =~ ^[0-9a-f]{40}$ ]] || refuse args-unreadable "$f lacks a plain --user, --home, --business, --node or --copy" + for ((i = sep + 1; i < ${#ARGS[@]}; i++)); do + case "${ARGS[i]}" in + --business|--org) + selector_take serve id args_serve_refused args_business_ok "${ARGS[i]}" "${ARGS[i + 1]:-}"; i=$((i + 1)) ;; + --port|--public-host|--auth-header|--allow-user|--proof-header|--proof-file) i=$((i + 1)) ;; + --tester-socket|--sso|--send) ;; + *) refuse args-unreadable "$f has ${ARGS[i]} after --, which case2-unit.sh does not take" ;; + esac + done + served="$(selector_value serve)" + [ -n "$served" ] || refuse args-unreadable "$f needs --business or --org after --, as case2-unit.sh does" + [ "$served" = "$BUSINESS" ] || refuse args-unreadable "$f names business $BUSINESS before -- and $served after it" COPIES_DIR="$S_HOME/.local/share/mosaic-stack/copies" CLONE="$S_HOME/src/mosaic-stack" SR="$CLONE/scripts/stack-release.sh"