diff --git a/cmd/mesh-controller/composed_judge.go b/cmd/mesh-controller/composed_judge.go index 22f77bf8..b8cf3719 100644 --- a/cmd/mesh-controller/composed_judge.go +++ b/cmd/mesh-controller/composed_judge.go @@ -44,6 +44,33 @@ func judgeMove(ctx context.Context, at composedPlace, key string, to sdkdelivery if err != nil { return out, err } + // A node whose have names no composition yet (the switch: it runs the declaration it last applied) is judged + // against that module's slice of that declaration (novox/hq ADR 0540 clause 9): equal is unchanged, a want that + // changes nothing on the node; otherwise by ADR 0377 ยง3's tests, as any move. + if found && have.Composed == "" && have.FP != "" && have.Phase != sdkdelivery.PhaseRemoved && at.lastApplied != nil { + slice, declared, applied, err := at.lastApplied(ctx, to.Module) + if err != nil { + return out, err + } + if applied { + from := sdkdelivery.Composed{Node: at.node, Module: to.Module, FP: have.FP, Data: to.Data, Resources: slice} + if sameResources(from, to) { + out.Judgement, out.Words = sdkdelivery.JudgedUnchanged, "equal to the declaration "+at.node+" last applied" + return out, nil + } + // Against names the declaration, by its digest: the composition the node runs is that declaration's slice. + out.Against = declared + if onlyTheUserList(from, to) { + // Only the bus's user list, as against a composition (ADR 0539 clause 7): no yes waits on it at the switch. + out.Judgement, out.UserListOnly = sdkdelivery.JudgedNeither, true + out.Words = "only the bus's user list changes, against the declaration " + at.node + " last applied" + return out, nil + } + j, words := judged(from, to) + out.Judgement, out.Words = j, words+", against the declaration "+at.node+" last applied" + return out, nil + } + } if !found || have.Phase == sdkdelivery.PhaseRemoved || (have.Composed == "" && have.FP == "") { out.Judgement, out.Words = sdkdelivery.JudgedNeither, "the node runs no composition of "+to.Module return out, nil @@ -297,3 +324,16 @@ func withinPlaces(p string, places []string) bool { } return false } + +// sameResources says two compositions of a module hold the same resources in the same order, each compared canonical. +func sameResources(a, b sdkdelivery.Composed) bool { + if len(a.Resources) != len(b.Resources) { + return false + } + for i := range a.Resources { + if !bytes.Equal(compactComposed(a.Resources[i]), compactComposed(b.Resources[i])) { + return false + } + } + return true +} diff --git a/cmd/mesh-controller/composed_judge_test.go b/cmd/mesh-controller/composed_judge_test.go index bde7bd8e..7316ae44 100644 --- a/cmd/mesh-controller/composed_judge_test.go +++ b/cmd/mesh-controller/composed_judge_test.go @@ -7,6 +7,8 @@ import ( "testing" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-controller/internal/link" ) // mailOn is a composition of mesh-mail on ace at fingerprint c, from the given resources, each saying its module. @@ -125,3 +127,86 @@ func TestAMoveIsJudgedAgainstWhatTheNodeRuns(t *testing.T) { t.Errorf("against a composition that is gone: %+v", v) } } + +const ( + busServer = `{"id":"nats.server","type":"container","module":"nats","name":"nats","image":"nats@sha256:4444444444444444444444444444444444444444444444444444444444444444"}` + busServer2 = `{"id":"nats.server","type":"container","module":"nats","name":"nats","image":"nats@sha256:5555555555555555555555555555555555555555555555555555555555555555"}` + busUsers = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users.conf","content":"users: [a]"}` + busUsers2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users.conf","content":"users: [a, b]"}` +) + +// busOn is a composition of the bus on ace from the given resources. +func busOn(t *testing.T, resources ...string) sdkdelivery.Composed { + t.Helper() + out := sdkdelivery.Composed{Node: "ace", Module: "nats", FP: sdkdelivery.SourcePrefix2 + strings.Repeat("b", 64)} + for _, r := range resources { + out.Resources = append(out.Resources, json.RawMessage(r)) + } + return out +} + +// ADR 0540 clause 9: a have that names no composition, only a fingerprint (the switch: the node runs the declaration it +// last applied), is judged against the module's slice of that declaration, read from the kept stream of declarations: +// an equal slice is unchanged; a slice differing only in the bus's user list is that (round 1's A2, so no yes waits on it at the switch); a changed setting is judged as any move; no declaration to read cannot be judged. +func TestAHaveWithNoCompositionIsJudgedAgainstTheLastDeclaration(t *testing.T) { + ctx := context.Background() + other := `{"id":"mesh-calendar.server","type":"container","module":"mesh-calendar","name":"calendar","image":"cal@sha256:3333333333333333333333333333333333333333333333333333333333333333"}` + unsaid := `{"id":"mesh-mail.tools","type":"file","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` + declared := func(resources ...string) []byte { + body, err := json.Marshal(map[string]any{"node": "ace", "resources": json.RawMessage("[" + strings.Join(resources, ",") + "]")}) + if err != nil { + t.Fatal(err) + } + signed, err := json.Marshal(link.Signed{Declaration: body, Signature: []byte("s")}) // as link.Envelope sent it + if err != nil { + t.Fatal(err) + } + return signed + } + cases := []struct { + name string + last []byte // the node's last declaration on the stream; nil when there is none + to sdkdelivery.Composed + want sdkdelivery.Judgement + inWords string + userList bool + }{ + {"an equal slice", declared(mailContainer, other, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer, mailSpool, mailTools), sdkdelivery.JudgedUnchanged, "last applied", false}, + {"a declared resource saying no module is not equal", declared(mailContainer, other, mailSpool, unsaid), mailOn(t, 'b', true, mailContainer, mailSpool, mailTools), sdkdelivery.JudgedNeither, "last applied", false}, + {"a changed image", declared(mailContainer, other, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer2, mailSpool, mailTools), sdkdelivery.JudgedRecreates, "last applied", false}, + {"a changed setting in its spool", declared(mailContainer, mailSpool, mailConfig), mailOn(t, 'b', true, mailContainer, mailSpool, mailConfig2), sdkdelivery.JudgedMovesData, "last applied", false}, + {"a changed tools file", declared(mailContainer, mailSpool, mailTools), mailOn(t, 'b', true, mailContainer, mailSpool, mailTools2), sdkdelivery.JudgedNeither, "last applied", false}, + {"only the bus's user list", declared(busServer, busUsers, other), busOn(t, busServer, busUsers2), sdkdelivery.JudgedNeither, "only the bus's user list", true}, + {"the bus's user list and its container", declared(busServer, busUsers), busOn(t, busServer2, busUsers2), sdkdelivery.JudgedRecreates, "last applied", false}, + {"no declaration kept", nil, mailOn(t, 'b', true, mailContainer, mailSpool, mailTools), sdkdelivery.JudgedCannot, "names no composition", false}, + } + for _, c := range cases { + at, _, _, haves := aPlace(t, "ace") + last := c.last + at.lastApplied = func(_ context.Context, module string) ([]json.RawMessage, string, bool, error) { + if last == nil { + return nil, "", false, nil + } + return sliceOfDeclaration(last, module) + } + haves[c.to.Module] = sdkdelivery.Have{FP: sdkdelivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: sdkdelivery.PhaseRunning, Health: sdkdelivery.HealthOK} + key, _ := sdkdelivery.ComposedKey("ace", c.to.Module, c.to.FP) + digest, err := sdkdelivery.ComposedDigestOf(c.to) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + got, err := judgeMove(ctx, at, key, c.to, digest) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if got.UserListOnly != c.userList { + t.Errorf("%s: user list only %v, want %v", c.name, got.UserListOnly, c.userList) + } + if got.Judgement != c.want || !strings.Contains(got.Words, c.inWords) { + t.Errorf("%s: judged %s (%q), want %s", c.name, got.Judgement, got.Words, c.want) + } + if _, err := sdkdelivery.EncodeComposedDigest(got); err != nil { + t.Errorf("%s: the judgement written is refused: %v", c.name, err) + } + } +} diff --git a/cmd/mesh-controller/composed_live.go b/cmd/mesh-controller/composed_live.go index 39b9924c..a1ea3ead 100644 --- a/cmd/mesh-controller/composed_live.go +++ b/cmd/mesh-controller/composed_live.go @@ -2,18 +2,23 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "sort" "strings" + "sync" "git.novox.be/novox/mesh-sdk/go/catalogue" + sdkdecl "git.novox.be/novox/mesh-sdk/go/declaration" sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/link" ) // composeModules composes each named node and writes its compositions that differ (ADR 0539 clause 5): what each act @@ -156,6 +161,7 @@ func openComposedPlace(ctx context.Context, js jetstream.JetStream, conn *nats.C ran: func(ctx context.Context, module string) (sdkdelivery.Have, bool, error) { return haveOn(ctx, js, node, module) }, + lastApplied: lastAppliedOn(js, node), }, nil } @@ -278,3 +284,79 @@ type composedPlan struct { catalogue.Plan RequiredBy string `json:"required-by,omitempty"` } + +// lastAppliedOn reads, once, the declaration the node was last sent from the kept stream of declarations (the last +// message on `mesh.node..declare`), and answers each module's slice of it (its resources that say the module, or +// whose id names it) with the declaration's digest. None when the stream or the node's message is gone. +func lastAppliedOn(js jetstream.JetStream, node string) func(ctx context.Context, module string) ([]json.RawMessage, string, bool, error) { + var once sync.Once + var last []byte + var found bool + var readErr error + return func(ctx context.Context, module string) ([]json.RawMessage, string, bool, error) { + once.Do(func() { last, found, readErr = lastDeclaration(ctx, js, node) }) + if readErr != nil || !found { + return nil, "", false, readErr + } + return sliceOfDeclaration(last, module) + } +} + +// lastDeclaration is the node's last signed declaration on the kept stream, as the stream holds it. +func lastDeclaration(ctx context.Context, js jetstream.JetStream, node string) ([]byte, bool, error) { + stream, err := js.Stream(ctx, broker.DeclarationsStream) + if errors.Is(err, jetstream.ErrStreamNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + msg, err := stream.GetLastMsgForSubject(ctx, "mesh.node."+node+".declare") + if errors.Is(err, jetstream.ErrMsgNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return msg.Data, true, nil +} + +// sliceOfDeclaration is module's slice of a signed declaration as the stream holds it, and the digest of the +// declaration it was signed over. +func sliceOfDeclaration(data []byte, module string) ([]json.RawMessage, string, bool, error) { + var signed link.Signed + if err := json.Unmarshal(data, &signed); err != nil { + return nil, "", false, fmt.Errorf("the last declaration is not readable: %w", err) + } + var envelope struct { + Resources []json.RawMessage `json:"resources"` + } + if err := json.Unmarshal(signed.Declaration, &envelope); err != nil { + return nil, "", false, fmt.Errorf("the last declaration is not readable: %w", err) + } + sum := sha256.Sum256(signed.Declaration) + return sliceOfModule(envelope.Resources, module), "sha256:" + hex.EncodeToString(sum[:]), true, nil +} + +// sliceOfModule is, of a declaration's resources in their order, those of module: saying it, or with no module said +// and an id that names it (sdkdecl.ModuleGuess). +func sliceOfModule(resources []json.RawMessage, module string) []json.RawMessage { + var out []json.RawMessage + for _, r := range resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + } + if json.Unmarshal(r, &head) != nil { + continue + } + owner := head.Module + if owner == "" { + owner, _ = sdkdecl.ModuleGuess(head.ID) + } + if owner == module { + out = append(out, r) + } + } + return out +} diff --git a/cmd/mesh-controller/composed_writer.go b/cmd/mesh-controller/composed_writer.go index 141cf5ae..fb67294a 100644 --- a/cmd/mesh-controller/composed_writer.go +++ b/cmd/mesh-controller/composed_writer.go @@ -53,6 +53,9 @@ type composedPlace struct { // names (the move's judgement reads it) and the fingerprint it runs. wanted func(ctx context.Context, module string) (string, error) ran func(ctx context.Context, module string) (sdkdelivery.Have, bool, error) + // lastApplied is the module's slice of the declaration the node last applied, from the kept stream of declarations + // (ADR 0540 clause 9), and that declaration's digest; false when there is none to read. Nil judges without it. + lastApplied func(ctx context.Context, module string) (slice []json.RawMessage, digest string, found bool, err error) // policy is a module's upgrade policy, where it comes from and why (catalogue.UpgradeOf), carried beside the // judgement for the assigner's hold (ADR 0539 clause 14). policy func(module string) (policy, from, why string) diff --git a/go.mod b/go.mod index b1b11097..f468959d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024183824-7f9cc0e55c37 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024191714-d7a9b9688b76 github.com/jackc/pgx/v5 v5.10.0 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/go.sum b/go.sum index 19c81e02..281f8127 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024182048-e45bdc0891bb h1:uwU95N4t git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024182048-e45bdc0891bb/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024183824-7f9cc0e55c37 h1:OdAK+TTR/JQJu1WyNXgjursdIj8zStTXovRxbcf3h/s= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024183824-7f9cc0e55c37/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024191714-d7a9b9688b76 h1:2Fw4O4C7CJitkY0qYHVHmLKaj4H8humeIQ1esHEbsFk= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024191714-d7a9b9688b76/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index cec76329..df239917 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself. mesh-catalog 3a1017f5594217092a654dd1e2d72226766bc16a modules/*/module.json, modules/*/files, modules/*/templates, predefined.json mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock -mesh-sdk 7f9cc0e55c37930aaafe65e28e1ca2c0dcf2d16a conformance/events/module-event.json +mesh-sdk d7a9b9688b768a11459bd9a65bb954361b35be77 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: