| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package pluginshared | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "log" | |
| "net/http" | |
| "net/url" | |
| "strings" | |
| "time" | |
| "github.com/hashicorp/go-retryablehttp" | |
| "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"` | |
| } | |
| // BasePluginClient fetches and verifies release distributions of the custom plugins | |
| // that correspond to an upstream backend. | |
| type BasePluginClient struct { | |
| serviceURL *url.URL | |
| httpClient *retryablehttp.Client | |
| ctx context.Context | |
| pluginName string | |
| } | |
| func requestLogHook(logger retryablehttp.Logger, req *http.Request, i int) { | |
| if i > 0 { | |
| logger.Printf("[INFO] Previous request to the remote plugin 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 | |
| } | |
| // FetchManifest retrieves the plugin manifest from HCP Terraform, | |
| // but returns a nil manifest if a 304 response is received, depending | |
| // on the lastModified time. | |
| func (b BasePluginClient) FetchManifest(lastModified time.Time) (*Release, error) { | |
| req, _ := retryablehttp.NewRequestWithContext(b.ctx, "GET", b.serviceURL.JoinPath("manifest.json").String(), nil) | |
| req.Header.Set("If-Modified-Since", lastModified.Format(http.TimeFormat)) | |
| resp, err := b.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, ErrPluginNotSupported | |
| 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 (b BasePluginClient) DownloadFile(pathOrURL string, writer io.Writer) error { | |
| url, err := b.resolveManifestURL(pathOrURL) | |
| if err != nil { | |
| return err | |
| } | |
| req, err := retryablehttp.NewRequestWithContext(b.ctx, "GET", url.String(), nil) | |
| if err != nil { | |
| return fmt.Errorf("invalid URL %q was provided by the %s manifest: %w", url, b.pluginName, err) | |
| } | |
| resp, err := b.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 ErrPluginNotFound | |
| 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 (b BasePluginClient) resolveManifestURL(pathOrURL string) (*url.URL, error) { | |
| if strings.HasPrefix(pathOrURL, "/") { | |
| copy := *b.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 %s manifest: %w", pathOrURL, b.pluginName, err) | |
| } | |
| return result, nil | |
| } | |
| // Select gets the specific build data from the Manifest for the specified OS/Architecture | |
| func (m Release) Select(pluginName, 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 %s archive for %s. Supported architectures: %v", pluginName, 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 | |
| } |