Compare commits

...
Author SHA1 Message Date
jason.woltje 9df40878fc stack-release: round 3 on row 190's landing, --sso for a named instance tested, T53/T55 usage checks carry --sso (row 200, #1698, sage-1556, sage-1569) 2026-10-20 18:18:22 -05:00
jason.woltje d395b6c95b launchpad: row 200 packet, N2 guard kept and mutant 12 ruling (sage-1446) 2026-10-20 17:56:18 -05:00
jason.woltje 46d5db0cbf stack-release: round 2, slot and proof checks resolve REPO, config_checks refuses a config that doesn't parse (row 200, #1698, sage-1432) 2026-10-20 17:56:18 -05:00
jason.woltje 7a2cd9e66b launchpad: row 200 packet, webby-0028 notes N1-N4 (#1698) 2026-10-20 17:56:18 -05:00
jason.woltje 87ffc55745 launchpad: row 200 packet, BUILD.md, mutants, base and manifest (#1698) 2026-10-20 17:56:18 -05:00
jason.woltje 30e4bb1a34 stack-release: activate --sso, its refusals before any stop, and the rollback to a Console without it (row 200, #1698)
activate --sso drops --public-host, --auth-header, --allow-user and any proof pair
and puts --sso before --send and --tester-port; --plan prints an sso: line. Before
any stop: sso-target, sso-config (the target's own checkConsole; from another copy
with no key it names the re-pin without --sso as the first step) and sso-slot
(lstat/stat/realpath only). A rollback from --sso to a Console without it takes the
flags from the arrival activation's rollback set (sage-1389) or refuses
sso-rollback-flags. Activate and rollback refuse config-console-key when the target's
loader refuses the config's console key. T56, TOOLS.md.
2026-10-20 17:56:18 -05:00
7 changed files with 752 additions and 26 deletions
+202
View File
@@ -0,0 +1,202 @@
# Row 200: `stack-release.sh activate --sso` (Q175 phase 2 prerequisite)
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1698. Brief
`docs/plans/2026-10-20_stack-release-sso.md` (blob 426336b177f6). Rulings sage-1389 (R1 uses the activation onto
the current copy, plus a same-copy test and a latest-set mutant), sage-1403 and sage-1407 (N4), mos-1056 item 5
(N4 is a window acceptance item), sage-1432 (round 2 for webby-0028 N1 and N2), sage-1556 (round 3: rebase onto row 190's
landing 00d4cc1f6, and `--sso` for a named instance).
Base 00d4cc1f6 since round 3 (`base.txt`), row 190's landing, which has row 192 (4429a104f) below it. Rounds 1
and 2 were built on 4d487dfdc: aa1bd209e plus row 192's two commits picked. 48359dbfa is 83b32d8d8 (patch-id
8d11f48a9e03) and 4d487dfdc is r2 37582e820 (patch-id 0e32af8aa2a4 on both). The webui tree at the base matches
`launchpad/row192` at 37582e820 path for path. Rounds 1 and 2 are on `launchpad/row200`;
round 3 is on `launchpad/row200-r3` (see Round 3 below).
`candidate-manifest.sha256` lists its 3 paths; the packet files are not in it. Drift check at commit time:
round 3, origin/refactor is 00d4cc1f6 itself, so `git log 00d4cc1f6..origin/refactor` is empty.
No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. T57 runs on
the scratch roots and seeded pointers rows 187 and 189 use. Its config and slot sit in the test's temp dir,
and the slot carries the `proofFile` read watch, so a run that opened it would fail the test.
## Files
| Path | Change |
| --- | --- |
| `scripts/stack-release.sh` | `--sso` on activate (usage, parse, `sso_for_target`), `sso_checks` and `slot_problem` (item 2), `sso_base` (rollback flags, item 3), `config_checks` (item 3 and N4), the `sso:` plan line and step lines. |
| `packages/cli/tests/stack-release.test.mjs` | Fixture copies gain row 192's `--sso` marker, an old loader without `console`, and a C copy whose `ALGS` lack RS256. T57 (T56 before round 3), 11 subtests (2 in round 2, 1 in round 3). |
| `docs/TOOLS.md` | Usage line and two paragraphs after row 189's `--no-send`. |
## What it does
1. **Item 1.** `activate ID BUSINESS --sso` drops `--public-host`, `--auth-header`, `--allow-user` and any proof
pair, and puts `--sso` after the other flags, before `--send` and `--tester-port`, as `webui-service.sh`
renders it (`sso_for_target`, `:775`). It may name the live copy once. After that, both a plain activate and
an `--sso` activate refuse `already-active`. Later activates carry `--sso` (`sso: carried`; T57 covers
activate, not `pm-on`, which carries the pointer's flags as it does for every flag).
`--plan` prints `sso: on; drops ...; carries ...`, plus the proof line's `dropped; --sso drops header ...`.
`--sso` with a pair, with `--no-proof` or on another verb is a usage error.
2. **Item 2** (`sso_checks`, `:817`). These checks run whenever the run's Console flags hold `--sso`, before
anything stops:
- `sso-target`: the target's `cli.mjs` has no `--sso`.
- `sso-config`: the target copy's own `checkConsole` refuses `console`, or there is no `console.sso`. The
config is parsed in node, and only the message's first line is kept. A JSON parse error never quotes
the text.
- `sso-slot`: one per provider, from `lstat`, `stat` and `realpath` in `readSlot`'s order (`slot_problem`,
`:847`). The slot is never opened.
3. **Item 3.**
- **Rollback** (`sso_base`, `:728`): from an `--sso` pointer to a copy without `--sso`, the Console flags
come from the rollback set of the activation that moved the stack onto the pointer's copy from the
target (`from_log arrival`). That set's `show-mosaic-webui.service.txt` is checked against its
`SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
- **`sso-rollback-flags`** refuses when that set is missing, fails its sums, holds no readable argv, or
already ran `--sso`.
- **`config-console-key`** (`config_checks`, `:866`): any activate or rollback refuses before anything
stops when the config has a `console` key that the target's `scripts/mosaic-config.mjs validate` refuses.
The message says to remove the key first (restore step 1's backup), then run the verb again.
4. **N4, the first half** (sage-1403, sage-1407). With no `console.sso` and the stack on another copy, the
`sso-config` refusal names the first step: "activate ID without --sso first, then write console.sso, then
run activate ID --sso". A copy before row 192 refuses the key, so the re-pin comes first. The `--sso`
activate is then the allowed same-copy re-pin.
5. **TOOLS.md.** The flag, its refusals, the rollback rule and the N4 order.
## Window plan acceptance item (N4, mos-1056 item 5, sage-1407)
For the row 200 window plan, word for word:
- **Before the `console` key is written, the window re-pins to row 192 or later.** Check line, run before the
key is written: `git -C /mnt/storage/src/mosaic-stack merge-base --is-ancestor <row 192 landing sha>
<pointer copy sha from scripts/stack-release.sh status acme> && echo REPIN_OK`. It must print `REPIN_OK`.
If the window instead runs `activate <ID> acme --sso --plan` before the key is written, the
`refuses sso-config:` line must end with "activate <ID> without --sso first, then write console.sso, then run
activate <ID> --sso". It names that step only when the pointer is on another copy.
- **A rollback past row 192 removes the key first.** Check line, run before the rollback:
`scripts/stack-release.sh rollback acme --plan` must print no `refuses config-console-key:` line. If it
prints one, restore `config.json` from the window's step 1 backup and run the plan again. The rollback itself
refuses on the same check, so a skipped step stops it before anything stops.
- **Back-out line (webby-0028 N4, sage-1432).** Once a copy with `--sso` support is live, no verb turns `--sso`
off. Only a rollback to a copy before row 192 gets the preview set back, after the `console` key is removed
(the check line above).
## Tests run (this worktree, final tree)
- `node --test --test-name-pattern='^T56' tests/stack-release.test.mjs`: 10/10 on the round 2 tree
(`/mnt/storage/scratch/launchpad-r200/t56-r2c.log`). Round 1 was 8/8 (`t56-6.log`).
- Round 3, this tree: `node --test tests/stack-release.test.mjs`, the whole file, 192/192
(`/mnt/storage/scratch/launchpad-r200/sr-whole-r3b.log`); T57 alone 11/11 (`t57-r3a.log`);
`tests/stack-instances.test.mjs` 23/23 (`si-r3.log`). No other test reads the 3 paths or pins their hashes.
- The cli suite and every `scripts/test-*.sh` go to Sage's gate.
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`, each against T57; T56 in rounds 1 and 2)
| # | Mutant | Result |
| --- | --- | --- |
| 1 | no `sso-target` refusal (item 2) | KILLED |
| 2 | no `sso-config` refusal (item 2) | KILLED |
| 3 | no `sso-slot` refusal (item 2) | KILLED |
| 4 | no `sso-rollback-flags` refusal (item 3) | KILLED |
| 5 | no `config-console-key` refusal (item 3) | KILLED |
| 6 | keep `--auth-header` under `--sso` (item 1) | KILLED |
| 7 | keep `--public-host` under `--sso` (item 1) | KILLED |
| 8 | rollback takes the latest activation onto the copy, not the arrival (sage-1389) | KILLED |
| 9 | `sso-config` names no first step (N4) | KILLED |
| 10 | `slot_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
| 11 | `proof_file_problem` compares the unresolved `REPO` (round 2, N1) | not run, Sage's gate |
| 12 | `config_checks` passes a config that doesn't parse (round 2, N2) | not run; equivalent if it survives, guard kept (sage-1446) |
| 13 | `sso_checks` reads the default config, not `MOSAIC_CONFIG` (round 3) | not run, Sage's gate |
| 14 | `sso_base` reads `show-mosaic-webui.service.txt`, not the instance unit's (round 3) | not run, Sage's gate |
9/9 killed (`/mnt/storage/scratch/launchpad-r200/mutants.log`). The script restored `stack-release.sh`, and it
matches its pre-run sha256. No mutant hit the 300 s timeout. This batch ran without Sage's slot (sage-1414),
while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and doesn't take these results
(sage-1423). Mutants 10 to 12 are new in round 2 and have not run. Sage runs 1 to 12 on the gate (sage-1446). Mutant 5's
pattern now names the `holds` message, because the round 2 message also starts with `config-console-key "$cfg`.
Every pattern matches once on the round 2 tree, and all 14 match once on the round 3 tree.
## Choices
- **R1, `--send` and `--tester-port` follow the pointer.** These two flags come from the pointer, not from the
rollback set, so a `--no-send` or a tester port set after the arrival stays. This is row 189's rule that
rollback carries `--send` off. Tradeoff: the rolled-back Console differs from the set's unit in those two
flags, and the plan line says so.
- **`config_checks` runs for activate too**, not only rollback. An activate onto a pre-192 copy with the key
in place breaks that copy's loader the same way.
- **The inside-repository slot test** symlinks the secrets directory to a temp dir under the fixture repo. The
slot file is then not a link, but its real path is inside the repository.
## Not tested
- The slot owner-uid refusal: it needs a file owned by another uid, which needs privilege.
## Review r1 notes (webby-0028, non-blocking)
- **N1.** `slot_problem` (`:859`) and row 187's `proof_file_problem` (`:988`) compare a resolved path to
`REPO="$(pwd)"` (`:26`), which is not resolved. Run through the `~/src/mosaic-stack-dev-test` symlink, a slot
inside the repository passes here, and the Console refuses it after the stop. Confirmed by reading; Sage
rules on a round 2 or a follow-up.
- **N2.** `config_checks` (`:868`) returns 0 when config.json doesn't parse. Same ruling.
- **N3.** A check this script doesn't make: `slot_problem` refuses only a size of 0. A slot holding only a
newline, or two lines, passes here, and the Console's one-non-empty-line rule (`readSlot`) refuses it after
the stop. Reading the content would break the rule that this script never opens the slot.
- **N4, for the window's back-out step.** Once the live copy supports `--sso`, no verb turns `--sso` off on it.
The only way back to the preview set is a rollback to a copy before row 192, which removes the `console` key
first (the second check line above).
## Round 2 (sage-1432: N1 and N2; N3 and N4 stay notes)
- **N1.** `slot_problem` (`:847`) and `proof_file_problem` (`:989`) now compare the slot's real path with
`realpath -m` of both `REPO` and `COPIES`, and refuse "cannot be resolved" if either fails. T57's
"round 2 (webby-0028 N1)" subtest runs the script through a symlink to the fixture repo
(`repo-link.200/scripts/stack-release.sh`). A proof file inside the repository is refused `proof-file-invalid`,
and a slot whose secrets directory links into the repository is refused `sso-slot` "is inside the repository
or the copies". Mutants 10 and 11 undo each check.
- **N2, with a deviation.** `config_checks` (`:867`) now tells "no key" (node exits 1) from "doesn't parse"
(exits 2) and refuses `config-console-key` "cannot be read as JSON" on the second. But webby-0028's case
doesn't reach it: `load_config` (common.sh, through `scripts/mosaic-config.mjs env`) exits 1 on a config that
doesn't parse, before anything is read or stopped. So the refusal is a guard for a config that changes after
`load_config` ran, and no test reaches it without a race. The T57 subtest "round 2 (webby-0028 N2)" checks
what does happen: activate, rollback and `activate --plan` each exit 1 with "common: configuration load
failed", the pointer, the start/stop log and the activation log are unchanged, and activate works after the
config is restored. Mutant 12 drops the guard and is expected to survive as equivalent for that reason. sage-1446:
the guard stays, and mutant 12 is equivalent if it survives, the same call as row 192's mutant 23.
- **N4.** The back-out line is in the window plan section above.
## Round 3 (sage-1556: rebase onto 00d4cc1f6, `--sso` for a named instance)
- **Rebase.** Row 190 landed as 00d4cc1f6 and changed all 3 paths. The 5 round 1 and 2 commits are picked onto
it on a new branch, `launchpad/row200-r3` (no force-push, no branch delete, #426(a)). `launchpad/row200` stays
as it was. `docs/TOOLS.md` and the test file applied cleanly. `scripts/stack-release.sh` conflicted in `plan()`
and in the `--plan` lines of `do_activate` and `do_rollback`. The merge keeps both sides: the `sso:` plan line
comes before the Console line, the Console and CLI lines carry row 190's `$flags` (`--instance NAME`), and
`--plan` runs `ports_unclaimed` after `console_for_target` and before `plugin_checks` and `config_checks`.
Functions on this tree: `sso_base` `:738`, `sso_for_target` `:785`, `sso_checks` `:827`, `slot_problem`
`:857`, `config_checks` `:877`, `proof_file_problem` `:999`.
- **Named instance: no code change was needed.** Row 190's `instance_select` exports `MOSAIC_CONFIG` as NAME's
config, and the named Console unit sets `Environment=MOSAIC_CONFIG` to the same file. So `sso_checks` and
`config_checks` read `~/.config/mosaic-NAME/config.json`, and the slot path is
`~/.config/mosaic-NAME/secrets/mosaic-stack/<slot>`, the directory the Console's `readSlot` reads under that
unit. `sso_base` reads `show-$CONSOLE_UNIT.txt`, so a named rollback reads `show-mosaic-webui-NAME.service.txt`
from NAME's own set. The pointer, the log and the sets are in NAME's data root. One comment (`:734`) named the
default unit's file and now says `show-$CONSOLE_UNIT.txt`.
- **Test.** New T57 subtest "round 3 (sage-1556)": instance `alpha`. A missing slot refuses `sso-slot` and names
alpha's slot path. `--plan` prints `--instance alpha` on the Console line. `activate C1 acme --sso` writes
`--sso` to `mosaic-webui-alpha.service` (with `MOSAIC_CONFIG` set to alpha's config), leaves the default
Console unit unwritten, and status matches. After the key is removed, `rollback` takes its flags from alpha's
arrival set, `show-mosaic-webui-alpha.service.txt`. The slot carries the read watch. Mutants 13 and 14 undo
the two instance-specific reads.
- **Rename.** Row 190 also named its test T56, so this row's test is now T57; `mutants.py` runs `^T57`, and the
in-repository temp dirs are `t57-slot-` and `t57-proof-`.
- **Defect from round 1, fixed.** Round 1 added `| --sso` to the activate usage line, but T53 test 6 (row 187)
and T55 test 5 (row 189) still matched the old line, so 4 tests failed (2 subtests and their parents). Rounds
1 and 2 ran only this row's test, so the failure went unseen. The round 2 tree d4ec998cc fails the same 4
(`r2-t53t55.log`), and the first whole-file run on this tree was 188/192 (`sr-whole-r3.log`). Both regexes now
include `| --sso` (`:1672`, `:1831`; T57's own check is `:1940`). Webby: please check these two lines; the
round 2 approve did not cover them (sage-1569). A grep of `packages/*/tests` and `scripts/test-*.sh` for the
usage text (`--no-proof]`, `proof-file PATH`, `usage: stack-release`) finds no third copy, and finds no
64-hex pin on any of the 3 paths. The only test files that name `stack-release` are `stack-release.test.mjs`
and `stack-instances.test.mjs` (a comment); both ran as whole files (above).
- **Not changed (note).** The N4 first step reads "activate ID without --sso first"; for a named instance the
operator also passes `--instance NAME`, as with every verb. The message doesn't repeat it.
## Not done here
- No change to the live pointer, units or config. The phase 2 window is Sage's and Mos's.
+1
View File
@@ -0,0 +1 @@
00d4cc1f6987d5eec340361c17efc2b3e58d1505
@@ -0,0 +1,3 @@
4bf241979d5cf2e83a2f209adbf3acb1a8ea091c5c384b27b5e3462fa348d9d0 docs/TOOLS.md
959567d9bf6c2435640060fcb33890a344ead7f7cbc1f8019e45ae2498fcdd4e packages/cli/tests/stack-release.test.mjs
63073fbea264549694b54cc1ae7d46ba9dbe8c5d72670da87985a5223c06ac3f scripts/stack-release.sh
@@ -0,0 +1,50 @@
# Row 200 (#1698) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's scripts/stack-release.sh,
# runs T57 in packages/cli/tests/stack-release.test.mjs and restores the file from a saved copy, checked by sha256.
# Killed = test exit != 0.
import hashlib, os, subprocess, sys, signal
R = os.path.abspath(sys.argv[1])
S = os.path.join(R, 'scripts', 'stack-release.sh')
if not os.path.isfile(S): sys.exit(f'ABORT no scripts/stack-release.sh under {sys.argv[1]}')
T, PAT = 'tests/stack-release.test.mjs', '^T57'
M = [
('no sso-target refusal (item 2)', [(" sso_supported || { check_refuse sso-target", " true || { check_refuse sso-target")]),
('no sso-config refusal (item 2)', [(' check_refuse sso-config "$TO', ' : "$TO')]),
('no sso-slot refusal (item 2)', [('[ -z "$why" ] || check_refuse sso-slot', '[ -z "$why" ] || : sso-slot')]),
('no sso-rollback-flags refusal (item 3)', [(' check_refuse sso-rollback-flags "$TO', ' : "$TO')]),
('no config-console-key refusal (item 3)', [(' check_refuse config-console-key "$cfg holds', ' : "$cfg holds')]),
('keep --auth-header under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --public-host | --allow-user)")]),
('keep --public-host under --sso (item 1)', [(" --public-host | --auth-header | --allow-user)", " --auth-header | --allow-user)")]),
('rollback takes the latest activation onto the copy, not the arrival (sage-1389)', [('o.to===current&&o.from!==current){process.stdout.write(`${o.activation', 'o.to===current){process.stdout.write(`${o.activation')]),
('sso-config names no first step (N4)', [(' first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"', ' first=""')]),
('slot_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$size" != 0 ]')]),
('proof_file_problem compares the unresolved REPO (round 2, webby-0028 N1)', [(' case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]', ' case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac\n [ "$mode" = 600 ]')]),
('config_checks passes a config that does not parse (round 2, webby-0028 N2; expected equivalent)', [(' [ "$key" != 1 ] || return 0', ' [ "$key" = 0 ] || return 0')]),
('sso_checks reads the default config, not the instance config (round 3, sage-1556)', [(' local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why', ' local cfg="$HOME/.config/mosaic-dev/config.json" out id slot path why')]),
('sso_base reads the default Console\'s show file, not the instance unit\'s (round 3, sage-1556)', [(' local f="show-$CONSOLE_UNIT.txt" argv=""', ' local f="show-mosaic-webui.service.txt" argv=""')]),
]
sha = lambda b: hashlib.sha256(b).hexdigest()
only = sys.argv[2:]
os.chdir(os.path.join(R, 'packages', 'cli'))
survived = []
for i, (name, pairs) in enumerate(M, 1):
if only and str(i) not in only: continue
orig = open(S, 'rb').read(); h = sha(orig)
src = orig.decode()
for a, b in pairs:
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
src = src.replace(a, b)
try:
open(S, 'w').write(src); os.chmod(S, 0o755)
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={PAT}', T], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try: out, _ = p.communicate(timeout=300)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
text = out.decode(errors='replace')
ran = [l for l in text.splitlines() if l.startswith('ℹ fail ')]
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
if verdict == 'SURVIVED': survived.append(name)
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
finally:
open(S, 'wb').write(orig); os.chmod(S, 0o755)
if sha(open(S, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {S}')
print('survived:', survived or 'none')
+31 -1
View File
@@ -445,7 +445,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
```
scripts/stack-release.sh adopt BUSINESS
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
@@ -565,6 +565,36 @@ rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
`--plan` prints one `send:` line: `dropped`, `off` with or without the
`--no-send` note, or `carried`.
`activate ... --sso` (row 200, #1698) starts the Console with Q175 sign-in
(row 192). It drops `--public-host`, `--auth-header`, `--allow-user` and any
proof pair from the pointer's Console flags and the unit, and puts `--sso` after
the other flags, before `--send` and `--tester-port`. It may name the live copy
once, so a copy already at row 192 or later turns sign-in on without a copy
change. Later activates, `pm-on` and rollbacks to a copy with `--sso` carry it.
`--sso` with a proof pair or `--no-proof`, or on any other verb, is a usage
error. Before anything stops, a run whose Console flags hold `--sso` refuses
`sso-target` for a target Console without it, `sso-config` when the target's own
`checkConsole` refuses the config's `console` key (or there is none, in which
case, from another copy, it names the first step: activate the target without
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
that is missing, a symlink, not a regular file, not owned by this uid, has group
or other bits, resolves inside the repository or the copies, or is empty. It
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
prints one `sso:` line: `on` with what it drops and carries, `carried`, or
`dropped` on a rollback.
A rollback from an `--sso` pointer to a copy whose Console lacks `--sso` takes
the Console flags from the rollback set of the activation that moved the stack
onto the pointer's copy from the target, past any same-copy re-pin, checked
against the set's `SHA256SUMS`. `--send` and `--tester-port` follow the pointer.
When that set is missing, fails its sums, holds no readable argv or already ran
`--sso`, the run refuses `sso-rollback-flags`. Any activate or rollback also
refuses `config-console-key` before anything stops when the config holds a
`console` key the target's `scripts/mosaic-config.mjs validate` refuses (a copy
before row 192 refuses the key). The message says to remove the key first. So a
window re-pins to row 192 or later before it writes the key, and a rollback past
row 192 removes the key first.
#### A target past c216f47db: the PM after the harness kind
From c216f47db on, the bus launches the PM only with a `harness` kind in the
+250 -3
View File
@@ -12,6 +12,7 @@ import {tmpdir} from 'node:os';
import {join,dirname,basename} from 'node:path';
import {fileURLToPath,pathToFileURL} from 'node:url';
import {once} from 'node:events';
import {ALGS} from '../../webui/src/sso-config.mjs';
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
const NODE=process.execPath;
@@ -274,9 +275,17 @@ const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexpor
const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8');
const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({');
assert.notEqual(KINDS_OLD,KINDS_NEW);
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
// from before row 192, which refuses a `console` key. The other copies have no loader.
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){
const d=join(COPIES,id);
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?"// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n":'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644]]){
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?`// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\n":''}`:'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
}
for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
@@ -1660,7 +1669,7 @@ test('T53 row 187: activate sets, carries and drops the proof pair',async(t)=>{
['activate',C1,'acme','--plan','--proof-file',f]]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof\]/);
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
@@ -1819,7 +1828,7 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
assert.match(usageText,/\| --no-proof \| --sso\] \[--no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
@@ -1851,6 +1860,244 @@ test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry
});
});
// Row 200 (#1698): activate --sso. The config gets console.sso with one provider `a`, and its slot
// file sits where cli.mjs's readSlot looks, beside the config, with a read watch: the run may stat
// it but never open it. ES256 passes every copy's checker; RS256 passes C1's and fails C's.
const SLOT='console-sso-a';
const slotPath=(e)=>join(dirname(e.cfg),'secrets','mosaic-stack',SLOT);
const SSO_KEY={sso:{publicOrigin:'https://console.example.test',providers:[{id:'a',issuer:'https://op.example.test/',clientId:'mosaic-console',clientSecret:{slot:SLOT},algs:['ES256'],subjects:[{sub:'subject-1',name:'jason'}]}]}};
function setKey(e,key){const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(key===undefined)delete c.console;else c.console=key;writeFileSync(e.cfg,JSON.stringify(c))}
async function ssoConfig(e,{key=SSO_KEY,slot=true}={}){
setKey(e,key);mkdirSync(dirname(slotPath(e)),{recursive:true,mode:0o700});
return slot?proofFile(e,SLOT,0o600,dirname(slotPath(e))):slotPath(e);
}
const ssoLines=(out)=>out.filter((l)=>l.startsWith('sso:'));
const DROPS='--public-host console.example --auth-header x-user --allow-user jason';
const SHOW=`show-${CON}.txt`;
const setOf=(e,act)=>join(e.state,'stack-rollback',act);
const arrivalSet=(e,to)=>setOf(e,logLines(e).findLast((l)=>l.result==='ok'&&l.to===to&&l.from!==to).activation);
const ROLLBACK_PLAN=(set)=>`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${SHOW}, with --send and --tester-port as the pointer has them`;
const KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console key, and ${to}'s loader refuses it (${why}); remove the key first (restore the config.json backup from the window's step 1), then run the ${op} again`;
const RB_MSG=(why)=>`${B}'s Console does not support --sso, and the Console flags it ran with cannot be read: ${why}`;
// Rewrites a file in a rollback set (0700, files kept by cp -p) and, unless sums is false, its SHA256SUMS line.
function setEdit(set,name,from,to,sums=true){
const f=join(set,name);chmodSync(set,0o700);chmodSync(f,0o600);edit(f,from,to);
if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))}
}
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
await t.test('item 1: --sso drops the preview set and the pair, puts --sso before --send and the tester port, and the plan names what it drops and carries',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send','--tester-port','7333']);
await ssoConfig(e);
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: carried',
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const steps=stepText(e);
assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps);
assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
await noLeak(e);
});
await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...ssoLines(out)],['proof: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send`]);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
assert.doesNotMatch(stepText(e),/proof/);
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
assert.match(stepText(e),/^serve: .* --sso --send$/m);
r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
for(const x of [e,g])await noLeak(x);
});
await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e);const f=await proofFile(e);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['activate',C1,'acme','--sso',...pair(f)],['activate',C1,'acme','--sso','--no-proof'],['activate',C1,'acme','--no-proof','--sso','--plan'],
['rollback','acme','--sso'],['pm-on','acme','--sso'],['status','acme','--sso'],['adopt','acme','--sso']]){
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
}
assert.match(usageText,/\[--proof-header NAME --proof-file PATH \| --no-proof \| --sso\] \[--no-send\]/);
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false});
const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
put();
await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`);
setKey(e,undefined);
// N4: from another copy, the refusal names the re-pin without --sso as the first step.
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`);
setKey(e,SSO_KEY);
const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`;
rmSync(slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing'));
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link'));
rmSync(slot);mkdirSync(slot,{mode:0o700});
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file'));
for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))}
put(0o600,'');
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty'));
// The slot directory resolving into the repository: the file is no link, but its real path is.
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies'));
// --plan lists the sso refusal with the rest and writes nothing.
const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]);
rmSync(secrets);mkdirSync(secrets,{mode:0o700});put();
r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const why='mosaic-config: unsupported configuration key: "console"';
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
const out=await planOnly(e,3,'rollback','acme');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
setKey(e,undefined);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const set=arrivalSet(e,C1);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
// The latest set holds the --sso Console; the arrival set holds B's.
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /);
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"'));
setKey(e,undefined);
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
const out=await planOnly(e,0,'rollback','acme');
assert.deepEqual(ssoLines(out),[ROLLBACK_PLAN(set)]);
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.ok(stepText(e).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${SHOW}\n`));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// The plain case: --send and the preview set and pair come back as they were.
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
setKey(g,undefined);
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f),'--send']);
for(const x of [e,g])await noLeak(x);
});
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
setKey(e,undefined);
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the Console in ${show} already ran with --sso`));
let out=await planOnly(e,3,'rollback','acme');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses sso-rollback-flags: ${RB_MSG(`the Console in ${show} already ran with --sso`)}`]);
setEdit(set,SHOW,' --sso --public-host ',' --public-host ',false);
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} does not match the set's SHA256SUMS`));
setEdit(set,SHOW,' serve ',' ',true);
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${show} holds no Console argv this script can read`));
rmSync(show);
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`${set} has no ${SHOW} and SHA256SUMS`));
writeFileSync(show,text);setEdit(set,SHOW,'\n','\n',true);
// A log whose arrival line came from another copy names no set for B.
const log=readFileSync(e.log,'utf8');chmodSync(e.log,0o600);
writeFileSync(e.log,log.replaceAll(`"from":"${B}","to":"${C1}"`,`"from":"${A}","to":"${C1}"`));
await refused(e,['rollback','acme'],'sso-rollback-flags',RB_MSG(`the log names no activation that moved the stack from ${B} onto ${C1}`));
writeFileSync(e.log,readFileSync(e.log,'utf8').replaceAll(`"from":"${A}","to":"${C1}"`,`"from":"${B}","to":"${C1}"`));
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{
const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link);
const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e);
const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t57-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true}));
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`);
await ssoConfig(e,{slot:false});
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`);
await noLeak(e);
});
await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{
const e=await mk(t);await seedPreview(e);
// load_config exits first, before the pointer, the log or a unit is read; config_checks' own
// refusal is the guard for a file that changes after that (BUILD.md, round 2).
const good=readFileSync(e.cfg,'utf8');writeFileSync(e.cfg,good.slice(0,-1));
const before=fp(e),n=calls(e).length,logs=logLines(e).length;
for(const args of [['activate',C1,'acme'],['rollback','acme'],['activate',C1,'acme','--plan']]){
const r=await prun(e,...args);assert.equal(r.code,1,args.join(' '));assert.match(r.err,/common: configuration load failed\n$/);
}
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
writeFileSync(e.cfg,good);
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart='));
assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a));
await ssoConfig(a,{slot:false});
await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`);
await proofFile(a,SLOT,0o600,dirname(slotPath(a)));
const want=['--port',a.port,'--business','acme','--sso'];
const out=await planOnly(a,0,'activate',C1,'acme','--sso');
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want);
assert.ok(unit().endsWith(` ${want.join(' ')}`),unit());
assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`));
assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written');
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
setKey(a,undefined);
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --send and --tester-port as the pointer has them`]);
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
assert.ok(stepText(a).includes(`Z drop --sso; ${B}'s Console does not support it, so the Console flags come from ${set}/${show}\n`));
await noLeak(a);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
+215 -22
View File
@@ -4,6 +4,7 @@
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
# activate --sso swaps the preview set and the pair for the Console's own sign-in (row 200, #1698).
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
#
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
@@ -30,13 +31,13 @@ COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 SSO=0 R_BACKUP="" R_PORT=""
usage() {
cat >&2 <<'USAGE'
usage: stack-release.sh adopt BUSINESS
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
[--proof-header NAME --proof-file PATH | --no-proof | --sso] [--no-send] [--timeout S] [--plan]
stack-release.sh pm-on BUSINESS [--timeout S]
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
@@ -91,6 +92,7 @@ parse_opts() {
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
--sso) [ "$OP" = activate ] || usage; SSO=1; shift ;;
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
@@ -108,6 +110,8 @@ parse_opts() {
# The proof pair goes together, and --no-proof goes alone (row 187).
[ "${PROOF_HEADER:+1}" = "${PROOF_FILE:+1}" ] || usage
[ "$NO_PROOF" = 0 ] || [ -z "$PROOF_HEADER" ] || usage
# --sso drops the pair itself, so it takes neither a pair nor --no-proof (row 200).
[ "$SSO" = 0 ] || { [ -z "$PROOF_HEADER" ] && [ "$NO_PROOF" = 0 ]; } || usage
local d
for d in "$COPIES" "$BIN"; do
case "$d" in /*) ;; *) usage ;; esac
@@ -163,7 +167,9 @@ for(const k of open.keys())process.stdout.write(k+"\n");' -- "$LOG"
# Reads the log's ok lines, newest first. `previous` prints rollback's target: the first `to`
# that is not the pointer's copy (release.sh's rule). `backup` prints the backup field of the
# line that made the pointer's copy live, the backup a rollback would restore.
# line that made the pointer's copy live, the backup a rollback would restore. `arrival` prints the
# activation and `from` of the line that moved the stack onto the pointer's copy from another one,
# past any same-copy re-pin after it (row 200, sage-1389).
from_log() {
[ -f "$LOG" ] || return 1
node -e '
@@ -174,6 +180,7 @@ for(const l of lines.reverse()){
if(o.result!=="ok"||!["adopt","activate","rollback"].includes(o.op)||!o.to)continue;
if(mode==="previous"&&o.to!==current){process.stdout.write(o.to+"\n");process.exit(0)}
if(mode==="backup"&&o.to===current){if(o.backup)process.stdout.write(o.backup+"\n");process.exit(o.backup?0:1)}
if(mode==="arrival"&&o.to===current&&o.from!==current){process.stdout.write(`${o.activation??""} ${o.from??""}\n`);process.exit(0)}
}
process.exit(1);' -- "$LOG" "$1" "$P_COPY"
}
@@ -201,15 +208,23 @@ read_units() {
U_CLAUDE=""
read -r -a argv <<< "$env"
for tok in "${argv[@]}"; do case "$tok" in MOSAIC_CLAUDE_BIN=*) U_CLAUDE="${tok#MOSAIC_CLAUDE_BIN=}" ;; esac; done
read -r -a argv <<< "$console_argv"
U_CONSOLE=()
serve_flags "$console_argv" || return 1
U_CONSOLE=("${SERVE_FLAGS[@]}")
}
# Sets SERVE_FLAGS (array) to the Console argv $1 after `serve`, without --checkout and its value;
# returns 1 with no `serve`.
serve_flags() {
local -a argv
read -r -a argv <<< "$1"
SERVE_FLAGS=()
local i serve=0
for ((i = 0; i < ${#argv[@]}; i++)); do
if [ "$serve" = 0 ]; then [ "${argv[i]}" != serve ] || serve=1; continue; fi
if [ "${argv[i]}" = --checkout ]; then i=$((i + 1)); continue; fi
U_CONSOLE+=("${argv[i]}")
SERVE_FLAGS+=("${argv[i]}")
done
[ "$serve" = 1 ] || return 1
[ "$serve" = 1 ]
}
console_port() {
@@ -250,9 +265,9 @@ target_schema() {
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
# Part B's B3 and B-R1; row 189, mos-0972).
# sets a proof pair, drops the pointer's pair with --no-proof, drops the pointer's --send with
# --no-send, or turns on --sso, may name the pointer's copy: it re-pins the live copy with new
# Console flags (row 187, Part B's B3 and B-R1; row 189, mos-0972; row 200, the phase 2 window).
check_target() {
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
@@ -260,6 +275,7 @@ check_target() {
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
{ [ "$SSO" = 1 ] && [[ " ${P_CONSOLE[*]} " != *" --sso "* ]]; } ||
refuse already-active "$TO is the pointer's copy"
}
@@ -676,16 +692,17 @@ flags_drift() {
console_for_target() {
local supported=0 main=7332 inherited="" i
local -a without_tester=()
sso_base
grep -q -- '--tester-port' "$COPIES/$TO/packages/webui/src/cli.mjs" && supported=1
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do
if [ "${P_CONSOLE[i]}" = --tester-port ]; then
i=$((i + 1)); inherited="${P_CONSOLE[i]:-}"
for ((i = 0; i < ${#BASE_CONSOLE[@]}; i++)); do
if [ "${BASE_CONSOLE[i]}" = --tester-port ]; then
i=$((i + 1)); inherited="${BASE_CONSOLE[i]:-}"
else
without_tester+=("${P_CONSOLE[i]}")
if [ "${P_CONSOLE[i]}" = --port ]; then main="${P_CONSOLE[i+1]:-7332}"; fi
without_tester+=("${BASE_CONSOLE[i]}")
if [ "${BASE_CONSOLE[i]}" = --port ]; then main="${BASE_CONSOLE[i+1]:-7332}"; fi
fi
done
RUN_CONSOLE=("${P_CONSOLE[@]}")
RUN_CONSOLE=("${BASE_CONSOLE[@]}")
DROP_TESTER=""
if [ -n "$TESTER_PORT" ]; then
# --plan lists both with its other refusals (row 180); outside it they end the run.
@@ -702,8 +719,179 @@ console_for_target() {
RUN_CONSOLE=("${without_tester[@]}")
DROP_TESTER="--tester-port $inherited"
fi
proof_for_target
if [ "$SSO" = 1 ]; then sso_for_target; else proof_for_target; fi
send_for_target
sso_checks
}
sso_supported() { grep -q -- '--sso' "$COPIES/$TO/packages/webui/src/cli.mjs"; }
has_flag() { local f="$1"; shift; [[ " $* " == *" $f "* ]]; }
# Row 200 (#1698, R1): a rollback from an --sso pointer to a copy whose Console has no --sso. The
# pointer no longer holds the preview set that Console ran with, so the flags come from the rollback
# set of the activation that moved the stack onto the pointer's copy from the rollback target. A
# same-copy re-pin after it has a set of its own, holding the same copy's earlier flags, so the
# pointer's own activation is the wrong set (sage-1389). The set's show-$CONSOLE_UNIT.txt is
# the Console unit as it was before that run, checked against the set's SHA256SUMS. Sets
# BASE_CONSOLE (the flags console_for_target starts from), SSO_PLAN and SSO_STEP. SSO_NO_TARGET
# skips sso_checks after a refusal here, so --plan lists that refusal alone.
sso_base() {
BASE_CONSOLE=("${P_CONSOLE[@]}") SSO_PLAN="" SSO_STEP="" SSO_NO_TARGET=""
has_flag --sso "${P_CONSOLE[@]}" || return 0
[ "$SSO" = 1 ] || SSO_PLAN="sso: carried"
[ "$OP" = rollback ] && ! sso_supported || return 0
local f="show-$CONSOLE_UNIT.txt" argv="" why="" act="" from="" set
read -r act from <<< "$(from_log arrival || true)"
set="$STATE/stack-rollback/$act"
if [ "$from" != "$TO" ]; then why="the log names no activation that moved the stack from $TO onto $P_COPY"
elif ! [[ "$act" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]]; then why="the activation $act that moved the stack onto $P_COPY names no rollback set"
elif [ ! -f "$set/$f" ] || [ ! -f "$set/SHA256SUMS" ]; then why="$set has no $f and SHA256SUMS"
elif [ "$(sum_of "$set" "$f")" != "$(sha256sum < "$set/$f" | cut -d' ' -f1)" ]; then why="$set/$f does not match the set's SHA256SUMS"
else
argv="$(argv_of "$(cat "$set/$f")")"
if ! unquoted "$argv" || ! serve_flags "$argv"; then why="$set/$f holds no Console argv this script can read"
elif has_flag --sso "${SERVE_FLAGS[@]}"; then why="the Console in $set/$f already ran with --sso"
fi
fi
if [ -n "$why" ]; then
SSO_NO_TARGET=1
check_refuse sso-rollback-flags "$TO's Console does not support --sso, and the Console flags it ran with cannot be read: $why"
return 0
fi
# --send and --tester-port follow the pointer, so a --no-send or a tester port set after that
# activation stays (row 189's rule that rollback carries --send off).
local i
BASE_CONSOLE=()
for ((i = 0; i < ${#SERVE_FLAGS[@]}; i++)); do
case "${SERVE_FLAGS[i]}" in
--send) ;;
--tester-port) i=$((i + 1)) ;;
*) BASE_CONSOLE+=("${SERVE_FLAGS[i]}") ;;
esac
done
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do
case "${P_CONSOLE[i]}" in
--send) BASE_CONSOLE+=(--send) ;;
--tester-port) i=$((i + 1)); BASE_CONSOLE+=(--tester-port "${P_CONSOLE[i]:-}") ;;
esac
done
SSO_PLAN="sso: dropped; $TO's Console does not support --sso, so the rollback takes the Console flags from $set/$f, with --send and --tester-port as the pointer has them"
SSO_STEP="drop --sso; $TO's Console does not support it, so the Console flags come from $set/$f"
}
# Row 200 (#1698): activate --sso drops the preview set and the proof pair, which serve refuses beside
# it, and puts --sso where webui-service.sh renders it: after the other flags, before --send and
# --tester-port. Sets SSO_DROP (the plan's dropped preview flags), PROOF_PLAN and PROOF_STEP.
sso_for_target() {
local header="" file="" i
local -a kept=()
SSO_DROP=""
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
case "${RUN_CONSOLE[i]}" in
--public-host | --auth-header | --allow-user) SSO_DROP+=" ${RUN_CONSOLE[i]} ${RUN_CONSOLE[i + 1]:-}"; i=$((i + 1)) ;;
--proof-header) i=$((i + 1)); header="${RUN_CONSOLE[i]:-}" ;;
--proof-file) i=$((i + 1)); file="${RUN_CONSOLE[i]:-}" ;;
--sso) ;;
*) kept+=("${RUN_CONSOLE[i]}") ;;
esac
done
RUN_CONSOLE=()
for ((i = 0; i < ${#kept[@]}; i++)); do
case "${kept[i]}" in --send | --tester-port) break ;; esac
RUN_CONSOLE+=("${kept[i]}")
done
RUN_CONSOLE+=(--sso "${kept[@]:i}")
PROOF_PLAN="proof: off" PROOF_STEP=""
if [ -n "$header" ]; then
PROOF_PLAN="proof: dropped; --sso drops header $header"
PROOF_STEP="drop --proof-header $header --proof-file $file; --sso was given"
fi
}
# Row 200 item 2: whenever the run's Console flags hold --sso, the checks row 192's --sso start runs,
# before anything stops: a target Console with --sso, console.sso in config.json as the target's own
# checkConsole reads it, and each provider's slot file. The slot gets lstat, stat and realpath
# only; this script never opens it. Also sets the plan's sso: line for activate --sso.
SSO_JS='
const {readFileSync}=require("node:fs");const {pathToFileURL}=require("node:url");
const [copy,file]=process.argv.slice(1);
(async()=>{
const {checkConsole}=await import(pathToFileURL(`${copy}/packages/webui/src/sso-config.mjs`).href);
let doc;
// A JSON.parse message can quote the text, so it is not passed on.
try{doc=JSON.parse(readFileSync(file,"utf8"))}catch{throw new Error(`cannot read ${file} as JSON`)}
if(!doc?.console?.sso)throw new Error("there is no console.sso in the system config");
checkConsole(doc.console);
for(const p of doc.console.sso.providers)process.stdout.write(`${p.id} ${p.clientSecret.slot}\n`);
})().catch((e)=>{process.stdout.write(String(e?.message??e).split("\n")[0]);process.exit(1)});'
sso_checks() {
has_flag --sso "${RUN_CONSOLE[@]}" || return 0
if [ "$SSO" = 1 ]; then
local t carried="" dropped="${SSO_DROP# }"
for t in "${RUN_CONSOLE[@]}"; do [ "$t" = --sso ] || carried+="${carried:+ }$t"; done
SSO_PLAN="sso: on; drops ${dropped:-nothing}; carries ${carried:-nothing}"
SSO_STEP="turn on --sso${SSO_DROP:+; drop$SSO_DROP}"
fi
[ -z "$SSO_NO_TARGET" ] || return 0
sso_supported || { check_refuse sso-target "$TO's Console does not support --sso"; return 0; }
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out id slot path why
# N4 (webby-0019, sage-1403): a pre-phase-1 loader refuses a console key, so with the key not yet
# written and the stack on another copy, the first step is the re-pin without --sso.
local first=""
if ! out="$(node -e "$SSO_JS" -- "$COPIES/$TO" "$cfg" 2>&1)"; then
out="${out%%$'\n'*}"
[ "$out" != "there is no console.sso in the system config" ] || [ "$P_COPY" = "$TO" ] ||
first="; activate $TO without --sso first, then write console.sso, then run activate $TO --sso"
check_refuse sso-config "$TO's Console would refuse its --sso start: $out$first"
return 0
fi
while read -r id slot; do
path="$(dirname "$cfg")/secrets/mosaic-stack/$slot"
why="$(slot_problem "$path")"
[ -z "$why" ] || check_refuse sso-slot "console.sso provider $id client secret slot $path $why"
done <<< "$out"
}
# Why a slot file can't serve, or nothing: readSlot's checks in packages/webui/src/sso.mjs, in its
# order, from lstat, stat and realpath only, then a size of 0 for its one-non-empty-line rule.
slot_problem() {
local st uid mode size type real copies repo
[ -e "$1" ] || [ -L "$1" ] || { echo "is missing"; return; }
[ ! -L "$1" ] || { echo "is a symbolic link"; return; }
st="$(stat -c '%u:%a:%s:%F' -- "$1" 2>/dev/null)" || { echo "cannot be read with stat"; return; }
uid="${st%%:*}" st="${st#*:}"
mode="${st%%:*}" st="${st#*:}"
size="${st%%:*}" type="${st#*:}"
case "$type" in "regular file" | "regular empty file") ;; *) echo "is not a regular file"; return ;; esac
[ "$uid" = "$(id -u)" ] || { echo "is owned by uid $uid, not $(id -u), the uid this script runs as"; return; }
(( (8#$mode & 8#077) == 0 )) || { echo "has mode $mode, which gives group or other permission bits"; return; }
# REPO is $(pwd), which keeps a symlink in the path the script ran by (webby-0028 N1).
real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" && repo="$(realpath -m -- "$REPO")" || { echo "cannot be resolved"; return; }
case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac
[ "$size" != 0 ] || echo "is empty"
}
# Row 200 item 3 and the design's phase 2 rollback order: a config.json with a `console` key must be
# one the target's loader reads, or its bus, Console and seats fail on their next read. Runs the
# target's own scripts/mosaic-config.mjs validate; its output, the resolved config, is not kept.
config_checks() {
local cfg="${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}" out loader="$COPIES/$TO/scripts/mosaic-config.mjs" first
# 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key
# is unknown, and the run refuses rather than pass it (webby-0028 N2). load_config already exits on
# such a file before anything is read or stopped, so this is reached only if the file changes after it.
local key=0
node -e 'const fs=require("fs"),f=process.argv[1];if(!fs.existsSync(f))process.exit(1);let d;try{d=JSON.parse(fs.readFileSync(f,"utf8"))}catch{process.exit(2)}
process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' -- "$cfg" 2>/dev/null || key=$?
[ "$key" != 1 ] || return 0
if [ "$key" != 0 ]; then
check_refuse config-console-key "$cfg cannot be read as JSON, so whether $TO's loader reads it is unknown; fix the config first, then run the $OP again"
return 0
fi
if [ ! -f "$loader" ]; then out="it has no scripts/mosaic-config.mjs"
elif out="$(MOSAIC_CONFIG="$cfg" node "$loader" validate 2>&1 >/dev/null)"; then return 0
fi
first="${out%%$'\n'*}"
check_refuse config-console-key "$cfg holds a console key, and $TO's loader refuses it ($first); remove the key first (restore the config.json backup from the window's step 1), then run the $OP again"
}
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
@@ -809,7 +997,7 @@ proof_checks() {
# Why the proof file can't serve, or nothing. lstat, stat and realpath only; the order follows
# webui-service.sh render, which refuses the same files.
proof_file_problem() {
local st uid mode type real copies
local st uid mode type real copies repo
case "$1" in /*) ;; *) echo "is not an absolute path"; return ;; esac
case "$1" in *[!A-Za-z0-9/._+-]*) echo "holds a character outside [A-Za-z0-9/._+-]"; return ;; esac
[ ! -L "$1" ] || { echo "is a symlink"; return; }
@@ -817,8 +1005,8 @@ proof_file_problem() {
uid="${st%%:*}" st="${st#*:}"
mode="${st%%:*}" type="${st#*:}"
case "$type" in "regular file" | "regular empty file") ;; *) echo "is not a regular file"; return ;; esac
real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" || { echo "cannot be resolved"; return; }
case "$real" in "$REPO" | "$REPO"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac
real="$(realpath -e -- "$1")" && copies="$(realpath -m -- "$COPIES")" && repo="$(realpath -m -- "$REPO")" || { echo "cannot be resolved"; return; }
case "$real" in "$repo" | "$repo"/* | "$copies" | "$copies"/*) echo "is inside the repository or the copies"; return ;; esac
[ "$mode" = 600 ] || [ "$mode" = 400 ] || { echo "has mode $mode, not 600 or 400"; return; }
[ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as"
}
@@ -1005,6 +1193,7 @@ plan() {
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
[ -z "$SSO_PLAN" ] || echo "$SSO_PLAN"
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
local r
@@ -1019,6 +1208,7 @@ run_steps() {
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
[ -z "$SSO_STEP" ] || say "$SSO_STEP"
trap 'after_fail signal' INT TERM HUP
stop_both || rollback_in_run stop-timeout
if [ -e "$STORE" ]; then
@@ -1078,9 +1268,10 @@ do_activate() {
pointer_for_business
run_pm
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
precheck
plugin_checks
config_checks
new_activation
run_steps migrate
}
@@ -1090,9 +1281,10 @@ do_rollback() {
pointer_for_business
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
run_pm
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
precheck
plugin_checks
config_checks
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
new_activation
if [ -n "$RESTORE" ]; then
@@ -1547,6 +1739,7 @@ BUS_UNIT="${BUS_TEMPLATE%.service}$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET=""
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
case "$OP" in
adopt) do_adopt ;;