25 february 2023
Utilisation des modèles YAML dans Azure DevOps pour des actions répétables : Partie 2 - Travailler avec les variables de sortie dans les modèles YAML dans Azure DevOps
Initialement, j'avais l'intention d'écrire un article sur la pratique de l'utilisation des modèles YAML dans Azure DevOps pour encapsuler des étapes répétables dans les pipelines. Mais pendant le processus d'écriture, j'ai réalisé que de simples références à la documentation et des exemples de fichiers YAML ne seraient pas aussi intéressants sans les relier à un scénario. Ainsi, un court article purement technique s'est transformé en un texte volumineux dédié à plusieurs sujets, chacun étant intéressant en soi. En fin de compte, j'ai décidé de transformer ce texte en une mini-série d'articles. Voici la deuxième partie, consacrée à l'utilisation des modèles YAML dans Azure DevOps, en mettant particulièrement l'accent sur le passage de paramètres vers et depuis les modèles.
Dans l'article précédent, j'ai décrit un scénario que j'utiliserai comme exemple pour discuter de l'utilisation des modèles YAML. Permettez-moi de vous en rappeler brièvement.
Nous avons une équipe de test qui a écrit des tests automatisés de deux types:

  • Des tests de code qui s'exécutent rapidement et ne nécessitent pas le déploiement de l'application.
  • Des tests système qui nécessitent à la fois le déploiement de l'application et l'utilisation de frameworks spéciaux.

La tâche de l'équipe DevOps (c'est nous) est de créer un pipeline qui construirait l'application, exécuterait les tests de code, déploierait l'application sur Azure et exécuterait des tests système pour l'application déployée. Et surtout, nous nous efforçons de rendre nos modèles réutilisables et combinables lors de la création de pipelines pour différentes applications.

En résolvant cette tâche, j'analogise cela à la programmation. Chaque étape (construction, exécution des tests de code, déploiement, exécution des tests système) représente essentiellement une fonction qui prend des paramètres d'entrée et produit quelque chose en résultat.

En termes simplifiés, nous obtenons la séquence d'appels suivante.

Les paramètres sont nécessaires afin que nous puissions réutiliser l'étape pour différentes applications, en spécifiant, par exemple, un lien de dépôt ou l'adresse d'une machine virtuelle pour le déploiement de l'application. Ainsi, nous n'avons besoin de passer que les paramètres communs à chacune des étapes.

Maintenant, juste par curiosité, déployons l'application sur une machine virtuelle spécialement créée. Et à la fin, après avoir exécuté tous les tests, nous supprimerons cette machine virtuelle. Le schéma devient un peu plus complexe:

Et ici, nous avons déjà les premiers paramètres de sortie au stade de création d'une machine virtuelle. Nous avons besoin du nom DNS ou de l'adresse IP pour déployer l'application sur cette machine et exécuter des tests système. Et nous obtenons cette adresse dynamiquement au moment de la création de la machine virtuelle. Ainsi, ce sera le paramètre de sortie de la fonction "Créer VM".

Complications un peu plus le schéma et générons des noms uniques pour la machine virtuelle et le mot de passe de l'utilisateur. D'une part, cela nous permettra d'exécuter plusieurs pipelines en parallèle sans craindre les conflits de noms, et d'autre part, cela renforcera la sécurité en utilisant un mot de passe unique pour chaque nouvelle machine virtuelle.

Ainsi, nous avons plusieurs paramètres de sortie obtenus à une étape que nous devons passer comme paramètres d'entrée aux étapes suivantes.

Cela semble très simple. Nous avons créé des variables globales, stocké les paramètres de sortie là-bas, puis les avons utilisés lors de l'appel des étapes suivantes. Mais ce n'est pas si simple. La chose est que la similitude avec la programmation dans les pipelines YAML dans Azure DevOps s'arrête précisément avec le travail sur les paramètres de sortie. De plus, les pipelines, en général, ne sont pas exactement des programmes, car l'exécution de la séquence d'actions dépend fortement de l'environnement d'exécution du pipeline.

Mais allons-y étape par étape. Du simple au complexe.

Tout d'abord, regardons le schéma général de fonctionnement des pipelines YAML dans Azure DevOps:

Le pipeline peut être déclenché manuellement ou automatiquement par un événement (déclencheur). Chaque pipeline se compose d'un ensemble de stages exécutées séquentiellement, qui peuvent être exécutées soit séquentiellement, soit en parallèle. Chaque stage s'exécute sur un agent spécifique (serveur ou machine virtuelle) dans un environnement déployé sur cet agent (système d'exploitation et frameworks et middleware installés). Chaque stage se compose d'un ensemble de jobs, qui peuvent également être exécutés soit séquentiellement, soit en parallèle. Et chaque job se compose d'une séquence de steps, où chaque étape représente une task atomique spécifique (par exemple, copier des fichiers ou exécuter un script PowerShell).

Pour une description plus détaillée des pipelines YAML, vous pouvez consulter ce lien.

La syntaxe d'un pipeline YAML ressemble à ceci:

trigger: none

parameters:
- name: Param1
  displayName: Param number one
  type: number
  default: 1
- name: Param2
  displayName: Param number two
  type: string
  default: Value 1
  values:
  - Value 1
  - Value 2
  - Value 3

pool:
 vmImage: windows-latest

variables:
 - name: Var1
   value: VarValue1
 - name: Var2
   value: VarValue2

stages:
 - stage: StageName1
   displayName: Stage one
   jobs:
     - job: job1
       steps:
       - task: <taskType>
  ...
       - task: <taskType>
  ...
     - job: job2
       steps:
       - task: <taskType>
  ...
       - task: <taskType>
  ...
  - stage: StageName2
   displayName: Stage two
   jobs:
     - job: job1
       steps:
       - task: <taskType>
  ...
       - task: <taskType>
  ...
     - job: job2
       steps:
       - task: <taskType>
  ...
       - task: <taskType>
  ...

Ici, je fournis une syntaxe très simplifiée pour comprendre la hiérarchie entre stage, job et tâche. Vous pouvez approfondir la syntaxe en utilisant le lien que j'ai fourni précédemment. Notez que le pipeline a des paramètres. Ils peuvent être définis dans une fenêtre spéciale lors de l'appel du pipeline. Les paramètres pourraient, par exemple, être utilisés pour transmettre des paramètres de construction à l'application.

Azure DevOps vous permet de créer des modèles pour les stages, les jobs et les steps. Je vais emballer un stage entier dans un modèle, qui servira de notre stage (ou fonction, en termes de développeur).

La syntaxe pour le modèle avec un stage sera la suivante:

# File: templates/npm-with-params.yml

parameters:
- name: Param1
displayName: Param number one
type: number
default: 1
- name: Param2
displayName: Param number 2
type: string
default: Value 1
values:
- Value 1
- Value 2
- Value 3

variables:
- name: Var1
value: VarValue1
- name: Var2
value: VarValue2

stages:
- stage: StageName1
displayName: Stage one
jobs:
- job: job1
steps:
- task: <taskType>
...
- task: <taskType>
...
- job: job2
steps:
- task: <taskType>
...
- task: <taskType>

Comme vous pouvez le voir, c'est une réplique exacte du pipeline lui-même. De plus, il peut y avoir plusieurs étapes dans le modèle, mais je n'utiliserai qu'une seule stage.

Ainsi, pour chaque stage de notre schéma, nous aurons un modèle séparé avec une stage distincte. Jetons un coup d'œil aux deux premières stages: le build et les tests.

Pour simplifier, supposons que nous avons une application dotnet et des tests écrits en NUnit ou XUnit dans le cadre de la solution globale. Ensuite, nous pouvons combiner la construction et les tests en une seule stage:
parameters:
- name: Test
type: boolean
default: true
values:
- true
- false

- name: PublishCodeTestsResult
type: boolean
default: true
values:
- true
- false

- name: Repository
type: string
default: self

- name: Configuration
type: string
default: Release
values:
- Debug
- Release

- name: Runtime
type: string
default: win-x64
values:
- win-x64
- linux-x64

- name: SolutionPath #The path to the .sln file
type: string

- name: SolutionName #The name of .sln file
type: string

- name: StageSuffix
type: string
default:

stages:
- stage: "BuildAndTestStage${{parameters.StageSuffix}}"
displayName: 'Build ${{ parameters.SolutionName }} and run code test'

jobs:
- job: "BuildJob"
displayName: 'Build ${{ parameters.SolutionName }} Job'

steps:
########################################################################################
# Checkout
########################################################################################
- checkout: ${{ parameters.Repository }}
########################################################################################
# Packages restore
# To manage centralized Nuget sources, nuget.config MUST be placed in the ${{ parameters.SolutionPath }} folder
########################################################################################
- task: DotNetCoreCLI@2 # install dependencies
displayName: Restore
inputs:
command: 'restore'
projects: '${{ parameters.SolutionPath }}/${{ parameters.SolutionName }}.sln'
restoreArguments: '-r ${{ parameters.Runtime }}'
feedsToUse: 'config'
nugetConfigPath: '${{ parameters.SolutionPath }}/nuget.config'
verbosityRestore: 'Normal'

########################################################################################
# Build
# --no-restore - because nuggets were restored on the previous step
# --no-self-contained - should be presented because of runtime exist
# -p:ImportByWildcardBeforeSolution=false - allows to set runtime for solution build
########################################################################################
- task: DotNetCoreCLI@2 # build
displayName: Build
inputs:
command: build
arguments: '-c ${{ parameters.Configuration }} -r ${{ parameters.Runtime }} --no-restore --no-self-contained -p:ImportByWildcardBeforeSolution=false'
projects: '${{ parameters.SolutionPath }}/${{ parameters.SolutionName }}.sln'

########################################################################################
# Run code tests
########################################################################################
- task: DotNetCoreCLI@2
condition: eq('${{ parameters.Test }}', 'true')
displayName: Test
inputs:
command: 'test'
projects: '${{ parameters.SolutionPath }}/${{ parameters.SolutionName }}.sln'
arguments: '-c ${{ parameters.Configuration }} -r ${{ parameters.Runtime }} --no-restore'
publishTestResults: ${{ parameters.publishCodeTestsResult }}
testRunTitle: '${{ parameters.SolutionName }}-$(buildConfiguration)-$(buildRuntime)'
En général, dans le cadre de cet article, ce n'est pas si important de savoir exactement comment vous allez construire votre application et exécuter les tests, car nous discutons du pipeline et des modèles eux-mêmes. Et ce modèle montre comment nous pouvons lui transmettre des paramètres. Afin de le rendre universel et de construire des applications dotnet à partir de différentes sources, j'ai ajouté plusieurs paramètres:

  • Repository - lien vers le dépôt
  • Configuration - configuration de construction (Debug/Release, etc.)
  • Runtime - environnement d'exécution pour la construction (win-x64/linux-x64, etc.)
  • SolutionPath - chemin vers le fichier sln à l'intérieur du dépôt
  • SolutionName - le nom du fichier sln. Il sera également utilisé comme nom de projet lors de la sortie d'informations dans le pipeline.
Pour travailler avec les tests, j'ai également ajouté quelques paramètres:

  • Test - indique s'il faut exécuter les tests de code après la construction
  • PublishCodeTestsResult - indique s'il faut publier les résultats des tests dans Azure DevOps.
Et un petit truc:

  • StageSuffix - un ajout au nom de l'étape qui permet d'utiliser ce modèle plusieurs fois dans un même pipeline, car le nom de l'étape doit être unique.
Un pipeline avec un appel à ce modèle pourrait ressembler à ceci:
resources:
repositories:
- repository: templates
type: git
name: automation-templates
ref: 'main'

parameters:
- name: Configuration
type: string
default: Release
values:
- Debug
- Release

- name: Runtime
type: string
default: win-x64
values:
- win-x64

variables:
- name: WebApi_SolutionPath # The path to the .sln file
value: 'src\'
- name: WebApi_SolutionName # The name of .sln file
value: WebAPI
- name: WebApi_Repo
value: git://OurProject/WebAPI@main
- name: WebApi_StageSuffix
value: "_webapi"

- name: ClientApp_SolutionPath # The path to the .sln file
value: 'src\'
- name: ClientApp_SolutionName # The name of .sln file
value: ClientApp
- name: ClientApp_Repo
value: git://OutProject/ClientApp@main
- name: ClientApp_StageSuffix
value: "_ClientApp"

pool:
vmImage: windows-latest



stages:
#BuildAndTestStage WebApi Stage
- template: Build\netcore-build-template.yaml@templates # Template from the templates repository
parameters:
Configuration: ${{ parameters.Configuration }}
Runtime: ${{ parameters.Runtime }}
SolutionPath: ${{ variables.WebApi_SolutionPath }}
SolutionName: ${{ variables.WebApi_SolutionName }}
Repository: ${{ variables.WebApi_Repo }}
StageSuffix: ${{variables.WebApi_StageSuffix}}

#BuildAndTestStage ClientApp Stage
- template: Build\netcore-build-template.yaml@templates # Template from the templates repository
parameters:
Configuration: ${{ parameters.Configuration }}
Runtime: ${{ parameters.Runtime }}
SolutionPath: ${{ variables.ClientApp_SolutionPath }}
SolutionName: ${{ variables.ClientApp_SolutionName }}
ArtifactsName: ${{ variables.ClientApp_SolutionName }}
Repository: ${{ variables.ClientApp_Repo }}
Test: false
StageSuffix: ${{variables.ClientApp_StageSuffix}}
Ici, nous construisons deux projets différents dans un même pipeline en utilisant le même modèle. Pour ce faire, nous utilisons StageSuffix, ce qui rend les noms des étapes uniques; sinon, nous obtiendrions une erreur.

Notez également que je ne transmets pas tous les paramètres aux modèles. Par conséquent, les tests seront exécutés et publiés automatiquement car ces paramètres ont des valeurs par défaut définies sur true dans le modèle.

Ainsi, nous avons appris à créer des modèles et à leur transmettre des paramètres. Tout est assez simple ici, sans surprises. De même, nous pourrions créer des modèles pour le reste de nos stages:

Cependant, il y a une chose importante : plus loin, nous générons des informations qui doivent être utilisées comme paramètres pour les étapes suivantes.
Nous voici donc à la véritable raison de la rédaction de cet article. Générer des paramètres dans une étape et les transmettre à l'étape suivante s'est avéré être une tâche pas si facile. Et voici pourquoi. Parce que théoriquement, chaque stage peut être exécutée sur un agent séparé, il ne peut y avoir de "mémoire" partagée ou de variables globales qui pourraient stocker des valeurs générées au moment de l'exécution dans une stage pour être utilisées dans une autre stage. Cependant, Microsoft ne pouvait pas priver complètement les DevOps de cette capacité en raison de la banalité même de la tâche et a trouvé peut-être la seule solution fonctionnelle dans cette architecture.

Mais avant d'aborder cette solution, plongeons dans l'approche des variables dans les pipelines YAML en général.

Il n'y a que 3 types de variables:

  • macro,
  • expression de modèle,
  • expression de runtime.
Chacun d'entre eux fonctionne légèrement différemment.

L'expression de modèle est traitée lors de la compilation du pipeline. Fondamentalement, c'est un copier-coller de la valeur que vous avez définie dans une variable de ce type dans le fichier YAML où vous utilisez cette variable. Vous ne pouvez pas modifier de telles variables une fois que le pipeline a démarré. La syntaxe de ces variables est ${{ variables.var }}.

Mais les macro et les expressions de runtime sont un peu plus compliquées. Ils fonctionnent tous les deux au moment de l'exécution, mais la macro est exécutée avant qu'une tâche ne soit exécutée. De plus, les expressions de runtime sont optimisées pour être utilisées dans des conditions et des expressions. Leur syntaxe est $(var) pour Macro et $[variables.var] pour les expressions de Runtime.

Pour les besoins de cet article, ces informations sont suffisantes, mais si vous voulez approfondir votre compréhension du travail avec les variables, vous pouvez vous référer à la documentation fournie dans le lien.
Maintenant, parlons des variables de sortie. Vous pouvez trouver tous les détails ici, mais nous discuterons du transfert de variables entre les étapes pour mettre en œuvre notre idée.
Ainsi, pour rendre une variable visible "à l'extérieur" de stage, il existe une syntaxe spéciale:

Powershell:
Write-Host "##vso[task.setvariable variable=<Var name>;isoutput=true]<Var value>“
Script:
echo "##vso[task.setvariable variable=<Var name>;isOutput=true]<Var value>"

En utilisant cette syntaxe, vous pouvez définir une variable directement lors de l'exécution du script, qui sera visible à la fois à l'intérieur de l'étape et dans d'autres étapes.

De plus, pour faire référence à cette variable, il existe également une syntaxe spéciale, en fonction de l'endroit où vous voulez faire référence.

Si vous utilisez une variable de sortie dans le même job, il vous suffit de faire une référence simple au nom de la tâche où cette variable a été déclarée:
steps:
- script: echo "##vso[task.setvariable variable=MyVar;isOutput=true]my val"  # this step generates the output variable
  name: ProduceVar  # because we're going to depend on it, we need to name the step
- script: echo $(ProduceVar.MyVar) # this step uses the output variable
Dans cet exemple, le script est simplement un type spécial de tâche qui exécute des commandes bash. Il remplace la définition plus longue d'une tâche avec des paramètres.

Si vous utilisez une variable de sortie dans un autre job, vous devez définir explicitement la dépendance sur le job où la variable est déclarée:
jobs:
- job: A
  steps:
  # assume that MyTask generates an output variable called "MyVar"
  - script: echo "##vso[task.setvariable variable=MyVar;isOutput=true]my val"
    name: ProduceVar  # because we're going to depend on it, we need to name the step
- job: B
  dependsOn: A
  variables:
    # map the output variable from A into this job
    varFromA: $[ dependencies.A.outputs['ProduceVar.MyVar']]
  steps:
  - script: echo $(varFromA) # this step uses the mapped-in variable
Un job dispose d'un paramètre appelé dependsOn, qui établit une dépendance avec le Job A. Grâce à cette dépendance, nous pouvons désormais accéder à la variable de sortie du job A en utilisant la syntaxe $[ dependencies.<nom du job>.outputs['<nom de la tâche>.<nom de la variable>']]. Cependant, si le paramètre dependsOn n'est pas défini, la référence à la variable ne fonctionnera pas. De plus, notez qu'il est nécessaire de faire une correspondance via les variables à l'intérieur du job : varFromA: $[ dependencies.A.outputs['ProduceVar.MyVar']]. Ensuite, seule $(varFromA) doit être utilisée. Si vous essayez de faire une référence directe via $[dependencies] dans le script, cela ne fonctionnera pas.

Si vous utilisez une variable de sortie dans un autre stage, vous devez définir explicitement la dépendance sur le stage où la variable est déclarée:
stages:
- stage: One
  jobs:
  - job: A
    steps:
    - script: echo "##vso[task.setvariable variable=MyVar;isOutput=true]my val"  # this step generates the output variable
      name: ProduceVar  # because we're going to depend on it, we need to name the step

- stage: Two
  dependsOn:
  - One
  jobs:
  - job: B
    variables:
      # map the output variable from A into this job
      varFromA: $[ stageDependencies.One.A.outputs['ProduceVar.MyVar'] ]
    steps:
    - script: echo $(varFromA) # this step uses the mapped-in variable

- stage: Three
  dependsOn:
  - One
  - Two
  jobs:
  - job: C
    variables:
      # map the output variable from A into this job
      varFromA: $[ stageDependencies.One.A.outputs['ProduceVar.MyVar'] ]
    steps:
    - script: echo $(varFromA) # this step uses the mapped-in variable

Ici, tout se passe presque de la même manière que pour les jobs, mais maintenant dependsOn doit être déclaré au niveau du stage, et la correspondance doit être faite avec une syntaxe légèrement plus complexe où le nom du stage est ajouté : $[ stageDependencies.<nom du stage>.<nom du job>.outputs['<nom de la tâche>.<nom de la variable>']]. Notez qu'au lieu du mot-clé "dependencies", nous avons maintenant "stageDependencies". De plus, la section des variables peut être située à la fois au niveau du job et du stage.

Et la dernière particularité : ces variables ne fonctionnent qu'au moment de l'exécution! Vous obtiendrez des valeurs vides si vous essayez de lire de telles variables au moment de la compilation, bien que la vérification du pipeline puisse réussir et que le pipeline puisse être lancé.

Maintenant, nous pouvons enfin passer au modèle pour générer un nom unique et un mot de passe pour la machine virtuelle.

parameters:
- name: VMName # VM name
  type: string

stages:
- stage: "GenerateVmValues"
  variables:
  - name: VMNameInternal
    value: ${{ lower(parameters.VMName) }}$(Get-Date -Format ssff)
 jobs:
    - job: ValuesGenJob
      steps:
        - task: PowerShell@2
          name: ExportVariables
          inputs:
            targetType: 'inline'
            script: |
               $pwd = [System.Web.Security.Membership]::GeneratePassword(15,2)
               Write-Host "##vso[task.setvariable variable=VmName;isoutput=true]$(VMNameInternal)"
               Write-Host "##vso[task.setvariable variable=VmUsername;isoutput=true]admin"
               Write-Host "##vso[task.setvariable variable=VmPassword;isoutput=true]$pwd"
        - task: PowerShell@2
          name: ValuesOutput
          displayName: 'Values Output'
          inputs:
            targetType: 'inline'
            script: |
                Write-Host "VmName $(ExportVariables.VmName)"
                Write-Host "VmUsername $(ExportVariables.VmUsername)"
                Write-Host "VmPassword $(ExportVariables.VmPassword)"

Dans ce modèle, nous avons deux tâches. Dans la première, nous générons des paramètres et créons des variables de sortie, et dans la seconde, nous affichons leurs valeurs à l'écran en utilisant la première méthode de référencement des variables de sortie dans le même job.

Nous créons un nom unique en ajoutant 4 chiffres des secondes actuelles et des millisecondes au nom de base (${{ lower(parameters.VMName) }}$(Get-Date -Format ssff)). Et nous générons le mot de passe en utilisant la fonction [System.Web.Security.Membership]::GeneratePassword.

L'appel du modèle ne diffère pas de ce que nous avons discuté précédemment, mais il est intéressant de noter comment nous passons les paramètres générés à l'intérieur du modèle plus loin:

resources:
  repositories:
    - repository: templates
      type: git
      name: automation-templates
      ref: 'main'

variables:
- name: SolutionName
  value: MySolution
- name: VMName
  value: vm-${{ lower(variables.SolutionName) }}

stages:
#GenerateVmValues Stage
  - template: Deploy\generate-values-for-vm.yaml@templates
    parameters:
      VMName:  ${{ VMName }}

#CreateAzureVm Stage
  - template: Deploy\create-azure-vm.yaml@templates
    parameters:
      VMName: $[stageDependencies.GenerateVmValues.ValuesGenJob.outputs['ExportVariables.VmName']]
      UserName: $[stageDependencies.GenerateVmValues.ValuesGenJob.outputs['ExportVariables.VmUsername']]
      Password: $[stageDependencies.GenerateVmValues.ValuesGenJob.outputs['ExportVariables.VmPassword']]
      DependsOn:    
       - GenerateVmValues
Dans l'appel au modèle create-azure-vm.yaml, nous faisons une référence DependsOn au nom de l'étape dans le modèle de génération de paramètres et passons 3 paramètres en utilisant le troisième méthode de référencement des variables de sortie, en spécifiant le chemin complet de la référence:

$[stageDependencies.GenerateVmValues.ValuesGenJob.outputs['ExportVariables.VmName']]

Et le dernier point: comment utiliser les paramètres générés ci-dessus dans un autre modèle.

parameters:
- name: VMName # VM name
  type: string
- name: UserName
  type: string
- name: Password
  type: string
- name: DependsOn
  type: object
  default:

stages:
- stage: "MyStage" 
  # only include the DependsOn parameter if provided
  ${{ if parameters.DependsOn }}:
    dependsOn: '${{ parameters.DependsOn }}'
  # we MUST use a local variable for VM name because this name can be generated in the previous stage.
  variables:
    - name: VMName
      value: ${{ parameters.VMName }}
    - name: VMUsername
      value: ${{ parameters.UserName }}
    - name: VMPassword
      value: ${{ parameters.Password }}
Ici, plusieurs aspects nécessitent une attention particulière. Tout d'abord, passer la valeur DependsOn à travers les paramètres du modèle. Cette technique permet d'établir une référence à une étape à partir d'un autre modèle.

De plus, notez que tous les paramètres sont redéfinis dans la section Variables au niveau de l'étape. Si vous faites référence directement aux paramètres, cela ne fonctionnera pas. Seule leur redéfinition dans la section variables permet à la "magie" de fonctionner.

Maintenant, nous pouvons créer des modèles simples sans paramètres de sortie, comme le démontre le constructeur de projet .NET universel, et créer des modèles avec des paramètres de sortie, en utilisant les paramètres de sortie d'un modèle dans d'autres modèles.

Nous avons la base, et les deux premières étapes de notre plan sont en place. Dans le prochain article, nous discuterons de la création d'une machine virtuelle et, surtout, de sa configuration pour une connexion à distance via PowerShell.