blob: c26e3abe513491d29c5a8f852c358287563875c3 [file] [log] [blame] [edit]
// 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"
"github.com/hashicorp/terraform/internal/releaseauth"
)
var (
defaultRequestTimeout = 60 * time.Second
)
// SHASumsSignatures holds a list of URLs, each referring a detached signature of the release's build artifacts.
type SHASumsSignatures []string
// BuildArtifact represents a single build artifact in a release response.
type BuildArtifact struct {
// The hardware architecture of the build artifact
// Enum: [386 all amd64 amd64-lxc arm arm5 arm6 arm64 arm7 armelv5 armhfv6 i686 mips mips64 mipsle ppc64le s390x ui x86_64]
Arch string `json:"arch"`
// The Operating System corresponding to the build artifact
// Enum: [archlinux centos darwin debian dragonfly freebsd linux netbsd openbsd plan9 python solaris terraform web windows]
Os string `json:"os"`
// This build is unsupported and provided for convenience only.
Unsupported bool `json:"unsupported,omitempty"`
// The URL where this build can be downloaded
URL string `json:"url"`
}
// ReleaseStatus Status of the product release
// Example: {"message":"This release is supported","state":"supported"}
type ReleaseStatus struct {
// Provides information about the most recent change; must be provided when Name="withdrawn"
Message string `json:"message,omitempty"`
// The state name of the release
// Enum: [supported unsupported withdrawn]
State string `json:"state"`
// The timestamp for the creation of the product release status
// Example: 2009-11-10T23:00:00Z
// Format: date-time
TimestampUpdated time.Time `json:"timestamp_updated"`
}
// Release All metadata for a single product release
type Release struct {
// builds
Builds []*BuildArtifact `json:"builds,omitempty"`
// A docker image name and tag for this release in the format `name`:`tag`
// Example: consul:1.10.0-beta3
DockerNameTag string `json:"docker_name_tag,omitempty"`
// True if and only if this product release is a prerelease.
IsPrerelease bool `json:"is_prerelease"`
// The license class indicates how this product is licensed.
// Enum: [enterprise hcp oss]
LicenseClass string `json:"license_class"`
// The product name
// Example: consul-enterprise
// Required: true
Name string `json:"name"`
// Status
Status ReleaseStatus `json:"status"`
// Timestamp at which this product release was created.
// Example: 2009-11-10T23:00:00Z
// Format: date-time
TimestampCreated time.Time `json:"timestamp_created"`
// Timestamp when this product release was most recently updated.
// Example: 2009-11-10T23:00:00Z
// Format: date-time
TimestampUpdated time.Time `json:"timestamp_updated"`
// URL for a blogpost announcing this release
URLBlogpost string `json:"url_blogpost,omitempty"`
// URL for the changelog covering this release
URLChangelog string `json:"url_changelog,omitempty"`
// The project's docker repo on Amazon ECR-Public
URLDockerRegistryDockerhub string `json:"url_docker_registry_dockerhub,omitempty"`
// The project's docker repo on DockerHub
URLDockerRegistryEcr string `json:"url_docker_registry_ecr,omitempty"`
// URL for the software license applicable to this release
// Required: true
URLLicense string `json:"url_license,omitempty"`
// The project's website URL
URLProjectWebsite string `json:"url_project_website,omitempty"`
// URL for this release's change notes
URLReleaseNotes string `json:"url_release_notes,omitempty"`
// URL for this release's file containing checksums of all the included build artifacts
URLSHASums string `json:"url_shasums"`
// An array of URLs, each pointing to a signature file. Each signature file is a detached signature
// of the checksums file (see field `url_shasums`). Signature files may or may not embed the signing
// key ID in the filename.
URLSHASumsSignatures SHASumsSignatures `json:"url_shasums_signatures"`
// URL for the product's source code repository. This field is empty for
// enterprise and hcp products.
URLSourceRepository string `json:"url_source_repository,omitempty"`
// The version of this release
// Example: 1.10.0-beta3
// Required: true
Version string `json:"version"`
}
// 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) (*Release, error) {
var man Release
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 HCP Terraform,
// but returns a nil manifest if a 304 response is received, depending
// on the lastModified time.
func (c CloudPluginClient) FetchManifest(lastModified time.Time) (*Release, error) {
req, _ := retryablehttp.NewRequestWithContext(c.ctx, "GET", c.serviceURL.JoinPath("manifest.json").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:
manifest, err := decodeManifest(resp.Body)
if err != nil {
return nil, err
}
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 Release) Select(goos, arch string) (*BuildArtifact, error) {
var supported []string
var found *BuildArtifact
for _, build := range m.Builds {
key := fmt.Sprintf("%s_%s", build.Os, build.Arch)
supported = append(supported, key)
if goos == build.Os && arch == build.Arch {
found = build
}
}
osArchKey := fmt.Sprintf("%s_%s", goos, arch)
log.Printf("[TRACE] checking for cloudplugin archive for %s. Supported architectures: %v", osArchKey, supported)
if found == nil {
return nil, ErrArchNotSupported
}
return found, nil
}
// PrimarySHASumsSignatureURL returns the URL among the URLSHASumsSignatures that matches
// the public key known by this version of terraform. It falls back to the first URL with no
// ID in the URL.
func (m Release) PrimarySHASumsSignatureURL() (string, error) {
if len(m.URLSHASumsSignatures) == 0 {
return "", fmt.Errorf("no SHA256SUMS URLs were available")
}
findBySuffix := func(suffix string) string {
for _, url := range m.URLSHASumsSignatures {
if len(url) > len(suffix) && strings.EqualFold(suffix, url[len(url)-len(suffix):]) {
return url
}
}
return ""
}
withKeyID := findBySuffix(fmt.Sprintf(".%s.sig", releaseauth.HashiCorpPublicKeyID))
if withKeyID == "" {
withNoKeyID := findBySuffix("_SHA256SUMS.sig")
if withNoKeyID == "" {
return "", fmt.Errorf("no SHA256SUMS URLs matched the known public key")
}
return withNoKeyID, nil
}
return withKeyID, nil
}