mirror of
https://github.com/hashicorp/terraform.git
synced 2026-02-03 20:50:59 -05:00
* Update comments to distinguish between operations and state-storage backends more clearly * Rename `BackendOpts` field `Config` to more specific `BackendConfig`
324 lines
10 KiB
Go
324 lines
10 KiB
Go
// 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/cloudplugin1"
|
|
"github.com/hashicorp/terraform/internal/logging"
|
|
"github.com/hashicorp/terraform/internal/pluginshared"
|
|
"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.(pluginshared.CustomPluginClient)
|
|
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{
|
|
BackendConfig: 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 := pluginshared.NewCloudBinaryManager(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
|
|
}
|