// 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" }