Skip to content

Commit 187bdce

Browse files
aprimakinaclaude
andcommitted
feat(db): accept a read replica ID in DB operation commands (#177)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 264fcc2 commit 187bdce

17 files changed

Lines changed: 944 additions & 234 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Tiger CLI provides the following commands:
110110
- `delete` - Delete a service
111111
- `update-password` - Update service master password
112112
- `logs` - View service logs
113-
- `tiger db` - Database operations
113+
- `tiger db` - Database operations (each command below also accepts a read replica set ID)
114114
- `connect` - Connect to a database with psql (in an interactive terminal, if the service has read replicas, offers to connect to one of them; use `--no-replica-prompt` to skip)
115115
- `connection-string` - Get connection string for a service
116116
- `test-connection` - Test database connectivity

internal/tiger/cmd/db.go

Lines changed: 80 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"encoding/base64"
77
"errors"
88
"fmt"
9-
"net/http"
109
"os"
1110
"os/exec"
1211
"strings"
@@ -56,6 +55,8 @@ The service ID can be provided as an argument or will use the default service
5655
from your configuration. The connection string includes all necessary parameters
5756
for establishing a database connection to the TimescaleDB/PostgreSQL service.
5857
58+
You can also pass a read replica set ID to get a connection string for that replica.
59+
5960
By default, passwords are excluded from the connection string for security.
6061
Use --with-password to include the password directly in the connection string.
6162
@@ -92,29 +93,25 @@ Examples:
9293
return err
9394
}
9495

95-
service, err := getServiceDetailsFunc(cmd, cfg, args)
96+
target, err := lookupConnectionTarget(cmd, cfg, args)
9697
if err != nil {
9798
return err
9899
}
99100

100-
details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{
101+
details, err := buildConnectionDetailsForTarget(cmd, target, common.ConnectionDetailsOptions{
101102
Pooled: dbConnectionStringPooled,
102103
Role: dbConnectionStringRole,
103104
WithPassword: dbConnectionStringWithPassword,
104105
ReadOnly: dbConnectionStringReadOnly || cfg.ReadOnly,
105106
})
106107
if err != nil {
107-
return fmt.Errorf("failed to build connection string: %w", err)
108+
return err
108109
}
109110

110111
if dbConnectionStringWithPassword && details.Password == "" {
111112
return fmt.Errorf("password not available to include in connection string")
112113
}
113114

114-
if err := details.RequirePooler(dbConnectionStringPooled); err != nil {
115-
return err
116-
}
117-
118115
fmt.Fprintln(cmd.OutOrStdout(), details.String())
119116
return nil
120117
},
@@ -163,11 +160,17 @@ primary. Use --no-replica-prompt to skip this prompt and always connect to the
163160
requested service. The prompt is automatically skipped when stdin is not a
164161
terminal (e.g. in scripts) or when the service has no read replicas.
165162
163+
You can also pass a read replica set ID to connect straight to that replica,
164+
skipping the prompt. Read replicas share the primary's credentials.
165+
166166
Examples:
167167
# Connect to default service
168168
tiger db connect
169169
tiger db psql
170170
171+
# Connect directly to a read replica by its ID
172+
tiger db connect rep1234567
173+
171174
# Connect without the read replica prompt
172175
tiger db connect svc-12345 --no-replica-prompt
173176
@@ -202,7 +205,7 @@ Examples:
202205
// Separate service ID from additional psql flags
203206
serviceArgs, psqlFlags := separateServiceAndPsqlArgs(cmd, args)
204207

205-
service, err := getServiceDetailsFunc(cmd, cfg, serviceArgs)
208+
target, err := lookupConnectionTarget(cmd, cfg, serviceArgs)
206209
if err != nil {
207210
return err
208211
}
@@ -219,23 +222,19 @@ Examples:
219222
ReadOnly: dbConnectReadOnly || cfg.ReadOnly,
220223
}
221224

222-
// Optionally offer to connect to an existing read replica instead of
223-
// the primary service. In non-interactive contexts, or when the
224-
// service has no read replicas, this returns the primary's details
225-
// without prompting. Pooler availability is validated here: a hard
226-
// error for the primary, warn-and-fall-back for replicas.
227-
details, err := resolveConnectTarget(cmd.Context(), cmd, cfg.Client, cfg.ProjectID, service, opts, dbConnectNoReplicaPrompt)
225+
// Connects straight to a replica named by ID, or offers the interactive
226+
// replica menu for a primary. Returns nil details if the user cancels.
227+
details, err := selectConnection(cmd.Context(), cmd, cfg.Client, cfg.ProjectID, target, opts, dbConnectNoReplicaPrompt)
228228
if err != nil {
229229
return err
230230
}
231231
if details == nil {
232-
// User cancelled the connection.
233232
return nil
234233
}
235234

236-
// Replicas share the primary's credentials, so password storage and
237-
// recovery always operate on the primary service.
238-
return connectWithPasswordMenu(cmd.Context(), cmd, cfg.Client, service, details, psqlPath, psqlFlags)
235+
// Read replicas share the primary's credentials, so password storage
236+
// and recovery always operate on the credential service.
237+
return connectWithPasswordMenu(cmd.Context(), cmd, cfg.Client, target.CredentialService, details, psqlPath, psqlFlags)
239238
},
240239
}
241240

@@ -262,6 +261,8 @@ The service ID can be provided as an argument or will use the default service
262261
from your configuration. This command tests if the database is accepting
263262
connections and returns appropriate exit codes following pg_isready conventions.
264263
264+
You can also pass a read replica set ID to test connectivity to that replica.
265+
265266
Return Codes:
266267
0: Server is accepting connections normally
267268
1: Server is rejecting connections (e.g., during startup)
@@ -292,22 +293,18 @@ Examples:
292293
return common.ExitWithCode(common.ExitInvalidParameters, err)
293294
}
294295

295-
service, err := getServiceDetailsFunc(cmd, cfg, args)
296+
target, err := lookupConnectionTarget(cmd, cfg, args)
296297
if err != nil {
297298
return common.ExitWithCode(common.ExitInvalidParameters, err)
298299
}
299300

300301
// Build connection string for testing with password (if available)
301-
details, err := common.GetConnectionDetails(service, common.ConnectionDetailsOptions{
302+
details, err := buildConnectionDetailsForTarget(cmd, target, common.ConnectionDetailsOptions{
302303
Pooled: dbTestConnectionPooled,
303304
Role: dbTestConnectionRole,
304305
WithPassword: true,
305306
})
306307
if err != nil {
307-
return common.ExitWithCode(common.ExitInvalidParameters, fmt.Errorf("failed to build connection string: %w", err))
308-
}
309-
310-
if err := details.RequirePooler(dbTestConnectionPooled); err != nil {
311308
return common.ExitWithCode(common.ExitInvalidParameters, err)
312309
}
313310

@@ -372,10 +369,14 @@ Examples:
372369
return err
373370
}
374371

375-
service, err := getServiceDetailsFunc(cmd, cfg, args)
372+
// Resolve the target so a read replica id stores the password against
373+
// its parent primary: replicas share the primary's credentials, and
374+
// connect/test-connection look the password up against the primary.
375+
target, err := lookupConnectionTarget(cmd, cfg, args)
376376
if err != nil {
377377
return err
378378
}
379+
service := target.CredentialService
379380

380381
// Determine password based on precedence:
381382
// 1. --password flag with value
@@ -415,6 +416,10 @@ Examples:
415416
return fmt.Errorf("failed to save password: %w", err)
416417
}
417418

419+
if target.IsReplica {
420+
fmt.Fprintf(cmd.ErrOrStderr(), "Read replicas share the primary's credentials; saving against primary %s.\n",
421+
*service.ServiceId)
422+
}
418423
fmt.Fprintf(cmd.ErrOrStderr(), "Password saved successfully for service %s (role: %s)\n",
419424
*service.ServiceId, dbSavePasswordRole)
420425
return nil
@@ -653,7 +658,8 @@ func buildDbCreateRoleCmd() *cobra.Command {
653658
Long: `Create a new database role with optional read-only enforcement.
654659
655660
The service ID can be provided as an argument or will use the default service
656-
from your configuration.
661+
from your configuration. A read replica ID is rejected, since replicas are
662+
read-only; create the role on the primary instead.
657663
658664
By default, a secure random password is auto-generated for the new role. You can:
659665
- Provide an explicit password with --password=<value>
@@ -727,6 +733,12 @@ PostgreSQL Configuration Parameters That May Be Set:
727733
return err
728734
}
729735

736+
// A read replica is read-only, so a role can't be created there.
737+
if common.IsReadReplica(service) {
738+
return fmt.Errorf("%q is a read replica; create the role on its primary service %q instead",
739+
util.Deref(service.ServiceId), util.DerefStr(service.ForkedFrom.ServiceId))
740+
}
741+
730742
// Get password
731743
rolePassword, err := getPasswordForRole(passwordFlag)
732744
if err != nil {
@@ -813,8 +825,9 @@ indexes, triggers, and TimescaleDB hypertable and continuous aggregate
813825
metadata.
814826
815827
The service ID can be provided as an argument or will use the default service
816-
from your configuration. Only objects the connecting role can access are
817-
returned. The connection is opened in Tiger Cloud's immutable read-only mode.
828+
from your configuration. You can also pass a read replica set ID to introspect
829+
that replica. Only objects the connecting role can access are returned. The
830+
connection is opened in Tiger Cloud's immutable read-only mode.
818831
819832
By default only user-facing schemas and objects are shown. View and routine
820833
definitions and object comments are omitted unless requested, since they can be
@@ -844,12 +857,14 @@ Examples:
844857
return err
845858
}
846859

847-
service, err := getServiceDetailsFunc(cmd, cfg, args)
860+
target, err := lookupConnectionTarget(cmd, cfg, args)
848861
if err != nil {
849862
return err
850863
}
851864

852-
schema, err := common.FetchServiceSchema(cmd.Context(), service, dbSchemaRole, dbSchemaPooled, common.SchemaOptions{
865+
warnReplicaPooler(cmd, target, dbSchemaPooled)
866+
867+
schema, err := common.FetchServiceSchema(cmd.Context(), target, dbSchemaRole, dbSchemaPooled, common.SchemaOptions{
853868
Schema: dbSchemaSchema,
854869
IncludeInternal: dbSchemaInternal,
855870
IncludeDefinitions: dbSchemaDefinitions,
@@ -891,6 +906,37 @@ func buildDbCmd() *cobra.Command {
891906
return cmd
892907
}
893908

909+
// lookupConnectionTarget looks up the target named by args, which may be a
910+
// primary service ID or a read replica set ID. This lets a replica ID work
911+
// anywhere a service ID does across the db connection commands.
912+
func lookupConnectionTarget(cmd *cobra.Command, cfg *common.Config, args []string) (*common.ConnectionTarget, error) {
913+
service, err := getServiceDetailsFunc(cmd, cfg, args)
914+
if err != nil {
915+
return nil, err
916+
}
917+
918+
// The API resolves both primary and read replica IDs via GetService; a read
919+
// replica comes back linked to its parent, whose credentials it shares.
920+
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
921+
defer cancel()
922+
return common.ResolveConnectionTarget(ctx, cfg.Client, cfg.ProjectID, service)
923+
}
924+
925+
// warnReplicaPooler prints the replica pooler-fallback warning to stderr, if
926+
// any. It is a no-op for a primary target or when there's nothing to warn.
927+
func warnReplicaPooler(cmd *cobra.Command, target *common.ConnectionTarget, pooled bool) {
928+
if warning := common.ReplicaPoolerWarning(target, pooled); warning != "" {
929+
fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", warning)
930+
}
931+
}
932+
933+
// buildConnectionDetailsForTarget builds connection details for a target,
934+
// warning first when a replica falls back from a requested pooler.
935+
func buildConnectionDetailsForTarget(cmd *cobra.Command, target *common.ConnectionTarget, opts common.ConnectionDetailsOptions) (*common.ConnectionDetails, error) {
936+
warnReplicaPooler(cmd, target, opts.Pooled)
937+
return target.Details(opts)
938+
}
939+
894940
// getServiceDetails is a helper that handles common service lookup logic and returns the service details
895941
func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (api.Service, error) {
896942
// Determine service ID
@@ -901,25 +947,14 @@ func getServiceDetails(cmd *cobra.Command, cfg *common.Config, args []string) (a
901947

902948
cmd.SilenceUsage = true
903949

904-
// Fetch service details
905950
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
906951
defer cancel()
907952

908-
resp, err := cfg.Client.GetServiceWithResponse(ctx, cfg.ProjectID, serviceID)
953+
service, err := common.GetService(ctx, cfg.Client, cfg.ProjectID, serviceID)
909954
if err != nil {
910-
return api.Service{}, fmt.Errorf("failed to fetch service details: %w", err)
911-
}
912-
913-
// Handle API response
914-
if resp.StatusCode() != http.StatusOK {
915-
return api.Service{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX)
916-
}
917-
918-
if resp.JSON200 == nil {
919-
return api.Service{}, fmt.Errorf("empty response from API")
955+
return api.Service{}, err
920956
}
921-
922-
return *resp.JSON200, nil
957+
return *service, nil
923958
}
924959

925960
// ArgsLenAtDashProvider defines the interface for getting ArgsLenAtDash

0 commit comments

Comments
 (0)