diff --git a/lib/client/db/dbcmd/dbcmd.go b/lib/client/db/dbcmd/dbcmd.go index ed52858c4d721..3528ce29b269f 100644 --- a/lib/client/db/dbcmd/dbcmd.go +++ b/lib/client/db/dbcmd/dbcmd.go @@ -33,6 +33,7 @@ import ( "go.mongodb.org/mongo-driver/x/mongo/driver/connstring" "github.com/gravitational/teleport/api/constants" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/db" "github.com/gravitational/teleport/lib/client/db/mysql" @@ -78,6 +79,8 @@ const ( awsBin = "aws" // oracleBin is the Oracle CLI program name. oracleBin = "sql" + // spannerBin is a Google Spanner interactive CLI program name. + spannerBin = "spanner-cli" ) // Execer is an abstraction of Go's exec module, as this one doesn't specify any interfaces. @@ -206,6 +209,8 @@ func (c *CLICommandBuilder) GetConnectCommand() (*exec.Cmd, error) { case defaults.ProtocolClickHouse: return c.getClickhouseNativeCommand() + case defaults.ProtocolSpanner: + return c.getSpannerCommand() } return nil, trace.BadParameter("unsupported database protocol: %v", c.db) @@ -668,15 +673,23 @@ func (c *CLICommandBuilder) getOpenSearchCLICommand() (*exec.Cmd, error) { return exec.Command(openSearchCLIBin, args...), nil } -func (c *CLICommandBuilder) getDynamoDBCommand() (*exec.Cmd, error) { - // we can't guess at what the user wants to do, so this command is for print purposes only, - // and it only works with a local proxy tunnel. - if !c.options.printFormat || !c.options.noTLS || c.options.localProxyHost == "" || c.options.localProxyPort == 0 { +func (c *CLICommandBuilder) checkLocalProxyTunnelOnly(requirePrint bool) error { + if (requirePrint && !c.options.printFormat) || !c.options.noTLS || c.options.localProxyHost == "" || c.options.localProxyPort == 0 { svc := "" if c.db != nil && c.db.ServiceName != "" { svc = c.db.ServiceName } - return nil, trace.BadParameter("DynamoDB requires a local proxy tunnel. Use `tsh proxy db --tunnel %v`", svc) + protocol := defaults.ReadableDatabaseProtocol(c.db.Protocol) + return trace.BadParameter("%s requires a local proxy tunnel. Use `tsh proxy db --tunnel %v`", protocol, svc) + } + return nil +} + +func (c *CLICommandBuilder) getDynamoDBCommand() (*exec.Cmd, error) { + // we can't guess at what the user wants to do, so this command is for print + // purposes only, and it only works with a local proxy tunnel. + if err := c.checkLocalProxyTunnelOnly(true); err != nil { + return nil, trace.Wrap(err) } args := []string{ "--endpoint", fmt.Sprintf("http://%v:%v/", c.options.localProxyHost, c.options.localProxyPort), @@ -686,6 +699,52 @@ func (c *CLICommandBuilder) getDynamoDBCommand() (*exec.Cmd, error) { return exec.Command(awsBin, args...), nil } +func (c *CLICommandBuilder) getSpannerCommand() (*exec.Cmd, error) { + if err := c.checkLocalProxyTunnelOnly(false); err != nil { + return nil, trace.Wrap(err) + } + var ( + project, + instance, + database string + ) + if c.options.printFormat { + // default placeholders for a print command if not all info is available + project, instance, database = "", "", "" + } + + if c.options.gcp.ProjectID != "" { + project = c.options.gcp.ProjectID + } + if c.options.gcp.InstanceID != "" { + instance = c.options.gcp.InstanceID + } + if c.db.Database != "" { + database = c.db.Database + } + + protocol := defaults.ReadableDatabaseProtocol(c.db.Protocol) + switch { + case project == "": + return nil, trace.BadParameter("missing GCP project ID for %s command (this is a bug)", protocol) + case instance == "": + return nil, trace.BadParameter("missing GCP instance ID for %s command (this is a bug)", protocol) + case database == "": + return nil, trace.BadParameter("missing database name for %s command (this is a bug)", protocol) + } + + args := []string{ + "-p", project, + "-i", instance, + "-d", database, + } + cmd := exec.Command(spannerBin, args...) + cmd.Env = append(cmd.Env, + fmt.Sprintf("SPANNER_EMULATOR_HOST=%s:%d", c.host, c.port), + ) + return cmd, nil +} + type jdbcOracleThinConnection struct { host string port int @@ -814,6 +873,7 @@ type connectionCommandOpts struct { log *logrus.Entry exe Execer password string + gcp types.GCPCloudSQL } // ConnectCommandFunc is a type for functions returned by the "With*" functions in this package. @@ -903,6 +963,13 @@ func WithExecer(exe Execer) ConnectCommandFunc { } } +// WithGCP adds GCP metadata for the database command to access. +func WithGCP(gcp types.GCPCloudSQL) ConnectCommandFunc { + return func(opts *connectionCommandOpts) { + opts.gcp = gcp + } +} + const ( // envVarMongoServerSelectionTimeoutMS is the environment variable that // controls the server selection timeout used for MongoDB clients. diff --git a/lib/client/db/dbcmd/dbcmd_test.go b/lib/client/db/dbcmd/dbcmd_test.go index 73e9e830ecefb..2a26458602c94 100644 --- a/lib/client/db/dbcmd/dbcmd_test.go +++ b/lib/client/db/dbcmd/dbcmd_test.go @@ -26,6 +26,7 @@ import ( "github.com/gravitational/trace" "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/fixtures" @@ -630,6 +631,102 @@ func TestCLICommandBuilderGetConnectCommand(t *testing.T) { cmd: []string{"sql", "-L", "'jdbc:oracle:thin:@tcps://localhost:12345/oracle01?TNS_ADMIN=/tmp/keys/example.com/bob-db/mysql-wallet'"}, wantErr: false, }, + { + name: "Spanner for exec is ok", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{ + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + WithGCP(types.GCPCloudSQL{ProjectID: "foo-proj", InstanceID: "bar-instance"}), + }, + execer: &fakeExec{}, + databaseName: "googlesql-db", + cmd: []string{"spanner-cli", "-p", "foo-proj", "-i", "bar-instance", "-d", "googlesql-db"}, + wantErr: false, + }, + { + name: "Spanner with print format is ok", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{ + WithPrintFormat(), + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + WithGCP(types.GCPCloudSQL{ProjectID: "foo-proj", InstanceID: "bar-instance"}), + }, + execer: &fakeExec{}, + databaseName: "googlesql-db", + cmd: []string{"spanner-cli", "-p", "foo-proj", "-i", "bar-instance", "-d", "googlesql-db"}, + wantErr: false, + }, + { + name: "Spanner with print format and placeholders is ok", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{ + WithPrintFormat(), + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + WithGCP(types.GCPCloudSQL{}), + }, + execer: &fakeExec{}, + databaseName: "", + cmd: []string{"spanner-cli", "-p", "", "-i", "", "-d", ""}, + wantErr: false, + }, + { + name: "Spanner for exec without GCP project is an error", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{ + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + WithGCP(types.GCPCloudSQL{InstanceID: "bar-instance"}), + }, + execer: &fakeExec{}, + databaseName: "googlesql-db", + wantErr: true, + }, + { + name: "Spanner for exec without GCP instance is an error", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{ + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + WithGCP(types.GCPCloudSQL{ProjectID: "foo-proj"}), + }, + execer: &fakeExec{}, + databaseName: "googlesql-db", + wantErr: true, + }, + { + name: "Spanner for exec without database name is an error", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{ + WithLocalProxy("localhost", 12345, ""), + WithNoTLS(), + WithGCP(types.GCPCloudSQL{ProjectID: "foo-proj"}), + }, + execer: &fakeExec{}, + databaseName: "googlesql-db", + wantErr: true, + }, + { + name: "Spanner without a local proxy is an error", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{ + WithLocalProxy("", 0, ""), + WithNoTLS(), + WithGCP(types.GCPCloudSQL{ProjectID: "foo-proj", InstanceID: "bar-instance"}), + }, + execer: &fakeExec{}, + databaseName: "googlesql-db", + wantErr: true, + }, + { + name: "Spanner with TLS local proxy is an error", + dbProtocol: defaults.ProtocolSpanner, + opts: []ConnectCommandFunc{WithPrintFormat(), WithLocalProxy("localhost", 12345, "")}, + execer: &fakeExec{}, + wantErr: true, + }, } for _, tt := range tests { diff --git a/lib/teleterm/cmd/db.go b/lib/teleterm/cmd/db.go index b5f4ff2f63fa3..5c12fb73a38e6 100644 --- a/lib/teleterm/cmd/db.go +++ b/lib/teleterm/cmd/db.go @@ -64,8 +64,12 @@ func newDBCLICommandWithExecer(cluster *clusters.Cluster, gateway gateway.Gatewa dbcmd.WithExecer(execer), } - // DynamoDB doesn't support non-print-format use. - if gateway.Protocol() == defaults.ProtocolDynamoDB { + switch gateway.Protocol() { + case defaults.ProtocolDynamoDB, defaults.ProtocolSpanner: + // DynamoDB doesn't support non-print-format use. + // Spanner does, but it's not supported in Teleterm yet. + // TODO(gavin): get the database GCP metadata to enable spanner-cli in + // Teleterm. opts = append(opts, dbcmd.WithPrintFormat()) } diff --git a/lib/teleterm/cmd/db_test.go b/lib/teleterm/cmd/db_test.go index e080a0cff6cc9..cee79a64760e7 100644 --- a/lib/teleterm/cmd/db_test.go +++ b/lib/teleterm/cmd/db_test.go @@ -92,6 +92,12 @@ func TestNewDBCLICommand(t *testing.T) { protocol: defaults.ProtocolDynamoDB, checkCmds: checkArgsNotEmpty, }, + { + name: "custom handling of Spanner does not blow up", + targetSubresourceName: "bar", + protocol: defaults.ProtocolSpanner, + checkCmds: checkArgsNotEmpty, + }, } for _, tc := range testCases { diff --git a/tool/tsh/common/db.go b/tool/tsh/common/db.go index fca12b7e6b685..0acf41d1bc1f8 100644 --- a/tool/tsh/common/db.go +++ b/tool/tsh/common/db.go @@ -779,6 +779,9 @@ func onDatabaseConnect(cf *CLIConf) error { if opts, err = maybeAddDBUserPassword(cf, tc, dbInfo, opts); err != nil { return trace.Wrap(err) } + if opts, err = maybeAddGCPMetadata(cf.Context, tc, dbInfo, opts); err != nil { + return trace.Wrap(err) + } bb := dbcmd.NewCmdBuilder(tc, profile, dbInfo.RouteToDatabase, rootClusterName, opts...) cmd, err := bb.GetConnectCommand() @@ -1642,7 +1645,8 @@ func getDBLocalProxyRequirement(tc *client.TeleportClient, route tlsca.RouteToDa defaults.ProtocolSQLServer, defaults.ProtocolCassandra, defaults.ProtocolOracle, - defaults.ProtocolClickHouse: + defaults.ProtocolClickHouse, + defaults.ProtocolSpanner: // Some protocols only work in the local tunnel mode. out.addLocalProxyWithTunnel(formatDBProtocolReason(route.Protocol)) @@ -1736,12 +1740,7 @@ func formatDbCmdUnsupportedDBProtocol(cf *CLIConf, route tlsca.RouteToDatabase) // getDbCmdAlternatives is a helper func that returns alternative tsh commands for connecting to a database. func getDbCmdAlternatives(clusterFlag string, route tlsca.RouteToDatabase) []string { var alts []string - switch route.Protocol { - case defaults.ProtocolDynamoDB: - // DynamoDB only works with a local proxy tunnel and there is no "shell-like" cli, so `tsh db connect` doesn't make sense. - case defaults.ProtocolClickHouseHTTP: - // ClickHouse HTTP protocol don't support interactive mode - default: + if protocolSupportsInteractiveMode(route.Protocol) { // prefer displaying the connect command as the first suggested command alternative. alts = append(alts, formatDatabaseConnectCommand(clusterFlag, route)) } diff --git a/tool/tsh/common/proxy.go b/tool/tsh/common/proxy.go index d5a2348b66d30..106c9428524e6 100644 --- a/tool/tsh/common/proxy.go +++ b/tool/tsh/common/proxy.go @@ -19,6 +19,7 @@ package common import ( + "context" "crypto/tls" "crypto/x509/pkix" "fmt" @@ -236,6 +237,9 @@ func onProxyCommandDB(cf *CLIConf) error { if opts, err = maybeAddDBUserPassword(cf, tc, dbInfo, opts); err != nil { return trace.Wrap(err) } + if opts, err = maybeAddGCPMetadata(cf.Context, tc, dbInfo, opts); err != nil { + return trace.Wrap(err) + } commands, err := dbcmd.NewCmdBuilder(tc, profile, dbInfo.RouteToDatabase, rootCluster, opts..., @@ -252,8 +256,9 @@ func onProxyCommandDB(cf *CLIConf) error { "address": listener.Addr().String(), "randomPort": randomPort, } + maybeAddGCPMetadataTplArgs(cf.Context, tc, dbInfo, templateArgs) - tmpl := chooseProxyCommandTemplate(templateArgs, commands, dbInfo.Protocol) + tmpl := chooseProxyCommandTemplate(templateArgs, commands, dbInfo) err = tmpl.Execute(os.Stdout, templateArgs) if err != nil { return trace.Wrap(err) @@ -302,18 +307,55 @@ func maybeAddDBUserPassword(cf *CLIConf, tc *libclient.TeleportClient, dbInfo *d return opts, nil } +func requiresGCPMetadata(protocol string) bool { + return protocol == defaults.ProtocolSpanner +} + +func maybeAddGCPMetadata(ctx context.Context, tc *libclient.TeleportClient, dbInfo *databaseInfo, opts []dbcmd.ConnectCommandFunc) ([]dbcmd.ConnectCommandFunc, error) { + if !requiresGCPMetadata(dbInfo.Protocol) { + return opts, nil + } + db, err := dbInfo.GetDatabase(ctx, tc) + if err != nil { + return nil, trace.Wrap(err) + } + gcp := db.GetGCP() + return append(opts, dbcmd.WithGCP(gcp)), nil +} + +func maybeAddGCPMetadataTplArgs(ctx context.Context, tc *libclient.TeleportClient, dbInfo *databaseInfo, templateArgs map[string]any) { + if !requiresGCPMetadata(dbInfo.Protocol) { + return + } + templateArgs["gcpProject"] = "" + templateArgs["gcpInstance"] = "" + db, err := dbInfo.GetDatabase(ctx, tc) + if err == nil { + gcp := db.GetGCP() + templateArgs["gcpProject"] = gcp.ProjectID + templateArgs["gcpInstance"] = gcp.InstanceID + } +} + type templateCommandItem struct { Description string Command string } -func chooseProxyCommandTemplate(templateArgs map[string]any, commands []dbcmd.CommandAlternative, protocol string) *template.Template { +func chooseProxyCommandTemplate(templateArgs map[string]any, commands []dbcmd.CommandAlternative, dbInfo *databaseInfo) *template.Template { // there is only one command, use plain template. if len(commands) == 1 { templateArgs["command"] = formatCommand(commands[0].Command) - if protocol == defaults.ProtocolOracle { + switch dbInfo.Protocol { + case defaults.ProtocolOracle: templateArgs["args"] = commands[0].Command.Args return dbProxyOracleAuthTpl + case defaults.ProtocolSpanner: + templateArgs["databaseName"] = "" + if dbInfo.Database != "" { + templateArgs["databaseName"] = dbInfo.Database + } + return dbProxySpannerAuthTpl } return dbProxyAuthTpl } @@ -711,6 +753,19 @@ Use the following command to connect to the database or to the address above usi $ {{.command}} `)) +// dbProxySpannerAuthTpl is the message that's printed for an authenticated spanner db proxy. +var dbProxySpannerAuthTpl = template.Must(template.New("").Parse( + `Started authenticated tunnel for the {{.type}} database "{{.database}}" in cluster "{{.cluster}}" on {{.address}}. +{{if .randomPort}}To avoid port randomization, you can choose the listening port using the --port flag. +{{end}} +` + dbProxyConnectAd + ` +Use the following command to connect to the database or to the address above using other database GUI/CLI clients: + $ {{.command}} + +Or use the following JDBC connection string to connect with other GUI/CLI clients: +jdbc:cloudspanner://{{.address}}/projects/{{.gcpProject}}/instances/{{.gcpInstance}}/databases/{{.databaseName}};usePlainText=true +`)) + // dbProxyOracleAuthTpl is the message that's printed for an authenticated db proxy. var dbProxyOracleAuthTpl = template.Must(template.New("").Funcs(templateFunctions).Parse( `Started authenticated tunnel for the {{.type}} database "{{.database}}" in cluster "{{.cluster}}" on {{.address}}. diff --git a/tool/tsh/common/proxy_test.go b/tool/tsh/common/proxy_test.go index e5689c682f40b..e9d40b181ef20 100644 --- a/tool/tsh/common/proxy_test.go +++ b/tool/tsh/common/proxy_test.go @@ -1251,7 +1251,7 @@ Use one of the following commands to connect to the database or to the address a for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { templateArgs := map[string]any{} - tpl := chooseProxyCommandTemplate(templateArgs, tt.commands, "") + tpl := chooseProxyCommandTemplate(templateArgs, tt.commands, &databaseInfo{}) require.Equal(t, tt.wantTemplate, tpl) require.Equal(t, tt.wantTemplateArgs, templateArgs) diff --git a/web/packages/shared/services/databases/databases.ts b/web/packages/shared/services/databases/databases.ts index c4a19efc5f9fc..02147ddd3eed3 100644 --- a/web/packages/shared/services/databases/databases.ts +++ b/web/packages/shared/services/databases/databases.ts @@ -29,7 +29,8 @@ export type DbType = | 'keyspace' | 'cassandra' | 'dynamodb' - | 'opensearch'; + | 'opensearch' + | 'spanner'; export type DbProtocol = | 'postgres' @@ -45,7 +46,8 @@ export type DbProtocol = | 'opensearch' | 'dynamodb' | 'clickhouse' - | 'clickhouse-http'; + | 'clickhouse-http' + | 'spanner'; const formatProtocol = (input: DbProtocol) => { switch (input) { @@ -67,6 +69,8 @@ const formatProtocol = (input: DbProtocol) => { return 'Cassandra'; case 'elasticsearch': return 'Elasticsearch'; + case 'spanner': + return 'Spanner'; default: return input; } @@ -117,6 +121,9 @@ export const formatDatabaseInfo = (type: DbType, protocol: DbProtocol) => { case 'azure': output.title = `Azure ${formatProtocol(protocol)}`; return output; + case 'spanner': + output.title = 'Cloud Spanner'; + return output; default: output.title = `${type} ${formatProtocol(protocol)}`; return output; diff --git a/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx b/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx index 9fa7bbd2a500c..98577fbcd2f72 100644 --- a/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx +++ b/web/packages/teleport/src/Audit/EventList/EventTypeCell.tsx @@ -181,6 +181,8 @@ const EventIconMap: Record = { [eventCodes.OPENSEARCH_REQUEST_FAILURE]: Icons.Database, [eventCodes.DYNAMODB_REQUEST]: Icons.Database, [eventCodes.DYNAMODB_REQUEST_FAILURE]: Icons.Database, + [eventCodes.SPANNER_RPC]: Icons.Database, + [eventCodes.SPANNER_RPC_DENIED]: Icons.Database, [eventCodes.DESKTOP_SESSION_STARTED]: Icons.Desktop, [eventCodes.DESKTOP_SESSION_STARTED_FAILED]: Icons.Desktop, [eventCodes.DESKTOP_SESSION_ENDED]: Icons.Desktop, diff --git a/web/packages/teleport/src/Audit/__snapshots__/Audit.story.test.tsx.snap b/web/packages/teleport/src/Audit/__snapshots__/Audit.story.test.tsx.snap index fe1e95c9d382b..1741e3dda820e 100644 --- a/web/packages/teleport/src/Audit/__snapshots__/Audit.story.test.tsx.snap +++ b/web/packages/teleport/src/Audit/__snapshots__/Audit.story.test.tsx.snap @@ -405,12 +405,12 @@ exports[`list of all events 1`] = ` - - 240 + 242 of - 240 + 242 + + + + +
+ + + + + + Spanner RPC +
+ + + User [alice@example.com] executed query [select * from TestTable] in database [dev-db] on [teleport-spanner] + + + 2024-03-13T00:02:44.739Z + + + + + ( /> ); +export const ConnectSpanner = () => ( + null} + authType="local" + /> +); + export const ConnectWithRequestId = () => { return ( { expect(screen.getByText(expectedOutput)).toBeInTheDocument(); }); +test('correct connect command generated for spanner', () => { + render(); + + // --db-name flag should be required + const expectedOutput = + 'tsh db connect gspanner --db-user= --db-name='; + + expect(screen.getByText(expectedOutput)).toBeInTheDocument(); +}); + test('correct connect command generated for mysql db', () => { render(); diff --git a/web/packages/teleport/src/Databases/ConnectDialog/ConnectDialog.tsx b/web/packages/teleport/src/Databases/ConnectDialog/ConnectDialog.tsx index 7d6427d0d8b1c..20394973f0459 100644 --- a/web/packages/teleport/src/Databases/ConnectDialog/ConnectDialog.tsx +++ b/web/packages/teleport/src/Databases/ConnectDialog/ConnectDialog.tsx @@ -53,6 +53,7 @@ export default function ConnectDialog({ case 'sqlserver': case 'oracle': case 'mongodb': + case 'spanner': // Required dbNameFlag = ' --db-name='; break; @@ -107,7 +108,7 @@ export default function ConnectDialog({ {' - Connect to the database'} ${dbNameFlag}`} + text={`tsh ${connectCommand} ${dbName} --db-user=${dbNameFlag}`} /> {accessRequestId && ( diff --git a/web/packages/teleport/src/Databases/ConnectDialog/__snapshots__/ConnectDialog.test.tsx.snap b/web/packages/teleport/src/Databases/ConnectDialog/__snapshots__/ConnectDialog.test.tsx.snap index 6bc6fca6cbcc1..5bb4b44c17efa 100644 --- a/web/packages/teleport/src/Databases/ConnectDialog/__snapshots__/ConnectDialog.test.tsx.snap +++ b/web/packages/teleport/src/Databases/ConnectDialog/__snapshots__/ConnectDialog.test.tsx.snap @@ -308,7 +308,7 @@ exports[`render dialog with instructions to connect to database 1`] = ` $
- tsh db connect aurora --db-user=<user> --db-name=<name> + tsh db connect aurora --db-user=<user> --db-name=<name>