Files
mesh-host/internal/store/store_test.go
T
jschoubben fa48b5825e The bundle and the mesh stop removing each other
04-ISSUES/010. The store now records where each resource came from -- carried,
or declared -- and each origin removes only its own. A declaration removes what
the mesh previously declared and never what the bundle raised.

State written before the field existed reads as carried, because everything a
host had applied by then came from its bundle: there was no other way to tell
it anything. Guessing the other way would have the first upgrade remove the
substrate, which is this fault arriving through the change that fixes it.

Verified on the scenario that caused it, and on the property that had to
survive it: a later declaration dropping a resource still removes that
resource, so removal by omission still means what it meant.

Also stops swallowing a publish failure. A node that applied a declaration and
could not tell the mesh looked exactly like one that had -- the mesh believing
it never answered, the node believing it did, and nothing anywhere saying so.
Reports are published mandatory now, so anything the broker cannot route comes
back and is said out loud rather than dropped in silence.
2026-08-29 16:43:46 +02:00

189 lines
7.0 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)
}
}
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 substrate 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 substrate — 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")
}
}