diff --git a/ociclient/writer.go b/ociclient/writer.go index 7214c4f..2837680 100644 --- a/ociclient/writer.go +++ b/ociclient/writer.go @@ -46,7 +46,6 @@ func (c *Client) PushManifest(ctx context.Context, repo string, contents []byte, Digest: dig, Size: int64(len(contents)), MediaType: mediaType, - Data: contents, } var tags []string @@ -57,14 +56,14 @@ func (c *Client) PushManifest(ctx context.Context, repo string, contents []byte, // If there are no tags, push once by digest. // If there are tags, push once per tag (all referencing the same contents). if len(tags) == 0 { - _, err := c.putManifest(ctx, repo, desc.Digest.String(), nil, desc) + _, err := c.putManifest(ctx, repo, desc.Digest.String(), nil, desc, contents) return desc, err } else { - createdTags, err := c.putManifest(ctx, repo, desc.Digest.String(), tags, desc) + createdTags, err := c.putManifest(ctx, repo, desc.Digest.String(), tags, desc, contents) if err != nil || len(createdTags) != len(tags) { // bulk send failed, fallback to sending one at a time for _, tag := range tags { - _, err = c.putManifest(ctx, repo, tag, nil, desc) + _, err = c.putManifest(ctx, repo, tag, nil, desc, contents) if err != nil { return oci.Descriptor{}, fmt.Errorf("creating tag %s failed: %w", tag, err) } @@ -74,16 +73,9 @@ func (c *Client) PushManifest(ctx context.Context, repo string, contents []byte, return desc, nil } -func (c *Client) putManifest(ctx context.Context, repo string, tagOrDigest string, tags []string, desc oci.Descriptor) ([]string, error) { - u := manifestURL(repo, tagOrDigest) - if len(tags) > 0 { - q := make(url.Values) - for _, tag := range tags { - q.Add("tag", tag) - } - u += "?" + q.Encode() - } - req, err := newRequest(ctx, http.MethodPut, u, bytes.NewReader(desc.Data), pushScope(repo)) +func (c *Client) putManifest(ctx context.Context, repo string, tagOrDigest string, tags []string, desc oci.Descriptor, contents []byte) ([]string, error) { + u := manifestURLWithTags(repo, tagOrDigest, tags) + req, err := newRequest(ctx, http.MethodPut, u, bytes.NewReader(contents), pushScope(repo)) if err != nil { return nil, err } @@ -104,6 +96,43 @@ func (c *Client) putManifest(ctx context.Context, repo string, tagOrDigest strin return createdTags, nil } +func manifestURLWithTags(repo string, tagOrDigest string, tags []string) string { + u := manifestURL(repo, tagOrDigest) + if len(tags) == 0 { + return u + } + q := make(url.Values) + for _, tag := range tags { + q.Add("tag", tag) + } + return u + "?" + q.Encode() +} + +// CopyImage copies an image from fromRepo into toRepo using reference as the +// source tag or digest. +// +// Experimental: CopyImage uses a registry extension and is not part of +// [oci.Interface] or the OCI distribution specification. +func (c *Client) CopyImage(ctx context.Context, fromRepo, toRepo, reference string) (oci.Descriptor, error) { + q := url.Values{} + q.Set("from", fromRepo) + q.Set("reference", reference) + req, err := newRequest(ctx, http.MethodPost, copyImageURL(toRepo, q), nil, mountScope(fromRepo, toRepo)) + if err != nil { + return oci.Descriptor{}, err + } + resp, err := c.do(req, http.StatusCreated) + if err != nil { + return oci.Descriptor{}, err + } + resp.Body.Close() + return descriptorFromResponse(resp, "", requireDigest) +} + +func copyImageURL(repo string, q url.Values) string { + return "/v2/" + repo + "/_docker/copy/image?" + q.Encode() +} + // MountBlob makes a blob from fromRepo available in toRepo without uploading it again. func (c *Client) MountBlob(ctx context.Context, fromRepo, toRepo string, dig oci.Digest) (oci.Descriptor, error) { q := url.Values{} diff --git a/ociclient/writer_test.go b/ociclient/writer_test.go new file mode 100644 index 0000000..375146a --- /dev/null +++ b/ociclient/writer_test.go @@ -0,0 +1,47 @@ +package ociclient + +import ( + "context" + "net/http" + "testing" + + "github.com/docker/oci" + "github.com/docker/oci/ociauth" + "github.com/stretchr/testify/require" +) + +func TestCopyImage(t *testing.T) { + c, err := New("registry.example", &Options{ + Transport: transportFunc(func(req *http.Request) (*http.Response, error) { + require.Equal(t, http.MethodPost, req.Method) + require.Equal(t, "https", req.URL.Scheme) + require.Equal(t, "registry.example", req.URL.Host) + require.Equal(t, "/v2/dst/repo/_docker/copy/image", req.URL.Path) + require.Equal(t, "src/repo", req.URL.Query().Get("from")) + require.Equal(t, "stable", req.URL.Query().Get("reference")) + require.Nil(t, req.Body) + require.Empty(t, req.Header.Get("Content-Type")) + + scope := ociauth.RequestInfoFromContext(req.Context()).RequiredScope + require.Equal(t, "repository:dst/repo:pull,push repository:src/repo:pull", scope.Canonical().String()) + + return &http.Response{ + StatusCode: http.StatusCreated, + Status: "201 Created", + Header: http.Header{ + "Content-Type": {oci.MediaTypeImageManifest}, + "Docker-Content-Digest": {testDigest.String()}, + }, + Body: http.NoBody, + Request: req, + }, nil + }), + }) + require.NoError(t, err) + + desc, err := c.CopyImage(context.Background(), "src/repo", "dst/repo", "stable") + require.NoError(t, err) + require.Equal(t, testDigest, desc.Digest) + require.Equal(t, oci.MediaTypeImageManifest, desc.MediaType) + require.Zero(t, desc.Size) +}