The controller asks no build of anybody (ADR 0501 §5, ADR 0525 clause 6, ADR 0503 §3). Every module of a walk is taken from the builder's done key; a key not done yet waits, named in the plan, and never fails it. - C2b/C2c: internal/builder and cmd/mesh-controller's asks go (askABuild, onlyTheBuildersOwn, buildOneAsked, takeIn from an outcome, buildAgentsOwn, the builder's say and its fallback, the daemon hearing an outcome). The server follows the retired seats' built subjects only to let go of them, because the installer's carried user list names them. - C2d: replay, queue, cancel, clear, kill, pause, resume, delivery-check, check-here and merge-gate are refused by name. - C3: the check queues (CHECKS_READY, CHECKS_OTHER) and their workers, the checks-running and plans-building buckets and their grants, the build queue and its hold (plans_building.go, queue.go, link/holds.go, builds_nats.go, checks_nats.go). - C4: merge_gate*.go, the pull request's repo-check path, the Checked event and its layers; a pull request asks the controller nothing but to tell mesh-config what its head declares. - C5: the builds bucket is written only by key_takein. - C10 (help and refusals): the terminal's help lists only what is served. Integration tests of the plan's tiers, sends, stops and retries now run on a stand-in builder with a done key for every module (walk_keys_scaffold_integration_test.go); the tests of the asks, the queue, the merge gate and the replays are deleted.
177 lines
7.0 KiB
Go
177 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// The grants reach the bus before the gate (novox/hq issue 490).
|
|
//
|
|
// On 2026-10-11 a merge gave restic, assigned to every machine, a new health tool. Its walk sent restic to
|
|
// its first machine and judged it there; that machine's node-engine asked the new tool and the bus refused
|
|
// it, because the grant was in the bus's user list, which only the machine holding the bus carries — and
|
|
// that machine runs restic too, so it was left out of the send while restic's new build waited there for
|
|
// the very gate that could not pass. A person pushed it by hand, carrying restic's new build there unjudged.
|
|
|
|
// aSend is one send the walk made: to which machines, and whether every build there was kept.
|
|
type aSend struct {
|
|
names []string
|
|
keepsAll bool
|
|
judged []string
|
|
}
|
|
|
|
// sendsRecorded replaces the walk's send and the reading of the bus's user list for one test: the machine
|
|
// holding the bus is novox, and its list is behind as behind says.
|
|
func sendsRecorded(t *testing.T, holder string, behind bool, refuse error) *[]aSend {
|
|
t.Helper()
|
|
var sends []aSend
|
|
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
|
|
}
|
|
return holder, behind, nil
|
|
}
|
|
sendRollout = func(ctx context.Context, _ *stores, names []string) ([]string, error) {
|
|
s := aSend{names: append([]string(nil), names...), keepsAll: len(names) == 1 && everyBuildKept(ctx, names[0])}
|
|
for n := range scopeOf(ctx).judged {
|
|
s.judged = append(s.judged, n)
|
|
}
|
|
sends = append(sends, s)
|
|
if refuse != nil && s.keepsAll {
|
|
return nil, refuse
|
|
}
|
|
return names, nil
|
|
}
|
|
return &sends
|
|
}
|
|
|
|
// The restic shape: a new tool on a module on every machine, its first machine ace, the bus on novox.
|
|
func TestAWalkSendsTheGrantsToTheBusBeforeTheFirstMachineIsJudged(t *testing.T) {
|
|
sends := sendsRecorded(t, "novox", true, nil)
|
|
sent, grants, err := sendJudged(t.Context(), nil, "ace")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*sends) != 2 {
|
|
t.Fatalf("the walk made %d send(s), want the grants step and then the first machine: %+v", len(*sends), *sends)
|
|
}
|
|
first, then := (*sends)[0], (*sends)[1]
|
|
if strings.Join(first.names, ",") != "novox" || !first.keepsAll || len(first.judged) != 0 {
|
|
t.Errorf("the first send is %+v, want novox alone, every build there kept, judging nothing", first)
|
|
}
|
|
if strings.Join(then.names, ",") != "ace" || then.keepsAll || strings.Join(then.judged, ",") != "ace" {
|
|
t.Errorf("the second send is %+v, want ace, judged", then)
|
|
}
|
|
if strings.Join(sent, ",") != "ace" {
|
|
t.Errorf("the gate's machines are %v, want ace alone: the bus's machine is not judged", sent)
|
|
}
|
|
if grants == nil || grants.Node != "novox" || grants.At == nil || grants.Failed != "" {
|
|
t.Fatalf("the gate keeps %+v as its grants step", grants)
|
|
}
|
|
line := gateLine(&inventory.PlanGate{Machines: sent, Grants: grants})
|
|
if !strings.Contains(line, "grants step: novox sent the bus's user list first, its builds kept") {
|
|
t.Errorf("plans says the gate as %q", line)
|
|
}
|
|
}
|
|
|
|
// Nothing more when there is nothing to carry: the list unchanged, or the first machine holds the bus.
|
|
func TestAWalkTakesNoGrantsStepWhenTheListIsAlreadyThere(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
node string
|
|
behind bool
|
|
}{{"the list unchanged", "ace", false}, {"the first machine holds the bus", "novox", true}} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
sends := sendsRecorded(t, "novox", c.behind, nil)
|
|
_, grants, err := sendJudged(t.Context(), nil, c.node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(*sends) != 1 || (*sends)[0].keepsAll || grants != nil {
|
|
t.Errorf("the walk made %+v with grants %+v, want the judged send alone", *sends, grants)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A grants step that cannot be sent is said on the gate, and the walk goes on as it did before.
|
|
func TestAGrantsStepThatFailsIsSaidAndTheSendGoesOn(t *testing.T) {
|
|
sends := sendsRecorded(t, "novox", true, errors.New("a bus upgrade waits for a person"))
|
|
sent, grants, err := sendJudged(t.Context(), nil, "ace")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(sent, ",") != "ace" || len(*sends) != 2 {
|
|
t.Errorf("the walk made %+v", *sends)
|
|
}
|
|
if grants == nil || grants.Failed == "" || grants.At != nil {
|
|
t.Fatalf("the gate keeps %+v", grants)
|
|
}
|
|
if line := gateLine(&inventory.PlanGate{Machines: sent, Grants: grants}); !strings.Contains(line,
|
|
"grants step to novox FAILED, sent without it: a bus upgrade waits for a person") {
|
|
t.Errorf("plans says the gate as %q", line)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
// The step's exemption is the list's content alone: its path or mode moving is a change like any other.
|
|
func TestOnlyTheUserListsContentIsExempt(t *testing.T) {
|
|
list := "nats.bus-users"
|
|
file := func(fields map[string]any) sentResource {
|
|
r := map[string]any{"id": list, "type": "file", "path": "/conf/accounts.conf", "mode": "0600", "content": "a"}
|
|
for k, v := range fields {
|
|
r[k] = v
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"resources": []any{r}})
|
|
s, err := summarize(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s[0]
|
|
}
|
|
was := file(nil)
|
|
if other := otherThanTheList(diffSent([]sentResource{was}, []sentResource{file(map[string]any{"content": "b"})}), list); len(other) != 0 {
|
|
t.Errorf("the list's new content reads as more: %v", other)
|
|
}
|
|
for _, f := range []map[string]any{{"mode": "0644"}, {"path": "/elsewhere"}, {"content": "b", "owner": "nats"}} {
|
|
other := otherThanTheList(diffSent([]sentResource{was}, []sentResource{file(f)}), list)
|
|
if len(other) != 1 || !strings.HasPrefix(other[0], "~"+list) {
|
|
t.Errorf("the list's resource changed by %v reads %v; want it refused", f, other)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The step keeps the builds of the machine it sends alone: the others its composition resolves are composed
|
|
// as any send composes them.
|
|
func TestTheGrantsStepKeepsOneMachinesBuilds(t *testing.T) {
|
|
ctx := keepingEveryBuild(t.Context(), "novox")
|
|
if !everyBuildKept(ctx, "novox") || everyBuildKept(ctx, "ace") || everyBuildKept(t.Context(), "novox") {
|
|
t.Error("the grants step keeps the builds of a machine it does not send")
|
|
}
|
|
}
|