Part 3: Setting up a Release Pipeline in Azure DevOps

This is Part 3 in a  series on Azure DevOps.

In  Part 1 I created a simple web app called WidgetApi. I then put it under source control and pushed it up to an Azure DevOps repo. In Part 2 I configured a build pipeline and made a code change to trigger that build with continuous integration. In this part we’re going to create a release pipeline that deploys our build artifacts to Azure.

Let’s Add an ARM Template

We’re going to get this thing into Azure and that means creating a resource group, a database server with a SQL Azure database (we’ll need it in Part 4), and an app service with a service plan. And we might as well set up some instrumentation with Application Insights while we’re at it. So add a new folder to the solution root called ARMTemplates and drop this file into it:

{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {
        "webAppName": {
            "type": "string"
        },
        "hostingPlanName": {
            "type": "string"
        },
        "appInsightsLocation": {
            "type": "string"
        },
        "sku": {
          "type": "string",
          "defaultValue": "Standard S1"
        },
        "databaseServerName": {
            "type": "string"
        },
        "databaseUsername": {
            "type": "string"
        },
        "databasePassword": {
            "type": "securestring"
        },
        "databaseLocation": {
            "type": "string"
        },
        "databaseName": {
            "type": "string"
        }
    },
    "resources": [
        {
            "type": "Microsoft.Web/sites",
            "name": "[parameters('webAppName')]",
            "apiVersion": "2016-08-01",
            "location": "[resourceGroup().location]",
            "tags": {
                "[concat('hidden-related:', '/subscriptions/', subscription().subscriptionId,'/resourcegroups/', resourceGroup().name, '/providers/Microsoft.Web/serverfarms/', parameters('hostingPlanName'))]": "empty"
            },
            "resources": [
                {
                    "type": "siteextensions",
                    "name": "Microsoft.ApplicationInsights.AzureWebSites",
                    "apiVersion": "2015-08-01",
                    "dependsOn": [
                        "[resourceId('Microsoft.Web/Sites', parameters('webAppName'))]"
                    ],
                    "properties": {
                    }
                }
            ],
            "properties": {
                "siteConfig": {
                    "appSettings": [
                        {
                            "name": "APPINSIGHTS_INSTRUMENTATIONKEY",
                            "value": "[reference(resourceId('microsoft.insights/components/', parameters('webAppName')), '2015-05-01').InstrumentationKey]"
                        },
                        {
                            "name": "WEBSITE_NODE_DEFAULT_VERSION",
                            "value": "6.9.1"
                        }
                    ],
                    "phpVersion": "7.1"
                },
                "name": "[parameters('webAppName')]",
                "serverFarmId": "[concat('/subscriptions/', subscription().subscriptionId,'/resourcegroups/', resourceGroup().name, '/providers/Microsoft.Web/serverfarms/', parameters('hostingPlanName'))]",
                "hostingEnvironment": ""
            },
            "dependsOn": [
                "[concat('Microsoft.Web/serverfarms/', parameters('hostingPlanName'))]",
                "[resourceId('microsoft.insights/components/', parameters('webAppName'))]"
            ]
        },
        {
            "type": "Microsoft.Web/serverfarms",
            "sku": {
                "Tier": "[first(skip(split(parameters('sku'), ' '), 1))]",
                "Name": "[first(split(parameters('sku'), ' '))]"
            },
            "name": "[parameters('hostingPlanName')]",
            "apiVersion": "2015-08-01",
            "location": "[resourceGroup().location]",
            "properties": {
                "name": "[parameters('hostingPlanName')]",
                "workerSizeId": "0",
                "reserved": false,
                "numberOfWorkers": "1",
                "hostingEnvironment": ""
            }
        },
        {
            "type": "Microsoft.Insights/components",
            "name": "[parameters('webAppName')]",
            "apiVersion": "2014-04-01",
            "location": "[parameters('appInsightsLocation')]",
            "tags": {
                "[concat('hidden-link:', resourceGroup().id, '/providers/Microsoft.Web/sites/', parameters('webAppName'))]": "Resource"
            },
            "properties": {
                "applicationId": "[parameters('webAppName')]",
                "Request_Source": "AzureTfsExtensionAzureProject"
            }
        },
      {
        "type": "Microsoft.Sql/servers",
        "name": "[parameters('databaseServerName')]",
        "apiVersion": "2014-04-01-preview",
        "location": "[parameters('databaseLocation')]",
        "properties": {
          "administratorLogin": "[parameters('databaseUsername')]",
          "administratorLoginPassword": "[parameters('databasePassword')]",
          "version": "12.0"
        },
        "resources": [
          {
            "type": "databases",
            "name": "[parameters('databaseName')]",
            "apiVersion": "2014-04-01-preview",
            "location": "[parameters('databaseLocation')]",
            "properties": {
              "collation": "SQL_Latin1_General_CP1_CI_AS",
              "maxSizeBytes": "2147483648",
              "requestedServiceObjectiveId": "dd6d99bb-f193-4ec1-86f2-43d3bccbc49c"
            },
            "dependsOn": [
              "[concat('Microsoft.Sql/servers/', parameters('databaseServerName'))]"
            ]
          },
          {
            "type": "firewallrules",
            "name": "AllowAllWindowsAzureIps",
            "apiVersion": "2014-04-01-preview",
            "location": "[parameters('databaseLocation')]",
            "properties": {
              "endIpAddress": "0.0.0.0",
              "startIpAddress": "0.0.0.0"
            },
            "dependsOn": [
              "[concat('Microsoft.Sql/servers/', parameters('databaseServerName'))]"
            ]
          }
        ]
      }
    ]
}

Add the file as an existing item to the solution (not the project) and commit these changes to your repo. Your repo should look like this after the push:

Include the ARM Template in the Build

We need to add a Copy Files task to the build pipeline to get that template into the $(Build.ArtifactStagingDirectory) where the release pipeline can use it. Make sure you pick the right source folder and just use wildcards for Contents. Here is my build:

Let’s Create a Release Pipeline

In your Azure DevOps portal go into your project and select Releases.  Looks like it’s time to click that magic button to create a new one.

The Visual Designer will display with an empty artifact and stage. You must pick a template for the release. Choose Azure App Server deployment and click Apply.

Give the stage the name Development and add your build as the artifact. Here you can see I gave my artifact the alias name Drop.

Notice the artifact lightning bolt which denotes a continuous deployment (CD) trigger. You can click on it to configure its settings. Right now it is set to do a CD to our Development stage. On the left side of the Development stage is another lightning bolt for the pre-deployment conditions. You can click on that too and configure gates, approvals, or schedule the release as a nightly build. For our purposes I’m not going to change anything. I want a CI/CD pipeline that takes us all the way through the Development stage when code is checked in.

Let’s Configure the Release Pipeline

This is a little gotcha (or at least it was for me) but to configure the tasks in the release pipeline you have to click the blue link that says “1 job, 1 task” instead of the box or the word “Development.” When you do notice that the task needs to be configured. Provide the service principal name, app type, and enter a unique app service name that won’t conflict with other services in the azurewebsites.net domain. I just appended a random string to mine to guarantee uniqueness.

Add the ARM Template

The last thing we need to do is add an Azure Resource Group Deployment task to the release pipeline and drag it up to the top so it runs first. This task will take our ARM template and create all of the resources we need on Azure.

Just like before the task needs to run under the service principal. I named my resource group widgetapi-rg and put it in West US 2:

Below these fields are some template parameters we need to provide. Here’s a screenshot:

The template and its parameters are in our ARM template at:

$(System.DefaultWorkingDirectory)/**/azure-arm-template.json

The override parameters give the ARM template everything it needs to set up the resource group, app service, app insights service, hosting plan, database server, and database:

-webAppName WidgetApi-6483468285 
-hostingPlanName WidgetApi-6483468285-Plan 
-appInsightsLocation "West US 2" 
-sku "S1 Standard" 
-databaseServerName widgetapi-example-db-server 
-databaseUsername widgetadmin 
-databasePassword P@ssw0rd 
-databaseLocation "West US 2" 
-databaseName widgetapi-example-db

Of course you wouldn’t really want to hard-code a password in there for a production app. Later I’ll discuss how to fetch a password from Azure Key Vault and pass it in as a variable to the ARM template. But for now this will do just fine.

Queue Up A Build

Either push a commit to trigger the CI or just manually queue up a new build. This will take several minutes to run. And you will get email notifications on successful builds. You can turn those off while you’re implementing and troubleshooting a pipeline and then turn them on later when you’re finished. As I write this my release is chugging along:

The ARM template will take the longest to run. Even though “infrastructure is code” it’s still not trivial to create a database server and a database. It takes time. After a while go into the widgetapi-rg and you should see the resources there:

If I go to the URL for the API at /api/widget I can see that it returns a list of widgets from the GET operation as expected:

Conclusion

We now have a full CI/CD pipeline in place. In the next part we’re going to add a database project to the solution and change the controller to return them from the database.