diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 3fd360c7..16aa5515 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -947,9 +947,14 @@ func renderingFor(ctx context.Context, open *stores, node string, if err != nil { return catalogue.Rendering{}, inventory.Node{}, err } + grantsGroups, err := engineGrantsGroups(ctx, inv, node) + if err != nil { + return catalogue.Rendering{}, inventory.Node{}, err + } return catalogue.Rendering{ ReadsHealth: readsHealth, JudgesRoot: judgesRoot, + GrantsGroups: grantsGroups, BusMembership: memberships[node], Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports, Certificate: certificate, Authority: authority, Mesh: private, Names: names, @@ -982,6 +987,16 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string return had && stated.Contract >= link.RootContract, nil } +// engineGrantsGroups says whether a machine's node-engine reads an access's `grant` (novox/hq ADR 0312), by its own +// newest statement, for the same reason as engineReadsHealth: an older engine parses strictly. +func engineGrantsGroups(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) { + stated, had, err := inv.HealthOf(ctx, node) + if err != nil { + return false, err + } + return had && stated.Contract >= link.GrantContract, nil +} + // zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR // 0199): the zone settled from that node's settings, the node's private address, the port the // answering listen is published on there. diff --git a/go.mod b/go.mod index cdf300d5..527faf8e 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-20261011092230-20d5af9b2d5f +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3 diff --git a/go.sum b/go.sum index e7436cbb..dfc79fdf 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU= -git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= +git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3 h1:VpH8CesuBOo7k8GcXZq1eRUfbvUoktfmcUynI5N3IFQ= +git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3/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 a74ca6dc..facc148e 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -246,6 +246,11 @@ type Rendering struct { // statement's contract is link.RootContract or later). To an older, strict engine the field is not // sent, and the account it names is not judged — which the self-check says, as not judged. JudgesRoot bool + + // GrantsGroups says this machine's node-engine reads an access's `grant` (novox/hq ADR 0312: its statement's + // contract is link.GrantContract or later). To an older, strict engine the field is not sent, nor the + // runtime's restarts on it, and a read-write access is reached as before: by whoever owns the path. + GrantsGroups bool } // RootField is a user resource's field saying the account must never become root without a person @@ -543,6 +548,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } var out []map[string]any + // The read-write accesses each module grants its tools' account, by module (novox/hq ADR 0312): the runtime + // that runs as that account is restarted on them. + grantedToTools := map[string][]string{} for _, m := range r.Modules { if with.Adopted && m.Filtering != nil { // Nothing of a module that loads a filter, on an adopted node: its table would drop @@ -1115,6 +1123,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string, out = append(out, copied) } + // **A read-write access grants its path's group to what runs this module's code** (novox/hq ADR 0312): + // the containers composed above that mount it, and the account its tools run as. Only to an engine that + // reads the field. + if with.GrantsGroups { + grantedToTools[m.Module] = grantAccesses(out, owner, m.Module, r.toolsAccount(m, with)) + } + // **What only the mesh knows, where this module asked for it.** The graph is the control // plane's; making a name resolve is the module's software. Emitted as ordinary files under // this module's name, so they are applied, reported and removed exactly as anything else @@ -1141,6 +1156,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } owner[fmt.Sprint(process["id"])] = RuntimeModule out = append(out, process) + // Restarted when an access moved its account's groups, so its process holds them (novox/hq ADR 0312 §6). + for _, m := range r.Modules { + if m.RunsAs == "" { + restartOnGrants(process, grantedToTools[m.Module]) + } + } // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). owns, err := r.ownRuntimes(with) if err != nil { @@ -1149,6 +1170,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string, for _, p := range owns { id := fmt.Sprint(p["id"]) owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID()) + restartOnGrants(p, grantedToTools[owner[id]]) out = append(out, p) } // What each module's bundles are given is read as the account the runtime runs as — not what a diff --git a/internal/catalogue/grants.go b/internal/catalogue/grants.go new file mode 100644 index 00000000..2574bdb7 --- /dev/null +++ b/internal/catalogue/grants.go @@ -0,0 +1,134 @@ +package catalogue + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +// A read-write access grants the path's own group to what runs the module's code (novox/hq ADR 0312, issue 537). +// +// **The controller says who; the node-engine reads which group.** Which group owns the operator's path is a fact +// of the machine the controller cannot know, and the path is never changed (§2): so each read-write access +// reaches a machine naming the identities its module's code runs as, and the node-engine reads the path's group +// at apply and grants it to them. Two kinds of identity run a module's code here: +// +// - each of its containers that mounts the path — the source of one of its volumes is the path, or below it — +// which gains the group as a supplementary group of its process and is recreated with it; +// - the account its tools run as, where it has a tools bundle the machine's runtime loads: the operator's +// account the runtime runs as, or the module's own account for a module served by a runtime of its own +// (ADR 0259 §8). That runtime names the access under its `restart-on`, so it is restarted when the access +// moved the account's groups, and its new process holds the group with no new login (§6). +// +// A read access grants nothing (§3): it is sent as before. A read-write access whose module runs nothing here is +// sent an empty grant, so the node-engine still states it unhealthy while its group cannot write (§2). +// +// **Only to an engine that reads it.** An older node-engine parses strictly and refuses a whole declaration for a +// field it does not know; the field and the restarts it brings are composed only where the engine's statement +// says link.GrantContract or later (Rendering.GrantsGroups). + +// GrantField is an access's field naming who its path's group is granted to (novox/hq ADR 0312). +const GrantField = "grant" + +// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one: +// no runtime here, no bundle it loads, a module left out of an adopted machine's declaration, or a runtime +// running as root on a machine with no account. +func (r Resolution) toolsAccount(m Manifest, with Rendering) string { + if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) { + return "" + } + loads := false + for _, b := range m.Bundles { + if len(b.Loads) > 0 { + loads = true + } + } + if !loads { + return "" + } + if m.RunsAs != "" { + return m.RunsAs + } + return r.Account +} + +// grantAccesses writes the grant of every read-write access of module, among the resources composed so far: the +// module's containers that mount the access's path, and account when it is not empty. The ids of the accesses +// that grant to account, for the runtime that runs as it to be restarted on. +func grantAccesses(out []map[string]any, owner map[string]string, module, account string) []string { + var containers []map[string]any + for _, res := range out { + if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == module { + containers = append(containers, res) + } + } + var toAccount []string + for _, res := range out { + if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != module || res["mode"] != AccessReadWrite { + continue + } + path, _ := res["path"].(string) + grant := map[string]any{} + var mounting []any + for _, c := range containers { + if mounts(c, path) { + mounting = append(mounting, fmt.Sprint(c["id"])) + } + } + if len(mounting) > 0 { + grant["containers"] = mounting + } + if account != "" { + grant["account"] = account + toAccount = append(toAccount, fmt.Sprint(res["id"])) + } + res[GrantField] = grant + } + sort.Strings(toAccount) + return toAccount +} + +// mounts says whether a container mounts path, or something below it, from the machine. A path that merely +// begins with the same letters — /storage/media2 beside /storage/media — is another path. A read-only mount of a +// read-write access still counts: the module declared the access read-write, the engine grants the group to what +// mounts it, and a read-only mount only means that container chose not to write through it. +func mounts(container map[string]any, path string) bool { + volumes, _ := container["volumes"].([]any) + path = filepath.Clean(path) + for _, v := range volumes { + s, ok := v.(string) + if !ok { + continue + } + source, _, _ := strings.Cut(s, ":") + if !strings.HasPrefix(source, "/") { + continue // a named volume: the runtime's, not the operator's path + } + source = filepath.Clean(source) + if source == path || strings.HasPrefix(source, path+"/") { + return true + } + } + return false +} + +// restartOnGrants adds to a runtime process's restart-on the accesses that grant to the account it runs as, so it +// is restarted to hold a group the account was put in or taken out of (novox/hq ADR 0312 §6). +func restartOnGrants(process map[string]any, ids []string) { + if process == nil || len(ids) == 0 { + return + } + restarts, _ := process["restart-on"].([]any) + seen := map[string]bool{} + for _, id := range restarts { + seen[fmt.Sprint(id)] = true + } + for _, id := range ids { + if !seen[id] { + restarts = append(restarts, id) + seen[id] = true + } + } + process["restart-on"] = restarts +} diff --git a/internal/catalogue/grants_test.go b/internal/catalogue/grants_test.go new file mode 100644 index 00000000..382b8e68 --- /dev/null +++ b/internal/catalogue/grants_test.go @@ -0,0 +1,174 @@ +package catalogue + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/novox/mesh-host/validate" +) + +// Defends novox/hq ADR 0312 and issue 537 on the controller's side: each read-write access reaches the machine +// naming who its path's group is granted to — the module's containers that mount it, and the account its tools +// run as — and the tool runner is restarted on it; a read access grants nothing; and none of it is sent to a +// node-engine older than the field, which would refuse the whole declaration. + +const anImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111" + +// anImporter files into one library it writes and reads another: a container mounting both, a step mounting +// neither, and tools served by the machine's runtime. +func anImporter(t *testing.T) Manifest { + t.Helper() + m := aToolsModule(t, "importer", "tools/index.js") + m.Accesses = []Access{ + {ID: "music", Path: "/storage/media/music", Mode: AccessReadWrite}, + {ID: "movies", Path: "/storage/media/movies", Mode: AccessRead}, + } + m.Resources = []map[string]any{ + {"id": "app", "type": "container", "name": "importer", "image": anImage, + "volumes": []any{"${access:music}/incoming:/music", "${access:movies}:/movies:ro"}}, + {"id": "sidecar", "type": "container", "name": "importer-sidecar", "image": anImage}, + } + return m +} + +func composeImporter(t *testing.T, r Resolution, with Rendering) map[string]map[string]any { + t.Helper() + with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}} + with.ArtifactStore = "anchor.internal:5101" + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + // What the node-engine this controller pins would make of it: nothing refused. + body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out}) + if err != nil { + t.Fatal(err) + } + if problems := validate.Declaration(body); len(problems) > 0 { + t.Fatalf("composed into something the node-engine refuses: %v", problems) + } + return byID(out) +} + +func TestAReadWriteAccessNamesTheContainersThatMountItAndTheToolsAccount(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}} + got := composeImporter(t, r, Rendering{GrantsGroups: true}) + + music := got["importer."+AccessID("/storage/media/music")] + if music == nil { + t.Fatalf("no access resource for the library: %v", keysOf(got)) + } + grant, _ := music[GrantField].(map[string]any) + if fmt.Sprint(grant["containers"]) != "[importer.app]" || grant["account"] != "jochen" { + t.Errorf("the read-write access grants %v; want the container that mounts it and the tools' account", grant) + } + // A read access grants nothing (§3). + if movies := got["importer."+AccessID("/storage/media/movies")]; movies[GrantField] != nil { + t.Errorf("a read access names a grant: %v", movies[GrantField]) + } + // The tool runner is restarted when the access moved its account's groups (§6). + runtime := got[RuntimeModule+"."+RuntimeProcessID()] + found := false + for _, id := range runtime["restart-on"].([]any) { + if id == "importer."+AccessID("/storage/media/music") { + found = true + } + } + if !found { + t.Errorf("the tool runner is not restarted on the access that grants its account: %v", runtime["restart-on"]) + } +} + +func TestAModuleOfItsOwnAccountIsGrantedAsThatAccount(t *testing.T) { + m := anImporter(t) + m.RunsAs = "importer" + m.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh/importer/broker"}} + runtime := theRuntime(t) + runtime.Bundles[0].Binary = "node-tools" + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m, runtime}} + with := Rendering{GrantsGroups: true} + with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed"}, "importer": {"broker": "sealed"}} + with.ArtifactStore = "anchor.internal:5101" + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + got := byID(out) + grant, _ := got["importer."+AccessID("/storage/media/music")][GrantField].(map[string]any) + if grant["account"] != "importer" { + t.Errorf("the access grants %v; want the module's own account", grant) + } + own := got["importer."+OwnRuntimeID()] + if fmt.Sprint(own["restart-on"]) == "" || !restartsOnIt(own["restart-on"], "importer."+AccessID("/storage/media/music")) { + t.Errorf("the module's own runtime is not restarted on its access: %v", own["restart-on"]) + } + if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) { + t.Error("the machine's runtime is restarted on an access of a module it does not serve") + } +} + +func TestAModuleThatRunsNothingHereSendsAnEmptyGrant(t *testing.T) { + m := mod("spool", nil, nil, nil) + m.Accesses = []Access{{Path: "/storage/downloads", Mode: AccessReadWrite}} + out, err := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true}) + if err != nil { + t.Fatal(err) + } + grant, has := byID(out)["spool."+AccessID("/storage/downloads")][GrantField].(map[string]any) + if !has || len(grant) != 0 { + t.Errorf("a read-write access of a module running nothing here is sent %v (sent %v); want an empty grant, "+ + "so its group is still judged", grant, has) + } +} + +func TestNoGrantIsSentToAnEngineOlderThanTheField(t *testing.T) { + r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}} + got := composeImporter(t, r, Rendering{}) + for id, res := range got { + if _, has := res[GrantField]; has { + t.Errorf("%s names a grant to an engine that does not read it", id) + } + } + if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) { + t.Error("the tool runner is restarted on an access an older engine grants nothing for") + } +} + +func restartsOnIt(list any, want string) bool { + items, _ := list.([]any) + for _, i := range items { + if fmt.Sprint(i) == want { + return true + } + } + return false +} + +func keysOf(m map[string]map[string]any) []string { + var out []string + for k := range m { + out = append(out, k) + } + return out +} + +// Only a container that mounts the access's path, or below it, is named: a path that begins with the same letters is +// another path. A read-only mount of a read-write access is still named — the access is read-write, and the grant +// follows what mounts it, not how. +func TestOnlyAContainerMountingThePathIsNamedReadOnlyIncluded(t *testing.T) { + m := mod("library", nil, nil, nil) + m.Accesses = []Access{{ID: "media", Path: "/storage/media", Mode: AccessReadWrite}} + m.Resources = []map[string]any{ + {"id": "beside", "type": "container", "name": "beside", "image": anImage, "volumes": []any{"/storage/media2:/x"}}, + {"id": "reader", "type": "container", "name": "reader", "image": anImage, "volumes": []any{"${access:media}:/media:ro"}}, + } + out, err := Resolution{Node: "ace", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true}) + if err != nil { + t.Fatal(err) + } + grant, _ := byID(out)["library."+AccessID("/storage/media")][GrantField].(map[string]any) + if fmt.Sprint(grant["containers"]) != "[library.reader]" { + t.Errorf("the access grants %v; want the read-only mount and not /storage/media2", grant) + } +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 63349ece..5b431422 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -436,6 +436,12 @@ const ReadinessContract = 2 // parses strictly and would refuse the whole declaration for it. const RootContract = 3 +// GrantContract is the statement of an engine that also reads an access's `grant` (novox/hq ADR 0312): it grants a +// read-write access's path group to the containers and the account the grant names, takes it back, and states the +// access unhealthy while the group cannot write. Only to such an engine is the field sent: an older one parses +// strictly and would refuse the whole declaration for it. +const GrantContract = 4 + // ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's // own words (mesh-host internal/accounts ReasonRoot). const ReasonRoot = "can become root without a person" @@ -487,6 +493,11 @@ const KindAccount = "account" // ReasonUsedAsFound. const KindDirectory = "directory" +// KindAccess is a read-write access that grants its path's group (novox/hq ADR 0312): healthy while the group may +// write the path, unhealthy while it cannot — the operator's to decide, since the mesh changes nothing on the path +// — or while the path is owned by a group that is never granted. The node-engine's own (mesh-host internal/link). +const KindAccess = "access" + // ReasonUsedAsFound starts the reason of a directory used as found. const ReasonUsedAsFound = "used as found:" diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index a37baf3b..c110ebf7 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 a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock +mesh-host 3f7f797168d33ec22241bdd6ef2c64de86eea914 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 50db892e..e2f6d294 100644 --- a/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go +++ b/vendor/github.com/novox/mesh-host/internal/declaration/declaration.go @@ -464,6 +464,35 @@ type Access struct { Path string `json:"path"` // Mode is how this module reaches the path: read or read-write. Absent narrows to read. Mode string `json:"mode,omitempty"` + + // Grant is who the path's own group is granted to, for a read-write access (novox/hq ADR 0312 §1): the + // identities the module's code runs as. The controller says who; the node-engine reads which group owns + // the path when it applies, because only the machine knows, and never changes the path (§2). Absent is a + // controller older than the grant, or an access that grants nothing — and a read access always is one (§3). + Grant *AccessGrant `json:"grant,omitempty"` +} + +// AccessGrant is the identities a read-write access grants its path's group to (novox/hq ADR 0312 §1). +type AccessGrant struct { + // Containers are the module's containers that mount the path, by id: each is created with the group as a + // supplementary group of its process, and recreated when the group moves (§6). + Containers []string `json:"containers,omitempty"` + // Account is the account that runs the module's tools: put in the group as an account group, as ADR 0252 + // §1 puts one, and taken out again as §3 takes back (§5). Empty for a module whose code runs nowhere as + // an account. + Account string `json:"account,omitempty"` +} + +// UnmarshalJSON reads a grant as strictly as a resource is read: a field the engine does not know is a +// thing the controller believes it asked for, and is refused rather than ignored. +func (g *AccessGrant) UnmarshalJSON(raw []byte) error { + type plain AccessGrant + var p plain + if err := strictDecode(raw, &p); err != nil { + return fmt.Errorf("a grant: %w", err) + } + *g = AccessGrant(p) + return nil } func (a *Access) Identity() string { return a.ID } @@ -482,6 +511,24 @@ func (a *Access) validate(where string, _ bool) []string { problems = append(problems, fmt.Sprintf( "%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode)) } + if a.Grant != nil && a.Mode != AccessReadWrite { + // A read access reads as any account may, and gains no group (novox/hq ADR 0312 §3). + problems = append(problems, where+": a read access grants nothing, and this one names a grant; "+ + "only a read-write access grants its path's group (novox/hq ADR 0312 §3)") + } + if a.Grant != nil { + for _, id := range a.Grant.Containers { + if id == "" { + problems = append(problems, where+": a grant names a container with no id") + } + } + if a.Grant.Account != "" && !accountName.MatchString(a.Grant.Account) { + // Handed to id, usermod and gpasswd as an argument: a name starting with a dash would be read as an + // option by each of them. + problems = append(problems, fmt.Sprintf("%s: a grant's account is an account's name — letters, digits, "+ + "dots, underscores and dashes, not starting with a dash — and %q is not one", where, a.Grant.Account)) + } + } return problems } @@ -1668,6 +1715,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { d.Resources = append(d.Resources, resource) } problems = append(problems, checkWhileStopped(d.Resources)...) + problems = append(problems, checkGrants(d.Resources)...) problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...) problems = append(problems, checkReplaces(d.Resources)...) if env.Adoption == nil { @@ -1732,6 +1780,36 @@ func checkWhileStopped(resources []Resource) []string { return problems } +// accountName is what a grant's account may be: the portable account name the user tools take, optionally ending +// in `$`, never starting with a dash. +var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`) + +// checkGrants judges each access's grant against the whole declaration (novox/hq ADR 0312): a container it +// names is a container of this declaration, as a maintenance window's are. A grant to a container the machine +// is not told about would be a group given to nothing, said as granted. +func checkGrants(resources []Resource) []string { + containers := map[string]bool{} + for _, r := range resources { + if r.Kind() == TypeContainer { + containers[r.Identity()] = true + } + } + var problems []string + for _, r := range resources { + a, ok := r.(*Access) + if !ok || a.Grant == nil { + continue + } + for _, id := range a.Grant.Containers { + if id != "" && !containers[id] { + problems = append(problems, fmt.Sprintf("resource %q: its grant names %q, and this declaration "+ + "has no container by that id", a.ID, id)) + } + } + } + return problems +} + func strictDecode(raw []byte, into any) error { // DisallowUnknownFields is the whole point rather than strictness for its own sake: a // field the host does not know is a thing the control plane believes it asked for. diff --git a/vendor/modules.txt b/vendor/modules.txt index bb6a448e..470b8478 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-20261011092230-20d5af9b2d5f +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3 ## 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-20261011092230-20d5af9b2d5f +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3