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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user