diff --git a/README.md b/README.md index 2d57425..c4ca315 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # version -A go-language package for managing [k0s](https://github.com/k0sproject/k0s) version numbers. It is based on [hashicorp/go-version](https://github.com/hashicorp/go-version) but adds sorting and comparison capabilities for the k0s version numbering scheme which requires additional sorting by the build tag. +A go-language package for parsing, comparing, sorting and constraint-checking [k0s](https://github.com/k0sproject/k0s) version numbers. The API is modeled after [hashicorp/go-version](https://github.com/hashicorp/go-version). + +K0s versioning follows [semver](https://semver.org/) v2.0 with the exception that there is a special metadata field for the k0s build version like `v1.23.4+k0s.1` which affects precedence while sorting or comparing version numbers. + +The library should work fine for performing the same operations on non-k0s version numbers as long as there are maximum of 3 numeric segments (1.2.3), but this is not a priority. ## Usage @@ -9,6 +13,7 @@ A go-language package for managing [k0s](https://github.com/k0sproject/k0s) vers ```go import ( "fmt" + "github.com/k0sproject/version" ) @@ -29,6 +34,70 @@ a is less than b: true a is equal to b: false ``` +### Constraints + +```go +import ( + "fmt" + + "github.com/k0sproject/version" +) + +func main() { + v := version.MustParse("1.23.3+k0s.1") + c := version.MustConstraint("> 1.23") + fmt.Printf("constraint %s satisfied by %s: %t\n", c, v, c.Check(v)) +} +``` + +Outputs: + +```text +constraint > 1.2.3 satisfied by v1.23.3+k0s.1: true +``` + +### Sorting + +```go +import ( + "fmt" + "sort" + + "github.com/k0sproject/version" +) + +func main() { + versions := []*version.Version{ + version.MustParse("v1.23.3+k0s.2"), + version.MustParse("1.23.2+k0s.3"), + version.MustParse("1.23.3+k0s.1"), + } + + fmt.Println("Before:") + for _, v range versions { + fmt.Println(v) + } + sort.Sort(versions) + fmt.Println("After:") + for _, v range versions { + fmt.Println(v) + } +} +``` + +Outputs: + +```text +Before: +v1.23.3+k0s.2 +v1.23.2+k0s.3 +v1.23.3+k0s.1 +After: +v1.23.2+k0s.3 +v1.23.3+k0s.1 +v1.23.3+k0s.2 +``` + ### Check online for latest version ```go diff --git a/collection.go b/collection.go index db27c3b..eb3c1a5 100644 --- a/collection.go +++ b/collection.go @@ -1,7 +1,6 @@ package version import ( - "encoding/json" "fmt" ) @@ -32,60 +31,3 @@ func (c Collection) Less(i, j int) bool { func (c Collection) Swap(i, j int) { c[i], c[j] = c[j], c[i] } - -func (c *Collection) marshal() ([]string, error) { - strSlice := make([]string, len(*c)) - for i, v := range *c { - s, err := v.MarshalJSON() - if err != nil { - return nil, err - } - strSlice[i] = string(s) - } - return strSlice, nil -} - -func (c *Collection) unmarshal(strSlice []string) error { - coll := make(Collection, len(strSlice)) - for i, s := range strSlice { - v, err := NewVersion(s) - if err != nil { - return err - } - coll[i] = v - } - *c = coll - return nil -} - -// UnmarshalText implements the json.Marshaler interface. -func (c *Collection) MarshalJSON() ([]byte, error) { - strSlice, err := c.marshal() - if err != nil { - return nil, err - } - return json.Marshal(strSlice) -} - -// UnmarshalJSON implements the json.Unmarshaler interface. -func (c *Collection) UnmarshalJSON(data []byte) error { - var strSlice []string - if err := json.Unmarshal(data, &strSlice); err != nil { - return fmt.Errorf("failed to decode JSON input: %w", err) - } - return c.unmarshal(strSlice) -} - -// MarshalYAML implements the yaml.Marshaler interface. -func (c *Collection) MarshalYAML() (interface{}, error) { - return c.marshal() -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface. -func (c *Collection) UnmarshalYAML(unmarshal func(interface{}) error) error { - var strSlice []string - if err := unmarshal(&strSlice); err != nil { - return fmt.Errorf("failed to decode YAML input: %w", err) - } - return c.unmarshal(strSlice) -} diff --git a/collection_test.go b/collection_test.go index 7129176..8f68b10 100644 --- a/collection_test.go +++ b/collection_test.go @@ -6,11 +6,12 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCollection(t *testing.T) { c, err := NewCollection("1.23.3+k0s.1", "1.23.4+k0s.1") - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, "v1.23.3+k0s.1", c[0].String()) assert.Equal(t, "v1.23.4+k0s.1", c[1].String()) assert.Len(t, c, 2) @@ -20,55 +21,39 @@ func TestNewCollection(t *testing.T) { func TestSorting(t *testing.T) { c, err := NewCollection( + "1.22.3+k0s.0", "1.21.2+k0s.0", "1.21.2-beta.1+k0s.0", "1.21.1+k0s.1", "0.13.1", "v1.21.1+k0s.2", ) - assert.NoError(t, err) + require.NoError(t, err) sort.Sort(c) assert.Equal(t, "v0.13.1", c[0].String()) assert.Equal(t, "v1.21.1+k0s.1", c[1].String()) assert.Equal(t, "v1.21.1+k0s.2", c[2].String()) assert.Equal(t, "v1.21.2-beta.1+k0s.0", c[3].String()) assert.Equal(t, "v1.21.2+k0s.0", c[4].String()) + assert.Equal(t, "v1.22.3+k0s.0", c[5].String()) } func TestCollectionMarshalling(t *testing.T) { c, err := NewCollection("v1.0.0+k0s.0", "v1.0.1+k0s.0") - assert.NoError(t, err) + require.NoError(t, err) t.Run("JSON", func(t *testing.T) { jsonData, err := json.Marshal(c) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, `["v1.0.0+k0s.0","v1.0.1+k0s.0"]`, string(jsonData)) }) - - t.Run("YAML", func(t *testing.T) { - yamlData, err := c.MarshalYAML() - assert.NoError(t, err) - assert.Equal(t, []string{`"v1.0.0+k0s.0"`, `"v1.0.1+k0s.0"`}, yamlData) - }) } func TestCollectionUnmarshalling(t *testing.T) { t.Run("JSON", func(t *testing.T) { var c Collection err := json.Unmarshal([]byte(`["v1.0.0+k0s.1","v1.0.1+k0s.1"]`), &c) - assert.NoError(t, err) - assert.Equal(t, "v1.0.0+k0s.1", c[0].String()) - assert.Equal(t, "v1.0.1+k0s.1", c[1].String()) - }) - - t.Run("YAML", func(t *testing.T) { - var c Collection - - err := c.UnmarshalYAML(func(i interface{}) error { - *(i.(*[]string)) = []string{"v1.0.0+k0s.1", "v1.0.1+k0s.1"} - return nil - }) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, "v1.0.0+k0s.1", c[0].String()) assert.Equal(t, "v1.0.1+k0s.1", c[1].String()) }) @@ -82,13 +67,4 @@ func TestFailingCollectionUnmarshalling(t *testing.T) { err = json.Unmarshal([]byte(`["invalid_version"]`), &c) assert.Error(t, err) }) - - t.Run("YAML", func(t *testing.T) { - var c Collection - err := c.UnmarshalYAML(func(i interface{}) error { - *(i.(*[]string)) = []string{"invalid\n"} - return nil - }) - assert.Error(t, err) - }) } diff --git a/constraint_test.go b/constraint_test.go index 55a0528..f8d221f 100644 --- a/constraint_test.go +++ b/constraint_test.go @@ -117,9 +117,9 @@ func TestConstraint(t *testing.T) { for expected, versions := range tc.truthTable { t.Run(fmt.Sprintf("%t", expected), func(t *testing.T) { - for _, version := range versions { - t.Run(version, func(t *testing.T) { - assert.Equal(t, expected, c.Check(MustParse(version))) + for _, v := range versions { + t.Run(v, func(t *testing.T) { + assert.Equal(t, expected, c.Check(MustParse(v))) }) } }) @@ -138,7 +138,7 @@ func TestInvalidConstraint(t *testing.T) { } for _, invalidConstraint := range invalidConstraints { - _, err := newConstraint(invalidConstraint) + _, err := NewConstraint(invalidConstraint) assert.Error(t, err, "Expected error for invalid constraint: "+invalidConstraint) } } @@ -158,4 +158,3 @@ func TestString(t *testing.T) { assert.Equal(t, ">= 1.0.0, < 2.0.0", c.String()) } - diff --git a/go.mod b/go.mod index 6c81343..7c698d2 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,7 @@ module github.com/k0sproject/version go 1.17 -require ( - github.com/hashicorp/go-version v1.6.0 - github.com/stretchr/testify v1.8.4 -) +require github.com/stretchr/testify v1.8.4 require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index c83dcf2..fa4b6e6 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,10 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= -github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/version.go b/version.go index 31ff115..0f2faae 100644 --- a/version.go +++ b/version.go @@ -1,23 +1,167 @@ +// package version implements a k0s version type and functions to parse and compare versions package version import ( + "errors" "fmt" "path/filepath" - "sort" + "strconv" "strings" +) - goversion "github.com/hashicorp/go-version" +const ( + BaseUrl = "https://github.com/k0sproject/k0s/" + k0s = "k0s" + maxSegments = 3 ) -var BaseUrl = "https://github.com/k0sproject/k0s/" +// this contains the fields that can be compared using go's equality operator +type comparableFields struct { + // arrays (not slices) of basic types are comparable in go + segments [maxSegments]int + numSegments int + + pre string + isK0s bool + k0s int + meta string +} // Version is a k0s version type Version struct { - goversion.Version + comparableFields + s string +} + +// NewVersion returns a new Version object from a string representation of a k0s version +func NewVersion(v string) (*Version, error) { + if len(v) > 0 && v[0] == 'v' { + v = v[1:] + } + if v == "" { + return nil, errors.New("empty version") + } + for _, c := range v { + if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '+' && c != '-' && c != '.' { + // version can only contain a-z, 0-9, +, -, . + return nil, fmt.Errorf("can't contain character %c", c) + } + } + idx := strings.IndexAny(v, "-+") + var extra string + if idx >= 0 { + extra = v[idx:] + v = v[:idx] + } + segments := strings.Split(v, ".") + if len(segments) > maxSegments { + return nil, fmt.Errorf("too many segments (%d > %d", len(segments), maxSegments) + } + + version := &Version{comparableFields: comparableFields{numSegments: len(segments)}} + for idx, s := range segments { + segment, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return nil, fmt.Errorf("parsing segment '%s': %w", s, err) + } + version.segments[idx] = int(segment) + } + + if extra == "" { + return version, nil + } + + var minusIndex int + plusIndex := strings.Index(extra, "+") + if plusIndex == -1 { + minusIndex = strings.Index(extra, "-") + } else { + minusIndex = strings.Index(extra[:plusIndex], "-") + } + + if minusIndex != -1 { + if plusIndex == -1 { + // no meta + version.pre = extra[minusIndex+1:] + } else { + version.pre = extra[minusIndex+1 : plusIndex] + } + } + + if plusIndex == -1 { + return version, nil + } + + meta := extra[plusIndex+1:] + metaParts := strings.Split(meta, ".") + if len(metaParts) == 1 { + version.meta = meta + } else { + // parse the k0s. part from metadata + // and rebuild a new metadata string without it + var newMeta strings.Builder + for idx, part := range metaParts { + if part == k0s && idx < len(metaParts)-1 { + k0sV, err := strconv.ParseUint(metaParts[idx+1], 10, 32) + if err == nil { + version.isK0s = true + version.k0s = int(k0sV) + } + } else if idx > 0 && metaParts[idx-1] != k0s { + newMeta.WriteString(part) + if idx < len(metaParts)-1 { + newMeta.WriteString(".") + } + } + } + version.meta = newMeta.String() + } + + return version, nil +} + +// Segments returns the numerical segments of the k0s version (eg 1.2.3 from v1.2.3). +func (v *Version) Segments() []int { + return v.segments[:v.numSegments] +} + +// Prerelease returns the prerelease part of the k0s version (eg rc1 from v1.2.3-rc1). +func (v *Version) Prerelease() string { + return v.pre } -func pair(a, b *Version) Collection { - return Collection{a, b} +// IsK0s returns true if the version is a k0s version +func (v *Version) IsK0s() bool { + return v.isK0s +} + +// K0sVersion returns the k0s version (eg 4 from v1.2.3-k0s.4) +func (v *Version) K0sVersion() int { + return v.k0s +} + +// Metadata returns the metadata part of the k0s version (eg 123abc from v1.2.3+k0s.1.123abc) +func (v *Version) Metadata() string { + return v.meta +} + +// ComparableFields returns the comparable fields of the k0s version +func (v *Version) ComparableFields() comparableFields { + return v.comparableFields +} + +// Segments64 returns the numerical segments of the k0s version as int64 (eg 1.2.3 from v1.2.3). +func (v *Version) Segments64() []int64 { + segments := make([]int64, v.numSegments) + for i := 0; i < v.numSegments; i++ { + segments[i] = int64(v.segments[i]) + } + return segments +} + +// IsPrerelease returns true if the k0s version is a prerelease version +func (v *Version) IsPrerelease() bool { + return v.pre != "" } // String returns a v-prefixed string representation of the k0s version @@ -25,11 +169,110 @@ func (v *Version) String() string { if v == nil { return "" } - plain := strings.TrimPrefix(v.Version.String(), "v") - if plain == "" { + if v.s != "" { + return v.s + } + if v.numSegments == 0 { return "" } - return fmt.Sprintf("v%s", plain) + + var sb strings.Builder + sb.WriteRune('v') + for i := 0; i < v.numSegments; i++ { + sb.WriteString(strconv.Itoa(v.segments[i])) + if i < v.numSegments-1 { + sb.WriteRune('.') + } + } + if v.pre != "" { + sb.WriteRune('-') + sb.WriteString(v.pre) + } + if v.isK0s || v.meta != "" { + sb.WriteRune('+') + } + if v.isK0s { + sb.WriteString(k0s) + sb.WriteRune('.') + sb.WriteString(strconv.Itoa(v.k0s)) + if v.meta != "" { + sb.WriteRune('.') + } + } + if v.meta != "" { + sb.WriteString(v.meta) + } + + v.s = sb.String() + return v.s +} + +// Equal returns true if the k0s version is equal to the supplied version +func (v *Version) Equal(b *Version) bool { + if v == nil || b == nil { + // nil versions are not equal + return false + } + + if v.s != "" && b.s != "" { + // compare strings if both versions are already stringified + return v.s == b.s + } + + // compare comparable fields using go's equality operator + return v.comparableFields == b.comparableFields +} + +// Compare returns 0 if the k0s version is equal to the supplied version, 1 if it's greater and -1 if it's lower +func (v *Version) Compare(b *Version) int { + if v.Equal(b) { + return 0 + } + for i := 0; i < maxSegments; i++ { + if v.numSegments >= i+1 && b.numSegments >= i+1 { + if v.segments[i] > b.segments[i] { + return 1 + } + if v.segments[i] < b.segments[i] { + return -1 + } + } + if i >= v.numSegments && i < b.numSegments { + // b has more segments, so it's greater + return -1 + } + if i >= b.numSegments && i < v.numSegments { + // v has more segments, so it's greater + return 1 + } + } + if v.pre == "" && b.pre != "" { + return 1 + } + if v.pre != "" && b.pre == "" { + return -1 + } + // segments are equal, so compare pre + if v.pre < b.pre { + return -1 + } + if v.pre > b.pre { + return 1 + } + if v.isK0s && !b.isK0s { + return 1 + } + if !v.isK0s && b.isK0s { + return -1 + } + if v.k0s > b.k0s { + return 1 + } + if v.k0s < b.k0s { + return -1 + } + // meta should not affect precedence + return 0 } func (v *Version) urlString() string { @@ -64,114 +307,66 @@ func (v *Version) DocsURL() string { return fmt.Sprintf("https://docs.k0sproject.io/%s/", v.String()) } -// Equal returns true if the version is equal to the supplied version -func (v *Version) Equal(b *Version) bool { - return v.String() == b.String() -} - // GreaterThan returns true if the version is greater than the supplied version func (v *Version) GreaterThan(b *Version) bool { - if v.String() == b.String() { - return false - } - p := pair(v, b) - sort.Sort(p) - return v.String() == p[1].String() + return v.Compare(b) == 1 } // LessThan returns true if the version is lower than the supplied version func (v *Version) LessThan(b *Version) bool { - if v.String() == b.String() { - return false - } - return !v.GreaterThan(b) + return v.Compare(b) == -1 } // GreaterThanOrEqual returns true if the version is greater than the supplied version or equal func (v *Version) GreaterThanOrEqual(b *Version) bool { - return v.Equal(b) || v.GreaterThan(b) + return v.Compare(b) >= 0 } // LessThanOrEqual returns true if the version is lower than the supplied version or equal func (v *Version) LessThanOrEqual(b *Version) bool { - return v.Equal(b) || v.LessThan(b) + return v.Compare(b) <= 0 } -// Compare compares two versions and returns one of the integers: -1, 0 or 1 (less than, equal, greater than) -func (v *Version) Compare(b *Version) int { - c := v.Version.Compare(&b.Version) - if c != 0 { - return c - } - - vA := v.String() - - // go to plain string comparison - s := []string{vA, b.String()} - sort.Strings(s) - - if vA == s[0] { - return -1 - } - - return 1 +// MarshalText implements the encoding.TextMarshaler interface (used as fallback by encoding/json and yaml.v3). +func (v *Version) MarshalText() ([]byte, error) { + return []byte(v.String()), nil } -// MarshalJSON implements the json.Marshaler interface. -func (v *Version) MarshalJSON() ([]byte, error) { - if v == nil { - return []byte("null"), nil +// UnmarshalText implements the encoding.TextUnmarshaler interface (used as fallback by encoding/json and yaml.v3). +func (v *Version) UnmarshalText(text []byte) error { + if len(text) == 0 { + *v = Version{} + return nil + } + version, err := NewVersion(string(text)) + if err != nil { + return err } + *v = *version - return []byte(fmt.Sprintf("\"%s\"", v.String())), nil + return nil } -// MarshalYAML implements the yaml.Marshaler interface. +// MarshalYAML implements the yaml.v2 Marshaler interface. func (v *Version) MarshalYAML() (interface{}, error) { - if v == nil { + if v == nil || v.numSegments == 0 { return nil, nil } - return v.String(), nil } -func (v *Version) unmarshal(f func(interface{}) error) error { - var s string - if err := f(&s); err != nil { - return fmt.Errorf("failed to decode input: %w", err) - } - if s == "" { - return nil - } - newV, err := NewVersion(s) - if err != nil { - return fmt.Errorf("failed to unmarshal version: %w", err) - } - *v = *newV - return nil -} - -// UnmarshalYAML implements the yaml.Unmarshaler interface. -func (v *Version) UnmarshalYAML(f func(interface{}) error) error { - return v.unmarshal(f) -} - -// UnmarshalJSON implements the json.Unmarshaler interface. -func (v *Version) UnmarshalJSON(b []byte) error { - s := strings.TrimSpace(strings.Trim(string(b), "\"")) - if s == "" || s == "null" { - // go doesn't allow to set nil to a non-pointer struct field, so the result - // is going to be an empty struct - return nil +// UnmarshalYAML implements the yaml.v2 Unmarshaler interface. +func (v *Version) UnmarshalYAML(unmarshal func(interface{}) error) error { + var text string + if err := unmarshal(&text); err != nil { + return err } - return v.unmarshal(func(i interface{}) error { - *(i.(*string)) = s - return nil - }) + return v.UnmarshalText([]byte(text)) } +// IsZero returns true if the version is nil or empty func (v *Version) IsZero() bool { - return v == nil || v.String() == "" + return v == nil || v.numSegments == 0 } // Satisfies returns true if the version satisfies the supplied constraint @@ -179,16 +374,6 @@ func (v *Version) Satisfies(constraint Constraints) bool { return constraint.Check(v) } -// NewVersion returns a new Version created from the supplied string or an error if the string is not a valid version number -func NewVersion(v string) (*Version, error) { - n, err := goversion.NewVersion(strings.TrimPrefix(v, "v")) - if err != nil { - return nil, err - } - - return &Version{Version: *n}, nil -} - // MustParse is like NewVersion but panics if the version cannot be parsed. // It simplifies safe initialization of global variables. func MustParse(v string) *Version {