diff --git a/docs/user/device-groups.md b/docs/user/device-groups.md index 9c8196ef68..ee611b11f2 100644 --- a/docs/user/device-groups.md +++ b/docs/user/device-groups.md @@ -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) + This greatly simplifies deployments of the same configuration to one or even hundreds of devices with a single click. The following requirements apply: @@ -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. diff --git a/forge/db/controllers/Device.js b/forge/db/controllers/Device.js index c44fd1aab3..47f3c5e5c6 100644 --- a/forge/db/controllers/Device.js +++ b/forge/db/controllers/Device.js @@ -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 { diff --git a/forge/db/migrations/20240116-01-add-targetSnapshot-to-DeviceGroup.js b/forge/db/migrations/20240116-01-add-targetSnapshot-to-DeviceGroup.js new file mode 100644 index 0000000000..3825bb2937 --- /dev/null +++ b/forge/db/migrations/20240116-01-add-targetSnapshot-to-DeviceGroup.js @@ -0,0 +1,68 @@ +/* eslint-disable no-unused-vars */ +/** + * Add targetSnapshotId to DeviceGroups + * + * -- DDL for table DeviceGroups - before migration + * CREATE TABLE DeviceGroups ( + * id INTEGER PRIMARY KEY AUTOINCREMENT, + * name VARCHAR (255) NOT NULL, + * description TEXT, + * createdAt DATETIME NOT NULL, + * updatedAt DATETIME NOT NULL, + * ApplicationId INTEGER REFERENCES Applications (id) ON DELETE CASCADE + * ON UPDATE CASCADE + * ); + * + * + * -- DDL for table DeviceGroups - after migration + * CREATE TABLE DeviceGroups ( + * id INTEGER PRIMARY KEY AUTOINCREMENT, + * name VARCHAR (255) NOT NULL, + * description TEXT, + * createdAt DATETIME NOT NULL, + * updatedAt DATETIME NOT NULL, + * ApplicationId INTEGER REFERENCES Applications (id) ON DELETE CASCADE + * ON UPDATE CASCADE, + * targetSnapshotId INTEGER REFERENCES ProjectSnapshots (id) ON DELETE SET NULL + * ON UPDATE CASCADE + * ); + * + * + * -- DDL for table DeviceGroups - auto created by sequelize + * CREATE TABLE DeviceGroups ( + * id INTEGER PRIMARY KEY AUTOINCREMENT, + * name VARCHAR (255) NOT NULL, + * description TEXT, + * targetSnapshotId INTEGER REFERENCES ProjectSnapshots (id) ON DELETE SET NULL + * ON UPDATE CASCADE, + * createdAt DATETIME NOT NULL, + * updatedAt DATETIME NOT NULL, + * ApplicationId INTEGER REFERENCES Applications (id) ON DELETE CASCADE + * ON UPDATE CASCADE + * ); + * + */ + +const { Sequelize, QueryInterface } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context QueryInterface + */ + up: async (context) => { + await context.addColumn('DeviceGroups', 'targetSnapshotId', { + type: Sequelize.INTEGER, + references: { + model: 'ProjectSnapshots', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'SET NULL' + }) + }, + + down: async (queryInterface, Sequelize) => { + + } +} diff --git a/forge/db/models/DeviceGroup.js b/forge/db/models/DeviceGroup.js index fc17277bab..39d2254fd3 100644 --- a/forge/db/models/DeviceGroup.js +++ b/forge/db/models/DeviceGroup.js @@ -20,10 +20,12 @@ module.exports = { notNull: nameValidator } }, - description: { type: DataTypes.TEXT } + description: { type: DataTypes.TEXT }, + targetSnapshotId: { type: DataTypes.INTEGER, allowNull: true } }, associations: function (M) { this.belongsTo(M.Application, { onDelete: 'CASCADE' }) + this.belongsTo(M.ProjectSnapshot, { as: 'targetSnapshot' }) this.hasMany(M.Device) }, finders: function (M) { @@ -54,6 +56,11 @@ module.exports = { ApplicationId: literal('"Devices"."ApplicationId" = "Application"."id"') }, required: false + }, + { + model: M.ProjectSnapshot, + as: 'targetSnapshot', + attributes: ['hashid', 'id', 'name'] } ], attributes: { diff --git a/forge/db/models/ProjectSnapshot.js b/forge/db/models/ProjectSnapshot.js index e8b647884d..c49acfe496 100644 --- a/forge/db/models/ProjectSnapshot.js +++ b/forge/db/models/ProjectSnapshot.js @@ -46,6 +46,7 @@ module.exports = { this.belongsTo(M.User) this.hasMany(M.Device, { foreignKey: 'targetSnapshotId' }) this.hasMany(M.Device, { foreignKey: 'activeSnapshotId' }) + this.hasMany(M.DeviceGroup, { foreignKey: 'targetSnapshotId' }) }, finders: function (M) { const self = this diff --git a/forge/ee/db/controllers/DeviceGroup.js b/forge/ee/db/controllers/DeviceGroup.js index 7f0183243d..79fa53a296 100644 --- a/forge/ee/db/controllers/DeviceGroup.js +++ b/forge/ee/db/controllers/DeviceGroup.js @@ -96,18 +96,22 @@ 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 targetSnapshotId = deviceGroup.targetSnapshotId || undefined try { // add devices if (actualAddDevices.length > 0) { - await this.assignDevicesToGroup(app, deviceGroup, actualAddDevices, t) + 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) { @@ -120,52 +124,89 @@ 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) { + 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, 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 `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 {*} 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} activeDeviceGroupTargetSnapshotId If specified, null devices `targetSnapshotId` where it matches */ - removeDevicesFromGroup: async function (app, deviceGroup, deviceList, transaction = null) { + 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 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) } } }, diff --git a/forge/ee/db/controllers/Pipeline.js b/forge/ee/db/controllers/Pipeline.js index 942943326d..bba3ea57d2 100644 --- a/forge/ee/db/controllers/Pipeline.js +++ b/forge/ee/db/controllers/Pipeline.js @@ -432,6 +432,9 @@ module.exports = { // Update the targetSnapshot of the device group await targetDeviceGroup.PipelineStageDeviceGroup.update({ targetSnapshotId: sourceSnapshot.id }, { transaction }) + // Update the targetSnapshotId on the device group + await targetDeviceGroup.update({ targetSnapshotId: sourceSnapshot.id }, { transaction }) + // update all devices targetSnapshotId await app.db.models.Device.update({ targetSnapshotId: sourceSnapshot.id }, { where: { DeviceGroupId: targetDeviceGroup.id }, transaction }) // commit the transaction diff --git a/forge/ee/db/models/PipelineStageDeviceGroup.js b/forge/ee/db/models/PipelineStageDeviceGroup.js index 2d22851bba..e09de12cb6 100644 --- a/forge/ee/db/models/PipelineStageDeviceGroup.js +++ b/forge/ee/db/models/PipelineStageDeviceGroup.js @@ -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'] + }, + { + model: M.PipelineStage, + attributes: ['hashid', 'id', 'name', 'action', 'NextStageId', 'PipelineId'] + }, + { model: M.ProjectSnapshot, as: 'targetSnapshot', attributes: ['id', 'hashid', 'name'] } + ] + }) + } + } + } } } diff --git a/frontend/src/components/pipelines/Stage.vue b/frontend/src/components/pipelines/Stage.vue index afeb77da62..9cd35429c4 100644 --- a/frontend/src/components/pipelines/Stage.vue +++ b/frontend/src/components/pipelines/Stage.vue @@ -107,10 +107,10 @@
-
+
diff --git a/frontend/src/pages/application/DeviceGroup/devices.vue b/frontend/src/pages/application/DeviceGroup/devices.vue index 1ca14f1ae7..a63298bdd7 100644 --- a/frontend/src/pages/application/DeviceGroup/devices.vue +++ b/frontend/src/pages/application/DeviceGroup/devices.vue @@ -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' @@ -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 = `

${warning.join('
')}

` + 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) => { + Alerts.emit('Failed to update Device Group: ' + err.toString(), 'warning', 7500) + console.error(err) + }) + }) } } } diff --git a/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js b/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js index bb240f0361..99551e77af 100644 --- a/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js +++ b/test/unit/forge/ee/routes/api/applicationDeviceGroups_spec.js @@ -1,4 +1,5 @@ const should = require('should') // eslint-disable-line +const sinon = require('sinon') const setup = require('../../setup.js') @@ -19,7 +20,10 @@ describe('Application Device Groups API', function () { ATeam: {}, BTeam: {}, /** B-team Application */ - application: {} + application: {}, + /** B-team Instance */ + instance: {} + } /** @type {import('../../../../../lib/TestModelFactory')} */ let factory = null @@ -61,6 +65,7 @@ describe('Application Device Groups API', function () { description: 'B-team Application description', TeamId: TestObjects.BTeam.id }) + TestObjects.instance = await app.factory.createInstance({ name: 'B-team-instance' }, TestObjects.application, app.stack, app.template, app.projectType, { start: false }) }) async function login (username, password) { @@ -665,5 +670,134 @@ describe('Application Device Groups API', function () { response.statusCode.should.be.oneOf([400, 403, 404]) }) + describe('Update', async function () { + const checkCallArgs = (calls, device) => { + const args = calls.find(e => e.args[1] === device.hashid).args + args[0].should.equal(device.Team.hashid) + args[1].should.equal(device.hashid) + args[2].should.equal('update') + const payload = args[3] + payload.should.have.property('ownerType', 'application') + payload.should.have.property('application', device.Application.hashid) + payload.should.have.property('snapshot') + payload.should.have.property('settings') + payload.should.have.property('mode', 'autonomous') + payload.should.have.property('licensed') + } + beforeEach(async function () { + // mock the `app.comms.devices.sendCommand()` + sinon.stub(app.comms.devices, 'sendCommand').resolves() + }) + + afterEach(async function () { + app.comms.devices.sendCommand.restore() + await app.db.models.PipelineStage.destroy({ where: {} }) + await app.db.models.Pipeline.destroy({ where: {} }) + await app.db.models.ProjectSnapshot.destroy({ where: {} }) + }) + + it('Add and Remove Devices get an update request', async function () { + // Premise: + // Create 4 devices, 1 is unused, 2 of them to be added to the group initially, 1 is added and another is removed + // Check the 3 devices involved get an update request + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const deviceGroup = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + const device1of3 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2of3 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device3of3 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + // eslint-disable-next-line no-unused-vars + const nonGroupedDevice = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + + // add 2 of the devices to the group + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroup, { addDevices: [device1of3.id, device2of3.id] }) + + // reset the mock call history + app.comms.devices.sendCommand.resetHistory() + + // now add 1 and remove 1 + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroup.hashid}`, + cookies: { sid }, + payload: { + remove: [device1of3.hashid], + add: [device3of3.hashid] + } + }) + + // should succeed + response.statusCode.should.equal(200) + + // check 3 devices got an update request + app.comms.devices.sendCommand.callCount.should.equal(3) + const devices = await app.db.models.Device.getAll({}, { id: [device1of3.id, device2of3.id, device3of3.id] }) + const d1 = devices.devices.find(d => d.id === device1of3.id) + const d2 = devices.devices.find(d => d.id === device2of3.id) + const d3 = devices.devices.find(d => d.id === device3of3.id) + const calls = app.comms.devices.sendCommand.getCalls() + checkCallArgs(calls, d1) + checkCallArgs(calls, d2) + checkCallArgs(calls, d3) + }) + it('All Devices removed get an update request and clears DeviceGroup.targetSnapshotId', async function () { + // Premise: + // Create 2 devices in a group, add group to a pipeline and deploy a snapshot + // (direct db ops: set the targetSnapshotId on the group and the targetSnapshotId on the device group pipeline stage) + // Test the API by removing both devices leaving the group empty + // Check the 2 devices involved get an update request + // Check the DeviceGroup `targetSnapshotId` is cleared (this is cleared because the group is empty) + const sid = await login('bob', 'bbPassword') + const application = TestObjects.application // BTeam application + const instance = TestObjects.instance + const deviceGroupOne = await factory.createApplicationDeviceGroup({ name: generateName('device-group') }, application) + + const device1of2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + const device2of2 = await factory.createDevice({ name: generateName('device') }, TestObjects.BTeam, null, application) + + // add the devices to the group + await app.db.controllers.DeviceGroup.updateDeviceGroupMembership(deviceGroupOne, { addDevices: [device1of2.id, device2of2.id] }) + + // create a pipeline and snapshot + const pipelineDeviceGroups = await factory.createPipeline({ name: 'new-pipeline-device-groups' }, TestObjects.application) + const pipelineDeviceGroupsStageOne = await factory.createPipelineStage({ name: 'stage-one-instance', instanceId: instance.id, action: 'use_latest_snapshot' }, pipelineDeviceGroups) + const pipelineDeviceGroupsStageTwo = await factory.createPipelineStage({ name: 'stage-two-device-group', deviceGroupId: deviceGroupOne.id, source: pipelineDeviceGroupsStageOne.hashid, action: 'use_latest_snapshot' }, pipelineDeviceGroups) + const snapshot = await factory.createSnapshot({ name: generateName('snapshot') }, instance, TestObjects.bob) + + // deploy the snapshot to the group + deviceGroupOne.set('targetSnapshotId', snapshot.id) + await deviceGroupOne.save() + await app.db.models.PipelineStageDeviceGroup.update({ targetSnapshotId: snapshot.id }, { where: { PipelineStageId: pipelineDeviceGroupsStageTwo.id } }) + + // reset the mock call history + app.comms.devices.sendCommand.resetHistory() + + // now remove both + const response = await app.inject({ + method: 'PATCH', + url: `/api/v1/applications/${application.hashid}/device-groups/${deviceGroupOne.hashid}`, + cookies: { sid }, + payload: { + remove: [device1of2.hashid, device2of2.hashid] + } + }) + + // should succeed + response.statusCode.should.equal(200) + + // check both devices got an update request + app.comms.devices.sendCommand.callCount.should.equal(2) + const devices = await app.db.models.Device.getAll({}, { id: [device1of2.id, device2of2.id] }) + const d1 = devices.devices.find(d => d.id === device1of2.id) + const d2 = devices.devices.find(d => d.id === device2of2.id) + const calls = app.comms.devices.sendCommand.getCalls() + checkCallArgs(calls, d1) + checkCallArgs(calls, d2) + + // check the DeviceGroup `targetSnapshotId` is cleared + const updatedDeviceGroup = await app.db.models.DeviceGroup.byId(deviceGroupOne.hashid) + updatedDeviceGroup.should.have.property('targetSnapshotId', null) + }) + }) }) }) diff --git a/test/unit/forge/ee/routes/api/pipeline_spec.js b/test/unit/forge/ee/routes/api/pipeline_spec.js index 1fc23ce47a..113129bd97 100644 --- a/test/unit/forge/ee/routes/api/pipeline_spec.js +++ b/test/unit/forge/ee/routes/api/pipeline_spec.js @@ -2256,7 +2256,7 @@ describe('Pipelines API', function () { // this way we can test that the group and a device within it are all updated await TestObjects.deviceGroupTwo.addDevice(TestObjects.deviceTwo) - await createSnapshot(app, TestObjects.instanceOne, TestObjects.user, { + const snapshot = await createSnapshot(app, TestObjects.instanceOne, TestObjects.user, { name: 'Existing Snapshot Created In Test', description: 'This was the second snapshot created as part of the test process', setAsTarget: false // no need to deploy to devices of the source @@ -2285,6 +2285,12 @@ describe('Pipelines API', function () { deviceGroups[0].should.have.property('isDeploying', true) deviceGroups[0].should.have.property('hasTargetSnapshot', true) deviceGroups[0].should.have.property('targetMatchCount', 1) + const deviceData = await app.db.models.Device.getAll({}, { id: TestObjects.deviceTwo.id }) + const device = deviceData.devices[0] + const deviceGroupData = await app.db.models.DeviceGroup.getAll({}, { id: TestObjects.deviceGroupTwo.id }) + const deviceGroup = deviceGroupData.groups[0] + device.should.have.property('targetSnapshotId', snapshot.id) + deviceGroup.should.have.property('targetSnapshotId', snapshot.id) }) })