Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/module-groups merged: every member is merged

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.
This commit is contained in:
jochen
2026-10-08 12:04:08 +02:00
parent ef8378a990
commit ab4ca44f98
13 changed files with 1096 additions and 31 deletions
+46 -3
View File
@@ -28,6 +28,7 @@ import (
"text/tabwriter" "text/tabwriter"
"time" "time"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/bundle" "github.com/novox/mesh-host/internal/bundle"
"github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/declaration"
@@ -1093,6 +1094,8 @@ func runLink(ctx context.Context, opts options) error {
judging = j judging = j
// And which units its service managers say failed, and whose each is (novox/hq issue 315). // 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}) 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 // **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. // issue 315); nil where judging is.
var unitJudge *units.Judge 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 // 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. // one-shot command and in a test, which say nothing of the network.
var netJudge networkJudge 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 var netSt *network.Statement
if n := netJudge.get(); n != nil { if n := netJudge.get(); n != nil {
ns, netChanged := n.Look(ctx) 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) since := time.Since(lastSaid)
healthy := st.Healthy() && (netSt == nil || netSt.State != network.Unhealthy) && 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 { if !owed && !(!healthy && since >= sayUnhealthyAgain) && since < sayAnyway {
continue 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 lastSaid, owed = time.Now(), false
} }
} }
@@ -1585,6 +1605,21 @@ func withUnits(h *link.Health, us *units.Statement) *link.Health {
return h 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. // failedUnitWords is a failed unit as the console says it.
func failedUnitWords(f units.Failed) string { func failedUnitWords(f units.Failed) string {
whose := "no module places it" whose := "no module places it"
@@ -1844,7 +1879,15 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
us := u.Last() us := u.Last()
unitSt = &us 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 // 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, // (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it,
+237
View File
@@ -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
}
+165
View File
@@ -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])
}
}
+103
View File
@@ -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@<uid>.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
}
+21 -5
View File
@@ -70,6 +70,9 @@ type Outcome struct {
// linger is, for a user, whether it lingered before the mesh changed that, and what the mesh // linger is, for a user, whether it lingered before the mesh changed that, and what the mesh
// set (novox/hq ADR 0177). // set (novox/hq ADR 0177).
linger *store.Lingering 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 is, for an archive, what it put on the machine (novox/hq issue 162).
unpacked *store.Unpacked unpacked *store.Unpacked
// reads is, for a container, the digest of each file it was created reading, by path — so // 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) heldFiles := filesHeld(d.Resources)
handedFiles := map[string]store.Applied{} 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 { removeOrphan := func(orphan store.Applied) error {
var action, detail string var action, detail string
var err error var err error
@@ -280,9 +288,12 @@ func ApplyMindingWindows(
return nil 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) 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) action, detail, err = remove(ctx, sys, orphan, run, made)
} }
if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) { if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) {
@@ -447,7 +458,8 @@ func ApplyMindingWindows(
// change one apply late, and never misses it. // 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 // 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. // 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 { for _, resource := range d.Resources {
in.declares[resource.Identity()] = declaredDigest(resource) in.declares[resource.Identity()] = declaredDigest(resource)
} }
@@ -711,6 +723,7 @@ func ApplyMindingWindows(
Found: outcome.found, Found: outcome.found,
Shell: outcome.shell, Shell: outcome.shell,
Linger: outcome.linger, Linger: outcome.linger,
Groups: outcome.groups,
Unpacked: outcome.unpacked, Unpacked: outcome.unpacked,
Holds: holds(resource), Holds: holds(resource),
}) })
@@ -917,7 +930,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
case *declaration.Container: case *declaration.Container:
return applyContainer(ctx, res, run, changed, in, previous) return applyContainer(ctx, res, run, changed, in, previous)
case *declaration.User: case *declaration.User:
return applyUser(ctx, sys, res, run, previous) return applyUser(ctx, sys, res, run, previous, in.groups)
case *declaration.Archive: case *declaration.Archive:
return applyArchive(ctx, res, previous, in.away) return applyArchive(ctx, res, previous, in.away)
case *declaration.Process: case *declaration.Process:
@@ -1687,7 +1700,7 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
case declaration.TypeUser: case declaration.TypeUser:
// Kept, with its shell given back when that is safe (novox/hq ADR 0176 §2, issue 228). // 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: case declaration.TypeNetwork:
// **The reason this is a shape at all** (novox/hq ADR 0029). Orphans are removed in // **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 // away is the apply's patience with an artifact store that does not answer (novox/hq issue
// 291). Nil fetches once. // 291). Nil fetches once.
away *storeAway 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. // fileDigest is what a file the container reads holds, by digest.
+221
View File
@@ -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
}
+231
View File
@@ -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)
}
}
+17 -22
View File
@@ -28,8 +28,11 @@ import (
// //
// previous is this resource's record, which carries the shell the account had before the mesh // 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). // 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, 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 := begin(r)
out.Action = "unchanged" out.Action = "unchanged"
// What was found is carried from the record for as long as the resource is recorded — for this // 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 // 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 // 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. // next time as the account's own, and the one it replaced lost.
if len(r.Groups) > 0 { if err := applyGroups(ctx, sys, r, run, previous, wanted, &out); err != nil {
in, err := system.GroupsOf(ctx, system.Runner(run), r.Name) return out, err
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"
}
}
} }
// The shell, only when it differs. Absent means the host asserts nothing — a field that // 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 // 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 // the outcome says why. Never errNoRemoval: an orphaned login that failed removal stopped the
// whole apply, on every apply after. // 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" const kept = "the account is kept; the host never deletes a login"
login, exists, err := system.LookUpUser(ctx, system.Runner(run), a.Target) login, exists, err := system.LookUpUser(ctx, system.Runner(run), a.Target)
if err != nil { if err != nil {
@@ -222,6 +211,12 @@ func removeUser(ctx context.Context, sys system.System, a store.Applied, run Run
action = "restored" action = "restored"
} }
} }
if gave, said := giveGroupsBack(ctx, sys, a, run, wanted); said != "" {
detail += "; " + said
if gave {
action = "restored"
}
}
return action, detail, nil return action, detail, nil
} }
+6
View File
@@ -254,6 +254,12 @@ type Health struct {
// service whose lifecycle is the machine's — said unhealthy for as long as it stays failed. // service whose lifecycle is the machine's — said unhealthy for as long as it stays failed.
const KindUnit = "unit" 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). // UnitsHealth is the machine's service managers in one statement (issue 315).
type UnitsHealth struct { type UnitsHealth struct {
// State is running, degraded — a unit failed, the modules' or the machine's — or unknown when no // State is running, degraded — a unit failed, the modules' or the machine's — or unknown when no
+7
View File
@@ -117,6 +117,13 @@ type Applied struct {
// still has the mesh's; absent when the mesh never changed it. // still has the mesh's; absent when the mesh never changed it.
Linger *Lingering `json:"linger,omitempty"` 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 // 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 // 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 // it were made by the host. Removal takes away exactly that and nothing else. Absent on a
+9
View File
@@ -173,3 +173,12 @@ func (alpine) AddUserToGroup(ctx context.Context, run Runner, name, group string
} }
return nil 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
}
+9
View File
@@ -341,6 +341,15 @@ func (arch) AddUserToGroup(ctx context.Context, run Runner, name, group string)
return nil 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 // 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 // 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. // ships under /usr (novox/hq ADR 0103). Empty, with no error, for a unit that loads from nowhere.
+24 -1
View File
@@ -76,7 +76,8 @@ type System interface {
// declared state rather than a command the link may not carry. // declared state rather than a command the link may not carry.
SetUserShell(ctx context.Context, run Runner, name, shell string) error 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 // 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 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 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 // 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. // can point it at a list of its own; ShellsIn is how.
var shells = "/etc/shells" var shells = "/etc/shells"