Files
mesh-host/internal/link/health_test.go
T
jschoubben e5e8ff5783 Say each account's root verdict and the engine's contract in the node's health report, so the self-check's DA and D-root judge without the controller (issue 1952, ADR 0266)
Every account resource marked root never goes into NodeHealth.Root; the statement's contract into Contract. Relogin,
root and found are judged each on its own, so an account may be both. Pins mesh-sdk f3677eb. Test: the report's row in
TestAHealthStatementIsSaidOnTheLinkOpenNow carries the agent account's verdict and the contract.
2026-10-29 23:49:36 +01:00

217 lines
10 KiB
Go

package link
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A health statement is said on the link open now, as the machine's own word on its own subject, and
// not said — for the caller to say again — while no link is open (novox/hq ADR 0240, to-be 48 §4).
// sayingLink is a link that can say health.
type sayingLink struct {
*quietLink
said [][]byte
}
func (l *sayingLink) Health(_ context.Context, node string, body []byte) error {
l.said = append(l.said, body)
return nil
}
func TestAHealthStatementIsSaidOnTheLinkOpenNow(t *testing.T) {
q := &Session{Membership: Membership{Node: "anchor"}}
h := Health{Contract: LivenessContract, At: time.Now().UTC(),
Resources: []ResourceHealth{{Module: "letta", Resource: "letta.server", State: StateUnhealthy, Reason: ReasonRestarting}}}
if q.SayHealth(t.Context(), h) {
t.Fatal("said with no link open")
}
l := &sayingLink{quietLink: newQuietLink()}
q.Attach(t.Context(), l)
if !q.SayHealth(t.Context(), h) || len(l.said) != 1 {
t.Fatalf("not said on the open link: %d", len(l.said))
}
var got HealthSaid
if err := json.Unmarshal(l.said[0], &got); err != nil {
t.Fatal(err)
}
if got.Node != "anchor" || len(got.Health.Resources) != 1 || got.Health.Resources[0].State != StateUnhealthy {
t.Fatalf("said %+v", got)
}
// A link that cannot say it is not an error: the next report carries the statement.
q.Detach(l)
q.Attach(t.Context(), newQuietLink())
if q.SayHealth(t.Context(), h) {
t.Fatal("said on a link that cannot")
}
if HealthSubject("anchor") != "mesh.control.anchor.health" {
t.Fatalf("the subject %s is not inside the host's own grant", HealthSubject("anchor"))
}
// Issue 774: the node's own health report in its key of mesh-host_health, for the watchman's six alerts: written on
// a change only, the streaks and the time of the look not being one, and read first after a restart.
t.Run("the node's health report is written on a change only", func(t *testing.T) {
since := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC)
look := func(streak int, writer string) Health {
return Health{Contract: 7, At: time.Now(), Network: &NetworkHealth{State: StateUnhealthy, Since: since, Parts: []NetworkPart{
{Part: "resolv-conf", State: StateUnhealthy, Writer: writer, Owner: "dnsmasq", Since: since, Streak: streak}}},
Units: &UnitsHealth{State: "degraded", Failed: []FailedUnit{{Unit: "cups.service", Scope: "system", Since: since}}},
Resources: []ResourceHealth{
{Module: "brother", Resource: "brother.scanner", Kind: KindAccount, Account: "jochen", State: StateUnhealthy,
Reason: "relogin needed: the session began before scanner", Since: since},
{Module: "photos", Resource: "photos.library", Kind: KindDirectory, Target: "/srv/photos", State: StateUnhealthy,
Reason: ReasonUsedAsFound + " owned by root", Since: since},
{Module: "letta", Resource: "letta.server", Kind: "container", State: StateHealthy, Since: since},
{Module: "claude-code", Resource: "claude-code.agent", Kind: KindAccount, Account: "agent", Root: "never",
State: StateHealthy, Since: since}}}
}
peers := []PeerSeen{{Key: "B=", Seen: true}, {Key: "A=", Addresses: []string{"10.10.0.2/32"}}}
r := NodeHealthOf("anchor", look(1, "NetworkManager"), peers, Machines{})
if r.Network.Parts[0].Writer != "NetworkManager" || r.Network.Parts[0].Streak != 0 || r.Peers[0].Key != "A=" ||
len(r.Units.Failed) != 1 || len(r.Relogin) != 1 || r.Relogin[0].Account != "jochen" ||
len(r.Found) != 1 || r.Found[0].Path != "/srv/photos" ||
len(r.Root) != 1 || r.Root[0].Account != "agent" || r.Root[0].State != StateHealthy || r.Contract != 7 {
t.Fatalf("the report is %+v", r)
}
// The machines an unhealthy part points at, as the controller holds it there; none for the machine's own.
machines := Machines{ByAddress: map[string]string{"10.10.0.1": "novox", "10.10.0.4": "ace", "10.10.0.9": "anchor"}, Hub: "novox"}
for _, tc := range []struct {
name string
toward []string
state string
want []string
}{
{"the tunnel to the hub", []string{"hub"}, StateUnhealthy, []string{"novox"}},
{"two mesh resolvers", []string{"10.10.0.1", "10.10.0.4"}, StateUnhealthy, []string{"novox", "ace"}},
{"a resolver a VPN client wrote in is the machine's own", []string{"10.10.0.1", "192.0.2.53"}, StateUnhealthy, nil},
{"its own resolver is its own", []string{"10.10.0.9"}, StateUnhealthy, nil},
{"a healthy part points at nothing", []string{"hub"}, StateHealthy, nil},
} {
h := Health{Network: &NetworkHealth{State: tc.state, Parts: []NetworkPart{{Part: "names", State: tc.state, Toward: tc.toward}}}}
got := NodeHealthOf("anchor", h, nil, machines).Network.Parts[0].Targets
if !slices.Equal(got, tc.want) {
t.Errorf("%s: targets %v, want %v", tc.name, got, tc.want)
}
}
store := &memHealth{}
var last *NodeHealth
clock := func() time.Time { return since }
for i, step := range []struct {
h NodeHealth
fresh bool // a restart: nothing written in this run yet
wrote bool
}{
{h: r, wrote: true},
{h: NodeHealthOf("anchor", look(2, "NetworkManager"), peers, Machines{})},
{h: NodeHealthOf("anchor", look(3, "NetworkManager"), peers, Machines{}), fresh: true},
{h: NodeHealthOf("anchor", look(3, "openvpn"), peers, Machines{}), wrote: true},
{h: NodeHealthOf("anchor", look(3, "openvpn"), []PeerSeen{{Key: "A=", Seen: true}}, Machines{}), wrote: true},
} {
if step.fresh {
last = nil
}
wrote, err := writeNodeHealth(t.Context(), store, &last, step.h, clock)
if err != nil || wrote != step.wrote {
t.Fatalf("step %d: wrote %v (want %v), %v", i, wrote, step.wrote, err)
}
}
got, err := delivery.DecodeNodeHealth(store.values["anchor"])
if err != nil || got.Node != "anchor" || got.Network.Parts[0].Writer != "openvpn" || !got.Peers[0].Seen || got.At.IsZero() {
t.Fatalf("the key holds %+v, %v", got, err)
}
// The key's bytes are mesh-sdk's layout: decoded and encoded again by it, the same bytes.
if again, err := delivery.EncodeNodeHealth(got); err != nil || string(again) != string(store.values["anchor"]) {
t.Fatalf("the key's bytes are not the sdk's layout: %s\n%s (%v)", store.values["anchor"], again, err)
}
if store.puts != 3 {
t.Fatalf("%d writes, want 3: one per change", store.puts)
}
})
}
// memHealth is a HealthStore in memory.
type memHealth struct {
values map[string][]byte
puts int
}
func (m *memHealth) Get(_ context.Context, key string) ([]byte, error) { return m.values[key], nil }
func (m *memHealth) Put(_ context.Context, key string, value []byte) error {
if m.values == nil {
m.values = map[string][]byte{}
}
m.values[key], m.puts = value, m.puts+1
return nil
}
// A declared tool check reads the node tools' reply envelope: healthy only when the tool says so (novox/hq
// ADR 0240, to-be 48 §2), and asked on this machine's own instance (to-be 48 §3).
func TestAToolsAnswerIsHealthyOnlyWhenItSaysSo(t *testing.T) {
for reply, want := range map[string]bool{
`{"result":{"healthy":true},"node":"anchor"}`: true,
`{"result":{"healthy":false,"why":"the admin refused the secret"}}`: false,
`{"result":{"status":"fine"}}`: false,
`{"error":"503 no responders"}`: false,
`{"result":"yes"}`: false,
} {
got, err := ReadToolAnswer([]byte(reply))
if err != nil || got.Healthy != want {
t.Errorf("%s read as %v (%v)", reply, got, err)
}
}
if ToolSubject("keycloak", "keycloak_admin_health", "anchor") != "mesh.mod.keycloak.tool.keycloak_admin_health.anchor" {
t.Errorf("asked on %s", ToolSubject("keycloak", "keycloak_admin_health", "anchor"))
}
}
// A declared tool check is asked on the link the node-engine actually holds — the NATS link — and not
// refused as if none were open (2026-10-08: every tool check read "no link to the bus is open" while
// the link was up, because the NATS link could not Ask). With no link open, and with a link that cannot
// ask, the check fails and says which.
func TestAToolCheckIsAskedOnTheLinkTheEngineHolds(t *testing.T) {
q := &Session{Membership: Membership{Node: "anchor"}}
if _, err := q.AskTool(t.Context(), "sensors", "sensors_health"); err == nil || err.Error() != "no link to the bus is open" {
t.Fatalf("with no link open: %v", err)
}
quiet := newQuietLink()
q.Attach(t.Context(), quiet)
if _, err := q.AskTool(t.Context(), "sensors", "sensors_health"); err == nil || err.Error() != "the link to the bus open now cannot ask a tool" {
t.Fatalf("on a link that cannot ask: %v", err)
}
q.Detach(quiet)
// The link Open returns, with no connection under it: the question must reach its Ask (and fail
// there), never stop at the queue's assertion.
held := &natsLink{node: "anchor"}
q.Attach(t.Context(), held)
_, err := q.AskTool(t.Context(), "sensors", "sensors_health")
if err == nil {
t.Fatal("asked on no connection and was answered")
}
if strings.Contains(err.Error(), "no link") || strings.Contains(err.Error(), "cannot ask a tool") {
t.Fatalf("the NATS link was not asked: %v", err)
}
}
// A tool may answer that it waits for the operator (novox/hq ADR 0283): read with what it waits for, and not
// healthy, so nothing reading `healthy` alone takes it for a pass.
func TestAToolsWaitIsReadWithWhatItWaitsFor(t *testing.T) {
got, err := ReadToolAnswer([]byte(`{"result":{"state":"waits","healthy":false,"why":"the source games waits",` +
`"waits":[{"part":"the source games","secret":"smb-password-games","what":"the password of the source games"}]}}`))
if err != nil || got.Healthy || got.State != "waits" || len(got.Waits) != 1 || got.Waits[0].Secret != "smb-password-games" {
t.Fatalf("read as %+v (%v)", got, err)
}
h := ResourceHealth{Module: "mounts", Resource: "mounts.watch", State: StateWaiting, Waits: got.Waits}
b, _ := json.Marshal(h)
if !strings.Contains(string(b), `"state":"waiting"`) || !strings.Contains(string(b), `"waits":[{"part":"the source games","secret":"smb-password-games"`) {
t.Fatalf("said on the wire as %s", b)
}
}