v2/internal/genericarmclient/generic_client.go (419 lines of code) (raw):

/* Copyright (c) Microsoft Corporation. Licensed under the MIT license. */ package genericarmclient import ( "context" "net/http" "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/rotisserie/eris" "github.com/Azure/azure-service-operator/v2/internal/metrics" "github.com/Azure/azure-service-operator/v2/internal/version" ) const ( CreatePollerID = "GenericClient.CreateOrUpdateByID" DeletePollerID = "GenericClient.DeleteByID" ) // NOTE: All of these methods (and types) were adapted from // https://github.com/Azure/azure-sdk-for-go/blob/sdk/resources/armresources/v0.3.0/sdk/resources/armresources/zz_generated_resources_client.go // which was then moved to here: https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/resourcemanager/resources/armresources/client.go type GenericClient struct { endpoint string pl runtime.Pipeline creds azcore.TokenCredential opts *arm.ClientOptions } // TODO: Need to do retryAfter detection in each call? type GenericClientOptions struct { HTTPClient *http.Client Metrics *metrics.ARMClientMetrics UserAgent string AdditionalTenants []string } // NewGenericClient creates a new instance of GenericClient func NewGenericClient( cloudCfg cloud.Configuration, creds azcore.TokenCredential, options *GenericClientOptions, ) (*GenericClient, error) { rmConfig, ok := cloudCfg.Services[cloud.ResourceManager] if !ok { return nil, eris.Errorf("provided cloud missing %q entry", cloud.ResourceManager) } if rmConfig.Endpoint == "" { return nil, eris.New("provided cloud missing resourceManager.Endpoint entry") } if options == nil { options = &GenericClientOptions{} } ua := options.UserAgent if ua == "" { ua = userAgent } opts := &arm.ClientOptions{ ClientOptions: policy.ClientOptions{ Cloud: cloudCfg, Retry: policy.RetryOptions{ MaxRetries: -1, // Have to use a value less than 0 means no retries (0 does NOT, 0 gets you 3...) }, PerCallPolicies: []policy.Policy{ NewUserAgentPolicy(ua), }, }, AuxiliaryTenants: options.AdditionalTenants, // Disabled here because we don't want the default configuration, it polls for 5+ minutes which is // far too long to block an operator. DisableRPRegistration: true, } // We assign this HTTPClient like this because if we actually set it to nil, due to the way // go interfaces wrap values, the subsequent if httpClient == nil check returns false (even though // the value IN the interface IS nil). if options.HTTPClient != nil { opts.Transport = options.HTTPClient } else { opts.Transport = defaultHTTPClient } rpRegistrationPolicy, err := NewRPRegistrationPolicy( creds, &opts.ClientOptions) if err != nil { return nil, eris.Wrapf(err, "failed to create rp registration policy") } opts.PerCallPolicies = append([]policy.Policy{rpRegistrationPolicy}, opts.PerCallPolicies...) if options.Metrics != nil { opts.PerCallPolicies = append(opts.PerCallPolicies, metrics.NewMetricsPolicy(options.Metrics)) } pipeline, err := armruntime.NewPipeline("generic", version.BuildVersion, creds, runtime.PipelineOptions{}, opts) if err != nil { return nil, err } return &GenericClient{ endpoint: rmConfig.Endpoint, pl: pipeline, creds: creds, opts: opts, }, nil } // Creds returns the credentials used by this client func (client *GenericClient) Creds() azcore.TokenCredential { return client.creds } // ClientOptions returns the arm.ClientOptions used by this client. These options include // the HTTP pipeline. If these options are used to create a new client, it will share the configured // HTTP pipeline. func (client *GenericClient) ClientOptions() *arm.ClientOptions { return client.opts } func (client *GenericClient) BeginCreateOrUpdateByID( ctx context.Context, resourceID string, apiVersion string, resource interface{}, ) (*PollerResponse[GenericResource], error) { // The linter doesn't realize that the response is closed in the course of // the autorest.NewPoller call below. Suppressing it as it is a false positive. //nolint:bodyclose resp, err := client.createOrUpdateByID(ctx, resourceID, apiVersion, resource) if err != nil { return nil, err } result := PollerResponse[GenericResource]{ RawResponse: resp, ID: CreatePollerID, ErrorHandler: client.handleError, } pt, err := runtime.NewPoller[GenericResource](resp, client.pl, nil) if err != nil { return nil, err } result.Poller = pt return &result, nil } func (client *GenericClient) createOrUpdateByID( ctx context.Context, resourceID string, apiVersion string, resource interface{}, ) (*http.Response, error) { req, err := client.createOrUpdateByIDCreateRequest(ctx, resourceID, apiVersion, resource) if err != nil { return nil, err } resp, err := client.pl.Do(req) if err != nil { return resp, err } if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated, http.StatusAccepted) { return nil, client.handleError(resp) } return resp, nil } // createOrUpdateByIDCreateRequest creates the CreateOrUpdateByID request. func (client *GenericClient) createOrUpdateByIDCreateRequest( ctx context.Context, resourceID string, apiVersion string, resource interface{}, ) (*policy.Request, error) { if resourceID == "" { return nil, eris.New("parameter resourceID cannot be empty") } urlPath := resourceID req, err := runtime.NewRequest(ctx, http.MethodPut, runtime.JoinPaths(client.endpoint, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") return req, runtime.MarshalAsJSON(req, resource) } // handleError handles the CreateOrUpdateByID error response. func (client *GenericClient) handleError(resp *http.Response) error { errType := NewCloudError(runtime.NewResponseError(resp)) if err := runtime.UnmarshalAsJSON(resp, errType); err != nil { return runtime.NewResponseError(resp) } return errType } // GetByID - Gets a resource by ID. // If the operation fails it returns the *CloudError error type. func (client *GenericClient) GetByID( ctx context.Context, resourceID string, apiVersion string, resource interface{}, ) (time.Duration, error) { req, err := client.getByIDCreateRequest(ctx, resourceID, apiVersion) if err != nil { return zeroDuration, err } // The linter doesn't realize that the response is closed in the course of // the getByIDHandleResponse call below. Suppressing it as it is a false positive. //nolint:bodyclose resp, err := client.pl.Do(req) retryAfter := GetRetryAfter(resp) if err != nil { return retryAfter, err } if !runtime.HasStatusCode(resp, http.StatusOK) { return retryAfter, runtime.NewResponseError(resp) } return zeroDuration, client.getByIDHandleResponse(resp, resource) } // getByIDCreateRequest creates the GetByID request. func (client *GenericClient) getByIDCreateRequest(ctx context.Context, resourceID string, apiVersion string) (*policy.Request, error) { urlPath := "/{resourceId}" if resourceID == "" { return nil, eris.New("parameter resourceID cannot be empty") } urlPath = strings.ReplaceAll(urlPath, "{resourceId}", resourceID) req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.endpoint, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") return req, nil } // getByIDHandleResponse handles the GetByID response. func (client *GenericClient) getByIDHandleResponse(resp *http.Response, resource interface{}) error { if err := runtime.UnmarshalAsJSON(resp, resource); err != nil { return err } return nil } // CheckExistenceByID - Heads a resource by ID. // If the operation fails it returns the *CloudError error type. func (client *GenericClient) CheckExistenceByID( ctx context.Context, resourceID string, apiVersion string, ) (bool, time.Duration, error) { retryAfter, err := client.checkExistenceByIDImpl(ctx, resourceID, apiVersion) switch { case IsNotFoundError(err): return false, retryAfter, nil case err != nil: return false, retryAfter, err default: return true, retryAfter, nil } } func (client *GenericClient) checkExistenceByIDImpl( ctx context.Context, resourceID string, apiVersion string, ) (time.Duration, error) { req, err := client.checkExistenceByIDCreateRequest(ctx, resourceID, apiVersion) if err != nil { return zeroDuration, err } // The linter doesn't realize that the response is closed as part of the pipeline //nolint:bodyclose resp, err := client.pl.Do(req) retryAfter := GetRetryAfter(resp) if err != nil { return retryAfter, err } if !runtime.HasStatusCode(resp, http.StatusNoContent, http.StatusNotFound) { return retryAfter, runtime.NewResponseError(resp) } return zeroDuration, nil } func (client *GenericClient) checkExistenceByIDCreateRequest(ctx context.Context, resourceID string, apiVersion string) (*policy.Request, error) { urlPath := "/{resourceId}" if resourceID == "" { return nil, eris.New("parameter resourceID cannot be empty") } urlPath = strings.ReplaceAll(urlPath, "{resourceId}", resourceID) req, err := runtime.NewRequest(ctx, http.MethodHead, runtime.JoinPaths(client.endpoint, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") return req, nil } type listPageResponse[T any] struct { // Value - The list of resources. Value []T `json:"value,omitempty"` // NextLink - The URI to fetch the next page of resources. NextLink *string `json:"nextLink,omitempty"` } func (p *listPageResponse[T]) More() bool { return p.NextLink != nil && len(*p.NextLink) > 0 } func (p *listPageResponse[T]) NextPage( ctx context.Context, client *GenericClient, containerID string, apiVersion string, ) (*listPageResponse[T], error) { var req *policy.Request var err error if p == nil { req, err = client.listByContainerIDCreateRequest(ctx, containerID, apiVersion) } else { req, err = runtime.NewRequest(ctx, http.MethodGet, *p.NextLink) } if err != nil { return nil, err } // The linter doesn't realize that the response is closed in the course of // the runtime.UnmarshalAsJSON() call below. Suppressing it as it is a false positive. //nolint:bodyclose resp, err := client.pl.Do(req) if err != nil { return nil, err } if !runtime.HasStatusCode(resp, http.StatusOK) { return nil, runtime.NewResponseError(resp) } newPage := listPageResponse[T]{} err = runtime.UnmarshalAsJSON(resp, &newPage) if err != nil { return nil, err } return &newPage, nil } // ListByContainerID returns all the resources of a given type under a specified parent. // If the operation fails it returns the *CloudError error type. // ctx is the context of the request. // client is the GenericClient to use for the request (can't declare generic methods, so this is standalone). // containerID is the unique ID of the container in which the resources are contained. // apiVersion is the API version to use for the request. // createResource is a function that returns a new instance of the resource type. func ListByContainerID[T any]( ctx context.Context, client *GenericClient, containerID string, apiVersion string, ) ([]T, error) { pager := runtime.NewPager( runtime.PagingHandler[listPageResponse[T]]{ More: func(page listPageResponse[T]) bool { // We have more if we have a link to follow return page.More() }, Fetcher: func(ctx context.Context, page *listPageResponse[T]) (listPageResponse[T], error) { nextPage, err := page.NextPage(ctx, client, containerID, apiVersion) if err != nil { return listPageResponse[T]{}, err } return *nextPage, nil }, }) var result []T for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, err } result = append(result, page.Value...) } return result, nil } // listByParentIDCreateRequest creates the ListByContainerID request. // ctx is the context of the request. // containerID is the unique ID of the container in which the resources are contained. // apiVersion is the API version to use for the request. func (client *GenericClient) listByContainerIDCreateRequest( ctx context.Context, containerID string, apiVersion string, ) (*policy.Request, error) { urlPath := "/{containerId}" if containerID == "" { return nil, eris.New("parameter containerID cannot be empty") } urlPath = strings.ReplaceAll(urlPath, "{containerId}", containerID) req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.endpoint, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") return req, nil } // BeginDeleteByID - Deletes a resource by ID. // If the operation fails it returns the *CloudError error type. func (client *GenericClient) BeginDeleteByID(ctx context.Context, resourceID string, apiVersion string) (*PollerResponse[GenericDeleteResponse], error) { // The linter doesn't realize that the response is closed in the course of // the autorest.NewPoller call below. Suppressing it as it is a false positive. //nolint:bodyclose resp, err := client.deleteByID(ctx, resourceID, apiVersion) if err != nil { return nil, err } result := PollerResponse[GenericDeleteResponse]{ RawResponse: resp, ID: DeletePollerID, ErrorHandler: client.handleError, } pt, err := runtime.NewPoller[GenericDeleteResponse](resp, client.pl, nil) if err != nil { return nil, err } result.Poller = pt return &result, nil } // DeleteByID - Deletes a resource by ID. // If the operation fails it returns the *CloudError error type. func (client *GenericClient) deleteByID(ctx context.Context, resourceID string, apiVersion string) (*http.Response, error) { req, err := client.deleteByIDCreateRequest(ctx, resourceID, apiVersion) if err != nil { return nil, err } resp, err := client.pl.Do(req) if err != nil { return resp, err } if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusAccepted, http.StatusNoContent) { return nil, runtime.NewResponseError(resp) } return resp, nil } // deleteByIDCreateRequest creates the DeleteByID request. func (client *GenericClient) deleteByIDCreateRequest(ctx context.Context, resourceID string, apiVersion string) (*policy.Request, error) { urlPath := "/{resourceId}" if resourceID == "" { return nil, eris.New("parameter resourceID cannot be empty") } urlPath = strings.ReplaceAll(urlPath, "{resourceId}", resourceID) req, err := runtime.NewRequest(ctx, http.MethodDelete, runtime.JoinPaths(client.endpoint, urlPath)) if err != nil { return nil, err } reqQP := req.Raw().URL.Query() reqQP.Set("api-version", apiVersion) req.Raw().URL.RawQuery = reqQP.Encode() req.Raw().Header.Set("Accept", "application/json") return req, nil } func (client *GenericClient) CheckExistenceWithGetByID(ctx context.Context, resourceID string, apiVersion string) (bool, time.Duration, error) { if resourceID == "" { return false, zeroDuration, eris.New("parameter resourceID cannot be empty") } ignored := struct{}{} retryAfter, err := client.GetByID(ctx, resourceID, apiVersion, &ignored) switch { case IsNotFoundError(err): return false, retryAfter, nil case err != nil: return false, retryAfter, err default: return true, retryAfter, nil } } func IsNotFoundError(err error) bool { var typedError *azcore.ResponseError if eris.As(err, &typedError) { if typedError.StatusCode == http.StatusNotFound { return true } } return false } func (client *GenericClient) ResumeDeletePoller(id string) *PollerResponse[GenericDeleteResponse] { return &PollerResponse[GenericDeleteResponse]{ID: id, ErrorHandler: client.handleError} } func (client *GenericClient) ResumeCreatePoller(id string) *PollerResponse[GenericResource] { return &PollerResponse[GenericResource]{ID: id, ErrorHandler: client.handleError} }