Let a directory say how it reacts to files it does not know, and move out what a move-out directory does not know (issue 1024, ADR 0422)
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
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.
This commit is contained in:
@@ -102,6 +102,19 @@ what it did not understand would apply most of a declaration and report success.
|
||||
]}
|
||||
```
|
||||
|
||||
**A directory or archive says how it treats files it did not write** (issue 1024,
|
||||
[`novox/hq` ADR 0422](https://git.novox.be/novox/hq)). `unknown` is `move-out`, `module` or `program`;
|
||||
absent, nothing about unknown paths is done. `knows` lists paths inside it, relative to its `path`, that
|
||||
are not declared but belong there; a trailing `/` names a folder and everything in it. `knows` without
|
||||
`unknown` is refused. For `move-out` the node-engine, after every apply and so on every reconcile, moves
|
||||
each path inside the directory that nothing declared, no archive unpacked and `knows` does not name,
|
||||
whole, into `<home>/.local/state/mesh-moved/<module>/<UTC time>/` beside a `<UTC time>.json` list. The
|
||||
list names each move before it is made. Nothing is deleted. A path younger than a minute waits for the
|
||||
next look; a socket, pipe or device is left and said. It is only for a directory under an account's
|
||||
home on one file system with the kept folder, and otherwise the resource fails and nothing moves.
|
||||
`module` and `program` are read by the module and the program that owns the path, and the node-engine
|
||||
does nothing for them.
|
||||
|
||||
**It converges rather than executes.** Applying twice changes nothing the second time; applying
|
||||
to a drifted machine returns it. A mode is *maintained*, not merely set — a permission applied
|
||||
at creation is not a permission held.
|
||||
|
||||
@@ -2254,6 +2254,12 @@ func applyAndKeepHeld(ctx context.Context, opts options, raw []byte, signed *sto
|
||||
report.Busy = append(report.Busy, link.Busy{ID: b.ID, Container: b.Container, State: b.State,
|
||||
Since: b.Since, Until: b.Until, Said: b.Said})
|
||||
}
|
||||
for _, m := range outcome.MovedOut {
|
||||
report.MovedOut = append(report.MovedOut, link.MovedOut{Resource: m.Resource, From: m.From, To: m.To})
|
||||
}
|
||||
for _, l := range outcome.Left {
|
||||
report.Left = append(report.Left, link.Left{Resource: l.Resource, Path: l.Path})
|
||||
}
|
||||
busyWaiting.Store(outcome.Waiting())
|
||||
// And what runs here that nobody asked for (novox/hq ADR 0163).
|
||||
if strays, err := apply.Strays(ctx, apply.ExecRunner, updated); err != nil {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
osuser "os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
)
|
||||
|
||||
// TestAMoveOutDirectoryKeepsOnlyWhatItKnows drives the node-engine whole through its command (issue 1024, novox/hq
|
||||
// ADR 0422): a directory under an account's home that says move-out keeps the file the mesh declared and moves a
|
||||
// file nobody declared into the kept folder, named in a list, and the run says it moved it. It needs no bus: the
|
||||
// apply is the one a delivery and the five-minute reconcile both run.
|
||||
func TestAMoveOutDirectoryKeepsOnlyWhatItKnows(t *testing.T) {
|
||||
me, err := osuser.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The user database lists an account as a person's only at uid 1000 or more; root, as the forge's merge check
|
||||
// runs, is listed under a number of its own (it can give files to it), by the same name.
|
||||
listedUID := me.Uid
|
||||
if uid, _ := strconv.Atoi(me.Uid); uid < 1000 || uid == 65534 {
|
||||
listedUID = "4242"
|
||||
}
|
||||
root := t.TempDir()
|
||||
home := filepath.Join(root, "home", "agent")
|
||||
dir := filepath.Join(home, ".ssh")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passwd := filepath.Join(root, "passwd")
|
||||
line := fmt.Sprintf("%s:x:%s:%s::%s:/bin/sh\n", me.Username, listedUID, me.Gid, home)
|
||||
if err := os.WriteFile(passwd, []byte(line), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(apply.UseAccountsFileForTest(passwd))
|
||||
was := builtFor
|
||||
builtFor = "arch"
|
||||
t.Cleanup(func() { builtFor = was })
|
||||
|
||||
stray := filepath.Join(dir, "id_stray")
|
||||
if err := os.WriteFile(stray, []byte("somebody else's key"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-time.Hour)
|
||||
if err := os.Chtimes(stray, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipe := filepath.Join(dir, "agent.sock")
|
||||
if err := syscall.Mkfifo(pipe, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chtimes(pipe, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := options{state: filepath.Join(root, "state.json")}
|
||||
raw := []byte(`{"declaration":1,"resources":[` +
|
||||
`{"id":"ssh.dir","type":"directory","path":"` + dir + `","owner":"` + me.Username + `","unknown":"move-out"},` +
|
||||
`{"id":"ssh.config","type":"file","path":"` + dir + `/config","content":"Host *\n","owner":"` + me.Username + `"}]}`)
|
||||
|
||||
var said []string
|
||||
report := applyAndKeep(context.Background(), opts, raw, nil, nil, func(s string) { said = append(said, s) })
|
||||
|
||||
if report.Refused != "" || len(report.Failed) > 0 {
|
||||
t.Fatalf("refused or failed: %q %v", report.Refused, report.Failed)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "config")); err != nil {
|
||||
t.Errorf("the declared file is not there: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(stray); err == nil {
|
||||
t.Error("the file nobody declared stayed in the directory")
|
||||
}
|
||||
lists, _ := filepath.Glob(filepath.Join(home, ".local", "state", "mesh-moved", "ssh", "*.json"))
|
||||
if len(lists) != 1 {
|
||||
t.Fatalf("one list expected: %v", lists)
|
||||
}
|
||||
rawList, err := os.ReadFile(lists[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var entries []struct{ From, To string }
|
||||
if err := json.Unmarshal(rawList, &entries); err != nil || len(entries) != 1 {
|
||||
t.Fatalf("the list: %s %v", rawList, err)
|
||||
}
|
||||
if got, err := os.ReadFile(entries[0].To); err != nil || string(got) != "somebody else's key" {
|
||||
t.Errorf("the moved file is not whole in the kept folder: %q %v", got, err)
|
||||
}
|
||||
if len(report.MovedOut) != 1 || report.MovedOut[0].From != stray || report.MovedOut[0].To != entries[0].To {
|
||||
t.Errorf("the report does not carry the move: %+v", report.MovedOut)
|
||||
}
|
||||
if len(report.Left) != 1 || report.Left[0].Path != pipe {
|
||||
t.Errorf("the report does not list the pipe left in place: %+v", report.Left)
|
||||
}
|
||||
if !strings.Contains(strings.Join(said, "\n"), "moved "+stray) {
|
||||
t.Errorf("the run does not say it moved the file: %v", said)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,10 @@ type Report struct {
|
||||
// Busy is every container this apply left running because it said busy, or replaced after it had
|
||||
// waited (novox/hq ADR 0335): a machine whose walk waits for it says why, and how it ended.
|
||||
Busy []BusyWait `json:"busy,omitempty"`
|
||||
// MovedOut is every path a move-out directory moved into its kept folder (issue 1024, novox/hq ADR 0422).
|
||||
MovedOut []MovedOut `json:"moved-out,omitempty"`
|
||||
// Left is every socket, pipe or device a move-out directory holds and did not move (ADR 0422 §4).
|
||||
Left []LeftInPlace `json:"left,omitempty"`
|
||||
}
|
||||
|
||||
// Waiting says whether a container of this apply waits to be replaced because it is busy: what makes the
|
||||
@@ -589,6 +593,8 @@ func ApplyMindingWindows(
|
||||
// is a different thing — one is "this machine could not do it", the other is "this was never
|
||||
// a declaration", and they are fixed in different places.
|
||||
var failures []*Error
|
||||
// Which resources applied without error in this run, for the move-out pass after the loop (issue 1024).
|
||||
appliedOK := map[string]bool{}
|
||||
// Modules whose own step did not complete. What follows *within such a module* is not attempted;
|
||||
// the rest of the machine is (novox/hq ADR 0136).
|
||||
gated := map[string]bool{}
|
||||
@@ -1011,6 +1017,7 @@ func ApplyMindingWindows(
|
||||
}
|
||||
// Only now. The record follows the fact, never leads it.
|
||||
recordApplied(&known, origin, resource, outcome, kept, keptMode, keptOwner)
|
||||
appliedOK[resource.Identity()] = true
|
||||
// Its module has been taken, and what was held for it is now the mesh's. A file written
|
||||
// into, or a service whose lifecycle is the machine's, replaced nothing that was found, so
|
||||
// its outcome says what the apply did, not that a cutover happened; a hold from when it was
|
||||
@@ -1082,6 +1089,15 @@ func ApplyMindingWindows(
|
||||
|
||||
failures = append(failures, identityFailures...)
|
||||
|
||||
// A directory or archive that says move-out keeps only what it knows (issue 1024, novox/hq ADR 0422).
|
||||
movedOut, leftIn, moveFailures := moveOutPass(d, known, appliedOK, log)
|
||||
report.MovedOut = append(report.MovedOut, movedOut...)
|
||||
report.Left = append(report.Left, leftIn...)
|
||||
for _, f := range moveFailures {
|
||||
f.Done = report
|
||||
failures = append(failures, f)
|
||||
}
|
||||
|
||||
// A replacement that did not apply — failed, skipped behind its module's step, held — leaves
|
||||
// what it replaces running and recorded, said, for the next apply to hand over.
|
||||
unhanded := make([]string, 0, len(handover))
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build linux
|
||||
|
||||
package apply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// deviceOf is the device a path lives on; a variable so a test can name another file system. Below a home it is
|
||||
// read through a descriptor opened without following a link (issue 1024).
|
||||
var deviceOf = func(path string) (uint64, error) {
|
||||
var st unix.Stat_t
|
||||
if home := homeAbove(path); home != "" {
|
||||
fd, err := openDirUnder(home, path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer unix.Close(fd)
|
||||
if err := unix.Fstat(fd, &st); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(st.Dev), nil
|
||||
}
|
||||
if err := unix.Stat(path, &st); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(st.Dev), nil
|
||||
}
|
||||
|
||||
// renameUnder moves from to to, both below home, through descriptors of their parents opened without following a
|
||||
// link, and never over something that is there: an account that owns the folders cannot point either at another
|
||||
// place between the look and the move (issue 1024, novox/hq ADR 0266).
|
||||
func renameUnder(home, from, to string) error {
|
||||
fromDir, err := openDirUnder(home, filepath.Dir(from))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(fromDir)
|
||||
toDir, err := openDirUnder(home, filepath.Dir(to))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unix.Close(toDir)
|
||||
if err := unix.Renameat2(fromDir, filepath.Base(from), toDir, filepath.Base(to), unix.RENAME_NOREPLACE); err != nil {
|
||||
return fmt.Errorf("rename %s to %s: %w", from, to, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ownerIDs is the user and group of a path, without following a link.
|
||||
func ownerIDs(path string) (uid, gid int, ok bool) {
|
||||
var st syscall.Stat_t
|
||||
if err := syscall.Lstat(path, &st); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return int(st.Uid), int(st.Gid), true
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !linux
|
||||
|
||||
package apply
|
||||
|
||||
import "os"
|
||||
|
||||
var deviceOf = func(string) (uint64, error) { return 0, nil }
|
||||
|
||||
func ownerIDs(string) (int, int, bool) { return 0, 0, false }
|
||||
|
||||
func renameUnder(home, from, to string) error {
|
||||
if err := refuseLinksUnderHome(from); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := refuseLinksUnderHome(to); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(from, to)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package apply
|
||||
|
||||
// Issue 1024, novox/hq ADR 0422: a directory that says unknown "move-out" keeps only what it knows, and moves every
|
||||
// other path into a kept folder under the account's home, listed before it is moved.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
osuser "os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// me is the account running the test, which owns the home the test names.
|
||||
func me() string {
|
||||
u, err := osuser.Current()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// aMoveOutHome is a home the test names, of the account running the test, so no chown needs privilege.
|
||||
func aMoveOutHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := filepath.Join(t.TempDir(), "home", "agent")
|
||||
if err := os.MkdirAll(home, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wasHomes, wasAccounts := homes, accountsOfHomes
|
||||
homes = func() []string { return []string{home} }
|
||||
accountsOfHomes = func() map[string]homeAccount {
|
||||
return map[string]homeAccount{home: {Name: me(), UID: os.Getuid()}}
|
||||
}
|
||||
t.Cleanup(func() { homes, accountsOfHomes = wasHomes, wasAccounts })
|
||||
return home
|
||||
}
|
||||
|
||||
func put(t *testing.T, path, content string, age time.Duration) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-age)
|
||||
if err := os.Chtimes(path, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func keptLists(t *testing.T, home string) []string {
|
||||
t.Helper()
|
||||
out, _ := filepath.Glob(filepath.Join(keptRoot(home), "*", "*.json"))
|
||||
return out
|
||||
}
|
||||
|
||||
func readList(t *testing.T, path string) []movedEntry {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var list []movedEntry
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func TestAMoveOutDirectoryMovesOnlyWhatIsNotKnown(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
dir := filepath.Join(home, ".ssh")
|
||||
outside := filepath.Join(t.TempDir(), "target")
|
||||
put(t, outside, "elsewhere", time.Hour)
|
||||
put(t, filepath.Join(dir, "config"), "declared", time.Hour)
|
||||
put(t, filepath.Join(dir, "known_hosts"), "knows", time.Hour)
|
||||
put(t, filepath.Join(dir, "x", "inside"), "in x", time.Hour)
|
||||
put(t, filepath.Join(dir, "stray"), "stray content", time.Hour)
|
||||
put(t, filepath.Join(dir, "oldfolder", "deep", "f"), "deep", time.Hour)
|
||||
put(t, filepath.Join(dir, "young"), "just made", 5*time.Second)
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "alink")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-time.Hour)
|
||||
_ = os.Chtimes(filepath.Join(dir, "oldfolder"), old, old)
|
||||
_ = os.Chtimes(filepath.Join(dir, "x"), old, old)
|
||||
// A named pipe stands for the socket: a socket's path is too long under a test directory to bind.
|
||||
if err := syscall.Mkfifo(filepath.Join(dir, "agent.sock"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(filepath.Join(dir, "agent.sock"), old, old)
|
||||
// The directory itself and its declared file are old enough too.
|
||||
// The symbolic link is older than a minute as well.
|
||||
ts := []unix.Timespec{unix.NsecToTimespec(old.UnixNano()), unix.NsecToTimespec(old.UnixNano())}
|
||||
if err := unix.UtimesNanoAt(unix.AT_FDCWD, filepath.Join(dir, "alink"), ts, unix.AT_SYMLINK_NOFOLLOW); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var logged []string
|
||||
report, _, err := Apply(context.Background(), archHost(t), declare(t,
|
||||
`{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out","knows":["known_hosts","x/"]},`+
|
||||
`{"id":"mod.config","type":"file","path":"`+dir+`/config","content":"declared"}`),
|
||||
store.State{}, store.OriginDeclared, noServices, func(s string) { logged = append(logged, s) }, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = report
|
||||
for _, kept := range []string{"config", "known_hosts", "x/inside", "young", "agent.sock"} {
|
||||
if !exists(filepath.Join(dir, kept)) {
|
||||
t.Errorf("%s should stay", kept)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{"stray", "oldfolder", "alink"} {
|
||||
if exists(filepath.Join(dir, gone)) {
|
||||
t.Errorf("%s should have moved", gone)
|
||||
}
|
||||
}
|
||||
lists := keptLists(t, home)
|
||||
if len(lists) != 1 {
|
||||
t.Fatalf("one list expected, got %v", lists)
|
||||
}
|
||||
if !strings.Contains(lists[0], filepath.Join("mesh-moved", "mod")) {
|
||||
t.Errorf("the module's name is not in the kept path: %s", lists[0])
|
||||
}
|
||||
list := readList(t, lists[0])
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("the list names three paths: %+v", list)
|
||||
}
|
||||
kept := strings.TrimSuffix(lists[0], ".json")
|
||||
for _, e := range list {
|
||||
if !strings.HasPrefix(e.To, kept+string(os.PathSeparator)) || !exists(e.To) {
|
||||
t.Errorf("not at %s: %+v", kept, e)
|
||||
}
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(kept, "stray")); string(b) != "stray content" {
|
||||
t.Errorf("content changed: %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(kept, "oldfolder", "deep", "f")); string(b) != "deep" {
|
||||
t.Errorf("the folder was not moved whole: %q", b)
|
||||
}
|
||||
if info, err := os.Lstat(filepath.Join(kept, "alink")); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Errorf("the link is not still a link: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(logged, "\n"), "(ADR 0422)") {
|
||||
t.Errorf("the moves were not logged: %v", logged)
|
||||
}
|
||||
if len(report.Left) != 1 || filepath.Base(report.Left[0].Path) != "agent.sock" {
|
||||
t.Errorf("the report does not list the pipe it left: %+v", report.Left)
|
||||
}
|
||||
if len(report.MovedOut) != 3 {
|
||||
t.Errorf("the report does not count the moves: %+v", report.MovedOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMoveOutListIsWrittenBeforeTheMove(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
dir := filepath.Join(home, "d")
|
||||
put(t, filepath.Join(dir, "a"), "a", time.Hour)
|
||||
put(t, filepath.Join(dir, "b"), "b", time.Hour)
|
||||
was := renamePath
|
||||
renamePath = func(h, from, to string) error {
|
||||
if filepath.Base(from) == "b" {
|
||||
return errors.New("injected")
|
||||
}
|
||||
return was(h, from, to)
|
||||
}
|
||||
t.Cleanup(func() { renamePath = was })
|
||||
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"}`, nil, store.State{})
|
||||
if err == nil {
|
||||
t.Fatal("a failed move must fail the resource")
|
||||
}
|
||||
if !exists(filepath.Join(dir, "b")) || exists(filepath.Join(dir, "a")) {
|
||||
t.Error("b stays, a moved")
|
||||
}
|
||||
lists := keptLists(t, home)
|
||||
if len(lists) != 1 {
|
||||
t.Fatalf("lists: %v", lists)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, e := range readList(t, lists[0]) {
|
||||
names[filepath.Base(e.From)] = true
|
||||
}
|
||||
if !names["a"] || !names["b"] {
|
||||
t.Errorf("the entry for the failed move was not listed first: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMoveOutThatMovedNothingMakesNothing(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
dir := filepath.Join(home, "d")
|
||||
put(t, filepath.Join(dir, "known"), "k", time.Hour)
|
||||
put(t, filepath.Join(dir, "young"), "y", time.Second)
|
||||
if _, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out","knows":["known"]}`, nil, store.State{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(keptRoot(home)) {
|
||||
t.Error("a kept folder was made with nothing to move")
|
||||
}
|
||||
// And when every move fails, what was made goes.
|
||||
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
|
||||
was := renamePath
|
||||
renamePath = func(string, string, string) error { return errors.New("injected") }
|
||||
t.Cleanup(func() { renamePath = was })
|
||||
if _, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out","knows":["known"]}`, nil, store.State{}); err == nil {
|
||||
t.Fatal("expected a failure")
|
||||
}
|
||||
if len(keptLists(t, home)) != 0 {
|
||||
t.Error("a list stayed though nothing moved")
|
||||
}
|
||||
if matches, _ := filepath.Glob(filepath.Join(keptRoot(home), "*", "*")); len(matches) != 0 {
|
||||
t.Errorf("a kept folder stayed though nothing moved: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMoveOutOfANestedStrayThatFailsLeavesNoKeptFolder(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
dir := filepath.Join(home, "d")
|
||||
put(t, filepath.Join(dir, "x", "f"), "declared", time.Hour)
|
||||
put(t, filepath.Join(dir, "x", "stray"), "s", time.Hour)
|
||||
was := renamePath
|
||||
renamePath = func(string, string, string) error { return errors.New("injected") }
|
||||
t.Cleanup(func() { renamePath = was })
|
||||
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"},`+
|
||||
`{"id":"mod.f","type":"file","path":"`+dir+`/x/f","content":"declared","owner":"`+me()+`"}`, nil, store.State{})
|
||||
if err == nil {
|
||||
t.Fatal("expected a failure")
|
||||
}
|
||||
if exists(keptRoot(home)) {
|
||||
matches, _ := filepath.Glob(filepath.Join(keptRoot(home), "*", "*"))
|
||||
t.Errorf("something was left behind though nothing moved: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
// A link swapped into a parent folder, on either side, between the look and the move, is not followed.
|
||||
func TestAMoveOutDoesNotFollowALinkSwappedIntoAParent(t *testing.T) {
|
||||
for _, side := range []string{"from", "to"} {
|
||||
t.Run(side, func(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
dir := filepath.Join(home, "d")
|
||||
outside := filepath.Join(t.TempDir(), "etc")
|
||||
put(t, filepath.Join(outside, "stray"), "root's", time.Hour)
|
||||
put(t, filepath.Join(dir, "x", "f"), "declared", time.Hour)
|
||||
put(t, filepath.Join(dir, "x", "stray"), "s", time.Hour)
|
||||
was := renamePath
|
||||
renamePath = func(h, from, to string) error {
|
||||
victim := filepath.Dir(from)
|
||||
if side == "to" {
|
||||
victim = filepath.Dir(to)
|
||||
_ = os.Remove(filepath.Join(victim, "nothing"))
|
||||
}
|
||||
if err := os.RemoveAll(victim); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if side == "from" {
|
||||
put(t, filepath.Join(outside, "other"), "o", time.Hour)
|
||||
}
|
||||
if err := os.Symlink(outside, victim); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return was(h, from, to)
|
||||
}
|
||||
t.Cleanup(func() { renamePath = was })
|
||||
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"},`+
|
||||
`{"id":"mod.f","type":"file","path":"`+dir+`/x/f","content":"declared","owner":"`+me()+`"}`, nil, store.State{})
|
||||
if err == nil {
|
||||
t.Fatal("a move through a swapped link must fail")
|
||||
}
|
||||
if b, e := os.ReadFile(filepath.Join(outside, "stray")); e != nil || string(b) != "root's" {
|
||||
t.Errorf("the file outside the home was touched: %q %v", b, e)
|
||||
}
|
||||
entries, _ := os.ReadDir(outside)
|
||||
if side == "to" && len(entries) != 1 || side == "from" && len(entries) != 2 {
|
||||
t.Errorf("something was moved into or out of the folder outside the home: %v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherUnknownValuesAndNoValueMoveNothing(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
for _, v := range []string{`,"unknown":"program"`, `,"unknown":"module"`, ``} {
|
||||
dir := filepath.Join(home, "d")
|
||||
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
|
||||
if _, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`"`+v+`}`, nil, store.State{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(filepath.Join(dir, "stray")) || exists(keptRoot(home)) {
|
||||
t.Errorf("%q moved something", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMoveOutArchiveKeepsWhatItUnpackedAndMovesAStray(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
dir := filepath.Join(home, "theme")
|
||||
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
|
||||
res, _ := archiveDecl(t, "mod.theme", dir, map[string]string{"one.txt": "1", "sub/two.txt": "2"}, "")
|
||||
res = strings.Replace(res, `"path":`, `"owner":"`+me()+`","unknown":"move-out","path":`, 1)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), declare(t, res), store.State{},
|
||||
store.OriginDeclared, noServices, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range []string{"one.txt", "sub/two.txt"} {
|
||||
if !exists(filepath.Join(dir, f)) {
|
||||
t.Errorf("%s was moved", f)
|
||||
}
|
||||
}
|
||||
if exists(filepath.Join(dir, "stray")) || len(keptLists(t, home)) != 1 {
|
||||
t.Error("the stray file did not move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOutNeedsAHome(t *testing.T) {
|
||||
aMoveOutHome(t)
|
||||
dir := filepath.Join(t.TempDir(), "elsewhere")
|
||||
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
|
||||
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"}`, nil, store.State{})
|
||||
if err == nil || !strings.Contains(err.Error(), "home") {
|
||||
t.Fatalf("expected a refusal naming the home: %v", err)
|
||||
}
|
||||
if !exists(filepath.Join(dir, "stray")) {
|
||||
t.Error("something moved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveOutRefusesAnotherFileSystem(t *testing.T) {
|
||||
home := aMoveOutHome(t)
|
||||
dir := filepath.Join(home, "d")
|
||||
put(t, filepath.Join(dir, "stray"), "s", time.Hour)
|
||||
was := deviceOf
|
||||
deviceOf = func(p string) (uint64, error) {
|
||||
if p == dir {
|
||||
return 1, nil
|
||||
}
|
||||
return 2, nil
|
||||
}
|
||||
t.Cleanup(func() { deviceOf = was })
|
||||
_, _, err := applyOnce(t, `{"id":"mod.dir","type":"directory","path":"`+dir+`","owner":"`+me()+`","unknown":"move-out"}`, nil, store.State{})
|
||||
if err == nil || !strings.Contains(err.Error(), "file system") {
|
||||
t.Fatalf("expected a refusal naming the file systems: %v", err)
|
||||
}
|
||||
if !exists(filepath.Join(dir, "stray")) || exists(keptRoot(home)) {
|
||||
t.Error("something moved or was made")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package apply
|
||||
|
||||
// UseAccountsFileForTest makes the accounts and homes this package knows come from the user database at path, until
|
||||
// the returned function is called. For the node-engine's own integration tests, which cannot name a home any other
|
||||
// way; nothing in the node-engine calls it (issue 1024).
|
||||
func UseAccountsFileForTest(path string) (restore func()) {
|
||||
was := passwdFile
|
||||
passwdFile = path
|
||||
return func() { passwdFile = was }
|
||||
}
|
||||
@@ -124,6 +124,60 @@ type Directory struct {
|
||||
// Owner is the user this belongs to, by name. Absent means root, which is what everything
|
||||
// managed was until users existed.
|
||||
Owner string `json:"owner,omitempty"`
|
||||
// Unknown says how the directory treats a path inside it that nothing declared (issue 1024, novox/hq ADR 0422):
|
||||
// move-out, module or program. Empty means not said, and nothing moves.
|
||||
Unknown string `json:"unknown,omitempty"`
|
||||
// Knows names paths relative to Path that are not declared but belong here; a trailing "/" names a folder and
|
||||
// everything in it.
|
||||
Knows []string `json:"knows,omitempty"`
|
||||
}
|
||||
|
||||
// How a directory or archive treats what it did not write (issue 1024, ADR 0422).
|
||||
const (
|
||||
UnknownMoveOut = "move-out"
|
||||
UnknownModule = "module"
|
||||
UnknownProgram = "program"
|
||||
)
|
||||
|
||||
// checkUnknown judges the unknown and knows fields of a directory or an archive.
|
||||
func checkUnknown(where, kind, unknown string, knows []string) []string {
|
||||
var problems []string
|
||||
switch unknown {
|
||||
case "", UnknownMoveOut, UnknownModule, UnknownProgram:
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: a %s's unknown is %q; it is %q, %q or %q (issue 1024)", where, kind, unknown,
|
||||
UnknownMoveOut, UnknownModule, UnknownProgram))
|
||||
}
|
||||
if len(knows) > 0 && unknown == "" {
|
||||
problems = append(problems, where+": knows is given without unknown, and says nothing without it")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range knows {
|
||||
switch {
|
||||
case k == "":
|
||||
problems = append(problems, where+": knows holds an empty entry")
|
||||
continue
|
||||
case strings.HasPrefix(k, "/"):
|
||||
problems = append(problems, fmt.Sprintf("%s: knows entry %q is absolute; it is relative to the path", where, k))
|
||||
continue
|
||||
}
|
||||
bad := false
|
||||
for _, c := range strings.Split(strings.TrimSuffix(k, "/"), "/") {
|
||||
if c == "" || c == "." || c == ".." {
|
||||
bad = true
|
||||
}
|
||||
}
|
||||
if bad {
|
||||
problems = append(problems, fmt.Sprintf("%s: knows entry %q holds an empty, . or .. component", where, k))
|
||||
continue
|
||||
}
|
||||
if seen[k] {
|
||||
problems = append(problems, fmt.Sprintf("%s: knows entry %q is repeated", where, k))
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func (d *Directory) Identity() string { return d.ID }
|
||||
@@ -141,6 +195,7 @@ func (d *Directory) validate(where string, _ bool) []string {
|
||||
if len(mode) == 4 && mode[0] == '2' {
|
||||
mode = "0" + mode[1:]
|
||||
}
|
||||
problems = append(problems, checkUnknown(where, "directory", d.Unknown, d.Knows)...)
|
||||
return append(problems, checkMode(where, mode)...)
|
||||
}
|
||||
|
||||
@@ -696,6 +751,9 @@ type Archive struct {
|
||||
Path string `json:"path"`
|
||||
// Owner is the user the unpacked files belong to. Absent means root.
|
||||
Owner string `json:"owner,omitempty"`
|
||||
// Unknown and Knows are as a directory's (issue 1024, ADR 0422).
|
||||
Unknown string `json:"unknown,omitempty"`
|
||||
Knows []string `json:"knows,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Archive) Identity() string { return a.ID }
|
||||
@@ -716,6 +774,7 @@ func (a *Archive) validate(where string, _ bool) []string {
|
||||
problems = append(problems, where+
|
||||
": an archive is pinned by digest, as sha256:<64 hex characters>")
|
||||
}
|
||||
problems = append(problems, checkUnknown(where, "archive", a.Unknown, a.Knows)...)
|
||||
return problems
|
||||
}
|
||||
|
||||
|
||||
@@ -545,3 +545,51 @@ func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write.
|
||||
|
||||
func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
|
||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]},
|
||||
{"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
dir := d.Resources[0].(*Directory)
|
||||
if dir.Unknown != UnknownMoveOut || len(dir.Knows) != 2 {
|
||||
t.Errorf("directory: %+v", dir)
|
||||
}
|
||||
if ar := d.Resources[1].(*Archive); ar.Unknown != UnknownModule {
|
||||
t.Errorf("archive: %+v", ar)
|
||||
}
|
||||
for _, v := range []string{UnknownMoveOut, UnknownModule, UnknownProgram} {
|
||||
if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"d","type":"directory","path":"/x","unknown":"` + v + `"}]}`)); err != nil {
|
||||
t.Errorf("%s refused: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
|
||||
digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"`
|
||||
cases := map[string]string{
|
||||
"bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`,
|
||||
"empty entry": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":[""]}`,
|
||||
"absolute": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["/etc"]}`,
|
||||
"empty comp": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a//b"]}`,
|
||||
"dot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["./a"]}`,
|
||||
"dotdot": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a/../b"]}`,
|
||||
"repeat": `{"id":"d","type":"directory","path":"/x","unknown":"move-out","knows":["a","a"]}`,
|
||||
"no unknown": `{"id":"d","type":"directory","path":"/x","knows":["a"]}`,
|
||||
"archive bad": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","unknown":"nope"}`,
|
||||
"archive knws": `{"id":"d","type":"archive","source":"s",` + digest + `,"path":"/x","knows":["a"]}`,
|
||||
}
|
||||
for name, res := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
refusal := refusalFor(t, `{"declaration":1,"resources":[`+res+`]}`)
|
||||
if !strings.Contains(strings.Join(refusal.Problems, "\n"), `resource "d"`) {
|
||||
t.Errorf("resource not named: %v", refusal.Problems)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,11 @@ type Report struct {
|
||||
// older than BusyContract, which never waits.
|
||||
Busy []Busy `json:"busy,omitempty"`
|
||||
|
||||
// MovedOut is every path a move-out directory moved into its kept folder, and Left every socket, pipe or
|
||||
// device it held and did not move (issue 1024, novox/hq ADR 0422).
|
||||
MovedOut []MovedOut `json:"moved-out,omitempty"`
|
||||
Left []Left `json:"left,omitempty"`
|
||||
|
||||
// Strays is what runs on the machine that the mesh neither wrote nor holds (novox/hq ADR
|
||||
// 0163): containers nobody declared and nobody holds, the ones a cutover leaves behind.
|
||||
Strays []Stray `json:"strays,omitempty"`
|
||||
@@ -792,6 +797,19 @@ type Busy struct {
|
||||
Said string `json:"said,omitempty"`
|
||||
}
|
||||
|
||||
// MovedOut is one path a move-out directory moved (issue 1024).
|
||||
type MovedOut struct {
|
||||
Resource string `json:"resource"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// Left is one socket, pipe or device a move-out directory did not move (issue 1024).
|
||||
type Left struct {
|
||||
Resource string `json:"resource"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// A Stray is a container the mesh neither wrote nor holds (ADR 0163).
|
||||
type Stray struct {
|
||||
Kind string `json:"kind"`
|
||||
|
||||
Reference in New Issue
Block a user