diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go index dce040350d46..40aba95b8508 100644 --- a/pkg/commands/artifact/run.go +++ b/pkg/commands/artifact/run.go @@ -171,7 +171,7 @@ func (r *runner) Close(ctx context.Context) error { // silently check if there is notifications if r.versionChecker != nil { - r.versionChecker.PrintNotices(os.Stderr) + r.versionChecker.PrintNotices(ctx, os.Stderr) } return errs diff --git a/pkg/notification/notice.go b/pkg/notification/notice.go index f06bcc3699c1..491ae0cb52f8 100644 --- a/pkg/notification/notice.go +++ b/pkg/notification/notice.go @@ -3,6 +3,7 @@ package notification import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -15,30 +16,6 @@ import ( "github.com/aquasecurity/trivy/pkg/version/app" ) -// flexibleTime is a custom time type that can handle -// different date formats in JSON. It implements the -// UnmarshalJSON method to parse the date string into a time.Time object. -type flexibleTime struct { - time.Time -} - -type versionInfo struct { - LatestVersion string `json:"latest_version"` - LatestDate flexibleTime `json:"latest_date"` -} - -type announcement struct { - FromDate time.Time `json:"from_date"` - ToDate time.Time `json:"to_date"` - Announcement string `json:"announcement"` -} - -type updateResponse struct { - Trivy versionInfo `json:"trivy"` - Announcements []announcement `json:"announcements"` - Warnings []string `json:"warnings"` -} - type VersionChecker struct { updatesApi string skipUpdateCheck bool @@ -125,37 +102,37 @@ func (v *VersionChecker) RunUpdateCheck(ctx context.Context, args []string) { // PrintNotices prints any announcements or warnings // to the output writer, most likely stderr -func (v *VersionChecker) PrintNotices(output io.Writer) { +func (v *VersionChecker) PrintNotices(ctx context.Context, output io.Writer) { if !v.responseReceived { return } logger := log.WithPrefix("notification") - logger.Debug("Printing notices") var notices []string - notices = append(notices, v.Warnings()...) - for _, announcement := range v.Announcements() { - if time.Now().Before(announcement.ToDate) && time.Now().After(announcement.FromDate) { - notices = append(notices, announcement.Announcement) - } - } - - cv, err := semver.Parse(strings.TrimPrefix(v.currentVersion, "v")) + cv, err := v.CurrentVersion() if err != nil { return } - lv, err := semver.Parse(strings.TrimPrefix(v.LatestVersion(), "v")) + lv, err := v.LatestVersion() if err != nil { return } + notices = append(notices, v.Warnings()...) + for _, announcement := range v.Announcements() { + if announcement.shouldDisplay(ctx, cv) { + notices = append(notices, announcement.Announcement) + } + } + if cv.LessThan(lv) { notices = append(notices, fmt.Sprintf("Version %s of Trivy is now available, current version is %s", lv, cv)) } if len(notices) > 0 { + logger.Debug("Printing notices") fmt.Fprintf(output, "\n📣 \x1b[34mNotices:\x1b[0m\n") for _, notice := range notices { fmt.Fprintf(output, " - %s\n", notice) @@ -166,11 +143,23 @@ func (v *VersionChecker) PrintNotices(output io.Writer) { } } -func (v *VersionChecker) LatestVersion() string { +func (v *VersionChecker) CurrentVersion() (semver.Version, error) { + current, err := semver.Parse(strings.TrimPrefix(v.currentVersion, "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("failed to parse current version: %w", err) + } + return current, nil +} + +func (v *VersionChecker) LatestVersion() (semver.Version, error) { if v.responseReceived { - return v.latestVersion.Trivy.LatestVersion + latest, err := semver.Parse(strings.TrimPrefix(v.latestVersion.Trivy.LatestVersion, "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("failed to parse latest version: %w", err) + } + return latest, nil } - return "" + return semver.Version{}, errors.New("no response received from version check") } func (v *VersionChecker) Announcements() []announcement { diff --git a/pkg/notification/notice_test.go b/pkg/notification/notice_test.go index c057b67e7ad1..1e25d1578a22 100644 --- a/pkg/notification/notice_test.go +++ b/pkg/notification/notice_test.go @@ -84,6 +84,101 @@ func TestPrintNotices(t *testing.T) { responseExpected: true, expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - There are some amazing things happening right now!\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", }, + { + name: "No new version with announcements and zero time", + options: []Option{WithCurrentVersion("0.60.0")}, + latestVersion: "0.60.0", + announcements: []announcement{ + { + FromDate: time.Time{}, + ToDate: time.Time{}, + Announcement: "There are some amazing things happening right now!", + }, + }, + responseExpected: true, + expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - There are some amazing things happening right now!\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", + }, + { + name: "No new version with announcement that fails announcement version constraints", + options: []Option{WithCurrentVersion("0.60.0")}, + latestVersion: "0.60.0", + announcements: []announcement{ + { + FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), + ToDate: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC), + FromVersion: "0.61.0", + Announcement: "There are some amazing things happening right now!", + }, + }, + responseExpected: true, + expectedOutput: "", + }, + { + name: "No new version with announcement where current version is greater than to_version", + options: []Option{WithCurrentVersion("0.60.0")}, + latestVersion: "0.60.0", + announcements: []announcement{ + { + FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), + ToDate: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC), + ToVersion: "0.59.0", + Announcement: "There are some amazing things happening right now!", + }, + }, + responseExpected: true, + expectedOutput: "", + }, + { + name: "No new version with announcement that satisfies version constraint but outside date range", + options: []Option{WithCurrentVersion("0.60.0")}, + latestVersion: "0.60.0", + announcements: []announcement{ + { + FromDate: time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC), + ToDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + FromVersion: "0.60.0", + Announcement: "There are some amazing things happening right now!", + }, + }, + responseExpected: true, + expectedOutput: "", + }, + { + name: "No new version with multiple announcements, one of which is valid", + options: []Option{WithCurrentVersion("0.60.0")}, + latestVersion: "0.60.0", + announcements: []announcement{ + { + FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), + ToDate: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC), + Announcement: "There are some amazing things happening right now!", + }, + { + FromDate: time.Date(2025, 2, 2, 12, 0, 0, 0, time.UTC), + ToDate: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC), + FromVersion: "0.61.0", + Announcement: "This announcement should not be displayed", + }, + }, + responseExpected: true, + expectedOutput: "\n📣 \x1b[34mNotices:\x1b[0m\n - There are some amazing things happening right now!\n\nTo suppress version checks, run Trivy scans with the --skip-version-check flag\n\n", + }, + { + name: "No new version with no announcements and quiet mode", + options: []Option{WithCurrentVersion("0.60.0"), WithQuietMode(true)}, + latestVersion: "0.60.0", + announcements: []announcement{}, + responseExpected: false, + expectedOutput: "", + }, + { + name: "No new version with no announcements", + options: []Option{WithCurrentVersion("0.60.0")}, + latestVersion: "0.60.0", + announcements: []announcement{}, + responseExpected: true, + expectedOutput: "", + }, } for _, tt := range tests { @@ -99,7 +194,7 @@ func TestPrintNotices(t *testing.T) { require.Eventually(t, func() bool { return v.responseReceived == tt.responseExpected }, time.Second*5, 500) sb := bytes.NewBufferString("") - v.PrintNotices(sb) + v.PrintNotices(t.Context(), sb) assert.Equal(t, tt.expectedOutput, sb.String()) // check metrics are sent @@ -161,7 +256,9 @@ func TestCheckForNotices(t *testing.T) { v.RunUpdateCheck(t.Context(), nil) require.Eventually(t, func() bool { return v.done }, time.Second*5, 500) require.Eventually(t, func() bool { return v.responseReceived }, time.Second*5, 500) - assert.Equal(t, tt.expectedVersion, v.LatestVersion()) + latestVersion, err := v.LatestVersion() + require.NoError(t, err) + assert.Equal(t, tt.expectedVersion, latestVersion.String()) assert.ElementsMatch(t, tt.expectedAnnouncements, v.Announcements()) if tt.expectNoMetrics { diff --git a/pkg/notification/response.go b/pkg/notification/response.go new file mode 100644 index 000000000000..7eecf5395ad9 --- /dev/null +++ b/pkg/notification/response.go @@ -0,0 +1,58 @@ +package notification + +import ( + "context" + "time" + + "github.com/aquasecurity/go-version/pkg/semver" + "github.com/aquasecurity/trivy/pkg/clock" +) + +// flexibleTime is a custom time type that can handle +// different date formats in JSON. It implements the +// UnmarshalJSON method to parse the date string into a time.Time object. +type flexibleTime struct { + time.Time +} + +type versionInfo struct { + LatestVersion string `json:"latest_version"` + LatestDate flexibleTime `json:"latest_date"` +} + +type announcement struct { + FromDate time.Time `json:"from_date"` + ToDate time.Time `json:"to_date"` + FromVersion string `json:"from_version"` + ToVersion string `json:"to_version"` + Announcement string `json:"announcement"` +} + +type updateResponse struct { + Trivy versionInfo `json:"trivy"` + Announcements []announcement `json:"announcements"` + Warnings []string `json:"warnings"` +} + +// shoudDisplay checks if the announcement should be displayed +// based on the current time and version. If version and date constraints are provided +// they are checked against the current time and version. +func (a *announcement) shouldDisplay(ctx context.Context, currentVersion semver.Version) bool { + if !a.FromDate.IsZero() && clock.Now(ctx).Before(a.FromDate) { + return false + } + if !a.ToDate.IsZero() && clock.Now(ctx).After(a.ToDate) { + return false + } + if a.FromVersion != "" { + if fromVersion, err := semver.Parse(a.FromVersion); err == nil && currentVersion.LessThan(fromVersion) { + return false + } + } + if a.ToVersion != "" { + if toVersion, err := semver.Parse(a.ToVersion); err == nil && currentVersion.GreaterThanOrEqual(toVersion) { + return false + } + } + return true +} diff --git a/pkg/notification/response_test.go b/pkg/notification/response_test.go new file mode 100644 index 000000000000..461bb331586b --- /dev/null +++ b/pkg/notification/response_test.go @@ -0,0 +1,180 @@ +package notification + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/go-version/pkg/semver" + "github.com/aquasecurity/trivy/pkg/clock" +) + +func TestAnnouncementShouldDisplay(t *testing.T) { + tests := []struct { + name string + announcement announcement + now time.Time + currentVersion string + expected bool + }{ + { + name: "Announcement with valid from_date and current date before it", + announcement: announcement{ + FromDate: time.Date(2023, 10, 1, 0, 0, 0, 0, time.UTC), + Announcement: "Upcoming feature", + }, + now: time.Date(2023, 9, 30, 0, 0, 0, 0, time.UTC), + currentVersion: "1.0.0", + expected: false, + }, + { + name: "Announcement with valid to_date and current date after it", + announcement: announcement{ + ToDate: time.Date(2023, 10, 1, 0, 0, 0, 0, time.UTC), + Announcement: "Past feature", + }, + now: time.Date(2023, 10, 2, 0, 0, 0, 0, time.UTC), + currentVersion: "1.0.0", + expected: false, + }, + { + name: "Announcement with valid from_date and current date after it and before to_date", + announcement: announcement{ + FromDate: time.Date(2023, 10, 1, 0, 0, 0, 0, time.UTC), + ToDate: time.Date(2023, 10, 31, 0, 0, 0, 0, time.UTC), + Announcement: "Ongoing feature", + }, + now: time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC), + currentVersion: "1.0.0", + expected: true, + }, + { + name: "Announcement with valid from_version and current version greater than it", + announcement: announcement{ + FromVersion: "1.1.0", + Announcement: "New feature", + }, + now: time.Now(), + currentVersion: "1.2.0", + expected: true, + }, + { + name: "Announcement with valid from_version and current version equal to it", + announcement: announcement{ + FromVersion: "1.0.0", + Announcement: "New feature", + }, + now: time.Now(), + currentVersion: "1.0.0", + expected: true, + }, + { + name: "Announcement with valid to_version and current version less than it", + announcement: announcement{ + ToVersion: "1.2.0", + Announcement: "Upcoming feature", + }, + now: time.Now(), + currentVersion: "1.0.0", + expected: true, + }, + { + name: "Announcement with valid to_version and current version equal to it", + announcement: announcement{ + ToVersion: "1.0.0", + Announcement: "Upcoming feature", + }, + now: time.Now(), + currentVersion: "1.0.0", + expected: false, + }, + { + name: "Announcement with valid from_version and valid to_version", + announcement: announcement{ + FromVersion: "1.0.0", + ToVersion: "1.2.0", + Announcement: "Feature announcement", + }, + now: time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC), + currentVersion: "1.1.0", + expected: true, + }, + { + name: "Announcement with no date or version constraints", + announcement: announcement{ + Announcement: "General announcement", + }, + now: time.Now(), + currentVersion: "1.0.0", + expected: true, + }, + { + name: "Announcement with all constraints but current version meets them", + announcement: announcement{ + FromDate: time.Date(2023, 10, 1, 0, 0, 0, 0, time.UTC), + ToDate: time.Date(2023, 10, 31, 0, 0, 0, 0, time.UTC), + FromVersion: "1.0.0", + ToVersion: "1.2.0", + Announcement: "Feature announcement", + }, + now: time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC), + currentVersion: "1.1.0", + expected: true, + }, + { + name: "Announcement with version having 'v' prefix", + announcement: announcement{ + FromVersion: "v1.0.0", + Announcement: "Version prefix handling", + }, + now: time.Now(), + currentVersion: "1.0.0", + expected: true, + }, + { + name: "Current version with 'v' prefix", + announcement: announcement{ + FromVersion: "1.0.0", + Announcement: "Version prefix handling", + }, + now: time.Now(), + currentVersion: "v1.0.0", + expected: true, + }, + { + name: "Pre-release version comparison", + announcement: announcement{ + FromVersion: "1.0.0", + ToVersion: "1.2.0", + Announcement: "Pre-release handling", + }, + now: time.Now(), + currentVersion: "1.1.0-beta.1", + expected: true, + }, + { + name: "Build metadata in version", + announcement: announcement{ + FromVersion: "1.0.0", + Announcement: "Build metadata handling", + }, + now: time.Now(), + currentVersion: "1.0.0+build.1", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + currentVersion, err := semver.Parse(strings.TrimPrefix(tt.currentVersion, "v")) + require.NoError(t, err) + + fakeCtx := clock.With(t.Context(), tt.now) + got := tt.announcement.shouldDisplay(fakeCtx, currentVersion) + assert.Equal(t, tt.expected, got) + }) + } +}