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.
209 lines
7.9 KiB
Go
209 lines
7.9 KiB
Go
package link
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
"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 report's layout is mesh-sdk's (delivery.NodeHealth): the bucket, its version and its parts.
|
|
const (
|
|
HealthBucket = delivery.NodeHealthBucket
|
|
NodeHealthVersion = delivery.NodeHealthVersion
|
|
)
|
|
|
|
type (
|
|
NodeHealth = delivery.NodeHealth
|
|
PeerSeen = delivery.PeerSeen
|
|
NodeAccount = delivery.NodeAccount
|
|
NodeDirectory = delivery.NodeDirectory
|
|
)
|
|
|
|
// ReasonReloginNeeded starts an account's reason when its session needs a new login (accounts.ReasonRelogin).
|
|
const ReasonReloginNeeded = "relogin needed"
|
|
|
|
// Machines names the mesh's machines for a report's targets: each node by its address on the private network, and the
|
|
// hub's node. The zero value names none, and every part is then the machine's own.
|
|
type Machines struct {
|
|
ByAddress map[string]string
|
|
Hub string
|
|
}
|
|
|
|
// targets is the machines a part points at, as the controller holds it (mesh-controller machine_network.go): the hub's
|
|
// node for TowardHub, each resolver's node by its address; none when it points at nothing, or at this machine or an
|
|
// address no other mesh machine has, as one of them makes the part the machine's own.
|
|
func (m Machines) targets(node string, toward []string) []string {
|
|
if len(toward) == 0 {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, t := range toward {
|
|
x := m.ByAddress[t]
|
|
if t == TowardHub {
|
|
x = m.Hub
|
|
}
|
|
if x == "" || x == node {
|
|
return nil
|
|
}
|
|
if !slices.Contains(out, x) {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TowardHub is what a part failing toward the hub names in Toward (network.TowardHub).
|
|
const TowardHub = "hub"
|
|
|
|
// NodeHealthOf is the report a health statement and the tunnel's peers make: the statement's network without its
|
|
// streaks, each unhealthy part naming the machines it points at (machines), its machine units, and each account and
|
|
// directory of its resources that the six alerts read.
|
|
func NodeHealthOf(node string, h Health, peers []PeerSeen, machines Machines) NodeHealth {
|
|
out := NodeHealth{V: NodeHealthVersion, Node: node, Units: h.Units, Contract: h.Contract}
|
|
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
|
|
p.Targets = nil
|
|
if p.State == StateUnhealthy {
|
|
p.Targets = machines.targets(node, p.Toward)
|
|
}
|
|
n.Parts = append(n.Parts, p)
|
|
}
|
|
out.Network = &n
|
|
}
|
|
for _, r := range h.Resources {
|
|
// Each alert reads its own: an account may need a new login and be declared never to become root at once.
|
|
if 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})
|
|
}
|
|
if r.Kind == KindAccount && r.Root == declaration.RootNever {
|
|
// The account declared never to become root without a person, judged (ADR 0266): what DA and D-root read.
|
|
out.Root = append(out.Root, delivery.RootVerdict{Account: r.Account, Module: r.Module, Resource: r.Resource,
|
|
State: r.State, Reason: r.Reason, Since: r.Since})
|
|
}
|
|
if 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 })
|
|
sort.Slice(out.Root, func(i, k int) bool {
|
|
return out.Root[i].Module+out.Root[i].Resource < out.Root[k].Module+out.Root[k].Resource
|
|
})
|
|
return out
|
|
}
|
|
|
|
// 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 := delivery.DecodeNodeHealth(raw); err == nil {
|
|
*last = &was
|
|
}
|
|
}
|
|
}
|
|
if *last != nil && delivery.SameNodeHealth(**last, h) {
|
|
return false, nil
|
|
}
|
|
h.At = now().UTC()
|
|
raw, err := delivery.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
|
|
}
|