The controller cannot know the operator's account's uid, names an access by its declared id (which holds a slash), and should not parse words. A tool runner's account may now come by name alone and is read here (refused when absent or root's, its uid said in the access's listing); the take act matches the declared id exactly; an access wait says its reason (keep or disk) and, for a filesystem that refuses ACLs, whether the take act can enable them.
1015 lines
38 KiB
Go
1015 lines
38 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/novox/mesh-host/internal/accounts"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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 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).
|
|
|
|
// 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
|
|
}
|
|
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)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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()
|
|
if err != nil {
|
|
return store.KeptFolder{}, false
|
|
}
|
|
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 {
|
|
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}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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)
|
|
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}
|
|
}
|
|
if err != nil {
|
|
return folderVerdict{err: errors.New("the folder cannot be read: " + pathless(err))}
|
|
}
|
|
if !info.IsDir() {
|
|
return folderVerdict{err: errors.New("the folder is not a directory, and an access is a shared directory")}
|
|
}
|
|
pl := placedOn(ctx, path, path, rec.Mount, others)
|
|
if pl.Away {
|
|
return folderVerdict{kind: folderWaitDisk, mount: pl.Mount}
|
|
}
|
|
// 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...)})
|
|
}
|
|
|
|
// PlacedID names the filesystem at mount for a wait, with no path in it: the controller asks again when the
|
|
// folder's filesystem is not the one a warrant was given for. Empty when the filesystem cannot be named, and a folder
|
|
// on it is then never kept (it fails closed, and waits).
|
|
func PlacedID(mount string) string { return placedFrom(mount, filesystemKey(mount)) }
|
|
|
|
// placedFrom is PlacedID from a filesystem key already read.
|
|
func placedFrom(mount, key string) string {
|
|
if mount == "" || key == "" {
|
|
return ""
|
|
}
|
|
sum := sha256.Sum256([]byte(mount + "\x00" + key))
|
|
return "fs-" + hex.EncodeToString(sum[:6])
|
|
}
|
|
|
|
// filesystemKey is what names the filesystem at mount across reboots, in this order:
|
|
//
|
|
// - a filesystem on a block device: its UUID, found by matching the mount's device against the devices in
|
|
// /dev/disk/by-uuid (never blkid). xfs's statfs id follows the device number, so the UUID comes first;
|
|
// - else the kernel's filesystem id (statfs f_fsid) when it is not zero: ZFS's from its dataset, btrfs's from its
|
|
// UUID;
|
|
// - else mountinfo's source with the filesystem's type: an NFS export's server:/export, a FUSE filesystem's own
|
|
// source string (both report f_fsid 0).
|
|
//
|
|
// Never the mount point and type alone, and never a device's name, which a reboot may renumber. Empty when none of
|
|
// these answers within statBound: then nothing is kept there. Read once per mount at a time.
|
|
func filesystemKey(mount string) string {
|
|
if mount == "" {
|
|
return ""
|
|
}
|
|
v, ok := boundedOnce("fs "+mount, func() any { return readFilesystemKey(mount) })
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return v.(string)
|
|
}
|
|
|
|
func readFilesystemKey(mount string) string {
|
|
if u := MountUUID(mount); u != "" {
|
|
return "uuid:" + u
|
|
}
|
|
if id := MountFsid(mount); strings.Trim(id, "0") != "" {
|
|
return "fsid:" + id
|
|
}
|
|
if source, fstype := mountSourceOf(mount); source != "" && source != "none" {
|
|
return "source:" + fstype + ":" + source
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// statBound is how long a read of a folder may take before it is said not to answer: a hard network mount whose
|
|
// server is down blocks a stat for ever, and one access must never hold the apply.
|
|
var statBound = 5 * time.Second
|
|
|
|
// errNoAnswer is a folder whose read did not return within statBound.
|
|
var errNoAnswer = errors.New("it does not answer: a read of it did not return within its bound (a mount that is down)")
|
|
|
|
// lstatWithin is os.Lstat within statBound, and statWithin os.Stat. A read that hangs is left to return on its own.
|
|
|
|
func lstatWithin(path string) (os.FileInfo, error) { return within("lstat", os.Lstat, path) }
|
|
func statWithin(path string) (os.FileInfo, error) { return within("stat", os.Stat, path) }
|
|
|
|
// flight is one read in flight, shared by everyone who asks while it is: a read that hangs on a dead mount holds
|
|
// one goroutine per path, never one per apply or look.
|
|
type flight struct {
|
|
done chan struct{}
|
|
v any
|
|
}
|
|
|
|
var inFlight = struct {
|
|
sync.Mutex
|
|
reads map[string]*flight
|
|
}{reads: map[string]*flight{}}
|
|
|
|
// boundedOnce is read's answer within statBound, sharing a read of key already in flight; false when it did not
|
|
// answer in time.
|
|
func boundedOnce(key string, read func() any) (any, bool) {
|
|
inFlight.Lock()
|
|
f, running := inFlight.reads[key]
|
|
if !running {
|
|
f = &flight{done: make(chan struct{})}
|
|
inFlight.reads[key] = f
|
|
go func() {
|
|
f.v = read()
|
|
inFlight.Lock()
|
|
delete(inFlight.reads, key)
|
|
inFlight.Unlock()
|
|
close(f.done)
|
|
}()
|
|
}
|
|
inFlight.Unlock()
|
|
select {
|
|
case <-f.done:
|
|
return f.v, true
|
|
case <-time.After(statBound):
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func within(op string, stat func(string) (os.FileInfo, error), path string) (os.FileInfo, error) {
|
|
type read struct {
|
|
info os.FileInfo
|
|
err error
|
|
}
|
|
v, ok := boundedOnce(op+" "+path, func() any {
|
|
info, err := stat(path)
|
|
return read{info, err}
|
|
})
|
|
if !ok {
|
|
return nil, errNoAnswer
|
|
}
|
|
r := v.(read)
|
|
return r.info, r.err
|
|
}
|
|
|
|
// absPath is an absolute path in words: what pathless takes out.
|
|
var absPath = regexp.MustCompile(`(^|[\s"'(=])/[^\s"',;:)]*`)
|
|
|
|
// Pathless is an error in words with no path (ADR 0234 §6): a path error's operation and cause, and any other
|
|
// path said as "a path".
|
|
func Pathless(err error) string { return pathless(err) }
|
|
|
|
func pathless(err error) string {
|
|
var pe *os.PathError
|
|
if errors.As(err, &pe) {
|
|
return pe.Op + ": " + pe.Err.Error()
|
|
}
|
|
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
|
|
}
|