// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package command import ( "bytes" "errors" "fmt" "io" "log" "net/url" "os" "os/exec" "path" "runtime" "slices" "strings" "github.com/hashicorp/go-plugin" svchost "github.com/hashicorp/terraform-svchost" "github.com/hashicorp/terraform-svchost/disco" backendInit "github.com/hashicorp/terraform/internal/backend/init" "github.com/hashicorp/terraform/internal/cloud" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/pluginshared" "github.com/hashicorp/terraform/internal/stacksplugin/stacksplugin1" "github.com/hashicorp/terraform/internal/tfdiags" "google.golang.org/grpc/metadata" ) // StacksCommand is a Command implementation that interacts with Terraform // Cloud for stack operations. It delegates all execution to an internal plugin. type StacksCommand 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 StacksPluginConfig } const ( // DefaultStacksPluginVersion is the implied protocol version, though all // historical versions are defined explicitly. DefaultStacksPluginVersion = 1 // ExitRPCError is the exit code that is returned if an plugin // communication error occurred. ExitStacksRPCError = 99 // ExitPluginError is the exit code that is returned if the plugin // cannot be downloaded. ExitStacksPluginError = 98 // The regular HCP Terraform API service that the go-tfe client relies on. tfeStacksServiceID = "tfe.v2" // The stacks plugin release download service that the BinaryManager relies // on to fetch the plugin. stackspluginServiceID = "stacksplugin.v1" defaultHostname = "app.terraform.io" ) var ( // Handshake is used to verify that the plugin is the appropriate plugin for // the client. This is not a security verification. StacksHandshake = plugin.HandshakeConfig{ MagicCookieKey: "TF_STACKS_MAGIC_COOKIE", MagicCookieValue: "c9183f264a1db49ef2cbcc7b74f508a7bba9c3704c47cde3d130ae7f3b7a59c8f97a1e43d9e17ec0ac43a57fd250f373b2a8d991431d9fb1ea7bc48c8e7696fd", ProtocolVersion: DefaultStacksPluginVersion, } // StacksPluginDataDir is the name of the directory within the data directory StacksPluginDataDir = "stacksplugin" ) func (c *StacksCommand) realRun(args []string, stdout, stderr io.Writer) int { var pluginCacheDirOverride string args = c.Meta.process(args) cmdFlags := c.Meta.defaultFlagSet("stacks") cmdFlags.StringVar(&pluginCacheDirOverride, "plugin-cache-dir", "", "plugin cache directory") cmdFlags.Parse(args) if pluginCacheDirOverride != "" { err := c.storeStacksPluginPath(path.Join(pluginCacheDirOverride, StacksPluginDataDir)) if err != nil { c.Ui.Error(fmt.Sprintf("Error storing cached stacks plugin path: %s\n", err)) return 1 } // Remove the cache override arg from the args so it doesn't get passed to the plugin args = slices.DeleteFunc(args, func(arg string) bool { return strings.HasPrefix(arg, "-plugin-cache-dir") }) } diags := c.initPlugin() if diags.HasWarnings() || diags.HasErrors() { c.View.Diagnostics(diags) } if diags.HasErrors() { return ExitPluginError } client := plugin.NewClient(&plugin.ClientConfig{ HandshakeConfig: StacksHandshake, AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, Cmd: exec.Command(c.pluginBinary), Logger: logging.NewStacksLogger(), VersionedPlugins: map[int]plugin.PluginSet{ 1: { "stacks": &stacksplugin1.GRPCStacksPlugin{ Metadata: c.pluginConfig.ToMetadata(), Services: c.Meta.Services, ShutdownCh: c.Meta.ShutdownCh, }, }, }, }) defer client.Kill() // Connect via RPC rpcClient, err := client.Client() if err != nil { fmt.Fprintf(stderr, "Failed to create stacks plugin client: %s", err) return ExitRPCError } // Request the plugin raw, err := rpcClient.Dispense("stacks") if err != nil { fmt.Fprintf(stderr, "Failed to request stacks 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. stacks1, ok := raw.(pluginshared.CustomPluginClient) if !ok { c.Ui.Error("If more than one stacksplugin versions are available, they need to be added to the stacks command. This is a bug in Terraform.") return ExitRPCError } return stacks1.Execute(args, stdout, stderr) } // discoverAndConfigure is an implementation detail of initPlugin. It fills in the // pluginService and pluginConfig fields on a StacksCommand struct. func (c *StacksCommand) discoverAndConfigure() tfdiags.Diagnostics { var diags tfdiags.Diagnostics // using the current terraform path for the plugin binary path tfBinaryPath, err := os.Executable() if err != nil { return diags.Append(tfdiags.Sourceless( tfdiags.Error, "Terraform binary path not found", "Terraform binary path not found: "+err.Error(), )) } // stacks plugin requires a cloud backend in order to work, // however `cloud` block in not yet allowed in the stacks working directory // initialize an empty cloud backend bf := backendInit.Backend("cloud") if bf == nil { return diags.Append(tfdiags.Sourceless( tfdiags.Error, "`cloud` backend not found, this should not happen", "`cloud` backend is a valid backend type, yet it was not found, this is could be a bug, report it.", )) } b := bf() cb, ok := b.(*cloud.Cloud) if !ok { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "`cloud` backend could not be initialized", "Could not initialize a `cloud` backend, this is could be a bug, report it.", )) return diags } displayHostname := os.Getenv("TF_STACKS_HOSTNAME") if strings.TrimSpace(displayHostname) == "" { log.Printf("[TRACE] stacksplugin hostname not set, falling back to %q", defaultHostname) displayHostname = defaultHostname } hostname, err := svchost.ForComparison(displayHostname) if err != nil { return diags.Append(tfdiags.Sourceless( tfdiags.Error, "Hostname string cannot be parsed into a svc.Hostname", err.Error(), )) } host, err := cb.Services().Discover(hostname) if err != nil { // Network errors from Discover() can read like non-sequiters, so we wrap em. var serviceDiscoErr *disco.ErrServiceDiscoveryNetworkRequest if errors.As(err, &serviceDiscoErr) { err = fmt.Errorf("a network issue prevented cloud configuration; %w", err) } return diags.Append(tfdiags.Sourceless( tfdiags.Error, "Hostname discovery failed", err.Error(), )) } // The discovery request worked, so cache the full results. cb.ServicesHost = host token := os.Getenv("TF_STACKS_TOKEN") if strings.TrimSpace(token) == "" { // attempt to read from the credentials file token, err = cloud.CliConfigToken(hostname, cb.Services()) if err != nil { // some commands like stacks init and validate could be run without a token so allow it without errors diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Could not read token from credentials file, proceeding without a token", err.Error(), )) } } // 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(stackspluginServiceID) if err != nil { return diags.Append(tfdiags.Sourceless( tfdiags.Error, "Stacks plugin service not found", err.Error(), )) } c.pluginService = pluginService tfeService, err := cb.ServicesHost.ServiceURL(tfeStacksServiceID) if err != nil { return diags.Append(tfdiags.Sourceless( tfdiags.Error, "HCP Terraform API service not found", err.Error(), )) } // optional env values orgName := os.Getenv("TF_STACKS_ORGANIZATION_NAME") projectName := os.Getenv("TF_STACKS_PROJECT_NAME") stackName := os.Getenv("TF_STACKS_STACK_NAME") // config to be passed to the plugin later. c.pluginConfig = StacksPluginConfig{ Address: tfeService.String(), BasePath: tfeService.Path, DisplayHostname: displayHostname, Token: token, TerraformBinaryPath: tfBinaryPath, OrganizationName: orgName, ProjectName: projectName, StackName: stackName, TerminalWidth: c.Meta.Streams.Stdout.Columns(), } return diags } func (c *StacksCommand) initPlugin() tfdiags.Diagnostics { var diags tfdiags.Diagnostics var errorSummary = "Stacks 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_STACKS_PLUGIN_DEV_OVERRIDE") bm, err := pluginshared.NewStacksBinaryManager(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, "Stacks 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 stacks plugin from the following location:\n\n - %s\n\nOverriding the stacks 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, "Stacks 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 *StacksCommand) initPackagesCache() (string, error) { var pluginPath string defaultPluginPath := path.Join(c.CLIConfigDir, StacksPluginDataDir) cacheStorePath := path.Join(c.WorkingDir.DataDir(), ".stackspluginpath") if _, err := os.Stat(cacheStorePath); err != nil { if os.IsNotExist(err) { log.Printf("[TRACE] No stacksplugin cache path store at '%s`, using default '%s'", cacheStorePath, defaultPluginPath) // Use the default plugin cache path if the file does not exist pluginPath = defaultPluginPath } else { log.Printf("[TRACE] Failed to check the stacksplugin cache path store at '%s', using default '%s'", cacheStorePath, defaultPluginPath) // Use the default plugin cache path if there was an error checking the file pluginPath = defaultPluginPath } } else { data, err := os.ReadFile(cacheStorePath) if err != nil { return "", fmt.Errorf("failed to read stacks plugin stored path file: %w", err) } pluginPath = string(data) } if info, err := os.Stat(pluginPath); err != nil || !info.IsDir() { log.Printf("[TRACE] initialized stacksplugin cache directory at %q", pluginPath) err = os.MkdirAll(pluginPath, 0755) if err != nil { return "", fmt.Errorf("failed to initialize stacksplugin cache directory: %w", err) } } else { log.Printf("[TRACE] stacksplugin cache directory found at %q", pluginPath) } return pluginPath, nil } func (c *StacksCommand) storeStacksPluginPath(pluginCachePath string) error { f, err := os.Create(path.Join(c.WorkingDir.DataDir(), ".stackspluginpath")) if err != nil { return fmt.Errorf("failed to create stacks plugin stored path file: %w", err) } defer f.Close() f.WriteString(pluginCachePath) return nil } // Run runs the stacks command with the given arguments. func (c *StacksCommand) 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 stacks command. func (c *StacksCommand) Help() string { helpText := new(bytes.Buffer) errorText := new(bytes.Buffer) parsedArgs := []string{} for _, arg := range os.Args[1:] { if arg == "stacks" { continue // skip stacks command name } parsedArgs = append(parsedArgs, arg) } if exitCode := c.realRun(parsedArgs, helpText, errorText); exitCode != 0 { return errorText.String() } return helpText.String() } // Synopsis returns a short summary of the stacks command. func (c *StacksCommand) Synopsis() string { return "Manage HCP Terraform stack operations" } // StacksPluginConfig is everything the stacks plugin needs to know to configure a // client and talk to HCP Terraform. type StacksPluginConfig struct { Address string `md:"tfc-address"` BasePath string `md:"tfc-base-path"` DisplayHostname string `md:"tfc-display-hostname"` Token string `md:"tfc-token"` TerraformBinaryPath string `md:"terraform-binary-path"` OrganizationName string `md:"tfc-organization"` ProjectName string `md:"tfc-project"` StackName string `md:"tfc-stack"` TerminalWidth int `md:"terminal-width"` } func (c StacksPluginConfig) ToMetadata() metadata.MD { md := metadata.Pairs( "tfc-address", c.Address, "tfc-base-path", c.BasePath, "tfc-display-hostname", c.DisplayHostname, "tfc-token", c.Token, "terraform-binary-path", c.TerraformBinaryPath, "tfc-organization", c.OrganizationName, "tfc-project", c.ProjectName, "tfc-stack", c.StackName, "terminal-width", fmt.Sprintf("%d", c.TerminalWidth), ) return md }