Files
mesh-host/internal/link/nodehealth.go
T
jschoubben 65fbc1e6f8 Write each node's health report into its own key of mesh-host_health on a change, so the watchman raises the machine's alerts without the controller (issue 774, the operator's alert table)
Until now the six machine alerts came from a health message passing to the controller. The report is now kept in the
engine's declared state `health`, key `<node>`, created by the grants part as `acts` is. Only this node's engine
writes it, after comparing it on every look and only when what it says changed; it is read first after a restart.

It carries:
- the network parts (machine-network; network-rewritten through the resolver file part's writer and owner);
- the tunnel's peers, each seen within five minutes or not (network-unreachable);
- the failed units no module owns (machine-units);
- the accounts marked relogin needed (relogin-needed);
- the directories used as found (directory-used-as-found).

The streaks and the time of the look are left out, so a look alone writes nothing. A peer is reported seen or not,
never its handshake time, so a renewed handshake writes nothing.

The layout is internal/link's (NodeHealth) until it moves into mesh-sdk. Pins mesh-sdk 58a8324.

Tests:
- rows in TestOnTheHubAnyFreshPeerIsAHealthyTunnel: the peers, and a change only when one is seen or lost;
- a case in TestAHealthStatementIsSaidOnTheLinkOpenNow: what the report takes, and one write per change across a
  restart;
- the manifest's states in TestTheManifestConsumesTheBackupEvent.
2026-10-29 22:56:14 +01:00

229 lines
8.7 KiB
Go

package link
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/nats-io/nats.go"
)
// The node-engine's own health report in the bus's own storage (issue 774, the operator's alert table, 2026-10-29): one
// key per node, `<node>`, of the engine's declared state `health` (HealthBucket, created by the grants part from the
// engine's manifest as its state `acts` is), written by this node's engine alone and only when what it says changed.
// The watchman (seat mesh-watchdogs) reads it for six alerts the controller raised from the passing health message:
//
// - machine-network: Network, the machine's route, tunnel, name servers, resolver file and bus, each a part;
// - network-rewritten: the resolver file's part, unhealthy with Writer, the program that rewrote it, and Owner, the
// module whose file it is;
// - network-unreachable: Peers, each peer of the mesh's tunnel and whether this machine saw it within five minutes,
// read across every node's report;
// - machine-units: Units, the failed system units no module owns;
// - relogin-needed: Relogin, each account whose running session lacks a group it was given;
// - directory-used-as-found: Found, each directory a module declares that the engine uses as found.
//
// The layout moves into mesh-sdk before the push (the lead, 2026-10-29); until then it is this package's.
// HealthBucket is the node-engine's declared state health: key `<node>`, value NodeHealth.
const HealthBucket = "mesh-host_health"
// NodeHealthVersion is the version of NodeHealth's layout.
const NodeHealthVersion = 1
// NodeHealth is one node's health report, as its engine last found it.
type NodeHealth struct {
V int `json:"v"`
Node string `json:"node"`
// Network is the machine's networking, its parts as Health.Network says them, without the streaks; nil where this
// engine does not judge it yet.
Network *NetworkHealth `json:"network,omitempty"`
// Peers are the mesh tunnel's peers on the last look that read them, sorted by key.
Peers []PeerSeen `json:"peers,omitempty"`
// Units is the machine's service managers: each failed unit no module places (Health.Units).
Units *UnitsHealth `json:"units,omitempty"`
// Relogin are the accounts whose running session needs a new login for a group it was given.
Relogin []NodeAccount `json:"relogin,omitempty"`
// Found are the directories used as found.
Found []NodeDirectory `json:"found,omitempty"`
// At is when the engine wrote this, on a change: never a reason to write.
At time.Time `json:"at"`
}
// PeerSeen is one peer of the mesh's tunnel: its public key, its allowed addresses, and whether this machine saw a
// handshake with it within five minutes.
type PeerSeen struct {
Key string `json:"key"`
Addresses []string `json:"addresses,omitempty"`
Seen bool `json:"seen"`
}
// NodeAccount is one account marked relogin needed, with the module whose resource it is and why.
type NodeAccount struct {
Account string `json:"account"`
Module string `json:"module,omitempty"`
Resource string `json:"resource,omitempty"`
Reason string `json:"reason"`
Since time.Time `json:"since"`
}
// NodeDirectory is one directory used as found: the module declaring it, its resource and path, and why.
type NodeDirectory struct {
Module string `json:"module"`
Resource string `json:"resource"`
Path string `json:"path"`
Reason string `json:"reason"`
Since time.Time `json:"since"`
}
// ReasonReloginNeeded starts an account's reason when its session needs a new login (accounts.ReasonRelogin).
const ReasonReloginNeeded = "relogin needed"
// NodeHealthOf is the report a health statement and the tunnel's peers make: the statement's network without its
// streaks, its machine units, and each account and directory of its resources that the six alerts read.
func NodeHealthOf(node string, h Health, peers []PeerSeen) NodeHealth {
out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units}
if len(peers) > 0 {
out.Peers = append([]PeerSeen(nil), peers...)
sort.Slice(out.Peers, func(i, k int) bool { return out.Peers[i].Key < out.Peers[k].Key })
}
if h.Network != nil {
n := NetworkHealth{State: h.Network.State, Since: h.Network.Since, Parts: make([]NetworkPart, 0, len(h.Network.Parts))}
for _, p := range h.Network.Parts {
p.Streak = 0 // a look's count moves every look; the report says a part's state, not how often it was seen
n.Parts = append(n.Parts, p)
}
out.Network = &n
}
for _, r := range h.Resources {
switch {
case r.Kind == KindAccount && r.State == StateUnhealthy && strings.HasPrefix(r.Reason, ReasonReloginNeeded):
out.Relogin = append(out.Relogin, NodeAccount{Account: r.Account, Module: r.Module, Resource: r.Resource,
Reason: r.Reason, Since: r.Since})
case r.Kind == KindDirectory && strings.HasPrefix(r.Reason, ReasonUsedAsFound):
out.Found = append(out.Found, NodeDirectory{Module: r.Module, Resource: r.Resource, Path: r.Target,
Reason: r.Reason, Since: r.Since})
}
}
sort.Slice(out.Relogin, func(i, k int) bool {
return out.Relogin[i].Account+out.Relogin[i].Module < out.Relogin[k].Account+out.Relogin[k].Module
})
sort.Slice(out.Found, func(i, k int) bool { return out.Found[i].Path < out.Found[k].Path })
return out
}
// EncodeNodeHealth is h as JSON at its version.
func EncodeNodeHealth(h NodeHealth) ([]byte, error) {
h.V = NodeHealthVersion
if h.Node == "" {
return nil, errors.New("a node's health report names its node")
}
return json.Marshal(h)
}
// DecodeNodeHealth reads a report.
func DecodeNodeHealth(raw []byte) (NodeHealth, error) {
var h NodeHealth
if err := json.Unmarshal(raw, &h); err != nil {
return NodeHealth{}, fmt.Errorf("a node's health report is not readable: %w", err)
}
if h.V != NodeHealthVersion {
return NodeHealth{}, fmt.Errorf("a node's health report of version %d, and this reads %d", h.V, NodeHealthVersion)
}
return h, nil
}
// SameNodeHealth says two reports say the same, whenever each was written.
func SameNodeHealth(a, b NodeHealth) bool {
a.At, b.At = time.Time{}, time.Time{}
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
return errA == nil && errB == nil && bytes.Equal(ra, rb)
}
// HealthStore is where a node's report is kept: its own key of HealthBucket.
type HealthStore interface {
// Get is the key's value, nil when it has none.
Get(ctx context.Context, key string) ([]byte, error)
Put(ctx context.Context, key string, value []byte) error
}
// healthStore binds HealthBucket over the link open now: bound, never created.
func (l *natsLink) healthStore() (HealthStore, error) {
kv, err := l.js.KeyValue(HealthBucket)
if err != nil {
return nil, fmt.Errorf("the node-engine's state %s cannot be bound (it is created from the node-engine's "+
"manifest; written again once it is): %w", HealthBucket, err)
}
return natsHealth{kv}, nil
}
type natsHealth struct{ kv nats.KeyValue }
func (n natsHealth) Get(_ context.Context, key string) ([]byte, error) {
e, err := n.kv.Get(key)
if errors.Is(err, nats.ErrKeyNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return e.Value(), nil
}
func (n natsHealth) Put(_ context.Context, key string, value []byte) error {
_, err := n.kv.Put(key, value)
return err
}
// WriteNodeHealth writes this node's report to its key when it says something other than the key holds, and says
// whether it wrote. The key is read before the first write of a run, so a restart that finds the same report writes
// nothing; a store that cannot be bound or written is an error, and the next look tries again.
func (q *Session) WriteNodeHealth(ctx context.Context, h NodeHealth) (bool, error) {
q.init()
q.mu.Lock()
bus := q.bus
q.mu.Unlock()
l, ok := bus.(interface{ healthStore() (HealthStore, error) })
if bus == nil || !ok {
return false, ErrNoLink
}
store, err := l.healthStore()
if err != nil {
return false, err
}
return writeNodeHealth(ctx, store, &q.healthWritten, h, time.Now)
}
// writeNodeHealth is WriteNodeHealth over a store: last is the report last written or read, nil before the first.
func writeNodeHealth(ctx context.Context, store HealthStore, last **NodeHealth, h NodeHealth, now func() time.Time) (bool, error) {
if *last == nil {
raw, err := store.Get(ctx, h.Node)
if err != nil {
return false, fmt.Errorf("the health report of %s cannot be read: %w", h.Node, err)
}
if raw != nil {
if was, err := DecodeNodeHealth(raw); err == nil {
*last = &was
}
}
}
if *last != nil && SameNodeHealth(**last, h) {
return false, nil
}
h.At = now().UTC()
raw, err := EncodeNodeHealth(h)
if err != nil {
return false, err
}
if err := store.Put(ctx, h.Node, raw); err != nil {
return false, fmt.Errorf("the health report of %s cannot be written: %w", h.Node, err)
}
*last = &h
return true, nil
}