diff --git a/go/buscompose/buscompose_test.go b/go/buscompose/buscompose_test.go index 8e3bbc5..714a8b8 100644 --- a/go/buscompose/buscompose_test.go +++ b/go/buscompose/buscompose_test.go @@ -96,6 +96,9 @@ func engineBeyond(user, g string) bool { 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 || + // And, read, the controller's mirror's key of its node, until the lease write (novox/hq ADR 0565 clause 6). + strings.Contains(g, "KV_"+DeclaredPackagesBucket) && !strings.HasPrefix(g, "$KV.") && + (!strings.Contains(g, "$KV.") || strings.HasSuffix(g, "$KV."+DeclaredPackagesBucket+"."+node)) || // And its own health report (issue 774). strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node || // And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6). @@ -815,7 +818,11 @@ func healthChecks(t *testing.T) { MayPublish(perms, "$KV.mesh-host_health.b")) { t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish) } - if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) { + // ADR 0565 clause 6: and it reads the controller's mirror's key of its own node alone, and writes none of it. + if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") || + !MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a") || + MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.b") || + MayPublish(perms, "$KV.mesh-controller_declared-packages.a")) { 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") { diff --git a/go/buscompose/engine.go b/go/buscompose/engine.go index 6426db7..56d93ce 100644 --- a/go/buscompose/engine.go +++ b/go/buscompose/engine.go @@ -74,9 +74,11 @@ func engineGrants(p Principal) (pub, sub []string) { if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil { pub = append(pub, own...) } - // And its node's declared packages, its own key alone (ADR 0565 clause 6). + // And its node's declared packages, its own key alone (ADR 0565 clause 6); and, read, the controller's mirror's key + // of its node, which the node seat's verb packages compares it with until the lease write ends the mirror. if safeSubject.MatchString(p.Node) { pub = append(pub, EnginePackagesGrants(p.Node)...) + pub = append(pub, readGrants(DeclaredPackagesBucket, p.Node)...) } return append(pub, tp...), append(sub, ts...) } diff --git a/go/buscompose/writers.go b/go/buscompose/writers.go index fc83892..a6541d2 100644 --- a/go/buscompose/writers.go +++ b/go/buscompose/writers.go @@ -288,7 +288,8 @@ var WritersTable = []WriterRow{ 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, + KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages + + ", and each node's engine its node's key, until the lease write (ADR 0565 clause 6)", 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 diff --git a/go/delivery/control_records_test.go b/go/delivery/control_records_test.go index 0356807..419ee62 100644 --- a/go/delivery/control_records_test.go +++ b/go/delivery/control_records_test.go @@ -142,6 +142,40 @@ func TestTheControlRecordsReadTheirWritersValues(t *testing.T) { t.Errorf("%s keyed %q, %v", pull, got, err) } } + // ADR 0565 §6: the engine works out its node's key of `packages` from the compositions it composed, the mirror's bytes + // for the same resources (mesh-controller internal/inventory/declared_packages_test.go aNodesPackages at 0fbb5ef3): + // a module's own composition, the node's own with a folded module's resource named by its id alone, and a file. + res := func(s ...string) []json.RawMessage { + var out []json.RawMessage + for _, r := range s { + out = append(out, json.RawMessage(r)) + } + return out + } + comps := []Composed{ + {Node: "one", Module: "web", Resources: res(`{"id":"pkg-curl","type":"package","package":"curl"}`, + `{"id":"pkg-nano","type":"package","package":"nano","absent":true}`, `{"id":"conf","type":"file","path":"/etc/web.conf"}`)}, + {Node: "one", Resources: res(`{"id":"backup.pkg-curl","type":"package","package":"curl"}`)}, + } + want := `{"node":"one","packages":[{"package":"curl","modules":["backup","web"],"absent":false},` + + `{"package":"nano","modules":["web"],"absent":true}]}` + got, err := DeclaredPackagesOf("one", comps) + if raw, _ := json.Marshal(got); err != nil || string(raw) != want { + t.Errorf("the node's packages are %s (%v)", raw, err) + } + if none, _ := DeclaredPackagesOf("one", nil); string(jsonOf(none)) != `{"node":"one","packages":[]}` || EnginePackagesRead != "mesh-host.packages" { + t.Errorf("a node declaring no package is %s", jsonOf(none)) + } + // Their difference with the mirror: a package only one side holds, and one whose modules differ; none when they agree. + mirror := DeclaredPackages{Node: "one", Packages: []PackageDeclared{{Package: "curl", Modules: []string{"web"}}, + {Package: "nano", Modules: []string{"web"}, Absent: true}, {Package: "vim", Modules: []string{"edit"}}}} + if d := DiffDeclaredPackages(got, mirror); len(d) != 2 || d[0].Package != "curl" || strings.Join(d[0].Engine, ",") != "backup,web" || + d[1].Package != "vim" || len(d[1].Engine) != 0 || d[1].Mirror[0] != "edit" { + t.Errorf("the difference is %+v", d) + } + if d := DiffDeclaredPackages(got, got); d == nil || len(d) != 0 { + t.Errorf("one declaration differs from itself: %+v", d) + } } func jsonOf(v any) []byte { diff --git a/go/delivery/enginepackages.go b/go/delivery/enginepackages.go new file mode 100644 index 0000000..4d197ad --- /dev/null +++ b/go/delivery/enginepackages.go @@ -0,0 +1,137 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// **A node's declared packages, worked out by its engine** (novox/hq ADR 0565 §1, §6; issue 1953): the derivation and +// the comparison of the node-engine's state `packages` (EnginePackagesState, layout DeclaredPackages), so the engine +// that writes the key, the node seat's verb that compares it with the controller's mirror, and any reader work them out +// one way. The controller's own copy of the derivation (mesh-controller internal/inventory DeclaredPackagesValue) is +// not changed (ADR 0547 §1) and ends with its lease. + +// EnginePackagesRead is the engine's state `packages` as a reader names it in `reads`: the node-engine's manifest is +// mesh-host's, so its bucket is mesh-host_packages, read as disk-load reads mesh-host.data (EngineDataRead). +const EnginePackagesRead = "mesh-host." + EnginePackagesState + +// DeclaredPackagesOf is a node's key of `packages` from the compositions its engine composed for the node's wants: +// each resource of type package, by its name and whether it is declared absent, with the modules that declare it so, +// in the mirror's layout and order (names sorted, absent after present, modules sorted). A resource's module is the +// one it names, else its composition's, else the guess from its id (declaration.ModuleGuess); a package of no module +// lists none. +func DeclaredPackagesOf(node string, compositions []Composed) (DeclaredPackages, error) { + by := map[packageKey]map[string]bool{} + for _, c := range compositions { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Type string `json:"type"` + Package string `json:"package"` + Absent bool `json:"absent"` + Module string `json:"module"` + } + if err := json.Unmarshal(raw, &r); err != nil { + return DeclaredPackages{}, fmt.Errorf("delivery: a resource of %s's composition %q is not readable: %w", node, c.Module, err) + } + if r.Type != "package" || r.Package == "" { + continue + } + k := packageKey{r.Package, r.Absent} + if by[k] == nil { + by[k] = map[string]bool{} + } + module := r.Module + if module == "" { + module = c.Module + } + if module == "" { + module, _ = declaration.ModuleGuess(r.ID) + } + if module != "" { + by[k][module] = true + } + } + } + v := DeclaredPackages{Node: node, Packages: []PackageDeclared{}} + for _, k := range sortedPackageKeys(by) { + 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 v, nil +} + +// PackageDifference is one package two declarations of a node hold differently: the modules each side declares it +// with (present, or absent when Absent), none on a side that does not hold it. +type PackageDifference struct { + Package string `json:"package"` + Absent bool `json:"absent"` + Engine []string `json:"engine"` + Mirror []string `json:"mirror"` +} + +// DiffDeclaredPackages is every package the engine's key and the controller's mirror hold differently for one node, in +// the layout's order: what the node seat's verb packages answers until the lease write (ADR 0565 §6). Empty when they +// agree. +func DiffDeclaredPackages(engine, mirror DeclaredPackages) []PackageDifference { + index := func(d DeclaredPackages) map[packageKey][]string { + out := map[packageKey][]string{} + for _, p := range d.Packages { + out[packageKey{p.Package, p.Absent}] = append([]string{}, p.Modules...) + } + return out + } + e, m := index(engine), index(mirror) + union := map[packageKey]bool{} + for k := range e { + union[k] = true + } + for k := range m { + union[k] = true + } + out := []PackageDifference{} + for _, k := range sortedPackageKeys(union) { + em, inE := e[k] + mm, inM := m[k] + if inE && inM && slices.Equal(em, mm) { + continue + } + if em == nil { + em = []string{} + } + if mm == nil { + mm = []string{} + } + out = append(out, PackageDifference{Package: k.name, Absent: k.absent, Engine: em, Mirror: mm}) + } + return out +} + +// packageKey is a package as the layout tells it apart: its name, and whether it is declared absent. +type packageKey struct { + name string + absent bool +} + +// sortedPackageKeys is the keys of m by name, absent after present. +func sortedPackageKeys[V any](m map[packageKey]V) []packageKey { + keys := make([]packageKey, 0, len(m)) + for k := range m { + 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 + }) + return keys +}