Skip to content

Commit

Permalink
feat(systemd): add doctor checks for systemd node version
Browse files Browse the repository at this point in the history
  • Loading branch information
acburdine committed Mar 20, 2021
1 parent 20b93cf commit 8e19a6e
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 2 deletions.
104 changes: 104 additions & 0 deletions extensions/systemd/doctor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const fs = require('fs-extra');
const get = require('lodash/get');
const path = require('path');
const ini = require('ini');
const chalk = require('chalk');
const semver = require('semver');
const execa = require('execa');
const {errors} = require('../../lib');

const {SystemError} = errors;

const systemdEnabled =
({instance}) => instance.config.get('process', 'local') === 'systemd';

const unitCheckTitle = 'Checking systemd unit file';
const nodeCheckTitle = 'Checking systemd node version';

async function checkUnitFile(ctx) {
const unitFilePath = `/lib/systemd/system/ghost_${ctx.instance.name}.service`;
ctx.systemd = {unitFilePath};

try {
const contents = await fs.readFile(unitFilePath);
ctx.systemd.unit = ini.parse(contents.toString('utf8').trim());
} catch (error) {
throw new SystemError({
message: 'Unable to load or parse systemd unit file',
err: error
});
}
}

async function checkNodeVersion({instance, systemd, ui}, task) {
const errBlock = {
message: 'Unable to determine node version in use by systemd',
help: `Ensure 'ExecStart' exists in ${chalk.cyan(systemd.unitFilePath)} and uses a valid Node version`
};

const execStart = get(systemd, 'unit.Service.ExecStart', null);
if (!execStart) {
throw new SystemError(errBlock);
}

const [nodePath] = execStart.split(' ');
let version;

try {
const stdout = await execa.stdout(nodePath, ['--version']);
version = semver.valid(stdout.trim());
} catch (_) {
throw new SystemError(errBlock);
}

if (!version) {
throw new SystemError(errBlock);
}

task.title = `${nodeCheckTitle} - found v${version}`;

if (!semver.eq(version, process.versions.node)) {
ui.log(
`Warning: Ghost is running with node v${version}.\n` +
`Your current node version is v${process.versions.node}.`,
'yellow'
);
}

let nodeRange;

try {
const packagePath = path.join(instance.dir, 'current/package.json');
const ghostPkg = await fs.readJson(packagePath);
nodeRange = get(ghostPkg, 'engines.node', null);
} catch (_) {
return;
}

if (!nodeRange) {
return;
}

if (!semver.satisfies(version, nodeRange)) {
throw new SystemError({
message: `Ghost v${instance.version} is not compatible with Node v${version}`,
help: `Check the version of Node configured in ${chalk.cyan(systemd.unitFilePath)} and update it to a compatible version`
});
}
}

module.exports = [{
title: unitCheckTitle,
task: checkUnitFile,
enabled: systemdEnabled,
category: ['start']
}, {
title: nodeCheckTitle,
task: checkNodeVersion,
enabled: systemdEnabled,
category: ['start']
}];

// exports for unit testing
module.exports.checkUnitFile = checkUnitFile;
module.exports.checkNodeVersion = checkNodeVersion;
7 changes: 5 additions & 2 deletions extensions/systemd/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use strict';

const fs = require('fs-extra');
const path = require('path');
const template = require('lodash/template');
Expand All @@ -10,6 +8,11 @@ const {Extension, errors} = require('../../lib');
const {ProcessError, SystemError} = errors;

class SystemdExtension extends Extension {
doctor() {
const checks = require('./doctor');
return checks;
}

setup() {
return [{
id: 'systemd',
Expand Down
196 changes: 196 additions & 0 deletions extensions/systemd/test/doctor-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
const {expect, use} = require('chai');
const sinon = require('sinon');

const fs = require('fs-extra');
const execa = require('execa');
const {errors} = require('../../../lib');

const {checkUnitFile, checkNodeVersion} = require('../doctor');

use(require('chai-as-promised'));

describe('Unit: Systemd > doctor checks', function () {
afterEach(function () {
sinon.restore();
});

describe('checkUnitFile', function () {
it('errors when readFile errors', async function () {
const readFile = sinon.stub(fs, 'readFile').rejects(new Error('test'));
const ctx = {
instance: {name: 'test'}
};

const expectedPath = '/lib/systemd/system/ghost_test.service';

await expect(checkUnitFile(ctx)).to.be.rejectedWith(errors.SystemError);
expect(readFile.calledOnceWithExactly(expectedPath)).to.be.true;
expect(ctx.systemd).to.deep.equal({unitFilePath: expectedPath});
});

it('adds valid unit file to context', async function () {
const readFile = sinon.stub(fs, 'readFile').resolves(`
[Section1]
Foo=Bar
Baz = Bat
[Section2]
Test=Value
`);

const ctx = {
instance: {name: 'test'}
};

const expectedPath = '/lib/systemd/system/ghost_test.service';
const expectedCtx = {
unitFilePath: expectedPath,
unit: {
Section1: {
Foo: 'Bar',
Baz: 'Bat'
},
Section2: {
Test: 'Value'
}
}
};

await expect(checkUnitFile(ctx)).to.not.be.rejected;
expect(readFile.calledOnceWithExactly(expectedPath)).to.be.true;
expect(ctx.systemd).to.deep.equal(expectedCtx);
});
});

describe('checkNodeVersion', function () {
it('rejects if ExecStart line not found', async function () {
const ctx = {
systemd: {
unitFilePath: '/tmp/unit-file',
unit: {}
}
};
const task = {};

await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
});

it('rejects if node --version rejects', async function () {
const stdout = sinon.stub(execa, 'stdout').rejects(new Error('test error'));

const ctx = {
systemd: {
unitFilePath: '/tmp/unit-file',
unit: {
Service: {
ExecStart: '/usr/bin/node /usr/bin/ghost'
}
}
}
};
const task = {};

await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
});

it('rejects if invalid semver', async function () {
const stdout = sinon.stub(execa, 'stdout').resolves('not-valid-semver');

const ctx = {
systemd: {
unitFilePath: '/tmp/unit-file',
unit: {
Service: {
ExecStart: '/usr/bin/node /usr/bin/ghost'
}
}
}
};
const task = {};

await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
});

it('returns if unable to parse ghost pkg json', async function () {
const stdout = sinon.stub(execa, 'stdout').resolves('12.0.0');
const readJson = sinon.stub(fs, 'readJson').rejects(new Error('test'));
const log = sinon.stub();

const ctx = {
systemd: {
unitFilePath: '/tmp/unit-file',
unit: {
Service: {
ExecStart: '/usr/bin/node /usr/bin/ghost'
}
}
},
ui: {log},
instance: {dir: '/var/www/ghost'}
};
const task = {};

await expect(checkNodeVersion(ctx, task)).to.not.be.rejected;
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
expect(task.title).to.equal('Checking systemd node version - found v12.0.0');
expect(readJson.calledOnceWithExactly('/var/www/ghost/current/package.json')).to.be.true;
expect(log.calledOnce).to.be.true;
});

it('returns if unable to find node range in ghost pkg json', async function () {
const stdout = sinon.stub(execa, 'stdout').resolves(process.versions.node);
const readJson = sinon.stub(fs, 'readJson').resolves({});
const log = sinon.stub();

const ctx = {
systemd: {
unitFilePath: '/tmp/unit-file',
unit: {
Service: {
ExecStart: '/usr/bin/node /usr/bin/ghost'
}
}
},
ui: {log},
instance: {dir: '/var/www/ghost'}
};
const task = {};

await expect(checkNodeVersion(ctx, task)).to.not.be.rejected;
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
expect(task.title).to.equal(`Checking systemd node version - found v${process.versions.node}`);
expect(readJson.calledOnceWithExactly('/var/www/ghost/current/package.json')).to.be.true;
expect(log.called).to.be.false;
});

it('rejects if node version isn\'t compatible with Ghost' , async function () {
const stdout = sinon.stub(execa, 'stdout').resolves(process.versions.node);
const readJson = sinon.stub(fs, 'readJson').resolves({
engines: {node: '< 1.0.0'}
});
const log = sinon.stub();

const ctx = {
systemd: {
unitFilePath: '/tmp/unit-file',
unit: {
Service: {
ExecStart: '/usr/bin/node /usr/bin/ghost'
}
}
},
ui: {log},
instance: {dir: '/var/www/ghost'}
};
const task = {};

await expect(checkNodeVersion(ctx, task)).to.be.rejectedWith(errors.SystemError);
expect(stdout.calledOnceWithExactly('/usr/bin/node', ['--version'])).to.be.true;
expect(task.title).to.equal(`Checking systemd node version - found v${process.versions.node}`);
expect(readJson.calledOnceWithExactly('/var/www/ghost/current/package.json')).to.be.true;
expect(log.called).to.be.false;
});
});
});
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"global-modules": "2.0.0",
"got": "9.6.0",
"https-proxy-agent": "5.0.0",
"ini": "2.0.0",
"inquirer": "7.3.3",
"is-running": "2.1.0",
"latest-version": "5.1.0",
Expand Down Expand Up @@ -91,6 +92,7 @@
},
"devDependencies": {
"chai": "4.3.4",
"chai-as-promised": "7.1.1",
"coveralls": "3.1.0",
"eslint": "7.22.0",
"eslint-plugin-ghost": "2.0.0",
Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,13 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=

chai-as-promised@7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/chai-as-promised/-/chai-as-promised-7.1.1.tgz#08645d825deb8696ee61725dbf590c012eb00ca0"
integrity sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==
dependencies:
check-error "^1.0.2"

chai@4.3.4:
version "4.3.4"
resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.4.tgz#b55e655b31e1eac7099be4c08c21964fce2e6c49"
Expand Down Expand Up @@ -2510,6 +2517,11 @@ inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==

ini@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5"
integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==

ini@^1.3.2, ini@^1.3.5, ini@~1.3.0:
version "1.3.5"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
Expand Down

0 comments on commit 8e19a6e

Please sign in to comment.