From c47942876998bcbf401e690f6ba03c00f2b6bace Mon Sep 17 00:00:00 2001 From: "planetscale-cli[bot]" <272331943+planetscale-cli[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:36:22 +0000 Subject: [PATCH] Sync API client from planetscale/cli@1601bb3 --- planetscale/branch_resizes.go | 140 +++++++++++++++++++++++++++++ planetscale/branches.go | 6 ++ planetscale/branches_test.go | 138 ++++++++++++++++++++++++++++ planetscale/client.go | 9 +- planetscale/client_test.go | 18 ++++ planetscale/databases.go | 21 +++-- planetscale/databases_test.go | 34 +++++++ planetscale/vtctld_general.go | 15 ++-- planetscale/vtctld_general_test.go | 39 ++++++-- 9 files changed, 399 insertions(+), 21 deletions(-) create mode 100644 planetscale/branch_resizes.go diff --git a/planetscale/branch_resizes.go b/planetscale/branch_resizes.go new file mode 100644 index 0000000..8d2d7a2 --- /dev/null +++ b/planetscale/branch_resizes.go @@ -0,0 +1,140 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +// BranchResizeRequest represents a Vitess branch VTGate resize request. +type BranchResizeRequest struct { + ID string `json:"id"` + Type string `json:"type"` + State string `json:"state"` + Actor *Actor `json:"actor"` + + VTGateSize string `json:"vtgate_size"` + PreviousVTGateSize string `json:"previous_vtgate_size"` + VTGateName string `json:"vtgate_name"` + VTGateDisplayName string `json:"vtgate_display_name"` + PreviousVTGateName string `json:"previous_vtgate_name"` + PreviousVTGateDisplayName string `json:"previous_vtgate_display_name"` + VTGateCount int `json:"vtgate_count"` + PreviousVTGateCount int `json:"previous_vtgate_count"` + VTGateMaxCount *int `json:"vtgate_max_count"` + PreviousVTGateMaxCount *int `json:"previous_vtgate_max_count"` + VTGateAutoscaling bool `json:"vtgate_autoscaling"` + PreviousVTGateAutoscaling bool `json:"previous_vtgate_autoscaling"` + VTGateTargetCPUUtilization *int `json:"vtgate_target_cpu_utilization"` + PreviousVTGateTargetCPUUtilization *int `json:"previous_vtgate_target_cpu_utilization"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +// ResizeBranchRequest encapsulates a request to resize a branch's VTGates. +type ResizeBranchRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + + VTGateSize string `json:"vtgate_size,omitempty"` + VTGateCount *int `json:"vtgate_count,omitempty"` + VTGateMaxCount *int `json:"vtgate_max_count,omitempty"` + VTGateAutoscaling *bool `json:"vtgate_autoscaling,omitempty"` + VTGateTargetCPUUtilization *int `json:"vtgate_target_cpu_utilization,omitempty"` +} + +// ListBranchResizesRequest encapsulates a request to list branch VTGate resize requests. +type ListBranchResizesRequest struct { + Organization string + Database string + Branch string +} + +// CancelBranchResizeRequest encapsulates a request to cancel a queued branch VTGate resize. +type CancelBranchResizeRequest struct { + Organization string + Database string + Branch string +} + +// BranchResizeStatusRequest encapsulates a request for the latest branch VTGate resize status. +type BranchResizeStatusRequest struct { + Organization string + Database string + Branch string +} + +type branchResizesResponse struct { + Resizes []*BranchResizeRequest `json:"data"` +} + +func branchResizesAPIPath(org, db, branch string) string { + return path.Join(databaseBranchAPIPath(org, db, branch), "resizes") +} + +// Resize queues or updates a VTGate resize for a Vitess branch. +func (d *databaseBranchesService) Resize(ctx context.Context, resizeReq *ResizeBranchRequest) (*BranchResizeRequest, error) { + req, err := d.client.newRequest(http.MethodPut, branchResizesAPIPath(resizeReq.Organization, resizeReq.Database, resizeReq.Branch), resizeReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resize := &BranchResizeRequest{} + if err := d.client.do(ctx, req, resize); err != nil { + return nil, err + } + + return resize, nil +} + +// ListResizes returns VTGate resize requests for a Vitess branch. +func (d *databaseBranchesService) ListResizes(ctx context.Context, listReq *ListBranchResizesRequest) ([]*BranchResizeRequest, error) { + req, err := d.client.newRequest(http.MethodGet, branchResizesAPIPath(listReq.Organization, listReq.Database, listReq.Branch), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + resizes := &branchResizesResponse{} + if err := d.client.do(ctx, req, resizes); err != nil { + return nil, err + } + + return resizes.Resizes, nil +} + +// CancelResize cancels a queued VTGate resize for a Vitess branch. +func (d *databaseBranchesService) CancelResize(ctx context.Context, cancelReq *CancelBranchResizeRequest) error { + req, err := d.client.newRequest(http.MethodDelete, branchResizesAPIPath(cancelReq.Organization, cancelReq.Database, cancelReq.Branch), nil) + if err != nil { + return fmt.Errorf("error creating http request: %w", err) + } + + return d.client.do(ctx, req, nil) +} + +// ResizeStatus returns the most recent VTGate resize request for a Vitess branch. +func (d *databaseBranchesService) ResizeStatus(ctx context.Context, statusReq *BranchResizeStatusRequest) (*BranchResizeRequest, error) { + resizes, err := d.ListResizes(ctx, &ListBranchResizesRequest{ + Organization: statusReq.Organization, + Database: statusReq.Database, + Branch: statusReq.Branch, + }) + if err != nil { + return nil, err + } + + if len(resizes) == 0 { + return nil, &Error{ + msg: "Not Found", + Code: ErrNotFound, + } + } + + return resizes[0], nil +} diff --git a/planetscale/branches.go b/planetscale/branches.go index 3062a37..6e1b118 100644 --- a/planetscale/branches.go +++ b/planetscale/branches.go @@ -29,6 +29,8 @@ type DatabaseBranch struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` SafeMigrations bool `json:"safe_migrations"` + VTGateSize string `json:"vtgate_size"` + VTGateCount int `json:"vtgate_count"` } type databaseBranchesResponse struct { @@ -180,6 +182,10 @@ type DatabaseBranchesService interface { DisableSafeMigrations(context.Context, *DisableSafeMigrationsRequest) (*DatabaseBranch, error) LintSchema(context.Context, *LintSchemaRequest) ([]*SchemaLintError, error) ListClusterSKUs(context.Context, *ListBranchClusterSKUsRequest, ...ListOption) ([]*ClusterSKU, error) + Resize(context.Context, *ResizeBranchRequest) (*BranchResizeRequest, error) + ListResizes(context.Context, *ListBranchResizesRequest) ([]*BranchResizeRequest, error) + CancelResize(context.Context, *CancelBranchResizeRequest) error + ResizeStatus(context.Context, *BranchResizeStatusRequest) (*BranchResizeRequest, error) } // ListBranchClusterSKUsRequest encapsulates the request for getting a list of Cluster SKUs for a branch. diff --git a/planetscale/branches_test.go b/planetscale/branches_test.go index cfe7fe5..40a2b60 100644 --- a/planetscale/branches_test.go +++ b/planetscale/branches_test.go @@ -674,3 +674,141 @@ func TestDatabaseBranches_ListClusterSKUsWithRates(t *testing.T) { c.Assert(orgs, qt.DeepEquals, want) } + +func TestDatabaseBranches_Resize(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"id":"resize-id","type":"BranchResizeRequest","state":"pending","vtgate_size":"vg.c1.xlarge","previous_vtgate_size":"vg.c1.nano","vtgate_name":"VTG_320","previous_vtgate_name":"VTG_5","vtgate_display_name":"VTG-320","previous_vtgate_display_name":"VTG-5","vtgate_count":2,"previous_vtgate_count":1,"vtgate_autoscaling":false,"previous_vtgate_autoscaling":false,"created_at":"2024-06-25T18:03:09.439Z","updated_at":"2024-06-25T18:03:09.439Z","started_at":"2024-06-25T18:03:09.459Z","completed_at":null}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodPut) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/branches/baz/resizes") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + count := 2 + resize, err := client.DatabaseBranches.Resize(ctx, &ResizeBranchRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + VTGateSize: "VTG_320", + VTGateCount: &count, + }) + + c.Assert(err, qt.IsNil) + c.Assert(resize.ID, qt.Equals, "resize-id") + c.Assert(resize.State, qt.Equals, "pending") + c.Assert(resize.VTGateName, qt.Equals, "VTG_320") + c.Assert(resize.PreviousVTGateName, qt.Equals, "VTG_5") + c.Assert(resize.VTGateCount, qt.Equals, 2) + c.Assert(resize.PreviousVTGateCount, qt.Equals, 1) +} + +func TestDatabaseBranches_ListResizes(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"id":"resize-id","type":"BranchResizeRequest","state":"completed","vtgate_size":"vg.c1.xlarge","previous_vtgate_size":"vg.c1.nano","vtgate_name":"VTG_320","previous_vtgate_name":"VTG_5","vtgate_display_name":"VTG-320","previous_vtgate_display_name":"VTG-5","vtgate_count":1,"previous_vtgate_count":1,"vtgate_autoscaling":false,"previous_vtgate_autoscaling":false,"created_at":"2024-06-25T18:03:09.439Z","updated_at":"2024-06-25T18:04:06.238Z","started_at":"2024-06-25T18:03:09.459Z","completed_at":"2024-06-25T18:04:06.228Z"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/branches/baz/resizes") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + resizes, err := client.DatabaseBranches.ListResizes(ctx, &ListBranchResizesRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + }) + + c.Assert(err, qt.IsNil) + c.Assert(resizes, qt.HasLen, 1) + c.Assert(resizes[0].ID, qt.Equals, "resize-id") + c.Assert(resizes[0].State, qt.Equals, "completed") +} + +func TestDatabaseBranches_CancelResize(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + c.Assert(r.Method, qt.Equals, http.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/branches/baz/resizes") + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + err = client.DatabaseBranches.CancelResize(ctx, &CancelBranchResizeRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + }) + + c.Assert(err, qt.IsNil) +} + +func TestDatabaseBranches_ResizeStatus(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + out := `{"data":[{"id":"latest","type":"BranchResizeRequest","state":"queued","vtgate_size":"vg.c1.2xlarge","previous_vtgate_size":"vg.c1.xlarge","vtgate_name":"VTG_640","previous_vtgate_name":"VTG_320","vtgate_display_name":"VTG-640","previous_vtgate_display_name":"VTG-320","vtgate_count":1,"previous_vtgate_count":1,"vtgate_autoscaling":false,"previous_vtgate_autoscaling":false,"created_at":"2024-06-25T18:03:09.439Z","updated_at":"2024-06-25T18:03:09.439Z","started_at":null,"completed_at":null}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + resize, err := client.DatabaseBranches.ResizeStatus(ctx, &BranchResizeStatusRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + }) + + c.Assert(err, qt.IsNil) + c.Assert(resize.ID, qt.Equals, "latest") + c.Assert(resize.State, qt.Equals, "queued") + c.Assert(resize.VTGateName, qt.Equals, "VTG_640") +} + +func TestDatabaseBranches_ResizeStatus_NotFound(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, err := w.Write([]byte(`{"data":[]}`)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + resize, err := client.DatabaseBranches.ResizeStatus(ctx, &BranchResizeStatusRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + }) + + wantError := &Error{ + msg: "Not Found", + Code: ErrNotFound, + } + + c.Assert(resize, qt.IsNil) + c.Assert(err.Error(), qt.Equals, wantError.Error()) +} diff --git a/planetscale/client.go b/planetscale/client.go index ae2f430..ffd8ae0 100644 --- a/planetscale/client.go +++ b/planetscale/client.go @@ -462,8 +462,9 @@ func (c *Client) handleResponse(ctx context.Context, res *http.Response, v inter } return &Error{ - msg: errorRes.Message, - Code: errCode, + msg: errorRes.Message, + Code: errCode, + APICode: errorRes.Code, } } @@ -661,6 +662,10 @@ type Error struct { // Code specifies the error code. i.e; NotFound, RateLimited, etc... Code ErrorCode + // APICode is the raw code from the API error JSON body (e.g. + // "schema_mutation_blocked"). Empty when the response had no code field. + APICode string + // Meta contains additional information depending on the error code. As an // example, if the Code is "ErrResponseMalformed", the map will be: ["body"] // = "body of the response" diff --git a/planetscale/client_test.go b/planetscale/client_test.go index 5826b3a..c966774 100644 --- a/planetscale/client_test.go +++ b/planetscale/client_test.go @@ -70,6 +70,19 @@ func TestDo(t *testing.T) { Code: ErrInvalid, }, }, + { + desc: "preserves raw API code for schema_mutation_blocked", + statusCode: http.StatusUnprocessableEntity, + method: http.MethodPost, + response: `{ + "code": "schema_mutation_blocked", + "message": "MoveTables create in progress" + }`, + expectedError: &Error{ + msg: "MoveTables create in progress", + APICode: "schema_mutation_blocked", + }, + }, { desc: "returns ErrorResponse for 5xx errors", statusCode: http.StatusInternalServerError, @@ -165,6 +178,11 @@ func TestDo(t *testing.T) { if err != nil { if tt.expectedError != nil { c.Assert(tt.expectedError.Error(), qt.Equals, err.Error()) + if want, ok := tt.expectedError.(*Error); ok && want.APICode != "" { + got, ok := err.(*Error) + c.Assert(ok, qt.IsTrue) + c.Assert(got.APICode, qt.Equals, want.APICode) + } } } diff --git a/planetscale/databases.go b/planetscale/databases.go index f8313aa..04ca982 100644 --- a/planetscale/databases.go +++ b/planetscale/databases.go @@ -23,15 +23,18 @@ type StorageConfig struct { // CreateDatabaseRequest encapsulates the request for creating a new database. type CreateDatabaseRequest struct { - Organization string - Name string `json:"name"` - Notes string `json:"notes,omitempty"` - Region string `json:"region,omitempty"` - ClusterSize string `json:"cluster_size,omitempty"` - Kind DatabaseEngine `json:"kind,omitempty"` - Replicas *int `json:"replicas,omitempty"` - MajorVersion string `json:"major_version,omitempty"` - Storage *StorageConfig `json:"storage,omitempty"` + Organization string + Name string `json:"name"` + Notes string `json:"notes,omitempty"` + Region string `json:"region,omitempty"` + ClusterSize string `json:"cluster_size,omitempty"` + Kind DatabaseEngine `json:"kind,omitempty"` + Replicas *int `json:"replicas,omitempty"` + MajorVersion string `json:"major_version,omitempty"` + Storage *StorageConfig `json:"storage,omitempty"` + CloudflareAccountID string `json:"cloudflare_account_id,omitempty"` + CloudflareTimestamp string `json:"cloudflare_timestamp,omitempty"` + CloudflareSignature string `json:"cloudflare_signature,omitempty"` } // DatabaseRequest encapsulates the request for getting a single database. diff --git a/planetscale/databases_test.go b/planetscale/databases_test.go index 003a6f7..fc40800 100644 --- a/planetscale/databases_test.go +++ b/planetscale/databases_test.go @@ -57,6 +57,40 @@ func TestDatabases_Create(t *testing.T) { c.Assert(db, qt.DeepEquals, want) } +func TestDatabases_CreateCloudflareBilling(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["cloudflare_account_id"], qt.Equals, "cf_account_123") + c.Assert(body["cloudflare_timestamp"], qt.Equals, "1710000000") + c.Assert(body["cloudflare_signature"], qt.Equals, "abc123sig") + + out := `{"id":"planetscale-go-test-db","type":"database","name":"planetscale-go-test-db","notes":"","created_at":"2021-01-14T10:19:23.000Z","updated_at":"2021-01-14T10:19:23.000Z", "region": { "slug": "us-west", "display_name": "US West" },"state":"ready"}` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + db, err := client.Databases.Create(ctx, &CreateDatabaseRequest{ + Organization: "my-org", + Region: "us-west", + Name: "planetscale-go-test-db", + CloudflareAccountID: "cf_account_123", + CloudflareTimestamp: "1710000000", + CloudflareSignature: "abc123sig", + }) + + c.Assert(err, qt.IsNil) + c.Assert(db.Name, qt.Equals, "planetscale-go-test-db") +} + func TestDatabases_CreatePostgres(t *testing.T) { c := qt.New(t) diff --git a/planetscale/vtctld_general.go b/planetscale/vtctld_general.go index 5ea3ecf..28026dc 100644 --- a/planetscale/vtctld_general.go +++ b/planetscale/vtctld_general.go @@ -164,14 +164,19 @@ type VtctldUpdateThrottlerConfigRequest struct { Keyspace string `json:"keyspace"` // Enabled controls whether the tablet throttler is enabled for the keyspace. - // It is required: the API has no tri-state, so omitting it would disable the - // throttler. - Enabled bool `json:"enabled"` - // Threshold is the threshold for the default throttler check (replication lag - // in seconds). It must be >= 0; the server defaults to 5.0 when omitted. + // Omit to leave the enable state unchanged. + Enabled *bool `json:"enabled,omitempty"` + // Threshold is the threshold for the default throttler check (replication + // lag in seconds). It must be >= 0. Omit to leave the existing threshold + // unchanged. Threshold *float64 `json:"threshold,omitempty"` // Apps configures zero or more per-app throttling rules. Apps []VtctldThrottledAppConfig `json:"apps,omitempty"` + // UnthrottleApps names apps whose throttled-app rules should be removed. + UnthrottleApps []string `json:"unthrottle_apps,omitempty"` + // AppCheckedMetrics assigns metrics to check per app name + // (e.g. {"rowstreamer": ["lag", "loadavg"]}). + AppCheckedMetrics map[string][]string `json:"app_checked_metrics,omitempty"` } type vtctldService struct { diff --git a/planetscale/vtctld_general_test.go b/planetscale/vtctld_general_test.go index bfc2285..1a720e3 100644 --- a/planetscale/vtctld_general_test.go +++ b/planetscale/vtctld_general_test.go @@ -520,7 +520,7 @@ func TestVtctld_UpdateThrottlerConfig(t *testing.T) { Database: "my-db", Branch: "my-branch", Keyspace: "commerce", - Enabled: true, + Enabled: boolPtr(true), Threshold: float64Ptr(2.5), Apps: []VtctldThrottledAppConfig{ {Name: "online-ddl", Ratio: float64Ptr(0.5)}, @@ -537,13 +537,10 @@ func TestVtctld_UpdateThrottlerConfig_DisableSendsEnabledFalse(t *testing.T) { err := json.NewDecoder(r.Body).Decode(&body) c.Assert(err, qt.IsNil) - // enabled is a plain bool, so it is always present in the body, even - // when false. The server has no tri-state, so this must be explicit. _, hasEnabled := body["enabled"] c.Assert(hasEnabled, qt.IsTrue) c.Assert(body["enabled"], qt.Equals, false) - // Optional threshold/apps are omitted when unset. _, hasThreshold := body["threshold"] c.Assert(hasThreshold, qt.IsFalse) @@ -562,7 +559,39 @@ func TestVtctld_UpdateThrottlerConfig_DisableSendsEnabledFalse(t *testing.T) { Database: "my-db", Branch: "my-branch", Keyspace: "commerce", - Enabled: false, + Enabled: boolPtr(false), + }) + c.Assert(err, qt.IsNil) +} + +func TestVtctld_UpdateThrottlerConfig_OmitsEnabledWhenNil(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + + _, hasEnabled := body["enabled"] + c.Assert(hasEnabled, qt.IsFalse) + c.Assert(body["unthrottle_apps"], qt.DeepEquals, []interface{}{"rowstreamer"}) + + w.WriteHeader(200) + _, err = w.Write([]byte(`{"data":{}}`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + ctx := context.Background() + _, err = client.Vtctld.UpdateThrottlerConfig(ctx, &VtctldUpdateThrottlerConfigRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "my-branch", + Keyspace: "commerce", + UnthrottleApps: []string{"rowstreamer"}, }) c.Assert(err, qt.IsNil) }