From 353e92bfe13cf4b1d7425b10e27dd0c97ca3329f Mon Sep 17 00:00:00 2001 From: Jonas Schlecht Date: Fri, 10 Jul 2026 11:24:27 +0200 Subject: [PATCH] feat(opensearch): add wait handlers to v2 opensearch api --- CHANGELOG.md | 4 +- services/opensearch/CHANGELOG.md | 4 + services/opensearch/VERSION | 2 +- services/opensearch/v2api/wait/wait.go | 121 +++++++ services/opensearch/v2api/wait/wait_test.go | 335 ++++++++++++++++++++ 5 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 services/opensearch/v2api/wait/wait.go create mode 100644 services/opensearch/v2api/wait/wait_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f4428089b..ceaada4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ ## Release (2026-MM-DD) - - `alb` - [v0.16.0](services/alb/CHANGELOG.md#v0160) - **Breaking change**: Remove `v2beta2api` API @@ -37,6 +36,9 @@ - [v0.2.0](services/modelexperiments/CHANGELOG.md#v020) - **New**: STACKIT Model Experiments module wait handler added. - `opensearch`: + - [v1.1.0](services/opensearch/CHANGELOG.md#v110) + - `v2api` + - **Feature**: Add `wait` handlers - [v1.0.1](services/opensearch/CHANGELOG.md#v101) - `v1api`: - **Improvement**: Improve http error handling diff --git a/services/opensearch/CHANGELOG.md b/services/opensearch/CHANGELOG.md index 549ef7631..2925b96bb 100644 --- a/services/opensearch/CHANGELOG.md +++ b/services/opensearch/CHANGELOG.md @@ -1,3 +1,7 @@ +## v1.1.0 +- `v2api` + - **Feature**: Add `wait` handlers + ## v1.0.1 - `v1api`: - **Improvement**: Improve http error handling diff --git a/services/opensearch/VERSION b/services/opensearch/VERSION index 6a2b0ac42..795460fce 100644 --- a/services/opensearch/VERSION +++ b/services/opensearch/VERSION @@ -1 +1 @@ -v1.0.1 \ No newline at end of file +v1.1.0 diff --git a/services/opensearch/v2api/wait/wait.go b/services/opensearch/v2api/wait/wait.go new file mode 100644 index 000000000..4c384e75e --- /dev/null +++ b/services/opensearch/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" + opensearch "github.com/stackitcloud/stackit-sdk-go/services/opensearch/v2api" +) + +// CreateInstanceWaitHandler will wait for instance creation +func CreateInstanceWaitHandler(ctx context.Context, a opensearch.DefaultAPI, projectId, region, instanceId string) *wait.AsyncActionHandler[opensearch.Instance] { + return createOrUpdateInstanceWaitHandler(ctx, a, projectId, region, instanceId) +} + +// PartialUpdateInstanceWaitHandler will wait for instance update +func PartialUpdateInstanceWaitHandler(ctx context.Context, a opensearch.DefaultAPI, projectId, region, instanceId string) *wait.AsyncActionHandler[opensearch.Instance] { + return createOrUpdateInstanceWaitHandler(ctx, a, projectId, region, instanceId) +} + +// DeleteInstanceWaitHandler will wait for instance deletion +func DeleteInstanceWaitHandler(ctx context.Context, a opensearch.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 != opensearch.INSTANCESTATUS_DELETING { + return false, nil, nil + } + if *s.Status == opensearch.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 opensearch.DefaultAPI, projectId, region, instanceId, credentialsId string) *wait.AsyncActionHandler[opensearch.CredentialsResponse] { + handler := wait.New(func() (waitFinished bool, response *opensearch.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 opensearch.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 +} + +func createOrUpdateInstanceWaitHandler(ctx context.Context, a opensearch.DefaultAPI, projectId, region, instanceId string) *wait.AsyncActionHandler[opensearch.Instance] { + waitConfig := wait.WaiterHelper[opensearch.Instance, opensearch.InstanceStatus]{ + FetchInstance: a.GetInstance(ctx, projectId, region, instanceId).Execute, + GetState: func(s *opensearch.Instance) (opensearch.InstanceStatus, error) { + if s == nil { + return "", errors.New("empty response") + } + if s.Status == nil { + return "", errors.New("status is missing") + } + return *s.Status, nil + }, + ActiveState: []opensearch.InstanceStatus{opensearch.INSTANCESTATUS_ACTIVE}, + ErrorState: []opensearch.InstanceStatus{opensearch.INSTANCESTATUS_FAILED}, + } + + handler := wait.New(waitConfig.Wait()) + handler.SetTimeout(45 * time.Minute) + return handler +} diff --git a/services/opensearch/v2api/wait/wait_test.go b/services/opensearch/v2api/wait/wait_test.go new file mode 100644 index 000000000..291c0e115 --- /dev/null +++ b/services/opensearch/v2api/wait/wait_test.go @@ -0,0 +1,335 @@ +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" + opensearch "github.com/stackitcloud/stackit-sdk-go/services/opensearch/v2api" +) + +type mockSettings struct { + instanceGetFails bool + instanceDeletionSucceedsWithErrors bool + instanceResourceId string + instanceResourceOperation opensearch.InstanceLastOperationType + instanceResourceState *opensearch.InstanceStatus + instanceResourceDescription string + + credentialGetFails bool + credentialResourceId string + credentialOperationSucceeds bool + credentialDeletionSucceeds bool +} + +func newAPIMock(settings *mockSettings) opensearch.DefaultAPI { + return &opensearch.DefaultAPIServiceMock{ + GetInstanceExecuteMock: utils.Ptr(func(_ opensearch.ApiGetInstanceRequest) (*opensearch.Instance, error) { + if settings.instanceGetFails { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 500, + } + } + + if settings.instanceResourceOperation == opensearch.INSTANCELASTOPERATIONTYPE_DELETE && settings.instanceResourceState != nil && *settings.instanceResourceState == opensearch.INSTANCESTATUS_ACTIVE { + if settings.instanceDeletionSucceedsWithErrors { + return &opensearch.Instance{ + InstanceId: &settings.instanceResourceId, + Status: settings.instanceResourceState, + LastOperation: opensearch.InstanceLastOperation{ + Description: settings.instanceResourceDescription, + Type: settings.instanceResourceOperation, + }, + }, nil + } + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 410, + } + } + + return &opensearch.Instance{ + InstanceId: &settings.instanceResourceId, + Status: settings.instanceResourceState, + }, nil + }), + GetCredentialsExecuteMock: utils.Ptr(func(_ opensearch.ApiGetCredentialsRequest) (*opensearch.CredentialsResponse, error) { + if settings.credentialGetFails { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 500, + } + } + + if !settings.credentialOperationSucceeds || settings.credentialDeletionSucceeds { + return nil, &oapierror.GenericOpenAPIError{ + StatusCode: 404, + } + } + + return &opensearch.CredentialsResponse{ + Id: settings.credentialResourceId, + }, nil + }), + } +} + +func TestCreateOrUpdateInstanceWaitHandler(t *testing.T) { + tests := []struct { + desc string + getFails bool + resourceState *opensearch.InstanceStatus + wantErr bool + wantResp bool + }{ + { + desc: "succeeded", + getFails: false, + resourceState: utils.Ptr(opensearch.INSTANCESTATUS_ACTIVE), + wantErr: false, + wantResp: true, + }, + { + desc: "failed", + getFails: false, + resourceState: utils.Ptr(opensearch.INSTANCESTATUS_FAILED), + wantErr: true, + wantResp: true, + }, + { + desc: "get_fails", + getFails: true, + wantErr: true, + wantResp: false, + }, + { + desc: "timeout", + getFails: false, + resourceState: utils.Ptr(opensearch.InstanceStatus("ANOTHER_STATE")), + wantErr: true, + wantResp: false, + }, + } + + handlers := map[string]func(context.Context, opensearch.DefaultAPI, string, string, string) *wait.AsyncActionHandler[opensearch.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 *opensearch.Instance + if tt.wantResp { + wantRes = &opensearch.Instance{ + InstanceId: &instanceId, + Status: tt.resourceState, + } + } + + handler := handlerFn(context.Background(), apiClient, "pid", "eu01", 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 *opensearch.InstanceStatus + resourceDescription string + wantErr bool + }{ + { + desc: "delete_succeeded", + getFails: false, + deleteSucceeedsWithErrors: false, + resourceState: utils.Ptr(opensearch.INSTANCESTATUS_ACTIVE), + wantErr: false, + }, + { + desc: "delete_failed", + getFails: false, + deleteSucceeedsWithErrors: false, + resourceState: utils.Ptr(opensearch.INSTANCESTATUS_FAILED), + wantErr: true, + }, + { + desc: "delete_succeeds_with_errors", + getFails: false, + resourceState: utils.Ptr(opensearch.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: opensearch.INSTANCELASTOPERATIONTYPE_DELETE, + instanceResourceDescription: tt.resourceDescription, + instanceResourceState: tt.resourceState, + }) + + handler := DeleteInstanceWaitHandler(context.Background(), apiClient, "", "eu01", 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 *opensearch.CredentialsResponse + if tt.wantResp { + wantRes = &opensearch.CredentialsResponse{ + Id: credentialsId, + } + } + + handler := CreateCredentialsWaitHandler(context.Background(), apiClient, "", "eu01", "", 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, "", "eu01", "", 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) + } + }) + }) + } +}