Accéder à l'en-tête Accéder au contenu principal Accéder au pied de page
Retour aux actualités
Non classé
20/08/2019 Cyrille Martraire

Tester son infrastructure Azure avec Pester

Dans un précédent article ( http://www.arolla.fr/blog/2019/08/tag-ressources-azure-createur/) nous avons vu comment poser un Tag sur ses ressources Azure pour identifier leurs créateurs.

Il y a de l’automatisation, ce qui est bien. Mais au niveau des tests, c’était plutôt pauvre. Voir très. Voir même complètement.

Nous allons remédier à cela.

Pourquoi on teste ?

Vaste question. Vous trouverez d’innombrables articles sur ce sujet. Personnellement, voici les 2 raisons principales pour lesquelles je teste :

  • Mettre en production, fêter ça, et arriver à 14h00 le lendemain et voir que tout va bien.
  • Avoir un feedback sur ce que l’on produit. Aussi bien au niveau de développement, de la livraison et de la production.

Pester

Pester est une framework de tests et de mock pour PowerShell.

J’ai choisi de l’utiliser pour 3 raisons :

  • Les Azure Functions sont écrites en PowerShell
  • Tester des ressources et une infrastructure Azure se fait très bien en PowerShell, même si d’autre langages permettent de le faire (Python, C# …)
  • Je préfère ne pas m’éparpiller dans les frameworks quand un seul suffit

Pour l’installer, https://www.powershellgallery.com/packages/Pester/5.0.0-alpha4

Tests unitaires

Les premiers tests que j’ai mis en place ont été les tests unitaires.

Je vais détailler ici deux exemples, sans et avec mock

La fonction Test-ResourceTypeSupportTags (sans mock)

Cette fonction a pour but de déterminer si un type de ressource dans Azure supporte les tags. Pour ce faire, elle utilise une référentiel, stocké sous la forme d’un fichier csv.

Elle prend en paramètre le type de ressource et le chemin vers le fichier référentiel. Elle renvoie un booléen indiquant si la ressource peut être taguée. Plutôt simple.

Voici son implémentation :

 

function Test-ResourceTypeSupportTags { 
    param(
        [Parameter(Mandatory=$true)]
        [string]$resourceType,
        [Parameter(Mandatory=$true)]
        [string]$referentialFilePath
    )
    $resourceInfo = $resourceType.split("/",2)
    $provider = $resourceInfo[0]
    $serviceName = $resourceInfo[1]

    # This file is coming from https://github.com/tfitzmac/resource-capabilities/blob/master/tag-support.csv
    $tagSupportedResources = Import-Csv -Path $referentialFilePath
    $currentResourceSupport = $tagSupportedResources | Where-Object {($_.providerName -eq $provider) -and ($_.resourceType -eq $serviceName)} | Select-Object -first 1

    if (-not ($currentResourceSupport)) {
        Write-Host "$resourceType not found in tag support referential"
        return $false
    }

    if ($currentResourceSupport.supportsTags -eq "FALSE") {
        Write-Host "$resourceType does not support tags"
        return $false
    }

    Write-Host "$resourceType does support tags"
    $true
}

Rien d’exceptionnel à noter (mise à part le fait que charger un csv en PowerShell est quand même d’une simplicité étonnante, ce langage est formidable).

Pas besoin de mocker quoi que ce soit ici.

Voici le code de test :

Describe "Tag resource module tests" -Tag @("tagResourceModule","unit_test") {

    BeforeAll {
        Import-Module .functionModulestagResource -Force 
    }

    Context "Checking the referential of resouces supporting tag" {
        it "Resources type not in referential should not support tags" {
            $supportTag = Test-ResourceTypeSupportTags -resourceType "ninja/youcantfindme" -referentialFilePath ".resourceCreatedFunctiontag-support.csv"
            $supportTag | Should -Be $false
        }
        ///
           The other tests 
        ///

On écrit ses tests avec Pester en utilisant trois blocs : Describe, Context et it

Ils permettent de regrouper logiquement les tests, et de définir des scopes.

BeforeAll est exécuté une fois, avant tous les tests du bloc Describe. Nous allons pouvoir importer le module à tester (tagResource) depuis le répertoire Modules des Azure Functions. Cet import est automatique dans le cadre de l’exécution dans Azure ou via l’émulateur, mais pour les tests automatisés, il doit être fait manuellement.

Le test ici vérifie qu’une ressource non présente dans le référentiel sera considérée comme ne supportant pas les tags. L’assertion est faite sur le résultat de l’appel à la fonction Test-ResourceTypeSupportTags.

Afin de couvrir tous les cas, les tests suivants sont implémentés:

  • Resources type not in referential should not support tags
  • Resources type with matching provider but no serviceName should not support tags
  • Resources type with matching provider and serviceName and TRUE supportsTags column should support tags
  • Resources type with matching provider and serviceName and FALSE supportsTags column should not support tags
  • Resources type with matching provider and multi part serviceName and TRUE supportsTags column should support tags
  • Resources type with matching provider and multi part serviceName and FALSE supportsTags column should not support tags

La fonction Update-referential (avec mock)

Cette fonction a pour but de mettre à jour le référentiel des créateurs de ressources. Si la ressource ne fait pas partie du référentiel, elle est ajoutée et retourne le nom du créateur. Dans le cas contraire, elle renvoie le nom du créateur trouvé dans le référentiel.

Voici son implémentation :

function Update-referential{
    Param(
        [Parameter(Mandatory=$true)]
        [string]$resourceUri,
        [Parameter(Mandatory=$true)]
        [string]$subscriptionId,
        [Parameter(Mandatory=$true)]
        [string]$creatorDisplayName,
        [Parameter(Mandatory=$true)]
        [string]$eventTime
    )
    $cloudTable = Get-AzTableTable -storageAccountName $env:storageName -resourceGroup $env:resourceGroupName -TableName $env:tableName 
    $formatedResourceUri = ($resourceUri -replace "/","-").ToLower()
    $row = Get-AzTableRow -Table $cloudTable -PartitionKey $formatedResourceUri -RowKey $subscriptionId
    if (-not $row) {
        Write-Host "No creator found in referential - Add it" 
        Add-AzTableRow -Table $cloudTable -PartitionKey $formatedResourceUri -RowKey $subscriptionId -property @{"createdBy"=$creatorDisplayName; "createdAt"=$eventTime} | Out-Null
        $displayName = $creatorDisplayName
    } else {
        Write-Host "Creator found in referential : $($row.createdBy)" 
        $displayName = $row.createdBy
    }
    $displayName
}

Rien d’exceptionnel ici non plus. Seule petite subtilité, certains caractères sont interdit dans la PartitionKey et la RowKey d’une ligne dans une Azure Table. La RowKey est un identifiant de souscription donc pas de soucis. La PartitionKey est l’Uri d’une ressource, contenant notamment des « / ». Il faut donc les remplacer.

On fait ici appel a trois fonctions qui déclenche un appel à l’API Azure. Il va falloir les mocker. Il s’agit de :

  • Get-AzTableTable : permet de récupérer l’Azure Table référentiel
  • Get-AzTableRow : permet de récupérer la ligne correspondant à la ressource
  • Add-AzTableRow : permet d’ajouter une ligne dans l’Azure Table référentiel

Voici le code du test couvrant le cas où une ressource n’est pas présente dans le référentiel :

InModuleScope "tagResource" {
it "Should add the creator in the referential if it does not exist" { 
    $resourceUri = "kiKKo/IamAResourCE/Nice" 
    $formatedResourceUri = "kikko-iamaresource-nice" 
    $subscriptionId = "46545645454"
    $creatorDisplayName = "Gabriel"
    $eventTime = "now"
    $cloudTable = @{hello="iamacloudtable"}

    $env:storageName = "theStorage"
    $env:resourceGroupName = "theResourceGroup" 
    $env:tableName = "theTableName" 
 
    Mock Get-AzTableTable -Verifiable {return $cloudTable} -ParameterFilter {
        $storageAccountName -eq $env:storageName `
        -and $resourceGroup -eq $env:resourceGroupName `
        -and $TableName -eq $env:tableName 
    } 
    Mock Get-AzTableRow -Verifiable {return $null} -ParameterFilter {
        $Table -eq $cloudTable `
        -and $PartitionKey -eq $formatedResourceUri `
        -and $RowKey -eq $subscriptionId
    }

    Mock Add-AzTableRow -Verifiable {return $null} -ParameterFilter {
        $Table -eq $cloudTable `
        -and $PartitionKey -eq $formatedResourceUri `
        -and $RowKey -eq $subscriptionId `
        -and $property["createdBy"] -eq $creatorDisplayName `
        -and $property["createdAt"] -eq $eventTime
    }

    $displayName = Update-referential -resourceUri $resourceUri -subscriptionId $subscriptionId -creatorDisplayName $creatorDisplayName -eventTime $eventTime
    $displayName | Should -Be $creatorDisplayName
    Assert-VerifiableMock 
}
}

Pour mocker avec Pester, il faut utiliser Mock 

Si on regarde de plus près l’exemple du mock de Get-AzTableTable, on passe en paramètre:

  • Le nom de la fonction à mocker
  • Le flag Verifiable. Il permet de vérifier que la fonction a bien été appelée lors de l’exécution du test, en utilisant la fonction Assert-VerifiableMock en fin de test
  • Un premier bloc de script correspondant au résultat à retourner
  • Le paramètre ParameterFilter. Il permet de spécifier la valeur des paramètres utilisés lors de l’appel de la fonction.

L’assertion se fait sur d’une part sur le résultat de la fonction et aussi sur le fait que les mocks on bien été appelés avec les bon paramètres.

Bilan des tests unitaires

Pros :

  • Ils sont rapides : 1,49 secondes pour exécuter 21 tests unitaires
  • Ils peuvent être joués très tôt : pas besoin d’Azure ou d’un déploiement
  • Ils sont gratuit : Aucune ressource créée ou consommée

Cons :

  • On est très loin d’avoir validé le bon fonctionnement une fois déployée. C’est beaucoup plus problématique pour un produit d’infrastructure que pour un développement classique.
  • Beaucoup de mock: le code ne fait au final qu’un peu d’orchestration au milieu des appels à l’API Azure. Beaucoup de tests se résume à mocker et vérifier que les mocks ont été appelés.
  • Cela peut devenir long à écrire

Tests du déploiement d’infrastructure

J’ai ensuite voulu tester le déploiement des ressources Azure. Pour rappel, il est fait en IaC (Infrastructure as Code) avec Terraform.

Ils sont à lancer une fois le déploiement réalisé et que vous avez une session connectée à Azure (Connect-AzAccount)

Voici le code:

Describe "Deployment of createdBy Tag tool" -Tag @("deploymentTests","integration_tests") {
    Context "The deployment has been done" {

    BeforeAll {
        # Read configuration files to get values
        $defaultConfig = Get-Content -Raw -Path ".terraformvar.tf.json" | ConvertFrom-Json
        $environmentConfigObject = Get-Content -Raw -Path ".terraformenvironmentdev.tfvars.json" | ConvertFrom-Json

        $environmentConfigHash = @{}
        foreach($currentSetting in $environmentConfigObject.PSObject.Properties) {
            $environmentConfigHash[$currentSetting.Name] = $currentSetting.Value
        }

        $deployedConfig = @{}
        foreach ($currentSetting in $defaultConfig.variable.PSObject.Properties) {
            $currentSettingName = $currentSetting.Name 
            if($environmentConfigHash.ContainsKey($currentSettingName)) {
                $deployedConfig[$currentSettingName] = $environmentConfigHash[$currentSettingName]
            }
            else {
                $deployedConfig[$currentSettingName] = $currentSetting.Value.default
            } 
        } 
    }

    It "The Resource Group should be created" {
        $resouceGroup = Get-AzResourceGroup -Name $deployedConfig["resourceGroupName"] -Location $deployedConfig["region"] -ErrorAction SilentlyContinue
        $resouceGroup | Should -Not -Be $null
    }

Dans le cadre de ces tests, dans le bloc BeforeAll, on va charger les variables de Terraform dans une table de hash, que l’on utilisera pour vérifier l’existence des ressources.

Le fait de pouvoir écrire ces fichiers de variables en json les rend beaucoup plus manipulables. Ici, nous commençons par récupérer le fichier var.tf.json contenant les valeurs par défaut, puis on merge avec le fichier dev.tfvars.json contenant les valeurs que l’on a voulu spécifier.

Les tests font ensuite appel à l’API Azure pour s’assurer que la ressource a bien été créée.

On va appliquer le même principe pour toutes les ressources dont on a besoin avec les tests suivants :

  • The Resource Group should be created
  • The Storage Account should be created
  • The Storage Account should be created in the specified region
  • The resource created queue should be created
  • The resource deleted queue should be created
  • The referential table should be created
  • The resource created Event Grid Subscription should be created
  • The resource deleted Event Grid Subscription should be created
  • The App Service Plan should be created
  • The Function App should be created
  • The Application Insight should be created

Bilan des tests de déploiement

Pros :

  • Rapides à écrire
  • Rapides à s’exécuter (moins de 10 secondes)
  • Permettent de diagnostiquer au plus tôt un problème dans le déploiement

Cons :

  • Nécessité d’avoir un environnement déployé
  • Consomme des ressources
  • Ne permettent toujours pas de s’assurer du bon fonctionnement du produit

Tests d’acceptance

Le dernier type de tests que j’ai mis en place.

A lancer une fois le déploiement réalisé (et validé par les tests) et que vous êtes connecté à Azure

Voici le code:

 

Describe "Created resources supporting Tag are tagged with their creator" -Tag @("acceptance_test") {

BeforeAll { 
    $configuration = Get-Content -Raw ".testsacceptance_testsconfiguration.json" | ConvertFrom-Json 
    $resourceGroupName = $configuration.resourceGroupName
    $location = $configuration.location
    $publicIPName = $configuration.publicIPName
    $servicePrincipalId = $configuration.servicePrincipalId
    $maxRetry = $configuration.maxRetry
    $delay = $configuration.delay
    Write-Host "Creating test Resource Group $resourceGroupName"
    New-AzResourceGroup -Name $resourceGroupName -Location $location | Out-Null 
 }
 
 AfterAll { 
 Write-Host "Removing test Resource Group $resourceGroupName"
 Remove-AzResourceGroup -Name "tag-test-rg" -Force | Out-Null
 }

Function Get-ResourceCreatedByTag {
 ...
 }


Context "A new Public IP is created" {

It "The Public IP is tagged with its creator" { 
 New-AzPublicIpAddress -Name $publicIPName -ResourceGroupName $resourceGroupName -Location $location -AllocationMethod Dynamic
 $tagCheck = Get-ResourceCreatedByTag -maxRetry $maxRetry -delay $delay -resourceName $publicIpName -resourceGroupName $resourceGroupName
 $tagCheck[0] | Should -Be $true
 $tagCheck[1] | Should -Be "Service Principal $servicePrincipalId" 
 }

It "The tag is added if it is removed" { 
 $publicIp = Get-AzResource -Name $publicIpName -ResourceGroupName $resourceGroupName
 Set-AzResource -ResourceId $publicIp.ResourceId -Tag @{}
 $tagCheck = Get-ResourceCreatedByTag -maxRetry $maxRetry -delay $delay -resourceName $publicIpName -resourceGroupName $resourceGroupName
 $tagCheck[0] | Should -Be $true
 $tagCheck[1] | Should -Be "Service Principal $servicePrincipalId" 
 } 
 }

Dans le bloc BeforeAll, on charge la configuration depuis un json et on crée le groupe de ressources qui va nous servir pour les tests.

Dans le bloc AfterAll (qui s’exécute après le dernier test), on supprime le groupe de ressources et tout ce qu’il contient.

Le premier test crée une IP publique, et vérifie que le tag est bien ajouté.

Le second supprime le tag, et vérifie que celui-ci est bien rajouté.

Pros :

  • Rapides à écrire
  • Vérifie le bon fonctionnement du produit

Cons :

  • Long a s’exécuter
  • Consomme des ressources

Au final, il faut tester quoi ?

La réponse naïve : tout.

Dans un mode idéal, on a le temps de tout tester, à chaque fois. On pourrait très bien à chaque commit déclencher une build qui lancerait les tests unitaires, le déploiement, les tests de déploiement puis les tests d’acceptance.

Un modèle comme celui-ci trouve vite ses limites :

  • Les tests, notamment unitaires, peuvent être long à écrire
  • Attendre que tous les tests passent peut devenir très long
  • Il faudrait une souscription par build
  • Il peut y avoir des effets de bord et des collisions si plusieurs builds tournent en parallèle

La réponse nihiliste : rien.

50 ans d’histoire du développement informatique prouve que ce n’est pas une bonne idée.

La réponse pragmatique : ce qui a de la valeur et quand ça a de la valeur 

Tous les tests présentés ici apportent de la valeur. Hiérarchiser cette valeur est un exercice complexe qui dépend du produit. C’est un choix qui devra se faire en équipe en prenant en compte les avantages et inconvénients de chaque type de test.

La question du « quand » est aussi importante. Nous pouvons lancé les tests unitaires très souvent. Nous pourrions préférer réserver les tests de déploiement et d’acceptance au Pull Request sur master ou alors lors d’un Nightly build.

Je vous proposerai une mise en place dans un prochaine article.