Repository: google/go-containerregistry
Pull request #644 (2019-12-20 20:55:29)

crane append: multiple layers, empty base
Allow multiple layers to be appended (via multiple -f flags, a la
kubectl apply).

Make "base" optional, which then uses empty.Image as the base.

codecov-io: # [Codecov](https://codecov.io/gh/google/go-containerregistry/pull/644?src=pr&el=h1) Report
> Merging [#644](https://codecov.io/gh/google/go-containerregistry/pull/644?src=pr&el=desc) into [master](https://codecov.io/gh/google/go-containerregistry/commit/34fb8ff33bed0fab41a607d21da5860b924ff694?src=pr&el=desc) will **increase** coverage by `0.01%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/google/go-containerregistry/pull/644/graphs/tree.svg?width=650&token=wG7iiDCdZ4&height=150&src=pr)](https://codecov.io/gh/google/go-containerregistry/pull/644?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #644      +/-   ##
==========================================
+ Coverage   76.53%   76.54%   +0.01%     
==========================================
  Files         101      101              
  Lines        4483     4486       +3     
==========================================
+ Hits         3431     3434       +3     
  Misses        636      636              
  Partials      416      416
```


| [Impacted Files](https://codecov.io/gh/google/go-containerregistry/pull/644?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pkg/crane/append.go](https://codecov.io/gh/google/go-containerregistry/pull/644/diff?src=pr&el=tree#diff-cGtnL2NyYW5lL2FwcGVuZC5nbw==) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/google/go-containerregistry/pull/644?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/google/go-containerregistry/pull/644?src=pr&el=footer). Last update [34fb8ff...80c735e](https://codecov.io/gh/google/go-containerregistry/pull/644?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/cmd/crane/cmd/append.go b/cmd/crane/cmd/append.go
index 29b18785..1c3f9e71 100644
--- a/cmd/crane/cmd/append.go
+++ b/cmd/crane/cmd/append.go
@@ -18,6 +18,9 @@ import (
 	"log"
 
 	"github.com/google/go-containerregistry/pkg/crane"
+	"github.com/google/go-containerregistry/pkg/logs"
+	v1 "github.com/google/go-containerregistry/pkg/v1"
+	"github.com/google/go-containerregistry/pkg/v1/empty"
 	"github.com/spf13/cobra"
 )
 
@@ -25,21 +28,31 @@ func init() { Root.AddCommand(NewCmdAppend()) }
 
 // NewCmdAppend creates a new cobra.Command for the append subcommand.
 func NewCmdAppend() *cobra.Command {
-	var baseRef, newTag, newLayer, outFile string
+	var baseRef, newTag, outFile string
+	var newLayers []string
 
 	appendCmd := &cobra.Command{
 		Use:   "append",
 		Short: "Append contents of a tarball to a remote image",
 		Args:  cobra.NoArgs,
 		Run: func(_ *cobra.Command, args []string) {
-			base, err := crane.Pull(baseRef, options...)
-			if err != nil {
-				log.Fatalf("pulling %s: %v", baseRef, err)
+			var base v1.Image
+			var err error
+
+			if baseRef == "" {
+				logs.Warn.Printf("base unspecified, using empty image")
+				base = empty.Image
+
+			} else {
+				base, err = crane.Pull(baseRef, options...)
+				if err != nil {
+					log.Fatalf("pulling %s: %v", baseRef, err)
+				}
 			}
 
-			img, err := crane.Append(base, newLayer)
+			img, err := crane.Append(base, newLayers...)
 			if err != nil {
-				log.Fatalf("appending %s: %v", newLayer, err)
+				log.Fatalf("appending %v: %v", newLayers, err)
 			}
 
 			if outFile != "" {
@@ -55,10 +68,9 @@ func NewCmdAppend() *cobra.Command {
 	}
 	appendCmd.Flags().StringVarP(&baseRef, "base", "b", "", "Name of base image to append to")
 	appendCmd.Flags().StringVarP(&newTag, "new_tag", "t", "", "Tag to apply to resulting image")
-	appendCmd.Flags().StringVarP(&newLayer, "new_layer", "f", "", "Path to tarball to append to image")
+	appendCmd.Flags().StringSliceVarP(&newLayers, "new_layer", "f", []string{}, "Path to tarball to append to image")
 	appendCmd.Flags().StringVarP(&outFile, "output", "o", "", "Path to new tarball of resulting image")
 
-	appendCmd.MarkFlagRequired("base")
 	appendCmd.MarkFlagRequired("new_tag")
 	appendCmd.MarkFlagRequired("new_layer")
 	return appendCmd
diff --git a/cmd/crane/doc/crane_append.md b/cmd/crane/doc/crane_append.md
index 40f8b951..01013c2c 100644
--- a/cmd/crane/doc/crane_append.md
+++ b/cmd/crane/doc/crane_append.md
@@ -13,11 +13,11 @@ crane append [flags]
 ### Options
 
 ```
-  -b, --base string        Name of base image to append to
-  -h, --help               help for append
-  -f, --new_layer string   Path to tarball to append to image
-  -t, --new_tag string     Tag to apply to resulting image
-  -o, --output string      Path to new tarball of resulting image
+  -b, --base string         Name of base image to append to
+  -h, --help                help for append
+  -f, --new_layer strings   Path to tarball to append to image
+  -t, --new_tag string      Tag to apply to resulting image
+  -o, --output string       Path to new tarball of resulting image
 ```
 
 ### Options inherited from parent commands
diff --git a/pkg/crane/append.go b/pkg/crane/append.go
index 86269123..5e2b5422 100644
--- a/pkg/crane/append.go
+++ b/pkg/crane/append.go
@@ -23,11 +23,15 @@ import (
 )
 
 // Append reads a layer from path and appends it the the v1.Image base.
-func Append(base v1.Image, path string) (v1.Image, error) {
-	layer, err := tarball.LayerFromFile(path)
-	if err != nil {
-		return nil, fmt.Errorf("reading tar %q: %v", path, err)
+func Append(base v1.Image, paths ...string) (v1.Image, error) {
+	layers := make([]v1.Layer, 0, len(paths))
+	for _, path := range paths {
+		layer, err := tarball.LayerFromFile(path)
+		if err != nil {
+			return nil, fmt.Errorf("reading tar %q: %v", path, err)
+		}
+		layers = append(layers, layer)
 	}
 
-	return mutate.AppendLayers(base, layer)
+	return mutate.AppendLayers(base, layers...)
 }
diff --git a/pkg/gcrane/copy.go b/pkg/gcrane/copy.go
index d1b2a0df..ddf9e262 100644
--- a/pkg/gcrane/copy.go
+++ b/pkg/gcrane/copy.go
@@ -126,12 +126,13 @@ func recursiveCopy(ctx context.Context, src, dst string, jobs int) error {
 	g, ctx := errgroup.WithContext(ctx)
 	walkFn := func(repo name.Repository, tags *google.Tags, err error) error {
 		if err != nil {
+			logs.Warn.Printf("failed walkFn for repo %s: %v", repo, err)
 			// If we hit an error when listing the repo, try re-listing with backoff.
 			if err := backoffErrors(GCRBackoff(), func() error {
 				tags, err = google.List(repo, google.WithAuthFromKeychain(google.Keychain))
 				return err
 			}); err != nil {
-				return fmt.Errorf("failed walkFn for repo %s: %v", repo, err)
+				return fmt.Errorf("failed List for repo %s: %v", repo, err)
 			}
 		}
 
diff --git a/pkg/internal/legacy/copy.go b/pkg/internal/legacy/copy.go
index 9b073d22..ba8f96ab 100644
--- a/pkg/internal/legacy/copy.go
+++ b/pkg/internal/legacy/copy.go
@@ -46,7 +46,7 @@ func CopySchema1(desc *remote.Descriptor, srcRef, dstRef name.Reference, srcAuth
 			return err
 		}
 
-		if err := remote.WriteLayer(dst, blob, remote.WithAuth(dstAuth)); err != nil {
+		if err := remote.WriteLayer(dst.Context(), blob, remote.WithAuth(dstAuth)); err != nil {
 			return err
 		}
 	}
diff --git a/pkg/registry/tls.go b/pkg/registry/tls.go
index 70bcc33d..d5e86192 100644
--- a/pkg/registry/tls.go
+++ b/pkg/registry/tls.go
@@ -15,19 +15,9 @@
 package registry
 
 import (
-	"bytes"
-	"context"
-	"crypto/ecdsa"
-	"crypto/elliptic"
-	"crypto/rand"
-	"crypto/tls"
-	"crypto/x509"
-	"encoding/pem"
-	"math/big"
-	"net"
-	"net/http"
 	"net/http/httptest"
-	"time"
+
+	ggcrtest "github.com/google/go-containerregistry/pkg/internal/httptest"
 )
 
 // TLS returns an httptest server, with an http client that has been configured to
@@ -35,70 +25,5 @@ import (
 // which should correspond to the domain the image is stored in.
 // If you need a transport, Client().Transport is correctly configured.
 func TLS(domain string) (*httptest.Server, error) {
-	s := httptest.NewUnstartedServer(New())
-
-	template := x509.Certificate{
-		SerialNumber: big.NewInt(1),
-		NotBefore:    time.Now().Add(-1 * time.Hour),
-		NotAfter:     time.Now().Add(time.Hour),
-		IPAddresses: []net.IP{
-			net.IPv4(127, 0, 0, 1),
-			net.IPv6loopback,
-		},
-		DNSNames: []string{domain},
-
-		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
-		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
-		BasicConstraintsValid: true,
-		IsCA:                  true,
-	}
-
-	priv, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
-	if err != nil {
-		return nil, err
-	}
-
-	b, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
-	if err != nil {
-		return nil, err
-	}
-
-	pc := &bytes.Buffer{}
-	if err := pem.Encode(pc, &pem.Block{Type: "CERTIFICATE", Bytes: b}); err != nil {
-		return nil, err
-	}
-
-	ek, err := x509.MarshalECPrivateKey(priv)
-	if err != nil {
-		return nil, err
-	}
-
-	pk := &bytes.Buffer{}
-	if err := pem.Encode(pk, &pem.Block{Type: "EC PRIVATE KEY", Bytes: ek}); err != nil {
-		return nil, err
-	}
-
-	c, err := tls.X509KeyPair(pc.Bytes(), pk.Bytes())
-	if err != nil {
-		return nil, err
-	}
-	s.TLS = &tls.Config{
-		Certificates: []tls.Certificate{c},
-	}
-	s.StartTLS()
-
-	certpool := x509.NewCertPool()
-	certpool.AddCert(s.Certificate())
-
-	t := &http.Transport{
-		TLSClientConfig: &tls.Config{
-			RootCAs: certpool,
-		},
-		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
-			return net.Dial(s.Listener.Addr().Network(), s.Listener.Addr().String())
-		},
-	}
-	s.Client().Transport = t
-
-	return s, nil
+	return ggcrtest.NewTLSServer(domain, New())
 }
diff --git a/pkg/v1/google/list.go b/pkg/v1/google/list.go
index e5a530bd..3b40b36f 100644
--- a/pkg/v1/google/list.go
+++ b/pkg/v1/google/list.go
@@ -121,6 +121,21 @@ func fromUnixMs(ms int64) time.Time {
 	return time.Unix(sec, ns)
 }
 
+func toUnixMs(t time.Time) string {
+	return strconv.FormatInt(t.UnixNano()/1000000, 10)
+}
+
+// MarshalJSON implements json.Marshaler
+func (m ManifestInfo) MarshalJSON() ([]byte, error) {
+	return json.Marshal(rawManifestInfo{
+		Size:      strconv.FormatUint(m.Size, 10),
+		MediaType: m.MediaType,
+		Created:   toUnixMs(m.Created),
+		Uploaded:  toUnixMs(m.Uploaded),
+		Tags:      m.Tags,
+	})
+}
+
 // UnmarshalJSON implements json.Unmarshaler
 func (m *ManifestInfo) UnmarshalJSON(data []byte) error {
 	raw := rawManifestInfo{}
diff --git a/pkg/v1/remote/check.go b/pkg/v1/remote/check.go
index da0fa24c..65ee4e7f 100644
--- a/pkg/v1/remote/check.go
+++ b/pkg/v1/remote/check.go
@@ -34,7 +34,7 @@ func CheckPushPermission(ref name.Reference, kc authn.Keychain, t http.RoundTrip
 	// authorize a push. Figure out how to return early here when we can,
 	// to avoid a roundtrip for spec-compliant registries.
 	w := writer{
-		ref:    ref,
+		repo:   ref.Context(),
 		client: &http.Client{Transport: tr},
 	}
 	loc, _, err := w.initiateUpload("", "")
diff --git a/pkg/v1/remote/write.go b/pkg/v1/remote/write.go
index 7be851f0..7b431c07 100644
--- a/pkg/v1/remote/write.go
+++ b/pkg/v1/remote/write.go
@@ -50,13 +50,13 @@ func Write(ref name.Reference, img v1.Image, options ...Option) error {
 		return err
 	}
 
-	scopes := scopesForUploadingImage(ref, ls)
+	scopes := scopesForUploadingImage(ref.Context(), ls)
 	tr, err := transport.New(ref.Context().Registry, o.auth, o.transport, scopes)
 	if err != nil {
 		return err
 	}
 	w := writer{
-		ref:    ref,
+		repo:   ref.Context(),
 		client: &http.Client{Transport: tr},
 	}
 
@@ -125,20 +125,20 @@ func Write(ref name.Reference, img v1.Image, options ...Option) error {
 
 	// With all of the constituent elements uploaded, upload the manifest
 	// to commit the image.
-	return w.commitImage(img)
+	return w.commitImage(img, ref)
 }
 
 // writer writes the elements of an image to a remote image reference.
 type writer struct {
-	ref    name.Reference
+	repo   name.Repository
 	client *http.Client
 }
 
 // url returns a url.Url for the specified path in the context of this remote image reference.
 func (w *writer) url(path string) url.URL {
 	return url.URL{
-		Scheme: w.ref.Context().Registry.Scheme(),
-		Host:   w.ref.Context().RegistryStr(),
+		Scheme: w.repo.Registry.Scheme(),
+		Host:   w.repo.RegistryStr(),
 		Path:   path,
 	}
 }
@@ -164,7 +164,7 @@ func (w *writer) nextLocation(resp *http.Response) (string, error) {
 // initiation if "mount" is specified, even if no "from" sources are specified.
 // However, this is not broadly applicable to all registries, e.g. ECR.
 func (w *writer) checkExistingBlob(h v1.Hash) (bool, error) {
-	u := w.url(fmt.Sprintf("/v2/%s/blobs/%s", w.ref.Context().RepositoryStr(), h.String()))
+	u := w.url(fmt.Sprintf("/v2/%s/blobs/%s", w.repo.RepositoryStr(), h.String()))
 
 	resp, err := w.client.Head(u.String())
 	if err != nil {
@@ -182,7 +182,7 @@ func (w *writer) checkExistingBlob(h v1.Hash) (bool, error) {
 // checkExistingManifest checks if a manifest exists already in the repository
 // by making a HEAD request to the manifest API.
 func (w *writer) checkExistingManifest(h v1.Hash, mt types.MediaType) (bool, error) {
-	u := w.url(fmt.Sprintf("/v2/%s/manifests/%s", w.ref.Context().RepositoryStr(), h.String()))
+	u := w.url(fmt.Sprintf("/v2/%s/manifests/%s", w.repo.RepositoryStr(), h.String()))
 
 	req, err := http.NewRequest(http.MethodHead, u.String(), nil)
 	if err != nil {
@@ -210,7 +210,7 @@ func (w *writer) checkExistingManifest(h v1.Hash, mt types.MediaType) (bool, err
 // upload was initiated and the body of that blob should be sent to the returned
 // location.
 func (w *writer) initiateUpload(from, mount string) (location string, mounted bool, err error) {
-	u := w.url(fmt.Sprintf("/v2/%s/blobs/uploads/", w.ref.Context().RepositoryStr()))
+	u := w.url(fmt.Sprintf("/v2/%s/blobs/uploads/", w.repo.RepositoryStr()))
 	uv := url.Values{}
 	if mount != "" && from != "" {
 		// Quay will fail if we specify a "mount" without a "from".
@@ -313,7 +313,7 @@ func (w *writer) uploadOne(l v1.Layer) error {
 		mount = h.String()
 	}
 	if ml, ok := l.(*MountableLayer); ok {
-		if w.ref.Context().RegistryStr() == ml.Reference.Context().RegistryStr() {
+		if w.repo.RegistryStr() == ml.Reference.Context().RegistryStr() {
 			from = ml.Reference.Context().RepositoryStr()
 		}
 	}
@@ -407,7 +407,7 @@ func unpackTaggable(t Taggable) (*v1.Descriptor, error) {
 }
 
 // commitImage does a PUT of the image's manifest.
-func (w *writer) commitImage(t Taggable) error {
+func (w *writer) commitImage(t Taggable, ref name.Reference) error {
 	raw, err := t.RawManifest()
 	if err != nil {
 		return err
@@ -417,7 +417,7 @@ func (w *writer) commitImage(t Taggable) error {
 		return err
 	}
 
-	u := w.url(fmt.Sprintf("/v2/%s/manifests/%s", w.ref.Context().RepositoryStr(), w.ref.Identifier()))
+	u := w.url(fmt.Sprintf("/v2/%s/manifests/%s", w.repo.RepositoryStr(), ref.Identifier()))
 
 	// Make the request to PUT the serialized manifest
 	req, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(raw))
@@ -437,11 +437,11 @@ func (w *writer) commitImage(t Taggable) error {
 	}
 
 	// The image was successfully pushed!
-	logs.Progress.Printf("%v: digest: %v size: %d", w.ref, desc.Digest, len(raw))
+	logs.Progress.Printf("%v: digest: %v size: %d", ref, desc.Digest, len(raw))
 	return nil
 }
 
-func scopesForUploadingImage(ref name.Reference, layers []v1.Layer) []string {
+func scopesForUploadingImage(repo name.Repository, layers []v1.Layer) []string {
 	// use a map as set to remove duplicates scope strings
 	scopeSet := map[string]struct{}{}
 
@@ -449,7 +449,7 @@ func scopesForUploadingImage(ref name.Reference, layers []v1.Layer) []string {
 		if ml, ok := l.(*MountableLayer); ok {
 			// we will add push scope for ref.Context() after the loop.
 			// for now we ask pull scope for references of the same registry
-			if ml.Reference.Context() != ref.Context() && ml.Reference.Context().Registry == ref.Context().Registry {
+			if ml.Reference.Context() != repo && ml.Reference.Context().Registry == repo.Registry {
 				scopeSet[ml.Reference.Scope(transport.PullScope)] = struct{}{}
 			}
 		}
@@ -457,7 +457,7 @@ func scopesForUploadingImage(ref name.Reference, layers []v1.Layer) []string {
 
 	scopes := make([]string, 0)
 	// Push scope should be the first element because a few registries just look at the first scope to determine access.
-	scopes = append(scopes, ref.Scope(transport.PushScope))
+	scopes = append(scopes, repo.Scope(transport.PushScope))
 
 	for scope := range scopeSet {
 		scopes = append(scopes, scope)
@@ -485,7 +485,7 @@ func WriteIndex(ref name.Reference, ii v1.ImageIndex, options ...Option) error {
 		return err
 	}
 	w := writer{
-		ref:    ref,
+		repo:   ref.Context(),
 		client: &http.Client{Transport: tr},
 	}
 
@@ -523,22 +523,22 @@ func WriteIndex(ref name.Reference, ii v1.ImageIndex, options ...Option) error {
 
 	// With all of the constituent elements uploaded, upload the manifest
 	// to commit the image.
-	return w.commitImage(ii)
+	return w.commitImage(ii, ref)
 }
 
-// WriteLayer uploads the provided Layer to the specified name.Digest.
-func WriteLayer(ref name.Digest, layer v1.Layer, options ...Option) error {
-	o, err := makeOptions(ref.Context(), options...)
+// WriteLayer uploads the provided Layer to the specified repo.
+func WriteLayer(repo name.Repository, layer v1.Layer, options ...Option) error {
+	o, err := makeOptions(repo, options...)
 	if err != nil {
 		return err
 	}
-	scopes := scopesForUploadingImage(ref, []v1.Layer{layer})
-	tr, err := transport.New(ref.Context().Registry, o.auth, o.transport, scopes)
+	scopes := scopesForUploadingImage(repo, []v1.Layer{layer})
+	tr, err := transport.New(repo.Registry, o.auth, o.transport, scopes)
 	if err != nil {
 		return err
 	}
 	w := writer{
-		ref:    ref,
+		repo:   repo,
 		client: &http.Client{Transport: tr},
 	}
 
@@ -564,9 +564,9 @@ func Tag(tag name.Tag, t Taggable, options ...Option) error {
 		return err
 	}
 	w := writer{
-		ref:    tag,
+		repo:   tag.Context(),
 		client: &http.Client{Transport: tr},
 	}
 
-	return w.commitImage(t)
+	return w.commitImage(t, tag)
 }
diff --git a/pkg/v1/tarball/image.go b/pkg/v1/tarball/image.go
index 0d36125d..67a5d437 100644
--- a/pkg/v1/tarball/image.go
+++ b/pkg/v1/tarball/image.go
@@ -79,15 +79,17 @@ func Image(opener Opener, tag *name.Tag) (v1.Image, error) {
 	}
 
 	// Peek at the first layer and see if it's compressed.
-	compressed, err := img.areLayersCompressed()
-	if err != nil {
-		return nil, err
-	}
-	if compressed {
-		c := compressedImage{
-			image: img,
+	if len(img.imgDescriptor.Layers) > 0 {
+		compressed, err := img.areLayersCompressed()
+		if err != nil {
+			return nil, err
+		}
+		if compressed {
+			c := compressedImage{
+				image: img,
+			}
+			return partial.CompressedToImage(&c)
 		}
-		return partial.CompressedToImage(&c)
 	}
 
 	uc := uncompressedImage{


Test patch:
diff --git a/pkg/gcrane/copy_test.go b/pkg/gcrane/copy_test.go
index fcaec5bc..553173b3 100644
--- a/pkg/gcrane/copy_test.go
+++ b/pkg/gcrane/copy_test.go
@@ -17,30 +17,193 @@ package gcrane
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"net/url"
+	"os"
+	"path"
+	"strings"
 	"testing"
 	"time"
 
 	"github.com/google/go-cmp/cmp"
+	ggcrtest "github.com/google/go-containerregistry/pkg/internal/httptest"
 	"github.com/google/go-containerregistry/pkg/internal/retry"
 	"github.com/google/go-containerregistry/pkg/logs"
 	"github.com/google/go-containerregistry/pkg/name"
+	"github.com/google/go-containerregistry/pkg/registry"
 	"github.com/google/go-containerregistry/pkg/v1/google"
+	"github.com/google/go-containerregistry/pkg/v1/partial"
+	"github.com/google/go-containerregistry/pkg/v1/random"
+	"github.com/google/go-containerregistry/pkg/v1/remote"
 	"github.com/google/go-containerregistry/pkg/v1/remote/transport"
 	"github.com/google/go-containerregistry/pkg/v1/types"
 )
 
 func mustRepo(s string) name.Repository {
-	repo, err := name.NewRepository(s, name.WeakValidation)
+	repo, err := name.NewRepository(s)
 	if err != nil {
 		panic(err)
 	}
 	return repo
 }
 
+type fakeGCR struct {
+	h     http.Handler
+	repos map[string]google.Tags
+	t     *testing.T
+}
+
+func (gcr *fakeGCR) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	gcr.t.Logf("%s %s", r.Method, r.URL)
+	if strings.HasPrefix(r.URL.Path, "/v2/") && strings.HasSuffix(r.URL.Path, "/tags/list") {
+		repo := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/v2/"), "/tags/list")
+		if tags, ok := gcr.repos[repo]; !ok {
+			w.WriteHeader(http.StatusNotFound)
+		} else {
+			gcr.t.Logf("%+v", tags)
+			json.NewEncoder(w).Encode(tags)
+		}
+	} else {
+		gcr.h.ServeHTTP(w, r)
+	}
+}
+
+func newFakeGCR(stuff map[name.Reference]partial.Describable, t *testing.T) (*fakeGCR, error) {
+	h := registry.New()
+
+	repos := make(map[string]google.Tags)
+
+	for ref, thing := range stuff {
+		repo := ref.Context().RepositoryStr()
+		tags, ok := repos[repo]
+		if !ok {
+			tags = google.Tags{
+				Name:     repo,
+				Children: []string{},
+			}
+		}
+
+		// Populate the "child" field.
+		for parentPath := repo; parentPath != "."; parentPath = path.Dir(parentPath) {
+			child, parent := path.Base(parentPath), path.Dir(parentPath)
+			tags, ok := repos[parent]
+			if !ok {
+				tags = google.Tags{}
+			}
+			for _, c := range repos[parent].Children {
+				if c == child {
+					break
+				}
+			}
+			tags.Children = append(tags.Children, child)
+			repos[parent] = tags
+		}
+
+		// Populate the "manifests" and "tags" field.
+		d, err := thing.Digest()
+		if err != nil {
+			return nil, err
+		}
+		mt, err := thing.MediaType()
+		if err != nil {
+			return nil, err
+		}
+		if tags.Manifests == nil {
+			tags.Manifests = make(map[string]google.ManifestInfo)
+		}
+		mi, ok := tags.Manifests[d.String()]
+		if !ok {
+			mi = google.ManifestInfo{
+				MediaType: string(mt),
+				Tags:      []string{},
+			}
+		}
+		if tag, ok := ref.(name.Tag); ok {
+			tags.Tags = append(tags.Tags, tag.Identifier())
+			mi.Tags = append(mi.Tags, tag.Identifier())
+		}
+		tags.Manifests[d.String()] = mi
+		repos[repo] = tags
+	}
+
+	return &fakeGCR{h: h, t: t, repos: repos}, nil
+}
+
+func TestCopy(t *testing.T) {
+	logs.Warn.SetOutput(os.Stderr)
+	src := "gcr.io/test/gcrane"
+	dst := "gcr.io/test/gcrane/copy"
+
+	oneTag, err := random.Image(1024, 5)
+	if err != nil {
+		t.Fatal(err)
+	}
+	twoTags, err := random.Image(1024, 5)
+	if err != nil {
+		t.Fatal(err)
+	}
+	noTags, err := random.Image(1024, 3)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	latestRef, err := name.ParseReference(src)
+	if err != nil {
+		t.Fatal(err)
+	}
+	oneTagRef := latestRef.Context().Tag("bar")
+
+	d, err := noTags.Digest()
+	if err != nil {
+		t.Fatal(err)
+	}
+	noTagsRef := latestRef.Context().Digest(d.String())
+	fooRef := latestRef.Context().Tag("foo")
+
+	// Set up a fake GCR.
+	h, err := newFakeGCR(map[name.Reference]partial.Describable{
+		oneTagRef: oneTag,
+		latestRef: twoTags,
+		fooRef:    twoTags,
+		noTagsRef: noTags,
+	}, t)
+	if err != nil {
+		t.Fatal(err)
+	}
+	s, err := ggcrtest.NewTLSServer("gcr.io", h)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer s.Close()
+
+	// Make sure we don't actually talk to GCR.
+	http.DefaultTransport = s.Client().Transport
+
+	if err := remote.Write(latestRef, twoTags); err != nil {
+		t.Fatal(err)
+	}
+	if err := remote.Write(fooRef, twoTags); err != nil {
+		t.Fatal(err)
+	}
+	if err := remote.Write(oneTagRef, oneTag); err != nil {
+		t.Fatal(err)
+	}
+	if err := remote.Write(noTagsRef, noTags); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := Copy(src, dst); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := CopyRepository(context.Background(), src, dst); err != nil {
+		t.Fatal(err)
+	}
+}
+
 func TestRename(t *testing.T) {
 	c := copier{
 		srcRepo: mustRepo("gcr.io/foo"),
diff --git a/pkg/internal/httptest/httptest.go b/pkg/internal/httptest/httptest.go
new file mode 100644
index 00000000..52983112
--- /dev/null
+++ b/pkg/internal/httptest/httptest.go
@@ -0,0 +1,103 @@
+// Copyright 2020 Google LLC All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package httptest
+
+import (
+	"bytes"
+	"context"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/pem"
+	"math/big"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"time"
+)
+
+// NewTLSServer returns an httptest server, with an http client that has been configured to
+// send all requests to the returned server. The TLS certs are generated for the given domain.
+// If you need a transport, Client().Transport is correctly configured.
+func NewTLSServer(domain string, handler http.Handler) (*httptest.Server, error) {
+	s := httptest.NewUnstartedServer(handler)
+
+	template := x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		NotBefore:    time.Now().Add(-1 * time.Hour),
+		NotAfter:     time.Now().Add(time.Hour),
+		IPAddresses: []net.IP{
+			net.IPv4(127, 0, 0, 1),
+			net.IPv6loopback,
+		},
+		DNSNames: []string{domain},
+
+		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	priv, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+
+	b, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
+	if err != nil {
+		return nil, err
+	}
+
+	pc := &bytes.Buffer{}
+	if err := pem.Encode(pc, &pem.Block{Type: "CERTIFICATE", Bytes: b}); err != nil {
+		return nil, err
+	}
+
+	ek, err := x509.MarshalECPrivateKey(priv)
+	if err != nil {
+		return nil, err
+	}
+
+	pk := &bytes.Buffer{}
+	if err := pem.Encode(pk, &pem.Block{Type: "EC PRIVATE KEY", Bytes: ek}); err != nil {
+		return nil, err
+	}
+
+	c, err := tls.X509KeyPair(pc.Bytes(), pk.Bytes())
+	if err != nil {
+		return nil, err
+	}
+	s.TLS = &tls.Config{
+		Certificates: []tls.Certificate{c},
+	}
+	s.StartTLS()
+
+	certpool := x509.NewCertPool()
+	certpool.AddCert(s.Certificate())
+
+	t := &http.Transport{
+		TLSClientConfig: &tls.Config{
+			RootCAs: certpool,
+		},
+		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+			return net.Dial(s.Listener.Addr().Network(), s.Listener.Addr().String())
+		},
+	}
+	s.Client().Transport = t
+
+	return s, nil
+}
diff --git a/pkg/internal/legacy/copy_test.go b/pkg/internal/legacy/copy_test.go
index 414e3c4c..49c0c58f 100644
--- a/pkg/internal/legacy/copy_test.go
+++ b/pkg/internal/legacy/copy_test.go
@@ -66,7 +66,7 @@ func TestCopySchema1(t *testing.T) {
 	}
 
 	// Populate the registry with a layer and a schema 1 manifest referencing it.
-	if err := remote.WriteLayer(layerRef, layer); err != nil {
+	if err := remote.WriteLayer(layerRef.Context(), layer); err != nil {
 		t.Fatal(err)
 	}
 	manifest := schema1{
diff --git a/pkg/v1/google/list_test.go b/pkg/v1/google/list_test.go
index e7c71623..709aaf97 100644
--- a/pkg/v1/google/list_test.go
+++ b/pkg/v1/google/list_test.go
@@ -16,6 +16,7 @@ package google
 
 import (
 	"bytes"
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -37,6 +38,35 @@ func mustParseDuration(t *testing.T, d string) time.Duration {
 	return dur
 }
 
+func TestRoundtrip(t *testing.T) {
+	raw := rawManifestInfo{
+		Size:      "100",
+		MediaType: "hi",
+		Created:   "12345678",
+		Uploaded:  "23456789",
+		Tags:      []string{"latest"},
+	}
+
+	og, err := json.Marshal(raw)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	parsed := ManifestInfo{}
+	if err := json.Unmarshal(og, &parsed); err != nil {
+		t.Fatal(err)
+	}
+
+	roundtripped, err := json.Marshal(parsed)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if diff := cmp.Diff(og, roundtripped); diff != "" {
+		t.Errorf("ManifestInfo can't roundtrip: (-want +got) = %s", diff)
+	}
+}
+
 func TestList(t *testing.T) {
 	cases := []struct {
 		name         string
diff --git a/pkg/v1/remote/layer_test.go b/pkg/v1/remote/layer_test.go
index 4708d83d..70c5ef75 100644
--- a/pkg/v1/remote/layer_test.go
+++ b/pkg/v1/remote/layer_test.go
@@ -56,7 +56,7 @@ func TestRemoteLayer(t *testing.T) {
 	}
 
 	t.Log(ref)
-	if err := WriteLayer(ref, layer); err != nil {
+	if err := WriteLayer(ref.Context(), layer); err != nil {
 		t.Fatalf("failed to WriteLayer: %v", err)
 	}
 
diff --git a/pkg/v1/remote/write_test.go b/pkg/v1/remote/write_test.go
index 89161487..40b6cfef 100644
--- a/pkg/v1/remote/write_test.go
+++ b/pkg/v1/remote/write_test.go
@@ -70,7 +70,7 @@ func TestUrl(t *testing.T) {
 
 	for _, test := range tests {
 		w := &writer{
-			ref: mustNewTag(t, test.tag),
+			repo: mustNewTag(t, test.tag).Context(),
 		}
 		if got, want := w.url(test.path), test.url; got.String() != want {
 			t.Errorf("url(%v) = %v, want %v", test.path, got.String(), want)
@@ -92,7 +92,7 @@ func TestNextLocation(t *testing.T) {
 
 	ref := mustNewTag(t, "gcr.io/foo/bar:latest")
 	w := &writer{
-		ref: ref,
+		repo: ref.Context(),
 	}
 
 	for _, test := range tests {
@@ -165,7 +165,7 @@ func setupWriterWithServer(server *httptest.Server, repo string) (*writer, close
 	}
 
 	return &writer{
-		ref:    tag,
+		repo:   tag.Context(),
 		client: http.DefaultClient,
 	}, server, nil
 }
@@ -701,7 +701,7 @@ func TestUploadOne(t *testing.T) {
 	}
 	ml := &MountableLayer{
 		Layer:     l,
-		Reference: w.ref,
+		Reference: w.repo.Digest(h.String()),
 	}
 	if err := w.uploadOne(ml); err != nil {
 		t.Errorf("uploadOne() = %v", err)
@@ -811,8 +811,8 @@ func TestCommitImage(t *testing.T) {
 	}
 	defer closer.Close()
 
-	if err := w.commitImage(img); err != nil {
-		t.Errorf("commitBlob() = %v", err)
+	if err := w.commitImage(img, w.repo.Tag("latest")); err != nil {
+		t.Errorf("commitImage() = %v", err)
 	}
 }
 
@@ -1069,7 +1069,7 @@ func TestScopesForUploadingImage(t *testing.T) {
 	}
 
 	for _, tc := range testCases {
-		actual := scopesForUploadingImage(tc.reference, tc.layers)
+		actual := scopesForUploadingImage(tc.reference.Context(), tc.layers)
 
 		if want, got := tc.expected[0], actual[0]; want != got {
 			t.Errorf("TestScopesForUploadingImage() %s: Wrong first scope; want %v, got %v", tc.name, want, got)