Skip to content

Commit

Permalink
feat(mysql): support MySQL version 8
Browse files Browse the repository at this point in the history
refs #1265
- refactor mysql extension to use async/await
- add separate setup steps for MySQL 8
  • Loading branch information
acburdine committed Oct 9, 2020
1 parent 0c79cf4 commit 0eb2771
Show file tree
Hide file tree
Showing 2 changed files with 278 additions and 134 deletions.
237 changes: 163 additions & 74 deletions extensions/mysql/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
'use strict';

const Promise = require('bluebird');
const mysql = require('mysql');
const omit = require('lodash/omit');
const generator = require('generate-password');
const semver = require('semver');

const {Extension, errors} = require('../../lib');

const localhostAliases = ['localhost', '127.0.0.1'];
const {ConfigError, CliError} = errors;
const {ConfigError, CliError, SystemError} = errors;

function isMySQL8(version) {
return version && version.major === 8;
}

function isUnsupportedMySQL(version) {
return version && semver.lt(version, '5.7.0');
}

class MySQLExtension extends Extension {
setup() {
Expand Down Expand Up @@ -35,6 +43,10 @@ class MySQLExtension extends Extension {
}, {
title: 'Granting new user permissions',
task: () => this.grantPermissions(ctx, dbconfig)
}, {
title: 'Setting up database (MySQL 8)',
task: () => this.createMySQL8Database(dbconfig),
enabled: ({mysql: mysqlCtx}) => mysqlCtx && isMySQL8(mysqlCtx.version)
}, {
title: 'Saving new config',
task: () => {
Expand All @@ -46,136 +58,213 @@ class MySQLExtension extends Extension {
}], false);
}

canConnect(ctx, dbconfig) {
async getServerVersion() {
try {
const result = await this._query('SELECT @@version AS version');
if (result && result[0] && result[0].version) {
return semver.parse(result[0].version);
}

return null;
} catch (error) {
this.ui.logVerbose('MySQL: failed to determine server version, assuming 5.x', 'gray');
return null;
}
}

async canConnect(ctx, dbconfig) {
this.connection = mysql.createConnection(omit(dbconfig, 'database'));

return Promise.fromCallback(cb => this.connection.connect(cb)).catch((error) => {
try {
await Promise.fromCallback(cb => this.connection.connect(cb));
} catch (error) {
if (error.code === 'ECONNREFUSED') {
return Promise.reject(new ConfigError({
throw new ConfigError({
message: error.message,
config: {
'database.connection.host': dbconfig.host,
'database.connection.port': dbconfig.port || '3306'
},
environment: this.system.environment,
help: 'Ensure that MySQL is installed and reachable. You can always re-run `ghost setup` to try again.'
}));
});
} else if (error.code === 'ER_ACCESS_DENIED_ERROR') {
return Promise.reject(new ConfigError({
throw new ConfigError({
message: error.message,
config: {
'database.connection.user': dbconfig.user,
'database.connection.password': dbconfig.password
},
environment: this.system.environment,
help: 'You can run `ghost config` to re-enter the correct credentials. Alternatively you can run `ghost setup` again.'
}));
});
}

return Promise.reject(new CliError({
throw new CliError({
message: 'Error trying to connect to the MySQL database.',
help: 'You can run `ghost config` to re-enter the correct credentials. Alternatively you can run `ghost setup` again.',
err: error
}));
});
});
}

const version = await this.getServerVersion();
if (version) {
if (isUnsupportedMySQL(version)) {
throw new SystemError({
message: `Error: unsupported MySQL version (${version.raw})`,
help: 'Update your MySQL server to at least MySQL v5.7 in order to run Ghost'
});
}

ctx.mysql = {version};
}
}

createUser(ctx, dbconfig) {
randomUsername() {
// IMPORTANT: we generate random MySQL usernames
// e.g. you delete all your Ghost instances from your droplet and start from scratch, the MySQL users would remain and the CLI has to generate a random user name to work
// e.g. if we would rely on the instance name, the instance naming only auto increments if there are existing instances
// the most important fact is, that if a MySQL user exists, we have no access to the password, which we need to autofill the Ghost config
// disadvantage: the CLI could potentially create lot's of MySQL users (but this should only happen if the user installs Ghost over and over again with root credentials)
return `ghost-${Math.floor(Math.random() * 1000)}`;
}

async getMySQL5Password() {
const randomPassword = generator.generate({
length: 20,
numbers: true,
symbols: true,
strict: true
});

await this._query('SET old_passwords = 0;');
this.ui.logVerbose('MySQL: successfully disabled old_password', 'green');

const result = await this._query(`SELECT PASSWORD('${randomPassword}') AS password;`);

if (!result || !result[0] || !result[0].password) {
throw new Error('MySQL password generation failed');
}

this.ui.logVerbose('MySQL: successfully created password hash.', 'green');
return {
password: randomPassword,
hash: result[0].password
};
}

async createMySQL5User(host) {
const username = this.randomUsername();
const {password, hash} = await this.getMySQL5Password();
await this._query(`CREATE USER '${username}'@'${host}' IDENTIFIED WITH mysql_native_password AS '${hash}';`);
this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');
return {username, password};
}

async createMySQL8User(host) {
const username = this.randomUsername();

const result = await this._query(
`CREATE USER '${username}'@'${host}' IDENTIFIED WITH mysql_native_password BY RANDOM PASSWORD`
);

if (!result || !result[0] || !result[0]['generated password']) {
throw new Error('MySQL user creation did not return a generated password');
}

this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');

return {
username,
password: result[0]['generated password']
};
}

async createUser(ctx, dbconfig) {
// This will be the "allowed connections from" host of the mysql user.
// If the db connection host is something _other_ than localhost (e.g. a remote db connection)
// we want the host to be `%` rather than the db host.
const host = !localhostAliases.includes(dbconfig.host) ? '%' : dbconfig.host;
const {version} = ctx.mysql || {};

let username;

// Ensure old passwords is set to 0
return this._query('SET old_passwords = 0;').then(() => {
this.ui.logVerbose('MySQL: successfully disabled old_password', 'green');

return this._query(`SELECT PASSWORD('${randomPassword}') AS password;`);
}).then((result) => {
this.ui.logVerbose('MySQL: successfully created password hash.', 'green');

const tryCreateUser = () => {
// IMPORTANT: we generate random MySQL usernames
// e.g. you delete all your Ghost instances from your droplet and start from scratch, the MySQL users would remain and the CLI has to generate a random user name to work
// e.g. if we would rely on the instance name, the instance naming only auto increments if there are existing instances
// the most important fact is, that if a MySQL user exists, we have no access to the password, which we need to autofill the Ghost config
// disadvantage: the CLI could potentially create lot's of MySQL users (but this should only happen if the user installs Ghost over and over again with root credentials)
username = `ghost-${Math.floor(Math.random() * 1000)}`;

return this._query(
`CREATE USER '${username}'@'${host}' ` +
`IDENTIFIED WITH mysql_native_password AS '${result[0].password}';`
).catch((error) => {
// User already exists, run this method again
if (error.err && error.err.errno === 1396) {
this.ui.logVerbose('MySQL: user exists, re-trying user creation with new username', 'yellow');
return tryCreateUser();
}

error.message = `Creating new MySQL user errored with message: ${error.message}`;

return Promise.reject(error);
});
};
try {
let user = {};

return tryCreateUser();
}).then(() => {
this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');
if (isMySQL8(version)) {
user = await this.createMySQL8User(host);
} else {
user = await this.createMySQL5User(host);
}

ctx.mysql = {
host: host,
username: username,
password: randomPassword
...(ctx.mysql || {}),
...user,
host
};
}).catch((error) => {
} catch (error) {
if (error.err && error.err.errno === 1396) {
this.ui.logVerbose('MySQL: user exists, re-trying user creation with new username', 'yellow');
return this.createUser(ctx, dbconfig);
}

this.ui.logVerbose('MySQL: Unable to create custom Ghost user', 'red');
this.connection.end(); // Ensure we end the connection

error.message = `Creating new MySQL user errored with message: ${error.message}`;

return Promise.reject(error);
});
throw error;
}
}

grantPermissions(ctx, dbconfig) {
return this._query(`GRANT ALL PRIVILEGES ON ${dbconfig.database}.* TO '${ctx.mysql.username}'@'${ctx.mysql.host}';`).then(() => {
async grantPermissions(ctx, dbconfig) {
try {
await this._query(`GRANT ALL PRIVILEGES ON ${dbconfig.database}.* TO '${ctx.mysql.username}'@'${ctx.mysql.host}';`);
this.ui.logVerbose(`MySQL: Successfully granted privileges for user "${ctx.mysql.username}"`, 'green');
return this._query('FLUSH PRIVILEGES;');
}).then(() => {

await this._query('FLUSH PRIVILEGES;');
this.ui.logVerbose('MySQL: flushed privileges', 'green');
}).catch((error) => {
} catch (error) {
this.ui.logVerbose('MySQL: Unable either to grant permissions or flush privileges', 'red');
this.connection.end();

error.message = `Granting database permissions errored with message: ${error.message}`;
throw error;
}
}

return Promise.reject(error);
});
async createMySQL8Database(dbconfig) {
const {database} = dbconfig;
if (!database) {
return;
}

try {
await this._query(`CREATE DATABASE IF NOT EXISTS \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;`);
this.ui.logVerbose(`MySQL: created database ${database}`, 'green');
} catch (error) {
this.ui.logVerbose(`MySQL: failed to create database ${database}`, 'red');
this.connection.end();

error.message = `Creating database ${database} errored with message: ${error.message}`;
throw error;
}
}

_query(queryString) {
async _query(queryString) {
this.ui.logVerbose(`MySQL: running query > ${queryString}`, 'gray');
return Promise.fromCallback(cb => this.connection.query(queryString, cb))
.catch((error) => {
if (error instanceof CliError) {
return Promise.reject(error);
}
try {
const result = await Promise.fromCallback(cb => this.connection.query(queryString, cb));
return result;
} catch (error) {
if (error instanceof CliError) {
throw error;
}

return Promise.reject(new CliError({
message: error.message,
context: queryString,
err: error
}));
throw new CliError({
message: error.message,
context: queryString,
err: error
});
}
}
}

Expand Down
Loading

0 comments on commit 0eb2771

Please sign in to comment.