-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathdb.go
More file actions
452 lines (365 loc) · 15.3 KB
/
db.go
File metadata and controls
452 lines (365 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
package cmd
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/spf13/cobra"
"github.com/timescale/tiger-cli/internal/tiger/api"
"github.com/timescale/tiger-cli/internal/tiger/config"
"github.com/timescale/tiger-cli/internal/tiger/password"
)
var (
// getAPIKeyForDB can be overridden for testing
getAPIKeyForDB = config.GetAPIKey
)
func buildDbConnectionStringCmd() *cobra.Command {
var dbConnectionStringPooled bool
var dbConnectionStringRole string
var dbConnectionStringWithPassword bool
cmd := &cobra.Command{
Use: "connection-string [service-id]",
Short: "Get connection string for a service",
Long: `Get a PostgreSQL connection string for connecting to a database service.
The service ID can be provided as an argument or will use the default service
from your configuration. The connection string includes all necessary parameters
for establishing a database connection to the TimescaleDB/PostgreSQL service.
By default, passwords are excluded from the connection string for security.
Use --with-password to include the password directly in the connection string.
Examples:
# Get connection string for default service
tiger db connection-string
# Get connection string for specific service
tiger db connection-string svc-12345
# Get pooled connection string (uses connection pooler if available)
tiger db connection-string svc-12345 --pooled
# Get connection string with custom role/username
tiger db connection-string svc-12345 --role readonly
# Get connection string with password included (less secure)
tiger db connection-string svc-12345 --with-password`,
RunE: func(cmd *cobra.Command, args []string) error {
service, err := getServiceDetails(cmd, args)
if err != nil {
return err
}
details, err := password.GetConnectionDetails(service, password.ConnectionDetailsOptions{
Pooled: dbConnectionStringPooled,
Role: dbConnectionStringRole,
WithPassword: dbConnectionStringWithPassword,
})
if err != nil {
return fmt.Errorf("failed to build connection string: %w", err)
}
if dbConnectionStringWithPassword && details.Password == "" {
return fmt.Errorf("password not available to include in connection string")
}
if dbConnectionStringPooled && !details.IsPooler {
return fmt.Errorf("connection pooler not available for this service")
}
fmt.Fprintln(cmd.OutOrStdout(), details.String())
return nil
},
}
// Add flags for db connection-string command
cmd.Flags().BoolVar(&dbConnectionStringPooled, "pooled", false, "Use connection pooling")
cmd.Flags().StringVar(&dbConnectionStringRole, "role", "tsdbadmin", "Database role/username")
cmd.Flags().BoolVar(&dbConnectionStringWithPassword, "with-password", false, "Include password in connection string (less secure)")
return cmd
}
func buildDbConnectCmd() *cobra.Command {
var dbConnectPooled bool
var dbConnectRole string
cmd := &cobra.Command{
Use: "connect [service-id]",
Aliases: []string{"psql"},
Short: "Connect to a database",
Long: `Connect to a database service using psql client.
The service ID can be provided as an argument or will use the default service
from your configuration. This command will launch an interactive psql session
with the appropriate connection parameters.
Authentication is handled automatically using:
1. Stored password (keyring, ~/.pgpass, or none based on --password-storage setting)
2. PGPASSWORD environment variable
3. Interactive password prompt (if neither above is available)
Examples:
# Connect to default service
tiger db connect
tiger db psql
# Connect to specific service
tiger db connect svc-12345
tiger db psql svc-12345
# Connect using connection pooler
tiger db connect svc-12345 --pooled
tiger db psql svc-12345 --pooled
# Connect with custom role/username
tiger db connect svc-12345 --role readonly
tiger db psql svc-12345 --role readonly
# Pass additional flags to psql (use -- to separate)
tiger db connect svc-12345 -- --single-transaction --quiet
tiger db psql svc-12345 -- -c "SELECT version();" --no-psqlrc`,
RunE: func(cmd *cobra.Command, args []string) error {
// Separate service ID from additional psql flags
serviceArgs, psqlFlags := separateServiceAndPsqlArgs(cmd, args)
service, err := getServiceDetails(cmd, serviceArgs)
if err != nil {
return err
}
// Check if psql is available
psqlPath, err := exec.LookPath("psql")
if err != nil {
return fmt.Errorf("psql client not found. Please install PostgreSQL client tools")
}
details, err := password.GetConnectionDetails(service, password.ConnectionDetailsOptions{
Pooled: dbConnectPooled,
Role: dbConnectRole,
})
if err != nil {
return fmt.Errorf("failed to build connection string: %w", err)
}
if dbConnectPooled && !details.IsPooler {
return fmt.Errorf("connection pooler not available for this service")
}
// Launch psql with additional flags
return launchPsqlWithConnectionString(details.String(), psqlPath, psqlFlags, service, cmd)
},
}
// Add flags for db connect command (works for both connect and psql)
cmd.Flags().BoolVar(&dbConnectPooled, "pooled", false, "Use connection pooling")
cmd.Flags().StringVar(&dbConnectRole, "role", "tsdbadmin", "Database role/username")
return cmd
}
func buildDbTestConnectionCmd() *cobra.Command {
var dbTestConnectionTimeout time.Duration
var dbTestConnectionPooled bool
var dbTestConnectionRole string
cmd := &cobra.Command{
Use: "test-connection [service-id]",
Short: "Test database connectivity",
Long: `Test database connectivity to a service.
The service ID can be provided as an argument or will use the default service
from your configuration. This command tests if the database is accepting
connections and returns appropriate exit codes following pg_isready conventions.
Return Codes:
0: Server is accepting connections normally
1: Server is rejecting connections (e.g., during startup)
2: No response to connection attempt (server unreachable)
3: No attempt made (e.g., invalid parameters)
Examples:
# Test connection to default service
tiger db test-connection
# Test connection to specific service
tiger db test-connection svc-12345
# Test connection with custom timeout (10 seconds)
tiger db test-connection svc-12345 --timeout 10s
# Test connection with longer timeout (5 minutes)
tiger db test-connection svc-12345 --timeout 5m
# Test connection with no timeout (wait indefinitely)
tiger db test-connection svc-12345 --timeout 0`,
RunE: func(cmd *cobra.Command, args []string) error {
service, err := getServiceDetails(cmd, args)
if err != nil {
return exitWithCode(ExitInvalidParameters, err)
}
// Build connection string for testing with password (if available)
details, err := password.GetConnectionDetails(service, password.ConnectionDetailsOptions{
Pooled: dbTestConnectionPooled,
Role: dbTestConnectionRole,
WithPassword: true,
})
if err != nil {
return exitWithCode(ExitInvalidParameters, fmt.Errorf("failed to build connection string: %w", err))
}
if dbTestConnectionPooled && !details.IsPooler {
return exitWithCode(ExitInvalidParameters, fmt.Errorf("connection pooler not available for this service"))
}
// Validate timeout (Cobra handles parsing automatically)
if dbTestConnectionTimeout < 0 {
return exitWithCode(ExitInvalidParameters, fmt.Errorf("timeout must be positive or zero, got %v", dbTestConnectionTimeout))
}
// Test the connection
return testDatabaseConnection(cmd.Context(), details.String(), dbTestConnectionTimeout, cmd)
},
}
// Add flags for db test-connection command
cmd.Flags().DurationVarP(&dbTestConnectionTimeout, "timeout", "t", 3*time.Second, "Timeout duration (e.g., 30s, 5m, 1h). Use 0 for no timeout")
cmd.Flags().BoolVar(&dbTestConnectionPooled, "pooled", false, "Use connection pooling")
cmd.Flags().StringVar(&dbTestConnectionRole, "role", "tsdbadmin", "Database role/username")
return cmd
}
func buildDbCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "db",
Short: "Database operations and management",
Long: `Database-specific operations including connection management, testing, and configuration.`,
}
cmd.AddCommand(buildDbConnectionStringCmd())
cmd.AddCommand(buildDbConnectCmd())
cmd.AddCommand(buildDbTestConnectionCmd())
return cmd
}
// getServiceDetails is a helper that handles common service lookup logic and returns the service details
func getServiceDetails(cmd *cobra.Command, args []string) (api.Service, error) {
// Get config
cfg, err := config.Load()
if err != nil {
return api.Service{}, fmt.Errorf("failed to load config: %w", err)
}
projectID := cfg.ProjectID
if projectID == "" {
return api.Service{}, fmt.Errorf("project ID is required. Set it using login with --project-id")
}
// Determine service ID
var serviceID string
if len(args) > 0 {
serviceID = args[0]
} else {
serviceID = cfg.ServiceID
}
if serviceID == "" {
return api.Service{}, fmt.Errorf("service ID is required. Provide it as an argument or set a default with 'tiger config set service_id <service-id>'")
}
cmd.SilenceUsage = true
// Get API key for authentication
apiKey, err := getAPIKeyForDB()
if err != nil {
return api.Service{}, exitWithCode(ExitAuthenticationError, fmt.Errorf("authentication required: %w", err))
}
// Create API client
client, err := api.NewTigerClient(apiKey)
if err != nil {
return api.Service{}, fmt.Errorf("failed to create API client: %w", err)
}
// Fetch service details
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, serviceID)
if err != nil {
return api.Service{}, fmt.Errorf("failed to fetch service details: %w", err)
}
// Handle API response
if resp.StatusCode() != 200 {
return api.Service{}, exitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX)
}
if resp.JSON200 == nil {
return api.Service{}, fmt.Errorf("empty response from API")
}
return *resp.JSON200, nil
}
// ArgsLenAtDashProvider defines the interface for getting ArgsLenAtDash
type ArgsLenAtDashProvider interface {
ArgsLenAtDash() int
}
// separateServiceAndPsqlArgs separates service arguments from psql flags using Cobra's ArgsLenAtDash
func separateServiceAndPsqlArgs(cmd ArgsLenAtDashProvider, args []string) ([]string, []string) {
var serviceArgs []string
psqlFlags := []string{}
argsLenAtDash := cmd.ArgsLenAtDash()
if argsLenAtDash >= 0 {
// There was a -- separator
serviceArgs = args[:argsLenAtDash]
psqlFlags = args[argsLenAtDash:]
} else {
// No -- separator
serviceArgs = args
}
return serviceArgs, psqlFlags
}
// launchPsqlWithConnectionString launches psql using the connection string and additional flags
func launchPsqlWithConnectionString(connectionString, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) error {
psqlCmd := buildPsqlCommand(connectionString, psqlPath, additionalFlags, service, cmd)
return psqlCmd.Run()
}
// buildPsqlCommand creates the psql command with proper environment setup
func buildPsqlCommand(connectionString, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) *exec.Cmd {
// Build command arguments: connection string first, then additional flags
// Note: connectionString contains only "postgresql://user@host:port/db" - no password
// Passwords are passed via PGPASSWORD environment variable (see below)
args := []string{connectionString}
args = append(args, additionalFlags...)
psqlCmd := exec.Command(psqlPath, args...)
// Use cmd's input/output streams for testability while maintaining CLI behavior
psqlCmd.Stdin = cmd.InOrStdin()
psqlCmd.Stdout = cmd.OutOrStdout()
psqlCmd.Stderr = cmd.ErrOrStderr()
// Only set PGPASSWORD for keyring storage method
// pgpass storage relies on psql automatically reading ~/.pgpass file
storage := password.GetPasswordStorage()
if _, isKeyring := storage.(*password.KeyringStorage); isKeyring {
if password, err := storage.Get(service); err == nil && password != "" {
// Set PGPASSWORD environment variable for psql when using keyring
psqlCmd.Env = append(os.Environ(), "PGPASSWORD="+password)
}
// Note: If keyring password retrieval fails, we let psql try without it
// This allows fallback to other authentication methods
}
return psqlCmd
}
// testDatabaseConnection tests the database connection and returns appropriate exit codes
func testDatabaseConnection(ctx context.Context, connectionString string, timeout time.Duration, cmd *cobra.Command) error {
// Create context with timeout if specified
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
// Attempt to connect to the database
// The connection string already includes the password (if available) thanks to PasswordOptional mode
conn, err := pgx.Connect(ctx, connectionString)
if err != nil {
// Determine the appropriate exit code based on error type
if isContextDeadlineExceeded(err) {
fmt.Fprintf(cmd.ErrOrStderr(), "Connection timeout after %v\n", timeout)
return exitWithCode(ExitTimeout, err) // Connection timeout
}
// Check if it's a connection rejection vs unreachable
if isConnectionRejected(err) {
fmt.Fprintf(cmd.ErrOrStderr(), "Connection rejected: %v\n", err)
return exitWithCode(ExitGeneralError, err) // Server is rejecting connections
}
fmt.Fprintf(cmd.ErrOrStderr(), "Connection failed: %v\n", err)
return exitWithCode(2, err) // No response to connection attempt
}
defer conn.Close(ctx)
// Test the connection with a simple ping
err = conn.Ping(ctx)
if err != nil {
// Determine the appropriate exit code based on error type
if isContextDeadlineExceeded(err) {
fmt.Fprintf(cmd.ErrOrStderr(), "Connection timeout after %v\n", timeout)
return exitWithCode(ExitTimeout, err) // Connection timeout
}
// Check if it's a connection rejection vs unreachable
if isConnectionRejected(err) {
fmt.Fprintf(cmd.ErrOrStderr(), "Connection rejected: %v\n", err)
return exitWithCode(ExitGeneralError, err) // Server is rejecting connections
}
fmt.Fprintf(cmd.ErrOrStderr(), "Connection failed: %v\n", err)
return exitWithCode(2, err) // No response to connection attempt
}
// Connection successful
fmt.Fprintf(cmd.OutOrStdout(), "Connection successful\n")
return nil // Server is accepting connections normally
}
// isContextDeadlineExceeded checks if the error is due to context timeout
func isContextDeadlineExceeded(err error) bool {
return errors.Is(err, context.DeadlineExceeded)
}
// isConnectionRejected determines if the connection was actively rejected vs unreachable
func isConnectionRejected(err error) bool {
// According to PostgreSQL error codes, only ERRCODE_CANNOT_CONNECT_NOW (57P03)
// should be considered as "server rejecting connections" (exit code 1).
// This occurs when the server is running but cannot accept new connections
// (e.g., during startup, shutdown, or when max_connections is reached).
// Check if this is a PostgreSQL error with the specific error code
if pgxErr, ok := err.(*pgconn.PgError); ok {
// ERRCODE_CANNOT_CONNECT_NOW is 57P03
return pgxErr.Code == "57P03"
}
// All other errors (authentication, authorization, network issues, etc.)
// should be treated as "unreachable" (exit code 2)
return false
}