UNPKG

@flowfuse/flowfuse

Version:

An open source low-code development platform

451 lines (420 loc) • 20.9 kB
const SemVer = require('semver') const { literal } = require('sequelize') const { ControllerError } = require('../../lib/errors') /** @type {import('../../auditLog/team').getLoggers} */ const getTeamLogger = (app) => { return app.auditLog.Team } module.exports = { isDeploying: function (app, device) { // Needs to have a target to be considered deploying if (!device.targetSnapshotId) { return false } // Active snapshot does not match target, consider this device deploying return device.activeSnapshotId !== device.targetSnapshotId }, // Set the connected state of a device without needing to retrieve the model // first. This is used when the platform is shutting down to proactively mark // the devices as not-connected. setConnected: function (app, deviceHashId, isConnected) { const deviceId = app.db.models.Device.decodeHashid(deviceHashId) if (deviceId?.length > 0) { app.db.models.Device.update({ editorConnected: isConnected }, { where: { id: deviceId } }) } }, updateState: async function (app, device, state) { device.set('lastSeenAt', literal('CURRENT_TIMESTAMP')) if (!state) { // We have received a `null` state from the device. That means it // is busy updating itself. Update our local state to infer as much // as we can from that device.set('state', 'updating') } else { if (state.state) { device.set('state', state.state) } if (state.agentVersion) { device.set('agentVersion', state.agentVersion) } if (state.nodeRedVersion) { device.set('nodeRedVersion', state.nodeRedVersion) } device.set('editorAffinity', state.affinity || null) if (!state.snapshot || state.snapshot === '0') { if (device.activeSnapshotId !== null) { device.set('activeSnapshotId', null) } } else { // Update the activeSnapshotId if valid and not already set const snapshotId = app.db.models.ProjectSnapshot.decodeHashid(state.snapshot) // hashid.decode returns an array of values, not the raw value. if (snapshotId?.length > 0 && snapshotId !== device.activeSnapshotId) { // Check to see if snapshot exists if (await app.db.models.ProjectSnapshot.count({ where: { id: snapshotId }, limit: 1 }) > 0) { device.set('activeSnapshotId', snapshotId[0]) } } } } await device.save() }, /** * Sends the project id, snapshot hash and settings hash to the device * so that the device can determine what/if it needs to update * @param {forge.db.models.Device} device The device to send an "update" command to */ sendDeviceUpdateCommand: async function (app, device) { if (app.comms) { let snapshotId = device.targetSnapshot?.hashid || null if (snapshotId) { // device.targetSnapshot is a limited view so we need to load the it from the db // If this device is owned by an instance, check it has an associated instance and that it matches the device's project if (!device.isApplicationOwned) { const targetSnapshot = (await app.db.models.ProjectSnapshot.byId(snapshotId)) if (!targetSnapshot || !targetSnapshot.ProjectId || targetSnapshot.ProjectId !== device.ProjectId) { snapshotId = null // target snapshot is not associated with this project (possibly orphaned), set it to null } } } const payload = { ownerType: device.ownerType, application: device.Application?.hashid || null, project: device.Project?.id || null, snapshot: snapshotId, settings: device.settingsHash || null, mode: device.mode, licensed: app.license.active() } // if the device is assigned to an application but has no snapshot we need to send enough // info to start the device in application mode so that it can start node-red and // permit the user to generate new flows and submit a snapshot if (device.isApplicationOwned) { delete payload.project // exclude project property to avoid triggering the wrong kind of update on the device if (!device.agentVersion || SemVer.lt(device.agentVersion, '1.11.0')) { // device is running an agent version < 1.11.0 we need to clear it payload.snapshot = null payload.project = null payload.settings = null } else if (payload.snapshot === null) { payload.snapshot = '0' // '0' indicates that the application owned device should start with starter flows } } else { delete payload.application // exclude application property to avoid triggering the wrong kind of update on the device } // ensure the device has a team association let team = device.Team if (!team) { // reload the device with the team association const _device = await app.db.models.Device.byId(device.id) team = _device?.Team if (!team) { app.log.warn(`Failed to send update command to device ${device.hashid} as it has no team association`) return } } // send out update command app.comms.devices.sendCommand(team.hashid, device.hashid, 'update', payload) } }, /** * 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 {Device} device The device * @param {[{name:string, value:string}]} envVars Environment variables array */ insertPlatformSpecificEnvVars: function (app, device, envVars) { if (!envVars || !Array.isArray(envVars)) { envVars = [] } const makeVar = (name, value) => { return { name, value: value || '', platform: true } // add `platform` flag for UI } const result = [] let snapshotId let snapshotName if (device.isApplicationOwned) { snapshotId = device.targetSnapshot ? device.targetSnapshot.hashid : '0' // '0' indicates that the device should start in application mode with starter flows snapshotName = device.targetSnapshot ? device.targetSnapshot.name : 'None' result.push(makeVar('FF_APPLICATION_ID', device.Application?.hashid || '')) result.push(makeVar('FF_APPLICATION_NAME', device.Application?.name || '')) } else { // assume older device / part of an instance (i.e. NOT at application level) snapshotId = device.targetSnapshot?.hashid || '' snapshotName = device.targetSnapshot?.name || '' } result.push(makeVar('FF_DEVICE_ID', device.hashid || '')) result.push(makeVar('FF_DEVICE_NAME', device.name || '')) result.push(makeVar('FF_DEVICE_TYPE', device.type || '')) result.push(makeVar('FF_SNAPSHOT_ID', snapshotId)) result.push(makeVar('FF_SNAPSHOT_NAME', snapshotName)) result.push(...app.db.controllers.Device.removePlatformSpecificEnvVars(envVars)) return result }, /** * Export a device config for snapshotting back up to the forge * @param {import('../../forge').forge} app Forge app instance * @param {Object} device The device to export the config from */ exportConfig: async function (app, device) { // request config and flows from device if (app.comms) { const config = await app.comms.devices.sendCommandAwaitReply(device.Team.hashid, device.hashid, 'upload', { timeout: 10000 }) if (config) { return config } } return null }, /** * Takes a comma separated list of key:value,name:smith pairs to an object */ parseFiltersFromString (app, filterString = '') { const filters = Object.fromEntries(filterString.split(',').map((filterString) => filterString.split(':'))) return { ...(filters.status ? { state: filters.status } : null), ...(filters.lastseen ? { lastseen: filters.lastseen } : null) } }, /** * Return pagination options for the request including status flag, filters, and order * On top of the standard limit/cursor/query * @param {import('../../forge').forge} app Forge app instance * @param {FastifyRequest} request request * @returns {Object} */ getDevicePaginationOptions: function (app, request) { const paginationOptions = { ...request.query } if (paginationOptions.query) { paginationOptions.query = paginationOptions.query.trim() } paginationOptions.statusOnly = !!paginationOptions.statusOnly if (paginationOptions.statusOnly) { delete paginationOptions.limit } if (paginationOptions.filters) { paginationOptions.filters = app.db.controllers.Device.parseFiltersFromString(paginationOptions.filters) } if (paginationOptions.sort) { paginationOptions.order = { [paginationOptions.sort]: paginationOptions.order } delete paginationOptions.sort delete paginationOptions.dir } return paginationOptions }, /** * Bulk delete devices. * Notes: * * All devices must belong to the same team * * All devices must be present in the database * @param {*} app - Forge app instance * @param {*} team - User's team * @param {Array<string>} deviceIds - Array of device hashids * @param {*} user - User performing the deletion (required for audit logging) */ bulkDelete: async function (app, team, deviceIds, user) { if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) { throw new ControllerError('invalid_input', 'No devices specified', 400) } // convert hashids to ids const idsDecoded = deviceIds.map(hashid => hashid && app.db.models.Device.decodeHashid(hashid)) const ids = idsDecoded.map(id => id && Array.isArray(id) ? id[0] : null).filter(id => id) // find all where id in ids const devices = await app.db.models.Device.findAll({ where: { id: ids } }) if (devices.length === 0) { throw new ControllerError('not_found', 'No devices found', 404) } // ensure all devices are part of the same team const deviceTeam = await app.db.models.Team.byId(devices[0].TeamId) if (!deviceTeam || deviceTeam.id !== team.id) { throw new ControllerError('invalid_input', 'Devices must belong to the users team', 400) } if (devices.some(d => d.TeamId !== team.id)) { throw new ControllerError('invalid_input', 'All devices must belong to the same team', 400) } // delete all devices await app.db.models.Device.destroy({ where: { id: ids } }) if (app.license.active() && app.billing) { await app.billing.updateTeamBillingCounts(team) } // Log the deletion const teamLogger = getTeamLogger(app) teamLogger.team.device.bulkDeleted(user, null, team, devices) }, moveDevices: async function (app, deviceIds, targetApplicationId, targetInstanceId, user) { // target is either a project or an application if (targetApplicationId && targetInstanceId) { throw new ControllerError('invalid_input', 'Target must be either an application or an instance', 400) } // Get devices const idsDecoded = deviceIds.map(hashid => hashid && app.db.models.Device.decodeHashid(hashid)) const ids = idsDecoded.map(id => id && Array.isArray(id) ? id[0] : null).filter(id => id) const devicesData = await app.db.models.Device.getAll({}, { id: ids }, { includeInstanceApplication: true }) if (!devicesData?.count) { throw new ControllerError('not_found', 'No devices found', 404) } const devices = devicesData.devices // Check devices and the target are all part of the same team /** @type {'instance'|'application'|null} */ const assignTo = targetInstanceId ? 'instance' : (targetApplicationId ? 'application' : null) const assignToApplication = assignTo === 'application' ? await app.db.models.Application.byId(targetApplicationId) : null const assignToProject = assignTo === 'instance' ? await app.db.models.Project.byId(targetInstanceId) : null const team = await app.db.models.Team.byId(devices[0].TeamId) if (!team) { throw new ControllerError('not_found', 'No team found', 404) } if (assignTo === 'application' && !assignToApplication) { throw new ControllerError('not_found', 'No application found', 404) } if (assignTo === 'instance' && !assignToProject) { throw new ControllerError('not_found', 'No instance found', 404) } if (assignToApplication && assignToApplication.TeamId !== team.id) { throw new ControllerError('invalid_application', 'Target application does not belong to the same team', 400) } if (assignToProject && assignToProject.TeamId !== team.id) { throw new ControllerError('invalid_instance', 'Target instance does not belong to the same team', 400) } if (devices.some(d => d.TeamId !== team.id)) { throw new ControllerError('invalid_input', 'All devices must belong to the same team', 400) } // Prepare the updates const logEntries = [] const devicesToUpdate = [] const projectSettings = await assignToProject?.getSetting('deviceSettings') || {} const projectTargetSnapshotId = projectSettings.targetSnapshot || null for (let index = 0; index < devices.length; index++) { const device = devices[index] if (!assignTo) { // ### Remove device from application/project ### let previousOwner if (!device.Project && device.Application) { previousOwner = { id: device.Application.id, name: device.Application.name, isApplicationOwned: true } } else if (device.Project) { previousOwner = { id: device.Project.id, name: device.Project.name, isApplicationOwned: false } } else { continue // Device is already unassigned - nothing to do } device.ProjectId = null // unassign from project device.ApplicationId = null // unassign from application device.targetSnapshotId = null // clear the target snapshot device.DeviceGroupId = null // clear the deviceGroup // RE: disable developer mode - this behaviour is aligned with the device update API endpoint device.mode = 'autonomous' // disable developer mode devicesToUpdate.push(device) logEntries.push({ logger: app.auditLog.Team.team.device.unassigned, params: [user, null, team, previousOwner, device] }) if (previousOwner.isApplicationOwned) { logEntries.push({ logger: app.auditLog.Application.application.device.unassigned, params: [user, null, previousOwner, device] }) } else { logEntries.push({ logger: app.auditLog.Project.project.device.unassigned, params: [user, null, previousOwner, device] }) } } else if (assignTo === 'instance') { // ### Add device to instance ### if (device.ownerType === 'instance' && device.Project?.id === assignToProject.id) { // Device is already assigned to this instance - nothing to do continue } else { device.ProjectId = assignToProject.id device.ApplicationId = null device.targetSnapshotId = projectTargetSnapshotId // inherit the target snapshot of the project device.DeviceGroupId = null // not relevant to instance devices devicesToUpdate.push(device) logEntries.push({ logger: app.auditLog.Team.team.device.assigned, params: [user, null, team, assignToProject, device] }) logEntries.push({ logger: app.auditLog.Project.project.device.assigned, params: [user, null, assignToProject, device] }) logEntries.push({ logger: app.auditLog.Device.device.assigned, params: [user, null, assignToProject, device] }) } } else if (assignTo === 'application') { // ### Add device to application ### if (device.ownerType === 'application' && device.Application?.id === assignToApplication.id) { // Device is already assigned to this application - nothing to do continue } else { device.ApplicationId = assignToApplication.id device.ProjectId = null device.targetSnapshotId = null device.DeviceGroupId = null devicesToUpdate.push(device) logEntries.push({ logger: app.auditLog.Team.team.device.assigned, params: [user, null, team, assignToApplication, device] }) logEntries.push({ logger: app.auditLog.Application.application.device.assigned, params: [user, null, assignToApplication, device] }) logEntries.push({ logger: app.auditLog.Device.device.assigned, params: [user, null, assignToApplication, device] }) } } } // Save the updates in one transaction to ensure consistency const transaction = await app.db.sequelize.transaction() try { for (let index = 0; index < devicesToUpdate.length; index++) { const device = devicesToUpdate[index] await device.save({ transaction }) } await transaction.commit() } catch (error) { await transaction.rollback() throw error } // Send update command const updatedIds = devicesToUpdate.map(d => d.id) const updatedDevices = await app.db.models.Device.getAll(undefined, { id: updatedIds }, { includeInstanceApplication: true }) if (!updatedDevices.count) { return updatedDevices } for (let index = 0; index < updatedDevices.devices.length; index++) { const device = updatedDevices.devices[index] await this.sendDeviceUpdateCommand(app, device) } // Log the changes for (let index = 0; index < logEntries.length; index++) { const logEntry = logEntries[index] await logEntry.logger(...logEntry.params) } return updatedDevices } }