The have's failed phase (apply-failing), unhealthy health (module-unhealthy) and kept words (kept-as-it-runs), and each part of its health report (machine-network, network-rewritten, network-unreachable, machine-units, relogin-needed, directory-used-as-found, node-account-fallback), raised and cleared by the held writer where the engine knows the fact. Builds: design 47 G A-197 (ADR 0596, A-192, A-149) Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
105 lines
5.1 KiB
Go
105 lines
5.1 KiB
Go
package seatwait
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
|
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
|
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
|
)
|
|
|
|
// Design 47 G A-197: what only the node-engine knows of itself it raises as its own findings, under the kind words the watchman
|
|
// raised them under, and clears where it knows the fact no longer holds.
|
|
func TestTheNodeEngineRaisesWhatOnlyItKnows(t *testing.T) {
|
|
at := time.Date(2026, 11, 12, 8, 0, 0, 0, time.UTC)
|
|
type step struct {
|
|
do func(k *NodeKeeper, advance func(time.Duration))
|
|
want []string // "scope.id.kind" of what stands after it, sorted
|
|
}
|
|
failed := nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseFailed, Detail: "refused: the package manager (pacman) does not answer"}
|
|
sick := nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "port closed"}
|
|
report := func(h nodeengine.HealthByNodeValue) func(*NodeKeeper, func(time.Duration)) {
|
|
return func(k *NodeKeeper, _ func(time.Duration)) { k.Report(h, map[string]string{"10.0.0.2": "g14"}) }
|
|
}
|
|
for name, steps := range map[string][]step{
|
|
"a have failed past the apply interval is apply-failing, cleared by the clean apply": {
|
|
{func(k *NodeKeeper, _ func(time.Duration)) { k.Have("b", failed) }, nil},
|
|
{func(k *NodeKeeper, adv func(time.Duration)) { adv(ApplyEvery + time.Second); k.Have("b", failed) }, []string{"machine.ace.apply-failing"}},
|
|
{func(k *NodeKeeper, _ func(time.Duration)) {
|
|
k.Have("b", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning})
|
|
}, nil},
|
|
},
|
|
"a have unhealthy past a minute is module-unhealthy, cleared when it is healthy or removed": {
|
|
{func(k *NodeKeeper, _ func(time.Duration)) { k.Have("b", sick) }, nil},
|
|
{func(k *NodeKeeper, adv func(time.Duration)) { adv(time.Minute); k.Have("b", sick) }, []string{"module.b.ace.module-unhealthy"}},
|
|
{func(k *NodeKeeper, _ func(time.Duration)) {
|
|
k.Have("b", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRemoved})
|
|
}, nil},
|
|
},
|
|
"what a module keeps as it runs is kept-as-it-runs until its have says none": {
|
|
{func(k *NodeKeeper, _ func(time.Duration)) {
|
|
k.Have("b", nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Kept: []string{"tool-runner"}})
|
|
}, []string{"module.b.ace.kept-as-it-runs"}},
|
|
{func(k *NodeKeeper, _ func(time.Duration)) { k.HaveGone("b") }, nil},
|
|
},
|
|
"each part of the report is its own finding and each ends with the report": {
|
|
{report(nodeengine.HealthByNodeValue{
|
|
Network: &nodeengine.NetworkHealth{Since: at, Parts: []nodeengine.NetworkPart{
|
|
{Part: "bus", State: "unhealthy", Reason: "no bus", Since: at},
|
|
{Part: "tunnel", State: "unhealthy", Since: at, Targets: []string{"g14"}},
|
|
{Part: "resolv-conf", State: "unhealthy", Owner: "dnsmasq", Since: at}}},
|
|
Units: &nodeengine.UnitsHealth{Failed: []nodeengine.FailedUnit{{Unit: "x.service", Since: at}}},
|
|
Relogin: []nodeengine.NodeAccount{{Account: "u", Module: "m", Since: at}},
|
|
Found: []nodeengine.NodeDirectory{{Module: "m", Resource: "m.dir", Since: at}},
|
|
Fallback: &nodeengine.LoginFallback{Node: "ace", From: "new", At: at}}),
|
|
[]string{"machine.ace.dnsmasq.network-rewritten", "machine.ace.machine-network", "machine.ace.machine-units",
|
|
"machine.ace.node-account-fallback", "machine.g14.ace.network-unreachable", "module.m.ace.directory-used-as-found",
|
|
"module.m.ace.relogin-needed"}},
|
|
{report(nodeengine.HealthByNodeValue{}), nil},
|
|
},
|
|
"a tunnel peer not seen is the machine unreachable": {
|
|
{report(nodeengine.HealthByNodeValue{Peers: []nodeengine.PeerSeen{{Key: "k", Addresses: []string{"10.0.0.2/32"}}}}),
|
|
[]string{"machine.g14.ace.network-unreachable"}},
|
|
{report(nodeengine.HealthByNodeValue{Peers: []nodeengine.PeerSeen{{Key: "k", Addresses: []string{"10.0.0.2/32"}, Seen: true}}}), nil},
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
now := at
|
|
w := &fakeFindings{}
|
|
// Another node's finding and an unrelated kind stand in the state and are never touched.
|
|
w.held = []condseat.Finding{{Scope: "machine", ID: "g14", Kind: KindMachineUnits, Subject: condseat.Subject{Machine: "g14"}},
|
|
{Scope: "machine", ID: "ace", Kind: HeldKind, Subject: condseat.Subject{Machine: "ace"}}}
|
|
k := &NodeKeeper{Node: "ace", Observer: "mesh-host", Writer: w, Now: func() time.Time { return now }}
|
|
for i, s := range steps {
|
|
s.do(k, func(d time.Duration) { now = now.Add(d) })
|
|
var got []string
|
|
for _, f := range w.held {
|
|
if f.Observer == "mesh-host" {
|
|
got = append(got, f.Scope+"."+f.ID+"."+f.Kind)
|
|
}
|
|
}
|
|
if strings.Join(sorted(got), ",") != strings.Join(s.want, ",") {
|
|
t.Fatalf("step %d: stands %v, want %v", i, sorted(got), s.want)
|
|
}
|
|
}
|
|
if len(w.held) != 2 {
|
|
t.Fatalf("what is not the keeper's was touched: %+v", w.held)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func sorted(in []string) []string {
|
|
out := append([]string(nil), in...)
|
|
for i := range out {
|
|
for j := i + 1; j < len(out); j++ {
|
|
if out[j] < out[i] {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|