diff --git a/docs/spec/api.md b/docs/spec/api.md
index 483f15f03..a3606d8db 100644
--- a/docs/spec/api.md
+++ b/docs/spec/api.md
@@ -120,6 +120,16 @@ indicating what is different. Optionally, we may start marking parts of the
specification to correspond with the versions enumerated here.
+
+ - 2.0.4
>
+ -
+
+ - Added support for listing registry contents.
+ - Added pagination to tags API.
+ - Added common approach to support pagination.
+
+
+
- 2.0.3
-
- Allow repository name components to be one character.
@@ -131,7 +141,6 @@ specification to correspond with the versions enumerated here.
- Added section covering digest format.
- Added more clarification that manifest cannot be deleted by tag.
-
- 2.0.1
-
@@ -745,7 +754,131 @@ each unknown blob. The response format is as follows:
]
}
-#### Listing Image Tags
+### Listing Repositories
+
+Images are stored in collections, known as a _repository_, which is keyed by a
+`name`, as seen throughout the API specification. A registry instance may
+contain several repositories. The list of available repositories is made
+available through the _catalog_.
+
+The catalog for a given registry can be retrived with the following request:
+
+```
+GET /v2/_catalog
+```
+
+The response will be in the following format:
+
+```
+200 OK
+Content-Type: application/json
+
+{
+ "repositories": [
+ ,
+ ...
+ ]
+}
+```
+
+Note that the contents of the response are specific to the registry
+implementation. Some registries may opt to provide a full catalog output,
+limit it based on the user's access level or omit upstream results, if
+providing mirroring functionality. Subsequently, the presence of a repository
+in the catalog listing only means that the registry *may* provide access to
+the repository at the time of the request. Conversely, a missing entry does
+*not* mean that the registry does not have the repository. More succinctly,
+the presence of a repository only guarantees that it is there but not that it
+is _not_ there.
+
+For registries with a large number of repositories, this response may be quite
+large. If such a response is expected, one should use pagination.
+
+#### Pagination
+
+Paginated catalog results can be retrieved by adding an `n` parameter to the
+request URL, declaring that the response should be limited to `n` results.
+Starting a paginated flow begins as follows:
+
+```
+GET /v2/_catalog?n=
+```
+
+The above specifies that a catalog response should be returned, from the start of
+the result set, ordered lexically, limiting the number of results to `n`. The
+response to such a request would look as follows:
+
+```
+200 OK
+Content-Type: application/json
+Link: <?n=&last=>; rel="next"
+
+{
+ "repositories": [
+ ,
+ ...
+ ]
+}
+```
+
+The above includes the _first_ `n` entries from the result set. To get the
+_next_ `n` entries, one can create a URL where the argument `last` has the
+value from `repositories[len(repositories)-1]`. If there are indeed more
+results, the URL for the next block is encoded in an
+[RFC5988](https://tools.ietf.org/html/rfc5988) `Link` header, as a "next"
+relation. The presence of the `Link` header communicates to the client that
+the entire result set has not been returned and another request must be
+issued. If the header is not present, the client can assume that all results
+have been recieved.
+
+> __NOTE:__ In the request template above, note that the brackets
+> are required. For example, if the url is
+> `http://example.com/v2/_catalog?n=20&last=b`, the value of the header would
+> be `; rel="next"`. Please see
+> [RFC5988](https://tools.ietf.org/html/rfc5988) for details.
+
+Compliant client implementations should always use the `Link` header
+value when proceeding through results linearly. The client may construct URLs
+to skip forward in the catalog.
+
+To get the next result set, a client would issue the request as follows, using
+the URL encoded in the described `Link` header:
+
+```
+GET /v2/_catalog?n=&last=
+```
+
+The above process should then be repeated until the `Link` header is no longer
+set.
+
+The catalog result set is represented abstractly as a lexically sorted list,
+where the position in that list can be specified by the query term `last`. The
+entries in the response start _after_ the term specified by `last`, up to `n`
+entries.
+
+The behavior of `last` is quite simple when demonstrated with an example. Let
+us say the registry has the following repositories:
+
+```
+a
+b
+c
+d
+```
+
+If the value of `n` is 2, _a_ and _b_ will be returned on the first response.
+The `Link` header returned on the response will have `n` set to 2 and last set
+to _b_:
+
+```
+Link: <?n=2&last=b>; rel="next"
+```
+
+The client can then issue the request with above value from the `Link` header,
+receiving the values _c_ and _d_. Note that n may change on second to last
+response or be omitted fully, if the server may so choose.
+
+### Listing Image Tags
It may be necessary to list all of the tags under a given repository. The tags
for an image repository can be retrieved with the following request:
@@ -766,8 +899,51 @@ The response will be in the following format:
}
For repositories with a large number of tags, this response may be quite
-large, so care should be taken by the client when parsing the response to
-reduce copying.
+large. If such a response is expected, one should use the pagination.
+
+#### Pagination
+
+Paginated tag results can be retrieved by adding the appropriate parameters to
+the request URL described above. The behavior of tag pagination is identical
+to that specified for catalog pagination. We cover a simple flow to highlight
+any differences.
+
+Starting a paginated flow may begin as follows:
+
+```
+GET /v2//tags/list?n=
+```
+
+The above specifies that a tags response should be returned, from the start of
+the result set, ordered lexically, limiting the number of results to `n`. The
+response to such a request would look as follows:
+
+```
+200 OK
+Content-Type: application/json
+Link: <?n=&last=>; rel="next"
+
+{
+ "name": ,
+ "tags": [
+ ,
+ ...
+ ]
+}
+```
+
+To get the next result set, a client would issue the request as follows, using
+the value encoded in the [RFC5988](https://tools.ietf.org/html/rfc5988) `Link`
+header:
+
+```
+GET /v2//tags/list?n=&last=
+```
+
+The above process should then be repeated until the `Link` header is no longer
+set in the response. The behavior of the `last` parameter, the provided
+response result, lexical ordering and encoding of the `Link` header are
+identical to that of catalog pagination.
### Deleting an Image
@@ -817,6 +993,7 @@ A list of methods and URIs are covered in the table below:
| PATCH | `/v2//blobs/uploads/` | Blob Upload | Upload a chunk of data for the specified upload. |
| PUT | `/v2//blobs/uploads/` | Blob Upload | Complete the upload specified by `uuid`, optionally appending the body as the final chunk. |
| DELETE | `/v2//blobs/uploads/` | Blob Upload | Cancel outstanding upload processes, releasing associated resources. If this is not called, the unfinished uploads will eventually timeout. |
+| GET | `/v2/_catalog` | Catalog | Retrieve a sorted, json list of repositories available in the registry. |
The detail for each endpoint is covered in the following sections.
@@ -886,7 +1063,6 @@ The API implements V2 protocol and is accessible.
-
###### On Failure: Unauthorized
```
@@ -973,6 +1149,7 @@ The following parameters should be specified on the request:
```
200 OK
Content-Length:
+Link: <?n=&last=>; rel="next"
Content-Type: application/json; charset=utf-8
{
@@ -1056,6 +1233,52 @@ The error codes that may be included in the response body are enumerated below:
+```
+GET /v2//tags/list?n=last=
+```
+
+Return a portion of the tags for the specified repository.
+
+
+The following parameters should be specified on the request:
+
+|Name|Kind|Description|
+|----|----|-----------|
+|`name`|path|Name of the target repository.|
+|`n`|query|Limit the number of entries in each response. It not present, all entries will be returned.|
+|`last`|query|Result set will include values lexically after last.|
+
+
+
+
+###### On Success: OK
+
+```
+200 OK
+Content-Length:
+Content-Type: application/json; charset=utf-8
+
+{
+ "name": ,
+ "tags": [
+ ,
+ ...
+ ],
+}
+```
+
+A list of tags for the named repository.
+
+The following headers will be returned with the response:
+
+|Name|Description|
+|----|-----------|
+|`Content-Length`|Length of the JSON response body.|
+|`Link`|RFC5988 compliant rel='next' with URL to next result set, if available|
+
+
+
+
### Manifest
@@ -1453,7 +1676,6 @@ The following parameters should be specified on the request:
-
###### On Failure: Invalid Name or Reference
```
@@ -2907,3 +3129,100 @@ The error codes that may be included in the response body are enumerated below:
+### Catalog
+
+List a set of available repositories in the local registry cluster. Does not provide any indication of what may be available upstream. Applications can only determine if a repository is available but not if it is not available.
+
+
+
+#### GET Catalog
+
+Retrieve a sorted, json list of repositories available in the registry.
+
+
+##### Catalog Fetch Complete
+
+```
+GET /v2/_catalog
+```
+
+Request an unabridged list of repositories available.
+
+
+
+
+
+###### On Success: OK
+
+```
+200 OK
+Content-Length:
+Link: <?n=&last=>; rel="next"
+Content-Type: application/json; charset=utf-8
+
+{
+ "repositories": [
+ ,
+ ...
+ ]
+}
+```
+
+Returns the unabridged list of repositories as a json response.
+
+The following headers will be returned with the response:
+
+|Name|Description|
+|----|-----------|
+|`Content-Length`|Length of the JSON response body.|
+
+
+
+##### Catalog Fetch Paginated
+
+```
+GET /v2/_catalog?n=last=
+```
+
+Return the specified portion of repositories.
+
+
+The following parameters should be specified on the request:
+
+|Name|Kind|Description|
+|----|----|-----------|
+|`n`|query|Limit the number of entries in each response. It not present, all entries will be returned.|
+|`last`|query|Result set will include values lexically after last.|
+
+
+
+
+###### On Success: OK
+
+```
+200 OK
+Content-Length:
+Content-Type: application/json; charset=utf-8
+
+{
+ "repositories": [
+ ,
+ ...
+ ]
+ "next": "?last=&n="
+}
+```
+
+
+
+The following headers will be returned with the response:
+
+|Name|Description|
+|----|-----------|
+|`Content-Length`|Length of the JSON response body.|
+|`Link`|RFC5988 compliant rel='next' with URL to next result set, if available|
+
+
+
+
+
diff --git a/docs/spec/api.md.tmpl b/docs/spec/api.md.tmpl
index bc6d6f926..ceee0c727 100644
--- a/docs/spec/api.md.tmpl
+++ b/docs/spec/api.md.tmpl
@@ -120,6 +120,16 @@ indicating what is different. Optionally, we may start marking parts of the
specification to correspond with the versions enumerated here.
+
+ - 2.0.4
>
+ -
+
+ - Added support for listing registry contents.
+ - Added pagination to tags API.
+ - Added common approach to support pagination.
+
+
+
- 2.0.3
-
- Allow repository name components to be one character.
@@ -131,7 +141,6 @@ specification to correspond with the versions enumerated here.
- Added section covering digest format.
- Added more clarification that manifest cannot be deleted by tag.
-
- 2.0.1
-
@@ -745,7 +754,131 @@ each unknown blob. The response format is as follows:
]
}
-#### Listing Image Tags
+### Listing Repositories
+
+Images are stored in collections, known as a _repository_, which is keyed by a
+`name`, as seen throughout the API specification. A registry instance may
+contain several repositories. The list of available repositories is made
+available through the _catalog_.
+
+The catalog for a given registry can be retrived with the following request:
+
+```
+GET /v2/_catalog
+```
+
+The response will be in the following format:
+
+```
+200 OK
+Content-Type: application/json
+
+{
+ "repositories": [
+ ,
+ ...
+ ]
+}
+```
+
+Note that the contents of the response are specific to the registry
+implementation. Some registries may opt to provide a full catalog output,
+limit it based on the user's access level or omit upstream results, if
+providing mirroring functionality. Subsequently, the presence of a repository
+in the catalog listing only means that the registry *may* provide access to
+the repository at the time of the request. Conversely, a missing entry does
+*not* mean that the registry does not have the repository. More succinctly,
+the presence of a repository only guarantees that it is there but not that it
+is _not_ there.
+
+For registries with a large number of repositories, this response may be quite
+large. If such a response is expected, one should use pagination.
+
+#### Pagination
+
+Paginated catalog results can be retrieved by adding an `n` parameter to the
+request URL, declaring that the response should be limited to `n` results.
+Starting a paginated flow begins as follows:
+
+```
+GET /v2/_catalog?n=
+```
+
+The above specifies that a catalog response should be returned, from the start of
+the result set, ordered lexically, limiting the number of results to `n`. The
+response to such a request would look as follows:
+
+```
+200 OK
+Content-Type: application/json
+Link: <?n=&last=>; rel="next"
+
+{
+ "repositories": [
+ ,
+ ...
+ ]
+}
+```
+
+The above includes the _first_ `n` entries from the result set. To get the
+_next_ `n` entries, one can create a URL where the argument `last` has the
+value from `repositories[len(repositories)-1]`. If there are indeed more
+results, the URL for the next block is encoded in an
+[RFC5988](https://tools.ietf.org/html/rfc5988) `Link` header, as a "next"
+relation. The presence of the `Link` header communicates to the client that
+the entire result set has not been returned and another request must be
+issued. If the header is not present, the client can assume that all results
+have been recieved.
+
+> __NOTE:__ In the request template above, note that the brackets
+> are required. For example, if the url is
+> `http://example.com/v2/_catalog?n=20&last=b`, the value of the header would
+> be `; rel="next"`. Please see
+> [RFC5988](https://tools.ietf.org/html/rfc5988) for details.
+
+Compliant client implementations should always use the `Link` header
+value when proceeding through results linearly. The client may construct URLs
+to skip forward in the catalog.
+
+To get the next result set, a client would issue the request as follows, using
+the URL encoded in the described `Link` header:
+
+```
+GET /v2/_catalog?n=&last=
+```
+
+The above process should then be repeated until the `Link` header is no longer
+set.
+
+The catalog result set is represented abstractly as a lexically sorted list,
+where the position in that list can be specified by the query term `last`. The
+entries in the response start _after_ the term specified by `last`, up to `n`
+entries.
+
+The behavior of `last` is quite simple when demonstrated with an example. Let
+us say the registry has the following repositories:
+
+```
+a
+b
+c
+d
+```
+
+If the value of `n` is 2, _a_ and _b_ will be returned on the first response.
+The `Link` header returned on the response will have `n` set to 2 and last set
+to _b_:
+
+```
+Link: <?n=2&last=b>; rel="next"
+```
+
+The client can then issue the request with above value from the `Link` header,
+receiving the values _c_ and _d_. Note that n may change on second to last
+response or be omitted fully, if the server may so choose.
+
+### Listing Image Tags
It may be necessary to list all of the tags under a given repository. The tags
for an image repository can be retrieved with the following request:
@@ -766,8 +899,51 @@ The response will be in the following format:
}
For repositories with a large number of tags, this response may be quite
-large, so care should be taken by the client when parsing the response to
-reduce copying.
+large. If such a response is expected, one should use the pagination.
+
+#### Pagination
+
+Paginated tag results can be retrieved by adding the appropriate parameters to
+the request URL described above. The behavior of tag pagination is identical
+to that specified for catalog pagination. We cover a simple flow to highlight
+any differences.
+
+Starting a paginated flow may begin as follows:
+
+```
+GET /v2//tags/list?n=
+```
+
+The above specifies that a tags response should be returned, from the start of
+the result set, ordered lexically, limiting the number of results to `n`. The
+response to such a request would look as follows:
+
+```
+200 OK
+Content-Type: application/json
+Link: <?n=&last=>; rel="next"
+
+{
+ "name": ,
+ "tags": [
+ ,
+ ...
+ ]
+}
+```
+
+To get the next result set, a client would issue the request as follows, using
+the value encoded in the [RFC5988](https://tools.ietf.org/html/rfc5988) `Link`
+header:
+
+```
+GET /v2//tags/list?n=&last=
+```
+
+The above process should then be repeated until the `Link` header is no longer
+set in the response. The behavior of the `last` parameter, the provided
+response result, lexical ordering and encoding of the `Link` header are
+identical to that of catalog pagination.
### Deleting an Image
@@ -867,8 +1043,13 @@ Content-Type: {{.Body.ContentType}}{{end}}{{if .Body.Format}}
```
{{.Description}}
+{{if .Fields}}The following fields may be returned in the response body:
-{{if .Headers}}The following headers will be returned with the response:
+|Name|Description|
+|----|-----------|
+{{range .Fields}}|`{{.Name}}`|{{.Description}}|
+{{end}}{{end}}{{if .Headers}}
+The following headers will be returned with the response:
|Name|Description|
|----|-----------|
diff --git a/registry.go b/registry.go
index 763a189b2..1a3de01d0 100644
--- a/registry.go
+++ b/registry.go
@@ -35,6 +35,12 @@ type Namespace interface {
// registry may or may not have the repository but should always return a
// reference.
Repository(ctx context.Context, name string) (Repository, error)
+
+ // Repositories fills 'repos' with a lexigraphically sorted catalog of repositories
+ // up to the size of 'repos' and returns the value 'n' for the number of entries
+ // which were filled. 'last' contains an offset in the catalog, and 'err' will be
+ // set to io.EOF if there are no more entries to obtain.
+ Repositories(ctx context.Context, repos []string, last string) (n int, err error)
}
// ManifestServiceOption is a function argument for Manifest Service methods
diff --git a/registry/api/v2/descriptors.go b/registry/api/v2/descriptors.go
index f2551ffeb..ee895b722 100644
--- a/registry/api/v2/descriptors.go
+++ b/registry/api/v2/descriptors.go
@@ -87,6 +87,30 @@ var (
Format: "",
}
+ linkHeader = ParameterDescriptor{
+ Name: "Link",
+ Type: "link",
+ Description: "RFC5988 compliant rel='next' with URL to next result set, if available",
+ Format: `<?n=&last=>; rel="next"`,
+ }
+
+ paginationParameters = []ParameterDescriptor{
+ {
+ Name: "n",
+ Type: "integer",
+ Description: "Limit the number of entries in each response. It not present, all entries will be returned.",
+ Format: "",
+ Required: false,
+ },
+ {
+ Name: "last",
+ Type: "string",
+ Description: "Result set will include values lexically after last.",
+ Format: "",
+ Required: false,
+ },
+ }
+
unauthorizedResponse = ResponseDescriptor{
Description: "The client does not have access to the repository.",
StatusCode: http.StatusUnauthorized,
@@ -269,6 +293,9 @@ type ResponseDescriptor struct {
// Headers covers any headers that may be returned from the response.
Headers []ParameterDescriptor
+ // Fields describes any fields that may be present in the response.
+ Fields []ParameterDescriptor
+
// ErrorCodes enumerates the error codes that may be returned along with
// the response.
ErrorCodes []errcode.ErrorCode
@@ -427,6 +454,36 @@ var routeDescriptors = []RouteDescriptor{
},
},
},
+ {
+ Description: "Return a portion of the tags for the specified repository.",
+ PathParameters: []ParameterDescriptor{nameParameterDescriptor},
+ QueryParameters: paginationParameters,
+ Successes: []ResponseDescriptor{
+ {
+ StatusCode: http.StatusOK,
+ Description: "A list of tags for the named repository.",
+ Headers: []ParameterDescriptor{
+ {
+ Name: "Content-Length",
+ Type: "integer",
+ Description: "Length of the JSON response body.",
+ Format: "",
+ },
+ linkHeader,
+ },
+ Body: BodyDescriptor{
+ ContentType: "application/json; charset=utf-8",
+ Format: `{
+ "name": ,
+ "tags": [
+ ,
+ ...
+ ],
+}`,
+ },
+ },
+ },
+ },
},
},
},
@@ -1320,6 +1377,76 @@ var routeDescriptors = []RouteDescriptor{
},
},
},
+ {
+ Name: RouteNameCatalog,
+ Path: "/v2/_catalog",
+ Entity: "Catalog",
+ Description: "List a set of available repositories in the local registry cluster. Does not provide any indication of what may be available upstream. Applications can only determine if a repository is available but not if it is not available.",
+ Methods: []MethodDescriptor{
+ {
+ Method: "GET",
+ Description: "Retrieve a sorted, json list of repositories available in the registry.",
+ Requests: []RequestDescriptor{
+ {
+ Name: "Catalog Fetch Complete",
+ Description: "Request an unabridged list of repositories available.",
+ Successes: []ResponseDescriptor{
+ {
+ Description: "Returns the unabridged list of repositories as a json response.",
+ StatusCode: http.StatusOK,
+ Headers: []ParameterDescriptor{
+ {
+ Name: "Content-Length",
+ Type: "integer",
+ Description: "Length of the JSON response body.",
+ Format: "",
+ },
+ },
+ Body: BodyDescriptor{
+ ContentType: "application/json; charset=utf-8",
+ Format: `{
+ "repositories": [
+ ,
+ ...
+ ]
+}`,
+ },
+ },
+ },
+ },
+ {
+ Name: "Catalog Fetch Paginated",
+ Description: "Return the specified portion of repositories.",
+ QueryParameters: paginationParameters,
+ Successes: []ResponseDescriptor{
+ {
+ StatusCode: http.StatusOK,
+ Body: BodyDescriptor{
+ ContentType: "application/json; charset=utf-8",
+ Format: `{
+ "repositories": [
+ ,
+ ...
+ ]
+ "next": "?last=&n="
+}`,
+ },
+ Headers: []ParameterDescriptor{
+ {
+ Name: "Content-Length",
+ Type: "integer",
+ Description: "Length of the JSON response body.",
+ Format: "",
+ },
+ linkHeader,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
}
var routeDescriptorsMap map[string]RouteDescriptor
diff --git a/registry/api/v2/routes.go b/registry/api/v2/routes.go
index 69f9d9012..5b80d5be7 100644
--- a/registry/api/v2/routes.go
+++ b/registry/api/v2/routes.go
@@ -11,10 +11,12 @@ const (
RouteNameBlob = "blob"
RouteNameBlobUpload = "blob-upload"
RouteNameBlobUploadChunk = "blob-upload-chunk"
+ RouteNameCatalog = "catalog"
)
var allEndpoints = []string{
RouteNameManifest,
+ RouteNameCatalog,
RouteNameTags,
RouteNameBlob,
RouteNameBlobUpload,
diff --git a/registry/api/v2/urls.go b/registry/api/v2/urls.go
index 60aad5659..429743940 100644
--- a/registry/api/v2/urls.go
+++ b/registry/api/v2/urls.go
@@ -100,6 +100,18 @@ func (ub *URLBuilder) BuildBaseURL() (string, error) {
return baseURL.String(), nil
}
+// BuildCatalogURL constructs a url get a catalog of repositories
+func (ub *URLBuilder) BuildCatalogURL(values ...url.Values) (string, error) {
+ route := ub.cloneRoute(RouteNameCatalog)
+
+ catalogURL, err := route.URL()
+ if err != nil {
+ return "", err
+ }
+
+ return appendValuesURL(catalogURL, values...).String(), nil
+}
+
// BuildTagsURL constructs a url to list the tags in the named repository.
func (ub *URLBuilder) BuildTagsURL(name string) (string, error) {
route := ub.cloneRoute(RouteNameTags)
diff --git a/registry/client/repository.go b/registry/client/repository.go
index fc90cb6e4..29effcce8 100644
--- a/registry/client/repository.go
+++ b/registry/client/repository.go
@@ -21,6 +21,83 @@ import (
"github.com/docker/distribution/registry/storage/cache/memory"
)
+// Registry provides an interface for calling Repositories, which returns a catalog of repositories.
+type Registry interface {
+ Repositories(ctx context.Context, repos []string, last string) (n int, err error)
+}
+
+// NewRegistry creates a registry namespace which can be used to get a listing of repositories
+func NewRegistry(ctx context.Context, baseURL string, transport http.RoundTripper) (Registry, error) {
+ ub, err := v2.NewURLBuilderFromString(baseURL)
+ if err != nil {
+ return nil, err
+ }
+
+ client := &http.Client{
+ Transport: transport,
+ Timeout: 1 * time.Minute,
+ }
+
+ return ®istry{
+ client: client,
+ ub: ub,
+ context: ctx,
+ }, nil
+}
+
+type registry struct {
+ client *http.Client
+ ub *v2.URLBuilder
+ context context.Context
+}
+
+// Repositories returns a lexigraphically sorted catalog given a base URL. The 'entries' slice will be filled up to the size
+// of the slice, starting at the value provided in 'last'. The number of entries will be returned along with io.EOF if there
+// are no more entries
+func (r *registry) Repositories(ctx context.Context, entries []string, last string) (int, error) {
+ var numFilled int
+ var returnErr error
+
+ values := buildCatalogValues(len(entries), last)
+ u, err := r.ub.BuildCatalogURL(values)
+ if err != nil {
+ return 0, err
+ }
+
+ resp, err := r.client.Get(u)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+
+ switch resp.StatusCode {
+ case http.StatusOK:
+ var ctlg struct {
+ Repositories []string `json:"repositories"`
+ }
+ decoder := json.NewDecoder(resp.Body)
+
+ if err := decoder.Decode(&ctlg); err != nil {
+ return 0, err
+ }
+
+ for cnt := range ctlg.Repositories {
+ entries[cnt] = ctlg.Repositories[cnt]
+ }
+ numFilled = len(ctlg.Repositories)
+
+ link := resp.Header.Get("Link")
+ if link == "" {
+ returnErr = io.EOF
+ }
+
+ default:
+ return 0, handleErrorResponse(resp)
+ }
+
+ return numFilled, returnErr
+}
+
// NewRepository creates a new Repository for the given repository name and base URL
func NewRepository(ctx context.Context, name, baseURL string, transport http.RoundTripper) (distribution.Repository, error) {
if err := v2.ValidateRepositoryName(name); err != nil {
@@ -444,3 +521,17 @@ func (bs *blobStatter) Stat(ctx context.Context, dgst digest.Digest) (distributi
return distribution.Descriptor{}, handleErrorResponse(resp)
}
}
+
+func buildCatalogValues(maxEntries int, last string) url.Values {
+ values := url.Values{}
+
+ if maxEntries > 0 {
+ values.Add("n", strconv.Itoa(maxEntries))
+ }
+
+ if last != "" {
+ values.Add("last", last)
+ }
+
+ return values
+}
diff --git a/registry/client/repository_test.go b/registry/client/repository_test.go
index 3a91be980..232501aa3 100644
--- a/registry/client/repository_test.go
+++ b/registry/client/repository_test.go
@@ -5,9 +5,11 @@ import (
"crypto/rand"
"encoding/json"
"fmt"
+ "io"
"log"
"net/http"
"net/http/httptest"
+ "strconv"
"strings"
"testing"
"time"
@@ -77,6 +79,28 @@ func addTestFetch(repo string, dgst digest.Digest, content []byte, m *testutil.R
})
}
+func addTestCatalog(route string, content []byte, link string, m *testutil.RequestResponseMap) {
+ headers := map[string][]string{
+ "Content-Length": {strconv.Itoa(len(content))},
+ "Content-Type": {"application/json; charset=utf-8"},
+ }
+ if link != "" {
+ headers["Link"] = append(headers["Link"], link)
+ }
+
+ *m = append(*m, testutil.RequestResponseMapping{
+ Request: testutil.Request{
+ Method: "GET",
+ Route: route,
+ },
+ Response: testutil.Response{
+ StatusCode: http.StatusOK,
+ Body: content,
+ Headers: http.Header(headers),
+ },
+ })
+}
+
func TestBlobFetch(t *testing.T) {
d1, b1 := newRandomBlob(1024)
var m testutil.RequestResponseMap
@@ -732,3 +756,71 @@ func TestManifestUnauthorized(t *testing.T) {
t.Fatalf("Unexpected message value: %q, expected %q", v2Err.Message, expected)
}
}
+
+func TestCatalog(t *testing.T) {
+ var m testutil.RequestResponseMap
+ addTestCatalog(
+ "/v2/_catalog?n=5",
+ []byte("{\"repositories\":[\"foo\", \"bar\", \"baz\"]}"), "", &m)
+
+ e, c := testServer(m)
+ defer c()
+
+ entries := make([]string, 5)
+
+ r, err := NewRegistry(context.Background(), e, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := context.Background()
+ numFilled, err := r.Repositories(ctx, entries, "")
+ if err != io.EOF {
+ t.Fatal(err)
+ }
+
+ if numFilled != 3 {
+ t.Fatalf("Got wrong number of repos")
+ }
+}
+
+func TestCatalogInParts(t *testing.T) {
+ var m testutil.RequestResponseMap
+ addTestCatalog(
+ "/v2/_catalog?n=2",
+ []byte("{\"repositories\":[\"bar\", \"baz\"]}"),
+ "", &m)
+ addTestCatalog(
+ "/v2/_catalog?last=baz&n=2",
+ []byte("{\"repositories\":[\"foo\"]}"),
+ "", &m)
+
+ e, c := testServer(m)
+ defer c()
+
+ entries := make([]string, 2)
+
+ r, err := NewRegistry(context.Background(), e, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ctx := context.Background()
+ numFilled, err := r.Repositories(ctx, entries, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if numFilled != 2 {
+ t.Fatalf("Got wrong number of repos")
+ }
+
+ numFilled, err = r.Repositories(ctx, entries, "baz")
+ if err != io.EOF {
+ t.Fatal(err)
+ }
+
+ if numFilled != 1 {
+ t.Fatalf("Got wrong number of repos")
+ }
+}
diff --git a/registry/handlers/api_test.go b/registry/handlers/api_test.go
index 8d6319417..4473eb995 100644
--- a/registry/handlers/api_test.go
+++ b/registry/handlers/api_test.go
@@ -13,6 +13,8 @@ import (
"os"
"path"
"reflect"
+ "regexp"
+ "strconv"
"strings"
"testing"
@@ -60,6 +62,152 @@ func TestCheckAPI(t *testing.T) {
}
}
+// TestCatalogAPI tests the /v2/_catalog endpoint
+func TestCatalogAPI(t *testing.T) {
+ chunkLen := 2
+ env := newTestEnv(t)
+
+ values := url.Values{
+ "last": []string{""},
+ "n": []string{strconv.Itoa(chunkLen)}}
+
+ catalogURL, err := env.builder.BuildCatalogURL(values)
+ if err != nil {
+ t.Fatalf("unexpected error building catalog url: %v", err)
+ }
+
+ // -----------------------------------
+ // try to get an empty catalog
+ resp, err := http.Get(catalogURL)
+ if err != nil {
+ t.Fatalf("unexpected error issuing request: %v", err)
+ }
+ defer resp.Body.Close()
+
+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
+
+ var ctlg struct {
+ Repositories []string `json:"repositories"`
+ }
+
+ dec := json.NewDecoder(resp.Body)
+ if err := dec.Decode(&ctlg); err != nil {
+ t.Fatalf("error decoding fetched manifest: %v", err)
+ }
+
+ // we haven't pushed anything to the registry yet
+ if len(ctlg.Repositories) != 0 {
+ t.Fatalf("repositories has unexpected values")
+ }
+
+ if resp.Header.Get("Link") != "" {
+ t.Fatalf("repositories has more data when none expected")
+ }
+
+ // -----------------------------------
+ // push something to the registry and try again
+ images := []string{"foo/aaaa", "foo/bbbb", "foo/cccc"}
+
+ for _, image := range images {
+ createRepository(env, t, image, "sometag")
+ }
+
+ resp, err = http.Get(catalogURL)
+ if err != nil {
+ t.Fatalf("unexpected error issuing request: %v", err)
+ }
+ defer resp.Body.Close()
+
+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
+
+ dec = json.NewDecoder(resp.Body)
+ if err = dec.Decode(&ctlg); err != nil {
+ t.Fatalf("error decoding fetched manifest: %v", err)
+ }
+
+ if len(ctlg.Repositories) != chunkLen {
+ t.Fatalf("repositories has unexpected values")
+ }
+
+ for _, image := range images[:chunkLen] {
+ if !contains(ctlg.Repositories, image) {
+ t.Fatalf("didn't find our repository '%s' in the catalog", image)
+ }
+ }
+
+ link := resp.Header.Get("Link")
+ if link == "" {
+ t.Fatalf("repositories has less data than expected")
+ }
+
+ newValues := checkLink(t, link, chunkLen, ctlg.Repositories[len(ctlg.Repositories)-1])
+
+ // -----------------------------------
+ // get the last chunk of data
+
+ catalogURL, err = env.builder.BuildCatalogURL(newValues)
+ if err != nil {
+ t.Fatalf("unexpected error building catalog url: %v", err)
+ }
+
+ resp, err = http.Get(catalogURL)
+ if err != nil {
+ t.Fatalf("unexpected error issuing request: %v", err)
+ }
+ defer resp.Body.Close()
+
+ checkResponse(t, "issuing catalog api check", resp, http.StatusOK)
+
+ dec = json.NewDecoder(resp.Body)
+ if err = dec.Decode(&ctlg); err != nil {
+ t.Fatalf("error decoding fetched manifest: %v", err)
+ }
+
+ if len(ctlg.Repositories) != 1 {
+ t.Fatalf("repositories has unexpected values")
+ }
+
+ lastImage := images[len(images)-1]
+ if !contains(ctlg.Repositories, lastImage) {
+ t.Fatalf("didn't find our repository '%s' in the catalog", lastImage)
+ }
+
+ link = resp.Header.Get("Link")
+ if link != "" {
+ t.Fatalf("catalog has unexpected data")
+ }
+}
+
+func checkLink(t *testing.T, urlStr string, numEntries int, last string) url.Values {
+ re := regexp.MustCompile("<(/v2/_catalog.*)>; rel=\"next\"")
+ matches := re.FindStringSubmatch(urlStr)
+
+ if len(matches) != 2 {
+ t.Fatalf("Catalog link address response was incorrect")
+ }
+ linkURL, _ := url.Parse(matches[1])
+ urlValues := linkURL.Query()
+
+ if urlValues.Get("n") != strconv.Itoa(numEntries) {
+ t.Fatalf("Catalog link entry size is incorrect")
+ }
+
+ if urlValues.Get("last") != last {
+ t.Fatal("Catalog link last entry is incorrect")
+ }
+
+ return urlValues
+}
+
+func contains(elems []string, e string) bool {
+ for _, elem := range elems {
+ if elem == e {
+ return true
+ }
+ }
+ return false
+}
+
func TestURLPrefix(t *testing.T) {
config := configuration.Configuration{
Storage: configuration.Storage{
@@ -869,3 +1017,60 @@ func checkErr(t *testing.T, err error, msg string) {
t.Fatalf("unexpected error %s: %v", msg, err)
}
}
+
+func createRepository(env *testEnv, t *testing.T, imageName string, tag string) {
+ unsignedManifest := &manifest.Manifest{
+ Versioned: manifest.Versioned{
+ SchemaVersion: 1,
+ },
+ Name: imageName,
+ Tag: tag,
+ FSLayers: []manifest.FSLayer{
+ {
+ BlobSum: "asdf",
+ },
+ {
+ BlobSum: "qwer",
+ },
+ },
+ }
+
+ // Push 2 random layers
+ expectedLayers := make(map[digest.Digest]io.ReadSeeker)
+
+ for i := range unsignedManifest.FSLayers {
+ rs, dgstStr, err := testutil.CreateRandomTarFile()
+
+ if err != nil {
+ t.Fatalf("error creating random layer %d: %v", i, err)
+ }
+ dgst := digest.Digest(dgstStr)
+
+ expectedLayers[dgst] = rs
+ unsignedManifest.FSLayers[i].BlobSum = dgst
+
+ uploadURLBase, _ := startPushLayer(t, env.builder, imageName)
+ pushLayer(t, env.builder, imageName, dgst, uploadURLBase, rs)
+ }
+
+ signedManifest, err := manifest.Sign(unsignedManifest, env.pk)
+ if err != nil {
+ t.Fatalf("unexpected error signing manifest: %v", err)
+ }
+
+ payload, err := signedManifest.Payload()
+ checkErr(t, err, "getting manifest payload")
+
+ dgst, err := digest.FromBytes(payload)
+ checkErr(t, err, "digesting manifest")
+
+ manifestDigestURL, err := env.builder.BuildManifestURL(imageName, dgst.String())
+ checkErr(t, err, "building manifest url")
+
+ resp := putManifest(t, "putting signed manifest", manifestDigestURL, signedManifest)
+ checkResponse(t, "putting signed manifest", resp, http.StatusAccepted)
+ checkHeaders(t, resp, http.Header{
+ "Location": []string{manifestDigestURL},
+ "Docker-Content-Digest": []string{dgst.String()},
+ })
+}
diff --git a/registry/handlers/app.go b/registry/handlers/app.go
index c895222bd..f61b2c1e0 100644
--- a/registry/handlers/app.go
+++ b/registry/handlers/app.go
@@ -69,6 +69,7 @@ func NewApp(ctx context.Context, configuration configuration.Configuration) *App
return http.HandlerFunc(apiBase)
})
app.register(v2.RouteNameManifest, imageManifestDispatcher)
+ app.register(v2.RouteNameCatalog, catalogDispatcher)
app.register(v2.RouteNameTags, tagsDispatcher)
app.register(v2.RouteNameBlob, blobDispatcher)
app.register(v2.RouteNameBlobUpload, blobUploadDispatcher)
@@ -493,6 +494,7 @@ func (app *App) authorized(w http.ResponseWriter, r *http.Request, context *Cont
}
return fmt.Errorf("forbidden: no repository name")
}
+ accessRecords = appendCatalogAccessRecord(accessRecords, r)
}
ctx, err := app.accessController.Authorized(context.Context, accessRecords...)
@@ -538,7 +540,8 @@ func (app *App) eventBridge(ctx *Context, r *http.Request) notifications.Listene
// nameRequired returns true if the route requires a name.
func (app *App) nameRequired(r *http.Request) bool {
route := mux.CurrentRoute(r)
- return route == nil || route.GetName() != v2.RouteNameBase
+ routeName := route.GetName()
+ return route == nil || (routeName != v2.RouteNameBase && routeName != v2.RouteNameCatalog)
}
// apiBase implements a simple yes-man for doing overall checks against the
@@ -588,6 +591,26 @@ func appendAccessRecords(records []auth.Access, method string, repo string) []au
return records
}
+// Add the access record for the catalog if it's our current route
+func appendCatalogAccessRecord(accessRecords []auth.Access, r *http.Request) []auth.Access {
+ route := mux.CurrentRoute(r)
+ routeName := route.GetName()
+
+ if routeName == v2.RouteNameCatalog {
+ resource := auth.Resource{
+ Type: "registry",
+ Name: "catalog",
+ }
+
+ accessRecords = append(accessRecords,
+ auth.Access{
+ Resource: resource,
+ Action: "*",
+ })
+ }
+ return accessRecords
+}
+
// applyRegistryMiddleware wraps a registry instance with the configured middlewares
func applyRegistryMiddleware(registry distribution.Namespace, middlewares []configuration.Middleware) (distribution.Namespace, error) {
for _, mw := range middlewares {
diff --git a/registry/handlers/catalog.go b/registry/handlers/catalog.go
new file mode 100644
index 000000000..6ec1fe550
--- /dev/null
+++ b/registry/handlers/catalog.go
@@ -0,0 +1,95 @@
+package handlers
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+
+ "github.com/docker/distribution/registry/api/errcode"
+ "github.com/gorilla/handlers"
+)
+
+const maximumReturnedEntries = 100
+
+func catalogDispatcher(ctx *Context, r *http.Request) http.Handler {
+ catalogHandler := &catalogHandler{
+ Context: ctx,
+ }
+
+ return handlers.MethodHandler{
+ "GET": http.HandlerFunc(catalogHandler.GetCatalog),
+ }
+}
+
+type catalogHandler struct {
+ *Context
+}
+
+type catalogAPIResponse struct {
+ Repositories []string `json:"repositories"`
+}
+
+func (ch *catalogHandler) GetCatalog(w http.ResponseWriter, r *http.Request) {
+ var moreEntries = true
+
+ q := r.URL.Query()
+ lastEntry := q.Get("last")
+ maxEntries, err := strconv.Atoi(q.Get("n"))
+ if err != nil || maxEntries < 0 {
+ maxEntries = maximumReturnedEntries
+ }
+
+ repos := make([]string, maxEntries)
+
+ filled, err := ch.App.registry.Repositories(ch.Context, repos, lastEntry)
+ if err == io.EOF {
+ moreEntries = false
+ } else if err != nil {
+ ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+
+ // Add a link header if there are more entries to retrieve
+ if moreEntries {
+ lastEntry = repos[len(repos)-1]
+ urlStr, err := createLinkEntry(r.URL.String(), maxEntries, lastEntry)
+ if err != nil {
+ ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
+ return
+ }
+ w.Header().Set("Link", urlStr)
+ }
+
+ enc := json.NewEncoder(w)
+ if err := enc.Encode(catalogAPIResponse{
+ Repositories: repos[0:filled],
+ }); err != nil {
+ ch.Errors = append(ch.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
+ return
+ }
+}
+
+// Use the original URL from the request to create a new URL for
+// the link header
+func createLinkEntry(origURL string, maxEntries int, lastEntry string) (string, error) {
+ calledURL, err := url.Parse(origURL)
+ if err != nil {
+ return "", err
+ }
+
+ v := url.Values{}
+ v.Add("n", strconv.Itoa(maxEntries))
+ v.Add("last", lastEntry)
+
+ calledURL.RawQuery = v.Encode()
+
+ calledURL.Fragment = ""
+ urlStr := fmt.Sprintf("<%s>; rel=\"next\"", calledURL.String())
+
+ return urlStr, nil
+}
diff --git a/registry/storage/catalog.go b/registry/storage/catalog.go
new file mode 100644
index 000000000..470894b71
--- /dev/null
+++ b/registry/storage/catalog.go
@@ -0,0 +1,65 @@
+package storage
+
+import (
+ "errors"
+ "io"
+ "path"
+ "sort"
+ "strings"
+
+ "github.com/docker/distribution/context"
+ "github.com/docker/distribution/registry/storage/driver"
+)
+
+// Returns a list, or partial list, of repositories in the registry.
+// Because it's a quite expensive operation, it should only be used when building up
+// an initial set of repositories.
+func (reg *registry) Repositories(ctx context.Context, repos []string, last string) (n int, err error) {
+ var foundRepos []string
+ var errVal error
+
+ if len(repos) == 0 {
+ return 0, errors.New("no space in slice")
+ }
+
+ root, err := defaultPathMapper.path(repositoriesRootPathSpec{})
+ if err != nil {
+ return 0, err
+ }
+
+ // Walk each of the directories in our storage. Unfortunately since there's no
+ // guarantee that storage will return files in lexigraphical order, we have
+ // to store everything another slice, sort it and then copy it back to our
+ // passed in slice.
+
+ Walk(ctx, reg.blobStore.driver, root, func(fileInfo driver.FileInfo) error {
+ filePath := fileInfo.Path()
+
+ // lop the base path off
+ repoPath := filePath[len(root)+1:]
+
+ _, file := path.Split(repoPath)
+ if file == "_layers" {
+ repoPath = strings.TrimSuffix(repoPath, "/_layers")
+ if repoPath > last {
+ foundRepos = append(foundRepos, repoPath)
+ }
+ return ErrSkipDir
+ } else if strings.HasPrefix(file, "_") {
+ return ErrSkipDir
+ }
+
+ return nil
+ })
+
+ sort.Strings(foundRepos)
+ n = copy(repos, foundRepos)
+
+ // Signal that we have no more entries by setting EOF
+ if len(foundRepos) <= len(repos) {
+ errVal = io.EOF
+ }
+
+ return n, errVal
+
+}
diff --git a/registry/storage/catalog_test.go b/registry/storage/catalog_test.go
new file mode 100644
index 000000000..a9a046a77
--- /dev/null
+++ b/registry/storage/catalog_test.go
@@ -0,0 +1,122 @@
+package storage
+
+import (
+ "io"
+ "testing"
+
+ "github.com/docker/distribution"
+ "github.com/docker/distribution/context"
+ "github.com/docker/distribution/registry/storage/cache/memory"
+ "github.com/docker/distribution/registry/storage/driver"
+ "github.com/docker/distribution/registry/storage/driver/inmemory"
+)
+
+type setupEnv struct {
+ ctx context.Context
+ driver driver.StorageDriver
+ expected []string
+ registry distribution.Namespace
+}
+
+func setupFS(t *testing.T) *setupEnv {
+ d := inmemory.New()
+ c := []byte("")
+ ctx := context.Background()
+ registry := NewRegistryWithDriver(ctx, d, memory.NewInMemoryBlobDescriptorCacheProvider())
+ rootpath, _ := defaultPathMapper.path(repositoriesRootPathSpec{})
+
+ repos := []string{
+ "/foo/a/_layers/1",
+ "/foo/b/_layers/2",
+ "/bar/c/_layers/3",
+ "/bar/d/_layers/4",
+ "/foo/d/in/_layers/5",
+ "/an/invalid/repo",
+ "/bar/d/_layers/ignored/dir/6",
+ }
+
+ for _, repo := range repos {
+ if err := d.PutContent(ctx, rootpath+repo, c); err != nil {
+ t.Fatalf("Unable to put to inmemory fs")
+ }
+ }
+
+ expected := []string{
+ "bar/c",
+ "bar/d",
+ "foo/a",
+ "foo/b",
+ "foo/d/in",
+ }
+
+ return &setupEnv{
+ ctx: ctx,
+ driver: d,
+ expected: expected,
+ registry: registry,
+ }
+}
+
+func TestCatalog(t *testing.T) {
+ env := setupFS(t)
+
+ p := make([]string, 50)
+
+ numFilled, err := env.registry.Repositories(env.ctx, p, "")
+
+ if !testEq(p, env.expected, numFilled) {
+ t.Errorf("Expected catalog repos err")
+ }
+
+ if err != io.EOF {
+ t.Errorf("Catalog has more values which we aren't expecting")
+ }
+}
+
+func TestCatalogInParts(t *testing.T) {
+ env := setupFS(t)
+
+ chunkLen := 2
+ p := make([]string, chunkLen)
+
+ numFilled, err := env.registry.Repositories(env.ctx, p, "")
+ if err == io.EOF || numFilled != len(p) {
+ t.Errorf("Expected more values in catalog")
+ }
+
+ if !testEq(p, env.expected[0:chunkLen], numFilled) {
+ t.Errorf("Expected catalog first chunk err")
+ }
+
+ lastRepo := p[len(p)-1]
+ numFilled, err = env.registry.Repositories(env.ctx, p, lastRepo)
+
+ if err == io.EOF || numFilled != len(p) {
+ t.Errorf("Expected more values in catalog")
+ }
+
+ if !testEq(p, env.expected[chunkLen:chunkLen*2], numFilled) {
+ t.Errorf("Expected catalog second chunk err")
+ }
+
+ lastRepo = p[len(p)-1]
+ numFilled, err = env.registry.Repositories(env.ctx, p, lastRepo)
+
+ if err != io.EOF {
+ t.Errorf("Catalog has more values which we aren't expecting")
+ }
+
+ if !testEq(p, env.expected[chunkLen*2:chunkLen*3-1], numFilled) {
+ t.Errorf("Expected catalog third chunk err")
+ }
+
+}
+
+func testEq(a, b []string, size int) bool {
+ for cnt := 0; cnt < size-1; cnt++ {
+ if a[cnt] != b[cnt] {
+ return false
+ }
+ }
+ return true
+}