SD2, SD3: the kept-artifact judgement and the neutral pipeline step ask and its end, shared by the runner and the three build modules

Ported from the dev branch (192f6ab2, 4f013f44, dc0df1b5) into the package sdk/buildstep where
they were built; the package is not named by a design line (see below).

Builds: design 47 G §271 (a module claiming oci-artifact-registry but not its holder in the seat
list is refused), §24 (a step's end is published on its node's subject), §416 (the step ask carries
the kept results), §370 (the version).

Not decided: the package of SD2 and SD3. G-9 §420 names sdk/steprun only for the step-run code
(SD1); no line places the kept-artifact judgement or the step ask.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 11:06:27 +01:00
parent 349a3519ef
commit c77e747f4e
4 changed files with 554 additions and 6 deletions
+21 -4
View File
@@ -28,10 +28,13 @@ const (
AskDelivery = "delivery"
AskCheck = "check"
AskRecheck = "recheck"
// AskStep is a pipeline step the runner sends (novox/hq design 47 *The steps and who serves them*: the accepted kind
// step on the stream check, after delivery and before recheck).
AskStep = "step"
)
// 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}
var AskKinds = []string{AskDelivery, AskCheck, AskStep, AskRecheck}
// The languages of the build seats: each is one build module's (mesh-build-<language>) and one seat's
// (node-build-<language>).
@@ -106,7 +109,7 @@ const (
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 fmt.Errorf("buildstep: the ask kind %q is not delivery, check, step or recheck", kind)
}
return nil
}
@@ -213,6 +216,9 @@ type StepAsk struct {
// Run is a run ask's job (run.go): a program built from a change and run over the heads, on the Go seat's check
// stream alone, as a compose.
Run *RunJob `json:"run,omitempty"`
// Step is a pipeline step the runner sends as the accepted kind step (novox/hq ADR 0666 §7): lint or tests of one
// artifact.
Step *StepRun `json:"step,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.
@@ -381,18 +387,22 @@ func (a StepAsk) check() error {
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, a.Run != nil} {
for _, set := range []bool{a.Build != nil, a.Check != nil, a.Compose != nil, a.Run != nil, a.Step != nil} {
if set {
jobs++
}
}
switch {
case jobs != 1:
return fmt.Errorf("buildstep: the ask %s holds a build, a check, a compose or a run, exactly one", a.ID)
return fmt.Errorf("buildstep: the ask %s holds a build, a check, a compose, a run or a step, 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.Run != nil && (a.Kind != AskCheck || a.Language != AskGo):
return fmt.Errorf("buildstep: the ask %s is a run, which the Go seat's check stream alone carries", a.ID)
case (a.Step != nil) != (a.Kind == AskStep):
return fmt.Errorf("buildstep: the ask %s is sent as %s and holds a pipeline step, or not: the accepted kind step carries a step alone", a.ID, a.Kind)
case a.Step != nil && a.Language == AskImage && a.Step.Step != StepLint:
return fmt.Errorf("buildstep: the ask %s is a %s step of an image, which has no tests", a.ID, a.Step.Step)
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:
@@ -419,6 +429,8 @@ func (a StepAsk) check() error {
return a.Compose.check(a.ID)
case a.Run != nil:
return a.Run.check(a.ID)
case a.Step != nil:
return a.Step.check(a.ID)
}
return a.Check.check(a.ID)
}
@@ -594,6 +606,10 @@ type BuildAnswer struct {
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
Imports []string `json:"imports,omitempty"`
OutOfStep []string `json:"out-of-step,omitempty"`
// Kept is the digest of the artifact this node kept for the artifact store's holder instead of storing it through
// the seat oci-artifact-registry (served by the verb kept-artifact; the keeping node is the step's own); empty when
// every artifact was stored.
Kept string `json:"kept,omitempty"`
}
// AskBuilt is one artifact a build made.
@@ -670,6 +686,7 @@ type StepResult struct {
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
Imports []string `json:"imports,omitempty"`
OutOfStep []string `json:"out-of-step,omitempty"`
Kept string `json:"kept,omitempty"`
Fetch *FetchAnswer `json:"fetch,omitempty"`
Levels []LevelAnswer `json:"levels,omitempty"`
Built bool `json:"built,omitempty"`
+140 -2
View File
@@ -10,6 +10,9 @@ import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
const (
@@ -44,10 +47,20 @@ func runAsk() StepAsk {
Input: in, Args: []string{"-verdict"}}}
}
// stepAsk is the runner's unit step of one artifact (086 A-D31): the names the lint step reads and a kept result.
func stepAsk() StepAsk {
return StepAsk{ID: testAskID, Kind: AskStep, Language: AskGo, Asker: "mesh-delivery-pipeline",
At: time.Date(2026, 11, 10, 10, 0, 0, 0, time.UTC),
Step: &StepRun{Pipeline: "main.12.nats", Step: StepUnit, Module: "nats", Artifact: "nats:program", Branch: "main",
Commit: testCommit, Revision: 12, Fingerprint: "fp-0123", Fetch: Fetch{Repository: "novox/mesh-catalog", Commit: testCommit},
Names: StepNames{Modules: []string{"nats"}, Repositories: []string{"novox/mesh-catalog"}, Branches: []string{"main"}},
Kept: []KeptResult{{Package: "./modules/nats/a", Digest: "sha256:bbbb", Level: StepUnit}}}}
}
// 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(), runAsk()} {
for _, a := range []StepAsk{goodBuild(), goodCheck(), runAsk(), stepAsk()} {
raw, err := EncodeStepAsk(a)
if err != nil {
t.Fatal(err)
@@ -88,7 +101,7 @@ func TestAskCheckAndRoundTrip(t *testing.T) {
edit func(*StepAsk)
want string
}{
{"run beside a check", func(a *StepAsk) { a.Check = goodCheck().Check }, "or a run, exactly one"},
{"run beside a check", func(a *StepAsk) { a.Check = goodCheck().Check }, "or a step, exactly one"},
{"run on the delivery stream", func(a *StepAsk) { a.Kind = AskDelivery }, "is a run"},
{"run on the Node seat", func(a *StepAsk) { a.Language = AskNode }, "is a run"},
{"run at a short commit", func(a *StepAsk) { a.Run.Program.Commit = "0123456" }, "no full commit"},
@@ -109,6 +122,33 @@ func TestAskCheckAndRoundTrip(t *testing.T) {
t.Errorf("%s: %v, want %q", c.name, err, c.want)
}
}
// 086 A-D31: a pipeline step is the accepted kind step alone, names its artifact of its module at a full commit and
// fingerprint, and carries the registrations' names; a rerun names the caller the runtime proved.
for _, c := range []struct {
name string
edit func(*StepAsk)
want string
}{
{"step beside a check", func(a *StepAsk) { a.Check = goodCheck().Check }, "exactly one"},
{"step sent as a check", func(a *StepAsk) { a.Kind = AskCheck }, "carries a step alone"},
{"check sent as a step", func(a *StepAsk) { a.Step, a.Check = nil, goodCheck().Check }, "carries a step alone"},
{"unit step of an image", func(a *StepAsk) { a.Language = AskImage }, "has no tests"},
{"unknown step", func(a *StepAsk) { a.Step.Step = "review" }, `the step "review"`},
{"artifact of another module", func(a *StepAsk) { a.Step.Artifact = "nats-tools:program" }, "not nats:<artifact>"},
{"step at a branch", func(a *StepAsk) { a.Step.Commit = "main" }, "no full commit"},
{"no fingerprint", func(a *StepAsk) { a.Step.Fingerprint = "" }, "no build-input fingerprint"},
{"no names", func(a *StepAsk) { a.Step.Names = StepNames{Modules: []string{"nats"}} }, "no repositories, branches of the registrations"},
{"rerun with no caller", func(a *StepAsk) { a.Step.Rerun = true }, "rerun with no caller"},
} {
a := stepAsk()
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)
}
}
if a := stepAsk(); !a.Step.Skips("./modules/nats/a", "sha256:bbbb") || a.Step.Skips("./modules/nats/a", "sha256:cccc") {
t.Error("a kept result skips its package at its digest and level only")
}
if _, err := RunInputOf("seat:mesh-delivery-checker.input", make([]byte, RunInputBound+1)); err == nil || !strings.Contains(err.Error(), "262145 bytes") {
t.Errorf("an input over its bound: %v", err)
}
@@ -244,9 +284,78 @@ func TestAskSubjectsStreamsAndKeys(t *testing.T) {
if _, err := CapacityKey("a.b"); err == nil {
t.Error("a dotted node keys")
}
// 086 A-D31: a step's end is said on its own node's subject, and one naming another node is refused.
subj, err := StepEndedSubject(AskNode, "ace")
if err != nil || subj != "mesh.seat.node-build-node.event.step-ended.ace" {
t.Fatalf("step-ended subject %q %v", subj, err)
}
if l, n, err := ParseStepEndedSubject(subj); err != nil || l != AskNode || n != "ace" {
t.Errorf("parse %q: %s %s %v", subj, l, n, err)
}
end, err := EndOf(stepAsk(), "ace", OutcomePassed)
if err != nil {
t.Fatal(err)
}
end.Tests = &TestCounts{Unit: 14}
raw, err := EncodeStepEnded(end)
if err != nil {
t.Fatal(err)
}
goSubj, _ := StepEndedSubject(AskGo, "ace")
if back, err := DecodeStepEnded(goSubj, raw); err != nil || back.Fingerprint != "fp-0123" || back.Tests.Unit != 14 {
t.Errorf("round trip: %+v %v", back, err)
}
g14, _ := StepEndedSubject(AskGo, "g14")
if _, err := DecodeStepEnded(g14, raw); err == nil || !strings.Contains(err.Error(), "said on g14's subject") {
t.Errorf("an end said on another node's subject: %v", err)
}
failed, _ := EndOf(stepAsk(), "ace", OutcomeCouldNotRun)
if _, err := EncodeStepEnded(failed); err == nil || !strings.Contains(err.Error(), "and not why") {
t.Errorf("could not run without why: %v", err)
}
if _, err := EndOf(goodCheck(), "ace", OutcomePassed); err == nil {
t.Error("a check has a step's end")
}
}
func TestDeclinesNeedsAndLevelDigest(t *testing.T) {
// design 47, the artifact store's holder takes its own new build (test 60): only the store holder's trunk build is
// kept, and kept-artifact answers the kept one or refuses another digest, a module claiming no store, one not kept.
t.Run("kept-artifact", func(t *testing.T) {
holder := catalogue.Manifest{Module: "demo", Claims: []catalogue.Claim{{Name: StoreSeat, Scope: "mesh"}}}
if KeepOf(true, holder, "fp1") == nil || KeepOf(false, holder, "fp1") != nil || KeepOf(true, catalogue.Manifest{Module: "web"}, "fp1") != nil {
t.Error("only the store holder's trunk build is kept")
}
hex := strings.Repeat("a", 64)
name, desc := KeptObject(KeptFor{Module: "demo", Fp: "fp1"}, "ace", "sha256:"+hex)
at := time.Date(2026, 11, 10, 10, 0, 0, 0, time.UTC)
kept := KeptBuildsOf([]buscalls.ObjectInfo{{Name: name, Size: 9, Description: desc, Modified: at},
{Name: "bb", Description: `{"module":"demo","fp":"fp1","node":"g14"}`}}, "ace")
if len(kept) != 1 || kept[0].Digest != hex || kept[0].Bytes != 9 {
t.Fatalf("kept on ace: %+v", kept)
}
claims := func(m string) (bool, error) {
man := `{"module":"` + m + `"}`
if m != "other" {
man = `{"module":"` + m + `","claims":[{"name":"oci-artifact-registry","scope":"mesh"}]}`
}
return ClaimsStoreOf(m, []byte(`{"module":{"module":"`+m+`","manifest":`+man+`}}`))
}
for _, c := range []struct{ name, module, digest, refused, store string }{
{"the kept build", "demo", hex, "", "mesh-build-go_kept-builds"},
{"another digest", "demo", strings.Repeat("b", 64), KeptRefusedDigestDiffers, ""},
{"a module claiming no store", "other", hex, KeptRefusedClaimsNoStore, ""},
{"a holder not kept here", "lost", hex, KeptRefusedNotKept, ""},
} {
a := KeptArtifact(c.module, "fp1", c.digest, kept, "mesh-build-go_kept-builds", claims)
if a.Refused != c.refused || a.Store != c.store || a.Error != "" || (c.refused != "" && !strings.Contains(a.Words, c.module)) {
t.Errorf("%s: %+v", c.name, a)
}
}
if a := KeptArtifact("lost", "fp1", hex, kept, "s", func(string) (bool, error) { return false, errors.New("no answer") }); !strings.Contains(a.Error, "no answer") {
t.Errorf("an unanswered catalog: %+v", a)
}
})
if got := Lacks([]string{NeedNodeBuildNode}, nil); len(got) != 1 {
t.Errorf("a nil has lacks all: %v", got)
}
@@ -405,6 +514,35 @@ func TestTheThreeSeatsFixturesFitTheNeutralForms(t *testing.T) {
})
}
}
// 086 A-D31: each seat's step-ended fixtures are the neutral StepEnded, said on their node's subject.
ended := 0
for _, seat := range []string{"node-build-go", "node-build-image", "node-build-node"} {
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "seats", seat, "fixtures", "events.json"))
if err != nil {
t.Fatal(err)
}
var fixtures []struct {
Name string `json:"name"`
Of string `json:"of"`
Subject string `json:"subject"`
Given json.RawMessage `json:"given"`
}
if err := json.Unmarshal(raw, &fixtures); err != nil {
t.Fatal(err)
}
for _, f := range fixtures {
if f.Of == "event:step-ended" {
ended++
strict(t, f.Given, &StepEnded{})
if _, err := DecodeStepEnded(f.Subject, f.Given); err != nil {
t.Errorf("%s/%s: %v", seat, f.Name, err)
}
}
}
}
if ended < 6 {
t.Errorf("only %d step-ended fixtures read", ended)
}
if seen < 12 {
t.Errorf("only %d fixtures read", seen)
}
+148
View File
@@ -0,0 +1,148 @@
package buildstep
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// **The artifact store's holder takes its own new build even when its seat is the one broken** (novox/hq design 47,
// lines 1888-1906 at b33ac325; test 60): when the seat oci-artifact-registry refuses or does not answer the store of a
// trunk build of a module that claims that seat, the build module keeps that build on the bus, one object per build
// named by its digest, and the node-engine composing the holder asks the build seats' verb kept-artifact where it is.
// The judgement the three build modules share is here (ADR 0634 §2): what may be kept, the kept builds a node holds,
// and the verb's answer with its three refusals. The store and the verb's serving are each build module's own.
// StoreSeat is the seat whose holder's builds a build node keeps.
const StoreSeat = "oci-artifact-registry"
// The refusals of kept-artifact, the words of the part build-step's enum.
const (
KeptRefusedClaimsNoStore = "claims no " + StoreSeat
KeptRefusedNotKept = "not kept"
KeptRefusedDigestDiffers = "digest differs"
)
// CatalogModuleVerb is the seat mesh-catalog's verb that answers one module's manifest, as a manifest invokes it.
const CatalogModuleVerb = "seat:mesh-catalog.module"
// KeptFor is what a build keeps a refused artifact for: the module, which claims the store's seat, and its build's
// fingerprint, which kept-artifact is asked by.
type KeptFor struct{ Module, Fp string }
// KeepOf is the keep of a build, or nil when nothing may be kept: only a trunk build (publish) of a module whose manifest
// at the built commit claims the store's seat, named by its fingerprint.
func KeepOf(publish bool, manifest catalogue.Manifest, fp string) *KeptFor {
if !publish || fp == "" || !manifest.ClaimsSeat(StoreSeat) {
return nil
}
return &KeptFor{Module: manifest.Module, Fp: fp}
}
// KeptBuild is one kept build: the object's description names its module, fingerprint and the node that kept it.
type KeptBuild struct {
Module string `json:"module"`
Fp string `json:"fp"`
Node string `json:"node"`
Digest string `json:"digest"`
Bytes uint64 `json:"bytes"`
LastUsed time.Time `json:"last-used"`
}
// KeptObject is the object name (the hex digest of "sha256:<hex>") and description a build is kept under on node.
func KeptObject(k KeptFor, node, digest string) (name, description string) {
desc, _ := json.Marshal(KeptBuild{Module: k.Module, Fp: k.Fp, Node: node})
return strings.TrimPrefix(digest, "sha256:"), string(desc)
}
// KeptBuildsOf are the builds node keeps among a store's objects, by digest.
func KeptBuildsOf(infos []buscalls.ObjectInfo, node string) []KeptBuild {
out := []KeptBuild{}
for _, i := range infos {
var k KeptBuild
if i.Deleted || json.Unmarshal([]byte(i.Description), &k) != nil || k.Node != node {
continue
}
k.Digest, k.Bytes, k.LastUsed = i.Name, i.Size, i.Modified.UTC()
out = append(out, k)
}
slices.SortFunc(out, func(a, b KeptBuild) int { return strings.Compare(a.Digest, b.Digest) })
return out
}
// ClaimsStoreOf reads the seat mesh-catalog's answer of one module (CatalogModuleVerb): whether its manifest claims the
// store's seat; a module the catalog holds no manifest of claims nothing.
func ClaimsStoreOf(module string, raw []byte) (bool, error) {
var a struct {
Module struct {
Manifest json.RawMessage `json:"manifest"`
} `json:"module"`
}
if err := json.Unmarshal(raw, &a); err != nil {
return false, fmt.Errorf("%s answered what is no module: %w", CatalogModuleVerb, err)
}
if len(a.Module.Manifest) == 0 {
return false, nil
}
man, err := catalogue.Decode(a.Module.Manifest)
if err != nil {
return false, fmt.Errorf("the manifest of %s: %w", module, err)
}
return man.ClaimsSeat(StoreSeat), nil
}
// KeptArtifactAnswer is the verb kept-artifact's answer, the part build-step's layout.
type KeptArtifactAnswer struct {
Module string `json:"module"`
Fp string `json:"fp"`
Digest string `json:"digest,omitempty"`
Size int64 `json:"size,omitempty"`
Store string `json:"store,omitempty"`
Object string `json:"object,omitempty"`
Refused string `json:"refused,omitempty"`
Words string `json:"words,omitempty"`
Error string `json:"error,omitempty"`
}
// KeptArtifact answers kept-artifact for module, fp and the hex digest from the builds this node keeps, in store (the
// store's name on the bus); claims asks the seat mesh-catalog whether a module claims the store's seat, only when no
// build of it is kept here at that digest.
func KeptArtifact(module, fp, digest string, kept []KeptBuild, store string, claims func(module string) (bool, error)) KeptArtifactAnswer {
a := KeptArtifactAnswer{Module: module, Fp: fp}
var other *KeptBuild
for i, k := range kept {
if k.Module != module || k.Fp != fp {
continue
}
if k.Digest == digest {
a.Digest, a.Size, a.Object = k.Digest, int64(k.Bytes), k.Digest
if store == "" {
a.Error = "this module runs with no name of its own (MESH_MODULE), so its store's name on the bus is unknown"
return a
}
a.Store = store
return a
}
other = &kept[i]
}
ok, err := claims(module)
switch {
case err != nil:
a.Error = "asking whether " + module + " claims " + StoreSeat + ": " + err.Error()
case !ok:
a.Refused = KeptRefusedClaimsNoStore
a.Words = fmt.Sprintf("%s claims no %s: a build node keeps builds for that seat's holder alone", module, StoreSeat)
case other != nil:
a.Refused, a.Digest = KeptRefusedDigestDiffers, other.Digest
a.Words = fmt.Sprintf("this node keeps the build of %s at %s under the digest %s, not %s", module, fp, other.Digest, digest)
default:
a.Refused = KeptRefusedNotKept
a.Words = fmt.Sprintf("this node keeps no build of %s at %s (never kept, or swept)", module, fp)
}
return a
}
+245
View File
@@ -0,0 +1,245 @@
package buildstep
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
meshkeys "git.novox.be/novox/mesh-sdk/go/keys"
)
// **A pipeline step, once.** The runner (seat mesh-pipeline-runner) asks each lint and test step of an artifact on the
// serving build seat's stream check, and the seat's holder says its end as the seat's event step-ended on its own node's
// subject (novox/hq ADR 0666 §7, §8, §21; design 47 *Pipeline steps and the policy*). These are the neutral forms of the
// part build-step's StepRun and StepEnded, which the three build modules share; the publish itself is the holder's.
// The pipeline steps a build seat runs.
const (
StepLint = "lint"
StepUnit = "unit"
StepIntegration = "integration"
)
// PipelineSteps are the steps a build seat serves, in a pipeline's order.
var PipelineSteps = []string{StepLint, StepUnit, StepIntegration}
// The outcomes a build seat says of a step: never passed for a step that ran nothing.
const (
OutcomePassed = "passed"
OutcomeFailed = "failed"
OutcomeNothingToRun = "nothing to run"
OutcomeCouldNotRun = "could not run"
)
// StepOutcomes are the outcomes a build seat may say.
var StepOutcomes = []string{OutcomePassed, OutcomeFailed, OutcomeNothingToRun, OutcomeCouldNotRun}
// StepEndedVersion is the layout version of a step's end.
const StepEndedVersion = 1
// StepNames are the names the registrations hold, which the lint step refuses fixed in code logic (ADR 0666 §5):
// carried in the ask, so the build module reads no other part for them.
type StepNames struct {
Modules []string `json:"modules"`
Repositories []string `json:"repositories"`
Branches []string `json:"branches"`
}
// KeptResult is a package's passing result at the digest of what its tests read (ADR 0666 §8).
type KeptResult struct {
Package string `json:"package"`
Digest string `json:"digest"`
Level string `json:"level"`
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
Pipeline string `json:"pipeline,omitempty"`
}
// TestCounts are how many tests a step ran and carried, per level.
type TestCounts struct {
Unit int `json:"unit,omitempty"`
Integration int `json:"integration,omitempty"`
}
// StepRun is one pipeline step of one artifact at one commit.
type StepRun struct {
Pipeline string `json:"pipeline"`
Step string `json:"step"`
Module string `json:"module"`
Artifact string `json:"artifact"`
Branch string `json:"branch,omitempty"`
Commit string `json:"commit"`
Revision int `json:"revision,omitempty"`
Fingerprint string `json:"fingerprint"`
Fetch Fetch `json:"fetch"`
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
Names StepNames `json:"names"`
Kept []KeptResult `json:"kept,omitempty"`
Rerun bool `json:"rerun,omitempty"`
By string `json:"by,omitempty"`
}
// Skips says whether the step runs no tests of pkg at digest, its kept result holding them.
func (r StepRun) Skips(pkg, digest string) bool {
return slices.ContainsFunc(r.Kept, func(k KeptResult) bool {
return k.Package == pkg && k.Digest == digest && k.Level == r.Step
})
}
func (r StepRun) check(id string) error {
if !slices.Contains(PipelineSteps, r.Step) {
return fmt.Errorf("buildstep: the step ask %s names the step %q, not %s", id, r.Step, strings.Join(PipelineSteps, ", "))
}
if r.Pipeline == "" {
return fmt.Errorf("buildstep: the step ask %s names no pipeline", id)
}
if err := meshkeys.CheckName("module", r.Module); err != nil {
return fmt.Errorf("the step ask %s: %w", id, err)
}
if mod, art, ok := strings.Cut(r.Artifact, ":"); !ok || mod != r.Module || art == "" {
return fmt.Errorf("buildstep: the step ask %s names the artifact %q, not %s:<artifact>", id, r.Artifact, r.Module)
}
if !askCommitOK.MatchString(r.Commit) {
return fmt.Errorf("buildstep: the step ask %s names no full commit (%q)", id, r.Commit)
}
if r.Fingerprint == "" {
return fmt.Errorf("buildstep: the step ask %s names no build-input fingerprint", id)
}
if err := r.Fetch.check(id); err != nil {
return err
}
var missing []string
for _, n := range []struct {
name string
list []string
}{{"modules", r.Names.Modules}, {"repositories", r.Names.Repositories}, {"branches", r.Names.Branches}} {
if len(n.list) == 0 {
missing = append(missing, n.name)
}
}
if len(missing) > 0 {
return fmt.Errorf("buildstep: the step ask %s names no %s of the registrations, which the lint step reads", id, strings.Join(missing, ", "))
}
if r.Rerun != (r.By != "") {
return fmt.Errorf("buildstep: the step ask %s is a rerun with no caller, or names a caller and is no rerun", id)
}
return nil
}
// StepEnded is a pipeline step's end, said by the build seat's holder on the node that ran it.
type StepEnded struct {
V int `json:"v"`
ID string `json:"id"`
Pipeline string `json:"pipeline"`
Step string `json:"step"`
Module string `json:"module"`
Artifact string `json:"artifact"`
Commit string `json:"commit"`
Fingerprint string `json:"fingerprint"`
Node string `json:"node"`
Outcome string `json:"outcome"`
Tests *TestCounts `json:"tests,omitempty"`
Carried []KeptResult `json:"carried,omitempty"`
Kept []KeptResult `json:"kept,omitempty"`
Said string `json:"said,omitempty"`
Output string `json:"output,omitempty"`
OutputObject string `json:"output-object,omitempty"`
Rerun bool `json:"rerun,omitempty"`
By string `json:"by,omitempty"`
Started time.Time `json:"started,omitzero"`
Ended time.Time `json:"ended,omitzero"`
}
// EndOf is the end of the step a holds, on node, with outcome: the ask's names copied, so the runner can match the ask,
// the commit and the fingerprint (ADR 0666 §21).
func EndOf(a StepAsk, node, outcome string) (StepEnded, error) {
if a.Step == nil {
return StepEnded{}, fmt.Errorf("buildstep: the ask %s is no pipeline step", a.ID)
}
s := a.Step
e := StepEnded{V: StepEndedVersion, ID: a.ID, Pipeline: s.Pipeline, Step: s.Step, Module: s.Module, Artifact: s.Artifact,
Commit: s.Commit, Fingerprint: s.Fingerprint, Node: node, Outcome: outcome, Rerun: s.Rerun, By: s.By}
return e, e.check()
}
func (e StepEnded) check() error {
if err := CheckAskID(e.ID); err != nil {
return err
}
if err := checkNode(e.Node); err != nil {
return err
}
if !slices.Contains(PipelineSteps, e.Step) {
return fmt.Errorf("buildstep: the end of %s names the step %q", e.ID, e.Step)
}
if !slices.Contains(StepOutcomes, e.Outcome) {
return fmt.Errorf("buildstep: the end of %s says %q, not one of %s", e.ID, e.Outcome, strings.Join(StepOutcomes, ", "))
}
if e.Pipeline == "" || e.Module == "" || e.Artifact == "" || !askCommitOK.MatchString(e.Commit) || e.Fingerprint == "" {
return fmt.Errorf("buildstep: the end of %s names no pipeline, module, artifact, full commit or fingerprint", e.ID)
}
return nil
}
// EncodeStepEnded is a step's end as its event's body; one that could not run or failed says why.
func EncodeStepEnded(e StepEnded) ([]byte, error) {
e.V = StepEndedVersion
if err := e.check(); err != nil {
return nil, err
}
if (e.Outcome == OutcomeCouldNotRun || e.Outcome == OutcomeFailed) && e.Said == "" {
return nil, fmt.Errorf("buildstep: the end of %s says %s and not why", e.ID, e.Outcome)
}
return json.Marshal(e)
}
// DecodeStepEnded reads a step's end heard on subject: the node it names must be the subject's, the one node whose
// holder's account may publish there (ADR 0363 §2, ADR 0666 §21).
func DecodeStepEnded(subject string, data []byte) (StepEnded, error) {
_, node, err := ParseStepEndedSubject(subject)
if err != nil {
return StepEnded{}, err
}
var e StepEnded
if err := json.Unmarshal(data, &e); err != nil {
return StepEnded{}, fmt.Errorf("buildstep: a step's end does not fit its shape: %w", err)
}
if e.V != StepEndedVersion {
return StepEnded{}, fmt.Errorf("%w: a step's end of version %d, this reader reads %d", ErrUnknownVersion, e.V, StepEndedVersion)
}
if err := e.check(); err != nil {
return StepEnded{}, err
}
if e.Node != node {
return StepEnded{}, fmt.Errorf("buildstep: the end of %s names the node %s and was said on %s's subject", e.ID, e.Node, node)
}
return e, nil
}
// StepEndedSubject is where the holder of a language's build seat on node says a step's end.
func StepEndedSubject(language, node string) (string, error) {
seat, err := BuildSeat(language)
if err != nil {
return "", err
}
if err := checkNode(node); err != nil {
return "", err
}
return "mesh.seat." + seat + ".event.step-ended." + node, nil
}
// ParseStepEndedSubject is the inverse of StepEndedSubject.
func ParseStepEndedSubject(subject string) (language, node string, err error) {
rest, ok := strings.CutPrefix(subject, "mesh.seat.node-build-")
if ok {
language, node, ok = strings.Cut(rest, ".event.step-ended.")
}
if !ok {
return "", "", fmt.Errorf("buildstep: %q is not a build seat's step-ended subject", subject)
}
if want, err := StepEndedSubject(language, node); err != nil || want != subject {
return "", "", fmt.Errorf("buildstep: %q is not a build seat's step-ended subject: %v", subject, err)
}
return language, node, nil
}