mesh-delivery-sdk buildstep: the build ask once, as one neutral form for the three build seats (the part build-step's StepAsk, Fetch, jobs, answers, kinds, languages, needs, subjects, pauses, capacity, composer key), one conversion AskOf for any seat's type; seatgen writes a package per seat and shares no part, so the form lives here (B5e ruling 1; ADR 0634 §6; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:48:52 +01:00
parent 3036130de2
commit eaa84387f7
3 changed files with 990 additions and 0 deletions
+622
View File
@@ -0,0 +1,622 @@
package buildstep
import (
"encoding/json"
"fmt"
"path"
"slices"
"sort"
"strings"
"time"
meshkeys "git.novox.be/novox/mesh-sdk/go/keys"
)
// **The build ask, once.** The three build seats (node-build-go, node-build-image, node-build-node) accept the same ask,
// defined once as the part `build-step` of the Delivery contracts. The generator writes one Go type per seat, so the
// seat packages hold three structurally equal StepAsks; this file is the one neutral form the shared runner and the
// askers use, and AskOf is the one conversion from any seat's (a seat's value and this form are the same JSON, held
// equal by the part's fixtures: the contracts' tests decode each in both). This package imports no seat's contract
// (novox/hq ADR 0634 §6).
// The kinds of ask: the seat's accepted kinds, in the order a holder pulls them.
const (
AskDelivery = "delivery"
AskCheck = "check"
AskRecheck = "recheck"
)
// AskKinds are the accepted kinds of a build seat, in the order a holder pulls them (ADR 0536 §4).
var AskKinds = []string{AskDelivery, AskCheck, AskRecheck}
// The languages of the build seats: each is one build module's (mesh-build-<language>) and one seat's
// (node-build-<language>).
const (
AskGo = "go"
AskNode = "node"
AskImage = "image"
)
// AskLanguages are the build seats' languages.
var AskLanguages = []string{AskGo, AskNode, AskImage}
// NeedNodeBuildNode is the need of a check whose tests run through the Node build seat's holder on the same node
// (issue 890): a node that holds no Node build module declines the ask.
const NeedNodeBuildNode = "node-build-node"
// AskNeeds are the needs an ask may name.
var AskNeeds = []string{NeedNodeBuildNode}
// CheckNeed refuses a need an ask may not name.
func CheckNeed(need string) error {
if !slices.Contains(AskNeeds, need) {
return fmt.Errorf("buildstep: %q is not a need an ask may name (%s)", need, strings.Join(AskNeeds, ", "))
}
return nil
}
// Lacks are the needs has does not say its node has; a nil has lacks all.
func Lacks(needs []string, has func(need string) bool) []string {
var out []string
for _, n := range needs {
if has == nil || !has(n) {
out = append(out, n)
}
}
return out
}
// AskRuns is how many times a holder runs one ask before it ends refused; AskMaxDeliver is the redelivery bound that
// leaves one more for the terminal step (ADR 0525 §5).
const (
AskRuns = 3
AskMaxDeliver = AskRuns + 2
)
// StepAskVersion is the layout version of an ask.
const StepAskVersion = 1
// The levels a check ask names.
const (
LevelUnit = "unit"
LevelIntegration = "integration"
)
// The registry seats a build publishes through (ADR 0605 §1).
var RegistrySeats = []string{"npm-package-registry", "go-package-registry", "container-image-registry", "oci-artifact-registry"}
// NodeNamesArg and NodeNamesEnv carry the node names a check's tests run against.
const (
NodeNamesArg = "node-names"
NodeNamesEnv = "MESH_NODE_NAMES"
)
func checkAskKind(kind string) error {
if !slices.Contains(AskKinds, kind) {
return fmt.Errorf("buildstep: the ask kind %q is not delivery, check or recheck", kind)
}
return nil
}
func checkAskLanguage(language string) error {
if !slices.Contains(AskLanguages, language) {
return fmt.Errorf("buildstep: the ask language %q is not go, node or image", language)
}
return nil
}
// BuildSeat is the seat of a language's build module.
func BuildSeat(language string) (string, error) {
if err := checkAskLanguage(language); err != nil {
return "", err
}
return "node-build-" + language, nil
}
// BuildModuleOf is the module of a language's build seat.
func BuildModuleOf(language string) (string, error) {
if err := checkAskLanguage(language); err != nil {
return "", err
}
return "mesh-build-" + language, nil
}
// AskSubject is the subject a build seat accepts a kind of ask on: the seat's accept subject.
func AskSubject(language, kind string) (string, error) {
seat, err := BuildSeat(language)
if err != nil {
return "", err
}
if err := checkAskKind(kind); err != nil {
return "", err
}
return "mesh.seat." + seat + ".accept." + kind, nil
}
// ParseAskSubject is the inverse of AskSubject.
func ParseAskSubject(subject string) (language, kind string, err error) {
rest, ok := strings.CutPrefix(subject, "mesh.seat.node-build-")
if !ok {
return "", "", fmt.Errorf("buildstep: %q is not a build seat's accept subject", subject)
}
language, kind, ok = strings.Cut(rest, ".accept.")
if !ok {
return "", "", fmt.Errorf("buildstep: %q is not a build seat's accept subject", subject)
}
if want, err := AskSubject(language, kind); err != nil || want != subject {
return "", "", fmt.Errorf("buildstep: %q is not a build seat's accept subject: %v", subject, err)
}
return language, kind, nil
}
// AskStream is the work-queue stream of a language's build seat.
func AskStream(language string) (string, error) {
seat, err := BuildSeat(language)
if err != nil {
return "", err
}
return "SEAT_" + strings.ToUpper(strings.ReplaceAll(seat, "-", "_")), nil
}
// AskWorker is the shared pull worker of a kind on a language's stream.
func AskWorker(language, kind string) (string, error) {
stream, err := AskStream(language)
if err != nil {
return "", err
}
if err := checkAskKind(kind); err != nil {
return "", err
}
return stream + "_" + strings.ToUpper(kind) + "_worker", nil
}
// AskStepKey is the key of the step of the ask askID on node.
func AskStepKey(node, askID string) (string, error) {
if err := CheckAskID(askID); err != nil {
return "", err
}
return StepKey(node, askID)
}
// AskStepWatch is the key pattern of an ask's step on any node.
func AskStepWatch(askID string) (string, error) {
if err := CheckAskID(askID); err != nil {
return "", err
}
return "*." + askID, nil
}
// StepAsk is one ask of a build module: the part's `StepAsk`.
type StepAsk struct {
V int `json:"v"`
ID string `json:"id"`
Kind string `json:"kind"`
Language string `json:"language"`
Asker string `json:"asker"`
At time.Time `json:"at,omitzero"`
Build *BuildJob `json:"build,omitempty"`
Check *CheckJob `json:"check,omitempty"`
Compose *ComposeJob `json:"compose,omitempty"`
// Needs are what a check's tests need of the holder's node (CheckNeed); a holder lacking one declines the ask.
Needs []string `json:"needs,omitempty"`
// Packages pin the mesh packages a build or a check builds against.
Packages PackageVersions `json:"packages,omitempty"`
}
// Fetch is what a build module's fetch tool is asked, inside an ask: the part's `Fetch`.
type Fetch struct {
V int `json:"v,omitempty"`
Repository string `json:"repository"`
Commit string `json:"commit"`
Ref string `json:"ref,omitempty"`
Base string `json:"base,omitempty"`
Dir string `json:"dir,omitempty"`
Paths []string `json:"paths,omitempty"`
Packages PackageVersions `json:"packages,omitempty"`
Beside []FetchBeside `json:"beside,omitempty"`
}
// FetchBeside is a sibling repository placed beside the fetched tree.
type FetchBeside struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
}
// ToolchainUsed is the toolchain a build ran with.
type ToolchainUsed struct {
Language string `json:"language"`
Recipe string `json:"recipe"`
Version string `json:"version"`
}
// AskArtifact is one artifact of a build ask: built from the fetched tree, or copied from an upstream pin (From).
type AskArtifact struct {
Artifact string `json:"artifact"`
From string `json:"from,omitempty"`
Seat string `json:"seat,omitempty"`
Version string `json:"version,omitempty"`
}
// BuildJob is a trunk fingerprint's build.
type BuildJob struct {
Module string `json:"module"`
Path string `json:"path,omitempty"`
Fp string `json:"fp,omitempty"`
Fetch *Fetch `json:"fetch,omitempty"`
Artifacts []AskArtifact `json:"artifacts"`
Publish bool `json:"publish,omitempty"`
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
}
// CheckJob is the check of one or both levels of a fetched tree.
type CheckJob struct {
Fetch Fetch `json:"fetch"`
Levels []string `json:"levels"`
Kept bool `json:"kept,omitempty"`
Runner string `json:"runner,omitempty"`
RunnerWhen string `json:"runner_when,omitempty"`
Run string `json:"run,omitempty"`
Packages []string `json:"packages,omitempty"`
NodeNames []string `json:"node-names,omitempty"`
}
// ComposeJob asks one compose-candidate build.
type ComposeJob struct {
Fetch Fetch `json:"fetch"`
Dir string `json:"dir"`
Package string `json:"package"`
Request json.RawMessage `json:"request"`
Reader string `json:"reader,omitempty"`
}
// AskOf is any seat's ask (or this form) as the neutral StepAsk: the one conversion, since the three seats' types
// are the part's one layout. A value that does not fit the layout is refused.
func AskOf[T any](seatAsk T) (StepAsk, error) {
raw, err := json.Marshal(seatAsk)
if err != nil {
return StepAsk{}, fmt.Errorf("buildstep: an ask does not encode: %w", err)
}
return DecodeStepAsk(raw)
}
// AskAs is the neutral ask as a seat's type T (the inverse of AskOf).
func AskAs[T any](a StepAsk) (T, error) {
var out T
raw, err := json.Marshal(a)
if err != nil {
return out, err
}
err = json.Unmarshal(raw, &out)
return out, err
}
func (a StepAsk) check() error {
if err := CheckAskID(a.ID); err != nil {
return err
}
if err := checkAskKind(a.Kind); err != nil {
return err
}
if err := checkAskLanguage(a.Language); err != nil {
return err
}
if err := meshkeys.CheckName("asker", a.Asker); err != nil {
return err
}
if a.At.IsZero() {
return fmt.Errorf("buildstep: 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 jobs != 1:
return fmt.Errorf("buildstep: 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("buildstep: 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("buildstep: the ask %s is a build on the recheck stream, which carries checks alone", a.ID)
case a.Check != nil && a.Kind == AskDelivery:
return fmt.Errorf("buildstep: the ask %s is a check on the delivery stream, which carries trunk builds alone", a.ID)
case a.Check != nil && a.Language == AskImage:
return fmt.Errorf("buildstep: the ask %s is a check of an image, which has no tests", a.ID)
case len(a.Needs) > 0 && a.Check == nil:
return fmt.Errorf("buildstep: the ask %s names needs and is no check, whose tests alone need a node", a.ID)
case len(a.Packages) > 0 && a.Compose != nil:
return fmt.Errorf("buildstep: the ask %s is a compose and names package versions, which a compose does not take", a.ID)
}
if err := a.Packages.Check(); err != nil {
return fmt.Errorf("the ask %s: %w", a.ID, err)
}
for _, n := range a.Needs {
if err := CheckNeed(n); err != nil {
return fmt.Errorf("the ask %s: %w", a.ID, err)
}
}
switch {
case a.Build != nil:
return a.Build.check(a.ID)
case a.Compose != nil:
return a.Compose.check(a.ID)
}
return a.Check.check(a.ID)
}
func (c ComposeJob) check(id string) error {
if err := c.Fetch.check(id); 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("buildstep: the compose ask %s names no clean directory inside the repository (%q)", id, c.Dir)
}
if c.Package == "" {
return fmt.Errorf("buildstep: 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("buildstep: the compose ask %s holds no request object", id)
}
return nil
}
func (b BuildJob) check(id string) error {
if err := meshkeys.CheckName("module", b.Module); err != nil {
return err
}
if len(b.Artifacts) == 0 {
return fmt.Errorf("buildstep: the build ask %s names no artifact", id)
}
fetch := false
for _, a := range b.Artifacts {
if err := meshkeys.CheckName("artifact", a.Artifact); err != nil {
return err
}
if a.Seat != "" && !slices.Contains(RegistrySeats, a.Seat) {
return fmt.Errorf("buildstep: the build ask %s publishes %s through %q, which is no registry seat (%s)", id,
a.Artifact, a.Seat, strings.Join(RegistrySeats, ", "))
}
if a.Version != "" && a.Seat == "" {
return fmt.Errorf("buildstep: the build ask %s gives %s the version %s and names no seat to publish it through",
id, a.Artifact, a.Version)
}
fetch = fetch || a.From == ""
}
if fetch {
if b.Path == "" {
return fmt.Errorf("buildstep: the build ask %s names no directory (path) for the module %s", id, b.Module)
}
if b.Fetch == nil {
return fmt.Errorf("buildstep: the ask %s fetches no repository <owner>/<name>", id)
}
return b.Fetch.check(id)
}
return nil
}
func (c CheckJob) check(id string) error {
if len(c.Levels) == 0 {
return fmt.Errorf("buildstep: the check ask %s names no level", id)
}
for _, l := range c.Levels {
if l != LevelUnit && l != LevelIntegration {
return fmt.Errorf("buildstep: the level %q is not %s or %s", l, LevelUnit, LevelIntegration)
}
}
for _, p := range c.Packages {
if p == "" || strings.ContainsAny(p, " \t\n") {
return fmt.Errorf("buildstep: the check ask %s names a package %q that is no import path", id, p)
}
}
seen := map[string]bool{}
for _, n := range c.NodeNames {
if err := meshkeys.CheckName("node", n); err != nil || strings.Contains(n, ",") || seen[n] {
return fmt.Errorf("buildstep: the check ask %s names the node %q, which is no node name or named twice", id, n)
}
seen[n] = true
}
return c.Fetch.check(id)
}
func (f Fetch) check(id string) error {
if f.Repository == "" || !strings.Contains(f.Repository, "/") {
return fmt.Errorf("buildstep: the ask %s fetches no repository <owner>/<name>", id)
}
if !askCommitOK.MatchString(f.Commit) {
return fmt.Errorf("buildstep: the ask %s fetches no full commit", id)
}
return nil
}
// EncodeStepAsk is a as the bytes the asker puts on a build seat's stream: stamped with the version and UTC, checked.
func EncodeStepAsk(a StepAsk) ([]byte, error) {
a.V = StepAskVersion
a.At = a.At.UTC()
if err := a.check(); err != nil {
return nil, err
}
return json.Marshal(a)
}
// DecodeStepAsk reads an ask, refusing another version, an unknown shape or a malformed ask.
func DecodeStepAsk(data []byte) (StepAsk, error) {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return StepAsk{}, fmt.Errorf("buildstep: an ask is not JSON: %w", err)
}
if probe.V == nil || *probe.V != StepAskVersion {
return StepAsk{}, fmt.Errorf("%w: an ask of version %v, this reader reads %d", ErrUnknownVersion, probe.V, StepAskVersion)
}
var a StepAsk
if err := json.Unmarshal(data, &a); err != nil {
return StepAsk{}, fmt.Errorf("buildstep: an ask does not fit its shape: %w", err)
}
if err := a.check(); err != nil {
return StepAsk{}, err
}
return a, nil
}
// PackageNeeds is what a package of a check needs of a node.
type PackageNeeds struct {
Package string `json:"package"`
Needs []string `json:"needs"`
}
// StepDeclined is the result of a step a holder declined: its node lacks what the ask needs.
type StepDeclined struct {
Declined bool `json:"declined"`
Needs []string `json:"needs"`
Lacks []string `json:"lacks"`
}
// DeclinedOf reads a refused step's result as a decline.
func DeclinedOf(step Step) (StepDeclined, bool) {
if step.State != StepRefused || len(step.Result) == 0 {
return StepDeclined{}, false
}
var d StepDeclined
if err := json.Unmarshal(step.Result, &d); err != nil || !d.Declined {
return StepDeclined{}, false
}
return d, true
}
// UnmetNeeds are the needs the levels' unmet packages name, sorted, once each.
func UnmetNeeds(levels []LevelAnswer) []string {
seen := map[string]bool{}
var out []string
for _, l := range levels {
for _, u := range l.Unmet {
for _, n := range u.Needs {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
}
}
sort.Strings(out)
return out
}
// BuildAnswer is what a build ask's step answers.
type BuildAnswer struct {
Artifacts []AskBuilt `json:"artifacts"`
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
Imports []string `json:"imports,omitempty"`
OutOfStep []string `json:"out-of-step,omitempty"`
}
// AskBuilt is one artifact a build made.
type AskBuilt struct {
Artifact string `json:"artifact"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
}
// CheckAnswer is what a check ask's step answers.
type CheckAnswer struct {
Fetch Fetch `json:"fetch"`
Levels []LevelAnswer `json:"levels,omitempty"`
}
// ComposeAnswer is what a compose ask's step answers.
type ComposeAnswer struct {
Built bool `json:"built"`
Key string `json:"key,omitempty"`
Verdict json.RawMessage `json:"verdict,omitempty"`
}
// LevelAnswer is one level of a check.
type LevelAnswer struct {
Level string `json:"level"`
Judged string `json:"judged,omitempty"`
Digest *LevelDigest `json:"digest,omitempty"`
Kept []KeptTaken `json:"kept,omitempty"`
Tests []json.RawMessage `json:"tests,omitempty"`
Unmet []PackageNeeds `json:"unmet,omitempty"`
}
// LevelDigest is the digest of a level's tests by package.
type LevelDigest struct {
Level string `json:"level,omitempty"`
Packages []PackageDigest `json:"packages,omitempty"`
Beside []string `json:"beside,omitempty"`
WithoutTests []string `json:"without_tests,omitempty"`
JudgedBy string `json:"judged_by,omitempty"`
Needs []PackageNeeds `json:"needs,omitempty"`
}
// PackageDigest is the digest of one package's tests.
type PackageDigest struct {
Package string `json:"package"`
Digest string `json:"digest"`
}
// KeptTaken is a package whose kept verdict a level took instead of running its tests.
type KeptTaken struct {
Package string `json:"package"`
Digest string `json:"digest"`
Tests int `json:"tests"`
}
// UnmarshalJSON reads the packages as the list or as the older map by package.
func (d *LevelDigest) UnmarshalJSON(raw []byte) error {
var v struct {
Level string `json:"level"`
Packages json.RawMessage `json:"packages"`
Beside []string `json:"beside"`
WithoutTests []string `json:"without_tests"`
JudgedBy string `json:"judged_by"`
Needs []PackageNeeds `json:"needs"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return err
}
var pkgs []PackageDigest
if p := strings.TrimSpace(string(v.Packages)); p != "" && p != "null" {
if strings.HasPrefix(p, "{") {
var m map[string]string
if err := json.Unmarshal(v.Packages, &m); err != nil {
return fmt.Errorf("a digest's packages: %w", err)
}
for pkg, digest := range m {
pkgs = append(pkgs, PackageDigest{Package: pkg, Digest: digest})
}
} else if err := json.Unmarshal(v.Packages, &pkgs); err != nil {
return fmt.Errorf("a digest's packages: %w", err)
}
}
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Package < pkgs[j].Package })
*d = LevelDigest{Level: v.Level, Packages: pkgs, Beside: v.Beside, WithoutTests: v.WithoutTests, JudgedBy: v.JudgedBy, Needs: v.Needs}
return nil
}
// NeedsOf are the needs of a package in the digest.
func (d *LevelDigest) NeedsOf(pkg string) []string {
if d == nil {
return nil
}
for _, n := range d.Needs {
if n.Package == pkg {
return n.Needs
}
}
return nil
}
// Map is the digest as digest by package.
func (d *LevelDigest) Map() map[string]string {
if d == nil || len(d.Packages) == 0 {
return nil
}
m := make(map[string]string, len(d.Packages))
for _, p := range d.Packages {
m[p.Package] = p.Digest
}
return m
}
+261
View File
@@ -0,0 +1,261 @@
package buildstep
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
testCommit = "0123456789abcdef0123456789abcdef01234567"
testAskID = "m2kx9a1b3c4d5e"
)
func goodBuild() StepAsk {
return StepAsk{ID: testAskID, Kind: AskDelivery, Language: AskGo, Asker: "mesh-delivery-builder",
At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC),
Build: &BuildJob{Module: "nats", Path: "modules/nats", Fetch: &Fetch{Repository: "novox/mesh-catalog", Commit: testCommit},
Artifacts: []AskArtifact{{Artifact: "go"}}}}
}
func goodCheck() StepAsk {
return StepAsk{ID: testAskID, Kind: AskCheck, Language: AskGo, Asker: "mesh-delivery-checker",
At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC),
Check: &CheckJob{Fetch: Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, Levels: []string{LevelUnit}}}
}
// The ask the three build seats share is one neutral form: it round-trips, and every ill-formed ask is refused,
// naming what is wrong (the rules of the old delivery.StepAsk, now the shared runner's).
func TestAskCheckAndRoundTrip(t *testing.T) {
for _, a := range []StepAsk{goodBuild(), goodCheck()} {
raw, err := EncodeStepAsk(a)
if err != nil {
t.Fatal(err)
}
back, err := DecodeStepAsk(raw)
if err != nil || back.ID != a.ID || back.V != StepAskVersion {
t.Fatalf("round trip: %+v, %v", back, err)
}
}
for _, c := range []struct {
name string
edit func(*StepAsk)
want string
}{
{"no id", func(a *StepAsk) { a.ID = "x" }, "not an ask id"},
{"unknown kind", func(a *StepAsk) { a.Kind = "build" }, "ask kind"},
{"unknown language", func(a *StepAsk) { a.Language = "rust" }, "ask language"},
{"no time", func(a *StepAsk) { a.At = time.Time{} }, "names no time"},
{"recheck build", func(a *StepAsk) { a.Kind = AskRecheck }, "recheck stream"},
{"two jobs", func(a *StepAsk) { a.Check = goodCheck().Check }, "exactly one"},
{"needs on a build", func(a *StepAsk) { a.Needs = []string{NeedNodeBuildNode} }, "names needs"},
{"unknown registry seat", func(a *StepAsk) { a.Build.Artifacts[0].Seat = "somewhere" }, "no registry seat"},
{"version without seat", func(a *StepAsk) { a.Build.Artifacts[0].Version = "1.0.0" }, "names no seat"},
{"no path", func(a *StepAsk) { a.Build.Path = "" }, "names no directory"},
{"short commit", func(a *StepAsk) { a.Build.Fetch.Commit = "abc" }, "full commit"},
{"bad package pin", func(a *StepAsk) { a.Packages = PackageVersions{"m": "main"} }, "neither a full commit"},
} {
a := goodBuild()
c.edit(&a)
if _, err := EncodeStepAsk(a); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want %q", c.name, err, c.want)
}
}
chk := goodCheck()
chk.Check.Levels = []string{"nightly"}
if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "nightly") {
t.Errorf("a level: %v", err)
}
chk = goodCheck()
chk.Language = AskImage
if _, err := EncodeStepAsk(chk); err == nil || !strings.Contains(err.Error(), "no tests") {
t.Errorf("an image check: %v", err)
}
if _, err := DecodeStepAsk([]byte(`{"v":2}`)); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("another version: %v", err)
}
}
// The seat's value and the neutral form are the same JSON: AskOf takes any seat's type (here its shape, with a
// pointer time), AskAs gives one back.
func TestAskOfAnySeatsType(t *testing.T) {
type seatAsk struct {
V int64 `json:"v,omitempty"`
ID string `json:"id"`
Kind string `json:"kind"`
Lang string `json:"language"`
Asker string `json:"asker"`
At *time.Time `json:"at,omitempty"`
Check *struct {
Fetch Fetch `json:"fetch"`
Levels []string `json:"levels"`
} `json:"check,omitempty"`
}
at := time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC)
s := seatAsk{V: 1, ID: testAskID, Kind: AskCheck, Lang: AskGo, Asker: "mesh-delivery-checker", At: &at}
s.Check = &struct {
Fetch Fetch `json:"fetch"`
Levels []string `json:"levels"`
}{Fetch{Repository: "novox/mesh-catalog", Commit: testCommit}, []string{LevelUnit}}
a, err := AskOf(s)
if err != nil || a.Check == nil || !a.At.Equal(at) || a.Check.Fetch.Commit != testCommit {
t.Fatalf("%+v %v", a, err)
}
back, err := AskAs[seatAsk](a)
if err != nil || back.ID != s.ID || !back.At.Equal(at) {
t.Fatalf("%+v %v", back, err)
}
s.Check = nil
if _, err := AskOf(s); err == nil {
t.Error("an ask with no job is accepted")
}
}
func TestAskSubjectsStreamsAndKeys(t *testing.T) {
for _, l := range AskLanguages {
for _, k := range AskKinds {
subj, err := AskSubject(l, k)
if err != nil {
t.Fatal(err)
}
gl, gk, err := ParseAskSubject(subj)
if err != nil || gl != l || gk != k {
t.Errorf("%s: %s %s %v", subj, gl, gk, err)
}
}
}
if got, _ := AskWorker(AskGo, AskCheck); got != "SEAT_NODE_BUILD_GO_CHECK_worker" {
t.Errorf("worker %q", got)
}
if got, _ := AskStream(AskImage); got != "SEAT_NODE_BUILD_IMAGE" {
t.Errorf("stream %q", got)
}
if _, _, err := ParseAskSubject("mesh.seat.node-build-go.accept.nothing"); err == nil {
t.Error("an unknown kind parses")
}
if _, err := AskSubject("rust", AskCheck); err == nil {
t.Error("an unknown language has a subject")
}
if k, err := AskStepKey("ace", testAskID); err != nil || k != "ace."+testAskID {
t.Errorf("%q %v", k, err)
}
if w, _ := AskStepWatch(testAskID); w != "*."+testAskID {
t.Errorf("watch %q", w)
}
if k, _ := PauseKey("ace"); k != "paused.ace" {
t.Errorf("pause key %q", k)
}
if k, _ := CapacityKey("ace"); k != "capacity.ace" {
t.Errorf("capacity key %q", k)
}
if _, err := CapacityKey("a.b"); err == nil {
t.Error("a dotted node keys")
}
}
func TestDeclinesNeedsAndLevelDigest(t *testing.T) {
if got := Lacks([]string{NeedNodeBuildNode}, nil); len(got) != 1 {
t.Errorf("a nil has lacks all: %v", got)
}
if got := Lacks([]string{NeedNodeBuildNode}, func(string) bool { return true }); len(got) != 0 {
t.Errorf("%v", got)
}
step := Step{State: StepRefused, Result: json.RawMessage(`{"declined":true,"needs":["node-build-node"],"lacks":["node-build-node"]}`)}
if d, ok := DeclinedOf(step); !ok || d.Lacks[0] != NeedNodeBuildNode {
t.Errorf("%+v %v", d, ok)
}
step.State = StepFailed
if _, ok := DeclinedOf(step); ok {
t.Error("a failed step is a decline")
}
var l LevelAnswer
raw := `{"level":"unit","digest":{"packages":{"b":"2","a":"1"},"needs":[{"package":"a","needs":["node-build-node"]}]},"unmet":[{"package":"a","needs":["node-build-node"]}]}`
if err := json.Unmarshal([]byte(raw), &l); err != nil {
t.Fatal(err)
}
if p := l.Digest.Packages; len(p) != 2 || p[0].Package != "a" || l.Digest.Map()["b"] != "2" || l.Digest.NeedsOf("a")[0] != NeedNodeBuildNode {
t.Errorf("%+v", l.Digest)
}
if got := UnmetNeeds([]LevelAnswer{l, l}); len(got) != 1 {
t.Errorf("%v", got)
}
}
func TestPauseCapacityAndComposerKey(t *testing.T) {
raw, err := EncodePause(Pause{Paused: true, By: "operator@shanks", At: time.Date(2026, 11, 5, 10, 0, 0, 0, time.UTC)})
if err != nil {
t.Fatal(err)
}
if p, err := DecodePause(raw); err != nil || !p.Paused || p.V != Version {
t.Fatalf("%+v %v", p, err)
}
if _, err := DecodePause([]byte(`{"v":9}`)); !errors.Is(err, ErrUnknownVersion) {
t.Errorf("%v", err)
}
craw, _ := EncodeCapacity(Capacity{Effective: 2, Configured: 2, Why: "fits"})
if c, err := DecodeCapacity(craw); err != nil || c.Effective != 2 {
t.Fatalf("%+v %v", c, err)
}
tc := ToolchainUsed{Language: "go", Version: "1.26.0", Recipe: "1"}
if k := ComposerKey("novox/r", testCommit, "d", "p", tc); k != ComposerKey("novox/r", testCommit, "d", "p", tc) || !strings.HasPrefix(k, "composer-") || len(k) != len("composer-")+16 {
t.Errorf("composer key %q", k)
}
if ComposerKey("novox/r", testCommit, "d", "p", tc) == ComposerKey("novox/r", testCommit, "d2", "p", tc) {
t.Error("the directory is not in the key")
}
}
// The part `build-step` is one layout for the three build seats: each seat's fixtures read strictly in the neutral
// forms (an unknown field, or a field this package lacks, fails). Where the generator cannot share a part, this is
// what holds the three seats' types and this form equal.
func TestTheThreeSeatsFixturesFitTheNeutralForms(t *testing.T) {
strict := func(t *testing.T, raw json.RawMessage, into any) {
t.Helper()
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(into); err != nil {
t.Error(err)
}
}
seen := 0
for _, seat := range []string{"node-build-go", "node-build-image", "node-build-node"} {
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "seats", seat, "fixtures", "states.json"))
if err != nil {
t.Fatal(err)
}
var fixtures []struct {
Name string `json:"name"`
Of string `json:"of"`
Given json.RawMessage `json:"given"`
}
if err := json.Unmarshal(raw, &fixtures); err != nil {
t.Fatal(err)
}
for _, f := range fixtures {
t.Run(seat+"/"+f.Name, func(t *testing.T) {
switch {
case strings.HasPrefix(f.Of, "accept:"):
seen++
strict(t, f.Given, &StepAsk{})
case strings.HasPrefix(f.Of, "state:pauses/paused."):
seen++
strict(t, f.Given, &Pause{})
case strings.HasPrefix(f.Of, "state:pauses/capacity."):
seen++
strict(t, f.Given, &Capacity{})
case strings.HasPrefix(f.Of, "state:steps/"):
seen++
strict(t, f.Given, &Step{})
}
})
}
}
if seen < 12 {
t.Errorf("only %d fixtures read", seen)
}
}
+107
View File
@@ -0,0 +1,107 @@
package buildstep
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
// The states `pauses` of the build seats, whose three seats hold the same layouts (the part `build-step`): a node's
// pause under `paused.<node>`, its capacity under `capacity.<node>`.
// PausesState is the name of the state.
const PausesState = "pauses"
// PauseKey is the key of a node's pause.
func PauseKey(node string) (string, error) {
if err := checkNode(node); err != nil {
return "", err
}
return "paused." + node, nil
}
// CapacityKey is the key of a node's capacity.
func CapacityKey(node string) (string, error) {
if err := checkNode(node); err != nil {
return "", err
}
return "capacity." + node, nil
}
// Pause is whether a node takes no new ask, and who said so.
type Pause struct {
V int `json:"v"`
Paused bool `json:"paused"`
By string `json:"by,omitempty"`
At time.Time `json:"at,omitzero"`
}
// EncodePause is p as a pauses value.
func EncodePause(p Pause) ([]byte, error) {
p.V = Version
p.At = p.At.UTC()
return json.Marshal(p)
}
// DecodePause reads a pause, refusing another version.
func DecodePause(data []byte) (Pause, error) {
var p Pause
if err := decodeVersioned(data, &p, func() int { return p.V }); err != nil {
return Pause{}, err
}
return p, nil
}
// Capacity is how many steps a node runs at once, and why.
type Capacity struct {
V int `json:"v"`
Effective int `json:"effective"`
Configured int `json:"configured"`
ConfiguredFrom string `json:"configured-from,omitempty"`
QuotaCores float64 `json:"quota-cores,omitempty"`
QuotaFrom string `json:"quota-from"`
CoresPerStep float64 `json:"cores-per-step"`
ByCPU int `json:"by-cpu,omitempty"`
BuildHours string `json:"build-hours,omitempty"`
OutsideHours bool `json:"outside-hours,omitempty"`
BuildHoursFault string `json:"build-hours-fault,omitempty"`
Ineligible string `json:"ineligible,omitempty"`
Why string `json:"why"`
At time.Time `json:"at,omitzero"`
}
// EncodeCapacity is c as a pauses value.
func EncodeCapacity(c Capacity) ([]byte, error) {
c.V = Version
c.At = c.At.UTC()
return json.Marshal(c)
}
// DecodeCapacity reads a capacity, refusing another version.
func DecodeCapacity(data []byte) (Capacity, error) {
var c Capacity
if err := decodeVersioned(data, &c, func() int { return c.V }); err != nil {
return Capacity{}, err
}
return c, nil
}
func decodeVersioned(data []byte, into any, version func() int) error {
if err := json.Unmarshal(data, into); err != nil {
return fmt.Errorf("buildstep: value does not fit its shape: %w", err)
}
if v := version(); v != Version {
return fmt.Errorf("%w: a value of version %d, this reader reads %d", ErrUnknownVersion, v, Version)
}
return nil
}
// ComposerKey is the key a composer build is kept under: the fetched commit, the directory, the package and the
// toolchain it ran with.
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])
}