diff --git a/forge/caches/index.js b/forge/caches/index.js new file mode 100644 index 0000000000..61af42e7a5 --- /dev/null +++ b/forge/caches/index.js @@ -0,0 +1,35 @@ +/** + * The a pluggable object cache + * + * @namespace cache + * @memberof forge + */ + +/** + * @typedef {Object} forge.Status + * @property {string} status + */ +const fp = require('fastify-plugin') + +const CACHE_DRIVERS = { + memory: './memory-cache.js', + redis: './redis-cache.js' +} + +module.exports = fp(async function (app, _opts) { + const cacheType = app.config.cache?.driver || 'memory' + const cacheModule = CACHE_DRIVERS[cacheType] + try { + app.log.info(`Cache driver: ${cacheType}`) + const driver = require(cacheModule) + await driver.initCache(app.config.cache?.options || {}) + app.decorate('caches', driver) + app.addHook('onClose', async (_) => { + app.log.info('Driver shutdown') + await driver.closeCache() + }) + } catch (err) { + app.log.error(`Failed to load the cache driver: ${cacheType}`) + throw err + } +}, { name: 'app.caches' }) diff --git a/forge/caches/memory-cache.js b/forge/caches/memory-cache.js new file mode 100644 index 0000000000..213ca2d77c --- /dev/null +++ b/forge/caches/memory-cache.js @@ -0,0 +1,45 @@ +const caches = {} + +async function initCache () {} + +async function closeCache () {} + +function getCache (name, options) { + if (!caches[name]) { + caches[name] = new Cache(name, options) + } + return caches[name] +} + +class Cache { + constructor (name, options) { + this.holder = {} + } + + async get (key) { + return this.holder[key] + } + + async set (key, value) { + this.holder[key] = value + return value + } + + async del (key) { + delete this.holder[key] + } + + async keys () { + return Object.keys(this.holder) + } + + async all () { + return this.holder + } +} + +module.exports = { + initCache, + getCache, + closeCache +} diff --git a/forge/caches/redis-cache.js b/forge/caches/redis-cache.js new file mode 100644 index 0000000000..edcba3f70b --- /dev/null +++ b/forge/caches/redis-cache.js @@ -0,0 +1,66 @@ +const { createClient } = require('@redis/client') + +const caches = {} + +let client + +async function initCache (options) { + client = createClient(options) + await client.connect() +} + +function getCache (name, options) { + if (!caches[name]) { + caches[name] = new Cache(name, { client, ...options }) + } + return caches[name] +} + +async function closeCache () { + client.close() +} + +class Cache { + constructor (name, options) { + this.name = name + this.client = options.client + } + + async get (key) { + const val = JSON.parse(await this.client.hGet(this.name, key)) + if (val !== null) { + return val + } else { + return undefined + } + } + + async set (key, value) { + await this.client.hSet(this.name, key, JSON.stringify(value)) + return value + } + + async del (key) { + await this.client.hDel(this.name, key) + } + + async keys () { + const keys = await this.client.hKeys(this.name) + return keys + } + + async all () { + const values = await this.client.hGetAll(this.name) + const newObj = {} + for (const k of Object.keys(values)) { + newObj[k] = JSON.parse(values[k]) + } + return newObj + } +} + +module.exports = { + initCache, + getCache, + closeCache +} diff --git a/forge/comms/devices.js b/forge/comms/devices.js index eece35555c..ab85b6d9cc 100644 --- a/forge/comms/devices.js +++ b/forge/comms/devices.js @@ -235,7 +235,7 @@ class DeviceCommsHandler { if (inFlightCommand) { // This command is known to the local instance - process it inFlightCommand.resolve(message.payload) - delete this.inFlightCommands[response.correlationData] + delete this.inFlightCommands[message.correlationData] } } } @@ -334,7 +334,7 @@ class DeviceCommsHandler { resolve(payload) delete this.inFlightCommands[inFlightCommand.correlationData] } - inFlightCommand.reject = (err) => { + inFlightCommand.reject = async (err) => { inFlightCommand.rejected = true clearTimeout(inFlightCommand.timer) reject(err) diff --git a/forge/db/controllers/Project.js b/forge/db/controllers/Project.js index 9c3d9edaca..dfab468367 100644 --- a/forge/db/controllers/Project.js +++ b/forge/db/controllers/Project.js @@ -8,11 +8,11 @@ const { KEY_SETTINGS } = require('../models/ProjectSettings') * is no need to store that in the database. But we do need to know it so the * information can be returned on the API. */ -const inflightProjectState = { } +const inflightProjectState = 'project-inflightProjectState' -const latestProjectState = { } +const latestProjectState = 'project-latestProjectState' -const inflightDeploys = new Set() +const inflightDeploys = 'project-inflightDeploys' module.exports = { /** @@ -21,8 +21,8 @@ module.exports = { * @param {*} project * @returns the in-flight state */ - getInflightState: function (app, project) { - return inflightProjectState[project.id] + getInflightState: async function (app, project) { + return await app.caches.getCache(inflightProjectState).get(project.id) }, /** @@ -31,8 +31,8 @@ module.exports = { * @param {*} project * @param {*} state */ - setInflightState: function (app, project, state) { - inflightProjectState[project.id] = state + setInflightState: async function (app, project, state) { + await app.caches.getCache(inflightProjectState).set(project.id, state) }, /** @@ -40,8 +40,9 @@ module.exports = { * @param {*} app * @param {*} instance */ - isDeploying: function (app, instance) { - return inflightDeploys.has(instance.id) + isDeploying: async function (app, instance) { + const has = await app.caches.getCache(inflightDeploys).get(instance.id) + return has === true }, /** @@ -49,8 +50,8 @@ module.exports = { * @param {*} app * @param {*} instance */ - setInDeploy: function (app, instance) { - inflightDeploys.add(instance.id) + setInDeploy: async function (app, instance) { + await app.caches.getCache(inflightDeploys).set(instance.id, true) }, /** @@ -58,9 +59,9 @@ module.exports = { * @param {*} app * @param {*} project */ - clearInflightState: function (app, project) { - delete inflightProjectState[project.id] - inflightDeploys.delete(project.id) + clearInflightState: async function (app, project) { + await app.caches.getCache(inflightProjectState).del(project.id) + await app.caches.getCache(inflightDeploys).del(project.id) }, /** @@ -570,7 +571,7 @@ module.exports = { throw error } if (project.state === 'running') { - app.db.controllers.Project.setInflightState(project, 'restarting') + await app.db.controllers.Project.setInflightState(project, 'restarting') project.state = 'running' await project.save() const result = await app.containers.restartFlows(project) @@ -699,8 +700,9 @@ module.exports = { * @param {string|Number} projectId - The unique identifier of the project whose latest state is to be retrieved. * @returns {string} The latest state of the specified project. */ - getLatestProjectState: function (app, projectId) { - return latestProjectState[projectId] + getLatestProjectState: async function (app, projectId) { + return await app.caches.getCache(latestProjectState).get(projectId) + // return latestProjectState[projectId] }, /** @@ -710,8 +712,8 @@ module.exports = { * @param {string|number} projectId - The unique identifier of the project whose state is being updated. * @param {any} state - The new state to be assigned to the project. */ - setLatestProjectState: function (app, projectId, state) { - latestProjectState[projectId] = state + setLatestProjectState: async function (app, projectId, state) { + app.caches.getCache(latestProjectState).set(projectId, state) }, /** @@ -723,8 +725,8 @@ module.exports = { * * @param {String|Number} projectId - The project id whose latest state should be cleared. */ - clearLatestProjectState: function (app, projectId) { - delete latestProjectState[projectId] + clearLatestProjectState: async function (app, projectId) { + return await app.caches.getCache(latestProjectState).del(projectId) }, /** @@ -733,11 +735,11 @@ module.exports = { * @param {String|Number} projectId - The project id related to the driver state update. * @param {string} state - The new state to update, can be 'running', 'stopped', or other valid states. */ - updateLatestProjectState: function (app, projectId, state) { + updateLatestProjectState: async function (app, projectId, state) { if (['running'].includes(state)) { - this.clearLatestProjectState(app, projectId) + await this.clearLatestProjectState(app, projectId) } else { - this.setLatestProjectState(app, projectId, state) + await this.setLatestProjectState(app, projectId, state) } } } diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 897c65ce7e..690ab10965 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -328,8 +328,8 @@ module.exports = { async liveState ({ omitStorageFlows = false } = { }) { const result = {} - const inflightState = Controllers.Project.getInflightState(this) - const isDeploying = Controllers.Project.isDeploying(this) + const inflightState = await Controllers.Project.getInflightState(this) + const isDeploying = await Controllers.Project.isDeploying(this) if (!omitStorageFlows) { let storageFlow = this.StorageFlow @@ -353,7 +353,7 @@ module.exports = { result.meta = await app.containers.details(this) || { state: 'unknown' } if (result.meta.state !== this.state) { - Controllers.Project.setLatestProjectState(this.id, result.meta.state) + await Controllers.Project.setLatestProjectState(this.id, result.meta.state) } if (result.meta.versions) { @@ -713,14 +713,14 @@ module.exports = { const teamRbacEnabled = team.TeamType.getFeatureProperty('rbacApplication', false) const rbacEnabled = platformRbacEnabled && teamRbacEnabled - results.forEach((project) => { + for (const project of results) { if (rbacEnabled && !app.hasPermission(membership, 'project:read', { applicationId: project.Application.hashid })) { // This instance is not accessible to this user, do not include in states map - return + continue } - const state = Controllers.Project.getLatestProjectState(project.id) ?? project.state + const state = await Controllers.Project.getLatestProjectState(project.id) ?? project.state statesMap[state] = (statesMap[state] || 0) + 1 - }) + } return Object.entries(statesMap).map(([state, count]) => ({ state, count })) }, diff --git a/forge/ee/db/controllers/Pipeline.js b/forge/ee/db/controllers/Pipeline.js index 727f5c0ec1..45f12f1018 100644 --- a/forge/ee/db/controllers/Pipeline.js +++ b/forge/ee/db/controllers/Pipeline.js @@ -536,11 +536,10 @@ module.exports = { const restartTargetInstance = targetInstance?.state === 'running' - app.db.controllers.Project.setInflightState(targetInstance, 'importing') - app.db.controllers.Project.setInDeploy(targetInstance) - // Complete heavy work async return (async function () { + await app.db.controllers.Project.setInflightState(targetInstance, 'importing') + await app.db.controllers.Project.setInDeploy(targetInstance) try { const setAsTargetForDevices = deployToDevices ?? false const targetSnapshot = await copySnapshot(app, sourceSnapshot, targetInstance, { @@ -559,9 +558,9 @@ module.exports = { await app.auditLog.Project.project.imported(user.id, null, targetInstance, sourceInstance, sourceDevice) // technically this isn't a project event await app.auditLog.Project.project.snapshot.imported(user.id, null, targetInstance, sourceInstance, sourceDevice, targetSnapshot) - app.db.controllers.Project.clearInflightState(targetInstance) + await app.db.controllers.Project.clearInflightState(targetInstance) } catch (err) { - app.db.controllers.Project.clearInflightState(targetInstance) + await app.db.controllers.Project.clearInflightState(targetInstance) await app.auditLog.Project.project.imported(user.id, null, targetInstance, sourceInstance, sourceDevice) // technically this isn't a project event await app.auditLog.Project.project.snapshot.imported(user.id, err, targetInstance, sourceInstance, sourceDevice, null) diff --git a/forge/ee/lib/billing/trialTask.js b/forge/ee/lib/billing/trialTask.js index 6c0e1791ab..8601a5e03f 100644 --- a/forge/ee/lib/billing/trialTask.js +++ b/forge/ee/lib/billing/trialTask.js @@ -123,12 +123,12 @@ module.exports.init = function (app) { // There is some DRY code here with projectActions.js suspend logic. // TODO: consider move to controllers.Project try { - app.db.controllers.Project.setInflightState(project, 'suspending') + await app.db.controllers.Project.setInflightState(project, 'suspending') await app.containers.stop(project) - app.db.controllers.Project.clearInflightState(project) + await app.db.controllers.Project.clearInflightState(project) await app.auditLog.Project.project.suspended(null, null, project) } catch (err) { - app.db.controllers.Project.clearInflightState(project) + await app.db.controllers.Project.clearInflightState(project) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.Project.project.suspended(null, resp, project) } diff --git a/forge/ee/routes/customHostnames/index.js b/forge/ee/routes/customHostnames/index.js index b1183c9c80..67812f1699 100644 --- a/forge/ee/routes/customHostnames/index.js +++ b/forge/ee/routes/customHostnames/index.js @@ -77,7 +77,7 @@ module.exports = async function (app) { if (request.body.hostname) { try { await request.project.setCustomHostname(request.body.hostname) - app.db.controllers.Project.setInflightState(request.project, 'starting') + await app.db.controllers.Project.setInflightState(request.project, 'starting') restartInstance(request.project, request.session.User) reply.send(await request.project.getCustomHostname() || {}) } catch (err) { @@ -92,7 +92,7 @@ module.exports = async function (app) { preHandler: app.needsPermission('project:edit') }, async (request, reply) => { await request.project.clearCustomHostname() - app.db.controllers.Project.setInflightState(request.project, 'starting') + await app.db.controllers.Project.setInflightState(request.project, 'starting') await restartInstance(request.project, request.session.User) reply.status(204).send({}) }) @@ -108,7 +108,7 @@ module.exports = async function (app) { const startResult = await app.containers.start(project) startResult.started.then(async () => { await app.auditLog.Project.project.started(user, null, project) - app.db.controllers.Project.clearInflightState(project) + await app.db.controllers.Project.clearInflightState(project) return true }).catch(err => { app.log.info(`Failed to restart project ${project.id}`) diff --git a/forge/ee/routes/ha/index.js b/forge/ee/routes/ha/index.js index 64785f2bda..f1376cf7d8 100644 --- a/forge/ee/routes/ha/index.js +++ b/forge/ee/routes/ha/index.js @@ -83,7 +83,7 @@ module.exports = async function (app) { // This code is copy/paste with slight changes from projects.js // We also have projectActions.js that does suspend logic. // TODO: refactor into a Model function to suspend a project - app.db.controllers.Project.setInflightState(project, 'starting') // TODO: better inflight state needed + await app.db.controllers.Project.setInflightState(project, 'starting') // TODO: better inflight state needed reply.send(await project.getHASettings() || {}) const targetState = project.state @@ -99,10 +99,10 @@ module.exports = async function (app) { const startResult = await app.containers.start(project) startResult.started.then(async () => { await app.auditLog.Project.project.started(user, null, project) - app.db.controllers.Project.clearInflightState(project) + await app.db.controllers.Project.clearInflightState(project) return true - }).catch(_ => { - app.db.controllers.Project.clearInflightState(project) + }).catch(async _ => { + await app.db.controllers.Project.clearInflightState(project) }) } else { // A suspended project doesn't need to do anything more diff --git a/forge/forge.js b/forge/forge.js index daa95759db..55b2c95209 100644 --- a/forge/forge.js +++ b/forge/forge.js @@ -5,6 +5,7 @@ const { ProfilingIntegration } = require('@sentry/profiling-node') const fastify = require('fastify') const auditLog = require('./auditLog') +const caches = require('./caches') const comms = require('./comms') const config = require('./config') // eslint-disable-line n/no-unpublished-require const containers = require('./containers') @@ -238,6 +239,8 @@ module.exports = async (options = {}) => { await server.register(require('@fastify/rate-limit'), server.config.rate_limits) } + // Setup Caches + await server.register(caches) // DB : the database connection/models/views/controllers await server.register(db) // Settings diff --git a/forge/housekeeper/tasks/licenseCheck.js b/forge/housekeeper/tasks/licenseCheck.js index 255131b7fd..71ca5199fe 100644 --- a/forge/housekeeper/tasks/licenseCheck.js +++ b/forge/housekeeper/tasks/licenseCheck.js @@ -49,9 +49,9 @@ module.exports = { const promises = projectList.map((project) => { return (async () => { try { - app.db.controllers.Project.setInflightState(project, 'suspending') + await app.db.controllers.Project.setInflightState(project, 'suspending') await app.containers.stop(project) - app.db.controllers.Project.clearInflightState(project) + await app.db.controllers.Project.clearInflightState(project) await app.auditLog.Project.project.suspended(null, null, project) } catch (err) { app.log.info(`Failed to suspend ${project.id} when licensed expired. ${err.toString()}`) diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index 2b5feec958..2c7e4b3449 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -357,8 +357,8 @@ module.exports = async function (app) { reply.code(403).send('Source Project and Target not in same team') } - app.db.controllers.Project.setInflightState(request.project, 'importing') - app.db.controllers.Project.setInDeploy(request.project) + await app.db.controllers.Project.setInflightState(request.project, 'importing') + await app.db.controllers.Project.setInDeploy(request.project) await app.auditLog.Project.project.copied(request.session.User.id, null, sourceProject, request.project) await app.auditLog.Project.project.imported(request.session.User.id, null, request.project, sourceProject) @@ -512,7 +512,7 @@ module.exports = async function (app) { let resumeProject, targetState if (changesToProjectDefinition) { // Early return and complete the rest async - app.db.controllers.Project.setInflightState(request.project, 'starting') // TODO: better inflight state needed + await app.db.controllers.Project.setInflightState(request.project, 'starting') // TODO: better inflight state needed reply.code(200).send({}) repliedEarly = true @@ -651,14 +651,14 @@ module.exports = async function (app) { const startResult = await app.containers.start(request.project) startResult.started.then(async () => { await app.auditLog.Project.project.started(request.session.User, null, request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) return true }).catch(err => { app.log.info(`Failed to restart project ${request.project.id}`) throw err }) } else { - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) } } @@ -1426,7 +1426,7 @@ module.exports = async function (app) { } } }, async (request, reply) => { - app.db.controllers.Project.updateLatestProjectState(request.params.instanceId, request.body.state) + await app.db.controllers.Project.updateLatestProjectState(request.params.instanceId, request.body.state) reply.code(202).send() }) diff --git a/forge/routes/api/projectActions.js b/forge/routes/api/projectActions.js index 4c431ad430..ebcf8d053d 100644 --- a/forge/routes/api/projectActions.js +++ b/forge/routes/api/projectActions.js @@ -47,27 +47,27 @@ module.exports = async function (app) { // Restart the container request.project.state = 'running' await request.project.save() - app.db.controllers.Project.setInflightState(request.project, 'starting') + await app.db.controllers.Project.setInflightState(request.project, 'starting') const startResult = await app.containers.start(request.project) startResult.started.then(async () => { await app.auditLog.Project.project.started(request.session.User, null, request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) return true }).catch(err => { app.log.info(`failed to start project ${request.project.id}`) throw err }) } else { - app.db.controllers.Project.setInflightState(request.project, 'starting') + await app.db.controllers.Project.setInflightState(request.project, 'starting') request.project.state = 'running' await request.project.save() await app.containers.startFlows(request.project) await app.auditLog.Project.project.started(request.session.User, null, request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) } reply.send({ status: 'okay' }) } catch (err) { - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.Project.project.started(request.session.User, resp, request.project) @@ -104,15 +104,15 @@ module.exports = async function (app) { reply.code(400).send({ code: 'project_suspended', error: 'Project suspended' }) return } - app.db.controllers.Project.setInflightState(request.project, 'stopping') + await app.db.controllers.Project.setInflightState(request.project, 'stopping') request.project.state = 'stopped' await request.project.save() await app.containers.stopFlows(request.project) await app.auditLog.Project.project.stopped(request.session.User, null, request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) reply.send({ status: 'okay' }) } catch (err) { - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.Project.project.stopped(request.session.User, resp, request.project) @@ -149,15 +149,15 @@ module.exports = async function (app) { reply.code(400).send({ code: 'project_suspended', error: 'Project suspended' }) return } - app.db.controllers.Project.setInflightState(request.project, 'restarting') + await app.db.controllers.Project.setInflightState(request.project, 'restarting') request.project.state = 'running' await request.project.save() await app.containers.restartFlows(request.project) await app.auditLog.Project.project.restarted(request.session.User, null, request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) reply.send({ status: 'okay' }) } catch (err) { - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.Project.project.restarted(request.session.User, resp, request.project) @@ -194,13 +194,13 @@ module.exports = async function (app) { reply.code(400).send({ code: 'project_suspended', error: 'Project suspended' }) return } - app.db.controllers.Project.setInflightState(request.project, 'suspending') + await app.db.controllers.Project.setInflightState(request.project, 'suspending') await app.containers.stop(request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) await app.auditLog.Project.project.suspended(request.session.User, null, request.project) reply.send({ status: 'okay' }) } catch (err) { - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.Project.project.suspended(request.session.User, resp, request.project) @@ -253,16 +253,16 @@ module.exports = async function (app) { if (request.project.state === 'running') { restartProject = true } - app.db.controllers.Project.setInflightState(request.project, 'rollback') + await app.db.controllers.Project.setInflightState(request.project, 'rollback') await app.db.controllers.Project.importProjectSnapshot(request.project, snapshot) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) await app.auditLog.Project.project.snapshot.rolledBack(request.session.User, null, request.project, snapshot) if (restartProject) { await app.containers.restartFlows(request.project) } reply.send({ status: 'okay' }) } catch (err) { - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.Project.project.snapshot.rolledBack(request.session.User, resp, request.project) reply.code(500).send(resp) @@ -296,19 +296,19 @@ module.exports = async function (app) { try { await app.auditLog.Project.project.stack.restart(request.session.User, null, request.project) if (request.project.state !== 'suspended') { - app.db.controllers.Project.setInflightState(request.project, 'suspending') + await app.db.controllers.Project.setInflightState(request.project, 'suspending') await app.containers.stop(request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) await app.auditLog.Project.project.suspended(request.session.User, null, request.project) } request.project.state = 'running' await request.project.save() - app.db.controllers.Project.setInflightState(request.project, 'starting') + await app.db.controllers.Project.setInflightState(request.project, 'starting') const startResult = await app.containers.start(request.project) startResult.started.then(async () => { await app.auditLog.Project.project.started(request.session.User, null, request.project) - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) return true }).catch(err => { app.log.info(`failed to restartStack for ${request.project.id}`) @@ -317,7 +317,7 @@ module.exports = async function (app) { reply.send() } catch (err) { - app.db.controllers.Project.clearInflightState(request.project) + await app.db.controllers.Project.clearInflightState(request.project) const resp = { code: 'unexpected_error', error: err.toString() } await app.auditLog.Project.project.stack.restart(request.session.User, resp, request.project) diff --git a/package-lock.json b/package-lock.json index 7996b177a5..17f5ac0da7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^4.0.2", "@node-saml/passport-saml": "^5.0.0", + "@redis/client": "^5.8.3", "@sentry/node": "^7.73.0", "@sentry/profiling-node": "^1.2.1", "@sentry/vue": "^7.91.0", @@ -5925,6 +5926,18 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@redis/client": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.3.tgz", + "integrity": "sha512-MZVUE+l7LmMIYlIjubPosruJ9ltSLGFmJqsXApTqPLyHLjsJUSAbAJb/A3N34fEqean4ddiDkdWzNu4ZKPvRUg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.40.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", @@ -10416,6 +10429,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -29755,6 +29777,14 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "@redis/client": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.3.tgz", + "integrity": "sha512-MZVUE+l7LmMIYlIjubPosruJ9ltSLGFmJqsXApTqPLyHLjsJUSAbAJb/A3N34fEqean4ddiDkdWzNu4ZKPvRUg==", + "requires": { + "cluster-key-slot": "1.1.2" + } + }, "@rollup/rollup-android-arm-eabi": { "version": "4.40.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", @@ -33006,6 +33036,11 @@ "mimic-response": "^1.0.0" } }, + "cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, "color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", diff --git a/package.json b/package.json index 46f854a5b7..4d5bcb3e8e 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^4.0.2", "@node-saml/passport-saml": "^5.0.0", + "@redis/client": "^5.8.3", "@sentry/node": "^7.73.0", "@sentry/profiling-node": "^1.2.1", "@sentry/vue": "^7.91.0", diff --git a/test/unit/forge/caches/memory-cache_spec.js b/test/unit/forge/caches/memory-cache_spec.js new file mode 100644 index 0000000000..dabc1505d1 --- /dev/null +++ b/test/unit/forge/caches/memory-cache_spec.js @@ -0,0 +1,84 @@ +const should = require('should') // eslint-disable-line +const setup = require('../routes/setup') + +describe('Memory Cache', function () { + let app + before(async function () { + app = await setup() + }) + + after(async function () { + await app.close() + }) + + it('create cache', async function () { + const cache = app.caches.getCache('cache1') + cache.should.have.property('set').which.is.a.Function() + cache.should.have.property('get').which.is.a.Function() + cache.should.have.property('del').which.is.a.Function() + cache.should.have.property('keys').which.is.a.Function() + cache.should.have.property('all').which.is.a.Function() + }) + it('set & get strings', async function () { + const cache = app.caches.getCache('cache1') + await cache.set('one', 'one') + const one = await cache.get('one') + one.should.equal('one') + }) + it('set & get number', async function () { + const cache = app.caches.getCache('cache1') + await cache.set('one', 1) + const one = await cache.get('one') + one.should.equal(1) + }) + it('set & get Object', async function () { + const cache = app.caches.getCache('cache1') + await cache.set('one', { one: 'one' }) + const one = await cache.get('one') + should(one).is.Object() + one.should.deepEqual({ one: 'one' }) + }) + it('set & get Array', async function () { + const cache = app.caches.getCache('cache1') + await cache.set('one', ['one', 2, { three: 3 }, ['one', 2, { three: 3 }]]) + const array = await cache.get('one') + should(array).containDeep(['one', 2, { three: 3 }, ['one', 2, { three: 3 }]]) + }) + it('keys', async function () { + const cache = app.caches.getCache('cache1') + await cache.set('one', 'one') + await cache.set('two', 'two') + await cache.set('three', 'three') + await cache.set('four', 'four') + + const keys = await cache.keys() + keys.should.containDeep(['one', 'two', 'three', 'four']) + }) + it('all', async function () { + const cache = app.caches.getCache('cache1') + await cache.set('one', 'one') + await cache.set('two', 'two') + await cache.set('three', 'three') + await cache.set('four', 'four') + + const all = await cache.all() + all.should.deepEqual({ one: 'one', two: 'two', three: 'three', four: 'four' }) + }) + it('del', async function () { + const cache = app.caches.getCache('cache1') + await cache.set('one', 'one') + await cache.set('two', 'two') + await cache.set('three', 'three') + await cache.set('four', 'four') + + let all = await cache.all() + all.should.deepEqual({ one: 'one', two: 'two', three: 'three', four: 'four' }) + + await cache.del('three') + all = await cache.all() + all.should.deepEqual({ one: 'one', two: 'two', four: 'four' }) + }) + it('close caches', async function () { + await app.caches.closeCache() + }) +}) diff --git a/test/unit/forge/db/controllers/Project_spec.js b/test/unit/forge/db/controllers/Project_spec.js index 579dae76eb..64a35dde55 100644 --- a/test/unit/forge/db/controllers/Project_spec.js +++ b/test/unit/forge/db/controllers/Project_spec.js @@ -485,42 +485,42 @@ describe('Project controller', function () { }) describe('latestProjectState', function () { - it('should return undefined when no project state exists', () => { - const result = app.db.controllers.Project.getLatestProjectState('non-existing') + it('should return undefined when no project state exists', async () => { + const result = await app.db.controllers.Project.getLatestProjectState('non-existing') should(result).be.undefined() }) - it('should return the project state if one exists', () => { + it('should return the project state if one exists', async () => { app.db.controllers.Project.setLatestProjectState('project-id', 'status') - const result = app.db.controllers.Project.getLatestProjectState('project-id') + const result = await app.db.controllers.Project.getLatestProjectState('project-id') should(result).equal('status') }) - it('should clear the project state if one exists', () => { - app.db.controllers.Project.setLatestProjectState('project-id', 'status') - const tempResult = app.db.controllers.Project.getLatestProjectState('project-id') + it('should clear the project state if one exists', async () => { + await app.db.controllers.Project.setLatestProjectState('project-id', 'status') + const tempResult = await app.db.controllers.Project.getLatestProjectState('project-id') should(tempResult).equal('status') - const result = app.db.controllers.Project.clearLatestProjectState('project-id') + const result = await app.db.controllers.Project.clearLatestProjectState('project-id') should(result).be.undefined() }) - it('should update non-definitive project states while removing definitive ones', () => { + it('should update non-definitive project states while removing definitive ones', async () => { app.db.controllers.Project.setLatestProjectState('project-id', 'status') - let tempResult = app.db.controllers.Project.getLatestProjectState('project-id') + let tempResult = await app.db.controllers.Project.getLatestProjectState('project-id') should(tempResult).equal('status') - app.db.controllers.Project.updateLatestProjectState('project-id', 'running') - tempResult = app.db.controllers.Project.getLatestProjectState('project-id') + await app.db.controllers.Project.updateLatestProjectState('project-id', 'running') + tempResult = await app.db.controllers.Project.getLatestProjectState('project-id') should(tempResult).be.undefined() - app.db.controllers.Project.updateLatestProjectState('project-id', 'status') - tempResult = app.db.controllers.Project.getLatestProjectState('project-id') + await app.db.controllers.Project.updateLatestProjectState('project-id', 'status') + tempResult = await app.db.controllers.Project.getLatestProjectState('project-id') should(tempResult).equal('status') - app.db.controllers.Project.updateLatestProjectState('project-id', 'stopped') - tempResult = app.db.controllers.Project.getLatestProjectState('project-id') + await app.db.controllers.Project.updateLatestProjectState('project-id', 'stopped') + tempResult = await app.db.controllers.Project.getLatestProjectState('project-id') should(tempResult).equal('stopped') }) }) diff --git a/test/unit/forge/ee/routes/billing/index_spec.js b/test/unit/forge/ee/routes/billing/index_spec.js index 93917c1ec4..c95566ca40 100644 --- a/test/unit/forge/ee/routes/billing/index_spec.js +++ b/test/unit/forge/ee/routes/billing/index_spec.js @@ -926,12 +926,12 @@ describe('Billing routes', function () { // Wait for the stub driver to start the project to avoid // an async call to the audit log completing after the test // has finished - app.db.controllers.Project.getInflightState(project).should.equal('starting') + should(await app.db.controllers.Project.getInflightState(project)).equal('starting') const { START_DELAY } = FF_UTIL.require('forge/containers/stub') return new Promise((resolve, reject) => { - setTimeout(() => { + setTimeout(async () => { try { - should.not.exist(app.db.controllers.Project.getInflightState(project)) + should.not.exist(await app.db.controllers.Project.getInflightState(project)) resolve() } catch (err) { reject(err) @@ -1211,12 +1211,12 @@ describe('Billing routes', function () { // Wait for the stub driver to start the project to avoid // an async call to the audit log completing after the test // has finished - app.db.controllers.Project.getInflightState(createdProject).should.equal('starting') + should(await app.db.controllers.Project.getInflightState(createdProject)).equal('starting') const { START_DELAY } = FF_UTIL.require('forge/containers/stub') return new Promise((resolve, reject) => { - setTimeout(() => { + setTimeout(async () => { try { - should.not.exist(app.db.controllers.Project.getInflightState(createdProject)) + should.not.exist(await app.db.controllers.Project.getInflightState(createdProject)) resolve() } catch (err) { reject(err) @@ -1444,12 +1444,12 @@ describe('Billing routes', function () { // Wait for the stub driver to start the project to avoid // an async call to the audit log completing after the test // has finished - app.db.controllers.Project.getInflightState(createdProject).should.equal('starting') + should(await app.db.controllers.Project.getInflightState(createdProject)).equal('starting') const { START_DELAY } = FF_UTIL.require('forge/containers/stub') return new Promise((resolve, reject) => { - setTimeout(() => { + setTimeout(async () => { try { - should.not.exist(app.db.controllers.Project.getInflightState(createdProject)) + should.not.exist(await app.db.controllers.Project.getInflightState(createdProject)) resolve() } catch (err) { reject(err) diff --git a/test/unit/forge/ee/routes/ha/index_spec.js b/test/unit/forge/ee/routes/ha/index_spec.js index d0f0fcefc5..209928fd6a 100644 --- a/test/unit/forge/ee/routes/ha/index_spec.js +++ b/test/unit/forge/ee/routes/ha/index_spec.js @@ -98,7 +98,7 @@ describe('HA Instance API', function () { // Project has been stopped but is presented as "starting" TestObjects.project.state.should.equal('suspended') - app.db.controllers.Project.getInflightState(TestObjects.project).should.equal('starting') + should(await app.db.controllers.Project.getInflightState(TestObjects.project)).equal('starting') // Wait for at least start delay as set in stub driver await sleep(START_DELAY + 100) @@ -112,7 +112,7 @@ describe('HA Instance API', function () { // Project is re-running TestObjects.project.state.should.equal('running') - should(app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) + should(await app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) const haSetting = await TestObjects.project.getHASettings() haSetting.should.have.property('replicas', 2) @@ -134,7 +134,7 @@ describe('HA Instance API', function () { // Project has been stopped but is presented as "starting" TestObjects.project.state.should.equal('suspended') - app.db.controllers.Project.getInflightState(TestObjects.project).should.equal('starting') + should(await app.db.controllers.Project.getInflightState(TestObjects.project)).equal('starting') // Wait for at least start delay as set in stub driver await sleep(START_DELAY + 100) @@ -148,7 +148,7 @@ describe('HA Instance API', function () { // Project is re-running TestObjects.project.state.should.equal('running') - should(app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) + should(await app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) const haSetting = await TestObjects.project.getHASettings() should.not.exist(haSetting) @@ -169,7 +169,7 @@ describe('HA Instance API', function () { await TestObjects.project.reload() TestObjects.project.state.should.equal('running') - should(app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) + should(await app.db.controllers.Project.getInflightState(TestObjects.project)).equal(undefined) }) describe('ha feature flag', async function () { diff --git a/test/unit/forge/routes/api/project_spec.js b/test/unit/forge/routes/api/project_spec.js index d51f820506..af8b78520f 100644 --- a/test/unit/forge/routes/api/project_spec.js +++ b/test/unit/forge/routes/api/project_spec.js @@ -1216,7 +1216,7 @@ describe('Project API', function () { // Project has been stopped but is presented as "starting" project.state.should.equal('suspended') - app.db.controllers.Project.getInflightState(project).should.equal('starting') + should(await app.db.controllers.Project.getInflightState(project)).equal('starting') // Wait for at least start delay as set in stub driver await sleep(START_DELAY + 100) @@ -1230,7 +1230,7 @@ describe('Project API', function () { // Project is re-running project.state.should.equal('running') - should(app.db.controllers.Project.getInflightState(project)).equal(undefined) + should(await app.db.controllers.Project.getInflightState(project)).equal(undefined) // Type and stack updated project.ProjectType.id.should.equal(projectType.id) @@ -1283,7 +1283,7 @@ describe('Project API', function () { // Project has been stopped but is presented as "starting" project.state.should.equal('suspended') - app.db.controllers.Project.getInflightState(project).should.equal('starting') + should(await app.db.controllers.Project.getInflightState(project)).equal('starting') // Wait for at least start delay as set in stub driver await sleep(START_DELAY + 100) @@ -1297,7 +1297,7 @@ describe('Project API', function () { // Project is re-running project.state.should.equal('running') - should(app.db.controllers.Project.getInflightState(project)).equal(undefined) + should(await app.db.controllers.Project.getInflightState(project)).equal(undefined) // Stack has been updated project.ProjectType.id.should.equal(projectType.id)