mesh-build-node: push each done bundle to the artifact store from its own process and answer its reference (issue 1587, S5-S8, ADR 0498 §1 §2, lead decision L9)

After the build ended the module pushes the packed bundle 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 the archive's 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:25:14 +02:00
parent 22992da15d
commit 3a25b6e1f8
12 changed files with 201 additions and 18 deletions
+6 -1
View File
@@ -70,4 +70,9 @@ toolchain install: a long step answers at once with its id and ends on `<node>.<
toolchain's packages (not typescript or esbuild) copied to `stage-<artifact>/node_modules`: the build agent's depth
(`/app/modules/module`, `/app/node_modules`), so esbuild's `../../node_modules/...` path comments, and the bundle's
digest, equal its. Copies, not links, because the mesh makes no symlink. A bundle that keeps an external gets them in its own `node_modules`.
The packed archive is written beside the tree as `<artifact>.tar.gz`.
Once the build ended, this process pushes the packed archive to the artifact store (novox/hq ADR 0498 §1 §2, mesh-sdk's
`buildstep.PushBlob`, the address in `${dir:state}/registry`) under `<module>/<artifact>` by its digest, a candidate's as a
trunk build's, and answers `reference: 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. tsc and esbuild, like the
type-check and the test script, run shut off in bubblewrap (ADR 0448): no network, none of the account, the toolchain
read-only, the npm cache beneath a throwaway layer unless the step is told `publish` (a trunk build).
@@ -18,6 +18,7 @@ import (
"strings"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// launcherSuffix is what an entrypoint's launcher is called beside it: index.js is started as index.serve.mjs
@@ -32,6 +33,8 @@ const banner = "--banner:js=import { createRequire as __meshRequire } from 'node
// artifact is the part of a module.json artifact the build reads.
type artifact struct {
// module is the manifest's module name, which the artifact is stored under.
module string
Name string `json:"name"`
Language string `json:"language"`
Entrypoints []string `json:"entrypoints"`
@@ -46,9 +49,10 @@ type BuildResult struct {
// Digest is "sha256:" and the hex of the archive's bytes (buildstep.Pack).
Digest string `json:"digest"`
Bytes int `json:"bytes"`
// Archive is the packed file, in the workspace beside its tree.
Archive string `json:"archive"`
Files []string `json:"files"`
// Reference is where the packed bundle is in the artifact store (novox/hq ADR 0498 §1):
// artifact-store://<module>/<artifact>/blobs/<digest>. The packed file never stays on the node.
Reference string `json:"reference"`
Files []string `json:"files"`
}
func (m *Module) buildTool() StepTool {
@@ -134,9 +138,10 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
if err != nil {
return nil, fmt.Errorf("packing %s: %w", a.Name, err)
}
file := filepath.Join(root, a.Name+".tar.gz")
if err := os.WriteFile(file, archive, 0o600); err != nil {
return nil, err
// Pushed from this process once the build ended, a candidate's as a trunk build's (ADR 0498 §1 §2): the step answers
// the reference, or fails not stored. The packed bytes are never written to the node (the lead's decision L9).
if err := m.push(ctx, a.module+"/"+a.Name, digest, archive); err != nil {
return nil, fmt.Errorf("%s%s: %v", delivery.NotStored, a.Name, err)
}
files := []string{}
_ = filepath.WalkDir(bundleDir, func(p string, d os.DirEntry, err error) error {
@@ -147,7 +152,8 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
return nil
})
sort.Strings(files)
return BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: digest, Bytes: len(archive), Archive: file, Files: files}, nil
return BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: digest, Bytes: len(archive),
Reference: buildstep.BlobReference(a.module, a.Name, digest), Files: files}, nil
}
// bundle makes every entrypoint and every launcher ONE file, with esbuild (builder.go:1971-2039): an entrypoint is
@@ -262,7 +268,8 @@ func readArtifact(dir, name string) (artifact, error) {
return artifact{}, fmt.Errorf("the module has no module.json: %w", err)
}
var m struct {
Build struct {
Module string `json:"module"`
Build struct {
Artifacts []artifact `json:"artifacts"`
} `json:"build"`
}
@@ -279,6 +286,7 @@ func readArtifact(dir, name string) (artifact, error) {
if len(a.Entrypoints) == 0 {
return artifact{}, fmt.Errorf("artifact %q names no entrypoints", name)
}
a.module = m.Module
return a, nil
}
return artifact{}, fmt.Errorf("module.json declares no artifact %q", name)
@@ -39,6 +39,7 @@ func realMachine(t *testing.T) (*Module, string) {
Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: run, Registry: func() (Registry, error) {
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
}}}
withStore(t, m)
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
t.Fatal(err)
}
@@ -96,12 +96,10 @@ func TestTscAndEsbuildRunWithTheBuildAgentsFlags(t *testing.T) {
}
}
func readArchive(t *testing.T, path string) map[string]os.FileMode {
// readArchive reads the archive a build pushed (ADR 0498: the packed file never stays on the node).
func readArchive(t *testing.T, r BuildResult) map[string]os.FileMode {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
raw := archiveOf(t, r)
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
@@ -135,7 +133,7 @@ func TestTheBundleIsPackedWithItsLauncherAndPackageJson(t *testing.T) {
if want := []string{"package.json", "tools/index.js", "tools/index.serve.mjs"}; !slices.Equal(r.Files, want) {
t.Errorf("files = %v, want %v", r.Files, want)
}
modes := readArchive(t, r.Archive)
modes := readArchive(t, r)
if len(modes) != 3 || modes["tools/index.serve.mjs"]&0o111 == 0 {
t.Errorf("archive = %v, want the three files with the launcher executable", modes)
}
@@ -143,7 +141,7 @@ func TestTheBundleIsPackedWithItsLauncherAndPackageJson(t *testing.T) {
if err != nil || again.(BuildResult).Digest != r.Digest {
t.Errorf("a second build: %v, digest %v, want %s", err, again, r.Digest)
}
launcher, _ := os.ReadFile(filepath.Join(filepath.Dir(r.Archive), "stage-tools", "modules", "module", ".mesh-build", "tools", "tools", "index.serve.mjs"))
launcher, _ := os.ReadFile(filepath.Join(m.Work, "w1", "stage-tools", "modules", "module", ".mesh-build", "tools", "tools", "index.serve.mjs"))
if !strings.Contains(string(launcher), `import { serveRegisteredOverStdio } from "@novox/mesh-sdk/stdio";`) || !strings.Contains(string(launcher), `await import("./index.js");`) {
t.Errorf("launcher = %q", launcher)
}
@@ -181,7 +179,7 @@ func TestThePackagesAreCopiedBesideTheTreeAndIntoTheBundleOnlyForExternals(t *te
t.Errorf("the toolchain's own %s was copied", p)
}
}
for f := range readArchive(t, out.(BuildResult).Archive) {
for f := range readArchive(t, out.(BuildResult)) {
if strings.HasPrefix(f, "node_modules") {
t.Errorf("a bundle without externals carries %s", f)
}
@@ -79,6 +79,7 @@ func newMachine(t *testing.T) (*Module, *fakeMachine, string) {
Work: work, Clock: f.clock, Ctx: context.Background()}
// A run step's commands run in bubblewrap (1309); the fake reads each back and runs its command.
m.Run = throughBubblewrap(t, f.run, &f.shut)
withStore(t, m) // a build pushes its bundle to a store of the test's own (ADR 0498)
// The toolchain directory as npm ci leaves it, with no bin links.
nm := filepath.Join(m.toolchainDir(), "node_modules")
for _, p := range []string{"typescript/bin/tsc", "esbuild/bin/esbuild", "@esbuild/linux-x64/bin/esbuild",
@@ -52,6 +52,7 @@ func main() {
Ctx: ctx,
Say: logf,
Bubblewrap: ProbeBubblewrap,
RegistryFile: filepath.Join(home, "registry"),
}
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
if m.Node != "" {
@@ -206,3 +206,17 @@ func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) {
t.Errorf("capabilities %v lack user-namespaces", m.Capabilities)
}
}
// 1587-S5 (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)
}
// The validator reads the template (files/registry) into the resource's content.
r := resource(t, m, "registry")
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)
}
}
@@ -48,6 +48,10 @@ type Module struct {
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
Ctx context.Context
Say func(format string, a ...any)
// 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
// Bubblewrap reads the installed bubblewrap's version (ProbeBubblewrap): a step is refused, and the module is
// unhealthy, while it is missing or older than BubblewrapLeast. Nil probes nothing.
Bubblewrap func(ctx context.Context) (string, error)
@@ -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,99 @@
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 is 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
}
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()
if s.fail != nil {
return s.fail
}
if sum := sha256.Sum256(body); "sha256:"+hex.EncodeToString(sum[:]) != digest || registry != "store.test:5000" {
t.Errorf("pushed %s to %s with bytes of another digest", digest, registry)
}
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, r BuildResult) []byte {
t.Helper()
raw, ok := pushed.Load(r.Digest)
if !ok {
t.Fatalf("nothing was pushed under %s", r.Digest)
}
return raw.([]byte)
}
// 1587-S6 (ADR 0498 §1): a done bundle answers the reference of the archive it pushed, under the manifest's module.
func TestADoneBundleAnswersTheReferenceOfTheArchiveItPushed(t *testing.T) {
m, _, _ := builtMachine(t, ``)
store := withStore(t, m)
out, err := m.build(context.Background(), buildArgs())
if err != nil {
t.Fatal(err)
}
r := out.(BuildResult)
if want := "artifact-store://m/tools/blobs/" + r.Digest; r.Reference != want {
t.Errorf("reference %q, want %q", r.Reference, want)
}
if store.blobs["m/tools@"+r.Digest] == nil {
t.Errorf("the store holds %d blobs, not m/tools@%s", len(store.blobs), r.Digest)
}
}
// 1587-S7 and S8 (ADR 0498 §2, the lead's decision L9): a push the store refuses fails the step `not stored:
// <artifact>: <the store's words>`, and whatever the push answered, no packed file is left on the node.
func TestARefusedPushFailsNotStoredAndNoPackedFileStays(t *testing.T) {
for _, fail := range []error{nil, errors.New("store.test:5000 refused the blob: 507 Insufficient Storage")} {
m, _, work := builtMachine(t, ``)
withStore(t, m).fail = fail
_, err := m.build(context.Background(), buildArgs())
if fail != nil && (err == nil || !strings.HasPrefix(err.Error(), delivery.NotStored+"tools: ") || !strings.Contains(err.Error(), "507")) {
t.Errorf("a refused push answered %v", err)
}
var archives []string
filepath.WalkDir(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: packed files left on the node: %v", fail, archives)
}
}
}
+1
View File
@@ -0,0 +1 @@
${seat:mesh-artifact-store:reach}
+10 -1
View File
@@ -33,7 +33,8 @@
"requires": [
"forge-account",
"npm-package-registry",
"node-toolchain"
"node-toolchain",
"artifact-store"
],
"contributes": {
"npm-package-registry": {
@@ -122,6 +123,14 @@
"mode": "0644",
"template": "files/gitconfig"
},
{
"id": "registry",
"type": "file",
"path": "${dir:state}/registry",
"owner": "mesh-build-node",
"mode": "0644",
"template": "files/registry"
},
{
"id": "toolchain-dir",
"type": "archive",