From 7ea6d36eab54186be5a4fc5a3949767dbf7c2513 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 23:02:31 +0200 Subject: [PATCH] Pin the node-engine at its ADR 0328 merge (#95), and speak its contract: a library's verdict is said for the modules that use it --- cmd/mesh-controller/kept_libraries_test.go | 54 +++++- cmd/mesh-controller/module_health.go | 49 ++++- go.mod | 2 +- go.sum | 4 +- internal/catalogue/libraries.go | 23 +-- internal/catalogue/libraries_test.go | 23 ++- testdata/beside/CAPTURED | 2 +- testdata/store-tests | 1 + .../internal/declaration/declaration.go | 173 +++++++++++++++--- vendor/modules.txt | 4 +- 10 files changed, 291 insertions(+), 44 deletions(-) diff --git a/cmd/mesh-controller/kept_libraries_test.go b/cmd/mesh-controller/kept_libraries_test.go index 27732ca3..f65ff83a 100644 --- a/cmd/mesh-controller/kept_libraries_test.go +++ b/cmd/mesh-controller/kept_libraries_test.go @@ -364,14 +364,14 @@ func TestTheDeclarationSaysItsLibrariesOnlyWhenComposed(t *testing.T) { } newer, err := sendable{Resources: []map[string]any{{"id": "a"}}, Groups: []catalogue.GroupEntry{{Name: "mesh-media", GID: 50010}}, - Identities: []catalogue.IdentityEntry{{Module: "radarr", Name: "mesh-radarr", UID: 50020, GID: 50020, Groups: []string{"mesh-media"}}}, + Identities: []catalogue.IdentityEntry{{Module: "radarr", Name: "mesh-radarr", UID: 50020, GID: 50010}}, Libraries: []catalogue.LibraryEntry{{Library: "media", Root: "/storage/media", Group: "mesh-media", GID: 50010, Home: true, Paths: []string{"movies"}, Runner: "jochen"}}}.Body() if err != nil { t.Fatal(err) } for _, want := range []string{`"groups":[{"name":"mesh-media","gid":50010}]`, - `"identities":[{"module":"radarr","name":"mesh-radarr","uid":50020,"gid":50020,"groups":["mesh-media"]}]`, + `"identities":[{"module":"radarr","name":"mesh-radarr","uid":50020,"gid":50010}]`, `"libraries":[{"library":"media","root":"/storage/media","group":"mesh-media","gid":50010,"home":true,` + `"taken":false,"paths":["movies"],"runner":"jochen"}]`} { if !strings.Contains(string(newer), want) { @@ -418,3 +418,53 @@ func TestAnUnknownLibraryIsJudgedByWhetherTheMoveChangesIt(t *testing.T) { t.Fatal("a move that adds a library, and joins, reads unchanged — or an unchanged one reads changed") } } + +// The engine states a library once, of no module; each module assigned to the machine that uses it is judged and said +// by a copy of that verdict — its wait excused and passed by the gate as a wait for a person. A library unknown or +// starting (a take walking under it) of a move that changes no library is not counted. +func TestALibrarysVerdictIsSaidForTheModulesThatUseIt(t *testing.T) { + inv := inventory.ForTest(t) + ctx := t.Context() + if _, err := inv.AddNode(ctx, "ace"); err != nil { + t.Fatal(err) + } + for _, m := range []catalogue.Manifest{radarrManifest, plexManifest, {Module: "plain", Version: "1"}} { + if err := inv.RegisterModule(ctx, m, inventory.Source{}); err != nil { + t.Fatal(err) + } + if _, err := inv.Assign(ctx, "ace", m.Module); err != nil { + t.Fatal(err) + } + } + lib := inventory.ResourceHealth{Resource: "library:media", Kind: link.KindLibrary, Target: "/storage/media", + State: link.StateWaiting, Waits: []inventory.Wait{{Part: "media", Access: "library:media", Reason: link.WaitKeep, + Placed: "fs-000000000001", What: "let the mesh keep the library media"}}} + copies := libraryVerdictsFor(ctx, inv, "ace", []inventory.ResourceHealth{lib}) + var modules []string + for _, c := range copies { + modules = append(modules, c.Module) + } + if strings.Join(modules, ",") != "plex,radarr" { + t.Fatalf("the library's verdict is said for %v; want plex and radarr, the modules using it", modules) + } + now := time.Now() + f := gateFacts{now: now, waits: libraryFactsWith(), groupsAdded: map[string]bool{"radarr": false}, + accessesChanged: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, Resources: copies}}} + if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthPerson { + t.Fatalf("radarr waiting on media reads %v %q; want a wait for a person", h, why) + } + for _, state := range []string{link.StateUnknown, link.StateStarting} { + c := copies[1] + c.State, c.Waits = state, nil + f.health["ace"] = inventory.NodeHealth{Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{c}} + if h, why := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthGood { + t.Errorf("media %s, a move that changes no library: %v %q", state, h, why) + } + f.accessesChanged = map[string]bool{"radarr": true} + if h, _ := moduleHealthWord("radarr", "ace", now.Add(-time.Minute), f); h != healthNotYet { + t.Errorf("media %s, a move that changes a library: %v; want held to the bound", state, h) + } + f.accessesChanged = map[string]bool{"radarr": false} + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index b3cfaf8b..032f6ad8 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -40,6 +40,47 @@ const ( moduleUnhealthyAfter = 2 ) +// libraryVerdictsFor is, for each library verdict in a statement (kind library, no module), one copy for each module +// assigned to the machine whose manifest uses that library: what the module is judged and said by. None when the +// assignments or the catalogue cannot be read: the library's own verdict stands alone, and no module is excused by it. +func libraryVerdictsFor(ctx context.Context, inv *inventory.Inventory, node string, + rs []inventory.ResourceHealth) []inventory.ResourceHealth { + var libraries []inventory.ResourceHealth + for _, r := range rs { + if r.Module == "" && r.Kind == link.KindLibrary && strings.HasPrefix(r.Resource, libraryAccess) { + libraries = append(libraries, r) + } + } + if len(libraries) == 0 || inv == nil { + return nil + } + assigned, err := inv.Assigned(ctx, node) + if err != nil { + // empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make + return nil + } + shelf, err := inv.Catalogue(ctx) + if err != nil { + // empty-on-error: the library's own verdict is kept and judged; no module is excused by a copy it could not make + return nil + } + var out []inventory.ResourceHealth + for _, lib := range libraries { + name := strings.TrimPrefix(lib.Resource, libraryAccess) + for _, module := range assigned { + for _, u := range shelf[module].Libraries { + if u.Library == name { + copied := lib + copied.Module = module + out = append(out, copied) + break + } + } + } + } + return out +} + // healthRefused counts the statements refused as older than the one kept, for the log and a test. var healthRefused atomic.Int64 @@ -85,6 +126,9 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke } resources = append(resources, kept) } + // **A library's verdict is said for the modules that use it** (novox/hq ADR 0328 §8): the engine states each + // library once, as `library:`, of no module; a module that uses it waits with it, and is said so. + resources = append(resources, libraryVerdictsFor(ctx, inv, node, resources)...) // **A wait for the operator is checked before it is excused** (novox/hq ADR 0283): a waiting resource whose // wait does not check out is judged unhealthy, saying why; one that does is kept beside the unhealthy ones, so // judgeModuleHealth can say it as needs-operator. What is stored is what the machine said. @@ -688,7 +732,10 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea // no wait. Of a module whose move leaves its accesses as they were it is a condition the move found, not // counted; of one whose move changes them it is not yet healthy, held to the gate's bound like anything still // unsettled, so the gate fails safe at the bound and never waits for ever. - if r.Kind == link.KindAccess && r.State == link.StateUnknown && !changes { + // The same for a library (ADR 0328): unknown this look, or starting while a take walks under it, of a move that + // leaves the module's libraries as they were, is not this move's. + if (r.Kind == link.KindAccess || r.Kind == link.KindLibrary) && !changes && + (r.State == link.StateUnknown || (r.Kind == link.KindLibrary && r.State == link.StateStarting)) { continue } switch r.State { diff --git a/go.mod b/go.mod index ed14f26c..46283c1e 100644 --- a/go.mod +++ b/go.mod @@ -36,4 +36,4 @@ require ( // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // `go mod vendor` fails loudly, at once, everywhere. -replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b diff --git a/go.sum b/go.sum index 5c037bcc..7e629522 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e h1:RxvY7T12EizGs2e9W1dVZFEnQFJBPNKRY+oUPgovcBc= -git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= +git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b h1:NvXKvFi4lV/oXg29dQ9iUX5oQm+NtPN9ACTa9Ev1i8E= +git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/internal/catalogue/libraries.go b/internal/catalogue/libraries.go index ec9275d1..dcd74d87 100644 --- a/internal/catalogue/libraries.go +++ b/internal/catalogue/libraries.go @@ -468,6 +468,7 @@ type Joined struct { Primary string PrimaryGID int Added []string + AddedGIDs []int } // librariesOf is the libraries a module uses, by name, in the order its entries first name them. @@ -504,6 +505,7 @@ func joinedOf(m Manifest, with Rendering) (Joined, error) { continue } j.Added = append(j.Added, MeshName(lib)) + j.AddedGIDs = append(j.AddedGIDs, gid) } return j, nil } @@ -723,14 +725,15 @@ type GroupEntry struct { GID int `json:"gid"` } -// IdentityEntry is one joining module's user as a machine's declaration names it (§4): its own group has its uid, -// and Groups are its library groups, the primary first. +// IdentityEntry is one joining module's user as a machine's declaration names it (§4; mesh-host internal/declaration +// Identity): the engine always makes its own group with its uid; GID is its primary group's number — its first +// library's, or its own — and Groups the numbers of its added groups. type IdentityEntry struct { - Module string `json:"module"` - Name string `json:"name"` - UID int `json:"uid"` - GID int `json:"gid"` - Groups []string `json:"groups,omitempty"` + Module string `json:"module"` + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` + Groups []int `json:"groups,omitempty"` } // libraryEnvelope is what a machine's declaration says of libraries and joining modules: the group of every library @@ -748,10 +751,8 @@ func (r Resolution) libraryEnvelope(with Rendering, joined []Joined) ([]GroupEnt sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name }) var ids []IdentityEntry for _, j := range joined { - e := IdentityEntry{Module: j.Module, Name: IdentityName(j.Module), UID: j.UID, GID: j.UID} - if j.Primary != IdentityName(j.Module) { - e.Groups = append([]string{j.Primary}, j.Added...) - } + e := IdentityEntry{Module: j.Module, Name: IdentityName(j.Module), UID: j.UID, GID: j.PrimaryGID, + Groups: append([]int(nil), j.AddedGIDs...)} ids = append(ids, e) } sort.Slice(ids, func(a, b int) bool { return ids[a].Module < ids[b].Module }) diff --git a/internal/catalogue/libraries_test.go b/internal/catalogue/libraries_test.go index 8152a6bb..bbe6a999 100644 --- a/internal/catalogue/libraries_test.go +++ b/internal/catalogue/libraries_test.go @@ -6,6 +6,8 @@ import ( "reflect" "strings" "testing" + + "github.com/novox/mesh-host/validate" ) // Defends novox/hq ADR 0328 on the controller's side, row by row of its "How it is checked" table where the row is the @@ -90,7 +92,7 @@ func TestAJoiningModuleIsComposedWithItsIdentityAndLibraries(t *testing.T) { t.Errorf("its directory is owned by %v; want its user and primary group", owner) } if len(composed.Identities) != 1 || !reflect.DeepEqual(composed.Identities[0], IdentityEntry{Module: "radarr", - Name: "mesh-radarr", UID: 50020, GID: 50020, Groups: []string{"mesh-media", "mesh-books"}}) { + Name: "mesh-radarr", UID: 50020, GID: 50010, Groups: []int{50011}}) { t.Errorf("the identities are %+v", composed.Identities) } if w := LibraryWarnings(aRadarr(t)); len(w) != 1 || !strings.Contains(w[0], "reaches only media") { @@ -317,3 +319,22 @@ func TestTheLibrariesAreTheOperatorsSetting(t *testing.T) { t.Fatal("a library in the machine's own tree was accepted") } } + +// What the node-engine this controller pins (mesh-host #95, ADR 0328's half) makes of a composed declaration with +// libraries and a joining module: nothing refused. +func TestTheEngineTakesWhatIsComposedForLibraries(t *testing.T) { + for _, node := range []string{"ace", "novox"} { + r := Resolution{Node: node, Account: "jochen", Modules: []Manifest{aRadarr(t), theRuntime(t)}} + with := librariesRendering() + with.Libraries["books"] = Library{Name: "books", Home: "ace", Root: "/storage/books", Reach: []string{"novox"}} + composed := composeLibraries(t, r, with) + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources, + "groups": composed.Groups, "identities": composed.Identities, "libraries": composed.Libraries}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Fatalf("%s: the pinned node-engine refuses it: %v", node, problems) + } + } +} diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index cd6e20a3..996e8683 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev a second place to clean besides the catalogue itself. mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile -mesh-host 303d4037e84ea4968da9880a11f4d5a374b3714a examples/foundation-first-node-nats.lock +mesh-host d8e785bb4b4bbd4ad99dccec60c588032cd78fa6 examples/foundation-first-node-nats.lock mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/testdata/store-tests b/testdata/store-tests index 8bbaf02c..3ded28a5 100644 --- a/testdata/store-tests +++ b/testdata/store-tests @@ -42,3 +42,4 @@ ./internal/inventory TestALibraryIsTakenOnceUnderAWarrant ./internal/inventory TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess ./internal/inventory TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead +./cmd/mesh-controller TestALibrarysVerdictIsSaidForTheModulesThatUseIt diff --git a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index a8b89cd1..16ffdc84 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "net" + "path/filepath" "reflect" "regexp" "slices" @@ -456,10 +457,9 @@ const ( // **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own — // it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A // media library, a download spool is the operator's: it existed before the mesh, several modules -// read and write it at once, and the mesh must not chown, chmod or remove it. The host confirms it -// is there and mounts it, and keeps it usable by the identities that use it through ACL entries it -// adds and nothing else (novox/hq ADR 0325 §5); a missing one is made only below a parent on the -// filesystem the data was placed on (§9). +// read and write it at once, and the mesh must not create, chown, chmod or remove it. The host +// confirms it is there and mounts it; nothing else. Library data is a library's (novox/hq ADR 0328), +// not an access. type Access struct { ID string `json:"id"` Type Type `json:"type"` @@ -467,15 +467,11 @@ type Access struct { // Mode is how this module reaches the path: read or read-write. Absent narrows to read. Mode string `json:"mode,omitempty"` - // Users are the identities that use the folder, resolved by the controller (novox/hq ADR 0325 §3, §4): each - // module identity whose container mounts it, and the tool runner's account standing for every module whose - // tools use it. The engine keeps one ACL entry per user on the folder and as its default (§5); several - // accesses naming one folder are kept as one, each uid at the widest use any of them gives it. Absent is a - // controller older than ADR 0325, or an access nobody but others' bits reach (§2): nothing is kept. + // Users and Taken are what a controller speaking ADR 0325 sends (the identities that use the folder, and + // that the operator's warrant was carried out): accepted, so its declaration is not refused whole, and not + // acted on since ADR 0328 superseded ADR 0325 — no ACL entry is kept. Users []AccessUser `json:"users,omitempty"` - // Taken is the controller saying the operator's warrant for this folder was carried out (§8): the folder is - // kept although it holds data. The engine also keeps a folder it took itself, on the act, or found empty. - Taken bool `json:"taken,omitempty"` + Taken bool `json:"taken,omitempty"` // Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted // so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5). @@ -1542,19 +1538,129 @@ type Declaration struct { // not refused for it. PutBack bool - // Identities is the identity of every module assigned to this machine (novox/hq ADR 0325 §3): an account + // Identities is the identity of every module assigned to this machine (novox/hq ADR 0328 §4): an account // and a group `mesh-` with the numbers the controller allocated once for the module, the same on // every machine. The engine makes each before any resource is applied, and removes what it made of one no // longer declared. Absent is a controller older than ADR 0325. Identities []Identity + + // Groups is the group of every library this machine is reached by (novox/hq ADR 0328 §3): `mesh-` + // with the number the controller allocated once, mesh-wide. The engine makes each, and removes what it made of + // one no longer declared. Absent is a controller older than ADR 0328. + Groups []Group + + // Libraries is every library this machine is reached by (ADR 0328 §1, §6, §9): its root here, its group, whether + // this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for, + // and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328. + Libraries []Library } -// Identity is one module's identity, as the controller allocated it. +// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-` with +// uid, its own group of the same name and number, its primary group gid (its own, or its first library's group), +// and the groups of its other libraries as added groups. type Identity struct { Module string `json:"module"` Name string `json:"name"` UID int `json:"uid"` GID int `json:"gid"` + Groups []int `json:"groups,omitempty"` +} + +// Group is one library's group, as the controller allocated it (ADR 0328 §3). +type Group struct { + Name string `json:"name"` + GID int `json:"gid"` +} + +// GroupPrefix starts every library group's name, as IdentityPrefix starts every module's. +const GroupPrefix = "mesh-" + +// LibraryAccessPrefix starts the access a library's wait and its take act name: `library:`. +const LibraryAccessPrefix = "library:" + +// Library is one library as this machine sees it (ADR 0328). +type Library struct { + Library string `json:"library"` + // Root is the library's root on this machine: on its home the data itself, elsewhere the mount point the mounts + // module mounts it at. + Root string `json:"root"` + Group string `json:"group"` + GID int `json:"gid"` + // Home is that the library's data lives on this machine: only there does the engine keep it. + Home bool `json:"home"` + // Taken is the controller saying the operator's warrant to take it was carried out (§7). + Taken bool `json:"taken,omitempty"` + // Paths are the sub-paths its modules ask for, relative to the root. + Paths []string `json:"paths,omitempty"` + // Runner is the tool runner's account, put in the library's group (§11). Absent when no module's tools use it. + Runner string `json:"runner,omitempty"` +} + +// checkLibraries judges the groups and libraries on arrival: every library's group declared, named for it, never +// root's number; paths that stay below the root; a runner that is an account's name; and no two roots that nest +// (§13). +func checkLibraries(groups []Group, libs []Library) []string { + var problems []string + byName := map[string]int{} + numbers := map[int]string{} + for _, g := range groups { + where := fmt.Sprintf("group %q", g.Name) + if !strings.HasPrefix(g.Name, GroupPrefix) || !accountName.MatchString(g.Name) { + problems = append(problems, fmt.Sprintf("%s: a library's group is named %s", where, GroupPrefix)) + } + if g.GID <= 0 { + problems = append(problems, fmt.Sprintf("%s: gid %d, and a library's group is never root, nor a number "+ + "below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, g.GID)) + } + if _, twice := byName[g.Name]; twice { + problems = append(problems, where+": declared twice") + } + if other, used := numbers[g.GID]; used { + problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, g.GID, other)) + } + byName[g.Name], numbers[g.GID] = g.GID, g.Name + } + seen := map[string]bool{} + for _, l := range libs { + where := fmt.Sprintf("library %q", l.Library) + switch { + case l.Library == "" || strings.ContainsAny(l.Library, "/ \t\n"): + problems = append(problems, where+": a library is named by one word") + case seen[l.Library]: + problems = append(problems, where+": declared twice") + } + seen[l.Library] = true + if !filepath.IsAbs(l.Root) || filepath.Clean(l.Root) != l.Root || l.Root == "/" { + problems = append(problems, fmt.Sprintf("%s: its root %q is not an absolute, clean path below /", where, + l.Root)) + } + if gid, ok := byName[l.Group]; !ok || gid != l.GID { + problems = append(problems, fmt.Sprintf("%s: its group %s (gid %d) is not one this declaration makes", + where, l.Group, l.GID)) + } + if l.Group != GroupPrefix+l.Library { + problems = append(problems, fmt.Sprintf("%s: its group is %s%s, and %s is not", where, GroupPrefix, + l.Library, l.Group)) + } + for _, p := range l.Paths { + if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || p == ".." || strings.HasPrefix(p, "../") { + problems = append(problems, fmt.Sprintf("%s: the path %q is not a clean path below its root", where, p)) + } + } + if l.Runner != "" && !accountName.MatchString(l.Runner) { + problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, l.Runner)) + } + } + for i, a := range libs { + for _, b := range libs[i+1:] { + if a.Root != "" && b.Root != "" && (a.Root == b.Root || strings.HasPrefix(a.Root, b.Root+"/") || + strings.HasPrefix(b.Root, a.Root+"/")) { + problems = append(problems, fmt.Sprintf("library %q: its root and library %q's nest, and libraries "+ + "never overlap (novox/hq ADR 0328 §13)", a.Library, b.Library)) + } + } + } + return problems } // IdentityPrefix starts every module identity's name. @@ -1562,9 +1668,13 @@ const IdentityPrefix = "mesh-" // checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number // twice — two modules with one account would write as each other. -func checkIdentities(ids []Identity) []string { +func checkIdentities(ids []Identity, groups []Group) []string { var problems []string - names, uids, gids := map[string]bool{}, map[int]string{}, map[int]string{} + names, uids := map[string]bool{}, map[int]string{} + libraryGroups := map[int]bool{} + for _, g := range groups { + libraryGroups[g.GID] = true + } for _, id := range ids { where := fmt.Sprintf("identity %q", id.Name) if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) { @@ -1573,7 +1683,7 @@ func checkIdentities(ids []Identity) []string { } if id.UID <= 0 || id.GID <= 0 { problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+ - "nor a number below it (novox/hq ADR 0325 §11); nothing of this declaration was made", where, + "nor a number below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, id.UID, id.GID)) } if names[id.Name] { @@ -1582,10 +1692,22 @@ func checkIdentities(ids []Identity) []string { if other, used := uids[id.UID]; used { problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other)) } - if other, used := gids[id.GID]; used { - problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other)) + // Its primary group is its own (gid == uid) or a library's this declaration makes (ADR 0328 §4); so is + // every added group. A module's own number is never a library group's. + if id.GID != id.UID && !libraryGroups[id.GID] { + problems = append(problems, fmt.Sprintf("%s: its primary group gid %d is neither its own (%d) nor a "+ + "library's this declaration makes", where, id.GID, id.UID)) } - names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name + if libraryGroups[id.UID] { + problems = append(problems, fmt.Sprintf("%s: uid %d is a library group's number too", where, id.UID)) + } + for _, g := range id.Groups { + if !libraryGroups[g] { + problems = append(problems, fmt.Sprintf("%s: its added group gid %d is not a library's this "+ + "declaration makes", where, g)) + } + } + names[id.Name], uids[id.UID] = true, id.Name } return problems } @@ -1757,9 +1879,13 @@ type envelope struct { // sends them only to a host whose reports say `reads_generation`. Generation int64 `json:"generation,omitempty"` PutBack bool `json:"put_back,omitempty"` - // Identities is optional on the wire too (novox/hq ADR 0325 §3). **An older host refuses this key**; a + // Identities is optional on the wire too (novox/hq ADR 0325 §3, ADR 0328 §4). **An older host refuses this key**; a // controller sends it only to a host whose health says link.IdentityContract. Identities []Identity `json:"identities,omitempty"` + // Groups and Libraries are optional too (novox/hq ADR 0328). **An older host refuses these keys**; a controller + // sends them only to a host whose health says link.LibraryContract. + Groups []Group `json:"groups,omitempty"` + Libraries []Library `json:"libraries,omitempty"` } func parse(raw []byte, allowActions bool) (*Declaration, error) { @@ -1778,8 +1904,9 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence, Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack, - Identities: env.Identities} - problems := checkIdentities(env.Identities) + Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries} + problems := checkIdentities(env.Identities, env.Groups) + problems = append(problems, checkLibraries(env.Groups, env.Libraries)...) if env.Generation < 0 { // Below zero would read as "none claimed" and pass every refusal of an older generation. problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+ diff --git a/vendor/modules.txt b/vendor/modules.txt index ea6d2f99..57535ea1 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -78,7 +78,7 @@ github.com/nats-io/nkeys # github.com/nats-io/nuid v1.0.1 ## explicit github.com/nats-io/nuid -# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -138,4 +138,4 @@ golang.org/x/text/width # golang.org/x/time v0.15.0 ## explicit; go 1.25.0 golang.org/x/time/rate -# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012175741-303d4037e84e +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012202816-d8e785bb4b4b