This commit is contained in:
Jim Chen 2025-06-03 18:00:27 +08:00 committed by GitHub
commit d8f25540bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 152 additions and 51 deletions

View File

@ -350,7 +350,11 @@ func (t *tags) Lookup(ctx context.Context, digest v1.Descriptor) ([]string, erro
panic("not implemented")
}
func (t *tags) Tag(ctx context.Context, tag string, desc v1.Descriptor) error {
func (t *tags) List(ctx context.Context, limit int, last string) ([]string, error) {
panic("not implemented")
}
func (t *tags) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error {
panic("not implemented")
}

View File

@ -564,9 +564,11 @@ func TestTagsAPI(t *testing.T) {
queryParams: url.Values{"last": []string{"does-not-exist"}, "n": []string{"3"}},
expectedStatusCode: http.StatusOK,
expectedBody: tagsAPIResponse{Name: imageName.Name(), Tags: []string{
"jyi7b",
"kb0j5",
"sb71y",
}},
expectedLinkHeader: `</v2/test/tags/list?last=sb71y&n=3>; rel="next"`,
},
}

View File

@ -2,8 +2,8 @@ package handlers
import (
"encoding/json"
"io"
"net/http"
"sort"
"strconv"
"github.com/distribution/distribution/v3"
@ -34,9 +34,34 @@ type tagsAPIResponse struct {
// GetTags returns a json list of tags for a specific image name.
func (th *tagsHandler) GetTags(w http.ResponseWriter, r *http.Request) {
var moreEntries = true
q := r.URL.Query()
lastEntry := q.Get("last")
limit := -1
// parse n, if n unparseable, or negative assign it to defaultReturnedEntries
if n := q.Get("n"); n != "" {
parsedMax, err := strconv.Atoi(n)
if err != nil || parsedMax < 0 {
th.Errors = append(th.Errors, errcode.ErrorCodePaginationNumberInvalid.WithDetail(map[string]int{"n": parsedMax}))
return
}
limit = parsedMax
}
filled := make([]string, 0)
if limit == 0 {
moreEntries = false
} else {
tagService := th.Repository.Tags(th)
tags, err := tagService.All(th)
// if limit is -1, we want to list all the tags, and receive a io.EOF error
returnedTags, err := tagService.List(th.Context, limit, lastEntry)
if err != nil {
if err != io.EOF {
switch err := err.(type) {
case distribution.ErrRepositoryUnknown:
th.Errors = append(th.Errors, errcode.ErrorCodeNameUnknown.WithDetail(map[string]string{"name": th.Repository.Named().Name()}))
@ -47,42 +72,18 @@ func (th *tagsHandler) GetTags(w http.ResponseWriter, r *http.Request) {
}
return
}
// do pagination if requested
q := r.URL.Query()
// get entries after latest, if any specified
if lastEntry := q.Get("last"); lastEntry != "" {
lastEntryIndex := sort.SearchStrings(tags, lastEntry)
// as`sort.SearchStrings` can return len(tags), if the
// specified `lastEntry` is not found, we need to
// ensure it does not panic when slicing.
if lastEntryIndex == len(tags) {
tags = []string{}
} else {
tags = tags[lastEntryIndex+1:]
// err is either io.EOF
moreEntries = false
}
filled = returnedTags
}
// if no error, means that the user requested `n` entries
if n := q.Get("n"); n != "" {
maxEntries, err := strconv.Atoi(n)
if err != nil || maxEntries < 0 {
th.Errors = append(th.Errors, errcode.ErrorCodePaginationNumberInvalid.WithDetail(map[string]string{"n": n}))
return
}
w.Header().Set("Content-Type", "application/json")
// if there is requested more than or
// equal to the amount of tags we have,
// then set the request to equal `len(tags)`.
// the reason for the `=`, is so the else
// clause will only activate if there
// are tags left the user needs.
if maxEntries >= len(tags) {
maxEntries = len(tags)
} else if maxEntries > 0 {
// defined in `catalog.go`
urlStr, err := createLinkEntry(r.URL.String(), maxEntries, tags[maxEntries-1])
// Add a link header if there are more entries to retrieve
if moreEntries {
lastEntry = filled[len(filled)-1]
urlStr, err := createLinkEntry(r.URL.String(), limit, lastEntry)
if err != nil {
th.Errors = append(th.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
return
@ -90,15 +91,10 @@ func (th *tagsHandler) GetTags(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Link", urlStr)
}
tags = tags[:maxEntries]
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
if err := enc.Encode(tagsAPIResponse{
Name: th.Repository.Named().Name(),
Tags: tags,
Tags: filled,
}); err != nil {
th.Errors = append(th.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
return

View File

@ -65,3 +65,7 @@ func (pt proxyTagService) All(ctx context.Context) ([]string, error) {
func (pt proxyTagService) Lookup(ctx context.Context, digest v1.Descriptor) ([]string, error) {
return []string{}, distribution.ErrUnsupported
}
func (pt proxyTagService) List(ctx context.Context, limit int, last string) ([]string, error) {
return []string{}, distribution.ErrUnsupported
}

View File

@ -58,7 +58,14 @@ func (m *mockTagStore) All(ctx context.Context) ([]string, error) {
return tags, nil
}
func testProxyTagService(local, remote map[string]v1.Descriptor) *proxyTagService {
func (m *mockTagStore) Lookup(ctx context.Context, digest distribution.Descriptor) ([]string, error) {
panic("not implemented")
}
func (m *mockTagStore) List(ctx context.Context, limit int, last string) ([]string, error) {
panic("not implemented")
}
func testProxyTagService(local, remote map[string]distribution.Descriptor) *proxyTagService {
if local == nil {
local = make(map[string]v1.Descriptor)
}

View File

@ -2,8 +2,11 @@ package storage
import (
"context"
"errors"
"io"
"path"
"sort"
"strings"
"sync"
"github.com/opencontainers/go-digest"
@ -230,3 +233,85 @@ func (ts *tagStore) ManifestDigests(ctx context.Context, tag string) ([]digest.D
}
return dgsts, nil
}
// List returns the tags for the repository.
func (ts *tagStore) List(ctx context.Context, limit int, last string) ([]string, error) {
filledBuffer := false
foundTags := 0
var tags []string
if limit == 0 {
return tags, errors.New("attempted to list 0 tags")
}
root, err := pathFor(manifestTagsPathSpec{
name: ts.repository.Named().Name(),
})
if err != nil {
return tags, err
}
startAfter := ""
if last != "" {
startAfter, err = pathFor(manifestTagPathSpec{
name: ts.repository.Named().Name(),
tag: last,
})
if err != nil {
return tags, err
}
}
err = ts.blobStore.driver.Walk(ctx, root, func(fileInfo storagedriver.FileInfo) error {
return handleTag(fileInfo, root, last, func(tagPath string) error {
tags = append(tags, tagPath)
foundTags += 1
// if we've filled our slice, no need to walk any further
if limit > 0 && foundTags == limit {
filledBuffer = true
return storagedriver.ErrFilledBuffer
}
return nil
})
}, storagedriver.WithStartAfterHint(startAfter))
if err != nil {
switch err := err.(type) {
case storagedriver.PathNotFoundError:
return tags, distribution.ErrRepositoryUnknown{Name: ts.repository.Named().Name()}
default:
return tags, err
}
}
if filledBuffer {
// There are potentially more tags to list
return tags, nil
}
// We didn't fill the buffer, so that's the end of the list of tags
return tags, io.EOF
}
// handleTag calls function fn with a tag path if fileInfo
// has a path of a tag under root and that it is lexographically
// after last. Otherwise, it will return ErrSkipDir or ErrFilledBuffer.
// These should be used with Walk to do handling with repositories in a
// storage.
func handleTag(fileInfo storagedriver.FileInfo, root, last string, fn func(tagPath string) error) error {
filePath := fileInfo.Path()
// lop the base path off
tag := filePath[len(root)+1:]
parts := strings.SplitN(tag, "/", 2)
if len(parts) > 1 {
return storagedriver.ErrSkipDir
}
if lessPath(last, tag) {
if err := fn(tag); err != nil {
return err
}
}
return storagedriver.ErrSkipDir
}

View File

@ -27,6 +27,9 @@ type TagService interface {
// Lookup returns the set of tags referencing the given digest.
Lookup(ctx context.Context, digest v1.Descriptor) ([]string, error)
// List returns the set of tags after last managed by this tag service
List(ctx context.Context, limit int, last string) ([]string, error)
}
// TagManifestsProvider provides method to retrieve the digests of manifests that a tag historically