ADR 0627 §1: node-engine's client holds the checks of compose-candidate's ask and answer
The checks the node-engine, the checker and the assigner share over the verb (ADR 0593 §7, §9) leave mesh-sdk's delivery package, where they sat over aliases of the seat's types, for go/clients/nodeengineclient, a Go module and package artifact of its own over the seat's generated contract: the verdicts, the module states, the asker's bound, the no-answer words, Encode/DecodeAsk, Encode/DecodeAnswer and CheckAnswer. Its test holds the cases mesh-sdk's held, and that an open input's number keeps its digits; each fails without the code. seatgen -check finds nothing out of step. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// Package nodeengineclient is the node-engine seat's client (novox/hq ADR 0627 §1, ADR 0625 §4): the checks its callers
|
||||
// share over the verb compose-candidate's ask and answer (ADR 0593 §7, §9), over the seat's generated contract. The
|
||||
// node-engine encodes its answer through it, and the checker and the assigner read answers and the asker's bound
|
||||
// through it. The composing is the node-engine's own; the call is the caller's own, so it holds no transport.
|
||||
package nodeengineclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const (
|
||||
// AnswerBound is the default bound an asker of compose-candidate waits for a node's answer (the checker's setting
|
||||
// compose-answer-bound, ADR 0593 §7, §9).
|
||||
AnswerBound = 60 * time.Second
|
||||
// NoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9).
|
||||
NoAnswer = "not composed on that node: no answer"
|
||||
|
||||
// Composed and Refused are an answer's verdict: no module and not the node's own composition is refused by the
|
||||
// candidate, or at least one is (or the node could not compose with it at all, the answer's Refused).
|
||||
Composed = "composed"
|
||||
Refused = "refused"
|
||||
// Unready is the verdict of a node that cannot compose yet (its shadow not ready, its agent starting): no module is
|
||||
// named, Refused says why in words, and the checker takes it as a node that did not answer (NoAnswer), deciding by
|
||||
// this verdict and never by the words.
|
||||
Unready = "unready"
|
||||
// NotJudged is the verdict for a node the checker's sandbox composed and could not judge (an older node-engine's
|
||||
// input that does not say how it composes, ADR 0593 §9): no module is named, Refused says why in words, and the
|
||||
// checker names the node with them and passes the check with that warning (ADR 0601 §4: a contract is a field, not
|
||||
// a phrase).
|
||||
NotJudged = "not-judged"
|
||||
|
||||
// The states of one module's composition with the candidate: composed (Digest set), refused by the candidate
|
||||
// (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict.
|
||||
ModuleComposed = "composed"
|
||||
ModuleRefused = "refused"
|
||||
ModuleHeld = "held"
|
||||
|
||||
// Own is the Module name of the node's own composition in an answer.
|
||||
Own = "node"
|
||||
|
||||
// Version is the version an answer is written at and the only one read.
|
||||
Version = 1
|
||||
)
|
||||
|
||||
// ErrUnknownVersion is what DecodeAnswer returns for an answer whose version is missing or not Version.
|
||||
var ErrUnknownVersion = errors.New("node-engine: unknown compose-candidate answer version")
|
||||
|
||||
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
|
||||
|
||||
func checkAsk(a seat.ComposeCandidateInput) error {
|
||||
if _, _, err := delivery.ParseCompositionKey(a.Candidate); err != nil {
|
||||
return fmt.Errorf("node-engine: a compose-candidate ask names no candidate: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkModule(m seat.Module, node string) error {
|
||||
if m.Module == "" {
|
||||
return fmt.Errorf("node-engine: a module's composition on %s names no module", node)
|
||||
}
|
||||
switch m.State {
|
||||
case ModuleComposed:
|
||||
if m.Digest == "" {
|
||||
return fmt.Errorf("node-engine: %s is composed on %s and names no digest", m.Module, node)
|
||||
}
|
||||
case ModuleRefused, ModuleHeld:
|
||||
if m.Words == "" {
|
||||
return fmt.Errorf("node-engine: %s is %s on %s and says no why", m.Module, m.State, node)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("node-engine: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State,
|
||||
ModuleComposed, ModuleRefused, ModuleHeld)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckAnswer refuses an answer naming no node or candidate, a negative time taken, an unknown module state, a composed
|
||||
// module without a digest, a refused or held one without words, modules not sorted and each once, a refusal of the
|
||||
// whole naming modules, an unready or not-judged one saying no why, and a verdict the modules do not give.
|
||||
func CheckAnswer(a seat.ComposeCandidateAnswer) error {
|
||||
if a.Node == "" || !nameOK.MatchString(a.Node) {
|
||||
return fmt.Errorf("node-engine: the answer's node name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", a.Node)
|
||||
}
|
||||
if _, _, err := delivery.ParseCompositionKey(a.Candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Took < 0 {
|
||||
return fmt.Errorf("node-engine: the answer of %s took %d ms", a.Node, a.Took)
|
||||
}
|
||||
if (a.Verdict == Unready || a.Verdict == NotJudged) && a.Refused == "" {
|
||||
return fmt.Errorf("node-engine: %s's answer is %s and says no why", a.Node, a.Verdict)
|
||||
}
|
||||
if a.Refused != "" {
|
||||
if len(a.Modules) > 0 || a.Verdict != Refused && a.Verdict != Unready && a.Verdict != NotJudged {
|
||||
return fmt.Errorf("node-engine: %s could not compose the candidate (%s), and its answer names modules or is neither %s, %s nor %s",
|
||||
a.Node, a.Refused, Refused, Unready, NotJudged)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
refused := false
|
||||
for i, m := range append([]seat.Module{a.Own}, a.Modules...) {
|
||||
if err := checkModule(m, a.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if i > 1 && a.Modules[i-2].Module >= m.Module {
|
||||
return fmt.Errorf("node-engine: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module)
|
||||
}
|
||||
refused = refused || m.State == ModuleRefused
|
||||
}
|
||||
switch {
|
||||
case a.Own.Module != Own:
|
||||
return fmt.Errorf("node-engine: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, Own)
|
||||
case refused && a.Verdict != Refused:
|
||||
return fmt.Errorf("node-engine: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict)
|
||||
case !refused && a.Verdict != Composed:
|
||||
return fmt.Errorf("node-engine: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strictDecode reads data into v, refusing an unknown key.
|
||||
func strictDecode(data []byte, v any, what string) error {
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.DisallowUnknownFields()
|
||||
d.UseNumber() // a number in an object the contract leaves open (the composition input) keeps its digits as written
|
||||
if err := d.Decode(v); err != nil {
|
||||
return fmt.Errorf("node-engine: %s does not fit its shape: %w", what, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeAsk is a as JSON; it refuses an ask naming no candidate key.
|
||||
func EncodeAsk(a seat.ComposeCandidateInput) ([]byte, error) {
|
||||
if err := checkAsk(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeAsk reads an ask strictly; it refuses an unknown key and what EncodeAsk refuses.
|
||||
func DecodeAsk(data []byte) (seat.ComposeCandidateInput, error) {
|
||||
var a seat.ComposeCandidateInput
|
||||
if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil {
|
||||
return seat.ComposeCandidateInput{}, err
|
||||
}
|
||||
return a, checkAsk(a)
|
||||
}
|
||||
|
||||
// EncodeAnswer is a as JSON at Version; it refuses what CheckAnswer refuses.
|
||||
func EncodeAnswer(a seat.ComposeCandidateAnswer) ([]byte, error) {
|
||||
if err := CheckAnswer(a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.V = Version
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// DecodeAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown key and what
|
||||
// CheckAnswer refuses.
|
||||
func DecodeAnswer(data []byte) (seat.ComposeCandidateAnswer, error) {
|
||||
var probe struct {
|
||||
V *int64 `json:"v"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return seat.ComposeCandidateAnswer{}, fmt.Errorf("node-engine: a compose-candidate answer is not JSON: %w", err)
|
||||
}
|
||||
if probe.V == nil || *probe.V != Version {
|
||||
return seat.ComposeCandidateAnswer{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version)
|
||||
}
|
||||
var a seat.ComposeCandidateAnswer
|
||||
if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil {
|
||||
return seat.ComposeCandidateAnswer{}, err
|
||||
}
|
||||
return a, CheckAnswer(a)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package nodeengineclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The verb compose-candidate's ask and answer (ADR 0593 §7, §9): each round-trips, and a reader refuses what it could
|
||||
// not take, naming it.
|
||||
func TestComposeCandidateRoundTripsAndRefuses(t *testing.T) {
|
||||
key, err := delivery.CompositionKey("mesh-sdk#7", "0123456789ab")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: key, Input: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a, err := DecodeAsk(raw); err != nil || a.Candidate != key || !a.Input {
|
||||
t.Fatalf("ask round trip: %v, %+v", err, a)
|
||||
}
|
||||
if _, err := DecodeAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil {
|
||||
t.Error("an ask with an unknown key was read")
|
||||
}
|
||||
if _, err := EncodeAsk(seat.ComposeCandidateInput{Candidate: "nope"}); err == nil {
|
||||
t.Error("an ask naming no candidate key was encoded")
|
||||
}
|
||||
|
||||
good := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Took: 812,
|
||||
Own: seat.Module{Module: Own, State: ModuleComposed, Digest: "sha256:00"},
|
||||
Modules: []seat.Module{
|
||||
{Module: "netcheck", Fp: "src2:x", State: ModuleComposed, Digest: "sha256:01", Changed: true},
|
||||
{Module: "route-proxy", State: ModuleRefused, Words: "the setting domain has no value"},
|
||||
{Module: "zz", State: ModuleHeld, Words: "held before"},
|
||||
}, Input: map[string]any{"machines": []any{"ace"}}}
|
||||
raw, err = EncodeAnswer(good)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := DecodeAnswer(raw)
|
||||
if err != nil || back.Verdict != Refused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) {
|
||||
t.Fatalf("answer round trip: %v, %+v", err, back)
|
||||
}
|
||||
if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil {
|
||||
t.Error("an answer with an unknown key was read")
|
||||
}
|
||||
if _, err := DecodeAnswer([]byte(strings.Replace(string(raw), `"v":1`, `"v":2`, 1))); !errors.Is(err, ErrUnknownVersion) {
|
||||
t.Errorf("an answer at another version read as %v", err)
|
||||
}
|
||||
numbered := strings.Replace(string(raw), `"machines":["ace"]`, `"bound":12345678901234567890`, 1)
|
||||
if back, err := DecodeAnswer([]byte(numbered)); err != nil || fmt.Sprint(back.Input["bound"]) != "12345678901234567890" {
|
||||
t.Errorf("an open input's number did not keep its digits: %v, %+v", err, back.Input)
|
||||
}
|
||||
|
||||
whole := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Refused, Refused: "the bound passed"}
|
||||
unready := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: Unready, Refused: "the agent is starting"}
|
||||
notJudged := seat.ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: NotJudged, Refused: "its node-engine sends no running state"}
|
||||
for _, a := range []seat.ComposeCandidateAnswer{whole, unready, notJudged} {
|
||||
if raw, err := EncodeAnswer(a); err != nil {
|
||||
t.Errorf("a %s answer was not encoded: %v", a.Verdict, err)
|
||||
} else if back, err := DecodeAnswer(raw); err != nil || back.Verdict != a.Verdict {
|
||||
t.Errorf("a %s answer read back as %+v, %v", a.Verdict, back, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, c := range map[string]struct {
|
||||
from seat.ComposeCandidateAnswer
|
||||
mutate func(a *seat.ComposeCandidateAnswer)
|
||||
}{
|
||||
"verdict not the modules'": {good, func(a *seat.ComposeCandidateAnswer) { a.Verdict = Composed }},
|
||||
"unknown state": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].State = "fine" }},
|
||||
"composed without digest": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0].Digest = "" }},
|
||||
"refused without words": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[1].Words = "" }},
|
||||
"unsorted": {good, func(a *seat.ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] }},
|
||||
"own misnamed": {good, func(a *seat.ComposeCandidateAnswer) { a.Own.Module = "ace" }},
|
||||
"whole refusal with modules": {good, func(a *seat.ComposeCandidateAnswer) { a.Refused = "the bound passed" }},
|
||||
"node with a dot": {good, func(a *seat.ComposeCandidateAnswer) { a.Node = "a.b" }},
|
||||
"negative took": {good, func(a *seat.ComposeCandidateAnswer) { a.Took = -1 }},
|
||||
"unready saying no why": {unready, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }},
|
||||
"unready naming modules": {unready, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }},
|
||||
"not judged saying no why": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Refused = "" }},
|
||||
"not judged naming modules": {notJudged, func(a *seat.ComposeCandidateAnswer) { a.Modules = good.Modules }},
|
||||
} {
|
||||
a := c.from
|
||||
a.Modules = append([]seat.Module(nil), c.from.Modules...)
|
||||
c.mutate(&a)
|
||||
if _, err := EncodeAnswer(a); err == nil {
|
||||
t.Errorf("%s: encoded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient
|
||||
|
||||
go 1.26.0
|
||||
Reference in New Issue
Block a user