Conditions client: Observation, Raise, Clear, Says, EncodeFinding and DecodeFinding raise and clear an observer's finding in the seat's layout, goldens from go/health's output (B5l; ADR 0634 §4; issue 1966)
Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package meshconditionsclient
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -109,3 +111,85 @@ func when(s string) time.Time {
|
||||
}
|
||||
|
||||
func ptr(t time.Time) *time.Time { return &t }
|
||||
|
||||
type fakeBucket struct {
|
||||
kept map[string][]byte
|
||||
puts, dels int
|
||||
}
|
||||
|
||||
func (b *fakeBucket) Get(key string) ([]byte, bool, error) { v, ok := b.kept[key]; return v, ok, nil }
|
||||
func (b *fakeBucket) Put(key string, v []byte) error {
|
||||
if b.kept == nil {
|
||||
b.kept = map[string][]byte{}
|
||||
}
|
||||
b.kept[key] = v
|
||||
b.puts++
|
||||
return nil
|
||||
}
|
||||
func (b *fakeBucket) Delete(key string) error { delete(b.kept, key); b.dels++; return nil }
|
||||
|
||||
// The goldens are what mesh-sdk's go/health EncodeFinding wrote for the same findings: the helpers write the same
|
||||
// values, read by the seat's strict decoder.
|
||||
func TestAnObservationIsWrittenAsMeshSDKsHealthWroteItsFinding(t *testing.T) {
|
||||
at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
for _, c := range []struct {
|
||||
golden string
|
||||
o Observation
|
||||
run string
|
||||
}{
|
||||
{"finding_full.json", Observation{Scope: ScopeMachine, ID: "g14", Kind: "silent-kind", Ref: "silent", Machine: "g14", Also: []string{"ace"},
|
||||
Severity: Urgent, Summary: "g14 is silent", Said: "no pulse", Headline: "g14 is silent", Explanation: "It said nothing.",
|
||||
Resolved: "It speaks.", Needs: "look", Source: "S1", Resolver: "operator"}, "r1"},
|
||||
{"finding_minimal.json", Observation{Scope: ScopeModule, ID: "nats.g14", Kind: "build-failed", Summary: "failed",
|
||||
Source: "builder.build-failed"}, ""},
|
||||
} {
|
||||
raw, err := os.ReadFile("testdata/" + c.golden)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := seat.DecodeFindingsByKeyValue(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.golden, err)
|
||||
}
|
||||
observer := want.Observer
|
||||
got, err := EncodeFinding(c.o.Finding(observer, c.run, at))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.golden, err)
|
||||
}
|
||||
have, err := seat.DecodeFindingsByKeyValue(got)
|
||||
if err != nil || !reflect.DeepEqual(have, want) {
|
||||
t.Errorf("%s: wrote %s, want %s (%v)", c.golden, got, raw, err)
|
||||
}
|
||||
if back, err := DecodeFinding(raw); err != nil || !reflect.DeepEqual(back, want) {
|
||||
t.Errorf("%s: read back %v", c.golden, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRaiseWritesWhenTheWordsChangeAndClearDeletesWhatIsThere(t *testing.T) {
|
||||
b := &fakeBucket{}
|
||||
o := Observation{Scope: ScopeModule, ID: "m", Kind: "k", Summary: "one", Source: "x.k"}
|
||||
at := when("2026-11-05T12:00:00Z")
|
||||
if w, err := Raise(b, "obs", "", o, at); err != nil || !w {
|
||||
t.Fatalf("first: %v %v", w, err)
|
||||
}
|
||||
if w, err := Raise(b, "obs", "r2", o, at.Add(time.Hour)); err != nil || w || b.puts != 1 {
|
||||
t.Fatalf("the same words later: wrote %v, %d puts, %v", w, b.puts, err)
|
||||
}
|
||||
o.Summary = "two"
|
||||
if w, err := Raise(b, "obs", "", o, at); err != nil || !w || b.puts != 2 {
|
||||
t.Fatalf("changed: %v %d %v", w, b.puts, err)
|
||||
}
|
||||
if _, err := Raise(b, "obs", "", Observation{Scope: "moon", ID: "m", Kind: "k"}, at); err == nil || b.puts != 2 {
|
||||
t.Errorf("a finding the keeper cannot use was written: %v", err)
|
||||
}
|
||||
if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || !c || b.dels != 1 {
|
||||
t.Fatalf("clear: %v %v %d", c, err, b.dels)
|
||||
}
|
||||
if c, err := Clear(b, ScopeModule, "m", "k"); err != nil || c || b.dels != 1 {
|
||||
t.Errorf("clearing what is not there: %v %v %d", c, err, b.dels)
|
||||
}
|
||||
if FindingsBucket("obs") != "obs_findings" {
|
||||
t.Error(FindingsBucket("obs"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package meshconditionsclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
)
|
||||
|
||||
// What an observer says it sees wrong, raised and cleared as a finding in its own state `findings` (novox/hq ADR 0564
|
||||
// clause 2, ADR 0634 §4): one write shared by every observer (the self-check, the healers, the watchman, the builder,
|
||||
// the indexer, the node-engine), in this seat's layout. The condition keeper raises and clears the conditions from them.
|
||||
|
||||
// FindingsState is the name of an observer's state of findings.
|
||||
const FindingsState = "findings"
|
||||
|
||||
// FindingsBucket is the bucket of an observer's findings, `<module>_findings`.
|
||||
func FindingsBucket(observer string) string { return observer + "_" + FindingsState }
|
||||
|
||||
// Observation is one thing an observer sees wrong now: what it is about (Scope, ID, Machine, Also), which kind of fault
|
||||
// (Kind; Ref when the key's last part is not the kind), and its words.
|
||||
type Observation struct {
|
||||
Scope, ID, Kind, Ref, Machine string
|
||||
Also []string
|
||||
// Severity is Urgent or Warning; Warning when empty.
|
||||
Severity string
|
||||
// Summary is one line; Said the one observation kept as evidence (the summary when empty).
|
||||
Summary, Said string
|
||||
Headline, Explanation, Resolved, Needs string
|
||||
// Source is the row, probe or event that saw it.
|
||||
Source string
|
||||
// Resolver is who resolves it, self when empty.
|
||||
Resolver string
|
||||
Actions []seat.Action
|
||||
// Since is when the finding is stamped; the zero value leaves it to the caller's moment.
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
// Key is the finding's key: `<scope>.<id>.<token>`, the token Ref or else Kind.
|
||||
func (o Observation) Key() string {
|
||||
token := o.Ref
|
||||
if token == "" {
|
||||
token = o.Kind
|
||||
}
|
||||
return Key(o.Scope, o.ID, token)
|
||||
}
|
||||
|
||||
// Finding is the observation as the seat's finding written by observer in run, at the moment at.
|
||||
func (o Observation) Finding(observer, run string, at time.Time) seat.FindingsByKeyValue {
|
||||
at = at.UTC()
|
||||
said := o.Said
|
||||
if said == "" {
|
||||
said = o.Summary
|
||||
}
|
||||
sev := o.Severity
|
||||
if sev == "" {
|
||||
sev = Warning
|
||||
}
|
||||
f := seat.FindingsByKeyValue{V: FindingVersion, Key: o.Key(), Kind: o.Kind,
|
||||
Subject: seat.Subject{Scope: o.Scope, ID: o.ID, Machine: o.Machine, Also: o.Also},
|
||||
Severity: sev, Summary: o.Summary, Headline: o.Headline, Explanation: o.Explanation, Resolved: o.Resolved,
|
||||
Needs: o.Needs, Actions: o.Actions, Evidence: []seat.Evidence{{At: at, Said: said}}, Source: o.Source,
|
||||
Resolver: o.Resolver, Observer: observer, Run: run, At: at}
|
||||
if o.Ref != "" && o.Ref != o.Kind {
|
||||
f.Ref = o.Ref
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// EncodeFinding is f as JSON at FindingVersion; it refuses what CheckFinding refuses.
|
||||
func EncodeFinding(f seat.FindingsByKeyValue) ([]byte, error) {
|
||||
f.V = FindingVersion
|
||||
if f.Evidence == nil {
|
||||
f.Evidence = []seat.Evidence{}
|
||||
}
|
||||
if err := CheckFinding(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(f)
|
||||
}
|
||||
|
||||
// DecodeFinding reads a finding's value; it refuses another version and what CheckFinding refuses. A finding written
|
||||
// before the field was named `ref` (novox/hq issue 1952) is read by its old name, `token`.
|
||||
func DecodeFinding(data []byte) (seat.FindingsByKeyValue, error) {
|
||||
f, err := seat.DecodeFindingsByKeyValue(data)
|
||||
if err != nil {
|
||||
return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding is not readable: %w", err)
|
||||
}
|
||||
if f.V != FindingVersion {
|
||||
return seat.FindingsByKeyValue{}, fmt.Errorf("health: a finding of version %d, and this reads %d", f.V, FindingVersion)
|
||||
}
|
||||
if err := CheckFinding(f); err != nil {
|
||||
return seat.FindingsByKeyValue{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Says is what a finding says, the moments and the run that wrote it aside: two findings that say the same are one
|
||||
// write (novox/hq ADR 0289 §1), so the condition moves only when the words do.
|
||||
func Says(f seat.FindingsByKeyValue) string {
|
||||
f.At, f.Run, f.V = time.Time{}, "", 0
|
||||
ev := make([]seat.Evidence, len(f.Evidence))
|
||||
for i, e := range f.Evidence {
|
||||
ev[i] = seat.Evidence{Said: e.Said}
|
||||
}
|
||||
f.Evidence = ev
|
||||
raw, _ := json.Marshal(f)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// FindingBucket is the part of an observer's state `findings` that Raise and Clear use.
|
||||
type FindingBucket interface {
|
||||
// Get answers the value at key; found is false when there is none.
|
||||
Get(key string) (value []byte, found bool, err error)
|
||||
Put(key string, value []byte) error
|
||||
Delete(key string) error
|
||||
}
|
||||
|
||||
// Raise writes the observation as observer's finding in run at at, only when what it says differs from the finding kept
|
||||
// (so the keeper's condition moves only then); wrote says whether it wrote. A finding the keeper could not make a
|
||||
// condition of is refused, nothing written.
|
||||
func Raise(b FindingBucket, observer, run string, o Observation, at time.Time) (wrote bool, err error) {
|
||||
f := o.Finding(observer, run, at)
|
||||
value, err := EncodeFinding(f)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if kept, found, err := b.Get(f.Key); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
if old, err := DecodeFinding(kept); err == nil && Says(old) == Says(f) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, b.Put(f.Key, value)
|
||||
}
|
||||
|
||||
// Clear deletes the finding about scope and id of kind (its token); one not there is nothing to clear, and cleared says
|
||||
// whether one was.
|
||||
func Clear(b FindingBucket, scope, id, token string) (cleared bool, err error) {
|
||||
key := Key(scope, id, token)
|
||||
if _, found, err := b.Get(key); err != nil || !found {
|
||||
return false, err
|
||||
}
|
||||
return true, b.Delete(key)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"v":1,"key":"machine.g14.silent","kind":"silent-kind","ref":"silent","subject":{"scope":"machine","id":"g14","machine":"g14","also":["ace"]},"severity":"urgent","summary":"g14 is silent","headline":"g14 is silent","explanation":"It said nothing.","resolved":"It speaks.","needs":"look","evidence":[{"at":"2026-11-05T12:00:00Z","said":"no pulse"}],"source":"S1","resolver":"operator","observer":"mesh-delivery-watchman","run":"r1","at":"2026-11-05T12:00:00Z"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"v":1,"key":"module.nats.g14.build-failed","kind":"build-failed","subject":{"scope":"module","id":"nats.g14"},"severity":"warning","summary":"failed","headline":"","explanation":"","resolved":"","evidence":[{"at":"2026-11-05T12:00:00Z","said":"failed"}],"source":"builder.build-failed","observer":"mesh-delivery-builder","at":"2026-11-05T12:00:00Z"}
|
||||
Reference in New Issue
Block a user