Migration de mes projets Symfony sur Jenkins pipeline pour la CI/CD

Nous sommes en 2022 et oui j'utilise toujours Jenkins pour l'intégration et le déploiement continue de mes projets. Pourquoi? Parce que Jenkins fait le taff et il le fait bien, également car j'aime héberger mes outils et Jenkins me semble plus simple que Gitlab sur le sujet.
Je viens de migrer tous mes projets PHP Symfony dans l'ancienne définition des Job Jenkins avec Phing (un port de Ant en PHP) en utilisant maintenant les Pipelines déclaratives de Jenkins.

En moyenne j'ai 2 jobs par projet. Un qui s’exécute sur toutes les branches du repository Git pour valider les non régressions du code et sa qualité. Un autre qui ne s'exécute que sur la branche "stable" que j'utilise pour déployer en production avec un minimum de vérification.

Une Pipeline déclarative est par convention dans un fichier nommé Jenkinsfile donc la structure ressemble à ça:

pipeline {
    agent any
 
    stages {
 
    }
    post {
 
    }
}
 

En fonction de mes projets j'utilise 4 ou 5 stages. Le premier me sert à construire le projet et initialiser l'environnement. Rien d'extraordinaire, j'installe les dépendances avec Composer, j'en ajoute quelques unes et je nettoie les dossiers du build qui vont me servir à enregistrer les résultats.

stage('Build') {
    steps {
        parallel(
            composer: {
                sh 'composer install --prefer-dist --optimize-autoloader'
                sh 'composer require --dev phpmetrics/phpmetrics friendsofphp/php-cs-fixer --no-interaction --prefer-dist --optimize-autoloader'
            },
            'prepare-dir': {
                sh 'rm -Rf ./build/'
                sh 'mkdir -p ./build/coverage'
                sh 'mkdir -p ./build/logs'
                sh 'mkdir -p ./build/phpmetrics'
            }
        )
    }
}


Le second stage me sert à tester l'application avec PHPUnit et pour certains projet Behat. J'utilise également des linters pour m'assurer que le code ne contient pas d'erreur de syntaxe.

stage('Linter & Test') {
    steps {
        parallel(
            'cache-clear prod': {
                sh 'APP_ENV=prod ./bin/console cache:clear'
            },
            'php-lint': {
                sh 'php -l src/'
            },
            'symfony-container': {
                sh './bin/console lint:container'
            },
            'symfony-yaml': {
                sh './bin/console lint:yaml config/ src/ --parse-tags'
            },
            'doctrine-mapping': {
                sh './bin/console doctrine:schema:validate --skip-sync'
            },
            phpunit: {
                sh 'vendor/bin/phpunit --configuration ./phpunit.xml --log-junit ./build/logs/phpunit.junit.xml --coverage-html ./build/coverage --coverage-cobertura ./build/logs/coverage.corbutera.xml'
            },
            /*behat: {},*/
            failFast: true
        )
    }
}

La dernière option "failFast: true" me permet d’arrêter la pipeline à la première erreur pour avoir un retour plus rapide.

Le 3ème stage me sert à l'analyse du code. C'est bien d'avoir des tests mais je trouve que l'analyse statique du code permet également d'obtenir du feedback sur la qualité du code et de détecter des problèmes que les tests n'auraient pas couvert. Je ne vais pas débattre des outils, ce sont ceux que je trouve utile pour mon usage.

stage('Analyze') {
    steps {
        parallel(
            phpstan: {
                sh 'vendor/bin/phpstan --configuration=./phpstan.neon --no-progress --error-format=checkstyle > ./build/logs/phpstan.xml'
            },
            phpmetrics: {
                sh 'vendor/bin/phpmetrics --report-html=./build/phpmetrics/ --junit=./build/logs/phpunit.junit.xml --report-violations=./build/logs/phpmetrics.violations.xml --quiet ./src/'
            },
            'php-cs-fixer': {
                /* IMPORTANT: use returnStatus to catch failed exit code and don't mark build FAILED */
                sh returnStatus: true, script: 'vendor/bin/php-cs-fixer fix --dry-run --format=checkstyle --config=.php-cs-fixer.php --using-cache=no > ./build/logs/checkstyle.xml'
            },
            'cpd-back': {
                /* IMPORTANT: use returnStatus to catch failed exit code and don't mark build FAILED */
                sh returnStatus: true, script: 'phpcpd.phar --exclude=Test --exclude=vendor --log-pmd=./build/logs/cpd.back.xml src/'
            },
            'pmd': {
                sh 'phpmd.phar src/ text codesize,cleancode,controversial,design --ignore-errors-on-exit --ignore-violations-on-exit --reportfile ./build/logs/pmd.xml'
            }
        )
    }
}

L'idée étant d'avoir de l'analyse et non de contrôler, j'utilise l'option "returnStatus: true" pour php-cs-fixer et cpd afin de ne pas faire échouer la pipeline dans le cas où des erreurs seraient trouvées.

Ensuite vient la vérification des failles de sécurités connues dans les dépendances du projet. J'utilise 2 outils, Snyk et Symfony cli, on pourraient croire qu'ils sont redondant mais ce n'est pas le cas. J'ai déjà eu des CVE remontées par Snyk qui étaient ignorées de Symfony. De plus Snyk ne se limite pas au PHP, je peux l'utiliser pour le javascript et docker.

stage('Security') {
    environment {
        SNYK_API_TOKEN = credentials('snyk')
    }
    steps {
        parallel(
            'snyk-back': {
                sh 'SNYK_TOKEN=$SNYK_API_TOKEN snyk test --file=composer.lock'
            },
            symfony: {
                sh 'symfony local:check:security'
            }
        )
    }
}

La clé d'API de Snyk est déclaré dans le système global de gestion des mots de passes de Jenkins. J'ai donc besoin de la récupérer, c'est ce à quoi me sert la partie "environment{}".

Une fois que tout a été exécuté avec ou sans erreurs, il est temps de récolter les données, les publier et les graffer. C'est la raison principal pour laquelle je reste fidèle à Jenkins, il est vraiment facile d'avoir du feedback visuel via des graphiques et de consulter les rapports.
J'utilise l'étape "post" qui est exécuté à la fin de la pipeline. L'instruction "always{}" m'assure de toujours avoir les rapports.
Grace aux différents plugin de Jenkins, je peux publier des rapports HTML mais aussi parser des fichiers XML de différent formats. C'est la partie un peu compliqué, trouver le bon plugin pour le bon format, j'y ai passé pas mal de temps.

always {
    publishHTML([allowMissing: false, alwaysLinkToLastBuild: false, keepAll: false, reportDir: './build/coverage/', reportFiles: 'index.html', reportName: 'Rapport couverture de code', reportTitles: ''])
    publishHTML([allowMissing: false, alwaysLinkToLastBuild: false, keepAll: false, reportDir: './build/phpmetrics/', reportFiles: 'index.html', reportName: 'Rapport phpmetrics', reportTitles: ''])
    junit skipPublishingChecks: true, testResults: '**/build/logs/phpunit.junit.xml'
    cobertura autoUpdateHealth: false, autoUpdateStability: false, coberturaReportFile: '**/build/logs/coverage.corbutera.xml', conditionalCoverageTargets: '70, 0, 0', failUnhealthy: false, failUnstable: false, lineCoverageTargets: '80, 0, 0', maxNumberOfBuilds: 0, methodCoverageTargets: '80, 0, 0', onlyStable: false, sourceEncoding: 'ASCII', zoomCoverageChart: false
    recordIssues enabledForFailure: true, tools: [phpStan(id: 'phpstan', name: 'PHPStan', pattern: '**/build/logs/phpstan.xml', reportEncoding: 'UTF-8')]
    recordIssues enabledForFailure: true, tools: [junitParser(id: 'phpunit', name: 'PHP Unit', pattern: '**/build/logs/phpunit.junit.xml', reportEncoding: 'UTF-8')]
    recordIssues enabledForFailure: true, tools: [checkStyle(id: 'checkstyle', name: 'PHP CS Fixer', pattern: '**/build/logs/checkstyle.xml', reportEncoding: 'UTF-8')]
    recordIssues enabledForFailure: true, tools: [cpd(id: 'cpdBack', name: 'CPD back', pattern: '**/build/logs/cpd.back.xml', reportEncoding: 'UTF-8')]
    recordIssues enabledForFailure: true, tools: [pmdParser(id: 'pmd', name: 'PMD', pattern: '**/build/logs/pmd.xml', reportEncoding: 'UTF-8')]
    recordIssues enabledForFailure: true, tools: [pmdParser(id: 'pmd-phpmetrics', name: 'PMD PhpMetrics', pattern: '**/build/logs/phpmetrics.violations.xml', reportEncoding: 'UTF-8')]
    script {
        currentBuild.result = currentBuild.result ?: 'SUCCESS'
        notifyBitbucket()
    }
}


A la fin, une page de pipeline Jenkins ressemble à ça. Je peux accéder aux rapports sur la gauche, j'ai l’historique des builds au milieu et  des métriques sur le code sous forme de graphique qui me permet de voir l'évolution dans le temps.

Jenkins job dashboard


Il manque encore 2 choses dans mes builds. La gestion du code front avec le build des assets CSS et Javascript ainsi que les tests (peut être un jour). Ainsi que l'utilisation de Docker pour avoir une matrice de test sur les différentes version de la stack afin d'anticiper les mises à jour.

Mais pour le moment, je suis satisfait avec cette première étape et grâce à l'instruction "parrallel", les builds sont beaucoup plus rapides.

Ajouter un commentaire