Files
at-container-registry/test/integration/clients.go
T

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{},
&regclientClient{},
}
// --- 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
}