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