diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 004a152..56d0879 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -75,7 +75,9 @@ func engineBeyond(user, g string) bool { } return strings.HasPrefix(g, "mesh.seat."+EngineSeat+".") && strings.Contains(g, "."+node) || strings.HasPrefix(g, "$SRV.") || strings.Contains(g, ".node-engine-"+node) || - strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") + strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") || + // And its node's declared packages (novox/hq ADR 0565 clause 6). + strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node } // createsAnAccountsConsumer is the grants part's create and read of a durable consumer an account needs, by its stream @@ -643,6 +645,20 @@ func healthChecks(t *testing.T) { "a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"}, "a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x"}, } + // ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the + // assigner writes its policies. + for _, u := range users { + perms, err := PermissionsFor(u) + if err != nil { + continue + } + if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) { + t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish) + } + if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") { + t.Errorf("%s writes the assigner's policies", u.Username()) + } + } // The tools account's secret is the module's own broker-, which its manifest's own-secrets names and the // vault keeps by that name (AccountSecret). if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools", diff --git a/go/buscompose/engine.go b/go/buscompose/engine.go index 501084b..2e6a902 100644 --- a/go/buscompose/engine.go +++ b/go/buscompose/engine.go @@ -3,6 +3,7 @@ package buscompose import ( "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" ) // The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module @@ -22,6 +23,16 @@ const ( // EngineActsBucket is that state's bucket. var EngineActsBucket = BucketName(EngineModule, EngineActsState) +// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the +// node's name, written by that node's engine alone. +var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState) + +// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone, +// and its read. +func EnginePackagesGrants(node string) []string { + return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...) +} + // engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it // answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the // seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR @@ -41,5 +52,9 @@ func engineGrants(p Principal) (pub, sub []string) { if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil { pub = append(pub, own...) } + // And its node's declared packages, its own key alone (ADR 0565 clause 6). + if safeSubject.MatchString(p.Node) { + pub = append(pub, EnginePackagesGrants(p.Node)...) + } return append(pub, tp...), append(sub, ts...) } diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index 1d94184..77abcd2 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -248,6 +248,10 @@ var WritersTable = []WriterRow{ Others: "read through `reads` as node-engine.have", Subjects: kvOf(delivery.HaveBucket), Writes: ownNodeKeys}, // The acts a node's engine was asked through its node seat, and its own asks (novox/hq ADR 0563 §2, §3): the // node-engine's declared state, created by the grants part, written by each node's engine under its own keys. + // A node's declared packages (novox/hq ADR 0565 clause 6): its engine's own key, read by pacman's verbs. + {State: "a machine's declared packages", Writer: "the node-engine of the machine, its key `` alone", + KeptIn: "key-value " + EnginePackagesBucket, Others: "read by pacman through `reads`", + Subjects: kvOf(EnginePackagesBucket), Writes: ownNodeKeys}, {State: "a machine's hand acts", Writer: "the node-engine of the machine, under its own keys `.>`, compare-and-set", KeptIn: "key-value " + EngineActsBucket + ", the node-engine's declared state acts", Others: "read through the node seat's verb acts", Subjects: kvOf(EngineActsBucket), diff --git a/go/catalogue/upgrade.go b/go/catalogue/upgrade.go index c8c2857..d498f56 100644 --- a/go/catalogue/upgrade.go +++ b/go/catalogue/upgrade.go @@ -3,6 +3,7 @@ package catalogue import ( "fmt" "strings" + "time" ) // What the mesh does when a module's build moves (novox/hq ADR 0236, extending ADR 0162 §3 and ADR @@ -118,3 +119,46 @@ func (m Manifest) irreplaceable() string { } return "" } + +// PersonPolicy is a person's choice of a module's upgrade policy, as the assigner keeps it in its state `policies`, one +// key per module (novox/hq ADR 0565 clause 7, issue 1953 L5-S2): the policy, why, who as the runtime proved the caller, +// and when. Set and taken back through `mesh-wants.upgrade`, each recorded as a hand act. +type PersonPolicy struct { + V int `json:"v"` + Module string `json:"module"` + Policy string `json:"policy"` + Why string `json:"why"` + By string `json:"by"` + At time.Time `json:"at"` +} + +// PersonPolicyVersion is the version of a person's policy's layout. +const PersonPolicyVersion = 1 + +// Check refuses a choice the assigner could not keep: a policy that is not roll, together or record, one other than +// roll without why, or one with no person. +func (p PersonPolicy) Check() error { + switch { + case p.Module == "": + return fmt.Errorf("a person's policy names its module") + case p.Policy != PolicyRoll && p.Policy != PolicyTogether && p.Policy != PolicyRecord: + return fmt.Errorf("%s: a policy is %q, %q or %q, not %q", p.Module, PolicyRoll, PolicyTogether, PolicyRecord, p.Policy) + case strings.TrimSpace(p.Why) == "": + return fmt.Errorf("%s: a person's policy says why", p.Module) + case strings.TrimSpace(p.By) == "": + return fmt.Errorf("%s: a person's policy names who chose it", p.Module) + } + return nil +} + +// Upgrade is a module's policy and where it comes from (ADR 0565 clause 7): the one derivation every reader takes, +// `mesh-wants.upgrade` and each node agent's judgement of a move alike. A person's choice over the module's word, its +// irreplaceable data and the default; the bus is never rolled, whoever says so (ADR 0236). By names the person when one +// chose. +func Upgrade(m Manifest, chosen *PersonPolicy) (policy, from, why, by string) { + policy, from, why = DerivedUpgrade(m) + if from == FromBus || chosen == nil { + return policy, from, why, "" + } + return chosen.Policy, FromPerson, chosen.Why, chosen.By +} diff --git a/go/catalogue/upgrade_test.go b/go/catalogue/upgrade_test.go index 6e41a73..79cfbff 100644 --- a/go/catalogue/upgrade_test.go +++ b/go/catalogue/upgrade_test.go @@ -32,6 +32,26 @@ func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) { if policy != c.policy || from != c.from { t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from) } + // ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the + // derivation as it was. + person := &PersonPolicy{V: PersonPolicyVersion, Module: c.m.Module, Policy: PolicyTogether, Why: "one zone", By: "person.jochen"} + policy, from, _, by := Upgrade(c.m, person) + wantPolicy, wantFrom, wantBy := PolicyTogether, FromPerson, "person.jochen" + if c.from == FromBus { + wantPolicy, wantFrom, wantBy = c.policy, FromBus, "" + } + if policy != wantPolicy || from != wantFrom || by != wantBy { + t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by) + } + if policy, from, _, _ := Upgrade(c.m, nil); policy != c.policy || from != c.from { + t.Errorf("%s with no choice: %s from %s", c.name, policy, from) + } + } + for _, p := range []PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: PolicyRecord, By: "p"}, + {Module: "m", Policy: PolicyRecord, Why: "x"}} { + if p.Check() == nil { + t.Errorf("%+v was accepted", p) + } } } diff --git a/go/delivery/control_records.go b/go/delivery/control_records.go new file mode 100644 index 0000000..9d9f869 --- /dev/null +++ b/go/delivery/control_records.go @@ -0,0 +1,95 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// **The control records' layouts, out of the controller** (novox/hq ADR 0565 clauses 2, 3 and 6; issue 1953 L5-S2): what +// the indexer keeps of the catalogue's predefined list and of each open pull request's config declarations, which +// mesh-config reads in place of the controller's mirrors and `pull-declares`; and each node's declared packages, which +// its engine writes in place of the controller's mirror. + +// The indexer's two states (clause 2). +const ( + // IndexerPredefinedState holds one key, PredefinedListKey: the catalogue's predefined.json at the newest merge taken. + IndexerPredefinedState = "predefined" + PredefinedListKey = "list" + // IndexerConfigCandidatesState holds one key per open pull request (ConfigCandidateKey). + IndexerConfigCandidatesState = "config-candidates" +) + +// PredefinedKept is the indexer's key `predefined.list`: the list whole, as predefined.json holds it, with the merge it +// was read at. A merge older than the one the key names never replaces it (ADR 0348 §7). +type PredefinedKept struct { + V int `json:"v"` + Commit string `json:"commit"` + At time.Time `json:"at"` + List json.RawMessage `json:"list"` +} + +// ConfigDeclaration is one config key a module's manifest declares, in the layout of the controller's declared-config +// mirror (mesh-controller internal/inventory DeclaredMirrored) unchanged, so mesh-config reads the indexer's as it read +// the mirror's. +type ConfigDeclaration struct { + Kind string `json:"kind"` + Default json.RawMessage `json:"default"` + Why string `json:"why"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Enum json.RawMessage `json:"enum,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Pattern string `json:"pattern,omitempty"` + Trusted bool `json:"trusted,omitempty"` + Predefined string `json:"predefined,omitempty"` + Required bool `json:"required,omitempty"` + Runtime bool `json:"runtime,omitempty"` +} + +// ConfigCandidate is one open pull request's key of `config-candidates`: its head, and per module the config +// declarations the head's manifests add or change against the trunk's, an empty set when none. Written at each new +// head and each trunk merge that changes a module it names; deleted when the pull request merges or closes. +type ConfigCandidate struct { + V int `json:"v"` + Pull string `json:"pull"` + Head string `json:"head"` + Declares map[string]map[string]ConfigDeclaration `json:"declares"` + At time.Time `json:"at"` +} + +// ConfigCandidateKey is a pull request's key, `owner/repository#number` with `/` and `#` as dots, the form mesh-config +// keeps a pull request's values under. +func ConfigCandidateKey(pull string) (string, error) { + owner, rest, ok := strings.Cut(strings.TrimSpace(pull), "/") + repo, number, ok2 := strings.Cut(rest, "#") + if !ok || !ok2 || owner == "" || repo == "" || number == "" || strings.ContainsAny(owner+repo+number, ".*> ") { + return "", fmt.Errorf("delivery: %q is not a pull request, owner/repository#number", pull) + } + return owner + "." + repo + "." + number, nil +} + +// The node-engine's packages (clause 6): its state `packages`, one key, the node's name, written by that node's engine +// alone. +const EnginePackagesState = "packages" + +// PackageDeclared is one package a node's modules declare, the controller's declared-packages mirror's entry unchanged +// (mesh-controller internal/inventory PackageDeclared): its name, the modules that declare it that way, sorted, and +// whether they declare it absent. +type PackageDeclared struct { + Package string `json:"package"` + Modules []string `json:"modules"` + Absent bool `json:"absent"` +} + +// DeclaredPackages is one node's key of `packages`, the mirror's value unchanged: 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 DeclaredPackages struct { + Node string `json:"node"` + Packages []PackageDeclared `json:"packages"` +} + +// The assigner's state of a person's upgrade policy, one key per module (clause 7; catalogue.PersonPolicy). +const AssignerPoliciesState = "policies" diff --git a/go/delivery/control_records_test.go b/go/delivery/control_records_test.go new file mode 100644 index 0000000..6e4d42b --- /dev/null +++ b/go/delivery/control_records_test.go @@ -0,0 +1,45 @@ +package delivery + +import ( + "encoding/json" + "testing" +) + +// ADR 0565 clauses 2 and 6 (issue 1953 L5-S2): the control records' layouts read what their writers write. The +// packages value is the controller's declared-packages mirror's, as its writer's own test pins it (mesh-controller +// internal/inventory/declared_packages_test.go at 0fbb5ef3); the config declaration is its declared-config mirror's +// (DeclaredMirrored); a pull request's key is mesh-config's form. +func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { + for _, c := range []struct { + name string + raw string + into any + }{ + {"declared packages", `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` + + `{"package":"nano","modules":["web"],"absent":true}]}`, &DeclaredPackages{}}, + {"a config candidate", `{"v":1,"pull":"novox/mesh-catalog#42","head":"abc","declares":{"mail":{"quota":{"kind":"config",` + + `"default":10,"why":"a quota","type":"integer","minimum":1}}},"at":"2026-10-29T12:00:00Z"}`, &ConfigCandidate{}}, + {"the predefined list", `{"v":1,"commit":"abc","at":"2026-10-29T12:00:00Z","list":{"domain":{"type":"string"}}}`, &PredefinedKept{}}, + } { + if err := json.Unmarshal([]byte(c.raw), c.into); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + again, _ := json.Marshal(c.into) + var want, got any + _ = json.Unmarshal([]byte(c.raw), &want) + _ = json.Unmarshal(again, &got) + if string(jsonOf(want)) != string(jsonOf(got)) || (c.name == "declared packages" && string(again) != c.raw) { + t.Errorf("%s reads back as %s", c.name, again) + } + } + for pull, want := range map[string]string{"novox/mesh-catalog#42": "novox.mesh-catalog.42", "x#1": "", "a/b": "", "a/b.c#1": ""} { + if got, err := ConfigCandidateKey(pull); got != want || (want == "") != (err != nil) { + t.Errorf("%s keyed %q, %v", pull, got, err) + } + } +} + +func jsonOf(v any) []byte { + b, _ := json.Marshal(v) + return b +}