From 2a2be687f3a50f84f78a6db415cc9a3f06d1beaf Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 22:11:09 +0200 Subject: [PATCH] Keep libraries under groups of their own, and give modules users in them (hq ADR 0328, issue 567) ADR 0328 supersedes ADR 0325: shared data is a library with one home, kept usable by its group rather than by ACL entries. The engine now makes each library's group and each module's user in its libraries' groups; keeps a library's root and requested sub-paths in its group with group write and setgid on its home, making missing sub-paths as the root is; waits for the operator's word before touching a root that holds data, and on the take act walks it by descriptor (no link, same filesystem, bounded and resumed, files with other names left); holds the containers of a library whose disk or mount is not there; and puts the tool runner's account in the group. The ACL keeping, mask repair and watch are gone; an access is again as before ADR 0325. --- cmd/mesh-host/kept.go | 439 -------- cmd/mesh-host/kept_test.go | 117 -- cmd/mesh-host/libraries.go | 274 +++++ cmd/mesh-host/libraries_test.go | 56 + cmd/mesh-host/main.go | 31 +- cmd/mesh-host/units_test.go | 8 +- internal/apply/acl.go | 242 ----- internal/apply/apply.go | 121 ++- internal/apply/contract_test.go | 128 --- internal/apply/identities.go | 173 ++- internal/apply/identities_test.go | 76 +- internal/apply/keep.go | 920 ++-------------- internal/apply/keeper_linux.go | 473 --------- internal/apply/kept_test.go | 1523 --------------------------- internal/apply/library.go | 434 ++++++++ internal/apply/library_linux.go | 155 +++ internal/apply/library_test.go | 643 +++++++++++ internal/apply/oldshape_test.go | 136 --- internal/apply/placed.go | 4 +- internal/apply/plan.go | 2 +- internal/apply/roots.go | 10 +- internal/apply/walk.go | 54 +- internal/apply/walk_linux.go | 462 +++----- internal/apply/walk_other.go | 48 +- internal/apply/watch_linux.go | 350 ------ internal/declaration/declaration.go | 173 ++- internal/link/handover.go | 28 +- internal/link/hearing_nats.go | 4 +- internal/link/messages.go | 69 +- internal/link/queue.go | 2 +- internal/link/run.go | 2 +- internal/store/kept.go | 212 ---- internal/store/libraries.go | 159 +++ internal/store/store.go | 24 +- internal/system/identities.go | 35 +- 35 files changed, 2583 insertions(+), 5004 deletions(-) delete mode 100644 cmd/mesh-host/kept.go delete mode 100644 cmd/mesh-host/kept_test.go create mode 100644 cmd/mesh-host/libraries.go create mode 100644 cmd/mesh-host/libraries_test.go delete mode 100644 internal/apply/acl.go delete mode 100644 internal/apply/contract_test.go delete mode 100644 internal/apply/keeper_linux.go delete mode 100644 internal/apply/kept_test.go create mode 100644 internal/apply/library.go create mode 100644 internal/apply/library_linux.go create mode 100644 internal/apply/library_test.go delete mode 100644 internal/apply/oldshape_test.go delete mode 100644 internal/apply/watch_linux.go delete mode 100644 internal/store/kept.go create mode 100644 internal/store/libraries.go diff --git a/cmd/mesh-host/kept.go b/cmd/mesh-host/kept.go deleted file mode 100644 index 0e28c3c..0000000 --- a/cmd/mesh-host/kept.go +++ /dev/null @@ -1,439 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" - "github.com/novox/mesh-host/internal/link" - "github.com/novox/mesh-host/internal/store" -) - -// An access is judged by what each of its users may do (novox/hq ADR 0325 §7, §10, §12). -// -// On every look the engine judges each module's access as its module's resource of kind `access`: waiting, with a -// wait of kind access, while the mesh may not keep its folder yet or its disk is not there; unhealthy for root's -// data on its placed filesystem, and for a kept folder that gives a user less than its right — read by entry and -// mask together, on the folder and the sweep's sample under it, which the sweep puts back where it can; healthy -// otherwise. The folder is the target, never in a reason nor in a wait's words: the controller says them on the -// operator's channel, which withholds a path (ADR 0234 §6). - -// judgedAccess is one access as the judge reads it. -type judgedAccess struct { - module, id, name, path string - taken bool - users []store.KeptUser - // leftover is the mesh's entries nobody wants, not an access (link.KindKeptEntries). - leftover bool -} - -// accessJudge reads every access on every look. Safe for the apply and the looking loop at once. -type accessJudge struct { - mu sync.Mutex - accesses []judgedAccess - // was is each access's last verdict, with since when and how many looks in a row found it unhealthy. - was map[string]link.ResourceHealth - - // reading guards inFlight: every folder whose look has not returned yet. - reading sync.Mutex - inFlight map[string]bool -} - -// accesses is the one access judge on this machine. -var accesses = newAccessJudge() - -func newAccessJudge() *accessJudge { - return &accessJudge{was: map[string]link.ResourceHealth{}, inFlight: map[string]bool{}} -} - -// set is what the declaration just applied asks: every access of a module, not held as found, with the users of -// its folder as every access of that folder gives them. An access no longer declared is forgotten. -func (j *accessJudge) set(d *declaration.Declaration, held map[string]bool) { - var as []judgedAccess - if d != nil { - // A tool runner's account that came with no number is read by name, as the apply read it (ADR 0325 §4). - ctx, cancel := context.WithTimeout(context.Background(), lookBound) - apply.ResolveRunners(ctx, d) - cancel() - users := map[string]map[uint32]store.KeptUser{} - taken := map[string]bool{} - for _, r := range d.Resources { - if a, ok := r.(*declaration.Access); ok { - path := filepath.Clean(a.Path) - if users[path] == nil { - users[path] = map[uint32]store.KeptUser{} - } - taken[path] = taken[path] || a.Taken - for _, u := range a.Users { - had, seen := users[path][uint32(u.UID)] - if !seen || (u.Use == declaration.AccessReadWrite && had.Use != declaration.AccessReadWrite) { - users[path][uint32(u.UID)] = store.KeptUser{Identity: u.Identity, UID: uint32(u.UID), - Use: u.Use, From: u.From} - } - } - } - } - for _, r := range d.Resources { - a, ok := r.(*declaration.Access) - if !ok || held[a.ID] || strings.HasPrefix(a.ID, declaration.AdoptionPrefix) { - continue - } - // An older controller's access is judged by nothing, as before ADR 0325: no state is said of it. - if !apply.Keeps(d, a.Path) { - continue - } - at := strings.LastIndex(a.ID, ".") - if at <= 0 { - continue - } - path := filepath.Clean(a.Path) - ja := judgedAccess{module: a.ID[:at], id: a.ID, name: a.ID[at+1:], path: path, taken: taken[path]} - for _, u := range users[path] { - ja.users = append(ja.users, u) - } - sort.Slice(ja.users, func(x, y int) bool { return ja.users[x].UID < ja.users[y].UID }) - as = append(as, ja) - } - } - sort.Slice(as, func(a, b int) bool { return as[a].id < as[b].id }) - j.mu.Lock() - defer j.mu.Unlock() - j.accesses = as - declared := map[string]bool{} - for _, a := range as { - declared[a.id] = true - } - for id := range j.was { - if !declared[id] { - delete(j.was, id) - } - } -} - -// lookBound is how long a look waits for one accessed folder to answer. A hard network mount whose server is down -// blocks a stat for ever, and one access must never stop every health statement this machine makes. -var lookBound = 5 * time.Second - -// verdict is one access's judgement in a look. -type verdict struct { - state, reason string - waits []link.AccessWait - act *link.AccessActRecord - entries []link.KeptEntry -} - -// judgeBounded judges one access within lookBound, outside any lock. A look that has not answered is said so; -// while it still hangs, later looks start no other on the same folder, so a dead mount costs one blocked read. -func (j *accessJudge) judgeBounded(a judgedAccess) (verdict, bool) { - j.reading.Lock() - if j.inFlight[a.path] { - j.reading.Unlock() - return verdict{}, false - } - j.inFlight[a.path] = true - j.reading.Unlock() - done := make(chan verdict, 1) - go func() { - v := judgeAccess(a) - j.reading.Lock() - delete(j.inFlight, a.path) - j.reading.Unlock() - done <- v - }() - select { - case v := <-done: - return v, true - case <-time.After(lookBound): - return verdict{}, false - } -} - -// judgeAccess is one access's judgement now (ADR 0325 §7, §10, §12), the sweep of its kept folder included. -func judgeAccess(a judgedAccess) verdict { - v := apply.AccessVerdict(context.Background(), a.path, a.taken, len(a.users) > 0) - switch v.Kind { - case apply.VerdictWaitKeep: - return verdict{state: link.StateWaiting, reason: "it holds data the mesh has not taken on the filesystem " + - "it is on now, and waits for the operator's word (novox/hq ADR 0325 §7, §8)", - waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module, - What: apply.KeepWords(a.name), Placed: v.Placed, Reason: link.WaitKeep, ACL: v.ACL}}} - case apply.VerdictWaitDisk: - return verdict{state: link.StateWaiting, reason: "the disk that holds it is not there (novox/hq ADR 0325 §9)", - waits: []link.AccessWait{{Kind: link.WaitKindAccess, Access: a.name, Part: a.module, - What: apply.DiskWords(a.name), Reason: link.WaitDisk}}} - case apply.VerdictRoot: - return verdict{state: link.StateUnhealthy, reason: apply.RootWords(v.Why)} - case apply.VerdictUnknown: - // Not a fault and not the operator's: said as not known, and judged again on the next look. - return verdict{state: link.StateUnknown, reason: v.Why + "; judged again on the next look"} - case apply.VerdictBroken: - // Its words carry no path (AccessVerdict says them so): the reason reaches the operator's channel. - return verdict{state: link.StateUnhealthy, reason: v.Why} - case apply.VerdictMake: - return verdict{state: link.StateUnhealthy, reason: "it is not there yet; the next apply makes it where " + - "its data lives (novox/hq ADR 0325 §9)"} - case apply.VerdictNobody: - return verdict{state: link.StateHealthy} - } - out := verdict{state: link.StateHealthy, entries: entriesOf(a.users)} - rec, _ := keptNow(a.path) - if act := rec.Act; act != nil { - out.act = &link.AccessActRecord{Act: act.Act, Access: a.id, Warrant: act.Warrant, By: act.By, State: act.State, - Started: act.Started, Ended: act.Ended, Folders: act.Folders, Files: act.Files, Kept: act.Kept, - Links: act.Links, Other: act.Other, Mounts: act.Mounts, Why: act.Why} - } - if !rec.Kept { - out.state, out.reason = link.StateStarting, "the controller says it was taken; the next apply keeps it" - return out - } - look := apply.LookKept(a.path, a.users, apply.SweepLimits) - var faults []string - if look.Failed != "" { - faults = append(faults, "the sweep could not look: "+apply.Pathless(errors.New(look.Failed))) - } - if len(look.Short) > 0 { - faults = append(faults, "the folder itself gives "+strings.Join(look.Short, ", ")+"; put back on the next "+ - "apply") - } - if n := look.Sweep.Unrepaired + look.Sweep.LastUnrepaired; n > 0 { - faults = append(faults, fmt.Sprintf("%d entries under it give a user less than its right and cannot be "+ - "put back: an owner's own bits, which the mesh never changes, or an entry the filesystem refused", n)) - } - if len(faults) > 0 { - out.state, out.reason = link.StateUnhealthy, strings.Join(faults, "; ") - return out - } - switch { - case rec.Act != nil && rec.Act.State == link.ActRunning: - out.state, out.reason = link.StateStarting, "the take act is adding its users' entries under it" - case !sameUsersList(rec.Users, rec.Want): - out.state, out.reason = link.StateStarting, "the entries under it are being changed to its users now" - } - return out -} - -// entriesOf is a kept folder's listing (ADR 0325 §4): each user, and beside the tool runner's what it stands for. -func entriesOf(users []store.KeptUser) []link.KeptEntry { - var out []link.KeptEntry - for _, u := range users { - e := link.KeptEntry{Identity: u.Identity, UID: int(u.UID), Use: u.Use, From: u.From} - if u.From == declaration.FromToolRunner { - e.Note = "the tool runner's account stands for every module whose tools use this folder, at the widest " + - "use any of them declares: a module that declared only read may write through it (novox/hq ADR " + - "0325 §4, until issue 569)" - } - out = append(out, e) - } - return out -} - -func sameUsersList(a, b []store.KeptUser) bool { - if len(a) != len(b) { - return false - } - seen := map[uint32]string{} - for _, u := range a { - seen[u.UID] = u.Use - } - for _, u := range b { - if use, ok := seen[u.UID]; !ok || use != u.Use { - return false - } - } - return true -} - -// keptNow is the record of one kept folder, as the engine's file holds it. -func keptNow(path string) (store.KeptFolder, bool) { - all, err := apply.KeptFolders.All() - if err != nil { - return store.KeptFolder{}, false - } - f, had := all[path] - return f, had -} - -// look reads every access now. Whether any verdict changed since the last look, for the loop to say it. -func (j *accessJudge) look(now time.Time) ([]link.ResourceHealth, bool) { - j.mu.Lock() - as := append([]judgedAccess(nil), j.accesses...) - j.mu.Unlock() - - read := make([]verdict, len(as)) - for i, a := range as { - v, answered := j.judgeBounded(a) - if !answered { - v = verdict{state: link.StateUnhealthy, reason: "its folder does not answer: judging it did not return " + - "within " + lookBound.String()} - } - read[i] = v - } - - // And the entries nobody wants that could not be taken out yet, each under the access that last named its - // folder, until its drop walk has run (ADR 0325 §8). - for _, l := range apply.Leftovers() { - as = append(as, judgedAccess{module: l.Module, id: l.Access + "~entries", name: l.Name, path: l.Path, - leftover: true}) - read = append(read, verdict{state: link.StateUnhealthy, reason: l.Why}) - } - - j.mu.Lock() - defer j.mu.Unlock() - changed := false - out := make([]link.ResourceHealth, 0, len(as)) - for i, a := range as { - r := read[i] - // Every kept or waiting access lists its users, the tool runner's account with the uid read here (§4). - if r.entries == nil && len(a.users) > 0 && !a.leftover { - r.entries = entriesOf(a.users) - } - kind := link.KindAccess - if a.leftover { - kind = link.KindKeptEntries - } - v := link.ResourceHealth{Module: a.module, Resource: a.id, Kind: kind, Target: a.path, - State: r.state, Reason: r.reason, Since: now.UTC(), AccessWaits: r.waits, Act: r.act, Entries: r.entries} - if was, had := j.was[a.id]; had && was.State == r.state && was.Reason == r.reason { - v.Since = was.Since - v.Streak = was.Streak - } else { - changed = true - } - if r.state == link.StateUnhealthy { - v.Streak++ - } - j.was[a.id] = v - out = append(out, v) - } - return out, changed -} - -// withAccesses adds to a statement every access, as its module's resource of kind access. -func withAccesses(h *link.Health, vs []link.ResourceHealth) *link.Health { - h.Resources = append(h.Resources, vs...) - return h -} - -// accessesHealthy says no access in vs is anything but healthy. -func accessesHealthy(vs []link.ResourceHealth) bool { - for _, v := range vs { - if v.State != link.StateHealthy { - return false - } - } - return true -} - -// keeper runs the walks under kept folders beside the applies, one at a time (ADR 0325 §8): poked after every -// apply and every look, it walks whatever folder's entries under it are not what was declared. -type keeper struct { - running, following atomic.Bool - say func(string) - watch *apply.Watcher -} - -// keeping is the one keeper on this machine. -var keeping = &keeper{say: func(string) {}} - -// poke starts a reconcile of the kept folders unless one is running, and has the watch follow what is kept. -func (k *keeper) poke(ctx context.Context) { - // The watch is set beside, never in the caller's way: watching a new folder's tree reads every folder of it. - if k.following.CompareAndSwap(false, true) { - go func() { - defer k.following.Store(false) - k.follow() - }() - } - if !k.running.CompareAndSwap(false, true) { - return - } - go func() { - defer k.running.Store(false) - for _, line := range apply.ReconcileKept(ctx) { - k.say(line) - } - }() -} - -// follow has the watch watch every kept folder with its users. -func (k *keeper) follow() { - if k.watch == nil { - return - } - all, err := apply.KeptFolders.All() - if err != nil { - return - } - folders := map[string][]store.KeptUser{} - for path, f := range all { - if f.Kept && len(f.Want) > 0 { - folders[path] = f.Want - } - } - k.watch.Set(folders) -} - -// startKeeping opens the watch and has the keeper say what it does. A machine without the watch is said so, and -// its sweep alone finds what is made. -func startKeeping(ctx context.Context, say func(string)) { - keeping.say = say - w, err := apply.NewWatcher() - if err != nil { - say(fmt.Sprintf("the kept folders are not watched (%v): the sweep alone finds what is made in them", err)) - } else { - keeping.watch = w - go w.Run(ctx) - } - keeping.poke(ctx) -} - -// accessActFrom is the take act as the controller asks it over the link (ADR 0325 §8): judged against the -// declaration this node keeps, and run beside the applies. -func accessActFrom(statePath string, timeout time.Duration) link.AccessActFunc { - return func(act link.AccessAct) (string, error) { - kept, err := store.ReadDeclared(store.DeclaredPath(statePath)) - if errors.Is(err, store.ErrNothingDeclared) { - return "", errors.New("this node keeps no declaration, so it is declared no access") - } - if err != nil { - return "", err - } - d, err := declaration.Parse(kept.Declaration) - if err != nil { - return "", fmt.Errorf("the declaration this node keeps does not read: %w", err) - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - _, said, err := apply.TakeFolder(ctx, d, act.Access, filepath.Clean(act.Path), act.Warrant, act.By, - apply.ExecRunner) - if err != nil { - fmt.Fprintf(os.Stderr, "mesh-host: the take act on %s was refused: %v\n", act.Access, err) - return "", err - } - return said, nil - } -} - -// withWaiting is held with every id of waiting beside it: what liveness does not judge. -func withWaiting(held, waiting map[string]bool) map[string]bool { - if len(waiting) == 0 { - return held - } - out := map[string]bool{} - for id := range held { - out[id] = true - } - for id := range waiting { - out[id] = true - } - return out -} diff --git a/cmd/mesh-host/kept_test.go b/cmd/mesh-host/kept_test.go deleted file mode 100644 index 36e267e..0000000 --- a/cmd/mesh-host/kept_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/novox/mesh-host/internal/apply" - "github.com/novox/mesh-host/internal/declaration" - "github.com/novox/mesh-host/internal/link" - "github.com/novox/mesh-host/internal/store" -) - -// Row 4: the listing of a kept folder says, beside the tool runner's entry, that it stands for every module whose -// tools use the folder at the widest use (novox/hq ADR 0325 §4). -func TestTheListingSaysWhatTheToolRunnersEntryStandsFor(t *testing.T) { - entries := entriesOf([]store.KeptUser{ - {Identity: "mesh-radarr", UID: 61001, Use: "read-write", From: declaration.FromContainer}, - {Identity: "nox", UID: 61003, Use: "read-write", From: declaration.FromToolRunner}, - }) - if entries[0].Note != "" || !strings.Contains(entries[1].Note, "widest use") { - t.Fatalf("the listing: %+v", entries) - } -} - -// Row 7, the engine's part: a folder that holds data and was never taken is said waiting with one wait of kind -// access — the access by its module's own id, the part its module, and words with no path (novox/hq ADR 0325 §7). -func TestAnAccessTheMeshMayNotKeepYetIsSaidAsAWaitOfItsOwnKind(t *testing.T) { - dir := t.TempDir() - lib := filepath.Join(dir, "movies") - if err := os.MkdirAll(filepath.Join(lib, "a film"), 0o755); err != nil { - t.Fatal(err) - } - // Run as root (the build seat's merge-check), the test's folders stand for an operator's, never root's. - if os.Geteuid() == 0 { - for _, p := range []string{dir, lib, filepath.Join(lib, "a film")} { - if err := os.Lchown(p, 65534, 65534); err != nil { - t.Fatal(err) - } - } - } - v := judgeAccess(judgedAccess{module: "radarr", id: "radarr.movies", name: "movies", path: lib, - users: []store.KeptUser{{Identity: "mesh-radarr", UID: 61001, Use: "read-write", From: "container"}}}) - if v.state != link.StateWaiting || len(v.waits) != 1 { - t.Fatalf("not waiting with one wait: %+v", v) - } - raw, _ := json.Marshal(v.waits[0]) - want := `{"kind":"access","access":"movies","part":"radarr","what":"let the mesh keep the shared folder movies",` + - `"placed":"` + v.waits[0].Placed + `","reason":"keep"}` - if string(raw) != want || !strings.HasPrefix(v.waits[0].Placed, "fs-") { - t.Fatalf("the wait reads %s", raw) - } - if strings.Contains(v.reason, dir) { - t.Fatalf("the reason names the path: %s", v.reason) - } -} - -// An older controller's accesses (no users, no identities) are judged by nothing, as before ADR 0325: the statement -// carries no resource for them, waiting, unknown or kept-entries. -func TestAnOlderControllersAccessesSayNothing(t *testing.T) { - dir := t.TempDir() - d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[ - {"id":"radarr.library","type":"access","path":"` + filepath.Join(dir, "library") + `","mode":"read-write"}, - {"id":"plex.movies","type":"access","path":"` + filepath.Join(dir, "absent") + `"}]}`)) - if err != nil { - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(dir, "library"), 0o755); err != nil { - t.Fatal(err) - } - j := newAccessJudge() - j.set(d, nil) - if vs, _ := j.look(time.Now()); len(vs) != 0 { - t.Fatalf("an older controller's accesses are judged: %+v", vs) - } -} - -// The waits' reasons and the tool runner's uid read here are said without words to parse (ADR 0325 §4, §7, §9). -func TestTheWaitsSayTheirReasonAndTheRunnersUid(t *testing.T) { - if link.ACLNeedsEnabling != apply.ACLNeedsEnabling || link.ACLUnsupported != apply.ACLUnsupported { - t.Fatal("the engine's ACL words are not the link's") - } - dir := t.TempDir() - was := apply.AccountIDs - apply.AccountIDs = func(context.Context, string) (int, int, bool, error) { return 61003, 1000, true, nil } - defer func() { apply.AccountIDs = was }() - d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"importer.books","type":"access",` + - `"path":"` + filepath.Join(dir, "no", "such") + `","mode":"read-write","users":[{"identity":"nox",` + - `"use":"read-write","from":"tool-runner"}]}]}`)) - if err != nil { - t.Fatal(err) - } - j := newAccessJudge() - j.set(d, nil) - vs, _ := j.look(time.Now()) - if len(vs) != 1 || len(vs[0].Entries) != 1 || vs[0].Entries[0].UID != 61003 { - t.Fatalf("the tool runner's uid read here is not said: %+v", vs) - } - // A folder whose disk is not there: its mount target named, not mounted. - disk := filepath.Join(dir, "disk") - if err := os.Mkdir(disk, 0o755); err != nil { - t.Fatal(err) - } - wasTargets := apply.MountTargets - apply.MountTargets = func(context.Context) []string { return []string{disk} } - defer func() { apply.MountTargets = wasTargets }() - v := judgeAccess(judgedAccess{module: "radarr", id: "radarr.movies", name: "movies", - path: filepath.Join(disk, "movies"), users: []store.KeptUser{{Identity: "mesh-radarr", UID: 61001, - Use: "read-write", From: "container"}}}) - if len(v.waits) != 1 || v.waits[0].Reason != link.WaitDisk || link.WaitDisk != "disk" || link.WaitKeep != "keep" { - t.Fatalf("a disk wait does not say its reason: %+v", v.waits) - } -} diff --git a/cmd/mesh-host/libraries.go b/cmd/mesh-host/libraries.go new file mode 100644 index 0000000..f4b1cec --- /dev/null +++ b/cmd/mesh-host/libraries.go @@ -0,0 +1,274 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/store" +) + +// A library is judged on every look (novox/hq ADR 0328 §8, §9). +// +// Each library this machine is reached by is said as a resource `library:` of kind library, with no module: +// healthy while it is kept (or, off its home, while its mount is there), waiting with an access wait of reason keep +// or disk, unknown while its filesystem could not be named, unhealthy otherwise. The root is the target, never in a +// reason nor in a wait's words: the controller says them on the operator's channel, which withholds a path (ADR +// 0234 §6). An access is judged by nothing, as before ADR 0325. + +// libraryJudge reads every library on every look. Safe for the apply and the looking loop at once. +type libraryJudge struct { + mu sync.Mutex + libraries []declaration.Library + declared *declaration.Declaration + // was is each library's last verdict, with since when and how many looks in a row found it unhealthy. + was map[string]link.ResourceHealth + + // reading guards inFlight: every library whose look has not returned yet. + reading sync.Mutex + inFlight map[string]bool +} + +// accesses is the one library judge on this machine (its name from when it judged accesses). +var accesses = newLibraryJudge() + +func newLibraryJudge() *libraryJudge { + return &libraryJudge{was: map[string]link.ResourceHealth{}, inFlight: map[string]bool{}} +} + +// set is what the declaration just applied declares: every library. One no longer declared is forgotten. +func (j *libraryJudge) set(d *declaration.Declaration, _ map[string]bool) { + var ls []declaration.Library + if d != nil { + ls = append(ls, d.Libraries...) + } + sort.Slice(ls, func(a, b int) bool { return ls[a].Library < ls[b].Library }) + j.mu.Lock() + defer j.mu.Unlock() + j.libraries, j.declared = ls, d + declared := map[string]bool{} + for _, l := range ls { + declared[apply.LibraryAccess(l.Library)] = true + } + for id := range j.was { + if !declared[id] { + delete(j.was, id) + } + } +} + +// declaration is the declaration last set. +func (j *libraryJudge) declaration() *declaration.Declaration { + j.mu.Lock() + defer j.mu.Unlock() + return j.declared +} + +// lookBound is how long a look waits for one library to answer. A hard network mount whose server is down blocks +// a stat for ever, and one library must never stop every health statement this machine makes. +var lookBound = 5 * time.Second + +// verdict is one library's judgement in a look. +type verdict struct { + state, reason string + waits []link.AccessWait + act *link.AccessActRecord +} + +// judgeBounded judges one library within lookBound, outside any lock. A look that has not answered is said so; +// while it still hangs, later looks start no other on the same library. +func (j *libraryJudge) judgeBounded(l declaration.Library) (verdict, bool) { + j.reading.Lock() + if j.inFlight[l.Library] { + j.reading.Unlock() + return verdict{}, false + } + j.inFlight[l.Library] = true + j.reading.Unlock() + done := make(chan verdict, 1) + go func() { + v := judgeLibrary(l) + j.reading.Lock() + delete(j.inFlight, l.Library) + j.reading.Unlock() + done <- v + }() + select { + case v := <-done: + return v, true + case <-time.After(lookBound): + return verdict{}, false + } +} + +// judgeLibrary is one library's judgement now (ADR 0328 §7, §8, §9). +func judgeLibrary(l declaration.Library) verdict { + v := apply.JudgeLibrary(context.Background(), l) + access := apply.LibraryAccess(l.Library) + var out verdict + if a := v.Act; a != nil { + out.act = &link.AccessActRecord{Act: a.Act, Access: access, Warrant: a.Warrant, By: a.By, State: a.State, + Started: a.Started, Ended: a.Ended, Folders: a.Folders, Files: a.Files, Kept: a.Kept, Links: a.Links, + Other: a.Other, Mounts: a.Mounts, Shared: a.Shared, Why: a.Why} + } + switch v.Kind { + case apply.VerdictWaitKeep: + out.state, out.reason = link.StateWaiting, "its root holds data the mesh has not taken on the filesystem "+ + "it is on now, and waits for the operator's word (novox/hq ADR 0328 §7, §8)" + out.waits = []link.AccessWait{{Kind: link.WaitKindAccess, Access: access, Part: l.Library, + What: apply.KeepWords(l.Library), Placed: v.Placed, Reason: link.WaitKeep}} + case apply.VerdictWaitDisk: + out.state, out.reason = link.StateWaiting, v.Why+" (novox/hq ADR 0328 §9)" + out.waits = []link.AccessWait{{Kind: link.WaitKindAccess, Access: access, Part: l.Library, + What: apply.DiskWords(l.Library), Reason: link.WaitDisk}} + case apply.VerdictUnknown: + // Not a fault and not the operator's: said as not known, and judged again on the next look. + out.state, out.reason = link.StateUnknown, v.Why+"; judged again on the next look" + case apply.VerdictBroken: + out.state, out.reason = link.StateUnhealthy, v.Why + default: + out.state = link.StateHealthy + switch { + case v.Act != nil && v.Act.State == link.ActRunning: + out.state, out.reason = link.StateStarting, "the take act is setting its group under its root" + case v.Act != nil && (v.Act.State == link.ActStopped || v.Act.State == link.ActFailed): + out.state, out.reason = link.StateStarting, "the take act goes on from where it stopped: "+v.Act.Why + } + } + return out +} + +// look reads every library now. Whether any verdict changed since the last look, for the loop to say it. +func (j *libraryJudge) look(now time.Time) ([]link.ResourceHealth, bool) { + j.mu.Lock() + ls := append([]declaration.Library(nil), j.libraries...) + j.mu.Unlock() + + read := make([]verdict, len(ls)) + for i, l := range ls { + v, answered := j.judgeBounded(l) + if !answered { + v = verdict{state: link.StateUnknown, reason: "its root does not answer: judging it did not return " + + "within " + lookBound.String()} + } + read[i] = v + } + + j.mu.Lock() + defer j.mu.Unlock() + changed := false + out := make([]link.ResourceHealth, 0, len(ls)) + for i, l := range ls { + r := read[i] + id := apply.LibraryAccess(l.Library) + v := link.ResourceHealth{Resource: id, Kind: link.KindLibrary, Target: l.Root, State: r.state, + Reason: r.reason, Since: now.UTC(), AccessWaits: r.waits, Act: r.act} + if was, had := j.was[id]; had && was.State == r.state && was.Reason == r.reason { + v.Since = was.Since + v.Streak = was.Streak + } else { + changed = true + } + if r.state == link.StateUnhealthy { + v.Streak++ + } + j.was[id] = v + out = append(out, v) + } + return out, changed +} + +// withAccesses adds to a statement every library, as a resource of kind library. +func withAccesses(h *link.Health, vs []link.ResourceHealth) *link.Health { + h.Resources = append(h.Resources, vs...) + return h +} + +// accessesHealthy says no library in vs is anything but healthy. +func accessesHealthy(vs []link.ResourceHealth) bool { + for _, v := range vs { + if v.State != link.StateHealthy { + return false + } + } + return true +} + +// keeper goes on with take acts stopped at their bound, beside the applies, one at a time (ADR 0328 §7). +type keeper struct { + running atomic.Bool + say func(string) +} + +// keeping is the one keeper on this machine. +var keeping = &keeper{say: func(string) {}} + +// poke goes on with every stopped take act of the declaration last applied, unless that is running already. +func (k *keeper) poke(ctx context.Context) { + d := accesses.declaration() + if d == nil || len(d.Libraries) == 0 || !k.running.CompareAndSwap(false, true) { + return + } + go func() { + defer k.running.Store(false) + for _, line := range apply.ReconcileLibraries(ctx, d) { + k.say(line) + } + }() +} + +// startKeeping has the keeper say what it does. +func startKeeping(ctx context.Context, say func(string)) { + keeping.say = say + keeping.poke(ctx) +} + +// accessActFrom is the take act as the controller asks it over the link (ADR 0328 §7): judged against the +// declaration this node keeps, and run beside the applies. +func accessActFrom(statePath string, timeout time.Duration) link.AccessActFunc { + return func(act link.AccessAct) (string, error) { + kept, err := store.ReadDeclared(store.DeclaredPath(statePath)) + if errors.Is(err, store.ErrNothingDeclared) { + return "", errors.New("this node keeps no declaration, so it is declared no library") + } + if err != nil { + return "", err + } + d, err := declaration.Parse(kept.Declaration) + if err != nil { + return "", fmt.Errorf("the declaration this node keeps does not read: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _, said, err := apply.TakeLibrary(ctx, d, act.Access, filepath.Clean(act.Path), act.Warrant, act.By) + if err != nil { + fmt.Fprintf(os.Stderr, "mesh-host: the take act on %s was refused: %v\n", act.Access, err) + return "", err + } + return said, nil + } +} + +// withWaiting is held with every id of waiting beside it: what liveness does not judge. +func withWaiting(held, waiting map[string]bool) map[string]bool { + if len(waiting) == 0 { + return held + } + out := map[string]bool{} + for id := range held { + out[id] = true + } + for id := range waiting { + out[id] = true + } + return out +} diff --git a/cmd/mesh-host/libraries_test.go b/cmd/mesh-host/libraries_test.go new file mode 100644 index 0000000..49ab1a3 --- /dev/null +++ b/cmd/mesh-host/libraries_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/link" +) + +// A library whose root holds data never taken is said waiting with one access wait: the access `library:`, the +// part its name, words with no path, the filesystem's name, and reason keep (novox/hq ADR 0328 §8); one whose root +// is absent waits with reason disk (§9). Neither carries a module. +func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) { + was := apply.Libraries + apply.Libraries = apply.NewLibraryMemory() + defer func() { apply.Libraries = was }() + dir := t.TempDir() + root := filepath.Join(dir, "media") + if err := os.MkdirAll(filepath.Join(root, "a film"), 0o755); err != nil { + t.Fatal(err) + } + d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[{"name":"mesh-media","gid":62000},` + + `{"name":"mesh-books","gid":62001}],"libraries":[` + + `{"library":"media","root":"` + root + `","group":"mesh-media","gid":62000,"home":true},` + + `{"library":"books","root":"` + filepath.Join(dir, "books") + `","group":"mesh-books","gid":62001,"home":true}],` + + `"owns_nothing":true,"resources":[]}`)) + if err != nil { + t.Fatal(err) + } + j := newLibraryJudge() + j.set(d, nil) + vs, _ := j.look(time.Now()) + if len(vs) != 2 { + t.Fatalf("the libraries are not said: %+v", vs) + } + books, media := vs[0], vs[1] + if media.Kind != link.KindLibrary || media.Resource != "library:media" || media.Module != "" || + media.State != link.StateWaiting || len(media.AccessWaits) != 1 { + t.Fatalf("a library holding data is not said waiting: %+v", media) + } + raw, _ := json.Marshal(media.AccessWaits[0]) + want := `{"kind":"access","access":"library:media","part":"media","what":"let the mesh keep the library media",` + + `"placed":"` + media.AccessWaits[0].Placed + `","reason":"keep"}` + if string(raw) != want || !strings.HasPrefix(media.AccessWaits[0].Placed, "fs-") { + t.Fatalf("the wait reads %s", raw) + } + if strings.Contains(media.Reason, dir) || len(books.AccessWaits) != 1 || books.AccessWaits[0].Reason != link.WaitDisk { + t.Fatalf("a missing root does not wait for its disk, or a reason names a path: %+v %+v", books, media) + } +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index a1d21ab..b52473e 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -241,8 +241,8 @@ func run(ctx context.Context, command string, opts options) error { // found (novox/hq issue 339). handedOverFrom(opts.state) foundState.Store(opts.state) - // And what it knows of the shared folders it keeps is beside its state (novox/hq ADR 0325 §5). - apply.KeptFolders = store.NewKeptFile(opts.state) + // And what it knows of the libraries it keeps is beside its state (novox/hq ADR 0328 §6). + apply.Libraries = store.NewLibraryFile(opts.state) // And the guard asks the running container runtimes where they keep their data (novox/hq issue 339). apply.AskRuntimes = apply.ExecRunner switch command { @@ -1123,7 +1123,7 @@ func runLink(ctx context.Context, opts options) error { // this process's, a `reconcile` run by hand, the installer's — reads it (novox/hq issue 224). sched.RecordWindowsIn(apply.WindowsIn(filepath.Dir(opts.state))) go sched.Run(ctx) - // The watch on every kept folder and the walks under them, beside the applies (novox/hq ADR 0325 §6, §8). + // The take act's walks that stopped at their bound go on beside the applies (novox/hq ADR 0328 §7). startKeeping(ctx, func(line string) { say(line) }) // The one liveness judge on this machine (novox/hq ADR 0240), made before anything applies: every @@ -1236,7 +1236,7 @@ func runLink(ctx context.Context, opts options) error { // A read-only judgement of the directories this node is declared, asked by the controller (novox/hq // issue 358): which the walk that follows no link another account could make would refuse. JudgeDirectories: judgeDirectoriesFrom(opts.state), - // The take act on an access's folder, on the operator's warrant (novox/hq ADR 0325 §8). + // The take act on a library, on the operator's warrant (novox/hq ADR 0328 §7). AccessAct: accessActFrom(opts.state, opts.timeout), Say: say, Timeout: opts.timeout, @@ -1602,8 +1602,7 @@ const ( // judgeWhatRuns looks at every long-running resource every liveness.LookEvery and says the statement on // the bus when a state changed, again every minute while one is not healthy, and every five minutes // anyway. A statement the bus did not take is said at the next look. It reads; it never acts (ADR 0240 -// rule 6) — but for the sweep of a kept folder, which ADR 0325 §10 has every look make: it puts back an entry or a -// mask an app narrowed, and nothing else. +// rule 6) — but for going on with a take act stopped at its bound (ADR 0328 §7), which runs beside it. func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, say link.Announce) { ticker := time.NewTicker(liveness.LookEvery) defer ticker.Stop() @@ -1642,8 +1641,8 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa } } } - // Every access, judged on every look, its kept folder swept (novox/hq ADR 0325 §10); and a walk stopped - // at its bound goes on. + // Every library, judged on every look (novox/hq ADR 0328 §8, §9); and a take act stopped at its bound + // goes on, beside. accessSt, accessesChanged := accesses.look(time.Now()) keeping.poke(ctx) owed = owed || accessesChanged @@ -1700,10 +1699,10 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa // healthAsReported is a statement as the report and the event carry it. func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health { - // IdentityContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), judges a - // user's declared `root` (novox/hq ADR 0266), and reads a declaration's identities, a container's identity and - // an access's users (novox/hq ADR 0325), which is what tells the controller it may be sent any of those fields. - h := &link.Health{Contract: link.IdentityContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} + // LibraryContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), judges a + // user's declared `root` (novox/hq ADR 0266), and reads a declaration's identities, groups and libraries + // (novox/hq ADR 0328), which is what tells the controller it may be sent any of those fields. + h := &link.Health{Contract: link.LibraryContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} for _, r := range st.Resources { h.Resources = append(h.Resources, link.ResourceHealth{Module: r.Module, Resource: r.ID, Kind: r.Kind, Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak, @@ -2084,9 +2083,9 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto // What runs here for a module, and how each is (novox/hq ADR 0240): judged from what was just // applied, so every resource this apply started says `starting` and the gate waits for it. if j := judging; j != nil { - // A container that waits for an accessed folder not there was not created: the access says why, and - // liveness judges it neither alive nor dead (novox/hq ADR 0325 §9). - j.Set(liveness.LongRunning(declared, withWaiting(held, apply.WaitingOnAccess(declared)))) + // A container that waits for a library whose disk or mount is not there was not created: the library says + // why, and liveness judges it neither alive nor dead (novox/hq ADR 0328 §9). + j.Set(liveness.LongRunning(declared, withWaiting(held, apply.WaitingOnLibrary(declared)))) st, _ := j.Look(ctx) netJudge.declare(declaredResolvConf(declared)) var netSt *network.Statement @@ -2109,7 +2108,7 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto accountSt = &as } accesses.set(declared, held) - // What the apply changed in who uses a kept folder is walked under it now, beside (ADR 0325 §8). + // A take act stopped at its bound goes on, beside (ADR 0328 §7). keeping.poke(ctx) accessSt, _ := accesses.look(time.Now()) report.Health = withAccesses(withFoundDirectories(withAccounts(withUnits(healthAsReported(st, netSt), unitSt), diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index 2036bce..c1b8a88 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -87,10 +87,10 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) { Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at}, }} h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as) - // At RootContract or later: an engine that also reads identities and keeps shared folders says IdentityContract - // (novox/hq ADR 0325), one above ADR 0312's GrantContract. - if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.IdentityContract || - link.IdentityContract != link.GrantContract+1 { + // At RootContract or later: an engine that also reads libraries says LibraryContract (novox/hq ADR 0328), one + // above ADR 0325's IdentityContract. + if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.LibraryContract || + link.LibraryContract != link.IdentityContract+1 { t.Fatalf("contract %d", h.Contract) } got := map[string]string{} diff --git a/internal/apply/acl.go b/internal/apply/acl.go deleted file mode 100644 index 2b8e8c5..0000000 --- a/internal/apply/acl.go +++ /dev/null @@ -1,242 +0,0 @@ -package apply - -// The POSIX ACL a kept folder carries, as the kernel stores it (novox/hq ADR 0325 §5, issue 567). -// -// **Read and written as the kernel's own extended attribute, never through setfacl.** Linux keeps a file's access -// ACL in the attribute system.posix_acl_access and a folder's default ACL in system.posix_acl_default, each a -// little-endian header (version 2) followed by entries of a tag, a permission and an id. The engine reads and -// writes them on a descriptor it opened itself (fgetxattr, fsetxattr), so a name swapped for a link between the -// look and the change changes nothing, and no program is run per file of a library of millions. -// -// **What the mesh changes is narrow.** It adds or sets one named-user entry per uid it keeps, widens the mask to -// what the entries need, and on a file with no ACL yet writes the owning group's entry as exactly the group bits -// the mode had: owner, owning group, the owner's bits, others' bits and every other entry stay as found. - -import ( - "encoding/binary" - "errors" - "fmt" - "sort" -) - -// The attributes and the tags of the kernel's ACL format (linux/posix_acl_xattr.h). -const ( - aclAccessXattr = "system.posix_acl_access" - aclDefaultXattr = "system.posix_acl_default" - aclVersion = 2 - - aclUserObj = 0x01 - aclUser = 0x02 - aclGroupObj = 0x04 - aclGroup = 0x08 - aclMask = 0x10 - aclOther = 0x20 - - // aclUndefinedID is the id of an entry that names nobody: the owner, the owning group, the mask, others. - aclUndefinedID = 0xFFFFFFFF -) - -// The permission bits of one entry. -const ( - permRead = 4 - permWrite = 2 - permExec = 1 -) - -// aclEntry is one entry: its tag, its permission bits (rwx as 4, 2, 1) and, for a named user or group, its id. -type aclEntry struct { - Tag uint16 - Perm uint16 - ID uint32 -} - -// acl is a whole ACL as the kernel holds it. -type acl []aclEntry - -// decodeACL reads an attribute's bytes. An attribute that is not version 2, or not whole entries, is refused. -func decodeACL(raw []byte) (acl, error) { - if len(raw) < 4 || (len(raw)-4)%8 != 0 { - return nil, fmt.Errorf("an ACL attribute of %d bytes is not a header and whole entries", len(raw)) - } - if v := binary.LittleEndian.Uint32(raw); v != aclVersion { - return nil, fmt.Errorf("an ACL attribute of version %d, and the engine reads version %d", v, aclVersion) - } - out := make(acl, 0, (len(raw)-4)/8) - for at := 4; at < len(raw); at += 8 { - out = append(out, aclEntry{ - Tag: binary.LittleEndian.Uint16(raw[at:]), - Perm: binary.LittleEndian.Uint16(raw[at+2:]), - ID: binary.LittleEndian.Uint32(raw[at+4:]), - }) - } - return out, nil -} - -// encode is the attribute's bytes, the entries in the order the kernel requires: by tag, then by id. -func (a acl) encode() []byte { - sorted := append(acl(nil), a...) - sort.SliceStable(sorted, func(i, j int) bool { - if sorted[i].Tag != sorted[j].Tag { - return sorted[i].Tag < sorted[j].Tag - } - return sorted[i].ID < sorted[j].ID - }) - out := make([]byte, 4, 4+8*len(sorted)) - binary.LittleEndian.PutUint32(out, aclVersion) - for _, e := range sorted { - var b [8]byte - binary.LittleEndian.PutUint16(b[0:], e.Tag) - binary.LittleEndian.PutUint16(b[2:], e.Perm) - binary.LittleEndian.PutUint32(b[4:], e.ID) - out = append(out, b[:]...) - } - return out -} - -// minimalACL is the ACL a mode alone stands for: the owner's, the owning group's and others' bits. -func minimalACL(mode uint32) acl { - return acl{ - {Tag: aclUserObj, Perm: uint16(mode>>6) & 7, ID: aclUndefinedID}, - {Tag: aclGroupObj, Perm: uint16(mode>>3) & 7, ID: aclUndefinedID}, - {Tag: aclOther, Perm: uint16(mode) & 7, ID: aclUndefinedID}, - } -} - -// find is the index of the entry with tag and id, or -1. -func (a acl) find(tag uint16, id uint32) int { - for i, e := range a { - if e.Tag == tag && (e.ID == id || (tag != aclUser && tag != aclGroup)) { - return i - } - } - return -1 -} - -// perm is the bits of the entry with tag and id, and whether there is one. -func (a acl) perm(tag uint16, id uint32) (uint16, bool) { - if i := a.find(tag, id); i >= 0 { - return a[i].Perm, true - } - return 0, false -} - -// check refuses an ACL the kernel would: one owner's, owning group's and others' entry each, and a mask whenever -// a named entry is there. -func (a acl) check() error { - count := map[uint16]int{} - named := false - for _, e := range a { - count[e.Tag]++ - if e.Tag == aclUser || e.Tag == aclGroup { - named = true - } - } - if count[aclUserObj] != 1 || count[aclGroupObj] != 1 || count[aclOther] != 1 || count[aclMask] > 1 { - return errors.New("an ACL without exactly one owner's, owning group's and others' entry") - } - if named && count[aclMask] != 1 { - return errors.New("an ACL with named entries and no mask") - } - return nil -} - -// keptACL is a with the mesh's entries made: for each uid of need its entry set to exactly its bits, and the mask -// widened by what those entries need — never narrowed, so an app's wider mask stays. A mask already there keeps -// what it gave every other entry: one holding a bit the widening adds loses that bit alone; with none yet (a file the mode alone described) the mask starts at the owning -// group's bits, which minimalACL gave its entry, so the owning group keeps exactly the right its group bits gave. -// Nothing else is changed. -func keptACL(a acl, need map[uint32]uint16) acl { - out := append(acl(nil), a...) - var widen uint16 - for uid, bits := range need { - widen |= bits - if i := out.find(aclUser, uid); i >= 0 { - out[i].Perm = bits - } else { - out = append(out, aclEntry{Tag: aclUser, Perm: bits, ID: uid}) - } - } - if i := out.find(aclMask, 0); i >= 0 { - old := out[i].Perm - out[i].Perm |= widen - if added := out[i].Perm &^ old; added != 0 { - // **A wider mask widens nobody else.** The owning group's entry and every named entry the mesh does - // not set here were limited by the old mask: a file made at 0644 under a default ACL carries the - // owning group's rwx with a mask of r--. One mask serves them all, so an entry holding a bit the mask - // now adds cannot keep its effective right without losing that bit: exactly those bits are taken off - // it, and nothing else of it changes. An entry holding none of them is not touched at all. - for j := range out { - e := &out[j] - _, ours := need[e.ID] - if (e.Tag == aclGroupObj || e.Tag == aclGroup || (e.Tag == aclUser && !ours)) && e.Perm&added != 0 { - e.Perm &^= added - } - } - } - return out - } - if len(need) == 0 { - return out - } - group, _ := out.perm(aclGroupObj, 0) - return append(out, aclEntry{Tag: aclMask, Perm: group | widen, ID: aclUndefinedID}) -} - -// withoutUsers is a with the named-user entry of each uid of drop taken out, and nothing else changed: the mask -// stays as it was, so no other entry loses a right. -func withoutUsers(a acl, drop map[uint32]bool) acl { - out := make(acl, 0, len(a)) - for _, e := range a { - if e.Tag == aclUser && drop[e.ID] { - continue - } - out = append(out, e) - } - return out -} - -// effective is the right a uid has through a, given the file's owner and mode: the owner's bits for the owner, -// else its named entry limited by the mask, else nothing a named entry gives (the owning group's and others' -// rights are not the mesh's to count on). An ACL with no mask limits nothing. -func effective(a acl, uid, owner uint32, mode uint32) uint16 { - if uid == owner { - return uint16(mode>>6) & 7 - } - bits, ok := a.perm(aclUser, uid) - if !ok { - return 0 - } - if mask, has := a.perm(aclMask, 0); has { - bits &= mask - } - return bits -} - -// needOn is what a use needs on a folder (read and enter; with read-write, write too) or on a file (read; with -// read-write, write): entering means nothing on a file, and an execute bit given to data would be a right nobody -// asked for. -func needOn(readWrite, folder bool) uint16 { - bits := uint16(permRead) - if readWrite { - bits |= permWrite - } - if folder { - bits |= permExec - } - return bits -} - -// permWords is bits as ls writes them: "rwx", "r-x". -func permWords(bits uint16) string { - out := []byte("---") - if bits&permRead != 0 { - out[0] = 'r' - } - if bits&permWrite != 0 { - out[1] = 'w' - } - if bits&permExec != 0 { - out[2] = 'x' - } - return string(out) -} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index ae74865..6b83d7c 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -262,7 +262,7 @@ func ApplyMindingWindows( } report = Report{} - // **No module identity is root** (novox/hq ADR 0325 §11): an identity or a container told a root number refuses + // **No module identity is root** (novox/hq ADR 0328 §15): an identity or a container told a root number refuses // the declaration whole, before anything of it is made or started. if err := refuseRootIdentities(d); err != nil { return report, known, &Error{Resource: "the declaration's identities", Err: err, Done: report} @@ -349,12 +349,8 @@ func ApplyMindingWindows( case declaration.TypeUser: action, detail, err = removeUser(ctx, sys, orphan, run, wanted, handed) case declaration.TypeAccess: - // The folder is left as it is: what the mesh added to it is taken out by the walk once no access names - // it (novox/hq ADR 0325 §8), never by removing the access's record. + // The path is left as it is (novox/hq ADR 0051); what ADR 0312's grant put an account in is taken back. action, detail = "forgotten", "an operator-owned path is never the host's to remove" - if _, kept := keptRecord(filepath.Clean(orphan.Target)); kept { - detail += "; the entries the mesh added go once no access names it" - } gave, left, said := takeBackGranted(ctx, sys, orphan, run, wanted, handed) if said != "" { detail = joinDetail(detail, said) @@ -539,13 +535,6 @@ func ApplyMindingWindows( // which declared directories were already here: one this apply makes is the mesh's, one found may not be. refreshRuntimeRoots(ctx) in.refused = refusedPlaces(d.Resources) - // What every declared access asks of its folder, several accesses of one folder as one (novox/hq ADR 0325 §5), - // and every accessed folder, which no container mounts while it is not there (§9). - // Every tool runner's account that came with no number, read by name here (ADR 0325 §4). - in.runners = ResolveRunners(ctx, d) - in.folders = foldersOf(d.Resources, in.refused) - in.identities = len(d.Identities) > 0 - in.accesses = accessPaths(d.Resources, in.refused, in.identities) in.agentHomes = rootNeverHomes(d) in.dirsBefore = map[string]bool{} for _, resource := range d.Resources { @@ -587,17 +576,46 @@ func ApplyMindingWindows( sort.Strings(out) return out } - // **Every module's identity is made before anything of it is applied** (novox/hq ADR 0325 §3): a directory - // it owns is given to it, and a container is told to run as it. One that cannot be made — its name or numbers - // another's here — fails, naming whose, and its module is not attempted; the rest of the machine is. + // **Every library's group, then every module's identity, is made before anything of it is applied** (novox/hq + // ADR 0328 §3, §4): a directory a module owns is given to it, and its user is in its libraries' groups. One that + // cannot be made — its name or numbers another's here — fails, naming whose, and what needs it is not + // attempted; the rest of the machine is. var identityFailures []*Error - if len(d.Identities) > 0 { + brokenGroups := map[int]bool{} + if len(d.Groups) > 0 || len(d.Identities) > 0 { tool, toolErr := toolFor(sys) - for _, id := range d.Identities { + for _, g := range d.Groups { var outcome Outcome err := toolErr if err == nil { - outcome, err = applyIdentity(ctx, tool, id, &known, run) + outcome, err = applyGroup(ctx, tool, g, &known, run) + } + if err != nil { + identityFailures = append(identityFailures, &Error{Resource: "group " + g.Name, Err: err}) + log(fmt.Sprintf(" failed group %s: %v", g.Name, err)) + brokenGroups[g.GID] = true + continue + } + report.Outcomes = append(report.Outcomes, outcome) + if outcome.Action != "unchanged" { + log(fmt.Sprintf(" %s %s: %s", outcome.Action, outcome.ID, outcome.Detail)) + } + } + names := map[int]string{} + for _, g := range d.Groups { + names[g.GID] = g.Name + } + for _, id := range d.Identities { + var outcome Outcome + err := toolErr + for _, gid := range append([]int{id.GID}, id.Groups...) { + if brokenGroups[gid] && err == nil { + err = fmt.Errorf("its library group %s could not be made; nothing of the module was attempted", + names[gid]) + } + } + if err == nil { + outcome, err = applyIdentity(ctx, tool, id, names, &known, run) } if err != nil { // Counted with the others once the resources are through: here, before the guard, it would read @@ -615,6 +633,37 @@ func ApplyMindingWindows( } } } + // **Every library this machine is reached by, before anything mounts it** (ADR 0328 §6, §9, §11): kept on its + // home, its tool runner's account in its group, and the containers that mount one whose disk or mount is not + // there held. A tool runner that joined a group is a change of `library:`, which the tool runner's unit + // names under restart-on to be restarted once. + verdicts := map[string]libraryVerdict{} + for _, l := range d.Libraries { + outcome, v, err := applyLibrary(ctx, l) + verdicts[l.Library] = v + if err == nil { + var moved bool + var said string + moved, said, err = joinRunner(ctx, sys, l, &known, run) + if moved { + changed[LibraryAccess(l.Library)] = true + outcome.Detail = joinDetail(outcome.Detail, said) + if outcome.Action == "unchanged" { + outcome.Action = "updated" + } + } + } + if err != nil { + identityFailures = append(identityFailures, &Error{Resource: LibraryAccess(l.Library), Err: err}) + log(fmt.Sprintf(" failed %s (%s): %v", LibraryAccess(l.Library), l.Root, err)) + continue + } + report.Outcomes = append(report.Outcomes, outcome) + if outcome.Action != "unchanged" { + log(fmt.Sprintf(" %s %s (%s): %s", outcome.Action, outcome.ID, outcome.Target, outcome.Detail)) + } + } + in.held = heldRoots(verdicts, d.Libraries) for i, resource := range ordered { if !orphansRemoved && i == guardFirst { // **Only a guard that is up may let the filter go.** Removing the derived filter's @@ -1001,14 +1050,22 @@ func ApplyMindingWindows( // Only the mesh says what is assigned: a carried bundle's silence undeclares no identity and no folder. if origin == store.OriginDeclared { - // A folder no access names any more has what the mesh added taken out by the next walk (ADR 0325 §8). - forgetUndeclared(in.folders) - // And an identity no longer declared goes, once what ran as it went above (ADR 0325 §3). + // A tool runner no longer put in a library's group leaves it (ADR 0328 §11, ADR 0252 §3). + for _, o := range leaveRunners(ctx, sys, d.Libraries, &known, run, wanted) { + report.Outcomes = append(report.Outcomes, o) + log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail)) + } + // And an identity, then a library group, no longer declared goes, once what ran as it went above (ADR 0328 + // §3, §4). if tool, err := toolFor(sys); err == nil { for _, o := range removeIdentities(ctx, tool, d.Identities, &known, run) { report.Outcomes = append(report.Outcomes, o) log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail)) } + for _, o := range removeGroups(ctx, tool, d.Groups, &known, run) { + report.Outcomes = append(report.Outcomes, o) + log(fmt.Sprintf(" %s %s: %s", o.Action, o.ID, o.Detail)) + } } } @@ -1139,12 +1196,13 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru if err := refuseMounts(res, in.refused); err != nil { return begin(r), err } - // Not created over an accessed folder not there, which the runtime would make as root (novox/hq ADR 0325 §9). - if id := mountsMissing(ctx, res, in.accesses); id != "" { + // Not created over a library whose disk or mount is not there: the runtime would make the root as root, + // and the container's writes would land on the filesystem below (novox/hq ADR 0328 §9). + if name := mountsHeld(res, in.held); name != "" { out := begin(r) out.Action = "waiting" - out.Detail = "not created: it mounts the access " + id + ", whose folder or its disk is not there; " + - "created once it is (novox/hq ADR 0325 §9)" + out.Detail = "not created: it mounts the library " + name + ", whose disk or mount is not there; " + + "created once it is (novox/hq ADR 0328 §9)" return out, nil } } @@ -2196,14 +2254,9 @@ type inputs struct { // dirsBefore is every declared directory that was on the machine when this apply began (novox/hq issue // 339): one the apply itself made — a file's parent — is the mesh's, one that was there may not be. dirsBefore map[string]bool - // folders is what every declared access asks of its folder, by path (novox/hq ADR 0325 §5); accesses every - // accessed folder with its access's id, which no container mounts while it is not there (§9). - folders map[string]*folderWant - // identities is that the declaration carries identities: its controller speaks ADR 0325 for every access. - identities bool - // runners is every access refused for its tool runner's account (not here, or root's), by id. - runners map[string]error - accesses map[string]string + // held is the root of every library whose disk or mount is not there, with its name: no container that mounts + // it is created (novox/hq ADR 0328 §9). + held map[string]string // handed is every group a record that went this apply handed to a resource still asking for it. handed Handed } diff --git a/internal/apply/contract_test.go b/internal/apply/contract_test.go deleted file mode 100644 index 6532c8c..0000000 --- a/internal/apply/contract_test.go +++ /dev/null @@ -1,128 +0,0 @@ -//go:build linux - -package apply - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/novox/mesh-host/internal/declaration" - "github.com/novox/mesh-host/internal/store" -) - -// The contract the controller needs (novox/hq ADR 0325, issue 567): what it cannot know is read here, and what it -// names is matched as it names it. - -// accountsHere stands in for the machine's user database: name → uid, gid. -func accountsHere(t *testing.T, known map[string][2]int) { - t.Helper() - was := AccountIDs - AccountIDs = func(_ context.Context, name string) (int, int, bool, error) { - ids, ok := known[name] - return ids[0], ids[1], ok, nil - } - t.Cleanup(func() { AccountIDs = was }) -} - -// A tool runner's account comes with its name alone: its uid is read here, and its entry kept with it. -func TestAToolRunnersAccountIsReadByName(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - accountsHere(t, map[string][2]int{"nox": {runner, 1000}}) - lib := filepath.Join(dir, "books") - if err := os.Mkdir(lib, 0o755); err != nil { - t.Fatal(err) - } - a := `{"id":"importer.books","type":"access","path":"` + lib + `","mode":"read-write","users":[` + - `{"identity":"nox","use":"read-write","from":"tool-runner"}]}` - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - if got := effectiveOn(t, lib, runner); got != 7 { - t.Fatalf("the tool runner's account read by name may %s", permWords(got)) - } - if rec, _ := keptRecord(lib); len(rec.Want) != 1 || rec.Want[0].UID != runner { - t.Errorf("the uid read is not what is kept: %+v", rec.Want) - } -} - -// One that is not here, or is root's, refuses its access, naming why, and gives nothing an entry. -func TestAToolRunnersAccountThatIsNotHereOrIsRootsIsRefused(t *testing.T) { - was := RootGroupIDs - RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} } - defer func() { RootGroupIDs = was }() - for name, says := range map[string]string{"ghost": "not on this machine", "root": "never root", - "admin": "root group"} { - dir := aclFolder(t) - keeping(t, nil, nil) - accountsHere(t, map[string][2]int{"root": {0, 0}, "admin": {1001, 10}}) - lib := filepath.Join(dir, "books") - if err := os.Mkdir(lib, 0o755); err != nil { - t.Fatal(err) - } - a := `{"id":"importer.books","type":"access","path":"` + lib + `","mode":"read-write","users":[` + - `{"identity":"` + name + `","use":"read-write","from":"tool-runner"}]}` - _, _, err := applyAccesses(t, store.State{}, a) - if err == nil || !strings.Contains(err.Error(), says) { - t.Errorf("%s: not refused saying %q: %v", name, says, err) - } - if n := len(readACLsOrNil(t, lib)); n > 3 { - t.Errorf("%s: an entry was kept", name) - } - } - // And only a tool runner's account may come without a number. - if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[{"id":"m.a","type":"access","path":"/srv/x",` + - `"mode":"read-write","users":[{"identity":"mesh-m","use":"read","from":"container"}]}]}`)); err == nil { - t.Error("a container's identity without a uid was accepted") - } -} - -func readACLsOrNil(t *testing.T, path string) acl { - a, _, _ := readACLs(t, path) - return a -} - -// The take act names its access by the declared resource id exactly, `/` and all; anything not declared is refused. -func TestTheTakeActMatchesTheDeclaredAccessId(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "movies") - if err := os.MkdirAll(filepath.Join(lib, "a film"), 0o755); err != nil { - t.Fatal(err) - } - id := "radarr.access-services/media/movies" - a := `{"id":"` + id + `","type":"access","path":"` + lib + `","mode":"read-write","users":[` + - `{"identity":"mesh-radarr","uid":61001,"use":"read-write","from":"container"}]}` - toUser(t) - if _, _, err := TakeFolder(context.Background(), declare(t, a), "radarr.access-services/media", lib, "w", "op", - noServices); err == nil { - t.Fatal("an access not declared was taken") - } - toUser(t) - done, _, err := TakeFolder(context.Background(), declare(t, a), id, lib, "w", "op", noServices) - if err != nil { - t.Fatalf("the declared id with a slash was refused: %v", err) - } - <-done -} - -// The words a keep wait says of ACLs: only a ZFS dataset is one the take act lets carry them. -func TestAKeepWaitSaysWhetherTheTakeActCanAllowACLs(t *testing.T) { - if aclWord("tank/media", "zfs") != ACLNeedsEnabling || aclWord("/dev/sdb1", "ext4") != ACLUnsupported || - aclWord("", "zfs") != ACLUnsupported { - t.Fatal("the ACL words are not what the take act can do") - } - dir := aclFolder(t) // carries ACLs: the wait says nothing of them - keeping(t, nil, nil) - lib := filepath.Join(dir, "music") - if err := os.MkdirAll(filepath.Join(lib, "album"), 0o755); err != nil { - t.Fatal(err) - } - toUser(t) - if v := AccessVerdict(context.Background(), lib, false, true); v.Kind != VerdictWaitKeep || v.ACL != "" { - t.Fatalf("a folder whose filesystem allows ACLs is said %+v", v) - } -} diff --git a/internal/apply/identities.go b/internal/apply/identities.go index 5fb229f..eeb453e 100644 --- a/internal/apply/identities.go +++ b/internal/apply/identities.go @@ -14,7 +14,7 @@ import ( "github.com/novox/mesh-host/internal/system" ) -// Every module is given an identity of its own (novox/hq ADR 0325 §3, §11, issue 567). +// Every module is given an identity of its own (novox/hq ADR 0328 §3, §4, §15, issue 567). // // The controller allocates each module a user and a group number once, from a range of its own, the same on every // machine, and declares them as `mesh-`. The engine makes the group and the account with exactly those @@ -32,7 +32,8 @@ import ( // accountsTool is how the engine reads and changes the machine's accounts: the system's own tools through the // runner, which is what a test stands in for. type accountsTool interface { - CreateSystemAccount(ctx context.Context, run system.Runner, name string, uid, gid int) error + CreateSystemAccount(ctx context.Context, run system.Runner, name string, uid, gid int, groups []string) error + ModifyAccount(ctx context.Context, run system.Runner, name string, gid int, groups []string) error CreateSystemGroup(ctx context.Context, run system.Runner, name string, gid int) error RemoveAccount(ctx context.Context, run system.Runner, name string) error RemoveGroup(ctx context.Context, run system.Runner, name string) error @@ -75,9 +76,22 @@ func lookUp(ctx context.Context, run Runner, database, key string) (entry, bool, func refuseRootIdentities(d *declaration.Declaration) error { var problems []string for _, id := range d.Identities { - if err := rootIdentity(id.UID, id.GID); err != nil { + // Its user's number, its own group's (the same number), its primary group and every added group. + if err := rootIdentity(id.UID, id.UID); err != nil { problems = append(problems, fmt.Sprintf("the identity %s: %v", id.Name, err)) } + for _, gid := range append([]int{id.GID}, id.Groups...) { + if why := rootGroup(gid); why != "" { + problems = append(problems, fmt.Sprintf("the identity %s is given gid %s, and a module's identity "+ + "is never in a root group (novox/hq ADR 0328 §15)", id.Name, why)) + } + } + } + for _, g := range d.Groups { + if why := rootGroup(g.GID); why != "" { + problems = append(problems, fmt.Sprintf("the library group %s is gid %s, and a library's group is never a "+ + "root group (novox/hq ADR 0328 §15)", g.Name, why)) + } } for _, r := range d.Resources { if c, ok := r.(*declaration.Container); ok && c.RunAs != nil { @@ -94,10 +108,13 @@ func refuseRootIdentities(d *declaration.Declaration) error { return fmt.Errorf("%s; nothing of this declaration was made or started", strings.Join(problems, "; ")) } -// applyIdentity makes one identity as declared, or refuses it naming whose the name or the numbers are. What it -// made is recorded on known; an identity already right is unchanged. -func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identity, known *store.State, - run Runner) (Outcome, error) { +// applyIdentity makes one identity as declared (ADR 0328 §4): its own group `mesh-` with the number of its +// uid, and its user with that uid, its primary group gid (its own, or its first library's) and its other libraries' +// groups as added groups — or refuses it naming whose the name or the numbers are. A user the engine made is given +// a changed primary or added groups; one that was there before is never changed, and is refused if it is not as +// declared. What it made is recorded on known; an identity already right is unchanged. +func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identity, names map[int]string, + known *store.State, run Runner) (Outcome, error) { out := Outcome{ID: "identity " + id.Name, Type: "identity", Target: id.Name, Action: "unchanged"} user, hasUser, err := lookUp(ctx, run, "passwd", id.Name) if err != nil { @@ -111,42 +128,74 @@ func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identi if err != nil { return out, err } - byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(id.GID)) + byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(id.UID)) if err != nil { return out, err } - const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0325 §3)" + rec := madeIdentity(known, id.Name) + const never = "; nothing was made, and nothing of the module was attempted (novox/hq ADR 0328 §4)" switch { - case hasUser && (user.id != id.UID || user.prim != id.GID): - return out, fmt.Errorf("the account %s is here with uid %d and group %d, and the mesh allocated uid %d, "+ - "gid %d%s", id.Name, user.id, user.prim, id.UID, id.GID, never) - case hasGroup && group.id != id.GID: - return out, fmt.Errorf("the group %s is here with gid %d, and the mesh allocated gid %d%s", id.Name, - group.id, id.GID, never) + case hasUser && user.id != id.UID: + return out, fmt.Errorf("the account %s is here with uid %d, and the mesh allocated uid %d%s", id.Name, + user.id, id.UID, never) + case hasUser && user.prim != id.GID && !rec.MadeUser: + return out, fmt.Errorf("the account %s is here with group %d, was not made by the mesh, and the mesh "+ + "gives it group %d%s", id.Name, user.prim, id.GID, never) + case hasGroup && group.id != id.UID: + return out, fmt.Errorf("the group %s is here with gid %d, and a module's own group has its uid, %d%s", + id.Name, group.id, id.UID, never) case uidUsed && byUID.name != id.Name: return out, fmt.Errorf("uid %d is the account %s's here, and the mesh allocated it to %s%s", id.UID, byUID.name, id.Name, never) case gidUsed && byGID.name != id.Name: - return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s%s", id.GID, + return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s%s", id.UID, byGID.name, id.Name, never) } - rec := madeIdentity(known, id.Name) + added := make([]string, 0, len(id.Groups)) + for _, gid := range id.Groups { + added = append(added, names[gid]) + } + sort.Strings(added) rec.Module, rec.Name, rec.UID, rec.GID = id.Module, id.Name, id.UID, id.GID var made []string if !hasGroup { - if err := tool.CreateSystemGroup(ctx, run, id.Name, id.GID); err != nil { + if err := tool.CreateSystemGroup(ctx, run, id.Name, id.UID); err != nil { return out, err } rec.MadeGroup = true - made = append(made, fmt.Sprintf("the group (gid %d)", id.GID)) + made = append(made, fmt.Sprintf("its own group (gid %d)", id.UID)) } if !hasUser { - if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID); err != nil { + if err := tool.CreateSystemAccount(ctx, run, id.Name, id.UID, id.GID, added); err != nil { recordIdentity(known, rec) return out, err } rec.MadeUser = true - made = append(made, fmt.Sprintf("the account (uid %d), with no login, no home and no password", id.UID)) + made = append(made, fmt.Sprintf("the account (uid %d, group %d%s), with no login, no home and no password", + id.UID, id.GID, alsoIn(added))) + } else if rec.MadeUser { + // The mesh's own account: its primary and added groups follow what is declared. + in, err := system.GroupsOf(ctx, system.Runner(run), id.Name) + if err != nil { + return out, err + } + have := map[string]bool{} + for _, g := range in { + if g != id.Name && g != names[user.prim] { + have[g] = true + } + } + same := user.prim == id.GID && len(have) == len(added) + for _, g := range added { + same = same && have[g] + } + if !same { + if err := tool.ModifyAccount(ctx, run, id.Name, id.GID, added); err != nil { + return out, err + } + out.Action = "updated" + out.Detail = fmt.Sprintf("gave the account group %d%s", id.GID, alsoIn(added)) + } } if rec.At.IsZero() { rec.At = time.Now().UTC() @@ -154,12 +203,92 @@ func applyIdentity(ctx context.Context, tool accountsTool, id declaration.Identi recordIdentity(known, rec) if len(made) > 0 { out.Action, out.Detail = "created", "made "+strings.Join(made, " and ") - } else { + } else if out.Detail == "" { out.Detail = "here with its numbers" } return out, nil } +func alsoIn(groups []string) string { + if len(groups) == 0 { + return "" + } + return ", also in " + strings.Join(groups, ", ") +} + +// applyGroup makes one library's group with the number the controller allocated (ADR 0328 §3), or refuses it naming +// whose the name or the number is, or that the number is a root group's (§15). What it made is recorded on known. +func applyGroup(ctx context.Context, tool accountsTool, g declaration.Group, known *store.State, + run Runner) (Outcome, error) { + out := Outcome{ID: "group " + g.Name, Type: "group", Target: g.Name, Action: "unchanged"} + if why := rootGroup(g.GID); why != "" { + return out, fmt.Errorf("its gid is %s, and a library's group is never a root group (novox/hq ADR 0328 §15); "+ + "nothing was made", why) + } + byName, hasName, err := lookUp(ctx, run, "group", g.Name) + if err != nil { + return out, err + } + byGID, gidUsed, err := lookUp(ctx, run, "group", strconv.Itoa(g.GID)) + if err != nil { + return out, err + } + switch { + case hasName && byName.id != g.GID: + return out, fmt.Errorf("the group %s is here with gid %d, and the mesh allocated gid %d; nothing was made", + g.Name, byName.id, g.GID) + case gidUsed && byGID.name != g.Name: + return out, fmt.Errorf("gid %d is the group %s's here, and the mesh allocated it to %s; nothing was made", + g.GID, byGID.name, g.Name) + case hasName: + out.Detail = "here with its number" + return out, nil + } + if err := tool.CreateSystemGroup(ctx, run, g.Name, g.GID); err != nil { + return out, err + } + known.Groups = append(known.Groups, store.MadeGroup{Name: g.Name, GID: g.GID, At: time.Now().UTC()}) + out.Action, out.Detail = "created", fmt.Sprintf("made the library's group (gid %d)", g.GID) + return out, nil +} + +// removeGroups removes every library group the engine made that is no longer declared, while it still has the +// number the engine gave it. Never fatal: one that could not be removed (still a user's primary group) stays +// recorded, said, for the next apply. +func removeGroups(ctx context.Context, tool accountsTool, declared []declaration.Group, known *store.State, + run Runner) []Outcome { + wanted := map[string]bool{} + for _, g := range declared { + wanted[g.Name] = true + } + var out []Outcome + var keep []store.MadeGroup + for _, m := range known.Groups { + if wanted[m.Name] { + keep = append(keep, m) + continue + } + o := Outcome{ID: "group " + m.Name, Type: "group", Target: m.Name, Action: "removed", + Detail: "no longer declared: removed the group"} + g, has, err := lookUp(ctx, run, "group", m.Name) + switch { + case err != nil || (has && g.id != m.GID): + o.Action, o.Detail = "forgotten", "no longer declared: the group was left, it no longer has the gid the "+ + "mesh gave it, or the database did not answer" + case has: + if err := tool.RemoveGroup(ctx, run, m.Name); err != nil { + o.Action, o.Detail = "waiting", "no longer declared: the group could not be removed: "+err.Error() + keep = append(keep, m) + } + default: + o.Action, o.Detail = "forgotten", "no longer declared, and no longer here" + } + out = append(out, o) + } + known.Groups = keep + return out +} + // madeIdentity is what the engine recorded of name, or an empty record. func madeIdentity(known *store.State, name string) store.MadeIdentity { for _, m := range known.Identities { diff --git a/internal/apply/identities_test.go b/internal/apply/identities_test.go index 023e088..e324319 100644 --- a/internal/apply/identities_test.go +++ b/internal/apply/identities_test.go @@ -19,7 +19,19 @@ import ( // accountsMachine is a user and group database the runner answers from, and every command that changed it. type accountsMachine struct { users, groups map[string][2]int // name → id, primary gid (a group's second is unused) - ran []string + // in is each account's added groups, by name. + in map[string][]string + ran []string +} + +// groupNamed is the name of gid in m, or the number. +func (m *accountsMachine) groupNamed(gid int) string { + for n, ids := range m.groups { + if ids[0] == gid { + return n + } + } + return strconv.Itoa(gid) } func (m *accountsMachine) run(_ context.Context, name string, args ...string) (string, error) { @@ -40,10 +52,38 @@ func (m *accountsMachine) run(_ context.Context, name string, args ...string) (s } } return "", errors.New("getent exited 2") + case "id": + name := args[len(args)-1] + u, ok := m.users[name] + if !ok { + return "", errors.New("id exited 1") + } + return strings.Join(append([]string{m.groupNamed(u[1])}, m.in[name]...), " ") + "\n", nil case "groupadd": m.groups[args[len(args)-1]] = [2]int{mustAtoi(args[2]), 0} case "useradd": - m.users[args[len(args)-1]] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])} + name := args[len(args)-1] + m.users[name] = [2]int{mustAtoi(args[2]), mustAtoi(args[4])} + if args[5] == "--groups" { + m.setIn(name, strings.Split(args[6], ",")) + } + case "usermod": + name := args[len(args)-1] + if args[0] == "--append" { + m.setIn(name, append(m.in[name], args[2])) + } else { + u := m.users[name] + m.users[name] = [2]int{u[0], mustAtoi(args[1])} + m.setIn(name, strings.Split(args[3], ",")) + } + case "gpasswd": + var kept []string + for _, g := range m.in[args[1]] { + if g != args[2] { + kept = append(kept, g) + } + } + m.setIn(args[1], kept) case "userdel": delete(m.users, args[0]) case "groupdel": @@ -55,11 +95,39 @@ func (m *accountsMachine) run(_ context.Context, name string, args ...string) (s return "", nil } +func (m *accountsMachine) setIn(name string, groups []string) { + if m.in == nil { + m.in = map[string][]string{} + } + var out []string + for _, g := range groups { + if g != "" { + out = append(out, g) + } + } + m.in[name] = out +} + func mustAtoi(s string) int { n, _ := strconv.Atoi(s) return n } +// withGroups is a declaration with library groups, identities and resources. +func withGroups(t *testing.T, groups, identities, resources string) *declaration.Declaration { + t.Helper() + nothing := "" + if resources == "" { + nothing = `"owns_nothing":true,` + } + d, err := declaration.Parse([]byte(`{"declaration":1,"groups":[` + groups + `],"identities":[` + identities + + `],` + nothing + `"resources":[` + resources + `]}`)) + if err != nil { + t.Fatal(err) + } + return d +} + func withIdentities(t *testing.T, identities, resources string) *declaration.Declaration { t.Helper() d, err := declaration.Parse([]byte(`{"declaration":1,"identities":[` + identities + `],"resources":[` + @@ -163,7 +231,9 @@ func TestNoModuleIdentityIsRoot(t *testing.T) { defer func() { RootGroupIDs = was }() dir := t.TempDir() for _, d := range []*declaration.Declaration{ - withIdentities(t, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`, + withGroups(t, `{"name":"mesh-media","gid":10}`, `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":10}`, + `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`), + withGroups(t, `{"name":"mesh-media","gid":10}`, ``, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"}`), withIdentities(t, ``, `{"id":"radarr.config","type":"directory","path":"`+dir+`/config"},`+ `{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`",`+ diff --git a/internal/apply/keep.go b/internal/apply/keep.go index 0b7ee43..19420f9 100644 --- a/internal/apply/keep.go +++ b/internal/apply/keep.go @@ -6,11 +6,8 @@ import ( "encoding/hex" "errors" "fmt" - "github.com/novox/mesh-host/internal/accounts" "os" - "path/filepath" "regexp" - "sort" "strings" "sync" "time" @@ -20,306 +17,101 @@ import ( "github.com/novox/mesh-host/internal/system" ) -// A shared folder is kept usable through access entries (novox/hq ADR 0325, issue 567). -// -// Several modules use one folder of the operator's — a library one imports into, one renames in, one plays from — -// each as its own identity. The engine keeps, for every placed folder, one ACL entry per identity that uses it, -// on the folder and as its default for everything made under it, and changes nothing else of the path (§5). -// -// **When it may keep a folder.** Only one that is the mesh's to keep: taken on the operator's warrant (§8), or -// holding no data yet — empty when first seen, or made by the engine (§9). A folder that holds data and was never -// taken is stated waiting, with a wait of kind access, until the operator lets the mesh keep it (§7); one whose disk -// is not there waits for the disk (§9); root's data on its placed filesystem is never kept, and is unhealthy (§12). -// -// **What every apply does.** It judges the folder (assessFolder), makes a missing one where its data lives, and -// sets the users' entries on the folder itself. What lies under a taken folder is the walks' (ReconcileKept): a -// user added has its entries added to everything under it, a user dropped has exactly its own removed, outside -// the apply and bounded. The watch and the sweep keep what is made later usable (§6, §10). +// An access is an operator's path a module mounts and the mesh does not own (novox/hq ADR 0051): mounted when +// present, refused when absent, nothing changed on it. ADR 0325 kept a shared folder through ACL entries; ADR 0328 +// supersedes it with libraries (library.go), and an access is again exactly what it was before: an access's `users` +// and `taken` are accepted and not acted on. -// keptRecords is where the engine keeps what it knows of each kept or seen folder: the file beside the state in -// the engine, memory in a test. -type keptRecords interface { - All() (map[string]store.KeptFolder, error) - Update(path string, change func(f *store.KeptFolder, had bool) bool) error -} - -// KeptFolders is the engine's record of the folders it keeps. The engine sets the file beside its state. -var KeptFolders keptRecords = NewKeptMemory() - -// keptMemory is a keptRecords held in memory. -type keptMemory struct { - mu sync.Mutex - m map[string]store.KeptFolder -} - -// NewKeptMemory is an empty record held in memory: a test's, or a one-shot apply's. -func NewKeptMemory() keptRecords { return &keptMemory{m: map[string]store.KeptFolder{}} } - -func (k *keptMemory) All() (map[string]store.KeptFolder, error) { - k.mu.Lock() - defer k.mu.Unlock() - out := map[string]store.KeptFolder{} - for p, f := range k.m { - out[p] = f +// applyAccess is one access, applied as ADR 0051 has it; what ADR 0312's grant put an account in is taken back +// first, so no account is in a group for data but a library's (ADR 0328 §11). +func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied, + in inputs) (Outcome, error) { + gave, left, said := takeBackGranted(ctx, sys, previous, run, in.groups, in.handed) + out, err := legacyAccess(r) + if len(left) > 0 { + out.user, out.groups = previous.User, left } - return out, nil -} - -func (k *keptMemory) Update(path string, change func(f *store.KeptFolder, had bool) bool) error { - k.mu.Lock() - defer k.mu.Unlock() - f, had := k.m[path] - f.Path = path - if change(&f, had) { - k.m[path] = f - } else { - delete(k.m, path) + if said != "" { + out.Detail = joinDetail(out.Detail, said) + if gave && out.Action == "unchanged" { + // Updated, so what names the access under restart-on — the tool runner — holds the groups it has now. + out.Action = "updated" + } } - return nil + return out, err } -// keptRecord is the record of path, and whether there is one. A record that cannot be read is none: the folder -// is judged afresh, which waits rather than keeps. -func keptRecord(path string) (store.KeptFolder, bool) { - all, err := KeptFolders.All() +// takeBackGranted takes an account out of every group ADR 0312's grant put it in for an access, as removeUser takes +// back a user's (ADR 0252 §3): unless another declared resource still asks for it, which is then handed the group. +// Whether any was taken back, the groups still to take back — one whose leaving failed stays recorded, so the next +// apply tries again — and what was said; nothing for an access that granted nothing. +func takeBackGranted(ctx context.Context, sys system.System, previous store.Applied, run Runner, wanted Wanted, + handed Handed) (bool, []string, string) { + if previous.User == "" || len(previous.Groups) == 0 { + return false, nil, "" + } + account := previous.User + in, err := system.GroupsOf(ctx, system.Runner(run), account) if err != nil { - return store.KeptFolder{}, false + return false, previous.Groups, fmt.Sprintf("the groups ADR 0312's grant put %s in (%s) were not taken "+ + "back, and are tried again: %v", account, strings.Join(previous.Groups, ", "), err) } - f, had := all[path] - return f, had -} - -// folderWant is what every access naming one folder asks of it: the accesses, each uid at the widest use any of -// them gives it (ADR 0325 §4: the tool runner's account stands for every module whose tools use it), and whether -// the controller says it was taken. -type folderWant struct { - path string - ids []string - users map[uint32]store.KeptUser - taken bool -} - -// foldersOf is every declared access's folder, by its clean path. A folder the guard refuses is not read (issue -// 339). -func foldersOf(resources []declaration.Resource, refused map[string]error) map[string]*folderWant { - out := map[string]*folderWant{} - for _, r := range resources { - a, ok := r.(*declaration.Access) - if !ok { + already := setOf(in) + gave := false + var left, said []string + for _, g := range previous.Groups { + switch { + case !already[g]: + continue + case len(wanted.othersAsk(account, g, previous.ID)) > 0: + said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, + strings.Join(wanted.othersAsk(account, g, previous.ID), ", "))) + handed.hand(account, g) continue } - path := filepath.Clean(a.Path) - if refused[path] != nil { - continue - } - f := out[path] - if f == nil { - f = &folderWant{path: path, users: map[uint32]store.KeptUser{}} - out[path] = f - } - f.ids = append(f.ids, a.ID) - f.taken = f.taken || a.Taken - for _, u := range a.Users { - had, seen := f.users[uint32(u.UID)] - if seen && (had.Use == declaration.AccessReadWrite || u.Use != declaration.AccessReadWrite) { - continue - } - f.users[uint32(u.UID)] = store.KeptUser{Identity: u.Identity, UID: uint32(u.UID), Use: u.Use, From: u.From} + gone, why := leaveGroup(ctx, sys, account, g, run) + if !gone { + left = append(left, g) + why += "; tried again on the next apply" } + gave = gave || gone + said = append(said, why) } - return out + return gave, left, "the group grant ADR 0312 made is taken back: " + account + " " + strings.Join(said, "; ") } -// list is the users, by uid. -func (f *folderWant) list() []store.KeptUser { - if f == nil { - return nil - } - out := make([]store.KeptUser, 0, len(f.users)) - for _, u := range f.users { - out = append(out, u) - } - sort.Slice(out, func(i, j int) bool { return out[i].UID < out[j].UID }) - return out -} - -// usersOf is a list of users as each uid and whether it writes. -func usersOf(us []store.KeptUser) map[uint32]bool { - out := map[uint32]bool{} - for _, u := range us { - out[u.UID] = out[u.UID] || u.Use == declaration.AccessReadWrite - } - return out -} - -// sameUsers is whether two lists give the same uids the same use. -func sameUsers(a, b []store.KeptUser) bool { - x, y := usersOf(a), usersOf(b) - if len(x) != len(y) { - return false - } - for uid, rw := range x { - if w, ok := y[uid]; !ok || w != rw { - return false - } - } - return true -} - -// accessName is the module and its own id for an access, from the resource id `.`. -func accessName(id string) (module, name string) { - if m, ok := moduleOf(id); ok { - return m, id[len(m)+1:] - } - return "", id -} - -// KeepWords and DiskWords are what an access wait says the operator gives (ADR 0325 §7): never a path. -func KeepWords(name string) string { return "let the mesh keep the shared folder " + name } -func DiskWords(name string) string { return "mount the disk that holds " + name } - -// The judgements of a folder. -const ( - // folderKeep is a folder the mesh keeps: taken, or holding no data yet. - folderKeep = "keep" - // folderMake is a folder absent under a parent on its placed filesystem, not root's: made, then kept. - folderMake = "make" - // folderWaitKeep is a folder that holds data and was never taken: it waits for the operator's word. - folderWaitKeep = "wait-keep" - // folderWaitDisk is a folder whose disk is not there. - folderWaitDisk = "wait-disk" - // folderRoot is root's data on its placed filesystem: never kept. - folderRoot = "root" - // folderUnknown is a folder whose filesystem could not be named: left as it is until it is. - folderUnknown = "unknown" -) - -// folderVerdict is the judgement of one folder, with what it read. -type folderVerdict struct { - kind string - // how is why a folder kept is the mesh's: store.KeptByWarrant, store.KeptEmpty or store.KeptMade. - how string - // mount is the mount point that holds what is there. - mount string - // why is a root verdict's or a broken folder's reason; err a folder that is not one (a file, a link). - why string - err error - // moved is that the folder's recorded mount is mounted and another holds it: it is judged afresh. - moved bool - // unnamed is a folder whose filesystem cannot be named: never kept, nor taken. - unnamed bool -} - -// assessFolder judges a folder as an apply, a look and every walk do (ADR 0325 §7, §9, §12): its disk first, then -// whose it is, then whether the mesh may keep it. Its words carry no path: they reach the operator's channel. -func assessFolder(ctx context.Context, path string, rec store.KeptFolder, taken bool) folderVerdict { - others := recordedMounts(path) - info, err := lstatWithin(path) +// legacyAccess is an access from a controller older than ADR 0325, applied exactly as before it (ADR 0051 §7). +// +// **Absent is refused, not created.** A bind mount whose source does not exist is made for you by the container +// runtime, as root, with whatever mode it picks — which is exactly the silent wrong-ownership 04-ISSUES/026 +// records. So the host checks first and says plainly that the operator must provide the path, rather than +// conjuring a directory it does not own and cannot give the right owner. Nothing is written, so this never reports +// a change: the machine did not move, the host merely confirmed a fact about it. +func legacyAccess(r *declaration.Access) (Outcome, error) { + out := begin(r) + info, err := os.Stat(r.Path) if errors.Is(err, os.ErrNotExist) { - m, err := NearestAncestor(path) - if err != nil { - return folderVerdict{err: err} - } - if !m.Info.IsDir() { - return folderVerdict{err: errors.New("the folder is not there, and its nearest existing parent is not " + - "a folder (a file or a link); nothing is made under it")} - } - pl := placedOn(ctx, path, m.Ancestor, rec.Mount, others) - if pl.Away { - return folderVerdict{kind: folderWaitDisk, mount: pl.Mount} - } - uid, gid, ok := statOwner(m.Info) - if !ok { - return folderVerdict{err: errors.New("the owner of the folder's nearest existing parent cannot be read " + - "on this machine")} - } - if why := rootOwned(uint32(uid), uint32(gid)); why != "" { - return folderVerdict{kind: folderRoot, mount: pl.Mount, why: "it is not there, and its nearest " + - "existing parent is " + why} - } - return folderVerdict{kind: folderMake, mount: pl.Mount, how: store.KeptMade, moved: pl.Moved} + return out, fmt.Errorf( + "%s is not there, and the mesh does not own it — the operator must provide it. It is "+ + "shared, pre-existing data (novox/hq ADR 0051): the host mounts it and creates "+ + "nothing, so a missing one is said here rather than made as root by the container "+ + "runtime", r.Path) } if err != nil { - return folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))} + return out, err } if !info.IsDir() { - return folderVerdict{err: errors.New("the folder is not a directory, and an access is a shared directory")} + return out, fmt.Errorf( + "%s is not a directory, and an access is a shared directory the operator provides", + r.Path) } - pl := placedOn(ctx, path, path, rec.Mount, others) - if pl.Away { - return folderVerdict{kind: folderWaitDisk, mount: pl.Mount} + mode := declaration.AccessRead + if r.Mode != "" { + mode = r.Mode } - // Another filesystem at the same mount point (another pool, another disk) is a move too. A record from an earlier - // build names its filesystem another way, and is not compared: the next keep names it this way. - here := PlacedID(pl.Mount) - // **A filesystem that cannot be named now is unknown, never a move** (a name read that did not answer, a - // filesystem with no UUID, id or source): the folder holds what it is — nothing walked, nothing newly kept, - // nothing reset — and is judged again when it is named. One never kept is so never kept: it fails closed. - if here == "" { - return folderVerdict{kind: folderUnknown, mount: pl.Mount, unnamed: true, - why: "its filesystem could not be named this time (no UUID, filesystem id or source answered)"} - } - moved := pl.Moved || (rec.Fs != "" && rec.Mount == pl.Mount && rec.Placed != here) - facts, err := readFolder(path) - if err != nil { - return folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))} - } - if why := rootOwned(facts.UID, facts.GID); why != "" { - return folderVerdict{kind: folderRoot, mount: pl.Mount, why: "it is " + why} - } - // The controller's word that the folder was taken holds for the filesystem the warrant was carried out on: a - // folder moved to another is not taken, and asks again (§8). - takenHere := taken && (rec.TakenOn == here || (rec.TakenOn == "" && !moved)) - switch { - case rec.Kept && !moved: - return folderVerdict{kind: folderKeep, mount: pl.Mount, how: rec.How} - case takenHere: - return folderVerdict{kind: folderKeep, mount: pl.Mount, how: store.KeptByWarrant, moved: moved} - case facts.Empty: - return folderVerdict{kind: folderKeep, mount: pl.Mount, how: store.KeptEmpty, moved: moved} - } - // What is there now holds data the mesh never took — a disk mounted over a folder kept while it was empty - // below it included (a move): it waits for the operator's word. - return folderVerdict{kind: folderWaitKeep, mount: pl.Mount, moved: moved} -} - -// movedRecord is f judged afresh on the filesystem now at mount: the warrant's filesystem is kept so the -// controller's word is not read for the new one, and nothing else of what was kept there holds. What the mesh -// added on the old filesystem is left to be taken out once it holds the folder again (Left) only where it was -// walked onto data under a warrant: a folder kept because it was empty, or made, carries entries on that empty -// folder alone, hidden under the mount point and granting nothing on data, and they are forgotten (said once, by -// the apply that saw the move). -func movedRecord(f store.KeptFolder, mount string) store.KeptFolder { - left := f.Left - if f.How == store.KeptByWarrant && len(f.Users) > 0 && f.Mount != "" { - left = addLeft(left, f.Mount, f.Placed, f.Fs, f.Users) - } - placed, fs := placeOf(mount) - return store.KeptFolder{Path: f.Path, Mount: mount, Placed: placed, Fs: fs, Want: f.Want, IDs: f.IDs, - Act: f.Act, TakenOn: f.TakenOn, Left: left} -} - -// placeOf is the name of the filesystem at mount, and what it was made from. -func placeOf(mount string) (placed, fs string) { - fs = filesystemKey(mount) - return placedFrom(mount, fs), fs -} - -// addLeft adds users' entries left on mount, once per mount and uid. -func addLeft(left []store.KeptLeft, mount, placed, fs string, users []store.KeptUser) []store.KeptLeft { - for i := range left { - if left[i].Mount == mount && left[i].Placed == placed { - have := usersOf(left[i].Users) - for _, u := range users { - if _, had := have[u.UID]; !had { - left[i].Users = append(left[i].Users, u) - } - } - return left - } - } - return append(left, store.KeptLeft{Mount: mount, Placed: placed, Fs: fs, Since: time.Now().UTC(), - Users: append([]store.KeptUser(nil), users...)}) + out.Action = "unchanged" + out.Detail = "operator-owned; present, " + mode + ", nothing managed" + return out, nil } // PlacedID names the filesystem at mount for a wait, with no path in it: the controller asks again when the @@ -450,565 +242,3 @@ func pathless(err error) string { } return absPath.ReplaceAllString(err.Error(), "${1}a path") } - -// recordedMounts is every mount point recorded for another kept folder: where a disk was once seen. -func recordedMounts(except string) []string { - all, err := KeptFolders.All() - if err != nil { - return nil - } - var out []string - for p, f := range all { - if p != except && f.Mount != "" && f.Mount != "/" { - out = append(out, f.Mount) - } - } - return out -} - -// RootWords is the reason root's data is stated unhealthy with (ADR 0325 §12): never a path. -func RootWords(why string) string { - return why + ", and root's data is never kept: the operator places the data elsewhere or gives it an owner " + - "(novox/hq ADR 0325 §12)" -} - -// applyAccess is one access, applied (ADR 0325): its folder judged, made when it is missing where its data lives, -// and its users' entries kept on it when the mesh may keep it; waiting otherwise, and failing for root's data. -// Nothing is changed on the folder but the entries the mesh adds and the mask that shows them. -func applyAccess(ctx context.Context, sys system.System, r *declaration.Access, run Runner, previous store.Applied, - in inputs) (Outcome, error) { - // A tool runner's account that is not here, or is root's, refuses its access (§4, §11). - if err := in.runners[r.ID]; err != nil { - return begin(r), err - } - // What ADR 0312's grant put an account in is taken back first: no account is in a group for data (§5). - gave, left, said := takeBackGranted(ctx, sys, previous, run, in.groups, in.handed) - out, err := applyAccessFolder(ctx, r, in) - if len(left) > 0 { - out.user, out.groups = previous.User, left - } - if said != "" { - out.Detail = joinDetail(out.Detail, said) - if gave && out.Action == "unchanged" { - // Updated, so what names the access under restart-on — the tool runner — holds the groups it has now. - out.Action = "updated" - } - } - return out, err -} - -// applyAccessFolder is applyAccess's folder. -func applyAccessFolder(ctx context.Context, r *declaration.Access, in inputs) (Outcome, error) { - out := begin(r) - path := filepath.Clean(r.Path) - want := in.folders[path] - if !keeps(want, in.identities) { - return legacyAccess(r) - } - users := want.list() - _, name := accessName(r.ID) - rec, _ := keptRecord(path) - v := assessFolder(ctx, path, rec, want != nil && want.taken) - if v.err != nil { - return out, v.err - } - if v.moved { - // What is at the path now is not what was kept (a disk mounted over a folder kept while it was empty - // below it): judged afresh, the record of what was kept there no longer holds. - _ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool { - *f = movedRecord(*f, v.mount) - return had - }) - } - note := "" - if v.moved { - note = "; what is at it now is on another filesystem than what the mesh kept there, so it was judged afresh" - if rec.How != store.KeptByWarrant { - note += "; the entries on the empty folder kept below are forgotten: hidden under the mount, they grant " + - "nothing on data" - } - } - if len(r.Grant) > 0 { - note += "; its grant is no longer acted on: the mesh puts no account in a group for data (novox/hq ADR 0325 §5)" - } - switch v.kind { - case folderUnknown: - out.Action = "waiting" - out.Detail = v.why + ": the folder is left exactly as it is, and judged again on the next apply" + note - return out, nil - case folderWaitDisk: - out.Action = "waiting" - out.Detail = "the disk that holds it is not there: nothing is made, and nothing mounts it until it is " + - "(novox/hq ADR 0325 §9); waits for the operator to " + DiskWords(name) + note - return out, nil - case folderRoot: - if len(users) == 0 { - out.Action, out.Detail = "unchanged", "present or not, nothing is kept: no identity uses it"+note - return out, nil - } - return out, errors.New(RootWords(v.why) + "; nothing was made or changed") - case folderMake: - made, err := CreatePath(path) - if err != nil { - return out, err - } - out.Action = "created" - out.Detail = fmt.Sprintf("made %d level(s) as its nearest existing parent is: owner, group and mode "+ - "(novox/hq ADR 0325 §9)", made) - if len(users) == 0 { - out.Detail += note - return out, nil - } - case folderWaitKeep: - if len(users) == 0 { - out.Action, out.Detail = "unchanged", "present; nothing is kept: no identity uses it"+note - return out, nil - } - _ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool { - f.Mount, f.Want, f.IDs = v.mount, users, want.ids - f.Placed, f.Fs = placeOf(v.mount) - return true - }) - out.Action = "waiting" - out.Detail = "it holds data the mesh has not taken: nothing is changed on it until the operator's warrant " + - "(novox/hq ADR 0325 §7, §8); waits for the operator to " + KeepWords(name) + note - return out, nil - } - if len(users) == 0 { - // Nobody uses it any more: what the mesh added goes on the next walk. - dropping := false - _ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool { - f.Want = nil - dropping = len(f.Users) > 0 - return had - }) - if out.Action == "" { - out.Action, out.Detail = "unchanged", "present; nothing is kept: no identity uses it" - } - if dropping { - out.Detail += "; the entries the mesh added are taken out by the next walk" - } - out.Detail += note - return out, nil - } - changed, err := keepTop(path, usersOf(users)) - if errors.Is(err, ErrNoACL) { - _ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool { - f.Mount, f.Want = v.mount, users - f.Placed, f.Fs = placeOf(v.mount) - return true - }) - out.Action = "waiting" - out.Detail = "its filesystem allows no access entries here: the take act lets it, on the operator's word " + - "(novox/hq ADR 0325 §8); waits for the operator to " + KeepWords(name) + note - return out, nil - } - if err != nil { - return out, err - } - var pending string - _ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool { - first := !f.Kept - f.Kept, f.Mount, f.Want, f.IDs = true, v.mount, users, want.ids - f.Placed, f.Fs = placeOf(v.mount) - if v.how == store.KeptByWarrant && f.TakenOn == "" { - // The controller says it was taken, and this engine has no record of where: here, now. - f.TakenOn = PlacedID(v.mount) - } - if first { - f.How, f.At = v.how, time.Now().UTC() - // A folder that held nothing has nothing under it to walk; one the controller says was taken - // without this engine's record is walked whole. - if v.how == store.KeptEmpty || v.how == store.KeptMade { - f.Users = users - } - } - if !sameUsers(f.Users, f.Want) { - pending = "; the entries under it follow on the next walk" - } - return true - }) - if out.Action == "" { - out.Action = "unchanged" - if changed { - out.Action = "updated" - } - } - out.Detail = joinDetail(out.Detail, "kept for "+usersWords(users)+": its own entries and default, nothing "+ - "else of it changed (novox/hq ADR 0325 §5)"+pending+note) - return out, nil -} - -// takeBackGranted takes an account out of every group ADR 0312's grant put it in for an access, as removeUser takes -// back a user's (ADR 0252 §3): unless another declared resource still asks for it, which is then handed the group. -// Whether any was taken back, the groups still to take back — one whose leaving failed stays recorded, so the next -// apply tries again — and what was said; nothing for an access that granted nothing. -func takeBackGranted(ctx context.Context, sys system.System, previous store.Applied, run Runner, wanted Wanted, - handed Handed) (bool, []string, string) { - if previous.User == "" || len(previous.Groups) == 0 { - return false, nil, "" - } - account := previous.User - in, err := system.GroupsOf(ctx, system.Runner(run), account) - if err != nil { - return false, previous.Groups, fmt.Sprintf("the groups ADR 0312's grant put %s in (%s) were not taken "+ - "back, and are tried again: %v", account, strings.Join(previous.Groups, ", "), err) - } - already := setOf(in) - gave := false - var left, said []string - for _, g := range previous.Groups { - switch { - case !already[g]: - continue - case len(wanted.othersAsk(account, g, previous.ID)) > 0: - said = append(said, fmt.Sprintf("left in %s, which %s still asks for", g, - strings.Join(wanted.othersAsk(account, g, previous.ID), ", "))) - handed.hand(account, g) - continue - } - gone, why := leaveGroup(ctx, sys, account, g, run) - if !gone { - left = append(left, g) - why += "; tried again on the next apply" - } - gave = gave || gone - said = append(said, why) - } - return gave, left, "the group grant ADR 0312 made is taken back: " + account + " " + strings.Join(said, "; ") -} - -// usersWords names users for a person: "mesh-radarr (read-write), the tool runner's nox (read)". -func usersWords(users []store.KeptUser) string { - words := make([]string, 0, len(users)) - for _, u := range users { - who := u.Identity - if u.From == declaration.FromToolRunner { - who = "the tool runner's " + who - } - words = append(words, who+" ("+u.Use+")") - } - return strings.Join(words, ", ") -} - -// forgetUndeclared marks every recorded folder no declared access names any more: nobody wants its entries, and -// the next walk takes out exactly what the mesh added, then forgets it. -func forgetUndeclared(folders map[string]*folderWant) { - all, err := KeptFolders.All() - if err != nil { - return - } - for path := range all { - if folders[path] != nil { - continue - } - _ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool { - f.Want = nil - // A folder never kept, or kept with nobody's entries left anywhere, is forgotten now. - return (f.Kept && len(f.Users) > 0) || len(f.Left) > 0 - }) - } -} - -// MissingPath is an accessed folder that is not there, and its nearest existing ancestor. -type MissingPath struct { - Ancestor string - // Levels are the names to make below Ancestor, outermost first. - Levels []string - // Info is the ancestor as lstat reads it: a link is not a directory. - Info os.FileInfo -} - -// NearestAncestor reads, for a path that is not there, its nearest existing ancestor by lstat. An error for a -// path that is there, one not stated exactly, or an ancestor that cannot be read. -func NearestAncestor(path string) (MissingPath, error) { - if err := keptPath(path); err != nil { - return MissingPath{}, err - } - if _, err := lstatWithin(path); err == nil || !errors.Is(err, os.ErrNotExist) { - return MissingPath{}, errors.New("the folder is there, or cannot be read") - } - var levels []string - for p := path; p != "/"; p = filepath.Dir(p) { - levels = append([]string{filepath.Base(p)}, levels...) - parent := filepath.Dir(p) - info, err := lstatWithin(parent) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - return MissingPath{}, errors.New("a parent of the folder cannot be read: " + pathless(err)) - } - return MissingPath{Ancestor: parent, Levels: levels, Info: info}, nil - } - return MissingPath{}, errors.New("no part of the folder's path is there") -} - -// accessPaths is every declared access's folder with its id: what no container mounts while it is not there. -// -// Only accesses the mesh keeps (Keeps): an older controller's access holds no container, as before ADR 0325. -func accessPaths(resources []declaration.Resource, refused map[string]error, identities bool) map[string]string { - folders := foldersOf(resources, refused) - out := map[string]string{} - for _, r := range resources { - a, ok := r.(*declaration.Access) - if !ok { - continue - } - if path := filepath.Clean(a.Path); refused[path] == nil && keeps(folders[path], identities) { - out[path] = a.ID - } - } - return out -} - -// keeps is whether ADR 0325's keeping applies to a folder: some access naming it carries users, or the -// declaration carries identities — a controller that speaks link.IdentityContract. An older controller's access -// (no users, no identities) is applied exactly as before: mounted when present, refused when absent, nothing kept, -// nothing made, nothing judged (ADR 0051 §7). -func keeps(f *folderWant, identities bool) bool { - return identities || (f != nil && len(f.users) > 0) -} - -// Keeps is keeps for a whole declaration's access at path, for the engine's looks. -func Keeps(d *declaration.Declaration, path string) bool { - if d == nil { - return false - } - return keeps(foldersOf(d.Resources, nil)[filepath.Clean(path)], len(d.Identities) > 0) -} - -// mountsMissing is the access a container waits for: one whose folder it mounts, or mounts below, is not there -// now — its disk away, or not made yet. The runtime would make it as root (04-ISSUES/026). -func mountsMissing(ctx context.Context, c *declaration.Container, accesses map[string]string) string { - for _, v := range c.Volumes { - src := mountSource(v) - if !strings.HasPrefix(src, "/") { - continue - } - for path, id := range accesses { - if !atOrBelow(filepath.Clean(src), path) { - continue - } - if _, err := statWithin(path); errors.Is(err, os.ErrNotExist) || errors.Is(err, errNoAnswer) { - return id - } - // There, and its disk not: what is at the path is the empty mount point on the filesystem below, and - // a container's writes would land there (§9). - rec, _ := keptRecord(path) - if placedOn(ctx, path, path, rec.Mount, recordedMounts(path)).Away { - return id - } - } - } - return "" -} - -// WaitingOnAccess is every container of the declaration that waits for an accessed folder not there, read now: -// not created by the apply, so not judged alive or dead — the access says why. -func WaitingOnAccess(d *declaration.Declaration) map[string]bool { - out := map[string]bool{} - if d == nil { - return out - } - accesses := accessPaths(d.Resources, refusedPlaces(d.Resources), len(d.Identities) > 0) - if len(accesses) == 0 { - return out - } - for _, r := range d.Resources { - if c, ok := r.(*declaration.Container); ok && mountsMissing(context.Background(), c, accesses) != "" { - out[c.ID] = true - } - } - return out -} - -// The judgements of an access as a look says them. -const ( - VerdictKeep = "keep" - VerdictMake = "make" - VerdictWaitKeep = "wait-keep" - VerdictWaitDisk = "wait-disk" - VerdictRoot = "root" - VerdictBroken = "broken" - // VerdictUnknown is a folder whose filesystem could not be named this look: neither kept nor faulted. - VerdictUnknown = "unknown" - // VerdictNobody is a folder no identity uses: nothing is kept, and only a missing disk is said. - VerdictNobody = "nobody" -) - -// What a keep wait says of its folder's filesystem and ACLs: the same words as link's ACLNeedsEnabling and -// ACLUnsupported, which the engine says them as. -const ( - ACLNeedsEnabling = "needs-enabling" - ACLUnsupported = "unsupported" -) - -// AccessJudgement is a look's judgement of an access's folder, and why, in words; Placed names the filesystem at -// it for a wait, with no path (PlacedID); ACL is, on a keep wait, ACLNeedsEnabling or ACLUnsupported -// when its filesystem refuses access entries now. -type AccessJudgement struct { - Kind, Why, Placed, ACL string -} - -// AccessVerdict judges an access's folder for a look, read only (ADR 0325 §7, §9, §12): taken is whether the -// controller says it was taken, used whether any identity uses it. A folder the mesh could keep that no apply has -// kept — its filesystem allowing no ACLs — waits as one not taken does. -func AccessVerdict(ctx context.Context, path string, taken, used bool) AccessJudgement { - rec, _ := keptRecord(path) - v := assessFolder(ctx, path, rec, taken) - switch { - case v.err != nil: - return AccessJudgement{Kind: VerdictBroken, Why: pathless(v.err)} - case v.kind == folderWaitDisk: - return AccessJudgement{Kind: VerdictWaitDisk} - case v.kind == folderUnknown: - return AccessJudgement{Kind: VerdictUnknown, Why: v.why} - case !used: - return AccessJudgement{Kind: VerdictNobody} - case v.kind == folderRoot: - return AccessJudgement{Kind: VerdictRoot, Why: v.why} - case v.kind == folderMake: - return AccessJudgement{Kind: VerdictMake} - case v.kind == folderWaitKeep, v.kind == folderKeep && !rec.Kept && !taken: - return AccessJudgement{Kind: VerdictWaitKeep, Placed: PlacedID(v.mount), ACL: aclProbe(path, v.mount)} - } - return AccessJudgement{Kind: VerdictKeep} -} - -// Leftover is the mesh's entries that could not be taken out yet: under the accesses that last named the folder, -// why, in words with no path. -type Leftover struct { - Module, Access, Name, Path, Why string -} - -// Leftovers is every folder the mesh still has entries on that nobody wants (ADR 0325 §8): a folder no access names -// any more whose disk is away, so its drop walk waits; and entries left on a filesystem no longer at a folder. Each -// is said until its drop walk has run. -func Leftovers() []Leftover { - all, err := KeptFolders.All() - if err != nil { - return nil - } - var out []Leftover - for path, f := range all { - module, name, access := recordName(path, f) - dropping := f.Kept && len(f.Want) == 0 && len(f.Users) > 0 && f.Act != nil && f.Act.State == "waiting" - switch { - case dropping: - // Why the walk could not run, as it recorded it: its disk not there, or its filesystem not named. - why := strings.TrimSuffix(strings.TrimPrefix(f.Act.Why, "not walked: "), "; walked once it is") - if why == "" { - why = "the walk could not run" - } - out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " + - name + " could not be removed: " + why + "; they are removed once it can (novox/hq ADR 0325 §8)"}) - case len(f.Left) > 0: - out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " + - name + " on a filesystem no longer at its folder could not be removed: they are removed once that " + - "filesystem is back (novox/hq ADR 0325 §8)"}) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].Access < out[j].Access }) - return out -} - -// recordName is the module, the name and the access a kept folder's record is said under: the accesses that last -// named it, or for a record written before it kept them, a name with no path — the module of a container identity -// among its users, and a folder named by a digest of its path. -func recordName(path string, f store.KeptFolder) (module, name, access string) { - if len(f.IDs) > 0 { - module, name = accessName(f.IDs[0]) - return module, name, f.IDs[0] - } - sum := sha256.Sum256([]byte(path)) - name = "folder-" + hex.EncodeToString(sum[:4]) - for _, u := range append(append([]store.KeptUser(nil), f.Users...), f.Want...) { - if m, ok := strings.CutPrefix(u.Identity, declaration.IdentityPrefix); ok && u.From == declaration.FromContainer { - return m, name, m + "." + name - } - } - return "", name, name -} - -// legacyAccess is an access from a controller older than ADR 0325, applied exactly as before it (ADR 0051 §7). -// -// **Absent is refused, not created.** A bind mount whose source does not exist is made for you by the container -// runtime, as root, with whatever mode it picks — which is exactly the silent wrong-ownership 04-ISSUES/026 -// records. So the host checks first and says plainly that the operator must provide the path, rather than -// conjuring a directory it does not own and cannot give the right owner. Nothing is written, so this never reports -// a change: the machine did not move, the host merely confirmed a fact about it. -func legacyAccess(r *declaration.Access) (Outcome, error) { - out := begin(r) - info, err := os.Stat(r.Path) - if errors.Is(err, os.ErrNotExist) { - return out, fmt.Errorf( - "%s is not there, and the mesh does not own it — the operator must provide it. It is "+ - "shared, pre-existing data (novox/hq ADR 0051): the host mounts it and creates "+ - "nothing, so a missing one is said here rather than made as root by the container "+ - "runtime", r.Path) - } - if err != nil { - return out, err - } - if !info.IsDir() { - return out, fmt.Errorf( - "%s is not a directory, and an access is a shared directory the operator provides", - r.Path) - } - mode := declaration.AccessRead - if r.Mode != "" { - mode = r.Mode - } - out.Action = "unchanged" - out.Detail = "operator-owned; present, " + mode + ", nothing managed" - return out, nil -} - -// AccountIDs reads an account's uid and primary gid by name from the machine's user database (getent, through the -// account judge's reader). A variable so a test can stand in for accounts it cannot make. -var AccountIDs = func(ctx context.Context, name string) (uid, gid int, found bool, err error) { - return accounts.Exec{Run: accounts.Runner(ExecRunner)}.IDs(ctx, name) -} - -// ResolveRunners gives every tool runner's account that came with no uid its number, read by name on this machine -// (ADR 0325 §4): the controller cannot know the operator's account's. One that is not here, or is root's — uid 0 or -// a root group (§11) — is taken out of the access's users, and its access is refused, naming why, by id. Changes -// the declaration in place; a user that already has its number is left as it is. -func ResolveRunners(ctx context.Context, d *declaration.Declaration) map[string]error { - refused := map[string]error{} - if d == nil { - return refused - } - for _, r := range d.Resources { - a, ok := r.(*declaration.Access) - if !ok { - continue - } - kept := a.Users[:0] - for _, u := range a.Users { - if u.From != declaration.FromToolRunner || u.UID != 0 { - kept = append(kept, u) - continue - } - uid, gid, found, err := AccountIDs(ctx, u.Identity) - switch { - case err != nil: - refused[a.ID] = fmt.Errorf("the tool runner's account %s could not be read: %w", u.Identity, err) - continue - case !found: - refused[a.ID] = fmt.Errorf("the tool runner's account %s is not on this machine, so no entry can "+ - "be kept for it (novox/hq ADR 0325 §4)", u.Identity) - continue - } - if err := rootIdentity(uid, gid); err != nil { - refused[a.ID] = fmt.Errorf("the tool runner's account %s is never given an entry: %w", u.Identity, err) - continue - } - u.UID = uid - kept = append(kept, u) - } - a.Users = kept - } - return refused -} diff --git a/internal/apply/keeper_linux.go b/internal/apply/keeper_linux.go deleted file mode 100644 index c3fd1d2..0000000 --- a/internal/apply/keeper_linux.go +++ /dev/null @@ -1,473 +0,0 @@ -//go:build linux - -package apply - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "sync" - "time" - - "golang.org/x/sys/unix" - - "github.com/novox/mesh-host/internal/declaration" - "github.com/novox/mesh-host/internal/store" -) - -// The walks under a kept folder, beside the apply (novox/hq ADR 0325 §6, §8, §10). -// -// The take act adds the users' entries to everything under a folder on the operator's warrant; ReconcileKept adds a -// joining user's and removes exactly a dropped user's under a folder already taken; the sweep looks at a bounded -// part of each kept folder on every look, going on where the last stopped, and puts back what an app narrowed. -// One walk at a time per folder: a sweep finds a folder being walked and waits for the next look. - -// folderLocks is one lock per kept folder, held by every walk of it. -var folderLocks sync.Map - -func folderLock(path string) *sync.Mutex { - l, _ := folderLocks.LoadOrStore(path, &sync.Mutex{}) - return l.(*sync.Mutex) -} - -// actRecord is a walk's counts as the record keeps them. -func actRecord(act, warrant, by string, started time.Time, w *aclWalk, err error) *store.KeptAct { - c := w.counts - rec := &store.KeptAct{Act: act, Warrant: warrant, By: by, State: "done", Started: started, Ended: time.Now().UTC(), - Folders: c.Folders, Files: c.Files, Kept: c.Kept, Links: c.Links, Other: c.Other, Mounts: c.Mounts} - switch { - case errors.Is(err, ErrWalkBound): - rec.State, rec.Why = "stopped", "it met its bound of entries or time; it goes on at the next apply" - case err != nil: - rec.State, rec.Why = "failed", err.Error() - case w.Failed != "": - rec.Why = fmt.Sprintf("%d entries could not be given their entries: %s", c.Unrepaired, w.Failed) - } - return rec -} - -// ReconcileKept walks every kept folder whose entries under it are not what the last apply declared: a user that -// joined has its entries added to everything under it, a dropped user — or one whose use changed — has exactly its -// own taken out (ADR 0325 §8). Bounded by ActLimits; a walk stopped is gone on with at the next call. A folder -// nobody uses any more is forgotten once its entries are out. What it did, in words, for the engine to say. -func ReconcileKept(ctx context.Context) []string { - all, err := KeptFolders.All() - if err != nil { - return []string{"the kept folders could not be read: " + err.Error()} - } - var said []string - for path, f := range all { - if ctx.Err() != nil { - break - } - if len(f.Left) > 0 { - said = append(said, dropLeft(path, f)...) - if f, _ = keptRecord(path); f.Path == "" { - continue - } - } - if !f.Kept || sameUsers(f.Users, f.Want) { - continue - } - said = append(said, reconcileOne(ctx, path, f)...) - } - return said -} - -// LeftFor is how long entries left on a filesystem are kept recorded, and said, while it is not back. -var LeftFor = 30 * 24 * time.Hour - -// dropLeft takes out the entries the mesh left on a filesystem no longer at the folder, once that filesystem holds -// it again (§8): exactly those users' entries, nothing else. Left on the filesystem the folder is kept on now, they -// are that folder's own, and its walk takes out whichever are no longer wanted. Until then they stay recorded, and -// said (Leftovers). -func dropLeft(path string, f store.KeptFolder) []string { - lock := folderLock(path) - lock.Lock() - defer lock.Unlock() - holding := "" - if _, err := lstatWithin(path); err == nil { - holding = mountHolding(path, MountPoints()) - } - holdingID := "" - if holding != "" { - holdingID = PlacedID(holding) - } - var said []string - var still []store.KeptLeft - var mine []store.KeptUser - for _, l := range f.Left { - // On the folder's own filesystem now: the same name, or a name of an earlier build's form at the same - // mount point (which cannot be compared, and is never walked away). - same := l.Mount == f.Mount && (l.Placed == "" || l.Placed == f.Placed || l.Fs == "") - back := l.Mount == holding && (l.Placed == "" || l.Placed == holdingID) - if l.Since.IsZero() { - l.Since = time.Now().UTC() - } - if !same && !back && time.Since(l.Since) > LeftFor { - // **A filesystem not back within LeftFor is gone** (a disk replaced, a pool destroyed): what was left - // on it is forgotten, said once, rather than said unhealthy for ever. - said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem not back for %s "+ - "are forgotten: it is taken for gone", path, LeftFor)) - continue - } - switch { - case f.Kept && same: - // Left on the filesystem the folder is kept on now: the folder's own entries. - mine = append(mine, l.Users...) - case same: - // On the filesystem the folder is on now, not kept yet: never walked away; they are the folder's - // once it is kept there again. - still = append(still, l) - case !back: - still = append(still, l) - default: - drop := map[uint32]bool{} - for _, u := range l.Users { - drop[u.UID] = true - } - w, err := walkKept(path, ActLimits, machineStats, nil, dropVisit(drop)) - if err != nil || w.Failed != "" { - still = append(still, l) - said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem that is back "+ - "were not all taken out, tried again: %s", path, firstOf(pathlessOr(err), w.Failed))) - continue - } - said = append(said, fmt.Sprintf("the kept folder %s: the entries left on a filesystem that is back "+ - "were taken out (%d folders and %d files)", path, w.counts.Folders, w.counts.Files)) - } - } - _ = KeptFolders.Update(path, func(k *store.KeptFolder, had bool) bool { - k.Left = still - for _, u := range mine { - if _, has := usersOf(k.Users)[u.UID]; !has { - k.Users = append(k.Users, u) - } - } - return had && ((k.Kept && (len(k.Users) > 0 || len(k.Want) > 0)) || len(k.Left) > 0) - }) - return said -} - -func pathlessOr(err error) string { - if err == nil { - return "" - } - return pathless(err) -} - -func firstOf(a, b string) string { - if a != "" { - return a - } - return b -} - -func reconcileOne(ctx context.Context, path string, f store.KeptFolder) []string { - lock := folderLock(path) - lock.Lock() - defer lock.Unlock() - // **Judged as an apply judges it, before every walk** (§9, §12): a disk not there leaves its mount point — - // an empty folder of root's on the filesystem below — at the path, and a walk there would write onto root's - // folder, or "drop" entries from a folder that never had them and forget the ones on the data. Nothing is - // walked then, and the record stays for the walk that can reach the data. - if v := assessFolder(ctx, path, f, false); v.err != nil || v.kind != folderKeep || v.moved { - why := "the disk that holds it is not there" - switch { - case v.err != nil: - why = pathless(v.err) - case v.kind == folderRoot: - why = RootWords(v.why) - case v.kind == folderUnknown: - why = v.why - case v.moved || v.kind == folderWaitKeep: - why = "what is at it now is not what the mesh kept there" - } - _ = KeptFolders.Update(path, func(k *store.KeptFolder, had bool) bool { - k.Act = &store.KeptAct{Act: "walk", State: "waiting", Started: time.Now().UTC(), - Why: "not walked: " + why + "; walked once it is"} - return had - }) - return []string{fmt.Sprintf("the kept folder %s: not walked, %s", path, why)} - } - have, want := usersOf(f.Users), usersOf(f.Want) - drop := map[uint32]bool{} - adds := false - for uid, rw := range have { - if w, ok := want[uid]; !ok || w != rw { - drop[uid] = true - } - } - for uid, rw := range want { - if h, ok := have[uid]; !ok || h != rw { - adds = true - } - } - act, visit := "add", keepVisit(want) - switch { - case len(drop) > 0 && adds: - visit = dropThenKeep(drop, want) - case len(drop) > 0: - act, visit = "drop", dropVisit(drop) - } - started := time.Now().UTC() - w, err := walkKept(path, ActLimits, machineStats, nil, visit) - rec := actRecord(act, "", "", started, w, err) - _ = KeptFolders.Update(path, func(k *store.KeptFolder, _ bool) bool { - k.Act = rec - // Done only when every entry was reached and changed: one that failed is walked again. - if err == nil && w.Failed == "" { - k.Users = f.Want - } - return k.Kept && (len(k.Want) > 0 || len(k.Users) > 0) - }) - return []string{fmt.Sprintf("the kept folder %s: %s walk %s (%d folders and %d files changed, %d had "+ - "them)%s", path, act, rec.State, rec.Folders, rec.Files, rec.Kept, orWhy(rec.Why))} -} - -func orWhy(why string) string { - if why == "" { - return "" - } - return ": " + why -} - -// TakeFolder is the take act (ADR 0325 §8): on the operator's warrant, the users' entries added to the folder of -// the access id and everything under it. It judges the ask against the declaration last applied and the folder as -// it is, sets the filesystem's ACL type where a ZFS dataset allows none yet, and starts the walk: what it answers -// at once, and a channel closed when the walk is through. Refused, nothing is changed. -func TakeFolder(ctx context.Context, d *declaration.Declaration, id, path, warrant, by string, - run Runner) (<-chan struct{}, string, error) { - if d == nil { - return nil, "", errors.New("this engine has applied no declaration, and takes no folder it was not told of") - } - if err := ResolveRunners(ctx, d)[id]; err != nil { - return nil, "", err - } - var access *declaration.Access - for _, r := range d.Resources { - if a, ok := r.(*declaration.Access); ok && a.ID == id { - access = a - } - } - switch { - case access == nil: - return nil, "", fmt.Errorf("the access %s is not declared on this machine", id) - case filepath.Clean(access.Path) != path: - return nil, "", fmt.Errorf("the access %s is declared at another folder than the one asked", id) - } - folders := foldersOf(d.Resources, refusedPlaces(d.Resources)) - want := folders[path] - users := want.list() - if len(users) == 0 { - return nil, "", errors.New("no identity uses the folder, so there is nothing to keep") - } - rec, _ := keptRecord(path) - v := assessFolder(ctx, path, rec, false) - switch { - case v.err != nil: - return nil, "", v.err - case v.kind == folderWaitDisk: - return nil, "", errors.New("the disk that holds the folder is not there") - case v.kind == folderRoot: - return nil, "", errors.New(RootWords(v.why)) - case v.kind == folderMake: - return nil, "", errors.New("the folder is not there; the next apply makes it, and asks nobody") - case v.unnamed: - return nil, "", errors.New(v.why + ", so a warrant could name no filesystem it was given for") - } - lock := folderLock(path) - if !lock.TryLock() { - return nil, "", errors.New("a walk of the folder is running; asked again once it is through, the act goes on") - } - said := "" - _, err := keepTop(path, usersOf(users)) - if errors.Is(err, ErrNoACL) { - allowed, aerr := allowACLs(ctx, v.mount, run) - if aerr != nil { - lock.Unlock() - return nil, "", aerr - } - said = allowed + "; " - _, err = keepTop(path, usersOf(users)) - } - if err != nil { - lock.Unlock() - return nil, "", err - } - started := time.Now().UTC() - _ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool { - if v.moved { - *f = movedRecord(*f, v.mount) - } - if !f.Kept { - f.Kept, f.How, f.At, f.Users = true, store.KeptByWarrant, started, nil - } - f.Warrant, f.By, f.Mount, f.Want, f.IDs = warrant, by, v.mount, users, want.ids - // The warrant is for the filesystem it was carried out on (§8), named as a wait names it. - f.Placed, f.Fs = placeOf(v.mount) - f.TakenOn = f.Placed - f.Act = &store.KeptAct{Act: "take", Warrant: warrant, By: by, State: "running", Started: started} - return true - }) - done := make(chan struct{}) - go func() { - defer close(done) - defer lock.Unlock() - w, err := walkKept(path, ActLimits, machineStats, nil, keepVisit(usersOf(users))) - rec := actRecord("take", warrant, by, started, w, err) - _ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool { - f.Act = rec - if err == nil { - f.Users = users - } - return true - }) - }() - return done, said + "the take act started under the warrant " + warrant + ": the entries of " + - usersWords(users) + " are added to everything under the folder, following no link and entering no other " + - "filesystem", nil -} - -// allowACLs lets the filesystem at mount carry ACLs, inside the take act only (ADR 0325 §8): a ZFS dataset's -// acltype set to posixacl, the dataset read from the machine's mounts. Any other filesystem is refused, naming why. -func allowACLs(ctx context.Context, mount string, run Runner) (string, error) { - source, fstype := mountSourceOf(mount) - if fstype != "zfs" || source == "" { - return "", fmt.Errorf("the filesystem that holds the folder (%s) allows no access entries, and only a ZFS "+ - "dataset's can be let by the act; the operator mounts it with ACLs", orUnknown(fstype)) - } - if _, err := run(ctx, "zfs", "set", "acltype=posixacl", source); err != nil { - return "", fmt.Errorf("the ZFS dataset that holds the folder could not be let carry access entries "+ - "(zfs set acltype=posixacl): %w", err) - } - return "set acltype=posixacl on the ZFS dataset " + source, nil -} - -func orUnknown(s string) string { - if s == "" { - return "of a type the machine does not say" - } - return s -} - -// KeptLook is what one look found of a kept folder (ADR 0325 §10). -type KeptLook struct { - // Short is every user the folder itself gives less than its right, by entry and mask together, in words. - Short []string - // Busy is a walk of the folder running: the sweep waited. - Busy bool - // Sweep is the sweep's place after this look. - Sweep store.KeptSweep - // Repaired is how many entries this look found narrowed and put back; Unrepaired how many it could not. - Repaired, Unrepaired int - // Failed is why the sweep could not look, in words with no path. - Failed string -} - -// LookKept looks at a kept folder once: the folder itself judged for each user, then the sweep's bounded part of -// what is under it, from where the last look stopped, putting back what an app narrowed. The sweep's place is -// recorded; a look that ran out of its bound says so. -func LookKept(path string, users []store.KeptUser, limits WalkLimits) KeptLook { - var out KeptLook - short, err := judgeTop(path, users) - if err != nil { - out.Failed = err.Error() - return out - } - out.Short = short - lock := folderLock(path) - if !lock.TryLock() { - out.Busy = true - rec, _ := keptRecord(path) - out.Sweep = rec.Sweep - return out - } - defer lock.Unlock() - rec, _ := keptRecord(path) - sw := rec.Sweep - w, err := walkKept(path, limits, machineStats, sw.Cursor, keepVisit(usersOf(users))) - out.Repaired, out.Unrepaired = w.counts.Narrowed-w.counts.Unrepaired, w.counts.Unrepaired - sw.Read += w.counts.Read - sw.Narrowed += w.counts.Narrowed - sw.Unrepaired += w.counts.Unrepaired - switch { - case errors.Is(err, ErrWalkBound): - sw.Cursor, sw.Bound = w.Cursor, true - case err != nil: - out.Failed = err.Error() - default: - // Covered whole: the next look starts a new pass, and what this one could not put back is kept until - // the next pass is through. - sw = store.KeptSweep{Covered: time.Now().UTC(), LastUnrepaired: sw.Unrepaired} - } - out.Sweep = sw - _ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool { - f.Sweep = sw - return had - }) - return out -} - -// judgeTop is every user the folder itself gives less than its right, read only. -func judgeTop(path string, users []store.KeptUser) ([]string, error) { - var short []string - _, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil) - if err != nil { - return nil, err - } - defer unix.Close(fd) - a, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777) - if err != nil { - return nil, err - } - for _, u := range users { - need := needOn(u.Use == declaration.AccessReadWrite, true) - if got := effective(a, u.UID, st.Uid, uint32(st.Mode)&0o7777); got&need != need { - short = append(short, fmt.Sprintf("%s has %s of the %s it needs", u.Identity, permWords(got), - permWords(need))) - } - } - return short, nil -} - -// JudgeKept reads, without changing anything, how many entries under a kept folder give a user less than its -// right, within limits: what the access says while an entry an app narrowed is not put back yet (ADR 0325 §6). -func JudgeKept(path string, users []store.KeptUser, limits WalkLimits) (WalkCounts, error) { - um := usersOf(users) - w, err := walkKept(path, limits, machineStats, nil, func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool, - error) { - a, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777) - if err != nil { - return false, false, false, err - } - n, u := judgeACL(a, um, st, folder) - return false, n, u, nil - }) - return w.counts, err -} - -// aclProbe is what a keep wait says of its folder's filesystem and ACLs: ACLNeedsEnabling when it refuses them -// now and is a ZFS dataset the take act lets carry them, ACLUnsupported when no act can, "" when it allows -// them or the folder cannot be read (the wait says nothing then). Reads only. -func aclProbe(path, mount string) string { - _, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil) - if err != nil { - return "" - } - defer unix.Close(fd) - if _, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777); !errors.Is(err, ErrNoACL) { - return "" - } - return aclWord(mountSourceOf(mount)) -} - -// aclWord is what a keep wait says of a filesystem that refuses ACLs now, by its mount's source and type: only a -// ZFS dataset is one the take act lets carry them. -func aclWord(source, fstype string) string { - if fstype == "zfs" && source != "" { - return ACLNeedsEnabling - } - return ACLUnsupported -} diff --git a/internal/apply/kept_test.go b/internal/apply/kept_test.go deleted file mode 100644 index 107f8ad..0000000 --- a/internal/apply/kept_test.go +++ /dev/null @@ -1,1523 +0,0 @@ -//go:build linux - -package apply - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "golang.org/x/sys/unix" - - "github.com/novox/mesh-host/internal/store" -) - -// The engine's half of ADR 0325's checks (novox/hq ADR 0325, "How it is checked", issue 567): a shared folder kept -// through access entries, on a filesystem of the test's own. Nothing here needs root: a file's owner may set its -// ACL, and the identities are numbers no account here has. - -const ( - radarr = 61001 // a container's identity, read-write - plex = 61002 // a container's identity, read - runner = 61003 // the tool runner's account -) - -// aclFolder is a folder of the test's own on a filesystem that carries ACLs, or the test is skipped saying why. -func aclFolder(t *testing.T) string { - t.Helper() - dir := userDir(t) - fd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY, 0) - if err != nil { - t.Fatal(err) - } - defer unix.Close(fd) - probe := keptACL(minimalACL(0o755), map[uint32]uint16{radarr: 5}) - if err := setACL(fd, aclAccessXattr, probe); errors.Is(err, ErrNoACL) { - t.Skipf("the test's filesystem (%s) carries no POSIX ACLs (ENOTSUP), and every check here reads them", dir) - } else if err != nil { - t.Fatal(err) - } - if err := unix.Fremovexattr(fd, aclAccessXattr); err != nil { - t.Fatal(err) - } - if err := os.Chmod(dir, 0o755); err != nil { - t.Fatal(err) - } - return dir -} - -// keeping has the test keep its own record of kept folders, and judge mounts as the test says. -func keeping(t *testing.T, points []string, targets []string) { - t.Helper() - was, wasPoints, wasTargets := KeptFolders, MountPoints, MountTargets - KeptFolders = NewKeptMemory() - forgetMountHistory() - MountPoints = func() []string { return append([]string{"/"}, points...) } - MountTargets = func(context.Context) []string { return targets } - t.Cleanup(func() { - KeptFolders, MountPoints, MountTargets = was, wasPoints, wasTargets - forgetMountHistory() - }) -} - -// accessJSON is an access of module to path with users, each "identity:uid:use:from". -func accessJSON(id, path, mode string, taken bool, users ...string) string { - var us []string - for _, u := range users { - f := strings.Split(u, ":") - us = append(us, fmt.Sprintf(`{"identity":%q,"uid":%s,"use":%q,"from":%q}`, f[0], f[1], f[2], f[3])) - } - return fmt.Sprintf(`{"id":%q,"type":"access","path":%q,"mode":%q,"taken":%v,"users":[%s]}`, id, path, mode, - taken, strings.Join(us, ",")) -} - -// readACLs is a path's access ACL and default ACL (nil when it has none), and its stat. -func readACLs(t *testing.T, path string) (acl, acl, unix.Stat_t) { - t.Helper() - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW, 0) - if err != nil { - t.Fatal(err) - } - defer unix.Close(fd) - var st unix.Stat_t - if err := unix.Fstat(fd, &st); err != nil { - t.Fatal(err) - } - access, err := getACL(fd, aclAccessXattr, uint32(st.Mode)&0o7777) - if err != nil { - t.Fatal(err) - } - def, err := getACL(fd, aclDefaultXattr, uint32(st.Mode)&0o7777) - if err != nil { - t.Fatal(err) - } - return access, def, st -} - -// effectiveOn is what uid may do on path, by entry and mask together. -func effectiveOn(t *testing.T, path string, uid uint32) uint16 { - t.Helper() - a, _, st := readACLs(t, path) - return effective(a, uid, st.Uid, uint32(st.Mode)&0o7777) -} - -func applyAccesses(t *testing.T, known store.State, resources ...string) (Report, store.State, error) { - t.Helper() - toUser(t) - d := declare(t, strings.Join(resources, ",")) - toUser(t) - return Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil) -} - -// Row 5: entries and default entries per identity and use; owner, owning group, owner's and others' bits unchanged; -// the owning group's entry equals its former group bits; the mask equals what the entries need; an entry the mesh -// did not add is untouched. -func TestAKeptFolderCarriesAnEntryPerUserAndNothingElseOfItChanges(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "movies") - if err := os.Mkdir(lib, 0o750); err != nil { - t.Fatal(err) - } - if err := os.Chmod(lib, 0o750); err != nil { - t.Fatal(err) - } - // The operator's own entry, there before: never the mesh's to change. - fd, _ := unix.Open(lib, unix.O_RDONLY|unix.O_DIRECTORY, 0) - operator := keptACL(minimalACL(0o750), map[uint32]uint16{60099: permRead}) - operator[len(operator)-1].Perm = permRead | permExec // its mask: r-x, as the operator set it - if err := setACL(fd, aclAccessXattr, operator); err != nil { - t.Fatal(err) - } - unix.Close(fd) - toUser(t) - _, _, before := readACLs(t, lib) - - report, _, err := applyAccesses(t, store.State{}, - accessJSON("radarr.movies", lib, "read-write", false, "mesh-radarr:61001:read-write:container"), - accessJSON("plex.movies", lib, "read", false, "mesh-plex:61002:read:container")) - if err != nil { - t.Fatalf("an empty folder was not kept: %v", err) - } - access, def, after := readACLs(t, lib) - if bits, _ := access.perm(aclUser, radarr); bits != 7 { - t.Errorf("the read-write identity's entry is %s, and it needs rwx", permWords(bits)) - } - if bits, _ := access.perm(aclUser, plex); bits != 5 { - t.Errorf("the read identity's entry is %s, and it needs r-x", permWords(bits)) - } - for _, uid := range []uint32{radarr, plex} { - if _, ok := def.perm(aclUser, uid); !ok { - t.Errorf("uid %d has no default entry: what is made under the folder would not be its", uid) - } - } - if bits, _ := access.perm(aclUser, 60099); bits != permRead { - t.Errorf("the operator's entry was changed to %s", permWords(bits)) - } - if g, _ := access.perm(aclGroupObj, 0); g != 5 { - t.Errorf("the owning group's entry is %s, and its group bits gave r-x", permWords(g)) - } - if m, _ := access.perm(aclMask, 0); m != 7 { - t.Errorf("the mask is %s, and the entries need rwx", permWords(m)) - } - if after.Uid != before.Uid || after.Gid != before.Gid { - t.Error("the folder's owner or owning group changed") - } - if after.Mode&0o707 != before.Mode&0o707 { - t.Errorf("the owner's or others' bits changed: %o to %o", before.Mode&0o777, after.Mode&0o777) - } - if after.Mode&0o070 != 0o070 { - t.Errorf("the group bits show %o, and they show the mask the entries need (rwx)", (after.Mode>>3)&7) - } - rec, _ := keptRecord(lib) - if !rec.Kept || rec.How != store.KeptEmpty { - t.Errorf("the folder was not recorded kept as one that held nothing: %+v", rec) - } - for _, o := range report.Outcomes { - if o.ID == "radarr.movies" && o.Action == "waiting" { - t.Errorf("an empty folder waited for a warrant: %s", o.Detail) - } - } -} - -// Row 4: the runner's entry is the widest use among the modules whose tools use the folder. -func TestTheToolRunnersEntryIsTheWidestUseAnyModuleGivesIt(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "books") - if err := os.Mkdir(lib, 0o755); err != nil { - t.Fatal(err) - } - _, _, err := applyAccesses(t, store.State{}, - accessJSON("reader.books", lib, "read", false, "nox:61003:read:tool-runner"), - accessJSON("importer.books", lib, "read-write", false, "nox:61003:read-write:tool-runner")) - if err != nil { - t.Fatal(err) - } - if got := effectiveOn(t, lib, runner); got != 7 { - t.Fatalf("the tool runner may %s, and one module whose tools use it declared read-write", permWords(got)) - } -} - -// Row 6: a file made at 0666 by one identity is writable by another read-write identity at once; one made at 0644 -// is writable by it after the watch's repair, and before it the judgement says so; with watches exhausted, the -// sweep repairs it; the judgement reads the effective right (entry and mask). -func TestWhatIsMadeInAKeptFolderIsGivenItsUsersRights(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "downloads") - if err := os.Mkdir(lib, 0o755); err != nil { - t.Fatal(err) - } - users := []string{"mesh-radarr:61001:read-write:container", "mesh-sab:61004:read-write:container"} - if _, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.downloads", lib, "read-write", false, - users...)); err != nil { - t.Fatal(err) - } - rec, _ := keptRecord(lib) - - usual := filepath.Join(lib, "usual") - if err := os.WriteFile(usual, nil, 0o666); err != nil { - t.Fatal(err) - } - if got := effectiveOn(t, usual, radarr); got&6 != 6 { - t.Fatalf("a file made at 0666 gives another read-write identity %s at once", permWords(got)) - } - - w, err := NewWatcher() - if err != nil { - t.Skipf("no inotify here: %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - ended := make(chan struct{}) - go func() { w.Run(ctx); close(ended) }() - defer func() { cancel(); <-ended }() - w.Set(map[string][]store.KeptUser{lib: rec.Want}) - - narrow := filepath.Join(lib, "narrow") - f, err := os.OpenFile(narrow, os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - t.Fatal(err) - } - f.Close() - waitFor(t, "the watch widened the mask of a file made at 0644", func() bool { - return effectiveOn(t, narrow, radarr)&6 == 6 - }) - - // Before a repair the judgement says so: a mask narrowed by hand where nothing watches. - w.Set(nil) - if err := os.Chmod(usual, 0o644); err != nil { - t.Fatal(err) - } - toUser(t) - c, err := JudgeKept(lib, rec.Want, ActLimits) - if err != nil { - t.Fatal(err) - } - if c.Narrowed == 0 { - t.Fatal("a file whose mask gives a read-write user only read is not said by the judgement") - } - - // Watches run out: the record says so, and the sweep puts it back. - was := MaxWatchedPerFolder - MaxWatchedPerFolder = 0 - defer func() { MaxWatchedPerFolder = was }() - w.Set(map[string][]store.KeptUser{lib: rec.Want}) - if r, _ := keptRecord(lib); !r.OutOfWatches { - t.Error("watches that ran out are not recorded") - } - toUser(t) - look := LookKept(lib, rec.Want, SweepLimits) - if look.Repaired == 0 { - t.Errorf("the sweep put nothing back: %+v", look) - } - if got := effectiveOn(t, usual, radarr); got&6 != 6 { - t.Fatalf("after the sweep a read-write identity may %s", permWords(got)) - } - toUser(t) - if c, _ := JudgeKept(lib, rec.Want, ActLimits); c.Narrowed != 0 { - t.Fatalf("after the sweep %d entries still give a user less than its right", c.Narrowed) - } -} - -func waitFor(t *testing.T, what string, ok func() bool) { - t.Helper() - deadline := time.Now().Add(3 * time.Second) - for !ok() { - if time.Now().After(deadline) { - t.Fatalf("not within 3s: %s", what) - } - time.Sleep(20 * time.Millisecond) - } -} - -// Row 8, the engine's part: a folder that holds data waits, and nothing is changed without a warrant; the act adds -// the entries under the folder, follows no link, enters no other filesystem; a dropped access removes exactly its -// own entries. -func TestAFolderThatHoldsDataIsTakenOnlyOnAWarrantAndLetGoEntryByEntry(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "music") - outside := filepath.Join(dir, "outside") - for _, d := range []string{lib, filepath.Join(lib, "album"), outside} { - if err := os.Mkdir(d, 0o755); err != nil { - t.Fatal(err) - } - } - song := filepath.Join(lib, "album", "song") - if err := os.WriteFile(song, []byte("data"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.Symlink(outside, filepath.Join(lib, "link")); err != nil { - t.Fatal(err) - } - a := accessJSON("lidarr.music", lib, "read-write", false, "mesh-lidarr:61001:read-write:container", - "mesh-plex:61002:read:container") - report, _, err := applyAccesses(t, store.State{}, a) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "lidarr.music"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("music")) { - t.Fatalf("a folder that holds data did not wait for the operator: %+v", o) - } - if has := effectiveOn(t, lib, radarr); has != 0 { - t.Fatal("the folder was given an entry without a warrant") - } - if strings.Contains(KeepWords("music"), "/") || strings.Contains(DiskWords("music"), "/") { - t.Fatal("a wait's words name a path") - } - - d := declare(t, a) - toUser(t) - done, said, err := TakeFolder(context.Background(), d, "lidarr.music", lib, "warrant-7", "the operator", noServices) - if err != nil { - t.Fatalf("the take act was refused: %v", err) - } - <-done - if !strings.Contains(said, "warrant-7") { - t.Errorf("the act does not say its warrant: %s", said) - } - for _, p := range []string{lib, filepath.Join(lib, "album"), song} { - if got := effectiveOn(t, p, radarr); got&6 != 6 { - t.Errorf("after the act the read-write identity may %s", permWords(got)) - } - } - if has := effectiveOn(t, outside, radarr); has != 0 { - t.Error("the act followed a link out of the folder") - } - rec, _ := keptRecord(lib) - if !rec.Kept || rec.How != store.KeptByWarrant || rec.Warrant != "warrant-7" || rec.Act == nil || - rec.Act.State != "done" || rec.Act.Links != 1 { - t.Fatalf("the act is not recorded as done under its warrant, with the link passed by: %+v %+v", rec, rec.Act) - } - - // Kept now: the next apply keeps it without asking, and plex dropping it has exactly plex's entries removed. - if _, _, err := applyAccesses(t, store.State{}, accessJSON("lidarr.music", lib, "read-write", false, - "mesh-lidarr:61001:read-write:container")); err != nil { - t.Fatal(err) - } - beforeMask, _ := func() (uint16, bool) { a, _, _ := readACLs(t, song); return a.perm(aclMask, 0) }() - toUser(t) - ReconcileKept(context.Background()) - for _, p := range []string{lib, song} { - a, def, _ := readACLs(t, p) - if _, still := a.perm(aclUser, plex); still { - t.Errorf("a dropped user's entry stayed") - } - if _, still := def.perm(aclUser, plex); still && def != nil { - t.Errorf("a dropped user's default entry stayed") - } - if _, kept := a.perm(aclUser, radarr); !kept { - t.Errorf("the user that stayed lost its entry") - } - } - if m, _ := func() (uint16, bool) { a, _, _ := readACLs(t, song); return a.perm(aclMask, 0) }(); m != beforeMask { - t.Errorf("the mask moved on a drop: %s to %s", permWords(beforeMask), permWords(m)) - } -} - -// Row 8: the act enters no other filesystem — a folder whose device is not the kept folder's is passed by. -func TestAWalkEntersNoOtherFilesystem(t *testing.T) { - dir := aclFolder(t) - mount := filepath.Join(dir, "elsewhere") - if err := os.Mkdir(mount, 0o755); err != nil { - t.Fatal(err) - } - inside := filepath.Join(mount, "file") - if err := os.WriteFile(inside, nil, 0o644); err != nil { - t.Fatal(err) - } - // A stand-in for a device the test cannot mount: the folder named elsewhere reads as another filesystem. - stats := walkStats{ - at: func(dirfd int, name string, st *unix.Stat_t) error { - err := unix.Fstatat(dirfd, name, st, unix.AT_SYMLINK_NOFOLLOW) - if name == "elsewhere" { - st.Dev++ - } - return err - }, - fd: unix.Fstat, - } - toUser(t) - w, err := walkKept(dir, ActLimits, stats, nil, keepVisit(map[uint32]bool{radarr: true})) - if err != nil { - t.Fatal(err) - } - if w.counts.Mounts != 1 { - t.Errorf("the walk counted %d folders on another filesystem, and there is one", w.counts.Mounts) - } - if got := effectiveOn(t, inside, radarr); got != 0 { - t.Error("the walk entered another filesystem") - } -} - -// Row 9: a missing folder under a mounted dataset is made as its parent; with the dataset not mounted (its empty -// mount point on the root filesystem) nothing is made and the access waits. -func TestAMissingFolderIsMadeWhereItsDataLivesAndWaitsWhereItsDiskIsNot(t *testing.T) { - dir := aclFolder(t) - dataset := filepath.Join(dir, "tank") - if err := os.Mkdir(dataset, 0o750); err != nil { - t.Fatal(err) - } - if err := os.Chmod(dataset, 0o750); err != nil { - t.Fatal(err) - } - keeping(t, []string{dataset}, []string{dataset}) - movies := filepath.Join(dataset, "media", "movies") - report, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.movies", movies, "read-write", false, - "mesh-radarr:61001:read-write:container")) - if err != nil { - t.Fatalf("a missing folder under a mounted dataset was not made: %v", err) - } - info, err := os.Stat(movies) - if err != nil { - t.Fatalf("the folder was not made: %v", err) - } - if info.Mode().Perm()&0o707 != 0o700 { - t.Errorf("the folder was made at %o, and its parent is 0750", info.Mode().Perm()) - } - if got := effectiveOn(t, movies, radarr); got != 7 { - t.Errorf("the folder made is not kept: the identity may %s", permWords(got)) - } - if rec, _ := keptRecord(movies); rec.How != store.KeptMade || rec.Mount != dataset { - t.Errorf("not recorded as made on its dataset: %+v", rec) - } - _ = report - - // The dataset not mounted: its mount point an empty folder on the filesystem below. - MountPoints = func() []string { return []string{"/"} } - books := filepath.Join(dataset, "books") - report, _, err = applyAccesses(t, store.State{}, accessJSON("readarr.books", books, "read-write", false, - "mesh-readarr:61001:read-write:container")) - if err != nil { - t.Fatalf("a missing disk failed the apply: %v", err) - } - if o := outcomeOf(report, "readarr.books"); o.Action != "waiting" || !strings.Contains(o.Detail, DiskWords("books")) { - t.Fatalf("a folder whose disk is not there did not wait for it: %+v", o) - } - if _, err := os.Stat(books); !errors.Is(err, os.ErrNotExist) { - t.Fatal("a folder was made where its disk is not") - } - toUser(t) - if v := AccessVerdict(context.Background(), books, false, true); v.Kind != VerdictWaitDisk { - t.Errorf("the look judges it %s, not waiting for its disk", v.Kind) - } - // And the folder made before, its dataset gone: its disk is not there, whatever is at its path now. - toUser(t) - if v := AccessVerdict(context.Background(), movies, false, true); v.Kind != VerdictWaitDisk { - t.Errorf("a kept folder whose dataset is not mounted is judged %s", v.Kind) - } -} - -// Row 10: a narrowed entry or mask is reported and put back; successive looks cover a folder whole; a look over -// its bound says how far it got. -func TestTheSweepPutsBackWhatAnAppNarrowedAndCoversTheFolderOverLooks(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "shows") - if err := os.Mkdir(lib, 0o755); err != nil { - t.Fatal(err) - } - a := accessJSON("sonarr.shows", lib, "read-write", false, "mesh-sonarr:61001:read-write:container") - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - rec, _ := keptRecord(lib) - for i := 0; i < 25; i++ { - p := filepath.Join(lib, fmt.Sprintf("episode-%02d", i)) - if err := os.WriteFile(p, nil, 0o666); err != nil { - t.Fatal(err) - } - if err := os.Chmod(p, 0o644); err != nil { // an app's chmod narrows the mask - t.Fatal(err) - } - } - // The folder itself narrowed too: put back on the next apply, said by the look until then. - if err := os.Chmod(lib, 0o755); err != nil { - t.Fatal(err) - } - toUser(t) - if short, _ := judgeTop(lib, rec.Want); len(short) == 0 { - t.Fatal("a folder whose mask an app narrowed is not said short") - } - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - toUser(t) - if short, _ := judgeTop(lib, rec.Want); len(short) != 0 { - t.Fatalf("the apply did not put the folder back: %v", short) - } - - limits := WalkLimits{Entries: 10, Time: time.Minute} - looks, repaired := 0, 0 - for { - looks++ - toUser(t) - look := LookKept(lib, rec.Want, limits) - repaired += look.Repaired - if look.Failed != "" { - t.Fatal(look.Failed) - } - if !look.Sweep.Bound || look.Sweep.Cursor == nil { - break - } - if len(look.Sweep.Cursor) == 0 || look.Sweep.Read == 0 { - t.Fatalf("a look over its bound does not say how far it got: %+v", look.Sweep) - } - if looks > 10 { - t.Fatal("the sweep never covered the folder") - } - } - if looks < 3 { - t.Errorf("26 entries at 10 a look were covered in %d looks", looks) - } - if repaired != 25 { - t.Errorf("the sweep put back %d of 25 narrowed entries", repaired) - } - toUser(t) - if c, _ := JudgeKept(lib, rec.Want, ActLimits); c.Narrowed != 0 { - t.Errorf("after the looks %d entries still give the user less than its right", c.Narrowed) - } - if r, _ := keptRecord(lib); r.Sweep.Covered.IsZero() { - t.Error("a sweep that covered the folder is not recorded as having done so") - } -} - -// Row 12: a root-owned folder on its placed filesystem, and an absent one under a root-owned parent on it, are -// unhealthy, nothing is made, and no ask is raised. Read from the machine's own root-owned folders, never changed. -func TestRootsDataOnItsPlacedFilesystemIsNeverKept(t *testing.T) { - keeping(t, nil, nil) - // A root-owned folder the placement guard lets an access name: below a protected root, never one. - root := "" - for _, c := range []string{"/srv/http", "/srv/ftp", "/var/empty", "/var/opt"} { - if info, err := os.Lstat(c); err == nil && info.IsDir() { - if uid, _, _ := statOwner(info); uid == 0 { - root = c - break - } - } - } - if root == "" { - t.Skip("this machine has no root-owned folder below /srv or /var to read") - } - absent := filepath.Join(root, "mesh-test-never-made") - for _, path := range []string{root, absent} { - report, _, err := applyAccesses(t, store.State{}, accessJSON("m.data", path, "read-write", false, - "mesh-m:61001:read-write:container")) - if err == nil || !strings.Contains(err.Error(), "root's data is never kept") { - t.Errorf("root's data at %s was not refused for what it is: %v", path, err) - } - if o := outcomeOf(report, "m.data"); o.Action == "waiting" { - t.Errorf("root's data waits, and is never asked about: %+v", o) - } - toUser(t) - if v := AccessVerdict(context.Background(), path, false, true); v.Kind != VerdictRoot { - t.Errorf("the look judges root's data at %s %s", path, v.Kind) - } - } - if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) { - t.Fatal("a folder was made under root's data") - } - if _, has := keptRecord(root); has { - t.Error("root's data was recorded as a kept folder") - } -} - -// The codec reads back what it writes, in the kernel's order. -func TestAnACLReadsBackAsWritten(t *testing.T) { - a := keptACL(minimalACL(0o640), map[uint32]uint16{radarr: 6, plex: 4}) - b, err := decodeACL(a.encode()) - if err != nil { - t.Fatal(err) - } - if !sameACL(a, b) || b[0].Tag != aclUserObj || b[len(b)-1].Tag != aclOther { - t.Fatalf("read back %+v", b) - } - if err := b.check(); err != nil { - t.Fatal(err) - } - if _, err := decodeACL([]byte{1, 0, 0, 0}); err == nil { - t.Error("a version 1 attribute was read") - } - if m, _ := b.perm(aclMask, 0); m != 6 { - t.Errorf("mask %s, and the entries need rw- and the group had r--", permWords(m)) - } -} - -func forgetMountHistory() { - mountHistory.Lock() - mountHistory.seen = map[string]bool{} - mountHistory.Unlock() -} - -// Review H1: a walk is judged as an apply is, first. With the dataset not mounted its mount point is what is at the -// path, and neither an add nor a drop walk touches it; the record stays for the walk that can reach the data. -func TestNoWalkRunsWhereTheDiskIsNotThere(t *testing.T) { - dir := aclFolder(t) - dataset := filepath.Join(dir, "tank") - if err := os.Mkdir(dataset, 0o755); err != nil { - t.Fatal(err) - } - keeping(t, []string{dataset}, nil) - lib := filepath.Join(dataset, "movies") - both := []string{"mesh-radarr:61001:read-write:container", "mesh-plex:61002:read:container"} - if _, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.movies", lib, "read-write", false, - both...)); err != nil { - t.Fatal(err) - } - if rec, _ := keptRecord(lib); rec.Mount != dataset || !sameUsers(rec.Users, rec.Want) { - t.Fatalf("not kept on its dataset: %+v", rec) - } - // plex drops it, and the dataset is unmounted before the walk. - if _, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.movies", lib, "read-write", false, - both[0])); err != nil { - t.Fatal(err) - } - MountPoints = func() []string { return []string{"/"} } - toUser(t) - ReconcileKept(context.Background()) - rec, had := keptRecord(lib) - if !had || len(rec.Users) != 2 { - t.Fatalf("a drop that could not reach the data forgot whose entries are on it: %+v", rec) - } - if rec.Act == nil || rec.Act.State != "waiting" { - t.Errorf("the walk not run is not said: %+v", rec.Act) - } - if _, still := func() (uint16, bool) { a, _, _ := readACLs(t, lib); return a.perm(aclUser, plex) }(); !still { - t.Fatal("a walk ran where the disk is not there") - } - // The disk back: the drop runs. - MountPoints = func() []string { return []string{"/", dataset} } - toUser(t) - ReconcileKept(context.Background()) - if _, still := func() (uint16, bool) { a, _, _ := readACLs(t, lib); return a.perm(aclUser, plex) }(); still { - t.Fatal("the drop did not run once the disk was there") - } -} - -// Review H2: a folder kept while it was empty on the filesystem below a mount nobody named is judged afresh when -// the disk mounts over it: holding data, it waits for the operator's word, and nothing is added to it. -func TestADiskMountedOverAFolderKeptEmptyIsNotKeptWithoutAWarrant(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - pool := filepath.Join(dir, "pool") - lib := filepath.Join(pool, "media") - if err := os.MkdirAll(lib, 0o755); err != nil { - t.Fatal(err) - } - a := accessJSON("radarr.media", lib, "read-write", false, "mesh-radarr:61001:read-write:container") - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - if rec, _ := keptRecord(lib); !rec.Kept || rec.How != store.KeptEmpty || rec.Mount != "/" { - t.Fatalf("not kept as empty on /: %+v", rec) - } - // The pool is imported: a library is at the path now, on its own filesystem. - film := filepath.Join(lib, "film") - if err := os.WriteFile(film, []byte("data"), 0o644); err != nil { - t.Fatal(err) - } - // The new disk's data carries no entries of the mesh's: the test's stand-in made it under the folder kept - // below, so what it inherited there is taken off. - if err := unix.Removexattr(film, aclAccessXattr); err != nil { - t.Fatal(err) - } - MountPoints = func() []string { return []string{"/", pool} } - toUser(t) - if v := AccessVerdict(context.Background(), lib, false, true); v.Kind != VerdictWaitKeep { - t.Fatalf("the look judges it %s, and the data on the new disk was never taken", v.Kind) - } - report, _, err := applyAccesses(t, store.State{}, a) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action != "waiting" { - t.Fatalf("a folder whose disk mounted over it was kept without a warrant: %+v", o) - } - if rec, _ := keptRecord(lib); rec.Kept || rec.Mount != pool { - t.Errorf("the record still says kept, or not on the new disk: %+v", rec) - } - if got := effectiveOn(t, film, radarr); got != 0 { - t.Error("an entry was added to the data on the new disk") - } -} - -// Review M3: widening a mask widens nobody else — a file made at 0644 under a default ACL carries the owning group's -// rwx under a mask of r--, and after the mesh widens the mask the owning group still reads only. -func TestWideningTheMaskGivesTheOwningGroupNothingMore(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "shows") - if err := os.Mkdir(lib, 0o775); err != nil { - t.Fatal(err) - } - if err := os.Chmod(lib, 0o775); err != nil { - t.Fatal(err) - } - a := accessJSON("sonarr.shows", lib, "read-write", false, "mesh-sonarr:61001:read-write:container") - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - ep := filepath.Join(lib, "episode") - f, err := os.OpenFile(ep, os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - t.Fatal(err) - } - f.Close() - groupRight := func() uint16 { - acl, _, _ := readACLs(t, ep) - g, _ := acl.perm(aclGroupObj, 0) - m, _ := acl.perm(aclMask, 0) - return g & m - } - before := groupRight() - rec, _ := keptRecord(lib) - toUser(t) - LookKept(lib, rec.Want, SweepLimits) - if got := effectiveOn(t, ep, radarr); got&6 != 6 { - t.Fatalf("the sweep did not widen the mask: %s", permWords(got)) - } - if after := groupRight(); after != before { - t.Fatalf("the owning group may %s after the mask widened, and %s before", permWords(after), permWords(before)) - } -} - -// watchedFolder is a kept folder with a watch running on it, its users, and the end of the watch's run. -func watchedFolder(t *testing.T) (string, []store.KeptUser, *Watcher, context.CancelFunc, <-chan struct{}) { - t.Helper() - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "music") - if err := os.Mkdir(lib, 0o755); err != nil { - t.Fatal(err) - } - if _, _, err := applyAccesses(t, store.State{}, accessJSON("lidarr.music", lib, "read-write", false, - "mesh-lidarr:61001:read-write:container")); err != nil { - t.Fatal(err) - } - rec, _ := keptRecord(lib) - w, err := NewWatcher() - if err != nil { - t.Skipf("no inotify here: %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - ended := make(chan struct{}) - go func() { w.Run(ctx); close(ended) }() - // The watch ends before the test's record of kept folders is put back. - t.Cleanup(func() { cancel(); <-ended }) - w.Set(map[string][]store.KeptUser{lib: rec.Want}) - return lib, rec.Want, w, cancel, ended -} - -// Review L4: a watch whose folder goes is let go (IN_IGNORED). -func TestTheWatchOfAFolderRemovedIsLetGo(t *testing.T) { - lib, _, w, _, _ := watchedFolder(t) - album := filepath.Join(lib, "album") - if err := os.Mkdir(album, 0o755); err != nil { - t.Fatal(err) - } - waitFor(t, "the folder made is watched", func() bool { return w.watched() == 2 }) - if err := os.Remove(album); err != nil { - t.Fatal(err) - } - waitFor(t, "the watch of a folder removed is let go", func() bool { return w.watched() == 1 }) -} - -// Review L4: notice lost re-arms every watch, and the record says the watch is whole again. -func TestNoticeLostReArmsTheWatch(t *testing.T) { - lib, _, w, _, _ := watchedFolder(t) - w.lost() - if w.watched() != 1 { - t.Fatalf("notice lost did not re-arm the watch: %d watched", w.watched()) - } - if r, _ := keptRecord(lib); r.OutOfWatches { - t.Error("a re-armed watch is still said out of watches") - } - late := filepath.Join(lib, "late") - f, _ := os.OpenFile(late, os.O_CREATE|os.O_WRONLY, 0o644) - f.Close() - waitFor(t, "the re-armed watch repairs", func() bool { return effectiveOn(t, late, radarr)&6 == 6 }) -} - -// Review L4: users given while notices are heard are read under the lock (run with -race). -func TestUsersGivenWhileNoticesAreHeardAreReadUnderTheLock(t *testing.T) { - lib, users, w, _, _ := watchedFolder(t) - stop := make(chan struct{}) - done := make(chan struct{}) - go func() { - defer close(done) - for { - select { - case <-stop: - return - default: - w.Set(map[string][]store.KeptUser{lib: users}) - } - } - }() - for i := 0; i < 50; i++ { - _ = os.WriteFile(filepath.Join(lib, fmt.Sprintf("f%d", i)), nil, 0o644) - } - waitFor(t, "the last file is repaired", func() bool { - return effectiveOn(t, filepath.Join(lib, "f49"), radarr)&6 == 6 - }) - close(stop) - <-done -} - -// Review L4: the watch ends with its context, however quiet the machine. -func TestTheWatchEndsWithItsContext(t *testing.T) { - _, _, _, cancel, ended := watchedFolder(t) - cancel() - select { - case <-ended: - case <-time.After(2 * time.Second): - t.Fatal("the watch did not end with its context") - } -} - -// Review L6: a broken folder's words carry no path. -func TestABrokenFoldersWordsCarryNoPath(t *testing.T) { - dir := userDir(t) - keeping(t, nil, nil) - file := filepath.Join(dir, "notafolder") - if err := os.WriteFile(file, nil, 0o644); err != nil { - t.Fatal(err) - } - for _, path := range []string{file, filepath.Join(file, "below")} { - toUser(t) - v := AccessVerdict(context.Background(), path, false, true) - if v.Kind != VerdictBroken || strings.Contains(v.Why, "/") { - t.Errorf("%s: %+v", path, v) - } - } -} - -// Review H2: a systemd .mount unit says where a disk is meant to be, as fstab does; and a mount point seen mounted -// once is remembered, so a folder below it is a missing disk on first sight when it is gone. -func TestMountUnitsAndMountsSeenSayWhereADiskIsMeantToBe(t *testing.T) { - units := t.TempDir() - unit := "[Unit]\nDescription=pool\n\n[Mount]\nWhat=/dev/disk/by-label/pool\nWhere=/srv/pool\nType=ext4\n" - if err := os.WriteFile(filepath.Join(units, "srv-pool.mount"), []byte(unit), 0o644); err != nil { - t.Fatal(err) - } - if got := mountUnitTargets([]string{units}); len(got) != 1 || got[0] != "/srv/pool" { - t.Fatalf("read %v from a .mount unit", got) - } - - dir := t.TempDir() - disk := filepath.Join(dir, "disk") - if err := os.Mkdir(disk, 0o755); err != nil { - t.Fatal(err) - } - keeping(t, []string{disk}, nil) - _ = placedOn(context.Background(), disk, disk, "", nil) // seen mounted once - MountPoints = func() []string { return []string{"/"} } - if pl := placedOn(context.Background(), filepath.Join(disk, "films"), disk, "", nil); !pl.Away { - t.Fatal("a folder below a mount point seen mounted and gone is not a missing disk") - } -} - -// keptOnDataset is a folder kept on a dataset the test says is mounted, with the users given, and the dataset. -func keptOnDataset(t *testing.T, users ...string) (dataset, lib string) { - t.Helper() - dir := aclFolder(t) - dataset = filepath.Join(dir, "tank") - if err := os.Mkdir(dataset, 0o755); err != nil { - t.Fatal(err) - } - keeping(t, []string{dataset}, nil) - lib = filepath.Join(dataset, "movies") - if _, _, err := applyAccesses(t, store.State{}, accessJSON("radarr.movies", lib, "read-write", false, - users...)); err != nil { - t.Fatal(err) - } - return dataset, lib -} - -// Review M1: a container that mounts an access whose disk is not there is not created, though its empty mount -// point is at the path: its writes would land on the filesystem below (§9). -func TestAContainerWaitsWhileItsAccessesDiskIsNotThere(t *testing.T) { - _, lib := keptOnDataset(t, "mesh-radarr:61001:read-write:container") - MountPoints = func() []string { return []string{"/"} } - var ran []string - run := func(_ context.Context, name string, args ...string) (string, error) { - ran = append(ran, name+" "+strings.Join(args, " ")) - if len(args) > 0 && args[0] == "container" { - return "", errors.New("no such container") - } - return "", nil - } - d := declare(t, accessJSON("radarr.movies", lib, "read-write", false, "mesh-radarr:61001:read-write:container")+ - `,{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`","volumes":["`+lib+`:/movies"]}`) - toUser(t) - report, _, _ := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil) - if o := outcomeOf(report, "radarr.app"); o.Action != "waiting" { - t.Fatalf("a container mounting a folder whose disk is not there was not held: %+v", o) - } - for _, r := range ran { - if strings.Contains(r, " run ") || strings.HasSuffix(strings.Fields(r)[0], "docker") && strings.Contains(r, "run") { - t.Fatalf("the container was started: %s", r) - } - } - if !WaitingOnAccess(d)["radarr.app"] { - t.Error("liveness is not told the container waits on its access") - } -} - -// Review L2: a folder no access names any more, whose disk is away, keeps its entries' record and is said until -// the drop walk can run; then it is forgotten. -func TestEntriesThatCouldNotBeDroppedAreSaidUntilTheyAre(t *testing.T) { - dataset, lib := keptOnDataset(t, "mesh-radarr:61001:read-write:container") - other := `{"id":"x.dir","type":"directory","path":"` + filepath.Join(filepath.Dir(dataset), "x") + `"}` - if _, _, err := applyAccesses(t, store.State{}, other); err != nil { - t.Fatal(err) - } - MountPoints = func() []string { return []string{"/"} } - toUser(t) - ReconcileKept(context.Background()) - left := Leftovers() - if len(left) != 1 || left[0].Access != "radarr.movies" || !strings.Contains(left[0].Why, "could not be removed") || - strings.Contains(left[0].Why, dataset) { - t.Fatalf("entries that could not be dropped are not said, path-free: %+v", left) - } - MountPoints = func() []string { return []string{"/", dataset} } - toUser(t) - ReconcileKept(context.Background()) - if left := Leftovers(); len(left) != 0 { - t.Fatalf("said after the drop ran: %+v", left) - } - if _, had := keptRecord(lib); had { - t.Error("a folder nobody uses was not forgotten once its entries were out") - } - if _, still := func() (uint16, bool) { a, _, _ := readACLs(t, lib); return a.perm(aclUser, radarr) }(); still { - t.Error("the entries were not taken out") - } -} - -// Review L3: a disk mounted over a folder taken below it on a warrant leaves the mesh's entries on that data -// recorded and said; once that filesystem holds the folder again, they are taken out. -func TestEntriesLeftOnAFilesystemNoLongerAtTheFolderAreDroppedWhenItIsBack(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - pool := filepath.Join(dir, "pool") - lib := filepath.Join(pool, "media") - if err := os.MkdirAll(filepath.Join(lib, "old"), 0o755); err != nil { - t.Fatal(err) - } - a := accessJSON("radarr.media", lib, "read-write", false, "mesh-radarr:61001:read-write:container") - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - toUser(t) - done, _, err := TakeFolder(context.Background(), declare(t, a), "radarr.media", lib, "w-1", "op", noServices) - if err != nil { - t.Fatal(err) - } - <-done - film := filepath.Join(lib, "film") - if err := os.WriteFile(film, []byte("data"), 0o644); err != nil { - t.Fatal(err) - } - _ = unix.Removexattr(film, aclAccessXattr) - MountPoints = func() []string { return []string{"/", pool} } - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - if left := Leftovers(); len(left) != 1 || !strings.Contains(left[0].Why, "no longer at its folder") { - t.Fatalf("entries left on the data taken below are not said: %+v", left) - } - // The pool goes: the filesystem below holds the folder again, and the entries left there are taken out. - MountPoints = func() []string { return []string{"/"} } - toUser(t) - ReconcileKept(context.Background()) - if _, still := func() (uint16, bool) { - a, _, _ := readACLs(t, filepath.Join(lib, "old")) - return a.perm(aclUser, radarr) - }(); still { - t.Fatal("the entries left on the filesystem that came back were not taken out") - } - if left := Leftovers(); len(left) != 0 { - t.Fatalf("still said after they were taken out: %+v", left) - } -} - -// Review (third) M: a folder kept only because it was empty leaves nothing said when a disk mounts over it — its -// entries sit on an empty folder hidden under the mount and grant nothing on data. -func TestAFolderKeptEmptyLeavesNoLeftoverWhenADiskMountsOverIt(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - pool := filepath.Join(dir, "pool") - lib := filepath.Join(pool, "media") - if err := os.MkdirAll(lib, 0o755); err != nil { - t.Fatal(err) - } - a := accessJSON("radarr.media", lib, "read-write", false, "mesh-radarr:61001:read-write:container") - if _, _, err := applyAccesses(t, store.State{}, a); err != nil { - t.Fatal(err) - } - MountPoints = func() []string { return []string{"/", pool} } - report, _, err := applyAccesses(t, store.State{}, a) - if err != nil { - t.Fatal(err) - } - if left := Leftovers(); len(left) != 0 { - t.Fatalf("a folder kept while empty is said as leftover entries for ever: %+v", left) - } - if rec, _ := keptRecord(lib); len(rec.Left) != 0 { - t.Errorf("entries on the hidden empty folder are recorded: %+v", rec.Left) - } - if o := outcomeOf(report, "radarr.media"); !strings.Contains(o.Detail, "forgotten") { - t.Errorf("the forgetting is not said: %+v", o) - } -} - -// Review (third) low: a record written before it kept the accesses' ids is said too, under a name with no path. -func TestALeftoverWithNoAccessIdsIsSaidUnderAPathFreeName(t *testing.T) { - keeping(t, nil, nil) - path := "/srv/media/old" - _ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool { - f.Kept, f.Users = true, []store.KeptUser{{Identity: "mesh-radarr", UID: radarr, Use: "read-write", From: "container"}} - f.Act = &store.KeptAct{State: "waiting"} - return true - }) - left := Leftovers() - if len(left) != 1 || left[0].Module != "radarr" || strings.Contains(left[0].Why, path) || - strings.Contains(left[0].Access, "/") { - t.Fatalf("a record without ids is not said, path-free: %+v", left) - } -} - -// placedPool is a folder of a library on a pool the test says is mounted, with the pool's device name and filesystem -// id as the test sets them, taken under a warrant. -func placedPool(t *testing.T, source, fsid *string) (pool, lib, taken string) { - t.Helper() - dir := aclFolder(t) - pool = filepath.Join(dir, "pool") - lib = filepath.Join(pool, "media") - if err := os.MkdirAll(filepath.Join(lib, "old"), 0o755); err != nil { - t.Fatal(err) - } - keeping(t, []string{pool}, nil) - wasSource, wasFsid := MountSource, MountFsid - MountSource = func(point string) (string, string) { - if point == pool { - return *source, "ext4" - } - return "", "" - } - MountFsid = func(point string) string { - if point == pool { - return *fsid - } - return "" - } - t.Cleanup(func() { MountSource, MountFsid = wasSource, wasFsid }) - notTaken := accessJSON("radarr.media", lib, "read-write", false, "mesh-radarr:61001:read-write:container") - if _, _, err := applyAccesses(t, store.State{}, notTaken); err != nil { - t.Fatal(err) - } - toUser(t) - done, _, err := TakeFolder(context.Background(), declare(t, notTaken), "radarr.media", lib, "w-1", "op", noServices) - if err != nil { - t.Fatal(err) - } - <-done - taken = accessJSON("radarr.media", lib, "read-write", true, "mesh-radarr:61001:read-write:container") - if report, _, _ := applyAccesses(t, store.State{}, taken); outcomeOf(report, "radarr.media").Action == "waiting" { - t.Fatal("the folder taken waits on its own filesystem") - } - return pool, lib, taken -} - -// Review (third) low: another filesystem mounted at the same point is not the one the warrant was for. -func TestAnotherFilesystemAtTheSameMountPointIsNotTaken(t *testing.T) { - source, fsid := "/dev/sdb1", "0a0b0c0d01020304" - _, lib, taken := placedPool(t, &source, &fsid) - fsid = "1111222233334444" // another filesystem mounted at the same point - report, _, err := applyAccesses(t, store.State{}, taken) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action != "waiting" { - t.Fatalf("another filesystem at the same mount point was kept on the old warrant: %+v", o) - } - toUser(t) - if v := AccessVerdict(context.Background(), lib, true, true); v.Kind != VerdictWaitKeep { - t.Fatalf("the look judges it %s", v.Kind) - } -} - -// Review (fourth): the same filesystem whose device the kernel renamed across a reboot (/dev/sdb1 to /dev/sdc1, one -// filesystem id) is the same filesystem: still taken, still kept, and none of its entries walked away. -func TestADeviceRenamedIsTheSameFilesystem(t *testing.T) { - source, fsid := "/dev/sdb1", "0a0b0c0d01020304" - _, lib, taken := placedPool(t, &source, &fsid) - source = "/dev/sdc1" - report, _, err := applyAccesses(t, store.State{}, taken) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action == "waiting" { - t.Fatalf("a device renamed reads as another filesystem: %+v", o) - } - toUser(t) - ReconcileKept(context.Background()) - if got := effectiveOn(t, filepath.Join(lib, "old"), radarr); got != 7 { - t.Fatalf("the library's entries were walked away: the identity may %s", permWords(got)) - } - if left := Leftovers(); len(left) != 0 { - t.Fatalf("a device renamed leaves entries said as left: %+v", left) - } -} - -// Review L4: the controller's word that a folder was taken holds for the filesystem the warrant was carried out on; -// moved to another, it is not taken, the wait names the new filesystem, and nothing is walked. -func TestAMovedFolderIsNotTakenOnTheOldWarrant(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - pool := filepath.Join(dir, "pool") - lib := filepath.Join(pool, "media") - if err := os.MkdirAll(filepath.Join(lib, "old"), 0o755); err != nil { - t.Fatal(err) - } - notTaken := accessJSON("radarr.media", lib, "read-write", false, "mesh-radarr:61001:read-write:container") - if _, _, err := applyAccesses(t, store.State{}, notTaken); err != nil { - t.Fatal(err) - } - toUser(t) - done, _, err := TakeFolder(context.Background(), declare(t, notTaken), "radarr.media", lib, "w-1", "op", noServices) - if err != nil { - t.Fatal(err) - } - <-done - toUser(t) - before := AccessVerdict(context.Background(), lib, true, true) - film := filepath.Join(lib, "film") - if err := os.WriteFile(film, []byte("data"), 0o644); err != nil { - t.Fatal(err) - } - _ = unix.Removexattr(film, aclAccessXattr) - MountPoints = func() []string { return []string{"/", pool} } - taken := accessJSON("radarr.media", lib, "read-write", true, "mesh-radarr:61001:read-write:container") - report, _, err := applyAccesses(t, store.State{}, taken) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action != "waiting" { - t.Fatalf("a folder moved to another filesystem was kept on the old warrant: %+v", o) - } - // And on the next apply too, its record now on the new filesystem. - report, _, _ = applyAccesses(t, store.State{}, taken) - if o := outcomeOf(report, "radarr.media"); o.Action != "waiting" { - t.Fatalf("the second apply kept it on the old warrant: %+v", o) - } - toUser(t) - v := AccessVerdict(context.Background(), lib, true, true) - if v.Kind != VerdictWaitKeep || v.Placed == "" || v.Placed == before.Placed || strings.Contains(v.Placed, "/") { - t.Fatalf("the look does not ask again naming the new filesystem: %+v (before %+v)", v, before) - } - toUser(t) - ReconcileKept(context.Background()) - if got := effectiveOn(t, film, radarr); got != 0 { - t.Fatal("the data on the new filesystem was walked") - } -} - -// Review L5: a read that hangs holds one goroutine per path, whoever asks meanwhile. -func TestAHungReadIsSharedByEveryoneWhoAsks(t *testing.T) { - was := statBound - statBound = 20 * time.Millisecond - defer func() { statBound = was }() - release := make(chan struct{}) - defer close(release) - var started int32 - var mu sync.Mutex - hang := func(string) (os.FileInfo, error) { - mu.Lock() - started++ - mu.Unlock() - <-release - return nil, nil - } - for i := 0; i < 5; i++ { - if _, err := within("test", hang, "/a/dead/mount"); !errors.Is(err, errNoAnswer) { - t.Fatalf("a hung read answered: %v", err) - } - } - mu.Lock() - defer mu.Unlock() - if started != 1 { - t.Fatalf("%d reads of one path were started while the first hung", started) - } -} - -// Review (fourth): entries left on the filesystem the folder is on now are never walked away — merged back into -// the folder's users when it is kept there, held while it is not. -func TestEntriesLeftOnTheFilesystemTheFolderIsOnAreNeverWalkedAway(t *testing.T) { - source, fsid := "/dev/sdb1", "0a0b0c0d01020304" - pool, lib, _ := placedPool(t, &source, &fsid) - rec, _ := keptRecord(lib) - users := rec.Users - for _, kept := range []bool{false, true} { - _ = KeptFolders.Update(lib, func(f *store.KeptFolder, _ bool) bool { - f.Kept, f.Users = kept, nil - f.Left = []store.KeptLeft{{Mount: pool, Placed: f.Placed, Users: users}} - return true - }) - toUser(t) - ReconcileKept(context.Background()) - if got := effectiveOn(t, filepath.Join(lib, "old"), radarr); got != 7 { - t.Fatalf("kept=%v: entries on the folder's own filesystem were walked away", kept) - } - r, _ := keptRecord(lib) - if kept && (len(r.Left) != 0 || len(r.Users) != 1) { - t.Errorf("kept: not merged back into the folder's users: %+v", r) - } - if !kept && len(r.Left) != 1 { - t.Errorf("not kept: the entries' record was dropped: %+v", r) - } - } -} - -// Review (fourth): a TakenOn naming a mount point (an unmerged build's record) is not taken on whatever is mounted -// there now. -func TestATakenOnOfTheOldFormIsNotTaken(t *testing.T) { - dir := aclFolder(t) - keeping(t, nil, nil) - lib := filepath.Join(dir, "media") - if err := os.MkdirAll(filepath.Join(lib, "old"), 0o755); err != nil { - t.Fatal(err) - } - _ = KeptFolders.Update(lib, func(f *store.KeptFolder, _ bool) bool { - f.Mount, f.TakenOn = "/", "/" - return true - }) - toUser(t) - if v := AccessVerdict(context.Background(), lib, true, true); v.Kind != VerdictWaitKeep { - t.Fatalf("an old-form record is read as taken: %s", v.Kind) - } -} - -// poolNamed is a library on a pool the test says is mounted, whose UUID, filesystem id and mountinfo source are as -// the test sets them; the access declared, not taken. -func poolNamed(t *testing.T, uuid, fsid, source *string) (pool, lib, access string) { - t.Helper() - dir := aclFolder(t) - pool = filepath.Join(dir, "pool") - lib = filepath.Join(pool, "media") - if err := os.MkdirAll(filepath.Join(lib, "old"), 0o755); err != nil { - t.Fatal(err) - } - keeping(t, []string{pool}, nil) - wasUUID, wasFsid, wasSource := MountUUID, MountFsid, MountSource - MountUUID = func(p string) string { return pick(p == pool, *uuid) } - MountFsid = func(p string) string { return pick(p == pool, *fsid) } - MountSource = func(p string) (string, string) { return pick(p == pool, *source), pick(p == pool, "nfs4") } - t.Cleanup(func() { MountUUID, MountFsid, MountSource = wasUUID, wasFsid, wasSource }) - return pool, lib, accessJSON("radarr.media", lib, "read-write", false, "mesh-radarr:61001:read-write:container") -} - -func pick(yes bool, v string) string { - if yes { - return v - } - return "x" -} - -// takeIt takes the folder of access under a warrant, and says it is kept. -func takeIt(t *testing.T, lib, access string) { - t.Helper() - if _, _, err := applyAccesses(t, store.State{}, access); err != nil { - t.Fatal(err) - } - toUser(t) - done, _, err := TakeFolder(context.Background(), declare(t, access), "radarr.media", lib, "w-1", "op", noServices) - if err != nil { - t.Fatal(err) - } - <-done -} - -// Review (fifth) M: an NFS export (f_fsid 0, no UUID) is named by its source; another export at the same mount -// point is another filesystem, not taken. -func TestAnNFSExportIsNamedByItsSource(t *testing.T) { - uuid, fsid, source := "", "0000000000000000", "nas:/export/media" - _, lib, access := poolNamed(t, &uuid, &fsid, &source) - takeIt(t, lib, access) - taken := strings.Replace(access, `"taken":false`, `"taken":true`, 1) - if report, _, _ := applyAccesses(t, store.State{}, taken); outcomeOf(report, "radarr.media").Action == "waiting" { - t.Fatal("the export taken waits on its own filesystem") - } - source = "other-nas:/export/media" - report, _, err := applyAccesses(t, store.State{}, taken) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action != "waiting" { - t.Fatalf("another export at the same mount point was kept on the old warrant: %+v", o) - } -} - -// Review (fifth) M: a filesystem that cannot be named is never kept, nor taken: it fails closed, and waits. -func TestAFilesystemThatCannotBeNamedIsNeverKept(t *testing.T) { - uuid, fsid, source := "", "0000000000000000", "" - _, lib, access := poolNamed(t, &uuid, &fsid, &source) - if err := os.RemoveAll(filepath.Join(lib, "old")); err != nil { // empty: it would be kept without asking - t.Fatal(err) - } - report, _, err := applyAccesses(t, store.State{}, access) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action != "waiting" { - t.Fatalf("a folder on a filesystem that cannot be named was kept: %+v", o) - } - if got := effectiveOn(t, lib, radarr); got != 0 { - t.Fatal("an entry was added on a filesystem that cannot be named") - } - toUser(t) - if _, _, err := TakeFolder(context.Background(), declare(t, access), "radarr.media", lib, "w", "op", noServices); err == nil { - t.Fatal("the take act ran on a filesystem that cannot be named") - } -} - -// Review (fifth): xfs's f_fsid follows the device number; its UUID names it first, so a renumbered device is the same -// filesystem. -func TestAFilesystemsUUIDNamesItBeforeItsFsid(t *testing.T) { - uuid, fsid, source := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33", "0000080100000000", "/dev/sdb1" - _, lib, access := poolNamed(t, &uuid, &fsid, &source) - takeIt(t, lib, access) - taken := strings.Replace(access, `"taken":false`, `"taken":true`, 1) - fsid, source = "0000082100000000", "/dev/sdc1" - report, _, err := applyAccesses(t, store.State{}, taken) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action == "waiting" { - t.Fatalf("a renumbered device with one UUID reads as another filesystem: %+v", o) - } - toUser(t) - ReconcileKept(context.Background()) - if got := effectiveOn(t, filepath.Join(lib, "old"), radarr); got != 7 { - t.Fatalf("the library's entries were walked away: %s", permWords(got)) - } -} - -// Review (fifth): entries left on a filesystem that never comes back are forgotten after LeftFor, said once, and no -// longer said unhealthy. -func TestEntriesLeftOnAFilesystemThatNeverReturnsAreForgotten(t *testing.T) { - keeping(t, nil, nil) - path := "/srv/media/gone" - _ = KeptFolders.Update(path, func(f *store.KeptFolder, _ bool) bool { - f.IDs = []string{"radarr.media"} - f.Left = []store.KeptLeft{{Mount: "/srv/media", Placed: "fs-000000000000", Fs: "uuid:gone", - Since: time.Now().Add(-LeftFor - time.Hour), - Users: []store.KeptUser{{Identity: "mesh-radarr", UID: radarr, Use: "read-write", From: "container"}}}} - return true - }) - if len(Leftovers()) != 1 { - t.Fatal("the leftover is not said before it expires") - } - toUser(t) - said := ReconcileKept(context.Background()) - if left := Leftovers(); len(left) != 0 { - t.Fatalf("a leftover on a filesystem gone for longer than %s is still said: %+v", LeftFor, left) - } - if !strings.Contains(strings.Join(said, "\n"), "forgotten") { - t.Errorf("the forgetting is not said: %v", said) - } -} - -// Review (fifth) low: naming a filesystem whose read hangs holds one read per mount, whoever asks meanwhile. -func TestNamingAHungFilesystemIsOneReadPerMount(t *testing.T) { - was, wasBound := MountUUID, statBound - statBound = 20 * time.Millisecond - release := make(chan struct{}) - var mu sync.Mutex - started := 0 - MountUUID = func(string) string { - mu.Lock() - started++ - mu.Unlock() - <-release - return "" - } - defer func() { close(release); flightsDone(t); MountUUID, statBound = was, wasBound }() - for i := 0; i < 5; i++ { - if id := PlacedID("/a/dead/mount"); id != "" { - t.Fatalf("a filesystem that does not answer is named %s", id) - } - } - mu.Lock() - defer mu.Unlock() - if started != 1 { - t.Fatalf("%d reads of one mount were started while the first hung", started) - } -} - -// Review (sixth): a name read that does not answer is unknown, never a move — a folder kept while empty and filled -// since is not asked about again, a warranted one is not walked again, and nothing of either record is reset. -func TestAFilesystemNotNamedThisTimeIsNeverAMove(t *testing.T) { - uuid, fsid, source := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33", "0000080100000000", "/dev/sdb1" - _, lib, access := poolNamed(t, &uuid, &fsid, &source) - // The name's reads hang past their bound while hanging is set; installed once, before anything reads it. - var hanging atomic.Bool - release := make(chan struct{}) - named := MountUUID - MountUUID = func(p string) string { - if hanging.Load() { - <-release - } - return named(p) - } - takeIt(t, lib, access) - taken := strings.Replace(access, `"taken":false`, `"taken":true`, 1) - before, _ := keptRecord(lib) - - wasBound := statBound - statBound = 20 * time.Millisecond - hanging.Store(true) - report, _, err := applyAccesses(t, store.State{}, taken) - hanging.Store(false) - close(release) - flightsDone(t) - statBound = wasBound - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action != "waiting" || !strings.Contains(o.Detail, "could not be named") { - t.Fatalf("a folder whose filesystem was not named this time is not said so: %+v", o) - } - after, _ := keptRecord(lib) - if !after.Kept || after.Placed != before.Placed || after.TakenOn != before.TakenOn || len(after.Left) != 0 || - !sameUsers(after.Users, before.Users) { - t.Fatalf("the record was reset by a name not read:\nbefore %+v\nafter %+v", before, after) - } - - // Named again: kept as it was, with nothing asked and nothing walked. - waitFor(t, "the hung read returned", func() bool { return PlacedID(filepath.Dir(lib)) == before.Placed }) - report, _, err = applyAccesses(t, store.State{}, taken) - if err != nil { - t.Fatal(err) - } - if o := outcomeOf(report, "radarr.media"); o.Action == "waiting" { - t.Fatalf("named again, the folder asks again: %+v", o) - } - if r, _ := keptRecord(lib); r.Act == nil || r.Act.Act != "take" { - t.Errorf("the folder was walked again: %+v", r.Act) - } -} - -// flightsDone waits until no read of a hung test is in flight: it goes on reading the stand-ins after its release. -func flightsDone(t *testing.T) { - t.Helper() - waitFor(t, "the hung reads returned", func() bool { - inFlight.Lock() - defer inFlight.Unlock() - return len(inFlight.reads) == 0 - }) -} - -// userDirs are the folders the running test made, which toUser gives an unprivileged owner when it runs as root. -var userDirs []string - -// userDir is a t.TempDir that toUser gives an unprivileged owner when the test runs as root (the build seat runs -// merge-check as root): root's data is never kept (ADR 0325 §12), and a test's own folders stand for an operator's. -func userDir(t *testing.T) string { - t.Helper() - dir := t.TempDir() - userDirs = append(userDirs, dir) - t.Cleanup(func() { userDirs = nil }) - return dir -} - -// unprivileged is the owner toUser gives a test's folders: nobody. -const unprivileged = 65534 - -// toUser gives everything under the running test's own folders to an unprivileged owner when the test runs as -// root, so they are judged as an operator's data is; as anyone else it changes nothing. -func toUser(t *testing.T) { - t.Helper() - if os.Geteuid() != 0 { - return - } - for _, dir := range userDirs { - err := filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error { - if err != nil { - return err - } - return os.Lchown(p, unprivileged, unprivileged) - }) - if err != nil { - t.Fatalf("giving the test's folders to an unprivileged owner: %v", err) - } - } -} diff --git a/internal/apply/library.go b/internal/apply/library.go new file mode 100644 index 0000000..db0ec1e --- /dev/null +++ b/internal/apply/library.go @@ -0,0 +1,434 @@ +package apply + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" + "github.com/novox/mesh-host/internal/system" +) + +// A library is kept usable by its group (novox/hq ADR 0328, issue 567). +// +// A library is a named tree of shared data with one home (§1). On its home the engine keeps its root and every +// sub-path its modules ask for — those folders themselves, never what is in them — in the library's group with +// group read, write and enter and the setgid bit, so what is made beneath keeps the group (§6); a missing sub-path +// is made with the root's owner and the library's group (§9). The owner, the owner's bits and others' bits are +// never changed. What the root already holds joins only on the operator's word (§7): until then the library waits +// with reason keep and nothing under it is walked. A root that is absent, or whose disk is not there, makes +// nothing, holds every container that mounts the library, and waits with reason disk (§9). Off its home a library is +// a mount the mounts module makes: nothing is kept there, and a mount that is not there holds the containers too. +// +// **When a root is the mesh's to keep without asking.** When it holds nothing yet, or is already in the library's +// group with group write and setgid (kept so before, by hand or by the mesh). Every other root waits for the take +// act, which walks it whole (TakeLibrary). Kept, it stays kept on the filesystem it was kept on — named by UUID, +// filesystem id or source (PlacedID) — and another filesystem at its root is judged afresh. + +// libraryRecords is where the engine keeps what it knows of each library it is home to: the file beside the state +// in the engine, memory in a test. +type libraryRecords interface { + All() (map[string]store.LibraryRecord, error) + Update(name string, change func(r *store.LibraryRecord, had bool) bool) error +} + +// Libraries is the engine's record of the libraries it is home to. The engine sets the file beside its state. +var Libraries libraryRecords = NewLibraryMemory() + +type libraryMemory struct { + mu sync.Mutex + m map[string]store.LibraryRecord +} + +// NewLibraryMemory is an empty record held in memory: a test's, or a one-shot apply's. +func NewLibraryMemory() libraryRecords { return &libraryMemory{m: map[string]store.LibraryRecord{}} } + +func (k *libraryMemory) All() (map[string]store.LibraryRecord, error) { + k.mu.Lock() + defer k.mu.Unlock() + out := map[string]store.LibraryRecord{} + for n, r := range k.m { + out[n] = r + } + return out, nil +} + +func (k *libraryMemory) Update(name string, change func(r *store.LibraryRecord, had bool) bool) error { + k.mu.Lock() + defer k.mu.Unlock() + r, had := k.m[name] + r.Library = name + if change(&r, had) { + k.m[name] = r + } else { + delete(k.m, name) + } + return nil +} + +// libraryRecord is the record of one library, and whether there is one. A record that cannot be read is none: the +// library is judged afresh, which waits rather than keeps. +func libraryRecord(name string) (store.LibraryRecord, bool) { + all, err := Libraries.All() + if err != nil { + return store.LibraryRecord{}, false + } + r, had := all[name] + return r, had +} + +// libraryLocks is one lock per library, held by every change to its root and every walk under it. +var libraryLocks sync.Map + +func libraryLock(name string) *sync.Mutex { + l, _ := libraryLocks.LoadOrStore(name, &sync.Mutex{}) + return l.(*sync.Mutex) +} + +// LibraryAccess is the access a library's wait and its take act name. +func LibraryAccess(name string) string { return declaration.LibraryAccessPrefix + name } + +// KeepWords and DiskWords are what a library's wait says the operator gives (ADR 0328 §8, §9): never a path. +func KeepWords(name string) string { return "let the mesh keep the library " + name } +func DiskWords(name string) string { return "mount the disk that holds the library " + name } + +// The judgements of a library. +const ( + // libraryKeep is a library the mesh keeps: taken, empty when first seen, or found in shape. + libraryKeep = "keep" + // libraryWaitKeep is a root that holds data never taken: it waits for the operator's word. + libraryWaitKeep = "wait-keep" + // libraryWaitDisk is a root that is absent, or whose disk (or, off its home, whose mount) is not there. + libraryWaitDisk = "wait-disk" + // libraryUnknown is a root whose filesystem could not be named now: left as it is until it is. + libraryUnknown = "unknown" + // libraryMounted is a library off its home whose mount is there: nothing to keep. + libraryMounted = "mounted" +) + +// libraryVerdict is the judgement of one library, with what it read. +type libraryVerdict struct { + kind string + // how is why a kept library is the mesh's: store.KeptByWarrant, store.KeptEmpty or store.KeptInShape. + how string + // mount is the mount point that holds the root; placed and fs name its filesystem. + mount, placed, fs string + // moved is that another filesystem than the one the library was kept on is at its root. + moved bool + why string + err error +} + +// assessLibrary judges a library as an apply, a look and the take act all do (ADR 0328 §7, §9, §15): its group +// first, then its disk, then whether the mesh may keep it. Its words carry no path: they reach the operator's +// channel. Reads only. +func assessLibrary(ctx context.Context, l declaration.Library, rec store.LibraryRecord) libraryVerdict { + if why := rootGroup(l.GID); why != "" { + return libraryVerdict{err: fmt.Errorf("its group is %s, and a library's group is never a root group "+ + "(novox/hq ADR 0328 §15); nothing was made or changed", why)} + } + // Nowhere the machine's own trees are, as for an access (novox/hq issue 339): a library at /etc would be + // the machine's configuration given a group. + if err := refusePath(l.Root, placingAccess, "", ""); err != nil { + return libraryVerdict{err: errors.New(pathless(err) + "; nothing was made or changed")} + } + if !l.Home { + // Off its home the mounts module mounts it: there when its root is a mount point now. + for _, p := range MountPoints() { + if p == l.Root { + return libraryVerdict{kind: libraryMounted, mount: p} + } + } + return libraryVerdict{kind: libraryWaitDisk, why: "its mount is not there"} + } + info, err := lstatWithin(l.Root) + switch { + case errors.Is(err, os.ErrNotExist): + return libraryVerdict{kind: libraryWaitDisk, why: "its root is not there"} + case errors.Is(err, errNoAnswer): + return libraryVerdict{kind: libraryUnknown, why: "its root does not answer: a read of it did not return " + + "within its bound"} + case err != nil: + return libraryVerdict{err: errors.New("its root cannot be read: " + pathless(err))} + case !info.IsDir(): + return libraryVerdict{err: errors.New("its root is not a folder (a file, or a link), and a library's root " + + "is a folder the engine reaches through no link")} + } + pl := placedOn(ctx, l.Root, l.Root, rec.Mount, recordedLibraryMounts(l.Library)) + if pl.Away { + return libraryVerdict{kind: libraryWaitDisk, mount: pl.Mount, why: "the disk that holds its root is not there"} + } + fs := filesystemKey(pl.Mount) + here := placedFrom(pl.Mount, fs) + // **A filesystem that cannot be named now is unknown, never a move**: the library holds what it is — nothing + // walked, nothing newly kept, nothing reset — and is judged again when it is named. One never kept is so never + // kept: it fails closed. + if here == "" { + return libraryVerdict{kind: libraryUnknown, mount: pl.Mount, + why: "its filesystem could not be named this time (no UUID, filesystem id or source answered)"} + } + moved := pl.Moved || (rec.Fs != "" && rec.Mount == pl.Mount && rec.Placed != here) + v := libraryVerdict{mount: pl.Mount, placed: here, fs: fs, moved: moved} + // The controller's word that the library was taken holds for the filesystem the warrant was carried out on. + takenHere := l.Taken && (rec.KeptOn == here || (rec.KeptOn == "" && !moved)) + switch { + case rec.Kept && rec.KeptOn == here && !moved: + v.kind, v.how = libraryKeep, rec.How + return v + case takenHere: + v.kind, v.how = libraryKeep, store.KeptByWarrant + return v + } + facts, err := readFolder(l.Root) + if err != nil { + return libraryVerdict{err: errors.New("its root cannot be read: " + pathless(err))} + } + switch { + case facts.Empty: + v.kind, v.how = libraryKeep, store.KeptEmpty + case int(facts.GID) == l.GID && facts.Mode&0o2070 == 0o2070: + v.kind, v.how = libraryKeep, store.KeptInShape + default: + // What is there holds data the mesh never took — a disk mounted over a root kept while it was empty below + // it included (a move): it waits for the operator's word, and nothing under it is walked. + v.kind = libraryWaitKeep + } + return v +} + +// recordedLibraryMounts is every mount point recorded for another library: where a disk was once seen. +func recordedLibraryMounts(except string) []string { + all, err := Libraries.All() + if err != nil { + return nil + } + var out []string + for name, r := range all { + if name != except && r.Mount != "" && r.Mount != "/" { + out = append(out, r.Mount) + } + } + return out +} + +// applyLibrary is one library, applied (ADR 0328 §6, §7, §9): kept on its home when the mesh may keep it, waiting +// otherwise, and failing for a root group. Nothing is changed on the root but the group, group rwx and setgid of +// the root and its requested sub-paths, and the sub-paths made. +func applyLibrary(ctx context.Context, l declaration.Library) (Outcome, libraryVerdict, error) { + out := Outcome{ID: LibraryAccess(l.Library), Type: "library", Target: l.Root} + lock := libraryLock(l.Library) + lock.Lock() + defer lock.Unlock() + rec, _ := libraryRecord(l.Library) + v := assessLibrary(ctx, l, rec) + if v.err != nil { + return out, v, v.err + } + switch v.kind { + case libraryMounted: + out.Action, out.Detail = "unchanged", "not its home: mounted, and nothing is kept here" + return out, v, nil + case libraryWaitDisk: + out.Action = "waiting" + out.Detail = v.why + ": nothing is made, and no container that mounts it is created until it is there " + + "(novox/hq ADR 0328 §9); waits for the operator to " + DiskWords(l.Library) + return out, v, nil + case libraryUnknown: + out.Action = "waiting" + out.Detail = v.why + ": the library is left exactly as it is, and judged again on the next apply" + return out, v, nil + case libraryWaitKeep: + _ = Libraries.Update(l.Library, func(r *store.LibraryRecord, had bool) bool { + if !had || v.moved { + r.Root, r.Mount, r.Placed, r.Fs = l.Root, v.mount, v.placed, v.fs + } + if v.moved { + // Kept on another filesystem: what was kept there no longer holds here. + r.Kept, r.How, r.Cursor = false, "", nil + } + return true + }) + out.Action = "waiting" + out.Detail = "its root holds data the mesh has not taken: nothing is changed under it until the operator's " + + "warrant (novox/hq ADR 0328 §7); waits for the operator to " + KeepWords(l.Library) + return out, v, nil + } + changed, made, err := keepFolders(l.Root, l.Paths, l.GID) + if err != nil { + return out, v, err + } + _ = Libraries.Update(l.Library, func(r *store.LibraryRecord, _ bool) bool { + if !r.Kept || r.KeptOn != v.placed { + r.How, r.At = v.how, time.Now().UTC() + } + r.Root, r.Mount, r.Placed, r.Fs, r.GID = l.Root, v.mount, v.placed, v.fs, l.GID + r.Kept, r.KeptOn = true, v.placed + return true + }) + out.Action = "unchanged" + if changed+made > 0 { + out.Action = "updated" + } + out.Detail = fmt.Sprintf("kept in %s: its root and %d sub-path(s) with group rwx and setgid, %d made; owners "+ + "and everyone else's rights unchanged (novox/hq ADR 0328 §6)", l.Group, len(l.Paths), made) + return out, v, nil +} + +// heldRoots is every library root whose containers are held: its disk, or its mount, is not there (§9). +func heldRoots(verdicts map[string]libraryVerdict, libs []declaration.Library) map[string]string { + out := map[string]string{} + for _, l := range libs { + if v, ok := verdicts[l.Library]; ok && v.kind == libraryWaitDisk { + out[l.Root] = l.Library + } + } + return out +} + +// mountsHeld is the library a container waits for: one whose root it mounts, or mounts below, is held. +func mountsHeld(c *declaration.Container, held map[string]string) string { + for _, v := range c.Volumes { + src := mountSource(v) + if !strings.HasPrefix(src, "/") { + continue + } + for root, name := range held { + if atOrBelow(filepath.Clean(src), root) { + return name + } + } + } + return "" +} + +// WaitingOnLibrary is every container of the declaration that waits for a library whose disk or mount is not +// there, read now: not created by the apply, so not judged alive or dead — the library says why. +func WaitingOnLibrary(d *declaration.Declaration) map[string]bool { + out := map[string]bool{} + if d == nil || len(d.Libraries) == 0 { + return out + } + verdicts := map[string]libraryVerdict{} + for _, l := range d.Libraries { + rec, _ := libraryRecord(l.Library) + verdicts[l.Library] = assessLibrary(context.Background(), l, rec) + } + held := heldRoots(verdicts, d.Libraries) + for _, r := range d.Resources { + if c, ok := r.(*declaration.Container); ok && mountsHeld(c, held) != "" { + out[c.ID] = true + } + } + return out +} + +// The judgements of a library as a look says them. +const ( + VerdictKeep = "keep" + VerdictMounted = "mounted" + VerdictWaitKeep = "wait-keep" + VerdictWaitDisk = "wait-disk" + VerdictUnknown = "unknown" + VerdictBroken = "broken" +) + +// LibraryJudgement is a look's judgement of a library, and why, in words with no path; Placed names its root's +// filesystem for a keep wait, with no path (PlacedID). +type LibraryJudgement struct { + Kind, Why, Placed string + // Act is the last walk under its root. + Act *store.KeptAct +} + +// JudgeLibrary judges a library for a look, read only. +func JudgeLibrary(ctx context.Context, l declaration.Library) LibraryJudgement { + rec, _ := libraryRecord(l.Library) + v := assessLibrary(ctx, l, rec) + j := LibraryJudgement{Act: rec.Act} + switch { + case v.err != nil: + j.Kind, j.Why = VerdictBroken, pathless(v.err) + case v.kind == libraryWaitKeep: + j.Kind, j.Placed = VerdictWaitKeep, v.placed + case v.kind == libraryWaitDisk: + j.Kind, j.Why = VerdictWaitDisk, v.why + case v.kind == libraryUnknown: + j.Kind, j.Why = VerdictUnknown, v.why + case v.kind == libraryMounted: + j.Kind = VerdictMounted + default: + j.Kind = VerdictKeep + } + return j +} + +// joinRunner puts a library's tool runner's account in its group (ADR 0328 §11), as ADR 0252 puts an account in a +// group: recorded only when the mesh put it there, so one it was in before is never taken back. Whether it moved. +func joinRunner(ctx context.Context, sys system.System, l declaration.Library, known *store.State, + run Runner) (bool, string, error) { + if l.Runner == "" { + return false, "", nil + } + in, err := system.GroupsOf(ctx, system.Runner(run), l.Runner) + if err != nil { + return false, "", fmt.Errorf("the tool runner's account %s could not be read: %w", l.Runner, err) + } + if setOf(in)[l.Group] { + return false, "", nil + } + if err := sys.AddUserToGroup(ctx, system.Runner(run), l.Runner, l.Group); err != nil { + return false, "", err + } + for _, m := range known.Members { + if m.Account == l.Runner && m.Group == l.Group { + return true, fmt.Sprintf("put the tool runner's %s back in %s", l.Runner, l.Group), nil + } + } + known.Members = append(known.Members, store.Membership{Account: l.Runner, Group: l.Group, Library: l.Library}) + return true, fmt.Sprintf("put the tool runner's %s in %s; it holds it once restarted", l.Runner, l.Group), nil +} + +// leaveRunners takes every tool runner's account the mesh put in a library's group out of it once no declared +// library puts it there and no declared user resource asks for it (ADR 0252 §3). Never fatal: what could not be +// left stays recorded, said, for the next apply. +func leaveRunners(ctx context.Context, sys system.System, libs []declaration.Library, known *store.State, run Runner, + wanted Wanted) []Outcome { + declared := map[string]bool{} + for _, l := range libs { + if l.Runner != "" { + declared[l.Runner+" "+l.Group] = true + } + } + var out []Outcome + var keep []store.Membership + for _, m := range known.Members { + if declared[m.Account+" "+m.Group] { + keep = append(keep, m) + continue + } + o := Outcome{ID: LibraryAccess(m.Library) + " runner", Type: "library", Target: m.Account} + if others := wanted.othersAsk(m.Account, m.Group, ""); len(others) > 0 { + o.Action, o.Detail = "forgotten", fmt.Sprintf("left %s in %s, which %s asks for", m.Account, m.Group, + strings.Join(others, ", ")) + out = append(out, o) + continue + } + gone, why := leaveGroup(ctx, sys, m.Account, m.Group, run) + o.Action, o.Detail = "restored", m.Account+" "+why + if !gone { + o.Action = "waiting" + keep = append(keep, m) + } + out = append(out, o) + } + known.Members = keep + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} diff --git a/internal/apply/library_linux.go b/internal/apply/library_linux.go new file mode 100644 index 0000000..d8bc00f --- /dev/null +++ b/internal/apply/library_linux.go @@ -0,0 +1,155 @@ +//go:build linux + +package apply + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// The take act on a library, and the walk that goes on with one stopped at its bound (novox/hq ADR 0328 §7). + +// actRecord is a walk's counts as the record keeps them. +func actRecord(warrant, by, on string, started time.Time, w *treeWalk, err error) *store.KeptAct { + c := w.counts + rec := &store.KeptAct{Act: "take", Warrant: warrant, By: by, On: on, State: "done", Started: started, + Ended: time.Now().UTC(), Folders: c.Folders, Files: c.Files, Kept: c.Kept, Links: c.Links, Other: c.Other, + Mounts: c.Mounts, Shared: c.Shared} + switch { + case errors.Is(err, ErrWalkBound): + rec.State, rec.Why = "stopped", "it met its bound of entries or time; it goes on from where it stopped" + case err != nil: + rec.State, rec.Why = "failed", err.Error() + case w.Failed != "": + rec.State = "failed" + rec.Why = fmt.Sprintf("%d entries could not be given the group: %s; walked again on the next look", c.Failed, + w.Failed) + } + return rec +} + +// TakeLibrary is the take act (ADR 0328 §7): on the operator's warrant, the library's group, group read and write, +// and on folders group enter and setgid, set on its root and everything under it — no link followed, no other +// filesystem entered, the owner and the owner's and others' bits unchanged, a file with another name elsewhere left +// as it is. It judges the ask against the declaration last applied and the root as it is, and starts the walk: what +// it answers at once, and a channel closed when the walk is through or stopped at its bound. Refused, nothing is +// changed. +func TakeLibrary(ctx context.Context, d *declaration.Declaration, access, path, warrant, by string) (<-chan struct{}, + string, error) { + if d == nil { + return nil, "", errors.New("this engine has applied no declaration, and takes no library it was not told of") + } + var lib *declaration.Library + for i := range d.Libraries { + if LibraryAccess(d.Libraries[i].Library) == access { + lib = &d.Libraries[i] + } + } + switch { + case lib == nil: + return nil, "", fmt.Errorf("the access %s names no library declared on this machine", access) + case !lib.Home: + return nil, "", fmt.Errorf("this machine is not the home of the library %s, and a library is taken on its "+ + "home", lib.Library) + case lib.Root != path: + return nil, "", fmt.Errorf("the library %s is declared at another root than the one asked", lib.Library) + } + lock := libraryLock(lib.Library) + if !lock.TryLock() { + return nil, "", errors.New("a walk of the library is running; asked again once it is through, the act goes on") + } + rec, _ := libraryRecord(lib.Library) + v := assessLibrary(ctx, *lib, rec) + var refused error + switch { + case v.err != nil: + refused = v.err + case v.kind == libraryWaitDisk: + refused = errors.New(v.why) + case v.kind == libraryUnknown: + refused = errors.New(v.why + ", so a warrant could name no filesystem it was given for") + } + if refused != nil { + lock.Unlock() + return nil, "", refused + } + if _, _, err := keepFolders(lib.Root, lib.Paths, lib.GID); err != nil { + lock.Unlock() + return nil, "", err + } + started := time.Now().UTC() + _ = Libraries.Update(lib.Library, func(r *store.LibraryRecord, _ bool) bool { + r.Root, r.Mount, r.Placed, r.Fs, r.GID = lib.Root, v.mount, v.placed, v.fs, lib.GID + r.Kept, r.KeptOn, r.How, r.At = true, v.placed, store.KeptByWarrant, started + r.Warrant, r.By, r.Cursor = warrant, by, nil + r.Act = &store.KeptAct{Act: "take", Warrant: warrant, By: by, On: v.placed, State: "running", Started: started} + return true + }) + done := make(chan struct{}) + go func() { + defer close(done) + defer lock.Unlock() + walkLibrary(lib.Library, lib.Root, lib.GID, warrant, by, v.placed, nil) + }() + return done, fmt.Sprintf("the take act started under the warrant %s: %s's group, group read and write, and "+ + "setgid on its folders are set on everything under its root, following no link and entering no other "+ + "filesystem", warrant, lib.Library), nil +} + +// walkLibrary walks a library's root from resume with its group, and records what it did: a walk stopped at its +// bound keeps its cursor for the next. The caller holds the library's lock. +func walkLibrary(name, root string, gid int, warrant, by, on string, resume []string) { + started := time.Now().UTC() + w, err := walkRoot(root, ActLimits, machineStats, resume, groupVisit(gid)) + rec := actRecord(warrant, by, on, started, w, err) + _ = Libraries.Update(name, func(r *store.LibraryRecord, _ bool) bool { + r.Act = rec + r.Cursor = nil + if errors.Is(err, ErrWalkBound) { + r.Cursor = w.Cursor + } + return true + }) +} + +// ReconcileLibraries goes on with every take act that stopped at its bound or left entries it could not change, +// from where it stopped — only while the library is still kept on the filesystem it was taken on (judged as an apply +// judges it). What it did, in words, for the engine to say. +func ReconcileLibraries(ctx context.Context, d *declaration.Declaration) []string { + if d == nil { + return nil + } + var said []string + for _, l := range d.Libraries { + if ctx.Err() != nil { + break + } + if !l.Home { + continue + } + rec, _ := libraryRecord(l.Library) + if rec.Act == nil || (rec.Act.State != "stopped" && rec.Act.State != "failed") || rec.How != store.KeptByWarrant { + continue + } + lock := libraryLock(l.Library) + if !lock.TryLock() { + continue + } + v := assessLibrary(ctx, l, rec) + if v.err != nil || v.kind != libraryKeep || v.placed != rec.KeptOn || v.moved { + lock.Unlock() + continue + } + walkLibrary(l.Library, l.Root, l.GID, rec.Warrant, rec.By, rec.KeptOn, rec.Cursor) + lock.Unlock() + after, _ := libraryRecord(l.Library) + said = append(said, fmt.Sprintf("the library %s: the take act went on, %s (%d folders and %d files changed)", + l.Library, after.Act.State, after.Act.Folders, after.Act.Files)) + } + return said +} diff --git a/internal/apply/library_test.go b/internal/apply/library_test.go new file mode 100644 index 0000000..46ab9a1 --- /dev/null +++ b/internal/apply/library_test.go @@ -0,0 +1,643 @@ +//go:build linux + +package apply + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/sys/unix" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// The engine's half of ADR 0328's checks (novox/hq ADR 0328, "How it is checked", issue 567). The engine runs as +// root; a test may not, and a non-root test gives its folders a group it is in itself. + +// testGID is a group the test may give its folders: any number as root, else one of its own added groups that is +// no root group's. +func testGID(t *testing.T) int { + t.Helper() + if os.Geteuid() == 0 { + return 61000 + } + groups, _ := os.Getgroups() + for _, g := range groups { + if g != os.Getgid() && g != 0 && rootGroup(g) == "" { + return g + } + } + t.Skip("the test runs as no root and in no group of its own beside its primary one, so it can give no " + + "folder a library's group") + return 0 +} + +// libraries has the test keep its own record of libraries and judge mounts as the test says. +func libraries(t *testing.T, points, targets []string) { + t.Helper() + was, wasPoints, wasTargets := Libraries, MountPoints, MountTargets + Libraries = NewLibraryMemory() + forgetMountHistory() + MountPoints = func() []string { return append([]string{"/"}, points...) } + MountTargets = func(context.Context) []string { return targets } + t.Cleanup(func() { + Libraries, MountPoints, MountTargets = was, wasPoints, wasTargets + forgetMountHistory() + }) +} + +func forgetMountHistory() { + mountHistory.Lock() + mountHistory.seen = map[string]bool{} + mountHistory.Unlock() +} + +// libraryDecl is a declaration with one library, its group, and resources. +func libraryDecl(t *testing.T, lib string, resources ...string) *declaration.Declaration { + t.Helper() + var l declaration.Library + if err := jsonInto(lib, &l); err != nil { + t.Fatal(err) + } + raw := fmt.Sprintf(`{"declaration":1,"groups":[{"name":%q,"gid":%d}],"libraries":[%s],"resources":[%s]}`, + l.Group, l.GID, lib, strings.Join(resources, ",")) + if len(resources) == 0 { + raw = strings.Replace(raw, `"resources":[]`, `"owns_nothing":true,"resources":[]`, 1) + } + d, err := declaration.Parse([]byte(raw)) + if err != nil { + t.Fatal(err) + } + return d +} + +// libJSON is a library's declaration. +func libJSON(name, root string, gid int, home, taken bool, paths ...string) string { + quoted := make([]string, len(paths)) + for i, p := range paths { + quoted[i] = strconv.Quote(p) + } + return fmt.Sprintf(`{"library":%q,"root":%q,"group":"mesh-%s","gid":%d,"home":%v,"taken":%v,"paths":[%s]}`, + name, root, name, gid, home, taken, strings.Join(quoted, ",")) +} + +// applyLib applies d with a runner that answers the group database for the library group (already there) and runs +// nothing else. +func applyLib(t *testing.T, d *declaration.Declaration, known store.State) (Report, store.State, error) { + t.Helper() + run := func(_ context.Context, name string, args ...string) (string, error) { + if name == "getent" && args[0] == "group" { + for _, g := range d.Groups { + if args[1] == g.Name || args[1] == strconv.Itoa(g.GID) { + return fmt.Sprintf("%s:x:%d:\n", g.Name, g.GID), nil + } + } + return "", errors.New("getent exited 2") + } + return "", nil + } + return Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil) +} + +func statOf(t *testing.T, path string) unix.Stat_t { + t.Helper() + var st unix.Stat_t + if err := unix.Lstat(path, &st); err != nil { + t.Fatal(err) + } + return st +} + +// Row 6: the root and every requested sub-path kept in the library's group with group rwx and setgid — those +// folders alone, never what is in them; owner, owner's and others' bits unchanged. +func TestALibraryIsKeptInItsGroup(t *testing.T) { + gid := testGID(t) + libraries(t, nil, nil) + root := filepath.Join(t.TempDir(), "media") + movies := filepath.Join(root, "movies") + if err := os.MkdirAll(movies, 0o750); err != nil { + t.Fatal(err) + } + for _, p := range []string{root, movies} { + if err := os.Chmod(p, 0o705); err != nil { + t.Fatal(err) + } + } + film := filepath.Join(movies, "film") + if err := os.WriteFile(film, []byte("data"), 0o640); err != nil { + t.Fatal(err) + } + // In shape at its root (the operator's own hand, or the mesh's before): kept without asking. + if err := os.Chown(root, -1, gid); err != nil { + t.Fatal(err) + } + if err := unix.Chmod(root, 0o2775); err != nil { + t.Fatal(err) + } + before := statOf(t, movies) + d := libraryDecl(t, libJSON("media", root, gid, true, false, "movies")) + report, _, err := applyLib(t, d, store.State{}) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(report, "library:media"); o.Action == "waiting" { + t.Fatalf("a root in shape waited: %+v", o) + } + after := statOf(t, movies) + if int(after.Gid) != gid || after.Mode&0o2070 != 0o2070 { + t.Fatalf("a requested path is not kept in the group with group rwx and setgid: gid %d, mode %o", after.Gid, + after.Mode&0o7777) + } + if after.Uid != before.Uid || after.Mode&0o707 != before.Mode&0o707 { + t.Errorf("the owner or the owner's and others' bits changed: %o to %o", before.Mode&0o7777, after.Mode&0o7777) + } + if f := statOf(t, film); int(f.Gid) == gid || f.Mode&0o7777 != 0o640 { + t.Error("a file under a kept path was changed without the take act") + } + if rec, _ := libraryRecord("media"); !rec.Kept || rec.How != store.KeptInShape { + t.Errorf("not recorded kept in shape: %+v", rec) + } +} + +// Row 9: a missing sub-path is made with the root's owner, the library's group, group rwx and setgid. +func TestAMissingSubPathIsMadeAsTheRootIs(t *testing.T) { + gid := testGID(t) + libraries(t, nil, nil) + root := filepath.Join(t.TempDir(), "media") + if err := os.Mkdir(root, 0o751); err != nil { + t.Fatal(err) + } + if err := os.Chmod(root, 0o751); err != nil { + t.Fatal(err) + } + d := libraryDecl(t, libJSON("media", root, gid, true, false, "tv/shows")) + if _, _, err := applyLib(t, d, store.State{}); err != nil { + t.Fatalf("an empty root was not kept: %v", err) + } + rst := statOf(t, root) + for _, p := range []string{filepath.Join(root, "tv"), filepath.Join(root, "tv", "shows")} { + st := statOf(t, p) + if st.Uid != rst.Uid || int(st.Gid) != gid || st.Mode&0o7777 != 0o2771 { + t.Errorf("%s made as uid %d gid %d mode %o, and the root's owner, the group and 2771 were wanted", p, + st.Uid, st.Gid, st.Mode&0o7777) + } + } + if rec, _ := libraryRecord("media"); rec.How != store.KeptEmpty { + t.Errorf("an empty root is not kept as empty: %+v", rec) + } +} + +// Row 7 (keep wait) and §8: a root that holds data not in the group waits with reason keep, and nothing is changed. +func TestARootThatHoldsDataWaitsForTheOperatorsWord(t *testing.T) { + gid := testGID(t) + libraries(t, nil, nil) + root := filepath.Join(t.TempDir(), "media") + if err := os.MkdirAll(filepath.Join(root, "movies"), 0o755); err != nil { + t.Fatal(err) + } + before := statOf(t, root) + d := libraryDecl(t, libJSON("media", root, gid, true, false, "movies")) + report, _, err := applyLib(t, d, store.State{}) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("media")) { + t.Fatalf("a root holding data did not wait for the operator: %+v", o) + } + if after := statOf(t, root); after.Gid != before.Gid || after.Mode != before.Mode { + t.Fatal("the root was changed without a warrant") + } + j := JudgeLibrary(context.Background(), d.Libraries[0]) + if j.Kind != VerdictWaitKeep || j.Placed == "" || strings.Contains(KeepWords("media")+DiskWords("media"), "/") { + t.Fatalf("the look does not wait for the operator, naming the filesystem with no path: %+v", j) + } +} + +// Row 7, the act: group, group write and setgid set under the root; no link followed; no other filesystem entered; +// a file with another name elsewhere left; owner, owner's and others' bits unchanged; nothing without a warrant +// (the act's ask, internal/link); an undeclared library refused; a walk over its bound goes on from its cursor. +func TestTheTakeActSetsTheGroupUnderTheRoot(t *testing.T) { + gid := testGID(t) + libraries(t, nil, nil) + base := t.TempDir() + root := filepath.Join(base, "media") + outside := filepath.Join(base, "outside") + for _, d := range []string{filepath.Join(root, "movies", "a film"), outside} { + if err := os.MkdirAll(d, 0o705); err != nil { + t.Fatal(err) + } + } + file := filepath.Join(root, "movies", "a film", "film.mkv") + if err := os.WriteFile(file, []byte("data"), 0o604); err != nil { + t.Fatal(err) + } + if err := unix.Chmod(file, 0o4604); err != nil { // its own setuid bit, which a group change drops + t.Fatal(err) + } + secret := filepath.Join(outside, "secret") + if err := os.WriteFile(secret, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Link(secret, filepath.Join(root, "movies", "hardlink")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + d := libraryDecl(t, libJSON("media", root, gid, true, false, "movies")) + if _, _, err := applyLib(t, d, store.State{}); err != nil { + t.Fatal(err) + } + if _, _, err := TakeLibrary(context.Background(), d, "library:books", root, "w", "op"); err == nil { + t.Fatal("an undeclared library was taken") + } + // Bounded: three entries a walk, gone on from the cursor until done. + was := ActLimits + ActLimits = WalkLimits{Entries: 3, Time: time.Minute} + defer func() { ActLimits = was }() + done, said, err := TakeLibrary(context.Background(), d, "library:media", root, "w-1", "the operator") + if err != nil { + t.Fatalf("the take act was refused: %v", err) + } + <-done + if !strings.Contains(said, "w-1") { + t.Errorf("the act does not say its warrant: %s", said) + } + rec, _ := libraryRecord("media") + if rec.Act == nil || rec.Act.State != "stopped" || len(rec.Cursor) == 0 { + t.Fatalf("a walk over its bound does not say where it stopped: %+v %+v", rec.Act, rec.Cursor) + } + for i := 0; i < 10; i++ { + ReconcileLibraries(context.Background(), d) + if rec, _ = libraryRecord("media"); rec.Act.State == "done" { + break + } + } + if rec.Act.State != "done" || rec.Act.Shared != 1 || !rec.Kept || rec.How != store.KeptByWarrant || + rec.KeptOn == "" { + t.Fatalf("the act is not recorded done under its warrant on its filesystem: %+v %+v", rec, rec.Act) + } + for _, p := range []string{root, filepath.Join(root, "movies"), filepath.Join(root, "movies", "a film")} { + if st := statOf(t, p); int(st.Gid) != gid || st.Mode&0o7777 != 0o2775 { + t.Errorf("%s: gid %d mode %o, and the group with 2775 (705 plus group rwx and setgid) was wanted", p, + st.Gid, st.Mode&0o7777) + } + } + if st := statOf(t, file); int(st.Gid) != gid || st.Mode&0o7777 != 0o4664 { + t.Errorf("a file: gid %d mode %o, and the group with 4664 (its own bits kept, group rw) was wanted", st.Gid, + st.Mode&0o7777) + } + if st := statOf(t, secret); int(st.Gid) == gid || st.Mode&0o7777 != 0o600 { + t.Error("a file with another name outside the library was given the group") + } + if st := statOf(t, outside); int(st.Gid) == gid { + t.Error("the act followed a link out of the library") + } +} + +// Row 7, the act: another filesystem is not entered — a folder whose device is not the root's is passed by. +func TestTheTakeActEntersNoOtherFilesystem(t *testing.T) { + gid := testGID(t) + root := t.TempDir() + mount := filepath.Join(root, "elsewhere") + if err := os.Mkdir(mount, 0o755); err != nil { + t.Fatal(err) + } + stats := walkStats{ + at: func(dirfd int, name string, st *unix.Stat_t) error { + err := unix.Fstatat(dirfd, name, st, unix.AT_SYMLINK_NOFOLLOW) + if name == "elsewhere" { + st.Dev++ + } + return err + }, + fd: unix.Fstat, + } + w, err := walkRoot(root, ActLimits, stats, nil, groupVisit(gid)) + if err != nil { + t.Fatal(err) + } + if w.counts.Mounts != 1 || int(statOf(t, mount).Gid) == gid { + t.Fatalf("the walk entered another filesystem: %+v", w.counts) + } +} + +// Row 9: a root that is absent, or whose disk is not mounted, makes nothing, holds the containers that mount it, +// and waits with reason disk; off its home, a mount that is not there holds them too. +func TestALibraryWhoseDiskIsNotThereHoldsItsContainers(t *testing.T) { + gid := testGID(t) + base := t.TempDir() + disk := filepath.Join(base, "disk") + if err := os.Mkdir(disk, 0o755); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + why, root string + home bool + targets []string + }{ + {"its root absent", filepath.Join(base, "gone", "media"), true, nil}, + {"its disk not mounted", filepath.Join(disk, "media"), true, []string{disk}}, + {"its mount not there", disk, false, nil}, + } { + libraries(t, nil, tc.targets) + d := libraryDecl(t, libJSON("media", tc.root, gid, tc.home, false, "movies"), + `{"id":"radarr.app","type":"container","name":"radarr","image":"`+pinned+`","volumes":["`+ + filepath.Join(tc.root, "movies")+`:/movies"]}`) + var started bool + run := func(_ context.Context, name string, args ...string) (string, error) { + if len(args) > 0 && args[0] == "run" { + started = true + } + if name == "getent" && args[0] == "group" { + return fmt.Sprintf("mesh-media:x:%d:\n", gid), nil + } + return "", nil + } + report, _, _ := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil) + if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, DiskWords("media")) { + t.Errorf("%s: the library does not wait for its disk: %+v", tc.why, o) + } + if o := outcomeOf(report, "radarr.app"); o.Action != "waiting" || started { + t.Errorf("%s: a container that mounts it was not held: %+v", tc.why, o) + } + if !WaitingOnLibrary(d)["radarr.app"] { + t.Errorf("%s: liveness is not told the container waits", tc.why) + } + if j := JudgeLibrary(context.Background(), d.Libraries[0]); j.Kind != VerdictWaitDisk { + t.Errorf("%s: the look judges it %s", tc.why, j.Kind) + } + if _, err := os.Stat(filepath.Join(tc.root, "movies")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("%s: something was made", tc.why) + } + } +} + +// Row 3: the library's group made with its number; a name or number that is another's refused; what was made, and +// only that, removed when no longer declared. +func TestALibrarysGroupIsMadeWithItsNumber(t *testing.T) { + m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{"media": {2000, 0}}} + d := withGroups(t, `{"name":"mesh-media","gid":62000}`, ``, ``) + report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) + if err != nil { + t.Fatal(err) + } + if strings.Join(m.ran, "\n") != "groupadd --system --gid 62000 mesh-media" { + t.Fatalf("made with: %v", m.ran) + } + if o := outcomeOf(report, "group mesh-media"); o.Action != "created" || len(known.Groups) != 1 { + t.Fatalf("not recorded as made: %+v %+v", o, known.Groups) + } + clash := withGroups(t, `{"name":"mesh-books","gid":2000}`, ``, ``) + if _, _, err := Apply(context.Background(), archHost(t), clash, known, store.OriginDeclared, m.run, nil, nil); err == nil || + !strings.Contains(err.Error(), "media") { + t.Errorf("a number that is another group's was not refused naming it: %v", err) + } + m.ran = nil + none := withGroups(t, ``, ``, `{"id":"x.d","type":"directory","path":"`+t.TempDir()+`/x"}`) + if _, known, err = Apply(context.Background(), archHost(t), none, known, store.OriginDeclared, m.run, nil, nil); err != nil { + t.Fatal(err) + } + if _, still := m.groups["mesh-media"]; still || len(known.Groups) != 0 { + t.Fatalf("a library group no longer declared was not removed: %v", m.ran) + } + if _, kept := m.groups["media"]; !kept { + t.Fatal("a group the mesh did not make was removed") + } +} + +// Row 4: a module's user with its own group (its uid's number), its first library's group as primary and its other +// libraries' as added groups. +func TestAModulesUserIsInItsLibrariesGroups(t *testing.T) { + m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}} + d := withGroups(t, `{"name":"mesh-media","gid":62000},{"name":"mesh-books","gid":62001}`, + `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":62000,"groups":[62001]}`, ``) + if _, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, + nil); err != nil { + t.Fatal(err) + } else if len(known.Identities) != 1 { + t.Fatalf("the identity is not recorded: %+v", known.Identities) + } + want := []string{ + "groupadd --system --gid 62000 mesh-media", + "groupadd --system --gid 62001 mesh-books", + "groupadd --system --gid 61001 mesh-radarr", + "useradd --system --uid 61001 --gid 62000 --groups mesh-books --no-create-home --home-dir / --shell " + + "/usr/bin/nologin mesh-radarr", + } + if strings.Join(m.ran, "\n") != strings.Join(want, "\n") { + t.Fatalf("made with:\n%s\nwant:\n%s", strings.Join(m.ran, "\n"), strings.Join(want, "\n")) + } +} + +// Row 11: the tool runner's account is put in each library's group once, its unit restarted for it (a change of +// library:), and taken out once no library puts it there. +func TestTheToolRunnerIsInItsLibrariesGroups(t *testing.T) { + gid := testGID(t) + libraries(t, []string{"/srv/media"}, nil) + m := &accountsMachine{users: map[string][2]int{"nox": {1003, 1003}}, groups: map[string][2]int{"nox": {1003, 0}}} + lib := `{"library":"media","root":"/srv/media","group":"mesh-media","gid":` + strconv.Itoa(gid) + + `,"home":false,"runner":"nox"}` + d := libraryDecl(t, lib) + report, known, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(m.ran, "\n"), "usermod --append --groups mesh-media nox") { + t.Fatalf("the tool runner was not put in the library's group: %v", m.ran) + } + if o := outcomeOf(report, "library:media"); o.Action != "updated" || len(known.Members) != 1 { + t.Fatalf("its joining is not a change of library:media, or not recorded: %+v %+v", o, known.Members) + } + m.ran = nil + if _, known, err = Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, m.run, nil, nil); err != nil { + t.Fatal(err) + } + if strings.Contains(strings.Join(m.ran, "\n"), "usermod") { + t.Fatal("the tool runner was put in its group again") + } + none := withGroups(t, ``, ``, `{"id":"x.d","type":"directory","path":"`+t.TempDir()+`/x"}`) + if _, known, err = Apply(context.Background(), archHost(t), none, known, store.OriginDeclared, m.run, nil, nil); err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(m.ran, "\n"), "gpasswd --delete nox mesh-media") || len(known.Members) != 0 { + t.Fatalf("the tool runner was not taken out of a group no library puts it in: %v", m.ran) + } +} + +// Row 15: a library group, an identity's added group or an image told a root group's number is refused with its +// reason, and nothing is made or started. +func TestNoLibraryGroupIsRoot(t *testing.T) { + was := RootGroupIDs + RootGroupIDs = func() map[int]string { return map[int]string{10: "wheel"} } + defer func() { RootGroupIDs = was }() + dir := t.TempDir() + for _, d := range []*declaration.Declaration{ + withGroups(t, `{"name":"mesh-media","gid":10}`, ``, `{"id":"m.d","type":"directory","path":"`+dir+`/d"}`), + withGroups(t, `{"name":"mesh-media","gid":10}`, + `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001,"groups":[10]}`, + `{"id":"m.d","type":"directory","path":"`+dir+`/d"}`), + } { + m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}} + _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, m.run, nil, nil) + if err == nil || !strings.Contains(err.Error(), "wheel") { + t.Errorf("a root group was not refused naming why: %v", err) + } + if len(m.ran) != 0 { + t.Errorf("something was made: %v", m.ran) + } + if _, err := os.Stat(filepath.Join(dir, "d")); err == nil { + t.Error("a resource of a declaration naming a root group was applied") + } + } + if _, err := declaration.Parse([]byte(`{"declaration":1,"groups":[{"name":"mesh-media","gid":0}],` + + `"owns_nothing":true,"resources":[]}`)); err == nil || !strings.Contains(err.Error(), "never root") { + t.Errorf("a library group of number 0 was not refused on arrival: %v", err) + } +} + +// A library's filesystem that cannot be named this time is unknown, never a move; another filesystem at the root is. +func TestALibraryNotNamedIsUnknownAndAnotherFilesystemIsAMove(t *testing.T) { + gid := testGID(t) + base := t.TempDir() + pool := filepath.Join(base, "pool") + root := filepath.Join(pool, "media") + if err := os.MkdirAll(filepath.Join(root, "old"), 0o755); err != nil { + t.Fatal(err) + } + libraries(t, []string{pool}, nil) + uuid := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33" + wasUUID := MountUUID + MountUUID = func(p string) string { + if p == pool { + return uuid + } + return "x" + } + defer func() { MountUUID = wasUUID }() + d := libraryDecl(t, libJSON("media", root, gid, true, false)) + if _, _, err := applyLib(t, d, store.State{}); err != nil { + t.Fatal(err) + } + done, _, err := TakeLibrary(context.Background(), d, "library:media", root, "w-1", "op") + if err != nil { + t.Fatal(err) + } + <-done + taken := libraryDecl(t, libJSON("media", root, gid, true, true)) + before, _ := libraryRecord("media") + uuid = "" // nothing names it now + wasFsid := MountFsid + MountFsid = func(string) string { return "" } + wasSource := MountSource + MountSource = func(string) (string, string) { return "", "" } + report, _, _ := applyLib(t, taken, store.State{}) + MountFsid, MountSource = wasFsid, wasSource + if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, "could not be named") { + t.Fatalf("an unnamed filesystem is not said so: %+v", o) + } + if after, _ := libraryRecord("media"); !after.Kept || after.KeptOn != before.KeptOn { + t.Fatalf("a name not read reset the record: %+v", after) + } + uuid = "00000000-1111-2222-3333-444444444444" // another pool at the same point, its root not in the group + if err := os.Chown(root, -1, os.Getgid()); err != nil { + t.Fatal(err) + } + if err := unix.Chmod(root, 0o755); err != nil { + t.Fatal(err) + } + report, _, _ = applyLib(t, taken, store.State{}) + if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, KeepWords("media")) { + t.Fatalf("another filesystem at the root was kept on the old warrant: %+v", o) + } +} + +// Old-shape parity: a controller older than ADR 0328 sends no groups, identities or libraries, and an access is +// applied exactly as before ADR 0325 — mounted when present, refused when absent, nothing kept, made or held. +func TestAnOlderControllersDeclarationIsAppliedAsBefore(t *testing.T) { + libraries(t, nil, nil) + dir := t.TempDir() + present := filepath.Join(dir, "library") + absent := filepath.Join(dir, "media", "movies") + if err := os.Mkdir(present, 0o755); err != nil { + t.Fatal(err) + } + before := statOf(t, present) + resources := []string{ + `{"id":"radarr.library","type":"access","path":"` + present + `","mode":"read-write","users":[` + + `{"identity":"mesh-radarr","uid":61001,"use":"read-write","from":"container"}],"taken":true}`, + `{"id":"plex.movies","type":"access","path":"` + absent + `"}`, + } + report, _, err := Apply(context.Background(), archHost(t), declare(t, strings.Join(resources, ",")), store.State{}, + store.OriginDeclared, noServices, nil, nil) + if err == nil || !strings.Contains(err.Error(), absent+" is not there, and the mesh does not own it — the operator "+ + "must provide it") { + t.Fatalf("an absent access is not refused as before: %v", err) + } + if o := outcomeOf(report, "radarr.library"); o.Action != "unchanged" || + o.Detail != "operator-owned; present, read-write, nothing managed" { + t.Errorf("a present access is not as before: %+v", o) + } + if after := statOf(t, present); after.Gid != before.Gid || after.Mode != before.Mode { + t.Error("a present access was changed") + } + if n, _ := unix.Getxattr(present, "system.posix_acl_access", nil); n > 0 { + t.Error("an ACL was set on an access") + } + if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) { + t.Error("an absent access was made") + } +} + +func jsonInto(raw string, into any) error { return json.Unmarshal([]byte(raw), into) } + +// Row 4: a module that joins a library later is given its group as primary on the account the mesh made; an account +// the mesh did not make is never changed, and is refused if it is not as declared. +func TestAModuleThatJoinsALibraryLaterIsGivenItsGroup(t *testing.T) { + m := &accountsMachine{users: map[string][2]int{}, groups: map[string][2]int{}} + own := withGroups(t, `{"name":"mesh-media","gid":62000}`, + `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}`, ``) + _, known, err := Apply(context.Background(), archHost(t), own, store.State{}, store.OriginDeclared, m.run, nil, nil) + if err != nil { + t.Fatal(err) + } + m.ran = nil + joined := withGroups(t, `{"name":"mesh-media","gid":62000}`, + `{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":62000}`, ``) + if _, known, err = Apply(context.Background(), archHost(t), joined, known, store.OriginDeclared, m.run, nil, nil); err != nil { + t.Fatal(err) + } + if strings.Join(m.ran, "\n") != "usermod --gid 62000 --groups mesh-radarr" || m.users["mesh-radarr"][1] != 62000 { + t.Fatalf("the mesh's account was not given its library's group: %v", m.ran) + } + theirs := &accountsMachine{users: map[string][2]int{"mesh-radarr": {61001, 61001}}, + groups: map[string][2]int{"mesh-radarr": {61001, 0}, "mesh-media": {62000, 0}}} + if _, _, err := Apply(context.Background(), archHost(t), joined, store.State{}, store.OriginDeclared, theirs.run, + nil, nil); err == nil || !strings.Contains(err.Error(), "was not made by the mesh") { + t.Fatalf("an account the mesh did not make was changed, or not refused: %v %v", err, theirs.ran) + } +} + +// A library's root is never in the machine's own trees, as an access's never is (novox/hq issue 339). +func TestALibraryIsNeverTheMachinesOwnTree(t *testing.T) { + gid := testGID(t) + libraries(t, nil, nil) + for _, root := range []string{"/etc", "/usr/share", "/var/lib/mesh-host/x"} { + d := libraryDecl(t, libJSON("sys", root, gid, true, true)) + if _, _, err := applyLib(t, d, store.State{}); err == nil { + t.Errorf("a library at %s was not refused", root) + } + if _, _, err := TakeLibrary(context.Background(), d, "library:sys", root, "w", "op"); err == nil { + t.Errorf("a library at %s was taken", root) + } + } +} diff --git a/internal/apply/oldshape_test.go b/internal/apply/oldshape_test.go deleted file mode 100644 index a4eb521..0000000 --- a/internal/apply/oldshape_test.go +++ /dev/null @@ -1,136 +0,0 @@ -//go:build linux - -package apply - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "golang.org/x/sys/unix" - - "github.com/novox/mesh-host/internal/declaration" - "github.com/novox/mesh-host/internal/store" -) - -// The engine delivers before the controller half (novox/hq ADR 0325): a controller that does not speak -// link.IdentityContract sends no identities, no users and no taken, and every access of its declarations is applied -// exactly as before this engine — mounted when present, refused when absent (ADR 0051 §7), nothing kept, nothing -// made, no container held, nothing judged. These are origin/main's outcomes, word for word. -func TestAnOlderControllersAccessesAreAppliedExactlyAsBefore(t *testing.T) { - keeping(t, nil, nil) - dir := userDir(t) - unwritable := filepath.Join(dir, "library") // its group may not write it - empty := filepath.Join(dir, "spool") - absent := filepath.Join(dir, "media", "movies") - for _, d := range []string{unwritable, empty} { - if err := os.Mkdir(d, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Chmod(d, 0o755); err != nil { - t.Fatal(err) - } - } - // A root-owned empty folder, read only. - rootEmpty := "" - for _, c := range []string{"/var/empty", "/srv/http", "/srv/ftp"} { - if entries, err := os.ReadDir(c); err == nil && len(entries) == 0 { - if uid, _, _ := statOwner(mustLstat(t, c)); uid == 0 { - rootEmpty = c - break - } - } - } - resources := []string{ - `{"id":"radarr.library","type":"access","path":"` + unwritable + `","mode":"read-write"}`, - `{"id":"sab.spool","type":"access","path":"` + empty + `","mode":"read-write"}`, - `{"id":"plex.movies","type":"access","path":"` + absent + `"}`, - `{"id":"plex.app","type":"container","name":"plex","image":"` + pinned + `","volumes":["` + absent + `:/movies"]}`, - } - if rootEmpty != "" { - resources = append(resources, `{"id":"web.root","type":"access","path":"`+rootEmpty+`","mode":"read-write"}`) - } - var started bool - run := func(_ context.Context, name string, args ...string) (string, error) { - if len(args) > 0 && args[0] == "run" { - started = true - } - if len(args) > 0 && args[0] == "container" { - if started { - return "true\t" + containerSpec(declare(t, resources[3]).Resources[0].(*declaration.Container), inputs{}), nil - } - return "", errors.New("no such container") - } - return "", nil - } - toUser(t) - report, _, err := Apply(context.Background(), archHost(t), declare(t, strings.Join(resources, ",")), store.State{}, - store.OriginDeclared, run, nil, nil) - - if err == nil || !strings.Contains(err.Error(), absent+" is not there, and the mesh does not own it — the operator "+ - "must provide it") { - t.Fatalf("an absent access is not refused as before: %v", err) - } - for id, mode := range map[string]string{"radarr.library": "read-write", "sab.spool": "read-write"} { - o := outcomeOf(report, id) - if o.Action != "unchanged" || o.Detail != "operator-owned; present, "+mode+", nothing managed" { - t.Errorf("%s: %+v, and before it was unchanged, %q", id, o, "operator-owned; present, "+mode+", nothing managed") - } - } - if rootEmpty != "" { - if o := outcomeOf(report, "web.root"); o.Action != "unchanged" { - t.Errorf("a root-owned folder was judged: %+v", o) - } - } - if !started { - t.Error("a container mounting an absent access was held, and before it was created") - } - if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) { - t.Error("an absent access was made") - } - for _, d := range []string{unwritable, empty} { - if n, _ := unix.Getxattr(d, aclAccessXattr, nil); n > 0 { - t.Errorf("an ACL was set on %s", d) - } - if info := mustLstat(t, d); info.Mode().Perm() != 0o755 { - t.Errorf("%s changed mode: %o", d, info.Mode().Perm()) - } - } - if all, _ := KeptFolders.All(); len(all) != 0 { - t.Errorf("folders were recorded as kept: %v", all) - } - if len(Leftovers()) != 0 || len(WaitingOnAccess(declare(t, strings.Join(resources, ",")))) != 0 { - t.Error("something was said or held for an older controller's access") - } -} - -func mustLstat(t *testing.T, path string) os.FileInfo { - t.Helper() - info, err := os.Lstat(path) - if err != nil { - t.Fatal(err) - } - return info -} - -// Review (low): a folder no access names, whose filesystem could not be named this look, is not said as its disk -// being away. -func TestALeftoverSaysWhyItsWalkCouldNotRun(t *testing.T) { - uuid, fsid, source := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33", "0000080100000000", "/dev/sdb1" - _, lib, access := poolNamed(t, &uuid, &fsid, &source) - takeIt(t, lib, access) - other := `{"id":"x.dir","type":"directory","path":"` + filepath.Join(filepath.Dir(filepath.Dir(lib)), "x") + `"}` - if _, _, err := applyAccesses(t, store.State{}, other); err != nil { - t.Fatal(err) - } - uuid, fsid, source = "", "0000000000000000", "" // nothing names it now - toUser(t) - ReconcileKept(context.Background()) - left := Leftovers() - if len(left) != 1 || strings.Contains(left[0].Why, "disk") || !strings.Contains(left[0].Why, "could not be named") { - t.Fatalf("the leftover does not say why its walk could not run: %+v", left) - } -} diff --git a/internal/apply/placed.go b/internal/apply/placed.go index 8008bda..f902259 100644 --- a/internal/apply/placed.go +++ b/internal/apply/placed.go @@ -11,7 +11,7 @@ import ( "time" ) -// Whether a shared folder's disk is there (novox/hq ADR 0325 §9). +// Whether a library's disk is there (novox/hq ADR 0328 §9; first ADR 0325 §9). // // **The rule the engine keeps.** A folder is placed on the filesystem it is first seen on: the mount point that // holds it — the longest mount point in /proc/self/mountinfo that is the folder or above it — is recorded beside the @@ -20,7 +20,7 @@ import ( // the disk not there. A recorded mount point that is mounted while another holds the folder is a **move**: what is // at the path now is not what was kept (a disk mounted over a folder first seen empty below it, a mount made // deeper), so the folder is judged afresh — kept again only if it holds nothing, else it waits for the operator's -// word as any folder holding data does (ADR 0325 §8). +// word as any folder holding data does (ADR 0328 §7). // // **On first sight, with no record,** the engine knows a disk is missing only from where disks are meant to be: // every mount target in /etc/fstab, every `Where=` of a systemd .mount unit, every mount point a ZFS dataset names diff --git a/internal/apply/plan.go b/internal/apply/plan.go index 6385eb7..d41b161 100644 --- a/internal/apply/plan.go +++ b/internal/apply/plan.go @@ -112,7 +112,7 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step { step.Verb, step.Why = "restore", "no longer declared; the original the mesh wrote over goes back "+ "from "+orphan.Kept+", unless the file was changed since the mesh last wrote it" case orphan.Type == string(declaration.TypeAccess) && orphan.User != "" && len(orphan.Groups) > 0: - // In takeBackGranted's words (novox/hq ADR 0325 §5: no account is in a group for data). + // In takeBackGranted's words (novox/hq ADR 0328 §11: no account is in a group for data but a library's). step.Verb, step.Why = "restore", "no longer declared; the path is left as it is, and "+orphan.User+ " leaves "+strings.Join(orphan.Groups, ", ")+", which the mesh put it in, unless another declared "+ "resource still asks for it" diff --git a/internal/apply/roots.go b/internal/apply/roots.go index 8dfccb6..9c03c26 100644 --- a/internal/apply/roots.go +++ b/internal/apply/roots.go @@ -15,7 +15,7 @@ import ( "github.com/novox/mesh-host/internal/accounts" ) -// Root's numbers, which no module identity is and whose data is never kept (novox/hq ADR 0325 §11, §12). +// Root's numbers, which no module identity is and whose data is never kept (novox/hq ADR 0328 §15). // // A module identity whose uid or gid is 0, or whose gid is a group whose members act as root (accounts.RootGroups, // the account judge's own closed list), would be a way to root through a container or a shared folder: refused, @@ -105,7 +105,7 @@ func rootGroup(gid int) string { return "" } -// rootOwned is why a folder owned by uid and gid is root's data (ADR 0325 §12), or "": owned by root, or by a +// rootOwned is why a folder owned by uid and gid is root's data (ADR 0325 §12, kept for the legacy reader), or "": owned by root, or by a // root group. func rootOwned(uid, gid uint32) string { if uid == 0 { @@ -117,13 +117,13 @@ func rootOwned(uid, gid uint32) string { return "" } -// rootIdentity is why a module identity's numbers are refused (ADR 0325 §11), or nil. +// rootIdentity is why a module identity's numbers are refused (ADR 0328 §15), or nil. func rootIdentity(uid, gid int) error { if uid <= 0 { - return fmt.Errorf("its uid is %d, and a module's identity is never root (novox/hq ADR 0325 §11)", uid) + return fmt.Errorf("its uid is %d, and a module's identity is never root (novox/hq ADR 0328 §15)", uid) } if why := rootGroup(gid); why != "" { - return fmt.Errorf("its gid is %s, and a module's identity is never in a root group (novox/hq ADR 0325 §11)", why) + return fmt.Errorf("its gid is %s, and a module's identity is never in a root group (novox/hq ADR 0328 §15)", why) } return nil } diff --git a/internal/apply/walk.go b/internal/apply/walk.go index 51d9d58..2473410 100644 --- a/internal/apply/walk.go +++ b/internal/apply/walk.go @@ -6,60 +6,48 @@ import ( "time" ) -// A bounded walk under a kept folder (novox/hq ADR 0325 §8, §10, issue 567). +// A bounded walk under a library's root (novox/hq ADR 0328 §7, issue 567). // -// **Walked by descriptor.** The folder is opened as a mesh directory is (meshWalk: no link another account could -// make, the folder itself no link), and everything under it is reached from its parent's descriptor with -// O_NOFOLLOW: a link met is passed by, a folder whose device is not the kept folder's is not entered, and an entry -// opened is checked to be the one that was looked at. An entry's ACL is read and written through its own -// descriptor, so a name swapped for a link between the look and the change changes nothing. +// **Walked by descriptor.** The root is opened as a mesh directory is (meshWalk: no link another account could +// make, the root itself no link), and everything under it is reached from its parent's descriptor with O_NOFOLLOW: +// a link met is passed by, a folder whose device is not the root's is not entered, and an entry opened is checked +// to be the one that was looked at. An entry's group and mode are set through its own descriptor, so a name +// swapped for a link between the look and the change changes nothing. // -// **Bounded, and resumed.** A media library is millions of files, and a walk that never ends is a machine that -// cannot say what it did. Each walk reads at most so many entries for at most so long; one that meets its bound -// says how far it got (a cursor, the names of each level below the folder, kept in the engine's state and never -// said) and the next goes on from there, in the order of the names, so successive looks cover a folder whole. +// **Bounded, and resumed.** A library is millions of files, and a walk that never ends is a machine that cannot +// say what it did. Each walk reads at most so many entries for at most so long; one that meets its bound says how +// far it got (a cursor, the names of each level below the root, kept in the engine's state and never said) and the +// next goes on from there, in the order of the names, so successive walks cover the library whole. -// WalkLimits bound one walk under a folder: how many entries it reads, and for how long. +// WalkLimits bound one walk under a root: how many entries it reads, and for how long. type WalkLimits struct { Entries int Time time.Duration } -// ActLimits bound the take act and the walk that adds or drops a user's entries. One stopped goes on at the next -// apply, from the start: what already has its entries is passed quickly. +// ActLimits bound one walk of the take act; one stopped at its bound goes on from its cursor at the next look. var ActLimits = WalkLimits{Entries: 5_000_000, Time: 30 * time.Minute} -// SweepLimits bound the sweep of one kept folder on one look (ADR 0325 §10): the look must come back within its -// own bound whatever the folder holds. -var SweepLimits = WalkLimits{Entries: 2000, Time: 2 * time.Second} - -// WalkCounts is what one walk did or found. Counts only: never a path below the folder. +// WalkCounts is what one walk did or found. Counts only: never a path below the root. type WalkCounts struct { // Folders and Files are the entries changed; Kept the ones that already had what they need. Folders, Files, Kept int - // Links are links passed by, Other entries neither a file nor a folder, Mounts folders on another - // filesystem not entered. - Links, Other, Mounts int - // Read is every entry looked at. - Read int - // Narrowed is the entries that gave a user less than its right (judged by entry and mask together) and - // were put back; Unrepaired the ones that could not be — the owner's bits, which the mesh never changes. - Narrowed, Unrepaired int + // Links are links passed by, Other entries neither a file nor a folder, Mounts folders on another filesystem + // not entered, Shared files with another name elsewhere left as they are. + Links, Other, Mounts, Shared int + // Read is every entry looked at; Failed the entries that could not be changed (an immutable file, say). + Read, Failed int } // ErrWalkBound is a walk that met its bound before it was through: what it did stays, and the cursor says where // the next one goes on. var ErrWalkBound = errors.New("the walk met its bound") -// ErrNoACL is a filesystem that allows no ACLs here (ENOTSUP): a ZFS dataset whose acltype is off, say. The access -// waits for the take act, which sets it where it can (ADR 0325 §8). -var ErrNoACL = errors.New("the filesystem allows no access entries") - -// keptPath refuses a folder that is not stated exactly: absolute and clean, so `..` and a doubled separator -// never name it. +// keptPath refuses a root that is not stated exactly: absolute and clean, so `..` and a doubled separator never +// name it. func keptPath(path string) error { if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return errors.New("the folder is not stated as the engine states one (absolute, no `..`, no doubled or " + + return errors.New("the root is not stated as the engine states one (absolute, no `..`, no doubled or " + "trailing separator); nothing was changed") } return nil diff --git a/internal/apply/walk_linux.go b/internal/apply/walk_linux.go index 692b312..1e8f5c7 100644 --- a/internal/apply/walk_linux.go +++ b/internal/apply/walk_linux.go @@ -2,7 +2,7 @@ package apply -// The descriptor half of walk.go and acl.go (novox/hq ADR 0325 §5, §8, §9, §10). +// The descriptor half of walk.go and library.go (novox/hq ADR 0328 §6, §7, §9). import ( "errors" @@ -10,6 +10,7 @@ import ( "io" "os" "sort" + "strings" "time" "golang.org/x/sys/unix" @@ -29,29 +30,42 @@ var machineStats = walkStats{ fd: unix.Fstat, } -// visitFunc is what a walk does to one folder or file, open on fd, whose stat is st: whether it changed it, and -// whether it found the entry narrowed and could not put it back. An error stops the walk. -type visitFunc func(fd int, st unix.Stat_t, folder bool) (changed, narrowed, unrepaired bool, err error) +// What a visit did to one entry. +const ( + visitKept = iota // it had what it needs + visitChanged // it was changed + visitShared // a file with another name elsewhere: left as it is +) -// aclWalk is one walk under a kept folder, from the descriptor of the folder itself. -type aclWalk struct { +// visitFunc is what a walk does to one folder or file, open on fd, whose stat is st: what it did, or the error +// counted on that entry alone (errStopWalk stops the walk). +type visitFunc func(fd int, st unix.Stat_t, folder bool) (int, error) + +// errStopWalk wraps an error that stops the whole walk, never one entry's alone. +type errStopWalk struct{ err error } + +func (e errStopWalk) Error() string { return e.err.Error() } +func (e errStopWalk) Unwrap() error { return e.err } + +// treeWalk is one walk under a library's root, from the descriptor of the root itself. +type treeWalk struct { dev uint64 deadline time.Time limit int stats walkStats visit visitFunc counts WalkCounts - // at is the names of the levels below the folder down to the entry being walked; Cursor, once the walk met - // its bound, where the next goes on. + // at is the names of the levels below the root down to the entry being walked; Cursor, once the walk met its + // bound, where the next goes on. at []string Cursor []string // Failed is the first failure on one entry, in words with no path. Failed string } -// openKept opens a kept folder as a mesh directory is opened (no link another account could make, the folder -// itself no link) and starts a walk from it on the folder's own device. -func openKept(path string, limits WalkLimits, stats walkStats, visit visitFunc) (*aclWalk, int, unix.Stat_t, error) { +// openRoot opens a library's root as a mesh directory is opened (no link another account could make, the root +// itself no link) and starts a walk from it on the root's own device. +func openRoot(path string, limits WalkLimits, stats walkStats, visit visitFunc) (*treeWalk, int, unix.Stat_t, error) { var st unix.Stat_t if err := keptPath(path); err != nil { return nil, -1, st, err @@ -59,36 +73,36 @@ func openKept(path string, limits WalkLimits, stats walkStats, visit visitFunc) mw := &meshWalk{want: path} fd, err := mw.open() if errors.Is(err, errNotThereYet) { - return nil, -1, st, errors.New("the folder is not there; nothing was changed") + return nil, -1, st, errors.New("the root is not there; nothing was changed") } if err != nil { - return nil, -1, st, errors.New("the folder is not walked: " + pathless(err)) + return nil, -1, st, errors.New("the root is not walked: " + pathless(err)) } if mw.At != path { unix.Close(fd) - return nil, -1, st, errors.New("the folder is reached through a link, and the engine walks no folder " + - "through one; nothing was changed") + return nil, -1, st, errors.New("the root is reached through a link, and the engine walks no root through " + + "one; nothing was changed") } if err := stats.fd(fd, &st); err != nil { unix.Close(fd) - return nil, -1, st, fmt.Errorf("the folder cannot be read: %v; nothing was changed", err) + return nil, -1, st, fmt.Errorf("the root cannot be read: %v; nothing was changed", err) } - return &aclWalk{dev: uint64(st.Dev), deadline: time.Now().Add(limits.Time), limit: limits.Entries, + return &treeWalk{dev: uint64(st.Dev), deadline: time.Now().Add(limits.Time), limit: limits.Entries, stats: stats, visit: visit}, fd, st, nil } -// walkKept walks path whole, or from resume, with visit: the walk, with what it did and the cursor where it -// stopped when it met its bound (with ErrWalkBound), and the walk's own error otherwise. -func walkKept(path string, limits WalkLimits, stats walkStats, resume []string, visit visitFunc) (*aclWalk, error) { - w, fd, st, err := openKept(path, limits, stats, visit) +// walkRoot walks path whole, or from resume, with visit: the walk, with what it did and the cursor where it stopped +// when it met its bound (with ErrWalkBound), and the walk's own error otherwise. +func walkRoot(path string, limits WalkLimits, stats walkStats, resume []string, visit visitFunc) (*treeWalk, error) { + w, fd, st, err := openRoot(path, limits, stats, visit) if err != nil { - return &aclWalk{}, err + return &treeWalk{}, err } return w, w.dir(fd, st, resume) } // read counts one entry, and says the bound when it is met, keeping where the walk stood as the cursor. -func (w *aclWalk) read() error { +func (w *treeWalk) read() error { if w.counts.Read >= w.limit || time.Now().After(w.deadline) { w.Cursor = append([]string(nil), w.at...) return ErrWalkBound @@ -97,27 +111,9 @@ func (w *aclWalk) read() error { return nil } -// count adds one visit's result. -func (w *aclWalk) count(folder, changed, narrowed, unrepaired bool) { - switch { - case changed && folder: - w.counts.Folders++ - case changed: - w.counts.Files++ - default: - w.counts.Kept++ - } - if narrowed { - w.counts.Narrowed++ - } - if unrepaired { - w.counts.Unrepaired++ - } -} - // dir is one folder, open on fd, whose stat is st: the folder itself (unless the walk resumes below it, which an // earlier walk visited), then every entry in it in the order of their names. It closes fd. -func (w *aclWalk) dir(fd int, st unix.Stat_t, resume []string) error { +func (w *treeWalk) dir(fd int, st unix.Stat_t, resume []string) error { f := os.NewFile(uintptr(fd), "") defer f.Close() if len(resume) == 0 { @@ -154,9 +150,9 @@ func (w *aclWalk) dir(fd int, st unix.Stat_t, resume []string) error { return nil } -// entry is one name in the folder open on dirfd: a link passed by, a folder on this device walked, a file -// visited through its own descriptor, anything else left. -func (w *aclWalk) entry(dirfd int, name string, resume []string) error { +// entry is one name in the folder open on dirfd: a link passed by, a folder on this device walked, a file visited +// through its own descriptor, anything else left. +func (w *treeWalk) entry(dirfd int, name string, resume []string) error { var st unix.Stat_t if err := w.stats.at(dirfd, name, &st); err != nil { if errors.Is(err, unix.ENOENT) { @@ -212,192 +208,65 @@ func (w *aclWalk) entry(dirfd int, name string, resume []string) error { return w.visited(fd, opened, false) } -// visited visits one entry and counts what it did. A filesystem that allows no ACLs stops the walk; any other -// failure on one entry (an immutable file, say) is counted unrepaired, its words kept once, and the walk goes on. -func (w *aclWalk) visited(fd int, st unix.Stat_t, folder bool) error { - changed, narrowed, unrepaired, err := w.visit(fd, st, folder) - if errors.Is(err, ErrNoACL) { - return err +// visited visits one entry and counts what it did. An error that stops the walk stops it; any other failure on +// one entry (an immutable file, say) is counted failed, its words kept once, and the walk goes on. +func (w *treeWalk) visited(fd int, st unix.Stat_t, folder bool) error { + did, err := w.visit(fd, st, folder) + var stop errStopWalk + if errors.As(err, &stop) { + return stop.err } if err != nil { - unrepaired = true + w.counts.Failed++ if w.Failed == "" { w.Failed = err.Error() } + return nil } - w.count(folder, changed, narrowed, unrepaired) - return nil -} - -// getACL reads one of an entry's ACLs through its descriptor: the access ACL, or a folder's default. An access -// ACL that is not there is the one its mode stands for; a default that is not there is nil. -func getACL(fd int, name string, mode uint32) (acl, error) { - buf := make([]byte, 4+8*32) - for { - n, err := unix.Fgetxattr(fd, name, buf) - switch { - case errors.Is(err, unix.ENODATA): - if name == aclAccessXattr { - return minimalACL(mode), nil - } - return nil, nil - case errors.Is(err, unix.ERANGE): - buf = make([]byte, 2*len(buf)) - continue - case errors.Is(err, unix.ENOTSUP), errors.Is(err, unix.EOPNOTSUPP): - return nil, ErrNoACL - case err != nil: - return nil, fmt.Errorf("an entry's ACL could not be read: %v", err) - } - return decodeACL(buf[:n]) - } -} - -// setACL writes one of an entry's ACLs through its descriptor, refusing one the kernel would. -func setACL(fd int, name string, a acl) error { - if err := a.check(); err != nil { - return err - } - err := unix.Fsetxattr(fd, name, a.encode(), 0) switch { - case errors.Is(err, unix.ENOTSUP), errors.Is(err, unix.EOPNOTSUPP): - return ErrNoACL - case err != nil: - return fmt.Errorf("an entry's ACL could not be written: %v", err) + case did == visitShared: + w.counts.Shared++ + case did == visitChanged && folder: + w.counts.Folders++ + case did == visitChanged: + w.counts.Files++ + default: + w.counts.Kept++ } return nil } -// sameACL is whether a and b hold the same entries, whatever their order. -func sameACL(a, b acl) bool { - return string(a.encode()) == string(b.encode()) -} - -// needs is, for a folder or a file, each kept uid's bits — except its owner's, which its owner's bits give and the -// mesh never changes. -func needs(users map[uint32]bool, owner uint32, folder bool) map[uint32]uint16 { - out := map[uint32]uint16{} - for uid, rw := range users { - if uid != owner { - out[uid] = needOn(rw, folder) +// groupVisit gives each entry the library's group gid, group read and write, and on a folder group enter and the +// setgid bit, so what is made beneath keeps the group (ADR 0328 §6, §7). The owner, the owner's bits and others' +// bits are never changed: a file's own setuid or setgid bit, which the kernel drops when the group changes, is put +// back as it was. A file with more than one name is left as it is — its other name may be outside the library, and +// giving it the group would give the group that other file. +func groupVisit(gid int) visitFunc { + return func(fd int, st unix.Stat_t, folder bool) (int, error) { + if !folder && st.Nlink > 1 { + return visitShared, nil } - } - return out -} - -// keepVisit makes users' entries on each entry (ADR 0325 §5): the access ACL with each uid's entry and a mask -// that covers it, and on a folder the same as its default, so what is made under it inherits them. An entry that -// had them is not written. A user that owns the entry is judged by its owner's bits, never given an entry: an -// owner whose bits fall short is unrepaired, said, and never changed. -func keepVisit(users map[uint32]bool) visitFunc { - return func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool, error) { mode := uint32(st.Mode) & 0o7777 - need := needs(users, st.Uid, folder) - access, err := getACL(fd, aclAccessXattr, mode) - if err != nil { - return false, false, false, err - } - narrowed, unrepaired := judgeACL(access, users, st, folder) - changed := false - if kept := keptACL(access, need); !sameACL(kept, access) { - if err := setACL(fd, aclAccessXattr, kept); err != nil { - return false, narrowed, true, err - } - changed = true - } + want := mode | 0o060 if folder { - def, err := getACL(fd, aclDefaultXattr, mode) - if err != nil { - return changed, narrowed, unrepaired, err - } - base := def - if base == nil { - // No default yet: one whose owner's, owning group's and others' entries are the folder's own, as - // setfacl -d derives it. - base = minimalACL(mode) - if g, ok := access.perm(aclGroupObj, 0); ok { - base[1].Perm = g - } - } - // The default is for what anyone makes under the folder, never its owner alone: every user's entry. - all := map[uint32]uint16{} - for uid, rw := range users { - all[uid] = needOn(rw, true) - } - if kept := keptACL(base, all); def == nil || !sameACL(kept, def) { - if err := setACL(fd, aclDefaultXattr, kept); err != nil { - return changed, narrowed, true, err - } - changed = true + want = mode | 0o070 | unix.S_ISGID + } + if int(st.Gid) == gid && want == mode { + return visitKept, nil + } + if int(st.Gid) != gid { + if err := unix.Fchown(fd, -1, gid); err != nil { + return 0, fmt.Errorf("an entry's group could not be set: %v", err) } } - return changed, narrowed, unrepaired, nil + if err := unix.Fchmod(fd, want); err != nil { + return 0, fmt.Errorf("an entry's mode could not be set: %v", err) + } + return visitChanged, nil } } -// judgeACL is whether an entry with access ACL a gives a user less than its right — by entry and mask together -// (ADR 0325 §6) — and whether that is one the mesh cannot put back: the owner's own bits. -func judgeACL(a acl, users map[uint32]bool, st unix.Stat_t, folder bool) (narrowed, unrepaired bool) { - mode := uint32(st.Mode) & 0o7777 - for uid, rw := range users { - need := needOn(rw, folder) - if effective(a, uid, st.Uid, mode)&need == need { - continue - } - narrowed = true - if uid == st.Uid { - unrepaired = true - } - } - return narrowed, unrepaired -} - -// dropVisit takes exactly the entries of each uid of drop out of each entry's access ACL and a folder's default -// (ADR 0325 §8): no other entry, and not the mask, so nobody else loses a right. -func dropVisit(drop map[uint32]bool) visitFunc { - return func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool, error) { - mode := uint32(st.Mode) & 0o7777 - changed := false - for _, name := range []string{aclAccessXattr, aclDefaultXattr} { - if name == aclDefaultXattr && !folder { - continue - } - a, err := getACL(fd, name, mode) - if errors.Is(err, ErrNoACL) { - continue - } - if err != nil { - // Counted unrepaired and said by the walk, which then walks it again: never read as "nothing to drop". - return changed, false, true, err - } - if a == nil { - continue - } - if left := withoutUsers(a, drop); len(left) != len(a) { - if err := setACL(fd, name, left); err != nil { - return changed, false, true, err - } - changed = true - } - } - return changed, false, false, nil - } -} - -// dropThenKeep is drop then keep, on one walk: a user whose use changed, or one gone beside one come. -func dropThenKeep(drop map[uint32]bool, users map[uint32]bool) visitFunc { - d, k := dropVisit(drop), keepVisit(users) - return func(fd int, st unix.Stat_t, folder bool) (bool, bool, bool, error) { - c1, _, _, err := d(fd, st, folder) - if err != nil { - return c1, false, false, err - } - c2, n, u, err := k(fd, st, folder) - return c1 || c2, n, u, err - } -} - -// folderEmpty is whether the folder open on fd holds nothing yet: the data the mesh may keep without asking. +// folderEmpty is whether the folder open on fd holds nothing yet: a root the mesh may keep without asking. func folderEmpty(fd int) (bool, error) { dup, err := unix.Dup(fd) if err != nil { @@ -415,20 +284,11 @@ func folderEmpty(fd int) (bool, error) { return false, fmt.Errorf("the folder could not be read: %v", err) } -// keepTop keeps users' entries on the kept folder itself (ADR 0325 §5): what every apply does, beside the walks. -func keepTop(path string, users map[uint32]bool) (changed bool, err error) { - w, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, keepVisit(users)) - if err != nil { - return false, err - } - defer unix.Close(fd) - changed, _, _, err = w.visit(fd, st, true) - return changed, err -} - -// FolderFacts is what the engine reads of a folder before keeping it: its owner, group and whether it holds data. +// FolderFacts is what the engine reads of a library's root before keeping it: its owner, group, mode and whether +// it holds data. type FolderFacts struct { UID, GID uint32 + Mode uint32 Empty bool } @@ -455,87 +315,105 @@ func readFolder(path string) (FolderFacts, error) { if err != nil { return FolderFacts{}, err } - return FolderFacts{UID: st.Uid, GID: st.Gid, Empty: empty}, nil + return FolderFacts{UID: st.Uid, GID: st.Gid, Mode: uint32(st.Mode) & 0o7777, Empty: empty}, nil } -// CreatePath makes a missing accessed folder (ADR 0325 §9): each missing level made from the nearest existing -// ancestor down, each with that ancestor's owner, group and mode. No link is followed (the ancestor is reached as -// a mesh directory is, and each level is opened with O_NOFOLLOW), no level is made on another filesystem, no file -// is made, and nothing that was there is changed: a level that appears meanwhile stops it. The caller has judged -// that the ancestor is on the placed filesystem and not root's. How many levels it made. -func CreatePath(path string) (int, error) { - return createPath(path, machineStats) -} - -func createPath(path string, stats walkStats) (int, error) { - m, err := NearestAncestor(path) +// keepFolders keeps a library's root and each of its requested sub-paths — the folders themselves, never what is +// in them — in the group gid with group read, write and enter and the setgid bit (ADR 0328 §6). A sub-path that is +// missing is made, each level with the root's owner, the group, and the root's owner's and others' bits beside +// group rwx and setgid (§9). No link is followed (the root is reached as a mesh directory is, and every level below +// it by O_NOFOLLOW), nothing is made or changed on another filesystem, and the owner and the owner's and others' +// bits of a folder that was there are never changed. How many folders it changed and made. +func keepFolders(root string, paths []string, gid int) (changed, made int, err error) { + _, fd, rst, err := openRoot(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil) if err != nil { - return 0, fmt.Errorf("the folder cannot be made: %v; nothing was changed", err) + return 0, 0, err } - if !m.Info.IsDir() { - return 0, errors.New("its nearest existing parent is not a folder (a file or a link), and a folder is " + - "made only below a folder; nothing was changed") - } - mw := &meshWalk{want: m.Ancestor} - fd, err := mw.open() - if err != nil { - return 0, fmt.Errorf("the nearest existing parent is not walked: %v; nothing was changed", err) - } - if mw.At != m.Ancestor { - unix.Close(fd) - return 0, errors.New("the nearest existing parent is reached through a link, and nothing is made " + - "through one; nothing was changed") - } - var anc unix.Stat_t - if err := stats.fd(fd, &anc); err != nil { - unix.Close(fd) - return 0, fmt.Errorf("the nearest existing parent cannot be read: %v; nothing was changed", err) - } - if why := rootOwned(anc.Uid, anc.Gid); why != "" { - unix.Close(fd) - return 0, fmt.Errorf("its nearest existing parent is %s, and nothing is made under root's data "+ - "(novox/hq ADR 0325 §12); nothing was changed", why) - } - mode := uint32(anc.Mode) & 0o7777 - made := 0 - for _, name := range m.Levels { - // Made for its maker alone (the engine, as root), so it is nobody else's until it is the ancestor's. - if err := unix.Mkdirat(fd, name, 0o700); err != nil { - unix.Close(fd) - if errors.Is(err, unix.EEXIST) { - return made, errors.New("a level of the folder appeared while it was made, and nothing that is " + - "there is changed; it stopped there") - } - return made, fmt.Errorf("a level could not be made: %v", err) + defer unix.Close(fd) + keep := func(fd int, st unix.Stat_t) error { + mode := uint32(st.Mode) & 0o7777 + want := mode | 0o070 | unix.S_ISGID + if int(st.Gid) == gid && want == mode { + return nil } - next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) - unix.Close(fd) + if int(st.Gid) != gid { + if err := unix.Fchown(fd, -1, gid); err != nil { + return fmt.Errorf("a folder's group could not be set: %v", err) + } + } + if err := unix.Fchmod(fd, want); err != nil { + return fmt.Errorf("a folder's mode could not be set: %v", err) + } + changed++ + return nil + } + if err := keep(fd, rst); err != nil { + return changed, made, err + } + madeMode := uint32(rst.Mode)&0o0707 | 0o070 | unix.S_ISGID + for _, p := range paths { + n, err := keepPath(fd, rst, p, gid, madeMode, keep) + made += n if err != nil { - return made, fmt.Errorf("a level made could not be opened: %v", err) + return changed, made, err } - fd = next - made++ + } + return changed, made, nil +} + +// keepPath walks one sub-path down from the root open on rootFd, making each missing level, and keeps the last. +func keepPath(rootFd int, rst unix.Stat_t, p string, gid int, madeMode uint32, + keep func(int, unix.Stat_t) error) (int, error) { + made := 0 + cur, err := unix.Dup(rootFd) + if err != nil { + return made, err + } + defer func() { unix.Close(cur) }() + const dirFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC + levels := strings.Split(p, "/") + for i, name := range levels { + next, err := unix.Openat(cur, name, dirFlags, 0) + if errors.Is(err, unix.ENOENT) { + // Made for its maker alone (the engine, as root), so it is nobody else's until it is the root's. + if mkErr := unix.Mkdirat(cur, name, 0o700); mkErr != nil && !errors.Is(mkErr, unix.EEXIST) { + return made, fmt.Errorf("a level of the path %s could not be made: %v", p, mkErr) + } + if next, err = unix.Openat(cur, name, dirFlags, 0); err == nil { + if err = unix.Fchown(next, int(rst.Uid), gid); err == nil { + err = unix.Fchmod(next, madeMode) + } + if err != nil { + unix.Close(next) + return made, fmt.Errorf("a level of the path %s made could not be given the root's owner "+ + "and the library's group: %v", p, err) + } + made++ + } + } + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) { + return made, fmt.Errorf("the path %s is not a folder below the root (a link, or a file), and the "+ + "engine keeps no path through one", p) + } + if err != nil { + return made, fmt.Errorf("the path %s could not be opened: %v", p, err) + } + unix.Close(cur) + cur = next var st unix.Stat_t - if err := stats.fd(fd, &st); err != nil { - unix.Close(fd) - return made, fmt.Errorf("a level made could not be read: %v", err) + if err := unix.Fstat(cur, &st); err != nil { + return made, err } - if uint64(st.Dev) != uint64(anc.Dev) { - unix.Close(fd) - return made, errors.New("a level made is on another filesystem than its parent, and it stopped there") + if st.Dev != rst.Dev { + return made, fmt.Errorf("the path %s lies on another filesystem than the root, and the engine keeps "+ + "nothing there", p) } - if st.Uid != anc.Uid || st.Gid != anc.Gid { - if err := unix.Fchown(fd, int(anc.Uid), int(anc.Gid)); err != nil { - unix.Close(fd) - return made, fmt.Errorf("a level made could not be given its parent's owner: %v", err) + if i == len(levels)-1 { + if err := keep(cur, st); err != nil { + return made, err } - } - if err := unix.Fchmod(fd, mode); err != nil { - unix.Close(fd) - return made, fmt.Errorf("a level made could not be given its parent's mode: %v", err) } } - unix.Close(fd) return made, nil } diff --git a/internal/apply/walk_other.go b/internal/apply/walk_other.go index 937c670..ecd6d51 100644 --- a/internal/apply/walk_other.go +++ b/internal/apply/walk_other.go @@ -7,57 +7,31 @@ import ( "errors" "github.com/novox/mesh-host/internal/declaration" - "github.com/novox/mesh-host/internal/store" ) -// The node-engine runs on Linux: a folder is kept only by descriptor and through the kernel's ACL attributes, -// which this system has not. Everything refuses, saying so, and changes nothing. +// The node-engine runs on Linux: a library is kept only by descriptor, which this system cannot. Everything refuses, +// saying so, and changes nothing. -var errNoKeeping = errors.New("a shared folder is kept only by descriptor, which this system cannot; nothing was changed") +var errNoKeeping = errors.New("a library is kept only by descriptor, which this system cannot; nothing was changed") -// FolderFacts is what the engine reads of a folder before keeping it. +// FolderFacts is what the engine reads of a library's root before keeping it. type FolderFacts struct { UID, GID uint32 + Mode uint32 Empty bool } -func readFolder(string) (FolderFacts, error) { return FolderFacts{}, errNoKeeping } -func keepTop(string, map[uint32]bool) (bool, error) { return false, errNoKeeping } -func CreatePath(string) (int, error) { return 0, errNoKeeping } -func ReconcileKept(context.Context) []string { return nil } -func JudgeKept(string, []store.KeptUser, WalkLimits) (WalkCounts, error) { - return WalkCounts{}, errNoKeeping -} +func readFolder(string) (FolderFacts, error) { return FolderFacts{}, errNoKeeping } +func keepFolders(string, []string, int) (int, int, error) { return 0, 0, errNoKeeping } +func ReconcileLibraries(context.Context, *declaration.Declaration) []string { return nil } -// KeptLook is what one look found of a kept folder. -type KeptLook struct { - Short []string - Busy bool - Sweep store.KeptSweep - Repaired, Unrepaired int - Failed string -} - -func LookKept(string, []store.KeptUser, WalkLimits) KeptLook { - return KeptLook{Failed: errNoKeeping.Error()} -} - -func TakeFolder(context.Context, *declaration.Declaration, string, string, string, string, Runner) (<-chan struct{}, +func TakeLibrary(context.Context, *declaration.Declaration, string, string, string, string) (<-chan struct{}, string, error) { return nil, "", errNoKeeping } -// Watcher watches nothing where a folder is not kept. -type Watcher struct{} - -func NewWatcher() (*Watcher, error) { return nil, errNoKeeping } -func (*Watcher) Set(map[string][]store.KeptUser) {} -func (*Watcher) Run(context.Context) {} - -// MountFsid reads no filesystem id where a folder is not kept. +// MountFsid reads no filesystem id where a library is not kept. var MountFsid = func(string) string { return "" } -// MountUUID reads no UUID where a folder is not kept. +// MountUUID reads no UUID where a library is not kept. var MountUUID = func(string) string { return "" } - -func aclProbe(string, string) string { return "" } diff --git a/internal/apply/watch_linux.go b/internal/apply/watch_linux.go deleted file mode 100644 index 8bbb43f..0000000 --- a/internal/apply/watch_linux.go +++ /dev/null @@ -1,350 +0,0 @@ -//go:build linux - -package apply - -import ( - "context" - "errors" - "strconv" - "sync" - "time" - "unsafe" - - "golang.org/x/sys/unix" - - "github.com/novox/mesh-host/internal/store" -) - -// The watch on each kept folder (novox/hq ADR 0325 §6). -// -// What an identity makes in a kept folder inherits the default entries, and its mask follows the mode its maker -// asked for: a file made at 0644 gives a read-write user only read until its mask is widened. The engine watches -// every folder of each kept tree (inotify: made, moved in, attributes changed) and, on notice, gives the entry its -// users' entries and a mask that covers them — the same keepVisit the walks use, through the entry's own -// descriptor, reached from the kept folder down by O_NOFOLLOW, never through a link and never on another -// filesystem. Notice can be lost (the kernel's queue overflowed) and watches run out (the machine's limit, ENOSPC): -// then the record says so and the sweep finds what was made where no watch is (§10). - -// watchMask is what a watch hears: an entry made or moved in, and an entry's mode or ACL changed. -// It is added through /proc/self/fd, whose link is the folder's own descriptor: following it is what reaches the -// folder that was opened, so the watch takes no IN_DONT_FOLLOW. -const watchMask = unix.IN_CREATE | unix.IN_MOVED_TO | unix.IN_ATTRIB | unix.IN_ONLYDIR - -// MaxWatchedPerFolder bounds the folders watched under one kept folder: beyond it the sweep alone keeps the rest. -var MaxWatchedPerFolder = 100_000 - -// Watcher watches every kept folder's tree. -type Watcher struct { - mu sync.Mutex - fd int - dirs map[int]watchedDir - roots map[string]*watchedRoot -} - -// watchedDir is one watched folder: its kept folder and the names below it. -type watchedDir struct { - root string - rel []string -} - -// watchedRoot is one kept folder watched: its users, its device, and its watches. -type watchedRoot struct { - users map[uint32]bool - dev uint64 - wds []int - out bool -} - -// NewWatcher opens the machine's notice of changes. -func NewWatcher() (*Watcher, error) { - // Not blocking: Run polls it, so the end of its context ends it whatever the kernel has to say. - fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK) - if err != nil { - return nil, err - } - return &Watcher{fd: fd, dirs: map[int]watchedDir{}, roots: map[string]*watchedRoot{}}, nil -} - -// Set is every kept folder to watch, with its users: one newly kept is watched whole (within -// MaxWatchedPerFolder), one no longer kept is let go, and one whose users changed is repaired with the new ones. -func (w *Watcher) Set(folders map[string][]store.KeptUser) { - w.mu.Lock() - defer w.mu.Unlock() - for path, root := range w.roots { - if _, still := folders[path]; !still { - for _, wd := range root.wds { - _, _ = unix.InotifyRmWatch(w.fd, uint32(wd)) - delete(w.dirs, wd) - } - delete(w.roots, path) - } - } - for path, users := range folders { - if root, had := w.roots[path]; had { - root.users = usersOf(users) - continue - } - root := &watchedRoot{users: usersOf(users)} - w.roots[path] = root - w.watchTree(path, root) - w.say(path, root) - } -} - -// say records how many folders of a kept folder are watched, and whether the watches ran out. -func (w *Watcher) say(path string, root *watchedRoot) { - n, out := len(root.wds), root.out - _ = KeptFolders.Update(path, func(f *store.KeptFolder, had bool) bool { - f.Watches, f.OutOfWatches = n, out - return had - }) -} - -// watchTree watches a kept folder and every folder under it on its device, by descriptor: each watch is added -// through /proc/self/fd of the folder's own descriptor, so no name is followed to another place. -func (w *Watcher) watchTree(path string, root *watchedRoot) { - _, fd, st, err := openKept(path, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil) - if err != nil { - return - } - root.dev = uint64(st.Dev) - w.watchBelow(path, root, fd, nil) -} - -// watchBelow watches the folder open on fd, rel below root, and every folder under it. It closes fd. -func (w *Watcher) watchBelow(path string, root *watchedRoot, fd int, rel []string) { - defer unix.Close(fd) - if root.out || len(root.wds) >= MaxWatchedPerFolder { - root.out = true - return - } - wd, err := unix.InotifyAddWatch(w.fd, "/proc/self/fd/"+strconv.Itoa(fd), watchMask) - if errors.Is(err, unix.ENOSPC) { - root.out = true - return - } - if err != nil { - return - } - root.wds = append(root.wds, wd) - w.dirs[wd] = watchedDir{root: path, rel: append([]string(nil), rel...)} - dup, err := unix.Dup(fd) - if err != nil { - return - } - names, err := readNames(dup) - if err != nil { - return - } - for _, name := range names { - var st unix.Stat_t - if unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW) != nil || st.Mode&unix.S_IFMT != unix.S_IFDIR || - uint64(st.Dev) != root.dev { - continue - } - next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) - if err != nil { - continue - } - w.watchBelow(path, root, next, append(rel, name)) - } -} - -// watchPoll is how long Run waits for notice before it looks whether its context ended. -const watchPoll = 250 * time.Millisecond - -// Run reads notices until ctx ends, repairing each entry made or changed in a watched folder, and then closes the -// watch: polled, so a quiet machine's watch ends as soon as a busy one's. -func (w *Watcher) Run(ctx context.Context) { - defer unix.Close(w.fd) - buf := make([]byte, 64*1024) - for ctx.Err() == nil { - ready, err := unix.Poll([]unix.PollFd{{Fd: int32(w.fd), Events: unix.POLLIN}}, int(watchPoll/time.Millisecond)) - if err != nil && !errors.Is(err, unix.EINTR) { - return - } - if ready <= 0 { - continue - } - n, err := unix.Read(w.fd, buf) - if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) { - continue - } - if err != nil || n <= 0 { - return - } - for at := 0; at+unix.SizeofInotifyEvent <= n; { - ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[at])) - nameAt := at + unix.SizeofInotifyEvent - name := cString(buf[nameAt : nameAt+int(ev.Len)]) - at = nameAt + int(ev.Len) - if ev.Mask&unix.IN_Q_OVERFLOW != 0 { - w.lost() - continue - } - if ev.Mask&unix.IN_IGNORED != 0 { - // The watch is gone — its folder removed, or let go by Set: what pointed at it goes too. - w.forget(int(ev.Wd)) - continue - } - w.heard(int(ev.Wd), name, ev.Mask) - } - } -} - -// lost is notice lost (the kernel's queue overflowed): every kept folder is watched afresh, and what was made -// meanwhile is the sweep's. -func (w *Watcher) lost() { - w.mu.Lock() - defer w.mu.Unlock() - for path, root := range w.roots { - for _, wd := range root.wds { - _, _ = unix.InotifyRmWatch(w.fd, uint32(wd)) - delete(w.dirs, wd) - } - root.wds, root.out = nil, false - w.watchTree(path, root) - w.say(path, root) - } -} - -// forget lets go of a watch the kernel removed. -func (w *Watcher) forget(wd int) { - w.mu.Lock() - defer w.mu.Unlock() - dir, ok := w.dirs[wd] - if !ok { - return - } - delete(w.dirs, wd) - if root := w.roots[dir.root]; root != nil { - for i, had := range root.wds { - if had == wd { - root.wds = append(root.wds[:i], root.wds[i+1:]...) - break - } - } - w.say(dir.root, root) - } -} - -// watched is how many folders are watched now. -func (w *Watcher) watched() int { - w.mu.Lock() - defer w.mu.Unlock() - return len(w.dirs) -} - -// heard repairs one entry a watch heard of: name in the watched folder wd, or the folder itself. -func (w *Watcher) heard(wd int, name string, mask uint32) { - w.mu.Lock() - dir, ok := w.dirs[wd] - root := w.roots[dir.root] - var users map[uint32]bool - var dev uint64 - if root != nil { - // Read under the lock: Set gives a root new users while notices are heard. - users, dev = root.users, root.dev - } - w.mu.Unlock() - if !ok || root == nil { - return - } - fd, err := openBelow(dir.root, dir.rel, dev) - if err != nil { - return - } - defer unix.Close(fd) - visit := keepVisit(users) - if name == "" { - var st unix.Stat_t - if unix.Fstat(fd, &st) == nil { - _, _, _, _ = visit(fd, st, true) - } - return - } - var st unix.Stat_t - if unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW) != nil || uint64(st.Dev) != dev { - return - } - isDir := st.Mode&unix.S_IFMT == unix.S_IFDIR - if !isDir && st.Mode&unix.S_IFMT != unix.S_IFREG { - return - } - flags := unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC | unix.O_NOCTTY | unix.O_NONBLOCK - if isDir { - flags |= unix.O_DIRECTORY - } - efd, err := unix.Openat(fd, name, flags, 0) - if err != nil { - return - } - var opened unix.Stat_t - if unix.Fstat(efd, &opened) != nil || opened.Ino != st.Ino || opened.Dev != st.Dev { - unix.Close(efd) - return - } - _, _, _, _ = visit(efd, opened, isDir) - if isDir && mask&(unix.IN_CREATE|unix.IN_MOVED_TO) != 0 { - // A folder made or moved in is watched, and what was moved in with it is the sweep's. - w.mu.Lock() - defer w.mu.Unlock() - if w.roots[dir.root] != root { - // Let go by Set meanwhile: nothing more is watched for it. - unix.Close(efd) - return - } - w.watchBelow(dir.root, root, efd, append(append([]string(nil), dir.rel...), name)) - w.say(dir.root, root) - return - } - unix.Close(efd) -} - -// openBelow opens the folder rel below the kept folder root, each level by O_NOFOLLOW on root's device. -func openBelow(root string, rel []string, dev uint64) (int, error) { - _, fd, _, err := openKept(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil) - if err != nil { - return -1, err - } - for _, name := range rel { - next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) - unix.Close(fd) - if err != nil { - return -1, err - } - var st unix.Stat_t - if err := unix.Fstat(next, &st); err != nil || uint64(st.Dev) != dev { - unix.Close(next) - return -1, errors.New("a level is on another filesystem") - } - fd = next - } - return fd, nil -} - -// readNames reads every name in the folder open on fd, and closes it. -func readNames(fd int) ([]string, error) { - var names []string - buf := make([]byte, 32*1024) - defer unix.Close(fd) - for { - n, err := unix.ReadDirent(fd, buf) - if err != nil { - return names, err - } - if n <= 0 { - return names, nil - } - _, _, names = unix.ParseDirent(buf[:n], -1, names) - } -} - -func cString(b []byte) string { - for i, c := range b { - if c == 0 { - return string(b[:i]) - } - } - return string(b) -} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index a8b89cd..16ffdc8 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "net" + "path/filepath" "reflect" "regexp" "slices" @@ -456,10 +457,9 @@ const ( // **The distinction 04-ISSUES/036 and 026 turn on.** A `directory` resource is the mesh's own — // it creates it, sets its owner and mode, and removes it when empty ([ADR 0030](novox/hq)). A // media library, a download spool is the operator's: it existed before the mesh, several modules -// read and write it at once, and the mesh must not chown, chmod or remove it. The host confirms it -// is there and mounts it, and keeps it usable by the identities that use it through ACL entries it -// adds and nothing else (novox/hq ADR 0325 §5); a missing one is made only below a parent on the -// filesystem the data was placed on (§9). +// read and write it at once, and the mesh must not create, chown, chmod or remove it. The host +// confirms it is there and mounts it; nothing else. Library data is a library's (novox/hq ADR 0328), +// not an access. type Access struct { ID string `json:"id"` Type Type `json:"type"` @@ -467,15 +467,11 @@ type Access struct { // Mode is how this module reaches the path: read or read-write. Absent narrows to read. Mode string `json:"mode,omitempty"` - // Users are the identities that use the folder, resolved by the controller (novox/hq ADR 0325 §3, §4): each - // module identity whose container mounts it, and the tool runner's account standing for every module whose - // tools use it. The engine keeps one ACL entry per user on the folder and as its default (§5); several - // accesses naming one folder are kept as one, each uid at the widest use any of them gives it. Absent is a - // controller older than ADR 0325, or an access nobody but others' bits reach (§2): nothing is kept. + // Users and Taken are what a controller speaking ADR 0325 sends (the identities that use the folder, and + // that the operator's warrant was carried out): accepted, so its declaration is not refused whole, and not + // acted on since ADR 0328 superseded ADR 0325 — no ACL entry is kept. Users []AccessUser `json:"users,omitempty"` - // Taken is the controller saying the operator's warrant for this folder was carried out (§8): the folder is - // kept although it holds data. The engine also keeps a folder it took itself, on the act, or found empty. - Taken bool `json:"taken,omitempty"` + Taken bool `json:"taken,omitempty"` // Grant is what a controller older than ADR 0325 still sends (ADR 0312's group grant, superseded): accepted // so its declaration is not refused whole, and never acted on — no account is put in a group for data (§5). @@ -1542,19 +1538,129 @@ type Declaration struct { // not refused for it. PutBack bool - // Identities is the identity of every module assigned to this machine (novox/hq ADR 0325 §3): an account + // Identities is the identity of every module assigned to this machine (novox/hq ADR 0328 §4): an account // and a group `mesh-` with the numbers the controller allocated once for the module, the same on // every machine. The engine makes each before any resource is applied, and removes what it made of one no // longer declared. Absent is a controller older than ADR 0325. Identities []Identity + + // Groups is the group of every library this machine is reached by (novox/hq ADR 0328 §3): `mesh-` + // with the number the controller allocated once, mesh-wide. The engine makes each, and removes what it made of + // one no longer declared. Absent is a controller older than ADR 0328. + Groups []Group + + // Libraries is every library this machine is reached by (ADR 0328 §1, §6, §9): its root here, its group, whether + // this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for, + // and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328. + Libraries []Library } -// Identity is one module's identity, as the controller allocated it. +// Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-` with +// uid, its own group of the same name and number, its primary group gid (its own, or its first library's group), +// and the groups of its other libraries as added groups. type Identity struct { Module string `json:"module"` Name string `json:"name"` UID int `json:"uid"` GID int `json:"gid"` + Groups []int `json:"groups,omitempty"` +} + +// Group is one library's group, as the controller allocated it (ADR 0328 §3). +type Group struct { + Name string `json:"name"` + GID int `json:"gid"` +} + +// GroupPrefix starts every library group's name, as IdentityPrefix starts every module's. +const GroupPrefix = "mesh-" + +// LibraryAccessPrefix starts the access a library's wait and its take act name: `library:`. +const LibraryAccessPrefix = "library:" + +// Library is one library as this machine sees it (ADR 0328). +type Library struct { + Library string `json:"library"` + // Root is the library's root on this machine: on its home the data itself, elsewhere the mount point the mounts + // module mounts it at. + Root string `json:"root"` + Group string `json:"group"` + GID int `json:"gid"` + // Home is that the library's data lives on this machine: only there does the engine keep it. + Home bool `json:"home"` + // Taken is the controller saying the operator's warrant to take it was carried out (§7). + Taken bool `json:"taken,omitempty"` + // Paths are the sub-paths its modules ask for, relative to the root. + Paths []string `json:"paths,omitempty"` + // Runner is the tool runner's account, put in the library's group (§11). Absent when no module's tools use it. + Runner string `json:"runner,omitempty"` +} + +// checkLibraries judges the groups and libraries on arrival: every library's group declared, named for it, never +// root's number; paths that stay below the root; a runner that is an account's name; and no two roots that nest +// (§13). +func checkLibraries(groups []Group, libs []Library) []string { + var problems []string + byName := map[string]int{} + numbers := map[int]string{} + for _, g := range groups { + where := fmt.Sprintf("group %q", g.Name) + if !strings.HasPrefix(g.Name, GroupPrefix) || !accountName.MatchString(g.Name) { + problems = append(problems, fmt.Sprintf("%s: a library's group is named %s", where, GroupPrefix)) + } + if g.GID <= 0 { + problems = append(problems, fmt.Sprintf("%s: gid %d, and a library's group is never root, nor a number "+ + "below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, g.GID)) + } + if _, twice := byName[g.Name]; twice { + problems = append(problems, where+": declared twice") + } + if other, used := numbers[g.GID]; used { + problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, g.GID, other)) + } + byName[g.Name], numbers[g.GID] = g.GID, g.Name + } + seen := map[string]bool{} + for _, l := range libs { + where := fmt.Sprintf("library %q", l.Library) + switch { + case l.Library == "" || strings.ContainsAny(l.Library, "/ \t\n"): + problems = append(problems, where+": a library is named by one word") + case seen[l.Library]: + problems = append(problems, where+": declared twice") + } + seen[l.Library] = true + if !filepath.IsAbs(l.Root) || filepath.Clean(l.Root) != l.Root || l.Root == "/" { + problems = append(problems, fmt.Sprintf("%s: its root %q is not an absolute, clean path below /", where, + l.Root)) + } + if gid, ok := byName[l.Group]; !ok || gid != l.GID { + problems = append(problems, fmt.Sprintf("%s: its group %s (gid %d) is not one this declaration makes", + where, l.Group, l.GID)) + } + if l.Group != GroupPrefix+l.Library { + problems = append(problems, fmt.Sprintf("%s: its group is %s%s, and %s is not", where, GroupPrefix, + l.Library, l.Group)) + } + for _, p := range l.Paths { + if p == "" || filepath.IsAbs(p) || filepath.Clean(p) != p || p == ".." || strings.HasPrefix(p, "../") { + problems = append(problems, fmt.Sprintf("%s: the path %q is not a clean path below its root", where, p)) + } + } + if l.Runner != "" && !accountName.MatchString(l.Runner) { + problems = append(problems, fmt.Sprintf("%s: its runner %q is not an account's name", where, l.Runner)) + } + } + for i, a := range libs { + for _, b := range libs[i+1:] { + if a.Root != "" && b.Root != "" && (a.Root == b.Root || strings.HasPrefix(a.Root, b.Root+"/") || + strings.HasPrefix(b.Root, a.Root+"/")) { + problems = append(problems, fmt.Sprintf("library %q: its root and library %q's nest, and libraries "+ + "never overlap (novox/hq ADR 0328 §13)", a.Library, b.Library)) + } + } + } + return problems } // IdentityPrefix starts every module identity's name. @@ -1562,9 +1668,13 @@ const IdentityPrefix = "mesh-" // checkIdentities judges the identities on arrival: each named for its module, never root, and no name or number // twice — two modules with one account would write as each other. -func checkIdentities(ids []Identity) []string { +func checkIdentities(ids []Identity, groups []Group) []string { var problems []string - names, uids, gids := map[string]bool{}, map[int]string{}, map[int]string{} + names, uids := map[string]bool{}, map[int]string{} + libraryGroups := map[int]bool{} + for _, g := range groups { + libraryGroups[g.GID] = true + } for _, id := range ids { where := fmt.Sprintf("identity %q", id.Name) if id.Module == "" || id.Name != IdentityPrefix+id.Module || !accountName.MatchString(id.Name) { @@ -1573,7 +1683,7 @@ func checkIdentities(ids []Identity) []string { } if id.UID <= 0 || id.GID <= 0 { problems = append(problems, fmt.Sprintf("%s: uid %d, gid %d, and a module's identity is never root, "+ - "nor a number below it (novox/hq ADR 0325 §11); nothing of this declaration was made", where, + "nor a number below it (novox/hq ADR 0328 §15); nothing of this declaration was made", where, id.UID, id.GID)) } if names[id.Name] { @@ -1582,10 +1692,22 @@ func checkIdentities(ids []Identity) []string { if other, used := uids[id.UID]; used { problems = append(problems, fmt.Sprintf("%s: uid %d is %s's too", where, id.UID, other)) } - if other, used := gids[id.GID]; used { - problems = append(problems, fmt.Sprintf("%s: gid %d is %s's too", where, id.GID, other)) + // Its primary group is its own (gid == uid) or a library's this declaration makes (ADR 0328 §4); so is + // every added group. A module's own number is never a library group's. + if id.GID != id.UID && !libraryGroups[id.GID] { + problems = append(problems, fmt.Sprintf("%s: its primary group gid %d is neither its own (%d) nor a "+ + "library's this declaration makes", where, id.GID, id.UID)) } - names[id.Name], uids[id.UID], gids[id.GID] = true, id.Name, id.Name + if libraryGroups[id.UID] { + problems = append(problems, fmt.Sprintf("%s: uid %d is a library group's number too", where, id.UID)) + } + for _, g := range id.Groups { + if !libraryGroups[g] { + problems = append(problems, fmt.Sprintf("%s: its added group gid %d is not a library's this "+ + "declaration makes", where, g)) + } + } + names[id.Name], uids[id.UID] = true, id.Name } return problems } @@ -1757,9 +1879,13 @@ type envelope struct { // sends them only to a host whose reports say `reads_generation`. Generation int64 `json:"generation,omitempty"` PutBack bool `json:"put_back,omitempty"` - // Identities is optional on the wire too (novox/hq ADR 0325 §3). **An older host refuses this key**; a + // Identities is optional on the wire too (novox/hq ADR 0325 §3, ADR 0328 §4). **An older host refuses this key**; a // controller sends it only to a host whose health says link.IdentityContract. Identities []Identity `json:"identities,omitempty"` + // Groups and Libraries are optional too (novox/hq ADR 0328). **An older host refuses these keys**; a controller + // sends them only to a host whose health says link.LibraryContract. + Groups []Group `json:"groups,omitempty"` + Libraries []Library `json:"libraries,omitempty"` } func parse(raw []byte, allowActions bool) (*Declaration, error) { @@ -1778,8 +1904,9 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence, Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack, - Identities: env.Identities} - problems := checkIdentities(env.Identities) + Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries} + problems := checkIdentities(env.Identities, env.Groups) + problems = append(problems, checkLibraries(env.Groups, env.Libraries)...) if env.Generation < 0 { // Below zero would read as "none claimed" and pass every refusal of an older generation. problems = append(problems, fmt.Sprintf("an assignment generation below zero (%d) is not one the "+ diff --git a/internal/link/handover.go b/internal/link/handover.go index dc2169e..12f5d14 100644 --- a/internal/link/handover.go +++ b/internal/link/handover.go @@ -328,16 +328,15 @@ type DirectoryJudgements interface { HearDirectoryJudgements(JudgeDirectoriesFunc) } -// The take act on an accessed folder, asked over the bus (novox/hq ADR 0325 §8, issue 567). +// The take act on a library, asked over the bus (novox/hq ADR 0328 §7, issue 567). // -// A folder that already holds data is kept only on the operator's word: the access waits (§7), the controller asks -// the operator once for that folder on that machine (ADR 0259), and on the approved warrant asks this node's engine -// on AskAccessActSubject, signed as a hand-over is and held to the same checks (believed), under a signing context -// of its own, naming the act, the access, its folder as the engine states it, and the warrant. The engine judges -// every value against what it was last declared and what is on disk, answers at once that the act started or why -// it is refused, adds the users' entries to everything under the folder (following no link, entering no other -// filesystem), and states the act on the access's verdict (Act). Adding or dropping a user of a folder already -// taken asks nobody: the engine does it on the apply that declares it. +// A library whose root already holds data is kept only on the operator's word: it waits (§8), the controller asks +// the operator once for that library (ADR 0259), and on the approved warrant asks the library's home engine on +// AskAccessActSubject, signed as a hand-over is and held to the same checks (believed), under a signing context of +// its own, naming the act, the access `library:`, its root as the engine states it, and the warrant. The engine +// judges every value against what it was last declared and what is on disk, answers at once that the act started or +// why it is refused, sets the library's group, group write and setgid on everything under the root (following no +// link, entering no other filesystem), and states the act on the library's verdict (Act). // AskAccessActSubject is where the controller asks this node's engine to run an act on one access's folder. func AskAccessActSubject(node string) string { return "mesh.node." + node + ".ask.access-act" } @@ -345,14 +344,9 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as // AccessActContext is prefixed to an act ask's bytes before signing: never another ask's, nor a declaration's. const AccessActContext = "novox-mesh access-act v2\n" -// The walks under an accessed folder. Only ActTake is asked; ActAdd and ActDrop are what an apply starts. -const ( - // ActTake adds the users' entries to the folder and everything under it, on a warrant (ADR 0325 §8). - ActTake = "take" - // ActAdd adds the entries of a user that joined a taken folder; ActDrop removes exactly a dropped user's. - ActAdd = "add" - ActDrop = "drop" -) +// ActTake is the one act asked: the library's group, group write and setgid set on everything under its root, on +// a warrant (ADR 0328 §7). +const ActTake = "take" // AccessActAsk is an act's own fields, beside a hand-over's (node, path, by, expires, nonce): which act, the // access by its declared resource id exactly as the declaration names it (`/` allowed), and the warrant the diff --git a/internal/link/hearing_nats.go b/internal/link/hearing_nats.go index d4bebc4..a9aace5 100644 --- a/internal/link/hearing_nats.go +++ b/internal/link/hearing_nats.go @@ -66,7 +66,7 @@ type natsLink struct { searching *nats.Subscription // judging hears the controller asking for a read-only judgement of this node's directories (issue 358). judging *nats.Subscription - // acting hears the controller asking for the take act on an access's folder (novox/hq ADR 0325 §8). + // acting hears the controller asking for the take act on a library (novox/hq ADR 0328 §7). acting *nats.Subscription node string // signer is the mesh's key, which a hand-over ask is verified with as a declaration is (issue 356). @@ -262,7 +262,7 @@ func (l *natsLink) HearDirectoryJudgements(handle JudgeDirectoriesFunc) { } // HearAccessActs listens for the controller asking this node to run the take act on an access's folder (novox/hq ADR -// 0325 §8), on this machine's own subject, verified as a hand-over is (AnswerAccessAct). A bus whose user list is older +// 0328 §7), on this machine's own subject, verified as a hand-over is (AnswerAccessAct). A bus whose user list is older // than the grant refuses the subscription, said by the client; the controller then hears no answer. func (l *natsLink) HearAccessActs(handle AccessActFunc) { if handle == nil { diff --git a/internal/link/messages.go b/internal/link/messages.go index b878a6b..56e1b94 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -266,6 +266,14 @@ const GrantContract = 4 // tells the controller this host may be sent those fields; an older host refuses the whole declaration for them. const IdentityContract = 5 +// LibraryContract is the statement of a host that reads a declaration's `groups` and `libraries` and an identity's +// `groups` (novox/hq ADR 0328, which supersedes ADR 0325): it makes each library's group and each module's user in +// its libraries' groups, keeps each library it is home to under its group, holds the containers of a library whose +// disk is not there, states a library it may not keep yet as waiting, and hears the take act for `library:`. +// It keeps no ACL entries: an access's `users` and `taken` are accepted and not acted on. Like the fields before +// it, its presence is what tells the controller this host may be sent those fields. +const LibraryContract = 6 + // Health is one statement of every long-running resource's state on this machine (to-be 48 §4). Said in // every report, as an event on each change, and again every minute while anything is not healthy — so a // lost statement is not a lost fault. @@ -304,15 +312,14 @@ const KindAccount = "account" // starting ReasonUsedAsFound, until the operator hands it to the mesh (`nox node hand-over ` on the control-node, novox/hq issue 356). const KindDirectory = "directory" -// KindAccess is an access whose folder the engine keeps for its users (novox/hq ADR 0325): healthy while every user -// has its effective right on the folder and the sweep's sample under it (§10), waiting while the mesh may not keep -// it yet or its disk is not there (§7), unhealthy otherwise — root's data on its placed filesystem among it (§12). +// KindAccess is an access (novox/hq ADR 0051): no engine since ADR 0328 states one; kept for the controller's reading +// of an older engine's statements. const KindAccess = "access" -// KindKeptEntries is the mesh's entries on a shared folder that nobody wants and that could not be taken out yet -// (novox/hq ADR 0325 §8): a folder no access names whose disk is away, or entries left on a filesystem no longer at -// a folder. Unhealthy, said under the access that last named it, until the drop walk has run. -const KindKeptEntries = "kept-entries" +// KindLibrary is a library this machine is reached by (novox/hq ADR 0328), said under resource `library:` +// with no module: healthy while it is kept (or, not home, while its mount is there), waiting with an access wait of +// reason keep or disk, unknown while its filesystem could not be named, unhealthy otherwise. +const KindLibrary = "library" // ReasonUsedAsFound starts the reason of a directory used as found. const ReasonUsedAsFound = "used as found:" @@ -426,24 +433,21 @@ type ResourceHealth struct { // Waits is what a resource stated waiting waits for (novox/hq ADR 0283): each one own secret or one // setting of the module by name, the part, and what the operator gives, in words. Absent otherwise. Waits []health.Wait `json:"waits,omitempty"` - // AccessWaits is what an access stated waiting waits for (novox/hq ADR 0325 §7). Beside Waits rather than in + // AccessWaits is what a library stated waiting waits for (novox/hq ADR 0328 §8, §9). Beside Waits rather than in // it because mesh-sdk's health.Wait names a secret or a setting and nothing else; it moves there once that type // has a kind. Absent otherwise. AccessWaits []AccessWait `json:"access-waits,omitempty"` - // Act is the last walk under an access's folder (ADR 0325 §8): a take on a warrant, or an add or a drop of a - // user's entries. Absent on every other verdict, and on a folder never walked. + // Act is the last walk under a library's root (ADR 0328 §7): the take on a warrant. Absent on every other + // verdict, and on a library never walked. Act *AccessActRecord `json:"act,omitempty"` - // Entries is the listing of a kept folder's users, for an access's verdict (ADR 0325 §4): each identity, its - // use, and a note beside the tool runner's entry. Absent on a folder not kept. - Entries []KeptEntry `json:"entries,omitempty"` } -// WaitKindAccess is the kind of a wait on an access (novox/hq ADR 0325 §7). +// WaitKindAccess is the kind of a wait on a library (novox/hq ADR 0328 §8, §9; first ADR 0325 §7). const WaitKindAccess = "access" -// AccessWait is one access waiting for the operator: the access by its module's own id for it, the part (its -// module), and what the operator gives, in words with no path (ADR 0234 §6): "let the mesh keep the shared folder -// movies", or "mount the disk that holds books". +// AccessWait is one library waiting for the operator (ADR 0328 §8, §9): the access `library:`, the part (the +// library), and what the operator gives, in words with no path (ADR 0234 §6): "let the mesh keep the library media", +// or "mount the disk that holds the library media". type AccessWait struct { // Kind is WaitKindAccess. Kind string `json:"kind"` @@ -456,31 +460,14 @@ type AccessWait struct { // Reason is what the wait is for, for the controller to read without parsing words: WaitKeep (the operator's // word to keep it) or WaitDisk (its disk mounted). Reason string `json:"reason"` - // ACL is said on a keep wait whose folder's filesystem refuses access entries now: ACLNeedsEnabling when the - // take act will let it (a ZFS dataset's acltype, which the ask then says it sets), ACLUnsupported when no act - // can (the operator mounts it with ACLs). Absent when it allows them. - ACL string `json:"acl,omitempty"` } -// The reasons of an access wait, and what its filesystem says of ACLs. +// The reasons of an access wait. const ( - WaitKeep = "keep" - WaitDisk = "disk" - ACLNeedsEnabling = "needs-enabling" - ACLUnsupported = "unsupported" + WaitKeep = "keep" + WaitDisk = "disk" ) -// KeptEntry is one user of a kept folder as its listing says it. -type KeptEntry struct { - Identity string `json:"identity"` - UID int `json:"uid"` - Use string `json:"use"` - From string `json:"from"` - // Note is said beside the tool runner's entry (ADR 0325 §4): it stands for every module whose tools use the - // folder, at the widest use any of them declares. - Note string `json:"note,omitempty"` -} - // The states of a walk under an accessed folder. const ( ActRunning = "running" @@ -491,10 +478,10 @@ const ( ActFailed = "failed" ) -// AccessActRecord is one walk under an access's folder, as the engine records it (novox/hq ADR 0325 §8). Counts -// only: never a path. +// AccessActRecord is one walk under a library's root, as the engine records it (novox/hq ADR 0328 §7). Counts only: +// never a path. type AccessActRecord struct { - // Act is ActTake, ActAdd or ActDrop. + // Act is ActTake. Act string `json:"act"` Access string `json:"access"` Warrant string `json:"warrant,omitempty"` @@ -511,6 +498,8 @@ type AccessActRecord struct { Links int `json:"links,omitempty"` Other int `json:"other,omitempty"` Mounts int `json:"mounts,omitempty"` + // Shared is the files with another name elsewhere (more than one link), left as they are. + Shared int `json:"shared,omitempty"` // Why is what stopped or failed it, in words with no path. Why string `json:"why,omitempty"` } diff --git a/internal/link/queue.go b/internal/link/queue.go index f24cdb1..1c345ee 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -63,7 +63,7 @@ type Queue struct { // link (novox/hq issue 358). Nil answers every such ask with a refusal. JudgeDirectories JudgeDirectoriesFunc // AccessAct judges and runs the take act on an access's folder, asked by the controller on an approved - // warrant (novox/hq ADR 0325 §8). Nil answers every such ask with a refusal. + // warrant (novox/hq ADR 0328 §7). Nil answers every such ask with a refusal. AccessAct AccessActFunc Say Announce Timeout time.Duration diff --git a/internal/link/run.go b/internal/link/run.go index f45d808..eb46c48 100644 --- a/internal/link/run.go +++ b/internal/link/run.go @@ -252,7 +252,7 @@ func serve(ctx context.Context, link Link, m Membership, queue *Queue, say Annou if j, ok := link.(DirectoryJudgements); ok { j.HearDirectoryJudgements(queue.JudgeDirectories) } - // And asking for the take act on an access's folder, on a warrant (novox/hq ADR 0325 §8). + // And asking for the take act on a library, on a warrant (novox/hq ADR 0328 §7). if s, ok := link.(AccessActs); ok { s.HearAccessActs(queue.AccessAct) } diff --git a/internal/store/kept.go b/internal/store/kept.go deleted file mode 100644 index 4e9252e..0000000 --- a/internal/store/kept.go +++ /dev/null @@ -1,212 +0,0 @@ -package store - -import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "sync" - "time" -) - -// KeptName is the file, beside the node's state, of every shared folder the engine keeps (novox/hq ADR 0325 §5): -// whose entries it added, on which filesystem it was placed, whether it was taken and under which warrant, and how -// far its sweep got. Its own file for the reason the hand-overs have theirs: the take act, the watch and the sweep -// run beside an apply, whose save of the state would lose a record written into it meanwhile. -const KeptName = "kept-folders.json" - -// KeptPath is that file beside the state at statePath. -func KeptPath(statePath string) string { - return filepath.Join(filepath.Dir(statePath), KeptName) -} - -// KeptUser is one identity the mesh keeps an entry for on a folder: its name, its number, its use and where it -// runs (a container, or the tool runner standing for every module whose tools use the folder, ADR 0325 §4). -type KeptUser struct { - Identity string `json:"identity"` - UID uint32 `json:"uid"` - Use string `json:"use"` - From string `json:"from"` -} - -// How a folder came to be kept. -const ( - // KeptByWarrant is a folder that held data, taken on the operator's word (ADR 0325 §8). - KeptByWarrant = "warrant" - // KeptEmpty is a folder that held nothing yet when it was first kept (§5). - KeptEmpty = "empty" - // KeptMade is a folder the engine made under a parent on its placed filesystem (§9). - KeptMade = "made" -) - -// KeptFolder is one shared folder the engine keeps, or has seen and waits to keep. Counts and mount points only -// beside its own path: never a path under it. -type KeptFolder struct { - Path string `json:"path"` - // Mount is the mount point of the filesystem the folder was placed on when the engine first saw it there: - // later, a nearest existing parent on another one is the disk not there (ADR 0325 §9). - Mount string `json:"mount,omitempty"` - // Kept is that the mesh keeps it; How why (KeptByWarrant, KeptEmpty, KeptMade); Warrant and By the warrant and - // who asked, for a folder taken on one; At since when. - Kept bool `json:"kept,omitempty"` - How string `json:"how,omitempty"` - Warrant string `json:"warrant,omitempty"` - By string `json:"by,omitempty"` - At time.Time `json:"at,omitzero"` - // Users are the identities whose entries are on everything under the folder; Want what the last apply - // declared. Where they differ a walk adds or removes exactly the difference. - Users []KeptUser `json:"users,omitempty"` - Want []KeptUser `json:"want,omitempty"` - // IDs are the accesses that last named the folder: what a leftover is said under once none does. - IDs []string `json:"ids,omitempty"` - // Placed names the filesystem the folder was kept on (the engine's PlacedID: its mount point, source and type), - // so another filesystem mounted at the same point is a move, as another mount point is. - Placed string `json:"placed,omitempty"` - // Fs is what Placed was made from beside the mount point: "uuid:", "fsid:" - // or "source::". Kept on this machine only, never said. Absent on a record from an - // earlier build, whose Placed is then not compared. - Fs string `json:"fs,omitempty"` - // TakenOn names, as Placed does, the filesystem the operator's warrant was carried out on (ADR 0325 §8): the - // controller's word that the folder was taken holds for that filesystem alone, and a folder moved to another - // asks again. One that names no filesystem this way (a mount point, from an unmerged build) is not taken. - TakenOn string `json:"taken_on,omitempty"` - // Left is the entries the mesh added on a filesystem no longer at the folder (a disk mounted over it, or - // one gone): taken out by a drop walk once that filesystem holds the folder again, said until then. - Left []KeptLeft `json:"left,omitempty"` - // Act is the last walk under the folder: a take, an add or a drop. - Act *KeptAct `json:"act,omitempty"` - // Sweep is how far the sweep got (ADR 0325 §10). - Sweep KeptSweep `json:"sweep"` - // Watches is how many folders under it are watched; OutOfWatches that the machine's watch limit was met, and - // the sweep alone finds what is made where no watch is (§6). - Watches int `json:"watches,omitempty"` - OutOfWatches bool `json:"out_of_watches,omitempty"` -} - -// KeptLeft is the mesh's entries left on one filesystem: its mount point and whose entries they are. -type KeptLeft struct { - Mount string `json:"mount"` - // Placed names that filesystem as KeptFolder.Placed does: entries left on the filesystem the folder is on - // now are the folder's own, never walked away. - Placed string `json:"placed,omitempty"` - Fs string `json:"fs,omitempty"` - Users []KeptUser `json:"users"` - // Since is when they were left: a filesystem not back at its mount point within LeftFor is gone, and what was - // left on it is forgotten (said once). - Since time.Time `json:"since,omitzero"` -} - -// KeptAct is one walk under a kept folder, as the engine records it. Counts only: never a path. -type KeptAct struct { - // Act is take, add or drop. - Act string `json:"act"` - Warrant string `json:"warrant,omitempty"` - By string `json:"by,omitempty"` - State string `json:"state"` - Started time.Time `json:"started"` - Ended time.Time `json:"ended,omitzero"` - // Folders and Files are the entries changed; Kept the ones that already had what they need; Links the links - // passed by unfollowed; Other the entries neither a file nor a folder; Mounts the folders on another - // filesystem not entered. - Folders int `json:"folders,omitempty"` - Files int `json:"files,omitempty"` - Kept int `json:"kept,omitempty"` - Links int `json:"links,omitempty"` - Other int `json:"other,omitempty"` - Mounts int `json:"mounts,omitempty"` - // Why is what stopped or failed it, in words with no path. - Why string `json:"why,omitempty"` -} - -// KeptSweep is the sweep's place in a kept folder: where the last look stopped, below the folder, by the names -// of each level (never said, only resumed from); when it last covered the folder whole; and what this pass found. -type KeptSweep struct { - Cursor []string `json:"cursor,omitempty"` - Covered time.Time `json:"covered,omitzero"` - // Read is how many entries this pass read so far; Narrowed how many gave a user less than its right and were - // put back; Unrepaired how many could not be (an owner's bits, which the mesh never changes, or a write that - // failed). - Read int `json:"read,omitempty"` - Narrowed int `json:"narrowed,omitempty"` - Unrepaired int `json:"unrepaired,omitempty"` - // LastUnrepaired is what the last whole pass could not put back: said until the next pass is through. - LastUnrepaired int `json:"last_unrepaired,omitempty"` - // Bound is that the last look ran out of its bound before the folder was covered. - Bound bool `json:"bound,omitempty"` -} - -// KeptFile is the kept folders' file, one writer at a time in this process. -type KeptFile struct { - path string - mu sync.Mutex -} - -// NewKeptFile is the kept folders' file beside the state at statePath. -func NewKeptFile(statePath string) *KeptFile { return &KeptFile{path: KeptPath(statePath)} } - -// All is every kept or seen folder, by path. None is an empty map; a file that cannot be read is an error. -func (k *KeptFile) All() (map[string]KeptFolder, error) { - k.mu.Lock() - defer k.mu.Unlock() - return k.load() -} - -// Update changes the record of one folder under the file's lock: change is given the record (empty when there is -// none) and whether there was one, and says whether to keep it; a record not kept is removed. -func (k *KeptFile) Update(path string, change func(f *KeptFolder, had bool) bool) error { - k.mu.Lock() - defer k.mu.Unlock() - all, err := k.load() - if err != nil { - return err - } - f, had := all[path] - f.Path = path - if change(&f, had) { - all[path] = f - } else { - delete(all, path) - } - return k.save(all) -} - -func (k *KeptFile) load() (map[string]KeptFolder, error) { - raw, err := os.ReadFile(k.path) - if errors.Is(err, fs.ErrNotExist) { - return map[string]KeptFolder{}, nil - } - if err != nil { - return nil, fmt.Errorf("reading the kept folders: %w", err) - } - out := map[string]KeptFolder{} - if err := json.Unmarshal(raw, &out); err != nil { - return nil, fmt.Errorf("the kept folders (%s) are unreadable: %w", k.path, err) - } - return out, nil -} - -func (k *KeptFile) save(all map[string]KeptFolder) error { - raw, err := json.MarshalIndent(all, "", " ") - if err != nil { - return err - } - tmp, err := os.CreateTemp(filepath.Dir(k.path), ".kept-folders-*.json") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if _, err := tmp.Write(append(raw, '\n')); err != nil { - tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmp.Name(), k.path) -} diff --git a/internal/store/libraries.go b/internal/store/libraries.go new file mode 100644 index 0000000..5c09620 --- /dev/null +++ b/internal/store/libraries.go @@ -0,0 +1,159 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sync" + "time" +) + +// LibrariesName is the file, beside the node's state, of every library this machine is home to (novox/hq ADR 0328 +// §6, §7, §9): which filesystem its root was kept on, whether and how it is kept, and the last walk under it. Its own +// file for the reason the hand-overs have theirs: the take act runs beside an apply, whose save of the state would +// lose a record written into it meanwhile. +const LibrariesName = "libraries.json" + +// LibrariesPath is that file beside the state at statePath. +func LibrariesPath(statePath string) string { + return filepath.Join(filepath.Dir(statePath), LibrariesName) +} + +// How a library came to be kept. +const ( + // KeptByWarrant is a root that held data, taken on the operator's word (ADR 0328 §7). + KeptByWarrant = "warrant" + // KeptEmpty is a root that held nothing yet when it was first kept. + KeptEmpty = "empty" + // KeptInShape is a root found already in the library's group with group write and setgid. + KeptInShape = "in-shape" +) + +// LibraryRecord is one library as the engine keeps it on its home. Counts, names and mount points only beside its +// root: never a path under it. +type LibraryRecord struct { + Library string `json:"library"` + Root string `json:"root"` + // GID is the library's group as it was last kept: what a walk stopped at its bound goes on with. + GID int `json:"gid,omitempty"` + // Mount is the mount point that held the root when the engine first saw it; Placed and Fs name that filesystem + // (the engine's PlacedID, and what it was made from: UUID, filesystem id or source). + Mount string `json:"mount,omitempty"` + Placed string `json:"placed,omitempty"` + Fs string `json:"fs,omitempty"` + // Kept is that the mesh keeps it, on the filesystem KeptOn names (a Placed): another filesystem at the root is + // judged afresh. How is why; Warrant and By the warrant and who asked, for one taken on a warrant. + Kept bool `json:"kept,omitempty"` + KeptOn string `json:"kept_on,omitempty"` + How string `json:"how,omitempty"` + Warrant string `json:"warrant,omitempty"` + By string `json:"by,omitempty"` + At time.Time `json:"at,omitzero"` + // Act is the last walk under the root; Cursor where a walk that met its bound stopped (the names of each level + // below the root, never said), for the next to go on from. + Act *KeptAct `json:"act,omitempty"` + Cursor []string `json:"cursor,omitempty"` +} + +// KeptAct is one walk under a library's root, as the engine records it. Counts only: never a path. +type KeptAct struct { + Act string `json:"act"` + Warrant string `json:"warrant,omitempty"` + By string `json:"by,omitempty"` + State string `json:"state"` + Started time.Time `json:"started"` + Ended time.Time `json:"ended,omitzero"` + // On names the filesystem it walked (a Placed). + On string `json:"on,omitempty"` + // Folders and Files are the entries changed; Kept the ones that already had what they need; Links the links + // passed by unfollowed; Other the entries neither a file nor a folder; Mounts the folders on another filesystem + // not entered; Shared the files with another name elsewhere (more than one link), left as they are. + Folders int `json:"folders,omitempty"` + Files int `json:"files,omitempty"` + Kept int `json:"kept,omitempty"` + Links int `json:"links,omitempty"` + Other int `json:"other,omitempty"` + Mounts int `json:"mounts,omitempty"` + Shared int `json:"shared,omitempty"` + // Why is what stopped or failed it, in words with no path. + Why string `json:"why,omitempty"` +} + +// LibraryFile is the libraries' file, one writer at a time in this process. +type LibraryFile struct { + path string + mu sync.Mutex +} + +// NewLibraryFile is the libraries' file beside the state at statePath. +func NewLibraryFile(statePath string) *LibraryFile { + return &LibraryFile{path: LibrariesPath(statePath)} +} + +// All is every library's record, by name. None is an empty map; a file that cannot be read is an error. +func (k *LibraryFile) All() (map[string]LibraryRecord, error) { + k.mu.Lock() + defer k.mu.Unlock() + return k.load() +} + +// Update changes one library's record under the file's lock: change is given the record (empty when there is none) +// and whether there was one, and says whether to keep it; a record not kept is removed. +func (k *LibraryFile) Update(name string, change func(r *LibraryRecord, had bool) bool) error { + k.mu.Lock() + defer k.mu.Unlock() + all, err := k.load() + if err != nil { + return err + } + r, had := all[name] + r.Library = name + if change(&r, had) { + all[name] = r + } else { + delete(all, name) + } + return k.save(all) +} + +func (k *LibraryFile) load() (map[string]LibraryRecord, error) { + raw, err := os.ReadFile(k.path) + if errors.Is(err, fs.ErrNotExist) { + return map[string]LibraryRecord{}, nil + } + if err != nil { + return nil, fmt.Errorf("reading the libraries: %w", err) + } + out := map[string]LibraryRecord{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("the libraries (%s) are unreadable: %w", k.path, err) + } + return out, nil +} + +func (k *LibraryFile) save(all map[string]LibraryRecord) error { + raw, err := json.MarshalIndent(all, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(k.path), ".libraries-*.json") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(append(raw, '\n')); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), k.path) +} diff --git a/internal/store/store.go b/internal/store/store.go index b1bba24..c803530 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -242,10 +242,32 @@ type State struct { // assigned again finds the tunnel's configuration retired rather than missing. Retired []Retired `json:"retired,omitempty"` - // Identities is every module identity the engine made here (novox/hq ADR 0325 §3): its account and group, + // Identities is every module identity the engine made here (novox/hq ADR 0328 §4): its account and group, // by name and number, and which of the two it made. An identity no longer declared is removed, and only // what the engine made: an account or group that was there before is never the mesh's to remove. Identities []MadeIdentity `json:"identities,omitempty"` + + // Groups is every library group the engine made here (novox/hq ADR 0328 §3), removed when no longer declared; + // a group that was there before is never the mesh's to remove. + Groups []MadeGroup `json:"groups,omitempty"` + + // Members is every library group the engine put a tool runner's account in that it was not in before (ADR 0328 + // §11, as ADR 0252 records an account's groups): taken back when no longer declared. + Members []Membership `json:"members,omitempty"` +} + +// MadeGroup is one library group the engine made. +type MadeGroup struct { + Name string `json:"name"` + GID int `json:"gid"` + At time.Time `json:"at"` +} + +// Membership is one account the engine put in one library's group. +type Membership struct { + Account string `json:"account"` + Group string `json:"group"` + Library string `json:"library"` } // MadeIdentity is one module identity as the engine made it. diff --git a/internal/system/identities.go b/internal/system/identities.go index 1c14265..3e3a363 100644 --- a/internal/system/identities.go +++ b/internal/system/identities.go @@ -7,7 +7,7 @@ import ( "strings" ) -// A module's identity as a system account (novox/hq ADR 0325 §3): an account and a group with exactly the numbers +// A module's identity as a system account (novox/hq ADR 0328 §3, §4): an account and a group with exactly the numbers // the controller allocated, no login shell, no home and no password. Optional beside System: a system that has // none (android) makes no module identity, and the engine says so. @@ -21,14 +21,27 @@ func (arch) CreateSystemGroup(ctx context.Context, run Runner, name string, gid // CreateSystemAccount makes a system account with uid and the group gid as its own: no home made, `/` as the // home field, nologin as its shell, and the password useradd leaves locked. -func (arch) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int) error { - if _, err := run(ctx, "useradd", "--system", "--uid", strconv.Itoa(uid), "--gid", strconv.Itoa(gid), - "--no-create-home", "--home-dir", "/", "--shell", "/usr/bin/nologin", name); err != nil { +func (arch) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int, groups []string) error { + args := []string{"--system", "--uid", strconv.Itoa(uid), "--gid", strconv.Itoa(gid)} + if len(groups) > 0 { + args = append(args, "--groups", strings.Join(groups, ",")) + } + args = append(args, "--no-create-home", "--home-dir", "/", "--shell", "/usr/bin/nologin", name) + if _, err := run(ctx, "useradd", args...); err != nil { return fmt.Errorf("cannot create the account %q: %w", name, err) } return nil } +// ModifyAccount gives an account the mesh made its primary group gid and exactly groups as its added groups. +func (arch) ModifyAccount(ctx context.Context, run Runner, name string, gid int, groups []string) error { + if _, err := run(ctx, "usermod", "--gid", strconv.Itoa(gid), "--groups", strings.Join(groups, ","), + name); err != nil { + return fmt.Errorf("cannot give the account %q its groups: %w", name, err) + } + return nil +} + // RemoveAccount removes an account, leaving every file it owns where it is. func (arch) RemoveAccount(ctx context.Context, run Runner, name string) error { if _, err := run(ctx, "userdel", name); err != nil { @@ -54,7 +67,7 @@ func (alpine) CreateSystemGroup(ctx context.Context, run Runner, name string, gi } // CreateSystemAccount makes a system account with busybox's adduser: -S system, -D no password, -H no home made. -func (alpine) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int) error { +func (alpine) CreateSystemAccount(ctx context.Context, run Runner, name string, uid, gid int, groups []string) error { group, err := run(ctx, "getent", "group", strconv.Itoa(gid)) if err != nil { return fmt.Errorf("cannot create the account %q: its group gid %d is not here: %w", name, gid, err) @@ -67,9 +80,21 @@ func (alpine) CreateSystemAccount(ctx context.Context, run Runner, name string, "-G", groupName, name); err != nil { return fmt.Errorf("cannot create the account %q: %w", name, err) } + for _, g := range groups { + if _, err := run(ctx, "addgroup", name, g); err != nil { + return fmt.Errorf("cannot put the account %q in %q: %w", name, g, err) + } + } return nil } +// ModifyAccount is not one busybox can do: it has no usermod to change an account's primary group, and removing +// an added group is a hand edit of /etc/group. Refused, saying so: the account is left as it is. +func (alpine) ModifyAccount(context.Context, Runner, string, int, []string) error { + return fmt.Errorf("this system cannot change an account's groups once it is made (busybox has no usermod): %w", + ErrUnsupported) +} + // RemoveAccount removes an account with busybox's deluser. func (alpine) RemoveAccount(ctx context.Context, run Runner, name string) error { if _, err := run(ctx, "deluser", name); err != nil {