Merge pull request #3 from squaremo/some-scanning

Some scanning
This commit is contained in:
Michael Bridgen 2020-07-12 18:26:49 +01:00 committed by GitHub
commit 323711f089
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 291 additions and 39 deletions

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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
@ -60,13 +68,49 @@ func (r *ImageRepositoryReconciler) Reconcile(req ctrl.Request) (ctrl.Result, er
canonicalName := ref.Context().String()
if imageRepo.Status.CanonicalImageName != canonicalName {
imageRepo.Status.CanonicalImageName = canonicalName
}
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 = ""
if err := r.Status().Update(ctx, &imageRepo); err != nil {
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
}
return ctrl.Result{}, 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 {

112
controllers/scan_test.go Normal file
View File

@ -0,0 +1,112 @@
/*
Copyright 2020 Michael Bridgen <mikeb@squaremobius.net>
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"
"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"
imagev1alpha1 "github.com/squaremo/image-update/api/v1alpha1"
// +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",
},
}
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"))
})
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)))
})
})

View File

@ -17,14 +17,17 @@ limitations under the License.
package controllers
import (
"context"
"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/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
@ -53,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)
@ -103,44 +107,68 @@ 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)
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()
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/<repo>/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/<repo>/manifests/<tag>
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)
}
}
}

4
go.sum
View File

@ -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=