ADR 0328 supersedes ADR 0325: shared data is a library with one home, kept usable by its group rather than by ACL entries. The engine now makes each library's group and each module's user in its libraries' groups; keeps a library's root and requested sub-paths in its group with group write and setgid on its home, making missing sub-paths as the root is; waits for the operator's word before touching a root that holds data, and on the take act walks it by descriptor (no link, same filesystem, bounded and resumed, files with other names left); holds the containers of a library whose disk or mount is not there; and puts the tool runner's account in the group. The ACL keeping, mask repair and watch are gone; an access is again as before ADR 0325.
245 lines
8.8 KiB
Go
245 lines
8.8 KiB
Go
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")
|
|
}
|