// 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 }