The mesh kept one report per machine, replaced, so a resource nothing can ever apply looked like a failure that had just happened, every reconcile interval, for ever. The row now keeps when the current failure began and how many reports in a row have said it — the same outcome, refusal and failed resources; anything different starts again and a clean apply clears it. Three make the machine stuck, and status says so beside the failure, in words and in JSON (novox/hq 04-ISSUES/065, ADR 0090).
170 lines
6.3 KiB
Go
170 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
)
|
|
|
|
// The shape something other than a person reads.
|
|
//
|
|
// Hard to change once anything is built against it, so it stays close to what the domain already
|
|
// calls things and carries no summary field that would have to be kept true.
|
|
|
|
func statusOf(t *testing.T, wrong []inventory.Doing, nodes, quiet []inventory.Node,
|
|
behind map[string][]string, sources map[string]inventory.Source) map[string]any {
|
|
t.Helper()
|
|
body, err := statusAsJSON(answers{
|
|
wrong: wrong, nodes: nodes, quiet: quiet, behind: behind, sources: sources})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
t.Fatalf("what a board would read is not JSON: %v", err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func TestRefusedAndFailedStayDistinctAllTheWayOut(t *testing.T) {
|
|
// They are fixed in different places, so one word for both would send half the readers of a
|
|
// page to the wrong one.
|
|
got := statusOf(t,
|
|
[]inventory.Doing{
|
|
{Node: "one", Outcome: inventory.OutcomeRefused, Refused: "a file needs a path"},
|
|
{Node: "two", Outcome: inventory.OutcomeFailed, Applied: 3,
|
|
Failed: []inventory.FailedResource{{ID: "shell.pkg", Error: "target not found"}}},
|
|
},
|
|
[]inventory.Node{{Name: "one"}, {Name: "two"}}, nil, nil, nil)
|
|
|
|
wrong, _ := got["wrong"].([]any)
|
|
if len(wrong) != 2 {
|
|
t.Fatalf("got %v", got["wrong"])
|
|
}
|
|
first, _ := wrong[0].(map[string]any)
|
|
if first["outcome"] != inventory.OutcomeRefused || first["refused"] == nil {
|
|
t.Fatalf("a refusal did not survive: %v", first)
|
|
}
|
|
second, _ := wrong[1].(map[string]any)
|
|
if second["outcome"] != inventory.OutcomeFailed {
|
|
t.Fatalf("a failure did not survive: %v", second)
|
|
}
|
|
if second["applied"] != float64(3) {
|
|
// "Three of eight" and "none of eight" are different machines.
|
|
t.Fatalf("what did apply was not carried: %v", second)
|
|
}
|
|
}
|
|
|
|
func TestAMachineThatNeverSpokeSaysSoByOmission(t *testing.T) {
|
|
// Never heard from and quiet for a while are different situations, and a zero time would read
|
|
// as a date in 1970 on any page that formatted it.
|
|
got := statusOf(t, nil,
|
|
[]inventory.Node{{Name: "silent"}, {Name: "away", LastSeen: time.Now().Add(-3 * time.Hour)}},
|
|
[]inventory.Node{{Name: "silent"}, {Name: "away", LastSeen: time.Now().Add(-3 * time.Hour)}},
|
|
nil, nil)
|
|
|
|
quiet, _ := got["quiet"].([]any)
|
|
if len(quiet) != 2 {
|
|
t.Fatalf("got %v", got["quiet"])
|
|
}
|
|
never, _ := quiet[0].(map[string]any)
|
|
if _, said := never["lastSeen"]; said {
|
|
t.Fatalf("a machine that never spoke carries a time: %v", never)
|
|
}
|
|
away, _ := quiet[1].(map[string]any)
|
|
if _, said := away["lastSeen"]; !said {
|
|
t.Fatalf("a machine that has been quiet carries no time: %v", away)
|
|
}
|
|
}
|
|
|
|
func TestNothingWrongIsAnEmptyListRatherThanNothing(t *testing.T) {
|
|
// A page distinguishing "no machines are wrong" from "this field is missing" would have to
|
|
// handle both, and null is the one that gets forgotten.
|
|
got := statusOf(t, nil, []inventory.Node{{Name: "a", LastSeen: time.Now()}}, nil, nil, nil)
|
|
for _, key := range []string{"wrong", "quiet", "behind"} {
|
|
list, ok := got[key].([]any)
|
|
if !ok {
|
|
t.Fatalf("%q is %T, not a list", key, got[key])
|
|
}
|
|
if len(list) != 0 {
|
|
t.Fatalf("%q is not empty: %v", key, list)
|
|
}
|
|
}
|
|
// And how many machines there are, so a reader can tell "none wrong" from "none at all".
|
|
if got["machines"] != float64(1) {
|
|
t.Fatalf("got %v", got["machines"])
|
|
}
|
|
}
|
|
|
|
func TestWhatIsBehindNamesTheMachinesRunningTheOldOne(t *testing.T) {
|
|
// A module being out of date is a fact about the catalogue; machines running the old one is
|
|
// the thing with consequences.
|
|
got := statusOf(t, nil, nil, nil,
|
|
map[string][]string{"shell": {"workstation", "laptop"}},
|
|
map[string]inventory.Source{"shell": {BuiltFrom: "aaaaaaa1", Head: "bbbbbbb2"}})
|
|
|
|
behind, _ := got["behind"].([]any)
|
|
if len(behind) != 1 {
|
|
t.Fatalf("got %v", got["behind"])
|
|
}
|
|
row, _ := behind[0].(map[string]any)
|
|
if row["module"] != "shell" || row["builtFrom"] != "aaaaaaa1" || row["head"] != "bbbbbbb2" {
|
|
t.Fatalf("got %v", row)
|
|
}
|
|
on, _ := row["on"].([]any)
|
|
if len(on) != 2 {
|
|
t.Fatalf("the machines running the old one are not named: %v", row)
|
|
}
|
|
}
|
|
|
|
func TestNoSecretIsInWhatABoardReads(t *testing.T) {
|
|
// Everything here comes from the mesh's own records, which hold no readable secret — but a
|
|
// shape a page is built against is exactly where one would eventually be added for
|
|
// convenience, so this says it out loud.
|
|
body, err := statusAsJSON(answers{
|
|
wrong: []inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused,
|
|
Refused: "resource \"x\": a file needs a path"}},
|
|
nodes: []inventory.Node{{Name: "a"}},
|
|
refused: map[string]string{"a": "nothing provides \"database\", wanted by web"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, word := range []string{"password", "secret", "sealed", "credential", "token"} {
|
|
if strings.Contains(strings.ToLower(string(body)), word) {
|
|
t.Fatalf("what a board reads carries a %q field:\n%s", word, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A failure that has been reported identically enough times is said to be stuck, with when it
|
|
// began and how many times — so a board can tell a machine looping on something that will never
|
|
// apply from one that failed a minute ago (novox/hq 04-ISSUES/065, ADR 0090).
|
|
func TestAMachineFailingTheSameWayIsSaidToBeStuck(t *testing.T) {
|
|
began := time.Date(2026, 9, 21, 9, 0, 0, 0, time.UTC)
|
|
got := statusOf(t,
|
|
[]inventory.Doing{
|
|
{Node: "looping", Outcome: inventory.OutcomeFailed, Since: &began, Times: inventory.StuckAfter,
|
|
Failed: []inventory.FailedResource{{ID: "img", Error: "no such image"}}},
|
|
{Node: "once", Outcome: inventory.OutcomeFailed, Since: &began, Times: 1,
|
|
Failed: []inventory.FailedResource{{ID: "img", Error: "no such image"}}},
|
|
},
|
|
[]inventory.Node{{Name: "looping"}, {Name: "once"}}, nil, nil, nil)
|
|
|
|
wrong, _ := got["wrong"].([]any)
|
|
looping, _ := wrong[0].(map[string]any)
|
|
if looping["stuck"] != true || looping["times"] != float64(inventory.StuckAfter) {
|
|
t.Fatalf("three identical failures are stuck: %v", looping)
|
|
}
|
|
if since, _ := looping["since"].(string); !strings.HasPrefix(since, "2026-09-21T09:00:00") {
|
|
t.Fatalf("when it began was not carried: %v", looping)
|
|
}
|
|
once, _ := wrong[1].(map[string]any)
|
|
if once["stuck"] != false || once["times"] != float64(1) {
|
|
t.Fatalf("one failure is not stuck: %v", once)
|
|
}
|
|
}
|