tfinstall package
diff --git a/tfinstall/pubkey.go b/tfinstall/pubkey.go new file mode 100644 index 0000000..a14f88c --- /dev/null +++ b/tfinstall/pubkey.go
@@ -0,0 +1,32 @@ +package tfinstall + +const hashicorpPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFMORM0BCADBRyKO1MhCirazOSVwcfTr1xUxjPvfxD3hjUwHtjsOy/bT6p9f +W2mRPfwnq2JB5As+paL3UGDsSRDnK9KAxQb0NNF4+eVhr/EJ18s3wwXXDMjpIifq +fIm2WyH3G+aRLTLPIpscUNKDyxFOUbsmgXAmJ46Re1fn8uKxKRHbfa39aeuEYWFA +3drdL1WoUngvED7f+RnKBK2G6ZEpO+LDovQk19xGjiMTtPJrjMjZJ3QXqPvx5wca +KSZLr4lMTuoTI/ZXyZy5bD4tShiZz6KcyX27cD70q2iRcEZ0poLKHyEIDAi3TM5k +SwbbWBFd5RNPOR0qzrb/0p9ksKK48IIfH2FvABEBAAG0K0hhc2hpQ29ycCBTZWN1 +cml0eSA8c2VjdXJpdHlAaGFzaGljb3JwLmNvbT6JAU4EEwEKADgWIQSRpuf4XQXG +VjC+8YlRhS2HNI/8TAUCXn0BIQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAK +CRBRhS2HNI/8TJITCACT2Zu2l8Jo/YLQMs+iYsC3gn5qJE/qf60VWpOnP0LG24rj +k3j4ET5P2ow/o9lQNCM/fJrEB2CwhnlvbrLbNBbt2e35QVWvvxwFZwVcoBQXTXdT ++G2cKS2Snc0bhNF7jcPX1zau8gxLurxQBaRdoL38XQ41aKfdOjEico4ZxQYSrOoC +RbF6FODXj+ZL8CzJFa2Sd0rHAROHoF7WhKOvTrg1u8JvHrSgvLYGBHQZUV23cmXH +yvzITl5jFzORf9TUdSv8tnuAnNsOV4vOA6lj61Z3/0Vgor+ZByfiznonPHQtKYtY +kac1M/Dq2xZYiSf0tDFywgUDIF/IyS348wKmnDGjuQENBFMORM0BCADWj1GNOP4O +wJmJDjI2gmeok6fYQeUbI/+Hnv5Z/cAK80Tvft3noy1oedxaDdazvrLu7YlyQOWA +M1curbqJa6ozPAwc7T8XSwWxIuFfo9rStHQE3QUARxIdziQKTtlAbXI2mQU99c6x +vSueQ/gq3ICFRBwCmPAm+JCwZG+cDLJJ/g6wEilNATSFdakbMX4lHUB2X0qradNO +J66pdZWxTCxRLomPBWa5JEPanbosaJk0+n9+P6ImPiWpt8wiu0Qzfzo7loXiDxo/ +0G8fSbjYsIF+skY+zhNbY1MenfIPctB9X5iyW291mWW7rhhZyuqqxN2xnmPPgFmi +QGd+8KVodadHABEBAAGJATwEGAECACYCGwwWIQSRpuf4XQXGVjC+8YlRhS2HNI/8 +TAUCXn0BRAUJEvOKdwAKCRBRhS2HNI/8TEzUB/9pEHVwtTxL8+VRq559Q0tPOIOb +h3b+GroZRQGq/tcQDVbYOO6cyRMR9IohVJk0b9wnnUHoZpoA4H79UUfIB4sZngma +enL/9magP1uAHxPxEa5i/yYqR0MYfz4+PGdvqyj91NrkZm3WIpwzqW/KZp8YnD77 +VzGVodT8xqAoHW+bHiza9Jmm9Rkf5/0i0JY7GXoJgk4QBG/Fcp0OR5NUWxN3PEM0 +dpeiU4GI5wOz5RAIOvSv7u1h0ZxMnJG4B4MKniIAr4yD7WYYZh/VxEPeiS/E1CVx +qHV5VVCoEIoYVHIuFIyFu1lIcei53VD6V690rmn0bp4A5hs+kErhThvkok3c +=+mCN +-----END PGP PUBLIC KEY BLOCK-----`
diff --git a/tfinstall/tfinstall.go b/tfinstall/tfinstall.go new file mode 100644 index 0000000..0330f03 --- /dev/null +++ b/tfinstall/tfinstall.go
@@ -0,0 +1,287 @@ +package tfinstall + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/hashicorp/go-checkpoint" + "github.com/hashicorp/go-getter" + "github.com/hashicorp/go-version" + "golang.org/x/crypto/openpgp" +) + +const baseUrl = "https://releases.hashicorp.com/terraform" + +type ExecPathFinder interface { + ExecPath() (string, error) +} + +type ExactPathOption struct { + execPath string +} + +func ExactPath(execPath string) *ExactPathOption { + opt := &ExactPathOption{ + execPath: execPath, + } + return opt +} + +func (opt *ExactPathOption) ExecPath() (string, error) { + if _, err := os.Stat(opt.execPath); err != nil { + // fall through to the next strategy if the local path does not exist + return "", nil + } + return opt.execPath, nil +} + +type LookPathOption struct { +} + +func LookPath() *LookPathOption { + opt := &LookPathOption{} + + return opt +} + +func (opt *LookPathOption) ExecPath() (string, error) { + p, err := exec.LookPath("terraform") + if err != nil { + if notFoundErr, ok := err.(*exec.Error); ok && notFoundErr.Err == exec.ErrNotFound { + log.Printf("[WARN] could not locate a terraform executable on system path; continuing") + return "", nil + } + return "", err + } + return p, nil +} + +type LatestVersionOption struct { + forceCheckpoint bool + installDir string +} + +func LatestVersion(installDir string, forceCheckpoint bool) *LatestVersionOption { + opt := &LatestVersionOption{ + forceCheckpoint: forceCheckpoint, + installDir: installDir, + } + + return opt +} + +func (opt *LatestVersionOption) ExecPath() (string, error) { + v, err := latestVersion(opt.forceCheckpoint) + if err != nil { + return "", err + } + + return downloadWithVerification(v, opt.installDir) +} + +type ExactVersionOption struct { + tfVersion string + installDir string +} + +func ExactVersion(tfVersion string, installDir string) *ExactVersionOption { + opt := &ExactVersionOption{ + tfVersion: tfVersion, + installDir: installDir, + } + + return opt +} + +func (opt *ExactVersionOption) ExecPath() (string, error) { + // validate version + _, err := version.NewVersion(opt.tfVersion) + if err != nil { + return "", err + } + + return downloadWithVerification(opt.tfVersion, opt.installDir) +} + +func Find(opts ...ExecPathFinder) (string, error) { + var terraformPath string + + // go through the options in order + // until a valid terraform executable is found + for _, opt := range opts { + p, err := opt.ExecPath() + if err != nil { + return "", fmt.Errorf("unexpected error: %s", err) + } + + if p == "" { + // strategy did not locate an executable - fall through to next + continue + } else { + terraformPath = p + break + } + } + + err := runTerraformVersion(terraformPath) + if err != nil { + return "", fmt.Errorf("executable found at path %s is not terraform: %s", terraformPath, err) + } + + if terraformPath == "" { + return "", fmt.Errorf("could not find terraform executable") + } + + return terraformPath, nil +} + +func downloadWithVerification(tfVersion string, installDir string) (string, error) { + osName := runtime.GOOS + archName := runtime.GOARCH + + // setup: ensure we have a place to put our downloaded terraform binary + var tfDir string + var err error + if installDir == "" { + tfDir, err = ioutil.TempDir("", "tfexec") + if err != nil { + return "", fmt.Errorf("failed to create temp dir: %s", err) + } + } else { + if _, err := os.Stat(installDir); err != nil { + return "", fmt.Errorf("could not access directory %s for installing Terraform: %s", installDir, err) + } + tfDir = installDir + + } + + // setup: getter client + httpHeader := make(http.Header) + httpHeader.Set("User-Agent", "HashiCorp-tfinstall/"+Version) + httpGetter := &getter.HttpGetter{ + Netrc: true, + } + client := getter.Client{ + Getters: map[string]getter.Getter{ + "https": httpGetter, + }, + } + client.Mode = getter.ClientModeAny + + // firstly, download and verify the signature of the checksum file + + sumsTmpDir, err := ioutil.TempDir("", "tfinstall") + if err != nil { + return "", err + } + defer os.RemoveAll(sumsTmpDir) + + sumsFilename := "terraform_" + tfVersion + "_SHA256SUMS" + sumsSigFilename := sumsFilename + ".sig" + + sumsUrl := fmt.Sprintf("%s/%s/%s", + baseUrl, tfVersion, sumsFilename) + sumsSigUrl := fmt.Sprintf("%s/%s/%s", + baseUrl, tfVersion, sumsSigFilename) + + client.Src = sumsUrl + client.Dst = sumsTmpDir + err = client.Get() + if err != nil { + return "", fmt.Errorf("error fetching checksums: %s", err) + } + + client.Src = sumsSigUrl + err = client.Get() + if err != nil { + return "", fmt.Errorf("error fetching checksums signature: %s", err) + } + + sumsPath := filepath.Join(sumsTmpDir, sumsFilename) + sumsSigPath := filepath.Join(sumsTmpDir, sumsSigFilename) + + err = verifySumsSignature(sumsPath, sumsSigPath) + if err != nil { + return "", err + } + + // secondly, download Terraform itself, verifying the checksum + url := tfUrl(tfVersion, osName, archName) + client.Src = url + client.Dst = tfDir + client.Mode = getter.ClientModeDir + err = client.Get() + if err != nil { + return "", err + } + + return filepath.Join(tfDir, "terraform"), nil +} + +func tfUrl(tfVersion, osName, archName string) string { + sumsFilename := "terraform_" + tfVersion + "_SHA256SUMS" + sumsUrl := fmt.Sprintf("%s/%s/%s", + baseUrl, tfVersion, sumsFilename) + return fmt.Sprintf( + "%s/%s/terraform_%s_%s_%s.zip?checksum=file:%s", + baseUrl, tfVersion, tfVersion, osName, archName, sumsUrl, + ) +} + +func latestVersion(forceCheckpoint bool) (string, error) { + resp, err := checkpoint.Check(&checkpoint.CheckParams{ + Product: "terraform", + Force: forceCheckpoint, + }) + if err != nil { + return "", err + } + + if resp.CurrentVersion == "" { + return "", fmt.Errorf("could not determine latest version of terraform using checkpoint: CHECKPOINT_DISABLE may be set") + } + + return resp.CurrentVersion, nil +} + +// verifySumsSignature downloads SHA256SUMS and SHA256SUMS.sig and verifies +// the signature using the HashiCorp public key. +func verifySumsSignature(sumsPath, sumsSigPath string) error { + el, err := openpgp.ReadArmoredKeyRing(strings.NewReader(hashicorpPublicKey)) + if err != nil { + return err + } + data, err := os.Open(sumsPath) + if err != nil { + return err + } + sig, err := os.Open(sumsSigPath) + if err != nil { + return err + } + _, err = openpgp.CheckDetachedSignature(el, data, sig) + + return err +} + +func runTerraformVersion(execPath string) error { + cmd := exec.Command(execPath, "version") + + out, err := cmd.Output() + if err != nil { + return err + } + + if !strings.HasPrefix(string(out), "Terraform v") { + return fmt.Errorf("located executable at %s, but output of `terraform version` was:\n%s", execPath, out) + } + + return nil +}
diff --git a/tfinstall/tfinstall_test.go b/tfinstall/tfinstall_test.go new file mode 100644 index 0000000..9f29762 --- /dev/null +++ b/tfinstall/tfinstall_test.go
@@ -0,0 +1,158 @@ +package tfinstall + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "strings" + "testing" + + "github.com/hashicorp/go-version" +) + +// downloads terraform 0.12.26 from the live releases site +func TestFindExactVersion(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "tfinstall-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tfpath, err := Find(ExactVersion("0.12.26", tmpDir)) + if err != nil { + t.Fatal(err) + } + + // run "terraform version" to check we've downloaded a terraform 0.12.26 binary + cmd := exec.Command(tfpath, "version") + + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + + expected := "Terraform v0.12.26" + actual := string(out) + if !strings.HasPrefix(actual, expected) { + t.Fatalf("ran terraform version, expected %s, but got %s", expected, actual) + } +} + +// downloads terraform 0.13.0-beta1 from the live releases site +func TestFindExactVersionPrerelease(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "tfinstall-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tfpath, err := Find(ExactVersion("0.13.0-beta1", tmpDir)) + if err != nil { + t.Fatal(err) + } + + // run "terraform version" to check we've downloaded a terraform 0.12.26 binary + cmd := exec.Command(tfpath, "version") + + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + + expected := "Terraform v0.13.0-beta1" + actual := string(out) + if !strings.HasPrefix(actual, expected) { + t.Fatalf("ran terraform version, expected %s, but got %s", expected, actual) + } +} + +// test that Find returns an appropriate error when given an exact path +// which exists, but is not a terraform executable +func TestExactPath(t *testing.T) { + // we just want the path to a local executable that definitely exists + execPath, err := exec.LookPath("go") + if err != nil { + t.Fatal(err) + } + + _, err = Find(ExactPath(execPath)) + if err == nil { + t.Fatalf("expected Find() to fail when given ExactPath(%s), but it did not", execPath) + } + + expected := fmt.Sprintf("executable found at path %s is not terraform", execPath) + if !strings.HasPrefix(err.Error(), expected) { + t.Fatalf("expected Find() to return %s, but got %s", expected, err) + } +} + +// test that Find falls back to the next working strategy when the file at +// ExactPath does not exist +func TestExactVersionFallback(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "tfinstall-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tfpath, err := Find(ExactPath("/hopefully/completely/nonexistent/path"), ExactVersion("0.12.26", tmpDir)) + if err != nil { + t.Fatal(err) + } + + // run "terraform version" to check we've downloaded a terraform 0.12.26 binary + cmd := exec.Command(tfpath, "version") + + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + + expected := "Terraform v0.12.26" + actual := string(out) + if !strings.HasPrefix(actual, expected) { + t.Fatalf("ran terraform version, expected %s, but got %s", expected, actual) + } +} + +// latest version calculation itself is handled by checkpoint, so the test can be straightforward - +// just test that we've managed to download a version of terraform later than 0.12.27 +func TestLatestVersion(t *testing.T) { + lowerBound := "0.12.27" + + tmpDir, err := ioutil.TempDir("", "tfinstall-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tfpath, err := Find(LatestVersion(tmpDir, false)) + if err != nil { + t.Fatal(err) + } + + cmd := exec.Command(tfpath, "version") + + out, err := cmd.Output() + if err != nil { + t.Fatal(err) + } + + lowerBoundVersion, err := version.NewVersion("0.12.27") + if err != nil { + t.Fatal(err) + } + + outVersion := strings.Trim(string(out), "\n") + outVersion = strings.TrimLeft(outVersion, "Terraform v") + + actualVersion, err := version.NewVersion(outVersion) + if err != nil { + t.Fatal(err) + } + + if actualVersion.LessThan(lowerBoundVersion) { + t.Fatalf("ran terraform version, expected version to be greater than %s, but got %s", lowerBound, out) + } +}
diff --git a/tfinstall/version.go b/tfinstall/version.go new file mode 100644 index 0000000..190fdf2 --- /dev/null +++ b/tfinstall/version.go
@@ -0,0 +1,4 @@ +package tfinstall + +// Version is the tfinstall package version, used in user agent headers +const Version = "0.1.0"