Industrialisation with Drupal 8

I had already mentioned the way I work with Drupal 7. Having lately begun to adopt Drupal 8 for good, I hereby review my current workflow.

Installation profile

As with Drupal 7, I keep relying on an installation profile per project. See my Fonamental installation profile. The branch we are interested in is 8.x-1.x.

This installation profile is systematically renamed for every project (associated with a unique prefix), see the README file which quickly sums up the steps I follow for every new project.

Composer

One of the greatest change is Composer (see composer.json), which for me, who was accostumed to drush make (which had its limitations, in particular when you wanted to update an existing platform), is a great improvement. Others, more used to placing the whole platform under version management, have more trouble with that. I have never been a great fan of this method, as I prefer to neatly distinguish what comes from the community, and custom developments (including configuration). True, behind the scenes, you depend on network access and external repositories and platforms, which can have glitches sometimes. However, considering the great multiplicity of dependencies (libraries, etc.) we increasingly rely on, I appreciate being able to easily manage updates with a tool like Composer.

One difference from my 7 branch, is that, from now on, I have one more directory, above Drupal root, which includes:

  • a composer.json file, which describes your platform;
  • a composer.lock file you keep under version management to ensure you get an identical platform when you type composer install (as opposed to composer update);
  • a vendor directory containing external dependencies (Symfony, etc.) Drupal and modules rely on; locating this directory one level above Drupal is recommended for security reasons.

Configuration management

Drupal 8 now natively integrates configuration management. Features still exists, but its developers insist that we should no longer use it for deployment.

As for me, I lost a lot of time on my first Drupal 8 project trying to reproduce my Drupal 7 workflow, before I resigned myself to use drush config-export and drush config-import. This model has its advantages as well as its drawbacks:

  • You have to start up from the same database for all instances (see this issue, which seems to be far from being solved), unless you decide to use the Configuration Installer profile, which I see as a last resort solution (I want to use my own installation profile). As an unpleasant consequence, although you publicly share your developments and your configuration, a third party will not be able to use it that easily (unless of course you explicitly bundle it with Features).
  • You cannot select the configuration to be exported, you have to export the configuration of the whole site (except for the --skip-modules argument). Now, there are elements wich can differ from one instance to another —think about the homepage if it is a node— and you are not willing to resynchronize, or parameters of an indexation server. You will have to manually override them in settings.php (o better, local.settings.php).
  • As a corollary of the last point, you cannot delimit a configuration which can legitimately altered by a user. Which Features, not exporting it was enough, and you could forget it. I plan to use the Configuration Read-only mode module.
  • That being said, you have nothing to do but execute drush config-export1 and drush config-import commands, hence less time taken to select elements to be exported. Along the same lines, it is very easy to export a project configuration, even when this aspect was never considered by a previous development team.

Keep in mind, though, that I still use the Features modules, which still exists in Drupal 8, but only to bundle a configuration set, allowing you to create some configuration on your development environment, which you will then export with core standard tools. Afterwards, the feature becomes useless and can be uninstalled. That is how my Fonamental installation profile includes features which generate configuration at install time in a modular way (I can choose not to enable the Blog feature, for instance).

This, apart from I mentioned above, works quite well when your project only includes a website. At the current time, I do no know which approach I would take to migrate to Drupal 8 a galaxy of similar websites, like in one of my last projects, which bothers me. I guess There is no other way than using Features at some point, but I do not know exactly what dose of it. At any rate, maintaining a sync directory per site is not an option.

About themes

Working Drupal 8 base theme are not legion. This post gives a good recap of the current situation. It looks like more and more people now have a preference for frameworks like Bootstrap, instead of base themes as we understood them in Drupal 7. Having no appetence for Bootstrap, and as I did not want to start from scratch with a theme directly inheriting from Classy (or Stable, but I find its markup perfectly logic and adapted to my use), I finally chose Neato, hence my Byrrh base theme (a reference to Bourbon2), which provides me with SASS and automatic browser reload, thanks to Gulp.

For the speleology gathering website, I was in a hurry, and thus, as an exception, I started from Bartik, which did the job given what I needed to do, but frankly this was an occasion to confirm SASS is a tool I am quite fond of. I know not everybody agrees, but I find unbearable having to repeat the same color zillions of times, and not being able to easily alter it when I am asked to change it.

Deployment tool

I use Jenkins, which is going to create tasks to be processed by Ægir, relying on mig5's original idea (see reference above).

When I push on a branch (master, stagin, or prod3), Jenkins is immediately triggered thanks to a Git hook. It then updates the branch on its local workspace, and launches the following command:

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

Where:

  • HOST is the domain name of the affected Ægir instance.
  • SITE is the website to deploy (domain name, corresponding to the drush alias automatically created by Ægir).
  • PROFILE is the installation profile machine name (rs2017 in the case of the spelelogy gathering).
  • master corresponds to the type of Ægir instance (you can define satellites, but I do not use this feature).
  • localhost is the database server used by Ægir.
  • ${BUILD_NUMBER} is a value defined by Jenkins, automatically incremented at every build.
  • fr is the website language.
  • CLIENT is the client defined by Ægir.

Here is this deployment.sh script:

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

This script relies on fabfile.py (requires the installation of Fabric):

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) 

So, at every deployment:

  1. Jenkins updates its local workspace with composer install. This way we ensure we do not overload the live instance, or cause transient errors, see what Andrea Pescetti wrote about it.
  2. This platform is copied onto the remote Ægir server, and declared.
  3. The website is migrated from the former platform to the new one. The migration task provided by Ægir is, in my opinion, the best way to update a Drupal instance, far superior to a simple drush up, were it only for the "rollback" occurring in case of error.

Additional tools

Automatically removing platforms managed by Ægir

In order to automatically remove empty platforms generated at every deployment, I use this 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');                                                                                      
}   

This script is called in a crontab:

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

With my new approach, I have one level more to remove above the Drupal platform. I use the following hook in /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)));                                           
    }                                                                                                                           
  }                                                                                                                             
}   

Automatically importing configuration at deployment

Again, I use a drush hook (in 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. One alias for this command is drush cex, the Drupal 8 development cycle took was really stressful for some developers ;-) ↩︎

  2. Byrrh is a Catalan alcoholic beverage. ↩︎

  3. You are free to use whatever scheme suits you, e.g. Gitflow. ↩︎