Browse Source

Support semver in container filter tag

Currently, containers can be tagged in manifests to filter what image
tags are considered when doing automated releases. Filtering is done by
specifying a wildcard glob. An optional prefix `glob:` can be used.

This PR adds support for tag filters based on [semantic versioning][0]
by using the prefix `semver:` instead. Version constraints can be
specified that filter images. Since versions have an implicit ordering
this also changes the way images are sorted when trying to determine the
newest image. For glob filtering this falls back to image creation date.

[0]: https://semver.org
Roland Schilter 2 years ago
parent
commit
f7a99c3ceb

+ 1
- 1
Gopkg.lock View File

@@ -745,6 +745,6 @@
745 745
 [solve-meta]
746 746
   analyzer-name = "dep"
747 747
   analyzer-version = 1
748
-  inputs-digest = "39117abf5941771d8502f737e9680717e0d6659d66b5f3da5ec48a142cdc986a"
748
+  inputs-digest = "1d8f352c4b156819b80ca52ed9c1a80d8bfa4ebd1af4a1bc63a8a5f480282459"
749 749
   solver-name = "gps-cdcl"
750 750
   solver-version = 1

+ 4
- 0
Gopkg.toml View File

@@ -49,3 +49,7 @@ required = ["k8s.io/code-generator/cmd/client-gen"]
49 49
 [[constraint]]
50 50
   branch = "master"
51 51
   name = "github.com/pkg/term"
52
+
53
+[[constraint]]
54
+  name = "github.com/Masterminds/semver"
55
+  version = "1.4.0"

+ 6
- 3
api/v6/container.go View File

@@ -3,6 +3,7 @@ package v6
3 3
 import (
4 4
 	"github.com/pkg/errors"
5 5
 	"github.com/weaveworks/flux/image"
6
+	"github.com/weaveworks/flux/policy"
6 7
 	"github.com/weaveworks/flux/registry"
7 8
 	"github.com/weaveworks/flux/update"
8 9
 )
@@ -26,7 +27,9 @@ type Container struct {
26 27
 }
27 28
 
28 29
 // NewContainer creates a Container given a list of images and the current image
29
-func NewContainer(name string, images update.ImageInfos, currentImage image.Info, tagPattern string, fields []string) (Container, error) {
30
+func NewContainer(name string, images update.ImageInfos, currentImage image.Info, tagPattern policy.Pattern, fields []string) (Container, error) {
31
+	images.Sort(tagPattern)
32
+
30 33
 	// All images
31 34
 	imagesCount := len(images)
32 35
 	imagesErr := ""
@@ -35,7 +38,7 @@ func NewContainer(name string, images update.ImageInfos, currentImage image.Info
35 38
 	}
36 39
 	var newImages []image.Info
37 40
 	for _, img := range images {
38
-		if img.CreatedAt.After(currentImage.CreatedAt) {
41
+		if tagPattern.Newer(&img, &currentImage) {
39 42
 			newImages = append(newImages, img)
40 43
 		}
41 44
 	}
@@ -46,7 +49,7 @@ func NewContainer(name string, images update.ImageInfos, currentImage image.Info
46 49
 	filteredImagesCount := len(filteredImages)
47 50
 	var newFilteredImages []image.Info
48 51
 	for _, img := range filteredImages {
49
-		if img.CreatedAt.After(currentImage.CreatedAt) {
52
+		if tagPattern.Newer(&img, &currentImage) {
50 53
 			newFilteredImages = append(newFilteredImages, img)
51 54
 		}
52 55
 	}

+ 31
- 9
api/v6/container_test.go View File

@@ -4,7 +4,10 @@ import (
4 4
 	"reflect"
5 5
 	"testing"
6 6
 
7
+	"github.com/stretchr/testify/assert"
8
+
7 9
 	"github.com/weaveworks/flux/image"
10
+	"github.com/weaveworks/flux/policy"
8 11
 	"github.com/weaveworks/flux/update"
9 12
 )
10 13
 
@@ -12,11 +15,15 @@ func TestNewContainer(t *testing.T) {
12 15
 
13 16
 	testImage := image.Info{ImageID: "test"}
14 17
 
18
+	currentSemver := image.Info{ID: image.Ref{Tag: "1.0.0"}}
19
+	oldSemver := image.Info{ID: image.Ref{Tag: "0.9.0"}}
20
+	newSemver := image.Info{ID: image.Ref{Tag: "1.2.3"}}
21
+
15 22
 	type args struct {
16 23
 		name         string
17 24
 		images       update.ImageInfos
18 25
 		currentImage image.Info
19
-		tagPattern   string
26
+		tagPattern   policy.Pattern
20 27
 		fields       []string
21 28
 	}
22 29
 	tests := []struct {
@@ -31,7 +38,7 @@ func TestNewContainer(t *testing.T) {
31 38
 				name:         "container1",
32 39
 				images:       update.ImageInfos{testImage},
33 40
 				currentImage: testImage,
34
-				tagPattern:   "*",
41
+				tagPattern:   policy.PatternAll,
35 42
 			},
36 43
 			want: Container{
37 44
 				Name:                    "container1",
@@ -45,17 +52,32 @@ func TestNewContainer(t *testing.T) {
45 52
 			},
46 53
 			wantErr: false,
47 54
 		},
55
+		{
56
+			name: "Semver filtering and sorting",
57
+			args: args{
58
+				name:         "container-semver",
59
+				images:       update.ImageInfos{currentSemver, newSemver, oldSemver, testImage},
60
+				currentImage: currentSemver,
61
+				tagPattern:   policy.NewPattern("semver:*"),
62
+			},
63
+			want: Container{
64
+				Name:                    "container-semver",
65
+				Current:                 currentSemver,
66
+				LatestFiltered:          newSemver,
67
+				Available:               []image.Info{newSemver, currentSemver, oldSemver, testImage},
68
+				AvailableImagesCount:    4,
69
+				NewAvailableImagesCount: 1,
70
+				FilteredImagesCount:     3,
71
+				NewFilteredImagesCount:  1,
72
+			},
73
+			wantErr: false,
74
+		},
48 75
 	}
49 76
 	for _, tt := range tests {
50 77
 		t.Run(tt.name, func(t *testing.T) {
51 78
 			got, err := NewContainer(tt.args.name, tt.args.images, tt.args.currentImage, tt.args.tagPattern, tt.args.fields)
52
-			if (err != nil) != tt.wantErr {
53
-				t.Errorf("NewContainer() error = %v, wantErr %v", err, tt.wantErr)
54
-				return
55
-			}
56
-			if !reflect.DeepEqual(got, tt.want) {
57
-				t.Errorf("NewContainer() = %v, want %v", got, tt.want)
58
-			}
79
+			assert.Equal(t, tt.wantErr, err != nil)
80
+			assert.Equal(t, tt.want, got)
59 81
 		})
60 82
 	}
61 83
 }

+ 6
- 3
cluster/kubernetes/policies.go View File

@@ -4,7 +4,7 @@ import (
4 4
 	"fmt"
5 5
 
6 6
 	"github.com/pkg/errors"
7
-	yaml "gopkg.in/yaml.v2"
7
+	"gopkg.in/yaml.v2"
8 8
 
9 9
 	"github.com/weaveworks/flux"
10 10
 	kresource "github.com/weaveworks/flux/cluster/kubernetes/resource"
@@ -27,7 +27,7 @@ func (m *Manifests) UpdatePolicies(def []byte, id flux.ResourceID, update policy
27 27
 		}
28 28
 
29 29
 		for _, container := range containers {
30
-			if tagAll == "glob:*" {
30
+			if tagAll == policy.PatternAll.String() {
31 31
 				del = del.Add(policy.TagPrefix(container.Name))
32 32
 			} else {
33 33
 				add = add.Set(policy.TagPrefix(container.Name), tagAll)
@@ -35,8 +35,11 @@ func (m *Manifests) UpdatePolicies(def []byte, id flux.ResourceID, update policy
35 35
 		}
36 36
 	}
37 37
 
38
-	args := []string{}
38
+	var args []string
39 39
 	for pol, val := range add {
40
+		if policy.Tag(pol) && !policy.NewPattern(val).Valid() {
41
+			return nil, fmt.Errorf("invalid tag pattern: %q", val)
42
+		}
40 43
 		args = append(args, fmt.Sprintf("%s%s=%s", kresource.PolicyPrefix, pol, val))
41 44
 	}
42 45
 	for pol, _ := range del {

+ 55
- 9
cluster/kubernetes/policies_test.go View File

@@ -5,6 +5,8 @@ import (
5 5
 	"testing"
6 6
 	"text/template"
7 7
 
8
+	"github.com/stretchr/testify/assert"
9
+
8 10
 	"github.com/weaveworks/flux"
9 11
 	"github.com/weaveworks/flux/policy"
10 12
 )
@@ -14,6 +16,7 @@ func TestUpdatePolicies(t *testing.T) {
14 16
 		name    string
15 17
 		in, out []string
16 18
 		update  policy.Update
19
+		wantErr bool
17 20
 	}{
18 21
 		{
19 22
 			name: "adding annotation with others existing",
@@ -113,17 +116,60 @@ func TestUpdatePolicies(t *testing.T) {
113 116
 				Remove: policy.Set{policy.LockedMsg: "foo"},
114 117
 			},
115 118
 		},
119
+		{
120
+			name: "add tag policy",
121
+			in:   nil,
122
+			out:  []string{"flux.weave.works/tag.nginx", "glob:*"},
123
+			update: policy.Update{
124
+				Add: policy.Set{policy.TagPrefix("nginx"): "glob:*"},
125
+			},
126
+		},
127
+		{
128
+			name: "add non-glob tag policy",
129
+			in:   nil,
130
+			out:  []string{"flux.weave.works/tag.nginx", "foo"},
131
+			update: policy.Update{
132
+				Add: policy.Set{policy.TagPrefix("nginx"): "foo"},
133
+			},
134
+		},
135
+		{
136
+			name: "add semver tag policy",
137
+			in:   nil,
138
+			out:  []string{"flux.weave.works/tag.nginx", "semver:*"},
139
+			update: policy.Update{
140
+				Add: policy.Set{policy.TagPrefix("nginx"): "semver:*"},
141
+			},
142
+		},
143
+		{
144
+			name: "add invalid semver tag policy",
145
+			in:   nil,
146
+			out:  []string{"flux.weave.works/tag.nginx", "semver:*"},
147
+			update: policy.Update{
148
+				Add: policy.Set{policy.TagPrefix("nginx"): "semver:invalid"},
149
+			},
150
+			wantErr: true,
151
+		},
116 152
 	} {
117
-		caseIn := templToString(t, annotationsTemplate, c.in)
118
-		caseOut := templToString(t, annotationsTemplate, c.out)
119
-		resourceID := flux.MustParseResourceID("default:deployment/nginx")
120
-		out, err := (&Manifests{}).UpdatePolicies([]byte(caseIn), resourceID, c.update)
121
-		if err != nil {
122
-			t.Errorf("[%s] %v", c.name, err)
123
-		} else if string(out) != caseOut {
124
-			t.Errorf("[%s] Did not get expected result:\n\n%s\n\nInstead got:\n\n%s", c.name, caseOut, string(out))
125
-		}
153
+		t.Run(c.name, func(t *testing.T) {
154
+			caseIn := templToString(t, annotationsTemplate, c.in)
155
+			caseOut := templToString(t, annotationsTemplate, c.out)
156
+			resourceID := flux.MustParseResourceID("default:deployment/nginx")
157
+			out, err := (&Manifests{}).UpdatePolicies([]byte(caseIn), resourceID, c.update)
158
+			assert.Equal(t, c.wantErr, err != nil)
159
+			if !c.wantErr {
160
+				assert.Equal(t, string(out), caseOut)
161
+			}
162
+		})
163
+	}
164
+}
165
+
166
+func TestUpdatePolicies_invalidTagPattern(t *testing.T) {
167
+	resourceID := flux.MustParseResourceID("default:deployment/nginx")
168
+	update := policy.Update{
169
+		Add: policy.Set{policy.TagPrefix("nginx"): "semver:invalid"},
126 170
 	}
171
+	_, err := (&Manifests{}).UpdatePolicies(nil, resourceID, update)
172
+	assert.Error(t, err)
127 173
 }
128 174
 
129 175
 var annotationsTemplate = template.Must(template.New("").Parse(`---

+ 27
- 0
cluster/kubernetes/testfiles/data.go View File

@@ -52,6 +52,7 @@ var ResourceMap = map[flux.ResourceID]string{
52 52
 	flux.MustParseResourceID("default:service/multi-service"):     "multi.yaml",
53 53
 	flux.MustParseResourceID("default:deployment/list-deploy"):    "list.yaml",
54 54
 	flux.MustParseResourceID("default:service/list-service"):      "list.yaml",
55
+	flux.MustParseResourceID("default:deployment/semver"):         "semver-deploy.yaml",
55 56
 }
56 57
 
57 58
 // ServiceMap ... given a base path, construct the map representing
@@ -64,6 +65,7 @@ func ServiceMap(dir string) map[flux.ResourceID][]string {
64 65
 		flux.MustParseResourceID("default:deployment/test-service"):   []string{filepath.Join(dir, "test/test-service-deploy.yaml")},
65 66
 		flux.MustParseResourceID("default:deployment/multi-deploy"):   []string{filepath.Join(dir, "multi.yaml")},
66 67
 		flux.MustParseResourceID("default:deployment/list-deploy"):    []string{filepath.Join(dir, "list.yaml")},
68
+		flux.MustParseResourceID("default:deployment/semver"):         []string{filepath.Join(dir, "semver-deploy.yaml")},
67 69
 	}
68 70
 }
69 71
 
@@ -96,6 +98,31 @@ spec:
96 98
         - -addr=:8080
97 99
         ports:
98 100
         - containerPort: 8080
101
+`,
102
+	// Automated deployment with semver enabled
103
+	"semver-deploy.yaml": `---
104
+apiVersion: extensions/v1beta1
105
+kind: Deployment
106
+metadata:
107
+  name: semver
108
+  annotations:
109
+    flux.weave.works/automated: "true"
110
+    flux.weave.works/tag.greeter: semver:*
111
+spec:
112
+  minReadySeconds: 1
113
+  replicas: 5
114
+  template:
115
+    metadata:
116
+      labels:
117
+        name: semver
118
+    spec:
119
+      containers:
120
+      - name: greeter
121
+        image: quay.io/weaveworks/helloworld:master-a000001
122
+        args:
123
+        - -msg=Ahoy
124
+        ports:
125
+        - containerPort: 80
99 126
 `,
100 127
 	"locked-service-deploy.yaml": `apiVersion: extensions/v1beta1
101 128
 kind: Deployment

+ 2
- 2
cmd/fluxctl/policy_cmd.go View File

@@ -142,7 +142,7 @@ func calculatePolicyChanges(opts *controllerPolicyOpts) (policy.Update, error) {
142 142
 			Add(policy.LockedUser)
143 143
 	}
144 144
 	if opts.tagAll != "" {
145
-		add = add.Set(policy.TagAll, "glob:"+opts.tagAll)
145
+		add = add.Set(policy.TagAll, policy.NewPattern(opts.tagAll).String())
146 146
 	}
147 147
 
148 148
 	for _, tagPair := range opts.tags {
@@ -153,7 +153,7 @@ func calculatePolicyChanges(opts *controllerPolicyOpts) (policy.Update, error) {
153 153
 
154 154
 		container, tag := parts[0], parts[1]
155 155
 		if tag != "*" {
156
-			add = add.Set(policy.TagPrefix(container), "glob:"+tag)
156
+			add = add.Set(policy.TagPrefix(container), policy.NewPattern(tag).String())
157 157
 		} else {
158 158
 			remove = remove.Add(policy.TagPrefix(container))
159 159
 		}

+ 4
- 4
daemon/daemon.go View File

@@ -163,14 +163,14 @@ func (d *Daemon) ListImagesWithOptions(ctx context.Context, opts v10.ListImagesO
163 163
 		services, err = d.Cluster.SomeControllers([]flux.ResourceID{id})
164 164
 	}
165 165
 
166
-	imageRepos, err := update.FetchImageRepos(d.Registry, clusterContainers(services), d.Logger)
166
+	policyResourceMap, _, err := d.getPolicyResourceMap(ctx)
167 167
 	if err != nil {
168
-		return nil, errors.Wrap(err, "getting images for services")
168
+		return nil, err
169 169
 	}
170 170
 
171
-	policyResourceMap, _, err := d.getPolicyResourceMap(ctx)
171
+	imageRepos, err := update.FetchImageRepos(d.Registry, clusterContainers(services), d.Logger)
172 172
 	if err != nil {
173
-		return nil, err
173
+		return nil, errors.Wrap(err, "getting images for services")
174 174
 	}
175 175
 
176 176
 	var res []v6.ImageStatus

+ 91
- 7
daemon/daemon_test.go View File

@@ -38,6 +38,7 @@ const (
38 38
 	svc               = "default:deployment/helloworld"
39 39
 	container         = "greeter"
40 40
 	ns                = "default"
41
+	oldHelloImage     = "quay.io/weaveworks/helloworld:3" // older in time but newer version!
41 42
 	newHelloImage     = "quay.io/weaveworks/helloworld:2"
42 43
 	currentHelloImage = "quay.io/weaveworks/helloworld:master-a000001"
43 44
 
@@ -151,6 +152,8 @@ func TestDaemon_ListImagesWithOptions(t *testing.T) {
151 152
 	assert.NoError(t, err)
152 153
 	newImageRef, err := image.ParseRef(newHelloImage)
153 154
 	assert.NoError(t, err)
155
+	oldImageRef, err := image.ParseRef(oldHelloImage)
156
+	assert.NoError(t, err)
154 157
 
155 158
 	// Service 2
156 159
 	anotherSvcID, err := flux.ParseResourceID(anotherSvc)
@@ -180,10 +183,11 @@ func TestDaemon_ListImagesWithOptions(t *testing.T) {
180 183
 							Available: []image.Info{
181 184
 								{ID: newImageRef},
182 185
 								{ID: currentImageRef},
186
+								{ID: oldImageRef},
183 187
 							},
184
-							AvailableImagesCount:    2,
188
+							AvailableImagesCount:    3,
185 189
 							NewAvailableImagesCount: 1,
186
-							FilteredImagesCount:     2,
190
+							FilteredImagesCount:     3,
187 191
 							NewFilteredImagesCount:  1,
188 192
 						},
189 193
 					},
@@ -222,10 +226,11 @@ func TestDaemon_ListImagesWithOptions(t *testing.T) {
222 226
 							Available: []image.Info{
223 227
 								{ID: newImageRef},
224 228
 								{ID: currentImageRef},
229
+								{ID: oldImageRef},
225 230
 							},
226
-							AvailableImagesCount:    2,
231
+							AvailableImagesCount:    3,
227 232
 							NewAvailableImagesCount: 1,
228
-							FilteredImagesCount:     2,
233
+							FilteredImagesCount:     3,
229 234
 							NewFilteredImagesCount:  1,
230 235
 						},
231 236
 					},
@@ -478,6 +483,57 @@ func TestDaemon_JobStatusWithNoCache(t *testing.T) {
478 483
 	w.ForJobSucceeded(d, id)
479 484
 }
480 485
 
486
+func TestDaemon_Automated(t *testing.T) {
487
+	d, start, clean, k8s, _ := mockDaemon(t)
488
+	start()
489
+	defer clean()
490
+	w := newWait(t)
491
+
492
+	service := cluster.Controller{
493
+		ID: flux.MakeResourceID(ns, "deployment", "helloworld"),
494
+		Containers: cluster.ContainersOrExcuse{
495
+			Containers: []resource.Container{
496
+				{
497
+					Name:  container,
498
+					Image: mustParseImageRef(currentHelloImage),
499
+				},
500
+			},
501
+		},
502
+	}
503
+	k8s.SomeServicesFunc = func([]flux.ResourceID) ([]cluster.Controller, error) {
504
+		return []cluster.Controller{service}, nil
505
+	}
506
+
507
+	// updates from helloworld:master-xxx to helloworld:2
508
+	w.ForImageTag(t, d, svc, container, "2")
509
+}
510
+
511
+func TestDaemon_Automated_semver(t *testing.T) {
512
+	d, start, clean, k8s, _ := mockDaemon(t)
513
+	start()
514
+	defer clean()
515
+	w := newWait(t)
516
+
517
+	resid := flux.MustParseResourceID("default:deployment/semver")
518
+	service := cluster.Controller{
519
+		ID: resid,
520
+		Containers: cluster.ContainersOrExcuse{
521
+			Containers: []resource.Container{
522
+				{
523
+					Name:  container,
524
+					Image: mustParseImageRef(currentHelloImage),
525
+				},
526
+			},
527
+		},
528
+	}
529
+	k8s.SomeServicesFunc = func([]flux.ResourceID) ([]cluster.Controller, error) {
530
+		return []cluster.Controller{service}, nil
531
+	}
532
+
533
+	// helloworld:3 is older than helloworld:2 but semver orders by version
534
+	w.ForImageTag(t, d, resid.String(), container, "3")
535
+}
536
+
481 537
 func makeImageInfo(ref string, t time.Time) image.Info {
482 538
 	return image.Info{ID: mustParseImageRef(ref), CreatedAt: t}
483 539
 }
@@ -506,13 +562,13 @@ func mockDaemon(t *testing.T) (*Daemon, func(), func(), *cluster.Mock, *mockEven
506 562
 	}
507 563
 	multiService := []cluster.Controller{
508 564
 		singleService,
509
-		cluster.Controller{
565
+		{
510 566
 			ID: flux.MakeResourceID("another", "deployment", "service"),
511 567
 			Containers: cluster.ContainersOrExcuse{
512 568
 				Containers: []resource.Container{
513 569
 					{
514
-						Name:  "it-doesn't-matter",
515
-						Image: mustParseImageRef("another/service:latest"),
570
+						Name:  anotherContainer,
571
+						Image: mustParseImageRef(anotherImage),
516 572
 					},
517 573
 				},
518 574
 			},
@@ -560,6 +616,7 @@ func mockDaemon(t *testing.T) (*Daemon, func(), func(), *cluster.Mock, *mockEven
560 616
 
561 617
 	var imageRegistry registry.Registry
562 618
 	{
619
+		img0 := makeImageInfo(oldHelloImage, time.Now().Add(-1*time.Second))
563 620
 		img1 := makeImageInfo(currentHelloImage, time.Now())
564 621
 		img2 := makeImageInfo(newHelloImage, time.Now().Add(1*time.Second))
565 622
 		img3 := makeImageInfo("another/service:latest", time.Now().Add(1*time.Second))
@@ -568,6 +625,7 @@ func mockDaemon(t *testing.T) (*Daemon, func(), func(), *cluster.Mock, *mockEven
568 625
 				img1,
569 626
 				img2,
570 627
 				img3,
628
+				img0,
571 629
 			},
572 630
 		}
573 631
 	}
@@ -698,6 +756,32 @@ func (w *wait) ForSyncStatus(d *Daemon, rev string, expectedNumCommits int) []st
698 756
 	return revs
699 757
 }
700 758
 
759
+func (w *wait) ForImageTag(t *testing.T, d *Daemon, service, container, tag string) {
760
+	w.Eventually(func() bool {
761
+		co, err := d.Repo.Clone(context.TODO(), d.GitConfig)
762
+		if err != nil {
763
+			return false
764
+		}
765
+		defer co.Clean()
766
+
767
+		m, err := d.Manifests.LoadManifests(co.Dir(), co.ManifestDir())
768
+		assert.NoError(t, err)
769
+
770
+		resources, err := d.Manifests.ParseManifests(m[service].Bytes())
771
+		assert.NoError(t, err)
772
+
773
+		workload, ok := resources[service].(resource.Workload)
774
+		assert.True(t, ok)
775
+		for _, c := range workload.Containers() {
776
+			if c.Name == container && c.Image.Tag == tag {
777
+				return true
778
+			}
779
+		}
780
+		return false
781
+	}, fmt.Sprintf("Waiting for image tag: %q", tag))
782
+
783
+}
784
+
701 785
 func updateImage(ctx context.Context, d *Daemon, t *testing.T) job.ID {
702 786
 	return updateManifest(ctx, t, d, update.Spec{
703 787
 		Type: update.Images,

+ 2
- 4
daemon/loop.go View File

@@ -1,17 +1,15 @@
1 1
 package daemon
2 2
 
3 3
 import (
4
+	"context"
4 5
 	"fmt"
5 6
 	"strings"
7
+	"sync"
6 8
 	"time"
7 9
 
8 10
 	"github.com/go-kit/kit/log"
9 11
 	"github.com/pkg/errors"
10 12
 
11
-	"sync"
12
-
13
-	"context"
14
-
15 13
 	"github.com/weaveworks/flux"
16 14
 	"github.com/weaveworks/flux/cluster"
17 15
 	"github.com/weaveworks/flux/event"

+ 55
- 8
image/image.go View File

@@ -4,9 +4,11 @@ import (
4 4
 	"encoding/json"
5 5
 	"fmt"
6 6
 	"regexp"
7
+	"sort"
7 8
 	"strings"
8 9
 	"time"
9 10
 
11
+	"github.com/Masterminds/semver"
10 12
 	"github.com/pkg/errors"
11 13
 )
12 14
 
@@ -276,14 +278,59 @@ func (im *Info) UnmarshalJSON(b []byte) error {
276 278
 	return nil
277 279
 }
278 280
 
279
-// ByCreatedDesc is a shim used to sort image info by creation date
280
-type ByCreatedDesc []Info
281
+// NewerByCreated returns true if lhs image should be sorted
282
+// before rhs with regard to their creation date descending.
283
+func NewerByCreated(lhs, rhs *Info) bool {
284
+	if lhs.CreatedAt.Equal(rhs.CreatedAt) {
285
+		return lhs.ID.String() < rhs.ID.String()
286
+	}
287
+	return lhs.CreatedAt.After(rhs.CreatedAt)
288
+}
289
+
290
+// NewerBySemver returns true if lhs image should be sorted
291
+// before rhs with regard to their semver order descending.
292
+func NewerBySemver(lhs, rhs *Info) bool {
293
+	lv, lerr := semver.NewVersion(lhs.ID.Tag)
294
+	rv, rerr := semver.NewVersion(rhs.ID.Tag)
295
+	if (lerr != nil && rerr != nil) || (lv == rv) {
296
+		return lhs.ID.String() < rhs.ID.String()
297
+	}
298
+	if lerr != nil {
299
+		return false
300
+	}
301
+	if rerr != nil {
302
+		return true
303
+	}
304
+	cmp := lv.Compare(rv)
305
+	// In semver, `1.10` and `1.10.0` is the same but in favor of explicitness
306
+	// we should consider the latter newer.
307
+	if cmp == 0 {
308
+		return lhs.ID.String() > rhs.ID.String()
309
+	}
310
+	return cmp > 0
311
+}
281 312
 
282
-func (is ByCreatedDesc) Len() int      { return len(is) }
283
-func (is ByCreatedDesc) Swap(i, j int) { is[i], is[j] = is[j], is[i] }
284
-func (is ByCreatedDesc) Less(i, j int) bool {
285
-	if is[i].CreatedAt.Equal(is[j].CreatedAt) {
286
-		return is[i].ID.String() < is[j].ID.String()
313
+// Sort orders the given image infos according to `newer` func.
314
+func Sort(infos []Info, newer func (a, b *Info) bool) {
315
+	if newer == nil {
316
+		newer = NewerByCreated
287 317
 	}
288
-	return is[i].CreatedAt.After(is[j].CreatedAt)
318
+	sort.Sort(&infoSort{infos: infos, newer: newer})
319
+}
320
+
321
+type infoSort struct {
322
+	infos []Info
323
+	newer func(a, b *Info) bool
324
+}
325
+
326
+func (s *infoSort) Len() int {
327
+	return len(s.infos)
328
+}
329
+
330
+func (s *infoSort) Swap(i, j int) {
331
+	s.infos[i], s.infos[j] = s.infos[j], s.infos[i]
332
+}
333
+
334
+func (s *infoSort) Less(i, j int) bool {
335
+	return s.newer(&s.infos[i], &s.infos[j])
289 336
 }

+ 44
- 9
image/image_test.go View File

@@ -4,10 +4,11 @@ import (
4 4
 	"encoding/json"
5 5
 	"fmt"
6 6
 	"reflect"
7
-	"sort"
8 7
 	"strconv"
9 8
 	"testing"
10 9
 	"time"
10
+
11
+	"github.com/stretchr/testify/assert"
11 12
 )
12 13
 
13 14
 const constTime = "2017-01-13T16:22:58.009923189Z"
@@ -190,17 +191,13 @@ func TestImage_OrderByCreationDate(t *testing.T) {
190 191
 	imE := mustMakeInfo("my/Image:1", testTime)    // test equal
191 192
 	imF := mustMakeInfo("my/Image:5", time.Time{}) // test nil equal
192 193
 	imgs := []Info{imA, imB, imC, imD, imE, imF}
193
-	sort.Sort(ByCreatedDesc(imgs))
194
+	Sort(imgs, NewerByCreated)
194 195
 	checkSorted(t, imgs)
195 196
 	// now check stability
196
-	sort.Sort(ByCreatedDesc(imgs))
197
+	Sort(imgs, NewerByCreated)
197 198
 	checkSorted(t, imgs)
198
-	// more stability checks
199
-	for i := len(imgs)/2 - 1; i >= 0; i-- {
200
-		opp := len(imgs) - 1 - i
201
-		imgs[i], imgs[opp] = imgs[opp], imgs[i]
202
-	}
203
-	sort.Sort(ByCreatedDesc(imgs))
199
+	reverse(imgs)
200
+	Sort(imgs, NewerByCreated)
204 201
 	checkSorted(t, imgs)
205 202
 }
206 203
 
@@ -214,3 +211,41 @@ func checkSorted(t *testing.T, imgs []Info) {
214 211
 		}
215 212
 	}
216 213
 }
214
+
215
+func TestImage_OrderBySemverTagDesc(t *testing.T) {
216
+	ti := time.Time{}
217
+	aa := mustMakeInfo("my/image:3", ti)
218
+	bb := mustMakeInfo("my/image:v1", ti)
219
+	cc := mustMakeInfo("my/image:1.10", ti)
220
+	dd := mustMakeInfo("my/image:1.2.30", ti)
221
+	ee := mustMakeInfo("my/image:1.10.0", ti) // same as 1.10 but should be considered newer
222
+	ff := mustMakeInfo("my/image:bbb-not-semver", ti)
223
+	gg := mustMakeInfo("my/image:aaa-not-semver", ti)
224
+
225
+	imgs := []Info{aa, bb, cc, dd, ee, ff, gg}
226
+	Sort(imgs, NewerBySemver)
227
+
228
+	expected := []Info{aa, ee, cc, dd, bb, gg, ff}
229
+	assert.Equal(t, tags(expected), tags(imgs))
230
+
231
+	// stable?
232
+	reverse(imgs)
233
+	Sort(imgs, NewerBySemver)
234
+	assert.Equal(t, tags(expected), tags(imgs))
235
+}
236
+
237
+func tags(imgs []Info) []string {
238
+	var vs []string
239
+	for _, i := range imgs {
240
+		vs = append(vs, i.ID.Tag)
241
+	}
242
+	return vs
243
+}
244
+
245
+func reverse(imgs []Info) {
246
+	for i := len(imgs)/2 - 1; i >= 0; i-- {
247
+		opp := len(imgs) - 1 - i
248
+		imgs[i], imgs[opp] = imgs[opp], imgs[i]
249
+	}
250
+}
251
+

+ 93
- 0
policy/pattern.go View File

@@ -0,0 +1,93 @@
1
+package policy
2
+
3
+import (
4
+	"github.com/Masterminds/semver"
5
+	"github.com/ryanuber/go-glob"
6
+	"github.com/weaveworks/flux/image"
7
+	"strings"
8
+)
9
+
10
+const (
11
+	globPrefix   = "glob:"
12
+	semverPrefix = "semver:"
13
+)
14
+
15
+var (
16
+	// PatternAll matches everything.
17
+	PatternAll    = NewPattern(globPrefix + "*")
18
+	PatternLatest = NewPattern(globPrefix + "latest")
19
+)
20
+
21
+// Pattern provides an interface to match image tags.
22
+type Pattern interface {
23
+	// Matches returns true if the given image tag matches the pattern.
24
+	Matches(tag string) bool
25
+	// String returns the prefixed string representation.
26
+	String() string
27
+	// Newer returns true if image `a` is newer than image `b`.
28
+	Newer(a, b *image.Info) bool
29
+	// Valid returns true if the pattern is considered valid.
30
+	Valid() bool
31
+}
32
+
33
+type GlobPattern string
34
+
35
+// SemverPattern matches by semantic versioning.
36
+// See https://semver.org/
37
+type SemverPattern struct {
38
+	pattern     string // pattern without prefix
39
+	constraints *semver.Constraints
40
+}
41
+
42
+// NewPattern instantiates a Pattern according to the prefix
43
+// it finds. The prefix can be either `glob:` (default if omitted)
44
+// or `semver:`.
45
+
46
+func NewPattern(pattern string) Pattern {
47
+	if strings.HasPrefix(pattern, semverPrefix) {
48
+		pattern = strings.TrimPrefix(pattern, semverPrefix)
49
+		c, _ := semver.NewConstraint(pattern)
50
+		return SemverPattern{pattern, c}
51
+	}
52
+	return GlobPattern(strings.TrimPrefix(pattern, globPrefix))
53
+}
54
+
55
+func (g GlobPattern) Matches(tag string) bool {
56
+	return glob.Glob(string(g), tag)
57
+}
58
+
59
+func (g GlobPattern) String() string {
60
+	return globPrefix + string(g)
61
+}
62
+
63
+func (g GlobPattern) Newer(a, b *image.Info) bool {
64
+	return image.NewerByCreated(a, b)
65
+}
66
+
67
+func (g GlobPattern) Valid() bool {
68
+	return true
69
+}
70
+
71
+func (s SemverPattern) Matches(tag string) bool {
72
+	v, err := semver.NewVersion(tag)
73
+	if err != nil {
74
+		return false
75
+	}
76
+	if s.constraints == nil {
77
+		// Invalid constraints match anything
78
+		return true
79
+	}
80
+	return s.constraints.Check(v)
81
+}
82
+
83
+func (s SemverPattern) String() string {
84
+	return semverPrefix + s.pattern
85
+}
86
+
87
+func (s SemverPattern) Newer(a, b *image.Info) bool {
88
+	return image.NewerBySemver(a, b)
89
+}
90
+
91
+func (s SemverPattern) Valid() bool {
92
+	return s.constraints != nil
93
+}

+ 88
- 0
policy/pattern_test.go View File

@@ -0,0 +1,88 @@
1
+package policy
2
+
3
+import (
4
+	"testing"
5
+
6
+	"fmt"
7
+	"github.com/stretchr/testify/assert"
8
+)
9
+
10
+func TestGlobPattern_Matches(t *testing.T) {
11
+	for _, tt := range []struct {
12
+		name    string
13
+		pattern string
14
+		true    []string
15
+		false   []string
16
+	}{
17
+		{
18
+			name:    "all",
19
+			pattern: "*",
20
+			true:    []string{"", "1", "foo"},
21
+			false:   nil,
22
+		},
23
+		{
24
+			name:    "all prefixed",
25
+			pattern: "glob:*",
26
+			true:    []string{"", "1", "foo"},
27
+			false:   nil,
28
+		},
29
+		{
30
+			name:    "prefix",
31
+			pattern: "master-*",
32
+			true:    []string{"master-", "master-foo"},
33
+			false:   []string{"", "foo-master"},
34
+		},
35
+	} {
36
+		pattern := NewPattern(tt.pattern)
37
+		assert.IsType(t, GlobPattern(""), pattern)
38
+		t.Run(tt.name, func(t *testing.T) {
39
+			for _, tag := range tt.true {
40
+				assert.True(t, pattern.Matches(tag))
41
+			}
42
+			for _, tag := range tt.false {
43
+				assert.False(t, pattern.Matches(tag))
44
+			}
45
+		})
46
+	}
47
+}
48
+
49
+func TestSemverPattern_Matches(t *testing.T) {
50
+	for _, tt := range []struct {
51
+		name    string
52
+		pattern string
53
+		true    []string
54
+		false   []string
55
+	}{
56
+		{
57
+			name:    "all",
58
+			pattern: "semver:*",
59
+			true:    []string{"1", "1.0", "v1.0.3"},
60
+			false:   []string{"", "latest", "2.0.1-alpha.1"},
61
+		},
62
+		{
63
+			name:    "semver",
64
+			pattern: "semver:~1",
65
+			true:    []string{"v1", "1", "1.2", "1.2.3"},
66
+			false:   []string{"", "latest", "2.0.0"},
67
+		},
68
+		{
69
+			name:    "semver pre-release",
70
+			pattern: "semver:2.0.1-alpha.1",
71
+			true:    []string{"2.0.1-alpha.1"},
72
+			false:   []string{"2.0.1"},
73
+		},
74
+	} {
75
+		pattern := NewPattern(tt.pattern)
76
+		assert.IsType(t, SemverPattern{}, pattern)
77
+		for _, tag := range tt.true {
78
+			t.Run(fmt.Sprintf("%s[%q]", tt.name, tag), func(t *testing.T) {
79
+				assert.True(t, pattern.Matches(tag))
80
+			})
81
+		}
82
+		for _, tag := range tt.false {
83
+			t.Run(fmt.Sprintf("%s[%q]", tt.name, tag), func(t *testing.T) {
84
+				assert.False(t, pattern.Matches(tag))
85
+			})
86
+		}
87
+	}
88
+}

+ 10
- 6
policy/policy.go View File

@@ -36,15 +36,19 @@ func Tag(policy Policy) bool {
36 36
 	return strings.HasPrefix(string(policy), "tag.")
37 37
 }
38 38
 
39
-func GetTagPattern(services ResourceMap, service flux.ResourceID, container string) string {
39
+func GetTagPattern(services ResourceMap, service flux.ResourceID, container string) Pattern {
40 40
 	if services == nil {
41
-		return "*"
41
+		return PatternAll
42 42
 	}
43
-	policies := services[service]
44
-	if pattern, ok := policies.Get(TagPrefix(container)); ok {
45
-		return strings.TrimPrefix(pattern, "glob:")
43
+	policies, ok := services[service]
44
+	if !ok {
45
+		return PatternAll
46 46
 	}
47
-	return "*"
47
+	pattern, ok := policies.Get(TagPrefix(container))
48
+	if !ok {
49
+		return PatternAll
50
+	}
51
+	return NewPattern(pattern)
48 52
 }
49 53
 
50 54
 type Updates map[flux.ResourceID]Update

+ 5
- 4
policy/policy_test.go View File

@@ -63,17 +63,17 @@ func Test_GetTagPattern(t *testing.T) {
63 63
 	tests := []struct {
64 64
 		name string
65 65
 		args args
66
-		want string
66
+		want Pattern
67 67
 	}{
68 68
 		{
69 69
 			name: "Nil policies",
70 70
 			args: args{services: nil},
71
-			want: "*",
71
+			want: PatternAll,
72 72
 		},
73 73
 		{
74 74
 			name: "No match",
75 75
 			args: args{services: ResourceMap{}},
76
-			want: "*",
76
+			want: PatternAll,
77 77
 		},
78 78
 		{
79 79
 			name: "Match",
@@ -86,13 +86,14 @@ func Test_GetTagPattern(t *testing.T) {
86 86
 				service:   resourceID,
87 87
 				container: container,
88 88
 			},
89
-			want: "master-*",
89
+			want: NewPattern("master-*"),
90 90
 		},
91 91
 	}
92 92
 	for _, tt := range tests {
93 93
 		t.Run(tt.name, func(t *testing.T) {
94 94
 			if got := GetTagPattern(tt.args.services, tt.args.service, tt.args.container); got != tt.want {
95 95
 				t.Errorf("GetTagPattern() = %v, want %v", got, tt.want)
96
+
96 97
 			}
97 98
 		})
98 99
 	}

+ 2
- 2
registry/cache/memcached/integration_test.go View File

@@ -66,14 +66,14 @@ Loop:
66 66
 		case <-timeout.C:
67 67
 			t.Fatal("Cache timeout")
68 68
 		case <-tick.C:
69
-			_, err := r.GetSortedRepositoryImages(id.Name)
69
+			_, err := r.GetRepositoryImages(id.Name)
70 70
 			if err == nil {
71 71
 				break Loop
72 72
 			}
73 73
 		}
74 74
 	}
75 75
 
76
-	img, err := r.GetSortedRepositoryImages(id.Name)
76
+	img, err := r.GetRepositoryImages(id.Name)
77 77
 	if err != nil {
78 78
 		t.Fatal(err)
79 79
 	}

+ 2
- 4
registry/cache/registry.go View File

@@ -2,7 +2,6 @@ package cache
2 2
 
3 3
 import (
4 4
 	"encoding/json"
5
-	"sort"
6 5
 	"time"
7 6
 
8 7
 	"github.com/pkg/errors"
@@ -31,9 +30,9 @@ type Cache struct {
31 30
 	Reader Reader
32 31
 }
33 32
 
34
-// GetSortedRepositoryImages returns the list of image manifests in an image
33
+// GetRepositoryImages returns the list of image manifests in an image
35 34
 // repository (e.g,. at "quay.io/weaveworks/flux")
36
-func (c *Cache) GetSortedRepositoryImages(id image.Name) ([]image.Info, error) {
35
+func (c *Cache) GetRepositoryImages(id image.Name) ([]image.Info, error) {
37 36
 	repoKey := NewRepositoryKey(id.CanonicalName())
38 37
 	bytes, _, err := c.Reader.GetKey(repoKey)
39 38
 	if err != nil {
@@ -59,7 +58,6 @@ func (c *Cache) GetSortedRepositoryImages(id image.Name) ([]image.Info, error) {
59 58
 		images[i] = im
60 59
 		i++
61 60
 	}
62
-	sort.Sort(image.ByCreatedDesc(images))
63 61
 	return images, nil
64 62
 }
65 63
 

+ 1
- 1
registry/cache/warming_test.go View File

@@ -71,7 +71,7 @@ func TestWarm(t *testing.T) {
71 71
 	warmer.warm(context.TODO(), logger, repo, registry.NoCredentials())
72 72
 
73 73
 	registry := &Cache{Reader: c}
74
-	repoInfo, err := registry.GetSortedRepositoryImages(ref.Name)
74
+	repoInfo, err := registry.GetRepositoryImages(ref.Name)
75 75
 	if err != nil {
76 76
 		t.Error(err)
77 77
 	}

+ 1
- 4
registry/mock/mock.go View File

@@ -2,8 +2,6 @@ package mock
2 2
 
3 3
 import (
4 4
 	"context"
5
-	"sort"
6
-
7 5
 	"github.com/pkg/errors"
8 6
 
9 7
 	"github.com/weaveworks/flux/image"
@@ -41,7 +39,7 @@ type Registry struct {
41 39
 	Err    error
42 40
 }
43 41
 
44
-func (m *Registry) GetSortedRepositoryImages(id image.Name) ([]image.Info, error) {
42
+func (m *Registry) GetRepositoryImages(id image.Name) ([]image.Info, error) {
45 43
 	var imgs []image.Info
46 44
 	for _, i := range m.Images {
47 45
 		// include only if it's the same repository in the same place
@@ -49,7 +47,6 @@ func (m *Registry) GetSortedRepositoryImages(id image.Name) ([]image.Info, error
49 47
 			imgs = append(imgs, i)
50 48
 		}
51 49
 	}
52
-	sort.Sort(image.ByCreatedDesc(imgs))
53 50
 	return imgs, m.Err
54 51
 }
55 52
 

+ 2
- 2
registry/monitoring.go View File

@@ -46,9 +46,9 @@ func NewInstrumentedRegistry(next Registry) Registry {
46 46
 	}
47 47
 }
48 48
 
49
-func (m *instrumentedRegistry) GetSortedRepositoryImages(id image.Name) (res []image.Info, err error) {
49
+func (m *instrumentedRegistry) GetRepositoryImages(id image.Name) (res []image.Info, err error) {
50 50
 	start := time.Now()
51
-	res, err = m.next.GetSortedRepositoryImages(id)
51
+	res, err = m.next.GetRepositoryImages(id)
52 52
 	registryDuration.With(
53 53
 		fluxmetrics.LabelSuccess, strconv.FormatBool(err == nil),
54 54
 	).Observe(time.Since(start).Seconds())

+ 1
- 1
registry/registry.go View File

@@ -12,7 +12,7 @@ var (
12 12
 
13 13
 // Registry is a store of image metadata.
14 14
 type Registry interface {
15
-	GetSortedRepositoryImages(image.Name) ([]image.Info, error)
15
+	GetRepositoryImages(image.Name) ([]image.Info, error)
16 16
 	GetImage(image.Ref) (image.Info, error)
17 17
 }
18 18
 

+ 10
- 9
release/releaser_test.go View File

@@ -1,7 +1,6 @@
1 1
 package release
2 2
 
3 3
 import (
4
-	"encoding/json"
5 4
 	"fmt"
6 5
 	"reflect"
7 6
 	"testing"
@@ -203,6 +202,7 @@ func Test_FilterLogic(t *testing.T) {
203 202
 				flux.MustParseResourceID("default:deployment/test-service"):   ignoredNotIncluded,
204 203
 				flux.MustParseResourceID("default:deployment/multi-deploy"):   ignoredNotIncluded,
205 204
 				flux.MustParseResourceID("default:deployment/list-deploy"):    ignoredNotIncluded,
205
+				flux.MustParseResourceID("default:deployment/semver"):         ignoredNotIncluded,
206 206
 			},
207 207
 		}, {
208 208
 			Name: "exclude specific service",
@@ -235,6 +235,7 @@ func Test_FilterLogic(t *testing.T) {
235 235
 				flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster,
236 236
 				flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster,
237 237
 				flux.MustParseResourceID("default:deployment/list-deploy"):  skippedNotInCluster,
238
+				flux.MustParseResourceID("default:deployment/semver"):       skippedNotInCluster,
238 239
 			},
239 240
 		}, {
240 241
 			Name: "update specific image",
@@ -262,6 +263,7 @@ func Test_FilterLogic(t *testing.T) {
262 263
 				flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster,
263 264
 				flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster,
264 265
 				flux.MustParseResourceID("default:deployment/list-deploy"):  skippedNotInCluster,
266
+				flux.MustParseResourceID("default:deployment/semver"):       skippedNotInCluster,
265 267
 			},
266 268
 		},
267 269
 		// skipped if: not ignored AND (locked or not found in cluster)
@@ -297,6 +299,7 @@ func Test_FilterLogic(t *testing.T) {
297 299
 				flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster,
298 300
 				flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster,
299 301
 				flux.MustParseResourceID("default:deployment/list-deploy"):  skippedNotInCluster,
302
+				flux.MustParseResourceID("default:deployment/semver"):       skippedNotInCluster,
300 303
 			},
301 304
 		},
302 305
 		{
@@ -330,6 +333,7 @@ func Test_FilterLogic(t *testing.T) {
330 333
 				flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster,
331 334
 				flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster,
332 335
 				flux.MustParseResourceID("default:deployment/list-deploy"):  skippedNotInCluster,
336
+				flux.MustParseResourceID("default:deployment/semver"):       skippedNotInCluster,
333 337
 			},
334 338
 		},
335 339
 		{
@@ -346,6 +350,7 @@ func Test_FilterLogic(t *testing.T) {
346 350
 				flux.MustParseResourceID("default:deployment/test-service"):   ignoredNotIncluded,
347 351
 				flux.MustParseResourceID("default:deployment/multi-deploy"):   ignoredNotIncluded,
348 352
 				flux.MustParseResourceID("default:deployment/list-deploy"):    ignoredNotIncluded,
353
+				flux.MustParseResourceID("default:deployment/semver"):         ignoredNotIncluded,
349 354
 				flux.MustParseResourceID(notInRepoService):                    skippedNotInRepo,
350 355
 			},
351 356
 		},
@@ -397,6 +402,7 @@ func Test_ImageStatus(t *testing.T) {
397 402
 				flux.MustParseResourceID("default:deployment/locked-service"): ignoredNotIncluded,
398 403
 				flux.MustParseResourceID("default:deployment/multi-deploy"):   ignoredNotIncluded,
399 404
 				flux.MustParseResourceID("default:deployment/list-deploy"):    ignoredNotIncluded,
405
+				flux.MustParseResourceID("default:deployment/semver"):         ignoredNotIncluded,
400 406
 				flux.MustParseResourceID("default:deployment/test-service"): update.ControllerResult{
401 407
 					Status: update.ReleaseStatusIgnored,
402 408
 					Error:  update.DoesNotUseImage,
@@ -419,6 +425,7 @@ func Test_ImageStatus(t *testing.T) {
419 425
 				flux.MustParseResourceID("default:deployment/test-service"):   ignoredNotIncluded,
420 426
 				flux.MustParseResourceID("default:deployment/multi-deploy"):   ignoredNotIncluded,
421 427
 				flux.MustParseResourceID("default:deployment/list-deploy"):    ignoredNotIncluded,
428
+				flux.MustParseResourceID("default:deployment/semver"):         ignoredNotIncluded,
422 429
 			},
423 430
 		},
424 431
 	} {
@@ -686,14 +693,8 @@ func Test_UpdateContainers(t *testing.T) {
686 693
 
687 694
 func testRelease(t *testing.T, ctx *ReleaseContext, spec update.ReleaseSpec, expected update.Result) {
688 695
 	results, err := Release(ctx, spec, log.NewNopLogger())
689
-	if err != nil {
690
-		t.Fatal(err)
691
-	}
692
-	if !reflect.DeepEqual(expected, results) {
693
-		exp, _ := json.Marshal(expected)
694
-		got, _ := json.Marshal(results)
695
-		t.Errorf("--- expected ---\n%s\n--- got ---\n%s\n", string(exp), string(got))
696
-	}
696
+	assert.NoError(t, err)
697
+	assert.Equal(t, expected, results)
697 698
 }
698 699
 
699 700
 // --- test verification

+ 2
- 2
site/using.md View File

@@ -400,8 +400,8 @@ or only release images that have a stable semantic version tag (X.Y.Z):
400 400
 fluxctl policy --controller=default:deployment/helloworld --tag-all='semver:*'
401 401
 ```
402 402
 
403
-This will also affect the way flux determines the newest image tag
404
-by looking at the newest version instead of image creation date.
403
+Using a semver filter will also affect how flux sorts images, so
404
+that the higher versions will be considered newer.
405 405
 
406 406
 ## Actions triggered through `fluxctl`
407 407
 

+ 14
- 10
update/images.go View File

@@ -6,10 +6,10 @@ import (
6 6
 
7 7
 	"github.com/go-kit/kit/log"
8 8
 	"github.com/pkg/errors"
9
-	glob "github.com/ryanuber/go-glob"
10 9
 
11 10
 	fluxerr "github.com/weaveworks/flux/errors"
12 11
 	"github.com/weaveworks/flux/image"
12
+	"github.com/weaveworks/flux/policy"
13 13
 	"github.com/weaveworks/flux/registry"
14 14
 	"github.com/weaveworks/flux/resource"
15 15
 )
@@ -38,24 +38,28 @@ func (r ImageRepos) GetRepoImages(repo image.Name) ImageInfos {
38 38
 // ImageInfos is a list of image.Info which can be filtered.
39 39
 type ImageInfos []image.Info
40 40
 
41
-// Filter returns only the images which match the tagGlob.
42
-func (ii ImageInfos) Filter(tagGlob string) ImageInfos {
41
+// Filter returns only the images that match the pattern, in a new list.
42
+// It also sorts the images according to the pattern's order.
43
+func (ii ImageInfos) Filter(pattern policy.Pattern) ImageInfos {
43 44
 	var filtered ImageInfos
44 45
 	for _, i := range ii {
45 46
 		tag := i.ID.Tag
46 47
 		// Ignore latest if and only if it's not what the user wants.
47
-		if !strings.EqualFold(tagGlob, "latest") && strings.EqualFold(tag, "latest") {
48
+		if pattern != policy.PatternLatest && strings.EqualFold(tag, "latest") {
48 49
 			continue
49 50
 		}
50
-		if glob.Glob(tagGlob, tag) {
51
-			var im image.Info
52
-			im = i
53
-			filtered = append(filtered, im)
51
+		if pattern.Matches(tag) {
52
+			filtered = append(filtered, i)
54 53
 		}
55 54
 	}
55
+	filtered.Sort(pattern)
56 56
 	return filtered
57 57
 }
58 58
 
59
+func (ii ImageInfos) Sort(pattern policy.Pattern) {
60
+	image.Sort(ii, pattern.Newer)
61
+}
62
+
59 63
 // Latest returns the latest image from ImageInfos. If no such image exists,
60 64
 // returns a zero value and `false`, and the caller can decide whether
61 65
 // that's an error or not.
@@ -109,7 +113,7 @@ func FetchImageRepos(reg registry.Registry, cs containers, logger log.Logger) (I
109 113
 		}
110 114
 	}
111 115
 	for repo := range imageRepos {
112
-		sortedRepoImages, err := reg.GetSortedRepositoryImages(repo.Name)
116
+		images, err := reg.GetRepositoryImages(repo.Name)
113 117
 		if err != nil {
114 118
 			// Not an error if missing. Use empty images.
115 119
 			if !fluxerr.IsMissing(err) {
@@ -117,7 +121,7 @@ func FetchImageRepos(reg registry.Registry, cs containers, logger log.Logger) (I
117 121
 				continue
118 122
 			}
119 123
 		}
120
-		imageRepos[repo] = sortedRepoImages
124
+		imageRepos[repo] = images
121 125
 	}
122 126
 	return ImageRepos{imageRepos}, nil
123 127
 }

+ 29
- 2
update/images_test.go View File

@@ -4,7 +4,10 @@ import (
4 4
 	"testing"
5 5
 	"time"
6 6
 
7
+	"github.com/stretchr/testify/assert"
8
+
7 9
 	"github.com/weaveworks/flux/image"
10
+	"github.com/weaveworks/flux/policy"
8 11
 )
9 12
 
10 13
 var (
@@ -24,7 +27,7 @@ func TestDecanon(t *testing.T) {
24 27
 		name: infos,
25 28
 	}}
26 29
 
27
-	filteredImages := m.GetRepoImages(mustParseName("weaveworks/helloworld")).Filter("*")
30
+	filteredImages := m.GetRepoImages(mustParseName("weaveworks/helloworld")).Filter(policy.PatternAll)
28 31
 	latest, ok := filteredImages.Latest()
29 32
 	if !ok {
30 33
 		t.Error("did not find latest image")
@@ -32,7 +35,7 @@ func TestDecanon(t *testing.T) {
32 35
 		t.Error("name did not match what was asked")
33 36
 	}
34 37
 
35
-	filteredImages = m.GetRepoImages(mustParseName("index.docker.io/weaveworks/helloworld")).Filter("*")
38
+	filteredImages = m.GetRepoImages(mustParseName("index.docker.io/weaveworks/helloworld")).Filter(policy.PatternAll)
36 39
 	latest, ok = filteredImages.Latest()
37 40
 	if !ok {
38 41
 		t.Error("did not find latest image")
@@ -51,6 +54,30 @@ func TestDecanon(t *testing.T) {
51 54
 	}
52 55
 }
53 56
 
57
+func TestImageInfos_Filter_latest(t *testing.T) {
58
+	latest := image.Info{
59
+		ID: image.Ref{Name: image.Name{Image: "flux"}, Tag: "latest"},
60
+	}
61
+	other := image.Info{
62
+		ID: image.Ref{Name: image.Name{Image: "moon"}, Tag: "v0"},
63
+	}
64
+	ii := ImageInfos{latest, other}
65
+	assert.Equal(t, ImageInfos{latest}, ii.Filter(policy.PatternLatest))
66
+	assert.Equal(t, ImageInfos{latest}, ii.Filter(policy.NewPattern("latest")))
67
+	assert.Equal(t, ImageInfos{other}, ii.Filter(policy.PatternAll))
68
+	assert.Equal(t, ImageInfos{other}, ii.Filter(policy.NewPattern("*")))
69
+}
70
+
71
+func TestImageInfos_Filter_semver(t *testing.T) {
72
+	latest := image.Info{ID: image.Ref{Name: image.Name{Image: "flux"}, Tag: "latest"}}
73
+	semver0 := image.Info{ID: image.Ref{Name: image.Name{Image: "moon"}, Tag: "v0.0.1"}}
74
+	semver1 := image.Info{ID: image.Ref{Name: image.Name{Image: "earth"}, Tag: "1.0.0"}}
75
+
76
+	ii := ImageInfos{latest, semver0, semver1}
77
+	assert.Equal(t, ImageInfos{semver1, semver0}, ii.Filter(policy.NewPattern("semver:*")))
78
+	assert.Equal(t, ImageInfos{semver1}, ii.Filter(policy.NewPattern("semver:~1")))
79
+}
80
+
54 81
 func TestAvail(t *testing.T) {
55 82
 	m := ImageRepos{imageReposMap{name: infos}}
56 83
 	avail := m.GetRepoImages(mustParseName("weaveworks/goodbyeworld"))

+ 1
- 1
update/release.go View File

@@ -231,7 +231,7 @@ func (s ReleaseSpec) calculateImageUpdates(rc ReleaseContext, candidates []*Cont
231 231
 		for _, container := range containers {
232 232
 			currentImageID := container.Image
233 233
 
234
-			filteredImages := imageRepos.GetRepoImages(currentImageID.Name).Filter("*")
234
+			filteredImages := imageRepos.GetRepoImages(currentImageID.Name).Filter(policy.PatternAll)
235 235
 			latestImage, ok := filteredImages.Latest()
236 236
 			if !ok {
237 237
 				if currentImageID.CanonicalName() != singleRepo {

Loading…
Cancel
Save