@flowfuse/flowfuse
Version:
An open source low-code development platform
689 lines (637 loc) • 29.5 kB
JavaScript
const crypto = require('crypto')
const { ControllerError } = require('../../lib/errors')
const { KEY_SETTINGS } = require('../models/ProjectSettings')
/**
* inflightProjectState - when projects are transitioning between states, there
* 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 inflightDeploys = new Set()
module.exports = {
/**
* Get the in-flight state of a project
* @param {*} app
* @param {*} project
* @returns the in-flight state
*/
getInflightState: function (app, project) {
return inflightProjectState[project.id]
},
/**
* Set the in-flight state of a project
* @param {*} app
* @param {*} project
* @param {*} state
*/
setInflightState: function (app, project, state) {
inflightProjectState[project.id] = state
},
/**
* Check whether an instance is currently flagged as deploying
* @param {*} app
* @param {*} instance
*/
isDeploying: function (app, instance) {
return inflightDeploys.has(instance.id)
},
/**
* Mark an instance as currently being deployed
* @param {*} app
* @param {*} instance
*/
setInDeploy: function (app, instance) {
inflightDeploys.add(instance.id)
},
/**
* Set the in-flight state of a project
* @param {*} app
* @param {*} project
*/
clearInflightState: function (app, project) {
delete inflightProjectState[project.id]
inflightDeploys.delete(project.id)
},
/**
* Get the settings object that should be passed to nr-launcher so it can
* start Node-RED with the proper project configuration.
*
* This merges the Project Template settings with the Project's own settings.
*
* @param {*} app the forge app
* @param {*} project the project to get the settings for
* @returns the runtime settings object
*/
getRuntimeSettings: async function (app, project) {
// This assumes the project has been loaded via `byId` so that
// it has the template and ProjectSettings attached
let result = {}
const env = {}
if (project.ProjectTemplate) {
result = project.ProjectTemplate.settings
if (result.env) {
result.env.forEach(envVar => {
env[envVar.name] = envVar.value
})
}
// special case for palette.modules - we don't want to inherit
// the template's list of modules. We want to use the list that
// is stored in the project's own settings.
if (result.palette?.modules) {
delete result.palette.modules
}
}
const projectSettingsRow = project.ProjectSettings?.find((projectSetting) => projectSetting.key === KEY_SETTINGS)
if (projectSettingsRow) {
const projectSettings = projectSettingsRow.value
result = app.db.controllers.ProjectTemplate.mergeSettings(result, projectSettings)
const envVars = app.db.controllers.Project.insertPlatformSpecificEnvVars(project, result.env)
// convert [{name: 'a', value: '1'}, {name: 'b', value: '2'}] >> to >> { a: 1, b: 2 }
envVars.forEach(envVar => {
env[envVar.name] = envVar.value
})
}
// If we don't have any modules listed in project settings. We should
// look them up from StorageSettings in case this is a pre-existing project
// that has nodes installed from before we started tracking them ourselves
const moduleList = result.palette?.modules || await app.db.controllers.StorageSettings.getProjectModules(project) || []
const modules = {}
moduleList.forEach(module => {
modules[module.name] = module.version
})
// ensure email alert settings for resources have a value that matches
// what we show in the UI when no value is set in the DB
// (see forge/db/views/Project.js for the matching UI default values)
if (typeof result.emailAlerts !== 'object') {
result.emailAlerts = {}
}
if (typeof result.emailAlerts.resource !== 'object') {
result.emailAlerts.resource = {}
}
if (typeof result.emailAlerts.resource.cpu !== 'boolean') {
const templateSetting = project.ProjectTemplate?.settings?.emailAlerts?.resource?.cpu
result.emailAlerts.resource.cpu = templateSetting ?? true
}
if (typeof result.emailAlerts.resource.memory !== 'boolean') {
const templateSetting = project.ProjectTemplate?.settings?.emailAlerts?.resource?.memory
result.emailAlerts.resource.memory = templateSetting ?? true
}
result.palette = result.palette || {}
result.palette.modules = modules
result.env = env
return result
},
exportProject: async function (app, project, components = {
flows: true,
credentials: true,
settings: true,
envVars: true
}) {
const projectExport = {}
if (components.flows) {
const flows = await app.db.models.StorageFlow.byProject(project.id)
projectExport.flows = !flows ? [] : JSON.parse(flows.flow)
if (components.credentials) {
const origCredentials = await app.db.models.StorageCredentials.byProject(project.id)
if (origCredentials) {
const encryptedCreds = JSON.parse(origCredentials.credentials)
if (components.credentialSecret) {
// This code path is currently unused. It is here
// for a future item where a user wants to export a project
// out of the platform. They will provide their own
// credentialSecret value - which we will used to re-encrypt
// the project credentials
const projectSecret = await project.getCredentialSecret()
const exportSecret = components.credentialSecret
projectExport.credentials = app.db.controllers.Project.exportCredentials(encryptedCreds, projectSecret, exportSecret)
} else {
projectExport.credentials = encryptedCreds
}
}
}
}
const settings = await app.db.controllers.Project.getRuntimeSettings(project)
if (components.settings || components.envVars) {
const envVars = settings.env
delete settings.env
if (components.settings) {
projectExport.settings = settings
}
if (components.envVars) {
projectExport.env = envVars
}
}
const NRSettings = await app.db.models.StorageSettings.byProject(project.id)
if (NRSettings) {
projectExport.modules = {}
try {
const nodeList = JSON.parse(NRSettings.settings).nodes || {}
Object.entries(nodeList).forEach(([key, value]) => {
projectExport.modules[key] = value.version
})
// The list from StorageSettings will only include modules that
// include nodes. The instance settings may specify other modules
// to be installed. We need to include them in the list.
if (settings.palette?.modules) {
Object.entries(settings.palette?.modules).forEach(([key, value]) => {
if (!Object.hasOwn(projectExport.modules, key)) {
projectExport.modules[key] = value
}
})
}
} catch (err) {}
}
return projectExport
},
importProjectSnapshot: async function restoreSnapshot (app, project, snapshot, { mergeEnvVars, mergeEditorSettings } = { mergeEnvVars: false, mergeEditorSettings: false }) {
const t = await app.db.sequelize.transaction() // start a transaction
try {
if (snapshot?.flows?.flows) {
const flows = JSON.stringify(!snapshot.flows.flows ? [] : snapshot.flows.flows)
await app.db.controllers.StorageFlows.updateOrCreateForProject(project, flows, { transaction: t })
if (snapshot.flows.credentials) {
await app.db.controllers.StorageCredentials.updateOrCreateForProject(project, snapshot.flows.credentials, { transaction: t })
}
}
if (snapshot?.settings?.settings || snapshot?.settings?.env) {
const snapshotSettings = JSON.parse(JSON.stringify(snapshot.settings.settings || {}))
snapshotSettings.env = []
const envVarKeys = Object.keys(snapshot.settings.env || {})
if (envVarKeys?.length) {
envVarKeys.forEach(key => {
snapshotSettings.env.push({
name: key,
value: snapshot.settings.env[key]
})
})
}
if (snapshotSettings.palette?.modules) {
const moduleList = []
for (const [name, version] of Object.entries(snapshotSettings.palette.modules)) {
moduleList.push({ name, version, local: true })
}
snapshotSettings.palette.modules = moduleList
} else {
snapshotSettings.palette = snapshotSettings.palette || {}
snapshotSettings.palette.modules = []
}
if (!project.ProjectTemplate) {
project = await app.db.models.Project.byId(project.id)
}
const newSettings = app.db.controllers.ProjectTemplate.validateSettings(snapshotSettings, project.ProjectTemplate, true)
const currentProjectSettings = await project.getSetting('settings') || {} // necessary?
const updatedSettings = app.db.controllers.ProjectTemplate.mergeSettings(currentProjectSettings, newSettings, { mergeEnvVars, mergeEditorSettings, targetTemplate: project.ProjectTemplate })
await project.updateSetting('settings', updatedSettings, { transaction: t }) // necessary?
}
await t.commit() // all good, commit the transaction
} catch (error) {
await t.rollback() // rollback the transaction.
throw error
}
},
/**
* Takes a credentials object and re-encrypts it with a new key.
* If oldKey is blank or undefined, it assumes the credentials object is
* unencrypted at this point and only needs to be re-encrypted
*/
exportCredentials: function (app, original, oldKey, newKey) {
if (oldKey && original.$) {
const oldHash = crypto.createHash('sha256').update(oldKey).digest()
original = decryptCreds(oldHash, original)
}
const newHash = crypto.createHash('sha256').update(newKey).digest()
return encryptCreds(newHash, original)
},
/**
* Helper for the above exportCredentials method
* @param {*} existingCredentials
* @param {*} oldCredentialSecret
* @param {*} newCredentialSecret
* @returns
*/
reEncryptCredentials (app, existingCredentials, oldCredentialSecret, newCredentialSecret) {
const newCredentials = app.db.controllers.Project.exportCredentials(existingCredentials, oldCredentialSecret, newCredentialSecret)
return newCredentials
},
/**
* Remove platform specific environment variables
* @param {[{name:string, value:string}]} envVars Environment variables array
*/
removePlatformSpecificEnvVars: function (app, envVars) {
if (!envVars || !Array.isArray(envVars)) {
return []
}
return [...envVars.filter(e => e.name.startsWith('FF_') === false)]
},
/**
* Insert platform specific environment variables
* @param {Project} project The device
* @param {[{name:string, value:string}]} envVars Environment variables array
*/
insertPlatformSpecificEnvVars: function (app, project, envVars) {
if (!envVars || !Array.isArray(envVars)) {
envVars = []
}
const makeVar = (name, value, deprecated) => {
return { name, value: value || '', platform: true, deprecated } // add `platform` and `deprecated` flags for UI
}
const result = []
result.push(makeVar('FF_INSTANCE_ID', project.id || ''))
result.push(makeVar('FF_INSTANCE_NAME', project.name || ''))
result.push(makeVar('FF_PROJECT_ID', project.id || '', true)) // deprecated as of V1.6.0
result.push(makeVar('FF_PROJECT_NAME', project.name || '', true)) // deprecated as of V1.6.0
result.push(...app.db.controllers.Project.removePlatformSpecificEnvVars(envVars))
return result
},
/**
*
* @param {*} app
* @param {Team} team
* @param {Application} application
* @param {User} user
* @param {ProjectType} type
* @param {ProjectStack} stack
* @param {ProjectTemplate} template
* @param {{name: string, ha: {}, sourceProject: Project, sourceProjectOptions: {}, flowTemplate: FlowTemplate}} properties Props of the project to create
* @returns
*/
create: async function (
app,
team,
application,
user,
type,
stack,
template,
{
name = '',
ha = null,
sourceProject = null,
sourceProjectOptions = {},
flowBlueprint = null
} = {}
) {
if (!user) {
throw new ControllerError('invalid_user', 'Invalid user')
}
if (!team) {
throw new ControllerError('invalid_team', 'Invalid team')
}
if (!application) {
throw new ControllerError('invalid_application', 'Invalid application')
}
if (!type) {
throw new ControllerError('invalid_project_type', 'Invalid project type')
}
// This will perform all checks needed to ensure this instance type can be created for this team.
// Throws an exception if not allowed
await team.checkInstanceTypeCreateAllowed(type)
if (sourceProject && flowBlueprint) {
throw new ControllerError('invalid_request', 'Source Project and Flow Blueprint cannot both be used')
}
if (sourceProject) {
if (sourceProject.Team.id !== team.id) {
throw new ControllerError('invalid_source_project', 'Source Project Not in Same Team', 403)
} else if (sourceProject && sourceProject.Application.id !== application.id) {
throw new ControllerError('invalid_source_project', 'Source Project Not in Same Application', 403)
}
}
if (!stack || stack.ProjectTypeId !== type.id) {
throw new ControllerError('invalid_stack', 'Invalid stack')
}
if (!template) {
throw new ControllerError('invalid_template', 'Invalid template')
}
name = name.trim()
const safeName = name?.toLowerCase()
if (app.db.models.Project.BANNED_NAME_LIST.includes(safeName)) {
throw new ControllerError('invalid_project_name', 'name not allowed', 409)
}
if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(safeName) === false) {
throw new ControllerError('invalid_project_name', 'name not allowed', 409)
}
if (await app.db.models.Project.isNameUsed(safeName)) {
throw new ControllerError('invalid_project_name', 'name in use', 409)
}
if (app.license.active() && app.ha) {
if (ha && !await app.ha.isHAAllowed(team, type, ha)) {
throw new ControllerError('invalid_ha', 'Invalid HA configuration')
}
}
let instance
try {
instance = await app.db.models.Project.create({
name,
ApplicationId: application.id,
type: '',
url: ''
})
} catch (err) {
throw new ControllerError('unexpected_error', err.message, null, { cause: err })
}
await team.addProject(instance)
await instance.setProjectStack(stack)
await instance.setProjectTemplate(template)
await instance.setProjectType(type)
if (app.license.active() && app.ha && ha) {
await instance.updateHASettings(ha)
}
await instance.reload({
include: [
{ model: app.db.models.Team },
{ model: app.db.models.ProjectType },
{ model: app.db.models.ProjectStack },
{ model: app.db.models.ProjectTemplate },
{ model: app.db.models.ProjectSettings }
]
})
if (sourceProject) {
await app.db.controllers.Project.importFromInstance(instance, sourceProject, sourceProjectOptions)
} else {
const newProjectSettings = { header: { title: instance.name } }
// Copy the palette modules from the template (if any)
// This is an instance creation time only operation to avoid the complexities of
// merging the palette modules from the template with the instance palette modules.
if (template.settings.palette?.modules?.length > 0) {
newProjectSettings.palette = { modules: [...template.settings.palette.modules] }
}
await instance.updateSetting(KEY_SETTINGS, newProjectSettings)
await instance.updateSetting('credentialSecret', app.db.models.Project.generateCredentialSecret())
if (flowBlueprint) {
await app.db.controllers.Project.applyFlowBlueprint(instance, flowBlueprint)
}
}
await app.containers.start(instance)
await app.auditLog.Project.project.created(user, null, team, instance)
if (sourceProject) {
await app.auditLog.Team.project.duplicated(user, null, team, sourceProject, instance)
} else {
await app.auditLog.Team.project.created(user, null, team, instance)
}
return instance
},
/**
* This method imports from an existing instance, whereas importProject imports from a representation of an instance
* Long term, these two method should be combined.
*
* @param {*} app
* @param {Project} targetInstance
* @param {Project} sourceInstance
* @param {{flows: boolean, credentials: boolean, envVars: boolean}} options
*/
importFromInstance: async function (app, targetInstance, sourceInstance, options = {}) {
// need to copy values over
const settingsString = (await app.db.models.StorageSettings.byProject(sourceInstance.id))?.settings ?? '{}'
const newSettings = {
users: {}
}
const sourceSettings = JSON.parse(settingsString)
if (settingsString) {
newSettings.nodes = sourceSettings.nodes
}
const newCredentialSecret = app.db.models.Project.generateCredentialSecret()
if (options.flows) {
const sourceFlows = await app.db.models.StorageFlow.byProject(sourceInstance.id)
if (sourceFlows) {
const newFlow = await app.db.models.StorageFlow.create({
flow: sourceFlows.flow,
ProjectId: targetInstance.id
})
await newFlow.save()
}
if (options.credentials) {
// To copy over the credentials, we have to:
// - get the existing credentials + credentialSecret
// - generate a new credentialSecret for the new project
// (this is normally left to NR to do itself)
// - re-encrypt the credentials using the new key
const origCredentialsModel = await app.db.models.StorageCredentials.byProject(sourceInstance.id)
if (origCredentialsModel) {
const origCredentials = JSON.parse(origCredentialsModel.credentials) // .credentials is stored as text in the DB
const origCredentialSecret = await sourceInstance.getSetting('credentialSecret') || sourceSettings._credentialSecret // Legacy
const newCredentials = await app.db.controllers.Project.reEncryptCredentials(origCredentials, origCredentialSecret, newCredentialSecret)
await app.db.models.StorageCredentials.create({
credentials: JSON.stringify(newCredentials),
ProjectId: targetInstance.id
})
}
}
}
await targetInstance.updateSetting('credentialSecret', newCredentialSecret)
const settings = await app.db.models.StorageSettings.create({
settings: JSON.stringify(newSettings),
ProjectId: targetInstance.id
})
await settings.save()
const sourceProjectSettings = await sourceInstance.getSetting(KEY_SETTINGS) || { env: [] }
const sourceProjectEnvVars = sourceProjectSettings.env || []
const newProjectSettings = { ...sourceProjectSettings }
newProjectSettings.env = []
if (options.envVars) {
sourceProjectEnvVars.forEach(envVar => {
newProjectSettings.env.push({
name: envVar.name,
value: options.envVars === 'keys' ? '' : envVar.value,
hidden: envVar.hidden ?? false
})
})
}
newProjectSettings.header = { title: targetInstance.name }
await targetInstance.updateSetting(KEY_SETTINGS, newProjectSettings)
return targetInstance
},
/**
* Imports settings, flows and credentials from a project export object
*
* @param {*} app
* @param {*} project
* @param {*} components
*/
importProject: async function (app, project, components) {
const transaction = await app.db.sequelize.transaction()
try {
if (components.flows) {
await app.db.controllers.StorageFlows.updateOrCreateForProject(project, components.flows, { transaction })
}
if (components.credentials) {
const projectSecret = await project.getCredentialSecret()
const encryptedCreds = app.db.controllers.Project.exportCredentials(JSON.parse(components.credentials), components.credsSecret, projectSecret)
await app.db.controllers.StorageCredentials.updateOrCreateForProject(project, encryptedCreds, { transaction })
}
await transaction.commit()
} catch (error) {
transaction.rollback()
throw error
}
if (project.state === 'running') {
app.db.controllers.Project.setInflightState(project, 'restarting')
project.state = 'running'
await project.save()
const result = await app.containers.restartFlows(project)
app.db.controllers.Project.clearInflightState(project)
return result
}
},
/**
* Add a module to the list of project modules.
*/
addProjectModule: async function (app, project, module, version) {
return app.db.controllers.Project.mergeProjectModules(project, [{
name: module,
version,
local: true
}], true)
},
/**
* Remove a module from the list of project modules.
*/
removeProjectModule: async function (app, project, module) {
let changed = false
let newProjectModuleList
const currentProjectSettings = await project.getSetting('settings') || {}
if (currentProjectSettings?.palette?.modules) {
newProjectModuleList = currentProjectSettings?.palette?.modules.filter(m => {
if (m.name === module) {
changed = true
return false
}
return true
})
}
if (changed) {
currentProjectSettings.palette = currentProjectSettings.palette || {}
currentProjectSettings.palette.modules = newProjectModuleList
await project.updateSetting('settings', currentProjectSettings)
}
},
/**
* Updates the project settings.palette.modules value based on the
* module list Node-RED has provided to the StorageSettings api.
*/
mergeProjectModules: async function (app, project, moduleList, updateExisting = false) {
let changed = false
let newProjectModuleList
const currentProjectSettings = await project.getSetting('settings') || {}
if (currentProjectSettings?.palette?.modules) {
// The project has an existing list of modules - need to resolve any
// changes between the two lists
// As the moduleList comes from Node-RED's runtimeSettings object,
// it will only contain the modules that include Node-RED nodes.
// Regular npm modules that do not include nodes (nrlint for eg)
// will not be included.
// So we don't want to remove anything from the current list just
// because it isn't listed in what moduleList provides.
// We use the nodes.remove audit event to remove entries relating
// to node modules.
// For modules we already know about, we only update the recorded
// version if explicitly asked to. That only happens when triggered
// by the 'nodes.install' audit event.
// This is the list of modules from ProjectSettings
const existingModulesList = currentProjectSettings?.palette?.modules
const existingModules = {}
existingModulesList.forEach(m => { existingModules[m.name] = m })
const newModules = {}
moduleList.forEach(m => {
newModules[m.name] = m
if (!existingModules[m.name] || updateExisting) {
changed = true
existingModules[m.name] = m
if (/^\d/.test(m.version)) {
m.version = `~${m.version}`
}
}
})
newProjectModuleList = Object.values(existingModules)
} else {
// No existing modules - so updated with the list as provided
changed = true
newProjectModuleList = moduleList.map(m => {
if (/^\d/.test(m.version)) {
m.version = `~${m.version}`
}
return m
})
}
if (changed) {
currentProjectSettings.palette = currentProjectSettings.palette || {}
currentProjectSettings.palette.modules = newProjectModuleList
await project.updateSetting('settings', currentProjectSettings)
}
},
/**
* Applies the contents of a FlowTemplate to an Instance. We assume this
* is being invoked when the Instance is being created - so it will overwrite
* any existing flows and will merge any modules with those provided by the ProjectTemplate.
* @param {*} app
* @param {*} instance
* @param {*} flowBlueprint
*/
applyFlowBlueprint: async function (app, instance, flowBlueprint) {
const flows = flowBlueprint.flows || { flows: [], credentials: {} }
if (flows.flows) {
await app.db.controllers.StorageFlows.updateOrCreateForProject(instance, JSON.stringify(flows.flows))
}
if (flows.credentials) {
const projectSecret = await instance.getCredentialSecret()
const encryptedCreds = app.db.controllers.Project.exportCredentials(flows.credentials, null, projectSecret)
await app.db.controllers.StorageCredentials.updateOrCreateForProject(instance, encryptedCreds)
}
const modules = flowBlueprint.modules || {}
for (const [moduleName, version] of Object.entries(modules)) {
await app.db.controllers.Project.addProjectModule(instance, moduleName, version)
}
}
}
function decryptCreds (key, cipher) {
let flows = cipher.$
const initVector = Buffer.from(flows.substring(0, 32), 'hex')
flows = flows.substring(32)
const decipher = crypto.createDecipheriv('aes-256-ctr', key, initVector)
const decrypted = decipher.update(flows, 'base64', 'utf8') + decipher.final('utf8')
return JSON.parse(decrypted)
}
function encryptCreds (key, plain) {
const initVector = crypto.randomBytes(16)
const cipher = crypto.createCipheriv('aes-256-ctr', key, initVector)
return { $: initVector.toString('hex') + cipher.update(JSON.stringify(plain), 'utf8', 'base64') + cipher.final('base64') }
}