From eaa84387f7392b554da4411dda6612d122efe467 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 15:48:52 +0100 Subject: [PATCH] =?UTF-8?q?mesh-delivery-sdk=20buildstep:=20the=20build=20?= =?UTF-8?q?ask=20once,=20as=20one=20neutral=20form=20for=20the=20three=20b?= =?UTF-8?q?uild=20seats=20(the=20part=20build-step's=20StepAsk,=20Fetch,?= =?UTF-8?q?=20jobs,=20answers,=20kinds,=20languages,=20needs,=20subjects,?= =?UTF-8?q?=20pauses,=20capacity,=20composer=20key),=20one=20conversion=20?= =?UTF-8?q?AskOf=20for=20any=20seat's=20type;=20seatgen=20writes=20a=20pac?= =?UTF-8?q?kage=20per=20seat=20and=20shares=20no=20part,=20so=20the=20form?= =?UTF-8?q?=20lives=20here=20(B5e=20ruling=201;=20ADR=200634=20=C2=A76;=20?= =?UTF-8?q?issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- sdk/buildstep/ask.go | 622 ++++++++++++++++++++++++++++++++++++++ sdk/buildstep/ask_test.go | 261 ++++++++++++++++ sdk/buildstep/pause.go | 107 +++++++ 3 files changed, 990 insertions(+) create mode 100644 sdk/buildstep/ask.go create mode 100644 sdk/buildstep/ask_test.go create mode 100644 sdk/buildstep/pause.go diff --git a/sdk/buildstep/ask.go b/sdk/buildstep/ask.go new file mode 100644 index 00000000..be85a8af --- /dev/null +++ b/sdk/buildstep/ask.go @@ -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-) and one seat's +// (node-build-). +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 /", 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 /", 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 +} diff --git a/sdk/buildstep/ask_test.go b/sdk/buildstep/ask_test.go new file mode 100644 index 00000000..07b2d67a --- /dev/null +++ b/sdk/buildstep/ask_test.go @@ -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) + } +} diff --git a/sdk/buildstep/pause.go b/sdk/buildstep/pause.go new file mode 100644 index 00000000..9ce027b9 --- /dev/null +++ b/sdk/buildstep/pause.go @@ -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.`, its capacity under `capacity.`. + +// 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]) +}