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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Further conventions:

### Service Refs

A command that identifies a service takes a **ref** — its ID, a read replica set ID, or its name — and 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.
Expand Down Expand Up @@ -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

Expand Down
151 changes: 151 additions & 0 deletions internal/cmd/branch.go
Original file line number Diff line number Diff line change
@@ -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
}
184 changes: 184 additions & 0 deletions internal/cmd/branch_create.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading