Skip to content

Commit

Permalink
Factor CRUD handling into a client that can be independently tested a…
Browse files Browse the repository at this point in the history
…nd used in custom resources (#3108)

This PR is a continuation of the idea of #3062, which is to factor the
provider, especially the huge `provider.go`, more cleanly.

Besides the standard benefits of encapsulation, testability, etc., this
is also opens up the opportunity to pass the newly extracted client into
custom resource implementations. This essentially enables them to call
back into the standard CRUD logic, making their implementation similar
to the standard resources while allowing variation, rather than the
current completely separate implementation. #3079 showed this by passing
the `AzureClient` into the custom resources. The new CRUD client in this
PR could be used in custom resources in a similar manner, allowing them
to use the standard provider CRUD building blocks like type lookup, REST
body and query generation, SDK shape converstion etc.

Unfortunately, I don't have a custom resource using this new client to
show at this point. Originally, I assumed #3079 might, but it turned out
to be unnecessary beyond the existing `AzureClient`.
  • Loading branch information
thomas11 authored Feb 28, 2024
1 parent 337b1c9 commit 8d5e805
Show file tree
Hide file tree
Showing 4 changed files with 878 additions and 605 deletions.
391 changes: 391 additions & 0 deletions provider/pkg/provider/crud/crud.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,391 @@
package crud

import (
"context"
"fmt"
"net/url"
"strings"

structpb "github.com/golang/protobuf/ptypes/struct"
"github.com/pkg/errors"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/azure"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/convert"
"github.com/pulumi/pulumi-azure-native/v2/provider/pkg/resources"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/rpcutil/rpcerror"
rpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
"google.golang.org/grpc/codes"
)

// AzureRESTConverter is an interface for preparing Azure inputs from Pulumi data and for converting from Azure outputs to Pulumi SDK shape.
// It operates in the context of a specific kind of Azure resource of type resources.AzureAPIResource.
type AzureRESTConverter interface {
PrepareAzureRESTIdAndQuery(inputs resource.PropertyMap) (string, map[string]any)
PrepareAzureRESTBody(id string, inputs resource.PropertyMap) map[string]any

ResponseBodyToSdkOutputs(response map[string]any) map[string]any
}

// ResourceCrudOperations is an interface for performing CRUD operations on Azure resources of a certain kind.
// See AzureRESTConverter for creating proper inputs and converting outputs.
// It operates in the context of a specific kind of Azure resource of type resources.AzureAPIResource.
type ResourceCrudOperations interface {
CanCreate(ctx context.Context, id string) error
CreateOrUpdate(ctx context.Context, id string, bodyParams, queryParams map[string]any) (map[string]any, bool, error)
Read(ctx context.Context, id string) (map[string]any, error)
HandleErrorWithCheckpoint(ctx context.Context, err error, id string, inputs resource.PropertyMap, properties *structpb.Struct) error
}

// SubresourceMaintainer is an interface for handling sub-resource properties.
// It operates in the context of a specific kind of Azure resource of type resources.AzureAPIResource.
type SubresourceMaintainer interface {
MaintainSubResourcePropertiesIfNotSet(ctx context.Context, id string, bodyParams map[string]any) error
// Properties pointing to sub-resources that can be maintained as separate resources might not be
// present in the inputs because the user wants to manage them as standalone resources. However,
// such a property might be required by Azure even if it's not annotated as such in the spec, e.g.,
// Key Vault's accessPolicies. Therefore, we set these properties to their default value here,
// an empty array. For more details, see section "Sub-resources" in CONTRIBUTING.md.
//
// During create, no sub-resources can exist yet so there's no danger of overwriting existing values.
//
// The `input` param is used to determine the unset sub-resource properties. They are then reset in
// the `output` parameter which is modified in-place.
//
// Implementation note: we should make it possible to write custom resources that call code from
// the default implementation as needed. This would allow us to cleanly implement special logic
// like for Key Vault into custom resources without duplicating much code. In the Key Vault case,
// the custom Read() would look like
//
// provider.azureCanCreate(ctx, id, &res)
// setUnsetSubresourcePropertiesToDefaults(res, bodyParams) // custom
// k.azureCreateOrUpdate
// ...
SetUnsetSubresourcePropertiesToDefaults(input, output map[string]any, outputIsInApiShape bool)
}

// ResourceCrudClient is a client for performing CRUD operations on Azure resources.
// It encapsulates both common logic and instances of helpers such as azure.AzureClient and convert.SdkShapeConverter.
// It operates in the context of a specific kind of Azure resource of type resources.AzureAPIResource.
type ResourceCrudClient interface {
ResourceCrudOperations
AzureRESTConverter
SubresourceMaintainer
}

type resourceCrudClient struct {
azureClient azure.AzureClient
lookupType resources.TypeLookupFunc
converter *convert.SdkShapeConverter
subscriptionID string

res *resources.AzureAPIResource
}

func NewResourceCrudClient(
azureClient azure.AzureClient,
lookupType resources.TypeLookupFunc,
converter *convert.SdkShapeConverter,
subscriptionID string,
res *resources.AzureAPIResource,
) ResourceCrudClient {
return &resourceCrudClient{
azureClient: azureClient,
lookupType: lookupType,
converter: converter,
subscriptionID: subscriptionID,
res: res,
}
}

func (r *resourceCrudClient) PrepareAzureRESTIdAndQuery(inputs resource.PropertyMap) (string, map[string]any) {
return PrepareAzureRESTIdAndQuery(r.res.Path, r.res.PutParameters, inputs.Mappable(), map[string]any{
"subscriptionId": r.subscriptionID,
"api-version": r.res.APIVersion,
})
}

func (r *resourceCrudClient) PrepareAzureRESTBody(id string, inputs resource.PropertyMap) map[string]any {
return PrepareAzureRESTBody(id, r.res.PutParameters, r.res.RequiredContainers, inputs.Mappable(), r.converter)
}

func PrepareAzureRESTIdAndQuery(path string, parameters []resources.AzureAPIParameter, methodInputs, clientInputs map[string]any) (string, map[string]any) {
params := map[string]map[string]interface{}{
"query": {
"api-version": clientInputs["api-version"],
},
"path": {},
}

// Build maps of path and query parameters.
for _, param := range parameters {
if param.Location == "body" {
continue
}
var val interface{}
var has bool
sdkName := param.Name
if param.Value.SdkName != "" {
sdkName = param.Value.SdkName
}
// Look in both `method` and `client` inputs with `method` first
val, has = methodInputs[sdkName]
if !has {
val, has = clientInputs[sdkName]
}
if has {
params[param.Location][param.Name] = val
}
}

// Calculate resource ID based on path parameter values.
id := path
for key, value := range params["path"] {
encodedVal := strings.Replace(url.QueryEscape(value.(string)), "+", "%20", -1)
id = strings.Replace(id, "{"+key+"}", encodedVal, -1)
}

return id, params["query"]
}

func PrepareAzureRESTBody(id string, parameters []resources.AzureAPIParameter, requiredContainers [][]string,
methodInputs map[string]any, converter *convert.SdkShapeConverter) map[string]any {
// Build the body JSON.
var body map[string]interface{}
for _, param := range parameters {
if param.Location != "body" {
continue
}
body = converter.SdkInputsToRequestBody(param.Body.Properties, methodInputs, id)
break
}

// Ensure all required containers are created.
for _, containers := range requiredContainers {
currentContainer := body
for _, containerName := range containers {
innerContainer, ok := currentContainer[containerName]
if !ok {
innerContainer = map[string]interface{}{}
currentContainer[containerName] = innerContainer
}
currentContainer, ok = innerContainer.(map[string]interface{})
if !ok {
break
}
}
}

return body
}

// partialError creates an error for resources that did not complete an operation in progress.
// The last known state of the object is included in the error so that it can be checkpointed.
func partialError(id string, err error, state *structpb.Struct, inputs *structpb.Struct) error {
detail := rpc.ErrorResourceInitFailed{
Id: id,
Properties: state,
Reasons: []string{err.Error()},
Inputs: inputs,
}
return rpcerror.WithDetails(rpcerror.New(codes.Unknown, err.Error()), &detail)
}

func (r *resourceCrudClient) HandleErrorWithCheckpoint(ctx context.Context, err error, id string, inputs resource.PropertyMap, properties *structpb.Struct) error {
// Resource was partially updated but the operation failed to complete.
// Try reading its state by ID and return a partial error if succeeded.
checkpoint, getErr := r.currentResourceStateCheckpoint(ctx, id, inputs)
if getErr != nil {
return azure.AzureError(errors.Wrapf(err, "resource updated but read failed %s", getErr))
}
return partialError(id, err, checkpoint, properties)
}

// currentResourceStateCheckpoint reads the resource state by ID, converts it to outputs map, and
// produces a checkpoint with these outputs and given inputs.
func (r *resourceCrudClient) currentResourceStateCheckpoint(ctx context.Context, id string, inputs resource.PropertyMap) (*structpb.Struct, error) {
getResp, getErr := r.azureClient.Get(ctx, id, r.res.APIVersion)
if getErr != nil {
return nil, getErr
}
outputs := r.converter.ResponseBodyToSdkOutputs(r.res.Response, getResp)
obj := checkpointObject(inputs, outputs)
return plugin.MarshalProperties(
obj,
plugin.MarshalOptions{
Label: "currentResourceStateCheckpoint.checkpoint",
KeepSecrets: true,
KeepUnknowns: true,
SkipNulls: true,
},
)
}

// checkpointObject puts inputs in the `__inputs` field of the state.
func checkpointObject(inputs resource.PropertyMap, outputs map[string]interface{}) resource.PropertyMap {
object := resource.NewPropertyMapFromMap(outputs)
object["__inputs"] = resource.MakeSecret(resource.NewObjectProperty(inputs))
return object
}

func (r *resourceCrudClient) MaintainSubResourcePropertiesIfNotSet(ctx context.Context, id string, bodyParams map[string]interface{}) error {
// Identify the properties we need to read
missingProperties := findUnsetPropertiesToMaintain(r.res, bodyParams, true /* returnApiShapePaths */, r.lookupType)

if len(missingProperties) == 0 {
// Everything's already specified explicitly by the user, no need to do read.
return nil
}

// Read the current resource state.
state, err := r.azureClient.Get(ctx, id+r.res.ReadPath, r.res.APIVersion)
if err != nil {
return fmt.Errorf("reading cloud state: %w", err)
}

writtenProperties := writePropertiesToBody(missingProperties, bodyParams, state)
for writtenProperty, writtenValue := range writtenProperties {
logging.V(9).Infof("Maintaining remote value for property: %s.%s = %v", id, writtenProperty, writtenValue)
}

return nil
}

// SetUnsetSubresourcePropertiesToDefaults is the standard implementation of SubresourceMaintainer.SetUnsetSubresourcePropertiesToDefaults.
func (r *resourceCrudClient) SetUnsetSubresourcePropertiesToDefaults(input, output map[string]interface{}, outputIsInApiShape bool) {
unset := findUnsetPropertiesToMaintain(r.res, input, outputIsInApiShape, r.lookupType)

for _, p := range unset {
cur := output
for _, pathEl := range p.path[:len(p.path)-1] {
curObj, ok := cur[pathEl]
if !ok {
newContainer := map[string]any{}
cur[pathEl] = newContainer
cur = newContainer
continue
}
cur, ok = curObj.(map[string]any)
if !ok {
break
}
}

cur[p.path[len(p.path)-1]] = []any{}
}
}

func findUnsetPropertiesToMaintain(res *resources.AzureAPIResource, bodyParams map[string]interface{}, returnApiShapePaths bool, lookupType resources.TypeLookupFunc) []propertyPath {
missingProperties := []propertyPath{}
for _, path := range res.PathsToSubResourcePropertiesToMaintain(returnApiShapePaths, lookupType) {
curBody := bodyParams
for i, pathEl := range path {
v, ok := curBody[pathEl]
if !ok {
missingProperties = append(missingProperties, propertyPath{
path: path,
propertyName: pathEl,
})
break
}

// At the end of the path we don't need to go deeper via references and map lookups.
if i == len(path)-1 {
break
}

curBody, ok = v.(map[string]interface{})
if !ok {
missingProperties = append(missingProperties, propertyPath{
path: path,
propertyName: pathEl,
})
break
}
}
}

return missingProperties
}

func (r *resourceCrudClient) ResponseBodyToSdkOutputs(response map[string]any) map[string]any {
return r.converter.ResponseBodyToSdkOutputs(r.res.Response, response)
}

func (r *resourceCrudClient) CanCreate(ctx context.Context, id string) error {
return r.azureClient.CanCreate(ctx, id, r.res.ReadMethod, r.res.APIVersion, r.res.ReadMethod, r.res.Singleton, r.res.DefaultBody != nil, func(outputs map[string]any) bool {
return r.converter.IsDefaultResponse(r.res.PutParameters, outputs, r.res.DefaultBody)
})
}

func (r *resourceCrudClient) CreateOrUpdate(ctx context.Context, id string, bodyParams, queryParams map[string]any) (map[string]any, bool, error) {
// Submit the `PUT` or `PATCH` against the ARM endpoint
op := r.azureClient.Put
if r.res.UpdateMethod == "PATCH" {
op = r.azureClient.Patch
}
return op(ctx, id, bodyParams, queryParams, r.res.PutAsyncStyle)
}

func (r *resourceCrudClient) Read(ctx context.Context, id string) (map[string]any, error) {
url := id + r.res.ReadPath

switch r.res.ReadMethod {
case "HEAD":
err := r.azureClient.Head(ctx, url, r.res.APIVersion)
return nil, err
case "POST":
bodyParams := map[string]interface{}{}
queryParams := map[string]interface{}{
"api-version": r.res.APIVersion,
}
return r.azureClient.Post(ctx, url, bodyParams, queryParams)
default:
return r.azureClient.Get(ctx, url, r.res.APIVersion)
}
}

type propertyPath struct {
path []string
propertyName string
}

func writePropertiesToBody(missingProperties []propertyPath, bodyParams map[string]interface{}, remoteState map[string]interface{}) map[string]interface{} {
writtenProperties := map[string]interface{}{}
for _, prop := range missingProperties {
currentBodyContainer := bodyParams
currentStateContainer := remoteState
for _, containerName := range prop.path {
innerBodyContainer, bodyOk := currentBodyContainer[containerName]
innerStateContainer, stateOk := currentStateContainer[containerName]
// If the container doesn't exist in either body or state, create it and continue iterating.
// But if it doesn't exist in either, there is no point in continuing.
if !bodyOk && !stateOk {
break
}
if !bodyOk {
innerBodyContainer = map[string]interface{}{}
currentBodyContainer[containerName] = innerBodyContainer
}
if !stateOk {
innerStateContainer = map[string]interface{}{}
currentStateContainer[containerName] = innerStateContainer
}
innerBodyObj, innerBodyIsObject := innerBodyContainer.(map[string]interface{})
innerStateObj, innerStateIsObject := innerStateContainer.(map[string]interface{})
if !innerBodyIsObject || !innerStateIsObject { // we've reached a leaf node (primitive type)
break
}
currentBodyContainer = innerBodyObj
currentStateContainer = innerStateObj
}

stateValue, ok := currentStateContainer[prop.propertyName]
if ok {
currentBodyContainer[prop.propertyName] = stateValue
writtenProperties[fmt.Sprintf("%s.%s", strings.Join(prop.path, "."), prop.propertyName)] = stateValue
}
}
return writtenProperties
}
Loading

0 comments on commit 8d5e805

Please sign in to comment.