mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 05:07:09 +00:00
431 lines
14 KiB
Go
431 lines
14 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"github.com/google/go-containerregistry/pkg/crane"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
|
"github.com/google/go-containerregistry/pkg/v1/layout"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
|
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"github.com/regclient/regclient"
|
|
"github.com/regclient/regclient/config"
|
|
"github.com/regclient/regclient/types/ref"
|
|
"oras.land/oras-go/v2"
|
|
orasoci "oras.land/oras-go/v2/content/oci"
|
|
orasremote "oras.land/oras-go/v2/registry/remote"
|
|
"oras.land/oras-go/v2/registry/remote/auth"
|
|
|
|
"atcr.io/internal/testharness"
|
|
)
|
|
|
|
// Client is the minimum OCI client surface the matrix tests need: push a
|
|
// ggcr-built image and pull back its manifest digest. Pairing crane (ggcr)
|
|
// with a second independent OCI implementation catches dialect divergence
|
|
// — different Accept-header ordering, blob upload chunking, manifest
|
|
// validation, auth-challenge parsing — that single-client coverage misses.
|
|
type Client interface {
|
|
Name() string
|
|
Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error
|
|
Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error)
|
|
PushIndex(ctx context.Context, t *testing.T, ref string, idx v1.ImageIndex, a testharness.Auth) error
|
|
PullIndex(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error)
|
|
}
|
|
|
|
// Clients are the OCI clients exercised by the matrix tests. Tests range over
|
|
// this and wrap each iteration in t.Run(c.Name(), …) so failures attribute
|
|
// cleanly to whichever client tripped.
|
|
//
|
|
// Note on digest comparisons: every assertion happens within one client's own
|
|
// round-trip (push and pull use the same c). Cross-client digest comparison
|
|
// would be unsafe — the two libraries can send different Accept-header
|
|
// orderings, and the registry could return different manifest content-types
|
|
// to each.
|
|
var Clients = []Client{
|
|
&craneClient{},
|
|
&orasClient{},
|
|
®clientClient{},
|
|
}
|
|
|
|
// --- crane client (ggcr) ----------------------------------------------------
|
|
|
|
type craneClient struct{}
|
|
|
|
func (craneClient) Name() string { return "crane" }
|
|
|
|
func (craneClient) Push(_ context.Context, _ *testing.T, ref string, img v1.Image, a testharness.Auth) error {
|
|
return normalizeErr(crane.Push(img, ref,
|
|
crane.WithAuth(toAuthn(a)),
|
|
crane.Insecure,
|
|
))
|
|
}
|
|
|
|
func (craneClient) Pull(_ context.Context, ref string, a testharness.Auth) (v1.Hash, error) {
|
|
img, err := crane.Pull(ref,
|
|
crane.WithAuth(toAuthn(a)),
|
|
crane.Insecure,
|
|
)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
d, err := img.Digest()
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("crane pulled image digest: %w", err)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (craneClient) PushIndex(_ context.Context, _ *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
|
|
// crane.Push only accepts v1.Image, so drop to remote.Push (which takes
|
|
// any Taggable). Parsing with name.Insecure switches the scheme to http;
|
|
// crane.Insecure also sets insecure on the default transport, but for a
|
|
// 127.0.0.1 dev registry the scheme flip is what matters.
|
|
ref, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("crane: parse ref %q: %w", refStr, err)
|
|
}
|
|
return normalizeErr(remote.Push(ref, idx, remote.WithAuth(toAuthn(a))))
|
|
}
|
|
|
|
func (craneClient) PullIndex(_ context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
ref, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("crane: parse ref %q: %w", refStr, err)
|
|
}
|
|
desc, err := remote.Get(ref, remote.WithAuth(toAuthn(a)))
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
return desc.Digest, nil
|
|
}
|
|
|
|
func toAuthn(a testharness.Auth) authn.Authenticator {
|
|
if a.Username == "" && a.Password == "" {
|
|
return authn.Anonymous
|
|
}
|
|
return &authn.Basic{Username: a.Username, Password: a.Password}
|
|
}
|
|
|
|
// --- oras-go client (OCI working group reference implementation) ------------
|
|
|
|
type orasClient struct{}
|
|
|
|
func (orasClient) Name() string { return "oras" }
|
|
|
|
func (orasClient) Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(ref, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: parse tag %q: %w", ref, err)
|
|
}
|
|
|
|
// Bridge ggcr -> oras-go via an OCI image layout dir. The ref-name
|
|
// annotation lets oras resolve the image by tag inside the layout.
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("oras: append image to layout: %w", err)
|
|
}
|
|
|
|
src, err := orasoci.New(layoutDir)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: open layout as oci store: %w", err)
|
|
}
|
|
|
|
dst, err := newOrasRepository(parsed, a)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
|
|
if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (orasClient) Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error) {
|
|
parsed, err := name.ParseReference(ref, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", ref, err)
|
|
}
|
|
tag, ok := parsed.(name.Tag)
|
|
if !ok {
|
|
return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed)
|
|
}
|
|
repo, err := newOrasRepository(tag, a)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
// Resolve performs HEAD /v2/<name>/manifests/<tag> and returns the
|
|
// manifest descriptor — its Digest matches ggcr's img.Digest() for the
|
|
// same content. We don't need to fetch blobs; that mirrors crane.Pull
|
|
// followed by .Digest(), which is also manifest-only.
|
|
desc, err := repo.Resolve(ctx, tag.TagStr())
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func (orasClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: parse tag %q: %w", refStr, err)
|
|
}
|
|
|
|
// Same OCI layout bridge as Push, but AppendIndex walks the index and
|
|
// writes every child manifest + blob into the layout in one shot.
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("oras: append index to layout: %w", err)
|
|
}
|
|
|
|
src, err := orasoci.New(layoutDir)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: open layout as oci store: %w", err)
|
|
}
|
|
|
|
dst, err := newOrasRepository(parsed, a)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
|
|
if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (orasClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
// Same head-only resolve as Pull — manifest digest is the same value for
|
|
// images and indexes (it's just the manifest body's sha256).
|
|
parsed, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", refStr, err)
|
|
}
|
|
tag, ok := parsed.(name.Tag)
|
|
if !ok {
|
|
return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed)
|
|
}
|
|
repo, err := newOrasRepository(tag, a)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
desc, err := repo.Resolve(ctx, tag.TagStr())
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func newOrasRepository(tag name.Tag, a testharness.Auth) (*orasremote.Repository, error) {
|
|
// orasremote.NewRepository wants "host[:port]/repo" form (no tag/digest).
|
|
repoRef := tag.Repository.String()
|
|
repo, err := orasremote.NewRepository(repoRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %q: %w", repoRef, err)
|
|
}
|
|
repo.PlainHTTP = true
|
|
if a.Username != "" || a.Password != "" {
|
|
host := tag.RegistryStr()
|
|
repo.Client = &auth.Client{
|
|
Credential: auth.StaticCredential(host, auth.Credential{
|
|
Username: a.Username,
|
|
Password: a.Password,
|
|
}),
|
|
}
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
// --- regclient client (regctl) ---------------------------------------------
|
|
|
|
type regclientClient struct{}
|
|
|
|
func (regclientClient) Name() string { return "regclient" }
|
|
|
|
func (regclientClient) Push(ctx context.Context, t *testing.T, refStr string, img v1.Image, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse tag %q: %w", refStr, err)
|
|
}
|
|
|
|
// Same OCI layout bridge as the oras client.
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("regclient: append image to layout: %w", err)
|
|
}
|
|
|
|
srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr()))
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse ocidir ref: %w", err)
|
|
}
|
|
dstRef, err := ref.New(refStr)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse dest ref: %w", err)
|
|
}
|
|
|
|
rc := newRegclient(parsed.RegistryStr(), a)
|
|
defer rc.Close(ctx, dstRef)
|
|
|
|
if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (regclientClient) Pull(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
parsed, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err)
|
|
}
|
|
r, err := ref.New(refStr)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err)
|
|
}
|
|
rc := newRegclient(parsed.Context().RegistryStr(), a)
|
|
defer rc.Close(ctx, r)
|
|
|
|
// ManifestHead matches crane's lazy-pull semantics: HEAD /manifests/<tag>
|
|
// returns the descriptor with the registry-computed digest.
|
|
mh, err := rc.ManifestHead(ctx, r)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
desc := mh.GetDescriptor()
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func (regclientClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse tag %q: %w", refStr, err)
|
|
}
|
|
|
|
// Same ocidir bridge as regclientClient.Push. AppendIndex walks the
|
|
// index and writes every child manifest + blob into the layout; ImageCopy
|
|
// then ships the whole tree (it dispatches on the source descriptor's
|
|
// media type, so OCI indexes work without extra plumbing).
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("regclient: append index to layout: %w", err)
|
|
}
|
|
|
|
srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr()))
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse ocidir ref: %w", err)
|
|
}
|
|
dstRef, err := ref.New(refStr)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse dest ref: %w", err)
|
|
}
|
|
|
|
rc := newRegclient(parsed.RegistryStr(), a)
|
|
defer rc.Close(ctx, dstRef)
|
|
|
|
if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (regclientClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
// ManifestHead returns the index manifest descriptor with its sha256.
|
|
parsed, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err)
|
|
}
|
|
r, err := ref.New(refStr)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err)
|
|
}
|
|
rc := newRegclient(parsed.Context().RegistryStr(), a)
|
|
defer rc.Close(ctx, r)
|
|
|
|
mh, err := rc.ManifestHead(ctx, r)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
desc := mh.GetDescriptor()
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func newRegclient(host string, a testharness.Auth) *regclient.RegClient {
|
|
cfg := config.Host{
|
|
Name: host,
|
|
Hostname: host,
|
|
TLS: config.TLSDisabled,
|
|
User: a.Username,
|
|
Pass: a.Password,
|
|
}
|
|
return regclient.New(regclient.WithConfigHost(cfg))
|
|
}
|
|
|
|
// --- error normalization ----------------------------------------------------
|
|
|
|
// normalizeErr surfaces the registry-supplied error message from either
|
|
// client's wrapper, so the substring assertions in auth_matrix_test.go and
|
|
// quota_test.go ("blob:write", "crew membership required",
|
|
// "authentication required", "quota exceeded") match for both clients.
|
|
//
|
|
// Both libraries route distribution-spec error bodies through
|
|
// errcode.Error / errcode.Errors at some layer of the wrapping. We unwrap
|
|
// when we can; otherwise return the original error (its .Error() string
|
|
// usually already contains the registry body).
|
|
func normalizeErr(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var ec errcode.Error
|
|
if errors.As(err, &ec) {
|
|
return fmt.Errorf("%s: %s: %w", ec.Code.String(), ec.Message, err)
|
|
}
|
|
var ecs errcode.Errors
|
|
if errors.As(err, &ecs) && len(ecs) > 0 {
|
|
var first errcode.Error
|
|
if errors.As(ecs[0], &first) {
|
|
return fmt.Errorf("%s: %s: %w", first.Code.String(), first.Message, err)
|
|
}
|
|
}
|
|
// If neither shape matches, the original error's .Error() string from
|
|
// either client typically already includes the registry response body.
|
|
return err
|
|
}
|