diff --git a/commands/displayers/registry.go b/commands/displayers/registry.go index 9555d0e74..005232f0d 100644 --- a/commands/displayers/registry.go +++ b/commands/displayers/registry.go @@ -104,6 +104,61 @@ func (r *Repository) KV() []map[string]interface{} { return out } +type RepositoryV2 struct { + Repositories []do.RepositoryV2 +} + +var _ Displayable = &Repository{} + +func (r *RepositoryV2) JSON(out io.Writer) error { + return writeJSON(r.Repositories, out) +} + +func (r *RepositoryV2) Cols() []string { + return []string{ + "Name", + "LatestManifest", + "LatestTag", + "TagCount", + "ManifestCount", + "UpdatedAt", + } +} + +func (r *RepositoryV2) ColMap() map[string]string { + return map[string]string{ + "Name": "Name", + "LatestManifest": "Latest Manifest", + "LatestTag": "Latest Tag", + "TagCount": "Tag Count", + "ManifestCount": "Manifest Count", + "UpdatedAt": "Updated At", + } +} + +func (r *RepositoryV2) KV() []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(r.Repositories)) + + for _, reg := range r.Repositories { + latestTag := "" // default when latest manifest has no tags + if len(reg.LatestManifest.Tags) > 0 { + latestTag = reg.LatestManifest.Tags[0] + } + m := map[string]interface{}{ + "Name": reg.Name, + "LatestManifest": reg.LatestManifest.Digest, + "LatestTag": latestTag, + "TagCount": reg.TagCount, + "ManifestCount": reg.ManifestCount, + "UpdatedAt": reg.LatestManifest.UpdatedAt, + } + + out = append(out, m) + } + + return out +} + type RepositoryTag struct { Tags []do.RepositoryTag } @@ -149,6 +204,54 @@ func (r *RepositoryTag) KV() []map[string]interface{} { return out } +type RepositoryManifest struct { + Manifests []do.RepositoryManifest +} + +var _ Displayable = &RepositoryManifest{} + +func (r *RepositoryManifest) JSON(out io.Writer) error { + return writeJSON(r.Manifests, out) +} + +func (r *RepositoryManifest) Cols() []string { + return []string{ + "Digest", + "CompressedSizeBytes", + "SizeBytes", + "UpdatedAt", + "Tags", + } +} + +func (r *RepositoryManifest) ColMap() map[string]string { + return map[string]string{ + "Digest": "Manifest Digest", + "CompressedSizeBytes": "Compressed Size", + "SizeBytes": "Uncompressed Size", + "UpdatedAt": "Updated At", + "Tags": "Tags", + } +} + +func (r *RepositoryManifest) KV() []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(r.Manifests)) + + for _, manifest := range r.Manifests { + m := map[string]interface{}{ + "Digest": manifest.Digest, + "CompressedSizeBytes": BytesToHumanReadableUnit(manifest.CompressedSizeBytes), + "SizeBytes": BytesToHumanReadableUnit(manifest.SizeBytes), + "UpdatedAt": manifest.UpdatedAt, + "Tags": manifest.Tags, + } + + out = append(out, m) + } + + return out +} + type GarbageCollection struct { GarbageCollections []do.GarbageCollection } diff --git a/commands/registry.go b/commands/registry.go index 82ac88a3e..6f0ff5ab2 100644 --- a/commands/registry.go +++ b/commands/registry.go @@ -142,6 +142,22 @@ func Repository() *Command { RunListRepositories, "list", "List repositories for a container registry", listRepositoriesDesc, Writer, aliasOpt("ls"), displayerType(&displayers.Repository{}), + hiddenCmd(), + ) + + listRepositoriesV2Desc := `This command retrieves information about repositories in a registry, including: + - The repository name + - The latest manifest of the repository + - The latest manifest's latest tag, if any + - The number of tags in the repository + - The number of manifests in the repository +` + + CmdBuilder( + cmd, + RunListRepositoriesV2, "list-v2", + "List repositories for a container registry", listRepositoriesV2Desc, + Writer, aliasOpt("ls2"), displayerType(&displayers.Repository{}), ) listRepositoryTagsDesc := `This command retrieves information about tags in a repository, including: @@ -169,6 +185,21 @@ func Repository() *Command { ) AddBoolFlag(cmdRunRepositoryDeleteTag, doctl.ArgForce, doctl.ArgShortForce, false, "Force tag deletion") + listRepositoryManifests := `This command retrieves information about manifests in a repository, including: + - The manifest digest + - The compressed size + - The uncompressed size + - The last updated timestamp + - The manifest tags + - The manifest blobs (available in detailed output only) +` + CmdBuilder( + cmd, + RunListRepositoryManifests, "list-manifests ", + "List manifests for a repository in a container registry", listRepositoryManifests, + Writer, aliasOpt("lm"), displayerType(&displayers.RepositoryManifest{}), + ) + deleteManifestDesc := "This command permanently deletes one or more repository manifests by digest." cmdRunRepositoryDeleteManifest := CmdBuilder( cmd, @@ -537,6 +568,21 @@ func RunListRepositories(c *CmdConfig) error { return displayRepositories(c, repositories...) } +// RunListRepositoriesV2 lists repositories for the registry +func RunListRepositoriesV2(c *CmdConfig) error { + registry, err := c.Registry().Get() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + + repositories, err := c.Registry().ListRepositoriesV2(registry.Name) + if err != nil { + return err + } + + return displayRepositoriesV2(c, repositories...) +} + // RunListRepositoryTags lists tags for the repository in a registry func RunListRepositoryTags(c *CmdConfig) error { err := ensureOneArg(c) @@ -557,6 +603,26 @@ func RunListRepositoryTags(c *CmdConfig) error { return displayRepositoryTags(c, tags...) } +// RunListRepositoryManifests lists manifests for the repository in a registry +func RunListRepositoryManifests(c *CmdConfig) error { + err := ensureOneArg(c) + if err != nil { + return err + } + + registry, err := c.Registry().Get() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + + manifests, err := c.Registry().ListRepositoryManifests(registry.Name, c.Args[0]) + if err != nil { + return err + } + + return displayRepositoryManifests(c, manifests...) +} + // RunRepositoryDeleteTag deletes one or more repository tags func RunRepositoryDeleteTag(c *CmdConfig) error { force, err := c.Doit.GetBool(c.NS, doctl.ArgForce) @@ -645,6 +711,13 @@ func displayRepositories(c *CmdConfig, repositories ...do.Repository) error { return c.Display(item) } +func displayRepositoriesV2(c *CmdConfig, repositories ...do.RepositoryV2) error { + item := &displayers.RepositoryV2{ + Repositories: repositories, + } + return c.Display(item) +} + func displayRepositoryTags(c *CmdConfig, tags ...do.RepositoryTag) error { item := &displayers.RepositoryTag{ Tags: tags, @@ -652,6 +725,13 @@ func displayRepositoryTags(c *CmdConfig, tags ...do.RepositoryTag) error { return c.Display(item) } +func displayRepositoryManifests(c *CmdConfig, manifests ...do.RepositoryManifest) error { + item := &displayers.RepositoryManifest{ + Manifests: manifests, + } + return c.Display(item) +} + // Garbage Collection run commands // RunStartGarbageCollection starts a garbage collection for the specified diff --git a/commands/registry_test.go b/commands/registry_test.go index 4aa3975a7..ba4f99691 100644 --- a/commands/registry_test.go +++ b/commands/registry_test.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "os" + "strings" "testing" "time" @@ -48,6 +49,48 @@ var ( UpdatedAt: time.Now(), }, } + testRepositoryManifest = do.RepositoryManifest{ + RepositoryManifest: &godo.RepositoryManifest{ + RegistryName: testRegistryName, + Repository: testRepoName, + Digest: "sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b", + CompressedSizeBytes: 50, + SizeBytes: 100, + UpdatedAt: time.Now(), + Tags: []string{"v1", "v2"}, + Blobs: []*godo.Blob{ + { + Digest: "sha256:123", + CompressedSizeBytes: 123, + }, + { + Digest: "sha256:456", + CompressedSizeBytes: 456, + }, + }, + }, + } + testRepositoryManifestNoTags = do.RepositoryManifest{ + RepositoryManifest: &godo.RepositoryManifest{ + RegistryName: testRegistryName, + Repository: testRepoName, + Digest: "sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b", + CompressedSizeBytes: 50, + SizeBytes: 100, + UpdatedAt: time.Now(), + Tags: []string{ /* I don't need any tags! */ }, + Blobs: []*godo.Blob{ + { + Digest: "sha256:123", + CompressedSizeBytes: 123, + }, + { + Digest: "sha256:456", + CompressedSizeBytes: 456, + }, + }, + }, + } testRepository = do.Repository{ Repository: &godo.Repository{ RegistryName: testRegistryName, @@ -56,6 +99,24 @@ var ( LatestTag: testRepositoryTag.RepositoryTag, }, } + testRepositoryV2 = do.RepositoryV2{ + RepositoryV2: &godo.RepositoryV2{ + RegistryName: testRegistryName, + Name: testRegistryName, + TagCount: 2, + ManifestCount: 1, + LatestManifest: testRepositoryManifest.RepositoryManifest, + }, + } + testRepositoryV2NoTags = do.RepositoryV2{ + RepositoryV2: &godo.RepositoryV2{ + RegistryName: testRegistryName, + Name: testRegistryName, + TagCount: 0, + ManifestCount: 1, + LatestManifest: testRepositoryManifestNoTags.RepositoryManifest, + }, + } testDockerCredentials = &godo.DockerCredentials{ // the base64 string is "username:password" DockerConfigJSON: []byte(`{"auths":{"hostname":{"auth":"dXNlcm5hbWU6cGFzc3dvcmQ="}}}`), @@ -88,7 +149,7 @@ func TestRegistryCommand(t *testing.T) { func TestRepositoryCommand(t *testing.T) { cmd := Repository() assert.NotNil(t, cmd) - assertCommandNames(t, cmd, "list", "list-tags", "delete-manifest", "delete-tag") + assertCommandNames(t, cmd, "list", "list-v2", "list-manifests", "list-tags", "delete-manifest", "delete-tag") } func TestGarbageCollectionCommand(t *testing.T) { @@ -216,6 +277,45 @@ func TestRepositoryList(t *testing.T) { }) } +func TestRepositoryListV2(t *testing.T) { + t.Run("with latest tag", func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.registry.EXPECT().Get().Return(&testRegistry, nil) + tm.registry.EXPECT().ListRepositoriesV2(testRepositoryV2.RegistryName).Return([]do.RepositoryV2{testRepositoryV2}, nil) + + var buf bytes.Buffer + config.Out = &buf + err := RunListRepositoriesV2(config) + assert.NoError(t, err) + + output := buf.String() + // instead of trying to match the output, do some basic content checks + assert.True(t, strings.Contains(output, testRepositoryV2.Name)) + assert.True(t, strings.Contains(output, testRepositoryV2.LatestManifest.Digest)) + // basic text format doesn't include blob data + assert.False(t, strings.Contains(output, testRepositoryV2.LatestManifest.Blobs[0].Digest)) + }) + }) + t.Run("with latest tag", func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.registry.EXPECT().Get().Return(&testRegistry, nil) + tm.registry.EXPECT().ListRepositoriesV2(testRepositoryV2NoTags.RegistryName).Return([]do.RepositoryV2{testRepositoryV2NoTags}, nil) + + var buf bytes.Buffer + config.Out = &buf + err := RunListRepositoriesV2(config) + assert.NoError(t, err) + + output := buf.String() + // instead of trying to match the output, do some basic content checks + assert.True(t, strings.Contains(output, testRepositoryV2NoTags.Name)) + assert.True(t, strings.Contains(output, "")) // default value when latest manifest has no tags + // basic text format doesn't include blob data + assert.False(t, strings.Contains(output, testRepositoryV2NoTags.LatestManifest.Blobs[0].Digest)) + }) + }) +} + func TestRepositoryListTags(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { tm.registry.EXPECT().Get().Return(&testRegistry, nil) @@ -327,6 +427,30 @@ func TestRepositoryDeleteTag(t *testing.T) { } } +func TestRepositoryListManifests(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + tm.registry.EXPECT().Get().Return(&testRegistry, nil) + tm.registry.EXPECT().ListRepositoryManifests( + testRepositoryManifest.RegistryName, + testRepositoryManifest.Repository, + ).Return([]do.RepositoryManifest{testRepositoryManifest}, nil) + + var buf bytes.Buffer + config.Out = &buf + config.Args = append(config.Args, testRepositoryManifest.Repository) + + err := RunListRepositoryManifests(config) + assert.NoError(t, err) + + output := buf.String() + // instead of trying to match the output, do some basic content checks + assert.True(t, strings.Contains(output, testRepositoryManifest.Digest)) + assert.True(t, strings.Contains(output, fmt.Sprintf("%s", testRepositoryManifest.Tags))) + // basic text format doesn't include blob data + assert.False(t, strings.Contains(output, testRepositoryManifest.Blobs[0].Digest)) + }) +} + func TestRepositoryDeleteManifest(t *testing.T) { extraDigest := "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270" tests := []struct { diff --git a/do/mocks/RegistryService.go b/do/mocks/RegistryService.go index b78cfcb25..1a503dbac 100644 --- a/do/mocks/RegistryService.go +++ b/do/mocks/RegistryService.go @@ -211,6 +211,36 @@ func (mr *MockRegistryServiceMockRecorder) ListRepositories(arg0 interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRepositories", reflect.TypeOf((*MockRegistryService)(nil).ListRepositories), arg0) } +// ListRepositoriesV2 mocks base method. +func (m *MockRegistryService) ListRepositoriesV2(arg0 string) ([]do.RepositoryV2, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRepositoriesV2", arg0) + ret0, _ := ret[0].([]do.RepositoryV2) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRepositoriesV2 indicates an expected call of ListRepositoriesV2. +func (mr *MockRegistryServiceMockRecorder) ListRepositoriesV2(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRepositoriesV2", reflect.TypeOf((*MockRegistryService)(nil).ListRepositoriesV2), arg0) +} + +// ListRepositoryManifests mocks base method. +func (m *MockRegistryService) ListRepositoryManifests(arg0, arg1 string) ([]do.RepositoryManifest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRepositoryManifests", arg0, arg1) + ret0, _ := ret[0].([]do.RepositoryManifest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRepositoryManifests indicates an expected call of ListRepositoryManifests. +func (mr *MockRegistryServiceMockRecorder) ListRepositoryManifests(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRepositoryManifests", reflect.TypeOf((*MockRegistryService)(nil).ListRepositoryManifests), arg0, arg1) +} + // ListRepositoryTags mocks base method. func (m *MockRegistryService) ListRepositoryTags(arg0, arg1 string) ([]do.RepositoryTag, error) { m.ctrl.T.Helper() diff --git a/do/registry.go b/do/registry.go index 2ded4d89f..ea81806d0 100644 --- a/do/registry.go +++ b/do/registry.go @@ -37,6 +37,16 @@ type Repository struct { *godo.Repository } +// RepositoryV2 wraps a godo RepositoryV2 +type RepositoryV2 struct { + *godo.RepositoryV2 +} + +// RepositoryManifest wraps a godo RepositoryManifest +type RepositoryManifest struct { + *godo.RepositoryManifest +} + // RepositoryTag wraps a godo RepositoryTag type RepositoryTag struct { *godo.RepositoryTag @@ -64,7 +74,9 @@ type RegistryService interface { Delete() error DockerCredentials(*godo.RegistryDockerCredentialsRequest) (*godo.DockerCredentials, error) ListRepositoryTags(string, string) ([]RepositoryTag, error) + ListRepositoryManifests(string, string) ([]RepositoryManifest, error) ListRepositories(string) ([]Repository, error) + ListRepositoriesV2(string) ([]RepositoryV2, error) DeleteTag(string, string, string) error DeleteManifest(string, string, string) error Endpoint() string @@ -152,6 +164,39 @@ func (rs *registryService) ListRepositories(registry string) ([]Repository, erro return list, nil } +func (rs *registryService) ListRepositoriesV2(registry string) ([]RepositoryV2, error) { + f := func(opt *godo.ListOptions) ([]interface{}, *godo.Response, error) { + list, resp, err := rs.client.Registry.ListRepositoriesV2(rs.ctx, registry, &godo.TokenListOptions{ + Page: opt.Page, + PerPage: opt.PerPage, + // there's an opportunity for optimization here by using page token instead of page + }) + if err != nil { + return nil, nil, err + } + + si := make([]interface{}, len(list)) + for i := range list { + si[i] = list[i] + } + + return si, resp, err + } + + si, err := PaginateResp(f) + if err != nil { + return nil, err + } + + list := make([]RepositoryV2, len(si)) + for i := range si { + a := si[i].(*godo.RepositoryV2) + list[i] = RepositoryV2{RepositoryV2: a} + } + + return list, nil +} + func (rs *registryService) ListRepositoryTags(registry, repository string) ([]RepositoryTag, error) { f := func(opt *godo.ListOptions) ([]interface{}, *godo.Response, error) { list, resp, err := rs.client.Registry.ListRepositoryTags(rs.ctx, registry, repository, opt) @@ -181,6 +226,35 @@ func (rs *registryService) ListRepositoryTags(registry, repository string) ([]Re return list, nil } +func (rs *registryService) ListRepositoryManifests(registry, repository string) ([]RepositoryManifest, error) { + f := func(opt *godo.ListOptions) ([]interface{}, *godo.Response, error) { + list, resp, err := rs.client.Registry.ListRepositoryManifests(rs.ctx, registry, repository, opt) + if err != nil { + return nil, nil, err + } + + si := make([]interface{}, len(list)) + for i := range list { + si[i] = list[i] + } + + return si, resp, err + } + + si, err := PaginateResp(f) + if err != nil { + return nil, err + } + + list := make([]RepositoryManifest, len(si)) + for i := range si { + a := si[i].(*godo.RepositoryManifest) + list[i] = RepositoryManifest{RepositoryManifest: a} + } + + return list, nil +} + func (rs *registryService) DeleteTag(registry, repository, tag string) error { _, err := rs.client.Registry.DeleteTag(rs.ctx, registry, repository, tag) return err diff --git a/go.mod b/go.mod index 8f5b42b63..7e5ca5335 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/containerd/continuity v0.0.0-20200413184840-d3ef23f19fbb // indirect github.com/creack/pty v1.1.11 - github.com/digitalocean/godo v1.72.0 + github.com/digitalocean/godo v1.73.0 github.com/docker/cli v0.0.0-20200622130859-87db43814b48 github.com/docker/docker v17.12.0-ce-rc1.0.20200531234253-77e06fda0c94+incompatible // indirect github.com/docker/docker-credential-helpers v0.6.3 // indirect diff --git a/go.sum b/go.sum index bbb6b35c0..ae97f6725 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/digitalocean/godo v1.72.0 h1:Y5+H6zO8UOmiwETd40Uee1Io8kGPCb5fBN76Kf7UyAI= github.com/digitalocean/godo v1.72.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs= +github.com/digitalocean/godo v1.73.0 h1:VEPb2YIgvbG5WP9+2Yin6ty+1s01mTUrSEW4CO6alVc= +github.com/digitalocean/godo v1.73.0/go.mod h1:GBmu8MkjZmNARE7IXRPmkbbnocNN8+uBm0xbEVw2LCs= github.com/docker/cli v0.0.0-20200622130859-87db43814b48 h1:AC8qbhi/SjYf4iN2W3jSsofZGHWPjG8pjf5P143KUM8= github.com/docker/cli v0.0.0-20200622130859-87db43814b48/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v17.12.0-ce-rc1.0.20200531234253-77e06fda0c94+incompatible h1:PmGHHCZ43l6h8aZIi+Xa+z1SWe4dFImd5EK3TNp1jlo= diff --git a/integration/registry_repo_list_v2_test.go b/integration/registry_repo_list_v2_test.go new file mode 100644 index 000000000..c5b11d8b3 --- /dev/null +++ b/integration/registry_repo_list_v2_test.go @@ -0,0 +1,126 @@ +package integration + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + "os/exec" + "strings" + "testing" + + "github.com/sclevine/spec" + "github.com/stretchr/testify/require" +) + +var _ = suite("registry/repository/list-v2", func(t *testing.T, when spec.G, it spec.S) { + var ( + expect *require.Assertions + server *httptest.Server + ) + + it.Before(func() { + expect = require.New(t) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Add("content-type", "application/json") + + switch req.URL.Path { + case "/v2/registry": + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(registryGetResponse)) + case "/v2/registry/my-registry/repositoriesV2": + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(repositoryListV2Response)) + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } + })) + }) + + it("returns list of repositories in registry", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "registry", + "repository", + "list-v2", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err, "Output: %s", output) + + expect.Equal(strings.TrimSpace(repositoryListV2Output), strings.TrimSpace(string(output))) + }) +}) + +var ( + repositoryListV2Output = ` +Name Latest Manifest Latest Tag Tag Count Manifest Count Updated At +repo-1 sha256:cb8a924afdf0229ef7515d9e5b3024e23b3eb03ddbba287f4a19c6ac90b8d221 v1 57 82 2021-04-09 23:54:25 +0000 UTC +` + + repositoryListV2Response = `{ + "repositories": [ + { + "registry_name": "example", + "name": "repo-1", + "tag_count": 57, + "manifest_count": 82, + "latest_manifest": { + "digest": "sha256:cb8a924afdf0229ef7515d9e5b3024e23b3eb03ddbba287f4a19c6ac90b8d221", + "registry_name": "example", + "repository": "repo-1", + "compressed_size_bytes": 1972332, + "size_bytes": 2816445, + "updated_at": "2021-04-09T23:54:25Z", + "tags": [ + "v1", + "v2" + ], + "blobs": [ + { + "digest": "sha256:14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab", + "compressed_size_bytes": 1471 + }, + { + "digest": "sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e", + "compressed_size_byte": 2814446 + }, + { + "digest": "sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a", + "compressed_size_bytes": 528 + } + ] + } + } + ], + "meta": { + "total": 1 + } +}` +) diff --git a/integration/registry_repo_manifest_list_test.go b/integration/registry_repo_manifest_list_test.go new file mode 100644 index 000000000..c67594bc7 --- /dev/null +++ b/integration/registry_repo_manifest_list_test.go @@ -0,0 +1,120 @@ +package integration + +import ( + "net/http" + "net/http/httptest" + "net/http/httputil" + "os/exec" + "strings" + "testing" + + "github.com/sclevine/spec" + "github.com/stretchr/testify/require" +) + +var _ = suite("registry/repository/manifest-list", func(t *testing.T, when spec.G, it spec.S) { + var ( + expect *require.Assertions + server *httptest.Server + ) + + it.Before(func() { + expect = require.New(t) + + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Add("content-type", "application/json") + + switch req.URL.Path { + case "/v2/registry": + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(registryGetResponse)) + case "/v2/registry/my-registry/repositories/my-repo/digests": + auth := req.Header.Get("Authorization") + if auth != "Bearer some-magic-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + w.Write([]byte(repositoryListManifestsResponse)) + default: + dump, err := httputil.DumpRequest(req, true) + if err != nil { + t.Fatal("failed to dump request") + } + + t.Fatalf("received unknown request: %s", dump) + } + })) + }) + + it("returns list of manifests in registry", func() { + cmd := exec.Command(builtBinaryPath, + "-t", "some-magic-token", + "-u", server.URL, + "registry", + "repository", + "list-manifests", + "my-repo", + ) + + output, err := cmd.CombinedOutput() + expect.NoError(err) + + expect.Equal(strings.TrimSpace(repositoryListManifestsOutput), strings.TrimSpace(string(output))) + }) +}) + +var ( + repositoryListManifestsOutput = ` +Manifest Digest Compressed Size Uncompressed Size Updated At Tags +sha256:cb8a924afdf0229ef7515d9e5b3024e23b3eb03ddbba287f4a19c6ac90b8d221 1.97 MB 2.82 MB 2021-04-09 23:54:25 +0000 UTC [v1 v2] +` + repositoryListManifestsResponse = `{ + "manifests": [ + { + "digest": "sha256:cb8a924afdf0229ef7515d9e5b3024e23b3eb03ddbba287f4a19c6ac90b8d221", + "registry_name": "example", + "repository": "repo-1", + "compressed_size_bytes": 1972332, + "size_bytes": 2816445, + "updated_at": "2021-04-09T23:54:25Z", + "tags": [ + "v1", + "v2" + ], + "blobs": [ + { + "digest": "sha256:14119a10abf4669e8cdbdff324a9f9605d99697215a0d21c360fe8dfa8471bab", + "compressed_size_bytes": 1471 + }, + { + "digest": "sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e", + "compressed_size_byte": 2814446 + }, + { + "digest": "sha256:69704ef328d05a9f806b6b8502915e6a0a4faa4d72018dc42343f511490daf8a", + "compressed_size_bytes": 528 + } + ] + } + ], + "meta": { + "total": 1 + } +}` +) diff --git a/vendor/github.com/digitalocean/godo/CHANGELOG.md b/vendor/github.com/digitalocean/godo/CHANGELOG.md index 7ee759fb1..db2145639 100644 --- a/vendor/github.com/digitalocean/godo/CHANGELOG.md +++ b/vendor/github.com/digitalocean/godo/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [v1.73.0] - 2021-12-03 + +- #501 - @CollinShoop - Add support for Registry ListManifests and ListRepositoriesV2 + ## [v1.72.0] - 2021-11-29 - #500 - @ElanHasson - APPS-4420: Add PreservePathPrefix to AppRouteSpec diff --git a/vendor/github.com/digitalocean/godo/README.md b/vendor/github.com/digitalocean/godo/README.md index 3cfc6f9a8..9a3ec2dad 100644 --- a/vendor/github.com/digitalocean/godo/README.md +++ b/vendor/github.com/digitalocean/godo/README.md @@ -118,6 +118,43 @@ func DropletList(ctx context.Context, client *godo.Client) ([]godo.Droplet, erro } ``` +Some endpoints offer token based pagination. For example, to fetch all Registry Repositories: + +```go +func ListRepositoriesV2(ctx context.Context, client *godo.Client, registryName string) ([]*godo.RepositoryV2, error) { + // create a list to hold our registries + list := []*godo.RepositoryV2{} + + // create options. initially, these will be blank + opt := &godo.TokenListOptions{} + for { + repositories, resp, err := client.Registry.ListRepositoriesV2(ctx, registryName, opt) + if err != nil { + return nil, err + } + + // append the current page's registries to our list + list = append(list, repositories...) + + // if we are at the last page, break out the for loop + if resp.Links == nil || resp.Links.IsLastPage() { + break + } + + // grab the next page token + nextPageToken, err := resp.Links.NextPageToken() + if err != nil { + return nil, err + } + + // provide the next page token for the next request + opt.Token = nextPageToken + } + + return list, nil +} +``` + ## Versioning Each version of the client is tagged and the version is updated accordingly. diff --git a/vendor/github.com/digitalocean/godo/godo.go b/vendor/github.com/digitalocean/godo/godo.go index 535a7e8bd..b701189df 100644 --- a/vendor/github.com/digitalocean/godo/godo.go +++ b/vendor/github.com/digitalocean/godo/godo.go @@ -20,7 +20,7 @@ import ( ) const ( - libraryVersion = "1.72.0" + libraryVersion = "1.73.0" defaultBaseURL = "https://api.digitalocean.com/" userAgent = "godo/" + libraryVersion mediaType = "application/json" @@ -99,6 +99,20 @@ type ListOptions struct { PerPage int `url:"per_page,omitempty"` } +// TokenListOptions specifies the optional parameters to various List methods that support token pagination. +type TokenListOptions struct { + // For paginated result sets, page of results to retrieve. + Page int `url:"page,omitempty"` + + // For paginated result sets, the number of results to include per page. + PerPage int `url:"per_page,omitempty"` + + // For paginated result sets which support tokens, the token provided by the last set + // of results in order to retrieve the next set of results. This is expected to be faster + // than incrementing or decrementing the page number. + Token string `url:"page_token,omitempty"` +} + // Response is a DigitalOcean response. This wraps the standard http.Response returned from DigitalOcean. type Response struct { *http.Response diff --git a/vendor/github.com/digitalocean/godo/links.go b/vendor/github.com/digitalocean/godo/links.go index 6f350bf9e..4b5db97fb 100644 --- a/vendor/github.com/digitalocean/godo/links.go +++ b/vendor/github.com/digitalocean/godo/links.go @@ -32,6 +32,16 @@ func (l *Links) CurrentPage() (int, error) { return l.Pages.current() } +// NextPageToken is the page token to request the next page of the list +func (l *Links) NextPageToken() (string, error) { + return l.Pages.nextPageToken() +} + +// PrevPageToken is the page token to request the previous page of the list +func (l *Links) PrevPageToken() (string, error) { + return l.Pages.prevPageToken() +} + func (p *Pages) current() (int, error) { switch { case p == nil: @@ -50,6 +60,28 @@ func (p *Pages) current() (int, error) { return 0, nil } +func (p *Pages) nextPageToken() (string, error) { + if p == nil || p.Next == "" { + return "", nil + } + token, err := pageTokenFromURL(p.Next) + if err != nil { + return "", err + } + return token, nil +} + +func (p *Pages) prevPageToken() (string, error) { + if p == nil || p.Prev == "" { + return "", nil + } + token, err := pageTokenFromURL(p.Prev) + if err != nil { + return "", err + } + return token, nil +} + // IsLastPage returns true if the current page is the last func (l *Links) IsLastPage() bool { if l.Pages == nil { @@ -77,6 +109,14 @@ func pageForURL(urlText string) (int, error) { return page, nil } +func pageTokenFromURL(urlText string) (string, error) { + u, err := url.ParseRequestURI(urlText) + if err != nil { + return "", err + } + return u.Query().Get("page_token"), nil +} + // Get a link action by id. func (la *LinkAction) Get(ctx context.Context, client *Client) (*Action, *Response, error) { return client.Actions.Get(ctx, la.ID) diff --git a/vendor/github.com/digitalocean/godo/registry.go b/vendor/github.com/digitalocean/godo/registry.go index 63099f5ca..a1ebcae50 100644 --- a/vendor/github.com/digitalocean/godo/registry.go +++ b/vendor/github.com/digitalocean/godo/registry.go @@ -25,8 +25,10 @@ type RegistryService interface { Delete(context.Context) (*Response, error) DockerCredentials(context.Context, *RegistryDockerCredentialsRequest) (*DockerCredentials, *Response, error) ListRepositories(context.Context, string, *ListOptions) ([]*Repository, *Response, error) + ListRepositoriesV2(context.Context, string, *TokenListOptions) ([]*RepositoryV2, *Response, error) ListRepositoryTags(context.Context, string, string, *ListOptions) ([]*RepositoryTag, *Response, error) DeleteTag(context.Context, string, string, string) (*Response, error) + ListRepositoryManifests(context.Context, string, string, *ListOptions) ([]*RepositoryManifest, *Response, error) DeleteManifest(context.Context, string, string, string) (*Response, error) StartGarbageCollection(context.Context, string, ...*StartGarbageCollectionRequest) (*GarbageCollection, *Response, error) GetGarbageCollection(context.Context, string) (*GarbageCollection, *Response, error) @@ -73,6 +75,15 @@ type Repository struct { TagCount uint64 `json:"tag_count,omitempty"` } +// RepositoryV2 represents a repository in the V2 format +type RepositoryV2 struct { + RegistryName string `json:"registry_name,omitempty"` + Name string `json:"name,omitempty"` + TagCount uint64 `json:"tag_count,omitempty"` + ManifestCount uint64 `json:"manifest_count,omitempty"` + LatestManifest *RepositoryManifest `json:"latest_manifest,omitempty"` +} + // RepositoryTag represents a repository tag type RepositoryTag struct { RegistryName string `json:"registry_name,omitempty"` @@ -84,6 +95,24 @@ type RepositoryTag struct { UpdatedAt time.Time `json:"updated_at,omitempty"` } +// RepositoryManifest represents a repository manifest +type RepositoryManifest struct { + RegistryName string `json:"registry_name,omitempty"` + Repository string `json:"repository,omitempty"` + Digest string `json:"digest,omitempty"` + CompressedSizeBytes uint64 `json:"compressed_size_bytes,omitempty"` + SizeBytes uint64 `json:"size_bytes,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + Tags []string `json:"tags,omitempty"` + Blobs []*Blob `json:"blobs,omitempty"` +} + +// Blob represents a registry blob +type Blob struct { + Digest string `json:"digest,omitempty"` + CompressedSizeBytes uint64 `json:"compressed_size_bytes,omitempty"` +} + type registryRoot struct { Registry *Registry `json:"registry,omitempty"` } @@ -94,12 +123,24 @@ type repositoriesRoot struct { Meta *Meta `json:"meta"` } +type repositoriesV2Root struct { + Repositories []*RepositoryV2 `json:"repositories,omitempty"` + Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta"` +} + type repositoryTagsRoot struct { Tags []*RepositoryTag `json:"tags,omitempty"` Links *Links `json:"links,omitempty"` Meta *Meta `json:"meta"` } +type repositoryManifestsRoot struct { + Manifests []*RepositoryManifest `json:"manifests,omitempty"` + Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta"` +} + // GarbageCollection represents a garbage collection. type GarbageCollection struct { UUID string `json:"uuid"` @@ -293,6 +334,30 @@ func (svc *RegistryServiceOp) ListRepositories(ctx context.Context, registry str return root.Repositories, resp, nil } +// ListRepositoriesV2 returns a list of the Repositories in a registry. +func (svc *RegistryServiceOp) ListRepositoriesV2(ctx context.Context, registry string, opts *TokenListOptions) ([]*RepositoryV2, *Response, error) { + path := fmt.Sprintf("%s/%s/repositoriesV2", registryPath, registry) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + req, err := svc.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + root := new(repositoriesV2Root) + + resp, err := svc.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + resp.Links = root.Links + resp.Meta = root.Meta + + return root.Repositories, resp, nil +} + // ListRepositoryTags returns a list of the RepositoryTags available within the given repository. func (svc *RegistryServiceOp) ListRepositoryTags(ctx context.Context, registry, repository string, opts *ListOptions) ([]*RepositoryTag, *Response, error) { path := fmt.Sprintf("%s/%s/repositories/%s/tags", registryPath, registry, url.PathEscape(repository)) @@ -336,6 +401,30 @@ func (svc *RegistryServiceOp) DeleteTag(ctx context.Context, registry, repositor return resp, nil } +// ListRepositoryManifests returns a list of the RepositoryManifests available within the given repository. +func (svc *RegistryServiceOp) ListRepositoryManifests(ctx context.Context, registry, repository string, opts *ListOptions) ([]*RepositoryManifest, *Response, error) { + path := fmt.Sprintf("%s/%s/repositories/%s/digests", registryPath, registry, url.PathEscape(repository)) + path, err := addOptions(path, opts) + if err != nil { + return nil, nil, err + } + req, err := svc.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, nil, err + } + root := new(repositoryManifestsRoot) + + resp, err := svc.client.Do(ctx, req, root) + if err != nil { + return nil, resp, err + } + + resp.Links = root.Links + resp.Meta = root.Meta + + return root.Manifests, resp, nil +} + // DeleteManifest deletes a manifest by its digest within a given repository. func (svc *RegistryServiceOp) DeleteManifest(ctx context.Context, registry, repository, digest string) (*Response, error) { path := fmt.Sprintf("%s/%s/repositories/%s/digests/%s", registryPath, registry, url.PathEscape(repository), digest) diff --git a/vendor/modules.txt b/vendor/modules.txt index 494a8398a..8ce9444b5 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -20,7 +20,7 @@ github.com/creack/pty # github.com/davecgh/go-spew v1.1.1 ## explicit github.com/davecgh/go-spew/spew -# github.com/digitalocean/godo v1.72.0 +# github.com/digitalocean/godo v1.73.0 ## explicit; go 1.16 github.com/digitalocean/godo github.com/digitalocean/godo/metrics