diff --git a/pkg/client/helper.go b/pkg/client/helper.go index 4e080ba7..7c47b01b 100644 --- a/pkg/client/helper.go +++ b/pkg/client/helper.go @@ -1,5 +1,46 @@ package client +import ( + "context" + "fmt" + "net/http" + + "github.com/stackitcloud/stackit-sdk-go/core/runtime" +) + +const ( + XTraceIDHeader = "X-Trace-Id" + XRequestIDHeader = "X-Request-Id" +) + +// WrapError wraps the error with an identifier but only if the error is not nil. +func WrapError(err error, name, id string) error { + if err == nil { + return nil + } + if id == "" { + return err + } + return fmt.Errorf("[%s:%s]: %w", name, id, err) +} + +func execute[T any](ctx context.Context, call func(context.Context) (T, error)) (T, error) { + var httpResp *http.Response + ctx = runtime.WithCaptureHTTPResponse(ctx, &httpResp) + + resp, err := call(ctx) + if err != nil { + var zero T + err = WrapError(err, XTraceIDHeader, runtime.GetTraceId(ctx)) + if httpResp != nil { + reqID := httpResp.Header.Get(XRequestIDHeader) + err = WrapError(err, XRequestIDHeader, reqID) + } + return zero, err + } + return resp, nil +} + // convertLabelsToSDK converts map[string]string to *map[string]any for SDK func convertLabelsToSDK(labels map[string]string) map[string]any { if labels == nil { diff --git a/pkg/client/helper_test.go b/pkg/client/helper_test.go new file mode 100644 index 00000000..46b38663 --- /dev/null +++ b/pkg/client/helper_test.go @@ -0,0 +1,88 @@ +package client + +import ( + "context" + "errors" + "fmt" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + sdkconfig "github.com/stackitcloud/stackit-sdk-go/core/config" +) + +var _ = Describe("WrapError", func() { + It("wraps the error with the provided identifier", func() { + err := errors.New("test error") + expected := fmt.Errorf("[X-Trace-Id:12345]: %w", err) + Expect(WrapError(err, XTraceIDHeader, "12345")).To(Equal(expected)) + }) + + It("returns the original error when the identifier is empty", func() { + err := errors.New("test error") + Expect(WrapError(err, "trace-id", "")).To(Equal(err)) + }) + + It("returns nil when the error is nil", func() { + Expect(WrapError(nil, "trace-id", "12345")).To(Succeed()) + }) +}) + +var _ = Describe("execute", func() { + It("wraps API errors with trace and request IDs", func() { + _, err := execute(context.Background(), func(ctx context.Context) (int, error) { + response, ok := ctx.Value(sdkconfig.ContextHTTPResponse).(**http.Response) + Expect(ok).To(BeTrue()) + *response = &http.Response{Header: http.Header{ + XTraceIDHeader: {"trace-123"}, + XRequestIDHeader: {"request-456"}, + }} + return 0, errors.New("api error") + }) + + Expect(err).To(MatchError("[X-Request-Id:request-456]: [X-Trace-Id:trace-123]: api error")) + }) + + It("returns the result and nil error on success", func() { + res, err := execute(context.Background(), func(_ context.Context) (string, error) { + return "success", nil + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal("success")) + }) + + It("wraps only trace ID if request ID is missing", func() { + _, err := execute(context.Background(), func(ctx context.Context) (int, error) { + response, ok := ctx.Value(sdkconfig.ContextHTTPResponse).(**http.Response) + Expect(ok).To(BeTrue()) + *response = &http.Response{Header: http.Header{ + XTraceIDHeader: {"trace-123"}, + }} + return 0, errors.New("api error") + }) + + Expect(err).To(MatchError("[X-Trace-Id:trace-123]: api error")) + }) + + It("wraps only request ID if trace ID is missing", func() { + _, err := execute(context.Background(), func(ctx context.Context) (int, error) { + response, ok := ctx.Value(sdkconfig.ContextHTTPResponse).(**http.Response) + Expect(ok).To(BeTrue()) + *response = &http.Response{Header: http.Header{ + XRequestIDHeader: {"request-456"}, + }} + return 0, errors.New("api error") + }) + + Expect(err).To(MatchError("[X-Request-Id:request-456]: api error")) + }) + + It("returns the original error when neither trace ID nor response is present", func() { + _, err := execute(context.Background(), func(_ context.Context) (int, error) { + return 0, errors.New("api error") + }) + + Expect(err).To(MatchError("api error")) + }) +}) diff --git a/pkg/client/sdk.go b/pkg/client/sdk.go index dea59f27..0aa963e7 100644 --- a/pkg/client/sdk.go +++ b/pkg/client/sdk.go @@ -201,9 +201,11 @@ func (c *SdkStackitClient) CreateServer(ctx context.Context, projectID, region s } // Call SDK using the stored client - sdkServer, err := c.iaasClient.DefaultAPI.CreateServer(ctx, projectID, region). - CreateServerPayload(*payload). - Execute() + sdkServer, err := execute(ctx, func(ctx context.Context) (*iaas.Server, error) { + return c.iaasClient.DefaultAPI.CreateServer(ctx, projectID, region). + CreateServerPayload(*payload). + Execute() + }) if err != nil { return nil, fmt.Errorf("SDK CreateServer failed: %w", err) } @@ -216,7 +218,9 @@ func (c *SdkStackitClient) CreateServer(ctx context.Context, projectID, region s // GetServer retrieves a server by ID via STACKIT SDK func (c *SdkStackitClient) GetServer(ctx context.Context, projectID, region, serverID string) (*Server, error) { - sdkServer, err := c.iaasClient.DefaultAPI.GetServer(ctx, projectID, region, serverID).Execute() + sdkServer, err := execute(ctx, func(ctx context.Context) (*iaas.Server, error) { + return c.iaasClient.DefaultAPI.GetServer(ctx, projectID, region, serverID).Execute() + }) if err != nil { // Check if error is 404 Not Found if isNotFoundError(err) { @@ -233,7 +237,9 @@ func (c *SdkStackitClient) GetServer(ctx context.Context, projectID, region, ser // DeleteServer deletes a server by ID via STACKIT SDK func (c *SdkStackitClient) DeleteServer(ctx context.Context, projectID, region, serverID string) error { - err := c.iaasClient.DefaultAPI.DeleteServer(ctx, projectID, region, serverID).Execute() + _, err := execute(ctx, func(ctx context.Context) (any, error) { + return nil, c.iaasClient.DefaultAPI.DeleteServer(ctx, projectID, region, serverID).Execute() + }) if err != nil { // Check if error is 404 Not Found - this is OK (idempotent) if isNotFoundError(err) { @@ -247,8 +253,7 @@ func (c *SdkStackitClient) DeleteServer(ctx context.Context, projectID, region, // ListServers lists all servers in a project via STACKIT SDK func (c *SdkStackitClient) ListServers(ctx context.Context, projectID, region string, labelSelector map[string]string) ([]*Server, error) { - serverRequest := c.iaasClient.DefaultAPI.ListServers(ctx, projectID, region) - + var selector string if labelSelector != nil { sb := strings.Builder{} for k, v := range labelSelector { @@ -262,10 +267,16 @@ func (c *SdkStackitClient) ListServers(ctx context.Context, projectID, region st } } - serverRequest = serverRequest.LabelSelector(sb.String()) + selector = sb.String() } - sdkResponse, err := serverRequest.Execute() + sdkResponse, err := execute(ctx, func(ctx context.Context) (*iaas.ServerListResponse, error) { + serverRequest := c.iaasClient.DefaultAPI.ListServers(ctx, projectID, region) + if selector != "" { + serverRequest = serverRequest.LabelSelector(selector) + } + return serverRequest.Execute() + }) if err != nil { return nil, fmt.Errorf("SDK ListServers failed: %w", err) } @@ -283,7 +294,9 @@ func (c *SdkStackitClient) ListServers(ctx context.Context, projectID, region st } func (c *SdkStackitClient) GetNICsForServer(ctx context.Context, projectID, region, serverID string) ([]*NIC, error) { - res, err := c.iaasClient.DefaultAPI.ListServerNICs(ctx, projectID, region, serverID).Execute() + res, err := execute(ctx, func(ctx context.Context) (*iaas.NICListResponse, error) { + return c.iaasClient.DefaultAPI.ListServerNICs(ctx, projectID, region, serverID).Execute() + }) if err != nil { return nil, fmt.Errorf("SDK ListServerNICs failed: %w", err) } @@ -309,7 +322,9 @@ func (c *SdkStackitClient) UpdateNIC(ctx context.Context, projectID, region, net AllowedAddresses: addresses, } - sdkNic, err := c.iaasClient.DefaultAPI.UpdateNic(ctx, projectID, region, networkID, nicID).UpdateNicPayload(payload).Execute() + sdkNic, err := execute(ctx, func(ctx context.Context) (*iaas.NIC, error) { + return c.iaasClient.DefaultAPI.UpdateNic(ctx, projectID, region, networkID, nicID).UpdateNicPayload(payload).Execute() + }) if err != nil { return nil, fmt.Errorf("SDK UpdateNic failed: %w", err) } diff --git a/pkg/client/sdk_test.go b/pkg/client/sdk_test.go index cb74ff30..1ff9c0c4 100644 --- a/pkg/client/sdk_test.go +++ b/pkg/client/sdk_test.go @@ -1,8 +1,11 @@ package client import ( + "context" "errors" "fmt" + "net/http" + "net/http/httptest" "os" . "github.com/onsi/ginkgo/v2" @@ -432,4 +435,88 @@ var _ = Describe("SDK Type Conversion Helpers", func() { }) }) + Describe("SdkStackitClient error wrapping with trace and request IDs", func() { + var ( + server *httptest.Server + client *SdkStackitClient + projectID = "00000000-0000-0000-0000-000000000000" + serverID = "11111111-1111-1111-1111-111111111111" + networkID = "22222222-2222-2222-2222-222222222222" + nicID = "33333333-3333-3333-3333-333333333333" + ) + + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Trace-Id", "trace-test-123") + w.Header().Set("X-Request-Id", "req-test-456") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "internal server error"}`)) + })) + + origEndpoint := os.Getenv("STACKIT_IAAS_ENDPOINT") + origNoAuth := os.Getenv("STACKIT_NO_AUTH") + os.Setenv("STACKIT_IAAS_ENDPOINT", server.URL) + os.Setenv("STACKIT_NO_AUTH", "true") + DeferCleanup(func() { + server.Close() + if origEndpoint == "" { + os.Unsetenv("STACKIT_IAAS_ENDPOINT") + } else { + os.Setenv("STACKIT_IAAS_ENDPOINT", origEndpoint) + } + if origNoAuth == "" { + os.Unsetenv("STACKIT_NO_AUTH") + } else { + os.Setenv("STACKIT_NO_AUTH", origNoAuth) + } + }) + + var err error + client, err = NewStackitClient("") + Expect(err).NotTo(HaveOccurred()) + }) + + It("GetServer wraps error with trace ID and request ID", func() { + _, err := client.GetServer(context.Background(), projectID, "eu01-1", serverID) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("[X-Request-Id:req-test-456]: [X-Trace-Id:trace-test-123]")) + }) + + It("DeleteServer wraps error with trace ID and request ID", func() { + err := client.DeleteServer(context.Background(), projectID, "eu01-1", serverID) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("[X-Request-Id:req-test-456]: [X-Trace-Id:trace-test-123]")) + }) + + It("CreateServer wraps error with trace ID and request ID", func() { + _, err := client.CreateServer(context.Background(), projectID, "eu01-1", &CreateServerRequest{ + Name: "test-srv", + MachineType: "c1.2", + Networking: &ServerNetworkingRequest{ + NetworkID: networkID, + }, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("[X-Request-Id:req-test-456]: [X-Trace-Id:trace-test-123]")) + }) + + It("ListServers wraps error with trace ID and request ID", func() { + _, err := client.ListServers(context.Background(), projectID, "eu01-1", nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("[X-Request-Id:req-test-456]: [X-Trace-Id:trace-test-123]")) + }) + + It("GetNICsForServer wraps error with trace ID and request ID", func() { + _, err := client.GetNICsForServer(context.Background(), projectID, "eu01-1", serverID) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("[X-Request-Id:req-test-456]: [X-Trace-Id:trace-test-123]")) + }) + + It("UpdateNIC wraps error with trace ID and request ID", func() { + _, err := client.UpdateNIC(context.Background(), projectID, "eu01-1", networkID, nicID, []string{"10.0.0.1"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("[X-Request-Id:req-test-456]: [X-Trace-Id:trace-test-123]")) + }) + }) + })