Judge a have with no composition against the module's slice of the declaration the node last applied: equal is unchanged (issue 1945; ADR 0540 clause 9)

The first want at the switch skips the record hold only where it changes nothing on the node. A slice differing only in
the bus's user list is marked user-list-only, so no yes waits on it at the switch; any other change at the same
fingerprint is judged as any move, against the declaration's digest.
This commit is contained in:
2026-10-24 21:27:07 +02:00
parent d640ef15ec
commit 9372733861
7 changed files with 214 additions and 2 deletions
+40
View File
@@ -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
}
@@ -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)
}
}
}
+82
View File
@@ -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.<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
}
+3
View File
@@ -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)