package upgrade import ( "os" "path/filepath" "strings" "testing" ) // started puts a binary on disk and captures it the way the host does at start. // // Against the real filesystem rather than a fake one. What is being tested is how the operating // system behaves when a file is replaced under a running process, and a fake would assert that // the fake behaves as expected (novox/hq ADR 0017). func started(t *testing.T) (Self, string) { t.Helper() binary := filepath.Join(t.TempDir(), "mesh-host") if err := os.WriteFile(binary, []byte("version one"), 0o755); err != nil { t.Fatal(err) } self, err := Current(binary) if err != nil { t.Fatal(err) } return self, binary } func TestAnUntouchedBinaryIsNotReplaced(t *testing.T) { // The case that runs every ten minutes forever. A false positive here is a node that exits // and restarts on every reconcile — a restart loop dressed as an upgrade. self, _ := started(t) replaced, err := self.Replaced() if err != nil { t.Fatalf("could not tell: %v", err) } if replaced { t.Error("an untouched binary was reported as replaced; this host would restart forever") } } func TestRewritingTheSameFileIsNotAReplacement(t *testing.T) { // Touching content in place keeps the inode, and a package manager does not install this // way — but something else on the machine might. The claim is about identity, not content. self, binary := started(t) f, err := os.OpenFile(binary, os.O_WRONLY, 0o755) if err != nil { t.Fatal(err) } if _, err := f.WriteString("same inode, new bytes"); err != nil { t.Fatal(err) } f.Close() replaced, err := self.Replaced() if err != nil { t.Fatalf("could not tell: %v", err) } if replaced { t.Error("writing through the same inode was reported as a replacement") } } func TestInstallingOverTheBinaryIsAReplacement(t *testing.T) { // What a package manager actually does: write a new file and rename it over the old one. // The running process keeps the old inode; the path now holds a different file. self, binary := started(t) next := binary + ".new" if err := os.WriteFile(next, []byte("version two"), 0o755); err != nil { t.Fatal(err) } if err := os.Rename(next, binary); err != nil { t.Fatal(err) } replaced, err := self.Replaced() if err != nil { t.Fatalf("could not tell: %v", err) } if !replaced { t.Error("a binary replaced by rename was not noticed; this host would keep running the " + "old version and report the new one") } } func TestRemovingTheBinaryIsAReplacement(t *testing.T) { // A package removed rather than upgraded. Nothing is at the path, and the honest answer is // still "not what I am running" — reporting unchanged would leave the host claiming a // version that is no longer installed. self, binary := started(t) if err := os.Remove(binary); err != nil { t.Fatal(err) } replaced, err := self.Replaced() if err != nil { t.Fatalf("could not tell: %v", err) } if !replaced { t.Error("a removed binary was reported as unchanged") } } func TestNotBeingAbleToTellIsAnError(t *testing.T) { // Never a silent false. A host that cannot read its own image must say so rather than // assume it is current, which is the shape of every fault this repository catalogues. if _, err := Current(filepath.Join(t.TempDir(), "no-such-binary")); err == nil { t.Fatal("capturing a nonexistent executable returned an identity instead of an error") } // And a Self that was never captured must refuse rather than answer. if _, err := (Self{}).Replaced(); err == nil { t.Fatal("an uncaptured Self answered instead of refusing") } } func TestKnownGoodRoundTrips(t *testing.T) { path := KnownGoodPath(filepath.Join(t.TempDir(), "state.json")) if err := RecordKnownGood(path, "1.4.2"); err != nil { t.Fatalf("could not record: %v", err) } got, err := ReadKnownGood(path) if err != nil { t.Fatalf("could not read back: %v", err) } if got != "1.4.2" { t.Errorf("recorded 1.4.2 and read back %q", got) } } func TestKnownGoodIsOneBareLine(t *testing.T) { // The reader is a shell script on a machine where the host is failing to start. It must not // need a JSON parser, and it must not need to strip anything but a newline. path := KnownGoodPath(filepath.Join(t.TempDir(), "state.json")) if err := RecordKnownGood(path, "1.4.2"); err != nil { t.Fatal(err) } raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(raw) != "1.4.2\n" { t.Errorf("known-good is %q; a rollback script reads this with `cat`, so it is one bare "+ "line and nothing else", string(raw)) } if strings.ContainsAny(string(raw), "{}\"") { t.Error("known-good contains structure; it must be readable without a parser") } } func TestNeverHavingBeenGoodIsNotAnError(t *testing.T) { // A machine whose host has never completed a reconcile has nothing to go back to. That is a // real state — the node was never working — and a rollback must be able to tell it apart // from a read failure, because guessing a version is how recovery becomes a second fault. path := KnownGoodPath(filepath.Join(t.TempDir(), "state.json")) got, err := ReadKnownGood(path) if err != nil { t.Fatalf("absence was reported as a failure: %v", err) } if got != "" { t.Errorf("expected no known-good version, got %q", got) } } func TestAnEmptyVersionIsRefused(t *testing.T) { // An empty known-good would make the rollback script install nothing and report success — // the exact failure the rollback exists to prevent, relocated into the rollback. path := KnownGoodPath(filepath.Join(t.TempDir(), "state.json")) if err := RecordKnownGood(path, ""); err == nil { t.Fatal("an empty version was accepted as known-good") } } func TestRecordingAgainReplacesRatherThanAppends(t *testing.T) { path := KnownGoodPath(filepath.Join(t.TempDir(), "state.json")) for _, v := range []string{"1.4.2", "1.4.3", "1.5.0"} { if err := RecordKnownGood(path, v); err != nil { t.Fatal(err) } } got, err := ReadKnownGood(path) if err != nil { t.Fatal(err) } if got != "1.5.0" { t.Errorf("after three recordings the file says %q; it holds the last one, not a history", got) } }