diff --git a/cmd/mesh-host/kept.go b/cmd/mesh-host/kept.go index b50151c..d435a77 100644 --- a/cmd/mesh-host/kept.go +++ b/cmd/mesh-host/kept.go @@ -83,6 +83,10 @@ func (j *accessJudge) set(d *declaration.Declaration, held map[string]bool) { if !ok || held[a.ID] || strings.HasPrefix(a.ID, declaration.AdoptionPrefix) { continue } + // An older controller's access is judged by nothing, as before ADR 0325: no state is said of it. + if !apply.Keeps(d, a.Path) { + continue + } at := strings.LastIndex(a.ID, ".") if at <= 0 { continue diff --git a/cmd/mesh-host/kept_test.go b/cmd/mesh-host/kept_test.go index c5b6817..6c9c5dc 100644 --- a/cmd/mesh-host/kept_test.go +++ b/cmd/mesh-host/kept_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/link" @@ -47,3 +48,23 @@ func TestAnAccessTheMeshMayNotKeepYetIsSaidAsAWaitOfItsOwnKind(t *testing.T) { t.Fatalf("the reason names the path: %s", v.reason) } } + +// An older controller's accesses (no users, no identities) are judged by nothing, as before ADR 0325: the statement +// carries no resource for them, waiting, unknown or kept-entries. +func TestAnOlderControllersAccessesSayNothing(t *testing.T) { + dir := t.TempDir() + d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[ + {"id":"radarr.library","type":"access","path":"` + filepath.Join(dir, "library") + `","mode":"read-write"}, + {"id":"plex.movies","type":"access","path":"` + filepath.Join(dir, "absent") + `"}]}`)) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dir, "library"), 0o755); err != nil { + t.Fatal(err) + } + j := newAccessJudge() + j.set(d, nil) + if vs, _ := j.look(time.Now()); len(vs) != 0 { + t.Fatalf("an older controller's accesses are judged: %+v", vs) + } +} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 4440a27..0934dcd 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -351,8 +351,10 @@ func ApplyMindingWindows( case declaration.TypeAccess: // The folder is left as it is: what the mesh added to it is taken out by the walk once no access names // it (novox/hq ADR 0325 §8), never by removing the access's record. - action, detail = "forgotten", "an operator-owned folder is never the host's to remove; the entries "+ - "the mesh added go once no access names it" + action, detail = "forgotten", "an operator-owned path is never the host's to remove" + if _, kept := keptRecord(filepath.Clean(orphan.Target)); kept { + detail += "; the entries the mesh added go once no access names it" + } gave, left, said := takeBackGranted(ctx, sys, orphan, run, wanted, handed) if said != "" { detail = joinDetail(detail, said) @@ -540,7 +542,8 @@ func ApplyMindingWindows( // What every declared access asks of its folder, several accesses of one folder as one (novox/hq ADR 0325 §5), // and every accessed folder, which no container mounts while it is not there (§9). in.folders = foldersOf(d.Resources, in.refused) - in.accesses = accessPaths(d.Resources, in.refused) + in.identities = len(d.Identities) > 0 + in.accesses = accessPaths(d.Resources, in.refused, in.identities) in.agentHomes = rootNeverHomes(d) in.dirsBefore = map[string]bool{} for _, resource := range d.Resources { @@ -2193,8 +2196,10 @@ type inputs struct { dirsBefore map[string]bool // folders is what every declared access asks of its folder, by path (novox/hq ADR 0325 §5); accesses every // accessed folder with its access's id, which no container mounts while it is not there (§9). - folders map[string]*folderWant - accesses map[string]string + folders map[string]*folderWant + // identities is that the declaration carries identities: its controller speaks ADR 0325 for every access. + identities bool + accesses map[string]string // handed is every group a record that went this apply handed to a resource still asking for it. handed Handed } diff --git a/internal/apply/keep.go b/internal/apply/keep.go index dad5a6f..27d5afc 100644 --- a/internal/apply/keep.go +++ b/internal/apply/keep.go @@ -497,6 +497,9 @@ func applyAccessFolder(ctx context.Context, r *declaration.Access, in inputs) (O out := begin(r) path := filepath.Clean(r.Path) want := in.folders[path] + if !keeps(want, in.identities) { + return legacyAccess(r) + } users := want.list() _, name := accessName(r.ID) rec, _ := keptRecord(path) @@ -737,16 +740,39 @@ func NearestAncestor(path string) (MissingPath, error) { } // accessPaths is every declared access's folder with its id: what no container mounts while it is not there. -func accessPaths(resources []declaration.Resource, refused map[string]error) map[string]string { +// +// Only accesses the mesh keeps (Keeps): an older controller's access holds no container, as before ADR 0325. +func accessPaths(resources []declaration.Resource, refused map[string]error, identities bool) map[string]string { + folders := foldersOf(resources, refused) out := map[string]string{} for _, r := range resources { - if a, ok := r.(*declaration.Access); ok && refused[filepath.Clean(a.Path)] == nil { - out[filepath.Clean(a.Path)] = a.ID + a, ok := r.(*declaration.Access) + if !ok { + continue + } + if path := filepath.Clean(a.Path); refused[path] == nil && keeps(folders[path], identities) { + out[path] = a.ID } } return out } +// keeps is whether ADR 0325's keeping applies to a folder: some access naming it carries users, or the +// declaration carries identities — a controller that speaks link.IdentityContract. An older controller's access +// (no users, no identities) is applied exactly as before: mounted when present, refused when absent, nothing kept, +// nothing made, nothing judged (ADR 0051 §7). +func keeps(f *folderWant, identities bool) bool { + return identities || (f != nil && len(f.users) > 0) +} + +// Keeps is keeps for a whole declaration's access at path, for the engine's looks. +func Keeps(d *declaration.Declaration, path string) bool { + if d == nil { + return false + } + return keeps(foldersOf(d.Resources, nil)[filepath.Clean(path)], len(d.Identities) > 0) +} + // mountsMissing is the access a container waits for: one whose folder it mounts, or mounts below, is not there // now — its disk away, or not made yet. The runtime would make it as root (04-ISSUES/026). func mountsMissing(ctx context.Context, c *declaration.Container, accesses map[string]string) string { @@ -780,7 +806,7 @@ func WaitingOnAccess(d *declaration.Declaration) map[string]bool { if d == nil { return out } - accesses := accessPaths(d.Resources, refusedPlaces(d.Resources)) + accesses := accessPaths(d.Resources, refusedPlaces(d.Resources), len(d.Identities) > 0) if len(accesses) == 0 { return out } @@ -857,9 +883,13 @@ func Leftovers() []Leftover { dropping := f.Kept && len(f.Want) == 0 && len(f.Users) > 0 && f.Act != nil && f.Act.State == "waiting" switch { case dropping: + // Why the walk could not run, as it recorded it: its disk not there, or its filesystem not named. + why := strings.TrimSuffix(strings.TrimPrefix(f.Act.Why, "not walked: "), "; walked once it is") + if why == "" { + why = "the walk could not run" + } out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " + - name + " could not be removed: its disk is not there; they are removed once it is (novox/hq ADR " + - "0325 §8)"}) + name + " could not be removed: " + why + "; they are removed once it can (novox/hq ADR 0325 §8)"}) case len(f.Left) > 0: out = append(out, Leftover{Module: module, Access: access, Name: name, Path: path, Why: "the mesh's entries for " + name + " on a filesystem no longer at its folder could not be removed: they are removed once that " + @@ -887,3 +917,37 @@ func recordName(path string, f store.KeptFolder) (module, name, access string) { } return "", name, name } + +// 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 +} diff --git a/internal/apply/oldshape_test.go b/internal/apply/oldshape_test.go new file mode 100644 index 0000000..a9d23da --- /dev/null +++ b/internal/apply/oldshape_test.go @@ -0,0 +1,134 @@ +//go:build linux + +package apply + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" + + "github.com/novox/mesh-host/internal/declaration" + "github.com/novox/mesh-host/internal/store" +) + +// The engine delivers before the controller half (novox/hq ADR 0325): a controller that does not speak +// link.IdentityContract sends no identities, no users and no taken, and every access of its declarations is applied +// exactly as before this engine — mounted when present, refused when absent (ADR 0051 §7), nothing kept, nothing +// made, no container held, nothing judged. These are origin/main's outcomes, word for word. +func TestAnOlderControllersAccessesAreAppliedExactlyAsBefore(t *testing.T) { + keeping(t, nil, nil) + dir := t.TempDir() + unwritable := filepath.Join(dir, "library") // its group may not write it + empty := filepath.Join(dir, "spool") + absent := filepath.Join(dir, "media", "movies") + for _, d := range []string{unwritable, empty} { + if err := os.Mkdir(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(d, 0o755); err != nil { + t.Fatal(err) + } + } + // A root-owned empty folder, read only. + rootEmpty := "" + for _, c := range []string{"/var/empty", "/srv/http", "/srv/ftp"} { + if entries, err := os.ReadDir(c); err == nil && len(entries) == 0 { + if uid, _, _ := statOwner(mustLstat(t, c)); uid == 0 { + rootEmpty = c + break + } + } + } + resources := []string{ + `{"id":"radarr.library","type":"access","path":"` + unwritable + `","mode":"read-write"}`, + `{"id":"sab.spool","type":"access","path":"` + empty + `","mode":"read-write"}`, + `{"id":"plex.movies","type":"access","path":"` + absent + `"}`, + `{"id":"plex.app","type":"container","name":"plex","image":"` + pinned + `","volumes":["` + absent + `:/movies"]}`, + } + if rootEmpty != "" { + resources = append(resources, `{"id":"web.root","type":"access","path":"`+rootEmpty+`","mode":"read-write"}`) + } + var started bool + run := func(_ context.Context, name string, args ...string) (string, error) { + if len(args) > 0 && args[0] == "run" { + started = true + } + if len(args) > 0 && args[0] == "container" { + if started { + return "true\t" + containerSpec(declare(t, resources[3]).Resources[0].(*declaration.Container), inputs{}), nil + } + return "", errors.New("no such container") + } + return "", nil + } + report, _, err := Apply(context.Background(), archHost(t), declare(t, strings.Join(resources, ",")), store.State{}, + store.OriginDeclared, run, nil, nil) + + if err == nil || !strings.Contains(err.Error(), absent+" is not there, and the mesh does not own it — the operator "+ + "must provide it") { + t.Fatalf("an absent access is not refused as before: %v", err) + } + for id, mode := range map[string]string{"radarr.library": "read-write", "sab.spool": "read-write"} { + o := outcomeOf(report, id) + if o.Action != "unchanged" || o.Detail != "operator-owned; present, "+mode+", nothing managed" { + t.Errorf("%s: %+v, and before it was unchanged, %q", id, o, "operator-owned; present, "+mode+", nothing managed") + } + } + if rootEmpty != "" { + if o := outcomeOf(report, "web.root"); o.Action != "unchanged" { + t.Errorf("a root-owned folder was judged: %+v", o) + } + } + if !started { + t.Error("a container mounting an absent access was held, and before it was created") + } + if _, err := os.Stat(absent); !errors.Is(err, os.ErrNotExist) { + t.Error("an absent access was made") + } + for _, d := range []string{unwritable, empty} { + if n, _ := unix.Getxattr(d, aclAccessXattr, nil); n > 0 { + t.Errorf("an ACL was set on %s", d) + } + if info := mustLstat(t, d); info.Mode().Perm() != 0o755 { + t.Errorf("%s changed mode: %o", d, info.Mode().Perm()) + } + } + if all, _ := KeptFolders.All(); len(all) != 0 { + t.Errorf("folders were recorded as kept: %v", all) + } + if len(Leftovers()) != 0 || len(WaitingOnAccess(declare(t, strings.Join(resources, ",")))) != 0 { + t.Error("something was said or held for an older controller's access") + } +} + +func mustLstat(t *testing.T, path string) os.FileInfo { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + return info +} + +// Review (low): a folder no access names, whose filesystem could not be named this look, is not said as its disk +// being away. +func TestALeftoverSaysWhyItsWalkCouldNotRun(t *testing.T) { + uuid, fsid, source := "6f1c0a4e-1b2d-4c3e-8f00-aa11bb22cc33", "0000080100000000", "/dev/sdb1" + _, lib, access := poolNamed(t, &uuid, &fsid, &source) + takeIt(t, lib, access) + other := `{"id":"x.dir","type":"directory","path":"` + filepath.Join(filepath.Dir(filepath.Dir(lib)), "x") + `"}` + if _, _, err := applyAccesses(t, store.State{}, other); err != nil { + t.Fatal(err) + } + uuid, fsid, source = "", "0000000000000000", "" // nothing names it now + ReconcileKept(context.Background()) + left := Leftovers() + if len(left) != 1 || strings.Contains(left[0].Why, "disk") || !strings.Contains(left[0].Why, "could not be named") { + t.Fatalf("the leftover does not say why its walk could not run: %+v", left) + } +}