mirror of
https://github.com/hashicorp/terraform.git
synced 2026-02-19 02:39:17 -05:00
429 lines
14 KiB
Go
429 lines
14 KiB
Go
// 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"
|
|
"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
|
|
}
|