From 9e10dde4c57e4a129f479e2e9007a34080a8f7cc Mon Sep 17 00:00:00 2001 From: "planetscale-cli[bot]" <272331943+planetscale-cli[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:56:10 +0000 Subject: [PATCH] Sync API client from planetscale/cli@d3f4a7a --- planetscale/client.go | 2 + planetscale/databases.go | 55 ++++++ planetscale/databases_test.go | 61 +++++++ planetscale/deploy_requests.go | 193 ++++++++++++++++++++++ planetscale/deploy_requests_test.go | 161 ++++++++++++++++++ planetscale/maintenance_schedules.go | 152 +++++++++++++++++ planetscale/maintenance_schedules_test.go | 147 ++++++++++++++++ 7 files changed, 771 insertions(+) create mode 100644 planetscale/maintenance_schedules.go create mode 100644 planetscale/maintenance_schedules_test.go diff --git a/planetscale/client.go b/planetscale/client.go index ce734e1..06c62fc 100644 --- a/planetscale/client.go +++ b/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/planetscale/databases.go b/planetscale/databases.go index 5c36bbe..0eb22f6 100644 --- a/planetscale/databases.go +++ b/planetscale/databases.go @@ -74,6 +74,29 @@ type UpdateDatabaseSettingsRequest struct { DefaultBranch *string `json:"default_branch,omitempty"` } +// GetDatabaseThrottlerRequest gets the database-level Vitess migration throttler. +type GetDatabaseThrottlerRequest struct { + Organization string `json:"-"` + Database string `json:"-"` +} + +// UpdateDatabaseThrottlerRequest updates the database-level Vitess migration throttler. +type UpdateDatabaseThrottlerRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Ratio *int `json:"ratio,omitempty"` + Configurations []*UpdateThrottlerConfiguration `json:"configurations,omitempty"` +} + +// DatabaseThrottler is the database-level throttler configuration for Vitess +// deploy request migrations. Distinct from per-deploy-request throttler and +// from tablet/vtctld throttler. +type DatabaseThrottler struct { + Keyspaces []string `json:"keyspaces"` + Configurable *ThrottlerConfigurable `json:"configurable"` + Configurations []*ThrottlerConfiguration `json:"configurations"` +} + // DatabaseService is an interface for communicating with the PlanetScale // Databases API endpoint. type DatabasesService interface { @@ -82,6 +105,8 @@ type DatabasesService interface { List(context.Context, *ListDatabasesRequest, ...ListOption) ([]*Database, error) Delete(context.Context, *DeleteDatabaseRequest) (*DatabaseDeletionRequest, error) UpdateSettings(context.Context, *UpdateDatabaseSettingsRequest) (*Database, error) + GetThrottler(context.Context, *GetDatabaseThrottlerRequest) (*DatabaseThrottler, error) + UpdateThrottler(context.Context, *UpdateDatabaseThrottlerRequest) (*DatabaseThrottler, error) } // DatabaseDeletionRequest encapsulates the request for deleting a database from @@ -231,6 +256,36 @@ func (ds *databasesService) UpdateSettings(ctx context.Context, updateReq *Updat return db, nil } +func (ds *databasesService) GetThrottler(ctx context.Context, getReq *GetDatabaseThrottlerRequest) (*DatabaseThrottler, error) { + pathStr := path.Join(databasesAPIPath(getReq.Organization), getReq.Database, "throttler") + req, err := ds.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get database throttler: %w", err) + } + + throttler := &DatabaseThrottler{} + if err := ds.client.do(ctx, req, throttler); err != nil { + return nil, err + } + + return throttler, nil +} + +func (ds *databasesService) UpdateThrottler(ctx context.Context, updateReq *UpdateDatabaseThrottlerRequest) (*DatabaseThrottler, error) { + pathStr := path.Join(databasesAPIPath(updateReq.Organization), updateReq.Database, "throttler") + req, err := ds.client.newRequest(http.MethodPatch, pathStr, updateReq) + if err != nil { + return nil, fmt.Errorf("error creating request for update database throttler: %w", err) + } + + throttler := &DatabaseThrottler{} + if err := ds.client.do(ctx, req, throttler); err != nil { + return nil, err + } + + return throttler, nil +} + func databasesAPIPath(org string) string { return path.Join("v1/organizations", org, "databases") } diff --git a/planetscale/databases_test.go b/planetscale/databases_test.go index 4535b7f..a150a60 100644 --- a/planetscale/databases_test.go +++ b/planetscale/databases_test.go @@ -580,3 +580,64 @@ func TestDatabases_UpdateSettings(t *testing.T) { c.Assert(db.RequireApprovalForDeploy, qt.IsTrue) c.Assert(db.InsightsRawQueries, qt.IsFalse) } + +func TestDatabases_GetThrottler(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/planetscale-go-test-db/throttler") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"keyspaces":["main"],"configurable":{"id":"db-1","name":"planetscale-go-test-db"},"configurations":[{"keyspace_name":"main","ratio":50}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + throttler, err := client.Databases.GetThrottler(context.Background(), &GetDatabaseThrottlerRequest{ + Organization: testOrg, + Database: testDatabase, + }) + c.Assert(err, qt.IsNil) + c.Assert(throttler.Keyspaces, qt.DeepEquals, []string{"main"}) + c.Assert(throttler.Configurations, qt.HasLen, 1) + c.Assert(throttler.Configurations[0].Ratio, qt.Equals, float64(50)) +} + +func TestDatabases_UpdateThrottler(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.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/throttler") + + var body UpdateDatabaseThrottlerRequest + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body.Ratio, qt.IsNotNil) + c.Assert(*body.Ratio, qt.Equals, 25) + c.Assert(body.Configurations, qt.DeepEquals, []*UpdateThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + }) + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"keyspaces":["main"],"configurations":[{"keyspace_name":"main","ratio":10}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ratio := 25 + throttler, err := client.Databases.UpdateThrottler(context.Background(), &UpdateDatabaseThrottlerRequest{ + Organization: testOrg, + Database: testDatabase, + Ratio: &ratio, + Configurations: []*UpdateThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + }, + }) + c.Assert(err, qt.IsNil) + c.Assert(throttler.Configurations, qt.HasLen, 1) + c.Assert(throttler.Configurations[0].Ratio, qt.Equals, float64(10)) +} diff --git a/planetscale/deploy_requests.go b/planetscale/deploy_requests.go index 8a626ef..d506979 100644 --- a/planetscale/deploy_requests.go +++ b/planetscale/deploy_requests.go @@ -30,6 +30,12 @@ type DeployRequestsService interface { Get(context.Context, *GetDeployRequestRequest) (*DeployRequest, error) List(context.Context, *ListDeployRequestsRequest) ([]*DeployRequest, error) GetDeployOperations(context.Context, *GetDeployOperationsRequest) ([]*DeployOperation, error) + GetDeployQueue(context.Context, *GetDeployQueueRequest) ([]*Deployment, error) + GetDeployment(context.Context, *GetDeploymentRequest) (*Deployment, error) + ListReviews(context.Context, *ListDeployRequestReviewsRequest) ([]*DeployRequestReview, error) + CheckStorage(context.Context, *CheckDeployRequestStorageRequest) (*DeployRequestStorageCheck, error) + GetThrottler(context.Context, *GetDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) + UpdateThrottler(context.Context, *UpdateDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) SkipRevertDeploy(context.Context, *SkipRevertDeployRequestRequest) (*DeployRequest, error) RevertDeploy(context.Context, *RevertDeployRequestRequest) (*DeployRequest, error) } @@ -135,6 +141,17 @@ type Deployment struct { InstantDDLEligible bool `json:"instant_ddl_eligible"` InstantDDL bool `json:"instant_ddl"` + AutoCutover bool `json:"auto_cutover"` + AutoDeleteBranch bool `json:"auto_delete_branch"` + CutoverExpiring bool `json:"cutover_expiring"` + TableLocked bool `json:"table_locked"` + QueuePaused bool `json:"queue_paused"` + ParallelLaneBlocked bool `json:"parallel_lane_blocked"` + DeployCheckErrors string `json:"deploy_check_errors"` + LockedTableName string `json:"locked_table_name"` + QueuePauseReason string `json:"queue_pause_reason"` + Strategy string `json:"strategy"` + Actor *Actor `json:"actor"` CutoverActor *Actor `json:"cutover_actor"` CancelledActor *Actor `json:"cancelled_actor"` @@ -144,7 +161,90 @@ type Deployment struct { StartedAt *time.Time `json:"started_at"` QueuedAt *time.Time `json:"queued_at"` FinishedAt *time.Time `json:"finished_at"` + SubmittedAt *time.Time `json:"submitted_at"` + CutoverAt *time.Time `json:"cutover_at"` + ReadyToCutoverAt *time.Time `json:"ready_to_cutover_at"` ForceCutoverRequestedAt *time.Time `json:"force_cutover_requested_at"` + SchemaLastUpdatedAt *time.Time `json:"schema_last_updated_at"` +} + +// GetDeployQueueRequest gets the deploy queue for a database. +type GetDeployQueueRequest struct { + Organization string `json:"-"` + Database string `json:"-"` +} + +// GetDeploymentRequest gets the deployment for a deploy request. +type GetDeploymentRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// ListDeployRequestReviewsRequest lists reviews for a deploy request. +type ListDeployRequestReviewsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// CheckDeployRequestStorageRequest checks storage readiness for a deploy request. +type CheckDeployRequestStorageRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// DeployRequestStorageCheck is the storage check response for a deploy request. +type DeployRequestStorageCheck struct { + EnoughStorage bool `json:"enough_storage"` + Upgradeable bool `json:"upgradeable"` + StorageBytesNeeded int64 `json:"storage_bytes_needed"` + StorageReport map[string]any `json:"storage_report"` +} + +// GetDeployRequestThrottlerRequest gets throttler config for a deploy request. +type GetDeployRequestThrottlerRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` +} + +// UpdateDeployRequestThrottlerRequest updates throttler config for a deploy request. +type UpdateDeployRequestThrottlerRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Number uint64 `json:"-"` + Ratio *int `json:"ratio,omitempty"` + Configurations []*UpdateThrottlerConfiguration `json:"configurations,omitempty"` +} + +// UpdateThrottlerConfiguration is a per-keyspace throttler ratio for updates. +type UpdateThrottlerConfiguration struct { + KeyspaceName string `json:"keyspace_name"` + Ratio int `json:"ratio"` +} + +// DeployRequestThrottler is the throttler configuration for a deploy request. +type DeployRequestThrottler struct { + Keyspaces []string `json:"keyspaces"` + Configurable *ThrottlerConfigurable `json:"configurable"` + Configurations []*ThrottlerConfiguration `json:"configurations"` +} + +// ThrottlerConfigurable identifies the resource owning throttler config. +type ThrottlerConfigurable struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` +} + +// ThrottlerConfiguration is a per-keyspace throttler ratio. +type ThrottlerConfiguration struct { + KeyspaceName string `json:"keyspace_name"` + Ratio float64 `json:"ratio"` } // DeployRequest encapsulates the request to deploy a database branch's schema @@ -552,6 +652,99 @@ func (d *deployRequestsService) GetDeployOperations(ctx context.Context, getReq return resp.Ops, nil } +type deployQueueResponse struct { + Deployments []*Deployment `json:"data"` +} + +func (d *deployRequestsService) GetDeployQueue(ctx context.Context, getReq *GetDeployQueueRequest) ([]*Deployment, error) { + pathStr := path.Join(databasesAPIPath(getReq.Organization), getReq.Database, "deploy-queue") + req, err := d.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &deployQueueResponse{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Deployments, nil +} + +func (d *deployRequestsService) GetDeployment(ctx context.Context, getReq *GetDeploymentRequest) (*Deployment, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(getReq.Organization, getReq.Database, getReq.Number, "deployment"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + deployment := &Deployment{} + if err := d.client.do(ctx, req, deployment); err != nil { + return nil, err + } + + return deployment, nil +} + +type deployRequestReviewsResponse struct { + Reviews []*DeployRequestReview `json:"data"` +} + +func (d *deployRequestsService) ListReviews(ctx context.Context, listReq *ListDeployRequestReviewsRequest) ([]*DeployRequestReview, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(listReq.Organization, listReq.Database, listReq.Number, "reviews"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resp := &deployRequestReviewsResponse{} + if err := d.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Reviews, nil +} + +func (d *deployRequestsService) CheckStorage(ctx context.Context, checkReq *CheckDeployRequestStorageRequest) (*DeployRequestStorageCheck, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(checkReq.Organization, checkReq.Database, checkReq.Number, "storage-check"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + check := &DeployRequestStorageCheck{} + if err := d.client.do(ctx, req, check); err != nil { + return nil, err + } + + return check, nil +} + +func (d *deployRequestsService) GetThrottler(ctx context.Context, getReq *GetDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) { + req, err := d.client.newRequest(http.MethodGet, deployRequestActionAPIPath(getReq.Organization, getReq.Database, getReq.Number, "throttler"), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + throttler := &DeployRequestThrottler{} + if err := d.client.do(ctx, req, throttler); err != nil { + return nil, err + } + + return throttler, nil +} + +func (d *deployRequestsService) UpdateThrottler(ctx context.Context, updateReq *UpdateDeployRequestThrottlerRequest) (*DeployRequestThrottler, error) { + req, err := d.client.newRequest(http.MethodPatch, deployRequestActionAPIPath(updateReq.Organization, updateReq.Database, updateReq.Number, "throttler"), updateReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + throttler := &DeployRequestThrottler{} + if err := d.client.do(ctx, req, throttler); err != nil { + return nil, err + } + + return throttler, nil +} + func deployRequestsAPIPath(org, db string) string { return path.Join(databasesAPIPath(org), db, "deploy-requests") } diff --git a/planetscale/deploy_requests_test.go b/planetscale/deploy_requests_test.go index 16f6980..cbe807d 100644 --- a/planetscale/deploy_requests_test.go +++ b/planetscale/deploy_requests_test.go @@ -623,3 +623,164 @@ func TestDeployRequests_DeployOperations(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(do, qt.DeepEquals, want) } + +func TestDeployRequests_GetDeployQueue(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/test-organization/databases/test-database/deploy-queue") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"type":"list","data":[{"id":"dep-1","deploy_request_number":7,"state":"queued","auto_cutover":true}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + deployments, err := client.DeployRequests.GetDeployQueue(context.Background(), &GetDeployQueueRequest{ + Organization: "test-organization", + Database: "test-database", + }) + c.Assert(err, qt.IsNil) + c.Assert(deployments, qt.HasLen, 1) + c.Assert(deployments[0].ID, qt.Equals, "dep-1") + c.Assert(deployments[0].DeployRequestNumber, qt.Equals, uint64(7)) + c.Assert(deployments[0].AutoCutover, qt.IsTrue) +} + +func TestDeployRequests_GetDeployment(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/test-organization/databases/test-database/deploy-requests/42/deployment") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"id":"dep-1","deploy_request_number":42,"state":"in_progress","auto_delete_branch":true}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + deployment, err := client.DeployRequests.GetDeployment(context.Background(), &GetDeploymentRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(deployment.ID, qt.Equals, "dep-1") + c.Assert(deployment.AutoDeleteBranch, qt.IsTrue) +} + +func TestDeployRequests_ListReviews(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/test-organization/databases/test-database/deploy-requests/42/reviews") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"type":"list","data":[{"id":"rev-1","state":"approved","body":"lgtm","actor":{"display_name":"gomez"}}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + reviews, err := client.DeployRequests.ListReviews(context.Background(), &ListDeployRequestReviewsRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(reviews, qt.HasLen, 1) + c.Assert(reviews[0].ID, qt.Equals, "rev-1") + c.Assert(reviews[0].Actor.Name, qt.Equals, "gomez") +} + +func TestDeployRequests_CheckStorage(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/test-organization/databases/test-database/deploy-requests/42/storage-check") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"enough_storage":true,"upgradeable":false,"storage_bytes_needed":0,"storage_report":{}}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + check, err := client.DeployRequests.CheckStorage(context.Background(), &CheckDeployRequestStorageRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(check.EnoughStorage, qt.IsTrue) + c.Assert(check.StorageBytesNeeded, qt.Equals, int64(0)) +} + +func TestDeployRequests_GetThrottler(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/test-organization/databases/test-database/deploy-requests/42/throttler") + w.WriteHeader(200) + _, err := w.Write([]byte(`{"keyspaces":["main"],"configurable":{"id":"dr-1","name":"42"},"configurations":[{"keyspace_name":"main","ratio":50}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + throttler, err := client.DeployRequests.GetThrottler(context.Background(), &GetDeployRequestThrottlerRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + }) + c.Assert(err, qt.IsNil) + c.Assert(throttler.Keyspaces, qt.DeepEquals, []string{"main"}) + c.Assert(throttler.Configurations, qt.HasLen, 1) + c.Assert(throttler.Configurations[0].Ratio, qt.Equals, float64(50)) +} + +func TestDeployRequests_UpdateThrottler(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.MethodPatch) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/test-organization/databases/test-database/deploy-requests/42/throttler") + + var body UpdateDeployRequestThrottlerRequest + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body.Ratio, qt.IsNotNil) + c.Assert(*body.Ratio, qt.Equals, 25) + c.Assert(body.Configurations, qt.DeepEquals, []*UpdateThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + }) + + w.WriteHeader(200) + _, err := w.Write([]byte(`{"keyspaces":["main"],"configurations":[{"keyspace_name":"main","ratio":10}]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ratio := 25 + throttler, err := client.DeployRequests.UpdateThrottler(context.Background(), &UpdateDeployRequestThrottlerRequest{ + Organization: "test-organization", + Database: "test-database", + Number: 42, + Ratio: &ratio, + Configurations: []*UpdateThrottlerConfiguration{ + {KeyspaceName: "main", Ratio: 10}, + }, + }) + c.Assert(err, qt.IsNil) + c.Assert(throttler.Configurations, qt.HasLen, 1) + c.Assert(throttler.Configurations[0].Ratio, qt.Equals, float64(10)) +} diff --git a/planetscale/maintenance_schedules.go b/planetscale/maintenance_schedules.go new file mode 100644 index 0000000..5f195d4 --- /dev/null +++ b/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/planetscale/maintenance_schedules_test.go b/planetscale/maintenance_schedules_test.go new file mode 100644 index 0000000..59670c0 --- /dev/null +++ b/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) +}