diff --git a/version.go b/version.go index ea1aa1e..9f5cc21 100644 --- a/version.go +++ b/version.go @@ -40,6 +40,63 @@ const ( adhoc = releasePhase(6) ) +var parsePatterns = []*regexp.Regexp{ + // these are roughly in "how often we expect to see them" order + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))(?:-fips)?$`), + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Palpha|beta|rc|cloudonly)\.(?P[0-9]+)(?:-fips)?$`), + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?P(?:[1-9][0-9]*|0))-g[a-f0-9]+(?:-fips)?$`), + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Palpha|beta|rc|cloudonly)\.(?P[0-9]+)-(?P(?:[1-9][0-9]*|0))-g[a-f0-9]+(?:-fips)?$`), + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Palpha|beta|rc|cloudonly)\.(?P[0-9]+)-cloudonly(-rc|\.)(?P(?:[1-9][0-9]*|0))$`), + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Pcloudonly)-rc(?P[0-9]+)$`), + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Pcloudonly)(?P[0-9]+)?$`), + + // vX.Y.Z- will sort after the corresponding "plain" vX.Y.Z version + regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?P[-a-zA-Z0-9\.\+]+)$`), + + // sha256::latest-vX.Y-build will sort just after vX.Y.0, but before vX.Y.1 + regexp.MustCompile(`^sha256:(?P[^:]+):latest-v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)-build$`), +} + +var formatPlaceholderRe = regexp.MustCompile("%[^%XYZpPosn]") + +func preReleasePhaseByName(name string) (releasePhase, bool) { + switch name { + case "alpha": + return alpha, true + case "beta": + return beta, true + case "rc": + return rc, true + case "cloudonly": + return cloudonly, true + default: + return 0, false + } +} + +func releasePhaseString(p releasePhase) string { + switch p { + case alpha: + return "alpha" + case beta: + return "beta" + case rc: + return "rc" + case cloudonly: + return "cloudonly" + default: + return "" + } +} + +func submatch(pat *regexp.Regexp, matches []string, group string) string { + index := pat.SubexpIndex(group) + if index == -1 { + return "" + } + return matches[index] +} + // Version represents a CockroachDB (binary) version. Versions consist of three parts: // a major version, written as "vX.Y" (which is typically the year and release number // within the year), a patch version (the "Z" in "vX.Y.Z"), and sometimes one or more @@ -85,26 +142,16 @@ func (v Version) Patch() int { // - %n: adhoc build ordinal (eg the 12 in "v24.1.0-12-gabcdef") // - %%: literal "%" func (v Version) Format(formatStr string) string { - placeholderRe := regexp.MustCompile("%[^%XYZpPosn]") - placeholders := placeholderRe.FindAllString(formatStr, -1) + placeholders := formatPlaceholderRe.FindAllString(formatStr, -1) if len(placeholders) > 0 { panic(fmt.Sprintf("unknown placeholders in format string: %s", strings.Join(placeholders, ", "))) } - phaseName := map[releasePhase]string{ - alpha: "alpha", - beta: "beta", - rc: "rc", - cloudonly: "cloudonly", - adhoc: "", - stable: "", - } - formatStr = strings.ReplaceAll(formatStr, "%X", strconv.Itoa(v.year)) formatStr = strings.ReplaceAll(formatStr, "%Y", strconv.Itoa(v.ordinal)) formatStr = strings.ReplaceAll(formatStr, "%Z", strconv.Itoa(v.patch)) formatStr = strings.ReplaceAll(formatStr, "%p", strconv.Itoa(int(v.phase))) - formatStr = strings.ReplaceAll(formatStr, "%P", phaseName[v.phase]) + formatStr = strings.ReplaceAll(formatStr, "%P", releasePhaseString(v.phase)) formatStr = strings.ReplaceAll(formatStr, "%o", strconv.Itoa(v.phaseOrdinal)) formatStr = strings.ReplaceAll(formatStr, "%s", strconv.Itoa(v.phaseSubOrdinal)) formatStr = strings.ReplaceAll(formatStr, "%n", strconv.Itoa(v.customOrdinal)) @@ -203,83 +250,54 @@ func (v Version) SafeFormat(p redact.SafePrinter, _ rune) { // Parse creates a version from a string. func Parse(str string) (Version, error) { - // these are roughly in "how often we expect to see them" order - patterns := []*regexp.Regexp{ - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))(?:-fips)?$`), - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Palpha|beta|rc|cloudonly)\.(?P[0-9]+)(?:-fips)?$`), - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?P(?:[1-9][0-9]*|0))-g[a-f0-9]+(?:-fips)?$`), - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Palpha|beta|rc|cloudonly).(?P[0-9]+)-(?P(?:[1-9][0-9]*|0))-g[a-f0-9]+(?:-fips)?$`), - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Palpha|beta|rc|cloudonly).(?P[0-9]+)-cloudonly(-rc|\.)(?P(?:[1-9][0-9]*|0))$`), - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Pcloudonly)-rc(?P[0-9]+)$`), - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?Pcloudonly)(?P[0-9]+)?$`), - - // vX.Y.Z- will sort after the corresponding "plain" vX.Y.Z version - regexp.MustCompile(`^v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)\.(?P(?:[1-9][0-9]*|0))-(?P[-a-zA-Z0-9\.\+]+)$`), - - // sha256::latest-vX.Y-build will sort just after vX.Y.0, but before vX.Y.1 - regexp.MustCompile(`^sha256:(?P[^:]+):latest-v(?P[1-9][0-9]*)\.(?P[1-9][0-9]*)-build$`), - } - - preReleasePhase := map[string]releasePhase{ - "alpha": alpha, - "beta": beta, - "rc": rc, - "cloudonly": cloudonly, - } + v := Version{raw: str, phase: stable} - submatch := func(pat *regexp.Regexp, matches []string, group string) string { - index := pat.SubexpIndex(group) - if index == -1 { - return "" + for _, pat := range parsePatterns { + matches := pat.FindStringSubmatch(str) + if matches == nil { + continue } - return matches[index] - } - - v := Version{raw: str, phase: stable} - for _, pat := range patterns { - if pat.MatchString(str) { - matches := pat.FindStringSubmatch(str) + // all patterns have vX.Y + v.year, _ = strconv.Atoi(submatch(pat, matches, "year")) + v.ordinal, _ = strconv.Atoi(submatch(pat, matches, "ordinal")) - // all patterns have vX.Y - v.year, _ = strconv.Atoi(submatch(pat, matches, "year")) - v.ordinal, _ = strconv.Atoi(submatch(pat, matches, "ordinal")) + // most have vX.Y.Z + if patch := submatch(pat, matches, "patch"); patch != "" { + v.patch, _ = strconv.Atoi(patch) + } - // most have vX.Y.Z - if patch := submatch(pat, matches, "patch"); patch != "" { - v.patch, _ = strconv.Atoi(patch) + // handle -alpha.1, -rc.3, etc + if phase := submatch(pat, matches, "phase"); phase != "" { + // Unreachable today: the regexes only match known phase names. + // Kept as a safeguard against future pattern changes. + if phaseName, ok := preReleasePhaseByName(phase); !ok { + return Version{}, errors.Newf("unknown phase '%s'", phase) + } else { + v.phase = phaseName } - // handle -alpha.1, -rc.3, etc - if phase := submatch(pat, matches, "phase"); phase != "" { - if phaseName, ok := preReleasePhase[phase]; !ok { - return Version{}, errors.Newf("unknown phase '%s", phaseName) - } else { - v.phase = phaseName - } - - if ord := submatch(pat, matches, "phaseOrdinal"); ord != "" { - v.phaseOrdinal, _ = strconv.Atoi(ord) - } - // -beta.1-cloudonly-rc1 - if subOrd := submatch(pat, matches, "phaseSubOrdinal"); subOrd != "" { - v.phaseSubOrdinal, _ = strconv.Atoi(subOrd) - } + if ord := submatch(pat, matches, "phaseOrdinal"); ord != "" { + v.phaseOrdinal, _ = strconv.Atoi(ord) } - - // adhoc/adhoc builds, eg -10-g7890abcd - if ord := submatch(pat, matches, "customOrdinal"); ord != "" { - v.customOrdinal, _ = strconv.Atoi(ord) + // -beta.1-cloudonly-rc1 + if subOrd := submatch(pat, matches, "phaseSubOrdinal"); subOrd != "" { + v.phaseSubOrdinal, _ = strconv.Atoi(subOrd) } + } - // arbitrary/adhoc build tags; we have these old versions and need to parse them - if adhocLabel := submatch(pat, matches, "adhocLabel"); adhocLabel != "" { - v.phase = adhoc - v.adhocLabel = adhocLabel - } + // adhoc/adhoc builds, eg -10-g7890abcd + if ord := submatch(pat, matches, "customOrdinal"); ord != "" { + v.customOrdinal, _ = strconv.Atoi(ord) + } - return v, nil + // arbitrary/adhoc build tags; we have these old versions and need to parse them + if adhocLabel := submatch(pat, matches, "adhocLabel"); adhocLabel != "" { + v.phase = adhoc + v.adhocLabel = adhocLabel } + + return v, nil } err := errors.Errorf("invalid version string '%s'", str) diff --git a/version_test.go b/version_test.go index fb1dcba..29be1bc 100644 --- a/version_test.go +++ b/version_test.go @@ -252,6 +252,9 @@ func TestParse(t *testing.T) { "v1.2.3+metadata", "v1.2.3+metadata-with-hyphen", "v1.2.3+metadata.with.dots", + "v24.1.0-alpha:1", + "v24.1.0-rc_1-12-gabcdef12", + "v24.1.0-beta/1-cloudonly.2", } for _, str := range testData { _, err := Parse(str) @@ -368,6 +371,100 @@ func TestParse(t *testing.T) { }) } +func TestFormatPlaceholders(t *testing.T) { + for _, tc := range []struct { + rawVersion string + wantPhase string + }{ + {rawVersion: "v24.1.0-alpha.2", wantPhase: "alpha"}, + {rawVersion: "v24.1.0-beta.2", wantPhase: "beta"}, + {rawVersion: "v24.1.0-rc.2", wantPhase: "rc"}, + {rawVersion: "v24.1.0-cloudonly.2", wantPhase: "cloudonly"}, + {rawVersion: "v24.1.0", wantPhase: ""}, + {rawVersion: "v24.1.0-build-tag", wantPhase: ""}, + } { + t.Run(tc.rawVersion, func(t *testing.T) { + v := MustParse(tc.rawVersion) + require.Equal(t, tc.wantPhase, v.Format("%P")) + }) + } + + require.PanicsWithValue(t, "unknown placeholders in format string: %Q", func() { + _ = MustParse("v24.1.0").Format("v%X.%Y.%Z-%Q") + }) +} + +func TestParseSpecificPatternWins(t *testing.T) { + for _, tc := range []struct { + raw string + phase releasePhase + phaseOrdinal int + phaseSubOrdinal int + customOrdinal int + adhocLabel string + }{ + { + raw: "v23.2.0-cloudonly2", + phase: cloudonly, + phaseOrdinal: 2, + }, + { + raw: "v23.2.0-cloudonly", + phase: cloudonly, + phaseOrdinal: 0, + }, + { + raw: "v23.2.0-cloudonly.1", + phase: cloudonly, + phaseOrdinal: 1, + }, + { + raw: "v24.1.0-rc.1-12-gabcdef56", + phase: rc, + phaseOrdinal: 1, + customOrdinal: 12, + }, + { + raw: "v24.1.0-beta.1-cloudonly.2", + phase: beta, + phaseOrdinal: 1, + phaseSubOrdinal: 2, + }, + } { + t.Run(tc.raw, func(t *testing.T) { + v := MustParse(tc.raw) + + require.Equal(t, tc.phase, v.phase) + require.Equal(t, tc.phaseOrdinal, v.phaseOrdinal) + require.Equal(t, tc.phaseSubOrdinal, v.phaseSubOrdinal) + require.Equal(t, tc.customOrdinal, v.customOrdinal) + require.Equal(t, tc.adhocLabel, v.adhocLabel) + require.False(t, v.IsAdhocBuild()) + }) + } +} + +func BenchmarkParse(b *testing.B) { + testData := []string{ + "v19.1.11", + "v21.1.0-1-g9cbe7c5281", + "v22.2.10-1-g7b8322d67c-fips", + "v23.1.0-alpha.1-1643-gdf8e73734e-fips", + "v23.2.0-rc.2-cloudonly-rc2", + "v24.3.0-alpha.1-cloudonly.1", + "v23.1.0-swenson-mr-4", + "sha256:6bbf843734d11db9cc5eb8ea77f6974032e17ad216c91ccecfaf52a4890eaa11:latest-v22.2-build", + } + + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, err := Parse(testData[i%len(testData)]) + if err != nil { + b.Fatal(err) + } + } +} + func TestVersionCompare(t *testing.T) { const aEqualsB = "equal" const aLessThanB = "less than"