From c9c5c4ad630e0afe702ce185b3e903a9c5ec1293 Mon Sep 17 00:00:00 2001 From: Michael Bridgen Date: Sat, 11 Jul 2020 09:37:45 +0100 Subject: [PATCH 1/3] Actually scan the image repository This commit adds a little code to scan the image repository given in an ImageRepository resource, and report the (number of) tags there. It does so anonymously, so it'll only work for public repos for the minute. The next step is to introduce ImagePolicy resources and figure out the interaction with them -- e.g., if they only care about semver, you might only need to get the tags. --- api/v1alpha1/imagerepository_types.go | 15 ++++++ api/v1alpha1/zz_generated.deepcopy.go | 30 ++++++++++- .../image.fluxcd.io_imagerepositories.yaml | 23 +++++++++ controllers/imagerepository_controller.go | 50 ++++++++++++++++++- go.sum | 4 ++ 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/api/v1alpha1/imagerepository_types.go b/api/v1alpha1/imagerepository_types.go index c1e6256..89371fa 100644 --- a/api/v1alpha1/imagerepository_types.go +++ b/api/v1alpha1/imagerepository_types.go @@ -26,6 +26,14 @@ type ImageRepositorySpec struct { // Image is the name of the image repository // +required Image string `json:"image,omitempty"` + // ScanInterval is the (minimum) length of time to wait between + // scans of the image repository. + // +optional + ScanInterval *metav1.Duration `json:"scanInterval,omitempty"` +} + +type ScanResult struct { + TagCount int `json:"tagCount"` } // ImageRepositoryStatus defines the observed state of ImageRepository @@ -37,10 +45,17 @@ type ImageRepositoryStatus struct { // LastError is the error from last reconciliation, or empty if // reconciliation was successful. LastError string `json:"lastError"` + // LastScanTime records the last time the repository was + // successfully scanned. + // +optional + LastScanTime *metav1.Time `json:"lastScanTime,omitempty"` + LastScanResult ScanResult `json:"lastScanResult,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Last scan",type=string,JSONPath=`.status.lastScanTime` +// +kubebuilder:printcolumn:name="Tags",type=string,JSONPath=`.status.lastScanResult.tagCount` // ImageRepository is the Schema for the imagerepositories API type ImageRepository struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 1526903..da4396e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha1 import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -29,8 +30,8 @@ func (in *ImageRepository) DeepCopyInto(out *ImageRepository) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - out.Status = in.Status + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepository. @@ -86,6 +87,11 @@ func (in *ImageRepositoryList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ImageRepositorySpec) DeepCopyInto(out *ImageRepositorySpec) { *out = *in + if in.ScanInterval != nil { + in, out := &in.ScanInterval, &out.ScanInterval + *out = new(v1.Duration) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepositorySpec. @@ -101,6 +107,11 @@ func (in *ImageRepositorySpec) DeepCopy() *ImageRepositorySpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ImageRepositoryStatus) DeepCopyInto(out *ImageRepositoryStatus) { *out = *in + if in.LastScanTime != nil { + in, out := &in.LastScanTime, &out.LastScanTime + *out = (*in).DeepCopy() + } + out.LastScanResult = in.LastScanResult } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageRepositoryStatus. @@ -112,3 +123,18 @@ func (in *ImageRepositoryStatus) DeepCopy() *ImageRepositoryStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScanResult) DeepCopyInto(out *ScanResult) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScanResult. +func (in *ScanResult) DeepCopy() *ScanResult { + if in == nil { + return nil + } + out := new(ScanResult) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/image.fluxcd.io_imagerepositories.yaml b/config/crd/bases/image.fluxcd.io_imagerepositories.yaml index 4687b5c..0dcd348 100644 --- a/config/crd/bases/image.fluxcd.io_imagerepositories.yaml +++ b/config/crd/bases/image.fluxcd.io_imagerepositories.yaml @@ -8,6 +8,13 @@ metadata: creationTimestamp: null name: imagerepositories.image.fluxcd.io spec: + additionalPrinterColumns: + - JSONPath: .status.lastScanTime + name: Last scan + type: string + - JSONPath: .status.lastScanResult.tagCount + name: Tags + type: string group: image.fluxcd.io names: kind: ImageRepository @@ -40,6 +47,10 @@ spec: image: description: Image is the name of the image repository type: string + scanInterval: + description: ScanInterval is the (minimum) length of time to wait between + scans of the image repository. + type: string type: object status: description: ImageRepositoryStatus defines the observed state of ImageRepository @@ -53,6 +64,18 @@ spec: description: LastError is the error from last reconciliation, or empty if reconciliation was successful. type: string + lastScanResult: + properties: + tagCount: + type: integer + required: + - tagCount + type: object + lastScanTime: + description: LastScanTime records the last time the repository was successfully + scanned. + format: date-time + type: string required: - lastError type: object diff --git a/controllers/imagerepository_controller.go b/controllers/imagerepository_controller.go index 7a301ec..803a893 100644 --- a/controllers/imagerepository_controller.go +++ b/controllers/imagerepository_controller.go @@ -18,9 +18,12 @@ package controllers import ( "context" + "time" "github.com/go-logr/logr" "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -28,6 +31,11 @@ import ( imagev1alpha1 "github.com/squaremo/image-update/api/v1alpha1" ) +const ( + scanTimeout = 10 * time.Second + defaultScanInterval = 10 * time.Minute +) + // ImageRepositoryReconciler reconciles a ImageRepository object type ImageRepositoryReconciler struct { client.Client @@ -66,7 +74,47 @@ func (r *ImageRepositoryReconciler) Reconcile(req ctrl.Request) (ctrl.Result, er } } - return ctrl.Result{}, nil + now := time.Now() + ok, when := shouldScan(&imageRepo, now) + if ok { + ctx, cancel := context.WithTimeout(ctx, scanTimeout) + defer cancel() + tags, err := remote.ListWithContext(ctx, ref.Context()) // TODO: auth + if err != nil { + imageRepo.Status.LastError = err.Error() + if err = r.Status().Update(ctx, &imageRepo); err != nil { + return ctrl.Result{}, err + } + } + + imageRepo.Status.LastScanTime = &metav1.Time{Time: now} + imageRepo.Status.LastScanResult.TagCount = len(tags) + imageRepo.Status.LastError = "" + log.Info("successful scan", "tag count", len(tags)) + if err = r.Status().Update(ctx, &imageRepo); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{RequeueAfter: when}, nil +} + +// shouldScan takes an image repo and the time now, and says whether +// the repository should be scanned now, and how long to wait for the +// next scan. +func shouldScan(repo *imagev1alpha1.ImageRepository, now time.Time) (bool, time.Duration) { + scanInterval := defaultScanInterval + if repo.Spec.ScanInterval != nil { + scanInterval = repo.Spec.ScanInterval.Duration + } + + if repo.Status.LastScanTime == nil { + return true, scanInterval + } + when := scanInterval - now.Sub(repo.Status.LastScanTime.Time) + if when < time.Second { + return true, scanInterval + } + return false, when } func (r *ImageRepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error { diff --git a/go.sum b/go.sum index 83545ae..f535524 100644 --- a/go.sum +++ b/go.sum @@ -168,10 +168,13 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017 h1:2HQmlpI3yI9deH18Q6xiSOIjXD4sLI55Y/gfpa8/558= github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7 h1:Cvj7S8I4Xpx78KAl6TwTmMHuHlZ/0SM60NUneGJQ7IE= github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ= github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= @@ -830,6 +833,7 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= From 31152814cc05863f3646d8b602b8061cdbd07ee0 Mon Sep 17 00:00:00 2001 From: Michael Bridgen Date: Sun, 12 Jul 2020 12:11:02 +0100 Subject: [PATCH 2/3] Separate test from suite setup/teardown .. in preparation for having more tests, and more setup and teardown (specifically: setting a local container registry up). --- controllers/scan_test.go | 62 +++++++++++++++++++++++++++++++++++++++ controllers/suite_test.go | 35 ---------------------- 2 files changed, 62 insertions(+), 35 deletions(-) create mode 100644 controllers/scan_test.go diff --git a/controllers/scan_test.go b/controllers/scan_test.go new file mode 100644 index 0000000..d4d6159 --- /dev/null +++ b/controllers/scan_test.go @@ -0,0 +1,62 @@ +/* +Copyright 2020 Michael Bridgen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + + imagev1alpha1 "github.com/squaremo/image-update/api/v1alpha1" + // +kubebuilder:scaffold:imports +) + +var _ = Describe("ImageRepository controller", func() { + It("expands the canonical image name", func() { + repo := imagev1alpha1.ImageRepository{ + Spec: imagev1alpha1.ImageRepositorySpec{ + Image: "alpine", + }, + } + imageRepoName := types.NamespacedName{ + Name: "alpine-image", + Namespace: "default", + } + + repo.Name = imageRepoName.Name + repo.Namespace = imageRepoName.Namespace + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + r := imageRepoReconciler + err := r.Create(ctx, &repo) + Expect(err).ToNot(HaveOccurred()) + + var repoAfter imagev1alpha1.ImageRepository + Eventually(func() bool { + err = r.Get(context.Background(), imageRepoName, &repoAfter) + return err == nil && repoAfter.Status.CanonicalImageName != "" + }, timeout, interval).Should(BeTrue()) + Expect(repoAfter.Name).To(Equal("alpine-image")) + Expect(repoAfter.Namespace).To(Equal("default")) + Expect(repoAfter.Status.CanonicalImageName).To(Equal("index.docker.io/library/alpine")) + }) +}) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index c5815c9..d9f8d2c 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -17,14 +17,12 @@ limitations under the License. package controllers import ( - "context" "path/filepath" "testing" "time" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" - "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" @@ -106,39 +104,6 @@ var _ = BeforeSuite(func(done Done) { close(done) }, 60) -var _ = Describe("ImageRepository controller", func() { - It("expands the canonical image name", func() { - repo := imagev1alpha1.ImageRepository{ - Spec: imagev1alpha1.ImageRepositorySpec{ - Image: "alpine", - }, - } - imageRepoName := types.NamespacedName{ - Name: "alpine-image", - Namespace: "default", - } - - repo.Name = imageRepoName.Name - repo.Namespace = imageRepoName.Namespace - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - r := imageRepoReconciler - err := r.Create(ctx, &repo) - Expect(err).ToNot(HaveOccurred()) - - var repoAfter imagev1alpha1.ImageRepository - Eventually(func() bool { - err = r.Get(context.Background(), imageRepoName, &repoAfter) - return err == nil && repoAfter.Status.CanonicalImageName != "" - }, timeout, interval).Should(BeTrue()) - Expect(repoAfter.Name).To(Equal("alpine-image")) - Expect(repoAfter.Namespace).To(Equal("default")) - Expect(repoAfter.Status.CanonicalImageName).To(Equal("index.docker.io/library/alpine")) - }) -}) - var _ = AfterSuite(func() { By("tearing down the test environment") err := testEnv.Stop() From 6ae6561b8bbedc11910d34618a64edeace4ae3f0 Mon Sep 17 00:00:00 2001 From: Michael Bridgen Date: Sun, 12 Jul 2020 14:08:16 +0100 Subject: [PATCH 3/3] Test that the tags for a repo are scanned In this commit I use the test registry implementation to check that the controller will scan the tags of an image. This needs a bit more scaffolding, since the test registry doesn't handle /tags/list. --- controllers/imagerepository_controller.go | 4 -- controllers/scan_test.go | 52 ++++++++++++++++++- controllers/suite_test.go | 63 +++++++++++++++++++++++ 3 files changed, 114 insertions(+), 5 deletions(-) diff --git a/controllers/imagerepository_controller.go b/controllers/imagerepository_controller.go index 803a893..0c8e162 100644 --- a/controllers/imagerepository_controller.go +++ b/controllers/imagerepository_controller.go @@ -68,10 +68,6 @@ func (r *ImageRepositoryReconciler) Reconcile(req ctrl.Request) (ctrl.Result, er canonicalName := ref.Context().String() if imageRepo.Status.CanonicalImageName != canonicalName { imageRepo.Status.CanonicalImageName = canonicalName - imageRepo.Status.LastError = "" - if err := r.Status().Update(ctx, &imageRepo); err != nil { - return ctrl.Result{}, err - } } now := time.Now() diff --git a/controllers/scan_test.go b/controllers/scan_test.go index d4d6159..3f1ffd1 100644 --- a/controllers/scan_test.go +++ b/controllers/scan_test.go @@ -18,8 +18,12 @@ package controllers import ( "context" + "strings" "time" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/types" @@ -28,8 +32,14 @@ import ( // +kubebuilder:scaffold:imports ) +// https://github.com/google/go-containerregistry/blob/v0.1.1/pkg/registry/compatibility_test.go +// has an example of loading a test registry with a random image. + var _ = Describe("ImageRepository controller", func() { It("expands the canonical image name", func() { + // would be good to test this without needing to do the scanning, since + // 1. better to not rely on external services being available + // 2. probably going to want to have several test cases repo := imagev1alpha1.ImageRepository{ Spec: imagev1alpha1.ImageRepositorySpec{ Image: "alpine", @@ -52,11 +62,51 @@ var _ = Describe("ImageRepository controller", func() { var repoAfter imagev1alpha1.ImageRepository Eventually(func() bool { - err = r.Get(context.Background(), imageRepoName, &repoAfter) + err := r.Get(context.Background(), imageRepoName, &repoAfter) return err == nil && repoAfter.Status.CanonicalImageName != "" }, timeout, interval).Should(BeTrue()) Expect(repoAfter.Name).To(Equal("alpine-image")) Expect(repoAfter.Namespace).To(Equal("default")) Expect(repoAfter.Status.CanonicalImageName).To(Equal("index.docker.io/library/alpine")) }) + + It("fetches the tags for an image", func() { + versions := []string{"0.1.0", "0.1.1", "0.2.0", "1.0.0", "1.0.1", "1.0.2", "1.1.0-alpha"} + registry := strings.TrimPrefix(registryServer.URL, "http://") + imgRepo := registry + "/myimage" + for _, tag := range versions { + imgRef, err := name.NewTag(imgRepo + ":" + tag) + Expect(err).ToNot(HaveOccurred()) + img, err := random.Image(512, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(remote.Write(imgRef, img)).To(Succeed()) + } + + repo := imagev1alpha1.ImageRepository{ + Spec: imagev1alpha1.ImageRepositorySpec{ + Image: imgRepo, + }, + } + objectName := types.NamespacedName{ + Name: "random", + Namespace: "default", + } + + repo.Name = objectName.Name + repo.Namespace = objectName.Namespace + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + r := imageRepoReconciler + Expect(r.Create(ctx, &repo)).To(Succeed()) + + var repoAfter imagev1alpha1.ImageRepository + Eventually(func() bool { + err := r.Get(context.Background(), objectName, &repoAfter) + return err == nil && repoAfter.Status.CanonicalImageName != "" + }, timeout, interval).Should(BeTrue()) + Expect(repoAfter.Status.CanonicalImageName).To(Equal(imgRepo)) + Expect(repoAfter.Status.LastScanResult.TagCount).To(Equal(len(versions))) + }) }) diff --git a/controllers/suite_test.go b/controllers/suite_test.go index d9f8d2c..39b4dbf 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -17,10 +17,15 @@ limitations under the License. package controllers import ( + "encoding/json" + "net/http" + "net/http/httptest" "path/filepath" + "strings" "testing" "time" + "github.com/google/go-containerregistry/pkg/registry" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "k8s.io/client-go/kubernetes/scheme" @@ -51,6 +56,7 @@ var k8sClient client.Client var k8sMgr ctrl.Manager var imageRepoReconciler *ImageRepositoryReconciler var testEnv *envtest.Environment +var registryServer *httptest.Server func TestAPIs(t *testing.T) { RegisterFailHandler(Fail) @@ -101,6 +107,13 @@ var _ = BeforeSuite(func(done Done) { k8sClient = k8sMgr.GetClient() Expect(k8sClient).ToNot(BeNil()) + // set up a local registry for testing scanning + regHandler := registry.New() + registryServer = httptest.NewServer(&tagListHandler{ + registryHandler: regHandler, + imagetags: map[string][]string{}, + }) + close(done) }, 60) @@ -108,4 +121,54 @@ var _ = AfterSuite(func() { By("tearing down the test environment") err := testEnv.Stop() Expect(err).ToNot(HaveOccurred()) + registryServer.Close() }) + +// --- + +// the go-containerregistry test regsitry implementation does not +// serve /myimage/tags/list. Until it does, I'm adding this handler. +// NB: +// - assumes repo name is a single element +// - assumes no overwriting tags + +type tagListHandler struct { + registryHandler http.Handler + imagetags map[string][]string +} + +type tagListResult struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} + +func (h *tagListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // a tag list request has a path like: /v2//tags/list + if withoutTagsList := strings.TrimSuffix(r.URL.Path, "/tags/list"); r.Method == "GET" && withoutTagsList != r.URL.Path { + repo := strings.TrimPrefix(withoutTagsList, "/v2/") + if tags, ok := h.imagetags[repo]; ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + result := tagListResult{ + Name: repo, + Tags: tags, + } + Expect(json.NewEncoder(w).Encode(result)).To(Succeed()) + println("Requested tags", repo, strings.Join(tags, ", ")) + return + } + w.WriteHeader(http.StatusNotFound) + return + } + + // record the fact of a PUT to a tag; the path looks like: /v2//manifests/ + h.registryHandler.ServeHTTP(w, r) + if r.Method == "PUT" { + pathElements := strings.Split(r.URL.Path, "/") + if len(pathElements) == 5 && pathElements[1] == "v2" && pathElements[3] == "manifests" { + repo, tag := pathElements[2], pathElements[4] + println("Recording tag", repo, tag) + h.imagetags[repo] = append(h.imagetags[repo], tag) + } + } +}