Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b635e2138 | ||
|
|
db62e82e26 | ||
|
|
47ab8cd3cf | ||
|
|
12f61998c5 | ||
|
|
5b09d1ccb5 | ||
|
|
bbc690539b | ||
|
|
bbb42d704c | ||
|
|
f2379dfeea | ||
|
|
113f7aa933 | ||
|
|
b519c8dde2 | ||
|
|
7214bf4368 |
@@ -57,6 +57,7 @@ type fakeBuildModules struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]int // by `<module>.<tool>`
|
||||
fetches []map[string]any
|
||||
builds []map[string]any // the arguments of every build call
|
||||
}
|
||||
|
||||
func (f *fakeBuildModules) count(tool string) int {
|
||||
@@ -88,6 +89,10 @@ func (f *fakeBuildModules) serve(t *testing.T, nc *nats.Conn, module string) {
|
||||
f.mu.Lock()
|
||||
f.fetches = append(f.fetches, args)
|
||||
f.mu.Unlock()
|
||||
} else {
|
||||
f.mu.Lock()
|
||||
f.builds = append(f.builds, args)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
id := fmt.Sprintf("s%d", f.n.Add(1))
|
||||
key, _ := delivery.StepKey(node, id)
|
||||
@@ -109,9 +114,16 @@ func (f *fakeBuildModules) serve(t *testing.T, nc *nats.Conn, module string) {
|
||||
step.Ended = time.Now().UTC()
|
||||
switch {
|
||||
case strings.HasSuffix(tool, "_fetch"):
|
||||
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"workspace":"w`+id+`","commit":"`+commit+`"}`)
|
||||
merge := ""
|
||||
if args["base"] != nil { // a fetch with a base answers the merge commit (mesh-build-go FetchResult)
|
||||
merge = `,"merge":"` + strings.Repeat("9", 40) + `"`
|
||||
}
|
||||
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"workspace":"w`+id+`","commit":"`+commit+`"`+merge+`}`)
|
||||
case f.failBuild.Load() && node == "n1":
|
||||
step.State, step.Reason = delivery.StepFailed, fmt.Sprintf("go build of %v: boom", args["artifact"])
|
||||
case tool == "build_image_build" && args["publish"] == true: // a trunk image is pushed: the digest and the reference
|
||||
d := digestOf(fmt.Sprint(args["artifact"]))
|
||||
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"digest":"`+d+`","reference":"registry.example/imaged/server@`+d+`"}`)
|
||||
default:
|
||||
step.State, step.Result = delivery.StepPassed, json.RawMessage(`{"digest":"`+digestOf(fmt.Sprint(args["artifact"]))+`"}`)
|
||||
}
|
||||
@@ -214,6 +226,8 @@ func startBed(t *testing.T) *bed {
|
||||
b.fake = &fakeBuildModules{js: js, ctx: ctx, calls: map[string]int{}}
|
||||
b.fake.serve(t, ctl, "mesh-build-go")
|
||||
b.fake.serve(t, ctl, "mesh-build-node")
|
||||
b.fake.serve(t, ctl, "mesh-build-image")
|
||||
b.setHealth(t, "n1", "mesh-build-image", delivery.HealthOK) // the image build module runs on n1 only
|
||||
b.report(t, "mesh-build-go", "n1", "go", "go1.27.1", "1")
|
||||
b.report(t, "mesh-build-go", "n2", "go", "go1.27.1", "1")
|
||||
b.report(t, "mesh-build-node", "n1", "typescript", "tsc5", "1")
|
||||
@@ -578,3 +592,85 @@ func TestBuilderBuildsACandidatesFingerprintFromTheHeadMergedOntoTheTrunk(t *tes
|
||||
t.Errorf("fetches = %v, want one of the candidate's head with its ref and the trunk commit", b.fake.fetches)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes: modules.imaged (one image artifact) is built on n1, where
|
||||
// mesh-build-image runs: a trunk record's key is done with the digest and the reference, its build call carrying publish; a
|
||||
// candidate's key for the same module is built first with no publish and no reference; when the pull request is merged and the
|
||||
// trunk's record names the candidate's fingerprint, the key is built again with publish and ends with a reference
|
||||
// (ADR 0487 clauses 5 and 12).
|
||||
func TestAnImageIsBuiltOnOneNodeAndOnlyATrunkKeyPublishes(t *testing.T) { // 1705
|
||||
b := startBed(t)
|
||||
other := "src2:" + strings.Repeat("e", 64)
|
||||
digest := b.moduleCommit(t, "imaged", "catalogue-a67f0196-imaged.module.json", "modules/imaged", nil, commit)
|
||||
// The trunk carries `other`; its key is built and published.
|
||||
trunk := func(fp2 string) {
|
||||
data, err := delivery.EncodeModule(delivery.Module{Repository: "novox/mesh-catalog", Commit: commit, FP: "src1:" + strings.Repeat("f", 64), FP2: fp2,
|
||||
Manifest: digest, Path: "modules/imaged"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := b.modules.Put(b.ctx, "imaged", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
trunk(other)
|
||||
b.start(t, "n1", nil)
|
||||
okey, _ := delivery.BuilderBuildKey("imaged", other)
|
||||
awaitKey := func(key string, pred func(delivery.BuilderBuild) bool) delivery.BuilderBuild {
|
||||
var last delivery.BuilderBuild
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if e, err := b.builds.Get(b.ctx, key); err == nil {
|
||||
if last, err = delivery.DecodeBuilderBuild(e.Value()); err == nil && pred(last) {
|
||||
return last
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("builds.%s = %+v within 20 s", key, last)
|
||||
return last
|
||||
}
|
||||
v := awaitKey(okey, isState(delivery.BuildDone))
|
||||
if len(v.Artifacts) != 1 || v.Artifacts[0].Digest != digestOf("server") || !strings.HasSuffix(v.Artifacts[0].Reference, "@"+digestOf("server")) {
|
||||
t.Fatalf("trunk build = %+v, want the digest and the reference", v)
|
||||
}
|
||||
// The candidate carries fp: built, not published.
|
||||
const head = "1111111111111111111111111111111111111111"
|
||||
pulls := []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-catalog", Number: 7, Head: head}, Modules: []string{"imaged"},
|
||||
Fps: map[string]delivery.CandidateModule{"imaged": {FP2: fp, Manifest: digest}}}}
|
||||
hash, _ := delivery.HeadsHash([]delivery.Head{pulls[0].Head})
|
||||
cand, err := delivery.EncodeCandidate(delivery.Candidate{V: delivery.Version, Group: "feat/x", HeadsHash: hash, Pulls: pulls, FPSet: delivery.FingerprintSet(pulls)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ckey, _ := delivery.CandidatesKey("feat/x")
|
||||
if _, err := b.candidates.Put(b.ctx, ckey, cand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v = b.await(t, "imaged", isState(delivery.BuildDone))
|
||||
if len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" {
|
||||
t.Fatalf("candidate build = %+v, want done with no reference", v)
|
||||
}
|
||||
b.fake.mu.Lock()
|
||||
if len(b.fake.builds) != 2 || b.fake.builds[0]["publish"] != true || b.fake.builds[1]["publish"] != nil {
|
||||
t.Errorf("builds = %v, want the trunk's with publish, then the candidate's with none", b.fake.builds)
|
||||
}
|
||||
b.fake.mu.Unlock()
|
||||
// The pull request is merged: the trunk's record names fp, and its key ends published, built once more with publish.
|
||||
trunk(fp)
|
||||
v = b.await(t, "imaged", func(v delivery.BuilderBuild) bool {
|
||||
return v.State == delivery.BuildDone && len(v.Artifacts) == 1 && v.Artifacts[0].Reference != ""
|
||||
})
|
||||
if v.Artifacts[0].Digest != digestOf("server") {
|
||||
t.Errorf("merged build = %+v", v)
|
||||
}
|
||||
b.fake.mu.Lock()
|
||||
defer b.fake.mu.Unlock()
|
||||
published := 0
|
||||
for _, a := range b.fake.builds {
|
||||
if a["publish"] == true {
|
||||
published++
|
||||
}
|
||||
}
|
||||
if len(b.fake.builds) != 3 || published != 2 {
|
||||
t.Errorf("builds = %v, want three, two of them with publish (the trunk's two fingerprints)", b.fake.builds)
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"module": "imaged",
|
||||
"version": "1",
|
||||
"slug": "imaged",
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "server",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -94,3 +95,20 @@ func TestAFingerprintTheTrunkCarriesIsBuiltFromTheTrunksCommit(t *testing.T) { /
|
||||
t.Errorf("build = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACandidateWhoseHeadDoesNotMergeIsFailedAndNoTrunkBuildIsStoredUnderIt(t *testing.T) { // review D4
|
||||
for name, set := range map[string]func(*rig){
|
||||
"conflict": func(r *rig) { r.bm.conflicts = "go.mod" },
|
||||
"no merge": func(r *rig) { r.bm.noMerge = true },
|
||||
} {
|
||||
r := newRig(t, goBundle)
|
||||
trunkModule(t, r)
|
||||
set(r)
|
||||
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
|
||||
r.b.Wait()
|
||||
v := valueOf(t, r.st, r.key)
|
||||
if v.State != delivery.BuildFailed || (name == "conflict" && !strings.Contains(v.Error, "go.mod")) || len(r.bm.callsOf("build_go_build")) != 0 {
|
||||
t.Errorf("%s: build = %+v, builds %d, want failed and nothing built", name, v, len(r.bm.callsOf("build_go_build")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,26 @@ type ClaimOptions struct {
|
||||
// qualifies now and the key may be built again (it is not held back after a failed second build, issue 1664). Nil means
|
||||
// none does.
|
||||
MoreNodes func(modules []string) bool
|
||||
// ImageBuilder says whether a node runs a healthy image build module now: a key failed for NoImageBuilderPrefix is taken
|
||||
// again only then. Nil means none does.
|
||||
ImageBuilder func() bool
|
||||
// Trunk is whether the trunk's record names this fingerprint. A done key whose image has no reference was built as a
|
||||
// candidate's, which never pushes (ADR 0487 clause 12); on the trunk it is taken again to publish the image, when an image
|
||||
// builder runs (ImageBuilder).
|
||||
Trunk bool
|
||||
}
|
||||
|
||||
// Unpublished is whether a done build holds an image built without a push: it has no reference.
|
||||
func Unpublished(b delivery.BuilderBuild) bool {
|
||||
if b.State != delivery.BuildDone {
|
||||
return false
|
||||
}
|
||||
for _, a := range b.Artifacts {
|
||||
if a.Kind == "image" && a.Reference == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Claim takes the build of module at fingerprint, or answers nil when it is not this builder's to build now:
|
||||
@@ -72,7 +92,8 @@ type ClaimOptions struct {
|
||||
// builder writes the new revision when the second build ends (ADR 0478 clause 4);
|
||||
// - a failed key is taken again as a new revision of the same key (ADR 0429 clause 18), except one failed for a kind no
|
||||
// builder builds yet (RefusedPrefix, ADR 0478 clause 2) or for digests that differ (buildrun.DigestsDifferPrefix, clause 4),
|
||||
// which wait for another fingerprint;
|
||||
// which wait for another fingerprint, and one failed for want of an image builder (NoImageBuilderPrefix), which waits for
|
||||
// another fingerprint or a node that runs the image build module (ClaimOptions.ImageBuilder);
|
||||
// - a key left queued or building by this builder is taken as it stands (ADR 0478 clause 5: a stopped builder takes its
|
||||
// own again when it starts), and so is one left by another builder when ClaimOptions.TakeOver (clause 6).
|
||||
func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string, opt ClaimOptions) (*Claim, error) {
|
||||
@@ -97,9 +118,14 @@ func (c Claimer) Claim(ctx context.Context, module, fingerprint, manifest string
|
||||
if modules := OneNodeModules(cur); len(modules) > 0 && opt.MoreNodes != nil && opt.MoreNodes(modules) {
|
||||
return &Claim{Key: key, Rev: rev, Build: cur}, nil
|
||||
}
|
||||
if opt.Trunk && Unpublished(cur) && opt.ImageBuilder != nil && opt.ImageBuilder() {
|
||||
return &Claim{Key: key, Rev: rev, Build: cur}, nil
|
||||
}
|
||||
return nil, nil
|
||||
case cur.State == delivery.BuildFailed && (strings.HasPrefix(cur.Error, RefusedPrefix) || strings.HasPrefix(cur.Error, buildrun.DigestsDifferPrefix)):
|
||||
return nil, nil
|
||||
case cur.State == delivery.BuildFailed && strings.HasPrefix(cur.Error, NoImageBuilderPrefix) && (opt.ImageBuilder == nil || !opt.ImageBuilder()):
|
||||
return nil, nil
|
||||
case cur.State == delivery.BuildFailed:
|
||||
return c.write(ctx, key, building, func(data []byte) (uint64, error) { return c.Store.Update(ctx, key, data, rev) })
|
||||
case cur.By == c.By || opt.TakeOver:
|
||||
|
||||
@@ -155,6 +155,22 @@ func TestAFailedKeyIsTakenAgainAsANewRevisionExceptARefusedKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyFailedForNoImageBuilderIsTakenAgainOnlyWhenANodeHasTheModule(t *testing.T) { // 1705b
|
||||
st := newMem()
|
||||
key := seed(t, st, delivery.BuilderBuild{State: delivery.BuildFailed, By: "novox.a", Error: NoImageBuilderPrefix + " no node has mesh-build-image"})
|
||||
writes := st.writes
|
||||
c := Claimer{Store: st, By: "novox.a"}
|
||||
for _, opt := range []ClaimOptions{{}, {ImageBuilder: func() bool { return false }}} {
|
||||
if got, err := c.Claim(context.Background(), "m", fp1, "", opt); err != nil || got != nil || st.writes != writes {
|
||||
t.Errorf("claim = %v, %v, writes %d (was %d), want none and no write", got, err, st.writes, writes)
|
||||
}
|
||||
}
|
||||
got, err := c.Claim(context.Background(), "m", fp1, hex64('a'), ClaimOptions{ImageBuilder: func() bool { return true }})
|
||||
if err != nil || got == nil || valueOf(t, st, key).State != delivery.BuildBuilding {
|
||||
t.Errorf("claim = %v, %v, want a claim with the key building", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishMovesTheKeyByCompareAndSetAndWritesOnlyADifferentValue(t *testing.T) { // 1607/AC3
|
||||
st := newMem()
|
||||
c := Claimer{Store: st, By: "novox.a"}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package build
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// result reads a captured step value of mesh-build-image (testdata/captured/README) and returns its result.
|
||||
func captured(t *testing.T, name string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/captured/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var step delivery.Step
|
||||
if err := json.Unmarshal(raw, &step); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if step.State != delivery.StepPassed || len(step.Result) == 0 {
|
||||
t.Fatalf("%s: state %q, result %s, want a passed step with a result", name, step.State, step.Result)
|
||||
}
|
||||
return step.Result
|
||||
}
|
||||
|
||||
func TestCapturedImageStepsDecodeWithTheBuildersOwnTypes(t *testing.T) { // review r2, mesh-catalog 2; ADR 0487 clause 1
|
||||
var f fetchResult
|
||||
if err := json.Unmarshal(captured(t, "fetch.json"), &f); err != nil || f.Workspace == "" {
|
||||
t.Errorf("fetch = %+v, %v, want a workspace (the field the builder reads)", f, err)
|
||||
}
|
||||
var trunk buildResult
|
||||
if err := json.Unmarshal(captured(t, "build-trunk.json"), &trunk); err != nil || trunk.Digest == "" || trunk.Reference == "" {
|
||||
t.Errorf("trunk build = %+v, %v, want a digest and a reference", trunk, err)
|
||||
}
|
||||
var cand buildResult
|
||||
if err := json.Unmarshal(captured(t, "build-candidate.json"), &cand); err != nil || cand.Digest == "" || cand.Reference != "" {
|
||||
t.Errorf("candidate build = %+v, %v, want a digest and no reference", cand, err)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@ import (
|
||||
// a key is not built again as a new revision.
|
||||
const RefusedPrefix = "refused kind:"
|
||||
|
||||
// ImageBuildModule is the build module that builds an image artifact (novox/hq ADR 0487): buildah, rootless, on the nodes that
|
||||
// run it. It has no toolchain language; the builder finds its nodes from the node-engine's have.
|
||||
const ImageBuildModule = "mesh-build-image"
|
||||
|
||||
// NoImageBuilderPrefix opens the error of a build key failed because no node runs ImageBuildModule (ADR 0487 clause 5): such a
|
||||
// key is not built again as a new revision, only when a node reports the module (ClaimOptions.ImageBuilder) or the fingerprint
|
||||
// changes.
|
||||
const NoImageBuilderPrefix = "no image builder:"
|
||||
|
||||
// Task is one artifact to build: its name and kind in the manifest, the build module that builds it and the language
|
||||
// of the toolchain it is built with.
|
||||
type Task struct {
|
||||
@@ -45,7 +54,7 @@ func (p Plan) Modules() []string {
|
||||
// (the indexer gives an archive no language, so a fingerprint names no toolchain for it; ADR 0478 clause 1).
|
||||
func (p Plan) NeedsToolchain(module string) bool {
|
||||
for _, t := range p.Tasks {
|
||||
if t.Module == module && t.Kind != catalogue.ArtifactArchive {
|
||||
if t.Module == module && t.Kind != catalogue.ArtifactArchive && t.Kind != catalogue.ArtifactImage {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -61,7 +70,7 @@ func (r *Refused) Error() string {
|
||||
}
|
||||
|
||||
// PlanOf is the plan of a manifest (ADR 0478 clauses 1 and 2): a Go bundle and an archive are built by the Go build
|
||||
// module, a TypeScript bundle by the Node one, each in the order of the artifacts' names. A manifest with an image,
|
||||
// module, an image by the image build module (ADR 0487), a TypeScript bundle by the Node one, each in the order of the artifacts' names. A manifest with an
|
||||
// upstream or package artifact, or a bundle of another language, is refused as a whole, naming the first such artifact
|
||||
// by name. A manifest with no artifact has an empty plan.
|
||||
func PlanOf(m catalogue.Manifest) (Plan, error) {
|
||||
@@ -79,6 +88,8 @@ func PlanOf(m catalogue.Manifest) (Plan, error) {
|
||||
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.GoBuildModule, Language: "go"})
|
||||
case a.Kind == catalogue.ArtifactBundle && a.Language == "typescript":
|
||||
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: buildrun.NodeBuildModule, Language: "typescript"})
|
||||
case a.Kind == catalogue.ArtifactImage:
|
||||
p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: ImageBuildModule})
|
||||
case a.Kind == catalogue.ArtifactBundle:
|
||||
return Plan{}, &Refused{Artifact: a.Name, What: fmt.Sprintf("bundle of language %q", a.Language)}
|
||||
default:
|
||||
|
||||
@@ -41,12 +41,11 @@ func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanRefusesImageUpstreamAndPackageByName(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
|
||||
func TestPlanRefusesUpstreamAndPackageByName(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
|
||||
for _, c := range []struct {
|
||||
artifact catalogue.Artifact
|
||||
kind string
|
||||
}{
|
||||
{catalogue.Artifact{Name: "img", Kind: "image"}, "image"},
|
||||
{catalogue.Artifact{Name: "up", Kind: "upstream"}, "upstream"},
|
||||
{catalogue.Artifact{Name: "pkg", Kind: "package", Language: "typescript"}, "package"},
|
||||
} {
|
||||
@@ -74,3 +73,16 @@ func TestPlanRefusesABundleOfAnotherLanguageAndAModuleWithNoArtifact(t *testing.
|
||||
}
|
||||
|
||||
func asRefused(err error, target **Refused) bool { return errors.As(err, target) }
|
||||
|
||||
func TestPlanSendsAnImageToMeshBuildImageWithNoToolchain(t *testing.T) { // 1705a, ADR 0487 clause 5
|
||||
p, err := PlanOf(manifestOf(catalogue.Artifact{Name: "img", Kind: "image"}, catalogue.Artifact{Name: "files", Kind: "archive"}))
|
||||
if err != nil || len(p.Tasks) != 2 {
|
||||
t.Fatalf("plan %+v, error %v", p, err)
|
||||
}
|
||||
if p.Tasks[1].Artifact != "img" || p.Tasks[1].Module != ImageBuildModule {
|
||||
t.Errorf("task = %+v, want the image on %s", p.Tasks[1], ImageBuildModule)
|
||||
}
|
||||
if p.NeedsToolchain(ImageBuildModule) {
|
||||
t.Error("an image needs no toolchain")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -19,7 +20,7 @@ type ToolchainEntries interface {
|
||||
}
|
||||
|
||||
// toolPrefix is the start of each build module's tool names: <prefix>fetch and <prefix>build.
|
||||
var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_"}
|
||||
var toolPrefix = map[string]string{buildrun.GoBuildModule: "build_go_", buildrun.NodeBuildModule: "build_node_", ImageBuildModule: "build_image_"}
|
||||
|
||||
// Builder builds, dark, every new-form fingerprint the indexer writes (novox/hq ADR 0453 clause 4, ADR 0478): it is
|
||||
// handed each modules value as the watch of the indexer's bucket sees it, and for each module with a fingerprint and an
|
||||
@@ -287,6 +288,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
|
||||
}
|
||||
key, _ := delivery.BuilderBuildKey(module, m.FP2)
|
||||
b.mu.Lock()
|
||||
trunk := b.trunkFP[key]
|
||||
first := !b.tried[key]
|
||||
b.tried[key] = true
|
||||
epoch := b.epoch
|
||||
@@ -296,7 +298,7 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
|
||||
// A second build that failed is not tried again until the nodes change (issue 1664).
|
||||
return (!failedBefore || epoch > failedAt) && b.moreNodes(modules)
|
||||
}
|
||||
cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, MoreNodes: moreNodes})
|
||||
cl, err := b.Claimer.Claim(ctx, module, m.FP2, m.Manifest, ClaimOptions{TakeOver: first, MoreNodes: moreNodes, ImageBuilder: b.imageBuilderRuns, Trunk: trunk})
|
||||
if err != nil {
|
||||
b.logf("%s: %v", module, err)
|
||||
return
|
||||
@@ -304,7 +306,22 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
|
||||
if cl == nil {
|
||||
return
|
||||
}
|
||||
result := b.build(ctx, module, m, plan, planErr)
|
||||
// A done key taken again builds only what is missing: the build modules that ran on one node, and the image when it was
|
||||
// built as a candidate's; the other artifacts stay as the done key has them (ADR 0487 clauses 5 and 8).
|
||||
var prior *delivery.BuilderBuild
|
||||
var only map[string]bool
|
||||
publishing := false
|
||||
if cl.Build.State == delivery.BuildDone {
|
||||
prior, only = &cl.Build, map[string]bool{}
|
||||
for _, bm := range OneNodeModules(cl.Build) {
|
||||
only[bm] = true
|
||||
}
|
||||
if trunk && Unpublished(cl.Build) {
|
||||
only[ImageBuildModule] = true
|
||||
publishing = true
|
||||
}
|
||||
}
|
||||
result := b.build(ctx, module, m, plan, planErr, prior, only)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -313,7 +330,11 @@ func (b *Builder) try(ctx context.Context, module string, m delivery.Module) {
|
||||
return
|
||||
}
|
||||
result.By, result.Manifest = b.Claimer.By, m.Manifest
|
||||
if cl.Build.State == delivery.BuildDone && result.State == delivery.BuildFailed && !strings.HasPrefix(result.Error, buildrun.DigestsDifferPrefix) {
|
||||
if publishing && result.State == delivery.BuildFailed {
|
||||
// The trunk's image was built only as a candidate's and its push failed: the key says so, so the builds view, the
|
||||
// checker and the failed-key retake see it, and a look does not build the image again for nothing.
|
||||
result.Error = "publishing the image of the merged commit failed: " + result.Error
|
||||
} else if cl.Build.State == delivery.BuildDone && result.State == delivery.BuildFailed && !strings.HasPrefix(result.Error, buildrun.DigestsDifferPrefix) {
|
||||
// A done key built again on a second node keeps its done value when the new build fails for another reason than
|
||||
// the digests: the first build stands, and a later change of the nodes looks again.
|
||||
b.logf("%s: built again on a second node and failed, the done build stands: %s", cl.Key, result.Error)
|
||||
@@ -360,10 +381,11 @@ type lane struct {
|
||||
module, node string
|
||||
tasks []Task
|
||||
digests map[string]string // artifact -> digest
|
||||
references map[string]string // artifact -> the reference an image was pushed to (a trunk build only)
|
||||
err error
|
||||
}
|
||||
|
||||
func (b *Builder) build(ctx context.Context, module string, m delivery.Module, plan Plan, planErr error) delivery.BuilderBuild {
|
||||
func (b *Builder) build(ctx context.Context, module string, m delivery.Module, plan Plan, planErr error, prior *delivery.BuilderBuild, only map[string]bool) delivery.BuilderBuild {
|
||||
switch {
|
||||
case planErr != nil:
|
||||
return failed("%s", planErr)
|
||||
@@ -386,12 +408,27 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
b.Places.Cancel(l.node)
|
||||
}
|
||||
}
|
||||
var kept []string // build modules whose artifacts stay as the prior done key has them
|
||||
for _, bm := range plan.Modules() {
|
||||
if prior != nil && len(only) > 0 && !only[bm] {
|
||||
kept = append(kept, bm)
|
||||
continue
|
||||
}
|
||||
language := buildrun.LanguageOf[bm]
|
||||
entries := b.Toolchains.Entries(language)
|
||||
qualifies := func(node string) bool { return b.qualifies(node, bm) }
|
||||
var nodes []string
|
||||
if plan.NeedsToolchain(bm) {
|
||||
if bm == ImageBuildModule {
|
||||
// An image is built on one node, chosen from the nodes whose image build module runs healthy (ADR 0487 clause 8):
|
||||
// the module has no toolchain language, so no toolchain entry names its nodes, and a second build would only
|
||||
// push the image again.
|
||||
ns, err := buildrun.ChooseAnyNodes("image", b.imageEntries(), b.Places, qualifies, 1)
|
||||
if err != nil {
|
||||
giveUpLanes()
|
||||
return failed("%s no node has %s, so the image cannot be built: the provision rootless-image-build is held by the module buildah; assign buildah and %s to a node (%v)", NoImageBuilderPrefix, ImageBuildModule, ImageBuildModule, err)
|
||||
}
|
||||
nodes = ns
|
||||
} else if plan.NeedsToolchain(bm) {
|
||||
ns, tc, err := buildrun.ChooseNodes(language, entries, b.Places, qualifies, b.Nodes)
|
||||
if err != nil {
|
||||
giveUpLanes()
|
||||
@@ -413,7 +450,9 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
}
|
||||
nodes = ns
|
||||
}
|
||||
if len(nodes) == 1 {
|
||||
if bm == ImageBuildModule {
|
||||
steps = append(steps, fmt.Sprintf("%s on %s", bm, nodes[0]))
|
||||
} else if len(nodes) == 1 {
|
||||
oneNode = append(oneNode, bm)
|
||||
steps = append(steps, fmt.Sprintf("%s on one node, %s", bm, nodes[0]))
|
||||
} else {
|
||||
@@ -428,7 +467,7 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
}
|
||||
}
|
||||
for _, n := range nodes {
|
||||
lanes = append(lanes, &lane{module: bm, node: n, tasks: tasks, digests: map[string]string{}})
|
||||
lanes = append(lanes, &lane{module: bm, node: n, tasks: tasks, digests: map[string]string{}, references: map[string]string{}})
|
||||
}
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
@@ -447,7 +486,23 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
return failed("%s", strings.Join(errs, "; "))
|
||||
}
|
||||
var artifacts []delivery.BuiltArtifact
|
||||
keptModule := map[string]bool{}
|
||||
for _, bm := range kept {
|
||||
keptModule[bm] = true
|
||||
}
|
||||
for _, t := range plan.Tasks {
|
||||
if keptModule[t.Module] {
|
||||
found := false
|
||||
for _, a := range prior.Artifacts {
|
||||
if a.Name == t.Artifact {
|
||||
artifacts, found = append(artifacts, a), true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return failed("the done build holds no artifact %s to keep", t.Artifact)
|
||||
}
|
||||
continue
|
||||
}
|
||||
byNode := map[string]string{}
|
||||
for _, l := range lanes {
|
||||
if d, ok := l.digests[t.Artifact]; ok && l.module == t.Module {
|
||||
@@ -458,7 +513,21 @@ func (b *Builder) build(ctx context.Context, module string, m delivery.Module, p
|
||||
if err != nil {
|
||||
return failed("%s", err)
|
||||
}
|
||||
artifacts = append(artifacts, delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d})
|
||||
built := delivery.BuiltArtifact{Name: t.Artifact, Kind: t.Kind, Digest: d}
|
||||
for _, l := range lanes {
|
||||
if r := l.references[t.Artifact]; r != "" && l.module == t.Module {
|
||||
built.Reference = r
|
||||
}
|
||||
}
|
||||
artifacts = append(artifacts, built)
|
||||
}
|
||||
for _, bm := range kept {
|
||||
steps = append(steps, bm+" kept")
|
||||
for lang, u := range prior.Toolchains {
|
||||
if _, ok := used[lang]; !ok && buildrun.LanguageOf[bm] == lang {
|
||||
used[lang] = u
|
||||
}
|
||||
}
|
||||
}
|
||||
step := "built " + strings.Join(steps, "; ")
|
||||
if len(oneNode) > 0 {
|
||||
@@ -478,6 +547,39 @@ func (b *Builder) qualifies(node, bm string) bool {
|
||||
return b.Places.CapacityOf(node) > 0 && b.online(node) && (b.Health == nil || b.Health.Healthy(node, bm))
|
||||
}
|
||||
|
||||
// imageEntries are the nodes an image may be built on, as toolchain entries for the chooser: every node the builder has a
|
||||
// capacity for or a build module toolchain from. Whether the image build module runs there is qualifies', from the have.
|
||||
func (b *Builder) imageEntries() []delivery.NodeToolchain {
|
||||
seen := map[string]bool{}
|
||||
var entries []delivery.NodeToolchain
|
||||
add := func(n string) {
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
entries = append(entries, delivery.NodeToolchain{Node: n})
|
||||
}
|
||||
}
|
||||
for n := range b.Places.Capacity {
|
||||
add(n)
|
||||
}
|
||||
for _, language := range []string{"go", "typescript"} {
|
||||
for _, e := range b.Toolchains.Entries(language) {
|
||||
add(e.Node)
|
||||
}
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Node < entries[j].Node })
|
||||
return entries
|
||||
}
|
||||
|
||||
// imageBuilderRuns is whether a node can build an image now.
|
||||
func (b *Builder) imageBuilderRuns() bool {
|
||||
for _, e := range b.imageEntries() {
|
||||
if b.qualifies(e.Node, ImageBuildModule) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// moreNodes is whether any of these build modules has two nodes that qualify now.
|
||||
func (b *Builder) moreNodes(modules []string) bool {
|
||||
for _, bm := range modules {
|
||||
@@ -501,6 +603,18 @@ func (b *Builder) step(ctx context.Context, bm, node, tool string, args map[stri
|
||||
return b.Runner.Run(ctx, bm, node, toolPrefix[bm]+tool, args)
|
||||
}
|
||||
|
||||
// fetchResult and buildResult are the results of a build module's fetch and build steps as the builder reads them.
|
||||
type fetchResult struct {
|
||||
Workspace string `json:"workspace"`
|
||||
Merge string `json:"merge"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
}
|
||||
|
||||
type buildResult struct {
|
||||
Digest string `json:"digest"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
// runLane takes a place on the node, fetches the commit there and builds each of the lane's artifacts, one step at a time.
|
||||
func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m delivery.Module, module string) {
|
||||
release, err := b.Places.Acquire(ctx, l.node)
|
||||
@@ -523,26 +637,43 @@ func (b *Builder) runLane(ctx context.Context, l *lane, repository string, m del
|
||||
l.err = err
|
||||
return
|
||||
}
|
||||
var fetched struct {
|
||||
Workspace string `json:"workspace"`
|
||||
}
|
||||
var fetched fetchResult
|
||||
if err := json.Unmarshal(raw, &fetched); err != nil || fetched.Workspace == "" {
|
||||
l.err = fmt.Errorf("the fetch on %s answered no workspace: %s", l.node, raw)
|
||||
return
|
||||
}
|
||||
// A candidate is built from its head merged onto the trunk. A head that does not merge ends the fetch passed with the tree at
|
||||
// the trunk, and building that would store the trunk's build under the candidate's fingerprint.
|
||||
_, candidate := fetchArgs["base"]
|
||||
if candidate {
|
||||
if len(fetched.Conflicts) > 0 {
|
||||
l.err = fmt.Errorf("the head does not merge into the trunk on %s: files in conflict: %s", l.node, strings.Join(fetched.Conflicts, ", "))
|
||||
return
|
||||
}
|
||||
if fetched.Merge == "" {
|
||||
l.err = fmt.Errorf("the fetch with a base on %s answered no merge: %s", l.node, raw)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, t := range l.tasks {
|
||||
raw, err := b.step(ctx, l.module, l.node, "build", map[string]any{"workspace": fetched.Workspace, "module": m.Path, "artifact": t.Artifact})
|
||||
args := map[string]any{"workspace": fetched.Workspace, "module": m.Path, "artifact": t.Artifact}
|
||||
if t.Kind == catalogue.ArtifactImage && !candidate {
|
||||
// Only a trunk key pushes the image to the registry; a candidate's build never does (ADR 0487 clause 12).
|
||||
args["publish"] = true
|
||||
}
|
||||
raw, err := b.step(ctx, l.module, l.node, "build", args)
|
||||
if err != nil {
|
||||
l.err = err
|
||||
return
|
||||
}
|
||||
var built struct {
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
var built buildResult
|
||||
if err := json.Unmarshal(raw, &built); err != nil || built.Digest == "" {
|
||||
l.err = fmt.Errorf("the build of %s of %s on %s answered no digest: %s", t.Artifact, module, l.node, raw)
|
||||
return
|
||||
}
|
||||
l.digests[t.Artifact] = built.Digest
|
||||
if built.Reference != "" && !candidate {
|
||||
l.references[t.Artifact] = built.Reference
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,11 @@ type buildModules struct {
|
||||
digests map[string]string // `<node>/<artifact>` -> digest; the default is `digest`
|
||||
fails map[string]string // `<node>/<tool>` -> the reason the step fails with
|
||||
// hold, when set before the builds start, blocks every call until it is closed; entered is sent to as a call arrives.
|
||||
hold chan struct{}
|
||||
entered chan struct{}
|
||||
// conflicts, when set, is a file a fetch with a base reports in conflict; noMerge makes it answer neither a merge nor a conflict.
|
||||
conflicts string
|
||||
noMerge bool
|
||||
hold chan struct{}
|
||||
entered chan struct{}
|
||||
}
|
||||
|
||||
func newBuildModules() *buildModules {
|
||||
@@ -52,12 +55,23 @@ func (b *buildModules) CallOn(node, key string, body any) (json.RawMessage, erro
|
||||
step.State, step.Reason = delivery.StepFailed, reason
|
||||
} else if strings.HasSuffix(tool, "_fetch") {
|
||||
step.Result = json.RawMessage(`{"workspace":"w-` + node + `"}`)
|
||||
if args["base"] != nil { // a fetch with a base answers the merge, or the files in conflict (mesh-build-go FetchResult)
|
||||
if b.conflicts != "" {
|
||||
step.Result = json.RawMessage(`{"workspace":"w-` + node + `","base":"` + fmt.Sprint(args["base"]) + `","conflicts":["` + b.conflicts + `"]}`)
|
||||
} else if !b.noMerge {
|
||||
step.Result = json.RawMessage(`{"workspace":"w-` + node + `","base":"` + fmt.Sprint(args["base"]) + `","merge":"` + strings.Repeat("9", 40) + `"}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
d := digest
|
||||
if v, ok := b.digests[node+"/"+fmt.Sprint(args["artifact"])]; ok {
|
||||
d = v
|
||||
}
|
||||
step.Result = json.RawMessage(`{"digest":"` + d + `"}`)
|
||||
if tool == "build_image_build" && args["publish"] == true {
|
||||
step.Result = json.RawMessage(`{"digest":"` + d + `","reference":"registry.example/m/img@` + d + `"}`)
|
||||
} else {
|
||||
step.Result = json.RawMessage(`{"digest":"` + d + `"}`)
|
||||
}
|
||||
}
|
||||
sk, _ := delivery.StepKey(node, id)
|
||||
b.steps.mu.Lock()
|
||||
@@ -228,11 +242,126 @@ func TestAFailingStepFailsTheBuildWithItsReasonAndNodeAndIsTriedAgainAsANewRevis
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithAnImageGetsARefusedKeyNeverBuiltAgain(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
|
||||
r := newRig(t, archiveA, imageArt)
|
||||
// imageHave marks mesh-build-image running and healthy on these nodes.
|
||||
func imageHave(t *testing.T, nodes ...string) *buildrun.HaveSet {
|
||||
t.Helper()
|
||||
have := &buildrun.HaveSet{}
|
||||
for _, n := range nodes {
|
||||
have.Put(n+".mesh-build-go", haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false)
|
||||
have.Put(n+"."+ImageBuildModule, haveValue(t, delivery.PhaseRunning, delivery.HealthOK), false)
|
||||
}
|
||||
return have
|
||||
}
|
||||
|
||||
func TestAnImageGoesToOneNodeAndGetsOneDoneKeyWithItsReference(t *testing.T) { // 1705a, ADR 0487 clause 5
|
||||
r := newRig(t, imageArt)
|
||||
r.b.Health = imageHave(t, "ace", "novox") // two nodes qualify; an image still builds on one
|
||||
v := r.consider(t, "modules/m")
|
||||
if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, RefusedPrefix) || !strings.Contains(v.Error, "image") || !strings.Contains(v.Error, "build-agent") {
|
||||
t.Fatalf("build = %+v, want failed 'refused kind: image' naming build-agent", v)
|
||||
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Kind != "image" || v.Artifacts[0].Digest != digest {
|
||||
t.Fatalf("build = %+v, want done with one image artifact and its digest", v)
|
||||
}
|
||||
if v.Artifacts[0].Reference == "" {
|
||||
t.Errorf("a trunk image has no reference: %+v", v.Artifacts[0])
|
||||
}
|
||||
if strings.HasPrefix(v.Step, OneNodePrefix) || len(OneNodeModules(v)) != 0 {
|
||||
t.Errorf("step = %q, want no one-node mark: an image is never built again on a second node", v.Step)
|
||||
}
|
||||
if n := len(r.bm.callsOf("build_image_fetch")); n != 1 {
|
||||
t.Errorf("%d image fetches, want one", n)
|
||||
}
|
||||
writes := r.st.writes
|
||||
r.consider(t, "modules/m")
|
||||
if r.st.writes != writes || len(r.bm.callsOf("build_image_build")) != 1 {
|
||||
t.Errorf("a done image key was built again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageIsBuiltOnlyWhereMeshBuildImageIsHealthy(t *testing.T) { // 1705a
|
||||
r := newRig(t, imageArt)
|
||||
r.b.Health = imageHave(t, "novox")
|
||||
r.consider(t, "modules/m")
|
||||
c := r.bm.callsOf("build_image_build")
|
||||
if len(c) != 1 || c[0].node != "novox" {
|
||||
t.Errorf("calls = %+v, want one on novox", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABundleAndAnImageOfOneModuleBuildOnTwoNodesAndOne(t *testing.T) { // 1705a
|
||||
r := newRig(t, goBundle, imageArt)
|
||||
r.b.Health = imageHave(t, "ace", "novox")
|
||||
v := r.consider(t, "modules/m")
|
||||
if v.State != delivery.BuildDone || len(v.Artifacts) != 2 {
|
||||
t.Fatalf("build = %+v", v)
|
||||
}
|
||||
if n := len(r.bm.callsOf("build_go_build")); n != 2 {
|
||||
t.Errorf("%d go builds, want 2", n)
|
||||
}
|
||||
if n := len(r.bm.callsOf("build_image_build")); n != 1 {
|
||||
t.Errorf("%d image builds, want 1", n)
|
||||
}
|
||||
if modules := OneNodeModules(v); len(modules) != 0 {
|
||||
t.Errorf("one-node modules = %v, want none", modules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoImageNodeTheKeyFailsNamingTheProvisionAndIsNotRetakenUntilANodeReportsTheModule(t *testing.T) { // 1705b, lead's decision
|
||||
r := newRig(t, imageArt)
|
||||
r.b.Health = imageHave(t) // mesh-build-image runs nowhere
|
||||
v := r.consider(t, "modules/m")
|
||||
if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, NoImageBuilderPrefix) || !strings.Contains(v.Error, "rootless-image-build") || !strings.Contains(v.Error, "buildah") || !strings.Contains(v.Error, ImageBuildModule) {
|
||||
t.Fatalf("build = %+v, want failed naming rootless-image-build, buildah and mesh-build-image", v)
|
||||
}
|
||||
if len(r.bm.calls) != 0 {
|
||||
t.Errorf("%d calls, want none", len(r.bm.calls))
|
||||
}
|
||||
writes := r.st.writes
|
||||
r.consider(t, "modules/m")
|
||||
r.consider(t, "modules/m")
|
||||
if r.st.writes != writes {
|
||||
t.Errorf("the failed key was written again (%d writes, was %d)", r.st.writes, writes)
|
||||
}
|
||||
// A node reports the module: the key is taken again.
|
||||
r.b.Health = imageHave(t, "ace")
|
||||
v = r.consider(t, "modules/m")
|
||||
if v.State != delivery.BuildDone {
|
||||
t.Errorf("build = %+v, want done once a node reports mesh-build-image", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACandidatesImageCallCarriesNoPublishAndItsKeyNoReference(t *testing.T) { // 1705b, lead's decision
|
||||
r := newRig(t, imageArt)
|
||||
r.b.Health = imageHave(t, "ace")
|
||||
trunkModule(t, r)
|
||||
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
|
||||
r.b.Wait()
|
||||
v := valueOf(t, r.st, r.key)
|
||||
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference != "" {
|
||||
t.Fatalf("build = %+v, want done with no reference", v)
|
||||
}
|
||||
calls := r.bm.callsOf("build_image_build")
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("%d image builds, want 1", len(calls))
|
||||
}
|
||||
if _, has := calls[0].body.(map[string]any)["publish"]; has {
|
||||
t.Errorf("a candidate's image call carries publish: %+v", calls[0].body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrunkImageCallCarriesPublish(t *testing.T) { // 1705b
|
||||
r := newRig(t, imageArt)
|
||||
r.b.Health = imageHave(t, "ace")
|
||||
r.consider(t, "modules/m")
|
||||
calls := r.bm.callsOf("build_image_build")
|
||||
if len(calls) != 1 || calls[0].body.(map[string]any)["publish"] != true {
|
||||
t.Errorf("calls = %+v, want publish true", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamArtifactIsStillRefusedKeyNeverBuiltAgain(t *testing.T) { // 1607/AC2, ADR 0478 clause 2
|
||||
r := newRig(t, archiveA, catalogue.Artifact{Name: "up", Kind: "upstream"})
|
||||
v := r.consider(t, "modules/m")
|
||||
if v.State != delivery.BuildFailed || !strings.HasPrefix(v.Error, RefusedPrefix) || !strings.Contains(v.Error, "upstream") || !strings.Contains(v.Error, "build-agent") {
|
||||
t.Fatalf("build = %+v, want failed 'refused kind: upstream' naming build-agent", v)
|
||||
}
|
||||
if len(r.bm.calls) != 0 {
|
||||
t.Errorf("%d calls, want none for a refused kind", len(r.bm.calls))
|
||||
@@ -401,3 +530,104 @@ func TestANodeThatIsNotOnlineIsNotChosenAndComingBackWakesTheBuild(t *testing.T)
|
||||
t.Errorf("build = %+v, want done once a node is online", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACandidatesImageIsPublishedOnceWhenTheTrunkNamesItsFingerprint(t *testing.T) { // review r1, mesh-delivery 1; ADR 0487 clause 5
|
||||
r := newRig(t, imageArt)
|
||||
r.b.Health = imageHave(t, "ace")
|
||||
trunkModule(t, r) // the trunk carries another fingerprint; fp1 is the candidate's
|
||||
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
|
||||
r.b.Wait()
|
||||
v := valueOf(t, r.st, r.key)
|
||||
if v.State != delivery.BuildDone || v.Artifacts[0].Reference != "" {
|
||||
t.Fatalf("candidate build = %+v, want done with no reference", v)
|
||||
}
|
||||
if n := len(r.bm.callsOf("build_image_build")); n != 1 {
|
||||
t.Fatalf("%d image builds after the candidate, want 1", n)
|
||||
}
|
||||
// The pull request is merged: the trunk's record names the candidate's fingerprint.
|
||||
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
|
||||
r.b.Wait()
|
||||
v = valueOf(t, r.st, r.key)
|
||||
if v.State != delivery.BuildDone || len(v.Artifacts) != 1 || v.Artifacts[0].Reference == "" {
|
||||
t.Fatalf("trunk build = %+v, want done with a reference", v)
|
||||
}
|
||||
calls := r.bm.callsOf("build_image_build")
|
||||
published := 0
|
||||
for _, c := range calls {
|
||||
if c.body.(map[string]any)["publish"] == true {
|
||||
published++
|
||||
}
|
||||
}
|
||||
if len(calls) != 2 || published != 1 {
|
||||
t.Errorf("%d image builds, %d with publish, want 2 and exactly 1", len(calls), published)
|
||||
}
|
||||
// Written once more only: looking again builds nothing.
|
||||
rev := r.st.revs[r.key]
|
||||
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
|
||||
r.b.Wait()
|
||||
if r.st.revs[r.key] != rev || len(r.bm.callsOf("build_image_build")) != 2 {
|
||||
t.Error("a published key was built again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondGoNodeDoesNotBuildOrPushTheImageAgain(t *testing.T) { // review r1, mesh-delivery 2; ADR 0487 clause 8
|
||||
r := newRig(t, goBundle, imageArt)
|
||||
r.b.Health = imageHave(t, "ace", "novox")
|
||||
r.pres.Put("ace", presenceValue(t, false), false) // only novox qualifies for Go (and the image)
|
||||
v := r.consider(t, "modules/m")
|
||||
if v.State != delivery.BuildDone || len(OneNodeModules(v)) != 1 {
|
||||
t.Fatalf("build = %+v, want done with the Go bundle on one node", v)
|
||||
}
|
||||
var image delivery.BuiltArtifact
|
||||
for _, a := range v.Artifacts {
|
||||
if a.Kind == "image" {
|
||||
image = a
|
||||
}
|
||||
}
|
||||
if image.Reference == "" {
|
||||
t.Fatalf("artifacts = %+v, want a published image", v.Artifacts)
|
||||
}
|
||||
r.pres.Put("ace", presenceValue(t, true), false)
|
||||
r.b.Reconsider(context.Background())
|
||||
r.b.Wait()
|
||||
v = valueOf(t, r.st, r.key)
|
||||
if len(OneNodeModules(v)) != 0 || len(v.Artifacts) != 2 {
|
||||
t.Fatalf("build = %+v, want done on two Go nodes", v)
|
||||
}
|
||||
if n := len(r.bm.callsOf("build_image_build")); n != 1 {
|
||||
t.Errorf("%d image builds, want the one of the first build only", n)
|
||||
}
|
||||
for _, a := range v.Artifacts {
|
||||
if a.Kind == "image" && a != image {
|
||||
t.Errorf("image = %+v, was %+v: the done key was rewritten with another image", a, image)
|
||||
}
|
||||
}
|
||||
if n := len(r.bm.callsOf("build_go_build")); n != 3 { // one, then two
|
||||
t.Errorf("%d go builds, want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedPublishOfACandidatesImageIsWrittenToTheKey(t *testing.T) { // review r2, mesh-delivery 1; ADR 0487 clause 5
|
||||
r := newRig(t, imageArt)
|
||||
r.b.Health = imageHave(t, "ace")
|
||||
trunkModule(t, r)
|
||||
r.b.Candidate(context.Background(), "feat%2Fx", candidateValue(t, "feat/x", 7, headX))
|
||||
r.b.Wait()
|
||||
if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone {
|
||||
t.Fatalf("candidate build = %+v", v)
|
||||
}
|
||||
r.bm.fails["ace/build_image_build"] = "push to the registry: connection refused"
|
||||
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m")) // the merge: the trunk names the fingerprint
|
||||
r.b.Wait()
|
||||
v := valueOf(t, r.st, r.key)
|
||||
if v.State != delivery.BuildFailed || !strings.Contains(v.Error, "connection refused") || !strings.Contains(v.Error, "publish") {
|
||||
t.Fatalf("build = %+v, want failed naming the publish and its reason", v)
|
||||
}
|
||||
// The failed key is retaken as any failed key is, and ends published once the registry answers.
|
||||
delete(r.bm.fails, "ace/build_image_build")
|
||||
r.b.Module(context.Background(), "m", moduleValue(t, "modules/m"))
|
||||
r.b.Wait()
|
||||
if v := valueOf(t, r.st, r.key); v.State != delivery.BuildDone || v.Artifacts[0].Reference == "" {
|
||||
t.Errorf("build = %+v, want done with a reference", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Captured step values of mesh-build-image (fetch, a candidate's build, a trunk's build), copied from
|
||||
novox/mesh-catalog modules/mesh-build-image/cmd/mesh-build-image/testdata/captured/. Both copies must change together: the
|
||||
module's capture test writes them, and the builder's TestCapturedImageStepsDecodeWithTheBuildersOwnTypes (internal/build,
|
||||
fixtures_test.go) decodes these with the types the builder reads results with and fails on a missing field.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"ended": "2026-10-22T08:00:01Z",
|
||||
"id": "STEPID",
|
||||
"node": "ace",
|
||||
"result": {
|
||||
"artifact": "server",
|
||||
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"pushed": false
|
||||
},
|
||||
"started": "2026-10-22T08:00:00Z",
|
||||
"state": "passed",
|
||||
"tool": "build_image_build",
|
||||
"v": 1
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"ended": "2026-10-22T08:00:01Z",
|
||||
"id": "STEPID",
|
||||
"node": "ace",
|
||||
"result": {
|
||||
"artifact": "server",
|
||||
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
"pushed": true,
|
||||
"reference": "REGISTRY/fixture-image/server@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
},
|
||||
"started": "2026-10-22T08:00:00Z",
|
||||
"state": "passed",
|
||||
"tool": "build_image_build",
|
||||
"v": 1
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"ended": "2026-10-22T08:00:01Z",
|
||||
"id": "STEPID",
|
||||
"node": "ace",
|
||||
"result": {
|
||||
"commit": "COMMIT",
|
||||
"workspace": "WORKSPACE"
|
||||
},
|
||||
"started": "2026-10-22T08:00:00Z",
|
||||
"state": "passed",
|
||||
"tool": "build_image_fetch",
|
||||
"v": 1
|
||||
}
|
||||
@@ -86,12 +86,12 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
|
||||
if want := []state{{Name: "builds", History: 5}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
|
||||
t.Errorf("state = %+v, want %+v", m.State, want)
|
||||
}
|
||||
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-node.steps", "mesh-build-node.toolchains",
|
||||
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-image.steps", "mesh-build-node.steps", "mesh-build-node.toolchains",
|
||||
"mesh-delivery-indexer.candidates", "mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "node-engine.have"}; !reflect.DeepEqual(sorted(m.Reads), want) {
|
||||
t.Errorf("reads = %v, want %v", m.Reads, want)
|
||||
}
|
||||
if want := []string{"mesh-build-go.build_go_build", "mesh-build-go.build_go_fetch", "mesh-build-node.build_node_build", "mesh-build-node.build_node_fetch"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
|
||||
t.Errorf("invokes = %v, want only the build modules' fetch and build", m.Invokes)
|
||||
if want := []string{"mesh-build-go.build_go_build", "mesh-build-go.build_go_fetch", "mesh-build-image.build_image_build", "mesh-build-image.build_image_fetch", "mesh-build-node.build_node_build", "mesh-build-node.build_node_fetch"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
|
||||
t.Errorf("invokes = %v, want only the build modules' fetch and build (go, image, node)", m.Invokes)
|
||||
}
|
||||
// The builder is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it starts.
|
||||
if want := []string{"mesh-build-go.toolchain.reported", "mesh-build-node.toolchain.reported"}; !reflect.DeepEqual(sorted(m.Consumes), want) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"reads": [
|
||||
"mesh-build-go.steps",
|
||||
"mesh-build-go.toolchains",
|
||||
"mesh-build-image.steps",
|
||||
"mesh-build-node.steps",
|
||||
"mesh-build-node.toolchains",
|
||||
"mesh-delivery-indexer.candidates",
|
||||
@@ -23,6 +24,8 @@
|
||||
"invokes": [
|
||||
"mesh-build-go.build_go_build",
|
||||
"mesh-build-go.build_go_fetch",
|
||||
"mesh-build-image.build_image_build",
|
||||
"mesh-build-image.build_image_fetch",
|
||||
"mesh-build-node.build_node_build",
|
||||
"mesh-build-node.build_node_fetch"
|
||||
],
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
const (
|
||||
getFileTool = "gitea.gitea_get_file"
|
||||
compareTool = "gitea.gitea_compare"
|
||||
commitsTool = "gitea.gitea_commits"
|
||||
)
|
||||
|
||||
// forgeReads reads the forge through call (mesh-sdk process.Conn.Call as the checker's own account).
|
||||
@@ -59,3 +60,21 @@ func (f forgeReads) OnTrunk(_ context.Context, repository, commit string) (bool,
|
||||
}
|
||||
return *a.Total == 0, nil
|
||||
}
|
||||
|
||||
// FullSHA is the whole sha of a commit named by its first digits: the newest commit of the history at that ref.
|
||||
func (f forgeReads) FullSHA(_ context.Context, repository, commit string) (string, error) {
|
||||
owner, repo := ownerRepo(repository)
|
||||
raw, err := f.call(commitsTool, map[string]any{"owner": owner, "repo": repo, "ref": commit, "limit": 1})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var a struct {
|
||||
Commits []struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"commits"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || len(a.Commits) == 0 || len(a.Commits[0].SHA) != 40 || !strings.HasPrefix(a.Commits[0].SHA, commit) {
|
||||
return "", fmt.Errorf("the answer of %s for %s is not a commit that starts with it: %v", commitsTool, commit, err)
|
||||
}
|
||||
return a.Commits[0].SHA, nil
|
||||
}
|
||||
|
||||
@@ -53,3 +53,18 @@ func TestTheForgeReadsParseWhatTheForgeAnswered(t *testing.T) { // 1624/AC4
|
||||
t.Error("an answer with no total was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheForgeNamesTheWholeShaOfAShortCommit(t *testing.T) { // review D2
|
||||
f := forgeReads{call: func(key string, body any) (json.RawMessage, error) {
|
||||
if key != commitsTool {
|
||||
t.Errorf("called %s", key)
|
||||
}
|
||||
return capturedAnswer(t, "forge-commits"), nil
|
||||
}}
|
||||
if sha, err := f.FullSHA(context.Background(), "novox/mesh-sdk", "cb54756c3865"); err != nil || sha != "cb54756c3865df5ac9c0dc0481ad9ebe2bf013a1" {
|
||||
t.Errorf("sha = %q, %v", sha, err)
|
||||
}
|
||||
if _, err := f.FullSHA(context.Background(), "novox/mesh-sdk", "deadbeefdead"); err == nil {
|
||||
t.Error("a commit that does not start with the pin was taken")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
// heardCaller is the build runner's caller with the calls that reached nobody marked: no responders, or none answering within
|
||||
// the call's bound. The step never began there, so the check may give it to another node (issue 1820). A call that was
|
||||
// answered, with a failure or not, is left as it is.
|
||||
type heardCaller struct {
|
||||
call func(node, key string, body any) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
func (h heardCaller) CallOn(node, key string, body any) (json.RawMessage, error) {
|
||||
raw, err := h.call(node, key, body)
|
||||
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, nats.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, &check.Unheard{Err: err}
|
||||
}
|
||||
return raw, err
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
func TestACallThatReachedNobodyIsMarkedAndAnAnsweredFailureIsNot(t *testing.T) { // 1820/AC6
|
||||
for _, err := range []error{nats.ErrNoResponders, nats.ErrTimeout, context.DeadlineExceeded} {
|
||||
h := heardCaller{call: func(string, string, any) (json.RawMessage, error) { return nil, fmt.Errorf("call: %w", err) }}
|
||||
_, got := h.CallOn("g14", "m.t", nil)
|
||||
var u *check.Unheard
|
||||
if !errors.As(got, &u) {
|
||||
t.Errorf("%v: %v is not marked unheard", err, got)
|
||||
}
|
||||
}
|
||||
h := heardCaller{call: func(string, string, any) (json.RawMessage, error) { return nil, errors.New("the module refused") }}
|
||||
_, got := h.CallOn("g14", "m.t", nil)
|
||||
var u *check.Unheard
|
||||
if errors.As(got, &u) {
|
||||
t.Errorf("an answered failure %v is marked unheard", got)
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,18 @@ import (
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
// holdWatch holds the checker back while a delivery builds (ADR 0484 clause 12): it watches the controller's bucket of the
|
||||
// delivery plans in their build phase and reads it as empty where it is absent (issue 1652 makes it) or cannot be read,
|
||||
// saying so once. The bucket may come into being after the checker started and the checker holds no timer (ADR 0437 §8), so
|
||||
// each time a check is about to start and the bucket is not watched yet, the checker looks for it.
|
||||
// holdWatch holds the checker back while a delivery builds (ADR 0484 clause 12): whether one does is the list of the keys of
|
||||
// the controller's bucket of the delivery plans in their build phase, read each time a check is about to start. Its keys age
|
||||
// out (a time to live, and no delete marker), which a watch never hears, so a watch cannot decide it (mesh-controller
|
||||
// internal/link/holds.go lists the keys too); the watch is only the wake-up, for a plan that ended by a delete. The bucket
|
||||
// is read as empty where it is absent (issue 1652 makes it) or cannot be read, saying so once. It may come into being after
|
||||
// the checker started and the checker holds no timer (ADR 0437 §8), so each time a check is about to start and the bucket is
|
||||
// not known yet, the checker looks for it.
|
||||
type holdWatch struct {
|
||||
ctx context.Context
|
||||
js bucketOpener
|
||||
hold check.PlanHold
|
||||
changed func(ctx context.Context) // the last plan ended: the checks that waited may start
|
||||
keys func(ctx context.Context) ([]string, error)
|
||||
changed func(ctx context.Context) // a plan key changed: the checks that waited may start
|
||||
logger *log.Logger
|
||||
errs chan error
|
||||
mu sync.Mutex
|
||||
@@ -34,23 +37,37 @@ func newHoldWatch(ctx context.Context, js bucketOpener, changed func(context.Con
|
||||
// Held says whether a delivery builds now.
|
||||
func (w *holdWatch) Held() bool {
|
||||
w.ensure()
|
||||
return w.hold.Held()
|
||||
w.mu.Lock()
|
||||
keys := w.keys
|
||||
w.mu.Unlock()
|
||||
if keys == nil {
|
||||
return false
|
||||
}
|
||||
list, err := keys(w.ctx)
|
||||
if err != nil {
|
||||
w.say("the keys of %s cannot be listed, so it is read as empty: %v", check.PlansBuildingBucket, err)
|
||||
return false
|
||||
}
|
||||
return len(list) > 0
|
||||
}
|
||||
|
||||
func (w *holdWatch) say(format string, args ...any) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.said {
|
||||
w.said = true
|
||||
w.logger.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// ensure starts the watch if the bucket exists and is not watched.
|
||||
// ensure finds the bucket if it is not known and exists, and starts the wake-up watch.
|
||||
func (w *holdWatch) ensure() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.watched {
|
||||
if w.watched || w.js == nil {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.mu.Unlock()
|
||||
kv, err := w.js.KeyValue(w.ctx, check.PlansBuildingBucket)
|
||||
switch {
|
||||
case errors.Is(err, jetstream.ErrBucketNotFound):
|
||||
@@ -60,17 +77,25 @@ func (w *holdWatch) ensure() {
|
||||
w.say("the bucket %s cannot be read, so it is read as empty: %v", check.PlansBuildingBucket, err)
|
||||
return
|
||||
}
|
||||
watcher, err := settle(w.ctx, kv, func(e jetstream.KeyValueEntry) { w.hold.Put(e.Key(), !opOf(e)) })
|
||||
if err != nil {
|
||||
w.say("the bucket %s cannot be watched, so it is read as empty: %v", check.PlansBuildingBucket, err)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.watched {
|
||||
return
|
||||
}
|
||||
w.watched, w.said = true, false
|
||||
w.keys = func(ctx context.Context) ([]string, error) {
|
||||
list, err := kv.Keys(ctx)
|
||||
if errors.Is(err, jetstream.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return list, err
|
||||
}
|
||||
go func() {
|
||||
w.errs <- follow(w.ctx, watcher, func(e jetstream.KeyValueEntry) {
|
||||
if w.hold.Put(e.Key(), !opOf(e)) && !w.hold.Held() {
|
||||
w.changed(w.ctx)
|
||||
}
|
||||
})
|
||||
wake, err := kv.WatchAll(w.ctx)
|
||||
if err != nil {
|
||||
w.errs <- err
|
||||
return
|
||||
}
|
||||
w.errs <- follow(w.ctx, wake, func(jetstream.KeyValueEntry) { w.changed(w.ctx) })
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The controller's plans-building keys age out of the bucket (a time to live, no delete marker), which a watch never hears:
|
||||
// whether a delivery builds is the list of the keys now, and the watch is only a wake-up (review D3).
|
||||
func TestADeliveryHoldsWhileItsKeyIsListedAndNotWhenItAgedOutWithNoEvent(t *testing.T) {
|
||||
listed := []string{"plan-1"}
|
||||
w := &holdWatch{watched: true, logger: log.New(io.Discard, "", 0), keys: func(context.Context) ([]string, error) { return listed, nil }}
|
||||
if !w.Held() {
|
||||
t.Fatal("a plan key is listed and nothing holds")
|
||||
}
|
||||
listed = nil // the key aged out: no delete reached the watch
|
||||
if w.Held() {
|
||||
t.Error("the key is gone from the bucket and the checker is still held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABucketThatCannotBeListedIsReadAsEmpty(t *testing.T) {
|
||||
w := &holdWatch{watched: true, logger: log.New(io.Discard, "", 0), keys: func(context.Context) ([]string, error) { return []string{"plan-1"}, errors.New("the bus is down") }}
|
||||
if w.Held() {
|
||||
t.Error("an unreadable bucket held the checker")
|
||||
}
|
||||
var absent holdWatch
|
||||
absent.logger = log.New(io.Discard, "", 0)
|
||||
if absent.Held() {
|
||||
t.Error("a watch with no bucket held the checker")
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type judgeDeps struct {
|
||||
|
||||
func newJudge(d judgeDeps) check.Judge {
|
||||
return &check.Examiner{Steps: d.runner, Toolchains: d.tools, Presence: d.presence, Health: d.have, Places: d.places,
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Forge: d.forge, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Logf: d.logf}
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Modules: d.repos, Forge: d.forge, Runs: d.repos, Builds: d.builds, Announce: d.announce, Trunks: d.trunks, Logf: d.logf}
|
||||
}
|
||||
|
||||
// manifestKV reads a manifest by its digest.
|
||||
@@ -49,6 +49,7 @@ type repoModules struct {
|
||||
mu sync.Mutex
|
||||
langs map[string]map[string]bool // by manifest digest: the languages of its artifacts
|
||||
repoOf map[string]string // by module: its repository
|
||||
commit map[string]string // by module: the commit its record was computed at (the commit the mesh runs)
|
||||
typed map[string]string // by module: its manifest digest
|
||||
}
|
||||
|
||||
@@ -56,11 +57,12 @@ type repoModules struct {
|
||||
func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted bool) {
|
||||
r.mu.Lock()
|
||||
if r.repoOf == nil {
|
||||
r.repoOf, r.typed, r.langs = map[string]string{}, map[string]string{}, map[string]map[string]bool{}
|
||||
r.repoOf, r.typed, r.langs, r.commit = map[string]string{}, map[string]string{}, map[string]map[string]bool{}, map[string]string{}
|
||||
}
|
||||
if deleted {
|
||||
delete(r.repoOf, key)
|
||||
delete(r.typed, key)
|
||||
delete(r.commit, key)
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -72,6 +74,7 @@ func (r *repoModules) Put(ctx context.Context, key string, value []byte, deleted
|
||||
r.mu.Lock()
|
||||
r.repoOf[key] = m.Repository
|
||||
r.typed[key] = m.Manifest
|
||||
r.commit[key] = m.Commit
|
||||
_, known := r.langs[m.Manifest]
|
||||
r.mu.Unlock()
|
||||
if m.Manifest == "" || known {
|
||||
@@ -128,3 +131,10 @@ func (r buildReader) Build(ctx context.Context, module, fp2 string) (delivery.Bu
|
||||
b, err := delivery.DecodeBuilderBuild(value)
|
||||
return b, err == nil, err
|
||||
}
|
||||
|
||||
// CommitOf is the commit the indexer's record of the module was computed at: the commit of its repository the mesh runs.
|
||||
func (r *repoModules) CommitOf(module string) string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.commit[module]
|
||||
}
|
||||
|
||||
@@ -66,6 +66,9 @@ func TestARepositoryWithATypeScriptModuleIsJudgedByTheNodeBuildModuleToo(t *test
|
||||
if got := r.BuildModules("novox/mesh-catalog"); strings.Join(got, ",") != buildrun.GoBuildModule+","+buildrun.NodeBuildModule {
|
||||
t.Errorf("mesh-catalog: %v, want both", got)
|
||||
}
|
||||
if got := r.CommitOf("mesh-delivery-checker"); got != strings.Repeat("a", 40) || r.CommitOf("gone") != "" {
|
||||
t.Errorf("the commit the mesh runs = %q", got)
|
||||
}
|
||||
if got := r.BuildModules("novox/mesh-sdk"); strings.Join(got, ",") != buildrun.GoBuildModule {
|
||||
t.Errorf("a repository with no module: %v, want the Go build module", got)
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
have := &buildrun.HaveSet{}
|
||||
trunks := &check.Trunks{}
|
||||
places := &buildrun.Places{Capacity: capacity}
|
||||
runner := buildrun.Runner{Caller: caller, Steps: stepsWaiter{js}, Bound: stepBound}
|
||||
runner := buildrun.Runner{Caller: heardCaller{caller.CallOn}, Steps: stepsWaiter{js}, Bound: stepBound}
|
||||
ch := &check.Checker{
|
||||
Verdicts: kvStore{buckets[delivery.CheckerVerdictsBucket]},
|
||||
Owed: kvStore{buckets[delivery.CheckerOwedBucket]},
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package check
|
||||
|
||||
import "strconv"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// BesideRef is a sibling repository a repository's tests read, placed beside the fetched tree as a plain directory (ADR
|
||||
// 0484 clause 4). Ref names the pull request's head when the commit is only there.
|
||||
@@ -10,37 +14,74 @@ type BesideRef struct {
|
||||
Ref string `json:"ref,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
catalogueRepository = "novox/mesh-catalog"
|
||||
nodeEngineRepo = "novox/mesh-host"
|
||||
nodeEngineModule = "mesh-host"
|
||||
controllerRepo = "novox/mesh-controller"
|
||||
)
|
||||
|
||||
// besideRules are the repositories each core repository's tests read, as the tests state it (mesh-sdk go/internal/beside
|
||||
// beside.go): the catalogue at its trunk, the node-engine, the SDK as the controller pins it, and the delivery's pinned
|
||||
// forge dependencies (mesh-catalog checks/deps). The checker chooses which sibling at which commit; the build module only
|
||||
// places what it is told.
|
||||
// beside.go): the catalogue, the node-engine, the SDK, and the delivery's pinned forge dependencies (mesh-catalog
|
||||
// checks/deps). Which commit of each is besideCommit's rule.
|
||||
var besideRules = map[string][]string{
|
||||
"novox/mesh-controller": {"novox/mesh-catalog", "novox/mesh-host", "novox/mesh-sdk"},
|
||||
"novox/mesh-host": {"novox/mesh-catalog", "novox/mesh-sdk"},
|
||||
"novox/mesh-sdk": {"novox/mesh-catalog", "novox/mesh-host"},
|
||||
"novox/mesh-catalog": {"novox/mesh-sdk", "novox/mesh-controller"},
|
||||
"novox/mesh-delivery": {"novox/mesh-sdk", "novox/mesh-catalog"},
|
||||
controllerRepo: {catalogueRepository, nodeEngineRepo, SDKRepository},
|
||||
nodeEngineRepo: {catalogueRepository, SDKRepository},
|
||||
SDKRepository: {catalogueRepository, nodeEngineRepo},
|
||||
catalogueRepository: {SDKRepository, controllerRepo},
|
||||
"novox/mesh-delivery": {SDKRepository, catalogueRepository},
|
||||
}
|
||||
|
||||
// Beside are the siblings a pull request's tests read: each at its trunk head, and at the head of the group's pull request
|
||||
// when the group holds one of that repository (a group's members are checked together, ADR 0482 clause 2) — except the
|
||||
// catalogue, which the tests read at its trunk even when the group holds a catalogue pull request (beside.go).
|
||||
func (j *Examiner) Beside(c *Case, p Pull) []BesideRef {
|
||||
// besideFor are the siblings a pull request's tests read (ADR 0484 clause 4): the catalogue at its trunk, always, even in a
|
||||
// group holding a catalogue pull request; the node-engine at the commit the mesh runs (the commit of the indexer's module
|
||||
// mesh-host, its trunk head when none is known); mesh-sdk, for the controller's tests, at the commit the controller's go.mod
|
||||
// pins; any other sibling at the head of the group's pull request for it when the group holds one, else at its trunk.
|
||||
func (j *Examiner) besideFor(ctx context.Context, c *Case, p Pull) ([]BesideRef, error) {
|
||||
var heads map[string]string
|
||||
if j.Trunks != nil {
|
||||
heads = j.Trunks.Heads()
|
||||
}
|
||||
var out []BesideRef
|
||||
for _, repo := range besideRules[p.Repository] {
|
||||
if member := c.member(repo); member != nil && repo != "novox/mesh-catalog" {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: member.Head, Ref: refOf(member.Number)})
|
||||
continue
|
||||
}
|
||||
if head := heads[repo]; head != "" {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: head})
|
||||
switch {
|
||||
case repo == catalogueRepository:
|
||||
if head := heads[repo]; head != "" {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: head})
|
||||
}
|
||||
case repo == nodeEngineRepo:
|
||||
commit := ""
|
||||
if j.Runs != nil {
|
||||
commit = j.Runs.CommitOf(nodeEngineModule)
|
||||
}
|
||||
if commit == "" {
|
||||
commit = heads[repo]
|
||||
}
|
||||
if commit != "" {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: commit})
|
||||
}
|
||||
case repo == SDKRepository && p.Repository == controllerRepo && j.Forge != nil:
|
||||
text, err := j.Forge.File(ctx, p.Repository, "go.mod", p.Head)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("go.mod of %s#%d cannot be read to place the mesh-sdk its tests read: %w", p.Repository, p.Number, err)
|
||||
}
|
||||
pin, ok := SDKPin(text)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("go.mod of %s#%d names no mesh-sdk commit, so the mesh-sdk its tests read cannot be placed", p.Repository, p.Number)
|
||||
}
|
||||
full, err := j.Forge.FullSHA(ctx, SDKRepository, pin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mesh-sdk %s, which %s#%d pins, cannot be found: %w", pin, p.Repository, p.Number, err)
|
||||
}
|
||||
out = append(out, BesideRef{Repository: repo, Commit: full})
|
||||
default:
|
||||
if member := c.member(repo); member != nil {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: member.Head, Ref: refOf(member.Number)})
|
||||
} else if head := heads[repo]; head != "" {
|
||||
out = append(out, BesideRef{Repository: repo, Commit: head})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Case) member(repository string) *Pull {
|
||||
@@ -56,7 +97,5 @@ func refOf(number int) string {
|
||||
if number <= 0 {
|
||||
return ""
|
||||
}
|
||||
return "refs/pull/" + itoa(number) + "/head"
|
||||
return "refs/pull/" + strconv.Itoa(number) + "/head"
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
@@ -150,7 +150,7 @@ func caseOf(t testing.TB, pulls ...delivery.CandidatePull) *Case {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs := CaseOf(c, map[string]string{repoA: trunkA, repoSDK: trunkS})
|
||||
cs := CaseOf(c, map[string]string{repoA: trunkA, repoSDK: trunkS, "novox/mesh-controller": trunkA, "novox/mesh-catalog": trunkA})
|
||||
if cs == nil {
|
||||
t.Fatal("no case")
|
||||
}
|
||||
@@ -552,3 +552,36 @@ func TestTheOutcomeSaysIfEachCandidatesModuleIsBuiltFailedOrWaiting(t *testing.T
|
||||
t.Errorf("builds = %v, want one note for each module that has a fingerprint, sorted", got)
|
||||
}
|
||||
}
|
||||
|
||||
// trunksOf are trunk heads of every repository, as the gate's source names them.
|
||||
func trunksOf(t *testing.T, heads map[string]string) *Trunks {
|
||||
t.Helper()
|
||||
data, err := delivery.EncodeSource(delivery.Source{Group: "g", Heads: heads})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := &Trunks{}
|
||||
tr.Put(data, false)
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestNothingIsKeptForAWorkspaceGivenSiblingsBeside(t *testing.T) { // review D1, ADR 0444, issue 1287
|
||||
b := newBench(t)
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{repoSDK: trunkS, "novox/mesh-catalog": trunkA})
|
||||
b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if got := len(b.steps.tool("fetch")); got != 1 || b.steps.tool("fetch")[0].args["beside"] == nil {
|
||||
t.Fatalf("the fetch was not given siblings: %+v", b.steps.tool("fetch"))
|
||||
}
|
||||
if keys, _ := b.kept.Keys(context.Background()); len(keys) != 0 {
|
||||
t.Errorf("kept %v for a package whose tests may read a sibling the digest does not cover", keys)
|
||||
}
|
||||
// And a kept verdict of the same digest from elsewhere is not taken for it: everything runs.
|
||||
if _, err := (Kept{Store: b.kept}).Keep(context.Background(), "unit", digA, keptValue("fixture/slow", 2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.steps.calls = nil
|
||||
b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if n := len(b.steps.tool("test")); n != 2 {
|
||||
t.Errorf("%d test steps, want both levels run whole", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// Unheard is the error of a call that reached nobody: the bus had no responder for it, or none answered within the call's
|
||||
// bound. The step it asked for never began, so the same step may be asked of another node (issue 1820). The program wraps
|
||||
// the errors of its calls in it where the bus gives a timeout; a no-responders error is known as it is.
|
||||
type Unheard struct{ Err error }
|
||||
|
||||
func (u *Unheard) Error() string { return u.Err.Error() }
|
||||
func (u *Unheard) Unwrap() error { return u.Err }
|
||||
|
||||
// isUnheard says whether err is a call that reached nobody. A step that began and failed, or ended failed, is not.
|
||||
func isUnheard(err error) bool {
|
||||
var u *Unheard
|
||||
return errors.As(err, &u) || errors.Is(err, nats.ErrNoResponders)
|
||||
}
|
||||
|
||||
// deafNode is a node that did not answer a build module's call during this judging.
|
||||
type deafNode struct{ node, why string }
|
||||
|
||||
// markDeaf takes a node out of the choice for a build module for the rest of the judging.
|
||||
func (st *state) markDeaf(bm, node string, err error) {
|
||||
if st.deaf == nil {
|
||||
st.deaf = map[string][]deafNode{}
|
||||
}
|
||||
st.deaf[bm] = append(st.deaf[bm], deafNode{node, err.Error()})
|
||||
}
|
||||
|
||||
func (st *state) isDeaf(bm, node string) bool {
|
||||
for _, d := range st.deaf[bm] {
|
||||
if d.node == node {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// triedWords names every node that did not answer a build module, with what the bus said.
|
||||
func (st *state) triedWords(bm string) string {
|
||||
var parts []string
|
||||
for _, d := range st.deaf[bm] {
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", d.node, d.why))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// moveStep is called when the target's node did not answer: the node is left out of the choice, and another build node of the
|
||||
// same build module takes the target's place. It answers an error naming every node tried when none is left (the words hold
|
||||
// NodesWords, so the verdict is judged again when the nodes change).
|
||||
func (st *state) moveStep(t *target, err error) error {
|
||||
old := t.node
|
||||
st.markDeaf(t.module, old, err)
|
||||
node, _, cerr := st.choose(t.module)
|
||||
if cerr != nil {
|
||||
return fmt.Errorf("%s; tried: %s", cerr, st.triedWords(t.module))
|
||||
}
|
||||
st.j.logf("%s#%d: %s did not answer (%v), the step goes to %s", t.pull.Repository, t.pull.Number, old, err, node)
|
||||
t.node = node
|
||||
other := false
|
||||
for _, o := range st.targets {
|
||||
if o != t && o.node == old {
|
||||
other = true
|
||||
}
|
||||
}
|
||||
if !other {
|
||||
delete(st.nodes, old)
|
||||
}
|
||||
st.nodes[node] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// refetch makes the target's workspace again on its new node: the workspace of a step that was lost is on the node that did
|
||||
// not answer.
|
||||
func (st *state) refetch(ctx context.Context, t *target) error {
|
||||
raw, err := st.step(ctx, t, "fetch", fmt.Sprintf("fetch %s#%d on %s", t.pull.Repository, t.pull.Number, t.node), t.fetchArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var f Fetched
|
||||
if err := json.Unmarshal(raw, &f); err != nil || f.Workspace == "" {
|
||||
return fmt.Errorf("the fetch of %s#%d on %s answered no workspace: %s", t.pull.Repository, t.pull.Number, t.node, raw)
|
||||
}
|
||||
t.fetched = f
|
||||
return nil
|
||||
}
|
||||
|
||||
// stepOnWorkspace sets the workspace a step reads to the target's current one.
|
||||
func stepOnWorkspace(t *target, args map[string]any) {
|
||||
if _, ok := args["workspace"]; ok {
|
||||
args["workspace"] = t.fetched.Workspace
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Issue 1820: a build step goes to a node whose build module is not heard (a laptop asleep, not yet marked offline by the
|
||||
// watchman), and the bus answers "no responders". The check moves the step to another node of the same build module.
|
||||
|
||||
// deafNodes makes every tool on the named nodes fail as the bus does when nobody listens there; count says how many
|
||||
// times the node was called.
|
||||
func deafNodes(b *bench, deaf ...string) {
|
||||
for tool, h := range b.steps.handlers {
|
||||
h := h
|
||||
b.steps.handlers[tool] = func(c stepCall) (any, error) {
|
||||
for _, n := range deaf {
|
||||
if c.node == n {
|
||||
return nil, fmt.Errorf("%s on %s: %w", c.tool, c.node, nats.ErrNoResponders)
|
||||
}
|
||||
}
|
||||
return h(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nodesOf(calls []stepCall) []string {
|
||||
var out []string
|
||||
for _, c := range calls {
|
||||
out = append(out, c.node)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAFetchThatNobodyAnswersMovesToAnotherBuildNode(t *testing.T) { // 1820/AC1
|
||||
b := newBench(t)
|
||||
b.ex.Toolchains = goEntries("ace", "g14")
|
||||
deafNodes(b, "ace")
|
||||
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
||||
if v.State != delivery.VerdictPass {
|
||||
t.Fatalf("verdict = %+v, want a pass on the node that answers", v)
|
||||
}
|
||||
if got := nodesOf(b.steps.tool("fetch")); len(got) != 2 || got[0] != "ace" || got[1] != "g14" {
|
||||
t.Errorf("fetch went to %v, want ace (no responders) then g14", got)
|
||||
}
|
||||
for _, c := range b.steps.tool("test") {
|
||||
if c.node != "g14" {
|
||||
t.Errorf("a test went to %s, want g14 only", c.node)
|
||||
}
|
||||
}
|
||||
if len(v.Machines) != 1 || v.Machines[0] != "g14" {
|
||||
t.Errorf("machines = %v, want [g14]: ace ran nothing", v.Machines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATestStepThatNobodyAnswersIsFetchedAgainOnAnotherNode(t *testing.T) { // 1820/AC2
|
||||
b := newBench(t)
|
||||
b.ex.Toolchains = goEntries("ace", "g14")
|
||||
// ace answers the fetch and the digest, then goes to sleep before the test.
|
||||
test := b.steps.handlers["build_go_test"]
|
||||
b.steps.handlers["build_go_test"] = func(c stepCall) (any, error) {
|
||||
if c.node == "ace" {
|
||||
return nil, fmt.Errorf("test on ace: %w", nats.ErrNoResponders)
|
||||
}
|
||||
return test(c)
|
||||
}
|
||||
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
||||
if v.State != delivery.VerdictPass {
|
||||
t.Fatalf("verdict = %+v, want a pass", v)
|
||||
}
|
||||
if got := nodesOf(b.steps.tool("fetch")); len(got) != 2 || got[1] != "g14" {
|
||||
t.Errorf("fetch went to %v, want ace then g14 (the workspace was on ace)", got)
|
||||
}
|
||||
for _, c := range b.steps.tool("test") {
|
||||
if c.node == "g14" && c.args["workspace"] == nil {
|
||||
t.Errorf("a test on g14 has no workspace")
|
||||
}
|
||||
}
|
||||
if len(v.Machines) != 1 || v.Machines[0] != "g14" {
|
||||
t.Errorf("machines = %v, want [g14]", v.Machines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhenNoBuildNodeAnswersTheFailureNamesEveryNodeTried(t *testing.T) { // 1820/AC3
|
||||
b := newBench(t)
|
||||
b.ex.Toolchains = goEntries("ace", "g14", "shanks")
|
||||
deafNodes(b, "ace", "g14", "shanks")
|
||||
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
||||
if v.State != delivery.VerdictFail {
|
||||
t.Fatalf("verdict = %+v, want a failure", v)
|
||||
}
|
||||
for _, n := range []string{"ace", "g14", "shanks"} {
|
||||
if !strings.Contains(v.Failed.Words, n) {
|
||||
t.Errorf("failure %q does not name %s", v.Failed.Words, n)
|
||||
}
|
||||
}
|
||||
if !IsNodesFailure(v) {
|
||||
t.Errorf("failure %q is not a failure of the nodes, so it would not be judged again when they change", v.Failed.Words)
|
||||
}
|
||||
if got := len(b.steps.tool("fetch")); got != 3 {
|
||||
t.Errorf("fetch was tried %d times, want once on each of 3 nodes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStepThatFailedForTheChangeIsNotMovedToAnotherNode(t *testing.T) { // 1820/AC4
|
||||
b := newBench(t)
|
||||
b.ex.Toolchains = goEntries("ace", "g14")
|
||||
b.steps.handlers["build_go_fetch"] = func(stepCall) (any, error) { return nil, fmt.Errorf("fetch on ace: the step failed: git exploded") }
|
||||
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
||||
if v.State != delivery.VerdictFail || len(b.steps.tool("fetch")) != 1 {
|
||||
t.Errorf("verdict = %+v after %d fetches, want a failure after one", v, len(b.steps.tool("fetch")))
|
||||
}
|
||||
}
|
||||
|
||||
func TestACanceledCheckIsNotMovedToAnotherNode(t *testing.T) { // 1820/AC4
|
||||
b := newBench(t)
|
||||
b.ex.Toolchains = goEntries("ace", "g14")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b.steps.handlers["build_go_fetch"] = func(stepCall) (any, error) {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("fetch on ace: %w", nats.ErrNoResponders)
|
||||
}
|
||||
_, _ = b.ex.Judge(ctx, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if n := len(b.steps.tool("fetch")); n != 1 {
|
||||
t.Errorf("fetch was tried %d times after the check ended, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPinReadThatNobodyAnswersIsTheForgesFailureNotABuildNodes(t *testing.T) { // 1820/AC5
|
||||
b := groupBench(t, "cb54756c3865")
|
||||
b.ex.Forge.(*fakeForge).err = fmt.Errorf("gitea.gitea_get_file: %w", nats.ErrNoResponders)
|
||||
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))).Verdict
|
||||
if v.State != delivery.VerdictFail || v.Failed.Machine != "forge" {
|
||||
t.Errorf("verdict = %+v, want a failure at the forge: no build node was to blame", v)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,7 @@
|
||||
package check
|
||||
|
||||
import "sync"
|
||||
|
||||
// PlansBuildingBucket is the controller's bucket of the delivery plans in their build phase, one key for each plan, written
|
||||
// by the controller alone (issue 1652): a plan key there means a delivery builds on the build nodes, and no new check starts
|
||||
// meanwhile (ADR 0484 clause 12, ADR 0483 clause 6). The bucket may be absent: the controller's issue 1652 is delivered
|
||||
// meanwhile (ADR 0484 clause 12, ADR 0483 clause 6). The keys age out with no delete marker, so the keys are listed, not watched. The bucket may be absent: the controller's issue 1652 is delivered
|
||||
// before R4c-5b, and if it is not, the bucket is read as empty.
|
||||
const PlansBuildingBucket = "mesh-controller_plans-building"
|
||||
|
||||
// PlanHold holds which delivery plans build now, from the controller's bucket (a watch puts the changes in). Safe for use
|
||||
// from the watch and from a check at once.
|
||||
type PlanHold struct {
|
||||
mu sync.Mutex
|
||||
plans map[string]bool
|
||||
}
|
||||
|
||||
// Put takes a change of the bucket in: a put of a plan's key, or its delete. It answers whether a delivery builds now
|
||||
// differently than before, so a watch wakes the checker when the last plan ended.
|
||||
func (h *PlanHold) Put(key string, deleted bool) (changed bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
before := len(h.plans) > 0
|
||||
if deleted {
|
||||
delete(h.plans, key)
|
||||
} else {
|
||||
if h.plans == nil {
|
||||
h.plans = map[string]bool{}
|
||||
}
|
||||
h.plans[key] = true
|
||||
}
|
||||
return before != (len(h.plans) > 0)
|
||||
}
|
||||
|
||||
// Held says whether any delivery plan builds now.
|
||||
func (h *PlanHold) Held() bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.plans) > 0
|
||||
}
|
||||
|
||||
@@ -7,39 +7,17 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func TestADeliveryThatBuildsHoldsTheChecksAndTheEndOfTheLastPlanSaysSo(t *testing.T) { // N8/AC1
|
||||
var h PlanHold
|
||||
if h.Held() {
|
||||
t.Fatal("an empty bucket holds")
|
||||
}
|
||||
if !h.Put("plan-1", false) || !h.Held() {
|
||||
t.Error("a first plan did not hold")
|
||||
}
|
||||
if h.Put("plan-2", false) {
|
||||
t.Error("a second plan changed whether a delivery builds")
|
||||
}
|
||||
if h.Put("plan-1", true) || !h.Held() {
|
||||
t.Error("one of two plans ending released the hold")
|
||||
}
|
||||
if !h.Put("plan-2", true) || h.Held() {
|
||||
t.Error("the last plan ending did not release the hold")
|
||||
}
|
||||
if h.Put("plan-3", true) {
|
||||
t.Error("the delete of a plan never put changed the hold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunningCheckIsNotStoppedWhenADeliveryStartsToBuild(t *testing.T) { // ADR 0484 clause 12, ADR 0483 clause 4
|
||||
ctx := context.Background()
|
||||
var h PlanHold
|
||||
held := false
|
||||
j := &judgeFunc{f: func(_ context.Context, _ *Case, call int) (Outcome, error) {
|
||||
if call == 1 {
|
||||
h.Put("plan-1", false) // a delivery starts while this check runs
|
||||
held = true // a delivery starts while this check runs
|
||||
}
|
||||
return Outcome{Verdict: passing()}, nil
|
||||
}}
|
||||
r := newRig(t, j)
|
||||
r.ch.Hold = h.Held
|
||||
r.ch.Hold = func() bool { return held }
|
||||
r.ch.Candidate(ctx, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1)))
|
||||
r.ch.Wait()
|
||||
if v := r.verdict(t, keyOf(t, "feat/x", candidateOf(t, "feat/x", pullOf(repoA, 7, head1)))); v.State != delivery.VerdictPass {
|
||||
@@ -51,7 +29,7 @@ func TestARunningCheckIsNotStoppedWhenADeliveryStartsToBuild(t *testing.T) { //
|
||||
if j.count() != 1 {
|
||||
t.Errorf("judged %d times while a delivery builds, want only the one already running", j.count())
|
||||
}
|
||||
h.Put("plan-1", true)
|
||||
held = false
|
||||
r.ch.Reconsider(ctx)
|
||||
r.ch.Wait()
|
||||
if j.count() != 2 {
|
||||
|
||||
@@ -58,6 +58,8 @@ type Examiner struct {
|
||||
// Previews is the previews bucket; nil writes none.
|
||||
Previews KV
|
||||
Modules BuildModules
|
||||
// Runs says which commit the mesh runs of a module (the indexer's modules); nil knows none.
|
||||
Runs interface{ CommitOf(module string) string }
|
||||
// Forge reads the go.mod files of the Go members and says whether a mesh-sdk commit is on its trunk (ADR 0484 clause 9);
|
||||
// nil checks no pin.
|
||||
Forge Forge
|
||||
@@ -92,9 +94,13 @@ type target struct {
|
||||
node string
|
||||
toolchain string
|
||||
fetched Fetched
|
||||
// fetchArgs are the arguments of the target's fetch, so a workspace lost with its node is made again on another.
|
||||
fetchArgs map[string]any
|
||||
// priority are the Go packages (import paths) or the Node module directories whose test files the merge changes.
|
||||
changed []string
|
||||
plans map[string]*goPlan // by level
|
||||
// beside says the workspace was given siblings: its tests may read what the digest does not cover, so nothing is kept for it.
|
||||
beside bool
|
||||
plans map[string]*goPlan // by level
|
||||
}
|
||||
|
||||
// goPlan is what a Go target has to run at one level: the digests, the packages without a kept verdict and the tests taken.
|
||||
@@ -120,6 +126,7 @@ type state struct {
|
||||
targets []*target
|
||||
levels map[string]*acc
|
||||
nodes map[string]bool
|
||||
deaf map[string][]deafNode // by build module: the nodes that did not answer during this judging
|
||||
clone time.Duration
|
||||
slow []string
|
||||
}
|
||||
@@ -198,8 +205,28 @@ func fail(machine, words string) *delivery.Verdict {
|
||||
return &v
|
||||
}
|
||||
|
||||
// step runs one step of a build module on the node the target is on, holding a place there while it runs.
|
||||
// step runs one step of a build module on the node the target is on, holding a place there while it runs. A node that does
|
||||
// not answer (the bus has no responder, or none answered) is left out and the step goes to another node of the same build
|
||||
// module; a step after the fetch makes its workspace again there first (issue 1820).
|
||||
func (st *state) step(ctx context.Context, t *target, tool, label string, args map[string]any) (json.RawMessage, error) {
|
||||
for {
|
||||
raw, err := st.stepOnce(ctx, t, tool, label, args)
|
||||
if err == nil || ctx.Err() != nil || !isUnheard(err) {
|
||||
return raw, err
|
||||
}
|
||||
if merr := st.moveStep(t, err); merr != nil {
|
||||
return nil, merr
|
||||
}
|
||||
if tool != "fetch" {
|
||||
if rerr := st.refetch(ctx, t); rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
stepOnWorkspace(t, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (st *state) stepOnce(ctx context.Context, t *target, tool, label string, args map[string]any) (json.RawMessage, error) {
|
||||
st.c.Progress.Set(label, t.node)
|
||||
release, err := st.j.Places.Acquire(ctx, t.node)
|
||||
if err != nil {
|
||||
@@ -213,7 +240,7 @@ func (st *state) step(ctx context.Context, t *target, tool, label string, args m
|
||||
func (st *state) choose(bm string) (node, toolchain string, err error) {
|
||||
language := buildrun.LanguageOf[bm]
|
||||
qualifies := func(node string) bool {
|
||||
return (st.j.Presence == nil || st.j.Presence.Online(node)) && (st.j.Health == nil || st.j.Health.Healthy(node, bm))
|
||||
return !st.isDeaf(bm, node) && (st.j.Presence == nil || st.j.Presence.Online(node)) && (st.j.Health == nil || st.j.Health.Healthy(node, bm))
|
||||
}
|
||||
nodes, tc, err := buildrun.ChooseNodes(language, st.j.Toolchains.Entries(language), st.j.Places, qualifies, 1)
|
||||
if err != nil {
|
||||
@@ -237,12 +264,19 @@ func (st *state) fetchAll(ctx context.Context) *delivery.Verdict {
|
||||
t := &target{pull: p, module: bm, node: node, toolchain: tc, plans: map[string]*goPlan{}}
|
||||
st.nodes[node] = true
|
||||
args := map[string]any{"repository": p.Repository, "commit": p.Head, "ref": refOf(p.Number), "base": p.Trunk}
|
||||
t.fetchArgs = args
|
||||
if bm == buildrun.GoBuildModule {
|
||||
if beside := st.j.Beside(st.c, p); len(beside) > 0 {
|
||||
beside, err := st.j.besideFor(ctx, st.c, p)
|
||||
if err != nil {
|
||||
return fail(node, err.Error())
|
||||
}
|
||||
if len(beside) > 0 {
|
||||
args["beside"] = beside
|
||||
t.beside = true
|
||||
}
|
||||
}
|
||||
raw, err := st.step(ctx, t, "fetch", fmt.Sprintf("fetch %s#%d on %s", p.Repository, p.Number, node), args)
|
||||
node = t.node // the step may have moved to another node
|
||||
if err != nil {
|
||||
return fail(node, fmt.Sprintf("fetch of %s#%d: %v%s", p.Repository, p.Number, err, pinHint(st.c, err.Error())))
|
||||
}
|
||||
@@ -364,6 +398,12 @@ func (st *state) planGo(ctx context.Context, t *target, level string) *delivery.
|
||||
}
|
||||
plan := &goPlan{digests: map[string]string{}}
|
||||
t.plans[level] = plan
|
||||
if t.beside {
|
||||
// The digest covers the package's own files, the toolchain and the header, not the siblings placed beside the tree: a
|
||||
// package whose tests read one must never be kept, nor taken from a verdict kept for other inputs (ADR 0444).
|
||||
plan.all = true
|
||||
return nil
|
||||
}
|
||||
raw, err := st.step(ctx, t, "digest", fmt.Sprintf("digest the %s level of %s#%d on %s", level, t.pull.Repository, t.pull.Number, t.node),
|
||||
map[string]any{"workspace": t.fetched.Workspace, "level": level})
|
||||
var d Digested
|
||||
|
||||
@@ -29,6 +29,8 @@ type Forge interface {
|
||||
// OnTrunk says whether commit (a full or a 12-digit sha) is already merged into the trunk of the repository: it holds no
|
||||
// commit the trunk does not.
|
||||
OnTrunk(ctx context.Context, repository, commit string) (bool, error)
|
||||
// FullSHA is the 40-digit sha of a commit named by its first 12 digits.
|
||||
FullSHA(ctx context.Context, repository, commit string) (string, error)
|
||||
}
|
||||
|
||||
var pseudoVersion = regexp.MustCompile(`-([0-9a-f]{12})$`)
|
||||
@@ -65,7 +67,7 @@ func (st *state) checkPins(ctx context.Context) verdictPtr {
|
||||
st.c.Progress.Set(fmt.Sprintf("read %s of %s#%d", file, t.pull.Repository, t.pull.Number), t.node)
|
||||
text, err := st.j.Forge.File(ctx, t.pull.Repository, file, t.pull.Head)
|
||||
if err != nil {
|
||||
return failed(t.node, fmt.Sprintf("%s#%d: %s cannot be read to check its mesh-sdk pin: %v", t.pull.Repository, t.pull.Number, file, err))
|
||||
return failed(forgeMachine(err, t.node), fmt.Sprintf("%s#%d: %s cannot be read to check its mesh-sdk pin: %v", t.pull.Repository, t.pull.Number, file, err))
|
||||
}
|
||||
pin, ok := SDKPin(text)
|
||||
if !ok {
|
||||
@@ -76,7 +78,7 @@ func (st *state) checkPins(ctx context.Context) verdictPtr {
|
||||
}
|
||||
onTrunk, err := st.j.Forge.OnTrunk(ctx, SDKRepository, pin)
|
||||
if err != nil {
|
||||
return failed(t.node, fmt.Sprintf("%s#%d: whether mesh-sdk %s is on its trunk cannot be told: %v", t.pull.Repository, t.pull.Number, pin, err))
|
||||
return failed(forgeMachine(err, t.node), fmt.Sprintf("%s#%d: whether mesh-sdk %s is on its trunk cannot be told: %v", t.pull.Repository, t.pull.Number, pin, err))
|
||||
}
|
||||
if onTrunk {
|
||||
continue
|
||||
@@ -102,3 +104,12 @@ func pinHint(c *Case, fetchError string) string {
|
||||
}
|
||||
return " (a pin to the head of the group's mesh-sdk pull request names a commit only that head carries, which go mod download cannot resolve; " + Issue1148 + ")"
|
||||
}
|
||||
|
||||
// forgeMachine is the machine a failed read of the forge is put at: the forge when nobody answered it (no build node is to
|
||||
// blame, issue 1820), the build node whose workspace was read otherwise.
|
||||
func forgeMachine(err error, node string) string {
|
||||
if isUnheard(err) {
|
||||
return "forge"
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ func (f *fakeForge) File(_ context.Context, repository, path, ref string) (strin
|
||||
return "", fmt.Errorf("no file %s of %s at %s", path, repository, ref)
|
||||
}
|
||||
|
||||
func (f *fakeForge) FullSHA(_ context.Context, _ string, commit string) (string, error) {
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return commit + strings.Repeat("0", 40-len(commit)), nil
|
||||
}
|
||||
|
||||
func (f *fakeForge) OnTrunk(_ context.Context, _ string, commit string) (bool, error) {
|
||||
if f.err != nil {
|
||||
return false, f.err
|
||||
@@ -145,3 +152,53 @@ func TestAPinThatCannotBeCheckedFailsAndNeverPasses(t *testing.T) { // ADR 0453
|
||||
t.Errorf("verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
type runsAt map[string]string
|
||||
|
||||
func (r runsAt) CommitOf(module string) string { return r[module] }
|
||||
|
||||
func besideOf(t *testing.T, b *bench, repository string) map[string]BesideRef {
|
||||
t.Helper()
|
||||
out := map[string]BesideRef{}
|
||||
for _, f := range b.steps.tool("fetch") {
|
||||
if f.args["repository"] == repository {
|
||||
refs, _ := f.args["beside"].([]BesideRef)
|
||||
for _, r := range refs {
|
||||
out[r.Repository] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestTheSiblingsAreWhereTheTestsStateThem(t *testing.T) { // review D2, ADR 0484 clause 4
|
||||
const controller, catalogue, host = "novox/mesh-controller", "novox/mesh-catalog", "novox/mesh-host"
|
||||
hostRuns := strings.Repeat("7", 40)
|
||||
b := newBench(t)
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{controller: trunkA, catalogue: trunkS, host: trunkA2, SDKRepository: "9999999999999999999999999999999999999999"})
|
||||
b.ex.Runs = runsAt{"mesh-host": hostRuns}
|
||||
forge := &fakeForge{goMods: map[string]string{controller + " go.mod " + head1: capturedGoMod(t, "cb54756c3865")}, onTrunk: map[string]bool{"cb54756c3865": true}}
|
||||
b.ex.Forge = forge
|
||||
// A group of the controller and the catalogue: the controller's tests read the catalogue's trunk, not the group's head.
|
||||
b.judge(t, caseOf(t, pullOf(controller, 7, head1), pullOf(catalogue, 12, head2)))
|
||||
got := besideOf(t, b, controller)
|
||||
if c := got[catalogue]; c.Commit != trunkS || c.Ref != "" {
|
||||
t.Errorf("the catalogue beside the controller = %+v, want its trunk %s", c, trunkS)
|
||||
}
|
||||
if h := got[host]; h.Commit != hostRuns {
|
||||
t.Errorf("the node-engine beside the controller = %+v, want the commit the mesh runs %s", h, hostRuns)
|
||||
}
|
||||
if s := got[SDKRepository]; s.Commit != "cb54756c3865"+strings.Repeat("0", 28) {
|
||||
t.Errorf("mesh-sdk beside the controller = %+v, want the commit its go.mod pins, whole", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePinOfTheControllerThatCannotBeReadFailsTheCheck(t *testing.T) { // review D2
|
||||
b := newBench(t)
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{"novox/mesh-controller": trunkA})
|
||||
b.ex.Forge = &fakeForge{goMods: map[string]string{}, onTrunk: map[string]bool{}}
|
||||
v := b.judge(t, caseOf(t, pullOf("novox/mesh-controller", 7, head1))).Verdict
|
||||
if v.State != delivery.VerdictFail || !strings.Contains(v.Failed.Words, "go.mod") {
|
||||
t.Errorf("verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,5 @@ novox/mesh-delivery at main, and forge-compare-trunk.json the answer of gitea_co
|
||||
trunk: no commit main does not hold); both called through the mesh on 2026-10-21 for issue 1624. forge-compare-branch.json is the answer of the same call for a
|
||||
head that is not on the trunk (novox/mesh-sdk base main, head afbe371003a5, the branch feat/r4c5-sdk), shortened to two of its seven commits and one of its
|
||||
twenty-six files, with the head and the totals set to match.
|
||||
|
||||
forge-commits.json is the answer of the forge's gitea_commits for novox/mesh-sdk, ref cb54756c3865, limit 1, called through the mesh on 2026-10-21 (review D2).
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"commits": [
|
||||
{
|
||||
"author": "mesh-tools",
|
||||
"date": "2026-10-21T10:02:09Z",
|
||||
"sha": "cb54756c3865df5ac9c0dc0481ad9ebe2bf013a1",
|
||||
"subject": "Merge pull request 'Give delivery.Module the toolchains its fingerprint was computed with, and '.' as a root module's path, so the builder builds a key only with its own toolchain (issue 1615)' (#55) from fix/1615-module-toolchains-root into main"
|
||||
}
|
||||
],
|
||||
"more": true,
|
||||
"page": 1,
|
||||
"ref": "cb54756c3865",
|
||||
"repository": "novox/mesh-sdk"
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
|
||||
"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "node-engine.have"}; !reflect.DeepEqual(sorted(m.Reads), want) {
|
||||
t.Errorf("reads = %v, want %v", m.Reads, want)
|
||||
}
|
||||
if want := []string{"gitea.gitea_compare", "gitea.gitea_get_file", "mesh-build-go.build_go_digest", "mesh-build-go.build_go_fetch", "mesh-build-go.build_go_test", "mesh-build-node.build_node_fetch", "mesh-build-node.build_node_test"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
|
||||
if want := []string{"gitea.gitea_commits", "gitea.gitea_compare", "gitea.gitea_get_file", "mesh-build-go.build_go_digest", "mesh-build-go.build_go_fetch", "mesh-build-go.build_go_test", "mesh-build-node.build_node_fetch", "mesh-build-node.build_node_test"}; !reflect.DeepEqual(sorted(m.Invokes), want) {
|
||||
t.Errorf("invokes = %v, want only the build modules' fetch, digest and test (no run step before issue 1309, ADR 0484 clause 5) and the forge's two reads for a mesh-sdk pin (clause 9)", m.Invokes)
|
||||
}
|
||||
// The checker is woken by a build module's report of a toolchain, as the indexer is: the bucket may not exist when it starts.
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"checker_verdicts"
|
||||
],
|
||||
"invokes": [
|
||||
"gitea.gitea_commits",
|
||||
"gitea.gitea_compare",
|
||||
"gitea.gitea_get_file",
|
||||
"mesh-build-go.build_go_digest",
|
||||
|
||||
@@ -35,18 +35,47 @@ func Tools(r view.Reader) []stdio.Tool {
|
||||
mods, err := r.Modules(only)
|
||||
return map[string]any{"modules": mods}, err
|
||||
}},
|
||||
{Name: view.BuilderBuildsReadTool,
|
||||
Description: "The builder's build keys, each as stored in mesh-delivery-builder_builds: key, module, fingerprint (src2) and build " +
|
||||
"(state, by, step with the nodes it was built on, manifest, artifacts with their digests, toolchains, error). Narrow by module " +
|
||||
"or state (queued, building, done, failed). Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"module": map[string]any{"type": "string", "description": "only this module's builds"},
|
||||
"state": map[string]any{"type": "string", "description": "only builds in this state"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
module, _ := args["module"].(string)
|
||||
state, _ := args["state"].(string)
|
||||
rows, err := r.BuilderBuilds(module, state)
|
||||
return map[string]any{"builds": rows}, err
|
||||
}},
|
||||
{Name: view.BuilderBuildReadTool,
|
||||
Description: "One build of the builder as stored. Give the module and its fingerprint (fp2, src2:...), or the module alone for the build " +
|
||||
"at the fingerprint the indexer holds for it now, joined through modules.<module>; with commit, only if the indexer holds the module at that commit. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"module"}, "properties": map[string]any{
|
||||
"module": map[string]any{"type": "string", "description": "the module's name"},
|
||||
"fp2": map[string]any{"type": "string", "description": "its new-form fingerprint; empty means the indexer's current one"},
|
||||
"commit": map[string]any{"type": "string", "description": "the commit the indexer must hold the module at"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
module, _ := args["module"].(string)
|
||||
fp2, _ := args["fp2"].(string)
|
||||
commit, _ := args["commit"].(string)
|
||||
return r.BuilderBuild(module, fp2, commit)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
state := stdio.State("mesh-delivery-indexer.modules")
|
||||
r := view.Reader{State: view.StateRead{Keys: state.Keys, Get: func(key string) (json.RawMessage, error) {
|
||||
func read(state stdio.KeptState) view.StateRead {
|
||||
return view.StateRead{Keys: state.Keys, Get: func(key string) (json.RawMessage, error) {
|
||||
e, err := state.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value, nil
|
||||
}}}
|
||||
}}
|
||||
}
|
||||
|
||||
func main() {
|
||||
r := view.Reader{State: read(stdio.State("mesh-delivery-indexer.modules")), Builds: read(stdio.State("mesh-delivery-builder.builds"))}
|
||||
if err := stdio.Serve("", Tools(r)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -94,3 +94,65 @@ func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) {
|
||||
t.Errorf("none2 modules = %v", only)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
fp2A = "src2:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
fp2B = "src2:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
keyA = "netcheck.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
keyB = "zsh.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
doneBuild = `{"v":1,"state":"done","by":"novox.mesh-delivery-builder","step":"built on shanks, ace","manifest":"m","artifacts":[{"name":"program","kind":"bundle","digest":"sha256:d1"}]}`
|
||||
failBuild = `{"v":1,"state":"failed","by":"novox.mesh-delivery-builder","error":"refused kind: package"}`
|
||||
)
|
||||
|
||||
func both(builds, modules fake) view.Reader {
|
||||
r := modules.reader()
|
||||
r.Builds = builds.reader().State
|
||||
return r
|
||||
}
|
||||
|
||||
// 1723/AC1: builder_builds lists every key as stored (module, fingerprint, state, digests, error), and narrows by module and state.
|
||||
func TestBuilderBuildsListsKeysAsStored(t *testing.T) {
|
||||
r := both(fake{keyA: doneBuild, keyB: failBuild, "junk": "{}"}, fake{})
|
||||
out, err := run(t, r, view.BuilderBuildsReadTool, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := out.(map[string]any)["builds"].([]view.BuilderRow)
|
||||
if len(rows) != 2 || rows[0].Key != keyA || rows[0].Module != "netcheck" || rows[0].Fingerprint != fp2A || string(rows[0].Build) != doneBuild || string(rows[1].Build) != failBuild {
|
||||
t.Errorf("rows = %+v", rows)
|
||||
}
|
||||
for args, want := range map[string]string{"state": keyB, "module": keyA} {
|
||||
out, _ := run(t, r, view.BuilderBuildsReadTool, map[string]any{args: map[string]string{keyB: "failed", keyA: "netcheck"}[want]})
|
||||
got := out.(map[string]any)["builds"].([]view.BuilderRow)
|
||||
if len(got) != 1 || got[0].Key != want {
|
||||
t.Errorf("%s filter = %+v, want %s", args, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1723/AC2: builder_build answers one key by module and fp2, and by module alone through the indexer's fp2 (joined at its commit).
|
||||
func TestBuilderBuildJoinsTheIndexersFingerprint(t *testing.T) {
|
||||
idx := `{"v":3,"fp2":"` + fp2A + `","commit":"c1","path":"."}`
|
||||
r := both(fake{keyA: doneBuild}, fake{"netcheck": idx, "zsh": `{"v":3,"none2":"not known","commit":"c9"}`})
|
||||
for _, args := range []map[string]any{{"module": "netcheck", "fp2": fp2A}, {"module": "netcheck"}, {"module": "netcheck", "commit": "c1"}} {
|
||||
out, err := run(t, r, view.BuilderBuildReadTool, args)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", args, err)
|
||||
}
|
||||
if row := out.(view.BuilderRow); row.Key != keyA || string(row.Build) != doneBuild {
|
||||
t.Errorf("%v = %+v", args, row)
|
||||
}
|
||||
}
|
||||
for args, want := range map[string]string{"netcheck/c2": "not c2", "zsh": "not known", "gone": "no module gone"} {
|
||||
a := map[string]any{"module": strings.Split(args, "/")[0]}
|
||||
if strings.Contains(args, "/") {
|
||||
a["commit"] = "c2"
|
||||
}
|
||||
if _, err := run(t, r, view.BuilderBuildReadTool, a); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%v: err = %v, want %q", a, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := run(t, r, view.BuilderBuildReadTool, map[string]any{"module": "netcheck", "fp2": fp2B}); err == nil || !strings.Contains(err.Error(), "holds no build") {
|
||||
t.Errorf("an unbuilt fingerprint: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-view
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPs
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -26,7 +26,10 @@ type StateRead struct {
|
||||
}
|
||||
|
||||
// Reader answers the read tools from a modules state.
|
||||
type Reader struct{ State StateRead }
|
||||
type Reader struct {
|
||||
State StateRead // the indexer's modules
|
||||
Builds StateRead // the builder's builds
|
||||
}
|
||||
|
||||
// Module is the stored value of modules.<module>, exactly as the indexer wrote it.
|
||||
func (r Reader) Module(module string) (json.RawMessage, error) {
|
||||
@@ -80,3 +83,110 @@ func (r Reader) Modules(none2 bool) (map[string]json.RawMessage, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The read tools of the builder's builds (novox/hq issue 1723): what the builder stored under `<module>.<fingerprint hex>`
|
||||
// in mesh-delivery-builder_builds, answered as stored, so its states, nodes (in the step) and digests can be checked live.
|
||||
const (
|
||||
BuilderBuildReadTool = "builder_build"
|
||||
BuilderBuildsReadTool = "builder_builds"
|
||||
)
|
||||
|
||||
// BuilderRow is one key of the builder's builds: the key, the module and the fingerprint it parses to, and the stored value
|
||||
// as the builder wrote it (state, by, step, manifest, artifacts with digests, toolchains, error).
|
||||
type BuilderRow struct {
|
||||
Key string `json:"key"`
|
||||
Module string `json:"module"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Build json.RawMessage `json:"build"`
|
||||
}
|
||||
|
||||
func (r Reader) builderRow(key string) (BuilderRow, bool, error) {
|
||||
module, fp, err := delivery.ParseBuilderBuildKey(key)
|
||||
if err != nil {
|
||||
return BuilderRow{}, false, nil // a key that is no build key is not a build
|
||||
}
|
||||
raw, err := r.Builds.Get(key)
|
||||
if err != nil {
|
||||
return BuilderRow{}, false, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return BuilderRow{}, false, nil
|
||||
}
|
||||
return BuilderRow{Key: key, Module: module, Fingerprint: fp, Build: raw}, true, nil
|
||||
}
|
||||
|
||||
// BuilderBuilds lists the builder's keys, each as stored, sorted by key; module and state, when given, keep only the builds
|
||||
// of that module or in that state.
|
||||
func (r Reader) BuilderBuilds(module, state string) ([]BuilderRow, error) {
|
||||
if r.Builds.Keys == nil || r.Builds.Get == nil {
|
||||
return nil, fmt.Errorf("the builder's builds cannot be read here")
|
||||
}
|
||||
keys, err := r.Builds.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
rows := []BuilderRow{}
|
||||
for _, k := range keys {
|
||||
row, ok, err := r.builderRow(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok || (module != "" && row.Module != module) {
|
||||
continue
|
||||
}
|
||||
if state != "" {
|
||||
var b struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
if json.Unmarshal(row.Build, &b) != nil || b.State != state {
|
||||
continue
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// BuilderBuild is the build of module at the new-form fingerprint fp2; with an empty fp2 it is the build at the fingerprint
|
||||
// the indexer holds for the module now (its fp2), joined through modules.<module>; commit, when given, must be the commit
|
||||
// the indexer holds that fp2 for, or the answer says which commit it holds (the fingerprint of another commit is not guessed).
|
||||
func (r Reader) BuilderBuild(module, fp2, commit string) (BuilderRow, error) {
|
||||
module = strings.TrimSpace(module)
|
||||
if r.Builds.Get == nil {
|
||||
return BuilderRow{}, fmt.Errorf("the builder's builds cannot be read here")
|
||||
}
|
||||
if fp2 == "" {
|
||||
raw, err := r.Module(module)
|
||||
if err != nil {
|
||||
return BuilderRow{}, err
|
||||
}
|
||||
var m struct {
|
||||
FP2 string `json:"fp2"`
|
||||
None2 string `json:"none2"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return BuilderRow{}, fmt.Errorf("the indexer's value of %s is not readable: %w", module, err)
|
||||
}
|
||||
if commit != "" && m.Commit != commit {
|
||||
return BuilderRow{}, fmt.Errorf("the indexer holds %s at commit %s, not %s, so its fingerprint is not the one of that commit", module, m.Commit, commit)
|
||||
}
|
||||
if m.FP2 == "" {
|
||||
return BuilderRow{}, fmt.Errorf("the indexer holds no new-form fingerprint of %s: %s", module, m.None2)
|
||||
}
|
||||
fp2 = m.FP2
|
||||
}
|
||||
key, err := delivery.BuilderBuildKey(module, fp2)
|
||||
if err != nil {
|
||||
return BuilderRow{}, err
|
||||
}
|
||||
row, ok, err := r.builderRow(key)
|
||||
if err != nil {
|
||||
return BuilderRow{}, err
|
||||
}
|
||||
if !ok {
|
||||
return BuilderRow{}, fmt.Errorf("the builder holds no build %s", key)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
if m.Module != "mesh-delivery-view" {
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3
|
||||
want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-builder.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3
|
||||
got := slices.Clone(m.Reads)
|
||||
slices.Sort(got)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -95,7 +95,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
|
||||
}
|
||||
// The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer
|
||||
// itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads.
|
||||
if want := []string{"indexer_module", "indexer_modules"}; !reflect.DeepEqual(m.Tools, want) {
|
||||
if want := []string{"indexer_module", "indexer_modules", "builder_build", "builder_builds"}; !reflect.DeepEqual(m.Tools, want) {
|
||||
t.Errorf("tools = %v, want %v", m.Tools, want)
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
|
||||
@@ -5,11 +5,14 @@
|
||||
"slug": "view",
|
||||
"tools": [
|
||||
"indexer_module",
|
||||
"indexer_modules"
|
||||
"indexer_modules",
|
||||
"builder_build",
|
||||
"builder_builds"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-controller.builds",
|
||||
"mesh-delivery-builder.builds",
|
||||
"mesh-delivery-gate.asks",
|
||||
"mesh-delivery-indexer.candidates",
|
||||
"mesh-delivery-indexer.modules",
|
||||
|
||||
Reference in New Issue
Block a user