diff --git a/CHANGELOG.md b/CHANGELOG.md index 789968aab..35b02fc94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ ## Release (2025-XX-YY) -- `alb`: [v0.1.0](services/alb/CHANGELOG.md#v010-2025-03-19) +- `core`: [0.17.0](core/CHANGELOG.md#v0170-2025-03-25) + - **New:** Helper functions for generic openapi error codes + - **New:** If a custom http.Client is provided, the http.Transport is respected. This allows customizing the http.Client with custom timeouts or instrumentation. +- `alb`: [v0.2.0](services/alb/CHANGELOG.md#v020-2025-03-20) - **New:** API for application load balancer - `cdn`: [v0.1.0](services/cdn/CHANGELOG.md#v010-2025-03-19) - **New:** Introduce new API for content delivery -- `core`: [v0.16.2](core/CHANGELOG.md#v0162-2025-03-21) - - **New:** If a custom http.Client is provided, the http.Transport is respected. This allows customizing the http.Client with custom timeouts or instrumentation. - `serverupdate`: [v1.0.0](services/serverupdate/CHANGELOG.md#v100-2025-03-19) - **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. - `serverbackup`: [v1.0.0](services/serverbackup/CHANGELOG.md#v100-2025-03-19) diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index cd48cbe6d..882bb5159 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,3 +1,6 @@ +## v0.17.0 (2025-03-25) +- **New:** Helper functions for generic openapi error codes + ## v0.16.2 (2025-03-21) - **New:** If a custom http.Client is provided, the http.Transport is respected. This allows customizing the http.Client with custom timeouts or instrumentation. diff --git a/core/oapierror/oapierror.go b/core/oapierror/oapierror.go index c40770f5c..205abc17f 100644 --- a/core/oapierror/oapierror.go +++ b/core/oapierror/oapierror.go @@ -19,6 +19,23 @@ type GenericOpenAPIError struct { Model interface{} } +func NewError(code int, status string) *GenericOpenAPIError { + return &GenericOpenAPIError{ + StatusCode: code, + ErrorMessage: status, + Model: map[string]any{}, + } +} + +func NewErrorWithBody(code int, status string, body []byte, model any) *GenericOpenAPIError { + return &GenericOpenAPIError{ + StatusCode: code, + ErrorMessage: status, + Body: body, + Model: model, + } +} + // Error returns non-empty string if there was an errorMessage. func (e GenericOpenAPIError) Error() string { // Prevent panic in case of negative value diff --git a/services/alb/CHANGELOG.md b/services/alb/CHANGELOG.md index f56e2cbf5..d39d9f186 100644 --- a/services/alb/CHANGELOG.md +++ b/services/alb/CHANGELOG.md @@ -1,2 +1,5 @@ +## v0.2.0 (2025-03-20) +- **Enhancement:** Provider waiter for loadbalancer api + ## v0.1.0 (2025-03-19) - **New:** API for application load balancer diff --git a/services/alb/go.mod b/services/alb/go.mod index 4176ff213..8075789f2 100644 --- a/services/alb/go.mod +++ b/services/alb/go.mod @@ -2,8 +2,6 @@ module github.com/stackitcloud/stackit-sdk-go/services/alb go 1.21 -require github.com/stackitcloud/stackit-sdk-go/core v0.16.0 - require ( github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect diff --git a/services/alb/wait/wait.go b/services/alb/wait/wait.go new file mode 100644 index 000000000..f46162d0d --- /dev/null +++ b/services/alb/wait/wait.go @@ -0,0 +1,68 @@ +package wait + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/wait" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +const ( + StatusUnspecified = "STATUS_UNSPECIFIED" + StatusPending = "STATUS_PENDING" + StatusReady = "STATUS_READY" + StatusError = "STATUS_ERROR" + StatusTerminating = "STATUS_TERMINATING" +) + +type APIClientLoadbalancerInterface interface { + GetLoadBalancerExecute(ctx context.Context, projectId string, region string, name string) (*alb.LoadBalancer, error) +} + +func CreateOrUpdateLoadbalancerWaitHandler(ctx context.Context, client APIClientLoadbalancerInterface, projectId, region, name string) *wait.AsyncActionHandler[alb.LoadBalancer] { + handler := wait.New(func() (bool, *alb.LoadBalancer, error) { + response, err := client.GetLoadBalancerExecute(ctx, projectId, region, name) + if err != nil { + return false, nil, err + } + if response.HasStatus() { + switch *response.Status { + case StatusPending: + return false, nil, nil + case StatusUnspecified: + return false, nil, nil + case StatusError: + return true, response, fmt.Errorf("loadbalancer in error: %s", *response.Status) + default: + return true, response, nil + } + } + + return false, nil, nil + }) + handler.SetTimeout(10 * time.Minute) + return handler +} + +func DeleteLoadbalancerWaitHandler(ctx context.Context, client APIClientLoadbalancerInterface, projectId, region, name string) *wait.AsyncActionHandler[alb.LoadBalancer] { + handler := wait.New(func() (bool, *alb.LoadBalancer, error) { + _, err := client.GetLoadBalancerExecute(ctx, projectId, region, name) + if err != nil { + var apiErr *oapierror.GenericOpenAPIError + if errors.As(err, &apiErr) { + if statusCode := apiErr.StatusCode; statusCode == http.StatusNotFound || statusCode == http.StatusGone { + return true, nil, nil + } + } + return true, nil, err + } + return false, nil, nil + }) + handler.SetTimeout(10 * time.Minute) + return handler +} diff --git a/services/alb/wait/wait_test.go b/services/alb/wait/wait_test.go new file mode 100644 index 000000000..ae918aa35 --- /dev/null +++ b/services/alb/wait/wait_test.go @@ -0,0 +1,203 @@ +package wait + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/alb" +) + +var ( + testProject = uuid.NewString() + testRegion = "eu01" + testName = "testlb" +) + +var _ APIClientLoadbalancerInterface = (*apiClientLoadbalancerMocked)(nil) + +type response struct { + loadbalancer *alb.LoadBalancer + err error +} + +type apiClientLoadbalancerMocked struct { + n int + responses []response +} + +// GetLoadBalancerExecute implements APIClientLoadbalancerInterface. +func (a *apiClientLoadbalancerMocked) GetLoadBalancerExecute(_ context.Context, _, _, _ string) (*alb.LoadBalancer, error) { + resp := a.responses[a.n] + a.n++ + a.n %= len(a.responses) + return resp.loadbalancer, resp.err +} + +func TestCreateOrUpdateLoadbalancerWaitHandler(t *testing.T) { + tests := []struct { + name string + responses []response + want *alb.LoadBalancer + wantErr bool + }{ + { + "create succeeded immediately", + []response{ + {&alb.LoadBalancer{Status: alb.PtrString(StatusReady)}, nil}, + }, + &alb.LoadBalancer{Status: utils.Ptr(StatusReady)}, + false, + }, + { + "create succeeded delayed", + []response{ + {&alb.LoadBalancer{Status: alb.PtrString(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: alb.PtrString(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: alb.PtrString(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: alb.PtrString(StatusReady)}, nil}, + }, + &alb.LoadBalancer{Status: utils.Ptr(StatusReady)}, + false, + }, + { + "create failed delayed", + []response{ + {&alb.LoadBalancer{Status: alb.PtrString(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: alb.PtrString(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: alb.PtrString(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: alb.PtrString(StatusError)}, nil}, + }, + &alb.LoadBalancer{Status: utils.Ptr(StatusError)}, + true, + }, + { + "timeout", + []response{ + {&alb.LoadBalancer{Status: alb.PtrString(StatusPending)}, nil}, + }, + nil, + true, + }, + { + "broken state", + []response{ + {&alb.LoadBalancer{Status: alb.PtrString("bogus")}, nil}, + }, + &alb.LoadBalancer{Status: alb.PtrString("bogus")}, + false, + }, + // no special update tests needed as the states are the same + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := &apiClientLoadbalancerMocked{ + responses: tt.responses, + } + handler := CreateOrUpdateLoadbalancerWaitHandler(ctx, client, testProject, testRegion, testName) + got, err := handler.SetTimeout(1 * time.Second). + SetThrottle(250 * time.Millisecond). + WaitWithContext(ctx) + + if (err != nil) != tt.wantErr { + t.Fatalf("unexpected error response. want %v but got %qe ", tt.wantErr, err) + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("differing loadbalancer %s", diff) + } + }) + } +} + +func TestDeleteLoadbalancerWaitHandler(t *testing.T) { + tests := []struct { + name string + responses []response + wantErr bool + }{ + { + "Delete with '404' succeeded immediately", + []response{ + {nil, oapierror.NewError(http.StatusNotFound, "not found")}, + }, + false, + }, + { + "Delete with '404' delayed", + []response{ + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {nil, oapierror.NewError(http.StatusNotFound, "not found")}, + }, + false, + }, + { + "Delete with 'gone' succeeded immediately", + []response{ + {nil, oapierror.NewError(http.StatusGone, "gone")}, + }, + false, + }, + { + "Delete with 'gone' delayed", + []response{ + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {nil, oapierror.NewError(http.StatusGone, "not found")}, + }, + false, + }, + { + "Delete with error delayed", + []response{ + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(string(StatusError))}, oapierror.NewError(http.StatusInternalServerError, "kapow")}, + }, + true, + }, + { + "Cannot delete", + []response{ + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(StatusPending)}, nil}, + {&alb.LoadBalancer{Status: utils.Ptr(string(StatusError))}, oapierror.NewError(http.StatusOK, "ok")}, + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := &apiClientLoadbalancerMocked{ + responses: tt.responses, + } + handler := DeleteLoadbalancerWaitHandler(ctx, client, testProject, testRegion, testName) + _, err := handler.SetTimeout(1 * time.Second). + SetThrottle(250 * time.Millisecond). + WaitWithContext(ctx) + + if tt.wantErr != (err != nil) { + t.Fatalf("wrong error result. want err: %v got %v", tt.wantErr, err) + } + if tt.wantErr { + var apiErr *oapierror.GenericOpenAPIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected openapi error, got %v", err) + } + } + }) + } +}