summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIshita Sequeira <46771830+ishitasequeira@users.noreply.github.com>2024-10-31 09:38:03 -0400
committerGitHub <noreply@github.com>2024-10-31 09:38:03 -0400
commite09b17d64e5ac8790f47dc0f8782bbc877cca16d (patch)
treec4521b381ddf3878c899ceabea8f60af30a7b9b7
parenta5acd25853dd19a5186c1cc3801bf9e5cfa052dc (diff)
refactor: Add additional packages to registry-scanner (#900)
Signed-off-by: Ishita Sequeira <ishiseq29@gmail.com>
-rw-r--r--registry-scanner/go.mod3
-rw-r--r--registry-scanner/go.sum2
-rw-r--r--registry-scanner/pkg/health/health.go25
-rw-r--r--registry-scanner/pkg/health/health_test.go52
-rw-r--r--registry-scanner/pkg/log/log.go183
-rw-r--r--registry-scanner/pkg/log/log_test.go156
-rw-r--r--registry-scanner/pkg/options/options.go107
-rw-r--r--registry-scanner/pkg/options/options_test.go100
-rw-r--r--registry-scanner/pkg/tag/semver.go31
-rw-r--r--registry-scanner/pkg/tag/tag.go185
-rw-r--r--registry-scanner/pkg/tag/tag_test.go184
-rw-r--r--registry-scanner/test/fixture/capture.go55
12 files changed, 1082 insertions, 1 deletions
diff --git a/registry-scanner/go.mod b/registry-scanner/go.mod
index 471f8c0..3e0dd95 100644
--- a/registry-scanner/go.mod
+++ b/registry-scanner/go.mod
@@ -4,13 +4,14 @@ go 1.22.3
require (
github.com/argoproj-labs/argocd-image-updater v0.14.0
+ github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.9.0
)
require (
+ github.com/Masterminds/semver/v3 v3.2.1
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/sirupsen/logrus v1.9.3 // indirect
golang.org/x/sys v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/registry-scanner/go.sum b/registry-scanner/go.sum
index dd8d76d..16441ee 100644
--- a/registry-scanner/go.sum
+++ b/registry-scanner/go.sum
@@ -1,3 +1,5 @@
+github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
+github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/argoproj-labs/argocd-image-updater v0.14.0 h1:DICeW/eVROJpdjiuQxMoEGYnyzMMjjgYDVUrkACH+vM=
github.com/argoproj-labs/argocd-image-updater v0.14.0/go.mod h1:PSVBweUoS6ogVFAikCTTNbXoZ5+pJT9ksG45rwsQqi0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
diff --git a/registry-scanner/pkg/health/health.go b/registry-scanner/pkg/health/health.go
new file mode 100644
index 0000000..cbc4977
--- /dev/null
+++ b/registry-scanner/pkg/health/health.go
@@ -0,0 +1,25 @@
+package health
+
+// Most simple health check probe to see whether our server is still alive
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
+)
+
+func StartHealthServer(port int) chan error {
+ errCh := make(chan error)
+ go func() {
+ sm := http.NewServeMux()
+ sm.HandleFunc("/healthz", HealthProbe)
+ errCh <- http.ListenAndServe(fmt.Sprintf(":%d", port), sm)
+ }()
+ return errCh
+}
+
+func HealthProbe(w http.ResponseWriter, r *http.Request) {
+ log.Tracef("/healthz ping request received, replying with pong")
+ fmt.Fprintf(w, "OK\n")
+}
diff --git a/registry-scanner/pkg/health/health_test.go b/registry-scanner/pkg/health/health_test.go
new file mode 100644
index 0000000..b5670f5
--- /dev/null
+++ b/registry-scanner/pkg/health/health_test.go
@@ -0,0 +1,52 @@
+package health
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// Unit test function
+func TestStartHealthServer_InvalidPort(t *testing.T) {
+ // Use an invalid port number
+ port := -1
+ errCh := StartHealthServer(port)
+ defer close(errCh) // Close the error channel after the test completes
+ select {
+ case err := <-errCh:
+ if err == nil {
+ t.Error("Expected error, got nil")
+ } else if err.Error() != fmt.Sprintf("listen tcp: address %d: invalid port", port) {
+ t.Errorf("Expected error message about invalid port, got %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Error("Timed out waiting for error")
+ }
+}
+
+func TestHealthProbe(t *testing.T) {
+ // Create a mock HTTP request
+ req, err := http.NewRequest("GET", "/healthz", nil)
+ if err != nil {
+ t.Fatalf("Failed to create request: %v", err)
+ }
+
+ // Create a mock HTTP response recorder
+ w := httptest.NewRecorder()
+
+ // Call the HealthProbe function directly
+ HealthProbe(w, req)
+
+ // Check the response status code
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status OK; got %d", w.Code)
+ }
+
+ // Check the response body
+ expectedBody := "OK\n"
+ if body := w.Body.String(); body != expectedBody {
+ t.Errorf("Expected body %q; got %q", expectedBody, body)
+ }
+}
diff --git a/registry-scanner/pkg/log/log.go b/registry-scanner/pkg/log/log.go
new file mode 100644
index 0000000..85be5d4
--- /dev/null
+++ b/registry-scanner/pkg/log/log.go
@@ -0,0 +1,183 @@
+package log
+
+// Wrapper package around logrus whose main purpose is to support having
+// different output streams for error and non-error messages.
+//
+// Does not wrap every method of logrus package. If you need direct access,
+// use log.Log() to get the actual logrus logger object.
+//
+// It might seem redundant, but we really want the different output streams.
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "sync"
+
+ "github.com/sirupsen/logrus"
+)
+
+// Internal Logger object
+var logger *logrus.Logger
+
+// LogContext contains a structured context for logging
+type LogContext struct {
+ fields logrus.Fields
+ normalOut io.Writer
+ errorOut io.Writer
+ mutex sync.RWMutex
+}
+
+// NewContext returns a LogContext with default settings
+func NewContext() *LogContext {
+ var logctx LogContext
+ logctx.fields = make(logrus.Fields)
+ logctx.normalOut = os.Stdout
+ logctx.errorOut = os.Stderr
+ return &logctx
+}
+
+// SetLogLevel sets the log level to use for the logger
+func SetLogLevel(logLevel string) error {
+ switch strings.ToLower(logLevel) {
+ case "trace":
+ logger.SetLevel(logrus.TraceLevel)
+ case "debug":
+ logger.SetLevel(logrus.DebugLevel)
+ case "info":
+ logger.SetLevel(logrus.InfoLevel)
+ case "warn":
+ logger.SetLevel(logrus.WarnLevel)
+ case "error":
+ logger.SetLevel(logrus.ErrorLevel)
+ default:
+ return fmt.Errorf("invalid loglevel: %s", logLevel)
+ }
+ return nil
+}
+
+// WithContext is an alias for NewContext
+func WithContext() *LogContext {
+ return NewContext()
+}
+
+// AddField adds a structured field to logctx
+func (logctx *LogContext) AddField(key string, value interface{}) *LogContext {
+ logctx.mutex.Lock()
+ logctx.fields[key] = value
+ logctx.mutex.Unlock()
+ return logctx
+}
+
+// Log retrieves the native logger interface. Use with care.
+func Log() *logrus.Logger {
+ return logger
+}
+
+// Tracef logs a debug message for logctx to stdout
+func (logctx *LogContext) Tracef(format string, args ...interface{}) {
+ logger.SetOutput(logctx.normalOut)
+ if len(logctx.fields) > 0 {
+ logger.WithFields(logctx.fields).Tracef(format, args...)
+ } else {
+ logger.Tracef(format, args...)
+ }
+}
+
+// Debugf logs a debug message for logctx to stdout
+func (logctx *LogContext) Debugf(format string, args ...interface{}) {
+ logger.SetOutput(logctx.normalOut)
+ if len(logctx.fields) > 0 {
+ logger.WithFields(logctx.fields).Debugf(format, args...)
+ } else {
+ logger.Debugf(format, args...)
+ }
+}
+
+// Infof logs an informational message for logctx to stdout
+func (logctx *LogContext) Infof(format string, args ...interface{}) {
+ logger.SetOutput(logctx.normalOut)
+ if len(logctx.fields) > 0 {
+ logger.WithFields(logctx.fields).Infof(format, args...)
+ } else {
+ logger.Infof(format, args...)
+ }
+}
+
+// Warnf logs a warning message for logctx to stdout
+func (logctx *LogContext) Warnf(format string, args ...interface{}) {
+ logger.SetOutput(logctx.normalOut)
+ if len(logctx.fields) > 0 {
+ logger.WithFields(logctx.fields).Warnf(format, args...)
+ } else {
+ logger.Warnf(format, args...)
+ }
+}
+
+// Errorf logs a non-fatal error message for logctx to stdout
+func (logctx *LogContext) Errorf(format string, args ...interface{}) {
+ logger.SetOutput(logctx.errorOut)
+ if len(logctx.fields) > 0 {
+ logger.WithFields(logctx.fields).Errorf(format, args...)
+ } else {
+ logger.Errorf(format, args...)
+ }
+}
+
+// Fatalf logs a fatal error message for logctx to stdout
+func (logctx *LogContext) Fatalf(format string, args ...interface{}) {
+ logger.SetOutput(logctx.errorOut)
+ if len(logctx.fields) > 0 {
+ logger.WithFields(logctx.fields).Fatalf(format, args...)
+ } else {
+ logger.Fatalf(format, args...)
+ }
+}
+
+// Tracef logs a warning message without context to stdout
+func Tracef(format string, args ...interface{}) {
+ logCtx := NewContext()
+ logCtx.Tracef(format, args...)
+}
+
+// Debugf logs a warning message without context to stdout
+func Debugf(format string, args ...interface{}) {
+ logCtx := NewContext()
+ logCtx.Debugf(format, args...)
+}
+
+// Infof logs a warning message without context to stdout
+func Infof(format string, args ...interface{}) {
+ logCtx := NewContext()
+ logCtx.Infof(format, args...)
+}
+
+// Warnf logs a warning message without context to stdout
+func Warnf(format string, args ...interface{}) {
+ logCtx := NewContext()
+ logCtx.Warnf(format, args...)
+}
+
+// Errorf logs an error message without context to stderr
+func Errorf(format string, args ...interface{}) {
+ logCtx := NewContext()
+ logCtx.Errorf(format, args...)
+}
+
+// Fatalf logs a non-recoverable error message without context to stderr
+func Fatalf(format string, args ...interface{}) {
+ logCtx := NewContext()
+ logCtx.Fatalf(format, args...)
+}
+
+func disableLogColors() bool {
+ return strings.ToLower(os.Getenv("ENABLE_LOG_COLORS")) == "false"
+}
+
+// Initializes the logging subsystem with default values
+func init() {
+ logger = logrus.New()
+ logger.SetFormatter(&logrus.TextFormatter{DisableColors: disableLogColors()})
+ logger.SetLevel(logrus.DebugLevel)
+}
diff --git a/registry-scanner/pkg/log/log_test.go b/registry-scanner/pkg/log/log_test.go
new file mode 100644
index 0000000..2d28b75
--- /dev/null
+++ b/registry-scanner/pkg/log/log_test.go
@@ -0,0 +1,156 @@
+package log
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/argoproj-labs/argocd-image-updater/registry-scanner/test/fixture"
+
+ "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_LogToStdout(t *testing.T) {
+ // We need tracing level
+ Log().SetLevel(logrus.TraceLevel)
+
+ t.Run("Test for Tracef() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ Tracef("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "this is a test")
+ assert.Contains(t, out, "level=trace")
+ })
+ t.Run("Test for Debugf() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ Debugf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "this is a test")
+ assert.Contains(t, out, "level=debug")
+ })
+ t.Run("Test for Infof() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ Infof("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "this is a test")
+ assert.Contains(t, out, "level=info")
+ })
+ t.Run("Test for Warnf() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ Warnf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "this is a test")
+ assert.Contains(t, out, "level=warn")
+ })
+ t.Run("Test for Errorf() to not log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ Errorf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+}
+
+func Test_LogToStderr(t *testing.T) {
+ // We need tracing level
+ Log().SetLevel(logrus.TraceLevel)
+
+ t.Run("Test for Tracef() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStderr(func() {
+ Tracef("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+ t.Run("Test for Debugf() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStderr(func() {
+ Debugf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+ t.Run("Test for Infof() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStderr(func() {
+ Infof("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+ t.Run("Test for Warnf() to log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStderr(func() {
+ Warnf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Empty(t, out)
+ })
+ t.Run("Test for Errorf() to not log to stdout", func(t *testing.T) {
+ out, err := fixture.CaptureStderr(func() {
+ Errorf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "this is a test")
+ assert.Contains(t, out, "level=error")
+ })
+}
+
+func Test_LoggerFields(t *testing.T) {
+ Log().SetLevel(logrus.TraceLevel)
+ t.Run("Test for Tracef() to log correctly with fields", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ WithContext().AddField("foo", "bar").Tracef("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "foo=bar")
+ assert.Contains(t, out, "msg=\"this is a test\"")
+ })
+ t.Run("Test for Debugf() to log correctly with fields", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ WithContext().AddField("foo", "bar").Debugf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "foo=bar")
+ assert.Contains(t, out, "msg=\"this is a test\"")
+ })
+ t.Run("Test for Infof() to log correctly with fields", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ WithContext().AddField("foo", "bar").Infof("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "foo=bar")
+ assert.Contains(t, out, "msg=\"this is a test\"")
+ })
+ t.Run("Test for Warnf() to log correctly with fields", func(t *testing.T) {
+ out, err := fixture.CaptureStdout(func() {
+ WithContext().AddField("foo", "bar").Warnf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "foo=bar")
+ assert.Contains(t, out, "msg=\"this is a test\"")
+ })
+ t.Run("Test for Errorf() to log correctly with fields", func(t *testing.T) {
+ out, err := fixture.CaptureStderr(func() {
+ WithContext().AddField("foo", "bar").Errorf("this is a test")
+ })
+ require.NoError(t, err)
+ assert.Contains(t, out, "foo=bar")
+ assert.Contains(t, out, "msg=\"this is a test\"")
+ })
+}
+
+func Test_LogLevel(t *testing.T) {
+ for _, level := range []string{"trace", "debug", "info", "warn", "error"} {
+ t.Run(fmt.Sprintf("Test set loglevel %s", level), func(t *testing.T) {
+ err := SetLogLevel(level)
+ assert.NoError(t, err)
+ })
+ }
+ t.Run("Test set invalid loglevel", func(t *testing.T) {
+ err := SetLogLevel("invalid")
+ assert.Error(t, err)
+ })
+}
diff --git a/registry-scanner/pkg/options/options.go b/registry-scanner/pkg/options/options.go
new file mode 100644
index 0000000..df45e95
--- /dev/null
+++ b/registry-scanner/pkg/options/options.go
@@ -0,0 +1,107 @@
+package options
+
+import (
+ "sort"
+ "sync"
+
+ "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
+)
+
+// ManifestOptions define some options when retrieving image manifests
+type ManifestOptions struct {
+ platforms map[string]bool
+ mutex sync.RWMutex
+ metadata bool
+ logger *log.LogContext
+}
+
+// NewManifestOptions returns an initialized ManifestOptions struct
+func NewManifestOptions() *ManifestOptions {
+ return &ManifestOptions{
+ platforms: make(map[string]bool),
+ metadata: false,
+ }
+}
+
+// PlatformKey returns a string usable as platform key
+func PlatformKey(os string, arch string, variant string) string {
+ key := os + "/" + arch
+ if variant != "" {
+ key += "/" + variant
+ }
+ return key
+}
+
+// WantsPlatform returns true if given platform matches the platform set in options
+func (o *ManifestOptions) WantsPlatform(os string, arch string, variant string) bool {
+ o.mutex.RLock()
+ defer o.mutex.RUnlock()
+ if len(o.platforms) == 0 {
+ return true
+ }
+ _, ok := o.platforms[PlatformKey(os, arch, variant)]
+ if ok {
+ return true
+ }
+
+ // if no exact match, and the passed platform has variant, it may be a more
+ // specific variant of the platform specified in options. So compare os/arch only
+ if variant != "" {
+ _, ok = o.platforms[PlatformKey(os, arch, "")]
+ return ok
+ }
+ return false
+}
+
+// WithPlatform sets a platform filter for options o
+func (o *ManifestOptions) WithPlatform(os string, arch string, variant string) *ManifestOptions {
+ o.mutex.Lock()
+ defer o.mutex.Unlock()
+ if o.platforms == nil {
+ o.platforms = map[string]bool{}
+ }
+ o.platforms[PlatformKey(os, arch, variant)] = true
+ return o
+}
+
+func (o *ManifestOptions) Platforms() []string {
+ o.mutex.RLock()
+ defer o.mutex.RUnlock()
+ if len(o.platforms) == 0 {
+ return []string{}
+ }
+ keys := make([]string, 0, len(o.platforms))
+ for k := range o.platforms {
+ keys = append(keys, k)
+ }
+ // We sort the slice before returning it, to guarantee stable order
+ sort.Strings(keys)
+ return keys
+}
+
+// WantsMetdata returns true if metadata should be requested
+func (o *ManifestOptions) WantsMetadata() bool {
+ return o.metadata
+}
+
+// WithMetadata sets metadata to be requested
+func (o *ManifestOptions) WithMetadata(val bool) *ManifestOptions {
+ o.metadata = val
+ return o
+}
+
+// WithLogger sets the log context to use for the given manifest options.
+func (o *ManifestOptions) WithLogger(logger *log.LogContext) *ManifestOptions {
+ o.logger = logger
+ return o
+}
+
+// Logger gets the configured log context for given manifest options. If logger
+// is nil, returns a default log context.
+func (o *ManifestOptions) Logger() *log.LogContext {
+ if o.logger == nil {
+ return log.WithContext()
+ } else {
+ return o.logger
+ }
+}
diff --git a/registry-scanner/pkg/options/options_test.go b/registry-scanner/pkg/options/options_test.go
new file mode 100644
index 0000000..99dd8bb
--- /dev/null
+++ b/registry-scanner/pkg/options/options_test.go
@@ -0,0 +1,100 @@
+package options
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_PlatformKey(t *testing.T) {
+ t.Run("Without variant", func(t *testing.T) {
+ assert.Equal(t, PlatformKey("linux", "amd64", ""), "linux/amd64")
+ })
+ t.Run("With variant", func(t *testing.T) {
+ assert.Equal(t, PlatformKey("linux", "arm", "v6"), "linux/arm/v6")
+ })
+}
+
+func Test_WantsPlatform(t *testing.T) {
+ opts := NewManifestOptions()
+ t.Run("Empty options", func(t *testing.T) {
+ assert.True(t, opts.WantsPlatform("linux", "arm", "v7"))
+ assert.True(t, opts.WantsPlatform("linux", "amd64", ""))
+ })
+ t.Run("Single platform", func(t *testing.T) {
+ opts = opts.WithPlatform("linux", "arm", "v7")
+ assert.True(t, opts.WantsPlatform("linux", "arm", "v7"))
+ })
+ t.Run("Platform appended and non-match", func(t *testing.T) {
+ opts = opts.WithPlatform("linux", "arm", "v8")
+ assert.True(t, opts.WantsPlatform("linux", "arm", "v7"))
+ assert.True(t, opts.WantsPlatform("linux", "arm", "v8"))
+
+ assert.False(t, opts.WantsPlatform("linux", "arm", "v6"))
+ assert.False(t, opts.WantsPlatform("linux", "arm", ""))
+ assert.False(t, opts.WantsPlatform("linux", "", ""))
+
+ assert.False(t, opts.WantsPlatform("linux", "amd64", "v7"))
+ assert.False(t, opts.WantsPlatform("linux", "amd64", ""))
+
+ assert.False(t, opts.WantsPlatform("darwin", "arm", "v7"))
+ assert.False(t, opts.WantsPlatform("darwin", "arm", ""))
+ })
+ t.Run("Platform lenient match", func(t *testing.T) {
+ opts := &ManifestOptions{}
+ opts = opts.WithPlatform("linux", "arm", "")
+ opts = opts.WithPlatform("linux", "arm", "v7")
+ assert.True(t, opts.WantsPlatform("linux", "arm", "v8"))
+ assert.True(t, opts.WantsPlatform("linux", "arm", "v7"))
+ assert.True(t, opts.WantsPlatform("linux", "arm", ""))
+ })
+ t.Run("Uninitialized options", func(t *testing.T) {
+ opts := &ManifestOptions{}
+ assert.True(t, opts.WantsPlatform("linux", "amd64", ""))
+ opts = (&ManifestOptions{}).WithPlatform("linux", "amd64", "")
+ assert.True(t, opts.WantsPlatform("linux", "amd64", ""))
+ })
+}
+
+func Test_WantsMetadata(t *testing.T) {
+ opts := NewManifestOptions()
+ t.Run("Empty options", func(t *testing.T) {
+ assert.False(t, opts.WantsMetadata())
+ })
+ t.Run("Wants metadata", func(t *testing.T) {
+ opts = opts.WithMetadata(true)
+ assert.True(t, opts.WantsMetadata())
+ })
+ t.Run("Does not want metadata", func(t *testing.T) {
+ opts = opts.WithMetadata(false)
+ assert.False(t, opts.WantsMetadata())
+ })
+}
+
+func Test_Platforms(t *testing.T) {
+ opts := NewManifestOptions()
+ t.Run("Empty platforms returns empty array", func(t *testing.T) {
+ ps := opts.Platforms()
+ assert.Len(t, ps, 0)
+ })
+ t.Run("Single platform without variant", func(t *testing.T) {
+ ps := opts.WithPlatform("linux", "amd64", "").Platforms()
+ require.Len(t, ps, 1)
+ assert.Equal(t, ps[0], PlatformKey("linux", "amd64", ""))
+ })
+ t.Run("Single platform with variant", func(t *testing.T) {
+ ps := opts.WithPlatform("linux", "arm", "v8").Platforms()
+ require.Len(t, ps, 2)
+ assert.Equal(t, ps[0], PlatformKey("linux", "amd64", ""))
+ assert.Equal(t, ps[1], PlatformKey("linux", "arm", "v8"))
+ })
+}
+
+func Test_WithLogger(t *testing.T) {
+ opts := NewManifestOptions()
+ logger := opts.Logger()
+ assert.NotNil(t, logger)
+ opts = opts.WithLogger(logger)
+ assert.Equal(t, logger, opts.Logger())
+}
diff --git a/registry-scanner/pkg/tag/semver.go b/registry-scanner/pkg/tag/semver.go
new file mode 100644
index 0000000..d722b80
--- /dev/null
+++ b/registry-scanner/pkg/tag/semver.go
@@ -0,0 +1,31 @@
+package tag
+
+import "github.com/Masterminds/semver/v3"
+
+// semverCollection is a replacement for semver.Collection that breaks version
+// comparison ties through a lexical comparison of the original version strings.
+// Using this, instead of semver.Collection, when sorting will yield
+// deterministic results that semver.Collection will not yield.
+type semverCollection []*semver.Version
+
+// Len returns the length of a collection. The number of Version instances
+// on the slice.
+func (s semverCollection) Len() int {
+ return len(s)
+}
+
+// Less is needed for the sort interface to compare two Version objects on the
+// slice. If checks if one is less than the other.
+func (s semverCollection) Less(i, j int) bool {
+ comp := s[i].Compare(s[j])
+ if comp != 0 {
+ return comp < 0
+ }
+ return s[i].Original() < s[j].Original()
+}
+
+// Swap is needed for the sort interface to replace the Version objects
+// at two different positions in the slice.
+func (s semverCollection) Swap(i, j int) {
+ s[i], s[j] = s[j], s[i]
+}
diff --git a/registry-scanner/pkg/tag/tag.go b/registry-scanner/pkg/tag/tag.go
new file mode 100644
index 0000000..26cc215
--- /dev/null
+++ b/registry-scanner/pkg/tag/tag.go
@@ -0,0 +1,185 @@
+package tag
+
+import (
+ "encoding/hex"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/argoproj-labs/argocd-image-updater/registry-scanner/pkg/log"
+
+ "github.com/Masterminds/semver/v3"
+)
+
+// ImageTag is a representation of an image tag with metadata
+// Use NewImageTag to to initialize a new object.
+type ImageTag struct {
+ TagName string
+ TagDate *time.Time
+ TagDigest string
+}
+
+// ImageTagList is a collection of ImageTag objects.
+// Use NewImageTagList to to initialize a new object.
+type ImageTagList struct {
+ items map[string]*ImageTag
+ lock *sync.RWMutex
+}
+
+// TagInfo contains information for a tag
+type TagInfo struct {
+ CreatedAt time.Time
+ Digest [32]byte
+}
+
+// SortableImageTagList is just that - a sortable list of ImageTag entries
+type SortableImageTagList []*ImageTag
+
+// Len returns the length of an SortableImageList
+func (il SortableImageTagList) Len() int {
+ return len(il)
+}
+
+// Swap swaps two entries in the SortableImageList
+func (il SortableImageTagList) Swap(i, j int) {
+ il[i], il[j] = il[j], il[i]
+}
+
+// NewImageTag initializes an ImageTag object and returns it
+func NewImageTag(tagName string, tagDate time.Time, tagDigest string) *ImageTag {
+ tag := &ImageTag{}
+ tag.TagName = tagName
+ tag.TagDate = &tagDate
+ tag.TagDigest = tagDigest
+ return tag
+}
+
+// NewImageTagList initializes an ImageTagList object and returns it
+func NewImageTagList() *ImageTagList {
+ itl := ImageTagList{}
+ itl.items = make(map[string]*ImageTag)
+ itl.lock = &sync.RWMutex{}
+ return &itl
+}
+
+// Tags returns a list of verbatim tag names as string slice
+func (il *ImageTagList) Tags() []string {
+ il.lock.RLock()
+ defer il.lock.RUnlock()
+ tagList := []string{}
+ for k := range il.items {
+ tagList = append(tagList, k)
+ }
+ return tagList
+}
+
+// Tags returns a list of verbatim tag names as string slice
+func (sil *SortableImageTagList) Tags() []string {
+ tagList := []string{}
+ for _, t := range *sil {
+ tagList = append(tagList, t.TagName)
+ }
+ return tagList
+}
+
+// String returns the tag name of the ImageTag, possibly with a digest appended
+// to its name.
+func (tag *ImageTag) String() string {
+ if tag.TagDigest != "" {
+ return tag.TagDigest
+ } else {
+ return tag.TagName
+ }
+}
+
+// IsDigest returns true if the tag has a digest
+func (tag *ImageTag) IsDigest() bool {
+ return tag.TagDigest != ""
+}
+
+// Equals checks whether two tags are equal. Will consider any digest set for
+// the tag with precedence, otherwise uses a tag's name.
+func (tag *ImageTag) Equals(aTag *ImageTag) bool {
+ if tag.IsDigest() {
+ return tag.TagDigest == aTag.TagDigest
+ } else {
+ return tag.TagName == aTag.TagName
+ }
+}
+
+// Checks whether given tag is contained in tag list in O(n) time
+func (il ImageTagList) Contains(tag *ImageTag) bool {
+ il.lock.RLock()
+ defer il.lock.RUnlock()
+ return il.unlockedContains(tag)
+}
+
+// Add adds an ImageTag to an ImageTagList, ensuring this will not result in
+// an double entry
+func (il ImageTagList) Add(tag *ImageTag) {
+ il.lock.Lock()
+ defer il.lock.Unlock()
+ il.items[tag.TagName] = tag
+}
+
+// SortByName returns an array of ImageTag objects, sorted by the tag's name
+func (il ImageTagList) SortAlphabetically() SortableImageTagList {
+ sil := make(SortableImageTagList, 0, len(il.items))
+ for _, v := range il.items {
+ sil = append(sil, v)
+ }
+ sort.Slice(sil, func(i, j int) bool {
+ return sil[i].TagName < sil[j].TagName
+ })
+ return sil
+}
+
+// SortByDate returns a SortableImageTagList, sorted by the tag's date
+func (il ImageTagList) SortByDate() SortableImageTagList {
+ sil := make(SortableImageTagList, 0, len(il.items))
+ for _, v := range il.items {
+ sil = append(sil, v)
+ }
+ sort.Slice(sil, func(i, j int) bool {
+ if sil[i].TagDate.Equal(*sil[j].TagDate) {
+ // if an image has two tags, return the same consistently
+ return sil[i].TagName < sil[j].TagName
+ }
+ return sil[i].TagDate.Before(*sil[j].TagDate)
+ })
+ return sil
+}
+
+func (il ImageTagList) SortBySemVer() SortableImageTagList {
+ // We need a read lock, because we access the items hash after sorting
+ il.lock.RLock()
+ defer il.lock.RUnlock()
+
+ sil := SortableImageTagList{}
+ svl := make([]*semver.Version, 0)
+ for _, v := range il.items {
+ svi, err := semver.NewVersion(v.TagName)
+ if err != nil {
+ log.Debugf("could not parse input tag %s as semver: %v", v.TagName, err)
+ continue
+ }
+ svl = append(svl, svi)
+ }
+ sort.Sort(semverCollection(svl))
+ for _, svi := range svl {
+ sil = append(sil, NewImageTag(svi.Original(), *il.items[svi.Original()].TagDate, il.items[svi.Original()].TagDigest))
+ }
+ return sil
+}
+
+// Should only be used in a method that holds a lock on the ImageTagList
+func (il ImageTagList) unlockedContains(tag *ImageTag) bool {
+ if _, ok := il.items[tag.TagName]; ok {
+ return true
+ }
+ return false
+}
+
+func (ti *TagInfo) EncodedDigest() string {
+ return "sha256:" + hex.EncodeToString(ti.Digest[:])
+}
diff --git a/registry-scanner/pkg/tag/tag_test.go b/registry-scanner/pkg/tag/tag_test.go
new file mode 100644
index 0000000..23beb6d
--- /dev/null
+++ b/registry-scanner/pkg/tag/tag_test.go
@@ -0,0 +1,184 @@
+package tag
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_NewImageTag(t *testing.T) {
+ t.Run("New image tag from valid Time type", func(t *testing.T) {
+ tagDate := time.Now()
+ tag := NewImageTag("v1.0.0", tagDate, "")
+ require.NotNil(t, tag)
+ assert.Equal(t, "v1.0.0", tag.TagName)
+ assert.Equal(t, &tagDate, tag.TagDate)
+ })
+}
+
+func Test_ImageTagEqual(t *testing.T) {
+ t.Run("Versions are similar", func(t *testing.T) {
+ tag1 := NewImageTag("v1.0.0", time.Now(), "")
+ tag2 := NewImageTag("v1.0.0", time.Now(), "")
+ assert.True(t, tag1.Equals(tag2))
+ })
+
+ t.Run("Digests are similar but version is not", func(t *testing.T) {
+ tag1 := NewImageTag("v1.0.0", time.Now(), "abcdef")
+ tag2 := NewImageTag("v1.0.1", time.Now(), "abcdef")
+ assert.True(t, tag1.Equals(tag2))
+ })
+
+ t.Run("Digests and versions are similar", func(t *testing.T) {
+ tag1 := NewImageTag("v1.0.0", time.Now(), "abcdef")
+ tag2 := NewImageTag("v1.0.0", time.Now(), "abcdef")
+ assert.True(t, tag1.Equals(tag2))
+ })
+
+ t.Run("Versions are not similar", func(t *testing.T) {
+ tag1 := NewImageTag("v1.0.0", time.Now(), "")
+ tag2 := NewImageTag("v1.0.1", time.Now(), "")
+ assert.False(t, tag1.Equals(tag2))
+ })
+
+ t.Run("Versions are not similar because digest is different", func(t *testing.T) {
+ tag1 := NewImageTag("v1.0.0", time.Now(), "abc")
+ tag2 := NewImageTag("v1.0.0", time.Now(), "def")
+ assert.False(t, tag1.Equals(tag2))
+ })
+
+ t.Run("Versions are not similar because digest is missing", func(t *testing.T) {
+ tag1 := NewImageTag("v1.0.0", time.Now(), "abc")
+ tag2 := NewImageTag("v1.0.0", time.Now(), "")
+ assert.False(t, tag1.Equals(tag2))
+ })
+
+}
+
+func Test_AppendToImageTagList(t *testing.T) {
+ t.Run("Append single entry to ImageTagList", func(t *testing.T) {
+ il := NewImageTagList()
+ tag := NewImageTag("v1.0.0", time.Now(), "")
+ il.Add(tag)
+ assert.Len(t, il.items, 1)
+ assert.True(t, il.Contains(tag))
+ })
+
+ t.Run("Append two same entries to ImageTagList", func(t *testing.T) {
+ il := NewImageTagList()
+ tag := NewImageTag("v1.0.0", time.Now(), "")
+ il.Add(tag)
+ tag = NewImageTag("v1.0.0", time.Now(), "")
+ il.Add(tag)
+ assert.True(t, il.Contains(tag))
+ assert.Len(t, il.items, 1)
+ })
+
+ t.Run("Append two distinct entries to ImageTagList", func(t *testing.T) {
+ il := NewImageTagList()
+ tag1 := NewImageTag("v1.0.0", time.Now(), "")
+ il.Add(tag1)
+ tag2 := NewImageTag("v1.0.1", time.Now(), "")
+ il.Add(tag2)
+ assert.True(t, il.Contains(tag1))
+ assert.True(t, il.Contains(tag2))
+ assert.Len(t, il.items, 2)
+ })
+}
+
+func Test_SortableImageTagList(t *testing.T) {
+ t.Run("Sort by name", func(t *testing.T) {
+ names := []string{"wohoo", "bazar", "alpha", "jesus", "zebra"}
+ il := NewImageTagList()
+ for _, name := range names {
+ tag := NewImageTag(name, time.Now(), "")
+ il.Add(tag)
+ }
+ sil := il.SortAlphabetically()
+ require.Len(t, sil, len(names))
+ assert.Equal(t, "alpha", sil[0].TagName)
+ assert.Equal(t, "bazar", sil[1].TagName)
+ assert.Equal(t, "jesus", sil[2].TagName)
+ assert.Equal(t, "wohoo", sil[3].TagName)
+ assert.Equal(t, "zebra", sil[4].TagName)
+ })
+
+ t.Run("Sort by semver", func(t *testing.T) {
+ names := []string{"v2.0.2", "v1.0", "v2.0.0", "v1.0.1", "v2.0.3", "v2.0"}
+ il := NewImageTagList()
+ for _, name := range names {
+ tag := NewImageTag(name, time.Now(), "")
+ il.Add(tag)
+ }
+ sil := il.SortBySemVer()
+ require.Len(t, sil, len(names))
+ assert.Equal(t, "v1.0", sil[0].TagName)
+ assert.Equal(t, "v1.0.1", sil[1].TagName)
+ assert.Equal(t, "v2.0", sil[2].TagName)
+ assert.Equal(t, "v2.0.0", sil[3].TagName)
+ assert.Equal(t, "v2.0.2", sil[4].TagName)
+ assert.Equal(t, "v2.0.3", sil[5].TagName)
+ })
+
+ t.Run("Sort by date", func(t *testing.T) {
+ names := []string{"v2.0.2", "v1.0", "v1.0.1", "v2.0.3", "v2.0"}
+ dates := []int64{4, 1, 0, 3, 2}
+ il := NewImageTagList()
+ for i, name := range names {
+ tag := NewImageTag(name, time.Unix(dates[i], 0), "")
+ il.Add(tag)
+ }
+ sil := il.SortByDate()
+ require.Len(t, sil, len(names))
+ assert.Equal(t, "v1.0.1", sil[0].TagName)
+ assert.Equal(t, "v1.0", sil[1].TagName)
+ assert.Equal(t, "v2.0", sil[2].TagName)
+ assert.Equal(t, "v2.0.3", sil[3].TagName)
+ assert.Equal(t, "v2.0.2", sil[4].TagName)
+ })
+
+ t.Run("Sort by date with same dates", func(t *testing.T) {
+ names := []string{"v2.0.2", "v1.0", "v1.0.1", "v2.0.3", "v2.0"}
+ date := time.Unix(0, 0)
+ il := NewImageTagList()
+ for _, name := range names {
+ tag := NewImageTag(name, date, "")
+ il.Add(tag)
+ }
+ sil := il.SortByDate()
+ require.Len(t, sil, len(names))
+ assert.Equal(t, "v1.0", sil[0].TagName)
+ assert.Equal(t, "v1.0.1", sil[1].TagName)
+ assert.Equal(t, "v2.0", sil[2].TagName)
+ assert.Equal(t, "v2.0.2", sil[3].TagName)
+ assert.Equal(t, "v2.0.3", sil[4].TagName)
+ })
+}
+
+func Test_TagsFromTagList(t *testing.T) {
+ t.Run("Get list of tags from ImageTagList", func(t *testing.T) {
+ names := []string{"wohoo", "bazar", "alpha", "jesus", "zebra"}
+ il := NewImageTagList()
+ for _, name := range names {
+ tag := NewImageTag(name, time.Now(), "")
+ il.Add(tag)
+ }
+ tl := il.Tags()
+ assert.NotEmpty(t, tl)
+ assert.Len(t, tl, len(names))
+ })
+
+ t.Run("Get list of tags from SortableImageTagList", func(t *testing.T) {
+ names := []string{"wohoo", "bazar", "alpha", "jesus", "zebra"}
+ sil := SortableImageTagList{}
+ for _, name := range names {
+ tag := NewImageTag(name, time.Now(), "")
+ sil = append(sil, tag)
+ }
+ tl := sil.Tags()
+ assert.NotEmpty(t, tl)
+ assert.Len(t, tl, len(names))
+ })
+}
diff --git a/registry-scanner/test/fixture/capture.go b/registry-scanner/test/fixture/capture.go
new file mode 100644
index 0000000..cf09ed3
--- /dev/null
+++ b/registry-scanner/test/fixture/capture.go
@@ -0,0 +1,55 @@
+package fixture
+
+import (
+ "io"
+ "os"
+)
+
+func CaptureStdout(callback func()) (string, error) {
+ oldStdout := os.Stdout
+ oldStderr := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ return "", err
+ }
+ os.Stdout = w
+ defer func() {
+ os.Stdout = oldStdout
+ os.Stderr = oldStderr
+ }()
+
+ callback()
+
+ w.Close()
+
+ data, err := io.ReadAll(r)
+
+ if err != nil {
+ return "", err
+ }
+ return string(data), err
+}
+
+func CaptureStderr(callback func()) (string, error) {
+ oldStdout := os.Stdout
+ oldStderr := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ return "", err
+ }
+ os.Stderr = w
+ defer func() {
+ os.Stdout = oldStdout
+ os.Stderr = oldStderr
+ }()
+
+ callback()
+ w.Close()
+
+ data, err := io.ReadAll(r)
+
+ if err != nil {
+ return "", err
+ }
+ return string(data), err
+}