Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 30 additions & 31 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,39 @@ 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;

// 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 ) {
// `composer install` and `composer update` reach repo.packagist.org for the same reason, and both
// are safe to repeat. Every other Composer command runs once, so a failure such as a PHPStan error
// is reported as the real result it is.
const composerArgs = dockerCommand.slice( dockerCommand.indexOf( 'composer' ) + 1 );
let composerCommand;

for ( let i = 0; i < composerArgs.length; i++ ) {
// Global options precede the command. `--working-dir` is the only one that takes a separate
// value, so it is the only value that could otherwise be mistaken for the command itself.
if ( '-d' === composerArgs[i] || '--working-dir' === composerArgs[i] ) {
i++;
} else if ( ! composerArgs[i].startsWith( '-' ) ) {
composerCommand = composerArgs[i];
break;
}
}

if ( attempt === maxAttempts ) {
if ( maxAttempts > 1 ) {
console.log( `\ndocker compose ${ dockerCommand[0] } failed after ${ attempt } attempts.` );
}
const retryable = 'pull' === dockerCommand[0] ||
( 'run' === dockerCommand[0] && dockerCommand.includes( 'composer' ) &&
[ 'install', 'update' ].includes( composerCommand ) );

break;
}

const delay = attempt * 10;
console.log( `\ndocker compose ${ dockerCommand[0] } failed (attempt ${ attempt } of ${ maxAttempts }). Retrying in ${ delay } seconds...\n` );
// Execute any Docker compose command passed to this script.
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. Ctrl-C
// signals the whole process group, so this script usually dies alongside Compose without reaching
// here. This covers a signal sent to Compose alone: SIGINT means the command was cancelled, as
// when ending `env:logs`, and every other signal means it 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
78 changes: 77 additions & 1 deletion tools/local-env/scripts/utils.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,85 @@
/* 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 rather than failed, and a command that
// could not be spawned at all fails the same way every time. Do not run either again.
if ( returns.signal || returns.error ) {
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