remotes: support cross-repo-push

With distribution source label in content store, select the longest
common prefix components as condidate mount blob source and try to push
with mount blob.

Fix #2964

Signed-off-by: Wei Fu <fuweid89@gmail.com>
This commit is contained in:
Wei Fu 2019-04-17 17:31:03 +08:00
parent 545e79ae11
commit dd7c0aabcc
6 changed files with 398 additions and 88 deletions

View File

@ -44,7 +44,8 @@ type dockerAuthorizer struct {
ua string
mu sync.Mutex
auth map[string]string
// indexed by host name
handlers map[string]*authHandler
}
// NewAuthorizer creates a Docker authorizer using the provided function to
@ -53,116 +54,226 @@ func NewAuthorizer(client *http.Client, f func(string) (string, string, error))
if client == nil {
client = http.DefaultClient
}
return &dockerAuthorizer{
credentials: f,
client: client,
ua: "containerd/" + version.Version,
auth: map[string]string{},
handlers: make(map[string]*authHandler),
}
}
// Authorize handles auth request.
func (a *dockerAuthorizer) Authorize(ctx context.Context, req *http.Request) error {
// TODO: Lookup matching challenge and scope rather than just host
if auth := a.getAuth(req.URL.Host); auth != "" {
req.Header.Set("Authorization", auth)
// skip if there is no auth handler
ah := a.getAuthHandler(req.URL.Host)
if ah == nil {
return nil
}
auth, err := ah.authorize(ctx)
if err != nil {
return err
}
req.Header.Set("Authorization", auth)
return nil
}
func (a *dockerAuthorizer) getAuthHandler(host string) *authHandler {
a.mu.Lock()
defer a.mu.Unlock()
return a.handlers[host]
}
func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.Response) error {
last := responses[len(responses)-1]
host := last.Request.URL.Host
a.mu.Lock()
defer a.mu.Unlock()
for _, c := range parseAuthHeader(last.Header) {
if c.scheme == bearerAuth {
if err := invalidAuthorization(c, responses); err != nil {
// TODO: Clear token
a.setAuth(host, "")
delete(a.handlers, host)
return err
}
// TODO(dmcg): Store challenge, not token
// Move token fetching to authorize
return a.setTokenAuth(ctx, host, c.parameters)
// reuse existing handler
//
// assume that one registry will return the common
// challenge information, including realm and service.
// and the resource scope is only different part
// which can be provided by each request.
if _, ok := a.handlers[host]; ok {
return nil
}
common, err := a.generateTokenOptions(ctx, host, c)
if err != nil {
return err
}
a.handlers[host] = newAuthHandler(a.client, a.ua, c.scheme, common)
return nil
} else if c.scheme == basicAuth && a.credentials != nil {
// TODO: Resolve credentials on authorize
username, secret, err := a.credentials(host)
if err != nil {
return err
}
if username != "" && secret != "" {
auth := username + ":" + secret
a.setAuth(host, fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(auth))))
common := tokenOptions{
username: username,
secret: secret,
}
a.handlers[host] = newAuthHandler(a.client, a.ua, c.scheme, common)
return nil
}
}
}
return errors.Wrap(errdefs.ErrNotImplemented, "failed to find supported auth scheme")
}
func (a *dockerAuthorizer) getAuth(host string) string {
a.mu.Lock()
defer a.mu.Unlock()
return a.auth[host]
}
func (a *dockerAuthorizer) setAuth(host string, auth string) bool {
a.mu.Lock()
defer a.mu.Unlock()
changed := a.auth[host] != auth
a.auth[host] = auth
return changed
}
func (a *dockerAuthorizer) setTokenAuth(ctx context.Context, host string, params map[string]string) error {
realm, ok := params["realm"]
func (a *dockerAuthorizer) generateTokenOptions(ctx context.Context, host string, c challenge) (tokenOptions, error) {
realm, ok := c.parameters["realm"]
if !ok {
return errors.New("no realm specified for token auth challenge")
return tokenOptions{}, errors.New("no realm specified for token auth challenge")
}
realmURL, err := url.Parse(realm)
if err != nil {
return errors.Wrap(err, "invalid token auth challenge realm")
return tokenOptions{}, errors.Wrap(err, "invalid token auth challenge realm")
}
to := tokenOptions{
realm: realmURL.String(),
service: params["service"],
service: c.parameters["service"],
}
to.scopes = getTokenScopes(ctx, params)
if len(to.scopes) == 0 {
return errors.Errorf("no scope specified for token auth challenge")
scope, ok := c.parameters["scope"]
if !ok {
return tokenOptions{}, errors.Errorf("no scope specified for token auth challenge")
}
to.scopes = append(to.scopes, scope)
if a.credentials != nil {
to.username, to.secret, err = a.credentials(host)
if err != nil {
return err
return tokenOptions{}, err
}
}
return to, nil
}
// authResult is used to control limit rate.
type authResult struct {
sync.WaitGroup
token string
err error
}
// authHandler is used to handle auth request per registry server.
type authHandler struct {
sync.Mutex
ua string
client *http.Client
// only support basic and bearer schemes
scheme authenticationScheme
// common contains common challenge answer
common tokenOptions
// scopedTokens caches token indexed by scopes, which used in
// bearer auth case
scopedTokens map[string]*authResult
}
func newAuthHandler(client *http.Client, ua string, scheme authenticationScheme, opts tokenOptions) *authHandler {
if client == nil {
client = http.DefaultClient
}
return &authHandler{
ua: ua,
client: client,
scheme: scheme,
common: opts,
scopedTokens: map[string]*authResult{},
}
}
var token string
func (ah *authHandler) authorize(ctx context.Context) (string, error) {
switch ah.scheme {
case basicAuth:
return ah.doBasicAuth(ctx)
case bearerAuth:
return ah.doBearerAuth(ctx)
default:
return "", errors.Wrap(errdefs.ErrNotImplemented, "failed to find supported auth scheme")
}
}
func (ah *authHandler) doBasicAuth(ctx context.Context) (string, error) {
username, secret := ah.common.username, ah.common.secret
if username == "" || secret == "" {
return "", fmt.Errorf("failed to handle basic auth because missing username or secret")
}
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + secret))
return fmt.Sprintf("%s %s", "Basic", auth), nil
}
func (ah *authHandler) doBearerAuth(ctx context.Context) (string, error) {
// copy common tokenOptions
to := ah.common
to.scopes = getTokenScopes(ctx, to.scopes)
if len(to.scopes) == 0 {
return "", errors.Errorf("no scope specified for token auth challenge")
}
// Docs: https://docs.docker.com/registry/spec/auth/scope
scoped := strings.Join(to.scopes, " ")
ah.Lock()
if r, exist := ah.scopedTokens[scoped]; exist {
ah.Unlock()
r.Wait()
return r.token, r.err
}
// only one fetch token job
r := new(authResult)
r.Add(1)
ah.scopedTokens[scoped] = r
ah.Unlock()
// fetch token for the resource scope
var (
token string
err error
)
if to.secret != "" {
// Credential information is provided, use oauth POST endpoint
token, err = a.fetchTokenWithOAuth(ctx, to)
if err != nil {
return errors.Wrap(err, "failed to fetch oauth token")
}
// credential information is provided, use oauth POST endpoint
token, err = ah.fetchTokenWithOAuth(ctx, to)
err = errors.Wrap(err, "failed to fetch oauth token")
} else {
// Do request anonymously
token, err = a.fetchToken(ctx, to)
if err != nil {
return errors.Wrap(err, "failed to fetch anonymous token")
// do request anonymously
token, err = ah.fetchToken(ctx, to)
err = errors.Wrap(err, "failed to fetch anonymous token")
}
}
a.setAuth(host, fmt.Sprintf("Bearer %s", token))
token = fmt.Sprintf("%s %s", "Bearer", token)
return nil
r.token, r.err = token, err
r.Done()
return r.token, r.err
}
type tokenOptions struct {
@ -181,7 +292,7 @@ type postTokenResponse struct {
Scope string `json:"scope"`
}
func (a *dockerAuthorizer) fetchTokenWithOAuth(ctx context.Context, to tokenOptions) (string, error) {
func (ah *authHandler) fetchTokenWithOAuth(ctx context.Context, to tokenOptions) (string, error) {
form := url.Values{}
form.Set("scope", strings.Join(to.scopes, " "))
form.Set("service", to.service)
@ -202,11 +313,11 @@ func (a *dockerAuthorizer) fetchTokenWithOAuth(ctx context.Context, to tokenOpti
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
if a.ua != "" {
req.Header.Set("User-Agent", a.ua)
if ah.ua != "" {
req.Header.Set("User-Agent", ah.ua)
}
resp, err := ctxhttp.Do(ctx, a.client, req)
resp, err := ctxhttp.Do(ctx, ah.client, req)
if err != nil {
return "", err
}
@ -216,7 +327,7 @@ func (a *dockerAuthorizer) fetchTokenWithOAuth(ctx context.Context, to tokenOpti
// As of September 2017, GCR is known to return 404.
// As of February 2018, JFrog Artifactory is known to return 401.
if (resp.StatusCode == 405 && to.username != "") || resp.StatusCode == 404 || resp.StatusCode == 401 {
return a.fetchToken(ctx, to)
return ah.fetchToken(ctx, to)
} else if resp.StatusCode < 200 || resp.StatusCode >= 400 {
b, _ := ioutil.ReadAll(io.LimitReader(resp.Body, 64000)) // 64KB
log.G(ctx).WithFields(logrus.Fields{
@ -245,15 +356,15 @@ type getTokenResponse struct {
RefreshToken string `json:"refresh_token"`
}
// getToken fetches a token using a GET request
func (a *dockerAuthorizer) fetchToken(ctx context.Context, to tokenOptions) (string, error) {
// fetchToken fetches a token using a GET request
func (ah *authHandler) fetchToken(ctx context.Context, to tokenOptions) (string, error) {
req, err := http.NewRequest("GET", to.realm, nil)
if err != nil {
return "", err
}
if a.ua != "" {
req.Header.Set("User-Agent", a.ua)
if ah.ua != "" {
req.Header.Set("User-Agent", ah.ua)
}
reqParams := req.URL.Query()
@ -272,7 +383,7 @@ func (a *dockerAuthorizer) fetchToken(ctx context.Context, to tokenOptions) (str
req.URL.RawQuery = reqParams.Encode()
resp, err := ctxhttp.Do(ctx, a.client, req)
resp, err := ctxhttp.Do(ctx, ah.client, req)
if err != nil {
return "", err
}

View File

@ -110,3 +110,45 @@ func appendDistributionSourceLabel(originLabel, repo string) string {
func distributionSourceLabelKey(source string) string {
return fmt.Sprintf("%s.%s", labelDistributionSource, source)
}
// selectRepositoryMountCandidate will select the repo which has longest
// common prefix components as the candidate.
func selectRepositoryMountCandidate(refspec reference.Spec, sources map[string]string) string {
u, err := url.Parse("dummy://" + refspec.Locator)
if err != nil {
// NOTE: basically, it won't be error here
return ""
}
source, target := u.Hostname(), strings.TrimPrefix(u.Path, "/")
repoLabel, ok := sources[distributionSourceLabelKey(source)]
if !ok || repoLabel == "" {
return ""
}
n, match := 0, ""
components := strings.Split(target, "/")
for _, repo := range strings.Split(repoLabel, ",") {
// the target repo is not a candidate
if repo == target {
continue
}
if l := commonPrefixComponents(components, repo); l >= n {
n, match = l, repo
}
}
return match
}
func commonPrefixComponents(components []string, target string) int {
targetComponents := strings.Split(target, "/")
i := 0
for ; i < len(components) && i < len(targetComponents); i++ {
if components[i] != targetComponents[i] {
break
}
}
return i
}

View File

@ -116,8 +116,6 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten
}
}
// TODO: Lookup related objects for cross repository push
if isManifest {
var putPath string
if p.tag != "" {
@ -132,21 +130,57 @@ func (p dockerPusher) Push(ctx context.Context, desc ocispec.Descriptor) (conten
}
req.Header.Add("Content-Type", desc.MediaType)
} else {
// TODO: Do monolithic upload if size is small
// Start upload request
req, err = http.NewRequest(http.MethodPost, p.url("blobs", "uploads")+"/", nil)
if err != nil {
return nil, err
}
resp, err := p.doRequestWithRetries(ctx, req, nil)
var resp *http.Response
if fromRepo := selectRepositoryMountCandidate(p.refspec, desc.Annotations); fromRepo != "" {
req = requestWithMountFrom(req, desc.Digest.String(), fromRepo)
pctx := contextWithAppendPullRepositoryScope(ctx, fromRepo)
// NOTE: the fromRepo might be private repo and
// auth service still can grant token without error.
// but the post request will fail because of 401.
//
// for the private repo, we should remove mount-from
// query and send the request again.
resp, err = p.doRequest(pctx, req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusUnauthorized {
log.G(ctx).Debugf("failed to mount from repository %s", fromRepo)
resp.Body.Close()
resp = nil
req, err = removeMountFromQuery(req)
if err != nil {
return nil, err
}
}
}
if resp == nil {
resp, err = p.doRequestWithRetries(ctx, req, nil)
if err != nil {
return nil, err
}
}
switch resp.StatusCode {
case http.StatusOK, http.StatusAccepted, http.StatusNoContent:
case http.StatusCreated:
p.tracker.SetStatus(ref, Status{
Status: content.Status{
Ref: ref,
},
})
return nil, errors.Wrapf(errdefs.ErrAlreadyExists, "content %v on remote", desc.Digest)
default:
// TODO: log error
return nil, errors.Errorf("unexpected response: %s", resp.Status)
@ -320,3 +354,25 @@ func (pw *pushWriter) Truncate(size int64) error {
// TODO: always error on manifest
return errors.New("cannot truncate remote upload")
}
func requestWithMountFrom(req *http.Request, mount, from string) *http.Request {
q := req.URL.Query()
q.Set("mount", mount)
q.Set("from", from)
req.URL.RawQuery = q.Encode()
return req
}
func removeMountFromQuery(req *http.Request) (*http.Request, error) {
req, err := copyRequest(req)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Del("mount")
q.Del("from")
req.URL.RawQuery = q.Encode()
return req, nil
}

View File

@ -18,6 +18,7 @@ package docker
import (
"context"
"fmt"
"net/url"
"sort"
"strings"
@ -53,24 +54,38 @@ func contextWithRepositoryScope(ctx context.Context, refspec reference.Spec, pus
return context.WithValue(ctx, tokenScopesKey{}, []string{s}), nil
}
// getTokenScopes returns deduplicated and sorted scopes from ctx.Value(tokenScopesKey{}) and params["scope"].
func getTokenScopes(ctx context.Context, params map[string]string) []string {
// contextWithAppendPullRepositoryScope is used to append repository pull
// scope into existing scopes indexed by the tokenScopesKey{}.
func contextWithAppendPullRepositoryScope(ctx context.Context, repo string) context.Context {
var scopes []string
if v := ctx.Value(tokenScopesKey{}); v != nil {
scopes = append(scopes, v.([]string)...)
}
scopes = append(scopes, fmt.Sprintf("repository:%s:pull", repo))
return context.WithValue(ctx, tokenScopesKey{}, scopes)
}
// getTokenScopes returns deduplicated and sorted scopes from ctx.Value(tokenScopesKey{}) and common scopes.
func getTokenScopes(ctx context.Context, common []string) []string {
var scopes []string
if x := ctx.Value(tokenScopesKey{}); x != nil {
scopes = append(scopes, x.([]string)...)
}
if scope, ok := params["scope"]; ok {
for _, s := range scopes {
scopes = append(scopes, common...)
sort.Strings(scopes)
l := 0
for idx := 1; idx < len(scopes); idx++ {
// Note: this comparison is unaware of the scope grammar (https://docs.docker.com/registry/spec/auth/scope/)
// So, "repository:foo/bar:pull,push" != "repository:foo/bar:push,pull", although semantically they are equal.
if s == scope {
// already appended
goto Sort
if scopes[l] == scopes[idx] {
continue
}
l++
scopes[l] = scopes[idx]
}
scopes = append(scopes, scope)
}
Sort:
sort.Strings(scopes)
return scopes
return scopes[:l+1]
}

View File

@ -17,6 +17,7 @@
package docker
import (
"context"
"testing"
"github.com/containerd/containerd/reference"
@ -54,3 +55,42 @@ func TestRepositoryScope(t *testing.T) {
})
}
}
func TestGetTokenScopes(t *testing.T) {
testCases := []struct {
scopesInCtx []string
commonScopes []string
expected []string
}{
{
scopesInCtx: []string{},
commonScopes: []string{"repository:foo/bar:pull"},
expected: []string{"repository:foo/bar:pull"},
},
{
scopesInCtx: []string{"repository:foo/bar:pull,push"},
commonScopes: []string{},
expected: []string{"repository:foo/bar:pull,push"},
},
{
scopesInCtx: []string{"repository:foo/bar:pull"},
commonScopes: []string{"repository:foo/bar:pull"},
expected: []string{"repository:foo/bar:pull"},
},
{
scopesInCtx: []string{"repository:foo/bar:pull"},
commonScopes: []string{"repository:foo/bar:pull,push"},
expected: []string{"repository:foo/bar:pull", "repository:foo/bar:pull,push"},
},
{
scopesInCtx: []string{"repository:foo/bar:pull"},
commonScopes: []string{"repository:foo/bar:pull,push", "repository:foo/bar:pull"},
expected: []string{"repository:foo/bar:pull", "repository:foo/bar:pull,push"},
},
}
for _, tc := range testCases {
ctx := context.WithValue(context.TODO(), tokenScopesKey{}, tc.scopesInCtx)
actual := getTokenScopes(ctx, tc.commonScopes)
assert.DeepEqual(t, tc.expected, actual)
}
}

View File

@ -156,7 +156,7 @@ func push(ctx context.Context, provider content.Provider, pusher Pusher, desc oc
//
// Base handlers can be provided which will be called before any push specific
// handlers.
func PushContent(ctx context.Context, pusher Pusher, desc ocispec.Descriptor, provider content.Provider, platform platforms.MatchComparer, wrapper func(h images.Handler) images.Handler) error {
func PushContent(ctx context.Context, pusher Pusher, desc ocispec.Descriptor, store content.Store, platform platforms.MatchComparer, wrapper func(h images.Handler) images.Handler) error {
var m sync.Mutex
manifestStack := []ocispec.Descriptor{}
@ -173,10 +173,14 @@ func PushContent(ctx context.Context, pusher Pusher, desc ocispec.Descriptor, pr
}
})
pushHandler := PushHandler(pusher, provider)
pushHandler := PushHandler(pusher, store)
platformFilterhandler := images.FilterPlatforms(images.ChildrenHandler(store), platform)
annotateHandler := annotateDistributionSourceHandler(platformFilterhandler, store)
var handler images.Handler = images.Handlers(
images.FilterPlatforms(images.ChildrenHandler(provider), platform),
annotateHandler,
filterHandler,
pushHandler,
)
@ -241,3 +245,45 @@ func FilterManifestByPlatformHandler(f images.HandlerFunc, m platforms.Matcher)
return descs, nil
}
}
// annotateDistributionSourceHandler add distribution source label into
// annotation of config or blob descriptor.
func annotateDistributionSourceHandler(f images.HandlerFunc, manager content.Manager) images.HandlerFunc {
return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
children, err := f(ctx, desc)
if err != nil {
return nil, err
}
// only add distribution source for the config or blob data descriptor
switch desc.MediaType {
case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest,
images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex:
default:
return children, nil
}
for i := range children {
child := children[i]
info, err := manager.Info(ctx, child.Digest)
if err != nil {
return nil, err
}
for k, v := range info.Labels {
if !strings.HasPrefix(k, "containerd.io/distribution.source.") {
continue
}
if child.Annotations == nil {
child.Annotations = map[string]string{}
}
child.Annotations[k] = v
}
children[i] = child
}
return children, nil
}
}