diff --git a/forge/db/migrations/20260729-01-add-node-red-node-version.js b/forge/db/migrations/20260729-01-add-node-red-node-version.js new file mode 100644 index 0000000000..41f3aca376 --- /dev/null +++ b/forge/db/migrations/20260729-01-add-node-red-node-version.js @@ -0,0 +1,46 @@ +const { DataTypes } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context Sequelize.QueryInterface + */ + up: async (context, Sequelize) => { + await context.createTable('NodeREDNodeVersions', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + ownerId: { + type: DataTypes.STRING, + allowNull: false + }, + ownerType: { + type: DataTypes.STRING, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + currentVersion: { + type: DataTypes.STRING, + allowNull: false + }, + latestVersion: { + type: DataTypes.STRING, + allowNull: true + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }) + }, + down: async (context, Sequelize) => {} +} diff --git a/forge/db/models/NodeREDNodeVersions.js b/forge/db/models/NodeREDNodeVersions.js new file mode 100644 index 0000000000..51601faa69 --- /dev/null +++ b/forge/db/models/NodeREDNodeVersions.js @@ -0,0 +1,66 @@ +/** + * Which nodes are installed in which Instances + * @namespace forge.db.models.NodeREDNodeVersion + * // type helpers for design time help and error checking + * @typedef {import('sequelize').Model} Model + * @typedef {import('sequelize').ModelAttributes} ModelAttributes + * @typedef {import('sequelize').SchemaOptions} SchemaOptions + * @typedef {import('sequelize').ModelIndexesOptions} ModelIndexesOptions + * @typedef {import('sequelize').InitOptions} InitOptions + * @typedef {import('sequelize').ModelScopeOptions} ModelScopeOptions + * @typedef {{name: string, schema: ModelAttributes, model: Model, indexes?: ModelIndexesOptions[], scopes?: ModelScopeOptions, options?: InitOptions}} FFModel + */ + +const { DataTypes } = require('sequelize') + +/** @type {FFModel} */ +module.exports = { + name: 'NodeREDNodeVersions', + schema: { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + ownerId: { + type: DataTypes.STRING, + allowNull: false + }, + ownerType: { + type: DataTypes.STRING, + allowNull: false + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + currentVersion: { + type: DataTypes.STRING, + allowNull: false + }, + latestVersion: { + type: DataTypes.STRING, + allowNull: true + } + }, + associations: function (M) { + this.belongsTo(M.Project, { foreignKey: 'ownerId', constraints: false }) + this.belongsTo(M.Device, { foreignKey: 'ownerId', constraints: false }) + }, + finders: function (M) { + return { + static: { + updateAllLatest: async (name, version) => { + await this.update({ + latestVersion: version + }, { + where: { + name + } + }) + } + }, + instance: {} + } + } +} diff --git a/forge/db/models/Project.js b/forge/db/models/Project.js index 3398bad723..69e9533d4a 100644 --- a/forge/db/models/Project.js +++ b/forge/db/models/Project.js @@ -260,6 +260,13 @@ module.exports = { app.log.error(`Error removing MCPRegistrations for deleted instance ${project.id}: ${err.message}`) } } + // Remove version info when Project removed + await app.db.models.NodeREDNodeVersions.destroy({ + where: { + ownerType: 'instance', + ownerId: project.id + } + }) } } }, diff --git a/forge/db/models/StorageSettings.js b/forge/db/models/StorageSettings.js index dac4852517..10a62bb420 100644 --- a/forge/db/models/StorageSettings.js +++ b/forge/db/models/StorageSettings.js @@ -4,6 +4,8 @@ */ const { DataTypes } = require('sequelize') +const NODE_VERSION_CACHE = 'nodes-latestVersion' + module.exports = { name: 'StorageSettings', schema: { @@ -23,5 +25,33 @@ module.exports = { } } } + }, + hooks: function (M, app) { + return { + afterUpdate: async (storageSettings, options) => { + try { + const cache = app.caches.getCache(NODE_VERSION_CACHE) + await storageSettings.reload({ + attributes: ['id', 'settings', 'ProjectId'] + }) + const settingsObj = JSON.parse(storageSettings.settings) + const nodes = settingsObj.nodes + for (const n of Object.keys(nodes)) { + if (n !== 'node-red') { + const latest = await cache.get(n) + await app.db.models.NodeREDNodeVersions.upsert({ + ownerId: storageSettings.ProjectId, + ownerType: 'instance', + name: n, + currentVersion: nodes[n].version, + latestVersion: latest + }) + } + } + } catch (err) { + // swallowing error as this is an async hook triggered by an update to the underlying table + } + } + } } } diff --git a/forge/db/models/index.js b/forge/db/models/index.js index 93ae1e13ea..49f4068e2f 100644 --- a/forge/db/models/index.js +++ b/forge/db/models/index.js @@ -85,7 +85,8 @@ const modelTypes = [ 'BrokerCredentials', 'MQTTTopicSchema', 'TeamBrokerAgent', - 'AsyncLoginSession' + 'AsyncLoginSession', + 'NodeREDNodeVersions' ] // A local map of the known models. diff --git a/forge/ee/lib/bom/index.js b/forge/ee/lib/bom/index.js new file mode 100644 index 0000000000..4883d21215 --- /dev/null +++ b/forge/ee/lib/bom/index.js @@ -0,0 +1,5 @@ +module.exports.init = async function (app) { + app.config.features.register('autoNodeUpdate', true, true) + + app.housekeeper.registerTask(require('./tasks/cache-catalogues')) +} diff --git a/forge/ee/lib/bom/tasks/cache-catalogues.js b/forge/ee/lib/bom/tasks/cache-catalogues.js new file mode 100644 index 0000000000..2167737d0b --- /dev/null +++ b/forge/ee/lib/bom/tasks/cache-catalogues.js @@ -0,0 +1,53 @@ +const axios = require('axios') + +const { decodeCertifiedNodesToken } = require('../../../../lib/npm') + +const NODE_VERSION_CACHE = 'nodes-latestVersion' + +module.exports = { + name: 'cacheCatalogues', + startup: 1000 * 90, // 90 seconds after start up to ensure cache populated + schedule: '47 21 * * *', // Update at 21:47 every day (if not done with a restart) + run: async function (app) { + app.caches.createCache(NODE_VERSION_CACHE) + const cache = app.caches.getCache(NODE_VERSION_CACHE) + // Starting list of catalogues + const cataloguesList = [ + 'https://catalogue.nodered.org/catalogue.json' + ] + + const platformNPMEnabled = !!app.config.features.enabled('certifiedNodes', false) && + !!app.config.features.enabled('ffNodes', false) && + !!app.settings.get('platform:ff-npm-registry:token') + if (platformNPMEnabled) { + // gets platform wide certified nodes catalogues but not per team override + const { catalogues } = decodeCertifiedNodesToken(app.settings.get('platform:ff-npm-registry:token'), 'placeholder') + cataloguesList.push(...catalogues) + } + + for (const cat of cataloguesList) { + app.log.debug(`Checking catalogue ${cat} for latest node versions`) + try { + const res = await axios.get(cat, { + headers: { + Accept: 'application/json' + } + }) + if (res.status === 200) { + const modules = res.data.modules + for (const mod of modules) { + const name = mod.id + const version = mod.version + const current = await cache.get(name) + if (current && current !== version) { + await app.models.NodeREDNodeVersions.updateAllLatest(name, version) + } + cache.set(name, version) + } + } + } catch (err) { + app.log.debug(`Problem reading catalogue ${cat}, ${err.toString()}`) + } + } + } +} diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index dff8995d87..e44e9179b1 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -61,6 +61,8 @@ module.exports = fp(async function (app, opts) { (Object.prototype.hasOwnProperty.call(app.config?.expert ?? {}, 'insights') ? !!app.config?.expert?.insights?.enabled : true) app.config.features.register('expertInsights', isInsightsEnabled ?? false, true) + + require('./bom').init(app) } // Set the Team Library Feature Flag