-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathdb.go
More file actions
937 lines (772 loc) · 32.8 KB
/
db.go
File metadata and controls
937 lines (772 loc) · 32.8 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
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
package cmd
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"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/common"
"github.com/timescale/tiger-cli/internal/tiger/util"
)
var (
// getServiceDetailsFunc can be overridden for testing
getServiceDetailsFunc = getServiceDetails
// checkOutputIsTTY can be overridden for testing to bypass TTY detection
checkOutputIsTTY = func(w io.Writer) bool {
return util.IsTerminal(w)
}
)
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`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: serviceIDCompletion,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := common.LoadConfig(cmd.Context())
if err != nil {
cmd.SilenceUsage = true
return err
}
service, err := getServiceDetailsFunc(cmd, cfg, args)
if err != nil {
return err
}
details, err := common.GetConnectionDetails(service, common.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. If authentication fails, offers interactive options:
- Enter password manually (will be saved for future use)
- Reset password (update or generates a new password via the API)
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`,
Args: cobra.ArbitraryArgs,
ValidArgsFunction: serviceIDCompletion,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := common.LoadConfig(cmd.Context())
if err != nil {
cmd.SilenceUsage = true
return err
}
// Separate service ID from additional psql flags
serviceArgs, psqlFlags := separateServiceAndPsqlArgs(cmd, args)
service, err := getServiceDetailsFunc(cmd, cfg, 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 := common.GetConnectionDetails(service, common.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")
}
return connectWithPasswordMenu(cmd.Context(), cmd, cfg.Client, service, details, psqlPath, psqlFlags)
},
}
// 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`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: serviceIDCompletion,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := common.LoadConfig(cmd.Context())
if err != nil {
cmd.SilenceUsage = true
return common.ExitWithCode(common.ExitInvalidParameters, err)
}
service, err := getServiceDetailsFunc(cmd, cfg, args)
if err != nil {
return common.ExitWithCode(common.ExitInvalidParameters, err)
}
// Build connection string for testing with password (if available)
details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{
Pooled: dbTestConnectionPooled,
Role: dbTestConnectionRole,
WithPassword: true,
})
if err != nil {
return common.ExitWithCode(common.ExitInvalidParameters, fmt.Errorf("failed to build connection string: %w", err))
}
if dbTestConnectionPooled && !details.IsPooler {
return common.ExitWithCode(common.ExitInvalidParameters, fmt.Errorf("connection pooler not available for this service"))
}
// Validate timeout (Cobra handles parsing automatically)
if dbTestConnectionTimeout < 0 {
return common.ExitWithCode(common.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 buildDbSavePasswordCmd() *cobra.Command {
var dbSavePasswordRole string
var dbSavePasswordValue string
cmd := &cobra.Command{
Use: "save-password [service-id]",
Short: "Save password for a database service",
Long: `Save a password for a database service to configured password storage.
The service ID can be provided as an argument or will use the default service
from your configuration. The password can be provided via:
1. --password flag with explicit value (highest precedence)
2. TIGER_NEW_PASSWORD environment variable
3. Interactive prompt (if neither provided)
The password will be saved according to your --password-storage setting
(keyring, pgpass, or none).
Examples:
# Save password with explicit value (highest precedence)
tiger db save-password svc-12345 --password=your-password
# Using environment variable
export TIGER_NEW_PASSWORD=your-password
tiger db save-password svc-12345
# Interactive password prompt (when neither flag nor env var provided)
tiger db save-password svc-12345
# Save password for custom role
tiger db save-password svc-12345 --password=your-password --role readonly
# Save to specific storage location
tiger db save-password svc-12345 --password=your-password --password-storage pgpass`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: serviceIDCompletion,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := common.LoadConfig(cmd.Context())
if err != nil {
cmd.SilenceUsage = true
return err
}
service, err := getServiceDetailsFunc(cmd, cfg, args)
if err != nil {
return err
}
// Determine password based on precedence:
// 1. --password flag with value
// 2. TIGER_NEW_PASSWORD environment variable
// 3. Interactive prompt
var passwordToSave string
if cmd.Flags().Changed("password") {
// --password flag was provided
passwordToSave = dbSavePasswordValue
if passwordToSave == "" {
return fmt.Errorf("password cannot be empty when provided via --password flag")
}
} else if envPassword := os.Getenv("TIGER_NEW_PASSWORD"); envPassword != "" {
// Use environment variable
passwordToSave = envPassword
} else {
// Interactive prompt - check if we're in a terminal
if !checkOutputIsTTY(cmd.OutOrStdout()) {
return fmt.Errorf("TTY not detected - password required. Use --password flag or TIGER_NEW_PASSWORD environment variable")
}
fmt.Fprint(cmd.OutOrStdout(), "Enter password: ")
passwordToSave, err = readPassword(cmd.Context(), cmd.InOrStdin())
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout()) // Print newline after hidden input
if passwordToSave == "" {
return fmt.Errorf("password cannot be empty")
}
}
// Save password using configured storage
storage := common.GetPasswordStorage()
if err := storage.Save(service, passwordToSave, dbSavePasswordRole); err != nil {
return fmt.Errorf("failed to save password: %w", err)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Password saved successfully for service %s (role: %s)\n",
*service.ServiceId, dbSavePasswordRole)
return nil
},
}
// Add flags for db save-password command
cmd.Flags().StringVarP(&dbSavePasswordValue, "password", "p", "", "Password to save")
cmd.Flags().StringVar(&dbSavePasswordRole, "role", "tsdbadmin", "Database role/username")
return cmd
}
// buildCreateRoleSQL generates the CREATE ROLE SQL statement with LOGIN, PASSWORD, and optional IN ROLE clause
func buildCreateRoleSQL(roleName string, quotedPassword string, fromRoles []string) string {
sanitizedRoleName := pgx.Identifier{roleName}.Sanitize()
createSQL := fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD %s", sanitizedRoleName, quotedPassword)
// Add IN ROLE clause if fromRoles is specified
// IN ROLE adds the new role as a member of existing roles (equivalent to GRANT existing_role TO new_role)
if len(fromRoles) > 0 {
var sanitizedRoles []string
for _, role := range fromRoles {
sanitizedRoles = append(sanitizedRoles, pgx.Identifier{role}.Sanitize())
}
createSQL += " IN ROLE " + strings.Join(sanitizedRoles, ", ")
}
return createSQL
}
// buildReadOnlyAlterSQL generates the ALTER ROLE SQL statement for read-only enforcement
func buildReadOnlyAlterSQL(roleName string) string {
sanitizedRoleName := pgx.Identifier{roleName}.Sanitize()
return fmt.Sprintf("ALTER ROLE %s SET tsdb_admin.read_only_role = true", sanitizedRoleName)
}
// buildStatementTimeoutAlterSQL generates the ALTER ROLE SQL statement for statement timeout configuration
func buildStatementTimeoutAlterSQL(roleName string, timeout time.Duration) string {
sanitizedRoleName := pgx.Identifier{roleName}.Sanitize()
timeoutMs := timeout.Milliseconds()
return fmt.Sprintf("ALTER ROLE %s SET statement_timeout = %d", sanitizedRoleName, timeoutMs)
}
// createRoleWithOptions creates a new PostgreSQL role with all specified options in a single transaction
func createRoleWithOptions(ctx context.Context, conn *pgx.Conn, roleName, rolePassword string, readOnly bool, statementTimeout time.Duration, fromRoles []string) error {
// Begin transaction for atomic operation
tx, err := conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
// Check if tsdbadmin is in the fromRoles list
hasTsdbadmin := false
var otherRoles []string
for _, role := range fromRoles {
if role == "tsdbadmin" {
hasTsdbadmin = true
} else {
otherRoles = append(otherRoles, role)
}
}
// If tsdbadmin is requested, use special TimescaleDB Cloud functions
if hasTsdbadmin {
// Enforce read-only requirement when inheriting from tsdbadmin
if !readOnly {
return fmt.Errorf("roles inheriting from tsdbadmin must be read-only (use --read-only flag)")
}
// Cannot set statement_timeout on roles created with create_bare_readonly_role
// due to permission restrictions on altering special roles
if statementTimeout > 0 {
return fmt.Errorf("cannot use --statement-timeout with --from tsdbadmin (permission denied to alter special roles)")
}
// Use timescale_functions.create_bare_readonly_role to create the role
// This function creates a read-only role that can inherit tsdbadmin privileges
if _, err := tx.Exec(ctx, "SELECT timescale_functions.create_bare_readonly_role($1, $2)",
roleName, rolePassword); err != nil {
return fmt.Errorf("failed to create role with create_bare_readonly_role: %w", err)
}
// Grant tsdbadmin privileges using the special function
if _, err := tx.Exec(ctx, "SELECT timescale_functions.grant_tsdbadmin_to_role($1)",
roleName); err != nil {
return fmt.Errorf("failed to grant tsdbadmin privileges: %w", err)
}
// Grant any other roles (besides tsdbadmin) if specified
// This is necessary because the special functions don't support IN ROLE clause
for _, role := range otherRoles {
grantSQL := fmt.Sprintf("GRANT %s TO %s",
pgx.Identifier{role}.Sanitize(),
pgx.Identifier{roleName}.Sanitize())
if _, err := tx.Exec(ctx, grantSQL); err != nil {
return fmt.Errorf("failed to grant role %s: %w", role, err)
}
}
} else {
// Use standard CREATE ROLE for non-tsdbadmin cases
// Fail if password contains a single quote (we don't support escaping)
if strings.Contains(rolePassword, "'") {
return fmt.Errorf("password cannot contain single quotes")
}
// Wrap password in single quotes for SQL literal
quotedPassword := "'" + rolePassword + "'"
// IN ROLE clause handles all role grants, so no need for separate GRANT statements
createSQL := buildCreateRoleSQL(roleName, quotedPassword, fromRoles)
if _, err := tx.Exec(ctx, createSQL); err != nil {
return fmt.Errorf("failed to create role: %w", err)
}
// Configure read-only mode if requested
if readOnly {
alterSQL := buildReadOnlyAlterSQL(roleName)
if _, err := tx.Exec(ctx, alterSQL); err != nil {
return fmt.Errorf("failed to configure read-only mode: %w", err)
}
}
}
// Set statement timeout if requested
if statementTimeout > 0 {
alterSQL := buildStatementTimeoutAlterSQL(roleName, statementTimeout)
if _, err := tx.Exec(ctx, alterSQL); err != nil {
return fmt.Errorf("failed to set statement timeout: %w", err)
}
}
// Commit transaction
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// generateSecurePassword generates a cryptographically secure random password
func generateSecurePassword(length int) (string, error) {
// Generate random bytes
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random password: %w", err)
}
// Encode as base64 (URL-safe variant to avoid special characters that might need escaping)
encodedPassword := base64.URLEncoding.EncodeToString(bytes)
// Trim to desired length (base64 encoding makes it slightly longer)
if len(encodedPassword) > length {
encodedPassword = encodedPassword[:length]
}
return encodedPassword, nil
}
// getPasswordForRole determines the password based on flags and environment
func getPasswordForRole(passwordFlag string) (string, error) {
// Priority order:
// 1. Explicit password value from --password flag
// 2. TIGER_NEW_PASSWORD environment variable
// 3. Auto-generate secure random password
if passwordFlag != "" {
// Explicit password provided via --password flag
return passwordFlag, nil
}
// Check environment variable
if envPassword := os.Getenv("TIGER_NEW_PASSWORD"); envPassword != "" {
return envPassword, nil
}
// Auto-generate secure password
return generateSecurePassword(32)
}
// CreateRoleResult represents the output of a create role operation
type CreateRoleResult struct {
RoleName string `json:"role_name"`
ReadOnly bool `json:"read_only,omitempty"`
StatementTimeout string `json:"statement_timeout,omitempty"`
FromRoles []string `json:"from_roles,omitempty"`
}
// outputCreateRoleResult formats and outputs the create role result
func outputCreateRoleResult(cmd *cobra.Command, roleName string, readOnly bool, statementTimeout time.Duration, fromRoles []string, format string) error {
result := CreateRoleResult{
RoleName: roleName,
ReadOnly: readOnly,
}
if statementTimeout > 0 {
result.StatementTimeout = statementTimeout.String()
}
if len(fromRoles) > 0 {
result.FromRoles = fromRoles
}
outputWriter := cmd.OutOrStdout()
switch strings.ToLower(format) {
case "json":
return util.SerializeToJSON(outputWriter, result)
case "yaml":
return util.SerializeToYAML(outputWriter, result)
default: // table format
fmt.Fprintf(outputWriter, "✓ Role '%s' created successfully\n", roleName)
if readOnly {
fmt.Fprintf(outputWriter, " Read-only enforcement: enabled (permanent, role-based)\n")
}
if statementTimeout > 0 {
fmt.Fprintf(outputWriter, " Statement timeout: %s\n", statementTimeout)
}
if len(fromRoles) > 0 {
fmt.Fprintf(outputWriter, " Inherits from: %s\n", strings.Join(fromRoles, ", "))
}
return nil
}
}
func buildDbCreateRoleCmd() *cobra.Command {
var roleName string
var readOnly bool
var fromRoles []string
var statementTimeout time.Duration
var passwordFlag string
var output string
cmd := &cobra.Command{
Use: "role [service-id]",
Short: "Create a new database role",
Long: `Create a new database role with optional read-only enforcement.
The service ID can be provided as an argument or will use the default service
from your configuration.
By default, a secure random password is auto-generated for the new role. You can:
- Provide an explicit password with --password=<value>
- Use TIGER_NEW_PASSWORD environment variable
- Let it auto-generate (default)
The password is saved according to your --password-storage setting (keyring, pgpass, or none).
Read-Only Mode for AI Agents:
The --read-only flag enables permanent read-only enforcement at the PostgreSQL level
using the tsdb_admin.read_only_role extension setting. This is designed to provide
safe database access for AI agents and automated tools that need to read production
data without risk of modification.
Examples:
# Create a role with global database access (uses default service, auto-generates password)
tiger db create role --name ai_analyst --from tsdbadmin
# Create a role for specific service
tiger db create role svc-12345 --name ai_analyst
# Create a read-only role
tiger db create role --name ai_analyst --read-only
# Create a read-only role with same grants as another role
tiger db create role --name ai_analyst --read-only --from app_role
# Create a read-only role inheriting from multiple roles
tiger db create role --name ai_analyst --read-only --from app_role --from readonly_role
# Create a read-only role with statement timeout
tiger db create role --name ai_analyst --read-only --statement-timeout 30s
# Create a role with specific password
tiger db create role --name ai_analyst --password=my-secure-password
# Create a role with password from environment variable
TIGER_NEW_PASSWORD=my-secure-password tiger db create role --name ai_analyst
Technical Details:
This command executes PostgreSQL statements in a transaction to create and configure the role.
CREATE ROLE Options Used:
- LOGIN: Always enabled to allow the role to connect
- PASSWORD: Always set (from flag, env var, or auto-generated)
- IN ROLE: Added when --from flag is provided to inherit grants from existing roles
PostgreSQL Configuration Parameters That May Be Set:
- tsdb_admin.read_only_role: Set to 'true' when --read-only flag is used
(enforces permanent read-only mode for the role)
- statement_timeout: Set when --statement-timeout flag is provided
(kills queries that exceed the specified duration, in milliseconds)`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: serviceIDCompletion,
PreRunE: bindFlags("output"),
RunE: func(cmd *cobra.Command, args []string) error {
// Validate arguments
if roleName == "" {
return fmt.Errorf("--name is required")
}
cfg, err := common.LoadConfig(cmd.Context())
if err != nil {
cmd.SilenceUsage = true
return err
}
// Get service details
service, err := getServiceDetailsFunc(cmd, cfg, args)
if err != nil {
return err
}
// Get password
rolePassword, err := getPasswordForRole(passwordFlag)
if err != nil {
return fmt.Errorf("failed to determine password: %w", err)
}
// Build connection string
details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{
Pooled: false,
Role: "tsdbadmin", // Use admin role to create new roles
WithPassword: true,
})
if err != nil {
return fmt.Errorf("failed to build connection string: %w", err)
}
// Connect to database
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
conn, err := pgx.Connect(ctx, details.String())
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer conn.Close(ctx)
// Create the role with all options in a transaction
if err := createRoleWithOptions(ctx, conn, roleName, rolePassword, readOnly, statementTimeout, fromRoles); err != nil {
return fmt.Errorf("failed to create role: %w", err)
}
// Save password to storage with the new role name
result, err := common.SavePasswordWithResult(service, rolePassword, roleName)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message)
} else if !result.Success {
fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message)
}
// Output result in requested format
return outputCreateRoleResult(cmd, roleName, readOnly, statementTimeout, fromRoles, cfg.Output)
},
}
// Add flags
cmd.Flags().StringVar(&roleName, "name", "", "Role name to create (required)")
cmd.Flags().BoolVar(&readOnly, "read-only", false, "Enable permanent read-only enforcement via tsdb_admin.read_only_role")
cmd.Flags().StringSliceVar(&fromRoles, "from", []string{}, "Roles to inherit grants from (e.g., --from app_role --from readonly_role or --from app_role,readonly_role)")
cmd.Flags().DurationVar(&statementTimeout, "statement-timeout", 0, "Set statement timeout for the role (e.g., 30s, 5m)")
cmd.Flags().StringVar(&passwordFlag, "password", "", "Password for the role. If not provided, checks TIGER_NEW_PASSWORD environment variable, otherwise auto-generates a secure random password.")
cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "output format (json, yaml, table)")
cmd.MarkFlagRequired("name")
return cmd
}
func buildDbCreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "Create database resources",
Long: `Create database resources such as roles, databases, and extensions.`,
}
cmd.AddCommand(buildDbCreateRoleCmd())
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())
cmd.AddCommand(buildDbSavePasswordCmd())
cmd.AddCommand(buildDbCreateCmd())
return cmd
}
// getServiceDetails is a helper that handles common service lookup logic and returns the service details
func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) {
// Determine service ID
serviceID, err := getServiceID(cfg.Config, args)
if err != nil {
return api.Service{}, err
}
cmd.SilenceUsage = true
// Fetch service details
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
resp, err := cfg.Client.GetServiceWithResponse(ctx, cfg.ProjectID, serviceID)
if err != nil {
return api.Service{}, fmt.Errorf("failed to fetch service details: %w", err)
}
// Handle API response
if resp.StatusCode() != http.StatusOK {
return api.Service{}, common.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
}
// launchPsql launches psql using the connection string and additional flags.
// It retrieves the password from storage and sets PGPASSWORD environment variable.
func launchPsql(details *common.ConnectionDetails, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) error {
psqlCmd := buildPsqlCommand(details, psqlPath, additionalFlags, service, cmd)
return psqlCmd.Run()
}
// buildPsqlCommand creates the psql command with proper environment setup
func buildPsqlCommand(details *common.ConnectionDetails, psqlPath string, additionalFlags []string, service api.Service, cmd *cobra.Command) *exec.Cmd {
password := details.Password
// Ensure we don't include password in the connection string to make it not show up in process lists
// Passwords are passed via PGPASSWORD environment variable (see below)
detailsCopy := *details
detailsCopy.Password = ""
connectionString := detailsCopy.String()
// Build command arguments: connection string first, then additional flags
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()
// Use provided password directly if available
if password != "" {
psqlCmd.Env = append(os.Environ(), "PGPASSWORD="+password)
} else {
storage := common.GetPasswordStorage()
// Only set PGPASSWORD for keyring storage method
// pgpass storage relies on psql automatically reading ~/.pgpass file
if _, isKeyring := storage.(*common.KeyringStorage); isKeyring {
if storedPassword, err := storage.Get(service, details.Role); err == nil && storedPassword != "" {
// Set PGPASSWORD environment variable for psql when using keyring
psqlCmd.Env = append(os.Environ(), "PGPASSWORD="+storedPassword)
}
// 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 common.ExitWithCode(common.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 common.ExitWithCode(common.ExitGeneralError, err) // Server is rejecting connections
}
fmt.Fprintf(cmd.ErrOrStderr(), "Connection failed: %v\n", err)
return common.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 common.ExitWithCode(common.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 common.ExitWithCode(common.ExitGeneralError, err) // Server is rejecting connections
}
fmt.Fprintf(cmd.ErrOrStderr(), "Connection failed: %v\n", err)
return common.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
}