From 3f8023697482025417a6e42f3a9643c5497910d9 Mon Sep 17 00:00:00 2001 From: James Kwon <96548424+hongil0316@users.noreply.github.com> Date: Wed, 18 May 2022 16:40:20 -0400 Subject: [PATCH] Implement a helper funciton to check minimum binary versions. (#1122) * Implement a helper funciton to check minimum binary versions * Address comments * Address comments * address comments * address comments * goimports --- modules/version-checker/errors.go | 10 + modules/version-checker/version_checker.go | 173 ++++++++++++++ .../version-checker/version_checker_test.go | 219 ++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 modules/version-checker/errors.go create mode 100644 modules/version-checker/version_checker.go create mode 100644 modules/version-checker/version_checker_test.go diff --git a/modules/version-checker/errors.go b/modules/version-checker/errors.go new file mode 100644 index 000000000..babae5811 --- /dev/null +++ b/modules/version-checker/errors.go @@ -0,0 +1,10 @@ +package version_checker + +// VersionMismatchErr is an error to indicate version mismatch. +type VersionMismatchErr struct { + errorMessage string +} + +func (r *VersionMismatchErr) Error() string { + return r.errorMessage +} diff --git a/modules/version-checker/version_checker.go b/modules/version-checker/version_checker.go new file mode 100644 index 000000000..dba27a86b --- /dev/null +++ b/modules/version-checker/version_checker.go @@ -0,0 +1,173 @@ +package version_checker + +import ( + "fmt" + "regexp" + + "github.com/gruntwork-io/terratest/modules/shell" + "github.com/gruntwork-io/terratest/modules/testing" + "github.com/hashicorp/go-version" + "github.com/stretchr/testify/require" +) + +// VersionCheckerBinary is an enum for supported version checking. +type VersionCheckerBinary int + +// List of binaries supported for version checking. +const ( + Docker VersionCheckerBinary = iota + Terraform + Packer +) + +const ( + // versionRegexMatcher is a regex used to extract version string from shell command output. + versionRegexMatcher = `\d+(\.\d+)+` + // defaultVersionArg is a default arg to pass in to get version output from shell command. + defaultVersionArg = "--version" +) + +type CheckVersionParams struct { + // BinaryPath is a path to the binary you want to check the version for. + BinaryPath string + // Binary is the name of the binary you want to check the version for. + Binary VersionCheckerBinary + // VersionConstraint is a string literal containing one or more conditions, which are separated by commas. + // More information here:https://www.terraform.io/language/expressions/version-constraints + VersionConstraint string + // WorkingDir is a directory you want to run the shell command. + WorkingDir string +} + +// CheckVersionE checks whether the given Binary version is greater than or equal +// to the given expected version. +func CheckVersionE( + t testing.TestingT, + params CheckVersionParams) error { + + if err := validateParams(params); err != nil { + return err + } + + binaryVersion, err := getVersionWithShellCommand(t, params) + if err != nil { + return err + } + + return checkVersionConstraint(binaryVersion, params.VersionConstraint) +} + +// CheckVersion checks whether the given Binary version is greater than or equal to the +// given expected version and fails if it's not. +func CheckVersion( + t testing.TestingT, + params CheckVersionParams) { + require.NoError(t, CheckVersionE(t, params)) +} + +// Validate whether the given params contains valid data to check version. +func validateParams(params CheckVersionParams) error { + // Check for empty parameters + if params.WorkingDir == "" { + return fmt.Errorf("set WorkingDir in params") + } else if params.VersionConstraint == "" { + return fmt.Errorf("set VersionConstraint in params") + } + + // Check the format of the version constraint if present. + if _, err := version.NewConstraint(params.VersionConstraint); params.VersionConstraint != "" && err != nil { + return fmt.Errorf( + "invalid version constraint format found {%s}", params.VersionConstraint) + } + + return nil +} + +// getVersionWithShellCommand get version by running a shell command. +func getVersionWithShellCommand(t testing.TestingT, params CheckVersionParams) (string, error) { + var versionArg = defaultVersionArg + binary, err := getBinary(params) + if err != nil { + return "", err + } + + // Run a shell command to get the version string. + output, err := shell.RunCommandAndGetOutputE(t, shell.Command{ + Command: binary, + Args: []string{versionArg}, + WorkingDir: params.WorkingDir, + Env: map[string]string{}, + }) + if err != nil { + return "", fmt.Errorf("failed to run shell command for Binary {%s} "+ + "w/ version args {%s}: %w", binary, versionArg, err) + } + + versionStr, err := extractVersionFromShellCommandOutput(output) + if err != nil { + return "", fmt.Errorf("failed to extract version from shell "+ + "command output {%s}: %w", output, err) + } + + return versionStr, nil +} + +// getBinary retrieves the binary to use from the given params. +func getBinary(params CheckVersionParams) (string, error) { + // Use BinaryPath if it is set, otherwise use the binary enum. + if params.BinaryPath != "" { + return params.BinaryPath, nil + } + + switch params.Binary { + case Docker: + return "docker", nil + case Packer: + return "packer", nil + case Terraform: + return "terraform", nil + default: + return "", fmt.Errorf("unsupported Binary for checking versions {%d}", params.Binary) + } +} + +// extractVersionFromShellCommandOutput extracts version with regex string matching +// from the given shell command output string. +func extractVersionFromShellCommandOutput(output string) (string, error) { + regexMatcher := regexp.MustCompile(versionRegexMatcher) + versionStr := regexMatcher.FindString(output) + if versionStr == "" { + return "", fmt.Errorf("failed to find version using regex matcher") + } + + return versionStr, nil +} + +// checkVersionConstraint checks whether the given version pass the version constraint. +// +// It returns Error for ill-formatted version string and VersionMismatchErr for +// minimum version check failure. +// +// checkVersionConstraint(t, "1.2.31", ">= 1.2.0, < 2.0.0") - no error +// checkVersionConstraint(t, "1.0.31", ">= 1.2.0, < 2.0.0") - error +func checkVersionConstraint(actualVersionStr string, versionConstraintStr string) error { + actualVersion, err := version.NewVersion(actualVersionStr) + if err != nil { + return fmt.Errorf("invalid version format found for actualVersionStr: %s", actualVersionStr) + } + + versionConstraint, err := version.NewConstraint(versionConstraintStr) + if err != nil { + return fmt.Errorf("invalid version format found for versionConstraint: %s", versionConstraintStr) + } + + if !versionConstraint.Check(actualVersion) { + return &VersionMismatchErr{ + errorMessage: fmt.Sprintf("actual version {%s} failed "+ + "the version constraint {%s}", actualVersionStr, versionConstraint), + } + + } + + return nil +} diff --git a/modules/version-checker/version_checker_test.go b/modules/version-checker/version_checker_test.go new file mode 100644 index 000000000..8279092d6 --- /dev/null +++ b/modules/version-checker/version_checker_test.go @@ -0,0 +1,219 @@ +package version_checker + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParamValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + param CheckVersionParams + containError bool + expectedErrorMessage string + }{ + { + name: "Empty Params", + param: CheckVersionParams{}, + containError: true, + expectedErrorMessage: "set WorkingDir in params", + }, + { + name: "Missing VersionConstraint", + param: CheckVersionParams{ + Binary: Docker, + VersionConstraint: "", + WorkingDir: ".", + }, + containError: true, + expectedErrorMessage: "set VersionConstraint in params", + }, + { + name: "Invalid Version Constraint Format", + param: CheckVersionParams{ + Binary: Docker, + VersionConstraint: "abc", + WorkingDir: ".", + }, + containError: true, + expectedErrorMessage: "invalid version constraint format found {abc}", + }, + { + name: "Success", + param: CheckVersionParams{ + Binary: Docker, + VersionConstraint: ">1.2.3", + WorkingDir: ".", + }, + containError: false, + expectedErrorMessage: "", + }, + } + + for _, tc := range tests { + err := validateParams(tc.param) + if tc.containError { + require.EqualError(t, err, tc.expectedErrorMessage, tc.name) + } else { + require.NoError(t, err, tc.name) + } + } +} + +func TestExtractVersionFromShellCommandOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + outputStr string + expectedVersionStr string + containError bool + expectedErrorMessage string + }{ + { + name: "Stand-alone version string", + outputStr: "version is 1.2.3", + expectedVersionStr: "1.2.3", + containError: false, + expectedErrorMessage: "", + }, + { + name: "version string with v prefix", + outputStr: "version is v1.0.0", + expectedVersionStr: "1.0.0", + containError: false, + expectedErrorMessage: "", + }, + { + name: "2 digit version string", + outputStr: "version is v1.0", + expectedVersionStr: "1.0", + containError: false, + expectedErrorMessage: "", + }, + { + name: "invalid output string", + outputStr: "version is vabc", + expectedVersionStr: "", + containError: true, + expectedErrorMessage: "failed to find version using regex matcher", + }, + { + name: "empty output string", + outputStr: "", + expectedVersionStr: "", + containError: true, + expectedErrorMessage: "failed to find version using regex matcher", + }, + } + + for _, tc := range tests { + versionStr, err := extractVersionFromShellCommandOutput(tc.outputStr) + if tc.containError { + require.EqualError(t, err, tc.expectedErrorMessage, tc.name) + } else { + require.NoError(t, err, tc.name) + require.Equal(t, tc.expectedVersionStr, versionStr, tc.name) + } + } +} + +func TestCheckVersionConstraint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actualVersionStr string + versionConstraint string + containError bool + expectedErrorMessage string + }{ + { + name: "invalid actualVersionStr", + actualVersionStr: "", + versionConstraint: "1.2.3", + containError: true, + expectedErrorMessage: "invalid version format found for actualVersionStr: ", + }, + { + name: "invalid versionConstraint", + actualVersionStr: "1.2.3", + versionConstraint: "", + containError: true, + expectedErrorMessage: "invalid version format found for versionConstraint: ", + }, + { + name: "pass version constraint", + actualVersionStr: "1.2.3", + versionConstraint: "1.2.3", + containError: false, + expectedErrorMessage: "", + }, + { + name: "fail version constraint", + actualVersionStr: "1.2.3", + versionConstraint: "1.2.4", + containError: true, + expectedErrorMessage: "actual version {1.2.3} failed the version constraint {1.2.4}", + }, + { + name: "special syntax version constraint", + actualVersionStr: "1.0.5", + versionConstraint: "~> 1.0.4", + containError: false, + expectedErrorMessage: "", + }, + { + name: "version constraint w/ operators", + actualVersionStr: "1.2.7", + versionConstraint: ">= 1.2.0, < 2.0.0", + containError: false, + expectedErrorMessage: ""}, + } + + for _, tc := range tests { + err := checkVersionConstraint(tc.actualVersionStr, tc.versionConstraint) + if tc.containError { + require.EqualError(t, err, tc.expectedErrorMessage, tc.name) + } else { + require.NoError(t, err, tc.name) + } + } +} + +// Note: with the current implementation of running shell command, it's not easy to +// mock the output of running a shell command. So we assume a certain Binary is installed in the working +// directory and it's greater than 0.0.1 version. +func TestCheckVersionEndToEnd(t *testing.T) { + t.Parallel() + tests := []struct { + name string + param CheckVersionParams + }{ + {name: "Docker", param: CheckVersionParams{ + Binary: Docker, + VersionConstraint: ">= 0.0.1", + WorkingDir: ".", + }}, + {name: "Terraform", param: CheckVersionParams{ + BinaryPath: "", + Binary: Terraform, + VersionConstraint: ">= 0.0.1", + WorkingDir: ".", + }}, + {name: "Packer", param: CheckVersionParams{ + BinaryPath: "/usr/local/bin/packer", + Binary: Packer, + VersionConstraint: ">= 0.0.1", + WorkingDir: ".", + }}, + } + + for _, tc := range tests { + err := CheckVersionE(t, tc.param) + require.NoError(t, err, tc.name) + } +}