From 1c117d11895da21a51bda0de8e3b0f9835a4027b Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 20:14:40 +0200 Subject: [PATCH] Read the engine's access wait by its reason and ACL, name the tool runner by name, and show a kept folder's entries (hq ADR 0325) A keep wait on a filesystem that cannot carry ACLs is not excused, and nothing is asked; one whose filesystem must first enable them says in the ask that the take sets it. The tool runner's account is sent with no uid, for the engine to resolve by name. --- cmd/mesh-controller/kept_folders.go | 25 +++++--- cmd/mesh-controller/kept_folders_test.go | 59 +++++++++++++++---- cmd/mesh-controller/module_health.go | 16 ++++- cmd/mesh-controller/operator_wait.go | 10 +++- go.mod | 2 +- go.sum | 4 +- internal/catalogue/declaration.go | 4 -- internal/catalogue/module_identity.go | 15 +++-- internal/catalogue/module_identity_test.go | 20 +++---- internal/inventory/health.go | 15 +++++ internal/link/protocol.go | 17 ++++-- testdata/beside/CAPTURED | 2 +- .../internal/declaration/declaration.go | 10 ++-- vendor/modules.txt | 4 +- 14 files changed, 141 insertions(+), 62 deletions(-) diff --git a/cmd/mesh-controller/kept_folders.go b/cmd/mesh-controller/kept_folders.go index 087cc599..16cca5e9 100644 --- a/cmd/mesh-controller/kept_folders.go +++ b/cmd/mesh-controller/kept_folders.go @@ -17,8 +17,9 @@ package main // **On the warrant, and only on it**, the controller asks the machine's engine to take the folder (link.AskTake), // naming the access by its resource id and the warrant; the engine adds the entries of the users it was declared for // that folder. On its word that it started, the folder is recorded taken (kept_folder), and from the next send every -// access to it says `taken`. Whether the filesystem must first allow ACLs is the engine's to see and set: its wait -// does not say so to the controller, so the ask does not either. +// access to it says `taken`. Where the engine's wait says the filesystem must first allow ACLs (acl +// "needs-enabling"), the ask says the take sets that; where it cannot carry them (acl "unsupported") the wait is not +// excused and nothing is asked. import ( "context" @@ -50,6 +51,9 @@ type folderTake struct { // Accesses are the access ids (each module's own) that place it, Modules the modules waiting on it, both sorted. Accesses []string `json:"accesses"` Modules []string `json:"modules"` + // EnablesACL says the folder's filesystem must first allow access entries (a ZFS dataset's acltype), which the + // take act sets: the ask says so. + EnablesACL bool `json:"enables-acl,omitempty"` // Placed is the filesystem the folder is on, as the engine names it without a path: the warrant is for that one. Placed string `json:"placed,omitempty"` At time.Time `json:"at"` @@ -68,7 +72,8 @@ func (t folderTake) digest() string { raw, _ := json.Marshal(struct { Node, Path, Access, Placed string Modules []string - }{t.Node, filepath.Clean(t.Path), t.Access, t.Placed, t.Modules}) + EnablesACL bool + }{t.Node, filepath.Clean(t.Path), t.Access, t.Placed, t.Modules, t.EnablesACL}) sum := sha256.Sum256(raw) return "sha256:" + hex.EncodeToString(sum[:]) } @@ -82,11 +87,15 @@ func (t folderTake) actions(id string) []conditions.Action { // takeWords is the clause's ask (ADR 0325 §8), with the folder as shown: "Let the mesh keep on , now // for : …". -func takeWords(shown, machine string, modules []string) string { - return fmt.Sprintf("Let the mesh keep %s on %s, now for %s: it adds their access entries, and those of any "+ +func takeWords(shown, machine string, modules []string, enablesACL bool) string { + words := fmt.Sprintf("Let the mesh keep %s on %s, now for %s: it adds their access entries, and those of any "+ "module given access later, to every folder and file there; the group bits `ls -l` shows become the "+ "entries' mask, and owners, groups and every other right stay as they are", shown, machine, strings.Join(modules, ", ")) + if enablesACL { + words += ". Its filesystem does not allow access entries yet (a ZFS dataset's acltype): approving sets that first" + } + return words } // ask is the take's ask, composed from it alone. The folder's path is said whole where the router's content rule @@ -101,7 +110,7 @@ func (t folderTake) ask(id string, machines []string) (asks.Ask, map[string]int) if len([]rune(headline)) > asks.HeadlineLength { headline = "Let the mesh keep a shared folder?" } - explanation := takeWords(shown, t.Node, t.Modules) + "." + explanation := takeWords(shown, t.Node, t.Modules, t.EnablesACL) + "." if _, ok := outward.Check(explanation, machines...); !ok { explanation = sayable(explanation, machines) } @@ -262,6 +271,7 @@ type waitingFolder struct { // resource is the access's resource id on the machine, as the engine stated it; placed the filesystem its folder // is on, without its path. resource, placed string + enablesACL bool } // foldersWaiting is every folder waiting to be kept across the mesh (ADR 0325 §7, §8): from each machine's newest @@ -306,7 +316,7 @@ func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[s out[machine] = map[string][]waitingFolder{} } out[machine][path] = append(out[machine][path], waitingFolder{module: r.Module, access: w.Access, - resource: r.Resource, placed: w.Placed}) + resource: r.Resource, placed: w.Placed, enablesACL: w.ACL == link.ACLNeedsEnabling}) } } } @@ -351,6 +361,7 @@ func takesOf(waiting map[string]map[string][]waitingFolder) []folderTake { t.Accesses = append(t.Accesses, w.access) } resources = append(resources, w.resource) + t.EnablesACL = t.EnablesACL || w.enablesACL } sort.Strings(t.Modules) sort.Strings(t.Accesses) diff --git a/cmd/mesh-controller/kept_folders_test.go b/cmd/mesh-controller/kept_folders_test.go index 317649a0..7ee4a5c0 100644 --- a/cmd/mesh-controller/kept_folders_test.go +++ b/cmd/mesh-controller/kept_folders_test.go @@ -30,7 +30,8 @@ var plexManifest = catalogue.Manifest{Module: "plex", Version: "1", const moviesAt = "/storage/media/movies" func keepWait(access string) inventory.Wait { - return inventory.Wait{Part: "radarr", Access: access, What: "let the mesh keep the shared folder " + access} + return inventory.Wait{Part: "radarr", Access: access, Reason: link.WaitKeep, + What: "let the mesh keep the shared folder " + access} } func placedWait(access, placed string) inventory.Wait { @@ -39,8 +40,15 @@ func placedWait(access, placed string) inventory.Wait { return w } +func aclWait(access, acl string) inventory.Wait { + w := keepWait(access) + w.ACL = acl + return w +} + func diskWait(access string) inventory.Wait { - return inventory.Wait{Part: "radarr", Access: access, Disk: true, What: "mount the disk that holds " + access} + return inventory.Wait{Part: "radarr", Access: access, Disk: true, Reason: link.WaitDisk, + What: "mount the disk that holds " + access} } // accessFacts is the facts with each folder given taken on the filesystem fs-000000000001. @@ -81,6 +89,11 @@ func TestAnAccessWaitIsExcusedOnlyForAFolderTheMeshMayNotKeepYet(t *testing.T) { {"unplaced", "novox", keepWait("movies"), accessFacts(), "not placed on novox"}, {"taken folders unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: accessFacts().manifests, placed: accessFacts().placed}, "could not be read"}, + {"its filesystem needs ACLs enabled: asked", "ace", aclWait("movies", link.ACLNeedsEnabling), accessFacts(), ""}, + {"its filesystem cannot carry ACLs: not asked", "ace", aclWait("movies", link.ACLUnsupported), accessFacts(), + "cannot carry access entries"}, + {"a reason the controller does not know", "ace", inventory.Wait{Part: "radarr", Access: "movies", + Reason: "later", What: "w"}, accessFacts(), "waits for the operator's word to keep its folder or for its disk"}, {"an access and a secret", "ace", inventory.Wait{Part: "p", Access: "movies", Secret: "x", What: "w"}, accessFacts(), "one secret or one setting, or one access"}, } { @@ -142,16 +155,6 @@ func TestAnAccessWaitPassesTheGateAndAsksForTheOperatorWithoutAPath(t *testing.T } } -// The engine's access-waits are kept as waits naming the access, and its words tell a disk from a keep. -func TestTheEnginesAccessWaitsAreReadAsWaits(t *testing.T) { - for what, disk := range map[string]bool{"let the mesh keep the shared folder movies": false, - "mount the disk that holds books": true} { - if got := (link.AccessWait{Kind: link.WaitKindAccess, Access: "movies", Part: "radarr", What: what}).WaitsForDisk(); got != disk { - t.Errorf("%q waits for a disk: %v", what, got) - } - } -} - // waitingOn is foldersWaiting's gathering for one folder: the given modules waiting on it by their access. func waitingOn(machine string, ws ...waitingFolder) map[string]map[string][]waitingFolder { return map[string]map[string][]waitingFolder{machine: {moviesAt: ws}} @@ -459,3 +462,35 @@ func TestAnUnknownAccessAndLeftEntriesAreJudgedByWhetherTheMoveChangesAccesses(t t.Error("sameAccesses does not tell a changed access from an unchanged one") } } + +// A take whose folder's filesystem must first allow access entries says so in the ask (ADR 0325 §8). +func TestTheAskSaysWhenTheTakeEnablesACLs(t *testing.T) { + r, _ := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies", + enablesACL: true}) + }) + if err := r.a.reconcile(context.Background()); err != nil { + t.Fatal(err) + } + q := takeAsks(r)[0].Ask + if !strings.Contains(q.Explanation, "does not allow access entries yet (a ZFS dataset's acltype): approving sets that first") { + t.Fatalf("the ask says %q", q.Explanation) + } + if err := q.Check(r.now); err != nil { + t.Fatal(err) + } +} + +// The engine's access waits are kept by their reason, never by their words, and a kept folder's entries are shown. +func TestTheEnginesAccessWaitsAndEntriesAreKept(t *testing.T) { + lines := healthLines(inventory.NodeHealth{Node: "ace", HeardAt: time.Now(), Resources: []inventory.ResourceHealth{{ + Module: "radarr", Resource: "radarr.access-movies", Kind: link.KindAccess, Target: moviesAt, + State: link.StateHealthy, Entries: []inventory.KeptEntry{{Identity: "mesh-radarr", UID: 50000, Use: "read-write", + From: "container"}, {Identity: "jochen", UID: 1000, Use: "read-write", From: "tool-runner", + Note: "the tool runner's account stands for every module whose tools use this folder"}}}}}, true, time.Now()) + all := strings.Join(lines, "\n") + if !strings.Contains(all, "entry mesh-radarr (uid 50000, read-write, from the container)") || + !strings.Contains(all, "entry jochen (uid 1000, read-write, from the tool-runner) — the tool runner's account") { + t.Fatalf("the listing reads:\n%s", all) + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 45079385..b1db79a5 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -80,8 +80,12 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke // An access's waits (novox/hq ADR 0325 §7), kept beside the others as one more thing the operator gives: the // access by its module's id, and whether it waits for a disk rather than for the operator's word. for _, w := range r.AccessWaits { - kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Access: w.Access, Disk: w.WaitsForDisk(), - Placed: w.Placed, What: w.What}) + kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Access: w.Access, Disk: w.Reason == link.WaitDisk, + Placed: w.Placed, Reason: w.Reason, ACL: w.ACL, What: w.What}) + } + for _, e := range r.Entries { + kept.Entries = append(kept.Entries, inventory.KeptEntry{Identity: e.Identity, UID: e.UID, Use: e.Use, From: e.From, + Note: e.Note}) } resources = append(resources, kept) } @@ -769,6 +773,14 @@ func healthLines(h inventory.NodeHealth, had bool, now time.Time) []string { line += " (unconfirmed: said once)" } out = append(out, line) + // A kept folder's users, as the engine resolved them (novox/hq ADR 0325 §4), the runner's note beside it. + for _, e := range r.Entries { + entry := fmt.Sprintf(" entry %s (uid %d, %s, from the %s)", e.Identity, e.UID, e.Use, e.From) + if e.Note != "" { + entry += " — " + e.Note + } + out = append(out, entry) + } } return out } diff --git a/cmd/mesh-controller/operator_wait.go b/cmd/mesh-controller/operator_wait.go index d37d9ad9..0f8f0672 100644 --- a/cmd/mesh-controller/operator_wait.go +++ b/cmd/mesh-controller/operator_wait.go @@ -112,8 +112,16 @@ func checkAccessWait(module, machine string, m catalogue.Manifest, w inventory.W if !placed { return fmt.Errorf("says it waits for the access %s, which is not placed on %s", w.Access, machine) } - if w.Disk { + switch { + case w.Reason == link.WaitDisk || (w.Reason == "" && w.Disk): return nil + case w.Reason != link.WaitKeep && w.Reason != "": + return fmt.Errorf("says it waits for the access %s for %q, and an access waits for the operator's word to "+ + "keep its folder or for its disk", w.Access, w.Reason) + case w.ACL == link.ACLUnsupported: + // No act can make this filesystem carry access entries: nothing to ask; the operator places the data elsewhere. + return fmt.Errorf("says it waits for the access %s, whose folder's filesystem cannot carry access entries: "+ + "the operator places the data on one that can, or mounts it with ACLs", w.Access) } taken, readable := f.taken[machine] if !readable { diff --git a/go.mod b/go.mod index f78e5a50..7b3fb2fa 100644 --- a/go.mod +++ b/go.mod @@ -35,4 +35,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-20261012170302-d2d9fbbd7d1c +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012174513-8b2d65072cd8 diff --git a/go.sum b/go.sum index aefa245f..d621159d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-host v0.0.0-20261012170302-d2d9fbbd7d1c h1:5k5y4bML7BQtFVPstHVHkQUdjKUpOQII4yTbu1MKMGY= -git.novox.be/novox/mesh-host v0.0.0-20261012170302-d2d9fbbd7d1c/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= +git.novox.be/novox/mesh-host v0.0.0-20261012174513-8b2d65072cd8 h1:MgLaqpvAcE8rkl+QAXv4nwYYcm7JIfirh+StGE+krQA= +git.novox.be/novox/mesh-host v0.0.0-20261012174513-8b2d65072cd8/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/declaration.go b/internal/catalogue/declaration.go index f7770830..5cd2345d 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -257,10 +257,6 @@ type Rendering struct { Identities map[string]ModuleIdentity // KeptFolders is every folder the operator let the mesh keep on this machine (ADR 0325 §8), by its clean path. KeptFolders map[string]bool - // AccountUIDs is the user number of each account a tool runner runs as here, where the mesh knows it: an access - // names the runner's account only with its number, which the node-engine requires (ADR 0325 §4). Nothing states - // it to the controller yet, so it is empty and no runner entry is sent. - AccountUIDs map[string]int } // RootField is a user resource's field saying the account must never become root without a person diff --git a/internal/catalogue/module_identity.go b/internal/catalogue/module_identity.go index 42c16158..c87ce4ed 100644 --- a/internal/catalogue/module_identity.go +++ b/internal/catalogue/module_identity.go @@ -36,7 +36,7 @@ import ( // uid:gid (§3). A directory only a process or the tools write keeps the owner it had: what writes it is not the // identity, and owning it by the identity would lock its writer out; // - an access's `users`: the module's identity where a container of it that declares `identity` mounts the -// access, and the tool runner's account where the module's tools run there and the account's number is known, +// access, and the tool runner's account where the module's tools run there as named, its number the engine reads by name, // each at the access's use — the node-engine keeps one entry per user on a folder at the widest use any access // to it gives (§4, §5), so the runner's entry is the widest of the modules whose tools use it; and `taken`, // whether the operator let the mesh keep that folder on that machine (§8). @@ -88,11 +88,11 @@ const ( ) // AccessUser is one identity that uses a shared folder, as an access's `users` names it (mesh-host internal/declaration -// AccessUser): the account, its user number, the use, and where it runs. The node-engine refuses a user with no -// number, so the tool runner's account is named only where its number is known (Rendering.AccountUIDs). +// AccessUser): the account, its user number, the use, and where it runs. The tool runner's account is named with no +// number: it is the machine's, and the node-engine resolves it by name, refusing one missing or root's. type AccessUser struct { Identity string `json:"identity"` - UID int `json:"uid"` + UID int `json:"uid,omitempty"` Use string `json:"use"` From string `json:"from"` } @@ -439,7 +439,7 @@ func (r Resolution) toolsAccount(m Manifest, with Rendering) string { // usersInto writes `users` and `taken` on every access of module m among the resources composed so far: its identity // where a container of it that declares `identity` mounts the access's path, and the tool runner's account where the -// module's tools run as it and its number is known, each at the access's use (§4: the engine keeps the widest). +// module's tools run as it, each at the access's use (§4: the engine keeps the widest). func usersInto(out []map[string]any, owner map[string]string, m Manifest, id ModuleIdentity, has bool, account string, with Rendering) { var containers []map[string]any @@ -450,7 +450,6 @@ func usersInto(out []map[string]any, owner map[string]string, m Manifest, id Mod } } } - runnerUID, runnerKnown := with.AccountUIDs[account] for _, res := range out { if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != m.Module { continue @@ -467,8 +466,8 @@ func usersInto(out []map[string]any, owner map[string]string, m Manifest, id Mod break // one identity per module, whichever of its containers mounts it } } - if account != "" && runnerKnown && runnerUID > 0 { - users = append(users, AccessUser{Identity: account, UID: runnerUID, Use: mode, From: FromToolRunner}) + if account != "" { + users = append(users, AccessUser{Identity: account, Use: mode, From: FromToolRunner}) } if len(users) > 0 { res["users"] = users diff --git a/internal/catalogue/module_identity_test.go b/internal/catalogue/module_identity_test.go index b17b8cee..3303ef5a 100644 --- a/internal/catalogue/module_identity_test.go +++ b/internal/catalogue/module_identity_test.go @@ -110,7 +110,6 @@ func TestAModuleRunsAsItsIdentityTheSameOnEveryMachine(t *testing.T) { // What the node-engine this controller pins (the ADR 0325 half) makes of it: nothing refused. r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} with := keepingFolders(identities, "/storage/media/movies") - with.AccountUIDs = map[string]int{"jochen": 1000} composed := composeFolders(t, r, with) body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": composed.Resources, "identities": composed.Identities}) @@ -126,13 +125,12 @@ func TestAModuleRunsAsItsIdentityTheSameOnEveryMachine(t *testing.T) { } // Rows 2, 4 and 8's composed part: each access names its users — the module's identity where a container declaring -// identity mounts it, never a container declaring none — and the tool runner's account where its number is known, +// identity mounts it, never a container declaring none — and the tool runner's account by name, with no number, // each at the access's own use (the node-engine keeps one entry per user at the widest use any access gives it, so // the runner's is the widest among the modules whose tools use the folder); and whether the folder is taken. func TestAnAccessNamesItsUsers(t *testing.T) { r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{aLibrarian(t), aPlayer(t), theRuntime(t)}} with := keepingFolders(identities, "/storage/media/movies") - with.AccountUIDs = map[string]int{"jochen": 1000} got := byID(composeFolders(t, r, with).Resources) users := func(id string) string { @@ -141,30 +139,26 @@ func TestAnAccessNamesItsUsers(t *testing.T) { } movies := "librarian." + AccessID("/storage/media/movies") if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"},` + - `{"identity":"jochen","uid":1000,"use":"read-write","from":"tool-runner"}]`; users(movies) != want { + `{"identity":"jochen","use":"read-write","from":"tool-runner"}]`; users(movies) != want { t.Errorf("the librarian's movies name %s; want %s", users(movies), want) } played := "player." + AccessID("/storage/media/movies") if want := `[{"identity":"mesh-player","uid":50002,"use":"read","from":"container"},` + - `{"identity":"jochen","uid":1000,"use":"read","from":"tool-runner"}]`; users(played) != want { + `{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(played) != want { t.Errorf("the player's movies name %s; want %s", users(played), want) } // The sidecar declares no identity: no entry for it (§2); the runner reads the music for the librarian's tools. music := "librarian." + AccessID("/storage/media/music") - if want := `[{"identity":"jochen","uid":1000,"use":"read","from":"tool-runner"}]`; users(music) != want { + if want := `[{"identity":"jochen","use":"read","from":"tool-runner"}]`; users(music) != want { t.Errorf("the music names %s; want only the tool runner's read", users(music)) } if got[movies]["taken"] != true || got[music]["taken"] != false { t.Errorf("taken is %v for the movies (taken) and %v for the music (not)", got[movies]["taken"], got[music]["taken"]) } - // The runner's number unknown: no entry for it, never one the engine refuses for uid 0. - got = byID(composeFolders(t, r, keepingFolders(identities)).Resources) - if want := `[{"identity":"mesh-librarian","uid":50001,"use":"read-write","from":"container"}]`; users(movies) != want { - t.Errorf("with no number for the runner's account, the movies name %s; want %s", users(movies), want) - } - if _, has := got[music]["users"]; has { - t.Errorf("an access nobody with a known number uses names users: %s", users(music)) + // The runner is named with no number: the engine resolves it by name, never as uid 0 sent from here. + if strings.Contains(users(music), `"uid"`) { + t.Errorf("the tool runner's entry carries a number: %s", users(music)) } } diff --git a/internal/inventory/health.go b/internal/inventory/health.go index e79b5dee..89e31e93 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -36,6 +36,17 @@ type ResourceHealth struct { Root string `json:"root,omitempty"` // Waits is what a waiting resource waits for the operator to give (novox/hq ADR 0283). Waits []Wait `json:"waits,omitempty"` + // Entries is a kept folder's listing as the engine resolved it (novox/hq ADR 0325 §4): each user with its uid. + Entries []KeptEntry `json:"entries,omitempty"` +} + +// KeptEntry is one user of a kept folder as the engine lists it, with a note beside the tool runner's. +type KeptEntry struct { + Identity string `json:"identity"` + UID int `json:"uid"` + Use string `json:"use"` + From string `json:"from"` + Note string `json:"note,omitempty"` } // Wait is one thing a waiting resource waits for the operator to give: exactly one of Secret, Setting and Access @@ -51,6 +62,10 @@ type Wait struct { // Placed is the filesystem the folder is on now, without its path (ADR 0325 §8): a warrant for another does not // answer the wait. Placed string `json:"placed,omitempty"` + // Reason is the engine's reason for an access wait ("keep" or "disk"), ACL what its folder's filesystem says of + // access entries ("needs-enabling", "unsupported", or empty when it allows them). + Reason string `json:"reason,omitempty"` + ACL string `json:"acl,omitempty"` What string `json:"what"` } diff --git a/internal/link/protocol.go b/internal/link/protocol.go index e3357bac..3498e26d 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -605,13 +605,20 @@ type AccessWait struct { // word: a warrant given for another filesystem (the folder moved) does not answer it, and the controller asks // again (mesh-host internal/link AccessWait.Placed). Placed string `json:"placed,omitempty"` + // Reason is what the wait is for: WaitKeep (the operator's word to keep the folder) or WaitDisk (its disk + // mounted). ACL is said on a keep wait whose filesystem refuses access entries now: ACLNeedsEnabling when the take + // act will let it, ACLUnsupported when no act can. Absent when it allows them. + Reason string `json:"reason"` + ACL string `json:"acl,omitempty"` } -// WaitsForDisk says an access wait is for a disk that is not there (ADR 0325 §9) rather than for the operator's word -// to keep a folder: read from the engine's words, the one thing its wait says of which it is. -func (w AccessWait) WaitsForDisk() bool { - return strings.HasPrefix(strings.TrimSpace(w.What), "mount the disk") -} +// The reasons of an access wait, and what its filesystem says of ACLs (mesh-host internal/link). +const ( + WaitKeep = "keep" + WaitDisk = "disk" + ACLNeedsEnabling = "needs-enabling" + ACLUnsupported = "unsupported" +) // KindAccess is an access's verdict (novox/hq ADR 0325; mesh-host internal/link KindAccess): healthy while each user // has its right, waiting while the mesh may not keep its folder yet or its disk is not there, unhealthy otherwise — diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 48d10391..20f261ae 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 d2d9fbbd7d1cb92efaf404ebe0bd8dd91896277f examples/foundation-first-node-nats.lock +mesh-host 8b2d65072cd825ba3ebe9a8b1e94ebc7ce046b59 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/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go index 4f4ba924..a8b89cd1 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -489,10 +489,10 @@ const ( ) // AccessUser is one identity that uses an accessed folder: its name and uid as the controller allocated them, its -// use, and where it runs. +// use, and where it runs. A tool runner's account may come with no uid (0 or absent): the engine reads it by name. type AccessUser struct { Identity string `json:"identity"` - UID int `json:"uid"` + UID int `json:"uid,omitempty"` Use string `json:"use"` From string `json:"from"` } @@ -529,9 +529,11 @@ func (a *Access) validate(where string, _ bool) []string { case u.Identity == "" || !accountName.MatchString(u.Identity): problems = append(problems, fmt.Sprintf("%s: a user of an access is an account's name, and %q is not one", where, u.Identity)) - case u.UID <= 0: + case u.UID < 0 || (u.UID == 0 && u.From != FromToolRunner): // Never root (novox/hq ADR 0325 §11): an entry for uid 0 would be a right root has anyway, and a - // number below it is no account. + // number below it is no account. A tool runner's account alone may come without its number: the + // controller cannot know the operator's account's, and the engine reads it by name + // (apply.ResolveRunners), refusing one that is root's. problems = append(problems, fmt.Sprintf("%s: the user %s has uid %d, and a module's identity is never "+ "root or below it (novox/hq ADR 0325 §11)", where, u.Identity, u.UID)) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 4cb2cf58..9ff611fb 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-20261012170302-d2d9fbbd7d1c +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012174513-8b2d65072cd8 ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -135,4 +135,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-20261012170302-d2d9fbbd7d1c +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012174513-8b2d65072cd8