If you are using a monorepo in Azure DevOps, big chances are that you want configure the pipelines for running the CI/CD in a custom way for each project inside the solution. That is when the parameters and templates that Azure DevOps provides become handy.
Lets have a look to the following simple (but realistic) example that I encounter at work few days ago.
Suppose that you have a .NET Solution containing several projects, in this case, Azure Functions Projects.
Solution
|--> AzureFunction.A
|--> AzureFunction.B
Because they are Azure Functions, naturally you want to deploy each project in a given Function App on Azure. Also, we don’t want to build and deploy AzureFunction.B if we only made changes on AzureFunction.A.
My recommendation to anyone new to fighting with the setup of pipelines is to start with the most straightforward way and then, once working, start to split things and organize them. And that is what we are going to do.
I’m going to assume that you have a basic knowledge on Yaml file format and know the differences between stage, job and step inside a pipeline. If that’s not the case, have a look to this stackoverflow answer.
Build & Deploy Azure Function
Let’s start just making a pipeline to build and deploy AzureFunction.A project. On Azure DevOps portal, go to:
Pipelines -> New pipeline -> Choose Repository -> .Net Core Function App to (Windows/Linux) on Azure
You will have to select an Azure subscription, a function app name and a project from the solution. Once done, this will configure you a ready-to-go pipeline file. You should get something quite similar to the next code:
# azure-pipeline.yml
trigger:
- main
variables:
azureSubscription: "magic-string"
functionAppName: "cool-function-app"
workingDirectory: "$(System.DefaultWorkingDirectory)/AzureFunction.A"
stages:
- stage:
displayName: Build stage
jobs:
- job:
displayName: Build
steps:
- task: DotNetCoreCLI@2
displayName: Build
inputs:
command: "build"
project: $(workingDirectory)/*.csproj
arguments: # value not relevant for the sample
- task: ArchiveFile@2
displayName: "Archive Files"
inputs: # inputs values not relevant for the sample
- publish: # value not relevant for the sample
artifact: drop
- stage:
displayName: Deploy
dependsOn: Build
condition: succeeded()
jobs:
- deployment: Deploy
displayName: Deploy
environment: 'development'
strategy:
runOnce:
deploy:
steps:
- task: AzureFunctionApp@1
displayName: 'Azure functions app deploy'
inputs:
azureSubscription: $(azureSubscription)
appName: $(functionAppName)
appType: # value not relevant for the sample
package: # value not relevant for the sample
This is simplified a little bit for our sanity and to focus only on the important stuff. We can see two stages, build and deploy, each with one or more task related.
If we want to make an other pipeline for AzureFunction.B we would have to create an other file almost identical to this one.
Split Stages
If you want to add a job to any stage, you would have to go trough each pipeline file and change it. To avoid that, we are going to split the stages into their own files. This is called a template.
# build-template.yml
parameters:
- name: workingDirectory
type: string
default: ""
steps:
- task: DotNetCoreCLI@2
displayName: Build
inputs:
command: "build"
projects: "${{ parameters.workingDirectory }}/*.csproj"
arguments: # value not relevant for the sample
- task: ArchiveFiles@2
displayName: "Archive files"
inputs: # inputs values not relevant for the sample
- publish: # value not relevant for the sample
artifact: drop
First thing to notice is the parameters section. In there we define the name, type and default value of the parameter to receive.
For making use of it just write ${{ parameters.name }}
In the previous sample, we are saying that this template will receive one parameter and then we use it to select the folder of the project we want to search for csproj inside, forcing to build only that specific project.
Now let’s do the same for the deploy stage.
# deploy-template.yml
parameters:
- name: azureSubscription
type: string
default: ""
- name: functionAppName
type: string
default: ""
steps:
- task: AzureFunctionApp@1
displayName: "Azure functions app deploy"
inputs:
azureSubscription: "${{ parameters.azureSubscription }}"
appName: "${{ parameters.functionAppName }}"
appType: # value not relevant for the sample
package: # value not relevant for the sample
As we can se, this time we have 2 parameters that are later use for deploying the function.
Composing templates
Now that we have the templates for each stage, lets join them together. I created a azure-functions-template.yml file that wraps the 2 previous templates and compose them. Have a look.
# azure-functions-template.yml
parameters:
- name: workingDirectory
type: string
default: ""
- name: azureSubscription
type: string
default: ""
- name: functionAppName
type: string
default: ""
stages:
- stage: Build
displayName: Build stage
jobs:
- job: Build
displayName: Build
steps:
- template: ./build-template.yml
parameters:
workingDirectory: ${{ parameters.workingDirectory }}
- stage: Deploy
displayName: Deploy stage
dependsOn: Build
condition: succeeded()
jobs:
- deployment: Deploy
displayName: Deploy
environment: "development"
strategy:
runOnce:
deploy:
steps:
- template: ./deploy-template.yml
parameters:
azureSubscription: "${{ parameters.azureSubscription }}"
functionAppName: "${{ parameters.functionAppName }}"
Again, first thing first, the parameters for all the templates are defined first. Then we compose the stages of the pipeline and for the steps we call template and give it the name of the template to use.
Once the template it called, is time to pass down the parameters.
Now we can reuse azure-functions-template.yml to create the actual pipelines.
The real pipeline
# functionA.yml
trigger:
branches:
include:
- main
paths:
include:
- AzureFunction.A
extends:
template: azure-functions-template.yml
parameters:
workingDirectory: "$(System.DefaultWorkingDirectory)/AzureFunctions.A"
azureSubscription: "magic string"
functionAppName: "wonderful-function-A-app-name"
# functionB.yml
trigger:
branches:
include:
- main
paths:
include:
- AzureFunction.B
extends:
template: azure-functions-template.yml
parameters:
workingDirectory: "$(System.DefaultWorkingDirectory)/AzureFunction.B"
azureSubscription: "magic string"
functionAppName: "wonderful-function-B-app-name"
Code is so little at this point that is pretty much self-explainable.
Magic word here is extends followed by the template to extend, and of course, the parameters.
- workingDirectory is where we select the project to compile and build. $(System.DefaultWorkingDirectory) is the root of the repo.
- azureSubscription is the service connection
- functionAppName is the name of the function app to deploy.
The path on the trigger section is to only trigger the pipeline when changes are done on this specific project.
This is as minimalist as it can get. We have decouple all stages making our pipeline file simpler and reusable.
Visual resume
----------------
| function A/B | # actual pipeline
----------------
|
| extends
----------------------------
| azure-functions-template | # compose several stages
----------------------------
| |
extends | | extends
------------------ -------------------
| build-template | | deploy-template | # individual stages and tasks
------------------ -------------------
Hope it helps 😊