Merge pull request 'An archive can be undeclared, and undeclaring one no longer stops the apply (hq issue 162)' (#90) from fix/162-an-archive-can-be-undeclared into main
This commit is contained in:
+17
-8
@@ -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")
|
||||
|
||||
|
||||
+80
-24
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-5
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user