diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index 1e8cdd3..134dae8 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -1,8 +1,11 @@ package delivery import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "path" "regexp" "sort" "strings" @@ -154,8 +157,8 @@ func checkAskID(id string) error { } // StepAsk is the message on a build seat's stream: who asked what, of which kind and language, and the one whole job. -// Exactly one of Build and Check is set: a delivery ask is a build; a check ask a build (a candidate's fingerprint) or a -// check; a recheck ask a check; an image ask is always a build. +// Exactly one of Build, Check and Compose is set: a delivery ask is a build; a check ask a build (a candidate's +// fingerprint), a check or, on the Go seat, a compose; a recheck ask a check; an image ask is always a build. type StepAsk struct { V int `json:"v"` ID string `json:"id"` @@ -163,10 +166,48 @@ type StepAsk struct { Language string `json:"language"` // Asker is the module that put the ask (mesh-delivery-builder or mesh-delivery-checker); the stream's grant is what // makes it true, this field only names it to a reader. - Asker string `json:"asker"` - At time.Time `json:"at"` - Build *BuildJob `json:"build,omitempty"` - Check *CheckJob `json:"check,omitempty"` + Asker string `json:"asker"` + At time.Time `json:"at"` + Build *BuildJob `json:"build,omitempty"` + Check *CheckJob `json:"check,omitempty"` + Compose *ComposeJob `json:"compose,omitempty"` +} + +// ComposeJob is a compose ask's job (novox/hq ADR 0482 clause 4, issue 1622): fetch the commit, build the composer +// command (Package) in the Go module at Dir of the fetched tree, once per ComposerKey, run it in a run step shut off +// with Request on its standard input, and answer its standard output whole (ComposeAnswer). The assigner asks it, to +// compose a group with its mesh-sdk member's head, and to read the group's manifests with the mesh-sdk the running +// controller and node-engine pin (ADR 0482 clause 5, issue 1623). +type ComposeJob struct { + // Fetch is the fetch tool's arguments: the repository and the full commit whose mesh-sdk composes (mesh-sdk itself + // at a head, or a module that requires mesh-sdk, at the version it pins). + Fetch map[string]any `json:"fetch"` + // Dir is the directory of the Go module the composer is built in, relative to the repository's root ("go" for + // mesh-sdk, RootPath for a module at the root); Package is the composer's import path (mesh-sdk composer.Package). + Dir string `json:"dir"` + Package string `json:"package"` + // Request is the composer's request (mesh-sdk composer.Request), its manifests named by their digests in the + // indexer's ManifestsBucket: the build module fills them before the run (composer.Fill). + Request json.RawMessage `json:"request"` + // Reader names, for a reader of the ask, whose composition this is: the mesh-sdk head of a group, or a module whose + // mesh-sdk reads the group's manifests (mesh-controller, mesh-host). + Reader string `json:"reader,omitempty"` +} + +// ComposeAnswer is a compose ask's step result: whether this run built the composer or took the one kept under its +// key, the key, and the composer's verdict (mesh-sdk composer.Verdict) whole. +type ComposeAnswer struct { + Built bool `json:"built"` + Key string `json:"key"` + Verdict json.RawMessage `json:"verdict"` +} + +// ComposerKey is the key a build module keeps a built composer under: the repository, commit, directory and package it +// was built from and the toolchain it was built with, hashed (`composer-` and 16 hex digits), so each head is built once +// on a node (ADR 0482 clause 4). +func ComposerKey(repository, commit, dir, pkg string, toolchain ToolchainUsed) string { + sum := sha256.Sum256([]byte(strings.Join([]string{repository, commit, dir, pkg, toolchain.Version, toolchain.Recipe}, "\n"))) + return "composer-" + hex.EncodeToString(sum[:8]) } // BuildJob is a build ask's job: fetch the commit (unless every artifact is a copy), then build or copy each artifact, @@ -232,9 +273,17 @@ func (a StepAsk) check() error { if a.At.IsZero() { return fmt.Errorf("delivery: the ask %s names no time", a.ID) } + jobs := 0 + for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil} { + if set { + jobs++ + } + } switch { - case (a.Build == nil) == (a.Check == nil): - return fmt.Errorf("delivery: the ask %s holds a build or a check, exactly one", a.ID) + case jobs != 1: + return fmt.Errorf("delivery: the ask %s holds a build, a check or a compose, exactly one", a.ID) + case a.Compose != nil && (a.Kind != AskCheck || a.Language != AskGo): + return fmt.Errorf("delivery: the ask %s is a compose, which the Go seat's check stream alone carries", a.ID) case a.Build != nil && a.Kind == AskRecheck: return fmt.Errorf("delivery: the ask %s is a build on the recheck stream, which carries checks alone", a.ID) case a.Check != nil && a.Kind == AskDelivery: @@ -245,9 +294,28 @@ func (a StepAsk) check() error { if a.Build != nil { return a.Build.check(a.ID) } + if a.Compose != nil { + return a.Compose.check(a.ID) + } return a.Check.check(a.ID) } +func (c ComposeJob) check(id string) error { + if err := checkFetchArgs(id, c.Fetch); err != nil { + return err + } + if c.Dir == "" || c.Dir != path.Clean(c.Dir) || strings.HasPrefix(c.Dir, "/") || c.Dir == ".." || strings.HasPrefix(c.Dir, "../") { + return fmt.Errorf("delivery: the compose ask %s names no clean directory inside the repository (%q)", id, c.Dir) + } + if c.Package == "" { + return fmt.Errorf("delivery: the compose ask %s names no package to build", id) + } + if !json.Valid(c.Request) || len(c.Request) == 0 || c.Request[0] != '{' { + return fmt.Errorf("delivery: the compose ask %s holds no request object", id) + } + return nil +} + func (b BuildJob) check(id string) error { if err := checkName("module", b.Module); err != nil { return err diff --git a/go/delivery/stepasks_compose_test.go b/go/delivery/stepasks_compose_test.go new file mode 100644 index 0000000..a9547f0 --- /dev/null +++ b/go/delivery/stepasks_compose_test.go @@ -0,0 +1,76 @@ +package delivery + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" +) + +func composeAsk() StepAsk { + return StepAsk{ID: "mh3k2x9qa1b2c5", Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-assigner", + At: time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC), + Compose: &ComposeJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": askCommit}, + Dir: "go", Package: "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose", + Request: json.RawMessage(`{"v":1,"cases":[{"name":"change","manifests":{"demo":{"digest":"d1"}}}]}`), Reader: "mesh-sdk head"}} +} + +// 1622: a compose ask encodes and decodes with every field. +func TestAComposeAskRoundTrips(t *testing.T) { + a := composeAsk() + raw, err := EncodeStepAsk(a) + if err != nil { + t.Fatal(err) + } + got, err := DecodeStepAsk(raw) + if err != nil { + t.Fatal(err) + } + a.V = StepAskVersion + if !reflect.DeepEqual(got, a) { + t.Fatalf("round trip:\n got %+v\nwant %+v", got, a) + } +} + +// 1622: a compose ask goes only on the Go seat's check stream, holds no other job beside it, fetches a full commit, +// builds a package in a clean directory of the repository and carries a request object. +func TestAComposeAskIsRefusedWhereItDoesNotBelong(t *testing.T) { + for name, change := range map[string]func(*StepAsk){ + "on the delivery stream": func(a *StepAsk) { a.Kind = AskDelivery }, + "on the recheck stream": func(a *StepAsk) { a.Kind = AskRecheck }, + "on the Node seat": func(a *StepAsk) { a.Language = AskNode }, + "beside a check": func(a *StepAsk) { a.Check = checkAsk().Check }, + "with a short commit": func(a *StepAsk) { a.Compose.Fetch["commit"] = "0123456" }, + "in no directory": func(a *StepAsk) { a.Compose.Dir = "" }, + "outside the repository": func(a *StepAsk) { a.Compose.Dir = "../x" }, + "with no package": func(a *StepAsk) { a.Compose.Package = "" }, + "with no request": func(a *StepAsk) { a.Compose.Request = nil }, + "with a request not JSON": func(a *StepAsk) { a.Compose.Request = json.RawMessage(`[1]`) }, + } { + a := composeAsk() + change(&a) + if _, err := EncodeStepAsk(a); err == nil { + t.Errorf("a compose ask %s is encoded", name) + } + } +} + +// 1622/AC3: a composer is kept under one key per repository, commit, directory, package and toolchain, so two asks of +// one head build it once on a node, and another head or toolchain builds its own. +func TestAComposerIsKeptByWhatItWasBuiltFrom(t *testing.T) { + tc := ToolchainUsed{Version: "go1.26.0", Recipe: "3"} + k := ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc) + if k != ComposerKey("novox/mesh-sdk", askCommit, "go", "p", tc) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 { + t.Fatalf("key = %q", k) + } + for _, other := range []string{ + ComposerKey("novox/mesh-sdk", strings.Repeat("1", 40), "go", "p", tc), + ComposerKey("novox/mesh-controller", askCommit, ".", "p", tc), + ComposerKey("novox/mesh-sdk", askCommit, "go", "p", ToolchainUsed{Version: "go1.26.1", Recipe: "3"}), + } { + if other == k { + t.Errorf("two composers built from different inputs share the key %s", k) + } + } +}