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).
262 lines
11 KiB
Go
262 lines
11 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|