From b83387a84ae3be2bb06572f7c95d892bffe50a57 Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Fri, 19 Feb 2021 11:13:25 +0100 Subject: [PATCH] Add a version service (#729) - Checks versions against https://version.unleash.run - Generates a unique instance id (uuid) --- CHANGELOG.md | 2 + docs/getting-started.md | 8 ++ src/lib/options.js | 6 + src/lib/routes/admin-api/config.js | 16 ++- src/lib/services/index.js | 3 + src/lib/services/version-service.js | 62 +++++++++ src/lib/services/versions-service.test.js | 118 ++++++++++++++++++ ...210218090213-generate-server-identifier.js | 19 +++ src/test/fixtures/fake-setting-store.js | 20 ++- 9 files changed, 246 insertions(+), 8 deletions(-) create mode 100644 src/lib/services/version-service.js create mode 100644 src/lib/services/versions-service.test.js create mode 100644 src/migrations/20210218090213-generate-server-identifier.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3033b790e4..7f129bebf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 3.x.x +- feat: check latest version +- feat: expose current and latest version to ui-config - feat: Use express-session backed by postgres ## 3.12.0 diff --git a/docs/getting-started.md b/docs/getting-started.md index a516b0a0e6..7f85d251db 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -100,3 +100,11 @@ curl --location --request PUT 'http://localhost:4242/api/admin/features/Feature. ]\ }'\ ``` + +## Version check + +- Unleash checks that it uses the latest version by making a call to https://version.unleash.run. + - This is a cloud function storing instance id to our database for statistics. +- This request includes a unique instance id for your server. +- If you do not wish to check for upgrades define the environment variable `CHECK_VERSION` to anything else other than `true` before starting, and Unleash won't make any calls + - `export CHECK_VERSION=false` diff --git a/src/lib/options.js b/src/lib/options.js index 7b7af79319..0d311367b4 100644 --- a/src/lib/options.js +++ b/src/lib/options.js @@ -69,6 +69,12 @@ function defaultOptions() { enableLegacyRoutes: false, extendedPermissions: false, publicFolder, + versionCheck: { + url: + process.env.UNLEASH_VERSION_URL || + 'https://version.unleash.run', + enable: process.env.CHECK_VERSION || 'true', + }, enableRequestLogger: false, adminAuthentication: process.env.ADMIN_AUTHENTICATION || 'unsecure', ui: {}, diff --git a/src/lib/routes/admin-api/config.js b/src/lib/routes/admin-api/config.js index 5c959d0a18..e7b37c0561 100644 --- a/src/lib/routes/admin-api/config.js +++ b/src/lib/routes/admin-api/config.js @@ -3,16 +3,24 @@ const Controller = require('../controller'); class ConfigController extends Controller { - constructor(config) { + constructor(config, { versionService }) { super(config); - this.uiConfig = { ...config.ui, version: config.version }; - + this.versionService = versionService; + this.uiConfig = { + ...config.ui, + version: config.version, + }; this.get('/', this.getUIConfig); } async getUIConfig(req, res) { const config = this.uiConfig; - res.json(config); + if (this.versionService) { + const versionInfo = this.versionService.getVersionInfo(); + res.json({ ...config, versionInfo }); + } else { + res.json(config); + } } } diff --git a/src/lib/services/index.js b/src/lib/services/index.js index 1d77698cc9..ef6ff73b56 100644 --- a/src/lib/services/index.js +++ b/src/lib/services/index.js @@ -7,6 +7,7 @@ const TagService = require('./tag-service'); const StrategyService = require('./strategy-service'); const AddonService = require('./addon-service'); const ContextService = require('./context-service'); +const VersionService = require('./version-service'); module.exports.createServices = (stores, config) => { const featureToggleService = new FeatureToggleService(stores, config); @@ -18,6 +19,7 @@ module.exports.createServices = (stores, config) => { const clientMetricsService = new ClientMetricsService(stores, config); const addonService = new AddonService(stores, config, tagTypeService); const contextService = new ContextService(stores, config); + const versionService = new VersionService(stores, config); return { addonService, @@ -29,5 +31,6 @@ module.exports.createServices = (stores, config) => { tagService, clientMetricsService, contextService, + versionService, }; }; diff --git a/src/lib/services/version-service.js b/src/lib/services/version-service.js new file mode 100644 index 0000000000..59d87f91ea --- /dev/null +++ b/src/lib/services/version-service.js @@ -0,0 +1,62 @@ +import fetch from 'node-fetch'; + +const TWO_DAYS = 48 * 60 * 60 * 1000; +class VersionService { + constructor( + { settingStore }, + { getLogger, versionCheck, version, enterpriseVersion }, + ) { + this.logger = getLogger('lib/services/version-service.js'); + this.settingStore = settingStore; + this.current = { + oss: version, + enterprise: enterpriseVersion, + }; + if (versionCheck) { + if (versionCheck.url) { + this.versionCheckUrl = versionCheck.url; + } + + if (versionCheck.enable === 'true') { + this.enabled = true; + this.checkLatestVersion(); + setInterval(this.checkLatestVersion, TWO_DAYS); + } else { + this.enabled = false; + } + } + } + + async checkLatestVersion() { + if (this.enabled) { + const { id } = await this.settingStore.get('instanceInfo'); + try { + const data = await fetch(this.versionCheckUrl, { + method: 'POST', + body: JSON.stringify({ + versions: this.current, + id, + }), + headers: { 'Content-Type': 'application/json' }, + }).then(res => res.json()); + this.latest = { + oss: data.versions.oss, + enterprise: data.versions.enterprise, + }; + this.isLatest = data.latest; + } catch (err) { + this.logger.info('Could not check newest version', err); + } + } + } + + getVersionInfo() { + return { + current: this.current, + latest: this.latest || {}, + isLatest: this.isLatest || false, + }; + } +} + +module.exports = VersionService; diff --git a/src/lib/services/versions-service.test.js b/src/lib/services/versions-service.test.js new file mode 100644 index 0000000000..115a00a12f --- /dev/null +++ b/src/lib/services/versions-service.test.js @@ -0,0 +1,118 @@ +const test = require('ava'); +const proxyquire = require('proxyquire').noCallThru(); +const fetchMock = require('fetch-mock').sandbox(); +const stores = require('../../test/fixtures/store'); +const getLogger = require('../../test/fixtures/no-logger'); +const version = require('../util/version'); + +const VersionService = proxyquire('./version-service', { + 'node-fetch': fetchMock, +}); + +test.serial('yields current versions', async t => { + const testurl = 'https://version.test'; + const { settingStore } = stores.createStores(); + await settingStore.insert({ + name: 'instanceInfo', + content: { id: '1234abc' }, + }); + const latest = { + oss: '4.0.0', + enterprise: '4.0.0', + }; + fetchMock.mock( + { url: testurl, method: 'POST' }, + { + latest: false, + versions: latest, + }, + ); + const service = new VersionService( + { settingStore }, + { getLogger, versionCheck: { url: testurl, enable: 'true' }, version }, + ); + await service.checkLatestVersion(); + fetchMock.done(); + const versionInfo = service.getVersionInfo(); + t.is(versionInfo.current.oss, version); + t.falsy(versionInfo.current.enterprise); + t.is(versionInfo.latest.oss, latest.oss); + t.is(versionInfo.latest.enterprise, latest.enterprise); +}); + +test.serial('supports setting enterprise version as well', async t => { + const testurl = `https://version.test${Math.random() * 1000}`; + const { settingStore } = stores.createStores(); + const enterpriseVersion = '3.7.0'; + await settingStore.insert({ + name: 'instanceInfo', + content: { id: '1234abc' }, + }); + const latest = { + oss: '4.0.0', + enterprise: '4.0.0', + }; + fetchMock.mock( + { url: testurl, method: 'POST' }, + { + latest: false, + versions: latest, + }, + ); + const service = new VersionService( + { settingStore }, + { + getLogger, + versionCheck: { url: testurl, enable: 'true' }, + version, + enterpriseVersion, + }, + ); + await service.checkLatestVersion(); + fetchMock.done(); + const versionInfo = service.getVersionInfo(); + t.is(versionInfo.current.oss, version); + t.is(versionInfo.current.enterprise, enterpriseVersion); + t.is(versionInfo.latest.oss, latest.oss); + t.is(versionInfo.latest.enterprise, latest.enterprise); +}); + +test.serial( + 'if version check is not enabled should not make any calls', + async t => { + const testurl = `https://version.test${Math.random() * 1000}`; + const { settingStore } = stores.createStores(); + const enterpriseVersion = '3.7.0'; + await settingStore.insert({ + name: 'instanceInfo', + content: { id: '1234abc' }, + }); + const latest = { + oss: '4.0.0', + enterprise: '4.0.0', + }; + fetchMock.mock( + { url: testurl, method: 'POST' }, + { + latest: false, + versions: latest, + }, + ); + const service = new VersionService( + { settingStore }, + { + getLogger, + versionCheck: { url: testurl, enable: false }, + version, + enterpriseVersion, + }, + ); + await service.checkLatestVersion(); + t.false(fetchMock.called(testurl)); + const versionInfo = service.getVersionInfo(); + t.is(versionInfo.current.oss, version); + t.is(versionInfo.current.enterprise, enterpriseVersion); + t.falsy(versionInfo.latest.oss, latest.oss); + t.falsy(versionInfo.latest.enterprise, latest.enterprise); + }, +); diff --git a/src/migrations/20210218090213-generate-server-identifier.js b/src/migrations/20210218090213-generate-server-identifier.js new file mode 100644 index 0000000000..30d42b4893 --- /dev/null +++ b/src/migrations/20210218090213-generate-server-identifier.js @@ -0,0 +1,19 @@ +'use strict'; + +exports.up = function(db, cb) { + db.runSql( + ` + INSERT INTO settings(name, content) VALUES ('instanceInfo', json_build_object('id', gen_random_uuid())); + `, + cb, + ); +}; + +exports.down = function(db, cb) { + db.runSql( + ` + DROP FROM settings WHERE name = 'instanceInfo' + `, + cb, + ); +}; diff --git a/src/test/fixtures/fake-setting-store.js b/src/test/fixtures/fake-setting-store.js index e84ddcabc3..07c262e091 100644 --- a/src/test/fixtures/fake-setting-store.js +++ b/src/test/fixtures/fake-setting-store.js @@ -1,6 +1,18 @@ 'use strict'; -module.exports = () => ({ - insert: () => Promise.resolve(), - get: () => Promise.resolve(), -}); +module.exports = () => { + const _settings = []; + return { + insert: setting => { + _settings.push(setting); + return Promise.resolve(); + }, + get: name => { + const setting = _settings.find(s => s.name === name); + if (setting) { + return Promise.resolve(setting.content); + } + return Promise.reject(new Error('Could not find setting')); + }, + }; +};