Skip to content
47 changes: 14 additions & 33 deletions tools/local-env/scripts/docker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

const dotenv = require( 'dotenv' );
const dotenvExpand = require( 'dotenv-expand' );
const { spawnSync } = require( 'child_process' );
const local_env_utils = require( './utils' );

dotenvExpand.expand( dotenv.config() );
local_env_utils.ensure_env_file();

const composeFiles = local_env_utils.get_compose_files();
dotenvExpand.expand( dotenv.config() );

if ( process.argv.includes( '--coverage-html' ) ) {
process.env.LOCAL_PHP_XDEBUG = 'true';
Expand All @@ -25,39 +24,21 @@ if ( dockerCommand.includes( 'cli' ) && dockerCommand.includes( 'db' ) && ! dock
dockerCommand.push( '--defaults' );
}

const composeArgs = [
'compose',
...composeFiles
.map( ( composeFile ) => [ '-f', composeFile ] )
.flat(),
...dockerCommand,
];

// Failures during image pulls are re-attempted to rule out registry rate limits and network issues.
const maxAttempts = 'pull' === dockerCommand[0] ? 3 : 1;
// Composer runs are re-attempted for the same reason: they reach repo.packagist.org, and both
// `composer install` and `composer update` are safe to repeat.
const retryable = 'pull' === dockerCommand[0] || dockerCommand.includes( 'composer' );

// Execute any Docker compose command passed to this script.
let returns;
for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) {
returns = spawnSync( 'docker', composeArgs, { stdio: 'inherit' } );

if ( 0 === returns.status ) {
break;
}

if ( attempt === maxAttempts ) {
if ( maxAttempts > 1 ) {
console.log( `\ndocker compose ${ dockerCommand[0] } failed after ${ attempt } attempts.` );
}

break;
}

const delay = attempt * 10;
console.log( `\ndocker compose ${ dockerCommand[0] } failed (attempt ${ attempt } of ${ maxAttempts }). Retrying in ${ delay } seconds...\n` );
const returns = local_env_utils.compose_with_retry( dockerCommand, retryable ? 3 : 1 );

// Sleep synchronously so the retry loop stays in order without going async.
Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, delay * 1000 );
if ( returns.error ) {
console.error( `Could not run Docker Compose. ${ returns.error.message }` );
} else if ( returns.signal && returns.signal !== 'SIGINT' ) {
console.error( `Docker Compose was terminated by ${ returns.signal }.` );
}

process.exit( returns.status );
// `status` is null when Docker could not be spawned at all, or was killed by a signal. SIGINT is
// how a long-running command such as `env:logs` is normally ended, so it is not a failure worth an
// npm error block. Every other signal means the command was killed before it finished.
process.exit( returns.signal === 'SIGINT' ? 0 : ( returns.status ?? 1 ) );
2 changes: 2 additions & 0 deletions tools/local-env/scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const { execSync } = require( 'child_process' );
const { readFileSync, writeFileSync } = require( 'fs' );
const local_env_utils = require( './utils' );

local_env_utils.ensure_env_file();

dotenvExpand.expand( dotenv.config() );

// Create wp-config.php. This verifies the database connection, so retrying it doubles as the
Expand Down
33 changes: 14 additions & 19 deletions tools/local-env/scripts/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,11 @@ const dotenv = require( 'dotenv' );
const dotenvExpand = require( 'dotenv-expand' );
const { execSync, spawnSync } = require( 'child_process' );
const local_env_utils = require( './utils' );
const { copyFileSync, existsSync } = require( 'node:fs' );

// Copy the default .env file when one is not present.
if ( ! existsSync( '.env' ) ) {
copyFileSync( '.env.example', '.env' );
}
local_env_utils.ensure_env_file();

dotenvExpand.expand( dotenv.config() );

const composeFiles = local_env_utils.get_compose_files();

// Check if the Docker service is running.
try {
execSync( 'docker info' );
Expand All @@ -32,18 +26,19 @@ if ( process.env.LOCAL_PHP_MEMCACHED === 'true' ) {
containers.push( 'memcached' );
}

spawnSync(
'docker',
[
'compose',
...composeFiles.map( ( composeFile ) => [ '-f', composeFile ] ).flat(),
'up',
'--quiet-pull',
'-d',
...containers,
],
{ stdio: 'inherit' }
);
// `up` pulls any image that is missing, so it is re-attempted for the same reasons as `env:pull`.
const up = local_env_utils.compose_with_retry( [ 'up', '--quiet-pull', '-d', ...containers ], 3 );

// No signal is exempt here, unlike in `docker.js`: `env:start` runs `composer update -W` next, and
// that must not run against containers that never came up.
if ( up.status !== 0 ) {
const reason = up.signal ? `It was terminated by ${ up.signal }.` : up.error?.message ?? '';

console.error( `Could not start the Docker containers. ${ reason }`.trim() );

// `status` is null when Docker could not be spawned at all, or was killed by a signal.
process.exit( up.status ?? 1 );
}

// If Docker Toolbox is being used, we need to manually forward LOCAL_PORT to the Docker VM.
if ( process.env.DOCKER_TOOLBOX_INSTALL_PATH ) {
Expand Down
77 changes: 76 additions & 1 deletion tools/local-env/scripts/utils.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,84 @@
/* jshint node:true */

const { existsSync } = require( 'node:fs' );
const { spawnSync } = require( 'node:child_process' );
const { constants, copyFileSync, existsSync } = require( 'node:fs' );
const { join } = require( 'node:path' );

const repo_root = join( __dirname, '..', '..', '..' );

const local_env_utils = {

/**
* Creates the .env file from .env.example when one is not present.
*
* Docker Compose reads this file to resolve the image tags, so it must exist before any
* Compose command runs, not just before the containers are started.
*/
ensure_env_file: function() {
try {
copyFileSync( join( repo_root, '.env.example' ), join( repo_root, '.env' ), constants.COPYFILE_EXCL );
} catch ( e ) {
// A .env that is already there is the common case and needs no warning. Any other
// failure means the scripts run without the settings from .env, which is worth
// reporting, but is never a reason to refuse to run a command such as `env:stop`.
if ( e.code !== 'EEXIST' ) {
console.warn( `Could not create a .env file from .env.example. ${ e.message }` );
}
}
},

/**
* Runs a Docker Compose command, re-attempting it when it fails.
*
* Any command that reaches a registry can fail for reasons that clear on their own, such as
* rate limits and transient network errors.
*
* @param {string[]} args The Compose command and its arguments, such as `[ 'pull' ]`.
* @param {number} attempts How many times to run the command before giving up.
*
* @return {Object} The result of the last attempt.
*/
compose_with_retry: function( args, attempts ) {
const composeArgs = [
'compose',
...local_env_utils.get_compose_files()
.map( ( composeFile ) => [ '-f', composeFile ] )
.flat(),
...args,
];

let returns;

for ( let attempt = 1; attempt <= attempts; attempt++ ) {
returns = spawnSync( 'docker', composeArgs, { stdio: 'inherit' } );

if ( 0 === returns.status ) {
break;
}

// A command killed by a signal was cancelled, not failed. Do not run it again.
if ( returns.signal ) {
break;
}

if ( attempt === attempts ) {
if ( attempts > 1 ) {
console.log( `\ndocker compose ${ args[0] } failed after ${ attempt } attempts.` );
}

break;
}

const delay = attempt * 10;
console.log( `\ndocker compose ${ args[0] } failed (attempt ${ attempt } of ${ attempts }). Retrying in ${ delay } seconds...\n` );

// Sleep synchronously so the retry loop stays in order without going async.
Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, delay * 1000 );
}

return returns;
},

/**
* Determines which Docker compose files are required to properly configure the local environment given the
* specified PHP version, database type, and database version.
Expand Down
Loading