Move the self-check out of the controller into mesh-self-check, so its runs and findings no longer need the controller's lease (issue 1952)
The probes whose facts are on the bus run with their assertions unchanged (D6, D7, D15, DW, H-bus); the rest answer deferred, naming what they lack, never a pass. Findings go to its own bucket for the condition keeper; each run is kept a week and said as checked. The finding layout is a copy of mesh-sdk's S3 until that lands.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/mesh-self-check
|
||||
/cmd/mesh-self-check/mesh-self-check
|
||||
@@ -0,0 +1,33 @@
|
||||
# mesh-self-check
|
||||
|
||||
Holds the seat `mesh-self-check` (novox/hq ADR 0564 §6): the mesh's self-check, moved out of the controller. Go, one
|
||||
binary (`cmd/mesh-self-check`), served by a runtime of its own on its own bus account (`runs-as`), so that its account
|
||||
alone writes its buckets.
|
||||
|
||||
## What it does
|
||||
|
||||
Every five minutes (the first run a minute after it starts) it runs its registry of probes, each within thirty
|
||||
seconds. A probe is one live invariant of a design, with its assertion as the controller had it, reading its facts on
|
||||
the bus (the bus module's tools for the bus's own state). A probe whose facts have no home on the bus yet answers
|
||||
`deferred`, naming them: never a pass. A probe that cannot run is said in the verdict at once, and written as a finding
|
||||
of its own (`probe.<id>.failed`) when the next run cannot run it either.
|
||||
|
||||
What a probe finds is written as a finding, one key `<scope>.<id>.<kind>` per fault, only when what it says changes, and
|
||||
deleted when a run no longer sees it. The condition keeper (`mesh-conditions`) raises and clears conditions from them.
|
||||
A finding one look can be wrong about (a consumer behind after a burst) is written when two runs in a row see it.
|
||||
|
||||
Every run ends with the event `checked`, the body of the controller's `doctor-heartbeat`, which the watchman (S10) and
|
||||
mesh-watcher on another machine hear: a self-check that stops checking is said through a path that does not pass
|
||||
through it.
|
||||
|
||||
## What it keeps
|
||||
|
||||
- `mesh-self-check_findings`: what it finds wrong now.
|
||||
- `mesh-self-check_verdict`: the key `last`, the last run whole.
|
||||
- `mesh-self-check_verdicts`: every run (each probe's findings at most twenty), kept a week by the bucket's age.
|
||||
|
||||
## Its verbs and tools
|
||||
|
||||
- `mesh-self-check.doctor`: the last verdict; `run=true` runs now; `probe=<id>` one probe whole.
|
||||
- `mesh-self-check.probes`: the registry, with what each deferred probe lacks.
|
||||
- `self_check_findings`, `self_check_verdicts`: the reads of its buckets.
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
// A finding: what an observer writes for the condition keeper (novox/hq ADR 0564 §2). **A copy of the contract
|
||||
// mesh-sdk holds (issue 1952, S3)**: its layout is the controller's observation (mesh-controller
|
||||
// internal/conditions/condition.go), its key the controller's key rule, so a finding written here and the condition
|
||||
// the controller raised for the same fault share one key. When mesh-sdk's go/health carries the finding, this file
|
||||
// is replaced by it and nothing else changes.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The two severities a condition has (to-be 45 §2).
|
||||
const (
|
||||
Urgent = "urgent"
|
||||
Warning = "warning"
|
||||
)
|
||||
|
||||
// The scopes a key starts with, as the controller names them.
|
||||
const (
|
||||
ScopeBus = "bus"
|
||||
ScopeCore = "core"
|
||||
ScopeProbe = "probe"
|
||||
ScopeMesh = "mesh"
|
||||
)
|
||||
|
||||
// Subject is what a finding is about: its scope, its id within the scope, the machine it concerns.
|
||||
type Subject struct {
|
||||
Scope string `json:"scope"`
|
||||
ID string `json:"id"`
|
||||
Machine string `json:"machine,omitempty"`
|
||||
Also []string `json:"also,omitempty"`
|
||||
}
|
||||
|
||||
// Finding is one thing an observer sees wrong now, kept under its key in the observer's bucket `findings` and
|
||||
// deleted when it is no longer seen. Token and Confirm are the observer's own and never written.
|
||||
type Finding struct {
|
||||
Kind string `json:"kind"`
|
||||
Subject Subject `json:"subject"`
|
||||
Severity string `json:"severity"`
|
||||
// Summary is one line in the mesh's words; Said the evidence (an address or a name stays here, never in the
|
||||
// summary, ADR 0234 §6).
|
||||
Summary string `json:"summary"`
|
||||
Said string `json:"said,omitempty"`
|
||||
// Headline, Explanation, Resolved and Needs are what the operator reads (ADR 0253).
|
||||
Headline string `json:"headline"`
|
||||
Explanation string `json:"explanation"`
|
||||
Resolved string `json:"resolved"`
|
||||
Needs string `json:"needs,omitempty"`
|
||||
// Source is what found it: a probe's id, or the observer itself for its own blindness.
|
||||
Source string `json:"source"`
|
||||
// Observer is the module that wrote it.
|
||||
Observer string `json:"observer"`
|
||||
|
||||
Token string `json:"-"`
|
||||
Confirm bool `json:"-"`
|
||||
}
|
||||
|
||||
// Key is where the finding is kept: `<scope>.<id>.<token>`, the controller's key for the same condition.
|
||||
func (f Finding) Key() string {
|
||||
token := f.Token
|
||||
if token == "" {
|
||||
token = f.Kind
|
||||
}
|
||||
return Key(f.Subject.Scope, f.Subject.ID, token)
|
||||
}
|
||||
|
||||
// Says is what the finding says, as written: two findings that say the same are one write (ADR 0289 §1).
|
||||
func (f Finding) Says() string {
|
||||
raw, _ := json.Marshal(f)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
var unsafeKey = regexp.MustCompile(`[^A-Za-z0-9_=/-]`)
|
||||
|
||||
// Key composes a key from its parts, each token made safe for the bus, as the controller's conditions.Key does.
|
||||
func Key(scope, id, token string) string {
|
||||
var parts []string
|
||||
for _, p := range append(append([]string{scope}, strings.Split(id, ".")...), token) {
|
||||
p = unsafeKey.ReplaceAllString(strings.TrimSpace(p), "_")
|
||||
if p == "" {
|
||||
p = "_"
|
||||
}
|
||||
parts = append(parts, p)
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// worded gives a finding the operator's words registered for its kind, where it says none of its own.
|
||||
func worded(f Finding) Finding {
|
||||
w, ok := words[f.Kind]
|
||||
if !ok {
|
||||
return f
|
||||
}
|
||||
if f.Headline == "" {
|
||||
f.Headline = w.Headline
|
||||
}
|
||||
if f.Explanation == "" {
|
||||
f.Explanation = w.Explanation
|
||||
}
|
||||
if f.Resolved == "" {
|
||||
f.Resolved = w.Resolved
|
||||
}
|
||||
if f.Needs == "" {
|
||||
f.Needs = w.Needs
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
type plain struct{ Headline, Explanation, Resolved, Needs string }
|
||||
|
||||
// words are the controller's words for each kind the self-check raises (mesh-controller plain_words.go), the
|
||||
// controller's name for itself replaced by the self-check's where the words spoke of it.
|
||||
var words = map[string]plain{
|
||||
"consumer-lost": {Headline: "A listener on the bus is gone",
|
||||
Explanation: "One of the mesh's listeners is no longer on the bus; the next send sets it up again.",
|
||||
Resolved: "The listener is back"},
|
||||
"consumer-wrong": {Headline: "A listener on the bus is set up wrong",
|
||||
Explanation: "One of the mesh's listeners on the bus is missing or not as defined; the next send sets it up again.",
|
||||
Resolved: "The listener is set up right again"},
|
||||
"consumer-behind": {Headline: "Messages pile up for a listener",
|
||||
Explanation: "One of the mesh's listeners on the bus is far behind, so what it handles happens late. A healer " +
|
||||
"restarts it if it stays.",
|
||||
Resolved: "The listener caught up"},
|
||||
"stream-wrong": {Headline: "Part of the bus's storage is wrong",
|
||||
Needs: "check the machine the bus runs on; the details say what is missing.",
|
||||
Explanation: "A store the mesh keeps on the bus is missing or not as defined, so what it keeps there may be lost.",
|
||||
Resolved: "The bus's storage is right again"},
|
||||
"bus-reconnects": {Headline: "A client keeps losing the bus",
|
||||
Explanation: "One of the mesh's clients lost its connection to the bus again and again in the last hour. While " +
|
||||
"it reconnects, what it says and what it is asked waits.",
|
||||
Resolved: "Resolved: the client stays connected"},
|
||||
"core-unhealthy": {Headline: "The bus is not healthy",
|
||||
Needs: "check the machine the bus runs on; the details say what fails.",
|
||||
Explanation: "The bus, which carries every message in the mesh, is not healthy. Instructions, builds and tool " +
|
||||
"calls may fail until it recovers.",
|
||||
Resolved: "The bus is healthy again"},
|
||||
"watchdogs-silent": {Headline: "The mesh's watchdogs stopped",
|
||||
Needs: "restart the watchman if this stays, from the mesh MCP server.",
|
||||
Explanation: "The watchdogs, which notice when something expected does not happen, do not answer, so missed " +
|
||||
"signals are not noticed.",
|
||||
Resolved: "The watchdogs run again"},
|
||||
"probe-failed": {Headline: "A check of the mesh could not run",
|
||||
Explanation: "One of the mesh's own checks could not run, so what it watches is unknown for now. It runs again " +
|
||||
"every few minutes.",
|
||||
Resolved: "The check runs again"},
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// mesh-self-check: the holder of the seat mesh-self-check (novox/hq ADR 0564 §6). A Go bundle served by a runtime of
|
||||
// its own, on its own bus account (`runs-as`), so that its account alone writes its buckets: `findings` (what it finds
|
||||
// wrong now, which the condition keeper raises and clears conditions from), `verdict` (the last run) and `verdicts`
|
||||
// (every run, kept a week). It runs the probes every five minutes and says `checked` at the end of each run. stdout is
|
||||
// the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// runtimeStore is the bus, through the runtime that launched this bundle.
|
||||
type runtimeStore struct{}
|
||||
|
||||
func (runtimeStore) Get(state, key string) ([]byte, bool, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
|
||||
func (runtimeStore) Put(state, key string, value any) error {
|
||||
_, err := stdio.State(state).Put(key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (runtimeStore) Delete(state, key string) error { return stdio.State(state).Delete(key) }
|
||||
|
||||
func (runtimeStore) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
// NewChecker is the self-check over the runtime.
|
||||
func NewChecker() *Checker {
|
||||
return &Checker{Store: runtimeStore{}, Registry: Registry(), Now: time.Now, Logf: logf, Host: os.Getenv("MESH_NODE"),
|
||||
Ask: func(key string, body any) (json.RawMessage, error) { return stdio.Ask(key, body) },
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) }}
|
||||
}
|
||||
|
||||
func main() {
|
||||
c := NewChecker()
|
||||
go func() {
|
||||
<-stdio.Served()
|
||||
c.Keep(context.Background())
|
||||
}()
|
||||
if err := stdio.Serve("", Tools(c)); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
RunsAs string `json:"runs-as"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Emits []string `json:"emits"`
|
||||
Invokes []string `json:"invokes"`
|
||||
Reads []string `json:"reads"`
|
||||
State []json.RawMessage `json:"state"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// The manifest says what the code does: it claims the seat mesh-self-check once for the mesh with the verbs it serves
|
||||
// (ADR 0564 §1, §6), runs on its own account, says checked, asks only what its probes ask, keeps a week of verdicts,
|
||||
// and reads no bucket of the controller's. Every state it keeps has a tool that reads it, and every act it takes by
|
||||
// itself a tool that starts it by hand (ADR 0564 §12).
|
||||
func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Module != Module || m.RunsAs != Module {
|
||||
t.Errorf("module %q runs as %q", m.Module, m.RunsAs)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || m.Claims[0].Scope != "mesh" || !slices.Equal(m.Claims[0].Serves, Verbs) {
|
||||
t.Errorf("claims %+v, want %s at mesh scope serving %v", m.Claims, Seat, Verbs)
|
||||
}
|
||||
if !slices.Equal(m.Emits, []string{CheckedEvent}) {
|
||||
t.Errorf("emits %v", m.Emits)
|
||||
}
|
||||
if !slices.Equal(m.Invokes, []string{StreamsTool, ClosedTool, SignalsVerb}) {
|
||||
t.Errorf("invokes %v, want what the probes ask", m.Invokes)
|
||||
}
|
||||
if len(m.Reads) != 0 {
|
||||
t.Errorf("reads %v: the self-check reads its facts through the bus module's tools", m.Reads)
|
||||
}
|
||||
states := map[string]int{}
|
||||
for _, s := range m.State {
|
||||
var d struct {
|
||||
Name string `json:"name"`
|
||||
TTL int `json:"ttl-seconds"`
|
||||
}
|
||||
if json.Unmarshal(s, &d.Name) != nil {
|
||||
_ = json.Unmarshal(s, &d)
|
||||
}
|
||||
states[d.Name] = d.TTL
|
||||
}
|
||||
if states[VerdictsState] != 7*24*60*60 {
|
||||
t.Errorf("verdicts are kept %d s, want a week", states[VerdictsState])
|
||||
}
|
||||
// ADR 0564 §12: each state with the tool that reads it, each act with the tool that starts it.
|
||||
readBy := map[string]string{FindingsState: FindingsTool, VerdictState: DoctorTool, VerdictsState: VerdictsTool,
|
||||
"the run": DoctorTool + " run=true"}
|
||||
tools := map[string]bool{}
|
||||
for _, tool := range Tools(&Checker{}) {
|
||||
tools[tool.Name] = true
|
||||
}
|
||||
for state := range states {
|
||||
if _, ok := readBy[state]; !ok {
|
||||
t.Errorf("the state %s has no tool that reads it", state)
|
||||
}
|
||||
}
|
||||
for what, tool := range readBy {
|
||||
name, _, _ := cutSpace(tool)
|
||||
if !tools[name] {
|
||||
t.Errorf("%s is read or started by %s, which is not served", what, tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cutSpace(s string) (string, string, bool) {
|
||||
for i, r := range s {
|
||||
if r == ' ' {
|
||||
return s[:i], s[i+1:], true
|
||||
}
|
||||
}
|
||||
return s, "", false
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package main
|
||||
|
||||
// The registry (novox/hq ADR 0564 §6 and the probe table of its Context): the probes that move from the controller,
|
||||
// each with its assertion unchanged, reading its facts where they live on the bus. A probe whose facts have no home
|
||||
// on the bus yet answers deferred, naming them, and is never a pass (tracked by issue 1952). Not registered, as the
|
||||
// table says: D1 (nodes compose their own; a refusal is the watchman's S21), D10 (a want ahead of its have is the
|
||||
// watchman's pulse), D5, D9 and H-controller (they end with the controller), stuck (the watchman's own finding) and
|
||||
// the bus step (its home is issue 1953's).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
)
|
||||
|
||||
// The tools a probe asks: the bus module's reads of its own server, and the watchdogs' seat.
|
||||
const (
|
||||
StreamsTool = "nats.nats_streams"
|
||||
ClosedTool = "nats.nats_closed_connections"
|
||||
SignalsVerb = "seat:mesh-watchdogs.signals"
|
||||
notOnTheBus = "the bus module's tools (nats_streams) answer this only once asked; "
|
||||
storeOnly = "held only in the controller's store today"
|
||||
behindBound = 1000
|
||||
reconnectMax = 12
|
||||
)
|
||||
|
||||
// Registry is the self-check's registry, in to-be 45's order. **The registry is the design's live form**: a probe
|
||||
// that moves here is a row added, and the registry test holds it to ADR 0564's table.
|
||||
func Registry() []Probe {
|
||||
return []Probe{
|
||||
{ID: "D2", Asserts: "every holder of the mesh's resolver answers a machine name for IPv4, and NODATA for IPv6",
|
||||
From: "issue 262", Kind: "resolver-wrong",
|
||||
Deferred: "the machines' names and the resolver holders' addresses to ask: the overlay's addresses are " + storeOnly},
|
||||
{ID: "D3", Asserts: "every seat on record that serves verbs has a live holder that answers, on every machine that " +
|
||||
"is heard from", From: "issues 208, 218", Kind: "holder-silent",
|
||||
Deferred: "the seats on record with their holders per machine, and which machines are heard from: the seat " +
|
||||
"records are " + storeOnly + ", and the watchman's presence has no read granted to this module"},
|
||||
{ID: "D4", Asserts: "every kept archive is held by a manifest", From: "issue 253", Kind: "archives-unheld",
|
||||
Deferred: "the artifact store's kept archives with the manifests that hold them: the archive index is " + storeOnly},
|
||||
{ID: "D6", Asserts: "every durable consumer the mesh expects exists with its definition, and is near its " +
|
||||
"stream's head", From: "issues 248, 266", Kind: "consumer-wrong",
|
||||
Raises: []string{"consumer-lost", "consumer-behind"}, Run: probeConsumers},
|
||||
{ID: "D7", Asserts: "every stream the mesh defines exists with its definition", From: "issue 208",
|
||||
Kind: "stream-wrong", Run: probeStreams},
|
||||
{ID: "D8", Asserts: "no address the mesh owns — a machine's private address or its endpoint — is in a ban list",
|
||||
From: "issue 238", Kind: "own-address-banned",
|
||||
Deferred: "the machines' private addresses and endpoints: the overlay is " + storeOnly},
|
||||
{ID: "D11", Asserts: "no provider holds a consumer retired more than thirty days without a person deciding its " +
|
||||
"cleanup", From: "ADR 0230", Kind: "cleanup-waiting",
|
||||
Deferred: "the retirement records of providers' consumers: " + storeOnly},
|
||||
{ID: "D12", Asserts: "every consumer of a provision that keeps its data is bound where it was last sent, or moves " +
|
||||
"by a pin", From: "issue 273, ADR 0232", Kind: "binding-moved",
|
||||
Deferred: "the bindings of provisions to their consumers: " + storeOnly},
|
||||
{ID: "D13", Asserts: "every item of data a machine declares is measured, is there, holds what it held, and is " +
|
||||
"backed up within its bound", From: "issue 273, ADR 0233", Kind: "data-shrank",
|
||||
Deferred: "the data items' measurements and their history: " + storeOnly},
|
||||
{ID: "D15", Asserts: "no user of the bus had its connection dropped more than twelve times in the last hour: the " +
|
||||
"bus module's nats_closed_connections", From: "issue 327", Kind: "bus-reconnects", Run: probeReconnects},
|
||||
{ID: "DA", Asserts: "every machine that names an agent account has it judged, on its node-engine's newest " +
|
||||
"statement, unable to become root without a person", From: "ADR 0266, ADR 0259 §8",
|
||||
Kind: "agent-can-become-root",
|
||||
Deferred: "the node-engines' statements of the agent account's judgement: " + storeOnly},
|
||||
{ID: "D-root", Asserts: "no agent can become root without a person on a machine where the router or a channel " +
|
||||
"proving its sender runs", From: "ADR 0259 §8", Kind: "root-not-free",
|
||||
Deferred: "the node-engines' statements of root and the tools that run as root: " + storeOnly},
|
||||
{ID: "D-fill", Asserts: "every module's bucket and log holds less than three quarters of its cap; one said " +
|
||||
"filling is cleared once it holds less than seven tenths", From: "ADR 0297, issue 501",
|
||||
Kind: "bucket-or-log-filling", Raises: []string{"bucket-or-log-unread"},
|
||||
Deferred: "each bucket's and log's cap: the bus module's nats_buckets answers bytes and no cap, and the " +
|
||||
"declared caps are " + storeOnly},
|
||||
{ID: "D-heard", Asserts: "no grant of a restricted stream names a module that composes nothing for it",
|
||||
From: "ADR 0376 §2", Kind: "grant-composes-nothing",
|
||||
Deferred: "the grants of restricted streams with what each machine composed for them: " + storeOnly},
|
||||
{ID: "DW", Asserts: "the watchdogs answer: the watchman holds the seat mesh-watchdogs and answers signals",
|
||||
From: "ADR 0227 rule 6: the watchers are watched", Kind: "watchdogs-silent", Run: probeWatchdogs},
|
||||
{ID: "H-bus", Asserts: "every stream and durable consumer the mesh defines is on the bus, and a request crosses it " +
|
||||
"to the bus's machine and back", From: "ADR 0236, to-be 45 §8", Kind: "core-unhealthy", Run: probeBusHealth},
|
||||
}
|
||||
}
|
||||
|
||||
// --- the bus's own state, as its module answers it ---------------------------------------------------------------
|
||||
|
||||
// streamsAnswer is what nats_streams answers.
|
||||
type streamsAnswer struct {
|
||||
Detail []streamRow `json:"detail"`
|
||||
}
|
||||
|
||||
type streamRow struct {
|
||||
Name string `json:"name"`
|
||||
Subjects []string `json:"subjects"`
|
||||
Retention string `json:"retention"`
|
||||
MaxPerSubject int64 `json:"max_per_subject"`
|
||||
Consumers []consumerRow `json:"consumer_detail"`
|
||||
}
|
||||
|
||||
type consumerRow struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Filters []string `json:"filters"`
|
||||
Pending int64 `json:"pending"`
|
||||
Unacked int64 `json:"unacknowledged"`
|
||||
MaxDeliver int64 `json:"max_deliver"`
|
||||
}
|
||||
|
||||
// errNoStream is a stream the bus module says it does not hold.
|
||||
var errNoStream = errors.New("no such stream")
|
||||
|
||||
// streams asks the bus module for every stream, or for one with its consumers.
|
||||
func (c *Checker) streams(stream string) ([]streamRow, error) {
|
||||
args := map[string]any{}
|
||||
if stream != "" {
|
||||
args["stream"] = stream
|
||||
}
|
||||
raw, err := c.Ask(StreamsTool, args)
|
||||
if err != nil {
|
||||
if stream != "" && strings.Contains(err.Error(), "no stream called") {
|
||||
return nil, errNoStream
|
||||
}
|
||||
return nil, fmt.Errorf("the bus module did not answer %s: %w", StreamsTool, err)
|
||||
}
|
||||
var a streamsAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return nil, fmt.Errorf("the bus module answered %s with something unreadable: %w", StreamsTool, err)
|
||||
}
|
||||
return a.Detail, nil
|
||||
}
|
||||
|
||||
// consumerWords is a consumer as the mesh says it, with its name.
|
||||
func consumerWords(c buscompose.Consumer) string {
|
||||
return fmt.Sprintf("the consumer %s on %s", c.Name, c.Stream)
|
||||
}
|
||||
|
||||
// expectedConsumers are the durable consumers the mesh defines, by stream.
|
||||
func expectedConsumers() map[string][]buscompose.Consumer {
|
||||
out := map[string][]buscompose.Consumer{}
|
||||
for _, c := range buscompose.MeshConsumers() {
|
||||
out[c.Stream] = append(out[c.Stream], c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probeConsumers is D6: every durable consumer the mesh defines exists as defined, and is near its stream's head.
|
||||
func probeConsumers(ctx context.Context, c *Checker) ([]Finding, error) {
|
||||
var out []Finding
|
||||
byStream := expectedConsumers()
|
||||
streams := make([]string, 0, len(byStream))
|
||||
for s := range byStream {
|
||||
streams = append(streams, s)
|
||||
}
|
||||
sort.Strings(streams)
|
||||
for _, stream := range streams {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
rows, err := c.streams(stream)
|
||||
held := map[string]consumerRow{}
|
||||
switch {
|
||||
case errors.Is(err, errNoStream):
|
||||
case err != nil:
|
||||
return nil, err
|
||||
default:
|
||||
for _, r := range rows {
|
||||
for _, cr := range r.Consumers {
|
||||
held[cr.Name] = cr
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, want := range byStream[stream] {
|
||||
who := want.Stream + "." + want.Name
|
||||
have, ok := held[want.Name]
|
||||
if !ok {
|
||||
out = append(out, Finding{Kind: "consumer-lost", Token: "missing", Severity: Warning,
|
||||
Subject: Subject{Scope: ScopeBus, ID: who},
|
||||
Summary: fmt.Sprintf("%s is not on the bus: what it delivers reaches nobody", consumerWords(want)),
|
||||
Said: "no consumer " + want.Name + " on " + want.Stream})
|
||||
continue
|
||||
}
|
||||
if differs := consumerDiffers(want, have); differs != "" {
|
||||
out = append(out, Finding{Token: "redefined", Severity: Warning, Subject: Subject{Scope: ScopeBus, ID: who},
|
||||
Summary: fmt.Sprintf("%s is not as the mesh defines it: %s", consumerWords(want), differs),
|
||||
Said: differs})
|
||||
}
|
||||
if want.Stream != "NODES" && have.Pending > behindBound {
|
||||
// One look at a consumer a burst put behind is not one that stays behind: held for the next run,
|
||||
// so healer H4 resets nothing on a single sample (novox/hq issue 277).
|
||||
out = append(out, Finding{Kind: "consumer-behind", Token: "behind", Severity: Warning, Confirm: true,
|
||||
Subject: Subject{Scope: ScopeBus, ID: who},
|
||||
Summary: fmt.Sprintf("%s is %d message(s) behind its stream's head", consumerWords(want), have.Pending),
|
||||
Said: fmt.Sprintf("%d pending, %d handed out and not settled", have.Pending, have.Unacked)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return sorted(out), nil
|
||||
}
|
||||
|
||||
// consumerDiffers is what about a consumer on the bus is not as defined, of what the bus module says of it: its
|
||||
// filters, its shape (push or pull) and how often it hands a message over. Empty when nothing is.
|
||||
func consumerDiffers(want buscompose.Consumer, have consumerRow) string {
|
||||
var differs []string
|
||||
haveFilters := slices.Clone(have.Filters)
|
||||
wantFilters := slices.Clone(want.Filters)
|
||||
sort.Strings(haveFilters)
|
||||
sort.Strings(wantFilters)
|
||||
if !slices.Equal(haveFilters, wantFilters) {
|
||||
differs = append(differs, fmt.Sprintf("filters %v, defined %v", haveFilters, wantFilters))
|
||||
}
|
||||
if want.MaxDeliver != 0 && have.MaxDeliver != int64(want.MaxDeliver) {
|
||||
differs = append(differs, fmt.Sprintf("hands a message over %d times, defined %d", have.MaxDeliver, want.MaxDeliver))
|
||||
}
|
||||
if wantPush, havePush := want.Push || want.Queue != "", have.Kind == "push"; wantPush != havePush {
|
||||
differs = append(differs, "delivers by the other shape (push or pull) than defined")
|
||||
}
|
||||
return strings.Join(differs, "; ")
|
||||
}
|
||||
|
||||
// probeStreams is D7: every stream the mesh defines exists as defined.
|
||||
func probeStreams(ctx context.Context, c *Checker) ([]Finding, error) {
|
||||
rows, err := c.streams("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
held := map[string]streamRow{}
|
||||
for _, r := range rows {
|
||||
held[r.Name] = r
|
||||
}
|
||||
var out []Finding
|
||||
for _, s := range buscompose.MeshStreams() {
|
||||
have, ok := held[s.Name]
|
||||
if !ok {
|
||||
out = append(out, Finding{Token: "stream-missing", Severity: Urgent, Subject: Subject{Scope: ScopeBus, ID: s.Name},
|
||||
Summary: fmt.Sprintf("the stream %s is not on the bus: %s", s.Name, firstLine(s.Why)),
|
||||
Said: "no stream " + s.Name})
|
||||
continue
|
||||
}
|
||||
if differs := streamDiffers(s, have); differs != "" {
|
||||
out = append(out, Finding{Token: "stream-redefined", Severity: Warning, Subject: Subject{Scope: ScopeBus, ID: s.Name},
|
||||
Summary: fmt.Sprintf("the stream %s is not as the mesh defines it: %s", s.Name, differs),
|
||||
Said: differs})
|
||||
}
|
||||
}
|
||||
return sorted(out), nil
|
||||
}
|
||||
|
||||
// streamDiffers is what about a stream on the bus is not as defined, of what the bus module says of it: its
|
||||
// subjects, its retention and how many it keeps per subject. Empty when nothing is.
|
||||
func streamDiffers(want buscompose.Stream, have streamRow) string {
|
||||
var differs []string
|
||||
haveSubjects := slices.Clone(have.Subjects)
|
||||
wantSubjects := slices.Clone(want.Subjects)
|
||||
sort.Strings(haveSubjects)
|
||||
sort.Strings(wantSubjects)
|
||||
if !slices.Equal(haveSubjects, wantSubjects) {
|
||||
differs = append(differs, fmt.Sprintf("subjects %v, defined %v", haveSubjects, wantSubjects))
|
||||
}
|
||||
retention, perSubject := "limits", int64(want.MaxMsgsPerSubject)
|
||||
switch want.Retention {
|
||||
case buscompose.RetentionWorkQueue:
|
||||
retention = "workqueue"
|
||||
case buscompose.RetentionLastPerSubject:
|
||||
perSubject = 1
|
||||
}
|
||||
if have.Retention != "" && have.Retention != retention {
|
||||
differs = append(differs, fmt.Sprintf("keeps by %s, defined %s", have.Retention, retention))
|
||||
}
|
||||
if perSubject != 0 && have.MaxPerSubject != perSubject {
|
||||
differs = append(differs, fmt.Sprintf("keeps %d per subject, defined %d", have.MaxPerSubject, perSubject))
|
||||
}
|
||||
return strings.Join(differs, "; ")
|
||||
}
|
||||
|
||||
// --- D15 -----------------------------------------------------------------------------------------------------
|
||||
|
||||
// closedAnswer is what nats_closed_connections answers.
|
||||
type closedAnswer struct {
|
||||
Hours float64 `json:"hours"`
|
||||
Reaches bool `json:"reaches"`
|
||||
Users []struct {
|
||||
User string `json:"user"`
|
||||
Closed int `json:"closed"`
|
||||
Dropped int `json:"dropped"`
|
||||
Names []struct {
|
||||
Name string `json:"name"`
|
||||
Dropped int `json:"dropped"`
|
||||
Reasons map[string]int `json:"reasons"`
|
||||
} `json:"names"`
|
||||
} `json:"users"`
|
||||
}
|
||||
|
||||
// probeReconnects is D15: each user of the bus whose connections were dropped more than twelve times an hour.
|
||||
func probeReconnects(_ context.Context, c *Checker) ([]Finding, error) {
|
||||
raw, err := c.Ask(ClosedTool, map[string]any{"hours": 1})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the bus module did not answer %s: %w", ClosedTool, err)
|
||||
}
|
||||
var read closedAnswer
|
||||
if err := json.Unmarshal(raw, &read); err != nil {
|
||||
return nil, fmt.Errorf("the bus module answered %s with something unreadable: %w", ClosedTool, err)
|
||||
}
|
||||
hours := read.Hours
|
||||
if hours <= 0 {
|
||||
hours = 1
|
||||
}
|
||||
var out []Finding
|
||||
for _, u := range read.Users {
|
||||
if u.User == "" || strings.HasPrefix(u.User, "(") || float64(u.Dropped)/hours <= reconnectMax {
|
||||
continue // refused before it logged in is nobody's reconnect loop
|
||||
}
|
||||
reasons := map[string]int{}
|
||||
var names []string
|
||||
for _, n := range u.Names {
|
||||
if n.Dropped == 0 {
|
||||
continue
|
||||
}
|
||||
names = append(names, fmt.Sprintf("%q ×%d", n.Name, n.Dropped))
|
||||
for r, k := range n.Reasons {
|
||||
if r != "Client Closed" {
|
||||
reasons[r] += k
|
||||
}
|
||||
}
|
||||
}
|
||||
var why []string
|
||||
for r, k := range reasons {
|
||||
why = append(why, fmt.Sprintf("%s ×%d", r, k))
|
||||
}
|
||||
sort.Strings(why)
|
||||
partial := ""
|
||||
if !read.Reaches {
|
||||
partial = " (at least: the server's record of closed connections does not reach back the whole hour)"
|
||||
}
|
||||
who, machine := busUserWords(u.User)
|
||||
out = append(out, Finding{Kind: "bus-reconnects", Severity: Warning,
|
||||
Subject: Subject{Scope: ScopeBus, ID: u.User, Machine: machine},
|
||||
Summary: fmt.Sprintf("the bus dropped %s's connection %d times in the last hour%s, more than %d: a client "+
|
||||
"reconnecting in a loop", u.User, u.Dropped, partial, reconnectMax),
|
||||
Said: fmt.Sprintf("%d of %d closed connections dropped in %.0f h; by name %s; why %s", u.Dropped, u.Closed,
|
||||
hours, strings.Join(names, ", "), strings.Join(why, ", ")),
|
||||
Headline: capital(who) + " keeps losing the bus",
|
||||
Explanation: capital(fmt.Sprintf("%s lost its connection to the bus %d times in the last hour and connected "+
|
||||
"again each time. While it reconnects, what it says and what it is asked waits.", who, u.Dropped)),
|
||||
Resolved: "Resolved: " + who + " stays connected"})
|
||||
}
|
||||
return sorted(out), nil
|
||||
}
|
||||
|
||||
// busUserWords is a bus user as the operator says it, and the machine it is on.
|
||||
func busUserWords(user string) (string, string) {
|
||||
switch {
|
||||
case user == "controller":
|
||||
return "the controller", ""
|
||||
case strings.HasPrefix(user, "node."):
|
||||
m := strings.TrimPrefix(user, "node.")
|
||||
return "the node-engine on " + m, m
|
||||
}
|
||||
if m, module, ok := strings.Cut(user, "."); ok {
|
||||
if module == "node-tools" {
|
||||
return "the tool runner on " + m, m
|
||||
}
|
||||
return module + " on " + m, m
|
||||
}
|
||||
return "a client of the bus", ""
|
||||
}
|
||||
|
||||
// --- DW and H-bus --------------------------------------------------------------------------------------------
|
||||
|
||||
// probeWatchdogs is DW: the watchdogs answer. The self-check and the watchdogs watch each other: S10, the
|
||||
// watchman's pulse on `checked`, is the other half.
|
||||
func probeWatchdogs(_ context.Context, c *Checker) ([]Finding, error) {
|
||||
if _, err := c.Ask(SignalsVerb, map[string]any{}); err != nil {
|
||||
return []Finding{{Token: "silent", Severity: Urgent, Subject: Subject{Scope: ScopeCore, ID: "watchdogs"},
|
||||
Summary: "the watchdogs do not answer: the watchman holding mesh-watchdogs did not answer signals, so no " +
|
||||
"late signal is being said",
|
||||
Said: firstLine(err.Error())}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// probeBusHealth is H-bus: every stream and durable consumer the mesh defines is on the bus, and a request crosses it
|
||||
// to the bus module's machine and back. The question to the bus module is that request: an answer is its round trip,
|
||||
// and no answer is no pass.
|
||||
func probeBusHealth(ctx context.Context, c *Checker) ([]Finding, error) {
|
||||
rows, err := c.streams("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no request crossed the bus to its module and came back: %w", err)
|
||||
}
|
||||
held := map[string]bool{}
|
||||
for _, r := range rows {
|
||||
held[r.Name] = true
|
||||
}
|
||||
var problems []string
|
||||
for _, s := range buscompose.MeshStreams() {
|
||||
if !held[s.Name] {
|
||||
problems = append(problems, "the stream "+s.Name+" is missing")
|
||||
}
|
||||
}
|
||||
byStream := expectedConsumers()
|
||||
streams := make([]string, 0, len(byStream))
|
||||
for s := range byStream {
|
||||
streams = append(streams, s)
|
||||
}
|
||||
sort.Strings(streams)
|
||||
for _, stream := range streams {
|
||||
if !held[stream] {
|
||||
continue // said above
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
detail, err := c.streams(stream)
|
||||
if err != nil && !errors.Is(err, errNoStream) {
|
||||
return nil, err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, r := range detail {
|
||||
for _, cr := range r.Consumers {
|
||||
have[cr.Name] = true
|
||||
}
|
||||
}
|
||||
for _, want := range byStream[stream] {
|
||||
if !have[want.Name] {
|
||||
problems = append(problems, consumerWords(want)+" is missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(problems) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []Finding{{Token: "unhealthy", Severity: Urgent, Subject: Subject{Scope: ScopeBus, ID: "mesh"},
|
||||
Summary: "the bus is not healthy: " + strings.Join(problems, "; "), Said: strings.Join(problems, "; ")}}, nil
|
||||
}
|
||||
|
||||
// sorted is findings in a stated order, so two runs over one mesh say the same.
|
||||
func sorted(out []Finding) []Finding {
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Key() < out[j].Key() })
|
||||
return out
|
||||
}
|
||||
|
||||
func capital(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
)
|
||||
|
||||
// bus is the bus module's answers as a probe asks them: every mesh stream and consumer as defined, unless changed.
|
||||
type bus struct {
|
||||
drop map[string]bool // stream or stream.consumer missing
|
||||
change func(rows map[string]*streamRow)
|
||||
closed string
|
||||
signals error
|
||||
asksFail error
|
||||
}
|
||||
|
||||
func (b bus) rows() map[string]*streamRow {
|
||||
rows := map[string]*streamRow{}
|
||||
for _, s := range buscompose.MeshStreams() {
|
||||
if b.drop[s.Name] {
|
||||
continue
|
||||
}
|
||||
retention := "limits"
|
||||
per := int64(s.MaxMsgsPerSubject)
|
||||
switch s.Retention {
|
||||
case buscompose.RetentionWorkQueue:
|
||||
retention = "workqueue"
|
||||
case buscompose.RetentionLastPerSubject:
|
||||
per = 1
|
||||
}
|
||||
rows[s.Name] = &streamRow{Name: s.Name, Subjects: s.Subjects, Retention: retention, MaxPerSubject: per}
|
||||
}
|
||||
for _, c := range buscompose.MeshConsumers() {
|
||||
r := rows[c.Stream]
|
||||
if r == nil || b.drop[c.Stream+"."+c.Name] {
|
||||
continue
|
||||
}
|
||||
kind := "pull"
|
||||
if c.Push || c.Queue != "" {
|
||||
kind = "push"
|
||||
}
|
||||
r.Consumers = append(r.Consumers, consumerRow{Name: c.Name, Kind: kind, Filters: c.Filters, MaxDeliver: int64(c.MaxDeliver)})
|
||||
}
|
||||
if b.change != nil {
|
||||
b.change(rows)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (b bus) ask(key string, body any) (json.RawMessage, error) {
|
||||
if b.asksFail != nil {
|
||||
return nil, b.asksFail
|
||||
}
|
||||
switch key {
|
||||
case StreamsTool:
|
||||
stream, _ := body.(map[string]any)["stream"].(string)
|
||||
var detail []streamRow
|
||||
for name, r := range b.rows() {
|
||||
if stream == "" {
|
||||
row := *r
|
||||
row.Consumers = nil
|
||||
detail = append(detail, row)
|
||||
} else if name == stream {
|
||||
detail = append(detail, *r)
|
||||
}
|
||||
}
|
||||
if stream != "" && len(detail) == 0 {
|
||||
return nil, errors.New("no stream called " + stream)
|
||||
}
|
||||
return json.Marshal(map[string]any{"detail": detail})
|
||||
case ClosedTool:
|
||||
if b.closed == "" {
|
||||
return json.RawMessage(`{"hours":1,"reaches":true,"users":[]}`), nil
|
||||
}
|
||||
return json.RawMessage(b.closed), nil
|
||||
case SignalsVerb:
|
||||
if b.signals != nil {
|
||||
return nil, b.signals
|
||||
}
|
||||
return json.RawMessage(`{"signals":[]}`), nil
|
||||
}
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
|
||||
func keysOf(found []Finding) []string {
|
||||
var out []string
|
||||
for _, f := range found {
|
||||
out = append(out, f.Key())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Each probe that moved judges the bus module's answers with its assertion unchanged (ADR 0564 §6; issue 1952 SC2).
|
||||
func TestTheBusProbes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
probe func(context.Context, *Checker) ([]Finding, error)
|
||||
bus bus
|
||||
want []string
|
||||
err string
|
||||
}{
|
||||
{"D6 all as defined", probeConsumers, bus{}, nil, ""},
|
||||
{"D6 a consumer gone", probeConsumers, bus{drop: map[string]bool{"DEAD_LETTER_NOTICES.controller": true}},
|
||||
[]string{"bus.DEAD_LETTER_NOTICES.controller.missing"}, ""},
|
||||
{"D6 a stream gone takes its consumers", probeConsumers, bus{drop: map[string]bool{"CONTROL": true}},
|
||||
[]string{"bus.CONTROL.controller.missing"}, ""},
|
||||
{"D6 redefined and far behind", probeConsumers, bus{change: func(rows map[string]*streamRow) {
|
||||
for i, c := range rows["EVENTS"].Consumers {
|
||||
if c.Name == "controller" {
|
||||
rows["EVENTS"].Consumers[i].MaxDeliver, rows["EVENTS"].Consumers[i].Pending = 9, 5000
|
||||
}
|
||||
}
|
||||
}}, []string{"bus.EVENTS.controller.behind", "bus.EVENTS.controller.redefined"}, ""},
|
||||
{"D6 the bus module silent", probeConsumers, bus{asksFail: errors.New("no responders")}, nil, "did not answer"},
|
||||
{"D7 all as defined", probeStreams, bus{}, nil, ""},
|
||||
{"D7 missing and redefined", probeStreams, bus{drop: map[string]bool{"NODES": true}, change: func(rows map[string]*streamRow) {
|
||||
rows["EVENTS"].Retention = "workqueue"
|
||||
}}, []string{"bus.EVENTS.stream-redefined", "bus.NODES.stream-missing"}, ""},
|
||||
{"D15 quiet", probeReconnects, bus{}, nil, ""},
|
||||
{"D15 a loop", probeReconnects, bus{closed: `{"hours":1,"reaches":true,"users":[
|
||||
{"user":"g14.node-tools","closed":30,"dropped":20,"names":[{"name":"node-tools","dropped":20,"reasons":{"Stale Connection":20}}]},
|
||||
{"user":"ace.records","closed":5,"dropped":3},{"user":"(unauthenticated)","closed":90,"dropped":90}]}`},
|
||||
[]string{"bus.g14.node-tools.bus-reconnects"}, ""},
|
||||
{"DW answers", probeWatchdogs, bus{}, nil, ""},
|
||||
{"DW silent", probeWatchdogs, bus{signals: errors.New("no responders")}, []string{"core.watchdogs.silent"}, ""},
|
||||
{"H-bus healthy", probeBusHealth, bus{}, nil, ""},
|
||||
{"H-bus a stream and a consumer missing", probeBusHealth,
|
||||
bus{drop: map[string]bool{"NODES": true, "EVENTS.controller-merges": true}}, []string{"bus.mesh.unhealthy"}, ""},
|
||||
{"H-bus no round trip", probeBusHealth, bus{asksFail: errors.New("timeout")}, nil, "no request crossed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
c := &Checker{Ask: tc.bus.ask}
|
||||
found, err := tc.probe(context.Background(), c)
|
||||
if tc.err != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.err) {
|
||||
t.Errorf("%s: error %v, want %q", tc.name, err, tc.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", tc.name, err)
|
||||
continue
|
||||
}
|
||||
if got := keysOf(found); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("%s: found %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The registry holds exactly the probes ADR 0564's table moves here, each with a kind, and a deferred one names what
|
||||
// it lacks; the probes that end with the controller or moved to the watchman are not in it (issue 1952 SC4).
|
||||
func TestTheRegistryIsADR0564sTable(t *testing.T) {
|
||||
moved := []string{"D2", "D3", "D4", "D6", "D7", "D8", "D11", "D12", "D13", "D15", "DA", "D-root", "D-fill", "D-heard",
|
||||
"DW", "H-bus"}
|
||||
var ids []string
|
||||
for _, p := range Registry() {
|
||||
ids = append(ids, p.ID)
|
||||
if p.Kind == "" || p.Asserts == "" {
|
||||
t.Errorf("%s has no kind or assertion", p.ID)
|
||||
}
|
||||
if (p.Run == nil) == (p.Deferred == "") {
|
||||
t.Errorf("%s is neither run nor deferred naming what it lacks", p.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
slices.Sort(moved)
|
||||
if !slices.Equal(ids, moved) {
|
||||
t.Errorf("the registry holds %v, ADR 0564 moves %v", ids, moved)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# What these tests read outside their package, as paths from the repository's root (issue 1936).
|
||||
modules/mesh-self-check/module.json
|
||||
@@ -0,0 +1,412 @@
|
||||
package main
|
||||
|
||||
// The self-check (novox/hq ADR 0564 §6): the registry of probes, each a live invariant of a design, run every five
|
||||
// minutes, each probe within thirty seconds. What a probe finds is written as a finding in this module's bucket
|
||||
// `findings`, which the condition keeper raises and clears conditions from (ADR 0564 §2); every run is kept in the
|
||||
// bucket `verdicts` for a week and the last as the key `last` of `verdict`, and is said as `checked`, which the
|
||||
// watchman and mesh-watcher hear (S10, ADR 0227 §6). A probe that errors or does not finish is never a pass: it is
|
||||
// said in the verdict at once and written as a finding of its own when the next run cannot run it either.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Module and Seat are this module and the seat it holds once for the mesh.
|
||||
const (
|
||||
Module = "mesh-self-check"
|
||||
Seat = "mesh-self-check"
|
||||
)
|
||||
|
||||
// The states it keeps, and the event that ends every run.
|
||||
const (
|
||||
FindingsState = "findings"
|
||||
VerdictState = "verdict"
|
||||
VerdictsState = "verdicts"
|
||||
LastKey = "last"
|
||||
CheckedEvent = "checked"
|
||||
)
|
||||
|
||||
// The clocks: the self-check's run is the one timer among the four holders (ADR 0564 §1).
|
||||
var (
|
||||
Every = 5 * time.Minute
|
||||
FirstAfter = time.Minute
|
||||
Within = 30 * time.Second
|
||||
)
|
||||
|
||||
// The verdicts a probe can have.
|
||||
const (
|
||||
VerdictPass = "pass"
|
||||
VerdictFail = "fail"
|
||||
VerdictFailedToRun = "failed-to-run"
|
||||
VerdictDeferred = "deferred"
|
||||
)
|
||||
|
||||
// sourceSelf is what writes a probe's failure to run: the self-check itself.
|
||||
const sourceSelf = "doctor"
|
||||
|
||||
// Store is the bus's buckets as this module reaches them, through its runtime.
|
||||
type Store interface {
|
||||
Get(state, key string) ([]byte, bool, error)
|
||||
Put(state, key string, value any) error
|
||||
Delete(state, key string) error
|
||||
Keys(state string) ([]string, error)
|
||||
}
|
||||
|
||||
// Probe is one live invariant: its id, what it asserts, the kind it raises, and the run; or why it waits.
|
||||
type Probe struct {
|
||||
ID string
|
||||
Asserts string
|
||||
From string
|
||||
Kind string
|
||||
Raises []string
|
||||
// Deferred names the facts it lacks on the bus, for a probe whose facts have no home there yet (ADR 0564 §6):
|
||||
// answered deferred, never a pass.
|
||||
Deferred string
|
||||
Run func(ctx context.Context, c *Checker) ([]Finding, error)
|
||||
}
|
||||
|
||||
// ProbeVerdict is one probe's outcome in a run, as the controller's doctor says it.
|
||||
type ProbeVerdict struct {
|
||||
ID string `json:"id"`
|
||||
Verdict string `json:"verdict"`
|
||||
Found []string `json:"found,omitempty"`
|
||||
Unconfirmed []string `json:"unconfirmed,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Took string `json:"took,omitempty"`
|
||||
FoundCount int `json:"found-count,omitempty"`
|
||||
More string `json:"more,omitempty"`
|
||||
}
|
||||
|
||||
// Counts are a run's verdicts, counted.
|
||||
type Counts struct {
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
FailedToRun int `json:"failed-to-run"`
|
||||
Deferred int `json:"deferred"`
|
||||
}
|
||||
|
||||
// Run is one run of the registry, and the body of `checked`: the controller's doctor-heartbeat body unchanged
|
||||
// (ADR 0564 §6), Controller naming the machine that ran it.
|
||||
type Run struct {
|
||||
Run string `json:"run"`
|
||||
At time.Time `json:"at"`
|
||||
Started time.Time `json:"started"`
|
||||
Took string `json:"took"`
|
||||
IntervalSeconds int `json:"interval-seconds"`
|
||||
Counts Counts `json:"counts"`
|
||||
Probes []ProbeVerdict `json:"probes"`
|
||||
Controller string `json:"controller"`
|
||||
Why string `json:"why"`
|
||||
Unsaid int `json:"unsaid,omitempty"`
|
||||
}
|
||||
|
||||
// Checker is the self-check: the registry and what its probes reach.
|
||||
type Checker struct {
|
||||
Store Store
|
||||
Ask func(key string, body any) (json.RawMessage, error)
|
||||
Emit func(event string, body any) error
|
||||
Now func() time.Time
|
||||
Logf func(format string, args ...any)
|
||||
Host string
|
||||
Registry []Probe
|
||||
|
||||
running sync.Mutex
|
||||
mu sync.Mutex
|
||||
n uint64
|
||||
last *Run
|
||||
// seen are the keys the run before saw, confirmed or not: a finding a single look can be wrong about is
|
||||
// written only when two runs in a row see it, or while it is already written (novox/hq issue 277).
|
||||
seen map[string]bool
|
||||
// unsaid counts the runs whose `checked` could not be said since the start.
|
||||
unsaid int
|
||||
}
|
||||
|
||||
// LastRun is the last run, nil before the first.
|
||||
func (c *Checker) LastRun() *Run {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
type result struct {
|
||||
found []Finding
|
||||
err error
|
||||
took time.Duration
|
||||
}
|
||||
|
||||
// RunOnce runs every probe of the registry, writes what changed in `findings`, keeps the verdict and says `checked`.
|
||||
// One run at a time: a person's run during a scheduled one waits for it.
|
||||
func (c *Checker) RunOnce(ctx context.Context, why string) Run {
|
||||
c.running.Lock()
|
||||
defer c.running.Unlock()
|
||||
c.mu.Lock()
|
||||
c.n++
|
||||
n := c.n
|
||||
c.mu.Unlock()
|
||||
started := c.Now()
|
||||
run := Run{Run: fmt.Sprintf("doctor-%d-%d", started.Unix(), n), Started: started.UTC(),
|
||||
IntervalSeconds: int(Every / time.Second), Controller: c.Host, Why: why}
|
||||
|
||||
results := make([]result, len(c.Registry))
|
||||
var wg sync.WaitGroup
|
||||
for i, p := range c.Registry {
|
||||
if p.Run == nil {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, p Probe) {
|
||||
defer wg.Done()
|
||||
results[i] = c.probe(ctx, p)
|
||||
}(i, p)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
written, readErr := c.written()
|
||||
seen := map[string]bool{}
|
||||
want := map[string]Finding{} // every finding this run writes, by key
|
||||
ran := map[string]bool{} // the sources whose findings this run judged whole
|
||||
var blind []Finding
|
||||
for i, p := range c.Registry {
|
||||
v := ProbeVerdict{ID: p.ID}
|
||||
r := results[i]
|
||||
switch {
|
||||
case p.Run == nil:
|
||||
v.Verdict = VerdictDeferred
|
||||
v.Error = "deferred: " + p.Deferred
|
||||
run.Counts.Deferred++
|
||||
case r.err != nil:
|
||||
v.Verdict, v.Error = VerdictFailedToRun, r.err.Error()
|
||||
run.Counts.FailedToRun++
|
||||
blind = append(blind, Finding{Kind: "probe-failed", Token: "failed", Severity: Warning, Confirm: true,
|
||||
Subject: Subject{Scope: ScopeProbe, ID: p.ID}, Source: sourceSelf,
|
||||
Summary: fmt.Sprintf("the probe %s (%s) could not run: what it checks is not known — never a pass", p.ID, p.Asserts),
|
||||
Said: firstLine(r.err.Error())})
|
||||
default:
|
||||
ran[p.ID] = true
|
||||
for _, f := range r.found {
|
||||
if f.Kind == "" {
|
||||
f.Kind = p.Kind
|
||||
}
|
||||
f.Source = p.ID
|
||||
f = worded(f)
|
||||
f.Observer = Module
|
||||
key := f.Key()
|
||||
seen[key] = true
|
||||
if f.Confirm && !c.seenBefore(key) && !written.has(key) {
|
||||
v.Unconfirmed = append(v.Unconfirmed, f.Summary)
|
||||
continue
|
||||
}
|
||||
want[key] = f
|
||||
v.Found = append(v.Found, f.Summary)
|
||||
}
|
||||
if len(v.Found) == 0 {
|
||||
v.Verdict = VerdictPass
|
||||
run.Counts.Passed++
|
||||
} else {
|
||||
v.Verdict = VerdictFail
|
||||
run.Counts.Failed++
|
||||
}
|
||||
}
|
||||
if p.Run != nil {
|
||||
v.Took = r.took.Round(time.Millisecond).String()
|
||||
}
|
||||
run.Probes = append(run.Probes, v)
|
||||
}
|
||||
ran[sourceSelf] = true
|
||||
for _, f := range blind {
|
||||
f = worded(f)
|
||||
f.Observer = Module
|
||||
key := f.Key()
|
||||
seen[key] = true
|
||||
if c.seenBefore(key) || written.has(key) {
|
||||
want[key] = f
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
// Blind to its own bucket: nothing is written or deleted (ADR 0227 §4), and the log says so.
|
||||
c.Logf("[%s] its findings cannot be read, so none is written or cleared this run: %v", Module, readErr)
|
||||
} else if err := c.write(written, want, ran); err != nil {
|
||||
c.Logf("[%s] what this run found is not all written: %v", Module, err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.seen = seen
|
||||
c.mu.Unlock()
|
||||
|
||||
run.At = c.Now().UTC()
|
||||
run.Took = run.At.Sub(started).Round(time.Millisecond).String()
|
||||
c.mu.Lock()
|
||||
run.Unsaid = c.unsaid
|
||||
c.mu.Unlock()
|
||||
c.keep(run)
|
||||
return run
|
||||
}
|
||||
|
||||
// probe runs one probe within its bound; a panic or an overrun is an error, never a pass.
|
||||
func (c *Checker) probe(ctx context.Context, p Probe) result {
|
||||
probing, cancel := context.WithTimeout(ctx, Within)
|
||||
defer cancel()
|
||||
began := c.Now()
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
done <- result{err: fmt.Errorf("the probe panicked: %v", r)}
|
||||
}
|
||||
}()
|
||||
found, err := p.Run(probing, c)
|
||||
done <- result{found: found, err: err}
|
||||
}()
|
||||
select {
|
||||
case r := <-done:
|
||||
r.took = c.Now().Sub(began)
|
||||
return r
|
||||
case <-probing.Done():
|
||||
return result{err: fmt.Errorf("it did not finish within %s", Within), took: c.Now().Sub(began)}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Checker) seenBefore(key string) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.seen[key]
|
||||
}
|
||||
|
||||
// writtenFindings are the findings in the bucket, by key, with the source that wrote each and what it says.
|
||||
type writtenFindings map[string]Finding
|
||||
|
||||
func (w writtenFindings) has(key string) bool { _, ok := w[key]; return ok }
|
||||
|
||||
// written reads every finding this module has written.
|
||||
func (c *Checker) written() (writtenFindings, error) {
|
||||
keys, err := c.Store.Keys(FindingsState)
|
||||
if err != nil {
|
||||
return writtenFindings{}, err
|
||||
}
|
||||
out := writtenFindings{}
|
||||
for _, k := range keys {
|
||||
raw, found, err := c.Store.Get(FindingsState, k)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
var f Finding
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return out, fmt.Errorf("the finding %s is not readable: %w", k, err)
|
||||
}
|
||||
out[k] = f
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// write puts each finding whose words changed and deletes each one a source that ran whole no longer sees. A finding
|
||||
// of a source that could not run is left as it is: a blind observer clears nothing (ADR 0227 §4).
|
||||
func (c *Checker) write(written writtenFindings, want map[string]Finding, ran map[string]bool) error {
|
||||
var errs []string
|
||||
keys := make([]string, 0, len(want))
|
||||
for k := range want {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
f := want[k]
|
||||
if was, ok := written[k]; ok && was.Says() == f.Says() {
|
||||
continue
|
||||
}
|
||||
if err := c.Store.Put(FindingsState, k, f); err != nil {
|
||||
errs = append(errs, k+": "+err.Error())
|
||||
}
|
||||
}
|
||||
for k, f := range written {
|
||||
if _, still := want[k]; still || !ran[f.Source] {
|
||||
continue
|
||||
}
|
||||
if err := c.Store.Delete(FindingsState, k); err != nil {
|
||||
errs = append(errs, k+": "+err.Error())
|
||||
}
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep keeps the run as the last verdict and in the week's verdicts, and says it as `checked`. Not said is said in
|
||||
// the log: the watchman's S10 says the self-check silent when `checked` stops.
|
||||
func (c *Checker) keep(run Run) {
|
||||
c.mu.Lock()
|
||||
c.last = &run
|
||||
c.mu.Unlock()
|
||||
if err := c.Store.Put(VerdictState, LastKey, run); err != nil {
|
||||
c.Logf("[%s] the run %s is not kept as the last verdict: %v", Module, run.Run, err)
|
||||
}
|
||||
if err := c.Store.Put(VerdictsState, run.Run, Overview(run)); err != nil {
|
||||
c.Logf("[%s] the run %s is not kept among the week's verdicts: %v", Module, run.Run, err)
|
||||
}
|
||||
if c.Emit == nil {
|
||||
return
|
||||
}
|
||||
if err := c.Emit(CheckedEvent, run); err != nil {
|
||||
c.mu.Lock()
|
||||
c.unsaid++
|
||||
c.mu.Unlock()
|
||||
c.Logf("[%s] the run %s could NOT be said as checked, so the watchers will say the self-check is silent: %v",
|
||||
Module, run.Run, err)
|
||||
}
|
||||
}
|
||||
|
||||
// overviewFindings is how many findings of one probe an overview carries (novox/hq issue 314).
|
||||
const overviewFindings = 20
|
||||
|
||||
// Overview is a run with each probe's findings at most overviewFindings, with their count and how to read them all.
|
||||
func Overview(run Run) Run {
|
||||
probes := make([]ProbeVerdict, len(run.Probes))
|
||||
for i, p := range run.Probes {
|
||||
total := len(p.Found) + len(p.Unconfirmed)
|
||||
if total > overviewFindings {
|
||||
p.FoundCount = len(p.Found)
|
||||
if len(p.Found) > overviewFindings {
|
||||
p.Found = p.Found[:overviewFindings:overviewFindings]
|
||||
}
|
||||
if room := overviewFindings - len(p.Found); len(p.Unconfirmed) > room {
|
||||
p.Unconfirmed = p.Unconfirmed[:room:room]
|
||||
}
|
||||
p.More = fmt.Sprintf("%d found and %d unconfirmed, %d of them shown — `%s.doctor probe=%s` gives them all",
|
||||
p.FoundCount, total-p.FoundCount, len(p.Found)+len(p.Unconfirmed), Seat, p.ID)
|
||||
}
|
||||
probes[i] = p
|
||||
}
|
||||
run.Probes = probes
|
||||
return run
|
||||
}
|
||||
|
||||
// Keep runs the registry on its schedule until ctx ends: the first run FirstAfter the start, then every Every.
|
||||
func (c *Checker) Keep(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(FirstAfter):
|
||||
}
|
||||
tick := time.NewTicker(Every)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
c.RunOnce(ctx, "the schedule")
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
line, _, _ := strings.Cut(s, "\n")
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// memStore is the bus's buckets in memory, counting the writes.
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
kv map[string]map[string][]byte
|
||||
puts map[string]int
|
||||
keysErr error
|
||||
}
|
||||
|
||||
func newStore() *memStore {
|
||||
return &memStore{kv: map[string]map[string][]byte{}, puts: map[string]int{}}
|
||||
}
|
||||
|
||||
func (s *memStore) Get(state, key string) ([]byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.kv[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (s *memStore) Put(state, key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.kv[state] == nil {
|
||||
s.kv[state] = map[string][]byte{}
|
||||
}
|
||||
s.kv[state][key] = raw
|
||||
s.puts[state+"/"+key]++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memStore) Delete(state, key string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.kv[state], key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memStore) Keys(state string) ([]string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.keysErr != nil && state == FindingsState {
|
||||
return nil, s.keysErr
|
||||
}
|
||||
var out []string
|
||||
for k := range s.kv[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *memStore) findingKeys() []string {
|
||||
keys, _ := s.Keys(FindingsState)
|
||||
return keys
|
||||
}
|
||||
|
||||
// checker is a self-check over memory, with a registry the test drives.
|
||||
func checker(store *memStore, registry ...Probe) (*Checker, *[]string) {
|
||||
var said []string
|
||||
now := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC)
|
||||
c := &Checker{Store: store, Registry: registry, Host: "novox", Logf: func(string, ...any) {},
|
||||
Now: func() time.Time { now = now.Add(time.Second); return now },
|
||||
Emit: func(event string, body any) error {
|
||||
raw, _ := json.Marshal(body)
|
||||
said = append(said, event+" "+string(raw))
|
||||
return nil
|
||||
}}
|
||||
return c, &said
|
||||
}
|
||||
|
||||
// A run writes what a probe finds, writes nothing it already wrote, and deletes what it no longer sees; a probe that
|
||||
// could not run clears nothing and is written as its own finding at the second run in a row; a finding one look can
|
||||
// be wrong about waits for the second run; a deferred probe is never a pass (ADR 0564 §2, §6; issue 1952 SC1).
|
||||
func TestARunWritesFindingsAsTheyChange(t *testing.T) {
|
||||
var found []Finding
|
||||
var fail error
|
||||
steady := Probe{ID: "P1", Kind: "stream-wrong", Run: func(context.Context, *Checker) ([]Finding, error) { return found, fail }}
|
||||
deferred := Probe{ID: "P2", Kind: "resolver-wrong", Deferred: "the overlay's addresses"}
|
||||
missing := Finding{Token: "stream-missing", Severity: Urgent, Subject: Subject{Scope: ScopeBus, ID: "EVENTS"},
|
||||
Summary: "the stream EVENTS is not on the bus"}
|
||||
behind := Finding{Kind: "consumer-behind", Token: "behind", Severity: Warning, Confirm: true,
|
||||
Subject: Subject{Scope: ScopeBus, ID: "EVENTS.controller"}, Summary: "far behind"}
|
||||
store := newStore()
|
||||
c, said := checker(store, steady, deferred)
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
found []Finding
|
||||
fail error
|
||||
want []string // finding keys written after the run
|
||||
verdict string
|
||||
missingPs int // puts of the missing finding so far
|
||||
}{
|
||||
{"a finding is written", []Finding{missing}, nil, []string{"bus.EVENTS.stream-missing"}, VerdictFail, 1},
|
||||
{"the same again is no write", []Finding{missing}, nil, []string{"bus.EVENTS.stream-missing"}, VerdictFail, 1},
|
||||
{"one look can be wrong: held", []Finding{missing, behind}, nil, []string{"bus.EVENTS.stream-missing"}, VerdictFail, 1},
|
||||
{"seen twice: written", []Finding{missing, behind}, nil,
|
||||
[]string{"bus.EVENTS.controller.behind", "bus.EVENTS.stream-missing"}, VerdictFail, 1},
|
||||
{"blind once: clears nothing, held", nil, errors.New("timed out"),
|
||||
[]string{"bus.EVENTS.controller.behind", "bus.EVENTS.stream-missing"}, VerdictFailedToRun, 1},
|
||||
{"blind twice: its own finding", nil, errors.New("timed out"),
|
||||
[]string{"bus.EVENTS.controller.behind", "bus.EVENTS.stream-missing", "probe.P1.failed"}, VerdictFailedToRun, 1},
|
||||
{"no longer seen: cleared", nil, nil, nil, VerdictPass, 1},
|
||||
}
|
||||
for _, s := range steps {
|
||||
found, fail = s.found, s.fail
|
||||
run := c.RunOnce(context.Background(), "the test")
|
||||
if got := store.findingKeys(); !slices.Equal(got, s.want) {
|
||||
t.Errorf("%s: findings %v, want %v", s.name, got, s.want)
|
||||
}
|
||||
if run.Probes[0].Verdict != s.verdict {
|
||||
t.Errorf("%s: P1 %s, want %s", s.name, run.Probes[0].Verdict, s.verdict)
|
||||
}
|
||||
if run.Probes[1].Verdict != VerdictDeferred || !strings.Contains(run.Probes[1].Error, "overlay") {
|
||||
t.Errorf("%s: a deferred probe answered %+v", s.name, run.Probes[1])
|
||||
}
|
||||
if n := store.puts[FindingsState+"/bus.EVENTS.stream-missing"]; n != s.missingPs {
|
||||
t.Errorf("%s: the unchanged finding was written %d times", s.name, n)
|
||||
}
|
||||
}
|
||||
raw, _, _ := store.Get(FindingsState, "probe.P1.failed")
|
||||
if raw != nil {
|
||||
t.Errorf("the blind finding outlived the probe running again: %s", raw)
|
||||
}
|
||||
// Every run is kept, the last as `last`, and said as checked with the controller's heartbeat body.
|
||||
if keys, _ := store.Keys(VerdictsState); len(keys) != len(steps) {
|
||||
t.Errorf("verdicts kept %d runs, want %d", len(keys), len(steps))
|
||||
}
|
||||
if len(*said) != len(steps) || !strings.HasPrefix((*said)[0], CheckedEvent+" ") {
|
||||
t.Fatalf("said %v", *said)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal([]byte(strings.TrimPrefix((*said)[0], CheckedEvent+" ")), &body)
|
||||
for _, field := range []string{"run", "at", "started", "took", "interval-seconds", "counts", "probes", "controller", "why"} {
|
||||
if _, ok := body[field]; !ok {
|
||||
t.Errorf("checked's body lacks %s, which doctor-heartbeat carries: %v", field, body)
|
||||
}
|
||||
}
|
||||
last, _, _ := store.Get(VerdictState, LastKey)
|
||||
var r Run
|
||||
_ = json.Unmarshal(last, &r)
|
||||
if r.Counts.Passed != 1 || r.Counts.Deferred != 1 {
|
||||
t.Errorf("the last verdict counts %+v", r.Counts)
|
||||
}
|
||||
// A finding carries the operator's words of its kind, the probe that found it and who observed it.
|
||||
found, fail = []Finding{missing}, nil
|
||||
c.RunOnce(context.Background(), "the test")
|
||||
raw, _, _ = store.Get(FindingsState, "bus.EVENTS.stream-missing")
|
||||
var f Finding
|
||||
_ = json.Unmarshal(raw, &f)
|
||||
if f.Kind != "stream-wrong" || f.Source != "P1" || f.Observer != Module || f.Headline == "" || f.Explanation == "" {
|
||||
t.Errorf("the written finding is %+v", f)
|
||||
}
|
||||
// Blind to its own bucket, a run writes and clears nothing.
|
||||
store.keysErr = errors.New("the bus is away")
|
||||
found = nil
|
||||
c.RunOnce(context.Background(), "the test")
|
||||
store.keysErr = nil
|
||||
if got := store.findingKeys(); !slices.Equal(got, []string{"bus.EVENTS.stream-missing"}) {
|
||||
t.Errorf("a run blind to its findings changed them: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A probe that panics or overruns its bound could not run: never a pass.
|
||||
func TestAProbeThatPanicsOrOverrunsCouldNotRun(t *testing.T) {
|
||||
within := Within
|
||||
Within = 50 * time.Millisecond
|
||||
t.Cleanup(func() { Within = within })
|
||||
for name, run := range map[string]func(context.Context, *Checker) ([]Finding, error){
|
||||
"panic": func(context.Context, *Checker) ([]Finding, error) { panic("boom") },
|
||||
"overrun": func(ctx context.Context, _ *Checker) ([]Finding, error) { time.Sleep(time.Second); return nil, nil },
|
||||
} {
|
||||
c, _ := checker(newStore(), Probe{ID: "P", Kind: "k", Run: run})
|
||||
c.Now = time.Now
|
||||
r := c.RunOnce(context.Background(), "the test")
|
||||
if r.Probes[0].Verdict != VerdictFailedToRun || r.Counts.Passed != 0 {
|
||||
t.Errorf("%s: %+v", name, r.Probes[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The verb answers the last run, one probe whole, a run now, and before any run says when one comes; the reads answer
|
||||
// every state the module keeps (ADR 0564 §12).
|
||||
func TestTheVerbsAndReads(t *testing.T) {
|
||||
store := newStore()
|
||||
many := make([]Finding, 25)
|
||||
for i := range many {
|
||||
many[i] = Finding{Severity: Warning, Subject: Subject{Scope: ScopeBus, ID: fmt.Sprintf("S%02d", i)}, Summary: "x"}
|
||||
}
|
||||
c, _ := checker(store, Probe{ID: "D7", Kind: "stream-wrong",
|
||||
Run: func(context.Context, *Checker) ([]Finding, error) { return many, nil }})
|
||||
if _, err := c.Doctor(false, "", "x"); err == nil || !strings.Contains(err.Error(), "first run") {
|
||||
t.Fatalf("before a run: %v", err)
|
||||
}
|
||||
if _, err := c.Doctor(true, "", "asked by person.jochen"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
over, _ := c.Doctor(false, "", "")
|
||||
if v := over["run"].(Run).Probes[0]; len(v.Found) != overviewFindings || v.More == "" {
|
||||
t.Errorf("the overview carries %d findings, more %q", len(v.Found), v.More)
|
||||
}
|
||||
whole, _ := c.Doctor(false, "d7", "")
|
||||
if v := whole["run"].(Run).Probes[0]; len(v.Found) != 25 {
|
||||
t.Errorf("one probe whole carries %d findings", len(v.Found))
|
||||
}
|
||||
if _, err := c.Doctor(false, "D99", ""); err == nil {
|
||||
t.Error("an unknown probe answered")
|
||||
}
|
||||
// A fresh checker over the same bucket answers the last verdict from it.
|
||||
fresh, _ := checker(store)
|
||||
if _, err := fresh.Doctor(false, "", ""); err != nil {
|
||||
t.Errorf("the last verdict is not read from its bucket: %v", err)
|
||||
}
|
||||
fs, err := c.Findings("")
|
||||
if err != nil || fs.(map[string]any)["count"] != 25 {
|
||||
t.Errorf("findings answered %v, %v", fs, err)
|
||||
}
|
||||
if one, err := c.Findings("bus.S03.stream-wrong"); err != nil || !strings.Contains(string(one.(json.RawMessage)), "S03") {
|
||||
t.Errorf("one finding answered %v, %v", one, err)
|
||||
}
|
||||
vs, err := c.Verdicts("", 0)
|
||||
if err != nil || len(vs.(map[string]any)["verdicts"].([]map[string]any)) != 1 {
|
||||
t.Errorf("verdicts answered %v, %v", vs, err)
|
||||
}
|
||||
if _, err := c.Verdicts("doctor-1-1", 0); err == nil {
|
||||
t.Error("an unknown run answered")
|
||||
}
|
||||
if p := c.Probes()["probes"].([]map[string]any); len(p) != 1 || p[0]["id"] != "D7" {
|
||||
t.Errorf("probes answered %v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
// The seat's verbs and the module's own tools (novox/hq ADR 0564 §6, §12): `doctor` answers the last verdict at once,
|
||||
// runs now, or gives one probe's verdict whole; `probes` is the registry; and a read of each state it keeps — the
|
||||
// findings, the last verdict and the week's verdicts — so a person sees all it holds and a recovery is a call.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Verbs are the seat's verbs, as the manifest claims them.
|
||||
var Verbs = []string{"doctor", "probes"}
|
||||
|
||||
// Tool names: the seat's verbs, and the module's own reads.
|
||||
const (
|
||||
DoctorTool = Seat + ".doctor"
|
||||
ProbesTool = Seat + ".probes"
|
||||
FindingsTool = "self_check_findings"
|
||||
VerdictsTool = "self_check_verdicts"
|
||||
)
|
||||
|
||||
func obj(props map[string]any) map[string]any {
|
||||
return map[string]any{"type": "object", "properties": props}
|
||||
}
|
||||
|
||||
// Tools are what the runtime serves.
|
||||
func Tools(c *Checker) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: DoctorTool,
|
||||
Description: "The mesh's self-check (novox/hq ADR 0564 §6): the last run's verdict at once, each probe passed, " +
|
||||
"failed with what it found, could not run (never a pass) or deferred with the facts it lacks; run=true runs " +
|
||||
"every probe now and answers that run; probe=<id> gives one probe's verdict whole, every finding.",
|
||||
Input: obj(map[string]any{
|
||||
"run": map[string]any{"type": "boolean", "description": "run every probe now"},
|
||||
"probe": map[string]any{"type": "string", "description": "one probe's verdict, whole, e.g. D6"}}),
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
return c.Doctor(boolArg(a, "run"), strArg(a, "probe"), "asked by "+stdio.CallerOf(a))
|
||||
}},
|
||||
{Name: ProbesTool,
|
||||
Description: "The self-check's registry: each probe's id, what it asserts, where it came from, the condition kinds " +
|
||||
"it raises, and for one deferred the facts it lacks on the bus.",
|
||||
Run: func(map[string]any) (any, error) { return c.Probes(), nil }},
|
||||
{Name: FindingsTool,
|
||||
Description: "What the self-check finds wrong now, as the condition keeper reads it (its bucket findings): every " +
|
||||
"finding's key, kind, severity, the probe that found it and its summary; or one finding whole by key.",
|
||||
Input: obj(map[string]any{"key": map[string]any{"type": "string", "description": "one finding's key"}}),
|
||||
Run: func(a map[string]any) (any, error) { return c.Findings(strArg(a, "key")) }},
|
||||
{Name: VerdictsTool,
|
||||
Description: "The self-check's runs of the last week (its bucket verdicts, each kept a week) newest first, each " +
|
||||
"with its counts; or one run whole by its id. The last run is the doctor verb's.",
|
||||
Input: obj(map[string]any{
|
||||
"run": map[string]any{"type": "string", "description": "one run's id, e.g. doctor-1793284800-12"},
|
||||
"limit": map[string]any{"type": "number", "description": "at most this many, newest (default 50)"}}),
|
||||
Run: func(a map[string]any) (any, error) { return c.Verdicts(strArg(a, "run"), intArg(a, "limit", 50)) }},
|
||||
}
|
||||
}
|
||||
|
||||
// Doctor is the verb: the last verdict, a run now, or one probe of either.
|
||||
func (c *Checker) Doctor(run bool, probe, why string) (map[string]any, error) {
|
||||
var r Run
|
||||
switch {
|
||||
case run:
|
||||
r = c.RunOnce(context.Background(), why)
|
||||
case c.LastRun() != nil:
|
||||
r = *c.LastRun()
|
||||
default:
|
||||
raw, found, err := c.Store.Get(VerdictState, LastKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the last verdict cannot be read: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("the self-check has not finished its first run yet: it runs %s after it starts, then "+
|
||||
"every %s — run=true runs it now", FirstAfter, Every)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &r); err != nil {
|
||||
return nil, fmt.Errorf("the last verdict is not readable: %w", err)
|
||||
}
|
||||
}
|
||||
if probe != "" {
|
||||
one, ok := oneProbe(r, probe)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the run %s has no probe %q — %s lists them", r.Run, probe, ProbesTool)
|
||||
}
|
||||
r = one
|
||||
} else {
|
||||
r = Overview(r)
|
||||
}
|
||||
return map[string]any{"run": r, "age": c.Now().Sub(r.At).Round(time.Second).String(),
|
||||
"note": "a probe that could not run is never a pass; each finding is a condition the keeper raises until a run no " +
|
||||
"longer sees it, and one a single look can be wrong about is written when two runs in a row see it"}, nil
|
||||
}
|
||||
|
||||
func oneProbe(r Run, id string) (Run, bool) {
|
||||
for _, p := range r.Probes {
|
||||
if strings.EqualFold(p.ID, id) {
|
||||
r.Probes = []ProbeVerdict{p}
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return Run{}, false
|
||||
}
|
||||
|
||||
// Probes is the registry as the verb answers it.
|
||||
func (c *Checker) Probes() map[string]any {
|
||||
var out []map[string]any
|
||||
for _, p := range c.Registry {
|
||||
row := map[string]any{"id": p.ID, "asserts": p.Asserts, "from": p.From, "kind": p.Kind}
|
||||
if len(p.Raises) > 0 {
|
||||
row["raises"] = p.Raises
|
||||
}
|
||||
if p.Run == nil {
|
||||
row["deferred"] = p.Deferred
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return map[string]any{"probes": out, "every": Every.String(), "each within": Within.String()}
|
||||
}
|
||||
|
||||
// Findings are the findings written now, or one whole.
|
||||
func (c *Checker) Findings(key string) (any, error) {
|
||||
if key != "" {
|
||||
raw, found, err := c.Store.Get(FindingsState, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no finding is written under %s now", key)
|
||||
}
|
||||
return json.RawMessage(raw), nil
|
||||
}
|
||||
written, err := c.written()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the findings cannot be read: %w", err)
|
||||
}
|
||||
rows := []map[string]any{}
|
||||
for k, f := range written {
|
||||
rows = append(rows, map[string]any{"key": k, "kind": f.Kind, "severity": f.Severity, "probe": f.Source,
|
||||
"summary": f.Summary})
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i]["key"].(string) < rows[j]["key"].(string) })
|
||||
return map[string]any{"findings": rows, "count": len(rows)}, nil
|
||||
}
|
||||
|
||||
// Verdicts are the week's runs newest first, or one whole.
|
||||
func (c *Checker) Verdicts(run string, limit int) (any, error) {
|
||||
if run != "" {
|
||||
raw, found, err := c.Store.Get(VerdictsState, run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no run %s is kept: runs are kept a week", run)
|
||||
}
|
||||
return json.RawMessage(raw), nil
|
||||
}
|
||||
keys, err := c.Store.Keys(VerdictsState)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the week's verdicts cannot be read: %w", err)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return runOrder(keys[i]) > runOrder(keys[j]) })
|
||||
total := len(keys)
|
||||
if limit > 0 && len(keys) > limit {
|
||||
keys = keys[:limit]
|
||||
}
|
||||
rows := []map[string]any{}
|
||||
for _, k := range keys {
|
||||
raw, found, err := c.Store.Get(VerdictsState, k)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
}
|
||||
var r Run
|
||||
if json.Unmarshal(raw, &r) == nil {
|
||||
rows = append(rows, map[string]any{"run": r.Run, "at": r.At, "why": r.Why, "counts": r.Counts})
|
||||
}
|
||||
}
|
||||
return map[string]any{"verdicts": rows, "kept": total, "kept-for": "a week"}, nil
|
||||
}
|
||||
|
||||
// runOrder orders run ids `doctor-<unix>-<n>` by time, then number.
|
||||
func runOrder(id string) string {
|
||||
parts := strings.Split(id, "-")
|
||||
if len(parts) != 3 {
|
||||
return id
|
||||
}
|
||||
return fmt.Sprintf("%020s-%020s", parts[1], parts[2])
|
||||
}
|
||||
|
||||
func strArg(a map[string]any, k string) string {
|
||||
s, _ := a[k].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func boolArg(a map[string]any, k string) bool {
|
||||
b, _ := a[k].(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
func intArg(a map[string]any, k string, otherwise int) int {
|
||||
if n, ok := a[k].(float64); ok && n > 0 {
|
||||
return int(n)
|
||||
}
|
||||
return otherwise
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//go:build integration
|
||||
|
||||
package main
|
||||
|
||||
// The self-check whole against a real bus (novox/hq ADR 0564 §6, issue 1952 SC1): the built binary, played over its
|
||||
// real stdin and stdout as the node's tool runtime plays it (tools/call; mesh/state.* answered from a real
|
||||
// nats-server's key-value buckets, mesh/publish onto its subjects, and the bus module's nats_streams answered from
|
||||
// that server's own streams). One run asked through the seat's verb finds the mesh's streams missing on a bare bus,
|
||||
// writes them as findings into its bucket on the bus, keeps the verdict, and says checked.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/testbus"
|
||||
)
|
||||
|
||||
type wire struct {
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// player is the node's tool runtime, as far as the self-check needs it, over a real bus.
|
||||
type player struct {
|
||||
ctx context.Context
|
||||
nc *nats.Conn
|
||||
js jetstream.JetStream
|
||||
mu sync.Mutex
|
||||
in io.Writer
|
||||
next int
|
||||
pending map[string]chan wire
|
||||
}
|
||||
|
||||
func (p *player) send(m map[string]any) {
|
||||
raw, _ := json.Marshal(m)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, _ = p.in.Write(append(raw, '\n'))
|
||||
}
|
||||
|
||||
func (p *player) answer(id json.RawMessage, result any, err error) {
|
||||
if err != nil {
|
||||
p.send(map[string]any{"id": id, "error": map[string]any{"code": -32000, "message": err.Error()}})
|
||||
return
|
||||
}
|
||||
p.send(map[string]any{"id": id, "result": result})
|
||||
}
|
||||
|
||||
func (p *player) serve(out io.Reader) {
|
||||
scanner := bufio.NewScanner(out)
|
||||
scanner.Buffer(make([]byte, 1<<20), 1<<24)
|
||||
for scanner.Scan() {
|
||||
var m wire
|
||||
if json.Unmarshal(scanner.Bytes(), &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.Method == "" {
|
||||
var key string
|
||||
_ = json.Unmarshal(m.ID, &key)
|
||||
p.mu.Lock()
|
||||
ch := p.pending[key]
|
||||
delete(p.pending, key)
|
||||
p.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
continue
|
||||
}
|
||||
go p.handle(m)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *player) kv(state string) (jetstream.KeyValue, error) {
|
||||
return p.js.KeyValue(p.ctx, Module+"_"+state)
|
||||
}
|
||||
|
||||
func (p *player) handle(m wire) {
|
||||
var a struct {
|
||||
State string `json:"state"`
|
||||
Key string `json:"key"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &a)
|
||||
switch m.Method {
|
||||
case "mesh/state.get":
|
||||
kv, err := p.kv(a.State)
|
||||
if err != nil {
|
||||
p.answer(m.ID, nil, err)
|
||||
return
|
||||
}
|
||||
e, err := kv.Get(p.ctx, a.Key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
p.answer(m.ID, nil, nil)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
p.answer(m.ID, nil, err)
|
||||
return
|
||||
}
|
||||
p.answer(m.ID, map[string]any{"key": e.Key(), "value": json.RawMessage(e.Value()), "revision": e.Revision()}, nil)
|
||||
case "mesh/state.put":
|
||||
kv, err := p.kv(a.State)
|
||||
if err == nil {
|
||||
var rev uint64
|
||||
if rev, err = kv.Put(p.ctx, a.Key, a.Value); err == nil {
|
||||
p.answer(m.ID, map[string]any{"revision": rev}, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.answer(m.ID, nil, err)
|
||||
case "mesh/state.keys":
|
||||
kv, err := p.kv(a.State)
|
||||
if err != nil {
|
||||
p.answer(m.ID, nil, err)
|
||||
return
|
||||
}
|
||||
keys, err := kv.Keys(p.ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
keys, err = []string{}, nil
|
||||
}
|
||||
p.answer(m.ID, keys, err)
|
||||
case "mesh/state.delete":
|
||||
kv, err := p.kv(a.State)
|
||||
if err == nil {
|
||||
err = kv.Delete(p.ctx, a.Key)
|
||||
}
|
||||
p.answer(m.ID, map[string]any{}, err)
|
||||
case "mesh/publish":
|
||||
var e struct {
|
||||
Key string `json:"key"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &e)
|
||||
p.answer(m.ID, map[string]any{}, p.nc.Publish("mesh.mod."+Module+".event."+e.Key, e.Body))
|
||||
case "mesh/ask":
|
||||
var ask struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Params, &ask)
|
||||
if ask.Key != StreamsTool {
|
||||
p.answer(m.ID, nil, errors.New("nats: no responders available for request"))
|
||||
return
|
||||
}
|
||||
// The bus module's answer, from the server's own streams.
|
||||
var detail []map[string]any
|
||||
for name := range p.js.StreamNames(p.ctx).Name() {
|
||||
detail = append(detail, map[string]any{"name": name})
|
||||
}
|
||||
p.answer(m.ID, map[string]any{"detail": detail}, nil)
|
||||
default:
|
||||
p.answer(m.ID, nil, errors.New("nats: no responders available for request"))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *player) call(name string, args map[string]any) (json.RawMessage, error) {
|
||||
p.mu.Lock()
|
||||
p.next++
|
||||
id := fmt.Sprintf("c-%d", p.next)
|
||||
ch := make(chan wire, 1)
|
||||
p.pending[id] = ch
|
||||
p.mu.Unlock()
|
||||
p.send(map[string]any{"id": id, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args,
|
||||
"_meta": map[string]any{"mesh/caller": "person.jochen"}}})
|
||||
select {
|
||||
case m := <-ch:
|
||||
if m.Error != nil {
|
||||
return nil, errors.New(m.Error.Message)
|
||||
}
|
||||
var r struct {
|
||||
Content []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
IsError bool `json:"isError"`
|
||||
}
|
||||
if err := json.Unmarshal(m.Result, &r); err != nil || len(r.Content) != 1 {
|
||||
return nil, fmt.Errorf("%s answered %s", name, m.Result)
|
||||
}
|
||||
if r.IsError {
|
||||
return nil, errors.New(r.Content[0].Text)
|
||||
}
|
||||
return json.RawMessage(r.Content[0].Text), nil
|
||||
case <-time.After(60 * time.Second):
|
||||
return nil, fmt.Errorf("%s did not answer in 60s", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 1952/SC1: one run of the self-check whole ends with checked and a verdict, its findings on the bus.
|
||||
func TestTheSelfCheckWholeRunsOnTheBus(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, Module)
|
||||
build := exec.Command("go", "build", "-o", bin, ".")
|
||||
build.Stderr = os.Stderr
|
||||
if err := build.Run(); err != nil {
|
||||
t.Fatalf("the self-check does not build: %v", err)
|
||||
}
|
||||
nc, err := nats.Connect(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{FindingsState, VerdictState, VerdictsState} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Module + "_" + s}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
checked, err := nc.SubscribeSync("mesh.mod." + Module + ".event." + CheckedEvent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, bin)
|
||||
cmd.Env = append(os.Environ(), "MESH_NODE=ace", "MESH_MODULE="+Module)
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = stdin.Close(); cancel(); _ = cmd.Wait() })
|
||||
p := &player{ctx: ctx, nc: nc, js: js, in: stdin, pending: map[string]chan wire{}}
|
||||
go p.serve(stdout)
|
||||
|
||||
raw, err := p.call(DoctorTool, map[string]any{"run": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var answer struct {
|
||||
Run Run `json:"run"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verdicts := map[string]string{}
|
||||
for _, v := range answer.Run.Probes {
|
||||
verdicts[v.ID] = v.Verdict
|
||||
}
|
||||
if verdicts["D7"] != VerdictFail || verdicts["DW"] != VerdictFail || verdicts["D15"] != VerdictFailedToRun ||
|
||||
verdicts["D2"] != VerdictDeferred || answer.Run.Controller != "ace" || !strings.Contains(answer.Run.Why, "person.jochen") {
|
||||
t.Errorf("the run answered %+v", answer.Run)
|
||||
}
|
||||
msg, err := checked.NextMsg(10 * time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("checked was not said: %v", err)
|
||||
}
|
||||
var said Run
|
||||
if err := json.Unmarshal(msg.Data, &said); err != nil || said.Run != answer.Run.Run {
|
||||
t.Errorf("checked said %s", msg.Data)
|
||||
}
|
||||
findings, _ := js.KeyValue(ctx, Module+"_"+FindingsState)
|
||||
e, err := findings.Get(ctx, "bus.EVENTS.stream-missing")
|
||||
if err != nil {
|
||||
t.Fatalf("the missing stream is not a finding on the bus: %v", err)
|
||||
}
|
||||
var f Finding
|
||||
if err := json.Unmarshal(e.Value(), &f); err != nil || f.Kind != "stream-wrong" || f.Severity != Urgent || f.Source != "D7" {
|
||||
t.Errorf("the finding is %s", e.Value())
|
||||
}
|
||||
verdict, _ := js.KeyValue(ctx, Module+"_"+VerdictState)
|
||||
if _, err := verdict.Get(ctx, LastKey); err != nil {
|
||||
t.Errorf("the last verdict is not kept: %v", err)
|
||||
}
|
||||
if raw, err := p.call(VerdictsTool, map[string]any{}); err != nil || !strings.Contains(string(raw), answer.Run.Run) {
|
||||
t.Errorf("the week's verdicts answered %s, %v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module mesh-self-check
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029200316-22c9ddd32b6a
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029200316-22c9ddd32b6a h1:nhRFmtdcI4VkghGUdJhpt1BnPuTQcekioOIRzW8fcG0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029200316-22c9ddd32b6a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"module": "mesh-self-check",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-self-check",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-self-check",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"doctor",
|
||||
"probes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"checked"
|
||||
],
|
||||
"invokes": [
|
||||
"nats.nats_streams",
|
||||
"nats.nats_closed_connections",
|
||||
"seat:mesh-watchdogs.signals"
|
||||
],
|
||||
"state": [
|
||||
"findings",
|
||||
"verdict",
|
||||
{
|
||||
"name": "verdicts",
|
||||
"ttl-seconds": 604800
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-self-check",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-self-check",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-self-check"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-self-check",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-self-check",
|
||||
"binary": "mesh-self-check",
|
||||
"loads": [
|
||||
"mesh-self-check"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user