blob: 69f194823ece946bcc085ff3637eaaf600a659a4 [file] [log] [blame] [edit]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"bytes"
"fmt"
"io"
"log"
"net/url"
"os"
"os/exec"
"path"
"runtime"
"google.golang.org/grpc/metadata"
"github.com/hashicorp/go-plugin"
"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
// Path to the plugin server executable
pluginBinary string
// Service URL we can download plugin release binaries from
pluginService *url.URL
// Everything the plugin needs to build a client and Do Things
pluginConfig CloudPluginConfig
}
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
// The regular HCP Terraform API service that the go-tfe client relies on.
tfeServiceID = "tfe.v2"
// The cloud plugin release download service that the BinaryManager relies
// on to fetch the plugin.
cloudpluginServiceID = "cloudplugin.v1"
)
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{
Metadata: c.pluginConfig.ToMetadata(),
},
},
},
})
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)
}
// discoverAndConfigure is an implementation detail of initPlugin. It fills in the
// pluginService and pluginConfig fields on a CloudCommand struct.
func (c *CloudCommand) discoverAndConfigure() tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
// First, spin up a Cloud backend. (Why? bc finding the info the plugin
// needs is hard, and the Cloud backend already knows how to do it all.)
backendConfig, bConfigDiags := c.loadBackendConfig(".")
diags = diags.Append(bConfigDiags)
if diags.HasErrors() {
return diags
}
b, backendDiags := c.Backend(&BackendOpts{
Config: backendConfig,
})
diags = diags.Append(backendDiags)
if diags.HasErrors() {
return diags
}
cb, ok := b.(*cloud.Cloud)
if !ok {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"No `cloud` block found",
"Cloud command requires that a `cloud` block be configured in the working directory",
))
return diags
}
// Ok sweet. First, re-use the cached service discovery info for this TFC
// instance to find our plugin service and TFE API URLs:
pluginService, err := cb.ServicesHost.ServiceURL(cloudpluginServiceID)
if err != nil {
return diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Cloud plugin service not found",
err.Error(),
))
}
c.pluginService = pluginService
tfeService, err := cb.ServicesHost.ServiceURL(tfeServiceID)
if err != nil {
return diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"HCP Terraform API service not found",
err.Error(),
))
}
currentWorkspace, err := c.Workspace()
if err != nil {
// The only possible error here is "you set TF_WORKSPACE badly"
return diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Bad current workspace",
err.Error(),
))
}
// Now just steal everything we need so we can pass it to the plugin later.
c.pluginConfig = CloudPluginConfig{
Address: tfeService.String(),
BasePath: tfeService.Path,
DisplayHostname: cb.Hostname,
Token: cb.Token,
Organization: cb.Organization,
CurrentWorkspace: currentWorkspace,
WorkspaceName: cb.WorkspaceMapping.Name,
WorkspaceTags: cb.WorkspaceMapping.TagsAsSet,
DefaultProjectName: cb.WorkspaceMapping.Project,
}
return diags
}
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()
// Discover service URLs, and build out the plugin config
diags = diags.Append(c.discoverAndConfigure())
if diags.HasErrors() {
return diags
}
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, c.pluginService, 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 HCP Terraform settings and metadata"
}
// CloudPluginConfig is everything the cloud plugin needs to know to configure a
// client and talk to HCP Terraform.
type CloudPluginConfig struct {
// Maybe someday we can use struct tags to automate grabbing these out of
// the metadata headers! And verify client-side that we're sending the right
// stuff, instead of having it all be a stringly-typed mystery ball! I want
// to believe in that distant shining day! 🌻 Meantime, these struct tags
// serve purely as docs.
Address string `md:"tfc-address"`
BasePath string `md:"tfc-base-path"`
DisplayHostname string `md:"tfc-display-hostname"`
Token string `md:"tfc-token"`
Organization string `md:"tfc-organization"`
// The actual selected workspace
CurrentWorkspace string `md:"tfc-current-workspace"`
// The raw "WorkspaceMapping" attributes, which determine the workspaces
// that could be selected. Generally you want CurrentWorkspace instead, but
// these can potentially be useful for niche use cases.
WorkspaceName string `md:"tfc-workspace-name"`
WorkspaceTags []string `md:"tfc-workspace-tags"`
DefaultProjectName string `md:"tfc-default-project-name"`
}
func (c CloudPluginConfig) ToMetadata() metadata.MD {
// First, do everything except tags the easy way
md := metadata.Pairs(
"tfc-address", c.Address,
"tfc-base-path", c.BasePath,
"tfc-display-hostname", c.DisplayHostname,
"tfc-token", c.Token,
"tfc-organization", c.Organization,
"tfc-current-workspace", c.CurrentWorkspace,
"tfc-workspace-name", c.WorkspaceName,
"tfc-default-project-name", c.DefaultProjectName,
)
// Then the straggler
md["tfc-workspace-tags"] = c.WorkspaceTags
return md
}