diff --git a/tools/local-env/scripts/docker.js b/tools/local-env/scripts/docker.js index 6a4bd157689e1..a6b0f4e0af38e 100644 --- a/tools/local-env/scripts/docker.js +++ b/tools/local-env/scripts/docker.js @@ -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'; @@ -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 ) ); diff --git a/tools/local-env/scripts/install.js b/tools/local-env/scripts/install.js index 0578545c11fec..17053b84d17f2 100644 --- a/tools/local-env/scripts/install.js +++ b/tools/local-env/scripts/install.js @@ -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 diff --git a/tools/local-env/scripts/start.js b/tools/local-env/scripts/start.js index b796b70c68bd1..984e06bee5617 100644 --- a/tools/local-env/scripts/start.js +++ b/tools/local-env/scripts/start.js @@ -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' ); @@ -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 ) { diff --git a/tools/local-env/scripts/utils.js b/tools/local-env/scripts/utils.js index 51f02e32a1d2d..58c02716241d5 100644 --- a/tools/local-env/scripts/utils.js +++ b/tools/local-env/scripts/utils.js @@ -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.