diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 79ef74f..e217253 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -63,6 +63,8 @@ type Outcome struct { // shell is, for a user, the login shell it was found with and the one the mesh set (novox/hq // ADR 0176 §2, issue 228). shell *store.LoginShell + // unpacked is, for an archive, what it put on the machine (novox/hq issue 162). + unpacked *store.Unpacked // reads is, for a container, the digest of each file it was created reading, by path — so // the next apply can say which one changed (novox/hq 04-ISSUES/103). reads map[string]string @@ -209,12 +211,13 @@ func ApplyKeeping( } if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) { // **A former target of a kind the host cannot remove is left in place and forgotten, - // never fatal.** The host's own archive is the case: every version it delivers itself - // has a new target, so the one before is a former target on the first apply of the new - // host — and a removal that refused there stopped every machine applying anything, the - // moment the host that carried former targets (novox/hq ADR 0163, rule 5) first - // replaced itself. What was written stays where it is, said, and the record no longer - // names it; whether an archive gets a removal is issue 162's question, not this apply's. + // never fatal.** The host's own archive was the case (novox/hq issue 194): every version + // it delivers itself has a new target, so the one before is a former target on the first + // apply of the new host — and a removal that refused there stopped every machine applying + // anything, the moment the host that carried former targets (novox/hq ADR 0163, rule 5) + // first replaced itself. What was written stays where it is, said, and the record no + // longer names it. An archive answers this itself since issue 162 (removeArchive); this + // stays for any kind that still has no removal. known.Forget(orphan.ID) report.Outcomes = append(report.Outcomes, Outcome{ ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, @@ -591,6 +594,7 @@ func ApplyKeeping( Stateless: outcome.stateless, Found: outcome.found, Shell: outcome.shell, + Unpacked: outcome.unpacked, Holds: holds(resource), }) if outcome.found != nil { @@ -1456,13 +1460,18 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner, } return "removed", "no longer declared", nil + case declaration.TypeArchive: + // Exactly what it unpacked, and the directories the host made for it once they are empty + // (novox/hq issue 162). + return removeArchive(a) + default: return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type) } } -// errNoRemoval is remove's answer for a kind the host has no removal for (novox/hq issue 162): an -// archive, among others. Fatal for an orphan the declaration dropped, so an unassignment nothing can +// errNoRemoval is remove's answer for a kind the host has no removal for (novox/hq issue 162; an +// archive has one since). Fatal for an orphan the declaration dropped, so an unassignment nothing can // undo is never reported as done; not fatal for a former target, which was never dropped by anyone. var errNoRemoval = errors.New("no way to remove") diff --git a/internal/apply/archive.go b/internal/apply/archive.go index 460682f..c82f025 100644 --- a/internal/apply/archive.go +++ b/internal/apply/archive.go @@ -56,20 +56,28 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap out.wrote = got // Already what it should be. The digest is the whole identity of an archive, so a matching - // record means the unpacked tree came from these exact bytes. - if previous.Wrote == got { + // record means the unpacked tree came from these exact bytes — at this path: a record of the + // same bytes somewhere else says nothing about what is here. + if previous.Wrote == got && previous.Target == r.Path { if _, err := os.Stat(r.Path); err == nil { - owned, err := ownedBy(r.Path, r.Owner) + owned, err := ownedBy(ownershipProbe(r.Path, previous.Unpacked), r.Owner) if err == nil && owned { + // What it unpacked is carried, or — on a record from before the host kept it — read + // from the archive now, so the record can say it from here on (novox/hq issue 162). + out.unpacked, err = stillUnpacked(body, r.Path, previous.Unpacked) + if err != nil { + return out, err + } return out, nil } } } - written, err := replaceWith(body, r.Path, r.Owner) + unpacked, written, err := replaceWith(body, r.Path, r.Owner, oursFrom(previous, r.Path)) if err != nil { return out, err } + out.unpacked = &unpacked out.Action = "updated" if previous.Wrote == "" { out.Action = "created" @@ -78,44 +86,90 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap return out, nil } -// replaceWith makes the directory exactly the archive (novox/hq issue 220). +// replaceWith makes the directory exactly the archive (novox/hq issue 220), and says what it put +// there (novox/hq issue 162). // -// **The tree on disk is the archive and nothing else.** The digest is the whole identity of what -// is unpacked here, so a file the previous archive had and this one does not must go. Unpacked over -// the old tree, it stayed: a bundle rebuilt as one file per entrypoint kept the package directory -// of the version before, which code could still import, and a fix that removed a file worked on a -// fresh machine only. So the archive is unpacked into a fresh directory beside the old one, owned, -// and swapped in by rename. A running process keeps the files it has open, and the old tree is -// removed only once the new one is in place. A failed unpack leaves the old tree untouched. -func replaceWith(body []byte, path, owner string) (int, error) { +// **The tree on disk is the archive and nothing else — of what the mesh put there.** The digest is +// the whole identity of what is unpacked here, so a file the previous archive had and this one does +// not must go. Unpacked over the old tree, it stayed: a bundle rebuilt as one file per entrypoint +// kept the package directory of the version before, which code could still import, and a fix that +// removed a file worked on a fresh machine only. So the archive is unpacked into a fresh directory +// beside the old one, owned, and swapped in by rename. A running process keeps the files it has open, +// and the old tree is removed only once the new one is in place. A failed unpack leaves the old tree +// untouched. +// +// **What the mesh did not put there is never swapped away** (novox/hq issue 162, ADR 0030). The +// swap is for a directory that is the host's own: one it made, holding nothing but what the mesh +// put there. A directory that was there before the archive, or that something else has written +// into since, is the machine's: the archive's files are moved into it one by one, what the previous +// archive placed and this one does not is taken out, and everything else is left as it is. A file +// the archive would write over that the mesh did not put there refuses the archive before anything +// is moved — unless it already holds exactly the archive's bytes. +func replaceWith(body []byte, path, owner string, o ours) (store.Unpacked, int, error) { parent := filepath.Dir(path) - if err := makeDirs(parent, 0o755, owner); err != nil { - return 0, err + madeParents, err := makeDirsSaying(parent, 0o755, owner) + if err != nil { + return store.Unpacked{}, 0, err } + parents := joinParents(madeParents, o.parents) fresh := path + ".unpacking" replaced := path + ".replaced" - // What an interrupted earlier attempt left beside the directory. - for _, leftover := range []string{fresh, replaced} { + // What an interrupted earlier attempt — or removal — left beside the directory. + for _, leftover := range []string{fresh, replaced, path + ".removing"} { if err := os.RemoveAll(leftover); err != nil { - return 0, err + return store.Unpacked{}, 0, err } } if err := os.Mkdir(fresh, 0o755); err != nil { - return 0, err + return store.Unpacked{}, 0, err } written, err := unpack(body, fresh) if err == nil { err = ownAll(fresh, owner) } + var files, dirs []string + if err == nil { + files, dirs, err = treeOf(fresh) + } if err != nil { os.RemoveAll(fresh) - return written, err + return store.Unpacked{}, written, err } + + info, err := os.Lstat(path) + existed := err == nil + if err != nil && !os.IsNotExist(err) { + os.RemoveAll(fresh) + return store.Unpacked{}, written, err + } + if existed && !info.IsDir() { + // Swapped, it would be deleted: a file at the path is nothing an archive put there. + os.RemoveAll(fresh) + return store.Unpacked{}, written, fmt.Errorf( + "%s is there and is not a directory, and the mesh did not put it there; nothing was unpacked", path) + } + foreign := 0 + if existed && !o.all { + if foreign, err = foreignIn(path, o.paths); err != nil { + os.RemoveAll(fresh) + return store.Unpacked{}, written, err + } + } + if existed && (foreign > 0 || !(o.made || o.all)) { + u, err := mergeInto(fresh, path, owner, files, dirs, o) + os.RemoveAll(fresh) + if err != nil { + return store.Unpacked{}, written, err + } + u.Parents = parents + return u, written, nil + } + hadOne := true if err := os.Rename(path, replaced); err != nil { if !os.IsNotExist(err) { os.RemoveAll(fresh) - return written, err + return store.Unpacked{}, written, err } hadOne = false } @@ -125,14 +179,16 @@ func replaceWith(body []byte, path, owner string) (int, error) { os.Rename(replaced, path) } os.RemoveAll(fresh) - return written, err + return store.Unpacked{}, written, err } + // The directory is the host's own: it made it, now or before, and nothing else is in it. + u := store.Unpacked{Files: files, Dirs: dirs, Made: true, Parents: parents} if hadOne { if err := os.RemoveAll(replaced); err != nil { - return written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err) + return u, written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err) } } - return written, nil + return u, written, nil } func fetch(ctx context.Context, source string) ([]byte, error) { diff --git a/internal/apply/archive_removal_test.go b/internal/apply/archive_removal_test.go new file mode 100644 index 0000000..b9f7b67 --- /dev/null +++ b/internal/apply/archive_removal_test.go @@ -0,0 +1,261 @@ +package apply + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq issue 162: an archive can be undeclared. What it unpacked is gone, anything that +// was in its directory beforehand is still there, and the apply that removed it applied everything +// else in the same declaration. + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func archiveDecl(t *testing.T, id, path string, files map[string]string, more string) (string, string) { + t.Helper() + body, digest := anArchive(t, files) + return `{"id":"` + id + `","type":"archive","source":"` + serving(t, body) + `","digest":"` + digest + + `","path":"` + path + `"}` + more, digest +} + +func TestUnassigningAnArchiveRemovesExactlyWhatItUnpacked(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "bundles", "notes", "tools") + archive, _ := archiveDecl(t, "notes.tools", target, + map[string]string{"index.js": "x", "lib/one.js": "1", "lib/two.js": "2"}, + `,{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`) + _, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{}, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if rec, _ := state.Find("notes.tools"); rec.Unpacked == nil || !rec.Unpacked.Made || + len(rec.Unpacked.Files) != 3 || len(rec.Unpacked.Parents) != 2 { + t.Fatalf("what the archive unpacked was not recorded: %+v", rec.Unpacked) + } + + // Unassigned, and the same push declares something new: both happen. + d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}, + {"id":"other.conf","type":"file","path":"`+dir+`/other.conf","content":"b"}`) + report, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatalf("undeclaring an archive stopped the apply: %v", err) + } + if o := outcomeOf(report, "notes.tools"); o.Action != "removed" { + t.Fatalf("the archive: %+v", o) + } + if o := outcomeOf(report, "other.conf"); o.Action != "created" { + t.Fatalf("the rest of the declaration: %+v", o) + } + if _, still := state.Find("notes.tools"); still { + t.Fatal("the archive is still on record") + } + // The directory it was unpacked into and the parents the host made to reach it, all gone. + if exists(filepath.Join(dir, "bundles")) { + t.Fatal("what the host made for the archive is still there") + } + if !exists(filepath.Join(dir, "notes.conf")) { + t.Fatal("a file the archive did not place was removed") + } + if entries, _ := os.ReadDir(dir); len(entries) != 2 { + t.Fatalf("%d entries left in the directory", len(entries)) + } +} + +func TestADirectoryFoundBeforeTheArchiveKeepsWhatWasInIt(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "powerlevel10k") + if err := os.MkdirAll(filepath.Join(target, "lib"), 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(target, "mine.zsh"), []byte("somebody's"), 0o644) + os.WriteFile(filepath.Join(target, "lib", "mine.zsh"), []byte("somebody's"), 0o644) + + archive, _ := archiveDecl(t, "shell.theme", target, + map[string]string{"p10k.zsh": "theme", "lib/theme.zsh": "lib", "gitstatus/gs": "gs"}, "") + _, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{}, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + // Applying over a directory that was there keeps what was in it. + for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} { + if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" { + t.Fatalf("%s after the apply: %q", kept, got) + } + } + if got, _ := os.ReadFile(filepath.Join(target, "lib", "theme.zsh")); string(got) != "lib" { + t.Fatalf("lib/theme.zsh is %q", got) + } + rec, _ := state.Find("shell.theme") + if rec.Unpacked == nil || rec.Unpacked.Made || strings.Join(rec.Unpacked.Dirs, ",") != "gitstatus" { + t.Fatalf("recorded as %+v", rec.Unpacked) + } + + // A new version without one of its files: that file goes, nothing else does. + archive, _ = archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme 2"}, "") + if _, state, err = Apply(context.Background(), archHost(t), declare(t, archive), state, + store.OriginDeclared, noServices, nil, nil); err != nil { + t.Fatal(err) + } + if exists(filepath.Join(target, "lib", "theme.zsh")) || exists(filepath.Join(target, "gitstatus")) { + t.Fatal("the previous version's files are still there") + } + if !exists(filepath.Join(target, "lib", "mine.zsh")) { + t.Fatal("a file the mesh did not put there went with the previous version") + } + + report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(report, "shell.theme"); o.Action != "removed" || !strings.Contains(o.Detail, "there before") { + t.Fatalf("the archive: %+v", o) + } + if exists(filepath.Join(target, "p10k.zsh")) { + t.Fatal("the archive's file is still there") + } + for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} { + if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" { + t.Fatalf("%s after the removal: %q", kept, got) + } + } +} + +// A file the archive would write over that the mesh did not put there refuses the archive, and the +// directory is left exactly as it was. +func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "theme") + os.MkdirAll(target, 0o755) + os.WriteFile(filepath.Join(target, "p10k.zsh"), []byte("somebody's"), 0o644) + archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme", "x": "x"}, "") + if _, _, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{}, + store.OriginDeclared, noServices, nil, nil); err == nil || !strings.Contains(err.Error(), "did not put there") { + t.Fatalf("written over: %v", err) + } + if got, _ := os.ReadFile(filepath.Join(target, "p10k.zsh")); string(got) != "somebody's" { + t.Fatalf("p10k.zsh is %q", got) + } + if exists(filepath.Join(target, "x")) { + t.Fatal("part of a refused archive was moved in") + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Fatalf("%d entries beside the directory", len(entries)) + } +} + +func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "theme") + archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "t", "lib/a.zsh": "a"}, "") + _, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{}, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + // Somebody writes into the directory the host made. + os.WriteFile(filepath.Join(target, "lib", "local.zsh"), []byte("mine"), 0o644) + + report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + o := outcomeOf(report, "shell.theme") + if o.Action != "removed" || !strings.Contains(o.Detail, "did not put there") { + t.Fatalf("the archive: %+v", o) + } + if exists(filepath.Join(target, "p10k.zsh")) || exists(filepath.Join(target, "lib", "a.zsh")) { + t.Fatal("the archive's files are still there") + } + if got, _ := os.ReadFile(filepath.Join(target, "lib", "local.zsh")); string(got) != "mine" { + t.Fatalf("a file the archive did not place: %q", got) + } +} + +// An archive recorded before the host kept what it unpacked cannot be told from anything else in +// its directory: it is left in place, said and forgotten, and the apply goes on. +func TestAnArchiveRecordedBeforeItsFilesWereKeptIsLeftAndForgotten(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "tools") + os.MkdirAll(target, 0o755) + os.WriteFile(filepath.Join(target, "index.js"), []byte("x"), 0o644) + known := store.State{Resources: []store.Applied{ + {ID: "notes.tools", Type: "archive", Target: target, Wrote: "sha256:old", Origin: store.OriginDeclared}, + }} + d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`) + report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatalf("an archive from before stopped the apply: %v", err) + } + if o := outcomeOf(report, "notes.tools"); o.Action != "forgotten" || !strings.Contains(o.Detail, "left in place") { + t.Fatalf("the archive: %+v", o) + } + if o := outcomeOf(report, "notes.conf"); o.Action != "created" { + t.Fatalf("the rest of the declaration: %+v", o) + } + if _, still := state.Find("notes.tools"); still { + t.Fatal("still on record") + } + if !exists(filepath.Join(target, "index.js")) { + t.Fatal("removed without a record of what it unpacked") + } +} + +// One recorded before, still declared and unchanged, is read from its own bytes on the next apply, +// so it can be undeclared from then on: the whole directory when it holds exactly the archive, only +// the archive's files when it holds anything else. +func TestAnArchiveRecordedBeforeLearnsWhatItUnpacked(t *testing.T) { + for _, extra := range []bool{false, true} { + dir := t.TempDir() + target := filepath.Join(dir, "tools") + files := map[string]string{"index.js": "x", "lib/a.js": "a"} + archive, digest := archiveDecl(t, "notes.tools", target, files, "") + for name, body := range files { + os.MkdirAll(filepath.Dir(filepath.Join(target, name)), 0o755) + os.WriteFile(filepath.Join(target, name), []byte(body), 0o644) + } + if extra { + os.WriteFile(filepath.Join(target, "lib", "local.js"), []byte("mine"), 0o644) + } + known := store.State{Resources: []store.Applied{ + {ID: "notes.tools", Type: "archive", Target: target, Wrote: digest, Origin: store.OriginDeclared}, + }} + report, state, err := Apply(context.Background(), archHost(t), declare(t, archive), known, + store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(report, "notes.tools"); o.Action != "unchanged" { + t.Fatalf("an unchanged archive was %s", o.Action) + } + rec, _ := state.Find("notes.tools") + if rec.Unpacked == nil || len(rec.Unpacked.Files) != 2 || rec.Unpacked.Made == extra { + t.Fatalf("extra=%v: learned %+v", extra, rec.Unpacked) + } + + if _, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state, + store.OriginDeclared, noServices, nil, nil); err != nil { + t.Fatal(err) + } + if exists(filepath.Join(target, "index.js")) || exists(filepath.Join(target, "lib", "a.js")) { + t.Fatalf("extra=%v: the archive's files are still there", extra) + } + if exists(target) != extra { + t.Fatalf("extra=%v: the directory is there: %v", extra, exists(target)) + } + if extra && !exists(filepath.Join(target, "lib", "local.js")) { + t.Fatal("a file the archive did not place was removed") + } + } +} diff --git a/internal/apply/archive_unpacked.go b/internal/apply/archive_unpacked.go new file mode 100644 index 0000000..cc032cd --- /dev/null +++ b/internal/apply/archive_unpacked.go @@ -0,0 +1,506 @@ +package apply + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/novox/mesh-host/internal/store" +) + +// What an archive put on the machine, and taking exactly that away (novox/hq issue 162). +// +// An archive unpacks many files into a directory the mesh did not necessarily make, so undeclaring +// one has a real question in it: remove what the archive put there, or remove the directory? The +// second deletes whatever else lives there — for the host's own versions directory, every other +// delivered version. So the host records what each archive unpacked and whether it made the +// directory, and removal takes away exactly that: the files the archive placed, then the +// directories the host made for them once they are empty. Never a file the archive did not place, +// never a directory that was there before, never one that still holds anything else. It is the +// rule every other kind follows: the mesh gives back what it found (ADR 0118), and data outlives +// the mesh that declared it (ADR 0030). + +// ours is what of the tree at an archive's path the record says is the mesh's. +type ours struct { + // all is a record from before the host kept what an archive unpacked: since the swap of issue + // 220 the tree at the path was the archive and nothing else, so the whole of it is taken for + // the mesh's, as the swap that follows has always taken it. + all bool + // paths are the files and directories the previous archive put there, relative to the path. + paths map[string]bool + files []string + dirs []string + made bool + // parents are the directories above the path the host made to reach it, deepest first. + parents []string +} + +// oursFrom reads the record of the archive before this apply, for this path only: a record of the +// same archive at a path it has moved from says nothing about what is at the new one. +func oursFrom(previous store.Applied, path string) ours { + if previous.Wrote == "" || previous.Target != path { + return ours{} + } + u := previous.Unpacked + if u == nil { + return ours{all: true, made: true} + } + o := ours{paths: map[string]bool{}, files: u.Files, dirs: u.Dirs, made: u.Made, parents: u.Parents} + for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) { + o.paths[rel] = true + } + return o +} + +// ownershipProbe is what says whether an archive is still its owner's. The directory, when the host +// made it; one of the archive's own files when the directory was there before — that one is held as +// found (ADR 0182), so its owner is never the archive's to judge. +func ownershipProbe(path string, u *store.Unpacked) string { + if u != nil && !u.Made && len(u.Files) > 0 { + return filepath.Join(path, u.Files[0]) + } + return path +} + +// stillUnpacked is what an unchanged archive has on the machine. The record's, when it has one; on +// a record from before the host kept it, read from the archive's own bytes now — and the directory +// is taken for the host's only when it holds exactly the archive and nothing else, which is what the +// swap of issue 220 leaves. Otherwise the directory is kept for somebody's, and only the archive's +// files are recorded as its. +func stillUnpacked(body []byte, path string, recorded *store.Unpacked) (*store.Unpacked, error) { + if recorded != nil { + kept := *recorded + return &kept, nil + } + files, dirs, err := listArchive(body) + if err != nil { + return nil, err + } + u := &store.Unpacked{Files: pathsOf(files)} + if exactly, err := holdsExactly(path, files, dirs); err == nil && exactly { + u.Dirs = pathsOf(dirs) + u.Made = true + } + return u, nil +} + +// listArchive reads what an archive holds without unpacking it: each file's digest by its path, and +// every directory, named or implied, relative to where it unpacks. Refused on the same terms as +// unpack, so a listing never names a path an unpack would not write. +func listArchive(body []byte) (map[string]string, map[string]bool, error) { + zipped, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, nil, fmt.Errorf("this is not a gzipped tar: %w", err) + } + defer zipped.Close() + files, dirs := map[string]string{}, map[string]bool{} + reader := tar.NewReader(zipped) + for { + header, err := reader.Next() + if err == io.EOF { + return files, dirs, nil + } + if err != nil { + return nil, nil, err + } + rel := filepath.Clean(header.Name) + if rel == "." { + continue + } + if !insideRel(rel) { + return nil, nil, fmt.Errorf("%s names a path outside the archive", header.Name) + } + for d := filepath.Dir(rel); d != "."; d = filepath.Dir(d) { + dirs[filepath.ToSlash(d)] = true + } + switch header.Typeflag { + case tar.TypeDir: + dirs[filepath.ToSlash(rel)] = true + case tar.TypeReg: + sum := sha256.New() + if _, err := io.Copy(sum, io.LimitReader(reader, maxArchive)); err != nil { + return nil, nil, err + } + files[filepath.ToSlash(rel)] = hex.EncodeToString(sum.Sum(nil)) + default: + return nil, nil, fmt.Errorf("%s is a %c, and this host unpacks only files and directories", + header.Name, header.Typeflag) + } + } +} + +// holdsExactly is whether a directory holds the archive's files with the archive's bytes, its +// directories, and nothing else. +func holdsExactly(root string, files map[string]string, dirs map[string]bool) (bool, error) { + seen := 0 + exact := true + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + rel = filepath.ToSlash(rel) + switch { + case d.IsDir(): + if !dirs[rel] { + exact = false + return filepath.SkipAll + } + case d.Type().IsRegular(): + want, ok := files[rel] + if !ok || digestOfFile(path) != want { + exact = false + return filepath.SkipAll + } + seen++ + default: + exact = false + return filepath.SkipAll + } + return nil + }) + if err != nil { + return false, err + } + return exact && seen == len(files), nil +} + +func digestOfFile(path string) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + sum := sha256.New() + if _, err := io.Copy(sum, file); err != nil { + return "" + } + return hex.EncodeToString(sum.Sum(nil)) +} + +// treeOf is every file and directory under root, relative to it, slash-separated and sorted. +func treeOf(root string) (files, dirs []string, err error) { + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." { + return err + } + if d.IsDir() { + dirs = append(dirs, filepath.ToSlash(rel)) + } else { + files = append(files, filepath.ToSlash(rel)) + } + return nil + }) + sort.Strings(files) + sort.Strings(dirs) + if files == nil { + files = []string{} + } + return files, dirs, err +} + +// foreignIn counts what under root the mesh did not put there. A directory that is not the mesh's +// counts once, with everything in it. +func foreignIn(root string, mine map[string]bool) (int, error) { + count := 0 + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." { + return err + } + if !mine[filepath.ToSlash(rel)] { + count++ + if d.IsDir() { + return filepath.SkipDir + } + } + return nil + }) + return count, err +} + +// mergeInto moves a freshly unpacked archive into a directory that is not only the mesh's, one entry +// at a time, and takes out what the previous archive placed that this one does not. Everything the +// mesh did not put there stays as it is. Collisions are looked for before anything is moved, so a +// refused archive leaves the directory exactly as it was. +func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.Unpacked, error) { + var collisions []string + for _, rel := range dirs { + info, err := os.Lstat(filepath.Join(path, filepath.FromSlash(rel))) + if err == nil && !info.IsDir() && !o.paths[rel] { + collisions = append(collisions, rel) + } + } + for _, rel := range files { + dest := filepath.Join(path, filepath.FromSlash(rel)) + info, err := os.Lstat(dest) + switch { + case err != nil: + case o.paths[rel] && !info.IsDir(): + case info.IsDir(): + if !o.paths[rel] { + collisions = append(collisions, rel) + } else if n, err := foreignIn(dest, o.paths); err != nil || n > 0 { + collisions = append(collisions, rel) + } + case !info.Mode().IsRegular() || + digestOfFile(dest) != digestOfFile(filepath.Join(fresh, filepath.FromSlash(rel))): + // Already holding exactly the archive's bytes is not a collision: it is what an + // interrupted earlier apply of this same archive left, or the same file either way. + collisions = append(collisions, rel) + } + } + if len(collisions) > 0 { + shown := collisions + if len(shown) > 5 { + shown = shown[:5] + } + return store.Unpacked{}, fmt.Errorf("%s already holds %d path(s) the archive would write over "+ + "and the mesh did not put there (%s); nothing was unpacked, and what is there is left as it "+ + "is (novox/hq issue 162)", path, len(collisions), strings.Join(shown, ", ")) + } + + var made []string + for _, rel := range dirs { + dest := filepath.Join(path, filepath.FromSlash(rel)) + info, err := os.Lstat(dest) + if err == nil && info.IsDir() { + if o.paths[rel] { + made = append(made, rel) + } + continue + } + if err == nil { + // The previous archive's file where this one has a directory. + if err := os.Remove(dest); err != nil { + return store.Unpacked{}, err + } + } + mode := os.FileMode(0o755) + if from, err := os.Stat(filepath.Join(fresh, filepath.FromSlash(rel))); err == nil { + mode = from.Mode().Perm() + } + if err := os.Mkdir(dest, mode); err != nil { + return store.Unpacked{}, err + } + if err := own(dest, owner); err != nil { + return store.Unpacked{}, err + } + made = append(made, rel) + } + for _, rel := range files { + dest := filepath.Join(path, filepath.FromSlash(rel)) + if info, err := os.Lstat(dest); err == nil && info.IsDir() { + // The previous archive's directory where this one has a file, holding nothing else. + if err := os.RemoveAll(dest); err != nil { + return store.Unpacked{}, err + } + } + if err := os.Rename(filepath.Join(fresh, filepath.FromSlash(rel)), dest); err != nil { + return store.Unpacked{}, err + } + } + + // What the previous archive placed and this one does not. + now := map[string]bool{} + for _, rel := range append(append([]string{}, files...), dirs...) { + now[rel] = true + } + for _, rel := range o.files { + if now[rel] { + continue + } + if err := os.Remove(filepath.Join(path, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) { + return store.Unpacked{}, err + } + } + for _, rel := range deepestFirst(o.dirs) { + if now[rel] { + continue + } + dest := filepath.Join(path, filepath.FromSlash(rel)) + if err := os.Remove(dest); err != nil && !os.IsNotExist(err) { + // Still holding something the mesh did not put there: kept, and still the host's to + // take away once it is empty. + made = append(made, rel) + } + } + sort.Strings(made) + return store.Unpacked{Files: files, Dirs: made, Made: o.made}, nil +} + +// removeArchive is what undeclaring an archive does (novox/hq issue 162): exactly the files it +// unpacked, then the directories the host made for them once they are empty. +// +// **Never fatal.** An archive that could not be removed stopped the whole apply, on every apply +// after, until it was declared again — so every module with tools was un-unassignable, and a race +// between two pushes froze a machine against every other change. Whatever cannot be taken away is +// said, left in place, and forgotten, as a former target is (issue 194). +// +// **Removed whole when it is the host's own, and in one step.** A directory the host made that +// holds nothing but the archive is renamed aside and then removed: a reader — the runtime serving a +// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has +// open stays readable until it closes it. The runtime is told the module went by its own membership, +// not by the files disappearing. +func removeArchive(a store.Applied) (string, string, error) { + if store.IsFormer(a.ID) { + // The version before is what a rollback starts (ADR 0141) and what a reader may still have + // open; the launcher retires the host's own versions, not the apply (issue 194). + return "forgotten", "a former target left in place: only an archive the declaration dropped is " + + "taken away (novox/hq issues 162, 194)", nil + } + u := a.Unpacked + if u == nil { + return "forgotten", "left in place: recorded before the host kept what an archive unpacked, so " + + "its files cannot be told from anything else there (novox/hq issue 162)", nil + } + root := filepath.Clean(a.Target) + mine := map[string]bool{} + for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) { + if !insideRel(filepath.FromSlash(rel)) { + return "forgotten", fmt.Sprintf("left in place: its record names %q, which is not inside %s", + rel, root), nil + } + mine[rel] = true + } + + info, err := os.Lstat(root) + if os.IsNotExist(err) { + removeParents(root, u.Parents) + return "forgotten", "no longer there", nil + } + if err != nil { + return "forgotten", fmt.Sprintf("left in place: %v", err), nil + } + if !info.IsDir() { + return "forgotten", "left in place: no longer a directory, so not what the archive was unpacked into", nil + } + foreign, err := foreignIn(root, mine) + if err != nil { + return "forgotten", fmt.Sprintf("left in place: cannot read what is in it: %v", err), nil + } + + if u.Made && foreign == 0 { + aside := root + ".removing" + if err := os.RemoveAll(aside); err == nil { + if err := os.Rename(root, aside); err == nil { + if err := os.RemoveAll(aside); err != nil { + return "forgotten", fmt.Sprintf("taken out of place, and what it unpacked could not be "+ + "removed from %s: %v — remove it by hand", aside, err), nil + } + removeParents(root, u.Parents) + return "removed", fmt.Sprintf("no longer declared; the %d file(s) it unpacked, and the "+ + "directory the host made for them", len(u.Files)), nil + } + } + // A rename that could not be made is taken file by file instead. + } + + removed := 0 + var failed []string + for _, rel := range u.Files { + err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))) + switch { + case err == nil: + removed++ + case os.IsNotExist(err): + default: + failed = append(failed, err.Error()) + } + } + for _, rel := range deepestFirst(u.Dirs) { + // Only once empty: what is still inside is somebody's. + _ = os.Remove(filepath.Join(root, filepath.FromSlash(rel))) + } + detail := fmt.Sprintf("no longer declared; %d file(s) it unpacked removed", removed) + switch { + case u.Made && os.Remove(root) == nil: + removeParents(root, u.Parents) + detail += ", and the directory the host made for them" + case u.Made: + left, _ := os.ReadDir(root) + detail += fmt.Sprintf("; the directory is kept: %d item(s) inside that the mesh did not put there", + len(left)) + default: + detail += "; the directory is kept: it was there before the archive" + } + if len(failed) > 0 { + // Said and not fatal: fatal, the record would stay and fail the same way on every apply + // after — the very wedge this removal exists to end. + return "forgotten", detail + "; could not remove, and left in place: " + strings.Join(failed, "; "), nil + } + return "removed", detail, nil +} + +// removeParents takes away the directories above an archive the host made to reach it, deepest +// first, each only once it is empty and only if it is above the archive's directory. +func removeParents(root string, parents []string) { + for _, p := range parents { + clean := filepath.Clean(p) + if !filepath.IsAbs(clean) || !strings.HasPrefix(root, clean+string(os.PathSeparator)) { + continue + } + _ = os.Remove(clean) + } +} + +// joinParents is the parents made now and those recorded before, deepest first, once each. +func joinParents(now, before []string) []string { + seen := map[string]bool{} + var out []string + for _, p := range append(append([]string{}, now...), before...) { + if !seen[p] { + seen[p] = true + out = append(out, p) + } + } + sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) }) + return out +} + +// insideRel is whether a relative path stays inside the directory it is relative to. +func insideRel(rel string) bool { + clean := filepath.Clean(rel) + return clean != "." && !filepath.IsAbs(clean) && clean != ".." && + !strings.HasPrefix(clean, ".."+string(os.PathSeparator)) +} + +func deepestFirst(rels []string) []string { + out := append([]string{}, rels...) + sort.Slice(out, func(i, j int) bool { + return strings.Count(out[i], "/") > strings.Count(out[j], "/") || + (strings.Count(out[i], "/") == strings.Count(out[j], "/") && out[i] > out[j]) + }) + return out +} + +func pathsOf[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/apply/former_archive_test.go b/internal/apply/former_archive_test.go index 9c7d401..16d9765 100644 --- a/internal/apply/former_archive_test.go +++ b/internal/apply/former_archive_test.go @@ -10,9 +10,9 @@ import ( // The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds // the version before it as a former target of the archive that delivered it; an archive has no -// removal (issue 162), and the refusal stopped every machine applying anything. A former target of -// such a kind is left in place, said, and forgotten. An archive the declaration dropped still fails, -// as 162 has it. +// removal then (issue 162), and the refusal stopped every machine applying anything. A former +// target of an archive is left in place, said, and forgotten: the version before is what a rollback +// starts (ADR 0141). func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) { run := func(_ context.Context, name string, args ...string) (string, error) { if name == "docker" && args[0] == "info" { @@ -62,14 +62,7 @@ func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) { t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes) } - // An archive the declaration dropped is a different matter: nothing can undo it, and saying - // it was would report an effect the host declined to have (issue 162). - dropped := store.State{Resources: []store.Applied{ - {ID: "tool.next", Type: "archive", Target: "/usr/lib/tool/versions/old", Origin: store.OriginDeclared}, - }} - only := parse(t, `{"declaration":1,"resources":[{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}]}`) - if _, _, err := Apply(context.Background(), archHost(t), only, dropped, store.OriginDeclared, run, nil, nil); err == nil || - !strings.Contains(err.Error(), "no way to remove") { - t.Fatalf("a dropped archive was passed over: %v", err) - } + // An archive the declaration dropped is taken away since issue 162; one recorded before the + // host kept what it unpacked is left in place and forgotten, never fatal — that case is + // archive_removal_test.go's. } diff --git a/internal/apply/plan.go b/internal/apply/plan.go index 475a34c..9c89761 100644 --- a/internal/apply/plan.go +++ b/internal/apply/plan.go @@ -90,6 +90,13 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step { switch { case orphan.Stateless: step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is" + case orphan.Type == string(declaration.TypeArchive) && store.IsFormer(orphan.ID): + // In removeArchive's words (novox/hq issues 162, 194). + step.Verb, step.Why = "forget", "a former target; left in place, since the version before is what a rollback starts" + case orphan.Type == string(declaration.TypeArchive) && orphan.Unpacked == nil: + step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it unpacked, so it is left in place" + case orphan.Type == string(declaration.TypeArchive): + step.Why = "no longer declared; the files it unpacked go, and the directories the host made for them once empty" case orphan.Type == string(declaration.TypeService): // What removal will do, said before it does it (novox/hq ADR 0118), in removeService's // words. "restore" only where it may stop or disable something — the record cannot say diff --git a/internal/apply/user.go b/internal/apply/user.go index 740eea2..d349cac 100644 --- a/internal/apply/user.go +++ b/internal/apply/user.go @@ -273,6 +273,13 @@ func ownedBy(path, owner string) (bool, error) { // home, read from the user database, not guessed from a prefix on /home: a module's directory under // /var/lib is made exactly as before, whoever its files belong to. func makeDirs(dir string, mode os.FileMode, owner string) error { + _, err := makeDirsSaying(dir, mode, owner) + return err +} + +// makeDirsSaying is makeDirs, and says which directories it made, deepest first — so an archive +// can take away on removal the parents it made to reach its directory (novox/hq issue 162). +func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error) { var made []string for d := filepath.Clean(dir); ; d = filepath.Dir(d) { if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) { @@ -284,16 +291,16 @@ func makeDirs(dir string, mode os.FileMode, owner string) error { } } if err := os.MkdirAll(dir, mode); err != nil { - return err + return nil, err } if owner == "" || len(made) == 0 { - return nil + return made, nil } home, err := homeOf(owner) if err != nil || home == "" { // A numeric owner — a container's user — has no home, and a name the machine does not // know fails where the target is given to it. Either way nothing here is a home's. - return nil + return made, nil } home = filepath.Clean(home) for _, d := range made { @@ -301,10 +308,10 @@ func makeDirs(dir string, mode os.FileMode, owner string) error { continue } if err := ownMade(d, owner); err != nil { - return err + return made, err } } - return nil + return made, nil } // homeOf is an owner's home from the user database, and ownMade gives a directory the host made to diff --git a/internal/store/store.go b/internal/store/store.go index 05d9c92..cdb9cb7 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -102,6 +102,13 @@ type Applied struct { // host kept it — then the shell is left exactly as it is. Shell *LoginShell `json:"shell,omitempty"` + // Unpacked is, for an archive, what it put on the machine (novox/hq issue 162): the files and + // directories it unpacked, and whether the directory it was unpacked into and the parents above + // it were made by the host. Removal takes away exactly that and nothing else. Absent on a + // record written before the host kept it — then removal cannot tell the archive's files from + // anything else in the directory, and leaves it in place. + Unpacked *Unpacked `json:"unpacked,omitempty"` + // Reads is, for a container, the digest of each file it was created reading — its env-files // and the files mounted into it — by path (novox/hq 04-ISSUES/103). // @@ -618,6 +625,22 @@ type LoginShell struct { Created bool `json:"created,omitempty"` } +// Unpacked is what an archive put under its directory, so undeclaring it takes away exactly that +// (novox/hq issue 162). +type Unpacked struct { + // Files are the files the archive placed, relative to its directory, slash-separated. + Files []string `json:"files"` + // Dirs are the directories inside it the host made for the archive — never one that was + // there before, so removal never takes a directory somebody else made, even an empty one. + Dirs []string `json:"dirs,omitempty"` + // Made is that the host made the directory itself: it was not there before the archive. One + // that was there before is never removed, empty or not. + Made bool `json:"made,omitempty"` + // Parents are the directories above it the host made to reach it, deepest first; each is + // removed on the way out only once it is empty. + Parents []string `json:"parents,omitempty"` +} + // PendingFound is a unit as found by an apply of its service that has not yet been recorded, and // who asked for that apply — so only a declaration from the same origin can say it is gone. type PendingFound struct {