A declaration is JSON, versioned, and an ordered list of resources with stable identities (novox/hq ADR 0043). The vocabulary is directory, file and service, and anything outside it — an unknown version, type or field — refuses the WHOLE declaration. A host that skipped what it did not understand would apply most of what it was sent and report success. It converges rather than executes: applying twice changes nothing the second time, and applying to a drifted machine returns it. A mode is maintained rather than set, because a permission applied at creation is not a permission held — this repository has paid for that once already. It owns a footprint and only that. What it applied and is no longer declared is removed; what it did not create is never touched. Removal runs FIRST, because a resource leaving a declaration while another arrives at the same path is an ordinary rename, and removing afterwards would delete the file just written. The store arrives here rather than at stage 3, as ADR 0043 predicted: nothing can be removed without knowing what was applied. It is written atomically, refuses to start empty when it exists and cannot be read — believing it owns nothing would leave everything behind forever — and is saved even when an apply fails, because what was applied before the failure is on the machine either way. Three faults found by running inside a raised machine rather than by reasoning: A unit that DOES NOT EXIST reads as `inactive` from `systemctl is-active`, exactly as a stopped one does. So declaring a unit stopped reported success for a unit the host cannot manage at all — absence read as satisfaction, which is 04-ISSUES/007 wearing a different hat. LoadState separates them. Removing an orphaned service whose unit has since been uninstalled failed the whole apply, and a host holding such a record could then apply NOTHING, ever, with no way out but editing its state by hand. Removal is now idempotent for the same reason os.RemoveAll is. And the flag parser was wrong in the same way twice: fixing `mesh-host inventory --json` by taking the subcommand off the front left `mesh-host apply decl.json --dry-run` broken identically, because the standard library stops at the first non-flag argument wherever that argument is. Parsed in a loop now. 30 new tests, 55 in total.
137 lines
4.5 KiB
Go
137 lines
4.5 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})
|
|
|
|
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}); len(got) != 0 {
|
|
t.Errorf("a declared resource was treated as an orphan: %+v", got)
|
|
}
|
|
}
|