stack-release: round 2, slot and proof checks resolve REPO, config_checks refuses a config that doesn't parse (row 200, #1698, sage-1432)
This commit is contained in:
@@ -3,13 +3,13 @@
|
||||
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).
|
||||
(N4 is a window acceptance item), sage-1432 (round 2 for webby-0028 N1 and N2).
|
||||
|
||||
Base 4d487dfdc (`base.txt`): 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. Candidate: commit 5dc0b5534 on `launchpad/row200`.
|
||||
`launchpad/row192` at 37582e820 path for path. Candidate: r1 5dc0b5534 and the round 2 commit on `launchpad/row200`.
|
||||
`candidate-manifest.sha256` lists its 3 paths; the packet files are not in it. Drift check at commit time:
|
||||
`git log aa1bd209e..origin/refactor` (origin/refactor ae3c36f24) touches none of the 3 paths.
|
||||
`git log aa1bd209e..origin/refactor` (origin/refactor 9ba31b5b3 at round 2) touches none of the 3 paths.
|
||||
|
||||
No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, `~/.mosaic`, a live unit or a live slot. T56 runs on
|
||||
the scratch roots and seeded pointers rows 187 and 189 use. Its config and slot sit in the test's temp dir,
|
||||
@@ -20,7 +20,7 @@ and the slot carries the `proofFile` read watch, so a run that opened it would f
|
||||
| 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. T56, 8 subtests. |
|
||||
| `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. T56, 10 subtests (2 in round 2). |
|
||||
| `docs/TOOLS.md` | Usage line and two paragraphs after row 189's `--no-send`. |
|
||||
|
||||
## What it does
|
||||
@@ -70,11 +70,14 @@ For the row 200 window plan, word for word:
|
||||
`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`: 8/8
|
||||
(`/mnt/storage/scratch/launchpad-r200/t56-6.log`).
|
||||
- `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`).
|
||||
- The whole file, the cli suite and every `scripts/test-*.sh` go to Sage's gate. T1 to T55 have not run on
|
||||
this tree.
|
||||
|
||||
@@ -91,11 +94,16 @@ For the row 200 window plan, word for word:
|
||||
| 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, expected equivalent |
|
||||
|
||||
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).
|
||||
(sage-1423). Mutants 10 to 12 are new in round 2 and have not run: they need Sage's slot (sage-1414). 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.
|
||||
|
||||
## Choices
|
||||
|
||||
@@ -126,6 +134,25 @@ while Darkwing's gate was running, so Sage reruns all 9 on the row 200 gate and
|
||||
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. T56'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 T56 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
|
||||
rules whether to keep the guard, as with row 192's mutant 23.
|
||||
- **N4.** The back-out line is in the window plan section above.
|
||||
|
||||
## Not done here
|
||||
|
||||
- No change to the live pointer, units or config. The phase 2 window is Sage's and Mos's.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
6ee1adb3951aeb1e3862cb119c12b600b88641f325912c09840b841761aa12dd docs/TOOLS.md
|
||||
0c1aa38932982b72ea71a6d2d785f6412143c298ea6f2b448c4b4ee615a66da8 packages/cli/tests/stack-release.test.mjs
|
||||
6cb5f90a85eb86973a5176934f867c7282509e49830933aaa998f70297c79392 scripts/stack-release.sh
|
||||
9b231532d14b21d0d39ca89035d9067e827e18cc1cf3dfe298bef840ae87ff0c packages/cli/tests/stack-release.test.mjs
|
||||
d24e75e9927623ad106b55bce82e40ddf7677d483626fb46923c3b3c7c1148ad scripts/stack-release.sh
|
||||
|
||||
@@ -11,11 +11,14 @@ M = [
|
||||
('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', ' : "$cfg')]),
|
||||
('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')]),
|
||||
]
|
||||
sha = lambda b: hashlib.sha256(b).hexdigest()
|
||||
only = sys.argv[2:]
|
||||
|
||||
@@ -2007,6 +2007,33 @@ test('T56 row 200: activate --sso, its refusals before any stop, and the rollbac
|
||||
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,'t56-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,'t56-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);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
|
||||
@@ -845,7 +845,7 @@ sso_checks() {
|
||||
# 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
|
||||
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; }
|
||||
@@ -855,8 +855,9 @@ slot_problem() {
|
||||
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; }
|
||||
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
|
||||
# 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"
|
||||
}
|
||||
|
||||
@@ -865,7 +866,17 @@ slot_problem() {
|
||||
# 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
|
||||
node -e 'process.exit(Object.hasOwn(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")),"console")?0:1)' -- "$cfg" 2>/dev/null || return 0
|
||||
# 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
|
||||
@@ -976,7 +987,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; }
|
||||
@@ -984,8 +995,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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user