You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
173 lines
6.8 KiB
173 lines
6.8 KiB
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<void>((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;
|
|
}
|
|
}
|