From 9f1a1463c34cd716a8333876ba2a5e6e543fbd98 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 03:58:01 +0000 Subject: [PATCH 1/3] backup create: add --emergency and --clone-tablet-type flags --emergency requests an emergency backup that clones data from a live tablet instead of waiting for the next scheduled backup. --clone-tablet-type selects the tablet type to clone from (primary, replica, or rdonly) and is required with --emergency, rejected otherwise. No client-side default is applied; both fields are sent on the existing create-backup request. Co-authored-by: max --- AGENTS.md | 11 +++ internal/cmd/backup/create.go | 28 +++++- internal/cmd/backup/create_test.go | 131 +++++++++++++++++++++++++++++ internal/planetscale/backups.go | 9 +- 4 files changed, 173 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3d1725d3..12ea1362 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -977,6 +977,17 @@ pscale billing invoice line-items --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 --org --format json \ + --emergency --clone-tablet-type replica +``` + +- `--clone-tablet-type` is `primary`, `replica`, or `rdonly`. It is **required** when `--emergency` is set, and **rejected** when `--emergency` is not set — there is no default tablet type. + ## 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. diff --git a/internal/cmd/backup/create.go b/internal/cmd/backup/create.go index f3975b96..78717e79 100644 --- a/internal/cmd/backup/create.go +++ b/internal/cmd/backup/create.go @@ -2,6 +2,8 @@ package backup import ( "fmt" + "slices" + "strings" "github.com/planetscale/cli/internal/cmdutil" ps "github.com/planetscale/cli/internal/planetscale" @@ -10,11 +12,20 @@ import ( "github.com/spf13/cobra" ) +// cloneTabletTypes are the valid values for --clone-tablet-type. +var cloneTabletTypes = []string{"primary", "replica", "rdonly"} + func CreateCmd(ch *cmdutil.Helper) *cobra.Command { createReq := &ps.CreateBackupRequest{} cmd := &cobra.Command{ - Use: "create ", - Short: "Backup a branch's data and schema", + Use: "create ", + 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. --clone-tablet-type +selects which tablet type to clone from and is required with --emergency +(and rejected otherwise).`, Args: cmdutil.RequiredArgs("database", "branch"), Aliases: []string{"b"}, RunE: func(cmd *cobra.Command, args []string) error { @@ -22,6 +33,17 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { database := args[0] branch := args[1] + if createReq.Emergency { + if !cmd.Flags().Changed("clone-tablet-type") { + return fmt.Errorf("--clone-tablet-type is required when --emergency is set") + } + if !slices.Contains(cloneTabletTypes, createReq.CloneTabletType) { + return fmt.Errorf("invalid --clone-tablet-type %q, must be one of: %s", createReq.CloneTabletType, strings.Join(cloneTabletTypes, ", ")) + } + } else if cmd.Flags().Changed("clone-tablet-type") { + return fmt.Errorf("--clone-tablet-type is only valid when --emergency is set") + } + createReq.Database = database createReq.Branch = branch createReq.Organization = ch.Config.Organization @@ -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 --clone-tablet-type.") + cmd.Flags().StringVar(&createReq.CloneTabletType, "clone-tablet-type", "", "Tablet type to clone from for an emergency backup: primary, replica, or rdonly. Required with --emergency; rejected otherwise.") return cmd } diff --git a/internal/cmd/backup/create_test.go b/internal/cmd/backup/create_test.go index 83510e10..5b873d4f 100644 --- a/internal/cmd/backup/create_test.go +++ b/internal/cmd/backup/create_test.go @@ -106,3 +106,134 @@ 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.CloneTabletType, 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", "--clone-tablet-type", "replica"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.CreateFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, res) +} + +func TestBackup_CreateCmdEmergencyRequiresCloneTabletType(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, "--clone-tablet-type is required when --emergency is set") + c.Assert(svc.CreateFnInvoked, qt.IsFalse) +} + +func TestBackup_CreateCmdCloneTabletTypeRequiresEmergency(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", "--clone-tablet-type", "replica"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, "--clone-tablet-type is only valid when --emergency is set") + c.Assert(svc.CreateFnInvoked, qt.IsFalse) +} + +func TestBackup_CreateCmdInvalidCloneTabletType(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", "--clone-tablet-type", "bogus"}) + err := cmd.Execute() + + c.Assert(err, qt.ErrorMatches, `invalid --clone-tablet-type "bogus", must be one of: primary, replica, rdonly`) + c.Assert(svc.CreateFnInvoked, qt.IsFalse) +} diff --git a/internal/planetscale/backups.go b/internal/planetscale/backups.go index 4b831d9c..394e95bb 100644 --- a/internal/planetscale/backups.go +++ b/internal/planetscale/backups.go @@ -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"` + CloneTabletType string `json:"clone_tablet_type,omitempty"` } type ListBackupsRequest struct { From 2eb465fb42c8316200959d706241653c9e112fbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 04:23:29 +0000 Subject: [PATCH 2/3] backup create: rename --clone-tablet-type to --emergency-source The create request sends emergency_source. --emergency-source is required with --emergency and rejected without it. Values stay primary, replica, and rdonly. Co-authored-by: max --- AGENTS.md | 4 ++-- internal/cmd/backup/create.go | 22 +++++++++++----------- internal/cmd/backup/create_test.go | 20 ++++++++++---------- internal/planetscale/backups.go | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12ea1362..24d09a0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -983,10 +983,10 @@ pscale billing invoice line-items --org --format json ```bash pscale backup create --org --format json \ - --emergency --clone-tablet-type replica + --emergency --emergency-source replica ``` -- `--clone-tablet-type` is `primary`, `replica`, or `rdonly`. It is **required** when `--emergency` is set, and **rejected** when `--emergency` is not set — there is no default tablet type. +- `--emergency-source` is `primary`, `replica`, or `rdonly`. 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 diff --git a/internal/cmd/backup/create.go b/internal/cmd/backup/create.go index 78717e79..6293c4ea 100644 --- a/internal/cmd/backup/create.go +++ b/internal/cmd/backup/create.go @@ -12,8 +12,8 @@ import ( "github.com/spf13/cobra" ) -// cloneTabletTypes are the valid values for --clone-tablet-type. -var cloneTabletTypes = []string{"primary", "replica", "rdonly"} +// emergencySources are the valid values for --emergency-source. +var emergencySources = []string{"primary", "replica", "rdonly"} func CreateCmd(ch *cmdutil.Helper) *cobra.Command { createReq := &ps.CreateBackupRequest{} @@ -23,7 +23,7 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { 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. --clone-tablet-type +tablet instead of waiting for the next scheduled backup. --emergency-source selects which tablet type to clone from and is required with --emergency (and rejected otherwise).`, Args: cmdutil.RequiredArgs("database", "branch"), @@ -34,14 +34,14 @@ selects which tablet type to clone from and is required with --emergency branch := args[1] if createReq.Emergency { - if !cmd.Flags().Changed("clone-tablet-type") { - return fmt.Errorf("--clone-tablet-type is required when --emergency is set") + if !cmd.Flags().Changed("emergency-source") { + return fmt.Errorf("--emergency-source is required when --emergency is set") } - if !slices.Contains(cloneTabletTypes, createReq.CloneTabletType) { - return fmt.Errorf("invalid --clone-tablet-type %q, must be one of: %s", createReq.CloneTabletType, strings.Join(cloneTabletTypes, ", ")) + 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("clone-tablet-type") { - return fmt.Errorf("--clone-tablet-type is only valid when --emergency is set") + } else if cmd.Flags().Changed("emergency-source") { + return fmt.Errorf("--emergency-source is only valid when --emergency is set") } createReq.Database = database @@ -79,8 +79,8 @@ selects which tablet type to clone from and is required with --emergency } 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 --clone-tablet-type.") - cmd.Flags().StringVar(&createReq.CloneTabletType, "clone-tablet-type", "", "Tablet type to clone from for an emergency backup: primary, replica, or rdonly. Required with --emergency; rejected otherwise.") + 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, replica, or rdonly. Required with --emergency; rejected otherwise.") return cmd } diff --git a/internal/cmd/backup/create_test.go b/internal/cmd/backup/create_test.go index 5b873d4f..f4b24b2c 100644 --- a/internal/cmd/backup/create_test.go +++ b/internal/cmd/backup/create_test.go @@ -127,7 +127,7 @@ func TestBackup_CreateCmdWithEmergency(t *testing.T) { c.Assert(req.Database, qt.Equals, db) c.Assert(req.Branch, qt.Equals, branch) c.Assert(req.Emergency, qt.IsTrue) - c.Assert(req.CloneTabletType, qt.Equals, "replica") + c.Assert(req.EmergencySource, qt.Equals, "replica") return res, nil }, @@ -146,7 +146,7 @@ func TestBackup_CreateCmdWithEmergency(t *testing.T) { } cmd := CreateCmd(ch) - cmd.SetArgs([]string{db, branch, "--emergency", "--clone-tablet-type", "replica"}) + cmd.SetArgs([]string{db, branch, "--emergency", "--emergency-source", "replica"}) err := cmd.Execute() c.Assert(err, qt.IsNil) @@ -154,7 +154,7 @@ func TestBackup_CreateCmdWithEmergency(t *testing.T) { c.Assert(buf.String(), qt.JSONEquals, res) } -func TestBackup_CreateCmdEmergencyRequiresCloneTabletType(t *testing.T) { +func TestBackup_CreateCmdEmergencyRequiresEmergencySource(t *testing.T) { c := qt.New(t) format := printer.JSON @@ -178,11 +178,11 @@ func TestBackup_CreateCmdEmergencyRequiresCloneTabletType(t *testing.T) { cmd.SetArgs([]string{"planetscale", "development", "--emergency"}) err := cmd.Execute() - c.Assert(err, qt.ErrorMatches, "--clone-tablet-type is required when --emergency is set") + c.Assert(err, qt.ErrorMatches, "--emergency-source is required when --emergency is set") c.Assert(svc.CreateFnInvoked, qt.IsFalse) } -func TestBackup_CreateCmdCloneTabletTypeRequiresEmergency(t *testing.T) { +func TestBackup_CreateCmdEmergencySourceRequiresEmergency(t *testing.T) { c := qt.New(t) format := printer.JSON @@ -203,14 +203,14 @@ func TestBackup_CreateCmdCloneTabletTypeRequiresEmergency(t *testing.T) { } cmd := CreateCmd(ch) - cmd.SetArgs([]string{"planetscale", "development", "--clone-tablet-type", "replica"}) + cmd.SetArgs([]string{"planetscale", "development", "--emergency-source", "replica"}) err := cmd.Execute() - c.Assert(err, qt.ErrorMatches, "--clone-tablet-type is only valid when --emergency is set") + c.Assert(err, qt.ErrorMatches, "--emergency-source is only valid when --emergency is set") c.Assert(svc.CreateFnInvoked, qt.IsFalse) } -func TestBackup_CreateCmdInvalidCloneTabletType(t *testing.T) { +func TestBackup_CreateCmdInvalidEmergencySource(t *testing.T) { c := qt.New(t) format := printer.JSON @@ -231,9 +231,9 @@ func TestBackup_CreateCmdInvalidCloneTabletType(t *testing.T) { } cmd := CreateCmd(ch) - cmd.SetArgs([]string{"planetscale", "development", "--emergency", "--clone-tablet-type", "bogus"}) + cmd.SetArgs([]string{"planetscale", "development", "--emergency", "--emergency-source", "bogus"}) err := cmd.Execute() - c.Assert(err, qt.ErrorMatches, `invalid --clone-tablet-type "bogus", must be one of: primary, replica, rdonly`) + c.Assert(err, qt.ErrorMatches, `invalid --emergency-source "bogus", must be one of: primary, replica, rdonly`) c.Assert(svc.CreateFnInvoked, qt.IsFalse) } diff --git a/internal/planetscale/backups.go b/internal/planetscale/backups.go index 394e95bb..2cb445f0 100644 --- a/internal/planetscale/backups.go +++ b/internal/planetscale/backups.go @@ -38,7 +38,7 @@ type CreateBackupRequest struct { RetentionUnit string `json:"retention_unit,omitempty"` RetentionValue int `json:"retention_value,omitempty"` Emergency bool `json:"emergency,omitempty"` - CloneTabletType string `json:"clone_tablet_type,omitempty"` + EmergencySource string `json:"emergency_source,omitempty"` } type ListBackupsRequest struct { From 3fd6a39100764e0927ff097e5ff9473614adb097 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 24 Oct 2026 04:25:57 +0000 Subject: [PATCH 3/3] backup create: accept only primary and replica for --emergency-source rdonly is rejected. Allowed values are primary and replica. Co-authored-by: max --- AGENTS.md | 2 +- internal/cmd/backup/create.go | 8 ++++---- internal/cmd/backup/create_test.go | 30 +++++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 24d09a0e..b5606c43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -986,7 +986,7 @@ pscale backup create --org --format json \ --emergency --emergency-source replica ``` -- `--emergency-source` is `primary`, `replica`, or `rdonly`. 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`. +- `--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 diff --git a/internal/cmd/backup/create.go b/internal/cmd/backup/create.go index 6293c4ea..350327a1 100644 --- a/internal/cmd/backup/create.go +++ b/internal/cmd/backup/create.go @@ -13,7 +13,7 @@ import ( ) // emergencySources are the valid values for --emergency-source. -var emergencySources = []string{"primary", "replica", "rdonly"} +var emergencySources = []string{"primary", "replica"} func CreateCmd(ch *cmdutil.Helper) *cobra.Command { createReq := &ps.CreateBackupRequest{} @@ -24,8 +24,8 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { --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 and is required with --emergency -(and rejected otherwise).`, +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 { @@ -80,7 +80,7 @@ selects which tablet type to clone from and is required with --emergency 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, replica, or rdonly. Required with --emergency; rejected otherwise.") + 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 } diff --git a/internal/cmd/backup/create_test.go b/internal/cmd/backup/create_test.go index f4b24b2c..8737fe9e 100644 --- a/internal/cmd/backup/create_test.go +++ b/internal/cmd/backup/create_test.go @@ -234,6 +234,34 @@ func TestBackup_CreateCmdInvalidEmergencySource(t *testing.T) { 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, rdonly`) + 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) }