feat(cli): add version constraints to annoucements (#9023)

This commit is contained in:
Owen Rumney
2025-06-12 09:09:39 +01:00
committed by GitHub
parent 40d017b67d
commit 19efa9fd37
5 changed files with 368 additions and 44 deletions

View File

@@ -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

View File

@@ -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
cv, err := v.CurrentVersion()
if err != nil {
return
}
lv, err := v.LatestVersion()
if err != nil {
return
}
notices = append(notices, v.Warnings()...)
for _, announcement := range v.Announcements() {
if time.Now().Before(announcement.ToDate) && time.Now().After(announcement.FromDate) {
if announcement.shouldDisplay(ctx, cv) {
notices = append(notices, announcement.Announcement)
}
}
cv, err := semver.Parse(strings.TrimPrefix(v.currentVersion, "v"))
if err != nil {
return
}
lv, err := semver.Parse(strings.TrimPrefix(v.LatestVersion(), "v"))
if err != nil {
return
}
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 {
if v.responseReceived {
return v.latestVersion.Trivy.LatestVersion
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 ""
return current, nil
}
func (v *VersionChecker) LatestVersion() (semver.Version, error) {
if v.responseReceived {
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 semver.Version{}, errors.New("no response received from version check")
}
func (v *VersionChecker) Announcements() []announcement {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
})
}
}