terraform/internal/command/stacks.go
2026-02-17 13:56:34 +00:00

429 lines
14 KiB
Go

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