Skip to content

Test-Driven Infrastructure : Simuler AWS en local pour vos tests d'infrastructure

Introduction

Le développement piloté par les tests (TDD) n'est plus réservé au code applicatif. L'Infrastructure as Code (IaC) bénéficie également de cette approche, permettant de valider nos configurations avant tout déploiement en production. Mais comment tester efficacement sans provisionner de vraies ressources AWS coûteuses ?

Dans cet article, nous allons explorer plusieurs solutions pour simuler un environnement AWS en local, puis déployer Apache sur une VM virtuelle avec Ansible, le tout dans une approche test-driven.

Pourquoi simuler AWS en local ?

Avantages de la simulation

  • Réduction des coûts : Pas de frais AWS pendant les phases de développement et test
  • Rapidité d'exécution : Les tests s'exécutent en quelques secondes au lieu de minutes
  • Isolation complète : Aucun risque d'impacter des ressources de production
  • Reproductibilité : Environnement identique pour tous les développeurs
  • CI/CD simplifié : Tests exécutables dans les pipelines sans credentials AWS

Les outils de simulation AWS

1. LocalStack : L'émulateur AWS complet

LocalStack est la solution la plus populaire pour émuler les services AWS localement. Il supporte plus de 80 services AWS.

Installation avec Docker :

# Démarrer LocalStack
docker run --rm -it \
  -p 4566:4566 \
  -p 4571:4571 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  localstack/localstack

Configuration AWS CLI pour LocalStack :

# Configurer l'endpoint local
aws configure set aws_access_key_id test
aws configure set aws_secret_access_key test
aws configure set region us-east-1

# Créer une instance EC2 sur LocalStack
aws ec2 run-instances \
  --image-id ami-12345678 \
  --count 1 \
  --instance-type t2.micro \
  --endpoint-url http://localhost:4566

2. Moto : Mock AWS Services en Python

Moto est une bibliothèque Python permettant de mocker les services AWS, idéale pour les tests unitaires.

import boto3
from moto import mock_ec2

@mock_ec2
def test_create_instance():
    ec2 = boto3.resource('ec2', region_name='us-east-1')

    # Créer une instance mockée
    instances = ec2.create_instances(
        ImageId='ami-12345',
        MinCount=1,
        MaxCount=1,
        InstanceType='t2.micro'
    )

    assert len(instances) == 1
    assert instances[0].instance_type == 't2.micro'

3. Vagrant + VirtualBox : Simulation de VM complète

Pour un contrôle total sur l'environnement de test, Vagrant reste une excellente option.

Vagrantfile exemple :

Vagrant.configure("2") do |config|
  # Configuration de la VM "AWS-like"
  config.vm.define "aws-vm" do |vm|
    vm.vm.box = "ubuntu/focal64"
    vm.vm.hostname = "aws-instance"

    # Configuration réseau
    vm.vm.network "private_network", ip: "192.168.56.10"

    # Ressources
    vm.vm.provider "virtualbox" do |vb|
      vb.memory = "1024"
      vb.cpus = 2
      vb.name = "aws-test-instance"
    end

    # Provisioning initial
    vm.vm.provision "shell", inline: <<-SHELL
      apt-get update
      apt-get install -y python3-pip
      pip3 install ansible
    SHELL
  end
end

Cas pratique : Déploiement d'Apache avec Ansible

Architecture du projet

project/
├── inventory/
│   ├── local/
│   │   └── hosts.yml
│   └── production/
│       └── hosts.yml
├── playbooks/
│   └── deploy-apache.yml
├── roles/
│   └── apache/
│       ├── tasks/
│       │   └── main.yml
│       ├── templates/
│       │   └── index.html.j2
│       ├── handlers/
│       │   └── main.yml
│       └── defaults/
│           └── main.yml
├── tests/
│   ├── test_infrastructure.py
│   └── molecule/
│       └── default/
│           ├── molecule.yml
│           └── verify.yml
├── Vagrantfile
└── requirements.txt

1. Configuration de l'inventaire Ansible

inventory/local/hosts.yml :

all:
  hosts:
    aws-vm:
      ansible_host: 192.168.56.10
      ansible_user: vagrant
      ansible_ssh_private_key_file: .vagrant/machines/aws-vm/virtualbox/private_key
      ansible_python_interpreter: /usr/bin/python3
  vars:
    environment: local
    apache_port: 80

2. Rôle Ansible pour Apache

roles/apache/tasks/main.yml :

---
- name: Installer Apache
  apt:
    name: apache2
    state: present
    update_cache: yes
  become: yes
  tags:
    - apache
    - install

- name: Créer le répertoire de configuration custom
  file:
    path: /etc/apache2/sites-available/custom
    state: directory
    mode: '0755'
  become: yes

- name: Configurer le port d'écoute
  lineinfile:
    path: /etc/apache2/ports.conf
    regexp: '^Listen '
    line: "Listen {{ apache_port }}"
  become: yes
  notify: restart apache

- name: Déployer la page d'accueil
  template:
    src: index.html.j2
    dest: /var/www/html/index.html
    mode: '0644'
  become: yes
  tags:
    - deploy

- name: S'assurer qu'Apache est démarré et activé
  systemd:
    name: apache2
    state: started
    enabled: yes
  become: yes

roles/apache/templates/index.html.j2 :

<!DOCTYPE html>
<html>
<head>
    <title>Test Infrastructure AWS</title>
</head>
<body>
    <h1>Bienvenue sur {{ ansible_hostname }}</h1>
    <p>Environnement : {{ environment }}</p>
    <p>Déployé le : {{ ansible_date_time.iso8601 }}</p>
    <p>Apache écoute sur le port : {{ apache_port }}</p>
</body>
</html>

roles/apache/handlers/main.yml :

---
- name: restart apache
  systemd:
    name: apache2
    state: restarted
  become: yes

3. Playbook principal

playbooks/deploy-apache.yml :

---
- name: Déployer Apache sur les instances
  hosts: all
  gather_facts: yes

  pre_tasks:
    - name: Afficher les informations de l'hôte
      debug:
        msg: "Déploiement sur {{ inventory_hostname }} ({{ ansible_host }})"

    - name: Vérifier la connectivité
      ping:

  roles:
    - role: apache
      tags: apache

  post_tasks:
    - name: Vérifier que le service répond
      uri:
        url: "http://{{ ansible_host }}:{{ apache_port }}"
        status_code: 200
      delegate_to: localhost
      tags: verify

4. Tests avec Molecule

Molecule permet de tester les rôles Ansible de manière automatisée.

Installation :

pip install molecule[docker] molecule-vagrant ansible-lint

tests/molecule/default/molecule.yml :

---
dependency:
  name: galaxy
driver:
  name: vagrant
  provider:
    name: virtualbox
platforms:
  - name: test-instance
    box: ubuntu/focal64
    memory: 1024
    cpus: 2
    groups:
      - webservers
provisioner:
  name: ansible
  playbooks:
    converge: ../../../playbooks/deploy-apache.yml
  inventory:
    host_vars:
      test-instance:
        apache_port: 8080
        environment: test
verifier:
  name: ansible

tests/molecule/default/verify.yml :

---
- name: Vérifier le déploiement Apache
  hosts: all
  gather_facts: no
  tasks:
    - name: Vérifier qu'Apache est installé
      package:
        name: apache2
        state: present
      check_mode: yes
      register: apache_installed
      failed_when: apache_installed.changed

    - name: Vérifier qu'Apache écoute sur le bon port
      wait_for:
        port: "{{ apache_port | default(80) }}"
        host: "{{ ansible_host }}"
        state: started
        timeout: 30

    - name: Tester la page d'accueil
      uri:
        url: "http://{{ ansible_host }}:{{ apache_port | default(80) }}"
        return_content: yes
      register: response

    - name: Vérifier le contenu de la page
      assert:
        that:
          - response.status == 200
          - "'Bienvenue' in response.content"
          - "'Apache écoute sur le port' in response.content"

5. Tests Python avec pytest

tests/test_infrastructure.py :

import pytest
import requests
import subprocess
import time
from paramiko import SSHClient, AutoAddPolicy

class TestInfrastructure:

    @classmethod
    def setup_class(cls):
        """Démarrer la VM Vagrant"""
        subprocess.run(["vagrant", "up"], check=True)
        time.sleep(10)  # Attendre que la VM soit prête

    @classmethod
    def teardown_class(cls):
        """Arrêter la VM Vagrant"""
        subprocess.run(["vagrant", "halt"], check=False)

    def test_vm_is_running(self):
        """Vérifier que la VM est accessible"""
        result = subprocess.run(
            ["vagrant", "status"],
            capture_output=True,
            text=True
        )
        assert "running" in result.stdout

    def test_ssh_connectivity(self):
        """Tester la connexion SSH"""
        ssh = SSHClient()
        ssh.set_missing_host_key_policy(AutoAddPolicy())

        # Récupérer les infos SSH de Vagrant
        result = subprocess.run(
            ["vagrant", "ssh-config"],
            capture_output=True,
            text=True
        )

        # Parser la config (simplifié)
        ssh.connect(
            hostname="127.0.0.1",
            port=2222,
            username="vagrant",
            key_filename=".vagrant/machines/aws-vm/virtualbox/private_key"
        )

        stdin, stdout, stderr = ssh.exec_command("hostname")
        assert stdout.read().decode().strip() == "aws-instance"
        ssh.close()

    def test_ansible_deployment(self):
        """Exécuter le playbook Ansible"""
        result = subprocess.run(
            [
                "ansible-playbook",
                "-i", "inventory/local/hosts.yml",
                "playbooks/deploy-apache.yml"
            ],
            capture_output=True,
            text=True
        )
        assert result.returncode == 0
        assert "failed=0" in result.stdout

    def test_apache_is_running(self):
        """Vérifier qu'Apache répond"""
        response = requests.get("http://192.168.56.10")
        assert response.status_code == 200
        assert "Bienvenue" in response.text

    @pytest.mark.parametrize("endpoint,expected_status", [
        ("/", 200),
        ("/nonexistent", 404),
    ])
    def test_apache_endpoints(self, endpoint, expected_status):
        """Tester différents endpoints"""
        response = requests.get(f"http://192.168.56.10{endpoint}")
        assert response.status_code == expected_status

Pipeline CI/CD avec GitHub Actions

.github/workflows/test-infrastructure.yml :

name: Test Infrastructure

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.9'

      - name: Installer les dépendances
        run: |
          pip install ansible-lint yamllint

      - name: Lint Ansible
        run: |
          ansible-lint playbooks/
          yamllint inventory/

  test-local:
    runs-on: ubuntu-latest
    services:
      localstack:
        image: localstack/localstack
        ports:
          - 4566:4566
        env:
          SERVICES: ec2,s3

    steps:
      - uses: actions/checkout@v3

      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.9'

      - name: Installer les dépendances
        run: |
          pip install -r requirements.txt
          pip install boto3 moto pytest

      - name: Tester avec Moto
        run: |
          pytest tests/test_aws_mocks.py

      - name: Tester avec LocalStack
        run: |
          aws configure set aws_access_key_id test
          aws configure set aws_secret_access_key test
          aws configure set region us-east-1

          # Créer une instance dans LocalStack
          aws ec2 run-instances \
            --image-id ami-12345678 \
            --instance-type t2.micro \
            --endpoint-url http://localhost:4566

  molecule-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.9'

      - name: Installer Molecule
        run: |
          pip install molecule[docker] ansible

      - name: Exécuter les tests Molecule
        run: |
          cd roles/apache
          molecule test

Bonnes pratiques et recommandations

1. Stratégie de test en pyramide

  • Tests unitaires (base) : Mocker avec Moto pour valider la logique
  • Tests d'intégration (milieu) : LocalStack pour tester les interactions
  • Tests end-to-end (sommet) : Vagrant/Docker pour validation complète

2. Configuration par environnement

# group_vars/all.yml
apache_defaults:
  port: 80
  max_clients: 150

# group_vars/local.yml
apache_port: 8080
debug_mode: true

# group_vars/production.yml
apache_port: 443
ssl_enabled: true

3. Gestion des secrets

# Utiliser Ansible Vault pour les secrets
ansible-vault create secrets.yml
ansible-playbook deploy.yml --ask-vault-pass

4. Monitoring et validation

- name: Validation post-déploiement
  block:
    - name: Vérifier les métriques système
      shell: |
        free -m
        df -h
        systemctl status apache2
      register: system_check

    - name: Test de charge simple
      uri:
        url: "http://{{ ansible_host }}"
        method: GET
      loop: "{{ range(1, 10) | list }}"
      loop_control:
        pause: 0.5

Comparaison des solutions

Solution Avantages Inconvénients Cas d'usage
LocalStack Émulation complète AWS, Nombreux services Consomme des ressources, Différences avec AWS réel Tests d'intégration complets
Moto Léger, Intégration Python native Limité à Python, Mock partiel Tests unitaires Python
Vagrant VM complète, Proche de la production Lent à démarrer, Consomme beaucoup de ressources Tests end-to-end
Docker Rapide, Léger Pas de VM complète, Limitations réseau Tests de conteneurs
Molecule Framework de test complet, CI/CD ready Courbe d'apprentissage Tests de rôles Ansible

Conclusion

Le test-driven infrastructure n'est plus un luxe mais une nécessité pour garantir la fiabilité de nos déploiements. En combinant des outils comme LocalStack, Vagrant et Ansible avec une stratégie de tests bien pensée, nous pouvons :

  • Développer notre infrastructure avec confiance
  • Réduire les coûts de développement
  • Accélérer les cycles de déploiement
  • Maintenir une qualité constante

La clé du succès réside dans le choix des bons outils pour chaque niveau de test et l'automatisation complète du processus via CI/CD.

Ressources complémentaires


Cet article vous a été utile ? N'hésitez pas à partager vos retours d'expérience sur l'infrastructure as code et vos stratégies de test !