@wordpress/env
Version:
A zero-config, self contained local WordPress environment for development and testing.
845 lines (745 loc) • 24.9 kB
JavaScript
;
/**
* 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;