From e363e9311d88b2a329e039771785b04e24dfc40c Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sat, 17 Oct 2026 18:04:31 -0300 Subject: [PATCH] versions: an image's pin is asked of its registry, not of docker The buildx Blacksmith's builder installs printed its whole listing for imagetools inspect --format, and ami's weekly refresh wrote that as go's pin. crane.Digest asks the registry for the tag's manifest digest with the same keychain docker reads; the test runs a registry in-process. Co-Authored-By: Claude Opus 5.5 --- go.mod | 8 +++- go.sum | 16 +++++++- versions/upstream/check_test.go | 68 +++++++++++++++++++++------------ versions/upstream/upstream.go | 13 +++++-- 4 files changed, 73 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index 070090b..0f2b188 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.27.2 require ( github.com/CycloneDX/cyclonedx-go v0.12.0 + github.com/google/go-containerregistry v0.21.7 github.com/in-toto/attestation v1.2.0 github.com/sigstore/sigstore-go v1.3.0 github.com/stretchr/testify v1.12.1 @@ -23,6 +24,8 @@ require ( github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/docker/cli v29.5.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.25.5 // indirect @@ -50,12 +53,13 @@ require ( github.com/go-openapi/validate v0.26.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect - github.com/google/go-containerregistry v0.21.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect @@ -64,6 +68,7 @@ require ( github.com/sigstore/rekor-tiles/v2 v2.3.0 // indirect github.com/sigstore/sigstore v1.10.8 // indirect github.com/sigstore/timestamp-authority/v2 v2.1.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect @@ -83,5 +88,6 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect google.golang.org/grpc v1.82.1 // indirect + gotest.tools/v3 v3.5.2 // indirect k8s.io/klog/v2 v2.140.0 // indirect ) diff --git a/go.sum b/go.sum index 5741484..a9d8ef6 100644 --- a/go.sum +++ b/go.sum @@ -82,8 +82,8 @@ github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNv github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= -github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= -github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= @@ -91,6 +91,10 @@ github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQM github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= +github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -216,6 +220,8 @@ github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7P github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/letsencrypt/boulder v0.20260309.0 h1:kZynrxK3QfqLGx6hhoz+Rfs3hgltJs1p9Mp+4+VwnY0= @@ -230,6 +236,8 @@ github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= @@ -269,6 +277,8 @@ github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8 h1:1DGe4/clcdO github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.8/go.mod h1:6IDFhpgxtzqbnzrFkyegbj7RfWwKeRrb3/+xAD1Wp+Y= github.com/sigstore/timestamp-authority/v2 v2.1.3 h1:Fc+LjCTfik1lh3YLkaosENfkXa3R2Y1nswiUKutBdFA= github.com/sigstore/timestamp-authority/v2 v2.1.3/go.mod h1:myoFOKJB/u5vNTFwvBBJVkG3NnOBeIJevbfjNeasLjo= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= @@ -377,6 +387,8 @@ google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fw google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/versions/upstream/check_test.go b/versions/upstream/check_test.go index f07e03a..9eef18d 100644 --- a/versions/upstream/check_test.go +++ b/versions/upstream/check_test.go @@ -3,14 +3,20 @@ package upstream import ( "crypto/sha256" "encoding/hex" + "io" + "log" "net/http" "net/http/httptest" - "os" "os/exec" "path/filepath" "strings" "testing" + "github.com/google/go-containerregistry/pkg/name" + ggcrregistry "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/spin-stack/go-tools/versions" ) @@ -81,32 +87,46 @@ func TestADownloadsPinIsTheSumAtTheVersionAsked(t *testing.T) { } } -// docker is a stand-in for the docker CLI that prints the digest in digestFile, or fails when -// there is none. -func docker(t *testing.T) (digestFile string) { +// registry is a registry in this process, whose host a localhost reference names over plain HTTP. +func registry(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(ggcrregistry.New(ggcrregistry.Logger(log.New(io.Discard, "", 0)))) + t.Cleanup(srv.Close) + return strings.TrimPrefix(srv.URL, "http://") +} + +// push tags a multi-platform image as ref, and returns its index's digest. +func push(t *testing.T, ref string) string { t.Helper() - dir := t.TempDir() - digestFile = filepath.Join(dir, "digest") - script := "#!/bin/sh\nexec cat " + digestFile + "\n" - if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil { //nolint:gosec // an executable is the point + idx, err := random.Index(64, 1, 2) + if err != nil { + t.Fatal(err) + } + r, err := name.ParseReference(ref) + if err != nil { + t.Fatal(err) + } + if err := remote.WriteIndex(r, idx, remote.WithContext(t.Context())); err != nil { + t.Fatal(err) + } + d, err := idx.Digest() + if err != nil { t.Fatal(err) } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - return digestFile + return d.String() } -// An image's pin is the digest its tag names now, as docker reads it. +// An image's pin is the digest of what its tag names now: the index, which every platform's +// manifest is reached from, and nothing but the digest. func TestAnImagesPinIsItsDigest(t *testing.T) { - digestFile := docker(t) - e := versions.Entry{Name: "go", Kind: versions.Image, Source: "golang"} + e := versions.Entry{Name: "go", Kind: versions.Image, Source: registry(t) + "/golang"} if got, err := resolve(t.Context(), e, "1.27"); err == nil { - t.Errorf("docker failed and the pin is %q", got) - } - if err := os.WriteFile(digestFile, []byte("sha256:abc\n"), 0o600); err != nil { - t.Fatal(err) + t.Errorf("the tag is not there and the pin is %q", got) } - if got, err := resolve(t.Context(), e, "1.27"); err != nil || got != "sha256:abc" { - t.Errorf("pin %q, %v", got, err) + want := push(t, e.Source+":1.27") + push(t, e.Source+":1.28") + if got, err := resolve(t.Context(), e, "1.27"); err != nil || got != want { + t.Errorf("pin %q, %v; want %s", got, err, want) } } @@ -154,17 +174,15 @@ func TestCheckSaysWhereEachEntryStands(t *testing.T) { sum := sha256.Sum256([]byte("v1.2.0's file")) missing := httptest.NewServer(http.NotFoundHandler()) t.Cleanup(missing.Close) - digestFile := docker(t) - if err := os.WriteFile(digestFile, []byte("sha256:new\n"), 0o600); err != nil { - t.Fatal(err) - } + golang := registry(t) + "/golang" + now := push(t, golang+":1.27") git := versions.Entry{Name: "g", Kind: versions.Git, Source: r.dir, Version: "master", Track: "branch"} moved, gone := git, git moved.Name, moved.Pin = "moved", r.second gone.Name, gone.Source = "gone", filepath.Join(r.dir, "nothing") git.Pin = r.head - image := versions.Entry{Name: "i", Kind: versions.Image, Source: "golang", Version: "1.27", Pin: "sha256:old", Track: "digest"} + image := versions.Entry{Name: "i", Kind: versions.Image, Source: golang, Version: "1.27", Pin: "sha256:old", Track: "digest"} // Behind by its version, and its file at that version is the same bytes: still behind, since // only a file tracked by a commit is excused. download := versions.Entry{Name: "d", Kind: versions.Download, Source: srv.URL + "/{version}", Version: "v1.0.0", @@ -191,7 +209,7 @@ func TestCheckSaysWhereEachEntryStands(t *testing.T) { {"master", false, ""}, {"master", true, "master is now " + r.head}, {"master", false, "ls-remote"}, - {"1.27", true, "1.27 is now sha256:new"}, + {"1.27", true, "1.27 is now " + now}, {"v1.2.0", true, ""}, {"v1.2.0", false, ""}, {r.head, false, ""}, diff --git a/versions/upstream/upstream.go b/versions/upstream/upstream.go index 9a54033..76933fb 100644 --- a/versions/upstream/upstream.go +++ b/versions/upstream/upstream.go @@ -1,6 +1,6 @@ // Package upstream is where each entry of a versions.yaml stands against what it pins: the newest // its track finds, the pin of a version, a bump, and a download taken only as the bytes it pins. It is the half of versions that reaches the -// network and runs git and docker, so that a program which embeds its versions.yaml links only +// network and runs git, so that a program which embeds its versions.yaml links only // the half that reads it. package upstream @@ -21,6 +21,9 @@ import ( "strings" "time" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/crane" + "github.com/spin-stack/go-tools/internal/fetch" "github.com/spin-stack/go-tools/versions" ) @@ -88,11 +91,13 @@ func resolve(ctx context.Context, e versions.Entry, version string) (string, err e.Version = version switch e.Kind { case versions.Image: - out, err := run(ctx, "docker", "buildx", "imagetools", "inspect", e.Source+":"+version, "--format", "{{.Manifest.Digest}}") + // Asked of the registry, not of docker: the buildx a CI builder installs printed its whole + // listing for `imagetools inspect --format`, and that was written as the pin. + pin, err := crane.Digest(e.Source+":"+version, crane.WithContext(ctx), crane.WithAuthFromKeychain(authn.DefaultKeychain)) if err != nil { - return "", err + return "", fmt.Errorf("versions: %s:%s: %w", e.Source, version, err) } - return strings.TrimSpace(out), nil + return pin, nil case versions.Git: out, err := run(ctx, "git", "ls-remote", e.Source, "refs/tags/"+version, "refs/tags/"+version+"^{}", "refs/heads/"+version)