From c7c96b9d1fbc480de2dde817940fc9d6c223111b Mon Sep 17 00:00:00 2001 From: "planetscale-cli[bot]" <272331943+planetscale-cli[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:27:15 +0000 Subject: [PATCH] Sync API client from planetscale/cli@18931e9 --- planetscale/auth_attempt_exports.go | 165 ++++++++++++++++++++ planetscale/backup_policies.go | 183 ++++++++++++++++++++++ planetscale/backup_policies_test.go | 227 ++++++++++++++++++++++++++++ planetscale/client.go | 18 ++- planetscale/databases.go | 62 +++++++- planetscale/databases_test.go | 81 ++++++++++ planetscale/keyspaces.go | 31 ++++ planetscale/keyspaces_test.go | 53 +++++++ planetscale/postgres_cidrs.go | 168 ++++++++++++++++++++ planetscale/postgres_cidrs_test.go | 207 +++++++++++++++++++++++++ planetscale/query_patterns.go | 50 +----- planetscale/signed_download.go | 85 +++++++++++ planetscale/signed_download_test.go | 32 ++++ 13 files changed, 1305 insertions(+), 57 deletions(-) create mode 100644 planetscale/auth_attempt_exports.go create mode 100644 planetscale/backup_policies.go create mode 100644 planetscale/backup_policies_test.go create mode 100644 planetscale/postgres_cidrs.go create mode 100644 planetscale/postgres_cidrs_test.go create mode 100644 planetscale/signed_download.go create mode 100644 planetscale/signed_download_test.go diff --git a/planetscale/auth_attempt_exports.go b/planetscale/auth_attempt_exports.go new file mode 100644 index 0000000..66141e6 --- /dev/null +++ b/planetscale/auth_attempt_exports.go @@ -0,0 +1,165 @@ +package planetscale + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path" + "strconv" + "strings" + "time" +) + +type AuthAttemptExportFilters struct { + SourceIPs []string `json:"source_ips,omitempty"` + Branches []string `json:"branches,omitempty"` + Outcomes []string `json:"outcomes,omitempty"` + Usernames []string `json:"usernames,omitempty"` + StartupDatabases []string `json:"startup_databases,omitempty"` + FailureReasons []string `json:"failure_reasons,omitempty"` + BackendRoutes []string `json:"backend_routes,omitempty"` +} + +type CreateAuthAttemptExportRequest struct { + Organization string `json:"-"` + StartAt time.Time `json:"start_at"` + EndAt time.Time `json:"end_at"` + Format string `json:"format"` + Filters AuthAttemptExportFilters `json:"filters,omitempty"` +} + +type GetAuthAttemptExportRequest struct { + Organization string + Export string +} + +type DownloadAuthAttemptExportRequest struct { + Organization string + Export string +} + +type AuthAttemptExport struct { + PublicID string `json:"id"` + State string `json:"state"` + Expired bool `json:"expired"` + StartAt time.Time `json:"start_at"` + EndAt time.Time `json:"end_at"` + Filters AuthAttemptExportFilters `json:"filters"` + Format string `json:"format"` + ResolvedBranchPublicIDs []string `json:"resolved_branch_public_ids"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at"` + GeneratedAt *time.Time `json:"generated_at"` + FinishedAt *time.Time `json:"finished_at"` + ExpiresAt *time.Time `json:"expires_at"` + FailureReason string `json:"failure_reason"` + FailureDetail string `json:"failure_detail"` + RecoveryHint string `json:"recovery_hint"` + RetryAfter time.Duration `json:"-"` +} + +type AuthAttemptExportsService interface { + CreateExport(context.Context, *CreateAuthAttemptExportRequest) (*AuthAttemptExport, error) + GetExport(context.Context, *GetAuthAttemptExportRequest) (*AuthAttemptExport, error) + DownloadExport(context.Context, *DownloadAuthAttemptExportRequest) (io.ReadCloser, error) +} + +type authAttemptExportsService struct { + client *Client +} + +var _ AuthAttemptExportsService = &authAttemptExportsService{} + +func (s *authAttemptExportsService) CreateExport(ctx context.Context, createReq *CreateAuthAttemptExportRequest) (*AuthAttemptExport, error) { + req, err := s.client.newRequest(http.MethodPost, authAttemptExportsAPIPath(createReq.Organization), createReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + export := &AuthAttemptExport{} + headers, err := s.client.doWithHeaders(ctx, req, export) + if err != nil { + return nil, err + } + export.RetryAfter = parseRetryAfter(headers.Get("Retry-After")) + return export, nil +} + +func (s *authAttemptExportsService) GetExport(ctx context.Context, getReq *GetAuthAttemptExportRequest) (*AuthAttemptExport, error) { + req, err := s.client.newRequest(http.MethodGet, authAttemptExportAPIPath(getReq.Organization, getReq.Export), nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + export := &AuthAttemptExport{} + headers, err := s.client.doWithHeaders(ctx, req, export) + if err != nil { + if expiredErr := authAttemptExportExpiredError(err); expiredErr != nil { + return nil, expiredErr + } + return nil, err + } + export.RetryAfter = parseRetryAfter(headers.Get("Retry-After")) + return export, nil +} + +func (s *authAttemptExportsService) DownloadExport(ctx context.Context, downloadReq *DownloadAuthAttemptExportRequest) (io.ReadCloser, error) { + reqPath := path.Join(authAttemptExportAPIPath(downloadReq.Organization, downloadReq.Export), "download") + req, err := s.client.newRequest(http.MethodGet, reqPath, nil) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + body, err := s.client.downloadSignedURL(ctx, req) + if err != nil { + if expiredErr := authAttemptExportExpiredError(err); expiredErr != nil { + return nil, expiredErr + } + return nil, fmt.Errorf("downloading auth attempt export: %w", err) + } + return body, nil +} + +func authAttemptExportExpiredError(err error) error { + var apiErr *Error + if !errors.As(err, &apiErr) || apiErr.Meta["http_status"] != http.StatusText(http.StatusGone) { + return nil + } + + export := &AuthAttemptExport{} + if json.Unmarshal([]byte(apiErr.Meta["body"]), export) != nil || !export.Expired || export.PublicID == "" || export.RecoveryHint == "" { + return nil + } + return fmt.Errorf("auth attempt export %s expired: %s", export.PublicID, export.RecoveryHint) +} + +func authAttemptExportsAPIPath(org string) string { + return path.Join("v1/organizations", org, "auth-attempt-exports") +} + +func authAttemptExportAPIPath(org, export string) string { + return path.Join(authAttemptExportsAPIPath(org), export) +} + +func parseRetryAfter(value string) time.Duration { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + + seconds, err := strconv.ParseUint(value, 10, 64) + if err == nil { + if seconds > 0 && seconds <= uint64((time.Duration(1<<63-1))/time.Second) { + return time.Duration(seconds) * time.Second + } + return 0 + } + + when, err := http.ParseTime(value) + if err != nil || !when.After(time.Now()) { + return 0 + } + return time.Until(when) +} diff --git a/planetscale/backup_policies.go b/planetscale/backup_policies.go new file mode 100644 index 0000000..293e57b --- /dev/null +++ b/planetscale/backup_policies.go @@ -0,0 +1,183 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +// BackupPolicy represents a scheduled backup policy for a database. +type BackupPolicy struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + Target string `json:"target"` + RetentionValue int `json:"retention_value"` + RetentionUnit string `json:"retention_unit"` + FrequencyValue int `json:"frequency_value"` + FrequencyUnit string `json:"frequency_unit"` + ScheduleTime string `json:"schedule_time"` + ScheduleDay *int `json:"schedule_day"` + ScheduleWeek *int `json:"schedule_week"` + Required bool `json:"required"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastRanAt *time.Time `json:"last_ran_at"` + NextRunAt *time.Time `json:"next_run_at"` +} + +type backupPoliciesResponse struct { + Policies []*BackupPolicy `json:"data"` +} + +// ListBackupPoliciesRequest encapsulates listing backup policies for a database. +type ListBackupPoliciesRequest struct { + Organization string + Database string +} + +// GetBackupPolicyRequest encapsulates getting a single backup policy. +type GetBackupPolicyRequest struct { + Organization string + Database string + Policy string +} + +// CreateBackupPolicyRequest encapsulates creating a backup policy. +type CreateBackupPolicyRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Name string `json:"name,omitempty"` + Target string `json:"target"` + RetentionValue int `json:"retention_value"` + RetentionUnit string `json:"retention_unit"` + FrequencyValue int `json:"frequency_value"` + FrequencyUnit string `json:"frequency_unit"` + ScheduleTime string `json:"schedule_time"` + ScheduleDay *int `json:"schedule_day,omitempty"` + ScheduleWeek *int `json:"schedule_week,omitempty"` +} + +// UpdateBackupPolicyRequest encapsulates updating a backup policy. +type UpdateBackupPolicyRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Policy string `json:"-"` + Name *string `json:"name,omitempty"` + Target *string `json:"target,omitempty"` + RetentionValue *int `json:"retention_value,omitempty"` + RetentionUnit *string `json:"retention_unit,omitempty"` + FrequencyValue *int `json:"frequency_value,omitempty"` + FrequencyUnit *string `json:"frequency_unit,omitempty"` + ScheduleTime *string `json:"schedule_time,omitempty"` + ScheduleDay *int `json:"schedule_day,omitempty"` + ScheduleWeek *int `json:"schedule_week,omitempty"` +} + +// DeleteBackupPolicyRequest encapsulates deleting a backup policy. +type DeleteBackupPolicyRequest struct { + Organization string + Database string + Policy string +} + +// BackupPoliciesService is an interface for the PlanetScale backup policies API. +type BackupPoliciesService interface { + List(context.Context, *ListBackupPoliciesRequest, ...ListOption) ([]*BackupPolicy, error) + Get(context.Context, *GetBackupPolicyRequest) (*BackupPolicy, error) + Create(context.Context, *CreateBackupPolicyRequest) (*BackupPolicy, error) + Update(context.Context, *UpdateBackupPolicyRequest) (*BackupPolicy, error) + Delete(context.Context, *DeleteBackupPolicyRequest) error +} + +type backupPoliciesService struct { + client *Client +} + +var _ BackupPoliciesService = &backupPoliciesService{} + +func NewBackupPoliciesService(client *Client) *backupPoliciesService { + return &backupPoliciesService{client: client} +} + +func (s *backupPoliciesService) List(ctx context.Context, listReq *ListBackupPoliciesRequest, opts ...ListOption) ([]*BackupPolicy, 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, backupPoliciesAPIPath(listReq.Organization, listReq.Database), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for list backup policies: %w", err) + } + + resp := &backupPoliciesResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Policies, nil +} + +func (s *backupPoliciesService) Get(ctx context.Context, getReq *GetBackupPolicyRequest) (*BackupPolicy, error) { + req, err := s.client.newRequest(http.MethodGet, backupPolicyAPIPath(getReq.Organization, getReq.Database, getReq.Policy), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get backup policy: %w", err) + } + + policy := &BackupPolicy{} + if err := s.client.do(ctx, req, &policy); err != nil { + return nil, err + } + + return policy, nil +} + +func (s *backupPoliciesService) Create(ctx context.Context, createReq *CreateBackupPolicyRequest) (*BackupPolicy, error) { + req, err := s.client.newRequest(http.MethodPost, backupPoliciesAPIPath(createReq.Organization, createReq.Database), createReq) + if err != nil { + return nil, fmt.Errorf("error creating request for create backup policy: %w", err) + } + + policy := &BackupPolicy{} + if err := s.client.do(ctx, req, &policy); err != nil { + return nil, err + } + + return policy, nil +} + +func (s *backupPoliciesService) Update(ctx context.Context, updateReq *UpdateBackupPolicyRequest) (*BackupPolicy, error) { + req, err := s.client.newRequest(http.MethodPatch, backupPolicyAPIPath(updateReq.Organization, updateReq.Database, updateReq.Policy), updateReq) + if err != nil { + return nil, fmt.Errorf("error creating request for update backup policy: %w", err) + } + + policy := &BackupPolicy{} + if err := s.client.do(ctx, req, &policy); err != nil { + return nil, err + } + + return policy, nil +} + +func (s *backupPoliciesService) Delete(ctx context.Context, deleteReq *DeleteBackupPolicyRequest) error { + req, err := s.client.newRequest(http.MethodDelete, backupPolicyAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.Policy), nil) + if err != nil { + return fmt.Errorf("error creating request for delete backup policy: %w", err) + } + + return s.client.do(ctx, req, nil) +} + +func backupPoliciesAPIPath(org, db string) string { + return path.Join("v1/organizations", org, "databases", db, "backup-policies") +} + +func backupPolicyAPIPath(org, db, policy string) string { + return path.Join(backupPoliciesAPIPath(org, db), policy) +} diff --git a/planetscale/backup_policies_test.go b/planetscale/backup_policies_test.go new file mode 100644 index 0000000..6643cfb --- /dev/null +++ b/planetscale/backup_policies_test.go @@ -0,0 +1,227 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestBackupPolicies_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/backup-policies") + w.WriteHeader(200) + out := `{ + "data":[{ + "id":"policy-1", + "display_name":"Backup policy Production daily", + "name":"Production daily", + "target":"production", + "retention_value":2, + "retention_unit":"day", + "frequency_value":12, + "frequency_unit":"hour", + "schedule_time":"09:10", + "schedule_day":null, + "schedule_week":null, + "required":true, + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z", + "last_ran_at":null, + "next_run_at":"2021-01-15T03:23:00.000Z" + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + policies, err := client.BackupPolicies.List(context.Background(), &ListBackupPoliciesRequest{ + Organization: testOrg, + Database: "my-db", + }) + c.Assert(err, qt.IsNil) + c.Assert(policies, qt.HasLen, 1) + c.Assert(policies[0].ID, qt.Equals, "policy-1") + c.Assert(policies[0].Target, qt.Equals, "production") + c.Assert(policies[0].Required, qt.IsTrue) + c.Assert(policies[0].ScheduleDay, qt.IsNil) + c.Assert(policies[0].NextRunAt, qt.IsNotNil) + c.Assert(policies[0].NextRunAt.Equal(time.Date(2021, time.January, 15, 3, 23, 0, 0, time.UTC)), qt.IsTrue) +} + +func TestBackupPolicies_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/backup-policies/policy-1") + w.WriteHeader(200) + out := `{ + "id":"policy-1", + "display_name":"Backup policy Production daily", + "name":"Production daily", + "target":"production", + "retention_value":2, + "retention_unit":"day", + "frequency_value":12, + "frequency_unit":"hour", + "schedule_time":"09:10", + "required":true, + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z" + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + policy, err := client.BackupPolicies.Get(context.Background(), &GetBackupPolicyRequest{ + Organization: testOrg, + Database: "my-db", + Policy: "policy-1", + }) + c.Assert(err, qt.IsNil) + c.Assert(policy.Name, qt.Equals, "Production daily") +} + +func TestBackupPolicies_Create(t *testing.T) { + c := qt.New(t) + + day := 1 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/backup-policies") + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["name"], qt.Equals, "Weekly prod") + c.Assert(body["target"], qt.Equals, "production") + c.Assert(body["retention_value"], qt.Equals, float64(7)) + c.Assert(body["retention_unit"], qt.Equals, "day") + c.Assert(body["frequency_value"], qt.Equals, float64(1)) + c.Assert(body["frequency_unit"], qt.Equals, "week") + c.Assert(body["schedule_time"], qt.Equals, "03:00") + c.Assert(body["schedule_day"], qt.Equals, float64(1)) + + w.WriteHeader(201) + out := `{ + "id":"policy-2", + "display_name":"Backup policy Weekly prod", + "name":"Weekly prod", + "target":"production", + "retention_value":7, + "retention_unit":"day", + "frequency_value":1, + "frequency_unit":"week", + "schedule_time":"03:00", + "schedule_day":1, + "required":false, + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z" + }` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + policy, err := client.BackupPolicies.Create(context.Background(), &CreateBackupPolicyRequest{ + Organization: testOrg, + Database: "my-db", + Name: "Weekly prod", + Target: "production", + RetentionValue: 7, + RetentionUnit: "day", + FrequencyValue: 1, + FrequencyUnit: "week", + ScheduleTime: "03:00", + ScheduleDay: &day, + }) + c.Assert(err, qt.IsNil) + c.Assert(policy.ID, qt.Equals, "policy-2") + c.Assert(policy.Required, qt.IsFalse) +} + +func TestBackupPolicies_Update(t *testing.T) { + c := qt.New(t) + + retention := 14 + unit := "day" + 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/my-db/backup-policies/policy-1") + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["retention_value"], qt.Equals, float64(14)) + c.Assert(body["retention_unit"], qt.Equals, "day") + _, ok := body["target"] + c.Assert(ok, qt.IsFalse) + + w.WriteHeader(200) + out := `{ + "id":"policy-1", + "display_name":"Backup policy Production daily", + "name":"Production daily", + "target":"production", + "retention_value":14, + "retention_unit":"day", + "frequency_value":12, + "frequency_unit":"hour", + "schedule_time":"09:10", + "required":true, + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z" + }` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + policy, err := client.BackupPolicies.Update(context.Background(), &UpdateBackupPolicyRequest{ + Organization: testOrg, + Database: "my-db", + Policy: "policy-1", + RetentionValue: &retention, + RetentionUnit: &unit, + }) + c.Assert(err, qt.IsNil) + c.Assert(policy.RetentionValue, qt.Equals, 14) +} + +func TestBackupPolicies_Delete(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.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/backup-policies/policy-2") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.BackupPolicies.Delete(context.Background(), &DeleteBackupPolicyRequest{ + Organization: testOrg, + Database: "my-db", + Policy: "policy-2", + }) + c.Assert(err, qt.IsNil) +} diff --git a/planetscale/client.go b/planetscale/client.go index 4211649..684b5f5 100644 --- a/planetscale/client.go +++ b/planetscale/client.go @@ -50,6 +50,8 @@ type Client struct { baseURL *url.URL AuditLogs AuditLogsService + AuthAttemptExports AuthAttemptExportsService + BackupPolicies BackupPoliciesService Backups BackupsService BranchInfrastructure BranchInfrastructureService D1ImportNotifications D1ImportNotificationsService @@ -65,6 +67,7 @@ type Client struct { Passwords PasswordsService PlannedReparentShard PlannedReparentShardService PostgresBranches PostgresBranchesService + PostgresCIDRs PostgresCIDRsService PostgresRoles PostgresRolesService Processlist ProcesslistService QueryInsights QueryInsightsService @@ -319,6 +322,8 @@ func NewClient(opts ...ClientOption) (*Client, error) { c.client.CheckRedirect = makeSameHostCheckRedirect(c.baseURL.Hostname()) c.AuditLogs = &auditlogsService{client: c} + c.AuthAttemptExports = &authAttemptExportsService{client: c} + c.BackupPolicies = &backupPoliciesService{client: c} c.Backups = &backupsService{client: c} c.BranchInfrastructure = &branchInfrastructureService{client: c} c.D1ImportNotifications = &d1ImportNotificationsService{client: c} @@ -335,6 +340,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { c.PlannedReparentShard = &plannedReparentShardService{client: c} c.Processlist = &processlistService{client: c} c.PostgresBranches = &postgresBranchesService{client: c} + c.PostgresCIDRs = &postgresCIDRsService{client: c} c.PostgresRoles = &postgresRolesService{client: c} c.QueryInsights = &queryInsightsService{client: c} c.QueryPatterns = &queryPatternsService{client: c} @@ -354,14 +360,18 @@ func NewClient(opts ...ClientOption) (*Client, error) { // do makes an HTTP request and populates the given struct v from the response. func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) error { - req = req.WithContext(ctx) - res, err := c.client.Do(req) + _, err := c.doWithHeaders(ctx, req, v) + return err +} + +func (c *Client) doWithHeaders(ctx context.Context, req *http.Request, v interface{}) (http.Header, error) { + res, err := c.client.Do(req.WithContext(ctx)) if err != nil { - return err + return nil, err } defer res.Body.Close() - return c.handleResponse(ctx, res, v) + return res.Header, c.handleResponse(ctx, res, v) } // handleResponse makes an HTTP request and populates the given struct v from diff --git a/planetscale/databases.go b/planetscale/databases.go index 04ca982..5c36bbe 100644 --- a/planetscale/databases.go +++ b/planetscale/databases.go @@ -56,6 +56,24 @@ type DeleteDatabaseRequest struct { Database string } +// UpdateDatabaseSettingsRequest encapsulates the request for updating +// database settings. +type UpdateDatabaseSettingsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + NewName *string `json:"new_name,omitempty"` + AutomaticMigrations *bool `json:"automatic_migrations,omitempty"` + MigrationFramework *string `json:"migration_framework,omitempty"` + MigrationTableName *string `json:"migration_table_name,omitempty"` + RequireApprovalForDeploy *bool `json:"require_approval_for_deploy,omitempty"` + RestrictBranchRegion *bool `json:"restrict_branch_region,omitempty"` + AllowDataBranching *bool `json:"allow_data_branching,omitempty"` + AllowForeignKeyConstraints *bool `json:"allow_foreign_key_constraints,omitempty"` + InsightsRawQueries *bool `json:"insights_raw_queries,omitempty"` + ProductionBranchWebConsole *bool `json:"production_branch_web_console,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` +} + // DatabaseService is an interface for communicating with the PlanetScale // Databases API endpoint. type DatabasesService interface { @@ -63,6 +81,7 @@ type DatabasesService interface { Get(context.Context, *GetDatabaseRequest) (*Database, error) List(context.Context, *ListDatabasesRequest, ...ListOption) ([]*Database, error) Delete(context.Context, *DeleteDatabaseRequest) (*DatabaseDeletionRequest, error) + UpdateSettings(context.Context, *UpdateDatabaseSettingsRequest) (*Database, error) } // DatabaseDeletionRequest encapsulates the request for deleting a database from @@ -86,14 +105,25 @@ const ( // Database represents a PlanetScale database type Database struct { - Name string `json:"name"` - Notes string `json:"notes"` - Region Region `json:"region"` - State DatabaseState `json:"state"` - Kind DatabaseEngine `json:"kind"` - HtmlURL string `json:"html_url"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Notes string `json:"notes"` + Region Region `json:"region"` + State DatabaseState `json:"state"` + Kind DatabaseEngine `json:"kind"` + HtmlURL string `json:"html_url"` + DefaultBranch string `json:"default_branch"` + RequireApprovalForDeploy bool `json:"require_approval_for_deploy"` + RestrictBranchRegion bool `json:"restrict_branch_region"` + AllowDataBranching bool `json:"allow_data_branching"` + ForeignKeysEnabled bool `json:"foreign_keys_enabled"` + AutomaticMigrations *bool `json:"automatic_migrations"` + InsightsRawQueries bool `json:"insights_raw_queries"` + InsightsEnabled bool `json:"insights_enabled"` + ProductionBranchWebConsole bool `json:"production_branch_web_console"` + MigrationTableName *string `json:"migration_table_name"` + MigrationFramework *string `json:"migration_framework"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Database represents a list of PlanetScale databases @@ -185,6 +215,22 @@ func (ds *databasesService) Delete(ctx context.Context, deleteReq *DeleteDatabas return dbr, nil } +func (ds *databasesService) UpdateSettings(ctx context.Context, updateReq *UpdateDatabaseSettingsRequest) (*Database, error) { + path := path.Join(databasesAPIPath(updateReq.Organization), updateReq.Database) + req, err := ds.client.newRequest(http.MethodPatch, path, updateReq) + if err != nil { + return nil, fmt.Errorf("error creating request for update database settings: %w", err) + } + + db := &Database{} + err = ds.client.do(ctx, req, &db) + if err != nil { + return nil, err + } + + return db, 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 fc40800..4535b7f 100644 --- a/planetscale/databases_test.go +++ b/planetscale/databases_test.go @@ -499,3 +499,84 @@ func TestDatabases_Empty(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(db, qt.HasLen, 0) } + +func TestDatabases_UpdateSettings(t *testing.T) { + c := qt.New(t) + + newName := "renamed" + defaultBranch := "main" + requireApproval := true + insightsRaw := false + disabled := false + framework := "rails" + tableName := "schema_migrations" + + 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") + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["new_name"], qt.Equals, newName) + c.Assert(body["default_branch"], qt.Equals, defaultBranch) + c.Assert(body["require_approval_for_deploy"], qt.Equals, requireApproval) + c.Assert(body["insights_raw_queries"], qt.Equals, insightsRaw) + c.Assert(body["restrict_branch_region"], qt.Equals, disabled) + c.Assert(body["allow_data_branching"], qt.Equals, disabled) + c.Assert(body["allow_foreign_key_constraints"], qt.Equals, disabled) + c.Assert(body["automatic_migrations"], qt.Equals, disabled) + c.Assert(body["migration_framework"], qt.Equals, framework) + c.Assert(body["migration_table_name"], qt.Equals, tableName) + c.Assert(body["production_branch_web_console"], qt.Equals, disabled) + + w.WriteHeader(200) + 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", + "kind":"mysql", + "default_branch":"main", + "require_approval_for_deploy":true, + "restrict_branch_region":false, + "allow_data_branching":false, + "foreign_keys_enabled":false, + "insights_raw_queries":false, + "insights_enabled":true, + "production_branch_web_console":true + }` + _, 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.UpdateSettings(ctx, &UpdateDatabaseSettingsRequest{ + Organization: testOrg, + Database: testDatabase, + NewName: &newName, + DefaultBranch: &defaultBranch, + RequireApprovalForDeploy: &requireApproval, + RestrictBranchRegion: &disabled, + AllowDataBranching: &disabled, + AllowForeignKeyConstraints: &disabled, + AutomaticMigrations: &disabled, + MigrationFramework: &framework, + MigrationTableName: &tableName, + InsightsRawQueries: &insightsRaw, + ProductionBranchWebConsole: &disabled, + }) + + c.Assert(err, qt.IsNil) + c.Assert(db.Name, qt.Equals, testDatabase) + c.Assert(db.DefaultBranch, qt.Equals, defaultBranch) + c.Assert(db.RequireApprovalForDeploy, qt.IsTrue) + c.Assert(db.InsightsRawQueries, qt.IsFalse) +} diff --git a/planetscale/keyspaces.go b/planetscale/keyspaces.go index 3b3b9d0..d5eaecc 100644 --- a/planetscale/keyspaces.go +++ b/planetscale/keyspaces.go @@ -34,6 +34,12 @@ type ReadOnlyRegionKeyspace struct { Replicas int `json:"replicas"` } +type ReadOnlyRegionKeyspaceConfig struct { + Region string `json:"region"` + ClusterSize *string `json:"cluster_size,omitempty"` + Replicas *int `json:"replicas,omitempty"` +} + // VSchema represnts the VSchema for a branch keyspace type VSchema struct { Raw string `json:"raw"` @@ -64,6 +70,14 @@ type GetKeyspaceRequest struct { Full bool `json:"-"` } +type UpdateReadOnlyRegionsRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Branch string `json:"-"` + Keyspace string `json:"-"` + ReadOnlyRegions []*ReadOnlyRegionKeyspaceConfig `json:"read_only_regions"` +} + type GetKeyspaceVSchemaRequest struct { Organization string `json:"-"` Database string `json:"-"` @@ -170,6 +184,7 @@ type KeyspacesService interface { Create(context.Context, *CreateKeyspaceRequest) (*Keyspace, error) List(context.Context, *ListKeyspacesRequest) ([]*Keyspace, error) Get(context.Context, *GetKeyspaceRequest) (*Keyspace, error) + UpdateReadOnlyRegions(context.Context, *UpdateReadOnlyRegionsRequest) ([]*ReadOnlyRegionKeyspace, error) VSchema(context.Context, *GetKeyspaceVSchemaRequest) (*VSchema, error) UpdateVSchema(context.Context, *UpdateKeyspaceVSchemaRequest) (*VSchema, error) Resize(context.Context, *ResizeKeyspaceRequest) (*KeyspaceResizeRequest, error) @@ -224,6 +239,22 @@ func (s *keyspacesService) Get(ctx context.Context, getReq *GetKeyspaceRequest) return keyspace, nil } +// UpdateReadOnlyRegions configures a keyspace's read-only regions. +func (s *keyspacesService) UpdateReadOnlyRegions(ctx context.Context, updateReq *UpdateReadOnlyRegionsRequest) ([]*ReadOnlyRegionKeyspace, error) { + pathStr := path.Join(keyspaceAPIPath(updateReq.Organization, updateReq.Database, updateReq.Branch, updateReq.Keyspace), "read-only-regions") + req, err := s.client.newRequest(http.MethodPut, pathStr, updateReq) + if err != nil { + return nil, fmt.Errorf("error creating http request: %w", err) + } + + regions := []*ReadOnlyRegionKeyspace{} + if err := s.client.do(ctx, req, ®ions); err != nil { + return nil, err + } + + return regions, nil +} + // Create creates a keyspace for a branch func (s *keyspacesService) Create(ctx context.Context, createReq *CreateKeyspaceRequest) (*Keyspace, error) { req, err := s.client.newRequest(http.MethodPost, keyspacesAPIPath(createReq.Organization, createReq.Database, createReq.Branch), createReq) diff --git a/planetscale/keyspaces_test.go b/planetscale/keyspaces_test.go index 112c86b..3325c43 100644 --- a/planetscale/keyspaces_test.go +++ b/planetscale/keyspaces_test.go @@ -2,6 +2,7 @@ package planetscale import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -108,6 +109,58 @@ func TestKeyspaces_GetFull(t *testing.T) { }}) } +func TestKeyspaces_UpdateReadOnlyRegions(t *testing.T) { + c := qt.New(t) + clusterSize := "PS_20" + replicas := 2 + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodPut) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/foo/databases/bar/branches/baz/keyspaces/main/read-only-regions") + + var body struct { + ReadOnlyRegions []*ReadOnlyRegionKeyspaceConfig `json:"read_only_regions"` + } + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body.ReadOnlyRegions, qt.DeepEquals, []*ReadOnlyRegionKeyspaceConfig{ + {Region: "us-west", ClusterSize: &clusterSize, Replicas: &replicas}, + {Region: "eu-west"}, + }) + + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(`[{ + "region": "us-west", + "cluster_name": "PS_20", + "cluster_display_name": "PS-20", + "replicas": 2 + }]`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + regions, err := client.Keyspaces.UpdateReadOnlyRegions(context.Background(), &UpdateReadOnlyRegionsRequest{ + Organization: "foo", + Database: "bar", + Branch: "baz", + Keyspace: "main", + ReadOnlyRegions: []*ReadOnlyRegionKeyspaceConfig{ + {Region: "us-west", ClusterSize: &clusterSize, Replicas: &replicas}, + {Region: "eu-west"}, + }, + }) + + c.Assert(err, qt.IsNil) + c.Assert(regions, qt.DeepEquals, []*ReadOnlyRegionKeyspace{{ + Region: "us-west", + ClusterName: "PS_20", + ClusterDisplayName: "PS-20", + Replicas: 2, + }}) +} + func TestKeyspaces_Create(t *testing.T) { c := qt.New(t) diff --git a/planetscale/postgres_cidrs.go b/planetscale/postgres_cidrs.go new file mode 100644 index 0000000..85ab513 --- /dev/null +++ b/planetscale/postgres_cidrs.go @@ -0,0 +1,168 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "path" + "time" +) + +// PostgresCIDR represents a Postgres IP restriction (CIDR restriction) entry. +type PostgresCIDR struct { + ID string `json:"id"` + Schema string `json:"schema"` + Role string `json:"role"` + CIDRs []string `json:"cidrs"` + Description *string `json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` + Actor Actor `json:"actor"` +} + +type postgresCIDRsResponse struct { + CIDRs []*PostgresCIDR `json:"data"` +} + +// ListPostgresCIDRsRequest encapsulates listing IP restriction entries for a database. +type ListPostgresCIDRsRequest struct { + Organization string + Database string +} + +// GetPostgresCIDRRequest encapsulates getting a single IP restriction entry. +type GetPostgresCIDRRequest struct { + Organization string + Database string + ID string +} + +// CreatePostgresCIDRRequest encapsulates creating an IP restriction entry. +type CreatePostgresCIDRRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + Schema string `json:"schema,omitempty"` + Role string `json:"role,omitempty"` + CIDRs []string `json:"cidrs"` + Description string `json:"description,omitempty"` +} + +// UpdatePostgresCIDRRequest encapsulates updating an IP restriction entry. +// Only set fields are sent. Pass an empty string for Description to clear it. +type UpdatePostgresCIDRRequest struct { + Organization string `json:"-"` + Database string `json:"-"` + ID string `json:"-"` + Schema *string `json:"schema,omitempty"` + Role *string `json:"role,omitempty"` + CIDRs []string `json:"cidrs,omitempty"` + Description *string `json:"description,omitempty"` +} + +// DeletePostgresCIDRRequest encapsulates deleting an IP restriction entry. +type DeletePostgresCIDRRequest struct { + Organization string + Database string + ID string +} + +// PostgresCIDRsService is an interface for the PlanetScale Postgres IP restriction API. +type PostgresCIDRsService interface { + List(context.Context, *ListPostgresCIDRsRequest, ...ListOption) ([]*PostgresCIDR, error) + Get(context.Context, *GetPostgresCIDRRequest) (*PostgresCIDR, error) + Create(context.Context, *CreatePostgresCIDRRequest) (*PostgresCIDR, error) + Update(context.Context, *UpdatePostgresCIDRRequest) (*PostgresCIDR, error) + Delete(context.Context, *DeletePostgresCIDRRequest) error +} + +type postgresCIDRsService struct { + client *Client +} + +var _ PostgresCIDRsService = &postgresCIDRsService{} + +// NewPostgresCIDRsService returns a PostgresCIDRsService that uses the given client. +func NewPostgresCIDRsService(client *Client) *postgresCIDRsService { + return &postgresCIDRsService{client: client} +} + +func (s *postgresCIDRsService) List(ctx context.Context, listReq *ListPostgresCIDRsRequest, opts ...ListOption) ([]*PostgresCIDR, 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, postgresCIDRsAPIPath(listReq.Organization, listReq.Database), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, fmt.Errorf("error creating request for list postgres cidrs: %w", err) + } + + resp := &postgresCIDRsResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.CIDRs, nil +} + +func (s *postgresCIDRsService) Get(ctx context.Context, getReq *GetPostgresCIDRRequest) (*PostgresCIDR, error) { + req, err := s.client.newRequest(http.MethodGet, postgresCIDRAPIPath(getReq.Organization, getReq.Database, getReq.ID), nil) + if err != nil { + return nil, fmt.Errorf("error creating request for get postgres cidr: %w", err) + } + + entry := &PostgresCIDR{} + if err := s.client.do(ctx, req, &entry); err != nil { + return nil, err + } + + return entry, nil +} + +func (s *postgresCIDRsService) Create(ctx context.Context, createReq *CreatePostgresCIDRRequest) (*PostgresCIDR, error) { + req, err := s.client.newRequest(http.MethodPost, postgresCIDRsAPIPath(createReq.Organization, createReq.Database), createReq) + if err != nil { + return nil, fmt.Errorf("error creating request for create postgres cidr: %w", err) + } + + entry := &PostgresCIDR{} + if err := s.client.do(ctx, req, &entry); err != nil { + return nil, err + } + + return entry, nil +} + +func (s *postgresCIDRsService) Update(ctx context.Context, updateReq *UpdatePostgresCIDRRequest) (*PostgresCIDR, error) { + req, err := s.client.newRequest(http.MethodPatch, postgresCIDRAPIPath(updateReq.Organization, updateReq.Database, updateReq.ID), updateReq) + if err != nil { + return nil, fmt.Errorf("error creating request for update postgres cidr: %w", err) + } + + entry := &PostgresCIDR{} + if err := s.client.do(ctx, req, &entry); err != nil { + return nil, err + } + + return entry, nil +} + +func (s *postgresCIDRsService) Delete(ctx context.Context, deleteReq *DeletePostgresCIDRRequest) error { + req, err := s.client.newRequest(http.MethodDelete, postgresCIDRAPIPath(deleteReq.Organization, deleteReq.Database, deleteReq.ID), nil) + if err != nil { + return fmt.Errorf("error creating request for delete postgres cidr: %w", err) + } + + return s.client.do(ctx, req, nil) +} + +func postgresCIDRsAPIPath(org, db string) string { + return path.Join("v1/organizations", org, "databases", db, "cidrs") +} + +func postgresCIDRAPIPath(org, db, id string) string { + return path.Join(postgresCIDRsAPIPath(org, db), id) +} diff --git a/planetscale/postgres_cidrs_test.go b/planetscale/postgres_cidrs_test.go new file mode 100644 index 0000000..30732e0 --- /dev/null +++ b/planetscale/postgres_cidrs_test.go @@ -0,0 +1,207 @@ +package planetscale + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +func TestPostgresCIDRs_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/cidrs") + w.WriteHeader(200) + out := `{ + "data":[{ + "id":"cidr-1", + "schema":"public", + "role":"reader", + "cidrs":["192.168.1.0/24","10.0.0.1/32"], + "description":"office network", + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z", + "deleted_at":null, + "actor":{"id":"user-1","display_name":"Alice","avatar_url":"https://example.com/a.png"} + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + entries, err := client.PostgresCIDRs.List(context.Background(), &ListPostgresCIDRsRequest{ + Organization: testOrg, + Database: "my-db", + }) + c.Assert(err, qt.IsNil) + c.Assert(entries, qt.HasLen, 1) + c.Assert(entries[0].ID, qt.Equals, "cidr-1") + c.Assert(entries[0].Schema, qt.Equals, "public") + c.Assert(entries[0].Role, qt.Equals, "reader") + c.Assert(entries[0].CIDRs, qt.DeepEquals, []string{"192.168.1.0/24", "10.0.0.1/32"}) + c.Assert(entries[0].Description, qt.IsNotNil) + c.Assert(*entries[0].Description, qt.Equals, "office network") + c.Assert(entries[0].Actor.Name, qt.Equals, "Alice") + c.Assert(entries[0].DeletedAt, qt.IsNil) +} + +func TestPostgresCIDRs_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/cidrs/cidr-1") + w.WriteHeader(200) + out := `{ + "id":"cidr-1", + "schema":"", + "role":"", + "cidrs":["203.0.113.0/24"], + "description":null, + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z", + "deleted_at":null, + "actor":{"id":"user-1","display_name":"Alice","avatar_url":"https://example.com/a.png"} + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + entry, err := client.PostgresCIDRs.Get(context.Background(), &GetPostgresCIDRRequest{ + Organization: testOrg, + Database: "my-db", + ID: "cidr-1", + }) + c.Assert(err, qt.IsNil) + c.Assert(entry.ID, qt.Equals, "cidr-1") + c.Assert(entry.Schema, qt.Equals, "") + c.Assert(entry.Role, qt.Equals, "") + c.Assert(entry.Description, qt.IsNil) + c.Assert(entry.CIDRs, qt.DeepEquals, []string{"203.0.113.0/24"}) +} + +func TestPostgresCIDRs_Create(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.MethodPost) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/cidrs") + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["schema"], qt.Equals, "public") + c.Assert(body["role"], qt.Equals, "writer") + c.Assert(body["cidrs"], qt.DeepEquals, []any{"192.168.1.0/24"}) + c.Assert(body["description"], qt.Equals, "vpn") + + w.WriteHeader(201) + out := `{ + "id":"cidr-2", + "schema":"public", + "role":"writer", + "cidrs":["192.168.1.0/24"], + "description":"vpn", + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-14T10:19:23.000Z", + "deleted_at":null, + "actor":{"id":"user-1","display_name":"Alice","avatar_url":"https://example.com/a.png"} + }` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + entry, err := client.PostgresCIDRs.Create(context.Background(), &CreatePostgresCIDRRequest{ + Organization: testOrg, + Database: "my-db", + Schema: "public", + Role: "writer", + CIDRs: []string{"192.168.1.0/24"}, + Description: "vpn", + }) + c.Assert(err, qt.IsNil) + c.Assert(entry.ID, qt.Equals, "cidr-2") + c.Assert(entry.CreatedAt.Equal(time.Date(2021, time.January, 14, 10, 19, 23, 0, time.UTC)), qt.IsTrue) +} + +func TestPostgresCIDRs_Update(t *testing.T) { + c := qt.New(t) + + desc := "updated" + 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/my-db/cidrs/cidr-1") + + var body map[string]any + err := json.NewDecoder(r.Body).Decode(&body) + c.Assert(err, qt.IsNil) + c.Assert(body["cidrs"], qt.DeepEquals, []any{"10.0.0.0/8"}) + c.Assert(body["description"], qt.Equals, "updated") + _, ok := body["schema"] + c.Assert(ok, qt.IsFalse) + + w.WriteHeader(200) + out := `{ + "id":"cidr-1", + "schema":"", + "role":"", + "cidrs":["10.0.0.0/8"], + "description":"updated", + "created_at":"2021-01-14T10:19:23.000Z", + "updated_at":"2021-01-15T10:19:23.000Z", + "deleted_at":null, + "actor":{"id":"user-1","display_name":"Alice","avatar_url":"https://example.com/a.png"} + }` + _, err = w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + entry, err := client.PostgresCIDRs.Update(context.Background(), &UpdatePostgresCIDRRequest{ + Organization: testOrg, + Database: "my-db", + ID: "cidr-1", + CIDRs: []string{"10.0.0.0/8"}, + Description: &desc, + }) + c.Assert(err, qt.IsNil) + c.Assert(entry.CIDRs, qt.DeepEquals, []string{"10.0.0.0/8"}) + c.Assert(*entry.Description, qt.Equals, "updated") +} + +func TestPostgresCIDRs_Delete(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.MethodDelete) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/cidrs/cidr-1") + w.WriteHeader(204) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + err = client.PostgresCIDRs.Delete(context.Background(), &DeletePostgresCIDRRequest{ + Organization: testOrg, + Database: "my-db", + ID: "cidr-1", + }) + c.Assert(err, qt.IsNil) +} diff --git a/planetscale/query_patterns.go b/planetscale/query_patterns.go index 20b669d..4035302 100644 --- a/planetscale/query_patterns.go +++ b/planetscale/query_patterns.go @@ -10,8 +10,6 @@ import ( "net/http" "path" "time" - - "github.com/hashicorp/go-cleanhttp" ) // QueryPatternsReport represents a query patterns report for a branch. @@ -74,7 +72,7 @@ func (s *queryPatternsService) CreateReport(ctx context.Context, createReq *Crea } report := &QueryPatternsReport{} - if err := s.client.do(ctx, req, &report); err != nil { + if err := s.client.do(ctx, req, report); err != nil { return nil, err } @@ -90,7 +88,7 @@ func (s *queryPatternsService) GetReport(ctx context.Context, getReq *GetQueryPa } report := &QueryPatternsReport{} - if err := s.client.do(ctx, req, &report); err != nil { + if err := s.client.do(ctx, req, report); err != nil { return nil, err } @@ -106,49 +104,11 @@ func (s *queryPatternsService) DownloadReport(ctx context.Context, downloadReq * return nil, fmt.Errorf("error creating http request: %w", err) } - // The download endpoint redirects to blob storage. The client's - // credentials live in its transport, so following the redirect with that - // client would send the Authorization header to the storage host, which - // rejects requests carrying credentials beyond the presigned URL. Stop at - // the redirect and fetch its target with an unauthenticated client. - httpClient := *s.client.client - httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse - } - - res, err := httpClient.Do(req.WithContext(ctx)) + body, err := s.client.downloadSignedURL(ctx, req) if err != nil { - return nil, err + return nil, fmt.Errorf("downloading query patterns report: %w", err) } - - switch { - case res.StatusCode >= 300 && res.StatusCode < 400: - location, err := res.Location() - res.Body.Close() - if err != nil { - return nil, err - } - - blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, location.String(), nil) - if err != nil { - return nil, err - } - blobReq.Header.Set("User-Agent", s.client.UserAgent) - - res, err = cleanhttp.DefaultClient().Do(blobReq) - if err != nil { - return nil, err - } - if res.StatusCode >= 300 { - res.Body.Close() - return nil, fmt.Errorf("downloading query patterns report: %s", http.StatusText(res.StatusCode)) - } - case res.StatusCode >= 400: - defer res.Body.Close() - return nil, s.client.handleResponse(ctx, res, nil) - } - - return decompressedReadCloser(res.Body) + return decompressedReadCloser(body) } // decompressedReadCloser wraps body so gzip-compressed content is transparently diff --git a/planetscale/signed_download.go b/planetscale/signed_download.go new file mode 100644 index 0000000..022b590 --- /dev/null +++ b/planetscale/signed_download.go @@ -0,0 +1,85 @@ +package planetscale + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/hashicorp/go-cleanhttp" +) + +type signedDownloadTransportError struct { + host string + cause error +} + +func (e *signedDownloadTransportError) Error() string { + return fmt.Sprintf("signed download from %s: %v", e.host, e.cause) +} + +func (e *signedDownloadTransportError) Unwrap() error { + return e.cause +} + +// IsSignedDownloadTransportError reports whether a signed download failed before its response body was available. +func IsSignedDownloadTransportError(err error) bool { + var transportErr *signedDownloadTransportError + return errors.As(err, &transportErr) +} + +func newSignedDownloadTransportError(request *http.Request, err error) error { + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return &signedDownloadTransportError{host: request.URL.Host, cause: err} +} + +// The download endpoint redirects to blob storage. The client's +// credentials live in its transport, so following the redirect with that +// client would send the Authorization header to the storage host, which +// rejects requests carrying credentials beyond the presigned URL. Stop at +// the redirect and fetch its target with an unauthenticated client. +func (c *Client) downloadSignedURL(ctx context.Context, req *http.Request) (io.ReadCloser, error) { + httpClient := *c.client + httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + res, err := httpClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, newSignedDownloadTransportError(req, err) + } + + switch { + case res.StatusCode >= 300 && res.StatusCode < 400: + location, err := res.Location() + res.Body.Close() + if err != nil { + return nil, fmt.Errorf("parsing signed download location: %w", err) + } + + blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, location.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating signed download request: %w", err) + } + blobReq.Header.Set("User-Agent", c.UserAgent) + + res, err = cleanhttp.DefaultClient().Do(blobReq) + if err != nil { + return nil, newSignedDownloadTransportError(blobReq, err) + } + if res.StatusCode >= 300 { + res.Body.Close() + return nil, newSignedDownloadTransportError(blobReq, fmt.Errorf("signed download returned %s", http.StatusText(res.StatusCode))) + } + case res.StatusCode >= 400: + defer res.Body.Close() + return nil, c.handleResponse(ctx, res, nil) + } + + return res.Body, nil +} diff --git a/planetscale/signed_download_test.go b/planetscale/signed_download_test.go new file mode 100644 index 0000000..84e9eb4 --- /dev/null +++ b/planetscale/signed_download_test.go @@ -0,0 +1,32 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestAuthAttemptExportDownloadSanitizesSignedURLTransportError(t *testing.T) { + c := qt.New(t) + + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://127.0.0.1:1/sensitive-object?X-Amz-Signature=secret", http.StatusFound) + })) + t.Cleanup(api.Close) + + client, err := NewClient(WithBaseURL(api.URL)) + c.Assert(err, qt.IsNil) + + _, err = client.AuthAttemptExports.DownloadExport(context.Background(), &DownloadAuthAttemptExportRequest{ + Organization: "my-org", + Export: "export1", + }) + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "127.0.0.1:1") + c.Assert(err.Error(), qt.Not(qt.Contains), "sensitive-object") + c.Assert(err.Error(), qt.Not(qt.Contains), "X-Amz-Signature") + c.Assert(err.Error(), qt.Not(qt.Contains), "secret") +}