J’ai récemment eu besoin de mettre en place un mécanisme permettant l’ajout automatique de tags sur des ressources Azure. Il s’agissait de positionner des tags pour tracer les modifications sur des ressources (auteur et date de création/modification). J’en profite donc pour faire un feedback technique, et aborder au passage la brique Azure Event Grid.

Comment ça fonctionne ?

Le fonctionnement est globalement assez simple, et ne nécessite que 2 composants :

  • Un Event Grid, qui sera chargé d’intercepter les évènements Azure indiquant la création ou la modification d’une ressource
  • Une Azure Function, qui sera chargée de réaliser les opérations de création et de mise à jour des tags sur les ressources, et qui sera déclenchée via l’Event Grid.
Architecture du système Architecture du système

Je vais maintenant détailler les 2 composants, et nous allons commencer par l’Event Grid.

Azure Event Grid

Azure Event Grid est un répartiteur d’évènements, complètement managé, et basé sur le modèle “pub/sub”.

Pour commencer, Event Grid a besoin de sources d’évènements (aka. event source). Il en supporte un grand nombre, en particulier via les services Azure mais aussi via des services personnalisés ou des plateformes partenaires. Dans notre cas, on souhaite surveiller l’ensemble des ressources, on va donc prendre la source d’évènement de plus haut niveau : l’abonnement Azure.

Nous allons ensuite avoir besoin d’une rubrique (aka. topic). Il s’agit du point de terminaison où la source envoie les évènements. Dans notre cas, il s’agit d’une rubrique système (aka. system topic) car on consomme des évènements publiés par un service Azure.

La source d’évènement de plus “haut niveau” étant l’abonnement (aka. souscription), il sera nécessaire de créer plusieurs rubriques si vous avez plusieurs abonnements à couvrir (par exemple, on ne peut pas cibler un groupe d’administration/management group).

On doit aussi indiquer à notre rubrique les évènements que l’on souhaite y recevoir. Chaque source va avoir ses propres types, et dans notre cas, c’est le type Microsoft.Resources.ResourceWriteSuccess qui va nous intéresser (“déclenché quand une opération de création ou de mise à jour réussit”). Il en existe d’autres comme ResourceWriteFailure, ResourceActionCancel ou encore ResourceDeleteSuccess.

Voici un exemple du contenu d’un évènement survenant lors de la création d’un compte de stockage :

# Microsoft.Resources.ResourceWriteSuccess

[{
  "subject": "/subscriptions/{subscription-id}/resourcegroups/{resource-group}/providers/Microsoft.Storage/storageAccounts/{storage-name}",
  "eventType": "Microsoft.Resources.ResourceWriteSuccess",
  "eventTime": "2018-07-19T18:38:04.6117357Z",
  "id": "4db48cba-50a2-455a-93b4-de41a3b5b7f6",
  "data": {
    "authorization": {
      "scope": "/subscriptions/{subscription-id}/resourcegroups/{resource-group}/providers/Microsoft.Storage/storageAccounts/{storage-name}",
      "action": "Microsoft.Storage/storageAccounts/write",
      "evidence": {
        "role": "Subscription Admin"
      }
    },
    "claims": {
      "aud": "{audience-claim}",
      "iss": "{issuer-claim}",
      "iat": "{issued-at-claim}",
      "nbf": "{not-before-claim}",
      "exp": "{expiration-claim}",
      "_claim_names": "{\"groups\":\"src1\"}",
      "_claim_sources": "{\"src1\":{\"endpoint\":\"{URI}\"}}",
      "http://schemas.microsoft.com/claims/authnclassreference": "1",
      "aio": "{token}",
      "http://schemas.microsoft.com/claims/authnmethodsreferences": "rsa,mfa",
      "appid": "{ID}",
      "appidacr": "2",
      "http://schemas.microsoft.com/2012/01/devicecontext/claims/identifier": "{ID}",
      "e_exp": "{expiration}",
      "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname": "{last-name}",
      "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": "{first-name}",
      "ipaddr": "{IP-address}",
      "name": "{full-name}",
      "http://schemas.microsoft.com/identity/claims/objectidentifier": "{ID}",
      "onprem_sid": "{ID}",
      "puid": "{ID}",
      "http://schemas.microsoft.com/identity/claims/scope": "user_impersonation",
      "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": "{ID}",
      "http://schemas.microsoft.com/identity/claims/tenantid": "{ID}",
      "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": "{user-name}",
      "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn": "{user-name}",
      "uti": "{ID}",
      "ver": "1.0"
    },
    "correlationId": "{ID}",
    "resourceProvider": "Microsoft.Storage",
    "resourceUri": "/subscriptions/{subscription-id}/resourcegroups/{resource-group}/providers/Microsoft.Storage/storageAccounts/{storage-name}",
    "operationName": "Microsoft.Storage/storageAccounts/write",
    "status": "Succeeded",
    "subscriptionId": "{subscription-id}",
    "tenantId": "{tenant-id}"
  },
  "dataVersion": "2",
  "metadataVersion": "1",
  "topic": "/subscriptions/{subscription-id}"
}]

Enfin, il reste à diffuser les évènements de la rubrique vers leur cible, et c’est le gestionnaire d’évènements qui s’en charge (aka. endpoint). Event Grid prend en charge plusieurs services Azure (Function, Event Hub, file d’attente, Service Bus…) mais aussi des services autres via les Webhooks. Dans notre cas, c’est l’Azure Function qui va nous intéresser.

Le gestionnaire d’évènements propose quelques fonctionnalités “avancées” :

  • la configuration du retry (configuration du nombre de tentatives et durée de vie de l’évènement)
  • la rétention des évènements non délivrés dans un compte de stockage
  • la possibilité de regrouper les évènements en batch
  • la personnalisation des propriétés de l’évènement au moment de l’envoi
  • les filtres avancés

La partie filtres avancés va nous être utile, notamment pour filtrer les évènements concernant les écritures de tag et les déploiements ou encore les groupes de ressources.

Certaines ressources ne supportent pas les tags, il peut être pertinent de les filtrer ici. Microsoft fournit une page indiquant les ressources qui supportent ou non les tags : https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/tag-support.

Azure Function

Une fois que l’on a un moyen d’être prévenu des évènements qui nous intéressent (la création/mise à jour de ressources), on peut passer à l’action : l’écriture des tags.

Pour cela, nous allons utiliser une Azure Function, et celle-ci sera donc déclenchée via notre Event Grid (pensez à choisir le bon type au moment de la création dans Visual Studio).

Création d'une Azure Function avec Visual Studio Création d'une Azure Function avec Visual Studio

Point important, étant donné que c’est votre Azure Function qui va réaliser les opérations d’écriture/mise à jour des tags, il est nécessaire de lui donner les bons droits pour le faire via la création d’une identité système (aka. system assigned identity) :

  • Reader au niveau de l’abonnement
  • Tag Contributor au niveau de l’abonnement

Activation de l'identité système au niveau de l'Azure Function Activation de l'identité système au niveau de l'Azure Function
Affectation des rôles à l'identité système Affectation des rôles à l'identité système

Road to the code !

Dans la littérature, j’ai trouvé beaucoup (exclusivement ?) de codes basés sur PowerShell. Je me suis donc dit, pourquoi ne pas plutôt le faire en C#, d’autant plus que je trouvais la plupart des scripts PS vraiment verbeux…

Du coup, je me suis renseigné sur le package Azure.ResourceManager.Resources, qui est une bibliothèque .NET permettant de manipuler nos ressources Azure.

using Azure.Core;
using Azure.Identity;
using Azure.Messaging.EventGrid;
using Azure.Messaging.EventGrid.SystemEvents;
using Azure.ResourceManager;
using Azure.ResourceManager.Resources.Models;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.EventGrid;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;

namespace AutoTagger
{
    public static class AutoTagger
    {
        const string CREATED_BY = "AUTO_CreatedBy";
        const string CREATED_DATE = "AUTO_CreatedDate";
        const string MODIFIED_BY = "AUTO_ModifiedBy";
        const string MODIFIED_DATE = "AUTO_ModifiedDate";

        [FunctionName("AutoTagger")]
        public static async Task Run([EventGridTrigger]EventGridEvent eventGridEvent, ILogger log)
        {
            try
            {
                log.LogInformation(eventGridEvent.Data.ToString());

                var data = eventGridEvent.Data.ToObjectFromJson<ResourceWriteSuccessEventData>();

                var id = new ResourceIdentifier(data.ResourceUri);
                var client = new ArmClient(new DefaultAzureCredential());

                var genericResource = client.GetGenericResource(id);
                var resource = genericResource.Get();

                var tagResource = resource.Value.GetTagResource();
                var tag = tagResource.Get();

                var tagPatch = new TagResourcePatch
                {
                    PatchMode = TagPatchMode.Merge
                };

                if (!tag.Value.Data.TagValues.ContainsKey(CREATED_BY))
                {
                    log.LogInformation($"Add tag: {CREATED_BY}");
                    tagPatch.TagValues.Add(CREATED_BY, data.ClaimsValue["name"]);
                }
                if (!tag.Value.Data.TagValues.ContainsKey(CREATED_DATE))
                {
                    log.LogInformation($"Add tag: {CREATED_DATE}");
                    tagPatch.TagValues.Add(CREATED_DATE, DateTimeOffset.Now.ToString());
                }

                log.LogInformation($"Add/update tag: {MODIFIED_BY}");
                tagPatch.TagValues.Add(MODIFIED_BY, data.ClaimsValue["name"]);
                
                log.LogInformation($"Add/update tag: {MODIFIED_DATE}");
                tagPatch.TagValues.Add(MODIFIED_DATE, DateTimeOffset.Now.ToString());                

                log.LogInformation("Saving Tags");
                await tag.Value.UpdateAsync(tagPatch);

                log.LogInformation($"Operation done on resource {data.ResourceUri}");
            }
            catch(Exception ex)
            {
                log.LogError($"An error is occurred... : {ex}");
            }
        }
    }
}

Le code obtenu pour réaliser ce que l’on veut est finalement assez simple et il se comprend plutôt bien.

A noter, la classe ResourceWriteSuccessEventData qui représente l’objet JSON envoyé par l’EventGrid et qui permet donc de le manipuler plus facilement.

Et si on faisait un peu de IaC ?

Maintenant qu’on a compris le fonctionnement global du système, et que l’on a le code de notre Azure Function, il faut mettre tout ça en musique ! On pourrait très bien reprendre étape par étape et créer/configurer les différentes briques à la main via le portail (ou via la CLI d’ailleurs). Mais ce qui serait encore mieux, ce serait de pouvoir le déployer automatiquement. Vous voyez ou je veux en venir… ?

Allez c’est parti, on va écrire un script Bicep qui va nous créer, déployer et configurer ça aux petits oignons :D

Etant donné que je voulais que mon script déploie l’infrastructure ET le code de l’Azure Function, et qu’il faut que cette dernière soit créée avant l’EventGrid, j’ai du découper mon script Bicep en modules que j’ordonnance ensuite via un script Bash. Cela me permet d’intégrer le déploiement du code C# dans le même processus.

Je me retrouve donc avec 4 modules (aka. fichiers Bicep) et un script Bash. Nous allons parcourir tout cela ensemble.

resourceGroup.bicep

La 1ère étape est de créer le groupe de ressource. Pas de difficultés particulières, mais pensez à bien préciser le scope au niveau de la souscription ;)

param location string
param rgName string

targetScope = 'subscription'

resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = {
  name: rgName
  location: location
}

function.bicep

La 2nd étape est de créer l’ensemble des composants nécessaires au bon fonctionnement de notre Azure Function, c’est-à-dire Application Insights, un compte de stockage, un plan et bien sûr notre fonction.

Là encore, on est sûr du standard, et l’auto-complétion Bicep facilite grandement l’écriture. Honnêtement, c’est très intuitif. En cas de doute, n’hésitez pas à vous référer à la documentation Microsoft. Elle fournit la syntaxe complète pour chaque service ainsi que de nombreux exemples de code (https://learn.microsoft.com/en-us/azure/app-service/samples-bicep et https://github.com/Azure/azure-docs-bicep-samples).

A noter la partie AppSettings de l’Azure Function, qui nécessite plusieurs clés pour que l’ensemble des services soient correctement branchés.

param location string
param storageAccountName string
param hostingPlanName string
param functionAppName string
param applicationInsightsName string

resource storageAccount 'Microsoft.Storage/storageAccounts@2022-05-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

resource hostingPlan 'Microsoft.Web/serverfarms@2022-03-01' = {
  name: hostingPlanName
  location: location
  sku: {
    name: 'Y1'
    tier: 'Dynamic'
  }
  properties: {}
}

resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: applicationInsightsName
  location: location
  kind: 'web'
  properties: {
    Application_Type: 'web'
    Request_Source: 'rest'
  }
}

resource functionApp 'Microsoft.Web/sites@2022-03-01' = {
  name: functionAppName
  location: location
  kind: 'functionapp'
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: hostingPlan.id
    siteConfig: {
      appSettings: [
        {
          name: 'AzureWebJobsStorage'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING'
          value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccountName};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}'
        }
        {
          name: 'WEBSITE_CONTENTSHARE'
          value: toLower(functionAppName)
        }
        {
          name: 'FUNCTIONS_EXTENSION_VERSION'
          value: '~4'
        }
        {
          name: 'APPINSIGHTS_INSTRUMENTATIONKEY'
          value: applicationInsights.properties.InstrumentationKey
        }
        {
          name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
          value: 'InstrumentationKey=${applicationInsights.properties.InstrumentationKey}'
        }
        {
          name: 'FUNCTIONS_WORKER_RUNTIME'
          value: 'dotnet'
        }
      ]
      minTlsVersion: '1.2'
      ftpsState: 'FtpsOnly'
      netFrameworkVersion: 'v6.0'
    }
    httpsOnly: true
  }
}

module rbac 'rbac.bicep' = {
  name: 'rbacDeploy'
  scope: subscription()
  params: {
    principalId: functionApp.identity.principalId
  }
}

output functionAppId string = functionApp.id
output functionAppPrincipalId string = functionApp.identity.principalId

rbac.bicep

Vous l’avez probablement noté dans l’étape 2, à la fin du script, je fais appel à un autre module : rbac.bicep.

Ce module est chargé d’affecter les rôles Reader et Tag Contributor à l’identité système de notre Azure Function (rappelez-vous, elle en a besoin pour écrire les tags sur les ressources).

Comme pour la création du groupe de ressource, on positionne le scope au niveau de la souscription. C’est d’ailleurs pour ça que j’ai créé un module, car on ne peut pas changer le scope au milieu d’un fichier Bicep.

Concernant les rôles, ça se passe en 2 étapes :

  1. Récupérer le rôle built-in à partir de son identifiant (que l’on trouve facilement dans la documentation MS)
  2. Affecter ce rôle à notre identité via son principalId
param principalId string

targetScope = 'subscription'

@description('This is the built-in Reader role. See https://docs.microsoft.com/azure/role-based-access-control/built-in-roles#reader')
resource readerRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
  scope: subscription()
  name: 'acdd72a7-3385-48ef-bd42-f606fba81ae7'
}

@description('This is the built-in Tag Contributor role.')
resource tagContributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
  scope: subscription()
  name: '4a9ae827-6dc8-4573-8ac7-8239d42aa03f'
}

resource readerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(subscription().id, principalId, readerRoleDefinition.id)
  properties: {
    roleDefinitionId: readerRoleDefinition.id
    principalId: principalId
    principalType: 'ServicePrincipal'
  }
}

resource tagContributorRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(subscription().id, principalId, tagContributorRoleDefinition.id)
  properties: {
    roleDefinitionId: tagContributorRoleDefinition.id
    principalId: principalId
    principalType: 'ServicePrincipal'
  }
}

eventGrid.bicep

Enfin, 3ème et dernière étape, la création du topic et la souscription aux évènements au niveau de l’EventGrid.

C’est à ce moment-là que l’on a besoin de l’identifiant de notre fonction, et c’est pour cela qu’il faut qu’elle soit déployée avant d’arriver ici.

Vous noterez que l’on peut faire absolument tout ce que permet l’UI du portail, comme la configuration du type d’évènement ou encore le paramétrage des filtres avancés.

param eventGridTopicName string
param eventGridSubscriptionName string
param functionAppName string

resource eventGridSystemTopic 'Microsoft.EventGrid/systemTopics@2022-06-15' = {
  name: eventGridTopicName
  location: 'global'
  properties: {
    source: subscription().id
    topicType: 'Microsoft.Resources.Subscriptions'
  }
}

resource eventGridSubscription 'Microsoft.EventGrid/systemTopics/eventSubscriptions@2022-06-15' = {
  parent: eventGridSystemTopic
  name: eventGridSubscriptionName
  properties: {
    destination: {
      endpointType: 'AzureFunction'
      properties: {
        resourceId: '${subscription().id}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Web/sites/${functionAppName}/functions/AutoTagger'
      }
    }
    filter: {
      includedEventTypes: [
        'Microsoft.Resources.ResourceWriteSuccess'
      ]
      enableAdvancedFilteringOnArrays: true
      advancedFilters: [        
        {
            values: [
                'Microsoft.Resources/tags/write'
                'Microsoft.Resources/deployments/write'
                'Microsoft.Resources/subscriptions/resourceGroups/write'
                'Microsoft.EventGrid/systemTopics/eventSubscriptions/write'
            ]
            operatorType: 'StringNotIn'
            key: 'data.operationName'
        }
      ]
    }
    eventDeliverySchema: 'EventGridSchema'
  }
}

deploy.sh

Il ne reste plus qu’à mettre en musique tous ces fichiers Bicep, et ajouter l’étape de déploiement de l’Azure Function. Pour cela, nous allons passer par un script Bash qui utilise l’Azure CLI.

Avant cela, il est nécessaire de compiler le code C# de notre Azure Function, soit via Visual Studio, soit via la CLI : dotnet publish --configuration Release "../AutoTagger"

Le résultat doit ce trouver dans un dossier ressemblant à ça : bin/Release/net6.0/publish. Il suffit de zipper le contenu de ce dossier (host.json, un dossier bin avec principalement des DLLs et un dossier AutoTagger avec un fichier function.json) et c’est gagné :)

Voyons voir ce script maintenant…

#!/bin/bash

# Parameters
location="francecentral"
rgName="man-autotagger"
storageName="manautotaggerstorage"
hostingPlanName="man-autotagger-plan"
functionAppName="man-autotagger-function"
appInsightsName="man-autotagger-insights"
eventGridTopicName="autotagger-topic"
eventGridSubscriptionName="autotagger-subscription"
functionAppZipPath="C:\source\AutoTagger\bin\Release\net6.0\publish\publish.zip"

echo "Deploy resource group..."
az deployment sub create \
    --name DeployResourceGroup \
    --template-file resourceGroup.bicep \
    --location $location \
    --parameters location=$location rgName=$rgName \

echo "Deploy function app (storage + plan + app insights)..."
az deployment group create \
    --template-file function.bicep \
    --resource-group $rgName \
    --parameters location=$location storageAccountName=$storageName hostingPlanName=$hostingPlanName functionAppName=$functionAppName applicationInsightsName=$appInsightsName

echo "Deploy function app code..."
az webapp deployment source config-zip -g $rgName -n $functionAppName --src $functionAppZipPath

echo "Restart function app..."
az functionapp restart --resource-group $rgName --name $functionAppName

echo "Deploy event grid..."
az deployment group create \
    --template-file eventgrid.bicep \
    --resource-group $rgName \
    --parameters functionAppName=$functionAppName eventGridTopicName=$eventGridTopicName eventGridSubscriptionName=$eventGridSubscriptionName

Les 1ères lignes se chargent de définir plusieurs variables, principalement les noms des ressources que l’on va générer, mais aussi la localisation et le chemin du zip contenant le code de notre Azure Function.

Ensuite, on exécute les fichiers Bicep chargés de créer le groupe de ressource ainsi que l’Azure Function (en prenant soin, à chaque fois, de passer les paramètres nécessaires).

Avant de passer à l’étape de l’EventGrid, on va déployer le code. Pour cela, on passe par Kudu, qui propose de pousser un zip prêt à l’emploi, ce qui est parfaitement adapté à notre cas. Ensuite, on redémarre la WebApp pour que le déploiement soit pris en compte.

Et voilà !

Conclusion

En guise de conclusion, plutôt que de grands discours, je préfère vous mettre les liens des éléments qui m’ont inspiré pour la rédaction de cet article :