mesh-build-go: push each done archive to the artifact store from its own process and answer its reference (issue 1587, S1-S4, ADR 0498 §1 §2, lead decision L9)

After the build ended the module pushes the packed bundle or archive with mesh-sdk's
PushBlob under <module>/<artifact> by its digest, a candidate's as a trunk build's, and the
step answers artifact-store://<module>/<artifact>/blobs/<digest> instead of a path. A push
that fails fails the step not stored: <artifact>: <the store's words>. The packed bytes are
never written to the node. The manifest requires artifact-store and places its address.
This commit is contained in:
2026-10-22 18:22:17 +02:00
parent 9c5b7e14e1
commit b6247f837d
15 changed files with 240 additions and 47 deletions
+7 -2
View File
@@ -105,8 +105,13 @@ that vendors), `go build -ldflags "-s -w -X main.builtFor=<system>" -o .mesh-bui
module's directory, by the installed go (`GOTOOLCHAIN=local`) from the module cache the fetch filled and no network
(`GOPROXY=off`). The binary is named as the build agent names it. The output directory is packed with mesh-sdk's `Pack`,
as the build agent packs; two build nodes on the same toolchain give the same digest for the same tree (ADR 0477 §6). The step ends with the artifact, the binary, the digest, the size and
the path of the archive, which stays in the workspace's `artifacts` directory: publishing it is the builder's, in a later
release. An artifact that is neither a Go bundle nor an archive fails the step naming it.
the reference of the archive: once the build ended, this process pushes it to the artifact store (novox/hq ADR 0498 §1 §2,
mesh-sdk's `buildstep.PushBlob`) under `<module>/<artifact>` by its digest, a candidate's as a trunk build's, and answers
`artifact-store://<module>/<artifact>/blobs/<digest>`; a push that fails fails the step `not stored: <artifact>: <the
store's words>`. The packed bytes never stay on the node. The store's address is `${dir:state}/registry`, placed by the
manifest, which requires `artifact-store`. The go build runs shut off in bubblewrap (ADR 0448, `isolate.go`): no
network, none of the account, the caches beneath a throwaway layer unless the step is told `publish` (a trunk build). An artifact that is neither a Go
bundle nor an archive fails the step naming it.
The same tool packs an archive artifact of any module's tree, whatever its language (ADR 0478 §1, issue 1589): the
directory named by `from`, or a file pinned by `url` and `sha256`, packed with the same `Pack` as the build agent's `pack`
@@ -43,9 +43,9 @@ func (m *Module) httpClient() *http.Client {
// buildArchive packs one archive artifact of the module in dir: a pinned file when it names a url, else the directory
// `from` of the module's tree.
func (m *Module) buildArchive(ctx context.Context, workspace, module, dir string, art catalogue.Artifact) (any, error) {
func (m *Module) buildArchive(ctx context.Context, workspace, module, name, dir string, art catalogue.Artifact) (any, error) {
if art.URL != "" {
return m.pinnedArchive(ctx, workspace, module, art)
return m.pinnedArchive(ctx, workspace, module, name, art)
}
from := strings.Trim(path.Clean("/"+filepath.ToSlash(art.From)), "/")
if strings.TrimSpace(art.From) == "" {
@@ -77,12 +77,12 @@ func (m *Module) buildArchive(ctx context.Context, workspace, module, dir string
if !within(realDir, realRoot) {
return nil, fmt.Errorf("the archive %s is taken from %q, which lies outside the module: it resolves to %s, and the module is %s", art.Name, art.From, realRoot, realDir)
}
return m.packInto(workspace, module, art.Name, root)
return m.packInto(ctx, workspace, module, name, art.Name, root)
}
// pinnedArchive fetches the file an artifact is pinned to, checks it against the pin, lays it out as the artifact says
// (kept whole as `file`, or unpacked from `within`) and packs it.
func (m *Module) pinnedArchive(ctx context.Context, workspace, module string, art catalogue.Artifact) (any, error) {
func (m *Module) pinnedArchive(ctx context.Context, workspace, module, name string, art catalogue.Artifact) (any, error) {
file, err := m.pinnedFile(ctx, workspace, art)
if err != nil {
return nil, fmt.Errorf("%s: %w", art.Name, err)
@@ -111,7 +111,7 @@ func (m *Module) pinnedArchive(ctx context.Context, workspace, module string, ar
} else if err := unpackUpstream(file, laid, art.Within); err != nil {
return nil, fmt.Errorf("%s: %s (%s): %w", art.Name, art.URL, art.Version, err)
}
return m.packInto(workspace, module, art.Name, laid)
return m.packInto(ctx, workspace, module, name, art.Name, laid)
}
// pinnedFile is the path of the pinned file in the workspace: kept from an earlier build when its bytes are still the
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
@@ -40,16 +41,11 @@ const (
capturedWithGo = "go1.27.1"
)
// sameAsBuildAgent says the archive at res.Path holds the tar stream the build agent's pack wrote, and, under the Go the
// sameAsBuildAgent says the archive the build pushed holds the tar stream the build agent's pack wrote, and, under the Go the
// digests were captured with, has its digest.
func sameAsBuildAgent(t *testing.T, res BuildResult, stream, digest string) {
t.Helper()
f, err := os.Open(res.Path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
z, err := gzip.NewReader(f)
z, err := gzip.NewReader(bytes.NewReader(archiveOf(t, res)))
if err != nil {
t.Fatal(err)
}
@@ -157,8 +153,8 @@ func TestAnArchiveOfADirectoryHasTheBuildAgentsDigest(t *testing.T) {
if res.Artifact != "files" || res.Size == 0 || res.Binary != "" {
t.Errorf("result = %+v", res)
}
if info, err := os.Stat(res.Path); err != nil || info.Size() != int64(res.Size) {
t.Errorf("the archive at %q: %v", res.Path, err)
if len(archiveOf(t, res)) != res.Size {
t.Errorf("the archive pushed is not %d bytes", res.Size)
}
if len(*calls) != 0 {
t.Errorf("an archive ran %d commands", len(*calls))
@@ -3,8 +3,8 @@ package main
// The build step (novox/hq ADR 0405 §3 §7, ADR 0453 §7; issue 1487): a Go bundle artifact of a module of a fetched
// workspace, built with today's recipe — the build agent's, mesh-controller's internal/builder/toolchain.go (the Go
// entry) and builder.go (compile, binaryName, pack) — by the installed Go and no network, packed with mesh-sdk's Pack
// so that its digest is the build agent's for the same tree. The archive is left in the workspace; publishing it is
// the builder's, in a later release.
// so that its digest is the build agent's for the same tree, and pushed to the artifact store by this process once the
// build ended (ADR 0498 §1 §2, push.go).
import (
"context"
@@ -15,6 +15,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The Go recipe of the build agent (toolchain.go, the entry of language go): static, trimmed of its own paths and of
@@ -33,8 +34,9 @@ type BuildResult struct {
Binary string `json:"binary"`
Digest string `json:"digest"`
Size int `json:"size"`
// Path is the archive, in the workspace's directory of the work directory.
Path string `json:"path"`
// Reference is where the archive is in the artifact store (novox/hq ADR 0498 §1):
// artifact-store://<module>/<artifact>/blobs/<digest>.
Reference string `json:"reference"`
}
func (m *Module) buildTool() StepTool {
@@ -108,7 +110,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
return nil, fmt.Errorf("the module %s has no artifact %q (it has %s)", manifest.Module, name, strings.Join(names, ", "))
}
if art.Kind == "archive" {
return m.buildArchive(ctx, workspace, module, dir, *art)
return m.buildArchive(ctx, workspace, module, manifest.Module, dir, *art)
}
if art.Kind != "bundle" || art.Language != "go" {
return nil, fmt.Errorf("the artifact %s of %s is a %s of language %q: this module builds Go bundles and archives", name, manifest.Module, art.Kind, art.Language)
@@ -147,7 +149,7 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
if err := m.insideModule(workspace, dir, out); err != nil {
return nil, fmt.Errorf("the output directory of %s: %w", art.Name, err)
}
res, err := m.packInto(workspace, module, art.Name, out)
res, err := m.packInto(ctx, workspace, module, manifest.Module, art.Name, out)
if err != nil {
return nil, err
}
@@ -155,22 +157,20 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
return res, nil
}
// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and leaves the archive in
// the workspace's artifacts directory.
func (m *Module) packInto(workspace, module, artifact, root string) (BuildResult, error) {
// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and pushes the archive to
// the artifact store from this process, after the build step ended (novox/hq ADR 0498 §1 §2): a candidate's as a trunk
// build's, under <module>/<artifact> by its digest. The step answers the reference; a push that fails fails the step
// `not stored:`. The packed bytes never stay on the node: they are held in this process until the push answered (the
// lead's decision L9).
func (m *Module) packInto(ctx context.Context, workspace, module, name, artifact, root string) (BuildResult, error) {
archive, digest, err := buildstep.Pack(root)
if err != nil {
return BuildResult{}, fmt.Errorf("packing %s: %w", artifact, err)
}
artifacts := filepath.Join(m.Work, workspace, "artifacts")
if err := os.MkdirAll(artifacts, 0o700); err != nil {
return BuildResult{}, err
if err := m.push(ctx, name+"/"+artifact, digest, archive); err != nil {
return BuildResult{}, fmt.Errorf("%s%s: %v", delivery.NotStored, artifact, err)
}
path := filepath.Join(artifacts, strings.ReplaceAll(module, "/", "_")+"."+artifact+".tar.gz")
if err := os.WriteFile(path, archive, 0o600); err != nil {
return BuildResult{}, err
}
return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Path: path}, nil
return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Reference: buildstep.BlobReference(name, artifact, digest)}, nil
}
// buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the installed go
@@ -52,6 +52,7 @@ func buildModule(t *testing.T, manifest, content string) (*Module, *keptStates,
}
return forgekey.Ran{}
}, &[]shutOff{}) // the compile runs shut off (1309)
withStore(t, m) // the archive is pushed to a store of the test's own (ADR 0498)
return m, states, calls
}
@@ -143,13 +144,10 @@ func TestTheArchiveHoldsOneExecutableAndTwoBuildsAreOneDigest(t *testing.T) {
m, states, _ := buildModule(t, manifestWith(goBundle), "the program")
first, _ := runBuild(t, m, states, "tools")
second, _ := runBuild(t, m, states, "tools")
if first.Digest != second.Digest || first.Path != second.Path {
if first.Digest != second.Digest || first.Reference != second.Reference {
t.Errorf("two builds: %+v and %+v", first, second)
}
raw, err := os.ReadFile(first.Path)
if err != nil {
t.Fatal(err)
}
raw := archiveOf(t, first)
sum := sha256.Sum256(raw)
if first.Digest != "sha256:"+hex.EncodeToString(sum[:]) {
t.Errorf("the digest %s is not the archive's", first.Digest)
@@ -143,8 +143,9 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) {
// template in the files/ archive (novox/hq ADR 0349 §2).
func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) {
m := readForgeManifest(t)
if len(m.Requires) != 2 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" {
t.Errorf("requires = %v, want [forge-account go-toolchain]", m.Requires)
// artifact-store: the module pushes a done build's archive itself (ADR 0498 §2).
if len(m.Requires) != 3 || m.Requires[0] != "forge-account" || m.Requires[1] != "go-toolchain" || m.Requires[2] != "artifact-store" {
t.Errorf("requires = %v, want [forge-account go-toolchain artifact-store]", m.Requires)
}
keeps := false
for _, s := range m.State {
@@ -50,6 +50,7 @@ func main() {
m.Home = u.HomeDir
}
m.Run = StepRunner(m.Home)
m.RegistryFile = m.Home + "/registry"
m.Probe = ProbeGo(m.Home, m.Cache)
m.Bubblewrap = ProbeBubblewrap
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
@@ -157,3 +157,21 @@ func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) {
t.Errorf("capabilities %v lack user-namespaces", m.Capabilities)
}
}
// 1587-S1 (ADR 0498 §2): the module requires the artifact store and is told its address in a file of its home, which no
// run step is given (a run step has no network either, ADR 0448).
func TestTheModuleRequiresTheArtifactStoreAndIsToldItsAddress(t *testing.T) {
m := readManifest(t)
if !slices.Contains(m.Requires, "artifact-store") {
t.Errorf("requires %v lack artifact-store", m.Requires)
}
r := resource(t, m, "registry")
// The validator reads the template (files/registry) into the resource's content.
if content, _ := r["content"].(string); r["type"] != "file" || r["path"] != "${dir:state}/registry" || !strings.Contains(content, "${seat:mesh-artifact-store:reach}") {
t.Errorf("the registry resource is %v", r)
}
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "registry"))
if err != nil || strings.TrimSpace(string(raw)) != "${seat:mesh-artifact-store:reach}" {
t.Errorf("files/registry is %q (%v), want the store seat's reach", raw, err)
}
}
@@ -56,6 +56,10 @@ type Module struct {
// Inside is the integration level's own process, started shut off in bubblewrap by a test step (level.go): its
// commands run as they are, never shut off a second time.
Inside bool
// RegistryFile holds the artifact store's address (host:port), placed by the manifest; Push puts a blob there
// (buildstep.PushBlob when nil; a test's fake store).
RegistryFile string
Push func(ctx context.Context, registry, repository, digest string, body []byte) error
// Executable is this program's path, which the integration level starts again shut off; os.Executable when empty.
Executable string
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
@@ -21,7 +21,7 @@ import (
// member, then both pass, the build with GOPROXY=off.
func TestRealFetchAndBuildOfAMemberPinningAPullHead(t *testing.T) {
// Not skipped when a tool is missing: a pass of the check must mean this test ran (the check prints no test log).
for _, tool := range []string{"go", "git"} {
for _, tool := range []string{"go", "git", "bwrap"} { // bwrap: the build runs shut off (1309)
if _, err := exec.LookPath(tool); err != nil {
t.Fatalf("no %s where this check runs: the pin on a pull request's head cannot be proven", tool)
}
@@ -41,6 +41,7 @@ func TestRealFetchAndBuildOfAMemberPinningAPullHead(t *testing.T) {
}
_, memberHead := f.repo("novox/member", member)
m, states := f.module(t)
withStore(t, m) // the build pushes its archive (ADR 0498)
res, st := fetched(t, m, states, map[string]any{"repository": "novox/member", "commit": memberHead})
if st.State != delivery.StepPassed {
@@ -0,0 +1,42 @@
package main
// The push of a done build's archive (novox/hq ADR 0498 §1 §2; issue 1587): from this module's own process, after the
// build step ended, with mesh-sdk's one client (buildstep.PushBlob), to the artifact store whose address the manifest
// binds into RegistryFile. No build step reaches the store: a run step has no network (ADR 0448).
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// registry is the artifact store's address, host:port, as the manifest bound it into RegistryFile.
func (m *Module) registry() (string, error) {
raw, err := os.ReadFile(m.RegistryFile)
if err != nil {
return "", fmt.Errorf("the artifact store's address cannot be read: %w", err)
}
addr := strings.TrimSpace(string(raw))
if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") {
return "", fmt.Errorf("the artifact store's address in %s is %q, not host:port", m.RegistryFile, addr)
}
return addr, nil
}
// push puts body into the artifact store as a blob of repository (<module>/<artifact>) by its digest: Push when a test
// set it, else buildstep.PushBlob.
func (m *Module) push(ctx context.Context, repository, digest string, body []byte) error {
addr, err := m.registry()
if err != nil {
return err
}
if m.Push != nil {
return m.Push(ctx, addr, repository, digest, body)
}
return buildstep.PushBlob(ctx, &http.Client{Timeout: 10 * time.Minute}, addr, repository, digest, body)
}
@@ -0,0 +1,120 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// pushed are the blobs the fake artifact store took, by digest, for the tests that read an archive back.
var pushed sync.Map
// fakeStore makes m push to a store of the test's own: its address in RegistryFile, each blob kept by repository and
// digest; fail, when set, is the store's refusal.
type fakeStore struct {
mu sync.Mutex
blobs map[string][]byte // "<repository>@<digest>"
fail error
addr string
}
func withStore(t *testing.T, m *Module) *fakeStore {
t.Helper()
s := &fakeStore{blobs: map[string][]byte{}}
m.RegistryFile = filepath.Join(t.TempDir(), "registry")
if err := os.WriteFile(m.RegistryFile, []byte("store.test:5000\n"), 0o644); err != nil {
t.Fatal(err)
}
m.Push = func(ctx context.Context, registry, repository, digest string, body []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
s.addr = registry
if s.fail != nil {
return s.fail
}
sum := sha256.Sum256(body)
if "sha256:"+hex.EncodeToString(sum[:]) != digest {
t.Errorf("pushed %s with bytes of another digest", digest)
}
s.blobs[repository+"@"+digest] = append([]byte(nil), body...)
pushed.Store(digest, s.blobs[repository+"@"+digest])
return nil
}
return s
}
// archiveOf is the archive a build pushed, by its digest.
func archiveOf(t *testing.T, res BuildResult) []byte {
t.Helper()
raw, ok := pushed.Load(res.Digest)
if !ok {
t.Fatalf("nothing was pushed under %s", res.Digest)
}
return raw.([]byte)
}
// 1587-S2 (ADR 0498 §1): a done build answers a reference of the stated form whose tail is the build's digest, and the
// store holds the archive there; a bundle and an archive alike.
func TestADoneBuildAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) {
m, states, _ := buildModule(t, manifestWith(goBundle), "the program")
store := withStore(t, m)
res, st := runBuild(t, m, states, "tools")
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if want := "artifact-store://demo/tools/blobs/" + res.Digest; res.Reference != want {
t.Errorf("reference %q, want %q", res.Reference, want)
}
if store.addr != "store.test:5000" || store.blobs["demo/tools@"+res.Digest] == nil {
t.Errorf("the store at %q holds %d blobs, not demo/tools@%s", store.addr, len(store.blobs), res.Digest)
}
a, states2, _ := archiveModule(t, "")
withStore(t, a)
arch, st := runBuild(t, a, states2, "files")
if st.State != delivery.StepPassed || arch.Reference != "artifact-store://demo/files/blobs/"+arch.Digest {
t.Errorf("the archive artifact: %s %q, reference %q", st.State, st.Reason, arch.Reference)
}
}
// 1587-S3 (ADR 0498 §2): a push the store refuses fails the step `not stored: <artifact>: <the store's words>`, and so
// does a store whose address the manifest did not place.
func TestARefusedPushFailsTheStepNotStored(t *testing.T) {
m, states, _ := buildModule(t, manifestWith(goBundle), "the program")
store := withStore(t, m)
store.fail = errors.New("store.test:5000 refused the blob: 500 Internal Server Error disk full")
if _, st := runBuild(t, m, states, "tools"); st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, delivery.NotStored+"tools: ") || !strings.Contains(st.Reason, "disk full") {
t.Errorf("the step ended %s: %q", st.State, st.Reason)
}
m.RegistryFile = filepath.Join(t.TempDir(), "absent")
if _, st := runBuild(t, m, states, "tools"); st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, delivery.NotStored+"tools: ") {
t.Errorf("no address: the step ended %s: %q", st.State, st.Reason)
}
}
// 1587-S4 (the lead's decision L9): the packed file is not left on the node once its push answered, a candidate's
// included: the workspace holds no archive, pushed or refused.
func TestThePackedFileLeavesTheNodeOnceItsPushAnswered(t *testing.T) {
for _, fail := range []error{nil, errors.New("refused")} {
m, states, _ := buildModule(t, manifestWith(goBundle), "the program")
withStore(t, m).fail = fail
runBuild(t, m, states, "tools")
var archives []string
filepath.WalkDir(m.Work, func(p string, d os.DirEntry, err error) error {
if err == nil && strings.HasSuffix(p, ".tar.gz") {
archives = append(archives, p)
}
return nil
})
if len(archives) != 0 {
t.Errorf("push answered %v: archives left on the node: %v", fail, archives)
}
}
}
@@ -161,6 +161,7 @@ func TestBuildTwiceGivesOneDigestAndOneExecutable(t *testing.T) {
home := t.TempDir()
m := &Module{Home: home, Work: work, Cache: t.TempDir()}
m.Run = StepRunner(home)
withStore(t, m)
args := map[string]any{"workspace": "ws1", "module": ".", "artifact": "tools"}
first, err := m.build(context.Background(), args)
if err != nil {
@@ -174,11 +175,7 @@ func TestBuildTwiceGivesOneDigestAndOneExecutable(t *testing.T) {
if a.Digest != b.Digest || a.Binary != "prog-bin" {
t.Errorf("two builds of one tree: %+v and %+v", a, b)
}
raw, err := os.ReadFile(a.Path)
if err != nil {
t.Fatal(err)
}
zr, err := gzip.NewReader(bytes.NewReader(raw))
zr, err := gzip.NewReader(bytes.NewReader(archiveOf(t, a)))
if err != nil {
t.Fatal(err)
}
+1
View File
@@ -0,0 +1 @@
${seat:mesh-artifact-store:reach}
+10 -1
View File
@@ -31,7 +31,8 @@
],
"requires": [
"forge-account",
"go-toolchain"
"go-toolchain",
"artifact-store"
],
"own-secrets": {
"broker": "${dir:state}/broker"
@@ -109,6 +110,14 @@
"mode": "0644",
"template": "files/gitconfig"
},
{
"id": "registry",
"type": "file",
"path": "${dir:state}/registry",
"owner": "mesh-build-go",
"mode": "0644",
"template": "files/registry"
},
{
"id": "git",
"type": "package",