diff --git a/AGENTS.md b/AGENTS.md index 8982a240..c402a9f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,6 +270,18 @@ pscale deploy-request throttler update --org --format Alias: `pscale dr …` works the same. Vitess only. `--ratio` is 0–95 (0 disables throttling; 95 is slowest). Use either `--ratio` or `--configuration keyspace=ratio`, not both. +## Maintenance schedules (Vitess Enterprise) + +Read-only visibility into planned maintenance windows for a Vitess database (Enterprise plans): + +```bash +pscale maintenance list --org --format json +pscale maintenance show --org --format json +pscale maintenance windows --org --format json +``` + +`--org` is required. Schedules include next/last window times, frequency, and any pending Vitess/MySQL version updates. Vitess Enterprise only. + ## Postgres branch changes (size, replicas, parameters) `pscale branch resize` queues a single asynchronous **change request** for a Postgres branch covering cluster size, replica count, and configuration parameters in any combination. Track it with `resize status`; cancel it with `resize cancel` while queued. diff --git a/internal/cmd/maintenance/list.go b/internal/cmd/maintenance/list.go new file mode 100644 index 00000000..3f10d8eb --- /dev/null +++ b/internal/cmd/maintenance/list.go @@ -0,0 +1,59 @@ +package maintenance + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// ListCmd lists maintenance schedules for a database. +func ListCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List maintenance schedules for a database", + Args: cmdutil.RequiredArgs("database"), + Aliases: []string{"ls"}, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return cmdutil.DatabaseCompletionFunc(ch, cmd, args, toComplete) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching maintenance schedules for %s", printer.BoldBlue(database))) + defer end() + + schedules, err := client.MaintenanceSchedules.List(ctx, &ps.ListMaintenanceSchedulesRequest{ + Organization: ch.Config.Organization, + Database: database, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("database %s does not exist in organization %s", + printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if len(schedules) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Printf("No maintenance schedules exist for %s.\n", printer.BoldBlue(database)) + return nil + } + + return ch.Printer.PrintResource(toMaintenanceSchedules(schedules)) + }, + } + + return cmd +} diff --git a/internal/cmd/maintenance/maintenance.go b/internal/cmd/maintenance/maintenance.go new file mode 100644 index 00000000..870ad5a6 --- /dev/null +++ b/internal/cmd/maintenance/maintenance.go @@ -0,0 +1,155 @@ +package maintenance + +import ( + "encoding/json" + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// MaintenanceCmd manages maintenance schedules for a database. +func MaintenanceCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "maintenance ", + Short: "List and show maintenance schedules for a database", + Long: `List and show maintenance schedules for a Vitess database. + +Maintenance schedules define when PlanetScale can perform planned maintenance +(for example version updates). Available for Vitess databases on Enterprise plans.`, + PersistentPreRunE: cmdutil.CheckAuthentication(ch.Config), + } + + cmd.PersistentFlags().StringVar(&ch.Config.Organization, "org", ch.Config.Organization, "The organization for the current user") + cmd.MarkPersistentFlagRequired("org") // nolint:errcheck + + cmd.AddCommand(ListCmd(ch)) + cmd.AddCommand(ShowCmd(ch)) + cmd.AddCommand(WindowsCmd(ch)) + + return cmd +} + +// MaintenanceSchedule is the human/JSON/CSV view of a maintenance schedule. +type MaintenanceSchedule struct { + ID string `header:"id" json:"id"` + Name string `header:"name" json:"name"` + Enabled bool `header:"enabled" json:"enabled"` + Required bool `header:"required" json:"required"` + Frequency string `header:"frequency" json:"frequency"` + Day string `header:"day" json:"day"` + Week string `header:"week" json:"week"` + HourUTC int `header:"hour_utc" json:"hour_utc"` + DurationHours int `header:"duration_hours" json:"duration_hours"` + NextWindow int64 `header:"next_window,timestamp(ms|utc|human)" json:"next_window"` + LastWindow int64 `header:"last_window,timestamp(ms|utc|human)" json:"last_window"` + PendingVitessVersion string `header:"pending_vitess" json:"pending_vitess"` + PendingMySQLVersion string `header:"pending_mysql" json:"pending_mysql"` + ExpiresAt *int64 `header:"expires_at,timestamp(ms|utc|human)" json:"expires_at"` + DeadlineAt *int64 `header:"deadline_at,timestamp(ms|utc|human)" json:"deadline_at"` + CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` + UpdatedAt int64 `header:"updated_at,timestamp(ms|utc|human)" json:"updated_at"` + + orig *ps.MaintenanceSchedule +} + +func (s *MaintenanceSchedule) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(s.orig, "", " ") +} + +func (s *MaintenanceSchedule) MarshalCSVValue() interface{} { + return []*MaintenanceSchedule{s} +} + +func toMaintenanceSchedule(schedule *ps.MaintenanceSchedule) *MaintenanceSchedule { + out := &MaintenanceSchedule{ + ID: schedule.ID, + Name: schedule.Name, + Enabled: schedule.Enabled, + Required: schedule.Required, + Frequency: formatFrequency(schedule.FrequencyValue, schedule.FrequencyUnit), + Day: formatDay(schedule.Day), + Week: formatWeek(schedule.Week, schedule.FrequencyUnit), + HourUTC: schedule.Hour, + DurationHours: schedule.Duration, + NextWindow: printer.GetMilliseconds(schedule.NextWindowDatetime), + LastWindow: printer.GetMilliseconds(schedule.LastWindowDatetime), + ExpiresAt: printer.GetMillisecondsIfExists(schedule.ExpiresAt), + DeadlineAt: printer.GetMillisecondsIfExists(schedule.DeadlineAt), + CreatedAt: printer.GetMilliseconds(schedule.CreatedAt), + UpdatedAt: printer.GetMilliseconds(schedule.UpdatedAt), + orig: schedule, + } + if schedule.PendingVitessVersion != nil && *schedule.PendingVitessVersion != "" { + out.PendingVitessVersion = *schedule.PendingVitessVersion + } else { + out.PendingVitessVersion = "-" + } + if schedule.PendingMySQLVersion != nil && *schedule.PendingMySQLVersion != "" { + out.PendingMySQLVersion = *schedule.PendingMySQLVersion + } else { + out.PendingMySQLVersion = "-" + } + return out +} + +func toMaintenanceSchedules(schedules []*ps.MaintenanceSchedule) []*MaintenanceSchedule { + out := make([]*MaintenanceSchedule, 0, len(schedules)) + for _, schedule := range schedules { + out = append(out, toMaintenanceSchedule(schedule)) + } + return out +} + +func formatFrequency(value int, unit string) string { + if unit == "" { + return fmt.Sprintf("%d", value) + } + if value == 1 { + return unit + } + return fmt.Sprintf("%d %ss", value, unit) +} + +func formatDay(day int) string { + switch day { + case 0: + return "Sunday" + case 1: + return "Monday" + case 2: + return "Tuesday" + case 3: + return "Wednesday" + case 4: + return "Thursday" + case 5: + return "Friday" + case 6: + return "Saturday" + case 7: + return "every day" + default: + return fmt.Sprintf("%d", day) + } +} + +func formatWeek(week int, frequencyUnit string) string { + if frequencyUnit != "month" { + return "-" + } + switch week { + case 0: + return "first" + case 1: + return "second" + case 2: + return "third" + case 3: + return "fourth" + default: + return fmt.Sprintf("%d", week) + } +} diff --git a/internal/cmd/maintenance/maintenance_test.go b/internal/cmd/maintenance/maintenance_test.go new file mode 100644 index 00000000..2081ae58 --- /dev/null +++ b/internal/cmd/maintenance/maintenance_test.go @@ -0,0 +1,203 @@ +package maintenance + +import ( + "bytes" + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +func TestMaintenance_ListCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "mydb" + createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) + nextWindow := time.Date(2025, 1, 22, 4, 0, 0, 0, time.UTC) + lastWindow := time.Date(2025, 1, 15, 4, 0, 0, 0, time.UTC) + mysqlVersion := "8.0.40" + + schedules := []*ps.MaintenanceSchedule{{ + ID: "sched-1", + Name: "Weekly maintenance", + CreatedAt: createdAt, + UpdatedAt: createdAt, + LastWindowDatetime: lastWindow, + NextWindowDatetime: nextWindow, + Duration: 2, + Day: 3, + Hour: 4, + Week: 0, + FrequencyValue: 1, + FrequencyUnit: "week", + Enabled: true, + Required: false, + PendingMySQLVersionUpdate: true, + PendingMySQLVersion: &mysqlVersion, + PendingVitessVersionUpdate: false, + }} + + svc := &mock.MaintenanceSchedulesService{ + ListFn: func(ctx context.Context, req *ps.ListMaintenanceSchedulesRequest, opts ...ps.ListOption) ([]*ps.MaintenanceSchedule, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + return schedules, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{MaintenanceSchedules: svc}, nil + }, + } + + cmd := ListCmd(ch) + cmd.SetArgs([]string{db}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.ListFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []*MaintenanceSchedule{{orig: schedules[0]}}) +} + +func TestMaintenance_ShowCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "mydb" + scheduleID := "sched-1" + createdAt := time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC) + nextWindow := time.Date(2025, 1, 22, 4, 0, 0, 0, time.UTC) + lastWindow := time.Date(2025, 1, 15, 4, 0, 0, 0, time.UTC) + + schedule := &ps.MaintenanceSchedule{ + ID: scheduleID, + Name: "Weekly maintenance", + CreatedAt: createdAt, + UpdatedAt: createdAt, + LastWindowDatetime: lastWindow, + NextWindowDatetime: nextWindow, + Duration: 2, + Day: 3, + Hour: 4, + FrequencyValue: 1, + FrequencyUnit: "week", + Enabled: true, + } + + svc := &mock.MaintenanceSchedulesService{ + GetFn: func(ctx context.Context, req *ps.GetMaintenanceScheduleRequest) (*ps.MaintenanceSchedule, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Schedule, qt.Equals, scheduleID) + return schedule, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{MaintenanceSchedules: svc}, nil + }, + } + + cmd := ShowCmd(ch) + cmd.SetArgs([]string{db, scheduleID}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.GetFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, &MaintenanceSchedule{orig: schedule}) +} + +func TestFormatDay(t *testing.T) { + c := qt.New(t) + c.Assert(formatDay(0), qt.Equals, "Sunday") + c.Assert(formatDay(3), qt.Equals, "Wednesday") + c.Assert(formatDay(7), qt.Equals, "every day") + c.Assert(formatDay(99), qt.Equals, "99") +} + +func TestFormatWeek(t *testing.T) { + c := qt.New(t) + c.Assert(formatWeek(0, "month"), qt.Equals, "first") + c.Assert(formatWeek(3, "month"), qt.Equals, "fourth") + c.Assert(formatWeek(9, "month"), qt.Equals, "9") + c.Assert(formatWeek(0, "week"), qt.Equals, "-") +} + +func TestFormatFrequency(t *testing.T) { + c := qt.New(t) + c.Assert(formatFrequency(1, "week"), qt.Equals, "week") + c.Assert(formatFrequency(2, "week"), qt.Equals, "2 weeks") + c.Assert(formatFrequency(1, "once"), qt.Equals, "once") +} + +func TestMaintenance_WindowsCmd(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + format := printer.JSON + p := printer.NewPrinter(&format) + p.SetResourceOutput(&buf) + + org := "planetscale" + db := "mydb" + scheduleID := "sched-1" + createdAt := time.Date(2025, 1, 15, 4, 0, 0, 0, time.UTC) + startedAt := time.Date(2025, 1, 15, 4, 0, 0, 0, time.UTC) + finishedAt := time.Date(2025, 1, 15, 5, 30, 0, 0, time.UTC) + + windows := []*ps.MaintenanceWindow{{ + ID: "win-1", + CreatedAt: createdAt, + UpdatedAt: finishedAt, + StartedAt: &startedAt, + FinishedAt: &finishedAt, + }} + + svc := &mock.MaintenanceSchedulesService{ + ListWindowsFn: func(ctx context.Context, req *ps.ListMaintenanceWindowsRequest, opts ...ps.ListOption) ([]*ps.MaintenanceWindow, error) { + c.Assert(req.Organization, qt.Equals, org) + c.Assert(req.Database, qt.Equals, db) + c.Assert(req.Schedule, qt.Equals, scheduleID) + return windows, nil + }, + } + + ch := &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: org}, + Client: func() (*ps.Client, error) { + return &ps.Client{MaintenanceSchedules: svc}, nil + }, + } + + cmd := WindowsCmd(ch) + cmd.SetArgs([]string{db, scheduleID}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.ListWindowsFnInvoked, qt.IsTrue) + c.Assert(buf.String(), qt.JSONEquals, []*MaintenanceWindow{{orig: windows[0]}}) +} diff --git a/internal/cmd/maintenance/show.go b/internal/cmd/maintenance/show.go new file mode 100644 index 00000000..c3b29ce6 --- /dev/null +++ b/internal/cmd/maintenance/show.go @@ -0,0 +1,56 @@ +package maintenance + +import ( + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// ShowCmd shows a single maintenance schedule. +func ShowCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a maintenance schedule", + Args: cmdutil.RequiredArgs("database", "schedule-id"), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return cmdutil.DatabaseCompletionFunc(ch, cmd, args, toComplete) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + scheduleID := args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching maintenance schedule %s for %s", + printer.BoldBlue(scheduleID), printer.BoldBlue(database))) + defer end() + + schedule, err := client.MaintenanceSchedules.Get(ctx, &ps.GetMaintenanceScheduleRequest{ + Organization: ch.Config.Organization, + Database: database, + Schedule: scheduleID, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("maintenance schedule %s does not exist in database %s (organization: %s)", + printer.BoldBlue(scheduleID), printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + return ch.Printer.PrintResource(toMaintenanceSchedule(schedule)) + }, + } + + return cmd +} diff --git a/internal/cmd/maintenance/windows.go b/internal/cmd/maintenance/windows.go new file mode 100644 index 00000000..9d42cdd1 --- /dev/null +++ b/internal/cmd/maintenance/windows.go @@ -0,0 +1,101 @@ +package maintenance + +import ( + "encoding/json" + "fmt" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" + "github.com/spf13/cobra" +) + +// WindowsCmd lists maintenance windows for a schedule. +func WindowsCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "windows ", + Short: "List maintenance windows for a schedule", + Args: cmdutil.RequiredArgs("database", "schedule-id"), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return cmdutil.DatabaseCompletionFunc(ch, cmd, args, toComplete) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + database := args[0] + scheduleID := args[1] + + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching maintenance windows for schedule %s on %s", + printer.BoldBlue(scheduleID), printer.BoldBlue(database))) + defer end() + + windows, err := client.MaintenanceSchedules.ListWindows(ctx, &ps.ListMaintenanceWindowsRequest{ + Organization: ch.Config.Organization, + Database: database, + Schedule: scheduleID, + }) + if err != nil { + switch cmdutil.ErrCode(err) { + case ps.ErrNotFound: + return fmt.Errorf("maintenance schedule %s does not exist in database %s (organization: %s)", + printer.BoldBlue(scheduleID), printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization)) + default: + return cmdutil.HandleError(err) + } + } + end() + + if len(windows) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Printf("No maintenance windows exist for schedule %s on %s.\n", + printer.BoldBlue(scheduleID), printer.BoldBlue(database)) + return nil + } + + return ch.Printer.PrintResource(toMaintenanceWindows(windows)) + }, + } + + return cmd +} + +// MaintenanceWindow is the human/JSON/CSV view of a maintenance window. +type MaintenanceWindow struct { + ID string `header:"id" json:"id"` + StartedAt *int64 `header:"started_at,timestamp(ms|utc|human)" json:"started_at"` + FinishedAt *int64 `header:"finished_at,timestamp(ms|utc|human)" json:"finished_at"` + CreatedAt int64 `header:"created_at,timestamp(ms|utc|human)" json:"created_at"` + UpdatedAt int64 `header:"updated_at,timestamp(ms|utc|human)" json:"updated_at"` + + orig *ps.MaintenanceWindow +} + +func (w *MaintenanceWindow) MarshalJSON() ([]byte, error) { + return json.MarshalIndent(w.orig, "", " ") +} + +func (w *MaintenanceWindow) MarshalCSVValue() interface{} { + return []*MaintenanceWindow{w} +} + +func toMaintenanceWindow(window *ps.MaintenanceWindow) *MaintenanceWindow { + return &MaintenanceWindow{ + ID: window.ID, + StartedAt: printer.GetMillisecondsIfExists(window.StartedAt), + FinishedAt: printer.GetMillisecondsIfExists(window.FinishedAt), + CreatedAt: printer.GetMilliseconds(window.CreatedAt), + UpdatedAt: printer.GetMilliseconds(window.UpdatedAt), + orig: window, + } +} + +func toMaintenanceWindows(windows []*ps.MaintenanceWindow) []*MaintenanceWindow { + out := make([]*MaintenanceWindow, 0, len(windows)) + for _, window := range windows { + out = append(out, toMaintenanceWindow(window)) + } + return out +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 2d7b382d..495e2bd0 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -48,6 +48,7 @@ import ( "github.com/planetscale/cli/internal/cmd/insights" "github.com/planetscale/cli/internal/cmd/inspect" "github.com/planetscale/cli/internal/cmd/keyspace" + "github.com/planetscale/cli/internal/cmd/maintenance" "github.com/planetscale/cli/internal/cmd/org" "github.com/planetscale/cli/internal/cmd/password" "github.com/planetscale/cli/internal/cmd/pgbouncer" @@ -359,6 +360,10 @@ func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer. keyspaceCmd.GroupID = "vitess" rootCmd.AddCommand(keyspaceCmd) + maintenanceCmd := maintenance.MaintenanceCmd(ch) + maintenanceCmd.GroupID = "vitess" + rootCmd.AddCommand(maintenanceCmd) + passwordCmd := password.PasswordCmd(ch) passwordCmd.GroupID = "vitess" rootCmd.AddCommand(passwordCmd) diff --git a/internal/mock/maintenance_schedule.go b/internal/mock/maintenance_schedule.go new file mode 100644 index 00000000..58e6e52b --- /dev/null +++ b/internal/mock/maintenance_schedule.go @@ -0,0 +1,33 @@ +package mock + +import ( + "context" + + ps "github.com/planetscale/cli/internal/planetscale" +) + +type MaintenanceSchedulesService struct { + ListFn func(context.Context, *ps.ListMaintenanceSchedulesRequest, ...ps.ListOption) ([]*ps.MaintenanceSchedule, error) + ListFnInvoked bool + + GetFn func(context.Context, *ps.GetMaintenanceScheduleRequest) (*ps.MaintenanceSchedule, error) + GetFnInvoked bool + + ListWindowsFn func(context.Context, *ps.ListMaintenanceWindowsRequest, ...ps.ListOption) ([]*ps.MaintenanceWindow, error) + ListWindowsFnInvoked bool +} + +func (s *MaintenanceSchedulesService) List(ctx context.Context, req *ps.ListMaintenanceSchedulesRequest, opts ...ps.ListOption) ([]*ps.MaintenanceSchedule, error) { + s.ListFnInvoked = true + return s.ListFn(ctx, req, opts...) +} + +func (s *MaintenanceSchedulesService) Get(ctx context.Context, req *ps.GetMaintenanceScheduleRequest) (*ps.MaintenanceSchedule, error) { + s.GetFnInvoked = true + return s.GetFn(ctx, req) +} + +func (s *MaintenanceSchedulesService) ListWindows(ctx context.Context, req *ps.ListMaintenanceWindowsRequest, opts ...ps.ListOption) ([]*ps.MaintenanceWindow, error) { + s.ListWindowsFnInvoked = true + return s.ListWindowsFn(ctx, req, opts...) +} diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index ce734e16..06c62fc9 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -61,6 +61,7 @@ type Client struct { DeployRequests DeployRequestsService Keyspaces KeyspacesService LookupVindex LookupVindexService + MaintenanceSchedules MaintenanceSchedulesService Materialize MaterializeService MoveTables MoveTablesService Organizations OrganizationsService @@ -334,6 +335,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { c.DeployRequests = &deployRequestsService{client: c} c.Keyspaces = &keyspacesService{client: c} c.LookupVindex = &lookupVindexService{client: c} + c.MaintenanceSchedules = &maintenanceSchedulesService{client: c} c.Materialize = &materializeService{client: c} c.MoveTables = &moveTablesService{client: c} c.Organizations = &organizationsService{client: c} diff --git a/internal/planetscale/maintenance_schedules.go b/internal/planetscale/maintenance_schedules.go new file mode 100644 index 00000000..5f195d46 --- /dev/null +++ b/internal/planetscale/maintenance_schedules.go @@ -0,0 +1,152 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +// MaintenanceSchedule represents a database maintenance schedule. +// Available for Vitess databases on Enterprise plans. +type MaintenanceSchedule struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastWindowDatetime time.Time `json:"last_window_datetime"` + NextWindowDatetime time.Time `json:"next_window_datetime"` + Duration int `json:"duration"` + Day int `json:"day"` + Hour int `json:"hour"` + Week int `json:"week"` + FrequencyValue int `json:"frequency_value"` + FrequencyUnit string `json:"frequency_unit"` + Enabled bool `json:"enabled"` + ExpiresAt *time.Time `json:"expires_at"` + DeadlineAt *time.Time `json:"deadline_at"` + Required bool `json:"required"` + PendingVitessVersionUpdate bool `json:"pending_vitess_version_update"` + PendingVitessVersion *string `json:"pending_vitess_version"` + PendingMySQLVersionUpdate bool `json:"pending_mysql_version_update"` + PendingMySQLVersion *string `json:"pending_mysql_version"` +} + +// MaintenanceWindow represents a past or ongoing maintenance window for a schedule. +type MaintenanceWindow struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` +} + +type maintenanceSchedulesResponse struct { + Schedules []*MaintenanceSchedule `json:"data"` +} + +type maintenanceWindowsResponse struct { + Windows []*MaintenanceWindow `json:"data"` +} + +// ListMaintenanceSchedulesRequest encapsulates listing maintenance schedules for a database. +type ListMaintenanceSchedulesRequest struct { + Organization string + Database string +} + +// GetMaintenanceScheduleRequest encapsulates getting a single maintenance schedule. +type GetMaintenanceScheduleRequest struct { + Organization string + Database string + Schedule string +} + +// ListMaintenanceWindowsRequest encapsulates listing windows for a maintenance schedule. +type ListMaintenanceWindowsRequest struct { + Organization string + Database string + Schedule string +} + +// MaintenanceSchedulesService is an interface for the PlanetScale maintenance schedules API. +type MaintenanceSchedulesService interface { + List(context.Context, *ListMaintenanceSchedulesRequest, ...ListOption) ([]*MaintenanceSchedule, error) + Get(context.Context, *GetMaintenanceScheduleRequest) (*MaintenanceSchedule, error) + ListWindows(context.Context, *ListMaintenanceWindowsRequest, ...ListOption) ([]*MaintenanceWindow, error) +} + +type maintenanceSchedulesService struct { + client *Client +} + +var _ MaintenanceSchedulesService = &maintenanceSchedulesService{} + +func (s *maintenanceSchedulesService) List(ctx context.Context, listReq *ListMaintenanceSchedulesRequest, opts ...ListOption) ([]*MaintenanceSchedule, error) { + listOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + if err := opt(listOpts); err != nil { + return nil, err + } + } + + req, err := s.client.newRequest(http.MethodGet, maintenanceSchedulesAPIPath(listReq.Organization, listReq.Database), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for list maintenance schedules: %w", err) + } + + resp := &maintenanceSchedulesResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Schedules, nil +} + +func (s *maintenanceSchedulesService) Get(ctx context.Context, getReq *GetMaintenanceScheduleRequest) (*MaintenanceSchedule, error) { + req, err := s.client.newRequest(http.MethodGet, maintenanceScheduleAPIPath(getReq.Organization, getReq.Database, getReq.Schedule), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get maintenance schedule: %w", err) + } + + schedule := &MaintenanceSchedule{} + if err := s.client.do(ctx, req, &schedule); err != nil { + return nil, err + } + + return schedule, nil +} + +func (s *maintenanceSchedulesService) ListWindows(ctx context.Context, listReq *ListMaintenanceWindowsRequest, opts ...ListOption) ([]*MaintenanceWindow, error) { + listOpts := defaultListOptions(WithPerPage(100)) + for _, opt := range opts { + if err := opt(listOpts); err != nil { + return nil, err + } + } + + req, err := s.client.newRequest(http.MethodGet, maintenanceWindowsAPIPath(listReq.Organization, listReq.Database, listReq.Schedule), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for list maintenance windows: %w", err) + } + + resp := &maintenanceWindowsResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Windows, nil +} + +func maintenanceSchedulesAPIPath(org, db string) string { + return path.Join("v1/organizations", org, "databases", db, "maintenance-schedules") +} + +func maintenanceScheduleAPIPath(org, db, schedule string) string { + return path.Join(maintenanceSchedulesAPIPath(org, db), schedule) +} + +func maintenanceWindowsAPIPath(org, db, schedule string) string { + return path.Join(maintenanceScheduleAPIPath(org, db, schedule), "windows") +} diff --git a/internal/planetscale/maintenance_schedules_test.go b/internal/planetscale/maintenance_schedules_test.go new file mode 100644 index 00000000..59670c09 --- /dev/null +++ b/internal/planetscale/maintenance_schedules_test.go @@ -0,0 +1,147 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestMaintenanceSchedules_List(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/maintenance-schedules") + w.WriteHeader(200) + out := `{ + "data":[{ + "id":"sched-1", + "name":"Weekly maintenance", + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z", + "last_window_datetime":"2021-01-07T04:00:00.000Z", + "next_window_datetime":"2021-01-14T04:00:00.000Z", + "duration":2, + "day":3, + "hour":4, + "week":0, + "frequency_value":1, + "frequency_unit":"week", + "enabled":true, + "expires_at":null, + "deadline_at":null, + "required":false, + "pending_vitess_version_update":false, + "pending_vitess_version":null, + "pending_mysql_version_update":true, + "pending_mysql_version":"8.0.40" + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + schedules, err := client.MaintenanceSchedules.List(context.Background(), &ListMaintenanceSchedulesRequest{ + Organization: testOrg, + Database: "my-db", + }) + c.Assert(err, qt.IsNil) + c.Assert(schedules, qt.HasLen, 1) + c.Assert(schedules[0].ID, qt.Equals, "sched-1") + c.Assert(schedules[0].Name, qt.Equals, "Weekly maintenance") + c.Assert(schedules[0].Enabled, qt.IsTrue) + c.Assert(schedules[0].Day, qt.Equals, 3) + c.Assert(schedules[0].Hour, qt.Equals, 4) + c.Assert(schedules[0].PendingMySQLVersionUpdate, qt.IsTrue) + c.Assert(schedules[0].PendingMySQLVersion, qt.IsNotNil) + c.Assert(*schedules[0].PendingMySQLVersion, qt.Equals, "8.0.40") + c.Assert(schedules[0].NextWindowDatetime.Equal(time.Date(2021, time.January, 14, 4, 0, 0, 0, time.UTC)), qt.IsTrue) +} + +func TestMaintenanceSchedules_Get(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/maintenance-schedules/sched-1") + w.WriteHeader(200) + out := `{ + "id":"sched-1", + "name":"Weekly maintenance", + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z", + "last_window_datetime":"2021-01-07T04:00:00.000Z", + "next_window_datetime":"2021-01-14T04:00:00.000Z", + "duration":2, + "day":3, + "hour":4, + "week":0, + "frequency_value":1, + "frequency_unit":"week", + "enabled":true, + "expires_at":null, + "deadline_at":null, + "required":false, + "pending_vitess_version_update":false, + "pending_vitess_version":null, + "pending_mysql_version_update":false, + "pending_mysql_version":null + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + schedule, err := client.MaintenanceSchedules.Get(context.Background(), &GetMaintenanceScheduleRequest{ + Organization: testOrg, + Database: "my-db", + Schedule: "sched-1", + }) + c.Assert(err, qt.IsNil) + c.Assert(schedule.Name, qt.Equals, "Weekly maintenance") + c.Assert(schedule.FrequencyUnit, qt.Equals, "week") +} + +func TestMaintenanceSchedules_ListWindows(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/maintenance-schedules/sched-1/windows") + w.WriteHeader(200) + out := `{ + "data":[{ + "id":"win-1", + "created_at":"2021-01-07T04:00:00.000Z", + "updated_at":"2021-01-07T06:00:00.000Z", + "started_at":"2021-01-07T04:00:00.000Z", + "finished_at":"2021-01-07T05:30:00.000Z" + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + windows, err := client.MaintenanceSchedules.ListWindows(context.Background(), &ListMaintenanceWindowsRequest{ + Organization: testOrg, + Database: "my-db", + Schedule: "sched-1", + }) + c.Assert(err, qt.IsNil) + c.Assert(windows, qt.HasLen, 1) + c.Assert(windows[0].ID, qt.Equals, "win-1") + c.Assert(windows[0].StartedAt, qt.IsNotNil) + c.Assert(windows[0].FinishedAt, qt.IsNotNil) +}