//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//manifests/ 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 }