diff --git a/client.go b/client.go index 0cb745e..7f88878 100644 --- a/client.go +++ b/client.go @@ -19,6 +19,7 @@ import ( "github.com/RedisLabs/rediscloud-go-api/service/cloud_accounts" "github.com/RedisLabs/rediscloud-go-api/service/databases" "github.com/RedisLabs/rediscloud-go-api/service/latest_backups" + "github.com/RedisLabs/rediscloud-go-api/service/latest_imports" "github.com/RedisLabs/rediscloud-go-api/service/regions" "github.com/RedisLabs/rediscloud-go-api/service/subscriptions" ) @@ -30,6 +31,8 @@ type Client struct { Subscription *subscriptions.API Regions *regions.API LatestBackup *latest_backups.API + LatestImport *latest_imports.API + //Pricing *pricing.API // acl RedisRules *redis_rules.API Roles *roles.API @@ -68,6 +71,8 @@ func NewClient(configs ...Option) (*Client, error) { Subscription: subscriptions.NewAPI(client, t, config.logger), Regions: regions.NewAPI(client, t, config.logger), LatestBackup: latest_backups.NewAPI(client, t, config.logger), + LatestImport: latest_imports.NewAPI(client, t, config.logger), + //Pricing: pricing.NewAPI(client), // acl RedisRules: redis_rules.NewAPI(client, t, config.logger), Roles: roles.NewAPI(client, t, config.logger), diff --git a/internal/http_client.go b/internal/http_client.go index 6d4407d..e16f8e2 100644 --- a/internal/http_client.go +++ b/internal/http_client.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "net/http" "net/url" ) @@ -85,7 +84,7 @@ func (c *HttpClient) connection(ctx context.Context, method, name, path string, defer response.Body.Close() if response.StatusCode > 299 { - body, _ := ioutil.ReadAll(response.Body) + body, _ := io.ReadAll(response.Body) return &HTTPError{ Name: name, StatusCode: response.StatusCode, diff --git a/internal/service.go b/internal/service.go index 562c7af..627de49 100644 --- a/internal/service.go +++ b/internal/service.go @@ -38,7 +38,7 @@ type Api interface { // by cancelling the context. WaitForResource(ctx context.Context, id string, resource interface{}) error - // WaitForTask will poll the Task, waiting for it to enter a terminal state (i.e Done or Error). This task + // WaitForTask will poll the Task, waiting for it to enter a terminal state (i.e Done or Error). This Task // will then be returned, or an error in case it cannot be retrieved. WaitForTask(ctx context.Context, id string) (*Task, error) } @@ -133,10 +133,8 @@ func (a *api) WaitForTask(ctx context.Context, id string) (*Task, error) { var err error task, err = a.get(ctx, id) if err != nil { - if status, ok := err.(*HTTPError); ok && status.StatusCode == 404 { - return &taskNotFoundError{err} - } - return retry.Unrecoverable(err) + // An error is a terminal state (any repeated pre-task 404s will have been exhausted by this point) + return nil } status := redis.StringValue(task.Status) @@ -180,7 +178,7 @@ func (a *api) get(ctx context.Context, id string) (*Task, error) { } if task.Response != nil && task.Response.Error != nil { - return nil, task.Response.Error + return &task, task.Response.Error } return &task, nil diff --git a/latest_backups_test.go b/latest_backups_test.go new file mode 100644 index 0000000..3b10920 --- /dev/null +++ b/latest_backups_test.go @@ -0,0 +1,121 @@ +package rediscloud_api + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetLatestBackup(t *testing.T) { + server := httptest.NewServer( + testServer( + "key", + "secret", + getRequest( + t, + "/subscriptions/12/databases/34/backup", + `{ + "taskId": "50ec6172-8475-4ef6-8b3c-d61e688d8fe5", + "commandType": "databaseBackupStatusRequest", + "status": "received", + "description": "Task request received and is being queued for processing.", + "timestamp": "2024-04-15T09:08:04.222268Z", + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/50ec6172-8475-4ef6-8b3c-d61e688d8fe5", + "type": "GET", + "rel": "task" + } + ] + }`, + ), + getRequest( + t, + "/tasks/50ec6172-8475-4ef6-8b3c-d61e688d8fe5", + `{ + "taskId": "50ec6172-8475-4ef6-8b3c-d61e688d8fe5", + "commandType": "databaseBackupStatusRequest", + "status": "processing-completed", + "description": "Request processing completed successfully and its resources are now being provisioned / de-provisioned.", + "timestamp": "2024-04-15T09:08:07.537915Z", + "response": { + "resourceId": 51051292, + "additionalResourceId": 12, + "resource": {} + }, + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/50ec6172-8475-4ef6-8b3c-d61e688d8fe5", + "type": "GET", + "rel": "self" + } + ] + }`, + ), + )) + + subject, err := clientFromTestServer(server, "key", "secret") + require.NoError(t, err) + + _, err = subject.LatestBackup.Get(context.TODO(), 12, 34) + require.NoError(t, err) +} + +func TestGetAALatestBackup(t *testing.T) { + server := httptest.NewServer( + testServer( + "key", + "secret", + getRequest( + t, + "/subscriptions/12/databases/34/backup?regionName=eu-west-2", + `{ + "taskId": "ce2cbfea-9b15-4250-a516-f014161a8dd3", + "commandType": "databaseBackupStatusRequest", + "status": "received", + "description": "Task request received and is being queued for processing.", + "timestamp": "2024-04-15T09:52:23.963337Z", + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/ce2cbfea-9b15-4250-a516-f014161a8dd3", + "type": "GET", + "rel": "task" + } + ] + }`, + ), + getRequest( + t, + "/tasks/ce2cbfea-9b15-4250-a516-f014161a8dd3", + `{ + "taskId": "ce2cbfea-9b15-4250-a516-f014161a8dd3", + "commandType": "databaseBackupStatusRequest", + "status": "processing-error", + "description": "Task request failed during processing. See error information for failure details.", + "timestamp": "2024-04-15T09:52:26.101936Z", + "response": { + "error": { + "type": "DATABASE_BACKUP_DISABLED", + "status": "400 BAD_REQUEST", + "description": "Database backup is disabled" + } + }, + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/ce2cbfea-9b15-4250-a516-f014161a8dd3", + "type": "GET", + "rel": "self" + } + ] + }`, + ), + )) + + subject, err := clientFromTestServer(server, "key", "secret") + require.NoError(t, err) + + _, err = subject.LatestBackup.GetActiveActive(context.TODO(), 12, 34, "eu-west-2") + require.NoError(t, err) +} diff --git a/latest_imports_test.go b/latest_imports_test.go new file mode 100644 index 0000000..c73301d --- /dev/null +++ b/latest_imports_test.go @@ -0,0 +1,121 @@ +package rediscloud_api + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGetLatestImportTooEarly(t *testing.T) { + server := httptest.NewServer( + testServer( + "key", + "secret", + getRequest( + t, + "/subscriptions/12/databases/34/import", + `{ + "taskId": "1dfd6084-21df-40c6-829c-e9b4790e207e", + "commandType": "databaseImportStatusRequest", + "status": "received", + "description": "Task request received and is being queued for processing.", + "timestamp": "2024-04-15T10:19:06.710686Z", + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/1dfd6084-21df-40c6-829c-e9b4790e207e", + "type": "GET", + "rel": "task" + } + ] + }`, + ), + getRequest( + t, + "/tasks/1dfd6084-21df-40c6-829c-e9b4790e207e", + `{ + "taskId": "1dfd6084-21df-40c6-829c-e9b4790e207e", + "commandType": "databaseImportStatusRequest", + "status": "processing-error", + "description": "Task request failed during processing. See error information for failure details.", + "timestamp": "2024-04-15T10:19:07.331898Z", + "response": { + "error": { + "type": "SUBSCRIPTION_NOT_ACTIVE", + "status": "403 FORBIDDEN", + "description": "Cannot preform any actions for subscription that is not in an active state" + } + }, + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/1dfd6084-21df-40c6-829c-e9b4790e207e", + "type": "GET", + "rel": "self" + } + ] + }`, + ), + )) + + subject, err := clientFromTestServer(server, "key", "secret") + require.NoError(t, err) + + _, err = subject.LatestImport.Get(context.TODO(), 12, 34) + require.NoError(t, err) +} + +func TestGetLatestImport(t *testing.T) { + server := httptest.NewServer( + testServer( + "key", + "secret", + getRequest( + t, + "/subscriptions/12/databases/34/import", + `{ + "taskId": "e9232e43-3781-4263-a38e-f4d150e03475", + "commandType": "databaseImportStatusRequest", + "status": "received", + "description": "Task request received and is being queued for processing.", + "timestamp": "2024-04-15T10:44:34.325298Z", + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/e9232e43-3781-4263-a38e-f4d150e03475", + "type": "GET", + "rel": "task" + } + ] + }`, + ), + getRequest( + t, + "/tasks/e9232e43-3781-4263-a38e-f4d150e03475", + `{ + "taskId": "e9232e43-3781-4263-a38e-f4d150e03475", + "commandType": "databaseImportStatusRequest", + "status": "processing-completed", + "description": "Request processing completed successfully and its resources are now being provisioned / de-provisioned.", + "timestamp": "2024-04-15T10:44:35.225468Z", + "response": { + "resourceId": 51051302, + "additionalResourceId": 110777, + "resource": {} + }, + "links": [ + { + "href": "https://api-staging.qa.redislabs.com/v1/tasks/e9232e43-3781-4263-a38e-f4d150e03475", + "type": "GET", + "rel": "self" + } + ] + }`, + ), + )) + + subject, err := clientFromTestServer(server, "key", "secret") + require.NoError(t, err) + + _, err = subject.LatestImport.Get(context.TODO(), 12, 34) + require.NoError(t, err) +} diff --git a/service/account/service.go b/service/account/service.go index e9b8c8a..dbf5545 100644 --- a/service/account/service.go +++ b/service/account/service.go @@ -46,7 +46,7 @@ func (a *API) ListDataPersistence(ctx context.Context) ([]*DataPersistence, erro return body.DataPersistence, nil } -// ListDataModules will return the list of available data modules that can be applied to a database. +// ListDatabaseModules will return the list of available data modules that can be applied to a database. func (a *API) ListDatabaseModules(ctx context.Context) ([]*DatabaseModule, error) { var body databaseModules if err := a.client.Get(ctx, "list database modules", "/database-modules", &body); err != nil { diff --git a/service/latest_imports/model.go b/service/latest_imports/model.go new file mode 100644 index 0000000..7265ab4 --- /dev/null +++ b/service/latest_imports/model.go @@ -0,0 +1,95 @@ +package latest_imports + +import ( + "encoding/json" + "fmt" + "regexp" + + "github.com/RedisLabs/rediscloud-go-api/internal" + "github.com/RedisLabs/rediscloud-go-api/redis" +) + +type LatestImportStatus struct { + CommandType *string `json:"commandType,omitempty"` + Description *string `json:"description,omitempty"` + Status *string `json:"status,omitempty"` + ID *string `json:"taskId,omitempty"` + Response *Response `json:"response,omitempty"` +} + +func (o LatestImportStatus) String() string { + return internal.ToString(o) +} + +type Response struct { + ID *int `json:"resourceId,omitempty"` + Resource *json.RawMessage `json:"resource,omitempty"` + Error *Error `json:"error,omitempty"` +} + +func (o Response) String() string { + return internal.ToString(o) +} + +type Error struct { + Type *string `json:"type,omitempty"` + Description *string `json:"description,omitempty"` + Status *string `json:"status,omitempty"` +} + +func (e Error) String() string { + return internal.ToString(e) +} + +func (e *Error) StatusCode() string { + matches := errorStatusCode.FindStringSubmatch(redis.StringValue(e.Status)) + if len(matches) == 2 { + return matches[1] + } + return "" +} + +func (e *Error) Error() string { + return fmt.Sprintf("%s - %s: %s", redis.StringValue(e.Status), redis.StringValue(e.Type), redis.StringValue(e.Description)) +} + +var errorStatusCode = regexp.MustCompile("^(\\d*).*$") + +func NewLatestImportStatus(task *internal.Task) *LatestImportStatus { + latestImportStatus := LatestImportStatus{ + CommandType: task.CommandType, + Description: task.Description, + Status: task.Status, + ID: task.ID, + } + + if task.Response != nil { + r := Response{ + ID: task.Response.ID, + Resource: task.Response.Resource, + } + + if task.Response.Error != nil { + e := Error{ + Type: task.Response.Error.Type, + Description: task.Response.Error.Description, + Status: task.Response.Error.Status, + } + + r.Error = &e + } + + latestImportStatus.Response = &r + } + + return &latestImportStatus +} + +type NotFound struct { + subId int + dbId int +} + +func (f *NotFound) Error() string { + return fmt.Sprintf("database %d in subscription %d not found", f.dbId, f.subId) +} diff --git a/service/latest_imports/service.go b/service/latest_imports/service.go new file mode 100644 index 0000000..9038d42 --- /dev/null +++ b/service/latest_imports/service.go @@ -0,0 +1,60 @@ +package latest_imports + +import ( + "context" + "fmt" + "net/http" + + "github.com/RedisLabs/rediscloud-go-api/internal" +) + +type HttpClient interface { + Get(ctx context.Context, name, path string, responseBody interface{}) error +} + +type TaskWaiter interface { + WaitForTask(ctx context.Context, id string) (*internal.Task, error) +} + +type Log interface { + Printf(format string, args ...interface{}) +} + +type API struct { + client HttpClient + taskWaiter TaskWaiter + logger Log +} + +func NewAPI(client HttpClient, taskWaiter TaskWaiter, logger Log) *API { + return &API{client: client, taskWaiter: taskWaiter, logger: logger} +} + +func (a *API) Get(ctx context.Context, subscription int, database int) (*LatestImportStatus, error) { + message := fmt.Sprintf("get latest import information for database %d in subscription %d", subscription, database) + address := fmt.Sprintf("/subscriptions/%d/databases/%d/import", subscription, database) + task, err := a.get(ctx, message, address) + if err != nil { + return nil, wrap404Error(subscription, database, err) + } + return NewLatestImportStatus(task), nil +} + +func (a *API) get(ctx context.Context, message string, address string) (*internal.Task, error) { + var taskResponse internal.TaskResponse + err := a.client.Get(ctx, message, address, &taskResponse) + if err != nil { + return nil, err + } + + a.logger.Printf("Waiting for backup status request %d to complete", taskResponse.ID) + + return a.taskWaiter.WaitForTask(ctx, *taskResponse.ID) +} + +func wrap404Error(subId int, dbId int, err error) error { + if v, ok := err.(*internal.HTTPError); ok && v.StatusCode == http.StatusNotFound { + return &NotFound{subId: subId, dbId: dbId} + } + return err +}