diff --git a/AGENTS.md b/AGENTS.md index 3d1725d3..b5606c43 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 --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. diff --git a/internal/cmd/backup/create.go b/internal/cmd/backup/create.go index f3975b96..350327a1 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" ) +// 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 ", - 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. --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 { @@ -22,6 +33,17 @@ func CreateCmd(ch *cmdutil.Helper) *cobra.Command { 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 @@ -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 } diff --git a/internal/cmd/backup/create_test.go b/internal/cmd/backup/create_test.go index 83510e10..8737fe9e 100644 --- a/internal/cmd/backup/create_test.go +++ b/internal/cmd/backup/create_test.go @@ -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) +} diff --git a/internal/planetscale/backups.go b/internal/planetscale/backups.go index 4b831d9c..2cb445f0 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"` + EmergencySource string `json:"emergency_source,omitempty"` } type ListBackupsRequest struct {