diff --git a/go.mod b/go.mod index bc54493b..0b3403f0 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.20261022190740-104533e8c0c9 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023132058-f625d98713e4 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/internal/broker/announced.go b/internal/broker/announced.go index 5f559b88..8a7d41cb 100644 --- a/internal/broker/announced.go +++ b/internal/broker/announced.go @@ -17,9 +17,10 @@ import ( const StatesAnnouncedBucket = announce.KeptBucket // controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares -// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, the checks running and the plans building. +// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, the checks running, the plans building and +// the grants issued, which the assigner reads (ADR 0528 clause 2). var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts, - ChecksRunning, PlansBuilding, MirrorDeclaredPackages} + ChecksRunning, PlansBuilding, MirrorDeclaredPackages, ControllerGrants} // ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local // name, the ones others may read marked readable. It announces itself, as any module does from its code. diff --git a/internal/broker/announced_test.go b/internal/broker/announced_test.go index 8b4c1167..c6a78a19 100644 --- a/internal/broker/announced_test.go +++ b/internal/broker/announced_test.go @@ -39,7 +39,7 @@ func TestTheControllerAnnouncesWhatItKeeps(t *testing.T) { } } want := []string{ChainBuilds, ChecksRunning, MirrorDeclaredConfig, PlansBuilding, MirrorPredefined, ChainRollouts, - MirrorAssignments, MirrorDeclaredPackages} + MirrorAssignments, MirrorDeclaredPackages, ControllerGrants} slices.Sort(want) if !slices.Equal(readable, want) { t.Fatalf("readable %v, want %v", readable, want) diff --git a/internal/broker/controller_buckets.go b/internal/broker/controller_buckets.go index 0462f01d..19c154c1 100644 --- a/internal/broker/controller_buckets.go +++ b/internal/broker/controller_buckets.go @@ -152,6 +152,16 @@ const ( ChainAssignmentBytes = 64 << 20 ) +// GrantsBucket holds, keyed `.`, each fingerprint of a module whose bus credential the controller +// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's +// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner, +// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's +// (delivery.ControllerGrantsBucket, ControllerGrantKey). +var GrantsBucket = BucketName(ControllerSeat, ControllerGrants) + +// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`. +const ControllerGrants = "grants" + // ChainBuckets are the three buckets of the chain: builds, rollouts and assignments. func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} } @@ -224,14 +234,16 @@ func IsControllerBucket(bucket string) bool { bucket == DeclaredPackagesBucket || bucket == MergeWindowBucket || bucket == RolloutSilentBucket || bucket == CheckRunningBucket || bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket || - bucket == PlansBuildingBucket || bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket + bucket == PlansBuildingBucket || bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket || + bucket == GrantsBucket } // 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, DeclaredPackagesBucket, MergeWindowBucket, RolloutSilentBucket, CheckRunningBucket, - DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket} + DeliveryMergesBucket, PlansBuildingBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, + GrantsBucket} } // ControllerBucketsAsserter is what raising the controller's buckets needs of a connection. @@ -402,6 +414,20 @@ func (j *JetStream) EnsureControllerBuckets() error { }); err != nil { return fmt.Errorf("asserting bucket %s: %w", StatesAnnouncedBucket, err) } + // **The grants issued: no age** (ADR 0528 clause 2, issue 1394). One key a module's fingerprint, never removed: a grant + // that expired would hold back a want whose credential exists. + if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: GrantsBucket, + Description: "each fingerprint of a module whose bus credential the controller issued (novox/hq ADR 0528): " + + "`.` holds the accounts issued, put once the bus's user list composed with the module's " + + "manifest at it was sent; read by the assigner through `reads`; written by the controller alone", + History: delivery.ControllerGrantsHistory, + MaxValueSize: 4 << 10, + MaxBytes: 64 << 20, + Storage: jetstream.FileStorage, + }); err != nil { + return fmt.Errorf("asserting bucket %s: %w", GrantsBucket, err) + } // **The merges mesh-delivery announced: keys that age** (ADR 0420 §7, issue 1025). One message a key; a week. if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ Bucket: DeliveryMergesBucket, diff --git a/internal/broker/controller_buckets_test.go b/internal/broker/controller_buckets_test.go index f0637377..2488ea44 100644 --- a/internal/broker/controller_buckets_test.go +++ b/internal/broker/controller_buckets_test.go @@ -120,7 +120,7 @@ func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) { module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"} rowed := map[string]bool{} for _, row := range WritersTable { - if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") { + if !strings.HasSuffix(row.State, " mirror") && !strings.HasSuffix(row.State, " of the chain") && row.State != "the grants issued" { continue } for _, subject := range row.Subjects { diff --git a/internal/broker/grants_test.go b/internal/broker/grants_test.go new file mode 100644 index 00000000..7ebd3ce5 --- /dev/null +++ b/internal/broker/grants_test.go @@ -0,0 +1,44 @@ +package broker + +import ( + "slices" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Issue 1394/C9a (ADR 0528 clause 2): the grants bucket is mesh-sdk's, one of the controller's own buckets asserted at its +// start, written by the controller alone; the assigner, naming mesh-controller.grants in `reads`, reads it and writes +// nothing of it. +func TestTheAssignerReadsTheGrantsAndWritesNone(t *testing.T) { + if GrantsBucket != delivery.ControllerGrantsBucket { + t.Fatalf("the grants bucket is %s, mesh-sdk names %s", GrantsBucket, delivery.ControllerGrantsBucket) + } + if !slices.Contains(controllerReadableStates(), ControllerGrants) { + t.Fatalf("a module cannot name %s in `reads`: %v", GrantsBucket, controllerReadableStates()) + } + if _, other := MirrorsWrittenByAnother[ControllerGrants]; other { + t.Error("the controller writes the grants itself") + } + if !IsControllerBucket(GrantsBucket) || !slices.Contains(ControllerBuckets(), GrantsBucket) { + t.Errorf("%s is not among the controller's buckets", GrantsBucket) + } + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-delivery-assigner", PasswordHash: "x", + Reads: []string{"mesh-controller.grants"}}) + if err != nil { + t.Fatal(err) + } + stream := "KV_" + GrantsBucket + has(t, perms.Publish, "$JS.API.STREAM.INFO."+stream) + has(t, perms.Publish, "$JS.API.CONSUMER.CREATE."+stream+".>") + for _, p := range perms.Publish { + if SubjectsOverlap(p, "$KV."+GrantsBucket+".mesh-x") { + t.Errorf("the assigner may write %s through %q", GrantsBucket, p) + } + } + controller, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + has(t, controller.Publish, "$KV."+GrantsBucket+".>") +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index a6980a5d..6e336118 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -460,6 +460,12 @@ var DegradableGrants = []DegradableGrant{ Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + "first push, and writes the chain only after that, holding the lease (ADR 0408 §2 and §8, issue 1089)", }, + { + Subject: "$KV." + GrantsBucket + ".>", + Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + + "first push, and writes a grant only after a send of the bus's user list, which comes after that (ADR 0528 " + + "clause 2, issue 1394)", + }, { Subject: "$KV." + RolloutsBucket + ".>", Reason: "carried only at genesis: the controller composes its own permissions and pushes them to the bus at its " + diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index dfc1d983..8aef1ebe 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_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.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "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_grants.>", "$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.>", "$KV.mesh-controller_states-announced.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.mod.mesh-controller.event.states", "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 73dd8369..5bb3e6d0 100644 --- a/internal/broker/writers.go +++ b/internal/broker/writers.go @@ -242,6 +242,10 @@ var WritersTable = []WriterRow{ {State: "the rollouts of the chain", Writer: "controller (lease holder), compare-and-set on the key's revision", KeptIn: "key-value " + RolloutsBucket + ", sixty-four revisions of each key, none removed", Others: "read through `reads` as mesh-controller." + ChainRollouts, Subjects: kvOf(RolloutsBucket), Writes: isController}, + // The grants issued (novox/hq ADR 0528 clause 2, issue 1394): the controller's, read by the assigner for its sixth rule. + {State: "the grants issued", Writer: "controller (lease holder), once the bus's user list carrying a module's manifest at a fingerprint was sent", + KeptIn: "key-value " + GrantsBucket + ", one key a module's fingerprint, none removed", + Others: "read through `reads` as mesh-controller." + ControllerGrants, Subjects: kvOf(GrantsBucket), Writes: isController}, // What each machine has of each module (novox/hq ADR 0429 §1, design 47): written by that machine's node-engine under // its own node's keys, never by the controller; the view and the watchman read it. {State: "a machine's have", Writer: "the node-engine of the machine, under its own keys `.>`", diff --git a/internal/broker/writers_test.go b/internal/broker/writers_test.go index aed01b84..63990695 100644 --- a/internal/broker/writers_test.go +++ b/internal/broker/writers_test.go @@ -46,6 +46,8 @@ var designRows = []string{ // The chain beside the assignments, the record itself and no mirror of one (ADR 0408, design 47, issue 1089). "the builds of the chain", "the rollouts of the chain", + // The grants issued, which the assigner's sixth rule reads (ADR 0528 clause 2, issue 1394). + "the grants issued", // What each machine has of each module, written by its node-engine under its own keys (ADR 0429 §1, issue 1136). "a machine's have", // The contents of a declaration's large files, in the bus's object bucket (ADR 0486 §2, issue 1697). diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 84ac613c..b8a8e6ba 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself. mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock -mesh-sdk 104533e8c0c9116fd1cbb02c474bafbbe229ce22 conformance/events/module-event.json +mesh-sdk f625d98713e478e37962e59e4421213770735d34 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: