Industrialisation avec Drupal 8

J'avais évoqué il y a déjà quelque temps ma manière de travailler en drupal 7. Ayant ces derniers mois commencé à adopter Drupal 8 pour de bon, je fais ici le point sur mes méthodes de travail actuelles.

Profil d'installation

Comme avec Drupal 7, je continue à m'appuyer sur un profil d'installation par projet. Voyez mon profil d'installation de départ, nommé Fonamental (la branche qui nous intéresse est la 8.x-1.x).

Ce profil d'installation est systématiquement renommé pour chaque projet (auquel j'associe un préfixe unique), voir le fichier README qui résume rapidement les étapes que je suis pour chaque nouveau projet.

Composer

La grande nouveauté est que j'utilise désormais Composer (voir le fichier composer.json), ce qui pour moi, qui avais l'habitude de drush make (qui avait ses limitations, notamment la mise à jour d'une plate-forme existante), est une grande amélioration. D'autres, qui avaient l'habitude de mettre sous gestion de version l'ensemble de la plate-forme, ont plus de mal à s'y faire. Je n'ai jamais été adepte de cette dernière méthode, préférant distinguer très nettement ce qui provient de la communauté, et les développements spécifiques (y compris la configuration). L'envers du décor est que l'on dépend du réseau et de dépôts ou de plates-formes externes, qui peuvent parfois dysfonctionner. Cependant, avec la grande multiplicité de dépendances (bibliothèques, etc.) sur lesquelles on s'appuie de plus en plus, je trouve très appréciable de pouvoir gérer simplement la mise à jour avec un outil tel que Composer.

La différence par rapport à ma branche 7, c'est que j'ai désormais recours à un niveau supérieur dans l'arborescence, placé au-dessus de la racine de Drupal, et comprenant notamment :

  • le fichier composer.json décrivant votre plate-forme ;
  • un fichier composer.lock que vous gardez sous gestion de version afin d'être assuré d'obtenir une plate-forme identique en tapant composer install (par opposition à composer update) ;
  • le répertoire vendor contenant les dépendances externes (Symfony, etc.) sur lesquelles s'appuient Drupal et les différents modules ; il est conseillé de le placer à ce niveau et non pas à la racine du site comme c'est le cas par défaut lorsque vous téléchargez Drupal depuis la page du projet.

Gestion de la configuration

Drupal 8 intègre désormais nativement une gestion de la configuration. Features existe toujours, mais les développeurs insistent pour que l'on ne l'utilise plus pour le déploiement.

En ce qui me concerne, j'ai perdu énormément de temps sur mon premier projet en Drupal 8 à vouloir reproduire mon mode de fonctionnement avec Drupal 7, avant de me résigner à utiliser drush config-export et drush config-import. Ce modèle a ses avantages et ses inconvénients :

  • Vous êtes obligé de partir de la même base de données pour toutes les instances, voir ce ticket, qui à mon avis n'est pas près d'être résolu, à moins d'utiliser le profil d'installation Configuration Installer, ce qui à mon sens n'est qu'un pis-aller (en tout état de cause je tiens à utiliser mon propre profil d'installation). Cela a la désagréable conséquence que je peux toujours mettre sous gestion de versions et partager publiquement mes développements, la configuration que je mets à disposition ne pourra pas être aisément utilisée par une tierce personne.
  • Vous ne pouvez plus choisir la configuration que vous voulez exporter, vous êtes obligé d'exporter toute la configuration du site (à un paramètre --skip-modules près), or il y a des éléments qui peuvent naturellement varier d'une instance à une autre — penser à la page d'accueil du site si c'est un nœud — et que vous n'avez pas forcément envie de resynchroniser depuis la prod ou la pré-prod à tout bout de champ, ou bien encore les paramètres d'une instance d'indexation. Vous devrez les surcharger manuellement dans le fichier settings.php (ou mieux local.settings.php).
  • Un corollaire de ce dernier point est que vous ne pouvez pas délimiter une configuration qui peut être légitimement modifiée par un utilisateur. Avec Features, il suffisait de ne pas l'exporter, et vous pouviez l'oublier. Pour ma part, j'envisage l'utilisation du module Configuration Read-only mode.
  • Ceci dit, vous n'avez du coup plus rien à faire à part exécuter vos commandes drush config-export1 et drush config-import, d'où moins de temps à passer à sélectionner les éléments à exporter. Dans le même ordre d'idées, il est du coup très facile d'exporter la configuration d'un projet même lorsque cet aspect n'a jamais été considéré par une équipe de développement vous ayant précédé.

Attention ceci dit, je me sers toujours du module Features, qui existe toujours en Drupal 8, mais uniquement pour empaqueter un ensemble de configuration, c'est à dire pour créer une configuration sur votre environnement de développement, après quoi vous l'exportez avec les outils standard du cœur, et vous pouvez même désinstaller votre feature. C'est ainsi que mon profil d'installation Fonamental comprend des features permettant de générer à l'installation une configuration de manière modulaire (je peux choisir de ne pas activer les actualités, par exemple).

Cela fonctionne parfaitement (aux réserves émises ci-dessus près) lorsque votre projet ne concerne qu'un seul site. À l'heure actuelle, je ne sais pas quelle approche j'adopterais pour un passage en Drupal 8 d'une galaxie (ou usine, mais ça fait moins rêver) de sites tous semblables (cf. un de mes derniers projets), ce qui me chiffonne passablement. Je suppose qu'il n'y a pas d'autre manière que de faire intervenir Features à un moment ou à un autre, mais je ne sais pas exactement à quelle dose — maintenir un répertoire sync par site est en ce qui me concerne tout à fait exclus.

À propos des thèmes

Les thèmes de base fonctionnels en Drupal 8 ne sont pas légion. La situation est assez bien résumée ici. Il semblerait que de plus en plus de gens s'orientent non plus tant vers des thèmes de base comme on les concevait encore en Drupal 7, que vers des cadriciels comme Bootstrap. N'ayant guère d'appétence pour ce dernier, et ne désirant pas partir de zéro en héritant directement de Classy (ou de Stable, c'est au choix, mais je trouve le balisage de Classy parfaitement logique et adapté à mon usage), j'ai finalement opté pour Neato, d'où finalement mon thème de départ Byrrh en référence à Bourbon2, avec lequel je dispose de SASS et du rechargement automatique du navigateur, grâce à Gulp.

Pour le site du rassemblement spéléo, j'étais dans l'urgence, et très exceptionnellement je suis parti de Bartik, qui ma foi était à peu près potable pour ce que je voulais en faire, mais ç'a été l'occasion de confirmer que SASS est définitivement un outil qui m'est très utile — j'ai conscience que tout le monde n'est pas forcément d'accord avec cette opinion, mais je trouve insupportable de devoir répéter trois zillions de fois la même couleur, et de ne pas pouvoir revenir dessus facilement en cas de demande de changement.

Outil de déploiement

J'utilise Jenkins, qui va créer des tâches à traiter par Ægir, sur une idée originelle de mig5 (voir billet précédent référencé en début d'article).

Lorsque je pousse sur une branche (master, staging ou prod3), Jenkins en est immédiatement averti grâce à un Git hook. Il Met alors la branche à jour en local, et lance la commande suivante :

/chemin/vers/script/deployment.sh HOST SITE PROFILE master localhost ${BUILD_NUMBER} fr CLIENT

Où :

  • HOST est le nom de domaine de l'instance Ægir concernée.
  • SITE est le site à déployer (nom de domaine, en correspondance avec l'alias drush créé automatiquement par Ægir).
  • PROFILE est le nom machine du profil d'installation (rs2017 dans le cas du rassemblement spéléo).
  • master correspond au type d'instance Ægir (on peut définir des satellites, mais je ne me sers pas de cette possibilité).
  • localhost est le serveur de base de données utilisé par Ægir.
  • ${BUILD_NUMBER} est une valeur définie par Jenkins s'incrémentant automatiquement à chaque construction.
  • fr est la langue du site.
  • CLIENT est le client définit par Ægir.

Voici le script deployment.sh en question :

#!/bin/bash                                                                                                                     
#                                                                                                                               
# Wrapper script for our fabfile, to be called from Jenkins                                                                     
#                                                                                                                               
 
# Where our fabfile is                                                                                                          
FABFILE=/opt/scripts/jenkins/drupal8/fabfile.py                                                                                 
 
HOST=$1                                                                                                                         
SITE=$2                                                                                                                         
PROFILE=$3                                                                                                                      
WEBSERVER=$4                                                                                                                    
DBSERVER=$5                                                                                                                     
BUILD_NUMBER=$6                                                                                                                 
LANG=$7                                                                                                                         
CLIENT=$8                                                                                                                       
BUILD=${PROFILE}_${BUILD_NUMBER}                                                                                                
REMOTE_DIRECTORY=/var/aegir/platforms/${BUILD}                                                                                  
 
if [[ -z $HOST ]] || [[ -z $SITE ]] || [[ -z $PROFILE ]] || [[ -z $WEBSERVER ]] || [[ -z $DBSERVER ]] || [[ -z $BUILD_NUMBER ]] || [[ -z $BUILD_TAG ]]                                                                                                          
then                                                                                                                            
  echo "Missing args! Exiting"                                                                                                  
  exit 1                                                                                                                        
fi                                                                                                                              
 
 
# Array of tasks - these are actually functions in the fabfile, as an array here for the sake of abstraction                    
TASKS=(                                                                                                                         
  build_platform                                                                                                                
  migrate_site                                                                                                                  
  save_alias                                                                                                                    
  import_site                                                                                                                   
)                                                                                                                               
 
set -x                                                                                                                          
 
# Build platform.                                                                                                               
# TODO: rajouter --no-dev.                                                                                                      
composer install --no-ansi -n                                                                                                   
tar czf - . | ssh aegir@$HOST "(mkdir ${REMOTE_DIRECTORY} && tar xzf - -C ${REMOTE_DIRECTORY})"                                 
 
# Loop over each 'task' and call it as a function via the fabfile,                                                              
# with some extra arguments which are sent to this shell script by Jenkins                                                      
for task in ${TASKS[@]}; do                                                                                                     
  fab -f $FABFILE -H $HOST $task:site=$SITE,profile=$PROFILE,webserver=$WEBSERVER,dbserver=$DBSERVER,build=$BUILD,lang=$LANG,client=$CLIENT || exit 1                                                                                                           
done   

Ce script sur repose sur fabfile.py (nécessitant l'installation de Fabric), que voici :

from fabric.api import *                                                                                                        
import time                                                                                                                     
 
env.user = 'aegir'                                                                                                              
env.shell = '/bin/bash -c'                                                                                                      
 
# Download and import a platform using Composer                                                                                 
def build_platform(site, profile, webserver, dbserver, build, lang, client):                                                    
  print "===> Registering the platform..."                                                                                      
  run("drush --root='/var/aegir/platforms/%s/docroot' provision-save '@platform_%s' --context_type='platform'" % (build, build))
  run("drush @hostmaster hosting-import '@platform_%s'" % build)                                                                
  run("drush @hostmaster hosting-dispatch")                                                                                     
 
# Migrate a site to a new platform                                                                                              
def migrate_site(site, profile, webserver, dbserver, build, lang, client):                                                      
  print "===> Migrating the site to the new platform"                                                                           
  run("drush @%s provision-migrate '@platform_%s'" % (site, build))                                                             
 
# Save the Drush alias to reflect the new platform                                                                              
def save_alias(site, profile, webserver, dbserver, build, lang, client):                                                        
  print "===> Updating the Drush alias for this site"                                                                           
  run("drush provision-save @%s --context_type=site --uri=%s --platform=@platform_%s --server=@server_%s --db_server=@server_%s --profile=%s --language=%s --client_name=%s" % (site, site, build, webserver, dbserver, profile, lang, client))                 
 
# Import a site into the frontend, so that Aegir learns the site is now on the new platform                                     
def import_site(site, profile, webserver, dbserver, build, lang, client):                                                       
  print "===> Refreshing the frontend to reflect the site under the new platform"                                               
  run("drush @hostmaster hosting-task --force @platform_%s verify" % build)                                                     
  run("drush @hostmaster hosting-import @%s" % site)                                                                            
  run("drush @hostmaster hosting-task --force @%s verify" % site) 

Ainsi, à chaque déploiement :

  1. Jenkins met à jour en local son espace de travail avec composer install. Ceci a l'avantage de ne pas encombrer l'instance de prod, voire pire d'y causer des erreurs transitoires, voir ce qu'en dit Andrea Pescetti.
  2. Cette plate-forme est copiée sur le serveur Ægir distant, et déclarée.
  3. Le site est migré de l'ancienne plate-forme vers la nouvelle. La migration fournie par Ægir est à mon sens la meilleure manière de mettre à jour une instance Drupal, bien supérieure à un simple drush up, ne serait-ce que pour le « rollback » qu'il opère en cas d'erreur.

Outils annexes

Suppression automatique de plates-formes gérées par Ægir

Pour supprimer automatiquement les plates-formes vides générées à chaque déploiement, javais l'habitude de m'en remettre à ce script (cleanup.drush) :

#!/usr/bin/env drush                                                                                                            
 
<?php                                                                                                                           
 
//Cleanup script to delete empty autogenerated platforms older than 2 hours                                                     
//Cf. http://community.aegirproject.org/discuss/our-zero-touch-build-setup-continuous-integration-lite                          
 
$sql = "SELECT n.nid FROM hosting_platform AS p                                                                                 
  INNER JOIN node AS n ON (p.nid=n.nid)                                                                                         
  LEFT JOIN hosting_site AS s ON (p.nid=s.platform)                                                                             
  WHERE s.platform IS NULL AND n.created < (UNIX_TIMESTAMP() - 7200) AND p.status = 1;";                                        
 
$result = db_query($sql);                                                                                                       
while ($row = $result->fetchAssoc()) {                                                                                          
  hosting_add_task($row['nid'], 'delete');                                                                                      
}   

Ce script est appelé dans une crontab :

30 2 * * * /usr/bin/env php /usr/local/bin/drush '@hostmaster' scr /opt/scripts/aegir/cleanup.drush

Avec ma nouvelle approche, j'ai désormais un niveau de plus à supprimer en plus de la plate-forme Drupal. J'utilise le hook drush suivant dans /var/aegir/.drush/restelae/auto_delete_ci.drush.inc :

<?php                                                                                                                           
 
/**                                                                                                                             
 * @file                                                                                                                        
 * Supprimer racine du projet en même temps que la plate-forme.                                                                 
 */                                                                                                                             
 
/**                                                                                                                             
 * Implements drush_hook_post_COMMAND().                                                                                        
 */                                                                                                                             
function drush_auto_delete_ci_post_provision_delete() {                                                                         
  if (d()->type != 'platform') {                                                                                                
    return;                                                                                                                     
  }                                                                                                                             
  $root_dir = dirname(d()->root);                                                                                               
  if (file_exists($root_dir . '/.ci_managed') && file_exists($root_dir . '/composer.json')) {                                   
    drush_log(dt('Deleting project root'));                                                                                     
    if (drush_delete_dir($root_dir)) {                                                                                          
      drush_log(dt('@root_dir successfully deleted', array('@root_dir' => $root_dir)));                                         
    }                                                                                                                           
    else {                                                                                                                      
      drush_log(dt('Deletion of @root_dir failed', array('@root_dir' => $root_dir)));                                           
    }                                                                                                                           
  }                                                                                                                             
}   

Importation automatique de la configuration au déploiement

À nouveau, j'utilise un hook drush (dans import_config_ci.drush.inc) :

<?php                                                                                                                           
 
/**                                                                                                                             
 * @file                                                                                                                        
 * Forcer l'importation de la configuration.                                                                                    
 */                                                                                                                             
 
/**                                                                                                                             
 * Implements drush_hook_post_COMMAND().                                                                                        
 */                                                                                                                             
function drush_import_config_ci_post_provision_verify() {                                                                       
  if (d()->type != 'site' || drush_drupal_major_version() != 8) {                                                               
    return;                                                                                                                     
  }                                                                                                                             
  $root_dir = dirname(d()->root);                                                                                               
  if (file_exists($root_dir . '/.ci_managed')) {                                                                                
    drush_log(dt('Importing sync configuration'));                                                                              
    $options = ['skip-modules' => 'devel,devel_generate,kint,dblog,field_ui'];                                                  
    if (provision_backend_invoke(d()->name, 'config-import', [], $options)) {                                                   
      drush_log(dt('Configuration successfully imported'), 'success');                                                          
    }                                                                                                                           
    else {                                                                                                                      
      drush_set_error('CONFIG_IMPORT_FAILED', dt('Configuration importation failed'));                                          
        drush_log(dt('Configuration importation failed'), 'error');                                                             
    }                                                                                                                           
  }                                                                                                                             
} 

  1. Commande qui s'abrège en drush cex, le cycle de développement de Drupal 8 a été décidément beaucoup trop stressant pour certains développeurs ;-) ↩︎

  2. Mais qui a le mérite de replacer les choses sur le bon continent, au bon endroit :-þ ↩︎

  3. Vous êtes libre d'adopter le schéma de votre choix, par exemple Gitflow. ↩︎