Files
jschoubben ee2648188d Repoint ADR references after HQ consolidated 65 records to 23
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.
2026-08-28 23:33:44 +02:00

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)
}
}