diff --git a/gen/proto/go/teleport/lib/teleterm/v1/gateway.pb.go b/gen/proto/go/teleport/lib/teleterm/v1/gateway.pb.go index b7d3e2bb326b3..3e584e1dac326 100644 --- a/gen/proto/go/teleport/lib/teleterm/v1/gateway.pb.go +++ b/gen/proto/go/teleport/lib/teleterm/v1/gateway.pb.go @@ -61,16 +61,16 @@ type Gateway struct { LocalPort string `protobuf:"bytes,6,opt,name=local_port,json=localPort,proto3" json:"local_port,omitempty"` // protocol is the gateway protocol Protocol string `protobuf:"bytes,7,opt,name=protocol,proto3" json:"protocol,omitempty"` - // cli_command is a command that the user can execute to connect to the resource within a CLI, - // if the given resource has a CLI client. - // - // Instead of generating those commands in in the frontend code, the tsh daemon returns them. - // This means that the Database Access team can add support for a new protocol and Teleterm will - // support it right away without any changes to Teleterm's code. - CliCommand string `protobuf:"bytes,8,opt,name=cli_command,json=cliCommand,proto3" json:"cli_command,omitempty"` // target_subresource_name points at a subresource of the remote resource, for example a // database name on a database server. TargetSubresourceName string `protobuf:"bytes,9,opt,name=target_subresource_name,json=targetSubresourceName,proto3" json:"target_subresource_name,omitempty"` + // gateway_cli_client represents a command that the user can execute to connect to the resource + // through the gateway. + // + // Instead of generating those commands in in the frontend code, they are returned from the tsh + // daemon. This means that the Database Access team can add support for a new protocol and + // Connect will support it right away with no extra changes. + GatewayCliCommand *GatewayCLICommand `protobuf:"bytes,10,opt,name=gateway_cli_command,json=gatewayCliCommand,proto3" json:"gateway_cli_command,omitempty"` } func (x *Gateway) Reset() { @@ -154,16 +154,94 @@ func (x *Gateway) GetProtocol() string { return "" } -func (x *Gateway) GetCliCommand() string { +func (x *Gateway) GetTargetSubresourceName() string { if x != nil { - return x.CliCommand + return x.TargetSubresourceName } return "" } -func (x *Gateway) GetTargetSubresourceName() string { +func (x *Gateway) GetGatewayCliCommand() *GatewayCLICommand { if x != nil { - return x.TargetSubresourceName + return x.GatewayCliCommand + } + return nil +} + +// GatewayCLICommand represents a command that the user can execute to connect to the gateway +// resource. It is a direct translation of os.exec.Cmd. +type GatewayCLICommand struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"` + Env []string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty"` + // preview is used to show the user what command will be executed before they decide to run it. + // It's like os.exec.Cmd.String with two exceptions: + // + // 1) It is prepended with Cmd.Env. + // 2) The command name is relative and not absolute. + Preview string `protobuf:"bytes,4,opt,name=preview,proto3" json:"preview,omitempty"` +} + +func (x *GatewayCLICommand) Reset() { + *x = GatewayCLICommand{} + if protoimpl.UnsafeEnabled { + mi := &file_teleport_lib_teleterm_v1_gateway_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GatewayCLICommand) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GatewayCLICommand) ProtoMessage() {} + +func (x *GatewayCLICommand) ProtoReflect() protoreflect.Message { + mi := &file_teleport_lib_teleterm_v1_gateway_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 GatewayCLICommand.ProtoReflect.Descriptor instead. +func (*GatewayCLICommand) Descriptor() ([]byte, []int) { + return file_teleport_lib_teleterm_v1_gateway_proto_rawDescGZIP(), []int{1} +} + +func (x *GatewayCLICommand) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *GatewayCLICommand) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *GatewayCLICommand) GetEnv() []string { + if x != nil { + return x.Env + } + return nil +} + +func (x *GatewayCLICommand) GetPreview() string { + if x != nil { + return x.Preview } return "" } @@ -175,7 +253,7 @@ var file_teleport_lib_teleterm_v1_gateway_proto_rawDesc = []byte{ 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2f, 0x76, 0x31, 0x2f, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x18, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, - 0x76, 0x31, 0x22, 0xb5, 0x02, 0x0a, 0x07, 0x47, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x12, 0x10, + 0x76, 0x31, 0x22, 0x84, 0x03, 0x0a, 0x07, 0x47, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x4e, 0x61, 0x6d, @@ -189,18 +267,29 @@ var file_teleport_lib_teleterm_v1_gateway_proto_rawDesc = []byte{ 0x70, 0x6f, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, - 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x6c, 0x69, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6c, 0x69, 0x43, 0x6f, 0x6d, 0x6d, 0x61, - 0x6e, 0x64, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x73, 0x75, 0x62, - 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x09, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x15, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x53, 0x75, 0x62, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x42, 0x54, 0x5a, 0x52, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, - 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x74, 0x65, 0x6c, - 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x6c, 0x69, 0x62, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x72, 0x6d, 0x2f, 0x76, 0x31, 0x3b, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x76, 0x31, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6c, 0x12, 0x36, 0x0a, 0x17, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x73, 0x75, 0x62, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x15, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x53, 0x75, 0x62, 0x72, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x5b, 0x0a, 0x13, 0x67, 0x61, 0x74, + 0x65, 0x77, 0x61, 0x79, 0x5f, 0x63, 0x6c, 0x69, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, + 0x74, 0x2e, 0x6c, 0x69, 0x62, 0x2e, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2e, 0x76, + 0x31, 0x2e, 0x47, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x43, 0x4c, 0x49, 0x43, 0x6f, 0x6d, 0x6d, + 0x61, 0x6e, 0x64, 0x52, 0x11, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x43, 0x6c, 0x69, 0x43, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x4a, 0x04, 0x08, 0x08, 0x10, 0x09, 0x52, 0x0b, 0x63, 0x6c, + 0x69, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x22, 0x67, 0x0a, 0x11, 0x47, 0x61, 0x74, + 0x65, 0x77, 0x61, 0x79, 0x43, 0x4c, 0x49, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, + 0x74, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x04, 0x61, 0x72, 0x67, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x65, 0x6e, 0x76, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x03, 0x65, 0x6e, 0x76, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x65, 0x76, + 0x69, 0x65, 0x77, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x65, 0x76, 0x69, + 0x65, 0x77, 0x42, 0x54, 0x5a, 0x52, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x67, 0x72, 0x61, 0x76, 0x69, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x61, 0x6c, 0x2f, 0x74, + 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x2f, 0x6c, 0x69, + 0x62, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x2f, 0x76, 0x31, 0x3b, 0x74, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x72, 0x6d, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -215,16 +304,18 @@ func file_teleport_lib_teleterm_v1_gateway_proto_rawDescGZIP() []byte { return file_teleport_lib_teleterm_v1_gateway_proto_rawDescData } -var file_teleport_lib_teleterm_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_teleport_lib_teleterm_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_teleport_lib_teleterm_v1_gateway_proto_goTypes = []interface{}{ - (*Gateway)(nil), // 0: teleport.lib.teleterm.v1.Gateway + (*Gateway)(nil), // 0: teleport.lib.teleterm.v1.Gateway + (*GatewayCLICommand)(nil), // 1: teleport.lib.teleterm.v1.GatewayCLICommand } var file_teleport_lib_teleterm_v1_gateway_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] 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 + 1, // 0: teleport.lib.teleterm.v1.Gateway.gateway_cli_command:type_name -> teleport.lib.teleterm.v1.GatewayCLICommand + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_teleport_lib_teleterm_v1_gateway_proto_init() } @@ -245,6 +336,18 @@ func file_teleport_lib_teleterm_v1_gateway_proto_init() { return nil } } + file_teleport_lib_teleterm_v1_gateway_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GatewayCLICommand); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -252,7 +355,7 @@ func file_teleport_lib_teleterm_v1_gateway_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_teleport_lib_teleterm_v1_gateway_proto_rawDesc, NumEnums: 0, - NumMessages: 1, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.d.ts b/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.d.ts index 5f23a862b2c84..37e82ede47869 100644 --- a/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.d.ts +++ b/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.d.ts @@ -28,13 +28,16 @@ export class Gateway extends jspb.Message { getProtocol(): string; setProtocol(value: string): Gateway; - getCliCommand(): string; - setCliCommand(value: string): Gateway; - getTargetSubresourceName(): string; setTargetSubresourceName(value: string): Gateway; + hasGatewayCliCommand(): boolean; + clearGatewayCliCommand(): void; + getGatewayCliCommand(): GatewayCLICommand | undefined; + setGatewayCliCommand(value?: GatewayCLICommand): Gateway; + + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): Gateway.AsObject; static toObject(includeInstance: boolean, msg: Gateway): Gateway.AsObject; @@ -54,7 +57,44 @@ export namespace Gateway { localAddress: string, localPort: string, protocol: string, - cliCommand: string, targetSubresourceName: string, + gatewayCliCommand?: GatewayCLICommand.AsObject, + } +} + +export class GatewayCLICommand extends jspb.Message { + getPath(): string; + setPath(value: string): GatewayCLICommand; + + clearArgsList(): void; + getArgsList(): Array; + setArgsList(value: Array): GatewayCLICommand; + addArgs(value: string, index?: number): string; + + clearEnvList(): void; + getEnvList(): Array; + setEnvList(value: Array): GatewayCLICommand; + addEnv(value: string, index?: number): string; + + getPreview(): string; + setPreview(value: string): GatewayCLICommand; + + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): GatewayCLICommand.AsObject; + static toObject(includeInstance: boolean, msg: GatewayCLICommand): GatewayCLICommand.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: GatewayCLICommand, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): GatewayCLICommand; + static deserializeBinaryFromReader(message: GatewayCLICommand, reader: jspb.BinaryReader): GatewayCLICommand; +} + +export namespace GatewayCLICommand { + export type AsObject = { + path: string, + argsList: Array, + envList: Array, + preview: string, } } diff --git a/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.js b/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.js index 23b1276794850..b013be5b25054 100644 --- a/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.js +++ b/gen/proto/js/teleport/lib/teleterm/v1/gateway_pb.js @@ -16,6 +16,7 @@ var goog = jspb; var global = (function() { return this || window || global || self || Function('return this')(); }).call(null); goog.exportSymbol('proto.teleport.lib.teleterm.v1.Gateway', null, global); +goog.exportSymbol('proto.teleport.lib.teleterm.v1.GatewayCLICommand', null, global); /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -37,6 +38,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.teleport.lib.teleterm.v1.Gateway.displayName = 'proto.teleport.lib.teleterm.v1.Gateway'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.teleport.lib.teleterm.v1.GatewayCLICommand.repeatedFields_, null); +}; +goog.inherits(proto.teleport.lib.teleterm.v1.GatewayCLICommand, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.teleport.lib.teleterm.v1.GatewayCLICommand.displayName = 'proto.teleport.lib.teleterm.v1.GatewayCLICommand'; +} @@ -76,8 +98,8 @@ proto.teleport.lib.teleterm.v1.Gateway.toObject = function(includeInstance, msg) localAddress: jspb.Message.getFieldWithDefault(msg, 5, ""), localPort: jspb.Message.getFieldWithDefault(msg, 6, ""), protocol: jspb.Message.getFieldWithDefault(msg, 7, ""), - cliCommand: jspb.Message.getFieldWithDefault(msg, 8, ""), - targetSubresourceName: jspb.Message.getFieldWithDefault(msg, 9, "") + targetSubresourceName: jspb.Message.getFieldWithDefault(msg, 9, ""), + gatewayCliCommand: (f = msg.getGatewayCliCommand()) && proto.teleport.lib.teleterm.v1.GatewayCLICommand.toObject(includeInstance, f) }; if (includeInstance) { @@ -142,14 +164,15 @@ proto.teleport.lib.teleterm.v1.Gateway.deserializeBinaryFromReader = function(ms var value = /** @type {string} */ (reader.readString()); msg.setProtocol(value); break; - case 8: - var value = /** @type {string} */ (reader.readString()); - msg.setCliCommand(value); - break; case 9: var value = /** @type {string} */ (reader.readString()); msg.setTargetSubresourceName(value); break; + case 10: + var value = new proto.teleport.lib.teleterm.v1.GatewayCLICommand; + reader.readMessage(value,proto.teleport.lib.teleterm.v1.GatewayCLICommand.deserializeBinaryFromReader); + msg.setGatewayCliCommand(value); + break; default: reader.skipField(); break; @@ -228,13 +251,6 @@ proto.teleport.lib.teleterm.v1.Gateway.serializeBinaryToWriter = function(messag f ); } - f = message.getCliCommand(); - if (f.length > 0) { - writer.writeString( - 8, - f - ); - } f = message.getTargetSubresourceName(); if (f.length > 0) { writer.writeString( @@ -242,6 +258,14 @@ proto.teleport.lib.teleterm.v1.Gateway.serializeBinaryToWriter = function(messag f ); } + f = message.getGatewayCliCommand(); + if (f != null) { + writer.writeMessage( + 10, + f, + proto.teleport.lib.teleterm.v1.GatewayCLICommand.serializeBinaryToWriter + ); + } }; @@ -372,11 +396,11 @@ proto.teleport.lib.teleterm.v1.Gateway.prototype.setProtocol = function(value) { /** - * optional string cli_command = 8; + * optional string target_subresource_name = 9; * @return {string} */ -proto.teleport.lib.teleterm.v1.Gateway.prototype.getCliCommand = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 8, "")); +proto.teleport.lib.teleterm.v1.Gateway.prototype.getTargetSubresourceName = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 9, "")); }; @@ -384,26 +408,310 @@ proto.teleport.lib.teleterm.v1.Gateway.prototype.getCliCommand = function() { * @param {string} value * @return {!proto.teleport.lib.teleterm.v1.Gateway} returns this */ -proto.teleport.lib.teleterm.v1.Gateway.prototype.setCliCommand = function(value) { - return jspb.Message.setProto3StringField(this, 8, value); +proto.teleport.lib.teleterm.v1.Gateway.prototype.setTargetSubresourceName = function(value) { + return jspb.Message.setProto3StringField(this, 9, value); }; /** - * optional string target_subresource_name = 9; + * optional GatewayCLICommand gateway_cli_command = 10; + * @return {?proto.teleport.lib.teleterm.v1.GatewayCLICommand} + */ +proto.teleport.lib.teleterm.v1.Gateway.prototype.getGatewayCliCommand = function() { + return /** @type{?proto.teleport.lib.teleterm.v1.GatewayCLICommand} */ ( + jspb.Message.getWrapperField(this, proto.teleport.lib.teleterm.v1.GatewayCLICommand, 10)); +}; + + +/** + * @param {?proto.teleport.lib.teleterm.v1.GatewayCLICommand|undefined} value + * @return {!proto.teleport.lib.teleterm.v1.Gateway} returns this +*/ +proto.teleport.lib.teleterm.v1.Gateway.prototype.setGatewayCliCommand = function(value) { + return jspb.Message.setWrapperField(this, 10, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.teleport.lib.teleterm.v1.Gateway} returns this + */ +proto.teleport.lib.teleterm.v1.Gateway.prototype.clearGatewayCliCommand = function() { + return this.setGatewayCliCommand(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.teleport.lib.teleterm.v1.Gateway.prototype.hasGatewayCliCommand = function() { + return jspb.Message.getField(this, 10) != null; +}; + + + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.repeatedFields_ = [2,3]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.toObject = function(opt_includeInstance) { + return proto.teleport.lib.teleterm.v1.GatewayCLICommand.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.toObject = function(includeInstance, msg) { + var f, obj = { + path: jspb.Message.getFieldWithDefault(msg, 1, ""), + argsList: (f = jspb.Message.getRepeatedField(msg, 2)) == null ? undefined : f, + envList: (f = jspb.Message.getRepeatedField(msg, 3)) == null ? undefined : f, + preview: jspb.Message.getFieldWithDefault(msg, 4, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.teleport.lib.teleterm.v1.GatewayCLICommand; + return proto.teleport.lib.teleterm.v1.GatewayCLICommand.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setPath(value); + break; + case 2: + var value = /** @type {string} */ (reader.readString()); + msg.addArgs(value); + break; + case 3: + var value = /** @type {string} */ (reader.readString()); + msg.addEnv(value); + break; + case 4: + var value = /** @type {string} */ (reader.readString()); + msg.setPreview(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.teleport.lib.teleterm.v1.GatewayCLICommand.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getPath(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } + f = message.getArgsList(); + if (f.length > 0) { + writer.writeRepeatedString( + 2, + f + ); + } + f = message.getEnvList(); + if (f.length > 0) { + writer.writeRepeatedString( + 3, + f + ); + } + f = message.getPreview(); + if (f.length > 0) { + writer.writeString( + 4, + f + ); + } +}; + + +/** + * optional string path = 1; * @return {string} */ -proto.teleport.lib.teleterm.v1.Gateway.prototype.getTargetSubresourceName = function() { - return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 9, "")); +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.getPath = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); }; /** * @param {string} value - * @return {!proto.teleport.lib.teleterm.v1.Gateway} returns this + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this */ -proto.teleport.lib.teleterm.v1.Gateway.prototype.setTargetSubresourceName = function(value) { - return jspb.Message.setProto3StringField(this, 9, value); +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.setPath = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + +/** + * repeated string args = 2; + * @return {!Array} + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.getArgsList = function() { + return /** @type {!Array} */ (jspb.Message.getRepeatedField(this, 2)); +}; + + +/** + * @param {!Array} value + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.setArgsList = function(value) { + return jspb.Message.setField(this, 2, value || []); +}; + + +/** + * @param {string} value + * @param {number=} opt_index + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.addArgs = function(value, opt_index) { + return jspb.Message.addToRepeatedField(this, 2, value, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.clearArgsList = function() { + return this.setArgsList([]); +}; + + +/** + * repeated string env = 3; + * @return {!Array} + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.getEnvList = function() { + return /** @type {!Array} */ (jspb.Message.getRepeatedField(this, 3)); +}; + + +/** + * @param {!Array} value + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.setEnvList = function(value) { + return jspb.Message.setField(this, 3, value || []); +}; + + +/** + * @param {string} value + * @param {number=} opt_index + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.addEnv = function(value, opt_index) { + return jspb.Message.addToRepeatedField(this, 3, value, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.clearEnvList = function() { + return this.setEnvList([]); +}; + + +/** + * optional string preview = 4; + * @return {string} + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.getPreview = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.lib.teleterm.v1.GatewayCLICommand} returns this + */ +proto.teleport.lib.teleterm.v1.GatewayCLICommand.prototype.setPreview = function(value) { + return jspb.Message.setProto3StringField(this, 4, value); }; diff --git a/lib/teleterm/apiserver/handler/handler_gateways.go b/lib/teleterm/apiserver/handler/handler_gateways.go index c5d2ef572f254..64f3d3f702efc 100644 --- a/lib/teleterm/apiserver/handler/handler_gateways.go +++ b/lib/teleterm/apiserver/handler/handler_gateways.go @@ -89,7 +89,7 @@ func newAPIGateway(gateway gateway.Gateway) (*api.Gateway, error) { Protocol: gateway.Protocol(), LocalAddress: gateway.LocalAddress(), LocalPort: gateway.LocalPort(), - CliCommand: command, + GatewayCliCommand: command, }, nil } diff --git a/lib/teleterm/gateway/gateway.go b/lib/teleterm/gateway/gateway.go index acb10e9f0efcb..4287b916d2af2 100644 --- a/lib/teleterm/gateway/gateway.go +++ b/lib/teleterm/gateway/gateway.go @@ -29,6 +29,7 @@ import ( "github.com/sirupsen/logrus" "github.com/gravitational/teleport/api/utils/keys" + api "github.com/gravitational/teleport/gen/proto/go/teleport/lib/teleterm/v1" alpn "github.com/gravitational/teleport/lib/srv/alpnproxy" "github.com/gravitational/teleport/lib/teleterm/api/uri" "github.com/gravitational/teleport/lib/tlsca" @@ -215,13 +216,11 @@ func (g *Gateway) LocalPortInt() int { return port } -// CLICommand returns a command which launches a CLI client pointed at the given gateway. -// It needs to return a relative command as it will be executed from a terminal in Connect, so we -// should let user's env resolve the command rather than depending on os.exec. -func (g *Gateway) CLICommand() (string, error) { +// CLICommand returns a command which launches a CLI client pointed at the gateway. +func (g *Gateway) CLICommand() (*api.GatewayCLICommand, error) { cmd, err := g.cfg.CLICommandProvider.GetCommand(g) if err != nil { - return "", trace.Wrap(err) + return nil, trace.Wrap(err) } cmdString := strings.TrimSpace( @@ -229,7 +228,12 @@ func (g *Gateway) CLICommand() (string, error) { strings.Join(cmd.Env, " "), strings.Join(cmd.Args, " "))) - return cmdString, nil + return &api.GatewayCLICommand{ + Path: cmd.Path, + Args: cmd.Args, + Env: cmd.Env, + Preview: cmdString, + }, nil } // RouteToDatabase returns tlsca.RouteToDatabase based on the config of the gateway. diff --git a/lib/teleterm/gateway/gateway_test.go b/lib/teleterm/gateway/gateway_test.go index 0597a949e560b..3419313a9e0e2 100644 --- a/lib/teleterm/gateway/gateway_test.go +++ b/lib/teleterm/gateway/gateway_test.go @@ -33,7 +33,7 @@ import ( "github.com/gravitational/teleport/lib/tlsca" ) -func TestCLICommandReturnsRelativeCommand(t *testing.T) { +func TestCLICommandPreviewReturnsRelativeCommandWithEnv(t *testing.T) { gateway := Gateway{ cfg: &Config{ TargetName: "foo", @@ -47,9 +47,11 @@ func TestCLICommandReturnsRelativeCommand(t *testing.T) { command, err := gateway.CLICommand() require.NoError(t, err) - args := strings.Split(command, " ") - path := args[0] - require.True(t, filepath.IsLocal(path), "Not a local path: %q", path) + args := strings.Split(command.Preview, " ") + env := args[0] + path := args[1] + require.Equal(t, "FOO=bar", env) + require.Equal(t, "postgres", path) } func TestGatewayStart(t *testing.T) { @@ -161,6 +163,7 @@ func (m mockCLICommandProvider) GetCommand(gateway *Gateway) (*exec.Cmd, error) // whether a command like postgres is installed on the system or not. cmd := exec.Command(gateway.Protocol(), arg) cmd.Path = absPath + cmd.Env = []string{"FOO=bar"} return cmd, nil } diff --git a/proto/teleport/lib/teleterm/v1/gateway.proto b/proto/teleport/lib/teleterm/v1/gateway.proto index 9ff4757366c13..32d1856435b34 100644 --- a/proto/teleport/lib/teleterm/v1/gateway.proto +++ b/proto/teleport/lib/teleterm/v1/gateway.proto @@ -41,14 +41,30 @@ message Gateway { string local_port = 6; // protocol is the gateway protocol string protocol = 7; - // cli_command is a command that the user can execute to connect to the resource within a CLI, - // if the given resource has a CLI client. - // - // Instead of generating those commands in in the frontend code, the tsh daemon returns them. - // This means that the Database Access team can add support for a new protocol and Teleterm will - // support it right away without any changes to Teleterm's code. - string cli_command = 8; + reserved 8; + reserved "cli_command"; // target_subresource_name points at a subresource of the remote resource, for example a // database name on a database server. string target_subresource_name = 9; + // gateway_cli_client represents a command that the user can execute to connect to the resource + // through the gateway. + // + // Instead of generating those commands in in the frontend code, they are returned from the tsh + // daemon. This means that the Database Access team can add support for a new protocol and + // Connect will support it right away with no extra changes. + GatewayCLICommand gateway_cli_command = 10; +} + +// GatewayCLICommand represents a command that the user can execute to connect to the gateway +// resource. It is a direct translation of os.exec.Cmd. +message GatewayCLICommand { + string path = 1; + repeated string args = 2; + repeated string env = 3; + // preview is used to show the user what command will be executed before they decide to run it. + // It's like os.exec.Cmd.String with two exceptions: + // + // 1) It is prepended with Cmd.Env. + // 2) The command name is relative and not absolute. + string preview = 4; } diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index 36be20374ccaa..318aec0ad0c0a 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -33,7 +33,7 @@ export class MockMainProcessClient implements MainProcessClient { } getRuntimeSettings(): RuntimeSettings { - return { ...defaultRuntimeSettings, ...this.runtimeSettings }; + return makeRuntimeSettings(this.runtimeSettings); } getResolvedChildProcessAddresses = () => @@ -70,7 +70,9 @@ export class MockMainProcessClient implements MainProcessClient { } } -const defaultRuntimeSettings = { +export const makeRuntimeSettings = ( + runtimeSettings?: Partial +): RuntimeSettings => ({ platform: 'darwin' as const, dev: true, userDataDir: '', @@ -95,4 +97,5 @@ const defaultRuntimeSettings = { arch: 'arm64', osVersion: '22.2.0', appVersion: '11.1.0', -}; + ...runtimeSettings, +}); diff --git a/web/packages/teleterm/src/services/pty/fixtures/mocks.ts b/web/packages/teleterm/src/services/pty/fixtures/mocks.ts index 83ec46a66e58a..9a636df72b665 100644 --- a/web/packages/teleterm/src/services/pty/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/pty/fixtures/mocks.ts @@ -29,16 +29,30 @@ export class MockPtyProcess implements IPtyProcess { dispose() {} - onData() {} + onData() { + return () => {}; + } - onExit() {} + onExit() { + return () => {}; + } - onOpen() {} + onOpen() { + return () => {}; + } + + onStartError() { + return () => {}; + } getPid() { return 0; } + getPtyId() { + return '1234'; + } + async getCwd() { return ''; } diff --git a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.test.ts b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.test.ts new file mode 100644 index 0000000000000..86a913c39c13a --- /dev/null +++ b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.test.ts @@ -0,0 +1,53 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { makeRuntimeSettings } from 'teleterm/mainProcess/fixtures/mocks'; + +import { GatewayCliClientCommand } from '../types'; + +import { getPtyProcessOptions } from './buildPtyOptions'; + +describe('getPtyProcessOptions', () => { + describe('pty.gateway-cli-client', () => { + it('merges process env with the env from cmd', () => { + const processEnv = { + processExclusive: 'process', + shared: 'fromProcess', + }; + const cmd: GatewayCliClientCommand = { + kind: 'pty.gateway-cli-client', + path: 'foo', + args: [], + clusterName: 'bar', + proxyHost: 'baz', + env: { + cmdExclusive: 'cmd', + shared: 'fromCmd', + }, + }; + + const { env } = getPtyProcessOptions( + makeRuntimeSettings(), + cmd, + processEnv + ); + + expect(env.processExclusive).toBe('process'); + expect(env.cmdExclusive).toBe('cmd'); + expect(env.shared).toBe('fromCmd'); + }); + }); +}); diff --git a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts index df559c99b1900..600a8fb0eff57 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/buildPtyOptions.ts @@ -18,6 +18,7 @@ import path, { delimiter } from 'path'; import { RuntimeSettings } from 'teleterm/mainProcess/types'; import { PtyProcessOptions } from 'teleterm/sharedProcess/ptyHost'; +import { assertUnreachable } from 'teleterm/ui/utils'; import { PtyCommand, @@ -67,13 +68,13 @@ export async function buildPtyOptions( }); } -function getPtyProcessOptions( +export function getPtyProcessOptions( settings: RuntimeSettings, cmd: PtyCommand, env: typeof process.env ): PtyProcessOptions { switch (cmd.kind) { - case 'pty.shell': + case 'pty.shell': { // Teleport Connect bundles a tsh binary, but the user might have one already on their system. // Since we use our own TELEPORT_HOME which might differ in format with the version that the // user has installed, let's prepend our bin directory to PATH. @@ -94,6 +95,7 @@ function getPtyProcessOptions( env, initCommand: cmd.initCommand, }; + } case 'pty.tsh-kube-login': { const isWindows = settings.platform === 'win32'; @@ -120,7 +122,7 @@ function getPtyProcessOptions( }; } - case 'pty.tsh-login': + case 'pty.tsh-login': { const loginHost = cmd.login ? `${cmd.login}@${cmd.serverId}` : cmd.serverId; @@ -135,8 +137,20 @@ function getPtyProcessOptions( ], env, }; + } + + case 'pty.gateway-cli-client': { + // TODO(ravicious): Set argv0 when node-pty adds support for it. + // https://github.com/microsoft/node-pty/issues/472 + return { + path: cmd.path, + args: cmd.args, + env: { ...env, ...cmd.env }, + }; + } + default: - throw Error(`Unknown pty command: ${cmd}`); + assertUnreachable(cmd); } } diff --git a/web/packages/teleterm/src/services/pty/ptyHost/ptyEventsStreamHandler.ts b/web/packages/teleterm/src/services/pty/ptyHost/ptyEventsStreamHandler.ts index aa6f199217c89..04b08868bb041 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/ptyEventsStreamHandler.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/ptyEventsStreamHandler.ts @@ -16,6 +16,8 @@ import { ClientDuplexStream } from '@grpc/grpc-js'; +import Logger from 'teleterm/logger'; + import { PtyClientEvent, PtyEventData, @@ -25,15 +27,22 @@ import { } from 'teleterm/sharedProcess/ptyHost'; export class PtyEventsStreamHandler { + private logger: Logger; + constructor( - private readonly stream: ClientDuplexStream - ) {} + private readonly stream: ClientDuplexStream, + ptyId: string + ) { + this.logger = new Logger(`PtyEventsStreamHandler ${ptyId}`); + } /** * Client -> Server stream events */ start(columns: number, rows: number): void { + this.logger.info('Start'); + this.writeOrThrow( new PtyClientEvent().setStart( new PtyEventStart().setColumns(columns).setRows(rows) @@ -56,6 +65,8 @@ export class PtyEventsStreamHandler { } dispose(): void { + this.logger.info('Dispose'); + this.stream.end(); this.stream.removeAllListeners(); } @@ -64,30 +75,51 @@ export class PtyEventsStreamHandler { * Stream -> Client stream events */ - onOpen(callback: () => void): void { - this.stream.addListener('data', (event: PtyServerEvent) => { - if (event.hasOpen()) { - callback(); + onOpen(callback: () => void): RemoveListenerFunction { + return this.addDataListenerAndReturnRemovalFunction( + (event: PtyServerEvent) => { + if (event.hasOpen()) { + callback(); + } } - }); + ); } - onData(callback: (data: string) => void): void { - this.stream.addListener('data', (event: PtyServerEvent) => { - if (event.hasData()) { - callback(event.getData().getMessage()); + onData(callback: (data: string) => void): RemoveListenerFunction { + return this.addDataListenerAndReturnRemovalFunction( + (event: PtyServerEvent) => { + if (event.hasData()) { + callback(event.getData().getMessage()); + } } - }); + ); } onExit( callback: (reason: { exitCode: number; signal?: number }) => void - ): void { - this.stream.addListener('data', (event: PtyServerEvent) => { - if (event.hasExit()) { - callback(event.getExit().toObject()); + ): RemoveListenerFunction { + return this.addDataListenerAndReturnRemovalFunction( + (event: PtyServerEvent) => { + if (event.hasExit()) { + this.logger.info('On exit', event.getExit().toObject()); + callback(event.getExit().toObject()); + } } - }); + ); + } + + onStartError(callback: (message: string) => void): RemoveListenerFunction { + return this.addDataListenerAndReturnRemovalFunction( + (event: PtyServerEvent) => { + if (event.hasStartError()) { + this.logger.info( + 'On start error', + event.getStartError().toObject().message + ); + callback(event.getStartError().toObject().message); + } + } + ); } private writeOrThrow(event: PtyClientEvent) { @@ -97,4 +129,16 @@ export class PtyEventsStreamHandler { } }); } + + private addDataListenerAndReturnRemovalFunction( + callback: (event: PtyServerEvent) => void + ) { + this.stream.addListener('data', callback); + + return () => { + this.stream.removeListener('data', callback); + }; + } } + +type RemoveListenerFunction = () => void; diff --git a/web/packages/teleterm/src/services/pty/ptyHost/ptyHostClient.ts b/web/packages/teleterm/src/services/pty/ptyHost/ptyHostClient.ts index 053e34a7cd725..80200a67bc5ae 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/ptyHostClient.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/ptyHostClient.ts @@ -72,7 +72,7 @@ export function createPtyHostClient( const metadata = new Metadata(); metadata.set('ptyId', ptyId); const stream = client.exchangeEvents(metadata); - return new PtyEventsStreamHandler(stream); + return new PtyEventsStreamHandler(stream, ptyId); }, }; } diff --git a/web/packages/teleterm/src/services/pty/ptyHost/ptyProcess.ts b/web/packages/teleterm/src/services/pty/ptyHost/ptyProcess.ts index 908d2ea5f9046..353b3baf05eaf 100644 --- a/web/packages/teleterm/src/services/pty/ptyHost/ptyProcess.ts +++ b/web/packages/teleterm/src/services/pty/ptyHost/ptyProcess.ts @@ -25,6 +25,10 @@ export function createPtyProcess( const exchangeEventsStream = ptyHostClient.exchangeEvents(ptyId); return { + getPtyId() { + return ptyId; + }, + /** * Client -> Server stream events */ @@ -49,18 +53,20 @@ export function createPtyProcess( * Server -> Client stream events */ - onData(callback: (data: string) => void): void { - exchangeEventsStream.onData(callback); + onData(callback: (data: string) => void) { + return exchangeEventsStream.onData(callback); + }, + + onOpen(callback: () => void) { + return exchangeEventsStream.onOpen(callback); }, - onOpen(callback: () => void): void { - exchangeEventsStream.onOpen(callback); + onExit(callback: (reason: { exitCode: number; signal?: number }) => void) { + return exchangeEventsStream.onExit(callback); }, - onExit( - callback: (reason: { exitCode: number; signal?: number }) => void - ): void { - exchangeEventsStream.onExit(callback); + onStartError(callback: (message: string) => void) { + return exchangeEventsStream.onStartError(callback); }, /** diff --git a/web/packages/teleterm/src/services/pty/types.ts b/web/packages/teleterm/src/services/pty/types.ts index 91548a793a988..ef261764b88d2 100644 --- a/web/packages/teleterm/src/services/pty/types.ts +++ b/web/packages/teleterm/src/services/pty/types.ts @@ -63,9 +63,29 @@ export type TshKubeLoginCommand = PtyCommandBase & { leafClusterId?: string; }; +export type GatewayCliClientCommand = PtyCommandBase & { + kind: 'pty.gateway-cli-client'; + // path is an absolute path to the CLI client. It is resolved on tshd side by GO's + // os.exec.LookPath. + // + // It cannot be just the command name such as `psql` because Windows fails to resolve the + // command name if it doesn't include the `.exe` suffix. + path: string; + // args is a Node.js-style list of arguments passed to the command, _without_ the command name as + // the first element. + args: string[]; + // env is a record of additional env variables that need to be set for the given CLI client. It + // will be merged into process env before the client is started. + env: Record; +}; + type PtyCommandBase = { proxyHost: string; clusterName: string; }; -export type PtyCommand = ShellCommand | TshLoginCommand | TshKubeLoginCommand; +export type PtyCommand = + | ShellCommand + | TshLoginCommand + | TshKubeLoginCommand + | GatewayCliClientCommand; diff --git a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts index b9d138a700f43..4c22e36fd3988 100644 --- a/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/tshd/fixtures/mocks.ts @@ -98,15 +98,3 @@ export class MockTshClient implements TshClient { transferFile: () => undefined; reportUsageEvent: () => undefined; } - -export const gateway: Gateway = { - uri: '/gateways/gateway1', - targetName: 'postgres', - targetUri: '/clusters/teleport-local/dbs/postgres', - targetUser: 'alice', - targetSubresourceName: '', - localAddress: 'localhost', - localPort: '59116', - protocol: 'postgres', - cliCommand: 'psql postgres://alice@localhost:59116', -}; diff --git a/web/packages/teleterm/src/services/tshd/gateway.test.ts b/web/packages/teleterm/src/services/tshd/gateway.test.ts new file mode 100644 index 0000000000000..686829de83276 --- /dev/null +++ b/web/packages/teleterm/src/services/tshd/gateway.test.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getCliCommandArgs, getCliCommandEnv } from './gateway'; +import { GatewayCLICommand } from './types'; + +describe('getCliCommandArgs', () => { + it("extracts Node.js-style args from cliCommand's argsList", () => { + const cliCommand = makeCliCommand(); + + const args = getCliCommandArgs(cliCommand); + + expect(args).toEqual([cliCommand.argsList[1]]); + }); +}); + +describe('getCliCommandEnv', () => { + it('converts Go-style env into a record', () => { + const cliCommand = makeCliCommand(); + + const env = getCliCommandEnv(cliCommand); + + expect(env.foo).toBe('bar'); + expect(env.baz).toBe('quux'); + }); +}); + +const makeCliCommand = (): GatewayCLICommand => { + return { + path: '/Users/foo/Applications/psql.app/MacOS/psql', + argsList: ['psql', 'localhost:1337'], + envList: ['foo=bar', 'baz=quux'], + preview: 'foo=bar baz=quux psql localhost:1337', + }; +}; diff --git a/web/packages/teleterm/src/services/tshd/gateway.ts b/web/packages/teleterm/src/services/tshd/gateway.ts new file mode 100644 index 0000000000000..4c54683cac86f --- /dev/null +++ b/web/packages/teleterm/src/services/tshd/gateway.ts @@ -0,0 +1,49 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GatewayCLICommand } from './types'; + +/** + * getCliCommandArgs returns a Node.js-compatible array with args. + * + * In Go, os.exec.Cmd.Args includes argv0 as the first element. Node expects the args array to + * include just the args. + */ +export function getCliCommandArgs(cliCommand: GatewayCLICommand): string[] { + const [, ...args] = cliCommand.argsList; + return args; +} + +/** + * getCliCommandArgv0 returns argv0 from the args list, as documented by os.exec.Cmd.Args. + * We are safe to use this as the presentational command name. + */ +export function getCliCommandArgv0(cliCommand: GatewayCLICommand): string { + return cliCommand.argsList[0]; +} + +/** + * getCliCommandEnv converts from os.exec.Cmd.Env format to a record of strings that Node expects. + * + * ['FOO=bar', 'BAZ=quux'] -> { FOO: 'bar', BAZ: 'quux; } + */ +export function getCliCommandEnv( + cliCommand: GatewayCLICommand +): Record { + return Object.fromEntries( + cliCommand.envList.map(nameEqualsValue => nameEqualsValue.split('=')) + ); +} diff --git a/web/packages/teleterm/src/services/tshd/testHelpers.ts b/web/packages/teleterm/src/services/tshd/testHelpers.ts index 82f76c33ee347..5d731ec91c84f 100644 --- a/web/packages/teleterm/src/services/tshd/testHelpers.ts +++ b/web/packages/teleterm/src/services/tshd/testHelpers.ts @@ -82,7 +82,12 @@ export const makeGateway = (props: Partial = {}): tsh.Gateway => ({ localAddress: 'localhost', localPort: '1337', protocol: 'postgres', - cliCommand: 'connect-me-to-db-please', + gatewayCliCommand: { + path: '/foo/psql', + argsList: ['psql', 'localhost:1337'], + envList: [], + preview: 'psql localhost:1337', + }, targetSubresourceName: 'bar', ...props, }); diff --git a/web/packages/teleterm/src/services/tshd/types.ts b/web/packages/teleterm/src/services/tshd/types.ts index 2cbce08b2bc3c..7897c71e65767 100644 --- a/web/packages/teleterm/src/services/tshd/types.ts +++ b/web/packages/teleterm/src/services/tshd/types.ts @@ -47,8 +47,26 @@ export interface Server extends apiServer.Server.AsObject { export interface Gateway extends apiGateway.Gateway.AsObject { uri: uri.GatewayUri; targetUri: uri.DatabaseUri; + // The type of gatewayCliCommand was repeated here just to refer to the type with the JSDoc. + gatewayCliCommand: GatewayCLICommand; } +/** + * GatewayCLICommand follows the API of os.exec.Cmd from Go. + * https://pkg.go.dev/os/exec#Cmd + * + * @property {string} path - The absolute path to the CLI client of a gateway if the client is + * in PATH. Otherwise, the name of the program we were trying to find. + * @property {string[]} argsList - A list containing the name of the program as the first element + * and the actual args as the other elements. + * @property {string[]} envList – A list of env vars that need to be set for the command + * invocation. The elements of the list are in the format of NAME=value. + * @property {string} preview - A string showing how the invocation of the command would look like + * if the user was to invoke it manually from the terminal. Should not be actually used to execute + * anything in the shell. + */ +export type GatewayCLICommand = apiGateway.GatewayCLICommand.AsObject; + export type AccessRequest = apiAccessRequest.AccessRequest.AsObject; export type ResourceId = apiAccessRequest.ResourceID.AsObject; export type AccessRequestReview = apiAccessRequest.AccessRequestReview.AsObject; diff --git a/web/packages/teleterm/src/sharedProcess/api/proto/ptyHostService.proto b/web/packages/teleterm/src/sharedProcess/api/proto/ptyHostService.proto index d433f7a2e7145..c52201800ed88 100644 --- a/web/packages/teleterm/src/sharedProcess/api/proto/ptyHostService.proto +++ b/web/packages/teleterm/src/sharedProcess/api/proto/ptyHostService.proto @@ -51,6 +51,7 @@ message PtyServerEvent { PtyEventData data = 2; PtyEventOpen open = 3; PtyEventExit exit = 4; + PtyEventStartError start_error = 5; } } @@ -75,6 +76,10 @@ message PtyEventExit { optional uint32 signal = 2; } +message PtyEventStartError { + string message = 1; +} + message PtyCwd { string cwd = 1; } diff --git a/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_grpc_pb.js b/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_grpc_pb.js index 8465390549c68..a81f3650c83f8 100644 --- a/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_grpc_pb.js +++ b/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_grpc_pb.js @@ -1,5 +1,23 @@ // GENERATED CODE -- DO NOT EDIT! +// Original file comments: +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// TODO(ravicious): Before introducing any changes, move this file to the /proto dir and +// remove the generate-grpc-shared script. +// 'use strict'; var grpc = require('@grpc/grpc-js'); var ptyHostService_pb = require('./ptyHostService_pb.js'); diff --git a/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.d.ts b/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.d.ts index af0faf4ee0f39..31ab1d6bfe3cd 100644 --- a/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.d.ts +++ b/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.d.ts @@ -150,6 +150,11 @@ export class PtyServerEvent extends jspb.Message { getExit(): PtyEventExit | undefined; setExit(value?: PtyEventExit): PtyServerEvent; + hasStartError(): boolean; + clearStartError(): void; + getStartError(): PtyEventStartError | undefined; + setStartError(value?: PtyEventStartError): PtyServerEvent; + getEventCase(): PtyServerEvent.EventCase; serializeBinary(): Uint8Array; @@ -168,6 +173,7 @@ export namespace PtyServerEvent { data?: PtyEventData.AsObject, open?: PtyEventOpen.AsObject, exit?: PtyEventExit.AsObject, + startError?: PtyEventStartError.AsObject, } export enum EventCase { @@ -176,6 +182,7 @@ export namespace PtyServerEvent { DATA = 2, OPEN = 3, EXIT = 4, + START_ERROR = 5, } } @@ -289,6 +296,26 @@ export namespace PtyEventExit { } } +export class PtyEventStartError extends jspb.Message { + getMessage(): string; + setMessage(value: string): PtyEventStartError; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): PtyEventStartError.AsObject; + static toObject(includeInstance: boolean, msg: PtyEventStartError): PtyEventStartError.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: PtyEventStartError, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): PtyEventStartError; + static deserializeBinaryFromReader(message: PtyEventStartError, reader: jspb.BinaryReader): PtyEventStartError; +} + +export namespace PtyEventStartError { + export type AsObject = { + message: string, + } +} + export class PtyCwd extends jspb.Message { getCwd(): string; setCwd(value: string): PtyCwd; diff --git a/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.js b/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.js index 390ba08ad8edd..a70320eb79331 100644 --- a/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.js +++ b/web/packages/teleterm/src/sharedProcess/api/protogen/ptyHostService_pb.js @@ -13,7 +13,13 @@ var jspb = require('google-protobuf'); var goog = jspb; -var global = Function('return this')(); +var global = (function() { + if (this) { return this; } + if (typeof window !== 'undefined') { return window; } + if (typeof global !== 'undefined') { return global; } + if (typeof self !== 'undefined') { return self; } + return Function('return this')(); +}.call(null)); var google_protobuf_struct_pb = require('google-protobuf/google/protobuf/struct_pb.js'); goog.object.extend(proto, google_protobuf_struct_pb); @@ -26,6 +32,7 @@ goog.exportSymbol('proto.PtyEventExit', null, global); goog.exportSymbol('proto.PtyEventOpen', null, global); goog.exportSymbol('proto.PtyEventResize', null, global); goog.exportSymbol('proto.PtyEventStart', null, global); +goog.exportSymbol('proto.PtyEventStartError', null, global); goog.exportSymbol('proto.PtyId', null, global); goog.exportSymbol('proto.PtyServerEvent', null, global); goog.exportSymbol('proto.PtyServerEvent.EventCase', null, global); @@ -218,6 +225,27 @@ if (goog.DEBUG && !COMPILED) { */ proto.PtyEventExit.displayName = 'proto.PtyEventExit'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.PtyEventStartError = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.PtyEventStartError, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.PtyEventStartError.displayName = 'proto.PtyEventStartError'; +} /** * Generated by JsPbCodeGenerator. * @param {Array=} opt_data Optional initial data array, typically from a @@ -973,7 +1001,7 @@ proto.PtyClientEvent.prototype.hasData = function() { * @private {!Array>} * @const */ -proto.PtyServerEvent.oneofGroups_ = [[1,2,3,4]]; +proto.PtyServerEvent.oneofGroups_ = [[1,2,3,4,5]]; /** * @enum {number} @@ -983,7 +1011,8 @@ proto.PtyServerEvent.EventCase = { RESIZE: 1, DATA: 2, OPEN: 3, - EXIT: 4 + EXIT: 4, + START_ERROR: 5 }; /** @@ -1027,7 +1056,8 @@ proto.PtyServerEvent.toObject = function(includeInstance, msg) { resize: (f = msg.getResize()) && proto.PtyEventResize.toObject(includeInstance, f), data: (f = msg.getData()) && proto.PtyEventData.toObject(includeInstance, f), open: (f = msg.getOpen()) && proto.PtyEventOpen.toObject(includeInstance, f), - exit: (f = msg.getExit()) && proto.PtyEventExit.toObject(includeInstance, f) + exit: (f = msg.getExit()) && proto.PtyEventExit.toObject(includeInstance, f), + startError: (f = msg.getStartError()) && proto.PtyEventStartError.toObject(includeInstance, f) }; if (includeInstance) { @@ -1084,6 +1114,11 @@ proto.PtyServerEvent.deserializeBinaryFromReader = function(msg, reader) { reader.readMessage(value,proto.PtyEventExit.deserializeBinaryFromReader); msg.setExit(value); break; + case 5: + var value = new proto.PtyEventStartError; + reader.readMessage(value,proto.PtyEventStartError.deserializeBinaryFromReader); + msg.setStartError(value); + break; default: reader.skipField(); break; @@ -1145,6 +1180,14 @@ proto.PtyServerEvent.serializeBinaryToWriter = function(message, writer) { proto.PtyEventExit.serializeBinaryToWriter ); } + f = message.getStartError(); + if (f != null) { + writer.writeMessage( + 5, + f, + proto.PtyEventStartError.serializeBinaryToWriter + ); + } }; @@ -1296,6 +1339,43 @@ proto.PtyServerEvent.prototype.hasExit = function() { }; +/** + * optional PtyEventStartError start_error = 5; + * @return {?proto.PtyEventStartError} + */ +proto.PtyServerEvent.prototype.getStartError = function() { + return /** @type{?proto.PtyEventStartError} */ ( + jspb.Message.getWrapperField(this, proto.PtyEventStartError, 5)); +}; + + +/** + * @param {?proto.PtyEventStartError|undefined} value + * @return {!proto.PtyServerEvent} returns this +*/ +proto.PtyServerEvent.prototype.setStartError = function(value) { + return jspb.Message.setOneofWrapperField(this, 5, proto.PtyServerEvent.oneofGroups_[0], value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.PtyServerEvent} returns this + */ +proto.PtyServerEvent.prototype.clearStartError = function() { + return this.setStartError(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.PtyServerEvent.prototype.hasStartError = function() { + return jspb.Message.getField(this, 5) != null; +}; + + @@ -2028,6 +2108,136 @@ proto.PtyEventExit.prototype.hasSignal = function() { +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.PtyEventStartError.prototype.toObject = function(opt_includeInstance) { + return proto.PtyEventStartError.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.PtyEventStartError} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.PtyEventStartError.toObject = function(includeInstance, msg) { + var f, obj = { + message: jspb.Message.getFieldWithDefault(msg, 1, "") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.PtyEventStartError} + */ +proto.PtyEventStartError.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.PtyEventStartError; + return proto.PtyEventStartError.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.PtyEventStartError} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.PtyEventStartError} + */ +proto.PtyEventStartError.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = /** @type {string} */ (reader.readString()); + msg.setMessage(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.PtyEventStartError.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.PtyEventStartError.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.PtyEventStartError} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.PtyEventStartError.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getMessage(); + if (f.length > 0) { + writer.writeString( + 1, + f + ); + } +}; + + +/** + * optional string message = 1; + * @return {string} + */ +proto.PtyEventStartError.prototype.getMessage = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 1, "")); +}; + + +/** + * @param {string} value + * @return {!proto.PtyEventStartError} returns this + */ +proto.PtyEventStartError.prototype.setMessage = function(value) { + return jspb.Message.setProto3StringField(this, 1, value); +}; + + + + + if (jspb.Message.GENERATE_TO_OBJECT) { /** * Creates an object representation of this proto. diff --git a/web/packages/teleterm/src/sharedProcess/ptyHost/ptyEventsStreamHandler.ts b/web/packages/teleterm/src/sharedProcess/ptyHost/ptyEventsStreamHandler.ts index 97a6c655fe570..3b500aef797ee 100644 --- a/web/packages/teleterm/src/sharedProcess/ptyHost/ptyEventsStreamHandler.ts +++ b/web/packages/teleterm/src/sharedProcess/ptyHost/ptyEventsStreamHandler.ts @@ -25,6 +25,7 @@ import { PtyEventOpen, PtyEventResize, PtyEventStart, + PtyEventStartError, PtyServerEvent, } from '../api/protogen/ptyHostService_pb'; @@ -75,6 +76,13 @@ export class PtyEventsStreamHandler { ) ) ); + this.ptyProcess.onStartError(message => { + this.stream.write( + new PtyServerEvent().setStartError( + new PtyEventStartError().setMessage(message) + ) + ); + }); this.ptyProcess.start(event.getColumns(), event.getRows()); this.logger.info(`stream has started`); } diff --git a/web/packages/teleterm/src/sharedProcess/ptyHost/ptyHostService.ts b/web/packages/teleterm/src/sharedProcess/ptyHost/ptyHostService.ts index cc06caa9ce66e..451afd8f4c6e8 100644 --- a/web/packages/teleterm/src/sharedProcess/ptyHost/ptyHostService.ts +++ b/web/packages/teleterm/src/sharedProcess/ptyHost/ptyHostService.ts @@ -33,9 +33,11 @@ export function createPtyHostService(): IPtyHostServer { const ptyId = unique(); try { const ptyProcess = new PtyProcess({ - ...ptyOptions, - ptyId, + path: ptyOptions.path, args: ptyOptions.argsList, + cwd: ptyOptions.cwd, + initCommand: ptyOptions.initCommand, + ptyId, env: call.request.getEnv()?.toJavaScript() as Record, }); ptyProcesses.set(ptyId, ptyProcess); diff --git a/web/packages/teleterm/src/sharedProcess/ptyHost/ptyProcess.ts b/web/packages/teleterm/src/sharedProcess/ptyHost/ptyProcess.ts index 2fbd427a5e7d1..a6356cde03685 100644 --- a/web/packages/teleterm/src/sharedProcess/ptyHost/ptyProcess.ts +++ b/web/packages/teleterm/src/sharedProcess/ptyHost/ptyProcess.ts @@ -42,25 +42,39 @@ export class PtyProcess extends EventEmitter implements IPtyProcess { constructor(private options: PtyProcessOptions & { ptyId: string }) { super(); this._logger = new Logger( - `PtyProcess (id: ${options.ptyId} ${options.path} ${options.args})` + `PtyProcess (id: ${options.ptyId} ${options.path} ${options.args.join( + ' ' + )})` ); } + getPtyId() { + return this.options.ptyId; + } + start(cols: number, rows: number) { - this._process = nodePTY.spawn(this.options.path, this.options.args, { - cols, - rows, - name: 'xterm-color', - // HOME should be always defined. But just in case it isn't let's use the cwd from process. - // https://unix.stackexchange.com/questions/123858 - cwd: this.options.cwd || getDefaultCwd(this.options.env), - env: this.options.env, - // Turn off ConPTY due to an uncaught exception being thrown when a PTY is closed. - useConpty: false, - }); + try { + // TODO(ravicious): Set argv0 when node-pty adds support for it. + // https://github.com/microsoft/node-pty/issues/472 + this._process = nodePTY.spawn(this.options.path, this.options.args, { + cols, + rows, + name: 'xterm-color', + // HOME should be always defined. But just in case it isn't let's use the cwd from process. + // https://unix.stackexchange.com/questions/123858 + cwd: this.options.cwd || getDefaultCwd(this.options.env), + env: this.options.env, + // Turn off ConPTY due to an uncaught exception being thrown when a PTY is closed. + useConpty: false, + }); + } catch (error) { + this._logger.error(error); + this.handleStartError(error); + return; + } this._setStatus('open'); - this.emit(TermEventEnum.OPEN); + this.emit(TermEventEnum.Open); this._process.onData(data => this._handleData(data)); this._process.onExit(ev => this._handleExit(ev)); @@ -110,15 +124,35 @@ export class PtyProcess extends EventEmitter implements IPtyProcess { } onData(cb: (data: string) => void) { - this.addListener(TermEventEnum.DATA, cb); + return this.addListenerAndReturnRemovalFunction(TermEventEnum.Data, cb); } onOpen(cb: () => void) { - this.addListener(TermEventEnum.OPEN, cb); + return this.addListenerAndReturnRemovalFunction(TermEventEnum.Open, cb); } onExit(cb: (ev: { exitCode: number; signal?: number }) => void) { - this.addListener(TermEventEnum.EXIT, cb); + return this.addListenerAndReturnRemovalFunction(TermEventEnum.Exit, cb); + } + + onStartError(cb: (message: string) => void) { + return this.addListenerAndReturnRemovalFunction( + TermEventEnum.StartError, + cb + ); + } + + private addListenerAndReturnRemovalFunction( + eventName: TermEventEnum, + listener: (...args: any[]) => void + ) { + this.addListener(eventName, listener); + + // The removal function is not used from within the shared process code, it is returned only to + // comply with the IPtyProcess interface. + return () => { + this.removeListener(eventName, listener); + }; } private getPid() { @@ -126,7 +160,7 @@ export class PtyProcess extends EventEmitter implements IPtyProcess { } private _flushBuffer() { - this.emit(TermEventEnum.DATA, this._attachedBuffer); + this.emit(TermEventEnum.Data, this._attachedBuffer); this._attachedBuffer = null; clearTimeout(this._attachedBufferTimer); this._attachedBufferTimer = null; @@ -142,7 +176,7 @@ export class PtyProcess extends EventEmitter implements IPtyProcess { } private _handleExit(e: { exitCode: number; signal?: number }) { - this.emit(TermEventEnum.EXIT, e); + this.emit(TermEventEnum.Exit, e); this._logger.info(`pty has been terminated with exit code: ${e.exitCode}`); this._setStatus('terminated'); } @@ -152,26 +186,35 @@ export class PtyProcess extends EventEmitter implements IPtyProcess { if (this._buffered) { this._pushToBuffer(data); } else { - this.emit(TermEventEnum.DATA, data); + this.emit(TermEventEnum.Data, data); } } catch (err) { this._logger.error('failed to parse incoming message.', err); } } + private handleStartError(error: Error) { + const command = `${this.options.path} ${this.options.args.join(' ')}`; + this.emit( + TermEventEnum.StartError, + `Cannot execute ${command}: ${error.message}` + ); + } + private _setStatus(value: Status) { this._status = value; this._logger.info(`status -> ${value}`); } } -export const TermEventEnum = { - CLOSE: 'terminal.close', - RESET: 'terminal.reset', - DATA: 'terminal.data', - OPEN: 'terminal.open', - EXIT: 'terminal.exit', -}; +export enum TermEventEnum { + Close = 'terminal.close', + Reset = 'terminal.reset', + Data = 'terminal.data', + Open = 'terminal.open', + Exit = 'terminal.exit', + StartError = 'terminal.start_error', +} async function getWorkingDirectory(pid: number): Promise { switch (process.platform) { diff --git a/web/packages/teleterm/src/sharedProcess/ptyHost/types.ts b/web/packages/teleterm/src/sharedProcess/ptyHost/types.ts index e9c1d0e60a214..32edc963cd124 100644 --- a/web/packages/teleterm/src/sharedProcess/ptyHost/types.ts +++ b/web/packages/teleterm/src/sharedProcess/ptyHost/types.ts @@ -23,12 +23,22 @@ export type PtyProcessOptions = { }; export type IPtyProcess = { + start(cols: number, rows: number): void; write(data: string): void; resize(cols: number, rows: number): void; dispose(): void; - onData(cb: (data: string) => void): void; - onOpen(cb: () => void): void; - start(cols: number, rows: number): void; - onExit(cb: (ev: { exitCode: number; signal?: number }) => void): void; getCwd(): Promise; + getPtyId(): string; + // The listener removal functions are used only on the frontend app side from the renderer process. + // They're not used in the shared process. However, IPtyProcess is a type shared between both, so + // both sides need to return them. In the future we should consider defining two separate types + // for both cases. + onData(cb: (data: string) => void): RemoveListenerFunction; + onOpen(cb: () => void): RemoveListenerFunction; + onStartError(cb: (message: string) => void): RemoveListenerFunction; + onExit( + cb: (ev: { exitCode: number; signal?: number }) => void + ): RemoveListenerFunction; }; + +type RemoveListenerFunction = () => void; diff --git a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx index dfb186affeafc..3f8a51e89b1fa 100644 --- a/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx +++ b/web/packages/teleterm/src/ui/ClusterConnect/ClusterLogin/ClusterLogin.story.tsx @@ -20,8 +20,7 @@ import { Box } from 'design'; import { Attempt } from 'shared/hooks/useAsync'; import * as types from 'teleterm/ui/services/clusters/types'; - -import { gateway } from 'teleterm/services/tshd/fixtures/mocks'; +import { makeGateway } from 'teleterm/services/tshd/testHelpers'; import { ClusterLoginPresentation, @@ -323,3 +322,14 @@ const TestContainer: React.FC = ({ children }) => ( ); + +const gateway = makeGateway({ + uri: '/gateways/gateway1', + targetName: 'postgres', + targetUri: '/clusters/teleport-local/dbs/postgres', + targetUser: 'alice', + targetSubresourceName: '', + localAddress: 'localhost', + localPort: '59116', + protocol: 'postgres', +}); diff --git a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx index 8345990f56c3f..37f54f10c0a42 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/DocumentGateway.story.tsx @@ -23,7 +23,7 @@ import { makeSuccessAttempt, } from 'shared/hooks/useAsync'; -import { Gateway } from 'teleterm/services/tshd/types'; +import { makeGateway } from 'teleterm/services/tshd/testHelpers'; import { DocumentGateway, DocumentGatewayProps } from './DocumentGateway'; @@ -31,7 +31,7 @@ export default { title: 'Teleterm/DocumentGateway', }; -const gateway: Gateway = { +const gateway = makeGateway({ uri: '/gateways/bar', targetName: 'sales-production', targetUri: '/clusters/bar/dbs/foo', @@ -39,9 +39,9 @@ const gateway: Gateway = { localAddress: 'localhost', localPort: '1337', protocol: 'postgres', - cliCommand: 'connect-me-to-db-please', targetSubresourceName: 'bar', -}; +}); +gateway.gatewayCliCommand.preview = 'connect-me-to-db-please'; const onlineDocumentGatewayProps: DocumentGatewayProps = { gateway: gateway, @@ -63,7 +63,7 @@ export function Online() { } export function OnlineWithLongValues() { - const gateway: Gateway = { + const gateway = makeGateway({ uri: '/gateways/bar', targetName: 'sales-production', targetUri: '/clusters/bar/dbs/foo', @@ -72,11 +72,11 @@ export function OnlineWithLongValues() { localAddress: 'localhost', localPort: '13337', protocol: 'postgres', - cliCommand: - 'connect-me-to-db-please-baz-quux-quuz-foo-baz-quux-quuz-foo-baz-quux-quuz-foo', targetSubresourceName: 'foo-bar-baz-quux-quuz-foo-bar-baz-quux-quuz-foo-bar-baz-quux-quuz', - }; + }); + gateway.gatewayCliCommand.preview = + 'connect-me-to-db-please-baz-quux-quuz-foo-baz-quux-quuz-foo-baz-quux-quuz-foo'; return ( The database connection is {statusDescription} + {/* TODO(ravicious): Use doc.status instead of LinearProgress. */} {props.connectAttempt.status === 'processing' && } {props.connectAttempt.status === 'error' && ( diff --git a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx index 22d39558d477d..45ddaeb309482 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx +++ b/web/packages/teleterm/src/ui/DocumentGateway/OnlineDocumentGateway.tsx @@ -105,7 +105,7 @@ export function OnlineDocumentGateway(props: OnlineDocumentGatewayProps) { diff --git a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts index 4c8f553c32c20..467147e198ed5 100644 --- a/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts +++ b/web/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts @@ -21,8 +21,8 @@ import { useAsync } from 'shared/hooks/useAsync'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import * as types from 'teleterm/ui/services/workspacesService'; import { useWorkspaceContext } from 'teleterm/ui/Documents'; -import { routing } from 'teleterm/ui/uri'; import { retryWithRelogin } from 'teleterm/ui/utils'; +import * as tshdGateway from 'teleterm/services/tshd/gateway'; export function useDocumentGateway(doc: types.DocumentGateway) { const ctx = useAppContext(); @@ -37,7 +37,6 @@ export function useDocumentGateway(doc: types.DocumentGateway) { const defaultPort = doc.port || ''; const gateway = ctx.clustersService.findGateway(doc.gatewayUri); const connected = !!gateway; - const cluster = ctx.clustersService.findClusterByResource(doc.targetUri); const [connectAttempt, createGateway] = useAsync(async (port: string) => { const gw = await retryWithRelogin(ctx, doc.targetUri, () => @@ -92,14 +91,18 @@ export function useDocumentGateway(doc: types.DocumentGateway) { }); const runCliCommand = () => { - const { rootClusterId, leafClusterId } = routing.parseClusterUri( - cluster.uri - ).params; - workspaceDocumentsService.openNewTerminal({ - initCommand: gateway.cliCommand, - rootClusterId, - leafClusterId, + const command = tshdGateway.getCliCommandArgv0(gateway.gatewayCliCommand); + const title = `${command} · ${doc.targetUser}@${doc.targetName}`; + + const cliDoc = workspaceDocumentsService.createGatewayCliDocument({ + title, + targetUri: doc.targetUri, + targetUser: doc.targetUser, + targetName: doc.targetName, + targetProtocol: gateway.protocol, }); + workspaceDocumentsService.add(cliDoc); + workspaceDocumentsService.setLocation(cliDoc.uri); }; useEffect( diff --git a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.story.tsx b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.story.tsx new file mode 100644 index 0000000000000..c4b65d20bde51 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.story.tsx @@ -0,0 +1,69 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { DocumentGatewayCliClient } from 'teleterm/ui/services/workspacesService'; + +import { WaitingForGatewayContent } from './DocumentGatewayCliClient'; + +export default { + title: 'Teleterm/DocumentGatewayCliClient', +}; + +const doc: DocumentGatewayCliClient = { + uri: '/docs/1234', + title: 'psql', + kind: 'doc.gateway_cli_client', + rootClusterId: 'foo', + leafClusterId: 'bar', + status: '', + targetName: 'postgres', + targetProtocol: 'postgres', + targetUri: '/clusters/foo/dbs/elo', + targetUser: 'alice', +}; + +const docWithLongValues = { + ...doc, + targetName: 'sales-quarterly-fq1-2024-production', + targetUser: + 'quux-quuz-foo-bar-quux-quuz-foo-bar-quux-quuz-foo-bar-quux-quuz-foo-bar', +}; + +const noop = () => {}; + +export const Waiting = (props: { doc?: DocumentGatewayCliClient }) => ( + +); + +export const WaitingTimedOut = (props: { doc?: DocumentGatewayCliClient }) => ( + +); + +export const WaitingWithLongValues = () => ; + +export const WaitingTimedOutWithLongValues = () => ( + +); diff --git a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx new file mode 100644 index 0000000000000..5e90c7c969fbb --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/DocumentGatewayCliClient.tsx @@ -0,0 +1,158 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { Flex, Text, ButtonPrimary } from 'design'; + +import Document from 'teleterm/ui/Document'; +import * as types from 'teleterm/ui/services/workspacesService'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { DocumentTerminal } from 'teleterm/ui/DocumentTerminal'; +import { useWorkspaceContext } from 'teleterm/ui/Documents'; +import { connectToDatabase } from 'teleterm/ui/services/workspacesService'; + +/** + * DocumentGatewayCliClient creates a terminal session that targets the given gateway. + * + * It waits for the gateway to be created before starting the terminal session. This typically + * happens only during the app restart. We assume that most of the time both the DocumentGateway and + * DocumentGatewayCliClient tabs will be reopened together. In that case, DocumentGatewayCliClient + * will wait for DocumentGateway to create the gateway first before attempting to start the client. + * + * However, if the user closes just the DocumentGateway tab and then restarts the app with just the + * DocumentGatewayCliClient tab present, the gateway will never be created. In that case, the user + * will be able to click "Open the connection" to manually open a new DocumentGateway tab. + */ +export const DocumentGatewayCliClient = (props: { + visible: boolean; + doc: types.DocumentGatewayCliClient; +}) => { + const { clustersService } = useAppContext(); + clustersService.useState(); + + const { doc, visible } = props; + const [hasRenderedTerminal, setHasRenderedTerminal] = useState(false); + + const gateway = clustersService.findGatewayByConnectionParams( + doc.targetUri, + doc.targetUser + ); + + // Once we render the terminal, we want to keep it visible. Otherwise removing the gateway would + // mean that this document would immediately unmount DocumentTerminal and close the PTY. + // + // After the gateway is closed, the CLI client will not be able to interact with the gateway + // target, but the user might still want to inspect the output. + if (gateway || hasRenderedTerminal) { + if (!hasRenderedTerminal) { + setHasRenderedTerminal(true); + } + + return ; + } + + return ; +}; + +const TIMEOUT_SECONDS = 10; + +const WaitingForGateway = (props: { + doc: types.DocumentGatewayCliClient; + visible: boolean; +}) => { + const { doc, visible } = props; + const ctx = useAppContext(); + const { documentsService } = useWorkspaceContext(); + // If we depended on doc.status for hasTimedOut instead of using a separate state, then on reopen + // the doc would have status set to 'connected' on 'error' and it'd be updated from useEffect, + // meaning that there would be a brief flash of old state. + const [hasTimedOut, setHasTimedOut] = useState(false); + + useEffect(() => { + // Update the doc state to make the progress bar show up in the tab bar. + // Once DocumentTerminal is mounted, it is going to update the status to 'connected' or 'error'. + documentsService.update(doc.uri, { status: 'connecting' }); + + const timeoutId = setTimeout(() => { + setHasTimedOut(true); + documentsService.update(doc.uri, { status: 'error' }); + }, TIMEOUT_SECONDS * 1000); + + return () => { + clearTimeout(timeoutId); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const openConnection = () => { + connectToDatabase( + ctx, + { + uri: doc.targetUri, + name: doc.targetName, + protocol: doc.targetProtocol, + dbUser: doc.targetUser, + }, + { origin: 'reopened_session' } + ); + }; + + return ( + + + + ); +}; + +export const WaitingForGatewayContent = ({ + doc, + hasTimedOut, + openConnection, +}: { + doc: types.DocumentGatewayCliClient; + hasTimedOut: boolean; + openConnection: () => void; +}) => ( + + {hasTimedOut ? ( +
+ + A connection to {doc.targetName} as{' '} + {doc.targetUser} has not been opened up within{' '} + {TIMEOUT_SECONDS} seconds. + + Please try to open the connection manually. +
+ ) : ( + + Waiting for a db connection to {doc.targetName} as{' '} + {doc.targetUser} to be opened up. + + )} + + Open the connection +
+); + +const StyledText = styled(Text).attrs({ + typography: 'h5', + textAlign: 'center', +})``; diff --git a/web/packages/teleterm/src/ui/DocumentGatewayCliClient/index.ts b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/index.ts new file mode 100644 index 0000000000000..ead0090098961 --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentGatewayCliClient/index.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DocumentGatewayCliClient } from './DocumentGatewayCliClient'; diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx index 3118055258bb5..27c7de61232c5 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/DocumentTerminal.tsx @@ -15,51 +15,51 @@ limitations under the License. */ import React from 'react'; -import { Flex, Text, ButtonPrimary } from 'design'; -import { Danger } from 'design/Alert'; import { FileTransferActionBar, FileTransfer, FileTransferContextProvider, } from 'shared/components/FileTransfer'; -import { Attempt } from 'shared/hooks/useAsync'; import Document from 'teleterm/ui/Document'; import { useAppContext } from 'teleterm/ui/appContextProvider'; -import { assertUnreachable } from 'teleterm/ui/utils'; import { isDocumentTshNodeWithServerId } from 'teleterm/ui/services/workspacesService'; import { Terminal } from './Terminal'; -import { Props, useDocumentTerminal } from './useDocumentTerminal'; +import { Reconnect } from './Reconnect'; +import { useDocumentTerminal } from './useDocumentTerminal'; import { useTshFileTransferHandlers } from './useTshFileTransferHandlers'; import type * as types from 'teleterm/ui/services/workspacesService'; -export function DocumentTerminal(props: Props & { visible: boolean }) { +export function DocumentTerminal(props: { + doc: types.DocumentTerminal; + visible: boolean; +}) { const ctx = useAppContext(); const { configService } = ctx.mainProcessClient; const { visible, doc } = props; - const { attempt, reconnect } = useDocumentTerminal(doc); - const ptyProcess = attempt.data?.ptyProcess; + const { attempt, initializePtyProcess } = useDocumentTerminal(doc); const { upload, download } = useTshFileTransferHandlers(); const unsanitizedTerminalFontFamily = configService.get( 'terminal.fontFamily' ).value; const terminalFontSize = configService.get('terminal.fontSize').value; - // Creating a new terminal might fail for multiple reasons, for example: + // Initializing a new terminal might fail for multiple reasons, for example: // // * The user tried to execute `tsh ssh user@host` from the command bar and the request which // tries to resolve `host` to a server object failed due to a network or cluster error. // * The PTY service has failed to create a new PTY process. if (attempt.status === 'error') { return ( - + + + ); } @@ -117,9 +117,16 @@ export function DocumentTerminal(props: Props & { visible: boolean }) { autoFocusDisabled={true} > {$fileTransfer} - {ptyProcess && ( + {attempt.status === 'success' && ( and re-run all hooks for the new PTY process. + key={attempt.data.ptyProcess.getPtyId()} + docKind={doc.kind} + ptyProcess={attempt.data.ptyProcess} + reconnect={initializePtyProcess} visible={props.visible} unsanitizedFontFamily={unsanitizedTerminalFontFamily} fontSize={terminalFontSize} @@ -129,54 +136,3 @@ export function DocumentTerminal(props: Props & { visible: boolean }) { ); } - -function DocumentReconnect(props: { - visible: boolean; - doc: types.DocumentTerminal; - attempt: Attempt; - reconnect: () => void; -}) { - const { message, buttonText } = getReconnectCopy(props.doc); - - return ( - - - - {message} - - - {props.attempt.statusText} - - {buttonText} - - - - - ); -} - -function getReconnectCopy(doc: types.DocumentTerminal) { - switch (doc.kind) { - case 'doc.terminal_tsh_node': { - return { - message: 'This SSH connection is currently offline.', - buttonText: 'Reconnect', - }; - } - case 'doc.terminal_shell': - case 'doc.terminal_tsh_kube': { - return { - message: 'Ran into an error when starting the terminal session.', - buttonText: 'Retry', - }; - } - default: - assertUnreachable(doc); - } -} diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx new file mode 100644 index 0000000000000..eb313b5a493df --- /dev/null +++ b/web/packages/teleterm/src/ui/DocumentTerminal/Reconnect.tsx @@ -0,0 +1,84 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Flex, Text, ButtonPrimary } from 'design'; +import { Danger } from 'design/Alert'; +import { Attempt } from 'shared/hooks/useAsync'; + +import { assertUnreachable } from 'teleterm/ui/utils'; + +import type * as types from 'teleterm/ui/services/workspacesService'; + +export function Reconnect(props: { + docKind: types.DocumentTerminal['kind']; + attempt: Attempt; + reconnect: () => void; +}) { + const { message, buttonText } = getReconnectCopy(props.docKind); + + return ( + + + {message} + + + + + {props.attempt.statusText} + + + + {buttonText} + + + + ); +} + +function getReconnectCopy(docKind: types.DocumentTerminal['kind']) { + switch (docKind) { + case 'doc.terminal_tsh_node': { + return { + message: 'This SSH connection is currently offline.', + buttonText: 'Reconnect', + }; + } + case 'doc.gateway_cli_client': + case 'doc.terminal_shell': + case 'doc.terminal_tsh_kube': { + return { + message: 'Ran into an error when starting the terminal session.', + buttonText: 'Retry', + }; + } + default: + assertUnreachable(docKind); + } +} diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx index 38eb2c415834f..29353a7bf7a02 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/Terminal.tsx @@ -14,17 +14,28 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { Box, Flex } from 'design'; import { debounce } from 'shared/utils/highbar'; +import { + Attempt, + makeEmptyAttempt, + makeErrorAttempt, + makeSuccessAttempt, +} from 'shared/hooks/useAsync'; import { IPtyProcess } from 'teleterm/sharedProcess/ptyHost'; +import { DocumentTerminal } from 'teleterm/ui/services/workspacesService'; + +import { Reconnect } from '../Reconnect'; import XTermCtrl from './ctrl'; type TerminalProps = { + docKind: DocumentTerminal['kind']; ptyProcess: IPtyProcess; + reconnect: () => void; visible: boolean; /** * This value can be provided by the user and is unsanitized. This means that it cannot be directly interpolated @@ -40,13 +51,27 @@ type TerminalProps = { export function Terminal(props: TerminalProps) { const refElement = useRef(); const refCtrl = useRef(); + const [startPtyProcessAttempt, setStartPtyProcessAttempt] = useState< + Attempt + >(makeEmptyAttempt()); useEffect(() => { + const removeOnStartErrorListener = props.ptyProcess.onStartError( + message => { + setStartPtyProcessAttempt(makeErrorAttempt(message)); + } + ); + + const removeOnOpenListener = props.ptyProcess.onOpen(() => { + setStartPtyProcessAttempt(makeSuccessAttempt(undefined)); + }); + const ctrl = new XTermCtrl(props.ptyProcess, { el: refElement.current, fontSize: props.fontSize, }); + // Start the PTY process. ctrl.open(); ctrl.term.onKey(event => { @@ -62,6 +87,8 @@ export function Terminal(props: TerminalProps) { }, 100); return () => { + removeOnStartErrorListener(); + removeOnOpenListener(); handleEnterPress.cancel(); ctrl.destroy(); }; @@ -83,9 +110,20 @@ export function Terminal(props: TerminalProps) { width="100%" style={{ overflow: 'hidden' }} > + {startPtyProcessAttempt.status === 'error' && ( + + )} ); diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/ctrl.ts b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/ctrl.ts index 1564514d1a770..aa72568507bd3 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/ctrl.ts +++ b/web/packages/teleterm/src/ui/DocumentTerminal/Terminal/ctrl.ts @@ -37,6 +37,7 @@ export default class TtyTerminal { private resizeHandler: IDisposable; private debouncedResize: () => void; private logger = new Logger('lib/term/terminal'); + private removePtyProcessOnDataListener: () => void; constructor(private ptyProcess: IPtyProcess, private options: Options) { this.el = options.el; @@ -84,8 +85,14 @@ export default class TtyTerminal { this.ptyProcess.resize(size.cols, size.rows); }); - this.ptyProcess.onData(data => this.handleData(data)); + this.removePtyProcessOnDataListener = this.ptyProcess.onData(data => + this.handleData(data) + ); + // TODO(ravicious): Don't call start if the process was already started. + // This is what is causing the terminal to visually repeat the input on hot reload. + // The shared process version of PtyProcess knows whether it was started or not (the status + // field), so it's a matter of exposing this field through gRPC and reading it here. this.ptyProcess.start(this.term.cols, this.term.rows); window.addEventListener('resize', this.debouncedResize); @@ -105,7 +112,7 @@ export default class TtyTerminal { } destroy(): void { - this.ptyProcess?.dispose(); + this.removePtyProcessOnDataListener?.(); this.term?.dispose(); this.fitAddon.dispose(); this.resizeHandler?.dispose(); diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx index 65827de2c4dc8..991c54285b1a8 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx +++ b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.test.tsx @@ -33,6 +33,7 @@ import { ResourcesService, AmbiguousHostnameError, } from 'teleterm/ui/services/resources'; +import { IPtyProcess } from 'teleterm/sharedProcess/ptyHost'; import { WorkspaceContextProvider } from '../Documents'; @@ -88,15 +89,17 @@ const getDocTshNodeWithLoginHost: () => DocumentTshNodeWithLoginHost = () => { }; }; -const getPtyProcessMock = () => ({ +const getPtyProcessMock = (): IPtyProcess => ({ onOpen: jest.fn(), write: jest.fn(), resize: jest.fn(), dispose: jest.fn(), onData: jest.fn(), start: jest.fn(), + onStartError: jest.fn(), onExit: jest.fn(), getCwd: jest.fn(), + getPtyId: jest.fn(), }); test('useDocumentTerminal calls TerminalsService during init', async () => { diff --git a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts index 774c67aee21b9..614f13db5182d 100644 --- a/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts +++ b/web/packages/teleterm/src/ui/DocumentTerminal/useDocumentTerminal.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useAsync } from 'shared/hooks/useAsync'; import { runOnce } from 'shared/utils/highbar'; @@ -31,6 +31,8 @@ import { PtyCommand, PtyProcessCreationStatus } from 'teleterm/services/pty'; import { AmbiguousHostnameError } from 'teleterm/ui/services/resources'; import { retryWithRelogin } from 'teleterm/ui/utils'; import Logger from 'teleterm/logger'; +import { ClustersService } from 'teleterm/ui/services/clusters'; +import * as tshdGateway from 'teleterm/services/tshd/gateway'; import type * as types from 'teleterm/ui/services/workspacesService'; import type * as uri from 'teleterm/ui/uri'; @@ -40,9 +42,13 @@ export function useDocumentTerminal(doc: types.DocumentTerminal) { const logger = useRef(new Logger('useDocumentTerminal')); const ctx = useAppContext(); const { documentsService } = useWorkspaceContext(); - const [attempt, startTerminal] = useAsync(async () => { + const [attempt, runAttempt] = useAsync(async () => { + if ('status' in doc) { + documentsService.update(doc.uri, { status: 'connecting' }); + } + try { - return await startTerminalSession( + return await initializePtyProcess( ctx, logger.current, documentsService, @@ -59,7 +65,7 @@ export function useDocumentTerminal(doc: types.DocumentTerminal) { useEffect(() => { if (attempt.status === '') { - startTerminal(); + runAttempt(); } return () => { @@ -67,19 +73,15 @@ export function useDocumentTerminal(doc: types.DocumentTerminal) { attempt.data.ptyProcess.dispose(); } }; + // This cannot be run only mount. If the user has initialized a new PTY process by clicking the + // Reconnect button (which happens post mount), we want to dispose this process when + // DocumentTerminal gets unmounted. To do this, we need to have a fresh reference to ptyProcess. }, [attempt]); - const reconnect = useCallback(() => { - if ('status' in doc) { - documentsService.update(doc.uri, { status: 'connecting' }); - } - startTerminal(); - }, [documentsService, doc.uri, startTerminal]); - - return { attempt, reconnect }; + return { attempt, initializePtyProcess: runAttempt }; } -async function startTerminalSession( +async function initializePtyProcess( ctx: IAppContext, logger: Logger, documentsService: DocumentsService, @@ -219,7 +221,13 @@ async function setUpPtyProcess( leafClusterId: doc.leafClusterId, }); const rootCluster = ctx.clustersService.findRootClusterByResource(clusterUri); - const cmd = createCmd(doc, rootCluster.proxyHost, getClusterName()); + const cmd = createCmd( + ctx.clustersService, + doc, + rootCluster.proxyHost, + getClusterName() + ); + const ptyProcess = await createPtyProcess(ctx, cmd); if (doc.kind === 'doc.terminal_tsh_node') { @@ -256,6 +264,8 @@ async function setUpPtyProcess( documentsService.update(doc.uri, { initCommand: undefined }); }; + // We don't need to clean up the listeners added on ptyProcess in this function. The effect which + // calls setUpPtyProcess automatically disposes of the process on cleanup, removing all listeners. ptyProcess.onOpen(() => { refreshTitle(); removeInitCommand(); @@ -273,6 +283,12 @@ async function setUpPtyProcess( // mark document as connected when first data arrives ptyProcess.onData(() => markDocumentAsConnectedOnce()); + ptyProcess.onStartError(() => { + if ('status' in doc) { + documentsService.update(doc.uri, { status: 'error' }); + } + }); + ptyProcess.onExit(event => { // Not closing the tab on non-zero exit code lets us show the error to the user if, for example, // tsh ssh cannot connect to the given node. @@ -314,7 +330,15 @@ async function createPtyProcess( return process; } +// TODO(ravicious): Instead of creating cmd within useDocumentTerminal, make useDocumentTerminal +// accept it as an argument. This will allow components such as DocumentGatewayCliClient contain +// the logic related to their specific use case. +// +// useDocumentTerminal used to assume that the doc contains everything that's needed to create the +// cmd. In case of the gateway CLI client that's not true – the state of ClustersService needs to be +// inspected to get the correct command for the gateway CLI client. function createCmd( + clustersService: ClustersService, doc: types.DocumentTerminal, proxyHost: string, clusterName: string @@ -346,6 +370,39 @@ function createCmd( }; } + if (doc.kind === 'doc.gateway_cli_client') { + const gateway = clustersService.findGatewayByConnectionParams( + doc.targetUri, + doc.targetUser + ); + if (!gateway) { + // This shouldn't happen as DocumentGatewayCliClient doesn't render DocumentTerminal before + // the gateway is found. In any case, if it does happen for some reason, the user will see + // this message and will be able to retry starting the terminal. + throw new Error( + `No gateway found for ${doc.targetUser} on ${doc.targetUri}` + ); + } + + // Below we convert cliCommand fields from Go conventions to Node.js conventions. + const args = tshdGateway.getCliCommandArgs(gateway.gatewayCliCommand); + const env = tshdGateway.getCliCommandEnv(gateway.gatewayCliCommand); + // We must not use argsList[0] as the path. Windows expects the executable to end with `.exe`, + // so if we passed just `psql` here, we wouldn't be able to start the process. + // + // Instead, let's use the absolute path resolved by Go. + const path = gateway.gatewayCliCommand.path; + + return { + kind: 'pty.gateway-cli-client', + path, + args, + env, + proxyHost, + clusterName, + }; + } + return { ...doc, kind: 'pty.shell', @@ -355,8 +412,3 @@ function createCmd( initCommand: doc.initCommand, }; } - -export type Props = { - doc: types.DocumentTerminal; - visible: boolean; -}; diff --git a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx index d908c209cab77..ce1c7ba890cf1 100644 --- a/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx +++ b/web/packages/teleterm/src/ui/Documents/DocumentsRenderer.tsx @@ -21,6 +21,8 @@ import styled from 'styled-components'; // @ts-ignore import { DocumentAccessRequests } from 'e-teleterm/ui/DocumentAccessRequests/DocumentAccessRequests'; +import { DocumentGatewayCliClient } from 'teleterm/ui/DocumentGatewayCliClient'; + import { useAppContext } from 'teleterm/ui/appContextProvider'; import * as types from 'teleterm/ui/services/workspacesService'; import { @@ -96,6 +98,8 @@ function MemoizedDocument(props: { doc: types.Document; visible: boolean }) { return ; case 'doc.gateway': return ; + case 'doc.gateway_cli_client': + return ; case 'doc.terminal_shell': case 'doc.terminal_tsh_node': case 'doc.terminal_tsh_kube': diff --git a/web/packages/teleterm/src/ui/Tabs/Tabs.tsx b/web/packages/teleterm/src/ui/Tabs/Tabs.tsx index 6568d97494a8f..5da1631a423c4 100644 --- a/web/packages/teleterm/src/ui/Tabs/Tabs.tsx +++ b/web/packages/teleterm/src/ui/Tabs/Tabs.tsx @@ -87,13 +87,7 @@ export function Tabs(props: Props) { } function getIsLoading(doc: Document): boolean { - switch (doc.kind) { - case 'doc.terminal_tsh_kube': - case 'doc.terminal_tsh_node': - return doc.status === 'connecting'; - default: - return false; - } + return 'status' in doc && doc.status === 'connecting'; } type Props = { diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts index b4443f07f0052..a5688824a1268 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.test.ts @@ -17,6 +17,7 @@ import { NotificationsService } from 'teleterm/ui/services/notifications'; import { UsageService } from 'teleterm/ui/services/usage'; import { MainProcessClient } from 'teleterm/mainProcess/types'; +import { makeGateway } from 'teleterm/services/tshd/testHelpers'; import { ClustersService } from './clustersService'; @@ -66,17 +67,10 @@ const leafClusterMock: tsh.Cluster = { }, }; -const gatewayMock: tsh.Gateway = { +const gatewayMock = makeGateway({ uri: '/gateways/gatewayTestUri', - localAddress: 'localhost', - localPort: '2000', - protocol: 'https', - targetName: 'Name', - targetSubresourceName: '', - targetUser: 'sam', targetUri: `${clusterUri}/dbs/databaseTestUri`, - cliCommand: 'psql postgres://postgres@localhost:5432/postgres', -}; +}); const NotificationsServiceMock = NotificationsService as jest.MockedClass< typeof NotificationsService diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts index 2806a02cfa89a..8fe0fc6735122 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts @@ -26,6 +26,7 @@ import * as uri from 'teleterm/ui/uri'; import { NotificationsService } from 'teleterm/ui/services/notifications'; import { Cluster, + Gateway, CreateAccessRequestParams, GetRequestableRolesParams, ReviewAccessRequestParams, @@ -409,6 +410,25 @@ export class ClustersService extends ImmutableStore return this.state.gateways.get(gatewayUri); } + findGatewayByConnectionParams( + targetUri: uri.DatabaseUri, + targetUser: string + ) { + let found: Gateway; + + for (const [, gateway] of this.state.gateways) { + if ( + gateway.targetUri === targetUri && + gateway.targetUser === targetUser + ) { + found = gateway; + break; + } + } + + return found; + } + /** * Returns a root cluster or a leaf cluster to which the given resource belongs to. */ diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index 7c96be9216212..f3bd43b86e8af 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -27,6 +27,7 @@ import { DocumentAccessRequests, DocumentCluster, DocumentGateway, + DocumentGatewayCliClient, DocumentOrigin, DocumentTshKube, DocumentTshNode, @@ -153,11 +154,40 @@ export class DocumentsService { }; } + createGatewayCliDocument({ + title, + targetUri, + targetUser, + targetName, + targetProtocol, + }: Pick< + DocumentGatewayCliClient, + 'title' | 'targetUri' | 'targetUser' | 'targetName' | 'targetProtocol' + >): DocumentGatewayCliClient { + const clusterUri = routing.ensureClusterUri(targetUri); + const { rootClusterId, leafClusterId } = + routing.parseClusterUri(clusterUri).params; + + return { + kind: 'doc.gateway_cli_client', + uri: routing.getDocUri({ docId: unique() }), + title, + status: 'connecting', + rootClusterId, + leafClusterId, + targetUri, + targetUser, + targetName, + targetProtocol, + }; + } + openNewTerminal(opts: CreateNewTerminalOpts) { const doc = ((): Document => { const activeDocument = this.getActive(); if (activeDocument && activeDocument.kind == 'doc.terminal_shell') { + // Copy activeDocument to use the same cwd in the new doc. return { ...activeDocument, uri: routing.getDocUri({ docId: unique() }), diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts index cb81f5ae31df9..826f7cd528a66 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsUtils.ts @@ -19,6 +19,14 @@ import { assertUnreachable } from 'teleterm/ui/utils'; import { Document, isDocumentTshNodeWithServerId } from './types'; +/** + * getResourceUri returns the URI of the cluster resource that is the subject of the document. + * + * For example, for DocumentGateway it's targetUri rather than gatewayUri because the gateway + * doesn't belong to the cluster. + * + * At the moment it's used only to get the breadcrumbs for the status bar. + */ export function getResourceUri( document: Document ): ClusterOrResourceUri | undefined { @@ -26,6 +34,7 @@ export function getResourceUri( case 'doc.cluster': return document.clusterUri; case 'doc.gateway': + case 'doc.gateway_cli_client': return document.targetUri; case 'doc.terminal_tsh_node': return isDocumentTshNodeWithServerId(document) diff --git a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index f11062b034e20..ad079394cca20 100644 --- a/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/web/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -16,14 +16,9 @@ limitations under the License. import * as uri from 'teleterm/ui/uri'; -export type Kind = - | 'doc.access_requests' - | 'doc.cluster' - | 'doc.blank' - | 'doc.gateway' - | 'doc.terminal_shell' - | 'doc.terminal_tsh_node' - | 'doc.terminal_tsh_kube'; +import type * as tsh from 'teleterm/services/tshd/types'; + +export type Kind = Document['kind']; export type DocumentOrigin = | 'resource_table' @@ -110,6 +105,34 @@ export interface DocumentGateway extends DocumentBase { origin: DocumentOrigin; } +/** + * DocumentGatewayCliClient is the tab that opens a CLI tool which targets the given gateway. + * + * The gateway is found by matching targetUri and targetUser rather than gatewayUri. gatewayUri + * changes between app restarts while targetUri and targetUser won't. + */ +export interface DocumentGatewayCliClient extends DocumentBase { + kind: 'doc.gateway_cli_client'; + // rootClusterId and leafClusterId are tech debt. They could be read from targetUri, but + // useDocumentTerminal expects these fields to be set on the doc. + rootClusterId: string; + leafClusterId: string | undefined; + // The four target properties are needed in order to call connectToDatabase from within + // DocumentGatewayCliClient. targetName is needed to set a proper tab title. + // + // targetUri and targetUser are also needed to find a gateway providing the connection to the + // target. + targetUri: tsh.Gateway['targetUri']; + targetUser: tsh.Gateway['targetUser']; + targetName: tsh.Gateway['targetName']; + targetProtocol: tsh.Gateway['protocol']; + // status is used merely to show a progress bar when the doc waits for the gateway to be created. + // It will be changed to 'connected' as soon as the CLI client prints something out. Some clients + // type something out immediately after starting while others only after they actually connect to + // a resource. + status: '' | 'connecting' | 'connected' | 'error'; +} + export interface DocumentCluster extends DocumentBase { kind: 'doc.cluster'; clusterUri: uri.ClusterUri; @@ -132,6 +155,7 @@ export interface DocumentPtySession extends DocumentBase { export type DocumentTerminal = | DocumentPtySession + | DocumentGatewayCliClient | DocumentTshNode | DocumentTshKube;