YAML Pipeline Templates
Hello, and welcome to the ninth post in our series exploring the basics of using HashiCorp Terraform to deploy resources into Microsoft Azure using Azure DevOps. I suppose that considering we’re on post nine and we’re already looking at putting our pipeline code into templates, we’re not really basic anymore, but I’m sticking with that until we’re using for-each loops and we’ve got a second environment in place (we’re nearly there!)…
As always, when I’m writing these posts I make an assumption that you’re following along with me, that your code is in the same place as mine and that generally means you’ve worked through the previous episodes:
- Prerequisites
- Repositories and Pipelines
- Build Pipeline and Resource Deployment
- Pipeline Security and Governance
- State File Storage Security
- Pipeline Refinement
- Modules
- Directories and Stages
If something in this post doesn’t make sense, or is confusing, go back over the previous posts and see if the answer’s there. Alternatively, I’m human, I get things wrong, I forget things, and I always appreciate suggestions on how to improve my posts, so leave me a comment!
I’ve copied in my usual preamble here: I’m writing this series as a memory aid for myself with the hope that others can use it that might be on the same learning path as me. It’s not a detailed guide into Terraform, DevOps or Azure, it’s just an aid to understanding all the moving parts which will help with an understanding of and troubleshooting infrastructure as code environments. It probably doesn’t meet all the best practices that a DevOps engineer would use, but it works for me. I’m writing in the middle of 2024, so by the time you read this things might have moved on a little, but I suspect the core concepts will remain the same. As always I’d like to credit my friend and colleague James Meegan for his original documentation which has been used as a foundation for the series.
This post aims to cover the subject of putting YAML pipelines into templates. This means separating out repeated and re-usable code into separate files that are project or client agnostic, just as we did with our Terraform modules.
Why use YAML Templates?
When we created our Terraform modules, the aim was to separate out repeatable code that could be re-used and contained no project or client-specific data. The concept is the same for YAML templates. We want our pipelines to be clean and short, with any code that gets repeated placed into a template that can be called by the main file. These templates can then in turn be used on other projects or even customer environments because they’re totally abstracted from any project-specific data. Right now our validation pipeline starts off like this:
We’re naming the variable library, our environment, detailing some variable names etc., plus putting in all our tasks. Better practice is to abstract the tasks themselves from that pipeline into their own templates and call those from the main YAML pipeline. A good first example from the above image is the Terraform formatting check. That’s not project-specific, it’s always the same code, so we can pop it into a template and call it from the main file. The same goes for disabling the storage account firewall. We can put the code into a separate YAML file, then call it from the main pipeline to perform the task. If you think about how many times the firewall is opened in our apply pipeline, you’ll understand how abstracting that will go a long way towards tidying up that file.
In addition, if we ever need to make any changes to the code for a task, such as that firewall disabling, we only need to do it once, in the template, instead of multiple times in all pipelines that run the code. With the two examples given above, the first is relatively simple, it’s just a single line of code. The second though is more complex and has variables passed to it. Let’s go through the process of putting the format checking code into a template so that we understand the basic logic, then move on to the more complex firewall code.
Building a Simple YAML Template
As with our Terraform Modules, we should ideally use a subdirectory to host our template files. We were starting to tidy up our root folder, and I’ve a feeling that I might want to put more than just templates into a directory related to pipelines, so I’m going to start with an “azurepipeline” directory, and I want this subdirectory to appear at the top of my list, so I’m going to start it with a “.”. In there is where I’ll put my “pipeline-templates” directory:
In that pipeline-templates directory, I’m going to create a new file to hold my Terraform format code, so I’ll give it a sensible name that I find descriptive: “terraformformat_template.yml”. In that file I’m going to add a comment at the top to explain what’s happening, then just paste the working code from my validation pipeline (taking out the check out line that isn’t part of it, but including the “steps” line):
steps:
# Check Terraform Formatting
- script: |
terraform fmt -check -recursive --diff
displayName: Check Terraform Formatting
Note how I’ve reduced the indent taking the beginning of all appropriate lines all the way to the left, but keeping the relative indentation of the other lines. After saving this new file, we need to call it in the appropriate place from our main pipeline file. I’m deleting the original code that we put in the template file and replacing it with a pointer:
# Call the template code to check Terraform Formatting
- template: .azurepipeline/pipeline-templates/terraformformat_template.yml
It’s worth pointing out here that the spelling of your path and file name must be correct. Also of note is that this template is called within the steps section. Although it seems like we’re duplicating that in the template file itself, that is how template code starts so it should be retained.
Building a More Complex YAML Template
Now we understand the logic, we need to do the same for our firewall disable code. This however has variables, which we don’t want in our template itself, this should be project or client-agnostic. With YAML we pass the variables to our template when calling it with parameters. We must declare those parameters within the template before using them to pull in the variables. Let’s get the template file created to make the understanding a little clearer. Create a new file, suitably named, in your previously created templates folder. In that file, copy the code from your main pipeline code, remembering to add a comment and a “steps” line at the beginning, and to set the appropriate indentation:
# Template to disable the Azure Storage Account firewall
steps:
- task: AzureCLI@2
displayName: Disable Storage Account Firewall
continueOnError: false
inputs:
azureSubscription: '$(backend_sc)'
scriptType: 'pscore'
scriptLocation: 'inlineScript'
inlineScript: |
$resourceGroupName = "$(backend_rg)"
$storageAccountName = "$(backend_saname)"
Write-Host "Disabling Storage Account Firewall, Please Wait..."
az storage account update --resource-group $resourceGroupName --name $storageAccountName --public-network-access Enabled
az storage account update --resource-group $resourceGroupName --name $storageAccountName --default-action Allow
Start-Sleep -Seconds 60
Now we need to identify the variables in the code, and declare parameters for each of them at the top of the template file, in the format:
parameters:
- name:
type:
From the code above, the variables we’re using are related to the service connection name, the resource group name and the storage account name. Each of them is a string, so that’s what we’ll record the type as. At the top of our new template file, above the code steps, let’s add the parameters for each of these:
# Parameters
parameters:
- name: serviceconnectionname
type: string
- name: resourcegroupname
type: string
- name: storageaccountname
type: string
Now we need to change the variables in the code steps to use these parameters. Where each of the variables is currently used in the code, we need to replace it with the following:
'${{ parameters.parametername }}'
So our existing code would become:
# Deployment Steps
steps:
- task: AzureCLI@2
displayName: Disable Storage Account Firewall
continueOnError: false
inputs:
azureSubscription: '${{ parameters.serviceconnectionname }}'
scriptType: 'pscore'
scriptLocation: 'inlineScript'
inlineScript: |
$resourceGroupName = '${{ parameters.resourcegroupname }}'
$storageAccountName = '${{ parameters.storageaccountname }}'
Write-Host "Disabling Storage Account Firewall, Please Wait..."
az storage account update --resource-group $resourceGroupName --name $storageAccountName --public-network-access Enabled
az storage account update --resource-group $resourceGroupName --name $storageAccountName --default-action Allow
Start-Sleep -Seconds 60
Your template file can be saved at this point, but we still need to call the template and pass it the parameter information from our main pipeline. In our validation.yml file, add a line calling the template as we did for our Terraform formatting template. We then need to add the parameters that we’ll send as the template is called. These will still be the variables that are stored in our variables library. We can then delete the previous task:
# Disable the Storage Account Firewall
- template: .azurepipeline/pipeline-templates/disablefirewall_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
Save all your files, check your formatting, commit and sync, then watch your validation pipeline job to make sure all is OK. You could also refresh your storage account’s firewall page to watch while it disables and enables when the commands run.
There’s one last little bit we can do to clean up our disable firewall template code. In the steps we’re declaring and using variables, that are themselves variables that have been passed in as parameters. Let’s just delete the declaration of those variables and replace them in the script lines with the parameters that are being passed directly:
inlineScript: |
Write-Host "Disabling Storage Account Firewall, Please Wait..."
az storage account update --resource-group '${{ parameters.resourcegroupname }}' --name '${{ parameters.storageaccountname }}' --public-network-access Enabled
az storage account update --resource-group '${{ parameters.resourcegroupname }}' --name '${{ parameters.storageaccountname }}' --default-action Allow
Start-Sleep -Seconds 60
So what else can we put into templates in our validation pipeline? How about the Terraform initialisation commands? The Plan? Re-enabling the firewall? By now you have the knowledge of how to do it, so I won’t go into too much depth, but here’s how I’ve done mine if you hit issues or just want to make sure your code matches my own:
Terraform Initialisation
Template:
# Template to run Terraform Initialisation
parameters:
- name: environment
type: string
- name: serviceconnectionname
type: string
- name: resourcegroupname
type: string
- name: storageaccountname
type: string
- name: containername
type: string
- name: azurermkeyname
type: string
steps:
# Terraform Initialisation
- task: TerraformTaskV4@4
displayName: Run Terraform Init (${{ parameters.environment }})
inputs:
provider: 'azurerm'
command: 'init'
backendServiceArm: '${{ parameters.serviceconnectionname }}'
backendAzureRmResourceGroupName: '${{ parameters.resourcegroupname }}'
backendAzureRmStorageAccountName: '${{ parameters.storageaccountname }}'
backendAzureRmContainerName: '${{ parameters.containername }}'
backendAzureRmKey: '${{ parameters.azurermkeyname }}'
Notice on the displayName line where I’ve changed “PRD” to a parameter called environment. There are no quotes or apostrophes, and the brackets at each side of the parameter will be displayed as they are. This means that whatever environment we’re using, we just need to pass that as a parameter, and again our code for running the init command is totally abstracted and reusable.
Main Pipeline Code:
# Terraform Initialisation
- template: .azurepipeline/pipeline-templates/terraforminit_template.yml
parameters:
environment: PRD
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
containername: '$(backend_container)'
azurermkeyname: 'terraformstatefiles/terraform_series.tfstate'
Terraform Plan
Template:
# Template to run the Terraform Plan command
# Parameters
parameters:
- name: environment
type: string
- name: commandoptions
type: string
- name: serviceconnectionname
type: string
steps:
# Terraform Plan
- task: TerraformTaskV4@4
displayName: Run Terraform Plan (${{ parameters.environment }})
inputs:
provider: 'azurerm'
command: 'plan'
commandOptions: '${{ parameters.commandoptions }}'
environmentServiceNameAzureRM: '${{ parameters.serviceconnectionname }}'
Main pipeline code:
# Terraform Plan
- template: .azurepipeline/pipeline-templates/terraformplan_template.yml
parameters:
environment: PRD
commandoptions: -var-file=config/production/do_series_prd.tfvars -lock=false -out=prdplan
serviceconnectionname: '$(backend_sc)'
Re-Enable Firewall
Template:
# Template to re-enable the Azure Storage Account firewall
# Parameters
parameters:
- name: serviceconnectionname
type: string
- name: resourcegroupname
type: string
- name: storageaccountname
type: string
# Deployment Steps
steps:
- task: AzureCLI@2
displayName: Enable Storage Account Firewall
continueOnError: false
inputs:
azureSubscription: '${{ parameters.serviceconnectionname }}'
scriptType: 'pscore'
scriptLocation: 'inlineScript'
inlineScript: |
Write-Host "Enabling Storage Account Firewall, Please Wait..."
az storage account update --resource-group '${{ parameters.resourcegroupname }}' --name '${{ parameters.storageaccountname }}' --public-network-access Disabled
Start-Sleep -Seconds 20
Main pipeline code:
# Enable the Storage Account Firewall
- template: .azurepipeline/pipeline-templates/enablefirewall_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
So that’s all our validation pipeline tasks put into templates and abstracted from any data, making them totally reusable whether in this project, another project, or for a totally different client. If we can re-use them in this project, where else can we do that though? Well we have an apply pipeline which does many of the same tasks, so we should be able to call the same templates with the same commands as in our plan pipeline. Let’s open the apply pipeline and see what we can use. We don’t currently check our Terraform formatting, because we’re relying on our validation pipeline doing that. What if somebody creates a pull request to main without checking the validation pipeline output though? Let’s just copy the line over from our validation pipeline and add it here as an extra check:
# Call the template code to check Terraform Formatting
- template: .azurepipeline/pipeline-templates/terraformformat_template.yml
Our next step is to disable the storage account firewall. We have a template for that, and we can again just copy the code over from our validation pipeline:
# Disable the Storage Account Firewall
- template: .azurepipeline/pipeline-templates/disablefirewall_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
Initialise Terraform is next. We’re applying the same production environment as we’re checking in our validation pipeline, meaning it’s the same backend state file, so we can just copy that block too:
# Terraform Initialisation
- template: .azurepipeline/pipeline-templates/terraforminit_template.yml
parameters:
environment: PRD
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
containername: '$(backend_container)'
azurermkeyname: 'terraformstatefiles/terraform_series.tfstate'
Terraform plan? Yup, same again, we can just copy that over verbatim from the validation pipeline:
# Terraform Plan
- template: .azurepipeline/pipeline-templates/terraformplan_template.yml
parameters:
environment: PRD
commandoptions: -var-file=config/production/do_series_prd.tfvars -lock=false -out=prdplan
serviceconnectionname: '$(backend_sc)'
And last off for that stage, re-enabling the storage account firewall. You guessed it, let’s just copy the whole block over:
# Enable the Storage Account Firewall
- template: .azurepipeline/pipeline-templates/enablefirewall_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
The next stage is the apply stage. We have already checked the Terraform formatting once in this pipeline, so we don’t need that, but let’s replace everything between the checkout step and the apply step with our code from the plan stage above. That’s our disabling of the firewall, the initialisation and the plan.
Now we have our apply step. We don’t have a template for Terraform apply yet, but it’s a repeatable step that we would benefit from abstracting away, so let’s create one. As usual, it’s a file in our templates folder, suitably named, with a comment, some parameters, and the code copied from our main apply pipeline (suitably indented) with the variables changed to match the parameters:
# Template to run the Terraform Apply command
# Parameters
parameters:
- name: environment
type: string
- name: commandoptions
type: string
- name: serviceconnectionname
type: string
steps:
# Terraform Apply
- task: TerraformTaskV4@4
displayName: Run Terraform Apply (${{ parameters.environment }})
inputs:
provider: 'azurerm'
command: 'apply'
commandOptions: '${{ parameters.commandoptions }}'
environmentServiceNameAzureRM: '${{ parameters.serviceconnectionname }}'
Back to our main apply pipeline, and we need to call that template, passing through the relevant variables as parameters:
# Terraform Apply
- template: .azurepipeline/pipeline-templates/terraformapply_template.yml
parameters:
environment: PRD
commandoptions: prdplan
serviceconnectionname: '$(backend_sc)'
The final step in that apply pipeline is for us to re-enable the storage account firewall. Again, we’ve already got that code above in our plan stage to call the template so let’s copy it down over our existing code.
And that’s it for that file. Save, check formatting, commit and sync, make sure your validation runs OK, pull to main, check your apply runs OK, and we’re in a great place with our pipeline code! Have a look at all the templates we’ve just created:
Nesting Templates
Although most of our code in our main pipelines is now abstracted, there are still a couple of things that we might need to edit in there for each new project or client, such as the backend state file or the variables library. One option we have here is to put most of the validation code into another YAML file that we create and call as a template, which in turn will then call the smaller templates that we’ve just created. You’d probably only do this in more complex environments, but it’s a good skill to learn so let’s do it for our validation pipeline.
Let’s create a new file in our templates folder that’s sensibly named. I’m calling mine “validate-root_template.yml”. We need a comment to say what it does, then a list of parameters, then the steps that it’s going to run. We’ll copy our code in from our validate pipeline so we get a view of what our parameters are going to be. We want all the steps from our stage:
steps:
# Check out the current repository branch
- checkout: self
# Call the template code to check Terraform Formatting
- template: .azurepipeline/pipeline-templates/terraformformat_template.yml
# Disable the Storage Account Firewall
- template: .azurepipeline/pipeline-templates/disablefirewall_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
# Terraform Initialisation
- template: .azurepipeline/pipeline-templates/terraforminit_template.yml
parameters:
environment: PRD
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
containername: '$(backend_container)'
azurermkeyname: 'terraformstatefiles/terraform_series.tfstate'
# Terraform Plan
- template: .azurepipeline/pipeline-templates/terraformplan_template.yml
parameters:
environment: PRD
commandoptions: -var-file=config/production/do_series_prd.tfvars -lock=false -out=prdplan
serviceconnectionname: '$(backend_sc)'
# Enable the Storage Account Firewall
- template: .azurepipeline/pipeline-templates/enablefirewall_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
We can see from this that there are a few parameters that we’ll need, and that they’re often repeated which will save us some lines in our main pipeline code. These are the parameters I’ve decided to use:
parameters:
- name: environment
type: string
- name: serviceconnectionname
type: string
- name: resourcegroupname
type: string
- name: storageaccountname
type: string
- name: containername
type: string
- name: azurermkeyname
type: string
- name: commandoptions
type: string
We now need to replace all the variables in our steps with those parameters, as we’ve done in all our other templates. We’ll also have to change the references to the templates it calls. This template is in the same folder as the templates it’s calling, so we need to remove the path to them and just leave the file name:
# Disable the Storage Account Firewall
- template: disablefirewall_template.yml
parameters:
serviceconnectionname: '${{ parameters.serviceconnectionname }}'
resourcegroupname: '${{ parameters.resourcegroupname }}'
storageaccountname: '${{ parameters.storageaccountname }}'
# Terraform Initialisation
- template: terraforminit_template.yml
parameters:
environment: ${{ parameters.environment }}
serviceconnectionname: '${{ parameters.serviceconnectionname }}'
resourcegroupname: '${{ parameters.resourcegroupname }}'
storageaccountname: '${{ parameters.storageaccountname }}'
containername: '$(backend_container)'
azurermkeyname: '${{ parameters.azurermkeyname }}'
# Terraform Plan
- template: terraformplan_template.yml
parameters:
environment: ${{ parameters.environment }}
commandoptions: ${{ parameters.commandoptions }}
serviceconnectionname: '${{ parameters.serviceconnectionname }}'
# Enable the Storage Account Firewall
- template: enablefirewall_template.yml
parameters:
serviceconnectionname: '${{ parameters.serviceconnectionname }}'
resourcegroupname: '${{ parameters.resourcegroupname }}'
storageaccountname: '${{ parameters.storageaccountname }}'
Now to call this template from the validation pipeline, using those parameters. Within our “steps” section, we first add a line to call the template, then add our parameters lines. We fill in those parameters with the information we have from our existing code. We can then delete all the existing code (maybe comment it out until you’ve tested your pipeline and are sure of the spellings etc.) that comes within the “steps” section under our parameters:
steps:
# Call the template that performs the actual validation
- template: .azurepipeline/pipeline-templates/validate-root_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
containername: '$(backend_container)'
azurermkeyname: 'terraformstatefiles/terraform_series.tfstate'
environment: PRD
commandoptions: -var-file=config/production/do_series_prd.tfvars -lock=false -out=prdplan
That certainly looks a lot neater to me! As usual, save all your files, check your formatting, commit and sync, then check what happens in your validation pipeline job.
Now that the validation pipeline is further abstracted, it’s time to take a look at our apply pipeline. Remembering that each stage is a completely independent piece of code, we should be calling templates from within each existing stage. Our plan stage is identical to the plan stage in our validation pipeline, so we should be able to just copy this code directly across into our main apply pipeline. The stage will still call our validation template in our templates folder:
steps:
# Call the template that performs the actual validation plan
- template: .azurepipeline/pipeline-templates/validate-root_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
containername: '$(backend_container)'
azurermkeyname: 'terraformstatefiles/terraform_series.tfstate'
environment: PRD
commandoptions: -var-file=config/production/do_series_prd.tfvars -lock=false -out=prdplan
Our apply stage however has the apply command, so it differs from our validation template. Other than that though, the code is the same, so let’s duplicate our validation template to create an apply template in the same sub-directory. We then need to add our Terraform apply step to the template, in between our plan step and the re-enable firewall step:
# Terraform Apply
- template: .azurepipeline/pipeline-templates/terraformapply_template.yml
parameters:
environment: PRD
commandoptions: prdplan
serviceconnectionname: '$(backend_sc)'
Next we need to adjust the “template” row to remove the path and just leave the file name. Finally, we’ll replace the variables with parameters. As “commandoptions” is already used in our plan step, we just need to declare a new parameter at the top of the template file and set it in the apply block. I’m using “applycommandoptions”. Note that the parameter sent to the nested template is still “commandoptions”, it’s only the value that changes:
# Terraform Apply
- template: terraformapply_template.yml
parameters:
environment: ${{ parameters.environment }}
commandoptions: ${{ parameters.applycommandoptions }}
serviceconnectionname: '${{ parameters.serviceconnectionname }}'
That completes our apply template, so now we need to call it, with all the appropriate parameters from our main apply pipeline. We can simplify the process a little. If we copy the step from the plan stage, we can just change the template file name, add our extra parameter for the apply command options, then delete our original code.
steps:
# Call the template that performs the actual apply steps
- template: .azurepipeline/pipeline-templates/apply-root_template.yml
parameters:
serviceconnectionname: '$(backend_sc)'
resourcegroupname: '$(backend_rg)'
storageaccountname: '$(backend_saname)'
containername: '$(backend_container)'
azurermkeyname: 'terraformstatefiles/terraform_series.tfstate'
environment: PRD
commandoptions: -var-file=config/production/do_series_prd.tfvars -lock=false -out=prdplan
applycommandoptions: prdplan
Now all our pipeline code is fully abstracted from the data which is held in much smaller root pipeline files. All our templates are in a sub-directory that is portable and can be re-used on other projects and clients. As is usual at this point, save all your files, check your formatting, commit and sync, check your validation pipeline runs OK, pull to main and check that your apply pipeline runs OK now too.
Summary
OK, so we’re not into advanced territory yet, but I think we’ve come a long way from where we started, and we’ve got some pretty complex functions happening within our pipelines.
This was another relatively long post, but I think it’s been more diagram / code heavy than actual text we need to read and comprehend. What we’ve learned is that pipelines can use templates to abstract the code from the data, that templates can be nested, and how to put that knowledge into practice, which I think is a pretty good thing.
In the next post I want to get into for / each loops, and really get our code nice and tight.
Until next time…
– The Zoo Keeper
One comment