From 79c6ee9aa683b8b86def1fe58490bf467fa09110 Mon Sep 17 00:00:00 2001 From: "planetscale-cli[bot]" <272331943+planetscale-cli[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:24:12 +0000 Subject: [PATCH] Sync API client from planetscale/cli@e3c260d --- planetscale/client.go | 4 + 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 ++++++++++++++++ planetscale/metrics.go | 165 ++++++++++++++++++ planetscale/metrics_test.go | 104 ++++++++++++ 9 files changed, 1042 insertions(+) create mode 100644 planetscale/maintenance_schedules.go create mode 100644 planetscale/maintenance_schedules_test.go create mode 100644 planetscale/metrics.go create mode 100644 planetscale/metrics_test.go diff --git a/planetscale/client.go b/planetscale/client.go index ce734e1..6a8c5f3 100644 --- a/planetscale/client.go +++ b/planetscale/client.go @@ -61,7 +61,9 @@ type Client struct { DeployRequests DeployRequestsService Keyspaces KeyspacesService LookupVindex LookupVindexService + MaintenanceSchedules MaintenanceSchedulesService Materialize MaterializeService + Metrics MetricsService MoveTables MoveTablesService Organizations OrganizationsService Passwords PasswordsService @@ -334,7 +336,9 @@ 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.Metrics = &metricsService{client: c} c.MoveTables = &moveTablesService{client: c} c.Organizations = &organizationsService{client: c} c.Passwords = &passwordsService{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) +} diff --git a/planetscale/metrics.go b/planetscale/metrics.go new file mode 100644 index 0000000..11f4621 --- /dev/null +++ b/planetscale/metrics.go @@ -0,0 +1,165 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "time" +) + +// MetricsService provides access to branch time-series and instant metrics. +type MetricsService interface { + GetSeries(context.Context, *GetMetricSeriesRequest) (*MetricSeries, error) + GetInstant(context.Context, *GetInstantMetricsRequest) (*InstantMetrics, error) +} + +type metricsService struct { + client *Client +} + +var _ MetricsService = &metricsService{} + +// MetricSeries is a collection of sampled time series over a common range. +type MetricSeries struct { + Type string `json:"type"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + Interval int `json:"interval"` + Series []*TimeSeries `json:"series"` +} + +// TimeSeries contains samples for one metric and set of dimensions. +type TimeSeries struct { + Type string `json:"type"` + Metric string `json:"metric"` + Label string `json:"label"` + Labels map[string]string `json:"labels"` + Points [][]float64 `json:"points"` +} + +// InstantMetrics contains current metric values grouped by their dimensions. +type InstantMetrics struct { + Type string `json:"type"` + Branch map[string]any `json:"branch"` + Metrics []*InstantMetric `json:"metrics"` +} + +// InstantMetric contains the current values for one metric. +type InstantMetric struct { + Metric string `json:"metric"` + Label string `json:"label"` + Values []map[string]any `json:"values"` +} + +// GetMetricSeriesRequest describes a branch time-series metrics query. +type GetMetricSeriesRequest struct { + Organization string + Database string + Branch string + Metrics []string + Period string + From string + To string + Steps int + TabletType string + Keyspace string + Shard string + Role string + Container string + Pod string + Pods []string + QueryIDs []string + Fingerprint string + BudgetID string + RuleID string + Search string +} + +// GetInstantMetricsRequest describes a branch instant metrics query. +type GetInstantMetricsRequest struct { + Organization string + Database string + Branch string + Metrics []string + Role string + Shard string + Container string + Pod string +} + +func (s *metricsService) GetSeries(ctx context.Context, getReq *GetMetricSeriesRequest) (*MetricSeries, error) { + query := url.Values{} + addQueryValues(query, "metrics[]", getReq.Metrics) + setQueryValue(query, "period", getReq.Period) + setQueryValue(query, "from", getReq.From) + setQueryValue(query, "to", getReq.To) + if getReq.Steps > 0 { + query.Set("steps", strconv.Itoa(getReq.Steps)) + } + setQueryValue(query, "tablet_type", getReq.TabletType) + setQueryValue(query, "keyspace", getReq.Keyspace) + setQueryValue(query, "shard", getReq.Shard) + setQueryValue(query, "role", getReq.Role) + setQueryValue(query, "container", getReq.Container) + setQueryValue(query, "pod", getReq.Pod) + addQueryValues(query, "pods[]", getReq.Pods) + addQueryValues(query, "query_ids[]", getReq.QueryIDs) + setQueryValue(query, "fingerprint", getReq.Fingerprint) + setQueryValue(query, "budget_id", getReq.BudgetID) + setQueryValue(query, "rule_id", getReq.RuleID) + setQueryValue(query, "q", getReq.Search) + + req, err := s.client.newRequest(http.MethodGet, metricsAPIPath(getReq.Organization, getReq.Database, getReq.Branch), nil, WithQueryParams(query)) + if err != nil { + return nil, fmt.Errorf("error creating request for branch metrics: %w", err) + } + + series := &MetricSeries{} + if err := s.client.do(ctx, req, series); err != nil { + return nil, err + } + + return series, nil +} + +func (s *metricsService) GetInstant(ctx context.Context, getReq *GetInstantMetricsRequest) (*InstantMetrics, error) { + query := url.Values{} + addQueryValues(query, "metrics[]", getReq.Metrics) + setQueryValue(query, "role", getReq.Role) + setQueryValue(query, "shard", getReq.Shard) + setQueryValue(query, "container", getReq.Container) + setQueryValue(query, "pod", getReq.Pod) + + req, err := s.client.newRequest(http.MethodGet, path.Join(metricsAPIPath(getReq.Organization, getReq.Database, getReq.Branch), "instant"), nil, WithQueryParams(query)) + if err != nil { + return nil, fmt.Errorf("error creating request for instant branch metrics: %w", err) + } + + metrics := &InstantMetrics{} + if err := s.client.do(ctx, req, metrics); err != nil { + return nil, err + } + + return metrics, nil +} + +func metricsAPIPath(org, db, branch string) string { + return path.Join("v1/organizations", org, "databases", db, "branches", branch, "metrics") +} + +func setQueryValue(query url.Values, key, value string) { + if value != "" { + query.Set(key, value) + } +} + +func addQueryValues(query url.Values, key string, values []string) { + for _, value := range values { + if value != "" { + query.Add(key, value) + } + } +} diff --git a/planetscale/metrics_test.go b/planetscale/metrics_test.go new file mode 100644 index 0000000..8d36d84 --- /dev/null +++ b/planetscale/metrics_test.go @@ -0,0 +1,104 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestMetrics_GetSeries(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/branches/main/metrics") + query := r.URL.Query() + c.Assert(query["metrics[]"], qt.DeepEquals, []string{"queries", "latency_p99"}) + c.Assert(query.Get("period"), qt.Equals, "1h") + c.Assert(query.Get("steps"), qt.Equals, "60") + c.Assert(query.Get("tablet_type"), qt.Equals, "replica") + c.Assert(query["pods[]"], qt.DeepEquals, []string{"pod-1", "pod-2"}) + c.Assert(query["query_ids[]"], qt.DeepEquals, []string{"abc-main"}) + c.Assert(query.Get("q"), qt.Equals, "checkout") + + _, err := w.Write([]byte(`{ + "type":"MetricSeries", + "start_date":"2026-08-18T16:00:00Z", + "end_date":"2026-08-18T17:00:00Z", + "interval":60, + "series":[{ + "type":"TimeSeries", + "metric":"queries", + "label":"Queries", + "labels":{}, + "points":[[1787068800,912],[1787068860,1048]] + }] + }`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + series, err := client.Metrics.GetSeries(context.Background(), &GetMetricSeriesRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "main", + Metrics: []string{"queries", "latency_p99"}, + Period: "1h", + Steps: 60, + TabletType: "replica", + Pods: []string{"pod-1", "pod-2"}, + QueryIDs: []string{"abc-main"}, + Search: "checkout", + }) + c.Assert(err, qt.IsNil) + c.Assert(series.Type, qt.Equals, "MetricSeries") + c.Assert(series.Interval, qt.Equals, 60) + c.Assert(series.Series, qt.HasLen, 1) + c.Assert(series.Series[0].Points[1][1], qt.Equals, 1048.0) +} + +func TestMetrics_GetInstant(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/branches/main/metrics/instant") + query := r.URL.Query() + c.Assert(query["metrics[]"], qt.DeepEquals, []string{"planetscale_volume_usage_percentage"}) + c.Assert(query.Get("role"), qt.Equals, "primary") + + _, err := w.Write([]byte(`{ + "type":"InstantMetrics", + "branch":{"id":"branch-id","name":"main"}, + "metrics":[{ + "metric":"planetscale_volume_usage_percentage", + "label":"volume_usage", + "values":[{"pod":"postgres-0","role":"primary","value":71.4}] + }] + }`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + metrics, err := client.Metrics.GetInstant(context.Background(), &GetInstantMetricsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "main", + Metrics: []string{"planetscale_volume_usage_percentage"}, + Role: "primary", + }) + c.Assert(err, qt.IsNil) + c.Assert(metrics.Type, qt.Equals, "InstantMetrics") + c.Assert(metrics.Branch["name"], qt.Equals, "main") + c.Assert(metrics.Metrics, qt.HasLen, 1) + c.Assert(metrics.Metrics[0].Values[0]["value"], qt.Equals, 71.4) +}