Skip to content

Commit 8e19a6e

Browse files
committed
feat(systemd): add doctor checks for systemd node version
1 parent 20b93cf commit 8e19a6e

File tree

5 files changed

+319
-2
lines changed

5 files changed

+319
-2
lines changed

extensions/systemd/doctor.js

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const fs = require('fs-extra');
2+
const get = require('lodash/get');
3+
const path = require('path');
4+
const ini = require('ini');
5+
const chalk = require('chalk');
6+
const semver = require('semver');
7+
const execa = require('execa');
8+
const {errors} = require('../../lib');
9+
10+
const {SystemError} = errors;
11+
12+
const systemdEnabled =
13+
({instance}) => instance.config.get('process', 'local') === 'systemd';
14+
15+
const unitCheckTitle = 'Checking systemd unit file';
16+
const nodeCheckTitle = 'Checking systemd node version';
17+
18+
async function checkUnitFile(ctx) {
19+
const unitFilePath = `/lib/systemd/system/ghost_${ctx.instance.name}.service`;
20+
ctx.systemd = {unitFilePath};
21+
22+
try {
23+
const contents = await fs.readFile(unitFilePath);
24+
ctx.systemd.unit = ini.parse(contents.toString('utf8').trim());
25+
} catch (error) {
26+
throw new SystemError({
27+
message: 'Unable to load or parse systemd unit file',
28+
err: error
29+
});
30+
}
31+
}
32+
33+
async function checkNodeVersion({instance, systemd, ui}, task) {
34+
const errBlock = {
35+
message: 'Unable to determine node version in use by systemd',
36+
help: `Ensure 'ExecStart' exists in ${chalk.cyan(systemd.unitFilePath)} and uses a valid Node version`
37+
};
38+
39+
const execStart = get(systemd, 'unit.Service.ExecStart', null);
40+
if (!execStart) {
41+
throw new SystemError(errBlock);
42+
}
43+
44+
const [nodePath] = execStart.split(' ');
45+
let version;
46+
47+
try {
48+
const stdout = await execa.stdout(nodePath, ['--version']);
49+
version = semver.valid(stdout.trim());
50+
} catch (_) {
51+
throw new SystemError(errBlock);
52+
}
53+
54+
if (!version) {
55+
throw new SystemError(errBlock);
56+
}
57+
58+
task.title = `${nodeCheckTitle} - found v${version}`;
59+
60+
if (!semver.eq(version, process.versions.node)) {
61+
ui.log(
62+
`Warning: Ghost is running with node v${version}.\n` +
63+
`Your current node version is v${process.versions.node}.`,
64+
'yellow'
65+
);
66+
}
67+
68+
let nodeRange;
69+
70+
try {
71+
const packagePath = path.join(instance.dir, 'current/package.json');
72+
const ghostPkg = await fs.readJson(packagePath);
73+
nodeRange = get(ghostPkg, 'engines.node', null);
74+
} catch (_) {
75+
return;
76+
}
77+
78+
if (!nodeRange) {
79+
return;
80+
}
81+
82+
if (!semver.satisfies(version, nodeRange)) {
83+
throw new SystemError({
84+
message: `Ghost v${instance.version} is not compatible with Node v${version}`,
85+
help: `Check the version of Node configured in ${chalk.cyan(systemd.unitFilePath)} and update it to a compatible version`
86+
});
87+
}
88+
}
89+
90+
module.exports = [{
91+
title: unitCheckTitle,
92+
task: checkUnitFile,
93+
enabled: systemdEnabled,
94+
category: ['start']
95+
}, {
96+
title: nodeCheckTitle,
97+
task: checkNodeVersion,
98+
enabled: systemdEnabled,
99+
category: ['start']
100+
}];
101+
102+
// exports for unit testing
103+
module.exports.checkUnitFile = checkUnitFile;
104+
module.exports.checkNodeVersion = checkNodeVersion;

extensions/systemd/index.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use strict';
2-
31
const fs = require('fs-extra');
42
const path = require('path');
53
const template = require('lodash/template');
@@ -10,6 +8,11 @@ const {Extension, errors} = require('../../lib');
108
const {ProcessError, SystemError} = errors;
119

1210
class SystemdExtension extends Extension {
11+
doctor() {
12+
const checks = require('./doctor');
13+
return checks;
14+
}
15+
1316
setup() {
1417
return [{
1518
id: 'systemd',
+196
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
const {expect, use} = require('chai');
2+
const sinon = require('sinon');
3+
4+
const fs = require('fs-extra');
5+
const execa = require('execa');
6+
const {errors} = require('../../../lib');
7+
8+
const {checkUnitFile, checkNodeVersion} = require('../doctor');
9+
10+
use(require('chai-as-promised'));
11+
12+
describe('Unit: Systemd > doctor checks', function () {
13+
afterEach(function () {
14+
sinon.restore();
15+
});
16+
17+
describe('checkUnitFile', function () {
18+
it('errors when readFile errors', async function () {
19+
const readFile = sinon.stub(fs, 'readFile').rejects(new Error('test'));
20+
const ctx = {
21+
instance: {name: 'test'}
22+
};
23+
24+
const expectedPath = '/lib/systemd/system/ghost_test.service';
25+
26+
await expect(checkUnitFile(ctx)).to.be.rejectedWith(errors.SystemError);
27+
expect(readFile.calledOnceWithExactly(expectedPath)).to.be.true;
28+
expect(ctx.systemd).to.deep.equal({unitFilePath: expectedPath});
29+
});
30+
31+
it('adds valid unit file to context', async function () {
32+
const readFile = sinon.stub(fs, 'readFile').resolves(`
33+
[Section1]
34+
Foo=Bar
35+
Baz = Bat
36+
37+
[Section2]
38+
Test=Value
39+
`);
40+
41+
const ctx = {
42+
instance: {name: 'test'}
43+
};
44+
45+
const expectedPath = '/lib/systemd/system/ghost_test.service';
46+
const expectedCtx = {
47+
unitFilePath: expectedPath,
48+
unit: {
49+
Section1: {
50+
Foo: 'Bar',
51+
Baz: 'Bat'
52+
},
53+
Section2: {
54+
Test: 'Value'
55+
}
56+
}
57+
};
58+
59+
await expect(checkUnitFile(ctx)).to.not.be.rejected;
60+
expect(readFile.calledOnceWithExactly(expectedPath)).to.be.true;
61+
expect(ctx.systemd).to.deep.equal(expectedCtx);
62+
});
63+
});
64+
65+
describe('checkNodeVersion', function () {
66+
it('rejects if ExecStart line not found', async function () {
67+
const ctx = {
68+
systemd: {
69+
unitFilePath: '/tmp/unit-file',
70+
unit: {}
71+
}
72+
};
73+
const task = {};
74+
75+
await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
76+
});
77+
78+
it('rejects if node --version rejects', async function () {
79+
const stdout = sinon.stub(execa, 'stdout').rejects(new Error('test error'));
80+
81+
const ctx = {
82+
systemd: {
83+
unitFilePath: '/tmp/unit-file',
84+
unit: {
85+
Service: {
86+
ExecStart: '/usr/bin/node /usr/bin/ghost'
87+
}
88+
}
89+
}
90+
};
91+
const task = {};
92+
93+
await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
94+
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
95+
});
96+
97+
it('rejects if invalid semver', async function () {
98+
const stdout = sinon.stub(execa, 'stdout').resolves('not-valid-semver');
99+
100+
const ctx = {
101+
systemd: {
102+
unitFilePath: '/tmp/unit-file',
103+
unit: {
104+
Service: {
105+
ExecStart: '/usr/bin/node /usr/bin/ghost'
106+
}
107+
}
108+
}
109+
};
110+
const task = {};
111+
112+
await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
113+
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
114+
});
115+
116+
it('returns if unable to parse ghost pkg json', async function () {
117+
const stdout = sinon.stub(execa, 'stdout').resolves('12.0.0');
118+
const readJson = sinon.stub(fs, 'readJson').rejects(new Error('test'));
119+
const log = sinon.stub();
120+
121+
const ctx = {
122+
systemd: {
123+
unitFilePath: '/tmp/unit-file',
124+
unit: {
125+
Service: {
126+
ExecStart: '/usr/bin/node /usr/bin/ghost'
127+
}
128+
}
129+
},
130+
ui: {log},
131+
instance: {dir: '/var/www/ghost'}
132+
};
133+
const task = {};
134+
135+
await expect(checkNodeVersion(ctx, task)).to.not.be.rejected;
136+
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
137+
expect(task.title).to.equal('Checking systemd node version - found v12.0.0');
138+
expect(readJson.calledOnceWithExactly('/var/www/ghost/current/package.json')).to.be.true;
139+
expect(log.calledOnce).to.be.true;
140+
});
141+
142+
it('returns if unable to find node range in ghost pkg json', async function () {
143+
const stdout = sinon.stub(execa, 'stdout').resolves(process.versions.node);
144+
const readJson = sinon.stub(fs, 'readJson').resolves({});
145+
const log = sinon.stub();
146+
147+
const ctx = {
148+
systemd: {
149+
unitFilePath: '/tmp/unit-file',
150+
unit: {
151+
Service: {
152+
ExecStart: '/usr/bin/node /usr/bin/ghost'
153+
}
154+
}
155+
},
156+
ui: {log},
157+
instance: {dir: '/var/www/ghost'}
158+
};
159+
const task = {};
160+
161+
await expect(checkNodeVersion(ctx, task)).to.not.be.rejected;
162+
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
163+
expect(task.title).to.equal(`Checking systemd node version - found v${process.versions.node}`);
164+
expect(readJson.calledOnceWithExactly('/var/www/ghost/current/package.json')).to.be.true;
165+
expect(log.called).to.be.false;
166+
});
167+
168+
it('rejects if node version isn\'t compatible with Ghost' , async function () {
169+
const stdout = sinon.stub(execa, 'stdout').resolves(process.versions.node);
170+
const readJson = sinon.stub(fs, 'readJson').resolves({
171+
engines: {node: '< 1.0.0'}
172+
});
173+
const log = sinon.stub();
174+
175+
const ctx = {
176+
systemd: {
177+
unitFilePath: '/tmp/unit-file',
178+
unit: {
179+
Service: {
180+
ExecStart: '/usr/bin/node /usr/bin/ghost'
181+
}
182+
}
183+
},
184+
ui: {log},
185+
instance: {dir: '/var/www/ghost'}
186+
};
187+
const task = {};
188+
189+
await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
190+
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
191+
expect(task.title).to.equal(`Checking systemd node version - found v${process.versions.node}`);
192+
expect(readJson.calledOnceWithExactly('/var/www/ghost/current/package.json')).to.be.true;
193+
expect(log.called).to.be.false;
194+
});
195+
});
196+
});

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"global-modules": "2.0.0",
6161
"got": "9.6.0",
6262
"https-proxy-agent": "5.0.0",
63+
"ini": "2.0.0",
6364
"inquirer": "7.3.3",
6465
"is-running": "2.1.0",
6566
"latest-version": "5.1.0",
@@ -91,6 +92,7 @@
9192
},
9293
"devDependencies": {
9394
"chai": "4.3.4",
95+
"chai-as-promised": "7.1.1",
9496
"coveralls": "3.1.0",
9597
"eslint": "7.22.0",
9698
"eslint-plugin-ghost": "2.0.0",

yarn.lock

+12
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,13 @@ caseless@~0.12.0:
721721
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
722722
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
723723

724+
725+
version "7.1.1"
726+
resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-7.1.1.tgz#08645d825deb8696ee61725dbf590c012eb00ca0"
727+
integrity sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==
728+
dependencies:
729+
check-error "^1.0.2"
730+
724731
725732
version "4.3.4"
726733
resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49"
@@ -2510,6 +2517,11 @@ inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3:
25102517
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
25112518
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
25122519

2520+
2521+
version "2.0.0"
2522+
resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5"
2523+
integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==
2524+
25132525
ini@^1.3.2, ini@^1.3.5, ini@~1.3.0:
25142526
version "1.3.5"
25152527
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"

0 commit comments

Comments
 (0)