diff --git a/modules/mesh-delivery-builder/internal/build/plan.go b/modules/mesh-delivery-builder/internal/build/plan.go new file mode 100644 index 0000000..78aff09 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/plan.go @@ -0,0 +1,83 @@ +// Package build is the builder's logic (novox/hq ADR 0453 clause 4, ADR 0478), apart from the bus: what a manifest asks +// to be built and where, the claim of a build key, the choice of build nodes, the build modules' long steps and the run of +// one build. The bus is reached through the small interfaces here, so each part is tested with fakes. +package build + +import ( + "fmt" + "sort" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The build modules a task is given to, and the language whose toolchain each reports (the indexer's ToolchainModules). +const ( + GoBuildModule = "mesh-build-go" + NodeBuildModule = "mesh-build-node" +) + +// RefusedPrefix opens the error of a build key that is failed for a kind no builder builds yet (ADR 0478 clause 2): such +// a key is not built again as a new revision. +const RefusedPrefix = "refused kind:" + +// 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 { + Artifact string + Kind string + Module string + Language string +} + +// Plan is what a manifest asks the builder to build, by artifact name. +type Plan struct{ Tasks []Task } + +// Modules are the build modules the plan calls, sorted. +func (p Plan) Modules() []string { + seen := map[string]bool{} + var out []string + for _, t := range p.Tasks { + if !seen[t.Module] { + seen[t.Module] = true + out = append(out, t.Module) + } + } + sort.Strings(out) + return out +} + +// Refused is the error of a plan the builder does not build: an artifact of a kind (or of a language) it has no build +// module for. +type Refused struct{ Artifact, What string } + +func (r *Refused) Error() string { + return fmt.Sprintf("%s %s (artifact %s): the builder builds Go and TypeScript bundles and archives; build-agent builds it", RefusedPrefix, r.What, r.Artifact) +} + +// 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, +// 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) { + if m.Build == nil { + return Plan{}, nil + } + artifacts := append([]catalogue.Artifact(nil), m.Build.Artifacts...) + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) + var p Plan + for _, a := range artifacts { + switch { + case a.Kind == catalogue.ArtifactArchive: + p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: GoBuildModule, Language: "go"}) + case a.Kind == catalogue.ArtifactBundle && a.Language == "go": + p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: GoBuildModule, Language: "go"}) + case a.Kind == catalogue.ArtifactBundle && a.Language == "typescript": + p.Tasks = append(p.Tasks, Task{Artifact: a.Name, Kind: a.Kind, Module: NodeBuildModule, Language: "typescript"}) + case a.Kind == catalogue.ArtifactBundle: + return Plan{}, &Refused{Artifact: a.Name, What: fmt.Sprintf("bundle of language %q", a.Language)} + default: + return Plan{}, &Refused{Artifact: a.Name, What: a.Kind} + } + } + return p, nil +} diff --git a/modules/mesh-delivery-builder/internal/build/plan_test.go b/modules/mesh-delivery-builder/internal/build/plan_test.go new file mode 100644 index 0000000..bbeb0c3 --- /dev/null +++ b/modules/mesh-delivery-builder/internal/build/plan_test.go @@ -0,0 +1,75 @@ +package build + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// manifestOf is a manifest holding these artifacts: the plan reads only their name, kind and language. +func manifestOf(artifacts ...catalogue.Artifact) catalogue.Manifest { + return catalogue.Manifest{Module: "m", Build: &catalogue.Build{Artifacts: artifacts}} +} + +func TestPlanRoutesEachKindToItsBuildModule(t *testing.T) { // 1607/AC2 + m := manifestOf( + catalogue.Artifact{Name: "tool", Kind: "bundle", Language: "go"}, + catalogue.Artifact{Name: "web", Kind: "bundle", Language: "typescript"}, + catalogue.Artifact{Name: "files", Kind: "archive"}) + p, err := PlanOf(m) + if err != nil { + t.Fatal(err) + } + want := []Task{ + {Artifact: "files", Kind: "archive", Module: GoBuildModule, Language: "go"}, + {Artifact: "tool", Kind: "bundle", Module: GoBuildModule, Language: "go"}, + {Artifact: "web", Kind: "bundle", Module: NodeBuildModule, Language: "typescript"}, + } + if len(p.Tasks) != len(want) { + t.Fatalf("tasks = %+v, want %+v", p.Tasks, want) + } + for i := range want { + if p.Tasks[i] != want[i] { + t.Errorf("task %d = %+v, want %+v", i, p.Tasks[i], want[i]) + } + } + if modules := p.Modules(); len(modules) != 2 || modules[0] != GoBuildModule || modules[1] != NodeBuildModule { + t.Errorf("modules = %v", modules) + } +} + +func TestPlanRefusesImageUpstreamAndPackageByName(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"}, + } { + m := manifestOf(catalogue.Artifact{Name: "ok", Kind: "archive"}, c.artifact) + p, err := PlanOf(m) + if err == nil { + t.Errorf("%s: plan %+v, want a refusal", c.kind, p) + continue + } + var r *Refused + if !asRefused(err, &r) || !strings.HasPrefix(err.Error(), RefusedPrefix) || !strings.Contains(err.Error(), c.kind) || !strings.Contains(err.Error(), "build-agent") { + t.Errorf("%s: error = %v, want it to open %q and name the kind and build-agent", c.kind, err, RefusedPrefix) + } + } +} + +func TestPlanRefusesABundleOfAnotherLanguageAndAModuleWithNoArtifact(t *testing.T) { + if _, err := PlanOf(manifestOf(catalogue.Artifact{Name: "x", Kind: "bundle", Language: "python"})); err == nil || !strings.HasPrefix(err.Error(), RefusedPrefix) { + t.Errorf("a python bundle: error = %v, want a refusal", err) + } + p, err := PlanOf(manifestOf()) + if err != nil || len(p.Tasks) != 0 { + t.Errorf("no artifact: plan %+v error %v, want an empty plan", p, err) + } +} + +func asRefused(err error, target **Refused) bool { return errors.As(err, target) }