diff --git a/CLAUDE.md b/CLAUDE.md index de64a249..c82f9ae0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 hands it to the `ref` filter on the API's service list through the helpers in `service_ref_helper.go`, which also return the whole service. Never try to tell an ID from a name in the CLI. +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 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. @@ -261,12 +261,12 @@ The CLI can be used to get around the MCP server's safeguards: `tiger config set ## Read-Only Mode -`cfg.ReadOnly` is a `config.ReadOnlyMode` — `all`, `prod`, or `off` (`prod` protects only services tagged `PROD`). `config.Load` normalizes every value through `parseReadOnlyMode`, which also accepts the legacy boolean spellings, so nothing downstream sees an unnormalized value. Every write/destructive surface on both planes gates through one of the two checks in `internal/common/read_only.go` — on the CLI side that's the `RunE` of `service create`, `fork`, `start`, `stop`, `rename`, `resize`, `update-password`, `delete`, `backup region add`, `backup region remove`, and `db create role`; on the MCP side, the handlers of the write tools listed in `readOnlyGatedTools`: +`cfg.ReadOnly` is a `config.ReadOnlyMode` — `all`, `prod`, or `off` (`prod` protects only services tagged `PROD`). `config.Load` normalizes every value through `parseReadOnlyMode`, which also accepts the legacy boolean spellings, so nothing downstream sees an unnormalized value. Every write/destructive surface on both planes gates through one of the two checks in `internal/common/read_only.go` — on the CLI side that's the `RunE` of `service create`, `fork`, `start`, `stop`, `rename`, `resize`, `update-password`, `delete`, `backup region add`, `backup region remove`, `db create role`, and every `branch` command that writes (`create` — also reachable as `service branch` — `delete`, `start`, `stop`, `resize`, `rename`, `update-password`); on the MCP side, the handlers of the write tools listed in `readOnlyGatedTools`: - `common.CheckReadOnly(cfg, tag)` — for a caller that has the target's environment tag: read off a service it already holds via `common.ServiceEnvironmentTag(service)` (what `resolveServiceForWrite` does on the CLI side), or the tag about to be requested (`--environment` on `service create`/`fork`, and the `environment` parameter of the matching MCP tools, both defaulting to `DEV`). - `common.CheckReadOnlyByServiceID(ctx, cfg, client, projectID, serviceID)` — the same verdict from an ID, fetching the service to read its tag. The fetch happens only under `prod` (`all` refuses and `off` allows without one), and a failed fetch is a refusal. Only the MCP write tools that don't already hold the service need it. -Gotchas when adding a gated surface: a replica set is judged on its *own* tag, never its primary's; `prod` refuses *creating* a `PROD` service too (otherwise it would create services it then can't stop or delete), so `create`/`fork` gate on the tag they're about to request; the MCP server skips registering write tools only under `all` — under `prod` they stay registered and refuse per call, with a `prod` variant of the server instructions explaining that; and where the verdict is wanted as a boolean, write `CheckReadOnly(…) != nil` rather than a wrapper, so grepping `CheckReadOnly` finds every gate. `CheckReadOnly` requires a tag so it can't silently ignore `prod`; the one shortcut allowed is refusing the blanket case before a fetch the command was making anyway (`if cfg.ReadOnly.BlocksAll() { return common.ErrReadOnly }`), then still gating once the service arrives — on the CLI side `resolveServiceForWrite` does both, so a command gets that order by calling it. +Gotchas when adding a gated surface: a replica set is judged on its *own* tag, never its primary's; a branch has no tag and is always judged as DEV (`common.BranchEnvironmentTag`), never by its parent's, so `prod` leaves branches of a PROD service writable; `prod` refuses *creating* a `PROD` service too (otherwise it would create services it then can't stop or delete), so `create`/`fork` gate on the tag they're about to request; the MCP server skips registering write tools only under `all` — under `prod` they stay registered and refuse per call, with a `prod` variant of the server instructions explaining that; and where the verdict is wanted as a boolean, write `CheckReadOnly(…) != nil` rather than a wrapper, so grepping `CheckReadOnly` finds every gate. `CheckReadOnly` requires a tag so it can't silently ignore `prod`; the one shortcut allowed is refusing the blanket case before a fetch the command was making anyway (`if cfg.ReadOnly.BlocksAll() { return common.ErrReadOnly }`), then still gating once the service arrives — on the CLI side `resolveServiceForWrite` does both, so a command gets that order by calling it. ## CLI/MCP Synchronization diff --git a/internal/cmd/branch.go b/internal/cmd/branch.go new file mode 100644 index 00000000..d28d0bf3 --- /dev/null +++ b/internal/cmd/branch.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "fmt" + "io" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/util" +) + +// branchCPUMemoryHelp is the table of CPU/Memory configurations a branch can +// be created or resized onto. +const branchCPUMemoryHelp = "Allowed CPU/Memory Configurations:\n" + fluidCPUMemoryHelp + +// buildBranchCmd creates the branch command with all subcommands. Branches are +// backed by gateway endpoints marked `x-tigerdata-preview: true`, so the whole +// group is only added to the tree behind the experimental gate (see root.go). +func buildBranchCmd(app *common.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "branch", + Aliases: []string{"branches"}, + Short: "Manage branches of database services", + Long: `Manage branches of Fluid Services. + +A branch is a lightweight, ephemeral copy of a Fluid Service, for development, +testing, CI/CD, and agent workflows. It shares its parent's storage as of the +moment it was created and gets its own compute.`, + } + + cmd.AddCommand(buildBranchCreateCmd(app)) + cmd.AddCommand(buildBranchListCmd(app)) + cmd.AddCommand(buildBranchGetCmd(app)) + cmd.AddCommand(buildBranchDeleteCmd(app)) + cmd.AddCommand(buildBranchStartCmd(app)) + cmd.AddCommand(buildBranchStopCmd(app)) + cmd.AddCommand(buildBranchResizeCmd(app)) + cmd.AddCommand(buildBranchRenameCmd(app)) + cmd.AddCommand(buildBranchUpdatePasswordCmd(app)) + + return cmd +} + +// OutputBranch represents a branch with computed fields for output. +type OutputBranch struct { + api.Branch + // ServiceName is the parent service's name, when it's known. + ServiceName string `json:"service_name,omitempty"` + common.ConnectionDetails + ConnectionString string `json:"connection_string,omitempty"` +} + +// branchOutputOptions controls what the branch output functions include. +// The zero value leaves out every optional field. +type branchOutputOptions struct { + // ServiceName is the parent service's name, if it's known. A branch + // carries only its parent's ID. + ServiceName string +} + +// outputBranch outputs a single branch in the configured output format. +func outputBranch(cmd *cobra.Command, cfg *config.Config, branch api.Branch, opts branchOutputOptions) error { + outputBr := prepareBranchForOutput(cmd, cfg, branch, opts) + outputWriter := cmd.OutOrStdout() + + switch strings.ToLower(cfg.Output) { + case "json": + return util.SerializeToJSON(outputWriter, outputBr) + case "yaml": + return util.SerializeToYAML(outputWriter, outputBr) + case "env": + return outputBranchEnv(cmd, outputBr) + default: // table format (default) + return outputBranchTable(outputWriter, outputBr) + } +} + +// outputBranchEnv outputs branch connection details in environment variable +// format. Branch commands never output a password, so PGPASSWORD is never set. +func outputBranchEnv(cmd *cobra.Command, branch OutputBranch) error { + cmd.Printf("PGHOST=%s\n", branch.Host) + cmd.Printf("PGPORT=%d\n", branch.Port) + cmd.Printf("PGDATABASE=%s\n", branch.Database) + cmd.Printf("PGUSER=%s\n", branch.Role) + return nil +} + +// outputBranchTable outputs detailed branch information in a formatted table. +func outputBranchTable(output io.Writer, branch OutputBranch) error { + table := tablewriter.NewWriter(output) + table.Header("PROPERTY", "VALUE") + + table.Append("Branch ID", branch.ID) + table.Append("Name", branch.Name) + if branch.ServiceName != "" { + table.Append("Parent Service", fmt.Sprintf("%s (%s)", branch.ServiceName, branch.ServiceID)) + } else { + table.Append("Parent Service", branch.ServiceID) + } + table.Append("Status", string(branch.Status)) + table.Append("CPU", fmt.Sprintf("%s cores (%dm)", common.FormatCPUCores(branch.CPUMillis), branch.CPUMillis)) + table.Append("Memory", fmt.Sprintf("%d GB", branch.MemoryGbs)) + if branch.Iops != nil { + table.Append("IOPS", fmt.Sprintf("%d", *branch.Iops)) + } + if branch.Endpoint != nil && branch.Endpoint.Host != nil && branch.Endpoint.Port != nil { + table.Append("Direct Endpoint", fmt.Sprintf("%s:%d", *branch.Endpoint.Host, *branch.Endpoint.Port)) + } + table.Append("Created", branch.Created.Format("2006-01-02 15:04:05 MST")) + if branch.ConnectionString != "" { + table.Append("Connection String", branch.ConnectionString) + } + + return table.Render() +} + +// prepareBranchForOutput builds the output view of a branch. cmd may be nil, +// in which case the connection-details warning is dropped rather than printed. +func prepareBranchForOutput(cmd *cobra.Command, cfg *config.Config, branch api.Branch, opts branchOutputOptions) OutputBranch { + outputBr := OutputBranch{ + Branch: branch, + ServiceName: opts.ServiceName, + } + outputBr.Branch.InitialPassword = nil + + // A branch that's still provisioning may have no endpoint yet, which + // isn't worth a warning. + if branch.Endpoint != nil { + opts := common.ConnectionDetailsOptions{ + Role: "tsdbadmin", + ReadOnly: common.CheckReadOnly(cfg, common.BranchEnvironmentTag) != nil, + } + if details, err := common.GetConnectionDetails(cfg, common.NewBranchConnectionTarget(branch), opts); err != nil { + if cmd != nil { + cmd.PrintErrf("Warning: failed to get connection details: %v\n", err) + } + } else { + outputBr.ConnectionDetails = *details + outputBr.ConnectionString = details.String() + } + } + + // TODO: add a Console URL once the Console supports branches. + + return outputBr +} diff --git a/internal/cmd/branch_create.go b/internal/cmd/branch_create.go new file mode 100644 index 00000000..16d9acf1 --- /dev/null +++ b/internal/cmd/branch_create.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "fmt" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// buildBranchCreateCmd creates the create subcommand of `tiger branch`, the +// same command as `tiger service branch` (see newBranchCreateCmd). +func buildBranchCreateCmd(app *common.App) *cobra.Command { + return newBranchCreateCmd(app, "create [service-name-or-id]", "tiger branch create") +} + +// newBranchCreateCmd builds the command that creates a branch of a service. +// `tiger branch create` and `tiger service branch` are the same command in two +// places in the tree, so both are built here, differing only in their use line +// and the command path their examples show. +func newBranchCreateCmd(app *common.App, use, commandPath string) *cobra.Command { + var name string + var cpu string + var memory string + var iops int + var noWait bool + var waitTimeout time.Duration + + cmd := &cobra.Command{ + Use: use, + Short: "Create a branch of a database service", + Long: `Create a branch of a Fluid Service. + +A branch is a lightweight, ephemeral copy of a service, sharing the service's +storage as of the moment it is created and getting its own compute. The +tsdbadmin user gets a new password, which is saved the same way as a new +service's. + +By default: +- The name is auto-generated +- The branch gets 0.25 CPU and 2GB of memory, whatever the service's size + +The service can be given by ID or name as an argument, or will use the default +service from your configuration. Branching from a branch isn't supported. + +` + branchCPUMemoryHelp + ` + +The allowed --iops values depend on your plan and the branch's CPU/memory +allocation. See the Tiger Data documentation for details. If --iops is not +given, the branch gets the baseline IOPS for your plan and its CPU allocation.`, + Example: fmt.Sprintf(` # Branch a service + %[1]s svc-12345 + + # Branch a service with a custom name + %[1]s svc-12345 --name ci-4821 + + # Branch a service with custom compute + %[1]s svc-12345 --cpu 2000 --memory 16 + + # Branch a service with provisioned IOPS + %[1]s svc-12345 --cpu 2000 --iops 16000 + + # Branch for a script + %[1]s svc-12345 --name ci-4821 -o json`, commandPath), + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: serviceRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + // Gate on the branch's own tag, which is always DEV: under prod + // mode, branching a PROD service is allowed, as forking one into + // a DEV fork is. + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return err + } + + cpuMemoryCfg, err := common.FluidCPUMemoryConfigs().ValidateAndNormalize(cpu, memory) + if err != nil { + return err + } + + serviceRef, err := getServiceRef(cmd, cfg, args) + if err != nil { + return err + } + parent, err := resolveService(cmd.Context(), client, projectID, serviceRef) + if err != nil { + return err + } + + if name != "" { + cmd.PrintErrf("Creating branch '%s' from service %s...\n", name, serviceLabel(*parent)) + } else { + cmd.PrintErrf("Creating branch from service %s...\n", serviceLabel(*parent)) + } + + var createReq api.BranchCreate + if name != "" { + createReq.Name = &name + } + if cpuMemoryCfg != nil { + createReq.CPUMillis = &cpuMemoryCfg.CPUMillis + createReq.MemoryGbs = &cpuMemoryCfg.MemoryGBs + } + if cmd.Flags().Changed("iops") { + createReq.Iops = &iops + } + + resp, err := client.CreateBranchWithResponse(cmd.Context(), projectID, parent.ServiceID, createReq) + if err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + cmd.PrintErrf("Branch ID: %s\n", branch.ID) + + // Saved before any waiting, so the password is stored even if the + // wait fails or is interrupted. + passwordSaved := handlePasswordSaving(cmd, cfg, common.BranchPasswordTarget(branch), util.Deref(branch.InitialPassword)) + + var waitErr error + if noWait { + cmd.PrintErrf("Branch is being created. Use 'tiger branch get %s' to check branch status.\n", branch.ID) + } else { + cmd.PrintErrf("Waiting for branch to be ready (timeout: %v)...\n", waitTimeout) + if waitErr = common.WaitForBranch(cmd.Context(), common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: parent.ServiceID, + BranchID: branch.ID, + Branch: &branch, + TargetStatus: api.DeployStatusREADY, + Input: cmd.InOrStdin(), + Output: cmd.ErrOrStderr(), + Timeout: waitTimeout, + TimeoutMsg: "branch may still be provisioning", + }); waitErr != nil { + cmd.PrintErrf("Error: %s\n", waitErr) + } else { + cmd.PrintErrln("Branch is ready.") + // A branch is never the default, so the command names it. + printConnectMessage(cmd, passwordSaved, true, branch.ID) + } + } + + if err := outputBranch(cmd, cfg, branch, branchOutputOptions{ServiceName: parent.Name}); err != nil { + cmd.PrintErrf("Warning: failed to output branch details: %v\n", err) + } + + // Return error for sake of exit code, but silence it since it was already output above + cmd.SilenceErrors = true + return waitErr + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Name for the branch (auto-generated if not provided)") + cmd.Flags().StringVar(&cpu, "cpu", "", "CPU allocation in millicores (default 250)") + cmd.Flags().StringVar(&memory, "memory", "", "Memory allocation in gigabytes (default 2)") + cmd.Flags().IntVar(&iops, "iops", 0, "Provisioned IOPS") + cmd.Flags().BoolVar(&noWait, "no-wait", false, "Don't wait for the branch to be ready") + cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 10*time.Minute, "Wait timeout duration (e.g., 30m, 1h30m, 90s)") + cmd.Flags().VarP(new(outputWithEnvFlag), "output", "o", "Output format (json, yaml, env, table)") + + registerFlagCompletion(cmd, "output", outputCompletion("env")) + registerFlagCompletion(cmd, "cpu", branchCPUCompletion) + registerFlagCompletion(cmd, "memory", branchMemoryCompletion) + registerFlagCompletion(cmd, "iops", iopsCompletion) + + return cmd +} diff --git a/internal/cmd/branch_create_test.go b/internal/cmd/branch_create_test.go new file mode 100644 index 00000000..59e01fbb --- /dev/null +++ b/internal/cmd/branch_create_test.go @@ -0,0 +1,293 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchCreateCmd(t *testing.T) { + parent := sampleService(fluidPlatform) + baseReq := api.BranchCreate{} + + // The create response carries the branch's new password, once. + created := sampleBranch(branchStatus(api.DeployStatusQUEUED), func(b *api.Branch) { + b.InitialPassword = new("branch-pw") + }) + + // setupCreate registers resolving svc-12345 onto parent and creating a + // branch of it with req, answered with branch. + setupCreate := func(req api.BranchCreate, branch api.Branch) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRef(m, "svc-12345", parent) + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "svc-12345", req). + Return(&api.CreateBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + + noWaitStderr := func(firstLine string) string { + return firstLine + ` +Branch ID: br-12345 +Password saved to system keyring +Branch is being created. Use 'tiger branch get br-12345' to check branch status. +` + } + defaultNoWaitStderr := noWaitStderr("Creating branch from service 'test-service' (svc-12345)...") + + runCmdTests(t, []cmdTest{ + { + // A branch never becomes the default, so there's nothing to opt out of. + name: "no --no-set-default flag", + args: []string{"branch", "create", "svc-12345", "--no-set-default"}, + opts: []runOption{withExperimental()}, + wantErr: "unknown flag: --no-set-default", + }, + { + name: "not logged in", + args: []string{"branch", "create", "svc-12345"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + name: "read-only all refuses", + args: []string{"branch", "create", "svc-12345"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + // A branch always counts as DEV, so prod allows branching a PROD + // service, as it allows forking one into a DEV fork. + name: "read-only prod allows branching a PROD service", + args: []string{"branch", "create", "svc-12345", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "prod"})}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRef(m, "svc-12345", sampleService(fluidPlatform, envTag("PROD"))) + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "svc-12345", baseReq). + Return(&api.CreateBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &created, + }, nil) + }, + wantStdout: sampleBranchEnv, + wantStderr: defaultNoWaitStderr, + }, + { + name: "invalid cpu/memory combination", + args: []string{"branch", "create", "svc-12345", "--cpu", "1000", "--memory", "4"}, + opts: []runOption{withExperimental()}, + wantErr: "invalid CPU/Memory combination. Allowed combinations: 0.25 CPU/2 GB, 0.5 CPU/4 GB, 1 CPU/8 GB, 2 CPU/16 GB, 4 CPU/32 GB, 8 CPU/64 GB, 16 CPU/128 GB, 32 CPU/256 GB", + }, + { + name: "missing service id", + args: []string{"branch", "create"}, + opts: []runOption{withExperimental()}, + wantErr: "service name or ID is required. Provide it as an argument or set a default with 'tiger config set service_id '", + }, + { + name: "service not found", + args: []string{"branch", "create", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRefNotFound(m, "svc-12345") + }, + wantErr: "service 'svc-12345' not found", + checks: []checkFunc{checkExitCode(common.ExitServiceNotFound)}, + }, + { + name: "network error", + args: []string{"branch", "create", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRef(m, "svc-12345", parent) + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "svc-12345", baseReq). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to create branch: connection refused", + wantStderr: "Creating branch from service 'test-service' (svc-12345)...\n" + + "Error: failed to create branch: connection refused\n", + }, + { + name: "API error", + args: []string{"branch", "create", "svc-12345", "--name", "ci-4821"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRef(m, "svc-12345", parent) + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "svc-12345", api.BranchCreate{Name: new("ci-4821")}). + Return(&api.CreateBranchResponse{ + HTTPResponse: httpResponse(http.StatusConflict), + JSON4XX: &api.ClientError{Message: new("a branch named ci-4821 already exists")}, + }, nil) + }, + wantErr: "a branch named ci-4821 already exists", + wantStderr: "Creating branch 'ci-4821' from service 'test-service' (svc-12345)...\n" + + "Error: a branch named ci-4821 already exists\n", + }, + { + name: "nil response body", + args: []string{"branch", "create", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRef(m, "svc-12345", parent) + m.EXPECT().CreateBranchWithResponse(validCtx, testProjectID, "svc-12345", baseReq). + Return(&api.CreateBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + }, nil) + }, + wantErr: "empty response from API", + wantStderr: "Creating branch from service 'test-service' (svc-12345)...\n" + + "Error: empty response from API\n", + }, + { + // Only service IDs go in the default, so a branch never becomes it. + name: "no-wait saves the password and leaves the default alone", + args: []string{"branch", "create", "svc-12345", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(baseReq, created), + wantStdout: sampleBranchEnv, + wantStderr: defaultNoWaitStderr, + checks: []checkFunc{checkStoredPassword("br-12345", "branch-pw"), checkDefaultService("")}, + }, + { + // Nothing to save, so nothing is said about saving. + name: "no initial password", + args: []string{"branch", "create", "svc-12345", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(baseReq, sampleBranch(branchStatus(api.DeployStatusQUEUED))), + wantStdout: sampleBranchEnv, + wantStderr: `Creating branch from service 'test-service' (svc-12345)... +Branch ID: br-12345 +Branch is being created. Use 'tiger branch get br-12345' to check branch status. +`, + }, + { + name: "password storage none", + args: []string{"branch", "create", "svc-12345", "--no-wait", "--password-storage", "none", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(baseReq, created), + wantStdout: sampleBranchEnv, + wantStderr: `Creating branch from service 'test-service' (svc-12345)... +Branch ID: br-12345 +Password not saved (--password-storage=none). Make sure to store it securely. +Branch is being created. Use 'tiger branch get br-12345' to check branch status. +`, + }, + { + name: "custom name and compute", + args: []string{"branch", "create", "svc-12345", "--name", "ci-4821", "--cpu", "2000", "--memory", "16", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(api.BranchCreate{ + Name: new("ci-4821"), + CPUMillis: new(2000), + MemoryGbs: new(16), + }, created), + wantStdout: sampleBranchEnv, + wantStderr: noWaitStderr("Creating branch 'ci-4821' from service 'test-service' (svc-12345)..."), + }, + { + name: "iops", + args: []string{"branch", "create", "svc-12345", "--iops", "16000", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(api.BranchCreate{Iops: new(16000)}, created), + wantStdout: sampleBranchEnv, + wantStderr: defaultNoWaitStderr, + }, + { + name: "memory alone sets the matching cpu", + args: []string{"branch", "create", "svc-12345", "--memory", "4", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(api.BranchCreate{ + CPUMillis: new(500), + MemoryGbs: new(4), + }, created), + wantStdout: sampleBranchEnv, + wantStderr: defaultNoWaitStderr, + }, + { + name: "wait polls until ready", + synctest: true, + args: []string{"branch", "create", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + setupCreate(baseReq, created)(m) + expectGetBranch(m, sampleBranch()) + }, + wantStdout: sampleBranchTable, + wantStderr: `Creating branch from service 'test-service' (svc-12345)... +Branch ID: br-12345 +Password saved to system keyring +Waiting for branch to be ready (timeout: 10m0s)... +⢎ Branch status: QUEUED +Branch is ready. +Connect with: tiger db psql br-12345 +`, + }, + { + name: "wait timeout", + synctest: true, + args: []string{"branch", "create", "svc-12345", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + setupCreate(baseReq, created)(m) + queued := sampleBranch(branchStatus(api.DeployStatusQUEUED)) + m.EXPECT().GetBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.GetBranchResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &queued, + }, nil).AnyTimes() + }, + wantErr: "wait timeout reached after 10m0s - branch may still be provisioning", + wantStdout: sampleBranchEnv, + wantStderr: `Creating branch from service 'test-service' (svc-12345)... +Branch ID: br-12345 +Password saved to system keyring +Waiting for branch to be ready (timeout: 10m0s)... +⢎ Branch status: QUEUED +Error: wait timeout reached after 10m0s - branch may still be provisioning +`, + checks: []checkFunc{checkExitCode(common.ExitTimeout)}, + }, + { + // The initial password is saved but never output. + name: "json", + args: []string{"branch", "create", "svc-12345", "-o", "json"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(baseReq, sampleBranch(func(b *api.Branch) { + b.InitialPassword = new("branch-pw") + })), + wantStdout: sampleBranchJSON, + wantStderr: `Creating branch from service 'test-service' (svc-12345)... +Branch ID: br-12345 +Password saved to system keyring +Waiting for branch to be ready (timeout: 10m0s)... +Branch is ready. +Connect with: tiger db psql br-12345 +`, + }, + { + name: "default service from config", + args: []string{"branch", "create", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + mock: setupCreate(baseReq, created), + wantStdout: sampleBranchEnv, + wantStderr: defaultNoWaitStderr, + checks: []checkFunc{checkDefaultService("svc-12345")}, + }, + { + name: "service branch is the same command", + args: []string{"service", "branch", "svc-12345", "--no-wait", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: setupCreate(baseReq, created), + wantStdout: sampleBranchEnv, + wantStderr: defaultNoWaitStderr, + checks: []checkFunc{checkStoredPassword("br-12345", "branch-pw"), checkDefaultService("")}, + }, + }) +} diff --git a/internal/cmd/branch_delete.go b/internal/cmd/branch_delete.go new file mode 100644 index 00000000..60f44d2a --- /dev/null +++ b/internal/cmd/branch_delete.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// buildBranchDeleteCmd creates the delete subcommand +func buildBranchDeleteCmd(app *common.App) *cobra.Command { + var confirm bool + + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete a branch", + Long: `Delete a branch permanently, along with any changes made on it. The parent +service is not affected. + +The branch can be given by ID or name. + +This operation is irreversible. By default, you will be prompted to type the +branch ID to confirm deletion — the ID, not the name — unless you use the +--confirm flag. + +Note for AI agents: Always confirm with the user before performing this destructive operation.`, + Example: ` # Delete a branch (with confirmation prompt) + tiger branch delete br-12345 + + # Delete a branch without confirmation prompt + tiger branch delete br-12345 --confirm`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: branchRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + branchRef, err := getBranchRef(args) + if err != nil { + return err + } + + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + // Gated here, ahead of the confirmation prompt, so read-only mode + // refuses without first asking the user to type the branch ID. + branch, err := resolveBranchForWrite(cmd.Context(), cfg, client, projectID, branchRef) + if err != nil { + return err + } + + if !confirm { + if !util.IsTerminal(cmd.InOrStdin()) || !util.IsTerminal(cmd.ErrOrStderr()) { + return fmt.Errorf("TTY not detected - cannot prompt for confirmation. Use --confirm to skip the prompt") + } + cmd.PrintErrf("Are you sure you want to delete branch %s? This operation cannot be undone.\n", branchLabel(*branch)) + cmd.PrintErrf("Type the branch ID '%s' to confirm: ", branch.ID) + confirmation, err := util.ReadLine(cmd.Context(), cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if confirmation != branch.ID { + cmd.PrintErrln("Delete operation cancelled.") + return nil + } + } + + resp, err := client.DeleteBranchWithResponse(cmd.Context(), projectID, branch.ServiceID, branch.ID) + if err != nil { + return fmt.Errorf("failed to delete branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + cmd.PrintErrf("Branch %s deleted.\n", branchLabel(*branch)) + return nil + }, + } + + cmd.Flags().BoolVar(&confirm, "confirm", false, "Skip confirmation prompt (AI agents must confirm with user first)") + + return cmd +} diff --git a/internal/cmd/branch_delete_test.go b/internal/cmd/branch_delete_test.go new file mode 100644 index 00000000..7da31432 --- /dev/null +++ b/internal/cmd/branch_delete_test.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchDeleteCmd(t *testing.T) { + setupDelete := func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().DeleteBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.DeleteBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + } + getOnly := func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + } + prompt := "Are you sure you want to delete branch 'test-branch' (br-12345)? This operation cannot be undone.\n" + + "Type the branch ID 'br-12345' to confirm: " + + runCmdTests(t, []cmdTest{ + { + // No default fallback: service_id only ever holds a service ID. + name: "missing branch id", + args: []string{"branch", "delete"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + wantErr: "branch name or ID is required", + }, + { + name: "not logged in", + args: []string{"branch", "delete", "br-12345", "--confirm"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + name: "read-only all refuses", + args: []string{"branch", "delete", "br-12345", "--confirm"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + // A branch always counts as DEV. + name: "read-only prod allows", + args: []string{"branch", "delete", "br-12345", "--confirm"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "prod"})}, + mock: setupDelete, + wantStderr: "Branch 'test-branch' (br-12345) deleted.\n", + }, + { + name: "branch not found", + args: []string{"branch", "delete", "br-12345", "--confirm"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranchStatus(m, "br-12345", http.StatusNotFound, &api.ClientError{Message: new("branch not found")}) + }, + wantErr: "branch 'br-12345' not found", + checks: []checkFunc{checkExitCode(common.ExitServiceNotFound)}, + }, + { + name: "non-TTY without confirm", + args: []string{"branch", "delete", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: getOnly, + wantErr: "TTY not detected - cannot prompt for confirmation. Use --confirm to skip the prompt", + }, + { + name: "confirmation mismatch", + args: []string{"branch", "delete", "br-12345"}, + opts: []runOption{withExperimental(), withIsTerminal(true), withStdin("test-branch\n")}, + mock: getOnly, + wantStderr: prompt + "Delete operation cancelled.\n", + }, + { + name: "confirmation match", + args: []string{"branch", "delete", "br-12345"}, + opts: []runOption{withExperimental(), withIsTerminal(true), withStdin("br-12345\n")}, + mock: setupDelete, + wantStderr: prompt + "Branch 'test-branch' (br-12345) deleted.\n", + }, + { + name: "network error", + args: []string{"branch", "delete", "br-12345", "--confirm"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().DeleteBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to delete branch: connection refused", + }, + { + name: "API error", + args: []string{"branch", "delete", "br-12345", "--confirm"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().DeleteBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.DeleteBranchResponse{ + HTTPResponse: httpResponse(http.StatusBadRequest), + JSON4XX: &api.ClientError{Message: new("branch is busy")}, + }, nil) + }, + wantErr: "branch is busy", + checks: []checkFunc{checkExitCode(common.ExitInvalidParameters)}, + }, + { + name: "rm alias", + args: []string{"branch", "rm", "br-12345", "--confirm"}, + opts: []runOption{withExperimental()}, + mock: setupDelete, + wantStderr: "Branch 'test-branch' (br-12345) deleted.\n", + }, + }) +} diff --git a/internal/cmd/branch_get.go b/internal/cmd/branch_get.go new file mode 100644 index 00000000..af3ab543 --- /dev/null +++ b/internal/cmd/branch_get.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" +) + +// buildBranchGetCmd creates the get subcommand +func buildBranchGetCmd(app *common.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Aliases: []string{"describe", "show"}, + Short: "Show detailed information about a branch", + Long: `Show detailed information about a branch, including its parent service, status, +compute, and connection details. + +The branch can be given by ID or name.`, + Example: ` # Get a branch's details + tiger branch get br-12345 + + # Get branch details in JSON format + tiger branch get br-12345 --output json`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: branchRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + branchRef, err := getBranchRef(args) + if err != nil { + return err + } + + branch, err := resolveBranch(cmd.Context(), client, projectID, branchRef) + if err != nil { + return err + } + + // The branch carries only its parent's ID, so the name takes a + // second fetch. The rest of the output doesn't depend on it. + var opts branchOutputOptions + if parent, err := common.GetService(cmd.Context(), client, projectID, branch.ServiceID); err != nil { + cmd.PrintErrf("Warning: failed to get parent service: %v\n", err) + } else { + opts.ServiceName = parent.Name + } + + return outputBranch(cmd, cfg, *branch, opts) + }, + } + + cmd.Flags().VarP(new(outputWithEnvFlag), "output", "o", "Output format (json, yaml, env, table)") + registerFlagCompletion(cmd, "output", outputCompletion("env")) + + return cmd +} diff --git a/internal/cmd/branch_get_test.go b/internal/cmd/branch_get_test.go new file mode 100644 index 00000000..dd73c6ae --- /dev/null +++ b/internal/cmd/branch_get_test.go @@ -0,0 +1,215 @@ +package cmd + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchGetCmd(t *testing.T) { + getSample := func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + expectGetService(m, "svc-12345", sampleService(fluidPlatform)) + } + + runCmdTests(t, []cmdTest{ + { + name: "not logged in", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + // No default fallback: service_id only ever holds a service ID. + name: "missing branch id", + args: []string{"branch", "get"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + wantErr: "branch name or ID is required", + }, + { + name: "network error", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveError(m, "br-12345", branchType, errors.New("connection refused")) + }, + wantErr: "failed to resolve branch 'br-12345': connection refused", + }, + { + name: "branch not found", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranchStatus(m, "br-12345", http.StatusNotFound, &api.ClientError{Message: new("branch not found")}) + }, + wantErr: "branch 'br-12345' not found", + checks: []checkFunc{checkExitCode(common.ExitServiceNotFound)}, + }, + { + name: "ambiguous name refused", + args: []string{"branch", "get", "ci-4821"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranchStatus(m, "ci-4821", http.StatusBadRequest, &api.ClientError{Message: new("ref matches multiple branches")}) + }, + wantErr: "ref matches multiple branches\nRun 'tiger branch list' on the branch's service to find the ID you want", + checks: []checkFunc{checkExitCode(common.ExitInvalidParameters)}, + }, + { + name: "API error", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranchStatus(m, "br-12345", http.StatusForbidden, &api.ClientError{Message: new("forbidden")}) + }, + wantErr: "forbidden", + checks: []checkFunc{checkExitCode(common.ExitPermissionDenied)}, + }, + { + name: "nil response body", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranchStatus(m, "br-12345", http.StatusOK, nil) + }, + wantErr: "empty response from API", + }, + { + name: "table", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: getSample, + wantStdout: sampleBranchTable, + }, + { + name: "json", + args: []string{"branch", "get", "br-12345", "-o", "json"}, + opts: []runOption{withExperimental()}, + mock: getSample, + wantStdout: sampleBranchJSON, + }, + { + name: "yaml", + args: []string{"branch", "get", "br-12345", "-o", "yaml"}, + opts: []runOption{withExperimental()}, + mock: getSample, + wantStdout: `connection_string: postgresql://tsdbadmin@br-12345.test-project-123.branch.tsdb.cloud.timescale.com:36956/tsdb?sslmode=require +cpu_millis: 250 +created: "2025-01-16T09:00:00Z" +database: tsdb +endpoint: + host: br-12345.test-project-123.branch.tsdb.cloud.timescale.com + port: 36956 +host: br-12345.test-project-123.branch.tsdb.cloud.timescale.com +id: br-12345 +memory_gbs: 2 +name: test-branch +port: 36956 +project_id: test-project-123 +role: tsdbadmin +service_id: svc-12345 +service_name: test-service +status: READY +`, + }, + { + // The parent's name is the only thing the second fetch adds, so + // without it the branch is still shown, by parent ID. + name: "parent fetch fails", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().GetServiceWithResponse(validCtx, testProjectID, "svc-12345"). + Return(nil, errors.New("connection refused")) + }, + wantStdout: strings.Replace(sampleBranchTable, + "│ Parent Service │ test-service (svc-12345) ", + "│ Parent Service │ svc-12345 ", 1), + wantStderr: "Warning: failed to get parent service: failed to fetch service details: connection refused\n", + }, + { + name: "env", + args: []string{"branch", "get", "br-12345", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: getSample, + wantStdout: sampleBranchEnv, + }, + { + // A branch counts as DEV, so only read_only=all makes its + // connection string read-only. + name: "read-only all makes the connection string read-only", + args: []string{"branch", "get", "br-12345", "-o", "json"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + mock: getSample, + wantStdout: `{ + "cpu_millis": 250, + "created": "2025-01-16T09:00:00Z", + "endpoint": { + "host": "br-12345.test-project-123.branch.tsdb.cloud.timescale.com", + "port": 36956 + }, + "id": "br-12345", + "memory_gbs": 2, + "name": "test-branch", + "project_id": "test-project-123", + "service_id": "svc-12345", + "status": "READY", + "service_name": "test-service", + "role": "tsdbadmin", + "host": "br-12345.test-project-123.branch.tsdb.cloud.timescale.com", + "port": 36956, + "database": "tsdb", + "connection_string": "postgresql://tsdbadmin@br-12345.test-project-123.branch.tsdb.cloud.timescale.com:36956/tsdb?sslmode=require\u0026options=-c%20tsdb_admin.read_only_connection%3Dtrue" +} +`, + }, + { + // No port is guessed, so without one there's no endpoint to show + // or connect to. + name: "endpoint without a port", + args: []string{"branch", "get", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch(func(b *api.Branch) { b.Endpoint.Port = nil })) + expectGetService(m, "svc-12345", sampleService(fluidPlatform)) + }, + wantStdout: `┌────────────────┬──────────────────────────┐ +│ PROPERTY │ VALUE │ +├────────────────┼──────────────────────────┤ +│ Branch ID │ br-12345 │ +│ Name │ test-branch │ +│ Parent Service │ test-service (svc-12345) │ +│ Status │ READY │ +│ CPU │ 0.25 cores (250m) │ +│ Memory │ 2 GB │ +│ Created │ 2025-01-16 09:00:00 UTC │ +└────────────────┴──────────────────────────┘ +`, + wantStderr: "Warning: failed to get connection details: endpoint port not available\n", + }, + { + name: "by name", + args: []string{"branch", "get", "test-branch", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "test-branch", sampleBranch()) + expectGetService(m, "svc-12345", sampleService(fluidPlatform)) + }, + wantStdout: sampleBranchEnv, + }, + { + name: "describe alias", + args: []string{"branch", "describe", "br-12345", "-o", "env"}, + opts: []runOption{withExperimental()}, + mock: getSample, + wantStdout: sampleBranchEnv, + }, + }) +} diff --git a/internal/cmd/branch_list.go b/internal/cmd/branch_list.go new file mode 100644 index 00000000..4c9e8dfb --- /dev/null +++ b/internal/cmd/branch_list.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "io" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/config" + "github.com/timescale/tiger-cli/internal/util" +) + +// buildBranchListCmd creates the list subcommand +func buildBranchListCmd(app *common.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "list [service-name-or-id]", + Aliases: []string{"ls"}, + Short: "List the branches of a service", + Long: `List the branches of a database service. + +The service can be given by ID or name as an argument, or will use the default +service from your configuration.`, + Example: ` # List the default service's branches + tiger branch list + + # List a specific service's branches + tiger branch list svc-12345`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: serviceRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + serviceRef, err := getServiceRef(cmd, cfg, args) + if err != nil { + return err + } + service, err := resolveService(cmd.Context(), client, projectID, serviceRef) + if err != nil { + return err + } + + branches, err := common.ListBranches(cmd.Context(), client, projectID, service.ServiceID) + if err != nil { + return err + } + + return outputBranches(cmd, cfg, *service, branches) + }, + } + + cmd.Flags().VarP(new(outputFlag), "output", "o", "Output format (json, yaml, table)") + registerFlagCompletion(cmd, "output", outputCompletion()) + + return cmd +} + +// outputBranches outputs the branches of service in the configured output +// format. +func outputBranches(cmd *cobra.Command, cfg *config.Config, service api.Service, branches []api.Branch) error { + opts := branchOutputOptions{ServiceName: service.Name} + prepared := make([]OutputBranch, len(branches)) + for i, branch := range branches { + prepared[i] = prepareBranchForOutput(cmd, cfg, branch, opts) + } + + outputWriter := cmd.OutOrStdout() + switch strings.ToLower(cfg.Output) { + case "json": + return util.SerializeToJSON(outputWriter, prepared) + case "yaml": + return util.SerializeToYAML(outputWriter, prepared) + default: // table format (default) + if len(prepared) == 0 { + cmd.PrintErrf("No branches found for service %s.\n", serviceLabel(service)) + return nil + } + return outputBranchesTable(outputWriter, prepared) + } +} + +// outputBranchesTable outputs branches in a formatted table. +func outputBranchesTable(output io.Writer, branches []OutputBranch) error { + table := tablewriter.NewWriter(output) + table.Header("BRANCH ID", "NAME", "STATUS", "CREATED") + + for _, branch := range branches { + table.Append( + branch.ID, + branch.Name, + string(branch.Status), + branch.Created.Format("2006-01-02 15:04"), + ) + } + + return table.Render() +} diff --git a/internal/cmd/branch_list_test.go b/internal/cmd/branch_list_test.go new file mode 100644 index 00000000..9eac788b --- /dev/null +++ b/internal/cmd/branch_list_test.go @@ -0,0 +1,169 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchListCmd(t *testing.T) { + paused := sampleBranch(func(b *api.Branch) { + b.ID = "br-67890" + b.Name = "feature/user-auth" + b.Status = api.DeployStatusPAUSED + }) + expectList := func(branches ...api.Branch) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRef(m, "svc-12345", sampleService()) + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "svc-12345", nil). + Return(&api.GetBranchesResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &branches, + }, nil) + } + } + + runCmdTests(t, []cmdTest{ + { + name: "not logged in", + args: []string{"branch", "list", "svc-12345"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + name: "missing service id", + args: []string{"branch", "list"}, + opts: []runOption{withExperimental()}, + wantErr: "service name or ID is required. Provide it as an argument or set a default with 'tiger config set service_id '", + }, + { + name: "service not found", + args: []string{"branch", "list", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRefNotFound(m, "svc-12345") + }, + wantErr: "service 'svc-12345' not found", + checks: []checkFunc{checkExitCode(common.ExitServiceNotFound)}, + }, + { + name: "network error", + args: []string{"branch", "list", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRefID(m, "svc-12345") + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "svc-12345", nil). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to list branches: connection refused", + }, + { + name: "API error", + args: []string{"branch", "list", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRefID(m, "svc-12345") + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "svc-12345", nil). + Return(&api.GetBranchesResponse{ + HTTPResponse: httpResponse(http.StatusForbidden), + JSON4XX: &api.Error{Message: new("forbidden")}, + }, nil) + }, + wantErr: "forbidden", + checks: []checkFunc{checkExitCode(common.ExitPermissionDenied)}, + }, + { + name: "nil response body", + args: []string{"branch", "list", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveRefID(m, "svc-12345") + m.EXPECT().GetBranchesWithResponse(validCtx, testProjectID, "svc-12345", nil). + Return(&api.GetBranchesResponse{HTTPResponse: httpResponse(http.StatusOK)}, nil) + }, + wantErr: "empty response from API", + }, + { + name: "no branches", + args: []string{"branch", "list", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: expectList(), + wantStderr: "No branches found for service 'test-service' (svc-12345).\n", + }, + { + // Structured output is always structured, so an empty list is + // still a list rather than a message. + name: "no branches json", + args: []string{"branch", "list", "svc-12345", "-o", "json"}, + opts: []runOption{withExperimental()}, + mock: expectList(), + wantStdout: "[]\n", + }, + { + name: "no branches yaml", + args: []string{"branch", "list", "svc-12345", "-o", "yaml"}, + opts: []runOption{withExperimental()}, + mock: expectList(), + wantStdout: "[]\n", + }, + { + name: "table", + args: []string{"branch", "list", "svc-12345"}, + opts: []runOption{withExperimental()}, + mock: expectList(sampleBranch(), paused), + wantStdout: `┌───────────┬───────────────────┬────────┬──────────────────┐ +│ BRANCH ID │ NAME │ STATUS │ CREATED │ +├───────────┼───────────────────┼────────┼──────────────────┤ +│ br-12345 │ test-branch │ READY │ 2025-01-16 09:00 │ +│ br-67890 │ feature/user-auth │ PAUSED │ 2025-01-16 09:00 │ +└───────────┴───────────────────┴────────┴──────────────────┘ +`, + }, + { + name: "json", + args: []string{"branch", "list", "svc-12345", "-o", "json"}, + opts: []runOption{withExperimental()}, + mock: expectList(sampleBranch()), + wantStdout: `[ + { + "cpu_millis": 250, + "created": "2025-01-16T09:00:00Z", + "endpoint": { + "host": "br-12345.test-project-123.branch.tsdb.cloud.timescale.com", + "port": 36956 + }, + "id": "br-12345", + "memory_gbs": 2, + "name": "test-branch", + "project_id": "test-project-123", + "service_id": "svc-12345", + "status": "READY", + "service_name": "test-service", + "role": "tsdbadmin", + "host": "br-12345.test-project-123.branch.tsdb.cloud.timescale.com", + "port": 36956, + "database": "tsdb", + "connection_string": "postgresql://tsdbadmin@br-12345.test-project-123.branch.tsdb.cloud.timescale.com:36956/tsdb?sslmode=require" + } +] +`, + }, + { + name: "default service from config", + args: []string{"branch", "list"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + mock: expectList(sampleBranch()), + wantStdout: `┌───────────┬─────────────┬────────┬──────────────────┐ +│ BRANCH ID │ NAME │ STATUS │ CREATED │ +├───────────┼─────────────┼────────┼──────────────────┤ +│ br-12345 │ test-branch │ READY │ 2025-01-16 09:00 │ +└───────────┴─────────────┴────────┴──────────────────┘ +`, + }, + }) +} diff --git a/internal/cmd/branch_rename.go b/internal/cmd/branch_rename.go new file mode 100644 index 00000000..24a7644a --- /dev/null +++ b/internal/cmd/branch_rename.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" +) + +// buildBranchRenameCmd creates the rename subcommand +func buildBranchRenameCmd(app *common.App) *cobra.Command { + cmd := &cobra.Command{ + Use: "rename ", + Short: "Rename a branch", + Long: `Rename a branch. + +Only the branch's name changes: its ID, endpoint, and data are untouched, so +existing connections and connection strings keep working. + +Both the branch and the new name are required. The branch can be given by ID +or by its current name.`, + Example: ` # Rename a branch + tiger branch rename br-12345 feature/rbac`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: branchRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + branchRef, newName := args[0], strings.TrimSpace(args[1]) + + if branchRef == "" { + return errBranchRequired + } + if newName == "" { + return errors.New("new name cannot be empty") + } + + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + branch, err := resolveBranchForWrite(cmd.Context(), cfg, client, projectID, branchRef) + if err != nil { + return err + } + + resp, err := client.RenameBranchWithResponse(cmd.Context(), projectID, branch.ServiceID, branch.ID, api.BranchRename{Name: newName}) + if err != nil { + return fmt.Errorf("failed to rename branch: %w", err) + } + if resp.StatusCode() != http.StatusOK { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON200 == nil { + return errors.New("empty response from API") + } + renamed := *resp.JSON200 + + cmd.Printf("Renamed branch %s to '%s'.\n", branchLabel(*branch), renamed.Name) + return nil + }, + } + + return cmd +} diff --git a/internal/cmd/branch_rename_test.go b/internal/cmd/branch_rename_test.go new file mode 100644 index 00000000..c427235e --- /dev/null +++ b/internal/cmd/branch_rename_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchRenameCmd(t *testing.T) { + setupRename := func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + renamed := sampleBranch(func(b *api.Branch) { b.Name = "feature/rbac" }) + m.EXPECT().RenameBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.BranchRename{Name: "feature/rbac"}). + Return(&api.RenameBranchResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &renamed, + }, nil) + } + + runCmdTests(t, []cmdTest{ + { + name: "missing new name", + args: []string{"branch", "rename", "br-12345"}, + opts: []runOption{withExperimental()}, + wantErr: "accepts 2 arg(s), received 1", + }, + { + name: "empty new name", + args: []string{"branch", "rename", "br-12345", " "}, + opts: []runOption{withExperimental()}, + wantErr: "new name cannot be empty", + }, + { + name: "not logged in", + args: []string{"branch", "rename", "br-12345", "feature/rbac"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + name: "read-only all refuses", + args: []string{"branch", "rename", "br-12345", "feature/rbac"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + args: []string{"branch", "rename", "br-12345", "feature/rbac"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "prod"})}, + mock: setupRename, + wantStdout: "Renamed branch 'test-branch' (br-12345) to 'feature/rbac'.\n", + }, + { + name: "network error", + args: []string{"branch", "rename", "br-12345", "feature/rbac"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().RenameBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.BranchRename{Name: "feature/rbac"}). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to rename branch: connection refused", + }, + { + name: "duplicate name", + args: []string{"branch", "rename", "br-12345", "feature/rbac"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().RenameBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.BranchRename{Name: "feature/rbac"}). + Return(&api.RenameBranchResponse{ + HTTPResponse: httpResponse(http.StatusConflict), + JSON4XX: &api.ClientError{Message: new("a branch named feature/rbac already exists")}, + }, nil) + }, + wantErr: "a branch named feature/rbac already exists", + }, + { + name: "nil response body", + args: []string{"branch", "rename", "br-12345", "feature/rbac"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().RenameBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.BranchRename{Name: "feature/rbac"}). + Return(&api.RenameBranchResponse{HTTPResponse: httpResponse(http.StatusOK)}, nil) + }, + wantErr: "empty response from API", + }, + { + name: "success", + args: []string{"branch", "rename", "br-12345", "feature/rbac"}, + opts: []runOption{withExperimental()}, + mock: setupRename, + wantStdout: "Renamed branch 'test-branch' (br-12345) to 'feature/rbac'.\n", + }, + }) +} diff --git a/internal/cmd/branch_resize.go b/internal/cmd/branch_resize.go new file mode 100644 index 00000000..e8ce20d5 --- /dev/null +++ b/internal/cmd/branch_resize.go @@ -0,0 +1,152 @@ +package cmd + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" +) + +// buildBranchResizeCmd creates the resize subcommand +func buildBranchResizeCmd(app *common.App) *cobra.Command { + var cpu string + var memory string + var iops int + var noWait bool + var waitTimeout time.Duration + + cmd := &cobra.Command{ + Use: "resize ", + Short: "Resize a branch", + Long: `Resize a branch by changing its CPU, memory, and provisioned IOPS allocation. +A branch's storage is shared with its parent service and isn't resized. + +The branch can be given by ID or name. It may be temporarily unavailable +during the resize. + +` + branchCPUMemoryHelp + ` + +Note: You can specify both CPU and memory together, or specify only one (the +other will be automatically configured). + +The allowed --iops values depend on your plan and the branch's CPU/memory +allocation. See the Tiger Data documentation for details. If only --iops is +given, the branch keeps its current CPU and memory.`, + Example: ` # Resize a branch to 2 CPU cores and 16GB memory + tiger branch resize br-12345 --cpu 2000 --memory 16 + + # Resize a branch using only CPU (memory will be auto-configured) + tiger branch resize br-12345 --cpu 2000 + + # Change only the provisioned IOPS + tiger branch resize br-12345 --iops 16000 + + # Resize without waiting for completion + tiger branch resize br-12345 --cpu 2000 --no-wait`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: branchRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + branchRef, err := getBranchRef(args) + if err != nil { + return err + } + + iopsSet := cmd.Flags().Changed("iops") + if cpu == "" && memory == "" && !iopsSet { + return errors.New("must specify at least one of --cpu, --memory, or --iops") + } + cpuMemoryCfg, err := common.FluidCPUMemoryConfigs().ValidateAndNormalize(cpu, memory) + if err != nil { + return err + } + + resolved, err := resolveBranchForWrite(cmd.Context(), cfg, client, projectID, branchRef) + if err != nil { + return err + } + + // An IOPS-only resize omits CPU and memory, which the API leaves + // unchanged. + resizeReq := api.BranchResizeInput{ + CPUMillis: cpuMemoryCfg.CPUMillisString(), + MemoryGbs: cpuMemoryCfg.MemoryGBsString(), + } + var targets []string + if cpuMemoryCfg != nil { + targets = append(targets, cpuMemoryCfg.String()) + } + if iopsSet { + resizeReq.Iops = &iops + targets = append(targets, fmt.Sprintf("%d IOPS", iops)) + } + target := strings.Join(targets, ", ") + + cmd.PrintErrf("Resizing branch %s to %s...\n", branchLabel(*resolved), target) + + resp, err := client.ResizeBranchWithResponse(cmd.Context(), projectID, resolved.ServiceID, resolved.ID, resizeReq) + if err != nil { + return fmt.Errorf("failed to resize branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + cmd.PrintErrf("Resize request accepted for branch %s.\n", branchLabel(*resolved)) + + if noWait { + cmd.PrintErrln("Use 'tiger branch get' to check branch status.") + return nil + } + + cmd.PrintErrf("Waiting for resize to complete (timeout: %v)...\n", waitTimeout) + if err := common.WaitForBranch(cmd.Context(), common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: resolved.ServiceID, + BranchID: resolved.ID, + Branch: &branch, + TargetStatus: api.DeployStatusREADY, + Input: cmd.InOrStdin(), + Output: cmd.ErrOrStderr(), + Timeout: waitTimeout, + TimeoutMsg: "branch may still be resizing", + }); err != nil { + // Return error for sake of exit code, but silence since we already output it + cmd.PrintErrf("Error: %s\n", err) + cmd.SilenceErrors = true + return err + } + + cmd.PrintErrf("Branch resized to %s.\n", target) + return nil + }, + } + + cmd.Flags().StringVar(&cpu, "cpu", "", "CPU allocation in millicores") + cmd.Flags().StringVar(&memory, "memory", "", "Memory allocation in gigabytes") + cmd.Flags().IntVar(&iops, "iops", 0, "Provisioned IOPS") + cmd.Flags().BoolVar(&noWait, "no-wait", false, "Don't wait for resize operation to complete") + cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 10*time.Minute, "Maximum time to wait for operation to complete") + + registerFlagCompletion(cmd, "cpu", branchCPUCompletion) + registerFlagCompletion(cmd, "memory", branchMemoryCompletion) + registerFlagCompletion(cmd, "iops", iopsCompletion) + + return cmd +} diff --git a/internal/cmd/branch_resize_test.go b/internal/cmd/branch_resize_test.go new file mode 100644 index 00000000..e61f71b1 --- /dev/null +++ b/internal/cmd/branch_resize_test.go @@ -0,0 +1,138 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchResizeCmd(t *testing.T) { + setupResize := func(req api.BranchResizeInput, status api.DeployStatus) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + branch := sampleBranch(branchStatus(status)) + m.EXPECT().ResizeBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", req). + Return(&api.ResizeBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + req2000 := api.BranchResizeInput{CPUMillis: new("2000"), MemoryGbs: new("16")} + noWaitStderr := "Resizing branch 'test-branch' (br-12345) to 2 CPU/16 GB...\n" + + "Resize request accepted for branch 'test-branch' (br-12345).\n" + + "Use 'tiger branch get' to check branch status.\n" + + runCmdTests(t, []cmdTest{ + { + name: "not logged in", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + // No default fallback: service_id only ever holds a service ID. + name: "missing branch id", + args: []string{"branch", "resize", "--cpu", "2000"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + wantErr: "branch name or ID is required", + }, + { + name: "missing cpu and memory", + args: []string{"branch", "resize", "br-12345"}, + opts: []runOption{withExperimental()}, + wantErr: "must specify at least one of --cpu, --memory, or --iops", + }, + { + name: "invalid cpu/memory combination", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000", "--memory", "8"}, + opts: []runOption{withExperimental()}, + wantErr: "invalid CPU/Memory combination. Allowed combinations: 0.25 CPU/2 GB, 0.5 CPU/4 GB, 1 CPU/8 GB, 2 CPU/16 GB, 4 CPU/32 GB, 8 CPU/64 GB, 16 CPU/128 GB, 32 CPU/256 GB", + }, + { + name: "read-only all refuses", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000", "--no-wait"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "prod"})}, + mock: setupResize(req2000, api.DeployStatusCONFIGURING), + wantStderr: noWaitStderr, + }, + { + name: "network error", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().ResizeBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", req2000). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to resize branch: connection refused", + wantStderr: "Resizing branch 'test-branch' (br-12345) to 2 CPU/16 GB...\n" + + "Error: failed to resize branch: connection refused\n", + }, + { + name: "nil response body", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().ResizeBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", req2000). + Return(&api.ResizeBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + }, + wantErr: "empty response from API", + wantStderr: "Resizing branch 'test-branch' (br-12345) to 2 CPU/16 GB...\n" + + "Error: empty response from API\n", + }, + { + // CPU and memory are left out, so the API keeps the current ones. + name: "iops only omits cpu and memory", + args: []string{"branch", "resize", "br-12345", "--iops", "16000", "--no-wait"}, + opts: []runOption{withExperimental()}, + mock: setupResize(api.BranchResizeInput{Iops: new(16000)}, api.DeployStatusCONFIGURING), + wantStderr: "Resizing branch 'test-branch' (br-12345) to 16000 IOPS...\n" + + "Resize request accepted for branch 'test-branch' (br-12345).\n" + + "Use 'tiger branch get' to check branch status.\n", + }, + { + name: "cpu and iops", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000", "--iops", "16000", "--no-wait"}, + opts: []runOption{withExperimental()}, + mock: setupResize(api.BranchResizeInput{CPUMillis: new("2000"), MemoryGbs: new("16"), Iops: new(16000)}, api.DeployStatusCONFIGURING), + wantStderr: "Resizing branch 'test-branch' (br-12345) to 2 CPU/16 GB, 16000 IOPS...\n" + + "Resize request accepted for branch 'test-branch' (br-12345).\n" + + "Use 'tiger branch get' to check branch status.\n", + }, + { + name: "no-wait with cpu only", + args: []string{"branch", "resize", "br-12345", "--cpu", "2000", "--no-wait"}, + opts: []runOption{withExperimental()}, + mock: setupResize(req2000, api.DeployStatusCONFIGURING), + wantStderr: noWaitStderr, + }, + { + name: "wait polls until ready", + synctest: true, + args: []string{"branch", "resize", "br-12345", "--memory", "16"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + setupResize(req2000, api.DeployStatusCONFIGURING)(m) + expectGetBranch(m, sampleBranch()) + }, + wantStderr: "Resizing branch 'test-branch' (br-12345) to 2 CPU/16 GB...\n" + + "Resize request accepted for branch 'test-branch' (br-12345).\n" + + "Waiting for resize to complete (timeout: 10m0s)...\n" + + "⢎ Branch status: CONFIGURING\n" + + "Branch resized to 2 CPU/16 GB.\n", + }, + }) +} diff --git a/internal/cmd/branch_start.go b/internal/cmd/branch_start.go new file mode 100644 index 00000000..ee47af1b --- /dev/null +++ b/internal/cmd/branch_start.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" +) + +// buildBranchStartCmd creates the start subcommand +func buildBranchStartCmd(app *common.App) *cobra.Command { + var noWait bool + var waitTimeout time.Duration + + cmd := &cobra.Command{ + Use: "start ", + Aliases: []string{"resume"}, + Short: "Start a stopped branch", + Long: `Start a stopped branch. + +Starting a branch resumes charging for its compute. + +The branch can be given by ID or name.`, + Example: ` # Start a branch (waits for completion by default) + tiger branch start br-12345 + + # Start a branch without waiting for completion + tiger branch start br-12345 --no-wait`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: branchRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + branchRef, err := getBranchRef(args) + if err != nil { + return err + } + + resolved, err := resolveBranchForWrite(cmd.Context(), cfg, client, projectID, branchRef) + if err != nil { + return err + } + + resp, err := client.StartBranchWithResponse(cmd.Context(), projectID, resolved.ServiceID, resolved.ID) + if err != nil { + return fmt.Errorf("failed to start branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + cmd.PrintErrf("Start request accepted for branch %s.\n", branchLabel(*resolved)) + + if noWait { + cmd.PrintErrln("Use 'tiger branch get' to check branch status.") + return nil + } + + cmd.PrintErrf("Waiting for branch to start (timeout: %v)...\n", waitTimeout) + if err := common.WaitForBranch(cmd.Context(), common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: resolved.ServiceID, + BranchID: resolved.ID, + Branch: &branch, + TargetStatus: api.DeployStatusREADY, + Input: cmd.InOrStdin(), + Output: cmd.ErrOrStderr(), + Timeout: waitTimeout, + TimeoutMsg: "branch may still be starting", + }); err != nil { + // Return error for sake of exit code, but silence since we already output it + cmd.PrintErrf("Error: %s\n", err) + cmd.SilenceErrors = true + return err + } + + cmd.PrintErrln("Branch started.") + return nil + }, + } + + cmd.Flags().BoolVar(&noWait, "no-wait", false, "Don't wait for the operation to complete") + cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 10*time.Minute, "Maximum time to wait for operation to complete") + + return cmd +} diff --git a/internal/cmd/branch_start_test.go b/internal/cmd/branch_start_test.go new file mode 100644 index 00000000..fcf8e479 --- /dev/null +++ b/internal/cmd/branch_start_test.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchStartCmd(t *testing.T) { + setupStart := func(status api.DeployStatus) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch(branchStatus(api.DeployStatusPAUSED))) + branch := sampleBranch(branchStatus(status)) + m.EXPECT().StartBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.StartBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + noWaitStderr := "Start request accepted for branch 'test-branch' (br-12345).\n" + + "Use 'tiger branch get' to check branch status.\n" + + runCmdTests(t, []cmdTest{ + { + name: "not logged in", + args: []string{"branch", "start", "br-12345"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + // No default fallback: service_id only ever holds a service ID. + name: "missing branch id", + args: []string{"branch", "start"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + wantErr: "branch name or ID is required", + }, + { + name: "read-only all refuses", + args: []string{"branch", "start", "br-12345"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + args: []string{"branch", "start", "br-12345", "--no-wait"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "prod"})}, + mock: setupStart(api.DeployStatusRESUMING), + wantStderr: noWaitStderr, + }, + { + name: "network error", + args: []string{"branch", "start", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().StartBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to start branch: connection refused", + }, + { + name: "nil response body", + args: []string{"branch", "start", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().StartBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.StartBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + }, + wantErr: "empty response from API", + }, + { + name: "no-wait", + args: []string{"branch", "start", "br-12345", "--no-wait"}, + opts: []runOption{withExperimental()}, + mock: setupStart(api.DeployStatusRESUMING), + wantStderr: noWaitStderr, + }, + { + name: "wait polls until ready", + synctest: true, + args: []string{"branch", "start", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + setupStart(api.DeployStatusRESUMING)(m) + expectGetBranch(m, sampleBranch()) + }, + wantStderr: "Start request accepted for branch 'test-branch' (br-12345).\n" + + "Waiting for branch to start (timeout: 10m0s)...\n" + + "⢎ Branch status: RESUMING\n" + + "Branch started.\n", + }, + { + name: "resume alias", + args: []string{"branch", "resume", "br-12345", "--no-wait"}, + opts: []runOption{withExperimental()}, + mock: setupStart(api.DeployStatusRESUMING), + wantStderr: noWaitStderr, + }, + }) +} diff --git a/internal/cmd/branch_stop.go b/internal/cmd/branch_stop.go new file mode 100644 index 00000000..532764aa --- /dev/null +++ b/internal/cmd/branch_stop.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "fmt" + "net/http" + "time" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/common" +) + +// buildBranchStopCmd creates the stop subcommand +func buildBranchStopCmd(app *common.App) *cobra.Command { + var noWait bool + var waitTimeout time.Duration + + cmd := &cobra.Command{ + Use: "stop ", + Aliases: []string{"pause"}, + Short: "Stop a running branch", + Long: `Stop a running branch. + +A stopped branch no longer accepts connections and stops charging for compute, +but keeps the changes made on it, whose storage is still billed. + +The branch can be given by ID or name.`, + Example: ` # Stop a branch (waits for completion by default) + tiger branch stop br-12345 + + # Stop a branch without waiting for completion + tiger branch stop br-12345 --no-wait`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: branchRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + branchRef, err := getBranchRef(args) + if err != nil { + return err + } + + resolved, err := resolveBranchForWrite(cmd.Context(), cfg, client, projectID, branchRef) + if err != nil { + return err + } + + resp, err := client.StopBranchWithResponse(cmd.Context(), projectID, resolved.ServiceID, resolved.ID) + if err != nil { + return fmt.Errorf("failed to stop branch: %w", err) + } + if resp.StatusCode() != http.StatusAccepted { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON202 == nil { + return fmt.Errorf("empty response from API") + } + branch := *resp.JSON202 + + cmd.PrintErrf("Stop request accepted for branch %s.\n", branchLabel(*resolved)) + + if noWait { + cmd.PrintErrln("Use 'tiger branch get' to check branch status.") + return nil + } + + cmd.PrintErrf("Waiting for branch to stop (timeout: %v)...\n", waitTimeout) + if err := common.WaitForBranch(cmd.Context(), common.WaitForBranchArgs{ + Client: client, + ProjectID: projectID, + ServiceID: resolved.ServiceID, + BranchID: resolved.ID, + Branch: &branch, + TargetStatus: api.DeployStatusPAUSED, + Input: cmd.InOrStdin(), + Output: cmd.ErrOrStderr(), + Timeout: waitTimeout, + TimeoutMsg: "branch may still be stopping", + }); err != nil { + // Return error for sake of exit code, but silence since we already output it + cmd.PrintErrf("Error: %s\n", err) + cmd.SilenceErrors = true + return err + } + + cmd.PrintErrln("Branch stopped.") + return nil + }, + } + + cmd.Flags().BoolVar(&noWait, "no-wait", false, "Don't wait for the operation to complete") + cmd.Flags().DurationVar(&waitTimeout, "wait-timeout", 10*time.Minute, "Maximum time to wait for operation to complete") + + return cmd +} diff --git a/internal/cmd/branch_stop_test.go b/internal/cmd/branch_stop_test.go new file mode 100644 index 00000000..f4c1bba0 --- /dev/null +++ b/internal/cmd/branch_stop_test.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchStopCmd(t *testing.T) { + setupStop := func(status api.DeployStatus) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + branch := sampleBranch(branchStatus(status)) + m.EXPECT().StopBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.StopBranchResponse{ + HTTPResponse: httpResponse(http.StatusAccepted), + JSON202: &branch, + }, nil) + } + } + noWaitStderr := "Stop request accepted for branch 'test-branch' (br-12345).\n" + + "Use 'tiger branch get' to check branch status.\n" + + runCmdTests(t, []cmdTest{ + { + name: "not logged in", + args: []string{"branch", "stop", "br-12345"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + // No default fallback: service_id only ever holds a service ID. + name: "missing branch id", + args: []string{"branch", "stop"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + wantErr: "branch name or ID is required", + }, + { + name: "read-only all refuses", + args: []string{"branch", "stop", "br-12345"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + args: []string{"branch", "stop", "br-12345", "--no-wait"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "prod"})}, + mock: setupStop(api.DeployStatusPAUSING), + wantStderr: noWaitStderr, + }, + { + name: "network error", + args: []string{"branch", "stop", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().StopBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to stop branch: connection refused", + }, + { + name: "API error", + args: []string{"branch", "stop", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().StopBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.StopBranchResponse{ + HTTPResponse: httpResponse(http.StatusBadRequest), + JSON4XX: &api.ClientError{Message: new("branch is already stopped")}, + }, nil) + }, + wantErr: "branch is already stopped", + checks: []checkFunc{checkExitCode(common.ExitInvalidParameters)}, + }, + { + name: "nil response body", + args: []string{"branch", "stop", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().StopBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.StopBranchResponse{HTTPResponse: httpResponse(http.StatusAccepted)}, nil) + }, + wantErr: "empty response from API", + }, + { + name: "no-wait", + args: []string{"branch", "stop", "br-12345", "--no-wait"}, + opts: []runOption{withExperimental()}, + mock: setupStop(api.DeployStatusPAUSING), + wantStderr: noWaitStderr, + }, + { + name: "wait polls until paused", + synctest: true, + args: []string{"branch", "stop", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + setupStop(api.DeployStatusPAUSING)(m) + expectGetBranch(m, sampleBranch(branchStatus(api.DeployStatusPAUSED))) + }, + wantStderr: "Stop request accepted for branch 'test-branch' (br-12345).\n" + + "Waiting for branch to stop (timeout: 10m0s)...\n" + + "⢎ Branch status: PAUSING\n" + + "Branch stopped.\n", + }, + { + name: "wait timeout", + synctest: true, + args: []string{"branch", "stop", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + setupStop(api.DeployStatusPAUSING)(m) + pausing := sampleBranch(branchStatus(api.DeployStatusPAUSING)) + m.EXPECT().GetBranchWithResponse(validCtx, testProjectID, "svc-12345", "br-12345"). + Return(&api.GetBranchResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &pausing, + }, nil).AnyTimes() + }, + wantErr: "wait timeout reached after 10m0s - branch may still be stopping", + wantStderr: "Stop request accepted for branch 'test-branch' (br-12345).\n" + + "Waiting for branch to stop (timeout: 10m0s)...\n" + + "⢎ Branch status: PAUSING\n" + + "Error: wait timeout reached after 10m0s - branch may still be stopping\n", + checks: []checkFunc{checkExitCode(common.ExitTimeout)}, + }, + { + name: "pause alias", + args: []string{"branch", "pause", "br-12345", "--no-wait"}, + opts: []runOption{withExperimental()}, + mock: setupStop(api.DeployStatusPAUSING), + wantStderr: noWaitStderr, + }, + }) +} diff --git a/internal/cmd/branch_test.go b/internal/cmd/branch_test.go new file mode 100644 index 00000000..5b2c81f9 --- /dev/null +++ b/internal/cmd/branch_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "net/http" + "testing" + "time" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" +) + +// TestBranchExperimentalGate covers the registration gate for the branch +// commands: `tiger branch` and `tiger service branch` exist only behind the +// experimental env var. +func TestBranchExperimentalGate(t *testing.T) { + runCmdTests(t, []cmdTest{ + { + name: "branch unregistered by default", + args: []string{"branch", "list"}, + wantErr: `unknown command "branch" for "tiger"`, + wantStderr: "Error: unknown command \"branch\" for \"tiger\"\n" + + "Run 'tiger --help' for usage.\n", + }, + { + // Cobra treats an unknown name under a group as a stray argument + // and prints the group's help (see TestServiceExperimentalGate). + name: "service branch unregistered by default", + args: []string{"service", "branch"}, + wantStdout: matchHelp("Available Commands:", "branch"), + }, + { + name: "branch registered when experimental", + args: []string{"branch", "--help"}, + opts: []runOption{withExperimental()}, + wantStdout: matchHelp("Manage branches of Fluid Services."), + }, + { + name: "branches alias", + args: []string{"branches", "--help"}, + opts: []runOption{withExperimental()}, + wantStdout: matchHelp("Manage branches of Fluid Services."), + }, + { + name: "service branch registered when experimental", + args: []string{"service", "branch", "--help"}, + opts: []runOption{withExperimental()}, + wantStdout: matchHelp("tiger service branch svc-12345 --name ci-4821"), + }, + }) +} + +// withExperimental turns on the experimental gate, which every branch command +// sits behind. +func withExperimental() runOption { + return withEnv("TIGER_EXPERIMENTAL", "true") +} + +// sampleBranch returns a branch of sampleService with reasonable defaults. +// Use overrides to customize specific fields. +func sampleBranch(overrides ...func(*api.Branch)) api.Branch { + branch := api.Branch{ + ID: "br-12345", + ProjectID: testProjectID, + ServiceID: "svc-12345", + Name: "test-branch", + Status: api.DeployStatusREADY, + CPUMillis: 250, + MemoryGbs: 2, + Created: time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC), + Endpoint: &api.Endpoint{ + Host: new("br-12345.test-project-123.branch.tsdb.cloud.timescale.com"), + Port: new(36956), + }, + } + for _, o := range overrides { + o(&branch) + } + return branch +} + +// branchStatus returns a sampleBranch override setting the branch's status. +func branchStatus(status api.DeployStatus) func(*api.Branch) { + return func(b *api.Branch) { b.Status = status } +} + +// branchType is the resolve type every `tiger branch` command restricts its +// lookup to. +var branchType = new(api.ResolveTypeBRANCH) + +// expectResolveBranch registers resolving ref as a branch, answered with +// branch. +func expectResolveBranch(m *mocks.MockClientWithResponsesInterface, ref string, branch api.Branch) { + expectResolve(m, ref, branchType, branch) +} + +// expectResolveBranchStatus registers resolving ref as a branch, answered with +// status. A nil apiErr stands in for a response with no body. +func expectResolveBranchStatus(m *mocks.MockClientWithResponsesInterface, ref string, status int, apiErr *api.ClientError) { + expectResolveStatus(m, ref, branchType, status, apiErr) +} + +// expectGetBranch registers one fetch of branch, through its parent, as a +// wait loop's poll makes it. +func expectGetBranch(m *mocks.MockClientWithResponsesInterface, branch api.Branch) { + m.EXPECT().GetBranchWithResponse(validCtx, testProjectID, branch.ServiceID, branch.ID). + Return(&api.GetBranchResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &branch, + }, nil) +} + +// sampleBranchEnv is the env output of sampleBranch. +const sampleBranchEnv = `PGHOST=br-12345.test-project-123.branch.tsdb.cloud.timescale.com +PGPORT=36956 +PGDATABASE=tsdb +PGUSER=tsdbadmin +` + +// sampleBranchTable and sampleBranchJSON are the table and JSON output of +// sampleBranch. +const ( + sampleBranchTable = `┌───────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ PROPERTY │ VALUE │ +├───────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ Branch ID │ br-12345 │ +│ Name │ test-branch │ +│ Parent Service │ test-service (svc-12345) │ +│ Status │ READY │ +│ CPU │ 0.25 cores (250m) │ +│ Memory │ 2 GB │ +│ Direct Endpoint │ br-12345.test-project-123.branch.tsdb.cloud.timescale.com:36956 │ +│ Created │ 2025-01-16 09:00:00 UTC │ +│ Connection String │ postgresql://tsdbadmin@br-12345.test-project-123.branch.tsdb.cloud.timescale.com:36956/tsdb?sslmode=require │ +└───────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +` + sampleBranchJSON = `{ + "cpu_millis": 250, + "created": "2025-01-16T09:00:00Z", + "endpoint": { + "host": "br-12345.test-project-123.branch.tsdb.cloud.timescale.com", + "port": 36956 + }, + "id": "br-12345", + "memory_gbs": 2, + "name": "test-branch", + "project_id": "test-project-123", + "service_id": "svc-12345", + "status": "READY", + "service_name": "test-service", + "role": "tsdbadmin", + "host": "br-12345.test-project-123.branch.tsdb.cloud.timescale.com", + "port": 36956, + "database": "tsdb", + "connection_string": "postgresql://tsdbadmin@br-12345.test-project-123.branch.tsdb.cloud.timescale.com:36956/tsdb?sslmode=require" +} +` +) diff --git a/internal/cmd/branch_update_password.go b/internal/cmd/branch_update_password.go new file mode 100644 index 00000000..58a86b1e --- /dev/null +++ b/internal/cmd/branch_update_password.go @@ -0,0 +1,94 @@ +package cmd + +import ( + "errors" + "os" + + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" + "github.com/timescale/tiger-cli/internal/util" +) + +// buildBranchUpdatePasswordCmd creates the update-password subcommand +func buildBranchUpdatePasswordCmd(app *common.App) *cobra.Command { + var newPassword string + var autoGenerate bool + + cmd := &cobra.Command{ + Use: "update-password ", + Short: "Update the master password for a branch", + Long: `Update the master password for the 'tsdbadmin' user of a branch. + +Changing a branch's password doesn't change its parent service's. + +The branch can be given by ID or name.`, + Example: ` # Update a branch's password, interactively prompts + tiger branch update-password br-12345 + + # Update a specific branch's password + tiger branch update-password br-12345 --new-password new-secure-password + + # Update password using environment variable (TIGER_NEW_PASSWORD) + export TIGER_NEW_PASSWORD="new-secure-password" + tiger branch update-password br-12345 + + # Auto-generate a secure password + tiger branch update-password br-12345 --auto-generate`, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: branchRefCompletion(app), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, projectID, err := app.GetAll() + if err != nil { + return err + } + + // The password comes from the flag, falling back to the env var + password := newPassword + if password == "" { + password = os.Getenv("TIGER_NEW_PASSWORD") + } + if autoGenerate && password != "" { + return errors.New("cannot use --auto-generate and --new-password together") + } + + branchRef, err := getBranchRef(args) + if err != nil { + return err + } + + ctx := cmd.Context() + + // Gated here, ahead of any prompt or write. The branch is needed + // to save the new password under it. + branch, err := resolveBranchForWrite(ctx, cfg, client, projectID, branchRef) + if err != nil { + return err + } + + credentials := common.BranchCredentials(*branch) + if autoGenerate || password != "" { + // An empty password is generated. + if _, err := resetPassword(ctx, cmd, cfg, client, credentials, "tsdbadmin", password); err != nil { + return err + } + } else { + if !util.IsTerminal(cmd.InOrStdin()) || !util.IsTerminal(cmd.ErrOrStderr()) { + return errors.New("TTY not detected - use --new-password flag, --auto-generate flag, or TIGER_NEW_PASSWORD environment variable") + } + if _, err := promptAndResetPassword(ctx, cmd, cfg, client, credentials, "tsdbadmin"); err != nil { + return err + } + } + + cmd.PrintErrf("Password updated for tsdbadmin\n") + return nil + }, + } + + cmd.Flags().StringVar(&newPassword, "new-password", "", "New password for the tsdbadmin user (can also be set via TIGER_NEW_PASSWORD env var)") + cmd.Flags().BoolVar(&autoGenerate, "auto-generate", false, "Auto-generate a secure password") + cmd.MarkFlagsMutuallyExclusive("new-password", "auto-generate") + return cmd +} diff --git a/internal/cmd/branch_update_password_test.go b/internal/cmd/branch_update_password_test.go new file mode 100644 index 00000000..1640c4b2 --- /dev/null +++ b/internal/cmd/branch_update_password_test.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "errors" + "net/http" + "testing" + + "github.com/timescale/tiger-cli/internal/api" + "github.com/timescale/tiger-cli/internal/api/mocks" + "github.com/timescale/tiger-cli/internal/common" +) + +func TestBranchUpdatePasswordCmd(t *testing.T) { + setupUpdate := func(password string) func(m *mocks.MockClientWithResponsesInterface) { + return func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().UpdateBranchPasswordWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.UpdatePasswordInput{Password: password}). + Return(&api.UpdateBranchPasswordResponse{HTTPResponse: httpResponse(http.StatusNoContent)}, nil) + } + } + successStderr := "Password saved to system keyring\nPassword updated for tsdbadmin\n" + + runCmdTests(t, []cmdTest{ + { + name: "auto-generate with new password env var", + args: []string{"branch", "update-password", "br-12345", "--auto-generate"}, + opts: []runOption{withExperimental(), withEnv("TIGER_NEW_PASSWORD", "secret")}, + wantErr: "cannot use --auto-generate and --new-password together", + }, + { + name: "not logged in", + args: []string{"branch", "update-password", "br-12345", "--new-password", "secret"}, + opts: []runOption{withExperimental(), withNotLoggedIn()}, + wantErr: notLoggedInMsg, + checks: []checkFunc{checkExitCode(common.ExitAuthenticationError)}, + }, + { + // No default fallback: service_id only ever holds a service ID. + name: "missing branch id", + args: []string{"branch", "update-password", "--new-password", "secret"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"service_id": "svc-12345"})}, + wantErr: "branch name or ID is required", + }, + { + name: "read-only all refuses", + args: []string{"branch", "update-password", "br-12345", "--new-password", "secret"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "all"})}, + wantErr: "this operation is not allowed in read-only mode", + }, + { + name: "read-only prod allows", + args: []string{"branch", "update-password", "br-12345", "--new-password", "secret"}, + opts: []runOption{withExperimental(), withConfig(map[string]any{"read_only": "prod"})}, + mock: setupUpdate("secret"), + wantStderr: successStderr, + }, + { + name: "non-TTY without password", + args: []string{"branch", "update-password", "br-12345"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { expectResolveBranch(m, "br-12345", sampleBranch()) }, + wantErr: "TTY not detected - use --new-password flag, --auto-generate flag, or TIGER_NEW_PASSWORD environment variable", + }, + { + name: "network error", + args: []string{"branch", "update-password", "br-12345", "--new-password", "secret"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().UpdateBranchPasswordWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.UpdatePasswordInput{Password: "secret"}). + Return(nil, errors.New("connection refused")) + }, + wantErr: "failed to update password: connection refused", + }, + { + name: "API error", + args: []string{"branch", "update-password", "br-12345", "--new-password", "secret"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().UpdateBranchPasswordWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.UpdatePasswordInput{Password: "secret"}). + Return(&api.UpdateBranchPasswordResponse{ + HTTPResponse: httpResponse(http.StatusBadRequest), + JSON4XX: &api.ClientError{Message: new("password too short")}, + }, nil) + }, + wantErr: "password too short", + checks: []checkFunc{checkExitCode(common.ExitInvalidParameters)}, + }, + { + // The endpoint answers success with a 204 and nothing else. + name: "unexpected 200 is an error", + args: []string{"branch", "update-password", "br-12345", "--new-password", "secret"}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + m.EXPECT().UpdateBranchPasswordWithResponse(validCtx, testProjectID, "svc-12345", "br-12345", api.UpdatePasswordInput{Password: "secret"}). + Return(&api.UpdateBranchPasswordResponse{HTTPResponse: httpResponse(http.StatusOK)}, nil) + }, + wantErr: "unknown error", + }, + { + name: "new password flag", + args: []string{"branch", "update-password", "br-12345", "--new-password", "secret"}, + opts: []runOption{withExperimental()}, + mock: setupUpdate("secret"), + wantStderr: successStderr, + checks: []checkFunc{checkStoredPassword("br-12345", "secret")}, + }, + { + name: "new password env var", + args: []string{"branch", "update-password", "br-12345"}, + opts: []runOption{withExperimental(), withEnv("TIGER_NEW_PASSWORD", "from-env")}, + mock: setupUpdate("from-env"), + wantStderr: successStderr, + checks: []checkFunc{checkStoredPassword("br-12345", "from-env")}, + }, + { + name: "prompt", + args: []string{"branch", "update-password", "br-12345"}, + opts: []runOption{withExperimental(), withIsTerminal(true), withReadPassword("typed")}, + mock: setupUpdate("typed"), + wantStderr: "Enter new password (leave empty to generate): \n" + successStderr, + checks: []checkFunc{checkStoredPassword("br-12345", "typed")}, + }, + { + name: "auto-generate", + args: []string{"branch", "update-password", "br-12345", "--auto-generate"}, + opts: []runOption{withExperimental(), withGenerateSecurePassword(32, "generated", nil)}, + mock: setupUpdate("generated"), + wantStderr: successStderr, + checks: []checkFunc{checkStoredPassword("br-12345", "generated")}, + }, + { + name: "password generation fails", + args: []string{"branch", "update-password", "br-12345", "--auto-generate"}, + opts: []runOption{withExperimental(), withGenerateSecurePassword(32, "", errors.New("entropy unavailable"))}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + expectResolveBranch(m, "br-12345", sampleBranch()) + }, + wantErr: "failed to generate new password: entropy unavailable", + }, + }) +} diff --git a/internal/cmd/completion_helper.go b/internal/cmd/completion_helper.go index 94ce0689..02b992d5 100644 --- a/internal/cmd/completion_helper.go +++ b/internal/cmd/completion_helper.go @@ -115,6 +115,38 @@ func listServices(cmd *cobra.Command, app *common.App) ([]api.Service, error) { return *resp.JSON200, nil } +// branchRefCompletion completes the branch argument of the branch commands. +// Like serviceRefCompletion, it offers each branch at most once, by name, +// falling back to its ID when what is typed matches the ID instead. +func branchRefCompletion(app *common.App) cobra.CompletionFunc { + return withAppLoad(app, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // The branch ref is always the first positional argument + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + client, projectID, err := app.GetClient() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + resp, err := client.GetProjectBranchesWithResponse(cmd.Context(), projectID) + if err != nil || resp.StatusCode() != http.StatusOK || resp.JSON200 == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + branches := *resp.JSON200 + results := make([]string, 0, len(branches)) + for _, branch := range branches { + if strings.HasPrefix(branch.Name, toComplete) { + results = append(results, cobra.CompletionWithDesc(branch.Name, branch.ID)) + } else if strings.HasPrefix(branch.ID, toComplete) { + results = append(results, cobra.CompletionWithDesc(branch.ID, branch.Name)) + } + } + return results, cobra.ShellCompDirectiveNoFileComp + }) +} + func projectIDCompletion(app *common.App) cobra.CompletionFunc { return withAppLoad(app, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // Project ID is always first positional argument @@ -264,11 +296,34 @@ func outputCompletion(extra ...string) cobra.CompletionFunc { // service can't be resized onto, and experimental includes the fluid platform's // configurations, which are a preview. func cpuCompletion(shared, experimental bool) cobra.CompletionFunc { - return sizeCompletion(shared, experimental, func(c common.CPUMemoryConfig) int { return c.CPUMillis }) + return sizeCompletion(serviceConfigs(shared, experimental), cpuMillis) } func memoryCompletion(shared, experimental bool) cobra.CompletionFunc { - return sizeCompletion(shared, experimental, func(c common.CPUMemoryConfig) int { return c.MemoryGBs }) + return sizeCompletion(serviceConfigs(shared, experimental), memoryGBs) +} + +// branchCPUCompletion and branchMemoryCompletion complete the --cpu and +// --memory flags of the branch commands, which take the fluid platform's +// configurations, since only fluid services can be branched. +var ( + branchCPUCompletion = sizeCompletion(fluidConfigs, cpuMillis) + branchMemoryCompletion = sizeCompletion(fluidConfigs, memoryGBs) +) + +func cpuMillis(c common.CPUMemoryConfig) int { return c.CPUMillis } +func memoryGBs(c common.CPUMemoryConfig) int { return c.MemoryGBs } + +// serviceConfigs returns the configurations the service commands' sizes are +// drawn from (see completionCPUMemoryConfigs). +func serviceConfigs(shared, experimental bool) func(*cobra.Command) common.CPUMemoryConfigs { + return func(cmd *cobra.Command) common.CPUMemoryConfigs { + return completionCPUMemoryConfigs(cmd, shared, experimental) + } +} + +func fluidConfigs(*cobra.Command) common.CPUMemoryConfigs { + return common.FluidCPUMemoryConfigs() } // sizeCompletion completes the sizes size reads off the allowed @@ -276,9 +331,9 @@ func memoryCompletion(shared, experimental bool) cobra.CompletionFunc { // The configurations can come from more than one platform, so the order is // imposed here, and the shell is told to keep it rather than sort the values // as strings. -func sizeCompletion(shared, experimental bool, size func(common.CPUMemoryConfig) int) cobra.CompletionFunc { +func sizeCompletion(configsFor func(*cobra.Command) common.CPUMemoryConfigs, size func(common.CPUMemoryConfig) int) cobra.CompletionFunc { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - configs := completionCPUMemoryConfigs(cmd, shared, experimental) + configs := configsFor(cmd) sizes := make([]int, 0, len(configs)) hasShared := false @@ -332,10 +387,10 @@ const ( iopsIncrement = 1000 ) -// iopsCompletion completes --iops on `service create`, `fork`, and `resize` -// with every value in the widest range. Which of them a service accepts -// depends on its plan, platform, and CPU/memory allocation, which is left to -// the API to validate. +// iopsCompletion completes --iops on `service create`, `fork`, and `resize`, +// and on `branch create` and `resize`, with every value in the widest range. +// Which of them a service accepts depends on its plan, platform, and +// CPU/memory allocation, which is left to the API to validate. var iopsCompletion = func() cobra.CompletionFunc { values := make([]string, 0, (maxCompletionIOPS-minCompletionIOPS)/iopsIncrement+1) for v := minCompletionIOPS; v <= maxCompletionIOPS; v += iopsIncrement { diff --git a/internal/cmd/completion_helper_test.go b/internal/cmd/completion_helper_test.go index a39660d5..5c8a7fbc 100644 --- a/internal/cmd/completion_helper_test.go +++ b/internal/cmd/completion_helper_test.go @@ -57,6 +57,21 @@ func TestCompletion(t *testing.T) { }, nil) } + listBranches := func(m *mocks.MockClientWithResponsesInterface) { + branches := []api.Branch{ + sampleBranch(), + sampleBranch(func(b *api.Branch) { + b.ID = "br-67890" + b.Name = "other-branch" + }), + } + m.EXPECT().GetProjectBranchesWithResponse(validCtx, testProjectID). + Return(&api.GetProjectBranchesResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: &branches, + }, nil) + } + runCmdTests(t, []cmdTest{ { name: "service argument lists services by name with their IDs", @@ -127,6 +142,42 @@ func TestCompletion(t *testing.T) { wantStdout: noFileComp, wantStderr: directive, }, + { + name: "branch argument lists branches by name with their IDs", + args: []string{"__complete", "branch", "get", ""}, + opts: []runOption{withExperimental()}, + mock: listBranches, + wantStdout: "test-branch\tbr-12345\nother-branch\tbr-67890\n" + noFileComp, + wantStderr: directive, + }, + { + // A prefix that matches no name falls back to IDs, with the name + // as the description. + name: "branch argument offers IDs matching what is typed", + args: []string{"__complete", "branch", "stop", "br-6"}, + opts: []runOption{withExperimental()}, + mock: listBranches, + wantStdout: "br-67890\tother-branch\n" + noFileComp, + wantStderr: directive, + }, + { + name: "branch argument completes only the first argument", + args: []string{"__complete", "branch", "rename", "br-12345", ""}, + opts: []runOption{withExperimental()}, + wantStdout: noFileComp, + wantStderr: directive, + }, + { + name: "branch argument offers nothing when the API fails", + args: []string{"__complete", "branch", "delete", ""}, + opts: []runOption{withExperimental()}, + mock: func(m *mocks.MockClientWithResponsesInterface) { + m.EXPECT().GetProjectBranchesWithResponse(validCtx, testProjectID). + Return(nil, errors.New("connection refused")) + }, + wantStdout: noFileComp, + wantStderr: directive, + }, { name: "--service-id lists services with their names", args: []string{"__complete", "service", "get", "--service-id", ""}, @@ -280,6 +331,31 @@ func TestCompletion(t *testing.T) { wantStderr: keepOrderDirective, checks: []checkFunc{checkNotLoaded}, }, + { + // Only fluid services can be branched. + name: "--cpu flag values for branches are the fluid platform's", + args: []string{"__complete", "branch", "create", "--cpu", ""}, + opts: []runOption{withExperimental()}, + wantStdout: "250\n500\n1000\n2000\n4000\n8000\n16000\n32000\n" + keepOrder, + wantStderr: keepOrderDirective, + checks: []checkFunc{checkNotLoaded}, + }, + { + name: "--iops flag values for branch create", + args: []string{"__complete", "branch", "create", "--iops", ""}, + opts: []runOption{withExperimental()}, + wantStdout: iopsSteps(3000, 256000) + keepOrder, + wantStderr: keepOrderDirective, + checks: []checkFunc{checkNotLoaded}, + }, + { + name: "--memory flag values for branch resize are the fluid platform's", + args: []string{"__complete", "branch", "resize", "--memory", ""}, + opts: []runOption{withExperimental()}, + wantStdout: "2\n4\n8\n16\n32\n64\n128\n256\n" + keepOrder, + wantStderr: keepOrderDirective, + checks: []checkFunc{checkNotLoaded}, + }, { // The widest range across every plan, platform, and CPU/memory // allocation: the API validates the value. diff --git a/internal/cmd/main_test.go b/internal/cmd/main_test.go index c087d48d..31396244 100644 --- a/internal/cmd/main_test.go +++ b/internal/cmd/main_test.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -628,6 +629,61 @@ func httpResponse(statusCode int) *http.Response { return &http.Response{StatusCode: statusCode} } +// expectResolve registers resolving ref through the resolve endpoint, +// restricted to resolveType (nil for none), answered with v: an api.Service, +// api.Branch, or api.ReadReplicaSet, whose Go type picks the discriminator. +func expectResolve(m *mocks.MockClientWithResponsesInterface, ref string, resolveType *api.ResolveType, v any) { + m.EXPECT().ResolveRefWithResponse(validCtx, testProjectID, &api.ResolveRefParams{Ref: ref, Type: resolveType}). + Return(&api.ResolveRefResponse{ + HTTPResponse: httpResponse(http.StatusOK), + JSON200: resolveResult(v), + }, nil) +} + +// expectResolveStatus registers a resolve answered with status. A nil apiErr +// stands in for a response with no body. +func expectResolveStatus(m *mocks.MockClientWithResponsesInterface, ref string, resolveType *api.ResolveType, status int, apiErr *api.ClientError) { + m.EXPECT().ResolveRefWithResponse(validCtx, testProjectID, &api.ResolveRefParams{Ref: ref, Type: resolveType}). + Return(&api.ResolveRefResponse{ + HTTPResponse: httpResponse(status), + JSON4XX: apiErr, + }, nil) +} + +// expectResolveError registers a resolve that never reaches the API. +func expectResolveError(m *mocks.MockClientWithResponsesInterface, ref string, resolveType *api.ResolveType, err error) { + m.EXPECT().ResolveRefWithResponse(validCtx, testProjectID, &api.ResolveRefParams{Ref: ref, Type: resolveType}). + Return(nil, err) +} + +// resolveResult encodes v as the resolve endpoint's union, discriminated by +// v's Go type. Each member is its plain type plus the discriminator. +func resolveResult(v any) *api.ResolveResult { + var resolveType api.ResolveType + switch v.(type) { + case api.Service: + resolveType = api.ResolveTypeSERVICE + case api.Branch: + resolveType = api.ResolveTypeBRANCH + case api.ReadReplicaSet: + resolveType = api.ResolveTypeREADREPLICASET + default: + panic(fmt.Sprintf("resolveResult: unsupported type %T", v)) + } + + var body map[string]any + if err := json.Unmarshal(util.Must(json.Marshal(v)), &body); err != nil { + panic(err) + } + body["type"] = resolveType + + var result api.ResolveResult + if err := result.UnmarshalJSON(util.Must(json.Marshal(body))); err != nil { + panic(err) + } + return &result +} + // expectResolveRef registers the resolve call a command makes for its service // ref — the ref filter on the service list — answered with svc as the one // match. diff --git a/internal/cmd/password_helper.go b/internal/cmd/password_helper.go index 171512f4..79212ac1 100644 --- a/internal/cmd/password_helper.go +++ b/internal/cmd/password_helper.go @@ -13,8 +13,8 @@ import ( "github.com/timescale/tiger-cli/internal/util" ) -// updateAndSavePassword sets the password of credentials' owner through the -// API, and saves it locally. +// updateAndSavePassword sets the password of credentials' owner, a service or +// a branch, through the matching API endpoint, and saves it locally. func updateAndSavePassword( ctx context.Context, cmd *cobra.Command, @@ -33,15 +33,30 @@ func updateAndSavePassword( cmd.PrintErrf("Warning: could not save password: %v\n", err) } else if result.Success { cmd.PrintErrf("%s\n", result.Message) - cmd.PrintErrf("View it with: tiger service get %s --with-password\n", credentials.ID) + // Only service get can show a stored password. + if credentials.BranchServiceID == "" { + cmd.PrintErrf("View it with: tiger service get %s --with-password\n", credentials.ID) + } } return nil } -// updatePassword sets the password of credentials' owner through the API. +// updatePassword sets the password of credentials' owner through the service +// or branch password endpoint. func updatePassword(ctx context.Context, client api.ClientWithResponsesInterface, credentials common.CredentialOwner, newPassword string) error { input := api.UpdatePasswordInput{Password: newPassword} + if credentials.BranchServiceID != "" { + resp, err := client.UpdateBranchPasswordWithResponse(ctx, credentials.ProjectID, credentials.BranchServiceID, credentials.ID, input) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + if resp.StatusCode() != http.StatusNoContent { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + return nil + } + resp, err := client.UpdatePasswordWithResponse(ctx, credentials.ProjectID, credentials.ID, input) if err != nil { return fmt.Errorf("failed to update password: %w", err) @@ -120,7 +135,7 @@ func handlePasswordSaving(cmd *cobra.Command, cfg *config.Config, target common. return printPasswordStorageResult(cmd, result) } -// printConnectMessage tells the user how to connect to a new service. +// printConnectMessage tells the user how to connect to a new service or branch. func printConnectMessage(cmd *cobra.Command, passwordSaved, noSetDefault bool, serviceID string) { if !passwordSaved { // We can't connect if no password was saved, so don't show message diff --git a/internal/cmd/service_ref_helper.go b/internal/cmd/ref_helper.go similarity index 72% rename from internal/cmd/service_ref_helper.go rename to internal/cmd/ref_helper.go index b665c500..ab31aa07 100644 --- a/internal/cmd/service_ref_helper.go +++ b/internal/cmd/ref_helper.go @@ -18,6 +18,9 @@ import ( // default identifies a service. var errServiceRequired = errors.New("service name or ID is required. Provide it as an argument or set a default with 'tiger config set service_id '") +// errBranchRequired is returned when no argument identifies a branch. +var errBranchRequired = errors.New("branch name or ID is required") + // serviceRef is what the user gave to identify a service, and where it came // from. The two resolve differently: an argument may be an ID, a read replica // set ID, or a name, and the API matches all of them together, while a @@ -154,3 +157,50 @@ func resolveServiceForWrite(ctx context.Context, cfg *config.Config, client api. return service, nil } + +// getBranchRef returns the branch ref, an ID or a name, given as an argument. +// There is no default branch: the service_id default only ever holds a +// service ID. +func getBranchRef(args []string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", errBranchRequired + } + return args[0], nil +} + +// resolveBranch resolves a branch ref to the branch it names, through the +// API's resolve endpoint restricted to branches. The branch carries its +// parent's ID, which every other branch endpoint needs. +func resolveBranch(ctx context.Context, client api.ClientWithResponsesInterface, projectID, ref string) (*api.Branch, error) { + branch, err := common.ResolveBranch(ctx, client, projectID, ref) + if err != nil { + return nil, withAmbiguousRefHint(err, "Run 'tiger branch list' on the branch's service to find the ID you want") + } + return branch, nil +} + +// branchLabel identifies a branch in status output by name and ID together. +func branchLabel(branch api.Branch) string { + return fmt.Sprintf("'%s' (%s)", branch.Name, branch.ID) +} + +// resolveBranchForWrite is resolveBranch for a command that changes the branch +// it resolves. A branch always counts as DEV (see common.BranchEnvironmentTag), +// so the gate needs nothing off the branch and runs before the lookup. +func resolveBranchForWrite(ctx context.Context, cfg *config.Config, client api.ClientWithResponsesInterface, projectID, ref string) (*api.Branch, error) { + if err := common.CheckReadOnly(cfg, common.BranchEnvironmentTag); err != nil { + return nil, err + } + return resolveBranch(ctx, client, projectID, ref) +} + +// withAmbiguousRefHint adds hint to a resolve error that refused a ref +// matching more than one resource. The resolve endpoint's only validation +// error is that refusal, so a 400 means it. The server's message doesn't name +// the candidates, so the hint points at the command that lists them. +func withAmbiguousRefHint(err error, hint string) error { + if exitErr, ok := errors.AsType[common.ExitCodeError](err); ok && exitErr.ExitCode() == common.ExitInvalidParameters { + return fmt.Errorf("%w\n%s", err, hint) + } + return err +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ff92590c..e3141d10 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -112,6 +112,12 @@ To get started, run: cmd.AddCommand(buildProjectCmd(app)) cmd.AddCommand(buildServiceCmd(app)) cmd.AddCommand(buildDbCmd(app)) + + // Branches are a preview, unregistered until it graduates. + if app.Experimental { + cmd.AddCommand(buildBranchCmd(app)) + } + cmd.AddCommand(buildMCPCmd(app)) cmd.AddCommand(buildSkillsCmd(app)) cmd.AddCommand(buildFeedbackCmd(app)) diff --git a/internal/cmd/service.go b/internal/cmd/service.go index 44615007..9e19e627 100644 --- a/internal/cmd/service.go +++ b/internal/cmd/service.go @@ -15,9 +15,9 @@ import ( ) // buildServiceCmd creates the main service command with all subcommands. -// experimental still gates `backups`, which is backed by a gateway endpoint -// marked `x-tigerdata-preview: true`; when false, that subtree is not added to -// the tree at all. See CLAUDE.md's "Experimental Feature Gating". +// experimental still gates `backups` and `branch`, which are backed by gateway +// endpoints marked `x-tigerdata-preview: true`; when false, they are not added +// to the tree at all. See CLAUDE.md's "Experimental Feature Gating". func buildServiceCmd(app *common.App) *cobra.Command { cmd := &cobra.Command{ Use: "service", @@ -40,9 +40,10 @@ func buildServiceCmd(app *common.App) *cobra.Command { cmd.AddCommand(buildServiceLogsCmd(app)) cmd.AddCommand(buildServiceMetricsCmd(app)) - // Experimental command, unregistered until the preview graduates. + // Experimental commands, unregistered until the previews graduate. if app.Experimental { cmd.AddCommand(buildServiceBackupCmd(app)) + cmd.AddCommand(buildServiceBranchCmd(app)) } return cmd diff --git a/internal/cmd/service_branch.go b/internal/cmd/service_branch.go new file mode 100644 index 00000000..0a229be9 --- /dev/null +++ b/internal/cmd/service_branch.go @@ -0,0 +1,13 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/timescale/tiger-cli/internal/common" +) + +// buildServiceBranchCmd creates the branch subcommand of `tiger service`, the +// same command as `tiger branch create` (see newBranchCreateCmd). +func buildServiceBranchCmd(app *common.App) *cobra.Command { + return newBranchCreateCmd(app, "branch [name-or-id]", "tiger service branch") +} diff --git a/internal/common/branch.go b/internal/common/branch.go new file mode 100644 index 00000000..d02590e4 --- /dev/null +++ b/internal/common/branch.go @@ -0,0 +1,42 @@ +package common + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/timescale/tiger-cli/internal/api" +) + +// BranchEnvironmentTag is the tag every branch is judged by. Branches have no +// environment of their own and always count as DEV, so read_only=prod leaves +// them writable even when their parent is tagged PROD: branching production +// to work against it safely is the main use case. read_only=all still blocks +// them. +const BranchEnvironmentTag = api.EnvironmentTagDEV + +// ResolveBranch resolves ref, a branch ID or name, to the branch it names. +// The branch carries its parent's ID, which every other branch endpoint needs. +func ResolveBranch(ctx context.Context, client api.ClientWithResponsesInterface, projectID, ref string) (*api.Branch, error) { + resolved, err := resolve(ctx, client, projectID, ref, new(api.ResolveTypeBRANCH), "branch") + if err != nil { + return nil, err + } + return resolved.Branch, nil +} + +// ListBranches returns serviceID's branches. +func ListBranches(ctx context.Context, client api.ClientWithResponsesInterface, projectID, serviceID string) ([]api.Branch, error) { + resp, err := client.GetBranchesWithResponse(ctx, projectID, serviceID, nil) + if err != nil { + return nil, fmt.Errorf("failed to list branches: %w", err) + } + if resp.StatusCode() != http.StatusOK { + return nil, ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON200 == nil { + return nil, errors.New("empty response from API") + } + return *resp.JSON200, nil +} diff --git a/internal/common/connection_target.go b/internal/common/connection_target.go index 0d03fd01..0a8a37c3 100644 --- a/internal/common/connection_target.go +++ b/internal/common/connection_target.go @@ -10,9 +10,9 @@ import ( "github.com/timescale/tiger-cli/internal/util" ) -// ConnectionTarget is what a database connection goes to: a service or one of -// its read replica sets, reduced to what connecting to it and reporting on it -// need. +// ConnectionTarget is what a database connection goes to: a service, one of +// its read replica sets, or a branch, reduced to what connecting to it and +// reporting on it need. type ConnectionTarget struct { Kind TargetKind ID string @@ -22,24 +22,30 @@ type ConnectionTarget struct { Status api.DeployStatus // Environment alone decides a session's read-only verdict, so a replica is - // judged on its own tag rather than its primary's. + // judged on its own tag rather than its primary's, and a branch, which has + // none, counts as DEV. Environment api.EnvironmentTag Endpoint *api.Endpoint Pooler *api.ConnectionPooler // Credentials is whose passwords the connection uses: the target itself - // for a service, the primary for a read replica, which shares them. + // for a service or a branch, the primary for a read replica, which shares + // them. Credentials CredentialOwner } -// CredentialOwner is the service whose passwords a connection uses. +// CredentialOwner is the service or branch whose passwords a connection uses. type CredentialOwner struct { PasswordTarget // Name names the owner in messages. Name string + // BranchServiceID is the parent service of an owner that is a branch, + // whose password endpoint is nested under it. It is empty for a service. + BranchServiceID string + // Environment is the owner's own tag, which decides whether read-only mode // lets its passwords be changed. Environment api.EnvironmentTag @@ -51,6 +57,7 @@ type TargetKind int const ( TargetService TargetKind = iota TargetReadReplica + TargetBranch ) // NewServiceConnectionTarget returns the ConnectionTarget of a service, @@ -78,6 +85,31 @@ func ServiceCredentials(service api.Service) CredentialOwner { } } +// NewBranchConnectionTarget returns the ConnectionTarget of a branch, which +// has its own credentials and no pooler. +func NewBranchConnectionTarget(branch api.Branch) *ConnectionTarget { + return &ConnectionTarget{ + Kind: TargetBranch, + ID: branch.ID, + Name: branch.Name, + Status: branch.Status, + Environment: BranchEnvironmentTag, + Endpoint: branch.Endpoint, + Credentials: BranchCredentials(branch), + } +} + +// BranchCredentials returns a branch as the owner of the credentials its +// connections use. A branch has its own, separate from its parent's. +func BranchCredentials(branch api.Branch) CredentialOwner { + return CredentialOwner{ + PasswordTarget: BranchPasswordTarget(branch), + Name: branch.Name, + BranchServiceID: branch.ServiceID, + Environment: BranchEnvironmentTag, + } +} + // NewReplicaConnectionTarget returns the ConnectionTarget of a read replica // set. The replica supplies the endpoint, and credentials is its primary's // (see ServiceCredentials), since a replica shares its primary's passwords. diff --git a/internal/common/password_storage.go b/internal/common/password_storage.go index 649b5492..abfec543 100644 --- a/internal/common/password_storage.go +++ b/internal/common/password_storage.go @@ -13,8 +13,9 @@ import ( "github.com/timescale/tiger-cli/internal/config" ) -// PasswordTarget is what a stored password belongs to. The keyring keys a -// password by project and ID, and ~/.pgpass by the endpoint's host and port. +// PasswordTarget is what a stored password belongs to: a service or a branch. +// The keyring keys a password by project and ID, and ~/.pgpass by the +// endpoint's host and port. type PasswordTarget struct { ProjectID string ID string @@ -31,6 +32,16 @@ func ServicePasswordTarget(service api.Service) PasswordTarget { } } +// BranchPasswordTarget returns the PasswordTarget of a branch's own +// passwords. +func BranchPasswordTarget(branch api.Branch) PasswordTarget { + return PasswordTarget{ + ProjectID: branch.ProjectID, + ID: branch.ID, + Endpoint: branch.Endpoint, + } +} + // buildPasswordKeyringUsername creates a unique keyring username for a password func buildPasswordKeyringUsername(target PasswordTarget, role string) (string, error) { if target.ID == "" { diff --git a/internal/common/resolve.go b/internal/common/resolve.go new file mode 100644 index 00000000..54c51eef --- /dev/null +++ b/internal/common/resolve.go @@ -0,0 +1,119 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/timescale/tiger-cli/internal/api" +) + +// Resolved is what a ref resolved to. Exactly one of its fields is set. +type Resolved struct { + Service *api.Service + Branch *api.Branch + ReadReplicaSet *api.ReadReplicaSet +} + +// ID returns the ID of whatever the ref resolved to. +func (r *Resolved) ID() string { + switch { + case r.Service != nil: + return r.Service.ServiceID + case r.Branch != nil: + return r.Branch.ID + case r.ReadReplicaSet != nil: + return r.ReadReplicaSet.ID + } + return "" +} + +// resolve resolves ref, an ID or a name, to the service, branch, or read +// replica set it names, considering only resources of the given type when it +// is non-nil. kind names what the caller is looking for, in errors. A ref that +// matches nothing is an ExitServiceNotFound error, and one that matches more +// than one resource is refused by the API with a 400. +func resolve(ctx context.Context, client api.ClientWithResponsesInterface, projectID, ref string, resolveType *api.ResolveType, kind string) (*Resolved, error) { + resp, err := client.ResolveRefWithResponse(ctx, projectID, &api.ResolveRefParams{Ref: ref, Type: resolveType}) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s '%s': %w", kind, ref, err) + } + if resp.StatusCode() == http.StatusNotFound { + return nil, ExitWithCode(ExitServiceNotFound, fmt.Errorf("%s '%s' not found", kind, ref)) + } + if resp.StatusCode() != http.StatusOK { + return nil, ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + if resp.JSON200 == nil { + return nil, fmt.Errorf("empty response from API") + } + + resolved, err := unpackResolveResult(*resp.JSON200) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s '%s': %w", kind, ref, err) + } + + // The API only returns the requested type, so a mismatch means the filter + // was ignored, and acting on the result would act on the wrong thing. + if resolveType != nil && resolvedType(resolved) != *resolveType { + return nil, fmt.Errorf("expected a %s for '%s', got a %s", kind, ref, resolveKind(resolvedType(resolved))) + } + return resolved, nil +} + +// resolveKind names a resolve type, in messages. +func resolveKind(resolveType api.ResolveType) string { + switch resolveType { + case api.ResolveTypeBRANCH: + return "branch" + case api.ResolveTypeREADREPLICASET: + return "read replica set" + default: + return "service" + } +} + +func resolvedType(r *Resolved) api.ResolveType { + switch { + case r.Branch != nil: + return api.ResolveTypeBRANCH + case r.ReadReplicaSet != nil: + return api.ResolveTypeREADREPLICASET + default: + return api.ResolveTypeSERVICE + } +} + +// unpackResolveResult decodes the union by its discriminator. Each Resolved* +// type is its plain counterpart plus the discriminator, so the raw JSON is +// decoded straight into the plain type rather than copied field by field. +func unpackResolveResult(result api.ResolveResult) (*Resolved, error) { + discriminator, err := result.Discriminator() + if err != nil { + return nil, err + } + raw, err := result.MarshalJSON() + if err != nil { + return nil, err + } + + var resolved Resolved + switch api.ResolveType(discriminator) { + case api.ResolveTypeSERVICE: + resolved.Service = &api.Service{} + err = json.Unmarshal(raw, resolved.Service) + case api.ResolveTypeBRANCH: + resolved.Branch = &api.Branch{} + err = json.Unmarshal(raw, resolved.Branch) + case api.ResolveTypeREADREPLICASET: + resolved.ReadReplicaSet = &api.ReadReplicaSet{} + err = json.Unmarshal(raw, resolved.ReadReplicaSet) + default: + return nil, fmt.Errorf("unknown resolved type '%s'", discriminator) + } + if err != nil { + return nil, err + } + return &resolved, nil +} diff --git a/internal/common/wait.go b/internal/common/wait.go index f0f4ee62..d87f9365 100644 --- a/internal/common/wait.go +++ b/internal/common/wait.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/http" "time" "github.com/timescale/tiger-cli/internal/api" @@ -36,22 +37,115 @@ type WaitForServiceArgs struct { // WaitForService polls a service until it reports TargetStatus, showing its // current status next to a spinner in the meantime. func WaitForService(ctx context.Context, args WaitForServiceArgs) error { - if args.Service.Status == args.TargetStatus { + return waitForStatus(ctx, waitArgs{ + kind: "service", + title: "Service", + status: &args.Service.Status, + targetStatus: args.TargetStatus, + input: args.Input, + output: args.Output, + timeout: args.Timeout, + timeoutMsg: args.TimeoutMsg, + poll: func(ctx context.Context) (int, string, *api.DeployStatus, error) { + resp, err := args.Client.GetServiceWithResponse(ctx, args.ProjectID, args.ServiceID) + if err != nil { + return 0, "", nil, err + } + if resp.JSON200 == nil { + return resp.StatusCode(), resp.Status(), nil, nil + } + return resp.StatusCode(), resp.Status(), &resp.JSON200.Status, nil + }, + }) +} + +type WaitForBranchArgs struct { + Client api.ClientWithResponsesInterface + ProjectID string + ServiceID string // the branch's parent + BranchID string + + // Branch is the branch as last returned by the API. Unlike + // WaitForServiceArgs.Service, the whole branch is replaced on every poll, + // so fields that only appear once the branch is provisioned (its endpoint) + // are there for the caller afterwards. + Branch *api.Branch + + // TargetStatus, Input, Output, Timeout, and TimeoutMsg work as in + // WaitForServiceArgs. + TargetStatus api.DeployStatus + Input io.Reader + Output io.Writer + Timeout time.Duration + TimeoutMsg string +} + +// WaitForBranch polls a branch until it reports TargetStatus, showing its +// current status next to a spinner in the meantime. +func WaitForBranch(ctx context.Context, args WaitForBranchArgs) error { + return waitForStatus(ctx, waitArgs{ + kind: "branch", + title: "Branch", + status: &args.Branch.Status, + targetStatus: args.TargetStatus, + input: args.Input, + output: args.Output, + timeout: args.Timeout, + timeoutMsg: args.TimeoutMsg, + poll: func(ctx context.Context) (int, string, *api.DeployStatus, error) { + resp, err := args.Client.GetBranchWithResponse(ctx, args.ProjectID, args.ServiceID, args.BranchID) + if err != nil { + return 0, "", nil, err + } + if resp.JSON200 == nil { + return resp.StatusCode(), resp.Status(), nil, nil + } + *args.Branch = *resp.JSON200 + return resp.StatusCode(), resp.Status(), &args.Branch.Status, nil + }, + }) +} + +// waitArgs is what waitForStatus needs to poll any resource with a +// DeployStatus. +type waitArgs struct { + // kind names the resource in error messages, and title in the spinner's + // status message. + kind string + title string + + // status is updated in place as polling proceeds. + status *api.DeployStatus + targetStatus api.DeployStatus + + // poll fetches the resource once, returning the HTTP status code and + // text, and the resource's status when the response carried a body. + poll func(ctx context.Context) (code int, httpStatus string, current *api.DeployStatus, err error) + + input io.Reader + output io.Writer + timeout time.Duration + timeoutMsg string +} + +// waitForStatus is the poll loop behind WaitForService and WaitForBranch. +func waitForStatus(ctx context.Context, args waitArgs) error { + if *args.status == args.targetStatus { return nil } // The spinner cancels this context on Ctrl+C, which the loop below reports // as a canceled wait. - ctx, cancel := context.WithTimeout(ctx, args.Timeout) + ctx, cancel := context.WithTimeout(ctx, args.timeout) defer cancel() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() spinner := NewSpinner(SpinnerArgs{ - Input: args.Input, - Output: args.Output, - Message: statusMessage(args.Service), + Input: args.input, + Output: args.output, + Message: statusMessage(args.title, *args.status), Cancel: cancel, }) defer spinner.Stop() @@ -61,42 +155,42 @@ func WaitForService(ctx context.Context, args WaitForServiceArgs) error { case <-ctx.Done(): switch { case errors.Is(ctx.Err(), context.DeadlineExceeded): - return ExitWithCode(ExitTimeout, fmt.Errorf("wait timeout reached after %v - %s", args.Timeout, args.TimeoutMsg)) + return ExitWithCode(ExitTimeout, fmt.Errorf("wait timeout reached after %v - %s", args.timeout, args.timeoutMsg)) case errors.Is(ctx.Err(), context.Canceled): - return fmt.Errorf("canceled waiting - %s", args.TimeoutMsg) + return fmt.Errorf("canceled waiting - %s", args.timeoutMsg) default: - return fmt.Errorf("error waiting - %s: %w", args.TimeoutMsg, ctx.Err()) + return fmt.Errorf("error waiting - %s: %w", args.timeoutMsg, ctx.Err()) } case <-ticker.C: - resp, err := args.Client.GetServiceWithResponse(ctx, args.ProjectID, args.ServiceID) + code, httpStatus, current, err := args.poll(ctx) if err != nil { - spinner.Update(fmt.Sprintf("Error checking service status: %s", err)) + spinner.Update(fmt.Sprintf("Error checking %s status: %s", args.kind, err)) continue } - switch resp.StatusCode() { - case 200: - if resp.JSON200 == nil { + switch code { + case http.StatusOK: + if current == nil { return errors.New("no response body returned from API") } - args.Service.Status = resp.JSON200.Status - if args.Service.Status == args.TargetStatus { + *args.status = *current + if *args.status == args.targetStatus { return nil } - spinner.Update(statusMessage(args.Service)) - case 404: - // Can happen if user deletes service while it's still provisioning - return errors.New("service not found") - case 500: + spinner.Update(statusMessage(args.title, *args.status)) + case http.StatusNotFound: + // Can happen if user deletes the resource while it's still provisioning + return fmt.Errorf("%s not found", args.kind) + case http.StatusInternalServerError: // Assume 500s are temporary server-side issues, and that it's safe to keep polling - spinner.Update("Error checking service status: internal server error") + spinner.Update(fmt.Sprintf("Error checking %s status: internal server error", args.kind)) default: - return fmt.Errorf("received unexpected %s while checking service status", resp.Status()) + return fmt.Errorf("received unexpected %s while checking %s status", httpStatus, args.kind) } } } } -func statusMessage(service *api.Service) string { - return fmt.Sprintf("Service status: %s", service.Status) +func statusMessage(title string, status api.DeployStatus) string { + return fmt.Sprintf("%s status: %s", title, status) }