Skip to content

Commit

Permalink
fs.Version to support version constrained binary discovery (#52)
Browse files Browse the repository at this point in the history
* `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
  • Loading branch information
magodo committed May 10, 2022
1 parent ba20d15 commit d615262
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 1 deletion.
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions fs/fs_test.go
Expand Up @@ -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"
)

Expand All @@ -16,6 +17,9 @@ var (

_ src.Findable = &ExactVersion{}
_ src.LoggerSettable = &ExactVersion{}

_ src.Findable = &Version{}
_ src.LoggerSettable = &Version{}
)

func TestExactVersion(t *testing.T) {
Expand All @@ -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")
}
}
97 changes: 97 additions & 0 deletions 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
}
70 changes: 70 additions & 0 deletions 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)
}
})
}
}

0 comments on commit d615262

Please sign in to comment.