From 499b8ceeed777581adc96dea754c12b75650703c Mon Sep 17 00:00:00 2001 From: GokceGK Date: Wed, 1 Jul 2026 07:54:01 +0200 Subject: [PATCH 1/2] feat(redis): add wait handlers for v2 relates to STACKITSDK-477 --- CHANGELOG.md | 8 +- examples/redis/go.mod | 6 +- examples/redis/redis.go | 43 +++- services/redis/CHANGELOG.md | 3 + services/redis/VERSION | 2 +- services/redis/v2api/wait/wait.go | 121 +++++++++ services/redis/v2api/wait/wait_test.go | 343 +++++++++++++++++++++++++ 7 files changed, 509 insertions(+), 17 deletions(-) create mode 100644 services/redis/v2api/wait/wait.go create mode 100644 services/redis/v2api/wait/wait_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e672937e..8d1742124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,12 @@ - **New**: STACKIT Model Experiments module wait handler added. - `rabbitmq`: - [v1.1.0](services/rabbitmq/CHANGELOG.md#v110) - - `v2api`: - - **Feature**: Added wait handlers + - `v2api`: + - **Feature**: Added wait handlers +- `redis`: + - [v1.1.0](services/redis/CHANGELOG.md#v110) + - `v2api`: + - **Feature**: Added wait handlers - `ske`: - [v1.19.0](services/ske/CHANGELOG.md#v1190) - Package `v1api`: diff --git a/examples/redis/go.mod b/examples/redis/go.mod index ae870ec3e..1271c13b3 100644 --- a/examples/redis/go.mod +++ b/examples/redis/go.mod @@ -5,12 +5,10 @@ go 1.25 // This is not needed in production. This is only here to point the golangci linter to the local version instead of the last release on GitHub. replace github.com/stackitcloud/stackit-sdk-go/services/redis => ../../services/redis -require ( - github.com/stackitcloud/stackit-sdk-go/core v0.26.0 - github.com/stackitcloud/stackit-sdk-go/services/redis v0.30.0 -) +require github.com/stackitcloud/stackit-sdk-go/services/redis v1.1.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/stackitcloud/stackit-sdk-go/core v0.26.0 // indirect ) diff --git a/examples/redis/redis.go b/examples/redis/redis.go index 2182424ab..97765b63f 100644 --- a/examples/redis/redis.go +++ b/examples/redis/redis.go @@ -5,25 +5,24 @@ import ( "fmt" "os" - "github.com/stackitcloud/stackit-sdk-go/core/config" - redis "github.com/stackitcloud/stackit-sdk-go/services/redis/v1api" + redis "github.com/stackitcloud/stackit-sdk-go/services/redis/v2api" + wait "github.com/stackitcloud/stackit-sdk-go/services/redis/v2api/wait" ) func main() { projectId := "PROJECT_ID" // the uuid of your STACKIT project planId := "PLAN_ID" + region := "eu01" // Create a new API client, that uses default authentication and configuration - redisClient, err := redis.NewAPIClient( - config.WithRegion("eu01"), - ) + redisClient, err := redis.NewAPIClient() if err != nil { fmt.Fprintf(os.Stderr, "Creating API client: %v\n", err) os.Exit(1) } // Get the redis instances for your project - getInstancesResp, err := redisClient.DefaultAPI.ListInstances(context.Background(), projectId).Execute() + getInstancesResp, err := redisClient.DefaultAPI.ListInstances(context.Background(), projectId, region).Execute() if err != nil { fmt.Fprintf(os.Stderr, "Error when calling `GetInstances`: %v\n", err) } else { @@ -31,7 +30,7 @@ func main() { } // Get the redis offerings for your project - getOfferingsResp, err := redisClient.DefaultAPI.ListOfferings(context.Background(), projectId).Execute() + getOfferingsResp, err := redisClient.DefaultAPI.ListOfferings(context.Background(), projectId, region).Execute() if err != nil { fmt.Fprintf(os.Stderr, "Error when calling `GetOfferings`: %v\n", err) } else { @@ -44,10 +43,34 @@ func main() { Parameters: &redis.InstanceParameters{}, PlanId: planId, } - createInstanceResp, err := redisClient.DefaultAPI.CreateInstance(context.Background(), projectId).CreateInstancePayload(createInstancePayload).Execute() + createInstanceResp, err := redisClient.DefaultAPI.CreateInstance(context.Background(), projectId, region).CreateInstancePayload(createInstancePayload).Execute() if err != nil { fmt.Fprintf(os.Stderr, "Error when calling `CreateInstance`: %v\n", err) - } else { - fmt.Printf("Created instance with instance id \"%s\".\n", createInstanceResp.InstanceId) + os.Exit(1) + } + fmt.Printf("Created instance with instance id \"%s\".\n", createInstanceResp.InstanceId) + + // Wait for creation of redis instance + instance, err := wait.CreateInstanceWaitHandler(context.Background(), redisClient.DefaultAPI, projectId, region, createInstanceResp.InstanceId).WaitWithContext(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error when waiting for creation: %v\n", err) + os.Exit(1) + } + fmt.Printf("Redis instance %q has been successfully created.\n", *instance.InstanceId) + + // Delete a redis instance + err = redisClient.DefaultAPI.DeleteInstance(context.Background(), projectId, region, *instance.InstanceId).Execute() + if err != nil { + fmt.Fprintf(os.Stderr, "Error when calling 'DeleteInstance': %v\n", err) + os.Exit(1) + } + fmt.Printf("Deleting instance with instance id %q.\n", createInstanceResp.InstanceId) + + // Wait for deletion of redis instance + _, err = wait.DeleteInstanceWaitHandler(context.Background(), redisClient.DefaultAPI, projectId, region, *instance.InstanceId).WaitWithContext(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Error when waiting for deletion: %v\n", err) + os.Exit(1) } + fmt.Printf("Redis instance %q has been successfully deleted.\n", *instance.InstanceId) } diff --git a/services/redis/CHANGELOG.md b/services/redis/CHANGELOG.md index f3e0297cd..c1e78a485 100644 --- a/services/redis/CHANGELOG.md +++ b/services/redis/CHANGELOG.md @@ -1,3 +1,6 @@ +## v1.1.0 +- `v2api`: **Feature**: Added wait handlers + ## v1.0.0 - **Breaking Change:** The region is no longer specified within the client configuration. Instead, the region must be passed as a parameter to any region-specific request. - `v2api`: diff --git a/services/redis/VERSION b/services/redis/VERSION index 60453e690..992977ad2 100644 --- a/services/redis/VERSION +++ b/services/redis/VERSION @@ -1 +1 @@ -v1.0.0 \ No newline at end of file +v1.1.0 \ No newline at end of file diff --git a/services/redis/v2api/wait/wait.go b/services/redis/v2api/wait/wait.go new file mode 100644 index 000000000..2991bbe00 --- /dev/null +++ b/services/redis/v2api/wait/wait.go @@ -0,0 +1,121 @@ +package wait + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/wait" + redis "github.com/stackitcloud/stackit-sdk-go/services/redis/v2api" +) + +func createOrUpdateInstanceWaitHandler(ctx context.Context, client redis.DefaultAPI, projectId, region, instanceId string) *wait.AsyncActionHandler[redis.Instance] { + waitConfig := wait.WaiterHelper[redis.Instance, redis.InstanceStatus]{ + FetchInstance: client.GetInstance(ctx, projectId, region, instanceId).Execute, + GetState: func(response *redis.Instance) (redis.InstanceStatus, error) { + if response == nil { + return "", errors.New("empty response") + } + if response.Status == nil { + return "", errors.New("status is missing in response") + } + return *response.Status, nil + }, + ActiveState: []redis.InstanceStatus{redis.INSTANCESTATUS_ACTIVE}, + ErrorState: []redis.InstanceStatus{redis.INSTANCESTATUS_FAILED}, + } + + handler := wait.New(waitConfig.Wait()) + handler.SetTimeout(45 * time.Minute) + return handler +} + +// CreateInstanceWaitHandler will wait for instance creation +func CreateInstanceWaitHandler(ctx context.Context, client redis.DefaultAPI, projectId, region, instanceId string) *wait.AsyncActionHandler[redis.Instance] { + return createOrUpdateInstanceWaitHandler(ctx, client, projectId, region, instanceId) +} + +// PartialUpdateInstanceWaitHandler will wait for instance update +func PartialUpdateInstanceWaitHandler(ctx context.Context, client redis.DefaultAPI, projectId, region, instanceId string) *wait.AsyncActionHandler[redis.Instance] { + return createOrUpdateInstanceWaitHandler(ctx, client, projectId, region, instanceId) +} + +// DeleteInstanceWaitHandler will wait for instance deletion +func DeleteInstanceWaitHandler(ctx context.Context, a redis.DefaultAPI, projectId, region, instanceId string) *wait.AsyncActionHandler[struct{}] { + handler := wait.New(func() (waitFinished bool, response *struct{}, err error) { + s, err := a.GetInstance(ctx, projectId, region, instanceId).Execute() + if err == nil { + if s.Status == nil { + return false, nil, fmt.Errorf("delete failed for instance with id %s. The response is not valid: The status is missing", instanceId) + } + if *s.Status != redis.INSTANCESTATUS_DELETING { + return false, nil, nil + } + if *s.Status == redis.INSTANCESTATUS_ACTIVE { + if strings.Contains(s.LastOperation.Description, "DeleteFailed") || strings.Contains(s.LastOperation.Description, "failed") { + return true, nil, fmt.Errorf("instance was deleted successfully but has errors: %s", s.LastOperation.Description) + } + return true, nil, nil + } + return false, nil, nil + } + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if !ok { + return false, nil, fmt.Errorf("could not convert error to oapierror.GenericOpenAPIError") + } + if oapiErr.StatusCode != http.StatusGone { + return false, nil, err + } + return true, nil, nil + }) + handler.SetTimeout(15 * time.Minute) + return handler +} + +// CreateCredentialsWaitHandler will wait for credentials creation +func CreateCredentialsWaitHandler(ctx context.Context, a redis.DefaultAPI, projectId, region, instanceId, credentialsId string) *wait.AsyncActionHandler[redis.CredentialsResponse] { + handler := wait.New(func() (waitFinished bool, response *redis.CredentialsResponse, err error) { + s, err := a.GetCredentials(ctx, projectId, region, instanceId, credentialsId).Execute() + if err != nil { + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if !ok { + return false, nil, fmt.Errorf("could not convert error to oapierror.GenericOpenAPIError") + } + // If the request returns 404, the credentials have not been created yet + if oapiErr.StatusCode == http.StatusNotFound { + return false, nil, nil + } + return false, nil, err + } + if s.Id == credentialsId { + return true, s, nil + } + return false, nil, nil + }) + handler.SetTimeout(1 * time.Minute) + return handler +} + +// DeleteCredentialsWaitHandler will wait for credentials deletion +func DeleteCredentialsWaitHandler(ctx context.Context, a redis.DefaultAPI, projectId, region, instanceId, credentialsId string) *wait.AsyncActionHandler[struct{}] { + handler := wait.New(func() (waitFinished bool, response *struct{}, err error) { + _, err = a.GetCredentials(ctx, projectId, region, instanceId, credentialsId).Execute() + if err == nil { + return false, nil, nil + } + oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped + if !ok { + return false, nil, fmt.Errorf("could not convert error to oapierror.GenericOpenAPIError") + } + if oapiErr.StatusCode != http.StatusNotFound && oapiErr.StatusCode != http.StatusGone { + return false, nil, err + } + return true, nil, nil + }) + handler.SetTimeout(1 * time.Minute) + return handler +} diff --git a/services/redis/v2api/wait/wait_test.go b/services/redis/v2api/wait/wait_test.go new file mode 100644 index 000000000..d0a35c658 --- /dev/null +++ b/services/redis/v2api/wait/wait_test.go @@ -0,0 +1,343 @@ +package wait + +import ( + "context" + "fmt" + "testing" + "testing/synctest" + "time" + + "github.com/google/go-cmp/cmp" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/core/wait" + redis "github.com/stackitcloud/stackit-sdk-go/services/redis/v2api" +) + +type mockSettings struct { + instanceGetFails bool + instanceDeletionSucceedsWithErrors bool + instanceResourceId string + instanceResourceOperation redis.InstanceLastOperationType + instanceResourceState *redis.InstanceStatus + instanceResourceDescription string + + credentialGetFails bool + credentialResourceId string + credentialOperationSucceeds bool + credentialDeletionSucceeds bool +} + +const testRegion = "eu01" + +func newAPIMock(settings *mockSettings) redis.DefaultAPI { + return &redis.DefaultAPIServiceMock{ + GetInstanceExecuteMock: utils.Ptr(func(_ redis.ApiGetInstanceRequest) (*redis.Instance, error) { + if settings.instanceGetFails { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 500, + } + } + if settings.instanceResourceOperation == redis.INSTANCELASTOPERATIONTYPE_DELETE && settings.instanceResourceState != nil && *settings.instanceResourceState == redis.INSTANCESTATUS_ACTIVE { + if settings.instanceDeletionSucceedsWithErrors { + return &redis.Instance{ + InstanceId: &settings.instanceResourceId, + Status: settings.instanceResourceState, + LastOperation: redis.InstanceLastOperation{ + Description: settings.instanceResourceDescription, + Type: settings.instanceResourceOperation, + }, + }, nil + } + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 410, + } + } + + return &redis.Instance{ + InstanceId: &settings.instanceResourceId, + Status: settings.instanceResourceState, + }, nil + }), + GetCredentialsExecuteMock: utils.Ptr(func(_ redis.ApiGetCredentialsRequest) (*redis.CredentialsResponse, error) { + if settings.credentialGetFails { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 500, + } + } + + if !settings.credentialOperationSucceeds || settings.credentialDeletionSucceeds { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 404, + } + } + + return &redis.CredentialsResponse{ + Id: settings.credentialResourceId, + }, nil + }), + } +} + +func TestCreateOrUpdateInstanceWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + resourceState *redis.InstanceStatus + wantErr bool + wantResp bool + }{ + { + desc: "create_or_update_succeeded", + getFails: false, + resourceState: utils.Ptr(redis.INSTANCESTATUS_ACTIVE), + wantErr: false, + wantResp: true, + }, + { + desc: "create_or_update_failed", + getFails: false, + resourceState: utils.Ptr(redis.INSTANCESTATUS_FAILED), + wantErr: true, + wantResp: true, + }, + { + desc: "wrong state in response", + getFails: false, + resourceState: utils.Ptr(redis.InstanceStatus("ANOTHER STATE")), + wantErr: true, + wantResp: false, + }, + { + desc: "get_fails", + getFails: true, + wantErr: true, + wantResp: false, + }, + { + desc: "timeout", + getFails: false, + resourceState: utils.Ptr(redis.InstanceStatus("ANOTHER STATE")), + wantErr: true, + wantResp: false, + }, + } + + handlers := map[string]func(context.Context, redis.DefaultAPI, string, string, string) *wait.AsyncActionHandler[redis.Instance]{ + "common logic": createOrUpdateInstanceWaitHandler, + "create": CreateInstanceWaitHandler, + "update": PartialUpdateInstanceWaitHandler, + } + + for handlerDesc, handlerFn := range handlers { + for _, tt := range tests { + t.Run(fmt.Sprintf("%s - %s", handlerDesc, tt.desc), func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + instanceId := "foo-bar" + + apiClient := newAPIMock(&mockSettings{ + instanceGetFails: tt.getFails, + instanceResourceId: instanceId, + instanceResourceState: tt.resourceState, + }) + + var wantRes *redis.Instance + if tt.wantResp { + wantRes = &redis.Instance{ + InstanceId: &instanceId, + Status: tt.resourceState, + } + } + + handler := handlerFn(context.Background(), apiClient, "pid", testRegion, instanceId) + gotRes, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + diff := cmp.Diff(gotRes, wantRes) + if diff != "" { + t.Fatalf("handler gotRes = %+v\n want %+v\n diff = %s", gotRes, wantRes, diff) + } + }) + }) + } + } +} + +func TestDeleteInstanceWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + deleteSucceeedsWithErrors bool + resourceState *redis.InstanceStatus + resourceDescription string + wantErr bool + }{ + { + desc: "delete_succeeded", + getFails: false, + deleteSucceeedsWithErrors: false, + resourceState: utils.Ptr(redis.INSTANCESTATUS_ACTIVE), + wantErr: false, + }, + { + desc: "delete_failed", + getFails: false, + deleteSucceeedsWithErrors: false, + resourceState: utils.Ptr(redis.INSTANCESTATUS_FAILED), + wantErr: true, + }, + { + desc: "delete_succeeds_with_errors", + getFails: false, + resourceState: utils.Ptr(redis.INSTANCESTATUS_ACTIVE), + deleteSucceeedsWithErrors: true, + resourceDescription: "Deleting resource: cf failed with error: DeleteFailed", + wantErr: true, + }, + { + desc: "get_fails", + deleteSucceeedsWithErrors: false, + getFails: true, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + instanceId := "foo-bar" + + apiClient := newAPIMock(&mockSettings{ + instanceGetFails: tt.getFails, + instanceDeletionSucceedsWithErrors: tt.deleteSucceeedsWithErrors, + instanceResourceId: instanceId, + instanceResourceOperation: redis.INSTANCELASTOPERATIONTYPE_DELETE, + instanceResourceDescription: tt.resourceDescription, + instanceResourceState: tt.resourceState, + }) + + handler := DeleteInstanceWaitHandler(context.Background(), apiClient, "", testRegion, instanceId) + + _, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + }) + }) + } +} + +func TestCreateCredentialsWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + operationSucceeds bool + wantErr bool + wantResp bool + }{ + { + desc: "create_succeeded", + getFails: false, + operationSucceeds: true, + wantErr: false, + wantResp: true, + }, + { + desc: "create_failed", + getFails: false, + operationSucceeds: false, + wantErr: true, + wantResp: false, + }, + { + desc: "get_fails", + getFails: true, + wantErr: true, + wantResp: false, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + credentialsId := "foo-bar" + + apiClient := newAPIMock(&mockSettings{ + credentialGetFails: tt.getFails, + credentialResourceId: credentialsId, + credentialOperationSucceeds: tt.operationSucceeds, + }) + + var wantRes *redis.CredentialsResponse + if tt.wantResp { + wantRes = &redis.CredentialsResponse{ + Id: credentialsId, + } + } + + handler := CreateCredentialsWaitHandler(context.Background(), apiClient, "", testRegion, "", credentialsId) + + gotRes, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + if !cmp.Equal(gotRes, wantRes) { + t.Fatalf("handler gotRes = %v, want %v", gotRes, wantRes) + } + }) + }) + } +} + +func TestDeleteCredentialsWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + deletionSucceeds bool + wantErr bool + }{ + { + desc: "delete_succeeded", + getFails: false, + deletionSucceeds: true, + wantErr: false, + }, + { + desc: "delete_failed", + getFails: false, + deletionSucceeds: false, + wantErr: true, + }, + { + desc: "get_fails", + getFails: true, + deletionSucceeds: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + credentialsId := "foo-bar" + + apiClient := newAPIMock(&mockSettings{ + credentialGetFails: tt.getFails, + credentialResourceId: credentialsId, + credentialOperationSucceeds: true, + credentialDeletionSucceeds: tt.deletionSucceeds, + }) + + handler := DeleteCredentialsWaitHandler(context.Background(), apiClient, "", testRegion, "", credentialsId) + + _, err := handler.SetTimeout(10 * time.Millisecond).WaitWithContext(context.Background()) + + if (err != nil) != tt.wantErr { + t.Fatalf("handler error = %v, wantErr %v", err, tt.wantErr) + } + }) + }) + } +} From 6753d175d245d972b9657bb3da1803ffd205ba01 Mon Sep 17 00:00:00 2001 From: GokceGK Date: Wed, 1 Jul 2026 08:57:25 +0200 Subject: [PATCH 2/2] feat(redis): improve example relates to STACKITSDK-477 --- examples/redis/redis.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/redis/redis.go b/examples/redis/redis.go index 97765b63f..7dc8b0244 100644 --- a/examples/redis/redis.go +++ b/examples/redis/redis.go @@ -6,7 +6,7 @@ import ( "os" redis "github.com/stackitcloud/stackit-sdk-go/services/redis/v2api" - wait "github.com/stackitcloud/stackit-sdk-go/services/redis/v2api/wait" + "github.com/stackitcloud/stackit-sdk-go/services/redis/v2api/wait" ) func main() { @@ -48,7 +48,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error when calling `CreateInstance`: %v\n", err) os.Exit(1) } - fmt.Printf("Created instance with instance id \"%s\".\n", createInstanceResp.InstanceId) + fmt.Printf("Triggered creation of instance with instance id \"%s\".\n", createInstanceResp.InstanceId) // Wait for creation of redis instance instance, err := wait.CreateInstanceWaitHandler(context.Background(), redisClient.DefaultAPI, projectId, region, createInstanceResp.InstanceId).WaitWithContext(context.Background())