issue 1840/S3: keep each node's declared packages on the bus, so the pacman verbs can say what a node declares

This commit is contained in:
2026-10-22 14:23:58 +02:00
parent cc8a74fce4
commit 485eb44de4
13 changed files with 360 additions and 9 deletions
@@ -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))
}
}
+63
View File
@@ -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)
}