From 4d3cb76c287c7c2c5c6d58651e7d55e785f67f81 Mon Sep 17 00:00:00 2001 From: Ryan Derr <54389874+RyanDerr@users.noreply.github.com> Date: Wed, 30 Jul 2025 16:21:11 -0500 Subject: [PATCH] Cassandra Connect Support (#5952) * Init cassandra * Init cassandra * Initial Cassandra Command Support For cqlsh * Add Make Gen * adding cassandra test * Cleanup cassandra docker setup * Adding container wait * Container restart * Minor Fix * Cleanup docker and test * Adding final tests * Removing comment * Fixing cleanup (uncomment) * Removing bad yaml * Cleanup * Cleanup * Adressing PR review comments * Adding test support * fix spacing * Adding changelog * docs * docs * adding cassandra page * nav * Versbose commenting * cleanup naming of file * updating helper func to return errors * doc change * Removed an incorrect comment and now passing the config by reference. * Comment cleanup * Fix option warning in cassandra connect command (#5951) * Add healthcheck to caller function as it's resposible for container health * Resolve Comments * Update Log Typo --------- Co-authored-by: Bharath Gajjala <120367134+bgajjala8@users.noreply.github.com> --- CHANGELOG.md | 4 + enos/modules/test_e2e_docker/test.sh | 21 ++- internal/cmd/commands.go | 6 + internal/cmd/commands/connect/cassandra.go | 117 +++++++++++++++ internal/cmd/commands/connect/connect.go | 20 +++ testing/internal/e2e/infra/docker.go | 135 ++++++++++++++++++ .../base/target_tcp_connect_cassandra_test.go | 127 ++++++++++++++++ .../docs/commands/connect/cassandra.mdx | 69 +++++++++ .../content/docs/commands/connect/index.mdx | 2 + website/data/docs-nav-data.json | 4 + 10 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/commands/connect/cassandra.go create mode 100644 testing/internal/e2e/tests/base/target_tcp_connect_cassandra_test.go create mode 100644 website/content/docs/commands/connect/cassandra.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d53579d9a..911a590532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Canonical reference for changes, improvements, and bugfixes for Boundary. connection parameters and credentials. * Adds support to parse User-Agent headers and emit them in telemetry events ([PR](https://github.com/hashicorp/boundary/pull/5645)). +* cli: Added `boundary connect cassandra` command for connecting to Cassandra targets. + This new helper command allows users to authorize sessions against Cassandra + targets and automatically invoke a Cassandra client with the appropriate + connection parameters and credentials. Currently only username/password credentials are automatically attached. ### Deprecations/Changes diff --git a/enos/modules/test_e2e_docker/test.sh b/enos/modules/test_e2e_docker/test.sh index 9935379db4..9c44c2d4a9 100755 --- a/enos/modules/test_e2e_docker/test.sh +++ b/enos/modules/test_e2e_docker/test.sh @@ -15,7 +15,26 @@ apt update # lsb-release is used for adding the hashicorp apt source # postgresql-client is used for postgres tests # default-mysql-client is used for mysql tests -apt install unzip pass lsb-release postgresql-client default-mysql-client -y +# wget is used for downloading external dependencies and repository keys +# apt-transport-https enables HTTPS transport for APT repositories +apt install unzip pass lsb-release postgresql-client default-mysql-client wget apt-transport-https -y + +# Function to install Cassandra +install_cassandra() { + # Add Cassandra repository key + wget -q -O - https://www.apache.org/dist/cassandra/KEYS | apt-key add - + + # Add Cassandra repository + echo "deb https://debian.cassandra.apache.org 41x main" | tee -a /etc/apt/sources.list.d/cassandra.sources.list + + # Update package list and install Cassandra + apt update + apt install cassandra -y +} + +# Install Cassandra +install_cassandra + # Create a GPG key export KEY_PW=boundary diff --git a/internal/cmd/commands.go b/internal/cmd/commands.go index 782d340870..ffebeab86b 100644 --- a/internal/cmd/commands.go +++ b/internal/cmd/commands.go @@ -417,6 +417,12 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) { Func: "mysql", } }), + "connect cassandra": wrapper.Wrap(func() wrapper.WrappableCommand { + return &connect.Command{ + Command: base.NewCommand(ui, opts...), + Func: "cassandra", + } + }), "connect rdp": wrapper.Wrap(func() wrapper.WrappableCommand { return &connect.Command{ Command: base.NewCommand(ui, opts...), diff --git a/internal/cmd/commands/connect/cassandra.go b/internal/cmd/commands/connect/cassandra.go new file mode 100644 index 0000000000..ec90ef1ad4 --- /dev/null +++ b/internal/cmd/commands/connect/cassandra.go @@ -0,0 +1,117 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package connect + +import ( + "fmt" + "os" + "strings" + + "github.com/hashicorp/boundary/api/proxy" + "github.com/hashicorp/boundary/internal/cmd/base" + "github.com/posener/complete" +) + +const ( + cassandraSynopsis = "Authorize a session against a target and invoke a Cassandra client to connect" +) + +func cassandraOptions(c *Command, set *base.FlagSets) { + f := set.NewFlagSet("Cassandra Options") + + f.StringVar(&base.StringVar{ + Name: "style", + Target: &c.flagCassandraStyle, + EnvVar: "BOUNDARY_CONNECT_CASSANDRA_STYLE", + Completion: complete.PredictSet("cqlsh"), + Default: "cqlsh", + Usage: `Specifies how the CLI will attempt to invoke a Cassandra client. This will also set a suitable default for -exec if a value was not specified. Currently-understood values are "cqlsh".`, + }) + + f.StringVar(&base.StringVar{ + Name: "username", + Target: &c.flagUsername, + EnvVar: "BOUNDARY_CONNECT_USERNAME", + Completion: complete.PredictNothing, + Usage: `Specifies the username to pass through to the client. May be overridden by credentials sourced from a credential store.`, + }) + + f.StringVar(&base.StringVar{ + Name: "keyspace", + Target: &c.flagDbname, + EnvVar: "BOUNDARY_CONNECT_KEYSPACE", + Completion: complete.PredictNothing, + Usage: `Specifies the keyspace name to pass through to the client.`, + }) +} + +type cassandraFlags struct { + flagCassandraStyle string +} + +func (m *cassandraFlags) defaultExec() string { + return strings.ToLower(m.flagCassandraStyle) +} + +func (m *cassandraFlags) buildArgs(c *Command, port, ip, _ string, creds proxy.Credentials) (args, envs []string, retCreds proxy.Credentials, retErr error) { + var username, password string + + retCreds = creds + if len(retCreds.UsernamePassword) > 0 { + // Mark credential as consumed, such that it is not printed to the user + retCreds.UsernamePassword[0].Consumed = true + + // Grab the first available username/password credential brokered + username = retCreds.UsernamePassword[0].Username + password = retCreds.UsernamePassword[0].Password + } + + switch m.flagCassandraStyle { + case "cqlsh": + switch { + case username != "": + args = append(args, "-u", username) + case c.flagUsername != "": + args = append(args, "-u", c.flagUsername) + } + + if c.flagDbname != "" { + args = append(args, "-k", c.flagDbname) + } else { + c.UI.Warn("Credentials are being brokered but no -keyspace parameter provided. cqlsh may misinterpret another parameter as the keyspace name.") + } + + if password != "" { + passfile, err := os.CreateTemp("", "*") + if err != nil { + return nil, nil, proxy.Credentials{}, fmt.Errorf("Error saving cassandra password to tmp file: %w", err) + } + + c.cleanupFuncs = append(c.cleanupFuncs, func() error { + if err := os.Remove(passfile.Name()); err != nil { + return fmt.Errorf("Error removing temporary password file; consider removing %s manually: %w", passfile.Name(), err) + } + return nil + }) + + _, err = passfile.WriteString("[PlainTextAuthProvider]\npassword = " + password) + if err != nil { + passfile.Close() + return nil, nil, proxy.Credentials{}, fmt.Errorf("Error writing password file to %s: %w", passfile.Name(), err) + } + + if err := passfile.Close(); err != nil { + return nil, nil, proxy.Credentials{}, fmt.Errorf("Error closing password file after writing to %s: %w", passfile.Name(), err) + } + + args = append(args, "--credentials", passfile.Name()) + } + + args = append(args, ip) + if port != "" { + args = append(args, port) + } + } + return +} diff --git a/internal/cmd/commands/connect/connect.go b/internal/cmd/commands/connect/connect.go index a6df08865e..b48c345f83 100644 --- a/internal/cmd/commands/connect/connect.go +++ b/internal/cmd/commands/connect/connect.go @@ -80,6 +80,9 @@ type Command struct { // MySQL mysqlFlags + // Cassandra + cassandraFlags + // RDP rdpFlags @@ -108,6 +111,8 @@ func (c *Command) Synopsis() string { return postgresSynopsis case "mysql": return mysqlSynopsis + case "cassandra": + return cassandraSynopsis case "rdp": return rdpSynopsis case "ssh": @@ -230,6 +235,9 @@ func (c *Command) Flags() *base.FlagSets { case "mysql": mysqlOptions(c, set) + case "cassandra": + cassandraOptions(c, set) + case "rdp": rdpOptions(c, set) @@ -319,6 +327,8 @@ func (c *Command) Run(args []string) (retCode int) { c.flagExec = c.postgresFlags.defaultExec() case "mysql": c.flagExec = c.mysqlFlags.defaultExec() + case "cassandra": + c.flagExec = c.cassandraFlags.defaultExec() case "rdp": c.flagExec = c.rdpFlags.defaultExec() case "kube": @@ -673,6 +683,16 @@ func (c *Command) handleExec(clientProxy *apiproxy.ClientProxy, passthroughArgs envs = append(envs, mysqlEnvs...) creds = mysqlCreds + case "cassandra": + cassandraArgs, cassandraEnvs, cassandraCreds, cassandraErr := c.cassandraFlags.buildArgs(c, port, host, addr, creds) + if cassandraErr != nil { + argsErr = cassandraErr + break + } + args = append(args, cassandraArgs...) + envs = append(envs, cassandraEnvs...) + creds = cassandraCreds + case "rdp": args = append(args, c.rdpFlags.buildArgs(c, port, host, addr)...) diff --git a/testing/internal/e2e/infra/docker.go b/testing/internal/e2e/infra/docker.go index d8c2dec84b..f84efb8f19 100644 --- a/testing/internal/e2e/infra/docker.go +++ b/testing/internal/e2e/infra/docker.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path" "path/filepath" "testing" @@ -26,6 +27,14 @@ type Container struct { UriNetwork string } +// cassandraConfig stores configuration details for the Cassandra container +type cassandraConfig struct { + User string + Password string + Keyspace string + NetworkAlias string +} + // StartBoundaryDatabase spins up a postgres database in a docker container. // Returns information about the container func StartBoundaryDatabase(t testing.TB, pool *dockertest.Pool, network *dockertest.Network, repository, tag string) *Container { @@ -344,3 +353,129 @@ func StartMysql(t testing.TB, pool *dockertest.Pool, network *dockertest.Network UriNetwork: fmt.Sprintf("mysql://%s:%s@%s:3306/%s", mysqlUser, mysqlPassword, networkAlias, mysqlDb), } } + +// StartCassandra starts a Cassandra database in a docker container. +// Returns information about the container +func StartCassandra(t testing.TB, pool *dockertest.Pool, network *dockertest.Network, repository, tag string) *Container { + t.Log("Starting Cassandra database...") + c, err := LoadConfig() + require.NoError(t, err) + + err = pool.Client.PullImage(docker.PullImageOptions{ + Repository: fmt.Sprintf("%s/%s", c.DockerMirror, repository), + Tag: tag, + }, docker.AuthConfiguration{}) + require.NoError(t, err) + + config := cassandraConfig{ + User: "e2eboundary", + Password: "e2eboundary", + Keyspace: "e2eboundarykeyspace", + NetworkAlias: "e2ecassandra", + } + + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: fmt.Sprintf("%s/%s", c.DockerMirror, repository), + Tag: tag, + Env: []string{ + "CASSANDRA_CLUSTER_NAME=e2e-boundary-cluster", + }, + + ExposedPorts: []string{"9042/tcp"}, + Name: config.NetworkAlias, + Networks: []*dockertest.Network{network}, + }) + require.NoError(t, err) + + // Cassandra container takes a while to start due to the gossip protocol needing to settle and establish connections. + // This relies on the pool's extended maxWait time to ensure the container is healthy. + err = pool.Retry(func() error { + cmd := exec.Command("docker", "exec", config.NetworkAlias, "cqlsh", "-e", "SELECT now() FROM system.local;") + output, cmdErr := cmd.CombinedOutput() + if cmdErr != nil { + return fmt.Errorf("failed to connect to Cassandra container '%s': %v\nOutput: %s", config.NetworkAlias, cmdErr, string(output)) + } + return nil + }) + require.NoError(t, err, "Cassandra container did not start in time or is not healthy") + + err = setupCassandraAuthAndUser(t, resource, pool, &config) + require.NoError(t, err) + + return &Container{ + Resource: resource, + UriLocalhost: fmt.Sprintf( + "cassandra://%s:%s@%s/%s", + config.User, + config.Password, + resource.GetHostPort("9042/tcp"), + config.Keyspace, + ), + UriNetwork: fmt.Sprintf( + "cassandra://%s:%s@%s:9042/%s", + config.User, + config.Password, + config.NetworkAlias, + config.Keyspace, + ), + } +} + +// setupCassandraAuthAndUser enables authentication on a Cassandra container and creates a user with permissions. +func setupCassandraAuthAndUser(t testing.TB, resource *dockertest.Resource, pool *dockertest.Pool, config *cassandraConfig) error { + t.Helper() + t.Log("Configuring Cassandra authentication and user permissions...") + + t.Logf("Initializing Cassandra keyspace: %s...", config.Keyspace) + createKeyspaceCmd := fmt.Sprintf( + "CREATE KEYSPACE IF NOT EXISTS %s WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1};", + config.Keyspace, + ) + if err := exec.Command("docker", "exec", config.NetworkAlias, "cqlsh", "-e", createKeyspaceCmd).Run(); err != nil { + return err + } + + // Commands to enable authentication and authorization by editing cassandra.yaml + sedCmd := []string{ + "sed", "-i", + "-e", "s/^authenticator:.*/authenticator: PasswordAuthenticator/", + "-e", "s/^authorizer:.*/authorizer: CassandraAuthorizer/", + "-e", "s/^role_manager:.*/role_manager: CassandraRoleManager/", + "/etc/cassandra/cassandra.yaml", + } + if _, err := resource.Exec(sedCmd, dockertest.ExecOptions{}); err != nil { + return err + } + + if err := pool.Client.RestartContainer(resource.Container.ID, uint(pool.MaxWait.Seconds())); err != nil { + return err + } + t.Log("Waiting for Cassandra container to restart and apply authentication settings...") + + // Wait for Cassandra to be up with authentication enabled + if err := pool.Retry(func() error { + return exec.Command( + "docker", "exec", config.NetworkAlias, + "cqlsh", "-u", "cassandra", "-p", "cassandra", + "-e", "SELECT now() FROM system.local;", + ).Run() + }); err != nil { + return err + } + + t.Log("Creating Cassandra user and granting permissions...") + cqlCmds := []string{ + fmt.Sprintf("CREATE ROLE IF NOT EXISTS %s WITH PASSWORD = '%s' AND LOGIN = true;", config.User, config.Password), + fmt.Sprintf("GRANT ALL PERMISSIONS ON KEYSPACE %s TO %s;", config.Keyspace, config.User), + fmt.Sprintf("USE %s; CREATE TABLE IF NOT EXISTS users (id UUID PRIMARY KEY, name TEXT, created_at TIMESTAMP);", config.Keyspace), + } + for _, cmd := range cqlCmds { + if err := exec.Command( + "docker", "exec", config.NetworkAlias, + "cqlsh", "-u", "cassandra", "-p", "cassandra", "-e", cmd, + ).Run(); err != nil { + return err + } + } + return nil +} diff --git a/testing/internal/e2e/tests/base/target_tcp_connect_cassandra_test.go b/testing/internal/e2e/tests/base/target_tcp_connect_cassandra_test.go new file mode 100644 index 0000000000..9fd07626a5 --- /dev/null +++ b/testing/internal/e2e/tests/base/target_tcp_connect_cassandra_test.go @@ -0,0 +1,127 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package base_test + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "os/exec" + "testing" + "time" + + "github.com/creack/pty" + "github.com/hashicorp/boundary/internal/target" + "github.com/hashicorp/boundary/testing/internal/e2e" + "github.com/hashicorp/boundary/testing/internal/e2e/boundary" + "github.com/hashicorp/boundary/testing/internal/e2e/infra" + "github.com/ory/dockertest/v3" + "github.com/stretchr/testify/require" +) + +// TestCliTcpTargetConnectCassandra uses the boundary cli to connect to a +// target using `connect cassandra` +func TestCliTcpTargetConnectCassandra(t *testing.T) { + e2e.MaybeSkipTest(t) + + pool, err := dockertest.NewPool("") + require.NoError(t, err) + + // Increase timeout to accommodate Cassandra's longer startup duration due to gossip needing to settle + pool.MaxWait = 90 * time.Second + ctx := context.Background() + + // e2e_cluster network is created by the e2e infra setup + network, err := pool.NetworksByName("e2e_cluster") + require.NoError(t, err, "Failed to get e2e_cluster network") + + c := infra.StartCassandra(t, pool, &network[0], "cassandra", "latest") + require.NotNil(t, c, "Cassandra container should not be nil") + t.Cleanup(func() { + if err := pool.Purge(c.Resource); err != nil { + t.Logf("Failed to purge Cassandra container: %v", err) + } + }) + + u, err := url.Parse(c.UriNetwork) + t.Log(u) + require.NoError(t, err, "Failed to parse Cassandra URL") + + user, hostname, port, keyspace := u.User.Username(), u.Hostname(), u.Port(), u.Path[1:] + pw, pwSet := u.User.Password() + + t.Logf("Cassandra info: user=%s, keyspace=%s, host=%s, port=%s, password-set:%t", + user, keyspace, hostname, port, pwSet) + + boundary.AuthenticateAdminCli(t, ctx) + + orgId, err := boundary.CreateOrgCli(t, ctx) + require.NoError(t, err) + t.Cleanup(func() { + ctx := context.Background() + boundary.AuthenticateAdminCli(t, ctx) + output := e2e.RunCommand(ctx, "boundary", e2e.WithArgs("scopes", "delete", "-id", orgId)) + require.NoError(t, output.Err, string(output.Stderr)) + }) + + projectId, err := boundary.CreateProjectCli(t, ctx, orgId) + require.NoError(t, err) + + targetId, err := boundary.CreateTargetCli( + t, + ctx, + projectId, + port, + target.WithAddress(hostname), + ) + require.NoError(t, err) + + storeId, err := boundary.CreateCredentialStoreStaticCli(t, ctx, projectId) + require.NoError(t, err) + + credentialId, err := boundary.CreateStaticCredentialPasswordCli( + t, + ctx, + storeId, + user, + pw, + ) + require.NoError(t, err) + + err = boundary.AddBrokeredCredentialSourceToTargetCli(t, ctx, targetId, credentialId) + require.NoError(t, err) + + cmd := exec.CommandContext(ctx, + "boundary", + "connect", "cassandra", + "-target-id", targetId, + "-keyspace", keyspace, + ) + f, err := pty.Start(cmd) + require.NoError(t, err) + t.Cleanup(func() { + err := f.Close() + require.NoError(t, err) + }) + + _, err = f.Write([]byte("DESCRIBE KEYSPACES;\n")) + require.NoError(t, err) + _, err = f.Write([]byte(fmt.Sprintf("SELECT keyspace_name FROM system_schema.keyspaces WHERE keyspace_name = '%s';\n", keyspace))) + require.NoError(t, err) + _, err = f.Write([]byte("exit\n")) + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = io.Copy(&buf, f) + + output := buf.String() + t.Logf("Cassandra session output: %s", output) + + require.Contains(t, output, "keyspace_name") + require.Contains(t, output, keyspace) + + t.Log("Successfully connected to Cassandra target") +} diff --git a/website/content/docs/commands/connect/cassandra.mdx b/website/content/docs/commands/connect/cassandra.mdx new file mode 100644 index 0000000000..ca8eeef2e1 --- /dev/null +++ b/website/content/docs/commands/connect/cassandra.mdx @@ -0,0 +1,69 @@ +--- +layout: docs +page_title: connect cassandra - Command +description: >- + The "connect cassandra" command performs a target authorization or consumes an existing authorization token, and then launches a proxied Cassandra (CQL) connection. +--- + +# connect cassandra + +Command: `boundary connect cassandra` +The `connect cassandra` command authorizes a session against a target and invokes a Cassandra client for the connection. +The command fills in the local address and port. + +> **Note:** Currently, only configurations using Cassandra's `PasswordAuthenticator` are supported. + +@include 'cmd-connect-env-vars.mdx' + + +## Examples + +The following example shows how to connect to a target with the ID `ttcp_eTcZMueUYv` using a Cassandra helper: + +```shell-session +$ boundary connect cassandra -target-id=ttcp_eTcZMueUYv \ + -keyspace=mykeyspace \ + -username=superuser +``` + +When prompted, you must enter the password for the user, "superuser": + + + +```plaintext +Password: +Connected to example-cluster at localhost:9042 +[cqlsh 6.2.0 | Cassandra 5.0.4 | CQL spec 3.4.7 | Native protocol v5] +Use HELP for help. +superuser@cqlsh:mykeyspace> +``` + + + +## Usage + + + +```shell-session +$ boundary connect cassandra [options] [args] +``` + + + +@include 'cmd-connect-command-options.mdx' + +### Cassandra options: + +- `-keyspace` `(string: "")` - The keyspace name you want to pass through to the client. +You can also specify the keyspace name using the **BOUNDARY_CONNECT_KEYSPACE** environment variable. + +- `-style` `(string: "")` - How the CLI attempts to invoke a Cassandra client. +This value also sets a suitable default for `-exec`, if you did not specify a value. +The default and currently-understood value is `cqlsh`. +You can also specify how the CLI attempts to invoke a Cassandra client using the **BOUNDARY_CONNECT_CASSANDRA_STYLE** environment variable. + +- `-username` `(string: "")` - The username you want to pass through to the client. +This value may be overridden by credentials sourced from a credential store. +You can also specify a username using the **BOUNDARY_CONNECT_USERNAME** environment variable. + +@include 'cmd-option-note.mdx' diff --git a/website/content/docs/commands/connect/index.mdx b/website/content/docs/commands/connect/index.mdx index 9751c7e0f1..42ed579f9f 100644 --- a/website/content/docs/commands/connect/index.mdx +++ b/website/content/docs/commands/connect/index.mdx @@ -41,6 +41,7 @@ Usage: boundary connect [options] [args] Subcommands: http Authorize a session against a target and invoke an HTTP client to connect kube Authorize a session against a target and invoke a Kubernetes client to connect + cassandra Authorize a session against a target and invoke a Cassandra client to connect mysql Authorize a session against a target and invoke a MySQL client to connect postgres Authorize a session against a target and invoke a Postgres client to connect rdp Authorize a session against a target and invoke an RDP client to connect @@ -54,6 +55,7 @@ of the subcommand in the sidebar or one of the links below: - [http](/boundary/docs/commands/connect/http) - [kube](/boundary/docs/commands/connect/kube) +- [cassandra](/boundary/docs/commands/connect/cassandra) - [mysql](/boundary/docs/commands/connect/mysql) - [postgres](/boundary/docs/commands/connect/postgres) - [rdp](/boundary/docs/commands/connect/rdp) diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 9944ef8746..b59e7b4950 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -1007,6 +1007,10 @@ "title": "mysql", "path": "commands/connect/mysql" }, + { + "title": "cassandra", + "path": "commands/connect/cassandra" + }, { "title": "postgres", "path": "commands/connect/postgres"