After spending a good deal of time working in depth with User Defined Types at the end of 2023, I want to expand my previous post on the basics of User Defined Types. User Defined Types provide a great new capability as we write Bicep code; it is also a feature that is hard to define it’s value until you see it in action. In this post I will walk through a Bicep template I designed to help deploy Azure SQL Databases. This template takes advantage of multiple advanced features and techniques that will bring better understanding and speed to your deployments.
Template Scope overview
Before diving into the Bicep template, I want to explain the design of our Bicep deployment. There are 2 components to our strategy, modules and templates.
- Modules - Generalized resource that contain the logic to successfully deploy the resource
- Templates - Contain the business and security requirements for a deployment
Modules are generalized Bicep files that target an Azure resource (e.g Azure SQL, Azure Virtual Machine, Virtual Network). These modules are designed to allow for deployment in multiple configurations without altering the module. These are based on the Azure Resource Modules project. Utilizing a generalized module allows us to abstract away logic and minimize the complexity of the template design by teams.
Here is an example of logic that would be part of module. The Bicep below checks if the template calling this module has passed any Identity properties.
|
|
This abstraction provides several benefits:
- Template designers are not required to develop complex logic to manage parameter and variable input.
- Template parameters are focused on deployment specific information.
- Template development speed can be increased.
The goal of this strategy is to create templates that require minimal complexity to meet the business requirements for specific deployment scenarios. For example, we have projects that are allowed to host resources with public access, while other projects that have access to on-premise resources are not allowed to have publicly facing resources. The template for public access deployment would have options for public access while the second template would instead require options to ensure traffic is not publicly available.
Here is snippet of a template that is used to ensure deployments are not publicly available:
|
|
Templates can meet security and business requirements by limiting the property options available for the user. With this template a user would not be able to enable public network access through misconfiguration of a parameter file. While it is possible for someone to modify the template directly during a PR, the expectation is that this change will be caught during the PR review. If it was missed during the review, then Azure Policy would block the deployment in our environment. Having layers of validation is crucial to ensuring properly configured resources in your deployments.
Template components
Let’s review the main components of our template.
Our template is organized into sections:
- User Defined Types
- Parameters
- Variables
- Resources
- Modules
- Outputs
Defining a good structure for your templates can ensure consistent development across multiple teams. Defining structure, formatting, and naming standards allow for faster development and review. I highly recommend developing a style guide for the creation of templates and modules. For this post we will be focusing on the User Defined Types in our template.
User Defined Types
Our template utilizes several User Defined Types to improve usability and discovery of valid parameter values. These types are imported from several files using the compileTimeImports
feature:
|
|
User Defined Types can grow to be quite large. We can move our User Defined Types to separate Bicep files that represent different parts of our template logic. To ensure our main template file does not become difficult to navigate, I have broken out the logic into several files. 1 for the core SQL server, database, and deployment specific types, and 3 for the SQL sku options for the SQL Database. We will learn more about those types later in this post. First lets look at the core User Defined Types.
Azure Environment Type
The azureEnvironmentType
contains information specific to our environments and deployments. It provides static information that can be used to dynamically create or complete resources. The export()
decorator makes the type available for import from another Bicep file.
The sealed()
decorator prevents modification of the property values to avoid alterations in the parameter file.
|
|
While this information could also be stored in a variable, providing it as a type allows us to add helpful descriptions for consumers of the template. Providing a re-usable type that can be used to retrieve environment static values ensures consistent resource deployments.
Here is an example of dynamically creating the SQL Server name based on environment and region information found in the azureEnvironmentType
:
|
|
Another example of accessing the private endpoint DNS zone Resource Id found in the hub network subscription:
|
|
sqlServerSettingType
This Type contains all the parameters needed by our template for the deployment of our SQL Server resource. As I mentioned before, the template focuses on the features of SQL Server for a specific business case. Ensuring each property has a detailed description helps consumers understand the parameter and the potential values available to them. Our goal is to allow consumers to create and complete a new parameter file without reading additional documentation.
|
|
sqlDatabaseSettingType
This Type defines all the required properties to successfully deploy databases to the SQL server.
|
|
Again good descriptions and simplified options are a great benefit to the consumers of the template. A good example of this is the maintenanceConfigurationId
property. The value required by the SQL resource is unique resource Id per region (‘Microsoft.Maintenance/publicMaintenanceConfigurations/SQL_eastus_DB_1’). The resourceId does not provide meaningful information for the user to determine what value they should choose. To assist the user, we provide them more descriptive options by having them select the name of the maintenance windows that each resourceId represents.
|
|
In the template, the maintenance window names and resource Ids are part of variable object.
|
|
We can then pass the proper value for the maintenance window using the variable and parameter value provided by the user.
|
|
Providing descriptive and meaningful options will minimize questions and confusion when using your template!
sqlDatabaseSkuOptions
Now we get into the complex portion of our template. This type was designed to help dynamically provide users the required properties based on the chosen sku for SQL Database. Generally you provide sku information for SQL Databases through properties such as:
- skuName
- skuSize
- skuCapacity
- skuFamily
These values align with various tiers of Azure SQL DB:
- DTU
- Basic
- Standard
- Premium
- vCore
- General Purpose Provisioned
- General Purpose Serverless
Each of these tiers has multiple sku’s and potentially different features based on your sku choice. Previously, there was no way to organize the potential options due to the complexity and quantity of sku’s available. However, with User Defined Types and discriminated unions, we can bring structure to the chaotic assortment of sku’s and properties.
Below is the top level User Defined Type, sqlDatabaseSkuOptions
used in the sqlDatabaseSettingType
to determine what properties are required for a SQL Database based on the sku chosen.
|
|
While this type appears simple, there is a great deal of logic and configuration hidden under the covers. The sqlDatabaseSkuOptions
type is a composition of 5 User Defined Types:
- sqlDatabaseSkuBasic
- sqlDatabaseSkuStandard
- sqlDatabaseSkuPremium
- sqlDatabaseSkuGeneralPurposeProvisioned
- sqlDatabaseSkuGeneralPurposeServerless
This is accomplished using the @discriminator()
decorator. User Defined Types can be combined using a common property found in each type. From the Bicep documentation:
The discriminator decorator takes a single parameter, which represents a shared property name among all union members. This property name must be a required string literal on all members and is case-sensitive. The values of the discriminated property on the union members must be unique in a case-insensitive manner.
Each of our 5 sku types above have a type
property that is unique to that sku. This is the first filter used to to choose our sku. Let’s take a look at at the sqlDatabaseSkuStandard
type:
|
|
For most SQL Database sku’s, there are 2 required properties, skuTier
and skuName
. For all standard sku’s, the skuTier
value is Standard. The SkuName
has 9 options available, one for each (Standard Service Tier). Along with skuName
, there is also a databaseMaxSize
property that is needed for each standard sku with. To further complicate our decision, skus can have different maximum database sizes. So how do we handle all these sku options? Discriminated Unions to the rescue! We use dtu
property as the key for the standardDtuType
discriminated type:
|
|
Each sku has different capacities such as DTU (Database Transaction Unit) and potential database sizes. Below is one of the sku types used in the standardDtuType
:
|
|
The dtu
property is used as the discriminated property in our standardDtuType
to combine all of our standard sku options. dtu
was chosen as it best represented the deciding factor most consumers would use, the performance level of the sku. The dtu
value is used as friendly type to help provide the user with a better understanding of the sku options. The skuName
property is the actual value passed to our SQL module to choose the correct sku. DatabaseMaxSize
is the value passed for the storage capacity of the sku. Standard sku’s also include specific step increases for the database size. By providing the user with this information from our template, choosing a sku results in all the required parameters being present. Here is an example of the intellisense provided in a Bicep parameter file using our SQL template. The user is provided meaningful information to determine what sku and required properties are needed for their deployment without needing to leave the parameter file.
While this improves the user experience for database sku selection, there some considerations with this approach.
Pros
- Users have convenient access to most required properties to choose a sku.
- Only valid options are presented to the user.
- Option values that are sku specific are only displayed for the appropriate sku.
Cons:
- Sku’s availability is region specific, some sku’s may not be available in all regions.
- Testing all sku options can be difficult to automate as part of a CI/CD pipeline.
- For more complex or unique DB deployments, additional guidance may still be needed for Azure Documentation.
Let’s map out our design so far!
Our sqlDatabaseSkuOptions
type contains the sku tiers defined in 5 types. Each sku tier type contains all the available skus for that tier. Those individual skus will then contain the required properties for deploying that sku.
This strategy, while complex, provides a huge benefit to consumers of our template. Ensuring users can only choose the required and valid options for their sku increases the success of our template deployment. There is no need to go read additional documentation or question which properties are needed for your chosen sku. By dividing the SQL Database sku tier’s into User Defined Types, we are able to create nested types that contain the sku specific options that are only presented when the user decides.
To drive the point home, lets look at the GeneralPurposeProvisioned Type:
|
|
Instead of using dtu
, the generalPurposeProvisionedType
discriminated union uses vCores
for it’s joining type. This helps the user in deciding which sku to choose based on the performance needed for their deployment (vCores being one of the biggest factors). Each sku type has 3 properties:
- skuName
- databaseMaxSize
- vCores
Both skuName
and databaseMaxSize
are needed for the deployment of the SQL DB, whereas vCores
is only used to provide a user readable convention for choosing the sku they need. GP_Gen5_2
, while being the required value for deployment, does not provide a good description for most users. You can also see in this case that the GP_Gen5_2
sku allows any value for databaseMaxSize
instead of set size limits like the Standard sku we looked at previously. Each User Defined Type for each sku tier is slightly different to handle the specific requirements of that tier. Please continue to investigate the template on my GitHub to see the different configurations of the other SQL sku tiers.
Conclusion
User Defined Types represent a major change in how we can author Bicep templates and modules. Having the ability to create complex logical types to provide our template easy to digest parameters is a huge win. Removing some of the guesswork and confusing parameter requirements from our template and module deployments will go a long way in increasing user adoption. I hope this post has helped you understand the potential of this new feature. Happy Coding!