row 281 design d4: step G detached from the verbs, open decisions and a fired-agent raise check, one step G per agent (dewey-0823, #1786)
This commit is contained in:
1 parent
36c2d98b81
commit
41983e2659
1 file changed
+269
-75
@@ -1,9 +1,15 @@
|
||||
# Row 281 build notes: a fire or a delete revokes the instance's grants (#1786)
|
||||
|
||||
Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d3 for Dewey's approval. d2 added Sage's
|
||||
Q1/Q2 rulings (sage-2573) to d1 (dfebd16cc). d3 adds the approved-before-fire tests T6/T6b/T6c (sage-2574). No code until it is
|
||||
Builder Megavolt, reviewer Dewey, gate owner Sage. Design draft d4 for Dewey's approval. No code until it is
|
||||
approved. The results sections are filled in after the runs on Sage's gate.
|
||||
|
||||
History: d1 dfebd16cc. d2 c746ce378 added Sage's Q1/Q2 rulings (sage-2573). d3 36c2d98b8 added the
|
||||
approved-before-fire tests (sage-2574). d4 answers Dewey's CHANGES on d3 (dewey-0823):
|
||||
- B1: the set takes every grant decision with no outcome, and a raise refuses a fired or firing agent.
|
||||
- B2: step G runs detached from the verb, so neither caller waits on the network.
|
||||
- B3: one step G per agent, with named refusals for everything that could race it.
|
||||
- N1-N3 are folded in.
|
||||
|
||||
## Sources
|
||||
|
||||
- Brief: `docs/plans/2026-10-22_fire-revokes-grants.md`, "Brief: no grant outlives its instance" (sage-2568,
|
||||
@@ -37,51 +43,102 @@ and on #1786). The details:
|
||||
grant decision for that agent that hasn't run yet still passes `#hostDefinition` and `#giteaRole`, so it can
|
||||
still apply after the fire. A delete removes the roles entry, so its executor refuses `unknown-role` or
|
||||
`gitea-not-declared`.
|
||||
- **A raise after the fire** (dewey-0823 B1). `#raiseGrant` (broker.mjs:1529) checks only that the caller is
|
||||
the chief, and the grant bindings check only the roles entry. So the chief can raise a new grant for a fired
|
||||
agent, and if the human approves it, it applies.
|
||||
|
||||
## 2. Where the grant revokes sit (D1)
|
||||
## 2. Where the grant revokes sit (D1, rewritten in d4 for B2)
|
||||
|
||||
The grant revokes run in the broker, awaited, inside `#chiefLifecycle`, after step A and before step B:
|
||||
d3 awaited step G inside the verb. Dewey's B2 showed that neither caller can wait that long:
|
||||
|
||||
1. `write('instance.fireCheck')`: the writer's checks against the file, as today. A refusal here revokes
|
||||
nothing.
|
||||
2. Step A: the runs stop and the open requests are cancelled, as today.
|
||||
3. **New, step G:** the grant revokes (section 3). A refusal stops the fire here.
|
||||
4. Step B: `write('instance.fire')`. The writer's checks, then `beforeWrite`, which is `revoke()` for the
|
||||
credentials, then the write. With the grant files gone after step G, `revoke()`'s store-directory check
|
||||
passes.
|
||||
5. Step C: as today.
|
||||
- **Delete.** The launcher's `host.op` waits `REQUEST_TIMEOUT_MS = 10000` (cli/src/host.mjs:31). On a timeout,
|
||||
`breakChannel` stops the whole bus host. One SSH remove can take 30 s (host-grant.mjs:231, :311), and each
|
||||
Gitea call 15 s (`CALL_MS`, gitea-grant.mjs:38).
|
||||
- **Fire.** server.mjs answers a verb that isn't on its async list synchronously (:154), and the async list
|
||||
gives 120 s (:150). The chief's runner client times out at 30 s (harness/src/runner.mjs:449). Two hosts
|
||||
plus the Gitea calls can pass both limits.
|
||||
|
||||
Why not in `beforeWrite` or `revoke()`: both are synchronous and run inside the organization writer, after
|
||||
its checks. A host `remove` is an SSH call and a Gitea revoke is HTTP, so they can't run there. The business
|
||||
package also has no reach into the broker's grant tables, and shouldn't get any.
|
||||
So step G never runs inside a verb. Both verbs stay synchronous and answer at once. Step G runs as tracked
|
||||
broker work after the answer, as row 236's grant executors do (`#track`, `setImmediate`). No caller waits on
|
||||
the network, so `server.mjs`, `process.mjs`, `cli/src/host.mjs` and the launcher don't change.
|
||||
|
||||
Why after step A: a fire is the decision to stop the agent. If step G came first, a host that can't be reached
|
||||
would keep the fired agent running. After step A, a refused step G leaves the same state as a failed step B
|
||||
today: the instance is "firing" (stopped, still listed) until a repeat. Row 194 already handles that state.
|
||||
### The fire
|
||||
|
||||
The repeat fire's own path (webby-0094 N1: an instance already delisted still has its tokens revoked, with no
|
||||
write) also runs step G. Step G sits in the broker before `write('instance.fire')`, so it runs on both paths.
|
||||
1. `write('instance.fireCheck')`, as today.
|
||||
2. Step A, as today, with one addition. In the same transaction, after `fire.stopped`, the grant set is read
|
||||
(section 3). Reading it there means a grant raise either committed before step A, and is in the set, or
|
||||
comes after `fire.stopped` and refuses (section 3d).
|
||||
3. If the set is empty and the agent has no Gitea leftover (section 5), steps B and C run synchronously,
|
||||
exactly as today. A fire with no grant answers today's result with today's events (T7).
|
||||
4. Otherwise the verb takes the agent's step G lock (section 3e), records `fire.revoking {grants}` in step A's
|
||||
transaction, starts the tracked job, and answers at once:
|
||||
`{...stopped, revoking: ['host:<id>' | 'gitea', ...], done: false}`. There's no `transaction`, since
|
||||
step B hasn't run.
|
||||
5. The tracked job runs step G, then step B (`write('instance.fire')`) and step C (`consumeAuthority`,
|
||||
`fire.done` with `grants`). It releases the lock when it ends, whichever way it ends.
|
||||
|
||||
The delete: `lifecycleDelete` (broker.mjs:3293) runs step G after its running and starting checks and before
|
||||
`instances.set delete`. It becomes async. Its one caller, the launcher's `host.op` (launcher.mjs:845), already
|
||||
awaits the reply, and `process.mjs` already returns async ops (`mayLaunch`). A refused step G answers the
|
||||
launcher with the refusal. `remove()` returns `{refusal: e.code}`, and the launcher closes the request through
|
||||
`lifecycleDone` with that code, as it does today for a writer refusal. The request is then closed, so the
|
||||
"repeat" for a delete is the human's new delete request. Grants revoked before the refusal stay revoked, and
|
||||
the new request's step G goes on from there (Q2, ruled by sage-2573 below). Also to check at build time: the launcher's `host.op` reply timeout against an SSH remove and the Gitea calls. If it's
|
||||
shorter than they can take, the design needs a change before code.
|
||||
What the chief sees meanwhile: the instance is "firing". That's an existing state (`#firing`, broker.mjs:645):
|
||||
`fire.stopped` with no later `fire.done`. Starts already refuse `instance-firing`. A repeat fire while the job
|
||||
runs refuses `fire-in-progress`, a new code. A repeat after it finishes takes the delisted path (webby-0094
|
||||
N1) and answers done. A repeat after a refused step G runs step G again (section 5). The human sees the outcome
|
||||
either way: `fire.done` names the grants, and a refusal sends the inbox message in section 4.
|
||||
|
||||
To check at build time: step C's `consumeAuthority(s, ...)` runs after the verb has answered, so the job keeps
|
||||
the fields of `s` that step C needs. It must not depend on the chief's run still being live.
|
||||
|
||||
### The delete
|
||||
|
||||
`lifecycleDelete` (broker.mjs:3293) stays synchronous. After its running and starting checks, it reads the
|
||||
grant set.
|
||||
|
||||
- An empty set: `instances.set delete` as today.
|
||||
- A non-empty set: it takes the lock, starts the same tracked step G with verb `lifecycle.delete`, and refuses
|
||||
`grants-revoking` at once. The launcher closes the request with that code, well inside its 10 s. That's
|
||||
sage-2573's Q2 path: a closed request, and the human raises the delete again.
|
||||
- When the job ends, the human gets an inbox message. Either "<agent>'s grants are revoked: raise the delete
|
||||
again", or section 4's refusal.
|
||||
- A delete raised while the job runs refuses `grants-revoking` again, without starting a second job. The
|
||||
delete raised after the job finds an empty set and goes as today.
|
||||
|
||||
The tradeoff: deleting a granted instance now always takes two requests. The alternatives were to hold the
|
||||
launcher's request open past its 10 s, which would need a host channel change and a strand path, or to make
|
||||
the launcher poll. Two requests changes no other package. **For Sage:** this extends Q2 from "a refused
|
||||
delete is raised again" to "every delete of a granted instance is raised again". Say if that's not
|
||||
acceptable.
|
||||
|
||||
While a delete's step G runs, the instance is still listed and not firing, so it could be started. That's
|
||||
harmless: its grants are being revoked, and the repeat delete refuses `instance-running` as today. No new
|
||||
start check.
|
||||
|
||||
### Why not in `beforeWrite` or `revoke()`
|
||||
|
||||
Both are synchronous and run inside the organization writer, after its checks. A host `remove` is SSH and a
|
||||
Gitea revoke is HTTP, so they can't run there. The business package also has no reach into the broker's grant
|
||||
tables, and shouldn't get any.
|
||||
|
||||
### Why after step A
|
||||
|
||||
A fire is the decision to stop the agent. If step G came first, a host that can't be reached would keep the
|
||||
fired agent running. After step A, a refused step G leaves the same state as a failed step B today: the
|
||||
instance is "firing" (stopped, still listed) until a repeat. Row 194 already handles that state. The repeat
|
||||
fire's delisted path (webby-0094 N1) also runs step G, since the set is read before step B on both paths.
|
||||
|
||||
## 3. Step G: what it revokes, and how
|
||||
|
||||
The set, for (business, agent), read in one store transaction at the start of step G:
|
||||
The set, for (business, agent), is read in one store transaction: step A's for a fire, the delete's checks
|
||||
for a delete (section 2). It holds:
|
||||
|
||||
- each `host_grants` row of the agent;
|
||||
- each host named by an open host grant decision for the agent (approved, with no `grant.applied` or
|
||||
`grant.failed`, and no `grant.revoked` of (agent, host) after it);
|
||||
- each host named by an open host grant decision for the agent;
|
||||
- the Gitea grant if the agent has a `gitea_grants` row, a `gitea_users` row, or an open Gitea grant decision.
|
||||
|
||||
An empty set means step G does nothing and records nothing. A fire of an instance with no grant is then
|
||||
exactly today's fire, with no new events (brief tests, fourth bullet).
|
||||
An open decision (d4, B1) is any grant decision raised for the agent with no outcome yet. That is: not denied,
|
||||
not expired, no `grant.applied` or `grant.failed`, and no `grant.revoked` of the same (agent, host), or of the
|
||||
agent's Gitea, recorded after its raise. A raised decision the human hasn't decided is open. d3 required
|
||||
"approved", which left that one out.
|
||||
|
||||
An empty set means no step G and no new records. A fire of an instance with no grant is then exactly today's
|
||||
fire, with no new events (brief tests, fourth bullet).
|
||||
|
||||
### 3a. A host grant: the host line first (D2)
|
||||
|
||||
@@ -129,6 +186,17 @@ gone (section 5's reconcile). Two changes keep the fire closed:
|
||||
gone, and only the Gitea side is left. That is the case the brief's host rule guards against, and it is
|
||||
kept visible here, not hidden.
|
||||
|
||||
**A Gitea executor in flight** (d3 T6c, and B3 seen from the executor's side). Step G's Gitea part goes in
|
||||
this order:
|
||||
1. Record `grant.revoked {granted: false}` for each open Gitea decision (3c).
|
||||
2. Await this agent's tracked Gitea executors and remote undos, if any are running. Each one ends `revoked` at
|
||||
its next `#giteaRevokedSince` check, or it committed `applied` before step 1.
|
||||
3. Read the `gitea_grants` row again and run the steps above.
|
||||
|
||||
`#giteaCloseRevoke` with `fire: true` lists the user's tokens and deletes every one. The list comes after
|
||||
step 2, so it includes any token an in-flight executor created. `#giteaRuns` is a single Set today. It
|
||||
becomes keyed by (business, agent) so step G can await one agent's executors alone.
|
||||
|
||||
### 3c. Open decisions
|
||||
|
||||
A host named only by an open decision has no record. Step 3a still runs `remove` (a no-op) and records
|
||||
@@ -143,37 +211,100 @@ whenever it runs. The fired agent keeps its roles entry, so `#hostDefinition` an
|
||||
this check is the only thing that stops the decision. T6 and T6b test it. A delete also removes the roles
|
||||
entry, so the executor refuses there as well.
|
||||
|
||||
The same holds for a decision raised but not yet approved (B1). It's in the set, so it gets its
|
||||
`grant.revoked {granted: false}`. If the human approves it later, its executor ends `revoked` (T6d). The
|
||||
decision stays approvable in the inbox. Approving it does nothing but end it, which `grant.failed` reports as
|
||||
it does for any revoked grant.
|
||||
|
||||
### 3d. A raise for a fired agent (d4, B1)
|
||||
|
||||
`#hostGrantBinding` and `#giteaGrantBinding` both run inside the raise transaction (broker.mjs:1539 and
|
||||
:1548; the host raise's keyscan is awaited before that transaction). Both gain one check. The raise refuses
|
||||
`instance-fired` if the agent is:
|
||||
- firing (`#firing`);
|
||||
- fired, by the writer's own rule (instance-settings.mjs, hire): a roles entry, not in `launch.instances`,
|
||||
and not `launch.by`.
|
||||
|
||||
A rehire lists the name again, so a rehired agent can be granted again.
|
||||
|
||||
Because the check is in the transaction that records the decision, there's no gap. A raise that commits
|
||||
before step A's transaction is in the set. One that commits after it refuses. A raise for an agent whose
|
||||
delete's step G is running refuses `grant-revoke-running` (3e).
|
||||
|
||||
No executor recheck is added. A raise can't commit after `fire.stopped`, and every decision raised before it
|
||||
is in the set and gets its `grant.revoked`.
|
||||
|
||||
### 3e. One step G per agent (d4, B3)
|
||||
|
||||
The broker keeps `#revoking`, a map from (business, agent) to the verb whose step G is running. The verb sets
|
||||
it synchronously, in the same turn as the set read, before it answers. The job clears it in a `finally`.
|
||||
While it's set:
|
||||
|
||||
| Caller | Answer |
|
||||
|---|---|
|
||||
| `instance.fire` | refuses `fire-in-progress` |
|
||||
| `lifecycleDelete` | refuses `grants-revoking`, no second job |
|
||||
| `hosts.revoke` / `grants.revoke` for the agent | refuse `grant-revoke-running` |
|
||||
| grant raise for the agent | refuses `grant-revoke-running` |
|
||||
| `grants.sweep` | skips this agent's entries and names it as `running` in its result |
|
||||
|
||||
Two fires started together (the chief retrying after a client timeout): the first takes the lock before it
|
||||
answers, and the second refuses. The map is in memory. After a broker restart the job is gone and so is the
|
||||
lock. The instance is still firing, or the delete still has a non-empty set, and the repeat starts a fresh
|
||||
step G (section 5).
|
||||
|
||||
A manual revoke can't race step G, since it refuses while the lock is held. Step G's transactions still
|
||||
check what they read: a `host_grants` row already gone at 3a step 3 records `grant.revoked {granted: false}`
|
||||
and goes on, as hosts.revoke does today for a revoke with no record.
|
||||
|
||||
## 4. A refusal (D4)
|
||||
|
||||
Step G stops at the first grant that can't be revoked. Grants already revoked in this step G stay revoked and
|
||||
recorded. The rest stay as they were. The fire refuses `grant-revoke-failed`, a new code. The organization
|
||||
file is unchanged, since step B never ran, and the credentials are unrevoked, since `revoke()` never ran.
|
||||
recorded, and the rest stay as they were. The job doesn't run step B: the organization file is unchanged, and
|
||||
the credentials are unrevoked, since `revoke()` never ran. A fire stays "firing". A delete's request was
|
||||
already closed with `grants-revoking` (section 2).
|
||||
|
||||
`BusError` carries only a code, so the names go in one grant-evidence event recorded before the throw:
|
||||
`grant.revoke.refused {agent, grant: 'host:<id>' | 'gitea', reason, verb: 'instance.fire' | 'lifecycle.delete',
|
||||
by}`. Its `reason` is one of `host-unknown`, `host-remove-failed`, `gitea-admin-unavailable` and
|
||||
`gitea-left:<ids>`. A message lands in the human's inbox, as `grant.failed` does today (broker.mjs:1139):
|
||||
"Fire of <agent> stopped: its grant on <host> couldn't be revoked (<reason>). Run the fire again once <host>
|
||||
is reachable." The refusal record has the verb and args, as every refusal does.
|
||||
The verb has already answered, so the refusal is a record, not a thrown code:
|
||||
- One grant-evidence event, `grant.revoke.refused {agent, grant: 'host:<id>' | 'gitea', reason, verb:
|
||||
'instance.fire' | 'lifecycle.delete', by}`.
|
||||
- `reason` is one of `host-unknown`, `host-remove-failed`, `gitea-admin-unavailable` and `gitea-left:<ids>`.
|
||||
- `grant-revoke-failed` is the code in the event and in the inbox message.
|
||||
|
||||
A message lands in the human's inbox, as `grant.failed` does today (broker.mjs:1139). It names the instance,
|
||||
the grant and the reason, with the manual way out beside the retry (N2):
|
||||
|
||||
> Fire of <agent> stopped: its grant on <host> couldn't be revoked (<reason>). Run the fire again once <host>
|
||||
> is reachable. If <host> won't come back, run `mosaic hosts revoke <agent> <host>` first: the grant record
|
||||
> goes, the line is named as left, and the next fire completes and names that line (section 5).
|
||||
|
||||
The delete's message has the same shape, with "raise the delete again". For Gitea, the way out is
|
||||
`mosaic grants sweep gitea` once Gitea is back.
|
||||
|
||||
A job that throws something unexpected records `grant.revoke.refused` with reason `internal`, sends the same
|
||||
message, and releases the lock. There's no unhandled rejection.
|
||||
|
||||
## 5. A repeat fire (D5)
|
||||
|
||||
A repeat runs the whole fire again: the checks, step A (nothing left to stop), step G, B and C.
|
||||
A repeat runs the whole fire again: the checks, step A (nothing left to stop), the set read, then step G, B
|
||||
and C in the job. A repeat while the job runs refuses `fire-in-progress` (3e). A repeat delete works the same
|
||||
way through the human's new request.
|
||||
|
||||
- A host grant already revoked has no row and no open decision, so it isn't in the set. It's done.
|
||||
- A host grant that refused still has its row, so it's retried from 3a step 1.
|
||||
- Gitea: if the set is empty but the agent has an entry in `#giteaLeft`, or an open Gitea revoke, step G runs
|
||||
`#sweepGitea` for the business first and then checks `#giteaLeft` again. Any entry still there for this
|
||||
agent refuses `gitea-left`. The sweep is the human's verb, and running it here records `grant.sweep` and
|
||||
`grant.swept` with `by` set to the firing chief. That's an internal caller of an existing method, not a new
|
||||
verb. An entry left by an earlier manual `mosaic grants revoke` of the same agent blocks the fire the same
|
||||
way. That's intended: no grant outlives its instance, whichever verb started its revoke.
|
||||
- Gitea (d4, N1): if the agent has entries in `#giteaLeft` or `#giteaOpenRevokes`, step G retries only those.
|
||||
It calls `#giteaCloseRevoke` for each, as `#sweepGitea` does per entry. It doesn't run the business-wide
|
||||
sweep, which would retry every agent's entries and settle every open rotation under the chief's name. Any
|
||||
entry still left for this agent refuses `gitea-left`. An entry left by an earlier manual
|
||||
`mosaic grants revoke` of the same agent blocks the fire the same way. That's intended: no grant outlives
|
||||
its instance, whichever verb started its revoke. These entries count toward a non-empty set (section 2,
|
||||
fire step 3), so they go to the job and never run inside the verb.
|
||||
- A host line left by an earlier manual `hosts.revoke` doesn't block the fire (Q1 below). These are the
|
||||
agent's `grant.revoke.closed` records with `hostLineLeft` and no later line removal for that tag. For each
|
||||
one whose host record still exists, step G sends `remove <user> <tag>` once. On success it records
|
||||
`grant.revoke.closed {revoke: <the old revoke id>, hostLine: 'removed', fire: true}`, so a later fire doesn't
|
||||
find it again. On failure it records nothing new and the fire goes on. The host and the tag go in
|
||||
`grantsLeft` (section 6).
|
||||
`grantsLeft` (section 6). These leftover lines also make the set non-empty, so the retry runs in the job,
|
||||
inside B2's answer (dewey-0823 accepted the single retry on that condition).
|
||||
|
||||
## Rulings
|
||||
|
||||
@@ -191,10 +322,13 @@ A repeat runs the whole fire again: the checks, step A (nothing left to stop), s
|
||||
|
||||
## 6. The result and the event (D6)
|
||||
|
||||
The fire's result and the delete's done record gain `grants: [{grant: 'host:<id>' | 'gitea', revoke,
|
||||
granted}]` beside `credentials`. Any leftover from 5's last bullet goes in `grantsLeft`. `fire.done` (step
|
||||
C) carries the same `grants` list, so the event the human sees names them. A fire with an empty step G
|
||||
carries neither key, so its shape is exactly today's.
|
||||
- **A fire with a non-empty set** answers at once with `revoking` and `done: false` (section 2).
|
||||
- **The job's outcome** is recorded, not returned. `fire.done` (step C) carries `grants: [{grant:
|
||||
'host:<id>' | 'gitea', revoke, granted}]`, plus `grantsLeft` for any leftover from section 5's last bullet.
|
||||
So the event the human sees names them.
|
||||
- **A delete's job** records `grants.revoked.done {agent, verb: 'lifecycle.delete', grants, grantsLeft}` and
|
||||
sends the inbox message in section 2.
|
||||
- **A fire with an empty set** carries none of these keys, so its shape is exactly today's.
|
||||
|
||||
## 7. What doesn't change (brief item 4)
|
||||
|
||||
@@ -203,56 +337,116 @@ carries neither key, so its shape is exactly today's.
|
||||
- No new verb, no Console route, no CLI change, no schema change. Every new record is an `events` row in the
|
||||
existing grant-evidence shape.
|
||||
- `revoke()` in runtime.mjs and the organization writer don't change.
|
||||
- `server.mjs`, `process.mjs`, `cli/src/host.mjs` and `cli/src/launcher.mjs` don't change. Both verbs stay
|
||||
synchronous. The launcher already closes a request with whatever code `lifecycleDelete` refuses.
|
||||
- New codes: `fire-in-progress`, `grants-revoking`, `grant-revoke-running`, `instance-fired`, and
|
||||
`grant-revoke-failed` in events and messages. `lifecycleDone` checks a refusal against `LIFECYCLE_CODE =
|
||||
/^[a-z-]{1,64}$/` (broker.mjs:149), and `grants-revoking` matches it.
|
||||
|
||||
## 8. Tests (each in the owning package, whole files)
|
||||
|
||||
Bus, with the fake installer and the fake Gitea the row 235 and 236 tests use:
|
||||
The bus tests use the fake installer and the fake Gitea from rows 235 and 236. They wait on the job with a new
|
||||
trusted test API, `grantsIdle()`, which resolves once every step G job and Gitea executor has ended. It's the
|
||||
same shape as `giteaIdle()`. Unless a test says otherwise, every fire or delete below is awaited that way.
|
||||
|
||||
- T1 (pins section 1 before the change): a fire of an instance with a host grant refuses
|
||||
`credential-revoke-failed` today. A delete leaves the `host_grants` row. Then: what the next start does with
|
||||
a record whose key file is gone.
|
||||
- T2: a fire with a host grant on two hosts and a Gitea grant revokes all three. Each gets `grant.revoked` and
|
||||
`grant.revoke.closed` as the manual verbs record them, `fire: true`, the line gone on both fake hosts, every
|
||||
token deleted and login prohibited at the fake Gitea. Afterwards that agent's `host.run` and Gitea use both
|
||||
refuse.
|
||||
- T3: the same through the delete (`lifecycleTake` then `lifecycleDelete`).
|
||||
- T3b (Q2): a delete refused at step G closes its request with `grant-revoke-failed`. The file and the
|
||||
instance are unchanged, and `grant.revoke.refused` names the instance, the grant and the reason. A new
|
||||
delete request then completes and skips the grants the first one revoked.
|
||||
- T2: a fire with a host grant on two hosts and a Gitea grant. The verb answers `revoking` with all three and
|
||||
`done: false`. After the job:
|
||||
- all three are revoked, each with `grant.revoked` and `grant.revoke.closed` as the manual verbs record
|
||||
them, plus `fire: true`;
|
||||
- the line is gone on both fake hosts, every token is deleted, and login is prohibited at the fake Gitea;
|
||||
- `fire.done` names all three;
|
||||
- that agent's `host.run` and Gitea use both refuse.
|
||||
- T3: the same through the delete (`lifecycleTake` then `lifecycleDelete`). The first delete refuses
|
||||
`grants-revoking` and starts the job. The launcher's pass closes the request with that code. After the job
|
||||
the human's inbox says to raise the delete again. A second request completes as today.
|
||||
- T3b (Q2): a delete whose job refuses (host B unreachable). The first request closes `grants-revoking`. The
|
||||
file and the instance are unchanged, and `grant.revoke.refused` and the inbox message name the instance,
|
||||
`host:B` and the reason. With B back, a new request starts a job that skips the grants already revoked, and
|
||||
the request after that completes.
|
||||
- T4: host B unreachable in a fire. The job records `grant.revoke.refused` naming the agent, `host:B` and
|
||||
`host-remove-failed`, and the inbox message names `mosaic hosts revoke` (N2). Afterwards:
|
||||
- the file is unchanged and the instance is still firing;
|
||||
- host A is revoked and recorded, and host B's row and key file are still there;
|
||||
- the Gitea grant is untouched, since it comes after B in the order;
|
||||
- the credentials are unrevoked.
|
||||
A repeat with B reachable completes and revokes only B and Gitea.
|
||||
- T4b (Q1): an earlier manual `hosts.revoke` left a line on host C. A fire with C reachable removes it and
|
||||
records the close. With C unreachable, the fire completes and names C and the tag in `grantsLeft` and in
|
||||
`fire.done`.
|
||||
- T4: host B unreachable. The fire refuses `grant-revoke-failed`, and `grant.revoke.refused` names the agent,
|
||||
`host:B` and `host-remove-failed`. The file is unchanged. Host A is revoked and recorded, host B's row and key
|
||||
file are still there, the Gitea grant is untouched (it comes after B in the order), and the credentials are
|
||||
unrevoked. A repeat with B reachable completes and revokes only B and Gitea.
|
||||
- T5: Gitea's admin token can't be checked. The fire refuses with the Gitea grant intact.
|
||||
- T5b: a token DELETE fails. The fire refuses `gitea-left`. A repeat with Gitea back sweeps and completes.
|
||||
- T5: Gitea's admin token can't be checked. The job refuses with the Gitea grant intact.
|
||||
- T5b: a token DELETE fails. The job refuses `gitea-left`. A repeat with Gitea back retries only this agent's
|
||||
entries and completes. Another agent's left entry is still left afterwards (N1).
|
||||
- T6 (sage-2574): a host grant decision approved before the fire, executed after it. The fire records
|
||||
`grant.revoked {granted: false}` for (agent, host). The executor runs after `fire.done` and ends the decision
|
||||
`revoked`: no `host_grants` row, no key file, no `add` sent to the host.
|
||||
- T6b (sage-2574): the same for a Gitea grant decision. The executor ends it `revoked` before any Gitea call:
|
||||
no `gitea_grants` row, no token file, and the fake Gitea saw no token create.
|
||||
- T6c: a host or Gitea executor already in flight when step G starts. The host side aborts its claim (step
|
||||
3a.1) and ends `revoked`. The Gitea side ends `revoked` at its step 8 check. Build-time check: a token the
|
||||
in-flight executor created before that check must be deleted, or named in `serviceLeft`, and not left live.
|
||||
- T7: an instance with no grant fires exactly as today. Same result keys, and the same events, counted.
|
||||
- T6c: a host or Gitea executor already in flight when step G starts.
|
||||
- The host side aborts its claim (3a step 1) and ends `revoked`.
|
||||
- The Gitea side: step G awaits the executor (3b), and then the fake Gitea holds no token for the user. That
|
||||
includes one the executor created mid-flight.
|
||||
- T6d (B1): a host decision and a Gitea decision, raised before the fire and still undecided. The fire puts
|
||||
both in the set. The human approves both after `fire.done`, and both executors end `revoked`, with nothing
|
||||
applied.
|
||||
- T6e (B1): a host raise and a Gitea raise refuse `instance-fired`:
|
||||
- while the instance is firing;
|
||||
- after `fire.done`.
|
||||
After a rehire of the same name, a raise goes through. While a delete's job runs, a raise refuses
|
||||
`grant-revoke-running`.
|
||||
- T7: an instance with no grant fires exactly as today, synchronously. Same result keys, no `revoking`, and
|
||||
the same events, counted.
|
||||
- T8: hosts.revoke and grants.revoke keep their records, their order and their tester refusals.
|
||||
- T9 (B3): with the job held on a remove:
|
||||
- a second fire refuses `fire-in-progress`;
|
||||
- a delete of the same agent refuses `grants-revoking` and starts no second job;
|
||||
- `hosts.revoke` and `grants.revoke` for the agent refuse `grant-revoke-running`;
|
||||
- `grants.sweep` names the agent as `running`.
|
||||
After a release, one set of `grant.revoked` records exists per grant.
|
||||
- T10 (B2): the fake installer holds a remove and the fake Gitea holds a DELETE, neither answering until
|
||||
released.
|
||||
- `instance.fire` and `lifecycleDelete` both answer before either is released. The test checks this with a
|
||||
timer, not a wall-clock bound.
|
||||
- Meanwhile the broker answers another verb from another session.
|
||||
- After release, the jobs complete.
|
||||
- A cli test drives the launcher's delete pass against a bus whose remove is held. The pass closes the
|
||||
request `grants-revoking` and the host channel stays up.
|
||||
|
||||
Business: none expected, since the writer doesn't change. Cli: the launcher's delete pass with a refused step
|
||||
G closes the request with `grant-revoke-failed`, if the launcher's tests stub `lifecycleDelete`.
|
||||
Files (N3). Every file listed runs whole in the manifest round, touched or not:
|
||||
- New: `packages/bus/tests/fire-revokes-grants.test.mjs` holds T2-T10.
|
||||
- Changed:
|
||||
- `grant-host.test.mjs` and `gitea-grant.test.mjs`: T1's pins, and the T8 regressions next to their
|
||||
existing cases;
|
||||
- `packages/cli/tests/lifecycle.test.mjs`: the T10 launcher pass;
|
||||
- `fake-gitea-api.mjs` and `host-fixtures.mjs`, if T10's holds need a hook there.
|
||||
- Run whole because they call `instance.fire` or `lifecycleDelete` at 47a35bf43:
|
||||
- bus: `chief-of-staff`, `lifecycle-revoke`, `lifecycle-verbs`, `reload`;
|
||||
- cli: `lifecycle`, `instances`;
|
||||
- harness: `tools`.
|
||||
- Also run: `grant-executor.test.mjs` (the executors' `#giteaRuns` change) and `gitea-grant-docker.test.mjs`
|
||||
(brief gate).
|
||||
- `server.mjs`, `process.mjs`, `host.mjs` and `launcher.mjs` are unchanged, so no test changes there for this
|
||||
row.
|
||||
|
||||
## 9. Mutants
|
||||
|
||||
- M1: step G skips the host revokes. Killed by T2.
|
||||
- M2: step G skips the Gitea revoke. Killed by T2.
|
||||
- M3: a refused grant revoke is ignored and the fire goes on to step B. Killed by T4.
|
||||
- M3: a refused grant revoke is ignored and the job goes on to step B. Killed by T4.
|
||||
- M4: `lifecycleDelete` skips step G. Killed by T3.
|
||||
- M5: the fire's host revoke goes local first (`lineFirst: false`). Killed by T4, because B's row is gone.
|
||||
- M6: a Gitea revoke with something in `serviceLeft` counts as done. Killed by T5b.
|
||||
- M7: open decisions left out of the set. Killed by T6 and T6b.
|
||||
- M8: step G skips the leftover-line retry. Killed by T4b.
|
||||
- M9: a failed leftover-line retry refuses the fire. Killed by T4b.
|
||||
- M10: the set takes only approved decisions (d3's rule). Killed by T6d.
|
||||
- M11: the raise bindings skip the fired check. Killed by T6e.
|
||||
- M12: no `#revoking` lock. Killed by T9.
|
||||
- M13: the verb awaits the job before it answers. Killed by T10.
|
||||
- M14: step G's Gitea part doesn't await the in-flight executor. Killed by T6c.
|
||||
- M15: step G's Gitea retry runs the business-wide `#sweepGitea`. Killed by T5b.
|
||||
|
||||
## 10. Order and rebase
|
||||
|
||||
|
||||
Reference in new issue
Block a user