From d73fc320e21c0c82e53e45494fe3c153ad395033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20P=C3=BAblio?= Date: Wed, 29 Jan 2025 00:14:53 -0300 Subject: [PATCH 1/3] feat: add Database as a Service (DBaaS) support with instances, instance types, and engines --- README.md | 6 + cmd/examples/dbaas/main.go | 129 +++++++ dbaas/client.go | 55 +++ dbaas/client_test.go | 72 ++++ dbaas/engines.go | 112 +++++++ dbaas/engines_test.go | 163 +++++++++ dbaas/instance_types.go | 100 ++++++ dbaas/instance_types_test.go | 164 +++++++++ dbaas/instances.go | 587 ++++++++++++++++++++++++++++++++ dbaas/instances_test.go | 632 +++++++++++++++++++++++++++++++++++ dbaas/replicas.go | 181 ++++++++++ dbaas/replicas_test.go | 447 +++++++++++++++++++++++++ 12 files changed, 2648 insertions(+) create mode 100644 cmd/examples/dbaas/main.go create mode 100644 dbaas/client.go create mode 100644 dbaas/client_test.go create mode 100644 dbaas/engines.go create mode 100644 dbaas/engines_test.go create mode 100644 dbaas/instance_types.go create mode 100644 dbaas/instance_types_test.go create mode 100644 dbaas/instances.go create mode 100644 dbaas/instances_test.go create mode 100644 dbaas/replicas.go create mode 100644 dbaas/replicas_test.go diff --git a/README.md b/README.md index 560ee85..409395c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,12 @@ go get github.com/MagaluCloud/mgc-sdk-go - Audit - Events - Events Types +- Database as a Service (DBaaS) + - Instances + - Instance Types + - Snapshots + - Replicas + - Engines ## Authentication diff --git a/cmd/examples/dbaas/main.go b/cmd/examples/dbaas/main.go new file mode 100644 index 0000000..937e9e8 --- /dev/null +++ b/cmd/examples/dbaas/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/MagaluCloud/mgc-sdk-go/client" + "github.com/MagaluCloud/mgc-sdk-go/dbaas" + "github.com/MagaluCloud/mgc-sdk-go/helpers" +) + +func main() { + ExampleListEngines() + ExampleListInstanceTypes() + ExampleListInstances() + ExampleCreateInstance() +} + +func ExampleListEngines() { + apiToken := os.Getenv("MGC_API_TOKEN") + if apiToken == "" { + log.Fatal("MGC_API_TOKEN environment variable is not set") + } + c := client.NewMgcClient(apiToken) + dbaasClient := dbaas.New(c) + + engines, err := dbaasClient.Engines().List(context.Background(), dbaas.ListEngineOptions{ + Limit: helpers.IntPtr(10), + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d database engines:\n", len(engines)) + for _, engine := range engines { + fmt.Printf("Engine: %s (ID: %s)\n", engine.Name, engine.ID) + fmt.Printf(" Version: %s\n", engine.Version) + fmt.Printf(" Status: %s\n", engine.Status) + } +} + +func ExampleListInstanceTypes() { + apiToken := os.Getenv("MGC_API_TOKEN") + if apiToken == "" { + log.Fatal("MGC_API_TOKEN environment variable is not set") + } + c := client.NewMgcClient(apiToken) + dbaasClient := dbaas.New(c) + + instanceTypes, err := dbaasClient.InstanceTypes().List(context.Background(), dbaas.ListInstanceTypeOptions{ + Limit: helpers.IntPtr(10), + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d instance types:\n", len(instanceTypes)) + for _, instanceType := range instanceTypes { + fmt.Printf("Instance Type: %s (ID: %s)\n", instanceType.Name, instanceType.ID) + fmt.Printf(" Label: %s\n", instanceType.Label) + fmt.Printf(" VCPU: %s\n", instanceType.VCPU) + fmt.Printf(" RAM: %s\n", instanceType.RAM) + fmt.Printf(" Family: %s (%s)\n", instanceType.FamilyDescription, instanceType.FamilySlug) + } +} + +func ExampleListInstances() { + apiToken := os.Getenv("MGC_API_TOKEN") + if apiToken == "" { + log.Fatal("MGC_API_TOKEN environment variable is not set") + } + c := client.NewMgcClient(apiToken) + dbaasClient := dbaas.New(c) + + instances, err := dbaasClient.Instances().List(context.Background(), dbaas.ListInstanceOptions{ + Limit: helpers.IntPtr(10), + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Found %d database instances:\n", len(instances)) + for _, instance := range instances { + fmt.Printf("Instance: %s (ID: %s)\n", instance.Name, instance.ID) + fmt.Printf(" Engine ID: %s\n", instance.EngineID) + fmt.Printf(" Status: %s\n", instance.Status) + fmt.Printf(" Volume Size: %d GB\n", instance.Volume.Size) + fmt.Printf(" Volume Type: %s\n", instance.Volume.Type) + if len(instance.Addresses) > 0 { + fmt.Println(" Addresses:") + for _, addr := range instance.Addresses { + if addr.Address != nil { + fmt.Printf(" %s (%s): %s\n", addr.Access, *addr.Type, *addr.Address) + } + } + } + } +} + +func ExampleCreateInstance() { + apiToken := os.Getenv("MGC_API_TOKEN") + if apiToken == "" { + log.Fatal("MGC_API_TOKEN environment variable is not set") + } + c := client.NewMgcClient(apiToken) + dbaasClient := dbaas.New(c) + + // Create a new database instance + instance, err := dbaasClient.Instances().Create(context.Background(), dbaas.InstanceCreateRequest{ + Name: "example-db-instance", + EngineID: "your-engine-id", // Replace with actual engine ID + InstanceTypeID: "your-instance-type-id", // Replace with actual instance type ID + User: "dbadmin", + Password: "YourStrongPassword123!", + Volume: dbaas.InstanceVolumeRequest{ + Size: 20, // Size in GB + Type: dbaas.VolumeTypeCloudNVME, + }, + BackupRetentionDays: 7, + BackupStartAt: "02:00", // Start backup at 2 AM + }) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Successfully created database instance with ID: %s\n", instance.ID) +} diff --git a/dbaas/client.go b/dbaas/client.go new file mode 100644 index 0000000..c6bd296 --- /dev/null +++ b/dbaas/client.go @@ -0,0 +1,55 @@ +package dbaas + +import ( + "context" + "net/http" + + "github.com/MagaluCloud/mgc-sdk-go/client" + mgc_http "github.com/MagaluCloud/mgc-sdk-go/internal/http" +) + +const ( + DefaultBasePath = "/database" +) + +type DBaaSClient struct { + *client.CoreClient +} + +type ClientOption func(*DBaaSClient) + +func New(core *client.CoreClient, opts ...ClientOption) *DBaaSClient { + if core == nil { + return nil + } + + client := &DBaaSClient{ + CoreClient: core, + } + + for _, opt := range opts { + opt(client) + } + + return client +} + +func (c *DBaaSClient) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) { + return mgc_http.NewRequest(c.GetConfig(), ctx, method, DefaultBasePath+path, &body) +} + +func (c *DBaaSClient) Engines() EngineService { + return &engineService{client: c} +} + +func (c *DBaaSClient) InstanceTypes() InstanceTypeService { + return &instanceTypeService{client: c} +} + +func (c *DBaaSClient) Instances() InstanceService { + return &instanceService{client: c} +} + +func (c *DBaaSClient) Replicas() ReplicaService { + return &replicaService{client: c} +} diff --git a/dbaas/client_test.go b/dbaas/client_test.go new file mode 100644 index 0000000..4a0d1c6 --- /dev/null +++ b/dbaas/client_test.go @@ -0,0 +1,72 @@ +package dbaas + +import ( + "net/http" + "testing" + + "github.com/MagaluCloud/mgc-sdk-go/client" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + core *client.CoreClient + wantNil bool + }{ + { + name: "valid core client", + core: client.NewMgcClient("test-token"), + wantNil: false, + }, + { + name: "nil core client", + core: nil, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := New(tt.core) + if (client == nil) != tt.wantNil { + t.Errorf("New() returned nil = %v, want nil = %v", client == nil, tt.wantNil) + } + }) + } +} + +func TestDBaaSClient_Services(t *testing.T) { + core := client.NewMgcClient("test-token", + client.WithHTTPClient(&http.Client{}), + client.WithBaseURL("https://api.test.com")) + + dbaas := New(core) + + t.Run("Engines service", func(t *testing.T) { + service := dbaas.Engines() + if service == nil { + t.Error("Engines() returned nil") + } + }) + + t.Run("InstanceTypes service", func(t *testing.T) { + service := dbaas.InstanceTypes() + if service == nil { + t.Error("InstanceTypes() returned nil") + } + }) + + t.Run("Instances service", func(t *testing.T) { + service := dbaas.Instances() + if service == nil { + t.Error("Instances() returned nil") + } + }) + + t.Run("Replicas service", func(t *testing.T) { + service := dbaas.Replicas() + if service == nil { + t.Error("Replicas() returned nil") + } + }) +} diff --git a/dbaas/engines.go b/dbaas/engines.go new file mode 100644 index 0000000..2ba025f --- /dev/null +++ b/dbaas/engines.go @@ -0,0 +1,112 @@ +package dbaas + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + mgc_http "github.com/MagaluCloud/mgc-sdk-go/internal/http" +) + +type ( + ListEnginesResponse struct { + Meta MetaResponse `json:"meta"` + Results []EngineDetail `json:"results"` + } + + MetaResponse struct { + Page PageResponse `json:"page"` + Filters []FieldValueFilter `json:"filters"` + } + + PageResponse struct { + Offset int `json:"offset"` + Limit int `json:"limit"` + Count int `json:"count"` + Total int `json:"total"` + MaxLimit int `json:"max_limit"` + } + + FieldValueFilter struct { + Field string `json:"field"` + Value string `json:"value"` + } + + EngineDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Status EngineStatus `json:"status"` + } +) + +// EngineStatus represents the status of a database engine +type EngineStatus string + +const ( + EngineStatusActive EngineStatus = "ACTIVE" + EngineStatusDeprecated EngineStatus = "DEPRECATED" +) + +type ( + EngineService interface { + // List returns all available database engines + List(ctx context.Context, opts ListEngineOptions) ([]EngineDetail, error) + + // Get retrieves detailed information about a specific engine + Get(ctx context.Context, id string) (*EngineDetail, error) + } + + engineService struct { + client *DBaaSClient + } + + ListEngineOptions struct { + Offset *int + Limit *int + Status *EngineStatus + } +) + +func (s *engineService) List(ctx context.Context, opts ListEngineOptions) ([]EngineDetail, error) { + query := make(url.Values) + + if opts.Offset != nil { + query.Set("_offset", strconv.Itoa(*opts.Offset)) + } + if opts.Limit != nil { + query.Set("_limit", strconv.Itoa(*opts.Limit)) + } + if opts.Status != nil { + query.Set("status", string(*opts.Status)) + } + + result, err := mgc_http.ExecuteSimpleRequestWithRespBody[ListEnginesResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + "/v1/engines", + nil, + query, + ) + if err != nil { + return nil, err + } + + return result.Results, nil +} + +func (s *engineService) Get(ctx context.Context, id string) (*EngineDetail, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[EngineDetail]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + fmt.Sprintf("/v1/engines/%s", id), + nil, + nil, + ) +} diff --git a/dbaas/engines_test.go b/dbaas/engines_test.go new file mode 100644 index 0000000..f9cf983 --- /dev/null +++ b/dbaas/engines_test.go @@ -0,0 +1,163 @@ +package dbaas + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/MagaluCloud/mgc-sdk-go/client" + "github.com/MagaluCloud/mgc-sdk-go/helpers" +) + +func testEngineClient(baseURL string) EngineService { + httpClient := &http.Client{} + core := client.NewMgcClient("test-api", + client.WithBaseURL(client.MgcUrl(baseURL)), + client.WithHTTPClient(httpClient)) + return New(core).Engines() +} + +func TestEngineService_List(t *testing.T) { + tests := []struct { + name string + opts ListEngineOptions + response string + statusCode int + wantCount int + wantErr bool + }{ + { + name: "basic list", + response: `{ + "meta": {"total": 2}, + "results": [ + {"id": "postgres-13", "name": "PostgreSQL", "version": "13", "status": "ACTIVE"}, + {"id": "mysql-8", "name": "MySQL", "version": "8.0", "status": "ACTIVE"} + ] + }`, + statusCode: http.StatusOK, + wantCount: 2, + wantErr: false, + }, + { + name: "with pagination and status filter", + opts: ListEngineOptions{ + Limit: helpers.IntPtr(10), + Offset: helpers.IntPtr(5), + Status: engineStatusPtr(EngineStatusActive), + }, + response: `{ + "meta": {"total": 1}, + "results": [{"id": "postgres-13", "status": "ACTIVE"}] + }`, + statusCode: http.StatusOK, + wantCount: 1, + wantErr: false, + }, + { + name: "server error", + response: `{"error": "internal server error"}`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/engines", r.URL.Path) + query := r.URL.Query() + + if tt.opts.Limit != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Limit), query.Get("_limit")) + } + if tt.opts.Offset != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Offset), query.Get("_offset")) + } + if tt.opts.Status != nil { + assertEqual(t, string(*tt.opts.Status), query.Get("status")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testEngineClient(server.URL) + result, err := client.List(context.Background(), tt.opts) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantCount, len(result)) + }) + } +} + +func TestEngineService_Get(t *testing.T) { + tests := []struct { + name string + id string + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "existing engine", + id: "postgres-13", + response: `{ + "id": "postgres-13", + "name": "PostgreSQL", + "version": "13", + "status": "ACTIVE" + }`, + statusCode: http.StatusOK, + wantID: "postgres-13", + wantErr: false, + }, + { + name: "not found", + id: "invalid", + response: `{"error": "not found"}`, + statusCode: http.StatusNotFound, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/engines/%s", tt.id), r.URL.Path) + assertEqual(t, http.MethodGet, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testEngineClient(server.URL) + result, err := client.Get(context.Background(), tt.id) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} + +func engineStatusPtr(status EngineStatus) *EngineStatus { + return &status +} diff --git a/dbaas/instance_types.go b/dbaas/instance_types.go new file mode 100644 index 0000000..ceac550 --- /dev/null +++ b/dbaas/instance_types.go @@ -0,0 +1,100 @@ +package dbaas + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + mgc_http "github.com/MagaluCloud/mgc-sdk-go/internal/http" +) + +type ( + ListInstanceTypesResponse struct { + Meta MetaResponse `json:"meta"` + Results []InstanceType `json:"results"` + } + + InstanceType struct { + ID string `json:"id"` + Name string `json:"name"` + Label string `json:"label"` + VCPU string `json:"vcpu"` + RAM string `json:"ram"` + FamilyDescription string `json:"family_description"` + FamilySlug string `json:"family_slug"` + Size string `json:"size"` + SKUSource string `json:"sku_source"` + SKUReplica string `json:"sku_replica"` + Status InstanceTypeStatus `json:"status,omitempty"` + } + + InstanceTypeStatus string +) + +const ( + InstanceTypeStatusActive InstanceTypeStatus = "ACTIVE" + InstanceTypeStatusDeprecated InstanceTypeStatus = "DEPRECATED" +) + +type ( + InstanceTypeService interface { + // List returns all available instance types + List(ctx context.Context, opts ListInstanceTypeOptions) ([]InstanceType, error) + + // Get retrieves detailed information about a specific instance type + Get(ctx context.Context, id string) (*InstanceType, error) + } + + instanceTypeService struct { + client *DBaaSClient + } + + ListInstanceTypeOptions struct { + Offset *int + Limit *int + Status *InstanceTypeStatus + } +) + +func (s *instanceTypeService) List(ctx context.Context, opts ListInstanceTypeOptions) ([]InstanceType, error) { + query := make(url.Values) + + if opts.Offset != nil { + query.Set("_offset", strconv.Itoa(*opts.Offset)) + } + if opts.Limit != nil { + query.Set("_limit", strconv.Itoa(*opts.Limit)) + } + if opts.Status != nil { + query.Set("status", string(*opts.Status)) + } + + result, err := mgc_http.ExecuteSimpleRequestWithRespBody[ListInstanceTypesResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + "/v1/instance-types", + nil, + query, + ) + if err != nil { + return nil, err + } + + return result.Results, nil +} + +func (s *instanceTypeService) Get(ctx context.Context, id string) (*InstanceType, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceType]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + fmt.Sprintf("/v1/instance-types/%s", id), + nil, + nil, + ) +} diff --git a/dbaas/instance_types_test.go b/dbaas/instance_types_test.go new file mode 100644 index 0000000..18688b1 --- /dev/null +++ b/dbaas/instance_types_test.go @@ -0,0 +1,164 @@ +package dbaas + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/MagaluCloud/mgc-sdk-go/client" + "github.com/MagaluCloud/mgc-sdk-go/helpers" +) + +func testInstanceTypeClient(baseURL string) InstanceTypeService { + httpClient := &http.Client{} + core := client.NewMgcClient("test-api", + client.WithBaseURL(client.MgcUrl(baseURL)), + client.WithHTTPClient(httpClient)) + return New(core).InstanceTypes() +} + +func TestInstanceTypeService_List(t *testing.T) { + tests := []struct { + name string + opts ListInstanceTypeOptions + response string + statusCode int + wantCount int + wantErr bool + }{ + { + name: "basic list", + response: `{ + "meta": {"total": 2}, + "results": [ + {"id": "type1", "name": "small", "vcpu": "1", "ram": "2GB"}, + {"id": "type2", "name": "medium", "vcpu": "2", "ram": "4GB"} + ] + }`, + statusCode: http.StatusOK, + wantCount: 2, + wantErr: false, + }, + { + name: "with filters and pagination", + opts: ListInstanceTypeOptions{ + Limit: helpers.IntPtr(10), + Offset: helpers.IntPtr(5), + Status: instanceTypeStatusPtr(InstanceTypeStatusActive), + }, + response: `{ + "meta": {"total": 1}, + "results": [{"id": "type1", "status": "ACTIVE"}] + }`, + statusCode: http.StatusOK, + wantCount: 1, + wantErr: false, + }, + { + name: "server error", + response: `{"error": "internal server error"}`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instance-types", r.URL.Path) + query := r.URL.Query() + + if tt.opts.Limit != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Limit), query.Get("_limit")) + } + if tt.opts.Offset != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Offset), query.Get("_offset")) + } + if tt.opts.Status != nil { + assertEqual(t, string(*tt.opts.Status), query.Get("status")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceTypeClient(server.URL) + result, err := client.List(context.Background(), tt.opts) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantCount, len(result)) + }) + } +} + +func TestInstanceTypeService_Get(t *testing.T) { + tests := []struct { + name string + id string + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "existing instance type", + id: "type1", + response: `{ + "id": "type1", + "name": "small", + "vcpu": "1", + "ram": "2GB", + "status": "ACTIVE" + }`, + statusCode: http.StatusOK, + wantID: "type1", + wantErr: false, + }, + { + name: "not found", + id: "invalid", + response: `{"error": "not found"}`, + statusCode: http.StatusNotFound, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/instance-types/%s", tt.id), r.URL.Path) + assertEqual(t, http.MethodGet, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceTypeClient(server.URL) + result, err := client.Get(context.Background(), tt.id) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} + +func instanceTypeStatusPtr(status InstanceTypeStatus) *InstanceTypeStatus { + return &status +} diff --git a/dbaas/instances.go b/dbaas/instances.go new file mode 100644 index 0000000..1850dcd --- /dev/null +++ b/dbaas/instances.go @@ -0,0 +1,587 @@ +package dbaas + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + mgc_http "github.com/MagaluCloud/mgc-sdk-go/internal/http" +) + +type ( + InstanceStatus string + InstanceGeneration string + VolumeType string + AddressAccess string + AddressType string + SnapshotType string + SnapshotStatus string +) + +const ( + InstanceStatusCreating InstanceStatus = "CREATING" + InstanceStatusError InstanceStatus = "ERROR" + InstanceStatusStopped InstanceStatus = "STOPPED" + InstanceStatusReboot InstanceStatus = "REBOOT" + InstanceStatusPending InstanceStatus = "PENDING" + InstanceStatusResizing InstanceStatus = "RESIZING" + InstanceStatusDeleted InstanceStatus = "DELETED" + InstanceStatusActive InstanceStatus = "ACTIVE" + InstanceStatusStarting InstanceStatus = "STARTING" + InstanceStatusStopping InstanceStatus = "STOPPING" + InstanceStatusBackingUp InstanceStatus = "BACKING_UP" + InstanceStatusDeleting InstanceStatus = "DELETING" + InstanceStatusRestoring InstanceStatus = "RESTORING" + InstanceStatusErrorDeleting InstanceStatus = "ERROR_DELETING" + InstanceStatusMaintenance InstanceStatus = "MAINTENANCE" + InstanceStatusMaintenanceError InstanceStatus = "MAINTENANCE_ERROR" +) + +const ( + VolumeTypeCloudNVME15K VolumeType = "CLOUD_NVME_15K" + VolumeTypeCloudNVME VolumeType = "CLOUD_NVME" + VolumeTypeCloudHDD VolumeType = "CLOUD_HDD" +) + +const ( + AddressAccessPrivate AddressAccess = "PRIVATE" + AddressAccessPublic AddressAccess = "PUBLIC" +) + +const ( + AddressTypeIPv4 AddressType = "IPv4" + AddressTypeIPv6 AddressType = "IPv6" +) + +const ( + SnapshotTypeOnDemand SnapshotType = "ON_DEMAND" + SnapshotTypeAutomated SnapshotType = "AUTOMATED" +) + +const ( + SnapshotStatusPending SnapshotStatus = "PENDING" + SnapshotStatusCreating SnapshotStatus = "CREATING" + SnapshotStatusAvailable SnapshotStatus = "AVAILABLE" + SnapshotStatusRestoring SnapshotStatus = "RESTORING" + SnapshotStatusError SnapshotStatus = "ERROR" + SnapshotStatusDeleting SnapshotStatus = "DELETING" + SnapshotStatusDeleted SnapshotStatus = "DELETED" +) + +type ( + Volume struct { + Size int `json:"size"` + Type VolumeType `json:"type"` + } + + Address struct { + Access AddressAccess `json:"access"` + Type *AddressType `json:"type,omitempty"` + Address *string `json:"address,omitempty"` + } + + InstanceParametersResponse struct { + Name string `json:"name"` + Value interface{} `json:"value"` + } + + InstanceParametersRequest struct { + Name string `json:"name"` + Value interface{} `json:"value"` + } + + InstanceVolumeRequest struct { + Size int `json:"size"` + Type VolumeType `json:"type,omitempty"` + } + + InstanceVolumeResizeRequest struct { + Size int `json:"size"` + Type VolumeType `json:"type,omitempty"` + } + + InstanceCreateRequest struct { + Name string `json:"name"` + EngineID string `json:"engine_id,omitempty"` + DatastoreID string `json:"datastore_id,omitempty"` + FlavorID string `json:"flavor_id,omitempty"` + InstanceTypeID string `json:"instance_type_id,omitempty"` + User string `json:"user"` + Password string `json:"password"` + Volume InstanceVolumeRequest `json:"volume"` + Parameters []InstanceParametersRequest `json:"parameters,omitempty"` + BackupRetentionDays int `json:"backup_retention_days,omitempty"` + BackupStartAt string `json:"backup_start_at,omitempty"` + } + + InstanceResizeRequest struct { + InstanceTypeID string `json:"instance_type_id,omitempty"` + FlavorID string `json:"flavor_id,omitempty"` + Volume *InstanceVolumeResizeRequest `json:"volume,omitempty"` + } + + DatabaseInstanceUpdateRequest struct { + Status *InstanceStatusUpdate `json:"status,omitempty"` + BackupRetentionDays *int `json:"backup_retention_days,omitempty"` + BackupStartAt *string `json:"backup_start_at,omitempty"` + } + + ReplicaDetailResponse struct { + ID string `json:"id"` + SourceID string `json:"source_id"` + Name string `json:"name"` + EngineID string `json:"engine_id"` + DatastoreID string `json:"datastore_id"` + FlavorID string `json:"flavor_id"` + InstanceTypeID string `json:"instance_type_id"` + Volume Volume `json:"volume"` + Addresses []ReplicaAddressResponse `json:"addresses"` + Status InstanceStatus `json:"status"` + Generation InstanceGeneration `json:"generation"` + Parameters []InstanceParametersResponse `json:"parameters"` + CreatedAt string `json:"created_at"` + UpdatedAt *string `json:"updated_at,omitempty"` + StartedAt *string `json:"started_at,omitempty"` + FinishedAt *string `json:"finished_at,omitempty"` + MaintenanceScheduledAt *string `json:"maintenance_scheduled_at,omitempty"` + } + + ReplicaAddressResponse struct { + Access AddressAccess `json:"access"` + Type *AddressType `json:"type,omitempty"` + Address *string `json:"address,omitempty"` + } + + SnapshotsResponse struct { + Meta MetaResponse `json:"meta"` + Results []SnapshotDetailResponse `json:"results"` + } + + SnapshotDetailResponse struct { + ID string `json:"id"` + Instance SnapshotInstanceDetailResponse `json:"instance"` + Name string `json:"name"` + Description string `json:"description"` + Type SnapshotType `json:"type"` + Status SnapshotStatus `json:"status"` + AllocatedSize int `json:"allocated_size"` + CreatedAt string `json:"created_at"` + StartedAt *string `json:"started_at,omitempty"` + FinishedAt *string `json:"finished_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + } + + SnapshotInstanceDetailResponse struct { + ID string `json:"id"` + Name string `json:"name"` + } + + SnapshotCreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + } + + SnapshotUpdateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + } + + SnapshotResponse struct { + ID string `json:"id"` + } + + RestoreSnapshotRequest struct { + Name string `json:"name"` + InstanceTypeID string `json:"instance_type_id"` + Volume *InstanceVolumeRequest `json:"volume,omitempty"` + BackupRetentionDays int `json:"backup_retention_days,omitempty"` + BackupStartAt string `json:"backup_start_at,omitempty"` + } + + ListSnapshotOptions struct { + Offset *int + Limit *int + Type *SnapshotType + Status *SnapshotStatus + } +) + +const ( + InstanceGenerationG0B InstanceGeneration = "G0B" + InstanceGenerationG1B InstanceGeneration = "G1B" + InstanceGenerationG2B InstanceGeneration = "G2B" + InstanceGenerationG3B InstanceGeneration = "G3B" + InstanceGenerationG4B InstanceGeneration = "G4B" + InstanceGenerationG5B InstanceGeneration = "G5B" + InstanceGenerationG6B InstanceGeneration = "G6B" + InstanceGenerationG7B InstanceGeneration = "G7B" + InstanceGenerationG8B InstanceGeneration = "G8B" + InstanceGenerationG9B InstanceGeneration = "G9B" + InstanceGenerationG10B InstanceGeneration = "G10B" + InstanceGenerationG1 InstanceGeneration = "G1" +) + +type InstanceStatusUpdate string + +const ( + InstanceStatusUpdateActive InstanceStatusUpdate = "ACTIVE" + InstanceStatusUpdateStopped InstanceStatusUpdate = "STOPPED" +) + +type ( + InstanceService interface { + // List returns a list of database instances for a x-tenant-id. + // It supports pagination and filtering by status, engine_id, and volume size. + List(ctx context.Context, opts ListInstanceOptions) ([]InstanceDetail, error) + + // Get returns a database instance detail by its ID. + // Supports expanding additional fields through the options parameter. + Get(ctx context.Context, id string, opts GetInstanceOptions) (*InstanceDetail, error) + + // Create creates a new database instance asynchronously for a tenant. + // Returns the ID of the created instance. + Create(ctx context.Context, req InstanceCreateRequest) (*InstanceResponse, error) + + // Delete deletes a database instance asynchronously. + Delete(ctx context.Context, id string) error + + // Update updates a database instance's properties. + // Supports updating status, backup retention days, and backup start time. + Update(ctx context.Context, id string, req DatabaseInstanceUpdateRequest) (*InstanceDetail, error) + + // Resize changes the instance type and/or volume size of a database instance. + Resize(ctx context.Context, id string, req InstanceResizeRequest) (*InstanceDetail, error) + + // Start initiates a stopped database instance. + Start(ctx context.Context, id string) (*InstanceDetail, error) + + // Stop stops a running database instance. + Stop(ctx context.Context, id string) (*InstanceDetail, error) + + // ListSnapshots returns a list of snapshots for a specific instance. + ListSnapshots(ctx context.Context, instanceID string, opts ListSnapshotOptions) ([]SnapshotDetailResponse, error) + + // CreateSnapshot creates a new snapshot for the specified instance. + CreateSnapshot(ctx context.Context, instanceID string, req SnapshotCreateRequest) (*SnapshotResponse, error) + + // GetSnapshot retrieves details of a specific snapshot. + GetSnapshot(ctx context.Context, instanceID, snapshotID string) (*SnapshotDetailResponse, error) + + // UpdateSnapshot updates the properties of an existing snapshot. + UpdateSnapshot(ctx context.Context, instanceID, snapshotID string, req SnapshotUpdateRequest) (*SnapshotDetailResponse, error) + + // DeleteSnapshot deletes a snapshot. + DeleteSnapshot(ctx context.Context, instanceID, snapshotID string) error + + // RestoreSnapshot creates a new instance from a snapshot. + RestoreSnapshot(ctx context.Context, snapshotID string, req RestoreSnapshotRequest) (*InstanceResponse, error) + } + + instanceService struct { + client *DBaaSClient + } + + ListInstanceOptions struct { + Offset *int + Limit *int + Status *InstanceStatus + EngineID *string + VolumeSize *int + VolumeSizeGt *int + VolumeSizeGte *int + VolumeSizeLt *int + VolumeSizeLte *int + ExpandedFields []string + } + + GetInstanceOptions struct { + ExpandedFields []string + } + + InstancesResponse struct { + Meta MetaResponse `json:"meta"` + Results []InstanceDetail `json:"results"` + } + + InstanceResponse struct { + ID string `json:"id"` + } + + InstanceDetail struct { + ID string `json:"id"` + Name string `json:"name"` + EngineID string `json:"engine_id"` + DatastoreID string `json:"datastore_id"` + FlavorID string `json:"flavor_id"` + InstanceTypeID string `json:"instance_type_id"` + Volume Volume `json:"volume"` + Addresses []Address `json:"addresses"` + Status InstanceStatus `json:"status"` + Generation InstanceGeneration `json:"generation"` + Parameters []InstanceParametersResponse `json:"parameters"` + BackupRetentionDays int `json:"backup_retention_days"` + BackupStartAt string `json:"backup_start_at"` + CreatedAt string `json:"created_at"` + UpdatedAt *string `json:"updated_at,omitempty"` + StartedAt *string `json:"started_at,omitempty"` + FinishedAt *string `json:"finished_at,omitempty"` + MaintenanceScheduledAt *string `json:"maintenance_scheduled_at,omitempty"` + Replicas []ReplicaDetailResponse `json:"replicas,omitempty"` + } +) + +// List implements the List method of InstanceService. +// Returns a paginated list of database instances with optional filters. +func (s *instanceService) List(ctx context.Context, opts ListInstanceOptions) ([]InstanceDetail, error) { + query := make(url.Values) + + if opts.Offset != nil { + query.Set("_offset", strconv.Itoa(*opts.Offset)) + } + if opts.Limit != nil { + query.Set("_limit", strconv.Itoa(*opts.Limit)) + } + if opts.Status != nil { + query.Set("status", string(*opts.Status)) + } + if opts.EngineID != nil { + query.Set("engine_id", *opts.EngineID) + } + if opts.VolumeSize != nil { + query.Set("volume.size", strconv.Itoa(*opts.VolumeSize)) + } + if opts.VolumeSizeGt != nil { + query.Set("volume.size__gt", strconv.Itoa(*opts.VolumeSizeGt)) + } + if opts.VolumeSizeGte != nil { + query.Set("volume.size__gte", strconv.Itoa(*opts.VolumeSizeGte)) + } + if opts.VolumeSizeLt != nil { + query.Set("volume.size__lt", strconv.Itoa(*opts.VolumeSizeLt)) + } + if opts.VolumeSizeLte != nil { + query.Set("volume.size__lte", strconv.Itoa(*opts.VolumeSizeLte)) + } + if len(opts.ExpandedFields) > 0 { + query.Set("_expand", strings.Join(opts.ExpandedFields, ",")) + } + + result, err := mgc_http.ExecuteSimpleRequestWithRespBody[InstancesResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + "/v1/instances", + nil, + query, + ) + if err != nil { + return nil, err + } + + return result.Results, nil +} + +// Get retrieves details of a specific database instance. +// The instance_id parameter specifies which instance to retrieve. +func (s *instanceService) Get(ctx context.Context, id string, opts GetInstanceOptions) (*InstanceDetail, error) { + query := make(url.Values) + if len(opts.ExpandedFields) > 0 { + query.Set("_expand", strings.Join(opts.ExpandedFields, ",")) + } + + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceDetail]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + fmt.Sprintf("/v1/instances/%s", id), + nil, + query, + ) +} + +// Create initiates the asynchronous creation of a new database instance. +// Returns a response containing the ID of the created instance. +func (s *instanceService) Create(ctx context.Context, req InstanceCreateRequest) (*InstanceResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + "/v1/instances", + req, + nil, + ) +} + +// Delete initiates the asynchronous deletion of a database instance. +// The operation is considered successful when the deletion process begins. +func (s *instanceService) Delete(ctx context.Context, id string) error { + return mgc_http.ExecuteSimpleRequest( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodDelete, + fmt.Sprintf("/v1/instances/%s", id), + nil, + nil, + ) +} + +// Update modifies the properties of an existing database instance. +// Returns the updated instance details. +func (s *instanceService) Update(ctx context.Context, id string, req DatabaseInstanceUpdateRequest) (*InstanceDetail, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceDetail]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPatch, + fmt.Sprintf("/v1/instances/%s", id), + req, + nil, + ) +} + +// Resize changes the instance type and/or volume specifications of a database instance. +// Returns the instance details with the new specifications. +func (s *instanceService) Resize(ctx context.Context, id string, req InstanceResizeRequest) (*InstanceDetail, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceDetail]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/instances/%s/resize", id), + req, + nil, + ) +} + +// Start initiates the startup process of a stopped database instance. +// Returns the instance details with updated status. +func (s *instanceService) Start(ctx context.Context, id string) (*InstanceDetail, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceDetail]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/instances/%s/start", id), + nil, + nil, + ) +} + +// Stop initiates the shutdown process of a running database instance. +// Returns the instance details with updated status. +func (s *instanceService) Stop(ctx context.Context, id string) (*InstanceDetail, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceDetail]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/instances/%s/stop", id), + nil, + nil, + ) +} + +// ListSnapshots returns a list of snapshots for a specific instance. +func (s *instanceService) ListSnapshots(ctx context.Context, instanceID string, opts ListSnapshotOptions) ([]SnapshotDetailResponse, error) { + query := make(url.Values) + + if opts.Offset != nil { + query.Set("_offset", strconv.Itoa(*opts.Offset)) + } + if opts.Limit != nil { + query.Set("_limit", strconv.Itoa(*opts.Limit)) + } + if opts.Type != nil { + query.Set("type", string(*opts.Type)) + } + if opts.Status != nil { + query.Set("status", string(*opts.Status)) + } + + result, err := mgc_http.ExecuteSimpleRequestWithRespBody[SnapshotsResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + fmt.Sprintf("/v1/instances/%s/snapshots", instanceID), + nil, + query, + ) + if err != nil { + return nil, err + } + + return result.Results, nil +} + +// CreateSnapshot creates a new snapshot for the specified instance. +func (s *instanceService) CreateSnapshot(ctx context.Context, instanceID string, req SnapshotCreateRequest) (*SnapshotResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[SnapshotResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/instances/%s/snapshots", instanceID), + req, + nil, + ) +} + +// GetSnapshot retrieves details of a specific snapshot. +func (s *instanceService) GetSnapshot(ctx context.Context, instanceID, snapshotID string) (*SnapshotDetailResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[SnapshotDetailResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + fmt.Sprintf("/v1/instances/%s/snapshots/%s", instanceID, snapshotID), + nil, + nil, + ) +} + +// UpdateSnapshot updates the properties of an existing snapshot. +func (s *instanceService) UpdateSnapshot(ctx context.Context, instanceID, snapshotID string, req SnapshotUpdateRequest) (*SnapshotDetailResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[SnapshotDetailResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPatch, + fmt.Sprintf("/v1/instances/%s/snapshots/%s", instanceID, snapshotID), + req, + nil, + ) +} + +// DeleteSnapshot deletes a snapshot. +func (s *instanceService) DeleteSnapshot(ctx context.Context, instanceID, snapshotID string) error { + return mgc_http.ExecuteSimpleRequest( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodDelete, + fmt.Sprintf("/v1/instances/%s/snapshots/%s", instanceID, snapshotID), + nil, + nil, + ) +} + +// RestoreSnapshot creates a new instance from a snapshot. +func (s *instanceService) RestoreSnapshot(ctx context.Context, snapshotID string, req RestoreSnapshotRequest) (*InstanceResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[InstanceResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/snapshots/%s/restores", snapshotID), + req, + nil, + ) +} diff --git a/dbaas/instances_test.go b/dbaas/instances_test.go new file mode 100644 index 0000000..023e3bb --- /dev/null +++ b/dbaas/instances_test.go @@ -0,0 +1,632 @@ +package dbaas + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/MagaluCloud/mgc-sdk-go/client" + "github.com/MagaluCloud/mgc-sdk-go/helpers" +) + +func testInstanceClient(baseURL string) InstanceService { + httpClient := &http.Client{} + core := client.NewMgcClient("test-api", + client.WithBaseURL(client.MgcUrl(baseURL)), + client.WithHTTPClient(httpClient)) + return New(core).Instances() +} + +func TestInstanceService_List(t *testing.T) { + tests := []struct { + name string + opts ListInstanceOptions + response string + statusCode int + wantCount int + wantErr bool + }{ + { + name: "basic list", + response: `{ + "meta": {"total": 2}, + "results": [ + {"id": "inst1", "name": "instance1"}, + {"id": "inst2", "name": "instance2"} + ] + }`, + statusCode: http.StatusOK, + wantCount: 2, + wantErr: false, + }, + { + name: "with filters and pagination", + opts: ListInstanceOptions{ + Limit: helpers.IntPtr(10), + Offset: helpers.IntPtr(5), + Status: instanceStatusPtr(InstanceStatusActive), + EngineID: helpers.StrPtr("postgres"), + VolumeSize: helpers.IntPtr(100), + ExpandedFields: []string{"replicas", "parameters"}, + }, + response: `{ + "meta": {"total": 1}, + "results": [{"id": "inst1", "status": "ACTIVE"}] + }`, + statusCode: http.StatusOK, + wantCount: 1, + wantErr: false, + }, + { + name: "server error", + response: `{"error": "internal server error"}`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instances", r.URL.Path) + query := r.URL.Query() + + if tt.opts.Limit != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Limit), query.Get("_limit")) + } + if tt.opts.Offset != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Offset), query.Get("_offset")) + } + if tt.opts.Status != nil { + assertEqual(t, string(*tt.opts.Status), query.Get("status")) + } + if tt.opts.EngineID != nil { + assertEqual(t, *tt.opts.EngineID, query.Get("engine_id")) + } + if tt.opts.VolumeSize != nil { + assertEqual(t, strconv.Itoa(*tt.opts.VolumeSize), query.Get("volume.size")) + } + if len(tt.opts.ExpandedFields) > 0 { + assertEqual(t, strings.Join(tt.opts.ExpandedFields, ","), query.Get("_expand")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.List(context.Background(), tt.opts) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantCount, len(result)) + }) + } +} + +func instanceStatusPtr(InstanceStatusActive InstanceStatus) *InstanceStatus { + return &InstanceStatusActive +} + +func TestInstanceService_Get(t *testing.T) { + tests := []struct { + name string + id string + opts GetInstanceOptions + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "existing instance", + id: "inst1", + opts: GetInstanceOptions{ + ExpandedFields: []string{"replicas"}, + }, + response: `{ + "id": "inst1", + "name": "test-instance", + "status": "ACTIVE" + }`, + statusCode: http.StatusOK, + wantID: "inst1", + wantErr: false, + }, + { + name: "not found", + id: "invalid", + response: `{"error": "not found"}`, + statusCode: http.StatusNotFound, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/instances/%s", tt.id), r.URL.Path) + query := r.URL.Query() + + if len(tt.opts.ExpandedFields) > 0 { + assertEqual(t, strings.Join(tt.opts.ExpandedFields, ","), query.Get("_expand")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + instance, err := client.Get(context.Background(), tt.id, tt.opts) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, instance.ID) + }) + } +} + +func TestInstanceService_Create(t *testing.T) { + tests := []struct { + name string + request InstanceCreateRequest + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "successful creation", + request: InstanceCreateRequest{ + Name: "new-instance", + User: "admin", + Password: "secret", + Volume: InstanceVolumeRequest{ + Size: 100, + Type: VolumeTypeCloudNVME, + }, + }, + response: `{"id": "inst-new"}`, + statusCode: http.StatusOK, + wantID: "inst-new", + wantErr: false, + }, + { + name: "invalid request", + request: InstanceCreateRequest{ + Name: "missing-password", + }, + response: `{"error": "password required"}`, + statusCode: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instances", r.URL.Path) + + var req InstanceCreateRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, tt.request.Name, req.Name) + assertEqual(t, tt.request.User, req.User) + assertEqual(t, tt.request.Password, req.Password) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.Create(context.Background(), tt.request) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} + +func TestInstanceService_Delete(t *testing.T) { + tests := []struct { + name string + id string + statusCode int + response string + wantErr bool + }{ + { + name: "successful delete", + id: "inst1", + statusCode: http.StatusNoContent, + wantErr: false, + }, + { + name: "not found", + id: "invalid", + statusCode: http.StatusNotFound, + response: `{"error": "not found"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/instances/%s", tt.id), r.URL.Path) + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + err := client.Delete(context.Background(), tt.id) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + }) + } +} + +func TestInstanceService_Update(t *testing.T) { + tests := []struct { + name string + id string + request DatabaseInstanceUpdateRequest + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "update backup settings", + id: "inst1", + request: DatabaseInstanceUpdateRequest{ + BackupRetentionDays: helpers.IntPtr(7), + BackupStartAt: helpers.StrPtr("02:00"), + }, + response: `{ + "id": "inst1", + "backup_retention_days": 7, + "backup_start_at": "02:00" + }`, + statusCode: http.StatusOK, + wantID: "inst1", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/instances/%s", tt.id), r.URL.Path) + assertEqual(t, http.MethodPatch, r.Method) + + var req DatabaseInstanceUpdateRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, *tt.request.BackupRetentionDays, *req.BackupRetentionDays) + assertEqual(t, *tt.request.BackupStartAt, *req.BackupStartAt) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.Update(context.Background(), tt.id, tt.request) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} + +func TestInstanceService_Resize(t *testing.T) { + tests := []struct { + name string + id string + request InstanceResizeRequest + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "resize instance type", + id: "inst1", + request: InstanceResizeRequest{ + InstanceTypeID: "type-large", + Volume: &InstanceVolumeResizeRequest{ + Size: 200, + Type: VolumeTypeCloudNVME, + }, + }, + response: `{ + "id": "inst1", + "instance_type_id": "type-large", + "volume": {"size": 200} + }`, + statusCode: http.StatusOK, + wantID: "inst1", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/instances/%s/resize", tt.id), r.URL.Path) + assertEqual(t, http.MethodPost, r.Method) + + var req InstanceResizeRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, tt.request.InstanceTypeID, req.InstanceTypeID) + assertEqual(t, tt.request.Volume.Size, req.Volume.Size) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.Resize(context.Background(), tt.id, tt.request) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} + +func TestInstanceService_StartStop(t *testing.T) { + tests := []struct { + name string + method string + id string + response string + statusCode int + wantStatus InstanceStatus + wantErr bool + }{ + { + name: "start instance", + method: "Start", + id: "inst1", + response: `{"id": "inst1", "status": "STARTING"}`, + statusCode: http.StatusOK, + wantStatus: InstanceStatusStarting, + wantErr: false, + }, + { + name: "stop instance", + method: "Stop", + id: "inst1", + response: `{"id": "inst1", "status": "STOPPING"}`, + statusCode: http.StatusOK, + wantStatus: InstanceStatusStopping, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var expectedPath string + switch tt.method { + case "Start": + expectedPath = fmt.Sprintf("/database/v1/instances/%s/start", tt.id) + case "Stop": + expectedPath = fmt.Sprintf("/database/v1/instances/%s/stop", tt.id) + } + assertEqual(t, expectedPath, r.URL.Path) + assertEqual(t, http.MethodPost, r.Method) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + var result *InstanceDetail + var err error + + switch tt.method { + case "Start": + result, err = client.Start(context.Background(), tt.id) + case "Stop": + result, err = client.Stop(context.Background(), tt.id) + } + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantStatus, result.Status) + }) + } +} + +func TestInstanceService_Snapshots(t *testing.T) { + t.Run("ListSnapshots", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instances/inst1/snapshots", r.URL.Path) + query := r.URL.Query() + assertEqual(t, "10", query.Get("_limit")) + assertEqual(t, "AUTOMATED", query.Get("type")) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"meta": {"total": 1}, "results": [{"id": "snap1"}]}`)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + snapshots, err := client.ListSnapshots(context.Background(), "inst1", ListSnapshotOptions{ + Limit: helpers.IntPtr(10), + Type: snapshotTypePtr(SnapshotTypeAutomated), + }) + + assertNoError(t, err) + assertEqual(t, 1, len(snapshots)) + }) + + t.Run("CreateSnapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instances/inst1/snapshots", r.URL.Path) + + var req SnapshotCreateRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, "daily-backup", req.Name) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id": "snap-new"}`)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.CreateSnapshot(context.Background(), "inst1", SnapshotCreateRequest{ + Name: "daily-backup", + }) + + assertNoError(t, err) + assertEqual(t, "snap-new", result.ID) + }) + + t.Run("GetSnapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instances/inst1/snapshots/snap1", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"id": "snap1", "status": "AVAILABLE"}`)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + snapshot, err := client.GetSnapshot(context.Background(), "inst1", "snap1") + + assertNoError(t, err) + assertEqual(t, "snap1", snapshot.ID) + }) + + t.Run("DeleteSnapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instances/inst1/snapshots/snap1", r.URL.Path) + assertEqual(t, http.MethodDelete, r.Method) + + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + err := client.DeleteSnapshot(context.Background(), "inst1", "snap1") + + assertNoError(t, err) + }) + + t.Run("UpdateSnapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/instances/inst1/snapshots/snap1", r.URL.Path) + assertEqual(t, http.MethodPatch, r.Method) + + var req SnapshotUpdateRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, "updated-name", req.Name) + assertEqual(t, "updated description", req.Description) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "id": "snap1", + "name": "updated-name", + "description": "updated description" + }`)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.UpdateSnapshot(context.Background(), "inst1", "snap1", SnapshotUpdateRequest{ + Name: "updated-name", + Description: "updated description", + }) + + assertNoError(t, err) + assertEqual(t, "snap1", result.ID) + assertEqual(t, "updated-name", result.Name) + assertEqual(t, "updated description", result.Description) + }) + + t.Run("RestoreSnapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/snapshots/snap1/restores", r.URL.Path) + assertEqual(t, http.MethodPost, r.Method) + + var req RestoreSnapshotRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, "restored-instance", req.Name) + assertEqual(t, "type-large", req.InstanceTypeID) + assertEqual(t, 100, req.Volume.Size) + assertEqual(t, VolumeTypeCloudNVME, req.Volume.Type) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id": "new-inst"}`)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.RestoreSnapshot(context.Background(), "snap1", RestoreSnapshotRequest{ + Name: "restored-instance", + InstanceTypeID: "type-large", + Volume: &InstanceVolumeRequest{ + Size: 100, + Type: VolumeTypeCloudNVME, + }, + }) + + assertNoError(t, err) + assertEqual(t, "new-inst", result.ID) + }) +} + +func snapshotTypePtr(SnapshotTypeAutomated SnapshotType) *SnapshotType { + return &SnapshotTypeAutomated +} diff --git a/dbaas/replicas.go b/dbaas/replicas.go new file mode 100644 index 0000000..d48d67e --- /dev/null +++ b/dbaas/replicas.go @@ -0,0 +1,181 @@ +package dbaas + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + + mgc_http "github.com/MagaluCloud/mgc-sdk-go/internal/http" +) + +type ( + ReplicaService interface { + // List returns a list of database replicas. + // Supports filtering by source_id and pagination. + List(ctx context.Context, opts ListReplicaOptions) ([]ReplicaDetailResponse, error) + + // Get returns details of a specific replica by its ID. + Get(ctx context.Context, id string) (*ReplicaDetailResponse, error) + + // Create creates a new replica for an instance asynchronously. + Create(ctx context.Context, req ReplicaCreateRequest) (*ReplicaResponse, error) + + // Delete deletes a replica instance asynchronously. + Delete(ctx context.Context, id string) error + + // Resize changes the instance type of a replica. + Resize(ctx context.Context, id string, req ReplicaResizeRequest) (*ReplicaDetailResponse, error) + + // Start initiates a stopped replica instance. + Start(ctx context.Context, id string) (*ReplicaDetailResponse, error) + + // Stop stops a running replica instance. + Stop(ctx context.Context, id string) (*ReplicaDetailResponse, error) + } + + replicaService struct { + client *DBaaSClient + } + + ListReplicaOptions struct { + Offset *int + Limit *int + SourceID *string + } + + ReplicasResponse struct { + Meta MetaResponse `json:"meta"` + Results []ReplicaDetailResponse `json:"results"` + } + + ReplicaCreateRequest struct { + SourceID string `json:"source_id"` + Name string `json:"name"` + FlavorID string `json:"flavor_id,omitempty"` + InstanceTypeID string `json:"instance_type_id,omitempty"` + } + + ReplicaResizeRequest struct { + InstanceTypeID string `json:"instance_type_id,omitempty"` + FlavorID string `json:"flavor_id,omitempty"` + } + + ReplicaResponse struct { + ID string `json:"id"` + } +) + +// NewReplicaService creates a new replica service instance +func NewReplicaService(client *DBaaSClient) ReplicaService { + return &replicaService{client: client} +} + +// List returns a paginated list of database replicas with optional source_id filter +func (s *replicaService) List(ctx context.Context, opts ListReplicaOptions) ([]ReplicaDetailResponse, error) { + query := make(url.Values) + + if opts.Offset != nil { + query.Set("_offset", strconv.Itoa(*opts.Offset)) + } + if opts.Limit != nil { + query.Set("_limit", strconv.Itoa(*opts.Limit)) + } + if opts.SourceID != nil { + query.Set("source_id", *opts.SourceID) + } + + result, err := mgc_http.ExecuteSimpleRequestWithRespBody[ReplicasResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + "/v1/replicas", + nil, + query, + ) + if err != nil { + return nil, err + } + + return result.Results, nil +} + +// Get retrieves details of a specific replica instance +func (s *replicaService) Get(ctx context.Context, id string) (*ReplicaDetailResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[ReplicaDetailResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodGet, + fmt.Sprintf("/v1/replicas/%s", id), + nil, + nil, + ) +} + +// Create initiates the creation of a new replica instance +func (s *replicaService) Create(ctx context.Context, req ReplicaCreateRequest) (*ReplicaResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[ReplicaResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + "/v1/replicas", + req, + nil, + ) +} + +// Delete initiates the deletion of a replica instance +func (s *replicaService) Delete(ctx context.Context, id string) error { + return mgc_http.ExecuteSimpleRequest( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodDelete, + fmt.Sprintf("/v1/replicas/%s", id), + nil, + nil, + ) +} + +// Resize changes the instance type of a replica +func (s *replicaService) Resize(ctx context.Context, id string, req ReplicaResizeRequest) (*ReplicaDetailResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[ReplicaDetailResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/replicas/%s/resize", id), + req, + nil, + ) +} + +// Start initiates a stopped replica instance +func (s *replicaService) Start(ctx context.Context, id string) (*ReplicaDetailResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[ReplicaDetailResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/replicas/%s/start", id), + nil, + nil, + ) +} + +// Stop stops a running replica instance +func (s *replicaService) Stop(ctx context.Context, id string) (*ReplicaDetailResponse, error) { + return mgc_http.ExecuteSimpleRequestWithRespBody[ReplicaDetailResponse]( + ctx, + s.client.newRequest, + s.client.GetConfig(), + http.MethodPost, + fmt.Sprintf("/v1/replicas/%s/stop", id), + nil, + nil, + ) +} diff --git a/dbaas/replicas_test.go b/dbaas/replicas_test.go new file mode 100644 index 0000000..4d312a0 --- /dev/null +++ b/dbaas/replicas_test.go @@ -0,0 +1,447 @@ +package dbaas + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/MagaluCloud/mgc-sdk-go/client" + "github.com/MagaluCloud/mgc-sdk-go/helpers" +) + +// Helper functions +func assertEqual(t *testing.T, expected, actual interface{}, msgAndArgs ...interface{}) { + t.Helper() + if expected != actual { + t.Errorf("Expected %v but got %v. %v", expected, actual, msgAndArgs) + } +} + +func assertError(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Error("Expected error but got nil") + } +} + +func assertNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Errorf("Expected no error but got: %v", err) + } +} + +func testClient(baseURL string) ReplicaService { + httpClient := &http.Client{} + core := client.NewMgcClient("test-api", + client.WithBaseURL(client.MgcUrl(baseURL)), + client.WithHTTPClient(httpClient)) + return NewReplicaService(New(core)) +} + +func TestReplicaService_List(t *testing.T) { + tests := []struct { + name string + opts ListReplicaOptions + response string + statusCode int + wantCount int + wantErr bool + }{ + { + name: "basic list", + response: `{ + "meta": {"total": 2}, + "results": [ + {"id": "rep1", "name": "replica1"}, + {"id": "rep2", "name": "replica2"} + ] + }`, + statusCode: http.StatusOK, + wantCount: 2, + wantErr: false, + }, + { + name: "with pagination", + opts: ListReplicaOptions{ + Limit: helpers.IntPtr(1), + Offset: helpers.IntPtr(1), + }, + response: `{ + "meta": {"total": 1}, + "results": [{"id": "rep2", "name": "replica2"}] + }`, + statusCode: http.StatusOK, + wantCount: 1, + wantErr: false, + }, + { + name: "filter by source", + opts: ListReplicaOptions{ + SourceID: helpers.StrPtr("src1"), + }, + response: `{ + "meta": {"total": 1}, + "results": [{"id": "rep1", "source_id": "src1"}] + }`, + statusCode: http.StatusOK, + wantCount: 1, + wantErr: false, + }, + { + name: "server error", + response: `{"error": "internal server error"}`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/replicas", r.URL.Path) + + query := r.URL.Query() + if tt.opts.Limit != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Limit), query.Get("_limit")) + } + if tt.opts.Offset != nil { + assertEqual(t, strconv.Itoa(*tt.opts.Offset), query.Get("_offset")) + } + if tt.opts.SourceID != nil { + assertEqual(t, *tt.opts.SourceID, query.Get("source_id")) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testClient(server.URL) + result, err := client.List(context.Background(), tt.opts) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantCount, len(result)) + }) + } +} + +func TestReplicaService_Get(t *testing.T) { + tests := []struct { + name string + id string + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "existing replica", + id: "rep1", + response: `{ + "id": "rep1", + "name": "test-replica", + "status": "ACTIVE" + }`, + statusCode: http.StatusOK, + wantID: "rep1", + wantErr: false, + }, + { + name: "not found", + id: "invalid", + response: `{"error": "not found"}`, + statusCode: http.StatusNotFound, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/replicas/%s", tt.id), r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testClient(server.URL) + replica, err := client.Get(context.Background(), tt.id) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, replica.ID) + }) + } +} + +func TestReplicaService_Create(t *testing.T) { + tests := []struct { + name string + request ReplicaCreateRequest + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "successful creation", + request: ReplicaCreateRequest{ + SourceID: "src1", + Name: "test-replica", + }, + response: `{"id": "rep1"}`, + statusCode: http.StatusOK, + wantID: "rep1", + wantErr: false, + }, + { + name: "invalid request", + request: ReplicaCreateRequest{ + Name: "missing-source", + }, + response: `{"error": "source_id required"}`, + statusCode: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/replicas", r.URL.Path) + + var req ReplicaCreateRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, tt.request.SourceID, req.SourceID) + assertEqual(t, tt.request.Name, req.Name) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testClient(server.URL) + result, err := client.Create(context.Background(), tt.request) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} + +func TestReplicaService_Delete(t *testing.T) { + tests := []struct { + name string + id string + statusCode int + response string + wantErr bool + }{ + { + name: "successful delete", + id: "rep1", + statusCode: http.StatusNoContent, + wantErr: false, + }, + { + name: "not found", + id: "invalid", + statusCode: http.StatusNotFound, + response: `{"error": "not found"}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/replicas/%s", tt.id), r.URL.Path) + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testClient(server.URL) + err := client.Delete(context.Background(), tt.id) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + }) + } +} + +func TestReplicaService_Resize(t *testing.T) { + tests := []struct { + name string + id string + request ReplicaResizeRequest + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "resize instance type", + id: "rep1", + request: ReplicaResizeRequest{ + InstanceTypeID: "type2", + }, + response: `{ + "id": "rep1", + "instance_type_id": "type2" + }`, + statusCode: http.StatusOK, + wantID: "rep1", + wantErr: false, + }, + { + name: "invalid resize", + id: "rep1", + request: ReplicaResizeRequest{ + FlavorID: "invalid-flavor", + }, + response: `{"error": "invalid flavor"}`, + statusCode: http.StatusBadRequest, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, fmt.Sprintf("/database/v1/replicas/%s/resize", tt.id), r.URL.Path) + + var req ReplicaResizeRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, tt.request.InstanceTypeID, req.InstanceTypeID) + assertEqual(t, tt.request.FlavorID, req.FlavorID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testClient(server.URL) + result, err := client.Resize(context.Background(), tt.id, tt.request) + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} + +func TestReplicaService_StartStop(t *testing.T) { + tests := []struct { + name string + method string + id string + response string + statusCode int + wantID string + wantErr bool + }{ + { + name: "successful start", + method: "Start", + id: "rep1", + response: `{"id": "rep1", "status": "STARTING"}`, + statusCode: http.StatusOK, + wantID: "rep1", + wantErr: false, + }, + { + name: "already running", + method: "Start", + id: "rep-running", + response: `{"error": "already running"}`, + statusCode: http.StatusConflict, + wantErr: true, + }, + { + name: "successful stop", + method: "Stop", + id: "rep1", + response: `{"id": "rep1", "status": "STOPPING"}`, + statusCode: http.StatusOK, + wantID: "rep1", + wantErr: false, + }, + { + name: "already stopped", + method: "Stop", + id: "rep-stopped", + response: `{"error": "already stopped"}`, + statusCode: http.StatusConflict, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var expectedPath string + if tt.method == "Start" { + expectedPath = fmt.Sprintf("/database/v1/replicas/%s/start", tt.id) + } else { + expectedPath = fmt.Sprintf("/database/v1/replicas/%s/stop", tt.id) + } + assertEqual(t, expectedPath, r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := testClient(server.URL) + var result *ReplicaDetailResponse + var err error + + if tt.method == "Start" { + result, err = client.Start(context.Background(), tt.id) + } else { + result, err = client.Stop(context.Background(), tt.id) + } + + if tt.wantErr { + assertError(t, err) + return + } + + assertNoError(t, err) + assertEqual(t, tt.wantID, result.ID) + }) + } +} From 7c1f4964a7eea79a97e28dcea60b8c0959192f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20P=C3=BAblio?= Date: Wed, 29 Jan 2025 00:16:00 -0300 Subject: [PATCH 2/3] style: format code for consistency and readability --- cmd/examples/dbaas/main.go | 8 ++--- dbaas/client_test.go | 16 ++++----- dbaas/instance_types.go | 26 +++++++------- dbaas/instances.go | 42 +++++++++++----------- dbaas/instances_test.go | 74 +++++++++++++++++++------------------- dbaas/replicas.go | 8 ++--- dbaas/replicas_test.go | 6 ++-- 7 files changed, 90 insertions(+), 90 deletions(-) diff --git a/cmd/examples/dbaas/main.go b/cmd/examples/dbaas/main.go index 937e9e8..f9601d3 100644 --- a/cmd/examples/dbaas/main.go +++ b/cmd/examples/dbaas/main.go @@ -109,11 +109,11 @@ func ExampleCreateInstance() { // Create a new database instance instance, err := dbaasClient.Instances().Create(context.Background(), dbaas.InstanceCreateRequest{ - Name: "example-db-instance", - EngineID: "your-engine-id", // Replace with actual engine ID + Name: "example-db-instance", + EngineID: "your-engine-id", // Replace with actual engine ID InstanceTypeID: "your-instance-type-id", // Replace with actual instance type ID - User: "dbadmin", - Password: "YourStrongPassword123!", + User: "dbadmin", + Password: "YourStrongPassword123!", Volume: dbaas.InstanceVolumeRequest{ Size: 20, // Size in GB Type: dbaas.VolumeTypeCloudNVME, diff --git a/dbaas/client_test.go b/dbaas/client_test.go index 4a0d1c6..6d600fc 100644 --- a/dbaas/client_test.go +++ b/dbaas/client_test.go @@ -9,18 +9,18 @@ import ( func TestNew(t *testing.T) { tests := []struct { - name string - core *client.CoreClient - wantNil bool + name string + core *client.CoreClient + wantNil bool }{ { - name: "valid core client", - core: client.NewMgcClient("test-token"), + name: "valid core client", + core: client.NewMgcClient("test-token"), wantNil: false, }, { - name: "nil core client", - core: nil, + name: "nil core client", + core: nil, wantNil: true, }, } @@ -39,7 +39,7 @@ func TestDBaaSClient_Services(t *testing.T) { core := client.NewMgcClient("test-token", client.WithHTTPClient(&http.Client{}), client.WithBaseURL("https://api.test.com")) - + dbaas := New(core) t.Run("Engines service", func(t *testing.T) { diff --git a/dbaas/instance_types.go b/dbaas/instance_types.go index ceac550..2f3fca7 100644 --- a/dbaas/instance_types.go +++ b/dbaas/instance_types.go @@ -12,22 +12,22 @@ import ( type ( ListInstanceTypesResponse struct { - Meta MetaResponse `json:"meta"` - Results []InstanceType `json:"results"` + Meta MetaResponse `json:"meta"` + Results []InstanceType `json:"results"` } InstanceType struct { - ID string `json:"id"` - Name string `json:"name"` - Label string `json:"label"` - VCPU string `json:"vcpu"` - RAM string `json:"ram"` - FamilyDescription string `json:"family_description"` - FamilySlug string `json:"family_slug"` - Size string `json:"size"` - SKUSource string `json:"sku_source"` - SKUReplica string `json:"sku_replica"` - Status InstanceTypeStatus `json:"status,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Label string `json:"label"` + VCPU string `json:"vcpu"` + RAM string `json:"ram"` + FamilyDescription string `json:"family_description"` + FamilySlug string `json:"family_slug"` + Size string `json:"size"` + SKUSource string `json:"sku_source"` + SKUReplica string `json:"sku_replica"` + Status InstanceTypeStatus `json:"status,omitempty"` } InstanceTypeStatus string diff --git a/dbaas/instances.go b/dbaas/instances.go index 1850dcd..dd5c8f6 100644 --- a/dbaas/instances.go +++ b/dbaas/instances.go @@ -7,7 +7,7 @@ import ( "net/url" "strconv" "strings" - + mgc_http "github.com/MagaluCloud/mgc-sdk-go/internal/http" ) @@ -161,17 +161,17 @@ type ( } SnapshotDetailResponse struct { - ID string `json:"id"` + ID string `json:"id"` Instance SnapshotInstanceDetailResponse `json:"instance"` - Name string `json:"name"` - Description string `json:"description"` - Type SnapshotType `json:"type"` - Status SnapshotStatus `json:"status"` - AllocatedSize int `json:"allocated_size"` - CreatedAt string `json:"created_at"` - StartedAt *string `json:"started_at,omitempty"` - FinishedAt *string `json:"finished_at,omitempty"` - UpdatedAt *string `json:"updated_at,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Type SnapshotType `json:"type"` + Status SnapshotStatus `json:"status"` + AllocatedSize int `json:"allocated_size"` + CreatedAt string `json:"created_at"` + StartedAt *string `json:"started_at,omitempty"` + FinishedAt *string `json:"finished_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` } SnapshotInstanceDetailResponse struct { @@ -194,18 +194,18 @@ type ( } RestoreSnapshotRequest struct { - Name string `json:"name"` - InstanceTypeID string `json:"instance_type_id"` - Volume *InstanceVolumeRequest `json:"volume,omitempty"` - BackupRetentionDays int `json:"backup_retention_days,omitempty"` - BackupStartAt string `json:"backup_start_at,omitempty"` + Name string `json:"name"` + InstanceTypeID string `json:"instance_type_id"` + Volume *InstanceVolumeRequest `json:"volume,omitempty"` + BackupRetentionDays int `json:"backup_retention_days,omitempty"` + BackupStartAt string `json:"backup_start_at,omitempty"` } ListSnapshotOptions struct { - Offset *int - Limit *int - Type *SnapshotType - Status *SnapshotStatus + Offset *int + Limit *int + Type *SnapshotType + Status *SnapshotStatus } ) @@ -491,7 +491,7 @@ func (s *instanceService) Stop(ctx context.Context, id string) (*InstanceDetail, // ListSnapshots returns a list of snapshots for a specific instance. func (s *instanceService) ListSnapshots(ctx context.Context, instanceID string, opts ListSnapshotOptions) ([]SnapshotDetailResponse, error) { query := make(url.Values) - + if opts.Offset != nil { query.Set("_offset", strconv.Itoa(*opts.Offset)) } diff --git a/dbaas/instances_test.go b/dbaas/instances_test.go index 023e3bb..92b8131 100644 --- a/dbaas/instances_test.go +++ b/dbaas/instances_test.go @@ -63,8 +63,8 @@ func TestInstanceService_List(t *testing.T) { wantErr: false, }, { - name: "server error", - response: `{"error": "internal server error"}`, + name: "server error", + response: `{"error": "internal server error"}`, statusCode: http.StatusInternalServerError, wantErr: true, }, @@ -158,7 +158,7 @@ func TestInstanceService_Get(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, fmt.Sprintf("/database/v1/instances/%s", tt.id), r.URL.Path) query := r.URL.Query() - + if len(tt.opts.ExpandedFields) > 0 { assertEqual(t, strings.Join(tt.opts.ExpandedFields, ","), query.Get("_expand")) } @@ -223,7 +223,7 @@ func TestInstanceService_Create(t *testing.T) { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, "/database/v1/instances", r.URL.Path) - + var req InstanceCreateRequest json.NewDecoder(r.Body).Decode(&req) assertEqual(t, tt.request.Name, req.Name) @@ -328,7 +328,7 @@ func TestInstanceService_Update(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, fmt.Sprintf("/database/v1/instances/%s", tt.id), r.URL.Path) assertEqual(t, http.MethodPatch, r.Method) - + var req DatabaseInstanceUpdateRequest json.NewDecoder(r.Body).Decode(&req) assertEqual(t, *tt.request.BackupRetentionDays, *req.BackupRetentionDays) @@ -390,7 +390,7 @@ func TestInstanceService_Resize(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, fmt.Sprintf("/database/v1/instances/%s/resize", tt.id), r.URL.Path) assertEqual(t, http.MethodPost, r.Method) - + var req InstanceResizeRequest json.NewDecoder(r.Body).Decode(&req) assertEqual(t, tt.request.InstanceTypeID, req.InstanceTypeID) @@ -513,7 +513,7 @@ func TestInstanceService_Snapshots(t *testing.T) { t.Run("CreateSnapshot", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, "/database/v1/instances/inst1/snapshots", r.URL.Path) - + var req SnapshotCreateRequest json.NewDecoder(r.Body).Decode(&req) assertEqual(t, "daily-backup", req.Name) @@ -595,36 +595,36 @@ func TestInstanceService_Snapshots(t *testing.T) { }) t.Run("RestoreSnapshot", func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assertEqual(t, "/database/v1/snapshots/snap1/restores", r.URL.Path) - assertEqual(t, http.MethodPost, r.Method) - - var req RestoreSnapshotRequest - json.NewDecoder(r.Body).Decode(&req) - assertEqual(t, "restored-instance", req.Name) - assertEqual(t, "type-large", req.InstanceTypeID) - assertEqual(t, 100, req.Volume.Size) - assertEqual(t, VolumeTypeCloudNVME, req.Volume.Type) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"id": "new-inst"}`)) - })) - defer server.Close() - - client := testInstanceClient(server.URL) - result, err := client.RestoreSnapshot(context.Background(), "snap1", RestoreSnapshotRequest{ - Name: "restored-instance", - InstanceTypeID: "type-large", - Volume: &InstanceVolumeRequest{ - Size: 100, - Type: VolumeTypeCloudNVME, - }, - }) - - assertNoError(t, err) - assertEqual(t, "new-inst", result.ID) - }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "/database/v1/snapshots/snap1/restores", r.URL.Path) + assertEqual(t, http.MethodPost, r.Method) + + var req RestoreSnapshotRequest + json.NewDecoder(r.Body).Decode(&req) + assertEqual(t, "restored-instance", req.Name) + assertEqual(t, "type-large", req.InstanceTypeID) + assertEqual(t, 100, req.Volume.Size) + assertEqual(t, VolumeTypeCloudNVME, req.Volume.Type) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"id": "new-inst"}`)) + })) + defer server.Close() + + client := testInstanceClient(server.URL) + result, err := client.RestoreSnapshot(context.Background(), "snap1", RestoreSnapshotRequest{ + Name: "restored-instance", + InstanceTypeID: "type-large", + Volume: &InstanceVolumeRequest{ + Size: 100, + Type: VolumeTypeCloudNVME, + }, + }) + + assertNoError(t, err) + assertEqual(t, "new-inst", result.ID) + }) } func snapshotTypePtr(SnapshotTypeAutomated SnapshotType) *SnapshotType { diff --git a/dbaas/replicas.go b/dbaas/replicas.go index d48d67e..0b5a36b 100644 --- a/dbaas/replicas.go +++ b/dbaas/replicas.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" "strconv" - + mgc_http "github.com/MagaluCloud/mgc-sdk-go/internal/http" ) @@ -52,14 +52,14 @@ type ( ReplicaCreateRequest struct { SourceID string `json:"source_id"` - Name string `json:"name"` - FlavorID string `json:"flavor_id,omitempty"` + Name string `json:"name"` + FlavorID string `json:"flavor_id,omitempty"` InstanceTypeID string `json:"instance_type_id,omitempty"` } ReplicaResizeRequest struct { InstanceTypeID string `json:"instance_type_id,omitempty"` - FlavorID string `json:"flavor_id,omitempty"` + FlavorID string `json:"flavor_id,omitempty"` } ReplicaResponse struct { diff --git a/dbaas/replicas_test.go b/dbaas/replicas_test.go index 4d312a0..b12dd26 100644 --- a/dbaas/replicas_test.go +++ b/dbaas/replicas_test.go @@ -104,7 +104,7 @@ func TestReplicaService_List(t *testing.T) { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, "/database/v1/replicas", r.URL.Path) - + query := r.URL.Query() if tt.opts.Limit != nil { assertEqual(t, strconv.Itoa(*tt.opts.Limit), query.Get("_limit")) @@ -225,7 +225,7 @@ func TestReplicaService_Create(t *testing.T) { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, "/database/v1/replicas", r.URL.Path) - + var req ReplicaCreateRequest json.NewDecoder(r.Body).Decode(&req) assertEqual(t, tt.request.SourceID, req.SourceID) @@ -336,7 +336,7 @@ func TestReplicaService_Resize(t *testing.T) { t.Run(tt.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assertEqual(t, fmt.Sprintf("/database/v1/replicas/%s/resize", tt.id), r.URL.Path) - + var req ReplicaResizeRequest json.NewDecoder(r.Body).Decode(&req) assertEqual(t, tt.request.InstanceTypeID, req.InstanceTypeID) From 2ea159ea08db03338b04d72cd23abad167446d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20P=C3=BAblio?= Date: Wed, 29 Jan 2025 00:38:40 -0300 Subject: [PATCH 3/3] feat: update README to include Container Registry details --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 409395c..1fead56 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,11 @@ go get github.com/MagaluCloud/mgc-sdk-go - Snapshots - Replicas - Engines +- Container Registry + - Repositories + - Registries + - Images + - Credentials ## Authentication