diff --git a/sdk/buildstep/ask.go b/sdk/buildstep/ask.go index 640c0e36..8a065726 100644 --- a/sdk/buildstep/ask.go +++ b/sdk/buildstep/ask.go @@ -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-) and one seat's // (node-build-). @@ -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"` diff --git a/sdk/buildstep/ask_test.go b/sdk/buildstep/ask_test.go index 1c6d3082..9122037a 100644 --- a/sdk/buildstep/ask_test.go +++ b/sdk/buildstep/ask_test.go @@ -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:"}, + {"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) } diff --git a/sdk/buildstep/keptartifact.go b/sdk/buildstep/keptartifact.go new file mode 100644 index 00000000..0282f781 --- /dev/null +++ b/sdk/buildstep/keptartifact.go @@ -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:") 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 +} diff --git a/sdk/buildstep/pipelinestep.go b/sdk/buildstep/pipelinestep.go new file mode 100644 index 00000000..58093fd0 --- /dev/null +++ b/sdk/buildstep/pipelinestep.go @@ -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:", 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 +}