A tool check alone is refused, so the store test's c2 never registered; the fixtures are now parsed by a test that needs no store.
227 lines
9.5 KiB
Go
227 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// The store test's manifests are manifests the controller takes in: checked here, where no store is needed,
|
|
// so a fixture the module rules refuse fails before the store test is run (repo-check of #230 at 325575b2).
|
|
func TestTheGrantsStepFixturesAreManifests(t *testing.T) {
|
|
for name, m := range map[string]map[string]any{"nats": natsFixture(), "restic-c1": resticFixture(false),
|
|
"restic-c2": resticFixture(true), "wallpaper": wallpaperFixture()} {
|
|
m["module"], m["version"] = strings.SplitN(name, "-", 2)[0], "1"
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := catalogue.ParseManifest(raw); err != nil {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The delivery plan says the step before the merge.
|
|
func TestAChangePlanSaysTheGrantsStep(t *testing.T) {
|
|
const catalogue_ = "http://forge.internal:20000/novox/mesh-catalog.git"
|
|
entry := func(name string, on ...string) inventory.Entry {
|
|
e := fromRepo(name, catalogue_, "modules/"+name)
|
|
e.Source.BuiltFrom = "old"
|
|
e.On = on
|
|
return e
|
|
}
|
|
nats := entry("nats", "novox")
|
|
nats.Manifest.BusUsers = "/etc/nats/users.conf"
|
|
nats.Manifest.Claims = []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}}
|
|
restic := entry("restic", "ace", "novox", "shanks")
|
|
only := entry("bus-tools", "novox")
|
|
entries := []inventory.Entry{nats, restic, only}
|
|
rollsOut := func(string) (inventory.Upgrade, bool) { return inventory.Upgrade{RollOut: true}, true }
|
|
plan := func(paths ...string) link.ChangePlan {
|
|
m := link.SourceMoved{Owner: "novox", Repo: "mesh-catalog", Base: "main", Commit: "head", Paths: paths}
|
|
return changePlanOf("novox/mesh-catalog", "main", "head", reachOfMerge(m, entries, nil, nil), entries, rollsOut)
|
|
}
|
|
|
|
text := planText(plan("modules/restic/module.json"))
|
|
if !strings.Contains(text, "grants step: if this changes what the bus's user list says (a new tool, subject or "+
|
|
"user), novox is sent that list alone, every build there kept, before restic is judged on its first machine") {
|
|
t.Errorf("the change plan does not say the grants step:\n%s", text)
|
|
}
|
|
// A module only on the bus's machine goes there with the list in its own send: no step of its own.
|
|
if text := planText(plan("modules/bus-tools/module.json")); strings.Contains(text, "grants step") {
|
|
t.Errorf("a module on the bus's machine alone reads:\n%s", text)
|
|
}
|
|
}
|