diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 05bfc4c5..ccdd28f3 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -264,6 +264,9 @@ func serve(ctx context.Context) (err error) { // The facts snapshot a merge check is fed (novox/hq to-be 45 §9): kept current while this // controller holds the lease, read by the build seat from the artifact store. go exportingFacts(ctx, open, bus.Conn.ConnectedServerVersion) + // The record mirrors (novox/hq ADR 0289, design 57 F3): the assignments and each registered module's + // declared config on the bus, written from the store only when a record changed, by the lease holder. + go mirroringRecords(ctx, inv, bus.Conn, bus) // Every call carries the lease's epoch, and its record is written only under the lease (novox/hq // to-be 45 §6). link.Calls.UnderLease(func() (uint64, error) { return theLease.epoch(ctx) }) diff --git a/cmd/mesh-controller/record_mirrors.go b/cmd/mesh-controller/record_mirrors.go new file mode 100644 index 00000000..2f25352e --- /dev/null +++ b/cmd/mesh-controller/record_mirrors.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/recordmirror" +) + +// The controller's record mirrors on the bus (novox/hq ADR 0289, design 54, design 57 F3): the assignments, +// and each registered module's declared config, which mesh-config resolves a module's config values from. +// +// **Built from the store, on a tick of their own, by the lease holder alone.** A record changes through a +// verb that runs as a command of its own, a reconcile, a merge's registration or a pending assignment made, +// so no one call site sees every change; what every change has in common is that it is committed to the +// store. So the serving controller reads the store every mirrorEvery and writes only what differs from what +// the bucket holds (ADR 0289 §1): a quiet mesh writes nothing, and a change rolled back is never read. A +// mirror is therefore at most mirrorEvery behind its store. + +// mirrorEvery is how often the mirrors are compared with the store: two small reads of the store. +const mirrorEvery = 10 * time.Second + +// mirroringRecords keeps the mirrors current for as long as this controller serves: ctx ends when it stops. +func mirroringRecords(ctx context.Context, inv *inventory.Inventory, conn *nats.Conn, announce recordmirror.Announcer) { + api, err := jetstream.New(conn) + if err != nil { + fmt.Printf("the record mirrors are not kept: %v\n", err) + return + } + holds := func() bool { + epoch, err := theLease.epoch(ctx) + return err == nil && epoch > 0 + } + epoch := func() uint64 { + e, _ := theLease.epoch(ctx) + return e + } + sources := map[string]func(context.Context) (map[string][]byte, error){ + recordmirror.Assignments.Name: inv.AssignmentsMirror, + recordmirror.DeclaredConfig.Name: inv.DeclaredConfigMirror, + } + writers := map[string]*recordmirror.Writer{} + failing := map[string]string{} + tick := time.NewTicker(mirrorEvery) + defer tick.Stop() + for { + if holds() { + for _, m := range recordmirror.Mirrors() { + why := mirrorOnce(ctx, api, m, writers, sources[m.Name], announce, epoch, holds) + if why != failing[m.Name] { + if why != "" { + fmt.Printf("the %s mirror cannot be kept: %s\n", m.Name, why) + } else { + fmt.Printf("the %s mirror is kept again\n", m.Name) + } + failing[m.Name] = why + } + } + } + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} + +// mirrorOnce brings one mirror to what the store holds, and answers why it could not; empty when it could. +func mirrorOnce(ctx context.Context, api jetstream.JetStream, m recordmirror.Mirror, writers map[string]*recordmirror.Writer, + source func(context.Context) (map[string][]byte, error), announce recordmirror.Announcer, epoch func() uint64, + holds func() bool) string { + reading, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + w := writers[m.Name] + if w == nil { + kv, err := api.KeyValue(reading, m.Bucket) + if err != nil { + return fmt.Sprintf("the bucket %s cannot be read: %v", m.Bucket, err) + } + w = &recordmirror.Writer{Mirror: m, KV: kv, Announce: announce, Epoch: epoch, Holds: holds} + writers[m.Name] = w + } + want, err := source(reading) + if err != nil { + return fmt.Sprintf("the store cannot be read: %v", err) + } + lost := w.LostEvents + r, err := w.Sync(reading, want) + if r.Written+r.Removed > 0 { + fmt.Printf("the %s mirror moved: %d written, %d removed\n", m.Name, r.Written, r.Removed) + } + if w.LostEvents > lost { + fmt.Printf("the %s mirror lost %d event(s): the bucket holds the change, its notice was refused twice\n", + m.Name, w.LostEvents-lost) + } + if err != nil { + return err.Error() + } + return "" +} diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index 69bc81ad..fe87dc4d 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -40,6 +40,39 @@ var ( AskedBucket = BucketName(ControllerSeat, "asked") ) +// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of +// record, one key per record, written from the store by the lease holder only when the record changed, with +// one event on the controller's seat per write. Read by whatever names them in `reads` as +// `mesh-controller.` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an +// assignment's value names its module and node; a module's declared config is what its manifest, which is +// public, declares. +// +// AssignmentsBucket holds which module is assigned to which node, keyed `.`; and +// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by +// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4). +var ( + AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments) + DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig) +) + +// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`. +const ( + MirrorAssignments = "assignments" + MirrorDeclaredConfig = "declared-config" +) + +// ControllerMirrors are the local names of the controller's record mirrors, which a module may read. +func ControllerMirrors() []string { return []string{MirrorAssignments, MirrorDeclaredConfig} } + +// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket. +const ( + MirrorValueBytes = 64 << 10 + MirrorBytes = 64 << 20 + // MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a + // per-message age for it, and the writer gives each marker a day. + MirrorMarkerTTL = 24 * time.Hour +) + // AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own. const AskedKeptFor = 30 * 24 * time.Hour @@ -66,12 +99,14 @@ const ( // IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares. func IsControllerBucket(bucket string) bool { return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket || - bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket + bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket || + bucket == AssignmentsBucket || bucket == DeclaredConfigBucket } // ControllerBuckets are the controller's own buckets, in the order they are asserted. func ControllerBuckets() []string { - return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket} + return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, + AssignmentsBucket, DeclaredConfigBucket} } // ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. @@ -168,6 +203,27 @@ func (j *JetStream) EnsureControllerBuckets() error { }); err != nil { return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err) } + // **The record mirrors: no age** (design 54 §5). A key is removed when its record is, by a purge whose + // marker lives a day; a key that expired would be a record the bus forgot while the store still holds it. + for _, m := range []struct{ bucket, why string }{ + {AssignmentsBucket, "which module is assigned to which node, keyed . (novox/hq ADR 0289, " + + "design 54): written by the controller alone from its store when an assignment changes, read through `reads`"}, + {DeclaredConfigBucket, "each registered module's config declarations as its manifest holds them, keyed by " + + "the module (novox/hq ADR 0348, design 57 F3): written by the controller alone from its store when a " + + "registration changes them, read through `reads`"}, + } { + if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: m.bucket, + Description: m.why, + History: 1, + MaxValueSize: MirrorValueBytes, + MaxBytes: MirrorBytes, + Storage: jetstream.FileStorage, + LimitMarkerTTL: MirrorMarkerTTL, + }); err != nil { + return fmt.Errorf("asserting bucket %s: %w", m.bucket, err) + } + } return nil } diff --git a/internal/broker/controller_buckets_test.go b/internal/broker/controller_buckets_test.go index 804f1c5e..1e8c7a69 100644 --- a/internal/broker/controller_buckets_test.go +++ b/internal/broker/controller_buckets_test.go @@ -21,7 +21,8 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) { if err != nil { t.Fatal(err) } - want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>"} + want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>", + "$KV." + AssignmentsBucket + ".>", "$KV." + DeclaredConfigBucket + ".>"} for _, seat := range seatsTheControllerAsks { if hasCancelledSet(seat) { want = append(want, "$KV."+CancelledSetName(seat)+".>") @@ -37,6 +38,34 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) { } } +// **A module reads a record mirror through `reads`, and never writes it** (ADR 0201 §1, design 54 §5, +// design 57 F3): granted the direct reads and watches of the bucket, and refused at composition a grant to +// write it, whose writer is the controller. +func TestAModuleReadsTheControllersMirrorsAndWritesNone(t *testing.T) { + reader := Principal{Kind: KindModule, Node: "one", Module: "mesh-config", PasswordHash: "x", + Reads: []string{"mesh-controller." + MirrorAssignments, "mesh-controller." + MirrorDeclaredConfig}} + p, err := PermissionsFor(reader) + if err != nil { + t.Fatal(err) + } + for _, bucket := range []string{AssignmentsBucket, DeclaredConfigBucket} { + for _, subject := range []string{"$JS.API.STREAM.INFO.KV_" + bucket, "$JS.API.DIRECT.GET.KV_" + bucket + ".>", + "$JS.API.CONSUMER.CREATE.KV_" + bucket + ".>"} { + if !slices.Contains(p.Publish, subject) { + t.Errorf("a module reading %s may not publish %s", bucket, subject) + } + } + for _, subject := range p.Publish { + if SubjectsOverlap(subject, "$KV."+bucket+".>") { + t.Errorf("a module reading %s may write it through %s", bucket, subject) + } + } + if err := CheckWriters(reader, []string{"$KV." + bucket + ".>"}); err == nil { + t.Errorf("a module granted a write of %s would compose", bucket) + } + } +} + // **A machine's node tools may say they are there, as that machine and no other** (novox/hq to-be 45 // S11), and the controller may hear the bus's advisories and ask who answers — read-only, named. func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) { diff --git a/internal/broker/log_test.go b/internal/broker/log_test.go index 3119fb03..95043e1c 100644 --- a/internal/broker/log_test.go +++ b/internal/broker/log_test.go @@ -172,7 +172,9 @@ func TestRaisingLogsReportsWhatNothingDeclares(t *testing.T) { // **No path of the mesh deletes or purges a log or a bucket, or recreates one** (novox/hq ADR 0297 §2, // ADR 0201 §15–16): no code of this repository outside its tests calls the client's stream or bucket // delete, or purges a stream, but for the controller lease's own stream, which purges its own history -// below a sequence (internal/lease). A new call is a decision, not a refactor. +// below a sequence (internal/lease), and the controller's record mirrors, which purge the key of a record +// the store no longer holds (internal/recordmirror; novox/hq ADR 0289, design 54 §2). A new call is a decision, +// not a refactor. func TestNoPathDeletesALogOrABucket(t *testing.T) { removers := map[string]bool{"DeleteStream": true, "DeleteKeyValue": true, "PurgeStream": true, "DeleteObjectStore": true} @@ -197,7 +199,9 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) { return true } name := sel.Sel.Name - if removers[name] || (name == "Purge" && !strings.Contains(filepath.ToSlash(path), "internal/lease/")) { + decided := strings.Contains(filepath.ToSlash(path), "internal/lease/") || + strings.Contains(filepath.ToSlash(path), "internal/recordmirror/") + if removers[name] || (name == "Purge" && !decided) { found = append(found, path+": "+name) } return true diff --git a/internal/broker/states_agreement_test.go b/internal/broker/states_agreement_test.go index 179203ef..e29a8992 100644 --- a/internal/broker/states_agreement_test.go +++ b/internal/broker/states_agreement_test.go @@ -34,6 +34,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) { states = append(states, link.KeyChecked) // And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239). states = append(states, link.KeyPlanMoved) + // And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3). + states = append(states, link.KeyAssignmentChanged, link.KeyDeclaredConfigChanged) for _, event := range states { if !slices.Contains(broker.ControllerStates, event) { t.Errorf("the mesh states %q and its account may not publish it", event) @@ -42,6 +44,11 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) { if len(broker.ControllerStates) != len(states) { t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates) } + // The record mirrors a module may read are the ones the bus holds (design 54 §5, design 57 F3). + if !slices.Equal(catalogue.ControllerMirrors, broker.ControllerMirrors()) { + t.Errorf("the catalogue lets a module read the controller's mirrors %v and the bus holds %v", + catalogue.ControllerMirrors, broker.ControllerMirrors()) + } // **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the // facts the control plane states are the seat's `emits` — which is what lets anything else declare // that it consumes them, and what the subject-agreement check reads to know they have an owner. diff --git a/internal/broker/streams.go b/internal/broker/streams.go index 124dfa86..3c286a19 100644 --- a/internal/broker/streams.go +++ b/internal/broker/streams.go @@ -313,7 +313,10 @@ var ControllerStates = []string{"applied", "refused", "built-before", // the pull request's status, and anybody else may read. "checked", // And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from. - "plan-moved"} + "plan-moved", + // And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and + // each registered module's declared config (design 57 F3). + "assignment-changed", "declared-config-changed"} // BusAdvisories are what the bus server says about the mesh's own account that the controller // reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 6099dfb0..b5e83824 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -158,6 +158,15 @@ var WritersTable = []WriterRow{ {State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream, Others: "read, delivered again or dropped through the controller's dead-letters verb", Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController}, + // The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets + // anything else write them is refused at composition. Built so far: the assignments and each registered + // module's declared config (design 57 F3). + {State: "the assignments mirror", Writer: "controller (lease holder), from its store, only on a change", + KeptIn: "key-value " + AssignmentsBucket, Others: "read through `reads` as mesh-controller." + MirrorAssignments, + Subjects: kvOf(AssignmentsBucket), Writes: isController}, + {State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change", + KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig, + Subjects: kvOf(DeclaredConfigBucket), Writes: isController}, } // CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 124de422..7ec91c5f 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -34,6 +34,9 @@ var designRows = []string{ "the operator-channel's open messages", "the facts snapshot", "a message a consumer gave up on", + // The record mirrors, rows of to-be 45 §1's table by design 54 §2 (ADR 0289 §9). + "the assignments mirror", + "the declared-config mirror", } func TestTheWritersTableIsTheDesigns(t *testing.T) { diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 68c7291a..8602d507 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -107,7 +107,9 @@ var defaultSeats = append([]Seat{ // A pull request's merge check, judged (novox/hq to-be 45 §9). "checked", // A walk kept in a new state, for the delivery it walks (novox/hq ADR 0239). - "plan-moved"}, + "plan-moved", + // A key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3). + "assignment-changed", "declared-config-changed"}, Serves: ControllerVerbs}, // The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable, // served by whichever module holds the seat with tools of these names. diff --git a/internal/catalogue/state.go b/internal/catalogue/state.go index 1944b778..42a14266 100644 --- a/internal/catalogue/state.go +++ b/internal/catalogue/state.go @@ -124,6 +124,14 @@ func StateProblems(m Manifest) []string { return problems } +// ControllerMirrors are the controller's record mirrors a module may read, each `mesh-controller.` +// (novox/hq ADR 0289, design 54 §5, design 57 F3). **The controller keeps them, not its manifest's `state`**: +// they are asserted with the controller's own buckets, with options of their own, and written by the +// controller's account alone (the writers table); declared as a module's state, they would be asserted a +// second way and granted to the module's own principal as well. Named here so a read of one is a read of +// kept state. The bus's list is broker.ControllerMirrors; a test keeps the two agreeing. +var ControllerMirrors = []string{"assignments", "declared-config"} + // StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no // such state. An absent owner says nothing — a module may be installed long before the one whose // state it reads, as a consumer may before its emitter (design 32 §1). @@ -136,6 +144,11 @@ func StateReadsNothingDeclares(manifests []Manifest) []string { } declared[m.Module] = own } + if own, present := declared[ControllerSeatName]; present { + for _, name := range ControllerMirrors { + own[name] = true + } + } var problems []string for _, m := range manifests { for _, r := range m.Reads { diff --git a/internal/catalogue/state_test.go b/internal/catalogue/state_test.go index 98eba5f9..e82f9711 100644 --- a/internal/catalogue/state_test.go +++ b/internal/catalogue/state_test.go @@ -67,6 +67,21 @@ func TestAReadNamesStateItsOwnerKeeps(t *testing.T) { } } +// **The controller's record mirrors are kept state** (design 54 §5, design 57 F3): a module may read +// `mesh-controller.assignments` and `mesh-controller.declared-config` although the controller's manifest +// declares no `state`; a read of anything else of the controller's is still named. +func TestAReadOfTheControllersMirrorsIsAReadOfKeptState(t *testing.T) { + controller := Manifest{Module: ControllerSeatName} + reader := Manifest{Module: "mesh-config", Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config"}} + if p := StateReadsNothingDeclares([]Manifest{controller, reader}); len(p) != 0 { + t.Fatalf("a read of the controller's mirrors was refused: %v", p) + } + other := Manifest{Module: "other", Reads: []string{"mesh-controller.calls"}} + if p := StateReadsNothingDeclares([]Manifest{controller, other}); len(p) != 1 { + t.Fatalf("a read of the controller's own calls was not named: %v", p) + } +} + // **Across the whole catalogue**: every state name is local, and every read whose owner is present // names state that owner keeps. func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) { diff --git a/internal/inventory/mirrors.go b/internal/inventory/mirrors.go new file mode 100644 index 00000000..5d878a0f --- /dev/null +++ b/internal/inventory/mirrors.go @@ -0,0 +1,100 @@ +package inventory + +import ( + "bytes" + "context" + "encoding/json" + "time" +) + +// The values of the controller's record mirrors, built from the store (novox/hq ADR 0289, design 54 §1, +// design 57 package F3). +// +// **Each value is built from a named list of fields, and nothing else** (design 54 §1): no epoch, no time +// of writing and no time of the last look, so any lease holder builds the same bytes from the same record +// and a pass over unchanged records writes nothing (ADR 0289 §1, §4). The encoding is fixed: the fields in +// their struct's order, a declaration's maps sorted. + +// AssignmentMirrored is one assignment as the assignments mirror holds it: the module, the node, and since +// when it is assigned there. Design 54 §1 also lists whether it is pending, the builds sent and running and +// the seats and provisions it holds there; those join with issue 419, which builds the rest of design 54. +type AssignmentMirrored struct { + Node string `json:"node"` + Module string `json:"module"` + Assigned time.Time `json:"assigned"` +} + +// DeclaredConfigMirrored is one registered module's config declarations as the declared-config mirror holds +// them: the module, and its manifest's `settings` exactly as the manifest holds them (each key's kind, +// default and why today), carried whole so a declaration that gains fields (ADR 0348 clause 4) is mirrored +// without a change here. An empty object when the module declares none: registered, declaring nothing. +type DeclaredConfigMirrored struct { + Module string `json:"module"` + Settings json.RawMessage `json:"settings"` +} + +// AssignmentsMirror is every assignment's mirrored value, keyed `.`. +func (i *Inventory) AssignmentsMirror(ctx context.Context) (map[string][]byte, error) { + rows, err := i.store.Pool().Query(ctx, + `select n.name, a.module, a.assigned from assignment a join node n on n.id = a.node`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string][]byte{} + for rows.Next() { + var a AssignmentMirrored + if err := rows.Scan(&a.Node, &a.Module, &a.Assigned); err != nil { + return nil, err + } + a.Assigned = a.Assigned.UTC() + value, err := json.Marshal(a) + if err != nil { + return nil, err + } + out[a.Node+"."+a.Module] = value + } + return out, rows.Err() +} + +// DeclaredConfigMirror is every registered module's mirrored config declarations, keyed by the module. +func (i *Inventory) DeclaredConfigMirror(ctx context.Context) (map[string][]byte, error) { + rows, err := i.store.Pool().Query(ctx, + `select name, coalesce(manifest->'settings', '{}'::jsonb) from module`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string][]byte{} + for rows.Next() { + var ( + d DeclaredConfigMirrored + raw []byte + ) + if err := rows.Scan(&d.Module, &raw); err != nil { + return nil, err + } + settings, err := canonical(raw) + if err != nil { + return nil, err + } + d.Settings = settings + value, err := json.Marshal(d) + if err != nil { + return nil, err + } + out[d.Module] = value + } + return out, rows.Err() +} + +// canonical is a JSON document encoded the one way: objects' keys sorted, numbers as written. +func canonical(raw []byte) ([]byte, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, err + } + return json.Marshal(v) +} diff --git a/internal/link/events.go b/internal/link/events.go index 48b7ece6..9d89dcfc 100644 --- a/internal/link/events.go +++ b/internal/link/events.go @@ -81,6 +81,11 @@ const ( // steps from. Said after every save of the serving controller; a delivery's holder also asks for the // walks it follows, so a save made elsewhere is found by comparison, never lost. KeyPlanMoved = "plan-moved" + // KeyAssignmentChanged and KeyDeclaredConfigChanged: a key of the controller's assignments or declared-config + // mirror was written or removed (novox/hq ADR 0289, design 54 §3, design 57 F3): which key, how and at which + // revision of the bucket — never the value, which the bucket holds. + KeyAssignmentChanged = "assignment-changed" + KeyDeclaredConfigChanged = "declared-config-changed" ) // Applied is what a machine now runs, as the mesh states it. diff --git a/testdata/store-tests b/testdata/store-tests index bbfc1467..b7dc4922 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -61,3 +61,4 @@ ./cmd/mesh-controller TestAProviderElsewhereServesItsOwnComposedName ./cmd/mesh-controller TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne ./internal/inventory TestAReportOfABusyContainerIsKeptAndReadBack +./internal/recordmirror TestNatsAnAssignmentAndARegistrationEachChangeOneKeyAndRaiseOneEvent