Parallel Docker builds for the wp-browser project - 01

The problem

In a perfect world, tests should “fail fast”.
Failing “fast” means the tests should fail as soon as possible, providing me enough feedback to go back, fix the code, and try again.
As the volume of tests and test suites mounts, this proposition is far removed from the seconds-long run time of unit tests to move into the minutes-long run time of multiple acceptance suites.
Even worse: at times the testing load is so heavy for a local machine, or a locally-deployed infrastructure, that tests time out or otherwise fail for the wrong reasons.

I run into this issue while working on wp-browser and dealing with its test base; the solution is to close any other application and cross my fingers as my laptop churns along.
Yet this is not a sustainable approach.

In this series of posts I try and document the steps, and the thought process behind those steps, to set up a Docker-based parallel build that I should be able to run locally and on the build system of choice (currently Travis CI).
Without having to quit the world to run them from start to finish, possibly.

The files I’m showing in these posts are current to wp-browser version 2.2.18.

Disclaimer about my incompetence

I’m not a DevOps person. I know how to set up and use the systems I need to deliver code, but not much more. I’m laying out my reasoning and documenting my approaches, but those might be wrong at any step: please keep that in mind while reading through.

The current build system

Before I start to write code like a mad man it’s worth illustrating what I have now as a working (although with some hiccups, at times) build system.

When I open a Pull-Request or push code in any way Travis CI runs the build from this configuration file (you can see the configuration file here):

sudo: required

language: php

notifications:
  email: false

matrix:
  include:
    - php: '5.6'
      env: CODECEPTION_VERSION="^2.5"
    - php: '5.6'
      env: CODECEPTION_VERSION="^3.0"
    - php: '7.0'
      env: CODECEPTION_VERSION="^2.5"
    - php: '7.0'
      env: CODECEPTION_VERSION="^3.0"
    - php: '7.1'
      env: CODECEPTION_VERSION="^2.5"
    - php: '7.1'
      env: CODECEPTION_VERSION="^3.0"
    - php: '7.2'
      env: CODECEPTION_VERSION="^2.5"
    - php: '7.2'
      env: CODECEPTION_VERSION="^3.0"

services:
  - docker

cache:
  apt: true
  directories:
    - $HOME/.composer/cache/files

addons:
  hosts:
    - wp.test
    - test1.wp.test
    - test2.wp.test
    - blog0.wp.test
    - blog1.wp.test
    - blog2.wp.test
    - mu-subdir.test
    - mu-subdomain.test

env:
  global:
    - WP_FOLDER="vendor/johnpbloch/wordpress-core"
    - WP_URL="http://wp.test"
    - WP_DOMAIN="wp.test"
    - DB_NAME="test_site"
    - TEST_DB_NAME="test"
    - WP_TABLE_PREFIX="wp_"
    - WP_ADMIN_USERNAME="admin"
    - WP_ADMIN_PASSWORD="admin"
    - WP_SUBDOMAIN_1="test1"
    - WP_SUBDOMAIN_1_TITLE="Test Subdomain 1"
    - WP_SUBDOMAIN_2="test2"
    - WP_SUBDOMAIN_2_TITLE="Test Subdomain 2"
  matrix:
    - WP_VERSION=latest

before_install:
  - make ci_before_install
  - make ensure_pingable_hosts
  # Make Composer binaries available w/o the vendor/bin prefix.
  - export PATH=vendor/bin:$PATH

install:
  - make ci_install

before_script:
  - make ci_before_script

script:
  - make ci_script
  - php docs/bin/sniff

I’ve configured the build to run a matrix of tests for PHP and Codeception versions, but There are some parts here that are worth pointing out in more detail.

I use make

You can see the Makefile here.

I find myself comfortable using make and Makefiles to automate my stuff.
I would not use make in code I share with others, but I’m fine using it here.
I’ve been using make in my previous life and enjoy its low-level approach to the tasks; if you’ve never used make before it’s (I apologize to the experts for the over-simplification) an automation tool like composer run, npm run, gulp, grunt and similar would be.
But meaner and badder.

I run the tests on the host

I currently run the tests from the host machine, as one can see looking at the part of the Makefile in charge of running the tests, the make ci_script target. I use a docker-compose stack to spin up the containers I need, set them up, and run the tests from the host machine relying on the containers to serve the web requests and the database.
This detail is crucial as it’s one of the limiting factors of the current build system: since each container with access to the code might modify the content of the host files (e.g., by adding a must-use plugin) having multiple Docker containers all accessing the same host files at the same time could lead to inconsistencies.

You can see the docker-compose file here.

I run the test suites sequentially

You can see the relevant make target here.

Due to limitations imposed by WordPress use of globals and constants, I’m running each suite with a dedicated command.
I want, and must, keep this behavior, but I would like the test runs to be non-blocking and parallelized, not dissimilar to what Codeception documentation suggests.
My initial idea is to leverage make built-in parallelism support to run each suite in parallel to the others; it might not be attainable, but I would like to keep the moving parts simple, if I can.

Objective 1: running unit tests in the container

I’ve forked latest master, version 2.2.18 of wp-browser, and started working on that.
The first objective is the ability to run the unit suite tests with one command, and the tests should run in the container.
Furthermore, I should be able to debug the tests using XDebug; this means I will bind files into the container in place of relying on the container-managed files.

I want to keep the ci_script target in the Makefile to “hide” more complex commands, issued to the docker-compose binary, in it, but am not against having something I could run in a Composer script too.

I feel the following command is an acceptable “API” to run the unit suite from the project root folder:

docker-compose run --rm wpbrowser run unit

Breaking down the command a bit: * I’m running the wpbrowser container and removing it afterward, which means I’m using the container as an executable * in that container I’m running the run unit command

I’ll be implementing the build infrastructure with a test-driven development approach; with some adaptations, of course.
Running the command, right now, yields this error:

ERROR:
        Can't find a suitable configuration file in this directory or any
        parent. Are you in the right directory?

        Supported filenames: docker-compose.yml, docker-compose.yaml

Time to create a docker-compose.yml file in the root folder:

version: '3.2'

services:

  wpbrowser:
    # Instead of using a pre-built image, let's build it from the file, see later.
    build:
      context: ./docker/wpbrowser
      dockerfile: Dockerfile
      args:
      # In the CI environment I need to test against different PHP versions.
      # Default to 7.3 but allow for its override.
        - BUILD_PHP_VERSION=${TEST_PHP_VERSION:-7.3}
    volumes:
      # Bind the project files into the /project directory.
      - ./:/project

In the docker/wpbrowser folder I’ve created the following Dockerfile:

# Take the BUILD_PHP_VERSION argument into account, default to 7.3.
ARG BUILD_PHP_VERSION=7.3

# Build from the php-cli image based on Debian Buster.
FROM php:${BUILD_PHP_VERSION}-cli-buster

LABEL maintainer="luca@theaveragedev.com"

# Set PHP timezone to avoid warnings.
RUN echo "date.timezone = UTC" >> /usr/local/etc/php/php.ini

# Create the /project directory and change to it.
WORKDIR /project

# Create an executable wrapper to execute the project vendor/bin/codecept binary appending command arguments to it.
RUN echo '#! /usr/bin/env bash\n\
# Run the command arguments on the project Codeception binary.\n\
vendor/bin/codecept $@\n' \
> /usr/local/bin/codecept \
&&  chmod +x /usr/local/bin/codecept

# By default run the wrapper when the container runs.
ENTRYPOINT ["/usr/local/bin/codecept"]


With these files in place, I can run the following command successfully and see the tests run:

docker-compose build
docker-compose run --rm wpbrowser run unit

Objective 2: debugging unit tests with XDebug

Whatever tests I’m running, I need to debug them using XDebug.

Code coverage generation requires XDebug so it’s better addressing the issue now, while the stack is simple.

A rapid introduction to XDebug

Being able to debug one’s code is considered a junior developer required skill; I very much agree with that.
XDebug, when used to run remote debug sessions, has two components to it: a client and a server.
Similarly to many client-server architectures, the client sends requests to the server, and the server processes them.
I’ve seen many developers failing to understand, though, that the machine running the PHP code is the client and the machine debugging the code is the server.
In this container stack, then, the client is the container, and the server is my debug tool of choice: PHPStorm running on my host machine (my laptop).
The second part of understanding is the one where the client needs to know-how, and when, to make requests to the server; this is accomplished by installing the XDebug PHP extension.

Installing and configuring XDebug in the container

Installing it is as simple as modifying the docker/wpbrowser/Dockerfile file:

# Take the BUILD_PHP_VERSION argument into account, default to 7.3.
ARG BUILD_PHP_VERSION=7.3

# Allow for the customization of other PHP parameters.
ARG BUILD_PHP_TIMEZONE=UTC
ARG BUILD_PHP_IDEKEY=PHPSTORM

# Build from the php-cli image based on Debian Buster.
FROM php:${BUILD_PHP_VERSION}-cli-buster

LABEL maintainer="luca@theaveragedev.com"

# Install XDebug extension.
RUN pecl install xdebug

# Set some ini values for PHP.
# Among them configure the XDebug extension.
RUN echo "date.timezone = ${BUILD_PHP_TIMEZONE}\n\
\n\
[xdebug]\n\
zend_extension=xdebug.so\n\
xdebug.idekey=${BUILD_PHP_IDEKEY}\n\
xdebug.remote_enable=1\n\
" >> /usr/local/etc/php/conf.d/99-overrides.ini

# Create the /project directory and change to it.
WORKDIR /project

# Create an executable wrapper to execute the project vendor/bin/codecept binary appending command arguments to it.
RUN echo '#! /usr/bin/env bash\n\
# Run the command arguments on the project Codeception binary.\n\
vendor/bin/codecept $@\n' \
> /usr/local/bin/codecept \
&&  chmod +x /usr/local/bin/codecept

# By default run the wrapper when the container runs.
ENTRYPOINT ["/usr/local/bin/codecept"]

Note that, in the file, I’m also setting up Xdebug with the following configuration parameters:

  • zend_extension=xdebug.so - installing the extension is not enough; I need to tell PHP to use it explicitly.
  • xdebug.idekey=PHPSTORM - the key by which the client request identifies itself. Sticking to the client-server architecture from before it’s easy to understand how I could have many clients (machines running PHP code) running connecting to the server (my IDE, PHPStorm, running on my laptop): the server needs to know “who’s calling”
  • xdebug.remote_enable=1 - by default XDebug client and server would run on the same machine; it’s not the case here as the machine running the code is the container (although virtual it’s still a separate machine), while the server runs on my host machine (my laptop). The two machines communicate using an IP Address (sorta, see later) and not on localhost, so this is a remote connection.

I’m configuring the client and there is still one information missing: the server address, remote_host in XDebug terms.
Before any code example, it’s important to understand remote_host means “what IP address or hostname should the client call to connect to the server?”.
That IP address is **the IP address of my host machine, from the point of view of the container".
That IP address (hostname, really) is, in Docker for Mac and Windows, host.docker.internal.
Since I use Mac OS as my daily driver, it’s reasonable, for me, to use host.docker.internal as a default, yet I can leave further scripting the possibility to set that value dynamically.

Below the docker-compose.yml file to accompany the update to the docker/wpbrowser/Dockerfile:

version: '3.2'

services:

  wpbrowser:
    # Instead of using a pre-built image, let's build it from the file.
    build:
      context: ./docker/wpbrowser
      dockerfile: Dockerfile
      args:
        # By default use PHP 7.3 but allow overriding the version.
        - BUILD_PHP_VERSION=${TEST_PHP_VERSION:-7.3}
    volumes:
      # Bind the project files into the /project directory.
      - ./:/project
    environment:
      # As XDebug remote host use the host hostname on Docker for Mac or Windows, but allow overriding it.
      # Build systems are usually Linux-based.
      # Use default port, 9000, by default.
      XDEBUG_CONFIG: "remote_host=${XDEBUG_REMOTE_HOST:-host.docker.internal} remote_port=${XDEBUG_REMOTE_PORT:-9000}"
      # I use PHPStorm as IDE and this will make the handshake easier.
      PHP_IDE_CONFIG: "serverName=wp.test"

I have, so far, configured only the XDebug client, it’s time to configure the server: my IDE, PHPStorm.

Configuring PHPStorm to listen for XDebug connections

The debugging tool, my IDE in this example, is the server listening for XDebug connections from the client (the container) and “processing” them.
That processing is me scouring the code line by line or looking at variable values; it’s slow and inefficient “processing”, yet it’s still a process.

When I’ve finished debugging, and allow the code to “move on”, the client receives an “OK” response from the server and moves on. The cycle restarts for the next breakpoint, and so on.

From the values set in the section above I’ve configured PHPStorm to listen on port 9000 for XDebug connections:

Also, set up the Servers accordingly:

Running the tests with XDebug

Now that it’s all set up, it’s time to set a breakpoint in the code, run the tests, and see the code execution stop:

Next

In my next post I will try to refine this configuration a bit further to make it more portable (e.g., easily usable on Linux) and more flexible: I’ve hard-coded XDebug usage in the code and having XDebug active all the times might not be the best choice in terms of speed.