mirror of
https://github.com/aquasecurity/trivy.git
synced 2026-01-31 13:53:14 +08:00
feat(cli): add version constraints to annoucements (#9023)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
58
pkg/notification/response.go
Normal file
58
pkg/notification/response.go
Normal 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
|
||||
}
|
||||
180
pkg/notification/response_test.go
Normal file
180
pkg/notification/response_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user