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 // `/.local/state/mesh-moved///`, 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, _ := d.ModuleOfID(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 }