mesh/delivery merged: source.main moved to novox/mesh-host@998d8f737008
mesh/check being carried to 5dc3a8e17f97 on 998d8f737008 | from fabfdf04f78a
The journal of an unpack sat at <target>.unpacked.json, in a directory the module's account may write: a forged one would make the root engine remove or adopt any path it named. It now lives under <statedir>/unpacked/<sha256 of the target>.json, mode 0600, names its target, and is refused whole when an entry is not filepath.IsLocal or a parent is not above the target. It goes when the archive is taken off, whether its state record still said what it unpacked or not. The launcher's stop grace for a host on trial rises from 20 s to 75 s and the unit's TimeoutStopSec is 120 s (openrc the same), past the host's own 60 s wait for the apply in flight, so neither cuts a save off again. Tests folded as rows into existing ones (the repository is over its unit budget): the journal's rows, a forged entry and parent refused, the journal gone with a record that lost its Unpacked; the test that passed on main dropped; the stop test waits on its own line, not a sleep, and no longer consumes the save it waits for. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
355 lines
15 KiB
Go
355 lines
15 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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))
|
|
}
|
|
|
|
// Issue 1585: what the host unpacked is written down at once, in its own state directory, so an archive whose
|
|
// record in the state was lost is still the host's: the next version replaces its files, drops the ones it no
|
|
// longer has and leaves a file somebody added. A file the host never unpacked still refuses it, and so does a
|
|
// journal naming a path outside the archive's directory. The journal goes with the archive, record lost or not.
|
|
for _, row := range []struct {
|
|
name string
|
|
journaled bool // the first version was unpacked by the host
|
|
foreign string
|
|
forged func(j *unpackJournal)
|
|
refused string
|
|
}{
|
|
{"unpacked by the host, record lost", true, "", nil, ""},
|
|
{"unpacked by the host, somebody's file beside", true, "mine.txt", nil, ""},
|
|
{"never unpacked by the host", false, "", nil, "did not put there"},
|
|
{"a journal naming a path outside", true, "", func(j *unpackJournal) {
|
|
j.Unpacked.Files = append(j.Unpacked.Files, "../../etc/passwd")
|
|
}, "not inside it"},
|
|
{"a journal naming a parent not above", true, "", func(j *unpackJournal) {
|
|
j.Unpacked.Parents = []string{"/etc"}
|
|
}, "not above it"},
|
|
} {
|
|
t.Run(row.name, func(t *testing.T) {
|
|
dir, state := t.TempDir(), t.TempDir()
|
|
windows, journals := WindowsIn(state), filepath.Join(state, JournalDirName)
|
|
applyIt := func(decl string, known store.State) (store.State, error) {
|
|
_, after, err := ApplyMindingWindows(context.Background(), archHost(t), declare(t, decl), known,
|
|
store.OriginDeclared, noServices, nil, nil, nil, windows)
|
|
return after, err
|
|
}
|
|
target := filepath.Join(dir, "tools")
|
|
first, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "one", "old": "o"}, "")
|
|
if row.journaled {
|
|
if _, err := applyIt(first, store.State{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
|
t.Fatalf("%d entries beside the archive: the journal is the state directory's", len(entries))
|
|
}
|
|
info, err := os.Stat(journalPath(journals, target))
|
|
if err != nil || info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("no journal of root's alone after the unpack: %v %v", info, err)
|
|
}
|
|
} else {
|
|
os.MkdirAll(target, 0o755)
|
|
os.WriteFile(filepath.Join(target, "bin"), []byte("one"), 0o644)
|
|
}
|
|
if row.forged != nil {
|
|
j, _, err := journalOf(journals, target)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
row.forged(&j)
|
|
raw, _ := json.Marshal(j)
|
|
os.WriteFile(journalPath(journals, target), raw, 0o600)
|
|
}
|
|
if row.foreign != "" {
|
|
os.WriteFile(filepath.Join(target, row.foreign), []byte("x"), 0o644)
|
|
}
|
|
second, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "two"}, "")
|
|
// The state is empty: the record of the first unpack was lost.
|
|
after, err := applyIt(second, store.State{})
|
|
if row.refused != "" {
|
|
if err == nil || !strings.Contains(err.Error(), row.refused) {
|
|
t.Fatalf("refused? %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, _ := os.ReadFile(filepath.Join(target, "bin")); string(got) != "two" {
|
|
t.Fatalf("bin is %q", got)
|
|
}
|
|
if exists(filepath.Join(target, "old")) {
|
|
t.Fatal("a file the new version no longer has is still there")
|
|
}
|
|
if row.foreign != "" && !exists(filepath.Join(target, row.foreign)) {
|
|
t.Fatal("somebody's file went")
|
|
}
|
|
// Taken off, with a record that no longer says what it unpacked: the journal goes too.
|
|
for i := range after.Resources {
|
|
after.Resources[i].Unpacked = nil
|
|
}
|
|
if _, err := applyIt(`{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`, after); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if exists(journalPath(journals, target)) {
|
|
t.Fatal("the journal outlived the archive")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|