From 485eb44de4b8ffa3009cdb18ae42bea4f6bddc35 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 14:23:58 +0200 Subject: [PATCH] issue 1840/S3: keep each node's declared packages on the bus, so the pacman verbs can say what a node declares --- .../declared_packages_integration_test.go | 61 ++++++++++++++ cmd/mesh-controller/push.go | 2 +- cmd/mesh-controller/record_mirrors.go | 41 ++++++++- internal/broker/controller_buckets.go | 13 ++- .../broker/declared_packages_mirror_test.go | 41 +++++++++ internal/broker/nats.go | 5 ++ internal/broker/testdata/composed.conf | 2 +- internal/broker/writers.go | 3 + internal/broker/writers_test.go | 1 + internal/inventory/declared_packages_test.go | 83 +++++++++++++++++++ internal/inventory/mirrors.go | 63 ++++++++++++++ internal/recordmirror/mirror.go | 7 +- .../recordmirror/mirror_integration_test.go | 47 +++++++++++ 13 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 cmd/mesh-controller/declared_packages_integration_test.go create mode 100644 internal/broker/declared_packages_mirror_test.go create mode 100644 internal/inventory/declared_packages_test.go diff --git a/cmd/mesh-controller/declared_packages_integration_test.go b/cmd/mesh-controller/declared_packages_integration_test.go new file mode 100644 index 00000000..4a74e243 --- /dev/null +++ b/cmd/mesh-controller/declared_packages_integration_test.go @@ -0,0 +1,61 @@ +//go:build integration + +package main + +import ( + "encoding/json" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-controller/internal/inventory" +) + +// 1840/AC5 (ADR 0507 §5): the source of the declared-packages mirror composes each node and names, for a package two modules +// declare and one declared absent, the modules that declare it; the same records give the same bytes on a second pass, so +// the mirror writes nothing; a node with no package has a key that says so. +func TestTheDeclaredPackagesMirrorNamesWhatEachNodesModulesDeclare(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + register(t, open, catalogue.Manifest{Module: "viewer", Version: "1", Resources: []map[string]any{ + {"id": "pkg-curl", "type": "package", "package": "curl"}, + {"id": "pkg-nano", "type": "package", "package": "nano", "absent": true}}}) + register(t, open, catalogue.Manifest{Module: "fetcher", Version: "1", Resources: []map[string]any{ + {"id": "pkg-curl", "type": "package", "package": "curl"}}}) + for _, module := range []string{"viewer", "fetcher"} { + if _, err := assign(ctx, open, "anchor", module); err != nil { + t.Fatalf("assign anchor %s: %v", module, err) + } + } + source := declaredPackagesMirror(open) + first, err := source(ctx) + if err != nil { + t.Fatal(err) + } + var got inventory.DeclaredPackagesMirrored + if err := json.Unmarshal(first["anchor"], &got); err != nil { + t.Fatalf("anchor has no value: %v", err) + } + want := []inventory.PackageDeclared{{Package: "curl", Modules: []string{"fetcher", "viewer"}}, + {Package: "nano", Modules: []string{"viewer"}, Absent: true}} + if len(got.Packages) != len(want) { + t.Fatalf("anchor declares %+v, want %+v", got.Packages, want) + } + for i, p := range want { + if g := got.Packages[i]; g.Package != p.Package || g.Absent != p.Absent || len(g.Modules) != len(p.Modules) || + (len(p.Modules) > 0 && g.Modules[0] != p.Modules[0]) { + t.Errorf("package %d is %+v, want %+v", i, g, p) + } + } + if string(first["laptop"]) != `{"node":"laptop","packages":[]}` { + t.Errorf("a node declaring none is %s", first["laptop"]) + } + second, err := source(ctx) + if err != nil { + t.Fatal(err) + } + for node, value := range first { + if string(second[node]) != string(value) { + t.Errorf("the value of %s changed between two passes over the same records:\n%s\n%s", node, value, second[node]) + } + } +} diff --git a/cmd/mesh-controller/push.go b/cmd/mesh-controller/push.go index 9f38cd91..e27d09ce 100644 --- a/cmd/mesh-controller/push.go +++ b/cmd/mesh-controller/push.go @@ -281,7 +281,7 @@ func serve(ctx context.Context) (err error) { // The provisioner's plan of each handed module, as its machine was last sent it (design 57 P6), in the module's // assignment. inv.SetProvisioningPlans(inv.SentProvisioningPlans) - go mirroringRecords(ctx, inv, bus.Conn, bus) + go mirroringRecords(ctx, open, inv, bus.Conn, bus) // And the bucket of the files that travel by digest, judged against what each machine's latest declaration names (ADR 0486 §3). go sweepingDeclarationFiles(ctx, inv, bus.Conn) // And the key of each plan in its build phase, which holds the build seat's checks while a delivery builds (ADR 0483 clause 2). diff --git a/cmd/mesh-controller/record_mirrors.go b/cmd/mesh-controller/record_mirrors.go index 25fd9d15..0f02cded 100644 --- a/cmd/mesh-controller/record_mirrors.go +++ b/cmd/mesh-controller/record_mirrors.go @@ -7,6 +7,7 @@ import ( "sort" "time" + "git.novox.be/novox/mesh-sdk/go/compose" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -29,7 +30,7 @@ import ( 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) { +func mirroringRecords(ctx context.Context, open *stores, 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) @@ -44,8 +45,9 @@ func mirroringRecords(ctx context.Context, inv *inventory.Inventory, conn *nats. return e } sources := map[string]func(context.Context) (map[string][]byte, error){ - recordmirror.DeclaredConfig.Name: inv.DeclaredConfigMirror, - recordmirror.Predefined.Name: inv.PredefinedMirror, + recordmirror.DeclaredConfig.Name: inv.DeclaredConfigMirror, + recordmirror.Predefined.Name: inv.PredefinedMirror, + recordmirror.DeclaredPackages.Name: declaredPackagesMirror(open), } writers := map[string]*recordmirror.Writer{} failing := map[string]string{} @@ -176,3 +178,36 @@ func keepingAssignments(ctx context.Context, inv *inventory.Inventory, announce } events.known = now } + +// declaredPackagesMirror is the source of the declared-packages mirror (ADR 0507 §5): each node's composition, asked as a +// question that makes nothing, and the packages it declares with the modules that declare them. A node whose own set does +// not compose has no key, and any other failure to read is the pass's error, so a key is never purged for a store that +// could not be read. +func declaredPackagesMirror(open *stores) func(context.Context) (map[string][]byte, error) { + return func(ctx context.Context) (map[string][]byte, error) { + in, err := inputFor(ctx, open) + if err != nil { + return nil, err + } + // Which module owns each resource is carried only for a node-engine that reads it, so every node is asked as one that does. + asked := in.Input + asked.Nodes = append([]compose.Node(nil), in.Nodes...) + for i := range asked.Nodes { + asked.Nodes[i].ReadsModules = true + } + out := map[string][]byte{} + for _, n := range in.sortedNodes() { + d, _, err := compose.ComposeIn(asked, n.Name, compose.Foreseeing) + if compose.IsUnresolvable(err) { + continue + } + if err != nil { + return nil, fmt.Errorf("the packages %s declares cannot be read: %w", n.Name, err) + } + if out[n.Name], err = inventory.DeclaredPackagesValue(n.Name, d.Resources, d.ModuleOf); err != nil { + return nil, err + } + } + return out, nil + } +} diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index fe588c01..4d63a383 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -104,6 +104,9 @@ var ( AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments) DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig) PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined) + // DeclaredPackagesBucket holds, keyed by the node, each package the modules assigned to it declare, present or absent, + // with the declaring modules: what the pacman module's verbs read to say a package is declared (ADR 0507 §5). + DeclaredPackagesBucket = BucketName(ControllerSeat, MirrorDeclaredPackages) ) // The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`. @@ -111,6 +114,8 @@ const ( MirrorAssignments = "assignments" MirrorDeclaredConfig = "declared-config" MirrorPredefined = "predefined" + // MirrorDeclaredPackages is the local name of the packages each node's modules declare (ADR 0507 §5, issue 1840/S3). + MirrorDeclaredPackages = "declared-packages" ) // The chain's buckets (novox/hq ADR 0408, issue 1089): builds, rollouts and assignments are one chain kept on the bus @@ -153,7 +158,7 @@ func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, Ass // ControllerMirrors are the local names of the controller's buckets a module may read: the record mirrors and the // chain's buckets. func ControllerMirrors() []string { - return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning, PlansBuilding} + return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, ChecksRunning, PlansBuilding, MirrorDeclaredPackages} } // MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule @@ -222,6 +227,7 @@ func IsControllerBucket(bucket string) bool { return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket || bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket || bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket || + bucket == DeclaredPackagesBucket || bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket || bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket || bucket == PlansBuildingBucket || bucket == delivery.HaveBucket @@ -230,7 +236,7 @@ func IsControllerBucket(bucket string) bool { // ControllerBuckets are the controller's own buckets, in the order they are asserted. func ControllerBuckets() []string { return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket, - AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket, + AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket, DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket} } @@ -446,6 +452,9 @@ func (j *JetStream) EnsureControllerBuckets() error { {PredefinedBucket, "the list of predefined keys the mesh published, the newest trunk build's of the " + "catalogue, one key `list` (novox/hq ADR 0348 §7, design 57 K3): written by the controller alone from " + "its store when a newer list is kept, read through `reads`"}, + {DeclaredPackagesBucket, "each package the modules assigned to a node declare, present or absent, with the " + + "declaring modules, keyed by the node (novox/hq ADR 0507 §5): written by the controller alone when it " + + "composes the node, only on a change, read through `reads`"}, } { if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: m.bucket, diff --git a/internal/broker/declared_packages_mirror_test.go b/internal/broker/declared_packages_mirror_test.go new file mode 100644 index 00000000..fca6dea7 --- /dev/null +++ b/internal/broker/declared_packages_mirror_test.go @@ -0,0 +1,41 @@ +package broker + +import ( + "slices" + "testing" +) + +// Issue 1840/S3 (ADR 0507 §5), 1840/AC5: a module whose manifest reads mesh-controller.declared-packages may name it in +// `reads`, and reads the bucket and writes none; the controller writes it, so it is no mirror written by another, and it is +// one of the controller's own buckets, asserted at its start. +func TestAModuleReadingDeclaredPackagesReadsAndWritesNone(t *testing.T) { + if !slices.Contains(ControllerMirrors(), "declared-packages") || + BucketName(ControllerSeat, MirrorDeclaredPackages) != DeclaredPackagesBucket { + t.Fatalf("a module cannot name %s in `reads`: %v", DeclaredPackagesBucket, ControllerMirrors()) + } + if _, other := MirrorsWrittenByAnother[MirrorDeclaredPackages]; other { + t.Error("the controller writes the declared packages itself") + } + if !IsControllerBucket(DeclaredPackagesBucket) || !slices.Contains(ControllerBuckets(), DeclaredPackagesBucket) { + t.Errorf("%s is not among the controller's buckets", DeclaredPackagesBucket) + } + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "pacman", PasswordHash: "x", + Reads: []string{"mesh-controller.declared-packages"}}) + if err != nil { + t.Fatal(err) + } + stream := "KV_" + DeclaredPackagesBucket + has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) + has(t, perms.Publish, "$JS.API.DIRECT.GET."+stream+".>") + has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>") + for _, p := range perms.Publish { + if SubjectsOverlap(p, "$KV."+DeclaredPackagesBucket+".one") { + t.Errorf("the reading module may write %s through %q", DeclaredPackagesBucket, p) + } + } + controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + has(t, controller.Publish, "$KV."+DeclaredPackagesBucket+".>") +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 2cfbb3e5..571bd44c 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -429,6 +429,11 @@ var DegradableGrants = []DegradableGrant{ Reason: "refused: the controller then says no plan builds, and no check is held while a delivery builds — a check run " + "wrongly costs a delivery a place, a check held wrongly costs a pull request its turn (ADR 0483 clause 2, issue 1687)", }, + { + Subject: "$KV." + DeclaredPackagesBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and keeps the packages each node declares only after that, holding the lease (ADR 0507 §5, issue 1840)", + }, { Subject: "mesh.seat." + ControllerSeat + ".event.sent", Reason: "refused: the controller says a tier sent to a machine only for outsiders, and a send that could not " + diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 4e2ba482..136f9dbe 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -29,7 +29,7 @@ accounts { ] users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller-merges.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_builds.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_checks-running.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_declared-packages.>", "$KV.mesh-controller_delivery-merges.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_merge-window.>", "$KV.mesh-controller_plans-building.>", "$KV.mesh-controller_predefined.>", "$KV.mesh-controller_rollout-silent.>", "$KV.mesh-controller_rollouts.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.event.sent", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-controller.tool.push", "mesh.seat.mesh-controller.tool.upgrade", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.check.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/broker/writers.go b/internal/broker/writers.go index 9dd7ed3f..c3a3da45 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -236,6 +236,9 @@ var WritersTable = []WriterRow{ {State: "the predefined-keys mirror", Writer: "controller (lease holder), from its store, only on a change", KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined, Subjects: kvOf(PredefinedBucket), Writes: isController}, + {State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change", + KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages, + Subjects: kvOf(DeclaredPackagesBucket), Writes: isController}, // What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own // machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module // in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket. diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index 24128be7..dc038ebc 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -49,6 +49,7 @@ var designRows = []string{ "a declaration's large files", "the declared-config mirror", "the predefined-keys mirror", + "the declared-packages mirror", // The provisioner client's word on what it placed (novox/hq ADR 0349 §4, design 57 P6). "a module's files placed on a machine", } diff --git a/internal/inventory/declared_packages_test.go b/internal/inventory/declared_packages_test.go new file mode 100644 index 00000000..51edea22 --- /dev/null +++ b/internal/inventory/declared_packages_test.go @@ -0,0 +1,83 @@ +package inventory + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "slices" + "testing" +) + +// aNodesPackages is the resources of a node whose modules declare packages: one package two modules declare present, one +// declared absent, and a resource that is no package. +func aNodesPackages() ([]map[string]any, map[string]string) { + return []map[string]any{ + {"id": "web.pkg-curl", "type": "package", "package": "curl"}, + {"id": "backup.pkg-curl", "type": "package", "package": "curl"}, + {"id": "web.pkg-nano", "type": "package", "package": "nano", "absent": true}, + {"id": "web.conf", "type": "file", "path": "/etc/web.conf"}, + }, + map[string]string{"web.pkg-curl": "web", "backup.pkg-curl": "backup", "web.pkg-nano": "web", "web.conf": "web"} +} + +// 1840/AC5: the declared-packages mirror's value for a node names each package with the modules that declare it and whether +// they declare it absent, and the same declaration is the same bytes however its resources are ordered, so a pass over an +// unchanged node writes nothing. +func TestTheDeclaredPackagesValueNamesEachPackageWithItsModules(t *testing.T) { + resources, owner := aNodesPackages() + got, err := DeclaredPackagesValue("one", resources, owner) + if err != nil { + t.Fatal(err) + } + want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` + + `{"package":"nano","modules":["web"],"absent":true}]}` + if string(got) != want { + t.Errorf("the value is\n%s\nwant\n%s", got, want) + } + reversed := slices.Clone(resources) + slices.Reverse(reversed) + again, err := DeclaredPackagesValue("one", reversed, owner) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, again) { + t.Errorf("the same declaration in another order is other bytes:\n%s\n%s", got, again) + } + if got := jsonFields(reflect.TypeOf(DeclaredPackagesMirrored{})); !slices.Equal(got, []string{"node", "packages"}) { + t.Errorf("the value carries %v", got) + } +} + +// A node declaring no package has a value that says so, an empty list and not null. +func TestANodeDeclaringNoPackageHasAnEmptyList(t *testing.T) { + got, err := DeclaredPackagesValue("one", nil, nil) + if err != nil || string(got) != `{"node":"one","packages":[]}` { + t.Errorf("the value is %s (%v)", got, err) + } +} + +// 1840/S3: a node's value stays under the 64 KiB a mirror's key holds at far more packages than a node declares: 600 +// packages, each declared by four modules, is a few times what the largest node declares today. +func TestTheDeclaredPackagesValueOfABigNodeFitsAKey(t *testing.T) { + var resources []map[string]any + owner := map[string]string{} + for p := 0; p < 600; p++ { + for m := 0; m < 4; m++ { + id := fmt.Sprintf("module-%d.pkg-%d", m, p) + resources = append(resources, map[string]any{"id": id, "type": "package", "package": fmt.Sprintf("package-%d", p)}) + owner[id] = fmt.Sprintf("module-%d", m) + } + } + got, err := DeclaredPackagesValue("one", resources, owner) + if err != nil { + t.Fatal(err) + } + var v DeclaredPackagesMirrored + if err := json.Unmarshal(got, &v); err != nil || len(v.Packages) != 600 { + t.Fatalf("the value holds %d packages (%v)", len(v.Packages), err) + } + if len(got) >= 64<<10 { + t.Errorf("the value is %d bytes, over the 64 KiB a key holds", len(got)) + } +} diff --git a/internal/inventory/mirrors.go b/internal/inventory/mirrors.go index 5836e921..11c1f7ca 100644 --- a/internal/inventory/mirrors.go +++ b/internal/inventory/mirrors.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "time" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -191,3 +192,65 @@ func DeclaredOf(settings map[string]catalogue.SettingDeclaration) (map[string]De } return declaredMirrored(raw) } + +// PackageDeclared is one package a node's modules declare, as the declared-packages mirror holds it (ADR 0507 §5): its +// name, the modules that declare it that way, sorted, and whether they declare it absent. A package some modules declare +// present and others absent is two entries; the composition refuses that pair, so it is seen only while it is refused. +type PackageDeclared struct { + Package string `json:"package"` + Modules []string `json:"modules"` + Absent bool `json:"absent"` +} + +// DeclaredPackagesMirrored is one node's value of the declared-packages mirror, keyed by the node: its packages sorted by +// name (absent after present). Fixed fields and no time, so the same declaration is the same bytes (ADR 0289 §1). +type DeclaredPackagesMirrored struct { + Node string `json:"node"` + Packages []PackageDeclared `json:"packages"` +} + +// DeclaredPackagesValue is the mirror's value for one node, from the resources its composition declares and the module that +// owns each (Composed.Owner, by resource id). A resource that is no package, or names none, is left out. +func DeclaredPackagesValue(node string, resources []map[string]any, owner map[string]string) ([]byte, error) { + type key struct { + name string + absent bool + } + by := map[key]map[string]bool{} + for _, r := range resources { + if fmt.Sprint(r["type"]) != "package" { + continue + } + name, _ := r["package"].(string) + if name == "" { + continue + } + k := key{name, r["absent"] == true} + if by[k] == nil { + by[k] = map[string]bool{} + } + if module := owner[fmt.Sprint(r["id"])]; module != "" { + by[k][module] = true + } + } + keys := make([]key, 0, len(by)) + for k := range by { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].name != keys[j].name { + return keys[i].name < keys[j].name + } + return !keys[i].absent && keys[j].absent + }) + v := DeclaredPackagesMirrored{Node: node, Packages: []PackageDeclared{}} + for _, k := range keys { + modules := make([]string, 0, len(by[k])) + for m := range by[k] { + modules = append(modules, m) + } + sort.Strings(modules) + v.Packages = append(v.Packages, PackageDeclared{Package: k.name, Modules: modules, Absent: k.absent}) + } + return json.Marshal(v) +} diff --git a/internal/recordmirror/mirror.go b/internal/recordmirror/mirror.go index f330ed4e..db9a3ce0 100644 --- a/internal/recordmirror/mirror.go +++ b/internal/recordmirror/mirror.go @@ -57,10 +57,13 @@ var ( // mesh-config publishes as its own (design 57 K3). Predefined = Mirror{Name: broker.MirrorPredefined, Bucket: broker.PredefinedBucket, Event: link.KeyPredefinedChanged} + // DeclaredPackages is each node's packages its modules declare, keyed by the node (ADR 0507 §5). It raises no event: + // the seat's emits, which the SDK states, do not name one, and a reader reads the bucket when it is asked. + DeclaredPackages = Mirror{Name: broker.MirrorDeclaredPackages, Bucket: broker.DeclaredPackagesBucket} ) // Mirrors is every mirror this package writes, in the order they are written. -func Mirrors() []Mirror { return []Mirror{DeclaredConfig, Predefined} } +func Mirrors() []Mirror { return []Mirror{DeclaredConfig, Predefined, DeclaredPackages} } // The changes an event says. const ( @@ -251,7 +254,7 @@ func (w *Writer) put(ctx context.Context, key string, value []byte, before kept, // say raises one write's event, tried once more when refused and then counted as lost. func (w *Writer) say(ctx context.Context, key, change string, fields []string, revision uint64) { - if w.Announce == nil { + if w.Announce == nil || w.Mirror.Event == "" { return } e := Event{Mirror: w.Mirror.Name, Key: key, Change: change, Fields: fields, Revision: revision, At: w.now().UTC()} diff --git a/internal/recordmirror/mirror_integration_test.go b/internal/recordmirror/mirror_integration_test.go index b4a1bebd..64a63d27 100644 --- a/internal/recordmirror/mirror_integration_test.go +++ b/internal/recordmirror/mirror_integration_test.go @@ -9,6 +9,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-controller/internal/broker" + "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/testbus" ) @@ -246,3 +247,49 @@ func TestNatsARemovalWithoutTheLeaseRemovesNothing(t *testing.T) { t.Errorf("the key was removed without the lease: %v", err) } } + +// 1840/AC5 (ADR 0507 §5, ADR 0289 §1): the declared-packages mirror, keyed by the node, writes a node's value once; a second +// pass over the same declaration writes nothing (the bucket takes no message); a package newly declared changes the one key; +// a node gone removes its key. It raises no event: the seat's emits name none for it. +func TestNatsTheDeclaredPackagesMirrorWritesOnlyOnAChange(t *testing.T) { + js, api := aMirrorBus(t) + events := listen(t, js.Conn(), "mesh.seat."+broker.ControllerSeat+".event.>") + w := aWriter(t, js, api, DeclaredPackages) + ctx := t.Context() + value := func(node string, resources ...map[string]any) []byte { + t.Helper() + owner := map[string]string{} + for _, r := range resources { + owner[r["id"].(string)] = "web" + } + v, err := inventory.DeclaredPackagesValue(node, resources, owner) + if err != nil { + t.Fatal(err) + } + return v + } + curl := map[string]any{"id": "web.curl", "type": "package", "package": "curl"} + nano := map[string]any{"id": "web.nano", "type": "package", "package": "nano", "absent": true} + + if r, err := w.Sync(ctx, map[string][]byte{"one": value("one", curl), "two": value("two")}); err != nil || + r != (Result{Written: 2}) { + t.Fatalf("the first pass wrote %+v (%v)", r, err) + } + before := writes(t, api, DeclaredPackages.Bucket) + if r, err := w.Sync(ctx, map[string][]byte{"one": value("one", curl), "two": value("two")}); err != nil || r != (Result{}) { + t.Errorf("a pass over the same declarations wrote %+v (%v)", r, err) + } + if got := writes(t, api, DeclaredPackages.Bucket) - before; got != 0 { + t.Errorf("a pass over the same declarations put %d messages in the bucket", got) + } + if r, err := w.Sync(ctx, map[string][]byte{"one": value("one", curl, nano), "two": value("two")}); err != nil || + r != (Result{Written: 1}) { + t.Errorf("a package newly declared wrote %+v (%v)", r, err) + } + if r, err := w.Sync(ctx, map[string][]byte{"one": value("one", curl, nano)}); err != nil || r != (Result{Removed: 1}) { + t.Errorf("a node gone wrote %+v (%v)", r, err) + } + if said := settle(events, 1); len(said) != 0 { + t.Errorf("the declared-packages mirror raised %+v", said) + } +}