From ccc86d35946126b1a5d5f9e267f08afaa8b44a2f Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 13 Oct 2026 00:06:29 +0200 Subject: [PATCH] =?UTF-8?q?Put=20the=20tool=20runner=20in=20the=20own=20gr?= =?UTF-8?q?oup=20of=20each=20module=20whose=20tools=20it=20runs=20(hq=20AD?= =?UTF-8?q?R=200334=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A joining module's own directories belong to its own group, and its tools run in the tool runner, which could not read them. An identity may now name the tool runner's account: the engine puts it in mesh- as it puts it in a library's group, says the change as identity: for the runner's unit to restart once, takes it back when no identity names it, and refuses an account that is missing or root's. The engine states contract 7. --- cmd/mesh-host/main.go | 2 +- cmd/mesh-host/units_test.go | 8 +-- internal/apply/apply.go | 17 +++++- internal/apply/library.go | 60 ++++++++++++++----- internal/apply/library_test.go | 89 +++++++++++++++++++++++++++++ internal/declaration/declaration.go | 6 ++ internal/link/messages.go | 6 ++ internal/store/store.go | 4 +- 8 files changed, 170 insertions(+), 22 deletions(-) diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 416814b..83b5473 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1736,7 +1736,7 @@ func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health // LibraryContract: 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 reads a declaration's identities, groups and libraries // (novox/hq ADR 0328), which is what tells the controller it may be sent any of those fields. - h := &link.Health{Contract: link.LibraryContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} + h := &link.Health{Contract: link.OwnGroupContract, 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, diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index c1b8a88..0084497 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -87,10 +87,10 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) { Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at}, }} h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as) - // At RootContract or later: an engine that also reads libraries says LibraryContract (novox/hq ADR 0328), one - // above ADR 0325's IdentityContract. - if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.LibraryContract || - link.LibraryContract != link.IdentityContract+1 { + // At RootContract or later: an engine that also reads an identity's runner says OwnGroupContract (novox/hq ADR + // 0334), one above ADR 0328's LibraryContract. + if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.OwnGroupContract || + link.OwnGroupContract != link.LibraryContract+1 { t.Fatalf("contract %d", h.Contract) } got := map[string]string{} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 6b83d7c..bb36d36 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -627,6 +627,21 @@ func ApplyMindingWindows( "was attempted" continue } + // The tool runner joins the module's own group, so its tools read what the module keeps (ADR 0334 + // §2); a change of `identity:` restarts it once, through its unit's restart-on. + if id.Runner != "" { + moved, said, err := joinGroup(ctx, sys, id.Runner, id.Name, store.Membership{Module: id.Module}, &known, run) + if err != nil { + identityFailures = append(identityFailures, &Error{Resource: "identity " + id.Name, Err: err}) + log(fmt.Sprintf(" failed identity %s's runner: %v", id.Name, err)) + } else if moved { + changed[IdentityChange(id.Module)] = true + outcome.Detail = joinDetail(outcome.Detail, said) + if outcome.Action == "unchanged" { + outcome.Action = "updated" + } + } + } report.Outcomes = append(report.Outcomes, outcome) if outcome.Action != "unchanged" { log(fmt.Sprintf(" %s %s: %s", outcome.Action, outcome.ID, outcome.Detail)) @@ -1051,7 +1066,7 @@ func ApplyMindingWindows( // Only the mesh says what is assigned: a carried bundle's silence undeclares no identity and no folder. if origin == store.OriginDeclared { // A tool runner no longer put in a library's group leaves it (ADR 0328 §11, ADR 0252 §3). - for _, o := range leaveRunners(ctx, sys, d.Libraries, &known, run, wanted) { + for _, o := range leaveRunners(ctx, sys, d.Libraries, d.Identities, &known, run, wanted) { report.Outcomes = append(report.Outcomes, o) log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail)) } diff --git a/internal/apply/library.go b/internal/apply/library.go index e44ecea..d1235f9 100644 --- a/internal/apply/library.go +++ b/internal/apply/library.go @@ -395,43 +395,70 @@ func JudgeLibrary(ctx context.Context, l declaration.Library) LibraryJudgement { return j } -// joinRunner puts a library's tool runner's account in its group (ADR 0328 §11), as ADR 0252 puts an account in a -// group: recorded only when the mesh put it there, so one it was in before is never taken back. Whether it moved. +// joinRunner puts a library's tool runner's account in its group (ADR 0328 §11). func joinRunner(ctx context.Context, sys system.System, l declaration.Library, known *store.State, run Runner) (bool, string, error) { if l.Runner == "" { return false, "", nil } - in, err := system.GroupsOf(ctx, system.Runner(run), l.Runner) - if err != nil { - return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", l.Runner, err) + return joinGroup(ctx, sys, l.Runner, l.Group, store.Membership{Library: l.Library}, known, run) +} + +// IdentityChange is what a tool runner joining a module's own group changes (ADR 0334 §2): `identity:`, +// which the tool runner's unit names under restart-on, as it names `library:`. +func IdentityChange(module string) string { return "identity:" + module } + +// joinGroup puts a tool runner's account in a group (ADR 0328 §11, ADR 0334 §2), as ADR 0252 puts an account in a +// group: recorded only when the mesh put it there, so one it was in before is never taken back. The account must be +// here and never root's: uid 0, or a root group as its primary (§15). Whether it moved, and what was said. +func joinGroup(ctx context.Context, sys system.System, account, group string, of store.Membership, known *store.State, + run Runner) (bool, string, error) { + who, found, err := lookUp(ctx, run, "passwd", account) + switch { + case err != nil: + return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", account, err) + case !found: + return false, "", fmt.Errorf("the tool runner's account %s is not on this machine, so it joins no group", account) } - if setOf(in)[l.Group] { + if err := rootIdentity(who.id, who.prim); err != nil { + return false, "", fmt.Errorf("the tool runner's account %s joins no group: %w", account, err) + } + in, err := system.GroupsOf(ctx, system.Runner(run), account) + if err != nil { + return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", account, err) + } + if setOf(in)[group] { return false, "", nil } - if err := sys.AddUserToGroup(ctx, system.Runner(run), l.Runner, l.Group); err != nil { + if err := sys.AddUserToGroup(ctx, system.Runner(run), account, group); err != nil { return false, "", err } for _, m := range known.Members { - if m.Account == l.Runner && m.Group == l.Group { - return true, fmt.Sprintf("put the tool runner's %s back in %s", l.Runner, l.Group), nil + if m.Account == account && m.Group == group { + return true, fmt.Sprintf("put the tool runner's %s back in %s", account, group), nil } } - known.Members = append(known.Members, store.Membership{Account: l.Runner, Group: l.Group, Library: l.Library}) - return true, fmt.Sprintf("put the tool runner's %s in %s; it holds it once restarted", l.Runner, l.Group), nil + of.Account, of.Group = account, group + known.Members = append(known.Members, of) + return true, fmt.Sprintf("put the tool runner's %s in %s; it holds it once restarted", account, group), nil } -// leaveRunners takes every tool runner's account the mesh put in a library's group out of it once no declared -// library puts it there and no declared user resource asks for it (ADR 0252 §3). Never fatal: what could not be +// leaveRunners takes every tool runner's account the mesh put in a library's group, or a module's own group, out of +// it once no declared library or identity puts it there and no declared user resource asks for it (ADR 0252 §3). Never fatal: what could not be // left stays recorded, said, for the next apply. -func leaveRunners(ctx context.Context, sys system.System, libs []declaration.Library, known *store.State, run Runner, - wanted Wanted) []Outcome { +func leaveRunners(ctx context.Context, sys system.System, libs []declaration.Library, ids []declaration.Identity, + known *store.State, run Runner, wanted Wanted) []Outcome { declared := map[string]bool{} for _, l := range libs { if l.Runner != "" { declared[l.Runner+" "+l.Group] = true } } + for _, id := range ids { + if id.Runner != "" { + declared[id.Runner+" "+id.Name] = true + } + } var out []Outcome var keep []store.Membership for _, m := range known.Members { @@ -440,6 +467,9 @@ func leaveRunners(ctx context.Context, sys system.System, libs []declaration.Lib continue } o := Outcome{ID: LibraryAccess(m.Library) + " runner", Type: "library", Target: m.Account} + if m.Module != "" { + o.ID, o.Type = "identity "+declaration.IdentityPrefix+m.Module+" runner", "identity" + } if others := wanted.othersAsk(m.Account, m.Group, ""); len(others) > 0 { o.Action, o.Detail = "forgotten", fmt.Sprintf("left %s in %s, which %s asks for", m.Account, m.Group, strings.Join(others, ", ")) diff --git a/internal/apply/library_test.go b/internal/apply/library_test.go index a1fef80..4bb756b 100644 --- a/internal/apply/library_test.go +++ b/internal/apply/library_test.go @@ -832,3 +832,92 @@ func TestAnApplyWaitsForALockThatIsNoWalk(t *testing.T) { t.Fatalf("a library whose root is not there was read as kept while its lock was held: %+v", o) } } + +// ADR 0334 §2: the tool runner's account joins a module's own group once, its unit restarted for it (a change of +// identity:), and is taken out once no identity names it; an account not here, or root's, is refused. +func TestTheToolRunnerJoinsAModulesOwnGroup(t *testing.T) { + m := &accountsMachine{users: map[string][2]int{"nox": {1003, 1003}}, groups: map[string][2]int{"nox": {1003, 0}}} + d := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"runner":"nox"}`, ``) + report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(m.ran, "\n"), "usermod --append --groups mesh-radarr nox") { + t.Fatalf("the tool runner was not put in the module's own group: %v", m.ran) + } + if o := outcomeOf(report, "identity mesh-radarr"); !strings.Contains(o.Detail, "tool runner") || + len(known.Members) != 1 || known.Members[0].Module != "radarr" { + t.Fatalf("its joining is not said, or not recorded: %+v %+v", o, known.Members) + } + m.ran = nil + if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, m.run, nil, nil); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(m.ran, "\n"), "usermod") { + t.Fatal("the tool runner was put in its group again") + } + none := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`, ``) + if _, known, err = Apply(context.Background(), archHost(t), none, known, store.OriginDeclared, m.run, nil, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(m.ran, "\n"), "gpasswd --delete nox mesh-radarr") || len(known.Members) != 0 { + t.Fatalf("the tool runner was not taken out of a group no identity puts it in: %v", m.ran) + } + + was := RootGroupIDs + RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} } + defer func() { RootGroupIDs = was }() + for runner, says := range map[string]string{"ghost": "not on this machine", "root": "never root", + "admin": "root group"} { + m := &accountsMachine{users: map[string][2]int{"root": {0, 0}, "admin": {1001, 10}}, groups: map[string][2]int{}} + d := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"runner":"`+ + runner+`"}`, ``) + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) + if err == nil || !strings.Contains(err.Error(), says) { + t.Errorf("%s: not refused saying %q: %v", runner, says, err) + } + if strings.Contains(strings.Join(m.ran, "\n"), "usermod --append") { + t.Errorf("%s: put in a group", runner) + } + } +} + +// ADR 0334 §2: a service naming identity: under restart-on is restarted when the tool runner joins the +// module's own group, and not on an apply where nothing moved. +func TestTheToolRunnerIsRestartedOnceForTheOwnGroup(t *testing.T) { + m := &accountsMachine{users: map[string][2]int{"nox": {1003, 1003}}, groups: map[string][2]int{"nox": {1003, 0}}} + d := withGroups(t, ``, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"runner":"nox"}`, + `{"id":"node-tools.unit","type":"service","unit":"node-tools.service","state":"running",`+ + `"restart-on":["identity:radarr"]}`) + var verbs []string + systemd := systemctlStub(t, "loaded", "active", "enabled", &verbs) + run := func(ctx context.Context, name string, args ...string) (string, error) { + if name == "systemctl" { + return systemd(ctx, name, args...) + } + return m.run(ctx, name, args...) + } + stops := func() int { + n := 0 + for _, v := range verbs { + if v == "stop" { + n++ + } + } + return n + } + _, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatal(err) + } + if stops() != 1 { + t.Fatalf("the tool runner was restarted %d times for joining the own group, and once was wanted", stops()) + } + verbs = nil + if _, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil); err != nil { + t.Fatal(err) + } + if stops() != 0 { + t.Fatalf("the tool runner was restarted %d times with nothing moved", stops()) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 16ffdc8..5bd7ff6 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -1564,6 +1564,9 @@ type Identity struct { UID int `json:"uid"` GID int `json:"gid"` Groups []int `json:"groups,omitempty"` + // Runner is the tool runner's account, put in the module's own group (novox/hq ADR 0334 §2) when its tools run + // on this machine. Absent for a module that declares no tools here, and from a controller older than ADR 0334. + Runner string `json:"runner,omitempty"` } // Group is one library's group, as the controller allocated it (ADR 0328 §3). @@ -1707,6 +1710,9 @@ func checkIdentities(ids []Identity, groups []Group) []string { "declaration makes", where, g)) } } + if id.Runner != "" && !accountName.MatchString(id.Runner) { + problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, id.Runner)) + } names[id.Name], uids[id.UID] = true, id.Name } return problems diff --git a/internal/link/messages.go b/internal/link/messages.go index 9609f27..69f2c1c 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -274,6 +274,12 @@ const IdentityContract = 5 // it, its presence is what tells the controller this host may be sent those fields. const LibraryContract = 6 +// OwnGroupContract is the statement of a host that also reads an identity's `runner` (novox/hq ADR 0334 §2): it puts +// the tool runner's account in the module's own group, takes it back when no identity names it, and says the change +// as `identity:`, which the tool runner's unit names under restart-on to be restarted once. Like the fields +// before it, its presence is what tells the controller this host may be sent `runner` on an identity. +const OwnGroupContract = 7 + // Health is one statement of every long-running resource's state on this machine (to-be 48 §4). Said in // every report, as an event on each change, and again every minute while anything is not healthy — so a // lost statement is not a lost fault. diff --git a/internal/store/store.go b/internal/store/store.go index c803530..5bdd2a4 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -267,7 +267,9 @@ type MadeGroup struct { type Membership struct { Account string `json:"account"` Group string `json:"group"` - Library string `json:"library"` + // Library is the library whose group it is; Module, for a module's own group (ADR 0334 §2), the module. + Library string `json:"library,omitempty"` + Module string `json:"module,omitempty"` } // MadeIdentity is one module identity as the engine made it.