mirror of
https://github.com/opentofu/opentofu.git
synced 2026-02-18 18:17:54 -05:00
getproviders: PackageLocation now knows how to install from itself
Previously we treated PackageLocation only as pure data, describing a location from which something could be retrieved. Unexported functions in package providercache then treated PackageLocation values as a closed union dispatched using a type switch. That strategy has been okay for our relatively-generic package locations so far, but in a future commit we intend to add a new kind of package location referring to a manifest in an OCI distribution repository, and installing from _that_ will require a nontrivial amount of OCI-distribution-specific logic that will be more tightly coupled to the getproviders.Source that will return such locations, and so we're switching to a model where _the location object itself_ is responsible for knowing how to install a provider package from its location, as a method of PackageLocation. The implementation of installing from each location type now moves from package providercache to package getproviders, which is arguably a better thematic home for that functionality anyway. For now these remain tested only indirectly through the tests in package providercache, since we didn't previously have any independent tests for these unexported functions. We might want to add more tightly-scoped unit tests for these to package getproviders in future, but for now this is not materially worse than it was before. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
parent
6f04489f23
commit
bcdadc80ee
8 changed files with 387 additions and 355 deletions
147
internal/getproviders/package_location_http_archive.go
Normal file
147
internal/getproviders/package_location_http_archive.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package getproviders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/hashicorp/go-getter"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"github.com/opentofu/opentofu/internal/httpclient"
|
||||
"github.com/opentofu/opentofu/internal/logging"
|
||||
)
|
||||
|
||||
// PackageHTTPURL is a provider package location accessible via HTTP.
|
||||
//
|
||||
// Its value is a URL string using either the http: scheme or the https: scheme.
|
||||
// The URL should respond with a .zip archive whose contents are to be extracted
|
||||
// into a local package directory.
|
||||
type PackageHTTPURL string
|
||||
|
||||
var _ PackageLocation = PackageHTTPURL("")
|
||||
|
||||
func (p PackageHTTPURL) String() string { return string(p) }
|
||||
|
||||
func (p PackageHTTPURL) InstallProviderPackage(ctx context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) {
|
||||
url := meta.Location.String()
|
||||
|
||||
// When we're installing from an HTTP URL we expect the URL to refer to
|
||||
// a zip file. We'll fetch that into a temporary file here and then
|
||||
// delegate to installFromLocalArchive below to actually extract it.
|
||||
// (We're not using go-getter here because its HTTP getter has a bunch
|
||||
// of extraneous functionality we don't need or want, like indirection
|
||||
// through X-Terraform-Get header, attempting partial fetches for
|
||||
// files that already exist, etc.)
|
||||
|
||||
retryableClient := retryablehttp.NewClient()
|
||||
retryableClient.HTTPClient = httpclient.New()
|
||||
retryableClient.RetryMax = maxHTTPPackageRetryCount
|
||||
retryableClient.RequestLogHook = func(logger retryablehttp.Logger, _ *http.Request, i int) {
|
||||
if i > 0 {
|
||||
logger.Printf("[INFO] failed to fetch provider package; retrying")
|
||||
}
|
||||
}
|
||||
retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags())
|
||||
|
||||
req, err := retryablehttp.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid provider download request: %w", err)
|
||||
}
|
||||
resp, err := retryableClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.Canceled {
|
||||
// "context canceled" is not a user-friendly error message,
|
||||
// so we'll return a more appropriate one here.
|
||||
return nil, fmt.Errorf("provider download was interrupted")
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", HostFromRequest(req.Request), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unsuccessful request to %s: %s", url, resp.Status)
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "terraform-provider")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open temporary file to download from %s: %w", url, err)
|
||||
}
|
||||
defer f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
// We'll borrow go-getter's "cancelable copy" implementation here so that
|
||||
// the download can potentially be interrupted partway through.
|
||||
n, err := getter.Copy(ctx, f, resp.Body)
|
||||
if err == nil && n < resp.ContentLength {
|
||||
err = fmt.Errorf("incorrect response size: expected %d bytes, but got %d bytes", resp.ContentLength, n)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
archiveFilename := f.Name()
|
||||
localLocation := PackageLocalArchive(archiveFilename)
|
||||
|
||||
var authResult *PackageAuthenticationResult
|
||||
if meta.Authentication != nil {
|
||||
if authResult, err = meta.Authentication.AuthenticatePackage(localLocation); err != nil {
|
||||
return authResult, err
|
||||
}
|
||||
}
|
||||
|
||||
// We can now delegate to localLocation for extraction. To do so,
|
||||
// we construct a new package meta description using the local archive
|
||||
// path as the location, and skipping authentication. installFromLocalMeta
|
||||
// is responsible for verifying that the archive matches the allowedHashes,
|
||||
// though.
|
||||
localMeta := PackageMeta{
|
||||
Provider: meta.Provider,
|
||||
Version: meta.Version,
|
||||
ProtocolVersions: meta.ProtocolVersions,
|
||||
TargetPlatform: meta.TargetPlatform,
|
||||
Filename: meta.Filename,
|
||||
Location: localLocation,
|
||||
Authentication: nil,
|
||||
}
|
||||
if _, err := localLocation.InstallProviderPackage(ctx, localMeta, targetDir, allowedHashes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return authResult, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// httpClientRetryCountEnvName is the environment variable name used to customize
|
||||
// the HTTP retry count for module downloads.
|
||||
httpClientRetryCountEnvName = "TF_PROVIDER_DOWNLOAD_RETRY"
|
||||
|
||||
httpClientDefaultRetry = 2
|
||||
)
|
||||
|
||||
//nolint:gochecknoinits // this init function predates our use of this linter
|
||||
func init() {
|
||||
configureProviderDownloadRetry()
|
||||
}
|
||||
|
||||
var (
|
||||
//nolint:gochecknoglobals // this variable predates our use of this linter
|
||||
maxHTTPPackageRetryCount int
|
||||
)
|
||||
|
||||
// will attempt for requests with retryable errors, like 502 status codes
|
||||
func configureProviderDownloadRetry() {
|
||||
maxHTTPPackageRetryCount = httpClientDefaultRetry
|
||||
if v := os.Getenv(httpClientRetryCountEnvName); v != "" {
|
||||
retry, err := strconv.Atoi(v)
|
||||
if err == nil && retry > 0 {
|
||||
maxHTTPPackageRetryCount = retry
|
||||
}
|
||||
}
|
||||
}
|
||||
74
internal/getproviders/package_location_local_archive.go
Normal file
74
internal/getproviders/package_location_local_archive.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package getproviders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/go-getter"
|
||||
)
|
||||
|
||||
// We borrow the "unpack a zip file into a target directory" logic from
|
||||
// go-getter, even though we're not otherwise using go-getter here.
|
||||
// (We don't need the same flexibility as we have for modules, because
|
||||
// providers _always_ come from provider registries, which have a very
|
||||
// specific protocol and set of expectations.)
|
||||
//
|
||||
//nolint:gochecknoglobals // this variable predates our use of this linter
|
||||
var unzip = getter.ZipDecompressor{}
|
||||
|
||||
// PackageLocalArchive is the location of a provider distribution archive file
|
||||
// in the local filesystem. Its value is a local filesystem path using the
|
||||
// syntax understood by Go's standard path/filepath package on the operating
|
||||
// system where OpenTofu is running.
|
||||
type PackageLocalArchive string
|
||||
|
||||
var _ PackageLocation = PackageLocalArchive("")
|
||||
|
||||
func (p PackageLocalArchive) String() string { return string(p) }
|
||||
|
||||
func (p PackageLocalArchive) InstallProviderPackage(_ context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) {
|
||||
var authResult *PackageAuthenticationResult
|
||||
if meta.Authentication != nil {
|
||||
var err error
|
||||
if authResult, err = meta.Authentication.AuthenticatePackage(meta.Location); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedHashes) > 0 {
|
||||
if matches, err := meta.MatchesAnyHash(allowedHashes); err != nil {
|
||||
return authResult, fmt.Errorf(
|
||||
"failed to calculate checksum for %s %s package at %s: %w",
|
||||
meta.Provider, meta.Version, meta.Location, err,
|
||||
)
|
||||
} else if !matches {
|
||||
return authResult, fmt.Errorf(
|
||||
"the current package for %s %s doesn't match any of the checksums previously recorded in the dependency lock file; for more information: https://opentofu.org/docs/language/files/dependency-lock/#checksum-verification",
|
||||
meta.Provider, meta.Version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
filename := meta.Location.String()
|
||||
|
||||
// NOTE: We're not checking whether there's already a directory at
|
||||
// targetDir with some files in it. Packages are supposed to be immutable
|
||||
// and therefore we'll just be overwriting all of the existing files with
|
||||
// their same contents unless something unusual is happening. If something
|
||||
// unusual _is_ happening then this will produce something that doesn't
|
||||
// match the allowed hashes and so our caller should catch that after
|
||||
// we return if so.
|
||||
|
||||
//nolint:mnd // magic number predates us using this linter
|
||||
err := unzip.Decompress(targetDir, filename, true, 0000)
|
||||
if err != nil {
|
||||
return authResult, err
|
||||
}
|
||||
|
||||
return authResult, nil
|
||||
}
|
||||
145
internal/getproviders/package_location_local_dir.go
Normal file
145
internal/getproviders/package_location_local_dir.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package getproviders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/copy"
|
||||
)
|
||||
|
||||
// PackageLocalDir is the location of a directory containing an unpacked
|
||||
// provider distribution archive in the local filesystem. Its value is a local
|
||||
// filesystem path using the syntax understood by Go's standard path/filepath
|
||||
// package on the operating system where OpenTofu is running.
|
||||
type PackageLocalDir string
|
||||
|
||||
var _ PackageLocation = PackageLocalDir("")
|
||||
|
||||
func (p PackageLocalDir) String() string { return string(p) }
|
||||
|
||||
func (p PackageLocalDir) InstallProviderPackage(_ context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) {
|
||||
sourceDir := meta.Location.String()
|
||||
|
||||
absNew, err := filepath.Abs(targetDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make target path %s absolute: %w", targetDir, err)
|
||||
}
|
||||
absCurrent, err := filepath.Abs(sourceDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make source path %s absolute: %w", sourceDir, err)
|
||||
}
|
||||
|
||||
// Before we do anything else, we'll do a quick check to make sure that
|
||||
// these two paths are not pointing at the same physical directory on
|
||||
// disk. This compares the files by their OS-level device and directory
|
||||
// entry identifiers, not by their virtual filesystem paths.
|
||||
var same bool
|
||||
if same, err = copy.SameFile(absNew, absCurrent); same {
|
||||
return nil, fmt.Errorf("cannot install existing provider directory %s to itself", targetDir)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to determine if %s and %s are the same: %w", sourceDir, targetDir, err)
|
||||
}
|
||||
|
||||
var authResult *PackageAuthenticationResult
|
||||
if meta.Authentication != nil {
|
||||
// (we have this here for completeness but note that local filesystem
|
||||
// mirrors typically don't include enough information for package
|
||||
// authentication and so we'll rarely get in here in practice.)
|
||||
if authResult, err = meta.Authentication.AuthenticatePackage(meta.Location); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If the caller provided at least one hash in allowedHashes then at
|
||||
// least one of those hashes ought to match. However, for local directories
|
||||
// in particular we can't actually verify the legacy "zh:" hash scheme
|
||||
// because it requires access to the original .zip archive, and so as a
|
||||
// measure of pragmatism we'll treat a set of hashes where all are "zh:"
|
||||
// the same as no hashes at all, and let anything pass. This is definitely
|
||||
// non-ideal but accepted for two reasons:
|
||||
// - Packages we find on local disk can be considered a little more trusted
|
||||
// than packages coming from over the network, because we assume that
|
||||
// they were either placed intentionally by an operator or they were
|
||||
// automatically installed by a previous network operation that would've
|
||||
// itself verified the hashes.
|
||||
// - Our installer makes a concerted effort to record at least one new-style
|
||||
// hash for each lock entry, so we should very rarely end up in this
|
||||
// situation anyway.
|
||||
suitableHashCount := 0
|
||||
for _, hash := range allowedHashes {
|
||||
if !hash.HasScheme(HashSchemeZip) {
|
||||
suitableHashCount++
|
||||
}
|
||||
}
|
||||
if suitableHashCount > 0 {
|
||||
var matches bool
|
||||
if matches, err = meta.MatchesAnyHash(allowedHashes); err != nil {
|
||||
return authResult, fmt.Errorf(
|
||||
"failed to calculate checksum for %s %s package at %s: %w",
|
||||
meta.Provider, meta.Version, meta.Location, err,
|
||||
)
|
||||
} else if !matches {
|
||||
return authResult, fmt.Errorf(
|
||||
"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://opentofu.org/docs/language/files/dependency-lock/#checksum-verification",
|
||||
meta.Provider, meta.Version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete anything that's already present at this path first.
|
||||
err = os.RemoveAll(targetDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to remove existing %s before linking it to %s: %w", sourceDir, targetDir, err)
|
||||
}
|
||||
|
||||
// We'll prefer to create a symlink if possible, but we'll fall back to
|
||||
// a recursive copy if symlink creation fails. It could fail for a number
|
||||
// of reasons, including being on Windows 8 without administrator
|
||||
// privileges or being on a legacy filesystem like FAT that has no way
|
||||
// to represent a symlink. (Generalized symlink support for Windows was
|
||||
// introduced in a Windows 10 minor update.)
|
||||
//
|
||||
// We use an absolute path for the symlink to reduce the risk of it being
|
||||
// broken by moving things around later, since the source directory is
|
||||
// likely to be a shared directory independent on any particular target
|
||||
// and thus we can't assume that they will move around together.
|
||||
linkTarget := absCurrent
|
||||
|
||||
parentDir := filepath.Dir(absNew)
|
||||
//nolint:mnd // magic number predates us using this linter
|
||||
err = os.MkdirAll(parentDir, 0755)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create parent directories leading to %s: %w", targetDir, err)
|
||||
}
|
||||
|
||||
err = os.Symlink(linkTarget, absNew)
|
||||
if err == nil {
|
||||
// Success, then!
|
||||
//nolint:nilnil // this API predates our use of this linter and callers rely on this behavior
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we get down here then symlinking failed and we need a deep copy
|
||||
// instead. To make a copy, we first need to create the target directory,
|
||||
// which would otherwise be a symlink.
|
||||
//nolint:mnd // magic number predates us using this linter
|
||||
err = os.Mkdir(absNew, 0755)
|
||||
if err != nil && os.IsExist(err) {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", absNew, err)
|
||||
}
|
||||
err = copy.CopyDir(absNew, absCurrent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to either symlink or copy %s to %s: %w", absCurrent, absNew, err)
|
||||
}
|
||||
|
||||
// If we got here then apparently our copy succeeded, so we're done.
|
||||
//nolint:nilnil // this API predates our use of this linter and callers rely on this behavior
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ const (
|
|||
// can be configured to customize number of retries for module and provider
|
||||
// discovery requests with the remote registry.
|
||||
registryDiscoveryRetryEnvName = "TF_REGISTRY_DISCOVERY_RETRY"
|
||||
defaultRetry = 1
|
||||
registryClientDefaultRetry = 1
|
||||
|
||||
// registryClientTimeoutEnvName is the name of the environment variable that
|
||||
// can be configured to customize the timeout duration (seconds) for module
|
||||
|
|
@ -456,7 +456,7 @@ func (c *registryClient) getFile(url *url.URL) ([]byte, error) {
|
|||
// configureDiscoveryRetry configures the number of retries the registry client
|
||||
// will attempt for requests with retryable errors, like 502 status codes
|
||||
func configureDiscoveryRetry() {
|
||||
discoveryRetry = defaultRetry
|
||||
discoveryRetry = registryClientDefaultRetry
|
||||
|
||||
if v := os.Getenv(registryDiscoveryRetryEnvName); v != "" {
|
||||
retry, err := strconv.Atoi(v)
|
||||
|
|
|
|||
|
|
@ -25,20 +25,20 @@ import (
|
|||
|
||||
func TestConfigureDiscoveryRetry(t *testing.T) {
|
||||
t.Run("default retry", func(t *testing.T) {
|
||||
if discoveryRetry != defaultRetry {
|
||||
t.Fatalf("expected retry %q, got %q", defaultRetry, discoveryRetry)
|
||||
if discoveryRetry != registryClientDefaultRetry {
|
||||
t.Fatalf("expected retry %q, got %q", registryClientDefaultRetry, discoveryRetry)
|
||||
}
|
||||
|
||||
rc := newRegistryClient(nil, nil)
|
||||
if rc.httpClient.RetryMax != defaultRetry {
|
||||
if rc.httpClient.RetryMax != registryClientDefaultRetry {
|
||||
t.Fatalf("expected client retry %q, got %q",
|
||||
defaultRetry, rc.httpClient.RetryMax)
|
||||
registryClientDefaultRetry, rc.httpClient.RetryMax)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("configured retry", func(t *testing.T) {
|
||||
defer func() {
|
||||
discoveryRetry = defaultRetry
|
||||
discoveryRetry = registryClientDefaultRetry
|
||||
}()
|
||||
t.Setenv(registryDiscoveryRetryEnvName, "2")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
package getproviders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sort"
|
||||
|
|
@ -325,38 +326,21 @@ func (m PackageMeta) AcceptableHashes() []Hash {
|
|||
}
|
||||
|
||||
// PackageLocation represents a location where a provider distribution package
|
||||
// can be obtained. A value of this type contains one of the following
|
||||
// concrete types: PackageLocalArchive, PackageLocalDir, or PackageHTTPURL.
|
||||
// can be obtained.
|
||||
type PackageLocation interface {
|
||||
packageLocation()
|
||||
// InstallProviderPackage installs the provider package at the location
|
||||
// represented by the implementer into a new directory at targetDir,
|
||||
// and then verifies both that the newly-created package directory
|
||||
// matches at least one of allowedHashes (if any) and that the
|
||||
// authentication strategy represented by meta.Authentication succeeds.
|
||||
InstallProviderPackage(ctx context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error)
|
||||
|
||||
// String returns a concise string representation of the package location
|
||||
// that is suitable to include in the UI to explain where a package
|
||||
// is being installed from.
|
||||
String() string
|
||||
}
|
||||
|
||||
// PackageLocalArchive is the location of a provider distribution archive file
|
||||
// in the local filesystem. Its value is a local filesystem path using the
|
||||
// syntax understood by Go's standard path/filepath package on the operating
|
||||
// system where OpenTofu is running.
|
||||
type PackageLocalArchive string
|
||||
|
||||
func (p PackageLocalArchive) packageLocation() {}
|
||||
func (p PackageLocalArchive) String() string { return string(p) }
|
||||
|
||||
// PackageLocalDir is the location of a directory containing an unpacked
|
||||
// provider distribution archive in the local filesystem. Its value is a local
|
||||
// filesystem path using the syntax understood by Go's standard path/filepath
|
||||
// package on the operating system where OpenTofu is running.
|
||||
type PackageLocalDir string
|
||||
|
||||
func (p PackageLocalDir) packageLocation() {}
|
||||
func (p PackageLocalDir) String() string { return string(p) }
|
||||
|
||||
// PackageHTTPURL is a provider package location accessible via HTTP.
|
||||
// Its value is a URL string using either the http: scheme or the https: scheme.
|
||||
type PackageHTTPURL string
|
||||
|
||||
func (p PackageHTTPURL) packageLocation() {}
|
||||
func (p PackageHTTPURL) String() string { return string(p) }
|
||||
|
||||
// PackageMetaList is a list of PackageMeta. It's just []PackageMeta with
|
||||
// some methods for convenient sorting and filtering.
|
||||
type PackageMetaList []PackageMeta
|
||||
|
|
|
|||
|
|
@ -34,18 +34,7 @@ func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta,
|
|||
d.metaCache = nil
|
||||
|
||||
log.Printf("[TRACE] providercache.Dir.InstallPackage: installing %s v%s from %s", meta.Provider, meta.Version, meta.Location)
|
||||
switch meta.Location.(type) {
|
||||
case getproviders.PackageHTTPURL:
|
||||
return installFromHTTPURL(ctx, meta, newPath, allowedHashes)
|
||||
case getproviders.PackageLocalArchive:
|
||||
return installFromLocalArchive(ctx, meta, newPath, allowedHashes)
|
||||
case getproviders.PackageLocalDir:
|
||||
return installFromLocalDir(ctx, meta, newPath, allowedHashes)
|
||||
default:
|
||||
// Should not get here, because the above should be exhaustive for
|
||||
// all implementations of getproviders.Location.
|
||||
return nil, fmt.Errorf("don't know how to install from a %T location", meta.Location)
|
||||
}
|
||||
return meta.Location.InstallProviderPackage(ctx, meta, newPath, allowedHashes)
|
||||
}
|
||||
|
||||
// LinkFromOtherCache takes a CachedProvider value produced from another Dir
|
||||
|
|
@ -107,6 +96,6 @@ func (d *Dir) LinkFromOtherCache(entry *CachedProvider, allowedHashes []getprovi
|
|||
}
|
||||
// No further hash check here because we already checked the hash
|
||||
// of the source directory above.
|
||||
_, err := installFromLocalDir(context.TODO(), meta, newPath, nil)
|
||||
_, err := meta.Location.InstallProviderPackage(context.TODO(), meta, newPath, nil)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,307 +0,0 @@
|
|||
// Copyright (c) The OpenTofu Authors
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
// Copyright (c) 2023 HashiCorp, Inc.
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package providercache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
getter "github.com/hashicorp/go-getter"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
|
||||
"github.com/opentofu/opentofu/internal/copy"
|
||||
"github.com/opentofu/opentofu/internal/getproviders"
|
||||
"github.com/opentofu/opentofu/internal/httpclient"
|
||||
"github.com/opentofu/opentofu/internal/logging"
|
||||
)
|
||||
|
||||
// We borrow the "unpack a zip file into a target directory" logic from
|
||||
// go-getter, even though we're not otherwise using go-getter here.
|
||||
// (We don't need the same flexibility as we have for modules, because
|
||||
// providers _always_ come from provider registries, which have a very
|
||||
// specific protocol and set of expectations.)
|
||||
var unzip = getter.ZipDecompressor{}
|
||||
|
||||
const (
|
||||
// httpClientRetryCountEnvName is the environment variable name used to customize
|
||||
// the HTTP retry count for module downloads.
|
||||
httpClientRetryCountEnvName = "TF_PROVIDER_DOWNLOAD_RETRY"
|
||||
|
||||
defaultRetry = 2
|
||||
)
|
||||
|
||||
func init() {
|
||||
configureProviderDownloadRetry()
|
||||
}
|
||||
|
||||
var (
|
||||
maxRetryCount int
|
||||
)
|
||||
|
||||
// will attempt for requests with retryable errors, like 502 status codes
|
||||
func configureProviderDownloadRetry() {
|
||||
maxRetryCount = defaultRetry
|
||||
if v := os.Getenv(httpClientRetryCountEnvName); v != "" {
|
||||
retry, err := strconv.Atoi(v)
|
||||
if err == nil && retry > 0 {
|
||||
maxRetryCount = retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) {
|
||||
if i > 0 {
|
||||
logger.Printf("[INFO] Previous request to the provider install failed, attempting retry.")
|
||||
}
|
||||
}
|
||||
|
||||
func installFromHTTPURL(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) {
|
||||
url := meta.Location.String()
|
||||
|
||||
// When we're installing from an HTTP URL we expect the URL to refer to
|
||||
// a zip file. We'll fetch that into a temporary file here and then
|
||||
// delegate to installFromLocalArchive below to actually extract it.
|
||||
// (We're not using go-getter here because its HTTP getter has a bunch
|
||||
// of extraneous functionality we don't need or want, like indirection
|
||||
// through X-Terraform-Get header, attempting partial fetches for
|
||||
// files that already exist, etc.)
|
||||
|
||||
retryableClient := retryablehttp.NewClient()
|
||||
retryableClient.HTTPClient = httpclient.New()
|
||||
retryableClient.RetryMax = maxRetryCount
|
||||
retryableClient.RequestLogHook = requestLogHook
|
||||
retryableClient.Logger = log.New(logging.LogOutput(), "", log.Flags())
|
||||
|
||||
req, err := retryablehttp.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid provider download request: %w", err)
|
||||
}
|
||||
resp, err := retryableClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.Canceled {
|
||||
// "context canceled" is not a user-friendly error message,
|
||||
// so we'll return a more appropriate one here.
|
||||
return nil, fmt.Errorf("provider download was interrupted")
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %w", getproviders.HostFromRequest(req.Request), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unsuccessful request to %s: %s", url, resp.Status)
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "terraform-provider")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open temporary file to download from %s: %w", url, err)
|
||||
}
|
||||
defer f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
// We'll borrow go-getter's "cancelable copy" implementation here so that
|
||||
// the download can potentially be interrupted partway through.
|
||||
n, err := getter.Copy(ctx, f, resp.Body)
|
||||
if err == nil && n < resp.ContentLength {
|
||||
err = fmt.Errorf("incorrect response size: expected %d bytes, but got %d bytes", resp.ContentLength, n)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
archiveFilename := f.Name()
|
||||
localLocation := getproviders.PackageLocalArchive(archiveFilename)
|
||||
|
||||
var authResult *getproviders.PackageAuthenticationResult
|
||||
if meta.Authentication != nil {
|
||||
if authResult, err = meta.Authentication.AuthenticatePackage(localLocation); err != nil {
|
||||
return authResult, err
|
||||
}
|
||||
}
|
||||
|
||||
// We can now delegate to installFromLocalArchive for extraction. To do so,
|
||||
// we construct a new package meta description using the local archive
|
||||
// path as the location, and skipping authentication. installFromLocalMeta
|
||||
// is responsible for verifying that the archive matches the allowedHashes,
|
||||
// though.
|
||||
localMeta := getproviders.PackageMeta{
|
||||
Provider: meta.Provider,
|
||||
Version: meta.Version,
|
||||
ProtocolVersions: meta.ProtocolVersions,
|
||||
TargetPlatform: meta.TargetPlatform,
|
||||
Filename: meta.Filename,
|
||||
Location: localLocation,
|
||||
Authentication: nil,
|
||||
}
|
||||
if _, err := installFromLocalArchive(ctx, localMeta, targetDir, allowedHashes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return authResult, nil
|
||||
}
|
||||
|
||||
func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) {
|
||||
var authResult *getproviders.PackageAuthenticationResult
|
||||
if meta.Authentication != nil {
|
||||
var err error
|
||||
if authResult, err = meta.Authentication.AuthenticatePackage(meta.Location); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedHashes) > 0 {
|
||||
if matches, err := meta.MatchesAnyHash(allowedHashes); err != nil {
|
||||
return authResult, fmt.Errorf(
|
||||
"failed to calculate checksum for %s %s package at %s: %w",
|
||||
meta.Provider, meta.Version, meta.Location, err,
|
||||
)
|
||||
} else if !matches {
|
||||
return authResult, fmt.Errorf(
|
||||
"the current package for %s %s doesn't match any of the checksums previously recorded in the dependency lock file; for more information: https://opentofu.org/docs/language/files/dependency-lock/#checksum-verification",
|
||||
meta.Provider, meta.Version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
filename := meta.Location.String()
|
||||
|
||||
// NOTE: We're not checking whether there's already a directory at
|
||||
// targetDir with some files in it. Packages are supposed to be immutable
|
||||
// and therefore we'll just be overwriting all of the existing files with
|
||||
// their same contents unless something unusual is happening. If something
|
||||
// unusual _is_ happening then this will produce something that doesn't
|
||||
// match the allowed hashes and so our caller should catch that after
|
||||
// we return if so.
|
||||
|
||||
err := unzip.Decompress(targetDir, filename, true, 0000)
|
||||
if err != nil {
|
||||
return authResult, err
|
||||
}
|
||||
|
||||
return authResult, nil
|
||||
}
|
||||
|
||||
// installFromLocalDir is the implementation of both installing a package from
|
||||
// a local directory source _and_ of linking a package from another cache
|
||||
// in LinkFromOtherCache, because they both do fundamentally the same
|
||||
// operation: symlink if possible, or deep-copy otherwise.
|
||||
func installFromLocalDir(ctx context.Context, meta getproviders.PackageMeta, targetDir string, allowedHashes []getproviders.Hash) (*getproviders.PackageAuthenticationResult, error) {
|
||||
sourceDir := meta.Location.String()
|
||||
|
||||
absNew, err := filepath.Abs(targetDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make target path %s absolute: %w", targetDir, err)
|
||||
}
|
||||
absCurrent, err := filepath.Abs(sourceDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make source path %s absolute: %w", sourceDir, err)
|
||||
}
|
||||
|
||||
// Before we do anything else, we'll do a quick check to make sure that
|
||||
// these two paths are not pointing at the same physical directory on
|
||||
// disk. This compares the files by their OS-level device and directory
|
||||
// entry identifiers, not by their virtual filesystem paths.
|
||||
if same, err := copy.SameFile(absNew, absCurrent); same {
|
||||
return nil, fmt.Errorf("cannot install existing provider directory %s to itself", targetDir)
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to determine if %s and %s are the same: %w", sourceDir, targetDir, err)
|
||||
}
|
||||
|
||||
var authResult *getproviders.PackageAuthenticationResult
|
||||
if meta.Authentication != nil {
|
||||
// (we have this here for completeness but note that local filesystem
|
||||
// mirrors typically don't include enough information for package
|
||||
// authentication and so we'll rarely get in here in practice.)
|
||||
var err error
|
||||
if authResult, err = meta.Authentication.AuthenticatePackage(meta.Location); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If the caller provided at least one hash in allowedHashes then at
|
||||
// least one of those hashes ought to match. However, for local directories
|
||||
// in particular we can't actually verify the legacy "zh:" hash scheme
|
||||
// because it requires access to the original .zip archive, and so as a
|
||||
// measure of pragmatism we'll treat a set of hashes where all are "zh:"
|
||||
// the same as no hashes at all, and let anything pass. This is definitely
|
||||
// non-ideal but accepted for two reasons:
|
||||
// - Packages we find on local disk can be considered a little more trusted
|
||||
// than packages coming from over the network, because we assume that
|
||||
// they were either placed intentionally by an operator or they were
|
||||
// automatically installed by a previous network operation that would've
|
||||
// itself verified the hashes.
|
||||
// - Our installer makes a concerted effort to record at least one new-style
|
||||
// hash for each lock entry, so we should very rarely end up in this
|
||||
// situation anyway.
|
||||
suitableHashCount := 0
|
||||
for _, hash := range allowedHashes {
|
||||
if !hash.HasScheme(getproviders.HashSchemeZip) {
|
||||
suitableHashCount++
|
||||
}
|
||||
}
|
||||
if suitableHashCount > 0 {
|
||||
if matches, err := meta.MatchesAnyHash(allowedHashes); err != nil {
|
||||
return authResult, fmt.Errorf(
|
||||
"failed to calculate checksum for %s %s package at %s: %w",
|
||||
meta.Provider, meta.Version, meta.Location, err,
|
||||
)
|
||||
} else if !matches {
|
||||
return authResult, fmt.Errorf(
|
||||
"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://opentofu.org/docs/language/files/dependency-lock/#checksum-verification",
|
||||
meta.Provider, meta.Version,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete anything that's already present at this path first.
|
||||
err = os.RemoveAll(targetDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to remove existing %s before linking it to %s: %w", sourceDir, targetDir, err)
|
||||
}
|
||||
|
||||
// We'll prefer to create a symlink if possible, but we'll fall back to
|
||||
// a recursive copy if symlink creation fails. It could fail for a number
|
||||
// of reasons, including being on Windows 8 without administrator
|
||||
// privileges or being on a legacy filesystem like FAT that has no way
|
||||
// to represent a symlink. (Generalized symlink support for Windows was
|
||||
// introduced in a Windows 10 minor update.)
|
||||
//
|
||||
// We use an absolute path for the symlink to reduce the risk of it being
|
||||
// broken by moving things around later, since the source directory is
|
||||
// likely to be a shared directory independent on any particular target
|
||||
// and thus we can't assume that they will move around together.
|
||||
linkTarget := absCurrent
|
||||
|
||||
parentDir := filepath.Dir(absNew)
|
||||
err = os.MkdirAll(parentDir, 0755)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create parent directories leading to %s: %w", targetDir, err)
|
||||
}
|
||||
|
||||
err = os.Symlink(linkTarget, absNew)
|
||||
if err == nil {
|
||||
// Success, then!
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we get down here then symlinking failed and we need a deep copy
|
||||
// instead. To make a copy, we first need to create the target directory,
|
||||
// which would otherwise be a symlink.
|
||||
err = os.Mkdir(absNew, 0755)
|
||||
if err != nil && os.IsExist(err) {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", absNew, err)
|
||||
}
|
||||
err = copy.CopyDir(absNew, absCurrent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to either symlink or copy %s to %s: %w", absCurrent, absNew, err)
|
||||
}
|
||||
|
||||
// If we got here then apparently our copy succeeded, so we're done.
|
||||
return nil, nil
|
||||
}
|
||||
Loading…
Reference in a new issue