From f8e1fb58d464b07ce8db046c992586dfe3271de2 Mon Sep 17 00:00:00 2001 From: Nathan Cochran Date: Wed, 21 Oct 2026 18:02:45 -0400 Subject: [PATCH] Add tiger branch commands Behind the experimental gate, tiger branch creates, lists, gets, deletes, starts, stops, resizes, renames, and updates the password of branches of fluid services. tiger service branch is the same command as branch create. Branch refs (ID or name) resolve through the API's resolve endpoint, restricted to branches. Waiting on a branch shares the service wait loop, and branch passwords are stored and reset through the same helpers as a service's. --- CLAUDE.md | 6 +- internal/cmd/branch.go | 151 +++++++++ internal/cmd/branch_create.go | 184 +++++++++++ internal/cmd/branch_create_test.go | 293 ++++++++++++++++++ internal/cmd/branch_delete.go | 89 ++++++ internal/cmd/branch_delete_test.go | 119 +++++++ internal/cmd/branch_get.go | 60 ++++ internal/cmd/branch_get_test.go | 215 +++++++++++++ internal/cmd/branch_list.go | 103 ++++++ internal/cmd/branch_list_test.go | 169 ++++++++++ internal/cmd/branch_rename.go | 70 +++++ internal/cmd/branch_rename_test.go | 101 ++++++ internal/cmd/branch_resize.go | 152 +++++++++ internal/cmd/branch_resize_test.go | 138 +++++++++ internal/cmd/branch_start.go | 99 ++++++ internal/cmd/branch_start_test.go | 107 +++++++ internal/cmd/branch_stop.go | 100 ++++++ internal/cmd/branch_stop_test.go | 143 +++++++++ internal/cmd/branch_test.go | 157 ++++++++++ internal/cmd/branch_update_password.go | 94 ++++++ internal/cmd/branch_update_password_test.go | 144 +++++++++ internal/cmd/completion_helper.go | 71 ++++- internal/cmd/completion_helper_test.go | 76 +++++ internal/cmd/main_test.go | 56 ++++ internal/cmd/password_helper.go | 25 +- .../{service_ref_helper.go => ref_helper.go} | 50 +++ internal/cmd/root.go | 6 + internal/cmd/service.go | 9 +- internal/cmd/service_branch.go | 13 + internal/common/branch.go | 42 +++ internal/common/connection_target.go | 44 ++- internal/common/password_storage.go | 15 +- internal/common/resolve.go | 119 +++++++ internal/common/wait.go | 142 +++++++-- 34 files changed, 3310 insertions(+), 52 deletions(-) create mode 100644 internal/cmd/branch.go create mode 100644 internal/cmd/branch_create.go create mode 100644 internal/cmd/branch_create_test.go create mode 100644 internal/cmd/branch_delete.go create mode 100644 internal/cmd/branch_delete_test.go create mode 100644 internal/cmd/branch_get.go create mode 100644 internal/cmd/branch_get_test.go create mode 100644 internal/cmd/branch_list.go create mode 100644 internal/cmd/branch_list_test.go create mode 100644 internal/cmd/branch_rename.go create mode 100644 internal/cmd/branch_rename_test.go create mode 100644 internal/cmd/branch_resize.go create mode 100644 internal/cmd/branch_resize_test.go create mode 100644 internal/cmd/branch_start.go create mode 100644 internal/cmd/branch_start_test.go create mode 100644 internal/cmd/branch_stop.go create mode 100644 internal/cmd/branch_stop_test.go create mode 100644 internal/cmd/branch_test.go create mode 100644 internal/cmd/branch_update_password.go create mode 100644 internal/cmd/branch_update_password_test.go rename internal/cmd/{service_ref_helper.go => ref_helper.go} (72%) create mode 100644 internal/cmd/service_branch.go create mode 100644 internal/common/branch.go create mode 100644 internal/common/resolve.go 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) }