UNPKG

@wordpress/env

Version:

A zero-config, self contained local WordPress environment for development and testing.

845 lines (745 loc) 24.9 kB
'use strict'; /** * External dependencies */ const { spawn, execSync } = require( 'child_process' ); const path = require( 'path' ); const util = require( 'util' ); const { v2: dockerCompose } = require( 'docker-compose' ); const { rimraf } = require( 'rimraf' ); /** * Promisified dependencies */ const exec = util.promisify( require( 'child_process' ).exec ); /** * Internal dependencies */ const { writeDockerFiles, ensureDockerInitialized, } = require( './docker-config' ); const getHostUser = require( './get-host-user' ); const downloadSources = require( './download-sources' ); const downloadWPPHPUnit = require( './download-wp-phpunit' ); const { RUN_CONTAINERS, validateRunContainer, } = require( './validate-run-container' ); const { configureWordPress, resetDatabase, setupWordPressDirectories, } = require( './wordpress' ); const { readWordPressVersion, canAccessWPORG } = require( '../../wordpress' ); const { didCacheChange, setCache } = require( '../../cache' ); const md5 = require( '../../md5' ); const retry = require( '../../retry' ); /** * @typedef {import('../../config').WPConfig} WPConfig */ const CONFIG_CACHE_KEY = 'config_checksum'; /** * Docker runtime implementation for wp-env. * * This runtime uses Docker Compose for container orchestration. */ class DockerRuntime { /** * Get the name of this runtime. * * @return {string} Runtime name. */ getName() { return 'docker'; } /** * Get supported features for this runtime. * * @return {Object} Feature flags. */ getFeatures() { return { testsEnvironment: true, xdebug: true, spx: true, phpMyAdmin: true, multisite: true, customPhpVersion: true, persistentDatabase: true, wpCli: true, }; } /** * Check if Docker is available. * * @return {Promise<boolean>} True if Docker is available. */ async isAvailable() { try { execSync( 'docker info', { stdio: 'ignore' } ); return true; } catch { return false; } } /** * Start the Docker containers and configure WordPress. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Start options. * @param {Object} options.spinner A CLI spinner which indicates progress. * @param {boolean} options.update If true, update sources. * @return {Promise<Object>} Result object with message and siteUrl. */ async start( config, { spinner, update } ) { // Write Docker-specific files (docker-compose.yml, Dockerfiles). // The config already has ports resolved and xdebug/spx set by start.js. const fullConfig = await writeDockerFiles( config ); const debug = fullConfig.debug; const testsEnabled = config.testsEnvironment !== false; // Check if the hash of the config has changed. If so, run configuration. const configHash = md5( fullConfig ); const { workDirectoryPath, dockerComposeConfigPath } = fullConfig; const shouldConfigureWp = ( update || ( await didCacheChange( CONFIG_CACHE_KEY, configHash, { workDirectoryPath, } ) ) ) && // Don't reconfigure everything when we can't connect to the internet because // the majority of update tasks involve connecting to the internet. (Such // as downloading sources and pulling docker images.) ( await canAccessWPORG() ); const dockerComposeConfig = { config: dockerComposeConfigPath, log: fullConfig.debug, }; if ( ! ( await canAccessWPORG() ) ) { spinner.info( 'wp-env is offline' ); } /** * If the Docker image is already running and the `wp-env` files have been * deleted, the start command will not complete successfully. Stopping * the container before continuing allows the docker entrypoint script, * which restores the files, to run again when we start the containers. * * Additionally, --remove-orphans ensures containers from services that * were removed in the new config (e.g., tests-* after setting * testsEnvironment: false) are properly stopped. * * @see https://github.com/WordPress/gutenberg/pull/20253#issuecomment-587228440 */ if ( shouldConfigureWp ) { spinner.text = 'Stopping WordPress.'; await dockerCompose.down( { config: dockerComposeConfigPath, log: debug, commandOptions: [ '--remove-orphans' ], } ); // Update the images before starting the services again. spinner.text = 'Updating docker images.'; const directoryHash = path.basename( workDirectoryPath ); // Note: when the base docker image is updated, we want that operation to // also update WordPress. Since we store wordpress/tests-wordpress files // as docker volumes, simply updating the image will not change those // files. Thus, we need to remove those volumes in order for the files // to be updated when pulling the new images. const volumesToRemove = testsEnabled ? `${ directoryHash }_wordpress ${ directoryHash }_tests-wordpress` : `${ directoryHash }_wordpress`; try { if ( fullConfig.debug ) { spinner.text = `Removing the WordPress volumes: ${ volumesToRemove }`; } await exec( `docker volume rm ${ volumesToRemove }` ); } catch { // Note: we do not care about this error condition because it will // mostly happen when the volume already exists. This error would not // stop wp-env from working correctly. } await dockerCompose.pullAll( dockerComposeConfig ); spinner.text = 'Downloading sources.'; } const mysqlServices = [ 'mysql' ]; if ( testsEnabled ) { mysqlServices.push( 'tests-mysql' ); } await Promise.all( [ dockerCompose.upMany( mysqlServices, { ...dockerComposeConfig, commandOptions: shouldConfigureWp ? [ '--build', '--force-recreate' ] : [], } ), shouldConfigureWp && downloadSources( fullConfig, spinner ), ] ); if ( shouldConfigureWp ) { spinner.text = 'Setting up WordPress directories'; await setupWordPressDirectories( fullConfig ); // Use the WordPress versions to download the PHPUnit suite. const wpVersionPromises = [ readWordPressVersion( fullConfig.env.development.coreSource, spinner, debug ), ]; if ( testsEnabled ) { wpVersionPromises.push( readWordPressVersion( fullConfig.env.tests.coreSource, spinner, debug ) ); } const wpVersions = await Promise.all( wpVersionPromises ); const wpVersionMap = { development: wpVersions[ 0 ], }; if ( testsEnabled ) { wpVersionMap.tests = wpVersions[ 1 ]; } await downloadWPPHPUnit( fullConfig, wpVersionMap, spinner, debug ); } spinner.text = 'Starting WordPress.'; const wpServices = [ 'wordpress', 'cli' ]; if ( testsEnabled ) { wpServices.push( 'tests-wordpress', 'tests-cli' ); } await dockerCompose.upMany( wpServices, { ...dockerComposeConfig, commandOptions: shouldConfigureWp ? [ '--build', '--force-recreate' ] : [], } ); if ( fullConfig.env.development.phpmyadmin ) { await dockerCompose.upOne( 'phpmyadmin', { ...dockerComposeConfig, commandOptions: shouldConfigureWp ? [ '--build', '--force-recreate' ] : [], } ); } if ( testsEnabled && fullConfig.env.tests.phpmyadmin ) { await dockerCompose.upOne( 'tests-phpmyadmin', { ...dockerComposeConfig, commandOptions: shouldConfigureWp ? [ '--build', '--force-recreate' ] : [], } ); } // Make sure we've consumed the custom CLI dockerfile. if ( shouldConfigureWp ) { await dockerCompose.buildOne( [ 'cli' ], { ...dockerComposeConfig, } ); } // Only run WordPress install/configuration when config has changed. if ( shouldConfigureWp ) { spinner.text = 'Configuring WordPress.'; // Retry WordPress installation in case MySQL *still* wasn't ready. const configTasks = [ retry( () => configureWordPress( 'development', fullConfig, spinner ), { times: 2, } ), ]; if ( testsEnabled ) { configTasks.push( retry( () => configureWordPress( 'tests', fullConfig, spinner ), { times: 2, } ) ); } await Promise.all( configTasks ); // Set the cache key once everything has been configured. await setCache( CONFIG_CACHE_KEY, configHash, { workDirectoryPath, } ); } // Get port information for the result message const siteUrl = fullConfig.env.development.config.WP_SITEURL; const mySQLPort = await this._getPublicDockerPort( 'mysql', 3306, dockerComposeConfig ); const phpmyadminPort = fullConfig.env.development.phpmyadmin ? await this._getPublicDockerPort( 'phpmyadmin', 80, dockerComposeConfig ) : null; const message = [ 'WordPress development site started' + ( siteUrl ? ` at ${ siteUrl }` : '.' ), `MySQL is listening on port ${ mySQLPort }`, phpmyadminPort && `phpMyAdmin started at http://localhost:${ phpmyadminPort }`, ]; if ( testsEnabled ) { const testsSiteUrl = fullConfig.env.tests.config.WP_SITEURL; const testsMySQLPort = await this._getPublicDockerPort( 'tests-mysql', 3306, dockerComposeConfig ); const testsPhpmyadminPort = fullConfig.env.tests.phpmyadmin ? await this._getPublicDockerPort( 'tests-phpmyadmin', 80, dockerComposeConfig ) : null; message.push( 'WordPress test site started' + ( testsSiteUrl ? ` at ${ testsSiteUrl }` : '.' ), `MySQL for automated testing is listening on port ${ testsMySQLPort }`, testsPhpmyadminPort && `phpMyAdmin for automated testing started at http://localhost:${ testsPhpmyadminPort }` ); } const formattedMessage = message.filter( Boolean ).join( '\n' ); return { message: formattedMessage, siteUrl, }; } /** * Get the public port for a Docker service. * * @param {string} service The service name. * @param {number} containerPort The container port. * @param {Object} dockerComposeConfig The docker-compose config. * @return {Promise<string>} The public port. */ async _getPublicDockerPort( service, containerPort, dockerComposeConfig ) { const { out: address } = await dockerCompose.port( service, containerPort, dockerComposeConfig ); return address.split( ':' ).pop().trim(); } /** * Get the warning message for destroy confirmation. * * @return {string} Warning message. */ getDestroyWarningMessage() { return 'WARNING! This will remove Docker containers, volumes, networks, and images associated with the WordPress instance.'; } /** * Get the warning message for cleanup confirmation. * * @return {string} Warning message. */ getCleanupWarningMessage() { return 'WARNING! This will remove Docker containers, volumes, networks, and local files associated with the WordPress instance. Docker images will be preserved.'; } /** * Stop the Docker containers. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Stop options. * @param {Object} options.spinner A CLI spinner which indicates progress. * @param {boolean} options.debug True if debug mode is enabled. */ async stop( config, { spinner, debug } ) { ensureDockerInitialized( config, spinner ); spinner.text = 'Stopping WordPress.'; await dockerCompose.down( { config: config.dockerComposeConfigPath, log: debug, } ); spinner.text = 'Stopped WordPress.'; } /** * Destroy the Docker containers and remove local files. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Destroy options. * @param {Object} options.spinner A CLI spinner which indicates progress. * @param {boolean} options.debug True if debug mode is enabled. */ async destroy( config, { spinner, debug } ) { spinner.text = 'Removing docker images, volumes, and networks.'; await dockerCompose.down( { config: config.dockerComposeConfigPath, commandOptions: [ '--volumes', '--remove-orphans', '--rmi', 'all' ], log: debug, } ); spinner.text = 'Removing local files.'; // Note: there is a race condition where docker compose actually hasn't finished // by this point, which causes rimraf to fail. We need to wait at least 2.5-5s, // but using 10s in case it's dependent on the machine. Removing images takes // longer so we use a longer wait time here. await new Promise( ( resolve ) => setTimeout( resolve, 10000 ) ); await rimraf( config.workDirectoryPath ); spinner.text = 'Removed WordPress environment.'; } /** * Cleanup the Docker containers and remove local files, but preserve images. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Cleanup options. * @param {Object} options.spinner A CLI spinner which indicates progress. * @param {boolean} options.debug True if debug mode is enabled. */ async cleanup( config, { spinner, debug } ) { spinner.text = 'Removing docker containers, volumes, and networks.'; await dockerCompose.down( { config: config.dockerComposeConfigPath, commandOptions: [ '--volumes', '--remove-orphans' ], log: debug, } ); spinner.text = 'Removing local files.'; // Note: there is a race condition where docker compose actually hasn't finished // by this point, which causes rimraf to fail. We need to wait at least 2.5-5s, // but since we're not removing images, the wait can be shorter. await new Promise( ( resolve ) => setTimeout( resolve, 3000 ) ); await rimraf( config.workDirectoryPath ); spinner.text = 'Cleaned up WordPress environment.'; } /** * Reset the WordPress database. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Reset options. * @param {string} options.environment The environment to reset. * @param {Object} options.spinner A CLI spinner which indicates progress. * @param {boolean} options.debug True if debug mode is enabled. */ async clean( config, { environment, spinner, debug } ) { ensureDockerInitialized( config, spinner ); const testsEnabled = config.testsEnvironment !== false; if ( ! testsEnabled && environment === 'tests' ) { throw new Error( 'Cannot reset the tests environment because it is disabled in the configuration.' ); } const description = `${ environment } environment${ environment === 'all' ? 's' : '' }`; spinner.text = `Resetting ${ description }.`; const tasks = []; // Start the appropriate MySQL service(s) first to avoid race conditions // where parallel tasks try to create docker networks with the same name. // The dependency chain (cli -> wordpress -> mysql with service_healthy) // ensures MySQL is ready before database operations run. const mysqlServices = []; if ( environment === 'all' || environment === 'development' ) { mysqlServices.push( 'mysql' ); } if ( testsEnabled && ( environment === 'all' || environment === 'tests' ) ) { mysqlServices.push( 'tests-mysql' ); } await dockerCompose.upMany( mysqlServices, { config: config.dockerComposeConfigPath, log: debug, } ); if ( environment === 'all' || environment === 'development' ) { tasks.push( resetDatabase( 'development', config ) .then( () => configureWordPress( 'development', config ) ) .catch( () => {} ) ); } if ( testsEnabled && ( environment === 'all' || environment === 'tests' ) ) { tasks.push( resetDatabase( 'tests', config ) .then( () => configureWordPress( 'tests', config ) ) .catch( () => {} ) ); } await Promise.all( tasks ); spinner.text = `Reset ${ description }.`; } /** * Get the list of valid container names for the run command. * * @return {string[]} Array of valid container names. */ getRunContainers() { return RUN_CONTAINERS; } /** * Run a command in a Docker container. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Run options. * @param {string} options.container The container to run the command in. * @param {string[]} options.command The command to run. * @param {string} options.envCwd The working directory. * @param {Object} options.spinner A CLI spinner which indicates progress. */ async run( config, { container, command, envCwd, spinner } ) { // Validate the container name (throws for deprecated containers) validateRunContainer( container ); if ( config.testsEnvironment === false && container.startsWith( 'tests-' ) ) { throw new Error( `Cannot run commands on "${ container }" because the tests environment is disabled in the configuration.` ); } ensureDockerInitialized( config, spinner ); // Shows a contextual tip for the given command. const joinedCommand = command.join( ' ' ); this._showCommandTips( joinedCommand, container, spinner ); await this._spawnCommandDirectly( config, container, command, envCwd ); spinner.text = `Ran \`${ joinedCommand }\` in '${ container }'.`; } /** * Show logs from Docker containers. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Logs options. * @param {string} options.environment The environment to show logs for. * @param {boolean} options.watch If true, follow along with log output. * @param {Object} options.spinner A CLI spinner which indicates progress. */ async logs( config, { environment, watch, spinner } ) { ensureDockerInitialized( config, spinner ); const testsEnabled = config.testsEnvironment !== false; if ( ! testsEnabled && environment === 'tests' ) { throw new Error( 'Cannot show logs for the tests environment because it is disabled in the configuration.' ); } // If we show text while watching the logs, it will continue showing up every // few lines in the logs as they happen, which isn't a good look. So only // show the message if we are not watching the logs. if ( ! watch ) { spinner.text = `Showing logs for the ${ environment } environment.`; } let servicesToWatch; if ( environment === 'all' ) { servicesToWatch = testsEnabled ? [ 'tests-wordpress', 'wordpress' ] : [ 'wordpress' ]; } else { servicesToWatch = [ environment === 'tests' ? 'tests-wordpress' : 'wordpress', ]; } const output = await Promise.all( [ ...servicesToWatch.map( ( service ) => dockerCompose.logs( service, { config: config.dockerComposeConfigPath, log: watch, // Must log inline if we are watching the log output. commandOptions: watch ? [ '--follow' ] : [], } ) ), ] ); // Combine the results from each docker output. const result = output.reduce( ( acc, current ) => { if ( current.out ) { acc.out = acc.out.concat( current.out ); } if ( current.err ) { acc.err = acc.err.concat( current.err ); } if ( current.exitCode !== 0 ) { acc.hasNon0ExitCode = true; } return acc; }, { out: '', err: '', hasNon0ExitCode: false } ); if ( result.out.length ) { console.log( process.stdout.isTTY ? `\n\n${ result.out }\n\n` : result.out ); } else if ( result.err.length ) { console.error( process.stdout.isTTY ? `\n\n${ result.err }\n\n` : result.err ); if ( result.hasNon0ExitCode ) { throw result.err; } } spinner.text = 'Finished showing logs.'; } /** * Get the status of the Docker environment. * * @param {WPConfig} config The wp-env config object. * @param {Object} options Status options. * @param {Object} options.spinner A CLI spinner which indicates progress. * @param {boolean} options.debug True if debug mode is enabled. * @return {Promise<Object>} Status object with environment information. */ async getStatus( config, { spinner, debug } ) { spinner.text = 'Getting environment status.'; ensureDockerInitialized( config, spinner ); const dockerComposeConfig = { config: config.dockerComposeConfigPath, log: debug, }; // Check if containers are running by trying to get a port. let isRunning = false; let developmentPort = null; let testsPort = null; let mySQLPort = null; let phpmyadminPort = null; try { mySQLPort = await this._getPublicDockerPort( 'mysql', 3306, dockerComposeConfig ); isRunning = true; developmentPort = await this._getPublicDockerPort( 'wordpress', 80, dockerComposeConfig ); testsPort = await this._getPublicDockerPort( 'tests-wordpress', 80, dockerComposeConfig ); if ( config.env.development.phpmyadmin ) { phpmyadminPort = await this._getPublicDockerPort( 'phpmyadmin', 80, dockerComposeConfig ); } } catch { // Containers are not running. } const siteUrl = config.env.development.config.WP_SITEURL; const testsEnabled = config.testsEnvironment !== false; return { status: isRunning ? 'running' : 'stopped', runtime: 'docker', urls: { development: isRunning ? siteUrl : null, phpmyadmin: isRunning && phpmyadminPort ? `http://localhost:${ phpmyadminPort }` : null, }, ports: { development: developmentPort, ...( testsEnabled && { tests: testsPort, } ), mysql: mySQLPort, }, config: { multisite: config.env.development.multisite, xdebug: config.xdebug || 'off', }, configPath: config.configDirectoryPath, installPath: config.workDirectoryPath, }; } /** * Runs an arbitrary command on the given Docker container. * * @param {WPConfig} config The wp-env configuration. * @param {string} container The Docker container to run the command on. * @param {string[]} command The command to run. * @param {string} envCwd The working directory for the command. * @return {Promise} Promise that resolves when the command completes. */ _spawnCommandDirectly( config, container, command, envCwd ) { // Both the `wordpress` and `tests-wordpress` containers have the host's // user so that they can maintain ownership parity with the host OS. // We should run any commands as that user so that they are able // to interact with the files mounted from the host. const hostUser = getHostUser(); // Since Docker requires absolute paths, we should resolve the input to a POSIX path. // This is needed because Windows resolves relative paths from the C: drive. envCwd = path.posix.resolve( // Not all containers have the same starting working directory. container === 'mysql' || container === 'tests-mysql' ? '/' : '/var/www/html', // Remove spaces and single quotes from both ends of the path. // This is needed because Windows treats single quotes as a literal character. envCwd.trim().replace( /^'|'$/g, '' ) ); const composeCommand = [ 'compose', '-f', config.dockerComposeConfigPath, 'exec', '-w', envCwd, '--user', hostUser.fullUser, ]; if ( ! process.stdout.isTTY ) { composeCommand.push( '-T' ); } composeCommand.push( container, ...command ); return new Promise( ( resolve, reject ) => { // Note: since the npm docker-compose package uses the -T option, we // cannot use it to spawn an interactive command. Thus, we run docker- // compose on the CLI directly. const childProc = spawn( 'docker', composeCommand, { stdio: 'inherit', } ); childProc.on( 'error', reject ); childProc.on( 'exit', ( code ) => { // Code 130 is set if the user tries to exit with ctrl-c before using // ctrl-d (so it is not an error which should fail the script.) if ( code === 0 || code === 130 ) { resolve(); } else { reject( `Command failed with exit code ${ code }` ); } } ); } ); } /** * This shows a contextual tip for the command being run. Certain commands (like * bash) may have weird behavior (exit with ctrl-d instead of ctrl-c or ctrl-z), * so we want the user to have that information without having to ask someone. * * @param {string} joinedCommand The command joined by spaces. * @param {string} container The container the command will be run on. * @param {Object} spinner A spinner object to show progress. */ _showCommandTips( joinedCommand, container, spinner ) { if ( ! joinedCommand.length ) { return; } const tip = `Starting '${ joinedCommand }' on the ${ container } container. ${ ( () => { switch ( joinedCommand ) { case 'bash': return 'Exit bash with ctrl-d.'; case 'wp shell': return 'Exit the WordPress shell with ctrl-c.'; default: return ''; } } )() }\n`; spinner.info( tip ); } } module.exports = DockerRuntime;