| // 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/command/cliconfig" | |
| "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) | |
| } | |
| func (c *StacksCommand) resolveDisplayHostname() (string, tfdiags.Diagnostics) { | |
| var diags tfdiags.Diagnostics | |
| displayHostname := strings.TrimSpace(os.Getenv("TF_STACKS_HOSTNAME")) | |
| if displayHostname != "" { | |
| return displayHostname, diags | |
| } | |
| displayHostname = strings.TrimSpace(os.Getenv("TF_CLOUD_HOSTNAME")) | |
| if displayHostname != "" { | |
| return displayHostname, diags | |
| } | |
| credentialsFile, err := cliconfig.CredentialsConfigFile() | |
| if err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Warning, | |
| "Could not inspect credentials file for stacks hostname, using default hostname", | |
| err.Error(), | |
| )) | |
| log.Printf("[TRACE] stacksplugin hostname inference failed, falling back to %q", defaultHostname) | |
| return defaultHostname, diags | |
| } | |
| hosts := cliconfig.ReadHostsInCredentialsFile(credentialsFile) | |
| if len(hosts) == 1 { | |
| for host := range hosts { | |
| return host.ForDisplay(), diags | |
| } | |
| } | |
| if len(hosts) > 1 { | |
| hostnames := make([]string, 0, len(hosts)) | |
| for host := range hosts { | |
| hostnames = append(hostnames, fmt.Sprintf("%q", host.ForDisplay())) | |
| } | |
| slices.Sort(hostnames) | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Multiple hostnames found in credentials file", | |
| fmt.Sprintf("Multiple hostnames found in credentials file: %s. Cannot determine which to use. Set TF_STACKS_HOSTNAME or TF_CLOUD_HOSTNAME to specify the intended host.", strings.Join(hostnames, ", ")), | |
| )) | |
| return "", diags | |
| } | |
| log.Printf("[TRACE] stacksplugin hostname not set, falling back to %q", defaultHostname) | |
| return defaultHostname, diags | |
| } | |
| // 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, hostnameDiags := c.resolveDisplayHostname() | |
| diags = diags.Append(hostnameDiags) | |
| if diags.HasErrors() { | |
| return diags | |
| } | |
| 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", | |
| fmt.Sprintf("%s\n\nSet TF_STACKS_HOSTNAME or TF_CLOUD_HOSTNAME to specify the intended host.", 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 = 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 | |
| } |