diff --git a/modules/mesh-build-node/README.md b/modules/mesh-build-node/README.md index 515bdb82..0cc734e1 100644 --- a/modules/mesh-build-node/README.md +++ b/modules/mesh-build-node/README.md @@ -70,4 +70,9 @@ toolchain install: a long step answers at once with its id and ends on `.< toolchain's packages (not typescript or esbuild) copied to `stage-/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 `.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 `/` by its digest, a candidate's as a + trunk build's, and answers `reference: artifact-store:////blobs/`; a push that fails fails the + step `not stored: : `. 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). diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build.go b/modules/mesh-build-node/cmd/mesh-build-node/build.go index 65b0ea83..053007f8 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build.go @@ -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:////blobs/. 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) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go index afe9d122..3ce6b010 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_integration_test.go @@ -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) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go index e59aefb2..fb45a6e4 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/build_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/build_test.go @@ -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) } diff --git a/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go index b991462d..abd3a634 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/harness_test.go @@ -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", diff --git a/modules/mesh-build-node/cmd/mesh-build-node/main.go b/modules/mesh-build-node/cmd/mesh-build-node/main.go index fcca5746..f06354a2 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/main.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/main.go @@ -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 != "" { diff --git a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go index d523c627..20894caf 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/manifest_test.go @@ -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) + } +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/module.go b/modules/mesh-build-node/cmd/mesh-build-node/module.go index f277560b..1dc36d76 100644 --- a/modules/mesh-build-node/cmd/mesh-build-node/module.go +++ b/modules/mesh-build-node/cmd/mesh-build-node/module.go @@ -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) diff --git a/modules/mesh-build-node/cmd/mesh-build-node/push.go b/modules/mesh-build-node/cmd/mesh-build-node/push.go new file mode 100644 index 00000000..95291c52 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/push.go @@ -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 (/) 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) +} diff --git a/modules/mesh-build-node/cmd/mesh-build-node/push_test.go b/modules/mesh-build-node/cmd/mesh-build-node/push_test.go new file mode 100644 index 00000000..50cbe7e9 --- /dev/null +++ b/modules/mesh-build-node/cmd/mesh-build-node/push_test.go @@ -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 // "@" + 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: +// : `, 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) + } + } +} diff --git a/modules/mesh-build-node/files/registry b/modules/mesh-build-node/files/registry new file mode 100644 index 00000000..1bcadab2 --- /dev/null +++ b/modules/mesh-build-node/files/registry @@ -0,0 +1 @@ +${seat:mesh-artifact-store:reach} diff --git a/modules/mesh-build-node/module.json b/modules/mesh-build-node/module.json index f0b078d1..4fad0c1b 100644 --- a/modules/mesh-build-node/module.json +++ b/modules/mesh-build-node/module.json @@ -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",