diff --git a/commands.go b/commands.go index a921b0f84a..dfec5d0a90 100644 --- a/commands.go +++ b/commands.go @@ -414,6 +414,14 @@ func initCommands( }, } + if meta.AllowExperimentalFeatures { + Commands["cloud"] = func() (cli.Command, error) { + return &command.CloudCommand{ + Meta: meta, + }, nil + } + } + PrimaryCommands = []string{ "init", "validate", @@ -423,11 +431,10 @@ func initCommands( } HiddenCommands = map[string]struct{}{ - "env": struct{}{}, - "internal-plugin": struct{}{}, - "push": struct{}{}, + "env": {}, + "internal-plugin": {}, + "push": {}, } - } // makeShutdownCh creates an interrupt listener and returns a channel. diff --git a/internal/cloudplugin/cloudplugin1/grpc_client.go b/internal/cloudplugin/cloudplugin1/grpc_client.go new file mode 100644 index 0000000000..5315b6e16d --- /dev/null +++ b/internal/cloudplugin/cloudplugin1/grpc_client.go @@ -0,0 +1,72 @@ +package cloudplugin1 + +import ( + "context" + "fmt" + "io" + "log" + + "github.com/hashicorp/terraform/internal/cloudplugin" + "github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1" +) + +// GRPCCloudClient is the client interface for interacting with terraform-cloudplugin +type GRPCCloudClient struct { + client cloudproto1.CommandServiceClient + context context.Context +} + +// Proof that GRPCCloudClient fulfills the go-plugin interface +var _ cloudplugin.Cloud1 = GRPCCloudClient{} + +// Execute sends the client Execute request and waits for the plugin to return +// an exit code response before returning +func (c GRPCCloudClient) Execute(args []string, stdout, stderr io.Writer) int { + client, err := c.client.Execute(c.context, &cloudproto1.CommandRequest{ + Args: args, + }) + + if err != nil { + fmt.Fprint(stderr, err.Error()) + return 1 + } + + for { + // cloudplugin streams output as multiple CommandResponse value. Each + // value will either contain stdout bytes, stderr bytes, or an exit code. + response, err := client.Recv() + if err == io.EOF { + log.Print("[DEBUG] received EOF from cloudplugin") + break + } else if err != nil { + fmt.Fprintf(stderr, "Failed to receive command response from cloudplugin: %s", err) + return 1 + } + + if bytes := response.GetStdout(); len(bytes) > 0 { + _, err := fmt.Fprint(stdout, string(bytes)) + if err != nil { + log.Printf("[ERROR] Failed to write cloudplugin output to stdout: %s", err) + return 1 + } + } else if bytes := response.GetStderr(); len(bytes) > 0 { + fmt.Fprint(stderr, string(bytes)) + if err != nil { + log.Printf("[ERROR] Failed to write cloudplugin output to stderr: %s", err) + return 1 + } + } else { + exitCode := response.GetExitCode() + log.Printf("[TRACE] received exit code: %d", exitCode) + if exitCode < 0 || exitCode > 255 { + log.Printf("[ERROR] cloudplugin returned an invalid error code %d", exitCode) + return 255 + } + return int(exitCode) + } + } + + // This should indicate a bug in the plugin + fmt.Fprint(stderr, "cloudplugin exited without responding with an error code") + return 1 +} diff --git a/internal/cloudplugin/cloudplugin1/grpc_client_test.go b/internal/cloudplugin/cloudplugin1/grpc_client_test.go new file mode 100644 index 0000000000..8113e88b1a --- /dev/null +++ b/internal/cloudplugin/cloudplugin1/grpc_client_test.go @@ -0,0 +1,137 @@ +package cloudplugin1 + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "testing" + + "github.com/golang/mock/gomock" + "github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1" + "github.com/hashicorp/terraform/internal/cloudplugin/mock_cloudproto1" +) + +var mockError = "this is a mock error" + +func testGRPCloudClient(t *testing.T, ctrl *gomock.Controller, client *mock_cloudproto1.MockCommandService_ExecuteClient, executeError error) *GRPCCloudClient { + t.Helper() + + if client != nil && executeError != nil { + t.Fatal("one of client or executeError must be nil") + } + + result := mock_cloudproto1.NewMockCommandServiceClient(ctrl) + + result.EXPECT().Execute( + gomock.Any(), + gomock.Any(), + gomock.Any(), + ).Return(client, executeError) + + return &GRPCCloudClient{ + client: result, + context: context.Background(), + } +} + +func Test_GRPCCloudClient_ExecuteError(t *testing.T) { + ctrl := gomock.NewController(t) + gRPCClient := testGRPCloudClient(t, ctrl, nil, errors.New(mockError)) + + buffer := bytes.Buffer{} + exitCode := gRPCClient.Execute([]string{"example"}, io.Discard, &buffer) + + if exitCode != 1 { + t.Fatalf("expected exit %d, got %d", 1, exitCode) + } + + if buffer.String() != mockError { + t.Errorf("expected error %q, got %q", mockError, buffer.String()) + } +} + +func Test_GRPCCloudClient_Execute_RecvError(t *testing.T) { + ctrl := gomock.NewController(t) + executeClient := mock_cloudproto1.NewMockCommandService_ExecuteClient(ctrl) + executeClient.EXPECT().Recv().Return(nil, errors.New(mockError)) + + gRPCClient := testGRPCloudClient(t, ctrl, executeClient, nil) + + buffer := bytes.Buffer{} + exitCode := gRPCClient.Execute([]string{"example"}, io.Discard, &buffer) + + if exitCode != 1 { + t.Fatalf("expected exit %d, got %d", 1, exitCode) + } + + mockRecvError := fmt.Sprintf("Failed to receive command response from cloudplugin: %s", mockError) + + if buffer.String() != mockRecvError { + t.Errorf("expected error %q, got %q", mockRecvError, buffer.String()) + } +} + +func Test_GRPCCloudClient_Execute_Invalid_Exit(t *testing.T) { + ctrl := gomock.NewController(t) + executeClient := mock_cloudproto1.NewMockCommandService_ExecuteClient(ctrl) + + executeClient.EXPECT().Recv().Return( + &cloudproto1.CommandResponse{ + Data: &cloudproto1.CommandResponse_ExitCode{ + ExitCode: 3_000, + }, + }, nil, + ) + + gRPCClient := testGRPCloudClient(t, ctrl, executeClient, nil) + + exitCode := gRPCClient.Execute([]string{"example"}, io.Discard, io.Discard) + + if exitCode != 255 { + t.Fatalf("expected exit %q, got %q", 255, exitCode) + } +} + +func Test_GRPCCloudClient_Execute(t *testing.T) { + ctrl := gomock.NewController(t) + executeClient := mock_cloudproto1.NewMockCommandService_ExecuteClient(ctrl) + + gomock.InOrder( + executeClient.EXPECT().Recv().Return( + &cloudproto1.CommandResponse{ + Data: &cloudproto1.CommandResponse_Stdout{ + Stdout: []byte("firstcall\n"), + }, + }, nil, + ), + executeClient.EXPECT().Recv().Return( + &cloudproto1.CommandResponse{ + Data: &cloudproto1.CommandResponse_Stdout{ + Stdout: []byte("secondcall\n"), + }, + }, nil, + ), + executeClient.EXPECT().Recv().Return( + &cloudproto1.CommandResponse{ + Data: &cloudproto1.CommandResponse_ExitCode{ + ExitCode: 99, + }, + }, nil, + ), + ) + + gRPCClient := testGRPCloudClient(t, ctrl, executeClient, nil) + + stdoutBuffer := bytes.Buffer{} + exitCode := gRPCClient.Execute([]string{"example"}, &stdoutBuffer, io.Discard) + + if exitCode != 99 { + t.Fatalf("expected exit %q, got %q", 99, exitCode) + } + + if stdoutBuffer.String() != "firstcall\nsecondcall\n" { + t.Errorf("expected output %q, got %q", "firstcall\nsecondcall\n", stdoutBuffer.String()) + } +} diff --git a/internal/cloudplugin/cloudplugin1/grpc_plugin.go b/internal/cloudplugin/cloudplugin1/grpc_plugin.go new file mode 100644 index 0000000000..0bb0fc7c77 --- /dev/null +++ b/internal/cloudplugin/cloudplugin1/grpc_plugin.go @@ -0,0 +1,45 @@ +package cloudplugin1 + +import ( + "context" + "errors" + "net/rpc" + + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/terraform/internal/cloudplugin" + "github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1" + "google.golang.org/grpc" +) + +// GRPCCloudPlugin is the go-plugin implementation, but only the client +// implementation exists in this package. +type GRPCCloudPlugin struct { + plugin.GRPCPlugin + Impl cloudplugin.Cloud1 +} + +// Server always returns an error; we're only implementing the GRPCPlugin +// interface, not the Plugin interface. +func (p *GRPCCloudPlugin) Server(*plugin.MuxBroker) (interface{}, error) { + return nil, errors.New("cloudplugin only implements gRPC clients") +} + +// Client always returns an error; we're only implementing the GRPCPlugin +// interface, not the Plugin interface. +func (p *GRPCCloudPlugin) Client(*plugin.MuxBroker, *rpc.Client) (interface{}, error) { + return nil, errors.New("cloudplugin only implements gRPC clients") +} + +// GRPCServer always returns an error; we're only implementing the client +// interface, not the server. +func (p *GRPCCloudPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { + return errors.New("cloudplugin only implements gRPC clients") +} + +// GRPCClient returns a new GRPC client for interacting with the cloud plugin server. +func (p *GRPCCloudPlugin) GRPCClient(ctx context.Context, broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { + return &GRPCCloudClient{ + client: cloudproto1.NewCommandServiceClient(c), + context: ctx, + }, nil +} diff --git a/internal/cloudplugin/cloudproto1/cloudproto1.pb.go b/internal/cloudplugin/cloudproto1/cloudproto1.pb.go new file mode 100644 index 0000000000..0fa9c1e46d --- /dev/null +++ b/internal/cloudplugin/cloudproto1/cloudproto1.pb.go @@ -0,0 +1,390 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.31.0 +// protoc v3.15.6 +// source: cloudproto1.proto + +package cloudproto1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// CommandRequest is used to request the execution of a specific command with +// provided flags. It is the raw args from the terraform cloud command. +type CommandRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Args []string `protobuf:"bytes,1,rep,name=args,proto3" json:"args,omitempty"` +} + +func (x *CommandRequest) Reset() { + *x = CommandRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_cloudproto1_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandRequest) ProtoMessage() {} + +func (x *CommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_cloudproto1_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandRequest.ProtoReflect.Descriptor instead. +func (*CommandRequest) Descriptor() ([]byte, []int) { + return file_cloudproto1_proto_rawDescGZIP(), []int{0} +} + +func (x *CommandRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +// CommandResponse contains the result of the command execution, including any +// output or errors. +type CommandResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Data: + // + // *CommandResponse_ExitCode + // *CommandResponse_Stdout + // *CommandResponse_Stderr + Data isCommandResponse_Data `protobuf_oneof:"data"` +} + +func (x *CommandResponse) Reset() { + *x = CommandResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_cloudproto1_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CommandResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandResponse) ProtoMessage() {} + +func (x *CommandResponse) ProtoReflect() protoreflect.Message { + mi := &file_cloudproto1_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandResponse.ProtoReflect.Descriptor instead. +func (*CommandResponse) Descriptor() ([]byte, []int) { + return file_cloudproto1_proto_rawDescGZIP(), []int{1} +} + +func (m *CommandResponse) GetData() isCommandResponse_Data { + if m != nil { + return m.Data + } + return nil +} + +func (x *CommandResponse) GetExitCode() int32 { + if x, ok := x.GetData().(*CommandResponse_ExitCode); ok { + return x.ExitCode + } + return 0 +} + +func (x *CommandResponse) GetStdout() []byte { + if x, ok := x.GetData().(*CommandResponse_Stdout); ok { + return x.Stdout + } + return nil +} + +func (x *CommandResponse) GetStderr() []byte { + if x, ok := x.GetData().(*CommandResponse_Stderr); ok { + return x.Stderr + } + return nil +} + +type isCommandResponse_Data interface { + isCommandResponse_Data() +} + +type CommandResponse_ExitCode struct { + ExitCode int32 `protobuf:"varint,1,opt,name=exitCode,proto3,oneof"` +} + +type CommandResponse_Stdout struct { + Stdout []byte `protobuf:"bytes,2,opt,name=stdout,proto3,oneof"` +} + +type CommandResponse_Stderr struct { + Stderr []byte `protobuf:"bytes,3,opt,name=stderr,proto3,oneof"` +} + +func (*CommandResponse_ExitCode) isCommandResponse_Data() {} + +func (*CommandResponse_Stdout) isCommandResponse_Data() {} + +func (*CommandResponse_Stderr) isCommandResponse_Data() {} + +var File_cloudproto1_proto protoreflect.FileDescriptor + +var file_cloudproto1_proto_rawDesc = []byte{ + 0x0a, 0x11, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, + 0x22, 0x24, 0x0a, 0x0e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x22, 0x6b, 0x0a, 0x0f, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, + 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x08, 0x65, 0x78, 0x69, + 0x74, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x65, + 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, + 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x6f, 0x75, + 0x74, 0x12, 0x18, 0x0a, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0c, 0x48, 0x00, 0x52, 0x06, 0x73, 0x74, 0x64, 0x65, 0x72, 0x72, 0x42, 0x06, 0x0a, 0x04, 0x64, + 0x61, 0x74, 0x61, 0x32, 0x5a, 0x0a, 0x0e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x53, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x48, 0x0a, 0x07, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65, + 0x12, 0x1b, 0x2e, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x2e, 0x43, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, + 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42, + 0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, + 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x74, 0x65, 0x72, 0x72, 0x61, 0x66, 0x6f, 0x72, + 0x6d, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6c, 0x6f, 0x75, 0x64, + 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x2f, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_cloudproto1_proto_rawDescOnce sync.Once + file_cloudproto1_proto_rawDescData = file_cloudproto1_proto_rawDesc +) + +func file_cloudproto1_proto_rawDescGZIP() []byte { + file_cloudproto1_proto_rawDescOnce.Do(func() { + file_cloudproto1_proto_rawDescData = protoimpl.X.CompressGZIP(file_cloudproto1_proto_rawDescData) + }) + return file_cloudproto1_proto_rawDescData +} + +var file_cloudproto1_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_cloudproto1_proto_goTypes = []interface{}{ + (*CommandRequest)(nil), // 0: cloudproto1.CommandRequest + (*CommandResponse)(nil), // 1: cloudproto1.CommandResponse +} +var file_cloudproto1_proto_depIdxs = []int32{ + 0, // 0: cloudproto1.CommandService.Execute:input_type -> cloudproto1.CommandRequest + 1, // 1: cloudproto1.CommandService.Execute:output_type -> cloudproto1.CommandResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_cloudproto1_proto_init() } +func file_cloudproto1_proto_init() { + if File_cloudproto1_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_cloudproto1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_cloudproto1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CommandResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_cloudproto1_proto_msgTypes[1].OneofWrappers = []interface{}{ + (*CommandResponse_ExitCode)(nil), + (*CommandResponse_Stdout)(nil), + (*CommandResponse_Stderr)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_cloudproto1_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_cloudproto1_proto_goTypes, + DependencyIndexes: file_cloudproto1_proto_depIdxs, + MessageInfos: file_cloudproto1_proto_msgTypes, + }.Build() + File_cloudproto1_proto = out.File + file_cloudproto1_proto_rawDesc = nil + file_cloudproto1_proto_goTypes = nil + file_cloudproto1_proto_depIdxs = nil +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConnInterface + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion6 + +// CommandServiceClient is the client API for CommandService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream. +type CommandServiceClient interface { + // Execute runs a specific command with the provided flags and returns the result. + Execute(ctx context.Context, in *CommandRequest, opts ...grpc.CallOption) (CommandService_ExecuteClient, error) +} + +type commandServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewCommandServiceClient(cc grpc.ClientConnInterface) CommandServiceClient { + return &commandServiceClient{cc} +} + +func (c *commandServiceClient) Execute(ctx context.Context, in *CommandRequest, opts ...grpc.CallOption) (CommandService_ExecuteClient, error) { + stream, err := c.cc.NewStream(ctx, &_CommandService_serviceDesc.Streams[0], "/cloudproto1.CommandService/Execute", opts...) + if err != nil { + return nil, err + } + x := &commandServiceExecuteClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type CommandService_ExecuteClient interface { + Recv() (*CommandResponse, error) + grpc.ClientStream +} + +type commandServiceExecuteClient struct { + grpc.ClientStream +} + +func (x *commandServiceExecuteClient) Recv() (*CommandResponse, error) { + m := new(CommandResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +// CommandServiceServer is the server API for CommandService service. +type CommandServiceServer interface { + // Execute runs a specific command with the provided flags and returns the result. + Execute(*CommandRequest, CommandService_ExecuteServer) error +} + +// UnimplementedCommandServiceServer can be embedded to have forward compatible implementations. +type UnimplementedCommandServiceServer struct { +} + +func (*UnimplementedCommandServiceServer) Execute(*CommandRequest, CommandService_ExecuteServer) error { + return status.Errorf(codes.Unimplemented, "method Execute not implemented") +} + +func RegisterCommandServiceServer(s *grpc.Server, srv CommandServiceServer) { + s.RegisterService(&_CommandService_serviceDesc, srv) +} + +func _CommandService_Execute_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(CommandRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(CommandServiceServer).Execute(m, &commandServiceExecuteServer{stream}) +} + +type CommandService_ExecuteServer interface { + Send(*CommandResponse) error + grpc.ServerStream +} + +type commandServiceExecuteServer struct { + grpc.ServerStream +} + +func (x *commandServiceExecuteServer) Send(m *CommandResponse) error { + return x.ServerStream.SendMsg(m) +} + +var _CommandService_serviceDesc = grpc.ServiceDesc{ + ServiceName: "cloudproto1.CommandService", + HandlerType: (*CommandServiceServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Execute", + Handler: _CommandService_Execute_Handler, + ServerStreams: true, + }, + }, + Metadata: "cloudproto1.proto", +} diff --git a/internal/cloudplugin/cloudproto1/cloudproto1.proto b/internal/cloudplugin/cloudproto1/cloudproto1.proto new file mode 100644 index 0000000000..e27f354de6 --- /dev/null +++ b/internal/cloudplugin/cloudproto1/cloudproto1.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; +package cloudproto1; + +option go_package = "github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1"; + +// CommandRequest is used to request the execution of a specific command with +// provided flags. It is the raw args from the terraform cloud command. +message CommandRequest { + repeated string args = 1; +} + +// CommandResponse contains the result of the command execution, including any +// output or errors. +message CommandResponse { + oneof data { + int32 exitCode = 1; + bytes stdout = 2; + bytes stderr = 3; + } +} + +// PluginService defines the gRPC service to handle available commands and +// their execution. +service CommandService { + // Execute runs a specific command with the provided flags and returns the result. + rpc Execute(CommandRequest) returns (stream CommandResponse) {} +} diff --git a/internal/cloudplugin/interface.go b/internal/cloudplugin/interface.go new file mode 100644 index 0000000000..6138508fd6 --- /dev/null +++ b/internal/cloudplugin/interface.go @@ -0,0 +1,9 @@ +package cloudplugin + +import ( + "io" +) + +type Cloud1 interface { + Execute(args []string, stdout, stderr io.Writer) int +} diff --git a/internal/cloudplugin/mock_cloudproto1/generate.go b/internal/cloudplugin/mock_cloudproto1/generate.go new file mode 100644 index 0000000000..3218961ccf --- /dev/null +++ b/internal/cloudplugin/mock_cloudproto1/generate.go @@ -0,0 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +//go:generate go run github.com/golang/mock/mockgen -destination mock.go github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1 CommandServiceClient,CommandService_ExecuteClient + +package mock_cloudproto1 diff --git a/internal/cloudplugin/mock_cloudproto1/mock.go b/internal/cloudplugin/mock_cloudproto1/mock.go new file mode 100644 index 0000000000..ae542baddc --- /dev/null +++ b/internal/cloudplugin/mock_cloudproto1/mock.go @@ -0,0 +1,181 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1 (interfaces: CommandServiceClient,CommandService_ExecuteClient) + +// Package mock_cloudproto1 is a generated GoMock package. +package mock_cloudproto1 + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + cloudproto1 "github.com/hashicorp/terraform/internal/cloudplugin/cloudproto1" + grpc "google.golang.org/grpc" + metadata "google.golang.org/grpc/metadata" +) + +// MockCommandServiceClient is a mock of CommandServiceClient interface. +type MockCommandServiceClient struct { + ctrl *gomock.Controller + recorder *MockCommandServiceClientMockRecorder +} + +// MockCommandServiceClientMockRecorder is the mock recorder for MockCommandServiceClient. +type MockCommandServiceClientMockRecorder struct { + mock *MockCommandServiceClient +} + +// NewMockCommandServiceClient creates a new mock instance. +func NewMockCommandServiceClient(ctrl *gomock.Controller) *MockCommandServiceClient { + mock := &MockCommandServiceClient{ctrl: ctrl} + mock.recorder = &MockCommandServiceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCommandServiceClient) EXPECT() *MockCommandServiceClientMockRecorder { + return m.recorder +} + +// Execute mocks base method. +func (m *MockCommandServiceClient) Execute(arg0 context.Context, arg1 *cloudproto1.CommandRequest, arg2 ...grpc.CallOption) (cloudproto1.CommandService_ExecuteClient, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0, arg1} + for _, a := range arg2 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Execute", varargs...) + ret0, _ := ret[0].(cloudproto1.CommandService_ExecuteClient) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Execute indicates an expected call of Execute. +func (mr *MockCommandServiceClientMockRecorder) Execute(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0, arg1}, arg2...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockCommandServiceClient)(nil).Execute), varargs...) +} + +// MockCommandService_ExecuteClient is a mock of CommandService_ExecuteClient interface. +type MockCommandService_ExecuteClient struct { + ctrl *gomock.Controller + recorder *MockCommandService_ExecuteClientMockRecorder +} + +// MockCommandService_ExecuteClientMockRecorder is the mock recorder for MockCommandService_ExecuteClient. +type MockCommandService_ExecuteClientMockRecorder struct { + mock *MockCommandService_ExecuteClient +} + +// NewMockCommandService_ExecuteClient creates a new mock instance. +func NewMockCommandService_ExecuteClient(ctrl *gomock.Controller) *MockCommandService_ExecuteClient { + mock := &MockCommandService_ExecuteClient{ctrl: ctrl} + mock.recorder = &MockCommandService_ExecuteClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCommandService_ExecuteClient) EXPECT() *MockCommandService_ExecuteClientMockRecorder { + return m.recorder +} + +// CloseSend mocks base method. +func (m *MockCommandService_ExecuteClient) CloseSend() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CloseSend") + ret0, _ := ret[0].(error) + return ret0 +} + +// CloseSend indicates an expected call of CloseSend. +func (mr *MockCommandService_ExecuteClientMockRecorder) CloseSend() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CloseSend", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).CloseSend)) +} + +// Context mocks base method. +func (m *MockCommandService_ExecuteClient) Context() context.Context { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Context") + ret0, _ := ret[0].(context.Context) + return ret0 +} + +// Context indicates an expected call of Context. +func (mr *MockCommandService_ExecuteClientMockRecorder) Context() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Context)) +} + +// Header mocks base method. +func (m *MockCommandService_ExecuteClient) Header() (metadata.MD, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Header") + ret0, _ := ret[0].(metadata.MD) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Header indicates an expected call of Header. +func (mr *MockCommandService_ExecuteClientMockRecorder) Header() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Header", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Header)) +} + +// Recv mocks base method. +func (m *MockCommandService_ExecuteClient) Recv() (*cloudproto1.CommandResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Recv") + ret0, _ := ret[0].(*cloudproto1.CommandResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Recv indicates an expected call of Recv. +func (mr *MockCommandService_ExecuteClientMockRecorder) Recv() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Recv", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Recv)) +} + +// RecvMsg mocks base method. +func (m *MockCommandService_ExecuteClient) RecvMsg(arg0 interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RecvMsg", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// RecvMsg indicates an expected call of RecvMsg. +func (mr *MockCommandService_ExecuteClientMockRecorder) RecvMsg(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecvMsg", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).RecvMsg), arg0) +} + +// SendMsg mocks base method. +func (m *MockCommandService_ExecuteClient) SendMsg(arg0 interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SendMsg", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// SendMsg indicates an expected call of SendMsg. +func (mr *MockCommandService_ExecuteClientMockRecorder) SendMsg(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendMsg", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).SendMsg), arg0) +} + +// Trailer mocks base method. +func (m *MockCommandService_ExecuteClient) Trailer() metadata.MD { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Trailer") + ret0, _ := ret[0].(metadata.MD) + return ret0 +} + +// Trailer indicates an expected call of Trailer. +func (mr *MockCommandService_ExecuteClientMockRecorder) Trailer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Trailer", reflect.TypeOf((*MockCommandService_ExecuteClient)(nil).Trailer)) +} diff --git a/internal/command/cloud.go b/internal/command/cloud.go new file mode 100644 index 0000000000..9e7d85748d --- /dev/null +++ b/internal/command/cloud.go @@ -0,0 +1,108 @@ +package command + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + + "github.com/hashicorp/go-plugin" + "github.com/hashicorp/terraform/internal/cloudplugin" + "github.com/hashicorp/terraform/internal/cloudplugin/cloudplugin1" + "github.com/hashicorp/terraform/internal/logging" +) + +// CloudCommand is a Command implementation that interacts with Terraform +// Cloud for operations that are inherently planless. It delegates +// all execution to an internal plugin. +type CloudCommand struct { + Meta +} + +const ( + // DefaultCloudPluginVersion is the implied protocol version, though all + // historical versions are defined explicitly. + DefaultCloudPluginVersion = 1 + + // ExitRPCError is the exit code that is returned if an plugin + // communication error occurred. + ExitRPCError = 99 +) + +var ( + // Handshake is used to verify that the plugin is the appropriate plugin for + // the client. This is not a security verification. + Handshake = plugin.HandshakeConfig{ + MagicCookieKey: "TF_CLOUDPLUGIN_MAGIC_COOKIE", + MagicCookieValue: "721fca41431b780ff3ad2623838faaa178d74c65e1cfdfe19537c31656496bf9f82d6c6707f71d81c8eed0db9043f79e56ab4582d013bc08ead14f57961461dc", + ProtocolVersion: DefaultCloudPluginVersion, + } +) + +func (c *CloudCommand) proxy(args []string, stdout, stderr io.Writer) int { + client := plugin.NewClient(&plugin.ClientConfig{ + HandshakeConfig: Handshake, + AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, + Cmd: exec.Command("./terraform-cloudplugin"), + Logger: logging.NewCloudLogger(), + VersionedPlugins: map[int]plugin.PluginSet{ + 1: { + "cloud": &cloudplugin1.GRPCCloudPlugin{}, + }, + }, + }) + defer client.Kill() + + // Connect via RPC + rpcClient, err := client.Client() + if err != nil { + fmt.Fprintf(stderr, "Failed to create cloud plugin client: %s", err) + return ExitRPCError + } + + // Request the plugin + raw, err := rpcClient.Dispense("cloud") + if err != nil { + fmt.Fprintf(stderr, "Failed to request cloud 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. + cloud1, ok := raw.(cloudplugin.Cloud1) + if !ok { + c.Ui.Error("If more than one cloudplugin versions are available, they need to be added to the cloud command. This is a bug in Terraform.") + return ExitRPCError + } + return cloud1.Execute(args, stdout, stderr) +} + +// Run runs the cloud command with the given arguments. +func (c *CloudCommand) Run(args []string) int { + args = c.Meta.process(args) + + // TODO: Download and verify the signing of the terraform-cloudplugin + // release that is appropriate for this OS/Arch + if _, err := os.Stat("./terraform-cloudplugin"); err != nil { + c.Ui.Warn("terraform-cloudplugin not found. This plugin does not have an official release yet.") + return 1 + } + + // TODO: Need to use some type of c.Meta handle here + return c.proxy(args, os.Stdout, os.Stderr) +} + +// Help returns help text for the cloud command. +func (c *CloudCommand) Help() string { + helpText := new(bytes.Buffer) + c.proxy([]string{}, helpText, io.Discard) + + return helpText.String() +} + +// Synopsis returns a short summary of the cloud command. +func (c *CloudCommand) Synopsis() string { + return "Manage Terraform Cloud settings and metadata" +} diff --git a/internal/command/console.go b/internal/command/console.go index 16c669b498..d7f84f35f6 100644 --- a/internal/command/console.go +++ b/internal/command/console.go @@ -19,8 +19,8 @@ import ( "github.com/mitchellh/cli" ) -// ConsoleCommand is a Command implementation that applies a Terraform -// configuration and actually builds or changes infrastructure. +// ConsoleCommand is a Command implementation that starts an interactive +// console that can be used to try expressions with the current config. type ConsoleCommand struct { Meta } diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 325b747ecb..92c33048dd 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -30,6 +30,7 @@ const ( // to other loggers, like provisioners and remote-state backends. envLogCore = "TF_LOG_CORE" envLogProvider = "TF_LOG_PROVIDER" + envLogCloud = "TF_LOG_CLOUD" ) var ( @@ -134,6 +135,20 @@ func NewProviderLogger(prefix string) hclog.Logger { return l } +// NewCloudLogger returns a logger for the cloud plugin, possibly with a +// different log level from the global logger. +func NewCloudLogger() hclog.Logger { + l := &logPanicWrapper{ + Logger: logger.Named("cloud"), + } + + level := cloudLogLevel() + logger.Debug("created cloud logger", "level", level) + + l.SetLevel(level) + return l +} + // CurrentLogLevel returns the current log level string based the environment vars func CurrentLogLevel() string { ll, _ := globalLogLevel() @@ -149,6 +164,15 @@ func providerLogLevel() hclog.Level { return parseLogLevel(providerEnvLevel) } +func cloudLogLevel() hclog.Level { + providerEnvLevel := strings.ToUpper(os.Getenv(envLogCloud)) + if providerEnvLevel == "" { + providerEnvLevel = strings.ToUpper(os.Getenv(envLog)) + } + + return parseLogLevel(providerEnvLevel) +} + func globalLogLevel() (hclog.Level, bool) { var json bool envLevel := strings.ToUpper(os.Getenv(envLog)) diff --git a/tools/protobuf-compile/protobuf-compile.go b/tools/protobuf-compile/protobuf-compile.go index 2591cd3dde..6e27fba689 100644 --- a/tools/protobuf-compile/protobuf-compile.go +++ b/tools/protobuf-compile/protobuf-compile.go @@ -58,6 +58,11 @@ var protocSteps = []protocStep{ "internal/plans/internal/planproto", []string{"--go_out=paths=source_relative:.", "planfile.proto"}, }, + { + "cloudproto1 (cloud protocol version 1)", + "internal/cloudplugin/cloudproto1", + []string{"--go_out=paths=source_relative,plugins=grpc:.", "cloudproto1.proto"}, + }, } func main() {