diff options
| author | Ishita Sequeira <46771830+ishitasequeira@users.noreply.github.com> | 2024-10-31 09:38:03 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-31 09:38:03 -0400 |
| commit | e09b17d64e5ac8790f47dc0f8782bbc877cca16d (patch) | |
| tree | c4521b381ddf3878c899ceabea8f60af30a7b9b7 | |
| parent | a5acd25853dd19a5186c1cc3801bf9e5cfa052dc (diff) | |
refactor: Add additional packages to registry-scanner (#900)
Signed-off-by: Ishita Sequeira <ishiseq29@gmail.com>
| -rw-r--r-- | registry-scanner/go.mod | 3 | ||||
| -rw-r--r-- | registry-scanner/go.sum | 2 | ||||
| -rw-r--r-- | registry-scanner/pkg/health/health.go | 25 | ||||
| -rw-r--r-- | registry-scanner/pkg/health/health_test.go | 52 | ||||
| -rw-r--r-- | registry-scanner/pkg/log/log.go | 183 | ||||
| -rw-r--r-- | registry-scanner/pkg/log/log_test.go | 156 | ||||
| -rw-r--r-- | registry-scanner/pkg/options/options.go | 107 | ||||
| -rw-r--r-- | registry-scanner/pkg/options/options_test.go | 100 | ||||
| -rw-r--r-- | registry-scanner/pkg/tag/semver.go | 31 | ||||
| -rw-r--r-- | registry-scanner/pkg/tag/tag.go | 185 | ||||
| -rw-r--r-- | registry-scanner/pkg/tag/tag_test.go | 184 | ||||
| -rw-r--r-- | registry-scanner/test/fixture/capture.go | 55 |
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 +} |
