row 203 FED R2: stack-release probe reads federation; a refusal case per federation check
stack-release config_checks refuses a target whose loader refuses a key the config holds; it looked only for console, so a federation-only config passed to a pre-203 copy. The probe now reads console or federation. The fixture's B loader replace follows the new rejectUnknownKeys list, which the federation key changed. Adds a test for each federation refusal in test-config.sh (six uncovered, plus trailing slash and duplicate peer id), and one stack-release test for the target-loader refusal. Mutants M7-M14 in BUILD.md. Not run yet.
This commit is contained in:
1 parent
e704a18aec
commit
8d34a9e948
5 files changed
+144
-18
No files matched your search
@@ -4,7 +4,14 @@ Owner Quackerjack, reviewer Webby, gate owner Sage. Brief: docs/plans/2026-10-20
|
||||
"Brief: FED R2". Design: DESIGN.md section 3.1 (D1), section 4 (the key shape), section 7 row R2, at
|
||||
gizmoduck/federation-design b93f098f0.
|
||||
|
||||
Base: aca16404c on quackerjack/row203. Rebase onto 253's tip after 253 lands, under a new branch name.
|
||||
Base: aca16404c, rebased onto f2735bb6a (origin/refactor, shared object store). Rebased onto 253's tip
|
||||
only if 253 lands first, under a new branch name.
|
||||
|
||||
## Commits
|
||||
|
||||
- 0eb4b6f1f, rebased to e704a18ae: the loader, the first FED R2 tests, and these notes.
|
||||
- Second commit on top of e704a18ae (sage-2998): B1 and B2 below, the fixture fix, and the notes
|
||||
rewrite. Its sha goes in the status line once committed.
|
||||
|
||||
## Shape
|
||||
|
||||
@@ -22,10 +29,16 @@ Base: aca16404c on quackerjack/row203. Rebase onto 253's tip after 253 lands, un
|
||||
- `peers[].label` is a non-empty string of at most 80 characters.
|
||||
- Labels and URLs only. Nothing here grants trust to a peer (section 3.1 D1, section 3.2 W1).
|
||||
|
||||
## Readers of the key (the manifest lists every one)
|
||||
## Readers of the key
|
||||
|
||||
Only the loader reads it: `scripts/mosaic-config.mjs` (`validate`, the `federation` branch). Nothing else
|
||||
reads config `federation` at aca16404c (`git grep`).
|
||||
Two readers read the raw key. Nothing else reads it (`git grep "federation"` at e704a18ae):
|
||||
|
||||
1. The loader, `scripts/mosaic-config.mjs` (`validate`, the `federation` branch). This is the only reader of
|
||||
the validated value. Its output is read by the CLI and business code, which read named fields only.
|
||||
The output is checked in `scripts/test-config.sh`.
|
||||
2. The stack-release probe, `config_checks` in `scripts/stack-release.sh` (B1, below). It reads the config
|
||||
file itself, before a copy is activated or rolled back to, and asks whether the target copy's loader
|
||||
refuses a key the config holds.
|
||||
|
||||
Stale wording, NOT changed in this row (Sage rulings sage-2808 and sage-2809):
|
||||
`packages/cli/src/access.mjs` is out of row 203. Megavolt's row 207 changes that file. Follow-up, to be
|
||||
@@ -43,6 +56,33 @@ Behavior after this row: `accessContextFor` still returns `peers: []` (access.mj
|
||||
config key only, so grants naming a peer home still refuse. Wiring the key into the access context is not in
|
||||
the FED R2 brief.
|
||||
|
||||
## B1: the stack-release probe (sage-2998, in scope)
|
||||
|
||||
Webby's early look (webby-0189): a config holding `federation` and no `console` passed `config_checks`, because
|
||||
the probe looked only for `console`. A rollback or activate to a pre-203 copy would then pass its checks, and
|
||||
that copy's loader refuses `unsupported configuration key: "federation"` on the next read.
|
||||
|
||||
Fix (scripts/stack-release.sh, `config_checks`):
|
||||
- The probe's node check now reads `console` or `federation` (`Object.hasOwn(d,"console")||Object.hasOwn(d,"federation")`).
|
||||
- The refusal keeps the check name `config-console-key`, so existing callers and tests do not change. The
|
||||
message now reads "holds a console or federation key". This is a wording change to an existing refusal,
|
||||
and `KEY_MSG` in `packages/cli/tests/stack-release.test.mjs` moves with it.
|
||||
- The new test is `row 203: a config whose federation key the target loader refuses ...` in
|
||||
`packages/cli/tests/stack-release.test.mjs`, after the row 200 console test. Target B's loader has no
|
||||
federation key in its list, so it refuses the key. Activate to B and rollback to B are both refused, and
|
||||
a rollback succeeds once the key is removed.
|
||||
|
||||
Fixture (packages/cli/tests/stack-release-fixture.mjs, line 300): `LOADER_OLD` builds B's loader by a string
|
||||
replace on the `rejectUnknownKeys` list. Adding `federation` to that list in this row left the replace with no
|
||||
match, and the fixture's `assert.notEqual(LOADER_OLD, LOADER)` would fail at load. The replace now targets
|
||||
`"execution", "console", "federation"]`, so B's loader refuses both keys. This regression came from my
|
||||
e704a18ae, and the fix is in this commit.
|
||||
|
||||
## B2: a case and a mutant for each refusal (sage-2998: "one mutant per refusal")
|
||||
|
||||
Webby's list is covered below. Each refusal has a case in `scripts/test-config.sh` (FED R2 block) and a
|
||||
mutant. M5 is killed by the self case; M13 covers the duplicate half of the id check that M5 did not reach.
|
||||
|
||||
## Refusals and their tests
|
||||
|
||||
`scripts/test-config.sh`, FED R2 block:
|
||||
@@ -55,27 +95,83 @@ the FED R2 brief.
|
||||
| origin over http | `federation.peers[0].origin must be an https origin ...` | M3 |
|
||||
| origin with a path | same | M4 |
|
||||
| origin with userinfo | same | M4 |
|
||||
| origin with a trailing slash | same | M4 |
|
||||
| peer id equal to self | `federation.peers[0].id ... is already named` | M5 |
|
||||
| duplicate peer id | `federation.peers[1].id "factory" is already named` | M13 |
|
||||
| peer label empty | `federation.peers[0].label must be a non-empty string ...` | M6 |
|
||||
| peer label over 80 characters | `federation.peers[0].label must be ...` | M12 |
|
||||
| federation not an object | `"federation" must be a JSON object` | M7 |
|
||||
| self does not match the pattern | `federation.self must match ...` | M8 |
|
||||
| peers not an array | `federation.peers must be an array` | M9 |
|
||||
| peer not an object | `federation.peers[0] must be a JSON object` | M10 |
|
||||
| peer id does not match the pattern | `federation.peers[0].id must match ...` | M11 |
|
||||
| no federation key | loads, no federation output | base |
|
||||
| B1: target loader refuses federation | `config-console-key` refusal (stack-release.test) | M14 |
|
||||
|
||||
Mutant plan (run on Sage's gate only, one edit each, a named test must fail):
|
||||
Mutant plan (run on Sage's gate only, one edit each, a named test must fail). Before the gate each edit string
|
||||
must occur exactly once in its file. I checked all of M1 to M14 with grep -cF in this commit: each is 1. The
|
||||
gate's mutant runner checks the count again before it applies each edit:
|
||||
|
||||
- M1: `rejectUnknownKeys(value, ["self", "peers"], "federation");` removed. Killed by "federation unknown sub-key".
|
||||
- M2: `rejectUnknownKeys(peer, ["id", "origin", "label"], where);` removed. Killed by "federation peer unknown sub-key".
|
||||
- M3: `url.protocol !== "https:" ||` removed. Killed by "origin over http".
|
||||
- M4: `|| url.origin !== value` removed. Killed by "origin with a path" and "origin with userinfo".
|
||||
- M5: the `ids.has(peer.id)` refusal removed. Killed by "peer id equal to self".
|
||||
|
||||
- M4: `|| url.origin !== value` removed. Killed by "origin with a path", "origin with userinfo" and
|
||||
"origin with a trailing slash".
|
||||
- M5: `if (ids.has(peer.id)) {` changed to `if (false) {`. Killed by "peer id equal to self".
|
||||
- M6: `peer.label.trim() === "" ||` removed. Killed by "peer label empty".
|
||||
- M7: `if (!isPlainObject(value)) {` changed to `if (false) {`. Killed by "federation not an object".
|
||||
- M8: `|| !FEDERATION_ID_PATTERN.test(value.self)` removed. Killed by "self does not match the pattern".
|
||||
- M9: `if (!Array.isArray(value.peers)) {` changed to `if (false) {`. Killed by "peers not an array".
|
||||
- M10: `if (!isPlainObject(peer)) {` changed to `if (false) {`. Killed by "peer not an object".
|
||||
- M11: `|| !FEDERATION_ID_PATTERN.test(peer.id)` removed. Killed by "peer id does not match the pattern".
|
||||
- M12: ` || peer.label.length > FEDERATION_LABEL_MAX` removed. Killed by "peer label over 80 characters".
|
||||
- M13: ` ids.add(peer.id);` removed. Killed by "duplicate peer id".
|
||||
- M14 (stack-release.sh): `||Object.hasOwn(d,"federation")` removed. Killed by the row 203 federation test in
|
||||
`stack-release.test.mjs`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No config is written. `~/.config/mosaic-dev/config.json` is not touched. Tests use `$SANDBOX` only.
|
||||
- No change to `packages/`, `scripts/bootstrap.sh`, or any reader.
|
||||
- No change to `packages/cli/src`, `scripts/bootstrap.sh`, or any reader beyond the stack-release probe.
|
||||
|
||||
## Manifest (sha256 of committed blobs; the list is the review scope)
|
||||
|
||||
- scripts/mosaic-config.mjs
|
||||
- scripts/test-config.sh
|
||||
- scripts/stack-release.sh (B1)
|
||||
- packages/cli/tests/stack-release.test.mjs (B1 test, KEY_MSG)
|
||||
- packages/cli/tests/stack-release-fixture.mjs (B's loader fixture; without this, the suite fails to load)
|
||||
- agents/quackerjack/work/queue-203/BUILD.md
|
||||
|
||||
## Slot estimate (for Sage's gap)
|
||||
|
||||
- test-config.sh: about 5 min, padded.
|
||||
- The cli suite: about 5 min, padded.
|
||||
- stack-release.test.mjs: up to 1800 s, per Sage. It copies the loader, so it runs whole at the gate (Webby N3).
|
||||
- Mutants M1–M13 each re-run the FED block of test-config.sh. M14 re-runs stack-release.test.mjs.
|
||||
The per-mutant cost is not measured: this run has not been timed.
|
||||
|
||||
## Status
|
||||
|
||||
- Code and tests written at aca16404c + this branch. NOT YET RUN: no node during Gander's slot (run 253, from
|
||||
16:32Z). The test-config.sh and cli gates run when Sage announces a CLEAR.
|
||||
- Not committed. Per invariant 8 the commit waits until test-config.sh is green.
|
||||
- The loader, tests and notes are in e704a18ae, rebased onto f2735bb6a.
|
||||
- B1, B2, the fixture fix and this notes rewrite are in the second commit on top (sha in the message to Sage).
|
||||
- NOT YET RUN: no node during Gander's slot. The test-config.sh, cli and stack-release gates run when Sage
|
||||
announces a CLEAR. `bash -n` passes on both shell scripts.
|
||||
- Not pushed, not claimed. Per invariant 8 the gate runs before any commit is called green.
|
||||
|
||||
## Review notes (Webby, webby-0189, answered here)
|
||||
|
||||
- B1: fixed as above (scope per sage-2998).
|
||||
- B2: a case and a mutant for each uncovered refusal, plus the trailing-slash case under M4 (N4) and the
|
||||
duplicate-peer case (M13).
|
||||
- N1: this file is rewritten with the current base and status.
|
||||
- N2: consumers checked by `git grep "mosaic-config"`. The loader's output is read by `packages/cli/src/config.mjs`,
|
||||
`packages/business/src/*.mjs`, `packages/webui/src/sso-config.mjs`, `packages/seat/src/seat.mjs`,
|
||||
`packages/discord/src/binding.mjs`, `packages/conversation/src/guard.mjs`, and the shell scripts that call
|
||||
`validate` (bootstrap.sh, common.sh, instance.sh, land-work.sh, stack-copy.sh, stack-release.sh,
|
||||
conductor-apply.sh). Checked by `git grep "federation"` over packages and scripts: no reader outside the
|
||||
loader and the stack-release probe mentions `federation`. I did NOT read each call site for which fields it
|
||||
uses; that is still open for Webby's static read. Not run.
|
||||
- N3: the cli stack-release tests copy the loader; they run whole at the gate. Listed in the manifest and
|
||||
the slot estimate.
|
||||
- N4: trailing-slash case added (see table; M4).
|
||||
@@ -295,9 +295,10 @@ export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "ha
|
||||
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.
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader. Row 203: B's
|
||||
// loader also refuses a `federation` key, as a pre-203 loader does (its list has no federation).
|
||||
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 LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console", "federation"]','["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]]:[];
|
||||
|
||||
@@ -1468,7 +1468,7 @@ 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 --tester-port as the pointer has it`;
|
||||
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 KEY_MSG=(e,to,op,why)=>`${e.cfg} holds a console or federation 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){
|
||||
@@ -1582,6 +1582,19 @@ test('T57 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('row 203: a config whose federation 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);
|
||||
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
|
||||
// B's loader has no federation key in its list, as a pre-203 loader has none: it refuses the key.
|
||||
const setFed=(fed)=>{const c=JSON.parse(readFileSync(e.cfg,'utf8'));if(fed===undefined)delete c.federation;else c.federation=fed;writeFileSync(e.cfg,JSON.stringify(c))};
|
||||
setFed({self:'hub',peers:[{id:'factory',origin:'https://mosaic.woltje.com',label:'Software factory'}]});
|
||||
const why='mosaic-config: unsupported configuration key: "federation"';
|
||||
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));
|
||||
setFed(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);
|
||||
|
||||
@@ -1078,12 +1078,12 @@ 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
|
||||
# 0: a console key; 1: no key, or no file; 2: the file doesn't parse, so whether it holds the key
|
||||
# 0: a console or federation key (row 203: a pre-203 loader refuses federation too); 1: neither, 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=$?
|
||||
process.exit(d!==null&&typeof d==="object"&&(Object.hasOwn(d,"console")||Object.hasOwn(d,"federation"))?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"
|
||||
@@ -1093,7 +1093,7 @@ process.exit(d!==null&&typeof d==="object"&&Object.hasOwn(d,"console")?0:1)' --
|
||||
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"
|
||||
check_refuse config-console-key "$cfg holds a console or federation 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
|
||||
|
||||
@@ -226,6 +226,22 @@ expect_fed_refusal "federation peer id equal to self exits 2 naming the key" "fe
|
||||
'{"self":"hub","peers":[{"id":"hub","origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
expect_fed_refusal "federation peer label empty exits 2 naming the key" "federation.peers[0].label" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":""}]}'
|
||||
expect_fed_refusal "federation not an object exits 2 naming the key" '"federation" must be a JSON object' '"hub"'
|
||||
expect_fed_refusal "federation self pattern exits 2 naming the key" "federation.self must match" \
|
||||
'{"self":"Hub","peers":[]}'
|
||||
expect_fed_refusal "federation peers not an array exits 2 naming the key" "federation.peers must be an array" \
|
||||
'{"self":"hub","peers":{}}'
|
||||
expect_fed_refusal "federation peer not an object exits 2 naming the key" "federation.peers[0] must be a JSON object" \
|
||||
'{"self":"hub","peers":["factory"]}'
|
||||
expect_fed_refusal "federation peer id pattern exits 2 naming the key" "federation.peers[0].id must match" \
|
||||
'{"self":"hub","peers":[{"id":"Factory","origin":"https://mosaic.woltje.com","label":"F"}]}'
|
||||
FED_LONG_LABEL="$(printf 'x%.0s' $(seq 81))"
|
||||
expect_fed_refusal "federation peer label over 80 characters exits 2 naming the key" "federation.peers[0].label" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"'"$FED_LONG_LABEL"'"}]}'
|
||||
expect_fed_refusal "federation duplicate peer id exits 2 naming the key" 'federation.peers[1].id "factory" is already named' \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com","label":"F"},{"id":"factory","origin":"https://other.woltje.com","label":"G"}]}'
|
||||
expect_fed_refusal "federation peer origin with a trailing slash exits 2 naming the key" "federation.peers[0].origin" \
|
||||
'{"self":"hub","peers":[{"id":"factory","origin":"https://mosaic.woltje.com/","label":"F"}]}'
|
||||
|
||||
# Without the key: no federation in the output, and the config still loads (FED R2 boundary).
|
||||
cfg no-federation.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
|
||||
|
||||
Reference in new issue
Block a user