Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Further conventions:

### Service Refs

A command that identifies a service takes a **ref** — its ID, a read replica set ID, or its name — and resolves it through the API, which also returns the whole resource. Never try to tell an ID from a name in the CLI. `tiger service` and `tiger db` commands resolve through the `ref` filter on the API's service list (`resolveService`). `tiger branch` commands use the API's resolve endpoint, with its `type` parameter set so that a ref can only name a branch (`common.ResolveBranch`, called by `resolveBranch`). The service and branch ref helpers sit side by side in `ref_helper.go`.
A command that identifies a service takes a **ref** — its ID, a read replica set ID, or its name — and resolves it through the API, which also returns the whole resource. Never try to tell an ID from a name in the CLI. `tiger service` commands resolve through the `ref` filter on the API's service list (`resolveService`). `tiger branch` and `tiger db` commands use the API's resolve endpoint, whose `type` parameter decides what a ref can name: only branches for `tiger branch` (`common.ResolveBranch`, called by `resolveBranch`), and anything they can connect to — a service, a read replica set, or a branch — for `tiger db` (`common.ResolveConnectionTarget`, called by `resolveConnectionTarget` in `db.go`). The service and branch ref helpers sit side by side in `ref_helper.go`.

- A command that changes the service it resolves uses `resolveServiceForWrite`, which carries the read-only gate; everything else uses `resolveService`.
- Help text shows `[name-or-id]` and says the service "can be given by ID or name". Shell completion offers names, falling back to IDs when what is typed matches an ID instead.
Expand Down
47 changes: 43 additions & 4 deletions internal/cmd/db.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package cmd

import (
"context"
"errors"
"fmt"

"github.com/jackc/pgx/v5/pgconn"
"github.com/spf13/cobra"

"github.com/timescale/tiger-cli/internal/api"
"github.com/timescale/tiger-cli/internal/common"
)

Expand All @@ -29,26 +31,63 @@ func buildDbCmd(app *common.App) *cobra.Command {
return cmd
}

// branchRefHelp is the paragraph a db command's help gains for branches, saying
// what passing one does. Branches are a preview, so it is empty without the
// experimental gate.
func branchRefHelp(experimental bool, text string) string {
if !experimental {
return ""
}
return "\n\n" + text
}

// handleDatabaseError adds guidance to the failures whose fix isn't evident from
// the message: the readiness sentinels, and a Postgres authentication failure,
// which otherwise arrives as a bare pgx error saying nothing about the password
// it was rejected for. Every other error passes through unchanged.
func handleDatabaseError(err error, target *common.ConnectionTarget) error {
// A branch is managed through `tiger branch`, everything else through
// `tiger service`. The group also names the target in the readiness
// errors, whose sentinels always say "service".
group := "service"
if target.Kind == common.TargetBranch {
group = "branch"
}

switch {
case errors.Is(err, common.ErrPaused):
return fmt.Errorf("%w — start it with 'tiger service start %s'", common.ErrPaused, target.ID)
return fmt.Errorf("%s is paused — start it with 'tiger %s start %s'", group, group, target.ID)
case errors.Is(err, common.ErrNotReady):
return fmt.Errorf("%w — check its status with 'tiger service get %s' and try again", common.ErrNotReady, target.ID)
return fmt.Errorf("%s is not ready — check its status with 'tiger %s get %s' and try again", group, group, target.ID)
case isPostgresAuthenticationError(err):
// A read replica shares its primary's credentials, so the password
// belongs to the primary rather than the replica connected to.
serviceID := target.Credentials.ID
return fmt.Errorf("%w\n\nThe stored password is missing or invalid. Save the current one with 'tiger db save-password %s', or reset it with 'tiger service update-password %s'",
err, serviceID, serviceID)
return fmt.Errorf("%w\n\nThe stored password is missing or invalid. Save the current one with 'tiger db save-password %s', or reset it with 'tiger %s update-password %s'",
err, serviceID, group, serviceID)
}
return err
}

// resolveConnectionTarget resolves the ref a db command was given to what it
// connects to: a service, a read replica set, or (behind the experimental
// gate) a branch, by ID or name.
func resolveConnectionTarget(ctx context.Context, client api.ClientWithResponsesInterface, projectID string, ref serviceRef, experimental bool) (*common.ConnectionTarget, error) {
hint := "Run 'tiger service list' to find the ID you want"
if experimental {
hint = "Run 'tiger service list' or 'tiger branch list' to find the ID you want"
}

target, err := common.ResolveConnectionTarget(ctx, client, projectID, ref.ref, experimental)
if err != nil {
return nil, withAmbiguousRefHint(err, hint)
}
if err := checkDefaultIsID(target.ID, ref); err != nil {
return nil, err
}
return target, nil
}

// isPostgresAuthenticationError checks if the error is a PostgreSQL authentication failure
func isPostgresAuthenticationError(err error) bool {
// Check for PostgreSQL error code 28P01 (invalid_password) or 28000 (invalid_authorization_specification)
Expand Down
20 changes: 14 additions & 6 deletions internal/cmd/db_create_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func buildDbCreateRoleCmd(app *common.App) *cobra.Command {

The service can be given by ID or name as an argument, or will use the default
service from your configuration. A read replica ID is rejected, since replicas
are read-only; create the role on the primary instead.
are read-only; create the role on the primary instead.` + branchRefHelp(app.Experimental, "You can also pass a branch, by ID or name, to create the role on that branch.") + `

By default, a secure random password is auto-generated for the new role. You can:
- Provide an explicit password with --password=<value>
Expand Down Expand Up @@ -100,15 +100,23 @@ PostgreSQL Configuration Parameters That May Be Set:
}

// Gated here rather than letting the server reject the CREATE ROLE.
service, err := resolveServiceForWrite(cmd.Context(), cfg, client, projectID, serviceRef)
// Refuse the blanket case before resolving, as resolveServiceForWrite
// does, then gate on the target's own tag.
if cfg.ReadOnly.BlocksAll() {
return common.ErrReadOnly
}
target, err := resolveConnectionTarget(cmd.Context(), client, projectID, serviceRef, app.Experimental)
if err != nil {
return err
}
if err := common.CheckReadOnly(cfg, target.Environment); err != nil {
return err
}

// A read replica is read-only, so a role can't be created there.
if common.IsReadReplica(*service) {
if target.Kind == common.TargetReadReplica {
return fmt.Errorf("'%s' is a read replica; create the role on its primary service '%s' instead",
service.ServiceID, util.DerefStr(service.ForkedFrom.ServiceID))
target.ID, target.Credentials.ID)
}

// Get password
Expand All @@ -118,7 +126,7 @@ PostgreSQL Configuration Parameters That May Be Set:
}

// Build connection string
details, err := common.GetConnectionDetails(cfg, common.NewServiceConnectionTarget(*service), common.ConnectionDetailsOptions{
details, err := common.GetConnectionDetails(cfg, target, common.ConnectionDetailsOptions{
Pooled: false,
Role: "tsdbadmin", // Use admin role to create new roles
WithPassword: true,
Expand All @@ -143,7 +151,7 @@ PostgreSQL Configuration Parameters That May Be Set:
}

// Save password to storage with the new role name
result, err := common.SavePasswordWithResult(cfg, common.ServicePasswordTarget(*service), rolePassword, name)
result, err := common.SavePasswordWithResult(cfg, target.Credentials.PasswordTarget, rolePassword, name)
if err != nil {
cmd.PrintErrf("Warning: %s\n", result.Message)
} else if !result.Success {
Expand Down
34 changes: 22 additions & 12 deletions internal/cmd/db_create_role_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func TestDbCreateRoleCmd(t *testing.T) {
name: "read-only prod refuses PROD service",
args: []string{"db", "create", "role", "svc-12345", "--name", "ai_analyst"},
opts: []runOption{withConfig(map[string]any{"read_only": "prod"})},
mock: expectTaggedService("PROD"),
mock: expectTaggedTarget("PROD"),
wantErr: `this operation is not allowed on services tagged PROD while read_only is set to "prod"`,
},
{
Expand All @@ -62,7 +62,7 @@ func TestDbCreateRoleCmd(t *testing.T) {
opts: []runOption{withConfig(map[string]any{"read_only": "prod"})},
mock: func(m *mocks.MockClientWithResponsesInterface) {
tag := "DEV"
expectResolveRef(m, "svc-12345", sampleService(func(s *api.Service) {
expectResolveTarget(m, "svc-12345", sampleService(func(s *api.Service) {
s.Metadata = &api.ServiceMetadata{Environment: &tag}
s.Endpoint = nil
}))
Expand All @@ -76,7 +76,7 @@ func TestDbCreateRoleCmd(t *testing.T) {
args: []string{"db", "create", "role", "--name", "ai_analyst"},
opts: []runOption{withConfig(map[string]any{"service_id": "svc-12345"})},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRef(m, "svc-12345", sampleService(func(s *api.Service) {
expectResolveTarget(m, "svc-12345", sampleService(func(s *api.Service) {
s.Endpoint = nil
}))
},
Expand All @@ -86,24 +86,24 @@ func TestDbCreateRoleCmd(t *testing.T) {
name: "network error",
args: []string{"db", "create", "role", "svc-12345", "--name", "ai_analyst"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRefError(m, "svc-12345", errors.New("connection refused"))
expectResolveTargetError(m, "svc-12345", errors.New("connection refused"))
},
wantErr: `failed to resolve service 'svc-12345': connection refused`,
wantErr: `failed to resolve service or read replica set 'svc-12345': connection refused`,
},
{
name: "service not found",
args: []string{"db", "create", "role", "svc-12345", "--name", "ai_analyst"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRefNotFound(m, "svc-12345")
expectResolveTargetNotFound(m, "svc-12345")
},
wantErr: "service 'svc-12345' not found",
wantErr: "service or read replica set 'svc-12345' not found",
checks: []checkFunc{checkExitCode(common.ExitServiceNotFound)},
},
{
name: "ambiguous name refused",
args: []string{"db", "create", "role", "my-api-db", "--name", "ai_analyst"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRefStatus(m, "my-api-db", http.StatusBadRequest, &api.Error{Message: new("ambiguous service name matches multiple services")})
expectResolveTargetStatus(m, "my-api-db", http.StatusBadRequest, &api.ClientError{Message: new("ambiguous service name matches multiple services")})
},
wantErr: "ambiguous service name matches multiple services\nRun 'tiger service list' to find the ID you want",
checks: []checkFunc{checkExitCode(common.ExitInvalidParameters)},
Expand All @@ -112,15 +112,16 @@ func TestDbCreateRoleCmd(t *testing.T) {
name: "nil response body",
args: []string{"db", "create", "role", "svc-12345", "--name", "ai_analyst"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRefStatus(m, "svc-12345", http.StatusOK, nil)
expectResolveTargetStatus(m, "svc-12345", http.StatusOK, nil)
},
wantErr: "empty response from API",
},
{
name: "read replica rejected",
args: []string{"db", "create", "role", "rep-67890", "--name", "ai_analyst"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRef(m, "rep-67890", sampleReplica())
expectResolveTarget(m, "rep-67890", sampleReplica())
expectGetService(m, "svc-12345", sampleService())
},
wantErr: "'rep-67890' is a read replica; create the role on its primary service 'svc-12345' instead",
},
Expand All @@ -131,20 +132,29 @@ func TestDbCreateRoleCmd(t *testing.T) {
args: []string{"db", "create", "role", "svc-12345", "--name", "ai_analyst"},
opts: []runOption{withGenerateSecurePassword(32, "", errors.New("entropy unavailable"))},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRef(m, "svc-12345", sampleService())
expectResolveTarget(m, "svc-12345", sampleService())
},
wantErr: "failed to determine password: entropy unavailable",
},
{
name: "endpoint not available",
args: []string{"db", "create", "role", "svc-12345", "--name", "ai_analyst"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRef(m, "svc-12345", sampleService(func(s *api.Service) {
expectResolveTarget(m, "svc-12345", sampleService(func(s *api.Service) {
s.Endpoint = nil
}))
},
wantErr: "failed to build connection string: service endpoint not available",
},
{
// Branches are a preview, so the help mentions them only behind
// the gate. The generated docs/cli reference covers the ungated
// help.
name: "help mentions branches when experimental",
args: []string{"db", "create", "role", "--help"},
opts: []runOption{withExperimental()},
wantStdout: matchHelp("You can also pass a branch, by ID or name, to create the role on that branch."),
},
})
}

Expand Down
9 changes: 2 additions & 7 deletions internal/cmd/db_extensions_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ extension's version, the schema it lives in, and its description.
The service can be given by ID or name as an argument, or will use the default
service from your configuration. You can also pass a read replica set ID to
list the extensions on that replica. The connection is opened in Tiger Cloud's
immutable read-only mode.`,
immutable read-only mode.` + branchRefHelp(app.Experimental, "You can also pass a branch, by ID or name, to list the extensions on that\nbranch."),
Example: ` # List the extensions installed in the default service
tiger db extensions list

Expand All @@ -46,12 +46,7 @@ immutable read-only mode.`,
return err
}

service, err := resolveService(cmd.Context(), client, projectID, serviceRef)
if err != nil {
return err
}

target, err := common.NewConnectionTarget(cmd.Context(), client, projectID, *service)
target, err := resolveConnectionTarget(cmd.Context(), client, projectID, serviceRef, app.Experimental)
if err != nil {
return err
}
Expand Down
58 changes: 47 additions & 11 deletions internal/cmd/db_extensions_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func withFetchExtensions(wantTarget *common.ConnectionTarget, wantRole string, e
func TestDbExtensionsListCmd(t *testing.T) {
setupGetWithStatus := func(status api.DeployStatus) func(m *mocks.MockClientWithResponsesInterface) {
return func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRef(m, "svc-12345", sampleService(func(s *api.Service) {
expectResolveTarget(m, "svc-12345", sampleService(func(s *api.Service) {
s.Status = status
}))
}
Expand All @@ -53,11 +53,9 @@ func TestDbExtensionsListCmd(t *testing.T) {
primaryTarget := common.NewServiceConnectionTarget(primary)
replica := sampleReplica()
// A replica connects to its own endpoint with its primary's credentials.
replicaTarget := common.NewServiceConnectionTarget(replica)
replicaTarget.Kind = common.TargetReadReplica
replicaTarget.Credentials = common.ServiceCredentials(primary)
replicaTarget := common.NewReplicaConnectionTarget(replica, common.ServiceCredentials(primary))
setupGetReplica := func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRef(m, "rep-67890", replica)
expectResolveTarget(m, "rep-67890", replica)
expectGetService(m, "svc-12345", primary)
}

Expand Down Expand Up @@ -90,32 +88,32 @@ func TestDbExtensionsListCmd(t *testing.T) {
name: "network error",
args: []string{"db", "extensions", "list", "svc-12345"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRefError(m, "svc-12345", errors.New("connection refused"))
expectResolveTargetError(m, "svc-12345", errors.New("connection refused"))
},
wantErr: `failed to resolve service 'svc-12345': connection refused`,
wantErr: `failed to resolve service or read replica set 'svc-12345': connection refused`,
},
{
name: "service not found",
args: []string{"db", "extensions", "list", "svc-12345"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRefNotFound(m, "svc-12345")
expectResolveTargetNotFound(m, "svc-12345")
},
wantErr: "service 'svc-12345' not found",
wantErr: "service or read replica set 'svc-12345' not found",
checks: []checkFunc{checkExitCode(common.ExitServiceNotFound)},
},
{
name: "nil response body",
args: []string{"db", "extensions", "list", "svc-12345"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRefStatus(m, "svc-12345", http.StatusOK, nil)
expectResolveTargetStatus(m, "svc-12345", http.StatusOK, nil)
},
wantErr: "empty response from API",
},
{
name: "read replica parent lookup fails",
args: []string{"db", "extensions", "list", "rep-67890"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveRef(m, "rep-67890", replica)
expectResolveTarget(m, "rep-67890", replica)
m.EXPECT().GetServiceWithResponse(validCtx, testProjectID, "svc-12345").
Return(nil, errors.New("connection refused"))
},
Expand All @@ -133,6 +131,27 @@ func TestDbExtensionsListCmd(t *testing.T) {
mock: setupGetWithStatus(api.DeployStatusQUEUED),
wantErr: notReadyMsg("svc-12345"),
},
{
// Branches are a preview, so without the gate a ref naming one is
// reported as not found, without mentioning branches.
name: "branch not found without experimental",
args: []string{"db", "extensions", "list", "test-branch"},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveTarget(m, "test-branch", sampleBranch())
},
wantErr: "service or read replica set 'test-branch' not found",
checks: []checkFunc{checkExitCode(common.ExitServiceNotFound)},
},
{
// A branch is started through `tiger branch`, not `tiger service`.
name: "branch paused",
args: []string{"db", "extensions", "list", "br-12345"},
opts: []runOption{withExperimental()},
mock: func(m *mocks.MockClientWithResponsesInterface) {
expectResolveTarget(m, "br-12345", sampleBranch(branchStatus(api.DeployStatusPAUSED)))
},
wantErr: "branch is paused — start it with 'tiger branch start br-12345'",
},
{
name: "fetch fails",
args: []string{"db", "extensions", "list", "svc-12345"},
Expand Down Expand Up @@ -228,12 +247,29 @@ func TestDbExtensionsListCmd(t *testing.T) {
opts: []runOption{withFetchExtensions(replicaTarget, "tsdbadmin", []common.Extension{}, nil)},
wantStdout: "[]\n",
},
{
// A branch connects with its own credentials, not its parent's.
name: "lists the extensions of a branch",
args: []string{"db", "extensions", "list", "br-12345", "-o", "json"},
mock: func(m *mocks.MockClientWithResponsesInterface) { expectResolveTarget(m, "br-12345", sampleBranch()) },
opts: []runOption{withExperimental(), withFetchExtensions(common.NewBranchConnectionTarget(sampleBranch()), "tsdbadmin", []common.Extension{}, nil)},
wantStdout: "[]\n",
},
{
name: "passes the role down to the fetch",
args: []string{"db", "extensions", "list", "svc-12345", "--role", "reader", "-o", "json"},
mock: setupGetWithStatus(api.DeployStatusREADY),
opts: []runOption{withFetchExtensions(primaryTarget, "reader", []common.Extension{}, nil)},
wantStdout: "[]\n",
},
{
// Branches are a preview, so the help mentions them only behind
// the gate. The generated docs/cli reference covers the ungated
// help.
name: "help mentions branches when experimental",
args: []string{"db", "extensions", "list", "--help"},
opts: []runOption{withExperimental()},
wantStdout: matchHelp("You can also pass a branch, by ID or name, to list the extensions on that\nbranch."),
},
})
}
Loading
Loading