Skip to content

Commit 29328bc

Browse files
Add subcommand to connect to database instances via SSH (#975)
* Add db subcommand for db access via SSH tunnel Add a new top-level `db` command to `ltctl` with two subcommands: - `db list`: lists all DB cluster instances with their role (writer, reader-N), endpoint, and identifier. - `db connect [target]`: opens an interactive `psql` session to an RDS instance by establishing a local SSH tunnel through a jump host (app server or metrics server). Defaults to the first reader instance. Only aurora-postgresql is supported. * Document db commands in terraform load-test guide Add a "Database access" section under Debugging in docs/terraform_loadtest.md covering `db list` and `db connect`. * Fix linting issue * Minor grammar fix Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent e55b6bd commit 29328bc

File tree

3 files changed

+303
-0
lines changed

3 files changed

+303
-0
lines changed

cmd/ltctl/db.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Copyright (c) 2019-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
package main
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"io"
10+
"net"
11+
"os"
12+
"os/exec"
13+
"os/signal"
14+
"strconv"
15+
"strings"
16+
"syscall"
17+
18+
"github.com/mattermost/mattermost-load-test-ng/deployment/terraform"
19+
"github.com/mattermost/mattermost-load-test-ng/deployment/terraform/ssh"
20+
21+
"github.com/spf13/cobra"
22+
)
23+
24+
func RunDBListCmdF(cmd *cobra.Command, args []string) error {
25+
config, err := getConfig(cmd)
26+
if err != nil {
27+
return err
28+
}
29+
30+
t, err := terraform.New("", config)
31+
if err != nil {
32+
return fmt.Errorf("failed to create terraform engine: %w", err)
33+
}
34+
35+
output, err := t.Output()
36+
if err != nil {
37+
return fmt.Errorf("could not parse output: %w", err)
38+
}
39+
40+
if !output.HasDB() {
41+
return fmt.Errorf("no database cluster found in deployment")
42+
}
43+
44+
readerIdx := 0
45+
for _, inst := range output.DBCluster.Instances {
46+
var role string
47+
if inst.IsWriter {
48+
role = "writer"
49+
} else {
50+
role = fmt.Sprintf("reader-%d", readerIdx)
51+
readerIdx++
52+
}
53+
fmt.Printf(" - %-10s %s (%s)\n", role, inst.Endpoint, inst.DBIdentifier)
54+
}
55+
56+
return nil
57+
}
58+
59+
// selectDBInstance picks a DB instance based on the target argument.
60+
// No args: first reader, or writer if only one instance exists.
61+
// "writer": the writer instance.
62+
// "reader-N": the reader at index N.
63+
func selectDBInstance(output *terraform.Output, args []string) (terraform.DBInstance, error) {
64+
instances := output.DBCluster.Instances
65+
66+
if len(args) == 0 {
67+
// Default: first reader, fallback to writer if only one instance.
68+
for _, inst := range instances {
69+
if !inst.IsWriter {
70+
return inst, nil
71+
}
72+
}
73+
// No readers found — return the writer.
74+
for _, inst := range instances {
75+
if inst.IsWriter {
76+
return inst, nil
77+
}
78+
}
79+
return terraform.DBInstance{}, fmt.Errorf("no database instances found")
80+
}
81+
82+
target := args[0]
83+
84+
if target == "writer" {
85+
for _, inst := range instances {
86+
if inst.IsWriter {
87+
return inst, nil
88+
}
89+
}
90+
return terraform.DBInstance{}, fmt.Errorf("no writer instance found")
91+
}
92+
93+
if idxStr, ok := strings.CutPrefix(target, "reader-"); ok {
94+
idx, err := strconv.Atoi(idxStr)
95+
if err != nil {
96+
return terraform.DBInstance{}, fmt.Errorf("invalid reader index %q", idxStr)
97+
}
98+
99+
readerIdx := 0
100+
for _, inst := range instances {
101+
if !inst.IsWriter {
102+
if readerIdx == idx {
103+
return inst, nil
104+
}
105+
readerIdx++
106+
}
107+
}
108+
return terraform.DBInstance{}, fmt.Errorf("reader-%d not found (have %d readers)", idx, readerIdx)
109+
}
110+
111+
return terraform.DBInstance{}, fmt.Errorf("invalid target %q: use \"writer\" or \"reader-N\"", target)
112+
}
113+
114+
// selectJumpHost picks an EC2 instance to use as an SSH jump host.
115+
// Fallback chain: app server -> metrics server -> error.
116+
func selectJumpHost(output *terraform.Output) (terraform.Instance, error) {
117+
if output.HasAppServers() {
118+
return output.Instances[0], nil
119+
}
120+
if output.HasMetrics() {
121+
return output.MetricsServer, nil
122+
}
123+
return terraform.Instance{}, fmt.Errorf("no jump host available: need at least an app server or metrics server")
124+
}
125+
126+
func RunDBConnectCmdF(cmd *cobra.Command, args []string) error {
127+
if os.Getenv("SSH_AUTH_SOCK") == "" {
128+
return fmt.Errorf("ssh agent not running. Please run eval \"$(ssh-agent -s)\" and then ssh-add")
129+
}
130+
131+
config, err := getConfig(cmd)
132+
if err != nil {
133+
return err
134+
}
135+
136+
// Validate engine is aurora-postgresql.
137+
if config.TerraformDBSettings.InstanceEngine != "aurora-postgresql" {
138+
return fmt.Errorf("only aurora-postgresql is supported, got %q", config.TerraformDBSettings.InstanceEngine)
139+
}
140+
141+
t, err := terraform.New("", config)
142+
if err != nil {
143+
return fmt.Errorf("failed to create terraform engine: %w", err)
144+
}
145+
146+
output, err := t.Output()
147+
if err != nil {
148+
return fmt.Errorf("could not parse output: %w", err)
149+
}
150+
151+
if !output.HasDB() {
152+
return fmt.Errorf("no database cluster found in deployment")
153+
}
154+
155+
// Select target DB instance and jump host.
156+
dbInst, err := selectDBInstance(output, args)
157+
if err != nil {
158+
return err
159+
}
160+
161+
jumpHost, err := selectJumpHost(output)
162+
if err != nil {
163+
return err
164+
}
165+
166+
// Establish SSH connection to the jump host.
167+
extAgent, err := ssh.NewAgent()
168+
if err != nil {
169+
return fmt.Errorf("failed to create SSH agent: %w", err)
170+
}
171+
172+
sshClient, err := extAgent.NewClient(output.AMIUser, jumpHost.GetConnectionIP())
173+
if err != nil {
174+
return fmt.Errorf("failed to connect to jump host %s: %w", jumpHost.GetConnectionIP(), err)
175+
}
176+
defer sshClient.Close()
177+
178+
fmt.Printf("Connected to jump host %s\n", jumpHost.GetConnectionIP())
179+
180+
// Start local TCP listener on a free port.
181+
listener, err := net.Listen("tcp", "127.0.0.1:0")
182+
if err != nil {
183+
return fmt.Errorf("failed to start local listener: %w", err)
184+
}
185+
defer listener.Close()
186+
187+
localAddr := listener.Addr().String()
188+
fmt.Printf("Tunnel listening on %s -> %s:5432\n", localAddr, dbInst.Endpoint)
189+
190+
// Set up context with signal handling for clean shutdown.
191+
ctx, cancel := context.WithCancel(context.Background())
192+
defer cancel()
193+
194+
sigCh := make(chan os.Signal, 1)
195+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
196+
go func() {
197+
<-sigCh
198+
cancel()
199+
listener.Close()
200+
}()
201+
202+
// Accept connections in the background and tunnel them via SSH.
203+
dialF := sshClient.DialContextF()
204+
go func() {
205+
for {
206+
localConn, err := listener.Accept()
207+
if err != nil {
208+
// Listener closed — exit goroutine.
209+
return
210+
}
211+
go func(lc net.Conn) {
212+
defer lc.Close()
213+
remoteConn, err := dialF(ctx, "tcp", dbInst.Endpoint+":5432")
214+
if err != nil {
215+
fmt.Fprintf(os.Stderr, "failed to dial remote DB: %v\n", err)
216+
return
217+
}
218+
defer remoteConn.Close()
219+
220+
// Bidirectional copy.
221+
done := make(chan struct{}, 2)
222+
go func() {
223+
io.Copy(remoteConn, lc)
224+
done <- struct{}{}
225+
}()
226+
go func() {
227+
io.Copy(lc, remoteConn)
228+
done <- struct{}{}
229+
}()
230+
<-done
231+
}(localConn)
232+
}
233+
}()
234+
235+
// Build psql connection string and exec.
236+
dbName := config.DBName()
237+
connStr := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable",
238+
config.TerraformDBSettings.UserName,
239+
config.TerraformDBSettings.Password,
240+
localAddr,
241+
dbName,
242+
)
243+
244+
fmt.Printf("Connecting to %s on %s...\n", dbName, dbInst.Endpoint)
245+
246+
psql := exec.CommandContext(ctx, "psql", connStr)
247+
psql.Stdin = os.Stdin
248+
psql.Stdout = os.Stdout
249+
psql.Stderr = os.Stderr
250+
251+
if err := psql.Run(); err != nil {
252+
// If killed by our signal handler, don't treat as error.
253+
if ctx.Err() != nil {
254+
return nil
255+
}
256+
return fmt.Errorf("psql exited with error: %w", err)
257+
}
258+
259+
return nil
260+
}

cmd/ltctl/main.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,27 @@ func main() {
431431
sshCmd.AddCommand(sshListCmd)
432432
rootCmd.AddCommand(sshCmd)
433433

434+
dbCmd := &cobra.Command{
435+
Use: "db",
436+
Short: "Manage database connections",
437+
RunE: RunDBListCmdF,
438+
}
439+
dbListCmd := &cobra.Command{
440+
Use: "list",
441+
Short: "List database cluster instances",
442+
RunE: RunDBListCmdF,
443+
Args: cobra.NoArgs,
444+
}
445+
dbConnectCmd := &cobra.Command{
446+
Use: "connect [target]",
447+
Short: "Connect to a database instance via psql",
448+
Example: "ltctl db connect reader-0",
449+
RunE: RunDBConnectCmdF,
450+
Args: cobra.MaximumNArgs(1),
451+
}
452+
dbCmd.AddCommand(dbListCmd, dbConnectCmd)
453+
rootCmd.AddCommand(dbCmd)
454+
434455
goCmd := &cobra.Command{
435456
Use: "go [instance]",
436457
Short: "Open browser for instance",

docs/terraform_loadtest.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,25 @@ To compare the results of your load tests, see [here](compare.md).
168168
* `proxy` — connects to the instance running Nginx
169169
* `metrics`, `prometheus`, or `grafana` — connects to the instance running all metrics-related services
170170
* `browser-agent` — connects to the instance running the browser agent
171+
172+
### Database access
173+
174+
* To list all database cluster instances with their roles:
175+
```sh
176+
go run ./cmd/ltctl db
177+
```
178+
This will output each instance with its role (`writer` or `reader-N`), endpoint, and identifier.
179+
180+
* To connect to a database instance via an interactive `psql` session:
181+
```sh
182+
go run ./cmd/ltctl db connect
183+
```
184+
This establishes an SSH tunnel through a jump host (app server or metrics server) and opens `psql`. By default, it connects to the first reader instance, falling back to the writer if only one instance exists.
185+
186+
* To connect to a specific instance:
187+
```sh
188+
go run ./cmd/ltctl db connect writer
189+
go run ./cmd/ltctl db connect reader-0
190+
```
191+
192+
**Note:** Only `aurora-postgresql` is supported. `psql` must be installed locally.

0 commit comments

Comments
 (0)