diff --git a/internal/command/check_version.go b/internal/command/check_version.go new file mode 100644 index 0000000..0c1e8d4 --- /dev/null +++ b/internal/command/check_version.go @@ -0,0 +1,49 @@ +// Copyright 2024 Humanitec +// +// 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 command + +import ( + "github.com/score-spec/score-k8s/internal/version" + "github.com/spf13/cobra" +) + +var checkVersionCmd = &cobra.Command{ + Use: "check-version [constraint]", + Short: "Assert that the version of score-k8s matches the required constraint", + Long: `score-k8s is commonly used in Makefiles and CI pipelines which may depend on a particular functionality +or a particular default provisioner provided by score-k8s init. This command provides a common way to check that +the version of score-k8s matches a required version. +`, + Example: ` + # check that the version is exactly 1.2.3 + score-k8s check-version =v1.2.3 + + # check that the version is 1.3.0 or greater + score-k8s check-version >v1.2 + + # check that the version is equal or greater to 1.2.3 + score-k8s check-version >=1.2.3`, + Args: cobra.ExactArgs(1), + SilenceErrors: true, + CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + return version.AssertVersion(args[0], version.Version) + }, +} + +func init() { + rootCmd.AddCommand(checkVersionCmd) +} diff --git a/internal/command/check_version_test.go b/internal/command/check_version_test.go new file mode 100644 index 0000000..361e6bb --- /dev/null +++ b/internal/command/check_version_test.go @@ -0,0 +1,72 @@ +// Copyright 2024 Humanitec +// +// 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 command + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCheckVersionHelp(t *testing.T) { + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"check-version", "--help"}) + assert.NoError(t, err) + assert.Equal(t, `score-k8s is commonly used in Makefiles and CI pipelines which may depend on a particular functionality +or a particular default provisioner provided by score-k8s init. This command provides a common way to check that +the version of score-k8s matches a required version. + +Usage: + score-k8s check-version [constraint] [flags] + +Examples: + + # check that the version is exactly 1.2.3 + score-k8s check-version =v1.2.3 + + # check that the version is 1.3.0 or greater + score-k8s check-version >v1.2 + + # check that the version is equal or greater to 1.2.3 + score-k8s check-version >=1.2.3 + +Flags: + -h, --help help for check-version + +Global Flags: + --quiet Mute any logging output + -v, --verbose count Increase log verbosity and detail by specifying this flag one or more times +`, stdout) + assert.Equal(t, "", stderr) + + stdout2, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"help", "check-version"}) + assert.NoError(t, err) + assert.Equal(t, stdout, stdout2) + assert.Equal(t, "", stderr) +} + +func TestCheckVersionPass(t *testing.T) { + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"check-version", ">=0.0.0"}) + assert.NoError(t, err) + assert.Equal(t, stdout, "") + assert.Equal(t, "", stderr) +} + +func TestCheckVersionFail(t *testing.T) { + stdout, stderr, err := executeAndResetCommand(context.Background(), rootCmd, []string{"check-version", ">99"}) + assert.EqualError(t, err, "current version 0.0.0 does not match requested constraint >99") + assert.Equal(t, stdout, "") + assert.Equal(t, "", stderr) +} diff --git a/internal/version/version.go b/internal/version/version.go index 5dd0d1c..440f37a 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -16,11 +16,15 @@ package version import ( "fmt" + "regexp" "runtime/debug" + "strconv" ) var ( - Version string = "0.0.0" + Version string = "0.0.0" + semverPattern = regexp.MustCompile(`^(?:v?)(\d+)(?:\.(\d+))?(?:\.(\d+))?$`) + constraintAndSemver = regexp.MustCompile("^(>|>=|=)?" + semverPattern.String()[1:]) ) // BuildVersionString constructs a version string by looking at the build metadata injected at build time. @@ -50,3 +54,46 @@ func BuildVersionString() string { } return fmt.Sprintf("%s (build: %s, sha: %s%s)", versionNumber, buildTime, gitSha, isDirtySuffix) } + +func semverToI(x string) (int, error) { + cpm := semverPattern.FindStringSubmatch(x) + if cpm == nil { + return 0, fmt.Errorf("invalid version: %s", x) + } + major, _ := strconv.Atoi(cpm[1]) + minor, patch := 999, 999 + if len(cpm) > 2 { + minor, _ = strconv.Atoi(cpm[2]) + if len(cpm) > 3 { + patch, _ = strconv.Atoi(cpm[3]) + } + } + return (major*1_000+minor)*1_000 + patch, nil +} + +func AssertVersion(constraint string, current string) error { + if currentI, err := semverToI(current); err != nil { + return fmt.Errorf("current version is missing or invalid '%s'", current) + } else if m := constraintAndSemver.FindStringSubmatch(constraint); m == nil { + return fmt.Errorf("invalid constraint '%s'", constraint) + } else { + op := m[1] + compareI, err := semverToI(m[0][len(op):]) + if err != nil { + return fmt.Errorf("failed to parse constraint: %w", err) + } + match := false + switch op { + case ">": + match = currentI > compareI + case ">=": + match = currentI >= compareI + case "=": + match = currentI == compareI + } + if !match { + return fmt.Errorf("current version %s does not match requested constraint %s", current, constraint) + } + return nil + } +} diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..0675669 --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,85 @@ +// Copyright 2024 Humanitec +// +// 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" + + "github.com/stretchr/testify/assert" +) + +func TestAssertVersion_good(t *testing.T) { + for _, tup := range [][2]string{ + {"=1.2.3", "v1.2.3"}, + {">=1.2.3", "v1.2.3"}, + {">=1.2.3", "v1.2.4"}, + {">1.2.3", "v1.2.4"}, + {">=1.1", "1.1.0"}, + {">=1.1", "1.2.0"}, + {">=1", "1.0.0"}, + {">1", "2.0.0"}, + } { + t.Run(fmt.Sprintf("%v", tup), func(t *testing.T) { + assert.NoError(t, AssertVersion(tup[0], tup[1])) + }) + } +} + +func TestAssertVersion_bad(t *testing.T) { + for _, tup := range [][3]string{ + {"=1.2.3", "v1.2.0", "current version v1.2.0 does not match requested constraint =1.2.3"}, + {">2", "v1.2.0", "current version v1.2.0 does not match requested constraint >2"}, + {">1.2", "v1.2.0", "current version v1.2.0 does not match requested constraint >1.2"}, + } { + t.Run(fmt.Sprintf("%v", tup), func(t *testing.T) { + assert.EqualError(t, AssertVersion(tup[0], tup[1]), tup[2]) + }) + } +} + +func TestSemverToI(t *testing.T) { + validCases := []struct { + version string + expected int + }{ + {"1.2.3", 1002003}, + {"2.0.0", 2000000}, + {"0.9.1", 9001}, + {"1.2", 1002000}, + {"1", 1000000}, + } + + for _, tc := range validCases { + t.Run(fmt.Sprintf("valid: %s", tc.version), func(t *testing.T) { + result, err := semverToI(tc.version) + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } + invalidCases := []string{ + "1.2.a", + "a.b.c", + "1..1", + "1.2.3.4", + } + for _, version := range invalidCases { + t.Run(fmt.Sprintf("invalid: %s", version), func(t *testing.T) { + result, err := semverToI(version) + assert.Error(t, err) + assert.Equal(t, 0, result) + }) + } +}