mirror of
https://github.com/hashicorp/terraform.git
synced 2026-02-03 20:50:59 -05:00
Changes the release manifest format to more closely match the releases API V1 (example https://api.releases.hashicorp.com/v1/releases/terraform-cloudplugin/0.1.0-prototype) - The new format doesn't carry the SHASUM for each build, so it made the matching_sums check in releaseauth redundant. - Added tests for checksum parsing - Added ID-based selection of signature file
256 lines
7.6 KiB
Go
256 lines
7.6 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package cloudplugin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-getter"
|
|
svchost "github.com/hashicorp/terraform-svchost"
|
|
"github.com/hashicorp/terraform/internal/releaseauth"
|
|
)
|
|
|
|
// BinaryManager downloads, caches, and returns information about the
|
|
// terraform-cloudplugin binary downloaded from the specified backend.
|
|
type BinaryManager struct {
|
|
signingKey string
|
|
binaryName string
|
|
cloudPluginDataDir string
|
|
host svchost.Hostname
|
|
client *CloudPluginClient
|
|
goos string
|
|
arch string
|
|
ctx context.Context
|
|
}
|
|
|
|
// Binary is a struct containing the path to an authenticated binary corresponding to
|
|
// a backend service.
|
|
type Binary struct {
|
|
Path string
|
|
ProductVersion string
|
|
ResolvedFromCache bool
|
|
}
|
|
|
|
const (
|
|
KB = 1000
|
|
MB = 1000 * KB
|
|
)
|
|
|
|
// BinaryManager initializes a new BinaryManager to broker data between the
|
|
// specified directory location containing cloudplugin package data and a
|
|
// Terraform Cloud backend URL.
|
|
func NewBinaryManager(ctx context.Context, cloudPluginDataDir string, serviceURL *url.URL, goos, arch string) (*BinaryManager, error) {
|
|
client, err := NewCloudPluginClient(ctx, serviceURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not initialize cloudplugin version manager: %w", err)
|
|
}
|
|
|
|
return &BinaryManager{
|
|
cloudPluginDataDir: cloudPluginDataDir,
|
|
host: svchost.Hostname(serviceURL.Host),
|
|
client: client,
|
|
binaryName: "terraform-cloudplugin",
|
|
goos: goos,
|
|
arch: arch,
|
|
ctx: ctx,
|
|
}, nil
|
|
}
|
|
|
|
func (v BinaryManager) binaryLocation() string {
|
|
return path.Join(v.cloudPluginDataDir, "bin", fmt.Sprintf("%s_%s", v.goos, v.arch))
|
|
}
|
|
|
|
func (v BinaryManager) cachedVersion(version string) *string {
|
|
binaryPath := path.Join(v.binaryLocation(), v.binaryName)
|
|
|
|
if _, err := os.Stat(binaryPath); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// The version from the manifest must match the contents of ".version"
|
|
versionData, err := os.ReadFile(path.Join(v.binaryLocation(), ".version"))
|
|
if err != nil || strings.Trim(string(versionData), " \n\r\t") != version {
|
|
return nil
|
|
}
|
|
|
|
return &binaryPath
|
|
}
|
|
|
|
// Resolve fetches, authenticates, and caches a plugin binary matching the specifications
|
|
// and returns its location and version.
|
|
func (v BinaryManager) Resolve() (*Binary, error) {
|
|
manifest, err := v.latestManifest(v.ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not resolve cloudplugin version for host %q: %w", v.host.ForDisplay(), err)
|
|
}
|
|
|
|
buildInfo, err := manifest.Select(v.goos, v.arch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check if there's a cached binary
|
|
if cachedBinary := v.cachedVersion(manifest.Version); cachedBinary != nil {
|
|
return &Binary{
|
|
Path: *cachedBinary,
|
|
ProductVersion: manifest.Version,
|
|
ResolvedFromCache: true,
|
|
}, nil
|
|
}
|
|
|
|
// Download the archive
|
|
t, err := os.CreateTemp(os.TempDir(), "terraform-cloudplugin")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temp file for download: %w", err)
|
|
}
|
|
defer os.Remove(t.Name())
|
|
|
|
err = v.client.DownloadFile(buildInfo.URL, t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t.Close() // Close only returns an error if it's already been called
|
|
|
|
// Authenticate the archive
|
|
err = v.verifyCloudPlugin(manifest, buildInfo, t.Name())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not resolve cloudplugin version %q: %w", manifest.Version, err)
|
|
}
|
|
|
|
// Unarchive
|
|
unzip := getter.ZipDecompressor{
|
|
FilesLimit: 1,
|
|
FileSizeLimit: 500 * MB,
|
|
}
|
|
targetPath := v.binaryLocation()
|
|
log.Printf("[TRACE] decompressing %q to %q", t.Name(), targetPath)
|
|
|
|
err = unzip.Decompress(targetPath, t.Name(), true, 0000)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decompress cloud plugin: %w", err)
|
|
}
|
|
|
|
err = os.WriteFile(path.Join(targetPath, ".version"), []byte(manifest.Version), 0644)
|
|
if err != nil {
|
|
log.Printf("[ERROR] failed to write .version file to %q: %s", targetPath, err)
|
|
}
|
|
|
|
return &Binary{
|
|
Path: path.Join(targetPath, v.binaryName),
|
|
ProductVersion: manifest.Version,
|
|
ResolvedFromCache: false,
|
|
}, nil
|
|
}
|
|
|
|
// Useful for small files that can be decoded all at once
|
|
func (v BinaryManager) downloadFileBuffer(pathOrURL string) ([]byte, error) {
|
|
buffer := bytes.Buffer{}
|
|
err := v.client.DownloadFile(pathOrURL, &buffer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buffer.Bytes(), err
|
|
}
|
|
|
|
// verifyCloudPlugin authenticates the downloaded release archive
|
|
func (v BinaryManager) verifyCloudPlugin(archiveManifest *Release, info *BuildArtifact, archiveLocation string) error {
|
|
signature, err := v.downloadFileBuffer(archiveManifest.URLSHASumsSignatures[0])
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download cloudplugin SHA256SUMS signature file: %w", err)
|
|
}
|
|
sums, err := v.downloadFileBuffer(archiveManifest.URLSHASums)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download cloudplugin SHA256SUMS file: %w", err)
|
|
}
|
|
|
|
checksums, err := releaseauth.ParseChecksums(sums)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse cloudplugin SHA256SUMS file: %w", err)
|
|
}
|
|
|
|
filename := path.Base(info.URL)
|
|
reportedSHA, ok := checksums[filename]
|
|
if !ok {
|
|
return fmt.Errorf("could not find checksum for file %q", filename)
|
|
}
|
|
|
|
sigAuth := releaseauth.NewSignatureAuthentication(signature, sums)
|
|
if len(v.signingKey) > 0 {
|
|
sigAuth.PublicKey = v.signingKey
|
|
}
|
|
|
|
all := releaseauth.AllAuthenticators(
|
|
releaseauth.NewChecksumAuthentication(reportedSHA, archiveLocation),
|
|
sigAuth,
|
|
)
|
|
|
|
return all.Authenticate()
|
|
}
|
|
|
|
func (v BinaryManager) latestManifest(ctx context.Context) (*Release, error) {
|
|
manifestCacheLocation := path.Join(v.cloudPluginDataDir, v.host.String(), "manifest.json")
|
|
|
|
// Find the manifest cache for the hostname.
|
|
data, err := os.ReadFile(manifestCacheLocation)
|
|
modTime := time.Time{}
|
|
var localManifest *Release
|
|
if err != nil {
|
|
log.Printf("[TRACE] no cloudplugin manifest cache found for host %q", v.host)
|
|
} else {
|
|
log.Printf("[TRACE] cloudplugin manifest cache found for host %q", v.host)
|
|
|
|
localManifest, err = decodeManifest(bytes.NewBuffer(data))
|
|
modTime = localManifest.TimestampUpdated
|
|
if err != nil {
|
|
log.Printf("[WARN] failed to decode cloudplugin manifest cache %q: %s", manifestCacheLocation, err)
|
|
}
|
|
}
|
|
|
|
// Even though we may have a local manifest, always see if there is a newer remote manifest
|
|
result, err := v.client.FetchManifest(modTime)
|
|
// FetchManifest can return nil, nil (see below)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch cloudplugin manifest: %w", err)
|
|
}
|
|
|
|
// No error and no remoteManifest means the existing manifest is not modified
|
|
// and it's safe to use the local manifest
|
|
if result == nil && localManifest != nil {
|
|
result = localManifest
|
|
} else {
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to dump cloudplugin manifest to JSON: %w", err)
|
|
}
|
|
|
|
// Ensure target directory exists
|
|
if err := os.MkdirAll(filepath.Dir(manifestCacheLocation), 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create cloudplugin manifest cache directory: %w", err)
|
|
}
|
|
|
|
output, err := os.Create(manifestCacheLocation)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create cloudplugin manifest cache: %w", err)
|
|
}
|
|
|
|
_, err = output.Write(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to write cloudplugin manifest cache: %w", err)
|
|
}
|
|
log.Printf("[TRACE] wrote cloudplugin manifest cache to %q", manifestCacheLocation)
|
|
}
|
|
|
|
return result, nil
|
|
}
|