node-engine client: the codec and the comparison of the have and the node's facts, and the phases and healths a have names (ADR 0634 §1, §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:14:16 +01:00
parent 181441e91b
commit db2a6da471
79 changed files with 313 additions and 6248 deletions
+206
View File
@@ -0,0 +1,206 @@
package nodeengineclient
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The state have (novox/hq ADR 0539 clause 9): a module's have on a node under `<node>.<module>` and the node's own facts
// under `<node>.node`; their layouts are the seat's contract (seat.HaveByNodeModuleValue, seat.HaveByNodeNodeValue). This
// file is the codec the node-engine, the assigner, the watchman and every reader share: it writes the version, and refuses
// a phase, a health, a judgement or a digest outside the lists.
// The phases of a module's change on a node, as the have states them (the seat's constants, short).
const (
PhaseBackingUp = seat.HaveByNodeModuleValuePhaseBackingUp
PhaseApplying = seat.HaveByNodeModuleValuePhaseApplying
PhaseChecking = seat.HaveByNodeModuleValuePhaseChecking
PhaseRunning = seat.HaveByNodeModuleValuePhaseRunning
PhaseFailed = seat.HaveByNodeModuleValuePhaseFailed
PhaseRemoved = seat.HaveByNodeModuleValuePhaseRemoved
PhaseHeld = seat.HaveByNodeModuleValuePhaseHeld
PhaseWaitingYes = seat.HaveByNodeModuleValuePhaseWaitingYes
)
// The healths a have carries.
const (
HealthOK = seat.HaveByNodeModuleValueHealthOk
HealthFailed = seat.HaveByNodeModuleValueHealthFailed
HealthWaits = seat.HaveByNodeModuleValueHealthWaits
HealthUnknown = seat.HaveByNodeModuleValueHealthUnknown
)
// The judgements of a move that waits for a yes.
const (
JudgedRecreates = seat.HaveByNodeModuleValueJudgementRecreates
JudgedMovesData = seat.HaveByNodeModuleValueJudgementMovesData
JudgedCannot = seat.HaveByNodeModuleValueJudgementCannotJudge
)
// ComposesOwn is a node applying its own compositions (the node facts' composes).
const ComposesOwn = "own"
// composedByTheEndedController is what an engine from before the controller ended wrote in composes: read, never written.
const composedByTheEndedController = "controller"
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
func checkDigest(digest, of string) error {
if !digestForm.MatchString(digest) {
return fmt.Errorf("node-engine: %s %q is not sha256:<64 lowercase hex digits>", of, digest)
}
return nil
}
func checkHave(h seat.HaveByNodeModuleValue) error {
switch h.Phase {
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved, PhaseHeld:
if h.Judgement != "" || h.Waits != "" || h.BuildAlone {
return fmt.Errorf("node-engine: only a have waiting for a yes names a judgement, the composition waiting and whether it changes the build alone")
}
if h.PullWait != nil && h.Phase != PhaseFailed {
return fmt.Errorf("node-engine: only a failed have names the pull it waits on, not one %s", h.Phase)
}
case PhaseWaitingYes:
if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot {
return fmt.Errorf("node-engine: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement)
}
if err := checkDigest(h.Waits, "the composition a have waits a yes for"); err != nil {
return err
}
default:
return fmt.Errorf("node-engine: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase)
}
if w := h.PullWait; w != nil && (w.Image == "" || w.Holder == "") {
return fmt.Errorf("node-engine: a have's pull wait names no image or no holder")
}
if h.Composed != "" {
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
return err
}
}
switch h.Health {
case HealthOK, HealthFailed, HealthWaits, HealthUnknown:
default:
return fmt.Errorf("node-engine: health %q is not ok, failed, waits or unknown", h.Health)
}
return nil
}
// EncodeHave is h as JSON at the current version; it refuses a phase or health outside the lists.
func EncodeHave(h seat.HaveByNodeModuleValue) ([]byte, error) {
if err := checkHave(h); err != nil {
return nil, err
}
h.V = keys.Version
return json.Marshal(h)
}
// DecodeHave reads a have; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does not know and
// what EncodeHave refuses.
func DecodeHave(data []byte) (seat.HaveByNodeModuleValue, error) {
if err := keys.CheckVersion(data); err != nil {
return seat.HaveByNodeModuleValue{}, err
}
h, err := seat.DecodeHaveByNodeModuleValue(data)
if err != nil {
return seat.HaveByNodeModuleValue{}, fmt.Errorf("node-engine: value does not fit its shape: %w", err)
}
if err := checkHave(h); err != nil {
return seat.HaveByNodeModuleValue{}, err
}
return h, nil
}
func checkHaveNode(n seat.HaveByNodeNodeValue) error {
switch n.Composes {
case "", ComposesOwn, composedByTheEndedController:
default:
return fmt.Errorf("node-engine: a node composes %q, not %s", n.Composes, ComposesOwn)
}
for group, a := range n.Candidates {
if group == "" {
return fmt.Errorf("node-engine: a node's candidate answer names no group")
}
switch a.Verdict {
case seat.CandidateAnswerVerdictSame, seat.CandidateAnswerVerdictDiffers, seat.CandidateAnswerVerdictRefused:
default:
return fmt.Errorf("node-engine: the answer to %s's candidate has the verdict %q", group, a.Verdict)
}
}
return nil
}
// EncodeHaveNode is n as JSON at the current version; it refuses a candidate answer with a verdict outside the lists.
func EncodeHaveNode(n seat.HaveByNodeNodeValue) ([]byte, error) {
if err := checkHaveNode(n); err != nil {
return nil, err
}
n.V = keys.Version
return json.Marshal(n)
}
// DecodeHaveNode reads a node's facts; it refuses an unknown version (keys.ErrUnknownVersion), a field the contract does
// not know and what EncodeHaveNode refuses.
func DecodeHaveNode(data []byte) (seat.HaveByNodeNodeValue, error) {
if err := keys.CheckVersion(data); err != nil {
return seat.HaveByNodeNodeValue{}, err
}
n, err := seat.DecodeHaveByNodeNodeValue(data)
if err != nil {
return seat.HaveByNodeNodeValue{}, fmt.Errorf("node-engine: value does not fit its shape: %w", err)
}
if err := checkHaveNode(n); err != nil {
return seat.HaveByNodeNodeValue{}, err
}
return n, nil
}
// SameHave says whether two haves are the same value, so a writer writes only when they differ: times compare as
// instants, the user list's and the handed accounts' time as well.
func SameHave(a, b seat.HaveByNodeModuleValue) bool {
return sameJSON(utcHave(a), utcHave(b))
}
// SameHaveNode says whether two node facts are the same value, compared as their JSON (maps sorted, spacing dropped).
func SameHaveNode(a, b seat.HaveByNodeNodeValue) bool { return sameJSON(a, b) }
// SameNodeRecord says whether two records are the same value but for their version.
func SameNodeRecord(a, b seat.NodeRecord) bool {
a.V, b.V = 0, 0
return sameJSON(a, b)
}
func utcHave(h seat.HaveByNodeModuleValue) seat.HaveByNodeModuleValue {
if w := h.PullWait; w != nil {
c := *w
c.Began = c.Began.UTC()
if c.RetriedAt != nil {
t := c.RetriedAt.UTC()
c.RetriedAt = &t
}
h.PullWait = &c
}
if u := h.UserList; u != nil {
c := *u
c.At = c.At.UTC()
if c.Handed != nil {
hd := *c.Handed
hd.At = hd.At.UTC()
c.Handed = &hd
}
h.UserList = &c
}
return h
}
func sameJSON(a, b any) bool {
ra, errA := json.Marshal(a)
rb, errB := json.Marshal(b)
return errA == nil && errB == nil && bytes.Equal(ra, rb)
}
+107
View File
@@ -0,0 +1,107 @@
package nodeengineclient
import (
"errors"
"strings"
"testing"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/keys"
)
const digestA = "sha256:0000000000000000000000000000000000000000000000000000000000000001"
func TestHaveCodec(t *testing.T) {
good := seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Composed: digestA, Fp: "src2:x"}
waiting := seat.HaveByNodeModuleValue{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: digestA}
pull := &seat.PullWait{Image: "i", Holder: "h"}
tests := map[string]struct {
have seat.HaveByNodeModuleValue
bad string
}{
"running": {good, ""},
"waiting for a yes": {waiting, ""},
"unknown phase": {seat.HaveByNodeModuleValue{Phase: "gone", Health: HealthOK}, "phase"},
"unknown health": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: "fine"}, "health"},
"judgement outside a yes": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedCannot}, "judgement"},
"a yes without a digest": {seat.HaveByNodeModuleValue{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedCannot, Waits: "x"}, "sha256"},
"pull wait on a running": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, PullWait: pull}, "pull"},
"pull wait without holder": {seat.HaveByNodeModuleValue{Phase: PhaseFailed, Health: HealthFailed, PullWait: &seat.PullWait{Image: "i"}}, "holder"},
"bad composed digest": {seat.HaveByNodeModuleValue{Phase: PhaseRunning, Health: HealthOK, Composed: "abc"}, "sha256"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
raw, err := EncodeHave(tc.have)
if tc.bad != "" {
if err == nil || !strings.Contains(err.Error(), tc.bad) {
t.Fatalf("encode: %v, want it to name %q", err, tc.bad)
}
return
}
if err != nil {
t.Fatal(err)
}
back, err := DecodeHave(raw)
if err != nil {
t.Fatal(err)
}
tc.have.V = keys.Version
if !SameHave(back, tc.have) {
t.Fatalf("round trip differs: %+v", back)
}
})
}
if _, err := DecodeHave([]byte(`{"v":2,"phase":"running","health":"ok"}`)); !errors.Is(err, keys.ErrUnknownVersion) {
t.Fatalf("version 2: %v", err)
}
if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"ok","extra":1}`)); err == nil {
t.Fatal("an unknown field is read")
}
}
func TestSameHaveComparesTimesAsInstants(t *testing.T) {
at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)
zone := time.FixedZone("x", 3600)
mk := func(tm time.Time) seat.HaveByNodeModuleValue {
return seat.HaveByNodeModuleValue{Phase: PhaseFailed, Health: HealthFailed,
PullWait: &seat.PullWait{Image: "i", Holder: "h", Began: tm},
UserList: &seat.UserListWritten{Source: "s", At: tm}}
}
if !SameHave(mk(at), mk(at.In(zone))) {
t.Fatal("the same instant in another zone differs")
}
if SameHave(mk(at), mk(at.Add(time.Second))) {
t.Fatal("another instant is the same")
}
}
func TestHaveNodeCodec(t *testing.T) {
n := seat.HaveByNodeNodeValue{Composed: digestA, Composes: ComposesOwn,
Candidates: map[string]seat.CandidateAnswer{"g": {Proposal: "p", Verdict: seat.CandidateAnswerVerdictSame}}}
raw, err := EncodeHaveNode(n)
if err != nil {
t.Fatal(err)
}
back, err := DecodeHaveNode(raw)
if err != nil {
t.Fatal(err)
}
n.V = keys.Version
if !SameHaveNode(back, n) {
t.Fatalf("round trip differs: %+v", back)
}
for name, bad := range map[string]seat.HaveByNodeNodeValue{
"composes": {Composes: "other"},
"no group": {Candidates: map[string]seat.CandidateAnswer{"": {Verdict: "same"}}},
"a verdict": {Candidates: map[string]seat.CandidateAnswer{"g": {Verdict: "maybe"}}},
} {
if _, err := EncodeHaveNode(bad); err == nil {
t.Errorf("%s: encoded", name)
}
}
a, b := seat.NodeRecord{Node: "n", V: 1}, seat.NodeRecord{Node: "n", V: 2}
if !SameNodeRecord(a, b) {
t.Error("a record differs by its version")
}
}