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.