Browse code

Compare V2Metadata with associated auth config

to avoid unnecessary blob re-uploads.

Cross repository mount from particular repo will most probably fail if
the user pushing to the registry is not the same as the one who pulled
or pushed to the source repo.

This PR attempts first to cross-repo mount from the source repositories
associated with the pusher's auth config. Then it falls back to other
repositories sorted from the most similar to the target repo to the
least.

It also prevents metadata deletion in cases where cross-repo mount fails
and the auth config hashes differ.

Signed-off-by: Michal Minář <miminar@redhat.com>

Michal Minář authored on 2016/09/18 17:55:28
Showing 3 changed files
... ...
@@ -1,9 +1,13 @@
1 1
 package metadata
2 2
 
3 3
 import (
4
+	"crypto/hmac"
5
+	"crypto/sha256"
6
+	"encoding/hex"
4 7
 	"encoding/json"
5 8
 
6 9
 	"github.com/docker/distribution/digest"
10
+	"github.com/docker/docker/api/types"
7 11
 	"github.com/docker/docker/layer"
8 12
 )
9 13
 
... ...
@@ -17,6 +21,69 @@ type V2MetadataService struct {
17 17
 type V2Metadata struct {
18 18
 	Digest           digest.Digest
19 19
 	SourceRepository string
20
+	// HMAC hashes above attributes with recent authconfig digest used as a key in order to determine matching
21
+	// metadata entries accompanied by the same credentials without actually exposing them.
22
+	HMAC string
23
+}
24
+
25
+// CheckV2MetadataHMAC return true if the given "meta" is tagged with a hmac hashed by the given "key".
26
+func CheckV2MetadataHMAC(meta *V2Metadata, key []byte) bool {
27
+	if len(meta.HMAC) == 0 || len(key) == 0 {
28
+		return len(meta.HMAC) == 0 && len(key) == 0
29
+	}
30
+	mac := hmac.New(sha256.New, key)
31
+	mac.Write([]byte(meta.Digest))
32
+	mac.Write([]byte(meta.SourceRepository))
33
+	expectedMac := mac.Sum(nil)
34
+
35
+	storedMac, err := hex.DecodeString(meta.HMAC)
36
+	if err != nil {
37
+		return false
38
+	}
39
+
40
+	return hmac.Equal(storedMac, expectedMac)
41
+}
42
+
43
+// ComputeV2MetadataHMAC returns a hmac for the given "meta" hash by the given key.
44
+func ComputeV2MetadataHMAC(key []byte, meta *V2Metadata) string {
45
+	if len(key) == 0 || meta == nil {
46
+		return ""
47
+	}
48
+	mac := hmac.New(sha256.New, key)
49
+	mac.Write([]byte(meta.Digest))
50
+	mac.Write([]byte(meta.SourceRepository))
51
+	return hex.EncodeToString(mac.Sum(nil))
52
+}
53
+
54
+// ComputeV2MetadataHMACKey returns a key for the given "authConfig" that can be used to hash v2 metadata
55
+// entries.
56
+func ComputeV2MetadataHMACKey(authConfig *types.AuthConfig) ([]byte, error) {
57
+	if authConfig == nil {
58
+		return nil, nil
59
+	}
60
+	key := authConfigKeyInput{
61
+		Username:      authConfig.Username,
62
+		Password:      authConfig.Password,
63
+		Auth:          authConfig.Auth,
64
+		IdentityToken: authConfig.IdentityToken,
65
+		RegistryToken: authConfig.RegistryToken,
66
+	}
67
+	buf, err := json.Marshal(&key)
68
+	if err != nil {
69
+		return nil, err
70
+	}
71
+	return []byte(digest.FromBytes([]byte(buf))), nil
72
+}
73
+
74
+// authConfigKeyInput is a reduced AuthConfig structure holding just relevant credential data eligible for
75
+// hmac key creation.
76
+type authConfigKeyInput struct {
77
+	Username string `json:"username,omitempty"`
78
+	Password string `json:"password,omitempty"`
79
+	Auth     string `json:"auth,omitempty"`
80
+
81
+	IdentityToken string `json:"identitytoken,omitempty"`
82
+	RegistryToken string `json:"registrytoken,omitempty"`
20 83
 }
21 84
 
22 85
 // maxMetadata is the number of metadata entries to keep per layer DiffID.
... ...
@@ -105,6 +172,13 @@ func (serv *V2MetadataService) Add(diffID layer.DiffID, metadata V2Metadata) err
105 105
 	return serv.store.Set(serv.digestNamespace(), serv.digestKey(metadata.Digest), []byte(diffID))
106 106
 }
107 107
 
108
+// TagAndAdd amends the given "meta" for hmac hashed by the given "hmacKey" and associates it with a layer
109
+// DiffID. If too many metadata entries are present, the oldest one is dropped.
110
+func (serv *V2MetadataService) TagAndAdd(diffID layer.DiffID, hmacKey []byte, meta V2Metadata) error {
111
+	meta.HMAC = ComputeV2MetadataHMAC(hmacKey, &meta)
112
+	return serv.Add(diffID, meta)
113
+}
114
+
108 115
 // Remove unassociates a metadata entry from a layer DiffID.
109 116
 func (serv *V2MetadataService) Remove(metadata V2Metadata) error {
110 117
 	diffID, err := serv.GetDiffID(metadata.Digest)
... ...
@@ -5,8 +5,12 @@ import (
5 5
 	"fmt"
6 6
 	"io"
7 7
 	"runtime"
8
+	"sort"
9
+	"strings"
8 10
 	"sync"
9 11
 
12
+	"golang.org/x/net/context"
13
+
10 14
 	"github.com/Sirupsen/logrus"
11 15
 	"github.com/docker/distribution"
12 16
 	"github.com/docker/distribution/digest"
... ...
@@ -23,9 +27,10 @@ import (
23 23
 	"github.com/docker/docker/pkg/stringid"
24 24
 	"github.com/docker/docker/reference"
25 25
 	"github.com/docker/docker/registry"
26
-	"golang.org/x/net/context"
27 26
 )
28 27
 
28
+const maxRepositoryMountAttempts = 3
29
+
29 30
 // PushResult contains the tag, manifest digest, and manifest size from the
30 31
 // push. It's used to signal this information to the trust code in the client
31 32
 // so it can sign the manifest if necessary.
... ...
@@ -133,10 +138,16 @@ func (p *v2Pusher) pushV2Tag(ctx context.Context, ref reference.NamedTagged, ima
133 133
 		defer layer.ReleaseAndLog(p.config.LayerStore, l)
134 134
 	}
135 135
 
136
+	hmacKey, err := metadata.ComputeV2MetadataHMACKey(p.config.AuthConfig)
137
+	if err != nil {
138
+		return fmt.Errorf("failed to compute hmac key of auth config: %v", err)
139
+	}
140
+
136 141
 	var descriptors []xfer.UploadDescriptor
137 142
 
138 143
 	descriptorTemplate := v2PushDescriptor{
139 144
 		v2MetadataService: p.v2MetadataService,
145
+		hmacKey:           hmacKey,
140 146
 		repoInfo:          p.repoInfo,
141 147
 		ref:               p.ref,
142 148
 		repo:              p.repo,
... ...
@@ -233,6 +244,7 @@ func manifestFromBuilder(ctx context.Context, builder distribution.ManifestBuild
233 233
 type v2PushDescriptor struct {
234 234
 	layer             layer.Layer
235 235
 	v2MetadataService *metadata.V2MetadataService
236
+	hmacKey           []byte
236 237
 	repoInfo          reference.Named
237 238
 	ref               reference.Named
238 239
 	repo              distribution.Repository
... ...
@@ -296,47 +308,44 @@ func (pd *v2PushDescriptor) Upload(ctx context.Context, progressOutput progress.
296 296
 	bs := pd.repo.Blobs(ctx)
297 297
 
298 298
 	var layerUpload distribution.BlobWriter
299
-	mountAttemptsRemaining := 3
300
-
301
-	// Attempt to find another repository in the same registry to mount the layer
302
-	// from to avoid an unnecessary upload.
303
-	// Note: metadata is stored from oldest to newest, so we iterate through this
304
-	// slice in reverse to maximize our chances of the blob still existing in the
305
-	// remote repository.
306
-	for i := len(v2Metadata) - 1; i >= 0 && mountAttemptsRemaining > 0; i-- {
307
-		mountFrom := v2Metadata[i]
308 299
 
309
-		sourceRepo, err := reference.ParseNamed(mountFrom.SourceRepository)
310
-		if err != nil {
311
-			continue
312
-		}
313
-		if pd.repoInfo.Hostname() != sourceRepo.Hostname() {
314
-			// don't mount blobs from another registry
315
-			continue
316
-		}
300
+	// Attempt to find another repository in the same registry to mount the layer from to avoid an unnecessary upload
301
+	candidates := getRepositoryMountCandidates(pd.repoInfo, pd.hmacKey, maxRepositoryMountAttempts, v2Metadata)
302
+	for _, mountCandidate := range candidates {
303
+		logrus.Debugf("attempting to mount layer %s (%s) from %s", diffID, mountCandidate.Digest, mountCandidate.SourceRepository)
304
+		createOpts := []distribution.BlobCreateOption{}
305
+
306
+		if len(mountCandidate.SourceRepository) > 0 {
307
+			namedRef, err := reference.WithName(mountCandidate.SourceRepository)
308
+			if err != nil {
309
+				logrus.Errorf("failed to parse source repository reference %v: %v", namedRef.String(), err)
310
+				pd.v2MetadataService.Remove(mountCandidate)
311
+				continue
312
+			}
317 313
 
318
-		namedRef, err := reference.WithName(mountFrom.SourceRepository)
319
-		if err != nil {
320
-			continue
321
-		}
314
+			// TODO (brianbland): We need to construct a reference where the Name is
315
+			// only the full remote name, so clean this up when distribution has a
316
+			// richer reference package
317
+			remoteRef, err := distreference.WithName(namedRef.RemoteName())
318
+			if err != nil {
319
+				logrus.Errorf("failed to make remote reference out of %q: %v", namedRef.RemoteName(), namedRef.RemoteName())
320
+				continue
321
+			}
322 322
 
323
-		// TODO (brianbland): We need to construct a reference where the Name is
324
-		// only the full remote name, so clean this up when distribution has a
325
-		// richer reference package
326
-		remoteRef, err := distreference.WithName(namedRef.RemoteName())
327
-		if err != nil {
328
-			continue
329
-		}
323
+			canonicalRef, err := distreference.WithDigest(remoteRef, mountCandidate.Digest)
324
+			if err != nil {
325
+				logrus.Errorf("failed to make canonical reference: %v", err)
326
+				continue
327
+			}
330 328
 
331
-		canonicalRef, err := distreference.WithDigest(remoteRef, mountFrom.Digest)
332
-		if err != nil {
333
-			continue
329
+			createOpts = append(createOpts, client.WithMountFrom(canonicalRef))
334 330
 		}
335 331
 
336
-		logrus.Debugf("attempting to mount layer %s (%s) from %s", diffID, mountFrom.Digest, sourceRepo.FullName())
337
-
338
-		layerUpload, err = bs.Create(ctx, client.WithMountFrom(canonicalRef))
332
+		// send the layer
333
+		lu, err := bs.Create(ctx, createOpts...)
339 334
 		switch err := err.(type) {
335
+		case nil:
336
+			// noop
340 337
 		case distribution.ErrBlobMounted:
341 338
 			progress.Updatef(progressOutput, pd.ID(), "Mounted from %s", err.From.Name())
342 339
 
... ...
@@ -348,18 +357,31 @@ func (pd *v2PushDescriptor) Upload(ctx context.Context, progressOutput progress.
348 348
 			pd.pushState.Unlock()
349 349
 
350 350
 			// Cache mapping from this layer's DiffID to the blobsum
351
-			if err := pd.v2MetadataService.Add(diffID, metadata.V2Metadata{Digest: mountFrom.Digest, SourceRepository: pd.repoInfo.FullName()}); err != nil {
351
+			if err := pd.v2MetadataService.TagAndAdd(diffID, pd.hmacKey, metadata.V2Metadata{
352
+				Digest:           err.Descriptor.Digest,
353
+				SourceRepository: pd.repoInfo.FullName(),
354
+			}); err != nil {
352 355
 				return distribution.Descriptor{}, xfer.DoNotRetry{Err: err}
353 356
 			}
354 357
 			return err.Descriptor, nil
355
-		case nil:
356
-			// blob upload session created successfully, so begin the upload
357
-			mountAttemptsRemaining = 0
358 358
 		default:
359
-			// unable to mount layer from this repository, so this source mapping is no longer valid
360
-			logrus.Debugf("unassociating layer %s (%s) with %s", diffID, mountFrom.Digest, mountFrom.SourceRepository)
361
-			pd.v2MetadataService.Remove(mountFrom)
362
-			mountAttemptsRemaining--
359
+			logrus.Infof("failed to mount layer %s (%s) from %s: %v", diffID, mountCandidate.Digest, mountCandidate.SourceRepository, err)
360
+		}
361
+
362
+		if len(mountCandidate.SourceRepository) > 0 &&
363
+			(metadata.CheckV2MetadataHMAC(&mountCandidate, pd.hmacKey) ||
364
+				len(mountCandidate.HMAC) == 0) {
365
+			cause := "blob mount failure"
366
+			if err != nil {
367
+				cause = fmt.Sprintf("an error: %v", err.Error())
368
+			}
369
+			logrus.Debugf("removing association between layer %s and %s due to %s", mountCandidate.Digest, mountCandidate.SourceRepository, cause)
370
+			pd.v2MetadataService.Remove(mountCandidate)
371
+		}
372
+
373
+		layerUpload = lu
374
+		if layerUpload != nil {
375
+			break
363 376
 		}
364 377
 	}
365 378
 
... ...
@@ -371,6 +393,35 @@ func (pd *v2PushDescriptor) Upload(ctx context.Context, progressOutput progress.
371 371
 	}
372 372
 	defer layerUpload.Close()
373 373
 
374
+	// upload the blob
375
+	desc, err := pd.uploadUsingSession(ctx, progressOutput, diffID, layerUpload)
376
+	if err != nil {
377
+		return desc, err
378
+	}
379
+
380
+	pd.pushState.Lock()
381
+	// If Commit succeeded, that's an indication that the remote registry speaks the v2 protocol.
382
+	pd.pushState.confirmedV2 = true
383
+	pd.pushState.remoteLayers[diffID] = desc
384
+	pd.pushState.Unlock()
385
+
386
+	return desc, nil
387
+}
388
+
389
+func (pd *v2PushDescriptor) SetRemoteDescriptor(descriptor distribution.Descriptor) {
390
+	pd.remoteDescriptor = descriptor
391
+}
392
+
393
+func (pd *v2PushDescriptor) Descriptor() distribution.Descriptor {
394
+	return pd.remoteDescriptor
395
+}
396
+
397
+func (pd *v2PushDescriptor) uploadUsingSession(
398
+	ctx context.Context,
399
+	progressOutput progress.Output,
400
+	diffID layer.DiffID,
401
+	layerUpload distribution.BlobWriter,
402
+) (distribution.Descriptor, error) {
374 403
 	arch, err := pd.layer.TarStream()
375 404
 	if err != nil {
376 405
 		return distribution.Descriptor{}, xfer.DoNotRetry{Err: err}
... ...
@@ -404,34 +455,18 @@ func (pd *v2PushDescriptor) Upload(ctx context.Context, progressOutput progress.
404 404
 	progress.Update(progressOutput, pd.ID(), "Pushed")
405 405
 
406 406
 	// Cache mapping from this layer's DiffID to the blobsum
407
-	if err := pd.v2MetadataService.Add(diffID, metadata.V2Metadata{Digest: pushDigest, SourceRepository: pd.repoInfo.FullName()}); err != nil {
407
+	if err := pd.v2MetadataService.TagAndAdd(diffID, pd.hmacKey, metadata.V2Metadata{
408
+		Digest:           pushDigest,
409
+		SourceRepository: pd.repoInfo.FullName(),
410
+	}); err != nil {
408 411
 		return distribution.Descriptor{}, xfer.DoNotRetry{Err: err}
409 412
 	}
410 413
 
411
-	pd.pushState.Lock()
412
-
413
-	// If Commit succeeded, that's an indication that the remote registry
414
-	// speaks the v2 protocol.
415
-	pd.pushState.confirmedV2 = true
416
-
417
-	descriptor := distribution.Descriptor{
414
+	return distribution.Descriptor{
418 415
 		Digest:    pushDigest,
419 416
 		MediaType: schema2.MediaTypeLayer,
420 417
 		Size:      nn,
421
-	}
422
-	pd.pushState.remoteLayers[diffID] = descriptor
423
-
424
-	pd.pushState.Unlock()
425
-
426
-	return descriptor, nil
427
-}
428
-
429
-func (pd *v2PushDescriptor) SetRemoteDescriptor(descriptor distribution.Descriptor) {
430
-	pd.remoteDescriptor = descriptor
431
-}
432
-
433
-func (pd *v2PushDescriptor) Descriptor() distribution.Descriptor {
434
-	return pd.remoteDescriptor
418
+	}, nil
435 419
 }
436 420
 
437 421
 // layerAlreadyExists checks if the registry already know about any of the
... ...
@@ -456,3 +491,95 @@ func layerAlreadyExists(ctx context.Context, metadata []metadata.V2Metadata, rep
456 456
 	}
457 457
 	return distribution.Descriptor{}, false, nil
458 458
 }
459
+
460
+// getRepositoryMountCandidates returns an array of v2 metadata items belonging to the given registry. The
461
+// array is sorted from youngest to oldest. If requireReigstryMatch is true, the resulting array will contain
462
+// only metadata entries having registry part of SourceRepository matching the part of repoInfo.
463
+func getRepositoryMountCandidates(
464
+	repoInfo reference.Named,
465
+	hmacKey []byte,
466
+	max int,
467
+	v2Metadata []metadata.V2Metadata,
468
+) []metadata.V2Metadata {
469
+	candidates := []metadata.V2Metadata{}
470
+	for _, meta := range v2Metadata {
471
+		sourceRepo, err := reference.ParseNamed(meta.SourceRepository)
472
+		if err != nil || repoInfo.Hostname() != sourceRepo.Hostname() {
473
+			continue
474
+		}
475
+		// target repository is not a viable candidate
476
+		if meta.SourceRepository == repoInfo.FullName() {
477
+			continue
478
+		}
479
+		candidates = append(candidates, meta)
480
+	}
481
+
482
+	sortV2MetadataByLikenessAndAge(repoInfo, hmacKey, candidates)
483
+	if max >= 0 && len(candidates) > max {
484
+		// select the youngest metadata
485
+		candidates = candidates[:max]
486
+	}
487
+
488
+	return candidates
489
+}
490
+
491
+// byLikeness is a sorting container for v2 metadata candidates for cross repository mount. The
492
+// candidate "a" is preferred over "b":
493
+//
494
+//  1. if it was hashed using the same AuthConfig as the one used to authenticate to target repository and the
495
+//     "b" was not
496
+//  2. if a number of its repository path components exactly matching path components of target repository is higher
497
+type byLikeness struct {
498
+	arr            []metadata.V2Metadata
499
+	hmacKey        []byte
500
+	pathComponents []string
501
+}
502
+
503
+func (bla byLikeness) Less(i, j int) bool {
504
+	aMacMatch := metadata.CheckV2MetadataHMAC(&bla.arr[i], bla.hmacKey)
505
+	bMacMatch := metadata.CheckV2MetadataHMAC(&bla.arr[j], bla.hmacKey)
506
+	if aMacMatch != bMacMatch {
507
+		return aMacMatch
508
+	}
509
+	aMatch := numOfMatchingPathComponents(bla.arr[i].SourceRepository, bla.pathComponents)
510
+	bMatch := numOfMatchingPathComponents(bla.arr[j].SourceRepository, bla.pathComponents)
511
+	return aMatch > bMatch
512
+}
513
+func (bla byLikeness) Swap(i, j int) {
514
+	bla.arr[i], bla.arr[j] = bla.arr[j], bla.arr[i]
515
+}
516
+func (bla byLikeness) Len() int { return len(bla.arr) }
517
+
518
+func sortV2MetadataByLikenessAndAge(repoInfo reference.Named, hmacKey []byte, marr []metadata.V2Metadata) {
519
+	// reverse the metadata array to shift the newest entries to the beginning
520
+	for i := 0; i < len(marr)/2; i++ {
521
+		marr[i], marr[len(marr)-i-1] = marr[len(marr)-i-1], marr[i]
522
+	}
523
+	// keep equal entries ordered from the youngest to the oldest
524
+	sort.Stable(byLikeness{
525
+		arr:            marr,
526
+		hmacKey:        hmacKey,
527
+		pathComponents: getPathComponents(repoInfo.FullName()),
528
+	})
529
+}
530
+
531
+// numOfMatchingPathComponents returns a number of path components in "pth" that exactly match "matchComponents".
532
+func numOfMatchingPathComponents(pth string, matchComponents []string) int {
533
+	pthComponents := getPathComponents(pth)
534
+	i := 0
535
+	for ; i < len(pthComponents) && i < len(matchComponents); i++ {
536
+		if matchComponents[i] != pthComponents[i] {
537
+			return i
538
+		}
539
+	}
540
+	return i
541
+}
542
+
543
+func getPathComponents(path string) []string {
544
+	// make sure to add docker.io/ prefix to the path
545
+	named, err := reference.ParseNamed(path)
546
+	if err == nil {
547
+		path = named.FullName()
548
+	}
549
+	return strings.Split(path, "/")
550
+}
459 551
new file mode 100644
... ...
@@ -0,0 +1,147 @@
0
+package distribution
1
+
2
+import (
3
+	"reflect"
4
+	"testing"
5
+
6
+	"github.com/docker/distribution/digest"
7
+	"github.com/docker/docker/distribution/metadata"
8
+	"github.com/docker/docker/reference"
9
+)
10
+
11
+func TestGetRepositoryMountCandidates(t *testing.T) {
12
+	for _, tc := range []struct {
13
+		name          string
14
+		hmacKey       string
15
+		targetRepo    string
16
+		maxCandidates int
17
+		metadata      []metadata.V2Metadata
18
+		candidates    []metadata.V2Metadata
19
+	}{
20
+		{
21
+			name:          "empty metadata",
22
+			targetRepo:    "busybox",
23
+			maxCandidates: -1,
24
+			metadata:      []metadata.V2Metadata{},
25
+			candidates:    []metadata.V2Metadata{},
26
+		},
27
+		{
28
+			name:          "one item not matching",
29
+			targetRepo:    "busybox",
30
+			maxCandidates: -1,
31
+			metadata:      []metadata.V2Metadata{taggedMetadata("key", "dgst", "127.0.0.1/repo")},
32
+			candidates:    []metadata.V2Metadata{},
33
+		},
34
+		{
35
+			name:          "one item matching",
36
+			targetRepo:    "busybox",
37
+			maxCandidates: -1,
38
+			metadata:      []metadata.V2Metadata{taggedMetadata("hash", "1", "hello-world")},
39
+			candidates:    []metadata.V2Metadata{taggedMetadata("hash", "1", "hello-world")},
40
+		},
41
+		{
42
+			name:          "allow missing SourceRepository",
43
+			targetRepo:    "busybox",
44
+			maxCandidates: -1,
45
+			metadata: []metadata.V2Metadata{
46
+				{Digest: digest.Digest("1")},
47
+				{Digest: digest.Digest("3")},
48
+				{Digest: digest.Digest("2")},
49
+			},
50
+			candidates: []metadata.V2Metadata{},
51
+		},
52
+		{
53
+			name:          "handle docker.io",
54
+			targetRepo:    "user/app",
55
+			maxCandidates: -1,
56
+			metadata: []metadata.V2Metadata{
57
+				{Digest: digest.Digest("1"), SourceRepository: "docker.io/user/foo"},
58
+				{Digest: digest.Digest("3"), SourceRepository: "user/bar"},
59
+				{Digest: digest.Digest("2"), SourceRepository: "app"},
60
+			},
61
+			candidates: []metadata.V2Metadata{
62
+				{Digest: digest.Digest("3"), SourceRepository: "user/bar"},
63
+				{Digest: digest.Digest("1"), SourceRepository: "docker.io/user/foo"},
64
+				{Digest: digest.Digest("2"), SourceRepository: "app"},
65
+			},
66
+		},
67
+		{
68
+			name:          "sort more items",
69
+			hmacKey:       "abcd",
70
+			targetRepo:    "127.0.0.1/foo/bar",
71
+			maxCandidates: -1,
72
+			metadata: []metadata.V2Metadata{
73
+				taggedMetadata("hash", "1", "hello-world"),
74
+				taggedMetadata("efgh", "2", "127.0.0.1/hello-world"),
75
+				taggedMetadata("abcd", "3", "busybox"),
76
+				taggedMetadata("hash", "4", "busybox"),
77
+				taggedMetadata("hash", "5", "127.0.0.1/foo"),
78
+				taggedMetadata("hash", "6", "127.0.0.1/bar"),
79
+				taggedMetadata("efgh", "7", "127.0.0.1/foo/bar"),
80
+				taggedMetadata("abcd", "8", "127.0.0.1/xyz"),
81
+				taggedMetadata("hash", "9", "127.0.0.1/foo/app"),
82
+			},
83
+			candidates: []metadata.V2Metadata{
84
+				// first by matching hash
85
+				taggedMetadata("abcd", "8", "127.0.0.1/xyz"),
86
+				// then by longest matching prefix
87
+				taggedMetadata("hash", "9", "127.0.0.1/foo/app"),
88
+				taggedMetadata("hash", "5", "127.0.0.1/foo"),
89
+				// sort the rest of the matching items in reversed order
90
+				taggedMetadata("hash", "6", "127.0.0.1/bar"),
91
+				taggedMetadata("efgh", "2", "127.0.0.1/hello-world"),
92
+			},
93
+		},
94
+		{
95
+			name:          "limit max candidates",
96
+			hmacKey:       "abcd",
97
+			targetRepo:    "user/app",
98
+			maxCandidates: 3,
99
+			metadata: []metadata.V2Metadata{
100
+				taggedMetadata("abcd", "1", "user/app1"),
101
+				taggedMetadata("abcd", "2", "user/app/base"),
102
+				taggedMetadata("hash", "3", "user/app"),
103
+				taggedMetadata("abcd", "4", "127.0.0.1/user/app"),
104
+				taggedMetadata("hash", "5", "user/foo"),
105
+				taggedMetadata("hash", "6", "app/bar"),
106
+			},
107
+			candidates: []metadata.V2Metadata{
108
+				// first by matching hash
109
+				taggedMetadata("abcd", "2", "user/app/base"),
110
+				taggedMetadata("abcd", "1", "user/app1"),
111
+				// then by longest matching prefix
112
+				taggedMetadata("hash", "3", "user/app"),
113
+			},
114
+		},
115
+	} {
116
+		repoInfo, err := reference.ParseNamed(tc.targetRepo)
117
+		if err != nil {
118
+			t.Fatalf("[%s] failed to parse reference name: %v", tc.name, err)
119
+		}
120
+		candidates := getRepositoryMountCandidates(repoInfo, []byte(tc.hmacKey), tc.maxCandidates, tc.metadata)
121
+		if len(candidates) != len(tc.candidates) {
122
+			t.Errorf("[%s] got unexpected number of candidates: %d != %d", tc.name, len(candidates), len(tc.candidates))
123
+		}
124
+		for i := 0; i < len(candidates) && i < len(tc.candidates); i++ {
125
+			if !reflect.DeepEqual(candidates[i], tc.candidates[i]) {
126
+				t.Errorf("[%s] candidate %d does not match expected: %#+v != %#+v", tc.name, i, candidates[i], tc.candidates[i])
127
+			}
128
+		}
129
+		for i := len(candidates); i < len(tc.candidates); i++ {
130
+			t.Errorf("[%s] missing expected candidate at position %d (%#+v)", tc.name, i, tc.candidates[i])
131
+		}
132
+		for i := len(tc.candidates); i < len(candidates); i++ {
133
+			t.Errorf("[%s] got unexpected candidate at position %d (%#+v)", tc.name, i, candidates[i])
134
+		}
135
+	}
136
+}
137
+
138
+func taggedMetadata(key string, dgst string, sourceRepo string) metadata.V2Metadata {
139
+	meta := metadata.V2Metadata{
140
+		Digest:           digest.Digest(dgst),
141
+		SourceRepository: sourceRepo,
142
+	}
143
+
144
+	meta.HMAC = metadata.ComputeV2MetadataHMAC([]byte(key), &meta)
145
+	return meta
146
+}