Judge the grants step on the declaration it sends, so nothing composed between the check and the send slips through (review of #230)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestTheGrantsStepSendsTheUserListAndNothingElse (2.55s)

The guard composed its own declaration ahead of the send; now sendToEach
judges the one it composed and stamped, and refuses it unsent.
This commit is contained in:
2026-10-11 19:12:33 +02:00
parent 538c4d5115
commit 78205d7405
4 changed files with 96 additions and 80 deletions
+48 -37
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
@@ -65,31 +66,50 @@ func TestTheGrantsStepSendsTheUserListAndNothingElse(t *testing.T) {
}
}
// What anchor was last sent: everything at the builds of before the merge, kept as a send keeps it.
sendAnchor := func() {
wasEpoch := epochForActs
epochForActs = func(context.Context) (uint64, error) { return 7, nil }
t.Cleanup(func() { epochForActs = wasEpoch })
// A send to anchor composed as sendToEach composes it: numbered, planned, declared, stamped.
compose := func(under context.Context) (catalogue.Resolution, sendable) {
t.Helper()
plan, settings, err := planFor(keepingRecorded(ctx), open, "anchor")
numbered, err := allot(under, inv, "anchor")
if err != nil {
t.Fatal(err)
}
gens, err := generators(ctx, open)
plan, settings, err := planFor(under, open, "anchor")
if err != nil {
t.Fatal(err)
}
declared, err := declarationWith(keepingRecorded(ctx), open, "anchor", plan, settings, gens, Allocating)
gens, err := generators(under, open)
if err != nil {
t.Fatal(err)
}
declared, err := declarationWith(under, open, "anchor", plan, settings, gens, Allocating)
if err != nil {
t.Fatal(err)
}
numbered.stamp(&declared)
return plan, declared
}
record := func(declared sendable) {
t.Helper()
body, err := declared.Body()
if err != nil {
t.Fatal(err)
}
if _, err := recordSent(ctx, inv, "anchor", body, declared.Builds, 0); err != nil {
if _, err := recordSent(ctx, inv, "anchor", body, declared.Builds, declared.Epoch,
sentRecordOf(ctx, declared)); err != nil {
t.Fatal(err)
}
}
sendAnchor()
if only, err := grantsOnly(ctx, open, "anchor"); err != nil || only != "" {
kept := keepingEveryBuild(keepingRecorded(ctx))
// What anchor was last sent: everything at the builds of before the merge, as an ordinary send sends it.
_, before := compose(keepingRecorded(ctx))
record(before)
plan, declared := compose(kept)
if only, err := grantsOnlyIn(ctx, open, "anchor", plan, declared); err != nil || only != "" {
t.Fatalf("with nothing changed, the step reads %q, %v", only, err)
}
@@ -100,62 +120,53 @@ func TestTheGrantsStepSendsTheUserListAndNothingElse(t *testing.T) {
}
// The step's send to anchor: restic as anchor runs it, c1, and the user list granting c2's tool.
kept := keepingEveryBuild(keepingRecorded(ctx))
plan, settings, err := planFor(kept, open, "anchor")
generation, err := inv.AssignmentGeneration(ctx)
if err != nil {
t.Fatal(err)
}
plan, declared = compose(kept)
for _, m := range plan.Modules {
if m.Module == "restic" && len(m.Tools) > 0 {
t.Fatalf("the grants step composed restic's new build on anchor: tools %v", m.Tools)
}
}
list, _, err := busUserList(ctx, inv, plan.Modules)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(list, "mesh.mod.restic.tool.backup_health.laptop") {
t.Errorf("the user list the step sends does not grant laptop's node-engine restic's new tool:\n%s", list)
}
gens, err := generators(kept, open)
if err != nil {
t.Fatal(err)
}
declared, err := declarationWith(kept, open, "anchor", plan, settings, gens, Allocating)
if err != nil {
t.Fatal(err)
}
if declared.Builds["restic"] != "c1" {
t.Fatalf("the step's send records it carries restic %q; want c1, which anchor runs", declared.Builds["restic"])
}
if !strings.Contains(declared.BusUsers, "mesh.mod.restic.tool.backup_health.laptop") {
t.Error("the step's declaration does not carry the new grant")
t.Errorf("the step's declaration does not grant laptop's node-engine restic's new tool:\n%s", declared.BusUsers)
}
// Recorded as a send records it: anchor still runs restic c1, and its gate is still to come there.
if err := inv.RecordSent(ctx, nodeID(t, open, "anchor"), "d-grants", declared.Builds); err != nil {
t.Fatal(err)
// The generation it carries is the current one (novox/hq issue 234), and the step does not raise it.
if declared.composedFrom != generation {
t.Errorf("the step's send was composed from generation %d; the mesh is at %d", declared.composedFrom, generation)
}
if sent, _, err := inv.SentBuilds(ctx, "anchor"); err != nil || sent["restic"] != "c1" {
t.Fatalf("after the step anchor reads as sent restic %q (%v); want c1", sent["restic"], err)
}
// The guard: only the user list differs from what anchor was last sent.
if only, err := grantsOnly(ctx, open, "anchor"); err != nil || only != "" {
// The guard, on the very declaration the send sends: only the user list differs from the last send.
if only, err := grantsOnlyIn(ctx, open, "anchor", plan, declared); err != nil || only != "" {
t.Fatalf("the step reads as changing more than the user list: %q, %v", only, err)
}
// And the gate's refusal still reads the step's send: it moves nothing there.
if names, err := ungatedIn(kept, open, []string{"anchor"}, ""); err != nil || strings.Join(names, ",") != "anchor" {
t.Fatalf("the step's send is refused as a move: %v, %v", names, err)
}
// Recorded as the send records it: anchor still runs restic c1, its gate still to come there.
record(declared)
if sent, _, err := inv.SentBuilds(ctx, "anchor"); err != nil || sent["restic"] != "c1" {
t.Fatalf("after the step anchor reads as sent restic %q (%v); want c1", sent["restic"], err)
}
if after, err := inv.AssignmentGeneration(ctx); err != nil || after != generation {
t.Fatalf("the step moved the assignment generation from %d to %d (%v)", generation, after, err)
}
// A module newly assigned to anchor: the step would install it, unjudged, so it is not sent.
// A module newly assigned to anchor: the step would install it, unjudged, so it is refused unsent.
if _, err := inv.Assign(ctx, "anchor", "wallpaper"); err != nil {
t.Fatal(err)
}
only, err := grantsOnly(ctx, open, "anchor")
plan, declared = compose(kept)
only, err := grantsOnlyIn(ctx, open, "anchor", plan, declared)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(only, "wallpaper newly assigned") || !strings.Contains(only, "wallpaper") {
if !strings.Contains(only, "wallpaper newly assigned") || !strings.Contains(only, "+wallpaper") {
t.Fatalf("a new assignment on the bus's machine reads %q; want the step refused, naming it", only)
}
}
+9 -10
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
@@ -32,9 +33,8 @@ type aSend struct {
func sendsRecorded(t *testing.T, holder string, behind bool, refuse error) *[]aSend {
t.Helper()
var sends []aSend
wasSend, wasBehind, wasOnly := sendRollout, brokerBehindOf, grantsOnlyOf
t.Cleanup(func() { sendRollout, brokerBehindOf, grantsOnlyOf = wasSend, wasBehind, wasOnly })
grantsOnlyOf = func(context.Context, *stores, string) (string, error) { return "", nil }
wasSend, wasBehind := sendRollout, brokerBehindOf
t.Cleanup(func() { sendRollout, brokerBehindOf = wasSend, wasBehind })
brokerBehindOf = func(_ context.Context, _ *stores, names []string) (string, bool, error) {
if slices.Contains(names, holder) {
return holder, false, nil
@@ -123,18 +123,17 @@ func TestAGrantsStepThatFailsIsSaidAndTheSendGoesOn(t *testing.T) {
}
}
// A step whose send would change more than the user list is not sent: it fails, said, and the walk goes on.
// A step whose send would change more than the user list is refused unsent by the send (sendToEach): the
// gate says it FAILED with what differed, and the walk goes on.
func TestAGrantsStepThatWouldCarryMoreIsNotSent(t *testing.T) {
sends := sendsRecorded(t, "novox", true, nil)
grantsOnlyOf = func(context.Context, *stores, string) (string, error) {
return "its send would change more than the bus's user list: -postgres.login-n8n", nil
}
refusal := fmt.Errorf("%w: its send would change more than the bus's user list: -postgres.login-n8n", errNotGrantsOnly)
sends := sendsRecorded(t, "novox", true, refusal)
sent, grants, err := sendJudged(t.Context(), nil, "ace")
if err != nil {
t.Fatal(err)
}
if len(*sends) != 1 || (*sends)[0].keepsAll || strings.Join(sent, ",") != "ace" {
t.Errorf("the walk made %+v, want ace's judged send alone", *sends)
if strings.Join(sent, ",") != "ace" || len(*sends) != 2 {
t.Errorf("the walk made %+v, want the refused step and then ace's judged send", *sends)
}
if grants == nil || grants.At != nil || !strings.Contains(grants.Failed, "-postgres.login-n8n") {
t.Fatalf("the gate keeps %+v", grants)
+11
View File
@@ -1236,6 +1236,17 @@ func sendToEach(ctx context.Context, open *stores, names []string) ([]string, er
continue
}
numbered.stamp(&declared)
// **The grants step sends the user list and nothing else** (novox/hq issue 490): judged on this very
// declaration, the one sent, so nothing composed between a check and the send can slip through.
if everyBuildKept(ctx) {
other, err := grantsOnlyIn(ctx, open, name, plan, declared)
if err != nil {
return nil, err
}
if other != "" {
return nil, fmt.Errorf("%w: %s", errNotGrantsOnly, other)
}
}
reportLeftOut(name, declared)
sending = append(sending, readyNode{name, declared})
}
+28 -33
View File
@@ -968,19 +968,6 @@ func grantsStep(ctx context.Context, open *stores, node string) *inventory.Grant
// list is the one already sent.
return nil
}
// **The user list and nothing else**: checked before it is sent, against what the machine was last sent.
// Kept builds are not the whole of a declaration — a new assignment, a settings change, an assignment
// taken away, a provision's logins are composed as the mesh holds them now — and a step that carried any
// of it would be the unjudged change this step exists to avoid.
if other, err := grantsOnlyOf(ctx, open, holder); err != nil || other != "" {
why := other
if err != nil {
why = err.Error()
}
fmt.Printf("grants step: %s is not sent its user list ahead of %s, which is sent without it: %s\n",
holder, node, why)
return &inventory.GrantsStep{Node: holder, Failed: why}
}
if _, err := sendRollout(keepingEveryBuild(withScope(ctx, sendScope{})), open, []string{holder}); err != nil {
fmt.Printf("grants step: the bus's user list could not be sent to %s ahead of %s, which is sent without "+
"it — the bus may refuse what the send newly grants: %v\n", holder, node, err)
@@ -992,21 +979,19 @@ func grantsStep(ctx context.Context, open *stores, node string) *inventory.Grant
return &inventory.GrantsStep{Node: holder, At: &now}
}
// grantsOnlyOf is grantsOnly: a variable so a test of the walk needs no store.
var grantsOnlyOf = grantsOnly
// errNotGrantsOnly is a grants step refused unsent: its declaration would change more than the user list.
var errNotGrantsOnly = errors.New("the grants step would change more than the bus's user list, and is not sent")
// grantsOnly is what the grants step's send to the machine holding the bus would change there besides its
// user list, against what it was last sent (novox/hq issue 490); empty when nothing else. Composed as the
// step's send composes it, without allocating anything (Foreseeing), and compared resource by resource
// with the summary the last send kept (ADR 0217): exact, rather than a list of the cases that can differ.
// A module there that was never sent is a new assignment, and refused too, whatever its resources.
func grantsOnly(ctx context.Context, open *stores, holder string) (string, error) {
// grantsOnlyIn is what a grants step's declaration, composed for sending, would change on the machine holding
// the bus besides its user list, against what it was last sent (novox/hq issue 490); empty when nothing else.
// Judged by sendToEach on the very declaration it then sends — one composition, judged, then sent or refused
// unsent. Kept builds are not the whole of a declaration: a new assignment, a settings change, an assignment
// taken away, a provision's logins are composed as the mesh holds them now, and a step that carried any of
// it would be the unjudged change this step exists to avoid. Compared resource by resource with the summary
// the last send kept (ADR 0217): exact, rather than a list of the cases that can differ. A module there that
// was never sent is a new assignment, refused whatever its resources.
func grantsOnlyIn(ctx context.Context, open *stores, holder string, plan catalogue.Resolution, declared sendable) (string, error) {
inv := open.inventory
kept := keepingEveryBuild(keepingRecorded(ctx))
plan, settings, err := planFor(kept, open, holder)
if err != nil {
return "", err
}
sent, known, err := inv.SentBuilds(ctx, holder)
if err != nil {
return "", err
@@ -1024,13 +1009,22 @@ func grantsOnly(ctx context.Context, open *stores, holder string) (string, error
listID = m.Module + "." + catalogue.BusUsersID()
}
}
gens, err := generators(kept, open)
if err != nil {
return "", err
}
declared, err := declarationWith(kept, open, holder, plan, settings, gens, Foreseeing)
if err != nil {
return "", err
var facts *moveFacts
for m, was := range sent {
now, carried := declared.Builds[m]
if !carried || sameCommit(now, was) {
continue
}
if facts == nil {
f, err := readMoveFacts(ctx, inv)
if err != nil {
return "", err
}
facts = &f
}
if !facts.identical(m, was, now) {
other = append(other, fmt.Sprintf("%s %s → %s", m, short(was), short(now)))
}
}
for _, f := range declared.foreseen {
other = append(other, f+" would be minted")
@@ -1067,6 +1061,7 @@ func grantsOnly(ctx context.Context, open *stores, holder string) (string, error
}
other = append(other, fmt.Sprintf("~%s (%s)", c.ID, strings.Join(c.Fields, ", ")))
}
sort.Strings(other)
if len(other) == 0 {
return "", nil
}