diff --git a/modules/mesh-build-go/README.md b/modules/mesh-build-go/README.md index ff16837f..b1bfe155 100644 --- a/modules/mesh-build-go/README.md +++ b/modules/mesh-build-go/README.md @@ -135,11 +135,13 @@ that vendors), `go build -ldflags "-s -w -X main.builtFor=" -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 the build runner'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 reference of the archive: once the build ended, this process pushes it to the artifact store (novox/hq ADR 0498 §1 §2, -the build runner's `buildstep.PushBlob`) under `/` by its digest, a candidate's as a trunk build's, and answers -`artifact-store:////blobs/`; a push that fails fails the step `not stored: : `. 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 +the reference of the archive: once the build ended, this process publishes it through the seat `oci-artifact-registry` +(design 51 *Publishing*; `publish.go`): its verbs `publish` and `publish-part`, which the manifest `invokes`, with mesh-sdk's +sender (`publish.Publish`), under `/` by its digest and of kind `bundle` or `archive`, a candidate's as a +trunk build's, in chunks, only what the holder lacks; the holder asks the builder and says `published` or `refused` on the +seat's events. The step answers `artifact-store:////blobs/`; a publish the holder refuses, or a +seat nothing holds, fails the step `not stored: : `. The packed bytes never stay on the node. +The module writes into no store itself and requires no `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. diff --git a/modules/mesh-build-go/cmd/mesh-build-go/archive.go b/modules/mesh-build-go/cmd/mesh-build-go/archive.go index a25d6477..5c138815 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/archive.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/archive.go @@ -77,7 +77,7 @@ func (m *Module) buildArchive(ctx context.Context, workspace, module, name, dir 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(ctx, workspace, module, name, art.Name, root) + return m.packInto(ctx, workspace, module, name, art.Name, "archive", root) } // pinnedArchive fetches the file an artifact is pinned to, checks it against the pin, lays it out as the artifact says @@ -111,7 +111,7 @@ func (m *Module) pinnedArchive(ctx context.Context, workspace, module, name stri } 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(ctx, workspace, module, name, art.Name, laid) + return m.packInto(ctx, workspace, module, name, art.Name, "archive", laid) } // pinnedFile is the path of the pinned file in the workspace: kept from an earlier build when its bytes are still the diff --git a/modules/mesh-build-go/cmd/mesh-build-go/build.go b/modules/mesh-build-go/cmd/mesh-build-go/build.go index acc4e7df..34754466 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/build.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/build.go @@ -149,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(ctx, workspace, module, manifest.Module, art.Name, out) + res, err := m.packInto(ctx, workspace, module, manifest.Module, art.Name, "bundle", out) if err != nil { return nil, err } @@ -157,17 +157,17 @@ 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 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 / 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) { +// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and publishes the archive +// on the seat oci-artifact-registry from this process, after the build step ended (design 51 *Publishing*; publish.go): +// a candidate's as a trunk build's, under / by its digest, of kind (bundle or archive). The step +// answers the reference; a publish the seat refuses fails the step `not stored:`. The packed bytes never stay on the +// node: they are held in this process until the publish answered (the lead's decision L9). +func (m *Module) packInto(ctx context.Context, workspace, module, name, artifact, kind, root string) (BuildResult, error) { archive, digest, err := buildstep.Pack(root) if err != nil { return BuildResult{}, fmt.Errorf("packing %s: %w", artifact, err) } - if err := m.push(ctx, name+"/"+artifact, digest, archive); err != nil { + if err := m.publishArchive(ctx, name, artifact, kind, digest, archive); err != nil { return BuildResult{}, fmt.Errorf("%s%s: %v", delivery.NotStored, artifact, err) } return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Reference: buildstep.BlobReference(name, artifact, digest)}, nil diff --git a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go index 2ca1ba57..f98fce5a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/forge_test.go @@ -144,10 +144,9 @@ func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) { // template in the files/ archive (novox/hq ADR 0349 §2). func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) { m := readForgeManifest(t) - // artifact-store: the module pushes a done build's archive itself (ADR 0498 §2). // node-tools: the module pulls its asks through the tool runner's seat pull (1911). // go-package-registry: the Go seat's read side, the fetch's first source (design 51, CAT-BG3, E2). - if want := []any{"forge-account", "go-toolchain", "go-package-registry", "artifact-store", "node-tools"}; !slices.Equal(m.Requires, want) { + if want := []any{"forge-account", "go-toolchain", "go-package-registry", "node-tools"}; !slices.Equal(m.Requires, want) { t.Errorf("requires = %v, want %v", m.Requires, want) } keeps := false diff --git a/modules/mesh-build-go/cmd/mesh-build-go/main.go b/modules/mesh-build-go/cmd/mesh-build-go/main.go index dabb3df5..92fe5780 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/main.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/main.go @@ -54,7 +54,6 @@ func main() { m.Home = u.HomeDir } m.Run = StepRunner(m.Home) - m.RegistryFile = m.Home + "/registry" m.GoSeatFile = m.Home + "/go-package-registry.json" m.Settings = m.Home + "/settings.json" m.ReadKept = readOf(stdio.State(KeptState)).Get diff --git a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go index 8e3790b9..333e3d6a 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/manifest_test.go @@ -159,21 +159,26 @@ func TestTheModuleDeclaresBubblewrapAndTheCapabilityItNeeds(t *testing.T) { } } -// 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) { +// 1587-S1, CAT-BG1 (design 51 *Publishing*, ADR 0614 §2): the module publishes a done archive through the seat +// oci-artifact-registry's verbs, which it invokes (the served-verb edge orders it after the holder), and no longer +// requires the artifact store or is told its address: nothing writes into the store but its holder. +func TestTheModuleInvokesTheArtifactSeatsVerbsAndWritesNoStore(t *testing.T) { m := readManifest(t) - if !slices.Contains(m.Requires, "artifact-store") { - t.Errorf("requires %v lack artifact-store", m.Requires) + if slices.Contains(m.Requires, "artifact-store") { + t.Errorf("requires %v still name artifact-store", m.Requires) } - r := resource(t, m, "registry") - // The template (files/registry) is read below; decoding leaves it unread in the resource. - if r["type"] != "file" || r["path"] != "${dir:state}/registry" || r["template"] != "files/registry" { - t.Errorf("the registry resource is %v", r) + for _, verb := range []string{"publish", "publish-part"} { + if !slices.Contains(m.Invokes, "seat:"+ArtifactSeat+"."+verb) { + t.Errorf("invokes %v lack the seat's %s", m.Invokes, verb) + } } - 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) + for _, r := range m.Resources { + if r["id"] == "registry" { + t.Errorf("the store's address is still placed: %v", r) + } + } + if _, err := os.Stat(filepath.Join(moduleDir, "files", "registry")); err == nil { + t.Error("files/registry is still shipped") } } diff --git a/modules/mesh-build-go/cmd/mesh-build-go/module.go b/modules/mesh-build-go/cmd/mesh-build-go/module.go index e4aedaf7..b17384e6 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/module.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/module.go @@ -67,14 +67,13 @@ 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 // GoSeatFile is the binding of the provision go-package-registry, the Go seat's read side (design 51 *Go // packages*): bound, the mesh's Go modules are fetched through it first, Go's public proxy second, and the forge's // git over the account's ssh last (Go's direct, kept for this milestone only); unbound, as before, directly. GoSeatFile string - Push func(ctx context.Context, registry, repository, digest string, body []byte) error + // Call asks a seat's verb over the bus: the publish of a done archive (publish.go). stdio.Ask when nil; a test's + // fake holder. + Call callFunc // Executable is this program's path, which the integration level starts again shut off; os.Executable when empty. Executable string // NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the diff --git a/modules/mesh-build-go/cmd/mesh-build-go/publish.go b/modules/mesh-build-go/cmd/mesh-build-go/publish.go new file mode 100644 index 00000000..f8c2e59c --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/publish.go @@ -0,0 +1,47 @@ +package main + +// The publish of a done build's archive (novox/hq design 51 *Publishing*, ADR 0611; CAT-BG1): from this module's own +// process, after the build step ended, through the seat oci-artifact-registry's verbs publish and publish-part, which +// the manifest `invokes`, with mesh-sdk's one sender (publish.Publish): the archive in chunks, only what the holder +// lacks. No build step reaches the store, and this module no longer writes into it directly (push.go is gone): the +// holder checks the build with the builder and says published or refused on the seat's events. + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + + registry "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/publish" +) + +// ArtifactSeat is the registry seat a bundle's or an archive's archive is published on. +const ArtifactSeat = "oci-artifact-registry" + +// transferName is the transfer's name, this module's own: the artifact and its digest's head, safe as a transfer id. +func transferName(module, artifact, digest string) string { + head := strings.TrimPrefix(digest, "sha256:") + if len(head) > 16 { + head = head[:16] + } + return strings.NewReplacer("/", "-", ".", "-", " ", "-", "*", "-", ">", "-").Replace(module + "-" + artifact + "-" + head) +} + +// publishArchive publishes body, of digest, as / of kind (bundle or archive) on the seat: through +// Call when a test set it, else the runtime's request over the bus (stdio.Ask). +func (m *Module) publishArchive(ctx context.Context, module, artifact, kind, digest string, body []byte) error { + call := m.Call + if call == nil { + call = stdio.Ask + } + in := registry.PublishInput{Name: module + "/" + artifact, Kind: kind, Digest: digest, Transfer: transferName(module, artifact, digest)} + _, err := publish.Publish(ctx, publish.Call(call), ArtifactSeat, in, []publish.Part{{Digest: digest, Size: int64(len(body)), + Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }}}) + return err +} + +// callFunc is the runtime's request and reply over the bus, as stdio.Ask makes it. +type callFunc = func(key string, body any) (json.RawMessage, error) diff --git a/modules/mesh-build-go/cmd/mesh-build-go/publish_test.go b/modules/mesh-build-go/cmd/mesh-build-go/publish_test.go new file mode 100644 index 00000000..980681b7 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/publish_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + registry "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" + "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 the seat oci-artifact-registry's holder as m's publish meets it (publish.go): publish opens a transfer +// and answers the part it lacks, publish-part takes the chunks in sequence, and the last one stores the whole part +// when it hashes to its digest. Each blob is kept by name and digest; fail, when set, is the holder's refusal at the +// last chunk; asked are the addresses m called. +type fakeStore struct { + mu sync.Mutex + blobs map[string][]byte // "@" + fail error + open map[string]registry.PublishInput + chunks map[string][]byte + asked []string +} + +func withStore(t *testing.T, m *Module) *fakeStore { + t.Helper() + s := &fakeStore{blobs: map[string][]byte{}, open: map[string]registry.PublishInput{}, chunks: map[string][]byte{}} + m.Call = func(key string, body any) (json.RawMessage, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.asked = append(s.asked, key) + raw, _ := json.Marshal(body) + switch key { + case "seat:" + ArtifactSeat + ".publish": + var in registry.PublishInput + _ = json.Unmarshal(raw, &in) + if in.Kind != "bundle" && in.Kind != "archive" || in.Transfer == "" || strings.ContainsAny(in.Transfer, "/. *>") { + t.Errorf("publish of kind %q, transfer %q", in.Kind, in.Transfer) + } + s.open[in.Transfer] = in + return json.Marshal(registry.PublishAnswer{Transfer: in.Transfer, Lacks: []string{in.Digest}}) + case "seat:" + ArtifactSeat + ".publish-part": + var c registry.PublishPartInput + _ = json.Unmarshal(raw, &c) + data, _ := base64.StdEncoding.DecodeString(c.Data) + s.chunks[c.Transfer] = append(s.chunks[c.Transfer], data...) + if !c.Last { + return json.Marshal(registry.PublishPartAnswer{Transfer: c.Transfer, Part: c.Part}) + } + in, whole := s.open[c.Transfer], s.chunks[c.Transfer] + delete(s.chunks, c.Transfer) + if s.fail != nil { + return nil, s.fail + } + sum := sha256.Sum256(whole) + if "sha256:"+hex.EncodeToString(sum[:]) != in.Digest { + t.Errorf("published %s with bytes of another digest", in.Digest) + } + s.blobs[in.Name+"@"+in.Digest] = whole + pushed.Store(in.Digest, whole) + return json.Marshal(registry.PublishPartAnswer{Transfer: c.Transfer, Part: c.Part, Complete: true}) + } + return nil, errors.New("nats: no responders available for request") + } + 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.blobs["demo/tools@"+res.Digest] == nil || store.asked[0] != "seat:oci-artifact-registry.publish" { + t.Errorf("the seat was asked %v and holds %d blobs, not demo/tools@%s", store.asked, 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), CAT-BG1: a publish the seat's holder refuses fails the step `not stored: : `, and so does a seat nothing holds. +func TestARefusedPushFailsTheStepNotStored(t *testing.T) { + m, states, _ := buildModule(t, manifestWith(goBundle), "the program") + store := withStore(t, m) + store.fail = errors.New("refused: the forge did not take it: 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.Call = func(string, any) (json.RawMessage, error) { + return nil, errors.New("nats: no responders available for request") + } + if _, st := runBuild(t, m, states, "tools"); st.State != delivery.StepFailed || !strings.HasPrefix(st.Reason, delivery.NotStored+"tools: ") || + !strings.Contains(st.Reason, "oci-artifact-registry") { + t.Errorf("no holder: 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) + } + } +} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/push.go b/modules/mesh-build-go/cmd/mesh-build-go/push.go deleted file mode 100644 index fb468a03..00000000 --- a/modules/mesh-build-go/cmd/mesh-build-go/push.go +++ /dev/null @@ -1,42 +0,0 @@ -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-catalog/modules/mesh-build-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-go/cmd/mesh-build-go/push_test.go b/modules/mesh-build-go/cmd/mesh-build-go/push_test.go deleted file mode 100644 index b830db1b..00000000 --- a/modules/mesh-build-go/cmd/mesh-build-go/push_test.go +++ /dev/null @@ -1,120 +0,0 @@ -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 // "@" - 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: : `, 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) - } - } -} diff --git a/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go b/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go index 1a2fa7ae..8b53340b 100644 --- a/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go +++ b/modules/mesh-build-go/cmd/mesh-build-go/store_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -19,7 +20,10 @@ import ( "github.com/distribution/distribution/v3/registry/handlers" _ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory" + registry "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" "git.novox.be/novox/mesh-sdk/go/delivery" + + "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep" ) // realStore starts the artifact store's own program (the distribution registry, modules/distribution) inside this @@ -34,11 +38,27 @@ func realStore(t *testing.T, m *Module) string { srv := httptest.NewServer(handlers.NewApp(context.Background(), config)) t.Cleanup(srv.Close) addr := strings.TrimPrefix(srv.URL, "http://") - m.RegistryFile = filepath.Join(t.TempDir(), "registry") - if err := os.WriteFile(m.RegistryFile, []byte(addr+"\n"), 0o644); err != nil { - t.Fatal(err) + // The seat's holder as distribution holds it: the publish through the seat's verbs (publish.go, played by withStore), + // each whole part then stored in the store under / by its digest. + s := withStore(t, m) + inner := m.Call + m.Call = func(key string, body any) (json.RawMessage, error) { + raw, err := inner(key, body) + var a registry.PublishPartAnswer + if err != nil || !strings.HasSuffix(key, ".publish-part") || json.Unmarshal(raw, &a) != nil || !a.Complete { + return raw, err + } + s.mu.Lock() + defer s.mu.Unlock() + for k, b := range s.blobs { + name, digest, _ := strings.Cut(k, "@") + if err := buildstep.PushBlob(context.Background(), http.DefaultClient, addr, name, digest, b); err != nil { + return nil, err + } + delete(s.blobs, k) + } + return raw, nil } - m.Push = nil return addr } diff --git a/modules/mesh-build-go/files/registry b/modules/mesh-build-go/files/registry deleted file mode 100644 index 1bcadab2..00000000 --- a/modules/mesh-build-go/files/registry +++ /dev/null @@ -1 +0,0 @@ -${seat:mesh-artifact-store:reach} diff --git a/modules/mesh-build-go/go.mod b/modules/mesh-build-go/go.mod index 2b01790e..272415a7 100644 --- a/modules/mesh-build-go/go.mod +++ b/modules/mesh-build-go/go.mod @@ -12,6 +12,7 @@ require ( ) require ( + git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect diff --git a/modules/mesh-build-go/module.json b/modules/mesh-build-go/module.json index 9c49aa7d..1a0d198b 100644 --- a/modules/mesh-build-go/module.json +++ b/modules/mesh-build-go/module.json @@ -67,12 +67,15 @@ "forge-account", "go-toolchain", "go-package-registry", - "artifact-store", "node-tools" ], "binds": { "go-package-registry": "${dir:state}/go-package-registry.json" }, + "invokes": [ + "seat:oci-artifact-registry.publish", + "seat:oci-artifact-registry.publish-part" + ], "own-secrets": { "broker": "${dir:state}/broker" }, @@ -160,14 +163,6 @@ "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",