terraform/internal/cloudplugin/binary.go
Brandon Croft 7acde98806
cloudplugin: change manifest format to match releases API
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
2023-09-06 14:03:24 -06:00

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
}