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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
${seat:mesh-artifact-store:reach}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user