Skip to content
Draft
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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,17 @@ pscale billing invoice line-items <invoice-id> --org <org> --format json
- JSON preserves the API objects. Line items include the billed database and nested resource (usually a branch).
- Requires `read_invoices` on a service token.

## Emergency backups

`pscale backup create` normally schedules a backup through the regular backup pipeline. `--emergency` instead clones data directly from a live tablet, skipping the wait for the next scheduled backup.

```bash
pscale backup create <database> <branch> --org <org> --format json \
--emergency --emergency-source replica
```

- `--emergency-source` is `primary` or `replica`. `rdonly` is rejected. It is **required** when `--emergency` is set, and **rejected** when `--emergency` is not set — there is no default source. The create request sends it as `emergency_source`.

## Neki backup restore

Restoring a Neki backup creates a new branch. Configuration profiles and routers are restored; omitted sizes inherit the source. Sidecar, admin, and parameter settings are not restored.
Expand Down
28 changes: 26 additions & 2 deletions internal/cmd/backup/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package backup

import (
"fmt"
"slices"
"strings"

"github.com/planetscale/cli/internal/cmdutil"
ps "github.com/planetscale/cli/internal/planetscale"
Expand All @@ -10,18 +12,38 @@ import (
"github.com/spf13/cobra"
)

// emergencySources are the valid values for --emergency-source.
var emergencySources = []string{"primary", "replica"}

func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
createReq := &ps.CreateBackupRequest{}
cmd := &cobra.Command{
Use: "create <database> <branch>",
Short: "Backup a branch's data and schema",
Use: "create <database> <branch>",
Short: "Backup a branch's data and schema",
Long: `Backup a branch's data and schema.

--emergency requests an emergency backup, which clones data from a live
tablet instead of waiting for the next scheduled backup. --emergency-source
selects which tablet type to clone from (primary or replica) and is required
with --emergency (and rejected otherwise). rdonly is not accepted.`,
Args: cmdutil.RequiredArgs("database", "branch"),
Aliases: []string{"b"},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database := args[0]
branch := args[1]

if createReq.Emergency {
if !cmd.Flags().Changed("emergency-source") {
return fmt.Errorf("--emergency-source is required when --emergency is set")
}
if !slices.Contains(emergencySources, createReq.EmergencySource) {
return fmt.Errorf("invalid --emergency-source %q, must be one of: %s", createReq.EmergencySource, strings.Join(emergencySources, ", "))
}
} else if cmd.Flags().Changed("emergency-source") {
return fmt.Errorf("--emergency-source is only valid when --emergency is set")
}

createReq.Database = database
createReq.Branch = branch
createReq.Organization = ch.Config.Organization
Expand Down Expand Up @@ -57,6 +79,8 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command {
}

cmd.Flags().StringVar(&createReq.Name, "name", "", "Optional name for the backup")
cmd.Flags().BoolVar(&createReq.Emergency, "emergency", false, "Request an emergency backup that clones data from a live tablet instead of waiting for the next scheduled backup. Requires --emergency-source.")
cmd.Flags().StringVar(&createReq.EmergencySource, "emergency-source", "", "Tablet type to clone from for an emergency backup: primary or replica. Required with --emergency; rejected otherwise.")

return cmd
}
159 changes: 159 additions & 0 deletions internal/cmd/backup/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,162 @@ func TestBackup_CreateCmdWithName(t *testing.T) {
c.Assert(svc.CreateFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, res)
}

func TestBackup_CreateCmdWithEmergency(t *testing.T) {
c := qt.New(t)

var buf bytes.Buffer
format := printer.JSON
p := printer.NewPrinter(&format)
p.SetResourceOutput(&buf)

org := "planetscale"
db := "planetscale"
branch := "development"

res := &ps.Backup{Name: "foo"}

svc := &mock.BackupsService{
CreateFn: func(ctx context.Context, req *ps.CreateBackupRequest) (*ps.Backup, error) {
c.Assert(req.Organization, qt.Equals, org)
c.Assert(req.Database, qt.Equals, db)
c.Assert(req.Branch, qt.Equals, branch)
c.Assert(req.Emergency, qt.IsTrue)
c.Assert(req.EmergencySource, qt.Equals, "replica")

return res, nil
},
}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: org,
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Backups: svc,
}, nil
},
}

cmd := CreateCmd(ch)
cmd.SetArgs([]string{db, branch, "--emergency", "--emergency-source", "replica"})
err := cmd.Execute()

c.Assert(err, qt.IsNil)
c.Assert(svc.CreateFnInvoked, qt.IsTrue)
c.Assert(buf.String(), qt.JSONEquals, res)
}

func TestBackup_CreateCmdEmergencyRequiresEmergencySource(t *testing.T) {
c := qt.New(t)

format := printer.JSON
p := printer.NewPrinter(&format)

svc := &mock.BackupsService{}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: "planetscale",
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Backups: svc,
}, nil
},
}

cmd := CreateCmd(ch)
cmd.SetArgs([]string{"planetscale", "development", "--emergency"})
err := cmd.Execute()

c.Assert(err, qt.ErrorMatches, "--emergency-source is required when --emergency is set")
c.Assert(svc.CreateFnInvoked, qt.IsFalse)
}

func TestBackup_CreateCmdEmergencySourceRequiresEmergency(t *testing.T) {
c := qt.New(t)

format := printer.JSON
p := printer.NewPrinter(&format)

svc := &mock.BackupsService{}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: "planetscale",
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Backups: svc,
}, nil
},
}

cmd := CreateCmd(ch)
cmd.SetArgs([]string{"planetscale", "development", "--emergency-source", "replica"})
err := cmd.Execute()

c.Assert(err, qt.ErrorMatches, "--emergency-source is only valid when --emergency is set")
c.Assert(svc.CreateFnInvoked, qt.IsFalse)
}

func TestBackup_CreateCmdInvalidEmergencySource(t *testing.T) {
c := qt.New(t)

format := printer.JSON
p := printer.NewPrinter(&format)

svc := &mock.BackupsService{}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: "planetscale",
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Backups: svc,
}, nil
},
}

cmd := CreateCmd(ch)
cmd.SetArgs([]string{"planetscale", "development", "--emergency", "--emergency-source", "bogus"})
err := cmd.Execute()

c.Assert(err, qt.ErrorMatches, `invalid --emergency-source "bogus", must be one of: primary, replica`)
c.Assert(svc.CreateFnInvoked, qt.IsFalse)
}

func TestBackup_CreateCmdRdonlyEmergencySource(t *testing.T) {
c := qt.New(t)

format := printer.JSON
p := printer.NewPrinter(&format)

svc := &mock.BackupsService{}

ch := &cmdutil.Helper{
Printer: p,
Config: &config.Config{
Organization: "planetscale",
},
Client: func() (*ps.Client, error) {
return &ps.Client{
Backups: svc,
}, nil
},
}

cmd := CreateCmd(ch)
cmd.SetArgs([]string{"planetscale", "development", "--emergency", "--emergency-source", "rdonly"})
err := cmd.Execute()

c.Assert(err, qt.ErrorMatches, `invalid --emergency-source "rdonly", must be one of: primary, replica`)
c.Assert(svc.CreateFnInvoked, qt.IsFalse)
}
9 changes: 5 additions & 4 deletions internal/planetscale/backups.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ type CreateBackupRequest struct {
Database string `json:"-"`
Branch string `json:"-"`

Name string `json:"name,omitempty"`
RetentionUnit string `json:"retention_unit,omitempty"`
RetentionValue int `json:"retention_value,omitempty"`
Emergency bool `json:"emergency,omitempty"`
Name string `json:"name,omitempty"`
RetentionUnit string `json:"retention_unit,omitempty"`
RetentionValue int `json:"retention_value,omitempty"`
Emergency bool `json:"emergency,omitempty"`
EmergencySource string `json:"emergency_source,omitempty"`
}

type ListBackupsRequest struct {
Expand Down
Loading