Skip to content

Commit

Permalink
feat: add version deprecation and sunset eligibility
Browse files Browse the repository at this point in the history
Annotate paths in a given resource version with the newer release
version that deprecates it, and when the deprecated version may be
sunset.

This may be used in linting, middleware, and documentation.

Implements #86.
  • Loading branch information
cmars committed Dec 6, 2021
1 parent 6526030 commit 6f6cb54
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 9 deletions.
34 changes: 32 additions & 2 deletions resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ const (
// with all the release versions containing a change in the path info. This
// is useful for navigating changes in a particular path across versions.
ExtSnykApiReleases = "x-snyk-api-releases"

// ExtSnykDeprecatedBy is used to annotate a path in a resource version
// spec with the subsequent version that deprecates it. This may be used
// by linters, service middleware and API documentation to indicate which
// version deprecates a given version.
ExtSnykDeprecatedBy = "x-snyk-deprecated-by"

// ExtSnykSunsetEligible is used to annotate a path in a resource version
// spec which is deprecated, with the sunset eligible date: the date after
// which the resource version may be removed and no longer available.
ExtSnykSunsetEligible = "x-snyk-sunset-eligible"
)

// Resource defines a specific version of a resource, corresponding to a
Expand All @@ -43,10 +54,12 @@ type extensionNotFoundError struct {
extension string
}

// Error implements error.
func (e *extensionNotFoundError) Error() string {
return fmt.Sprintf("extension \"%s\" not found", e.extension)
}

// Is returns whether an error matches this error instance.
func (e *extensionNotFoundError) Is(err error) bool {
_, ok := err.(*extensionNotFoundError)
return ok
Expand All @@ -72,6 +85,7 @@ type ResourceVersions struct {
versions resourceVersionSlice
}

// Name returns the resource name for a collection of resource versions.
func (e *ResourceVersions) Name() string {
for i := range e.versions {
return e.versions[i].Name
Expand Down Expand Up @@ -115,10 +129,15 @@ func (e *ResourceVersions) At(vs string) (*Resource, error) {

type resourceVersionSlice []*Resource

// Less implements sort.Interface.
func (e resourceVersionSlice) Less(i, j int) bool {
return e[i].Version.Compare(&e[j].Version) < 0
}
func (e resourceVersionSlice) Len() int { return len(e) }

// Len implements sort.Interface.
func (e resourceVersionSlice) Len() int { return len(e) }

// Swap implements sort.Interface.
func (e resourceVersionSlice) Swap(i, j int) { e[i], e[j] = e[j], e[i] }

// LoadResourceVersions returns a ResourceVersions slice parsed from a
Expand All @@ -143,6 +162,8 @@ func LoadResourceVersions(epPath string) (*ResourceVersions, error) {
return LoadResourceVersionsFileset(specYamls)
}

// LoadResourceVersionFileset returns a ResourceVersions slice parsed from the
// directory structure described above for LoadResourceVersions.
func LoadResourceVersionsFileset(specYamls []string) (*ResourceVersions, error) {
var resourceVersions ResourceVersions
var err error
Expand Down Expand Up @@ -183,7 +204,16 @@ func LoadResourceVersionsFileset(specYamls []string) (*ResourceVersions, error)
// versions affecting the path. This supports navigation across versions.
for _, rc := range resourceVersions.versions {
for path, pathInfo := range rc.Paths {
pathInfo.ExtensionProps.Extensions[ExtSnykApiReleases] = pathReleases[path].Strings()
// Annotate path with other release versions available for this path
releases := pathReleases[path]
pathInfo.ExtensionProps.Extensions[ExtSnykApiReleases] = releases.Strings()
// Annotate path with deprecated-by and sunset information
if deprecatedBy, ok := releases.Deprecates(rc.Version); ok {
pathInfo.ExtensionProps.Extensions[ExtSnykDeprecatedBy] = deprecatedBy.String()
if sunset, ok := rc.Version.Sunset(deprecatedBy); ok {
pathInfo.ExtensionProps.Extensions[ExtSnykSunsetEligible] = sunset.Format("2006-01-02")
}
}
}
}
return &resourceVersions, nil
Expand Down
4 changes: 3 additions & 1 deletion testdata/output/2021-06-01~experimental/spec.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions testdata/output/2021-06-01~experimental/spec.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion testdata/output/2021-06-04~experimental/spec.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions testdata/output/2021-06-04~experimental/spec.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion testdata/output/2021-06-07~experimental/spec.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions testdata/output/2021-06-07~experimental/spec.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

94 changes: 90 additions & 4 deletions version.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ func (v *Version) String() string {
return d
}

// AddDays returns the version corresponding to adding the given number of days
// to the version date.
func (v *Version) AddDays(days int) Version {
return Version{
Date: v.Date.AddDate(0, 0, days),
Stability: v.Stability,
}
}

// Stability defines the stability level of the version.
type Stability int

Expand Down Expand Up @@ -146,6 +155,48 @@ func (v *Version) Compare(vr *Version) int {
return stabilityCmp
}

// DeprecatedBy returns true if the given version deprecates the caller target
// version.
func (v *Version) DeprecatedBy(vr *Version) bool {
dateCmp, stabilityCmp := v.compareDateStability(vr)
// A version is deprecated by a newer version of equal or greater stability.
return dateCmp == -1 && stabilityCmp <= 0
}

const (
// SunsetWIP is the duration past deprecation after which a work-in-progress version may be sunset.
SunsetWIP = 0

// SunsetExperimental is the duration past deprecation after which an experimental version may be sunset.
SunsetExperimental = 31 * 24 * time.Hour

// SunsetBeta is the duration past deprecation after which a beta version may be sunset.
SunsetBeta = 91 * 24 * time.Hour

// SunsetGA is the duration past deprecation after which a GA version may be sunset.
SunsetGA = 181 * 24 * time.Hour
)

// Sunset returns, given a potentially deprecating version, the eligible sunset
// date and whether the caller target version would actually be deprecated and
// sunset by the given version.
func (v *Version) Sunset(vr *Version) (time.Time, bool) {
if !v.DeprecatedBy(vr) {
return time.Time{}, false
}
switch v.Stability {
case StabilityWIP:
return vr.Date.Add(SunsetWIP), true
case StabilityExperimental:
return vr.Date.Add(SunsetExperimental), true
case StabilityBeta:
return vr.Date.Add(SunsetBeta), true
case StabilityGA:
return vr.Date.Add(SunsetGA), true
}
return time.Time{}, false
}

// compareDateStability returns the comparison of both the date and stability
// between two versions. Used internally where these need to be evaluated
// independently, such as when searching for the best matching version.
Expand Down Expand Up @@ -182,10 +233,24 @@ type VersionSlice []Version
// This method requires that the VersionSlice has already been sorted with
// sort.Sort, otherwise behavior is undefined.
func (vs VersionSlice) Resolve(q Version) (*Version, error) {
i, err := vs.ResolveIndex(q)
if err != nil {
return nil, err
}
v := vs[i]
return &v, nil
}

// ResolveIndex returns the slice index of the most recent Version in the slice
// with equal or greater stability.
//
// This method requires that the VersionSlice has already been sorted with
// sort.Sort, otherwise behavior is undefined.
func (vs VersionSlice) ResolveIndex(q Version) (int, error) {
lower, curr, upper := 0, len(vs)/2, len(vs)
if upper == 0 {
// Nothing matches an empty slice.
return nil, ErrNoMatchingVersion
return -1, ErrNoMatchingVersion
}
for curr < upper && lower != upper-1 {
dateCmp, stabilityCmp := vs[curr].compareDateStability(&q)
Expand All @@ -207,10 +272,31 @@ func (vs VersionSlice) Resolve(q Version) (*Version, error) {
// Did we find a match?
dateCmp, stabilityCmp := vs[lower].compareDateStability(&q)
if dateCmp <= 0 && stabilityCmp >= 0 {
v := &vs[lower]
return v, nil
return lower, nil
}
return -1, ErrNoMatchingVersion
}

// Deprecates returns the version that deprecates the given version in the
// slice.
func (vs VersionSlice) Deprecates(q Version) (*Version, bool) {
match, err := vs.ResolveIndex(q)
if err == ErrNoMatchingVersion {
return nil, false
} else if err != nil {
panic(err)
}
for i := match + 1; i < len(vs); i++ {
dateCmp, stabilityCmp := vs[match].compareDateStability(&vs[i])
if stabilityCmp > 0 {
continue
}
if dateCmp < 0 {
v := vs[i]
return &v, true
}
}
return nil, ErrNoMatchingVersion
return nil, false
}

// Len implements sort.Interface.
Expand Down
83 changes: 83 additions & 0 deletions version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package vervet_test
import (
"sort"
"testing"
"time"

qt "github.com/frankban/quicktest"

Expand Down Expand Up @@ -237,3 +238,85 @@ func TestVersionSliceResolveEmpty(t *testing.T) {
_, err := VersionSlice{}.Resolve(MustParseVersion("2021-10-31"))
c.Assert(err, qt.ErrorMatches, "no matching version")
}

func TestDeprecatedBy(t *testing.T) {
c := qt.New(t)
tests := []struct {
base, deprecatedBy string
result bool
}{{
"2021-06-01", "2021-02-01", false,
}, {
"2021-06-01", "2021-02-01~experimental", false,
}, {
"2021-06-01", "2021-02-01~beta", false,
}, {
"2021-06-01", "2022-02-01~beta", false,
}, {
"2021-06-01", "2022-02-01~experimental", false,
}, {
"2021-06-01", "2021-06-01", false,
}, {
"2021-06-01", "2021-06-02", true,
}, {
"2021-06-01~experimental", "2021-06-02~beta", true,
}, {
"2021-06-01~beta", "2021-06-02", true,
}, {
"2021-06-01", "2021-06-01", false,
}}
for i, test := range tests {
c.Logf("test#%d: %#v", i, test)
base, deprecatedBy := MustParseVersion(test.base), MustParseVersion(test.deprecatedBy)
c.Assert(base.DeprecatedBy(&deprecatedBy), qt.Equals, test.result)
}
}

func TestDeprecates(t *testing.T) {
c := qt.New(t)
versions := VersionSlice{
MustParseVersion("2021-06-01~experimental"),
MustParseVersion("2021-06-07~beta"),
MustParseVersion("2021-07-01"),
MustParseVersion("2021-08-12~experimental"),
MustParseVersion("2021-09-16~beta"),
MustParseVersion("2021-10-31"),
}
sort.Sort(versions)
tests := []struct {
name string
target Version
deprecatedBy Version
isDeprecated bool
sunset time.Time
}{{
name: "beta deprecates experimental",
target: MustParseVersion("2021-06-01~experimental"),
deprecatedBy: MustParseVersion("2021-06-07~beta"),
isDeprecated: true,
sunset: time.Date(2021, time.July, 8, 0, 0, 0, 0, time.UTC),
}, {
name: "ga deprecates beta",
target: MustParseVersion("2021-06-07~beta"),
deprecatedBy: MustParseVersion("2021-07-01"),
isDeprecated: true,
sunset: time.Date(2021, time.September, 30, 0, 0, 0, 0, time.UTC),
}, {
name: "ga deprecates ga",
target: MustParseVersion("2021-07-01"),
deprecatedBy: MustParseVersion("2021-10-31"),
isDeprecated: true,
sunset: time.Date(2022, time.April, 30, 0, 0, 0, 0, time.UTC),
}}
for i, test := range tests {
c.Logf("test#%d: %s", i, test.name)
deprecates, ok := versions.Deprecates(test.target)
c.Assert(ok, qt.Equals, test.isDeprecated)
if test.isDeprecated {
c.Assert(&test.deprecatedBy, qt.DeepEquals, deprecates)
sunset, ok := test.target.Sunset(deprecates)
c.Assert(ok, qt.IsTrue)
c.Assert(test.sunset, qt.Equals, sunset)
}
}
}

0 comments on commit 6f6cb54

Please sign in to comment.