diff --git a/internal/app/diun.go b/internal/app/diun.go index 0ea50f91..9a035d66 100644 --- a/internal/app/diun.go +++ b/internal/app/diun.go @@ -153,32 +153,32 @@ func (di *Diun) Run() { defer di.pool.Release() // Docker provider - for _, job := range dockerPrd.New(di.cfg.Providers.Docker).ListJob() { + for _, job := range dockerPrd.New(di.cfg.Providers.Docker, di.cfg.Watch.ImageDefaults).ListJob() { di.createJob(job) } // Swarm provider - for _, job := range swarmPrd.New(di.cfg.Providers.Swarm).ListJob() { + for _, job := range swarmPrd.New(di.cfg.Providers.Swarm, di.cfg.Watch.ImageDefaults).ListJob() { di.createJob(job) } // Kubernetes provider - for _, job := range kubernetesPrd.New(di.cfg.Providers.Kubernetes).ListJob() { + for _, job := range kubernetesPrd.New(di.cfg.Providers.Kubernetes, di.cfg.Watch.ImageDefaults).ListJob() { di.createJob(job) } // File provider - for _, job := range filePrd.New(di.cfg.Providers.File).ListJob() { + for _, job := range filePrd.New(di.cfg.Providers.File, di.cfg.Watch.ImageDefaults).ListJob() { di.createJob(job) } // Dockerfile provider - for _, job := range dockerfilePrd.New(di.cfg.Providers.Dockerfile).ListJob() { + for _, job := range dockerfilePrd.New(di.cfg.Providers.Dockerfile, di.cfg.Watch.ImageDefaults).ListJob() { di.createJob(job) } // Nomad provider - for _, job := range nomadPrd.New(di.cfg.Providers.Nomad).ListJob() { + for _, job := range nomadPrd.New(di.cfg.Providers.Nomad, di.cfg.Watch.ImageDefaults).ListJob() { di.createJob(job) } diff --git a/internal/app/job.go b/internal/app/job.go index 0f1f7c11..956a9b03 100644 --- a/internal/app/job.go +++ b/internal/app/job.go @@ -63,7 +63,7 @@ func (di *Diun) createJob(job model.Job) { // Set defaults if err := mergo.Merge(&job.Image, model.Image{ Platform: model.ImagePlatform{}, - WatchRepo: false, + WatchRepo: utl.NewFalse(), MaxTags: 0, }); err != nil { sublog.Error().Err(err).Msg("Cannot set default values") @@ -118,7 +118,7 @@ func (di *Diun) createJob(job model.Job) { sublog.Error().Err(err).Msgf("Invoking job") } - if !job.Image.WatchRepo || len(job.RegImage.Domain) == 0 { + if job.Image.WatchRepo == nil || !*job.Image.WatchRepo || len(job.RegImage.Domain) == 0 { return } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1a092f49..46ac17c8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,6 +7,7 @@ import ( "github.com/crazy-max/diun/v4/internal/config" "github.com/crazy-max/diun/v4/internal/model" + "github.com/crazy-max/diun/v4/pkg/registry" "github.com/crazy-max/diun/v4/pkg/utl" "github.com/crazy-max/gonfig/env" "github.com/stretchr/testify/assert" @@ -58,6 +59,10 @@ func TestLoadFile(t *testing.T) { BaseURL: "https://hc-ping.com/", UUID: "5bf66975-d4c7-4bf5-bcc8-b8d8a82ea278", }, + ImageDefaults: &model.Image{ + NotifyOn: model.NotifyOnDefaults, + SortTags: registry.SortTagReverse, + }, }, Notif: &model.Notif{ Amqp: &model.NotifAmqp{ diff --git a/internal/model/image.go b/internal/model/image.go index 59d44aac..fe712df5 100644 --- a/internal/model/image.go +++ b/internal/model/image.go @@ -1,13 +1,15 @@ package model -import "github.com/crazy-max/diun/v4/pkg/registry" +import ( + "github.com/crazy-max/diun/v4/pkg/registry" +) // Image holds image configuration type Image struct { Name string `yaml:"name,omitempty" json:",omitempty"` Platform ImagePlatform `yaml:"platform,omitempty" json:",omitempty"` RegOpt string `yaml:"regopt,omitempty" json:",omitempty"` - WatchRepo bool `yaml:"watch_repo,omitempty" json:",omitempty"` + WatchRepo *bool `yaml:"watch_repo,omitempty" json:",omitempty"` NotifyOn []NotifyOn `yaml:"notify_on,omitempty" json:",omitempty"` MaxTags int `yaml:"max_tags,omitempty" json:",omitempty"` SortTags registry.SortTag `yaml:"sort_tags,omitempty" json:",omitempty"` diff --git a/internal/model/watch.go b/internal/model/watch.go index f9239519..13f192be 100644 --- a/internal/model/watch.go +++ b/internal/model/watch.go @@ -3,6 +3,7 @@ package model import ( "time" + "github.com/crazy-max/diun/v4/pkg/registry" "github.com/crazy-max/diun/v4/pkg/utl" ) @@ -15,6 +16,7 @@ type Watch struct { RunOnStartup *bool `yaml:"runOnStartup,omitempty" json:"runOnStartup,omitempty" validate:"required"` CompareDigest *bool `yaml:"compareDigest,omitempty" json:"compareDigest,omitempty" validate:"required"` Healthchecks *Healthchecks `yaml:"healthchecks,omitempty" json:"healthchecks,omitempty"` + ImageDefaults *Image `yaml:"defaults,omitempty" json:"defaults,omitempty"` } // GetDefaults gets the default values @@ -31,4 +33,8 @@ func (s *Watch) SetDefaults() { s.FirstCheckNotif = utl.NewFalse() s.RunOnStartup = utl.NewTrue() s.CompareDigest = utl.NewTrue() + s.ImageDefaults = &Image{ + NotifyOn: NotifyOnDefaults, + SortTags: registry.SortTagReverse, + } } diff --git a/internal/provider/common.go b/internal/provider/common.go index cdcc5131..97cd5939 100644 --- a/internal/provider/common.go +++ b/internal/provider/common.go @@ -1,6 +1,7 @@ package provider import ( + "errors" "fmt" "regexp" "strconv" @@ -9,30 +10,33 @@ import ( "github.com/containerd/containerd/platforms" "github.com/crazy-max/diun/v4/internal/model" "github.com/crazy-max/diun/v4/pkg/registry" - "github.com/pkg/errors" + "github.com/imdario/mergo" ) var ( metadataKeyChars = `a-zA-Z0-9_` metadataKeyRegexp = regexp.MustCompile(`^[` + metadataKeyChars + `]+$`) + ErrInvalidLabel = errors.New("invalid label error") ) // ValidateImage returns a standard image through Docker labels -func ValidateImage(image string, metadata, labels map[string]string, watchByDef bool) (img model.Image, err error) { +func ValidateImage(image string, metadata, labels map[string]string, watchByDef bool, imageDefaults model.Image) (img model.Image, err error) { if i := strings.Index(image, "@sha256:"); i > 0 { image = image[:i] } + img = model.Image{ - Name: image, - Metadata: metadata, - NotifyOn: model.NotifyOnDefaults, - SortTags: registry.SortTagReverse, + Name: image, + } + + if err := mergo.Merge(&img, imageDefaults); err != nil { + return img, fmt.Errorf("failed to merge image defaults for image %s", image) } if enableStr, ok := labels["diun.enable"]; ok { enable, err := strconv.ParseBool(enableStr) if err != nil { - return img, fmt.Errorf("cannot parse %s value of label diun.enable", enableStr) + return img, fmt.Errorf("cannot parse %q value of label diun.enable: %w", enableStr, ErrInvalidLabel) } if !enable { return model.Image{}, nil @@ -46,8 +50,10 @@ func ValidateImage(image string, metadata, labels map[string]string, watchByDef case key == "diun.regopt": img.RegOpt = value case key == "diun.watch_repo": - if img.WatchRepo, err = strconv.ParseBool(value); err != nil { - return img, fmt.Errorf("cannot parse %s value of label %s", value, key) + if watchRepo, err := strconv.ParseBool(value); err == nil { + img.WatchRepo = &watchRepo + } else { + return img, fmt.Errorf("cannot parse %q value of label %s: %w", value, key, ErrInvalidLabel) } case key == "diun.notify_on": if len(value) == 0 { @@ -57,7 +63,7 @@ func ValidateImage(image string, metadata, labels map[string]string, watchByDef for _, no := range strings.Split(value, ";") { notifyOn := model.NotifyOn(no) if !notifyOn.Valid() { - return img, fmt.Errorf("unknown notify status %q", value) + return img, fmt.Errorf("unknown notify status %q: %w", value, ErrInvalidLabel) } img.NotifyOn = append(img.NotifyOn, notifyOn) } @@ -67,12 +73,12 @@ func ValidateImage(image string, metadata, labels map[string]string, watchByDef } sortTags := registry.SortTag(value) if !sortTags.Valid() { - return img, fmt.Errorf("unknown sort tags type %q", value) + return img, fmt.Errorf("unknown sort tags type %q: %w", value, ErrInvalidLabel) } img.SortTags = sortTags case key == "diun.max_tags": if img.MaxTags, err = strconv.Atoi(value); err != nil { - return img, fmt.Errorf("cannot parse %s value of label %s", value, key) + return img, fmt.Errorf("cannot parse %q value of label %s: %w", value, key, ErrInvalidLabel) } case key == "diun.include_tags": img.IncludeTags = strings.Split(value, ";") @@ -85,7 +91,7 @@ func ValidateImage(image string, metadata, labels map[string]string, watchByDef case key == "diun.platform": platform, err := platforms.Parse(value) if err != nil { - return img, fmt.Errorf("cannot parse %s platform of label %s", value, key) + return img, fmt.Errorf("cannot parse %q platform of label %s: %w", value, key, ErrInvalidLabel) } img.Platform = model.ImagePlatform{ OS: platform.OS, @@ -98,7 +104,7 @@ func ValidateImage(image string, metadata, labels map[string]string, watchByDef break } if err := validateMetadataKey(mkey); err != nil { - return img, errors.Wrapf(err, "invalid metadata key %q", mkey) + return img, fmt.Errorf("invalid metadata key %q: %w: %w", mkey, err, ErrInvalidLabel) } if img.Metadata == nil { img.Metadata = map[string]string{} @@ -107,12 +113,17 @@ func ValidateImage(image string, metadata, labels map[string]string, watchByDef } } + // Update provider metadata with metadata from img labels + if err := mergo.Merge(&img.Metadata, metadata); err != nil { + return img, fmt.Errorf("failed merging metadata: %w", err) + } + return img, nil } func validateMetadataKey(key string) error { if !metadataKeyRegexp.MatchString(key) { - return errors.Errorf("only %q are allowed", metadataKeyChars) + return fmt.Errorf("only %q are allowed", metadataKeyChars) } return nil } diff --git a/internal/provider/common_test.go b/internal/provider/common_test.go new file mode 100644 index 00000000..88822a7a --- /dev/null +++ b/internal/provider/common_test.go @@ -0,0 +1,882 @@ +package provider_test + +import ( + "testing" + + "github.com/crazy-max/diun/v4/internal/model" + "github.com/crazy-max/diun/v4/internal/provider" + "github.com/crazy-max/diun/v4/pkg/registry" + "github.com/crazy-max/diun/v4/pkg/utl" + "github.com/stretchr/testify/assert" +) + +func TestValidateImage(t *testing.T) { + cases := []struct { + name string + image string + metadata map[string]string + labels map[string]string + watchByDef bool + imageDefaults model.Image + expectedImage model.Image + expectedErr error + }{ + // Test strip sha + { + name: "Test strip sha", + image: "myimg@sha256:1234567890abcdef", + watchByDef: true, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: nil, + }, + // Test enable and watch by default + { + name: "All excluded by default", + image: "myimg", + expectedImage: model.Image{}, + expectedErr: nil, + }, + { + name: "Include using watch by default", + image: "myimg", + watchByDef: true, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: nil, + }, + { + name: "Include using diun.enable", + image: "myimg", + watchByDef: false, + labels: map[string]string{ + "diun.enable": "true", + }, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: nil, + }, + { + name: "Exclude using diun.enable", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.enable": "false", + }, + expectedImage: model.Image{}, + expectedErr: nil, + }, + { + name: "Invlaid diun.enable", + image: "myimg", + watchByDef: false, + labels: map[string]string{ + "diun.enable": "chickens", + }, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: provider.ErrInvalidLabel, + }, + // Test diun.regopt + { + name: "Set regopt", + image: "myimg", + labels: map[string]string{ + "diun.regopt": "foo", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + RegOpt: "foo", + }, + expectedErr: nil, + }, + { + name: "Set empty recopt", + image: "myimg", + labels: map[string]string{ + "diun.regopt": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + RegOpt: "", + }, + expectedErr: nil, + }, + { + name: "Default regopt", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + RegOpt: "foo", + }, + expectedImage: model.Image{ + Name: "myimg", + RegOpt: "foo", + }, + expectedErr: nil, + }, + { + name: "Override default regopt", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.regopt": "bar", + }, + imageDefaults: model.Image{ + RegOpt: "foo", + }, + expectedImage: model.Image{ + Name: "myimg", + RegOpt: "bar", + }, + expectedErr: nil, + }, + // Test watch_repo + { + name: "Include using global settings", + image: "myimg", + watchByDef: true, + imageDefaults: model.Image{ + WatchRepo: utl.NewTrue(), + }, + expectedImage: model.Image{ + Name: "myimg", + WatchRepo: utl.NewTrue(), + }, + expectedErr: nil, + }, + { + name: "Invalid watch_repo", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.watch_repo": "chickens", + }, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Override default image values with labels (true > false)", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.watch_repo": "false", + }, + imageDefaults: model.Image{ + WatchRepo: utl.NewTrue(), + }, + expectedImage: model.Image{ + Name: "myimg", + WatchRepo: utl.NewFalse(), + }, + expectedErr: nil, + }, + { + name: "Override default image values with labels (false > true): invalid label error", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.watch_repo": "true", + }, + imageDefaults: model.Image{ + WatchRepo: utl.NewFalse(), + }, + expectedImage: model.Image{ + Name: "myimg", + WatchRepo: utl.NewTrue(), + }, + expectedErr: nil, + }, + // Test diun.notify_on + { + name: "Set valid notify_on", + image: "myimg", + labels: map[string]string{ + "diun.notify_on": "new", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + NotifyOn: []model.NotifyOn{model.NotifyOnNew}, + }, + expectedErr: nil, + }, + { + name: "Set invalid notify_on", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.notify_on": "chickens", + }, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + NotifyOn: []model.NotifyOn{}, + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Set empty notify_on", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.notify_on": "", + }, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: nil, + }, + { + name: "Default notify_on", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + NotifyOn: []model.NotifyOn{model.NotifyOnNew}, + }, + expectedImage: model.Image{ + Name: "myimg", + NotifyOn: []model.NotifyOn{model.NotifyOnNew}, + }, + expectedErr: nil, + }, + { + name: "Override default notify_on", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.notify_on": "update", + }, + imageDefaults: model.Image{ + NotifyOn: []model.NotifyOn{model.NotifyOnNew}, + }, + expectedImage: model.Image{ + Name: "myimg", + NotifyOn: []model.NotifyOn{model.NotifyOnUpdate}, + }, + expectedErr: nil, + }, + // Test diun.sort_tags + { + name: "Set valid sort_tags", + image: "myimg", + labels: map[string]string{ + "diun.sort_tags": "semver", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + SortTags: registry.SortTagSemver, + }, + expectedErr: nil, + }, + { + name: "Set invalid sort_tags", + image: "myimg", + labels: map[string]string{ + "diun.sort_tags": "chickens", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Set empty sort_tags", + image: "myimg", + labels: map[string]string{ + "diun.sort_tags": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: nil, + }, + { + name: "Default sort_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + SortTags: registry.SortTagSemver, + }, + expectedImage: model.Image{ + Name: "myimg", + SortTags: registry.SortTagSemver, + }, + expectedErr: nil, + }, + { + name: "Override default sort_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.sort_tags": "reverse", + }, + imageDefaults: model.Image{ + SortTags: registry.SortTagSemver, + }, + expectedImage: model.Image{ + Name: "myimg", + SortTags: registry.SortTagReverse, + }, + expectedErr: nil, + }, + // Test diun.max_tags + { + name: "Set valid max_tags", + image: "myimg", + labels: map[string]string{ + "diun.max_tags": "10", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + MaxTags: 10, + }, + expectedErr: nil, + }, + { + name: "Set invalid max_tags", + image: "myimg", + labels: map[string]string{ + "diun.max_tags": "chickens", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Set empty max_tags", + image: "myimg", + labels: map[string]string{ + "diun.max_tags": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Default max_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + MaxTags: 10, + }, + expectedImage: model.Image{ + Name: "myimg", + MaxTags: 10, + }, + expectedErr: nil, + }, + { + name: "Override default max_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.max_tags": "11", + }, + imageDefaults: model.Image{ + MaxTags: 10, + }, + expectedImage: model.Image{ + Name: "myimg", + MaxTags: 11, + }, + expectedErr: nil, + }, + // Test diun.include_tags + { + name: "Set include_tags", + image: "myimg", + labels: map[string]string{ + "diun.include_tags": "alpine;ubuntu", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + IncludeTags: []string{"alpine", "ubuntu"}, + }, + expectedErr: nil, + }, + { + name: "Set empty include_tags", + image: "myimg", + labels: map[string]string{ + "diun.include_tags": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + IncludeTags: []string{""}, + }, + expectedErr: nil, + }, + { + name: "Default include_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + IncludeTags: []string{"alpine"}, + }, + expectedImage: model.Image{ + Name: "myimg", + IncludeTags: []string{"alpine"}, + }, + expectedErr: nil, + }, + { + name: "Override default include_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.include_tags": "ubuntu", + }, + imageDefaults: model.Image{ + IncludeTags: []string{"alpine"}, + }, + expectedImage: model.Image{ + Name: "myimg", + IncludeTags: []string{"ubuntu"}, + }, + expectedErr: nil, + }, + // Test diun.exclude_tags + { + name: "Set exclude_tags", + image: "myimg", + labels: map[string]string{ + "diun.exclude_tags": "alpine;ubuntu", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + ExcludeTags: []string{"alpine", "ubuntu"}, + }, + expectedErr: nil, + }, + { + name: "Set empty exclude_tags", + image: "myimg", + labels: map[string]string{ + "diun.exclude_tags": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + ExcludeTags: []string{""}, + }, + expectedErr: nil, + }, + { + name: "Default exclude_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + ExcludeTags: []string{"alpine"}, + }, + expectedImage: model.Image{ + Name: "myimg", + ExcludeTags: []string{"alpine"}, + }, + expectedErr: nil, + }, + { + name: "Override default exclude_tags", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.exclude_tags": "ubuntu", + }, + imageDefaults: model.Image{ + ExcludeTags: []string{"alpine"}, + }, + expectedImage: model.Image{ + Name: "myimg", + ExcludeTags: []string{"ubuntu"}, + }, + expectedErr: nil, + }, + // Test diun.hub_tpl + { + name: "Set hub_tpl", + image: "myimg", + labels: map[string]string{ + "diun.hub_tpl": "foo", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + HubTpl: "foo", + }, + expectedErr: nil, + }, + { + name: "Set empty hub_tpl", + image: "myimg", + labels: map[string]string{ + "diun.hub_tpl": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + HubTpl: "", + }, + expectedErr: nil, + }, + { + name: "Default hub_tpl", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + HubTpl: "foo", + }, + expectedImage: model.Image{ + Name: "myimg", + HubTpl: "foo", + }, + expectedErr: nil, + }, + { + name: "Override default hub_tpl", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.hub_tpl": "bar", + }, + imageDefaults: model.Image{ + HubTpl: "foo", + }, + expectedImage: model.Image{ + Name: "myimg", + HubTpl: "bar", + }, + expectedErr: nil, + }, + // Test diun.hub_link + { + name: "Set hub_link", + image: "myimg", + labels: map[string]string{ + "diun.hub_link": "foo", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + HubLink: "foo", + }, + expectedErr: nil, + }, + { + name: "Set empty hub_link", + image: "myimg", + labels: map[string]string{ + "diun.hub_link": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + HubLink: "", + }, + expectedErr: nil, + }, + { + name: "Default hub_link", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + HubLink: "foo", + }, + expectedImage: model.Image{ + Name: "myimg", + HubLink: "foo", + }, + expectedErr: nil, + }, + { + name: "Override default hub_link", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.hub_link": "bar", + }, + imageDefaults: model.Image{ + HubLink: "foo", + }, + expectedImage: model.Image{ + Name: "myimg", + HubLink: "bar", + }, + expectedErr: nil, + }, + // Test diun.platform + { + name: "Set valid platform", + image: "myimg", + labels: map[string]string{ + "diun.platform": "linux/arm/v7", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + Platform: model.ImagePlatform{ + OS: "linux", + Arch: "arm", + Variant: "v7", + }, + }, + expectedErr: nil, + }, + { + name: "Set invalid platform", + image: "myimg", + labels: map[string]string{ + "diun.platform": "chickens", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Set empty platform", + image: "myimg", + labels: map[string]string{ + "diun.platform": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + Platform: model.ImagePlatform{}, + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Default platform", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + Platform: model.ImagePlatform{ + OS: "linux", + Arch: "arm", + Variant: "v7", + }, + }, + expectedImage: model.Image{ + Name: "myimg", + Platform: model.ImagePlatform{ + OS: "linux", + Arch: "arm", + Variant: "v7", + }, + }, + expectedErr: nil, + }, + { + name: "Override default platform", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.platform": "linux/arm/v6", + }, + imageDefaults: model.Image{ + Platform: model.ImagePlatform{ + OS: "linux", + Arch: "arm", + Variant: "v7", + }, + }, + expectedImage: model.Image{ + Name: "myimg", + Platform: model.ImagePlatform{ + OS: "linux", + Arch: "arm", + Variant: "v6", + }, + }, + expectedErr: nil, + }, + // Test diun.metadata + { + name: "Set valid metadata", + image: "myimg", + labels: map[string]string{ + "diun.metadata.foo123": "bar", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + Metadata: map[string]string{ + "foo123": "bar", + }, + }, + expectedErr: nil, + }, + { + name: "Set invalid metadata", + image: "myimg", + labels: map[string]string{ + "diun.metadata.lots of chickens": "bar", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + expectedErr: provider.ErrInvalidLabel, + }, + { + name: "Set empty metadata key", + image: "myimg", + labels: map[string]string{ + "diun.metadata.": "bar", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + }, + { + name: "Set empty metadata value", + image: "myimg", + labels: map[string]string{ + "diun.metadata.foo123": "", + }, + watchByDef: true, + imageDefaults: model.Image{}, + expectedImage: model.Image{ + Name: "myimg", + }, + }, + { + name: "Default metadata", + image: "myimg", + watchByDef: true, + labels: map[string]string{}, + imageDefaults: model.Image{ + Metadata: map[string]string{ + "foo123": "bar", + }, + }, + expectedImage: model.Image{ + Name: "myimg", + Metadata: map[string]string{ + "foo123": "bar", + }, + }, + expectedErr: nil, + }, + { + name: "Merge default metadata", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.metadata.biz123": "baz", + }, + imageDefaults: model.Image{ + Metadata: map[string]string{ + "foo123": "bar", + }, + }, + expectedImage: model.Image{ + Name: "myimg", + Metadata: map[string]string{ + "foo123": "bar", + "biz123": "baz", + }, + }, + expectedErr: nil, + }, + { + name: "Override default metadata", + image: "myimg", + watchByDef: true, + labels: map[string]string{ + "diun.metadata.foo123": "baz", + }, + imageDefaults: model.Image{ + Metadata: map[string]string{ + "foo123": "bar", + }, + }, + expectedImage: model.Image{ + Name: "myimg", + Metadata: map[string]string{ + "foo123": "baz", + }, + }, + expectedErr: nil, + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + actualImg, actualErr := provider.ValidateImage( + c.image, + c.metadata, + c.labels, + c.watchByDef, + c.imageDefaults, + ) + + assert.Equal(t, c.expectedImage, actualImg) + + if c.expectedErr == nil { + assert.NoError(t, actualErr) + } else { + if assert.Error(t, c.expectedErr) { + assert.ErrorIs(t, actualErr, c.expectedErr) + } + } + }) + } +} diff --git a/internal/provider/docker/container.go b/internal/provider/docker/container.go index 54e75b69..8f264847 100644 --- a/internal/provider/docker/container.go +++ b/internal/provider/docker/container.go @@ -91,7 +91,7 @@ func (c *Client) listContainerImage() []model.Image { Str("ctn_image", imageName). Interface("ctn_labels", ctn.Labels). Msg("Validate image") - image, err := provider.ValidateImage(imageName, metadata(ctn), ctn.Labels, *c.config.WatchByDefault) + image, err := provider.ValidateImage(imageName, metadata(ctn), ctn.Labels, *c.config.WatchByDefault, *c.imageDefaults) if err != nil { c.logger.Error().Err(err). diff --git a/internal/provider/docker/docker.go b/internal/provider/docker/docker.go index 90df4c23..26a7521c 100644 --- a/internal/provider/docker/docker.go +++ b/internal/provider/docker/docker.go @@ -10,16 +10,18 @@ import ( // Client represents an active docker provider object type Client struct { *provider.Client - config *model.PrdDocker - logger zerolog.Logger + config *model.PrdDocker + logger zerolog.Logger + imageDefaults *model.Image } // New creates new docker provider instance -func New(config *model.PrdDocker) *provider.Client { +func New(config *model.PrdDocker, imageDefaults *model.Image) *provider.Client { return &provider.Client{ Handler: &Client{ - config: config, - logger: log.With().Str("provider", "docker").Logger(), + config: config, + logger: log.With().Str("provider", "docker").Logger(), + imageDefaults: imageDefaults, }, } } diff --git a/internal/provider/dockerfile/dockerfile.go b/internal/provider/dockerfile/dockerfile.go index 1bcf4172..cf009b83 100644 --- a/internal/provider/dockerfile/dockerfile.go +++ b/internal/provider/dockerfile/dockerfile.go @@ -10,16 +10,18 @@ import ( // Client represents an active dockerfile provider object type Client struct { *provider.Client - config *model.PrdDockerfile - logger zerolog.Logger + config *model.PrdDockerfile + logger zerolog.Logger + imageDefaults *model.Image } // New creates new dockerfile provider instance -func New(config *model.PrdDockerfile) *provider.Client { +func New(config *model.PrdDockerfile, imageDefaults *model.Image) *provider.Client { return &provider.Client{ Handler: &Client{ - config: config, - logger: log.With().Str("provider", "dockerfile").Logger(), + config: config, + logger: log.With().Str("provider", "dockerfile").Logger(), + imageDefaults: imageDefaults, }, } } diff --git a/internal/provider/dockerfile/image.go b/internal/provider/dockerfile/image.go index 3b7d77fa..c941fd10 100644 --- a/internal/provider/dockerfile/image.go +++ b/internal/provider/dockerfile/image.go @@ -32,7 +32,7 @@ func (c *Client) listExtImage() (list []model.Image) { Interface("dfile_comments", fromImage.Comments). Int("dfile_line", fromImage.Line). Msg("Validate image") - image, err := provider.ValidateImage(fromImage.Name, nil, c.extractLabels(fromImage.Comments), true) + image, err := provider.ValidateImage(fromImage.Name, nil, c.extractLabels(fromImage.Comments), true, *c.imageDefaults) if err != nil { c.logger.Error().Err(err). Str("dfile_image", fromImage.Name). diff --git a/internal/provider/file/file.go b/internal/provider/file/file.go index 78a3f06b..0f8468b7 100644 --- a/internal/provider/file/file.go +++ b/internal/provider/file/file.go @@ -10,16 +10,18 @@ import ( // Client represents an active file provider object type Client struct { *provider.Client - config *model.PrdFile - logger zerolog.Logger + config *model.PrdFile + logger zerolog.Logger + imageDefaults *model.Image } // New creates new file provider instance -func New(config *model.PrdFile) *provider.Client { +func New(config *model.PrdFile, imageDefaults *model.Image) *provider.Client { return &provider.Client{ Handler: &Client{ - config: config, - logger: log.With().Str("provider", "file").Logger(), + config: config, + logger: log.With().Str("provider", "file").Logger(), + imageDefaults: imageDefaults, }, } } diff --git a/internal/provider/file/file_test.go b/internal/provider/file/file_test.go index f2eddc05..400133fa 100644 --- a/internal/provider/file/file_test.go +++ b/internal/provider/file/file_test.go @@ -6,10 +6,15 @@ import ( "github.com/crazy-max/diun/v4/internal/model" "github.com/crazy-max/diun/v4/internal/provider/file" "github.com/crazy-max/diun/v4/pkg/registry" + "github.com/crazy-max/diun/v4/pkg/utl" "github.com/stretchr/testify/assert" ) var ( + defaultImageDefaults = model.Image{ + NotifyOn: model.NotifyOnDefaults, + SortTags: registry.SortTagReverse, + } bintrayFile = []model.Job{ { Provider: "file", @@ -24,7 +29,7 @@ var ( Provider: "file", Image: model.Image{ Name: "docker.bintray.io/jfrog/xray-server:2.8.6", - WatchRepo: true, + WatchRepo: utl.NewTrue(), NotifyOn: []model.NotifyOn{ model.NotifyOnNew, }, @@ -47,7 +52,7 @@ var ( Provider: "file", Image: model.Image{ Name: "crazymax/swarm-cronjob", - WatchRepo: true, + WatchRepo: utl.NewTrue(), NotifyOn: model.NotifyOnDefaults, SortTags: registry.SortTagSemver, IncludeTags: []string{ @@ -59,7 +64,7 @@ var ( Provider: "file", Image: model.Image{ Name: "docker.io/portainer/portainer", - WatchRepo: true, + WatchRepo: utl.NewTrue(), NotifyOn: model.NotifyOnDefaults, MaxTags: 10, SortTags: registry.SortTagReverse, @@ -72,7 +77,7 @@ var ( Provider: "file", Image: model.Image{ Name: "traefik", - WatchRepo: true, + WatchRepo: utl.NewTrue(), NotifyOn: model.NotifyOnDefaults, SortTags: registry.SortTagDefault, }, @@ -110,7 +115,7 @@ var ( Provider: "file", Image: model.Image{ Name: "crazymax/ddns-route53", - WatchRepo: true, + WatchRepo: utl.NewTrue(), NotifyOn: model.NotifyOnDefaults, SortTags: registry.SortTagReverse, IncludeTags: []string{ @@ -153,13 +158,25 @@ var ( func TestListJobFilename(t *testing.T) { fc := file.New(&model.PrdFile{ Filename: "./fixtures/dockerhub.yml", - }) + }, &defaultImageDefaults) + assert.Equal(t, dockerhubFile, fc.ListJob()) } func TestListJobDirectory(t *testing.T) { fc := file.New(&model.PrdFile{ Directory: "./fixtures", - }) + }, &defaultImageDefaults) + assert.Equal(t, append(append(bintrayFile, dockerhubFile...), append(lscrFile, quayFile...)...), fc.ListJob()) } + +func TestDefaultImageOptions(t *testing.T) { + fc := file.New(&model.PrdFile{ + Filename: "./fixtures/dockerhub.yml", + }, &model.Image{WatchRepo: utl.NewTrue()}) + + for _, job := range fc.ListJob() { + assert.True(t, *job.Image.WatchRepo) + } +} diff --git a/internal/provider/file/image.go b/internal/provider/file/image.go index 1339f801..f26de19b 100644 --- a/internal/provider/file/image.go +++ b/internal/provider/file/image.go @@ -7,7 +7,6 @@ import ( "github.com/containerd/containerd/platforms" "github.com/crazy-max/diun/v4/internal/model" - "github.com/crazy-max/diun/v4/pkg/registry" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "gopkg.in/yaml.v2" ) @@ -31,10 +30,15 @@ func (c *Client) listFileImage() []model.Image { c.logger.Error().Err(err).Msgf("Unable to decode into struct %s", file) continue } + for _, item := range items { + // Set default WatchRepo + if item.WatchRepo == nil { + item.WatchRepo = c.imageDefaults.WatchRepo + } // Check NotifyOn if len(item.NotifyOn) == 0 { - item.NotifyOn = model.NotifyOnDefaults + item.NotifyOn = c.imageDefaults.NotifyOn } else { for _, no := range item.NotifyOn { if !no.Valid() { @@ -48,7 +52,7 @@ func (c *Client) listFileImage() []model.Image { // Check SortType if item.SortTags == "" { - item.SortTags = registry.SortTagReverse + item.SortTags = c.imageDefaults.SortTags } if !item.SortTags.Valid() { c.logger.Error(). diff --git a/internal/provider/kubernetes/kubernetes.go b/internal/provider/kubernetes/kubernetes.go index be2788e7..b1c91e36 100644 --- a/internal/provider/kubernetes/kubernetes.go +++ b/internal/provider/kubernetes/kubernetes.go @@ -10,16 +10,18 @@ import ( // Client represents an active kubernetes provider object type Client struct { *provider.Client - config *model.PrdKubernetes - logger zerolog.Logger + config *model.PrdKubernetes + logger zerolog.Logger + imageDefaults *model.Image } // New creates new kubernetes provider instance -func New(config *model.PrdKubernetes) *provider.Client { +func New(config *model.PrdKubernetes, imageDefaults *model.Image) *provider.Client { return &provider.Client{ Handler: &Client{ - config: config, - logger: log.With().Str("provider", "kubernetes").Logger(), + config: config, + logger: log.With().Str("provider", "kubernetes").Logger(), + imageDefaults: imageDefaults, }, } } diff --git a/internal/provider/kubernetes/pod.go b/internal/provider/kubernetes/pod.go index 7b12ce9c..dd478d15 100644 --- a/internal/provider/kubernetes/pod.go +++ b/internal/provider/kubernetes/pod.go @@ -41,7 +41,7 @@ func (c *Client) listPodImage() []model.Image { Str("ctn_image", ctn.Image). Msg("Validate image") - image, err := provider.ValidateImage(ctn.Image, metadata(pod, ctn), pod.Annotations, *c.config.WatchByDefault) + image, err := provider.ValidateImage(ctn.Image, metadata(pod, ctn), pod.Annotations, *c.config.WatchByDefault, *c.imageDefaults) if err != nil { c.logger.Error().Err(err). Str("pod_name", pod.Name). diff --git a/internal/provider/nomad/nomad.go b/internal/provider/nomad/nomad.go index ebc6c323..26d4f21e 100644 --- a/internal/provider/nomad/nomad.go +++ b/internal/provider/nomad/nomad.go @@ -10,16 +10,18 @@ import ( // Client represents an active nomad provider object type Client struct { *provider.Client - config *model.PrdNomad - logger zerolog.Logger + config *model.PrdNomad + logger zerolog.Logger + imageDefaults *model.Image } -// New creates new kubernetes provider instance -func New(config *model.PrdNomad) *provider.Client { +// New creates new nomad provider instance +func New(config *model.PrdNomad, imageDefaults *model.Image) *provider.Client { return &provider.Client{ Handler: &Client{ - config: config, - logger: log.With().Str("provider", "nomad").Logger(), + config: config, + logger: log.With().Str("provider", "nomad").Logger(), + imageDefaults: imageDefaults, }, } } diff --git a/internal/provider/nomad/task.go b/internal/provider/nomad/task.go index 302fb52a..7a1bec0b 100644 --- a/internal/provider/nomad/task.go +++ b/internal/provider/nomad/task.go @@ -101,7 +101,7 @@ func (c *Client) listTaskImages() []model.Image { c.logger.Error().Err(err).Msg("Cannot merge task metadata") } - image, err := provider.ValidateImage(imageName, metadata(job, taskGroup, task), labels, *c.config.WatchByDefault) + image, err := provider.ValidateImage(imageName, metadata(job, taskGroup, task), labels, *c.config.WatchByDefault, *c.imageDefaults) if err != nil { c.logger.Error(). Err(err). diff --git a/internal/provider/swarm/service.go b/internal/provider/swarm/service.go index d10dd95c..d32c9019 100644 --- a/internal/provider/swarm/service.go +++ b/internal/provider/swarm/service.go @@ -37,7 +37,7 @@ func (c *Client) listServiceImage() []model.Image { Str("ctn_image", svc.Spec.TaskTemplate.ContainerSpec.Image). Msg("Validate image") - image, err := provider.ValidateImage(svc.Spec.TaskTemplate.ContainerSpec.Image, metadata(svc), svc.Spec.Labels, *c.config.WatchByDefault) + image, err := provider.ValidateImage(svc.Spec.TaskTemplate.ContainerSpec.Image, metadata(svc), svc.Spec.Labels, *c.config.WatchByDefault, *c.imageDefaults) if err != nil { c.logger.Error().Err(err). Str("svc_name", svc.Spec.Name). diff --git a/internal/provider/swarm/swarm.go b/internal/provider/swarm/swarm.go index 80de2d08..56a8ce40 100644 --- a/internal/provider/swarm/swarm.go +++ b/internal/provider/swarm/swarm.go @@ -10,16 +10,18 @@ import ( // Client represents an active swarm provider object type Client struct { *provider.Client - config *model.PrdSwarm - logger zerolog.Logger + config *model.PrdSwarm + logger zerolog.Logger + imageDefaults *model.Image } // New creates new swarm provider instance -func New(config *model.PrdSwarm) *provider.Client { +func New(config *model.PrdSwarm, imageDefaults *model.Image) *provider.Client { return &provider.Client{ Handler: &Client{ - config: config, - logger: log.With().Str("provider", "swarm").Logger(), + config: config, + logger: log.With().Str("provider", "swarm").Logger(), + imageDefaults: imageDefaults, }, } }