From 3bf1a5cf5360b97f871c522e6fb34728f2ad5ec6 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 6 Jun 2023 16:58:33 -0700 Subject: [PATCH] rpcapi: Now wired up to the "terraform rpcapi" plumbing command This doesn't actually do anything useful yet, but this does at least make the RPC server accessible to external callers where they can handshake and then get an error saying that nothing else is implemented. --- commands.go | 19 +++++-- internal/rpcapi/cli.go | 101 ++++++++++++++++++++++++++++++++++++++ internal/rpcapi/plugin.go | 6 ++- internal/rpcapi/server.go | 44 +++++++++++++++-- 4 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 internal/rpcapi/cli.go 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{