Let the Go build seat carry a compose ask and its answer, so the assigner composes a group with its mesh-sdk head (issue 1622)

This commit is contained in:
2026-10-24 03:51:35 +02:00
parent 20d18eb4f5
commit 668fc2a722
2 changed files with 152 additions and 8 deletions
+76 -8
View File
@@ -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
+76
View File
@@ -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)
}
}
}