Commit the build modules' publish code a bare ignore pattern hid; drop committed go.work.sum
The patterns mesh-build-node and mesh-build-image matched the command directory, so publish.go and its test were never committed and the branch did not compile. Anchored to the binaries now. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// The publish of a trunk build (novox/hq design 51 *Publishing*, ADR 0611; CAT-BI1): after the build has ended, from
|
||||
// the module's own process and not from a Dockerfile step, the image is written as an OCI layout in the step's
|
||||
// workspace and published through the seat container-image-registry's verbs publish and publish-part, which the
|
||||
// manifest `invokes`, with the build modules' one sender (buildstep.PublishFiles): its config, its layers and its
|
||||
// manifest, by their digests, of which only the parts the holder lacks travel. This module holds no registry address and
|
||||
// no credential; the holder checks the build with the builder and says published or refused on the seat's events. The
|
||||
// step answers the seat's reference, container-image-registry/<module>/<artifact>@sha256:<digest>.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
|
||||
)
|
||||
|
||||
// KindImage is the kind an image is published as on its seat.
|
||||
const KindImage = "image"
|
||||
|
||||
// call is the seat's request: Call when a test set it, else the runtime's request over the bus (stdio.Ask).
|
||||
func (m *Module) call() buildstep.SeatCall {
|
||||
if m.Call != nil {
|
||||
return m.Call
|
||||
}
|
||||
return stdio.Ask
|
||||
}
|
||||
|
||||
// layout is an OCI image layout one build wrote: its one manifest's digest and the files of its parts.
|
||||
type layout struct {
|
||||
Digest string
|
||||
Files []buildstep.File
|
||||
}
|
||||
|
||||
type descriptor struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// readLayout reads the layout in dir: index.json names exactly one manifest, which names its config and layers; every
|
||||
// part is a blob of the layout, of the size its descriptor says. The manifest comes last, so a holder stores it after
|
||||
// what it names.
|
||||
func readLayout(dir string) (layout, error) {
|
||||
var index struct {
|
||||
Manifests []descriptor `json:"manifests"`
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "index.json"))
|
||||
if err != nil {
|
||||
return layout{}, fmt.Errorf("the image's layout has no index: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &index); err != nil || len(index.Manifests) != 1 {
|
||||
return layout{}, fmt.Errorf("the image's layout names %d manifests, not one (%v)", len(index.Manifests), err)
|
||||
}
|
||||
top := index.Manifests[0]
|
||||
blob := func(d descriptor) (buildstep.File, error) {
|
||||
if !digestOK.MatchString(d.Digest) {
|
||||
return buildstep.File{}, fmt.Errorf("%q is not a digest", d.Digest)
|
||||
}
|
||||
p := filepath.Join(dir, "blobs", "sha256", strings.TrimPrefix(d.Digest, "sha256:"))
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return buildstep.File{}, fmt.Errorf("the layout lacks the blob %s: %w", d.Digest, err)
|
||||
}
|
||||
if info.Size() != d.Size {
|
||||
return buildstep.File{}, fmt.Errorf("the blob %s holds %d bytes, not the %d its descriptor says", d.Digest, info.Size(), d.Size)
|
||||
}
|
||||
return buildstep.File{Digest: d.Digest, Size: d.Size, Path: p}, nil
|
||||
}
|
||||
manifest, err := blob(top)
|
||||
if err != nil {
|
||||
return layout{}, err
|
||||
}
|
||||
var body struct {
|
||||
Config descriptor `json:"config"`
|
||||
Layers []descriptor `json:"layers"`
|
||||
}
|
||||
raw, err = os.ReadFile(manifest.Path)
|
||||
if err != nil || json.Unmarshal(raw, &body) != nil || body.Config.Digest == "" {
|
||||
return layout{}, fmt.Errorf("the manifest %s names no config (%v)", top.Digest, err)
|
||||
}
|
||||
out := layout{Digest: top.Digest}
|
||||
seen := map[string]bool{}
|
||||
for _, d := range append([]descriptor{body.Config}, body.Layers...) {
|
||||
if seen[d.Digest] {
|
||||
continue
|
||||
}
|
||||
seen[d.Digest] = true
|
||||
f, err := blob(d)
|
||||
if err != nil {
|
||||
return layout{}, err
|
||||
}
|
||||
out.Files = append(out.Files, f)
|
||||
}
|
||||
out.Files = append(out.Files, manifest)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// publish writes local as an OCI layout and publishes it on the image seat as <module>/<artifact>, at the version the
|
||||
// builder gave it (and the tag the call names): the step answers the layout's manifest digest, checked against the
|
||||
// digest buildah wrote, and the seat's reference; a publish the holder does not take fails `not stored:`.
|
||||
func (m *Module) publish(ctx context.Context, workspace, module, local string, res BuildResult, version, tag string) (any, error) {
|
||||
dir := filepath.Join(m.Work, workspace, "layout."+res.Artifact)
|
||||
digestFile := filepath.Join(m.Work, workspace, "pushed."+res.Artifact)
|
||||
_ = os.RemoveAll(dir)
|
||||
defer os.RemoveAll(dir)
|
||||
argv := append(m.storeFlags(), "push", "--digestfile", digestFile, local, "oci:"+dir)
|
||||
if r := m.Run(ctx, m.env(), "buildah", argv...); r.Status != 0 || r.Err != "" {
|
||||
m.sayFailure("buildah push of "+res.Artifact+" to its layout", r)
|
||||
return nil, fmt.Errorf("%s%s: writing its layout: %s", delivery.NotStored, res.Artifact, cause(r))
|
||||
}
|
||||
img, err := readLayout(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s%s: %w", delivery.NotStored, res.Artifact, err)
|
||||
}
|
||||
// What buildah wrote is checked against the layout: a digest never goes out unchecked.
|
||||
raw, err := os.ReadFile(digestFile)
|
||||
if pushed := strings.TrimSpace(string(raw)); err != nil || pushed != img.Digest {
|
||||
return nil, fmt.Errorf("%s%s: buildah wrote the digest %q (%v), but its layout's manifest is %s", delivery.NotStored, res.Artifact, pushed, err, img.Digest)
|
||||
}
|
||||
name := module + "/" + res.Artifact
|
||||
q := KindImage
|
||||
if version != "" {
|
||||
q = "v" + version
|
||||
}
|
||||
in := reg.PublishInput{Name: name, Kind: KindImage, Version: version, Tag: tag, Digest: img.Digest,
|
||||
Transfer: buildstep.TransferName(name, q, img.Digest)}
|
||||
sent, err := buildstep.PublishFiles(ctx, m.call(), buildstep.ImageSeat, in, img.Files)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s%s: %v", delivery.NotStored, res.Artifact, err)
|
||||
}
|
||||
m.say("published %s on %s: %d of its %d parts sent, the rest held", name, buildstep.ImageSeat, len(sent.Parts), len(img.Files))
|
||||
res.Digest, res.Pushed = img.Digest, true
|
||||
res.Reference = catalogue.SeatReference(buildstep.ImageSeat, module, res.Artifact, img.Digest)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m *Module) say(format string, a ...any) {
|
||||
if m.Say != nil {
|
||||
m.Say(format, a...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 1703d/AC (ADR 0487 §5): a candidate's build leaves the image seat unchanged: nothing is written, nothing is asked.
|
||||
func TestACandidatesBuildLeavesTheRegistryUnchanged(t *testing.T) {
|
||||
m, f, states := setup(t, same)
|
||||
res, st := runBuild(t, m, states, nil) // no publish
|
||||
if st.State != delivery.StepPassed || res.Pushed {
|
||||
t.Fatalf("step = %+v result = %+v", st, res)
|
||||
}
|
||||
if len(f.called("push")) != 0 || len(f.published) != 0 {
|
||||
t.Errorf("a candidate's build reached the seat: published %v, calls %v", f.published, f.lines())
|
||||
}
|
||||
}
|
||||
|
||||
// CAT-BI1 (design 51 *Publishing*): a trunk build writes its image as an OCI layout after the build has ended and
|
||||
// publishes it once through container-image-registry, as <module>/<artifact> at the ask's version, its config, layers
|
||||
// and manifest by digest, sending only the parts the holder lacks; the key carries the layout's manifest digest and the
|
||||
// seat's reference. A holder refusing, a digest file that disagrees and a seat other than the image seat each fail.
|
||||
func TestATrunkBuildPublishesThroughTheImageSeatOnlyWhatTheHolderLacks(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
extra map[string]any
|
||||
heldLayer bool
|
||||
refuse error
|
||||
digestFile string
|
||||
reason string
|
||||
}{
|
||||
"every part sent": {extra: map[string]any{"version": "1.4.0"}},
|
||||
"the held layer is not sent": {extra: map[string]any{"version": "1.4.0"}, heldLayer: true},
|
||||
"a tag of the call's own": {extra: map[string]any{"tag": "fp-abc123"}},
|
||||
"the holder refuses": {refuse: errors.New("refused: the builder did not number it"), reason: "not stored: server: "},
|
||||
"buildah wrote another digest": {digestFile: "other", reason: "not stored: server: buildah wrote the digest"},
|
||||
"buildah wrote no digest": {digestFile: "none", reason: "not stored: server: buildah wrote the digest \"\""},
|
||||
"another seat": {extra: map[string]any{"seat": "oci-artifact-registry"}, reason: "is published through container-image-registry"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, f, states := setup(t, same)
|
||||
f.refuse, f.digestFile = c.refuse, c.digestFile
|
||||
args := map[string]any{"publish": true}
|
||||
for k, v := range c.extra {
|
||||
args[k] = v
|
||||
}
|
||||
if c.heldLayer {
|
||||
// The layout's layer is the fake's one fixed layer: held under any name.
|
||||
f.held[sha([]byte("the layer's bytes"))] = true
|
||||
}
|
||||
res, st := runBuild(t, m, states, args)
|
||||
if c.reason != "" {
|
||||
if (st.State != delivery.StepFailed && st.State != delivery.StepRefused) || !strings.Contains(st.Reason, c.reason) {
|
||||
t.Fatalf("step = %+v, want failed with %q", st, c.reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
if st.State != delivery.StepPassed {
|
||||
t.Fatalf("step = %+v", st)
|
||||
}
|
||||
if !res.Pushed || res.Digest != f.pushed || res.Reference != reg.Seat+"/fixture-image/server@"+f.pushed {
|
||||
t.Errorf("result = %+v, want the layout's %s", res, f.pushed)
|
||||
}
|
||||
if len(f.published) != 1 {
|
||||
t.Fatalf("published = %+v", f.published)
|
||||
}
|
||||
p := f.published[0]
|
||||
version, _ := c.extra["version"].(string)
|
||||
tag, _ := c.extra["tag"].(string)
|
||||
if p.Name != "fixture-image/server" || p.Kind != "image" || p.Digest != f.pushed || p.Version != version || p.Tag != tag || p.Transfer == "" {
|
||||
t.Errorf("publish = %+v", p)
|
||||
}
|
||||
var parts []string
|
||||
for _, x := range p.Parts {
|
||||
parts = append(parts, x.Digest)
|
||||
}
|
||||
if !slices.Equal(parts, f.parts) {
|
||||
t.Errorf("parts = %v, want config, layer, manifest %v", parts, f.parts)
|
||||
}
|
||||
want := f.parts
|
||||
if c.heldLayer {
|
||||
want = []string{f.parts[0], f.parts[2]}
|
||||
}
|
||||
if !slices.Equal(f.sent, want) {
|
||||
t.Errorf("sent = %v, want %v", f.sent, want)
|
||||
}
|
||||
push := f.called("push")
|
||||
if len(push) != 1 || !strings.Contains(push[0].line, " oci:"+filepath.Join(m.Work, "ws1", "layout.server")) || strings.Contains(push[0].line, "docker://") {
|
||||
t.Fatalf("push = %v", push)
|
||||
}
|
||||
for _, bad := range []string{"--creds", "--authfile", "--sign", "--tls-verify"} {
|
||||
if strings.Contains(push[0].line, bad) {
|
||||
t.Errorf("the push carries %s", bad)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.Work, "ws1", "layout.server")); !os.IsNotExist(err) {
|
||||
t.Errorf("the layout outlived the step: %v", err)
|
||||
}
|
||||
// The layout is written after the build has ended: bud, then images, then push.
|
||||
order := []string{}
|
||||
for _, l := range f.lines() {
|
||||
for _, v := range []string{" bud ", " images ", " push ", " rmi "} {
|
||||
if strings.Contains(l, "buildah") && strings.Contains(l, v) {
|
||||
order = append(order, strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Join(order, ",") != "bud,images,push,rmi" {
|
||||
t.Errorf("order = %v", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
m2, f2, states2 := setup(t, same)
|
||||
if _, st := runBuild(t, m2, states2, map[string]any{"publish": true, "tag": "a/b:c"}); st.State != delivery.StepFailed || len(f2.called("bud")) != 0 {
|
||||
t.Errorf("a tag that is not one: step = %+v", st)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user