Files
mesh-host/internal/upgrade/witnessed_test.go
T
jochen 0c405b70cc Roll a core build back by a witness that is not the new build (hq to-be 45 Phase 4)
The launcher trusted a counter only a by-hand reconcile ever cleared and a
known-good nothing in the daemon wrote, so no machine could roll its host back;
the controller and the node tools were replaced in place with nothing kept.

- The launcher runs a delivered host that is not known-good on trial: one that
  crashes, stops for nothing, or does not report within ten minutes goes back
  to known-good, once per version, recorded in rolled-back. The host proves
  itself when the mesh takes a report under its own build, says every standing
  verdict on its reports, never stands aside for a rolled-back version, and
  restarts its service once when its launcher was replaced on disk.
- The engine keeps the controller's and the node tools' previous build beside
  the new one and judges the new one: the lease taken by the controller it
  started (read-only direct get of mesh-controller_lease/holder), or this
  machine's runtime answering $SRV.PING.node-tools.<node>, within sixty seconds
  of time it could ask. Not healthy: the previous restored, once, said. Proved:
  the previous deleted. A build declared not-reversible is never rolled back.
- Retire never removes a version newer than the running one.
2026-10-06 18:23:56 +02:00

139 lines
5.2 KiB
Go

package upgrade
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// The launcher's record, read as the launcher writes it: the launcher itself writes it here, so the
// two cannot drift (novox/hq to-be 45 §8).
func TestTheLaunchersRecordIsReadAsTheLauncherWritesIt(t *testing.T) {
state := t.TempDir()
libexec := t.TempDir()
versions := filepath.Join(libexec, VersionsDirName)
base := time.Now().Add(-2 * time.Hour)
for i, v := range []string{"1.0", "2.0"} {
binary := deliver(t, versions, v, base.Add(time.Duration(i)*time.Hour))
// Each fails, so the one run iteration ends.
if err := os.WriteFile(binary, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(state, KnownGoodName), []byte("1.0\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(state, AttemptsName), []byte("3\n"), 0o644); err != nil {
t.Fatal(err)
}
launch := exec.Command("sh", "../../packaging/nox-mesh-host-launch")
launch.Env = append(os.Environ(), "MESH_HOST_STATE_DIR="+state, "MESH_HOST_LIBEXEC="+libexec,
"MESH_HOST_BIN=/nonexistent", "MESH_HOST_RUN_ONCE=1", "MESH_HOST_BACKOFF=0")
_ = launch.Run()
verdicts, err := ReadRolledBack(RolledBackPath(filepath.Join(state, "state.json")))
if err != nil {
t.Fatal(err)
}
if len(verdicts) == 0 {
t.Fatal("the launcher rolled back and nothing was read from its record")
}
v := verdicts[0]
if v.From != "2.0" || v.To != "1.0" || v.Outcome != "rolled-back" || !strings.Contains(v.Why, "failed 3 times") ||
time.Since(v.At) > time.Minute {
t.Fatalf("the record reads as %+v", v)
}
}
// A version the launcher rolled back is never what this host stands aside for: it would be refused,
// and the host would stand aside after every apply for ever.
func TestTheHostNeverStandsAsideForARolledBackVersion(t *testing.T) {
dir := t.TempDir()
base := time.Now().Add(-2 * time.Hour)
deliver(t, dir, "1.0", base)
deliver(t, dir, "2.0", base.Add(time.Hour))
if _, waiting, err := Successor(dir, "1.0", "2.0"); err != nil || waiting {
t.Fatalf("standing aside for a rolled-back version (%v, %v)", waiting, err)
}
deliver(t, dir, "3.0", base.Add(90*time.Minute))
if next, waiting, err := Successor(dir, "1.0", "2.0"); err != nil || !waiting || next.Version != "3.0" {
t.Fatalf("a newer version after a rollback is not stood aside for: %+v %v %v", next, waiting, err)
}
}
// Nothing newer than the running version is retired: a successor waiting, or one rolled back that the
// mesh still declares — either would only be delivered again.
func TestRetireNeverRemovesANewerVersion(t *testing.T) {
dir := t.TempDir()
base := time.Now().Add(-4 * time.Hour)
for i, v := range []string{"one", "two", "three", "four"} {
deliver(t, dir, v, base.Add(time.Duration(i)*time.Hour))
}
removed, err := Retire(dir, "two")
if err != nil {
t.Fatal(err)
}
if len(removed) != 0 {
t.Fatalf("retired %v while running two: one is its predecessor, three and four are newer", removed)
}
}
// Proved: known-good, the counter cleared, older versions retired, and the launcher's verdicts ended —
// unless this is the version a rollback went back to, about which they still stand.
func TestProvingAHostEndsTheLaunchersVerdictsOnlyWhenItIsNewer(t *testing.T) {
state := filepath.Join(t.TempDir(), "state.json")
dir := t.TempDir()
base := time.Now().Add(-4 * time.Hour)
for i, v := range []string{"0.9", "1.0", "2.0", "3.0"} {
deliver(t, dir, v, base.Add(time.Duration(i)*time.Hour))
}
record := "2.0\t1.0\t1759744800\trolled-back\tit failed 3 times in a row\n"
if err := os.WriteFile(RolledBackPath(state), []byte(record), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(AttemptsPath(state), []byte("2\n"), 0o644); err != nil {
t.Fatal(err)
}
// The version gone back to reports: it is known-good, and the rollback still stands.
if _, err := Proved(state, dir, "1.0"); err != nil {
t.Fatal(err)
}
if v, _ := ReadRolledBack(RolledBackPath(state)); len(v) != 1 {
t.Fatal("proving the version a rollback went back to ended the rollback")
}
if got, _ := ReadKnownGood(KnownGoodPath(state)); got != "1.0" {
t.Fatalf("known-good is %q", got)
}
if raw, _ := os.ReadFile(AttemptsPath(state)); strings.TrimSpace(string(raw)) != "0" {
t.Fatalf("the start counter was not cleared: %q", raw)
}
// A newer one reports: the rollback ends, and what is older than its predecessor goes.
retired, err := Proved(state, dir, "3.0")
if err != nil {
t.Fatal(err)
}
if v, _ := ReadRolledBack(RolledBackPath(state)); len(v) != 0 {
t.Fatalf("a newer host proved itself and the old rollback still stands: %+v", v)
}
if !reflect.DeepEqual(retired, []string{"0.9", "1.0"}) {
t.Fatalf("retired %v, want 0.9 and 1.0 — 2.0 is 3.0's predecessor", retired)
}
}
// A line the launcher's record cannot be read by is named, never guessed at.
func TestAnUnreadableRecordLineIsNamed(t *testing.T) {
path := filepath.Join(t.TempDir(), RolledBackName)
if err := os.WriteFile(path, []byte("2.0\t1.0\t1759744800\trolled-back\twhy\ngarbage\n"), 0o644); err != nil {
t.Fatal(err)
}
verdicts, err := ReadRolledBack(path)
if len(verdicts) != 1 || err == nil || !strings.Contains(err.Error(), "line(s) 2") {
t.Fatalf("read %+v, %v", verdicts, err)
}
}