Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update device on group membership change #3330

Merged
merged 17 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/user/device-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ navTitle: Device Groups
When managing many devices that are intended to run the same [snapshot](./snapshots.md), Device Groups allow you
to organise your devices into logical groups.
These groups can then be set as the target of a [DevOps Pipelines](./devops-pipelines.md).

Furthermore:
* Devices added to an active Device Group will automatically be updated to the active pipeline snapshot
* Devices removed from an active Device Group will have their active pipeline snapshot cleared
More details are provided below in [Adding a Device to a group](#adding-a-device-to-a-group-which-has-an-active-pipeline-snapshot) and [Removing a Device from a group](#removing-a-device-from-a-group-which-has-an-active-pipeline-snapshot)
Steve-Mcl marked this conversation as resolved.
Show resolved Hide resolved

This greatly simplifies deployments of the same configuration to one or even hundreds of devices with a single click.

The following requirements apply:
Expand Down Expand Up @@ -41,7 +47,23 @@ _Note: Adding a description can help you better distinguish device groups._
1. On the right, you will be shown devices that are already in the device group
1. Place a checkmark next to the devices in the Available Devices list that you want to add to the Device Group then click "Add Devices"
1. Place a checkmark next to the devices in the Device Group list that you want to remove then click "Remove Devices"
1. Click "Save" to commit your changes
1. Click "Save"
1. You will be prompted to confirm your changes
1. Refer to the below information for more details about what happens when you add or remove devices from a device group
1. Click "Confirm" to continue or "Cancel" to abort

_Note: If you make a mistake, you can cancel your changes at any time by clicking "Cancel"_
_Note: When a device you want to add to a group doesn't appear in the list, it's likely already assigned to another group._

### Adding a Device to a group which has an active pipeline snapshot

When a pipeline stage is operated and it deploys to a device group, that device group remembers the snapshot that was deployed.

Subsequently, if you add a device to a group, it will be instructed to update to the active snapshot.

### Removing a Device from a group which has an active pipeline snapshot

When a pipeline stage is operated and it deploys to a device group, that device group remembers the snapshot that was deployed.

Subsequently, if you remove a device from a group and the device is running the active pipeline snapshot,
the device snapshot will be cleared, effectively resetting the device to a blank state.
4 changes: 2 additions & 2 deletions forge/db/controllers/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ module.exports = {
device.set('agentVersion', state.agentVersion)
}
device.set('lastSeenAt', literal('CURRENT_TIMESTAMP'))
if (!state.snapshot) {
if (device.currentSnapshot !== null) {
if (!state.snapshot || state.snapshot === '0') {
if (device.activeSnapshotId !== null) {
device.set('activeSnapshotId', null)
}
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* eslint-disable no-unused-vars */
/**
* Add DeviceGroups table that has an FK association with Applications and Devices
*/

const { Sequelize, QueryInterface } = require('sequelize')

module.exports = {
/**
* upgrade database
* @param {QueryInterface} context QueryInterface
*/
up: async (context) => {
// add a column activePipelineStageId to DeviceGroup table
// This column is deliberately not a reference to PipelineStage table
// it is set/updated by the application / controller upon deployment
await context.addColumn('DeviceGroups', 'activePipelineStageId', {
type: Sequelize.INTEGER,
allowNull: true
})
},

down: async (queryInterface, Sequelize) => {

}
}
3 changes: 2 additions & 1 deletion forge/db/models/DeviceGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ module.exports = {
notNull: nameValidator
}
},
description: { type: DataTypes.TEXT }
description: { type: DataTypes.TEXT },
activePipelineStageId: { type: DataTypes.INTEGER, allowNull: true }
},
associations: function (M) {
this.belongsTo(M.Application, { onDelete: 'CASCADE' })
Expand Down
117 changes: 85 additions & 32 deletions forge/ee/db/controllers/DeviceGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,27 @@ module.exports = {
actualAddDevices = addDevices.filter(d => !currentMemberIds.includes(d))
}
}

// wrap the dual operation in a transaction to avoid inconsistent state
let changeCount = 0
// wrap the operations in a transaction to avoid inconsistent state
const t = await app.db.sequelize.transaction()
const activePipelineStageId = deviceGroup.activePipelineStageId
const activePipelineStage = (activePipelineStageId && await app.db.models.PipelineStageDeviceGroup.byId(activePipelineStageId)) || null
const targetSnapshotId = activePipelineStage?.targetSnapshotId || undefined
try {
// add devices
if (actualAddDevices.length > 0) {
await this.assignDevicesToGroup(app, deviceGroup, actualAddDevices, t)
// get the activePipelineStageId for this device group and get the
// targetSnapshotId for the pipeline stage. Include that in the payload
// so that newly added devices can be updated to the current snapshot
changeCount += actualAddDevices.length
await this.assignDevicesToGroup(app, deviceGroup, actualAddDevices, targetSnapshotId, t)
}
// remove devices
if (actualRemoveDevices.length > 0) {
await this.removeDevicesFromGroup(app, deviceGroup, actualRemoveDevices, t)
changeCount += actualRemoveDevices.length
await this.removeDevicesFromGroup(app, deviceGroup, actualRemoveDevices, targetSnapshotId, t)
}

// commit the transaction
await t.commit()
} catch (err) {
Expand All @@ -120,52 +129,96 @@ module.exports = {
// 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) {
// remove the activePipelineStageId
deviceGroup.activePipelineStageId = null
await deviceGroup.save()

// remove `targetSnapshotId` from PipelineStageDeviceGroup
if (activePipelineStage) {
activePipelineStage.targetSnapshotId = null
await activePipelineStage.save()
}
}
// finally, inform the devices an update may be required
await this.sendUpdateCommand(app, deviceGroup, actualRemoveDevices)
}
},

assignDevicesToGroup: async function (app, deviceGroup, deviceList, transaction = null) {
assignDevicesToGroup: async function (app, deviceGroup, deviceList, applyTargetSnapshot, transaction = null) {
const deviceIds = await validateDeviceList(app, deviceGroup, deviceList, null)
await app.db.models.Device.update({ DeviceGroupId: deviceGroup.id }, { where: { id: deviceIds.addList }, transaction })
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 `activePipelineTargetSnapshotId` 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 pipeline
* @param {*} app The application object
* @param {*} deviceGroupId The device group id
* @param {*} deviceList A list of devices to remove from the group
* @param {number} deviceGroupId The device group id
* @param {number[]} deviceList A list of devices to remove from the group
* @param {number} activePipelineTargetSnapshotId If specified, null devices `targetSnapshotId` where it matches
*/
removeDevicesFromGroup: async function (app, deviceGroup, deviceList, transaction = null) {
removeDevicesFromGroup: async function (app, deviceGroup, deviceList, activePipelineTargetSnapshotId, transaction = null) {
const deviceIds = await validateDeviceList(app, deviceGroup, null, deviceList)
// Before removing from the group, if removeTargetSnapshotWhenSet is specified, null targetSnapshotId of each device in `deviceList`
// where the device ACTUALLY DOES HAVE the matching active pipeline targetsnapshotid
if (typeof activePipelineTargetSnapshotId !== 'undefined') {
await app.db.models.Device.update({ targetSnapshotId: null }, { where: { id: deviceIds.removeList, DeviceGroupId: deviceGroup.id, targetSnapshotId: activePipelineTargetSnapshotId }, 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 the project id, snapshot hash and settings hash to all devices in the group
* so that they can determine what/if it needs to update
* NOTE: Only devices belonging to an application are present in a device group
* @param {forge.db.models.DeviceGroup} deviceGroup The device group to send an "update" command to
* 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) {
sendUpdateCommand: async function (app, deviceGroup, deviceList) {
if (app.comms) {
const application = await deviceGroup.getApplication({ include: [{ model: app.db.models.Team }] })
const targetSnapshot = deviceGroup.targetSnapshot || (await app.db.models.ProjectSnapshot.byId(deviceGroup.PipelineStageDeviceGroup.targetSnapshotId))
const payloadTemplate = {
ownerType: 'application',
application: application.hashid,
snapshot: targetSnapshot.hashid,
settings: null,
mode: null,
licensed: app.license.active()
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)
}
}
}
}
const devices = await deviceGroup.getDevices()
for (const device of devices) {
// If the device doesnt have the same target snapshot as the group, skip it
if (device.targetSnapshotId !== deviceGroup.PipelineStageDeviceGroup.targetSnapshotId) {
continue
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)
}
const payload = { ...payloadTemplate }
payload.settings = device.settingsHash || null
payload.mode = device.mode
app.comms.devices.sendCommand(application.Team.hashid, device.hashid, 'update', payload)
}
}
},
Expand Down
3 changes: 3 additions & 0 deletions forge/ee/db/controllers/Pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,9 @@ module.exports = {
// Update the targetSnapshot of the device group
await targetDeviceGroup.PipelineStageDeviceGroup.update({ targetSnapshotId: sourceSnapshot.id }, { transaction })

// Update the activePipelineStageId on the device group
await targetDeviceGroup.update({ activePipelineStageId: targetDeviceGroup.PipelineStageDeviceGroup.PipelineStageId }, { transaction })

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A possibly cleaner approach would be to have a controller for DeviceGroup which provides a function to update the targetSnapshotId which takes care of updating the devices and sending the update command. As it is we've got that logic spread through this Pipeline controller. Not blocking, just a thought.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could add a method to DeviceGroup controller but I dont want to be updating devices until the transaction is committed.

// update all devices targetSnapshotId
await app.db.models.Device.update({ targetSnapshotId: sourceSnapshot.id }, { where: { DeviceGroupId: targetDeviceGroup.id }, transaction })
// commit the transaction
Expand Down
26 changes: 26 additions & 0 deletions forge/ee/db/models/PipelineStageDeviceGroup.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,31 @@ module.exports = {
slug: false,
hashid: false,
links: false
},
finders: function (M) {
return {
static: {
byId: async function (idOrHash) {
let id = idOrHash
if (typeof idOrHash === 'string') {
id = M.PipelineStageDeviceGroup.decodeHashid(idOrHash)
}
return this.findOne({
where: { PipelineStageId: id },
include: [
{
model: M.DeviceGroup,
attributes: ['hashid', 'id', 'name', 'description', 'ApplicationId', 'activePipelineStageId']
},
{
model: M.PipelineStage,
attributes: ['hashid', 'id', 'name', 'action', 'NextStageId', 'PipelineId']
},
{ model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'] }
]
})
}
}
}
}
}
50 changes: 39 additions & 11 deletions frontend/src/pages/application/DeviceGroup/devices.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import { mapState } from 'vuex'
import ApplicationApi from '../../../api/application.js'
import FormHeading from '../../../components/FormHeading.vue'
import Alerts from '../../../services/alerts.js'
import Dialog from '../../../services/dialog.js'

import { debounce } from '../../../utils/eventHandling.js'

Expand Down Expand Up @@ -290,17 +291,44 @@ export default {
},
saveChanges () {
const deviceIds = this.localMemberDevices.map((device) => device.id)
ApplicationApi.updateDeviceGroupMembership(this.application.id, this.deviceGroup.id, { set: deviceIds })
.then(() => {
Alerts.emit('Device Group updated.', 'confirmation')
this.hasChanges = false
this.$emit('device-group-members-updated')
this.editMode = false
})
.catch((err) => {
this.$toast.error('Failed to update Device Group')
console.error(err)
})
const devicesRemoved = this.deviceGroup.devices.filter((device) => this.localAvailableDevices.map((d) => d.id).includes(device.id))
const devicesAdded = this.localMemberDevices.filter((device) => !this.deviceGroup.devices.map((d) => d.id).includes(device.id))
const removedCount = devicesRemoved.length
const addedCount = devicesAdded.length
const warning = []
if (addedCount > 0) {
warning.push('1 or more devices will be added to this group. These device(s) will be updated to deploy the active pipeline snapshot.')
warning.push('')
}
if (removedCount > 0) {
warning.push('1 or more devices will be removed from this group. These device(s) will be cleared of any active pipeline snapshot.')
warning.push('')
}
if (addedCount <= 0 && removedCount <= 0) {
return // nothing to do, shouldn't be able to get here as the save button should be disabled. but just in case...
}
warning.push('Do you want to continue?')

const warningMessage = `<p>${warning.join('<br>')}</p>`
Dialog.show({
header: 'Update device group members',
kind: 'danger',
html: warningMessage,
confirmLabel: 'Confirm',
cancelLabel: 'No'
}, async () => {
ApplicationApi.updateDeviceGroupMembership(this.application.id, this.deviceGroup.id, { set: deviceIds })
.then(() => {
Alerts.emit('Device Group updated.', 'confirmation')
this.hasChanges = false
this.$emit('device-group-members-updated')
this.editMode = false
})
.catch((err) => {
this.$toast.error('Failed to update Device Group')
console.error(err)
})
})
}
}
}
Expand Down
Loading