diff --git a/remotes/docker/authorizer.go b/remotes/docker/authorizer.go index 3097cc3b5..bec9b7a0c 100644 --- a/remotes/docker/authorizer.go +++ b/remotes/docker/authorizer.go @@ -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 +} - var token string +// 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{}, + } +} + +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 } diff --git a/remotes/docker/handler.go b/remotes/docker/handler.go index 1a355783b..529cfbc27 100644 --- a/remotes/docker/handler.go +++ b/remotes/docker/handler.go @@ -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 +} diff --git a/remotes/docker/pusher.go b/remotes/docker/pusher.go index c3c0923f0..af8a77427 100644 --- a/remotes/docker/pusher.go +++ b/remotes/docker/pusher.go @@ -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) - if err != nil { - return nil, err + 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 +} diff --git a/remotes/docker/scope.go b/remotes/docker/scope.go index 52c244311..86bd81bf5 100644 --- a/remotes/docker/scope.go +++ b/remotes/docker/scope.go @@ -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 { - // 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 - } - } - scopes = append(scopes, scope) - } -Sort: + + scopes = append(scopes, common...) sort.Strings(scopes) - return 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 scopes[l] == scopes[idx] { + continue + } + + l++ + scopes[l] = scopes[idx] + } + return scopes[:l+1] } diff --git a/remotes/docker/scope_test.go b/remotes/docker/scope_test.go index 340e20506..938129f46 100644 --- a/remotes/docker/scope_test.go +++ b/remotes/docker/scope_test.go @@ -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) + } +} diff --git a/remotes/handlers.go b/remotes/handlers.go index 0ee56c887..066794c19 100644 --- a/remotes/handlers.go +++ b/remotes/handlers.go @@ -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 + } +}