From 8bfa626a8b6db9373e3c4972492b9ffb6af7af8d Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Wed, 21 Oct 2026 09:13:32 +0000 Subject: [PATCH] Test archive artifacts against build-agent's own digests (issue 1589) --- .../cmd/mesh-build-go/archive_test.go | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 modules/mesh-build-go/cmd/mesh-build-go/archive_test.go diff --git a/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go b/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go new file mode 100644 index 00000000..40960f03 --- /dev/null +++ b/modules/mesh-build-go/cmd/mesh-build-go/archive_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Issue 1589: the build step packs an archive artifact (novox/hq ADR 0478 ยง1), as the build agent packs it +// (mesh-controller internal/builder: `pack` for a directory, upstreamArchive for a pinned file). The digests below are +// the build agent's own, captured from its code (testdata/captured/README). +const ( + buildAgentDirectoryDigest = "sha256:9503d3f91a95be6d8faf8ec696054007cfefe4f5b4713dfc8f4284b026aa6443" + buildAgentWithinDigest = "sha256:9bacf4aab06ccfe90100aeb80b1bd94508a4d461b7e5665c599d7c5cb36c790f" + buildAgentFileDigest = "sha256:0d605adbdef9e41d8290720296ab13dc820861ab2ef36fafa9de02558d03716b" + releaseSHA256 = "2c9e5753c06902072422c4e9424154e8177684cc74af0bcc8dfe2bdf223a7e95" +) + +// copyTree copies a directory, keeping modes. +func copyTree(t *testing.T, from, to string) { + t.Helper() + err := filepath.Walk(from, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, p) + // A module.json in the repository would be taken for a module (issue 1527): fixtures keep it as module.json.fixture. + if filepath.Base(rel) == "module.json.fixture" { + rel = strings.TrimSuffix(rel, ".fixture") + } + target := filepath.Join(to, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + body, err := os.ReadFile(p) + if err != nil { + return err + } + return os.WriteFile(target, body, info.Mode().Perm()) + }) + if err != nil { + t.Fatal(err) + } +} + +// archiveModule is a module whose workspace ws1 holds the fixture tree of testdata/repos/archive; manifest, when given, +// replaces modules/demo/module.json. +func archiveModule(t *testing.T, manifest string) (*Module, *keptStates, *[]cmdRecord) { + t.Helper() + m, states, calls := buildModule(t, manifestWith(goBundle), "x") + tree := filepath.Join(m.Work, "ws1", "tree") + if err := os.RemoveAll(tree); err != nil { + t.Fatal(err) + } + copyTree(t, filepath.Join("testdata", "repos", "archive"), tree) + if manifest != "" { + if err := os.WriteFile(filepath.Join(tree, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + } + return m, states, calls +} + +// pinnedModule is an archive module whose artifact is pinned to path of a TLS server serving the release, under the +// name example.com (a manifest must say https and a named host); the module's client dials the server whatever the name. +func pinnedModule(t *testing.T, path, sha, extra string) (*Module, *keptStates) { + t.Helper() + body, err := os.ReadFile(filepath.Join("testdata", "upstream-release.tar.gz")) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/release.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(body) + })) + t.Cleanup(srv.Close) + m, states, _ := archiveModule(t, pinned("https://example.com"+path, sha, extra)) + client := srv.Client() + client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, srv.Listener.Addr().String()) + } + m.HTTP = client + return m, states +} + +// 1589/AC1: an archive of a directory of the module's tree has the digest the build agent's `pack` gives that tree, +// and the module runs no command for it. +func TestAnArchiveOfADirectoryHasTheBuildAgentsDigest(t *testing.T) { + m, states, calls := archiveModule(t, "") + res, st := runBuild(t, m, states, "files") + if st.State != delivery.StepPassed { + t.Fatalf("the step ended %s: %s", st.State, st.Reason) + } + if res.Digest != buildAgentDirectoryDigest { + t.Errorf("digest %s, the build agent's is %s", res.Digest, buildAgentDirectoryDigest) + } + if res.Artifact != "files" || res.Size == 0 || res.Binary != "" { + t.Errorf("result = %+v", res) + } + if info, err := os.Stat(res.Path); err != nil || info.Size() != int64(res.Size) { + t.Errorf("the archive at %q: %v", res.Path, err) + } + if len(*calls) != 0 { + t.Errorf("an archive ran %d commands", len(*calls)) + } +} + +// An archive does not leave a stray directory in the module's tree: packing the same tree twice is one digest. +func TestPackingAnArchiveTwiceIsOneDigest(t *testing.T) { + m, states, _ := archiveModule(t, "") + a, _ := runBuild(t, m, states, "files") + b, _ := runBuild(t, m, states, "files") + if a.Digest == "" || a.Digest != b.Digest { + t.Errorf("%s and %s", a.Digest, b.Digest) + } +} + +func TestAnArchiveFromOutsideTheModuleOrMissingFailsTheStep(t *testing.T) { + for from, want := range map[string]string{ + "../../..": "outside", + "nothing": "nothing", + } { + m, states, _ := archiveModule(t, `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"`+from+`"}]}}`) + if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, want) { + t.Errorf("from %q: %s %q", from, st.State, st.Reason) + } + } +} + +func pinned(url, sha, extra string) string { + return `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","url":"` + url + + `","sha256":"` + sha + `","version":"1.0"` + extra + `}]}}` +} + +// 1589/AC2: a pinned file is fetched, checked against its sha256 and laid out as the artifact says; the digest is the +// build agent's for the same file. +func TestAPinnedArchiveIsUnpackedWithinAndHasTheBuildAgentsDigest(t *testing.T) { + m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"within":"release/app"`) + res, st := runBuild(t, m, states, "files") + if st.State != delivery.StepPassed || res.Digest != buildAgentWithinDigest { + t.Fatalf("%s %q: digest %s, the build agent's is %s", st.State, st.Reason, res.Digest, buildAgentWithinDigest) + } +} + +func TestAPinnedFileKeptWholeHasTheBuildAgentsDigest(t *testing.T) { + m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"file":"tool.bin"`) + res, st := runBuild(t, m, states, "files") + if st.State != delivery.StepPassed || res.Digest != buildAgentFileDigest { + t.Fatalf("%s %q: digest %s, the build agent's is %s", st.State, st.Reason, res.Digest, buildAgentFileDigest) + } +} + +// A file that is not the pin's is refused, naming both digests, and nothing is packed. +func TestAPinnedFileOfAnotherDigestIsRefused(t *testing.T) { + wrong := strings.Repeat("ab", 32) + m, states := pinnedModule(t, "/release.tar.gz", wrong, `,"within":"release/app"`) + _, st := runBuild(t, m, states, "files") + if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "sha256:"+wrong) || !strings.Contains(st.Reason, "sha256:"+releaseSHA256) { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } + if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1", "artifacts")); len(entries) != 0 { + t.Errorf("an archive was left: %v", entries) + } +} + +func TestAPinnedFileTheServerRefusesFailsTheStep(t *testing.T) { + m, states := pinnedModule(t, "/gone", releaseSHA256, `,"file":"x"`) + if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "404") { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } +} + +// Entries outside `within` are refused, as the build agent refuses them. +func TestAPinnedArchiveWithEntriesOutsideWithinIsRefused(t *testing.T) { + m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"within":"release/other"`) + if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "release/other") { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } +} + +// Other kinds are still refused by name. +func TestAnImageArtifactIsStillRefusedByName(t *testing.T) { + m, states, _ := archiveModule(t, `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"image","from":"files"}]}}`) + if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "image") { + t.Errorf("the step ended %s: %q", st.State, st.Reason) + } +}