From bb60f0415a3a2747655347e4bd4c9dc5684d5966 Mon Sep 17 00:00:00 2001 From: Dan Winship Date: Fri, 21 Oct 2016 12:19:14 -0400 Subject: [PATCH] Add a package for handling version numbers (including non-semvers) --- hack/.linted_packages | 1 + pkg/util/version/BUILD | 26 ++++ pkg/util/version/doc.go | 18 +++ pkg/util/version/version.go | 236 ++++++++++++++++++++++++++++ pkg/util/version/version_test.go | 259 +++++++++++++++++++++++++++++++ test/test_owners.csv | 1 + 6 files changed, 541 insertions(+) create mode 100644 pkg/util/version/BUILD create mode 100644 pkg/util/version/doc.go create mode 100644 pkg/util/version/version.go create mode 100644 pkg/util/version/version_test.go diff --git a/hack/.linted_packages b/hack/.linted_packages index 4b32ccac3cc..6740498cb50 100644 --- a/hack/.linted_packages +++ b/hack/.linted_packages @@ -266,6 +266,7 @@ pkg/util/ratelimit pkg/util/replicaset pkg/util/restoptions pkg/util/validation/field +pkg/util/version pkg/util/workqueue pkg/version/prometheus pkg/volume diff --git a/pkg/util/version/BUILD b/pkg/util/version/BUILD new file mode 100644 index 00000000000..5fadf6dd75a --- /dev/null +++ b/pkg/util/version/BUILD @@ -0,0 +1,26 @@ +package(default_visibility = ["//visibility:public"]) + +licenses(["notice"]) + +load( + "@io_bazel_rules_go//go:def.bzl", + "go_library", + "go_test", +) + +go_library( + name = "go_default_library", + srcs = [ + "doc.go", + "version.go", + ], + tags = ["automanaged"], +) + +go_test( + name = "go_default_test", + srcs = ["version_test.go"], + library = "go_default_library", + tags = ["automanaged"], + deps = [], +) diff --git a/pkg/util/version/doc.go b/pkg/util/version/doc.go new file mode 100644 index 00000000000..ebe43152e8e --- /dev/null +++ b/pkg/util/version/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package version provides utilities for version number comparisons +package version // import "k8s.io/kubernetes/pkg/util/version" diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go new file mode 100644 index 00000000000..327f2e67f40 --- /dev/null +++ b/pkg/util/version/version.go @@ -0,0 +1,236 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package version + +import ( + "bytes" + "fmt" + "regexp" + "strconv" + "strings" +) + +// Version is an opqaue representation of a version number +type Version struct { + components []uint + semver bool + preRelease string + buildMetadata string +} + +var ( + // versionMatchRE splits a version string into numeric and "extra" parts + versionMatchRE = regexp.MustCompile(`^\s*v?([0-9]+(?:\.[0-9]+)*)(.*)*$`) + // extraMatchRE splits the "extra" part of versionMatchRE into semver pre-release and build metadata; it does not validate the "no leading zeroes" constraint for pre-release + extraMatchRE = regexp.MustCompile(`^(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?\s*$`) +) + +func parse(str string, semver bool) (*Version, error) { + parts := versionMatchRE.FindStringSubmatch(str) + if parts == nil { + return nil, fmt.Errorf("could not parse %q as version", str) + } + numbers, extra := parts[1], parts[2] + + components := strings.Split(numbers, ".") + if (semver && len(components) != 3) || (!semver && len(components) < 2) { + return nil, fmt.Errorf("illegal version string %q", str) + } + + v := &Version{ + components: make([]uint, len(components)), + semver: semver, + } + for i, comp := range components { + if (i == 0 || semver) && strings.HasPrefix(comp, "0") && comp != "0" { + return nil, fmt.Errorf("illegal zero-prefixed version component %q in %q", comp, str) + } + num, err := strconv.ParseUint(comp, 10, 0) + if err != nil { + return nil, fmt.Errorf("illegal non-numeric version component %q in %q: %v", comp, str, err) + } + v.components[i] = uint(num) + } + + if semver && extra != "" { + extraParts := extraMatchRE.FindStringSubmatch(extra) + if extraParts == nil { + return nil, fmt.Errorf("could not parse pre-release/metadata (%s) in version %q", extra, str) + } + v.preRelease, v.buildMetadata = extraParts[1], extraParts[2] + + for _, comp := range strings.Split(v.preRelease, ".") { + if _, err := strconv.ParseUint(comp, 10, 0); err == nil { + if strings.HasPrefix(comp, "0") && comp != "0" { + return nil, fmt.Errorf("illegal zero-prefixed version component %q in %q", comp, str) + } + } + } + } + + return v, nil +} + +// ParseGeneric parses a "generic" version string. The version string must consist of two +// or more dot-separated numeric fields (the first of which can't have leading zeroes), +// followed by arbitrary uninterpreted data (which need not be separated from the final +// numeric field by punctuation). For convenience, leading and trailing whitespace is +// ignored, and the version can be preceded by the letter "v". See also ParseSemantic. +func ParseGeneric(str string) (*Version, error) { + return parse(str, false) +} + +// MustParseGeneric is like ParseGeneric except that it panics on error +func MustParseGeneric(str string) *Version { + v, err := ParseGeneric(str) + if err != nil { + panic(err) + } + return v +} + +// ParseSemantic parses a version string that exactly obeys the syntax and semantics of +// the "Semantic Versioning" specification (http://semver.org/) (although it ignores +// leading and trailing whitespace, and allows the version to be preceded by "v"). For +// version strings that are not guaranteed to obey the Semantic Versioning syntax, use +// ParseGeneric. +func ParseSemantic(str string) (*Version, error) { + return parse(str, true) +} + +// MustParseSemantic is like ParseSemantic except that it panics on error +func MustParseSemantic(str string) *Version { + v, err := ParseSemantic(str) + if err != nil { + panic(err) + } + return v +} + +// BuildMetadata returns the build metadata, if v is a Semantic Version, or "" +func (v *Version) BuildMetadata() string { + return v.buildMetadata +} + +// String converts a Version back to a string; note that for versions parsed with +// ParseGeneric, this will not include the trailing uninterpreted portion of the version +// number. +func (v *Version) String() string { + var buffer bytes.Buffer + + for i, comp := range v.components { + if i > 0 { + buffer.WriteString(".") + } + buffer.WriteString(fmt.Sprintf("%d", comp)) + } + if v.preRelease != "" { + buffer.WriteString("-") + buffer.WriteString(v.preRelease) + } + if v.buildMetadata != "" { + buffer.WriteString("+") + buffer.WriteString(v.buildMetadata) + } + + return buffer.String() +} + +// compareInternal returns -1 if v is less than other, 1 if it is greater than other, or 0 +// if they are equal +func (v *Version) compareInternal(other *Version) int { + for i := range v.components { + switch { + case i >= len(other.components): + if v.components[i] != 0 { + return 1 + } + case other.components[i] < v.components[i]: + return 1 + case other.components[i] > v.components[i]: + return -1 + } + } + + if !v.semver || !other.semver { + return 0 + } + + switch { + case v.preRelease == "" && other.preRelease != "": + return 1 + case v.preRelease != "" && other.preRelease == "": + return -1 + case v.preRelease == other.preRelease: // includes case where both are "" + return 0 + } + + vPR := strings.Split(v.preRelease, ".") + oPR := strings.Split(other.preRelease, ".") + for i := range vPR { + if i >= len(oPR) { + return 1 + } + vNum, err := strconv.ParseUint(vPR[i], 10, 0) + if err == nil { + oNum, err := strconv.ParseUint(oPR[i], 10, 0) + if err == nil { + switch { + case oNum < vNum: + return 1 + case oNum > vNum: + return -1 + default: + continue + } + } + } + if oPR[i] < vPR[i] { + return 1 + } else if oPR[i] > vPR[i] { + return -1 + } + } + + return 0 +} + +// AtLeast tests if a version is at least equal to a given minimum version. If both +// Versions are Semantic Versions, this will use the Semantic Version comparison +// algorithm. Otherwise, it will compare only the numeric components, with non-present +// components being considered "0" (ie, "1.4" is equal to "1.4.0"). +func (v *Version) AtLeast(min *Version) bool { + return v.compareInternal(min) != -1 +} + +// LessThan tests if a version is less than a given version. (It is exactly the opposite +// of AtLeast, for situations where asking "is v too old?" makes more sense than asking +// "is v new enough?".) +func (v *Version) LessThan(other *Version) bool { + return v.compareInternal(other) == -1 +} + +// Compare compares v against a version string (which will be parsed as either Semantic +// or non-Semantic depending on v). On success it returns -1 if v is less than other, 1 if +// it is greater than other, or 0 if they are equal. +func (v *Version) Compare(other string) (int, error) { + ov, err := parse(other, v.semver) + if err != nil { + return 0, err + } + return v.compareInternal(ov), nil +} diff --git a/pkg/util/version/version_test.go b/pkg/util/version/version_test.go new file mode 100644 index 00000000000..555c59b4b75 --- /dev/null +++ b/pkg/util/version/version_test.go @@ -0,0 +1,259 @@ +/* +Copyright 2016 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package version + +import ( + "fmt" + "testing" +) + +type testItem struct { + version string + unparsed string + equalsPrev bool +} + +func testOne(v *Version, item, prev testItem) error { + str := v.String() + if item.unparsed == "" { + if str != item.version { + return fmt.Errorf("bad round-trip: %q -> %q", item.version, str) + } + } else { + if str != item.unparsed { + return fmt.Errorf("bad unparse: %q -> %q, expected %q", item.version, str, item.unparsed) + } + } + + if prev.version != "" { + cmp, err := v.Compare(prev.version) + if err != nil { + return fmt.Errorf("unexpected parse error: %v", err) + } + switch { + case cmp == -1: + return fmt.Errorf("unexpected ordering %q < %q", item.version, prev.version) + case cmp == 0 && !item.equalsPrev: + return fmt.Errorf("unexpected comparison %q == %q", item.version, item.version) + case cmp == 1 && item.equalsPrev: + return fmt.Errorf("unexpected comparison %q != %q", item.version, item.version) + } + } + + return nil +} + +func TestSemanticVersions(t *testing.T) { + tests := []testItem{ + // This is every version string that appears in the 2.0 semver spec, + // sorted in strictly increasing order except as noted. + {version: "0.1.0"}, + {version: "1.0.0-0.3.7"}, + {version: "1.0.0-alpha"}, + {version: "1.0.0-alpha+001", equalsPrev: true}, + {version: "1.0.0-alpha.1"}, + {version: "1.0.0-alpha.beta"}, + {version: "1.0.0-beta"}, + {version: "1.0.0-beta+exp.sha.5114f85", equalsPrev: true}, + {version: "1.0.0-beta.2"}, + {version: "1.0.0-beta.11"}, + {version: "1.0.0-rc.1"}, + {version: "1.0.0-x.7.z.92"}, + {version: "1.0.0"}, + {version: "1.0.0+20130313144700", equalsPrev: true}, + {version: "1.9.0"}, + {version: "1.10.0"}, + {version: "1.11.0"}, + {version: "2.0.0"}, + {version: "2.1.0"}, + {version: "2.1.1"}, + {version: "42.0.0"}, + + // We also allow whitespace and "v" prefix + {version: " 42.0.0", unparsed: "42.0.0", equalsPrev: true}, + {version: "\t42.0.0 ", unparsed: "42.0.0", equalsPrev: true}, + {version: "43.0.0-1", unparsed: "43.0.0-1"}, + {version: "43.0.0-1 ", unparsed: "43.0.0-1", equalsPrev: true}, + {version: "v43.0.0-1", unparsed: "43.0.0-1", equalsPrev: true}, + {version: " v43.0.0", unparsed: "43.0.0"}, + {version: " 43.0.0 ", unparsed: "43.0.0", equalsPrev: true}, + } + + var prev testItem + for _, item := range tests { + v, err := ParseSemantic(item.version) + if err != nil { + t.Errorf("unexpected parse error: %v", err) + continue + } + err = testOne(v, item, prev) + if err != nil { + t.Errorf("%v", err) + } + prev = item + } +} + +func TestBadSemanticVersions(t *testing.T) { + tests := []string{ + // "MUST take the form X.Y.Z" + "1", + "1.2", + "1.2.3.4", + ".2.3", + "1..3", + "1.2.", + "", + "..", + // "where X, Y, and Z are non-negative integers" + "-1.2.3", + "1.-2.3", + "1.2.-3", + "1a.2.3", + "1.2a.3", + "1.2.3a", + "a1.2.3", + "a.b.c", + "1 .2.3", + "1. 2.3", + // "and MUST NOT contain leading zeroes." + "01.2.3", + "1.02.3", + "1.2.03", + // "[pre-release] identifiers MUST comprise only ASCII alphanumerics and hyphen" + "1.2.3-/", + // "[pre-release] identifiers MUST NOT be empty" + "1.2.3-", + "1.2.3-.", + "1.2.3-foo.", + "1.2.3-.foo", + // "Numeric [pre-release] identifiers MUST NOT include leading zeroes" + "1.2.3-01", + // "[build metadata] identifiers MUST comprise only ASCII alphanumerics and hyphen" + "1.2.3+/", + // "[build metadata] identifiers MUST NOT be empty" + "1.2.3+", + "1.2.3+.", + "1.2.3+foo.", + "1.2.3+.foo", + + // whitespace/"v"-prefix checks + "v 1.2.3", + "vv1.2.3", + } + + for i := range tests { + _, err := ParseSemantic(tests[i]) + if err == nil { + t.Errorf("unexpected success parsing invalid semver %q", tests[i]) + } + } +} + +func TestGenericVersions(t *testing.T) { + tests := []testItem{ + // This is all of the strings from TestSemanticVersions, plus some strings + // from TestBadSemanticVersions that should parse as generic versions, + // plus some additional strings. + {version: "0.1.0", unparsed: "0.1.0"}, + {version: "1.0.0-0.3.7", unparsed: "1.0.0"}, + {version: "1.0.0-alpha", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0-alpha+001", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0-alpha.1", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0-alpha.beta", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0.beta", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0-beta+exp.sha.5114f85", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0.beta.2", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0.beta.11", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0.rc.1", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0-x.7.z.92", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.0.0+20130313144700", unparsed: "1.0.0", equalsPrev: true}, + {version: "1.2", unparsed: "1.2"}, + {version: "1.2a.3", unparsed: "1.2", equalsPrev: true}, + {version: "1.2.3", unparsed: "1.2.3"}, + {version: "1.2.3a", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.3-foo.", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.3-.foo", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.3-01", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.3+", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.3+foo.", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.3+.foo", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.02.3", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.03", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.003", unparsed: "1.2.3", equalsPrev: true}, + {version: "1.2.3.4", unparsed: "1.2.3.4"}, + {version: "1.2.3.4b3", unparsed: "1.2.3.4", equalsPrev: true}, + {version: "1.2.3.4.5", unparsed: "1.2.3.4.5"}, + {version: "1.9.0", unparsed: "1.9.0"}, + {version: "1.10.0", unparsed: "1.10.0"}, + {version: "1.11.0", unparsed: "1.11.0"}, + {version: "2.0.0", unparsed: "2.0.0"}, + {version: "2.1.0", unparsed: "2.1.0"}, + {version: "2.1.1", unparsed: "2.1.1"}, + {version: "42.0.0", unparsed: "42.0.0"}, + {version: " 42.0.0", unparsed: "42.0.0", equalsPrev: true}, + {version: "\t42.0.0 ", unparsed: "42.0.0", equalsPrev: true}, + {version: "42.0.0-1", unparsed: "42.0.0", equalsPrev: true}, + {version: "42.0.0-1 ", unparsed: "42.0.0", equalsPrev: true}, + {version: "v42.0.0-1", unparsed: "42.0.0", equalsPrev: true}, + {version: " v43.0.0", unparsed: "43.0.0"}, + {version: " 43.0.0 ", unparsed: "43.0.0", equalsPrev: true}, + } + + var prev testItem + for _, item := range tests { + v, err := ParseGeneric(item.version) + if err != nil { + t.Errorf("unexpected parse error: %v", err) + continue + } + err = testOne(v, item, prev) + if err != nil { + t.Errorf("%v", err) + } + prev = item + } +} + +func TestBadGenericVersions(t *testing.T) { + tests := []string{ + "1", + "01.2.3", + "-1.2.3", + "1.-2.3", + ".2.3", + "1..3", + "1a.2.3", + "a1.2.3", + "1 .2.3", + "1. 2.3", + "1.bob", + "bob", + "v 1.2.3", + "vv1.2.3", + "", + ".", + } + + for i := range tests { + _, err := ParseGeneric(tests[i]) + if err == nil { + t.Errorf("unexpected success parsing invalid version %q", tests[i]) + } + } +} diff --git a/test/test_owners.csv b/test/test_owners.csv index d0b49f260c1..68a387671e2 100644 --- a/test/test_owners.csv +++ b/test/test_owners.csv @@ -872,6 +872,7 @@ k8s.io/kubernetes/pkg/util/testing,jlowdermilk,1 k8s.io/kubernetes/pkg/util/threading,roberthbailey,1 k8s.io/kubernetes/pkg/util/validation,Q-Lee,1 k8s.io/kubernetes/pkg/util/validation/field,timstclair,1 +k8s.io/kubernetes/pkg/util/version,danwinship,0 k8s.io/kubernetes/pkg/util/wait,Q-Lee,1 k8s.io/kubernetes/pkg/util/workqueue,mtaufen,1 k8s.io/kubernetes/pkg/util/wsstream,timothysc,1