Files
opentf/internal/getproviders/package_location_http_archive.go
2025-10-13 10:00:19 +03:00

154 lines
5.7 KiB
Go

// 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"
"github.com/hashicorp/go-getter"
"github.com/hashicorp/go-retryablehttp"
"github.com/opentofu/opentofu/internal/httpclient"
"github.com/opentofu/opentofu/internal/logging"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
"go.opentelemetry.io/otel/trace"
"github.com/opentofu/opentofu/internal/tracing"
)
// 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.
//
// This type evolved from a single specific HTTP URL location to a more evolved
// type since we had to extract the logic of making an HTTP request into a more
// configurable piece to be able to provider a more specific-per-configuration
// http client.
type PackageHTTPURL struct {
// URL indicates the fully qualified location where the package
// resides and should be used as it is to perform a request.
URL string
// ClientBuilder is the external given function that is meant to
// construct a [*retryablehttp.Client] for making the actual request
// to the [PackageHTTPURL.URL] specified above.
// This came from the need of unifying the retries and timeout configurations
// into a single place. This way, the "creator" of this struct
// can inject a client by its liking to customize the requests
// accordingly.
ClientBuilder func(ctx context.Context) *retryablehttp.Client
}
var _ PackageLocation = PackageHTTPURL{}
func (p PackageHTTPURL) String() string { return p.URL }
func (p PackageHTTPURL) InstallProviderPackage(ctx context.Context, meta PackageMeta, targetDir string, allowedHashes []Hash) (*PackageAuthenticationResult, error) {
url := meta.Location.String()
ctx, span := tracing.Tracer().Start(ctx, "Install (http)", trace.WithAttributes(
semconv.URLFull(url),
))
defer span.End()
// 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 := p.ClientBuilder(ctx)
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 os.Remove(f.Name())
defer f.Close()
// 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
}
// packageHTTPUrlClientWithRetry is the extracted logic from the [PackageHTTPURL.InstallProviderPackage] to be
// able to reuse the same logic with a custom retry.
// This is kept as it was previously, before being moved here, to avoid introducing unwanted behaviors
// in a package download process.
// Later, this method might be removed in favor of a more common client from [httpclient] package.
func packageHTTPUrlClientWithRetry(ctx context.Context, retries int) *retryablehttp.Client {
retryableClient := retryablehttp.NewClient()
retryableClient.HTTPClient = httpclient.New(ctx)
retryableClient.RetryMax = retries
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())
return retryableClient
}