An archive can be undeclared, and undeclaring one no longer stops the apply (hq issue 162)
An archive had no removal, so an unassigned one failed as an orphan and aborted every apply after: a module with tools could not be unassigned, and a race between two pushes froze a machine against every change. The record now keeps what an archive unpacked: its files, the directories the host made inside its path, whether the host made the path itself, and the parents it made to reach it. Removal takes exactly that away, directories only once empty, never one that was there before; a directory that is the host's alone is renamed aside first so a reader sees the whole bundle or none of it. Whatever cannot be removed is said and forgotten, never fatal. A directory found before the archive is no longer swapped away with what was in it: the archive is moved in file by file, and one that would write over a file the mesh did not put there is refused before anything moves. A record from before this change learns its files from the archive's bytes on the next apply; one already orphaned is left in place, said and forgotten. A former target is still left in place: the version before is what a rollback starts (ADR 0141).
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