From 54caaba9cb17e63b79461a3fc79c4b1e166bd63b Mon Sep 17 00:00:00 2001 From: Roland Schilter Date: Thu, 26 Jul 2018 19:26:12 -0700 Subject: [PATCH] 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 --- Gopkg.toml | 4 +- api/v6/container.go | 9 +- api/v6/container_test.go | 40 +++++-- cluster/kubernetes/policies.go | 9 +- cluster/kubernetes/policies_test.go | 64 ++++++++++-- cluster/kubernetes/testfiles/data.go | 27 +++++ cmd/fluxctl/policy_cmd.go | 4 +- daemon/daemon.go | 21 ++-- daemon/daemon_test.go | 103 +++++++++++++++++-- daemon/images.go | 2 +- daemon/loop.go | 6 +- image/image.go | 60 +++++++++-- image/image_test.go | 51 +++++++-- policy/pattern.go | 93 +++++++++++++++++ policy/pattern_test.go | 82 +++++++++++++++ policy/policy.go | 8 +- policy/policy_test.go | 9 +- registry/cache/memcached/integration_test.go | 4 +- registry/cache/registry.go | 6 +- registry/cache/warming_test.go | 2 +- registry/mock/mock.go | 6 +- registry/monitoring.go | 4 +- registry/registry.go | 2 +- release/releaser_test.go | 19 ++-- update/images.go | 28 +++-- update/images_test.go | 31 +++++- update/release.go | 2 +- 27 files changed, 590 insertions(+), 106 deletions(-) create mode 100644 policy/pattern.go create mode 100644 policy/pattern_test.go diff --git a/Gopkg.toml b/Gopkg.toml index ec587fc3b0..e6a33fde2c 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -47,5 +47,5 @@ required = ["k8s.io/code-generator/cmd/client-gen"] version = "v1.0.0" [[constraint]] - branch = "master" - name = "github.com/pkg/term" + name = "github.com/Masterminds/semver" + version = "1.4.0" diff --git a/api/v6/container.go b/api/v6/container.go index b09bdda8cf..6689b9fdb7 100644 --- a/api/v6/container.go +++ b/api/v6/container.go @@ -3,6 +3,7 @@ package v6 import ( "github.com/pkg/errors" "github.com/weaveworks/flux/image" + "github.com/weaveworks/flux/policy" "github.com/weaveworks/flux/registry" "github.com/weaveworks/flux/update" ) @@ -26,7 +27,9 @@ type Container struct { } // NewContainer creates a Container given a list of images and the current image -func NewContainer(name string, images update.ImageInfos, currentImage image.Info, tagPattern string, fields []string) (Container, error) { +func NewContainer(name string, images update.ImageInfos, currentImage image.Info, tagPattern policy.Pattern, fields []string) (Container, error) { + images.Sort(tagPattern) + // All images imagesCount := len(images) imagesErr := "" @@ -35,7 +38,7 @@ func NewContainer(name string, images update.ImageInfos, currentImage image.Info } var newImages []image.Info for _, img := range images { - if img.CreatedAt.After(currentImage.CreatedAt) { + if tagPattern.ImageNewerFunc()(&img, ¤tImage) { newImages = append(newImages, img) } } @@ -46,7 +49,7 @@ func NewContainer(name string, images update.ImageInfos, currentImage image.Info filteredImagesCount := len(filteredImages) var newFilteredImages []image.Info for _, img := range filteredImages { - if img.CreatedAt.After(currentImage.CreatedAt) { + if tagPattern.ImageNewerFunc()(&img, ¤tImage) { newFilteredImages = append(newFilteredImages, img) } } diff --git a/api/v6/container_test.go b/api/v6/container_test.go index 57ebaaa5df..371a8f44d9 100644 --- a/api/v6/container_test.go +++ b/api/v6/container_test.go @@ -4,7 +4,10 @@ import ( "reflect" "testing" + "github.com/stretchr/testify/assert" + "github.com/weaveworks/flux/image" + "github.com/weaveworks/flux/policy" "github.com/weaveworks/flux/update" ) @@ -12,11 +15,15 @@ func TestNewContainer(t *testing.T) { testImage := image.Info{ImageID: "test"} + currentSemver := image.Info{ID: image.Ref{Tag: "1.0.0"}} + oldSemver := image.Info{ID: image.Ref{Tag: "0.9.0"}} + newSemver := image.Info{ID: image.Ref{Tag: "1.2.3"}} + type args struct { name string images update.ImageInfos currentImage image.Info - tagPattern string + tagPattern policy.Pattern fields []string } tests := []struct { @@ -31,7 +38,7 @@ func TestNewContainer(t *testing.T) { name: "container1", images: update.ImageInfos{testImage}, currentImage: testImage, - tagPattern: "*", + tagPattern: policy.PatternAll, }, want: Container{ Name: "container1", @@ -45,17 +52,32 @@ func TestNewContainer(t *testing.T) { }, wantErr: false, }, + { + name: "Semver filtering and sorting", + args: args{ + name: "container-semver", + images: update.ImageInfos{currentSemver, newSemver, oldSemver, testImage}, + currentImage: currentSemver, + tagPattern: policy.NewPattern("semver:*"), + }, + want: Container{ + Name: "container-semver", + Current: currentSemver, + LatestFiltered: newSemver, + Available: []image.Info{newSemver, currentSemver, oldSemver, testImage}, + AvailableImagesCount: 4, + NewAvailableImagesCount: 1, + FilteredImagesCount: 3, + NewFilteredImagesCount: 1, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := NewContainer(tt.args.name, tt.args.images, tt.args.currentImage, tt.args.tagPattern, tt.args.fields) - if (err != nil) != tt.wantErr { - t.Errorf("NewContainer() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("NewContainer() = %v, want %v", got, tt.want) - } + assert.Equal(t, tt.wantErr, err != nil) + assert.Equal(t, tt.want, got) }) } } diff --git a/cluster/kubernetes/policies.go b/cluster/kubernetes/policies.go index df061b5824..3f3c4e1ce7 100644 --- a/cluster/kubernetes/policies.go +++ b/cluster/kubernetes/policies.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/pkg/errors" - yaml "gopkg.in/yaml.v2" + "gopkg.in/yaml.v2" "github.com/weaveworks/flux" kresource "github.com/weaveworks/flux/cluster/kubernetes/resource" @@ -27,7 +27,7 @@ func (m *Manifests) UpdatePolicies(def []byte, id flux.ResourceID, update policy } for _, container := range containers { - if tagAll == "glob:*" { + if tagAll == policy.PatternAll.String() { del = del.Add(policy.TagPrefix(container.Name)) } else { add = add.Set(policy.TagPrefix(container.Name), tagAll) @@ -35,8 +35,11 @@ func (m *Manifests) UpdatePolicies(def []byte, id flux.ResourceID, update policy } } - args := []string{} + var args []string for pol, val := range add { + if policy.Tag(pol) && !policy.NewPattern(val).Valid() { + return nil, fmt.Errorf("invalid tag pattern: %q", val) + } args = append(args, fmt.Sprintf("%s%s=%s", kresource.PolicyPrefix, pol, val)) } for pol, _ := range del { diff --git a/cluster/kubernetes/policies_test.go b/cluster/kubernetes/policies_test.go index 8586eca585..f17bba15df 100644 --- a/cluster/kubernetes/policies_test.go +++ b/cluster/kubernetes/policies_test.go @@ -5,6 +5,8 @@ import ( "testing" "text/template" + "github.com/stretchr/testify/assert" + "github.com/weaveworks/flux" "github.com/weaveworks/flux/policy" ) @@ -14,6 +16,7 @@ func TestUpdatePolicies(t *testing.T) { name string in, out []string update policy.Update + wantErr bool }{ { name: "adding annotation with others existing", @@ -113,17 +116,60 @@ func TestUpdatePolicies(t *testing.T) { Remove: policy.Set{policy.LockedMsg: "foo"}, }, }, + { + name: "add tag policy", + in: nil, + out: []string{"flux.weave.works/tag.nginx", "glob:*"}, + update: policy.Update{ + Add: policy.Set{policy.TagPrefix("nginx"): "glob:*"}, + }, + }, + { + name: "add non-glob tag policy", + in: nil, + out: []string{"flux.weave.works/tag.nginx", "foo"}, + update: policy.Update{ + Add: policy.Set{policy.TagPrefix("nginx"): "foo"}, + }, + }, + { + name: "add semver tag policy", + in: nil, + out: []string{"flux.weave.works/tag.nginx", "semver:*"}, + update: policy.Update{ + Add: policy.Set{policy.TagPrefix("nginx"): "semver:*"}, + }, + }, + { + name: "add invalid semver tag policy", + in: nil, + out: []string{"flux.weave.works/tag.nginx", "semver:*"}, + update: policy.Update{ + Add: policy.Set{policy.TagPrefix("nginx"): "semver:invalid"}, + }, + wantErr: true, + }, } { - caseIn := templToString(t, annotationsTemplate, c.in) - caseOut := templToString(t, annotationsTemplate, c.out) - resourceID := flux.MustParseResourceID("default:deployment/nginx") - out, err := (&Manifests{}).UpdatePolicies([]byte(caseIn), resourceID, c.update) - if err != nil { - t.Errorf("[%s] %v", c.name, err) - } else if string(out) != caseOut { - t.Errorf("[%s] Did not get expected result:\n\n%s\n\nInstead got:\n\n%s", c.name, caseOut, string(out)) - } + t.Run(c.name, func(t *testing.T) { + caseIn := templToString(t, annotationsTemplate, c.in) + caseOut := templToString(t, annotationsTemplate, c.out) + resourceID := flux.MustParseResourceID("default:deployment/nginx") + out, err := (&Manifests{}).UpdatePolicies([]byte(caseIn), resourceID, c.update) + assert.Equal(t, c.wantErr, err != nil) + if !c.wantErr { + assert.Equal(t, string(out), caseOut) + } + }) + } +} + +func TestUpdatePolicies_invalidTagPattern(t *testing.T) { + resourceID := flux.MustParseResourceID("default:deployment/nginx") + update := policy.Update{ + Add: policy.Set{policy.TagPrefix("nginx"): "semver:invalid"}, } + _, err := (&Manifests{}).UpdatePolicies(nil, resourceID, update) + assert.Error(t, err) } var annotationsTemplate = template.Must(template.New("").Parse(`--- diff --git a/cluster/kubernetes/testfiles/data.go b/cluster/kubernetes/testfiles/data.go index 2ec9eb2888..0fde7e641b 100644 --- a/cluster/kubernetes/testfiles/data.go +++ b/cluster/kubernetes/testfiles/data.go @@ -52,6 +52,7 @@ var ResourceMap = map[flux.ResourceID]string{ flux.MustParseResourceID("default:service/multi-service"): "multi.yaml", flux.MustParseResourceID("default:deployment/list-deploy"): "list.yaml", flux.MustParseResourceID("default:service/list-service"): "list.yaml", + flux.MustParseResourceID("default:deployment/semver"): "semver-deploy.yaml", } // ServiceMap ... given a base path, construct the map representing @@ -64,6 +65,7 @@ func ServiceMap(dir string) map[flux.ResourceID][]string { flux.MustParseResourceID("default:deployment/test-service"): []string{filepath.Join(dir, "test/test-service-deploy.yaml")}, flux.MustParseResourceID("default:deployment/multi-deploy"): []string{filepath.Join(dir, "multi.yaml")}, flux.MustParseResourceID("default:deployment/list-deploy"): []string{filepath.Join(dir, "list.yaml")}, + flux.MustParseResourceID("default:deployment/semver"): []string{filepath.Join(dir, "semver-deploy.yaml")}, } } @@ -96,6 +98,31 @@ spec: - -addr=:8080 ports: - containerPort: 8080 +`, + // Automated deployment with semver enabled + "semver-deploy.yaml": `--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: semver + annotations: + flux.weave.works/automated: "true" + flux.weave.works/tag.greeter: semver:* +spec: + minReadySeconds: 1 + replicas: 5 + template: + metadata: + labels: + name: semver + spec: + containers: + - name: greeter + image: quay.io/weaveworks/helloworld:master-a000001 + args: + - -msg=Ahoy + ports: + - containerPort: 80 `, "locked-service-deploy.yaml": `apiVersion: extensions/v1beta1 kind: Deployment diff --git a/cmd/fluxctl/policy_cmd.go b/cmd/fluxctl/policy_cmd.go index 894ea2ac10..e6e668bd75 100644 --- a/cmd/fluxctl/policy_cmd.go +++ b/cmd/fluxctl/policy_cmd.go @@ -142,7 +142,7 @@ func calculatePolicyChanges(opts *controllerPolicyOpts) (policy.Update, error) { Add(policy.LockedUser) } if opts.tagAll != "" { - add = add.Set(policy.TagAll, "glob:"+opts.tagAll) + add = add.Set(policy.TagAll, policy.PatternAll.String()) } for _, tagPair := range opts.tags { @@ -153,7 +153,7 @@ func calculatePolicyChanges(opts *controllerPolicyOpts) (policy.Update, error) { container, tag := parts[0], parts[1] if tag != "*" { - add = add.Set(policy.TagPrefix(container), "glob:"+tag) + add = add.Set(policy.TagPrefix(container), policy.NewPattern(tag).String()) } else { remove = remove.Add(policy.TagPrefix(container)) } diff --git a/daemon/daemon.go b/daemon/daemon.go index 3003a73109..1e6a997456 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -134,14 +134,21 @@ func (d *Daemon) ListServices(ctx context.Context, namespace string) ([]v6.Contr return res, nil } -type clusterContainers []cluster.Controller +type clusterContainers struct { + controllers []cluster.Controller + policies policy.ResourceMap +} func (cs clusterContainers) Len() int { - return len(cs) + return len(cs.controllers) } func (cs clusterContainers) Containers(i int) []resource.Container { - return cs[i].ContainersOrNil() + return cs.controllers[i].ContainersOrNil() +} + +func (cs clusterContainers) Pattern(i int, container string) policy.Pattern { + return policy.GetTagPattern(cs.policies, cs.controllers[i].ID, container) } // ListImages - deprecated from v10, lists the images available for set of services @@ -163,14 +170,14 @@ func (d *Daemon) ListImagesWithOptions(ctx context.Context, opts v10.ListImagesO services, err = d.Cluster.SomeControllers([]flux.ResourceID{id}) } - imageRepos, err := update.FetchImageRepos(d.Registry, clusterContainers(services), d.Logger) + policyResourceMap, _, err := d.getPolicyResourceMap(ctx) if err != nil { - return nil, errors.Wrap(err, "getting images for services") + return nil, err } - policyResourceMap, _, err := d.getPolicyResourceMap(ctx) + imageRepos, err := update.FetchImageRepos(d.Registry, clusterContainers{controllers: services, policies: policyResourceMap}, d.Logger) if err != nil { - return nil, err + return nil, errors.Wrap(err, "getting images for services") } var res []v6.ImageStatus diff --git a/daemon/daemon_test.go b/daemon/daemon_test.go index 35516d1065..db1a082ebb 100644 --- a/daemon/daemon_test.go +++ b/daemon/daemon_test.go @@ -38,6 +38,7 @@ const ( svc = "default:deployment/helloworld" container = "greeter" ns = "default" + oldHelloImage = "quay.io/weaveworks/helloworld:3" // older in time but newer version! newHelloImage = "quay.io/weaveworks/helloworld:2" currentHelloImage = "quay.io/weaveworks/helloworld:master-a000001" @@ -151,6 +152,8 @@ func TestDaemon_ListImagesWithOptions(t *testing.T) { assert.NoError(t, err) newImageRef, err := image.ParseRef(newHelloImage) assert.NoError(t, err) + oldImageRef, err := image.ParseRef(oldHelloImage) + assert.NoError(t, err) // Service 2 anotherSvcID, err := flux.ParseResourceID(anotherSvc) @@ -180,10 +183,11 @@ func TestDaemon_ListImagesWithOptions(t *testing.T) { Available: []image.Info{ {ID: newImageRef}, {ID: currentImageRef}, + {ID: oldImageRef}, }, - AvailableImagesCount: 2, + AvailableImagesCount: 3, NewAvailableImagesCount: 1, - FilteredImagesCount: 2, + FilteredImagesCount: 3, NewFilteredImagesCount: 1, }, }, @@ -222,10 +226,11 @@ func TestDaemon_ListImagesWithOptions(t *testing.T) { Available: []image.Info{ {ID: newImageRef}, {ID: currentImageRef}, + {ID: oldImageRef}, }, - AvailableImagesCount: 2, + AvailableImagesCount: 3, NewAvailableImagesCount: 1, - FilteredImagesCount: 2, + FilteredImagesCount: 3, NewFilteredImagesCount: 1, }, }, @@ -478,6 +483,57 @@ func TestDaemon_JobStatusWithNoCache(t *testing.T) { w.ForJobSucceeded(d, id) } +func TestDaemon_Automated(t *testing.T) { + d, start, clean, k8s, _ := mockDaemon(t) + start() + defer clean() + w := newWait(t) + + service := cluster.Controller{ + ID: flux.MakeResourceID(ns, "deployment", "helloworld"), + Containers: cluster.ContainersOrExcuse{ + Containers: []resource.Container{ + { + Name: container, + Image: mustParseImageRef(currentHelloImage), + }, + }, + }, + } + k8s.SomeServicesFunc = func([]flux.ResourceID) ([]cluster.Controller, error) { + return []cluster.Controller{service}, nil + } + + // updates from helloworld:master-xxx to helloworld:2 + w.ForImageTag(t, d, svc, container, "2") +} + +func TestDaemon_Automated_semver(t *testing.T) { + d, start, clean, k8s, _ := mockDaemon(t) + start() + defer clean() + w := newWait(t) + + resid := flux.MustParseResourceID("default:deployment/semver") + service := cluster.Controller{ + ID: resid, + Containers: cluster.ContainersOrExcuse{ + Containers: []resource.Container{ + { + Name: container, + Image: mustParseImageRef(currentHelloImage), + }, + }, + }, + } + k8s.SomeServicesFunc = func([]flux.ResourceID) ([]cluster.Controller, error) { + return []cluster.Controller{service}, nil + } + + // helloworld:3 is older than helloworld:2 but semver orders by version + w.ForImageTag(t, d, resid.String(), container, "3") +} + func makeImageInfo(ref string, t time.Time) image.Info { return image.Info{ID: mustParseImageRef(ref), CreatedAt: t} } @@ -506,13 +562,13 @@ func mockDaemon(t *testing.T) (*Daemon, func(), func(), *cluster.Mock, *mockEven } multiService := []cluster.Controller{ singleService, - cluster.Controller{ + { ID: flux.MakeResourceID("another", "deployment", "service"), Containers: cluster.ContainersOrExcuse{ Containers: []resource.Container{ { - Name: "it-doesn't-matter", - Image: mustParseImageRef("another/service:latest"), + Name: anotherContainer, + Image: mustParseImageRef(anotherImage), }, }, }, @@ -560,6 +616,7 @@ func mockDaemon(t *testing.T) (*Daemon, func(), func(), *cluster.Mock, *mockEven var imageRegistry registry.Registry { + img0 := makeImageInfo(oldHelloImage, time.Now().Add(-1*time.Second)) img1 := makeImageInfo(currentHelloImage, time.Now()) img2 := makeImageInfo(newHelloImage, time.Now().Add(1*time.Second)) 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 img1, img2, img3, + img0, }, } } @@ -595,7 +653,10 @@ func mockDaemon(t *testing.T) (*Daemon, func(), func(), *cluster.Mock, *mockEven JobStatusCache: &job.StatusCache{Size: 100}, EventWriter: events, Logger: logger, - LoopVars: &LoopVars{}, + // LoopVars: &LoopVars{}, + LoopVars: &LoopVars{ + RegistryPollInterval: 5 * time.Minute, + }, } start := func() { @@ -698,6 +759,32 @@ func (w *wait) ForSyncStatus(d *Daemon, rev string, expectedNumCommits int) []st return revs } +func (w *wait) ForImageTag(t *testing.T, d *Daemon, service, container, tag string) { + w.Eventually(func() bool { + co, err := d.Repo.Clone(context.TODO(), d.GitConfig) + if err != nil { + return false + } + defer co.Clean() + + m, err := d.Manifests.LoadManifests(co.Dir(), co.ManifestDir()) + assert.NoError(t, err) + + resources, err := d.Manifests.ParseManifests(m[service].Bytes()) + assert.NoError(t, err) + + workload, ok := resources[service].(resource.Workload) + assert.True(t, ok) + for _, c := range workload.Containers() { + if c.Name == container && c.Image.Tag == tag { + return true + } + } + return false + }, fmt.Sprintf("Waiting for image tag: %q", tag)) + +} + func updateImage(ctx context.Context, d *Daemon, t *testing.T) job.ID { return updateManifest(ctx, t, d, update.Spec{ Type: update.Images, diff --git a/daemon/images.go b/daemon/images.go index e8f8592cd6..66289a652c 100644 --- a/daemon/images.go +++ b/daemon/images.go @@ -33,7 +33,7 @@ func (d *Daemon) pollForNewImages(logger log.Logger) { return } // Check the latest available image(s) for each service - imageRepos, err := update.FetchImageRepos(d.Registry, clusterContainers(services), logger) + imageRepos, err := update.FetchImageRepos(d.Registry, clusterContainers{controllers: services, policies: candidateServicesPolicyMap}, logger) if err != nil { logger.Log("error", errors.Wrap(err, "fetching image updates")) return diff --git a/daemon/loop.go b/daemon/loop.go index b752d5ef4b..d9fc29c313 100644 --- a/daemon/loop.go +++ b/daemon/loop.go @@ -1,17 +1,15 @@ package daemon import ( + "context" "fmt" "strings" + "sync" "time" "github.com/go-kit/kit/log" "github.com/pkg/errors" - "sync" - - "context" - "github.com/weaveworks/flux" "github.com/weaveworks/flux/cluster" "github.com/weaveworks/flux/event" diff --git a/image/image.go b/image/image.go index 717c8eb0de..8d38b3bb66 100644 --- a/image/image.go +++ b/image/image.go @@ -4,9 +4,11 @@ import ( "encoding/json" "fmt" "regexp" + "sort" "strings" "time" + "github.com/Masterminds/semver" "github.com/pkg/errors" ) @@ -276,14 +278,56 @@ func (im *Info) UnmarshalJSON(b []byte) error { return nil } -// ByCreatedDesc is a shim used to sort image info by creation date -type ByCreatedDesc []Info +type LessFunc func(lhs, rhs *Info) bool -func (is ByCreatedDesc) Len() int { return len(is) } -func (is ByCreatedDesc) Swap(i, j int) { is[i], is[j] = is[j], is[i] } -func (is ByCreatedDesc) Less(i, j int) bool { - if is[i].CreatedAt.Equal(is[j].CreatedAt) { - return is[i].ID.String() < is[j].ID.String() +// ByCreatedDesc returns true if lhs image should be sorted +// before rhs with regard to their creation date descending. +func ByCreatedDesc(lhs, rhs *Info) bool { + if lhs.CreatedAt.Equal(rhs.CreatedAt) { + return lhs.ID.String() < rhs.ID.String() } - return is[i].CreatedAt.After(is[j].CreatedAt) + return lhs.CreatedAt.After(rhs.CreatedAt) } + +// BySemverTagDesc returns true if lhs image should be sorted +// before rhs with regard to their semver order descending. +func BySemverTagDesc(lhs, rhs *Info) bool { + lv, lerr := semver.NewVersion(lhs.ID.Tag) + rv, rerr := semver.NewVersion(rhs.ID.Tag) + if (lerr != nil && rerr != nil) || (lv == rv) { + return lhs.ID.String() < rhs.ID.String() + } + if lerr != nil { + return false + } + if rerr != nil { + return true + } + return lv.Compare(rv) > 0 +} + +// Sort orders given image infos according to less func. +func Sort(infos []Info, less LessFunc) { + if less == nil { + less = ByCreatedDesc + } + sort.Sort(&infoSort{infos: infos, less: less}) +} + +type infoSort struct { + infos []Info + less LessFunc +} + +func (s *infoSort) Len() int { + return len(s.infos) +} + +func (s *infoSort) Swap(i, j int) { + s.infos[i], s.infos[j] = s.infos[j], s.infos[i] +} + +func (s *infoSort) Less(i, j int) bool { + return s.less(&s.infos[i], &s.infos[j]) +} + diff --git a/image/image_test.go b/image/image_test.go index ed87957f8b..e7439c0648 100644 --- a/image/image_test.go +++ b/image/image_test.go @@ -4,10 +4,11 @@ import ( "encoding/json" "fmt" "reflect" - "sort" "strconv" "testing" "time" + + "github.com/stretchr/testify/assert" ) const constTime = "2017-01-13T16:22:58.009923189Z" @@ -190,17 +191,13 @@ func TestImage_OrderByCreationDate(t *testing.T) { imE := mustMakeInfo("my/Image:1", testTime) // test equal imF := mustMakeInfo("my/Image:5", time.Time{}) // test nil equal imgs := []Info{imA, imB, imC, imD, imE, imF} - sort.Sort(ByCreatedDesc(imgs)) + Sort(imgs, ByCreatedDesc) checkSorted(t, imgs) // now check stability - sort.Sort(ByCreatedDesc(imgs)) + Sort(imgs, ByCreatedDesc) checkSorted(t, imgs) - // more stability checks - for i := len(imgs)/2 - 1; i >= 0; i-- { - opp := len(imgs) - 1 - i - imgs[i], imgs[opp] = imgs[opp], imgs[i] - } - sort.Sort(ByCreatedDesc(imgs)) + reverse(imgs) + Sort(imgs, ByCreatedDesc) checkSorted(t, imgs) } @@ -214,3 +211,39 @@ func checkSorted(t *testing.T, imgs []Info) { } } } + +func TestImage_OrderBySemverTagDesc(t *testing.T) { + ti := time.Time{} + aa := mustMakeInfo("my/image:3", ti) + bb := mustMakeInfo("my/image:v1", ti) + cc := mustMakeInfo("my/image:1.10", ti) + dd := mustMakeInfo("my/image:1.2.30", ti) + ee := mustMakeInfo("my/image:bbb-not-semver", ti) + ff := mustMakeInfo("my/image:aaa-not-semver", ti) + + imgs := []Info{aa, bb, cc, dd, ee, ff} + Sort(imgs, BySemverTagDesc) + + expected := []Info{aa, cc, dd, bb, ff, ee} + assert.Equal(t, tags(expected), tags(imgs)) + + reverse(imgs) + Sort(imgs, BySemverTagDesc) + assert.Equal(t, tags(expected), tags(imgs)) +} + +func tags(imgs []Info) []string { + var vs []string + for _, i := range imgs { + vs = append(vs, i.ID.Tag) + } + return vs +} + +func reverse(imgs []Info) { + for i := len(imgs)/2 - 1; i >= 0; i-- { + opp := len(imgs) - 1 - i + imgs[i], imgs[opp] = imgs[opp], imgs[i] + } +} + diff --git a/policy/pattern.go b/policy/pattern.go new file mode 100644 index 0000000000..8f9c3b5dc2 --- /dev/null +++ b/policy/pattern.go @@ -0,0 +1,93 @@ +package policy + +import ( + "github.com/Masterminds/semver" + "github.com/ryanuber/go-glob" + "github.com/weaveworks/flux/image" + "strings" +) + +const ( + globPrefix = "glob:" + semverPrefix = "semver:" +) + +var ( + // PatternAll matches everything. + PatternAll = NewPattern(globPrefix + "*") + PatternLatest = NewPattern(globPrefix + "latest") +) + +// Pattern provides an interface to match image tags. +type Pattern interface { + // Matches returns true if the given image tag matches the pattern. + Matches(tag string) bool + // String returns the prefixed string representation. + String() string + // ImageNewerFunc returns a function to compare image newness. + ImageNewerFunc() image.LessFunc + // Valid returns true if the pattern is considered valid. + Valid() bool +} + +type GlobPattern string + +// SemverPattern matches by semantic versioning. +// See https://semver.org/ +type SemverPattern struct { + pattern string // pattern without prefix + constraints *semver.Constraints +} + +// NewPattern instantiates a Pattern according to the prefix +// it finds. The prefix can be either `glob:` (default if omitted) +// or `semver:`. + +func NewPattern(pattern string) Pattern { + if strings.HasPrefix(pattern, semverPrefix) { + pattern = strings.TrimPrefix(pattern, semverPrefix) + c, _ := semver.NewConstraint(pattern) + return SemverPattern{pattern, c} + } + return GlobPattern(strings.TrimPrefix(pattern, globPrefix)) +} + +func (g GlobPattern) Matches(tag string) bool { + return glob.Glob(string(g), tag) +} + +func (g GlobPattern) String() string { + return globPrefix + string(g) +} + +func (g GlobPattern) ImageNewerFunc() image.LessFunc { + return image.ByCreatedDesc +} + +func (g GlobPattern) Valid() bool { + return true +} + +func (s SemverPattern) Matches(tag string) bool { + v, err := semver.NewVersion(tag) + if err != nil { + return false + } + if s.constraints == nil { + // Invalid constraints match anything + return true + } + return s.constraints.Check(v) +} + +func (s SemverPattern) String() string { + return semverPrefix + s.pattern +} + +func (s SemverPattern) ImageNewerFunc() image.LessFunc { + return image.BySemverTagDesc +} + +func (s SemverPattern) Valid() bool { + return s.constraints != nil +} diff --git a/policy/pattern_test.go b/policy/pattern_test.go new file mode 100644 index 0000000000..541e667035 --- /dev/null +++ b/policy/pattern_test.go @@ -0,0 +1,82 @@ +package policy + +import ( + "testing" + + "fmt" + "github.com/stretchr/testify/assert" +) + +func TestGlobPattern_Matches(t *testing.T) { + for _, tt := range []struct { + name string + pattern string + true []string + false []string + }{ + { + name: "all", + pattern: "*", + true: []string{"", "1", "foo"}, + false: nil, + }, + { + name: "all prefixed", + pattern: "glob:*", + true: []string{"", "1", "foo"}, + false: nil, + }, + { + name: "prefix", + pattern: "master-*", + true: []string{"master-", "master-foo"}, + false: []string{"", "foo-master"}, + }, + } { + pattern := NewPattern(tt.pattern) + assert.IsType(t, GlobPattern(""), pattern) + t.Run(tt.name, func(t *testing.T) { + for _, tag := range tt.true { + assert.True(t, pattern.Matches(tag)) + } + for _, tag := range tt.false { + assert.False(t, pattern.Matches(tag)) + } + }) + } +} + +func TestSemverPattern_Matches(t *testing.T) { + for _, tt := range []struct { + name string + pattern string + true []string + false []string + }{ + { + name: "all", + pattern: "semver:*", + true: []string{"1", "1.0", "v1.0.3"}, + false: []string{"", "latest", "2.0.1-alpha.1"}, + }, + { + name: "semver", + pattern: "semver:~1", + true: []string{"v1", "1", "1.2", "1.2.3"}, + false: []string{"", "latest", "2.0.0", ""}, + }, + } { + pattern := NewPattern(tt.pattern) + assert.IsType(t, SemverPattern{}, pattern) + for _, tag := range tt.true { + t.Run(fmt.Sprintf("%s[%q]", tt.name, tag), func(t *testing.T) { + assert.True(t, pattern.Matches(tag)) + }) + } + for _, tag := range tt.false { + t.Run(fmt.Sprintf("%s[%q]", tt.name, tag), func(t *testing.T) { + assert.False(t, pattern.Matches(tag)) + }) + } + } +} diff --git a/policy/policy.go b/policy/policy.go index 105294fb7d..e8e8ef8ca6 100644 --- a/policy/policy.go +++ b/policy/policy.go @@ -36,15 +36,15 @@ func Tag(policy Policy) bool { return strings.HasPrefix(string(policy), "tag.") } -func GetTagPattern(services ResourceMap, service flux.ResourceID, container string) string { +func GetTagPattern(services ResourceMap, service flux.ResourceID, container string) Pattern { if services == nil { - return "*" + return PatternAll } policies := services[service] if pattern, ok := policies.Get(TagPrefix(container)); ok { - return strings.TrimPrefix(pattern, "glob:") + return NewPattern(pattern) } - return "*" + return PatternAll } type Updates map[flux.ResourceID]Update diff --git a/policy/policy_test.go b/policy/policy_test.go index ac8e2863a9..cad84e3cac 100644 --- a/policy/policy_test.go +++ b/policy/policy_test.go @@ -63,17 +63,17 @@ func Test_GetTagPattern(t *testing.T) { tests := []struct { name string args args - want string + want Pattern }{ { name: "Nil policies", args: args{services: nil}, - want: "*", + want: PatternAll, }, { name: "No match", args: args{services: ResourceMap{}}, - want: "*", + want: PatternAll, }, { name: "Match", @@ -86,13 +86,14 @@ func Test_GetTagPattern(t *testing.T) { service: resourceID, container: container, }, - want: "master-*", + want: NewPattern("master-*"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := GetTagPattern(tt.args.services, tt.args.service, tt.args.container); got != tt.want { t.Errorf("GetTagPattern() = %v, want %v", got, tt.want) + } }) } diff --git a/registry/cache/memcached/integration_test.go b/registry/cache/memcached/integration_test.go index 3766e9fd13..9da70b6575 100644 --- a/registry/cache/memcached/integration_test.go +++ b/registry/cache/memcached/integration_test.go @@ -66,14 +66,14 @@ Loop: case <-timeout.C: t.Fatal("Cache timeout") case <-tick.C: - _, err := r.GetSortedRepositoryImages(id.Name) + _, err := r.GetRepositoryImages(id.Name) if err == nil { break Loop } } } - img, err := r.GetSortedRepositoryImages(id.Name) + img, err := r.GetRepositoryImages(id.Name) if err != nil { t.Fatal(err) } diff --git a/registry/cache/registry.go b/registry/cache/registry.go index ea55ec5750..c531c55440 100644 --- a/registry/cache/registry.go +++ b/registry/cache/registry.go @@ -2,7 +2,6 @@ package cache import ( "encoding/json" - "sort" "time" "github.com/pkg/errors" @@ -31,9 +30,9 @@ type Cache struct { Reader Reader } -// GetSortedRepositoryImages returns the list of image manifests in an image +// GetRepositoryImages returns the list of image manifests in an image // repository (e.g,. at "quay.io/weaveworks/flux") -func (c *Cache) GetSortedRepositoryImages(id image.Name) ([]image.Info, error) { +func (c *Cache) GetRepositoryImages(id image.Name) ([]image.Info, error) { repoKey := NewRepositoryKey(id.CanonicalName()) bytes, _, err := c.Reader.GetKey(repoKey) if err != nil { @@ -59,7 +58,6 @@ func (c *Cache) GetSortedRepositoryImages(id image.Name) ([]image.Info, error) { images[i] = im i++ } - sort.Sort(image.ByCreatedDesc(images)) return images, nil } diff --git a/registry/cache/warming_test.go b/registry/cache/warming_test.go index 7ad49c68a1..0a733ce3c9 100644 --- a/registry/cache/warming_test.go +++ b/registry/cache/warming_test.go @@ -71,7 +71,7 @@ func TestWarm(t *testing.T) { warmer.warm(context.TODO(), logger, repo, registry.NoCredentials()) registry := &Cache{Reader: c} - repoInfo, err := registry.GetSortedRepositoryImages(ref.Name) + repoInfo, err := registry.GetRepositoryImages(ref.Name) if err != nil { t.Error(err) } diff --git a/registry/mock/mock.go b/registry/mock/mock.go index 5cea85bff5..5246a363f2 100644 --- a/registry/mock/mock.go +++ b/registry/mock/mock.go @@ -2,8 +2,6 @@ package mock import ( "context" - "sort" - "github.com/pkg/errors" "github.com/weaveworks/flux/image" @@ -41,7 +39,7 @@ type Registry struct { Err error } -func (m *Registry) GetSortedRepositoryImages(id image.Name) ([]image.Info, error) { +func (m *Registry) GetRepositoryImages(id image.Name) ([]image.Info, error) { var imgs []image.Info for _, i := range m.Images { // include only if it's the same repository in the same place @@ -49,7 +47,7 @@ func (m *Registry) GetSortedRepositoryImages(id image.Name) ([]image.Info, error imgs = append(imgs, i) } } - sort.Sort(image.ByCreatedDesc(imgs)) + image.Sort(imgs, image.ByCreatedDesc) return imgs, m.Err } diff --git a/registry/monitoring.go b/registry/monitoring.go index fcdef54444..adfd307c1b 100644 --- a/registry/monitoring.go +++ b/registry/monitoring.go @@ -46,9 +46,9 @@ func NewInstrumentedRegistry(next Registry) Registry { } } -func (m *instrumentedRegistry) GetSortedRepositoryImages(id image.Name) (res []image.Info, err error) { +func (m *instrumentedRegistry) GetRepositoryImages(id image.Name) (res []image.Info, err error) { start := time.Now() - res, err = m.next.GetSortedRepositoryImages(id) + res, err = m.next.GetRepositoryImages(id) registryDuration.With( fluxmetrics.LabelSuccess, strconv.FormatBool(err == nil), ).Observe(time.Since(start).Seconds()) diff --git a/registry/registry.go b/registry/registry.go index 5794ee5030..207db6905c 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -12,7 +12,7 @@ var ( // Registry is a store of image metadata. type Registry interface { - GetSortedRepositoryImages(image.Name) ([]image.Info, error) + GetRepositoryImages(image.Name) ([]image.Info, error) GetImage(image.Ref) (image.Info, error) } diff --git a/release/releaser_test.go b/release/releaser_test.go index 8f8d5d7deb..72a6e3c2e9 100644 --- a/release/releaser_test.go +++ b/release/releaser_test.go @@ -1,7 +1,6 @@ package release import ( - "encoding/json" "fmt" "reflect" "testing" @@ -203,6 +202,7 @@ func Test_FilterLogic(t *testing.T) { flux.MustParseResourceID("default:deployment/test-service"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/semver"): ignoredNotIncluded, }, }, { Name: "exclude specific service", @@ -235,6 +235,7 @@ func Test_FilterLogic(t *testing.T) { flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/semver"): skippedNotInCluster, }, }, { Name: "update specific image", @@ -262,6 +263,7 @@ func Test_FilterLogic(t *testing.T) { flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/semver"): skippedNotInCluster, }, }, // skipped if: not ignored AND (locked or not found in cluster) @@ -297,6 +299,7 @@ func Test_FilterLogic(t *testing.T) { flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/semver"): skippedNotInCluster, }, }, { @@ -330,6 +333,7 @@ func Test_FilterLogic(t *testing.T) { flux.MustParseResourceID("default:deployment/test-service"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/multi-deploy"): skippedNotInCluster, flux.MustParseResourceID("default:deployment/list-deploy"): skippedNotInCluster, + flux.MustParseResourceID("default:deployment/semver"): skippedNotInCluster, }, }, { @@ -346,6 +350,7 @@ func Test_FilterLogic(t *testing.T) { flux.MustParseResourceID("default:deployment/test-service"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/semver"): ignoredNotIncluded, flux.MustParseResourceID(notInRepoService): skippedNotInRepo, }, }, @@ -397,6 +402,7 @@ func Test_ImageStatus(t *testing.T) { flux.MustParseResourceID("default:deployment/locked-service"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/semver"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/test-service"): update.ControllerResult{ Status: update.ReleaseStatusIgnored, Error: update.DoesNotUseImage, @@ -419,6 +425,7 @@ func Test_ImageStatus(t *testing.T) { flux.MustParseResourceID("default:deployment/test-service"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/multi-deploy"): ignoredNotIncluded, flux.MustParseResourceID("default:deployment/list-deploy"): ignoredNotIncluded, + flux.MustParseResourceID("default:deployment/semver"): ignoredNotIncluded, }, }, } { @@ -686,14 +693,8 @@ func Test_UpdateContainers(t *testing.T) { func testRelease(t *testing.T, ctx *ReleaseContext, spec update.ReleaseSpec, expected update.Result) { results, err := Release(ctx, spec, log.NewNopLogger()) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(expected, results) { - exp, _ := json.Marshal(expected) - got, _ := json.Marshal(results) - t.Errorf("--- expected ---\n%s\n--- got ---\n%s\n", string(exp), string(got)) - } + assert.NoError(t, err) + assert.Equal(t, expected, results) } // --- test verification diff --git a/update/images.go b/update/images.go index 42e870a64e..8f0a8361e2 100644 --- a/update/images.go +++ b/update/images.go @@ -6,10 +6,10 @@ import ( "github.com/go-kit/kit/log" "github.com/pkg/errors" - glob "github.com/ryanuber/go-glob" fluxerr "github.com/weaveworks/flux/errors" "github.com/weaveworks/flux/image" + "github.com/weaveworks/flux/policy" "github.com/weaveworks/flux/registry" "github.com/weaveworks/flux/resource" ) @@ -38,24 +38,30 @@ func (r ImageRepos) GetRepoImages(repo image.Name) ImageInfos { // ImageInfos is a list of image.Info which can be filtered. type ImageInfos []image.Info -// Filter returns only the images which match the tagGlob. -func (ii ImageInfos) Filter(tagGlob string) ImageInfos { +// Filter returns only the images that match the pattern, in a new list. +// It also sorts the images according to the pattern's order. +func (ii ImageInfos) Filter(pattern policy.Pattern) ImageInfos { var filtered ImageInfos for _, i := range ii { tag := i.ID.Tag // Ignore latest if and only if it's not what the user wants. - if !strings.EqualFold(tagGlob, "latest") && strings.EqualFold(tag, "latest") { + if pattern != policy.PatternLatest && strings.EqualFold(tag, "latest") { continue } - if glob.Glob(tagGlob, tag) { + if pattern.Matches(tag) { var im image.Info im = i filtered = append(filtered, im) } } + filtered.Sort(pattern) return filtered } +func (ii ImageInfos) Sort(pattern policy.Pattern) { + image.Sort(ii, pattern.ImageNewerFunc()) +} + // Latest returns the latest image from ImageInfos. If no such image exists, // returns a zero value and `false`, and the caller can decide whether // that's an error or not. @@ -81,6 +87,7 @@ func (ii ImageInfos) FindWithRef(ref image.Ref) image.Info { type containers interface { Len() int Containers(i int) []resource.Container + Pattern(i int, container string) policy.Pattern } type controllerContainers []*ControllerUpdate @@ -93,6 +100,13 @@ func (cs controllerContainers) Containers(i int) []resource.Container { return cs[i].Controller.ContainersOrNil() } +func (cs controllerContainers) Pattern(i int, container string) policy.Pattern { + if pattern, ok := cs[i].Resource.Policy().Get(policy.TagPrefix(container)); ok { + return policy.NewPattern(pattern) + } + return policy.PatternAll +} + // fetchUpdatableImageRepos is a convenient shim to // `FetchImageRepos`. func fetchUpdatableImageRepos(registry registry.Registry, updateable []*ControllerUpdate, logger log.Logger) (ImageRepos, error) { @@ -109,7 +123,7 @@ func FetchImageRepos(reg registry.Registry, cs containers, logger log.Logger) (I } } for repo := range imageRepos { - sortedRepoImages, err := reg.GetSortedRepositoryImages(repo.Name) + images, err := reg.GetRepositoryImages(repo.Name) if err != nil { // Not an error if missing. Use empty images. if !fluxerr.IsMissing(err) { @@ -117,7 +131,7 @@ func FetchImageRepos(reg registry.Registry, cs containers, logger log.Logger) (I continue } } - imageRepos[repo] = sortedRepoImages + imageRepos[repo] = images } return ImageRepos{imageRepos}, nil } diff --git a/update/images_test.go b/update/images_test.go index 79e3c929ed..2d8c15e77b 100644 --- a/update/images_test.go +++ b/update/images_test.go @@ -4,7 +4,10 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/weaveworks/flux/image" + "github.com/weaveworks/flux/policy" ) var ( @@ -24,7 +27,7 @@ func TestDecanon(t *testing.T) { name: infos, }} - filteredImages := m.GetRepoImages(mustParseName("weaveworks/helloworld")).Filter("*") + filteredImages := m.GetRepoImages(mustParseName("weaveworks/helloworld")).Filter(policy.PatternAll) latest, ok := filteredImages.Latest() if !ok { t.Error("did not find latest image") @@ -32,7 +35,7 @@ func TestDecanon(t *testing.T) { t.Error("name did not match what was asked") } - filteredImages = m.GetRepoImages(mustParseName("index.docker.io/weaveworks/helloworld")).Filter("*") + filteredImages = m.GetRepoImages(mustParseName("index.docker.io/weaveworks/helloworld")).Filter(policy.PatternAll) latest, ok = filteredImages.Latest() if !ok { t.Error("did not find latest image") @@ -51,6 +54,30 @@ func TestDecanon(t *testing.T) { } } +func TestImageInfos_Filter_latest(t *testing.T) { + latest := image.Info{ + ID: image.Ref{Name: image.Name{Image: "flux"}, Tag: "latest"}, + } + other := image.Info{ + ID: image.Ref{Name: image.Name{Image: "moon"}, Tag: "v0"}, + } + ii := ImageInfos{latest, other} + assert.Equal(t, ImageInfos{latest}, ii.Filter(policy.PatternLatest)) + assert.Equal(t, ImageInfos{latest}, ii.Filter(policy.NewPattern("latest"))) + assert.Equal(t, ImageInfos{other}, ii.Filter(policy.PatternAll)) + assert.Equal(t, ImageInfos{other}, ii.Filter(policy.NewPattern("*"))) +} + +func TestImageInfos_Filter_semver(t *testing.T) { + latest := image.Info{ID: image.Ref{Name: image.Name{Image: "flux"}, Tag: "latest"}} + semver0 := image.Info{ID: image.Ref{Name: image.Name{Image: "moon"}, Tag: "v0.0.1"}} + semver1 := image.Info{ID: image.Ref{Name: image.Name{Image: "earth"}, Tag: "1.0.0"}} + + ii := ImageInfos{latest, semver0, semver1} + assert.Equal(t, ImageInfos{semver1, semver0}, ii.Filter(policy.NewPattern("semver:*"))) + assert.Equal(t, ImageInfos{semver1}, ii.Filter(policy.NewPattern("semver:~1"))) +} + func TestAvail(t *testing.T) { m := ImageRepos{imageReposMap{name: infos}} avail := m.GetRepoImages(mustParseName("weaveworks/goodbyeworld")) diff --git a/update/release.go b/update/release.go index ff5a23c502..191231047b 100644 --- a/update/release.go +++ b/update/release.go @@ -231,7 +231,7 @@ func (s ReleaseSpec) calculateImageUpdates(rc ReleaseContext, candidates []*Cont for _, container := range containers { currentImageID := container.Image - filteredImages := imageRepos.GetRepoImages(currentImageID.Name).Filter("*") + filteredImages := imageRepos.GetRepoImages(currentImageID.Name).Filter(policy.PatternAll) latestImage, ok := filteredImages.Latest() if !ok { if currentImageID.CanonicalName() != singleRepo {