From ab4ca44f98e88b4e3d80889951c1e8d6f49bcc7c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 12:02:31 +0200 Subject: [PATCH] Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247) A module puts the operator's account in a group by declaring the account with that group alone. The node-engine now records each group it added, takes back only those when nothing declared still asks for them, refuses a group the machine lacks before usermod runs, and states each such account as its module's resource of kind account: relogin needed while the running session lacks the group. --- cmd/mesh-host/main.go | 49 +++++- internal/accounts/accounts.go | 237 +++++++++++++++++++++++++++++ internal/accounts/accounts_test.go | 165 ++++++++++++++++++++ internal/accounts/exec.go | 103 +++++++++++++ internal/apply/apply.go | 26 +++- internal/apply/groups.go | 221 +++++++++++++++++++++++++++ internal/apply/groups_test.go | 231 ++++++++++++++++++++++++++++ internal/apply/user.go | 39 +++-- internal/link/messages.go | 6 + internal/store/store.go | 7 + internal/system/alpine.go | 9 ++ internal/system/arch.go | 9 ++ internal/system/system.go | 25 ++- 13 files changed, 1096 insertions(+), 31 deletions(-) create mode 100644 internal/accounts/accounts.go create mode 100644 internal/accounts/accounts_test.go create mode 100644 internal/accounts/exec.go create mode 100644 internal/apply/groups.go create mode 100644 internal/apply/groups_test.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 580e1e5..4a0dc3f 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -28,6 +28,7 @@ import ( "text/tabwriter" "time" + "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/bundle" "github.com/novox/mesh-host/internal/declaration" @@ -1093,6 +1094,8 @@ func runLink(ctx context.Context, opts options) error { judging = j // And which units its service managers say failed, and whose each is (novox/hq issue 315). unitJudge = units.New(units.Exec{Run: apply.ExecRunner, System: builtFor}) + // And whether an account a module put in a group has it where it runs (novox/hq ADR 0252). + accountJudge = accounts.New(accounts.Exec{Run: accounts.Runner(apply.ExecRunner)}) } // **Standing aside for a successor happens between reconciles and nowhere else** (novox/hq ADR @@ -1391,6 +1394,10 @@ var judging *liveness.Judge // issue 315); nil where judging is. var unitJudge *units.Judge +// accountJudge reads whether an account a module put in a group has it in its running session (novox/hq +// ADR 0252); nil where judging is. +var accountJudge *accounts.Judge + // netJudge is the serving host's judge of its machine's networking (novox/hq ADR 0241); empty in a // one-shot command and in a test, which say nothing of the network. var netJudge networkJudge @@ -1496,6 +1503,19 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa } } } + var accountSt *accounts.Statement + if a := accountJudge; a != nil { + as, accountsChanged := a.Look(ctx) + accountSt = &as + owed = owed || accountsChanged + if accountsChanged { + for _, v := range as.Accounts { + if v.State != accounts.Healthy { + say(fmt.Sprintf("%s (account %s) is %s: %s", v.ID, v.Name, v.State, v.Reason)) + } + } + } + } var netSt *network.Statement if n := netJudge.get(); n != nil { ns, netChanged := n.Look(ctx) @@ -1519,7 +1539,7 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa } since := time.Since(lastSaid) healthy := st.Healthy() && (netSt == nil || netSt.State != network.Unhealthy) && - (unitSt == nil || len(unitSt.Failed) == 0) + (unitSt == nil || len(unitSt.Failed) == 0) && (accountSt == nil || accountSt.Healthy()) if !owed && !(!healthy && since >= sayUnhealthyAgain) && since < sayAnyway { continue } @@ -1531,7 +1551,7 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa } } } - if queue.SayHealth(ctx, *withUnits(healthAsReported(st, netSt), unitSt)) { + if queue.SayHealth(ctx, *withAccounts(withUnits(healthAsReported(st, netSt), unitSt), accountSt)) { lastSaid, owed = time.Now(), false } } @@ -1585,6 +1605,21 @@ func withUnits(h *link.Health, us *units.Statement) *link.Health { return h } +// withAccounts adds to a statement every account a module put in a group (novox/hq ADR 0252), as that +// module's resource of kind account: healthy, or unhealthy with why — "relogin needed" when the account's +// running session began before it was put in the group. Nil says nothing of them. +func withAccounts(h *link.Health, as *accounts.Statement) *link.Health { + if as == nil { + return h + } + for _, v := range as.Accounts { + h.Resources = append(h.Resources, link.ResourceHealth{Module: v.Module, Resource: v.ID, + Kind: link.KindAccount, Target: v.Name, State: v.State, Reason: v.Reason, Since: v.Since.UTC(), + Streak: v.Streak}) + } + return h +} + // failedUnitWords is a failed unit as the console says it. func failedUnitWords(f units.Failed) string { whose := "no module places it" @@ -1844,7 +1879,15 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto us := u.Last() unitSt = &us } - report.Health = withUnits(healthAsReported(st, netSt), unitSt) + // Looked at now, not taken from the last look: an apply that just put the account in a group is + // 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)) + as, _ := a.Look(ctx) + accountSt = &as + } + report.Health = withAccounts(withUnits(healthAsReported(st, netSt), unitSt), accountSt) } // 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, diff --git a/internal/accounts/accounts.go b/internal/accounts/accounts.go new file mode 100644 index 0000000..740c22c --- /dev/null +++ b/internal/accounts/accounts.go @@ -0,0 +1,237 @@ +// Package accounts is the node-engine reading whether an account has, where it runs, the groups a module +// put it in (novox/hq ADR 0252, issue 247). +// +// **A group takes effect at the next login.** The apply puts the account in a group in the machine's +// database at once. Every process already running keeps the groups it started with: the account's own +// service manager, and every unit it starts. The lighting daemon's module puts the operator's account in +// `openrazer`, and the daemon, started by that manager, still refuses to start, for as long as the +// session that began before lasts. Nothing said why. +// +// On every look the engine reads, for each account a module declares groups for: +// +// 1. whether the user database lists the account in each group. One it does not is the apply's to put +// right, and is said as `not in the group`; +// 2. whether the account's own service manager runs, and if it does, the groups that process holds, +// read from the process itself. A group the database lists and the running manager lacks is said as +// **relogin needed**, with what to do. +// +// No running manager is healthy: nobody is logged in, and the next login takes the groups. +// +// Each verdict is the declaring module's, as a resource of kind `account`, beside what liveness says: the +// controller raises it as the module's condition on two statements in a row, and clears it on the first +// healthy one. +// +// **It reads; it never acts** (ADR 0240 rule 6): the user and group databases, the machine's own service +// manager's `show` of the account's manager unit, and that process's status file. It never starts the +// account's manager, which asking that manager itself would. +package accounts + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/novox/mesh-host/internal/declaration" +) + +// The states, in the words liveness says them (the controller reads them alike). +const ( + Healthy = "healthy" + Unhealthy = "unhealthy" + Unknown = "unknown" +) + +// ReasonRelogin starts the reason of an account whose running session lacks a group it is in. +const ReasonRelogin = "relogin needed" + +// Account is one user resource of a module that declares groups. +type Account struct { + Module string + // ID is the user resource's id; Name the account. + ID string + Name string + Groups []string +} + +// Of is every account a declaration has a module put in a group. held is every resource an adopted +// machine holds as found, by id: its groups are not the mesh's yet. A resource the mesh declares in its +// own right (no module) is not judged here. +func Of(d *declaration.Declaration, held map[string]bool) []Account { + if d == nil { + return nil + } + var out []Account + for _, r := range d.Resources { + u, ok := r.(*declaration.User) + if !ok || len(u.Groups) == 0 || held[u.ID] || u.Name == "" { + continue + } + at := strings.LastIndex(u.ID, ".") + if at <= 0 || strings.HasPrefix(u.ID, declaration.AdoptionPrefix) { + continue + } + out = append(out, Account{Module: u.ID[:at], ID: u.ID, Name: u.Name, + Groups: append([]string(nil), u.Groups...)}) + } + sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID }) + return out +} + +// Session is what an account's own service manager holds. +type Session struct { + // Running is whether the manager runs. + Running bool + // Has is, of the groups asked about, those the running manager holds. + Has map[string]bool +} + +// Reader is what a look asks the machine. An error is "could not be read": said unknown, never healthy. +type Reader interface { + // InDatabase is every group the user database lists an account in. + InDatabase(ctx context.Context, account string) ([]string, error) + // Session is the account's own service manager: whether it runs, and which of groups it holds. + Session(ctx context.Context, account string, groups []string) (Session, error) +} + +// Verdict is one account's state as a statement says it. +type Verdict struct { + Account + State string + Reason string + Since time.Time + Streak int +} + +// Statement is one look at every account. +type Statement struct { + At time.Time + Accounts []Verdict +} + +// Healthy says no account in it is anything but healthy. +func (s Statement) Healthy() bool { + for _, v := range s.Accounts { + if v.State != Healthy { + return false + } + } + return true +} + +// Judge reads every account's groups on every look. Safe for the apply and the looking loop at once. +type Judge struct { + reader Reader + Now func() time.Time + + mu sync.Mutex + accounts []Account + kept map[string]*Verdict + last Statement +} + +// New is a judge reading through r. +func New(r Reader) *Judge { + return &Judge{reader: r, Now: time.Now, kept: map[string]*Verdict{}} +} + +// Set is what the declaration just applied asks. An account no longer asked for is forgotten. +func (j *Judge) Set(as []Account) { + j.mu.Lock() + defer j.mu.Unlock() + j.accounts = append([]Account(nil), as...) + declared := map[string]bool{} + for _, a := range as { + declared[a.ID] = true + } + for id := range j.kept { + if !declared[id] { + delete(j.kept, id) + } + } +} + +// Last is the statement of the last look. +func (j *Judge) Last() Statement { + j.mu.Lock() + defer j.mu.Unlock() + return j.last +} + +// Look reads every account once, and answers the statement and whether any verdict changed since the +// last look. +func (j *Judge) Look(ctx context.Context) (Statement, bool) { + j.mu.Lock() + defer j.mu.Unlock() + now := j.Now() + st := Statement{At: now} + changed := len(j.last.Accounts) != len(j.accounts) + for _, a := range j.accounts { + state, reason := j.judge(ctx, a) + k := j.kept[a.ID] + if k == nil || k.State != state || k.Reason != reason { + changed = true + k = &Verdict{Account: a, State: state, Reason: reason, Since: now} + j.kept[a.ID] = k + } + k.Account = a + if state == Unhealthy { + k.Streak++ + } else { + k.Streak = 0 + } + st.Accounts = append(st.Accounts, *k) + } + j.last = st + return st, changed +} + +// judge is one account's verdict on one look. +func (j *Judge) judge(ctx context.Context, a Account) (string, string) { + in, err := j.reader.InDatabase(ctx, a.Name) + if err != nil { + return Unknown, "the user database could not be read: " + firstLine(err.Error()) + } + listed := map[string]bool{} + for _, g := range in { + listed[g] = true + } + var missing, inDB []string + for _, g := range a.Groups { + if listed[g] { + inDB = append(inDB, g) + } else { + missing = append(missing, g) + } + } + if len(missing) > 0 { + return Unhealthy, fmt.Sprintf("not in the group %s: the apply has not put %s there; its outcome says why", + strings.Join(missing, ", "), a.Name) + } + s, err := j.reader.Session(ctx, a.Name, inDB) + if err != nil { + return Unknown, "the account's own service manager could not be read: " + firstLine(err.Error()) + } + if !s.Running { + // Nobody is logged in, and the account does not linger: the next login takes every group. + return Healthy, "" + } + var lacking []string + for _, g := range inDB { + if !s.Has[g] { + lacking = append(lacking, g) + } + } + if len(lacking) > 0 { + return Unhealthy, fmt.Sprintf("%s: %s is in the group %s, and its running session began before it was; "+ + "log out of every session and in again, or reboot", ReasonRelogin, a.Name, strings.Join(lacking, ", ")) + } + return Healthy, "" +} + +func firstLine(s string) string { + line, _, _ := strings.Cut(strings.TrimSpace(s), "\n") + return line +} diff --git a/internal/accounts/accounts_test.go b/internal/accounts/accounts_test.go new file mode 100644 index 0000000..12c32d5 --- /dev/null +++ b/internal/accounts/accounts_test.go @@ -0,0 +1,165 @@ +package accounts + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/novox/mesh-host/internal/declaration" +) + +// The account judge (novox/hq ADR 0252, "how it is checked"): an account in a group its running session +// lacks is unhealthy with "relogin needed"; the same account with no session, or a session that has the +// group, is healthy; one the database does not list in the group is unhealthy saying so; and everything +// asked of the machine is a read. + +// machine is a fake: the database's groups, the manager's process and its status file. +type machine struct { + t *testing.T + proc string + inDB string + pid string + held string // the Groups line of the manager's status + asked []string + failsID bool +} + +func (m *machine) run(_ context.Context, name string, args ...string) (string, error) { + m.asked = append(m.asked, name+" "+strings.Join(args, " ")) + switch { + case name == "id": + if m.failsID { + return "", errors.New("id exited 1") + } + return m.inDB + "\n", nil + case name == "getent" && args[0] == "passwd": + return "operator:x:1500:1500::/home/operator:/bin/zsh\n", nil + case name == "getent" && args[0] == "group": + switch args[1] { + case "openrazer": + return "openrazer:x:964:operator\n", nil + case "wheel": + return "wheel:x:998:operator\n", nil + } + return "", errors.New("getent exited 2: ") + case name == "systemctl": + return m.pid + "\n", nil + } + return "", errors.New("not a command the judge may run") +} + +func (m *machine) status() { + m.t.Helper() + if m.pid == "" || m.pid == "0" { + return + } + dir := filepath.Join(m.proc, m.pid) + if err := os.MkdirAll(dir, 0o755); err != nil { + m.t.Fatal(err) + } + body := "Name:\tsystemd\nUid:\t1500\t1500\t1500\t1500\nGid:\t1500\t1500\t1500\t1500\nGroups:\t" + m.held + "\n" + if err := os.WriteFile(filepath.Join(dir, "status"), []byte(body), 0o644); err != nil { + m.t.Fatal(err) + } +} + +var razer = Account{Module: "openrazer", ID: "openrazer.account", Name: "operator", Groups: []string{"openrazer"}} + +func look(t *testing.T, m *machine) Verdict { + t.Helper() + m.status() + j := New(Exec{Run: m.run, Proc: m.proc}) + j.Now = func() time.Time { return time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) } + j.Set([]Account{razer}) + st, _ := j.Look(t.Context()) + if len(st.Accounts) != 1 { + t.Fatalf("the statement: %+v", st) + } + return st.Accounts[0] +} + +func TestAGroupTheRunningSessionLacksSaysReloginNeeded(t *testing.T) { + m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel openrazer", pid: "4242", held: "998"} + v := look(t, m) + if v.State != Unhealthy || !strings.HasPrefix(v.Reason, ReasonRelogin) || !strings.Contains(v.Reason, "openrazer") || + !strings.Contains(v.Reason, "log out") { + t.Fatalf("a session without the group: %+v", v) + } + if v.Module != "openrazer" || v.ID != "openrazer.account" { + t.Errorf("the verdict is not the module's: %+v", v) + } + for _, a := range m.asked { + read := strings.HasPrefix(a, "id -nG ") || strings.HasPrefix(a, "getent ") || + strings.HasPrefix(a, "systemctl show --property=MainPID --value user@") + if !read { + t.Errorf("the judge asked something that is not a read: %q", a) + } + } +} + +func TestASessionThatHasTheGroupIsHealthy(t *testing.T) { + m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel openrazer", pid: "4242", held: "998 964"} + if v := look(t, m); v.State != Healthy || v.Reason != "" { + t.Fatalf("a session with the group: %+v", v) + } +} + +func TestNoSessionIsHealthyTheNextLoginTakesTheGroup(t *testing.T) { + m := &machine{t: t, proc: t.TempDir(), inDB: "operator openrazer", pid: "0"} + if v := look(t, m); v.State != Healthy { + t.Fatalf("no session: %+v", v) + } +} + +func TestAnAccountTheDatabaseDoesNotListIsUnhealthySayingSo(t *testing.T) { + m := &machine{t: t, proc: t.TempDir(), inDB: "operator wheel", pid: "4242", held: "998"} + v := look(t, m) + if v.State != Unhealthy || !strings.HasPrefix(v.Reason, "not in the group openrazer") { + t.Fatalf("not in the group: %+v", v) + } +} + +func TestADatabaseThatDoesNotAnswerIsUnknownNeverHealthy(t *testing.T) { + m := &machine{t: t, proc: t.TempDir(), failsID: true} + if v := look(t, m); v.State != Unknown { + t.Fatalf("an unread database: %+v", v) + } +} + +func TestOnlyAModulesAccountWithGroupsIsJudged(t *testing.T) { + d, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[ + {"id":"zsh.login","type":"user","name":"operator","shell":"/bin/zsh"}, + {"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer"]}, + {"id":"docker.account","type":"user","name":"operator","groups":["docker"]}, + {"id":"meshown","type":"user","name":"mesh","groups":["x"]} + ]}`)) + if err != nil { + t.Fatal(err) + } + got := Of(d, map[string]bool{"docker.account": true}) + if len(got) != 1 || got[0].ID != "openrazer.account" || got[0].Module != "openrazer" { + t.Fatalf("judged: %+v", got) + } +} + +func TestAVerdictThatHoldsCountsItsStreakAndAChangeIsSaid(t *testing.T) { + m := &machine{t: t, proc: t.TempDir(), inDB: "operator openrazer", pid: "4242", held: "998"} + m.status() + j := New(Exec{Run: m.run, Proc: m.proc}) + j.Set([]Account{razer}) + _, changed := j.Look(t.Context()) + st, again := j.Look(t.Context()) + if !changed || again || st.Accounts[0].Streak != 2 { + t.Fatalf("first look changed %v, second %v, streak %d", changed, again, st.Accounts[0].Streak) + } + m.held = "998 964" + m.status() + st, changed = j.Look(t.Context()) + if !changed || st.Accounts[0].State != Healthy || st.Accounts[0].Streak != 0 { + t.Fatalf("after the new login: changed %v, %+v", changed, st.Accounts[0]) + } +} diff --git a/internal/accounts/exec.go b/internal/accounts/exec.go new file mode 100644 index 0000000..ed256c4 --- /dev/null +++ b/internal/accounts/exec.go @@ -0,0 +1,103 @@ +package accounts + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Runner runs a command and answers what it printed — the apply's own (apply.ExecRunner). +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// Exec reads the machine through its command lines and the process table. **Reads only**: `id`, `getent`, +// the machine's own manager's `systemctl show`, and a status file under /proc (a test holds it). +type Exec struct { + Run Runner + // Proc is where the process table is; empty is /proc. A test points it at a directory of its own. + Proc string +} + +// InDatabase is `id -nG`: every group the user database lists the account in. +func (e Exec) InDatabase(ctx context.Context, account string) ([]string, error) { + out, err := e.Run(ctx, "id", "-nG", account) + if err != nil { + return nil, err + } + return strings.Fields(out), nil +} + +// Session reads the account's own manager, user@.service, from the machine's manager — never from +// the account's, which asking would start — and the groups its process holds, from its status file. +func (e Exec) Session(ctx context.Context, account string, groups []string) (Session, error) { + passwd, err := e.Run(ctx, "getent", "passwd", account) + if err != nil { + return Session{}, fmt.Errorf("the user database did not answer about %q: %w", account, err) + } + fields := strings.Split(strings.TrimSpace(passwd), ":") + if len(fields) < 7 || fields[2] == "" { + return Session{}, fmt.Errorf("the user database gave no number for %q", account) + } + shown, err := e.Run(ctx, "systemctl", "show", "--property=MainPID", "--value", "user@"+fields[2]+".service") + if err != nil { + return Session{}, fmt.Errorf("the machine's service manager did not say whether %q's own runs: %w", account, err) + } + pid := strings.TrimSpace(shown) + if pid == "" || pid == "0" { + return Session{}, nil + } + held, err := e.heldBy(pid) + if err != nil { + return Session{}, err + } + s := Session{Running: true, Has: map[string]bool{}} + for _, g := range groups { + entry, err := e.Run(ctx, "getent", "group", g) + if err != nil { + return Session{}, fmt.Errorf("the group database did not answer about %q: %w", g, err) + } + parts := strings.Split(strings.TrimSpace(entry), ":") + if len(parts) < 3 { + return Session{}, fmt.Errorf("the group database gave %q for %q, which is not a group entry", entry, g) + } + s.Has[g] = held[parts[2]] + } + return s, nil +} + +// heldBy is every group id a process holds, from the Groups line of its status file. +func (e Exec) heldBy(pid string) (map[string]bool, error) { + proc := e.Proc + if proc == "" { + proc = "/proc" + } + raw, err := os.ReadFile(filepath.Join(proc, pid, "status")) + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("the account's manager, process %s, ended while it was read", pid) + } + if err != nil { + return nil, err + } + // Its supplementary groups, and its own group, which the supplementary list need not repeat. + held := map[string]bool{} + named := false + for _, line := range strings.Split(string(raw), "\n") { + if rest, ok := strings.CutPrefix(line, "Groups:"); ok { + named = true + for _, gid := range strings.Fields(rest) { + held[gid] = true + } + } + if rest, ok := strings.CutPrefix(line, "Gid:"); ok { + if f := strings.Fields(rest); len(f) > 0 { + held[f[0]] = true + } + } + } + if !named { + return nil, fmt.Errorf("process %s's status names no groups", pid) + } + return held, nil +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index a821353..be49645 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -70,6 +70,9 @@ type Outcome struct { // linger is, for a user, whether it lingered before the mesh changed that, and what the mesh // set (novox/hq ADR 0177). linger *store.Lingering + // groups is, for a user, the groups the mesh put it in that it was not in before (novox/hq ADR + // 0252). + groups []string // unpacked is, for an archive, what it put on the machine (novox/hq issue 162). unpacked *store.Unpacked // reads is, for a container, the digest of each file it was created reading, by path — so @@ -261,6 +264,11 @@ func ApplyMindingWindows( heldFiles := filesHeld(d.Resources) handedFiles := map[string]store.Applied{} + // **A group still asked for by another resource is not given back when one record of it goes** + // (novox/hq ADR 0252), on the unit's rule above: two modules may each want the account in one group, + // and the one going takes nothing the other still needs. + wanted := groupsWanted(d.Resources) + removeOrphan := func(orphan store.Applied) error { var action, detail string var err error @@ -280,9 +288,12 @@ func ApplyMindingWindows( return nil } } - if declaration.Type(orphan.Type) == declaration.TypeOpening { + switch declaration.Type(orphan.Type) { + case declaration.TypeOpening: action, detail, err = removeOpening(ctx, orphan, run, known.Firewall) - } else { + case declaration.TypeUser: + action, detail, err = removeUser(ctx, sys, orphan, run, wanted) + default: action, detail, err = remove(ctx, sys, orphan, run, made) } if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) { @@ -447,7 +458,8 @@ func ApplyMindingWindows( // change one apply late, and never misses it. // And one patience with the artifact store for the whole apply (novox/hq issue 291): a fetch it // does not answer during a maintenance window waits for the window instead of failing. - in := inputs{declares: map[string]string{}, known: &known, windows: windows, away: newStoreAway(windows, log)} + in := inputs{declares: map[string]string{}, known: &known, windows: windows, away: newStoreAway(windows, log), + groups: wanted} for _, resource := range d.Resources { in.declares[resource.Identity()] = declaredDigest(resource) } @@ -711,6 +723,7 @@ func ApplyMindingWindows( Found: outcome.found, Shell: outcome.shell, Linger: outcome.linger, + Groups: outcome.groups, Unpacked: outcome.unpacked, Holds: holds(resource), }) @@ -917,7 +930,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru case *declaration.Container: return applyContainer(ctx, res, run, changed, in, previous) case *declaration.User: - return applyUser(ctx, sys, res, run, previous) + return applyUser(ctx, sys, res, run, previous, in.groups) case *declaration.Archive: return applyArchive(ctx, res, previous, in.away) case *declaration.Process: @@ -1687,7 +1700,7 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner, case declaration.TypeUser: // Kept, with its shell given back when that is safe (novox/hq ADR 0176 §2, issue 228). - return removeUser(ctx, sys, a, run) + return removeUser(ctx, sys, a, run, nil) case declaration.TypeNetwork: // **The reason this is a shape at all** (novox/hq ADR 0029). Orphans are removed in @@ -1834,6 +1847,9 @@ type inputs struct { // away is the apply's patience with an artifact store that does not answer (novox/hq issue // 291). Nil fetches once. away *storeAway + // groups is every group the declaration asks an account to be in, and by which resources + // (novox/hq ADR 0252): a group one user resource stops asking for stays while another asks. + groups Wanted } // fileDigest is what a file the container reads holds, by digest. diff --git a/internal/apply/groups.go b/internal/apply/groups.go new file mode 100644 index 0000000..e19672e --- /dev/null +++ b/internal/apply/groups.go @@ -0,0 +1,221 @@ +package apply + +import ( + "context" + "fmt" + "strings" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" +) + +// An account's groups, from any module (novox/hq ADR 0252, issue 247). +// +// **A module that needs the account in a group declares the account with that group, and nothing else +// of it.** The shell's module sets the account's shell; the lighting daemon's module puts the same +// account in `openrazer`; the container runtime's in `docker`. A shell is one value and stays one +// module's (the controller refuses two); a group is added, so several modules' groups never contradict. +// +// **The mesh takes back only what it gave.** A group the account was in before is the machine's or the +// operator's, and stays when every resource that named it goes. A group the mesh put the account in is +// recorded on the resource that put it there, and given back when that resource stops asking for it — +// unless another declared resource still asks for the same group, which keeps it. +// +// **A group takes effect at the next login.** The machine's group database changes at once; every +// process already running, the account's own service manager and everything it started included, keeps +// the groups it started with. So the outcome says a new login is needed, and the node-engine's account +// judge (internal/accounts) says so on every look until the account's running manager has the group. + +// Wanted is every group a declaration asks an account to be in, and which resources ask: account → +// group → resource ids. +type Wanted map[string]map[string][]string + +// groupsWanted reads every user resource's groups from a declaration. +func groupsWanted(resources []declaration.Resource) Wanted { + w := Wanted{} + for _, r := range resources { + u, ok := r.(*declaration.User) + if !ok || u.Name == "" { + continue + } + for _, g := range u.Groups { + if w[u.Name] == nil { + w[u.Name] = map[string][]string{} + } + w[u.Name][g] = append(w[u.Name][g], u.ID) + } + } + return w +} + +// othersAsk is every resource other than one that asks for an account to be in a group. +func (w Wanted) othersAsk(account, group, except string) []string { + var others []string + for _, id := range w[account][group] { + if id != except { + others = append(others, id) + } + } + return others +} + +// applyGroups makes the account be in every group the resource declares, and takes it out of every group +// this resource put it in and no longer asks for. What the mesh put the account in is recorded on out. +func applyGroups(ctx context.Context, sys system.System, r *declaration.User, run Runner, + previous store.Applied, wanted Wanted, out *Outcome) error { + // What this resource put the account in before, for this account only: a declaration that renamed its + // user says nothing about the new one's groups. + var added []string + if previous.Target == r.Name { + added = append(added, previous.Groups...) + } + if len(r.Groups) == 0 && len(added) == 0 { + return nil + } + in, err := system.GroupsOf(ctx, system.Runner(run), r.Name) + if err != nil { + return err + } + already := setOf(in) + declared := setOf(r.Groups) + + var put []string + for _, want := range r.Groups { + if already[want] { + continue + } + exists, err := system.GroupExists(ctx, system.Runner(run), want) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%q was not put in the group %q: this machine has no such group yet. The package "+ + "that makes it is not installed, or is declared after the account", r.Name, want) + } + if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil { + return err + } + already[want] = true + put = append(put, want) + if !contains(added, want) { + added = append(added, want) + } + } + + // What this resource put the account in and no longer asks for. + var kept, said []string + for _, g := range added { + switch { + case declared[g]: + kept = append(kept, g) + case !already[g]: + // Taken out since, by a person: nothing to give back. + case len(wanted.othersAsk(r.Name, g, r.ID)) > 0: + said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, + strings.Join(wanted.othersAsk(r.Name, g, r.ID), ", "))) + default: + gone, why := leaveGroup(ctx, sys, r.Name, g, run) + if !gone { + kept = append(kept, g) + } + said = append(said, why) + } + } + out.groups = kept + if len(put) > 0 { + said = append([]string{fmt.Sprintf("put in %s; a session that began before has %s only after a new "+ + "login", strings.Join(put, ", "), them(len(put)))}, said...) + } + if len(put) > 0 || len(said) > 0 { + if out.Action == "unchanged" { + out.Action = "updated" + } + out.Detail = joinDetail(out.Detail, strings.Join(said, "; ")) + } + return nil +} + +// giveGroupsBack is removeUser's groups: every group the mesh put the account in, taken back unless +// another declared resource still asks for it. Never fatal, for the shell's reason: a removal that failed +// would stay recorded and fail the same way on every apply after. Empty when there was nothing to say. +func giveGroupsBack(ctx context.Context, sys system.System, a store.Applied, run Runner, wanted Wanted) (bool, string) { + if len(a.Groups) == 0 { + return false, "" + } + in, err := system.GroupsOf(ctx, system.Runner(run), a.Target) + if err != nil { + return false, fmt.Sprintf("the groups the mesh put it in (%s) were not given back: %v", + strings.Join(a.Groups, ", "), err) + } + already := setOf(in) + gave := false + var said []string + for _, g := range a.Groups { + if !already[g] { + continue + } + if others := wanted.othersAsk(a.Target, g, a.ID); len(others) > 0 { + said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, strings.Join(others, ", "))) + continue + } + gone, why := leaveGroup(ctx, sys, a.Target, g, run) + gave = gave || gone + said = append(said, why) + } + return gave, strings.Join(said, "; ") +} + +// leaveGroup takes an account out of one group the mesh put it in, read back from the machine, and says +// what came of it. +func leaveGroup(ctx context.Context, sys system.System, account, group string, run Runner) (bool, string) { + l, ok := sys.(system.GroupLeaver) + if !ok { + return false, fmt.Sprintf("left in %s: this machine's system cannot take an account out of one group", group) + } + if err := l.RemoveUserFromGroup(ctx, system.Runner(run), account, group); err != nil { + return false, fmt.Sprintf("left in %s: %v", group, err) + } + in, err := system.GroupsOf(ctx, system.Runner(run), account) + if err != nil { + return false, fmt.Sprintf("taken out of %s, and the group database could not be read back: %v", group, err) + } + if setOf(in)[group] { + return false, fmt.Sprintf("taken out of %s, and the group database still lists it there", group) + } + return true, fmt.Sprintf("taken out of %s, which the mesh had put it in", group) +} + +func setOf(items []string) map[string]bool { + s := map[string]bool{} + for _, i := range items { + s[i] = true + } + return s +} + +func contains(items []string, want string) bool { + for _, i := range items { + if i == want { + return true + } + } + return false +} + +func them(n int) string { + if n == 1 { + return "it" + } + return "them" +} + +func joinDetail(a, b string) string { + switch { + case a == "": + return b + case b == "": + return a + } + return a + "; " + b +} diff --git a/internal/apply/groups_test.go b/internal/apply/groups_test.go new file mode 100644 index 0000000..03346d2 --- /dev/null +++ b/internal/apply/groups_test.go @@ -0,0 +1,231 @@ +package apply + +import ( + "context" + "errors" + "sort" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0252 and issue 247: a module puts the operator's account in a group by declaring +// the account with that group alone; the account's other groups are never touched; a new login is said; +// and the mesh gives back only a group it put the account in, and only when nobody declared still asks. + +// groupDB is a fake user and group database: the account's groups, the groups the machine has, and every +// command it was asked. +type groupDB struct { + account string + in map[string]bool + exists map[string]bool + asked []string +} + +func newGroupDB(in []string, exists ...string) *groupDB { + g := &groupDB{account: "operator", in: map[string]bool{}, exists: map[string]bool{}} + for _, x := range in { + g.in[x], g.exists[x] = true, true + } + for _, x := range exists { + g.exists[x] = true + } + return g +} + +func (g *groupDB) run(_ context.Context, name string, args ...string) (string, error) { + g.asked = append(g.asked, name+" "+strings.Join(args, " ")) + switch { + case name == "getent" && args[0] == "passwd": + if args[1] == g.account { + return g.account + ":x:1500:1500::/home/" + g.account + ":/bin/bash\n", nil + } + return "", errors.New("getent exited 2: ") + case name == "getent" && args[0] == "group": + if g.exists[args[1]] { + return args[1] + ":x:900:\n", nil + } + return "", errors.New("getent exited 2: ") + case name == "id": + var out []string + for x := range g.in { + out = append(out, x) + } + sort.Strings(out) + return strings.Join(out, " ") + "\n", nil + case name == "usermod" && args[0] == "--append": + if !g.exists[args[2]] { + return "", errors.New("usermod exited 6: group '" + args[2] + "' does not exist") + } + g.in[args[2]] = true + case name == "gpasswd" && args[0] == "--delete": + delete(g.in, args[2]) + } + return "", nil +} + +func (g *groupDB) groups() string { + var out []string + for x := range g.in { + out = append(out, x) + } + sort.Strings(out) + return strings.Join(out, " ") +} + +func applyGroupsOf(t *testing.T, g *groupDB, known store.State, resources ...string) (Report, store.State, error) { + t.Helper() + if len(resources) == 0 { + resources = []string{`{"id":"other.dir","type":"directory","path":"` + t.TempDir() + `/other"}`} + } + return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+ + strings.Join(resources, ",")+`]}`), known, store.OriginDeclared, g.run, nil, nil) +} + +const ( + razer = `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer"]}` + docker = `{"id":"docker.account","type":"user","name":"operator","groups":["docker"]}` + shell = `{"id":"zsh.login","type":"user","name":"operator","groups":[]}` +) + +func TestAModulePutsTheAccountInAGroupAndSaysANewLoginIsNeeded(t *testing.T) { + g := newGroupDB([]string{"wheel", "plugdev"}, "openrazer") + report, state, err := applyGroupsOf(t, g, store.State{}, shell, razer) + if err != nil { + t.Fatal(err) + } + if got := g.groups(); got != "openrazer plugdev wheel" { + t.Fatalf("the account's groups are %q", got) + } + o := outcomeOf(report, "openrazer.account") + if o.Action != "updated" || !strings.Contains(o.Detail, "put in openrazer") || !strings.Contains(o.Detail, "new login") { + t.Errorf("the outcome did not say the group and the new login: %+v", o) + } + a, _ := state.Find("openrazer.account") + if strings.Join(a.Groups, " ") != "openrazer" { + t.Errorf("the record holds %v, want the one group the mesh added", a.Groups) + } + for _, asked := range g.asked { + if strings.HasPrefix(asked, "usermod") && !strings.HasPrefix(asked, "usermod --append --groups openrazer ") { + t.Errorf("usermod was asked something other than appending the one group: %q", asked) + } + } + // Applied again: nothing to do, and the record still says the mesh added it. + report, state, err = applyGroupsOf(t, g, state, shell, razer) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(report, "openrazer.account"); o.Action != "unchanged" { + t.Errorf("a second apply changed something: %+v", o) + } + if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" { + t.Errorf("a second apply lost what the mesh added: %v", a.Groups) + } +} + +func TestAGroupTheAccountWasAlreadyInIsNeverTakenBack(t *testing.T) { + g := newGroupDB([]string{"wheel", "openrazer"}) + _, state, err := applyGroupsOf(t, g, store.State{}, razer) + if err != nil { + t.Fatal(err) + } + if a, _ := state.Find("openrazer.account"); len(a.Groups) != 0 { + t.Fatalf("a group found was recorded as the mesh's: %v", a.Groups) + } + if _, _, err := applyGroupsOf(t, g, state); err != nil { + t.Fatal(err) + } + if got := g.groups(); got != "openrazer wheel" { + t.Errorf("undeclaring took a found group: %q", got) + } +} + +func TestTheGroupTheMeshAddedIsGivenBackWhenItsModuleGoes(t *testing.T) { + g := newGroupDB([]string{"wheel"}, "openrazer") + _, state, err := applyGroupsOf(t, g, store.State{}, razer) + if err != nil { + t.Fatal(err) + } + report, state, err := applyGroupsOf(t, g, state) + if err != nil { + t.Fatal(err) + } + if got := g.groups(); got != "wheel" { + t.Errorf("the account's groups after its module went: %q, want wheel alone", got) + } + o := outcomeOf(report, "openrazer.account") + if o.Action != "restored" || !strings.Contains(o.Detail, "taken out of openrazer") { + t.Errorf("the removal did not say the group was given back: %+v", o) + } + if _, still := state.Find("openrazer.account"); still { + t.Error("the record stayed") + } +} + +func TestAGroupAnotherResourceStillAsksForStays(t *testing.T) { + both := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","video"]}` + video := `{"id":"media.account","type":"user","name":"operator","groups":["video"]}` + g := newGroupDB(nil, "openrazer", "video") + _, state, err := applyGroupsOf(t, g, store.State{}, both, video) + if err != nil { + t.Fatal(err) + } + report, _, err := applyGroupsOf(t, g, state, video) + if err != nil { + t.Fatal(err) + } + if got := g.groups(); got != "video" { + t.Errorf("the account's groups: %q, want video kept for media and openrazer given back", got) + } + if o := outcomeOf(report, "openrazer.account"); !strings.Contains(o.Detail, "media.account still asks for") { + t.Errorf("the removal did not say why video stayed: %+v", o) + } +} + +func TestAGroupNoLongerDeclaredIsGivenBackWhileTheAccountStaysDeclared(t *testing.T) { + g := newGroupDB(nil, "openrazer", "input") + two := `{"id":"openrazer.account","type":"user","name":"operator","groups":["openrazer","input"]}` + _, state, err := applyGroupsOf(t, g, store.State{}, two) + if err != nil { + t.Fatal(err) + } + _, state, err = applyGroupsOf(t, g, state, razer) + if err != nil { + t.Fatal(err) + } + if got := g.groups(); got != "openrazer" { + t.Errorf("the account's groups: %q, want input given back", got) + } + if a, _ := state.Find("openrazer.account"); strings.Join(a.Groups, " ") != "openrazer" { + t.Errorf("the record holds %v", a.Groups) + } +} + +func TestAGroupThatDoesNotExistYetFailsTheResourceSayingSo(t *testing.T) { + g := newGroupDB([]string{"wheel"}) + _, _, err := applyGroupsOf(t, g, store.State{}, razer) + if err == nil || !strings.Contains(err.Error(), "no such group yet") { + t.Fatalf("a missing group was not said: %v", err) + } + for _, asked := range g.asked { + if strings.HasPrefix(asked, "usermod") { + t.Errorf("usermod was run for a group the machine does not have: %q", asked) + } + } +} + +func TestAGroupAPersonTookTheAccountOutOfSinceIsPutBackWhileDeclared(t *testing.T) { + g := newGroupDB(nil, "openrazer") + _, state, err := applyGroupsOf(t, g, store.State{}, razer) + if err != nil { + t.Fatal(err) + } + delete(g.in, "openrazer") + if _, _, err := applyGroupsOf(t, g, state, razer); err != nil { + t.Fatal(err) + } + if got := g.groups(); got != "openrazer" { + t.Errorf("a declared group was not put back: %q", got) + } +} diff --git a/internal/apply/user.go b/internal/apply/user.go index 3d5b16c..7ef0689 100644 --- a/internal/apply/user.go +++ b/internal/apply/user.go @@ -28,8 +28,11 @@ import ( // // previous is this resource's record, which carries the shell the account had before the mesh // first changed it, so removal can give it back (novox/hq ADR 0176 §2, issue 228). +// +// wanted is every group the whole declaration asks an account to be in (novox/hq ADR 0252), so a group +// this resource stops asking for is kept while another resource asks for it. func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner, - previous store.Applied) (Outcome, error) { + previous store.Applied, wanted Wanted) (Outcome, error) { out := begin(r) out.Action = "unchanged" // What was found is carried from the record for as long as the resource is recorded — for this @@ -82,26 +85,8 @@ func applyUser(ctx context.Context, sys system.System, r *declaration.User, run // Groups before the shell, so that a failure here comes before the shell is changed: a record // is written only for an apply that worked, and a shell changed by a failed one would be read // next time as the account's own, and the one it replaced lost. - if len(r.Groups) > 0 { - in, err := system.GroupsOf(ctx, system.Runner(run), r.Name) - if err != nil { - return out, err - } - already := map[string]bool{} - for _, g := range in { - already[g] = true - } - for _, want := range r.Groups { - if already[want] { - continue - } - if err := sys.AddUserToGroup(ctx, system.Runner(run), r.Name, want); err != nil { - return out, err - } - if out.Action == "unchanged" { - out.Action = "updated" - } - } + if err := applyGroups(ctx, sys, r, run, previous, wanted, &out); err != nil { + return out, err } // The shell, only when it differs. Absent means the host asserts nothing — a field that @@ -203,7 +188,11 @@ func onOff(on bool) string { // uninstalled since would break the very logins the giving back is for. Otherwise it is left, and // the outcome says why. Never errNoRemoval: an orphaned login that failed removal stopped the // whole apply, on every apply after. -func removeUser(ctx context.Context, sys system.System, a store.Applied, run Runner) (string, string, error) { +// +// And every group the mesh put the account in, while no other declared resource asks for it (novox/hq +// ADR 0252), on the same rule: never fatal, and never a group the account was in before. +func removeUser(ctx context.Context, sys system.System, a store.Applied, run Runner, + wanted Wanted) (string, string, error) { const kept = "the account is kept; the host never deletes a login" login, exists, err := system.LookUpUser(ctx, system.Runner(run), a.Target) if err != nil { @@ -222,6 +211,12 @@ func removeUser(ctx context.Context, sys system.System, a store.Applied, run Run action = "restored" } } + if gave, said := giveGroupsBack(ctx, sys, a, run, wanted); said != "" { + detail += "; " + said + if gave { + action = "restored" + } + } return action, detail, nil } diff --git a/internal/link/messages.go b/internal/link/messages.go index b95938a..0931fad 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -254,6 +254,12 @@ type Health struct { // service whose lifecycle is the machine's — said unhealthy for as long as it stays failed. const KindUnit = "unit" +// KindAccount is an account a module puts in a group (novox/hq ADR 0252): a resource of the module, said +// healthy when the account's running session has every group it declares, or nobody is logged in, and +// unhealthy when the database does not list it in one, or its running session began before it was — the +// reason then starting "relogin needed". +const KindAccount = "account" + // UnitsHealth is the machine's service managers in one statement (issue 315). type UnitsHealth struct { // State is running, degraded — a unit failed, the modules' or the machine's — or unknown when no diff --git a/internal/store/store.go b/internal/store/store.go index 9e780f2..90f0bb3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -117,6 +117,13 @@ type Applied struct { // still has the mesh's; absent when the mesh never changed it. Linger *Lingering `json:"linger,omitempty"` + // Groups is, for a user, every group the mesh put the account in that it was not in before + // (novox/hq ADR 0252). Never a group the account was found in: that one is the machine's, or the + // operator's, and stays when the resource goes. Removal takes the account out of each of these that + // no other declared resource still asks for. Absent on a record written before the host kept it, + // and then every group is left as it is. + Groups []string `json:"groups,omitempty"` + // Unpacked is, for an archive, what it put on the machine (novox/hq issue 162): the files and // directories it unpacked, and whether the directory it was unpacked into and the parents above // it were made by the host. Removal takes away exactly that and nothing else. Absent on a diff --git a/internal/system/alpine.go b/internal/system/alpine.go index 3eeb759..bbdc833 100644 --- a/internal/system/alpine.go +++ b/internal/system/alpine.go @@ -173,3 +173,12 @@ func (alpine) AddUserToGroup(ctx context.Context, run Runner, name, group string } return nil } + +// RemoveUserFromGroup uses busybox delgroup, which given an account and a group takes the account out of +// that group only (novox/hq ADR 0252). +func (alpine) RemoveUserFromGroup(ctx context.Context, run Runner, name, group string) error { + if _, err := run(ctx, "delgroup", name, group); err != nil { + return fmt.Errorf("cannot take %q out of the group %q: %w", name, group, err) + } + return nil +} diff --git a/internal/system/arch.go b/internal/system/arch.go index adbf383..9aeab7e 100644 --- a/internal/system/arch.go +++ b/internal/system/arch.go @@ -341,6 +341,15 @@ func (arch) AddUserToGroup(ctx context.Context, run Runner, name, group string) return nil } +// RemoveUserFromGroup takes an account out of one group with gpasswd, which changes that group's entry +// and no other (novox/hq ADR 0252). Never usermod --groups, which replaces the whole set. +func (arch) RemoveUserFromGroup(ctx context.Context, run Runner, name, group string) error { + if _, err := run(ctx, "gpasswd", "--delete", name, group); err != nil { + return fmt.Errorf("cannot take %q out of the group %q: %w", name, group, err) + } + return nil +} + // ServiceUnitFile says where the service manager loads a unit from — systemd's FragmentPath. It // is how the host tells a unit an administrator installed, under /etc or /run, from one a package // ships under /usr (novox/hq ADR 0103). Empty, with no error, for a unit that loads from nowhere. diff --git a/internal/system/system.go b/internal/system/system.go index cfd7fa6..e0e8435 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -76,7 +76,8 @@ type System interface { // declared state rather than a command the link may not carry. SetUserShell(ctx context.Context, run Runner, name, shell string) error // AddUserToGroup is additive and never removes. A machine's own groups are not the mesh's to - // know about, and a declaration that pruned them would take away what somebody set by hand. + // know about, and a declaration that pruned them would take away what somebody set by hand. The + // one group the mesh takes an account out of is one it put it in (GroupLeaver, ADR 0252). AddUserToGroup(ctx context.Context, run Runner, name, group string) error } @@ -130,6 +131,28 @@ func GroupsOf(ctx context.Context, run Runner, name string) ([]string, error) { return strings.Fields(out), nil } +// GroupExists is whether the machine's group database has a group (novox/hq ADR 0252). Absent is an +// answer, an error is not, on LookUpUser's rule: `getent` exits 2 for a key it does not have. +// +// Asked before an account is put in a group, so that a group whose package has not made it yet is said +// as that, rather than in usermod's words. +func GroupExists(ctx context.Context, run Runner, group string) (bool, error) { + if _, err := run(ctx, "getent", "group", group); err != nil { + if code, ok := ExitCode(err); ok && code == 2 { + return false, nil + } + return false, fmt.Errorf("the group database did not answer about %q: %w", group, err) + } + return true, nil +} + +// GroupLeaver is a system that can take an account out of one group, and out of no other (novox/hq ADR +// 0252): what gives back a group the mesh put an account in, when the module that wanted it goes. +// Optional, as lingering is: a system without it keeps every group, and the removal says so. +type GroupLeaver interface { + RemoveUserFromGroup(ctx context.Context, run Runner, name, group string) error +} + // shells is where the machine lists the shells a login may have (shells(5)). A variable so a test // can point it at a list of its own; ShellsIn is how. var shells = "/etc/shells"