96 comments across the two repos named records that no longer exist. Each now points at the consolidated record that holds its reasoning -- ADR 0034 (a test defends a decision) is 0017, the eight host records are 0005, the four lab records are 0016. Worth noting for next time: these are references from outside HQ, so renumbering there is not free. It cost 38 files here.
194 lines
6.0 KiB
Go
194 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|