You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
terraform/internal/command/cloud.go

262 lines
7.6 KiB

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"bytes"
"errors"
"fmt"
"io"
"log"
"net/url"
"os"
"os/exec"
"path"
"runtime"
"github.com/hashicorp/go-plugin"
svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/internal/cloud"
"github.com/hashicorp/terraform/internal/cloudplugin"
"github.com/hashicorp/terraform/internal/cloudplugin/cloudplugin1"
"github.com/hashicorp/terraform/internal/logging"
"github.com/hashicorp/terraform/internal/tfdiags"
)
// CloudCommand is a Command implementation that interacts with Terraform
// Cloud for operations that are inherently planless. It delegates
// all execution to an internal plugin.
type CloudCommand struct {
Meta
pluginBinary string
}
const (
// DefaultCloudPluginVersion is the implied protocol version, though all
// historical versions are defined explicitly.
DefaultCloudPluginVersion = 1
// ExitRPCError is the exit code that is returned if an plugin
// communication error occurred.
ExitRPCError = 99
// ExitPluginError is the exit code that is returned if the plugin
// cannot be downloaded.
ExitPluginError = 98
)
var (
// Handshake is used to verify that the plugin is the appropriate plugin for
// the client. This is not a security verification.
Handshake = plugin.HandshakeConfig{
MagicCookieKey: "TF_CLOUDPLUGIN_MAGIC_COOKIE",
MagicCookieValue: "721fca41431b780ff3ad2623838faaa178d74c65e1cfdfe19537c31656496bf9f82d6c6707f71d81c8eed0db9043f79e56ab4582d013bc08ead14f57961461dc",
ProtocolVersion: DefaultCloudPluginVersion,
}
// CloudPluginDataDir is the name of the directory within the data directory
CloudPluginDataDir = "cloudplugin"
)
func (c *CloudCommand) realRun(args []string, stdout, stderr io.Writer) int {
args = c.Meta.process(args)
diags := c.initPlugin()
if diags.HasWarnings() || diags.HasErrors() {
c.View.Diagnostics(diags)
}
if diags.HasErrors() {
return ExitPluginError
}
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: Handshake,
AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
Cmd: exec.Command(c.pluginBinary),
Logger: logging.NewCloudLogger(),
VersionedPlugins: map[int]plugin.PluginSet{
1: {
"cloud": &cloudplugin1.GRPCCloudPlugin{},
},
},
})
defer client.Kill()
// Connect via RPC
rpcClient, err := client.Client()
if err != nil {
fmt.Fprintf(stderr, "Failed to create cloud plugin client: %s", err)
return ExitRPCError
}
// Request the plugin
raw, err := rpcClient.Dispense("cloud")
if err != nil {
fmt.Fprintf(stderr, "Failed to request cloud plugin interface: %s", err)
return ExitRPCError
}
// Proxy the request
// Note: future changes will need to determine the type of raw when
// multiple versions are possible.
cloud1, ok := raw.(cloudplugin.Cloud1)
if !ok {
c.Ui.Error("If more than one cloudplugin versions are available, they need to be added to the cloud command. This is a bug in Terraform.")
return ExitRPCError
}
return cloud1.Execute(args, stdout, stderr)
}
// discover the TFC/E API service URL and version constraints.
func (c *CloudCommand) discover(hostname string) (*url.URL, error) {
hn, err := svchost.ForComparison(hostname)
if err != nil {
return nil, err
}
host, err := c.Services.Discover(hn)
if err != nil {
var serviceDiscoErr *disco.ErrServiceDiscoveryNetworkRequest
switch {
case errors.As(err, &serviceDiscoErr):
err = fmt.Errorf("a network issue prevented cloud configuration; %w", err)
return nil, err
default:
return nil, err
}
}
service, err := host.ServiceURL("cloudplugin.v1")
// Return the error, unless its a disco.ErrVersionNotSupported error.
if _, ok := err.(*disco.ErrVersionNotSupported); !ok && err != nil {
return nil, err
}
return service, err
}
func (c *CloudCommand) hostnameFromConfig() (string, error) {
var diags tfdiags.Diagnostics
backendConfig, backendDiags := c.loadBackendConfig(".")
diags = diags.Append(backendDiags)
if diags.HasErrors() {
return "", diags.Err()
}
b, backendDiags := c.Backend(&BackendOpts{
Config: backendConfig,
})
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
return "", diags.Err()
}
cloudBackend, ok := b.(*cloud.Cloud)
if !ok {
return "", fmt.Errorf("cloud command requires that a cloud block be configured in the working directory")
}
return cloudBackend.Hostname, nil
}
func (c *CloudCommand) hostnameFromEnv() string {
return os.Getenv("TF_CLOUD_HOSTNAME")
}
func (c *CloudCommand) initPlugin() tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
var errorSummary = "Cloud plugin initialization error"
// Initialization can be aborted by interruption signals
ctx, done := c.InterruptibleContext(c.CommandContext())
defer done()
var hostname string
if hostname = c.hostnameFromEnv(); hostname == "" {
var err error
hostname, err = c.hostnameFromConfig()
if err != nil {
return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error()))
}
}
serviceURL, err := c.discover(hostname)
if err != nil {
return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error()))
}
packagesPath, err := c.initPackagesCache()
if err != nil {
return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error()))
}
overridePath := os.Getenv("TF_CLOUD_PLUGIN_DEV_OVERRIDE")
bm, err := cloudplugin.NewBinaryManager(ctx, packagesPath, overridePath, serviceURL, runtime.GOOS, runtime.GOARCH)
if err != nil {
return diags.Append(tfdiags.Sourceless(tfdiags.Error, errorSummary, err.Error()))
}
version, err := bm.Resolve()
if err != nil {
return diags.Append(tfdiags.Sourceless(tfdiags.Error, "Cloud plugin download error", err.Error()))
}
var cacheTraceMsg = ""
if version.ResolvedFromCache {
cacheTraceMsg = " (resolved from cache)"
}
if version.ResolvedFromDevOverride {
cacheTraceMsg = " (resolved from dev override)"
detailMsg := fmt.Sprintf("Instead of using the current released version, Terraform is loading the cloud plugin from the following location:\n\n - %s\n\nOverriding the cloud plugin location can cause unexpected behavior, and is only intended for use when developing new versions of the plugin.", version.Path)
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning,
"Cloud plugin development overrides are in effect",
detailMsg,
))
}
log.Printf("[TRACE] plugin %q binary located at %q%s", version.ProductVersion, version.Path, cacheTraceMsg)
c.pluginBinary = version.Path
return diags
}
func (c *CloudCommand) initPackagesCache() (string, error) {
packagesPath := path.Join(c.WorkingDir.DataDir(), CloudPluginDataDir)
if info, err := os.Stat(packagesPath); err != nil || !info.IsDir() {
log.Printf("[TRACE] initialized cloudplugin cache directory at %q", packagesPath)
err = os.MkdirAll(packagesPath, 0755)
if err != nil {
return "", fmt.Errorf("failed to initialize cloudplugin cache directory: %w", err)
}
} else {
log.Printf("[TRACE] cloudplugin cache directory found at %q", packagesPath)
}
return packagesPath, nil
}
// Run runs the cloud command with the given arguments.
func (c *CloudCommand) Run(args []string) int {
args = c.Meta.process(args)
return c.realRun(args, c.Meta.Streams.Stdout.File, c.Meta.Streams.Stderr.File)
}
// Help returns help text for the cloud command.
func (c *CloudCommand) Help() string {
helpText := new(bytes.Buffer)
if exitCode := c.realRun([]string{}, helpText, io.Discard); exitCode != 0 {
return ""
}
return helpText.String()
}
// Synopsis returns a short summary of the cloud command.
func (c *CloudCommand) Synopsis() string {
return "Manage Terraform Cloud settings and metadata"
}