A module names the module its build stands on, not a copy of it
A fingerprint written into a recipe names one particular copy of the base — the copy on whichever machine the person typing it was using. On any other mesh that copy has never existed, so the build stops on its first line with a message about an image nobody can look up. Three modules in the catalogue were in exactly that state, and the line each of them replaced was equally dead. A module now names the module and artifact instead, and the mesh answers with what it holds. The builder is still a thing that clones, builds and answers: the answer travels with the question, because only the mesh knows what it has. A base the mesh has not built is refused before anything is built, naming which module has to exist first.
This commit is contained in:
@@ -67,7 +67,7 @@ type Result struct {
|
||||
// archive failed would otherwise leave half of itself in the store under a digest the mesh never
|
||||
// records — reachable, unreferenced, and indistinguishable from something in use.
|
||||
func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
repository, path, ref, workspace string) (Result, error) {
|
||||
repository, path, ref, workspace string, held map[string]string) (Result, error) {
|
||||
|
||||
// Made rather than required. A builder that fails because the directory it was told to work
|
||||
// in does not exist is a builder that needs a setup step nobody documented.
|
||||
@@ -117,12 +117,19 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
||||
|
||||
var built []catalogue.Built
|
||||
if manifest.Build != nil {
|
||||
// What this module said it stands on, answered with what this mesh actually holds. Done
|
||||
// before anything is built, so a missing base is refused in front of the person who can
|
||||
// fix it rather than inside a build that stops on its own first line.
|
||||
args, err := standingOn(manifest, held)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
artifacts := append([]catalogue.Artifact{}, manifest.Build.Artifacts...)
|
||||
// Ordered, so two builds of one commit do the same work in the same sequence and their
|
||||
// logs can be compared.
|
||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||
for _, a := range artifacts {
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, commit, a)
|
||||
made, err := one(ctx, run, publish, manifest.Module, within, commit, a, args)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
@@ -208,7 +215,7 @@ func against(within string, manifest catalogue.Manifest) []string {
|
||||
const ManifestName = "module.json"
|
||||
|
||||
func one(ctx context.Context, run Runner, publish Publisher,
|
||||
module, tree, commit string, a catalogue.Artifact) (catalogue.Built, error) {
|
||||
module, tree, commit string, a catalogue.Artifact, args []string) (catalogue.Built, error) {
|
||||
|
||||
switch a.Kind {
|
||||
case catalogue.ArtifactUpstream:
|
||||
@@ -229,7 +236,11 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
||||
// release and a commit is what was actually built. The mesh pins the digest anyway; this
|
||||
// is only so a person looking at the build node can tell what is there.
|
||||
local := fmt.Sprintf("%s-%s:%s", module, a.Name, short(commit))
|
||||
if _, err := run(ctx, tree, "docker", "build", "-f", a.From, "-t", local, "."); err != nil {
|
||||
// The bases this module named, resolved to what this mesh holds. A recipe reads them as
|
||||
// build arguments, so a module says which module it stands on and never which copy.
|
||||
invocation := append([]string{"build", "-f", a.From, "-t", local}, args...)
|
||||
invocation = append(invocation, ".")
|
||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
||||
return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err)
|
||||
}
|
||||
reference, err := publish.PublishImage(ctx, local, module+"/"+a.Name)
|
||||
@@ -357,3 +368,38 @@ func Command(ctx context.Context, dir, name string, args ...string) (string, err
|
||||
}
|
||||
|
||||
var _ io.Writer = (*stringWriter)(nil)
|
||||
|
||||
// standingOn turns the bases a module named into build arguments for what this mesh holds.
|
||||
//
|
||||
// **Refused rather than defaulted** (novox/hq issue 044). A module naming a base the mesh has not
|
||||
// built cannot be built here yet, and the useful sentence names which module is missing — not the
|
||||
// one a container runtime produces when a recipe's first line refers to an image nobody has.
|
||||
//
|
||||
// The order is fixed so two builds of one commit invoke the same command.
|
||||
func standingOn(manifest catalogue.Manifest, held map[string]string) ([]string, error) {
|
||||
if manifest.Build == nil || len(manifest.Build.On) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
on := append([]catalogue.BuildsOn{}, manifest.Build.On...)
|
||||
sort.Slice(on, func(i, j int) bool { return on[i].Arg < on[j].Arg })
|
||||
|
||||
var args []string
|
||||
for _, base := range on {
|
||||
if base.Arg == "" || base.Module == "" || base.Artifact == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%s says its build stands on something, and does not say all of what: a base "+
|
||||
"needs the module, the artifact, and the build argument the recipe reads it "+
|
||||
"from", manifest.Module)
|
||||
}
|
||||
key := base.Module + "/" + base.Artifact
|
||||
reference, has := held[key]
|
||||
if !has {
|
||||
return nil, fmt.Errorf(
|
||||
"%s builds on %s, and this mesh has not built it. Build %s first — every module "+
|
||||
"in this toolchain stands on it, so it is the thing to have before anything "+
|
||||
"else", manifest.Module, key, base.Module)
|
||||
}
|
||||
args = append(args, "--build-arg", base.Arg+"="+reference)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestABuildProducesAManifestThePinsAreIn(t *testing.T) {
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{
|
||||
"Dockerfile": "FROM scratch", "files/theme.conf": "dark",
|
||||
})
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) {
|
||||
})
|
||||
// A year apart, so a packer carrying timestamps cannot accidentally agree.
|
||||
r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) {
|
||||
// unreferenced, and indistinguishable from something in use.
|
||||
r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"})
|
||||
// `files` is missing, so packing the archive fails — after the image would have been pushed.
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace)
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a build with a missing input succeeded")
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) {
|
||||
func TestARepositoryWithNoManifestSaysSo(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
r := &recorded{contents: map[string]string{"README.md": "nothing to see"}}
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace)
|
||||
_, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a repository with nothing saying what it is was built")
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) {
|
||||
// Most of what a person installs is configuration.
|
||||
r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[
|
||||
{"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func TestTheTreeIsFreshEveryTime(t *testing.T) {
|
||||
if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace); err != nil {
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(leftover); err == nil {
|
||||
@@ -222,7 +222,7 @@ func TestABuildThatCannotPushFails(t *testing.T) {
|
||||
"Dockerfile": "FROM scratch", "files/a": "b",
|
||||
})
|
||||
r.failPush = true
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace); err == nil {
|
||||
if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil); err == nil {
|
||||
t.Fatal("a build that could publish nothing reported success")
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,7 @@ func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) {
|
||||
"resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}`
|
||||
|
||||
r, workspace := aRepository(t, mirrors, nil)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) {
|
||||
"modules/other/" + ManifestName: `{"module":"other","version":"1"}`,
|
||||
}}
|
||||
got, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir())
|
||||
"https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -321,7 +321,7 @@ func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) {
|
||||
for _, escaping := range []string{"../../etc", "/etc"} {
|
||||
r := &recorded{contents: map[string]string{ManifestName: withBoth}}
|
||||
_, err := Build(context.Background(), r.run, r,
|
||||
"https://forge.invalid/x.git", escaping, "", t.TempDir())
|
||||
"https://forge.invalid/x.git", escaping, "", t.TempDir(), nil)
|
||||
if err == nil {
|
||||
t.Fatalf("%q was accepted as a module's path", escaping)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
)
|
||||
|
||||
// A module naming a base the mesh has not built is refused, and the refusal names what is missing.
|
||||
//
|
||||
// **This is the whole point of naming a base rather than pinning one** (novox/hq issue 044). A
|
||||
// recipe with a fingerprint typed into it fails inside a container runtime, on its first line, with
|
||||
// a message about an image nobody can look up. This fails before anything is built, saying which
|
||||
// module has to exist first.
|
||||
func TestABaseTheMeshHasNotBuiltIsRefused(t *testing.T) {
|
||||
manifest := catalogue.Manifest{
|
||||
Module: "postgres",
|
||||
Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}},
|
||||
},
|
||||
}
|
||||
_, err := standingOn(manifest, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("a base nothing has built was accepted; the build would have failed on its first line")
|
||||
}
|
||||
for _, want := range []string{"mesh-tools", "postgres"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And one the mesh holds becomes the argument the recipe reads it from.
|
||||
func TestABaseTheMeshHoldsBecomesABuildArgument(t *testing.T) {
|
||||
manifest := catalogue.Manifest{
|
||||
Module: "postgres",
|
||||
Build: &catalogue.Build{
|
||||
On: []catalogue.BuildsOn{{Arg: "RUNTIME_BASE", Module: "mesh-tools", Artifact: "runtime"}},
|
||||
},
|
||||
}
|
||||
held := map[string]string{"mesh-tools/runtime": "127.0.0.1:5000/mesh-tools/runtime@sha256:" + strings.Repeat("a", 64)}
|
||||
args, err := standingOn(manifest, held)
|
||||
if err != nil {
|
||||
t.Fatalf("a base this mesh holds was refused: %v", err)
|
||||
}
|
||||
want := []string{"--build-arg", "RUNTIME_BASE=" + held["mesh-tools/runtime"]}
|
||||
if len(args) != len(want) || args[0] != want[0] || args[1] != want[1] {
|
||||
t.Fatalf("the build was invoked with %v, not %v", args, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module naming no base asks for nothing, which is most modules.
|
||||
func TestAModuleNamingNoBaseAddsNoArguments(t *testing.T) {
|
||||
args, err := standingOn(catalogue.Manifest{Module: "hello-web", Build: &catalogue.Build{}}, nil)
|
||||
if err != nil || args != nil {
|
||||
t.Fatalf("a module naming no base produced %v, %v", args, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Half a base is refused rather than half-applied.
|
||||
func TestAnIncompleteBaseIsRefused(t *testing.T) {
|
||||
manifest := catalogue.Manifest{
|
||||
Module: "postgres",
|
||||
Build: &catalogue.Build{On: []catalogue.BuildsOn{{Module: "mesh-tools", Artifact: "runtime"}}},
|
||||
}
|
||||
if _, err := standingOn(manifest, map[string]string{"mesh-tools/runtime": "x"}); err == nil {
|
||||
t.Fatal("a base with no build argument was accepted; nothing would have read it")
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,31 @@ type Build struct {
|
||||
// Artifacts are what the source produces, each named so a resource can refer to it before
|
||||
// anybody knows its digest.
|
||||
Artifacts []Artifact `json:"artifacts,omitempty"`
|
||||
// On is what this module's own build stands on: another module's artifact, named rather than
|
||||
// pinned.
|
||||
//
|
||||
// **A module may not write down which copy of its base to use** (novox/hq issue 044). Every
|
||||
// module in a scripted toolchain is compiled inside one shared image, and a fingerprint typed
|
||||
// into a recipe names one particular copy of it — the copy on whichever machine the person
|
||||
// typing was using. On any other mesh that copy has never existed, so the build stops on its
|
||||
// first line. Naming the module instead lets the mesh answer with the copy *this* mesh has,
|
||||
// which is the only one it can fetch.
|
||||
//
|
||||
// It does not make the build edge declared. What this says is where to start; what the build
|
||||
// was actually built against is still read back out of the build itself (ADR 0009), and the
|
||||
// two can disagree — a recipe that names a base and then bakes in a second one is exactly the
|
||||
// drift that reading it back catches.
|
||||
On []BuildsOn `json:"on,omitempty"`
|
||||
}
|
||||
|
||||
// BuildsOn is one base a build needs, and the name the recipe knows it by.
|
||||
type BuildsOn struct {
|
||||
// Arg is the build argument the recipe reads it from.
|
||||
Arg string `json:"arg"`
|
||||
// Module is whose artifact it is.
|
||||
Module string `json:"module"`
|
||||
// Artifact is which of that module's artifacts, by its own name for it.
|
||||
Artifact string `json:"artifact"`
|
||||
}
|
||||
|
||||
// Artifact is one thing built from a module's source.
|
||||
|
||||
@@ -100,3 +100,47 @@ func (i *Inventory) Builds(ctx context.Context, module string, limit int) ([]Bui
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Held is every artifact this mesh has built, keyed "<module>/<artifact>".
|
||||
//
|
||||
// **The newest successful build of each module wins**, which is the same rule the rest of the mesh
|
||||
// uses for what a module currently is. A module rebuilt to something broken and then rebuilt again
|
||||
// is at the second one; a module whose last build failed is at the last one that worked, because a
|
||||
// failure published nothing and the thing it published before is still what exists.
|
||||
//
|
||||
// Only successes, and only builds that knew what they were building: a build that failed before it
|
||||
// could read a manifest has no module to be the artifact of.
|
||||
func (i *Inventory) Held(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select distinct on (module) module, made
|
||||
from build
|
||||
where module is not null and module <> '' and failed = ''
|
||||
order by module, at desc`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
held := map[string]string{}
|
||||
for rows.Next() {
|
||||
var module string
|
||||
var raw []byte
|
||||
if err := rows.Scan(&module, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var made []Artifact
|
||||
if err := json.Unmarshal(raw, &made); err != nil {
|
||||
// Skipped rather than fatal. One unreadable build record should not stop every other
|
||||
// module's base from being answerable — and the build that needs this one will say
|
||||
// plainly that it is missing.
|
||||
continue
|
||||
}
|
||||
for _, artifact := range made {
|
||||
if artifact.Name == "" || artifact.Reference == "" {
|
||||
continue
|
||||
}
|
||||
held[module+"/"+artifact.Name] = artifact.Reference
|
||||
}
|
||||
}
|
||||
return held, rows.Err()
|
||||
}
|
||||
|
||||
@@ -51,6 +51,18 @@ type BuildRequest struct {
|
||||
// repository root, which is the ordinary case; a repository holding several modules names
|
||||
// each by its own directory.
|
||||
Path string `json:"path,omitempty"`
|
||||
// Held is every artifact this mesh has built, keyed "<module>/<artifact>".
|
||||
//
|
||||
// **Sent with the asking rather than fetched by the builder** (novox/hq issue 044). A module
|
||||
// says which module's artifact its build stands on; only the mesh knows which copy of that
|
||||
// artifact *this* mesh holds, and the builder is deliberately a thing that clones, runs a
|
||||
// build and answers — giving it a way to ask the mesh questions would make it something else.
|
||||
// So the answer travels with the question.
|
||||
//
|
||||
// It is everything rather than only what this module needs, because what this module needs is
|
||||
// written in a manifest the mesh has not read: it is inside the repository, and reading it is
|
||||
// the build's first act.
|
||||
Held map[string]string `json:"held,omitempty"`
|
||||
}
|
||||
|
||||
// BuildResult is what a builder says back.
|
||||
|
||||
Reference in New Issue
Block a user