package apply import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "regexp" "strings" "sync" "time" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" "github.com/novox/mesh-host/internal/system" ) // 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. // 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 } 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 } // 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, "; ") } // 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 } // 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") }