On novox the node-engine's state had lost the records of nats's two archives, which only a temporary save file of 2026-10-24 still held, and about 260 such files (~80 MB) lay beside it. The cause: an apply runs detached from the engine's stop (applyContext, WithoutCancel), and runLink returned, and the process ended, without waiting for it; a save cut between its write and its rename left its temporary file and the state before it, and whatever the apply did was never recorded. Quick fix: remove the leftover files by hand and restore the records. Thorough fix, chosen: the engine ends between applies whichever way it ends (a stop, standing aside, a move to another bus): it waits up to 60 s for the apply in flight, then lets no save start (store.Quiesce). Save is one writer per process, flushes the directory after its rename, and says loudly each record the state saved before held that this save drops and nothing forgot (Forget, Release mark what is dropped on purpose, and no longer write into an array an earlier copy reads). The engine's start removes leftover temporary saves under the apply lock and says how many. Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
249 lines
9.4 KiB
Go
249 lines
9.4 KiB
Go
package store
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAFreshMachineHasAnEmptyStateNotAnError(t *testing.T) {
|
|
// The first apply on a machine that has never been touched is the ordinary case, not a
|
|
// failure. A host that errored here could never bootstrap anything.
|
|
s, err := Load(filepath.Join(t.TempDir(), "nothing-here.json"))
|
|
if err != nil {
|
|
t.Fatalf("a fresh machine produced an error: %v", err)
|
|
}
|
|
if len(s.Resources) != 0 {
|
|
t.Errorf("a fresh machine claims to own %d resources", len(s.Resources))
|
|
}
|
|
}
|
|
|
|
func TestAnUnreadableStateIsRefusedNotIgnored(t *testing.T) {
|
|
// The dangerous one. Starting empty would make the host believe it owns nothing, so it
|
|
// would remove nothing it should and re-apply everything it need not — silently.
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
if err := os.WriteFile(path, []byte("{this is not json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("a corrupt state was read as an empty one")
|
|
}
|
|
if !strings.Contains(err.Error(), "believes it owns nothing") {
|
|
t.Errorf("the error does not say why this matters: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWhatIsSavedIsWhatIsLoaded(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
want := State{Resources: []Applied{
|
|
{ID: "etc", Type: "directory", Target: "/etc/mesh", AppliedAt: time.Now().UTC().Truncate(time.Second)},
|
|
{ID: "conf", Type: "file", Target: "/etc/mesh/host.conf", AppliedAt: time.Now().UTC().Truncate(time.Second)},
|
|
}}
|
|
if err := Save(path, want); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Resources) != 2 || got.Resources[0].ID != "etc" || got.Resources[1].ID != "conf" {
|
|
t.Fatalf("order or content was lost: %+v", got.Resources)
|
|
}
|
|
if got.UpdatedAt.IsZero() {
|
|
t.Error("the state does not say when it was written")
|
|
}
|
|
}
|
|
|
|
func TestTheStateIsNotWorldReadable(t *testing.T) {
|
|
// It records what is on the machine and where. Not secret, and not everyone's business.
|
|
path := filepath.Join(t.TempDir(), "state.json")
|
|
if err := Save(path, State{Resources: []Applied{{ID: "a", Type: "file", Target: "/a"}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mode := info.Mode().Perm(); mode&0o077 != 0 {
|
|
t.Errorf("the state is readable by others: %o", mode)
|
|
}
|
|
}
|
|
|
|
func TestSavingLeavesNoDebrisBehind(t *testing.T) {
|
|
// The write is atomic through a temporary file. A run that left those behind would fill a
|
|
// directory with near-copies of the truth, and the next reader would have to guess.
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "state.json")
|
|
for i := 0; i < 3; i++ {
|
|
if err := Save(path, State{Resources: []Applied{{ID: "a", Type: "file", Target: "/a"}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 {
|
|
names := []string{}
|
|
for _, e := range entries {
|
|
names = append(names, e.Name())
|
|
}
|
|
t.Errorf("expected only the state file, found: %v", names)
|
|
}
|
|
|
|
// Issue 1585: a save cut off between its write and its rename (the engine ended under it) left its temporary file;
|
|
// the engine's start removes them under the apply lock and names each, and leaves the state and every other file.
|
|
t.Run("leftovers of cut-off saves are removed", func(t *testing.T) {
|
|
for _, name := range []string{".state-3758449435.json", ".state-1403853615.json", ".declared-1.json"} {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("{}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
removed, err := RemoveLeftovers(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(removed, ",") != ".state-1403853615.json,.state-3758449435.json" {
|
|
t.Errorf("removed %v", removed)
|
|
}
|
|
for _, keep := range []string{"state.json", ".declared-1.json"} {
|
|
if _, err := os.Stat(filepath.Join(dir, keep)); err != nil {
|
|
t.Errorf("%s was not left: %v", keep, err)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Issue 1585: a save that drops a record the state saved before held, which nothing forgot, says so loudly; one
|
|
// forgotten or released on purpose is not said. Forget never writes into the array a copy taken before still reads.
|
|
t.Run("a dropped record is said, a forgotten one is not", func(t *testing.T) {
|
|
var said []string
|
|
was := Said
|
|
Said = func(line string) { said = append(said, line) }
|
|
defer func() { Said = was }()
|
|
before := State{Resources: []Applied{{ID: "c", Type: "file", Target: "/c"},
|
|
{ID: "nats.program-snapshot", Type: "archive", Target: "/s"}, {ID: "nats.bundle-tools", Type: "archive", Target: "/t"}},
|
|
Held: []Held{{ID: "h"}}}
|
|
if err := Save(path, before); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(said) != 1 || !strings.Contains(said[0], "resource a,") {
|
|
t.Errorf("the record of a, saved before and dropped, was not said: %v", said)
|
|
}
|
|
said = nil
|
|
copied := before
|
|
copied.Forget("c")
|
|
copied.Release("h")
|
|
if before.Resources[0].ID != "c" {
|
|
t.Errorf("Forget wrote into the copy taken before: %+v", before.Resources)
|
|
}
|
|
if err := Save(path, copied); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(said) != 0 {
|
|
t.Errorf("a record forgotten on purpose was said lost: %v", said)
|
|
}
|
|
stale := State{Resources: []Applied{{ID: "nats.bundle-tools", Type: "archive", Target: "/t"}}}
|
|
if err := Save(path, stale); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(said) != 1 || !strings.Contains(said[0], "resource nats.program-snapshot") {
|
|
t.Errorf("a lost record was not said, or said wrong: %v", said)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRecordReplacesRatherThanDuplicating(t *testing.T) {
|
|
s := State{}
|
|
s.Record(Applied{ID: "a", Type: "file", Target: "/old"})
|
|
s.Record(Applied{ID: "a", Type: "file", Target: "/new"})
|
|
|
|
if len(s.Resources) != 1 {
|
|
t.Fatalf("one identity produced %d records", len(s.Resources))
|
|
}
|
|
if s.Resources[0].Target != "/new" {
|
|
t.Errorf("the record was not updated: %+v", s.Resources[0])
|
|
}
|
|
}
|
|
|
|
func TestOrphansAreWhatWasAppliedAndIsNoLongerDeclared(t *testing.T) {
|
|
// The whole reason the store arrives at stage 2 rather than stage 3: removal is impossible
|
|
// without knowing what was applied.
|
|
s := State{Resources: []Applied{
|
|
{ID: "dir", Type: "directory", Target: "/etc/mesh"},
|
|
{ID: "file", Type: "file", Target: "/etc/mesh/a.conf"},
|
|
{ID: "kept", Type: "file", Target: "/etc/mesh/b.conf"},
|
|
}}
|
|
orphans := s.Orphans(map[string]bool{"kept": true}, OriginCarried)
|
|
|
|
if len(orphans) != 2 {
|
|
t.Fatalf("expected two orphans, got %d: %+v", len(orphans), orphans)
|
|
}
|
|
// Reverse order: undoing in the order things were made would remove a directory before the
|
|
// file inside it.
|
|
if orphans[0].ID != "file" || orphans[1].ID != "dir" {
|
|
t.Errorf("orphans are not in reverse order: %s then %s", orphans[0].ID, orphans[1].ID)
|
|
}
|
|
}
|
|
|
|
func TestNothingIsAnOrphanWhenEverythingIsDeclared(t *testing.T) {
|
|
s := State{Resources: []Applied{{ID: "a", Type: "file", Target: "/a"}}}
|
|
if got := s.Orphans(map[string]bool{"a": true}, OriginCarried); len(got) != 0 {
|
|
t.Errorf("a declared resource was treated as an orphan: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestADeclarationDoesNotOrphanWhatTheBundleRaised(t *testing.T) {
|
|
// 04-ISSUES/010. A first node raises its foundation from the bundle it carries, then enrols
|
|
// and is sent a declaration naming two resources. Before origins, that removed the store, the
|
|
// broker and the control plane that had sent it — the mesh deleting itself over the link the
|
|
// message arrived on, in under a second, on the first end-to-end run.
|
|
s := State{Resources: []Applied{
|
|
{ID: "store", Type: "container", Target: "mesh-store", Origin: OriginCarried},
|
|
{ID: "broker", Type: "container", Target: "mesh-broker", Origin: OriginCarried},
|
|
{ID: "greeting", Type: "directory", Target: "/var/lib/demo", Origin: OriginDeclared},
|
|
}}
|
|
|
|
// The mesh declares nothing at all. Everything it previously declared is an orphan; nothing
|
|
// the bundle raised is.
|
|
orphans := s.Orphans(map[string]bool{}, OriginDeclared)
|
|
if len(orphans) != 1 || orphans[0].ID != "greeting" {
|
|
var got []string
|
|
for _, o := range orphans {
|
|
got = append(got, o.ID)
|
|
}
|
|
t.Fatalf("a declaration would remove %v; it may only remove what the mesh declared", got)
|
|
}
|
|
}
|
|
|
|
func TestTheBundleDoesNotOrphanWhatTheMeshDeclared(t *testing.T) {
|
|
// The same rule the other way. A host reconciling its carried bundle must not remove what the
|
|
// mesh assigned to this node, or every restart would undo the node's actual work.
|
|
s := State{Resources: []Applied{
|
|
{ID: "store", Type: "container", Target: "mesh-store", Origin: OriginCarried},
|
|
{ID: "workload", Type: "container", Target: "some-app", Origin: OriginDeclared},
|
|
}}
|
|
|
|
orphans := s.Orphans(map[string]bool{"store": true}, OriginCarried)
|
|
if len(orphans) != 0 {
|
|
t.Errorf("reconciling the bundle would remove %s, which the mesh declared", orphans[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestStateWrittenBeforeOriginsExistedIsTreatedAsCarried(t *testing.T) {
|
|
// Every resource a host had applied before this field existed came from its bundle, because
|
|
// there was no other way to tell it anything. Guessing the other way would have the first
|
|
// declaration remove the foundation — which is the fault this exists to prevent, arriving
|
|
// through the upgrade that fixes it.
|
|
s := State{Resources: []Applied{{ID: "store", Type: "container", Target: "mesh-store"}}}
|
|
|
|
if got := s.Orphans(map[string]bool{}, OriginDeclared); len(got) != 0 {
|
|
t.Errorf("a declaration would remove %s, recorded before origins existed", got[0].ID)
|
|
}
|
|
if got := s.Orphans(map[string]bool{}, OriginCarried); len(got) != 1 {
|
|
t.Error("the bundle cannot remove its own resource, so nothing could ever remove it")
|
|
}
|
|
}
|