github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/providercache/package_install.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package providercache
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"io/ioutil"
    10  	"net/http"
    11  	"os"
    12  	"path/filepath"
    13  
    14  	getter "github.com/hashicorp/go-getter"
    15  
    16  	"github.com/terramate-io/tf/copy"
    17  	"github.com/terramate-io/tf/getproviders"
    18  	"github.com/terramate-io/tf/httpclient"
    19  )
    20  
    21  // We borrow the "unpack a zip file into a target directory" logic from
    22  // go-getter, even though we're not otherwise using go-getter here.
    23  // (We don't need the same flexibility as we have for modules, because
    24  // providers _always_ come from provider registries, which have a very
    25  // specific protocol and set of expectations.)
    26  var unzip = getter.ZipDecompressor{}
    27  
    28  func installFromHTTPURL(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) {
    29  	url := meta.Location.String()
    30  
    31  	// When we're installing from an HTTP URL we expect the URL to refer to
    32  	// a zip file. We'll fetch that into a temporary file here and then
    33  	// delegate to installFromLocalArchive below to actually extract it.
    34  	// (We're not using go-getter here because its HTTP getter has a bunch
    35  	// of extraneous functionality we don't need or want, like indirection
    36  	// through X-Terraform-Get header, attempting partial fetches for
    37  	// files that already exist, etc.)
    38  
    39  	httpClient := httpclient.New()
    40  	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    41  	if err != nil {
    42  		return nil, fmt.Errorf("invalid provider download request: %s", err)
    43  	}
    44  	resp, err := httpClient.Do(req)
    45  	if err != nil {
    46  		if ctx.Err() == context.Canceled {
    47  			// "context canceled" is not a user-friendly error message,
    48  			// so we'll return a more appropriate one here.
    49  			return nil, fmt.Errorf("provider download was interrupted")
    50  		}
    51  		return nil, fmt.Errorf("%s: %w", getproviders.HostFromRequest(req), err)
    52  	}
    53  	defer resp.Body.Close()
    54  
    55  	if resp.StatusCode != http.StatusOK {
    56  		return nil, fmt.Errorf("unsuccessful request to %s: %s", url, resp.Status)
    57  	}
    58  
    59  	f, err := ioutil.TempFile("", "terraform-provider")
    60  	if err != nil {
    61  		return nil, fmt.Errorf("failed to open temporary file to download from %s: %w", url, err)
    62  	}
    63  	defer f.Close()
    64  	defer os.Remove(f.Name())
    65  
    66  	// We'll borrow go-getter's "cancelable copy" implementation here so that
    67  	// the download can potentially be interrupted partway through.
    68  	n, err := getter.Copy(ctx, f, resp.Body)
    69  	if err == nil && n < resp.ContentLength {
    70  		err = fmt.Errorf("incorrect response size: expected %d bytes, but got %d bytes", resp.ContentLength, n)
    71  	}
    72  	if err != nil {
    73  		return nil, err
    74  	}
    75  
    76  	archiveFilename := f.Name()
    77  	localLocation := getproviders.PackageLocalArchive(archiveFilename)
    78  
    79  	var authResult *getproviders.PackageAuthenticationResult
    80  	if meta.Authentication != nil {
    81  		if authResult, err = meta.Authentication.AuthenticatePackage(localLocation); err != nil {
    82  			return authResult, err
    83  		}
    84  	}
    85  
    86  	// We can now delegate to installFromLocalArchive for extraction. To do so,
    87  	// we construct a new package meta description using the local archive
    88  	// path as the location, and skipping authentication. installFromLocalMeta
    89  	// is responsible for verifying that the archive matches the allowedHashes,
    90  	// though.
    91  	localMeta := getproviders.PackageMeta{
    92  		Provider:         meta.Provider,
    93  		Version:          meta.Version,
    94  		ProtocolVersions: meta.ProtocolVersions,
    95  		TargetPlatform:   meta.TargetPlatform,
    96  		Filename:         meta.Filename,
    97  		Location:         localLocation,
    98  		Authentication:   nil,
    99  	}
   100  	if _, err := installFromLocalArchive(ctx, localMeta, targetDir, allowedHashes); err != nil {
   101  		return nil, err
   102  	}
   103  	return authResult, nil
   104  }
   105  
   106  func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) {
   107  	var authResult *getproviders.PackageAuthenticationResult
   108  	if meta.Authentication != nil {
   109  		var err error
   110  		if authResult, err = meta.Authentication.AuthenticatePackage(meta.Location); err != nil {
   111  			return nil, err
   112  		}
   113  	}
   114  
   115  	if len(allowedHashes) > 0 {
   116  		if matches, err := meta.MatchesAnyHash(allowedHashes); err != nil {
   117  			return authResult, fmt.Errorf(
   118  				"failed to calculate checksum for %s %s package at %s: %s",
   119  				meta.Provider, meta.Version, meta.Location, err,
   120  			)
   121  		} else if !matches {
   122  			return authResult, fmt.Errorf(
   123  				"the current package for %s %s doesn't match any of the checksums previously recorded in the dependency lock file; for more information: https://www.terraform.io/language/provider-checksum-verification",
   124  				meta.Provider, meta.Version,
   125  			)
   126  		}
   127  	}
   128  
   129  	filename := meta.Location.String()
   130  
   131  	// NOTE: We're not checking whether there's already a directory at
   132  	// targetDir with some files in it. Packages are supposed to be immutable
   133  	// and therefore we'll just be overwriting all of the existing files with
   134  	// their same contents unless something unusual is happening. If something
   135  	// unusual _is_ happening then this will produce something that doesn't
   136  	// match the allowed hashes and so our caller should catch that after
   137  	// we return if so.
   138  
   139  	err := unzip.Decompress(targetDir, filename, true, 0000)
   140  	if err != nil {
   141  		return authResult, err
   142  	}
   143  
   144  	return authResult, nil
   145  }
   146  
   147  // installFromLocalDir is the implementation of both installing a package from
   148  // a local directory source _and_ of linking a package from another cache
   149  // in LinkFromOtherCache, because they both do fundamentally the same
   150  // operation: symlink if possible, or deep-copy otherwise.
   151  func installFromLocalDir(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) {
   152  	sourceDir := meta.Location.String()
   153  
   154  	absNew, err := filepath.Abs(targetDir)
   155  	if err != nil {
   156  		return nil, fmt.Errorf("failed to make target path %s absolute: %s", targetDir, err)
   157  	}
   158  	absCurrent, err := filepath.Abs(sourceDir)
   159  	if err != nil {
   160  		return nil, fmt.Errorf("failed to make source path %s absolute: %s", sourceDir, err)
   161  	}
   162  
   163  	// Before we do anything else, we'll do a quick check to make sure that
   164  	// these two paths are not pointing at the same physical directory on
   165  	// disk. This compares the files by their OS-level device and directory
   166  	// entry identifiers, not by their virtual filesystem paths.
   167  	if same, err := copy.SameFile(absNew, absCurrent); same {
   168  		return nil, fmt.Errorf("cannot install existing provider directory %s to itself", targetDir)
   169  	} else if err != nil {
   170  		return nil, fmt.Errorf("failed to determine if %s and %s are the same: %s", sourceDir, targetDir, err)
   171  	}
   172  
   173  	var authResult *getproviders.PackageAuthenticationResult
   174  	if meta.Authentication != nil {
   175  		// (we have this here for completeness but note that local filesystem
   176  		// mirrors typically don't include enough information for package
   177  		// authentication and so we'll rarely get in here in practice.)
   178  		var err error
   179  		if authResult, err = meta.Authentication.AuthenticatePackage(meta.Location); err != nil {
   180  			return nil, err
   181  		}
   182  	}
   183  
   184  	// If the caller provided at least one hash in allowedHashes then at
   185  	// least one of those hashes ought to match. However, for local directories
   186  	// in particular we can't actually verify the legacy "zh:" hash scheme
   187  	// because it requires access to the original .zip archive, and so as a
   188  	// measure of pragmatism we'll treat a set of hashes where all are "zh:"
   189  	// the same as no hashes at all, and let anything pass. This is definitely
   190  	// non-ideal but accepted for two reasons:
   191  	// - Packages we find on local disk can be considered a little more trusted
   192  	//   than packages coming from over the network, because we assume that
   193  	//   they were either placed intentionally by an operator or they were
   194  	//   automatically installed by a previous network operation that would've
   195  	//   itself verified the hashes.
   196  	// - Our installer makes a concerted effort to record at least one new-style
   197  	//   hash for each lock entry, so we should very rarely end up in this
   198  	//   situation anyway.
   199  	suitableHashCount := 0
   200  	for _, hash := range allowedHashes {
   201  		if !hash.HasScheme(getproviders.HashSchemeZip) {
   202  			suitableHashCount++
   203  		}
   204  	}
   205  	if suitableHashCount > 0 {
   206  		if matches, err := meta.MatchesAnyHash(allowedHashes); err != nil {
   207  			return authResult, fmt.Errorf(
   208  				"failed to calculate checksum for %s %s package at %s: %s",
   209  				meta.Provider, meta.Version, meta.Location, err,
   210  			)
   211  		} else if !matches {
   212  			return authResult, fmt.Errorf(
   213  				"the local package for %s %s doesn't match any of the checksums previously recorded in the dependency lock file (this might be because the available checksums are for packages targeting different platforms); for more information: https://www.terraform.io/language/provider-checksum-verification",
   214  				meta.Provider, meta.Version,
   215  			)
   216  		}
   217  	}
   218  
   219  	// Delete anything that's already present at this path first.
   220  	err = os.RemoveAll(targetDir)
   221  	if err != nil && !os.IsNotExist(err) {
   222  		return nil, fmt.Errorf("failed to remove existing %s before linking it to %s: %s", sourceDir, targetDir, err)
   223  	}
   224  
   225  	// We'll prefer to create a symlink if possible, but we'll fall back to
   226  	// a recursive copy if symlink creation fails. It could fail for a number
   227  	// of reasons, including being on Windows 8 without administrator
   228  	// privileges or being on a legacy filesystem like FAT that has no way
   229  	// to represent a symlink. (Generalized symlink support for Windows was
   230  	// introduced in a Windows 10 minor update.)
   231  	//
   232  	// We use an absolute path for the symlink to reduce the risk of it being
   233  	// broken by moving things around later, since the source directory is
   234  	// likely to be a shared directory independent on any particular target
   235  	// and thus we can't assume that they will move around together.
   236  	linkTarget := absCurrent
   237  
   238  	parentDir := filepath.Dir(absNew)
   239  	err = os.MkdirAll(parentDir, 0755)
   240  	if err != nil {
   241  		return nil, fmt.Errorf("failed to create parent directories leading to %s: %s", targetDir, err)
   242  	}
   243  
   244  	err = os.Symlink(linkTarget, absNew)
   245  	if err == nil {
   246  		// Success, then!
   247  		return nil, nil
   248  	}
   249  
   250  	// If we get down here then symlinking failed and we need a deep copy
   251  	// instead. To make a copy, we first need to create the target directory,
   252  	// which would otherwise be a symlink.
   253  	err = os.Mkdir(absNew, 0755)
   254  	if err != nil && os.IsExist(err) {
   255  		return nil, fmt.Errorf("failed to create directory %s: %s", absNew, err)
   256  	}
   257  	err = copy.CopyDir(absNew, absCurrent)
   258  	if err != nil {
   259  		return nil, fmt.Errorf("failed to either symlink or copy %s to %s: %s", absCurrent, absNew, err)
   260  	}
   261  
   262  	// If we got here then apparently our copy succeeded, so we're done.
   263  	return nil, nil
   264  }