diff --git a/README.md b/README.md index f3cef586..b1585311 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ enrich version checking on image tags: used to change the URL for where to lookup where the latest image version is. In this example, the current version of `my-container` will be compared against the image versions in the `docker.io/bitnami/etcd` registry. + (This also overrrides any substitution/replacement from `--image-url-substitution`.) ## Known configurations diff --git a/cmd/app/app.go b/cmd/app/app.go index 647e2bc4..7ce526b5 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -3,17 +3,17 @@ package app import ( "context" "fmt" - "os" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" "k8s.io/client-go/kubernetes" - _ "k8s.io/client-go/plugin/pkg/client/auth" // Load all auth plugins + "os" "github.com/jetstack/version-checker/pkg/api" "github.com/jetstack/version-checker/pkg/client" "github.com/jetstack/version-checker/pkg/controller" + "github.com/jetstack/version-checker/pkg/controller/checker" "github.com/jetstack/version-checker/pkg/metrics" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + _ "k8s.io/client-go/plugin/pkg/client/auth" // Load all auth plugins ) const ( @@ -74,8 +74,14 @@ func NewCommand(ctx context.Context) *cobra.Command { log.Infof("flag --test-all-containers=%t %s", opts.DefaultTestAll, defaultTestAllInfoMsg) - c := controller.New(opts.CacheTimeout, metrics, - client, kubeClient, log, opts.DefaultTestAll) + var imageURLSubstitution *checker.Substitution + if opts.ImageURLSubstitution != "" { + if imageURLSubstitution, err = checker.NewSubstitutionFromSedCommand(opts.ImageURLSubstitution); err != nil { + return fmt.Errorf("failed to parse --image-url-substitution %q: %s", opts.ImageURLSubstitution, err) + } + } + + c := controller.New(opts.CacheTimeout, metrics, client, kubeClient, log, opts.DefaultTestAll, imageURLSubstitution) return c.Run(ctx, opts.CacheTimeout/2) }, diff --git a/cmd/app/options.go b/cmd/app/options.go index 4c82ccdf..56c820e7 100644 --- a/cmd/app/options.go +++ b/cmd/app/options.go @@ -65,6 +65,7 @@ type Options struct { MetricsServingAddress string DefaultTestAll bool CacheTimeout time.Duration + ImageURLSubstitution string LogLevel string kubeConfigFlags *genericclioptions.ConfigFlags @@ -114,6 +115,14 @@ func (o *Options) addAppFlags(fs *pflag.FlagSet) { "The time for an image version in the cache to be considered fresh. Images "+ "will be rechecked after this interval.") + fs.StringVarP(&o.ImageURLSubstitution, + "image-url-substitution", "", "", + "Image URL substitution. In case you want to apply a replacement to all image URLs. "+ + "E.g. when you mirrored them and want to check the originals. "+ + "The format follows the sed substitution command syntax. "+ + "E.g. s#myacr.azurecr.io/mirror/##g to remove your registry from: myacr.azurecr.io/mirror/docker.io/alpine:3"+ + "(Any override-url.version-checker.io/my-container annotation would still override everything afterwards.)") + fs.StringVarP(&o.LogLevel, "log-level", "v", "info", "Log level (debug, info, warn, error, fatal, panic).") diff --git a/pkg/controller/checker/checker.go b/pkg/controller/checker/checker.go index 8149dcce..8eb70612 100644 --- a/pkg/controller/checker/checker.go +++ b/pkg/controller/checker/checker.go @@ -14,7 +14,8 @@ import ( ) type Checker struct { - search search.Searcher + search search.Searcher + imageURLSubstitution *Substitution } type Result struct { @@ -24,9 +25,10 @@ type Result struct { ImageURL string } -func New(search search.Searcher) *Checker { +func New(search search.Searcher, imageURLSubstitution *Substitution) *Checker { return &Checker{ - search: search, + search: search, + imageURLSubstitution: imageURLSubstitution, } } @@ -46,6 +48,7 @@ func (c *Checker) Container(ctx context.Context, log *logrus.Entry, pod *corev1. usingTag = false } + imageURL = c.substituteImageURL(log, imageURL) imageURL = c.overrideImageURL(log, imageURL, opts) if opts.UseSHA { @@ -60,6 +63,18 @@ func (c *Checker) handleLatestOrEmptyTag(log *logrus.Entry, currentTag, currentS log.WithField("module", "checker").Debugf("image using %q tag, comparing image SHA %q", currentTag, currentSHA) } +func (c *Checker) substituteImageURL(log *logrus.Entry, imageURL string) string { + + if c.imageURLSubstitution == nil { + return imageURL + } + newImageURL := c.imageURLSubstitution.Pattern.ReplaceAllString(imageURL, c.imageURLSubstitution.Substitute) + if newImageURL != imageURL { + log.Debugf("substituting image URL %s -> %s", imageURL, newImageURL) + } + return newImageURL +} + func (c *Checker) overrideImageURL(log *logrus.Entry, imageURL string, opts *api.Options) string { if opts.OverrideURL != nil && *opts.OverrideURL != imageURL { log.Debugf("overriding image URL %s -> %s", imageURL, *opts.OverrideURL) diff --git a/pkg/controller/checker/checker_test.go b/pkg/controller/checker/checker_test.go index ac5dadd6..ba391d8b 100644 --- a/pkg/controller/checker/checker_test.go +++ b/pkg/controller/checker/checker_test.go @@ -2,6 +2,7 @@ package checker import ( "context" + "github.com/stretchr/testify/require" "reflect" "testing" @@ -15,11 +16,12 @@ import ( func TestContainer(t *testing.T) { tests := map[string]struct { - statusSHA string - imageURL string - opts *api.Options - searchResp *api.ImageTag - expResult *Result + statusSHA string + imageURL string + opts *api.Options + imageURLSubstitution string + searchResp *api.ImageTag + expResult *Result }{ "no status sha should return nil, nil": { statusSHA: "", @@ -165,6 +167,22 @@ func TestContainer(t *testing.T) { IsLatest: true, }, }, + "if latest is latest version, and imageURLSubstitution is set, then return true": { + statusSHA: "myregistry.example.com/mirrored/quay.io/jetstack/version-checker:latest@sha:123", + imageURL: "myregistry.example.com/mirrored/quay.io/jetstack/version-checker:latest", + opts: new(api.Options), + imageURLSubstitution: "s/myregistry.example.com\\/mirrored\\///g", + searchResp: &api.ImageTag{ + Tag: "", + SHA: "sha:123", + }, + expResult: &Result{ + CurrentVersion: "sha:123", + LatestVersion: "sha:123", + ImageURL: "quay.io/jetstack/version-checker", + IsLatest: true, + }, + }, "if using v0.2.0 with use sha, but not latest, return false": { statusSHA: "localhost:5000/version-checker@sha:123", imageURL: "localhost:5000/version-checker:v0.2.0", @@ -263,7 +281,13 @@ func TestContainer(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - checker := New(search.New().With(test.searchResp, nil)) + var err error + var imageURLSubstitution *Substitution + if test.imageURLSubstitution != "" { + imageURLSubstitution, err = NewSubstitutionFromSedCommand(test.imageURLSubstitution) + require.NoError(t, err) + } + checker := New(search.New().With(test.searchResp, nil), imageURLSubstitution) pod := &corev1.Pod{ Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{ @@ -398,7 +422,7 @@ func TestIsLatestOrEmptyTag(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - checker := New(search.New()) + checker := New(search.New(), nil) if is := checker.isLatestOrEmptyTag(test.tag); is != test.expIs { t.Errorf("unexpected isLatestOrEmptyTag exp=%t got=%t", test.expIs, is) @@ -475,7 +499,7 @@ func TestIsLatestSemver(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - checker := New(search.New().With(test.searchResp, nil)) + checker := New(search.New().With(test.searchResp, nil), nil) latestImage, isLatest, err := checker.isLatestSemver(context.TODO(), test.imageURL, test.currentSHA, test.currentImage, nil) if err != nil { t.Fatal(err) @@ -530,7 +554,7 @@ func TestIsLatestSHA(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - checker := New(search.New().With(test.searchResp, nil)) + checker := New(search.New().With(test.searchResp, nil), nil) result, err := checker.isLatestSHA(context.TODO(), test.imageURL, test.currentSHA, nil) if err != nil { t.Fatal(err) diff --git a/pkg/controller/checker/substitution.go b/pkg/controller/checker/substitution.go new file mode 100644 index 00000000..2ea6e982 --- /dev/null +++ b/pkg/controller/checker/substitution.go @@ -0,0 +1,59 @@ +package checker + +import ( + "fmt" + "regexp" + "strings" +) + +type Substitution struct { + Pattern *regexp.Regexp + Substitute string + All bool +} + +func NewSubstitutionFromSedCommand(sedCommand string) (*Substitution, error) { + pattern, substitute, flags, err := splitSedSubstitutionCommand(sedCommand) + if err != nil { + return nil, err + } + compiledPattern, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("sed command for substitution has regex that does not compile: %s %w", pattern, err) + } + var all bool + if flags == "g" { + all = true + } else if flags != "" { + return nil, fmt.Errorf("sed command for substitution only supports the 'g' flag: %s", flags) + } + + return &Substitution{ + Pattern: compiledPattern, + Substitute: substitute, + All: all, + }, nil +} + +func splitSedSubstitutionCommand(sedCommand string) (string, string, string, error) { + if len(sedCommand) < 4 { + return "", "", "", fmt.Errorf("sed command for substitution seems to short: %s", sedCommand) + } + if sedCommand[0] != 's' { + return "", "", "", fmt.Errorf("sed command for substitution should start with s: %s", sedCommand) + } + separator := regexp.QuoteMeta(sedCommand[1:2]) + group := fmt.Sprintf(`((?:.|\\[%s])*)`, separator) + pattern := `^s` + separator + group + separator + group + separator + group + `$` + matcher, err := regexp.Compile(pattern) + if err != nil { + return "", "", "", fmt.Errorf("regexp to parse sed command for substitution does not compile: %s %w", pattern, err) + } + submatches := matcher.FindStringSubmatch(sedCommand) + if len(submatches) != 4 { + return "", "", "", fmt.Errorf("sed command for substitution could not be parsed: %s", pattern) + } + return strings.ReplaceAll(submatches[1], "\\"+separator, separator), + strings.ReplaceAll(submatches[2], "\\"+separator, separator), + submatches[3], nil +} diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 89917c93..3bb84379 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -51,6 +51,7 @@ func New( kubeClient kubernetes.Interface, log *logrus.Entry, defaultTestAll bool, + imageURLSubstitution *checker.Substitution, ) *Controller { workqueue := workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[any]()) scheduledWorkQueue := scheduler.NewScheduledWorkQueue(clock.RealClock{}, workqueue.Add) @@ -58,6 +59,7 @@ func New( log = log.WithField("module", "controller") versionGetter := version.New(log, imageClient, cacheTimeout) search := search.New(log, cacheTimeout, versionGetter) + checker := checker.New(search, imageURLSubstitution) c := &Controller{ log: log, @@ -65,7 +67,7 @@ func New( workqueue: workqueue, scheduledWorkQueue: scheduledWorkQueue, metrics: metrics, - checker: checker.New(search), + checker: checker, defaultTestAll: defaultTestAll, } diff --git a/pkg/controller/controller_test.go b/pkg/controller/controller_test.go index 7b9fa729..768cc0d0 100644 --- a/pkg/controller/controller_test.go +++ b/pkg/controller/controller_test.go @@ -30,7 +30,7 @@ func TestNewController(t *testing.T) { metrics := &metrics.Metrics{} imageClient := &client.Client{} - controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true) + controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true, nil) assert.NotNil(t, controller) assert.Equal(t, controller.defaultTestAll, true) @@ -43,7 +43,7 @@ func TestRun(t *testing.T) { kubeClient := fake.NewSimpleClientset() metrics := &metrics.Metrics{} imageClient := &client.Client{} - controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true) + controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true, nil) ctx, cancel := context.WithCancel(context.Background()) @@ -72,7 +72,7 @@ func TestAddObject(t *testing.T) { kubeClient := fake.NewSimpleClientset() metrics := &metrics.Metrics{} imageClient := &client.Client{} - controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true) + controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true, nil) obj := &corev1.Pod{} controller.addObject(obj) @@ -99,7 +99,7 @@ func TestDeleteObject(t *testing.T) { kubeClient := fake.NewSimpleClientset() metrics := &metrics.Metrics{} imageClient := &client.Client{} - controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true) + controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true, nil) pod := &corev1.Pod{ Spec: corev1.PodSpec{ @@ -120,7 +120,7 @@ func TestProcessNextWorkItem(t *testing.T) { kubeClient := fake.NewSimpleClientset() metrics := &metrics.Metrics{} imageClient := &client.Client{} - controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true) + controller := New(5*time.Minute, metrics, imageClient, kubeClient, testLogger, true, nil) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/pkg/controller/sync_test.go b/pkg/controller/sync_test.go index ba1ade4e..5ad3574f 100644 --- a/pkg/controller/sync_test.go +++ b/pkg/controller/sync_test.go @@ -25,7 +25,7 @@ func TestController_Sync(t *testing.T) { metrics := &metrics.Metrics{} imageClient := &client.Client{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) - checker := checker.New(searcher) + checker := checker.New(searcher, nil) controller := &Controller{ log: log, @@ -59,7 +59,7 @@ func TestController_SyncContainer(t *testing.T) { metrics := &metrics.Metrics{} imageClient := &client.Client{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) - checker := checker.New(searcher) + checker := checker.New(searcher, nil) controller := &Controller{ log: log, @@ -90,7 +90,7 @@ func TestController_CheckContainer(t *testing.T) { metrics := &metrics.Metrics{} imageClient := &client.Client{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) - checker := checker.New(searcher) + checker := checker.New(searcher, nil) controller := &Controller{ log: log, @@ -118,7 +118,7 @@ func TestController_SyncContainer_NoVersionFound(t *testing.T) { metrics := &metrics.Metrics{} imageClient := &client.Client{} searcher := search.New(log, 5*time.Minute, version.New(log, imageClient, 5*time.Minute)) - checker := checker.New(searcher) + checker := checker.New(searcher, nil) controller := &Controller{ log: log,