You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
terraform/internal/cloudplugin/client.go

199 lines
5.4 KiB

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cloudplugin
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/hashicorp/terraform/internal/httpclient"
"github.com/hashicorp/terraform/internal/logging"
)
var (
defaultRequestTimeout = 60 * time.Second
)
// ManifestReleaseBuild is the json-encoded details about a particular
// build of terraform-cloudplygin
type ManifestReleaseBuild struct {
URL string `json:"url"`
SHA256Sum string `json:"sha256sum"`
}
// Manifest is the json-encoded manifest details sent by Terraform Cloud
type Manifest struct {
ProductVersion string `json:"plugin_version"`
Archives map[string]ManifestReleaseBuild `json:"archives"`
SHA256SumsURL string `json:"sha256sums_url"`
SHA256SumsSignatureURL string `json:"sha256sums_signature_url"`
lastModified time.Time
}
// CloudPluginClient fetches and verifies release distributions of the cloudplugin
// that correspond to an upstream backend.
type CloudPluginClient struct {
serviceURL *url.URL
httpClient *retryablehttp.Client
ctx context.Context
}
func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) {
if i > 0 {
logger.Printf("[INFO] Previous request to the remote cloud manifest failed, attempting retry.")
}
}
func decodeManifest(data io.Reader) (*Manifest, error) {
var man Manifest
dec := json.NewDecoder(data)
if err := dec.Decode(&man); err != nil {
return nil, ErrQueryFailed{
inner: fmt.Errorf("failed to decode response body: %w", err),
}
}
return &man, nil
}
// NewCloudPluginClient creates a new client for downloading and verifying
// terraform-cloudplugin archives
func NewCloudPluginClient(ctx context.Context, serviceURL *url.URL) (*CloudPluginClient, error) {
httpClient := httpclient.New()
httpClient.Timeout = defaultRequestTimeout
retryableClient := retryablehttp.NewClient()
retryableClient.HTTPClient = httpClient
retryableClient.RetryMax = 3
retryableClient.RequestLogHook = requestLogHook
retryableClient.Logger = logging.HCLogger()
return &CloudPluginClient{
httpClient: retryableClient,
serviceURL: serviceURL,
ctx: ctx,
}, nil
}
// FetchManifest retrieves the cloudplugin manifest from Terraform Cloud,
// but returns a nil manifest if a 304 response is received, depending
// on the lastModified time.
func (c CloudPluginClient) FetchManifest(lastModified time.Time) (*Manifest, error) {
req, _ := retryablehttp.NewRequestWithContext(c.ctx, "GET", c.serviceURL.JoinPath("manifest").String(), nil)
req.Header.Set("If-Modified-Since", lastModified.Format(http.TimeFormat))
resp, err := c.httpClient.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil, ErrRequestCanceled
}
return nil, ErrQueryFailed{
inner: err,
}
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
lastModifiedRaw := resp.Header.Get("Last-Modified")
if len(lastModifiedRaw) > 0 {
lastModified, _ = time.Parse(http.TimeFormat, lastModifiedRaw)
}
manifest, err := decodeManifest(resp.Body)
if err != nil {
return nil, err
}
manifest.lastModified = lastModified
return manifest, nil
case http.StatusNotModified:
return nil, nil
case http.StatusNotFound:
return nil, ErrCloudPluginNotSupported
default:
return nil, ErrQueryFailed{
inner: errors.New(resp.Status),
}
}
}
// DownloadFile gets the URL at the specified path or URL and writes the
// contents to the specified Writer.
func (c CloudPluginClient) DownloadFile(pathOrURL string, writer io.Writer) error {
url, err := c.resolveManifestURL(pathOrURL)
if err != nil {
return err
}
req, err := retryablehttp.NewRequestWithContext(c.ctx, "GET", url.String(), nil)
if err != nil {
return fmt.Errorf("invalid URL %q was provided by the cloudplugin manifest: %w", url, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
return ErrRequestCanceled
}
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// OK
case http.StatusNotFound:
return ErrCloudPluginNotFound
default:
return ErrQueryFailed{
inner: errors.New(resp.Status),
}
}
_, err = io.Copy(writer, resp.Body)
if err != nil {
return fmt.Errorf("failed to write downloaded file: %w", err)
}
return nil
}
func (c CloudPluginClient) resolveManifestURL(pathOrURL string) (*url.URL, error) {
if strings.HasPrefix(pathOrURL, "/") {
copy := *c.serviceURL
copy.Path = ""
return copy.JoinPath(pathOrURL), nil
}
result, err := url.Parse(pathOrURL)
if err != nil {
return nil, fmt.Errorf("received malformed URL %q from cloudplugin manifest: %w", pathOrURL, err)
}
return result, nil
}
// Select gets the specific build data from the Manifest for the specified OS/Architecture
func (m Manifest) Select(goos, arch string) (*ManifestReleaseBuild, error) {
var supported []string
for key := range m.Archives {
supported = append(supported, key)
}
osArchKey := fmt.Sprintf("%s_%s", goos, arch)
log.Printf("[TRACE] checking for cloudplugin archive for %s. Supported architectures: %v", osArchKey, supported)
archiveOSArch, ok := m.Archives[osArchKey]
if !ok {
return nil, ErrArchNotSupported
}
return &archiveOSArch, nil
}