Merge pull request 'Give back only the groups the mesh added, and say when a new login is needed (hq ADR 0252, issue 247)' (#54) from feat/module-groups into main
This commit was merged in pull request #54.
This commit is contained in:
+46
-3
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user