Grant a read-write access's path group to what runs the module's code, so a module can write shared data without the mesh changing it (hq ADR 0312, issue 537)

The engine reads the group owning the accessed path at apply and gives it to the
containers and the tools' account an access's new `grant` names: --group-add for a
container (recreated with it), an account group for the account (recorded, taken
back as ADR 0252 takes back). The path is never changed; an access whose group
cannot write is stated unhealthy for the operator to decide; group 0 and the
root-granting groups are refused by name. The tool runner restarts through the
restart-on the controller gives it, because the access reports updated exactly
when the account's groups moved. Health says GrantContract (4), which tells the
controller it may send `grant`.
This commit is contained in:
2026-10-12 12:23:38 +02:00
parent df5de3bd9f
commit f4efc3cad0
14 changed files with 1249 additions and 30 deletions
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"sort"
"strings"
"sync"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
)
// A read-write access whose group cannot write is never quiet (novox/hq ADR 0312 §2).
//
// The engine grants a read-write access's path group to the identities the module's code runs as, and changes
// nothing on the path: whether its group may write is the operator's, as the data is. So an access whose path's
// group has no write permission is stated, on every look, as its module's resource of kind `access`, unhealthy,
// saying so and that the operator decides — and one owned by a group that is never granted (§4) is stated
// unhealthy saying that. The controller raises it as that module's condition on two statements in a row and
// clears it on the first healthy one, as it does every resource's health (ADR 0240). The path is the target,
// never in the reason: the controller says a reason on the operator's channel, which withholds a path (ADR 0234
// §6).
// grantedAccess is one read-write access that grants, as the judge reads it.
type grantedAccess struct {
module, id, path string
}
// accessJudge reads, on every look, whether each read-write access that grants may write its path through its
// group. Safe for the apply and the looking loop at once.
type accessJudge struct {
mu sync.Mutex
accesses []grantedAccess
// was is each access's last verdict, with since when and how many looks in a row found it unhealthy.
was map[string]link.ResourceHealth
}
// accesses is the one access judge on this machine.
var accesses = &accessJudge{was: map[string]link.ResourceHealth{}}
// set is what the declaration just applied asks: every read-write access of a module that names a grant, not
// held as found. An access no longer declared is forgotten.
func (j *accessJudge) set(d *declaration.Declaration, held map[string]bool) {
var as []grantedAccess
if d != nil {
for _, r := range d.Resources {
a, ok := r.(*declaration.Access)
if !ok || a.Grant == nil || a.Mode != declaration.AccessReadWrite || held[a.ID] {
continue
}
at := strings.LastIndex(a.ID, ".")
if at <= 0 || strings.HasPrefix(a.ID, declaration.AdoptionPrefix) {
continue
}
as = append(as, grantedAccess{module: a.ID[:at], id: a.ID, path: a.Path})
}
}
sort.Slice(as, func(a, b int) bool { return as[a].id < as[b].id })
j.mu.Lock()
defer j.mu.Unlock()
j.accesses = as
declared := map[string]bool{}
for _, a := range as {
declared[a.id] = true
}
for id := range j.was {
if !declared[id] {
delete(j.was, id)
}
}
}
// look reads every access now: healthy when its group may write and may be granted, unhealthy saying why
// otherwise. Whether any verdict changed since the last look, for the loop to say it.
func (j *accessJudge) look(now time.Time) ([]link.ResourceHealth, bool) {
j.mu.Lock()
defer j.mu.Unlock()
changed := false
out := make([]link.ResourceHealth, 0, len(j.accesses))
for _, a := range j.accesses {
state, reason := link.StateHealthy, ""
g, err := apply.GroupOfPath(a.path)
switch {
case err != nil:
state, reason = link.StateUnhealthy, "its group cannot be read: "+strings.ReplaceAll(err.Error(), a.path, "the path")
case apply.GrantRefusal(g) != nil:
state, reason = link.StateUnhealthy, "its group is never granted: "+apply.GrantRefusal(g).Error()
case !g.Writable:
state, reason = link.StateUnhealthy, apply.CannotWriteWords(g)
}
v := link.ResourceHealth{Module: a.module, Resource: a.id, Kind: link.KindAccess, Target: a.path,
State: state, Reason: reason, Since: now.UTC()}
if was, had := j.was[a.id]; had && was.State == state && was.Reason == reason {
v.Since = was.Since
v.Streak = was.Streak
} else {
changed = true
}
if state != link.StateHealthy {
v.Streak++
}
j.was[a.id] = v
out = append(out, v)
}
return out, changed
}
// withAccesses adds to a statement every read-write access that grants, as its module's resource of kind access.
func withAccesses(h *link.Health, vs []link.ResourceHealth) *link.Health {
h.Resources = append(h.Resources, vs...)
return h
}
// accessesHealthy says no access in vs is anything but healthy.
func accessesHealthy(vs []link.ResourceHealth) bool {
for _, v := range vs {
if v.State != link.StateHealthy {
return false
}
}
return true
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/link"
)
// A read-write access whose path's group cannot write is stated unhealthy on every look, saying so and that the
// operator decides, and one whose group is never granted says that (novox/hq ADR 0312 §2, §4). The path is the
// target and never in the reason (ADR 0234 §6); a read access, granting nothing, is not stated at all.
func TestAnAccessWhoseGroupCannotWriteIsStatedUnhealthyWithTheOperatorsRemedy(t *testing.T) {
spool, library, shared, read := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir()
groups := map[string]apply.PathGroup{
spool: {GID: 1002, Name: "downloads", Writable: false},
library: {GID: 1001, Name: "media", Writable: true},
shared: {GID: 0, Name: "root", Writable: true},
}
was := apply.GroupOfPath
apply.GroupOfPath = func(path string) (apply.PathGroup, error) { return groups[path], nil }
t.Cleanup(func() { apply.GroupOfPath = was })
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
{"id":"nzbget.downloads","type":"access","path":"` + spool + `","mode":"read-write","grant":{"account":"operator"}},
{"id":"importer.music","type":"access","path":"` + library + `","mode":"read-write","grant":{"account":"operator"}},
{"id":"importer.shared","type":"access","path":"` + shared + `","mode":"read-write","grant":{"account":"operator"}},
{"id":"player.music","type":"access","path":"` + read + `","mode":"read"}]}`))
if err != nil {
t.Fatal(err)
}
j := &accessJudge{was: map[string]link.ResourceHealth{}}
j.set(d, nil)
at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
vs, changed := j.look(at)
if !changed || len(vs) != 3 {
t.Fatalf("stated %d accesses (changed %v), want the three read-write ones: %+v", len(vs), changed, vs)
}
got := map[string]link.ResourceHealth{}
for _, v := range vs {
got[v.Resource] = v
if v.Kind != link.KindAccess || strings.Contains(v.Reason, v.Target) {
t.Errorf("%s is stated as %q with its path in the reason %q", v.Resource, v.Kind, v.Reason)
}
}
if v := got["nzbget.downloads"]; v.State != link.StateUnhealthy || v.Module != "nzbget" || v.Target != spool ||
!strings.Contains(v.Reason, "cannot write") || !strings.Contains(v.Reason, "operator's to decide") || v.Streak != 1 {
t.Errorf("an access whose group cannot write is stated %+v", v)
}
if v := got["importer.shared"]; v.State != link.StateUnhealthy || !strings.Contains(v.Reason, "never granted") {
t.Errorf("an access of the root group is stated %+v", v)
}
if v := got["importer.music"]; v.State != link.StateHealthy {
t.Errorf("an access whose group may write is stated %+v", v)
}
if h := withAccesses(&link.Health{}, vs); accessesHealthy(vs) || len(h.Resources) != 3 {
t.Errorf("the statement does not carry the accesses as unhealthy: %+v", h.Resources)
}
// Looked at again: the same verdict, its since kept and its streak counted.
vs, changed = j.look(at.Add(time.Minute))
for _, v := range vs {
if v.Resource == "nzbget.downloads" && (changed || v.Streak != 2 || !v.Since.Equal(at)) {
t.Errorf("the second look is %+v (changed %v)", v, changed)
}
}
// The operator gives the group write: healthy at the next look, and nothing the engine did made it so.
groups[spool] = apply.PathGroup{GID: 1002, Name: "downloads", Writable: true}
vs, _ = j.look(at.Add(2 * time.Minute))
for _, v := range vs {
if v.Resource == "nzbget.downloads" && v.State != link.StateHealthy {
t.Errorf("an access whose group was given write is stated %+v", v)
}
}
}
+25 -10
View File
@@ -1572,6 +1572,16 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
}
}
}
// Every read-write access that grants, read on every look (novox/hq ADR 0312 §2).
accessSt, accessesChanged := accesses.look(time.Now())
owed = owed || accessesChanged
if accessesChanged {
for _, v := range accessSt {
if v.State != link.StateHealthy {
say(fmt.Sprintf("%s (access %s) is %s: %s", v.Resource, v.Target, v.State, v.Reason))
}
}
}
var netSt *network.Statement
if n := netJudge.get(); n != nil {
ns, netChanged := n.Look(ctx)
@@ -1597,7 +1607,8 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
found := foundNow()
since := time.Since(lastSaid)
healthy := st.Healthy() && (netSt == nil || netSt.State != network.Unhealthy) &&
(unitSt == nil || len(unitSt.Failed) == 0) && (accountSt == nil || accountSt.Healthy()) && len(found) == 0
(unitSt == nil || len(unitSt.Failed) == 0) && (accountSt == nil || accountSt.Healthy()) && len(found) == 0 &&
accessesHealthy(accessSt)
if !owed && !(!healthy && since >= sayUnhealthyAgain) && since < sayAnyway {
continue
}
@@ -1608,8 +1619,8 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
}
}
}
if queue.SayHealth(ctx, *withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt), unitSt),
accountSt), found)) {
if queue.SayHealth(ctx, *withAccesses(withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt),
unitSt), accountSt), found), accessSt)) {
lastSaid, owed = time.Now(), false
}
}
@@ -1617,10 +1628,10 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa
// healthAsReported is a statement as the report and the event carry it.
func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health {
// RootContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), and
// judges a user's declared `root` (novox/hq ADR 0266), which is what tells the controller it may be sent
// either field.
h := &link.Health{Contract: link.RootContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}}
// GrantContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), judges a
// user's declared `root` (novox/hq ADR 0266), and grants a read-write access's path group (novox/hq ADR 0312),
// which is what tells the controller it may be sent any of those fields.
h := &link.Health{Contract: link.GrantContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}}
for _, r := range st.Resources {
h.Resources = append(h.Resources, link.ResourceHealth{Module: r.Module, Resource: r.ID, Kind: r.Kind,
Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak,
@@ -2007,12 +2018,16 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
// said with it, relogin needed included, in the report that says the apply.
var accountSt *accounts.Statement
if a := accountJudge; a != nil {
a.Set(accounts.Of(declared, held))
// A read-write access's account too (novox/hq ADR 0312 §6): relogin needed is said for a session of
// the account's own manager that began before the grant, as for a user resource's group.
a.Set(append(accounts.Of(declared, held), apply.GrantedAccounts(declared, held)...))
as, _ := a.Look(ctx)
accountSt = &as
}
report.Health = withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt), unitSt), accountSt),
foundDirectories(updated))
accesses.set(declared, held)
accessSt, _ := accesses.look(time.Now())
report.Health = withAccesses(withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt), unitSt),
accountSt), foundDirectories(updated)), accessSt)
}
// Which of this machine's links face outside, for the filter the mesh writes around them
// (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it,
+2 -1
View File
@@ -87,7 +87,8 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) {
Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at},
}}
h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as)
if h.Contract != link.RootContract || link.RootContract != 3 {
// At RootContract or later: an engine that also grants an access's group says GrantContract (novox/hq ADR 0312).
if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.GrantContract {
t.Fatalf("contract %d", h.Contract)
}
got := map[string]string{}