From d615262197ecd2d3ed2f6048efd9c60ab69b5893 Mon Sep 17 00:00:00 2001 From: magodo Date: Tue, 10 May 2022 21:08:59 +0800 Subject: [PATCH] `fs.Version` to support version constrained binary discovery (#52) * `fs.AnyVersion` supports optional version constraint Allows users to optionally specify one or more (comma separated) version constraints for `fs.AnyVersion` source. * Refine readme * Modify `Constraint` type and name to align with `releases.Versions` * revert everything * New source - `fs.Version`: allows specifying version constraints * Modify e2e test * enable test * modify and pass test --- README.md | 2 +- fs/fs_test.go | 38 ++++++++++++++++++ fs/version.go | 97 ++++++++++++++++++++++++++++++++++++++++++++++ fs/version_test.go | 70 +++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 fs/version.go create mode 100644 fs/version_test.go diff --git a/README.md b/README.md index 87c06a2..eb287ff 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ The `Installer` offers a few high-level methods: The `Installer` methods accept number of different `Source` types. Each comes with different trade-offs described below. - - `fs.{AnyVersion,ExactVersion}` - Finds a binary in `$PATH` (or additional paths) + - `fs.{AnyVersion,ExactVersion,Version}` - Finds a binary in `$PATH` (or additional paths) - **Pros:** - This is most convenient when you already have the product installed on your system which you already manage. diff --git a/fs/fs_test.go b/fs/fs_test.go index 9d81127..dafa2ca 100644 --- a/fs/fs_test.go +++ b/fs/fs_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/go-version" "github.com/hashicorp/hc-install/internal/testutil" "github.com/hashicorp/hc-install/product" + "github.com/hashicorp/hc-install/releases" "github.com/hashicorp/hc-install/src" ) @@ -16,6 +17,9 @@ var ( _ src.Findable = &ExactVersion{} _ src.LoggerSettable = &ExactVersion{} + + _ src.Findable = &Version{} + _ src.LoggerSettable = &Version{} ) func TestExactVersion(t *testing.T) { @@ -36,3 +40,37 @@ func TestExactVersion(t *testing.T) { t.Fatal(err) } } + +func TestVersion(t *testing.T) { + testutil.EndToEndTest(t) + + ctx := context.Background() + + p := t.TempDir() + t.Setenv("PATH", p) + ev := releases.ExactVersion{ + Product: product.Terraform, + Version: version.Must(version.NewVersion("1.0.0")), + InstallDir: p, + } + + if _, err := ev.Install(ctx); err != nil { + t.Fatalf("installing release version failed: %v", err) + } + + // Version matches constraint + v := &Version{ + Product: product.Terraform, + Constraints: version.MustConstraints(version.NewConstraint(">= 1.0")), + } + v.SetLogger(testutil.TestLogger()) + if _, err := v.Find(ctx); err != nil { + t.Fatalf("finding: %v", err) + } + + // Version mismatches constraint + v.Constraints = version.MustConstraints(version.NewConstraint("> 1.0")) + if _, err := v.Find(ctx); err == nil { + t.Fatal("expecting error") + } +} diff --git a/fs/version.go b/fs/version.go new file mode 100644 index 0000000..26633b8 --- /dev/null +++ b/fs/version.go @@ -0,0 +1,97 @@ +package fs + +import ( + "context" + "fmt" + "log" + "path/filepath" + "time" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/hc-install/errors" + "github.com/hashicorp/hc-install/internal/src" + "github.com/hashicorp/hc-install/internal/validators" + "github.com/hashicorp/hc-install/product" +) + +// Version finds the first executable binary of the product name +// which matches the version constraint within system $PATH and any declared ExtraPaths +// (which are *appended* to any directories in $PATH) +type Version struct { + Product product.Product + Constraints version.Constraints + ExtraPaths []string + Timeout time.Duration + + logger *log.Logger +} + +func (*Version) IsSourceImpl() src.InstallSrcSigil { + return src.InstallSrcSigil{} +} + +func (v *Version) SetLogger(logger *log.Logger) { + v.logger = logger +} + +func (v *Version) log() *log.Logger { + if v.logger == nil { + return discardLogger + } + return v.logger +} + +func (v *Version) Validate() error { + if !validators.IsBinaryNameValid(v.Product.BinaryName()) { + return fmt.Errorf("invalid binary name: %q", v.Product.BinaryName()) + } + if len(v.Constraints) == 0 { + return fmt.Errorf("undeclared version constraints") + } + if v.Product.GetVersion == nil { + return fmt.Errorf("undeclared version getter") + } + return nil +} + +func (v *Version) Find(ctx context.Context) (string, error) { + timeout := defaultTimeout + if v.Timeout > 0 { + timeout = v.Timeout + } + ctx, cancelFunc := context.WithTimeout(ctx, timeout) + defer cancelFunc() + + execPath, err := findFile(lookupDirs(v.ExtraPaths), v.Product.BinaryName(), func(file string) error { + err := checkExecutable(file) + if err != nil { + return err + } + + ver, err := v.Product.GetVersion(ctx, file) + if err != nil { + return err + } + + for _, vc := range v.Constraints { + if !vc.Check(ver) { + return fmt.Errorf("version (%s) doesn't meet constraints %s", ver, vc.String()) + } + } + + return nil + }) + if err != nil { + return "", errors.SkippableErr(err) + } + + if !filepath.IsAbs(execPath) { + var err error + execPath, err = filepath.Abs(execPath) + if err != nil { + return "", errors.SkippableErr(err) + } + } + + return execPath, nil +} diff --git a/fs/version_test.go b/fs/version_test.go new file mode 100644 index 0000000..721041d --- /dev/null +++ b/fs/version_test.go @@ -0,0 +1,70 @@ +package fs + +import ( + "fmt" + "testing" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/hc-install/product" +) + +func TestVersionValidate(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + v Version + expectedErr error + }{ + "Product-incorrect-binary-name": { + v: Version{ + Product: product.Product{ + BinaryName: func() string { return "invalid!" }, + }, + }, + expectedErr: fmt.Errorf("invalid binary name: \"invalid!\""), + }, + "Product-missing-get-version": { + v: Version{ + Product: product.Product{ + BinaryName: product.Terraform.BinaryName, + }, + Constraints: version.MustConstraints(version.NewConstraint(">= 1.0")), + }, + expectedErr: fmt.Errorf("undeclared version getter"), + }, + "Product-missing-version-constraint": { + v: Version{ + Product: product.Terraform, + }, + expectedErr: fmt.Errorf("undeclared version constraints"), + }, + "Product-and-version-constraint": { + v: Version{ + Product: product.Terraform, + Constraints: version.MustConstraints(version.NewConstraint(">= 1.0")), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + err := testCase.v.Validate() + + if err == nil && testCase.expectedErr != nil { + t.Fatalf("expected error: %s, got no error", testCase.expectedErr) + } + + if err != nil && testCase.expectedErr == nil { + t.Fatalf("expected no error, got error: %s", err) + } + + if err != nil && testCase.expectedErr != nil && err.Error() != testCase.expectedErr.Error() { + t.Fatalf("expected error: %s, got error: %s", testCase.expectedErr, err) + } + }) + } +}