Industrialització amb Drupal 8

Havia esmentat ja fa estona la meva manera de treballar en Drupal 7. Havent aquests últims mesos començat a adoptar el Drupal 8 de debò, faig el balanç sobre els meus mètodes de treball actuals.

Perfil d'instal·lació

Tal com ho feia amb el Drupal 7, segueixo basant-me sobre un perfil d'instal·lació per projecte. Vegeu el meu perfil d'instal·lació inicial, Fonamental (la branca que ens és útil aquí és la 8.x-1.x).

Per cada projecte nou, li dono sistemàticament un nom específic, vegeu el fitxer README que resumeix ràpidament les etapes que segueixo.

Composer

La gran novetat és que d'ara endavant utilitzo Composer (vegeu el fitxer composer.json), el que per mi, acostumat a drush make (que tenia les seves limitacions, en particular per actualitzar una plataforma existent), és un gran avenç. A altres, que posaven sota gestió de versió la plataforma sencera, els costa més. Mai he sigut molt aficionat d'aquest darrer mètode, ja que prefereixo distingir molt netament el que prové de la comunitat, i els desenvolupaments propis (inclosa la configuració). Mirant l'altra cara, depenem així de la xarxa i de dipòsits o de plataformes externes, que a vegades poden no funcionar. Nogensmenys, amb la gran multiplicitat de dependències (biblioteques, etc.) sobre les quals ens basem cada vegada més, em sembla molt apreciable poder gestionar simplement les actualitzacions amb una eina com el Composer.

La diferència amb la meva branca 7.x-1.x, és que d'ara endavant empro un nivell superior dins l'arborescència, a sota de l'arrel del Drupal, que conté:

  • el fitxer composer.json, que descriu la vostra plataforma;
  • el fitxer composer.lock que guardeu sota gestió de versió per assegurar-vos que sempre obtindreu la mateixa plataforma amb l'ordre composer install (contrariàment al composer update);
  • el directori vendor que conté les dependències externes (Symfony, etc.), utilitzades pel Drupal i els diferents mòduls; per motiu de seguretat, és aconsellable posar-lo en aquest nivell i no pas a l'arrel del lloc com és el cas quan descarregueu el Drupal des de la pàgina del projecte.

Gestió de la configuració

El Drupal 8 integra ara de manera nadiva una gestió de la configuració. Features segueix existint, però els seus desenvolupadors insisteixen perquè no l'utilitzem pel desplegament.

En el meu cas, vaig perdre molt de temps sobre el meu primer projecte en Drupal 8 mirant de reproduir el meu procediment amb el Drupal 7, abans de finalment resignar-me a utilitzar drush config-export i drush config-import. Aquest model té els seus avantatges i inconvenients:

  • Esteu obligat a utilitzar la mateixa base de dades inicial per totes les instàncies, vegeu aquest tiquet que al meu parer no serà resolt d'aquí poc, excepte si utilitzeu el perfil d'instal·lació Configuration Installer, que em sembla un succedani — vull utilitzar el meu perfil d'instal·lació propi. Com a conseqüència desagradable, per molt que posi sota gestió de versió i comparteixi públicament els meus desenvolupaments, la configuració que proveeixo difícilment en podrà fer ús una tercera persona.
  • No podeu escollir la configuració que voleu exportar, esteu obligat a exportar tota la configuració del lloc (fent abstracció del paràmetre --skip-modules). Ara bé, hi ha elements que poden naturalment variar d'una instància a una altra —penseu a la pàgina d'inici si és un «node»— si no recupereu de manera expressa la base de dades de la instància de producció, o bé encara els paràmetres d'un servidor d'indexació. Els haureu de sobrecarregar manualment dins del fitxer settings.php (o millor local.settings.php).
  • Com a corol·lari del punt precedent, no podeu delimitar una configuració que pot ser legítimament modificada per l'usuari. Amb Features, en teníem prou no exportant-la, i ja la podíem oblidar. Per la meva part, contemplo l'ús del mòdul Configuration Read-only mode.
  • Dit això, ja no heu de fer res, excepte executar els ordres drush config-export1 i drush config-import. Llavors trigueu menys temps seleccionant els elements per exportar. També cal notar que és molt fàcil exportar la configuració d'un projecte fins i tot quan aquest aspecte no havia sigut considerat mai per un equip de desenvolupament anterior.

Atenció, segueixo emprant el mòdul Features amb el Drupal 8, però únicament per empaquetar un conjunt de configuració, és a dir per crear configuracions sobre el vostre entorn de desenvolupament, i després exportar-la amb les eines estàndard del cor. Fins i tot podeu llavors desinstal·lar la «feature». D'aquesta manera, el meu perfil d'instal·lació Fonamental conté features, el que permet generar a la instal·lació una configuració de manera modular (puc decidir no activar les actualitats, per exemple).

Això funciona perfectament (amb les reserves anteriors) quan el vostre projecte només conté un únic lloc. A hores d'ara, no sé quin mètode adoptaria per actualitzar en Drupal 8 una galàxia de llocs semblants (cf. un dels meus darrers projectes), el que m'empipa passablement. Suposo que no hi ha altra manera emprar Features en algun moment, però no sé exactament a quina dosi—excloc un directori sync per cada lloc.

Pel que fa als temes

Els temes de base en Drupal 8 no són molts. La situació està ben explicada aquí. Semblaria que cada vegada més gent s'orienten no pas cap a temes de base com els concebíem encara en Drupal 7, però cap a «frameworks» com el Bootstrap. Com que aquest últim em fa fàstic, i no desitjo començar des de zero amb Classy (o Stable, però trobo el marcatge de Classy perfectament lògic i adaptat al meu ús), al final em vaig decantar per Neato, d'aquí finalment el meu tema de base Byrrh en referència a Bourbon2, amb el qual disposo de SASS i del recarregament automàtic del navegador, gràcies al Gulp.

Pel lloc de l'aplec espeleo, dins la urgència, vaig escollir Bartik com a punt de partida, el que al final era acceptable pel que volia fer-ne, però va ser l'avinentesa per comprovar que SASS m'és realment molt útil—sé que no tothom pensa així, però no puc aguantar haver de repetir zilions de vegades el mateix color, i no poder modificar-lo fàcilment si em demanen un canvi.

Eina de desplegament

Utilitzo el Jenkins, que crea tasques pendents de tractar per l'Ægir, sobre una idea original del mig5 (vegeu article precedent esmentat a l'inici del text).

Quan empenyo sobre una branca (master, staging o prod3), el Jenkins immediatament n'és advertit gràcies a un Git Hook. Llavors, actualitza la branca en local, i executa l'ordre següent:

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

On:

  • HOST és el nom de domini de la instància Ægir concernida.
  • SITE és el lloc per desplegar (nom de domini, en correspondència amb l'àlies drush creat automàticament per l'Ægir).
  • PROFILE és el «nom màquina» del perfil d'instal·lació (rs2017 tractant-se de l'aplec d'espeleo).
  • master correspon al tipus d'instància Ægir (es pot definir satèl·lits, funcionalitat que no empro).
  • localhost és el servidor de base de dades utilitzat per Ægir.
  • ${BUILD_NUMBER} és un valor definit pel Jenkins automàticament incrementat a cada construcció.
  • fr és la llengua del lloc.
  • CLIENT és el client definit per Ægir.

Heus aquí el script deployment.sh:

#!/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   

Aquest script es basa sobre fabfile.py (que necessita la instal·lació del Fabric), que ara teniu:

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) 

Per cada desplegament:

  1. Jenkins actualitza el seu espai de treball amb composer install. Això té l'avantatge de no sobrecarregar la instància de producció, o encara pitjor arriscar-hi errors transitoris, vegeu allò que en diu l'Andrea Pescetti.
  2. Aquesta plataforma està copiada al servidor Ægir, i declarada.
  3. El lloc està migrat de l'antiga plataforma cap a la nova. La migració proveïda per l'Ægir és, a parer meu, la millor manera d'actualitzar una instància Drupal, ben superior a un simple drush up, encara que només fos pel «rollback» en cas d'error.

Eines annexes

Supressió automàtica de plataformes gestionades amb l'Ægir

Per suprimir automàticament les plataformes buides generades a cada desplegament, empro aquest 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');                                                                                      
}   

El script està cridat dins una crontab:

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

Amb el meu nou procediment, ara cal que suprimeixi el nivell a sota de la plataforma Drupal. Utilitzo el hook de drush següent a dins de /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)));                                           
    }                                                                                                                           
  }                                                                                                                             
}   

Importació automàtica de la configuració al desplegament

De nou, utilitzo un hook drush (a 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. Ordre que s'abreuja en drush cex, el cicle de desenvolupament del Drupal 8 de debò va ser massa pesat per alguns desenvolupadors ;-) ↩︎

  2. Però que té el mèrit tornar situar les coses sobre el bon continent, al bon lloc :-þ ↩︎

  3. Podeu adoptar l'esquema que millor us convingui, com per exemple Gitflow. ↩︎