// 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" "strings" "time" seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-sdk/go/keys" ) 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 := 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 := 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) } var heads12 = regexp.MustCompile(`^[0-9a-f]{12}$`) // CompositionKey is the key of a candidate the node is asked to compose: the group's check at its heads, `.`, the group escaped as a key part and the heads hash 12 lowercase hex digits (the checker's verdict // key of the same check). func CompositionKey(group, headsHash string) (string, error) { if group == "" { return "", errors.New("node-engine: the group name is empty") } if !heads12.MatchString(headsHash) { return "", fmt.Errorf("node-engine: %q is not a heads hash, 12 lowercase hex digits", headsHash) } return keys.EscapeName(group) + "." + headsHash, nil } // ParseCompositionKey is the inverse of CompositionKey; it refuses a key that is not one, naming it. func ParseCompositionKey(key string) (group, headsHash string, err error) { i := strings.LastIndex(key, ".") if i < 0 { return "", "", fmt.Errorf("node-engine: %q is not a composition key, .", key) } part, headsHash := key[:i], key[i+1:] if !heads12.MatchString(headsHash) { return "", "", fmt.Errorf("node-engine: %q is not a composition key: %q is not a heads hash, 12 lowercase hex digits", key, headsHash) } if part == "" || strings.Contains(part, ".") { return "", "", fmt.Errorf("node-engine: %q is not a composition key: the group part is empty or holds a dot", key) } if group, err = keys.UnescapeName(part); err != nil { return "", "", fmt.Errorf("node-engine: %q is not a composition key: %w", key, err) } return group, headsHash, nil }