Skip to content

Schema Design Considerations#

Whilst it is acceptable in certain cases to map the schema of a new resource or feature when extending an existing resource, one-to-one from the Azure API, in the majority of cases more consideration needs to be given how to expose the Azure API in Terraform so that the provider presents a consistent and intuitive experience to the end user.

Below are a list of common patterns found in the Azure API and how these typically get mapped within Terraform.

Features that are toggled by the property Enabled#

It is commonplace for features to be toggled on and off by an Enabled property within an object in the SDK used to interact with the Azure API. See the examples below.

Example A.

type ManagedClusterStorageProfileBlobCSIDriver struct {
    Enabled *bool `json:"enabled,omitempty"`
}

Example B.

type ManagedClusterWorkloadAutoScalerProfileVerticalPodAutoscaler struct {
    ControlledValues ControlledValues `json:"controlledValues"`
    Enabled          bool             `json:"enabled"`
    UpdateMode       UpdateMode       `json:"updateMode"`
}

Although there are still resources within the provider where this property is exposed in the Terraform schema, the provider is moving away from this and instead translates this behaviour in one of two ways.

In cases where Enabled is the only field required to turn a feature on and off we opt to flatten the block into a single top level property (or higher level property if already nested inside a block). So in the case of Example A, this would become:

"storage_blob_driver_enabled": {
    Type:     pluginsdk.TypeBool,
    Optional: true,
    Default:  false,
},

For features that can accept or require configuration, i.e. the object contains additional properties other than Enabled like in Example B, the behaviour should be such that when the block is present the feature is enabled, and when it is absent it is disabled. The corresponding Terraform schema would be as follows:

"vertical_pod_autoscaler": {
    Type:     pluginsdk.TypeList,
    Optional: true,
    MaxItems: 1,
    Elem: &pluginsdk.Resource{
        Schema: map[string]*pluginsdk.Schema{
            "update_mode": {
                Type:     pluginsdk.TypeString,
                Required: true,
                ValidateFunc: validation.StringInSlice([]string{
                    string(managedclusters.UpdateModeAuto),
                    string(managedclusters.UpdateModeInitial),
                    string(managedclusters.UpdateModeRecreate),
                }, false),
            },
            "controlled_values": {
                Type:     pluginsdk.TypeString,
                Required: true,
                ValidateFunc: validation.StringInSlice([]string{
                    string(managedclusters.ControlledValuesRequestsAndLimits),
                    string(managedclusters.ControlledValuesRequestsOnly),
                }, false),
            },
        },
    },
},

There are instances where configuration properties for a feature is optional, as shown below.

Example C.

type ManagedClusterStorageProfileDiskCSIDriver struct {
    Enabled *bool   `json:"enabled,omitempty"`
    Version *string `json:"version,omitempty"`
}

In cases like these one option is to flatten the block into two top level properties.

"storage_disk_driver_enabled": {
    Type:     pluginsdk.TypeBool,
    Optional: true,
    Default:  false,
},

"storage_disk_driver_version": {
    Type:     pluginsdk.TypeString,
    Optional: true,
    Default:  "V1",
    ValidateFunc: validation.StringInSlice([]string{
        "V1",
        "V2",
    }, false),
},

Depending on the behaviour of the Azure API and the default set by it, a worthwhile alternative is to set the property as required in the Terraform schema, to avoid having to set empty blocks to enable features.

"storage_disk_driver": {
    Type:     pluginsdk.TypeList,
    Optional: true,
    MaxItems: 1,
    Elem: &pluginsdk.Resource{
        Schema: map[string]*pluginsdk.Schema{
            "version": {
                Type:     pluginsdk.TypeString,
                Required: true,
                ValidateFunc: validation.StringInSlice([]string{
                    "V1",
                    "V2",
                }, false),
            },
        },
    },
},

The None value or similar#

Many Azure APIs and services will accept the values like None, Off, or Default as a default value and expose it as a constant in the API specification.

    "shutdownOnIdleMode": {
      "type": "string",
      "enum": [
        "None",
        "UserAbsence",
        "LowUsage"
      ],

Whilst it isn't uncommon to stumble across older resources in the provider that expose and accept these as a valid values, the provider is moving away from this pattern, since Terraform has its own null type i.e. by omitting the field. Existing None, Off or Default values within the provider are planned for removal in version 4.0.

This ultimately means that the end user doesn't need to bloat their configuration with superfluous information that is implied through the omission of information.

The resulting schema in Terraform would look as follows and also requires a conversion between the Terraform null value and None within the Create and Read functions.

// How the property is exposed in the schema
"shutdown_on_idle": {
    Type:     pluginsdk.TypeString,
    Optional: true,
    ValidateFunc: validation.StringInSlice([]string{
        string(labplan.ShutdownOnIdleModeUserAbsence),
        string(labplan.ShutdownOnIdleModeLowUsage),
        // NOTE: Whilst the `None` value exists it's handled in the Create/Update and Read functions.
        // string(labplan.ShutdownOnIdleModeNone),
    }, false),
},

// Normalising in the create or expand function
func (r resource) Create() sdk.ResourceFunc {

    ...

    var config resourceModel
    if err := metadata.Decode(&config); err != nil {
        return fmt.Errorf("decoding: %+v", err)
    }

    // The resource property shutdown_on_idle maps to the attribute shutdownOnIdle in the defined model for a typed resource in this example
    shutdownOnIdle := string(labplan.ShutdownOnIdleModeNone)
    if v := model.ShutdownOnIdle; v != "" {
        shutdownOnIdle = v
    }

    ...

}

// Normalising in the read or flatten function
func (r resource) Read() sdk.ResourceFunc {

    ...

    shutdownOnIdle := ""
    if v := props.ShutdownOnIdle; v != nil && v != string(labplan.ShutdownOnIdleModeNone) {
        shutdownOnIdle = string(*v)
    }

    state.ShutdownOnIdle = shutdownOnIdle

    ...

}

The type field#

The Azure API makes use of classes and inheritance through discriminator types defined in the REST API specifications. A strong indicator that a resource is actually a discriminated type is through the definition of a type or kind property.

Rather than exposing a generic resource with all the possible fields for all the possible different type's, we intentionally opt to split these resources by the type to improve the user experience. This means we can only output the relevant fields for this type which in turn allows us to provide more granular validation etc.

Whilst there is a trade-off here, since this means that we have to maintain more Data Sources/Resources, this is a worthwhile trade-off since each of these resources only exposes the fields which are relevant for this resource, meaning the logic is far simpler than trying to maintain a generic resource and pushing the complexity onto end-users.

Taking the Data Factory Linked Service resources as an example which could have all of possible types defined below, each requiring a different set of inputs:

"type": {
    Type:     pluginsdk.TypeString,
    Required: true,
    ValidateFunc: validation.StringInSlice([]string{
        string(datafactory.TypeBasicLinkedServiceTypeAzureBlobStorage),
        string(datafactory.TypeBasicLinkedServiceTypeAzureDatabricks),
        string(datafactory.TypeBasicLinkedServiceTypeAzureFileStorage),
        string(datafactory.TypeBasicLinkedServiceTypeAzureFunction),
        string(datafactory.TypeBasicLinkedServiceTypeAzureSearch),
        ...
    }, false),
},

Would be better exposed as the following resources: - azurerm_data_factory_linked_service_azure_blob_storage - azurerm_data_factory_linked_service_azure_databricks - azurerm_data_factory_linked_service_azure_file_storage - azurerm_data_factory_linked_service_azure_function - azurerm_data_factory_linked_service_azure_search

...