internal/clients/resource_client.go (630 lines of code) (raw):

package clients import ( "context" "errors" "fmt" "net/http" "regexp" "slices" "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" armruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/terraform-provider-azapi/internal/retry" "github.com/cenkalti/backoff/v4" "github.com/hashicorp/terraform-plugin-log/tflog" ) const ( moduleName = "resource" moduleVersion = "v0.1.0" ) type ResourceClient struct { host string pl runtime.Pipeline } // ResourceClientRetryableErrors is a wrapper around ResourceClient that allows for retrying on specific errors. type ResourceClientRetryableErrors struct { client Requester // client is a Requester interface to allow mocking backoff *backoff.ExponentialBackOff // backoff is the backoff configuration for retrying errors []regexp.Regexp // errors is the list of errors regexp to retry on statusCodes []int // statusCodes is the list of status codes to retry on dataCallbackFuncs []func(interface{}) bool // dataCallbackFuncs is the list of functions to call to determine if the data is retryable } // NewResourceClientRetryableErrors creates a new ResourceClientRetryableErrors. func NewResourceClientRetryableErrors(client Requester, bkof *backoff.ExponentialBackOff, errRegExps []regexp.Regexp, statusCodes []int, dataCallbackFuncs []func(any) bool) *ResourceClientRetryableErrors { rcre := &ResourceClientRetryableErrors{ client: client, backoff: bkof, errors: errRegExps, statusCodes: statusCodes, dataCallbackFuncs: dataCallbackFuncs, } rcre.backoff.Reset() return rcre } // Requester is the interface for HTTP operations, meaning we can supply a ResourceClient or a ResourceClientRetryableErrors. type Requester interface { Get(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (interface{}, error) CreateOrUpdate(ctx context.Context, resourceID string, apiVersion string, body interface{}, options RequestOptions) (interface{}, error) Delete(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (interface{}, error) Action(ctx context.Context, resourceID string, action string, apiVersion string, method string, body interface{}, options RequestOptions) (interface{}, error) List(ctx context.Context, url string, apiVersion string, options RequestOptions) (interface{}, error) } var ( _ Requester = &ResourceClient{} _ Requester = &ResourceClientRetryableErrors{} ) func NewResourceClient(credential azcore.TokenCredential, opt *arm.ClientOptions) (*ResourceClient, error) { if opt == nil { opt = &arm.ClientOptions{} } ep := cloud.AzurePublic.Services[cloud.ResourceManager].Endpoint if c, ok := opt.Cloud.Services[cloud.ResourceManager]; ok { ep = c.Endpoint } pl, err := armruntime.NewPipeline(moduleName, moduleVersion, credential, runtime.PipelineOptions{}, opt) if err != nil { return nil, err } return &ResourceClient{ host: ep, pl: pl, }, nil } // StringSliceToRegexpSliceMust converts a slice of strings to a slice of regexps. // It panics if any of the strings are invalid regexps. func StringSliceToRegexpSliceMust(ss []string) []regexp.Regexp { res := make([]regexp.Regexp, len(ss)) for i, e := range ss { res[i] = *regexp.MustCompile(e) } return res } // WithRetry configures the retryable errors for the client. func (client *ResourceClient) WithRetry(bkof *backoff.ExponentialBackOff, errRegExps []regexp.Regexp, statusCodes []int, dataCallbackFuncs []func(interface{}) bool) *ResourceClientRetryableErrors { rcre := &ResourceClientRetryableErrors{ client: client, backoff: bkof, errors: errRegExps, statusCodes: statusCodes, dataCallbackFuncs: dataCallbackFuncs, } rcre.backoff.Reset() return rcre } func (retryclient *ResourceClientRetryableErrors) updateContext(ctx context.Context) context.Context { ctx = tflog.SetField(ctx, "backoff_max_elapsed_time", retryclient.backoff.MaxElapsedTime.String()) ctx = tflog.SetField(ctx, "backoff_initial_interval", retryclient.backoff.InitialInterval.String()) ctx = tflog.SetField(ctx, "backoff_max_interval", retryclient.backoff.MaxInterval.String()) ctx = tflog.SetField(ctx, "backoff_multiplier", retryclient.backoff.Multiplier) ctx = tflog.SetField(ctx, "backoff_randomization_factor", retryclient.backoff.RandomizationFactor) ctx = tflog.SetField(ctx, "retryable_http_status_codes", retryclient.statusCodes) ctx = tflog.SetField(ctx, "retryable_data_callback_funcs_length", len(retryclient.dataCallbackFuncs)) re := make([]string, len(retryclient.errors)) for i, r := range retryclient.errors { re[i] = r.String() } ctx = tflog.SetField(ctx, "retryable_errors", re) return ctx } // CreateOrUpdate configures the retryable errors for the client. // It calls CreateOrUpdate, then checks if the error is contained in the retryable errors list. // If it is, it will retry the operation with the configured backoff. // If it is not, it will return the error as a backoff.PermanentError{}. func (retryclient *ResourceClientRetryableErrors) CreateOrUpdate(ctx context.Context, resourceID string, apiVersion string, body interface{}, options RequestOptions) (interface{}, error) { if retryclient.backoff == nil { return nil, errors.New("retry is not configured, please call WithRetry() first") } ctx = tflog.SetField(ctx, "request", "CreateOrUpdate") ctx = retryclient.updateContext(ctx) tflog.Debug(ctx, "retryclient: Begin") i := 0 op := backoff.OperationWithData[interface{}]( func() (interface{}, error) { data, err := retryclient.client.CreateOrUpdate(ctx, resourceID, apiVersion, body, options) if err != nil { if isRetryable(ctx, *retryclient, data, err) { tflog.Debug(ctx, "retryclient: Retry attempt", map[string]interface{}{ "err": err, "attempt": i, }) i++ return data, err } tflog.Debug(ctx, "retryclient: PermanentError", map[string]interface{}{ "err": err, "attempt": i, }) return nil, &backoff.PermanentError{Err: err} } tflog.Debug(ctx, "retryclient: Success", map[string]interface{}{ "attempt": i, }) return data, err }) exbo := backoff.WithContext(retryclient.backoff, ctx) return backoff.RetryWithData(op, exbo) } func (client *ResourceClient) CreateOrUpdate(ctx context.Context, resourceID string, apiVersion string, body interface{}, options RequestOptions) (interface{}, error) { resp, err := client.createOrUpdate(ctx, resourceID, apiVersion, body, options) if err != nil { return nil, err } var responseBody interface{} pt, err := runtime.NewPoller[interface{}](resp, client.pl, nil) if err == nil { resp, err := pt.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ Frequency: 10 * time.Second, }) if err == nil { return resp, nil } if !client.shouldIgnorePollingError(err) { return nil, err } } if err := runtime.UnmarshalAsJSON(resp, &responseBody); err != nil { return nil, err } return responseBody, nil } func (client *ResourceClient) createOrUpdate(ctx context.Context, resourceID string, apiVersion string, body interface{}, options RequestOptions) (*http.Response, error) { req, err := client.createOrUpdateCreateRequest(ctx, resourceID, apiVersion, body, options) if err != nil { return nil, err } resp, err := client.pl.Do(req) if err != nil { return nil, err } if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusAccepted) { return nil, runtime.NewResponseError(resp) } return resp, nil } func (client *ResourceClient) createOrUpdateCreateRequest(ctx context.Context, resourceID string, apiVersion string, body interface{}, options RequestOptions) (*policy.Request, error) { urlPath := resourceID req, err := runtime.NewRequest(ctx, http.MethodPut, runtime.JoinPaths(client.host, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) for key, value := range options.QueryParameters { reqQP.Set(key, value) } req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") for key, value := range options.Headers { req.Raw().Header.Set(key, value) } return req, runtime.MarshalAsJSON(req, body) } // Get configures the retryable errors for the client. // It calls Get, then checks if the error is contained in the retryable errors list. // If it is, it will retry the operation with the configured backoff. // If it is not, it will return the error as a backoff.PermanentError{}. func (retryclient *ResourceClientRetryableErrors) Get(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (interface{}, error) { if retryclient.backoff == nil { return nil, errors.New("retry is not configured, please call WithRetry() first") } ctx = tflog.SetField(ctx, "request", "Get") ctx = retryclient.updateContext(ctx) tflog.Debug(ctx, "retryclient: Begin") i := 0 op := backoff.OperationWithData[interface{}]( func() (interface{}, error) { data, err := retryclient.client.Get(ctx, resourceID, apiVersion, options) if err != nil { if isRetryable(ctx, *retryclient, data, err) { tflog.Debug(ctx, "retryclient: Retry attempt", map[string]interface{}{ "err": err, "attempt": i, }) i++ return data, err } tflog.Debug(ctx, "retryclient: PermanentError", map[string]interface{}{ "err": err, "attempt": i, }) return nil, &backoff.PermanentError{Err: err} } tflog.Debug(ctx, "retryclient: Success", map[string]interface{}{ "attempt": i, }) return data, err }) exbo := backoff.WithContext(retryclient.backoff, ctx) return backoff.RetryWithData(op, exbo) } func (client *ResourceClient) Get(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (interface{}, error) { req, err := client.getCreateRequest(ctx, resourceID, apiVersion, options) if err != nil { return nil, err } resp, err := client.pl.Do(req) if err != nil { return nil, err } if !runtime.HasStatusCode(resp, http.StatusOK) { return nil, runtime.NewResponseError(resp) } var responseBody interface{} if err := runtime.UnmarshalAsJSON(resp, &responseBody); err != nil { return nil, err } return responseBody, nil } func (client *ResourceClient) getCreateRequest(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (*policy.Request, error) { urlPath := resourceID req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.host, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) for key, value := range options.QueryParameters { reqQP.Set(key, value) } req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") for key, value := range options.Headers { req.Raw().Header.Set(key, value) } return req, nil } // Delete configures the retryable errors for the client. // It calls Delete, then checks if the error is contained in the retryable errors list. // If it is, it will retry the operation with the configured backoff. // If it is not, it will return the error as a backoff.PermanentError{}. func (retryclient *ResourceClientRetryableErrors) Delete(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (interface{}, error) { if retryclient.backoff == nil { return nil, errors.New("retry is not configured, please call WithRetry() first") } ctx = tflog.SetField(ctx, "request", "Delete") ctx = retryclient.updateContext(ctx) tflog.Debug(ctx, "retryclient: Begin") i := 0 op := backoff.OperationWithData[interface{}]( func() (interface{}, error) { data, err := retryclient.client.Delete(ctx, resourceID, apiVersion, options) if err != nil { if isRetryable(ctx, *retryclient, data, err) { tflog.Debug(ctx, "retryclient: Retry attempt", map[string]interface{}{ "err": err, "attempt": i, }) i++ return data, err } tflog.Debug(ctx, "retryclient: PermanentError", map[string]interface{}{ "err": err, "attempt": i, }) return nil, &backoff.PermanentError{Err: err} } tflog.Debug(ctx, "retryclient: Success", map[string]interface{}{ "attempt": i, }) return data, err }) exbo := backoff.WithContext(retryclient.backoff, ctx) return backoff.RetryWithData(op, exbo) } func (client *ResourceClient) Delete(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (interface{}, error) { resp, err := client.delete(ctx, resourceID, apiVersion, options) if err != nil { return nil, err } var responseBody interface{} pt, err := runtime.NewPoller[interface{}](resp, client.pl, nil) if err == nil { resp, err := pt.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ Frequency: 10 * time.Second, }) if err == nil { return resp, nil } if !client.shouldIgnorePollingError(err) { return nil, err } } if err := runtime.UnmarshalAsJSON(resp, &responseBody); err != nil { return nil, err } return responseBody, nil } func (client *ResourceClient) delete(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (*http.Response, error) { req, err := client.deleteCreateRequest(ctx, resourceID, apiVersion, options) if err != nil { return nil, err } resp, err := client.pl.Do(req) if err != nil { return nil, err } if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusAccepted, http.StatusNoContent) { return nil, runtime.NewResponseError(resp) } return resp, nil } func (client *ResourceClient) deleteCreateRequest(ctx context.Context, resourceID string, apiVersion string, options RequestOptions) (*policy.Request, error) { urlPath := resourceID req, err := runtime.NewRequest(ctx, http.MethodDelete, runtime.JoinPaths(client.host, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) for key, value := range options.QueryParameters { reqQP.Set(key, value) } req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") for key, value := range options.Headers { req.Raw().Header.Set(key, value) } return req, nil } // Action configures the retryable errors for the client. // It calls Action, then checks if the error is contained in the retryable errors list. // If it is, it will retry the operation with the configured backoff. // If it is not, it will return the error as a backoff.PermanentError{}. func (retryclient *ResourceClientRetryableErrors) Action(ctx context.Context, resourceID string, action string, apiVersion string, method string, body interface{}, options RequestOptions) (interface{}, error) { if retryclient.backoff == nil { return nil, errors.New("retry is not configured, please call WithRetry() first") } ctx = tflog.SetField(ctx, "request", "Action") ctx = retryclient.updateContext(ctx) tflog.Debug(ctx, "retryclient: Begin") i := 0 op := backoff.OperationWithData[interface{}]( func() (interface{}, error) { data, err := retryclient.client.Action(ctx, resourceID, action, apiVersion, method, body, options) if err != nil { if isRetryable(ctx, *retryclient, data, err) { tflog.Debug(ctx, "retryclient: Retry attempt", map[string]interface{}{ "err": err, "attempt": i, }) i++ return data, err } tflog.Debug(ctx, "retryclient: PermanentError", map[string]interface{}{ "err": err, "attempt": i, }) return nil, &backoff.PermanentError{Err: err} } tflog.Debug(ctx, "retryclient: Success", map[string]interface{}{ "attempt": i, }) return data, err }) exbo := backoff.WithContext(retryclient.backoff, ctx) return backoff.RetryWithData(op, exbo) } func (client *ResourceClient) Action(ctx context.Context, resourceID string, action string, apiVersion string, method string, body interface{}, options RequestOptions) (interface{}, error) { resp, err := client.action(ctx, resourceID, action, apiVersion, method, body, options) if err != nil { return nil, err } var responseBody interface{} pt, err := runtime.NewPoller[interface{}](resp, client.pl, nil) if err == nil { resp, err := pt.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{ Frequency: 10 * time.Second, }) if err == nil { return resp, nil } if !client.shouldIgnorePollingError(err) { return nil, err } } contentType := resp.Header.Get("Content-Type") switch { case strings.Contains(contentType, "text/plain"): payload, err := runtime.Payload(resp) if err != nil { return nil, err } responseBody = string(payload) case strings.Contains(contentType, "application/json"): if err := runtime.UnmarshalAsJSON(resp, &responseBody); err != nil { return nil, err } default: } return responseBody, nil } func (client *ResourceClient) action(ctx context.Context, resourceID string, action string, apiVersion string, method string, body interface{}, options RequestOptions) (*http.Response, error) { req, err := client.actionCreateRequest(ctx, resourceID, action, apiVersion, method, body, options) if err != nil { return nil, err } resp, err := client.pl.Do(req) if err != nil { return nil, err } if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent) { return nil, runtime.NewResponseError(resp) } return resp, nil } func (client *ResourceClient) actionCreateRequest(ctx context.Context, resourceID string, action string, apiVersion string, method string, body interface{}, options RequestOptions) (*policy.Request, error) { urlPath := resourceID if action != "" { urlPath = fmt.Sprintf("%s/%s", resourceID, action) } req, err := runtime.NewRequest(ctx, method, runtime.JoinPaths(client.host, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) for key, value := range options.QueryParameters { reqQP.Set(key, value) } req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") for key, value := range options.Headers { req.Raw().Header.Set(key, value) } if method != "GET" && body != nil { err = runtime.MarshalAsJSON(req, body) } return req, err } // List configures the retryable errors for the client. // It calls Get, then checks if the error is contained in the retryable errors list. // If it is, it will retry the operation with the configured backoff. // If it is not, it will return the error as a backoff.PermanentError{}. func (retryclient *ResourceClientRetryableErrors) List(ctx context.Context, url string, apiVersion string, options RequestOptions) (interface{}, error) { if retryclient.backoff == nil { return nil, errors.New("retry is not configured, please call WithRetry() first") } ctx = tflog.SetField(ctx, "request", "List") ctx = retryclient.updateContext(ctx) tflog.Debug(ctx, "retryclient: Begin") i := 0 op := backoff.OperationWithData[interface{}]( func() (interface{}, error) { data, err := retryclient.client.List(ctx, url, apiVersion, options) if err != nil { if isRetryable(ctx, *retryclient, data, err) { tflog.Debug(ctx, "retryclient: Retry attempt", map[string]interface{}{ "err": err, "attempt": i, }) i++ return data, err } tflog.Debug(ctx, "retryclient: PermanentError", map[string]interface{}{ "err": err, "attempt": i, }) return nil, &backoff.PermanentError{Err: err} } tflog.Debug(ctx, "retryclient: Success", map[string]interface{}{ "attempt": i, }) return data, err }) exbo := backoff.WithContext(retryclient.backoff, ctx) return backoff.RetryWithData(op, exbo) } func (client *ResourceClient) List(ctx context.Context, url string, apiVersion string, options RequestOptions) (interface{}, error) { pager := runtime.NewPager(runtime.PagingHandler[interface{}]{ More: func(current interface{}) bool { if current == nil { return false } currentMap, ok := current.(map[string]interface{}) if !ok { return false } if currentMap["nextLink"] == nil { return false } if nextLink := currentMap["nextLink"].(string); nextLink == "" { return false } return true }, Fetcher: func(ctx context.Context, current *interface{}) (interface{}, error) { var request *policy.Request if current == nil { req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.host, url)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) for key, value := range options.QueryParameters { reqQP.Set(key, value) } req.Raw().URL.RawQuery = reqQP.Encode() for key, value := range options.Headers { req.Raw().Header.Set(key, value) } request = req } else { nextLink := "" if currentMap, ok := (*current).(map[string]interface{}); ok && currentMap["nextLink"] != nil { nextLink = currentMap["nextLink"].(string) } req, err := runtime.NewRequest(ctx, http.MethodGet, nextLink) if err != nil { return nil, err } request = req } request.Raw().Header.Set("Accept", "application/json") resp, err := client.pl.Do(request) if err != nil { return nil, err } if !runtime.HasStatusCode(resp, http.StatusOK) { return nil, runtime.NewResponseError(resp) } var responseBody interface{} if err := runtime.UnmarshalAsJSON(resp, &responseBody); err != nil { return nil, err } return responseBody, nil }, }) value := make([]interface{}, 0) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, err } if pageMap, ok := page.(map[string]interface{}); ok { if pageMap["value"] != nil { if pageValue, ok := pageMap["value"].([]interface{}); ok { value = append(value, pageValue...) continue } } } // if response doesn't follow the ARM paging guideline, return the response as is return page, nil } return map[string]interface{}{ "value": value, }, nil } func (client *ResourceClient) shouldIgnorePollingError(err error) bool { if err == nil { return true } // there are some APIs that don't follow the ARM LRO guideline, return the response as is var responseErr *azcore.ResponseError if errors.As(err, &responseErr) { if responseErr.RawResponse != nil && responseErr.RawResponse.Request != nil { // all control plane APIs must flow through ARM, ignore the polling error if it's not ARM // issue: https://github.com/Azure/azure-rest-api-specs/issues/25356, in this case, the polling url is not exposed by ARM pollRequest := responseErr.RawResponse.Request if pollRequest.Host != strings.TrimPrefix(client.host, "https://") { return true } // ignore the polling error if the polling url doesn't support GET method // issue:https://github.com/Azure/azure-rest-api-specs/issues/25362, in this case, the polling url doesn't support GET method if responseErr.StatusCode == http.StatusMethodNotAllowed { return true } } } return false } func isRetryable(ctx context.Context, retryclient ResourceClientRetryableErrors, data interface{}, err error) bool { for _, e := range retryclient.errors { if e.MatchString(err.Error()) { tflog.Debug(ctx, "isRetryable: Error is retryable by regex", map[string]interface{}{ "err": err, "regexp": e.String(), }) return true } } var respErr *azcore.ResponseError if errors.As(err, &respErr) { if slices.Contains(retryclient.statusCodes, respErr.StatusCode) { tflog.Debug(ctx, "isRetryable: Error is retryable by status code", map[string]interface{}{ "err": err, "statusCode": respErr.StatusCode, }) return true } } for i, f := range retryclient.dataCallbackFuncs { if f(data) { tflog.Debug(ctx, "isRetryable: Error is retryable by function callback", map[string]interface{}{ "err": err, "callback_func_idx": i, }) return true } } tflog.Debug(ctx, "isRetryable: Error is not retryable") return false } // ConfigureClientWithCustomRetry configures the client with a custom retry configuration if supplied. // If the retry configuration is null or unknown, it will use the default retry configuration. // If the supplied context has a deadline, it will use the deadline as the max elapsed time when a custom retry is provided. func (client *ResourceClient) ConfigureClientWithCustomRetry(ctx context.Context, rtry retry.RetryValue, useReadAfterCreateValues bool) Requester { backOff, errRegExps, statusCodes := configureCustomRetry(ctx, rtry, useReadAfterCreateValues) return client.WithRetry(backOff, errRegExps, statusCodes, nil) }