package delivery import ( "bytes" "encoding/json" "fmt" "time" ) // A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb // compose-candidate, served by each node's engine alone, composes a check's candidate in shadow with the node's own // sealed values and the indexer's manifest verdicts, applies and writes nothing, and answers within the asker's bound. // The checker asks every placed node in parallel and makes the check's composition verdict from the answers; a node // that does not answer within the bound is named "not composed on that node: no answer" and the check passes with that // warning (§9). This is the contract only: the composing is the node-engine's own. const ( // EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat). EngineComposeCandidateVerb = "compose-candidate" // ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's // answer (ADR 0593 §7, §9). ComposeAnswerBound = 60 * time.Second // ComposeNoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9). ComposeNoAnswer = "not composed on that node: no answer" // ComposeCandidateComposed and ComposeCandidateRefused 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, Refused). ComposeCandidateComposed = "composed" ComposeCandidateRefused = "refused" // ComposeCandidateUnready 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 // (ComposeNoAnswer, ADR 0593 §9), deciding by this verdict and never by the words. ComposeCandidateUnready = "unready" // 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. ModuleComposedState = "composed" ModuleRefusedState = "refused" ModuleHeldState = "held" ) // ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey, // `.`); the engine reads that candidate itself and lays its modules over its placement. // Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the // catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer // runs over, for a group that changes the node-engine's composition code (ADR 0593 §7). type ComposeCandidateAsk struct { Candidate string `json:"candidate"` Input bool `json:"input,omitempty"` } // ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node // sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at // all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the engine took in milliseconds, and, when asked // (ComposeCandidateAsk.Input), the composition input it composed from. type ComposeCandidateAnswer struct { V int `json:"v"` Node string `json:"node"` Candidate string `json:"candidate"` Verdict string `json:"verdict"` Modules []ModuleComposed `json:"modules"` Own ModuleComposed `json:"own"` Refused string `json:"refused,omitempty"` Took int64 `json:"took"` Input json.RawMessage `json:"input,omitempty"` } // ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the candidate's // FP2 for a module the candidate touches, else its newest done build as composed- holds it), its state, the // composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one composed without // it (the checker's "what the change reaches on this node"), and why, for a refused or held one. type ModuleComposed struct { Module string `json:"module"` FP string `json:"fp,omitempty"` State string `json:"state"` Digest string `json:"digest,omitempty"` Changed bool `json:"changed,omitempty"` Words string `json:"words,omitempty"` } func (a ComposeCandidateAsk) check() error { if _, _, err := ParseCompositionKey(a.Candidate); err != nil { return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err) } return nil } func (m ModuleComposed) check(node string) error { if m.Module == "" { return fmt.Errorf("delivery: a module's composition on %s names no module", node) } switch m.State { case ModuleComposedState: if m.Digest == "" { return fmt.Errorf("delivery: %s is composed on %s and names no digest", m.Module, node) } case ModuleRefusedState, ModuleHeldState: if m.Words == "" { return fmt.Errorf("delivery: %s is %s on %s and says no why", m.Module, m.State, node) } default: return fmt.Errorf("delivery: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State, ModuleComposedState, ModuleRefusedState, ModuleHeldState) } return nil } func (a ComposeCandidateAnswer) check() error { if err := checkName("node", a.Node); err != nil { return err } if _, _, err := ParseCompositionKey(a.Candidate); err != nil { return err } if a.Took < 0 { return fmt.Errorf("delivery: the answer of %s took %d ms", a.Node, a.Took) } if a.Verdict == ComposeCandidateUnready && a.Refused == "" { return fmt.Errorf("delivery: %s is not ready to compose and its answer says no why", a.Node) } if a.Refused != "" { if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused && a.Verdict != ComposeCandidateUnready { return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or is neither %s nor %s", a.Node, a.Refused, ComposeCandidateRefused, ComposeCandidateUnready) } return nil } refused := false for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) { if err := m.check(a.Node); err != nil { return err } if i > 1 && a.Modules[i-2].Module >= m.Module { return fmt.Errorf("delivery: 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 == ModuleRefusedState } switch { case a.Own.Module != nodeWord: return fmt.Errorf("delivery: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, nodeWord) case refused && a.Verdict != ComposeCandidateRefused: return fmt.Errorf("delivery: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict) case !refused && a.Verdict != ComposeCandidateComposed: return fmt.Errorf("delivery: %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() if err := d.Decode(v); err != nil { return fmt.Errorf("delivery: %s does not fit its shape: %w", what, err) } return nil } // EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key. func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) { if err := a.check(); err != nil { return nil, err } return json.Marshal(a) } // DecodeComposeCandidateAsk reads an ask strictly; it refuses an unknown key and what EncodeComposeCandidateAsk refuses. func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) { var a ComposeCandidateAsk if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { return ComposeCandidateAsk{}, err } return a, a.check() } // EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest, // a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give. func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) { if err := a.check(); err != nil { return nil, err } a.V = Version return json.Marshal(a) } // DecodeComposeCandidateAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown // key and what EncodeComposeCandidateAnswer refuses. func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) { if err := checkVersion(data); err != nil { return ComposeCandidateAnswer{}, err } var a ComposeCandidateAnswer if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { return ComposeCandidateAnswer{}, err } return a, a.check() }