diff --git a/docs/TOOLS.md b/docs/TOOLS.md index c0062fdb9..f7e7c4ff1 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -808,9 +808,10 @@ the port and the backup's `SHA256SUMS`. Then it makes - `steps.log` holds the same PASS and FAIL lines the run prints, with times; `host.log`, `console.log` and, after a migration, `migrate.log` sit beside it. - When the live config has `console.sso` (M3, #1873), the scratch copy keeps - its provider ids, client ids, algorithms and subjects, and moves the public - origin and every issuer to `rehearsal.invalid`, a name that never resolves. - Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/` + its provider ids, client ids, algorithms and subjects and moves the public + origin to `rehearsal.invalid`, a name that never resolves. For a stamped + target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below; + otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/` holding `rehearsal-placeholder-secret`; the live secret is never read. A stamped target copy (one with `.stack-build.json`) serves only with `--sso`, @@ -819,10 +820,27 @@ so its rehearsal needs the live instance's human-made SSO steps. Without a live beside the config it refuses `accounts-not-initialized`. Both name the run-sheet step. Otherwise it copies the live `accounts.json` into `config/`, writes `data/.deployed` with the target copy's own `writeDeployment`, prints -`PASS deployment`, and starts the Console with `--sso`. The scratch Console -runs every startup check, finds no provider at `rehearsal.invalid`, and starts -degraded. The live `accounts.json` joins the end check `live-config-unchanged`. -The rehearsal adds no subject and no account. +`PASS deployment`, and starts the Console with `--sso`. + +Before that, a stamped run starts a rehearsal provider and prints +`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves +HTTPS with a certificate made for this run, under `provider/` (mode 0700). For +each live provider id it serves discovery and a JWKS whose public key it made +in memory for the first algorithm it can make from that provider's list. Its +authorize and token routes answer 403: it issues no code and no token, and +it reads no live secret. It logs each request to `provider/requests.log`, +deletes its TLS key file once it has read it, and exits on SIGTERM or when +the rehearsal's process is gone. `provider-start` fails when it exits early, +writes no origin within the timeout, writes an origin that is not +`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets +`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not. + +The scratch Console runs every startup check against that provider. The gate +adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate, +`provider-discovery` requires a 200 discovery and JWKS request per provider and +no authorize or token request. The end stops the provider with the Console and +host and prints `provider-stopped`. The live `accounts.json` joins the end check +`live-config-unchanged`. The rehearsal adds no subject and no account. A guard then loads the scratch config with the target's code and refuses with a named check: `data-root-live` when the scratch data root resolves to @@ -830,8 +848,10 @@ the live one, `sessions-live` when a `sessions.json` there names a running pid (the run never deletes the file), `data-root-scratch`, `notify-off`, `tracker-configured` when `bootConfig` still sets a tracker, `credential-ref-live` for an env reference or a file outside `placeholders/`, -and `sso-isolated` when a `console.sso` origin or issuer is not on -`rehearsal.invalid` or a slot is not its scratch placeholder. +and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`, +an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on +`rehearsal.invalid` for an unstamped target), or a slot is not its scratch +placeholder. When the backup's schema differs from the target's, the run checks that the target refuses the store, migrates it, opens it, migrates it again as a no-op, and that the pointer's copy refuses the migrated store. @@ -840,7 +860,8 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's `serve --port N --business BUSINESS` from the target copy, without `--send`. The gate needs the host's `launches disabled` line, a live `bus status` host with this run's pid and a boot from this start, the Console's address line, -`/healthz` 200, and both command lines on the target copy. Then it checks +`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines +on the target copy. Then it checks that the host wrote no `sessions.json` and no `launches`, stops the Console and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that the host released `writer.lock`, and compares the backup and the live config @@ -927,8 +948,9 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under ``` scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal] - [--rehearse-port N] [--timeout S] -scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S] + [--rehearse-port N] [--next-args] [--timeout S] +scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args] + [--timeout S] ``` Root runs it from a root-owned checkout of the reviewed commit, never from a @@ -968,7 +990,13 @@ user's clone `/src/mosaic-stack`: (`args-differ`), and `activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0 (`plan-refused`, which covers `open-activation`, `migrate-required` and - `pointer-differs`). + `pointer-differs`). Then the render checks (#1873 M3): + `case2-unit.sh --check` renders OLD from the args file and compares the + installed units with it, so a rollback could render them, then checks + SHA's render the same way. Either refusal is `render-refused`. A re-pin + that changes the serve flags fails the second check, because an + installed unit must equal the render but for the copy id (row 195); it + takes `--next-args`, below. 4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339` (`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`; @@ -983,6 +1011,22 @@ user's clone `/src/mosaic-stack`: still names the old one, and root moves the new file (the result line names it) into place by hand. +`--next-args` changes the serve flags, as the SSO migration does +(`docs/guides/host-run-sheet.md` section 7). The run reads +`/etc/mosaic-stack/dev.args.next` as it reads the args file +(`args-unreadable`). It must equal the args file before `--` but for +`--copy`'s value (`next-args-differ`) and differ from it after `--` +(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a +first install's checks from the next args, without the installed units'. +After A3, root moves the installed unit files into a new 0700 directory +`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders +as a first install. A failure from there moves them back unchanged +(`units-back`), runs `daemon-reload` and starts the old units with no render. +After A5 the old args file is copied into the replaced directory, the next +args file is removed (step `next-args`), and the new args file, made from +the next args, moves into place. The result line names the replaced +directory. Nothing removes it. + A failure in A2 or a stage that exits 3 starts the old units again and gates them (A2f). A later failure stops both units, runs `revert`, renders the old copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the diff --git a/docs/guides/host-run-sheet.md b/docs/guides/host-run-sheet.md index 505ab9047..ca45d1487 100644 --- a/docs/guides/host-run-sheet.md +++ b/docs/guides/host-run-sheet.md @@ -446,8 +446,36 @@ stack picks or invents a subject: the subjects come from `console.sso`, which a 4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member of its primary group, and owner of its config directory and data root. An accounts instance never runs under a human or development seat uid, and never under the `webui-service.sh` user units. -5. Re-pin. Put `--sso` after `--` in the args file (`factory`: `--sso --send`). For `library`, also remove - the proxy-preview trio and the proof pair. Then run `case2-unit.sh` or `stack-upgrade.sh upgrade`. +5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to + `/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the + serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the + proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair + (`--proof-header`, `--proof-file`). Then run + `stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`. + + A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for + the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against + `dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when + the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after + it. After the stage it moves the installed unit files into a new 0700 directory + `/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from + there moves them back unchanged and starts the old copy again. After `confirm` passes, + `dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old + `dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no + rollback needs it. + + To roll back past the migration, put the replaced args back as the next args and roll back with + `--next-args`: + + ```sh + install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next + stack-upgrade.sh rollback --instance dev --to --next-args + ``` + + For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for + an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview + admission included, as before the migration. A `factory` rollback may instead keep `--sso --send` + with a plain `rollback`. If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps, numbered in this order, for example: @@ -461,11 +489,11 @@ case2-unit: refusing the units for : a stamped copy's Console starts only w ``` Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO -has not configured yet. The next render after step 2 checks it. Rollback keeps `--sso`, accounts.json and -`.deployed` in place (`docs/plans/2026-11-10_accounts-design.md`, section 7). +has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json, +its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7). -Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these -rules, before it stops a unit. +Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`, +`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit. ## 8. Enrollment, once per host diff --git a/docs/plans/2026-11-10_accounts-design.md b/docs/plans/2026-11-10_accounts-design.md index 029d76866..1c5c89e9d 100644 --- a/docs/plans/2026-11-10_accounts-design.md +++ b/docs/plans/2026-11-10_accounts-design.md @@ -1298,7 +1298,8 @@ business file. ### Rollback -Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off. +Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off, +except the library rollback past the migration below. - The supported rollback is a pinned code and config rollback: install the earlier stack copy with the same unit flags, including `--sso`, through @@ -1306,6 +1307,13 @@ Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off. dedicated system units. The accounts instance must not use the `webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in place. BASE code ignores them, and the next roll-forward needs them. +- A library host rolled back to a copy older than M3 is the exception. + `case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so + that rollback renders the old copy from the args file the migration + replaced, through `stack-upgrade.sh rollback ... --next-args` + (`docs/guides/host-run-sheet.md` section 7). The old copy comes back with + its old flags, proxy-preview admission included, as before the migration. + accounts.json and `.deployed` stay in place. - Before the first accounts org exists, that rollback restores today's behavior completely. `console.sso` owners sign in as before, and FED R6 users keep their root grants. Account-only users and pending invites stop diff --git a/packages/cli/tests/case2-unit.test.mjs b/packages/cli/tests/case2-unit.test.mjs index 28e502a27..cc0a9a1ea 100644 --- a/packages/cli/tests/case2-unit.test.mjs +++ b/packages/cli/tests/case2-unit.test.mjs @@ -364,7 +364,7 @@ test("the unit takes --org or --business on either side of --, renders --busines test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => { const h = hub(t); const serve = ["--port", "443", "--business", "acme"]; - const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n"; + const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n"; const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"]; for (const [label, argv, code, err] of [ ["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"], @@ -886,6 +886,65 @@ test("M3 a stamped copy's query refuses on noise or a failed query, before any w } }); +// #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's +// checks, the installed units' included, and writes nothing; --check-new skips only the installed +// units' checks, for --next-args, which moves them aside before its render. +const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) => + [mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--", + ...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))]; +const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null)); +const logNow = (h) => (existsSync(h.log) ? read(h.log) : ""); + +test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => { + const h = hub(t); + const empty = unit(h, { argv: checkArgv(h) }); + assert.equal(empty.code, 0, empty.out + empty.err); + assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`); + noUnits(h); + assert.equal(unit(h).code, 0); + const units = unitsNow(h), log = logNow(h); + for (const copy of [COPY_A, COPY_B]) { + const r = unit(h, { argv: checkArgv(h, { copy }) }); + assert.equal(r.code, 0, r.out + r.err); + assert.deepEqual(unitsNow(h), units, copy); + assert.equal(logNow(h), log, `${copy}: no systemctl call`); + } + // The installed-unit match stays: an edit, or other serve flags, refuses as the render would. + writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited")); + refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/); + writeFileSync(busFile(h), units[0]); + const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f)); + refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/); + assert.deepEqual(unitsNow(h), units); + assert.equal(logNow(h), log); +}); + +for (const profile of ["library", "factory"]) { + test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => { + const h = hub(t); + assert.equal(unit(h, { profile }).code, 0); + stamp(h, COPY_B); + stampSso(h); + stampAccounts(h); + stampDeployed(h); + const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY; + const units = unitsNow(h), log = logNow(h); + refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/); + refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/); + const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }); + assert.equal(r.code, 0, r.out + r.err); + assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`); + // --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse. + refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/); + rmSync(accountsFile(h)); + refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/); + assert.deepEqual(unitsNow(h), units); + assert.equal(logNow(h), log, "no systemctl call"); + assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json"); + assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444); + }); +} + test("row253 factory refuses retired TCP and a value after the socket marker", (t) => { const h = hub(t); refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/); diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index cdc44f89c..ed4bf2fb7 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -1578,15 +1578,37 @@ function rootUp(e,copy,flags){ .replace(/^ExecStart=.*$/m,`ExecStart=${node} ${dir}/packages/webui/src/cli.mjs serve --checkout ${repo} ${flags.join(' ')}`)); execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con); } -// One system-path run: OP (activate TO, or rollback --to TO) staged, root's units for TO with FLAGS, confirm. +// One system-path run: OP (activate TO, or rollback --to TO) planned and staged from the pointer's +// copy, root's units for TO with FLAGS, confirm. The plan, the staged set and both log lines are this +// script's output; the unit flags are root's render (case2-unit.test.mjs pins that render), so the +// pointer pinning them is confirm's read of root's unit, not a conversion of this script's. async function viaSystem(e,op,to,flags,...more){ - const x=X(e);rootDown(e); - let r=await prun(x,...(op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to]),...more);assert.equal(r.code,0,r.out+r.err); - const act=logLines(e).at(-1).activation; + const x=X(e),from=pointer(e).copy,p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,e.wrapper)); + const args=op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to],inst=e.flags.length?`${e.flags.join(' ')} `:''; + rootDown(e); + const con0=sha(join(e.units,e.con)); + const out=await planOnly(x,0,...args,...more),plan=out.join('\n'); + assert.deepEqual([out.find((l)=>l.startsWith('plan: ')),out.find((l)=>l.startsWith('from: ')),out.find((l)=>l.startsWith('to: '))],[`plan: ${op} for acme`,`from: ${from}`,`to: ${to}`],plan); + assert.ok(out.includes(`units: external; root installs the units for ${to} and starts them, then confirm gates them`),plan); + assert.ok(out.includes(`CLI: scripts/stack-copy.sh wrapper ${to} ${inst}--copies ${COPIES} --bin ${e.bin}`),plan); + // Root renders the units, so the plan names no unit, Console flag or refusal of this script's. + assert.deepEqual(out.filter((l)=>/^(bus|pm|proof|send|sso|Console):|^(drop|refuses) /.test(l)),[],plan); + let r=await prun(x,...args,...more);assert.equal(r.code,0,r.out+r.err); + const st=logLines(e).at(-1),act=st.activation,rs=setOf(e,act); + assert.deepEqual([st.op,st.from,st.to,st.result,st.units],[op,from,to,'started','external']); + assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} staged ${to} for acme as ${act}; root installs and starts the units, then runs confirm ${to} acme --units external`); + // Staged, not live: the pointer stays; the set holds the pointer, the wrapper and the Console unit root stopped. + assert.equal(readFileSync(e.pointer,'utf8'),p0); + assert.equal(readFileSync(join(rs,'AWAITING-UNITS'),'utf8'),`${to}\n`); + assert.equal(readFileSync(join(rs,'pointer'),'utf8'),p0);assert.equal(sha(join(rs,'wrapper')),w0); + assert.ok(readFileSync(join(rs,'UNITS.sha256'),'utf8').includes(`${con0} ${join(e.units,e.con)}\n`)); + assert.ok(readFileSync(join(e.bin,e.wrapper),'utf8').includes(join(COPIES,to)),'the wrapper names the target copy'); rootUp(e,to,flags); r=await prun(x,'confirm',to,'acme');assert.equal(r.code,0,r.out+r.err); assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} ok: ${to} is live for acme`); - const l=logLines(e).at(-1);assert.deepEqual([l.op,l.to,l.result,l.units,l.activation],[op,to,'ok','external',act]); + const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.units,l.activation],[op,from,to,'ok','external',act]); + assert.ok(!existsSync(join(rs,'AWAITING-UNITS'))); + assert.deepEqual([pointer(e).copy,pointer(e).console],[to,flags]); return act; } @@ -2102,6 +2124,7 @@ function rHost(){ } 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`); + fs.appendFileSync(path.join(home,'host-ca.log'),`${process.env.NODE_EXTRA_CA_CERTS??'none'}\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'}); @@ -2122,14 +2145,35 @@ function rHost(){ } // The Console: serve on the given loopback port; /healthz answers 200 unless healthz- is set. -function rConsole(){ +// M3 (#1873): under --sso it runs its copy's real createSso on the scratch console.sso, with the +// scratch slot placeholders, as the real Console does, and /healthz carries sso.health(). Faults: +// console-hold waits for $HOME/release before it starts; console-no-sso reports every provider +// ready without asking any; console-asks-token asks provider a's token endpoint once. +async 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)); + const c=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')); 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'); + fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:c.dataRoot,ca:process.env.NODE_EXTRA_CA_CERTS??null})+'\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()}); + while(fault('console-hold')&&!fs.existsSync(path.join(home,'release')))await new Promise((r)=>setTimeout(r,50)); + let health=null; + if(a.includes('--sso')){ + const ps=c.console.sso.providers; + if(fault('console-no-sso'))health=()=>Object.fromEntries(ps.map((p)=>[p.id,'ready'])); + else{ + const {createSso}=await import(path.join(import.meta.dirname,'sso.mjs')); + const secrets=new Map(ps.map((p)=>[p.id,fs.readFileSync(path.join(path.dirname(process.env.MOSAIC_CONFIG),'secrets','mosaic-stack',p.clientSecret.slot),'utf8')])); + let sso;try{sso=await createSso(c.console.sso,{secrets,log:()=>{}})}catch(err){console.error(`fixture Console: ${err.message}`);process.exit(2)} + health=()=>sso.health(); + } + if(fault('console-asks-token'))await fetch(`${ps[0].issuer}token`).catch(()=>{}); + } + const s=http.createServer((q,r)=>{ + const ok=q.url==='/healthz'&&!fault(`healthz-${id}`);r.writeHead(ok?200:500,{'content-type':'application/json'}); + r.end(ok&&health?JSON.stringify({ok:true,sso:health()}):''); + }); 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)); } @@ -2173,15 +2217,23 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']], execFileSync('chmod',['-R','a-w',d]); } // M3 (#1873): SCOPIES holds B as a stamped copy: RCOPIES's B with a read-only .stack-build.json -// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, and -// the deployment.mjs whose writeDeployment the rehearsal runs. +// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, its +// sso.mjs the stand-in Console runs, and the deployment.mjs whose writeDeployment the rehearsal runs. const SCOPIES=join(base,'scopies');mkdirSync(SCOPIES); { const d=join(SCOPIES,B);cpSync(join(RCOPIES,B),d,{recursive:true});execFileSync('chmod',['-R','u+w',d]); - for(const p of ['packages/webui/src/sso-config.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p)); + for(const p of ['packages/webui/src/sso-config.mjs','packages/webui/src/sso.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p)); + // sso.mjs imports oauth4webapi. + symlinkSync(join(ROOT,'node_modules'),join(d,'node_modules')); writeFileSync(join(d,'.stack-build.json'),JSON.stringify({commit:B}),{mode:0o444}); execFileSync('chmod',['-R','a-w',d]); } +// UCOPIES holds B unstamped but able to load a console key: SCOPIES's B without .stack-build.json. +const UCOPIES=join(base,'ucopies');mkdirSync(UCOPIES); +{ + const d=join(UCOPIES,B);cpSync(join(SCOPIES,B),d,{recursive:true,verbatimSymlinks:true});execFileSync('chmod',['-R','u+w',d]); + rmSync(join(d,'.stack-build.json'));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 @@ -2194,12 +2246,13 @@ const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync // 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. +// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG or a rehearsal path in their command +// line (the rehearsal provider), 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{} + try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`)||readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(`${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{} }); const db=new DatabaseSync(e.store); db.exec('PRAGMA journal_mode=WAL'); @@ -2479,9 +2532,10 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t // --- M3 (#1873): rehearsing a stamped copy (the brief's item 1) --- // A stamped copy serves only with --sso, console.sso, accounts.json and a .deployed naming its -// instance. The rehearsal gives its scratch Console all four: the live console.sso with origin and -// issuers on rehearsal.invalid, a placeholder per slot, a copy of the live accounts.json, and a -// .deployed from the copy's writeDeployment. The live slot has a read watch: it is never opened. +// instance. The rehearsal gives its scratch Console all four: the live console.sso with its origin +// on rehearsal.invalid and each issuer at the rehearsal's own provider, a placeholder per slot, a +// copy of the live accounts.json, and a .deployed from the copy's writeDeployment. The live slot +// has a read watch: it is never opened. const ACCOUNTS={accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'subject-1'}],name:'Jason',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}; async function smk(t,{sso=true,accounts=true}={}){ const e=await rmk(t);e.copies=SCOPIES; @@ -2490,7 +2544,10 @@ async function smk(t,{sso=true,accounts=true}={}){ if(accounts)writeFileSync(e.accounts,JSON.stringify(ACCOUNTS),{mode:0o600}); return e; } -const SPRE=[...PRE.slice(0,4),'PASS deployment',...PRE.slice(4)]; +const SPRE=[...PRE.slice(0,3),'PASS provider-start',PRE[3],'PASS deployment',...PRE.slice(4)]; +const SPOST=['PASS console-stopped','PASS host-stopped','PASS provider-stopped',...POST.slice(2)]; +// The rehearsal provider's origin and pid, from its provider-start line. +const provOf=(out)=>{const m=/^PASS provider-start: pid (\d+) at (https:\/\/127\.0\.0\.1:\d+): /m.exec(out);return m?{pid:Number(m[1]),origin:m[2]}:null}; // A real stamped copy of this checkout, as deployment-boundary.test.mjs builds one. function stampedCopy(t){ const sha40='a'.repeat(40),root=mkdtempSync(join(tmpdir(),'rs-'));t.after(()=>rmSync(root,{recursive:true,force:true})); @@ -2503,8 +2560,8 @@ function stampedCopy(t){ } // Starts the copy's real Console on port 0 against the rehearsal's scratch config. Resolves with // {code,out,err} on exit, or with the bound port once it prints its address line. -async function realServe(t,copy,e,dir){ - const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json')},stdio:['ignore','pipe','pipe']}); +async function realServe(t,copy,e,dir,more={}){ + const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json'),...more},stdio:['ignore','pipe','pipe']}); t.after(async()=>{if(ch.exitCode===null&&ch.signalCode===null){const done=once(ch,'close');ch.kill('SIGKILL');await done}}); let out='',err='';ch.stderr.on('data',(d)=>{err+=d}); return new Promise((res)=>{ @@ -2514,21 +2571,32 @@ async function realServe(t,copy,e,dir){ }); } -test('M3 R16 a stamped copy rehearses with --sso against rehearsal.invalid, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{ +test('M3 R16 a stamped copy rehearses with --sso against its own provider, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{ const st=(f)=>{const x=statSync(f);return [x.ino,x.size,x.mtimeMs,x.mode]}; const e=await smk(t),before=untouched(e),accounts=sha(e.accounts),slot=st(slotPath(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,[...SPRE,'PASS migration','PASS gate','PASS no-session',...POST]); - const R=r.dir; + assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','PASS provider-discovery',...SPOST]); + const R=r.dir,prov=provOf(r.out);assert.ok(prov,r.out); + assert.ok(r.out.includes(`PASS provider-start: pid ${prov.pid} at ${prov.origin}: discovery and a JWKS from keys made for this run, no code and no token; its certificate ${join(R,'provider','cert.pem')} is the trust the scratch Console alone gets\n`),r.out); assert.ok(r.out.includes(`PASS deployment: ${B} is stamped: ${join(R,'data','.deployed')} from its writeDeployment, the live accounts.json copied to ${join(R,'config')}, the Console starts with --sso\n`),r.out); - assert.ok(r.out.includes('PASS sso-isolated: 1 provider(s), each at rehearsal.invalid with a placeholder secret\n'),r.out); - // The Console started with --sso, and only on the scratch config. + assert.ok(r.out.includes(`PASS sso-isolated: 1 provider(s), each at the rehearsal provider ${prov.origin} with a placeholder secret\n`),r.out); + assert.ok(r.out.includes(`PASS gate: host-up, bus-status, console-up, healthz, sso-ready and copy-match on ${B}\n`),r.out); + assert.ok(r.out.includes(`PASS provider-discovery: the Console fetched each provider's discovery and JWKS from ${prov.origin} and asked for no code or token\n`),r.out); + // The Console started with --sso, only on the scratch config, and alone trusts the provider's + // certificate; the host gets no added trust. assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme --sso`]); + assert.deepEqual(lines(rh(e,'console-context.jsonl')).map((l)=>JSON.parse(l).ca),[join(R,'provider','cert.pem')]); + assert.deepEqual(lines(rh(e,'host-ca.log')),['none']); + // The provider served discovery and the JWKS, got no other request, kept no TLS key and is gone. + assert.deepEqual(lines(join(R,'provider','requests.log')).sort(),['GET /a/.well-known/openid-configuration 200','GET /a/jwks 200']); + assert.deepEqual(readdirSync(join(R,'provider')).sort(),['cert.pem','origin','provider.log','requests.log']); + assert.equal(statSync(join(R,'provider')).mode&0o777,0o700); + assert.equal(existsSync(`/proc/${prov.pid}`),false,`provider pid ${prov.pid} is still there`); // The scratch config: the live one with dataRoot, origin and issuer moved; provider id, client id, // algorithms, slot name and subjects as they were. No subject is added. const live=JSON.parse(readFileSync(e.cfg,'utf8')),sc=JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')); - const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer='https://rehearsal.invalid/a'; + const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer=`${prov.origin}/a/`; assert.deepEqual(sc,want);assert.deepEqual(sc.console.sso.providers[0].subjects,SSO_KEY.sso.providers[0].subjects); // The slot is a scratch placeholder, 0600 in a 0700 directory. const ph=join(R,'config','secrets','mosaic-stack',SLOT); @@ -2545,16 +2613,30 @@ test('M3 R16 a stamped copy rehearses with --sso against rehearsal.invalid, a sc await noLeak(e); }); -test('M3 R17 the real stamped Console accepts the rehearsal\'s scratch prerequisites and starts degraded; without .deployed or accounts.json it refuses',async(t)=>{ - const e=await smk(t); - const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10'); - assert.equal(r.code,0,r.out+r.err); +test('M3 R17 the real stamped Console finds the rehearsal provider ready with the scratch prerequisites, starts degraded once it is gone, and refuses without .deployed or accounts.json',async(t)=>{ + const e=await smk(t);rfault(e,'console-hold'); const copy=stampedCopy(t); - // Every startup check passes: stamp, --sso, .deployed, accounts, console.sso and its slot. The - // provider at rehearsal.invalid is unreachable, so the Console serves degraded and /healthz answers. + // While the stand-in Console holds, the rehearsal provider is up: the real Console of a stamped + // copy passes every startup check (stamp, --sso, .deployed, accounts, console.sso and its slot) + // and, trusting only the provider's certificate, finds provider a ready. + let during=null; + e.during=async()=>{ + try{ + for(let i=0;i<400&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50)); + const dir=join(e.data,'rehearsals',rDirs(e)[0]); + const up=await realServe(t,copy,e,dir,{NODE_EXTRA_CA_CERTS:join(dir,'provider','cert.pem')}); + during={up:up.port?await (await fetch(`http://127.0.0.1:${up.port}/healthz`)).json():null,why:`${up.code} ${up.out} ${up.err}`}; + if(up.ch){const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done} + }finally{writeFileSync(rh(e,'release'),'')} + }; + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30'); + assert.equal(r.code,0,r.out+r.err); + assert.deepEqual(during?.up,{ok:true,sso:{a:'ready'}},during?.why); + // After the rehearsal its provider is gone: the same Console serves degraded and /healthz says so. const up=await realServe(t,copy,e,r.dir); assert.ok(up.port,`${up.code} ${up.out} ${up.err}`); - assert.equal((await fetch(`http://127.0.0.1:${up.port}/healthz`)).status,200); + const h=await fetch(`http://127.0.0.1:${up.port}/healthz`); + assert.equal(h.status,200);assert.deepEqual(await h.json(),{ok:true,sso:{a:'unreachable'}}); const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done; // The same start without each prerequisite refuses before it listens. const dep=join(r.dir,'data','.deployed'),acc=join(r.dir,'config','accounts.json'),keep=readFileSync(dep); @@ -2589,19 +2671,86 @@ test('M3 R18 a stamped copy refuses sso-not-prepared without a live console.sso }); test('M3 R19 sso-isolated fails before the host starts when the scratch origin, an issuer or a slot is not the rehearsal\'s',async(t)=>{ + // stamped: the issuer must be the rehearsal provider's; unstamped (no provider): on rehearsal.invalid. + for(const [stamped,from,to,why] of [ + [true,'sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'], + [true,'p.issuer=`${op||','p.issuer=p.issuer||`${op||','console.sso provider a issuer is https://op.example.test/, not ORIGIN/a/ at the rehearsal provider'], + [false,'p.issuer=`${op||"https://rehearsal.invalid"}','p.issuer=`${op||"https://op.example.test"}','console.sso provider a issuer is https://op.example.test/a, not on rehearsal.invalid'], + [true,'"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'], + ]){ + let e;if(stamped)e=await smk(t);else{e=await rmk(t);e.copies=UCOPIES;await ssoConfig(e)} + ownScript(e,from,to); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3'); + assert.equal(r.code,1,why+r.out+r.err); + const prov=provOf(r.out);assert.equal(!!prov,stamped,r.out); + assert.ok(r.out.includes(`FAIL sso-isolated: ${why.replace('ORIGIN',prov?.origin)}\n`),r.out); + assert.deepEqual(r.names.slice(stamped?-4:-3),['FAIL sso-isolated',...(stamped?['PASS provider-stopped']:[]),'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); + if(prov)assert.equal(existsSync(`/proc/${prov.pid}`),false); + await noLeak(e); + } +}); + +test('M3 R20 the rehearsal provider\'s failure controls: no start, no trust, no discovery, a token request, a signal and a SIGKILL each fail and stop it',async(t)=>{ + const ends=['PASS backup-unchanged','PASS live-config-unchanged']; + // The provider cannot start, leaves its TLS key, offers a non-loopback origin or never writes one: + // provider-start fails before the scratch build, and no host or Console starts. for(const [from,to,why] of [ - ['sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'], - ['p.issuer=`https://rehearsal.invalid/${encodeURIComponent(String(p.id))}`','0','console.sso provider a issuer is https://op.example.test/, not on rehearsal.invalid'], - ['"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'], + ['spawnSync("openssl"','spawnSync("openssl-missing-for-r20"','the rehearsal provider exited: rehearsal provider: cannot make the certificate: openssl exit ENOENT'], + ['finally{fs.rmSync(tls,{force:true})}','finally{}','the rehearsal provider left its TLS key at RDIR/provider/tls-key.pem'], + ['origin=`https://127.0.0.1:${','origin=`https://localhost:${','the rehearsal provider\'s origin https://localhost:PORT is not https on 127.0.0.1'], + ['fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));','','the rehearsal provider wrote no origin within 3s'], ]){ const e=await smk(t);ownScript(e,from,to); const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3'); - assert.equal(r.code,1,why+r.out+r.err); - assert.ok(r.out.includes(`FAIL sso-isolated: ${why}\n`),r.out); - assert.deepEqual(r.names.slice(-3),['FAIL sso-isolated','PASS backup-unchanged','PASS live-config-unchanged']); + assert.equal(r.code,1,why+r.out+r.err);assert.ok(r.dir,r.out+r.err); + const line=r.out.split('\n').find((l)=>l.startsWith('FAIL provider-start: '))??''; + const re=new RegExp(`^FAIL provider-start: ${why.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace('RDIR',r.dir.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).replace('PORT','\\d+')}$`); + assert.match(line,re,r.out); + assert.deepEqual(r.names,[...PRE.slice(0,3),'FAIL provider-start',...(why.includes('exited')?[]:['PASS provider-stopped']),...ends],r.out); + assert.equal(existsSync(join(r.dir,'config')),false); assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false); - await noLeak(e); } + // The Console cannot reach the provider (its discovery drops the connection, or the Console has + // no trust for its certificate): it starts degraded and the gate fails sso-ready. + for(const [from,to] of [ + [' send(200,{issuer,authorization_endpoint',' return q.socket.destroy();send(200,{issuer,authorization_endpoint'], + ['r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")','r_ca=()'], + ]){ + const e=await smk(t);ownScript(e,from,to); + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','5'); + assert.equal(r.code,1,from+r.out+r.err); + assert.ok(r.out.includes('FAIL sso-ready: provider a is unreachable, not ready\n'),r.out); + assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL sso-ready',...SPOST],r.out); + assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false); + } + // A Console that reports ready without asking the provider, or asks it for a token: the gate + // passes and provider-discovery fails. + for(const [f,why] of [ + ['console-no-sso','no GET /a/.well-known/openid-configuration 200; no GET /a/jwks 200'], + ['console-asks-token','a request for GET /a/token 403'], + ]){ + const e=await smk(t);rfault(e,f); + 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.ok(r.out.includes(`FAIL provider-discovery: ${why}; see ${join(r.dir,'provider','requests.log')}\n`),r.out); + assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','FAIL provider-discovery',...SPOST],r.out); + } + // A signal while the gate waits stops the Console, host and provider. + const e=await smk(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('SIGTERM')}; + const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30'); + assert.equal(r.code,1,r.out+r.err); + assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL signal',...SPOST],r.out); + assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false); + // SIGKILL leaves no trap to run: the provider sees its parent gone and exits by itself. + const k=await smk(t);rfault(k,`healthz-${B}`); + k.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(k,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGKILL')}; + const rk=await rehearse(k,B,'--backup',k.act,'--port',await freePort(),'--timeout','30'); + assert.equal(rk.code,null,rk.out+rk.err); + const pk=provOf(rk.out);assert.ok(pk,rk.out); + for(let i=0;i<60&&existsSync(`/proc/${pk.pid}`);i++)await new Promise((r)=>setTimeout(r,100)); + assert.equal(existsSync(`/proc/${pk.pid}`),false,`provider pid ${pk.pid} outlived the killed rehearsal`); }); // --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) --- diff --git a/packages/cli/tests/stack-upgrade.test.mjs b/packages/cli/tests/stack-upgrade.test.mjs index 941f1b66a..2df0580fc 100644 --- a/packages/cli/tests/stack-upgrade.test.mjs +++ b/packages/cli/tests/stack-upgrade.test.mjs @@ -47,12 +47,19 @@ 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. 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 +// 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}`))){ @@ -147,7 +154,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text); // The step log: every line `