@flowfuse/flowfuse
Version:
An open source low-code development platform
376 lines (349 loc) • 17.6 kB
JavaScript
const { Op, ValidationError } = require('sequelize')
const { ControllerError } = require('../../../lib/errors')
const hasProperty = (obj, key) => obj && Object.prototype.hasOwnProperty.call(obj, key)
class DeviceGroupMembershipValidationError extends ControllerError {
/**
* @param {string} code
* @param {string} message
* @param {number} statusCode
* @param {Object} options
*/
constructor (code, message, statusCode, options) {
super(code, message, statusCode, options)
this.name = 'DeviceGroupMembershipValidationError'
}
}
module.exports = {
/**
* Create a Device Group
* @param {import("../../../forge").ForgeApplication} app The application object
* @param {string} name The name of the Device Group
* @param {Object} options
* @param {Object} [options.application] The application this Device Group will belong to
* @param {string} [options.description] The description of the Device Group
* @returns {Promise<Object>} The created Device Group
*/
createDeviceGroup: async function (app, name, { application = null, description } = {}) {
// Create a Device Group that devices can be linked to
// * name is required
// * application, description are optional
// * FUTURE: colors (background, border, text) and icon will be optional
return await app.db.models.DeviceGroup.create({
name,
description,
ApplicationId: application?.id
})
},
/**
* Update a Device Group.
*
* NOTE: If the targetSnapshotId is updated, devices in the group will be informed of the change via `sendUpdateCommand`
*
* @param {*} app - The application object
* @param {*} deviceGroup - The Device Group to update
* @param {Object} [options] - The options to update the Device Group
* @param {string} [options.name] - The new name of the Device Group. Exclude to keep the current name.
* @param {string} [options.description] - The new description of the Device Group. Exclude to keep the current description.
* @param {number} [options.targetSnapshotId] - The new target snapshot id of the Device Group. Exclude to keep the current snapshot. Send null to clear the current target snapshot.
*/
updateDeviceGroup: async function (app, deviceGroup, { name = undefined, description = undefined, targetSnapshotId = undefined, settings = undefined } = {}) {
// * deviceGroup is required.
// * name, description, color are optional
if (!deviceGroup) {
throw new Error('DeviceGroup is required')
}
let changed = false
let saved = false
if (typeof name !== 'undefined') {
deviceGroup.name = name
changed = true
}
if (typeof description !== 'undefined') {
deviceGroup.description = description
changed = true
}
if (typeof settings !== 'undefined' && hasProperty(settings, 'env')) {
// NOTE: For now, device group settings only support environment variables
// validate settings
if (!Array.isArray(settings.env)) {
throw new ValidationError('Invalid settings')
}
settings.env.forEach((envVar) => {
if (!envVar?.name?.match(/^[a-zA-Z_]+[a-zA-Z0-9_]*$/)) {
throw new ValidationError(`Invalid Env Var name '${envVar.name}'`)
}
})
// find duplicates
const seen = new Set()
const duplicates = settings.env.some(item => { return seen.size === seen.add(item.name).size })
if (duplicates) {
throw new ValidationError('Duplicate Env Var names provided')
}
deviceGroup.settings = {
...deviceGroup.settings,
env: settings.env
}
changed = true
}
if (typeof targetSnapshotId !== 'undefined') {
let snapshotId = targetSnapshotId
// ensure the snapshot exists (if targetSnapshotId is not null)
if (targetSnapshotId) {
const snapshot = await app.db.models.ProjectSnapshot.byId(targetSnapshotId)
if (!snapshot) {
throw new ValidationError('Snapshot does not exist')
}
snapshotId = snapshot.id
}
const devices = await deviceGroup.getDevices()
const transaction = await app.db.sequelize.transaction()
try {
deviceGroup.targetSnapshotId = snapshotId
await deviceGroup.save({ transaction })
if (devices?.length) {
const deviceIds = devices.map(d => d.id)
await app.db.models.Device.update({ targetSnapshotId: snapshotId }, { where: { id: deviceIds }, transaction })
}
await transaction.commit()
saved = true
changed = true
} catch (err) {
await transaction.rollback()
throw err
}
}
if (changed && !saved) {
await deviceGroup.save()
}
await deviceGroup.reload()
if (changed) {
await this.sendUpdateCommand(app, deviceGroup)
}
return deviceGroup
},
updateDeviceGroupMembership: async function (app, deviceGroup, { addDevices, removeDevices, setDevices } = {}) {
// * deviceGroup is required. The object must be a Sequelize model instance and must include the Devices
// * addDevices, removeDevices, setDevices are optional
// * if setDevices is provided, this will be used to set the devices assigned to the group, removing any devices that are not in the set
// * if addDevices is provided, these devices will be added to the group
// * if removeDevices is provided, these devices will be removed from the group
// if a device appears in both addDevices and removeDevices, it will be removed from the group (remove occurs after add)
if (!setDevices && !addDevices && !removeDevices) {
return // nothing to do
}
if (!deviceGroup || typeof deviceGroup !== 'object') {
throw new Error('DeviceGroup is required')
}
let actualRemoveDevices = []
let actualAddDevices = []
const currentMembers = await deviceGroup.getDevices()
// from this point on, all IDs need to be numeric (convert as needed)
const currentMemberIds = deviceListToIds(currentMembers, app.db.models.Device.decodeHashid)
setDevices = setDevices && deviceListToIds(setDevices, app.db.models.Device.decodeHashid)
addDevices = addDevices && deviceListToIds(addDevices, app.db.models.Device.decodeHashid)
removeDevices = removeDevices && deviceListToIds(removeDevices, app.db.models.Device.decodeHashid)
// setDevices is an atomic operation, it will replace the current list of devices with the specified list
if (typeof setDevices !== 'undefined') {
// create a list of devices that are currently assigned to the group, minus the devices in the set, these are the ones to remove
actualRemoveDevices = currentMemberIds.filter(d => !setDevices.includes(d))
// create a list of devices that are in the set, minus the devices that are currently assigned to the group, these are the ones to add
actualAddDevices = setDevices.filter(d => !currentMemberIds.includes(d))
} else {
if (typeof removeDevices !== 'undefined') {
actualRemoveDevices = currentMemberIds.filter(d => removeDevices.includes(d))
}
if (typeof addDevices !== 'undefined') {
actualAddDevices = addDevices.filter(d => !currentMemberIds.includes(d))
}
}
let changeCount = 0
// wrap the operations in a transaction to avoid inconsistent state
const t = await app.db.sequelize.transaction()
const targetSnapshotId = deviceGroup.targetSnapshotId || undefined
try {
// add devices
if (actualAddDevices.length > 0) {
changeCount += actualAddDevices.length
await this.assignDevicesToGroup(app, deviceGroup, actualAddDevices, targetSnapshotId, t)
}
// remove devices
if (actualRemoveDevices.length > 0) {
changeCount += actualRemoveDevices.length
await this.removeDevicesFromGroup(app, deviceGroup, actualRemoveDevices, targetSnapshotId, t)
}
// commit the transaction
await t.commit()
} catch (err) {
// Rollback transaction if any errors were encountered
await t.rollback()
// if the error is a DeviceGroupMembershipValidationError, rethrow it
if (err instanceof DeviceGroupMembershipValidationError) {
throw err
}
// otherwise, throw a friendly error message along with the original error
throw new Error(`Failed to update device group membership: ${err.message}`)
}
if (changeCount > 0) {
// clean up where necessary
// check to see if the group is now empty
const remainingDevices = await deviceGroup.deviceCount()
if (remainingDevices === 0) {
deviceGroup.targetSnapshotId = null
}
await deviceGroup.save()
// finally, inform the devices an update may be required
await this.sendUpdateCommand(app, deviceGroup, actualRemoveDevices)
}
},
assignDevicesToGroup: async function (app, deviceGroup, deviceList, applyTargetSnapshot, transaction = null) {
const deviceIds = await validateDeviceList(app, deviceGroup, deviceList, null)
const updates = { DeviceGroupId: deviceGroup.id }
if (typeof applyTargetSnapshot !== 'undefined') {
updates.targetSnapshotId = applyTargetSnapshot
}
await app.db.models.Device.update(updates, { where: { id: deviceIds.addList }, transaction })
},
/**
* Remove 1 or more devices from the specified DeviceGroup
* Specifying `activeDeviceGroupTargetSnapshotId` will null the `targetSnapshotId` of each device in `deviceList` where it matches
* This is used to remove the project from a device when being removed from a group where the active snapshot is the one applied by the DeviceGroup
* @param {*} app The application object
* @param {number} deviceGroupId The device group id
* @param {number[]} deviceList A list of devices to remove from the group
* @param {number} activeDeviceGroupTargetSnapshotId If specified, null devices `targetSnapshotId` where it matches
*/
removeDevicesFromGroup: async function (app, deviceGroup, deviceList, activeDeviceGroupTargetSnapshotId, transaction = null) {
const deviceIds = await validateDeviceList(app, deviceGroup, null, deviceList)
// Before removing from the group, if activeDeviceGroupTargetSnapshotId is specified, null `targetSnapshotId` of each device in `deviceList`
// where the device ACTUALLY DOES HAVE the matching targetsnapshotid
if (typeof activeDeviceGroupTargetSnapshotId !== 'undefined') {
await app.db.models.Device.update({ targetSnapshotId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id, targetSnapshotId: activeDeviceGroupTargetSnapshotId }, transaction })
}
// null every device.DeviceGroupId row in device table where the id === deviceGroupId and device.id is in the deviceList
await app.db.models.Device.update({ DeviceGroupId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id }, transaction })
},
/**
* Sends an update to all devices in the group and/or the specified list of devices
* so that they can determine what/if it needs to be updated
* NOTE: Since device groups only support application owned devices, this will only send updates to application owned devices
* @param {forge.db.models.DeviceGroup} [deviceGroup] A device group to send an "update" command to
* @param {Number[]} [deviceList] A list of device IDs to send an "update" command to
*/
sendUpdateCommand: async function (app, deviceGroup, deviceList) {
if (app.comms) {
if (deviceGroup) {
const devices = await deviceGroup.getDevices()
if (devices?.length) {
// add them to the deviceList if not already present
deviceList = deviceList || []
for (const device of devices) {
if (!deviceList.includes(device.id)) {
deviceList.push(device.id)
}
}
}
}
if (deviceList?.length) {
const devices = await app.db.models.Device.getAll({}, { id: deviceList })
if (!devices || !devices.devices || devices.devices.length === 0) {
return
}
const licenseActive = app.license.active()
for (const device of devices.devices) {
if (device.ownerType !== 'application') {
continue // ensure we only send updates to application owned devices
}
const payload = {
ownerType: device.ownerType,
application: device.Application?.hashid || null,
snapshot: device.targetSnapshot?.hashid || '0', // '0' means starter snapshot + flows
settings: device.settingsHash || null,
mode: device.mode,
licensed: licenseActive
}
app.comms.devices.sendCommand(device.Team.hashid, device.hashid, 'update', payload)
}
}
}
},
DeviceGroupMembershipValidationError
}
/**
* Convert a list of devices to a list of device ids
* @param {Object[]|String[]|Number[]} deviceList List of devices to convert to ids
* @param {Function} decoderFn The decoder function to use on hashes
* @returns {Number[]} Array of device IDs
*/
function deviceListToIds (deviceList, decoderFn) {
// Convert a list of devices (object|id|hash) to a list of device ids
const ids = deviceList?.map(device => {
let id = device
if (typeof device === 'string') {
[id] = decoderFn(device)
} else if (typeof device === 'object') {
id = device.id
}
return id
})
return ids
}
/**
* Verify devices are suitable for the specified group:
*
* * All devices in the list must either have DeviceGroupId===null or DeviceGroupId===deviceGroupId
* * All devices in the list must belong to the same Application as the DeviceGroup
* * All devices in the list must belong to the same Team as the DeviceGroup
* @param {*} app The application object
* @param {*} deviceGroupId The device group id
* @param {*} deviceList A list of devices to verify
*/
async function validateDeviceList (app, deviceGroup, addList, removeList) {
// check to ensure all devices in deviceList are not assigned to any group before commencing
// Assign 1 or more devices to a DeviceGroup
if (!deviceGroup || typeof deviceGroup !== 'object') {
throw new Error('DeviceGroup is required')
}
// reload with the Application association if not already loaded
if (!deviceGroup.Application) {
await deviceGroup.reload({ include: [{ model: app.db.models.Application }] })
}
const teamId = deviceGroup.Application.TeamId
if (!teamId) {
throw new Error('DeviceGroup must belong to an Application that belongs to a Team')
}
const deviceIds = {
addList: addList && deviceListToIds(addList, app.db.models.Device.decodeHashid),
removeList: removeList && deviceListToIds(removeList, app.db.models.Device.decodeHashid)
}
const deviceGroupId = deviceGroup.id
if (deviceIds.addList) {
const okCount = await app.db.models.Device.count({
where: {
id: deviceIds.addList,
[Op.or]: [
{ DeviceGroupId: null },
{ DeviceGroupId: deviceGroupId }
],
ApplicationId: deviceGroup.ApplicationId,
TeamId: teamId
}
})
if (okCount !== deviceIds.addList.length) {
throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be added to the group', 400)
}
}
if (deviceIds.removeList) {
const okCount = await app.db.models.Device.count({
where: {
id: deviceIds.removeList,
DeviceGroupId: deviceGroupId,
ApplicationId: deviceGroup.ApplicationId,
TeamId: teamId
}
})
if (okCount !== deviceIds.removeList.length) {
throw new DeviceGroupMembershipValidationError('invalid_input', 'One or more devices cannot be removed from the group', 400)
}
}
return deviceIds
}