mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 18:54:16 +00:00
The lint target never passed the integration or stripe_integration tags, so every file under test/ was compiled out and unchecked. Add a pass for each and move the integration package comment onto the package's one non-test file, which is where staticcheck looks for it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WTdBxLFU5TpwmqVdVsN1wq
526 lines
18 KiB
Go
526 lines
18 KiB
Go
//go:build integration
|
|
|
|
// Package integration runs an in-process smoke test of the full ATCR stack:
|
|
// fake PDS, gofakes3 (S3), hold, appview — all wired together in goroutines
|
|
// with no docker, no compose, no external network. Confirms that the happy
|
|
// path of "push an image and pull it back" works before deploy.
|
|
//
|
|
// Run with: go test -tags=integration,testmode -v ./test/integration/...
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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/manifest"
|
|
"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)
|
|
}
|
|
// Materialize the layer bytes. crane.Pull is lazy and img.Digest() needs
|
|
// only the manifest, which ATCR serves from the user's PDS where it is
|
|
// world-readable by design — so a "pull" that stops here never touches the
|
|
// hold and never exercises blob authorization at all. Reading the layers is
|
|
// what makes a pull case a real test of who may read blobs.
|
|
layers, err := img.Layers()
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
for _, l := range layers {
|
|
rc, err := l.Compressed()
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
_, cerr := io.Copy(io.Discard, rc)
|
|
rc.Close()
|
|
if cerr != nil {
|
|
return v1.Hash{}, normalizeErr(cerr)
|
|
}
|
|
}
|
|
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.
|
|
desc, err := repo.Resolve(ctx, tag.TagStr())
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
// Then read the layer bytes, for the same reason craneClient.Pull does:
|
|
// resolving the manifest alone never asks the registry for a blob, and
|
|
// ATCR serves manifests from the user's PDS where they are world-readable.
|
|
// A manifest-only "pull" cannot fail an authorization check on blobs, so
|
|
// every pull row in the matrix would pass without testing what it claims.
|
|
if err := orasReadLayers(ctx, repo, desc); 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
|
|
}
|
|
|
|
// orasReadLayers fetches every layer blob named by a manifest and discards the
|
|
// bytes. Pulling is the only matrix operation that can exercise blob
|
|
// authorization, and it only does so if something actually reads a blob.
|
|
func orasReadLayers(ctx context.Context, repo *orasremote.Repository, desc ocispec.Descriptor) error {
|
|
rc, err := repo.Manifests().Fetch(ctx, desc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := io.ReadAll(rc)
|
|
if closeErr := rc.Close(); err == nil {
|
|
err = closeErr
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var m ocispec.Manifest
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
// An index rather than an image manifest: PullIndex covers that shape.
|
|
return nil //nolint:nilerr
|
|
}
|
|
for _, layer := range m.Layers {
|
|
lr, err := repo.Blobs().Fetch(ctx, layer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(io.Discard, lr)
|
|
closeErr := lr.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
}
|
|
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)
|
|
|
|
// ManifestGet rather than ManifestHead: the head gives the digest, but the
|
|
// body is what names the layers, and the layers are the only part of a
|
|
// pull that exercises blob authorization. See craneClient.Pull.
|
|
m, err := rc.ManifestGet(ctx, r)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
desc := m.GetDescriptor()
|
|
if imager, ok := m.(manifest.Imager); ok {
|
|
layers, err := imager.GetLayers()
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
for _, l := range layers {
|
|
rdr, err := rc.BlobGet(ctx, r, l)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
_, copyErr := io.Copy(io.Discard, rdr)
|
|
closeErr := rdr.Close()
|
|
if copyErr != nil {
|
|
return v1.Hash{}, normalizeErr(copyErr)
|
|
}
|
|
if closeErr != nil {
|
|
return v1.Hash{}, normalizeErr(closeErr)
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|