diff --git a/go/catalogue/engine_compose_candidate_test.go b/go/catalogue/engine_compose_candidate_test.go index fb34000..b2c3797 100644 --- a/go/catalogue/engine_compose_candidate_test.go +++ b/go/catalogue/engine_compose_candidate_test.go @@ -2,7 +2,7 @@ package catalogue import "testing" -// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a switch. +// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a boolean, as the engine and the checker read it. func TestTheEngineServesComposeCandidate(t *testing.T) { for _, v := range EngineVerbs() { if v.Name != "compose-candidate" { @@ -10,7 +10,7 @@ func TestTheEngineServesComposeCandidate(t *testing.T) { } props, _ := v.Input["properties"].(map[string]any) input, _ := props["input"].(map[string]any) - if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil { + if !v.Optional || props["candidate"] == nil || input == nil || input["type"] != "boolean" { t.Fatalf("compose-candidate reads as %+v", v) } return diff --git a/go/catalogue/engine_seat.go b/go/catalogue/engine_seat.go index bcbbd2f..34d39eb 100644 --- a/go/catalogue/engine_seat.go +++ b/go/catalogue/engine_seat.go @@ -1,23 +1,26 @@ package catalogue -import contractseats "git.novox.be/novox/mesh-contracts/go/seats" +import "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" // EngineSeat is the node seat every node's engine holds. -const EngineSeat = contractseats.NodeEngine +// +// Deprecated: the seat is defined whole in mesh-contracts (nodeengine.Seat, its verbs, events, subjects and message +// types); kept for its importers until their next change takes it from there (testdata/seat-names.txt). +const EngineSeat = nodeengine.Seat // EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the -// generated seat list (design 26). +// generated seat list (design 26), each with its description and its input's schema as discovery shows them. Their +// names are nodeengine.Verbs(). func EngineVerbs() []Verb { s, _ := seatIn(defaultSeats, EngineSeat) return append([]Verb(nil), s.Serves...) } -// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime -// proved it and the ask (ADR 0563 §1). -var EngineEvents = func() []string { - s, _ := seatIn(defaultSeats, EngineSeat) - return append([]string(nil), s.Emits...) -}() +// EngineEvents are what the seat says: of an act, each naming the act, the path or access, the caller as the runtime +// proved it and the ask (ADR 0563 §1), and that what its engine reads of a manifest changed (ADR 0618 §4). +// +// Deprecated: the seat's contract names them, nodeengine.Events(); kept for its importers. +var EngineEvents = nodeengine.Events() // seatIn is the seat of that name in a list. func seatIn(list []Seat, name string) (Seat, bool) { diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 8afda6e..4f6bdec 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -54,10 +54,13 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { if err := json.Unmarshal(raw, &compiled); err != nil { t.Fatal(err) } - whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker"} + whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"} dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true} // Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it. addedVerbs := map[string]bool{"node-engine.pull-waits": true} + // A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the + // checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it). + retyped := map[string]bool{"node-engine.compose-candidate": true} norm := func(v any) any { b, _ := json.Marshal(v) var out any @@ -100,7 +103,11 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) { } continue } - kept = append(kept, byName[v.Name]) + kv := byName[v.Name] + if retyped[want.Name+"."+v.Name] { + kv.Input = v.Input + } + kept = append(kept, kv) } g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts // A verb the whole seat added (new, optional while its holders catch up) is no verb of the compiled list. diff --git a/go/delivery/builder.go b/go/delivery/builder.go index 98a35a0..32bf4c1 100644 --- a/go/delivery/builder.go +++ b/go/delivery/builder.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "reflect" + "slices" "strings" "git.novox.be/novox/mesh-sdk/go/catalogue" @@ -88,6 +89,10 @@ type BuiltArtifact struct { Kind string `json:"kind,omitempty"` Digest string `json:"digest"` Reference string `json:"reference,omitempty"` + // References are every form this artifact is named in, Reference among them (novox/hq ADR 0618, ADR 0624): a build + // keeps each form that a counted node-engine states, away nodes included, and Reference stays the form every one + // of them reads. A reader that does not know the field reads Reference alone. + References []string `json:"references,omitempty"` } const artifactStore = "artifact-store://" @@ -210,6 +215,10 @@ type BuilderBuild struct { // Imports are, on a done key, the mesh packages the built programs import, as the build module answered them // (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports). Imports []string `json:"imports,omitempty"` + // Needs are, on a done key only, the reader capabilities (`form:`) the build's reference forms need of a + // node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field + // reads the build as needing none. + Needs []string `json:"needs,omitempty"` } func (b BuilderBuild) check() error { @@ -244,6 +253,22 @@ func (b BuilderBuild) check() error { if p := a.referenceProblem(); p != "" { return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p) } + if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) { + return fmt.Errorf("delivery: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference) + } + for _, r := range a.References { + if p := (BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}).referenceProblem(); p != "" { + return fmt.Errorf("delivery: the reference %q of the %s %q %s", r, a.Kind, a.Name, p) + } + } + } + for _, n := range b.Needs { + if !strings.HasPrefix(n, "form:") || len(n) == len("form:") { + return fmt.Errorf("delivery: the need %q of the build is not form:", n) + } + } + if len(b.Needs) > 0 && b.State != BuildDone { + return fmt.Errorf("delivery: a %s build names needs; only a done key does", b.State) } for language, id := range b.Asks { if b.State != BuildBuilding { diff --git a/go/delivery/builder_test.go b/go/delivery/builder_test.go index 8e29474..82038ca 100644 --- a/go/delivery/builder_test.go +++ b/go/delivery/builder_test.go @@ -26,11 +26,15 @@ func doneBuild() BuilderBuild { func builderSamples() map[string]BuilderBuild { failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"} + both := doneBuild() + both.Needs = []string{"form:artifact-registry"} + both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest} return map[string]BuilderBuild{ - "queued": {State: BuildQueued, Manifest: digestOf('4')}, - "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, - "done": doneBuild(), - "failed": failed, + "done with needs and both forms": both, + "queued": {State: BuildQueued, Manifest: digestOf('4')}, + "building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')}, + "done": doneBuild(), + "failed": failed, } } @@ -90,10 +94,17 @@ func TestBuilderBuildChecked(t *testing.T) { edit func(*BuilderBuild) words []string }{ - "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, - "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, - "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, - "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, + "done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}}, + "a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}}, + "an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}}, + "an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}}, + "a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}}, + "a reference not among the references": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest} + }, []string{"server", "references"}}, + "a reference of another artifact": {func(b *BuilderBuild) { + b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest} + }, []string{"server", "other"}}, "an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}}, "a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}}, "a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}}, @@ -119,6 +130,11 @@ func TestBuilderBuildChecked(t *testing.T) { } // a queued or building value may not already carry an outcome q := builderSamples()["queued"] + q.Needs = []string{"form:x"} + if _, err := EncodeBuilderBuild(q); err == nil { + t.Error("a queued build with needs accepted") + } + q.Needs = nil q.Artifacts = doneBuild().Artifacts if _, err := EncodeBuilderBuild(q); err == nil { t.Error("a queued build with artifacts accepted") diff --git a/go/delivery/compose_candidate.go b/go/delivery/compose_candidate.go index 84cdefd..2420d61 100644 --- a/go/delivery/compose_candidate.go +++ b/go/delivery/compose_candidate.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "time" + + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" ) // A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb @@ -16,7 +18,9 @@ import ( const ( // EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat). - EngineComposeCandidateVerb = "compose-candidate" + // + // Deprecated: the seat's contract names it, nodeengine.VerbComposeCandidate; kept for its importers. + EngineComposeCandidateVerb = nodeengine.VerbComposeCandidate // 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 @@ -45,53 +49,41 @@ const ( 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"` -} +// The verb's ask and answer are the seat's contract, generated in mesh-contracts (seats/node-engine): mesh-sdk keeps +// these names for their importers and the checks below over them, until the seat has a client package (ADR 0625 §4). +type ( + // 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). + // + // Deprecated: the seat's contract is nodeengine.ComposeCandidateInput; kept for its importers. + ComposeCandidateAsk = nodeengine.ComposeCandidateInput + // 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. + // + // Deprecated: the seat's contract is nodeengine.ComposeCandidateAnswer; kept for its importers. + ComposeCandidateAnswer = nodeengine.ComposeCandidateAnswer + // 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. + // + // Deprecated: the seat's contract is nodeengine.Module; kept for its importers. + ModuleComposed = nodeengine.Module +) -// 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 { +func checkComposeAsk(a ComposeCandidateAsk) 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 { +func checkComposedModule(m ModuleComposed, node string) error { if m.Module == "" { return fmt.Errorf("delivery: a module's composition on %s names no module", node) } @@ -111,7 +103,7 @@ func (m ModuleComposed) check(node string) error { return nil } -func (a ComposeCandidateAnswer) check() error { +func checkComposeAnswer(a ComposeCandidateAnswer) error { if err := checkName("node", a.Node); err != nil { return err } @@ -133,7 +125,7 @@ func (a ComposeCandidateAnswer) check() error { } refused := false for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) { - if err := m.check(a.Node); err != nil { + if err := checkComposedModule(m, a.Node); err != nil { return err } if i > 1 && a.Modules[i-2].Module >= m.Module { @@ -156,6 +148,7 @@ func (a ComposeCandidateAnswer) check() error { 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("delivery: %s does not fit its shape: %w", what, err) } @@ -164,7 +157,7 @@ func strictDecode(data []byte, v any, what string) error { // 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 { + if err := checkComposeAsk(a); err != nil { return nil, err } return json.Marshal(a) @@ -176,13 +169,13 @@ func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) { if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil { return ComposeCandidateAsk{}, err } - return a, a.check() + return a, checkComposeAsk(a) } // 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 { + if err := checkComposeAnswer(a); err != nil { return nil, err } a.V = Version @@ -199,5 +192,5 @@ func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) { if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil { return ComposeCandidateAnswer{}, err } - return a, a.check() + return a, checkComposeAnswer(a) } diff --git a/go/delivery/compose_candidate_test.go b/go/delivery/compose_candidate_test.go index 63e4e1d..66afd83 100644 --- a/go/delivery/compose_candidate_test.go +++ b/go/delivery/compose_candidate_test.go @@ -2,6 +2,7 @@ package delivery import ( "encoding/json" + "reflect" "strings" "testing" @@ -32,16 +33,16 @@ func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) { good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812, Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"}, Modules: []ModuleComposed{ - {Module: "netcheck", FP: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, + {Module: "netcheck", Fp: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true}, {Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"}, {Module: "zz", State: ModuleHeldState, Words: "held before"}, - }, Input: json.RawMessage(`{"machines":["ace"]}`)} + }, Input: map[string]any{"machines": []any{"ace"}}} raw, err = EncodeComposeCandidateAnswer(good) if err != nil { t.Fatal(err) } back, err := DecodeComposeCandidateAnswer(raw) - if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || string(back.Input) != string(good.Input) { + if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || !reflect.DeepEqual(back.Input, good.Input) { t.Fatalf("answer round trip: %v, %+v", err, back) } for name, mutate := range map[string]func(a *ComposeCandidateAnswer){ diff --git a/go/testdata/seat-names.txt b/go/testdata/seat-names.txt index 0c08efc..2dce7f4 100644 --- a/go/testdata/seat-names.txt +++ b/go/testdata/seat-names.txt @@ -11,7 +11,6 @@ catalogue/checker_seat.go: CheckerSeat catalogue/control_records_seats.go: HandActsSeat catalogue/control_records_seats.go: WantsSeat catalogue/delivery_seat.go: DeliverySeat -catalogue/engine_seat.go: EngineSeat catalogue/enrolment_seat.go: EnrolmentSeat catalogue/environment_into.go: EnvironmentSeat catalogue/environment_into.go: LoginShellSeat