github.com/joshdk/godel@v0.0.0-20170529232908-862138a45aee/cmd/godel/install.go (about)

     1  // Copyright 2016 Palantir Technologies, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package godel
    16  
    17  import (
    18  	"crypto/sha256"
    19  	"encoding/hex"
    20  	"fmt"
    21  	"io"
    22  	"io/ioutil"
    23  	"net/http"
    24  	"os"
    25  	"os/exec"
    26  	"path"
    27  	"strings"
    28  
    29  	"github.com/nmiyake/archiver"
    30  	"github.com/nmiyake/pkg/dirs"
    31  	"github.com/palantir/pkg/specdir"
    32  	"github.com/pkg/errors"
    33  	"gopkg.in/cheggaaa/pb.v1"
    34  
    35  	"github.com/palantir/godel/layout"
    36  	"github.com/palantir/godel/properties"
    37  )
    38  
    39  // Copies and installs the gödel package from the provided PkgSrc. If the PkgSrc includes a checksum, this
    40  // function will check to see if a TGZ file for the version as already been downloaded and if the checksum matches. If
    41  // it does, that file will be used. Otherwise, the TGZ will be downloaded from the specified location and the downloaded
    42  // TGZ will be verified against the checksum. If the checksum is empty, no verification will occur. If the install
    43  // succeeds, the following files will be created:
    44  // "{{layout.GödelHomePath()}}/downloads/{{layout.AppName}}-{{version}}.tgz" and
    45  // "{{layout.GödelHomePath()}}/dists/{{layout.AppName}}-{{version}}". If the downloaded distribution matches a version
    46  // that already exists in the distribution directory and a download occurs, the existing distribution will be
    47  // overwritten by the newly downloaded one. Returns the version of the distribution that was installed.
    48  func install(src PkgSrc, stdout io.Writer) (string, error) {
    49  	gödelHomeSpecDir, err := layout.GödelHomeSpecDir(specdir.Create)
    50  	if err != nil {
    51  		return "", errors.Wrapf(err, "failed to create SpecDir for gödel home")
    52  	}
    53  	gödelHome := gödelHomeSpecDir.Root()
    54  
    55  	downloadsDir := gödelHomeSpecDir.Path(layout.DownloadsDir)
    56  	tgzFilePath, err := getPkg(src, downloadsDir, stdout)
    57  	if err != nil {
    58  		return "", err
    59  	}
    60  
    61  	tgzVersion, err := verifyPackageTgz(tgzFilePath)
    62  	if err != nil {
    63  		return "", errors.Wrapf(err, "downloaded file %s is not a valid %s package", tgzFilePath, layout.AppName)
    64  	}
    65  
    66  	// create temporary directory in gödel home in which downloaded tgz is expanded. If verification is successful,
    67  	// the expanded directory will be moved to the destination.
    68  	tmpDir, cleanup, err := dirs.TempDir(gödelHome, "")
    69  	defer cleanup()
    70  	if err != nil {
    71  		return "", errors.Wrapf(err, "failed to create temporary directory rooted at %s", gödelHome)
    72  	}
    73  
    74  	if err := archiver.UntarGz(tgzFilePath, tmpDir); err != nil {
    75  		return "", errors.Wrapf(err, "failed to extract archive %s to %s", tgzFilePath, tmpDir)
    76  	}
    77  
    78  	expandedGödelDir := path.Join(tmpDir, layout.AppName+"-"+tgzVersion)
    79  	expandedGödelApp, err := layout.AppSpecDir(expandedGödelDir, tgzVersion)
    80  	if err != nil {
    81  		return "", errors.Wrapf(err, "extracted archive layout did not match expected gödel layout")
    82  	}
    83  
    84  	version, err := getExecutableVersion(expandedGödelApp)
    85  	if err != nil {
    86  		return "", errors.Wrapf(err, "failed to get version of downloaded gödel package")
    87  	}
    88  
    89  	if version != tgzVersion {
    90  		return "", errors.Errorf("version reported by executable does not match version specified by tgz: expected %s, was %s", tgzVersion, version)
    91  	}
    92  
    93  	gödelDist, err := layout.GödelDistLayout(version, specdir.Create)
    94  	if err != nil {
    95  		return "", errors.Wrapf(err, "failed to create distribution directory")
    96  	}
    97  	gödelDirDestPath := gödelDist.Path(layout.AppDir)
    98  
    99  	// delete destination directory if it already exists
   100  	if _, err := os.Stat(gödelDirDestPath); !os.IsNotExist(err) {
   101  		if err != nil {
   102  			return "", errors.Wrapf(err, "failed to stat %s", gödelDirDestPath)
   103  		}
   104  
   105  		if err := os.RemoveAll(gödelDirDestPath); err != nil {
   106  			return "", errors.Wrapf(err, "failed to remove %s", gödelDirDestPath)
   107  		}
   108  	}
   109  
   110  	if err := os.Rename(expandedGödelDir, gödelDirDestPath); err != nil {
   111  		return "", errors.Wrapf(err, "failed to rename %s to %s", expandedGödelDir, gödelDirDestPath)
   112  	}
   113  
   114  	return version, nil
   115  }
   116  
   117  // GetDistPkgInfo returns the distribution URL and checksum (if it exists) from the configuration file in the provided
   118  // directory. Returns an error if the URL cannot be read.
   119  func GetDistPkgInfo(configDir string) (PkgWithChecksum, error) {
   120  	propsFilePath := path.Join(configDir, fmt.Sprintf("%v.properties", layout.AppName))
   121  	props, err := properties.Read(propsFilePath)
   122  	if err != nil {
   123  		return PkgWithChecksum{}, errors.Wrapf(err, "failed to read properties file %s", propsFilePath)
   124  	}
   125  	url, err := properties.Get(props, properties.URL)
   126  	if err != nil {
   127  		return PkgWithChecksum{}, errors.Wrapf(err, "failed to get URL")
   128  	}
   129  	checksum, _ := properties.Get(props, properties.Checksum)
   130  	return PkgWithChecksum{
   131  		Pkg:      url,
   132  		Checksum: checksum,
   133  	}, nil
   134  }
   135  
   136  // getExecutableVersion gets the version of gödel contained in the provided root gödel directory. Invokes the executable
   137  // for the current platform with the "--version" flag and returns the version determined by that output.
   138  func getExecutableVersion(gödelApp specdir.SpecDir) (string, error) {
   139  	executablePath := gödelApp.Path(layout.AppExecutable)
   140  	cmd := exec.Command(executablePath, "version")
   141  	output, err := cmd.Output()
   142  	if err != nil {
   143  		return "", errors.Wrapf(err, "failed to execute command %v: %s", cmd.Args, string(output))
   144  	}
   145  
   146  	outputString := strings.TrimSpace(string(output))
   147  	parts := strings.Split(outputString, " ")
   148  	if len(parts) != 3 {
   149  		return "", errors.Errorf(`expected output %s to have 3 parts when split by " ", but was %v`, outputString, parts)
   150  	}
   151  
   152  	return parts[2], nil
   153  }
   154  
   155  // getPkg gets the source package from the specified source and copies it to a new file in the specified directory
   156  // (which must already exist). Returns the path to the downloaded file.
   157  func getPkg(src PkgSrc, destDir string, stdout io.Writer) (rPkg string, rErr error) {
   158  	expectedChecksum := src.checksum()
   159  
   160  	if destDirInfo, err := os.Stat(destDir); err != nil {
   161  		if os.IsNotExist(err) {
   162  			return "", errors.Wrapf(err, "destination directory %s does not exist", destDir)
   163  		}
   164  		return "", errors.WithStack(err)
   165  	} else if !destDirInfo.IsDir() {
   166  		return "", errors.Errorf("destination path %s exists, but is not a directory", destDir)
   167  	}
   168  
   169  	destFilePath := path.Join(destDir, src.name())
   170  	if info, err := os.Stat(destFilePath); err == nil {
   171  		if info.IsDir() {
   172  			return "", errors.Errorf("destination path %s already exists and is a directory", destFilePath)
   173  		}
   174  		if expectedChecksum != "" {
   175  			// if tgz already exists at destination and checksum is known, verify checksum of existing tgz.
   176  			// If it matches, use existing file.
   177  			checksum, err := sha256Checksum(destFilePath)
   178  			if err != nil {
   179  				// if checksum computation fails, print error but continue execution
   180  				fmt.Fprintf(stdout, "Failed to compute checksum of %s: %v\n", destFilePath, err)
   181  			} else if checksum == expectedChecksum {
   182  				return destFilePath, nil
   183  			}
   184  		}
   185  	}
   186  
   187  	// create new file for package (overwrite any existing file)
   188  	destFile, err := os.Create(destFilePath)
   189  	if err != nil {
   190  		return "", errors.Wrapf(err, "failed to create file %s", destFilePath)
   191  	}
   192  	defer func() {
   193  		if err := destFile.Close(); err != nil && rErr == nil {
   194  			rErr = errors.Wrapf(err, "failed to close file %s in defer", destFilePath)
   195  		}
   196  	}()
   197  
   198  	r, size, err := src.getPkg()
   199  	if err != nil {
   200  		return "", err
   201  	}
   202  	defer func() {
   203  		if err := r.Close(); err != nil && rErr == nil {
   204  			rErr = errors.Wrapf(err, "failed to close reader for %s in defer", src.path())
   205  		}
   206  	}()
   207  
   208  	h := sha256.New()
   209  	w := io.MultiWriter(h, destFile)
   210  
   211  	fmt.Fprintf(stdout, "Getting package from %v...\n", src.path())
   212  	if err := copyWithProgress(w, r, size, stdout); err != nil {
   213  		return "", errors.Wrapf(err, "failed to copy package %s to %s", src.path(), destFilePath)
   214  	}
   215  
   216  	// verify checksum if provided
   217  	if expectedChecksum != "" {
   218  		actualChecksum := hex.EncodeToString(h.Sum(nil))
   219  		if expectedChecksum != actualChecksum {
   220  			return "", errors.Errorf("SHA-256 checksum of downloaded package did not match expected checksum: expected %s, was %s", expectedChecksum, actualChecksum)
   221  		}
   222  	}
   223  
   224  	return destFilePath, nil
   225  }
   226  
   227  func copyWithProgress(w io.Writer, r io.Reader, dataLen int64, stdout io.Writer) error {
   228  	bar := pb.New64(dataLen).SetUnits(pb.U_BYTES)
   229  	bar.SetMaxWidth(120)
   230  	bar.Output = stdout
   231  	bar.Start()
   232  	defer func() {
   233  		bar.Finish()
   234  	}()
   235  	mw := io.MultiWriter(w, bar)
   236  	_, err := io.Copy(mw, r)
   237  	return err
   238  }
   239  
   240  type PkgSrc interface {
   241  	// returns a reader that can be used to read the package and the size of the package. Reader will be open and
   242  	// ready for reads -- the caller is responsible for closing the reader when done.
   243  	getPkg() (io.ReadCloser, int64, error)
   244  	// returns the name of this package.
   245  	name() string
   246  	// returns the path to this package.
   247  	path() string
   248  	// returns the expected SHA-256 checksum for the package. If this function returns an empty string, then a
   249  	// checksum will not be performed.
   250  	checksum() string
   251  }
   252  
   253  type PkgWithChecksum struct {
   254  	Pkg      string
   255  	Checksum string
   256  }
   257  
   258  func (p PkgWithChecksum) ToPkgSrc() PkgSrc {
   259  	if strings.HasPrefix(p.Pkg, "http://") || strings.HasPrefix(p.Pkg, "https://") {
   260  		return remotePkg(p)
   261  	}
   262  	return localPkg(p)
   263  }
   264  
   265  type remotePkg PkgWithChecksum
   266  
   267  func (p remotePkg) getPkg() (io.ReadCloser, int64, error) {
   268  	url := p.Pkg
   269  	response, err := http.Get(url)
   270  	if err != nil {
   271  		return nil, 0, errors.Wrapf(err, "get call for url %s failed", url)
   272  	}
   273  	if response.StatusCode >= 400 {
   274  		return nil, 0, errors.Errorf("request for URL %s returned status code %d", url, response.StatusCode)
   275  	}
   276  	return response.Body, response.ContentLength, nil
   277  }
   278  
   279  func (p remotePkg) name() string {
   280  	return p.Pkg[strings.LastIndex(p.Pkg, "/")+1:]
   281  }
   282  
   283  func (p remotePkg) path() string {
   284  	return p.Pkg
   285  }
   286  
   287  func (p remotePkg) checksum() string {
   288  	return p.Checksum
   289  }
   290  
   291  type localPkg PkgWithChecksum
   292  
   293  func (p localPkg) getPkg() (io.ReadCloser, int64, error) {
   294  	pathToLocalTgz := p.Pkg
   295  	localTgzFileInfo, err := os.Stat(pathToLocalTgz)
   296  	if err != nil {
   297  		if os.IsNotExist(err) {
   298  			return nil, 0, errors.Errorf("%s does not exist", pathToLocalTgz)
   299  		}
   300  		return nil, 0, errors.WithStack(err)
   301  	} else if localTgzFileInfo.IsDir() {
   302  		return nil, 0, errors.Errorf("%s is a directory", pathToLocalTgz)
   303  	}
   304  	srcTgzFile, err := os.Open(pathToLocalTgz)
   305  	if err != nil {
   306  		return nil, 0, errors.Wrapf(err, "failed to open %s", pathToLocalTgz)
   307  	}
   308  	return srcTgzFile, localTgzFileInfo.Size(), nil
   309  }
   310  
   311  func (p localPkg) name() string {
   312  	return path.Base(p.Pkg)
   313  }
   314  
   315  func (p localPkg) path() string {
   316  	return p.Pkg
   317  }
   318  
   319  func (p localPkg) checksum() string {
   320  	return p.Checksum
   321  }
   322  
   323  func sha256Checksum(filename string) (string, error) {
   324  	bytes, err := ioutil.ReadFile(filename)
   325  	if err != nil {
   326  		return "", errors.Wrapf(err, "failed to read file %s", filename)
   327  	}
   328  	sha256Checksum := sha256.Sum256(bytes)
   329  	return hex.EncodeToString(sha256Checksum[:]), nil
   330  }