mesh/merge-gate head d237dee4c9e3 merged into trunk cd13f7726ef0 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head d237dee4c9e3 merged into trunk cd13f7726ef0 — fail: its merge-check.sh failed: --- FAIL: TestAMoveOutDirectoryKeepsOnlyWhatItKnows (0.…
mesh/delivery superseded: a newer head of the same pull request
A directory or archive resource carries `unknown` (move-out, module, program) and `knows`. For move-out under a home, every look moves each path the declaration does not know into ~/.local/state/mesh-moved/<module>/<time>/, the list written before each move, without following a link, a young path waiting, a socket or pipe left and reported, nothing made when nothing moved, and another file system refused. Reviewed in two rounds.
372 lines
11 KiB
Go
372 lines
11 KiB
Go
package apply
|
|
|
|
// A directory or archive that says unknown "move-out" keeps only what it knows (issue 1024, novox/hq ADR 0422).
|
|
//
|
|
// Everything inside it that nothing declared, no archive unpacked and `knows` does not name is moved, whole, into
|
|
// `<home>/.local/state/mesh-moved/<module>/<UTC time>/`, with a list beside it that names each move before it is
|
|
// made (ADR 0417 §7). Nothing is deleted: the owner of the path reacts to a file it did not write by keeping it
|
|
// where its writer can find it. The pass runs after the resources of an apply, so a reconcile (every five minutes)
|
|
// runs it too.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// movedOutWait is how old a path is before it is moved: a younger one may be mid-write by whatever made it, and
|
|
// waits for the next look (ADR 0417 §4).
|
|
const movedOutWait = time.Minute
|
|
|
|
// MovedOut is one path a move-out directory moved.
|
|
type MovedOut struct {
|
|
Resource string `json:"resource"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
}
|
|
|
|
// LeftInPlace is a socket, pipe or device a move-out directory did not move (ADR 0422 §4).
|
|
type LeftInPlace struct {
|
|
Resource string `json:"resource"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// renamePath and deviceOf are the machine's calls, variables so a test can fail a move or name another file system.
|
|
// renamePath renames from to to without following a link in either parent below home (move_out_linux.go).
|
|
var renamePath = renameUnder
|
|
|
|
type movedEntry struct {
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
}
|
|
|
|
// moveOutOwner is the user and group the kept folders and the list are given: the declared owner, or the home's
|
|
// account.
|
|
func moveOutOwner(home, owner string) (uid, gid int, err error) {
|
|
if owner != "" {
|
|
return idsOf(owner)
|
|
}
|
|
acc, ok := accountsOfHomes()[home]
|
|
if !ok {
|
|
return 0, 0, fmt.Errorf("%s is not the home of an account", home)
|
|
}
|
|
if u, g, err := idsOf(acc.Name); err == nil {
|
|
return u, g, nil
|
|
}
|
|
return acc.UID, acc.UID, nil
|
|
}
|
|
|
|
// knownUnder is what a directory knows of its own contents: the declared targets, the files an archive unpacked,
|
|
// `knows` entries, and every folder above one of them.
|
|
type knownUnder struct {
|
|
exact map[string]bool
|
|
prefixes []string
|
|
// own are nested directories and archives that say what they do with unknown paths themselves.
|
|
own map[string]bool
|
|
}
|
|
|
|
func (k *knownUnder) add(root, path string) {
|
|
path = filepath.Clean(path)
|
|
for path != root && strings.HasPrefix(path, root+string(os.PathSeparator)) {
|
|
k.exact[path] = true
|
|
path = filepath.Dir(path)
|
|
}
|
|
}
|
|
|
|
func (k *knownUnder) knows(path string) bool {
|
|
if k.exact[path] {
|
|
return true
|
|
}
|
|
for _, p := range k.prefixes {
|
|
if path == p || strings.HasPrefix(path, p+string(os.PathSeparator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func knownOf(d *declaration.Declaration, known store.State, id, root string, knows []string) *knownUnder {
|
|
k := &knownUnder{exact: map[string]bool{}, own: map[string]bool{}}
|
|
for _, r := range d.Resources {
|
|
target := filepath.Clean(r.Target())
|
|
if target == "" || target == "." {
|
|
continue
|
|
}
|
|
if r.Identity() != id && strings.HasPrefix(target, root+string(os.PathSeparator)) {
|
|
k.add(root, target)
|
|
switch o := r.(type) {
|
|
case *declaration.Directory:
|
|
if o.Unknown != "" {
|
|
k.own[target] = true
|
|
}
|
|
case *declaration.Archive:
|
|
if o.Unknown != "" {
|
|
k.own[target] = true
|
|
}
|
|
}
|
|
}
|
|
if a, ok := r.(*declaration.Archive); ok {
|
|
if rec, found := known.Find(a.ID); found && rec.Unpacked != nil {
|
|
base := filepath.Clean(a.Path)
|
|
for _, f := range append(append([]string{}, rec.Unpacked.Files...), rec.Unpacked.Dirs...) {
|
|
p := filepath.Join(base, filepath.FromSlash(f))
|
|
if p == root || strings.HasPrefix(p, root+string(os.PathSeparator)) {
|
|
k.add(root, p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, e := range knows {
|
|
p := filepath.Join(root, filepath.FromSlash(strings.TrimSuffix(e, "/")))
|
|
if strings.HasSuffix(e, "/") {
|
|
k.prefixes = append(k.prefixes, p)
|
|
} else {
|
|
k.exact[p] = true
|
|
}
|
|
k.add(root, p)
|
|
}
|
|
return k
|
|
}
|
|
|
|
// moveOut moves what the directory at dir does not know, for the resource id. It answers the paths moved.
|
|
func moveOut(d *declaration.Declaration, known store.State, id, module, dir, owner string, knows []string,
|
|
log func(string)) ([]MovedOut, []LeftInPlace, error) {
|
|
dir = filepath.Clean(dir)
|
|
home := homeAbove(dir)
|
|
if home == "" {
|
|
return nil, nil, fmt.Errorf("%s: move-out is only for a directory under an account's home, and %s is under none "+
|
|
"(ADR 0422 §5); nothing was moved", id, dir)
|
|
}
|
|
k := knownOf(d, known, id, dir, knows)
|
|
skip := filepath.Join(keptRoot(home), module)
|
|
var candidates []string
|
|
var left []string
|
|
err := filepath.WalkDir(dir, func(path string, e fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if path == dir {
|
|
return nil
|
|
}
|
|
if path == skip || path == keptRoot(home) {
|
|
return fs.SkipDir
|
|
}
|
|
if k.own[path] {
|
|
if e.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if k.knows(path) {
|
|
if e.IsDir() && !k.exact[path] {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
// Unknown. Moved whole, so never descended into.
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
switch {
|
|
case info.Mode()&(os.ModeSocket|os.ModeNamedPipe|os.ModeDevice|os.ModeCharDevice) != 0:
|
|
left = append(left, path)
|
|
case time.Since(info.ModTime()) < movedOutWait:
|
|
default:
|
|
candidates = append(candidates, path)
|
|
}
|
|
if e.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%s: cannot look through %s: %w", id, dir, err)
|
|
}
|
|
for _, p := range left {
|
|
log(fmt.Sprintf(" left %s in %s: a socket, pipe or device is not moved (ADR 0422)", p, dir))
|
|
}
|
|
var stayed []LeftInPlace
|
|
for _, p := range left {
|
|
stayed = append(stayed, LeftInPlace{Resource: id, Path: p})
|
|
}
|
|
if len(candidates) == 0 {
|
|
return nil, stayed, nil
|
|
}
|
|
sort.Strings(candidates)
|
|
|
|
uid, gid, err := moveOutOwner(home, owner)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%s: %w", id, err)
|
|
}
|
|
|
|
// The same file system first, so a move is a rename and nothing is copied (ADR 0417 §5).
|
|
ancestor := skip
|
|
for {
|
|
if _, err := os.Lstat(ancestor); err == nil {
|
|
break
|
|
}
|
|
ancestor = filepath.Dir(ancestor)
|
|
}
|
|
devDir, err1 := deviceOf(dir)
|
|
devKept, err2 := deviceOf(ancestor)
|
|
if err1 != nil || err2 != nil {
|
|
return nil, nil, fmt.Errorf("%s: cannot read the file system of %s or %s: %v %v", id, dir, ancestor, err1, err2)
|
|
}
|
|
if devDir != devKept {
|
|
return nil, nil, fmt.Errorf("%s: %s and %s are on different file systems (devices %d and %d), so a move would "+
|
|
"copy; nothing was moved (ADR 0417 §5)", id, dir, ancestor, devDir, devKept)
|
|
}
|
|
|
|
// The kept folder and the list, named by the second; a clash in the same second gets -2, -3.
|
|
stamp := time.Now().UTC().Format("20060102T150405Z")
|
|
kept, listPath := "", ""
|
|
for n := 1; ; n++ {
|
|
name := stamp
|
|
if n > 1 {
|
|
name = fmt.Sprintf("%s-%d", stamp, n)
|
|
}
|
|
kept, listPath = filepath.Join(skip, name), filepath.Join(skip, name+".json")
|
|
if !pathExists(kept) && !pathExists(listPath) {
|
|
break
|
|
}
|
|
}
|
|
var made []string
|
|
cleanup := func() {
|
|
_ = os.Remove(listPath)
|
|
// In the reverse of the order they were made, so a folder goes after what is in it.
|
|
for i := len(made) - 1; i >= 0; i-- {
|
|
_ = os.Remove(made[i])
|
|
}
|
|
}
|
|
chown := func(paths []string) error {
|
|
for _, p := range paths {
|
|
if err := chownIfNeeded(home, p, uid, gid); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
madeNow, err := mkdirAllUnder(home, kept, 0o700)
|
|
made = append(made, reversed(madeNow)...)
|
|
if err == nil {
|
|
err = chown(madeNow)
|
|
}
|
|
if err != nil {
|
|
cleanup()
|
|
return nil, nil, fmt.Errorf("%s: cannot make the kept folder %s: %w", id, kept, err)
|
|
}
|
|
|
|
var list []movedEntry
|
|
var moved []MovedOut
|
|
var failures []string
|
|
for _, from := range candidates {
|
|
rel, _ := filepath.Rel(dir, from)
|
|
to := filepath.Join(kept, rel)
|
|
// Named before it is moved: a move that stops half way leaves its entry (ADR 0417 §7).
|
|
list = append(list, movedEntry{From: from, To: to})
|
|
raw, _ := json.MarshalIndent(list, "", " ")
|
|
if err := writeUnder(home, listPath, append(raw, '\n'), 0o600); err != nil {
|
|
failures = append(failures, fmt.Sprintf("cannot write the list %s: %v", listPath, err))
|
|
break
|
|
}
|
|
if err := chownIfNeeded(home, listPath, uid, gid); err != nil {
|
|
failures = append(failures, err.Error())
|
|
break
|
|
}
|
|
parents, err := mkdirAllUnder(home, filepath.Dir(to), 0o700)
|
|
made = append(made, reversed(parents)...)
|
|
if err == nil {
|
|
err = chown(parents)
|
|
}
|
|
if err == nil {
|
|
err = renamePath(home, from, to)
|
|
}
|
|
if err != nil {
|
|
failures = append(failures, fmt.Sprintf("cannot move %s to %s: %v", from, to, err))
|
|
continue
|
|
}
|
|
log(fmt.Sprintf(" moved %s to %s (ADR 0422)", from, to))
|
|
moved = append(moved, MovedOut{Resource: id, From: from, To: to})
|
|
}
|
|
if len(moved) == 0 {
|
|
cleanup()
|
|
}
|
|
if len(failures) > 0 {
|
|
return moved, stayed, fmt.Errorf("%s: %s", id, strings.Join(failures, "; "))
|
|
}
|
|
return moved, stayed, nil
|
|
}
|
|
|
|
// reversed is paths shallowest first: mkdirAllUnder answers deepest first.
|
|
func reversed(in []string) []string {
|
|
out := make([]string, len(in))
|
|
for i, p := range in {
|
|
out[len(in)-1-i] = p
|
|
}
|
|
return out
|
|
}
|
|
|
|
func keptRoot(home string) string { return filepath.Join(home, ".local", "state", "mesh-moved") }
|
|
|
|
// chownIfNeeded gives a path to uid and gid through the home-safe call, unless it is theirs already.
|
|
func chownIfNeeded(home, path string, uid, gid int) error {
|
|
if u, g, ok := ownerIDs(path); ok && u == uid && g == gid {
|
|
return nil
|
|
}
|
|
return chownUnder(home, path, uid, gid)
|
|
}
|
|
|
|
// moveOutPass runs move-out for every directory and archive of d that says so and applied in this run.
|
|
func moveOutPass(d *declaration.Declaration, known store.State, applied map[string]bool, log func(string)) (
|
|
moved []MovedOut, left []LeftInPlace, failures []*Error) {
|
|
for _, r := range d.Resources {
|
|
var unknown, owner string
|
|
var knows []string
|
|
switch o := r.(type) {
|
|
case *declaration.Directory:
|
|
unknown, owner, knows = o.Unknown, o.Owner, o.Knows
|
|
case *declaration.Archive:
|
|
unknown, owner, knows = o.Unknown, o.Owner, o.Knows
|
|
default:
|
|
continue
|
|
}
|
|
if unknown != declaration.UnknownMoveOut || !applied[r.Identity()] {
|
|
continue
|
|
}
|
|
module, _ := moduleOf(r.Identity())
|
|
if module == "" {
|
|
module = r.Identity()
|
|
}
|
|
m, l, err := moveOut(d, known, r.Identity(), module, r.Target(), owner, knows, log)
|
|
moved = append(moved, m...)
|
|
left = append(left, l...)
|
|
if err != nil {
|
|
log(fmt.Sprintf(" failed %s (%s): %v", r.Identity(), r.Target(), err))
|
|
failures = append(failures, &Error{Resource: r.Identity(), Err: err})
|
|
}
|
|
}
|
|
return moved, left, failures
|
|
}
|
|
|
|
func pathExists(path string) bool {
|
|
_, err := os.Lstat(path)
|
|
return err == nil
|
|
}
|