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