Skip to content

Commit 0eb2771

Browse files
committed
feat(mysql): support MySQL version 8
refs #1265 - refactor mysql extension to use async/await - add separate setup steps for MySQL 8
1 parent 0c79cf4 commit 0eb2771

File tree

2 files changed

+278
-134
lines changed

2 files changed

+278
-134
lines changed

extensions/mysql/index.js

+163-74
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1-
'use strict';
2-
31
const Promise = require('bluebird');
42
const mysql = require('mysql');
53
const omit = require('lodash/omit');
64
const generator = require('generate-password');
5+
const semver = require('semver');
6+
77
const {Extension, errors} = require('../../lib');
88

99
const localhostAliases = ['localhost', '127.0.0.1'];
10-
const {ConfigError, CliError} = errors;
10+
const {ConfigError, CliError, SystemError} = errors;
11+
12+
function isMySQL8(version) {
13+
return version && version.major === 8;
14+
}
15+
16+
function isUnsupportedMySQL(version) {
17+
return version && semver.lt(version, '5.7.0');
18+
}
1119

1220
class MySQLExtension extends Extension {
1321
setup() {
@@ -35,6 +43,10 @@ class MySQLExtension extends Extension {
3543
}, {
3644
title: 'Granting new user permissions',
3745
task: () => this.grantPermissions(ctx, dbconfig)
46+
}, {
47+
title: 'Setting up database (MySQL 8)',
48+
task: () => this.createMySQL8Database(dbconfig),
49+
enabled: ({mysql: mysqlCtx}) => mysqlCtx && isMySQL8(mysqlCtx.version)
3850
}, {
3951
title: 'Saving new config',
4052
task: () => {
@@ -46,136 +58,213 @@ class MySQLExtension extends Extension {
4658
}], false);
4759
}
4860

49-
canConnect(ctx, dbconfig) {
61+
async getServerVersion() {
62+
try {
63+
const result = await this._query('SELECT @@version AS version');
64+
if (result && result[0] && result[0].version) {
65+
return semver.parse(result[0].version);
66+
}
67+
68+
return null;
69+
} catch (error) {
70+
this.ui.logVerbose('MySQL: failed to determine server version, assuming 5.x', 'gray');
71+
return null;
72+
}
73+
}
74+
75+
async canConnect(ctx, dbconfig) {
5076
this.connection = mysql.createConnection(omit(dbconfig, 'database'));
5177

52-
return Promise.fromCallback(cb => this.connection.connect(cb)).catch((error) => {
78+
try {
79+
await Promise.fromCallback(cb => this.connection.connect(cb));
80+
} catch (error) {
5381
if (error.code === 'ECONNREFUSED') {
54-
return Promise.reject(new ConfigError({
82+
throw new ConfigError({
5583
message: error.message,
5684
config: {
5785
'database.connection.host': dbconfig.host,
5886
'database.connection.port': dbconfig.port || '3306'
5987
},
6088
environment: this.system.environment,
6189
help: 'Ensure that MySQL is installed and reachable. You can always re-run `ghost setup` to try again.'
62-
}));
90+
});
6391
} else if (error.code === 'ER_ACCESS_DENIED_ERROR') {
64-
return Promise.reject(new ConfigError({
92+
throw new ConfigError({
6593
message: error.message,
6694
config: {
6795
'database.connection.user': dbconfig.user,
6896
'database.connection.password': dbconfig.password
6997
},
7098
environment: this.system.environment,
7199
help: 'You can run `ghost config` to re-enter the correct credentials. Alternatively you can run `ghost setup` again.'
72-
}));
100+
});
73101
}
74102

75-
return Promise.reject(new CliError({
103+
throw new CliError({
76104
message: 'Error trying to connect to the MySQL database.',
77105
help: 'You can run `ghost config` to re-enter the correct credentials. Alternatively you can run `ghost setup` again.',
78106
err: error
79-
}));
80-
});
107+
});
108+
}
109+
110+
const version = await this.getServerVersion();
111+
if (version) {
112+
if (isUnsupportedMySQL(version)) {
113+
throw new SystemError({
114+
message: `Error: unsupported MySQL version (${version.raw})`,
115+
help: 'Update your MySQL server to at least MySQL v5.7 in order to run Ghost'
116+
});
117+
}
118+
119+
ctx.mysql = {version};
120+
}
81121
}
82122

83-
createUser(ctx, dbconfig) {
123+
randomUsername() {
124+
// IMPORTANT: we generate random MySQL usernames
125+
// 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
126+
// e.g. if we would rely on the instance name, the instance naming only auto increments if there are existing instances
127+
// 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
128+
// 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)
129+
return `ghost-${Math.floor(Math.random() * 1000)}`;
130+
}
131+
132+
async getMySQL5Password() {
84133
const randomPassword = generator.generate({
85134
length: 20,
86135
numbers: true,
87136
symbols: true,
88137
strict: true
89138
});
90139

140+
await this._query('SET old_passwords = 0;');
141+
this.ui.logVerbose('MySQL: successfully disabled old_password', 'green');
142+
143+
const result = await this._query(`SELECT PASSWORD('${randomPassword}') AS password;`);
144+
145+
if (!result || !result[0] || !result[0].password) {
146+
throw new Error('MySQL password generation failed');
147+
}
148+
149+
this.ui.logVerbose('MySQL: successfully created password hash.', 'green');
150+
return {
151+
password: randomPassword,
152+
hash: result[0].password
153+
};
154+
}
155+
156+
async createMySQL5User(host) {
157+
const username = this.randomUsername();
158+
const {password, hash} = await this.getMySQL5Password();
159+
await this._query(`CREATE USER '${username}'@'${host}' IDENTIFIED WITH mysql_native_password AS '${hash}';`);
160+
this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');
161+
return {username, password};
162+
}
163+
164+
async createMySQL8User(host) {
165+
const username = this.randomUsername();
166+
167+
const result = await this._query(
168+
`CREATE USER '${username}'@'${host}' IDENTIFIED WITH mysql_native_password BY RANDOM PASSWORD`
169+
);
170+
171+
if (!result || !result[0] || !result[0]['generated password']) {
172+
throw new Error('MySQL user creation did not return a generated password');
173+
}
174+
175+
this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');
176+
177+
return {
178+
username,
179+
password: result[0]['generated password']
180+
};
181+
}
182+
183+
async createUser(ctx, dbconfig) {
91184
// This will be the "allowed connections from" host of the mysql user.
92185
// If the db connection host is something _other_ than localhost (e.g. a remote db connection)
93186
// we want the host to be `%` rather than the db host.
94187
const host = !localhostAliases.includes(dbconfig.host) ? '%' : dbconfig.host;
188+
const {version} = ctx.mysql || {};
95189

96-
let username;
97-
98-
// Ensure old passwords is set to 0
99-
return this._query('SET old_passwords = 0;').then(() => {
100-
this.ui.logVerbose('MySQL: successfully disabled old_password', 'green');
101-
102-
return this._query(`SELECT PASSWORD('${randomPassword}') AS password;`);
103-
}).then((result) => {
104-
this.ui.logVerbose('MySQL: successfully created password hash.', 'green');
105-
106-
const tryCreateUser = () => {
107-
// IMPORTANT: we generate random MySQL usernames
108-
// 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
109-
// e.g. if we would rely on the instance name, the instance naming only auto increments if there are existing instances
110-
// 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
111-
// 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)
112-
username = `ghost-${Math.floor(Math.random() * 1000)}`;
113-
114-
return this._query(
115-
`CREATE USER '${username}'@'${host}' ` +
116-
`IDENTIFIED WITH mysql_native_password AS '${result[0].password}';`
117-
).catch((error) => {
118-
// User already exists, run this method again
119-
if (error.err && error.err.errno === 1396) {
120-
this.ui.logVerbose('MySQL: user exists, re-trying user creation with new username', 'yellow');
121-
return tryCreateUser();
122-
}
123-
124-
error.message = `Creating new MySQL user errored with message: ${error.message}`;
125-
126-
return Promise.reject(error);
127-
});
128-
};
190+
try {
191+
let user = {};
129192

130-
return tryCreateUser();
131-
}).then(() => {
132-
this.ui.logVerbose(`MySQL: successfully created new user ${username}`, 'green');
193+
if (isMySQL8(version)) {
194+
user = await this.createMySQL8User(host);
195+
} else {
196+
user = await this.createMySQL5User(host);
197+
}
133198

134199
ctx.mysql = {
135-
host: host,
136-
username: username,
137-
password: randomPassword
200+
...(ctx.mysql || {}),
201+
...user,
202+
host
138203
};
139-
}).catch((error) => {
204+
} catch (error) {
205+
if (error.err && error.err.errno === 1396) {
206+
this.ui.logVerbose('MySQL: user exists, re-trying user creation with new username', 'yellow');
207+
return this.createUser(ctx, dbconfig);
208+
}
209+
140210
this.ui.logVerbose('MySQL: Unable to create custom Ghost user', 'red');
141211
this.connection.end(); // Ensure we end the connection
142212

143213
error.message = `Creating new MySQL user errored with message: ${error.message}`;
144-
145-
return Promise.reject(error);
146-
});
214+
throw error;
215+
}
147216
}
148217

149-
grantPermissions(ctx, dbconfig) {
150-
return this._query(`GRANT ALL PRIVILEGES ON ${dbconfig.database}.* TO '${ctx.mysql.username}'@'${ctx.mysql.host}';`).then(() => {
218+
async grantPermissions(ctx, dbconfig) {
219+
try {
220+
await this._query(`GRANT ALL PRIVILEGES ON ${dbconfig.database}.* TO '${ctx.mysql.username}'@'${ctx.mysql.host}';`);
151221
this.ui.logVerbose(`MySQL: Successfully granted privileges for user "${ctx.mysql.username}"`, 'green');
152-
return this._query('FLUSH PRIVILEGES;');
153-
}).then(() => {
222+
223+
await this._query('FLUSH PRIVILEGES;');
154224
this.ui.logVerbose('MySQL: flushed privileges', 'green');
155-
}).catch((error) => {
225+
} catch (error) {
156226
this.ui.logVerbose('MySQL: Unable either to grant permissions or flush privileges', 'red');
157227
this.connection.end();
158228

159229
error.message = `Granting database permissions errored with message: ${error.message}`;
230+
throw error;
231+
}
232+
}
160233

161-
return Promise.reject(error);
162-
});
234+
async createMySQL8Database(dbconfig) {
235+
const {database} = dbconfig;
236+
if (!database) {
237+
return;
238+
}
239+
240+
try {
241+
await this._query(`CREATE DATABASE IF NOT EXISTS \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;`);
242+
this.ui.logVerbose(`MySQL: created database ${database}`, 'green');
243+
} catch (error) {
244+
this.ui.logVerbose(`MySQL: failed to create database ${database}`, 'red');
245+
this.connection.end();
246+
247+
error.message = `Creating database ${database} errored with message: ${error.message}`;
248+
throw error;
249+
}
163250
}
164251

165-
_query(queryString) {
252+
async _query(queryString) {
166253
this.ui.logVerbose(`MySQL: running query > ${queryString}`, 'gray');
167-
return Promise.fromCallback(cb => this.connection.query(queryString, cb))
168-
.catch((error) => {
169-
if (error instanceof CliError) {
170-
return Promise.reject(error);
171-
}
254+
try {
255+
const result = await Promise.fromCallback(cb => this.connection.query(queryString, cb));
256+
return result;
257+
} catch (error) {
258+
if (error instanceof CliError) {
259+
throw error;
260+
}
172261

173-
return Promise.reject(new CliError({
174-
message: error.message,
175-
context: queryString,
176-
err: error
177-
}));
262+
throw new CliError({
263+
message: error.message,
264+
context: queryString,
265+
err: error
178266
});
267+
}
179268
}
180269
}
181270

0 commit comments

Comments
 (0)