Comment tester fonctionnellement un projet legacy

Travailler sur un projet fraîchement démarré, c’est hype !

Mais beaucoup d’entre nous n’ont pas cette chance. Au travers d’une mission, j’ai dû mettre en place un système d’intégration continue sur un projet considéré par certains comme « legacy ». Le projet comporte plusieurs dizaines de milliers de lignes de code, une base de donnée MySQL d’environ 8Go et une autre base MongoDB de plus de 1Go.

Ce projet a déjà quelques tests unitaires, qui sont lancés par les développeurs en local, mais aucun test fonctionnel.

Première étape : CI et tests unitaires

Chez JoliCode, nous utilisons et aimons beaucoup Docker. Mon choix s’est naturellement porté vers celui-ci pour industrialiser la CI.

La Dockerisation de la stack (assez classique : PHP, Nginx, Yarn, MySQL, MongoDB, Redis) fut très rapide. Nous avons un « starter-kit » en interne que j’ai pu utiliser pour mettre les différents services dans des containers.

Vu que le client utilise déjà Gitlab, l’utilisation de GitLab CI était une évidence. Nous avons monté une nouvelle machine pour le runner, car c’est une très mauvaise idée de mettre le runner sur la même machine que GitLab (pourtant prévenu par la doc, on a essayé, on n’aurait pas dû). L’installation d’un runner est vraiment simple, cependant il faut choisir parmi certains types. Je ne voulais pas faire de Docker In Docker pour certaines raisons donc mon choix s’est porté sur un shell executor.

La première version de la CI ressemblait à ça:

tests:
    before_script:
        - fab ci build
        - fab ci install
    script:
        - fab ci tests
    after_script:
        - fab ci down

Tout est géré par fabric. Nous utilisons fabric comme wrapper de commandes docker-compose. Cependant, il n’est pas possible de lancer plusieurs builds en même temps sans invoquer le chaos.

Nous avons mis à jour nos scripts pour être capables de préfixer tous les objets Docker par un numéro :

- local('docker-compose -p project %s %s' % (...))
+ local('docker-compose -p project_%s %s %s' % (os.environ.get('CI_PIPELINE_ID'), ...))

Et voilà ! Les tests sont maintenant lancés à chaque push et de manière concurrente.

Deuxième étape : Tests fonctionnels

La homepage du site a besoin de MySQL, MongoDB et Redis. Mais avoir une base de données minimale (via des fixtures par exemple) était impossible. Les développeurs travaillent tous avec une copie anonymisée de la production.

Je me suis dit que la solution la plus rapide pour mettre en place des tests fonctionnels était de coder des tests contre un snapshot de la DB (anonymisée) de production. Comme les tests peuvent altérer l’état de la DB, il faut l’isoler pour chaque build. Les containers de base de données utilisent des volumes pour stocker les données. Il faut donc pré-remplir le volume avec les données de production. Via quelques commandes Fabric / Docker, nous les avons dans la CI et en dev :

env.job_id = os.environ.get('CI_PIPELINE_ID') or ''

@task
def volume_mysql_snapshot_restore(source=None):
    """
    Restore a database filesystem snapshot
    """
    docker_compose('stop mysql')

    if source == None:
        source = env.root_dir + '/docker-volume-mysql-snapshot'

    if not os.path.exists(source):
        raise Exception('The source "%s" does not exist.' % source)

    cmd = 'docker run --rm '
    cmd += '--volume project%s_mysql-data:/destination '
    cmd += '--volume %s:/source '
    cmd += 'alpine '
    cmd += '/bin/sh -c "rm -rf /destination/* && cp -r /source/. /destination"'
    local(cmd % (env.job_id, source))

    docker_compose('up -d mysql')

Et voilà ! Il est maintenant possible d’écrire des tests fonctionnels.

Cependant, copier 8Go pour MySQL et 1Go pour MongoDB, c’est lent. Environ 2 minutes de perdu à chaque push.

BTRFS à la rescousse

BTRFS est un système de fichiers qui permet de faire du Copy-On-Write.

À la place de copier les données dans un volume Docker, pourquoi ne pas utiliser un point de montage qui est un volume BTRFS ? C’est tout à fait possible et simple. Comme la CI n’avait pas de disque disponible, j’ai utilisé un fichier qui fait office de block device. Voici les étapes nécessaires :

# On contruit un FS (en fait un simple fichier de 12Go)
dd if=/dev/zero of=/var/lib/btrfs bs=1M count=12288

# On crée un FS BTRFS
mkfs.btrfs /var/lib/btrfs

# On crée un dossier pour monter ce FS
mkdir /mnt/btrfs
# On mount ce FS
echo '/var/lib/btrfs /mnt/btrfs btrfs defaults,compress 0 1' >> /etc/fstab
mount -a

Ensuite, il faut créer le volume qui contient les données de référence, puis copier le snapshot dedans :

btrfs subvolume create /mnt/btrfs/project-docker-volume-mysql-snapshot-v1
cp -ra /path/to/docker-volume-mysql-snapshot /mnt/btrfs/project-docker-volume-mysql-snapshot-v1

Puis, il faut mettre à jour la configuration GitLab pour utiliser ce volume.

Dans before_script, GitLab fait un snapshot du système de fichier utilisé par la base de donnée. Cette étape prend environ … quelques milli-secondes.

Dans after_script, GitLab va supprimer ce snapshot.

tests:
    before_script:
        - sudo btrfs subvolume snapshot /mnt/btrfs/project-docker-volume-mysql-snapshot-v1/ /mnt/btrfs/project-docker-volume-mysql-snapshot-${CI_PIPELINE_ID}
        - fab ci build
        - fab ci install
    script:
        - fab ci tests
    after_script:
        - fab ci down
        - sudo btrfs subvolume delete /mnt/btrfs/project-docker-volume-mysql-snapshot-${CI_PIPELINE_ID}

Le script fabric met à jour la valeur de env.mysql_volume (uniquement dans l’environement de CI) pour utiliser le snapshot BTRFS.

# default value
env.mysql_volume = "mysql-data"

@task
def ci():
    env.mysql_volume = '/mnt/btrfs/project-docker-volume-mysql-snapshot-' + env.job_id

def docker_compose(command_name):
    local('MYSQL_VOLUME=%s docker-compose -p project%s %s %s' % (
        env.mysql_volume,
        #...
    )

Le fichier docker-compose utilise maintenant cette variable pour choisir le volume à utiliser pour les data MySQL.

# docker-compose:
volumes:
    mysql-data: {}

services:
    mysql:
        build: ../services/mysql
        volumes:
            - "${MYSQL_VOLUME}:/var/lib/mysql"

Conclusion

GitLab-CI est vraiment agréable à utiliser et configurer. En ajoutant Docker à la stack, l’intégration entre les deux fût très rapide et simple.

Finalement, l’ajout de BTRFS pour gérer les snapshots de MySQL et MongoDB a permis d’économiser beaucoup de temps et de reduire notre empreinte carbone 😎

blog comments powered by Disqus