diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index d29e3f5aa6587a..c30996b473509c 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,8 +2,16 @@ ## Unreleased +### New Features + +- Add a `mariadbVersion` option and `WP_ENV_MARIADB_VERSION` environment variable to choose the MariaDB version used by the Docker runtime, including versions older than 10.4 ([#83751](https://github.com/WordPress/gutenberg/pull/83751)). +- Explain why `wp-env start` fails when a database was last used by a newer MariaDB version, which MariaDB cannot downgrade from ([#83751](https://github.com/WordPress/gutenberg/pull/83751)). +- Stop `wp-env start` with an error naming the image when an image cannot be pulled and is not available locally, such as a `mariadbVersion` that does not exist, instead of reporting that cached images will be used ([#83751](https://github.com/WordPress/gutenberg/pull/83751)). +- Make `wp db` commands, and so `wp-env reset`, work with a `mariadbVersion` older than 11.4. The MariaDB client in the CLI image requires TLS, which those servers do not offer, so the CLI image now runs the client without verifying the server certificate, as WP-CLI does from db-command 3.0 ([#83751](https://github.com/WordPress/gutenberg/pull/83751)). + ### Bug Fixes +- Reject a `phpVersion` or `WP_ENV_PHP_VERSION` that is not only a version number, such as `8.2-apache` or `abc8`, instead of failing later because the Docker image does not exist ([#83751](https://github.com/WordPress/gutenberg/pull/83751)). - Pass `-T` to `docker compose exec` when stdin is not a terminal, so commands run from a Git hook, which has a TTY on stdout but not on stdin, no longer fail with "cannot attach stdin to a TTY-enabled container" ([#78374](https://github.com/WordPress/gutenberg/pull/78374)). ### Internal diff --git a/packages/env/README.md b/packages/env/README.md index cb8ed70dff2dfb..28d3ddeeddf5ad 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -45,6 +45,7 @@ $ wp-env start --runtime=playground | MySQL database | Yes | No (SQLite) | | Multisite | Yes | Yes | | Custom PHP version | Yes | Yes | +| Custom MariaDB version | Yes | No (SQLite) | | Plugin/theme mounting | Yes | Yes | | `wp-env run` command | Yes | No | @@ -601,6 +602,7 @@ To enable editor autocomplete and validation, add a `$schema` key: |----------------------|----------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------| | `"core"` | `string\|null` | `null` | The WordPress installation to use. If `null` is specified, `wp-env` will use the latest production release of WordPress. | | `"phpVersion"` | `string\|null` | `null` | The PHP version to use. If `null` is specified, `wp-env` will use the default version used with production release of WordPress. | +| `"mariadbVersion"` | `string\|null` | `null` | The MariaDB version to use: `"lts"`, `"latest"`, or a version such as `"10.11"`. If `null` is specified, `lts` is used. | | `"plugins"` | `string[]` | `[]` | A list of plugins to install and activate in the environment. | | `"themes"` | `string[]` | `[]` | A list of themes to install in the environment. | | `"port"` | `integer` | `8888` | The port number to use for the installation. | @@ -827,6 +829,21 @@ You can tell `wp-env` to use a specific PHP version for compatibility and testin } ``` +### Specific MariaDB Version + +You can tell `wp-env` which MariaDB version the database should run, for compatibility and testing. Use `"lts"`, `"latest"`, or a version such as `"10.11"` or `"11.4.2"`. This can also be set via the environment variable `WP_ENV_MARIADB_VERSION`, which applies to every environment. The Playground runtime uses SQLite and ignores this option. + +```json +{ + "mariadbVersion": "10.11", + "plugins": [ "." ] +} +``` + +MariaDB cannot start on a database last used by a newer version. When that happens, `wp-env start` stops with an error that names both versions. To switch to a lower version, run `wp-env cleanup` first. It removes the environment's Docker containers, volumes, and local files, not only the databases, so everything is recreated on the next start. + +**Note:** MariaDB 5.5 images are not published for ARM processors. Apple silicon devices can still run version 5.5 under emulation by setting `DOCKER_DEFAULT_PLATFORM=linux/amd64` before running `wp-env start`. Be aware that every container in the environment runs under emulation, not only the database, so the whole environment runs more slowly. + ### Multisite support You can tell `wp-env` if the site should be multisite enabled. diff --git a/packages/env/lib/cli.js b/packages/env/lib/cli.js index 7d185ae483a213..76f3e48df3596f 100644 --- a/packages/env/lib/cli.js +++ b/packages/env/lib/cli.js @@ -11,6 +11,8 @@ const { getRuntime, UnsupportedCommandError, EnvironmentNotInitializedError, + DatabaseDowngradeError, + MissingImageError, } = require( './runtime' ); // Spinner. @@ -39,7 +41,9 @@ const withSpinner = ( error ) => { if ( error instanceof UnsupportedCommandError || - error instanceof EnvironmentNotInitializedError + error instanceof EnvironmentNotInitializedError || + error instanceof DatabaseDowngradeError || + error instanceof MissingImageError ) { // Error is a known user-facing error. spinner.fail( error.message ); diff --git a/packages/env/lib/config/db-env.js b/packages/env/lib/config/db-env.js index aa73fc70d60ceb..96a8a1986e4d77 100644 --- a/packages/env/lib/config/db-env.js +++ b/packages/env/lib/config/db-env.js @@ -17,8 +17,29 @@ const development = { WORDPRESS_DB_NAME: 'wordpress', }; +/* + * MariaDB image used by the database services. Besides a version number, the + * image accepts these tags, and the default follows the latest LTS release. + */ +const MARIADB_IMAGE_TAGS = [ 'lts', 'latest' ]; +const DEFAULT_MARIADB_VERSION = 'lts'; + +/** + * Gets the MariaDB image for an environment's configured version. + * + * @param {string|null|undefined} mariadbVersion The configured MariaDB version, or null for the default. + * + * @return {string} The image name, such as `mariadb:lts` or `mariadb:10.11`. + */ +function getMariaDBImage( mariadbVersion ) { + return `mariadb:${ mariadbVersion ?? DEFAULT_MARIADB_VERSION }`; +} + module.exports = { credentials, tests, development, + MARIADB_IMAGE_TAGS, + DEFAULT_MARIADB_VERSION, + getMariaDBImage, }; diff --git a/packages/env/lib/config/get-config-from-environment-vars.js b/packages/env/lib/config/get-config-from-environment-vars.js index 35025599bbdbfe..8bd98bac6229c5 100644 --- a/packages/env/lib/config/get-config-from-environment-vars.js +++ b/packages/env/lib/config/get-config-from-environment-vars.js @@ -3,7 +3,12 @@ const { parseSourceString, includeTestsPath, } = require( './parse-source-string' ); -const { checkPort, checkVersion, checkString } = require( './validate-config' ); +const { + checkPort, + checkVersion, + checkMariaDBVersion, + checkString, +} = require( './validate-config' ); /** * @typedef {import('./parse-source-string').WPSource} WPSource @@ -20,6 +25,7 @@ const { checkPort, checkVersion, checkString } = require( './validate-config' ); * @property {?number} phpmyadminPort An override for the development environment's phpMyAdmin port. * @property {?WPSource} coreSource An override for all environment's coreSource. * @property {?string} phpVersion An override for all environment's PHP version. + * @property {?string} mariadbVersion An override for all environment's MariaDB version. * @property {?Object.} lifecycleScripts An override for various lifecycle scripts. */ @@ -65,6 +71,15 @@ module.exports = function getConfigFromEnvironmentVars( cacheDirectoryPath ) { environmentConfig.phpVersion = process.env.WP_ENV_PHP_VERSION; } + if ( process.env.WP_ENV_MARIADB_VERSION ) { + checkMariaDBVersion( + 'environment variable', + 'WP_ENV_MARIADB_VERSION', + process.env.WP_ENV_MARIADB_VERSION + ); + environmentConfig.mariadbVersion = process.env.WP_ENV_MARIADB_VERSION; + } + return environmentConfig; }; diff --git a/packages/env/lib/config/parse-config.js b/packages/env/lib/config/parse-config.js index 020f553662059e..9edeb029aa8963 100644 --- a/packages/env/lib/config/parse-config.js +++ b/packages/env/lib/config/parse-config.js @@ -11,6 +11,7 @@ const { checkStringArray, checkObjectWithValues, checkVersion, + checkMariaDBVersion, checkValidURL, } = require( './validate-config' ); const getConfigFromEnvironmentVars = require( './get-config-from-environment-vars' ); @@ -51,6 +52,7 @@ const mergeConfigs = require( './merge-configs' ); * @property {Object} config Mapping of wp-config.php constants to their desired values. * @property {Object.} mappings Mapping of WordPress directories to local directories which should be mounted. * @property {string|null} phpVersion Version of PHP to use in the environments, of the format 0.0. + * @property {string|null} mariadbVersion Version of MariaDB to use in the environments: "lts", "latest", or of the format 0.0. Null uses the default. */ /** @@ -79,6 +81,7 @@ const mergeConfigs = require( './merge-configs' ); const DEFAULT_ENVIRONMENT_CONFIG = { core: null, phpVersion: null, + mariadbVersion: null, plugins: [], themes: [], port: 8888, @@ -327,6 +330,13 @@ function getEnvironmentVarOverrides( cacheDirectoryPath ) { overrideConfig.env.tests.phpVersion = overrides.phpVersion; } + if ( overrides.mariadbVersion ) { + overrideConfig.mariadbVersion = overrides.mariadbVersion; + overrideConfig.env.development.mariadbVersion = + overrides.mariadbVersion; + overrideConfig.env.tests.mariadbVersion = overrides.mariadbVersion; + } + if ( overrides.multisite ) { overrideConfig.multisite = overrides.multisite; overrideConfig.env.development.multisite = overrides.multisite; @@ -532,6 +542,18 @@ async function parseEnvironmentConfig( parsedConfig.phpVersion = config.phpVersion; } + if ( config.mariadbVersion !== undefined ) { + // Support null as a valid input. + if ( config.mariadbVersion !== null ) { + checkMariaDBVersion( + configFile, + `${ environmentPrefix }mariadbVersion`, + config.mariadbVersion + ); + } + parsedConfig.mariadbVersion = config.mariadbVersion; + } + if ( config.core !== undefined ) { parsedConfig.coreSource = includeTestsPath( await parseCoreSource( config.core, options ), diff --git a/packages/env/lib/config/test/__snapshots__/config-integration.js.snap b/packages/env/lib/config/test/__snapshots__/config-integration.js.snap index 423e9dab482396..4fb98580ea5ce5 100644 --- a/packages/env/lib/config/test/__snapshots__/config-integration.js.snap +++ b/packages/env/lib/config/test/__snapshots__/config-integration.js.snap @@ -30,6 +30,7 @@ exports[`Config Integration > should load local and override configuration files "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": 23306, "phpVersion": null, @@ -62,6 +63,7 @@ exports[`Config Integration > should load local and override configuration files "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": 23307, "phpVersion": null, @@ -115,6 +117,7 @@ exports[`Config Integration > should load local configuration file 1`] = ` "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": 13306, "phpVersion": null, @@ -147,6 +150,7 @@ exports[`Config Integration > should load local configuration file 1`] = ` "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": 23307, "phpVersion": null, @@ -200,6 +204,7 @@ exports[`Config Integration > should use default configuration 1`] = ` "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": null, "phpVersion": null, @@ -232,6 +237,7 @@ exports[`Config Integration > should use default configuration 1`] = ` "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": null, "phpVersion": null, @@ -285,6 +291,7 @@ exports[`Config Integration > should use environment variables over local and ov "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": 23306, "phpVersion": null, @@ -318,6 +325,7 @@ exports[`Config Integration > should use environment variables over local and ov "url": "https://github.com/WordPress/WordPress.git", }, "mappings": {}, + "mariadbVersion": null, "multisite": false, "mysqlPort": 23307, "phpVersion": null, diff --git a/packages/env/lib/config/test/parse-config.js b/packages/env/lib/config/test/parse-config.js index 65ea7e5016c268..4eacb97cad4122 100644 --- a/packages/env/lib/config/test/parse-config.js +++ b/packages/env/lib/config/test/parse-config.js @@ -49,6 +49,7 @@ const DEFAULT_CONFIG = { phpmyadminPort: null, multisite: false, phpVersion: null, + mariadbVersion: null, coreSource: { type: 'git', url: 'https://github.com/WordPress/WordPress.git', @@ -104,6 +105,7 @@ describe( 'parseConfig', () => { delete process.env.WP_ENV_TESTS_PORT; delete process.env.WP_ENV_CORE; delete process.env.WP_ENV_PHP_VERSION; + delete process.env.WP_ENV_MARIADB_VERSION; delete process.env.WP_ENV_LIFECYCLE_SCRIPT_AFTER_START; } ); @@ -612,4 +614,106 @@ describe( 'parseConfig', () => { ) ); } ); + + it( 'should parse mariadbVersion at the root and per environment', async () => { + readRawConfigFile.mockImplementation( async ( configFile ) => { + if ( configFile === '/test/gutenberg/.wp-env.json' ) { + return { + mariadbVersion: '10.11', + env: { + tests: { + mariadbVersion: 'latest', + }, + }, + }; + } + + if ( configFile === '/test/gutenberg/.wp-env.override.json' ) { + return {}; + } + + throw new Error( 'Invalid File: ' + configFile ); + } ); + + const parsed = await parseConfig( '/test/gutenberg', '/cache' ); + + expect( parsed.mariadbVersion ).toBe( '10.11' ); + expect( parsed.env.tests.mariadbVersion ).toBe( 'latest' ); + } ); + + it( 'should accept a null mariadbVersion', async () => { + readRawConfigFile.mockImplementation( async ( configFile ) => { + if ( configFile === '/test/gutenberg/.wp-env.json' ) { + return { mariadbVersion: null }; + } + + if ( configFile === '/test/gutenberg/.wp-env.override.json' ) { + return {}; + } + + throw new Error( 'Invalid File: ' + configFile ); + } ); + + const parsed = await parseConfig( '/test/gutenberg', '/cache' ); + + expect( parsed.mariadbVersion ).toBeNull(); + } ); + + it( 'should throw for an invalid mariadbVersion', async () => { + readRawConfigFile.mockImplementation( async ( configFile ) => { + if ( configFile === '/test/gutenberg/.wp-env.json' ) { + return { env: { tests: { mariadbVersion: 'LTS' } } }; + } + + if ( configFile === '/test/gutenberg/.wp-env.override.json' ) { + return {}; + } + + throw new Error( 'Invalid File: ' + configFile ); + } ); + + await expect( + parseConfig( '/test/gutenberg', '/cache' ) + ).rejects.toEqual( + new ValidationError( + 'Invalid /test/gutenberg/.wp-env.json: "tests.mariadbVersion" must be "lts", "latest", or a version such as "10.11" or "11.4.2".' + ) + ); + } ); + + it( 'should override mariadbVersion in every environment with WP_ENV_MARIADB_VERSION', async () => { + readRawConfigFile.mockImplementation( async ( configFile ) => { + if ( configFile === '/test/gutenberg/.wp-env.json' ) { + return { + mariadbVersion: '10.11', + env: { tests: { mariadbVersion: 'latest' } }, + }; + } + + if ( configFile === '/test/gutenberg/.wp-env.override.json' ) { + return {}; + } + + throw new Error( 'Invalid File: ' + configFile ); + } ); + process.env.WP_ENV_MARIADB_VERSION = '10.3'; + + const parsed = await parseConfig( '/test/gutenberg', '/cache' ); + + expect( parsed.mariadbVersion ).toBe( '10.3' ); + expect( parsed.env.development.mariadbVersion ).toBe( '10.3' ); + expect( parsed.env.tests.mariadbVersion ).toBe( '10.3' ); + } ); + + it( 'should throw for an invalid WP_ENV_MARIADB_VERSION', async () => { + process.env.WP_ENV_MARIADB_VERSION = 'LTS'; + + await expect( + parseConfig( '/test/gutenberg', '/cache' ) + ).rejects.toEqual( + new ValidationError( + 'Invalid environment variable: "WP_ENV_MARIADB_VERSION" must be "lts", "latest", or a version such as "10.11" or "11.4.2".' + ) + ); + } ); } ); diff --git a/packages/env/lib/config/test/validate-config.js b/packages/env/lib/config/test/validate-config.js index 8d531084dfc352..0a84902cdce7d7 100644 --- a/packages/env/lib/config/test/validate-config.js +++ b/packages/env/lib/config/test/validate-config.js @@ -8,6 +8,7 @@ const { checkStringArray, checkObjectWithValues, checkVersion, + checkMariaDBVersion, checkValidURL, } = require( '../validate-config' ); @@ -285,6 +286,27 @@ describe( 'validate-config', () => { ); } ); + it.each( [ + 'abc8', + '8.2-apache', + '8.5-fpm', + 'php8.2', + '8.x', + '8.2 ', + '', + ] )( + 'throws for a value that is not only a version number: %j', + ( version ) => { + expect( () => + checkVersion( 'test.json', 'test', version ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a string of the format "X", "X.X", or "X.X.X".' + ) + ); + } + ); + it( 'passes for different version formats', () => { expect( () => checkVersion( 'test.json', 'test', '1' ) @@ -304,6 +326,40 @@ describe( 'validate-config', () => { } ); } ); + describe( 'checkMariaDBVersion', () => { + it( 'throws for non-string', () => { + expect( () => + checkMariaDBVersion( 'test.json', 'test', 10.11 ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be a string.' + ) + ); + } ); + + it.each( [ 'LTS', 'Latest', '11-noble', 'lts-10', '10.x', '' ] )( + 'throws for invalid value %j', + ( version ) => { + expect( () => + checkMariaDBVersion( 'test.json', 'test', version ) + ).toThrow( + new ValidationError( + 'Invalid test.json: "test" must be "lts", "latest", or a version such as "10.11" or "11.4.2".' + ) + ); + } + ); + + it.each( [ 'lts', 'latest', '5', '10', '10.11', '10.3.39', '11.4.2' ] )( + 'passes for %j', + ( version ) => { + expect( () => + checkMariaDBVersion( 'test.json', 'test', version ) + ).not.toThrow(); + } + ); + } ); + describe( 'checkValidURL', () => { it( 'throws for invalid URLs', () => { expect( () => diff --git a/packages/env/lib/config/validate-config.js b/packages/env/lib/config/validate-config.js index 36c454ac5a6c0e..74653f32f28a03 100644 --- a/packages/env/lib/config/validate-config.js +++ b/packages/env/lib/config/validate-config.js @@ -1,4 +1,11 @@ 'use strict'; +const { MARIADB_IMAGE_TAGS } = require( './db-env' ); + +/* + * A version number such as "8", "8.2", or "8.2.1". Anchored so that the whole + * value has to match, as in the JSON schema. + */ +const VERSION_PATTERN = /^[0-9]+(?:\.[0-9]+)*$/; /** * @typedef {import('./parse-source-string').WPSource} WPSource @@ -140,15 +147,36 @@ function checkObjectWithValues( * @param {string} version The version that we're checking. */ function checkVersion( configFile, configKey, version ) { - if ( typeof version !== 'string' ) { + checkString( configFile, configKey, version ); + + if ( ! VERSION_PATTERN.test( version ) ) { throw new ValidationError( - `Invalid ${ configFile }: "${ configKey }" must be a string.` + `Invalid ${ configFile }: "${ configKey }" must be a string of the format "X", "X.X", or "X.X.X".` ); } +} - if ( ! version.match( /[0-9]+(?:\.[0-9]+)*/ ) ) { +/** + * Validates a MariaDB version and throws if it isn't valid. Accepts one of + * the MariaDB image tags, such as "lts", or a numeric version such as "10.11". + * + * @param {string} configFile The config file we're validating. + * @param {string} configKey The configuration key we're validating. + * @param {string} version The version that we're checking. + */ +function checkMariaDBVersion( configFile, configKey, version ) { + checkString( configFile, configKey, version ); + + if ( MARIADB_IMAGE_TAGS.includes( version ) ) { + return; + } + + if ( ! VERSION_PATTERN.test( version ) ) { + const tags = MARIADB_IMAGE_TAGS.map( ( tag ) => `"${ tag }"` ).join( + ', ' + ); throw new ValidationError( - `Invalid ${ configFile }: "${ configKey }" must be a string of the format "X", "X.X", or "X.X.X".` + `Invalid ${ configFile }: "${ configKey }" must be ${ tags }, or a version such as "10.11" or "11.4.2".` ); } } @@ -177,5 +205,6 @@ module.exports = { checkStringArray, checkObjectWithValues, checkVersion, + checkMariaDBVersion, checkValidURL, }; diff --git a/packages/env/lib/runtime/docker/build-docker-compose-config.js b/packages/env/lib/runtime/docker/build-docker-compose-config.js index 96689edcac777c..26362758a8ae8d 100644 --- a/packages/env/lib/runtime/docker/build-docker-compose-config.js +++ b/packages/env/lib/runtime/docker/build-docker-compose-config.js @@ -72,6 +72,41 @@ function getMounts( ]; } +/** + * The database healthcheck, shared by the development and tests databases. + * + * MariaDB's `healthcheck.sh` runs when the image can use it: --connect + * verifies a TCP connection and that the entrypoint has finished, and + * --innodb_initialized ensures InnoDB is fully initialized. It connects as a + * healthcheck user whose credentials the entrypoint writes to + * `.my-healthcheck.cnf` in the data directory, and the MARIADB_AUTO_UPGRADE env + * var ensures that user exists for existing installations. + * + * Images for older MariaDB versions may predate that user or the script (the + * script was added to the images in 2022 and the user in 2023, and older tags + * were never rebuilt). The file is in the data volume and the script is in the + * image, so an older image can find a file a newer one left behind. The script + * is used only when both exist, and the server is pinged over TCP otherwise, + * with `mariadb-admin` (11.0+ only ships this name) or `mysqladmin` (versions + * before 10.4 only ship this name). The image is checked at runtime rather + * than by its tag, so every version, including `lts` and `latest`, uses the + * same check. Using 127.0.0.1 rather than localhost avoids the Unix socket, + * which the temporary server used to initialize a new volume answers before + * the real server is listening. + * + * Timing is generous to support slow CI environments. + */ +const MARIADB_HEALTHCHECK = { + test: [ + 'CMD-SHELL', + 'if [ -f /var/lib/mysql/.my-healthcheck.cnf ] && command -v healthcheck.sh > /dev/null; then healthcheck.sh --connect --innodb_initialized; else "$$(command -v mariadb-admin || echo mysqladmin)" ping -h 127.0.0.1 --protocol=tcp -uroot -p"$$MYSQL_ROOT_PASSWORD"; fi', + ], + interval: '5s', + timeout: '10s', + retries: 12, + start_period: '60s', +}; + /** * Creates a docker-compose config object which, when serialized into a * docker-compose.yml file, tells docker-compose how to run the environment. @@ -172,23 +207,12 @@ module.exports = function buildDockerComposeConfig( config ) { config.env.development.phpmyadminPort ?? '' }}:80`; - // MySQL healthcheck using MariaDB's official healthcheck.sh script. - // --connect: verifies TCP connection and that entrypoint has finished - // --innodb_initialized: ensures InnoDB storage engine is fully initialized - // MARIADB_AUTO_UPGRADE env var ensures healthcheck user exists for existing installations. - // Timing is generous to support slow CI environments. - const mysqlHealthcheck = { - test: [ 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized' ], - interval: '5s', - timeout: '10s', - retries: 12, - start_period: '60s', - }; - // Build the services object, conditionally including tests services. const services = { mysql: { - image: 'mariadb:lts', + image: dbEnv.getMariaDBImage( + config.env.development.mariadbVersion + ), ports: [ developmentMysqlPorts ], environment: { MYSQL_ROOT_HOST: '%', @@ -198,7 +222,7 @@ module.exports = function buildDockerComposeConfig( config ) { MARIADB_AUTO_UPGRADE: '1', }, volumes: [ 'mysql:/var/lib/mysql' ], - healthcheck: mysqlHealthcheck, + healthcheck: MARIADB_HEALTHCHECK, }, wordpress: { depends_on: { @@ -266,7 +290,7 @@ module.exports = function buildDockerComposeConfig( config ) { }}:80`; services[ 'tests-mysql' ] = { - image: 'mariadb:lts', + image: dbEnv.getMariaDBImage( config.env.tests.mariadbVersion ), ports: [ testsMysqlPorts ], environment: { MYSQL_ROOT_HOST: '%', @@ -276,7 +300,7 @@ module.exports = function buildDockerComposeConfig( config ) { MARIADB_AUTO_UPGRADE: '1', }, volumes: [ 'mysql-test:/var/lib/mysql' ], - healthcheck: mysqlHealthcheck, + healthcheck: MARIADB_HEALTHCHECK, }; services[ 'tests-wordpress' ] = { depends_on: { diff --git a/packages/env/lib/runtime/docker/database-downgrade.js b/packages/env/lib/runtime/docker/database-downgrade.js new file mode 100644 index 00000000000000..7052cf0c5699a4 --- /dev/null +++ b/packages/env/lib/runtime/docker/database-downgrade.js @@ -0,0 +1,161 @@ +'use strict'; +const { v2: dockerCompose } = require( 'docker-compose' ); + +/** + * Prints the MariaDB version of the service's image, then the newest version + * that has run on its data directory. MariaDB records that version in + * `mariadb_upgrade_info` (`mysql_upgrade_info` before 11.0), which wp-env keeps + * current by setting MARIADB_AUTO_UPGRADE. + */ +const READ_VERSIONS_SCRIPT = + 'printf "%s\\n" "$MARIADB_VERSION"; cat /var/lib/mysql/mariadb_upgrade_info 2> /dev/null || cat /var/lib/mysql/mysql_upgrade_info 2> /dev/null || true'; + +/** + * Extracts the first `X.Y.Z` version from a string, such as `1:12.3.3+maria~ubu2404` + * from an image's MARIADB_VERSION or `12.3.3-MariaDB` from `mariadb_upgrade_info`. + * + * @param {string|undefined} text The text to search. + * + * @return {string|null} The version, or null when there is none. + */ +function parseMariaDBVersion( text ) { + const match = ( text || '' ).match( /(\d+)\.(\d+)\.(\d+)/ ); + return match ? match[ 0 ] : null; +} + +/** + * Checks whether one `X.Y.Z` version is a newer major or minor release than + * another. Patch versions are ignored, since MariaDB supports downgrading + * within the same major and minor version. + * + * @param {string} version The version to check. + * @param {string} compareVersion The version to compare against. + * + * @return {boolean} True when `version` is a newer major or minor release than `compareVersion`. + */ +function isNewerMinorVersion( version, compareVersion ) { + const parts = version.split( '.' ).map( Number ); + const compareParts = compareVersion.split( '.' ).map( Number ); + + for ( let i = 0; i < 2; i++ ) { + if ( parts[ i ] !== compareParts[ i ] ) { + return parts[ i ] > compareParts[ i ]; + } + } + + return false; +} + +/** + * Checks whether a database service's data was last used by a newer MariaDB + * version than the one it is configured to run. + * + * @param {string} service The database service to check. + * @param {Object} dockerComposeConfig Options for docker-compose. + * + * @return {Promise<{service: string, dataVersion: string, serverVersion: string}|null>} The downgrade, or null. + */ +async function checkDatabaseDowngrade( service, dockerComposeConfig ) { + let out; + try { + ( { out } = await dockerCompose.run( + service, + [ '-c', READ_VERSIONS_SCRIPT ], + { + ...dockerComposeConfig, + commandOptions: [ '--rm', '--no-deps', '--entrypoint', 'sh' ], + } + ) ); + } catch { + // The versions could not be read, so the start failure stays as is. + return null; + } + + const [ imageLine, ...dataLines ] = out.split( '\n' ); + const serverVersion = parseMariaDBVersion( imageLine ); + const dataVersion = parseMariaDBVersion( dataLines.join( '\n' ) ); + + if ( + serverVersion && + dataVersion && + isNewerMinorVersion( dataVersion, serverVersion ) + ) { + return { service, dataVersion, serverVersion }; + } + + return null; +} + +/** + * Gets the services whose container has exited with an error, which is how a + * database that cannot start ends up. + * + * @param {string[]} services The services to check. + * @param {Object} dockerComposeConfig Options for docker-compose. + * + * @return {Promise} The failed services, in the order given. + */ +async function getFailedServices( services, dockerComposeConfig ) { + let out; + try { + // `execCompose` returns the raw output; the library's `ps` parser fails + // on the JSON current Compose versions print. + ( { out } = await dockerCompose.execCompose( + 'ps', + [ '--all', '--format', 'json', ...services ], + dockerComposeConfig + ) ); + } catch { + return []; + } + + // Compose prints one JSON object per line, or a single array in older versions. + const containers = out + .split( '\n' ) + .filter( ( line ) => line.trim() ) + .flatMap( ( line ) => JSON.parse( line ) ); + + return services.filter( ( service ) => + containers.some( + ( container ) => + container.Service === service && + container.State === 'exited' && + container.ExitCode !== 0 + ) + ); +} + +/** + * Looks for a database service whose data was last used by a newer MariaDB + * version than the one it is configured to run. MariaDB cannot downgrade, so + * such a server fails to start, and Docker only reports that a dependency + * failed. Called after a start fails, to explain why. Only database containers + * that exited with an error are checked, so a start that failed for another + * reason keeps its own error. They are checked in parallel, since each check + * starts a container. + * + * @param {string[]} services The database services to check. + * @param {Object} dockerComposeConfig Options for docker-compose. + * + * @return {Promise<{service: string, dataVersion: string, serverVersion: string}|null>} The first downgraded service, in the order given, or null. + */ +async function findDatabaseDowngrade( services, dockerComposeConfig ) { + const failedServices = await getFailedServices( + services, + dockerComposeConfig + ); + + const downgrades = await Promise.all( + failedServices.map( ( service ) => + checkDatabaseDowngrade( service, dockerComposeConfig ) + ) + ); + + return downgrades.find( Boolean ) ?? null; +} + +module.exports = { + findDatabaseDowngrade, + isNewerMinorVersion, + parseMariaDBVersion, +}; diff --git a/packages/env/lib/runtime/docker/docker-config.js b/packages/env/lib/runtime/docker/docker-config.js index 8adf071f6d63d2..d7003a010c88e5 100644 --- a/packages/env/lib/runtime/docker/docker-config.js +++ b/packages/env/lib/runtime/docker/docker-config.js @@ -194,7 +194,21 @@ RUN apk --no-cache add $PHPIZE_DEPS && touch /usr/local/etc/php/php.ini # Set up sudo so they can have root access. RUN apk --no-cache add sudo linux-headers -RUN echo "#$HOST_UID ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers`; +RUN echo "#$HOST_UID ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# The MariaDB 11.4+ client requires TLS by default, which MariaDB servers +# before 11.4 do not offer, so every \`wp db\` command fails against them. WP-CLI +# runs the client with --no-defaults, which ignores option files, so wrap each +# client to skip server certificate verification instead, which lets it fall +# back to an unencrypted connection when the server has no TLS. This is the same +# flag WP-CLI adds from db-command 3.0, so these wrappers can be removed once a +# WP-CLI release with it is in the image. --no-defaults has to stay the first +# argument. +RUN for bin in mariadb mariadb-check mariadb-dump mysql mysqlcheck mysqldump; do \\ + [ -x /usr/bin/$bin ] || continue; \\ + printf '#!/bin/sh\\nif [ "$1" = "--no-defaults" ]; then\\n\\tshift\\n\\texec /usr/bin/%s --no-defaults --skip-ssl-verify-server-cert "$@"\\nfi\\nexec /usr/bin/%s --skip-ssl-verify-server-cert "$@"\\n' "$bin" "$bin" > /usr/local/bin/$bin; \\ + chmod +x /usr/local/bin/$bin; \\ +done`; break; } default: { @@ -366,5 +380,6 @@ module.exports = { ensureDockerInitialized, // Exported for testing. wordpressDockerFileContents, + cliDockerFileContents, getLoopbackPortConfig, }; diff --git a/packages/env/lib/runtime/docker/index.js b/packages/env/lib/runtime/docker/index.js index df935d72c85cf5..2f71622a55c5c7 100644 --- a/packages/env/lib/runtime/docker/index.js +++ b/packages/env/lib/runtime/docker/index.js @@ -10,6 +10,9 @@ const { ensureDockerInitialized, } = require( './docker-config' ); const getHostUser = require( './get-host-user' ); +const { findDatabaseDowngrade } = require( './database-downgrade' ); +const { findMissingImages } = require( './missing-images' ); +const { DatabaseDowngradeError, MissingImageError } = require( '../errors' ); const downloadSources = require( './download-sources' ); const downloadWPPHPUnit = require( './download-wp-phpunit' ); const { @@ -164,7 +167,17 @@ class DockerRuntime { try { await dockerCompose.pullAll( dockerComposeConfig ); - } catch { + } catch ( error ) { + // An image that is neither pullable nor cached, such as a MariaDB + // version that does not exist, would only fail later with a less + // clear error, so stop here and name it. + const missingImages = await findMissingImages( + dockerComposeConfigPath + ); + if ( missingImages.length ) { + throw new MissingImageError( missingImages, error?.err ); + } + // Note: pulling the images requires connecting to the Docker // registry, which may be unavailable (e.g., offline or an // outage). Locally cached images will be used instead, so this @@ -228,12 +241,31 @@ class DockerRuntime { if ( testsEnabled ) { wpServices.push( 'tests-wordpress', 'tests-cli' ); } - await dockerCompose.upMany( wpServices, { - ...dockerComposeConfig, - commandOptions: shouldConfigureWp - ? [ '--build', '--force-recreate' ] - : [], - } ); + try { + await dockerCompose.upMany( wpServices, { + ...dockerComposeConfig, + commandOptions: shouldConfigureWp + ? [ '--build', '--force-recreate' ] + : [], + } ); + } catch ( error ) { + // The WordPress services wait for a healthy database, so a database + // that cannot start fails here. Docker only reports that a + // dependency failed, so explain the case users can act on: a + // database last used by a newer MariaDB version. + const downgrade = await findDatabaseDowngrade( + mysqlServices, + dockerComposeConfig + ); + if ( downgrade ) { + throw new DatabaseDowngradeError( + downgrade.service === 'mysql' ? 'development' : 'tests', + downgrade.dataVersion, + downgrade.serverVersion + ); + } + throw error; + } if ( fullConfig.env.development.phpmyadmin ) { await dockerCompose.upOne( 'phpmyadmin', { diff --git a/packages/env/lib/runtime/docker/missing-images.js b/packages/env/lib/runtime/docker/missing-images.js new file mode 100644 index 00000000000000..31dbc7f7c047b1 --- /dev/null +++ b/packages/env/lib/runtime/docker/missing-images.js @@ -0,0 +1,81 @@ +'use strict'; +const { readFile } = require( 'fs' ).promises; +const util = require( 'util' ); +const execFile = util.promisify( require( 'child_process' ).execFile ); +const yaml = require( 'js-yaml' ); + +/** + * Gets the images the docker-compose config pulls, which are the services with + * an `image`. Services with a `build` are built from their Dockerfile instead, + * so `docker compose pull` skips them. + * + * @param {Object} dockerComposeConfig A docker-compose config object. + * + * @return {Array<{image: string, services: string[]}>} Each pulled image, with the services that use it. + */ +function getPulledImages( dockerComposeConfig ) { + const images = new Map(); + + for ( const [ service, { image } ] of Object.entries( + dockerComposeConfig.services ?? {} + ) ) { + if ( image ) { + images.set( image, [ ...( images.get( image ) ?? [] ), service ] ); + } + } + + return [ ...images ].map( ( [ image, services ] ) => ( { + image, + services, + } ) ); +} + +/** + * Checks whether an image is available locally. + * + * @param {string} image The image name, such as `mariadb:lts`. + * + * @return {Promise} True when the image is cached. + */ +async function isImageCached( image ) { + try { + await execFile( 'docker', [ 'image', 'inspect', image ] ); + return true; + } catch { + return false; + } +} + +/** + * Finds the images the environment pulls that are not available locally. Called + * after pulling fails, to tell an outage, where the cached images still work, + * from an image that cannot be used at all, such as a MariaDB version that does + * not exist. + * + * @param {string} dockerComposeConfigPath Path to the docker-compose.yml file. + * @param {(image: string) => Promise} [isCached] Checks whether an image is cached. + * + * @return {Promise>} The missing images, with the services that use them. + */ +async function findMissingImages( + dockerComposeConfigPath, + isCached = isImageCached +) { + const dockerComposeConfig = yaml.safeLoad( + await readFile( dockerComposeConfigPath, 'utf8' ) + ); + + const missing = []; + for ( const pulledImage of getPulledImages( dockerComposeConfig ) ) { + if ( ! ( await isCached( pulledImage.image ) ) ) { + missing.push( pulledImage ); + } + } + + return missing; +} + +module.exports = { + findMissingImages, + getPulledImages, +}; diff --git a/packages/env/lib/runtime/errors.js b/packages/env/lib/runtime/errors.js index 376e0fde38a515..ecf7b6803de87e 100644 --- a/packages/env/lib/runtime/errors.js +++ b/packages/env/lib/runtime/errors.js @@ -22,7 +22,65 @@ class EnvironmentNotInitializedError extends Error { } } +/** + * Error thrown when a database cannot start because its data was last used by + * a newer MariaDB version, which MariaDB cannot downgrade from. + */ +class DatabaseDowngradeError extends Error { + /** + * @param {string} environment The environment whose database failed, such as `development`. + * @param {string} dataVersion The newest MariaDB version that has run on the data. + * @param {string} serverVersion The MariaDB version the environment is configured to run. + */ + constructor( environment, dataVersion, serverVersion ) { + super( + `The ${ environment } database could not start: it was last used by MariaDB ${ dataVersion }, and MariaDB ${ serverVersion } cannot run on data from a newer version. Use MariaDB ${ dataVersion } or newer, or run \`wp-env cleanup\` to start over. Cleanup removes the environment's Docker containers, volumes, and local files, not only the databases.` + ); + this.name = 'DatabaseDowngradeError'; + } +} + +/** + * Error thrown when images the environment needs could not be pulled and are + * not available locally, so the environment cannot start. + */ +class MissingImageError extends Error { + /** + * @param {Array<{image: string, services: string[]}>} missingImages The images that are missing, with the services that use them. + * @param {string} [details] The output Docker reported while pulling. Its last line, which states the cause, is included. + */ + constructor( missingImages, details ) { + const images = missingImages + .map( + ( { image, services } ) => + `\`${ image }\` (${ services.join( ', ' ) })` + ) + .join( ', ' ); + const isMariaDB = missingImages.some( ( { image } ) => + image.startsWith( 'mariadb:' ) + ); + + super( + [ + `Could not pull ${ images }, and ${ + missingImages.length === 1 ? 'it is' : 'they are' + } not available locally.${ + isMariaDB + ? ' Check that "mariadbVersion" is a published MariaDB version, such as "10.11" or "lts".' + : '' + }`, + details?.trim().split( '\n' ).pop(), + ] + .filter( Boolean ) + .join( '\n\n' ) + ); + this.name = 'MissingImageError'; + } +} + module.exports = { UnsupportedCommandError, EnvironmentNotInitializedError, + DatabaseDowngradeError, + MissingImageError, }; diff --git a/packages/env/lib/runtime/index.js b/packages/env/lib/runtime/index.js index b26fb4a9975751..05f0cabf827aba 100644 --- a/packages/env/lib/runtime/index.js +++ b/packages/env/lib/runtime/index.js @@ -4,6 +4,8 @@ const PlaygroundRuntime = require( './playground' ); const { UnsupportedCommandError, EnvironmentNotInitializedError, + DatabaseDowngradeError, + MissingImageError, } = require( './errors' ); const { setCache, getCache } = require( '../cache' ); @@ -84,4 +86,6 @@ module.exports = { PlaygroundRuntime, UnsupportedCommandError, EnvironmentNotInitializedError, + DatabaseDowngradeError, + MissingImageError, }; diff --git a/packages/env/lib/test/build-docker-compose-config.js b/packages/env/lib/test/build-docker-compose-config.js index eedd5c9434af6d..aa98ba2f79ae89 100644 --- a/packages/env/lib/test/build-docker-compose-config.js +++ b/packages/env/lib/test/build-docker-compose-config.js @@ -1,5 +1,17 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; import { createRequire } from 'node:module'; -import { afterAll, describe, expect, it, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; const require = createRequire( import.meta.url ); const getHostUserPath = require.resolve( '../runtime/docker/get-host-user' ); const originalGetHostUser = require( getHostUserPath ); @@ -13,6 +25,7 @@ require.cache[ getHostUserPath ].exports = getHostUser; const buildDockerComposeConfig = require( '../runtime/docker/build-docker-compose-config' ); const { wordpressDockerFileContents, + cliDockerFileContents, getLoopbackPortConfig, } = require( '../runtime/docker/docker-config' ); @@ -164,12 +177,9 @@ describe( 'buildDockerComposeConfig', () => { } ); expect( config.services.mysql.healthcheck ).toBeDefined(); - expect( config.services.mysql.healthcheck.test ).toEqual( [ - 'CMD', - 'healthcheck.sh', - '--connect', - '--innodb_initialized', - ] ); + expect( config.services.mysql.healthcheck.test[ 0 ] ).toBe( + 'CMD-SHELL' + ); expect( config.services.mysql.healthcheck.interval ).toBe( '5s' ); expect( config.services.mysql.healthcheck.timeout ).toBe( '10s' ); expect( config.services.mysql.healthcheck.retries ).toBe( 12 ); @@ -180,18 +190,192 @@ describe( 'buildDockerComposeConfig', () => { '1' ); - expect( config.services[ 'tests-mysql' ].healthcheck ).toBeDefined(); - expect( config.services[ 'tests-mysql' ].healthcheck.test ).toEqual( [ - 'CMD', - 'healthcheck.sh', - '--connect', - '--innodb_initialized', - ] ); + expect( config.services[ 'tests-mysql' ].healthcheck ).toEqual( + config.services.mysql.healthcheck + ); expect( config.services[ 'tests-mysql' ].environment.MARIADB_AUTO_UPGRADE ).toBe( '1' ); } ); + describe( 'mariadbVersion', () => { + function buildWithVersions( development, tests ) { + return buildDockerComposeConfig( { + workDirectoryPath: '/path', + env: { + development: { ...CONFIG, mariadbVersion: development }, + tests: { ...CONFIG, mariadbVersion: tests }, + }, + } ); + } + + it( 'uses mariadb:lts by default', () => { + const config = buildDockerComposeConfig( { + workDirectoryPath: '/path', + env: { development: CONFIG, tests: CONFIG }, + } ); + + for ( const service of [ 'mysql', 'tests-mysql' ] ) { + expect( config.services[ service ].image ).toBe( + 'mariadb:lts' + ); + } + } ); + + it( 'treats null the same as unset', () => { + const config = buildWithVersions( null, null ); + + expect( config.services.mysql.image ).toBe( 'mariadb:lts' ); + } ); + + it( 'uses each environment’s own version', () => { + const config = buildWithVersions( '10.3', 'latest' ); + + expect( config.services.mysql.image ).toBe( 'mariadb:10.3' ); + expect( config.services[ 'tests-mysql' ].image ).toBe( + 'mariadb:latest' + ); + } ); + } ); + + /* + * Runs the health check command in a shell, with stub scripts in place of + * the MariaDB tools. Each stub logs how it was called and exits with the + * code the test gives it. Skipped on Windows, which has no `/bin/sh` to run + * the command or the stubs; the command itself only ever runs in Linux + * containers. + */ + describe.skipIf( process.platform === 'win32' )( + 'MariaDB health check command', + () => { + const STUB = + '#!/bin/sh\necho "${0##*/} $*" >> "$STUB_LOG"\nexit "$EXIT_CODE"\n'; + let directory; + let binDirectory; + let logFile; + let configFile; + + beforeEach( () => { + directory = fs.mkdtempSync( + path.join( os.tmpdir(), 'wp-env-healthcheck-' ) + ); + binDirectory = path.join( directory, 'bin' ); + logFile = path.join( directory, 'calls.log' ); + configFile = path.join( directory, '.my-healthcheck.cnf' ); + fs.mkdirSync( binDirectory ); + } ); + + afterEach( () => { + fs.rmSync( directory, { recursive: true, force: true } ); + } ); + + function addStub( name, exitCode ) { + const stubPath = path.join( binDirectory, name ); + fs.writeFileSync( + stubPath, + STUB.replace( '"$EXIT_CODE"', String( exitCode ) ) + ); + fs.chmodSync( stubPath, 0o755 ); + } + + /* + * Runs the command the way Docker does after Compose replaces each `$$` + * with `$`, with the data directory's config file moved into the temporary + * directory, and only the stubs on the PATH. + */ + function runHealthcheck() { + const [ , command ] = buildDockerComposeConfig( { + workDirectoryPath: '/path', + env: { development: CONFIG, tests: CONFIG }, + } ).services.mysql.healthcheck.test; + const script = command + .replaceAll( '$$', '$' ) + .replace( + '/var/lib/mysql/.my-healthcheck.cnf', + configFile + ); + + let exitCode = 0; + try { + execFileSync( '/bin/sh', [ '-c', script ], { + env: { + PATH: binDirectory, + STUB_LOG: logFile, + MYSQL_ROOT_PASSWORD: 'password', + }, + stdio: 'ignore', + } ); + } catch ( error ) { + exitCode = error.status; + } + + const calls = fs.existsSync( logFile ) + ? fs.readFileSync( logFile, 'utf8' ).trim().split( '\n' ) + : []; + + return { exitCode, calls }; + } + + it.each( [ 0, 1 ] )( + 'runs healthcheck.sh and returns its exit code %j when the healthcheck user exists', + ( exitCode ) => { + fs.writeFileSync( configFile, '' ); + addStub( 'healthcheck.sh', exitCode ); + addStub( 'mariadb-admin', 0 ); + + expect( runHealthcheck() ).toEqual( { + exitCode, + calls: [ + 'healthcheck.sh --connect --innodb_initialized', + ], + } ); + } + ); + + it( 'pings instead when the healthcheck user exists but the image has no healthcheck.sh', () => { + fs.writeFileSync( configFile, '' ); + addStub( 'mariadb-admin', 0 ); + + expect( runHealthcheck() ).toEqual( { + exitCode: 0, + calls: [ + 'mariadb-admin ping -h 127.0.0.1 --protocol=tcp -uroot -ppassword', + ], + } ); + } ); + + it.each( [ 0, 1 ] )( + 'pings with mariadb-admin and returns its exit code %j without the healthcheck user', + ( exitCode ) => { + addStub( 'healthcheck.sh', 0 ); + addStub( 'mariadb-admin', exitCode ); + addStub( 'mysqladmin', 0 ); + + expect( runHealthcheck() ).toEqual( { + exitCode, + calls: [ + 'mariadb-admin ping -h 127.0.0.1 --protocol=tcp -uroot -ppassword', + ], + } ); + } + ); + + it.each( [ 0, 1 ] )( + 'pings with mysqladmin and returns its exit code %j when mariadb-admin is missing', + ( exitCode ) => { + addStub( 'mysqladmin', exitCode ); + + expect( runHealthcheck() ).toEqual( { + exitCode, + calls: [ + 'mysqladmin ping -h 127.0.0.1 --protocol=tcp -uroot -ppassword', + ], + } ); + } + ); + } + ); + it( 'should use service_healthy condition for WordPress depends_on', () => { const config = buildDockerComposeConfig( { workDirectoryPath: '/some/path', @@ -329,3 +513,143 @@ describe( 'wordpressDockerFileContents', () => { expect( dockerfile ).not.toContain( 'Listen 8888' ); } ); } ); + +describe( 'cliDockerFileContents', () => { + const config = { + xdebug: 'off', + spx: 'off', + env: { development: { port: 8888, phpVersion: null } }, + }; + + it( 'wraps the MariaDB clients to skip server certificate verification', () => { + expect( cliDockerFileContents( 'development', config ) ).toContain( + 'RUN for bin in mariadb mariadb-check mariadb-dump mysql mysqlcheck mysqldump; do' + ); + } ); + + /* + * Runs the Dockerfile step that writes the wrappers in a shell, with + * `/usr/bin` and `/usr/local/bin` replaced by temporary directories, and + * stub clients that log how they were called. Skipped on Windows, which + * has no `/bin/sh`; the step itself only ever runs in the Linux CLI image. + */ + describe.skipIf( process.platform === 'win32' )( + 'MariaDB client wrappers', + () => { + let directory; + let clientDirectory; + let wrapperDirectory; + let logFile; + + beforeEach( () => { + directory = fs.mkdtempSync( + path.join( os.tmpdir(), 'wp-env-client-wrappers-' ) + ); + clientDirectory = path.join( directory, 'usr-bin' ); + wrapperDirectory = path.join( directory, 'usr-local-bin' ); + logFile = path.join( directory, 'calls.log' ); + fs.mkdirSync( clientDirectory ); + fs.mkdirSync( wrapperDirectory ); + } ); + + afterEach( () => { + fs.rmSync( directory, { recursive: true, force: true } ); + } ); + + function addClient( name, exitCode ) { + const clientPath = path.join( clientDirectory, name ); + fs.writeFileSync( + clientPath, + `#!/bin/sh\necho "\${0##*/} $*" >> "${ logFile }"\nexit ${ exitCode }\n` + ); + fs.chmodSync( clientPath, 0o755 ); + } + + // Runs the Dockerfile step with the temporary directories in place. + function writeWrappers() { + const dockerfile = cliDockerFileContents( + 'development', + config + ); + const step = dockerfile + .slice( + dockerfile.indexOf( 'RUN for bin in mariadb' ) + + 'RUN '.length, + dockerfile.indexOf( + '\ndone', + dockerfile.indexOf( 'RUN for bin in mariadb' ) + ) + '\ndone'.length + ) + .replaceAll( '/usr/local/bin/', `${ wrapperDirectory }/` ) + .replaceAll( '/usr/bin/', `${ clientDirectory }/` ); + + execFileSync( '/bin/sh', [ '-c', step ] ); + } + + function runWrapper( name, args ) { + let exitCode = 0; + try { + execFileSync( path.join( wrapperDirectory, name ), args, { + stdio: 'ignore', + } ); + } catch ( error ) { + exitCode = error.status; + } + + return { + exitCode, + calls: fs + .readFileSync( logFile, 'utf8' ) + .trim() + .split( '\n' ), + }; + } + + it( 'adds the flag after --no-defaults, which has to stay first', () => { + addClient( 'mariadb-check', 0 ); + writeWrappers(); + + expect( + runWrapper( 'mariadb-check', [ + '--no-defaults', + 'wordpress', + '--host=mysql', + ] ) + ).toEqual( { + exitCode: 0, + calls: [ + 'mariadb-check --no-defaults --skip-ssl-verify-server-cert wordpress --host=mysql', + ], + } ); + } ); + + it( 'adds the flag first when there is no --no-defaults', () => { + addClient( 'mysql', 0 ); + writeWrappers(); + + expect( runWrapper( 'mysql', [ '-e', 'SELECT 1' ] ) ).toEqual( { + exitCode: 0, + calls: [ + 'mysql --skip-ssl-verify-server-cert -e SELECT 1', + ], + } ); + } ); + + it( 'returns the client’s exit code', () => { + addClient( 'mariadb', 3 ); + writeWrappers(); + + expect( runWrapper( 'mariadb', [] ).exitCode ).toBe( 3 ); + } ); + + it( 'only wraps the clients the image has', () => { + addClient( 'mysql', 0 ); + writeWrappers(); + + expect( fs.readdirSync( wrapperDirectory ) ).toEqual( [ + 'mysql', + ] ); + } ); + } + ); +} ); diff --git a/packages/env/lib/test/database-downgrade.js b/packages/env/lib/test/database-downgrade.js new file mode 100644 index 00000000000000..bcc0b265aa8e2a --- /dev/null +++ b/packages/env/lib/test/database-downgrade.js @@ -0,0 +1,234 @@ +import { createRequire } from 'node:module'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const require = createRequire( import.meta.url ); +const { v2: dockerCompose } = require( 'docker-compose' ); +const { + findDatabaseDowngrade, + isNewerMinorVersion, + parseMariaDBVersion, +} = require( '../runtime/docker/database-downgrade' ); + +describe( 'parseMariaDBVersion', () => { + it.each( [ + [ '1:12.3.3+maria~ubu2404', '12.3.3' ], + [ '1:10.3.39+maria~ubu2004', '10.3.39' ], + [ '12.3.3-MariaDB', '12.3.3' ], + [ '10.6.28-MariaDB', '10.6.28' ], + ] )( 'reads the version from %j', ( text, expected ) => { + expect( parseMariaDBVersion( text ) ).toBe( expected ); + } ); + + it.each( [ '', undefined, 'no version here' ] )( + 'returns null for %j', + ( text ) => { + expect( parseMariaDBVersion( text ) ).toBeNull(); + } + ); +} ); + +describe( 'isNewerMinorVersion', () => { + it.each( [ + [ '12.3.3', '10.6.28', true ], + [ '10.11.2', '10.6.28', true ], + [ '11.4.2', '11.3.9', true ], + [ '11.4.2', '11.4.1', false ], + [ '10.6.21', '10.6.20', false ], + [ '10.6.28', '12.3.3', false ], + [ '10.6.28', '10.11.2', false ], + [ '11.4.2', '11.4.2', false ], + ] )( 'compares %j with %j', ( version, compareVersion, expected ) => { + expect( isNewerMinorVersion( version, compareVersion ) ).toBe( + expected + ); + } ); +} ); + +describe( 'findDatabaseDowngrade', () => { + const dockerComposeConfig = { config: 'docker-compose.yml' }; + + /* + * Mocks the output of `docker compose ps` with the given container states, + * as one JSON object per line. + */ + function mockContainers( containers ) { + return vi.spyOn( dockerCompose, 'execCompose' ).mockResolvedValue( { + out: containers + .map( ( container ) => JSON.stringify( container ) ) + .join( '\n' ), + } ); + } + + beforeEach( () => { + vi.restoreAllMocks(); + mockContainers( [ + { Service: 'mysql', State: 'exited', ExitCode: 1 }, + { Service: 'tests-mysql', State: 'exited', ExitCode: 1 }, + ] ); + } ); + + it( 'finds a database last used by a newer version', async () => { + const run = vi + .spyOn( dockerCompose, 'run' ) + .mockResolvedValueOnce( { + out: '1:12.3.3+maria~ubu2404\n12.3.3-MariaDB', + } ) + .mockResolvedValueOnce( { + out: '1:10.6.28+maria~ubu2004\n12.3.3-MariaDB', + } ); + + await expect( + findDatabaseDowngrade( + [ 'mysql', 'tests-mysql' ], + dockerComposeConfig + ) + ).resolves.toEqual( { + service: 'tests-mysql', + dataVersion: '12.3.3', + serverVersion: '10.6.28', + } ); + expect( run ).toHaveBeenCalledWith( + 'mysql', + expect.any( Array ), + expect.objectContaining( { + config: 'docker-compose.yml', + commandOptions: [ '--rm', '--no-deps', '--entrypoint', 'sh' ], + } ) + ); + } ); + + it( 'checks every database and reports the first downgrade in order', async () => { + const run = vi.spyOn( dockerCompose, 'run' ).mockResolvedValue( { + out: '1:10.6.28+maria~ubu2004\n12.3.3-MariaDB', + } ); + + await expect( + findDatabaseDowngrade( + [ 'mysql', 'tests-mysql' ], + dockerComposeConfig + ) + ).resolves.toEqual( { + service: 'mysql', + dataVersion: '12.3.3', + serverVersion: '10.6.28', + } ); + expect( run ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'only checks databases whose container exited with an error', async () => { + mockContainers( [ + { Service: 'mysql', State: 'running', ExitCode: 0 }, + { Service: 'tests-mysql', State: 'exited', ExitCode: 1 }, + ] ); + const run = vi.spyOn( dockerCompose, 'run' ).mockResolvedValue( { + out: '1:10.6.28+maria~ubu2004\n12.3.3-MariaDB', + } ); + + await expect( + findDatabaseDowngrade( + [ 'mysql', 'tests-mysql' ], + dockerComposeConfig + ) + ).resolves.toEqual( { + service: 'tests-mysql', + dataVersion: '12.3.3', + serverVersion: '10.6.28', + } ); + expect( run ).toHaveBeenCalledTimes( 1 ); + expect( run ).toHaveBeenCalledWith( + 'tests-mysql', + expect.any( Array ), + expect.any( Object ) + ); + } ); + + it( 'checks nothing when every database is running', async () => { + mockContainers( [ + { Service: 'mysql', State: 'running', ExitCode: 0 }, + { Service: 'tests-mysql', State: 'running', ExitCode: 0 }, + ] ); + const run = vi.spyOn( dockerCompose, 'run' ); + + await expect( + findDatabaseDowngrade( + [ 'mysql', 'tests-mysql' ], + dockerComposeConfig + ) + ).resolves.toBeNull(); + expect( run ).not.toHaveBeenCalled(); + } ); + + it( 'reads the container states printed as one JSON array', async () => { + vi.spyOn( dockerCompose, 'execCompose' ).mockResolvedValue( { + out: JSON.stringify( [ + { Service: 'mysql', State: 'exited', ExitCode: 1 }, + ] ), + } ); + vi.spyOn( dockerCompose, 'run' ).mockResolvedValue( { + out: '1:10.6.28+maria~ubu2004\n12.3.3-MariaDB', + } ); + + await expect( + findDatabaseDowngrade( [ 'mysql' ], dockerComposeConfig ) + ).resolves.toEqual( { + service: 'mysql', + dataVersion: '12.3.3', + serverVersion: '10.6.28', + } ); + } ); + + it( 'checks nothing when the container states cannot be read', async () => { + vi.spyOn( dockerCompose, 'execCompose' ).mockRejectedValue( { + exitCode: 1, + out: '', + err: 'error', + } ); + const run = vi.spyOn( dockerCompose, 'run' ); + + await expect( + findDatabaseDowngrade( [ 'mysql' ], dockerComposeConfig ) + ).resolves.toBeNull(); + expect( run ).not.toHaveBeenCalled(); + } ); + + it( 'ignores a database last used by a newer patch release', async () => { + vi.spyOn( dockerCompose, 'run' ).mockResolvedValue( { + out: '1:10.6.20+maria~ubu2004\n10.6.21-MariaDB', + } ); + + await expect( + findDatabaseDowngrade( [ 'mysql' ], dockerComposeConfig ) + ).resolves.toBeNull(); + } ); + + it( 'ignores a database last used by the same or an older version', async () => { + vi.spyOn( dockerCompose, 'run' ).mockResolvedValue( { + out: '1:12.3.3+maria~ubu2404\n10.6.28-MariaDB', + } ); + + await expect( + findDatabaseDowngrade( [ 'mysql' ], dockerComposeConfig ) + ).resolves.toBeNull(); + } ); + + it( 'ignores a database without a recorded version', async () => { + vi.spyOn( dockerCompose, 'run' ).mockResolvedValue( { + out: '1:10.6.28+maria~ubu2004\n', + } ); + + await expect( + findDatabaseDowngrade( [ 'mysql' ], dockerComposeConfig ) + ).resolves.toBeNull(); + } ); + + it( 'ignores a database whose versions cannot be read', async () => { + vi.spyOn( dockerCompose, 'run' ).mockRejectedValue( { + exitCode: 1, + out: '', + err: 'no such service', + } ); + + await expect( + findDatabaseDowngrade( [ 'mysql' ], dockerComposeConfig ) + ).resolves.toBeNull(); + } ); +} ); diff --git a/packages/env/lib/test/missing-images.js b/packages/env/lib/test/missing-images.js new file mode 100644 index 00000000000000..24be197b7adf88 --- /dev/null +++ b/packages/env/lib/test/missing-images.js @@ -0,0 +1,105 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +const require = createRequire( import.meta.url ); +const yaml = require( 'js-yaml' ); +const { + findMissingImages, + getPulledImages, +} = require( '../runtime/docker/missing-images' ); +const { MissingImageError } = require( '../runtime/errors' ); + +const DOCKER_COMPOSE_CONFIG = { + services: { + mysql: { image: 'mariadb:10.12' }, + wordpress: { build: { dockerfile: 'WordPress.Dockerfile' } }, + cli: { build: { dockerfile: 'CLI.Dockerfile' } }, + phpmyadmin: { image: 'phpmyadmin' }, + 'tests-mysql': { image: 'mariadb:10.12' }, + 'tests-wordpress': { + build: { dockerfile: 'Tests-WordPress.Dockerfile' }, + }, + }, +}; + +describe( 'getPulledImages', () => { + it( 'lists each image once, with the services that use it, and skips built services', () => { + expect( getPulledImages( DOCKER_COMPOSE_CONFIG ) ).toEqual( [ + { image: 'mariadb:10.12', services: [ 'mysql', 'tests-mysql' ] }, + { image: 'phpmyadmin', services: [ 'phpmyadmin' ] }, + ] ); + } ); + + it( 'returns nothing without services', () => { + expect( getPulledImages( {} ) ).toEqual( [] ); + } ); +} ); + +describe( 'findMissingImages', () => { + let directory; + let dockerComposeConfigPath; + + beforeEach( () => { + directory = fs.mkdtempSync( + path.join( os.tmpdir(), 'wp-env-missing-images-' ) + ); + dockerComposeConfigPath = path.join( directory, 'docker-compose.yml' ); + fs.writeFileSync( + dockerComposeConfigPath, + yaml.dump( DOCKER_COMPOSE_CONFIG ) + ); + } ); + + afterEach( () => { + fs.rmSync( directory, { recursive: true, force: true } ); + } ); + + it( 'finds the pulled images that are not cached', async () => { + const isCached = async ( image ) => image === 'phpmyadmin'; + + await expect( + findMissingImages( dockerComposeConfigPath, isCached ) + ).resolves.toEqual( [ + { image: 'mariadb:10.12', services: [ 'mysql', 'tests-mysql' ] }, + ] ); + } ); + + it( 'finds nothing when every pulled image is cached', async () => { + const isCached = async () => true; + + await expect( + findMissingImages( dockerComposeConfigPath, isCached ) + ).resolves.toEqual( [] ); + } ); +} ); + +describe( 'MissingImageError', () => { + it( 'names the image and its services, points to mariadbVersion, and includes the last line of the Docker output', () => { + const error = new MissingImageError( + [ + { + image: 'mariadb:10.12', + services: [ 'mysql', 'tests-mysql' ], + }, + ], + ' Image mariadb:10.12 Pulling\n Image phpmyadmin Interrupted\nError response from daemon: docker.io/library/mariadb:10.12: not found\n' + ); + + expect( error.message ).toBe( + 'Could not pull `mariadb:10.12` (mysql, tests-mysql), and it is not available locally. Check that "mariadbVersion" is a published MariaDB version, such as "10.11" or "lts".\n\nError response from daemon: docker.io/library/mariadb:10.12: not found' + ); + } ); + + it( 'leaves out the mariadbVersion hint for other images', () => { + const error = new MissingImageError( [ + { image: 'phpmyadmin', services: [ 'phpmyadmin' ] }, + { image: 'example', services: [ 'example' ] }, + ] ); + + expect( error.message ).toBe( + 'Could not pull `phpmyadmin` (phpmyadmin), `example` (example), and they are not available locally.' + ); + } ); +} ); diff --git a/schemas/json/wp-env.json b/schemas/json/wp-env.json index e8c275b7f0e63d..a4da3e700000af 100644 --- a/schemas/json/wp-env.json +++ b/schemas/json/wp-env.json @@ -33,6 +33,23 @@ ], "default": null }, + "mariadbVersion": { + "description": "The MariaDB version to use in the Docker runtime: \"lts\", \"latest\", or a version such as \"10.11\". If null is specified, wp-env will use the \"lts\" image.", + "oneOf": [ + { + "type": "string", + "enum": [ "lts", "latest" ] + }, + { + "type": "string", + "pattern": "^[0-9]+(?:\\.[0-9]+)*$" + }, + { + "type": "null" + } + ], + "default": null + }, "plugins": { "description": "A list of plugins to install and activate in the environment.", "type": "array", @@ -113,6 +130,7 @@ "enum": [ "core", "phpVersion", + "mariadbVersion", "plugins", "themes", "port", diff --git a/test/integration/wp-env-schema.test.js b/test/integration/wp-env-schema.test.js index 26071fe2e7eded..cff366ca879b3b 100644 --- a/test/integration/wp-env-schema.test.js +++ b/test/integration/wp-env-schema.test.js @@ -30,4 +30,27 @@ describe( '.wp-env.json schema', () => { expect( result ).toBe( true ); } ); + + test.each( [ 'lts', 'latest', '10', '10.11', '10.3.39', null ] )( + 'accepts mariadbVersion %j at the root and in an environment', + ( mariadbVersion ) => { + expect( ajv.validate( wpEnvSchema, { mariadbVersion } ) ).toBe( + true + ); + expect( + ajv.validate( wpEnvSchema, { + env: { tests: { mariadbVersion } }, + } ) + ).toBe( true ); + } + ); + + test.each( [ 'LTS', 'lts-10', '11-noble', '', 10.11 ] )( + 'rejects mariadbVersion %j', + ( mariadbVersion ) => { + expect( ajv.validate( wpEnvSchema, { mariadbVersion } ) ).toBe( + false + ); + } + ); } );