Keep libraries under a group of their own, and give each joining module a user in it (hq ADR 0328, issue 567) #95
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:<name>` 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, Linked: a.Linked, Stripped: a.Stripped, 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
|
||||
}
|
||||
@@ -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:<name>`, 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)
|
||||
}
|
||||
}
|
||||
+15
-16
@@ -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),
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+87
-34
@@ -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:<name>`, 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+151
-22
@@ -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-<module>`. 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-<module>` 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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
// The engine's half of ADR 0325 §3 and §11 (issue 567): every module given an account and a group of its own with
|
||||
@@ -19,7 +20,20 @@ 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; homes each account's home, "/" when not set.
|
||||
in map[string][]string
|
||||
homes 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) {
|
||||
@@ -36,14 +50,55 @@ func (m *accountsMachine) run(_ context.Context, name string, args ...string) (s
|
||||
if db == "group" {
|
||||
return n + ":x:" + strconv.Itoa(ids[0]) + ":\n", nil
|
||||
}
|
||||
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::/:/usr/bin/nologin\n", nil
|
||||
home := m.homes[n]
|
||||
if home == "" {
|
||||
home = "/"
|
||||
}
|
||||
return n + ":x:" + strconv.Itoa(ids[0]) + ":" + strconv.Itoa(ids[1]) + "::" + home + ":/usr/bin/nologin\n", nil
|
||||
}
|
||||
}
|
||||
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], ","))
|
||||
}
|
||||
if m.homes == nil {
|
||||
m.homes = map[string]string{}
|
||||
}
|
||||
m.homes[name] = "/nonexistent"
|
||||
case "usermod":
|
||||
name := args[len(args)-1]
|
||||
if args[0] == "--append" {
|
||||
m.setIn(name, append(m.in[name], args[2]))
|
||||
} else if args[0] == "--home" {
|
||||
if m.homes == nil {
|
||||
m.homes = map[string]string{}
|
||||
}
|
||||
m.homes[name] = args[1]
|
||||
} 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 +110,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":[` +
|
||||
@@ -83,7 +166,7 @@ func TestAModulesIdentityIsMadeWithItsNumbersAndRemovedWhenUndeclared(t *testing
|
||||
}
|
||||
want := []string{
|
||||
"groupadd --system --gid 61001 mesh-radarr",
|
||||
"useradd --system --uid 61001 --gid 61001 --no-create-home --home-dir / --shell /usr/bin/nologin mesh-radarr",
|
||||
"useradd --system --uid 61001 --gid 61001 --no-create-home --home-dir /nonexistent --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"))
|
||||
@@ -163,7 +246,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+`",`+
|
||||
@@ -285,3 +370,16 @@ func TestAGrantsGroupThatCouldNotBeLeftIsTriedAgain(t *testing.T) {
|
||||
t.Fatalf("the group was not taken back on the next apply: %+v (%s)", rec, groups)
|
||||
}
|
||||
}
|
||||
|
||||
// Every system the engine runs on that makes accounts makes module identities: arch and alpine alike.
|
||||
func TestArchAndAlpineMakeModuleIdentities(t *testing.T) {
|
||||
for _, name := range []string{"arch", "alpine"} {
|
||||
sys, err := system.For(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := toolFor(sys); err != nil {
|
||||
t.Errorf("%s makes no module identity: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
-845
@@ -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 `<module>.<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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// libraryWalks is every library a take walk is running under now, with the filesystem it walks: set once the walk
|
||||
// has been judged and started, cleared when it ends.
|
||||
var libraryWalks sync.Map
|
||||
|
||||
// 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 && rec.How == store.KeptByWarrant, takenHere:
|
||||
// Taken whole on the operator's word: every sub-path is the library's.
|
||||
v.kind, v.how = libraryKeep, store.KeptByWarrant
|
||||
return v
|
||||
case rec.Kept && rec.KeptOn == here && !moved:
|
||||
// Kept without a warrant (empty, or in shape): a sub-path asked for since that holds data not in the group
|
||||
// waits for the operator's word as the root would (§7).
|
||||
v.kind, v.how = libraryKeep, rec.How
|
||||
if !pathsInShape(l, rec.Paths) {
|
||||
v.kind, v.how = libraryWaitKeep, ""
|
||||
}
|
||||
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:
|
||||
// Nothing there, so no sub-path either: every one is made.
|
||||
v.kind, v.how = libraryKeep, store.KeptEmpty
|
||||
case int(facts.GID) == l.GID && facts.Mode&0o2070 == 0o2070 && sampleInShape(l.Root, l.GID) &&
|
||||
pathsInShape(l, nil):
|
||||
// The root and a bounded sample beneath it already in the group with group write and setgid: data that
|
||||
// is the library's already, kept without asking. One entry of the sample out of shape asks (§7).
|
||||
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)
|
||||
if !lock.TryLock() {
|
||||
// **A take walk is running (up to its bound, half an hour): the apply does not wait for it.** The library is
|
||||
// the walk's until it is through; it keeps it, and its containers are not held for it. Only a walk that
|
||||
// has started, on the filesystem it judged: anything else holding the lock (a take act about to be
|
||||
// refused, say) is short, and the apply waits for it and judges the library as always.
|
||||
if on, walking := libraryWalks.Load(l.Library); walking {
|
||||
out.Action = "unchanged"
|
||||
out.Detail = "the take act is setting its group under its root now; this apply leaves the library to it"
|
||||
return out, libraryVerdict{kind: libraryKeep, how: store.KeptByWarrant, placed: on.(string)}, nil
|
||||
}
|
||||
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
|
||||
r.Paths = unionPaths(r.Paths, l.Paths)
|
||||
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
|
||||
}
|
||||
|
||||
// pathsInShape is whether every requested sub-path not kept before is absent (it is made), empty, or already in the
|
||||
// group with group write and setgid, it and a bounded sample beneath it: what may be kept without the operator's
|
||||
// word (§7). One that cannot be read is not.
|
||||
func pathsInShape(l declaration.Library, kept []string) bool {
|
||||
done := map[string]bool{}
|
||||
for _, p := range kept {
|
||||
done[p] = true
|
||||
}
|
||||
for _, p := range l.Paths {
|
||||
if done[p] {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(l.Root, p)
|
||||
if _, err := lstatWithin(path); errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
facts, err := readFolder(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if facts.Empty {
|
||||
continue
|
||||
}
|
||||
if int(facts.GID) != l.GID || facts.Mode&0o2070 != 0o2070 || !sampleInShape(path, l.GID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// unionPaths is a with every path of b not in it.
|
||||
func unionPaths(a, b []string) []string {
|
||||
have := map[string]bool{}
|
||||
for _, p := range a {
|
||||
have[p] = true
|
||||
}
|
||||
for _, p := range b {
|
||||
if !have[p] {
|
||||
a = append(a, p)
|
||||
have[p] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(a)
|
||||
return a
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//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, Linked: c.Linked, Stripped: c.Stripped}
|
||||
if c.Shared+c.Linked+c.Stripped > 0 {
|
||||
rec.Why = fmt.Sprintf("%d file(s) with another name given the group, %d root's left as they are, %d whose "+
|
||||
"setuid or setgid bit the group change took", c.Linked, c.Shared, c.Stripped)
|
||||
}
|
||||
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) {
|
||||
libraryWalks.Store(name, on)
|
||||
defer libraryWalks.Delete(name)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
//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 on every apply —
|
||||
// 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")
|
||||
if err := os.Mkdir(root, 0o755); 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)
|
||||
}
|
||||
// Kept since it was empty; an app then narrows a requested folder and writes beneath it.
|
||||
movies := filepath.Join(root, "movies")
|
||||
if err := os.Chown(movies, -1, os.Getgid()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unix.Chmod(movies, 0o705); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
film := filepath.Join(movies, "film")
|
||||
if err := os.WriteFile(film, []byte("data"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filmBefore := statOf(t, film)
|
||||
before := statOf(t, movies)
|
||||
report, _, err := applyLib(t, d, store.State{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(report, "library:media"); o.Action != "updated" {
|
||||
t.Fatalf("a kept library was not put back: %+v", o)
|
||||
}
|
||||
after := statOf(t, movies)
|
||||
if int(after.Gid) != gid || after.Mode&0o7777 != 0o2775 {
|
||||
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); f.Mode != filmBefore.Mode || f.Gid != filmBefore.Gid {
|
||||
t.Error("a file under a kept path was changed without the take act")
|
||||
}
|
||||
if rec, _ := libraryRecord("media"); !rec.Kept || rec.How != store.KeptEmpty {
|
||||
t.Errorf("not recorded kept as empty: %+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 os.Geteuid() == 0 { // an account's file, as an *arr app's hardlinked download is
|
||||
if err := os.Chown(secret, 65534, -1); 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.Linked != 1 || rec.Act.Stripped != 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 != 0o0664 {
|
||||
t.Errorf("a file: gid %d mode %o, and the group with 0664 (its setuid bit taken by the group change and "+
|
||||
"never put back) was wanted", st.Gid, st.Mode&0o7777)
|
||||
}
|
||||
if st := statOf(t, secret); int(st.Gid) != gid || st.Mode&0o7777 != 0o660 {
|
||||
t.Error("an account's file with another name (a hardlinked download) was not 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 /nonexistent --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:<name>), 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// §7: a root in shape with old data beneath it not in the group is asked about, never kept without a warrant; a root
|
||||
// and sample all in shape are kept; once taken, never asked again on that filesystem.
|
||||
func TestARootInShapeWithDataBelowNotInTheGroupWaits(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, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := filepath.Join(movies, "old film")
|
||||
if err := os.WriteFile(old, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{root, movies} {
|
||||
if err := os.Chown(p, -1, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unix.Chmod(p, 0o2775); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
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 in shape with an old file below not in the group was kept without asking: %+v", o)
|
||||
}
|
||||
// The old file in the group too: the sample is in shape, and the library is kept.
|
||||
if err := os.Chown(old, -1, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unix.Chmod(old, 0o664); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report, _, _ = applyLib(t, d, store.State{}); outcomeOf(report, "library:media").Action == "waiting" {
|
||||
t.Fatal("a root and sample in shape were not kept")
|
||||
}
|
||||
// Once kept, a file an app later leaves out of the group asks nothing again on that filesystem.
|
||||
if err := os.WriteFile(filepath.Join(movies, "new"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report, _, _ = applyLib(t, d, store.State{}); outcomeOf(report, "library:media").Action == "waiting" {
|
||||
t.Fatal("a kept library asked again")
|
||||
}
|
||||
}
|
||||
|
||||
// Review: a file with another name that root owns is left as it is (its other name may be anywhere), and a setgid
|
||||
// file never keeps its setgid bit once regrouped.
|
||||
func TestRootsLinkedFilesAreLeftAndSetgidIsNeverKept(t *testing.T) {
|
||||
gid := testGID(t)
|
||||
f, err := os.CreateTemp(t.TempDir(), "file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := unix.Chmod(f.Name(), 0o2755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := statOf(t, f.Name())
|
||||
rootLinked := st
|
||||
rootLinked.Uid, rootLinked.Nlink = 0, 2
|
||||
if did, err := groupVisit(gid)(int(f.Fd()), rootLinked, false); err != nil || did != visitShared {
|
||||
t.Fatalf("a root-owned file with another name was not left: %d %v", did, err)
|
||||
}
|
||||
if after := statOf(t, f.Name()); after.Gid != st.Gid || after.Mode != st.Mode {
|
||||
t.Fatal("a root-owned file with another name was changed")
|
||||
}
|
||||
did, err := groupVisit(gid)(int(f.Fd()), st, false)
|
||||
if err != nil || did&visitStripped == 0 {
|
||||
t.Fatalf("a setgid file's bit is not said taken: %d %v", did, err)
|
||||
}
|
||||
if after := statOf(t, f.Name()); after.Mode&(unix.S_ISGID|unix.S_ISUID) != 0 || int(after.Gid) != gid {
|
||||
t.Fatalf("a setgid file kept its bit in the library's group: %o", after.Mode&0o7777)
|
||||
}
|
||||
}
|
||||
|
||||
// Review: a sub-path asked for after the library was kept without a warrant, which holds data not in the group,
|
||||
// waits for the operator's word as the root would, and nothing of it is changed; an empty one is kept.
|
||||
func TestASubPathHoldingOldDataWaitsForTheOperatorsWord(t *testing.T) {
|
||||
gid := testGID(t)
|
||||
libraries(t, nil, nil)
|
||||
root := filepath.Join(t.TempDir(), "media")
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false, "movies")), store.State{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Someone puts old data at a sub-path the library is asked for only now.
|
||||
music := filepath.Join(root, "music")
|
||||
if err := os.MkdirAll(filepath.Join(music, "album"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := statOf(t, music)
|
||||
report, _, err := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false, "movies", "music")), 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 sub-path holding old data was kept without a warrant: %+v", o)
|
||||
}
|
||||
if after := statOf(t, music); after.Gid != before.Gid || after.Mode != before.Mode {
|
||||
t.Fatal("a sub-path holding old data was changed")
|
||||
}
|
||||
// An empty one asked for is kept.
|
||||
if err := os.RemoveAll(filepath.Join(music, "album")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, _, _ = applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false, "movies", "music")), store.State{})
|
||||
if o := outcomeOf(report, "library:media"); o.Action == "waiting" {
|
||||
t.Fatalf("an empty sub-path was not kept: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// Review: an apply does not wait for a take walk that is running: it leaves the library to it.
|
||||
func TestAnApplyDoesNotWaitForATakeWalk(t *testing.T) {
|
||||
gid := testGID(t)
|
||||
libraries(t, nil, nil)
|
||||
root := filepath.Join(t.TempDir(), "media")
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lock := libraryLock("media")
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
libraryWalks.Store("media", "fs-test")
|
||||
defer libraryWalks.Delete("media")
|
||||
done := make(chan Report, 1)
|
||||
go func() {
|
||||
r, _, _ := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false)), store.State{})
|
||||
done <- r
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
if o := outcomeOf(r, "library:media"); !strings.Contains(o.Detail, "take act") {
|
||||
t.Fatalf("the apply does not say it left the library to the walk: %+v", o)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the apply waited for the take walk")
|
||||
}
|
||||
}
|
||||
|
||||
// Review: a root in shape whose data beneath — in no requested sub-path — is not in the group waits: the sample, not
|
||||
// the root alone, says whether the data is the library's already (§7).
|
||||
func TestTheSampleBeneathARootInShapeIsJudged(t *testing.T) {
|
||||
gid := testGID(t)
|
||||
libraries(t, nil, nil)
|
||||
root := filepath.Join(t.TempDir(), "media")
|
||||
if err := os.MkdirAll(filepath.Join(root, "old"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "old", "film"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chown(root, -1, gid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unix.Chmod(root, 0o2775); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, _, err := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false)), store.State{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(report, "library:media"); o.Action != "waiting" {
|
||||
t.Fatalf("a root in shape with data beneath not in the group was kept: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// Review: an apply that finds the library's lock held by something that is no take walk (a take act about to be
|
||||
// refused) waits for it and judges the library as always: a library whose disk is not there still holds.
|
||||
func TestAnApplyWaitsForALockThatIsNoWalk(t *testing.T) {
|
||||
gid := testGID(t)
|
||||
libraries(t, nil, nil)
|
||||
root := filepath.Join(t.TempDir(), "gone", "media")
|
||||
lock := libraryLock("media")
|
||||
lock.Lock()
|
||||
go func() { time.Sleep(50 * time.Millisecond); lock.Unlock() }()
|
||||
report, _, _ := applyLib(t, libraryDecl(t, libJSON("media", root, gid, true, false)), store.State{})
|
||||
if o := outcomeOf(report, "library:media"); o.Action != "waiting" || !strings.Contains(o.Detail, DiskWords("media")) {
|
||||
t.Fatalf("a library whose root is not there was read as kept while its lock was held: %+v", o)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+22
-33
@@ -6,60 +6,49 @@ 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; Linked files with another name an account owns, given the group; Shared files with another
|
||||
// name root owns, left as they are; Stripped files whose setuid or setgid bit the group change took.
|
||||
Links, Other, Mounts, Linked, Shared, Stripped 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
|
||||
|
||||
+255
-292
@@ -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,45 @@ 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, as flags: nothing (it had what it needs), or changed, and beside it whether it was a
|
||||
// file with another name (linked, changed; shared, left), and whether its setuid or setgid bit went.
|
||||
const (
|
||||
visitKept = 0
|
||||
visitChanged = 1 << iota
|
||||
visitLinked // a file with another name, owned by an account: given the group
|
||||
visitShared // a file with another name, owned by root: left as it is
|
||||
visitStripped // a file whose setuid or setgid bit the group change took, and which stays taken
|
||||
)
|
||||
|
||||
// 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 +76,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 +114,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 +153,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 +211,88 @@ 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 != 0:
|
||||
w.counts.Shared++
|
||||
case did&visitChanged != 0 && folder:
|
||||
w.counts.Folders++
|
||||
case did&visitChanged != 0:
|
||||
w.counts.Files++
|
||||
default:
|
||||
w.counts.Kept++
|
||||
}
|
||||
if did&visitLinked != 0 {
|
||||
w.counts.Linked++
|
||||
}
|
||||
if did&visitStripped != 0 {
|
||||
w.counts.Stripped++
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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 — but for what the group change itself takes:
|
||||
//
|
||||
// - **A file's setuid and setgid bits stay taken.** The kernel drops them when a file's group changes; a setgid
|
||||
// file in the library's group would hand anyone who runs it the group, and a setuid one is no library data. They
|
||||
// are never put back, setgid is taken off a file the group change left it on, and each such file is counted.
|
||||
// - **A file with another name** (the *arr apps hardlink downloads into the library) is given the group when an
|
||||
// account owns it: the kernel's protected_hardlinks already keeps an account from linking a file it does not
|
||||
// own. One root owns is left as it is: its other name may be anywhere, and the group would reach it. Both are
|
||||
// counted.
|
||||
func groupVisit(gid int) visitFunc {
|
||||
return func(fd int, st unix.Stat_t, folder bool) (int, error) {
|
||||
did := visitKept
|
||||
if !folder && st.Nlink > 1 {
|
||||
if st.Uid == 0 {
|
||||
return visitShared, nil
|
||||
}
|
||||
changed = true
|
||||
did |= visitLinked
|
||||
}
|
||||
mode := uint32(st.Mode) & 0o7777
|
||||
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
|
||||
}
|
||||
regroup := int(st.Gid) != gid
|
||||
if !folder && regroup {
|
||||
want &^= unix.S_ISUID | unix.S_ISGID
|
||||
}
|
||||
if !regroup && want == mode {
|
||||
return did &^ visitLinked, nil
|
||||
}
|
||||
if regroup {
|
||||
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)
|
||||
}
|
||||
if !folder && mode&(unix.S_ISUID|unix.S_ISGID) != 0 && want&(unix.S_ISUID|unix.S_ISGID) == 0 {
|
||||
did |= visitStripped
|
||||
}
|
||||
return did | 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 +310,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 +341,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
|
||||
}
|
||||
|
||||
@@ -570,3 +474,62 @@ var MountUUID = func(point string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SampleLimits bound the sample that judges whether a root's data is already the library's: so many entries a
|
||||
// folder, so many levels down, for so long. A sample that runs out of time is not in shape: it asks.
|
||||
var SampleLimits = struct {
|
||||
PerFolder, Levels int
|
||||
Time time.Duration
|
||||
}{PerFolder: 20, Levels: 3, Time: 2 * time.Second}
|
||||
|
||||
// sampleInShape is whether the first entries of each folder under root, a few levels down, are all in the group gid
|
||||
// with group read and write, and folders with group enter and setgid (ADR 0328 §7). Reads only, by descriptor: no
|
||||
// link followed, no other filesystem entered. A file with more than one name is not judged (the take act leaves it).
|
||||
func sampleInShape(root string, gid int) bool {
|
||||
_, fd, st, err := openRoot(root, WalkLimits{Entries: 1, Time: time.Minute}, machineStats, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(SampleLimits.Time)
|
||||
return sampleFolder(fd, uint64(st.Dev), gid, SampleLimits.Levels, deadline)
|
||||
}
|
||||
|
||||
// sampleFolder judges the folder open on fd's first entries, and the folders among them levels further. It closes fd.
|
||||
func sampleFolder(fd int, dev uint64, gid, levels int, deadline time.Time) bool {
|
||||
f := os.NewFile(uintptr(fd), "")
|
||||
defer f.Close()
|
||||
names, err := f.Readdirnames(SampleLimits.PerFolder)
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return false
|
||||
}
|
||||
for _, name := range names {
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstatat(fd, name, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
||||
return false
|
||||
}
|
||||
if uint64(st.Dev) != dev {
|
||||
continue
|
||||
}
|
||||
mode := uint32(st.Mode)
|
||||
switch mode & unix.S_IFMT {
|
||||
case unix.S_IFREG:
|
||||
if st.Nlink == 1 && (int(st.Gid) != gid || mode&0o060 != 0o060) {
|
||||
return false
|
||||
}
|
||||
case unix.S_IFDIR:
|
||||
if int(st.Gid) != gid || mode&0o2070 != 0o2070 {
|
||||
return false
|
||||
}
|
||||
if levels > 1 {
|
||||
next, err := unix.Openat(fd, name, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
|
||||
if err != nil || !sampleFolder(next, dev, gid, levels-1, deadline) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,57 +7,33 @@ 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 "" }
|
||||
func sampleInShape(string, int) bool { return false }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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-<module>` 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-<library>`
|
||||
// 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-<module>` 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:<name>`.
|
||||
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<library>", 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 "+
|
||||
|
||||
+11
-17
@@ -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:<name>`, 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+32
-40
@@ -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:<name>`.
|
||||
// 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 <node> <path>` 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:<name>`
|
||||
// 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:<name>`, 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,11 @@ 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 root owns, left as they are; Linked those an account owns, given the
|
||||
// group; Stripped the files whose setuid or setgid bit the group change took.
|
||||
Shared int `json:"shared,omitempty"`
|
||||
Linked int `json:"linked,omitempty"`
|
||||
Stripped int `json:"stripped,omitempty"`
|
||||
// Why is what stopped or failed it, in words with no path.
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:<the filesystem's UUID>", "fsid:<statfs's id>"
|
||||
// or "source:<type>:<mountinfo's 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)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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"`
|
||||
// Paths are the sub-paths kept so far on the filesystem KeptOn names: one asked for later that holds data not
|
||||
// in the group waits for the operator's word as a root would, unless the library was taken whole.
|
||||
Paths []string `json:"paths,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 root owns, 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"`
|
||||
// Linked is the files with another name an account owns, given the group; Stripped the files whose setuid or
|
||||
// setgid bit the group change took, and which stays taken.
|
||||
Linked int `json:"linked,omitempty"`
|
||||
Stripped int `json:"stripped,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)
|
||||
}
|
||||
+23
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,16 +19,52 @@ func (arch) CreateSystemGroup(ctx context.Context, run Runner, name string, gid
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSystemAccount makes a system account with uid and the group gid as its own: no home made, `/` as the
|
||||
// NoHome is the home field of every module account: a path that is not there, so nothing that follows an account's
|
||||
// home — usermod regrouping every file under it, for one — ever walks a real tree. Never `/`: `usermod --gid` on
|
||||
// an account whose home is `/` regroups every file it owns on the machine.
|
||||
const NoHome = "/nonexistent"
|
||||
|
||||
// CreateSystemAccount makes a system account with uid and the group gid as its own: no home made, NoHome 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", NoHome, "--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.
|
||||
//
|
||||
// **Its home is moved to NoHome first, never its files.** `usermod --gid` regroups every file under the account's
|
||||
// home that the account owns; an account made with `/` as its home (an earlier engine's) would have the whole
|
||||
// machine walked as root. So an account whose home is not NoHome is given NoHome first, without -m: no file moves.
|
||||
func (arch) ModifyAccount(ctx context.Context, run Runner, name string, gid int, groups []string) error {
|
||||
passwd, err := run(ctx, "getent", "passwd", name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read the account %q: %w", name, err)
|
||||
}
|
||||
fields := strings.Split(strings.TrimSpace(passwd), ":")
|
||||
if len(fields) < 6 {
|
||||
return fmt.Errorf("the user database gave %q for %q, which is not a passwd entry", strings.TrimSpace(passwd),
|
||||
name)
|
||||
}
|
||||
if fields[5] != NoHome {
|
||||
if _, err := run(ctx, "usermod", "--home", NoHome, name); err != nil {
|
||||
return fmt.Errorf("cannot give the account %q no home before its groups: %w", name, err)
|
||||
}
|
||||
}
|
||||
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 +90,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)
|
||||
@@ -63,10 +99,60 @@ func (alpine) CreateSystemAccount(ctx context.Context, run Runner, name string,
|
||||
if n, _, ok := strings.Cut(group, ":"); ok {
|
||||
groupName = n
|
||||
}
|
||||
if _, err := run(ctx, "adduser", "-S", "-D", "-H", "-h", "/", "-s", "/sbin/nologin", "-u", strconv.Itoa(uid),
|
||||
if _, err := run(ctx, "adduser", "-S", "-D", "-H", "-h", NoHome, "-s", "/sbin/nologin", "-u", strconv.Itoa(uid),
|
||||
"-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 gives an account the mesh made exactly groups as its added groups, with busybox's addgroup and
|
||||
// delgroup. Its primary group busybox cannot change (it has no usermod, and the mesh edits no passwd by hand):
|
||||
// an account whose primary group must change is refused, saying so, and left as it is.
|
||||
func (alpine) ModifyAccount(ctx context.Context, run Runner, name string, gid int, groups []string) error {
|
||||
passwd, err := run(ctx, "getent", "passwd", name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read the account %q: %w", name, err)
|
||||
}
|
||||
fields := strings.Split(strings.TrimSpace(passwd), ":")
|
||||
if len(fields) < 4 || fields[3] != strconv.Itoa(gid) {
|
||||
return fmt.Errorf("this system cannot change the account %q's primary group to %d: busybox has no usermod, "+
|
||||
"and the mesh edits no passwd by hand: %w", name, gid, ErrUnsupported)
|
||||
}
|
||||
primary := ""
|
||||
if g, err := run(ctx, "getent", "group", fields[3]); err == nil {
|
||||
primary, _, _ = strings.Cut(strings.TrimSpace(g), ":")
|
||||
}
|
||||
out, err := run(ctx, "id", "-nG", name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read the account %q's groups: %w", name, err)
|
||||
}
|
||||
have, want := map[string]bool{}, map[string]bool{}
|
||||
for _, g := range strings.Fields(out) {
|
||||
if g != primary && g != name {
|
||||
have[g] = true
|
||||
}
|
||||
}
|
||||
for _, g := range groups {
|
||||
want[g] = true
|
||||
if !have[g] {
|
||||
if _, err := run(ctx, "addgroup", name, g); err != nil {
|
||||
return fmt.Errorf("cannot put the account %q in %q: %w", name, g, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for g := range have {
|
||||
if !want[g] {
|
||||
if _, err := run(ctx, "delgroup", name, g); err != nil {
|
||||
return fmt.Errorf("cannot take the account %q out of %q: %w", name, g, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// busybox has no usermod (novox/hq ADR 0328 §4): an account the mesh made is given its added groups with addgroup
|
||||
// and delgroup, and a change of its primary group is refused, saying why.
|
||||
func TestBusyboxGivesAnAccountItsAddedGroupsAndRefusesAnotherPrimary(t *testing.T) {
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
switch {
|
||||
case name == "getent" && args[0] == "passwd":
|
||||
return "mesh-radarr:x:61001:62000::/:/sbin/nologin\n", nil
|
||||
case name == "getent" && args[0] == "group":
|
||||
return "mesh-media:x:62000:\n", nil
|
||||
case name == "id":
|
||||
return "mesh-media mesh-old\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if err := (alpine{}).ModifyAccount(context.Background(), run, "mesh-radarr", 62000, []string{"mesh-books"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(ran, "\n")
|
||||
if !strings.Contains(got, "addgroup mesh-radarr mesh-books") || !strings.Contains(got, "delgroup mesh-radarr mesh-old") ||
|
||||
strings.Contains(got, "delgroup mesh-radarr mesh-media") {
|
||||
t.Fatalf("not given exactly its added groups:\n%s", got)
|
||||
}
|
||||
err := (alpine{}).ModifyAccount(context.Background(), run, "mesh-radarr", 62005, nil)
|
||||
if !errors.Is(err, ErrUnsupported) || !strings.Contains(err.Error(), "primary group") {
|
||||
t.Fatalf("a change of primary group was not refused saying why: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An account is made with no home, and a change of its groups moves an old home of `/` away first: `usermod --gid`
|
||||
// regroups every file under the home the account owns, and under `/` that is the whole machine.
|
||||
func TestAnAccountsGroupsChangeOnlyWithNoHome(t *testing.T) {
|
||||
var ran []string
|
||||
home := "/"
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
ran = append(ran, line)
|
||||
if name == "getent" {
|
||||
return "mesh-radarr:x:61001:61001::" + home + ":/usr/bin/nologin\n", nil
|
||||
}
|
||||
if name == "usermod" && args[0] == "--home" {
|
||||
home = args[1]
|
||||
}
|
||||
if name == "usermod" && args[0] == "--gid" && home != NoHome {
|
||||
t.Fatalf("usermod --gid ran with the home %s: every file there would be regrouped", home)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if err := (arch{}).CreateSystemAccount(context.Background(), run, "mesh-radarr", 61001, 61001, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(ran[0], "--no-create-home --home-dir "+NoHome+" ") {
|
||||
t.Fatalf("an account is made with a home: %s", ran[0])
|
||||
}
|
||||
if err := (arch{}).ModifyAccount(context.Background(), run, "mesh-radarr", 62000, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := strings.Join(ran, "\n")
|
||||
if !strings.Contains(got, "usermod --home "+NoHome+" mesh-radarr") || strings.Contains(got, " -m") ||
|
||||
strings.Contains(got, "--move-home") {
|
||||
t.Fatalf("the home was not moved away first, without moving files:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// busybox removes what the mesh made of an identity with deluser and delgroup.
|
||||
func TestBusyboxRemovesAnAccountAndAGroup(t *testing.T) {
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
return "", nil
|
||||
}
|
||||
if err := (alpine{}).RemoveAccount(context.Background(), run, "mesh-radarr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (alpine{}).RemoveGroup(context.Background(), run, "mesh-media"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(ran, "\n") != "deluser mesh-radarr\ndelgroup mesh-media" {
|
||||
t.Fatalf("removed with: %v", ran)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user