import type {Container} from 'dockerode'; import logging from '@tryghost/logging'; import baseDebug from '@tryghost/debug'; import {DockerCompose} from './DockerCompose'; import {PassThrough} from 'stream'; const debug = baseDebug('e2e:MySQLManager'); interface ContainerWithModem extends Container { modem: { demuxStream(stream: NodeJS.ReadableStream, stdout: NodeJS.WritableStream, stderr: NodeJS.WritableStream): void; }; } /** * Encapsulates MySQL operations within the docker-compose environment. * Responsible for creating snapshots, creating/restoring/dropping databases, and * updating database settings needed by tests. */ export class MySQLManager { private readonly dockerCompose: DockerCompose; constructor(dockerCompose: DockerCompose) { this.dockerCompose = dockerCompose; } /** * Create a snapshot of a source database inside the MySQL container. * Default path is written within the container filesystem. */ async createSnapshot(sourceDatabase: string = 'ghost_testing', outputPath: string = '/tmp/dump.sql'): Promise { logging.info('Creating database snapshot...'); const mysqlContainer = await this.dockerCompose.getContainerForService('mysql'); await this.execInContainer( mysqlContainer, `mysqldump -uroot -proot --opt --single-transaction ${sourceDatabase} > ${outputPath}` ); logging.info('Database snapshot created'); } /** Create a database if it does not already exist. */ async createDatabase(database: string): Promise { debug('Creating database:', database); const mysqlContainer = await this.dockerCompose.getContainerForService('mysql'); await this.execInContainer( mysqlContainer, 'mysql -uroot -proot -e "CREATE DATABASE IF NOT EXISTS \\`' + database + '\\`;"' ); debug('Database created:', database); } /** Restore a database from an existing snapshot file in the container. */ async restoreDatabaseFromSnapshot(database: string, snapshotPath: string = '/tmp/dump.sql'): Promise { debug('Restoring database from snapshot:', database); const mysqlContainer = await this.dockerCompose.getContainerForService('mysql'); await this.execInContainer( mysqlContainer, 'mysql -uroot -proot ' + database + ' < ' + snapshotPath ); debug('Database restored from snapshot:', database); } /** Update site_uuid within the settings table for a given database. */ async updateSiteUuid(database: string, siteUuid: string): Promise { debug('Updating site_uuid in database settings:', database, siteUuid); const mysqlContainer = await this.dockerCompose.getContainerForService('mysql'); await this.execInContainer( mysqlContainer, 'mysql -uroot -proot -e "UPDATE \\`' + database + '\\`.settings SET value=\'' + siteUuid + '\' WHERE \\`key\\`=\'site_uuid\';"' ); debug('site_uuid updated in database settings:', siteUuid); } /** Drop a database if it exists. */ async dropDatabase(database: string): Promise { debug('Dropping database if exists:', database); const mysqlContainer = await this.dockerCompose.getContainerForService('mysql'); await this.execInContainer( mysqlContainer, 'mysql -uroot -proot -e "DROP DATABASE IF EXISTS \\`' + database + '\\`;"' ); debug('Database dropped (if existed):', database); } /** * High-level helper used by tests: create DB, restore snapshot, apply settings. */ async setupTestDatabase(database: string, siteUuid: string): Promise { try { await this.createDatabase(database); await this.restoreDatabaseFromSnapshot(database); await this.updateSiteUuid(database, siteUuid); debug('Test database setup completed:', database, 'with site_uuid:', siteUuid); } catch (error) { logging.error('Failed to setup test database:', error); throw new Error(`Failed to setup test database: ${error}`); } } /** High-level helper used by tests: drop the DB. */ async cleanupTestDatabase(database: string): Promise { try { await this.dropDatabase(database); debug('Test database cleanup completed:', database); } catch (error) { logging.warn('Failed to cleanup test database:', error); // Don't throw - cleanup failures shouldn't break tests } } /** * Execute a command in a container and wait for completion * * This is primarily needed to run CLI commands like mysqldump inside the container * * Dockerode's exec API is a bit low-level and requires some boilerplate to handle the streams * and detect errors, so we encapsulate that complexity here. * * @param container - The Docker container to execute the command in * @param command - The shell command to execute * @returns The command output * @throws Error if the command fails */ private async execInContainer(container: Container, command: string): Promise { const exec = await container.exec({ Cmd: ['sh', '-c', command], AttachStdout: true, AttachStderr: true, Tty: false }); const stream = await exec.start({ hijack: true, stdin: false }); // Demultiplex the stream into separate stdout and stderr const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; const stdoutStream = new PassThrough(); const stderrStream = new PassThrough(); stdoutStream.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); stderrStream.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); // Use Docker modem's demuxStream to separate stdout and stderr (container as ContainerWithModem).modem.demuxStream(stream, stdoutStream, stderrStream); // Wait for the stream to end await new Promise((resolve, reject) => { stream.on('end', () => resolve()); stream.on('error', reject); }); // Get the exit code from exec inspection const execInfo = await exec.inspect(); const exitCode = execInfo.ExitCode; const stdout = Buffer.concat(stdoutChunks).toString('utf8').trim(); const stderr = Buffer.concat(stderrChunks).toString('utf8').trim(); if (exitCode !== 0) { throw new Error( `Command failed with exit code ${exitCode}: ${command}\n` + `STDOUT: ${stdout}\n` + `STDERR: ${stderr}` ); } return stdout; } }