Search code examples
azureazure-devopsazure-pipelinesazure-resource-managerazure-pipelines-yaml

Azure Pipeline deployment resets App configuration


I've created a simple Azure Function and I'd like to use it as an exercise of good DevOps practices. I prepared an azure-pipelines.yml, which does the following:

  1. Build the app's code
  2. Runs tests
  3. Publishes the binaries as an artifact
  4. Creates the Azure resources (App Service Plan, Azure Function, Storage Account, App Insights)
  5. Deploys the code to the Azure Function.

I heard a lot about Infrastructure as a Code, and I really wanted to try it out, that's why point 4 is there.

Here's my azure-pipelines.yml:

trigger:
- master

variables:
  azureServiceConnection: service-connection
  appName: az-func-123456123
  resourceGroup: rg-1223456123
  location: North Europe
  buildConfiguration: Release

pool:
  vmImage: 'ubuntu-latest'

stages:
- stage: CI
  jobs:
  - job: Azure_Function
    displayName: 'Azure Functions'
    steps:
      - checkout: self
      - task: DotNetCoreCLI@2
        displayName: Restore
        inputs:
          command: 'restore'
          projects: '**/*.csproj'
      
      - task: DotNetCoreCLI@2
        displayName: Build
        inputs:
          command: build
          projects: '**/*.csproj'
          arguments: '--configuration $(buildConfiguration)'

      - task: DotNetCoreCLI@2
        displayName: Test
        inputs:
          command: test
          projects: '**/*Tests/*.csproj'
          arguments: '--configuration $(buildConfiguration)'
      
      - task: DotNetCoreCLI@2
        displayName: Zip Artifact
        inputs:
          command: publish
          publishWebProjects: false
          arguments: '--no-build --configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
          zipAfterPublish: True
          workingDirectory: src/az-function-with-deployment

      - publish: $(Build.ArtifactStagingDirectory)
        artifact: AzureFunction


- stage: Deployment
  condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
  jobs:
  - deployment: Deploy
    environment: test-env
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self

          - task: AzureResourceGroupDeployment@2
            displayName: Deploy Azure resources
            inputs:
              deploymentScope: 'Resource Group'
              ConnectedServiceName: '$(azureServiceConnection)'
              action: 'Create Or Update Resource Group'
              resourceGroupName: $(resourceGroup)
              location: $(location)
              templateLocation: 'Linked artifact'
              csmFile: 'templates/function-app-deployment.json'
              deploymentMode: 'Incremental'

          - task: AzureFunctionApp@1
            displayName: Deploy Azure Function
            inputs:
              azureSubscription: $(azureServiceConnection)
              resourceGroupName: $(resourceGroup)
              appType: functionAppLinux
              appName: $(appName)
              package: $(Pipeline.Workspace)/AzureFunction/*.zip

Here's my templates/function-app-deployment.json that the AzureResourceGroupDeployment@2 task uses:

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "appName": {
      "type": "string",
      "defaultValue": "az-func-123456123",
      "metadata": {
        "description": "The name of the function app that you wish to create."
      }
    },
    "storageAccountType": {
      "type": "string",
      "defaultValue": "Standard_LRS",
      "allowedValues": [
        "Standard_LRS",
        "Standard_GRS",
        "Standard_RAGRS"
      ],
      "metadata": {
        "description": "Storage Account type"
      }
    },
    "location": {
      "type": "string",
      "defaultValue": "[resourceGroup().location]",
      "metadata": {
        "description": "Location for all resources."
      }
    },
    "appInsightsLocation": {
      "type": "string",
      "defaultValue": "[resourceGroup().location]",
      "metadata": {
        "description": "Location for Application Insights"
      }
    },
    "runtime": {
      "type": "string",
      "defaultValue": "dotnet",
      "allowedValues": [
        "node",
        "dotnet",
        "java"
      ],
      "metadata": {
        "description": "The language worker runtime to load in the function app."
      }
    }
  },
  "variables": {
    "functionAppName": "[parameters('appName')]",
    "hostingPlanName": "[parameters('appName')]",
    "applicationInsightsName": "[parameters('appName')]",
    "storageAccountName": "[concat(uniquestring(resourceGroup().id), 'azfunctions')]",
    "functionWorkerRuntime": "[parameters('runtime')]"
  },
  "resources": [
    {
      "type": "Microsoft.Storage/storageAccounts",
      "apiVersion": "2019-06-01",
      "name": "[variables('storageAccountName')]",
      "location": "[parameters('location')]",
      "sku": {
        "name": "[parameters('storageAccountType')]"
      },
      "kind": "Storage"
    },
    {
      "type": "Microsoft.Web/serverfarms",
      "apiVersion": "2020-06-01",
      "name": "[variables('hostingPlanName')]",
      "location": "[parameters('location')]",
      "kind": "linux",
      "sku": {
        "tier": "Dynamic",
        "name": "Y1"
      },
      "properties": {
        "name": "[variables('hostingPlanName')]",
        "reserved": true
      }
    },
    {
      "type": "Microsoft.Web/sites",
      "apiVersion": "2020-06-01",
      "name": "[variables('functionAppName')]",
      "location": "[parameters('location')]",
      "kind": "functionapp",
      "dependsOn": [
        "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
        "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]"
      ],
      "properties": {
        "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('hostingPlanName'))]",
        "siteConfig": {
          "appSettings": [
            {
              "name": "AzureWebJobsStorage",
              "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';EndpointSuffix=', environment().suffixes.storage, ';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2019-06-01').keys[0].value)]"
            },
            {
              "name": "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING",
              "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';EndpointSuffix=', environment().suffixes.storage, ';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2019-06-01').keys[0].value)]"
            },
            {
              "name": "WEBSITE_CONTENTSHARE",
              "value": "[toLower(variables('functionAppName'))]"
            },
            {
              "name": "FUNCTIONS_EXTENSION_VERSION",
              "value": "~2"
            },
            {
              "name": "APPINSIGHTS_INSTRUMENTATIONKEY",
              "value": "[reference(resourceId('microsoft.insights/components', variables('applicationInsightsName')), '2020-02-02-preview').InstrumentationKey]"
            },
            {
              "name": "FUNCTIONS_WORKER_RUNTIME",
              "value": "[variables('functionWorkerRuntime')]"
            }
          ],
          "linuxFxVersion": "dotnet|3.1"
        }
      }
    },
    {
      "type": "microsoft.insights/components",
      "apiVersion": "2020-02-02-preview",
      "name": "[variables('applicationInsightsName')]",
      "location": "[parameters('appInsightsLocation')]",
      "tags": {
        "[concat('hidden-link:', resourceId('Microsoft.Web/sites', variables('applicationInsightsName')))]": "Resource"
      },
      "properties": {
        "ApplicationId": "[variables('applicationInsightsName')]",
        "Request_Source": "IbizaWebAppExtensionCreate"
      }
    }
  ]
}

The deployment works and I get my resources created. However, I noticed a few issues:

  1. With each deployment, my App's configuration is reset! Currently, after the deployment, I manually add all the configuration values to my Azure Function, which is pretty inconvenient. I guess I could do the configuration in the pipeline itself, however, I don't want to hardcode any values there! It doesn't seem right. I haven't found any resource/best practices about the configuration of applications in the cloud. What am I missing?
  2. Some of the values in my pipeline are hardocded (like app's name). I don't really see any issue with that currently, but I wonder, is it how the things should be? May I improve anything?

In general, I'll be happy to see any comments about both the YAML and JSON that I posted. I am sure there's a lot of place for improvement.


Solution

  • The issue that you run ARM deployment each time you want to deploy function. So it provides you AppSettings only those declared in template. Please take a look here - Don't delete AppSettings not declared in a template.

    What you can do to approach this?!

    1. Move ARM template deployment to separate pipeline - the one dedicated just to update infrastructure for you pipeline. There is no need to redeploy infrastructure each time you want to deploy code. You can use path filter to be sure that your pipeline will run only when proper changes was done. But, this doesn't solve issue with removing app settings. It makes that it happens less often.

    2. To address issue with deleted settings you need to do one of the thing:

      • handle them in ARM template
      • call Azure CLI before running ARM template to store appsetings/connection strings in file, deploy ARM template, run Azute CLI to update appsettings/connection strings