diff --git a/commands.go b/commands.go index 066f00a71b..683e07e66d 100644 --- a/commands.go +++ b/commands.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform/internal/command/webbrowser" "github.com/hashicorp/terraform/internal/getproviders" pluginDiscovery "github.com/hashicorp/terraform/internal/plugin/discovery" + "github.com/hashicorp/terraform/internal/rpcapi" "github.com/hashicorp/terraform/internal/terminal" ) @@ -275,6 +276,17 @@ func initCommands( }, nil }, + // "rpcapi" is handled a bit differently because the whole point of + // this interface is to bypass the CLI layer so wrapping automation can + // get as-direct-as-possible access to Terraform Core functionality, + // without interference from behaviors that are intended for CLI + // end-user convenience. We bypass the "command" package entirely + // for this command in particular. + "rpcapi": rpcapi.CLICommandFactory(rpcapi.CommandFactoryOpts{ + ExperimentsAllowed: meta.AllowExperimentalFeatures, + ShutdownCh: meta.ShutdownCh, + }), + "show": func() (cli.Command, error) { return &command.ShowCommand{ Meta: meta, @@ -431,9 +443,10 @@ func initCommands( } HiddenCommands = map[string]struct{}{ - "env": {}, - "internal-plugin": {}, - "push": {}, + "env": struct{}{}, + "internal-plugin": struct{}{}, + "push": struct{}{}, + "rpcapi": struct{}{}, } } diff --git a/internal/rpcapi/cli.go b/internal/rpcapi/cli.go new file mode 100644 index 0000000000..dfc01b418b --- /dev/null +++ b/internal/rpcapi/cli.go @@ -0,0 +1,101 @@ +package rpcapi + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/mitchellh/cli" +) + +// CLICommand is a command initialization callback for use with +// github.com/mitchellh/cli, allowing Terraform's "package main" to +// jump straight into the RPC plugin server without any interference +// from the usual Terraform CLI machinery in package "command", which +// is irrelevant here because this RPC API exists to bypass the +// Terraform CLI layer as much as possible. +func CLICommandFactory(opts CommandFactoryOpts) func() (cli.Command, error) { + return func() (cli.Command, error) { + return cliCommand{opts}, nil + } +} + +type CommandFactoryOpts struct { + ExperimentsAllowed bool + ShutdownCh <-chan struct{} +} + +type cliCommand struct { + opts CommandFactoryOpts +} + +// Help implements cli.Command. +func (c cliCommand) Help() string { + helpText := ` +Usage: terraform [global options] rpcapi + + Starts a gRPC server for programmatic access to Terraform Core from + wrapping automation. + + This interface is currently intended only for Terraform Cloud and is + subject to breaking changes even in patch releases. Do not use this. +` + return strings.TrimSpace(helpText) +} + +// Run implements cli.Command. +func (c cliCommand) Run(args []string) int { + if len(args) != 0 { + fmt.Fprintf(os.Stderr, "This command does not accept any arguments.\n") + return 1 + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + // We'll adapt the caller's "shutdown channel" into a context + // cancellation. + for { + select { + case <-c.opts.ShutdownCh: + cancel() + case <-ctx.Done(): + return + } + } + }() + + err := ServePlugin(ctx, ServerOpts{ + ExperimentsAllowed: c.opts.ExperimentsAllowed, + }) + if err != nil { + if err == ErrNotPluginClient { + fmt.Fprintf( + os.Stderr, + ` +This subcommand is for use by Terraform Cloud and is not intended for direct use. +Its behavior is not subject to Terraform compatibility promises. To interact +with Terraform using the CLI workflow, refer to the main set of subcommands by +running the following command: + terraform help + +`) + } else { + fmt.Fprintf(os.Stderr, "Failed to start RPC server: %s.\n", err) + } + return 1 + } + + // NOTE: In practice it's impossible to get here, because if ServePlugin + // doesn't error then it blocks forever and then eventually terminates + // the process itself without returning. + + return 0 +} + +// Synopsis implements cli.Command. +func (c cliCommand) Synopsis() string { + return "An RPC server used for integration with wrapping automation" +} diff --git a/internal/rpcapi/plugin.go b/internal/rpcapi/plugin.go index d848aec341..91afe4f1ac 100644 --- a/internal/rpcapi/plugin.go +++ b/internal/rpcapi/plugin.go @@ -11,6 +11,8 @@ import ( type corePlugin struct { plugin.Plugin + + experimentsAllowed bool } func (p *corePlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { @@ -34,8 +36,8 @@ func (p *corePlugin) handshakeFunc(s *grpc.Server) func(context.Context, *terraf // If handshaking is successful (which it currently always is, because // we don't have any special capabilities to negotiate yet) then we // will register all of the other services so the client can being - // doing real work. (In future the details of what we register here - // might vary based on the negotiated capabilities.) + // doing real work. In future the details of what we register here + // might vary based on the negotiated capabilities. terraform1.RegisterDependenciesServer(s, &dependenciesServer{}) return &terraform1.ServerCapabilities{}, nil } diff --git a/internal/rpcapi/server.go b/internal/rpcapi/server.go index 0da5e5de76..30b9a53138 100644 --- a/internal/rpcapi/server.go +++ b/internal/rpcapi/server.go @@ -2,26 +2,60 @@ package rpcapi import ( "context" + "errors" + "os" "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" ) // ServePlugin attempts to complete the go-plugin protocol handshake, and then -// if successful starts the plugin server and blocks until externally -// terminated. -func ServePlugin(ctx context.Context) error { +// if successful starts the plugin server and blocks until the given context +// is cancelled. +// +// Returns [ErrNotPluginClient] if this program doesn't seem to be running as +// the child of a plugin client, which is detected based on a magic environment +// variable that the client ought to have set. +func ServePlugin(ctx context.Context, opts ServerOpts) error { + // go-plugin has its own check for the environment variable magic cookie + // but it returns a generic error message. We'll pre-check it out here + // instead so we can return a more specific error message. + if os.Getenv(handshake.MagicCookieKey) != handshake.MagicCookieValue { + return ErrNotPluginClient + } + plugin.Serve(&plugin.ServeConfig{ HandshakeConfig: handshake, VersionedPlugins: map[int]plugin.PluginSet{ 1: plugin.PluginSet{ - "tfcore": &corePlugin{}, + "tfcore": &corePlugin{ + experimentsAllowed: opts.ExperimentsAllowed, + }, }, }, - GRPCServer: plugin.DefaultGRPCServer, + GRPCServer: func(opts []grpc.ServerOption) *grpc.Server { + server := grpc.NewServer(opts...) + // We'll also monitor the given context for cancellation + // and terminate the server gracefully if we get cancelled. + go func() { + <-ctx.Done() + server.GracefulStop() + // The above will block until all of the pending RPCs have + // finished. + os.Exit(0) + }() + return server + }, }) return nil } +var ErrNotPluginClient = errors.New("caller is not a plugin client") + +type ServerOpts struct { + ExperimentsAllowed bool +} + // handshake is the HandshakeConfig used to begin negotiation between client // and server. var handshake = plugin.HandshakeConfig{