ARM templates are a powerfull tool and from a DevOps perspective can deal with a lot of the hassle in creating new environments and ensure that the environments don't differ. However writing ARM templates can be a tedious task as there aren't tools for generating templates and you often have to do an actual deploy to get relevant error messages about invalid values and missing dependencies. One method of creating ARM templates is to export templates from the Azure portal, however the templates generated this way are often bloated and you still need to do a lot of manual work to make parameters and variables for the settings that differ between environments.
We have developed a tool for generating ARM templates which can be seen here. The tool is still under development. Currently it's only possible to create storage accounts and blob containers, but it shows the potential of such a tool.
Structure of an ARM template
An ARM template consists of four parts:
- Parameters
- Variables
- Resources
- Outputs
We will go into a short description of each part excluding outputs.
Parameters
Parameters are quite simple in their nature, but this is the point where you are able to differentiate the environments. You are able to define numbers, strings, objects etc. as a parameter, which can be used in variables and resources. Parameters are isolated, meaning they cannot reference any other parts of the ARM template.
Variables
Variables are a bit more complicated than parameters, but still their functionality is limited. You can define strings, arrays, and objects as a variable and you can use other variables and parameters as part of your variable. This can e.g. be used to combine two parameters:
"variable": "[concat(parameters('name'), '-', parameters('environment'))]"
Resources
Resources are the heart of the ARM template. Each resource corresponds to a resource in the Azure environment. The resources can utilise parameters and variables as part of their specification.
Resources only have a few shared properties between them, so knowing how e.g. a database server is defined doesn't necessarily help you with defining another type of resource. Also the limitations of a property is not always intuitive and it can be bothersome to look up what's possible. This is the main focus of the application to mitigate these differences in a simple way.
Goals of the tool
There is one main focus of the tool: Making it easy to create a functioning ARM template. The goal of the tool is not be able to support all use cases and especially not the large scale ones. To achieve this goal, there are a few subgoals to complete:
- Create or reference dependencies automatically
- Create parameters automatically including limit to valid values
- Be able to use parameters, variables, and functions
The tool looks like this:
The different parts are visible in a friendly way on the left, the working window in the middle (it's here the forms for parameters, variables, resources, and outputs will be, where you'll do most of your work), and finally the generated template on the right which is updated automatically each time you press save
.
Technology used
The idea of the project was to be as simple as possible and accessible, therefore we implemented it as a static web page using the following:
- React
- TypeScript
We're going to skip going into detail why these technologies are used as the program flow is in focus although a object oriented language in general makes it easier with the power of inheritence.
Class structure
The general idea of the application is to have a base class handle all the basic information of resources such as name and location and let the inheriting classes handle resource specific information.
In our case two different base classes are needed: one for handling the resource information itself and one for handling form logic on the web page Resource.ts and ResourceTypeForm.tsx respectively.
The form logic classes are responsible for keeping track of the properties of the given resource and which parameters to create. For each resource property, it's possible to define a parameter name, if you want to generate a parameter for it. It will automatically create a parameter of the correct type for the property and limit the options to what's allowed including setting those limitations on the created parameter.
Automatic parameter creation
For parameter creation we went with a function in ResourceTypeForm.tsx to check if a parameter should be created and do so adding it to the list of new parameters.
protected createParameter(name: string, defaultValue: boolean | number | string, type: string, allowedValues: number[] | string[], parameterList: { [index: string]: Parameter }): void {
if(!name) {
return null;
}
let parameter = new Parameter();
if(defaultValue !== null && defaultValue !== undefined && defaultValue !== "") {
parameter.defaultValue = defaultValue;
}
if(allowedValues && allowedValues.length > 0) {
parameter.allowedValues = allowedValues
}
parameter.type = type;
parameterList[name] = parameter;
}
When the function is called, the parameters for the function are quite self explanatory where they come from except for the allowed values. The allowed values are only applied if it's not an empty list. The allowed values are defined in each resource's respective class and fetched from there, when creating a parameter as can be seen here from the class StorageAccountForm.tsx:
this.createParameter(this.state.kindParameterName, this.state.kind, "string", StorageAccount.allowedKinds, parametersToCreate);
If we look in StorageAccount.ts, we can see that allowedKinds
is a static property which defines all allowed values for a storage account type:
static allowedKinds:string[] = ["Storage", "StorageV2", "BlobStorage", "FileStorage", "BlockBlobStorage"];
This approach allows us to let the models keep track of what's possible and will in turn mean less maintenance when Microsoft adds other options. This static property is also used to populate the options list when setting the property in the web form.
Automatic dependency creation
The automatic dependency creation is really helpful if you just want a standard setup, or you're not sure what a resource actually depends on. When creating a resource, every type of dependent resource will be in the bottom of the form, where you can either select an existing resource (if any are available), or create a new one with the basic setup. All you need to specify is the name of the resource.
When creating a new resource, if that type of resource is dependant on another resource type, the form will recursively ask for dependencies until you either have selected existing resources or the resource types are not dependent on any other resource types e.g. when entering a blob container, the blob container requires a blob container service which in turn requires a storage account.
To achieve this functionality, every resource type implements a static function defined here:
static getDefault(_name: string, _resourceDependency: ResourceDependency): Resource[]
All that's required is the name you want to assign to the resource and ResourceDependency
. The resource dependency is a model to maintain the dependency graph. If you want to look into the implementation, you can find it here. An example from StorageAccountBlobContainer.ts, where it automatically creates a default blob service if you have chosen to create or new, otherwise it fetches the selected service, sets the dependent resources and adds the container to list of created resources which in turn tells the form logic to add the resources returned from the getDefault
function.
static getDefault(name: string, dependencyModel: ResourceDependency): Resource[] {
let resources: Resource[] = [];
let blobService: StorageAccountBlobService;
Object.keys(dependencyModel.newResources).forEach(type => {
const name: string = dependencyModel.newResources[type];
if(type === StorageAccountBlobService.resourceType) {
resources.push.apply(resources, StorageAccountBlobService.getDefault(name, dependencyModel.required.find(r => r.type === StorageAccountBlobService.resourceType)));
blobService = resources.find(r => r.type === StorageAccountBlobService.resourceType) as StorageAccountBlobService;
}
});
Object.keys(dependencyModel.existingResources).forEach(type => {
let resource = dependencyModel.existingResources[type];
if(resource.type === StorageAccountBlobService.resourceType) {
blobService = resource as StorageAccountBlobService;
}
});
let service = new StorageAccountBlobContainer();
service.requiredResources = blobService;
service.setName = name;
service.dependsOn = [blobService.getResourceId()];
resources.push(service);
return resources;
}
With this logic, it is possible to add a storage container including the blob service and storage account by only entering the name of each resource and the public access level of the container.
By pressing save the following ARM template is created:
{
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"StorageContainerPublicAccess": {
"defaultValue": "Blob",
"allowedValues": [
"None",
"Container",
"Blob"
],
"type": "string"
},
"StorageContainerName": {
"defaultValue": "StorageContainer",
"type": "string"
}
},
"variables": {},
"resources": [
{
"apiVersion": "2019-04-01",
"type": "Microsoft.Storage/storageAccounts",
"name": "storageaccount",
"location": "[resourceGroup().location]",
"kind": "StorageV2",
"properties": {
"accessTier": "Cool",
"supportsHttpsTrafficOnly": true,
"encryption": {
"keySource": "Microsoft.Storage",
"services": {
"blob": {
"enabled": true
},
"file": {
"enabled": true
}
}
}
},
"sku": {
"name": "Standard_LRS",
"tier": "Standard"
}
},
{
"apiVersion": "2019-04-01",
"type": "Microsoft.Storage/storageAccounts/blobServices",
"dependsOn": [
"[resourceId('Microsoft.Storage/storageAccounts', 'storageaccount')]"
],
"name": "storageaccount/default"
},
{
"apiVersion": "2019-04-01",
"type": "Microsoft.Storage/storageAccounts/blobServices/containers",
"name": "storageaccount/default/[parameters('StorageContainerName')]",
"properties": {
"publicAccess": "Blob"
},
"dependsOn": [
"[resourceId('Microsoft.Storage/storageAccounts/blobServices', 'storageaccount', 'default')]"
]
}
],
"outputs": {}
}
Restrictions
Even though the tool creates valid templates, it doesn't check that the names are available, only that the syntax is valid. As the tool is meant as a light-weight helper tool, this is not planned to be implemented.
Next steps
This tool shows that it's possible to make a tool, which can leviate a lot of the hassles of writing an ARM template, although utilising the full power of the ARM template with a tool like this would require a lot of work and may not be worth the development time.
The next step of the tool is to support more resource types and refactor the shared logic between resources to make it more readable and more simple.
Resources
- The code is available on Github here